四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付: - cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档 - teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0 - keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4 - bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行 公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、 gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00) 验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿 遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
1559 lines
64 KiB
JavaScript
1559 lines
64 KiB
JavaScript
/**
|
||
* 《守塔人》——孤岛灯塔叙事 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;
|