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。
This commit is contained in:
+495
-328
@@ -1,29 +1,40 @@
|
||||
/**
|
||||
* 《声命线》——末世互动广播剧(Web 移植版,忠实对齐 Android StorylineGame.kt)。
|
||||
* 《声命线》R3 —— 末世互动广播剧,按 docs/design/26-audio-interaction-playbook.md 全面重构。
|
||||
*
|
||||
* 手势映射(12 号文档 §1 裁定):
|
||||
* - tap :voice 播放中 = 跳过本句(audio.skipVoice,链继续走下一句;
|
||||
* 抉择提示 p* 播放中不可跳,盲玩公平性);无语音时 = 继续剧情;
|
||||
* 结局后 = 重新开始
|
||||
* - doubletap :重听当前节点的全部台词(含结局节点——重播期间单击=跳句,
|
||||
* 播完回到结算态;live round1 P2-1)
|
||||
* - left / right :在选择节点做抉择(提示音播完后生效;播放中会缓存 pendingChoice)
|
||||
* - up :听操作说明 + 当前进度,随后自动重播当前剧情
|
||||
* - down :呼出菜单(左滑重新开始,右滑/单击返回,上滑听帮助)
|
||||
* 交互层(对齐 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)。
|
||||
*
|
||||
* 状态 BGM(10 号文档 §2):剧情/结局 = bgm/dark_drone 不切;菜单 = bgm/menu
|
||||
* (进菜单 crossfade 500ms,关菜单淡回 dark_drone 800ms)。
|
||||
* 叙事层保留:节点图、剧情标记(companion/knowTrap/betrayed/viaCulvert)、虚拟节点
|
||||
* resolve、e1 彩蛋、4 结局。
|
||||
*
|
||||
* 机制保留:语音链 onComplete 串行 + seqToken 打断作废、pendingChoice 缓存、
|
||||
* 剧情标记(companion/knowTrap/betrayed)、虚拟节点 resolve、e1 彩蛋、4 结局、
|
||||
* 单句看门狗(VOICE_WATCHDOG_MS 超时且引擎确认 voice 未在播 = 资产缺失,
|
||||
* 才自动续链不卡死;真实在播的长语音续约不掐——13 号文档 §1 + §16 N1)。
|
||||
*
|
||||
* 与 Android 差异:sfx 预热用 prewarm(提前解码)代替音量 0 假播(Web 引擎
|
||||
* 按需解码 + AudioBuffer 缓存,见 docs/web-engine-api.md)。
|
||||
* 状态 × 手势矩阵(新手期,完整版见设计单):
|
||||
* | 状态 | 单击 | 左/右滑 | 上滑 | 下滑(未解锁) | 双击(未解锁) |
|
||||
* |-----------|---------------|--------------------|-------------|------------------|--------------|
|
||||
* | 游戏在说 | 跳过这句+ok | 抉择节点→跳去选项;否则 invalid | 打断,答「我在哪」 | invalid+解释 | 按单击处理 |
|
||||
* | 等你继续 | 继续+ok | invalid+解释 | 「我在哪」 | invalid+解释 | (阶段3=重听)|
|
||||
* | 等你选择 | 再听选项(hold) | 抉择+ok | 「正在抉择」 | invalid+解释 | (阶段3=重听)|
|
||||
* | 结算 | 重新开始+ok | invalid+解释 | 重播结局句 | 菜单(阶段2解锁) | 按单击处理 |
|
||||
* | 菜单 | 换一项+播报 | 左=上一项 / 右=确认 | 重听菜单说明 | invalid+解释 | 按单击处理 |
|
||||
*/
|
||||
import { Game } from '../engine/game.js';
|
||||
import { Guide } from '../engine/guide.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 = {}) {
|
||||
@@ -31,9 +42,9 @@ function node(voices, opts = {}) {
|
||||
}
|
||||
|
||||
const NODES = {
|
||||
// 第一幕:地铁站与街面
|
||||
n00: node(['voice/n00_intro'], { next: 'n01' }),
|
||||
n01: node(['voice/n01_wake'], { next: 'n02', sfx: 'sfx/heartbeat' }),
|
||||
// 第一幕:地铁站与街面(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' }),
|
||||
@@ -63,10 +74,10 @@ const NODES = {
|
||||
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' }), // 结局:静默
|
||||
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 = [
|
||||
@@ -75,313 +86,342 @@ const ALL_SFX = [
|
||||
'sfx/ui_menu', 'sfx/water', 'sfx/whistle', 'sfx/wind',
|
||||
];
|
||||
|
||||
const MODE = { STORY: 'STORY', MENU: 'MENU' };
|
||||
const MODE = { REOPEN: 'REOPEN', STORY: 'STORY', MENU: 'MENU' };
|
||||
|
||||
/** 单句语音看门狗:超时未 onComplete 且引擎确认 voice 未在播才视为资产缺失,
|
||||
* 自动推进链(不卡死);voice 真在播(长句)则续约等待,不得掐断(N1)。 */
|
||||
const VOICE_WATCHDOG_MS = 30000;
|
||||
const SAVE_KEY = 'soundgame.storyline.save';
|
||||
|
||||
/** 抉择提示音 id(voice/p1~p8):播放中单击不可跳过(12 号文档 §1.2)。 */
|
||||
const PROMPT_RE = /^voice\/p\d/;
|
||||
// 等待点静默阈值(§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],
|
||||
};
|
||||
|
||||
// 向导层(14 号文档 + round2 synthesis F1.1):full 档 25s 轻提醒+gd_continue,
|
||||
// 50s 升级只重申手势(gd_again),**永不重播 help/进度/当前节点**;
|
||||
// 同一等待点最多 2 次提示后彻底静默(guide.js 升级链用尽自动停止)。
|
||||
// full 档节奏由 web/harness/guide.test.mjs 锁定,不可改。
|
||||
const GUIDE_WATCHDOG_AT = [25000, 50000];
|
||||
// 状态一句话(原则 3:位置 → 目标 → 现在能做的一个动作,≤20 字)
|
||||
const STATUS = { 1: 'voice/status_s1', 2: 'voice/status_s2', 3: 'voice/status_s3' };
|
||||
|
||||
// live round1 P2-3(静默无兜底):standard 档 guide 看门狗只发 nudge earcon、
|
||||
// 无语音(14 号文档 #2),玩家等不到可听提示(老陈开局 45s 空白)。复用同一
|
||||
// 看门狗(计时/复位/升级/取消都在向导层,不另造定时器):standard 档提前到
|
||||
// 13s/30s,由 onEscalate 补播同 id 提示句(游戏反馈层,gd_economy 全档位先例);
|
||||
// off 档看门狗整体不挂(既定全静契约 + 6 playtest 零干扰红线)。
|
||||
const IDLE_HINT_AT = [13000, 30000];
|
||||
|
||||
// live round2 N3(抉择节点无静默兜底):p6/p8 实测 35~39s 无操作零提醒——
|
||||
// 抉择点恰恰最容易漏听选项、最需要复述。14 号文档原裁定「抉择态(p* 播完)
|
||||
// 不插语音不挂看门狗」的本义是"提示播完立即可选、不插语音延迟抉择"(W2
|
||||
// 契约,保留:播完当刻 choiceArmed 即真、零插播);本兜底与之不冲突——只在
|
||||
// 播完 25s 完全无操作后重播当前 p* 提示句本身(复用既有资产,不新增语音
|
||||
// id),50s 再复述一次后彻底静默(与继续节点同一两级上限礼仪);玩家任何
|
||||
// 手势经 guide.disarm 复位。新裁定依据 = round2 玩家复测证据(13 号文档 §16)。
|
||||
// full/standard 档提示句都由游戏层播(p* 是剧情内容不是向导句,standard 档
|
||||
// 玩家同样必须能复述选项);off 档整体不挂(既定全静契约)。
|
||||
const CHOICE_HINT_AT = [25000, 50000];
|
||||
// 无效操作解释(原则 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.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
|
||||
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 = 'n00';
|
||||
this.voicePlaying = false;
|
||||
this.currentVoiceId = null; // 链上当前句 id
|
||||
this.promptPlaying = false; // 当前句是抉择提示 p*
|
||||
this.choiceArmed = false;
|
||||
this.pendingChoice = null;
|
||||
this.awaitingRestart = false;
|
||||
this.seqToken = 0;
|
||||
this.skipHintShown = false; // 开场"单击可跳过旁白"提示每局只播一次
|
||||
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; // n15 左滑走了涵洞(分支经历标记,保留给 TTS 分支句启用)
|
||||
this.companion = false;
|
||||
this.knowTrap = false;
|
||||
this.betrayed = false;
|
||||
this.viaCulvert = false;
|
||||
|
||||
// 菜单
|
||||
this._menuIdx = 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Game
|
||||
|
||||
start() {
|
||||
this.audio.play('bgm/dark_drone');
|
||||
this.guide.state('explore'); // 状态签名:剧情/探索(与状态 BGM 同点,10 号文档)
|
||||
// 预解码全部短音效(等价 Android 的 SoundPool 音量 0 预热)
|
||||
this.audio.prewarm(ALL_SFX);
|
||||
this.playNode('n00');
|
||||
}
|
||||
|
||||
onGesture(g) {
|
||||
console.log(`[StorylineGame] onGesture: ${g} mode=${this.mode} node=${this.nodeId} armed=${this.choiceArmed} playing=${this.voicePlaying}`);
|
||||
this.guide.disarm(); // 任何手势复位静默看门狗(guide=off 时 no-op)
|
||||
if (this.mode === MODE.MENU) this.onMenuGesture(g);
|
||||
else this.onStoryGesture(g);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 手势
|
||||
|
||||
onStoryGesture(g) {
|
||||
const node = NODES[this.nodeId];
|
||||
if (!node) return;
|
||||
switch (g) {
|
||||
case 'tap':
|
||||
if (this.awaitingRestart) this.restart();
|
||||
else if (this.voicePlaying) {
|
||||
// 旁白播放中单击 = 跳过本句(语音链继续);抉择提示 p* 不可跳
|
||||
if (!this.promptPlaying) this.audio.skipVoice();
|
||||
} else if (node.next != null) this.playNode(this.resolve(node.next));
|
||||
// 抉择等待态空点 = 无进展手势:重新挂载兜底(25s 计时重置,N3「操作即复位」)
|
||||
else if (node.prompt != null && this.choiceArmed) this.armChoiceHint(node);
|
||||
break;
|
||||
case 'doubletap':
|
||||
this.replayNode();
|
||||
break;
|
||||
case 'left':
|
||||
case 'right': {
|
||||
if (node.prompt == null) {
|
||||
// 无效手势零反馈是全 6 款最盲点(清点 §1.4):向导纠错——error 震 +
|
||||
// full 档 gd_invalid(onceKey 同局只播一次;off 档整段 no-op)
|
||||
this.guide.error({ voice: 'gd_invalid', onceKey: 'storyline_invalid' });
|
||||
return;
|
||||
}
|
||||
if (this.choiceArmed) this.choose(g);
|
||||
else if (this.voicePlaying) this.pendingChoice = g; // 提示音播放中,缓存抉择
|
||||
break;
|
||||
}
|
||||
case 'up':
|
||||
this.playHelpAndResume();
|
||||
break;
|
||||
case 'down':
|
||||
this.openMenu();
|
||||
break;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
onMenuGesture(g) {
|
||||
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 'left':
|
||||
this.restart();
|
||||
break;
|
||||
case 'right':
|
||||
case 'tap':
|
||||
this.closeMenu();
|
||||
break;
|
||||
case 'up':
|
||||
this.playHelpAndResume(true);
|
||||
break;
|
||||
case 'doubletap':
|
||||
if (!this.voicePlaying) {
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
this.playSeq(['voice/menu'], () => { this.voicePlaying = false; });
|
||||
case 'tap': {
|
||||
// 游戏在说 → pipeline 已按「跳过这句」处理(consumed),到不了这里
|
||||
const wp = this.floor.waitPoint;
|
||||
if (wp && wp.conf.id === 'choice') {
|
||||
// 抉择点空点 = 主动再听选项(「再说一遍:」+ 完整句,原则 9)
|
||||
wp.requestReplay();
|
||||
return 'hold';
|
||||
}
|
||||
break;
|
||||
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) {
|
||||
playNode(id, { resume = false } = {}) {
|
||||
const node = NODES[id];
|
||||
if (!node) {
|
||||
console.error('[StorylineGame] 未知节点:', id);
|
||||
return;
|
||||
}
|
||||
this.nodeId = id;
|
||||
this.choiceArmed = false;
|
||||
this.pendingChoice = null;
|
||||
this.awaitingRestart = false;
|
||||
this.chainKind = 'story';
|
||||
this._save();
|
||||
if (node.sfx) this.audio.play(node.sfx);
|
||||
const seq = this.nodeVoices(id);
|
||||
// F8 开场瘦身(round2):操作口令 gd_brief(≤8s)前置到开场链最前,
|
||||
// 「brief→intro」;原 intro 长句里的操作教学资产保留不动。
|
||||
// 向导句只在 full 档插播(off/standard 保持纯剧情链)。
|
||||
if (id === 'n00' && this.guide.level() === 'full') seq.unshift('voice/gd_brief');
|
||||
// 开场教学:intro 早期追加一次"单击可跳过旁白"提示(每局只播一次,重开不重播)
|
||||
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);
|
||||
this.playSeq(seq, () => this.onNodeSeqDone(node));
|
||||
}
|
||||
|
||||
replayNode() {
|
||||
// 结局节点同样兑现"双击:重听刚才的台词"(live round1 P2-1:结局播完双击
|
||||
// 只有震动不重播)。重播期间清结算态——单击回到"跳过本句"而非重开;
|
||||
// 播完由 onNodeSeqDone 重新进入结算态。
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
// 重播当前节点(不重复进入音效之外的状态重置)
|
||||
const node = NODES[this.nodeId];
|
||||
if (!node) return;
|
||||
this.choiceArmed = false;
|
||||
this.pendingChoice = null;
|
||||
this.awaitingRestart = false;
|
||||
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.playSeq(seq, () => this.onNodeSeqDone(node));
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点基础语音链。
|
||||
* known-issue(live round2 N2,13 号文档 §16):round1 修 P2-2「涵洞也拦不住
|
||||
* 你」穿帮时按 !viaCulvert 整段滤掉 n18_dog,结果钢索路径猎狗对峙戏蒸发、
|
||||
* p6 抉择(交出坐标还是拒绝)失去对手戏上下文——玩家裁定这比穿帮更糟。
|
||||
* 现恢复涵洞/钢索两条路径都播 n18_dog:钢索路径接受该句的轻微穿帮,待
|
||||
* TTS 补录分支句后按 viaCulvert 选播(voice_text.json 无替代资产,台词
|
||||
* 文本不可改、不新增语音 id)。
|
||||
*/
|
||||
nodeVoices(id) {
|
||||
const node = NODES[id];
|
||||
if (!node) return [];
|
||||
return [...node.voices];
|
||||
}
|
||||
|
||||
/** playNode / replayNode 的语音链自然播完后的公共收尾。 */
|
||||
onNodeSeqDone(node) {
|
||||
this.voicePlaying = false;
|
||||
if (node.prompt != null) {
|
||||
this.choiceArmed = true;
|
||||
if (this.pendingChoice) {
|
||||
const g = this.pendingChoice;
|
||||
this.pendingChoice = null;
|
||||
this.choose(g);
|
||||
} else {
|
||||
// 抉择提示播完即可选(W2 契约:播完当刻 choiceArmed 即真、零插播)。
|
||||
// 在此之上挂静默兜底:25s 无操作重播 p* 提示(N3,见 CHOICE_HINT_AT 注释)
|
||||
this.armChoiceHint(node);
|
||||
}
|
||||
} else if (node.ending) {
|
||||
this.awaitingRestart = true;
|
||||
}
|
||||
// W1/W3 静默等待看门狗(向导层,round2 F1.1 礼仪重构):full 档 25s nudge+
|
||||
// gd_continue、50s 升级 gd_again——**不重播 help+进度+当前节点**,两级用尽
|
||||
// 彻底静默。live round1 P2-3:standard 档 guide 只发 earcon 无语音,同一
|
||||
// 看门狗换用 IDLE_HINT_AT(13s/30s)并由 onEscalate 补播同 id 提示句
|
||||
// (游戏反馈层,off 档整体不挂)。玩家手势后由 guide.disarm 复位,下一
|
||||
// 节点播完重新挂载。语音播放中不挂;抉择态改挂 armChoiceHint(N3 兜底)。
|
||||
if (node.prompt == null) {
|
||||
this.guide.watchdog({
|
||||
arm: true,
|
||||
at: this.guide.level() === 'full' ? GUIDE_WATCHDOG_AT : IDLE_HINT_AT,
|
||||
say: ['gd_continue', 'gd_again'],
|
||||
onEscalate: (n) => {
|
||||
// full 档提示句由 guide 的 say 播(guide.test 锁定),不重复播
|
||||
if (this.guide.level() === 'full') return;
|
||||
this.audio.play(`voice/${n === 1 ? 'gd_continue' : 'gd_again'}`, { queue: true });
|
||||
},
|
||||
cond: () => this.mode === MODE.STORY && !this.voicePlaying,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* N3 抉择节点静默兜底:25s/50s 无操作重播当前 p* 提示句(复用既有资产),
|
||||
* 两级用尽彻底静默;玩家任何手势经 guide.disarm 复位(抉择态空点由
|
||||
* onStoryGesture 重新挂载 = 计时重置;已缓存 pendingChoice 直接推进不挂)。
|
||||
* guide=off 时 guide.watchdog 整体 no-op(不挂定时器,全静契约)。
|
||||
*/
|
||||
armChoiceHint(node) {
|
||||
this.guide.watchdog({
|
||||
arm: true,
|
||||
at: CHOICE_HINT_AT,
|
||||
onEscalate: () => {
|
||||
if (node.prompt) this.audio.play(node.prompt, { queue: true });
|
||||
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));
|
||||
},
|
||||
// 已抉择(choiceArmed=false)/离开剧情态/新链在播 → 到点静默取消
|
||||
cond: () => this.mode === MODE.STORY && this.choiceArmed && !this.voicePlaying,
|
||||
});
|
||||
}
|
||||
|
||||
/** 顺序播放一组对白,全部自然播完后回调。被打断时回调作废(seqToken 机制)。 */
|
||||
playSeq(ids, onDone) {
|
||||
const token = ++this.seqToken;
|
||||
this.voicePlaying = true;
|
||||
const step = (i) => {
|
||||
if (token !== this.seqToken) return;
|
||||
if (i >= ids.length) {
|
||||
this.currentVoiceId = null;
|
||||
this.promptPlaying = false;
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
this.currentVoiceId = ids[i];
|
||||
this.promptPlaying = PROMPT_RE.test(ids[i]);
|
||||
let advanced = false;
|
||||
// 看门狗:资产缺失时引擎不触发 onComplete,超时自动续链(不卡死)。
|
||||
// live round2 N1:固定 30s 阈值会把真实在播的长语音误判为卡死——冷启动
|
||||
// n00_intro(71.7s,2.2x 也要 32.6s)在 30s 处被续链的下一句 skip_hint
|
||||
// 顶掉,末句「现在,单击屏幕,睁开眼睛」被吃(菜单重开时链上无下一句
|
||||
// 可顶,故只在冷启动咬人);常速下 e1/e2/e4(48~51s)同样越线。判定
|
||||
// 对齐真实播放状态:引擎 isVoicePlaying() 为真 = 资产在播(自然播完
|
||||
// 必触发 onComplete),续约再等一轮,不得掐;为假才是资产缺失(引擎
|
||||
// 静默跳过、永不回调),照旧告警续链(13 号文档 §1 不卡死断言不变)。
|
||||
const armWd = () => setTimeout(() => {
|
||||
if (advanced || token !== this.seqToken) return;
|
||||
if (this.audio.isVoicePlaying()) { wd = armWd(); return; }
|
||||
advanced = true;
|
||||
console.warn('[StorylineGame] 语音看门狗超时(voice 未在播,判定资产缺失),自动续链:', ids[i]);
|
||||
step(i + 1);
|
||||
}, VOICE_WATCHDOG_MS);
|
||||
let wd = armWd();
|
||||
this.audio.play(ids[i], {
|
||||
onComplete: () => {
|
||||
if (advanced || token !== this.seqToken) return;
|
||||
advanced = true;
|
||||
clearTimeout(wd);
|
||||
step(i + 1);
|
||||
},
|
||||
});
|
||||
};
|
||||
step(0);
|
||||
/** 只播选项句并交出抉择(旁白被打断跳来这里)。 */
|
||||
_playPrompt(node) {
|
||||
this.chainKind = 'prompt';
|
||||
this.floor.speak([node.prompt], { onDone: () => this.floor.yieldTurn(this._choiceWp(node)) });
|
||||
}
|
||||
|
||||
choose(g) {
|
||||
_replayNode() {
|
||||
const node = NODES[this.nodeId];
|
||||
if (!node || node.left == null || node.right == null) return;
|
||||
this.choiceArmed = false;
|
||||
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');
|
||||
// 选择带来的剧情标记(注意此时 nodeId 仍是抉择点,n09 是 n07 右滑的目标)
|
||||
if (this.nodeId === 'n07' && g === 'right') this.knowTrap = true; // 装死听情报
|
||||
// 选择带来的剧情标记(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 === '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';
|
||||
@@ -389,68 +429,195 @@ export class StorylineGame extends Game {
|
||||
return target;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 帮助 / 菜单 / 重开
|
||||
// ---------------------------------------------------------------- 等待点
|
||||
|
||||
playHelpAndResume(backToMenu = false) {
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
const node = NODES[this.nodeId];
|
||||
const ending = !!(node && node.ending);
|
||||
const act = this.actOf(this.nodeId);
|
||||
// live round1 P3-2:结局后状态播报 = 当前结局语音(句尾自带"单击重新开始"),
|
||||
// 不再报"这是最后一段路了"(status3)。结局句本身已是节点重播,播完直接
|
||||
// 回结算态,不二次重播节点。
|
||||
const status = ending ? node.voices[0]
|
||||
: act === 1 ? 'voice/status1' : act === 2 ? 'voice/status2' : 'voice/status3';
|
||||
this.playSeq(['voice/help', status], () => {
|
||||
this.voicePlaying = false;
|
||||
if (backToMenu) {
|
||||
this.playSeq(['voice/menu'], () => { this.voicePlaying = false; });
|
||||
} else if (ending) {
|
||||
this.onNodeSeqDone(node); // 回结算态(awaitingRestart + 静默兜底重挂)
|
||||
} else {
|
||||
this.playNode(this.nodeId); // 重播当前剧情节点
|
||||
}
|
||||
_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()),
|
||||
});
|
||||
}
|
||||
|
||||
openMenu() {
|
||||
this.mode = MODE.MENU;
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
this.audio.play('sfx/ui_menu');
|
||||
this.audio.play('bgm/menu', { fadeMs: 500 }); // 状态 BGM:菜单 pad(10 号文档 §2)
|
||||
this.guide.state('menu');
|
||||
this.playSeq(['voice/menu'], () => { this.voicePlaying = false; });
|
||||
}
|
||||
|
||||
closeMenu() {
|
||||
if (this.voicePlaying) {
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
}
|
||||
_closeMenu() {
|
||||
this.mode = MODE.STORY;
|
||||
this.floor.interrupt();
|
||||
this.audio.play('sfx/ui_confirm');
|
||||
this.audio.play('bgm/dark_drone', { fadeMs: 800 }); // 关菜单淡回主题 drone
|
||||
this.guide.state('explore');
|
||||
this.playNode(this.nodeId); // 回到故事,重播当前节点
|
||||
this.audio.play('bgm/dark_drone', { fadeMs: 800 });
|
||||
this.playNode(this.nodeId);
|
||||
}
|
||||
|
||||
restart() {
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
_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;
|
||||
this.mode = MODE.STORY;
|
||||
this.audio.play('sfx/ui_confirm');
|
||||
this.audio.play('bgm/dark_drone'); // 从菜单重开时切回主题(结局重开为同首不重启)
|
||||
this.guide.state('explore');
|
||||
this.playNode('n00');
|
||||
}
|
||||
|
||||
_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;
|
||||
|
||||
Reference in New Issue
Block a user