Files
SoundGame/web/storyline/game.js
T
xsl ee9d7efe57 feat(playbook): 26 号交互手册首批落地——storyline/escape/simon 全面重构 + 引擎六模块
按 docs/design/26-audio-interaction-playbook.md 重构三款游戏(对白重写 + 双性别语音重生成 580 文件)。

引擎层(web/engine/,其余 12 款游戏零改动):
- floor.js 发言权管理器:SPEAKING/YOUR_TURN/BUSY/ACTION 四态、交接提示音、
  等待声(220/330Hz 嘟-嘟,20s 后放缓)、跨链音乐压低(audio.js holdDuck)
- receipts.js 输入回执三件套(ok/invalid/busy,100ms 内,原则 4)
- confusion.js 迷茫检测(乱点/无效/静默信号 → 三级接管,§4.5)
- hints.js 等待点 + 三档提示(full→short→提示音,30s 台账跨点去重,原则 9)
- novice.js 手势解锁阶梯(up/down/doubletap 跨局持久,§4.3)
- pipeline.js 输入管道(回执收口、防连点 400ms、双击折算、explainOnce)

游戏层:
- storyline R3:开场 71.7s 规则念白 → 3 秒内动手;双击=重播(反模式5)废除;
  抉择点三档提示+主动重听;断点存档复述(§4.12)
- escape R3:单击=检查+使用合并(双击用法废除);长按=我在哪(四向玩法裁定);
  撞墙归因每方向一次(原则 7);迷茫 L2 缩小/L3 演示;坐标存档续玩
- simon R3:新手三音色,按实力渐进解锁(+up/down/dt);失败表替代机会账
  (归因 2s 内→免费重听→放慢→结算,原则 15);播放期手势=busy 回执

测试:三款 playtest 重写(含 §6.2 乱点机器人:无声操作/被动重复/打断失败全 0,
首操作 ~2s);guide.test 改为 Guide 层单测+rhythm/space 段。全部通过
(38+33+22+72+19+35 断言)。设计单见 docs/design/26-apply-*.md。
2026-10-01 08:11:12 +08:00

630 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《声命线》R3 —— 末世互动广播剧,按 docs/design/26-audio-interaction-playbook.md 全面重构。
*
* 交互层(对齐 26 号文档,设计单见 docs/design/26-apply-storyline.md):
* - 发言权管理器(§4.1):整条语音链 = SPEAKING(音乐压低跨链、句间不掉回「轮到你」
* 听感);链完 → yieldTurn:交接提示音 → 400ms 后起等待声;玩家一操作等待声
* 80ms 内淡出。不存在安静态。
* - 输入回执 + 输入管道(§4.2):ok/invalid/busy 三件套;单击执行「继续/跳过」后
* 400ms 防连点;滑动打断并执行(floor 层统一裁定)。
* - 新手手势宪法(§4.3):doubleTapWindow=0(无双击、单击零延迟);双击未解锁按
* 单击处理;up=「我在哪」(阶段 1 解锁)、down=菜单(阶段 2)、双击=重听(阶段 3)。
* 双击=整节点重播的旧行为是 26 号 §7 反模式 5,已废除。
* - 三档提示(§4.4):每个等待点 full → short → 提示音;玩家主动重听 =
* 「再说一遍:」+ 完整句。开场不再念 26 秒规则(旧 n00_intro 71.7s 首句教学
* 在 3 秒内、第一次成功在 10 秒内,§4.10 逐秒脚本见设计单)。
* - 迷茫检测(§4.5):乱点/无效/静默信号 → 三级接管(短句 → 只留左右滑 → 演示)。
* - 断点复述(§4.12):任意节点自动存档;回来先问「继续上次 / 从头开始」,
* 复述句 = 状态一句话(位置 → 目标 → 一个动作,原则 3)。
*
* 叙事层保留:节点图、剧情标记(companion/knowTrap/betrayed/viaCulvert)、虚拟节点
* resolve、e1 彩蛋、4 结局。
*
* 状态 × 手势矩阵(新手期,完整版见设计单):
* | 状态 | 单击 | 左/右滑 | 上滑 | 下滑(未解锁) | 双击(未解锁) |
* |-----------|---------------|--------------------|-------------|------------------|--------------|
* | 游戏在说 | 跳过这句+ok | 抉择节点→跳去选项;否则 invalid | 打断,答「我在哪」 | invalid+解释 | 按单击处理 |
* | 等你继续 | 继续+ok | invalid+解释 | 「我在哪」 | invalid+解释 | (阶段3=重听)|
* | 等你选择 | 再听选项(hold) | 抉择+ok | 「正在抉择」 | invalid+解释 | (阶段3=重听)|
* | 结算 | 重新开始+ok | invalid+解释 | 重播结局句 | 菜单(阶段2解锁) | 按单击处理 |
* | 菜单 | 换一项+播报 | 左=上一项 / 右=确认 | 重听菜单说明 | invalid+解释 | 按单击处理 |
*/
import { Game } from '../engine/game.js';
import { Floor } from '../engine/floor.js';
import { Receipts } from '../engine/receipts.js';
import { Confusion } from '../engine/confusion.js';
import { WaitPoint, InvalidHints } from '../engine/hints.js';
import { GestureLadder } from '../engine/novice.js';
/** 剧情节点。voices 顺序播放;prompt 非空表示这是一个选择节点。 */
function node(voices, opts = {}) {
return { voices, next: null, prompt: null, left: null, right: null, sfx: null, ending: false, ...opts };
}
const NODES = {
// 第一幕:地铁站与街面(n00 拆成两句;旧 71.7s n00_intro 的规则念白废除,§4.10)
n00: node(['voice/n00_b'], { next: 'n01' }),
n01: node(['voice/n01_wake'], { next: 'n02', sfx: 'sfx/radio_static' }),
n02: node(['voice/n02_linwan'], { prompt: 'voice/p1', left: 'n03', right: 'n04' }),
n03: node(['voice/n03_radio', 'voice/n03_msg'], { next: 'n05', sfx: 'sfx/radio_static' }),
n04: node(['voice/n04_exit'], { next: 'n05', sfx: 'sfx/footsteps' }),
n05: node(['voice/n05_signal', 'voice/n05_bcast'], { next: 'n06', sfx: 'sfx/radio_static' }),
n06: node(['voice/n06_whistle'], { next: 'n07', sfx: 'sfx/whistle' }),
n07: node(['voice/n07_dog'], { prompt: 'voice/p2', left: 'n08', right: 'n09' }),
n08: node(['voice/n08_vent'], { next: 'n10', sfx: 'sfx/rubble' }),
n09: node(['voice/n09_dead'], { next: 'n10' }),
// 第二幕:雨夜与断桥
n10: node(['voice/n10_rain'], { prompt: 'voice/p3', left: 'n11', right: 'n14', sfx: 'sfx/wind' }),
n11: node(['voice/n11_child'], { prompt: 'voice/p4', left: 'n12', right: 'n13' }),
n12: node(['voice/n12_take'], { next: 'n15' }),
n13: node(['voice/n13_leave'], { next: 'n15', sfx: 'sfx/footsteps' }),
n14: node(['voice/n14_ignore'], { next: 'n15', sfx: 'sfx/footsteps' }),
n15: node(['voice/n15_bridge'], { prompt: 'voice/p5', left: 'v_culvert', right: 'v_cable', sfx: 'sfx/wind' }),
n16: node(['voice/n16_culvert'], { next: 'n18', sfx: 'sfx/water' }),
n16b: node(['voice/n16b_culvert_c'], { next: 'n18', sfx: 'sfx/water' }),
n17: node(['voice/n17_cable'], { next: 'n18', sfx: 'sfx/wind' }),
n17b: node(['voice/n17b_cable_c'], { next: 'n18', sfx: 'sfx/wind' }),
// 第三幕:广播塔
n18: node(['voice/n18_dog'], { prompt: 'voice/p6', left: 'n19', right: 'n22', sfx: 'sfx/footsteps' }),
n19: node(['voice/n19_betray'], { next: 'n20' }),
n20: node(['voice/n20_convoy'], { prompt: 'voice/p7', left: 'n21', right: 'e3' }),
n21: node(['voice/n21_redeem'], { next: 'e2', sfx: 'sfx/footsteps' }),
n22: node(['voice/n22_fight'], { next: 'n23', sfx: 'sfx/gunshot' }),
n23: node(['voice/n23_tower'], { prompt: 'voice/p8', left: 'n24', right: 'n25', sfx: 'sfx/signal_beep' }),
n24: node(['voice/n24_open'], { next: 'e4', sfx: 'sfx/signal_beep' }),
n25: node(['voice/n25_secure'], { next: 'e1' }),
// 结局
e1: node(['voice/e1_dawn'], { ending: true }),
e2: node(['voice/e2_price'], { ending: true }),
e3: node(['voice/e3_alone'], { ending: true, sfx: 'sfx/radio_static' }),
e4: node(['voice/e4_static'], { ending: true, sfx: 'sfx/struggle' }),
};
const ALL_SFX = [
'sfx/footsteps', 'sfx/gunshot', 'sfx/heartbeat', 'sfx/radio_static',
'sfx/rubble', 'sfx/signal_beep', 'sfx/struggle', 'sfx/ui_confirm',
'sfx/ui_menu', 'sfx/water', 'sfx/whistle', 'sfx/wind',
];
const MODE = { REOPEN: 'REOPEN', STORY: 'STORY', MENU: 'MENU' };
const SAVE_KEY = 'soundgame.storyline.save';
// 等待点静默阈值(§4.5:剧情类 18s;开场/抉择更快,对齐旧 CHOICE_HINT 礼仪)
const SILENCE = {
intro: [6000, 15000], // 开场第一下:6 秒就提醒(F7:30~60 秒内必须第一次成功)
continue: [18000, 40000],
choice: [25000, 50000],
settle: [20000, 45000],
reopen: [12000, 30000],
menu: [20000, 45000],
};
// 状态一句话(原则 3:位置 → 目标 → 现在能做的一个动作,≤20 字)
const STATUS = { 1: 'voice/status_s1', 2: 'voice/status_s2', 3: 'voice/status_s3' };
// 无效操作解释(原则 7:第一次说原因和正确做法,之后只给回执)
const INVALID_LINES = {
'locked:up': 'voice/inv_up_locked',
'locked:down': 'voice/inv_down_locked',
'locked:longpress': 'voice/inv_generic',
'story:swipe': 'voice/inv_nosel',
'settle:swipe': 'voice/sys_hint_again_full',
'menu:down': 'voice/menu_help',
};
export class StorylineGame extends Game {
constructor(audio, root) {
super(audio, root);
this.receipts = new Receipts(audio);
this.floor = new Floor(audio);
this.confusion = new Confusion({
onConfused: (lvl) => { const wp = this.floor.waitPoint; if (wp) wp.takeover(lvl); },
});
this.invalidHints = new InvalidHints().bind(this.floor);
this.ladder = new GestureLadder();
this.audio.setRateBoostEnabled(false); // 新手期长按不快进(长按=无效手势,§4.3)
this.mode = MODE.STORY;
this.nodeId = null;
this.prelude = 0; // 开场前奏步(0=等第一下单击,1=旁白+状态教学已播)
this.chainKind = 'none'; // 'story' | 'prompt' | 'other':滑动打断时的分流依据
this.goalMet = false; // 第一个小目标(第一次抉择)已达成 → 解锁上滑
this.skipHintShown = false;
this._teachQueue = null; // 待挂到下一段链尾的解锁教学句(§4.3 阶梯)
// 剧情标记
this.companion = false;
this.knowTrap = false;
this.betrayed = false;
this.viaCulvert = false;
// 菜单
this._menuIdx = 0;
}
// ---------------------------------------------------------------- Game
start() {
this.audio.play('bgm/dark_drone');
this.audio.prewarm(ALL_SFX);
const save = this._loadSave();
if (save && save.nodeId && save.nodeId !== 'n00') {
// 断点复述(§4.12):回来先问继续还是重开
this.mode = MODE.REOPEN;
this._restoreFlags(save);
this.floor.speak(['voice/sys_continue_ask'], {
onDone: () => this.floor.yieldTurn(this._reopenWp()),
});
} else {
this._coldStart();
}
}
stop() {
this._save();
this.floor.interrupt();
this.audio.stopAll();
}
/** 冷启动首 3 分钟开场(§4.10 逐秒脚本,见设计单)。 */
_coldStart() {
this.mode = MODE.STORY;
this.nodeId = null;
this.prelude = 0;
this.audio.play('sfx/heartbeat');
// 0~3 秒:第一句就让玩家动手。指令句 critical(<3s):抢拍单击给 busy
// 回执,保证第一次教学完整到达(F7:第一分钟内必须给出第一次成功)
this.chainKind = 'story';
this.floor.speak(['voice/sys_first_tap'], {
critical: true,
onDone: () => this.floor.yieldTurn(this._introWp()),
});
}
/** 第一次单击之后:旁白 + 「轮到你了」状态教学(原则 2:状态声第一次出现配解释)。 */
_afterFirstTap() {
this.prelude = 1;
this.chainKind = 'story';
this.floor.speak(['voice/n00_a', 'voice/sys_turn_teach'], {
onDone: () => this.floor.yieldTurn(this._continueWp()),
});
}
// ---------------------------------------------------------------- 手势(pipeline 之后)
/** pipeline 收口后的游戏处理;返回 'ok' | 'hold' | 'invalid'。 */
onGesture(g) {
if (this.mode === MODE.MENU) return this._onMenuGesture(g);
if (this.mode === MODE.REOPEN) return this._onReopenGesture(g);
return this._onStoryGesture(g);
}
stateName() {
if (this.mode === MODE.MENU) return 'menu';
if (this.mode === MODE.REOPEN) return 'reopen';
const node = NODES[this.nodeId];
if (node && node.ending && !this.floor.speaking) return 'settle';
return 'story';
}
invalidHint(state, g) {
if (state === 'locked') return INVALID_LINES[`locked:${g}`] || 'voice/inv_generic';
if (g === 'left' || g === 'right') {
if (state === 'menu') return null; // 菜单里左右滑是合法操作
return INVALID_LINES[`${state}:swipe`] || 'voice/inv_nosel';
}
if (state === 'menu' && g === 'down') return INVALID_LINES['menu:down'];
return INVALID_LINES[`${state}:swipe`] || 'voice/inv_generic';
}
_onStoryGesture(g) {
// 开场前奏(§4.10):第一下单击 → 旁白+状态教学;第二下单击 → 正式剧情
if (this.prelude === 0 && g === 'tap') {
this._afterFirstTap();
return 'ok';
}
if (this.prelude === 1 && g === 'tap') {
this.prelude = 2;
this.playNode('n00');
return 'ok';
}
if (this.prelude < 2 && (g === 'left' || g === 'right')) return 'invalid'; // 前奏不选
const node = NODES[this.nodeId];
if (!node) return 'invalid';
switch (g) {
case 'tap': {
// 游戏在说 → pipeline 已按「跳过这句」处理(consumed),到不了这里
const wp = this.floor.waitPoint;
if (wp && wp.conf.id === 'choice') {
// 抉择点空点 = 主动再听选项(「再说一遍:」+ 完整句,原则 9)
wp.requestReplay();
return 'hold';
}
if (node.ending) { this._restart(); return 'ok'; }
if (node.next != null) { this.playNode(this.resolve(node.next)); return 'ok'; }
return 'invalid';
}
case 'doubletap': {
// 阶段 3 解锁的重听(§4.3):等待点 → 重听该点完整句;否则重播当前节点
const wp = this.floor.waitPoint;
if (wp) { wp.requestReplay(); return 'hold'; }
this._replayNode();
return 'ok';
}
case 'left':
case 'right': {
if (node.prompt != null) {
if (this.chainKind === 'story') {
// 旁白没听完就滑(打断并执行的落点 = 抉择点):只跳去选项,不当场盲选
this._playPrompt(node);
return 'ok';
}
this._choose(g, node);
return 'ok';
}
return 'invalid'; // inv_nosel:现在不用选
}
case 'up': {
// 「我在哪、我该干嘛」(原则 3):一句话,位置 → 目标 → 动作
const line = this._isChoiceWaiting(node)
? 'voice/status_choice' : STATUS[this.actOf(this.nodeId)];
this.chainKind = 'other';
this.floor.speak([line], { onDone: () => this.floor.yieldTurn(this._wpFor(node)) });
return 'ok';
}
case 'down': {
this._openMenu();
return 'ok';
}
default:
return 'invalid';
}
}
_onReopenGesture(g) {
const save = this._loadSave();
switch (g) {
case 'tap':
this.mode = MODE.STORY;
this.prelude = 2;
this.playNode(save.nodeId, { resume: true });
return 'ok';
case 'right':
this._resetFlags();
this.prelude = 2;
this.playNode('n00');
return 'ok';
case 'up': {
const line = STATUS[this.actOf(save.nodeId)];
this.chainKind = 'other';
this.floor.speak([line], { onDone: () => this.floor.yieldTurn(this._reopenWp()) });
return 'ok';
}
default:
return 'invalid';
}
}
_onMenuGesture(g) {
const items = ['voice/menu_item1', 'voice/menu_item2', 'voice/menu_item3'];
const announce = () => {
this.chainKind = 'other';
this.floor.speak([items[this._menuIdx]], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
};
switch (g) {
case 'tap':
this._menuIdx = (this._menuIdx + 1) % items.length;
announce();
return 'hold';
case 'left':
this._menuIdx = (this._menuIdx + items.length - 1) % items.length;
announce();
return 'hold';
case 'right': {
const sel = this._menuIdx;
if (sel === 0) this._restart();
else if (sel === 1) { this.stop(); window.location.href = '../'; }
else this._closeMenu();
return 'ok';
}
case 'up':
this.chainKind = 'other';
this.floor.speak(['voice/menu_help'], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
default:
return 'invalid';
}
}
// ---------------------------------------------------------------- 剧情驱动
playNode(id, { resume = false } = {}) {
const node = NODES[id];
if (!node) {
console.error('[StorylineGame] 未知节点:', id);
return;
}
this.nodeId = id;
this.chainKind = 'story';
this._save();
if (node.sfx) this.audio.play(node.sfx);
const seq = [];
if (resume) seq.push(STATUS[this.actOf(id)]); // 断点复述句(§4.12)
seq.push(...this.nodeVoices(id));
if (id === 'n00' && !this.skipHintShown) {
this.skipHintShown = true;
seq.push('voice/skip_hint');
}
if (id === 'e1' && this.companion) seq.push('voice/e1_child');
if (node.prompt) seq.push(node.prompt); // 选项句收尾整条链,链完即交出抉择
// 手势解锁教学(§4.3 阶梯):第一次抉择后教上滑;第一/三局后教菜单/双击
const teach = this._pendingTeach(id);
if (teach) seq.push(teach);
this.floor.speak(seq, {
onDone: () => {
if (node.prompt != null) this.chainKind = 'prompt'; // 选项句已听过
this.floor.yieldTurn(this._wpFor(node));
},
});
}
/** 只播选项句并交出抉择(旁白被打断跳来这里)。 */
_playPrompt(node) {
this.chainKind = 'prompt';
this.floor.speak([node.prompt], { onDone: () => this.floor.yieldTurn(this._choiceWp(node)) });
}
_replayNode() {
const node = NODES[this.nodeId];
if (!node) return;
this.chainKind = 'story';
const seq = [...this.nodeVoices(this.nodeId)];
if (this.nodeId === 'e1' && this.companion) seq.push('voice/e1_child');
if (node.prompt) seq.push(node.prompt);
this.floor.speak(seq, { onDone: () => this.floor.yieldTurn(this._wpFor(node)) });
}
nodeVoices(id) {
const node = NODES[id];
return node ? [...node.voices] : [];
}
_choose(g, node) {
this.audio.play('sfx/ui_confirm');
// 选择带来的剧情标记(n07 右滑=装死听情报;以此类推)
if (this.nodeId === 'n07' && g === 'right') this.knowTrap = true;
if (this.nodeId === 'n11' && g === 'left') this.companion = true;
if (this.nodeId === 'n15') this.viaCulvert = g === 'left';
if (this.nodeId === 'n18' && g === 'left') this.betrayed = true;
// 完成第一个小目标 → 解锁上滑(§4.3 阶梯阶段 1),教学句挂在下一段链尾
if (!this.goalMet) {
this.goalMet = true;
const got = this.ladder.onGoal();
if (got) this._teachQueue = this.ladder.teachLine(got);
}
this.playNode(this.resolve(g === 'left' ? node.left : node.right));
}
/** 结局 → 解锁阶段 2/3 的教学句(挂在结算链尾,onGameDone 由 ladder 计数)。 */
_pendingTeach(id) {
const node = NODES[id];
if (this._teachQueue) { const t = this._teachQueue; this._teachQueue = null; return t; }
if (node && node.ending) {
const got = this.ladder.onGameDone();
if (got) return this.ladder.teachLine(got);
}
return null;
}
/** 虚拟目标:根据剧情标记解析到实际节点。 */
resolve(target) {
if (target === 'v_culvert') return this.companion ? 'n16b' : 'n16';
if (target === 'v_cable') return this.companion ? 'n17b' : 'n17';
return target;
}
// ---------------------------------------------------------------- 等待点
_wpFor(node) {
if (node.prompt != null) return this._choiceWp(node);
if (node.ending) return this._settleWp(node);
return this._continueWp();
}
_introWp() {
return new WaitPoint({
id: 'intro',
expected: ['tap'],
full: null, // 主动句刚说过,静默第一档直接短句(§4.10 找猫示例同型)
short: 'voice/sys_first_tap_short',
silenceMs: SILENCE.intro,
onDemo: () => {
this.chainKind = 'other';
this.floor.speak(['voice/sys_demo_continue'], {
onDone: () => this._afterFirstTap(), // 演示「继续」:直接替玩家走一步
});
},
}, { floor: this.floor, confusion: this.confusion });
}
_continueWp() {
return new WaitPoint({
id: 'continue',
expected: ['tap'],
full: 'voice/sys_hint_continue_full',
short: 'voice/sys_hint_continue_short',
silenceMs: SILENCE.continue,
onDemo: () => {
const node = NODES[this.nodeId];
this.chainKind = 'other';
this.floor.speak(['voice/sys_demo_continue'], {
onDone: () => {
if (node && node.next != null && !node.ending) this.playNode(this.resolve(node.next));
else this.floor.yieldTurn(this._continueWp());
},
});
},
}, { floor: this.floor, confusion: this.confusion });
}
_choiceWp(node) {
return new WaitPoint({
id: 'choice',
expected: ['left', 'right'],
full: node.prompt,
short: 'voice/sys_hint_choice_short',
restrictLine: 'voice/sys_only_choice',
silenceMs: SILENCE.choice,
onDemo: () => {
// 演示不替玩家做有后果的抉择:复述两个方向,交回
this.chainKind = 'other';
this.floor.speak(['voice/sys_demo_choice'], {
onDone: () => this.floor.yieldTurn(this._choiceWp(node)),
});
},
}, { floor: this.floor, confusion: this.confusion });
}
_settleWp(node) {
return new WaitPoint({
id: 'settle',
expected: ['tap'],
full: 'voice/sys_hint_again_full',
short: 'voice/sys_hint_continue_short',
silenceMs: SILENCE.settle,
onDemo: () => {
this.chainKind = 'other';
this.floor.speak(['voice/sys_hint_again_full'], {
onDone: () => this.floor.yieldTurn(this._settleWp(node)),
});
},
}, { floor: this.floor, confusion: this.confusion });
}
_reopenWp() {
return new WaitPoint({
id: 'reopen',
expected: ['tap', 'right'],
full: 'voice/sys_continue_ask',
short: 'voice/sys_hint_continue_short',
silenceMs: SILENCE.reopen,
onDemo: () => this.floor.chime(),
}, { floor: this.floor, confusion: this.confusion });
}
_menuWp() {
return new WaitPoint({
id: 'menu',
expected: ['tap', 'left', 'right'],
full: 'voice/menu_help',
short: 'voice/menu_help',
silenceMs: SILENCE.menu,
onDemo: () => this.floor.chime(),
}, { floor: this.floor, confusion: this.confusion });
}
_isChoiceWaiting(node) {
const wp = this.floor.waitPoint;
return !!(node && node.prompt != null && wp && wp.conf.id === 'choice');
}
// ---------------------------------------------------------------- 菜单 / 重开
_openMenu() {
this.mode = MODE.MENU;
this._menuIdx = 0;
this.floor.interrupt();
this.audio.play('sfx/ui_menu');
this.audio.play('bgm/menu', { fadeMs: 500 });
this.chainKind = 'other';
this.floor.speak(['voice/menu_open', 'voice/menu_item1'], {
onDone: () => this.floor.yieldTurn(this._menuWp()),
});
}
_closeMenu() {
this.mode = MODE.STORY;
this.floor.interrupt();
this.audio.play('sfx/ui_confirm');
this.audio.play('bgm/dark_drone', { fadeMs: 800 });
this.playNode(this.nodeId);
}
_restart() {
this._resetFlags();
this.mode = MODE.STORY;
this.floor.interrupt();
this.audio.play('sfx/ui_confirm');
this.audio.play('bgm/dark_drone');
// 重开不再念开场教学(做对过的事不教第二遍,原则 11)
this.skipHintShown = true;
this.nodeId = 'n00';
this._save();
this.chainKind = 'story';
const node = NODES.n00;
this.floor.speak(this.nodeVoices('n00'), {
onDone: () => this.floor.yieldTurn(this._wpFor(node)),
});
}
_resetFlags() {
this.companion = false;
this.knowTrap = false;
this.betrayed = false;
this.viaCulvert = false;
}
_restoreFlags(save) {
this.companion = !!save.companion;
this.knowTrap = !!save.knowTrap;
this.betrayed = !!save.betrayed;
this.viaCulvert = !!save.viaCulvert;
if (save.goalMet) this.goalMet = true;
if (save.skipHintShown) this.skipHintShown = true;
if (save.nodeId) this.nodeId = save.nodeId;
}
// ---------------------------------------------------------------- 存档(§4.12 / 原则 16)
_save() {
if (!this.nodeId) return;
try {
localStorage.setItem(SAVE_KEY, JSON.stringify({
nodeId: this.nodeId,
companion: this.companion,
knowTrap: this.knowTrap,
betrayed: this.betrayed,
viaCulvert: this.viaCulvert,
goalMet: this.goalMet,
skipHintShown: this.skipHintShown,
}));
} catch (_) { /* 隐私模式 */ }
}
_loadSave() {
try {
const raw = localStorage.getItem(SAVE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (_) {
return null;
}
}
actOf(id) {
if (!id) return 1;
if (id.startsWith('e')) return 3;
const m = /^n(\d+)/.exec(id);
const num = m ? parseInt(m[1], 10) : NaN;
if (Number.isNaN(num)) return 1;
if (num < 10) return 1;
if (num < 18) return 2;
return 3;
}
}