storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行 (发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/ 菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。 游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio / dungeon / abyss / detective / cipher / teahouse / keeper / bridge / hunt(联机切片): - 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0); 四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt 旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore) - 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动 原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿 - 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/ 台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写 引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record 前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前 状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在 speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。 语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110 废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器 tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a 落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。 大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README / HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
1779 lines
72 KiB
JavaScript
1779 lines
72 KiB
JavaScript
/**
|
||
* 《深海回音》R3 —— 深海救援叙事 RPG,按 docs/design/26-audio-interaction-playbook.md
|
||
* 深度重构(设计单正文见 docs/design/17-abyss.md 附录 C;施工单见 26-apply-abyss.md)。
|
||
*
|
||
* 玩法核心不动(17 号正文):节点链 + seqToken + pendingChoice 骨架、氧气步进经济
|
||
* (每潜扣 3 / 抉择计分 / floor 至 0 干涸链 / SURFACE(3) 氧气门三形态)、9 抉择
|
||
* (单局实走 7-9)、4 结局 + 尾声回响、DIVER/DECK 双音色资产 + NPC 平铺、存档
|
||
* summary。交互层整体换成 26 号六模块:
|
||
*
|
||
* - 发言权管理器(§4.1):整条语音链 = SPEAKING(holdDuck 跨链、句间不掉回
|
||
* 「轮到你」听感);链完 → yieldTurn:交接提示音 → 400ms 后等待声;玩家一
|
||
* 操作等待声 80ms 内淡出。声呐步(回波余音/危机前置)= BUSY,手势给 busy 回执。
|
||
* - 输入回执 + 管道(§4.2):ok / invalid / busy 三件套;tap「继续/跳过」后
|
||
* 400ms 防连点;滑动打断并执行(floor 统一裁定)。
|
||
* - 新手手势宪法(§4.3):doubleTapWindow=0;单击=继续/跳句/搜索等待发脉冲;
|
||
* 左右滑=抉择;上滑=「我在哪」(阶段 1,第一次脉冲成功后解锁);下滑=菜单
|
||
* (阶段 2,第一次结局);双击阶段 3=重听当前等待点完整句。**废除双击重听
|
||
* 回波**:搜索等待再单击=再发脉冲,自然再听(17 号 §4)。
|
||
* - 三档提示(§4.4):等待点 full → short → 交接提示音;主动重听=「再说一遍:」
|
||
* +完整句;台账 30s 跨等待点去重(原则 9)。
|
||
* - 迷茫检测(§4.5):乱点/无效/静默信号 → 三级接管(L1 短句 → L2 只留期待
|
||
* 手势 → L3 演示:搜索等待替玩家发一枚脉冲)。
|
||
* - 手势阶梯(§4.3):第一次脉冲成功 → onGoal(上滑);一局结局 → onGameDone
|
||
* (下滑 / 第 3 局双击);教学句挂链尾,先说一句马上用一次(原则 11)。
|
||
*
|
||
* 状态 × 手势矩阵(新手期,完整版见 17 号附录 C §4):
|
||
* | 状态 | 单击 | 左/右滑 | 上滑 | 下滑(未解锁) | 双击(未解锁) |
|
||
* |-----------|------------------|--------------------|-------------|--------------|--------------|
|
||
* | 游戏在说 | 跳句+ok;p 选项句/锚句/档位句/结算链首句 critical → busy | 旁白中滑到抉择点:缓存 pendingChoice+跳选项句;否则 invalid | 打断,答「我在哪」 | invalid+解释 | 按单击处理 |
|
||
* | 等你继续 | 继续+ok | invalid(inv_nosel)| 我在哪+局面复述 | invalid | (阶段3=重听)|
|
||
* | 搜索等待 | **脉冲**(silent-ok,声呐即内容音) | invalid+「点一下,发脉冲。」 | 状态链(潜次/已救/氧档/动作) | invalid | 按单击=再脉冲(阶段3=重听) |
|
||
* | 等你选择 | 再听选项(hold) | 抉择+ok | 「正在抉择。左右滑。」 | invalid | (阶段3=重听)|
|
||
* | 结算 | 新救援+ok | invalid | 结局主句+氧档+动作 | 菜单(阶段2) | 按单击 |
|
||
* | 菜单 | 确认当前项 | 左上一项/右下一项并报名 | 重听菜单说明 | 关闭 | (阶段3=重听说明)|
|
||
*
|
||
* 搜索等待听感(17 号 §2):深海 amb_deep 常驻 + 回波余音替代合成等待声——
|
||
* 引擎 Bed 为全局统一音色,游戏层在搜索等待点置 floor.bedEnabled=false(偏差
|
||
* 记录见施工单)。声呐与语音互斥(sonarGate 空闲门 + 排队补发)保留。
|
||
*
|
||
* 结算死窗(17 号 §8):终局取消链 / 干涸链 / 签收链 / 结局链**首句 critical**
|
||
* ——手势给 busy 回执不截链(比旧 800ms 死窗覆盖整句,更强)。断点复述(§4.12):
|
||
* 恢复链首句同样 critical(F4 锁的等价实现)。
|
||
*/
|
||
import { Game } from '../engine/game.js';
|
||
import { Floor } from '../engine/floor.js';
|
||
import { Receipts } from '../engine/receipts.js';
|
||
import { Confusion } from '../engine/confusion.js';
|
||
import { WaitPoint, InvalidHints, markHintSaid } from '../engine/hints.js';
|
||
import { GestureLadder } from '../engine/novice.js';
|
||
import { vibrate } from '../engine/haptics.js';
|
||
|
||
// ---------------------------------------------------------------- 声呐时序常量
|
||
|
||
// 声呐链驻留时长(ms):脉冲→回波静默一拍 / 回波余音 / 危机前置 2s(17 号 §5)
|
||
const PING_GAP_MS = 900;
|
||
const W_Metal = 3300;
|
||
const W_Knock = 3500;
|
||
const W_Groan = 5000;
|
||
const W_Crisis = 2000;
|
||
const W_Bell = 3200;
|
||
const DANGER_LOOP_MS = 1100; // danger 震循环间隔(≥400ms,§5.2)
|
||
|
||
// ---------------------------------------------------------------- 节点表
|
||
|
||
/** 声呐步:信息音/危机前置音,wait = 起播后链驻留时长(ms)。 */
|
||
function S(sfx, wait, extra = {}) {
|
||
return { sfx, wait, danger: false, dangerLoop: false, volume: undefined, ...extra };
|
||
}
|
||
|
||
/** 叙事节点。voices 顺序播放;pre/post 为声呐步(链内零重叠,§5)。 */
|
||
function N(voices, opts = {}) {
|
||
return {
|
||
voices, prompt: null, left: null, right: null, next: null, dyn: null,
|
||
pre: null, post: null, sfx: null, auto: false, enterComms: false,
|
||
lockFirst: false, ...opts,
|
||
};
|
||
}
|
||
/** 抉择节点:本体无台词,链尾接 p* 提示句(整句 critical:tap=busy、滑动缓存承接)。 */
|
||
function P(prompt, left, right, opts = {}) {
|
||
return {
|
||
voices: [], prompt, left, right, next: null, dyn: null,
|
||
pre: null, post: null, sfx: null, auto: false, enterComms: false,
|
||
lockFirst: false, ...opts,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 全部节点(id = 首句 voice id;台词全文见 17-abyss-voice-ids.md)。
|
||
* next 为 '@x' 虚拟目标:@dive 进第一潜 / @search 武装搜索等待 /
|
||
* @surfaceN 上浮结算 / @surfaceNext 结算后分流(下一潜 / 氧气门 / 签收)/
|
||
* @signoff 签收链 / @ending 进结局。p7 提示与目标按氧气门形态动态解析
|
||
* (双载 p7 / 单载 p7_chen|p7_zheng,dyn='p7')。lockFirst:结算链首句
|
||
* critical(g9_in / c_none,替代旧 800ms 手势死窗,17 号 §8)。
|
||
*/
|
||
const NODES = {
|
||
// ---- INTRO 开场(26 号 §4.10:首句 sys_first_tap 让玩家动手;b_goal 极短目标句;
|
||
// b_rule 规则念白废除——账目契约由氧气门取消链与档位句承载)
|
||
b_open: N(['voice/b_open'], { next: 'b_goal' }),
|
||
b_goal: N(['voice/b_goal'], { next: 'b_go' }),
|
||
b_go: N(['voice/b_go'], { next: '@dive' }),
|
||
|
||
// ---- D1 找船·小舟(教学潜:单击=脉冲 / 金属回波=船身,两大教学承诺)
|
||
dv1_down: N(['voice/dv1_down'], { auto: true, next: 'dv1_teach' }),
|
||
// dv1_teach 是下潜链尾句(auto + next=@search 过场)→ 自动 critical:
|
||
// 教学指令「单击,发一枚脉冲」不可被打断(17 号 §8 critical 清单)
|
||
dv1_teach: N(['voice/dv1_teach'], { auto: true, next: '@search' }),
|
||
// 搜索回波链:脉冲 → 静默一拍 → echo_metal_l → 解读 dv1_metal(承诺音)
|
||
sv1_echo: N(['voice/dv1_metal'], {
|
||
auto: true,
|
||
pre: [S('sfx/sonar_ping', PING_GAP_MS), S('sfx/echo_metal_l', W_Metal)],
|
||
next: 'dv1_link',
|
||
}),
|
||
dv1_link: N(['voice/dv1_link'], { sfx: 'sfx/link_beep', auto: true, next: 'z1_a' }),
|
||
z1_a: N(['voice/z1_a'], { sfx: 'sfx/static_burst', enterComms: true, next: 'z1_b' }),
|
||
z1_b: N(['voice/z1_b'], { next: 'z1_knock' }),
|
||
// z1_knock 垫 pipe_knock 两遍(线索音 2-3 次取确定值 2;语音播完才响)
|
||
z1_knock: N(['voice/z1_knock'], {
|
||
post: [S('sfx/pipe_knock', W_Knock), S('sfx/pipe_knock', W_Knock)],
|
||
next: 'p1',
|
||
}),
|
||
p1: P('voice/p1', 'd1_listen', 'd1_up'),
|
||
d1_listen: N(['voice/d1_listen'], { post: [S('sfx/pipe_knock', W_Knock)], next: 'd1_code' }),
|
||
d1_code: N(['voice/d1_code'], { next: 'p2' }),
|
||
d1_up: N(['voice/d1_up'], { next: 'z1_bye' }),
|
||
z1_bye: N(['voice/z1_bye'], { next: 'p2' }),
|
||
p2: P('voice/p2', 'd1_log', 'd1_nolog'),
|
||
d1_log: N(['voice/d1_log'], { next: '@surface1' }),
|
||
d1_nolog: N(['voice/d1_nolog'], { next: '@surface1' }),
|
||
|
||
// ---- SURFACE(1) 上浮结算(自动链 ≤15s:扣账句 → 跨档档位句 → 直接下一潜)
|
||
s1_surf: N(['voice/d1_surf'], { auto: true, next: 's_tier' }),
|
||
|
||
// ---- D2 机舱·老白(危机拍#1:danger 震 + spark_flash 前置 2s)
|
||
dv2_down: N(['voice/dv2_down'], { auto: true, next: '@search' }),
|
||
sv2_echo: N(['voice/dv2_found'], {
|
||
auto: true,
|
||
pre: [S('sfx/sonar_ping', PING_GAP_MS), S('sfx/echo_debris', W_Metal), S('sfx/pipe_knock', W_Knock)],
|
||
next: 'bai_a',
|
||
}),
|
||
bai_a: N(['voice/bai_a'], { enterComms: true, next: 'bai_b' }),
|
||
bai_b: N(['voice/bai_b'], { next: 'p3' }),
|
||
p3: P('voice/p3', 'd2_pry', 'd2_cut'),
|
||
d2_pry: N(['voice/d2_pry'], { next: 'd2_pryres' }),
|
||
// 节点内条件选播:knowsPipe 决定 -0 / -1 的差分收尾
|
||
d2_pryres: N([], { dyn: 'd2pry', next: 'bai_out' }),
|
||
d2_cut: N(['voice/d2_cut'], {
|
||
auto: true,
|
||
post: [S('sfx/spark_flash', W_Crisis, { danger: true })],
|
||
next: 'd2_flash',
|
||
}),
|
||
d2_flash: N(['voice/d2_flash'], { next: 'bai_out' }),
|
||
bai_out: N(['voice/bai_out'], { next: 'p4' }),
|
||
p4: P('voice/p4', 'd2_valve', 'd2_leave'),
|
||
d2_valve: N(['voice/d2_valve'], { post: [S('sfx/valve_close', W_Metal)], next: 'd2_valve_e' }),
|
||
d2_valve_e: N(['voice/d2_valve_e'], { next: 'bai_thanks' }),
|
||
bai_thanks: N(['voice/bai_thanks'], { next: '@surface2' }),
|
||
d2_leave: N(['voice/d2_leave'], { next: '@surface2' }),
|
||
|
||
// ---- SURFACE(2):d2_surf → d2_supply → oxy_gain(应急瓶 +3,全局唯一)
|
||
s2_surf: N(['voice/d2_surf'], { auto: true, next: 's2_supply' }),
|
||
s2_supply: N(['voice/d2_supply'], { auto: true, next: 's2_gain' }),
|
||
s2_gain: N(['voice/oxy_gain'], { auto: true, next: 's_tier' }),
|
||
|
||
// ---- D3 病舱·陈医官/小郑(回波预警埋 D4 险;小郑无台词,吊床固定)
|
||
dv3_down: N(['voice/dv3_down'], { auto: true, next: '@search' }),
|
||
sv3_echo: N(['voice/dv3_groan'], {
|
||
auto: true,
|
||
pre: [S('sfx/sonar_ping', PING_GAP_MS), S('sfx/echo_groan', W_Groan)],
|
||
next: 'chen_a',
|
||
}),
|
||
chen_a: N(['voice/chen_a'], { enterComms: true, next: 'chen_b' }),
|
||
chen_b: N(['voice/chen_b'], { next: 'p5' }),
|
||
p5: P('voice/p5', 'd3_chen', 'd3_zheng'),
|
||
d3_chen: N(['voice/d3_chen'], { next: 'chen_up' }),
|
||
chen_up: N(['voice/chen_up'], { next: 'd3_chen_e' }),
|
||
d3_chen_e: N(['voice/d3_chen_e'], { next: 'p6' }),
|
||
d3_zheng: N(['voice/d3_zheng'], { next: 'd3_zheng_e' }),
|
||
d3_zheng_e: N(['voice/d3_zheng_e'], { next: 'chen_stay' }),
|
||
chen_stay: N(['voice/chen_stay'], { next: 'p6' }),
|
||
p6: P('voice/p6', 'd3_spare', 'd3_promise'),
|
||
d3_spare: N(['voice/d3_spare'], { next: 'd3_spare_e' }),
|
||
d3_spare_e: N(['voice/d3_spare_e'], { next: '@surface3' }),
|
||
d3_promise: N(['voice/d3_promise'], { next: '@surface3' }),
|
||
|
||
// ---- SURFACE(3):d3_surf → 档位 → 氧气门三形态
|
||
s3_surf: N(['voice/d3_surf'], { auto: true, next: 's_tier' }),
|
||
|
||
// ---- D4 指挥舱·高船长(高潮:滑坡危机拍前置 2s 渐强 + danger 震循环)
|
||
// dv4_down 链内条件选播(R2-F4):双载形态追加 dv4_dual「这趟往返,扣四格气」
|
||
dv4_down: N([], { auto: true, next: '@search', dyn: 'dv4down' }),
|
||
// 回波渐强 + 金属回波(§6.3 承诺:D4 接近指挥舱必播 echo_metal*,取 _r 声像)
|
||
sv4_echo: N([], {
|
||
auto: true,
|
||
pre: [S('sfx/sonar_ping', PING_GAP_MS), S('sfx/echo_groan', W_Groan), S('sfx/echo_metal_r', W_Metal)],
|
||
next: 'sv4_crisis',
|
||
}),
|
||
sv4_crisis: N(['voice/dv4_slide'], {
|
||
auto: true,
|
||
pre: [S('sfx/landslide', W_Crisis, { danger: true, dangerLoop: true })],
|
||
next: 'gao_a',
|
||
}),
|
||
gao_a: N(['voice/gao_a'], { enterComms: true, next: 'gao_b' }),
|
||
gao_b: N(['voice/gao_b'], { next: 'p7' }),
|
||
// p7 三形态:双载(快慢浮)/ 单载(按 stayer 选 p7_chen|p7_zheng,谁上)
|
||
p7: P(null, null, null, { dyn: 'p7' }),
|
||
d4_slow: N(['voice/d4_slow'], { next: 'd4_slow_e' }),
|
||
d4_slow_e: N(['voice/d4_slow_e'], { next: 'd4_confess' }),
|
||
d4_fast: N(['voice/d4_fast'], { next: 'd4_fast_e' }),
|
||
d4_fast_e: N(['voice/d4_fast_e'], { next: 'd4_confess' }),
|
||
// 心结引入差分:hasLog ? gao_confess : gao_c
|
||
d4_confess: N([], { dyn: 'gaoconfess', next: 'p8' }),
|
||
p8: P('voice/p8', 'd4_talk', 'd4_silent'),
|
||
d4_talk: N(['voice/d4_talk'], { next: 'gao_peace' }),
|
||
gao_peace: N(['voice/gao_peace'], { next: '@surface4' }),
|
||
d4_silent: N(['voice/d4_silent'], { next: '@surface4' }),
|
||
d4_pick_gao: N(['voice/d4_pick_gao'], { next: 'd4_leave' }),
|
||
// 带走船长时留守者留别:chen_last | d4_stay_lost;船长在乘 → 心结差分 → p8
|
||
d4_leave: N([], { dyn: 'stayleave', next: 'd4_confess' }),
|
||
d4_pick_chen: N(['voice/d4_pick_chen'], { auto: true, next: 'd4_gaolast' }),
|
||
d4_pick_zheng: N(['voice/d4_pick_zheng'], { auto: true, next: 'd4_gaolast' }),
|
||
// 带走留守者时船长留别(p8 跳过,直达上浮)
|
||
d4_gaolast: N(['voice/gao_last'], { auto: true, next: '@surface4' }),
|
||
|
||
// ---- SURFACE(4):d4_surf → 档位 → 签收
|
||
s4_surf: N(['voice/d4_surf'], { auto: true, next: 's_tier' }),
|
||
|
||
// ---- 档位节点(dyn='tier':只在跨档时播分档整句,整句 critical=档位句不可跳;空则直通)
|
||
s_tier: N([], { dyn: 'tier', auto: true, next: '@surfaceNext' }),
|
||
|
||
// ---- 终局取消链(氧气门 ≤2 形态;首句 critical:结算死窗,17 号 §8)
|
||
c_none: N(['voice/dv4_none'], { auto: true, next: 'c_final', lockFirst: true }),
|
||
c_final: N(['voice/gao_final'], { auto: true, next: '@signoff' }),
|
||
|
||
// ---- 签收 + ◆p9 心结抉择(p9 左需 oxy≥1;不足时 g9_dry 播出后自动走右)
|
||
g9_in: N(['voice/g9_in'], { auto: true, next: 'g9_knot', lockFirst: true }),
|
||
g9_knot: N(['voice/g9_knot'], { next: 'p9' }),
|
||
p9: P('voice/p9', 'd9_bell', 'd9_rest'),
|
||
g9_dry: N(['voice/g9_dry'], { auto: true, next: 'd9_rest' }),
|
||
d9_bell: N(['voice/d9_bell'], { auto: true, post: [S('sfx/bell_toll', W_Bell)], next: 'd9_bello' }),
|
||
d9_bello: N(['voice/d9_bello'], { auto: true, next: '@ending' }),
|
||
d9_rest: N(['voice/d9_rest'], { auto: true, next: '@ending' }),
|
||
};
|
||
|
||
// ---------------------------------------------------------------- 常量表
|
||
|
||
/** 潜次舱段(summary / 状态链用)。 */
|
||
const SEGS = ['找船', '机舱', '病舱', '指挥舱'];
|
||
|
||
/** 上滑状态链「第几潜」分档整句(§4.3 数字规避:禁止运行时拼接)。 */
|
||
const PROG_VOICES = ['voice/prog_d1', 'voice/prog_d2', 'voice/prog_d3', 'voice/prog_d4'];
|
||
/** 氧气档位分档整句(floor(oxy/3)=可下潜次数划界,句义逐格为真,§4.3)。 */
|
||
const TIER_VOICES = { full: 'voice/oxy_full', ok: 'voice/oxy_ok', low: 'voice/oxy_low', crit: 'voice/oxy_crit' };
|
||
/** R2-F5:crit 水面变体——人已出水后「必须上浮」是错场景措辞(甲板=一潜都下不去)。 */
|
||
const TIER_CRIT_DECK = 'voice/oxy_crit_deck';
|
||
|
||
/** 上滑状态链「已救人数」分档整句(R2-F10;saved=0 不播,数字写死禁拼接)。 */
|
||
const SAVED_VOICES = [
|
||
'voice/prog_saved1', 'voice/prog_saved2', 'voice/prog_saved3',
|
||
'voice/prog_saved4', 'voice/prog_saved5',
|
||
];
|
||
|
||
const MENU_ITEMS = [
|
||
{ id: 'resume', voice: 'voice/sys_menu_resume' }, // 回到水下
|
||
{ id: 'restart', voice: 'voice/sys_menu_restart' }, // 重新开始这次救援(二次确认)
|
||
{ id: 'lobby', voice: 'voice/sys_menu_lobby' }, // 保存并回大厅
|
||
];
|
||
|
||
/** 4 结局(§3.4:savedCount × p9 四选一)。 */
|
||
const ENDINGS = {
|
||
e1: { name: '海燕归钟', voices: ['voice/e1_a', 'voice/e1_b'] },
|
||
e2: { name: '满载的黎明', voices: ['voice/e2_a', 'voice/e2_b'] },
|
||
e3: { name: '最后一潜', voices: ['voice/e3_a', 'voice/e3_b'] },
|
||
e4: { name: '按规程上浮', voices: ['voice/e4_a', 'voice/e4_b'] },
|
||
};
|
||
|
||
const ALL_SFX = [
|
||
'sfx/sonar_ping', 'sfx/echo_metal_l', 'sfx/echo_metal_r', 'sfx/echo_debris',
|
||
'sfx/echo_groan', 'sfx/pipe_knock', 'sfx/link_beep', 'sfx/static_burst',
|
||
'sfx/spark_flash', 'sfx/valve_close', 'sfx/landslide', 'sfx/surf_splash',
|
||
'sfx/ascent_chime', 'sfx/bell_toll', 'sfx/amb_deep', 'sfx/amb_deck',
|
||
'sfx/breath_loop', 'sfx/ui_confirm', 'sfx/ui_menu',
|
||
];
|
||
|
||
const SAVE_KEY = 'soundgame.progress.abyss';
|
||
const AUTH_KEY = 'soundgame.auth';
|
||
|
||
/** 抉择提示音 id(voice/p1~p9,含 p7_chen/p7_zheng):整句 critical 不可跳。 */
|
||
const PROMPT_RE = /^voice\/p\d/;
|
||
|
||
// 等待点静默阈值(17 号附录 C §5:intro 6/15、ping 8/18、continue 13/30、
|
||
// choice 25/50、settle 20/45;COMMS 链末 full 档 13s/30s 不对齐旧 25/50)
|
||
const SILENCE = {
|
||
intro: [6000, 15000],
|
||
ping: [8000, 18000],
|
||
continue: [13000, 30000],
|
||
choice: [25000, 50000],
|
||
settle: [20000, 45000],
|
||
ask: [12000, 30000],
|
||
menu: [20000, 45000],
|
||
};
|
||
|
||
// 无效操作解释句(原则 7:每个「状态 × 手势」只解释一次;locked:* 由管道触发)
|
||
const INVALID_LINES = {
|
||
'locked:up': 'voice/inv_up_locked',
|
||
'locked:down': 'voice/inv_down_locked',
|
||
'search:swipe': 'voice/sys_act_ping', // 「点一下,发脉冲。」(17 号 §7 失败表)
|
||
'nosel': 'voice/inv_nosel', // 「现在不用选。」
|
||
generic: 'voice/gd_invalid', // 「这个手势,水里用不上。」(保留旧句作通用)
|
||
menuHelp: 'voice/menu_help',
|
||
};
|
||
|
||
/** 氧气档位(§4.3):floor(oxy/3) = 可下潜次数,句子说的就是判定用的。 */
|
||
function tierOf(oxy) {
|
||
const dives = Math.floor(Math.max(0, oxy) / 3);
|
||
if (dives >= 3) return 'full';
|
||
if (dives === 2) return 'ok';
|
||
if (dives === 1) return 'low';
|
||
return 'crit';
|
||
}
|
||
|
||
/** 节点 id → 潜号(断点续玩兜底:存档缺 dive 字段时按节点前缀推断)。 */
|
||
function diveOfNode(id) {
|
||
if (/^(dv|sv)(\d)/.test(id)) return Number(id[2]);
|
||
if (/^s(\d)_/.test(id)) return Number(id[1]);
|
||
if (/^z1_|^d1_|^p[12]$/.test(id)) return 1;
|
||
if (/^bai_|^d2_|^p[34]$/.test(id)) return 2;
|
||
if (/^chen_|^d3_|^p[56]$/.test(id)) return 3;
|
||
if (/^gao_|^d4_|^g9_|^d9_|^p[789]$|^c_|^e[1-4]$|^ep_/.test(id)) return 4;
|
||
return 0;
|
||
}
|
||
|
||
export class AbyssGame extends Game {
|
||
constructor(audio, root) {
|
||
super(audio, root);
|
||
|
||
// ---- 26 号六模块接线(§4.1/§4.2/§4.5/§4.3)
|
||
this.receipts = new Receipts(audio);
|
||
this.floor = new Floor(audio);
|
||
this.confusion = new Confusion({
|
||
onConfused: (lvl) => { const wp = this.floor.waitPoint; if (wp) wp.takeover(lvl); },
|
||
});
|
||
this.invalidHints = new InvalidHints().bind(this.floor);
|
||
this.ladder = new GestureLadder();
|
||
this.audio.setRateBoostEnabled(this.ladder.stage() >= 3); // 长按:新手 invalid,阶段 3 起旁白加速
|
||
|
||
// 语音占用区间记账(sonarGate 零重叠断言用):包装 floor.speak/interrupt
|
||
// _chainSpeaking = 打断快照:pipeline handleInput 对滑动/长按「打断并执行」
|
||
// 先杀链把 floor 置 BUSY,游戏 onGesture 里 floor.speaking 已 false——快照
|
||
// 保留「刚才叙事链在说」语义,让滑动按矩阵落「旁白中」分支(缓存抉择/恢复)
|
||
this._chainSpeaking = false;
|
||
this._voiceIntervals = [];
|
||
this._sonarPlays = [];
|
||
const origSpeak = this.floor.speak.bind(this.floor);
|
||
this.floor.speak = (ids, opts = {}) => {
|
||
for (const id of ids || []) {
|
||
if (typeof id === 'string' && id.startsWith('voice/')) {
|
||
this._voiceIntervals.push({ id, start: performance.now(), end: Infinity });
|
||
}
|
||
}
|
||
const done = opts.onDone;
|
||
opts.onDone = () => {
|
||
this._chainSpeaking = false;
|
||
this._closeVoiceRecs();
|
||
this._setAmbDucked(false);
|
||
if (done) done();
|
||
};
|
||
this._chainSpeaking = true;
|
||
this._setAmbDucked(true); // 语音在播时 amb 常驻声呐元素降 -6dB(R3)
|
||
return origSpeak(ids, opts);
|
||
};
|
||
const origInterrupt = this.floor.interrupt.bind(this.floor);
|
||
this.floor.interrupt = () => {
|
||
this._chainSpeaking = false;
|
||
this._closeVoiceRecs();
|
||
this._setAmbDucked(false);
|
||
return origInterrupt();
|
||
};
|
||
|
||
this.phase = 'INTRO'; // INTRO|DIVE|COMMS|SURFACE|ENDING|MENU|ASK
|
||
this.returnPhase = null; // MENU 关闭后回原状态
|
||
this.returnNode = null;
|
||
this.returnSearch = false; // MENU 前是否在搜索等待
|
||
|
||
this.nodeId = 'b_goal';
|
||
this.diveN = 0; // 当前潜号(0=甲板简报)
|
||
this.diveForm = null; // 氧气门形态:'double'|'single'|'cancel'(D4 前置)
|
||
this.endingId = null;
|
||
|
||
this.choiceArmed = false;
|
||
this.pendingChoice = null; // 旁白链中的滑动缓存(V1:选项句播完才生效,零插播)
|
||
this.awaitingRestart = false;
|
||
this.leaving = false; // 菜单「保存并回大厅」已确认
|
||
this.searchArmed = false; // DIVE 搜索等待(单击=发脉冲)
|
||
this.seqToken = 0;
|
||
|
||
// ---- 跨潜标量(§4.1;跨潜只传标量,分支不爆炸)
|
||
this.oxy = 12; // 氧气格(步进资源:只在事件点扣)
|
||
this.supply = false; // D2 上浮后应急瓶已发(全局唯一)
|
||
this.saved = { zhou: 0, bai: 0, chen: 0, zheng: 0, gao: 0 };
|
||
this.stayer = null; // p5 后留守病舱者('chen'|'zheng')
|
||
this.flags = {
|
||
knowsPipe: false, hasLog: false, valve: false,
|
||
spareGift: false, gaoPeace: false, bell: false,
|
||
};
|
||
this.bond = { zhou: 0, bai: 0 }; // 好感(只影响回响选播,不影响结局判定)
|
||
this.endings = {}; // 结局收集(跨局保留)
|
||
this.runs = 0;
|
||
|
||
this.lastAnnouncedTier = null; // 跨档播报基准(null=首潜结算必播档位)
|
||
this.saveStash = null; // ASK 态暂存的存档
|
||
this.sysSkipShown = false; // sys_skip 每实例一次
|
||
this.goalMet = false; // 第一个小目标(第一次脉冲成功)→ 解锁上滑
|
||
this._teachQueue = null; // 待挂到下一段链尾的解锁教学句(§4.3 阶梯)
|
||
|
||
// F4 断点续玩首句锁:恢复链首句 critical(播完即清)
|
||
this._resumeGuard = false;
|
||
this._critWarned = false; // R2-F9:crit warning 震按档进入去重
|
||
|
||
// 声呐双通道(§5):空闲门 + 排队补发(回波与语音零重叠)
|
||
this._sonarQueue = [];
|
||
this._seqBusy = false; // 声呐步序列运行中(voicePlaying 兼容语义)
|
||
|
||
// 干涸链(§4.3):mid-dive floor 至 0 → dry_a(当场合)→ dry_b(上浮结算内)
|
||
this._dryPending = false;
|
||
this._dryAQueued = false;
|
||
this._dryBQueued = false;
|
||
|
||
// 环境音循环句柄(amb_deck/amb_deep/breath_loop;buffer.loop 无重触发定时器)
|
||
this._loops = {};
|
||
this._ambDucked = false; // R3:语音在播时 amb 降 -6dB(gain ×0.5)
|
||
this._breathOn = false; // 低氧混音签名(tier=low/crit 且水下)
|
||
this._breathLog = []; // 断言用:呼吸垫起停轨迹
|
||
|
||
// danger 震循环(D4 危机拍专用,间隔 ≥400ms)
|
||
this._dangerLoop = null;
|
||
|
||
// 菜单
|
||
this.menuIdx = 0;
|
||
this._restartArmed = false; // 重新开始二次确认(00 号 §5.4)
|
||
}
|
||
|
||
/** 管道选项:长按由游戏层裁定(新手 invalid;阶段 3 起=引擎旁白加速)。 */
|
||
get PIPELINE_OPTS() { return { longpressCore: true }; }
|
||
|
||
// ---- 兼容语义(harness/测试):voicePlaying 含声呐步 BUSY 段
|
||
get voicePlaying() { return this.floor.speaking || this._seqBusy; }
|
||
get currentVoiceId() { return this.floor.currentId(); }
|
||
get promptPlaying() { return PROMPT_RE.test(this.floor.currentId() || ''); }
|
||
|
||
// ---------------------------------------------------------------- Game
|
||
|
||
start() {
|
||
this.audio.play('bgm/deck'); // 甲板(bgm=0 时引擎只记意图,重开自动补播)
|
||
this.audio.prewarm(ALL_SFX); // 预解码短音效
|
||
this._startLoop('sfx/amb_deck', 0.6);
|
||
const local = this._loadLocal();
|
||
if (local && local.phase && local.node) {
|
||
this._askContinue(local);
|
||
return;
|
||
}
|
||
// 无本地存档:登录态下发服务器存档(1.5s 内未返回/无存档 → 冷启动)
|
||
let auth = null;
|
||
try { auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); } catch (_) { /* 静默 */ }
|
||
if (!(auth && auth.token)) { this._coldStart(); return; }
|
||
let settled = false;
|
||
const goFresh = () => { if (settled) return; settled = true; this._coldStart(); };
|
||
fetch('/api/progress?game=abyss', { headers: { Authorization: `Bearer ${auth.token}` } })
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((j) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
const d = j && j.data;
|
||
if (d && d.phase && d.node) this._askContinue(d);
|
||
else goFresh();
|
||
})
|
||
.catch(goFresh);
|
||
setTimeout(goFresh, 1500);
|
||
}
|
||
|
||
stop() {
|
||
this.seqToken++; // 作废一切在途链/声呐步回调
|
||
this._seqBusy = false;
|
||
this._save(); // 退出自动存档(26 号 §4.12 / 原则 16)
|
||
this.floor.interrupt();
|
||
this._stopAllLoops();
|
||
this._stopDangerLoop();
|
||
this.audio.stopAll();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 开场(§4.10 首 3 分钟)
|
||
|
||
/**
|
||
* 冷启动首 3 分钟(17 号附录 C §6 逐秒脚本):0~3 秒第一句就让玩家动手——
|
||
* 甲板风浪(amb_deck)→「点一下屏幕。」(critical 指令句,抢拍单击给 busy,
|
||
* 保证唯一一次教学完整到达);规则念白废除(b_rule 停用),目标句后置。
|
||
*/
|
||
_coldStart() {
|
||
this.phase = 'INTRO';
|
||
this.nodeId = 'b_goal';
|
||
this._save();
|
||
this.floor.speak(['voice/sys_first_tap'], {
|
||
critical: true,
|
||
onDone: () => this._yieldTurn(this._introWp()),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 第一次单击之后(3~8 秒):极短目标句 +「轮到你了」状态教学(原则 2:
|
||
* 状态声第一次出现配解释)+ sys_skip(每实例一次);随后链末等待单击下潜。
|
||
*/
|
||
_afterFirstTap() {
|
||
this.nodeId = 'b_goal';
|
||
const seq = ['voice/b_goal', 'voice/sys_turn_teach'];
|
||
if (!this.sysSkipShown) {
|
||
this.sysSkipShown = true;
|
||
seq.push('voice/sys_skip');
|
||
}
|
||
this.floor.speak(seq, {
|
||
onDone: () => this._yieldTurn(this._continueWp(NODES.b_goal)),
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势(pipeline 之后)
|
||
|
||
/** pipeline 收口后的游戏处理;返回 'ok' | 'hold' | 'silent-ok' | 'invalid'。 */
|
||
onGesture(g) {
|
||
// 长按(§4.3 游戏层裁定):阶段 3 起=引擎旁白加速(加速本身即反馈);
|
||
// 新手期=无效手势(回执 + 首次解释)。长按也走 floor「打断并执行」——
|
||
// 若砍断了叙事链,先交回 continue 等待点(零无声死锁,原则 4)
|
||
if (g === 'longpress') {
|
||
if (this.ladder.stage() >= 3) return 'hold'; // 等待点保留(不推进、不结清)
|
||
if (this._chainSpeaking) this._recoverInterrupted();
|
||
return 'invalid';
|
||
}
|
||
if (this.phase === 'MENU') return this._onMenuGesture(g);
|
||
if (this.phase === 'ASK') return this._onAskGesture(g);
|
||
if (this.searchArmed) return this._onSearchGesture(g);
|
||
return this._onStoryGesture(g);
|
||
}
|
||
|
||
stateName() {
|
||
if (this.phase === 'MENU') return 'menu';
|
||
if (this.phase === 'ASK') return 'ask';
|
||
if (this.searchArmed) return 'search';
|
||
if (this.choiceArmed && !this.floor.speaking) return 'choice';
|
||
if (this.floor.speaking || this._seqBusy) return 'story';
|
||
if (this.phase === 'ENDING') return 'settle';
|
||
return 'continue';
|
||
}
|
||
|
||
invalidHint(state, g) {
|
||
if (state === 'locked') return INVALID_LINES[`locked:${g}`] || INVALID_LINES.generic;
|
||
if (g === 'left' || g === 'right') {
|
||
if (state === 'menu') return null; // 菜单内左右滑是合法操作(换项)
|
||
if (state === 'search') return INVALID_LINES['search:swipe'];
|
||
return INVALID_LINES.nosel;
|
||
}
|
||
if (g === 'doubletap') return null; // 阶段 3 已解锁才会到这里:重听,无需解释
|
||
if (state === 'menu') return INVALID_LINES.menuHelp;
|
||
return INVALID_LINES.generic;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:ASK
|
||
|
||
_onAskGesture(g) {
|
||
switch (g) {
|
||
case 'tap':
|
||
this._resume(this.saveStash);
|
||
return 'ok';
|
||
case 'right':
|
||
this.newRun();
|
||
return 'ok';
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:DIVE 搜索等待
|
||
|
||
_onSearchGesture(g) {
|
||
switch (g) {
|
||
case 'tap':
|
||
this._firePing(); // 单击=发一枚声呐脉冲(宪法 §5.1「检查」义;恒出 ping)
|
||
return 'silent-ok'; // 声呐脉冲即内容音,省 ok 回执(§4.2)
|
||
case 'doubletap': {
|
||
// 阶段 3 解锁的重听(17 号 §3):重听当前等待点完整句(「再说一遍:」+
|
||
// 「单击,发一枚脉冲。」);废除旧「双击重听回波」——再单击=再发脉冲
|
||
const wp = this.floor.waitPoint;
|
||
if (wp) { wp.requestReplay(); return 'hold'; }
|
||
return 'invalid';
|
||
}
|
||
case 'up':
|
||
this._upStatus();
|
||
return 'ok';
|
||
case 'down':
|
||
this.openMenu();
|
||
return 'ok';
|
||
default:
|
||
return 'invalid'; // 左/右滑:invalid + 「点一下,发脉冲。」(§4 矩阵)
|
||
}
|
||
}
|
||
|
||
/** 单击=发脉冲:搜索等待 → 回波链(脉冲 → 静默一拍 → 回波 → 解读)。 */
|
||
_firePing() {
|
||
if (!this.searchArmed) return;
|
||
this.searchArmed = false;
|
||
// 第一次脉冲成功 = 第一个小目标 → 解锁上滑(§4.3 阶梯阶段 1),
|
||
// 教学句挂到本条回波链尾(先说一句,马上就能用)
|
||
if (!this.goalMet) {
|
||
this.goalMet = true;
|
||
const got = this.ladder.onGoal();
|
||
if (got) this._teachQueue = this.ladder.teachLine(got); // 已带 voice/ 前缀(novice.js 修复后语义)
|
||
}
|
||
this.playNode(`sv${this.diveN}_echo`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:INTRO / DIVE 链 / COMMS / SURFACE / ENDING
|
||
|
||
_onStoryGesture(g) {
|
||
// ENDING 态 nodeId=结局 id(e1..e4),不在 NODES 表里——结算态手势
|
||
// (下滑=菜单 / 单击=新救援 / 双击=重听结局链)不依赖节点,直接放行
|
||
const node = NODES[this.nodeId] || null;
|
||
if (!node && this.phase !== 'ENDING') return 'invalid';
|
||
|
||
// 「游戏在说」= SPEAKING / 声呐步 BUSY / 被滑动打断的链(快照;tap 已被
|
||
// floor 按跳句收口,到不了这里)
|
||
if (this.floor.speaking || this._seqBusy || this._chainSpeaking) {
|
||
switch (g) {
|
||
case 'left':
|
||
case 'right': {
|
||
// 抉择点缓存(§4 矩阵):旁白链中滑动 → 缓存 pendingChoice + 跳去
|
||
// 选项句(不当场盲选);选项句播完才生效(V1 零插播)。非抉择点:
|
||
// 打断已成事实,交回 continue 等待点恢复可玩(invalid + 解释句)
|
||
const pt = this._swipePromptTarget(node);
|
||
if (pt) {
|
||
this.pendingChoice = g;
|
||
this.playNode(pt);
|
||
return 'ok';
|
||
}
|
||
this._recoverInterrupted();
|
||
return 'invalid';
|
||
}
|
||
case 'up':
|
||
this._upStatus();
|
||
return 'ok';
|
||
case 'down':
|
||
this.openMenu();
|
||
return 'ok';
|
||
case 'doubletap':
|
||
if (this.phase === 'ENDING') { this._playEndingChain(); return 'ok'; }
|
||
this.playNode(this.nodeId); // 重听当前节点(阶段 3;复播尾句可即掐)
|
||
return 'ok';
|
||
default:
|
||
this._recoverInterrupted();
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
// ---- 等待态(continue / choice / settle)
|
||
switch (g) {
|
||
case 'tap': {
|
||
const wp = this.floor.waitPoint;
|
||
if (wp && wp.conf.id === 'choice') {
|
||
// 等你选择空点 = 主动再听选项:「再说一遍:」+ 完整 pN(原则 9)
|
||
wp.requestReplay();
|
||
return 'hold';
|
||
}
|
||
if (wp && wp.conf.id === 'intro') {
|
||
this._afterFirstTap(); // 开场第一下单击 → 旁白 + 「轮到你了」教学
|
||
return 'ok';
|
||
}
|
||
if (this.phase === 'ENDING') { this.newRun(); return 'ok'; } // 结算单击=新救援
|
||
if (node.next != null) { this.advance(node.next); return 'ok'; }
|
||
return 'invalid';
|
||
}
|
||
case 'left':
|
||
case 'right':
|
||
if (this.choiceArmed) { this.choose(g); return 'ok'; }
|
||
return 'invalid'; // inv_nosel:现在不用选
|
||
case 'up':
|
||
this._upStatus();
|
||
return 'ok';
|
||
case 'down':
|
||
this.openMenu();
|
||
return 'ok';
|
||
case 'doubletap': {
|
||
const wp = this.floor.waitPoint;
|
||
if (wp) { wp.requestReplay(); return 'hold'; } // 阶段 3:重听等待点完整句
|
||
if (this.phase === 'ENDING') { this._playEndingChain(); return 'ok'; }
|
||
return 'invalid';
|
||
}
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 旁白链中的滑动打断已发生(floor=BUSY、链已死):无效手势不能把游戏晾在
|
||
* 无等待点的 BUSY(无声死锁,原则 4)——交回 continue 等待点,链末单击继续。
|
||
*/
|
||
_recoverInterrupted() {
|
||
const node = NODES[this.nodeId] || null;
|
||
this._chainSpeaking = false;
|
||
if (node) this._yieldTurn(this._continueWp(node));
|
||
}
|
||
|
||
/**
|
||
* 旁白链中的滑动是否落在抉择点上:本节点是抉择节点,或 next 直入抉择节点。
|
||
* 返回抉择节点 id(playNode 跳去选项句)或 null。
|
||
*/
|
||
_swipePromptTarget(node) {
|
||
if (this._isPromptNode(node)) return this.nodeId;
|
||
if (node.next != null && !this._isTransition(node.next)) {
|
||
const nx = NODES[node.next];
|
||
if (nx && this._isPromptNode(nx)) return node.next;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:MENU(00 号 §5.4:左右滑换项 + 单击确认)
|
||
|
||
_onMenuGesture(g) {
|
||
switch (g) {
|
||
case 'tap':
|
||
this._execMenuItem(MENU_ITEMS[this.menuIdx].id);
|
||
return 'ok';
|
||
case 'left':
|
||
this._restartArmed = false;
|
||
this.menuIdx = (this.menuIdx + MENU_ITEMS.length - 1) % MENU_ITEMS.length;
|
||
this._menuAnnounce();
|
||
return 'hold';
|
||
case 'right':
|
||
this._restartArmed = false;
|
||
this.menuIdx = (this.menuIdx + 1) % MENU_ITEMS.length;
|
||
this._menuAnnounce();
|
||
return 'hold';
|
||
case 'up':
|
||
// 重听菜单说明 + 复述当前项(玩家一动游戏就停:新链顶掉旧报名)
|
||
this.floor.speak(['voice/menu_help', MENU_ITEMS[this.menuIdx].voice], {
|
||
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
|
||
});
|
||
return 'hold';
|
||
case 'down':
|
||
this.closeMenu();
|
||
return 'ok';
|
||
case 'doubletap': {
|
||
const wp = this.floor.waitPoint;
|
||
if (wp) { wp.requestReplay(); return 'hold'; } // 阶段 3:重听菜单说明
|
||
return 'invalid';
|
||
}
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
_menuAnnounce() {
|
||
this.floor.speak([MENU_ITEMS[this.menuIdx].voice], {
|
||
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
|
||
});
|
||
}
|
||
|
||
_execMenuItem(id) {
|
||
if (id === 'resume') {
|
||
this.closeMenu();
|
||
return;
|
||
}
|
||
if (id === 'restart') {
|
||
// 破坏性操作二次确认(00 号 §5.4 / 26 号原则 6)
|
||
if (!this._restartArmed) {
|
||
this._restartArmed = true;
|
||
this.floor.speak(['voice/sys_menu_restart_confirm'], {
|
||
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
|
||
});
|
||
return;
|
||
}
|
||
this._restartArmed = false;
|
||
this.newRun();
|
||
return;
|
||
}
|
||
// lobby:确认句播出且存档落盘后跳转(§2.2「确认后跳转前播」)
|
||
this.leaving = true;
|
||
this._save();
|
||
this.floor.speak(['voice/sys_menu_lobby'], {
|
||
onDone: () => {
|
||
try {
|
||
if (typeof window !== 'undefined' && window.location) window.location.href = '../';
|
||
} catch (_) { /* 无头环境无 location:留档 leaving 标记 */ }
|
||
},
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 菜单开/关
|
||
|
||
openMenu() {
|
||
this.returnPhase = this.phase;
|
||
this.returnNode = this.nodeId;
|
||
this.returnSearch = this.searchArmed;
|
||
this.phase = 'MENU';
|
||
this.menuIdx = 0;
|
||
this._restartArmed = false;
|
||
this.searchArmed = false;
|
||
this._stopDangerLoop();
|
||
this.seqToken++;
|
||
this._seqBusy = false;
|
||
this.floor.interrupt();
|
||
this._sonarQueue.length = 0;
|
||
this.audio.play('sfx/ui_menu');
|
||
this.audio.play('bgm/deck', { fadeMs: 500, volume: 0.4 }); // MENU 低响度淡入
|
||
this.floor.speak(['voice/sys_menu', MENU_ITEMS[0].voice], {
|
||
onDone: () => this._yieldTurn(this._menuWp()),
|
||
});
|
||
}
|
||
|
||
closeMenu() {
|
||
this._restartArmed = false;
|
||
const back = this.returnPhase || 'INTRO';
|
||
this.phase = back;
|
||
this.seqToken++;
|
||
this._seqBusy = false;
|
||
this.floor.interrupt();
|
||
this.audio.play('sfx/ui_confirm');
|
||
if (back === 'DIVE') this.audio.play('bgm/dive', { fadeMs: 800 });
|
||
else if (back === 'COMMS') this.audio.play('bgm/comms', { fadeMs: 800 });
|
||
else if (back === 'ENDING') this.audio.play('bgm/ending', { fadeMs: 800 });
|
||
else this.audio.play('bgm/deck', { fadeMs: 800 });
|
||
this._updateBreath();
|
||
if (back === 'DIVE' && this.returnSearch) {
|
||
this._enterSearch(); // 搜索等待恢复(amb+余音等待声 + 等待点重挂)
|
||
} else if (back === 'ENDING') {
|
||
this.awaitingRestart = false;
|
||
this._playEndingChain(); // 重播结局链(局面复述)
|
||
} else if (back === 'ASK') {
|
||
this.floor.speak(['voice/sys_continue_ask'], {
|
||
onDone: () => this._yieldTurn(this._askWp()),
|
||
});
|
||
} else {
|
||
this.playNode(this.returnNode || 'b_goal'); // INTRO/COMMS/SURFACE:重播当前节点
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 救援流转
|
||
|
||
/**
|
||
* 新救援:重置跨潜标量(结局收集跨局保留),回 INTRO。重开不再念开场教学
|
||
* (做对过的事不教第二遍,原则 11):sys_new_dive + 目标句 + 出发句。
|
||
*/
|
||
newRun() {
|
||
this.runs += 1;
|
||
this.oxy = 12;
|
||
this.supply = false;
|
||
this.saved = { zhou: 0, bai: 0, chen: 0, zheng: 0, gao: 0 };
|
||
this.stayer = null;
|
||
this.flags = {
|
||
knowsPipe: false, hasLog: false, valve: false,
|
||
spareGift: false, gaoPeace: false, bell: false,
|
||
};
|
||
this.bond = { zhou: 0, bai: 0 };
|
||
this.diveN = 0;
|
||
this.diveForm = null;
|
||
this.endingId = null;
|
||
this.lastAnnouncedTier = null;
|
||
this.leaving = false;
|
||
this.saveStash = null;
|
||
this._dryPending = false;
|
||
this._dryAQueued = false;
|
||
this._dryBQueued = false;
|
||
this.choiceArmed = false; // 清掉上一局的抉择武装(radio 实现修复 #7)
|
||
this.pendingChoice = null;
|
||
this.searchArmed = false;
|
||
this.awaitingRestart = false;
|
||
this._teachQueue = null;
|
||
this._stopDangerLoop();
|
||
this.seqToken++;
|
||
this._seqBusy = false;
|
||
this.floor.interrupt();
|
||
this.audio.stopVoice();
|
||
this._stopAllLoops();
|
||
this._sonarQueue.length = 0;
|
||
this.phase = 'INTRO';
|
||
this.nodeId = 'b_open';
|
||
this.audio.play('bgm/deck');
|
||
this._startLoop('sfx/amb_deck', 0.6);
|
||
this._save();
|
||
this.floor.speak(
|
||
['voice/sys_new_dive', 'voice/b_open', 'voice/b_goal', 'voice/b_go'],
|
||
{ onDone: () => this._yieldTurn(this._continueWp(NODES.b_go)) },
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 进潜:扣固定消耗(每次下潜扣 3;D4 双载额外 +1,§4.3),BGM crossfade 进
|
||
* dive 曲,amb_deep 循环垫起,播下潜链(auto)→ 链末武装搜索等待。
|
||
* noCharge:断点续玩恢复用(存档 oxy 已是扣后值,不二次扣账)。
|
||
*/
|
||
enterDive(n, opts = {}) {
|
||
this.diveN = n;
|
||
if (!opts.noCharge) {
|
||
const cost = 3 + (n === 4 && this.diveForm === 'double' ? 1 : 0);
|
||
this.oxy = Math.max(0, this.oxy - cost);
|
||
}
|
||
this.phase = 'DIVE';
|
||
this.searchArmed = false;
|
||
this._stopLoop('sfx/amb_deck');
|
||
this.audio.play('bgm/dive', { fadeMs: 800 });
|
||
this._startLoop('sfx/amb_deep', 0.6);
|
||
this._updateBreath();
|
||
this.nodeId = `dv${n}_down`; // 存档 node 先行(写入时机:每潜 DIVE 进入)
|
||
this._save();
|
||
this.playNode(`dv${n}_down`);
|
||
}
|
||
|
||
/** 搜索等待武装(下潜链末):交出话轮(turn 提示音 + 等待声)。 */
|
||
_enterSearch() {
|
||
this.searchArmed = true;
|
||
this.phase = 'DIVE';
|
||
this._yieldTurn(this._pingWp());
|
||
}
|
||
|
||
/** 接通 COMMS(z1_a/bai_a/chen_a/gao_a 节点 enterComms 标记触发)。 */
|
||
_enterComms() {
|
||
this.phase = 'COMMS';
|
||
this.searchArmed = false;
|
||
this.audio.play('bgm/comms', { fadeMs: 600 });
|
||
this._updateBreath();
|
||
}
|
||
|
||
/**
|
||
* 上浮结算(自动链 ≤15s):出水 sfx → d*_surf →(D2 后)d2_supply+oxy_gain
|
||
* →(跨档才播)档位句 → @surfaceNext 分流。补给/氧气门/干涸账目全部在入口
|
||
* 一次性结清(supply 幂等守卫、resume 不重复发瓶)。
|
||
*/
|
||
enterSurface(n) {
|
||
this.diveN = n;
|
||
this.phase = 'SURFACE';
|
||
this.searchArmed = false;
|
||
this._stopLoop('sfx/amb_deep');
|
||
this._startLoop('sfx/amb_deck', 0.6);
|
||
this._updateBreath();
|
||
this.audio.play('bgm/deck', { fadeMs: 800 });
|
||
this.audio.play('sfx/surf_splash');
|
||
this.audio.play('sfx/ascent_chime');
|
||
if (n === 1) this.saved.zhou = 1; // 教学潜保底救上(恒活)
|
||
if (n === 2) this.saved.bai = 1; // 机舱潜恒可成(预算数学保证,§4.4)
|
||
if (n === 2 && !this.supply) {
|
||
this.supply = true; // 应急瓶 +3:全局唯一一次
|
||
this.oxy += 3;
|
||
}
|
||
if (this._dryPending) {
|
||
this._dryPending = false;
|
||
this._dryBQueued = true; // 干涸链归因句(上浮结算内,首句 critical)
|
||
}
|
||
this._save(); // 写入时机:SURFACE 进入
|
||
this.playNode(`s${n}_surf`);
|
||
}
|
||
|
||
/** 结算链末分流:D1/D2 → 下一潜;D3 → 氧气门;D4 → 签收。 */
|
||
_surfaceNext() {
|
||
if (this.diveN <= 2) this.enterDive(this.diveN + 1);
|
||
else if (this.diveN === 3) this._oxygenGate();
|
||
else this.playNode('g9_in');
|
||
}
|
||
|
||
/**
|
||
* 第四潜氧气门(§4.3 三形态):oxy≥4 → 双载(带船长+留守者,p7 快慢浮);
|
||
* oxy=3 → 单载(p7 按 stayer 选 p7_chen|p7_zheng);oxy≤2 → 终局取消链。
|
||
*/
|
||
_oxygenGate() {
|
||
if (this.oxy >= 4) {
|
||
this.diveForm = 'double';
|
||
this.enterDive(4);
|
||
} else if (this.oxy === 3) {
|
||
this.diveForm = 'single';
|
||
this.enterDive(4);
|
||
} else {
|
||
this.diveForm = 'cancel';
|
||
this._save();
|
||
this.playNode('c_none'); // 首句 critical:结算死窗
|
||
}
|
||
}
|
||
|
||
/** ENDING:savedCount × p9 四选一 + 尾声回响链,结算态等待单击重开。 */
|
||
enterEnding(forced) {
|
||
const savedCount = ['zhou', 'bai', 'chen', 'zheng', 'gao']
|
||
.filter((k) => this.saved[k]).length;
|
||
const ending = forced
|
||
|| (savedCount === 5 ? (this.flags.bell ? 'e1' : 'e2') : (this.flags.bell ? 'e3' : 'e4'));
|
||
this.endingId = ending;
|
||
this.endings[ending] = true;
|
||
this.phase = 'ENDING';
|
||
this.nodeId = ending;
|
||
this.awaitingRestart = false;
|
||
this._stopLoop('sfx/amb_deep');
|
||
this._startLoop('sfx/amb_deck', 0.5);
|
||
this._stopDangerLoop();
|
||
this.audio.play('bgm/ending', { fadeMs: 1500 });
|
||
this._updateBreath(); // 收队即停呼吸垫
|
||
// 一局结局 → 手势阶梯 onGameDone(第 1 局解锁下滑=菜单、第 3 局解锁双击=重听)
|
||
const got = this.ladder.onGameDone();
|
||
if (got) {
|
||
this._teachQueue = this.ladder.teachLine(got); // 已带 voice/ 前缀(novice.js 修复后语义)
|
||
if (this.ladder.stage() >= 3) this.audio.setRateBoostEnabled(true); // 长按=旁白加速
|
||
}
|
||
this._save(); // 写入时机:ENDING 进入
|
||
this._playEndingChain();
|
||
}
|
||
|
||
/** 尾声回响(P2):逐人按好感/生死双线点名 + 沟底按 bell 二选一。 */
|
||
_epChain() {
|
||
const ids = [];
|
||
ids.push(this.bond.zhou ? 'voice/ep_zhou_ok' : 'voice/ep_zhou_no');
|
||
ids.push(this.bond.bai ? 'voice/ep_bai_ok' : 'voice/ep_bai_no');
|
||
ids.push(this.saved.chen ? 'voice/ep_chen_ok' : 'voice/ep_chen_lost');
|
||
ids.push(this.saved.zheng ? 'voice/ep_zheng_ok' : 'voice/ep_zheng_lost');
|
||
if (!this.saved.gao) ids.push('voice/ep_gao_lost');
|
||
else ids.push(this.flags.gaoPeace ? 'voice/ep_gao_peace' : 'voice/ep_gao_up');
|
||
ids.push(this.flags.bell ? 'voice/ep_trench_bell' : 'voice/ep_trench_deep');
|
||
return ids;
|
||
}
|
||
|
||
_playEndingChain() {
|
||
const def = ENDINGS[this.endingId] || ENDINGS.e4;
|
||
const rest = [...def.voices.slice(1), 'voice/ep_lead', ...this._epChain()];
|
||
const teach = this._pullTeach();
|
||
if (teach) rest.push(teach);
|
||
// 首句 critical:结局链入口结算死窗(17 号 §8;tap/swipe=busy 不截链)
|
||
this.floor.speak([def.voices[0]], {
|
||
critical: true,
|
||
onDone: () => {
|
||
this.floor.speak(rest, {
|
||
onDone: () => {
|
||
this.awaitingRestart = true;
|
||
this._yieldTurn(this._settleWp());
|
||
},
|
||
});
|
||
},
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 存档续玩
|
||
|
||
_askContinue(save) {
|
||
this.saveStash = save;
|
||
// 预载 runs/endings:ASK 右滑=新救援走 newRun(+1),与结算重开同口径
|
||
this.runs = (save && save.runs) || 0;
|
||
this.endings = { ...((save && save.endings) || {}) };
|
||
this.phase = 'ASK';
|
||
this.nodeId = save && save.node ? save.node : 'b_goal';
|
||
markHintSaid('voice/sys_continue_ask'); // 台账:静默提示不逐字复述刚说过的询问
|
||
this.floor.speak(['voice/sys_continue_ask'], {
|
||
onDone: () => this._yieldTurn(this._askWp()),
|
||
});
|
||
}
|
||
|
||
/** 断点续玩:恢复 phase+node+全部标量并重播当前节点链(一句话局面复述,L5)。 */
|
||
_resume(s) {
|
||
if (!s) { this.newRun(); return; }
|
||
this.saveStash = null;
|
||
this.oxy = s.oxy | 0;
|
||
this.supply = !!s.supply;
|
||
this.saved = { zhou: 0, bai: 0, chen: 0, zheng: 0, gao: 0, ...(s.saved || {}) };
|
||
this.stayer = s.stayer || null;
|
||
this.flags = {
|
||
knowsPipe: false, hasLog: false, valve: false,
|
||
spareGift: false, gaoPeace: false, bell: false, ...(s.flags || {}),
|
||
};
|
||
this.bond = { zhou: 0, bai: 0, ...(s.bond || {}) };
|
||
this.endings = { ...(s.endings || {}) };
|
||
this.runs = s.runs || 1;
|
||
this.diveN = s.dive || diveOfNode(s.node);
|
||
this.diveForm = s.form || null;
|
||
this._dryPending = !!s.dryPending;
|
||
this.lastAnnouncedTier = tierOf(this.oxy); // 恢复不重播跨档句(账目已播过)
|
||
this.leaving = false;
|
||
this.goalMet = this.ladder.stage() >= 1; // 阶梯跨局持久,恢复后不再触发教学
|
||
this.seqToken++;
|
||
this._seqBusy = false;
|
||
this.floor.interrupt();
|
||
this.audio.stopVoice();
|
||
this._resumeGuard = true; // F4:恢复链首句 critical,播完才武装跳句
|
||
const phase = s.phase;
|
||
if (phase === 'DIVE') {
|
||
this.enterDive(this.diveN >= 1 ? this.diveN : 1, { noCharge: true });
|
||
} else if (phase === 'SURFACE') {
|
||
this.enterSurface(this.diveN >= 1 && this.diveN <= 4 ? this.diveN : 4);
|
||
} else if (phase === 'ENDING') {
|
||
this.enterEnding(s.node && ENDINGS[s.node] ? s.node : undefined);
|
||
this._resumeGuard = false; // 结局链首句本身 critical,不叠加
|
||
} else {
|
||
this.phase = 'INTRO';
|
||
this.audio.play('bgm/deck');
|
||
this._startLoop('sfx/amb_deck', 0.6);
|
||
if (phase === 'COMMS') {
|
||
this.phase = 'COMMS';
|
||
this.audio.play('bgm/comms');
|
||
this._startLoop('sfx/amb_deep', 0.6);
|
||
}
|
||
this._updateBreath();
|
||
this.playNode(NODES[s.node] ? s.node : 'b_goal');
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 节点链驱动
|
||
|
||
playNode(id) {
|
||
const node = NODES[id];
|
||
if (!node) {
|
||
console.error('[AbyssGame] 未知节点:', id);
|
||
return;
|
||
}
|
||
this.nodeId = id;
|
||
this.choiceArmed = false;
|
||
this.awaitingRestart = false;
|
||
this._stopDangerLoop();
|
||
this.seqToken++; // 作废在途声呐步序列
|
||
this._seqBusy = false;
|
||
this.floor.interrupt(); // 干净起步:杀旧链、摘旧等待点、停等待声
|
||
if (node.enterComms) this._enterComms();
|
||
if (node.sfx) this.audio.play(node.sfx); // 非信息类一次性垫音(link_beep 等)
|
||
this._runSteps(node.pre || [], () => this._speakNodeVoices(id, node));
|
||
}
|
||
|
||
/** 节点内条件选播(radio w1_say 先例:同一节点 id,差分选组,树形不变)。 */
|
||
_dynVoices(node) {
|
||
switch (node.dyn) {
|
||
case 'd2pry':
|
||
return [this.flags.knowsPipe ? 'voice/d2_pry_ok' : 'voice/d2_pry_slow'];
|
||
case 'dv4down':
|
||
// R2-F4:双载形态补播扣账句(D4 双载起播 3+1=4 格,不再无声扣款)
|
||
return this.diveForm === 'double'
|
||
? ['voice/dv4_down', 'voice/dv4_dual']
|
||
: ['voice/dv4_down'];
|
||
case 'gaoconfess':
|
||
return [this.flags.hasLog ? 'voice/gao_confess' : 'voice/gao_c'];
|
||
case 'stayleave':
|
||
return [this.stayer === 'chen' ? 'voice/chen_last' : 'voice/d4_stay_lost'];
|
||
case 'p7':
|
||
case 'tier':
|
||
return [];
|
||
default:
|
||
return [...node.voices];
|
||
}
|
||
}
|
||
|
||
/** p7 三形态提示句(双载 p7 / 单载按 stayer)。 */
|
||
_promptOf(node) {
|
||
if (node.dyn === 'p7') {
|
||
if (this.diveForm === 'double') return 'voice/p7';
|
||
return this.stayer === 'chen' ? 'voice/p7_chen' : 'voice/p7_zheng';
|
||
}
|
||
return node.prompt;
|
||
}
|
||
|
||
/** 抉择目标解析(氧气门形态决定 p7 分叉;跨潜状态只有标量,无组合节点)。 */
|
||
_targetsOf(node) {
|
||
if (node.dyn === 'p7') {
|
||
if (this.diveForm === 'double') return { left: 'd4_slow', right: 'd4_fast' };
|
||
return {
|
||
left: 'd4_pick_gao',
|
||
right: this.stayer === 'chen' ? 'd4_pick_chen' : 'd4_pick_zheng',
|
||
};
|
||
}
|
||
return { left: node.left, right: node.right };
|
||
}
|
||
|
||
_isPromptNode(node) {
|
||
return !!(node && (node.prompt != null || node.dyn === 'p7'));
|
||
}
|
||
|
||
/**
|
||
* 尾句保护(§4 矩阵「锚句/档位句不可跳 → busy」,仅限 auto 过场链):
|
||
* auto 链尾在「next 是 @x 过场」时恒 critical——下潜链尾 dv1_teach(教学句)、
|
||
* s_tier 档位句、p9 结果链尾、取消链尾都在此类。
|
||
*
|
||
* 偏差(施工单):非 auto 叙事节点的尾句**不**做首播 critical——floor 的
|
||
* critical 是整链一刀切,会把「滑动打断并执行 / 上滑我在哪」一并挡成 busy
|
||
* (26 号 §4.1 宪法)。叙事尾句跳过的代价只是提前进入等待点,由等待点
|
||
* 三档提示兜底复述;「先听完再选」的盲玩公平由 pendingChoice 承接。
|
||
*/
|
||
_tailLocked(id, node) {
|
||
if (!node.auto) return false;
|
||
if (node.next == null || this._isTransition(node.next)) return true;
|
||
const nx = NODES[node.next];
|
||
return !!(nx && this._isPromptNode(nx));
|
||
}
|
||
|
||
/**
|
||
* 语音阶段:按 critical 拆分段落顺序播放(floor 语义 = 链级 critical,按句
|
||
* 保护靠拆链实现)。段序:dry_a / dry_b(结算死窗,critical)→ 首句(lockFirst
|
||
* / 续玩恢复锁)→ 正文 → 尾句(锚句/档位句)→ 解锁教学句(阶梯)。播完接
|
||
* post 声呐步,再走 _onNodeSeqDone。
|
||
*/
|
||
_speakNodeVoices(id, node) {
|
||
const parts = []; // [{ ids, critical }]
|
||
if (this._dryAQueued) {
|
||
this._dryAQueued = false;
|
||
parts.push({ ids: ['voice/dry_a'], critical: true });
|
||
}
|
||
if (this._dryBQueued) {
|
||
this._dryBQueued = false;
|
||
parts.push({ ids: ['voice/dry_b'], critical: true });
|
||
}
|
||
let seq = this._dynVoices(node);
|
||
if (node.dyn === 'tier') {
|
||
// 档位节点:只在跨档时播分档整句(账目真实;sidefx 在此一次结清)
|
||
const tv = this._tierPending();
|
||
if (tv) {
|
||
this.lastAnnouncedTier = tierOf(this.oxy);
|
||
if (this.lastAnnouncedTier === 'crit') vibrate('warning'); // G2:crit 附 warning 震
|
||
this._updateBreath();
|
||
seq = [...seq, tv];
|
||
}
|
||
}
|
||
const teach = this._pullTeach();
|
||
if (teach) seq = [...seq, teach];
|
||
|
||
if (this._isPromptNode(node)) {
|
||
// 抉择节点:整条链就是选项句,整句 critical(tap=busy 不可跳;旁白期滑动
|
||
// 已缓存 pendingChoice,播完才生效——盲玩公平)
|
||
const prompt = this._promptOf(node);
|
||
if (prompt) {
|
||
markHintSaid(prompt); // 台账:选项句 30s 内不做逐字被动复述(原则 9)
|
||
parts.push({ ids: [prompt], critical: true });
|
||
}
|
||
} else if (seq.length) {
|
||
if (node.lockFirst || this._resumeGuard) {
|
||
this._resumeGuard = false;
|
||
parts.push({ ids: [seq[0]], critical: true });
|
||
seq = seq.slice(1);
|
||
}
|
||
if (seq.length && this._tailLocked(id, node)) {
|
||
if (seq.length > 1) parts.push({ ids: seq.slice(0, -1) });
|
||
parts.push({ ids: [seq[seq.length - 1]], critical: true });
|
||
} else if (seq.length) {
|
||
parts.push({ ids: seq });
|
||
}
|
||
}
|
||
|
||
const post = node.post || [];
|
||
const runParts = (k) => {
|
||
if (k >= parts.length) {
|
||
this._runSteps(post, () => this._onNodeSeqDone(node));
|
||
return;
|
||
}
|
||
this.floor.speak(parts[k].ids, {
|
||
critical: !!parts[k].critical,
|
||
onDone: () => runParts(k + 1),
|
||
});
|
||
};
|
||
runParts(0);
|
||
}
|
||
|
||
/** 手势解锁教学句出队(挂在当前链尾,先说一句马上用)。 */
|
||
_pullTeach() {
|
||
if (this._teachQueue) {
|
||
const t = this._teachQueue;
|
||
this._teachQueue = null;
|
||
return t;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** '@x' 虚拟目标 = 过场转移(自动衔接)。 */
|
||
_isTransition(target) {
|
||
return typeof target === 'string' && target.startsWith('@');
|
||
}
|
||
|
||
/**
|
||
* 节点链自然播完的公共收尾:抉择武装(含 pendingChoice 生效)/ 自动过场 /
|
||
* 链末等待(交出话轮)。自动衔接仅限节点表标注 auto 的段落;@x 过场目标
|
||
* 本身由链末单击触发;其余叙事节点链末交出 continue 等待点。
|
||
*/
|
||
_onNodeSeqDone(node) {
|
||
this._drainSonar(); // 节点链自然收尾:voice idle,补发排队的信息音(零重叠)
|
||
if (this._isPromptNode(node)) {
|
||
this._armChoice(node);
|
||
return;
|
||
}
|
||
if (node.next == null) return;
|
||
if (node.auto) {
|
||
this.advance(node.next);
|
||
return;
|
||
}
|
||
this._yieldTurn(this._continueWp(node));
|
||
}
|
||
|
||
/** 抉择武装:W2 契约(播完当刻即可选,零插播);旁白期缓存的滑动此刻生效。 */
|
||
_armChoice(node) {
|
||
this.choiceArmed = true;
|
||
if (this.pendingChoice) {
|
||
const g = this.pendingChoice;
|
||
this.pendingChoice = null;
|
||
this.choose(g);
|
||
return;
|
||
}
|
||
this._yieldTurn(this._choiceWp(node));
|
||
}
|
||
|
||
/** 链完转移:'@x' 虚拟目标 = 过场自动衔接;普通节点 = 播下一节点。 */
|
||
advance(target) {
|
||
if (target == null) return;
|
||
switch (target) {
|
||
case '@dive': this.enterDive(1); return;
|
||
case '@search': this._enterSearch(); return;
|
||
case '@surface1': this.enterSurface(1); return;
|
||
case '@surface2': this.enterSurface(2); return;
|
||
case '@surface3': this.enterSurface(3); return;
|
||
case '@surface4': this.enterSurface(4); return;
|
||
case '@surfaceNext': this._surfaceNext(); return;
|
||
case '@signoff': this.playNode('g9_in'); return;
|
||
case '@ending': this.enterEnding(); return;
|
||
default: this.playNode(target);
|
||
}
|
||
}
|
||
|
||
/** 抉择:计分(§4.2)→ 存档 → 进结果节点。p9 左在 oxy<1 时封锁走 g9_dry。 */
|
||
choose(g) {
|
||
const node = NODES[this.nodeId];
|
||
if (!node || !this._isPromptNode(node) || !this.choiceArmed) return;
|
||
this.choiceArmed = false;
|
||
this.pendingChoice = null;
|
||
this.audio.play('sfx/ui_confirm');
|
||
const targets = this._targetsOf(node);
|
||
let target = g === 'left' ? targets.left : targets.right;
|
||
if (this.nodeId === 'p9' && g === 'left' && this.oxy < 1) {
|
||
target = 'g9_dry'; // 左路封锁:播出后自动走右,不摆假选项(§3.2)
|
||
}
|
||
this._applyChoice(this.nodeId, g);
|
||
this._save(); // 写入时机:每个抉择结算后
|
||
this.advance(target);
|
||
}
|
||
|
||
/** 抉择计分表(§4.2:左=走心·多花气 / 右=克制·省气;floor 至 0 触发干涸链)。 */
|
||
_applyChoice(nodeId, g) {
|
||
const L = g === 'left';
|
||
switch (nodeId) {
|
||
case 'p1':
|
||
if (L) { this.flags.knowsPipe = true; this.bond.zhou = 1; this._spend(1); }
|
||
break;
|
||
case 'p2':
|
||
if (L) { this.flags.hasLog = true; this.bond.zhou = 1; this._spend(1); }
|
||
break;
|
||
case 'p3':
|
||
if (L) { this.bond.bai = 1; this._spend(this.flags.knowsPipe ? 0 : 1); }
|
||
break;
|
||
case 'p4':
|
||
if (L) { this.flags.valve = true; this.bond.bai = 1; this._spend(2); }
|
||
break;
|
||
case 'p5':
|
||
// 唯一无气差的双义抉择:stayer 决定 D4 单载差分
|
||
if (L) { this.saved.chen = 1; this.stayer = 'zheng'; }
|
||
else { this.saved.zheng = 1; this.stayer = 'chen'; }
|
||
break;
|
||
case 'p6':
|
||
if (L) { this.flags.spareGift = true; this._spend(2); }
|
||
break;
|
||
case 'p7':
|
||
if (this.diveForm === 'double') {
|
||
this.saved.gao = 1;
|
||
if (this.stayer) this.saved[this.stayer] = 1;
|
||
if (L) this._spend(2);
|
||
} else if (L) {
|
||
this.saved.gao = 1;
|
||
} else if (this.stayer) {
|
||
this.saved[this.stayer] = 1;
|
||
}
|
||
break;
|
||
case 'p8':
|
||
if (L) this.flags.gaoPeace = true;
|
||
break;
|
||
case 'p9':
|
||
if (L && this.oxy >= 1) this.flags.bell = true; // 左路需 oxy≥1(choose 已拦)
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 氧气支出(§4.3 宽限):mid-dive 扣至负数一律 floor 到 0,不中断当前救援
|
||
* (人已挂上吊索照常带上);从正值 floor 到 0 时触发干涸链。
|
||
*/
|
||
_spend(cost) {
|
||
if (cost <= 0) return;
|
||
const before = this.oxy;
|
||
this.oxy = Math.max(0, this.oxy - cost);
|
||
this._updateBreath();
|
||
if (before > 0 && this.oxy === 0) {
|
||
this._dryAQueued = true; // dry_a 挂下一结果节点链首(critical)
|
||
this._dryPending = true; // dry_b 挂上浮结算链首(critical)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 档位 / 状态链(我在哪)
|
||
|
||
/** 档位句选播(R2-F5):crit 档在水面场景(SURFACE/ENDING/INTRO)用「一潜都
|
||
* 下不去了」变体——人已出水再说「必须上浮」是错场景措辞;水下保留原句。 */
|
||
_tierVoice(tier) {
|
||
if (tier === 'crit'
|
||
&& (this.phase === 'SURFACE' || this.phase === 'ENDING' || this.phase === 'INTRO')) {
|
||
return TIER_CRIT_DECK;
|
||
}
|
||
return TIER_VOICES[tier];
|
||
}
|
||
|
||
/** 跨档待播档位句(无跨档返回 null;副作用由 playNode('s_tier') 结清)。 */
|
||
_tierPending() {
|
||
const tier = tierOf(this.oxy);
|
||
if (tier === this.lastAnnouncedTier) return null;
|
||
return this._tierVoice(tier);
|
||
}
|
||
|
||
/**
|
||
* 上滑「我在哪」状态链(17 号 §3/§9:潜次 → 已救人数 → 氧档 → 一个动作;
|
||
* G2:crit 附 warning 震按档进入去重,同档复听不重复震)。
|
||
*/
|
||
_statusIds() {
|
||
const ids = [];
|
||
if (this.phase === 'ENDING') {
|
||
const def = ENDINGS[this.endingId] || ENDINGS.e4;
|
||
ids.push(def.voices[0]); // 结局主句 = 结局名播报(storyline P3-2 先例)
|
||
} else if (this.diveN >= 1 && this.diveN <= 4) {
|
||
ids.push(PROG_VOICES[this.diveN - 1]);
|
||
}
|
||
const savedCount = ['zhou', 'bai', 'chen', 'zheng', 'gao']
|
||
.filter((k) => this.saved[k]).length;
|
||
if (savedCount >= 1) ids.push(SAVED_VOICES[savedCount - 1]);
|
||
const tier = tierOf(this.oxy);
|
||
if (tier === 'crit' && !this._critWarned) { vibrate('warning'); this._critWarned = true; }
|
||
if (tier !== 'crit') this._critWarned = false;
|
||
ids.push(this._tierVoice(tier));
|
||
// 动作尾巴(原则 3:位置 → 目标 → 现在能做的一个动作)
|
||
if (this.searchArmed) ids.push('voice/sys_act_ping'); // 「点一下,发脉冲。」
|
||
else if (this.choiceArmed) ids.push('voice/status_choice'); // 「正在抉择。左右滑。」
|
||
else if (this.phase === 'ENDING') ids.push('voice/sys_hint_settle_full');
|
||
else ids.push('voice/sys_hint_continue_full');
|
||
return ids;
|
||
}
|
||
|
||
/**
|
||
* 上滑「我在哪」:状态链(radio 口径:不摘等待点、播完回原等待点——不重播
|
||
* 节点,状态句也不算被动重复);等待点不在(旁白被打断)则按来源重建。
|
||
* 状态句全量进台账(原则 9:刚答过的话,30s 内等待点被动提示自动降档)。
|
||
*/
|
||
/**
|
||
* 上滑「我在哪」:状态链(radio 口径:播完回原等待点类型——不重播节点,
|
||
* 状态句也不算被动重复)。interrupt 摘掉等待点:状态链期间静默 timer 清零,
|
||
* 不会出现「hint 在 SPEAKING 中被丢弃却污染台账」的引擎边界。
|
||
* 选项句(p*)整句 critical 不可被上滑打断,pendingChoice 缓存不受影响。
|
||
*/
|
||
_upStatus() {
|
||
const wasSearch = this.searchArmed;
|
||
const wasEnding = this.phase === 'ENDING';
|
||
const node = NODES[this.nodeId] || null;
|
||
this.seqToken++; // 作废在途声呐步序列
|
||
this._seqBusy = false;
|
||
this.floor.interrupt(); // 摘等待点 + 清静默计时(状态链期间零 fire)
|
||
const ids = this._statusIds();
|
||
for (const id of ids) markHintSaid(id); // 原则 9:刚答过的话,30s 内被动提示降档
|
||
this.floor.speak(ids, {
|
||
onDone: () => {
|
||
if (this.phase === 'MENU' || this.phase === 'ASK') return;
|
||
if (wasSearch) this._enterSearch(); // 搜索等待重挂(amb 静音)
|
||
else if (wasEnding) this._yieldTurn(this._settleWp()); // 结算:不再重播结局链
|
||
else if (node && this._isPromptNode(node)) this._yieldTurn(this._choiceWp(node));
|
||
else if (node) this._yieldTurn(this._continueWp(node)); // 旁白被打断:链末单击继续
|
||
},
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 等待点(§5 配置表)
|
||
|
||
/** 交出话轮的统一入口:搜索等待点静音合成等待声(深海 amb+余音替代,偏差记录)。 */
|
||
_yieldTurn(wp) {
|
||
this._chainSpeaking = false; // 话轮已交出:打断快照失效
|
||
this.floor.bedEnabled = !(wp && wp.conf.id === 'ping');
|
||
this.floor.yieldTurn(wp);
|
||
}
|
||
|
||
_introWp() {
|
||
return new WaitPoint({
|
||
id: 'intro',
|
||
expected: ['tap'],
|
||
full: null, // 指令句(sys_first_tap)刚说过,静默第一档直接短句
|
||
short: 'voice/sys_first_tap_short',
|
||
silenceMs: SILENCE.intro,
|
||
onDemo: () => this._afterFirstTap(), // L3 替点:直接替玩家走一步
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_continueWp(node) {
|
||
return new WaitPoint({
|
||
id: 'continue',
|
||
expected: ['tap'],
|
||
full: 'voice/sys_hint_continue_full',
|
||
short: 'voice/sys_hint_continue_short',
|
||
silenceMs: SILENCE.continue,
|
||
onDemo: () => {
|
||
// L3 替继续:替玩家推进一步(等价单击「继续」)
|
||
this.floor.speak(['voice/sys_demo_continue'], {
|
||
kind: 'hint',
|
||
onDone: () => {
|
||
if (node && node.next != null && NODES[node.next] !== undefined) this.advance(node.next);
|
||
else this._yieldTurn(this._continueWp(node));
|
||
},
|
||
});
|
||
},
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_pingWp() {
|
||
return new WaitPoint({
|
||
id: 'ping',
|
||
expected: ['tap'],
|
||
full: 'voice/sys_hint_ping_full',
|
||
short: 'voice/sys_hint_ping_short',
|
||
silenceMs: SILENCE.ping,
|
||
onDemo: () => this._firePing(), // L3 替发脉冲:游戏自己发一枚,回波照常解读
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_choiceWp(node) {
|
||
return new WaitPoint({
|
||
id: 'choice',
|
||
expected: ['left', 'right'],
|
||
full: this._promptOf(node), // 复述选项 = 合法帮助(N3;台账防逐字重复)
|
||
short: 'voice/sys_hint_choice_short',
|
||
restrictLine: 'voice/sys_only_choice',
|
||
silenceMs: SILENCE.choice,
|
||
onDemo: () => {
|
||
// 演示不替玩家做有后果的抉择:复述两个方向,交回
|
||
this.floor.speak(['voice/sys_demo_choice'], {
|
||
kind: 'hint',
|
||
onDone: () => this._yieldTurn(this._choiceWp(node)),
|
||
});
|
||
},
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_settleWp() {
|
||
return new WaitPoint({
|
||
id: 'settle',
|
||
expected: ['tap'],
|
||
full: 'voice/sys_hint_settle_full',
|
||
short: 'voice/sys_hint_continue_short',
|
||
silenceMs: SILENCE.settle,
|
||
onDemo: () => this.floor.chime(),
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_askWp() {
|
||
return new WaitPoint({
|
||
id: 'ask',
|
||
expected: ['tap', 'right'],
|
||
full: 'voice/sys_continue_ask',
|
||
short: 'voice/sys_hint_continue_short',
|
||
silenceMs: SILENCE.ask,
|
||
onDemo: () => this.floor.chime(),
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_menuWp() {
|
||
return new WaitPoint({
|
||
id: 'menu',
|
||
expected: ['tap', 'left', 'right'],
|
||
full: 'voice/menu_help',
|
||
short: 'voice/menu_help',
|
||
silenceMs: SILENCE.menu,
|
||
onDemo: () => this.floor.chime(),
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 声呐步序列(BUSY)
|
||
|
||
/**
|
||
* 顺序播放一组声呐步(pre/post 信息音、危机前置音)。运行期间 floor=BUSY:
|
||
* 手势统一 busy 回执(旧「tap 快进回波余音」废除——26 号 BUSY 语义)。
|
||
* token 作废机制与 floor 链并行。
|
||
*/
|
||
_runSteps(steps, onDone) {
|
||
if (!steps || !steps.length) {
|
||
if (onDone) onDone();
|
||
return;
|
||
}
|
||
const token = ++this.seqToken;
|
||
this._seqBusy = true;
|
||
let i = 0;
|
||
const next = () => {
|
||
if (token !== this.seqToken) return;
|
||
if (i >= steps.length) {
|
||
this._seqBusy = false;
|
||
this._drainSonar(); // 声呐步序列结束:链真正歇下,补发排队的信息音
|
||
if (onDone) onDone();
|
||
return;
|
||
}
|
||
const s = steps[i++];
|
||
if (s.danger) {
|
||
vibrate('danger');
|
||
if (s.dangerLoop) this._startDangerLoop(); // D4 滑坡另叠 danger 震循环
|
||
}
|
||
this._sonar(s.sfx, s.volume != null ? { volume: s.volume } : undefined);
|
||
setTimeout(next, s.wait || 0);
|
||
};
|
||
next();
|
||
}
|
||
|
||
_closeVoiceRecs() {
|
||
const now = performance.now();
|
||
for (const rec of this._voiceIntervals) {
|
||
if (rec.end === Infinity) rec.end = now;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 声呐双通道(§5)
|
||
|
||
/**
|
||
* sonarGate 空闲门(R1):信息类声呐音只在 voice idle 时播出,否则挂队列表、
|
||
* 链末补发——回波与语音零重叠。玩家主动脉冲只在搜索等待(voice 恒静)受理,
|
||
* 按构造不重叠。
|
||
*/
|
||
_sonar(id, opts) {
|
||
if (this.audio.isVoicePlaying()) {
|
||
this._sonarQueue.push({ id, opts });
|
||
return;
|
||
}
|
||
this.audio.play(id, opts);
|
||
this._sonarPlays.push({ id, t: performance.now() });
|
||
}
|
||
|
||
_drainSonar() {
|
||
if (this._sonarQueue.length === 0) return;
|
||
if (this.audio.isVoicePlaying()) return;
|
||
const item = this._sonarQueue.shift();
|
||
this.audio.play(item.id, item.opts);
|
||
this._sonarPlays.push({ id: item.id, t: performance.now() });
|
||
}
|
||
|
||
/** danger 震循环(D4 滑坡危机拍专用;间隔 ≥400ms,链推进/菜单/重开即停)。 */
|
||
_startDangerLoop() {
|
||
this._stopDangerLoop();
|
||
const token = this.seqToken;
|
||
const loop = { token, timer: null };
|
||
this._dangerLoop = loop;
|
||
const fire = () => {
|
||
if (this._dangerLoop !== loop) return;
|
||
vibrate('danger');
|
||
loop.timer = setTimeout(fire, DANGER_LOOP_MS);
|
||
};
|
||
loop.timer = setTimeout(fire, DANGER_LOOP_MS);
|
||
}
|
||
|
||
_stopDangerLoop() {
|
||
if (!this._dangerLoop) return;
|
||
if (this._dangerLoop.timer) clearTimeout(this._dangerLoop.timer);
|
||
this._dangerLoop = null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 环境音循环(游戏层 buffer.loop)
|
||
|
||
/**
|
||
* 循环垫(amb_deck/amb_deep/breath_loop):引擎 sfx 层即发即弃无 loop——
|
||
* 游戏侧经引擎 ctx 起循环 BufferSource 并持句柄(radio _ringSrc 先例)。
|
||
* load 异步:落地前已被停掉/被替换则不播;资产缺失静默降级(玩法不受阻)。
|
||
* 搜索等待的等待声 = amb_deep 常驻 + 回波余音(17 号 §2)。
|
||
*/
|
||
_startLoop(id, vol = 0.6) {
|
||
this._stopLoop(id); // 防双循环(重入时旧句柄先清)
|
||
if (!this.audio.ctx) return;
|
||
const handle = { want: true, src: null, gain: null };
|
||
this._loops[id] = handle;
|
||
this.audio._load(id).then((buf) => {
|
||
if (!buf || !handle.want || this._loops[id] !== handle) return;
|
||
const src = this.audio.ctx.createBufferSource();
|
||
src.buffer = buf;
|
||
src.loop = true;
|
||
const gain = this.audio.ctx.createGain();
|
||
gain.gain.value = this.audio.sfxVolume * vol * (this._ambDucked ? 0.5 : 1);
|
||
src.connect(gain);
|
||
gain.connect(this.audio._sfxBus);
|
||
src.start();
|
||
handle.src = src;
|
||
handle.gain = gain;
|
||
}).catch(() => { /* 资产缺失降级:声场靠 BGM/语音承载 */ });
|
||
}
|
||
|
||
_stopLoop(id) {
|
||
const h = this._loops[id];
|
||
if (!h) return;
|
||
h.want = false;
|
||
if (h.src) {
|
||
try { h.src.stop(); } catch (_) { /* 已停止 */ }
|
||
}
|
||
delete this._loops[id];
|
||
}
|
||
|
||
_stopAllLoops() {
|
||
for (const id of Object.keys(this._loops)) this._stopLoop(id);
|
||
}
|
||
|
||
/** R3 常驻退位:语音在播时 amb 常驻声呐元素降 -6dB(gain ×0.5),链歇恢复。 */
|
||
_setAmbDucked(on) {
|
||
if (this._ambDucked === on) return;
|
||
this._ambDucked = on;
|
||
const h = this._loops['sfx/amb_deep'];
|
||
if (h && h.gain) h.gain.gain.value = h.gain.gain.value * (on ? 0.5 : 2);
|
||
}
|
||
|
||
/**
|
||
* 低氧混音签名(§4.3):tier=low/crit 且处于水下(DIVE/COMMS)时垫
|
||
* breath_loop(低响度呼吸声);升档/上浮/收队即停。
|
||
*/
|
||
_updateBreath() {
|
||
const want = (tierOf(this.oxy) === 'low' || tierOf(this.oxy) === 'crit')
|
||
&& (this.phase === 'DIVE' || this.phase === 'COMMS');
|
||
if (want === this._breathOn) return;
|
||
this._breathOn = want;
|
||
this._breathLog.push({ on: want, t: performance.now() });
|
||
if (want) this._startLoop('sfx/breath_loop', 0.45);
|
||
else this._stopLoop('sfx/breath_loop');
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 存档(§7 / 26 号 §4.12)
|
||
|
||
/** data 结构照 §7(超集:form/dryPending/bond 为断点续玩派生账)。 */
|
||
_save() {
|
||
if (this.phase === 'ASK') return; // 询问态不落盘:stash 里的原存档才是真进度
|
||
const k = Object.keys(this.endings).length;
|
||
let summary;
|
||
if (this.phase === 'ENDING' && this.endingId) {
|
||
summary = `救援收队·${ENDINGS[this.endingId].name} · 结局${k}/4`;
|
||
} else {
|
||
const seg = this.diveN >= 1 && this.diveN <= 4
|
||
? `第${this.diveN}潜·${SEGS[this.diveN - 1]}`
|
||
: '甲板·简报';
|
||
summary = `${seg} · 氧气${this.oxy}格 · 结局${k}/4`;
|
||
}
|
||
const data = {
|
||
schema: 1,
|
||
updatedAt: new Date().toISOString(),
|
||
summary,
|
||
phase: this.phase === 'MENU' ? (this.returnPhase || 'INTRO') : this.phase,
|
||
node: this.phase === 'MENU' ? (this.returnNode || this.nodeId) : this.nodeId,
|
||
dive: this.diveN,
|
||
oxy: this.oxy,
|
||
supply: this.supply,
|
||
saved: { ...this.saved },
|
||
stayer: this.stayer,
|
||
flags: { ...this.flags },
|
||
bond: { ...this.bond },
|
||
form: this.diveForm,
|
||
dryPending: this._dryPending,
|
||
endings: { ...this.endings },
|
||
runs: this.runs,
|
||
};
|
||
try { localStorage.setItem(SAVE_KEY, JSON.stringify(data)); } catch (_) { /* 隐私模式 */ }
|
||
// 已登录时同步服务器(未登录静默跳过;大厅负责登录,游戏内只读写)
|
||
try {
|
||
const auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null');
|
||
if (!auth || !auth.token) return;
|
||
fetch('/api/progress', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
|
||
body: JSON.stringify({ game: 'abyss', data }),
|
||
}).catch(() => { /* 离线静默 */ });
|
||
} catch (_) { /* 静默 */ }
|
||
}
|
||
|
||
_loadLocal() {
|
||
try {
|
||
const data = JSON.parse(localStorage.getItem(SAVE_KEY));
|
||
if (!data || data.schema !== 1 || !data.phase || !data.node) return null;
|
||
return data;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
export default AbyssGame;
|