/** * 《不眠频率》——深夜电台热线叙事 RPG(docs/design/15-radio.md)。 * * 你是午夜档电台热线主播「今夜不眠」:一晚接进五通电话,9 个抉择点、 * 4 结局、跨通话三个标量(rep 口碑 / heardArgument 线索 / bond 好感×5)。 * * 6 宏状态(§2.1):INTRO / RING / ONAIR / BRIDGE / ENDING + MENU 覆盖层 * (另加 ASK = 有存档进游戏的「继续上次?」询问态,属 INTRO 前置瞬态)。 * * 手势(§2.3,6+2 全覆盖零豁免): * - tap :播放中=跳过本句(p* 不可跳);叙事节点链末=继续; * RING=接听;ENDING 结算态=再来一局(新夜班) * - doubletap :重听当前节点台词(含抉择提示);RING=重听上一通话收尾句; * ENDING=重听结局链;MENU=确认执行(§5.2 模态约定覆盖重听) * - left / right :ONAIR 抉择(左=走心 / 右=克制;p* 播放中缓存 pendingChoice); * 其余态无效 → error 震 + gd_invalid(onceKey,零静默) * - up :帮助+状态(RING=第几通+口碑档;ENDING=结局主句),随后复述局面 * - down :模态菜单(单击循环选项 / 双击确认 / 上滑帮助 / 下滑关闭) * - longpress :引擎接管旁白 1.7×(叙事类不豁免,12 号 §2.2;游戏不收此手势) * * 链驱动(storyline playNode 范式):节点链 onComplete 串行推进 + seqToken 打断 * 作废 + pendingChoice 缓存(盲玩公平)。与 storyline 的差异:INTRO 开场链与 * 全部过场转移(@ring/@bridge/@ending 等)**自动衔接**(电台节目流,§2.2 转移 * 图「链完→」),ONAIR 通话内叙事节点链末等待单击(「链末=继续」+ gd_continue * 看门狗),危机拍(h1_talko→h1_danger)与签收链自动流不等待。 * * 资产缺失兜底:引擎 console.error 静默跳过且不回调 onComplete;单句语音 * 看门狗 30s 且 isVoicePlaying()===false 才自动续链(长句续约不掐,storyline * N1 先例照抄)——屏蔽任意 voice 资产仍可一路走到结局,不卡死。 * * R2 修复(research/new8/radio/round1/synthesis.md F1-F5): * - F1 链末等待态的无效手势不再吞掉「还在吗」看门狗(disarm 后按态立即重挂); * - F3 RING 铃声为程序合成无缝循环垫(sfx/phone_ring,游戏侧持句柄,接听即停); * - F4 断点续玩恢复后的首句播完才武装跳句(_resumeGuard → _skipLockId); * - F5 链末句跳过保护(等待/过场/抉择提示前的末句单击不可跳)。 * * 确定性:铃声为无缝循环垫(无定时器重触发),测试与转写可复现。 */ import { Game } from '../engine/game.js'; import { Guide } from '../engine/guide.js'; import { vibrate } from '../engine/haptics.js'; // ---------------------------------------------------------------- 节点表 /** 叙事节点。voices 顺序播放;prompt 非空 = 抉择节点(播完即选,W2 契约)。 */ function N(voices, opts = {}) { return { voices, prompt: null, left: null, right: null, next: null, sfx: null, amb: null, dyn: null, auto: false, danger: false, preMs: 0, sfxEcho: null, ...opts, }; } /** 抉择节点:本体无台词,链尾接 p* 提示句(PROMPT_RE 命中 → 不可跳)。 */ function P(prompt, left, right) { return { voices: [], prompt, left, right, next: null, sfx: null, amb: null, dyn: null, auto: false, danger: false, preMs: 0, sfxEcho: null, }; } /** * 全部节点(id = 首句 voice id;台词全文见 15-radio-voice-ids.md,132 句)。 * next 为 '@x' 虚拟目标:@ring 进铃声 / @bridge 进幕间 / @h1end 回响插播判断 / * @ending 进结局;选择目标 '@transfer' 由 resolve() 按 p1 选择解析。 */ const NODES = { // ---- INTRO 开场链(台标口播 + 一句规矩 + 今晚目标;自动流,§9.1「链完自动进 RING」) b_open: N(['voice/b_open'], { sfx: 'sfx/onair_sting', auto: true, next: 'b_rain' }), b_rain: N(['voice/b_rain'], { auto: true, next: 'b_rule' }), b_rule: N(['voice/b_rule'], { auto: true, next: 'b_goal' }), b_goal: N(['voice/b_goal'], { next: '@ring' }), // 链完自动进 RING(c1) // ---- C1 老周·走丢的老人(教学通 · 2 抉择) z1_ring_in: N(['voice/z1_ring_in'], { sfx: 'sfx/line_connect', amb: 'sfx/amb_rain_city', next: 'z1_a' }), z1_a: N(['voice/z1_a'], { next: 'z1_b' }), z1_b: N(['voice/z1_b'], { next: 'p1' }), p1: P('voice/p1', 'z1_song', 'z1_ask'), z1_song: N(['voice/z1_song'], { next: 'z1_songo' }), z1_songo: N(['voice/z1_songo'], { next: 'z1_sdone' }), z1_sdone: N(['voice/z1_sdone'], { next: 'p2' }), z1_ask: N(['voice/z1_ask'], { next: 'z1_asko' }), z1_asko: N(['voice/z1_asko'], { next: 'z1_adone' }), z1_adone: N(['voice/z1_adone'], { next: 'p2' }), p2: P('voice/p2', 'z1_stay', '@transfer'), // 右=转接:仅 p1=右 时改走归因组 z1_bad z1_stay: N(['voice/z1_stay'], { next: 'z1_stayo' }), z1_stayo: N(['voice/z1_stayo'], { next: 'z1_staye' }), z1_staye: N(['voice/z1_staye'], { next: 'z1_ok1' }), z1_pol: N(['voice/z1_pol'], { next: 'z1_polo' }), z1_polo: N(['voice/z1_polo'], { next: 'z1_pole' }), z1_pole: N(['voice/z1_pole'], { next: 'z1_ok1' }), z1_bad: N(['voice/z1_bad'], { next: 'z1_bade' }), z1_bade: N(['voice/z1_bade'], { next: '@bridge' }), // 归因句(真话律)后直接幕间,不播 z1_ok1 z1_ok1: N(['voice/z1_ok1'], { next: '@bridge' }), // 首通成功收尾确认(全局一次) // ---- C2 小鹿·想退票的年轻人(单抉择 · 呼吸口) l1_in: N(['voice/l1_in'], { sfx: 'sfx/line_connect', amb: 'sfx/amb_station', next: 'l1_a' }), l1_a: N(['voice/l1_a'], { next: 'l1_b' }), l1_b: N(['voice/l1_b'], { next: 'p3' }), p3: P('voice/p3', 'l1_go', 'l1_free'), l1_go: N(['voice/l1_go'], { next: 'l1_goo' }), l1_goo: N(['voice/l1_goo'], { next: 'l1_goe' }), l1_goe: N(['voice/l1_goe'], { next: '@bridge' }), l1_free: N(['voice/l1_free'], { next: 'l1_freeo' }), l1_freeo: N(['voice/l1_freeo'], { next: 'l1_freee' }), l1_freee: N(['voice/l1_freee'], { next: '@bridge' }), // ---- C3 糖糖·失眠的孩子(2 抉择 · 埋 heardArgument) k1_in: N(['voice/k1_in'], { sfx: 'sfx/line_connect', amb: 'sfx/amb_kid_room', next: 'k1_a' }), k1_a: N(['voice/k1_a'], { next: 'k1_b' }), // k1_b 垫 arg_muffled 线索音(信息层,起播后再叠一次,「2-3 次」取确定值 2) k1_b: N(['voice/k1_b'], { sfx: 'sfx/arg_muffled', sfxEcho: { id: 'sfx/arg_muffled', atMs: 6500 }, next: 'p4' }), p4: P('voice/p4', 'k1_lull', 'k1_ask'), // 左=哄睡(错过线索)/ 右=问她(heardArgument=true) k1_lull: N(['voice/k1_lull'], { next: 'k1_lullo' }), k1_lullo: N(['voice/k1_lullo'], { next: 'k1_lulle' }), k1_lulle: N(['voice/k1_lulle'], { next: '@bridge' }), k1_ask: N(['voice/k1_ask'], { next: 'k1_asko' }), k1_asko: N(['voice/k1_asko'], { next: 'p5' }), p5: P('voice/p5', 'k1_truth', 'k1_lie'), // 仅 p4=右 可达 k1_truth: N(['voice/k1_truth'], { next: 'k1_trutho' }), k1_trutho: N(['voice/k1_trutho'], { next: 'k1_truthe' }), k1_truthe: N(['voice/k1_truthe'], { next: '@bridge' }), k1_lie: N(['voice/k1_lie'], { next: 'k1_lieo' }), k1_lieo: N(['voice/k1_lieo'], { next: 'k1_liee' }), k1_liee: N(['voice/k1_liee'], { next: '@bridge' }), // ---- C4 陈姐·争吵的夫妻(2 抉择 · 消费 heardArgument) w1_in: N(['voice/w1_in'], { sfx: 'sfx/line_connect', amb: 'sfx/amb_street_rain', next: 'w1_a' }), w1_a: N(['voice/w1_a'], { next: 'w1_b' }), w1_b: N(['voice/w1_b'], { next: 'p6' }), p6: P('voice/p6', 'w1_say', 'w1_home'), // w1_say 节点内选播(唯一跨来电内容差分之一,不改树形):heardArgument // 决定播 w1_say 组还是 w1_say2 组,两条件路线共用同一节点 id,汇于 p7。 w1_say: N([], { dyn: 'w1say', next: 'p7' }), w1_home: N(['voice/w1_home'], { next: 'w1_homeo' }), w1_homeo: N(['voice/w1_homeo'], { next: 'w1_homee' }), w1_homee: N(['voice/w1_homee'], { next: '@bridge' }), // 右路本通结束,无 p7 p7: P('voice/p7', 'w1_air', 'w1_priv'), // 仅 p6=左 可达 w1_air: N(['voice/w1_air'], { next: 'w1_airo' }), w1_airo: N(['voice/w1_airo'], { next: 'w1_aire' }), w1_aire: N(['voice/w1_aire'], { next: '@bridge' }), w1_priv: N(['voice/w1_priv'], { next: 'w1_privq' }), w1_privq: N(['voice/w1_privq'], { next: 'w1_prive' }), w1_prive: N(['voice/w1_prive'], { next: '@bridge' }), // ---- C5 老韩·雨夜司机(高潮 · 危机拍 + bond_zhou 回响) h1_in: N(['voice/h1_in'], { sfx: 'sfx/line_connect', amb: 'sfx/amb_cab', next: 'h1_a' }), h1_a: N(['voice/h1_a'], { next: 'h1_b' }), h1_b: N(['voice/h1_b'], { next: 'p8' }), p8: P('voice/p8', 'h1_talk', 'h1_stop'), h1_talk: N(['voice/h1_talk'], { next: 'h1_talko' }), // 危机拍整段自动流(戏剧事件不等待单击):talko → danger(前置 2s 预警) → dango → talke → 幕间 h1_talko: N(['voice/h1_talko'], { auto: true, next: 'h1_danger' }), h1_danger: N(['voice/h1_danger'], { danger: true, preMs: 2000, sfx: 'sfx/brake_screech', auto: true, next: 'h1_dango', }), h1_dango: N(['voice/h1_dango'], { auto: true, next: 'h1_talke' }), h1_talke: N(['voice/h1_talke'], { next: '@h1end' }), h1_stop: N(['voice/h1_stop'], { next: 'h1_stopo' }), h1_stopo: N(['voice/h1_stopo'], { next: 'h1_stope' }), h1_stope: N(['voice/h1_stope'], { next: '@h1end' }), // 回响插播判断:bond_zhou=1 补播 h1_echo(C1→C5 回响),否则直接幕间 h1_end: N([], { dyn: 'h1echo', next: '@bridge' }), // ---- 签收(SIGNOFF 属 ONAIR,不单设状态)+ ◆p9 心结抉择 g9_in: N(['voice/g9_in'], { auto: true, next: 'p9' }), // 垫乐起,自动流入签收抉择 p9: P('voice/p9', 'd9_face', 'd9_avoid'), d9_face: N(['voice/d9_face'], { sfx: 'sfx/tape_static', auto: true, next: 'd9_facee' }), d9_facee: N(['voice/d9_facee'], { next: '@ending' }), d9_avoid: N(['voice/d9_avoid'], { auto: true, next: 'd9_avoide' }), d9_avoide: N(['voice/d9_avoide'], { next: '@ending' }), }; // ---------------------------------------------------------------- 常量表 const CALLS = [ { key: 'zhou', label: '走丢的老人', entry: 'z1_ring_in' }, { key: 'deer', label: '退票的年轻人', entry: 'l1_in' }, { key: 'kid', label: '失眠的孩子', entry: 'k1_in' }, { key: 'wife', label: '争吵的夫妻', entry: 'w1_in' }, { key: 'han', label: '雨夜司机', entry: 'h1_in' }, ]; const CALL_OF_NODE = { z: 1, l: 2, k: 3, w: 4, h: 5, g: 5, d: 5 }; // 节点前缀 → 通号 /** 上滑状态链的「第几通」分档整句(§4.3 数字规避:禁止运行时拼接)。 */ const PROG_VOICES = [ 'voice/prog_c1', 'voice/prog_c2', 'voice/prog_c3', 'voice/prog_c4', 'voice/prog_c5', ]; /** 口碑分档整句(BRIDGE 跨档被动播报 + 上滑状态链复用;rep_low 附 warning 震)。 */ const TIER_VOICES = { high: 'voice/rep_high', mid: 'voice/rep_mid', low: 'voice/rep_low' }; const MENU_ITEMS = [ { id: 'resume', voice: 'voice/sys_menu_resume' }, // 回到直播 { id: 'restart', voice: 'voice/sys_menu_restart' }, // 重新开始今晚 { id: 'lobby', voice: 'voice/sys_menu_lobby' }, // 保存并回大厅 ]; /** 4 结局(§3.4 矩阵:rep≥5 高/低口碑 × p9 直面/未解)。 */ const ENDINGS = { e1: { name: '天亮之前', voices: ['voice/e1_a', 'voice/e1_b'] }, e2: { name: '热线常亮', voices: ['voice/e2_a', 'voice/e2_b'] }, e3: { name: '一个人的频道', voices: ['voice/e3_a', 'voice/e3_b'] }, e4: { name: '静音', voices: ['voice/e4_a', 'voice/e4_b'] }, }; /** 尾声回响:5 个来电者各播 bond ? 救到 : 错过(P2 回响感)。 */ const EP_VOICES = { zhou: ['voice/ep_zhou_ok', 'voice/ep_zhou_no'], deer: ['voice/ep_deer_ok', 'voice/ep_deer_no'], kid: ['voice/ep_kid_ok', 'voice/ep_kid_no'], wife: ['voice/ep_wife_ok', 'voice/ep_wife_no'], han: ['voice/ep_han_ok', 'voice/ep_han_no'], }; const ALL_SFX = [ 'sfx/onair_sting', 'sfx/phone_ring', 'sfx/line_connect', 'sfx/line_drop', 'sfx/amb_rain_city', 'sfx/amb_station', 'sfx/amb_kid_room', 'sfx/arg_muffled', 'sfx/amb_street_rain', 'sfx/amb_cab', 'sfx/brake_screech', 'sfx/tape_static', 'sfx/ui_confirm', 'sfx/ui_menu', ]; const SAVE_KEY = 'soundgame.progress.radio'; const AUTH_KEY = 'soundgame.auth'; /** 抉择提示音 id(voice/p1~p9):播放中单击不可跳(盲玩公平,12 号 §1.2)。 */ const PROMPT_RE = /^voice\/p\d/; /** 单句语音看门狗:超时且引擎确认 voice 未在播 = 资产缺失才续链(storyline N1 先例)。 */ const VOICE_WATCHDOG_MS = 30000; // 向导层看门狗(§7.2;full 档 say 由 guide 播,standard 档 onEscalate 游戏层补播, // off 档整体不挂——与 storyline F1.1 / P2-3 同款双档节奏)。 const GUIDE_WATCHDOG_AT = [25000, 50000]; // full:叙事链末 / ENDING 结算等待 const IDLE_HINT_AT = [13000, 30000]; // standard:同 storyline P2-3 先例 const CHOICE_HINT_AT = [25000, 50000]; // 抉择点:两级都重播当前 p* 提示句本身 const RING_WATCHDOG_AT = [10000, 25000]; // RING 两级**不同句**(gd_ring1 ≠ gd_ring2) // 铃声节奏(双音一响 + 静默)烘进 sfx/phone_ring 文件本身(5.2s ≥4s 一循环, // 无缝 loop,见 gen/gen_sfx.py);游戏侧只持一个循环句柄,不再定时重触发。 const BRIDGE_REST_MS = 3000; // 幕间垫乐喘息(自动链 ≤12s:rep 整句 + 3s 垫乐) const DANGER_LEAD_MS = 2000; // 危机拍前置预警窗(brake 渐强 + danger 签名同窗) /** 口碑档位(§4.3):≥5 高 / 1-4 中 / ≤0 低。 */ function tierOf(rep) { if (rep >= 5) return 'high'; if (rep >= 1) return 'mid'; return 'low'; } export class RadioGame extends Game { constructor(audio, root) { super(audio, root); this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op) this.phase = 'INTRO'; // INTRO|RING|ONAIR|BRIDGE|ENDING|MENU|ASK this.returnPhase = null; // MENU 关闭后回原状态 this.returnNode = null; this.nodeId = 'b_open'; this.callN = 0; // 当前通号(0=开场未接入) this.endingId = null; this.voicePlaying = false; this.currentVoiceId = null; // 链上当前句 id this.promptPlaying = false; // 当前句是抉择提示 p* this.choiceArmed = false; this.pendingChoice = null; this.awaitingRestart = false; this.leaving = false; // 菜单「保存并回大厅」已确认(跳转大厅前 true) this.seqToken = 0; // 跨通话变量(§4.1) this.rep = 0; this.knot = false; // 心结,仅 p9=左 置 true this.heardArgument = false; // p4=右 置 true,C4 消费 this.bond = { zhou: 0, deer: 0, kid: 0, wife: 0, han: 0 }; this.endings = {}; // 结局收集(跨夜保留) this.nights = 0; this.p1Choice = null; // p1 选择(p2 计分 / @transfer 解析用) this.lastAnnouncedTier = 'low'; // BRIDGE 跨档播报基准(开局 rep=0 → low) this.lastCloser = null; // 上一通话收尾句(RING 双击重听用) this.sysSkipShown = false; // sys_skip 开场每实例一次(storyline skip_hint 先例) this.saved = null; // ASK 态暂存的存档 this._menuQueue = []; // 菜单项报名队列(游戏侧排队,不截断当前报名) // F4 断点续玩跳句锁:_resume 置位,恢复链首句起播时落为该句 id,播完即清 //(首句播完才武装跳句——ASK「单击=确认」与「单击=跳句」的语义陷阱,R2 F4) this._resumeGuard = false; this._skipLockId = null; // F3 铃声循环句柄(sfx/phone_ring 无缝 loop;引擎 sfx 层无 loop/stop, // 游戏侧经 ctx 起源并持句柄——guide earcon 同款直用引擎总线,接听/菜单即停) this._ringSrc = null; // 计时器句柄(菜单/重开时统一清理) this.ambTimer = null; this.ambId = null; this.restTimer = null; /** 环境音循环开关:通话侧 amb 由游戏层 onEnded 重触发(escape amb 先例); * 无头测试/转写置 false(重触发定时器会让虚拟时钟永不空闲)。 */ this.ambLoopEnabled = true; } // ---------------------------------------------------------------- Game start() { this.audio.play('bgm/onair'); // 全夜班不切(bgm=0 时引擎只记意图,重开自动补播) this.audio.prewarm(ALL_SFX); // 预解码短音效(等价 Android SoundPool 音量 0 预热) const local = this._loadLocal(); if (local && local.phase && local.node) { this._askContinue(local); return; } // 无本地存档:登录态下发服务器存档(1.5s 内未返回/无存档 → 直接开新夜班) 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=radio', { 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++; // 作废一切在途链回调:stop 后(页面卸载/测试拆除)中断链 this._stopRing(); // 不得经 30s 语音看门狗"资产缺失"路径复活(复活会走到 this._stopAmb(); // enterRing 留下无人接听的永久铃声循环) this._resumeGuard = false; // F4 跳句锁随实例拆除 this._skipLockId = null; if (this.restTimer) { clearTimeout(this.restTimer); this.restTimer = null; } this.audio.stopAll(); } onGesture(g) { this.guide.disarm(); // 任何手势复位看门狗(guide=off 时 no-op) switch (this.phase) { case 'ASK': this._onAskGesture(g); break; case 'MENU': this._onMenuGesture(g); break; case 'RING': this._onRingGesture(g); break; case 'ONAIR': case 'INTRO': this._onStoryGesture(g); break; case 'BRIDGE': this._onBridgeGesture(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: 'radio_invalid' }); // F1:非推进手势已被 onGesture 的 guide.disarm() 拆掉 ASK 空闲看门狗, // 处理完立即重挂——否则询问态从此无限静默(live t=445.4 同款断音) if (!go && this.phase === 'ASK' && !this.voicePlaying) this._armIdleWatchdog('ASK'); } // ---------------------------------------------------------------- 手势:RING _onRingGesture(g) { switch (g) { case 'tap': this._stopRing(); // F3:铃声循环垫接听即停 this.phase = 'ONAIR'; this.playNode(CALLS[this.callN - 1] ? CALLS[this.callN - 1].entry : 'z1_ring_in'); break; case 'doubletap': // 重听上一通话的收尾句(首通前无上一通 → 无效手势零静默; // INTRO 链不走此路径——收尾句只来自通话节点) if (this.callN <= 1 || !this.lastCloser) { this.guide.error({ voice: 'gd_invalid', onceKey: 'radio_invalid' }); this._rearmRingWatchdog(); // F1:重听请求无效也不拆铃看门狗 return; } this.seqToken++; this.audio.stopVoice(); this._playSeq([this.lastCloser], () => { this.voicePlaying = false; if (this.phase === 'RING') this._armRingWatchdog(); }); break; case 'up': { // 状态播报(第几通 + 口碑档),留在 RING this.seqToken++; this.audio.stopVoice(); const ids = [PROG_VOICES[Math.max(0, this.callN - 1)], TIER_VOICES[tierOf(this.rep)]]; this._playSeq(ids, () => { this.voicePlaying = false; if (this.phase === 'RING') this._armRingWatchdog(); }); break; } case 'down': this.openMenu(); break; case 'left': case 'right': // 无效手势零静默(§2.3 RING 行:左右滑 → error 震 + gd_invalid) this.guide.error({ voice: 'gd_invalid', onceKey: 'radio_invalid' }); this._rearmRingWatchdog(); // F1:无效手势不拆铃看门狗(拆后不补=静默死局) break; default: this._rearmRingWatchdog(); // F1:快进等引擎接管手势同款(空闲态才重挂) break; // longpress/longpressend/tapcancel:引擎快进接管,游戏不处理 } } /** F1:RING 空闲(无对白在播)时重挂两级铃看门狗;重听/播报在播时不抢挂。 */ _rearmRingWatchdog() { if (this.phase === 'RING' && !this.audio.isVoicePlaying()) this._armRingWatchdog(); } _callEntry() { const call = CALLS[this.callN - 1]; return call && NODES[call.entry] ? call.entry : 'z1_ring_in'; } // ---------------------------------------------------------------- 手势:ONAIR / INTRO _onStoryGesture(g) { const node = NODES[this.nodeId]; if (!node) return; switch (g) { case 'tap': if (this.voicePlaying) { // 旁白播放中单击 = 跳过本句(链继续);不可跳的三类: // ① 抉择提示 p*(PROMPT_RE 门,盲玩公平); // ② F5 链末句——等待/过场/抉择交接前的末句(抢跑吃钩子:蛋糕句/点题句); // ③ F4 续玩锁——断点恢复后的首句播完才武装跳句。 if (!this.promptPlaying && !this._isTailSentence() && this.currentVoiceId !== this._skipLockId) { this.audio.skipVoice(); } } else if (node.prompt != null && this.choiceArmed) { this.armChoiceHint(); // 抉择等待态空点 = 重新挂载兜底(25s 计时重置) } else if (node.next != null) { this.advance(node.next); } break; case 'doubletap': this.replayNode(); break; case 'left': case 'right': { if (node.prompt == null) { // 无效手势零静默(§2.3):error 震 + full 档 gd_invalid(onceKey 同局一次); // F1:onGesture 入口的 disarm 已拆链末看门狗,处理完立即重挂 //(live 局 B t=445.4:无效左滑后 34.2s 零事件,「还在吗」永不来的根因) this.guide.error({ voice: 'gd_invalid', onceKey: 'radio_invalid' }); if (this._inStoryWait()) this._armIdleWatchdog(this.phase); return; } if (this.choiceArmed) this.choose(g); else if (this.voicePlaying) this.pendingChoice = g; // p* 播放中缓存(盲玩公平) break; } case 'up': this._playHelpAndResume(); break; case 'down': this.openMenu(); break; default: // longpress/longpressend/tapcancel:引擎快进接管,游戏不处理; // F1 同款:链末等待态下它们同样拆掉了看门狗,立即重挂 if (this._inStoryWait()) this._armIdleWatchdog(this.phase); break; } } /** 当前是否处于 ONAIR/INTRO「链末等待单击」态(F1 重挂判据:与 _onNodeSeqDone 的 * 等待分支同口径——非抉择节点、有后继、非自动流、非过场转移、且无对白在播)。 */ _inStoryWait() { const node = NODES[this.nodeId]; return !!(node && !this.voicePlaying && node.prompt == null && node.next != null && !node.auto && !this._isTransition(node.next)); } /** * F5 链末句保护:当前句是本节点末句、且节点把控制权交出(链末等待单击 / * @x 过场转移 / 直入抉择提示)时,屏蔽「跳过本句」——单击身兼跳句与继续, * 末句被跳过则关键尾句(k1_trutho 的蛋糕句、d9_*e 的点题句)被抢跑吃掉, * 下一句应答在没播出的钩子上(live 局 A 三次吃尾的根因)。 * 自动链中段(b_open/b_rain、危机拍)不受保护,跳句自由保留。 */ _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); // 自动链直入抉择提示(g9_in→p9) } // ---------------------------------------------------------------- 手势:BRIDGE _onBridgeGesture(g) { switch (g) { case 'tap': if (this.voicePlaying) this.audio.skipVoice(); // 跳过本句,自动链继续 break; case 'doubletap': this.seqToken++; this.audio.stopVoice(); this._bridgeChain(this._bridgeAnnounce ? [this._bridgeAnnounce] : []); break; case 'up': { // 帮助 + 状态,自动链继续(垫乐喘息不被打断——链尾照常进下一通) this.seqToken++; this.audio.stopVoice(); const ids = ['voice/sys_help']; if (this.callN < CALLS.length) ids.push(PROG_VOICES[this.callN]); // 下一通 ids.push(TIER_VOICES[tierOf(this.rep)]); this._bridgeChain(ids); break; } case 'down': this.openMenu(); break; case 'left': case 'right': this.guide.error({ voice: 'gd_invalid', onceKey: 'radio_invalid' }); // §2.3 BRIDGE 行 break; default: break; // longpress/longpressend/tapcancel:引擎快进接管,游戏不处理 } } // ---------------------------------------------------------------- 手势:ENDING _onEndingGesture(g) { switch (g) { case 'tap': if (this.awaitingRestart) this.newNight(); else if (this.voicePlaying && this.currentVoiceId !== this._skipLockId) { this.audio.skipVoice(); // F4:续玩恢复的首句播完才武装跳句 } break; case 'doubletap': // 重听结局链(结算态/播放中皆可;重播期间清结算态,播完回到结算态) this.awaitingRestart = false; this.seqToken++; this.audio.stopVoice(); this._playEndingChain(); break; case 'up': { // 帮助 + 状态(结局主句=结局名播报 + 口碑档整句,§2.3「结局名+口碑」; // storyline P3-2 先例),播完回结算态 this.seqToken++; this.audio.stopVoice(); const def = ENDINGS[this.endingId] || ENDINGS.e1; this._playSeq(['voice/sys_help', def.voices[0], TIER_VOICES[tierOf(this.rep)]], () => { 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: 'radio_invalid' }); // §2.3 ENDING 行 // F1:结算等待态的看门狗被 disarm 拆掉后立即重挂(对白在播时本就无看门狗) if (this.awaitingRestart && !this.voicePlaying) this._armIdleWatchdog('ENDING'); break; default: // F1 同款:快进等引擎接管手势不拆结算等待的看门狗 if (this.awaitingRestart && !this.voicePlaying) this._armIdleWatchdog('ENDING'); break; // longpress/longpressend/tapcancel:引擎快进接管,游戏不处理 } } // ---------------------------------------------------------------- 手势:MENU(§5.2 模态约定) _onMenuGesture(g) { switch (g) { case 'tap': { // 单击循环切换:报名经游戏侧队列**接在当前报名之后**(不截断)。 // 不用引擎 queue:true——引擎在 onComplete 里先起链播下一句、后 shift // 队列,排队句会把链播下一句顶掉(sys_menu→resume 链被吞的根因)。 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(); this._playSeq(['voice/sys_help', 'voice/sys_settings', MENU_ITEMS[this.menuIdx].voice], () => { this.voicePlaying = false; }); break; } case 'down': this.closeMenu(); // 关闭回原状态(重播当前节点 = 局面复述) break; case 'left': case 'right': this.guide.error({}); // 菜单内左右滑无效:error 震(§2.3 MENU 行,无语音) break; default: break; // longpress/longpressend/tapcancel:引擎快进接管(快进菜单语音) } } _execMenuItem(id) { this._menuQueue = []; // 确认执行:待播报名作废 if (id === 'resume') { this.closeMenu(); } else if (id === 'restart') { this.seqToken++; this.audio.stopVoice(); this.newNight(); } else if (id === 'lobby') { // 保存并回大厅:确认句播出且存档落盘后跳转(§2.2「确认后跳转前播」) 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 标记 */ } }); } } // ---------------------------------------------------------------- 菜单开/关 openMenu() { this.returnPhase = this.phase; this.returnNode = this.nodeId; this.phase = 'MENU'; this.menuIdx = 0; this._menuQueue = []; this.seqToken++; this.audio.stopVoice(); this._stopRing(); this._cancelRest(); this.audio.play('sfx/ui_menu'); this.audio.play('bgm/menu', { fadeMs: 500 }); // 菜单 pad crossfade 500ms this.guide.state('menu'); this._playSeq(['voice/sys_menu', MENU_ITEMS[0].voice], () => { this.voicePlaying = false; this._drainMenuQueue(); }); } /** 菜单报名队列排水:链播完接播玩家循环切换过的项(游戏侧排队,零截断)。 */ _drainMenuQueue() { if (this.phase !== 'MENU' || this._menuQueue.length === 0) return; const ids = this._menuQueue.splice(0); this._playSeq(ids, () => { this.voicePlaying = false; this._drainMenuQueue(); }); } closeMenu() { this.seqToken++; this._menuQueue = []; // 未播完的报名作废(局面复述接管) this.audio.stopVoice(); const back = this.returnPhase || 'INTRO'; this.phase = back; this.audio.play('sfx/ui_confirm'); if (back === 'BRIDGE') this.audio.play('bgm/bridge', { fadeMs: 800 }); else if (back === 'ENDING') this.audio.play('bgm/ending', { fadeMs: 800 }); else this.audio.play('bgm/onair', { fadeMs: 800 }); // 关菜单淡回原状态曲 this.guide.state(back === 'BRIDGE' || back === 'ENDING' ? 'settle' : 'explore'); if (back === 'RING') { this._startRing(); this._armRingWatchdog(); } else if (back === 'BRIDGE') { this._bridgeChain([]); // 幕间续走(口碑句已播过不重播,账目真实) } 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/ONAIR:重播当前节点 } } // ---------------------------------------------------------------- 夜班流转 /** 新夜班:重置跨通话标量(结局收集跨夜保留),回 INTRO(sys_new_night 先行)。 */ newNight() { this.nights += 1; this.rep = 0; this.knot = false; this.heardArgument = false; this.bond = { zhou: 0, deer: 0, kid: 0, wife: 0, han: 0 }; this.p1Choice = null; this.callN = 0; this.endingId = null; this.lastAnnouncedTier = 'low'; this.lastCloser = null; this.leaving = false; this.saved = null; this.choiceArmed = false; // 清掉上一夜的抉择武装(重启到首链起播前不收滑动手势) this.pendingChoice = null; this._resumeGuard = false; // F4 跳句锁不跨夜 this._skipLockId = null; this.seqToken++; this.audio.stopVoice(); this._stopRing(); this._stopAmb(); this._cancelRest(); this.phase = 'INTRO'; this.audio.play('bgm/onair'); // 从菜单/结局重开为同首不重启 this.guide.state('explore'); this._playSeq(['voice/sys_new_night'], () => this.playNode('b_open')); } /** RING:热线接入。铃声循环垫 + turn cue + 两级不同句看门狗,无超时惩罚。 */ enterRing(n) { this.callN = n; this.phase = 'RING'; this._stopAmb(); this.audio.play('bgm/onair', { fadeMs: 600 }); // 进下一通淡回 onair this.guide.turn({ haptic: true }); // 铃声 + turn 签名 =「轮到你接听」(无语音) this._save(); // 写入时机:每通电话 RING 进入 this._startRing(); this._armRingWatchdog(); } /** * F3:铃声循环垫起播(sfx/phone_ring,程序合成无缝 loop:双音一响 + 静默烘进 * 文件,循环边界静音零接缝)。引擎 sfx 层「即发即弃」无 loop/stop——游戏侧经 * 引擎 ctx/_sfxBus 起循环 BufferSource 并持句柄(guide earcon 同款直用总线), * 接听/菜单/重开的 _stopRing 即停,不拖尾盖住通话。load 异步:落地前已离开 * RING(秒接)则不播;资产缺失静默降级(震动 + 看门狗仍在,玩法不受阻)。 */ _startRing() { if (this.phase !== 'RING') return; this._stopRingSfx(); // 防双循环(closeMenu/resume 重入时旧句柄先清) const ctx = this.audio.ctx; if (!ctx) return; this.audio._load('sfx/phone_ring').then((buf) => { if (!buf || this.phase !== 'RING' || this._ringSrc) return; const src = ctx.createBufferSource(); src.buffer = buf; src.loop = true; const gain = ctx.createGain(); gain.gain.value = this.audio.sfxVolume; src.connect(gain); gain.connect(this.audio._sfxBus); src.start(); this._ringSrc = { src, gain }; }).catch(() => { /* 资产缺失降级:RING 仍有 turn 震 + 两级看门狗 */ }); } _stopRingSfx() { if (this._ringSrc) { try { this._ringSrc.src.stop(); } catch (_) { /* 已停止 */ } this._ringSrc = null; } } _stopRing() { this._stopRingSfx(); } /** BRIDGE:幕间喘息。口碑跨档被动播报(分档整句账目真实)→ 垫乐喘息 → 下一通。 */ enterBridge() { this._stopAmb(); this.phase = 'BRIDGE'; this.guide.state('settle'); this.audio.play('sfx/line_drop'); // 挂断咔哒 this.audio.play('bgm/bridge', { fadeMs: 800 }); // crossfade 进幕间 const tier = tierOf(this.rep); if (tier !== this.lastAnnouncedTier) { this.lastAnnouncedTier = tier; this._bridgeAnnounce = TIER_VOICES[tier]; if (tier === 'low') vibrate('warning'); // rep_low 附 warning 震(G0 组注记) } else { this._bridgeAnnounce = null; } this._bridgeChain(this._bridgeAnnounce ? [this._bridgeAnnounce] : []); } _bridgeChain(ids) { this.seqToken++; this.audio.stopVoice(); this._playSeq(ids, () => { this.voicePlaying = false; this._armRest(); }); } _armRest() { if (this.restTimer) clearTimeout(this.restTimer); const token = this.seqToken; this.restTimer = setTimeout(() => { this.restTimer = null; if (this.phase !== 'BRIDGE' || this.seqToken !== token) return; // 已被菜单/手势打断 this._advanceBridge(); }, BRIDGE_REST_MS); } _cancelRest() { if (this.restTimer) { clearTimeout(this.restTimer); this.restTimer = null; } } _advanceBridge() { if (this.callN >= CALLS.length) this._enterSignoff(); else this.enterRing(this.callN + 1); } /** 签收(C5 幕间后):SIGNOFF 属 ONAIR,g9_in 自动流入 ◆p9。 */ _enterSignoff() { this.phase = 'ONAIR'; this.playNode('g9_in'); } /** ENDING:rep×knot 四选一 + 尾声回响链,结算态等待单击重开。 */ enterEnding(forced) { const ending = forced || (this.rep >= 5 ? (this.knot ? 'e1' : 'e2') : (this.knot ? 'e3' : 'e4')); this.endingId = ending; this.endings[ending] = true; this.phase = 'ENDING'; this.nodeId = ending; this.awaitingRestart = false; this._stopAmb(); this.guide.state('settle'); this.audio.play('bgm/ending', { fadeMs: 1500 }); // p9 结果链播完后淡入 this._save(); // 写入时机:ENDING 进入 this._playEndingChain(); } _playEndingChain() { const def = ENDINGS[this.endingId] || ENDINGS.e4; const chain = [...def.voices, 'voice/ep_lead']; for (const key of Object.keys(EP_VOICES)) { chain.push(EP_VOICES[key][this.bond[key] ? 0 : 1]); // 救到 / 错过,点名每一通 } 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 并重播当前节点链(一句话局面复述,L5)。 */ _resume(s) { if (!s) { this.newNight(); return; } this.saved = null; this.rep = s.rep | 0; this.knot = !!s.knot; this.heardArgument = !!s.heardArgument; this.bond = { zhou: 0, deer: 0, kid: 0, wife: 0, han: 0, ...(s.bond || {}) }; this.endings = { ...(s.endings || {}) }; this.nights = s.nights || 1; this.lastAnnouncedTier = tierOf(this.rep); // 恢复不重播跨档句(账目已播过) this.lastCloser = s.lastCloser || null; this.callN = s.call || this._callOfNode(s.node) || 1; this.leaving = false; this.seqToken++; this.audio.stopVoice(); this._resumeGuard = true; // F4:恢复链首句播完才武装跳句(经 _playSeq 消费) const phase = s.phase; if (phase === 'RING') { this.phase = 'RING'; this._startRing(); this._armRingWatchdog(); } else if (phase === 'BRIDGE') { this.phase = 'BRIDGE'; this.audio.play('bgm/bridge', { fadeMs: 800 }); this.guide.state('settle'); this._bridgeChain([]); // 续走幕间喘息 } else if (phase === 'ENDING') { this.enterEnding(s.ending && ENDINGS[s.ending] ? s.ending : undefined); } else { this.phase = 'ONAIR'; this.guide.state('explore'); this.playNode(NODES[s.node] ? s.node : this._callEntry()); } } _callOfNode(id) { const c = typeof id === 'string' ? id[0] : ''; return CALL_OF_NODE[c] || 1; } // ---------------------------------------------------------------- 节点链驱动 playNode(id) { const node = NODES[id]; if (!node) { console.error('[RadioGame] 未知节点:', id); return; } this.nodeId = id; this.choiceArmed = false; this.pendingChoice = null; this.awaitingRestart = false; if (node.amb) this._startAmb(node.amb); if (id === 'p9') this.audio.play('bgm/onair', { fadeMs: 600 }); // 签收回 onair let seq = this._nodeVoices(id); // F8 先例:操作口令 gd_brief 前置开场链最前(仅 full 档) if (id === 'b_open' && this.guide.level() === 'full') seq = ['voice/gd_brief', ...seq]; // sys_skip:开场每实例一次(storyline skip_hint 先例) if (id === 'b_open' && !this.sysSkipShown) { this.sysSkipShown = true; seq = [...seq, 'voice/sys_skip']; } if (node.prompt) seq = [...seq, node.prompt]; if (node.danger) { // 危机拍:前 2s brake 渐强 + danger 签名/震同窗(L2 前置预警),链占用期间单击不推进 this.guide.state('danger'); vibrate('danger'); if (node.sfx) this.audio.play(node.sfx); 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))); }, node.preMs || 0); return; } if (node.sfx) this.audio.play(node.sfx); if (node.sfxEcho) { // k1_b 的 arg_muffled 第二遍(线索音 2-3 次取确定值 2;节点被顶掉则不播) setTimeout(() => { if (this.nodeId === id && this.phase === 'ONAIR') this.audio.play(node.sfxEcho.id); }, node.sfxEcho.atMs); } this._playSeq(seq, () => this._onNodeSeqDone(node, seq)); } /** 节点基础语音链(含节点内条件选播:w1_say 组 / h1_echo 插播)。 */ _nodeVoices(id) { const node = NODES[id]; if (!node) return []; if (node.dyn === 'w1say') { return this.heardArgument ? ['voice/w1_say', 'voice/w1_sayo', 'voice/w1_saye'] : ['voice/w1_say2', 'voice/w1_say2o', 'voice/w1_say2e']; } if (node.dyn === 'h1echo') { return this.bond.zhou ? ['voice/h1_echo'] : []; } return [...node.voices]; } /** 双击重听当前节点台词(含抉择提示);ENDING 态重听结局链。 */ replayNode() { this.seqToken++; this.audio.stopVoice(); 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); this._playSeq(seq, () => this._onNodeSeqDone(node, seq)); } /** '@x' 虚拟目标 = 过场转移(电台节目流自动衔接,§2.2 转移图「链完→」)。 */ _isTransition(target) { return typeof target === 'string' && target.startsWith('@'); } /** * 节点链自然播完的公共收尾:抉择武装 / 自动过场 / 链末等待。 * 自动衔接仅限两类(§2.2 转移图 + 本文件头部说明):① 节点表标注 auto 的 * 电台流段落(INTRO 开场链、C5 危机拍 h1_talko→h1_danger→h1_dango、签收链 * g9_in、p9 结果链 d9_face/d9_avoid);② 全部过场转移(@ring/@bridge/ * @h1end/@ending)。其余 ONAIR 通话内叙事节点链末**等待单击**(§2.3「链末= * 继续」)并挂 gd_continue/gd_again 看门狗(§7.2 叙事链末等待行; * storyline onNodeSeqDone 同款——只提示不重播,两级用尽彻底静默)。 */ _onNodeSeqDone(node, seq) { this.voicePlaying = false; if (seq.length > 0) this.lastCloser = seq[seq.length - 1]; 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('ONAIR'); // 链末等待单击(tap=继续,见 _onStoryGesture) } /** 链完转移:'@x' 虚拟目标 = 过场自动衔接;普通节点 = 播下一节点。 */ advance(target) { switch (target) { case '@ring': this.enterRing(this.callN + 1); return; case '@bridge': this.enterBridge(); return; case '@h1end': this.playNode('h1_end'); return; case '@ending': this.enterEnding(); return; default: this.playNode(this.resolve(target)); } } /** 抉择目标解析(跨通话状态只有三个标量,无组合节点)。 */ resolve(target) { if (target === '@transfer') return this.p1Choice === 'left' ? 'z1_pol' : 'z1_bad'; return target; } /** 抉择:计分(§4.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._save(); // 写入时机:每个抉择结算后 this.advance(g === 'left' ? node.left : node.right); } _applyChoice(nodeId, g) { const L = g === 'left'; switch (nodeId) { case 'p1': this.p1Choice = g; if (L) this.rep += 1; // 哼老歌:首个正反馈 break; case 'p2': // 唯一负分组合(p1=右 且 p2=右 → -1 砸),触发 z1_bad 归因组 if (L) this.rep += this.p1Choice === 'left' ? 2 : 1; else this.rep += this.p1Choice === 'left' ? 1 : -1; this.bond.zhou = (!L && this.p1Choice === 'right') ? 0 : 1; // 砸 = 错过 break; case 'p3': this.rep += L ? 2 : 1; // 两个选项都体面 this.bond.deer = L ? 1 : 0; break; case 'p4': // 左=哄睡(错过线索,+0)/ 右=问她(倾听,+1 且 heardArgument=true)。 // 注:§4.2 表行「p4 问她/哄睡 | 左 | 右」的选项列序与 §3.2 树形/§9 断言 // 的滑动绑定相反,以 §3.2 + p4 提示句原文(左哄睡/右问她)为准。 if (!L) { this.rep += 1; this.heardArgument = true; } break; case 'p5': if (L) { this.rep += 1; this.bond.kid = 1; } // 真话 = 完整接住 break; case 'p6': if (L) { this.rep += this.heardArgument ? 2 : 1; this.bond.wife = 1; } break; // 右=劝回家:本通结束 +0 case 'p7': this.rep += L ? 2 : 1; break; case 'p8': if (L) { this.rep += 1; this.bond.han = 1; } // 陪聊闺女(含危机拍) break; case 'p9': if (L) this.knot = true; // 直面(播出老主播的晚安);不动 rep break; default: break; } } // ---------------------------------------------------------------- 帮助 / 复述 /** 上滑:帮助 + 状态(第几通 + 口碑档整句),随后重播当前节点(局面复述)。 */ _playHelpAndResume() { this.seqToken++; this.audio.stopVoice(); const ids = ['voice/sys_help']; if (this.phase === 'ONAIR' && this.callN >= 1) { ids.push(PROG_VOICES[this.callN - 1]); ids.push(TIER_VOICES[tierOf(this.rep)]); } this._playSeq(ids, () => { this.voicePlaying = false; if (this.phase === 'ONAIR' || this.phase === 'INTRO') this.playNode(this.nodeId); }); } // ---------------------------------------------------------------- 看门狗 /** 叙事等待点(ASK / ONAIR 链末 / 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) => { // full 档提示句由 guide 的 say 播(guide.test 锁定),不重复播 if (full) return; this.audio.play(n === 1 ? 'voice/gd_continue' : 'voice/gd_again', { queue: true }); }, cond: () => this.phase === phaseName && !this.voicePlaying, }); } /** RING 两级新句提示(gd_ring1 ≠ gd_ring2,绝不逐字重播;两级后仅铃声循环)。 */ _armRingWatchdog() { const full = this.guide.level() === 'full'; this.guide.watchdog({ arm: true, at: RING_WATCHDOG_AT, // full / standard 同参数;standard 由 onEscalate 游戏层补播 say: ['gd_ring1', 'gd_ring2'], onEscalate: (n) => { if (full) return; this.audio.play(n === 1 ? 'voice/gd_ring1' : 'voice/gd_ring2', { queue: true }); }, cond: () => this.phase === 'RING', }); } /** 抉择点静默兜底:25s/50s 重播当前 p* 提示句本身(复述选项 = 合法帮助,N3 先例)。 */ 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.phase === 'ONAIR' && this.choiceArmed && !this.voicePlaying, }); } // ---------------------------------------------------------------- 语音链(storyline 范式) /** * 顺序播放一组对白,全部自然播完后回调。被打断时回调作废(seqToken 机制)。 * 单句 30s 看门狗:超时且 isVoicePlaying()===false(资产缺失)才自动续链; * voice 真在播(长句)则续约等待,不得掐断(storyline N1 先例照抄)。 */ _playSeq(ids, onDone) { const token = ++this.seqToken; // F4:消费续玩跳句锁——恢复链的第一句起播时落锁,该句播完(i>0 / 链完)或 // 被任何新链顶掉(token 更替)即解锁;非续玩链一律无锁。 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) let advanced = false; const armWd = () => setTimeout(() => { if (advanced || token !== this.seqToken) return; if (this.audio.isVoicePlaying()) { wd = armWd(); return; } // 长句在播:续约不掐 advanced = true; console.warn('[RadioGame] 语音看门狗超时(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); } // ---------------------------------------------------------------- 环境音(游戏层循环) /** * 通话侧 amb(信息层:雨夜街道/车站/儿童房/公车站/驾驶室)。 * sfx 层无 loop 选项 → 按解码时长 onEnded 重触发(escape amb 先例); * 进 BRIDGE/菜单/重开时 _stopAmb。重触发循环会让无头虚拟时钟永不空闲, * 测试置 ambLoopEnabled=false(每通仍起播一次,断言可用)。 */ async _startAmb(id) { this._stopAmb(); this._ambId = id; this.audio.play(id, { volume: 0.8 }); if (!this.ambLoopEnabled) return; // 引擎 _load 是 prewarm 同款解码缓存(含 Promise),取 buffer 时长排重触发 const buf = await this.audio._load(id); if (!buf || this._ambId !== id) return; this.ambTimer = setTimeout(() => { if (this._ambId === id) this._startAmb(id); }, Math.max(1200, (buf.duration - 0.4) * 1000)); } _stopAmb() { this._ambId = null; if (this.ambTimer) { clearTimeout(this.ambTimer); this.ambTimer = null; } } // ---------------------------------------------------------------- 存档(§8) /** data 结构照 §8.1;summary 字符串必带(大厅卡片直接显示)。 */ _save() { const k = Object.keys(this.endings).length; const signed = `${this.rep >= 0 ? '+' : ''}${this.rep}`; let summary; if (this.phase === 'ENDING' && this.endingId) { summary = `已收播·${ENDINGS[this.endingId].name} · 结局${k}/4`; } else { const head = this.callN >= 1 ? `第${this.callN}通·${CALLS[this.callN - 1].label}` : '深夜开播'; summary = `${head} · 口碑${signed} · 结局${k}/4`; } const data = { schema: 1, updatedAt: new Date().toISOString(), summary, phase: this.phase, node: this.nodeId, call: this.callN, ending: this.endingId, rep: this.rep, knot: this.knot, heardArgument: this.heardArgument, bond: { ...this.bond }, endings: { ...this.endings }, nights: this.nights, lastCloser: this.lastCloser, }; 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: 'radio', 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 RadioGame;