/** * 《深海回音》R3 —— 深海救援叙事 RPG,按 docs/design/26-audio-interaction-playbook.md * 深度重构(设计单正文见 docs/design/17-abyss.md 附录 C;施工单见 26-apply-abyss.md)。 * * 玩法核心不动(17 号正文):节点链 + seqToken + pendingChoice 骨架、氧气步进经济 * (每潜扣 3 / 抉择计分 / floor 至 0 干涸链 / SURFACE(3) 氧气门三形态)、9 抉择 * (单局实走 7-9)、4 结局 + 尾声回响、DIVER/DECK 双音色资产 + NPC 平铺、存档 * summary。交互层整体换成 26 号六模块: * * - 发言权管理器(§4.1):整条语音链 = SPEAKING(holdDuck 跨链、句间不掉回 * 「轮到你」听感);链完 → yieldTurn:交接提示音 → 400ms 后等待声;玩家一 * 操作等待声 80ms 内淡出。声呐步(回波余音/危机前置)= BUSY,手势给 busy 回执。 * - 输入回执 + 管道(§4.2):ok / invalid / busy 三件套;tap「继续/跳过」后 * 400ms 防连点;滑动打断并执行(floor 统一裁定)。 * - 新手手势宪法(§4.3):doubleTapWindow=0;单击=继续/跳句/搜索等待发脉冲; * 左右滑=抉择;上滑=「我在哪」(阶段 1,第一次脉冲成功后解锁);下滑=菜单 * (阶段 2,第一次结局);双击阶段 3=重听当前等待点完整句。**废除双击重听 * 回波**:搜索等待再单击=再发脉冲,自然再听(17 号 §4)。 * - 三档提示(§4.4):等待点 full → short → 交接提示音;主动重听=「再说一遍:」 * +完整句;台账 30s 跨等待点去重(原则 9)。 * - 迷茫检测(§4.5):乱点/无效/静默信号 → 三级接管(L1 短句 → L2 只留期待 * 手势 → L3 演示:搜索等待替玩家发一枚脉冲)。 * - 手势阶梯(§4.3):第一次脉冲成功 → onGoal(上滑);一局结局 → onGameDone * (下滑 / 第 3 局双击);教学句挂链尾,先说一句马上用一次(原则 11)。 * * 状态 × 手势矩阵(新手期,完整版见 17 号附录 C §4): * | 状态 | 单击 | 左/右滑 | 上滑 | 下滑(未解锁) | 双击(未解锁) | * |-----------|------------------|--------------------|-------------|--------------|--------------| * | 游戏在说 | 跳句+ok;p 选项句/锚句/档位句/结算链首句 critical → busy | 旁白中滑到抉择点:缓存 pendingChoice+跳选项句;否则 invalid | 打断,答「我在哪」 | invalid+解释 | 按单击处理 | * | 等你继续 | 继续+ok | invalid(inv_nosel)| 我在哪+局面复述 | invalid | (阶段3=重听)| * | 搜索等待 | **脉冲**(silent-ok,声呐即内容音) | invalid+「点一下,发脉冲。」 | 状态链(潜次/已救/氧档/动作) | invalid | 按单击=再脉冲(阶段3=重听) | * | 等你选择 | 再听选项(hold) | 抉择+ok | 「正在抉择。左右滑。」 | invalid | (阶段3=重听)| * | 结算 | 新救援+ok | invalid | 结局主句+氧档+动作 | 菜单(阶段2) | 按单击 | * | 菜单 | 确认当前项 | 左上一项/右下一项并报名 | 重听菜单说明 | 关闭 | (阶段3=重听说明)| * * 搜索等待听感(17 号 §2):深海 amb_deep 常驻 + 回波余音替代合成等待声—— * 引擎 Bed 为全局统一音色,游戏层在搜索等待点置 floor.bedEnabled=false(偏差 * 记录见施工单)。声呐与语音互斥(sonarGate 空闲门 + 排队补发)保留。 * * 结算死窗(17 号 §8):终局取消链 / 干涸链 / 签收链 / 结局链**首句 critical** * ——手势给 busy 回执不截链(比旧 800ms 死窗覆盖整句,更强)。断点复述(§4.12): * 恢复链首句同样 critical(F4 锁的等价实现)。 */ 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'; // ---------------------------------------------------------------- 声呐时序常量 // 声呐链驻留时长(ms):脉冲→回波静默一拍 / 回波余音 / 危机前置 2s(17 号 §5) 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) // ---------------------------------------------------------------- 节点表 /** 声呐步:信息音/危机前置音,wait = 起播后链驻留时长(ms)。 */ function S(sfx, wait, extra = {}) { return { sfx, wait, danger: false, dangerLoop: false, volume: undefined, ...extra }; } /** 叙事节点。voices 顺序播放;pre/post 为声呐步(链内零重叠,§5)。 */ 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, lockFirst: false, ...opts, }; } /** 抉择节点:本体无台词,链尾接 p* 提示句(整句 critical:tap=busy、滑动缓存承接)。 */ 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, lockFirst: false, ...opts, }; } /** * 全部节点(id = 首句 voice id;台词全文见 17-abyss-voice-ids.md)。 * next 为 '@x' 虚拟目标:@dive 进第一潜 / @search 武装搜索等待 / * @surfaceN 上浮结算 / @surfaceNext 结算后分流(下一潜 / 氧气门 / 签收)/ * @signoff 签收链 / @ending 进结局。p7 提示与目标按氧气门形态动态解析 * (双载 p7 / 单载 p7_chen|p7_zheng,dyn='p7')。lockFirst:结算链首句 * critical(g9_in / c_none,替代旧 800ms 手势死窗,17 号 §8)。 */ const NODES = { // ---- INTRO 开场(26 号 §4.10:首句 sys_first_tap 让玩家动手;b_goal 极短目标句; // b_rule 规则念白废除——账目契约由氧气门取消链与档位句承载) b_open: N(['voice/b_open'], { 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 是下潜链尾句(auto + next=@search 过场)→ 自动 critical: // 教学指令「单击,发一枚脉冲」不可被打断(17 号 §8 critical 清单) 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;语音播完才响) 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' }), // 节点内条件选播: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「这趟往返,扣四格气」 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 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':只在跨档时播分档整句,整句 critical=档位句不可跳;空则直通) s_tier: N([], { dyn: 'tier', auto: true, next: '@surfaceNext' }), // ---- 终局取消链(氧气门 ≤2 形态;首句 critical:结算死窗,17 号 §8) c_none: N(['voice/dv4_none'], { auto: true, next: 'c_final', lockFirst: true }), 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', lockFirst: true }), 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):整句 critical 不可跳。 */ const PROMPT_RE = /^voice\/p\d/; // 等待点静默阈值(17 号附录 C §5:intro 6/15、ping 8/18、continue 13/30、 // choice 25/50、settle 20/45;COMMS 链末 full 档 13s/30s 不对齐旧 25/50) const SILENCE = { intro: [6000, 15000], ping: [8000, 18000], continue: [13000, 30000], choice: [25000, 50000], settle: [20000, 45000], ask: [12000, 30000], menu: [20000, 45000], }; // 无效操作解释句(原则 7:每个「状态 × 手势」只解释一次;locked:* 由管道触发) const INVALID_LINES = { 'locked:up': 'voice/inv_up_locked', 'locked:down': 'voice/inv_down_locked', 'search:swipe': 'voice/sys_act_ping', // 「点一下,发脉冲。」(17 号 §7 失败表) 'nosel': 'voice/inv_nosel', // 「现在不用选。」 generic: 'voice/gd_invalid', // 「这个手势,水里用不上。」(保留旧句作通用) menuHelp: 'voice/menu_help', }; /** 氧气档位(§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); // ---- 26 号六模块接线(§4.1/§4.2/§4.5/§4.3) 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(this.ladder.stage() >= 3); // 长按:新手 invalid,阶段 3 起旁白加速 // 语音占用区间记账(sonarGate 零重叠断言用):包装 floor.speak/interrupt // _chainSpeaking = 打断快照:pipeline handleInput 对滑动/长按「打断并执行」 // 先杀链把 floor 置 BUSY,游戏 onGesture 里 floor.speaking 已 false——快照 // 保留「刚才叙事链在说」语义,让滑动按矩阵落「旁白中」分支(缓存抉择/恢复) this._chainSpeaking = false; this._voiceIntervals = []; this._sonarPlays = []; const origSpeak = this.floor.speak.bind(this.floor); this.floor.speak = (ids, opts = {}) => { for (const id of ids || []) { if (typeof id === 'string' && id.startsWith('voice/')) { this._voiceIntervals.push({ id, start: performance.now(), end: Infinity }); } } const done = opts.onDone; opts.onDone = () => { this._chainSpeaking = false; this._closeVoiceRecs(); this._setAmbDucked(false); if (done) done(); }; this._chainSpeaking = true; this._setAmbDucked(true); // 语音在播时 amb 常驻声呐元素降 -6dB(R3) return origSpeak(ids, opts); }; const origInterrupt = this.floor.interrupt.bind(this.floor); this.floor.interrupt = () => { this._chainSpeaking = false; this._closeVoiceRecs(); this._setAmbDucked(false); return origInterrupt(); }; this.phase = 'INTRO'; // INTRO|DIVE|COMMS|SURFACE|ENDING|MENU|ASK this.returnPhase = null; // MENU 关闭后回原状态 this.returnNode = null; this.returnSearch = false; // MENU 前是否在搜索等待 this.nodeId = 'b_goal'; this.diveN = 0; // 当前潜号(0=甲板简报) this.diveForm = null; // 氧气门形态:'double'|'single'|'cancel'(D4 前置) this.endingId = null; this.choiceArmed = false; this.pendingChoice = null; // 旁白链中的滑动缓存(V1:选项句播完才生效,零插播) 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.saveStash = null; // ASK 态暂存的存档 this.sysSkipShown = false; // sys_skip 每实例一次 this.goalMet = false; // 第一个小目标(第一次脉冲成功)→ 解锁上滑 this._teachQueue = null; // 待挂到下一段链尾的解锁教学句(§4.3 阶梯) // F4 断点续玩首句锁:恢复链首句 critical(播完即清) this._resumeGuard = false; this._critWarned = false; // R2-F9:crit warning 震按档进入去重 // 声呐双通道(§5):空闲门 + 排队补发(回波与语音零重叠) this._sonarQueue = []; this._seqBusy = false; // 声呐步序列运行中(voicePlaying 兼容语义) // 干涸链(§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 = []; // 断言用:呼吸垫起停轨迹 // danger 震循环(D4 危机拍专用,间隔 ≥400ms) this._dangerLoop = null; // 菜单 this.menuIdx = 0; this._restartArmed = false; // 重新开始二次确认(00 号 §5.4) } /** 管道选项:长按由游戏层裁定(新手 invalid;阶段 3 起=引擎旁白加速)。 */ get PIPELINE_OPTS() { return { longpressCore: true }; } // ---- 兼容语义(harness/测试):voicePlaying 含声呐步 BUSY 段 get voicePlaying() { return this.floor.speaking || this._seqBusy; } get currentVoiceId() { return this.floor.currentId(); } get promptPlaying() { return PROMPT_RE.test(this.floor.currentId() || ''); } // ---------------------------------------------------------------- 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._coldStart(); return; } let settled = false; const goFresh = () => { if (settled) return; settled = true; this._coldStart(); }; 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++; // 作废一切在途链/声呐步回调 this._seqBusy = false; this._save(); // 退出自动存档(26 号 §4.12 / 原则 16) this.floor.interrupt(); this._stopAllLoops(); this._stopDangerLoop(); this.audio.stopAll(); } // ---------------------------------------------------------------- 开场(§4.10 首 3 分钟) /** * 冷启动首 3 分钟(17 号附录 C §6 逐秒脚本):0~3 秒第一句就让玩家动手—— * 甲板风浪(amb_deck)→「点一下屏幕。」(critical 指令句,抢拍单击给 busy, * 保证唯一一次教学完整到达);规则念白废除(b_rule 停用),目标句后置。 */ _coldStart() { this.phase = 'INTRO'; this.nodeId = 'b_goal'; this._save(); this.floor.speak(['voice/sys_first_tap'], { critical: true, onDone: () => this._yieldTurn(this._introWp()), }); } /** * 第一次单击之后(3~8 秒):极短目标句 +「轮到你了」状态教学(原则 2: * 状态声第一次出现配解释)+ sys_skip(每实例一次);随后链末等待单击下潜。 */ _afterFirstTap() { this.nodeId = 'b_goal'; const seq = ['voice/b_goal', 'voice/sys_turn_teach']; if (!this.sysSkipShown) { this.sysSkipShown = true; seq.push('voice/sys_skip'); } this.floor.speak(seq, { onDone: () => this._yieldTurn(this._continueWp(NODES.b_goal)), }); } // ---------------------------------------------------------------- 手势(pipeline 之后) /** pipeline 收口后的游戏处理;返回 'ok' | 'hold' | 'silent-ok' | 'invalid'。 */ onGesture(g) { // 长按(§4.3 游戏层裁定):阶段 3 起=引擎旁白加速(加速本身即反馈); // 新手期=无效手势(回执 + 首次解释)。长按也走 floor「打断并执行」—— // 若砍断了叙事链,先交回 continue 等待点(零无声死锁,原则 4) if (g === 'longpress') { if (this.ladder.stage() >= 3) return 'hold'; // 等待点保留(不推进、不结清) if (this._chainSpeaking) this._recoverInterrupted(); return 'invalid'; } if (this.phase === 'MENU') return this._onMenuGesture(g); if (this.phase === 'ASK') return this._onAskGesture(g); if (this.searchArmed) return this._onSearchGesture(g); return this._onStoryGesture(g); } stateName() { if (this.phase === 'MENU') return 'menu'; if (this.phase === 'ASK') return 'ask'; if (this.searchArmed) return 'search'; if (this.choiceArmed && !this.floor.speaking) return 'choice'; if (this.floor.speaking || this._seqBusy) return 'story'; if (this.phase === 'ENDING') return 'settle'; return 'continue'; } invalidHint(state, g) { if (state === 'locked') return INVALID_LINES[`locked:${g}`] || INVALID_LINES.generic; if (g === 'left' || g === 'right') { if (state === 'menu') return null; // 菜单内左右滑是合法操作(换项) if (state === 'search') return INVALID_LINES['search:swipe']; return INVALID_LINES.nosel; } if (g === 'doubletap') return null; // 阶段 3 已解锁才会到这里:重听,无需解释 if (state === 'menu') return INVALID_LINES.menuHelp; return INVALID_LINES.generic; } // ---------------------------------------------------------------- 手势:ASK _onAskGesture(g) { switch (g) { case 'tap': this._resume(this.saveStash); return 'ok'; case 'right': this.newRun(); return 'ok'; default: return 'invalid'; } } // ---------------------------------------------------------------- 手势:DIVE 搜索等待 _onSearchGesture(g) { switch (g) { case 'tap': this._firePing(); // 单击=发一枚声呐脉冲(宪法 §5.1「检查」义;恒出 ping) return 'silent-ok'; // 声呐脉冲即内容音,省 ok 回执(§4.2) case 'doubletap': { // 阶段 3 解锁的重听(17 号 §3):重听当前等待点完整句(「再说一遍:」+ // 「单击,发一枚脉冲。」);废除旧「双击重听回波」——再单击=再发脉冲 const wp = this.floor.waitPoint; if (wp) { wp.requestReplay(); return 'hold'; } return 'invalid'; } case 'up': this._upStatus(); return 'ok'; case 'down': this.openMenu(); return 'ok'; default: return 'invalid'; // 左/右滑:invalid + 「点一下,发脉冲。」(§4 矩阵) } } /** 单击=发脉冲:搜索等待 → 回波链(脉冲 → 静默一拍 → 回波 → 解读)。 */ _firePing() { if (!this.searchArmed) return; this.searchArmed = false; // 第一次脉冲成功 = 第一个小目标 → 解锁上滑(§4.3 阶梯阶段 1), // 教学句挂到本条回波链尾(先说一句,马上就能用) if (!this.goalMet) { this.goalMet = true; const got = this.ladder.onGoal(); if (got) this._teachQueue = this.ladder.teachLine(got); // 已带 voice/ 前缀(novice.js 修复后语义) } this.playNode(`sv${this.diveN}_echo`); } // ---------------------------------------------------------------- 手势:INTRO / DIVE 链 / COMMS / SURFACE / ENDING _onStoryGesture(g) { // ENDING 态 nodeId=结局 id(e1..e4),不在 NODES 表里——结算态手势 // (下滑=菜单 / 单击=新救援 / 双击=重听结局链)不依赖节点,直接放行 const node = NODES[this.nodeId] || null; if (!node && this.phase !== 'ENDING') return 'invalid'; // 「游戏在说」= SPEAKING / 声呐步 BUSY / 被滑动打断的链(快照;tap 已被 // floor 按跳句收口,到不了这里) if (this.floor.speaking || this._seqBusy || this._chainSpeaking) { switch (g) { case 'left': case 'right': { // 抉择点缓存(§4 矩阵):旁白链中滑动 → 缓存 pendingChoice + 跳去 // 选项句(不当场盲选);选项句播完才生效(V1 零插播)。非抉择点: // 打断已成事实,交回 continue 等待点恢复可玩(invalid + 解释句) const pt = this._swipePromptTarget(node); if (pt) { this.pendingChoice = g; this.playNode(pt); return 'ok'; } this._recoverInterrupted(); return 'invalid'; } case 'up': this._upStatus(); return 'ok'; case 'down': this.openMenu(); return 'ok'; case 'doubletap': if (this.phase === 'ENDING') { this._playEndingChain(); return 'ok'; } this.playNode(this.nodeId); // 重听当前节点(阶段 3;复播尾句可即掐) return 'ok'; default: this._recoverInterrupted(); return 'invalid'; } } // ---- 等待态(continue / choice / settle) switch (g) { case 'tap': { const wp = this.floor.waitPoint; if (wp && wp.conf.id === 'choice') { // 等你选择空点 = 主动再听选项:「再说一遍:」+ 完整 pN(原则 9) wp.requestReplay(); return 'hold'; } if (wp && wp.conf.id === 'intro') { this._afterFirstTap(); // 开场第一下单击 → 旁白 + 「轮到你了」教学 return 'ok'; } if (this.phase === 'ENDING') { this.newRun(); return 'ok'; } // 结算单击=新救援 if (node.next != null) { this.advance(node.next); return 'ok'; } return 'invalid'; } case 'left': case 'right': if (this.choiceArmed) { this.choose(g); return 'ok'; } return 'invalid'; // inv_nosel:现在不用选 case 'up': this._upStatus(); return 'ok'; case 'down': this.openMenu(); return 'ok'; case 'doubletap': { const wp = this.floor.waitPoint; if (wp) { wp.requestReplay(); return 'hold'; } // 阶段 3:重听等待点完整句 if (this.phase === 'ENDING') { this._playEndingChain(); return 'ok'; } return 'invalid'; } default: return 'invalid'; } } /** * 旁白链中的滑动打断已发生(floor=BUSY、链已死):无效手势不能把游戏晾在 * 无等待点的 BUSY(无声死锁,原则 4)——交回 continue 等待点,链末单击继续。 */ _recoverInterrupted() { const node = NODES[this.nodeId] || null; this._chainSpeaking = false; if (node) this._yieldTurn(this._continueWp(node)); } /** * 旁白链中的滑动是否落在抉择点上:本节点是抉择节点,或 next 直入抉择节点。 * 返回抉择节点 id(playNode 跳去选项句)或 null。 */ _swipePromptTarget(node) { if (this._isPromptNode(node)) return this.nodeId; if (node.next != null && !this._isTransition(node.next)) { const nx = NODES[node.next]; if (nx && this._isPromptNode(nx)) return node.next; } return null; } // ---------------------------------------------------------------- 手势:MENU(00 号 §5.4:左右滑换项 + 单击确认) _onMenuGesture(g) { switch (g) { case 'tap': this._execMenuItem(MENU_ITEMS[this.menuIdx].id); return 'ok'; case 'left': this._restartArmed = false; this.menuIdx = (this.menuIdx + MENU_ITEMS.length - 1) % MENU_ITEMS.length; this._menuAnnounce(); return 'hold'; case 'right': this._restartArmed = false; this.menuIdx = (this.menuIdx + 1) % MENU_ITEMS.length; this._menuAnnounce(); return 'hold'; case 'up': // 重听菜单说明 + 复述当前项(玩家一动游戏就停:新链顶掉旧报名) this.floor.speak(['voice/menu_help', MENU_ITEMS[this.menuIdx].voice], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'hold'; case 'down': this.closeMenu(); return 'ok'; case 'doubletap': { const wp = this.floor.waitPoint; if (wp) { wp.requestReplay(); return 'hold'; } // 阶段 3:重听菜单说明 return 'invalid'; } default: return 'invalid'; } } _menuAnnounce() { this.floor.speak([MENU_ITEMS[this.menuIdx].voice], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); } _execMenuItem(id) { if (id === 'resume') { this.closeMenu(); return; } if (id === 'restart') { // 破坏性操作二次确认(00 号 §5.4 / 26 号原则 6) if (!this._restartArmed) { this._restartArmed = true; this.floor.speak(['voice/sys_menu_restart_confirm'], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return; } this._restartArmed = false; this.newRun(); return; } // lobby:确认句播出且存档落盘后跳转(§2.2「确认后跳转前播」) 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 标记 */ } }, }); } // ---------------------------------------------------------------- 菜单开/关 openMenu() { this.returnPhase = this.phase; this.returnNode = this.nodeId; this.returnSearch = this.searchArmed; this.phase = 'MENU'; this.menuIdx = 0; this._restartArmed = false; this.searchArmed = false; this._stopDangerLoop(); this.seqToken++; this._seqBusy = false; this.floor.interrupt(); this._sonarQueue.length = 0; this.audio.play('sfx/ui_menu'); this.audio.play('bgm/deck', { fadeMs: 500, volume: 0.4 }); // MENU 低响度淡入 this.floor.speak(['voice/sys_menu', MENU_ITEMS[0].voice], { onDone: () => this._yieldTurn(this._menuWp()), }); } closeMenu() { this._restartArmed = false; const back = this.returnPhase || 'INTRO'; this.phase = back; this.seqToken++; this._seqBusy = false; this.floor.interrupt(); 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._updateBreath(); if (back === 'DIVE' && this.returnSearch) { this._enterSearch(); // 搜索等待恢复(amb+余音等待声 + 等待点重挂) } else if (back === 'ENDING') { this.awaitingRestart = false; this._playEndingChain(); // 重播结局链(局面复述) } else if (back === 'ASK') { this.floor.speak(['voice/sys_continue_ask'], { onDone: () => this._yieldTurn(this._askWp()), }); } else { this.playNode(this.returnNode || 'b_goal'); // INTRO/COMMS/SURFACE:重播当前节点 } } // ---------------------------------------------------------------- 救援流转 /** * 新救援:重置跨潜标量(结局收集跨局保留),回 INTRO。重开不再念开场教学 * (做对过的事不教第二遍,原则 11):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.leaving = false; this.saveStash = 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._teachQueue = null; this._stopDangerLoop(); this.seqToken++; this._seqBusy = false; this.floor.interrupt(); this.audio.stopVoice(); this._stopAllLoops(); this._sonarQueue.length = 0; this.phase = 'INTRO'; this.nodeId = 'b_open'; this.audio.play('bgm/deck'); this._startLoop('sfx/amb_deck', 0.6); this._save(); this.floor.speak( ['voice/sys_new_dive', 'voice/b_open', 'voice/b_goal', 'voice/b_go'], { onDone: () => this._yieldTurn(this._continueWp(NODES.b_go)) }, ); } /** * 进潜:扣固定消耗(每次下潜扣 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._startLoop('sfx/amb_deep', 0.6); this._updateBreath(); this.nodeId = `dv${n}_down`; // 存档 node 先行(写入时机:每潜 DIVE 进入) this._save(); this.playNode(`dv${n}_down`); } /** 搜索等待武装(下潜链末):交出话轮(turn 提示音 + 等待声)。 */ _enterSearch() { this.searchArmed = true; this.phase = 'DIVE'; this._yieldTurn(this._pingWp()); } /** 接通 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._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.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; // 干涸链归因句(上浮结算内,首句 critical) } 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._save(); this.playNode('c_none'); // 首句 critical:结算死窗 } } /** 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._stopLoop('sfx/amb_deep'); this._startLoop('sfx/amb_deck', 0.5); this._stopDangerLoop(); this.audio.play('bgm/ending', { fadeMs: 1500 }); this._updateBreath(); // 收队即停呼吸垫 // 一局结局 → 手势阶梯 onGameDone(第 1 局解锁下滑=菜单、第 3 局解锁双击=重听) const got = this.ladder.onGameDone(); if (got) { this._teachQueue = this.ladder.teachLine(got); // 已带 voice/ 前缀(novice.js 修复后语义) if (this.ladder.stage() >= 3) this.audio.setRateBoostEnabled(true); // 长按=旁白加速 } 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 rest = [...def.voices.slice(1), 'voice/ep_lead', ...this._epChain()]; const teach = this._pullTeach(); if (teach) rest.push(teach); // 首句 critical:结局链入口结算死窗(17 号 §8;tap/swipe=busy 不截链) this.floor.speak([def.voices[0]], { critical: true, onDone: () => { this.floor.speak(rest, { onDone: () => { this.awaitingRestart = true; this._yieldTurn(this._settleWp()); }, }); }, }); } // ---------------------------------------------------------------- 存档续玩 _askContinue(save) { this.saveStash = save; // 预载 runs/endings:ASK 右滑=新救援走 newRun(+1),与结算重开同口径 this.runs = (save && save.runs) || 0; this.endings = { ...((save && save.endings) || {}) }; this.phase = 'ASK'; this.nodeId = save && save.node ? save.node : 'b_goal'; markHintSaid('voice/sys_continue_ask'); // 台账:静默提示不逐字复述刚说过的询问 this.floor.speak(['voice/sys_continue_ask'], { onDone: () => this._yieldTurn(this._askWp()), }); } /** 断点续玩:恢复 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.leaving = false; this.goalMet = this.ladder.stage() >= 1; // 阶梯跨局持久,恢复后不再触发教学 this.seqToken++; this._seqBusy = false; this.floor.interrupt(); this.audio.stopVoice(); this._resumeGuard = true; // F4:恢复链首句 critical,播完才武装跳句 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); this._resumeGuard = false; // 结局链首句本身 critical,不叠加 } else { this.phase = 'INTRO'; this.audio.play('bgm/deck'); this._startLoop('sfx/amb_deck', 0.6); 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_goal'); } } // ---------------------------------------------------------------- 节点链驱动 playNode(id) { const node = NODES[id]; if (!node) { console.error('[AbyssGame] 未知节点:', id); return; } this.nodeId = id; this.choiceArmed = false; this.awaitingRestart = false; this._stopDangerLoop(); this.seqToken++; // 作废在途声呐步序列 this._seqBusy = false; this.floor.interrupt(); // 干净起步:杀旧链、摘旧等待点、停等待声 if (node.enterComms) this._enterComms(); if (node.sfx) this.audio.play(node.sfx); // 非信息类一次性垫音(link_beep 等) this._runSteps(node.pre || [], () => this._speakNodeVoices(id, node)); } /** 节点内条件选播(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')); } /** * 尾句保护(§4 矩阵「锚句/档位句不可跳 → busy」,仅限 auto 过场链): * auto 链尾在「next 是 @x 过场」时恒 critical——下潜链尾 dv1_teach(教学句)、 * s_tier 档位句、p9 结果链尾、取消链尾都在此类。 * * 偏差(施工单):非 auto 叙事节点的尾句**不**做首播 critical——floor 的 * critical 是整链一刀切,会把「滑动打断并执行 / 上滑我在哪」一并挡成 busy * (26 号 §4.1 宪法)。叙事尾句跳过的代价只是提前进入等待点,由等待点 * 三档提示兜底复述;「先听完再选」的盲玩公平由 pendingChoice 承接。 */ _tailLocked(id, node) { if (!node.auto) return false; if (node.next == null || this._isTransition(node.next)) return true; const nx = NODES[node.next]; return !!(nx && this._isPromptNode(nx)); } /** * 语音阶段:按 critical 拆分段落顺序播放(floor 语义 = 链级 critical,按句 * 保护靠拆链实现)。段序:dry_a / dry_b(结算死窗,critical)→ 首句(lockFirst * / 续玩恢复锁)→ 正文 → 尾句(锚句/档位句)→ 解锁教学句(阶梯)。播完接 * post 声呐步,再走 _onNodeSeqDone。 */ _speakNodeVoices(id, node) { const parts = []; // [{ ids, critical }] if (this._dryAQueued) { this._dryAQueued = false; parts.push({ ids: ['voice/dry_a'], critical: true }); } if (this._dryBQueued) { this._dryBQueued = false; parts.push({ ids: ['voice/dry_b'], critical: true }); } let seq = this._dynVoices(node); if (node.dyn === 'tier') { // 档位节点:只在跨档时播分档整句(账目真实;sidefx 在此一次结清) const tv = this._tierPending(); if (tv) { this.lastAnnouncedTier = tierOf(this.oxy); if (this.lastAnnouncedTier === 'crit') vibrate('warning'); // G2:crit 附 warning 震 this._updateBreath(); seq = [...seq, tv]; } } const teach = this._pullTeach(); if (teach) seq = [...seq, teach]; if (this._isPromptNode(node)) { // 抉择节点:整条链就是选项句,整句 critical(tap=busy 不可跳;旁白期滑动 // 已缓存 pendingChoice,播完才生效——盲玩公平) const prompt = this._promptOf(node); if (prompt) { markHintSaid(prompt); // 台账:选项句 30s 内不做逐字被动复述(原则 9) parts.push({ ids: [prompt], critical: true }); } } else if (seq.length) { if (node.lockFirst || this._resumeGuard) { this._resumeGuard = false; parts.push({ ids: [seq[0]], critical: true }); seq = seq.slice(1); } if (seq.length && this._tailLocked(id, node)) { if (seq.length > 1) parts.push({ ids: seq.slice(0, -1) }); parts.push({ ids: [seq[seq.length - 1]], critical: true }); } else if (seq.length) { parts.push({ ids: seq }); } } const post = node.post || []; const runParts = (k) => { if (k >= parts.length) { this._runSteps(post, () => this._onNodeSeqDone(node)); return; } this.floor.speak(parts[k].ids, { critical: !!parts[k].critical, onDone: () => runParts(k + 1), }); }; runParts(0); } /** 手势解锁教学句出队(挂在当前链尾,先说一句马上用)。 */ _pullTeach() { if (this._teachQueue) { const t = this._teachQueue; this._teachQueue = null; return t; } return null; } /** '@x' 虚拟目标 = 过场转移(自动衔接)。 */ _isTransition(target) { return typeof target === 'string' && target.startsWith('@'); } /** * 节点链自然播完的公共收尾:抉择武装(含 pendingChoice 生效)/ 自动过场 / * 链末等待(交出话轮)。自动衔接仅限节点表标注 auto 的段落;@x 过场目标 * 本身由链末单击触发;其余叙事节点链末交出 continue 等待点。 */ _onNodeSeqDone(node) { this._drainSonar(); // 节点链自然收尾:voice idle,补发排队的信息音(零重叠) if (this._isPromptNode(node)) { this._armChoice(node); return; } if (node.next == null) return; if (node.auto) { this.advance(node.next); return; } this._yieldTurn(this._continueWp(node)); } /** 抉择武装:W2 契约(播完当刻即可选,零插播);旁白期缓存的滑动此刻生效。 */ _armChoice(node) { this.choiceArmed = true; if (this.pendingChoice) { const g = this.pendingChoice; this.pendingChoice = null; this.choose(g); return; } this._yieldTurn(this._choiceWp(node)); } /** 链完转移:'@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) || !this.choiceArmed) return; this.choiceArmed = false; this.pendingChoice = null; 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._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 挂下一结果节点链首(critical) this._dryPending = true; // dry_b 挂上浮结算链首(critical) } } // ---------------------------------------------------------------- 档位 / 状态链(我在哪) /** 档位句选播(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); } /** * 上滑「我在哪」状态链(17 号 §3/§9:潜次 → 已救人数 → 氧档 → 一个动作; * G2:crit 附 warning 震按档进入去重,同档复听不重复震)。 */ _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]); } 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)); // 动作尾巴(原则 3:位置 → 目标 → 现在能做的一个动作) if (this.searchArmed) ids.push('voice/sys_act_ping'); // 「点一下,发脉冲。」 else if (this.choiceArmed) ids.push('voice/status_choice'); // 「正在抉择。左右滑。」 else if (this.phase === 'ENDING') ids.push('voice/sys_hint_settle_full'); else ids.push('voice/sys_hint_continue_full'); return ids; } /** * 上滑「我在哪」:状态链(radio 口径:不摘等待点、播完回原等待点——不重播 * 节点,状态句也不算被动重复);等待点不在(旁白被打断)则按来源重建。 * 状态句全量进台账(原则 9:刚答过的话,30s 内等待点被动提示自动降档)。 */ /** * 上滑「我在哪」:状态链(radio 口径:播完回原等待点类型——不重播节点, * 状态句也不算被动重复)。interrupt 摘掉等待点:状态链期间静默 timer 清零, * 不会出现「hint 在 SPEAKING 中被丢弃却污染台账」的引擎边界。 * 选项句(p*)整句 critical 不可被上滑打断,pendingChoice 缓存不受影响。 */ _upStatus() { const wasSearch = this.searchArmed; const wasEnding = this.phase === 'ENDING'; const node = NODES[this.nodeId] || null; this.seqToken++; // 作废在途声呐步序列 this._seqBusy = false; this.floor.interrupt(); // 摘等待点 + 清静默计时(状态链期间零 fire) const ids = this._statusIds(); for (const id of ids) markHintSaid(id); // 原则 9:刚答过的话,30s 内被动提示降档 this.floor.speak(ids, { onDone: () => { if (this.phase === 'MENU' || this.phase === 'ASK') return; if (wasSearch) this._enterSearch(); // 搜索等待重挂(amb 静音) else if (wasEnding) this._yieldTurn(this._settleWp()); // 结算:不再重播结局链 else if (node && this._isPromptNode(node)) this._yieldTurn(this._choiceWp(node)); else if (node) this._yieldTurn(this._continueWp(node)); // 旁白被打断:链末单击继续 }, }); } // ---------------------------------------------------------------- 等待点(§5 配置表) /** 交出话轮的统一入口:搜索等待点静音合成等待声(深海 amb+余音替代,偏差记录)。 */ _yieldTurn(wp) { this._chainSpeaking = false; // 话轮已交出:打断快照失效 this.floor.bedEnabled = !(wp && wp.conf.id === 'ping'); this.floor.yieldTurn(wp); } _introWp() { return new WaitPoint({ id: 'intro', expected: ['tap'], full: null, // 指令句(sys_first_tap)刚说过,静默第一档直接短句 short: 'voice/sys_first_tap_short', silenceMs: SILENCE.intro, onDemo: () => this._afterFirstTap(), // L3 替点:直接替玩家走一步 }, { floor: this.floor, confusion: this.confusion }); } _continueWp(node) { return new WaitPoint({ id: 'continue', expected: ['tap'], full: 'voice/sys_hint_continue_full', short: 'voice/sys_hint_continue_short', silenceMs: SILENCE.continue, onDemo: () => { // L3 替继续:替玩家推进一步(等价单击「继续」) this.floor.speak(['voice/sys_demo_continue'], { kind: 'hint', onDone: () => { if (node && node.next != null && NODES[node.next] !== undefined) this.advance(node.next); else this._yieldTurn(this._continueWp(node)); }, }); }, }, { floor: this.floor, confusion: this.confusion }); } _pingWp() { return new WaitPoint({ id: 'ping', expected: ['tap'], full: 'voice/sys_hint_ping_full', short: 'voice/sys_hint_ping_short', silenceMs: SILENCE.ping, onDemo: () => this._firePing(), // L3 替发脉冲:游戏自己发一枚,回波照常解读 }, { floor: this.floor, confusion: this.confusion }); } _choiceWp(node) { return new WaitPoint({ id: 'choice', expected: ['left', 'right'], full: this._promptOf(node), // 复述选项 = 合法帮助(N3;台账防逐字重复) short: 'voice/sys_hint_choice_short', restrictLine: 'voice/sys_only_choice', silenceMs: SILENCE.choice, onDemo: () => { // 演示不替玩家做有后果的抉择:复述两个方向,交回 this.floor.speak(['voice/sys_demo_choice'], { kind: 'hint', onDone: () => this._yieldTurn(this._choiceWp(node)), }); }, }, { floor: this.floor, confusion: this.confusion }); } _settleWp() { return new WaitPoint({ id: 'settle', expected: ['tap'], full: 'voice/sys_hint_settle_full', short: 'voice/sys_hint_continue_short', silenceMs: SILENCE.settle, onDemo: () => this.floor.chime(), }, { floor: this.floor, confusion: this.confusion }); } _askWp() { return new WaitPoint({ id: 'ask', expected: ['tap', 'right'], full: 'voice/sys_continue_ask', short: 'voice/sys_hint_continue_short', silenceMs: SILENCE.ask, onDemo: () => this.floor.chime(), }, { floor: this.floor, confusion: this.confusion }); } _menuWp() { return new WaitPoint({ id: 'menu', expected: ['tap', 'left', 'right'], full: 'voice/menu_help', short: 'voice/menu_help', silenceMs: SILENCE.menu, onDemo: () => this.floor.chime(), }, { floor: this.floor, confusion: this.confusion }); } // ---------------------------------------------------------------- 声呐步序列(BUSY) /** * 顺序播放一组声呐步(pre/post 信息音、危机前置音)。运行期间 floor=BUSY: * 手势统一 busy 回执(旧「tap 快进回波余音」废除——26 号 BUSY 语义)。 * token 作废机制与 floor 链并行。 */ _runSteps(steps, onDone) { if (!steps || !steps.length) { if (onDone) onDone(); return; } const token = ++this.seqToken; this._seqBusy = true; let i = 0; const next = () => { if (token !== this.seqToken) return; if (i >= steps.length) { this._seqBusy = false; this._drainSonar(); // 声呐步序列结束:链真正歇下,补发排队的信息音 if (onDone) onDone(); return; } const s = steps[i++]; if (s.danger) { vibrate('danger'); if (s.dangerLoop) this._startDangerLoop(); // D4 滑坡另叠 danger 震循环 } this._sonar(s.sfx, s.volume != null ? { volume: s.volume } : undefined); setTimeout(next, s.wait || 0); }; next(); } _closeVoiceRecs() { const now = performance.now(); for (const rec of this._voiceIntervals) { if (rec.end === Infinity) rec.end = now; } } // ---------------------------------------------------------------- 声呐双通道(§5) /** * 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 异步:落地前已被停掉/被替换则不播;资产缺失静默降级(玩法不受阻)。 * 搜索等待的等待声 = amb_deep 常驻 + 回波余音(17 号 §2)。 */ _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'); } // ---------------------------------------------------------------- 存档(§7 / 26 号 §4.12) /** data 结构照 §7(超集:form/dryPending/bond 为断点续玩派生账)。 */ _save() { if (this.phase === 'ASK') return; // 询问态不落盘:stash 里的原存档才是真进度 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.phase === 'MENU' ? (this.returnNode || this.nodeId) : 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;