Files
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

1779 lines
72 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《深海回音》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;