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 全项目全绿
1796 lines
75 KiB
JavaScript
1796 lines
75 KiB
JavaScript
/**
|
||
* 《深海回音》——深海救援叙事 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;
|