storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行 (发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/ 菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。 游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio / dungeon / abyss / detective / cipher / teahouse / keeper / bridge / hunt(联机切片): - 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0); 四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt 旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore) - 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动 原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿 - 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/ 台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写 引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record 前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前 状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在 speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。 语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110 废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器 tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a 落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。 大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README / HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
1649 lines
70 KiB
JavaScript
1649 lines
70 KiB
JavaScript
/**
|
||
* 《守塔人》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;
|