/** * 《暗码》R4 —— 1940 密战叙事 RPG,按 docs/design/26-audio-interaction-playbook.md 深度重构。 * * 玩法核心不动(19 号正文):电文生成(4 封固定字符)、摩尔斯运行时合成(morse.js)、 * 暴露度隐藏账本、4 结局、OP(你)/WIND(北风+系统)双音色。交互层整体换成 26 号模式 * (设计单正文见 docs/design/19-cipher.md 附录 C;施工接线与偏差见 docs/design/26-apply-cipher.md): * - 发言权管理器(§4.1):整条语音链 = SPEAKING(BGM 压低跨链、句间不掉回「轮到你」); * 链完 → yieldTurn:交接提示音 → 400ms 后起等待声。摩尔斯排程 = BUSY(手势 busy 回执)。 * - 输入回执 + 输入管道(§4.2):ok/invalid/busy 三件套;防连点 400ms;滑动打断并执行。 * - 新手手势宪法(§4.3):doubleTapWindow=0;双击未解锁按单击;上滑=「我在哪」(阶段 1, * T1 译对解锁)、下滑=菜单(阶段 2,一局结束)、双击=重听电文原速→慢放(阶段 3)。 * **废除** DECODE 双击重听/慢放教学、长按翻密码本(翻页走菜单项或迷茫 L3 演示)。 * - 三档提示(§4.4):每个等待点 full → short → 提示音;主动重听 =「再说一遍:」+ 完整句。 * - 迷茫检测(§4.5):乱点/无效/反复重听 → 三级接管;L3 = 慢放电文(T1 附免费教学页)。 * - 断点复述(§4.12):自动存档 + 回来问继续/重开;DECODE 恢复 = prog_t → book 档 → * 重挂浏览 + 自动预听当前候选。 * - 开场 §4.10:第一句 = 「点一下屏幕。」(critical),砍规则念白(b_rule 停用; * b_open/b_goal/b_chief 移驻 NIGHT(1) 链首补全叙事)。 * * 摩尔斯与语音互斥(morseGate,19 号 §3.4):摩尔斯只在整条语音链外起振(floor.speaking * 为准,句间空隙也不抢发);摩尔斯期间 BGM −6dB(游戏层 _bgmBus dip,R3);u 恒定不受 * settings.speed/快进影响。 * * 状态 × 手势矩阵(新手期,完整版见 19 号附录 C §4): * | 状态 | 单击 | 左/右滑 | 上滑 | 下滑(未解锁) | 双击(未解锁) | 连点 | * |---------------|-----------------|------------------------|-----------|--------------|--------------|------------| * | 游戏在说 | 跳句+ok | 抉择节点跳去选项/否则 invalid | 我在哪 | invalid+解释 | 按单击 | 400ms busy | * | RX 首播摩尔斯 | busy | busy | busy | busy | busy | busy | * | DECODE 浏览 | 进确认(电文重播)| 切候选:预听对照档(ok+摩尔斯)| 状态链 | invalid | (阶段3=重听电文)| 迷茫 | * | DECODE 确认 | 定下(→揭晓) | 取消回浏览 | busy* | invalid | 按单击 | busy | * | 叙事等继续 | 继续+ok | invalid(inv_nosel) | 我在哪 | invalid | (阶段3=重听)| busy | * | 叙事等选择 | 再听选项(hold) | 抉择+ok | 「左右滑。」| invalid | (阶段3=重听)| 迷茫 | * | 夜结算(自动流)| 跳句+ok | invalid | 状态链 | invalid | 重播夜链 | busy | * | 结算 | 新夜班+ok | invalid | 结局+档 | 菜单(阶段2) | 按单击 | 第一下 | * | 菜单 | 确认当前项 | 切项并报名 | 重听说明 | 关闭 | 按单击 | busy | */ 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, markHintSaid, hintSaidRecently } from '../engine/hints.js'; import { GestureLadder } from '../engine/novice.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 非空 = 抉择节点(提示句单独成链,播完即选)。 */ function N(voices, opts = {}) { return { voices, prompt: null, left: null, right: null, next: null, sfx: null, dyn: null, danger: false, ...opts, }; } /** 抉择节点:本体无台词,提示句单独成链(26 号:链完 yieldTurn 抉择等待点)。 */ function P(prompt, left, right) { return { voices: [], prompt, left, right, next: null, sfx: null, dyn: null, danger: false }; } /** * 叙事节点(id = 首句 voice id;台词全文见 19-cipher-voice-ids.md)。 * '@rx:k'/'@night:k'/'@signoff'/'@ending'/'@p4'/'@p8'/'@p9'/'@d9e' = 过场转移,由 advance() 解析。 * 开场四句(b_open/b_goal/b_chief)与 sys_skip 移驻 NIGHT(1) 链首/尾(§4.10 砍规则念白, * b_rule 停用);rx / dv*_ask 各句由 enterRX/_continueRx 直接驱动,不占节点表。 */ const NODES = { // ---- T1 船期·教学(译对 dv1_right 进 p2;译错 dv1_wrong 即时归因回浏览无限重选) dv1_right: N(['voice/dv1_right'], { next: 'p2' }), 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'], { 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'], { 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'], { 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'], { next: 'g9_knot' }), g9_knot: N(['voice/g9_knot'], { 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'], { next: '@ending' }), d9_caught_e: N(['voice/d9_caught_e'], { danger: true, next: '@ending' }), d9_sleep: N(['voice/d9_sleep'], { next: 'd9_sleepe' }), d9_sleepe: N(['voice/d9_sleepe'], { 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'; /** 揭晓/收尾类节点:链首句 critical(判定出口 800ms 手势死窗的 26 号等价物,§10)。 */ const PAYOFF_FIRST = new Set(['dv1_right', 'd9_send', 'd9_send_e', 'd9_caught_e', 'd9_sleep', 'd9_sleepe']); const MORSE_GATE_POLL_MS = 150; // morseGate 句间轮询补发间隔(abyss sonarGate 同款) const PREVIEW_GAP_MS = 500; // 候选摩尔斯 → 词义句的静默一拍(§3.4 R1 ≥500ms) // 等待点静默阈值(19 号附录 C §5:剧情类 13s;抉择 25s;DECODE 10s;确认 10s;开场 6s) const SILENCE = { intro: [6000, 15000], decode: [10000, 25000], confirm: [10000, 20000], choice: [25000, 50000], continue: [13000, 30000], settle: [20000, 45000], ask: [12000, 30000], menu: [20000, 45000], }; /** 暴露度档位(§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); // ---- 26 号六模块(构造并暴露,index.html 用 makeInputPipeline 接线)---- 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) // guide 仅保留 danger 签名 earcon + harness 转写钩子(看门狗/纠错语音全部由等待点接管) this.guide = new Guide(audio); this.guide.setEventHook((ev) => { if (this._earconHook) this._earconHook(ev); }); 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 = null; this.tel = 0; // 当前电文号(0=开场) this.endingId = null; this.choiceArmed = false; // 抉择提示句播完、等待点左右滑 this.promptHeard = false; // 当前抉择提示句已开始播(滑=直接抉择;此前滑=跳去选项) this.awaitingRestart = false; this.leaving = false; this.seqToken = 0; // 游戏层异步流作废令牌(摩尔斯尾跳/夜链推进) this.goalMet = false; // 第一个小目标(T1 译对)已达成 → 解锁上滑(§4.3 阶梯) this.sysSkipShown = false; this._gameDoneCounted = false; // 一局只向阶梯报一次 onGameDone this._earconHook = null; // 测试钩子:guide 事件外泄 // 跨电文标量(§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; // 阶段 3 双击连击计数(第 2 次=慢放 0.5×;其他手势重置) this.browseArmed = false; // DECODE 浏览武装(harness/会话判据) this.lastAnnouncedTier = 'calm'; // NIGHT 跨档播报基准(开局 expose=0 → calm) this._nightAnnounce = null; // 本次 NIGHT 链尾待播档位句 this._paperSfx = null; // R2 F1:上一封抉择的纸面标记音(NIGHT 链首垫) this.saved = null; // 首播保护窗(会话/直调兜底;管道路径由 floor BUSY 拦截给 busy 回执) this._rxGuard = false; // 双循环垫句柄(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, onEnd} morseGate 排队(R1) this._gateTimer = null; this.morseLog = []; // 摩尔斯排程记录(playtest 固定字符/时值断言挂点,§9) this._bgmDipMenu = false; // MENU 低响度淡入(§6.1 省一首:同曲低响度) this._bgmDipMorse = false; // R3 摩尔斯期间 −6dB } // 兼容访问器(旧字段语义 → 26 号发言权层;harness/会话与 describe 判据沿用) /** 语音链在播(整条链含句间空隙,比 audio.isVoicePlaying 更严)。 */ get voicePlaying() { return this.floor.speaking; } /** 当前在播句 id。 */ get currentVoiceId() { return this.floor.currentId(); } // ---------------------------------------------------------------- Game start() { this.audio.play('bgm/radio_room'); // 开局淡入(bgm=0 时引擎只记意图) this.audio.prewarm(ALL_SFX); 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++; // 作废一切在途链回调(摩尔斯尾跳/夜链推进) this.floor.interrupt(); this.morse.cancel(); this._clampMorseLog(); this._stopStatic(); this._stopAmb(); this._cancelGate(); this.audio.stopAll(); } // ---------------------------------------------------------------- 手势(pipeline 之后) /** * pipeline 收口后的游戏处理;返回 'ok' | 'hold' | 'invalid' | 'silent-ok'。 * 语音链中的 tap=跳句、滑动=打断并执行,均由 floor 层统一裁定,到不了这里。 */ onGesture(g) { if (this._rxGuard) return 'silent-ok'; // 首播保护窗(管道路径由 floor BUSY 拦截;直调兜底) switch (this.phase) { case 'ASK': return this._onAskGesture(g); case 'MENU': return this._onMenuGesture(g); case 'DECODE': return this._onDecodeGesture(g); case 'NIGHT': return this._onNightGesture(g); case 'ENDING': return this._onEndingGesture(g); default: return this._onStoryGesture(g); // INTRO / RX / RELAY } } stateName() { if (this.phase === 'MENU') return 'menu'; if (this.phase === 'ASK') return 'ask'; if (this.phase === 'DECODE') return this.confirming ? 'confirm' : 'decode'; if (this.phase === 'ENDING') return this.awaitingRestart ? 'settle' : 'story'; if (this.phase === 'RELAY' && this.choiceArmed) return 'choice'; if (this.phase === 'NIGHT') return 'night'; return 'story'; } invalidHint(state, g) { if (state === 'locked') { if (g === 'up') return 'voice/inv_up_locked'; if (g === 'down') return 'voice/inv_down_locked'; return 'voice/gd_invalid'; } if (g === 'left' || g === 'right') { if (state === 'menu') return null; // 菜单里左右滑=切项(合法) if (state === 'settle') return 'voice/sys_hint_again_full'; return 'voice/inv_nosel'; } return 'voice/gd_invalid'; } /** 离开当前等待点:完成它(清静默计时),防旧计时在后续演出中误触发提示。 */ _detachWp() { if (this.floor.waitPoint) this.floor.settleWaitPoint(true); } // ---------------------------------------------------------------- 手势:ASK _onAskGesture(g) { switch (g) { case 'tap': this._resume(this.saved); return 'ok'; case 'right': this.newNight(); return 'ok'; case 'up': this.floor.speak(['voice/sys_continue_ask'], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'hold'; case 'doubletap': return this._onAskGesture('tap'); default: return 'invalid'; } } // ---------------------------------------------------------------- 手势:INTRO / RX / RELAY _onStoryGesture(g) { // 开场前奏(§4.10):第一下单击 → 状态教学 → 第一封电文;其余手势 invalid // (双击按单击处理——阶段 3 的重听语义在等待点上,开场还没有「上一句」可重听) if (this.phase === 'INTRO' && this.nodeId == null) { if (g === 'tap' || g === 'doubletap') { this._afterFirstTap(); return 'ok'; } return 'invalid'; } const node = NODES[this.nodeId]; switch (g) { case 'tap': { if (!node) return 'invalid'; const wp = this.floor.waitPoint; if (wp && wp.conf.id === 'choice') { wp.requestReplay(); // 抉择点空点 = 主动再听选项(「再说一遍:」+ 完整句) return 'hold'; } if (node.prompt != null && !this.promptHeard) { this._playPrompt(node); return 'ok'; } if (node.next != null) { this.advance(node.next); return 'ok'; } return 'invalid'; } case 'doubletap': { // 阶段 3 解锁的重听(00 号 §5.2):等待点 → 完整句;否则重播当前节点 if (this.phase === 'RX') return 'invalid'; const wp = this.floor.waitPoint; if (wp) { wp.requestReplay(); return 'hold'; } this._replayNode(); return 'ok'; } case 'left': case 'right': { if (!node || node.prompt == null) return 'invalid'; // inv_nosel:现在不用选 if (this.promptHeard) { this.choose(g); return 'ok'; } // 旁白没听完就滑(打断并执行的落点):只跳去选项句,不当场盲选 this._playPrompt(node); return 'ok'; } case 'up': { if (!node) return 'invalid'; if (this.tel < 1) return 'invalid'; this._detachWp(); this.floor.speak(this._statusIds(), { onDone: () => { if (this.phase === 'RX') { this._playRxChain(); return; } // 接回截获链(被上滑打断) if (this.phase !== 'RELAY') return; if (this.choiceArmed) { this._playPrompt(NODES[this.nodeId]); return; } this.playNode(this.nodeId); }, }); return 'ok'; } case 'down': this.openMenu(); return 'ok'; default: return 'invalid'; } } // ---------------------------------------------------------------- 手势:DECODE 解码小局(§2.3) _onDecodeGesture(g) { switch (g) { case 'tap': { this.dtCount = 0; if (this.confirming) { this._settleDecode(); return 'ok'; } // 第 2 击:定下 this._confirmEntry(); // 第 1 击:进确认 return 'ok'; } case 'doubletap': { // 阶段 3(pipeline 已把未解锁双击折算成 tap):重听电文原速;再请求=慢放 0.5× if (this.confirming) { this._settleDecode(); return 'ok'; } this.dtCount += 1; this.confusion.recordReplay(); const tel = TELS[this.decodeTel - 1]; this._previewToken++; this.morse.cancel(); this._clampMorseLog(); this._morseTx(tel.word, this.dtCount >= 2 ? MORSE_PRESET.slow : MORSE_PRESET.tx); return 'hold'; // 电文滴答即内容反馈;等待点仍在(重听没完成这个点) } case 'left': case 'right': { this.dtCount = 0; if (this.confirming) { this._cancelConfirm(); return 'hold'; } // 取消后同步重挂浏览等待点 → hold 保留 this._switchCandidate(g === 'right' ? 1 : -1); // 偏差记录:设计单为 silent-ok(摩尔斯即内容音),但管道无 silent-hold 档, // 为保留等待点(三档提示计数)取 hold(附 ok 回执)。见 26-apply-cipher.md §2。 return 'hold'; } case 'up': { this.dtCount = 0; this._previewToken++; this.morse.cancel(); this._clampMorseLog(); this._cancelGate(); this._detachWp(); this.floor.speak(this._statusIds(), { onDone: () => { if (this.phase === 'DECODE') { this._armBrowse(); this._requestPreview({ auto: true }); // 30s 内刚说过的不原样重复(原则 9) } }, }); return 'ok'; } case 'down': this.dtCount = 0; this.openMenu(); return 'ok'; default: return 'invalid'; } } /** 两段确认第 1 击:cf_confirm → 电文原速重播 + 确认等待点(重播期间即可定下/取消)。 */ _confirmEntry() { this._detachWp(); this.confirming = true; this._previewToken++; this.morse.cancel(); this._clampMorseLog(); this._cancelGate(); const tel = TELS[this.decodeTel - 1]; this.floor.speak(['voice/cf_confirm'], { onDone: () => { if (this.phase !== 'DECODE' || !this.confirming) return; this.floor.yieldTurn(this._confirmWp()); this._morseTx(tel.word, MORSE_PRESET.tx); }, }); } /** 确认态取消:掐电文重播,回浏览并预听当前候选。 */ _cancelConfirm() { this.confirming = false; this.floor.interrupt(); this.morse.cancel(); this._clampMorseLog(); this._cancelGate(); this._armBrowse(); this._requestPreview(); } /** 判定落定(译词):T1 教学即时反馈/无限重选零惩罚;T2-T4 不揭穿直入剧情。 */ _settleDecode() { const tel = TELS[this.decodeTel - 1]; const sel = tel.cands[this.candIdx]; this._detachWp(); this.browseArmed = false; this._previewToken++; this.morse.cancel(); this._clampMorseLog(); this._cancelGate(); if (this.decodeTel === 1) { this.t.t1 = 'bay'; // T1 恒收敛真词(教学期零惩罚,A5) if (sel.id === 'bay') { vibrate('success'); // 首个正反馈(§10 ≈t+2min) this.phase = 'RELAY'; this._save(); const seq = ['voice/dv1_right']; // 完成第一个小目标 → 解锁上滑(§4.3 阶梯阶段 1),教学句挂揭晓链尾 if (!this.goalMet) { this.goalMet = true; const got = this.ladder.onGoal(); if (got) seq.push(this.ladder.teachLine(got)); } this.nodeId = 'dv1_right'; this._speakPayoff(seq, () => this._onNodeSeqDone(NODES.dv1_right)); } else { // 即时真话归因(字母/码写死句内,与 §2.2 电码表一致)→ 回浏览无限重选 this.nodeId = 'dv1_wrong'; this._speakPayoff(['voice/dv1_wrong'], () => { if (this.phase === 'DECODE') this._armBrowse(); }); } return; } // 正片:记录认知(t2..t4),译错 +1/封(§5.2 单源事件表);不揭穿,直入 rev_* 剧情 this.t[`t${this.decodeTel}`] = sel.id; if (sel.id !== tel.trueId) this.expose += 1; this.phase = 'RELAY'; if (this.decodeTel === 4) this.audio.play('bgm/closing', { fadeMs: 800 }); // RELAY(4) 起 this._save(); this.playNode(`rev${this.decodeTel}`); } /** * 揭晓/收尾链:首句 critical(<3s)。判定出口 800ms 手势死窗的 26 号等价物—— * 决定性单击后的抢拍 tap 给 busy 回执,不吞掉整段揭晓(旧死窗是静默吞)。 */ _speakPayoff(ids, onDone) { if (!ids || !ids.length) { if (onDone) onDone(); return; } this.floor.speak([ids[0]], { critical: true, onDone: () => { if (ids.length > 1) this.floor.speak(ids.slice(1), { onDone }); else if (onDone) onDone(); }, }); } /** 左右滑切候选(循环),每切自动预听(候选摩尔斯对照档 → 一拍 → 词义句)。 */ _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;语音在播则排队补发)。auto=true:30s 内说过的不重复。 */ _requestPreview(opts = {}) { const tel = TELS[this.decodeTel - 1]; const cand = tel.cands[this.candIdx]; if (opts.auto && hintSaidRecently(cand.cand)) return; // 原则 9:不原样重复 this._requestMorse({ kind: 'preview', word: cand.word, preset: MORSE_PRESET.cand }); } /** 两段确认后的电文重播 / 阶段 3 重听(原速/慢放),走 morseGate。 */ _morseTx(word, preset, onEnd) { this._requestMorse({ kind: 'tx', word, preset, onEnd }); } /** 摩尔斯与语音互斥的判据:整条链(含句间空隙)都算「语音在播」。 */ _busyVoice() { return this.floor.speaking || this.audio.isVoicePlaying(); } /** * morseGate(§3.4 R1):摩尔斯只在语音链外起振;在播则挂队列表、句间轮询补发 * (abyss sonarGate 同款)。候选预听链 = 候选摩尔斯 → 静默一拍(≥500ms)→ * cand_* 词义句(给耳朵留对照时间)。 */ _requestMorse(req) { this._morsePending = req; if (!this._busyVoice()) { this._fireMorse(); return; } if (this._gateTimer) clearTimeout(this._gateTimer); const poll = () => { this._gateTimer = null; if (!this._morsePending) return; if (this._busyVoice()) { 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) { if (req.onEnd) req.onEnd(); 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 }); 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); if (req.kind === 'preview') { const token = this._previewToken; setTimeout(() => { if (token !== this._previewToken || this.phase !== 'DECODE' || this.confirming) return; if (this.floor.speaking) return; // 提示/状态在播:词义句让路(下次滑词再听) const candVoice = TELS[this.decodeTel - 1].cands[this.candIdx].cand; markHintSaid(candVoice); // 自动重挂(菜单关闭/状态链尾)不原样重复的判据 this.floor.speak([candVoice], { onDone: () => { if (this.floor.waitPoint && this.phase === 'DECODE' && !this.confirming) { this.floor.resumeTurn(); } }, }); }, waitMs + PREVIEW_GAP_MS); } else if (req.onEnd) { setTimeout(() => { req.onEnd(); }, waitMs + 40); // 调用方自带 phase 守卫 } } /** 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 (_) { /* 静默 */ } } } /** 浏览武装:交出 DECODE 等待点(交接提示音 → 等待声 → 三档提示/迷茫检测)。 */ _armBrowse() { this.browseArmed = true; this.confirming = false; this.floor.yieldTurn(this._decodeWp()); } /** 翻密码本(菜单项 / 迷茫 L3 入口;账目逐值为真;页尽 = error 震 + 翻空本声零惩罚)。 */ _useHint() { if (this.phase !== 'DECODE') return 'invalid'; 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 'hold'; // 等待点仍在(翻页失败没完成这个点) } this._detachWp(); this.confirming = false; this._previewToken++; this.morse.cancel(); this._clampMorseLog(); this._cancelGate(); if (free) this.hintUsedT1 = true; // T1 教学首次翻页免费(Prime→Teach) else this.hints -= 1; this.audio.play('sfx/paper_page'); this.floor.speak([tel.hint], { onDone: () => { if (this.phase === 'DECODE') { this._armBrowse(); this._requestPreview({ auto: true }); } }, }); this._save(); return 'ok'; } // ---------------------------------------------------------------- 手势:NIGHT _onNightGesture(g) { switch (g) { case 'tap': return 'invalid'; // 链中 tap=跳句(floor 层);夜链自动流转,无空点等待 case 'doubletap': this._playNightChain(); // 阶段 3:重播夜结算链 return 'ok'; case 'up': { // 状态在前(G6):prog_t → book 档 → exp 档,随后续播夜链 this._detachWp(); this.floor.speak(this._statusIds(), { onDone: () => { if (this.phase === 'NIGHT') this._playNightChain(); }, }); return 'ok'; } case 'down': this.openMenu(); return 'ok'; default: return 'invalid'; } } // ---------------------------------------------------------------- 手势:ENDING _onEndingGesture(g) { switch (g) { case 'tap': if (this.awaitingRestart) { this.newNight(); return 'ok'; } // 新夜班(runs+1) return 'invalid'; case 'doubletap': if (this.awaitingRestart) { this.newNight(); return 'ok'; } this._playEndingChain(); // 阶段 3:重听结局链 return 'ok'; case 'up': { // 结局主句 + 暴露档(§4 结算行);原链被打断即视为听完,直接进结算等待 this._detachWp(); const def = ENDINGS[this.endingId] || ENDINGS.e1; const tier = TIER_VOICES[tierOf(this.endingPre)]; this._tierCue(tier); this.floor.speak([def.voices[0], tier], { onDone: () => { if (this.phase === 'ENDING') { this.awaitingRestart = true; this.floor.yieldTurn(this._settleWp()); } }, }); return 'ok'; } case 'down': this.openMenu(); return 'ok'; default: return 'invalid'; } } // ---------------------------------------------------------------- 手势:MENU(00 号 §5.4 模态约定) _onMenuGesture(g) { const items = MENU_ITEMS; const announce = () => { this.floor.speak([items[this.menuIdx].voice], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); }; switch (g) { case 'left': case 'right': this.menuIdx = (this.menuIdx + (g === 'right' ? 1 : items.length - 1)) % items.length; announce(); return 'hold'; case 'tap': case 'doubletap': return this._execMenuItem(items[this.menuIdx].id); case 'up': // 重听菜单说明 + 设置提示 + 当前项 this.floor.speak(['voice/sys_menu', 'voice/sys_settings', items[this.menuIdx].voice], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'hold'; case 'down': this.closeMenu(); return 'hold'; // 关闭回原状态会同步重挂等待点(如 DECODE 浏览)→ hold 保留 default: return 'invalid'; } } /** 执行菜单项;返回回执档:resume 同步重挂等待点 → hold,其余 ok(语音链接管)。 */ _execMenuItem(id) { if (id === 'resume') { this.closeMenu(); return 'hold'; } else if (id === 'book') { // 「翻密码本」菜单项(P4 冗余入口;长按捷径已废止) if (this.returnPhase === 'DECODE') { this.closeMenu(true); this._useHint(); } else { this.floor.speak(['voice/gd_invalid'], { kind: 'feedback', onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); } } else if (id === 'restart') { this.newNight(); } else if (id === 'lobby') { this.leaving = true; this._detachWp(); this.floor.interrupt(); this._save(); this.floor.speak(['voice/sys_menu_lobby'], { onDone: () => { try { if (typeof window !== 'undefined' && window.location) window.location.href = '../'; } catch (_) { /* 无头环境无 location:留档 leaving 标记 */ } }, }); } return 'ok'; } openMenu() { this.returnPhase = this.phase; this.returnNode = this.nodeId; this.phase = 'MENU'; this.menuIdx = 0; this.floor.interrupt(); this.morse.cancel(); this._clampMorseLog(); this._cancelGate(); this._previewToken++; this._rxGuard = false; this.browseArmed = false; this.confirming = false; this.audio.play('sfx/ui_menu'); this._bgmDipMenu = true; // 同曲低响度(§6.1) this._applyBgmGain(); this.floor.speak(['voice/sys_menu', MENU_ITEMS[0].voice], { onDone: () => this.floor.yieldTurn(this._menuWp()), }); } /** 关闭菜单回原状态。skipArm=true:不重挂浏览(调用方接管,如翻密码本)。 */ closeMenu(skipArm = false) { this.floor.interrupt(); 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 }); if (back === 'DECODE') { // 局面复述:重挂浏览并自动预听当前候选(§3.2;30s 内说过的不重复) this._startStatic(); if (skipArm) return; this._armBrowse(); this._requestPreview({ auto: true }); } else if (back === 'NIGHT') { this._playNightChain(); } else if (back === 'ENDING') { this._playEndingChain(); } else if (back === 'ASK') { this.floor.speak(['voice/sys_continue_ask'], { onDone: () => this.floor.yieldTurn(this._askWp()), }); } else if (back === 'RX') { this._playRxChain(); // RX 无可重播节点:接回截获链 } else if (back === 'INTRO') { this.floor.yieldTurn(this._introWp()); // 开场前奏:仍等第一下单击 } else if (this.returnNode && NODES[this.returnNode]) { this.playNode(this.returnNode); // INTRO/RX/RELAY:重播当前节点 } else { this.newNight(); // 兜底:无有效返回节点(理论上不可达) } } // ---------------------------------------------------------------- 夜班流转 /** * 新夜班:重置跨电文标量(结局收集跨夜保留)。首局第一句 = 「点一下屏幕。」 * (critical,§4.10);此后只播 sys_new_night(做对过的事不教第二遍,原则 11)。 */ 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._nightAnnounce = null; this._paperSfx = null; this.leaving = false; this.saved = null; this.choiceArmed = false; this.promptHeard = false; this.browseArmed = false; this.confirming = false; this.dtCount = 0; this._rxGuard = false; this.goalMet = this.ladder.stage() >= 1; // 跨局记住的解锁不重教 this._gameDoneCounted = false; this.seqToken++; this.floor.interrupt(); this.audio.stopVoice(); this.morse.cancel(); this._clampMorseLog(); this._stopStatic(); this._cancelGate(); this.phase = 'INTRO'; this.nodeId = null; this.audio.play('bgm/radio_room'); if (this.ambLoopEnabled) this._startAmb('sfx/amb_room'); // 值班室夜境(雨+挂钟+暖炉) if (this.runs <= 1) { // 0~3 秒:第一句就让玩家动手(§4.10);指令句 critical:抢拍单击 = busy 回执 this.floor.speak(['voice/sys_first_tap'], { critical: true, onDone: () => this.floor.yieldTurn(this._introWp()), }); } else { this.floor.speak(['voice/sys_new_night'], { onDone: () => this.floor.yieldTurn(this._introWp()), }); } } /** 首次单击之后:教「轮到你了」状态声(原则 2),随即第一封电文进频道。 */ _afterFirstTap() { this.floor.speak(['voice/sys_turn_teach'], { onDone: () => this.enterRX(1), }); } /** RX:截获第 k 封。调谐 + 引入链,静电底噪 loop 起,进入即存档。 */ enterRX(k) { this.tel = k; this.phase = 'RX'; this._startStatic(); if (k > 1) this.audio.play('bgm/radio_room', { fadeMs: 600 }); // NIGHT 淡回 this._save(); // 写入时机:每封 RX 进入 this._playRxChain(); } /** RX 引入链:调谐标记音 + 引入句 → 电文摩尔斯首播(上滑打断后也走这里接回)。 */ _playRxChain() { const k = this.tel; this._detachWp(); this.audio.play('sfx/tune_seek'); this.floor.speak([`voice/${TELS[k - 1].rx}`], { onDone: () => this._broadcastMorse(k) }); } /** * 电文摩尔斯首播(RX 链内):原速 750Hz,不可跳——floor=BUSY,玩法手势一律 * busy 回执(不判无效不惩罚,G4 等价物)。首播完接教学/提示句,链完进 DECODE。 */ _broadcastMorse(k) { const tel = TELS[k - 1]; const ctx = this.audio.ctx; this._rxGuard = true; this.browseArmed = false; this._detachWp(); 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._continueRx(k); }, waitMs + 40); }; // morseGate:首播在引入链尾(语音链已完),空闲直发,否则轮询 if (!this._busyVoice()) { fire(); return; } const poll = () => { if (token !== this.seqToken) return; if (this._busyVoice()) { setTimeout(poll, MORSE_GATE_POLL_MS); return; } fire(); }; setTimeout(poll, MORSE_GATE_POLL_MS); } /** 首播后的提示句链(T1 = 教学句 critical;正片 = dv*_ask),链完进 DECODE 浏览。 */ _continueRx(k) { if (k === 1) { this.floor.speak(['voice/dv1_teach'], { critical: true, onDone: () => this._enterDecode() }); } else { this.floor.speak([`voice/${TELS[k - 1].ask}`], { onDone: () => this._enterDecode() }); } } /** 进入 DECODE 浏览:候选索引归零、交出等待点、自动预听候选 1。 */ _enterDecode() { this.phase = 'DECODE'; this.decodeTel = this.tel; this.candIdx = 0; this.dtCount = 0; this._rxGuard = false; this._save(); this._armBrowse(); this._requestPreview(); // 进浏览自动预听候选 1(新词,必播) } /** NIGHT:夜结算。开场叙事移驻链首 + n_k 过渡句 + 跨档 exp_* 播报(跨档才播)。 */ enterNIGHT(k) { this.phase = 'NIGHT'; this._stopStatic(); 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._save(); // 写入时机:NIGHT 进入 this._playNightChain(); } /** NIGHT 链:T1 夜补全开场叙事(b_rule 停用)→ n_k → 跨档句 → sys_skip(每夜一次)。 */ _playNightChain() { this._detachWp(); const nNode = NODES[`n${this.tel}`]; const ids = this._nodeVoices(`n${this.tel}`); if (this.tel === 1) { ids.unshift('voice/b_open', 'voice/b_goal', 'voice/b_chief'); this.audio.play('sfx/tea_cup'); // b_chief 场标记音(链首近似,floor 链无逐句钩子) } if (this._nightAnnounce) { ids.push(this._nightAnnounce); this._nightAnnounce = null; } if (this.tel === 1 && !this.sysSkipShown) { this.sysSkipShown = true; ids.push('voice/sys_skip'); // 每夜一次:教「单击跳句」 } if (this._paperSfx) { // 压日志/暗处理/呈报的纸面痕迹在夜结算垫一记(现有 sfx 零新增) this.audio.play(this._paperSfx); this._paperSfx = null; } this.floor.speak(ids, { onDone: () => { 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.audio.play('bgm/ending', { fadeMs: 1500 }); this._save(); // 写入时机:ENDING 进入 this._playEndingChain(); } _playEndingChain() { this._detachWp(); 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)}`); // 一局结束 → 阶梯阶段 2/3 解锁教学挂链尾(§4.3;一局只报一次) if (!this._gameDoneCounted) { this._gameDoneCounted = true; const got = this.ladder.onGameDone(); if (got) chain.push(this.ladder.teachLine(got)); } this._speakPayoff(chain, () => { this.awaitingRestart = true; this.floor.yieldTurn(this._settleWp()); }); } // ---------------------------------------------------------------- 存档续玩 _askContinue(save) { this.saved = save; this.phase = 'ASK'; this.floor.speak(['voice/sys_continue_ask'], { onDone: () => this.floor.yieldTurn(this._askWp()), }); } /** 断点续玩:恢复 phase+node+全部数值并局面复述(prog_t → book 档 → 重挂浏览+预听)。 */ _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.goalMet = this.ladder.stage() >= 1; this.leaving = false; this.seqToken++; this.floor.interrupt(); this.morse.cancel(); this._clampMorseLog(); this._stopStatic(); const phase = s.phase; if (phase === 'DECODE' && (s.tel | 0) >= 1 && (s.tel | 0) <= 4) { // DECODE 恢复:prog_t → book 档 → 重挂浏览 + 自动预听(§8.1;gd_tune2 已停用) this.phase = 'DECODE'; this.decodeTel = s.tel; this.candIdx = Math.min(2, s.cand | 0); this._startStatic(); this._save(); this.floor.speak([PROG_VOICES[this.decodeTel - 1], BOOK_VOICES[this.hints]], { onDone: () => { if (this.phase === 'DECODE') { this._armBrowse(); this._requestPreview({ auto: true }); } }, }); } else if (phase === 'NIGHT') { this.phase = 'NIGHT'; this.audio.play('bgm/night', { fadeMs: 600 }); 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 if ((phase === 'RELAY') && NODES[s.node]) { this.phase = 'RELAY'; this.playNode(s.node); } else { this.newNight(); // 兜底:存档不可解析(理论上不可达) } } // ---------------------------------------------------------------- 节点链驱动 playNode(id) { const node = NODES[id]; if (!node) { console.error('[CipherGame] 未知节点:', id); return; } this.nodeId = id; this.promptHeard = false; this.choiceArmed = false; this.awaitingRestart = false; this._detachWp(); const seq = this._nodeVoices(id); // T4 揭晓 danger 拍(§3.4 R4 / §6.2 预警窗纪律):仅 rev_spy 一次——step_halt 前置 // 2s 渐强 + danger 签名/震同窗,危机链整段自动流;误读分支常规流入。 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.floor.state = 'BUSY'; // 演出前置 2s(引擎无公开 API,直置;手势=busy 回执) const token = ++this.seqToken; setTimeout(() => { if (token !== this.seqToken) return; this._speakPayoff(this._nodeVoices(id), () => this._onNodeSeqDone(node)); }, rev4Spy ? 2000 : 0); return; } if (node.sfx) this.audio.play(node.sfx); const done = () => { if (node.prompt != null) this._playPrompt(node); else this._onNodeSeqDone(node); }; if (!seq.length) { done(); return; } if (PAYOFF_FIRST.has(id) || node.dyn) this._speakPayoff(seq, done); else this.floor.speak(seq, { onDone: done }); } /** 只播抉择提示句并交出抉择(旁白被打断跳来这里;W2:播完当刻即可选)。 */ _playPrompt(node) { this._detachWp(); this.promptHeard = true; markHintSaid(node.prompt); // 刚播过:等待点三档提示自动降档(不原样重复,原则 9) this.floor.speak([node.prompt], { onDone: () => { this.choiceArmed = true; this.floor.yieldTurn(this._choiceWp(node)); }, }); } /** 阶段 3 双击:重播当前节点(含抉择提示句)。 */ _replayNode() { const node = NODES[this.nodeId]; if (!node) return; this.playNode(this.nodeId); } /** 节点基础语音链(节点内条件选播:rev2/rev3/rev4/r3bsel 归因)。 */ _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]; } /** '@x' 虚拟目标 = 过场转移(链完自动衔接,§3.2)。 */ _isTransition(target) { return typeof target === 'string' && target.startsWith('@'); } /** * 节点链自然播完的公共收尾:抉择提示句单独成链;auto/过场直通;其余链末交出 * 「继续」等待点(tap=继续,13s/30s 三档提示)。 */ _onNodeSeqDone(node) { if (this.nodeId === 'dv1_wrong') { // T1 译错:即时归因后回浏览(无限重选零惩罚,§2.3) if (this.phase === 'DECODE') this._armBrowse(); return; } if (node.prompt != null) { this._playPrompt(node); return; } if (node.next == null) return; if (this._isTransition(node.next)) { this.advance(node.next); return; } this.floor.yieldTurn(this._continueWp()); } /** 链完转移:'@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 '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._detachWp(); this.choiceArmed = false; this.promptHeard = false; this.audio.play('sfx/ui_confirm'); this._applyChoice(this.nodeId, g); 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(差分在提示句) 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; // 开机信号进了值机室登记(结算后计入) break; default: break; } } // ---------------------------------------------------------------- 状态链(上滑「我在哪」) /** G2:exp_hot 整句播报位附 warning 震(book_1 为链首近似,floor 链无逐句钩子)。 */ _tierCue(tierId) { if (tierId === TIER_VOICES.hot) vibrate('warning'); } /** 上滑状态链(原则 3):第几封 → 密码本余页 → 暴露档(分档整句,逐值为真)。 */ _statusIds() { const n = this.phase === 'DECODE' ? this.decodeTel : Math.min(4, Math.max(1, this.tel)); const book = BOOK_VOICES[this.hints]; const tier = TIER_VOICES[tierOf(this.expose)]; this._tierCue(tier); if (book === 'voice/book_1') vibrate('warning'); // R2 F2:最后一页预警(链首近似) return [PROG_VOICES[n - 1], book, tier]; } // ---------------------------------------------------------------- 等待点(26 号附录 A) _introWp() { return new WaitPoint({ id: 'intro', expected: ['tap'], full: null, // 指令句刚说过:静默第一档直接短句(§4.10 找猫示例同型) short: 'voice/sys_first_tap_short', silenceMs: SILENCE.intro, onDemo: () => { 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: () => { this.floor.speak(['voice/sys_demo_continue'], { onDone: () => { const node = NODES[this.nodeId]; if (node && node.next != null && node.prompt == null) this.advance(node.next); else this.floor.yieldTurn(this._continueWp()); }, }); }, }, { floor: this.floor, confusion: this.confusion }); } _decodeWp() { return new WaitPoint({ id: 'decode', expected: ['left', 'right'], full: 'voice/gd_tune1', short: 'voice/gd_tune1_short', silenceMs: SILENCE.decode, onDemo: () => this._demoDecode(), }, { floor: this.floor, confusion: this.confusion }); } /** 迷茫 L3(§3/§5):T1 = 慢放 + 揭示辨析位(首次免费页);正片 = 慢放电文一遍再交还。 */ _demoDecode() { const tel = TELS[this.decodeTel - 1]; this._detachWp(); this._previewToken++; this.morse.cancel(); this._clampMorseLog(); this._cancelGate(); this.floor.state = 'BUSY'; // 演出中:手势 busy(引擎无公开 API,直置) const free = this.decodeTel === 1 && !this.hintUsedT1; if (free) { this.hintUsedT1 = true; // 教学首次翻页免费(系统自动给一页,§3) this.audio.play('sfx/paper_page'); } this._morseTx(tel.word, MORSE_PRESET.slow, () => { if (this.phase !== 'DECODE') return; if (free) { this.floor.speak([tel.hint], { onDone: () => { if (this.phase === 'DECODE') this._armBrowse(); }, }); } else { this._armBrowse(); } }); } _confirmWp() { return new WaitPoint({ id: 'confirm', expected: ['tap'], full: 'voice/cf_hint_full', short: 'voice/cf_hint_short', silenceMs: SILENCE.confirm, onDemo: () => this._settleDecode(), // 替确认(设计单 §5;迷茫兜底要能推进) }, { 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.floor.speak(['voice/sys_demo_choice'], { onDone: () => this.floor.yieldTurn(this._choiceWp(node)), }); }, }, { floor: this.floor, confusion: this.confusion }); } _settleWp() { return new WaitPoint({ id: 'settle', expected: ['tap'], full: 'voice/sys_hint_again_full', short: 'voice/sys_hint_continue_short', silenceMs: SILENCE.settle, onDemo: () => this.floor.chime(), // 不替玩家开新夜班 }, { floor: this.floor, confusion: this.confusion }); } _askWp() { return new WaitPoint({ id: 'ask', expected: ['tap', 'right'], full: 'voice/sys_continue_ask', short: 'voice/sys_hint_continue_short', silenceMs: SILENCE.ask, onDemo: () => this.floor.chime(), }, { floor: this.floor, confusion: this.confusion }); } _menuWp() { return new WaitPoint({ id: 'menu', expected: ['tap', 'left', 'right'], full: 'voice/sys_menu', short: 'voice/gd_tune1_short', // 「左右滑。」(与 DECODE 短句同文案复用资产) silenceMs: SILENCE.menu, onDemo: () => this.floor.chime(), }, { floor: this.floor, confusion: this.confusion }); } // ---------------------------------------------------------------- 环境音(游戏层循环) /** 电台静电底噪 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(挂钟+雨+暖炉,开局起全程底噪)。 */ _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;