/** * 《暗码》——1940 密战叙事 RPG(docs/design/19-cipher.md)。 * * 你是电讯处夜班报务员、双重间谍「耳机」:一晚四封密电过你的手,每个词怎么译、 * 报不报、报给谁,都由你按下。9 个抉择点(4 译词 3 选 + 5 叙事 2 选)、4 结局、 * 跨电文标量:expose 暴露度 / hints 密码本余页 / t1..t4 译文认知 / p2..p9 抉择。 * * 6 宏状态(§3.1):INTRO / RX / DECODE / RELAY / NIGHT / ENDING + MENU 覆盖层 * (另加 ASK = 有存档进游戏的「继续上次?」询问态)。核心增量 = DECODE 解码小局: * 电文摩尔斯由运行时程序合成模块(morse.js)排程 oscillator,不走音频文件 * (时值精确是玩法命根,§2.1);三层兜底 = 正常译(候选对照预听)/ 慢放(双击第 2 次 * 0.5×,无限免费)/ 密码本提示直译辨析位(3 页,T1 首次免费)。 * * 手势(§3.3,唯一豁免 E1:DECODE 空闲长按=翻密码本,与引擎快进时间互斥,detective E2 * 同款):tap=跳句/继续/两段确认;doubletap=重听电文(再双击=慢放);left/right=切候选 * (DECODE)/抉择(RELAY);up=状态链(状态在前帮助在后);down=模态菜单;longpress= * 引擎快进(DECODE 空闲=翻密码本 E1)。菜单 = 单击循环 + 双击确认 + 上滑帮助 + 下滑关闭 * (00 号 §5.2 模态约定)。 * * 双通道仲裁(§3.4 R1-R5):摩尔斯只在 !isVoicePlaying() 时起振(morseGate 排队补发, * abyss sonarGate 同款);摩尔斯期间 BGM −6dB(游戏层 _bgmBus 增益,R3);T4 揭晓 * danger 拍(step_halt 前置 2s 渐强 + danger 签名 + danger 震);摩尔斯不受 * settings.speed/长按快进影响(u 恒定,边界登记)。 * * 判定出口(DECODE 第 2 击确认)、NIGHT 链、d9 结果链、e* 结局链入口处盖 * _settleGraceUntil = now+800ms 手势死窗(窗内手势一律吞掉,dungeon H1/abyss 同款); * 电文摩尔斯首播期间玩法手势 = beat 轻震(首播保护窗,G4,不判无效不惩罚)。 * * 资产缺失兜底:引擎 console.error 静默跳过且不回调 onComplete;单句语音看门狗 30s 且 * isVoicePlaying()===false 才自动续链(radio N1 先例);摩尔斯为运行时合成零文件依赖。 * * 存档(§8.1):PUT /api/progress {game:'cipher'} data 必带 summary;断点续玩 DECODE 态 * 恢复 = prog_t → book 档 → gd_tune2 → 重挂浏览 + 自动预听当前候选(一句话 ≤5s 回到可玩)。 */ import { Game } from '../engine/game.js'; import { Guide } from '../engine/guide.js'; import { vibrate } from '../engine/haptics.js'; import { MorsePlayer, MORSE_PRESET } from './morse.js'; // ---------------------------------------------------------------- 电文数据(§2.2 信息单源) /** 四封电文:真词/候选/辨析位写死;cand=词义句,rev=揭晓句,hint=密码本句。 */ const TELS = [ { word: 'BAY', trueId: 'bay', rx: 'rx1_in', ask: 'dv1_ask', hint: 'voice/hint_t1', cands: [ { id: 'bay', word: 'BAY', cand: 'voice/cand_bay', rev: 'voice/dv1_right' }, { id: 'day', word: 'DAY', cand: 'voice/cand_day', rev: 'voice/dv1_wrong' }, { id: 'ray', word: 'RAY', cand: 'voice/cand_ray', rev: 'voice/dv1_wrong' }, ], }, { word: 'TOP', trueId: 'top', rx: 'rx2_in', ask: 'dv2_ask', hint: 'voice/hint_t2', cands: [ { id: 'top', word: 'TOP', cand: 'voice/cand_top', rev: 'voice/rev_top' }, { id: 'tap', word: 'TAP', cand: 'voice/cand_tap', rev: 'voice/rev_tap' }, { id: 'tip', word: 'TIP', cand: 'voice/cand_tip', rev: 'voice/rev_tip' }, ], }, { word: 'HAND', trueId: 'hand', rx: 'rx3_in', ask: 'dv3_ask', hint: 'voice/hint_t3', cands: [ { id: 'hand', word: 'HAND', cand: 'voice/cand_hand', rev: 'voice/rev_hand' }, { id: 'sand', word: 'SAND', cand: 'voice/cand_sand', rev: 'voice/rev_sand' }, { id: 'land', word: 'LAND', cand: 'voice/cand_land', rev: 'voice/rev_land' }, ], }, { word: 'SPY', trueId: 'spy', rx: 'rx4_in', ask: 'dv4_ask', hint: 'voice/hint_t4', cands: [ { id: 'spy', word: 'SPY', cand: 'voice/cand_spy', rev: 'voice/rev_spy' }, { id: 'say', word: 'SAY', cand: 'voice/cand_say', rev: 'voice/rev_say' }, { id: 'sly', word: 'SLY', cand: 'voice/cand_sly', rev: 'voice/rev_sly' }, ], }, ]; const TEL_LABEL = ['船期', '名单', '交接', '处置令']; // ---------------------------------------------------------------- 节点表 /** 叙事节点。voices 顺序播放;prompt 非空 = 抉择节点(播完即选,W2 契约)。 */ function N(voices, opts = {}) { return { voices, prompt: null, left: null, right: null, next: null, sfx: null, dyn: null, auto: false, danger: false, preMs: 0, ...opts, }; } /** 抉择节点:本体无台词,链尾接 p* 提示句(PROMPT_RE 命中 → 不可跳)。 */ function P(prompt, left, right) { return { voices: [], prompt, left, right, next: null, sfx: null, dyn: null, auto: false, danger: false, preMs: 0, }; } /** * 全部节点(id = 首句 voice id;台词全文见 19-cipher-voice-ids.md,139 句)。 * '@morse:k' = 电文摩尔斯首播(RX 链内,不可跳);'@rx:k'/'@night:k'/'@decode'/ * '@signoff'/'@ending'/'@p4'/'@p8'/'@p9'/'@d9e' = 过场转移,由 advance() 解析。 */ const NODES = { // ---- INTRO 开场链(夜班开场 + 一句规矩 + 今晚目标 + 处长夜令;自动流) b_open: N(['voice/b_open'], { auto: true, next: 'b_rule' }), b_rule: N(['voice/b_rule'], { auto: true, next: 'b_goal' }), b_goal: N(['voice/b_goal'], { auto: true, next: 'b_chief' }), b_chief: N(['voice/b_chief'], { sfx: 'sfx/tea_cup', next: '@rx:1' }), // ---- RX 截获链(引入 + 调谐 + 电文摩尔斯首播 + 提示句;全部 auto,首播不可跳) rx1_in: N(['voice/rx1_in'], { sfx: 'sfx/tune_seek', next: '@morse:1' }), rx2_in: N(['voice/rx2_in'], { sfx: 'sfx/tune_seek', next: '@morse:2' }), rx3_in: N(['voice/rx3_in'], { sfx: 'sfx/tune_seek', next: '@morse:3' }), rx4_in: N(['voice/rx4_in'], { sfx: 'sfx/tune_seek', next: '@morse:4' }), dv1_teach: N(['voice/dv1_teach'], { auto: true, next: 'dv1_teach2' }), dv1_teach2: N(['voice/dv1_teach2'], { auto: true, next: 'dv1_ask' }), dv1_ask: N(['voice/dv1_ask'], { next: '@decode' }), dv2_ask: N(['voice/dv2_ask'], { next: '@decode' }), dv3_ask: N(['voice/dv3_ask'], { next: '@decode' }), dv4_ask: N(['voice/dv4_ask'], { next: '@decode' }), // ---- T1 船期·教学(译对 dv1_right 进 p2;译错 dv1_wrong 即时归因回浏览无限重选) dv1_right: N(['voice/dv1_right'], { next: 'p2' }), dv1_wrong: N(['voice/dv1_wrong'], { next: null }), // 教学归因:播完回浏览(代码特判) p2: P('voice/p2', 'r1a_send', 'r1b_hold'), r1a_send: N(['voice/r1a_send'], { sfx: 'sfx/key_send', next: 'r1a_e' }), r1a_e: N(['voice/r1a_e'], { next: '@night:1' }), r1b_hold: N(['voice/r1b_hold'], { next: 'r1b_e' }), r1b_e: N(['voice/r1b_e'], { next: '@night:1' }), n1: N(['voice/n1'], { auto: true, next: '@rx:2' }), // ---- T2 名单(正片起译错不揭穿:rev_* 按认知播;p4 known/blind 差分) rev2: N([], { dyn: 'rev2', next: '@p4' }), p4_known: P('voice/p4_known', 'r2a_act', 'r2b_act'), p4_blind: P('voice/p4_blind', 'r2a_act', 'r2b_act'), r2a_act: N(['voice/r2a_act'], { next: 'r2a_e' }), r2a_e: N(['voice/r2a_e'], { next: '@night:2' }), r2b_act: N(['voice/r2b_act'], { next: 'm2_chief' }), m2_chief: N(['voice/m2_chief'], { sfx: 'sfx/pen_scratch', next: 'r2b_e' }), r2b_e: N(['voice/r2b_e'], { next: '@night:2' }), n2: N(['voice/n2'], { auto: true, next: '@rx:3' }), // ---- T3 交接(p6 右 r3b_e_ok/no 由 t3 差分) rev3: N([], { dyn: 'rev3', next: 'p6' }), p6: P('voice/p6', 'r3a_go', 'r3b_act'), r3a_go: N(['voice/r3a_go'], { next: 'r3a_e' }), r3a_e: N(['voice/r3a_e'], { next: '@night:3' }), r3b_act: N(['voice/r3b_act'], { next: 'r3bsel' }), r3bsel: N([], { dyn: 'r3b', next: '@night:3' }), n3: N(['voice/n3'], { auto: true, next: '@rx:4' }), // ---- T4 处置令·高潮(rev_spy = 全作唯一 danger 拍:step_halt 前置 2s + 签名 + 震) rev4: N([], { dyn: 'rev4', next: '@p8' }), p8_known: P('voice/p8_known', 'r4a_feed', 'r4b_act'), p8_blind: P('voice/p8_blind', 'r4a_feed', 'r4b_act'), r4a_feed: N(['voice/r4a_feed'], { sfx: 'sfx/key_send', next: 'r4a_e' }), r4a_e: N(['voice/r4a_e'], { next: '@signoff' }), r4b_act: N(['voice/r4b_act'], { next: 'm4_chief_c' }), m4_chief_c: N(['voice/m4_chief_c'], { next: 'r4b_e' }), r4b_e: N(['voice/r4b_e'], { next: '@signoff' }), // ---- 签收(g9_in → g9_knot → ◆p9〔known/blind〕→ d9 结果链 → ENDING) g9_in: N(['voice/g9_in'], { auto: true, next: 'g9_knot' }), g9_knot: N(['voice/g9_knot'], { auto: true, next: '@p9' }), p9_known: P('voice/p9_known', 'd9_send', 'd9_sleep'), p9_blind: P('voice/p9_blind', 'd9_send', 'd9_sleep'), d9_send: N(['voice/d9_send'], { sfx: 'sfx/key_send', next: '@d9e' }), d9_send_e: N(['voice/d9_send_e'], { auto: true, next: '@ending' }), d9_caught_e: N(['voice/d9_caught_e'], { auto: true, danger: true, next: '@ending' }), d9_sleep: N(['voice/d9_sleep'], { auto: true, next: 'd9_sleepe' }), d9_sleepe: N(['voice/d9_sleepe'], { auto: true, next: '@ending' }), }; const MENU_ITEMS = [ { id: 'resume', voice: 'voice/sys_menu_resume' }, // 回到电波 { id: 'book', voice: 'voice/sys_menu_book' }, // 翻密码本(长按捷径冗余入口,P4) { id: 'restart', voice: 'voice/sys_menu_restart' }, // 重新开始今晚 { id: 'lobby', voice: 'voice/sys_menu_lobby' }, // 保存并回大厅 ]; /** 4 结局(§4.4:p9 前 expose ≤4/≥5 × p9 左/右)。 */ const ENDINGS = { e1: { name: '最后一电', voices: ['voice/e1_a', 'voice/e1_b'], sfx: 'sfx/dawn_bell' }, e2: { name: '值完这个班', voices: ['voice/e2_a', 'voice/e2_b'], sfx: 'sfx/dawn_bell' }, e3: { name: '孤灯', voices: ['voice/e3_a', 'voice/e3_b'], sfx: 'sfx/lock_click' }, e4: { name: '长夜', voices: ['voice/e4_a', 'voice/e4_b'], sfx: 'sfx/lock_click' }, }; const ALL_SFX = [ 'sfx/tune_seek', 'sfx/static_bed', 'sfx/amb_room', 'sfx/paper_page', 'sfx/key_send', 'sfx/pen_scratch', 'sfx/tea_cup', 'sfx/step_halt', 'sfx/lock_click', 'sfx/dawn_bell', 'sfx/ui_confirm', 'sfx/ui_menu', ]; const SAVE_KEY = 'soundgame.progress.cipher'; const AUTH_KEY = 'soundgame.auth'; /** 抉择提示音 id(voice/p2..p9):播放中单击不可跳(盲玩公平,12 号 §1.2)。 */ const PROMPT_RE = /^voice\/p[2-9]/; /** 单句语音看门狗:超时且引擎确认 voice 未在播 = 资产缺失才续链(radio N1 先例)。 */ const VOICE_WATCHDOG_MS = 30000; // 向导层看门狗(§7.2;full 档 say 由 guide 播,standard 档 onEscalate 游戏层补播) const GUIDE_WATCHDOG_AT = [25000, 50000]; // full:叙事链末 / ENDING 结算等待 const IDLE_HINT_AT = [13000, 30000]; // standard const CHOICE_HINT_AT = [25000, 50000]; // 抉择点:两级重播当前 p* 提示句本身 const TUNE_WATCHDOG_AT = [10000, 25000]; // DECODE 浏览两级**不同句**(gd_tune1 ≠ gd_tune2) const PROG_WATCHDOG_AT = [60000, 120000]; // 无进展轨(仅 DECODE 浏览一个可饿死点) const MORSE_GATE_POLL_MS = 150; // morseGate 句间轮询补发间隔(abyss sonarGate 同款) const SETTLE_GRACE_MS = 800; // 判定出口/NIGHT/d9/e* 链手势死窗(dungeon H1 同款) const PREVIEW_GAP_MS = 500; // 候选摩尔斯 → 词义句的静默一拍(§3.4 R1 ≥500ms) /** 暴露度档位(§5.3,措辞逐值为真;hot 档阈值 5 = p9 收网门,同一源)。 */ function tierOf(expose) { if (expose >= 5) return 'hot'; if (expose >= 3) return 'mid'; if (expose >= 1) return 'low'; return 'calm'; } const TIER_VOICES = { calm: 'voice/exp_calm', low: 'voice/exp_low', mid: 'voice/exp_mid', hot: 'voice/exp_hot', }; const TIER_NAMES = { calm: '平安', low: '疑云', mid: '被盯', hot: '危局' }; /** 密码本账目整句(§5.4 逐值)。 */ const BOOK_VOICES = ['voice/book_0', 'voice/book_1', 'voice/book_2', 'voice/book_3']; /** 上滑状态链「第几封」分档整句(§4.3 数字规避)。 */ const PROG_VOICES = ['voice/prog_t1', 'voice/prog_t2', 'voice/prog_t3', 'voice/prog_t4']; export class CipherGame extends Game { constructor(audio, root) { super(audio, root); this.guide = new Guide(audio); this.morse = new MorsePlayer(audio); this.phase = 'INTRO'; // INTRO|RX|DECODE|RELAY|NIGHT|ENDING|MENU|ASK this.returnPhase = null; // MENU 关闭后回原状态 this.returnNode = null; this.nodeId = 'b_open'; this.tel = 0; // 当前电文号(0=开场) this.endingId = null; this.voicePlaying = false; this.currentVoiceId = null; this.promptPlaying = false; this.choiceArmed = false; this.pendingChoice = null; this.awaitingRestart = false; this.leaving = false; this.seqToken = 0; // 跨电文标量(§5.1) this.expose = 0; this.hints = 3; this.t = { t1: null, t2: null, t3: null, t4: null }; this.picks = { p2: null, p4: null, p6: null, p8: null, p9: null }; this.hintUsedT1 = false; // T1 首次翻页免费(Prime→Teach 兑现) this.endingPre = 0; // p9 判定前暴露度(结局门用 p9 前值,§5.2) this.endings = {}; this.runs = 0; // DECODE 小局状态 this.decodeTel = 0; // 1-4(= this.tel) this.candIdx = 0; this.confirming = false; // 两段确认第 2 击=定下判定 this.dtCount = 0; // 双击连击计数(任何其他手势重置) this.browseArmed = false; // DECODE 浏览武装(看门狗/手势判据) this.lastAnnouncedTier = 'calm'; // NIGHT 跨档播报基准(开局 expose=0 → calm) this._nightAnnounce = null; // 本次 NIGHT 链尾待播档位句 this._paperSfx = null; // R2 F1:上一封抉择的纸面标记音(NIGHT 链首垫) this.sysSkipShown = false; this.saved = null; this._menuQueue = []; this._helpSeen = new Set(); // 死窗 / 首播保护 this._settleGraceUntil = 0; this._rxGuard = false; // 电文摩尔斯首播中:玩法手势 = beat 轻震(G4) // F4 断点续玩跳句锁(radio 同款) this._resumeGuard = false; this._skipLockId = null; // 双循环垫句柄(sfx 层无 loop/stop,游戏层经 ctx 起源持句柄,radio _ringSrc 先例) this._staticSrc = null; this._ambSrc = null; this.ambLoopEnabled = true; // 无头测试置 false(重触发定时器会让虚拟时钟永不空闲) // morseGate / 预听 / BGM dip this._previewToken = 0; this._morsePending = null; // {kind, word, preset} morseGate 排队(R1) this._gateTimer = null; this.morseLog = []; // 摩尔斯排程记录(playtest 固定字符/时值断言挂点,§9) this._progGen = 0; // 无进展轨代际令牌(残留定时器到点即弃) this._bgmDipMenu = false; // MENU 低响度淡入(§6.1 省一首:同曲低响度) this._bgmDipMorse = false; // R3 摩尔斯期间 −6dB // 看门狗双轨(§7.2):无进展轨计时(仅 DECODE 浏览) this._progFired = [false, false]; this._tuneHinted = 0; // 本等待点已播 gd_tune 级数(双轨去重) this._progTimer = null; this._earconHook = null; // 测试钩子:guide 事件外泄(playtest 断言) } // ---------------------------------------------------------------- Game start() { this.audio.play('bgm/radio_room'); // 开局淡入 800ms(bgm=0 时引擎只记意图) this.audio.prewarm(ALL_SFX); this.guide.setEventHook((ev) => { if (this._earconHook) this._earconHook(ev); }); const local = this._loadLocal(); if (local && local.phase && local.node) { this._askContinue(local); return; } let auth = null; try { auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); } catch (_) { /* 静默 */ } if (!(auth && auth.token)) { this.newNight(); return; } let settled = false; const goFresh = () => { if (settled) return; settled = true; this.newNight(); }; fetch('/api/progress?game=cipher', { headers: { Authorization: `Bearer ${auth.token}` } }) .then((r) => (r.ok ? r.json() : null)) .then((j) => { if (settled) return; settled = true; const d = j && j.data; if (d && d.phase && d.node) this._askContinue(d); else goFresh(); }) .catch(goFresh); setTimeout(goFresh, 1500); } stop() { this.seqToken++; // 作废一切在途链回调(radio 实现修复 #6 同款 + 摩尔斯排程) this.morse.cancel(); this._clampMorseLog(); this._stopStatic(); this._stopAmb(); this._cancelProg(); this._cancelGate(); if (this.restTimer) { clearTimeout(this.restTimer); this.restTimer = null; } this._resumeGuard = false; this._skipLockId = null; this.audio.stopAll(); } onGesture(g) { // 结算死窗:判定出口/NIGHT/d9/e* 链入口 800ms 内手势一律吞掉(dungeon H1 同款) if (this._settleGraceUntil && performance.now() < this._settleGraceUntil) return; this.guide.disarm(); // 任何手势复位看门狗(guide=off 时 no-op) switch (this.phase) { case 'ASK': this._onAskGesture(g); break; case 'MENU': this._onMenuGesture(g); break; case 'INTRO': case 'RX': case 'RELAY': this._onStoryGesture(g); break; case 'DECODE': this._onDecodeGesture(g); break; case 'NIGHT': this._onNightGesture(g); break; case 'ENDING': this._onEndingGesture(g); break; default: break; } } // ---------------------------------------------------------------- 手势:ASK _onAskGesture(g) { const go = g === 'tap' || g === 'right'; if (g === 'tap') this._resume(this.saved); else if (g === 'right') this.newNight(); else if (g === 'longpress' || g === 'longpressend' || g === 'tapcancel') { /* 引擎快进 */ } else this.guide.error({ voice: 'gd_invalid', onceKey: 'cipher_invalid' }); if (!go && this.phase === 'ASK' && !this.voicePlaying) this._armIdleWatchdog('ASK'); } // ---------------------------------------------------------------- 手势:INTRO / RX / RELAY _onStoryGesture(g) { const node = NODES[this.nodeId]; if (!node) return; switch (g) { case 'tap': if (this._rxGuard) { vibrate('beat'); break; } // 首播保护窗:beat 轻震(G4) if (this.voicePlaying) { if (!this.promptPlaying && !this._isTailSentence() && this.currentVoiceId !== this._skipLockId) { this.audio.skipVoice(); } else { vibrate('beat'); // G4:不可跳句上的空点单击补 beat 签名 } } else if (node.prompt != null && this.choiceArmed) { this.armChoiceHint(); // 抉择等待态空点 = 重新挂载兜底(25s 计时重置) } else if (node.next != null) { this._touchProgress(); this.advance(node.next); } break; case 'doubletap': this.replayNode(); break; case 'left': case 'right': { if (this._rxGuard) { vibrate('beat'); break; } // 首播保护窗 if (node.prompt == null) { this.guide.error({ voice: 'gd_invalid', onceKey: 'cipher_invalid' }); if (this._inStoryWait()) this._armIdleWatchdog(this.phase); 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; default: if (this._inStoryWait()) this._armIdleWatchdog(this.phase); break; } } /** INTRO/RX/RELAY「链末等待单击」判据(非抉择节点、有后继、非自动流、非过场、无对白)。 */ _inStoryWait() { const node = NODES[this.nodeId]; return !!(node && !this.voicePlaying && node.prompt == null && node.next != null && !node.auto && !this._isTransition(node.next)); } /** F5 链末句保护:当前句是本节点末句且节点把控制权交出时屏蔽跳句(radio 同款)。 */ _isTailSentence() { const node = NODES[this.nodeId]; if (!node || !this.voicePlaying || this.promptPlaying) return false; const seq = this._nodeVoices(this.nodeId); const tail = node.prompt ? node.prompt : (seq.length ? seq[seq.length - 1] : null); if (tail == null || this.currentVoiceId !== tail) return false; if (!node.auto) return true; if (node.next == null || this._isTransition(node.next)) return true; const nx = NODES[node.next]; return !!(nx && nx.prompt != null); } // ---------------------------------------------------------------- 手势:DECODE 解码小局(§2.3) _onDecodeGesture(g) { switch (g) { case 'tap': { this.dtCount = 0; if (this.voicePlaying && !this.confirming) { // 浏览态语音在播(候选词义句/状态链):单击不跳句,等播完 if (PROMPT_RE.test(this.currentVoiceId || '')) vibrate('beat'); break; } if (!this.confirming) { // 第 1 击:进确认 —— cf_confirm + 电文重播原速一遍(候选摩尔斯即掐,R1 不叠语音) this.confirming = true; this._previewToken++; // 作废在途预听 this.morse.cancel(); this._clampMorseLog(); this._clampMorseLog(); const tel = TELS[this.decodeTel - 1]; this._playSeq(['voice/cf_confirm'], () => { this.voicePlaying = false; // 电文重播走 sfx 域,voice 链至此完结(防标志残留) this._morseTx(tel.word, MORSE_PRESET.tx); }); } else { // 第 2 击:定下判定(判定出口 800ms 死窗 → 揭晓链) this.confirming = false; this._settleDecode(); } break; } case 'doubletap': { // 重听电文:第 1 次原速;第 2 次慢放 0.5×(任何其他手势重置计数) this.dtCount += 1; const tel = TELS[this.decodeTel - 1]; this._previewToken++; if (this.dtCount >= 2) this._morseTx(tel.word, MORSE_PRESET.slow); else this._morseTx(tel.word, MORSE_PRESET.tx); break; } case 'left': case 'right': { this.dtCount = 0; if (this.confirming) { // 确认态左右滑 = 取消回浏览(§2.3) this.confirming = false; this.seqToken++; this.audio.stopVoice(); this._armBrowse(); this._requestPreview(); break; } this._switchCandidate(g === 'right' ? 1 : -1); break; } case 'up': { this.dtCount = 0; // 状态链:prog_t → book 档 → exp 档 → sys_help,状态在前(G6),留在浏览 this.seqToken++; this.audio.stopVoice(); this._previewToken++; const ids = [PROG_VOICES[this.decodeTel - 1], BOOK_VOICES[this.hints]]; const tier = TIER_VOICES[tierOf(this.expose)]; this._tierCue(tier); ids.push(tier); this._withHelp(ids, `D:${this.decodeTel}`); this._playSeq(ids, () => { this.voicePlaying = false; if (this.phase === 'DECODE') this._armBrowse(); }); break; } case 'down': this.dtCount = 0; this.openMenu(); break; case 'longpress': // E1 唯一豁免:DECODE 空闲长按 = 翻密码本;语音在播 = 引擎快进(时间互斥) if (!this.audio.isVoicePlaying()) this._useHint(); break; case 'longpressend': case 'tapcancel': break; default: break; } } /** 判定落定(译词):T1 教学即时反馈/无限重选零惩罚;T2-T4 不揭穿直入剧情。 */ _settleDecode() { const tel = TELS[this.decodeTel - 1]; const sel = tel.cands[this.candIdx]; this._settleGraceUntil = performance.now() + SETTLE_GRACE_MS; this.browseArmed = false; this._cancelProg(); this._previewToken++; this.seqToken++; this.audio.stopVoice(); this.morse.cancel(); this._clampMorseLog(); if (this.decodeTel === 1) { this.t.t1 = 'bay'; // T1 恒收敛真词(教学期零惩罚,A5) if (sel.id === 'bay') { vibrate('success'); // 首个正反馈(§10 ≈t+2min) this._touchProgress(); this.phase = 'RELAY'; this._save(); // 写入时机:译词判定落定后 this.playNode('dv1_right'); } else { // 即时真话归因(字母/码写死句内,与 §2.2 电码表一致)→ 回浏览无限重选 this._playSeq(['voice/dv1_wrong'], () => { this.voicePlaying = false; if (this.phase === 'DECODE') { this._armBrowse(); this._requestPreview(); } }); } return; } // 正片:记录认知(t2..t4),译错 +1/封(§5.2 单源事件表);不揭穿,直入 rev_* 剧情 this.t[`t${this.decodeTel}`] = sel.id; if (sel.id !== tel.trueId) this.expose += 1; this._touchProgress(); this.phase = 'RELAY'; if (this.decodeTel === 4) this.audio.play('bgm/closing', { fadeMs: 800 }); // RELAY(4) 起 this._save(); this.playNode(`rev${this.decodeTel}`); } /** 两段确认第 1 击后的电文重播(原速一遍,走 morseGate)。 */ _morseTx(word, preset) { this._requestMorse({ kind: 'tx', word, preset }); } /** * morseGate(§3.4 R1):摩尔斯只在 !isVoicePlaying() 时起振;在播则挂队列表、 * 句间轮询补发(abyss sonarGate 同款)。候选预听链 = 候选摩尔斯 → 静默一拍 * (≥500ms)→ cand_* 词义句(给耳朵留对照时间)。 */ _requestMorse(req) { this._morsePending = req; if (!this.audio.isVoicePlaying()) { this._fireMorse(); return; } if (this._gateTimer) clearTimeout(this._gateTimer); const poll = () => { this._gateTimer = null; if (!this._morsePending) return; if (this.audio.isVoicePlaying()) { this._gateTimer = setTimeout(poll, MORSE_GATE_POLL_MS); return; } this._fireMorse(); }; this._gateTimer = setTimeout(poll, MORSE_GATE_POLL_MS); } _cancelGate() { this._morsePending = null; if (this._gateTimer) { clearTimeout(this._gateTimer); this._gateTimer = null; } } /** R1 账目:在排程被掐断/覆盖时把在途记录的 endMs 收敛到当前时刻(转写零重叠断言口径)。 */ _clampMorseLog() { const t = Math.round(performance.now()); for (const r of this.morseLog) { if (r.endMs > t) r.endMs = t; } } _fireMorse() { const req = this._morsePending; this._morsePending = null; if (!req) return; const ctx = this.audio.ctx; if (!ctx) return; this.morse.cancel(); this._clampMorseLog(); // 被覆盖的在途记录收敛(新排程接管前景) const base = ctx.currentTime; const rec = { kind: req.kind, word: req.word, hz: req.preset.hz, unit: req.preset.unit, letterGapUnits: req.preset.letterGapUnits, atMs: Math.round(base * 1000), endMs: 0, elements: [], }; const onElement = (el) => rec.elements.push({ type: el.type, letter: el.letter, at: el.at }); if (req.kind === 'preview') { const token = ++this._previewToken; const h = this.morse.play(req.word, { ...req.preset, baseSec: base, onElement }); rec.endMs = Math.round(h.endAt * 1000); this.morseLog.push(rec); this._dipBgmForMorse(h.endAt); const waitMs = Math.max(0, (h.endAt - ctx.currentTime) * 1000) + PREVIEW_GAP_MS; setTimeout(() => { if (token !== this._previewToken || this.phase !== 'DECODE') return; if (this.confirming) return; // 确认态不补候选词义句 const candVoice = TELS[this.decodeTel - 1].cands[this.candIdx].cand; this.voicePlaying = true; this.currentVoiceId = candVoice; this.audio.play(candVoice, { onComplete: () => { if (token !== this._previewToken) return; this.voicePlaying = false; this.currentVoiceId = null; }, }); }, waitMs); } else { const h = this.morse.play(req.word, { ...req.preset, baseSec: base, onElement }); rec.endMs = Math.round(h.endAt * 1000); this.morseLog.push(rec); this._dipBgmForMorse(h.endAt); } } /** R3:摩尔斯排程期间 BGM −6dB(游戏层 _bgmBus 增益,dip 到 endAt 自动恢复)。 */ _dipBgmForMorse(endAt) { const ctx = this.audio.ctx; if (!ctx || !this.audio._bgmBus) return; this._bgmDipMorse = true; this._applyBgmGain(); const waitMs = Math.max(0, (endAt - ctx.currentTime) * 1000); setTimeout(() => { this._bgmDipMorse = false; this._applyBgmGain(); }, waitMs + 60); } /** * 菜单低响度(§6.1 省一首:同曲低响度)× 摩尔斯 −6dB(R3)复合 dip。 * 引擎 duck/unduck 独占 _bgmBus(每句对白结束 unduck 回 1.0),游戏层不能直写—— * 在 _bgmBus → _bgmPrefBus 之间插入私有 dip 节点(乘法因子,引擎 duck 不干扰)。 */ _ensureBgmDipNode() { if (this._bgmDipNode || !this.audio._bgmBus || !this.audio._bgmPrefBus || !this.audio.ctx) return; try { this.audio._bgmBus.disconnect(); } catch (_) { /* 未连接 */ } const node = this.audio.ctx.createGain(); node.gain.value = 1; this.audio._bgmBus.connect(node); node.connect(this.audio._bgmPrefBus); this._bgmDipNode = node; } _applyBgmGain() { if (!this.audio._bgmBus) return; this._ensureBgmDipNode(); if (!this._bgmDipNode) return; const v = (this._bgmDipMenu ? 0.4 : 1) * (this._bgmDipMorse ? 0.5 : 1); const g = this._bgmDipNode.gain; g.value = v; // 直写保底(无头 mock 的 setValueAtTime 为 no-op) if (this.audio.ctx) { try { g.setValueAtTime(v, this.audio.ctx.currentTime); } catch (_) { /* 静默 */ } } } /** 左右滑切候选(循环),每切自动预听(候选摩尔斯对照档 → 一拍 → 词义句)。 */ _switchCandidate(delta) { const tel = TELS[this.decodeTel - 1]; this.candIdx = (this.candIdx + delta + tel.cands.length) % tel.cands.length; this._save(); // 候选索引入档(DECODE 中断恢复用) this._requestPreview(); } /** 预听请求(经 morseGate;语音在播则排队补发)。 */ _requestPreview() { const tel = TELS[this.decodeTel - 1]; const cand = tel.cands[this.candIdx]; this._requestMorse({ kind: 'preview', word: cand.word, preset: MORSE_PRESET.cand }); } /** 翻密码本(E1 兑换;账目逐值为真;页尽 = error 震 + 翻空本声零惩罚)。 */ _useHint() { if (this.phase !== 'DECODE') return; const tel = TELS[this.decodeTel - 1]; const free = this.decodeTel === 1 && !this.hintUsedT1; if (!free && this.hints <= 0) { vibrate('error'); this.audio.play('sfx/paper_page'); // 翻空本声,无语音(零静默、不轰炸) return; } this.confirming = false; this._touchProgress(); // 翻密码本 = 真实进展(§7.2 无进展轨) if (free) this.hintUsedT1 = true; else this.hints -= 1; this.seqToken++; this.audio.stopVoice(); this._previewToken++; this.audio.play('sfx/paper_page'); this._playSeq([tel.hint], () => { this.voicePlaying = false; if (this.phase === 'DECODE') { this._armBrowse(); this._requestPreview(); } }); this._save(); } /** 浏览武装:看门狗双轨(§7.2)+ 无进展轨计时起点。 */ _armBrowse() { this.browseArmed = true; this.confirming = false; if (this.guide.level() === 'off') return; // guide=off:零定时器零向导事件 this._tuneHinted = 0; this._progFired = [false, false]; const full = this.guide.level() === 'full'; this.guide.watchdog({ arm: true, at: TUNE_WATCHDOG_AT, say: ['gd_tune1', 'gd_tune2'], onEscalate: (n) => { this._tuneHinted = n; if (full) return; this.audio.play(n === 1 ? 'voice/gd_tune1' : 'voice/gd_tune2', { queue: true }); }, cond: () => this.phase === 'DECODE' && this.browseArmed && !this.voicePlaying, }); this._armProgWatchdog(); } /** 无进展轨(§7.2):纯浏览/无效手势不计进展,60s/120s 必被抢救;语音占用则续约重排,用尽即停。 */ _armProgWatchdog() { this._cancelProg(); const gen = ++this._progGen; // 代际令牌:上一等待点的残留定时器到点即弃(不跨点复活) const t0 = performance.now(); const step = (level, delayMs) => { this._progTimer = setTimeout(() => { this._progTimer = null; if (gen !== this._progGen) return; if (this._progFired[level - 1]) return; if (this.phase !== 'DECODE' || !this.browseArmed) return; // 等待点已过,静默取消 if (this.voicePlaying || this.audio.isVoicePlaying()) { step(level, 2000); // 语音占用:续约重排(绝不因撞期静默丢失) return; } if (this._tuneHinted >= level) return; // 双轨去重:同义级已播 gd_tune* 则跳过 this._progFired[level - 1] = true; this.audio.play(level === 1 ? 'voice/gd_prog1' : 'voice/gd_prog2', { queue: true }); }, Math.max(50, delayMs != null ? delayMs : PROG_WATCHDOG_AT[level - 1] - (performance.now() - t0))); }; step(1); step(2); } _cancelProg() { if (this._progTimer) { clearTimeout(this._progTimer); this._progTimer = null; } } /** 真实进展:译词落定 / 叙事抉择结算 / 节点推进 / 翻密码本(§7.2 定义)。 */ _touchProgress() { this._progFired = [true, true]; // 有真实进展:全程零 prog 播出(不误伤) this._cancelProg(); } // ---------------------------------------------------------------- 手势:NIGHT _onNightGesture(g) { switch (g) { case 'tap': if (this.voicePlaying) this.audio.skipVoice(); // 跳过本句,自动链继续 break; case 'doubletap': this._playNightChain(); break; case 'up': { // 帮助+状态(状态在前),自动链继续(§3.3 NIGHT 行) this.seqToken++; this.audio.stopVoice(); const ids = [PROG_VOICES[Math.min(3, this.tel)], BOOK_VOICES[this.hints]]; const tier = TIER_VOICES[tierOf(this.expose)]; this._tierCue(tier); ids.push(tier); this._withHelp(ids, 'N'); this._playSeq(ids, () => { this.voicePlaying = false; if (this.phase === 'NIGHT') this._playNightChain(); }); break; } case 'down': this.openMenu(); break; case 'left': case 'right': this.guide.error({ voice: 'gd_invalid', onceKey: 'cipher_invalid' }); break; default: break; } } // ---------------------------------------------------------------- 手势:ENDING _onEndingGesture(g) { switch (g) { case 'tap': if (this.awaitingRestart) this.newNight(); // 再来一局(新夜班,runs+1) else if (this.voicePlaying) { if (this.currentVoiceId !== this._skipLockId) this.audio.skipVoice(); else vibrate('beat'); } break; case 'doubletap': this.awaitingRestart = false; this._playEndingChain(); break; case 'up': { this.seqToken++; this.audio.stopVoice(); const def = ENDINGS[this.endingId] || ENDINGS.e1; const tier = TIER_VOICES[tierOf(this.endingPre)]; this._tierCue(tier); const ids = this._withHelp([def.voices[0], tier], `E:${this.endingId}`); this._playSeq(ids, () => { this.voicePlaying = false; if (this.phase === 'ENDING') { this.awaitingRestart = true; this._armIdleWatchdog('ENDING'); } }); break; } case 'down': this.openMenu(); break; case 'left': case 'right': this.guide.error({ voice: 'gd_invalid', onceKey: 'cipher_invalid' }); if (this.awaitingRestart && !this.voicePlaying) this._armIdleWatchdog('ENDING'); break; default: if (this.awaitingRestart && !this.voicePlaying) this._armIdleWatchdog('ENDING'); break; } } // ---------------------------------------------------------------- 手势:MENU(00 号 §5.2 模态约定) _onMenuGesture(g) { switch (g) { case 'tap': this.menuIdx = (this.menuIdx + 1) % MENU_ITEMS.length; this._menuQueue.push(MENU_ITEMS[this.menuIdx].voice); if (!this.voicePlaying) this._drainMenuQueue(); break; case 'doubletap': this._execMenuItem(MENU_ITEMS[this.menuIdx].id); break; case 'up': { this._menuQueue = []; this.seqToken++; this.audio.stopVoice(); const ids = this._withHelp([], 'M'); ids.push('voice/sys_settings', MENU_ITEMS[this.menuIdx].voice); this._playSeq(ids, () => { this.voicePlaying = false; }); break; } case 'down': this.closeMenu(); break; case 'left': case 'right': this.guide.error({}); // 菜单内左右滑无效:error 震(无语音) break; default: break; } } _execMenuItem(id) { this._menuQueue = []; if (id === 'resume') { this.closeMenu(); } else if (id === 'book') { // 「翻密码本」= 长按同效:回 DECODE 执行提示流程(P4 冗余入口) if (this.returnPhase === 'DECODE') { this.closeMenu(); this._useHint(); } else { this.guide.error({ voice: 'gd_invalid', onceKey: 'cipher_invalid_book' }); } } else if (id === 'restart') { this.seqToken++; this.audio.stopVoice(); this.newNight(); } else if (id === 'lobby') { this.leaving = true; this.seqToken++; this.audio.stopVoice(); this._save(); this._playSeq(['voice/sys_menu_lobby'], () => { this.voicePlaying = false; try { if (typeof window !== 'undefined' && window.location) window.location.href = '../'; } catch (_) { /* 无头环境无 location:留档 leaving 标记 */ } }); } } _drainMenuQueue() { if (this.phase !== 'MENU' || this._menuQueue.length === 0) return; const ids = this._menuQueue.splice(0); this._playSeq(ids, () => { this.voicePlaying = false; this._drainMenuQueue(); }); } openMenu() { this.returnPhase = this.phase; this.returnNode = this.nodeId; this.phase = 'MENU'; this.menuIdx = 0; this._menuQueue = []; this.seqToken++; this.audio.stopVoice(); this.morse.cancel(); this._clampMorseLog(); // 停摩尔斯排程(§3.2) this._cancelGate(); this._cancelProg(); this._previewToken++; this._rxGuard = false; this.browseArmed = false; this.audio.play('sfx/ui_menu'); this._bgmDipMenu = true; // 同曲低响度淡入 500ms(§6.1) this._applyBgmGain(); this.guide.state('menu'); this._playSeq(['voice/sys_menu', MENU_ITEMS[0].voice], () => { this.voicePlaying = false; this._drainMenuQueue(); }); } closeMenu() { this.seqToken++; this._menuQueue = []; this.audio.stopVoice(); this._bgmDipMenu = false; this._applyBgmGain(); const back = this.returnPhase || 'INTRO'; this.phase = back; this.audio.play('sfx/ui_confirm'); if (back === 'NIGHT') this.audio.play('bgm/night', { fadeMs: 600 }); else if (back === 'ENDING') this.audio.play('bgm/ending', { fadeMs: 1500 }); else if (back === 'RELAY' && this.tel === 4) this.audio.play('bgm/closing', { fadeMs: 800 }); else if (back !== 'ASK') this.audio.play('bgm/radio_room', { fadeMs: 800 }); this.guide.state(back === 'NIGHT' || back === 'ENDING' ? 'settle' : 'explore'); if (back === 'DECODE') { // 局面复述:重挂浏览并自动预听当前候选(§3.2) this._armBrowse(); this._requestPreview(); } else if (back === 'NIGHT') { this._playNightChain(); } else if (back === 'ENDING') { this._playEndingChain(); } else if (back === 'ASK') { this._playSeq(['voice/sys_continue_ask'], () => { this.voicePlaying = false; this._armIdleWatchdog('ASK'); }); } else { this.playNode(this.returnNode); // INTRO/RX/RELAY:重播当前节点 } } // ---------------------------------------------------------------- 夜班流转 /** 新夜班:重置跨电文标量(结局收集跨夜保留),回 INTRO(sys_new_night 先行)。 */ newNight() { this.runs += 1; this.expose = 0; this.hints = 3; this.t = { t1: null, t2: null, t3: null, t4: null }; this.picks = { p2: null, p4: null, p6: null, p8: null, p9: null }; this.hintUsedT1 = false; this.endingPre = 0; this.tel = 0; this.endingId = null; this.lastAnnouncedTier = 'calm'; this._paperSfx = null; this.leaving = false; this.saved = null; this.choiceArmed = false; this.pendingChoice = null; this.browseArmed = false; this.confirming = false; this.dtCount = 0; this._resumeGuard = false; this._skipLockId = null; this._rxGuard = false; this._settleGraceUntil = 0; this.seqToken++; this.audio.stopVoice(); this.morse.cancel(); this._clampMorseLog(); this._stopStatic(); this._cancelGate(); this._cancelProg(); this.phase = 'INTRO'; this.audio.play('bgm/radio_room'); this.guide.state('explore'); this._playSeq(['voice/sys_new_night'], () => this.playNode('b_open')); } /** RX:截获第 k 封。调谐 + 引入 auto 链,静电底噪 loop 起,进入即存档。 */ enterRX(k) { this.tel = k; this.phase = 'RX'; this._startStatic(); if (k > 1) this.audio.play('bgm/radio_room', { fadeMs: 600 }); // NIGHT 淡回 this.guide.state('explore'); this._save(); // 写入时机:每封 RX 进入 this.playNode(TELS[k - 1].rx); } /** * 电文摩尔斯首播(RX 链内 '@morse:k'):原速 750Hz,auto,不可跳—— * 首播保护窗内玩法手势一律 beat 轻震(§3.3,G4;摩尔斯走 sfx 域无跳句通道)。 * 首播完 guide.turn(签名 + confirm 震 =「轮到你选词」),提示句随后落地。 */ _broadcastMorse(k) { const tel = TELS[k - 1]; const ctx = this.audio.ctx; this._rxGuard = true; this.browseArmed = false; const token = ++this.seqToken; const fire = () => { if (token !== this.seqToken || this.phase !== 'RX') return; const base = ctx ? ctx.currentTime : 0; const rec = { kind: 'broadcast', word: tel.word, hz: MORSE_PRESET.tx.hz, unit: MORSE_PRESET.tx.unit, letterGapUnits: MORSE_PRESET.tx.letterGapUnits, atMs: Math.round(base * 1000), endMs: 0, elements: [], }; const h = this.morse.play(tel.word, { ...MORSE_PRESET.tx, baseSec: base, onElement: (el) => rec.elements.push({ type: el.type, letter: el.letter, at: el.at }), }); rec.endMs = Math.round(h.endAt * 1000); this.morseLog.push(rec); this._dipBgmForMorse(h.endAt); const waitMs = Math.max(0, (h.endAt - base) * 1000); setTimeout(() => { if (token !== this.seqToken) return; this._rxGuard = false; if (this.phase !== 'RX') return; this.guide.turn({ haptic: true }); // 轮到你选词(dv*_ask 随后落地) this._continueRx(k); }, waitMs + 40); }; // morseGate:首播在 RX 链尾(dv*_ask 前无 voice 在播),空闲直发,否则轮询 if (!this.audio.isVoicePlaying()) { fire(); return; } const poll = () => { if (token !== this.seqToken) return; if (this.audio.isVoicePlaying()) { setTimeout(poll, MORSE_GATE_POLL_MS); return; } fire(); }; setTimeout(poll, MORSE_GATE_POLL_MS); } /** 首播后的提示句链(T1 带两句教学),链完进 DECODE 浏览。 */ _continueRx(k) { if (k === 1) this.playNode('dv1_teach'); else this.playNode(TELS[k - 1].ask); } /** 进入 DECODE 浏览:候选索引归零、自动预听候选 1、看门狗双轨武装。 */ _enterDecode() { this.phase = 'DECODE'; this.decodeTel = this.tel; this.candIdx = 0; this.dtCount = 0; this._rxGuard = false; this.guide.state('choice'); this._armBrowse(); this._requestPreview(); // 进浏览自动预听候选 1 } /** NIGHT:夜结算。n_k 过渡句 + 跨档 exp_* 播报(跨档才播,hot 附 warning 震)。 */ enterNIGHT(k) { this.phase = 'NIGHT'; this._stopStatic(); this.guide.state('settle'); this.audio.play('bgm/night', { fadeMs: 600 }); const tier = tierOf(this.expose); if (tier !== this.lastAnnouncedTier) { this.lastAnnouncedTier = tier; this._nightAnnounce = TIER_VOICES[tier]; if (tier === 'hot') vibrate('warning'); // exp_hot 播报位附 warning 震(G2) } else { this._nightAnnounce = null; } this._settleGraceUntil = performance.now() + SETTLE_GRACE_MS; // NIGHT 链入口死窗 this._save(); // 写入时机:NIGHT 进入 this._playNightChain(); } /** NIGHT 链尾待播档位句 + 上一封抉择的纸面标记音(R2 F1:世界因你而动可感知)。 */ _playNightChain() { const nNode = NODES[`n${this.tel}`]; const ids = this._nodeVoices(`n${this.tel}`); if (this._nightAnnounce) ids.push(this._nightAnnounce); if (this._paperSfx) { // 压日志/暗处理/呈报的纸面痕迹在夜结算垫一记(现有 sfx 零新增) this.audio.play(this._paperSfx); this._paperSfx = null; } this.seqToken++; this.audio.stopVoice(); this._playSeq(ids.length ? ids : [], () => { this.voicePlaying = false; if (this.phase !== 'NIGHT') return; if (nNode && nNode.next) this.advance(nNode.next); }); } /** ENDING:e*(expose 档 × p9)→ ep 回响链(逐封点名,flags 单源选播),结算态。 */ enterEnding(forced) { const ending = forced || (this.endingPre <= 4 ? (this.picks.p9 === 'left' ? 'e1' : 'e2') : (this.picks.p9 === 'left' ? 'e3' : 'e4')); this.endingId = ending; this.endings[ending] = true; this.phase = 'ENDING'; this.nodeId = ending; this.awaitingRestart = false; this._stopStatic(); this.guide.state('settle'); this.audio.play('bgm/ending', { fadeMs: 1500 }); // d9 结果链播完后淡入 this._save(); // 写入时机:ENDING 进入 this._playEndingChain(); } _playEndingChain() { const def = ENDINGS[this.endingId] || ENDINGS.e4; if (def.sfx) this.audio.play(def.sfx); // e1/e2 dawn_bell;e3/e4 lock_click(§6.2 承诺绑定) const chain = [...def.voices, 'voice/ep_lead']; // 尾声回响:每一封的译与错都被点名(§4.4 P2;flags 单源,ep_*=播报=存储) chain.push(this.picks.p2 === 'left' ? 'voice/ep_med_ok' : 'voice/ep_med_no'); chain.push(this.picks.p4 === 'left' ? 'voice/ep_list_ok' : 'voice/ep_list_no'); chain.push(this.t.t3 === 'hand' ? 'voice/ep_hand_ok' : 'voice/ep_hand_no'); chain.push(this.t.t4 === 'spy' ? 'voice/ep_spy_ok' : 'voice/ep_spy_no'); // R2 F3:结局收集计数(数字规避分档整句;endings 账含本局,1-4 档) chain.push(`voice/end_c${Math.min(4, Object.keys(this.endings).length)}`); this._settleGraceUntil = performance.now() + SETTLE_GRACE_MS; // e* 链入口死窗 this._playSeq(chain, () => { this.voicePlaying = false; this.awaitingRestart = true; this._armIdleWatchdog('ENDING'); }); } // ---------------------------------------------------------------- 存档续玩 _askContinue(save) { this.saved = save; this.phase = 'ASK'; this._playSeq(['voice/sys_continue_ask'], () => { this.voicePlaying = false; this._armIdleWatchdog('ASK'); }); } /** 断点续玩:恢复 phase+node+全部数值并局面复述(一句话 ≤5s 回到可玩,L5)。 */ _resume(s) { if (!s) { this.newNight(); return; } this.saved = null; this.expose = s.expose | 0; this.hints = s.hints != null ? s.hints : 3; this.t = { t1: null, t2: null, t3: null, t4: null, ...(s.t || {}) }; this.picks = { p2: null, p4: null, p6: null, p8: null, p9: null, ...(s.picks || {}) }; this.hintUsedT1 = !!s.hintUsedT1; this.endingPre = s.endingPre | 0; this.endings = { ...(s.endings || {}) }; this.runs = (s.runs || 0) + 1; // 继续上次同样 +1(统一口径) this.lastAnnouncedTier = tierOf(this.expose); // 恢复不重播跨档句(账目已播过) this.tel = s.tel | 0; this.leaving = false; this.seqToken++; this.audio.stopVoice(); this.morse.cancel(); this._clampMorseLog(); this._stopStatic(); this._resumeGuard = true; // F4:恢复链首句播完才武装跳句 const phase = s.phase; if (phase === 'DECODE' && (s.tel | 0) >= 1 && (s.tel | 0) <= 4) { // DECODE 恢复:prog_t → book 档 → gd_tune2 → 重挂浏览 + 自动预听(§8.1) this.phase = 'DECODE'; this.decodeTel = s.tel; this.candIdx = Math.min(2, s.cand | 0); this._startStatic(); this.guide.state('choice'); this._playSeq( [PROG_VOICES[this.decodeTel - 1], BOOK_VOICES[this.hints], 'voice/gd_tune2'], () => { this.voicePlaying = false; if (this.phase === 'DECODE') { this._armBrowse(); this._requestPreview(); } }, ); } else if (phase === 'NIGHT') { this.phase = 'NIGHT'; this.audio.play('bgm/night', { fadeMs: 600 }); this.guide.state('settle'); this._playNightChain(); } else if (phase === 'ENDING') { this.enterEnding(s.ending && ENDINGS[s.ending] ? s.ending : undefined); } else if (phase === 'RX' && s.tel >= 1) { this.enterRX(s.tel); } else { this.phase = phase === 'RELAY' || phase === 'INTRO' ? phase : 'INTRO'; this.guide.state('explore'); this.playNode(NODES[s.node] ? s.node : 'b_open'); } } // ---------------------------------------------------------------- 节点链驱动 playNode(id) { const node = NODES[id]; if (!node) { console.error('[CipherGame] 未知节点:', id); return; } this.nodeId = id; this.choiceArmed = false; this.pendingChoice = null; this.awaitingRestart = false; if (id === 'b_open' && !this._ambSrc) this._startAmb('sfx/amb_room'); // 值班室夜境 let seq = this._nodeVoices(id); // F8 先例:操作口令 gd_brief 前置开场链最前(仅 full 档) if (id === 'b_open' && this.guide.level() === 'full') seq = ['voice/gd_brief', ...seq]; if (id === 'b_open' && !this.sysSkipShown) { this.sysSkipShown = true; seq = [...seq, 'voice/sys_skip']; } if (node.prompt) seq = [...seq, node.prompt]; // T4 揭晓 danger 拍(§3.4 R4 / §6.2 预警窗纪律):仅 rev_spy 一次——step_halt 前置 // 2s 渐强 + danger 签名/震同窗,危机链整段自动流(radio 危机拍同款);误读分支常规流入。 const rev4Spy = id === 'rev4' && (this.t.t4 || 'spy') === 'spy'; if (node.danger || rev4Spy) { this.guide.state('danger'); vibrate('danger'); if (node.sfx) this.audio.play(node.sfx); if (rev4Spy) this.audio.play('sfx/step_halt'); this.voicePlaying = true; this.currentVoiceId = null; this.promptPlaying = false; const token = ++this.seqToken; setTimeout(() => { if (token !== this.seqToken) return; this._playSeq(this._nodeVoices(id), () => this._onNodeSeqDone(node, this._nodeVoices(id))); }, rev4Spy ? 2000 : (node.preMs || 0)); return; } if (node.sfx) this.audio.play(node.sfx); this._playSeq(seq, () => this._onNodeSeqDone(node, seq)); } /** 节点基础语音链(节点内条件选播:rev2/rev3/rev4/r3bsel/dv1_wrong 归因)。 */ _nodeVoices(id) { const node = NODES[id]; if (!node) return []; if (node.dyn === 'rev2') { const sel = this.t.t2 || 'top'; return [`voice/rev_${sel}`]; } if (node.dyn === 'rev3') { const sel = this.t.t3 || 'hand'; return [`voice/rev_${sel}`]; } if (node.dyn === 'rev4') { const sel = this.t.t4 || 'spy'; return [`voice/rev_${sel}`]; } if (node.dyn === 'r3b') { return [this.t.t3 === 'hand' ? 'voice/r3b_e_ok' : 'voice/r3b_e_no']; } return [...node.voices]; } /** 双击重听当前节点台词(含抉择提示);ENDING 态重听结局链。 */ replayNode() { this.seqToken++; this.audio.stopVoice(); this._previewToken++; if (this.phase === 'ENDING') { this.awaitingRestart = false; this._playEndingChain(); return; } const node = NODES[this.nodeId]; if (!node) return; this.choiceArmed = false; this.pendingChoice = null; this.awaitingRestart = false; const seq = this._nodeVoices(this.nodeId); if (node.prompt) seq.push(node.prompt); if (node.sfx) this.audio.play(node.sfx); this._playSeq(seq, () => this._onNodeSeqDone(node, seq)); } /** '@x' 虚拟目标 = 过场转移(链完自动衔接,§3.2)。 */ _isTransition(target) { return typeof target === 'string' && target.startsWith('@'); } /** * 节点链自然播完的公共收尾:抉择武装 / 自动过场 / 链末等待。 * INTRO/RX 链 auto 流(含电文首播与提示句)直通;RELAY 叙事节点链末等待单击 * (§3.2)并挂 gd_continue/gd_again 看门狗;dv1_wrong 归因回浏览(教学无限重选)。 */ _onNodeSeqDone(node, seq) { this.voicePlaying = false; if (node.dyn === 'rev4' && (this.t.t4 || 'spy') !== 'spy' && node.next === '@p8') { // 误读分支常规流入 p8_blind(danger 拍仅 rev_spy 一次) } if (this.nodeId === 'dv1_wrong') { // T1 译错:即时归因后回浏览(无限重选零惩罚,§2.3) if (this.phase === 'DECODE') { this._armBrowse(); this._requestPreview(); } return; } if (node.prompt != null) { this.choiceArmed = true; // W2 契约:播完当刻即可选,零插播 if (this.pendingChoice) { const g = this.pendingChoice; this.pendingChoice = null; this.choose(g); } else { this.armChoiceHint(); } return; } if (node.next == null) return; if (node.auto || this._isTransition(node.next)) { this.advance(node.next); return; } this._armIdleWatchdog(this.phase); // 链末等待单击(tap=继续) } /** 链完转移:'@x' 虚拟目标 = 过场自动衔接;普通节点 = 播下一节点。 */ advance(target) { if (typeof target === 'string' && target.startsWith('@')) { const [kind, arg] = target.slice(1).split(':'); switch (kind) { case 'rx': this.enterRX(Number(arg)); return; case 'night': this.enterNIGHT(Number(arg)); return; case 'morse': this._broadcastMorse(Number(arg)); return; case 'decode': this._enterDecode(); return; case 'signoff': this.playNode('g9_in'); return; case 'ending': this.enterEnding(); return; case 'p4': this.playNode(this.t.t2 === 'top' ? 'p4_known' : 'p4_blind'); return; case 'p8': this.playNode(this.t.t4 === 'spy' ? 'p8_known' : 'p8_blind'); return; case 'p9': this.playNode(this.t.t4 === 'spy' ? 'p9_known' : 'p9_blind'); return; case 'd9e': this.playNode(this.endingPre <= 4 ? 'd9_send_e' : 'd9_caught_e'); return; default: return; } } this.playNode(target); } /** 抉择:计分(§5.2 单源事件表)→ 存档 → 进结果节点。 */ choose(g) { const node = NODES[this.nodeId]; if (!node || node.prompt == null) return; this.choiceArmed = false; this.audio.play('sfx/ui_confirm'); this._applyChoice(this.nodeId, g); this._touchProgress(); this._save(); // 写入时机:每个抉择结算后 this.advance(g === 'left' ? node.left : node.right); } _applyChoice(nodeId, g) { const L = g === 'left'; const key = nodeId.replace(/_(known|blind)$/, ''); // p4_known/p4_blind → p4(known/blind 差分在提示句) switch (key) { case 'p2': this.picks.p2 = g; if (!L) { this.expose += 1; this._paperSfx = 'sfx/paper_page'; } // 压日志:值机簿翻页痕(F1) break; case 'p4': this.picks.p4 = g; if (L) { this.expose += 1; this._paperSfx = 'sfx/paper_page'; } // 暗处理:抽屉/缺页(F1) else this._paperSfx = 'sfx/pen_scratch'; // 呈报:卷宗落笔(F1) break; case 'p6': this.picks.p6 = g; if (L) this.expose += 2; // 亲自去:值班室空岗一刻钟 break; case 'p8': this.picks.p8 = g; if (L) this.expose += 2; // 反喂:拍假电的机器记录抹不掉 break; case 'p9': this.picks.p9 = g; this.endingPre = this.expose; // 结局判定用 p9 前值(§5.2) if (L) this.expose += 2; // 开机信号进了值机室登记(结算后计入) this._settleGraceUntil = performance.now() + SETTLE_GRACE_MS; // d9 链入口死窗 break; default: break; } } // ---------------------------------------------------------------- 帮助 / 复述 /** G2:exp_hot 整句播报位附 warning 震(含上滑复听)。 */ _tierCue(tierId) { if (tierId === TIER_VOICES.hot) vibrate('warning'); } /** * G1 帮助链去重(radio 同款):同一等待点第 1 次全量口播 sys_help;第 2 次起改 * 「纯音频标记」——beat 轻震 + nudge earcon(复用统一 8 音词表,零新增)。 * @returns {boolean} true = 本次应全量口播 sys_help */ _helpSpokenOnce(waitKey) { if (this._helpSeen.has(waitKey)) { vibrate('beat'); if (this.guide.level() !== 'off') this.guide._playEarcon('nudge'); return false; } this._helpSeen.add(waitKey); return true; } /** G6:状态在前、帮助垫后。 */ _withHelp(ids, waitKey) { if (this._helpSpokenOnce(waitKey)) ids.push('voice/sys_help'); return ids; } /** 上滑:状态(第几封 + 密码本 + 暴露档)在前 + 帮助垫后,随后重播当前节点。 */ _playHelpAndResume() { this.seqToken++; this.audio.stopVoice(); this._previewToken++; const ids = []; if (this.tel >= 1) { ids.push(PROG_VOICES[this.tel - 1]); ids.push(BOOK_VOICES[this.hints]); const tier = TIER_VOICES[tierOf(this.expose)]; this._tierCue(tier); ids.push(tier); } this._withHelp(ids, `S:${this.nodeId}`); this._playSeq(ids, () => { this.voicePlaying = false; if (this.phase === 'INTRO' || this.phase === 'RX' || this.phase === 'RELAY') { this.playNode(this.nodeId); } }); } // ---------------------------------------------------------------- 看门狗 /** 叙事等待点(ASK / 链末 / ENDING 结算):full 25s/50s,standard 13s/30s。 */ _armIdleWatchdog(phaseName) { const full = this.guide.level() === 'full'; this.guide.watchdog({ arm: true, at: full ? GUIDE_WATCHDOG_AT : IDLE_HINT_AT, say: ['gd_continue', 'gd_again'], onEscalate: (n) => { if (full) return; this.audio.play(n === 1 ? 'voice/gd_continue' : 'voice/gd_again', { queue: true }); }, cond: () => this.phase === phaseName && !this.voicePlaying, }); } /** 抉择点静默兜底:25s/50s 重播当前 p* 提示句本身(复述选项 = 合法帮助)。 */ armChoiceHint() { this.guide.watchdog({ arm: true, at: CHOICE_HINT_AT, onEscalate: () => { const node = NODES[this.nodeId]; if (node && node.prompt) this.audio.play(node.prompt, { queue: true }); }, cond: () => this.choiceArmed && !this.voicePlaying && (this.phase === 'RELAY' || this.phase === 'ENDING'), }); } // ---------------------------------------------------------------- 语音链(radio/storyline 范式) /** * 顺序播放一组对白,全部自然播完后回调。被打断时回调作废(seqToken 机制)。 * 单句 30s 看门狗:超时且 isVoicePlaying()===false(资产缺失)才自动续链; * voice 真在播(长句)则续约等待,不得掐断(radio N1 先例照抄)。 */ _playSeq(ids, onDone) { const token = ++this.seqToken; this._skipLockId = this._resumeGuard ? (ids[0] || null) : null; this._resumeGuard = false; this.voicePlaying = true; const step = (i) => { if (token !== this.seqToken) return; if (i >= ids.length) { this.currentVoiceId = null; this.promptPlaying = false; this._skipLockId = null; onDone(); return; } if (i > 0) this._skipLockId = null; // F4:首句播完,武装跳句 this.currentVoiceId = ids[i]; this.promptPlaying = PROMPT_RE.test(ids[i]); if (this.promptPlaying) this.guide.state('choice'); // 选项句本身即 turn cue(W2) if (ids[i] === 'voice/book_1') vibrate('warning'); // R2 F2:密码本只剩最后一页,链内到句即震 let advanced = false; const armWd = () => setTimeout(() => { if (advanced || token !== this.seqToken) return; if (this.audio.isVoicePlaying()) { wd = armWd(); return; } // 长句在播:续约不掐 advanced = true; console.warn('[CipherGame] 语音看门狗超时(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); } // ---------------------------------------------------------------- 环境音(游戏层循环) /** 电台静电底噪 loop(RX/DECODE;buffer.loop=true,§3.4 引擎边界表同款)。 */ _startStatic() { this._staticWant = true; if (this._staticSrc) return; const ctx = this.audio.ctx; if (!ctx) return; this.audio._load('sfx/static_bed').then((buf) => { if (!buf || this._staticSrc || !this._staticWant) return; const src = ctx.createBufferSource(); src.buffer = buf; src.loop = true; const gain = ctx.createGain(); gain.gain.value = 0.55 * (this.audio.sfxVolume != null ? this.audio.sfxVolume : 1); src.connect(gain); gain.connect(this.audio._sfxBus); src.start(); this._staticSrc = { src, gain }; }).catch(() => { /* 资产缺失降级:电文仍可听(摩尔斯零依赖) */ }); } _stopStatic() { this._staticWant = false; if (this._staticSrc) { try { this._staticSrc.src.stop(); } catch (_) { /* 已停止 */ } this._staticSrc = null; } } /** 值班室 amb loop(挂钟+雨+暖炉,INTRO 起全程底噪)。 */ _startAmb(id) { this._ambWant = true; if (this._ambSrc) return; const ctx = this.audio.ctx; if (!ctx) return; this.audio._load(id).then((buf) => { if (!buf || this._ambSrc || !this._ambWant) return; const src = ctx.createBufferSource(); src.buffer = buf; src.loop = true; const gain = ctx.createGain(); gain.gain.value = 0.5 * (this.audio.sfxVolume != null ? this.audio.sfxVolume : 1); src.connect(gain); gain.connect(this.audio._sfxBus); src.start(); this._ambSrc = { src, gain }; }).catch(() => { /* 资产缺失降级 */ }); } _stopAmb() { this._ambWant = false; if (this._ambSrc) { try { this._ambSrc.src.stop(); } catch (_) { /* 已停止 */ } this._ambSrc = null; } } // ---------------------------------------------------------------- 存档(§8.1) /** data 结构照 §8.1;summary 字符串必带(大厅卡片直接显示)。 */ _save() { const k = Object.keys(this.endings).length; let summary; if (this.phase === 'ENDING' && this.endingId) { summary = `已收台·${ENDINGS[this.endingId].name} · 结局${k}/4`; } else { const head = this.tel >= 1 ? `第${this.tel}封·${TEL_LABEL[this.tel - 1]}` : '夜班开场'; summary = `${head} · 暴露:${TIER_NAMES[tierOf(this.expose)]} · 结局${k}/4`; } const data = { schema: 1, updatedAt: new Date().toISOString(), summary, phase: this.phase, node: this.nodeId, tel: this.tel, expose: this.expose, hints: this.hints, t: { ...this.t }, picks: { ...this.picks }, cand: this.candIdx, hintUsedT1: this.hintUsedT1, endingPre: this.endingPre, ending: this.endingId, endings: { ...this.endings }, runs: this.runs, }; try { localStorage.setItem(SAVE_KEY, JSON.stringify(data)); } catch (_) { /* 隐私模式 */ } try { const auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); if (!auth || !auth.token) return; fetch('/api/progress', { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` }, body: JSON.stringify({ game: 'cipher', data }), }).catch(() => { /* 离线静默 */ }); } catch (_) { /* 静默 */ } } _loadLocal() { try { const data = JSON.parse(localStorage.getItem(SAVE_KEY)); if (!data || data.schema !== 1 || !data.phase || !data.node) return null; return data; } catch (_) { return null; } } } export default CipherGame;