/** * 《守塔人》——孤岛灯塔叙事 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;