/** * 《声命线》——末世互动广播剧(Web 移植版,忠实对齐 Android StorylineGame.kt)。 * * 手势映射(12 号文档 §1 裁定): * - tap :voice 播放中 = 跳过本句(audio.skipVoice,链继续走下一句; * 抉择提示 p* 播放中不可跳,盲玩公平性);无语音时 = 继续剧情; * 结局后 = 重新开始 * - doubletap :重听当前节点的全部台词 * - left / right :在选择节点做抉择(提示音播完后生效;播放中会缓存 pendingChoice) * - up :听操作说明 + 当前进度,随后自动重播当前剧情 * - down :呼出菜单(左滑重新开始,右滑/单击返回,上滑听帮助) * * 状态 BGM(10 号文档 §2):剧情/结局 = bgm/dark_drone 不切;菜单 = bgm/menu * (进菜单 crossfade 500ms,关菜单淡回 dark_drone 800ms)。 * * 机制保留:语音链 onComplete 串行 + seqToken 打断作废、pendingChoice 缓存、 * 剧情标记(companion/knowTrap/betrayed)、虚拟节点 resolve、e1 彩蛋、4 结局、 * 单句看门狗(VOICE_WATCHDOG_MS 超时自动续链,voice 资产缺失不卡死—— * 13 号文档 §1 边界断言)。 * * 与 Android 差异:sfx 预热用 prewarm(提前解码)代替音量 0 假播(Web 引擎 * 按需解码 + AudioBuffer 缓存,见 docs/web-engine-api.md)。 */ import { Game } from '../engine/game.js'; import { Guide } from '../engine/guide.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: node(['voice/n00_intro'], { next: 'n01' }), n01: node(['voice/n01_wake'], { next: 'n02', sfx: 'sfx/heartbeat' }), 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 = { STORY: 'STORY', MENU: 'MENU' }; /** 单句语音看门狗:超时未 onComplete 视为资产缺失,自动推进链(不卡死)。 */ const VOICE_WATCHDOG_MS = 30000; /** 抉择提示音 id(voice/p1~p8):播放中单击不可跳过(12 号文档 §1.2)。 */ const PROMPT_RE = /^voice\/p\d/; // 向导层(14 号文档 + round2 synthesis F1.1):剧情类等待默认低频——25s 轻提醒 // +gd_continue,50s 升级只重申手势(gd_again),**永不重播 help/进度/当前节点**; // 同一等待点最多 2 次提示后彻底静默(guide.js 升级链用尽自动停止) const GUIDE_WATCHDOG_AT = [25000, 50000]; export class StorylineGame extends Game { constructor(audio, root) { super(audio, root); this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op) 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.companion = false; // 带上了小满 this.knowTrap = false; // 装死时听到北区陷阱的情报 this.betrayed = false; // 向猎狗交出过坐标 } // ---------------------------------------------------------------- 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)); 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; } } onMenuGesture(g) { 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; }); } break; } } // ---------------------------------------------------------------- 剧情驱动 playNode(id) { const node = NODES[id]; if (!node) { console.error('[StorylineGame] 未知节点:', id); return; } this.nodeId = id; this.choiceArmed = false; this.pendingChoice = null; this.awaitingRestart = false; if (node.sfx) this.audio.play(node.sfx); const seq = [...node.voices]; // F8 开场瘦身(round2):操作口令 gd_brief(≤8s)前置到开场链最前, // 「brief→intro」;原 intro 长句里的操作教学资产保留不动。 // 向导句只在 full 档插播(off/standard 保持纯剧情链)。 if (id === 'n00' && this.guide.level() === 'full') seq.unshift('voice/gd_brief'); // 开场教学:intro 早期追加一次"单击可跳过旁白"提示(每局只播一次,重开不重播) 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() { if (this.awaitingRestart) return; this.seqToken++; this.audio.stopVoice(); // 重播当前节点(不重复进入音效之外的状态重置) const node = NODES[this.nodeId]; if (!node) return; this.choiceArmed = false; this.pendingChoice = null; const seq = [...node.voices]; if (this.nodeId === 'e1' && this.companion) seq.push('voice/e1_child'); if (node.prompt) seq.push(node.prompt); this.playSeq(seq, () => this.onNodeSeqDone(node)); } /** 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); } // 抉择提示播完即可选(W2 契约):此处不可插任何语音,也不挂看门狗 } else if (node.ending) { this.awaitingRestart = true; } // W1/W3 静默等待看门狗(向导层,round2 F1.1 礼仪重构):25s nudge+gd_continue; // 50s 升级只重申手势(gd_again)——**不再重播 help+进度+当前节点**。两级用尽 // 彻底静默,玩家下一次手势(新节点播完)重新挂载。抉择态与语音播放中不挂。 if (node.prompt == null) { this.guide.watchdog({ arm: true, at: GUIDE_WATCHDOG_AT, say: ['gd_continue', 'gd_again'], cond: () => this.mode === MODE.STORY && !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,超时自动续链(不卡死) const wd = setTimeout(() => { if (advanced || token !== this.seqToken) return; advanced = true; console.warn('[StorylineGame] 语音看门狗超时,自动续链:', ids[i]); step(i + 1); }, VOICE_WATCHDOG_MS); this.audio.play(ids[i], { onComplete: () => { if (advanced || token !== this.seqToken) return; advanced = true; clearTimeout(wd); step(i + 1); }, }); }; step(0); } choose(g) { const node = NODES[this.nodeId]; if (!node || node.left == null || node.right == null) return; this.choiceArmed = false; this.audio.play('sfx/ui_confirm'); // 选择带来的剧情标记(注意此时 nodeId 仍是抉择点,n09 是 n07 右滑的目标) if (this.nodeId === 'n07' && g === 'right') this.knowTrap = true; // 装死听情报 if (this.nodeId === 'n11' && g === 'left') this.companion = true; if (this.nodeId === 'n18' && g === 'left') this.betrayed = true; this.playNode(this.resolve(g === 'left' ? node.left : node.right)); } /** 虚拟目标:根据剧情标记解析到实际节点。 */ resolve(target) { if (target === 'v_culvert') return this.companion ? 'n16b' : 'n16'; if (target === 'v_cable') return this.companion ? 'n17b' : 'n17'; return target; } // ---------------------------------------------------------------- 帮助 / 菜单 / 重开 playHelpAndResume(backToMenu = false) { this.seqToken++; this.audio.stopVoice(); const act = this.actOf(this.nodeId); const status = 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 { this.playNode(this.nodeId); // 重播当前剧情节点 } }); } 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(); } this.mode = MODE.STORY; this.audio.play('sfx/ui_confirm'); this.audio.play('bgm/dark_drone', { fadeMs: 800 }); // 关菜单淡回主题 drone this.guide.state('explore'); this.playNode(this.nodeId); // 回到故事,重播当前节点 } restart() { this.seqToken++; this.audio.stopVoice(); this.companion = false; this.knowTrap = false; this.betrayed = false; this.mode = MODE.STORY; this.audio.play('sfx/ui_confirm'); this.audio.play('bgm/dark_drone'); // 从菜单重开时切回主题(结局重开为同首不重启) this.guide.state('explore'); this.playNode('n00'); } actOf(id) { 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; } }