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:
xsl
2026-10-01 08:11:12 +08:00
parent d15a9b9635
commit ee9d7efe57
1183 changed files with 3945 additions and 4588 deletions
+495 -328
View File
@@ -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;