/** * 《声命线》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; } }