Files
SoundGame/web/keeper/game.js
T
xsl 4b39ae2e49 feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官
四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付:
- cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档
- teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0
- keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4
- bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行

公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、
gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00)

验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿
遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
2026-09-14 05:48:13 +08:00

1559 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《守塔人》——孤岛灯塔叙事 RPG(docs/design/21-keeper.md)。
*
* 风暴夜,白角灯塔的电台里一声比一声近的求救,牵出你家三代守塔人的秘密。
* 声命线范式(radio/abyss 同骨架):节点链 onComplete 串行 + seqToken 打断作废 +
* pendingChoice 缓存;9 个抉择点、4 结局、跨幕双轻量标量(trust 信任 / truth 真相)。
*
* 6 宏状态(§2.1):INTRO / WATCH / MEMORY / CALM / ENDING + MENU 覆盖层
* (另加 ASK = 有存档进游戏的「继续上次?」询问态,属 INTRO 前置瞬态)。
*
* 本作核心差异层 = 时空双线(§6):现在(风暴+amb_storm/amb_room+bgm/storm)/
* 回忆(老唱片 amb_vinyl+bgm/memory)全靠音色+底噪签名区分;插叙 = 节点链外显式
* MEMORY 宏状态(auto 链 ≤6 句,锚入/锚出句首播不可跳,链末自动回返现在线);
* 不新增 guide earcon(统一 8 音词表纪律),时空签名住内容层。
*
* 手势(§2.3,6+2 全覆盖零豁免):
* - tap :播放中=跳过本句(p* / 锚句首播 / 交接尾句 / 恢复首句锁不可跳);
* 链末=继续;ENDING 结算态=再来一夜(新风暴夜)
* - doubletap :重听当前节点台词(含抉择提示);MEMORY=重听当前回忆节点起链;
* ENDING=重听结局链;MENU=确认执行(§5.2 模态约定覆盖重听)
* - left / right :WATCH 抉择(p* 播放中缓存 pendingChoice);其余态无效 →
* error 震 + gd_invalid(onceKey 同局限次,零静默)
* - up :状态链(prog_m 系 → prog_mem 系 → tr 系 → tru 系 → sys_help,G6 顺序),
* 随后重播当前节点(局面复述)
* - down :模态菜单(单击循环选项 / 双击确认 / 上滑帮助 / 下滑关闭)
* - longpress :引擎接管旁白 1.7×(叙事类不豁免,12 号 §2.2;游戏不收此手势)
*
* 链驱动与继承 radio 修复(§7.4 照抄不重设计):seqToken 作废在途链、菜单报名
* _menuQueue 排队零截断、F1 无效手势拆看门狗后立即重挂、F4 续玩首句跳句锁、
* G1 帮助链去重(同等待点第 2 次上滑=beat 轻震+nudge earcon)、G2 tr_thin 附
* warning 震、G4 不可跳句空点补 beat 轻震、G5 首播必完整/复播可即掐、G6 状态在前
* 帮助垫后。新增本作层:800ms 结算死窗(e* 链/c3_dark/插叙链入口)、过渡音
* idle 门(memory_in/out、static_burst 只在语音空闲放行,否则排队句间补发,
* §6.6 R1)、底噪互斥(vinyl 与 storm/room 零重叠)、危机拍 danger 节点
* (前置 2s sfx 渐强烘进文件 + danger 签名/震同窗)。
*
* 资产缺失兜底:引擎 console.error 静默跳过且不回调 onComplete;单句语音看门狗
* 30s 且 isVoicePlaying()===false 才自动续链(radio N1 先例)——屏蔽任意 voice
* 资产仍可一路走到结局,不卡死。
*
* 确定性:底噪/引擎为游戏层 buffer.loop 持句柄(无重触发定时器),测试与转写可复现。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
// ---------------------------------------------------------------- 节点表
/** 叙事节点。voices 顺序播放;prompt 非空 = 抉择节点(播完即选,W2 契约)。 */
function N(voices, opts = {}) {
return {
voices, prompt: null, left: null, right: null, next: null,
sfx: null, sfxIdle: null, auto: false, danger: false, preMs: 0,
...opts,
};
}
/** 抉择节点:本体无台词,链尾接 p* 提示句(PROMPT_RE 命中 → 不可跳)。 */
function P(prompt, left, right) {
return {
voices: [], prompt, left, right, next: null,
sfx: null, sfxIdle: null, auto: false, danger: false, preMs: 0,
};
}
/**
* 全部节点(台词全文见 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 分支局部化)。
*/
const NODES = {
// ---- INTRO 开场链(塔名口播 + 一句规矩 + 今晚目标;§10 首输入 ~30s 链末单击)
b_open: N(['voice/b_open'], { auto: true, next: 'b_rain' }),
b_rain: N(['voice/b_rain'], { sfx: 'sfx/foghorn', auto: true, next: 'b_rule' }),
b_rule: N(['voice/b_rule'], { auto: true, next: 'b_goal' }),
b_goal: N(['voice/b_goal'], { next: '@c1' }), // 链完单击 → C1(雾角承诺音=b_rain 兑现点①)
// ---- 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' }), // 复刻祖父之夜(800ms 死窗)
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):播放中单击不可跳(盲玩公平)。 */
const PROMPT_RE = /^voice\/p\d/;
/** 插叙锚句(rev_g1/g4、rev_f1/f5、rev_c1/c5):首播不可跳(G5 首播必完整)。 */
const ANCHOR_RE = /^voice\/rev_(g[14]|f[15]|c[15])$/;
/** 单句语音看门狗:超时且引擎确认 voice 未在播 = 资产缺失才续链(radio N1 先例)。 */
const VOICE_WATCHDOG_MS = 30000;
// 向导层看门狗(§7.2;full 档 say 由 guide 播,standard 档 onEscalate 游戏层补播)
const GUIDE_WATCHDOG_AT = [25000, 50000]; // full:叙事链末 / ENDING 结算等待
const IDLE_HINT_AT = [13000, 30000]; // standard:同 storyline P2-3 先例
const CHOICE_HINT_AT = [25000, 50000]; // 抉择点:两级都重播当前 p* 提示句本身
// 无进展轨(游戏层计时,§7.2 双轨之②:乱按 96s 必被抢救;无效手势不复位)
const PROG_TRACK_AT = [60000, 120000];
const CALM_REST_MS = 2500; // 幕间喘息(自动链 ≤12s:foghorn+lull+档句+喘息)
const DANGER_LEAD_MS = 2000; // 危机拍前置预警窗(sfx 渐强烘进文件 + danger 签名同窗)
const SETTLE_GRACE_MS = 800; // 结算链死窗:e* 链 / c3_dark / 插叙链入口(§2.2)
/** 信任档位(§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);
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
this.phase = 'INTRO'; // INTRO|WATCH|MEMORY|CALM|ENDING|MENU|ASK
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.voicePlaying = false;
this.currentVoiceId = null; // 链上当前句 id
this.promptPlaying = false; // 当前句是抉择提示 p*
this.choiceArmed = false;
this.pendingChoice = null;
this.awaitingRestart = false;
this.leaving = false; // 菜单「保存并回大厅」已确认
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 全局一次(首插叙前自动插播)
// 插叙运行时(§6.3)
this.memSet = null; // 'g1' | 'f2' | 'c3'
this.memIdx = 0; // 当前回忆句下标(打断/菜单/存档恢复用)
this.memRet = null; // 回返 now 线节点
this._memFirstBroadcast = false; // 本段插叙是否首播(锚句锁只锁首播,G5)
this.lastAnnouncedTier = 'thin'; // CALM 跨档播报基准(开局 trust=0 → thin,§9 剧本 A tr_half 跨档依据)
this.sysSkipShown = false;
this.saved = null;
// 结算死窗(§2.2):e* 结局链 / c3_dark / 插叙链入口
this._settleGraceUntil = 0;
// 菜单报名队列(radio 实现修复 #3:游戏侧排队,不截断当前报名)
this._menuQueue = [];
this._helpSeen = new Set(); // G1:已全量播过 sys_help 口令的等待点
// F4 断点续玩跳句锁:_resume 置位,恢复链首句起播时落为该句 id,播完即清
this._resumeGuard = false;
this._skipLockId = null;
// 过渡音 idle 门(§6.6 R1):语音在播则排队,句间补发
this._pendingSfx = [];
// 底噪/引擎循环句柄(游戏层 buffer.loop,radio _ringSrc 同款;无重触发定时器)
this._loops = {}; // key -> { src, gain }
this._ambLog = []; // { id, on, t } 起停流水(测试断言底噪互斥/时序)
this._callOn = false; // 阿澈通话期(amb_engine 常驻、storm 让位 -4dB,§6.6 R3)
this._duckVoice = false; // voice 在播 duck 标志(engine -6dB)
// 计时器句柄(菜单/重开时统一清理)
this.restTimer = null;
this._progTimer = null; // 无进展轨定时器
this._progEpoch = 0; // 等待点纪元:任何真实进展 +1(作废在途 prog 计时)
this._noProgThisWait = false; // 双轨去重:本等待点已播过 gd_* 同义级 → prog 轨跳过
}
/** truth = 插叙完成数(§4.1)。 */
get truth() {
return (this.rev1 ? 1 : 0) + (this.rev2 ? 1 : 0) + (this.rev3 ? 1 : 0);
}
// ---------------------------------------------------------------- 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.newNight(); return; }
let settled = false;
const goFresh = () => { if (settled) return; settled = true; this.newNight(); };
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._stopAllLoops(); // 不得经 30s 语音看门狗"资产缺失"路径复活底噪循环
this._resumeGuard = false;
this._skipLockId = null;
if (this.restTimer) { clearTimeout(this.restTimer); this.restTimer = null; }
this._progDisarm();
this.audio.stopAll();
}
onGesture(g) {
this.guide.disarm(); // 任何手势复位看门狗(guide=off 时 no-op)
// 结算死窗(§2.2):结局链/c3_dark/插叙链开播后 800ms 内手势一律吞掉
if (performance.now() < this._settleGraceUntil) return;
switch (this.phase) {
case 'ASK': this._onAskGesture(g); break;
case 'MENU': this._onMenuGesture(g); break;
case 'CALM': this._onCalmGesture(g); break;
case 'ENDING': this._onEndingGesture(g); break;
default: this._onStoryGesture(g); break; // INTRO / WATCH / MEMORY
}
}
// ---------------------------------------------------------------- 手势:ASK
_onAskGesture(g) {
const go = g === 'tap' || g === 'right'; // 推进手势:局面即将改变,不重挂
if (g === 'tap') this._resume(this.saved);
else if (g === 'right') this.newNight();
else if (g === 'longpress' || g === 'longpressend' || g === 'tapcancel') { /* 引擎快进接管 */ }
else this.guide.error({ voice: 'gd_invalid', onceKey: 'keeper_invalid' });
// F1:非推进手势拆掉 ASK 空闲看门狗后立即重挂(否则询问态无限静默)
if (!go && this.phase === 'ASK' && !this.voicePlaying) this._armIdleWatchdog('ASK');
}
// ---------------------------------------------------------------- 手势:INTRO / WATCH / MEMORY
_onStoryGesture(g) {
const node = NODES[this.nodeId];
if (this.phase === 'MEMORY') { this._onMemoryGesture(g); return; }
if (!node) return;
switch (g) {
case 'tap':
if (this.voicePlaying) {
// 播放中单击 = 跳过本句(链继续);不可跳的四类:
// ① 抉择提示 p*(PROMPT_RE 门);② 交接尾句(@x 转移/直入抉择前的末句);
// ③ F4 续玩锁;④(MEMORY 分支另加锚句锁)。
if (!this.promptPlaying && !this._isTailSentence()
&& this.currentVoiceId !== this._skipLockId) {
this.audio.skipVoice();
} else {
vibrate('beat'); // G4:不可跳句空点补 beat 轻震(可区分于真确认的 [20])
}
} else if (node.prompt != null && this.choiceArmed) {
this.armChoiceHint(); // 抉择等待态空点 = 重挂兜底(25s 计时重置)
} else if (node.next != null) {
this.advance(node.next);
}
break;
case 'doubletap':
this.replayNode();
break;
case 'left':
case 'right': {
if (node.prompt == null || !node.prompt) {
// 无效手势零静默(§2.3):error 震 + full 档 gd_invalid(onceKey 同局一次);
// F1:disarm 已拆链末看门狗,处理完立即重挂
this.guide.error({ voice: 'gd_invalid', onceKey: 'keeper_invalid' });
if (this._inStoryWait()) this._armIdleWatchdog(this.phase);
return;
}
if (this.choiceArmed) this.choose(g);
else if (this.voicePlaying) this.pendingChoice = g; // p* 播放中缓存(盲玩公平)
break;
}
case 'up':
this._playHelpAndResume();
break;
case 'down':
this.openMenu();
break;
default:
// longpress/longpressend/tapcancel:引擎快进接管,游戏不处理;
// F1 同款:链末等待态下它们同样拆掉了看门狗,立即重挂
if (this._inStoryWait()) this._armIdleWatchdog(this.phase);
break;
}
}
/** MEMORY 手势(§2.3 MEMORY 行):单击跳句(锚句首播不可跳)、双击重听当前回忆节点、
* 上滑时位句先行、下滑菜单(停插叙链,关闭重播当前回忆节点,底噪仍 vinyl)。 */
_onMemoryGesture(g) {
switch (g) {
case 'tap':
if (this.voicePlaying) {
const anchorLocked = this._memFirstBroadcast && this.currentVoiceId
&& ANCHOR_RE.test(this.currentVoiceId);
if (!anchorLocked && this.currentVoiceId !== this._skipLockId) {
this.audio.skipVoice(); // 插叙中段句可跳(G5 首播保护只罩锚句)
} else {
vibrate('beat'); // G4:锚句首播空点 beat 轻震
}
}
break;
case 'doubletap':
this._replayMemoryNode();
break;
case 'up': {
this.seqToken++;
this.audio.stopVoice();
const ids = [];
const set = MEMORY_SETS[this.memSet];
if (set) ids.push(PROG_MEM_VOICES[set.label]); // 时位句先行(§6.4)
const tier = TIER_VOICES[tierOf(this.trust)];
this._tierCue(tier);
ids.push(tier, TRU_VOICES[this.truth]);
this._withHelp(ids, `MEM:${this.memSet}:${this.memIdx}`);
this._playSeq(ids, () => {
this.voicePlaying = false;
this._replayMemoryNode(); // 局面复述:重播当前回忆节点起链
});
break;
}
case 'down':
this.openMenu();
break;
case 'left':
case 'right':
this.guide.error({ voice: 'gd_invalid', onceKey: 'keeper_invalid' });
break;
default:
break; // longpress/longpressend/tapcancel:引擎快进接管
}
}
/** 当前是否处于「链末等待单击」态(F1 重挂判据)。 */
_inStoryWait() {
const node = NODES[this.nodeId];
return !!(node && !this.voicePlaying && node.prompt == null && node.next != null
&& !node.auto && !this._isTransition(node.next));
}
/**
* 交接尾句保护(F5 同款,本作口径按 §修订记录:wait 节点仅护「交接」尾句):
* 当前句是节点末句、且节点把控制权交出(@x 过场转移 / 直入抉择提示)时屏蔽跳句——
* 末句被跳过则关键尾句被抢跑吃掉(g9_knot 的抉择交接、d9_telle 的 @ending 点题)。
* 普通接续节点(c1_tune→amo_a)的单句可跳(§2.3「播放中=跳过本句」)。
*/
_isTailSentence() {
const node = NODES[this.nodeId];
if (!node || !this.voicePlaying || this.promptPlaying) return false;
const seq = this._nodeVoices(this.nodeId);
const tail = node.prompt ? node.prompt : (seq.length ? seq[seq.length - 1] : null);
if (tail == null || this.currentVoiceId !== tail) return false; // 仅护末句
if (this._isTransition(node.next)) return true; // @x 过场转移前
if (node.next == null) return true;
const nx = NODES[node.next];
return !!(nx && nx.prompt != null); // 直入抉择提示前
}
// ---------------------------------------------------------------- 手势:CALM
_onCalmGesture(g) {
switch (g) {
case 'tap':
if (this.voicePlaying) this.audio.skipVoice(); // 跳过本句,自动链继续
break;
case 'doubletap': {
this.seqToken++;
this.audio.stopVoice();
this._calmChain(true); // 重听幕间链(foghorn 重播 + lull + 本幕已播档句)
break;
}
case 'up': {
// 帮助+状态,自动链继续(§2.3 CALM 行):状态链后走喘息 → 下一幕
this.seqToken++;
this.audio.stopVoice();
const ids = [];
ids.push(PROG_VOICES[this.act]);
const tier = TIER_VOICES[tierOf(this.trust)];
this._tierCue(tier);
ids.push(tier, TRU_VOICES[this.truth]);
this._withHelp(ids, 'CALM');
this._playSeq(ids, () => {
this.voicePlaying = false;
this._armRest(); // 已播账目句不重播,自动链继续
});
break;
}
case 'down':
this.openMenu();
break;
case 'left':
case 'right':
this.guide.error({ voice: 'gd_invalid', onceKey: 'keeper_invalid' }); // §2.3 CALM 行
break;
default:
break; // longpress/longpressend/tapcancel:引擎快进接管
}
}
// ---------------------------------------------------------------- 手势:ENDING
_onEndingGesture(g) {
switch (g) {
case 'tap':
if (this.awaitingRestart) this.newNight();
else if (this.voicePlaying) {
if (this.currentVoiceId !== this._skipLockId) this.audio.skipVoice();
else vibrate('beat'); // G4:续玩锁首句空点 beat 轻震
}
break;
case 'doubletap':
this.awaitingRestart = false;
this.seqToken++;
this.audio.stopVoice();
this._playEndingChain(); // 重听结局链(含尾声回响)
break;
case 'up': {
this.seqToken++;
this.audio.stopVoice();
const def = ENDINGS[this.endingId] || ENDINGS.e1;
const tier = TIER_VOICES[tierOf(this.trust)];
this._tierCue(tier);
const ids = this._withHelp([def.voices[0], tier], `END:${this.endingId}`);
this._playSeq(ids, () => {
this.voicePlaying = false;
if (this.phase === 'ENDING') {
this.awaitingRestart = true;
this._armIdleWatchdog('ENDING');
}
});
break;
}
case 'down':
this.openMenu();
break;
case 'left':
case 'right':
this.guide.error({ voice: 'gd_invalid', onceKey: 'keeper_invalid' }); // §2.3 ENDING 行
if (this.awaitingRestart && !this.voicePlaying) this._armIdleWatchdog('ENDING');
break;
default:
if (this.awaitingRestart && !this.voicePlaying) this._armIdleWatchdog('ENDING');
break; // longpress/longpressend/tapcancel:引擎快进接管
}
}
// ---------------------------------------------------------------- 手势:MENU(§5.2 模态约定)
_onMenuGesture(g) {
switch (g) {
case 'tap': {
// 单击循环切换:报名经游戏侧队列接在当前报名之后(不截断,radio 实现修复 #3)
this.menuIdx = (this.menuIdx + 1) % MENU_ITEMS.length;
this._menuQueue.push(MENU_ITEMS[this.menuIdx].voice);
if (!this.voicePlaying) this._drainMenuQueue();
break;
}
case 'doubletap':
this._execMenuItem(MENU_ITEMS[this.menuIdx].id); // 双击确认执行
break;
case 'up': {
// 帮助 + 菜单内设置提示 + 复述当前项(清空报名队列:帮助链顶掉一切)
this._menuQueue = [];
this.seqToken++;
this.audio.stopVoice();
const ids = this._withHelp([], 'MENU');
ids.push('voice/sys_settings', MENU_ITEMS[this.menuIdx].voice);
this._playSeq(ids, () => {
this.voicePlaying = false;
});
break;
}
case 'down':
this.closeMenu(); // 关闭回原状态(重播当前节点 = 局面复述)
break;
case 'left':
case 'right':
this.guide.error({}); // 菜单内左右滑无效:error 震(§2.3 MENU 行,无语音)
break;
default:
break; // longpress/longpressend/tapcancel:引擎快进接管(快进菜单语音)
}
}
_execMenuItem(id) {
this._menuQueue = [];
if (id === 'resume') {
this.closeMenu();
} else if (id === 'restart') {
this.seqToken++;
this.audio.stopVoice();
this.newNight();
} else if (id === 'lobby') {
// 保存并回大厅:确认句播出且存档落盘后跳转(§2.2「确认后跳转前播」)
this.leaving = true;
this.seqToken++;
this.audio.stopVoice();
this._save();
this._playSeq(['voice/sys_menu_lobby'], () => {
this.voicePlaying = false;
try {
if (typeof window !== 'undefined' && window.location) window.location.href = '../';
} catch (_) { /* 无头环境无 location:留档 leaving 标记 */ }
});
}
}
// ---------------------------------------------------------------- 菜单开/关
openMenu() {
this.returnPhase = this.phase;
this.returnNode = this.nodeId;
this.phase = 'MENU';
this.menuIdx = 0;
this._menuQueue = [];
this.seqToken++;
this.audio.stopVoice();
this._cancelRest();
this._progDisarm();
this.audio.play('sfx/ui_menu');
// BGM 不切:MENU 复用当前曲低响度语义(§5.1 表「bgm/storm 低响度」= 塔内同构,
// 本作 storm 全夜常驻;MEMORY 中开菜单 memory 曲继续,回返照切——abyss menu 先例)
this.guide.state('menu');
this._playSeq(['voice/sys_menu', MENU_ITEMS[0].voice], () => {
this.voicePlaying = false;
this._drainMenuQueue();
});
}
_drainMenuQueue() {
if (this.phase !== 'MENU' || this._menuQueue.length === 0) return;
const ids = this._menuQueue.splice(0);
this._playSeq(ids, () => {
this.voicePlaying = false;
this._drainMenuQueue();
});
}
closeMenu() {
this.seqToken++;
this._menuQueue = [];
this.audio.stopVoice();
const back = this.returnPhase || 'INTRO';
this.phase = back;
this.audio.play('sfx/ui_confirm');
this.guide.state(back === 'CALM' || back === 'ENDING' ? 'settle' : 'explore');
if (back === 'MEMORY') {
this._replayMemoryNode(); // 重播当前回忆节点(底噪仍 vinyl,timeline 不丢,§6.4)
} else if (back === 'CALM') {
this._armRest(); // 幕间续走(账目句已播不重播)
} else if (back === 'ENDING') {
this._playEndingChain(); // 重播结局链(局面复述)
} else if (back === 'ASK') {
this._playSeq(['voice/sys_continue_ask'], () => {
this.voicePlaying = false;
this._armIdleWatchdog('ASK');
});
} else {
this.playNode(this.returnNode); // INTRO/WATCH:重播当前节点
}
}
// ---------------------------------------------------------------- 风暴夜流转
/** 新风暴夜:重置跨幕标量(结局收集跨夜保留),回 INTRO(sys_new_night 先行)。 */
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;
this.pendingChoice = null;
this._resumeGuard = false;
this._skipLockId = null;
this._settleGraceUntil = 0;
this._memFirstBroadcast = false;
this._c2KeyNext = null;
this.seqToken++;
this.audio.stopVoice();
this._cancelRest();
this._progDisarm();
this.phase = 'INTRO';
this.audio.play('bgm/storm'); // 从菜单/结局重开为同首不重启
this.guide.state('explore');
this._applyAmbNow(); // 底噪回现在签名(storm 0.5 档 + room,vinyl/engine 停)
this._playSeq(['voice/sys_new_night'], () => this.playNode('b_open'));
}
/** 进幕:act 推进 + amb_storm 分幕 gain + explore 签名。 */
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.guide.state('explore');
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:幕间喘息。foghorn + lull_N +〔跨档才播 tr_*〕→ 喘息 → 下一幕(§修订记录 8)。 */
enterCalm(n) {
this._endCall();
this.phase = 'CALM';
this._calmN = n;
this.guide.state('settle');
this._save(); // 写入时机:每幕 CALM 进入
this._calmChain(false);
}
_calmChain(replay) {
this.seqToken++;
this.audio.stopVoice();
this.audio.play('sfx/foghorn');
const ids = [CALMS[this._calmN].lull];
if (!replay) {
// 跨档被动播报(§4.3:跨档才播;tr_thin 附 warning 震 G2;基准在句实际起播时更新)
const tier = tierOf(this.trust);
if (tier !== this.lastAnnouncedTier) {
this._pendingTier = tier;
ids.push(TIER_VOICES[tier]);
}
} else if (this._announcedTierThisCalm) {
ids.push(TIER_VOICES[this._announcedTierThisCalm]);
}
this._playSeq(ids, () => {
this.voicePlaying = false;
this._armRest();
}, {
onItem: (id) => {
if (this._pendingTier && id === TIER_VOICES[this._pendingTier]) {
this.lastAnnouncedTier = this._pendingTier; // 账目真实:句起播才更新基准
this._announcedTierThisCalm = this._pendingTier;
if (this._pendingTier === 'thin') vibrate('warning'); // G2
this._pendingTier = null;
}
},
});
}
_armRest() {
if (this.restTimer) clearTimeout(this.restTimer);
const token = this.seqToken;
this.restTimer = setTimeout(() => {
this.restTimer = null;
if (this.phase !== 'CALM' || this.seqToken !== token) return; // 已被菜单/手势打断
this._calmN += 1;
this.advance(CALMS[this._calmN - 1].next);
}, CALM_REST_MS);
}
_cancelRest() {
if (this.restTimer) { clearTimeout(this.restTimer); this.restTimer = null; }
}
/** 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.guide.state('settle');
this.audio.play('bgm/dawn', { fadeMs: 1500 }); // d9 结果链播完后淡入
this._save(); // 写入时机:ENDING 进入
this._settleGraceUntil = performance.now() + SETTLE_GRACE_MS; // 结算死窗
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._settleGraceUntil = performance.now() + SETTLE_GRACE_MS; // 结算链死窗(含重播)
this._playSeq(this._endingVoices(), () => {
this.voicePlaying = false;
this.awaitingRestart = true;
this._armIdleWatchdog('ENDING');
this._progressArm();
}, {
onItem: (id) => { if (id === 'voice/ep_shore') this.audio.play('sfx/foghorn'); }, // 雾角兑现点④
});
}
// ---------------------------------------------------------------- 插叙(MEMORY,本作核心差异层 §6)
/** 进入插叙:静默 600ms →〔rev_teach 全局一次〕→ memory_in + bgm/memory + amb 切 vinyl →
* auto 链(锚入→NPC→锚出)→ memory_out + storm 回切 → 回返 now 线(§6.3 时序)。 */
_enterMemory(setId, retNode) {
const set = MEMORY_SETS[setId];
if (!set) { this.playNode(retNode); return; }
this.memSet = setId;
this.memIdx = 0;
this.memRet = retNode;
this._memFirstBroadcast = true;
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.guide.state('explore'); // 时空不换 earcon(§6.2 纪律)
this.audio.play('bgm/memory', { fadeMs: 800 });
this._applyAmbMemory(); // storm/room 淡出 / vinyl 淡入(互斥硬约束)
this._settleGraceUntil = performance.now() + SETTLE_GRACE_MS; // 插叙链入口死窗
const token = ++this.seqToken;
this._pendingSfx = [];
setTimeout(() => {
if (token !== this.seqToken || this.phase !== 'MEMORY') return;
const pre = [];
if (!this.taught) {
this.taught = true;
pre.push('voice/rev_teach'); // 时空签名教学(全局一次,首插叙前,§修订记录 5)
}
const begin = () => {
// 不打 token 比对:teach 链的 _playSeq 已 seqToken++,此处再比会永远中止;
// 中断安全由 teach 链自身的 token 守卫承担(被打断则 onDone 不触发,begin 不会跑)
if (this.phase !== 'MEMORY') return;
this._playIdleGated('sfx/memory_in'); // 唱针落下(idle 门,R1)
this._playMemoryChain(0);
};
if (pre.length) this._playSeq(pre, begin);
else begin();
}, 600); // now 结果句播完 → 静默一拍(§6.3 步 1)
}
/** 插叙链 auto 播放:锚句首播不可跳(G5),中段句可跳;链末不设等待自动回返。 */
_playMemoryChain(fromIdx) {
const set = MEMORY_SETS[this.memSet];
if (!set) return;
const ids = set.voices.slice(fromIdx);
this._playSeq(ids, () => this._exitMemory(), {
onItem: (id) => {
this.currentVoiceId = id;
this.memIdx = set.voices.indexOf(id); // 菜单/存档恢复的记忆点
this.nodeId = id.slice(6);
},
});
}
_replayMemoryNode() {
const set = MEMORY_SETS[this.memSet];
if (!set) return;
this._memFirstBroadcast = false; // 复播可即掐(G5:锁不随复播重新武装)
this.seqToken++;
this.audio.stopVoice();
this._playMemoryChain(this.memIdx);
}
/** 插叙回返:memory_out + bgm/storm 回切 + amb 回现在签名 → 接播 now 线回返节点。 */
_exitMemory() {
const token = this.seqToken;
const done = () => {
if (token !== this.seqToken || this.phase !== 'MEMORY') return;
this.audio.play('bgm/storm', { fadeMs: 800 });
this._applyAmbNow();
if (this._callOn) this._startLoop('amb_engine', 0.4); // 通话期继续(mem 2/3 在通话中段触发)
this.phase = 'WATCH';
this.timeline = 'now';
const ret = this.memRet;
this.memSet = null;
this.playNode(ret); // 回返后 now 句接播(§6.3 步 5)
};
this._playIdleGated('sfx/memory_out'); // 唱针抬起(idle 门,R1)
done();
}
// ---------------------------------------------------------------- 存档续玩
_askContinue(save) {
this.saved = save;
this.phase = 'ASK';
this._playSeq(['voice/sys_continue_ask'], () => {
this.voicePlaying = false;
this._armIdleWatchdog('ASK');
this._progressArm();
});
}
/** 断点续玩:恢复 phase+node+timeline+全部数值;局面复述(§8.1,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._c2KeyNext = s.c2KeyNext || null;
this.leaving = false;
this.seqToken++;
this.audio.stopVoice();
this._resumeGuard = true; // F4:恢复链首句播完才武装跳句
const phase = s.phase;
if (phase === 'ENDING' && s.ending && ENDINGS[s.ending]) {
this.enterEnding(s.ending);
} else if (phase === 'MEMORY' && s.memSet && MEMORY_SETS[s.memSet]) {
// 中断复述含时空位:时位句先行 → 重播当前回忆节点起链(§6.4,≤5s 可玩)
this.memSet = s.memSet;
this.memRet = s.memRet;
const set = MEMORY_SETS[this.memSet];
const idx = Math.max(0, set.voices.indexOf(['voice', s.node].join('/')));
this.memIdx = idx;
this._memFirstBroadcast = false;
this.phase = 'MEMORY';
this.timeline = set.timeline;
this.nodeId = s.node;
this.audio.play('bgm/memory', { fadeMs: 800 });
this._applyAmbMemory();
const ids = [PROG_VOICES[this.act], PROG_MEM_VOICES[set.label], ...set.voices.slice(idx)];
this._playSeq(ids, () => this._exitMemory());
} else if (phase === 'CALM') {
this.phase = 'CALM';
this._calmN = Math.min(2, this.act);
this.guide.state('settle');
this.audio.play('bgm/storm');
this._applyAmbNow();
this._armRest(); // 续走幕间(账目句已播不重播)
} else {
this.phase = phase === 'INTRO' ? 'INTRO' : 'WATCH';
this.guide.state('explore');
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');
}
}
// ---------------------------------------------------------------- 节点链驱动
playNode(id) {
const node = NODES[id];
if (!node) {
console.error('[KeeperGame] 未知节点:', id);
return;
}
this.nodeId = id;
this.choiceArmed = false;
this.pendingChoice = null;
this.awaitingRestart = false;
if (id === 'c1_diary' || id === 'c1_focuse' || id === 'c5_patcho' || id === 'c5_suito') {
this._endCall(); // 各幕通话收线(engine 停)
}
if (id === 'c3_dark') {
this._settleGraceUntil = performance.now() + SETTLE_GRACE_MS; // 灯灭差分死窗
}
let seq = this._nodeVoices(id);
// F8 先例:操作口令 gd_brief 前置开场链最前(仅 full 档)
if (id === 'b_open' && this.guide.level() === 'full') {
seq = ['voice/gd_brief', ...seq];
this._resumeGuard = true; // R2 F2:操作教学首播锁(复用 F4 机制)——
// 首播被跳则四个基础手势绑定漏听(live R1 实测截断),复播不锁(G5)
}
// sys_skip:开场每实例一次(storyline skip_hint 先例)
if (id === 'b_open' && !this.sysSkipShown) {
this.sysSkipShown = true;
seq = [...seq, 'voice/sys_skip'];
}
if (node.dyn === 'p9prompt') node.prompt = this.truth >= 3 ? 'voice/p9a' : 'voice/p9b';
if (node.prompt) seq = [...seq, node.prompt];
if (node.danger) {
// 危机拍:sfx 前置渐强(烘进文件)+ danger 签名/震同窗(L2 前置预警),preMs 后链占用
this.guide.state('danger');
vibrate('danger');
if (node.sfx) this.audio.play(node.sfx);
this.voicePlaying = true;
this.currentVoiceId = null;
this.promptPlaying = false;
const token = ++this.seqToken;
setTimeout(() => {
if (token !== this.seqToken) return;
this._playSeq(this._nodeVoices(id), () => this._onNodeSeqDone(node, this._nodeVoices(id)));
}, node.preMs || 0);
return;
}
if (node.sfx) this.audio.play(node.sfx);
if (node.sfxIdle) this._playIdleGated(node.sfxIdle); // 信息音 idle 门(R1)
this._playSeq(seq, () => this._onNodeSeqDone(node, seq));
}
/** 节点基础语音链。 */
_nodeVoices(id) {
const node = NODES[id];
if (!node) return [];
return [...node.voices];
}
/** 双击重听当前节点台词(含抉择提示);MEMORY 重听当前回忆节点;ENDING 重听结局链。 */
replayNode() {
this.seqToken++;
this.audio.stopVoice();
if (this.phase === 'ENDING') {
this.awaitingRestart = false;
this._playEndingChain();
return;
}
const node = NODES[this.nodeId];
if (!node) return;
this._progressMark(); // 重听 = 进展(§7.2 无进展轨定义)
this.choiceArmed = false;
this.pendingChoice = null;
this.awaitingRestart = false;
const seq = this._nodeVoices(this.nodeId);
if (node.prompt) seq.push(node.prompt);
this._playSeq(seq, () => this._onNodeSeqDone(node, seq));
}
/** '@x' 虚拟目标 = 过场转移/条件解析(§3.2 树形不变,差分全在节点内选播)。 */
_isTransition(target) {
return typeof target === 'string' && target.startsWith('@');
}
/**
* 节点链自然播完的公共收尾:抉择武装 / 自动过场 / 链末等待(radio 同款口径)。
* 自动衔接仅限节点表标注 auto 的段落与全部 @x 转移;WATCH 叙事节点链末等待单击
* (§2.3「链末=继续」)并挂 gd_continue/gd_again 看门狗 + 无进展轨。
*/
_onNodeSeqDone(node, seq) {
this.voicePlaying = false;
if (node.prompt != null && node.prompt) {
this.choiceArmed = true; // W2 契约:播完当刻即可选,零插播
if (this.pendingChoice) {
const g = this.pendingChoice;
this.pendingChoice = null;
this.choose(g); // 缓存的抉择立即生效(不丢、不重复)
} else {
this.armChoiceHint();
}
return;
}
if (node.next == null) return;
if (node.prompt == null && 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._armIdleWatchdog(this.phase); // 链末等待单击(tap=继续)
this._progressArm();
}
/** 链完转移:'@x' 虚拟目标 = 过场自动衔接 / 条件解析;普通节点 = 播下一节点。 */
advance(target) {
this._progressMark(); // 节点推进 = 进展(无进展轨复位)
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'); // 灯灭过一刻钟(复刻祖父之夜)
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) return;
this.choiceArmed = false;
this.audio.play('sfx/ui_confirm');
this._applyChoice(this.nodeId, g);
this._progressMark(); // 抉择结算 = 进展
this._save(); // 写入时机:每个抉择结算后
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;
}
}
// ---------------------------------------------------------------- 帮助 / 复述
/** G2:tr_thin 整句播报附 warning 震——但首幕首查豁免(R2 F1):
* trust=0 在第一幕第一夜是「自然起点」而非警告语义,新手第一次上滑查状态
* 不该被「警告」触感迎接;跨夜/第二幕起恢复 warning(账目句照播一字不改)。 */
_tierCue(tierId) {
if (tierId !== TIER_VOICES.thin) return;
const maiden = this.act === 1 && this.trust === 0 && this.runs <= 1;
vibrate(maiden ? 'confirm' : 'warning');
}
/**
* G1 帮助链去重:同一等待点第 1 次全量口播 sys_help;第 2 次起改「纯音频标记」——
* beat 轻震 + nudge earcon(复用统一 8 音词表,零新增),状态句照播。
* @returns {boolean} true = 本次应全量口播 sys_help
*/
_helpSpokenOnce(waitKey) {
if (this._helpSeen.has(waitKey)) {
vibrate('beat');
if (this.guide.level() !== 'off') this.guide._playEarcon('nudge');
return false;
}
this._helpSeen.add(waitKey);
return true;
}
/** G6:状态在前、帮助垫后。 */
_withHelp(ids, waitKey) {
if (this._helpSpokenOnce(waitKey)) ids.push('voice/sys_help');
return ids;
}
/**
* 上滑:状态链(时位句 → 信任档 → 真相度 → 帮助,G6 顺序,§9 断言锁定),
* 随后重播当前节点(局面复述)。MEMORY 由 _onMemoryGesture 处理(时位句先行)。
*/
_playHelpAndResume() {
this.seqToken++;
this.audio.stopVoice();
const ids = [];
if (this.phase === 'ENDING') {
const def = ENDINGS[this.endingId] || ENDINGS.e1;
const tier = TIER_VOICES[tierOf(this.trust)];
this._tierCue(tier);
ids.push(def.voices[0], tier);
this._withHelp(ids, `END:${this.endingId}`);
} else {
ids.push(PROG_VOICES[this.act]); // 时位句(now 线按当前幕选播)
const tier = TIER_VOICES[tierOf(this.trust)];
this._tierCue(tier); // G2
ids.push(tier, TRU_VOICES[this.truth]);
this._withHelp(ids, `S:${this.nodeId}`);
}
this._playSeq(ids, () => {
this.voicePlaying = false;
if (this.phase === 'ONAIR' || this.phase === 'INTRO' || this.phase === 'WATCH') {
this.playNode(this.nodeId);
}
});
}
// ---------------------------------------------------------------- 看门狗(双轨,§7.2)
/** 叙事等待点(ASK / 链末 / ENDING 结算):full 25s/50s,standard 13s/30s。 */
_armIdleWatchdog(phaseName) {
const full = this.guide.level() === 'full';
this.guide.watchdog({
arm: true,
at: full ? GUIDE_WATCHDOG_AT : IDLE_HINT_AT,
say: ['gd_continue', 'gd_again'],
onEscalate: () => {
this._noProgThisWait = true; // 双轨去重:gd_* 已播 → 本等待点无进展轨跳过
if (full) return; // full 档提示句由 guide 的 say 播(guide.test 锁定)
// standard 档语音由游戏层补播(guide=off 时 watchdog 整体不挂)
},
cond: () => this.phase === phaseName && !this.voicePlaying,
});
if (!full && this.guide.level() === 'standard') {
// standard:onEscalate 不播语音(earcon+震已够)——双轨去重标记照置
}
}
/** 抉择点静默兜底:25s/50s 重播当前 p* 提示句本身(复述选项 = 合法帮助,13 号 §16 先例)。 */
armChoiceHint() {
this.guide.watchdog({
arm: true,
at: CHOICE_HINT_AT,
onEscalate: () => {
const node = NODES[this.nodeId];
if (node && node.prompt) this.audio.play(node.prompt, { queue: true });
},
cond: () => (this.phase === 'WATCH' || this.phase === 'INTRO')
&& this.choiceArmed && !this.voicePlaying,
});
}
// ---- 无进展轨(游戏层计时,§7.2 双轨之②) ----
/** 挂无进展轨:60s→gd_prog1、120s→gd_prog2,用尽即停;任何真实进展作废(_progressMark)。 */
_progressArm() {
this._progDisarm();
this._progEpoch += 1;
this._noProgThisWait = false;
if (this.guide.level() === 'off') return; // guide=off 全程零定时器零向导事件(§9)
const epoch = this._progEpoch;
const fire = (n) => {
if (epoch !== this._progEpoch) return; // 有真实进展/已重挂
if (this._noProgThisWait) return; // 双轨去重:gd_* 同义级已播过
this.audio.play(n === 1 ? 'voice/gd_prog1' : 'voice/gd_prog2', { queue: true });
if (n === 1) this._progTimer = setTimeout(() => fire(2), PROG_TRACK_AT[1] - PROG_TRACK_AT[0]);
};
this._progTimer = setTimeout(() => fire(1), PROG_TRACK_AT[0]);
}
_progDisarm() {
if (this._progTimer) { clearTimeout(this._progTimer); this._progTimer = null; }
this._progEpoch += 1;
}
/** 真实进展:节点推进 / 抉择结算 / 重听。无效手势(滑错、空点)不得调用本方法。 */
_progressMark() {
this._progDisarm();
}
// ---------------------------------------------------------------- 语音链(storyline 范式)
/**
* 顺序播放一组对白,全部自然播完后回调。被打断时回调作废(seqToken 机制)。
* 单句 30s 看门狗:超时且 isVoicePlaying()===false(资产缺失)才自动续链;
* voice 真在播(长句)则续约等待,不得掐断(radio N1 先例)。
* opts.onItem:每句起播回调(账目更新/音效触发);opts.queueSfx:句间补发 idle 门挂队 sfx。
*/
_playSeq(ids, onDone, opts = {}) {
const token = ++this.seqToken;
// F4:消费续玩跳句锁——恢复链的第一句起播时落锁,播完/被顶即解锁
this._skipLockId = this._resumeGuard ? (ids[0] || null) : null;
this._resumeGuard = false;
this.voicePlaying = true;
const step = (i) => {
if (token !== this.seqToken) return;
if (i >= ids.length) {
this.currentVoiceId = null;
this.promptPlaying = false;
this._skipLockId = null;
this._syncAmbDuck();
onDone();
return;
}
if (i > 0) {
this._skipLockId = null; // F4:首句播完,武装跳句
this._flushIdleSfx(); // 句间空隙补发挂队的过渡音(R1)
}
this.currentVoiceId = ids[i];
this.promptPlaying = PROMPT_RE.test(ids[i]);
if (this.promptPlaying) this.guide.state('choice'); // 选项句本身即 turn cue(W2)
this._syncAmbDuck();
if (opts.onItem) opts.onItem(ids[i]);
let advanced = false;
const armWd = () => setTimeout(() => {
if (advanced || token !== this.seqToken) return;
if (this.audio.isVoicePlaying()) { wd = armWd(); return; } // 长句在播:续约不掐
advanced = true;
console.warn('[KeeperGame] 语音看门狗超时(voice 未在播,判定资产缺失),自动续链:', ids[i]);
step(i + 1);
}, VOICE_WATCHDOG_MS);
let wd = armWd();
this.audio.play(ids[i], {
onComplete: () => {
if (advanced || token !== this.seqToken) return;
advanced = true;
clearTimeout(wd);
this._syncAmbDuck();
step(i + 1);
},
});
};
step(0);
}
/** 过渡音 idle 门(§6.6 R1):语音空闲即播,在播则挂队到句间空隙补发。 */
_playIdleGated(id) {
if (!this.audio.isVoicePlaying()) this.audio.play(id);
else this._pendingSfx.push(id);
}
_flushIdleSfx() {
if (this._pendingSfx.length === 0 || this.audio.isVoicePlaying()) return;
for (const id of this._pendingSfx.splice(0)) 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();
if (this._loops.amb_engine) {
this._setLoopVol('amb_engine', this._duckVoice ? 0.2 : 0.4);
}
this._syncStormGain();
}
// ---------------------------------------------------------------- 存档(§8)
/** data 结构照 §8.1 + 插叙恢复扩展字段(§修订记录 4);summary 字符串必带。 */
_save() {
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,
node: 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,
};
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;