/** * 《深海回音》——深海救援叙事 RPG(docs/design/17-abyss.md)。 * * 你是深潜救援员:声呐里是被困海沟的潜艇「海燕号」——氧气在倒数,每次下潜 * 都是抉择:先救谁、花多少气、留不留、上不上去。148 句台词见 * 17-abyss-voice-ids.md;4 潜共享主干、9 抉择点(单局实走 7-9)、4 结局、 * 跨潜只传标量(oxy / saved{} / stayer / 6 flag + bond 好感派生账)。 * * 6 宏状态(§2.1):INTRO / DIVE / COMMS / SURFACE / ENDING + MENU 覆盖层 * (另加 ASK = 有存档进游戏的「继续上次?」询问态,radio 同款前置瞬态)。 * DIVE 内分两段:下潜链(auto)→ 搜索等待(单击=发一枚声呐脉冲,宪法 §5.1 * 「检查」义;回波→静默一拍→解读,R2 声呐主导段)。 * * 手势(§2.3,6+2 全覆盖零豁免): * - tap :播放中=跳过本句(p* 不可跳 / F5 链末句 / F4 续玩锁); * 声呐步等待中=快进该回波余音(危机拍前置窗除外); * 链末=继续;搜索等待=发一枚声呐脉冲;ENDING 结算态=再来一局 * - doubletap :重听当前节点台词(含抉择提示);搜索等待=重听上一条回波 * +其解读句(尚无回波=无效,零静默);ENDING=重听结局链; * MENU=确认执行(§5.2 模态约定覆盖重听) * - left / right :COMMS/SURFACE 抉择(左=走心多花气 / 右=克制省气;p* 播放中 * 缓存 pendingChoice);其余态无效 → error 震 + gd_invalid * - up :帮助+状态(潜次+氧档),G6 顺序 prog → oxy → sys_help * - down :模态菜单(单击循环 / 双击确认 / 上滑帮助 / 下滑关闭) * - longpress :引擎接管旁白 1.7×(叙事类不豁免,游戏不收此手势) * * 氧气经济(§4.3):步进资源非实时倒计时。每潜开始扣 3(D4 双载额外 +1); * 抉择按 §4.2 扣;mid-dive 扣至负数一律 floor 到 0(人已挂上吊索照常带上), * floor 触发干涸链 dry_a(链入口 800ms 手势死窗)→ 上浮结算内 dry_b(归因 * 真话)。档位以 floor(oxy/3)(可下潜次数)划界,只在跨档时挂上浮结算链尾; * SURFACE(3) 结算后走氧气门三形态(≥4 双载 / =3 单载 / ≤2 终局取消链)。 * * 声呐双通道(§6.2):sonarGate 空闲门——信息类回波(echo_* / pipe_knock / * groan)只在 voice idle 时播出,否则挂队列表、链末补发(R1 零重叠); * 危机拍(D2 火花 / D4 滑坡)前置 2s 起播(danger 签名 + 音效同窗,D4 叠 * danger 震循环 ≥400ms 间隔);语音在播时 amb_deep 降 -6dB(R3 常驻退位)。 * * 结算死窗(H1 内化):终局取消链 / 干涸链 / e* 结局链入口盖 800ms 手势 * 死窗,窗内手势一律吞掉,结算链完整播出。 * * 看门狗双轨(§7.2):无操作轨(guide watchdog,任何手势复位;搜索等待 * [10,25] gd_ping1/gd_ping2 两级不同句;叙事链末 [13,30](R2-F7 起 full 档与 * standard 对齐)gd_continue/gd_again)+ 无进展轨(游戏层计时 60/120s gd_prog1/gd_prog2, * 仅挂搜索等待与 COMMS 链末两个可饿死点;无效手势不复位——乱按 96s 也必被 * 抢救;两轨同级交叉登记去重;guide=off 不挂任何定时器)。 * * radio R2/R3 修复清单全部继承(15-radio.md 实现核对 #1-8 + G1-G6): * 链末等待(auto 标志生效)/ longpress 不误报无效 / 菜单报名游戏侧排队 / * 首潜前重听报无效 / ENDING 上滑含状态 / stop() 先 seqToken++ 作废在途链 / * newRun 清抉择武装 / 循环垫句柄防双链;G1 帮助链去重(同等待点第 2 次起 * 纯音频标记)、G2 oxy_crit 附 warning 震、G4 不可跳句空点 beat 轻震、 * G5 首播必完整复播可即掐、G6 状态链顺序。 * * R2 修复(research/new8/abyss/round1/synthesis.md F1-F10):F1 向导散句在播 * 时发脉冲先掐散句立即出音(ping 不再迟到 12~17s);F2 F5 尾句保护改认实际 * 播放链尾(dyn 注入的档位句纳入保护)+ 签收链入口 800ms 死窗;F3 回波 * wait = 资产实长+静默一拍(「回波→静默一拍→解读」兑现);F4 双载 D4 补播 * dv4_dual 扣账句(3+1 不再无声);F5 crit 水面变体 oxy_crit_deck;F6 叙事 * 尾句保护改首播制(_tailHeard 复播可即掐,G5 后半句兑现)+ sys_skip 改词; * F7 叙事链末 full 档看门狗 [13,30];F8 danger 震循环 1100ms;F9 状态链 * crit 震按档进入去重;F10 状态链插入已救人数 prog_saved1..5。 * * 资产缺失兜底:引擎 console.error 静默跳过且不回调 onComplete;单句语音 * 看门狗 30s 且 isVoicePlaying()===false 才自动续链(storyline N1 先例照抄) * ——屏蔽任意 voice 资产仍可一路走到结局,不卡死。环境音循环(amb_deck / * amb_deep / breath_loop)由游戏层 buffer.loop=true 持句柄(radio _ringSrc * 先例,无重触发定时器,无头测试可复现)。 */ import { Game } from '../engine/game.js'; import { Guide } from '../engine/guide.js'; import { vibrate } from '../engine/haptics.js'; // ---------------------------------------------------------------- 声呐时序常量 // 声呐链驻留时长(ms):脉冲→回波静默一拍 / 回波余音 / 危机前置 2s(L2) // R2-F3:回波 wait = 资产实长 + 静默一拍(§6.2 R2「回波→静默一拍→解读」)—— // echo_metal/echo_debris 2.76-2.78s、pipe_knock 2.96s、echo_groan 4.42s // (R1 实测尾巴压解读句开头 0.35~0.55s,wait 常数全部低于资产实长) const PING_GAP_MS = 900; const W_Metal = 3300; const W_Knock = 3500; const W_Groan = 5000; const W_Crisis = 2000; const W_Bell = 3200; const DANGER_LOOP_MS = 1100; // danger 震循环间隔(≥400ms,§5.2;R2-F8:500ms 约 10 连震过密) // ---------------------------------------------------------------- 节点表 /** 声呐步:信息音/危机前置音,wait = 起播后链驻留时长(ms)。 */ function S(sfx, wait, extra = {}) { return { sfx, wait, danger: false, dangerLoop: false, volume: undefined, ...extra }; } /** 叙事节点。voices 顺序播放;pre/post 为声呐步(链内零重叠,§6.2 R1)。 */ function N(voices, opts = {}) { return { voices, prompt: null, left: null, right: null, next: null, dyn: null, pre: null, post: null, sfx: null, auto: false, enterComms: false, ...opts, }; } /** 抉择节点:本体无台词,链尾接 p* 提示句(PROMPT_RE 命中 → 不可跳)。 */ function P(prompt, left, right, opts = {}) { return { voices: [], prompt, left, right, next: null, dyn: null, pre: null, post: null, sfx: null, auto: false, enterComms: false, ...opts, }; } /** * 全部节点(id = 首句 voice id;台词全文见 17-abyss-voice-ids.md,148 句)。 * next 为 '@x' 虚拟目标:@dive 进第一潜 / @search 武装搜索等待 / * @surfaceN 上浮结算 / @surfaceNext 结算后分流(下一潜 / 氧气门 / 签收)/ * @signoff 签收链 / @ending 进结局。p7 提示与目标按氧气门形态动态解析 * (双载 p7 / 单载 p7_chen|p7_zheng,dyn='p7')。 */ const NODES = { // ---- INTRO 开场链(甲板口播 + 一句规矩 + 今晚目标;链末等待单击,§2.2) b_open: N(['voice/b_open'], { next: 'b_rule' }), b_rule: N(['voice/b_rule'], { next: 'b_goal' }), b_goal: N(['voice/b_goal'], { next: 'b_go' }), b_go: N(['voice/b_go'], { next: '@dive' }), // ---- D1 找船·小舟(教学潜:单击=脉冲 / 金属回波=船身,两大教学承诺) dv1_down: N(['voice/dv1_down'], { auto: true, next: 'dv1_teach' }), dv1_teach: N(['voice/dv1_teach'], { auto: true, next: '@search' }), // 搜索回波链:脉冲 → 静默一拍 → echo_metal_l → 解读 dv1_metal(承诺音) sv1_echo: N(['voice/dv1_metal'], { auto: true, pre: [S('sfx/sonar_ping', PING_GAP_MS), S('sfx/echo_metal_l', W_Metal)], next: 'dv1_link', }), dv1_link: N(['voice/dv1_link'], { sfx: 'sfx/link_beep', auto: true, next: 'z1_a' }), z1_a: N(['voice/z1_a'], { sfx: 'sfx/static_burst', enterComms: true, next: 'z1_b' }), z1_b: N(['voice/z1_b'], { next: 'z1_knock' }), // z1_knock 垫 pipe_knock 两遍(线索音 2-3 次取确定值 2;语音播完才响,R1) z1_knock: N(['voice/z1_knock'], { post: [S('sfx/pipe_knock', W_Knock), S('sfx/pipe_knock', W_Knock)], next: 'p1', }), p1: P('voice/p1', 'd1_listen', 'd1_up'), d1_listen: N(['voice/d1_listen'], { post: [S('sfx/pipe_knock', W_Knock)], next: 'd1_code' }), d1_code: N(['voice/d1_code'], { next: 'p2' }), d1_up: N(['voice/d1_up'], { next: 'z1_bye' }), z1_bye: N(['voice/z1_bye'], { next: 'p2' }), p2: P('voice/p2', 'd1_log', 'd1_nolog'), d1_log: N(['voice/d1_log'], { next: '@surface1' }), d1_nolog: N(['voice/d1_nolog'], { next: '@surface1' }), // ---- SURFACE(1) 上浮结算(自动链 ≤15s:扣账句 → 跨档档位句 → 直接下一潜) s1_surf: N(['voice/d1_surf'], { auto: true, next: 's_tier' }), // ---- D2 机舱·老白(危机拍#1:danger 签名 + spark_flash 前置 2s) dv2_down: N(['voice/dv2_down'], { auto: true, next: '@search' }), sv2_echo: N(['voice/dv2_found'], { auto: true, pre: [S('sfx/sonar_ping', PING_GAP_MS), S('sfx/echo_debris', W_Metal), S('sfx/pipe_knock', W_Knock)], next: 'bai_a', }), bai_a: N(['voice/bai_a'], { enterComms: true, next: 'bai_b' }), bai_b: N(['voice/bai_b'], { next: 'p3' }), p3: P('voice/p3', 'd2_pry', 'd2_cut'), d2_pry: N(['voice/d2_pry'], { next: 'd2_pryres' }), // 节点内条件选播(radio w1_say 先例):knowsPipe 决定 -0 / -1 的差分收尾 d2_pryres: N([], { dyn: 'd2pry', next: 'bai_out' }), d2_cut: N(['voice/d2_cut'], { auto: true, post: [S('sfx/spark_flash', W_Crisis, { danger: true })], next: 'd2_flash', }), d2_flash: N(['voice/d2_flash'], { next: 'bai_out' }), bai_out: N(['voice/bai_out'], { next: 'p4' }), p4: P('voice/p4', 'd2_valve', 'd2_leave'), d2_valve: N(['voice/d2_valve'], { post: [S('sfx/valve_close', W_Metal)], next: 'd2_valve_e' }), d2_valve_e: N(['voice/d2_valve_e'], { next: 'bai_thanks' }), bai_thanks: N(['voice/bai_thanks'], { next: '@surface2' }), d2_leave: N(['voice/d2_leave'], { next: '@surface2' }), // ---- SURFACE(2):d2_surf → d2_supply → oxy_gain(应急瓶 +3,全局唯一) s2_surf: N(['voice/d2_surf'], { auto: true, next: 's2_supply' }), s2_supply: N(['voice/d2_supply'], { auto: true, next: 's2_gain' }), s2_gain: N(['voice/oxy_gain'], { auto: true, next: 's_tier' }), // ---- D3 病舱·陈医官/小郑(回波预警埋 D4 险;小郑无台词,吊床固定) dv3_down: N(['voice/dv3_down'], { auto: true, next: '@search' }), sv3_echo: N(['voice/dv3_groan'], { auto: true, pre: [S('sfx/sonar_ping', PING_GAP_MS), S('sfx/echo_groan', W_Groan)], next: 'chen_a', }), chen_a: N(['voice/chen_a'], { enterComms: true, next: 'chen_b' }), chen_b: N(['voice/chen_b'], { next: 'p5' }), p5: P('voice/p5', 'd3_chen', 'd3_zheng'), d3_chen: N(['voice/d3_chen'], { next: 'chen_up' }), chen_up: N(['voice/chen_up'], { next: 'd3_chen_e' }), d3_chen_e: N(['voice/d3_chen_e'], { next: 'p6' }), d3_zheng: N(['voice/d3_zheng'], { next: 'd3_zheng_e' }), d3_zheng_e: N(['voice/d3_zheng_e'], { next: 'chen_stay' }), chen_stay: N(['voice/chen_stay'], { next: 'p6' }), p6: P('voice/p6', 'd3_spare', 'd3_promise'), d3_spare: N(['voice/d3_spare'], { next: 'd3_spare_e' }), d3_spare_e: N(['voice/d3_spare_e'], { next: '@surface3' }), d3_promise: N(['voice/d3_promise'], { next: '@surface3' }), // ---- SURFACE(3):d3_surf → 档位 → 氧气门三形态 s3_surf: N(['voice/d3_surf'], { auto: true, next: 's_tier' }), // ---- D4 指挥舱·高船长(高潮:滑坡危机拍前置 2s 渐强 + danger 震循环) // dv4_down 链内条件选播(R2-F4):双载形态追加 dv4_dual「这趟往返,扣四格气」 // ——D4 双载下潜 3+1 扣账不再是无声扣款(R1 P1 心算账对不上耳朵) dv4_down: N([], { auto: true, next: '@search', dyn: 'dv4down' }), // 回波渐强 + 金属回波(§6.3 承诺:D4 接近指挥舱必播 echo_metal*,取 _r 声像) sv4_echo: N([], { auto: true, pre: [S('sfx/sonar_ping', PING_GAP_MS), S('sfx/echo_groan', W_Groan), S('sfx/echo_metal_r', W_Metal)], next: 'sv4_crisis', }), sv4_crisis: N(['voice/dv4_slide'], { auto: true, pre: [S('sfx/landslide', W_Crisis, { danger: true, dangerLoop: true })], next: 'gao_a', }), gao_a: N(['voice/gao_a'], { enterComms: true, next: 'gao_b' }), gao_b: N(['voice/gao_b'], { next: 'p7' }), // p7 三形态:双载(快慢浮)/ 单载(按 stayer 选 p7_chen|p7_zheng,谁上) p7: P(null, null, null, { dyn: 'p7' }), d4_slow: N(['voice/d4_slow'], { next: 'd4_slow_e' }), d4_slow_e: N(['voice/d4_slow_e'], { next: 'd4_confess' }), d4_fast: N(['voice/d4_fast'], { next: 'd4_fast_e' }), d4_fast_e: N(['voice/d4_fast_e'], { next: 'd4_confess' }), // 心结引入差分:hasLog ? gao_confess : gao_c d4_confess: N([], { dyn: 'gaoconfess', next: 'p8' }), p8: P('voice/p8', 'd4_talk', 'd4_silent'), d4_talk: N(['voice/d4_talk'], { next: 'gao_peace' }), gao_peace: N(['voice/gao_peace'], { next: '@surface4' }), d4_silent: N(['voice/d4_silent'], { next: '@surface4' }), d4_pick_gao: N(['voice/d4_pick_gao'], { next: 'd4_leave' }), // 带走船长时留守者留别:chen_last | d4_stay_lost(克制措辞);船长在乘 → // 照常走心结差分 → p8(voice-ids:仅带走留守者的右路才跳过 p8) d4_leave: N([], { dyn: 'stayleave', next: 'd4_confess' }), d4_pick_chen: N(['voice/d4_pick_chen'], { auto: true, next: 'd4_gaolast' }), d4_pick_zheng: N(['voice/d4_pick_zheng'], { auto: true, next: 'd4_gaolast' }), // 带走留守者时船长留别(p8 跳过,直达上浮) d4_gaolast: N(['voice/gao_last'], { auto: true, next: '@surface4' }), // ---- SURFACE(4):d4_surf → 档位 → 签收 s4_surf: N(['voice/d4_surf'], { auto: true, next: 's_tier' }), // ---- 档位节点(dyn='tier':只在跨档时播分档整句;空则直通) s_tier: N([], { dyn: 'tier', auto: true, next: '@surfaceNext' }), // ---- 终局取消链(氧气门 ≤2 形态;入口 800ms 手势死窗) c_none: N(['voice/dv4_none'], { auto: true, next: 'c_final' }), c_final: N(['voice/gao_final'], { auto: true, next: '@signoff' }), // ---- 签收 + ◆p9 心结抉择(p9 左需 oxy≥1;不足时 g9_dry 播出后自动走右) g9_in: N(['voice/g9_in'], { auto: true, next: 'g9_knot' }), g9_knot: N(['voice/g9_knot'], { next: 'p9' }), p9: P('voice/p9', 'd9_bell', 'd9_rest'), g9_dry: N(['voice/g9_dry'], { auto: true, next: 'd9_rest' }), d9_bell: N(['voice/d9_bell'], { auto: true, post: [S('sfx/bell_toll', W_Bell)], next: 'd9_bello' }), d9_bello: N(['voice/d9_bello'], { auto: true, next: '@ending' }), d9_rest: N(['voice/d9_rest'], { auto: true, next: '@ending' }), }; // ---------------------------------------------------------------- 常量表 /** 潜次舱段(summary / 状态链用)。 */ const SEGS = ['找船', '机舱', '病舱', '指挥舱']; /** 上滑状态链「第几潜」分档整句(§4.3 数字规避:禁止运行时拼接)。 */ const PROG_VOICES = ['voice/prog_d1', 'voice/prog_d2', 'voice/prog_d3', 'voice/prog_d4']; /** 氧气档位分档整句(floor(oxy/3)=可下潜次数划界,句义逐格为真,§4.3)。 */ const TIER_VOICES = { full: 'voice/oxy_full', ok: 'voice/oxy_ok', low: 'voice/oxy_low', crit: 'voice/oxy_crit' }; /** R2-F5:crit 水面变体——人已出水后「必须上浮」是错场景措辞(甲板=一潜都下不去)。 */ const TIER_CRIT_DECK = 'voice/oxy_crit_deck'; /** 上滑状态链「已救人数」分档整句(R2-F10;saved=0 不播,数字写死禁拼接)。 */ const SAVED_VOICES = [ 'voice/prog_saved1', 'voice/prog_saved2', 'voice/prog_saved3', 'voice/prog_saved4', 'voice/prog_saved5', ]; 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:savedCount × 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'] }, }; const ALL_SFX = [ 'sfx/sonar_ping', 'sfx/echo_metal_l', 'sfx/echo_metal_r', 'sfx/echo_debris', 'sfx/echo_groan', 'sfx/pipe_knock', 'sfx/link_beep', 'sfx/static_burst', 'sfx/spark_flash', 'sfx/valve_close', 'sfx/landslide', 'sfx/surf_splash', 'sfx/ascent_chime', 'sfx/bell_toll', 'sfx/amb_deep', 'sfx/amb_deck', 'sfx/breath_loop', 'sfx/ui_confirm', 'sfx/ui_menu', ]; const SAVE_KEY = 'soundgame.progress.abyss'; const AUTH_KEY = 'soundgame.auth'; /** 抉择提示音 id(voice/p1~p9,含 p7_chen/p7_zheng):播放中单击不可跳。 */ const PROMPT_RE = /^voice\/p\d/; /** 单句语音看门狗:超时且引擎确认 voice 未在播 = 资产缺失才续链(N1 先例)。 */ const VOICE_WATCHDOG_MS = 30000; /** 结算链手势死窗(dungeon H1 同款):窗内手势一律吞掉。 */ const SETTLE_GRACE_MS = 800; // 向导层看门狗(§7.2;full 档 say 由 guide 播,standard 档 onEscalate 游戏层补播) // R2-F7:叙事链末/ENDING full 档与 standard 对齐 [13,30]——R1 主线 25s 首轮静默 // 掐在钩子落地上(两人设点名),标准档 13s 已被 live 局 B 验证不烦人 const GUIDE_WATCHDOG_AT = [13000, 30000]; // full:叙事链末 / ENDING 结算等待 const IDLE_HINT_AT = [13000, 30000]; // standard:同 storyline P2-3 先例 const CHOICE_HINT_AT = [25000, 50000]; // 抉择点:两级都重播当前 p* 提示句本身 const SEARCH_WATCHDOG_AT = [10000, 25000]; // 搜索等待两级**不同句**(gd_ping1≠gd_ping2) const PROG_AT = [60000, 120000]; // 无进展轨(游戏层计时,乱按抢救) /** 氧气档位(§4.3):floor(oxy/3) = 可下潜次数,句子说的就是判定用的。 */ function tierOf(oxy) { const dives = Math.floor(Math.max(0, oxy) / 3); if (dives >= 3) return 'full'; if (dives === 2) return 'ok'; if (dives === 1) return 'low'; return 'crit'; } /** 节点 id → 潜号(断点续玩兜底:存档缺 dive 字段时按节点前缀推断)。 */ function diveOfNode(id) { if (/^(dv|sv)(\d)/.test(id)) return Number(id[2]); if (/^s(\d)_/.test(id)) return Number(id[1]); if (/^z1_|^d1_|^p[12]$/.test(id)) return 1; if (/^bai_|^d2_|^p[34]$/.test(id)) return 2; if (/^chen_|^d3_|^p[56]$/.test(id)) return 3; if (/^gao_|^d4_|^g9_|^d9_|^p[789]$|^c_|^e[1-4]$|^ep_/.test(id)) return 4; return 0; } export class AbyssGame extends Game { constructor(audio, root) { super(audio, root); this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op) this.phase = 'INTRO'; // INTRO|DIVE|COMMS|SURFACE|ENDING|MENU|ASK this.returnPhase = null; // MENU 关闭后回原状态 this.returnNode = null; this.returnSearch = false; // MENU 前是否在搜索等待 this.nodeId = 'b_open'; this.diveN = 0; // 当前潜号(0=甲板简报) this.diveForm = null; // 氧气门形态:'double'|'single'|'cancel'(D4 前置) this.endingId = null; this.voicePlaying = false; this.currentVoiceId = null; // 链上当前句 id(null=声呐步) this.promptPlaying = false; // 当前句是抉择提示 p* this.choiceArmed = false; this.pendingChoice = null; this.awaitingRestart = false; this.leaving = false; // 菜单「保存并回大厅」已确认 this.searchArmed = false; // DIVE 搜索等待(单击=发脉冲) this.seqToken = 0; // ---- 跨潜标量(§4.1;跨潜只传标量,分支不爆炸) this.oxy = 12; // 氧气格(步进资源:只在事件点扣) this.supply = false; // D2 上浮后应急瓶已发(全局唯一) this.saved = { zhou: 0, bai: 0, chen: 0, zheng: 0, gao: 0 }; this.stayer = null; // p5 后留守病舱者('chen'|'zheng') this.flags = { knowsPipe: false, hasLog: false, valve: false, spareGift: false, gaoPeace: false, bell: false, }; this.bond = { zhou: 0, bai: 0 }; // 好感(只影响回响选播,不影响结局判定) this.endings = {}; // 结局收集(跨局保留) this.runs = 0; this.lastAnnouncedTier = null; // 跨档播报基准(null=首潜结算必播档位) this.lastCloser = null; // 上一节点末句(结算双击重听备用) this.saveStash = null; // ASK 态暂存的存档 this._lastEcho = null; // 搜索双击重听(回波步+解读句) this.sysSkipShown = false; // sys_skip 每实例一次 this._briefShown = false; // gd_brief 每实例一次(提示永不逐字重复) this._menuQueue = []; // 菜单项报名队列(游戏侧排队,零截断) this._helpSeen = new Set(); // G1:已全量播过 sys_help 口令的等待点 // F4 断点续玩跳句锁:_resumeGuard 置位,恢复链首句起播时落锁,播完即清 this._resumeGuard = false; this._skipLockId = null; this._graceUntil = 0; // H1:结算手势死窗截止时刻 this._chainTail = null; // 实际播放链的尾句(R2-F2:dyn 注入句也算尾句) this._tailHeard = new Set(); // R2-F6:链自然播完登记的节点(复播可即掐,G5) this._critWarned = false; // R2-F9:状态链 crit warning 震按档进入去重 // 声呐双通道(§6.2):空闲门 + 排队补发(回波与语音零重叠,R1) this._sonarQueue = []; this._sonarSkip = null; // 声呐步等待中的单击快进(危机前置窗除外) this._voiceIntervals = []; // 转写/断言用:voice 占用区间 {id,start,end} this._sonarPlays = []; // 转写/断言用:声呐音播出时刻 {id,t} // 干涸链(§4.3):mid-dive floor 至 0 → dry_a(当场合)→ dry_b(上浮结算内) this._dryPending = false; this._dryAQueued = false; this._dryBQueued = false; // 环境音循环句柄(amb_deck/amb_deep/breath_loop;buffer.loop 无重触发定时器) this._loops = {}; this._ambDucked = false; // R3:语音在播时 amb 降 -6dB(gain ×0.5) this._breathOn = false; // 低氧混音签名(tier=low/crit 且水下) this._breathLog = []; // 断言用:呼吸垫起停轨迹 // 无进展轨(游戏层计时,§7.2 双轨之「乱按也抢救」) this._prog = null; this._progToken = 0; // danger 震循环(D4 危机拍专用,间隔 ≥400ms) this._dangerLoop = null; } // ---------------------------------------------------------------- Game start() { this.audio.play('bgm/deck'); // 甲板(bgm=0 时引擎只记意图,重开自动补播) this.audio.prewarm(ALL_SFX); // 预解码短音效 this._startLoop('sfx/amb_deck', 0.6); 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.newRun(); return; } let settled = false; const goFresh = () => { if (settled) return; settled = true; this.newRun(); }; fetch('/api/progress?game=abyss', { 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 后中断链不得经 30s 看门狗复活 this._stopAllLoops(); this._stopDangerLoop(); this._clearProg(); this._graceUntil = 0; this._resumeGuard = false; this._skipLockId = null; this._sonarQueue.length = 0; this._closeVoiceRecs(); this.audio.stopAll(); } onGesture(g) { this.guide.disarm(); // 任何手势复位看门狗(guide=off 时 no-op) // H1 结算死窗:终局取消链/干涸链/结局链开播 800ms 内的在途手势一律吞掉 // (ghost 手势不算玩家输入——落进结算态会被当 tap 掐死结算链半句) if (performance.now() < this._graceUntil) return; switch (this.phase) { case 'ASK': this._onAskGesture(g); break; case 'MENU': this._onMenuGesture(g); break; case 'DIVE': if (this.searchArmed) this._onSearchGesture(g); else this._onStoryGesture(g); break; case 'INTRO': case 'COMMS': case 'SURFACE': this._onStoryGesture(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.saveStash); else if (g === 'right') this.newRun(); else if (g === 'longpress' || g === 'longpressend' || g === 'tapcancel') { /* 引擎快进接管 */ } else this.guide.error({ voice: 'gd_invalid', onceKey: 'abyss_invalid' }); // F1:非推进手势拆掉的 ASK 空闲看门狗处理完立即重挂(否则询问态无限静默) if (!go && this.phase === 'ASK' && !this.voicePlaying) this._armIdleWatchdog('ASK'); } // ---------------------------------------------------------------- 手势:DIVE 搜索等待 _onSearchGesture(g) { switch (g) { case 'tap': this._firePing(); // 单击=发一枚声呐脉冲(宪法 §5.1「检查」义;恒出 ping) break; case 'doubletap': // 重听上一条回波+其解读句(R5);尚无回波=无效手势零静默(首潜前) this._replayEcho(); break; case 'up': { // 帮助+状态(潜次+氧档),留在搜索等待(§2.3 DIVE 行) this.seqToken++; this.audio.stopVoice(); const ids = this._statusIds(); this._withHelp(ids, `S:search${this.diveN}`); this.searchArmed = false; this._playSeqSteps(ids, () => { this.voicePlaying = false; this._enterSearch(); }); break; } case 'down': this.openMenu(); break; case 'left': case 'right': // 无效手势零静默(§2.3 DIVE 行:喂无进展轨——不复位 prog 计时) this.guide.error({ voice: 'gd_invalid', onceKey: 'abyss_invalid' }); this._rearmSearch(); break; default: this._rearmSearch(); // F1:快进等引擎接管手势同款(空闲态才重挂) break; // longpress/longpressend/tapcancel:引擎快进接管,游戏不处理 } } /** F1:搜索空闲(无对白在播)时重挂两级搜索看门狗;重听/播报在播时不抢挂。 */ _rearmSearch() { if (this.phase === 'DIVE' && this.searchArmed && !this.audio.isVoicePlaying()) { this._armSearchWatchdog(); } } /** 单击=发脉冲:搜索等待 → 回波链(脉冲 → 静默一拍 → 回波 → 解读)。 */ _firePing() { if (!this.searchArmed) return; // R2-F1:向导看门狗行(gd_ping*/gd_prog* 散句)在播时玩家照做单击——先掐 // 散句再发脉冲,玩家脉冲立即出音。R1 根因:ping 走 sonarGate 见 voice 忙入队, // 排水要等链真歇下 → 迟到 12~17s 落在 COMMS 句隙,「回波先于脉冲」错序。 // 搜索等待态游戏链恒不在播(voicePlaying=false),voice 层只可能是向导散句。 if (!this.voicePlaying && this.audio.isVoicePlaying()) this.audio.stopVoice(); this.searchArmed = false; this._clearProg(); // 有真实进展:无进展轨撤防(当次等待点作废) this.playNode(`sv${this.diveN}_echo`); } /** 双击重听上一条回波+其解读句(无回波=无效)。 */ _replayEcho() { if (!this._lastEcho) { this.guide.error({ voice: 'gd_invalid', onceKey: 'abyss_invalid' }); this._rearmSearch(); return; } this.seqToken++; this.audio.stopVoice(); this.searchArmed = false; const steps = [...(this._lastEcho.pre || [])]; if (this._lastEcho.voice) steps.push(this._lastEcho.voice); this._playSeqSteps(steps, () => { this.voicePlaying = false; this._enterSearch(); }); } // ---------------------------------------------------------------- 手势:INTRO / DIVE 链 / COMMS / SURFACE _onStoryGesture(g) { const node = NODES[this.nodeId]; if (!node) return; switch (g) { case 'tap': if (this.voicePlaying) { if (this.currentVoiceId == null) { // 声呐步等待中:快进回波余音(危机拍前置窗不快进,vibrate beat 标记) if (this._sonarSkip) this._sonarSkip(); else vibrate('beat'); return; } // 旁白播放中单击 = 跳过本句(链继续);不可跳的三类: // ① 抉择提示 p*(PROMPT_RE 门,盲玩公平); // ② F5 链末句——等待/过场/抉择交接前的末句; // ③ F4 续玩锁——断点恢复后的首句播完才武装跳句。 if (!this.promptPlaying && !this._isTailSentence() && this.currentVoiceId !== this._skipLockId) { this.audio.skipVoice(); } else { // G4:不可跳句上的单击「点了但没选上」补 beat 轻震(可区分签名) vibrate('beat'); } } else if (this._isPromptNode(node) && this.choiceArmed) { this.armChoiceHint(); // 抉择等待态空点 = 重新挂载兜底(25s 计时重置) } else if (node.next != null) { this._clearProg(); // 有真实进展 this.advance(node.next); } break; case 'doubletap': this.replayNode(); break; case 'left': case 'right': { if (!this._isPromptNode(node)) { // 无效手势零静默(§2.3):error 震 + full 档 gd_invalid(onceKey 同局一次) this.guide.error({ voice: 'gd_invalid', onceKey: 'abyss_invalid' }); // F1:onGesture 入口的 disarm 已拆链末看门狗,处理完立即重挂 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; } } /** 当前是否处于「链末等待单击」态(F1 重挂判据,与 _onNodeSeqDone 等待分支同口径)。 */ _inStoryWait() { const node = NODES[this.nodeId]; return !!(node && !this.voicePlaying && !this._isPromptNode(node) && node.next != null && !node.auto); } /** * F5 链末句保护:当前句是**实际播放链**的末句、且节点把控制权交出(链末等待 * 单击 / @x 过场转移 / 直入抉择提示)时,屏蔽「跳过本句」。自动链中段不受保护。 * R2-F2:尾句改认 _chainTail(_playSeqSteps 按实际步序记录)——R1 根因是 * 用 _nodeSteps 重算,dyn 注入的档位句(s_tier)不在其中,保护形同虚设。 * R2-F6(G5 复播可即掐):非 auto 叙事节点的尾句保护仅限**首播**——链自然 * 播完登记 _tailHeard,菜单/状态复述等重播时尾句可跳(sys_skip 教的必须真); * auto 过场/直入抉择的末句与 p* 提示恒保护(抢跑吃钩子根因不回潮)。 */ _isTailSentence() { const node = NODES[this.nodeId]; if (!node || !this.voicePlaying || this.promptPlaying) return false; if (this.currentVoiceId == null) return false; // 声呐步不适用(由快进语义接管) if (this._chainTail == null || this.currentVoiceId !== this._chainTail) return false; if (!node.auto) return !this._tailHeard.has(this.nodeId); // 链末等待型:首播保护 if (node.next == null || this._isTransition(node.next)) return true; // 自动链终点 const nx = NODES[node.next]; return !!(nx && (nx.prompt != null || nx.dyn === 'p7')); // 自动链直入抉择提示 } // ---------------------------------------------------------------- 手势:ENDING _onEndingGesture(g) { switch (g) { case 'tap': if (this.awaitingRestart) this.newRun(); else if (this.voicePlaying) { if (this.currentVoiceId != null && this.currentVoiceId !== this._skipLockId) { this.audio.skipVoice(); // F4:续玩恢复的首句播完才武装跳句 } else { vibrate('beat'); // G4:续玩锁首句/声呐步上的空点单击同款轻震标记 } } break; case 'doubletap': // 重听结局链(结算态/播放中皆可;重播期间清结算态,播完回到结算态) this.awaitingRestart = false; this.seqToken++; this.audio.stopVoice(); this._playEndingChain(); break; case 'up': { // 状态(结局主句 + 氧档整句)在前 + 帮助垫后(G6),G2:crit 附 warning 震, // G1:同一等待点第 2 次起口令换纯音频标记;播完回结算态 this.seqToken++; this.audio.stopVoice(); const def = ENDINGS[this.endingId] || ENDINGS.e4; const ids = this._withHelp(this._statusIds(), `E:${this.endingId}`); void def; this._playSeqSteps(ids, () => { this.voicePlaying = false; if (this.phase === 'ENDING') { this.awaitingRestart = true; this._armIdleWatchdog('ENDING'); } }); break; } case 'down': this.openMenu(); break; case 'left': case 'right': this.guide.error({ voice: 'gd_invalid', onceKey: 'abyss_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 // 队列,排队句会把链播下一句顶掉(radio 实现修复 #3 的根因)。 this.menuIdx = (this.menuIdx + 1) % MENU_ITEMS.length; this._menuQueue.push(MENU_ITEMS[this.menuIdx].voice); if (!this.voicePlaying) this._drainMenuQueue(); break; } case 'doubletap': this._execMenuItem(MENU_ITEMS[this.menuIdx].id); // 双击确认执行 break; case 'up': { // 帮助 + 菜单内设置提示 + 复述当前项(清空报名队列:帮助链顶掉一切) this._menuQueue = []; this.seqToken++; this.audio.stopVoice(); const ids = this._withHelp([], 'M'); ids.push('voice/sys_settings', MENU_ITEMS[this.menuIdx].voice); this._playSeqSteps(ids, () => { 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.newRun(); } else if (id === 'lobby') { // 保存并回大厅:确认句播出且存档落盘后跳转(§2.2「确认后跳转前播」) this.leaving = true; this.seqToken++; this.audio.stopVoice(); this._save(); this._playSeqSteps(['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.returnSearch = this.searchArmed; this.phase = 'MENU'; this.menuIdx = 0; this._menuQueue = []; this._sonarQueue.length = 0; this._clearProg(); this._stopDangerLoop(); this.seqToken++; this.audio.stopVoice(); this._closeVoiceRecs(); this.audio.play('sfx/ui_menu'); this.audio.play('bgm/deck', { fadeMs: 500, volume: 0.4 }); // MENU 低响度淡入 this.guide.state('menu'); this._playSeqSteps(['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._playSeqSteps(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 === 'DIVE') this.audio.play('bgm/dive', { fadeMs: 800 }); else if (back === 'COMMS') this.audio.play('bgm/comms', { fadeMs: 800 }); else if (back === 'ENDING') this.audio.play('bgm/ending', { fadeMs: 800 }); else this.audio.play('bgm/deck', { fadeMs: 800 }); this.guide.state(back === 'ENDING' || back === 'SURFACE' ? 'settle' : 'explore'); this._updateBreath(); if (back === 'DIVE' && this.returnSearch) { this._enterSearch(); // 搜索等待恢复:turn 签名 + 两级看门狗(局面复述) } else if (back === 'ENDING') { this._playEndingChain(); // 重播结局链(局面复述) } else if (back === 'ASK') { this._playSeqSteps(['voice/sys_continue_ask'], () => { this.voicePlaying = false; this._armIdleWatchdog('ASK'); }); } else { this.playNode(this.returnNode); // INTRO/COMMS/SURFACE:重播当前节点 } } // ---------------------------------------------------------------- 救援流转 /** 新救援:重置跨潜标量(结局收集跨局保留),回 INTRO(sys_new_dive 先行)。 */ newRun() { this.runs += 1; this.oxy = 12; this.supply = false; this.saved = { zhou: 0, bai: 0, chen: 0, zheng: 0, gao: 0 }; this.stayer = null; this.flags = { knowsPipe: false, hasLog: false, valve: false, spareGift: false, gaoPeace: false, bell: false, }; this.bond = { zhou: 0, bai: 0 }; this.diveN = 0; this.diveForm = null; this.endingId = null; this.lastAnnouncedTier = null; this.lastCloser = null; this.leaving = false; this.saveStash = null; this._lastEcho = null; this._dryPending = false; this._dryAQueued = false; this._dryBQueued = false; this.choiceArmed = false; // 清掉上一局的抉择武装(radio 实现修复 #7) this.pendingChoice = null; this.searchArmed = false; this.awaitingRestart = false; this._clearProg(); this._stopDangerLoop(); this.seqToken++; this.audio.stopVoice(); this._stopAllLoops(); this._closeVoiceRecs(); this._sonarQueue.length = 0; this.phase = 'INTRO'; this.audio.play('bgm/deck'); this._startLoop('sfx/amb_deck', 0.6); this.guide.state('explore'); this._save(); this._playSeqSteps(['voice/sys_new_dive'], () => this.playNode('b_open')); } /** * 进潜:扣固定消耗(每次下潜扣 3;D4 双载额外 +1,§4.3),BGM crossfade 进 * dive 曲,amb_deep 循环垫起,播下潜链(auto)→ 链末武装搜索等待。 * noCharge:断点续玩恢复用(存档 oxy 已是扣后值,不二次扣账)。 */ enterDive(n, opts = {}) { this.diveN = n; if (!opts.noCharge) { const cost = 3 + (n === 4 && this.diveForm === 'double' ? 1 : 0); this.oxy = Math.max(0, this.oxy - cost); } this.phase = 'DIVE'; this.searchArmed = false; this._stopLoop('sfx/amb_deck'); this.audio.play('bgm/dive', { fadeMs: 800 }); this.guide.state('explore'); this._startLoop('sfx/amb_deep', 0.6); this._updateBreath(); this.nodeId = `dv${n}_down`; // 存档 node 先行(写入时机:每潜 DIVE 进入) this._save(); this.playNode(`dv${n}_down`); } /** 搜索等待武装(下潜链末):turn 签名+confirm 震 + 两级不同句看门狗 + 无进展轨。 */ _enterSearch() { this.searchArmed = true; this.phase = 'DIVE'; this.guide.turn({ haptic: true }); // 「轮到你发脉冲」(首次前 dv1_teach 已口播) this._armSearchWatchdog(); this._armProg(`search:${this.diveN}`); } /** 接通 COMMS(z1_a/bai_a/chen_a/gao_a 节点 enterComms 标记触发)。 */ _enterComms() { this.phase = 'COMMS'; this.searchArmed = false; this.audio.play('bgm/comms', { fadeMs: 600 }); this.guide.state('explore'); this._updateBreath(); } /** * 上浮结算(自动链 ≤15s):出水 sfx → d*_surf →(D2 后)d2_supply+oxy_gain * →(跨档才播)档位句 → @surfaceNext 分流。补给/氧气门/干涸账目全部在入口 * 一次性结清(supply 幂等守卫、resume 不重复发瓶)。 */ enterSurface(n) { this.diveN = n; this.phase = 'SURFACE'; this.searchArmed = false; this._stopLoop('sfx/amb_deep'); this._startLoop('sfx/amb_deck', 0.6); this._updateBreath(); this.guide.state('settle'); this.audio.play('bgm/deck', { fadeMs: 800 }); this.audio.play('sfx/surf_splash'); this.audio.play('sfx/ascent_chime'); if (n === 1) this.saved.zhou = 1; // 教学潜保底救上(恒活) if (n === 2) this.saved.bai = 1; // 机舱潜恒可成(预算数学保证,§4.4) if (n === 2 && !this.supply) { this.supply = true; // 应急瓶 +3:全局唯一一次 this.oxy += 3; } if (this._dryPending) { this._dryPending = false; this._dryBQueued = true; // 干涸链归因句(上浮结算内,链入口死窗) } this._save(); // 写入时机:SURFACE 进入 this.playNode(`s${n}_surf`); } /** 结算链末分流:D1/D2 → 下一潜;D3 → 氧气门;D4 → 签收。 */ _surfaceNext() { if (this.diveN <= 2) this.enterDive(this.diveN + 1); else if (this.diveN === 3) this._oxygenGate(); else this.playNode('g9_in'); } /** * 第四潜氧气门(§4.3 三形态):oxy≥4 → 双载(带船长+留守者,p7 快慢浮); * oxy=3 → 单载(p7 按 stayer 选 p7_chen|p7_zheng);oxy≤2 → 终局取消链。 */ _oxygenGate() { if (this.oxy >= 4) { this.diveForm = 'double'; this.enterDive(4); } else if (this.oxy === 3) { this.diveForm = 'single'; this.enterDive(4); } else { this.diveForm = 'cancel'; this._grace(); // H1:终局取消链入口手势死窗 this._save(); this.playNode('c_none'); } } /** ENDING:savedCount × p9 四选一 + 尾声回响链,结算态等待单击重开。 */ enterEnding(forced) { const savedCount = ['zhou', 'bai', 'chen', 'zheng', 'gao'] .filter((k) => this.saved[k]).length; const ending = forced || (savedCount === 5 ? (this.flags.bell ? 'e1' : 'e2') : (this.flags.bell ? 'e3' : 'e4')); this.endingId = ending; this.endings[ending] = true; this.phase = 'ENDING'; this.nodeId = ending; this.awaitingRestart = false; this._grace(); // H1:结局链入口手势死窗 this._stopLoop('sfx/amb_deep'); this._startLoop('sfx/amb_deck', 0.5); this._stopDangerLoop(); this.guide.state('settle'); this.audio.play('bgm/ending', { fadeMs: 1500 }); // p9 结果链播完后淡入 this._updateBreath(); // 收队即停呼吸垫 this._save(); // 写入时机:ENDING 进入 this._playEndingChain(); } /** 尾声回响(P2):逐人按好感/生死双线点名 + 沟底按 bell 二选一。 */ _epChain() { const ids = []; ids.push(this.bond.zhou ? 'voice/ep_zhou_ok' : 'voice/ep_zhou_no'); ids.push(this.bond.bai ? 'voice/ep_bai_ok' : 'voice/ep_bai_no'); ids.push(this.saved.chen ? 'voice/ep_chen_ok' : 'voice/ep_chen_lost'); ids.push(this.saved.zheng ? 'voice/ep_zheng_ok' : 'voice/ep_zheng_lost'); if (!this.saved.gao) ids.push('voice/ep_gao_lost'); else ids.push(this.flags.gaoPeace ? 'voice/ep_gao_peace' : 'voice/ep_gao_up'); ids.push(this.flags.bell ? 'voice/ep_trench_bell' : 'voice/ep_trench_deep'); return ids; } _playEndingChain() { const def = ENDINGS[this.endingId] || ENDINGS.e4; const chain = [...def.voices, 'voice/ep_lead', ...this._epChain()]; this._playSeqSteps(chain, () => { this.voicePlaying = false; this.awaitingRestart = true; this._armIdleWatchdog('ENDING'); }); } // ---------------------------------------------------------------- 存档续玩 _askContinue(save) { this.saveStash = save; // 预载 runs/endings:ASK 右滑=新救援走 newRun(+1),与结算重开同口径 //(radio 遗留项在 abyss 修复:被拒绝的存档也把局数账带给下一局) this.runs = (save && save.runs) || 0; this.endings = { ...((save && save.endings) || {}) }; this.phase = 'ASK'; this._playSeqSteps(['voice/sys_continue_ask'], () => { this.voicePlaying = false; this._armIdleWatchdog('ASK'); }); } /** 断点续玩:恢复 phase+node+全部标量并重播当前节点链(一句话局面复述,L5)。 */ _resume(s) { if (!s) { this.newRun(); return; } this.saveStash = null; this.oxy = s.oxy | 0; this.supply = !!s.supply; this.saved = { zhou: 0, bai: 0, chen: 0, zheng: 0, gao: 0, ...(s.saved || {}) }; this.stayer = s.stayer || null; this.flags = { knowsPipe: false, hasLog: false, valve: false, spareGift: false, gaoPeace: false, bell: false, ...(s.flags || {}), }; this.bond = { zhou: 0, bai: 0, ...(s.bond || {}) }; this.endings = { ...(s.endings || {}) }; this.runs = s.runs || 1; this.diveN = s.dive || diveOfNode(s.node); this.diveForm = s.form || null; this._dryPending = !!s.dryPending; this.lastAnnouncedTier = tierOf(this.oxy); // 恢复不重播跨档句(账目已播过) this._lastEcho = null; this.leaving = false; this.seqToken++; this.audio.stopVoice(); this._resumeGuard = true; // F4:恢复链首句播完才武装跳句(经 _playSeqSteps 消费) const phase = s.phase; if (phase === 'DIVE') { this.enterDive(this.diveN >= 1 ? this.diveN : 1, { noCharge: true }); } else if (phase === 'SURFACE') { this.enterSurface(this.diveN >= 1 && this.diveN <= 4 ? this.diveN : 4); } else if (phase === 'ENDING') { this.enterEnding(s.node && ENDINGS[s.node] ? s.node : undefined); } else { this.phase = 'INTRO'; this.audio.play('bgm/deck'); this._startLoop('sfx/amb_deck', 0.6); this.guide.state('explore'); if (phase === 'COMMS') { this.phase = 'COMMS'; this.audio.play('bgm/comms'); this._startLoop('sfx/amb_deep', 0.6); } this._updateBreath(); this.playNode(NODES[s.node] ? s.node : 'b_open'); } } // ---------------------------------------------------------------- 节点链驱动 playNode(id) { const node = NODES[id]; if (!node) { console.error('[AbyssGame] 未知节点:', id); return; } // R2-F2:签收链入口盖 800ms 死窗——SURFACE(4)/取消链过场的在途 tap 不得 // 剁断 g9_in(R1 live:g9_in 只播 0.28s 被掐) if (id === 'g9_in') this._grace(); this.nodeId = id; this.choiceArmed = false; this.pendingChoice = null; this.awaitingRestart = false; this._stopDangerLoop(); if (node.enterComms) this._enterComms(); if (node.sfx) this.audio.play(node.sfx); // 非信息类一次性垫音(link_beep 等) // 搜索双击重听素材:回波步(不含脉冲)+ 解读句 if (/^sv\d_echo$/.test(id)) { this._lastEcho = { pre: (node.pre || []).filter((st) => st.sfx !== 'sfx/sonar_ping'), voice: node.voices[0] || null, }; } let steps = this._nodeSteps(id); if (node.dyn === 'tier') { // 档位节点:只在跨档时播分档整句(账目真实;sidefx 在此一次结清) const tierVoice = this._tierPending(); if (tierVoice) { this.lastAnnouncedTier = tierOf(this.oxy); if (this.lastAnnouncedTier === 'crit') vibrate('warning'); // G2:crit 附 warning 震 this._updateBreath(); steps = [...steps, tierVoice]; } } const prompt = this._promptOf(node); if (prompt) steps = [...steps, prompt]; if (id === 'b_open') { // F8 先例:操作口令 gd_brief 前置开场链最前(仅 full 档;每实例一次) if (this.guide.level() === 'full' && !this._briefShown) { this._briefShown = true; steps = ['voice/gd_brief', ...steps]; } // sys_skip:开场每实例一次(storyline skip_hint 先例) if (!this.sysSkipShown) { this.sysSkipShown = true; steps = [...steps, 'voice/sys_skip']; } } if (this._dryAQueued) { // 干涸链首句(mid-dive floor 至 0 当场合播;当前救援照常完成) this._dryAQueued = false; this._grace(); steps = ['voice/dry_a', ...steps]; } if (this._dryBQueued) { // 干涸链归因句(上浮结算内) this._dryBQueued = false; this._grace(); steps = ['voice/dry_b', ...steps]; } this._playSeqSteps(steps, () => this._onNodeSeqDone(node, steps)); } /** 节点基础步序:pre 声呐步 → voices(含节点内条件选播)→ post 声呐步。 */ _nodeSteps(id) { const node = NODES[id]; if (!node) return []; return [...(node.pre || []), ...this._dynVoices(node), ...(node.post || [])]; } /** 节点内条件选播(radio w1_say 先例:同一节点 id,差分选组,树形不变)。 */ _dynVoices(node) { switch (node.dyn) { case 'd2pry': return [this.flags.knowsPipe ? 'voice/d2_pry_ok' : 'voice/d2_pry_slow']; case 'dv4down': // R2-F4:双载形态补播扣账句(D4 双载起播 3+1=4 格,不再无声扣款) return this.diveForm === 'double' ? ['voice/dv4_down', 'voice/dv4_dual'] : ['voice/dv4_down']; case 'gaoconfess': return [this.flags.hasLog ? 'voice/gao_confess' : 'voice/gao_c']; case 'stayleave': return [this.stayer === 'chen' ? 'voice/chen_last' : 'voice/d4_stay_lost']; case 'p7': case 'tier': return []; default: return [...node.voices]; } } /** p7 三形态提示句(双载 p7 / 单载按 stayer)。 */ _promptOf(node) { if (node.dyn === 'p7') { if (this.diveForm === 'double') return 'voice/p7'; return this.stayer === 'chen' ? 'voice/p7_chen' : 'voice/p7_zheng'; } return node.prompt; } /** 抉择目标解析(氧气门形态决定 p7 分叉;跨潜状态只有标量,无组合节点)。 */ _targetsOf(node) { if (node.dyn === 'p7') { if (this.diveForm === 'double') return { left: 'd4_slow', right: 'd4_fast' }; return { left: 'd4_pick_gao', right: this.stayer === 'chen' ? 'd4_pick_chen' : 'd4_pick_zheng', }; } return { left: node.left, right: node.right }; } _isPromptNode(node) { return !!(node && (node.prompt != null || node.dyn === 'p7')); } /** 双击重听当前节点台词(含抉择提示);ENDING 态重听结局链。 */ replayNode() { this.seqToken++; this.audio.stopVoice(); if (this.phase === 'ENDING') { this.awaitingRestart = false; this._playEndingChain(); return; } if (!NODES[this.nodeId]) return; this.playNode(this.nodeId); // G5:复播不重新武装 F4 锁(锁不随复播复活) } /** '@x' 虚拟目标 = 过场转移(自动衔接)。 */ _isTransition(target) { return typeof target === 'string' && target.startsWith('@'); } /** * 节点链自然播完的公共收尾:抉择武装 / 自动过场 / 链末等待。 * 自动衔接仅限节点表标注 auto 的段落(下潜链/上浮结算链/危机拍/签收链/ * p9 结果链)。@x 过场目标本身**由链末单击触发**(§2.2「INTRO ──链完单击 * ──> DIVE」/ 结果节点 → 上浮 = 玩家的「带人上来」动作);其余叙事节点链末 * 等待单击(§2.3「链末=继续」)并挂 gd_continue/gd_again 看门狗;COMMS 链末 * 另挂无进展轨(§7.2 可饿死点)。 */ _onNodeSeqDone(node, steps) { this.voicePlaying = false; this._setAmbDucked(false); const voices = steps.filter((x) => typeof x === 'string'); if (voices.length > 0) this.lastCloser = voices[voices.length - 1]; // R2-F6:链自然播完 = 本节点尾句已首播完整(尾句受 F5 保护必完整),登记后 // 该节点重播(菜单/状态复述)尾句可即掐(G5 复播可即掐) if (voices.length > 0) this._tailHeard.add(this.nodeId); if (this._isPromptNode(node)) { 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.advance(node.next); return; } this._armIdleWatchdog(this.phase); // 链末等待单击(tap=继续) if (this.phase === 'COMMS') this._armProg(`n:${this.nodeId}`); // 无进展轨挂点 } /** 链完转移:'@x' 虚拟目标 = 过场自动衔接;普通节点 = 播下一节点。 */ advance(target) { if (target == null) return; switch (target) { case '@dive': this.enterDive(1); return; case '@search': this._enterSearch(); return; case '@surface1': this.enterSurface(1); return; case '@surface2': this.enterSurface(2); return; case '@surface3': this.enterSurface(3); return; case '@surface4': this.enterSurface(4); return; case '@surfaceNext': this._surfaceNext(); return; case '@signoff': this.playNode('g9_in'); return; case '@ending': this.enterEnding(); return; default: this.playNode(target); } } /** 抉择:计分(§4.2)→ 存档 → 进结果节点。p9 左在 oxy<1 时封锁走 g9_dry。 */ choose(g) { const node = NODES[this.nodeId]; if (!node || !this._isPromptNode(node)) return; this.choiceArmed = false; this.audio.play('sfx/ui_confirm'); const targets = this._targetsOf(node); let target = g === 'left' ? targets.left : targets.right; if (this.nodeId === 'p9' && g === 'left' && this.oxy < 1) { target = 'g9_dry'; // 左路封锁:播出后自动走右,不摆假选项(§3.2) } this._applyChoice(this.nodeId, g); this._clearProg(); // 抉择结算 = 有真实进展 this._save(); // 写入时机:每个抉择结算后 this.advance(target); } /** 抉择计分表(§4.2:左=走心·多花气 / 右=克制·省气;floor 至 0 触发干涸链)。 */ _applyChoice(nodeId, g) { const L = g === 'left'; switch (nodeId) { case 'p1': if (L) { this.flags.knowsPipe = true; this.bond.zhou = 1; this._spend(1); } break; case 'p2': if (L) { this.flags.hasLog = true; this.bond.zhou = 1; this._spend(1); } break; case 'p3': if (L) { this.bond.bai = 1; this._spend(this.flags.knowsPipe ? 0 : 1); } break; case 'p4': if (L) { this.flags.valve = true; this.bond.bai = 1; this._spend(2); } break; case 'p5': // 唯一无气差的双义抉择:stayer 决定 D4 单载差分 if (L) { this.saved.chen = 1; this.stayer = 'zheng'; } else { this.saved.zheng = 1; this.stayer = 'chen'; } break; case 'p6': if (L) { this.flags.spareGift = true; this._spend(2); } break; case 'p7': if (this.diveForm === 'double') { this.saved.gao = 1; if (this.stayer) this.saved[this.stayer] = 1; if (L) this._spend(2); } else if (L) { this.saved.gao = 1; } else if (this.stayer) { this.saved[this.stayer] = 1; } break; case 'p8': if (L) this.flags.gaoPeace = true; break; case 'p9': if (L && this.oxy >= 1) this.flags.bell = true; // 左路需 oxy≥1(choose 已拦) break; default: break; } } /** * 氧气支出(§4.3 宽限):mid-dive 扣至负数一律 floor 到 0,不中断当前救援 * (人已挂上吊索照常带上);从正值 floor 到 0 时触发干涸链。 */ _spend(cost) { if (cost <= 0) return; const before = this.oxy; this.oxy = Math.max(0, this.oxy - cost); this._updateBreath(); if (before > 0 && this.oxy === 0) { this._dryAQueued = true; // dry_a 挂下一结果节点链首(含 800ms 死窗) this._dryPending = true; // dry_b 挂上浮结算链首 } } // ---------------------------------------------------------------- 档位 / 状态链 /** 档位句选播(R2-F5):crit 档在水面场景(SURFACE/ENDING/INTRO)用「一潜都 * 下不去了」变体——人已出水再说「必须上浮」是错场景措辞;水下保留原句。 */ _tierVoice(tier) { if (tier === 'crit' && (this.phase === 'SURFACE' || this.phase === 'ENDING' || this.phase === 'INTRO')) { return TIER_CRIT_DECK; } return TIER_VOICES[tier]; } /** 跨档待播档位句(无跨档返回 null;副作用由 playNode('s_tier') 结清)。 */ _tierPending() { const tier = tierOf(this.oxy); if (tier === this.lastAnnouncedTier) return null; return this._tierVoice(tier); } /** 上滑状态链(G6 顺序:prog_dN → 已救人数 → oxy 档 → sys_help; * G2:crit 附 warning 震——R2-F9 按「进入 crit 档」去重,同档复听不重复震)。 */ _statusIds() { const ids = []; if (this.phase === 'ENDING') { const def = ENDINGS[this.endingId] || ENDINGS.e4; ids.push(def.voices[0]); // 结局主句 = 结局名播报(storyline P3-2 先例) } else if (this.diveN >= 1 && this.diveN <= 4) { ids.push(PROG_VOICES[this.diveN - 1]); } // R2-F10:已救人数(P3 被打断后要捡回的三样局面:潜次/氧气/船上躺着谁) const savedCount = ['zhou', 'bai', 'chen', 'zheng', 'gao'] .filter((k) => this.saved[k]).length; if (savedCount >= 1) ids.push(SAVED_VOICES[savedCount - 1]); const tier = tierOf(this.oxy); if (tier === 'crit' && !this._critWarned) { vibrate('warning'); this._critWarned = true; } if (tier !== 'crit') this._critWarned = false; ids.push(this._tierVoice(tier)); return ids; } /** * G1 帮助链去重:同一等待点第 1 次全量口播 sys_help;第 2 次起改纯音频标记 * (beat 轻震 + nudge earcon,零新增词表),状态句照播。 */ _helpSpokenOnce(waitKey) { if (this._helpSeen.has(waitKey)) { vibrate('beat'); if (this.guide.level() !== 'off') this.guide._playEarcon('nudge'); return false; } this._helpSeen.add(waitKey); return true; } /** G6:状态在前、帮助垫后——把帮助句接在状态链尾。 */ _withHelp(ids, waitKey) { if (this._helpSpokenOnce(waitKey)) ids.push('voice/sys_help'); return ids; } /** * 上滑(叙事态):状态(潜次+氧档)在前 + 帮助垫后(G6),随后重播当前节点 * (局面复述)。INTRO 开播前无潜次可报,仅氧档+帮助后重播当前段。 */ _playHelpAndResume() { this.seqToken++; this.audio.stopVoice(); const ids = this._statusIds(); this._withHelp(ids, `S:${this.nodeId}`); this._playSeqSteps(ids, () => { this.voicePlaying = false; this.playNode(this.nodeId); }); } // ---------------------------------------------------------------- 看门狗双轨(§7.2) /** 无操作轨·叙事等待(ASK/INTRO/COMMS/SURFACE 链末/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) => { this._progSaid(n); // 双轨去重:无操作轨已播的同级,无进展轨跳过 if (full) return; this.audio.play(n === 1 ? 'voice/gd_continue' : 'voice/gd_again', { queue: true }); }, cond: () => this.phase === phaseName && !this.voicePlaying, }); } /** 无操作轨·搜索等待:两级不同句 gd_ping1/gd_ping2(10s/25s,绝不逐字重播)。 */ _armSearchWatchdog() { const full = this.guide.level() === 'full'; this.guide.watchdog({ arm: true, at: SEARCH_WATCHDOG_AT, // full/standard 同参数;standard 由游戏层补播 say: ['gd_ping1', 'gd_ping2'], onEscalate: (n) => { this._progSaid(n); if (full) return; this.audio.play(n === 1 ? 'voice/gd_ping1' : 'voice/gd_ping2', { queue: true }); }, cond: () => this.phase === 'DIVE' && this.searchArmed, }); } /** 抉择点静默兜底:25s/50s 重播当前 p* 提示句(复述选项=合法帮助,N3 先例)。 */ armChoiceHint() { this.guide.watchdog({ arm: true, at: CHOICE_HINT_AT, onEscalate: () => { const prompt = this._promptOf(NODES[this.nodeId]); if (prompt) this.audio.play(prompt, { queue: true }); }, cond: () => (this.phase === 'COMMS' || this.phase === 'SURFACE') && this.choiceArmed && !this.voicePlaying, }); } /** * 无进展轨(游戏层计时,dungeon E0-1 同型):仅挂搜索等待与 COMMS 链末两个 * 可饿死点;60s→gd_prog1、120s→gd_prog2,用尽即停(每等待点一轮)。 * 无效手势(滑错/空点)不复位本轨——乱按饿不死;guide=off 不挂(零定时器)。 */ _armProg(site) { this._clearProg(); if (this.guide.level() === 'off') return; const token = ++this._progToken; const p = { site, token, said1: false, said2: false, t1: null, t2: null }; p.t1 = setTimeout(() => this._fireProg(p, 1), PROG_AT[0]); p.t2 = setTimeout(() => this._fireProg(p, 2), PROG_AT[1]); this._prog = p; } _fireProg(p, level) { if (!this._prog || this._prog !== p || this._prog.token !== p.token) return; if (p['said' + level]) return; // 双轨同级交叉登记:先播过则跳过 p['said' + level] = true; this.audio.play(level === 1 ? 'voice/gd_prog1' : 'voice/gd_prog2', { queue: true }); } _clearProg() { const p = this._prog; if (!p) return; if (p.t1) { clearTimeout(p.t1); p.t1 = null; } if (p.t2) { clearTimeout(p.t2); p.t2 = null; } this._prog = null; } /** 双轨去重登记:无操作轨(guide watchdog)触发同级后,无进展轨跳过同义级。 */ _progSaid(level) { if (this._prog) this._prog['said' + level] = true; } // ---------------------------------------------------------------- 语音链(storyline 范式 + 声呐步) /** * 顺序播放一组步(string = 语音句;{sfx,wait,...} = 声呐步),全部自然播完 * 后回调。被打断时回调作废(seqToken 机制)。声呐步经 sonarGate 空闲门 * (§6.2 R1:信息类回波与语音零重叠);危机步前置 danger 签名+震(+D4 震 * 循环)。单句 30s 看门狗:超时且 isVoicePlaying()===false(资产缺失)才 * 自动续链;voice 真在播(长句)则续约等待,不得掐断(N1 先例照抄)。 */ _playSeqSteps(steps, onDone) { const token = ++this.seqToken; this._closeVoiceRecs(); const firstVoice = steps.find((x) => typeof x === 'string') || null; this._skipLockId = this._resumeGuard ? firstVoice : null; // F4:续玩锁 this._resumeGuard = false; // R2-F2:记录实际播放链的尾句(含 dyn 注入的档位句),供 F5 保护判定 this._chainTail = null; for (let k = steps.length - 1; k >= 0; k--) { if (typeof steps[k] === 'string') { this._chainTail = steps[k]; break; } } this.voicePlaying = true; this._sonarSkip = null; const step = (i) => { if (token !== this.seqToken) return; if (i >= steps.length) { this.currentVoiceId = null; this.promptPlaying = false; this._skipLockId = null; this.voicePlaying = false; this._setAmbDucked(false); this._stopDangerLoop(); this._closeVoiceRecs(); if (onDone) onDone(); // sonarGate 补发:链真正歇下(无新句接续)才排水,保证零重叠 if (!this.voicePlaying && !this.audio.isVoicePlaying()) this._drainSonar(); return; } if (i > 0 && typeof steps[i - 1] === 'string') this._skipLockId = null; // F4:武装跳句 const s = steps[i]; if (typeof s === 'string') { this.currentVoiceId = s; this.promptPlaying = PROMPT_RE.test(s); if (this.promptPlaying) this.guide.state('choice'); // 选项句本身即 turn cue(W2) this._setAmbDucked(true); // R3:语音在播时 amb 常驻声呐元素降 -6dB const rec = { id: s, start: performance.now(), end: Infinity }; this._voiceIntervals.push(rec); let advanced = false; const armWd = () => setTimeout(() => { if (advanced || token !== this.seqToken) return; if (this.audio.isVoicePlaying()) { wd = armWd(); return; } // 长句在播:续约不掐 advanced = true; console.warn('[AbyssGame] 语音看门狗超时(voice 未在播,判定资产缺失),自动续链:', s); rec.end = performance.now(); step(i + 1); }, VOICE_WATCHDOG_MS); let wd = armWd(); this.audio.play(s, { onComplete: () => { if (advanced || token !== this.seqToken) return; advanced = true; clearTimeout(wd); rec.end = performance.now(); step(i + 1); }, }); } else { // 声呐步:信息回波/危机前置音(R1 空闲门;R4 危机拍前置 2s 起播) this.currentVoiceId = null; this.promptPlaying = false; if (s.danger) { this.guide.state('danger'); vibrate('danger'); if (s.dangerLoop) this._startDangerLoop(); // D4 另叠 danger 震循环 } this._sonar(s.sfx, s.volume != null ? { volume: s.volume } : undefined); let done = false; const timer = setTimeout(() => { if (done || token !== this.seqToken) return; done = true; this._sonarSkip = null; step(i + 1); }, s.wait || 0); if (!s.danger) { // 回波余音等待中单击=快进(危机前置窗不快进,点按只 beat 标记) this._sonarSkip = () => { if (done || token !== this.seqToken) return; done = true; clearTimeout(timer); this._sonarSkip = null; step(i + 1); }; } } }; step(0); } /** G5 复播兼容入口(等价单语音链)。 */ _playSeq(ids, onDone) { this._playSeqSteps(ids, onDone); } _closeVoiceRecs() { const now = performance.now(); for (const rec of this._voiceIntervals) { if (rec.end === Infinity) rec.end = now; } } // ---------------------------------------------------------------- 声呐双通道(§6.2) /** * sonarGate 空闲门(R1):信息类声呐音只在 voice idle 时播出,否则挂队列表、 * 链末补发——回波与语音零重叠。玩家主动脉冲只在搜索等待(voice 恒静)受理, * 按构造不重叠。 */ _sonar(id, opts) { if (this.audio.isVoicePlaying()) { this._sonarQueue.push({ id, opts }); return; } this.audio.play(id, opts); this._sonarPlays.push({ id, t: performance.now() }); } _drainSonar() { if (this._sonarQueue.length === 0) return; if (this.audio.isVoicePlaying()) return; const item = this._sonarQueue.shift(); this.audio.play(item.id, item.opts); this._sonarPlays.push({ id: item.id, t: performance.now() }); } /** danger 震循环(D4 滑坡危机拍专用;间隔 ≥400ms,链推进/菜单/重开即停)。 */ _startDangerLoop() { this._stopDangerLoop(); const token = this.seqToken; const loop = { token, timer: null }; this._dangerLoop = loop; const fire = () => { if (this._dangerLoop !== loop) return; vibrate('danger'); loop.timer = setTimeout(fire, DANGER_LOOP_MS); }; loop.timer = setTimeout(fire, DANGER_LOOP_MS); } _stopDangerLoop() { if (!this._dangerLoop) return; if (this._dangerLoop.timer) clearTimeout(this._dangerLoop.timer); this._dangerLoop = null; } // ---------------------------------------------------------------- 环境音循环(游戏层 buffer.loop) /** * 循环垫(amb_deck/amb_deep/breath_loop):引擎 sfx 层即发即弃无 loop—— * 游戏侧经引擎 ctx 起循环 BufferSource 并持句柄(radio _ringSrc 先例)。 * load 异步:落地前已被停掉/被替换则不播;资产缺失静默降级(玩法不受阻)。 */ _startLoop(id, vol = 0.6) { this._stopLoop(id); // 防双循环(重入时旧句柄先清) if (!this.audio.ctx) return; const handle = { want: true, src: null, gain: null }; this._loops[id] = handle; this.audio._load(id).then((buf) => { if (!buf || !handle.want || this._loops[id] !== handle) return; const src = this.audio.ctx.createBufferSource(); src.buffer = buf; src.loop = true; const gain = this.audio.ctx.createGain(); gain.gain.value = this.audio.sfxVolume * vol * (this._ambDucked ? 0.5 : 1); src.connect(gain); gain.connect(this.audio._sfxBus); src.start(); handle.src = src; handle.gain = gain; }).catch(() => { /* 资产缺失降级:声场靠 BGM/语音承载 */ }); } _stopLoop(id) { const h = this._loops[id]; if (!h) return; h.want = false; if (h.src) { try { h.src.stop(); } catch (_) { /* 已停止 */ } } delete this._loops[id]; } _stopAllLoops() { for (const id of Object.keys(this._loops)) this._stopLoop(id); } /** R3 常驻退位:语音在播时 amb 常驻声呐元素降 -6dB(gain ×0.5),链歇恢复。 */ _setAmbDucked(on) { if (this._ambDucked === on) return; this._ambDucked = on; const h = this._loops['sfx/amb_deep']; if (h && h.gain) h.gain.gain.value = h.gain.gain.value * (on ? 0.5 : 2); } /** * 低氧混音签名(§4.3):tier=low/crit 且处于水下(DIVE/COMMS)时垫 * breath_loop(低响度呼吸声);升档/上浮/收队即停。 */ _updateBreath() { const want = (tierOf(this.oxy) === 'low' || tierOf(this.oxy) === 'crit') && (this.phase === 'DIVE' || this.phase === 'COMMS'); if (want === this._breathOn) return; this._breathOn = want; this._breathLog.push({ on: want, t: performance.now() }); if (want) this._startLoop('sfx/breath_loop', 0.45); else this._stopLoop('sfx/breath_loop'); } /** H1:盖 800ms 手势死窗(终局取消链/干涸链/结局链入口)。 */ _grace() { this._graceUntil = performance.now() + SETTLE_GRACE_MS; } // ---------------------------------------------------------------- 存档(§8) /** data 结构照 §8.1(超集:form/dryPending/bond 为断点续玩派生账)。 */ _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 seg = this.diveN >= 1 && this.diveN <= 4 ? `第${this.diveN}潜·${SEGS[this.diveN - 1]}` : '甲板·简报'; summary = `${seg} · 氧气${this.oxy}格 · 结局${k}/4`; } const data = { schema: 1, updatedAt: new Date().toISOString(), summary, phase: this.phase === 'MENU' ? (this.returnPhase || 'INTRO') : this.phase, node: this.nodeId, dive: this.diveN, oxy: this.oxy, supply: this.supply, saved: { ...this.saved }, stayer: this.stayer, flags: { ...this.flags }, bond: { ...this.bond }, form: this.diveForm, dryPending: this._dryPending, 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: 'abyss', 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 AbyssGame;