/** * 《不眠频率》R4 —— 深夜电台热线叙事 RPG,按 docs/design/26-audio-interaction-playbook.md * 深度重构(设计单 docs/design/26-apply-radio.md;玩法正文 docs/design/15-radio.md)。 * * 玩法核心不动:节点链剧情树、五通来电、rep/bond/heardArgument 数值、4 结局、 * 尾声回响全部保留;交互层整体换成 26 号六模块(storyline 范式): * - 发言权(§4.1):整条语音链 = SPEAKING(BGM 压低跨链、句间不掉回「轮到你」 * 听感);链完 → floor.yieldTurn(等待点):交接提示音 → 400ms 后等待声——RING * 用铃声循环代替合成等待声(15 号 §2 声音身份);不存在安静态,任何打断后 * 由 _ensureWait 按当前局面重新交出话轮。 * - 输入管道(§4.2):ok/invalid/busy 三件套回执 + 400ms 防连点;新手手势门、 * 迷茫检测、等待点缩圈全在 engine/pipeline.js(index.html 接线)。 * - 手势宪法(§4.3):doubleTapWindow=0;双击未解锁按单击处理;up=我在哪 * (阶段 1)、down=菜单(阶段 2)、双击=重听当前等待点完整句(阶段 3)。 * 旧「双击=重听整段 / RING 重听收尾句 / 菜单单击循环+双击确认」全部废止 * (26 §7 反模式 5;00 号 §0 菜单废止行)。 * - 三档提示(§4.4):每个等待点 full → short → 提示音;主动重听 =「再说一遍:」 * + 完整句;被动提示永不原样重复(选项句链播即登 30s 台账,静默提示自动降档)。 * - 迷茫检测(§4.5):乱点/连无效/静默信号累计 ≥2 → 三级接管(短句 → 只留期待 * 手势 → 演示;抉择点演示只复述方向,不替玩家做有后果的选择)。 * - 首 3 分钟(§4.10):第一句 =「点一下屏幕。」(critical 指令句),砍掉开场 * 规矩念白(b_rule 废除);RING 状态声首现配「热线灯亮了。点一下,接听。」; * 「轮到你」状态配 sys_turn_teach;首个抉择给「对。」并解锁上滑(教学句挂 * 下一段链尾,原则 11)。 * - 断点复述(§4.12):每通 RING、每个抉择、ENDING 自动存档;回来先问 * 「单击继续上次 / 右滑从头开始」(sys_continue_ask),单击后重播当前节点。 * - 跳过纪律(15 号 G5 的 26 号化):p* 首播 critical——tap/滑动都给 busy 回执 * (盲玩公平);其余句 tap=跳句、滑动=打断并执行,链尾交接由 floor 的 onDone * 保证不被吃掉(旧 F4 恢复锁 / F5 链末句保护被结构性消解;复播天然可掐)。 * * 状态 × 手势矩阵(新手期,完整版见设计单): * | 状态 | 单击 | 左/右滑 | 上滑 | 下滑(未解锁) | 双击(未解锁) | * |------------|------------------|-----------------------|------------------|---------------|--------------| * | 游戏在说 | 跳过这句+ok(p*=busy) | 打断→invalid+解释(不盲选) | 打断,答「我在哪」 | invalid+解释 | 按单击处理 | * | 等你继续 | 继续+ok | invalid(inv_nosel) | 我在哪 | invalid+解释 | (阶段3=重听)| * | RING 等接听 | 接听+line_connect(silent-ok) | invalid(inv_ring) | 我在哪(通号+口碑) | invalid+解释 | (阶段3=重听)| * | 等你选择 | 再听选项(hold) | 抉择+ok | 「正在抉择。左右滑。」 | invalid+解释 | (阶段3=重听)| * | 结算 | 新夜班+ok | invalid(sys_hint_again_full) | 结局主句+口碑 | 菜单(阶段2) | 按单击处理 | * | 菜单 | 确认当前项+ok | 左=上一项 / 右=下一项,每项报名 | 重听菜单说明 | 关闭 | (阶段3=重听)| */ import { Game } from '../engine/game.js'; import { Floor } from '../engine/floor.js'; import { Receipts } from '../engine/receipts.js'; import { Confusion } from '../engine/confusion.js'; import { WaitPoint, InvalidHints, markHintSaid } from '../engine/hints.js'; import { GestureLadder } from '../engine/novice.js'; import { vibrate } from '../engine/haptics.js'; // ---------------------------------------------------------------- 节点表 /** 叙事节点。voices 顺序播放;prompt 非空 = 抉择节点(p* 链 critical,播完即选)。 */ 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* 提示句一句(critical)。 */ 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)。 * next 为 '@x' 虚拟目标:@ring 进铃声 / @bridge 进幕间 / @h1end 回响插播判断 / * @ending 进结局;选择目标 '@transfer' 由 resolve() 按 p1 选择解析。 * R4:b_rule(开场规矩念白)按 §4.10 废除;INTRO 链只在重开夜班时走。 */ const NODES = { // ---- INTRO 开场链(重开夜班的台标口播,自动流;冷启动不再念,§4.10) 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_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_studio', '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'; // 等待点静默阈值(15 号 §5 等待点配置表;剧情类偏慢、RING/开场偏快) const SILENCE = { intro: [6000, 15000], // 开场第一下:6 秒就提醒(F7:30~60 秒内必须第一次成功) ring: [8000, 18000], // 铃声在闪:8 秒提醒,18 秒降短句 continue: [18000, 40000], choice: [25000, 50000], settle: [20000, 45000], reopen: [12000, 30000], menu: [20000, 45000], }; // 无效操作解释(原则 7:第一次说原因和正确做法,之后只给回执;键 = 状态×手势) const INVALID_LINES = { 'locked:up': 'voice/inv_up_locked', 'locked:down': 'voice/inv_down_locked', 'locked:longpress': 'voice/inv_generic', 'ring:swipe': 'voice/inv_ring', 'story:swipe': 'voice/inv_nosel', 'settle:swipe': 'voice/sys_hint_again_full', }; const BRIDGE_REST_MS = 3000; // 幕间垫乐喘息(自动链 ≤12s:rep 整句 + 3s 垫乐) /** 口碑档位(§4 数值表):≥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); // ---- 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,storyline 同款) // ---- 宏状态(玩法骨架不动:INTRO/RING/ONAIR/BRIDGE/ENDING + MENU/ASK)---- this.phase = 'INTRO'; // INTRO|RING|ONAIR|BRIDGE|ENDING|MENU|ASK this.returnPhase = null; // MENU 关闭后回原状态 this.returnNode = null; this.nodeId = null; this.callN = 0; // 当前通号(0=开场未接入) this.endingId = null; this.leaving = false; // 菜单「保存并回大厅」已确认(跳转大厅前 true) this.saved = null; // ASK 态暂存的存档 this.menuIdx = 0; // 链代际令牌:危机拍前置窗 / 线索音回放 / 幕间喘息定时器的作废判据 this.chainGen = 0; this._bridgeGen = 0; // ---- 跨通话变量(§4 数值表,玩法核心不动)---- 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) // ---- 教学标志(存档携带;做对过的事不教第二遍,原则 11)---- this.ringTaught = false; // RING 状态声首现教学(sys_ring_teach,全局一次) this.turnTaught = false; // 等待声首现教学(sys_turn_teach,挂 z1_a 链尾,全局一次) this.skipHintShown = false; // sys_skip 每实例一次(storyline skip_hint 先例) this.goalMet = false; // 第一个抉择完成 → 解锁上滑(§4.3 阶梯) this._teachHead = null; // 挂下一段链头:首抉择肯定句「对。」 this._teachTail = null; // 挂下一段链尾:解锁教学句(§4.3 阶梯) // ---- 铃声循环 / 环境音循环 / 幕间喘息句柄 ---- this._ringSrc = null; this.ambTimer = null; this.ambId = null; this.restTimer = null; /** 环境音循环开关:无头测试/转写置 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._coldStart(); return; } let settled = false; const goFresh = () => { if (settled) return; settled = true; this._coldStart(); }; 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.chainGen++; // 作废危机拍前置窗 / 线索音回放等在途定时器 this._bridgeGen++; // 作废幕间喘息 this.floor.interrupt(); this._stopRing(); this._stopAmb(); this._cancelRest(); this.audio.stopAll(); } /** 冷启动首 3 分钟开场(§4.10 逐秒脚本,见设计单)。 */ _coldStart() { this.phase = 'INTRO'; this.nodeId = null; if (!this.nights) this.nights = 1; this.audio.play('sfx/onair_sting'); this.audio.play('sfx/amb_studio', { volume: 0.8 }); // 0~3 秒:第一句就让玩家动手。critical 指令句(<3s):抢拍单击给 busy 回执, // 保证唯一一次教学完整到达(F7:第一分钟内必须给出第一次成功) this.floor.speak(['voice/sys_first_tap'], { critical: true, onDone: () => this.floor.yieldTurn(this._introWp()), }); } /** 开场第一下单击之后:RING 状态声首现教学一句 → 铃声 + 交出话轮(§2 声音身份)。 */ _beginFirstRing() { this.ringTaught = true; this.floor.speak(['voice/sys_ring_teach'], { onDone: () => this.enterRing(1), }); } // ---------------------------------------------------------------- 手势(pipeline 之后) /** pipeline 收口后的游戏处理;返回 'ok' | 'hold' | 'invalid' | 'silent-ok'。 */ onGesture(g) { switch (this.phase) { case 'ASK': return this._onAskGesture(g); case 'MENU': return this._onMenuGesture(g); case 'RING': return this._onRingGesture(g); case 'BRIDGE': return this._onBridgeGesture(g); case 'ENDING': return this._onEndingGesture(g); default: return this._onStoryGesture(g); // INTRO / ONAIR } } stateName() { if (this.phase === 'MENU') return 'menu'; if (this.phase === 'RING') return 'ring'; if (this.phase === 'ASK') return 'reopen'; if (this.phase === 'ENDING') return 'settle'; return 'story'; // INTRO / ONAIR / BRIDGE(BRIDGE 为自动流,手势到不了游戏层) } invalidHint(state, g) { if (state === 'locked') return INVALID_LINES[`locked:${g}`] || 'voice/inv_generic'; if (g === 'left' || g === 'right') { if (state === 'menu') return null; // 菜单内左右滑=换项(合法操作) return INVALID_LINES[`${state}:swipe`] || 'voice/inv_generic'; } return null; // 其余格子只给回执(原则 7:不重复解释) } /** * 当前等待点是否为抉择点(链末交接出的话轮)。真等待态判据:链在播时 * waitPoint 可能是上一点的残留(floor.speak 不摘旧点),不可当真等待—— * 管道正常不会在链播中把手势递进来,这里兜底直调(无头会话/转写器)场景。 */ _atChoice() { const wp = this.floor.waitPoint; return !!(wp && wp.conf.id === 'choice' && !this.floor.speaking); } /** 阶段 3 的双击=重听当前等待点完整句(§4.3);无等待点时按各态语义重播。 */ _replayWaitPoint() { const wp = this.floor.waitPoint; if (wp && !this.floor.speaking) { wp.requestReplay(); return 'hold'; } return null; } _onStoryGesture(g) { const node = NODES[this.nodeId]; switch (g) { case 'tap': { // 游戏在说 → pipeline 已按「跳过这句」处理(consumed),到不了这里 if (this._atChoice()) { this.floor.waitPoint.requestReplay(); // 空点=主动再听选项(原则 9) return 'hold'; } if (this.floor.waitPoint && this.floor.waitPoint.conf.id === 'intro') { // 开场第一下(或开场教学链被打断后的恢复):进第一通(§4.10) this._beginFirstRing(); return 'ok'; } if (node && node.next != null) { this.advance(node.next); return 'ok'; } this._ensureWait(); return 'invalid'; } case 'doubletap': { const r = this._replayWaitPoint(); if (r) return r; this._replayNode(); return 'ok'; } case 'left': case 'right': { if (node && node.prompt != null && this._atChoice()) { this.choose(g); return 'ok'; } // 非抉择点滑动(含打断旁白):不盲选,解释一次(inv_nosel) this._ensureWait(); return 'invalid'; } case 'up': { if (this._atChoice()) { this.floor.speak(['voice/status_choice'], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'ok'; } return this._speakStatus() ? 'ok' : 'invalid'; } case 'down': this.openMenu(); return 'ok'; default: this._ensureWait(); return 'invalid'; } } _onRingGesture(g) { switch (g) { case 'tap': this._stopRing(); // 铃声循环垫接听即停 this.floor.bedEnabled = true; this.phase = 'ONAIR'; this.playNode(this._callEntry()); return 'silent-ok'; // line_connect 线路音即内容反馈(§4.2 已有明显内容音) case 'doubletap': { const r = this._replayWaitPoint(); if (r) return r; this._ensureWait(); return 'invalid'; } case 'up': return this._speakStatus() ? 'ok' : 'invalid'; case 'down': this.openMenu(); return 'ok'; default: this._ensureWait(); return 'invalid'; // 左右滑:inv_ring「现在不用选。点一下,接听。」 } } _onBridgeGesture(g) { // 幕间为自动流(BUSY 演出态):正常到不了这里(floor 给 busy 回执); // 链被打断的兜底也不重交话轮——喘息定时器会照常把节目流推进到下一通 if (g === 'down') { this.openMenu(); return 'ok'; } return 'invalid'; } _onEndingGesture(g) { const settled = this._atSettle(); switch (g) { case 'tap': if (settled) { this.newNight(); return 'ok'; } // 结局/尾声链被打断后无等待点:重播结局链(局面复述)兜底 this._playEndingChain(); return 'ok'; case 'doubletap': { const r = this._replayWaitPoint(); if (r) return r; this._playEndingChain(); return 'ok'; } case 'up': return this._speakStatus() ? 'ok' : 'invalid'; case 'down': this.openMenu(); return 'ok'; default: this._ensureWait(); return 'invalid'; // 左右滑:解释句 = settle full「单击,重新开始。」 } } _onAskGesture(g) { const s = this.saved; switch (g) { case 'tap': this._resume(s); return 'ok'; case 'right': this.saved = null; this.newNight(); return 'ok'; case 'doubletap': { const r = this._replayWaitPoint(); return r || 'invalid'; } case 'up': { // 询问态的状态一句话:第几通 + 口碑档(存档里的账) const n = (s && s.call) || 0; if (n < 1 || n > CALLS.length) return 'invalid'; const ids = [PROG_VOICES[n - 1], TIER_VOICES[tierOf(s ? s.rep : 0)]]; this._tierCue(ids[1]); this.floor.speak(ids, { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'ok'; } default: this._ensureWait(); return 'invalid'; } } _onMenuGesture(g) { const announce = () => { this.floor.speak([MENU_ITEMS[this.menuIdx].voice], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); }; switch (g) { case 'left': // 上一项,报名(00 号 §5.4:左右滑切换、单击确认) this.menuIdx = (this.menuIdx + MENU_ITEMS.length - 1) % MENU_ITEMS.length; announce(); return 'hold'; case 'right': // 下一项,报名 this.menuIdx = (this.menuIdx + 1) % MENU_ITEMS.length; announce(); return 'hold'; case 'tap': // 确认当前项(单击的全局含义=确定,菜单里不改) this._execMenuItem(MENU_ITEMS[this.menuIdx].id); return 'ok'; case 'up': // 重听菜单说明 + 设置提示(设置只在大厅改) this.floor.speak(['voice/menu_help', 'voice/sys_settings'], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'hold'; case 'doubletap': { const r = this._replayWaitPoint(); return r || 'invalid'; } case 'down': // 再下滑关闭(00 号 §5.4) this.closeMenu(); return 'ok'; default: return 'invalid'; } } /** * 打断兜底(§4.1 不允许安静态):滑动打断了当前链且本游戏判 invalid 时,链已死、 * 等待点可能已被消费——按当前局面重新交出话轮,等待声/铃声重新响起。 * (floor.speak 不摘旧等待点,pipeline 的 settleWaitPoint(false) 只在有等待点时 * 复位;这里补齐「无等待点」的分支,连点/乱滑永不留死局。) */ _ensureWait() { if (this.floor.speaking || this.floor.waitPoint) return; if (this.phase === 'MENU') { this.floor.yieldTurn(this._menuWp()); return; } if (this.phase === 'ASK') { this.floor.yieldTurn(this._reopenWp()); return; } if (this.phase === 'RING') { this.floor.yieldTurn(this._ringWp()); return; } if (this.phase === 'ENDING') { this.floor.yieldTurn(this._settleWp()); return; } if (this.phase === 'BRIDGE') return; // 自动流:喘息定时器照走 if (!this.nodeId) { this.floor.yieldTurn(this._introWp()); return; } // 冷启动开场被打断 const node = NODES[this.nodeId]; if (node && node.prompt != null) this.floor.yieldTurn(this._choiceWp(node)); else this.floor.yieldTurn(this._continueWp()); } // ---------------------------------------------------------------- 上滑「我在哪」(原则 3) /** 状态一句话的分档整句(位置=第几通 → 目标=口碑档;动作由等待声/铃声承担)。 */ _statusIds() { if (this.phase === 'ENDING' && this.endingId) { const def = ENDINGS[this.endingId] || ENDINGS.e1; return [def.voices[0], TIER_VOICES[tierOf(this.rep)]]; // 结局主句 → 口碑 } if (this.callN >= 1 && this.callN <= CALLS.length) { return [PROG_VOICES[this.callN - 1], TIER_VOICES[tierOf(this.rep)]]; } return []; } /** 播状态一句话,播完回到原等待点(不重播节点;口径=G6 状态在前)。 */ _speakStatus() { const ids = this._statusIds(); if (!ids.length) return false; this._tierCue(ids[ids.length - 1]); this.floor.speak(ids, { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return true; } // ---------------------------------------------------------------- 夜班流转 /** 新夜班:重置跨通话标量(结局收集跨夜保留),台标口播后回 RING(c1)。 */ 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.leaving = false; this.saved = null; this._teachHead = null; this._teachTail = null; this.chainGen++; this._bridgeGen++; this.floor.interrupt(); this._stopRing(); this._stopAmb(); this._cancelRest(); this.floor.bedEnabled = true; this.phase = 'INTRO'; this.audio.play('bgm/onair'); // 从菜单/结局重开为同首不重启 this.nodeId = 'b_open'; this._save(); // 重开不再念开场教学(做对过的事不教第二遍,原则 11);台标口播照走 this.floor.speak(['voice/sys_new_night'], { onDone: () => this.playNode('b_open'), }); } /** RING:热线接入。铃声循环垫即等待声(不起合成「嘟—嘟」)+ 交出话轮。 */ enterRing(n) { this.callN = n; this.phase = 'RING'; this.nodeId = this._callEntry(); // 存档落「本通入口」:RING 断点重进即重铃本通 this._stopAmb(); this.audio.play('bgm/onair', { fadeMs: 600 }); // 进下一通淡回 onair this.floor.bedEnabled = false; // 15 号 §2:RING 用铃声循环代替等待声 this._save(); // 写入时机:每通电话 RING 进入 this._startRing(); this.floor.yieldTurn(this._ringWp()); } /** * 铃声循环垫起播(sfx/phone_ring,程序合成无缝 loop):引擎 sfx 层「即发即弃」 * 无 loop/stop——游戏侧经引擎 ctx/_sfxBus 起循环 BufferSource 并持句柄,接听/ * 菜单/重开的 _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 仍有交接待声 + 等待点提示 */ }); } _stopRingSfx() { if (this._ringSrc) { try { this._ringSrc.src.stop(); } catch (_) { /* 已停止 */ } this._ringSrc = null; } } _stopRing() { this._stopRingSfx(); } /** BRIDGE:幕间喘息(自动流,BUSY 演出态)。口碑跨档被动播报 → 3s 垫乐 → 下一通。 */ enterBridge() { this._stopAmb(); this.phase = 'BRIDGE'; this.floor.interrupt(); // 自动流不挂等待点;摘掉残留等待点/杀在途链 this.audio.play('sfx/line_drop'); // 挂断咔哒 this.audio.play('bgm/bridge', { fadeMs: 800 }); // crossfade 进幕间 const gen = ++this._bridgeGen; const tier = tierOf(this.rep); const ids = []; if (tier !== this.lastAnnouncedTier) { this.lastAnnouncedTier = tier; ids.push(TIER_VOICES[tier]); this._tierCue(TIER_VOICES[tier]); // rep_low 附 warning 震 } this.floor.speak(ids, { onDone: () => this._armRest(gen) }); } _armRest(gen) { if (this.restTimer) clearTimeout(this.restTimer); this.restTimer = setTimeout(() => { this.restTimer = null; if (this.phase !== 'BRIDGE' || gen !== this._bridgeGen) 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._stopAmb(); this.audio.play('bgm/ending', { fadeMs: 1500 }); // p9 结果链播完后淡入 // 一局结束 → 手势阶梯阶段 2/3(§4.3),教学句挂结局链尾(做完最后一件事才教) const got = this.ladder.onGameDone(); if (got) this._teachTail = this._teachLineOf(got); 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]); // 救到 / 错过,点名每一通 } const teach = this._takeTeach(); if (teach) chain.push(teach); this.floor.speak(chain, { onDone: () => this.floor.yieldTurn(this._settleWp()), }); } // ---------------------------------------------------------------- 存档续玩(§4.12) _askContinue(save) { this.saved = save; this.nights = save.nights || 1; // 右滑新夜班 → nights+1 的基数从存档起 this.phase = 'ASK'; this.floor.speak(['voice/sys_continue_ask'], { onDone: () => this.floor.yieldTurn(this._reopenWp()), }); } /** 断点续玩:恢复变量并重播当前局面(RING=铃声;ONAIR=重播当前节点=局面复述)。 */ _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.callN = s.call || this._callOfNode(s.node) || 1; this.goalMet = !!s.goalMet || this.ladder.stage() >= 1; this.skipHintShown = !!s.skipHintShown; this.ringTaught = !!s.ringTaught; this.turnTaught = !!s.turnTaught; this.leaving = false; this.chainGen++; this._bridgeGen++; this.floor.interrupt(); this._stopRing(); this._stopAmb(); this._cancelRest(); const phase = s.phase === 'MENU' ? 'ONAIR' : s.phase; if (phase === 'RING') { this.phase = 'RING'; this.floor.bedEnabled = false; this._startRing(); this.floor.yieldTurn(this._ringWp()); } else if (phase === 'BRIDGE') { this.phase = 'BRIDGE'; this.audio.play('bgm/bridge', { fadeMs: 800 }); this._armRest(++this._bridgeGen); // 续走幕间喘息 } else if (phase === 'ENDING') { this.enterEnding(s.ending && ENDINGS[s.ending] ? s.ending : undefined); } else { this.phase = 'ONAIR'; this.playNode(NODES[s.node] ? s.node : this._callEntry()); } } _callOfNode(id) { const c = typeof id === 'string' ? id[0] : ''; return CALL_OF_NODE[c] || 1; } _callEntry() { const call = CALLS[this.callN - 1]; return call && NODES[call.entry] ? call.entry : 'z1_ring_in'; } // ---------------------------------------------------------------- 节点链驱动(floor 范式) playNode(id) { const node = NODES[id]; if (!node) { console.error('[RadioGame] 未知节点:', id); return; } this.nodeId = id; this._save(); // 写入时机:每个节点进入时(§4.12 任意时刻退出都在本节点上) if (node.amb) this._startAmb(node.amb); if (id === 'p9') this.audio.play('bgm/onair', { fadeMs: 600 }); // 签收回 onair if (node.danger) { // 危机拍:danger 震 + 前置预警窗(brake_screech 资产自带渐强),BUSY 演出态 this.floor.interrupt(); vibrate('danger'); if (node.sfx) this.audio.play(node.sfx); const gen = ++this.chainGen; setTimeout(() => { if (gen !== this.chainGen) return; this._speakNode(node); }, node.preMs || 0); return; } this._speakNode(node); } /** 节点语音链:链头/链尾附加(教学)→ floor.speak → 链完交出对应等待点。 */ _speakNode(node) { const id = this.nodeId; ++this.chainGen; const seq = this._nodeVoices(id); if (node.prompt) seq.push(node.prompt); if (this._teachHead) { seq.unshift(this._teachHead); this._teachHead = null; } // 「对。」 const teach = this._takeTeach(); // 解锁教学句挂链尾(§4.3 阶梯) if (teach) seq.push(teach); // 「轮到你」状态声首现教学(原则 2):挂老周开口(z1_a)链尾,全局一次 if (id === 'z1_a' && !this.turnTaught) { this.turnTaught = true; seq.push('voice/sys_turn_teach'); } // sys_skip:开场每实例一次(「tap=跳句」的可发现性,storyline skip_hint 先例) if (id === 'z1_b' && !this.skipHintShown) { this.skipHintShown = true; seq.push('voice/sys_skip'); } 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); } if (node.prompt) markHintSaid(node.prompt); // 原则 9:选项句已播,被动提示自动降档 this.floor.speak(seq, { critical: !!node.prompt, // p* 首播不可跳(盲玩公平,15 号 G5;tap/滑动=busy) onDone: () => this._onNodeDone(node), }); } /** 节点基础语音链(含节点内条件选播: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]; } /** 双击重听(无等待点时)/ 打断兜底:重播当前节点链(复播可掐;p* 复播仍 critical)。 */ _replayNode() { const node = NODES[this.nodeId]; if (!node) { this._ensureWait(); return; } this._speakNode(node); } /** '@x' 虚拟目标 = 过场转移(电台节目流自动衔接,§2.2 转移图「链完→」)。 */ _isTransition(target) { return typeof target === 'string' && target.startsWith('@'); } /** * 节点链自然播完的公共收尾(floor 保证跳句/打断不会吃掉这次交接): * 抉择节点 → choice 等待点;auto / @x 过场 → 自动衔接;其余 → continue 等待点。 */ _onNodeDone(node) { if (node.prompt != null) { this.floor.yieldTurn(this._choiceWp(node)); // 播完当刻即可选,零插播(W2) return; } if (node.next == null) { this._ensureWait(); return; } if (node.auto || this._isTransition(node.next)) { this.advance(node.next); return; } this.floor.yieldTurn(this._continueWp()); } /** 链完转移:'@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 数值表)→ 存档 → 首目标解锁上滑(教学句挂下一段链头/尾)→ 进结果节点。 */ choose(g) { const node = NODES[this.nodeId]; if (!node || node.prompt == null) return; this.audio.play('sfx/ui_confirm'); this._applyChoice(this.nodeId, g); this._save(); // 写入时机:每个抉择结算后 // 完成第一个小目标 → 解锁上滑(§4.3 阶段 1): // 「对。」挂下一段链头(原则 11 肯定 ≤4 字),教学句挂链尾(一次只教一个) if (!this.goalMet) { this.goalMet = true; const got = this.ladder.onGoal(); if (got) { this._teachHead = 'voice/sys_praise'; this._teachTail = this._teachLineOf(got); } } 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 表行「p4 问她/哄睡」的列序与 §3.2 树形/提示句相反,以树形为准。 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; } } /** 解锁教学句 voice id(novice.teachLine 已返回带 voice/ 前缀的 id,直接进链)。 */ _teachLineOf(got) { return this.ladder.teachLine(got); } _takeTeach() { const t = this._teachTail; this._teachTail = null; return t; } // ---------------------------------------------------------------- 等待点(§4.4 附录 A 配置) _introWp() { return new WaitPoint({ id: 'intro', expected: ['tap'], full: null, // 指令句刚说过:静默第一档直接短句(§4.10 找猫示例同型) short: 'voice/sys_first_tap_short', silenceMs: SILENCE.intro, onDemo: () => { // 迷茫第 3 级:替玩家点一下(直接进第一通),话轮在通话链末交回 this.floor.speak(['voice/sys_demo_continue'], { onDone: () => this._beginFirstRing() }); }, }, { floor: this.floor, confusion: this.confusion }); } _ringWp() { return new WaitPoint({ id: 'ring', expected: ['tap'], full: 'voice/gd_ring1', short: 'voice/sys_hint_ring_short', silenceMs: SILENCE.ring, onDemo: () => { // 演示接听:替玩家走一步(进通话),话轮在通话链末交回 this.floor.speak(['voice/sys_demo_continue'], { onDone: () => { this._stopRing(); this.floor.bedEnabled = true; this.phase = 'ONAIR'; this.playNode(this._callEntry()); }, }); }, }, { 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 }); } _choiceWp(node) { return new WaitPoint({ id: 'choice', expected: ['left', 'right'], full: node.prompt, // 主动重听给完整选项;被动提示因台账自动降档(原则 9) short: 'voice/sys_hint_choice_short', restrictLine: 'voice/sys_only_choice', silenceMs: SILENCE.choice, onDemo: () => { // 演示不替玩家做有后果的抉择:复述两个方向,交回(storyline 同款) 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 }); } _reopenWp() { return new WaitPoint({ id: 'reopen', expected: ['tap', 'right'], full: 'voice/sys_continue_ask', short: 'voice/sys_hint_continue_short', silenceMs: SILENCE.reopen, onDemo: () => this.floor.chime(), }, { floor: this.floor, confusion: this.confusion }); } _menuWp() { return new WaitPoint({ id: 'menu', expected: ['tap', 'left', 'right'], full: 'voice/menu_help', short: 'voice/sys_hint_menu_short', silenceMs: SILENCE.menu, onDemo: () => this.floor.chime(), }, { floor: this.floor, confusion: this.confusion }); } _atSettle() { const wp = this.floor.waitPoint; return !!(this.phase === 'ENDING' && wp && wp.conf.id === 'settle' && !this.floor.speaking); } // ---------------------------------------------------------------- 菜单(00 号 §5.4:左右滑切换、单击确认) openMenu() { this.returnPhase = this.phase; this.returnNode = this.nodeId; this.phase = 'MENU'; this.menuIdx = 0; this.floor.interrupt(); this._stopRing(); this._cancelRest(); this.audio.play('sfx/ui_menu'); this.audio.play('bgm/menu', { fadeMs: 500 }); // 菜单 pad crossfade 500ms this.floor.speak(['voice/sys_menu', MENU_ITEMS[0].voice], { onDone: () => this.floor.yieldTurn(this._menuWp()), }); } _execMenuItem(id) { if (id === 'resume') { this.closeMenu(); } else if (id === 'restart') { this.newNight(); } else if (id === 'lobby') { // 保存并回大厅:确认句播出且存档落盘后跳转(「确认后跳转前播」) this.leaving = true; this._save(); this.floor.speak(['voice/sys_menu_lobby'], { onDone: () => { try { if (typeof window !== 'undefined' && window.location) window.location.href = '../'; } catch (_) { /* 无头环境无 location:留档 leaving 标记 */ } }, }); } } closeMenu() { this.floor.interrupt(); // 摘掉菜单等待点、杀残留报名链 const back = this.returnPhase || 'ONAIR'; 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 }); // 关菜单淡回原状态曲 if (back === 'RING') { this.floor.bedEnabled = false; this._startRing(); this.floor.yieldTurn(this._ringWp()); } else if (back === 'BRIDGE') { this._armRest(++this._bridgeGen); // 幕间续走(口碑句已播过不重播,账目真实) } else if (back === 'ENDING') { this._playEndingChain(); // 重播结局链(局面复述) } else if (back === 'ASK') { this.floor.speak(['voice/sys_continue_ask'], { onDone: () => this.floor.yieldTurn(this._reopenWp()), }); } else { this.playNode(this.returnNode); // INTRO/ONAIR:重播当前节点(局面复述) } } // ---------------------------------------------------------------- 口碑提示(G2:rep_low 附 warning 震) _tierCue(tierId) { if (tierId === TIER_VOICES.low) vibrate('warning'); } // ---------------------------------------------------------------- 环境音(游戏层循环) /** * 通话侧 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; } } // ---------------------------------------------------------------- 存档(§7;summary 服务器进度卡片用) /** data 结构照 §7;summary 字符串必带(大厅卡片直接显示)。 */ _save() { if (!this.nodeId) return; 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 === 'MENU' ? (this.returnPhase || 'ONAIR') : 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, goalMet: this.goalMet, skipHintShown: this.skipHintShown, ringTaught: this.ringTaught, turnTaught: this.turnTaught, }; 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;