feat: 自定义主角称呼(profile.name + {name} 占位符 + 自报句式预填 + 报告/分享卡展示)

This commit is contained in:
Jayway Liu
2026-10-03 22:13:31 +08:00
parent 316cdfaffe
commit 512651b973
9 changed files with 94 additions and 22 deletions
+32 -5
View File
@@ -20,6 +20,8 @@ export interface EngineState {
pendingFollowUp: PendingFollowUp | null;
/** 分支变量(附录 A):从本轨迹的选择/讲述事件增量推导,驱动条件文本 */
flags: Flags;
/** 玩家自取称呼(C1-01 profile.name),驱动文案里的 {name} 占位符;未设置时回退"你" */
playerName: string;
/** 全部三章完成后为 true → 进入画像报告 */
finished: boolean;
}
@@ -60,6 +62,15 @@ function newSessionId(): string {
: `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。
* 与 UI 完全解耦——UI 只读 state、调 advance/choose/submitNarration。
@@ -89,6 +100,7 @@ export class StoryEngine {
activeVariant: null,
pendingFollowUp: null,
flags: {},
playerName: '',
finished: false,
};
this.skipHiddenNodes();
@@ -139,8 +151,9 @@ export class StoryEngine {
/**
* 从持久化位置恢复。持久化的 nodeId 在新版章节数据里已不存在时,
* 降级到该章开头而不是崩溃;章节本身不存在则不恢复(返回 false)。
* 恢复同时从该轨迹的 profile 事件里取回玩家称呼(旧数据缺 name 字段时回退"你")。
*/
restore(snapshot: ProgressSnapshot | null): boolean {
async restore(snapshot: ProgressSnapshot | null): Promise<boolean> {
if (!snapshot) return false;
const chapter = this.chapters.find((c) => c.id === snapshot.chapterId);
if (!chapter) return false;
@@ -152,6 +165,9 @@ export class StoryEngine {
this.state.activeVariant = null;
this.state.pendingFollowUp = null;
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.persist();
return true;
@@ -173,10 +189,12 @@ export class StoryEngine {
/**
* 渲染文本:求值文案中的全部【若 …】条件标记(节点级 + 内联段),
* 返回玩家可见文本。条件标记本身永不渲染给玩家。
* 并把 {name} 占位符替换为玩家称呼(未设置时回退"你")。
* 条件标记与占位符本身永不渲染给玩家。
*/
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 类选择:落事件并按选项跳转 */
@@ -427,7 +445,7 @@ export class StoryEngine {
answers = await this.jev.ask({ transcript }, questions);
} catch (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 a = answers[id];
@@ -443,7 +461,14 @@ export class StoryEngine {
let city = choiceVal('city');
const mentioned = answers.city_mentioned;
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 事件并推进 */
@@ -461,6 +486,7 @@ export class StoryEngine {
}),
);
this.state.nodeId = node.next;
this.state.playerName = profile.name.trim();
this.skipHiddenNodes();
this.persist();
}
@@ -501,6 +527,7 @@ export class StoryEngine {
this.state.activeVariant = null;
this.state.pendingFollowUp = null;
this.state.flags = {};
this.state.playerName = '';
this.state.finished = false;
this.skipHiddenNodes();
this.persist();