Files
SoundGame/web/abyss/game.js
T
xsl c8843233b0 research(new8/batch2): abyss + detective 迭代闭环收口——双双 R1-R2 连续达标裁定可交付
abyss《深海回音》:
- R1 补 synthesis(5/5 愿意玩 P0=0)+ 补录 R1 缺失裁决材料
- R2 十修全部现场坐实:F1 ping迟到/尾句保护首播制/链末看门狗不拆/回波静默一拍/dv4_dual扣账口播/crit水面变体/看门狗13s/震循环1.1s/震去重/状态链人数句
- 补录语音18段(flock串行,QC无离群);playtest 242→278
- 终裁:4/4 人设满分档,连续 2 轮达标 → 可交付 ✅

detective《雨夜专案》:
- R1 取证轮:补跑 live 两会话(认真新手33分钟全弧线/乱按200s三级保底抢救),5/5 愿意玩 P0=0
- R2 十一修全部坐实:intro完整落声/tut不被掐/按轴取句/人名指认/结案确认闸/指路句/选句记忆位/hint轻震/st_wrong当场记账/tut_skip补动词/L2疑点齐分支
- 补录+改词33条(29新增+4重录);playtest 265→279
- 终裁:6/6 愿意玩,连续 2 轮达标 → 可交付 ✅

公共:00-program 批次2进度账收账、13号文档 R2 断言关键词、gen_voice_dual 两游戏段、voice_text.json
验证:abyss 278/278 + detective 279/279 + guide 122/122 + check_all_assets 全项目全绿
2026-09-14 00:05:42 +08:00

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