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