feat: 自定义主角称呼(profile.name + {name} 占位符 + 自报句式预填 + 报告/分享卡展示)
This commit is contained in:
@@ -48,7 +48,7 @@
|
|||||||
"id": "c1-01",
|
"id": "c1-01",
|
||||||
"type": "identity",
|
"type": "identity",
|
||||||
"pointId": "C1-01",
|
"pointId": "C1-01",
|
||||||
"prompt": "登记界面浮现,五项字段以档案卡形式呈现:城市、年龄、性别、职业、学历。用一两句话介绍自己,想到哪说到哪,没有格式——不是审查,是锚定。(性别一栏可以选「不便透露」——无妨,锚不必每个刻度都示人。)",
|
"prompt": "登记界面浮现。五项字段以档案卡形式呈现:城市、年龄、性别、职业、学历。再留一个称呼——你希望路上的人怎么喊你。用一两句话介绍自己,想到哪说到哪,没有格式——不是审查,是锚定。(性别一栏可以选「不便透露」——无妨,锚不必每个刻度都示人。)",
|
||||||
"dimensions": [],
|
"dimensions": [],
|
||||||
"next": "c1-d2"
|
"next": "c1-d2"
|
||||||
},
|
},
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
"id": "c1-d2",
|
"id": "c1-d2",
|
||||||
"type": "dialogue",
|
"type": "dialogue",
|
||||||
"speaker": "馆长",
|
"speaker": "馆长",
|
||||||
"text": "好了。从现在起,档案编号 C-2049 就是你。这是你的第一枚锚。",
|
"text": "好了。从现在起,档案编号 C-2049 就是你——这是你的第一枚锚。编号是档案馆给的;称呼是你自己的。{name}——路上的人这么喊你的时候,记得应一声。",
|
||||||
"sprite": "curator",
|
"sprite": "curator",
|
||||||
"next": "c1-d3"
|
"next": "c1-d3"
|
||||||
},
|
},
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
"id": "c1-d5",
|
"id": "c1-d5",
|
||||||
"type": "dialogue",
|
"type": "dialogue",
|
||||||
"speaker": "阿棠",
|
"speaker": "阿棠",
|
||||||
"text": "(集结甲板,她第一个冲过来)来了来了!第五个人!哎你就是那个研究苍梧七年的?那你得给我们讲讲,那城底下到底是不是真的有——",
|
"text": "(集结甲板,她第一个冲过来)来了来了!第五个人!{name},对吧?哎你就是那个研究苍梧七年的?那你得给我们讲讲,那城底下到底是不是真的有——",
|
||||||
"sprite": "atang",
|
"sprite": "atang",
|
||||||
"next": "c1-d5a"
|
"next": "c1-d5a"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -328,7 +328,7 @@
|
|||||||
"id": "c3-d5b",
|
"id": "c3-d5b",
|
||||||
"type": "dialogue",
|
"type": "dialogue",
|
||||||
"speaker": "阿棠",
|
"speaker": "阿棠",
|
||||||
"text": "(打断)规程哥,我腿这样,背绳子就是背个拖累。(看你,收起嬉皮笑脸,这是她全章第一次没拿腔拿调)我这个人,认死理:绳子交给谁,命就交给谁。我不想交给规程,我想交给一个人。",
|
"text": "(打断)规程哥,我腿这样,背绳子就是背个拖累。(看你,收起嬉皮笑脸,这是她全章第一次没拿腔拿调){name}。我这个人,认死理:绳子交给谁,命就交给谁。我不想交给规程,我想交给一个人。",
|
||||||
"sprite": "atang",
|
"sprite": "atang",
|
||||||
"next": "c3-d5c"
|
"next": "c3-d5c"
|
||||||
},
|
},
|
||||||
|
|||||||
+38
-6
@@ -418,7 +418,7 @@ async function walkAll(eng: StoryEngine, picker: Record<string, string> = {}) {
|
|||||||
`推进后持久化当前位置(${snap?.chapterId}/${snap?.nodeId})`,
|
`推进后持久化当前位置(${snap?.chapterId}/${snap?.nodeId})`,
|
||||||
);
|
);
|
||||||
const eng2 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
|
const eng2 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
|
||||||
const restored = eng2.restore(progress.load());
|
const restored = await eng2.restore(progress.load());
|
||||||
check(
|
check(
|
||||||
restored && eng2.currentNode().id === 'c1-d5' && eng2.state.sessionId === snap?.sessionId,
|
restored && eng2.currentNode().id === 'c1-d5' && eng2.state.sessionId === snap?.sessionId,
|
||||||
'重建引擎从持久化位置恢复(sessionId 一致,事件轨迹续接)',
|
'重建引擎从持久化位置恢复(sessionId 一致,事件轨迹续接)',
|
||||||
@@ -427,11 +427,11 @@ async function walkAll(eng: StoryEngine, picker: Record<string, string> = {}) {
|
|||||||
// 内容更新导致持久化节点消失 → 降级该章开头
|
// 内容更新导致持久化节点消失 → 降级该章开头
|
||||||
const eng3 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
|
const eng3 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
|
||||||
check(
|
check(
|
||||||
eng3.restore({ sessionId: 'ghost', chapterId: 'chapter1', nodeId: 'deleted-node', flags: {} }) &&
|
(await eng3.restore({ sessionId: 'ghost', chapterId: 'chapter1', nodeId: 'deleted-node', flags: {} })) &&
|
||||||
eng3.currentNode().id === chapters[0].startNodeId,
|
eng3.currentNode().id === chapters[0].startNodeId,
|
||||||
'持久化节点已不存在 → 降级到该章开头(不崩溃)',
|
'持久化节点已不存在 → 降级到该章开头(不崩溃)',
|
||||||
);
|
);
|
||||||
check(!eng3.restore({ sessionId: 'ghost', chapterId: 'chapter99', nodeId: 'x', flags: {} }), '持久化章节不存在 → 不恢复');
|
check(!(await eng3.restore({ sessionId: 'ghost', chapterId: 'chapter99', nodeId: 'x', flags: {} })), '持久化章节不存在 → 不恢复');
|
||||||
// replay:新 sessionId + 持久化位置重置(条件不满足的开场节点会被跳过,落点是跳过后的首个可见节点)
|
// replay:新 sessionId + 持久化位置重置(条件不满足的开场节点会被跳过,落点是跳过后的首个可见节点)
|
||||||
eng2.replay('chapter2');
|
eng2.replay('chapter2');
|
||||||
const after = progress.load();
|
const after = progress.load();
|
||||||
@@ -461,7 +461,7 @@ class FailingJev extends MockJevClient {
|
|||||||
const silentVariant = c102.variants.find((v) => v.signature.includes('沉默'))!;
|
const silentVariant = c102.variants.find((v) => v.signature.includes('沉默'))!;
|
||||||
const ev = s.events.find((e) => e.kind === 'narration' && e.pointId === 'C1-02');
|
const ev = s.events.find((e) => e.kind === 'narration' && e.pointId === 'C1-02');
|
||||||
check(ev?.variantId === silentVariant.variantId, 'Jev 故障:情绪签名解析降级到沉默/通用档变体');
|
check(ev?.variantId === silentVariant.variantId, 'Jev 故障:情绪签名解析降级到沉默/通用档变体');
|
||||||
// 12b. 身份抽取失败 → 全空字段(UI 直接进可手填确认页),不抛出
|
// 12b. 身份抽取失败 → 硬过滤字段全空(称呼走本地启发式仍可预填),UI 直接进可手填确认页,不抛出
|
||||||
const eng2 = new StoryEngine(chapters, new MemoryStore(), new FailingJev());
|
const eng2 = new StoryEngine(chapters, new MemoryStore(), new FailingJev());
|
||||||
await walkTo(eng2, 'c1-01');
|
await walkTo(eng2, 'c1-01');
|
||||||
let profile: IdentityProfile | null = null;
|
let profile: IdentityProfile | null = null;
|
||||||
@@ -472,13 +472,45 @@ class FailingJev extends MockJevClient {
|
|||||||
threw = true;
|
threw = true;
|
||||||
}
|
}
|
||||||
check(
|
check(
|
||||||
!threw && profile !== null && Object.values(profile).every((v) => v === ''),
|
!threw &&
|
||||||
'Jev 故障:身份抽取降级为全空字段进手填确认页(不抛出)',
|
profile !== null &&
|
||||||
|
profile.city === '' &&
|
||||||
|
profile.age === '' &&
|
||||||
|
profile.gender === '' &&
|
||||||
|
profile.occupation === '' &&
|
||||||
|
profile.education === '',
|
||||||
|
'Jev 故障:身份抽取降级为空字段进手填确认页(不抛出)',
|
||||||
);
|
);
|
||||||
await eng2.confirmIdentity(profile!);
|
await eng2.confirmIdentity(profile!);
|
||||||
check(eng2.state.nodeId === 'c1-d2', 'Jev 故障:手填确认后流程照常推进');
|
check(eng2.state.nodeId === 'c1-d2', 'Jev 故障:手填确认后流程照常推进');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 13. 玩家称呼:{name} 占位符替换、预填启发式、恢复后带回 ---
|
||||||
|
{
|
||||||
|
// 13a. 启发式:只认"我叫/叫我/称呼我 X",其余句式不瞎猜
|
||||||
|
const s = new MemoryStore();
|
||||||
|
const eng = new StoryEngine(chapters, s, new MockJevClient());
|
||||||
|
await walkTo(eng, 'c1-01');
|
||||||
|
const p1 = await eng.extractIdentity('我叫林晚,29岁,在杭州做互联网产品经理,硕士学历');
|
||||||
|
check(p1.name === '林晚', `称呼预填:「我叫林晚」→ "${p1.name}"`);
|
||||||
|
const p2 = await eng.extractIdentity('29岁,杭州,产品经理');
|
||||||
|
check(p2.name === '', '称呼预填:无自报句式 → 留空手填(不硬抽)');
|
||||||
|
// 13b. {name} 替换与回退
|
||||||
|
await eng.confirmIdentity(p1);
|
||||||
|
check(eng.renderText('馆长:{name},登记完成。') === '馆长:林晚,登记完成。', '{name} 替换为玩家称呼');
|
||||||
|
const eng2 = new StoryEngine(chapters, new MemoryStore(), new MockJevClient());
|
||||||
|
check(eng2.renderText('馆长:{name},登记完成。') === '馆长:你,登记完成。', '未设置称呼时 {name} 回退「你」');
|
||||||
|
// 13c. 称呼随轨迹事件走:restore 后从该 session 的 profile 事件取回(旧事件缺 name 不崩溃)
|
||||||
|
await walkTo(eng, 'c1-d5');
|
||||||
|
const d2 = chapters[0].nodes.find((n) => n.id === 'c1-d2');
|
||||||
|
check(d2?.type === 'dialogue' && eng.renderText(d2.text).includes('林晚'), '馆长登记台词中的 {name} 渲染为称呼');
|
||||||
|
const progress = new MemoryProgressStore();
|
||||||
|
progress.save({ sessionId: eng.state.sessionId, chapterId: 'chapter1', nodeId: 'c1-d5', flags: { ...eng.state.flags } });
|
||||||
|
const eng3 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
|
||||||
|
await eng3.restore(progress.load());
|
||||||
|
check(eng3.state.playerName === '林晚', '断点恢复后从 profile 事件取回称呼');
|
||||||
|
}
|
||||||
|
|
||||||
if (failures > 0) {
|
if (failures > 0) {
|
||||||
console.error(`\n${failures} 项校验失败`);
|
console.error(`\n${failures} 项校验失败`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
+4
-3
@@ -29,11 +29,12 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void engine.hasConsent().then((ok) => {
|
void (async () => {
|
||||||
|
const ok = await engine.hasConsent();
|
||||||
// 同意门之后才恢复剧情进度(刷新/热更新前的断点)
|
// 同意门之后才恢复剧情进度(刷新/热更新前的断点)
|
||||||
if (ok) engine.restore(progress.load());
|
if (ok) await engine.restore(progress.load());
|
||||||
setConsent(ok ? 'granted' : 'needed');
|
setConsent(ok ? 'granted' : 'needed');
|
||||||
});
|
})();
|
||||||
}, [engine, progress]);
|
}, [engine, progress]);
|
||||||
|
|
||||||
if (consent !== 'granted') {
|
if (consent !== 'granted') {
|
||||||
|
|||||||
+32
-5
@@ -20,6 +20,8 @@ export interface EngineState {
|
|||||||
pendingFollowUp: PendingFollowUp | null;
|
pendingFollowUp: PendingFollowUp | null;
|
||||||
/** 分支变量(附录 A):从本轨迹的选择/讲述事件增量推导,驱动条件文本 */
|
/** 分支变量(附录 A):从本轨迹的选择/讲述事件增量推导,驱动条件文本 */
|
||||||
flags: Flags;
|
flags: Flags;
|
||||||
|
/** 玩家自取称呼(C1-01 profile.name),驱动文案里的 {name} 占位符;未设置时回退"你" */
|
||||||
|
playerName: string;
|
||||||
/** 全部三章完成后为 true → 进入画像报告 */
|
/** 全部三章完成后为 true → 进入画像报告 */
|
||||||
finished: boolean;
|
finished: boolean;
|
||||||
}
|
}
|
||||||
@@ -60,6 +62,15 @@ function newSessionId(): string {
|
|||||||
: `s-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
: `s-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 称呼预填的轻量本地启发式(故意不走 Jev:typed 模型抽不出任意字符串名字)。
|
||||||
|
* 只认"我叫X / 叫我X / 称呼我X"三种自报句式,取不到就留空给用户手填。
|
||||||
|
*/
|
||||||
|
function guessName(transcript: string): string {
|
||||||
|
const m = transcript.match(/(?:我叫|叫我|称呼我)([一-龥A-Za-z][一-龥A-Za-z·]{0,11})/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 剧情引擎:状态机。当前章节/节点、按选择推进、事件落 EventStore。
|
* 剧情引擎:状态机。当前章节/节点、按选择推进、事件落 EventStore。
|
||||||
* 与 UI 完全解耦——UI 只读 state、调 advance/choose/submitNarration。
|
* 与 UI 完全解耦——UI 只读 state、调 advance/choose/submitNarration。
|
||||||
@@ -89,6 +100,7 @@ export class StoryEngine {
|
|||||||
activeVariant: null,
|
activeVariant: null,
|
||||||
pendingFollowUp: null,
|
pendingFollowUp: null,
|
||||||
flags: {},
|
flags: {},
|
||||||
|
playerName: '',
|
||||||
finished: false,
|
finished: false,
|
||||||
};
|
};
|
||||||
this.skipHiddenNodes();
|
this.skipHiddenNodes();
|
||||||
@@ -139,8 +151,9 @@ export class StoryEngine {
|
|||||||
/**
|
/**
|
||||||
* 从持久化位置恢复。持久化的 nodeId 在新版章节数据里已不存在时,
|
* 从持久化位置恢复。持久化的 nodeId 在新版章节数据里已不存在时,
|
||||||
* 降级到该章开头而不是崩溃;章节本身不存在则不恢复(返回 false)。
|
* 降级到该章开头而不是崩溃;章节本身不存在则不恢复(返回 false)。
|
||||||
|
* 恢复同时从该轨迹的 profile 事件里取回玩家称呼(旧数据缺 name 字段时回退"你")。
|
||||||
*/
|
*/
|
||||||
restore(snapshot: ProgressSnapshot | null): boolean {
|
async restore(snapshot: ProgressSnapshot | null): Promise<boolean> {
|
||||||
if (!snapshot) return false;
|
if (!snapshot) return false;
|
||||||
const chapter = this.chapters.find((c) => c.id === snapshot.chapterId);
|
const chapter = this.chapters.find((c) => c.id === snapshot.chapterId);
|
||||||
if (!chapter) return false;
|
if (!chapter) return false;
|
||||||
@@ -152,6 +165,9 @@ export class StoryEngine {
|
|||||||
this.state.activeVariant = null;
|
this.state.activeVariant = null;
|
||||||
this.state.pendingFollowUp = null;
|
this.state.pendingFollowUp = null;
|
||||||
this.state.finished = false;
|
this.state.finished = false;
|
||||||
|
const events = await this.store.list(snapshot.sessionId);
|
||||||
|
const profile = [...events].reverse().find((e) => e.kind === 'profile')?.profile;
|
||||||
|
this.state.playerName = profile?.name ?? '';
|
||||||
this.skipHiddenNodes();
|
this.skipHiddenNodes();
|
||||||
this.persist();
|
this.persist();
|
||||||
return true;
|
return true;
|
||||||
@@ -173,10 +189,12 @@ export class StoryEngine {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染文本:求值文案中的全部【若 …】条件标记(节点级 + 内联段),
|
* 渲染文本:求值文案中的全部【若 …】条件标记(节点级 + 内联段),
|
||||||
* 返回玩家可见文本。条件标记本身永不渲染给玩家。
|
* 并把 {name} 占位符替换为玩家称呼(未设置时回退"你")。
|
||||||
|
* 条件标记与占位符本身永不渲染给玩家。
|
||||||
*/
|
*/
|
||||||
renderText(text: string): string {
|
renderText(text: string): string {
|
||||||
return resolveConditionalText(text, this.state.flags) ?? '';
|
const resolved = resolveConditionalText(text, this.state.flags) ?? '';
|
||||||
|
return resolved.replace(/\{name\}/g, this.state.playerName || '你');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** B 类选择:落事件并按选项跳转 */
|
/** B 类选择:落事件并按选项跳转 */
|
||||||
@@ -427,7 +445,7 @@ export class StoryEngine {
|
|||||||
answers = await this.jev.ask({ transcript }, questions);
|
answers = await this.jev.ask({ transcript }, questions);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[engine] 身份字段抽取失败,降级为手填确认页', e);
|
console.warn('[engine] 身份字段抽取失败,降级为手填确认页', e);
|
||||||
return { city: '', age: '', gender: '', occupation: '', education: '' };
|
return { name: guessName(transcript), city: '', age: '', gender: '', occupation: '', education: '' };
|
||||||
}
|
}
|
||||||
const choiceVal = (id: string): string => {
|
const choiceVal = (id: string): string => {
|
||||||
const a = answers[id];
|
const a = answers[id];
|
||||||
@@ -443,7 +461,14 @@ export class StoryEngine {
|
|||||||
let city = choiceVal('city');
|
let city = choiceVal('city');
|
||||||
const mentioned = answers.city_mentioned;
|
const mentioned = answers.city_mentioned;
|
||||||
if (mentioned?.type === 'noul' && mentioned.probability < 0.5) city = '';
|
if (mentioned?.type === 'noul' && mentioned.probability < 0.5) city = '';
|
||||||
return { city, age, gender: choiceVal('gender'), occupation: choiceVal('occupation'), education: choiceVal('education') };
|
return {
|
||||||
|
name: guessName(transcript),
|
||||||
|
city,
|
||||||
|
age,
|
||||||
|
gender: choiceVal('gender'),
|
||||||
|
occupation: choiceVal('occupation'),
|
||||||
|
education: choiceVal('education'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** C1-01 确认页通过后调用:append profile 事件并推进 */
|
/** C1-01 确认页通过后调用:append profile 事件并推进 */
|
||||||
@@ -461,6 +486,7 @@ export class StoryEngine {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
this.state.nodeId = node.next;
|
this.state.nodeId = node.next;
|
||||||
|
this.state.playerName = profile.name.trim();
|
||||||
this.skipHiddenNodes();
|
this.skipHiddenNodes();
|
||||||
this.persist();
|
this.persist();
|
||||||
}
|
}
|
||||||
@@ -501,6 +527,7 @@ export class StoryEngine {
|
|||||||
this.state.activeVariant = null;
|
this.state.activeVariant = null;
|
||||||
this.state.pendingFollowUp = null;
|
this.state.pendingFollowUp = null;
|
||||||
this.state.flags = {};
|
this.state.flags = {};
|
||||||
|
this.state.playerName = '';
|
||||||
this.state.finished = false;
|
this.state.finished = false;
|
||||||
this.skipHiddenNodes();
|
this.skipHiddenNodes();
|
||||||
this.persist();
|
this.persist();
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ export interface ChapterEndNode extends BaseNode {
|
|||||||
|
|
||||||
/** 身份登记字段(C1-01):匹配硬过滤字段(ADR-0003),用户有权直接修改确认 */
|
/** 身份登记字段(C1-01):匹配硬过滤字段(ADR-0003),用户有权直接修改确认 */
|
||||||
export interface IdentityProfile {
|
export interface IdentityProfile {
|
||||||
|
/** 称呼:玩家自取的名字(NPC 在路上喊玩家用)。非硬过滤字段,同为用户可直接修改的 profile 数据 */
|
||||||
|
name: string;
|
||||||
city: string;
|
city: string;
|
||||||
age: string;
|
age: string;
|
||||||
gender: string;
|
gender: string;
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ function NarrationView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const IDENTITY_FIELDS: { key: keyof IdentityProfile; label: string; placeholder: string }[] = [
|
const IDENTITY_FIELDS: { key: keyof IdentityProfile; label: string; placeholder: string }[] = [
|
||||||
|
{ key: 'name', label: '称呼', placeholder: '你希望路上的人怎么喊你' },
|
||||||
{ key: 'city', label: '城市', placeholder: '如:杭州' },
|
{ key: 'city', label: '城市', placeholder: '如:杭州' },
|
||||||
{ key: 'age', label: '年龄段', placeholder: '如:28-32' },
|
{ key: 'age', label: '年龄段', placeholder: '如:28-32' },
|
||||||
{ key: 'gender', label: '性别', placeholder: '男 / 女 / 其他 / 不便透露' },
|
{ key: 'gender', label: '性别', placeholder: '男 / 女 / 其他 / 不便透露' },
|
||||||
@@ -253,7 +254,7 @@ function IdentityView({ node, engine, onDone }: { node: IdentityNode; engine: St
|
|||||||
className="secondary"
|
className="secondary"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setProfile({ city: '', age: '', gender: '', occupation: '', education: '' });
|
setProfile({ name: '', city: '', age: '', gender: '', occupation: '', education: '' });
|
||||||
setPhase('confirm');
|
setPhase('confirm');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -43,11 +43,15 @@ export function ReportPage({ store, jev, onReplay }: Props) {
|
|||||||
const [scores, setScores] = useState<DimensionScore[] | null>(null);
|
const [scores, setScores] = useState<DimensionScore[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [retry, setRetry] = useState(0);
|
const [retry, setRetry] = useState(0);
|
||||||
|
const [playerName, setPlayerName] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const events = await store.list();
|
const events = await store.list();
|
||||||
|
// 最近一次身份登记的称呼(旧数据缺 name 字段时回退空串 → 只显示编号)
|
||||||
|
const name = [...events].reverse().find((e) => e.kind === 'profile')?.profile?.name ?? '';
|
||||||
|
if (!cancelled) setPlayerName(name);
|
||||||
const state = buildState(events);
|
const state = buildState(events);
|
||||||
// 7 维 score 问题,criteria 为 10 级有序数组(索引 0-9 对应 1-10 级)
|
// 7 维 score 问题,criteria 为 10 级有序数组(索引 0-9 对应 1-10 级)
|
||||||
const criteria = Array.from({ length: 10 }, (_, i) => `${i + 1} 级`);
|
const criteria = Array.from({ length: 10 }, (_, i) => `${i + 1} 级`);
|
||||||
@@ -120,6 +124,7 @@ export function ReportPage({ store, jev, onReplay }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="report">
|
<div className="report">
|
||||||
<h1>人格画像报告 · Act 1</h1>
|
<h1>人格画像报告 · Act 1</h1>
|
||||||
|
<p className="hint">C-2049{playerName ? `「${playerName}」` : ''}的档案</p>
|
||||||
<section className="badge-banner">
|
<section className="badge-banner">
|
||||||
<div className="badge-medal">勋</div>
|
<div className="badge-medal">勋</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -143,7 +148,7 @@ export function ReportPage({ store, jev, onReplay }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
<ShareCard badgeDimension={top.id} scores={scores} />
|
<ShareCard badgeDimension={top.id} scores={scores} playerName={playerName} />
|
||||||
<button className="secondary" onClick={onReplay}>
|
<button className="secondary" onClick={onReplay}>
|
||||||
重玩 Act 1(新轨迹,历史保留)
|
重玩 Act 1(新轨迹,历史保留)
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ import type { DimensionScore } from './ReportPage';
|
|||||||
interface Props {
|
interface Props {
|
||||||
badgeDimension: string;
|
badgeDimension: string;
|
||||||
scores: DimensionScore[];
|
scores: DimensionScore[];
|
||||||
|
/** 玩家自取称呼(C1-01 profile.name),空串时只显示编号 */
|
||||||
|
playerName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分享卡:canvas 生成 PNG 下载(勋章 + 7 维分数) */
|
/** 分享卡:canvas 生成 PNG 下载(勋章 + 7 维分数) */
|
||||||
export function ShareCard({ badgeDimension, scores }: Props) {
|
export function ShareCard({ badgeDimension, scores, playerName }: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
draw();
|
draw();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [scores, badgeDimension]);
|
}, [scores, badgeDimension, playerName]);
|
||||||
|
|
||||||
function draw(): HTMLCanvasElement {
|
function draw(): HTMLCanvasElement {
|
||||||
const canvas = canvasRef.current!;
|
const canvas = canvasRef.current!;
|
||||||
@@ -38,6 +40,8 @@ export function ShareCard({ badgeDimension, scores }: Props) {
|
|||||||
ctx.fillStyle = '#c9b48a';
|
ctx.fillStyle = '#c9b48a';
|
||||||
ctx.font = '28px serif';
|
ctx.font = '28px serif';
|
||||||
ctx.fillText('苍梧疑冢 · 人格画像', W / 2, 80);
|
ctx.fillText('苍梧疑冢 · 人格画像', W / 2, 80);
|
||||||
|
ctx.font = '22px serif';
|
||||||
|
ctx.fillText(`C-2049${playerName ? `「${playerName}」` : ''}的档案`, W / 2, 118);
|
||||||
|
|
||||||
// 勋章圆
|
// 勋章圆
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
|
|||||||
Reference in New Issue
Block a user