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

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

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

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

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

1649 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《守塔人》R2 —— 孤岛灯塔叙事 RPG,按 docs/design/26-audio-interaction-playbook.md
* 深度重构(设计单正文见 docs/design/21-keeper.md 附录 C;施工单见 26-apply-keeper.md)。
*
* 玩法核心不动(21 号正文):风暴夜时间轴 INTRO→C1→CALM→C2→CALM→C3→◆p9→ENDING、
* 回忆插叙三段(auto 链锚入→NPC→锚出)、trust×truth 双轻量标量、9 抉择 4 结局 +
* 尾声回响、KEEPER/SHORE 双音色资产 + NPC 三代声平铺、时空双线内容层签名
* (amb_storm/room vs amb_vinyl 互斥 + bgm 切换 + 锚句,不新增 earcon)、
* 危机拍前置 2s、雾角四处兑现、存档 summary。交互层整体换成 26 号六模块:
*
* - 发言权管理器(§4.1):整条语音链 = SPEAKING(holdDuck 跨链、句间不掉回
* 「轮到你」听感);链完 → floor.yieldTurn(等待点):交接提示音 → 400ms 后
* 等待声;玩家一操作等待声 80ms 内淡出。不存在安静态。插叙 / 幕间 / 危机拍
* 前置窗 = BUSY 演出态(手势 busy 回执)。
* - 输入回执 + 管道(§4.2):ok/invalid/busy 三件套;tap「继续/跳过」后 400ms
* 防连点;滑动打断并执行(floor 统一裁定)。
* - 新手手势宪法(§4.3):doubleTapWindow=0;单击=继续/跳句;左右滑=抉择;
* 上滑=「我在哪」(阶段 1,第一次抉择后解锁);下滑=菜单(阶段 2,第一结局);
* 双击按单击处理,阶段 3 起=重听当前等待点完整句 / 当前回忆节点;长按新手
* invalid(叙事类不占用帮助)。旧「双击=重听节点」口令废止(26 §7 反模式 5)。
* - 三档提示(§4.4):等待点 full → short → 提示音;主动重听=「再说一遍:」+
* 完整句;台账 30s 跨等待点去重(原则 9);选项句链播即登台账。
* - 迷茫检测(§4.5):乱点/连无效/静默信号累计 ≥2 → 三级接管(L1 短句 →
* L2 只留期待手势 → L3 演示:intro/continue 替玩家走一步,choice 只复述方向)。
* - 断点复述(§4.12):每抉择/CALM/ENDING 自动存档;回来 sys_continue_ask 问
* 「单击继续上次 / 右滑开始新的风暴夜」;MEMORY 存档恢复链时位句先行
* (prog_m → prog_mem → 重播当前回忆句起链,≤5s 回到可玩)。
*
* 状态 × 手势矩阵(新手期,完整版见 21 号附录 C §4):
* | 状态 | 单击 | 左/右滑 | 上滑 | 下滑(未解锁) | 双击(未解锁) |
* |------------|-------------------|------------------------|------------------|--------------|--------------|
* | 现在在说 | 跳句+ok(选项句、锚句首播 critical → busy) | 旁白滑到抉择点:缓存 pendingChoice+跳选项句;否则 invalid | 打断,答「我在哪」 | invalid+解释 | 按单击处理 |
* | 回忆在说 | 跳中段句+ok;锚入/锚出首播 critical → busy | invalid(插叙无选择,重播当前回忆句恢复) | 时位句先行状态链 | 菜单 | 按单击处理 |
* | 等你继续 | 继续+ok | invalid(inv_nosel) | 我在哪 | invalid | (阶段3=重听)|
* | 等你选择 | 再听选项(hold) | 抉择+ok(ui_confirm) | 「正在抉择。左右滑。」 | invalid | (阶段3=重听)|
* | 幕间(自动流)| 跳句+ok;空档 busy | invalid(喘息定时器照走) | 状态链 → 播完续走 | 菜单 | 按单击处理 |
* | 结算 | 新风暴夜+ok | invalid(settle full) | 结局主句+信任档+动作 | 菜单(阶段2) | 按单击处理 |
* | 菜单 | 确认当前项+ok | 左=上一项 / 右=下一项,每项报名 | 重听菜单说明+设置提示 | 关闭 | (阶段3=重听说明)|
*
* 结算/插叙保护(21 号 §2.2 的 26 号化,abyss 先例):结局链首句(e1~e4_a)、
* c3_dark、rev_teach、插叙锚句首播、恢复链首句(_resumeGuard)整句 critical
* ——手势 busy 回执不截链,覆盖旧 800ms 死窗且更强;插叙入口 600ms 静默拍期间
* floor=BUSY(手势 busy),语义等价。
*/
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';
// ---------------------------------------------------------------- 节点表
/** 叙事节点。voices 顺序播放;prompt 非空 = 抉择节点(链 = p* 选项句整句 critical)。 */
function N(voices, opts = {}) {
return {
voices, prompt: null, left: null, right: null, next: null,
sfx: null, sfxIdle: null, auto: false, danger: false, preMs: 0, dyn: null,
...opts,
};
}
/** 抉择节点:本体无台词,链 = p* 提示句一句(整句 critical:tap/滑动=busy)。 */
function P(prompt, left, right) {
return {
voices: [], prompt, left, right, next: null,
sfx: null, sfxIdle: null, auto: false, danger: false, preMs: 0, dyn: null,
};
}
/**
* 全部节点(台词全文见 21-keeper-voice-ids.md,148 句)。
* next 为 '@x' 虚拟目标:@c1/@c2/@c3 进各幕 / @calm1/@calm2 幕间 / @mem1..3 插叙 /
* @crisis1 危机拍1 / @p7left/@p8/@dark/@c9 条件解析 / @signoff 签收 / @ending 结局。
* 跨幕状态只有标量(trust/rev1..3/p7/p9told),无组合节点(§3.3 分支局部化)。
* R2(26 号化):b_rule 开场规矩念白废除(§4.10);INTRO 链 = b_open→b_rain→b_goal。
*/
const NODES = {
// ---- INTRO 开场链(冷启动:sys_first_tap 先行,链在 _afterFirstTap 走)
b_open: N(['voice/b_open'], { auto: true, next: 'b_rain' }),
b_rain: N(['voice/b_rain'], { auto: true, next: 'b_goal' }), // 雾角兑现点①
b_goal: N(['voice/b_goal'], { next: '@c1' }), // 链完等待单击 → C1
// ---- C1 幕一·风暴起(教学幕 · p1/p2 + 插叙1)
c1_tune: N(['voice/c1_tune'], { sfx: 'sfx/radio_tune', next: 'amo_a' }),
amo_a: N(['voice/amo_a'], { sfxIdle: 'sfx/static_burst', next: 'amo_b' }),
amo_b: N(['voice/amo_b'], { sfx: 'sfx/radio_beep', next: 'p1' }),
p1: P('voice/p1', 'c1_hold', 'c1_ask'),
c1_hold: N(['voice/c1_hold'], { next: 'c1_holdo' }),
c1_holdo: N(['voice/c1_holdo'], { next: 'c1_holde' }),
c1_holde: N(['voice/c1_holde'], { next: 'amo_c' }),
c1_ask: N(['voice/c1_ask'], { next: 'c1_asko' }),
c1_asko: N(['voice/c1_asko'], { next: 'c1_aske' }),
c1_aske: N(['voice/c1_aske'], { next: 'amo_c' }),
amo_c: N(['voice/amo_c'], { next: 'p2' }), // 回澜号伏笔(p9 左 amo_g 回环闭合)
p2: P('voice/p2', 'c1_open', 'c1_focus'),
c1_open: N(['voice/c1_open'], { sfx: 'sfx/hatch_open', next: '@mem1' }),
// rev_teach 不进节点表:实现为「进入首个插叙前自动插播一次」(全局一次,§修订记录 5)
c1_diary: N(['voice/c1_diary'], { next: '@calm1' }),
c1_focus: N(['voice/c1_focus'], { next: 'c1_focuse' }),
c1_focuse: N(['voice/c1_focuse'], { next: '@calm1' }),
// ---- C2 幕二·风暴顶(p3/p4 + 插叙2 + 危机拍1 + p5)
c2_back: N(['voice/c2_back'], { sfx: 'sfx/radio_tune', sfxIdle: 'sfx/static_burst', next: 'amo_d' }),
amo_d: N(['voice/amo_d'], { next: 'p3' }),
p3: P('voice/p3', 'c2_song', 'c2_shore'),
c2_song: N(['voice/c2_song'], { next: 'c2_songo' }),
c2_songo: N(['voice/c2_songo'], { next: '@mem2' }), // 插叙2 主触发(回返 c2_key)
c2_key: N(['voice/c2_key'], { next: 'amo_e' }),
c2_shore: N(['voice/c2_shore'], { next: 'c2_shoreo' }),
c2_shoreo: N(['voice/c2_shoreo'], { next: 'amo_e' }),
amo_e: N(['voice/amo_e'], { next: 'p4' }),
p4: P('voice/p4', 'c4_true', 'c4_pass'),
c4_true: N(['voice/c4_true'], { next: '@c4true' }), // rev2 ? 直进 : 插叙2 兜底(回返 c4_trueo)
c4_trueo: N(['voice/c4_trueo'], { next: '@crisis1' }),
c4_pass: N(['voice/c4_pass'], { next: 'c4_passo' }),
c4_passo: N(['voice/c4_passo'], { next: '@crisis1' }),
c2_crack: N(['voice/c2_crack'], { danger: true, preMs: 2000, sfx: 'sfx/hull_crack', auto: true, next: 'amo_f' }),
amo_f: N(['voice/amo_f'], { next: 'p5' }),
p5: P('voice/p5', 'c5_patch', 'c5_suit'),
c5_patch: N(['voice/c5_patch'], { next: 'c5_patcho' }),
c5_patcho: N(['voice/c5_patcho'], { next: '@calm2' }),
c5_suit: N(['voice/c5_suit'], { next: 'c5_suito' }),
c5_suito: N(['voice/c5_suito'], { next: '@calm2' }),
// ---- C3 幕三·风歇前(p6+插叙3 / p7 核心 / 危机拍2 / p8 双形态)
c3_eye: N(['voice/c3_eye'], { next: '@c3eye' }), // rev2 ? c3_page→p6 : m6_seal 封锁
c3_page: N(['voice/c3_page'], { sfx: 'sfx/flash_click', next: 'p6' }),
p6: P('voice/p6', '@mem3', 'c6_shut'), // 左 = 插叙3(回返 c6_reade);仅 rev2=true 可达
m6_seal: N(['voice/m6_seal'], { auto: true, next: 'c6_shut' }), // 封锁句:播后自动走右
c6_reade: N(['voice/c6_reade'], { next: 'p7' }),
c6_shut: N(['voice/c6_shut'], { next: 'p7' }),
p7: P('voice/p7', '@p7left', 'c7_hold'), // 核心宿命抉择:不动 trust
c7_go: N(['voice/c7_go'], { sfx: 'sfx/lamp_mech', next: 'c3_wall' }), // rev2=true 满发条
c7_go2: N(['voice/c7_go2'], { next: 'c3_wall' }), // rev2=false 半卷(灯灭伏笔)
c7_hold: N(['voice/c7_hold'], { next: 'c3_wall' }),
c3_wall: N(['voice/c3_wall'], { danger: true, preMs: 2000, sfx: 'sfx/wave_wall', auto: true, next: '@p8' }),
p8_boat: P('voice/p8_boat', 'c8_song', 'c8_math'), // p7=左 形态
p8_reef: P('voice/p8_reef', 'c8_old', 'c8_new'), // p7=右 形态
c8_song: N(['voice/c8_song'], { next: 'c8_songo' }),
c8_songo: N(['voice/c8_songo'], { next: 'c9_return' }),
c8_math: N(['voice/c8_math'], { next: 'c8_matho' }),
c8_matho: N(['voice/c8_matho'], { next: 'c9_return' }),
c8_old: N(['voice/c8_old'], { next: 'c8_oldo' }),
c8_oldo: N(['voice/c8_oldo'], { next: '@c9' }),
c8_new: N(['voice/c8_new'], { next: 'c8_newo' }),
c8_newo: N(['voice/c8_newo'], { next: '@c9' }),
c9_return: N(['voice/c9_return'], { next: '@dark' }), // p7=左:〔rev2 ? — : c3_dark 插播〕
c3_dark: N(['voice/c3_dark'], { auto: true, next: '@signoff' }), // 复刻祖父之夜(首句 critical)
c9_land: N(['voice/c9_land'], { next: '@signoff' }), // p7=右 且 trust≥3 自渡成功
c9_found: N(['voice/c9_found'], { next: '@signoff' }), // p7=右 且 trust<3 天亮被找到(零死亡)
g9_in: N(['voice/g9_in'], { auto: true, next: 'g9_knot' }),
g9_knot: N(['voice/g9_knot'], { next: '@p9' }),
// p9 为动态提示节点(dyn):truth=3 → p9a 全真相版;truth<3 → p9b 碎片版;左目标按 truth 解析
p9: { voices: [], prompt: null, left: '@p9left', right: 'd9_keep', next: null,
sfx: null, sfxIdle: null, auto: false, danger: false, preMs: 0, dyn: 'p9prompt' },
d9_tell_a: N(['voice/d9_tell_a'], { next: 'amo_g' }),
d9_tell_b: N(['voice/d9_tell_b'], { next: 'amo_g' }),
amo_g: N(['voice/amo_g'], { next: 'd9_telle' }), // 回环闭合:amo_c 伏笔兑现
d9_telle: N(['voice/d9_telle'], { next: '@ending' }),
d9_keep: N(['voice/d9_keep'], { next: 'amo_h' }),
amo_h: N(['voice/amo_h'], { next: 'd9_keepe' }),
d9_keepe: N(['voice/d9_keepe'], { next: '@ending' }),
};
// ---------------------------------------------------------------- 常量表
/** 插叙段(§6.3):锚入=首句(KEEPER 不滤波旁白),锚出=末句;auto 链播完自动回返。 */
const MEMORY_SETS = {
g1: { timeline: 'mem_g', label: 'g', voices: ['voice/rev_g1', 'voice/rev_g2', 'voice/rev_g3', 'voice/rev_g4'] },
f2: { timeline: 'mem_f', label: 'f', voices: ['voice/rev_f1', 'voice/rev_f2', 'voice/rev_f3', 'voice/rev_f4', 'voice/rev_f5'] },
c3: { timeline: 'mem_g', label: 'g', voices: ['voice/rev_c1', 'voice/rev_c2', 'voice/rev_c3', 'voice/rev_c4', 'voice/rev_c5'] },
};
/** 幕间(CALM 自动链:foghorn → lull_N →〔跨档才播 tr_*〕→ 喘息 → 下一幕)。 */
const CALMS = {
1: { lull: 'voice/lull_1', next: '@c2' },
2: { lull: 'voice/lull_2', next: '@c3' },
};
const ACT_NAMES = { 1: '风暴起', 2: '风暴顶', 3: '风歇前' };
const ACT_STORM_GAIN = { 1: 0.5, 2: 1.0, 3: 0.7 }; // amb_storm 分幕 gain(§2.1)
/** 上滑状态链分档整句(§4.3 数字规避:禁止运行时拼接;账目逐值/逐区间为真)。 */
const PROG_VOICES = { 1: 'voice/prog_m1', 2: 'voice/prog_m2', 3: 'voice/prog_m3' };
const PROG_MEM_VOICES = { g: 'voice/prog_mem_g', f: 'voice/prog_mem_f' };
const TIER_VOICES = { deep: 'voice/tr_deep', half: 'voice/tr_half', thin: 'voice/tr_thin' };
const TIER_SUMMARY = { deep: '深', half: '半', thin: '薄' };
const TRU_VOICES = ['voice/tru_zero', 'voice/tru_one', 'voice/tru_two', 'voice/tru_full'];
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:trust≥3 × 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/foghorn', 'sfx/radio_tune', 'sfx/static_burst', 'sfx/radio_beep',
'sfx/hatch_open', 'sfx/flash_click', 'sfx/lamp_mech', 'sfx/hull_crack',
'sfx/wave_wall', 'sfx/memory_in', 'sfx/memory_out',
'sfx/ui_confirm', 'sfx/ui_menu',
];
const SAVE_KEY = 'soundgame.progress.keeper';
const AUTH_KEY = 'soundgame.auth';
/** 抉择提示句 id(voice/p1~p7、p8_boat、p8_reef、p9a/p9b):promptPlaying 兼容语义。 */
const PROMPT_RE = /^voice\/p\d/;
/** 插叙锚句(rev_g1/g4、rev_f1/f5、rev_c1/c5):首播整句 critical(G5)。 */
const ANCHOR_RE = /^voice\/rev_(g[14]|f[15]|c[15])$/;
// 等待点静默阈值(21 号附录 C §5:intro 6/15、continue 13/30、choice 25/50、
// settle 20/45;reopen/menu 照 radio 口径)
const SILENCE = {
intro: [6000, 15000], // 开场第一下:6 秒就提醒(F7:第一分钟内必须第一次成功)
continue: [13000, 30000], // 叙事链末(storyline P2-3 先例)
choice: [25000, 50000],
settle: [20000, 45000],
reopen: [12000, 30000],
menu: [20000, 45000],
};
// 无效操作解释句(原则 7:每个「状态 × 手势」只解释一次;键 = 状态×手势)
const INVALID_LINES = {
'locked:up': 'voice/inv_up_locked',
'locked:down': 'voice/inv_down_locked',
'locked:longpress': 'voice/inv_generic',
'memory:swipe': 'voice/inv_mem', // 「现在不用选。听完就好。」
'settle:swipe': 'voice/sys_hint_again_full',
menuHelp: 'voice/menu_help',
nosel: 'voice/inv_nosel',
generic: 'voice/inv_generic',
};
const CALM_REST_MS = 2500; // 幕间喘息(自动链 ≤12s:foghorn+lull+档句+喘息)
const DANGER_LEAD_MS = 2000; // 危机拍前置预警窗(sfx 渐强烘进文件 + danger 震同窗)
const MEMORY_GAP_MS = 600; // 插叙入口静默拍(§6.3 步 1:结果句完 → 静默一拍)
/** 信任档位(§4.3):3-4 深 / 1-2 半 / 0 薄。 */
function tierOf(trust) {
if (trust >= 3) return 'deep';
if (trust >= 1) return 'half';
return 'thin';
}
export class KeeperGame extends Game {
constructor(audio, root) {
super(audio, root);
// ---- 26 号六模块接线(index.html 的 makeInputPipeline 依赖这些字段)----
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(false); // 新手期长按不快进(长按=无效手势,§4.3)
// ---- 宏状态(玩法骨架不动:INTRO/WATCH/MEMORY/CALM/ENDING + MENU/ASK)----
this.phase = 'INTRO';
this.returnPhase = null; // MENU 关闭后回原状态
this.returnNode = null;
this.nodeId = 'b_open';
this.act = 1;
this.timeline = 'now'; // 'now' | 'mem_g' | 'mem_f'(存档带时空位,§6.4)
this.endingId = null;
this.choiceArmed = false;
this.pendingChoice = null; // 旁白链中的滑动缓存(V1:选项句播完才生效,零插播)
this.awaitingRestart = false;
this.leaving = false; // 菜单「保存并回大厅」已确认
this.saved = null; // ASK 态暂存的存档
this.menuIdx = 0;
this.seqToken = 0; // 链代际令牌:插叙静默拍/危机拍前置窗/幕间喘息的作废判据
// ---- 跨幕变量(§4.1)----
this.trust = 0;
this.rev1 = false;
this.rev2 = false;
this.rev3 = false;
this.p7 = null; // 'boat' | 'lamp'
this.p9told = false; // p9=左(说出)
this.endings = {};
this.runs = 0;
this.taught = false; // rev_teach 全局一次(首插叙前自动插播)
// ---- 教学标志(存档携带;做对过的事不教第二遍,原则 11)----
this.goalMet = false; // 第一个抉择完成 → 解锁上滑(§4.3 阶梯)
this.turnTaught = false; // 「轮到你」状态声首现教学(挂 amo_a 链尾,全局一次)
this.skipHintShown = false; // sys_skip 每实例一次(radio z1_b 先例)
this._teachHead = null; // 挂下一段链头:首抉择肯定句「对。」
this._teachTail = null; // 挂下一段链尾:解锁教学句(§4.3 阶梯)
// ---- 插叙运行时(§6.3)----
this.memSet = null; // 'g1' | 'f2' | 'c3'
this.memIdx = 0; // 当前回忆句下标(打断/菜单/存档恢复用)
this.memRet = null; // 回返 now 线节点
this._memFirstBroadcast = false; // 本段插叙是否首播(锚句 critical 只锁首播,G5)
this._memChainIds = null; // 当前插叙链句表(audio.play 钩子反查 memIdx 用)
this.lastAnnouncedTier = 'thin'; // CALM 跨档播报基准(开局 trust=0 → thin)
this._c2KeyNext = null; // 插叙2 兜底回返链(c2_key → c4_trueo)
this._resumeGuard = false; // F4:恢复链首句 critical(播完即清)
// ---- 打断快照 _chainSpeaking(abyss 先例,施工单偏差 8)----
// pipeline 的 floor.handleInput 对滑动「打断并执行」先杀链置 BUSY,游戏侧
// floor.speaking 已 false——快照在 speak 置位、onDone/interrupt 清零,让被
// 掐断的旁白链按矩阵落「旁白中」分支(缓存抉择 / 恢复等待点,零无声死锁)。
this._chainSpeaking = false;
const origSpeak = this.floor.speak.bind(this.floor);
this.floor.speak = (ids, opts = {}) => {
const done = opts.onDone;
opts.onDone = () => {
this._chainSpeaking = false;
this._syncAmbDuck();
if (done) done();
};
this._chainSpeaking = true;
const r = origSpeak(ids, opts);
if (r) this._syncAmbDuck(); // 真正起播才 duck(hint/feedback 被丢弃不算)
return r;
};
const origInterrupt = this.floor.interrupt.bind(this.floor);
this.floor.interrupt = () => {
this._chainSpeaking = false;
this._syncAmbDuck();
return origInterrupt();
};
// 插叙链句级记账(floor 无句级钩子):插叙句起播即反查 memIdx——
// 打断/菜单/双击重听时记忆点已就位(链死后 floor.currentId() 为 null)
const origPlay = this.audio.play.bind(this.audio);
this.audio.play = (id, opts) => {
if (this._memChainIds && typeof id === 'string' && this._memChainIds.includes(id)) {
this.memIdx = this._memChainIds.indexOf(id);
}
return origPlay(id, opts);
};
// ---- 底噪/引擎循环句柄(游戏层 buffer.loop,无重触发定时器)----
this._loops = {}; // key -> { src, gain }
this._ambLog = []; // { id, on, t } 起停流水(测试断言底噪互斥/时序)
this._callOn = false; // 阿澈通话期(amb_engine 常驻、storm 让位 -4dB)
this._duckVoice = false; // voice 在播 duck 标志(engine -6dB)
// 计时器句柄(菜单/重开时统一清理)
this.restTimer = null; // 幕间喘息(CALM 自动流推进器)
this._calmGen = 0; // 幕间代际(菜单/重开作废在途喘息)
}
/** truth = 插叙完成数(§4.1)。 */
get truth() {
return (this.rev1 ? 1 : 0) + (this.rev2 ? 1 : 0) + (this.rev3 ? 1 : 0);
}
// ---- 兼容语义(harness/测试):链级门控走 floor ----
get voicePlaying() { return this.floor.speaking; }
get currentVoiceId() { return this.floor.currentId(); }
get promptPlaying() { return PROMPT_RE.test(this.floor.currentId() || ''); }
// ---------------------------------------------------------------- Game
start() {
this.audio.play('bgm/storm'); // 全夜 storm 常驻(bgm=0 时引擎只记意图,重开自动补播)
this.audio.prewarm(ALL_SFX);
const local = this._loadLocal();
if (local && local.phase && local.node) {
this._askContinue(local);
return;
}
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=keeper', { 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._calmGen++; // 作废幕间喘息
this._save(); // 退出自动存档(26 号 §4.12 / 原则 16)
this.floor.interrupt();
this._stopAllLoops(); // 不得经 Floor 资产缺失哨兵路径复活底噪循环
if (this.restTimer) { clearTimeout(this.restTimer); this.restTimer = null; }
this._resumeGuard = false;
this.audio.stopAll();
}
// ---------------------------------------------------------------- 开场(§4.10 首 3 分钟)
/**
* 冷启动首 3 分钟(21 号附录 C §6 逐秒脚本):0~3 秒第一句就让玩家动手——
* 风暴底噪 + 雾角 →「点一下屏幕。」(critical 指令句,抢拍单击给 busy 回执,
* 保证唯一一次教学完整到达);规则念白废除(b_rule/gd_brief 停用)。
*/
_coldStart() {
this.phase = 'INTRO';
this.nodeId = null;
this.runs = this.runs || 1;
this._applyAmbNow(); // 现在签名底噪(storm 0.5 档 + room,冷启动即起播)
this.audio.play('sfx/foghorn'); // 风暴夜开场雾角(附录 C §6「风暴+雾角」氛围)
this.floor.speak(['voice/sys_first_tap'], {
critical: true,
onDone: () => this.floor.yieldTurn(this._introWp()),
});
}
/**
* 第一下单击之后(3~8 秒):塔名 + 雾角承诺 → 极短目标句 → continue 等待点
* (链末单击 → @c1 电台静电 → 阿澈求救)。b_rule 规矩念白废除(§4.10)。
*/
_afterFirstTap() {
this.nodeId = 'b_open';
this._save();
this.floor.speak(['voice/b_open', 'voice/b_rain'], {
onDone: () => {
this.audio.play('sfx/foghorn'); // 雾角兑现点①:b_rain 口播「两长一短」后呼应
this.nodeId = 'b_goal'; // 存档落进度点(continue 等待所在的节点)
this.floor.speak(['voice/b_goal'], {
onDone: () => this.floor.yieldTurn(this._continueWp(NODES.b_goal)),
});
},
});
}
// ---------------------------------------------------------------- 手势(pipeline 之后)
/** pipeline 收口后的游戏处理;返回 'ok' | 'hold' | 'invalid'。 */
onGesture(g) {
if (this.phase === 'MENU') return this._onMenuGesture(g);
if (this.phase === 'ASK') return this._onAskGesture(g);
return this._onStoryGesture(g); // INTRO / WATCH / MEMORY / CALM / ENDING
}
stateName() {
if (this.phase === 'MENU') return 'menu';
if (this.phase === 'ASK') return 'reopen';
if (this.phase === 'MEMORY') return 'memory';
if (this.choiceArmed && !this.floor.speaking) return 'choice';
if (this.floor.speaking) 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 === 'memory') return INVALID_LINES['memory:swipe'];
if (state === 'settle') return INVALID_LINES['settle:swipe'];
return INVALID_LINES.nosel;
}
if (g === 'doubletap') return null; // 阶段 3 已解锁才会到这里:重听,无需解释
if (state === 'menu') return INVALID_LINES.menuHelp;
return INVALID_LINES.generic;
}
/**
* 当前等待点是否为抉择点(链末交接出的话轮)。真等待态判据:链在播时
* waitPoint 可能是上一点的残留(floor.speak 不摘旧点),不可当真等待——
* 管道正常不会在链播中把手势递进来,这里兜底直调(无头会话/转写器)场景。
*/
_atChoice() {
const wp = this.floor.waitPoint;
return !!(wp && wp.conf.id === 'choice' && !this.floor.speaking);
}
_onStoryGesture(g) {
const node = NODES[this.nodeId] || null;
// ---- 「游戏在说」= SPEAKING / 被滑动打断的链(快照;tap 已被 floor 按
// 「跳句」收口、critical 句给 busy,都到不了这里)----
if (this.floor.speaking || this._chainSpeaking) {
switch (g) {
case 'left':
case 'right': {
if (this.phase === 'MEMORY') {
// 插叙无选择(21 附录 C §4):打断已成事实,重播当前回忆句恢复可玩
this._replayMemoryFrom();
return 'invalid';
}
// 旁白链中的滑动落在抉择点:缓存 pendingChoice + 跳去选项句
// (不当场盲选;选项句整句 critical 播完才生效,W2 + 盲玩公平)
const pt = this._swipePromptTarget(node);
if (pt) {
this.pendingChoice = g;
this.playNode(pt);
return 'ok';
}
this._recoverInterrupted(node);
return 'invalid'; // inv_nosel:现在不用选
}
case 'up':
this._upStatus();
return 'ok';
case 'down':
this.openMenu();
return 'ok';
case 'doubletap':
if (this.phase === 'ENDING') { this._playEndingChain(); return 'ok'; }
if (this.phase === 'MEMORY') { this._replayMemoryFrom(); return 'ok'; }
if (this.phase === 'CALM') { this._calmChain(); return 'ok'; }
this._chainSpeaking = false;
if (node) { this._speakNode(node); return 'ok'; } // 重听当前节点(阶段 3)
return 'invalid';
default:
this._recoverInterrupted(node);
return 'invalid';
}
}
// ---- 等待态(continue / choice / settle;CALM/插叙为 BUSY 到不了这里)----
switch (g) {
case 'tap': {
const wp = this.floor.waitPoint;
if (wp && wp.conf.id === 'choice') {
wp.requestReplay(); // 空点=主动再听选项(「再说一遍:」+ 完整句,原则 9)
return 'hold';
}
if (wp && wp.conf.id === 'intro') {
this._afterFirstTap(); // 开场第一下单击 → 旁白 + 极短目标
return 'ok';
}
if (this.phase === 'ENDING') { this.newNight(); return 'ok'; } // 结算=新风暴夜
if (node && node.next != null) { this.advance(node.next); return 'ok'; }
this._ensureWait();
return 'invalid';
}
case 'left':
case 'right':
if (this.choiceArmed) { this.choose(g); return 'ok'; }
this._ensureWait();
return 'invalid'; // inv_nosel / inv_mem:现在不用选
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'; }
if (node) { this._speakNode(node); return 'ok'; }
return 'invalid';
}
default:
this._ensureWait();
return 'invalid';
}
}
_onAskGesture(g) {
const s = this.saved;
switch (g) {
case 'tap':
this._resume(s);
return 'ok';
case 'right':
this.saved = null;
this.newNight();
return 'ok';
case 'doubletap': {
const wp = this.floor.waitPoint;
if (wp) { wp.requestReplay(); return 'hold'; }
return 'invalid';
}
case 'up': {
// 询问态的状态一句话:幕 + 信任档(存档里的账)
if (!s) return 'invalid';
const tier = TIER_VOICES[tierOf(s.trust | 0)];
this._tierCue(tier);
const ids = [PROG_VOICES[s.act] || PROG_VOICES[1], tier];
for (const id of ids) markHintSaid(id);
this.floor.speak(ids, {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'ok';
}
default:
return 'invalid';
}
}
_onMenuGesture(g) {
switch (g) {
case 'left': // 上一项,报名(00 号 §5.4:左右滑换项、单击确认)
this.menuIdx = (this.menuIdx + MENU_ITEMS.length - 1) % MENU_ITEMS.length;
this._menuAnnounce();
return 'hold';
case 'right': // 下一项,报名
this.menuIdx = (this.menuIdx + 1) % MENU_ITEMS.length;
this._menuAnnounce();
return 'hold';
case 'tap': // 确认当前项(单击的全局含义=确定,菜单里不改)
this._execMenuItem(MENU_ITEMS[this.menuIdx].id);
return 'ok';
case 'up': // 重听菜单说明 + 设置提示(设置只在大厅改)+ 复述当前项
this.floor.speak(['voice/menu_help', 'voice/sys_settings', MENU_ITEMS[this.menuIdx].voice], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
case 'doubletap': {
const wp = this.floor.waitPoint;
if (wp) { wp.requestReplay(); return 'hold'; } // 阶段 3:重听菜单说明
return 'invalid';
}
case 'down': // 再下滑关闭(00 号 §5.4)
this.closeMenu();
return 'ok';
default:
return 'invalid';
}
}
_menuAnnounce() {
this.floor.speak([MENU_ITEMS[this.menuIdx].voice], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
}
// ---------------------------------------------------------------- 打断兜底(§4.1 不允许安静态)
/**
* 旁白链被打断且本游戏判 invalid 时,链已死、等待点可能已被消费——按当前
* 局面重新交出话轮(radio _ensureWait 同款)。插叙(auto 流)不交等待点:
* 从当前回忆句重播起链;CALM(auto 流)挂喘息定时器照走。
*/
_recoverInterrupted(node) {
this._chainSpeaking = false;
this._ensureWait(node);
}
_ensureWait(node = null) {
if (this.floor.speaking || this.floor.waitPoint) return;
if (this.phase === 'MENU') { this.floor.yieldTurn(this._menuWp()); return; }
if (this.phase === 'ASK') { this.floor.yieldTurn(this._reopenWp()); return; }
if (this.phase === 'MEMORY') {
this.seqToken++; // 作废插叙入口静默拍(防双链)
this._memFirstBroadcast = false; // 复播可掐(G5)
this._playMemoryChain(this.memIdx);
return;
}
if (this.phase === 'CALM') { this._armRest(); return; } // 自动流:喘息照走推进下一幕
if (this.phase === 'ENDING') { this.floor.yieldTurn(this._settleWp()); return; }
if (!this.nodeId) { this.floor.yieldTurn(this._introWp()); return; } // 冷启动开场被打断
const n = node || NODES[this.nodeId];
if (n && n.prompt != null) this.floor.yieldTurn(this._choiceWp(n));
else this.floor.yieldTurn(this._continueWp(n));
}
/**
* 旁白链中的滑动是否落在抉择点上:本节点是抉择节点,或 next 直入抉择节点
* (@x 过场目标动态解析,不追——保守落 invalid 恢复)。返回抉择节点 id 或 null。
*/
_swipePromptTarget(node) {
if (!node) return null;
if (node.prompt != null) return this.nodeId;
if (node.next != null && !this._isTransition(node.next)) {
const nx = NODES[node.next];
if (nx && nx.prompt != null) return node.next;
}
return null;
}
// ---------------------------------------------------------------- 上滑「我在哪」(原则 3)
/**
* 状态链(21 附录 C §3:时位句先行 → 信任档 → 一个动作;G6 顺序
* prog_m →〔mem: prog_mem〕→ tr → tru):播完回原等待点(radio 口径
* resumeTurn,档位计数保留);等待点不在(旁白被打断/自动流)按来源重建,
* 不重播节点(原则 9)。状态句全量进台账(刚答过的话,30s 内被动提示降档)。
*/
_statusIds() {
const ids = [];
if (this.phase === 'ENDING') {
const def = ENDINGS[this.endingId] || ENDINGS.e1;
ids.push(def.voices[0]); // 结局主句 = 结局名播报
} else if (this.phase === 'MEMORY' && this.memSet && MEMORY_SETS[this.memSet]) {
ids.push(PROG_MEM_VOICES[MEMORY_SETS[this.memSet].label]); // 时位句先行(§6.4)
} else {
ids.push(PROG_VOICES[this.act]); // 时位句(now 线按当前幕选播)
}
const tier = TIER_VOICES[tierOf(this.trust)];
this._tierCue(tier); // G2:tr_thin 附 warning 震(首夜首幕豁免,F1)
ids.push(tier, TRU_VOICES[this.truth]);
// 动作尾巴(原则 3:位置 → 目标 → 现在能做的一个动作)
if (this.phase === 'MEMORY') ids.push('voice/sys_mem_back'); // 「听完,自动回到现在。」
else if (this._atChoice()) ids.push('voice/status_choice'); // 「正在抉择。左右滑。」
else if (this.phase === 'ENDING') ids.push('voice/sys_hint_again_full');
else ids.push('voice/sys_hint_continue_full');
return ids;
}
_upStatus() {
const wasEnding = this.phase === 'ENDING';
this.seqToken++; // 作废插叙入口静默拍 / 危机拍前置窗(若恰在途;BUSY 期手势到不了这里)
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 (this.floor.waitPoint) { this.floor.resumeTurn(); return; }
// 等待点不在(旁白被打断 / 自动流):按来源重建
if (this.phase === 'MEMORY') {
this._memFirstBroadcast = false;
this._playMemoryChain(this.memIdx);
} else if (this.phase === 'CALM') {
this._armRest(); // 幕间续走
} else if (wasEnding && !this.awaitingRestart) {
this._playEndingChain(); // 结局链被打断:重播结局链(局面复述)
} else {
this._ensureWait();
}
},
});
}
/** G2:tr_thin 整句播报附 warning 震——但首夜首幕首查豁免(R2 F1):
* trust=0 在第一幕第一夜是「自然起点」而非警告语义;跨夜/第二幕起恢复。 */
_tierCue(tierId) {
if (tierId !== TIER_VOICES.thin) return;
const maiden = this.act === 1 && this.trust === 0 && this.runs <= 1;
vibrate(maiden ? 'confirm' : 'warning');
}
// ---------------------------------------------------------------- 风暴夜流转
/** 新风暴夜:重置跨幕标量(结局收集跨夜保留),台标链回 INTRO。 */
newNight() {
this.runs += 1;
this.trust = 0;
this.rev1 = false;
this.rev2 = false;
this.rev3 = false;
this.p7 = null;
this.p9told = false;
this.taught = false;
this.act = 1;
this.timeline = 'now';
this.memSet = null;
this.memIdx = 0;
this.memRet = null;
this.endingId = null;
this.lastAnnouncedTier = 'thin';
this._callOn = false;
this.leaving = false;
this.saved = null;
this.choiceArmed = false; // 清掉上一夜的抉择武装(radio 实现修复 #7)
this.pendingChoice = null;
this._teachHead = null;
this._teachTail = null;
this._resumeGuard = false;
this._memFirstBroadcast = false;
this._c2KeyNext = null;
this.seqToken++;
this._calmGen++;
this.floor.interrupt();
this._cancelRest();
this.phase = 'INTRO';
this.audio.play('bgm/storm'); // 从菜单/结局重开为同首不重启
this.nodeId = 'b_open';
this._applyAmbNow(); // 底噪回现在签名(storm 0.5 档 + room,vinyl/engine 停)
this._save();
// 重开不再念开场教学(做对过的事不教第二遍,原则 11):台标链照走
this.floor.speak(['voice/sys_new_night', 'voice/b_open', 'voice/b_rain'], {
onDone: () => {
this.audio.play('sfx/foghorn'); // 雾角兑现点①(新夜)
this.floor.speak(['voice/b_goal'], {
onDone: () => this.floor.yieldTurn(this._continueWp(NODES.b_goal)),
});
},
});
}
/** 进幕:act 推进 + amb_storm 分幕 gain。 */
enterWatch(nodeId) {
if (nodeId === 'c1_tune') this.act = 1;
else if (nodeId === 'c2_back') this.act = 2;
else if (nodeId === 'c3_eye') this.act = 3;
this.phase = 'WATCH';
this.timeline = 'now';
this._syncStormGain();
if (nodeId === 'c1_tune' || nodeId === 'c2_back' || nodeId === 'c3_eye') {
this._callOn = true; // 阿澈通话期开始(engine 常驻低响度,§6.6 R3)
this._startLoop('amb_engine', 0.4);
}
this.playNode(nodeId);
}
/** 通话期结束节点(p2/p5 结果句、签收):停 engine、storm 复位。 */
_endCall() {
this._callOn = false;
this._stopLoop('amb_engine');
this._syncStormGain();
}
/** CALM:幕间喘息(自动流,BUSY)。foghorn + lull_N +〔跨档才播 tr_*〕→ 喘息 → 下一幕。 */
enterCalm(n) {
this._endCall();
this.phase = 'CALM';
this._calmN = n;
this._save(); // 写入时机:每幕 CALM 进入
this._calmChain();
}
_calmChain() {
this.floor.interrupt(); // 自动流不挂等待点;摘掉残留等待点/杀在途链
this.audio.play('sfx/foghorn');
const ids = [CALMS[this._calmN].lull];
// 跨档被动播报(§4.3:跨档才播;radio enterBridge 口径:链构造时结清账目)
const tier = tierOf(this.trust);
if (tier !== this.lastAnnouncedTier) {
this.lastAnnouncedTier = tier;
ids.push(TIER_VOICES[tier]);
this._tierCue(TIER_VOICES[tier]); // G2:跨档落到 thin 附 warning 震
}
this.floor.speak(ids, { onDone: () => this._armRest() });
}
_armRest() {
if (this.restTimer) clearTimeout(this.restTimer);
const gen = ++this._calmGen;
this.restTimer = setTimeout(() => {
this.restTimer = null;
if (this.phase !== 'CALM' || gen !== this._calmGen) return; // 已被菜单/重开打断
this._calmN += 1;
this.advance(CALMS[this._calmN - 1].next);
}, CALM_REST_MS);
}
_cancelRest() {
if (this.restTimer) { clearTimeout(this.restTimer); this.restTimer = null; }
this._calmGen++;
}
/** ENDING:trust×p9 四选一 + 尾声回响链,结算态等待单击重开。 */
enterEnding(forced) {
const ending = forced
|| (this.trust >= 3 ? (this.p9told ? 'e1' : 'e2') : (this.p9told ? 'e3' : 'e4'));
this.endingId = ending;
this.endings[ending] = true;
this._endCall();
this.phase = 'ENDING';
this.nodeId = ending;
this.awaitingRestart = false;
this.audio.play('bgm/dawn', { fadeMs: 1500 }); // d9 结果链播完后淡入
// 一局结局 → 手势阶梯 onGameDone(第 1 局解锁下滑=菜单、第 3 局解锁双击=重听)
const got = this.ladder.onGameDone();
if (got) this._teachTail = this.ladder.teachLine(got); // 已带 voice/ 前缀(novice.js 修复后语义)
this._save(); // 写入时机:ENDING 进入
this._playEndingChain();
}
/** 尾声回响逐项点名(§3.4):阿澈(p7×trust)/灯(p7×rev2)/日志(p9×truth≥1)/父亲班次(rev2)。 */
_endingVoices() {
const chain = [...ENDINGS[this.endingId].voices, 'voice/ep_lead'];
chain.push(this.p7 === 'boat'
? 'voice/ep_amo_home'
: (this.trust >= 3 ? 'voice/ep_amo_solo' : 'voice/ep_amo_dawn'));
chain.push(this.p7 === 'boat'
? (this.rev2 ? 'voice/ep_lamp_full' : 'voice/ep_lamp_dark')
: 'voice/ep_lamp_hold');
if (this.truth >= 1) {
chain.push(this.p9told ? 'voice/ep_log_told' : 'voice/ep_log_kept');
}
if (this.rev2) chain.push('voice/ep_dad');
chain.push('voice/ep_shore');
return chain;
}
_playEndingChain() {
this.awaitingRestart = false; // 重播清结算态(链完重新交出 settle 等待点)
const chain = this._endingVoices();
const teach = this._pullTeach();
if (teach) chain.push(teach);
// 首句 critical:结局链入口结算保护(覆盖旧 800ms 死窗且更强,abyss 先例)
this.floor.speak([chain[0]], {
critical: true,
onDone: () => {
const rest = chain.slice(1);
this.floor.speak(rest, {
onDone: () => {
this.audio.play('sfx/foghorn'); // 雾角兑现点④:ep_shore 播完远应
this.awaitingRestart = true;
this.floor.yieldTurn(this._settleWp());
},
});
},
});
}
// ---------------------------------------------------------------- 插叙(MEMORY,本作核心差异层 §6)
/**
* 进入插叙:静默 600ms(floor=BUSY,手势 busy 回执 = 入口死窗语义)→
* 〔rev_teach 全局一次,critical〕→ memory_in 唱针(idle 门)→ auto 链
* (锚入 critical → NPC 中段可跳 → 锚出 critical)→ memory_out → 回返 now 线。
*/
_enterMemory(setId, retNode) {
const set = MEMORY_SETS[setId];
if (!set) { this.playNode(retNode); return; }
this.floor.interrupt(); // 入口即 BUSY:摘掉抉择等待点残留 + 清静默计时,
// 600ms 静默拍内手势给 busy 回执(插叙入口死窗语义,21 号 §2.2 的 26 号化)
this.memSet = setId;
this.memIdx = 0;
this.memRet = retNode;
this._memFirstBroadcast = true;
this._memChainIds = set.voices;
this.phase = 'MEMORY';
this.timeline = set.timeline; // 存档带时空位(§6.4)
this.nodeId = set.voices[0].slice(6); // 存档 node = 当前回忆句 id
this._stopLoop('amb_engine'); // 插叙期间电台静默(§6.6 R2)
this.audio.play('bgm/memory', { fadeMs: 800 });
this._applyAmbMemory(); // storm/room 淡出 / vinyl 淡入(互斥硬约束)
const token = ++this.seqToken;
setTimeout(() => {
if (token !== this.seqToken || this.phase !== 'MEMORY') return;
const pre = [];
if (!this.taught) {
this.taught = true;
pre.push('voice/rev_teach'); // 时空签名教学(全局一次,首插叙前,critical)
}
const begin = () => {
// 不打 token 比对:teach 链的 floor.speak 已被打断即作废(onDone 不触发,
// begin 不会跑);中断安全由 teach 链自身的作废语义承担
if (this.phase !== 'MEMORY') return;
this._playIdleGated('sfx/memory_in'); // 唱针落下(idle 门)
this._playMemoryChain(0);
};
if (pre.length) this.floor.speak(pre, { critical: true, onDone: begin });
else begin();
}, MEMORY_GAP_MS);
}
/**
* 插叙链 auto 播放(拆链实现句级 critical,abyss parts 模式):
* 首播锚入/锚出整句 critical(G5),中段句可跳(tap=consumed);
* 复播(打断/菜单/双击重听后)不锁(复播可即掐)。链末不设等待自动回返。
* memIdx 由 audio.play 钩子实时维护(当前回忆句)。
*/
_playMemoryChain(fromIdx) {
const set = MEMORY_SETS[this.memSet];
if (!set) return;
this._memChainIds = set.voices;
const ids = set.voices.slice(Math.max(0, fromIdx));
if (!ids.length) { this._exitMemory(); return; }
const parts = [];
if (this._memFirstBroadcast) {
parts.push({ ids: [ids[0]], critical: true }); // 锚入句首播不可跳
if (ids.length > 2) parts.push({ ids: ids.slice(1, -1) }); // NPC 中段可跳
if (ids.length >= 2) parts.push({ ids: [ids[ids.length - 1]], critical: true }); // 锚出句
} else {
parts.push({ ids });
}
const runParts = (k) => {
if (k >= parts.length) { this._exitMemory(); return; }
this.floor.speak(parts[k].ids, {
critical: !!parts[k].critical,
onDone: () => runParts(k + 1),
});
};
runParts(0);
}
/** 打断/双击重听/菜单关闭/上滑播完的插叙恢复:从当前回忆句重播(不锁锚句)。 */
_replayMemoryFrom() {
this.seqToken++; // 作废插叙入口静默拍(防双链)
this._memFirstBroadcast = false; // 复播可即掐(G5:锁不随复播重新武装)
this.floor.interrupt();
this._playMemoryChain(this.memIdx);
}
/** 插叙回返:memory_out + bgm/storm 回切 + amb 回现在签名 → 接播 now 线回返节点。 */
_exitMemory() {
this._memChainIds = null;
this._playIdleGated('sfx/memory_out'); // 唱针抬起(idle 门)
if (this.phase !== 'MEMORY') return;
this.audio.play('bgm/storm', { fadeMs: 800 });
this._applyAmbNow();
if (this._callOn) this._startLoop('amb_engine', 0.4); // 通话期继续(插叙 2/3 在通话中段触发)
this.phase = 'WATCH';
this.timeline = 'now';
const ret = this.memRet;
this.memSet = null;
this.playNode(ret); // 回返后 now 句接播(§6.3 步 5)
}
// ---------------------------------------------------------------- 存档续玩(§4.12)
_askContinue(save) {
this.saved = save;
this.runs = (save && save.runs) || 0; // 右滑新夜 → runs+1 的基数从存档起
this.endings = { ...((save && save.endings) || {}) };
this.phase = 'ASK';
this.nodeId = save && save.node ? save.node : 'b_open';
markHintSaid('voice/sys_continue_ask'); // 台账:静默提示不逐字复述刚说过的询问
this.floor.speak(['voice/sys_continue_ask'], {
onDone: () => this.floor.yieldTurn(this._reopenWp()),
});
}
/** 断点续玩:恢复 phase+node+timeline+全部数值;局面复述(runs 统一 +1 口径)。 */
_resume(s) {
if (!s) { this.newNight(); return; }
this.saved = null;
this.runs = (s.runs || 0) + 1;
this.trust = s.trust | 0;
this.rev1 = !!(s.flags && s.flags.rev1);
this.rev2 = !!(s.flags && s.flags.rev2);
this.rev3 = !!(s.flags && s.flags.rev3);
this.p7 = s.p7 || null;
this.p9told = !!s.p9told;
this.taught = !!s.taught;
this.act = s.act || 1;
this.endings = { ...(s.endings || {}) };
this.lastAnnouncedTier = s.lastTier || tierOf(this.trust); // 恢复不重播跨档句(账目已播过)
this.goalMet = !!s.goalMet || this.ladder.stage() >= 1; // 阶梯跨局持久,不再触发教学
if (s.goalMet && this.ladder.stage() < 1) this.ladder.onGoal(); // 存档已有首个目标:阶段同步不回退
this.turnTaught = !!s.turnTaught;
this.skipHintShown = !!s.skipHintShown;
this._c2KeyNext = s.c2KeyNext || null;
this.leaving = false;
this.floor.interrupt();
this._cancelRest();
this._resumeGuard = true; // F4:恢复链首句 critical(播完即清)
const phase = s.phase;
if (phase === 'ENDING' && s.ending && ENDINGS[s.ending]) {
this._resumeGuard = false; // 结局链首句本身 critical,不叠加
this.enterEnding(s.ending);
} else if (phase === 'MEMORY' && s.memSet && MEMORY_SETS[s.memSet]) {
// 中断复述含时空位:时位句先行 → 重播当前回忆句起链(§6.4,≤5s 可玩)
const set = MEMORY_SETS[s.memSet];
this.memSet = s.memSet;
this.memRet = s.memRet;
this.memIdx = Math.max(0, set.voices.indexOf(['voice', s.node].join('/')));
this._memFirstBroadcast = false;
this._memChainIds = set.voices;
this.phase = 'MEMORY';
this.timeline = set.timeline;
this.nodeId = s.node;
this.audio.play('bgm/memory', { fadeMs: 800 });
this._applyAmbMemory();
// 恢复链:prog_m(首句 critical,F4)→ prog_mem(时位句)→ 当前回忆句起链
this.floor.speak([PROG_VOICES[this.act]], {
critical: true,
onDone: () => {
this.floor.speak([PROG_MEM_VOICES[set.label]], {
onDone: () => this._playMemoryChain(this.memIdx),
});
},
});
} else if (phase === 'CALM') {
this.phase = 'CALM';
this._calmN = Math.min(2, this.act);
this.audio.play('bgm/storm');
this._applyAmbNow();
this._armRest(); // 续走幕间(账目句已播不重播)
} else {
this.phase = phase === 'INTRO' ? 'INTRO' : 'WATCH';
this.audio.play('bgm/storm');
this._applyAmbNow();
if (this.phase === 'WATCH' && (s.node === 'c1_tune' || s.node === 'c2_back' || s.node === 'c3_eye')) {
this._callOn = true;
this._startLoop('amb_engine', 0.4);
}
this.playNode(NODES[s.node] ? s.node : 'c1_tune');
}
}
// ---------------------------------------------------------------- 节点链驱动(floor 范式)
playNode(id) {
const node = NODES[id];
if (!node) {
console.error('[KeeperGame] 未知节点:', id);
return;
}
this.nodeId = id;
this.choiceArmed = false;
this.awaitingRestart = false;
// 注意不清 pendingChoice:旁白链中滑动的缓存要跨 playNode(跳去选项句)
// 保留到 _armChoice 生效;choose 与误触兜底负责清。
if (id === 'c1_diary' || id === 'c1_focuse' || id === 'c5_patcho' || id === 'c5_suito') {
this._endCall(); // 各幕通话收线(engine 停)
}
if (node.dyn === 'p9prompt') node.prompt = this.truth >= 3 ? 'voice/p9a' : 'voice/p9b';
this.floor.interrupt(); // 干净起步:杀旧链、摘旧等待点、停等待声
if (node.danger) {
// 危机拍:danger 震 + sfx 前置渐强(烘进文件)2s,期间 floor=BUSY(busy 回执)
vibrate('danger');
if (node.sfx) this.audio.play(node.sfx);
const token = ++this.seqToken;
setTimeout(() => {
if (token !== this.seqToken) return;
this._speakNode(node);
}, node.preMs || DANGER_LEAD_MS);
return;
}
this._speakNode(node);
}
/**
* 节点语音链:链头/链尾附加(教学)→ floor.speak → 链完交出对应等待点。
* 按 critical 拆分段落顺序播(floor 的 critical 是整链一刀切,句级保护靠拆链)。
*/
_speakNode(node) {
const id = this.nodeId;
let seq = this._nodeVoices(id);
if (this._teachHead) { seq = [this._teachHead, ...seq]; this._teachHead = null; } // 「对。」
if (node.prompt) seq = [...seq, node.prompt]; // P 节点:链 = 选项句一句
// 教学句挂载点(原则 2/原则 11,全局一次):
// 「轮到你」状态声首现挂阿澈开口(amo_a)链尾;sys_skip 挂 amo_b(radio 先例)
if (id === 'amo_a' && !this.turnTaught) {
this.turnTaught = true;
seq = [...seq, 'voice/sys_turn_teach'];
}
if (id === 'amo_b' && !this.skipHintShown) {
this.skipHintShown = true;
seq = [...seq, 'voice/sys_skip'];
}
const teach = this._pullTeach(); // 解锁教学句挂链尾(§4.3 阶梯)
if (teach) seq = [...seq, teach];
// danger 节点的 sfx 已在 playNode 前置窗播过(渐强烘进),链起播不重播
if (node.sfx && !node.danger) this.audio.play(node.sfx);
if (node.sfxIdle) this._playIdleGated(node.sfxIdle); // 信息音 idle 门(R1)
const parts = [];
if (node.prompt) {
// 抉择节点:整条链 = 选项句,整句 critical(tap/滑动=busy,盲玩公平)
markHintSaid(node.prompt); // 台账:选项句 30s 内不做逐字被动复述(原则 9)
parts.push({ ids: seq, critical: true });
} else if (seq.length) {
if (this._resumeGuard) {
this._resumeGuard = false; // F4:恢复链首句 critical,播完即清
parts.push({ ids: [seq[0]], critical: true });
seq = seq.slice(1);
}
if (seq.length && this._tailLocked(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 runParts = (k) => {
if (k >= parts.length) { this._onNodeDone(node); return; }
this.floor.speak(parts[k].ids, {
critical: !!parts[k].critical,
onDone: () => runParts(k + 1),
});
};
runParts(0);
}
/** 节点基础语音链。 */
_nodeVoices(id) {
const node = NODES[id];
if (!node) return [];
return [...node.voices];
}
/**
* 尾句保护(仅限 auto 过场链,abyss 口径):auto 链尾在「next 是 @x 过场 /
* 直入抉择」时恒 critical——c3_dark(灯灭差分)、插叙前置链尾等过场门不可跳。
* 非 auto 叙事节点的尾句不做首播 critical——会把「滑动打断并执行 / 上滑
* 我在哪」一并挡成 busy(§4.1 宪法);叙事尾句跳过的代价只是提前进等待点,
* 由三档提示兜底复述(「先听完再选」由 pendingChoice 承接)。
*/
_tailLocked(node) {
if (!node.auto) return false;
if (node.next == null || this._isTransition(node.next)) return true;
const nx = NODES[node.next];
return !!(nx && nx.prompt != null);
}
/** 手势解锁教学句出队(挂在当前链尾,先说一句马上用)。 */
_pullTeach() {
const t = this._teachTail;
this._teachTail = null;
return t;
}
/** '@x' 虚拟目标 = 过场转移/条件解析(§3.2 树形不变,差分全在节点内选播)。 */
_isTransition(target) {
return typeof target === 'string' && target.startsWith('@');
}
/**
* 节点链自然播完的公共收尾(floor 保证跳句/打断不会吃掉这次交接):
* 抉择节点 → 缓存生效或 choice 等待点;auto/@x 过场 → 自动衔接;
* 其余 → continue 等待点(链末单击继续,21 附录 C §5)。
*/
_onNodeDone(node) {
if (node.prompt != null && node.prompt) {
this._armChoice(node);
return;
}
if (node.next == null) return;
if (this.nodeId === 'c2_key' && this._c2KeyNext) {
const nx = this._c2KeyNext;
this._c2KeyNext = null;
this.advance(nx); // 插叙2 兜底回返链:c2_key → c4_trueo(p4 左语境)
return;
}
if (node.auto || this._isTransition(node.next)) {
this.advance(node.next);
return;
}
this.floor.yieldTurn(this._continueWp(node)); // 链末等待单击(tap=继续)
}
/** 抉择武装:W2 契约(播完当刻即可选,零插播);旁白期缓存的滑动此刻生效。 */
_armChoice(node) {
this.choiceArmed = true;
if (this.pendingChoice) {
const g = this.pendingChoice;
this.pendingChoice = null;
this.choose(g); // 缓存的抉择立即生效(不丢、不重复)
return;
}
this.floor.yieldTurn(this._choiceWp(node));
}
/** 链完转移:'@x' 虚拟目标 = 过场自动衔接 / 条件解析;普通节点 = 播下一节点。 */
advance(target) {
switch (target) {
case '@c1': this.enterWatch('c1_tune'); return;
case '@c2': this.enterWatch('c2_back'); return;
case '@c3': this.enterWatch('c3_eye'); return;
case '@calm1': this.enterCalm(1); return;
case '@calm2': this.enterCalm(2); return;
case '@mem1': this._enterMemory('g1', 'c1_diary'); return;
case '@mem2': this._enterMemory('f2', 'c2_key'); return;
case '@mem3': this._enterMemory('c3', 'c6_reade'); return;
case '@c4true':
if (this.rev2) {
this.playNode('c4_trueo'); // 插叙2 已完成:直进
} else {
this.rev2 = true; // 兜底插叙2 播出即授予(truth+1,层2 钥匙)
this._c2KeyNext = 'c4_trueo'; // 兜底回返链:c2_key → c4_trueo(§3.2 树形)
this._enterMemory('f2', 'c2_key');
}
return;
case '@crisis1': this.playNode('c2_crack'); return;
case '@c3eye':
if (this.rev2) this.playNode('c3_page');
else this.playNode('m6_seal'); // p6 封锁:不摆假选项(abyss g9_dry 先例)
return;
case '@p7left':
this.playNode(this.rev2 ? 'c7_go' : 'c7_go2'); // 发条知识差分(rev_f4 兑现)
return;
case '@p8':
this.playNode(this.p7 === 'boat' ? 'p8_boat' : 'p8_reef');
return;
case '@dark':
if (this.rev2) this.advance('@signoff');
else this.playNode('c3_dark'); // 灯灭过一刻钟(复刻祖父之夜,首句 critical)
return;
case '@c9':
this.playNode(this.trust >= 3 ? 'c9_land' : 'c9_found');
return;
case '@signoff':
this._endCall();
this.playNode('g9_in');
return;
case '@p9': this.playNode('p9'); return;
case '@ending': this.enterEnding(); return;
default: this.playNode(this.resolve(target));
}
}
/** 抉择目标解析(跨幕状态只有标量,无组合节点)。 */
resolve(target) {
if (target === '@p9left') return this.truth >= 3 ? 'd9_tell_a' : 'd9_tell_b';
return target;
}
/** 抉择:计分(§4.2)→ 存档 → 首目标解锁上滑(教学句挂下一段链头/尾)→ 进结果节点。 */
choose(g) {
const node = NODES[this.nodeId];
if (!node || node.prompt == null || !this.choiceArmed) return;
this.choiceArmed = false;
this.pendingChoice = null;
this.audio.play('sfx/ui_confirm');
this._applyChoice(this.nodeId, g);
this._save(); // 写入时机:每个抉择结算后
// 完成第一个小目标 → 解锁上滑(§4.3 阶段 1):
// 「对。」挂下一段链头(原则 11 肯定 ≤4 字),教学句挂链尾(一次只教一个)
if (!this.goalMet) {
this.goalMet = true;
const got = this.ladder.onGoal();
if (got) {
this._teachHead = 'voice/sys_praise';
this._teachTail = this.ladder.teachLine(got); // 已带 voice/ 前缀,不拼
}
}
const target = g === 'left' ? node.left : node.right;
this.advance(this._isTransition(target) ? target : this.resolve(target));
}
_applyChoice(nodeId, g) {
const L = g === 'left';
switch (nodeId) {
case 'p1':
if (L) this.trust += 1; // 接住人:首个正反馈
break;
case 'p2':
if (L) this.rev1 = true; // 插叙1(truth+1)
break;
case 'p3':
if (L) { this.trust += 1; this.rev2 = true; } // 插叙2 主触发
break;
case 'p4':
if (L) this.trust += 1;
// rev2 不在这里置位:兜底插叙2 播出即授予(advance '@c4true'),
// 提前置位会让「插叙2 未触发 ? 兜底 : 直进」的差分永远走直进(剧本 F 验出)
break;
case 'p5':
if (L) this.trust += 1;
break;
case 'p6':
if (L) this.rev3 = true; // 插叙3(truth+1,前置 rev2 由 m6_seal 保证)
break;
case 'p7':
this.p7 = L ? 'boat' : 'lamp'; // 核心宿命抉择:不动 trust
break;
case 'p8_boat':
case 'p8_reef':
break; // 双活路:差分在反应句与尾声心境
case 'p9':
if (L) this.p9told = true; // 真相出口;不动 trust
break;
default:
break;
}
}
// ---------------------------------------------------------------- 等待点(§4.4 附录 A 配置)
_introWp() {
return new WaitPoint({
id: 'intro',
expected: ['tap'],
full: null, // 指令句(sys_first_tap)刚说过:静默第一档直接短句(§4.10)
short: 'voice/sys_first_tap_short',
silenceMs: SILENCE.intro,
onDemo: () => {
// 迷茫第 3 级:替玩家点一下(直接进开场链),话轮在链末交回
this.floor.speak(['voice/sys_demo_continue'], { onDone: () => this._afterFirstTap() });
},
}, { 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: () => {
// 演示继续:替玩家走一步(进下一节点),话轮在新链末交回
this.floor.speak(['voice/sys_demo_continue'], {
onDone: () => {
if (node && node.next != null && NODES[node.next]) this.advance(node.next);
else this.floor.yieldTurn(this._continueWp(node));
},
});
},
}, { floor: this.floor, confusion: this.confusion });
}
_choiceWp(node) {
return new WaitPoint({
id: 'choice',
expected: ['left', 'right'],
full: node.prompt, // 主动重听给完整选项;被动提示因台账自动降档(原则 9)
short: 'voice/sys_hint_choice_short',
restrictLine: 'voice/sys_only_choice',
silenceMs: SILENCE.choice,
onDemo: () => {
// 演示不替玩家做有后果的抉择:复述两个方向,交回(21 附录 C §5 L3)
this.floor.speak(['voice/sys_demo_choice'], {
onDone: () => this.floor.yieldTurn(this._choiceWp(node)),
});
},
}, { floor: this.floor, confusion: this.confusion });
}
_settleWp() {
return new WaitPoint({
id: 'settle',
expected: ['tap'],
full: 'voice/sys_hint_again_full',
short: 'voice/sys_hint_continue_short',
silenceMs: SILENCE.settle,
onDemo: () => this.floor.chime(), // 21 附录 C §5:L3 只给提示音
}, { floor: this.floor, confusion: this.confusion });
}
_reopenWp() {
return new WaitPoint({
id: 'reopen',
expected: ['tap', 'right'],
full: 'voice/sys_continue_ask',
short: 'voice/sys_hint_continue_short',
silenceMs: SILENCE.reopen,
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/sys_hint_menu_short',
silenceMs: SILENCE.menu,
onDemo: () => this.floor.chime(),
}, { floor: this.floor, confusion: this.confusion });
}
// ---------------------------------------------------------------- 菜单(00 号 §5.4:左右滑切换、单击确认)
openMenu() {
this.returnPhase = this.phase;
this.returnNode = this.nodeId;
// MEMORY 记忆点已由 audio.play 钩子实时维护(memIdx = 当前回忆句)
this.phase = 'MENU';
this.menuIdx = 0;
this._cancelRest();
this.floor.interrupt();
this.audio.play('sfx/ui_menu');
// BGM 不切:MENU 复用当前曲低响度语义(storm 全夜常驻;MEMORY 中开菜单
// memory 曲继续,回返照切——abyss menu 先例)
this.floor.speak(['voice/sys_menu', MENU_ITEMS[0].voice], {
onDone: () => this.floor.yieldTurn(this._menuWp()),
});
}
_execMenuItem(id) {
if (id === 'resume') {
this.closeMenu();
} else if (id === 'restart') {
this.newNight();
} else if (id === '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 标记 */ }
},
});
}
}
closeMenu() {
this.floor.interrupt(); // 摘掉菜单等待点、杀残留报名链
const back = this.returnPhase || 'INTRO';
this.phase = back;
this.audio.play('sfx/ui_confirm');
if (back === 'MEMORY') {
this._replayMemoryFrom(); // 重播当前回忆节点(底噪仍 vinyl,timeline 不丢,§6.4)
} else if (back === 'CALM') {
this._armRest(); // 幕间续走(账目句已播不重播)
} else if (back === 'ENDING') {
this._playEndingChain(); // 重播结局链(局面复述)
} else if (back === 'ASK') {
this.floor.speak(['voice/sys_continue_ask'], {
onDone: () => this.floor.yieldTurn(this._reopenWp()),
});
} else {
this.playNode(this.returnNode); // INTRO/WATCH:重播当前节点(局面复述)
}
}
// ---------------------------------------------------------------- 过渡音 idle 门(§6.6 R1)
/** 过渡音(memory_in/out、static_burst):语音空闲即播,在播则丢弃(简化口径,
* 调用点全在链入口前;旧行为的句间补发依赖游戏层句级钩子,floor 时代无此钩子)。 */
_playIdleGated(id) {
if (!this.audio.isVoicePlaying()) this.audio.play(id);
}
// ---------------------------------------------------------------- 底噪循环(游戏层,§6.1/§6.2)
_loopEvent(id, on) {
this._ambLog.push({ id, on, t: performance.now() });
}
_startLoop(key, vol) {
if (this._loops[key]) return;
const ctx = this.audio.ctx;
if (!ctx) return;
this.audio._load(`sfx/${key}`).then((buf) => {
if (!buf || this._loops[key]) return;
const src = ctx.createBufferSource();
src.buffer = buf;
src.loop = true;
const gain = ctx.createGain();
gain.gain.value = vol;
src.connect(gain);
gain.connect(this.audio._sfxBus);
src.start();
this._loops[key] = { src, gain };
this._loopEvent(key, true);
}).catch(() => { /* 资产缺失降级:玩法不受阻 */ });
}
_stopLoop(key) {
const h = this._loops[key];
if (!h) return;
try { h.src.stop(); } catch (_) { /* 已停止 */ }
this._loops[key] = null;
this._loopEvent(key, false);
}
_stopAllLoops() {
for (const key of Object.keys(this._loops)) this._stopLoop(key);
}
_setLoopVol(key, vol) {
const h = this._loops[key];
if (!h) return;
try {
h.gain.gain.cancelScheduledValues(0);
h.gain.gain.linearRampToValueAtTime(vol, this.audio.ctx.currentTime + 0.4);
} catch (_) { h.gain.gain.value = vol; }
}
/** 现在签名:storm(分幕 gain)+ room 常驻;vinyl/engine 停(互斥硬约束 §6.2)。 */
_applyAmbNow() {
this._stopLoop('amb_vinyl');
this._startLoop('amb_storm', ACT_STORM_GAIN[this.act] || 0.5);
this._startLoop('amb_room', 0.4);
this._syncStormGain();
this._syncAmbDuck();
}
/** 回忆签名:vinyl 唯一底噪(storm/room 双双淡出停,零重叠断言位 §9)。 */
_applyAmbMemory() {
this._stopLoop('amb_storm');
this._stopLoop('amb_room');
this._startLoop('amb_vinyl', 0.55);
}
_syncStormGain() {
if (this._loops.amb_storm) {
this._setLoopVol('amb_storm', (ACT_STORM_GAIN[this.act] || 0.5)
* (this._callOn && this._duckVoice ? 0.63 : 1)); // 通话期风暴让位 -4dB(§6.6 R3)
}
}
/** 通话期 duck:engine 在 voice 播时 -6dB;storm 同步 -4dB(§6.6 R3,链级)。 */
_syncAmbDuck() {
this._duckVoice = this.audio.isVoicePlaying() || this.floor.speaking;
if (this._loops.amb_engine) {
this._setLoopVol('amb_engine', this._duckVoice ? 0.2 : 0.4);
}
this._syncStormGain();
}
// ---------------------------------------------------------------- 存档(§8)
/** data 结构照 §8.1 + 插叙恢复扩展字段;summary 字符串必带(大厅卡片用)。 */
_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 {
summary = `第${this.act}幕·${ACT_NAMES[this.act]} · 信任${TIER_SUMMARY[tierOf(this.trust)]} · 结局${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,
act: this.act,
timeline: this.timeline,
trust: this.trust,
flags: { rev1: this.rev1, rev2: this.rev2, rev3: this.rev3 },
p7: this.p7,
p9told: this.p9told,
taught: this.taught,
endings: { ...this.endings },
runs: this.runs,
lastTier: this.lastAnnouncedTier,
memSet: this.memSet,
memRet: this.memRet,
c2KeyNext: this._c2KeyNext || null,
ending: this.endingId,
goalMet: this.goalMet,
turnTaught: this.turnTaught,
skipHintShown: this.skipHintShown,
};
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: 'keeper', 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 KeeperGame;