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

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

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

1679 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《暗码》——1940 密战叙事 RPG(docs/design/19-cipher.md)。
*
* 你是电讯处夜班报务员、双重间谍「耳机」:一晚四封密电过你的手,每个词怎么译、
* 报不报、报给谁,都由你按下。9 个抉择点(4 译词 3 选 + 5 叙事 2 选)、4 结局、
* 跨电文标量:expose 暴露度 / hints 密码本余页 / t1..t4 译文认知 / p2..p9 抉择。
*
* 6 宏状态(§3.1):INTRO / RX / DECODE / RELAY / NIGHT / ENDING + MENU 覆盖层
* (另加 ASK = 有存档进游戏的「继续上次?」询问态)。核心增量 = DECODE 解码小局:
* 电文摩尔斯由运行时程序合成模块(morse.js)排程 oscillator,不走音频文件
* (时值精确是玩法命根,§2.1);三层兜底 = 正常译(候选对照预听)/ 慢放(双击第 2 次
* 0.5×,无限免费)/ 密码本提示直译辨析位(3 页,T1 首次免费)。
*
* 手势(§3.3,唯一豁免 E1:DECODE 空闲长按=翻密码本,与引擎快进时间互斥,detective E2
* 同款):tap=跳句/继续/两段确认;doubletap=重听电文(再双击=慢放);left/right=切候选
* (DECODE)/抉择(RELAY);up=状态链(状态在前帮助在后);down=模态菜单;longpress=
* 引擎快进(DECODE 空闲=翻密码本 E1)。菜单 = 单击循环 + 双击确认 + 上滑帮助 + 下滑关闭
* (00 号 §5.2 模态约定)。
*
* 双通道仲裁(§3.4 R1-R5):摩尔斯只在 !isVoicePlaying() 时起振(morseGate 排队补发,
* abyss sonarGate 同款);摩尔斯期间 BGM −6dB(游戏层 _bgmBus 增益,R3);T4 揭晓
* danger 拍(step_halt 前置 2s 渐强 + danger 签名 + danger 震);摩尔斯不受
* settings.speed/长按快进影响(u 恒定,边界登记)。
*
* 判定出口(DECODE 第 2 击确认)、NIGHT 链、d9 结果链、e* 结局链入口处盖
* _settleGraceUntil = now+800ms 手势死窗(窗内手势一律吞掉,dungeon H1/abyss 同款);
* 电文摩尔斯首播期间玩法手势 = beat 轻震(首播保护窗,G4,不判无效不惩罚)。
*
* 资产缺失兜底:引擎 console.error 静默跳过且不回调 onComplete;单句语音看门狗 30s 且
* isVoicePlaying()===false 才自动续链(radio N1 先例);摩尔斯为运行时合成零文件依赖。
*
* 存档(§8.1):PUT /api/progress {game:'cipher'} data 必带 summary;断点续玩 DECODE 态
* 恢复 = prog_t → book 档 → gd_tune2 → 重挂浏览 + 自动预听当前候选(一句话 ≤5s 回到可玩)。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
import { MorsePlayer, MORSE_PRESET } from './morse.js';
// ---------------------------------------------------------------- 电文数据(§2.2 信息单源)
/** 四封电文:真词/候选/辨析位写死;cand=词义句,rev=揭晓句,hint=密码本句。 */
const TELS = [
{
word: 'BAY', trueId: 'bay', rx: 'rx1_in', ask: 'dv1_ask', hint: 'voice/hint_t1',
cands: [
{ id: 'bay', word: 'BAY', cand: 'voice/cand_bay', rev: 'voice/dv1_right' },
{ id: 'day', word: 'DAY', cand: 'voice/cand_day', rev: 'voice/dv1_wrong' },
{ id: 'ray', word: 'RAY', cand: 'voice/cand_ray', rev: 'voice/dv1_wrong' },
],
},
{
word: 'TOP', trueId: 'top', rx: 'rx2_in', ask: 'dv2_ask', hint: 'voice/hint_t2',
cands: [
{ id: 'top', word: 'TOP', cand: 'voice/cand_top', rev: 'voice/rev_top' },
{ id: 'tap', word: 'TAP', cand: 'voice/cand_tap', rev: 'voice/rev_tap' },
{ id: 'tip', word: 'TIP', cand: 'voice/cand_tip', rev: 'voice/rev_tip' },
],
},
{
word: 'HAND', trueId: 'hand', rx: 'rx3_in', ask: 'dv3_ask', hint: 'voice/hint_t3',
cands: [
{ id: 'hand', word: 'HAND', cand: 'voice/cand_hand', rev: 'voice/rev_hand' },
{ id: 'sand', word: 'SAND', cand: 'voice/cand_sand', rev: 'voice/rev_sand' },
{ id: 'land', word: 'LAND', cand: 'voice/cand_land', rev: 'voice/rev_land' },
],
},
{
word: 'SPY', trueId: 'spy', rx: 'rx4_in', ask: 'dv4_ask', hint: 'voice/hint_t4',
cands: [
{ id: 'spy', word: 'SPY', cand: 'voice/cand_spy', rev: 'voice/rev_spy' },
{ id: 'say', word: 'SAY', cand: 'voice/cand_say', rev: 'voice/rev_say' },
{ id: 'sly', word: 'SLY', cand: 'voice/cand_sly', rev: 'voice/rev_sly' },
],
},
];
const TEL_LABEL = ['船期', '名单', '交接', '处置令'];
// ---------------------------------------------------------------- 节点表
/** 叙事节点。voices 顺序播放;prompt 非空 = 抉择节点(播完即选,W2 契约)。 */
function N(voices, opts = {}) {
return {
voices, prompt: null, left: null, right: null, next: null,
sfx: null, dyn: 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, dyn: null, auto: false, danger: false, preMs: 0,
};
}
/**
* 全部节点(id = 首句 voice id;台词全文见 19-cipher-voice-ids.md,139 句)。
* '@morse:k' = 电文摩尔斯首播(RX 链内,不可跳);'@rx:k'/'@night:k'/'@decode'/
* '@signoff'/'@ending'/'@p4'/'@p8'/'@p9'/'@d9e' = 过场转移,由 advance() 解析。
*/
const NODES = {
// ---- INTRO 开场链(夜班开场 + 一句规矩 + 今晚目标 + 处长夜令;自动流)
b_open: N(['voice/b_open'], { auto: true, next: 'b_rule' }),
b_rule: N(['voice/b_rule'], { auto: true, next: 'b_goal' }),
b_goal: N(['voice/b_goal'], { auto: true, next: 'b_chief' }),
b_chief: N(['voice/b_chief'], { sfx: 'sfx/tea_cup', next: '@rx:1' }),
// ---- RX 截获链(引入 + 调谐 + 电文摩尔斯首播 + 提示句;全部 auto,首播不可跳)
rx1_in: N(['voice/rx1_in'], { sfx: 'sfx/tune_seek', next: '@morse:1' }),
rx2_in: N(['voice/rx2_in'], { sfx: 'sfx/tune_seek', next: '@morse:2' }),
rx3_in: N(['voice/rx3_in'], { sfx: 'sfx/tune_seek', next: '@morse:3' }),
rx4_in: N(['voice/rx4_in'], { sfx: 'sfx/tune_seek', next: '@morse:4' }),
dv1_teach: N(['voice/dv1_teach'], { auto: true, next: 'dv1_teach2' }),
dv1_teach2: N(['voice/dv1_teach2'], { auto: true, next: 'dv1_ask' }),
dv1_ask: N(['voice/dv1_ask'], { next: '@decode' }),
dv2_ask: N(['voice/dv2_ask'], { next: '@decode' }),
dv3_ask: N(['voice/dv3_ask'], { next: '@decode' }),
dv4_ask: N(['voice/dv4_ask'], { next: '@decode' }),
// ---- T1 船期·教学(译对 dv1_right 进 p2;译错 dv1_wrong 即时归因回浏览无限重选)
dv1_right: N(['voice/dv1_right'], { next: 'p2' }),
dv1_wrong: N(['voice/dv1_wrong'], { next: null }), // 教学归因:播完回浏览(代码特判)
p2: P('voice/p2', 'r1a_send', 'r1b_hold'),
r1a_send: N(['voice/r1a_send'], { sfx: 'sfx/key_send', next: 'r1a_e' }),
r1a_e: N(['voice/r1a_e'], { next: '@night:1' }),
r1b_hold: N(['voice/r1b_hold'], { next: 'r1b_e' }),
r1b_e: N(['voice/r1b_e'], { next: '@night:1' }),
n1: N(['voice/n1'], { auto: true, next: '@rx:2' }),
// ---- T2 名单(正片起译错不揭穿:rev_* 按认知播;p4 known/blind 差分)
rev2: N([], { dyn: 'rev2', next: '@p4' }),
p4_known: P('voice/p4_known', 'r2a_act', 'r2b_act'),
p4_blind: P('voice/p4_blind', 'r2a_act', 'r2b_act'),
r2a_act: N(['voice/r2a_act'], { next: 'r2a_e' }),
r2a_e: N(['voice/r2a_e'], { next: '@night:2' }),
r2b_act: N(['voice/r2b_act'], { next: 'm2_chief' }),
m2_chief: N(['voice/m2_chief'], { sfx: 'sfx/pen_scratch', next: 'r2b_e' }),
r2b_e: N(['voice/r2b_e'], { next: '@night:2' }),
n2: N(['voice/n2'], { auto: true, next: '@rx:3' }),
// ---- T3 交接(p6 右 r3b_e_ok/no 由 t3 差分)
rev3: N([], { dyn: 'rev3', next: 'p6' }),
p6: P('voice/p6', 'r3a_go', 'r3b_act'),
r3a_go: N(['voice/r3a_go'], { next: 'r3a_e' }),
r3a_e: N(['voice/r3a_e'], { next: '@night:3' }),
r3b_act: N(['voice/r3b_act'], { next: 'r3bsel' }),
r3bsel: N([], { dyn: 'r3b', next: '@night:3' }),
n3: N(['voice/n3'], { auto: true, next: '@rx:4' }),
// ---- T4 处置令·高潮(rev_spy = 全作唯一 danger 拍:step_halt 前置 2s + 签名 + 震)
rev4: N([], { dyn: 'rev4', next: '@p8' }),
p8_known: P('voice/p8_known', 'r4a_feed', 'r4b_act'),
p8_blind: P('voice/p8_blind', 'r4a_feed', 'r4b_act'),
r4a_feed: N(['voice/r4a_feed'], { sfx: 'sfx/key_send', next: 'r4a_e' }),
r4a_e: N(['voice/r4a_e'], { next: '@signoff' }),
r4b_act: N(['voice/r4b_act'], { next: 'm4_chief_c' }),
m4_chief_c: N(['voice/m4_chief_c'], { next: 'r4b_e' }),
r4b_e: N(['voice/r4b_e'], { next: '@signoff' }),
// ---- 签收(g9_in → g9_knot → ◆p9〔known/blind〕→ d9 结果链 → ENDING)
g9_in: N(['voice/g9_in'], { auto: true, next: 'g9_knot' }),
g9_knot: N(['voice/g9_knot'], { auto: true, next: '@p9' }),
p9_known: P('voice/p9_known', 'd9_send', 'd9_sleep'),
p9_blind: P('voice/p9_blind', 'd9_send', 'd9_sleep'),
d9_send: N(['voice/d9_send'], { sfx: 'sfx/key_send', next: '@d9e' }),
d9_send_e: N(['voice/d9_send_e'], { auto: true, next: '@ending' }),
d9_caught_e: N(['voice/d9_caught_e'], { auto: true, danger: true, next: '@ending' }),
d9_sleep: N(['voice/d9_sleep'], { auto: true, next: 'd9_sleepe' }),
d9_sleepe: N(['voice/d9_sleepe'], { auto: true, next: '@ending' }),
};
const MENU_ITEMS = [
{ id: 'resume', voice: 'voice/sys_menu_resume' }, // 回到电波
{ id: 'book', voice: 'voice/sys_menu_book' }, // 翻密码本(长按捷径冗余入口,P4)
{ id: 'restart', voice: 'voice/sys_menu_restart' }, // 重新开始今晚
{ id: 'lobby', voice: 'voice/sys_menu_lobby' }, // 保存并回大厅
];
/** 4 结局(§4.4:p9 前 expose ≤4/≥5 × p9 左/右)。 */
const ENDINGS = {
e1: { name: '最后一电', voices: ['voice/e1_a', 'voice/e1_b'], sfx: 'sfx/dawn_bell' },
e2: { name: '值完这个班', voices: ['voice/e2_a', 'voice/e2_b'], sfx: 'sfx/dawn_bell' },
e3: { name: '孤灯', voices: ['voice/e3_a', 'voice/e3_b'], sfx: 'sfx/lock_click' },
e4: { name: '长夜', voices: ['voice/e4_a', 'voice/e4_b'], sfx: 'sfx/lock_click' },
};
const ALL_SFX = [
'sfx/tune_seek', 'sfx/static_bed', 'sfx/amb_room', 'sfx/paper_page', 'sfx/key_send',
'sfx/pen_scratch', 'sfx/tea_cup', 'sfx/step_halt', 'sfx/lock_click', 'sfx/dawn_bell',
'sfx/ui_confirm', 'sfx/ui_menu',
];
const SAVE_KEY = 'soundgame.progress.cipher';
const AUTH_KEY = 'soundgame.auth';
/** 抉择提示音 id(voice/p2..p9):播放中单击不可跳(盲玩公平,12 号 §1.2)。 */
const PROMPT_RE = /^voice\/p[2-9]/;
/** 单句语音看门狗:超时且引擎确认 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
const CHOICE_HINT_AT = [25000, 50000]; // 抉择点:两级重播当前 p* 提示句本身
const TUNE_WATCHDOG_AT = [10000, 25000]; // DECODE 浏览两级**不同句**(gd_tune1 ≠ gd_tune2)
const PROG_WATCHDOG_AT = [60000, 120000]; // 无进展轨(仅 DECODE 浏览一个可饿死点)
const MORSE_GATE_POLL_MS = 150; // morseGate 句间轮询补发间隔(abyss sonarGate 同款)
const SETTLE_GRACE_MS = 800; // 判定出口/NIGHT/d9/e* 链手势死窗(dungeon H1 同款)
const PREVIEW_GAP_MS = 500; // 候选摩尔斯 → 词义句的静默一拍(§3.4 R1 ≥500ms)
/** 暴露度档位(§5.3,措辞逐值为真;hot 档阈值 5 = p9 收网门,同一源)。 */
function tierOf(expose) {
if (expose >= 5) return 'hot';
if (expose >= 3) return 'mid';
if (expose >= 1) return 'low';
return 'calm';
}
const TIER_VOICES = {
calm: 'voice/exp_calm', low: 'voice/exp_low', mid: 'voice/exp_mid', hot: 'voice/exp_hot',
};
const TIER_NAMES = { calm: '平安', low: '疑云', mid: '被盯', hot: '危局' };
/** 密码本账目整句(§5.4 逐值)。 */
const BOOK_VOICES = ['voice/book_0', 'voice/book_1', 'voice/book_2', 'voice/book_3'];
/** 上滑状态链「第几封」分档整句(§4.3 数字规避)。 */
const PROG_VOICES = ['voice/prog_t1', 'voice/prog_t2', 'voice/prog_t3', 'voice/prog_t4'];
export class CipherGame extends Game {
constructor(audio, root) {
super(audio, root);
this.guide = new Guide(audio);
this.morse = new MorsePlayer(audio);
this.phase = 'INTRO'; // INTRO|RX|DECODE|RELAY|NIGHT|ENDING|MENU|ASK
this.returnPhase = null; // MENU 关闭后回原状态
this.returnNode = null;
this.nodeId = 'b_open';
this.tel = 0; // 当前电文号(0=开场)
this.endingId = null;
this.voicePlaying = false;
this.currentVoiceId = null;
this.promptPlaying = false;
this.choiceArmed = false;
this.pendingChoice = null;
this.awaitingRestart = false;
this.leaving = false;
this.seqToken = 0;
// 跨电文标量(§5.1)
this.expose = 0;
this.hints = 3;
this.t = { t1: null, t2: null, t3: null, t4: null };
this.picks = { p2: null, p4: null, p6: null, p8: null, p9: null };
this.hintUsedT1 = false; // T1 首次翻页免费(Prime→Teach 兑现)
this.endingPre = 0; // p9 判定前暴露度(结局门用 p9 前值,§5.2)
this.endings = {};
this.runs = 0;
// DECODE 小局状态
this.decodeTel = 0; // 1-4(= this.tel)
this.candIdx = 0;
this.confirming = false; // 两段确认第 2 击=定下判定
this.dtCount = 0; // 双击连击计数(任何其他手势重置)
this.browseArmed = false; // DECODE 浏览武装(看门狗/手势判据)
this.lastAnnouncedTier = 'calm'; // NIGHT 跨档播报基准(开局 expose=0 → calm)
this._nightAnnounce = null; // 本次 NIGHT 链尾待播档位句
this._paperSfx = null; // R2 F1:上一封抉择的纸面标记音(NIGHT 链首垫)
this.sysSkipShown = false;
this.saved = null;
this._menuQueue = [];
this._helpSeen = new Set();
// 死窗 / 首播保护
this._settleGraceUntil = 0;
this._rxGuard = false; // 电文摩尔斯首播中:玩法手势 = beat 轻震(G4)
// F4 断点续玩跳句锁(radio 同款)
this._resumeGuard = false;
this._skipLockId = null;
// 双循环垫句柄(sfx 层无 loop/stop,游戏层经 ctx 起源持句柄,radio _ringSrc 先例)
this._staticSrc = null;
this._ambSrc = null;
this.ambLoopEnabled = true; // 无头测试置 false(重触发定时器会让虚拟时钟永不空闲)
// morseGate / 预听 / BGM dip
this._previewToken = 0;
this._morsePending = null; // {kind, word, preset} morseGate 排队(R1)
this._gateTimer = null;
this.morseLog = []; // 摩尔斯排程记录(playtest 固定字符/时值断言挂点,§9)
this._progGen = 0; // 无进展轨代际令牌(残留定时器到点即弃)
this._bgmDipMenu = false; // MENU 低响度淡入(§6.1 省一首:同曲低响度)
this._bgmDipMorse = false; // R3 摩尔斯期间 −6dB
// 看门狗双轨(§7.2):无进展轨计时(仅 DECODE 浏览)
this._progFired = [false, false];
this._tuneHinted = 0; // 本等待点已播 gd_tune 级数(双轨去重)
this._progTimer = null;
this._earconHook = null; // 测试钩子:guide 事件外泄(playtest 断言)
}
// ---------------------------------------------------------------- Game
start() {
this.audio.play('bgm/radio_room'); // 开局淡入 800ms(bgm=0 时引擎只记意图)
this.audio.prewarm(ALL_SFX);
this.guide.setEventHook((ev) => { if (this._earconHook) this._earconHook(ev); });
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=cipher', { 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++; // 作废一切在途链回调(radio 实现修复 #6 同款 + 摩尔斯排程)
this.morse.cancel();
this._clampMorseLog();
this._stopStatic();
this._stopAmb();
this._cancelProg();
this._cancelGate();
if (this.restTimer) { clearTimeout(this.restTimer); this.restTimer = null; }
this._resumeGuard = false;
this._skipLockId = null;
this.audio.stopAll();
}
onGesture(g) {
// 结算死窗:判定出口/NIGHT/d9/e* 链入口 800ms 内手势一律吞掉(dungeon H1 同款)
if (this._settleGraceUntil && performance.now() < this._settleGraceUntil) return;
this.guide.disarm(); // 任何手势复位看门狗(guide=off 时 no-op)
switch (this.phase) {
case 'ASK': this._onAskGesture(g); break;
case 'MENU': this._onMenuGesture(g); break;
case 'INTRO': case 'RX': case 'RELAY': this._onStoryGesture(g); break;
case 'DECODE': this._onDecodeGesture(g); break;
case 'NIGHT': this._onNightGesture(g); break;
case 'ENDING': this._onEndingGesture(g); break;
default: break;
}
}
// ---------------------------------------------------------------- 手势: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: 'cipher_invalid' });
if (!go && this.phase === 'ASK' && !this.voicePlaying) this._armIdleWatchdog('ASK');
}
// ---------------------------------------------------------------- 手势:INTRO / RX / RELAY
_onStoryGesture(g) {
const node = NODES[this.nodeId];
if (!node) return;
switch (g) {
case 'tap':
if (this._rxGuard) { vibrate('beat'); break; } // 首播保护窗:beat 轻震(G4)
if (this.voicePlaying) {
if (!this.promptPlaying && !this._isTailSentence()
&& this.currentVoiceId !== this._skipLockId) {
this.audio.skipVoice();
} else {
vibrate('beat'); // G4:不可跳句上的空点单击补 beat 签名
}
} else if (node.prompt != null && this.choiceArmed) {
this.armChoiceHint(); // 抉择等待态空点 = 重新挂载兜底(25s 计时重置)
} else if (node.next != null) {
this._touchProgress();
this.advance(node.next);
}
break;
case 'doubletap':
this.replayNode();
break;
case 'left':
case 'right': {
if (this._rxGuard) { vibrate('beat'); break; } // 首播保护窗
if (node.prompt == null) {
this.guide.error({ voice: 'gd_invalid', onceKey: 'cipher_invalid' });
if (this._inStoryWait()) this._armIdleWatchdog(this.phase);
return;
}
if (this.choiceArmed) this.choose(g);
else if (this.voicePlaying) this.pendingChoice = g;
break;
}
case 'up':
this._playHelpAndResume();
break;
case 'down':
this.openMenu();
break;
default:
if (this._inStoryWait()) this._armIdleWatchdog(this.phase);
break;
}
}
/** INTRO/RX/RELAY「链末等待单击」判据(非抉择节点、有后继、非自动流、非过场、无对白)。 */
_inStoryWait() {
const node = NODES[this.nodeId];
return !!(node && !this.voicePlaying && node.prompt == null && node.next != null
&& !node.auto && !this._isTransition(node.next));
}
/** F5 链末句保护:当前句是本节点末句且节点把控制权交出时屏蔽跳句(radio 同款)。 */
_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 (!node.auto) return true;
if (node.next == null || this._isTransition(node.next)) return true;
const nx = NODES[node.next];
return !!(nx && nx.prompt != null);
}
// ---------------------------------------------------------------- 手势:DECODE 解码小局(§2.3)
_onDecodeGesture(g) {
switch (g) {
case 'tap': {
this.dtCount = 0;
if (this.voicePlaying && !this.confirming) {
// 浏览态语音在播(候选词义句/状态链):单击不跳句,等播完
if (PROMPT_RE.test(this.currentVoiceId || '')) vibrate('beat');
break;
}
if (!this.confirming) {
// 第 1 击:进确认 —— cf_confirm + 电文重播原速一遍(候选摩尔斯即掐,R1 不叠语音)
this.confirming = true;
this._previewToken++; // 作废在途预听
this.morse.cancel();
this._clampMorseLog();
this._clampMorseLog();
const tel = TELS[this.decodeTel - 1];
this._playSeq(['voice/cf_confirm'], () => {
this.voicePlaying = false; // 电文重播走 sfx 域,voice 链至此完结(防标志残留)
this._morseTx(tel.word, MORSE_PRESET.tx);
});
} else {
// 第 2 击:定下判定(判定出口 800ms 死窗 → 揭晓链)
this.confirming = false;
this._settleDecode();
}
break;
}
case 'doubletap': {
// 重听电文:第 1 次原速;第 2 次慢放 0.5×(任何其他手势重置计数)
this.dtCount += 1;
const tel = TELS[this.decodeTel - 1];
this._previewToken++;
if (this.dtCount >= 2) this._morseTx(tel.word, MORSE_PRESET.slow);
else this._morseTx(tel.word, MORSE_PRESET.tx);
break;
}
case 'left':
case 'right': {
this.dtCount = 0;
if (this.confirming) {
// 确认态左右滑 = 取消回浏览(§2.3)
this.confirming = false;
this.seqToken++;
this.audio.stopVoice();
this._armBrowse();
this._requestPreview();
break;
}
this._switchCandidate(g === 'right' ? 1 : -1);
break;
}
case 'up': {
this.dtCount = 0;
// 状态链:prog_t → book 档 → exp 档 → sys_help,状态在前(G6),留在浏览
this.seqToken++;
this.audio.stopVoice();
this._previewToken++;
const ids = [PROG_VOICES[this.decodeTel - 1], BOOK_VOICES[this.hints]];
const tier = TIER_VOICES[tierOf(this.expose)];
this._tierCue(tier);
ids.push(tier);
this._withHelp(ids, `D:${this.decodeTel}`);
this._playSeq(ids, () => {
this.voicePlaying = false;
if (this.phase === 'DECODE') this._armBrowse();
});
break;
}
case 'down':
this.dtCount = 0;
this.openMenu();
break;
case 'longpress':
// E1 唯一豁免:DECODE 空闲长按 = 翻密码本;语音在播 = 引擎快进(时间互斥)
if (!this.audio.isVoicePlaying()) this._useHint();
break;
case 'longpressend':
case 'tapcancel':
break;
default:
break;
}
}
/** 判定落定(译词):T1 教学即时反馈/无限重选零惩罚;T2-T4 不揭穿直入剧情。 */
_settleDecode() {
const tel = TELS[this.decodeTel - 1];
const sel = tel.cands[this.candIdx];
this._settleGraceUntil = performance.now() + SETTLE_GRACE_MS;
this.browseArmed = false;
this._cancelProg();
this._previewToken++;
this.seqToken++;
this.audio.stopVoice();
this.morse.cancel();
this._clampMorseLog();
if (this.decodeTel === 1) {
this.t.t1 = 'bay'; // T1 恒收敛真词(教学期零惩罚,A5)
if (sel.id === 'bay') {
vibrate('success'); // 首个正反馈(§10 ≈t+2min)
this._touchProgress();
this.phase = 'RELAY';
this._save(); // 写入时机:译词判定落定后
this.playNode('dv1_right');
} else {
// 即时真话归因(字母/码写死句内,与 §2.2 电码表一致)→ 回浏览无限重选
this._playSeq(['voice/dv1_wrong'], () => {
this.voicePlaying = false;
if (this.phase === 'DECODE') { this._armBrowse(); this._requestPreview(); }
});
}
return;
}
// 正片:记录认知(t2..t4),译错 +1/封(§5.2 单源事件表);不揭穿,直入 rev_* 剧情
this.t[`t${this.decodeTel}`] = sel.id;
if (sel.id !== tel.trueId) this.expose += 1;
this._touchProgress();
this.phase = 'RELAY';
if (this.decodeTel === 4) this.audio.play('bgm/closing', { fadeMs: 800 }); // RELAY(4) 起
this._save();
this.playNode(`rev${this.decodeTel}`);
}
/** 两段确认第 1 击后的电文重播(原速一遍,走 morseGate)。 */
_morseTx(word, preset) {
this._requestMorse({ kind: 'tx', word, preset });
}
/**
* morseGate(§3.4 R1):摩尔斯只在 !isVoicePlaying() 时起振;在播则挂队列表、
* 句间轮询补发(abyss sonarGate 同款)。候选预听链 = 候选摩尔斯 → 静默一拍
* (≥500ms)→ cand_* 词义句(给耳朵留对照时间)。
*/
_requestMorse(req) {
this._morsePending = req;
if (!this.audio.isVoicePlaying()) { this._fireMorse(); return; }
if (this._gateTimer) clearTimeout(this._gateTimer);
const poll = () => {
this._gateTimer = null;
if (!this._morsePending) return;
if (this.audio.isVoicePlaying()) {
this._gateTimer = setTimeout(poll, MORSE_GATE_POLL_MS);
return;
}
this._fireMorse();
};
this._gateTimer = setTimeout(poll, MORSE_GATE_POLL_MS);
}
_cancelGate() {
this._morsePending = null;
if (this._gateTimer) { clearTimeout(this._gateTimer); this._gateTimer = null; }
}
/** R1 账目:在排程被掐断/覆盖时把在途记录的 endMs 收敛到当前时刻(转写零重叠断言口径)。 */
_clampMorseLog() {
const t = Math.round(performance.now());
for (const r of this.morseLog) {
if (r.endMs > t) r.endMs = t;
}
}
_fireMorse() {
const req = this._morsePending;
this._morsePending = null;
if (!req) return;
const ctx = this.audio.ctx;
if (!ctx) return;
this.morse.cancel();
this._clampMorseLog(); // 被覆盖的在途记录收敛(新排程接管前景)
const base = ctx.currentTime;
const rec = {
kind: req.kind, word: req.word, hz: req.preset.hz, unit: req.preset.unit,
letterGapUnits: req.preset.letterGapUnits, atMs: Math.round(base * 1000),
endMs: 0, elements: [],
};
const onElement = (el) => rec.elements.push({ type: el.type, letter: el.letter, at: el.at });
if (req.kind === 'preview') {
const token = ++this._previewToken;
const h = this.morse.play(req.word, { ...req.preset, baseSec: base, onElement });
rec.endMs = Math.round(h.endAt * 1000);
this.morseLog.push(rec);
this._dipBgmForMorse(h.endAt);
const waitMs = Math.max(0, (h.endAt - ctx.currentTime) * 1000) + PREVIEW_GAP_MS;
setTimeout(() => {
if (token !== this._previewToken || this.phase !== 'DECODE') return;
if (this.confirming) return; // 确认态不补候选词义句
const candVoice = TELS[this.decodeTel - 1].cands[this.candIdx].cand;
this.voicePlaying = true;
this.currentVoiceId = candVoice;
this.audio.play(candVoice, {
onComplete: () => {
if (token !== this._previewToken) return;
this.voicePlaying = false;
this.currentVoiceId = null;
},
});
}, waitMs);
} else {
const h = this.morse.play(req.word, { ...req.preset, baseSec: base, onElement });
rec.endMs = Math.round(h.endAt * 1000);
this.morseLog.push(rec);
this._dipBgmForMorse(h.endAt);
}
}
/** R3:摩尔斯排程期间 BGM −6dB(游戏层 _bgmBus 增益,dip 到 endAt 自动恢复)。 */
_dipBgmForMorse(endAt) {
const ctx = this.audio.ctx;
if (!ctx || !this.audio._bgmBus) return;
this._bgmDipMorse = true;
this._applyBgmGain();
const waitMs = Math.max(0, (endAt - ctx.currentTime) * 1000);
setTimeout(() => {
this._bgmDipMorse = false;
this._applyBgmGain();
}, waitMs + 60);
}
/**
* 菜单低响度(§6.1 省一首:同曲低响度)× 摩尔斯 −6dB(R3)复合 dip。
* 引擎 duck/unduck 独占 _bgmBus(每句对白结束 unduck 回 1.0),游戏层不能直写——
* 在 _bgmBus → _bgmPrefBus 之间插入私有 dip 节点(乘法因子,引擎 duck 不干扰)。
*/
_ensureBgmDipNode() {
if (this._bgmDipNode || !this.audio._bgmBus || !this.audio._bgmPrefBus || !this.audio.ctx) return;
try { this.audio._bgmBus.disconnect(); } catch (_) { /* 未连接 */ }
const node = this.audio.ctx.createGain();
node.gain.value = 1;
this.audio._bgmBus.connect(node);
node.connect(this.audio._bgmPrefBus);
this._bgmDipNode = node;
}
_applyBgmGain() {
if (!this.audio._bgmBus) return;
this._ensureBgmDipNode();
if (!this._bgmDipNode) return;
const v = (this._bgmDipMenu ? 0.4 : 1) * (this._bgmDipMorse ? 0.5 : 1);
const g = this._bgmDipNode.gain;
g.value = v; // 直写保底(无头 mock 的 setValueAtTime 为 no-op)
if (this.audio.ctx) {
try { g.setValueAtTime(v, this.audio.ctx.currentTime); } catch (_) { /* 静默 */ }
}
}
/** 左右滑切候选(循环),每切自动预听(候选摩尔斯对照档 → 一拍 → 词义句)。 */
_switchCandidate(delta) {
const tel = TELS[this.decodeTel - 1];
this.candIdx = (this.candIdx + delta + tel.cands.length) % tel.cands.length;
this._save(); // 候选索引入档(DECODE 中断恢复用)
this._requestPreview();
}
/** 预听请求(经 morseGate;语音在播则排队补发)。 */
_requestPreview() {
const tel = TELS[this.decodeTel - 1];
const cand = tel.cands[this.candIdx];
this._requestMorse({ kind: 'preview', word: cand.word, preset: MORSE_PRESET.cand });
}
/** 翻密码本(E1 兑换;账目逐值为真;页尽 = error 震 + 翻空本声零惩罚)。 */
_useHint() {
if (this.phase !== 'DECODE') return;
const tel = TELS[this.decodeTel - 1];
const free = this.decodeTel === 1 && !this.hintUsedT1;
if (!free && this.hints <= 0) {
vibrate('error');
this.audio.play('sfx/paper_page'); // 翻空本声,无语音(零静默、不轰炸)
return;
}
this.confirming = false;
this._touchProgress(); // 翻密码本 = 真实进展(§7.2 无进展轨)
if (free) this.hintUsedT1 = true;
else this.hints -= 1;
this.seqToken++;
this.audio.stopVoice();
this._previewToken++;
this.audio.play('sfx/paper_page');
this._playSeq([tel.hint], () => {
this.voicePlaying = false;
if (this.phase === 'DECODE') { this._armBrowse(); this._requestPreview(); }
});
this._save();
}
/** 浏览武装:看门狗双轨(§7.2)+ 无进展轨计时起点。 */
_armBrowse() {
this.browseArmed = true;
this.confirming = false;
if (this.guide.level() === 'off') return; // guide=off:零定时器零向导事件
this._tuneHinted = 0;
this._progFired = [false, false];
const full = this.guide.level() === 'full';
this.guide.watchdog({
arm: true,
at: TUNE_WATCHDOG_AT,
say: ['gd_tune1', 'gd_tune2'],
onEscalate: (n) => {
this._tuneHinted = n;
if (full) return;
this.audio.play(n === 1 ? 'voice/gd_tune1' : 'voice/gd_tune2', { queue: true });
},
cond: () => this.phase === 'DECODE' && this.browseArmed && !this.voicePlaying,
});
this._armProgWatchdog();
}
/** 无进展轨(§7.2):纯浏览/无效手势不计进展,60s/120s 必被抢救;语音占用则续约重排,用尽即停。 */
_armProgWatchdog() {
this._cancelProg();
const gen = ++this._progGen; // 代际令牌:上一等待点的残留定时器到点即弃(不跨点复活)
const t0 = performance.now();
const step = (level, delayMs) => {
this._progTimer = setTimeout(() => {
this._progTimer = null;
if (gen !== this._progGen) return;
if (this._progFired[level - 1]) return;
if (this.phase !== 'DECODE' || !this.browseArmed) return; // 等待点已过,静默取消
if (this.voicePlaying || this.audio.isVoicePlaying()) {
step(level, 2000); // 语音占用:续约重排(绝不因撞期静默丢失)
return;
}
if (this._tuneHinted >= level) return; // 双轨去重:同义级已播 gd_tune* 则跳过
this._progFired[level - 1] = true;
this.audio.play(level === 1 ? 'voice/gd_prog1' : 'voice/gd_prog2', { queue: true });
}, Math.max(50, delayMs != null ? delayMs : PROG_WATCHDOG_AT[level - 1] - (performance.now() - t0)));
};
step(1);
step(2);
}
_cancelProg() {
if (this._progTimer) { clearTimeout(this._progTimer); this._progTimer = null; }
}
/** 真实进展:译词落定 / 叙事抉择结算 / 节点推进 / 翻密码本(§7.2 定义)。 */
_touchProgress() {
this._progFired = [true, true]; // 有真实进展:全程零 prog 播出(不误伤)
this._cancelProg();
}
// ---------------------------------------------------------------- 手势:NIGHT
_onNightGesture(g) {
switch (g) {
case 'tap':
if (this.voicePlaying) this.audio.skipVoice(); // 跳过本句,自动链继续
break;
case 'doubletap':
this._playNightChain();
break;
case 'up': {
// 帮助+状态(状态在前),自动链继续(§3.3 NIGHT 行)
this.seqToken++;
this.audio.stopVoice();
const ids = [PROG_VOICES[Math.min(3, this.tel)], BOOK_VOICES[this.hints]];
const tier = TIER_VOICES[tierOf(this.expose)];
this._tierCue(tier);
ids.push(tier);
this._withHelp(ids, 'N');
this._playSeq(ids, () => {
this.voicePlaying = false;
if (this.phase === 'NIGHT') this._playNightChain();
});
break;
}
case 'down':
this.openMenu();
break;
case 'left':
case 'right':
this.guide.error({ voice: 'gd_invalid', onceKey: 'cipher_invalid' });
break;
default:
break;
}
}
// ---------------------------------------------------------------- 手势:ENDING
_onEndingGesture(g) {
switch (g) {
case 'tap':
if (this.awaitingRestart) this.newNight(); // 再来一局(新夜班,runs+1)
else if (this.voicePlaying) {
if (this.currentVoiceId !== this._skipLockId) this.audio.skipVoice();
else vibrate('beat');
}
break;
case 'doubletap':
this.awaitingRestart = false;
this._playEndingChain();
break;
case 'up': {
this.seqToken++;
this.audio.stopVoice();
const def = ENDINGS[this.endingId] || ENDINGS.e1;
const tier = TIER_VOICES[tierOf(this.endingPre)];
this._tierCue(tier);
const ids = this._withHelp([def.voices[0], tier], `E:${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: 'cipher_invalid' });
if (this.awaitingRestart && !this.voicePlaying) this._armIdleWatchdog('ENDING');
break;
default:
if (this.awaitingRestart && !this.voicePlaying) this._armIdleWatchdog('ENDING');
break;
}
}
// ---------------------------------------------------------------- 手势:MENU(00 号 §5.2 模态约定)
_onMenuGesture(g) {
switch (g) {
case 'tap':
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([], 'M');
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 震(无语音)
break;
default:
break;
}
}
_execMenuItem(id) {
this._menuQueue = [];
if (id === 'resume') {
this.closeMenu();
} else if (id === 'book') {
// 「翻密码本」= 长按同效:回 DECODE 执行提示流程(P4 冗余入口)
if (this.returnPhase === 'DECODE') {
this.closeMenu();
this._useHint();
} else {
this.guide.error({ voice: 'gd_invalid', onceKey: 'cipher_invalid_book' });
}
} else if (id === 'restart') {
this.seqToken++;
this.audio.stopVoice();
this.newNight();
} else if (id === 'lobby') {
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 标记 */ }
});
}
}
_drainMenuQueue() {
if (this.phase !== 'MENU' || this._menuQueue.length === 0) return;
const ids = this._menuQueue.splice(0);
this._playSeq(ids, () => {
this.voicePlaying = false;
this._drainMenuQueue();
});
}
openMenu() {
this.returnPhase = this.phase;
this.returnNode = this.nodeId;
this.phase = 'MENU';
this.menuIdx = 0;
this._menuQueue = [];
this.seqToken++;
this.audio.stopVoice();
this.morse.cancel();
this._clampMorseLog(); // 停摩尔斯排程(§3.2)
this._cancelGate();
this._cancelProg();
this._previewToken++;
this._rxGuard = false;
this.browseArmed = false;
this.audio.play('sfx/ui_menu');
this._bgmDipMenu = true; // 同曲低响度淡入 500ms(§6.1)
this._applyBgmGain();
this.guide.state('menu');
this._playSeq(['voice/sys_menu', MENU_ITEMS[0].voice], () => {
this.voicePlaying = false;
this._drainMenuQueue();
});
}
closeMenu() {
this.seqToken++;
this._menuQueue = [];
this.audio.stopVoice();
this._bgmDipMenu = false;
this._applyBgmGain();
const back = this.returnPhase || 'INTRO';
this.phase = back;
this.audio.play('sfx/ui_confirm');
if (back === 'NIGHT') this.audio.play('bgm/night', { fadeMs: 600 });
else if (back === 'ENDING') this.audio.play('bgm/ending', { fadeMs: 1500 });
else if (back === 'RELAY' && this.tel === 4) this.audio.play('bgm/closing', { fadeMs: 800 });
else if (back !== 'ASK') this.audio.play('bgm/radio_room', { fadeMs: 800 });
this.guide.state(back === 'NIGHT' || back === 'ENDING' ? 'settle' : 'explore');
if (back === 'DECODE') {
// 局面复述:重挂浏览并自动预听当前候选(§3.2)
this._armBrowse();
this._requestPreview();
} else if (back === 'NIGHT') {
this._playNightChain();
} 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/RX/RELAY:重播当前节点
}
}
// ---------------------------------------------------------------- 夜班流转
/** 新夜班:重置跨电文标量(结局收集跨夜保留),回 INTRO(sys_new_night 先行)。 */
newNight() {
this.runs += 1;
this.expose = 0;
this.hints = 3;
this.t = { t1: null, t2: null, t3: null, t4: null };
this.picks = { p2: null, p4: null, p6: null, p8: null, p9: null };
this.hintUsedT1 = false;
this.endingPre = 0;
this.tel = 0;
this.endingId = null;
this.lastAnnouncedTier = 'calm';
this._paperSfx = null;
this.leaving = false;
this.saved = null;
this.choiceArmed = false;
this.pendingChoice = null;
this.browseArmed = false;
this.confirming = false;
this.dtCount = 0;
this._resumeGuard = false;
this._skipLockId = null;
this._rxGuard = false;
this._settleGraceUntil = 0;
this.seqToken++;
this.audio.stopVoice();
this.morse.cancel();
this._clampMorseLog();
this._stopStatic();
this._cancelGate();
this._cancelProg();
this.phase = 'INTRO';
this.audio.play('bgm/radio_room');
this.guide.state('explore');
this._playSeq(['voice/sys_new_night'], () => this.playNode('b_open'));
}
/** RX:截获第 k 封。调谐 + 引入 auto 链,静电底噪 loop 起,进入即存档。 */
enterRX(k) {
this.tel = k;
this.phase = 'RX';
this._startStatic();
if (k > 1) this.audio.play('bgm/radio_room', { fadeMs: 600 }); // NIGHT 淡回
this.guide.state('explore');
this._save(); // 写入时机:每封 RX 进入
this.playNode(TELS[k - 1].rx);
}
/**
* 电文摩尔斯首播(RX 链内 '@morse:k'):原速 750Hz,auto,不可跳——
* 首播保护窗内玩法手势一律 beat 轻震(§3.3,G4;摩尔斯走 sfx 域无跳句通道)。
* 首播完 guide.turn(签名 + confirm 震 =「轮到你选词」),提示句随后落地。
*/
_broadcastMorse(k) {
const tel = TELS[k - 1];
const ctx = this.audio.ctx;
this._rxGuard = true;
this.browseArmed = false;
const token = ++this.seqToken;
const fire = () => {
if (token !== this.seqToken || this.phase !== 'RX') return;
const base = ctx ? ctx.currentTime : 0;
const rec = {
kind: 'broadcast', word: tel.word, hz: MORSE_PRESET.tx.hz, unit: MORSE_PRESET.tx.unit,
letterGapUnits: MORSE_PRESET.tx.letterGapUnits, atMs: Math.round(base * 1000),
endMs: 0, elements: [],
};
const h = this.morse.play(tel.word, {
...MORSE_PRESET.tx, baseSec: base,
onElement: (el) => rec.elements.push({ type: el.type, letter: el.letter, at: el.at }),
});
rec.endMs = Math.round(h.endAt * 1000);
this.morseLog.push(rec);
this._dipBgmForMorse(h.endAt);
const waitMs = Math.max(0, (h.endAt - base) * 1000);
setTimeout(() => {
if (token !== this.seqToken) return;
this._rxGuard = false;
if (this.phase !== 'RX') return;
this.guide.turn({ haptic: true }); // 轮到你选词(dv*_ask 随后落地)
this._continueRx(k);
}, waitMs + 40);
};
// morseGate:首播在 RX 链尾(dv*_ask 前无 voice 在播),空闲直发,否则轮询
if (!this.audio.isVoicePlaying()) { fire(); return; }
const poll = () => {
if (token !== this.seqToken) return;
if (this.audio.isVoicePlaying()) { setTimeout(poll, MORSE_GATE_POLL_MS); return; }
fire();
};
setTimeout(poll, MORSE_GATE_POLL_MS);
}
/** 首播后的提示句链(T1 带两句教学),链完进 DECODE 浏览。 */
_continueRx(k) {
if (k === 1) this.playNode('dv1_teach');
else this.playNode(TELS[k - 1].ask);
}
/** 进入 DECODE 浏览:候选索引归零、自动预听候选 1、看门狗双轨武装。 */
_enterDecode() {
this.phase = 'DECODE';
this.decodeTel = this.tel;
this.candIdx = 0;
this.dtCount = 0;
this._rxGuard = false;
this.guide.state('choice');
this._armBrowse();
this._requestPreview(); // 进浏览自动预听候选 1
}
/** NIGHT:夜结算。n_k 过渡句 + 跨档 exp_* 播报(跨档才播,hot 附 warning 震)。 */
enterNIGHT(k) {
this.phase = 'NIGHT';
this._stopStatic();
this.guide.state('settle');
this.audio.play('bgm/night', { fadeMs: 600 });
const tier = tierOf(this.expose);
if (tier !== this.lastAnnouncedTier) {
this.lastAnnouncedTier = tier;
this._nightAnnounce = TIER_VOICES[tier];
if (tier === 'hot') vibrate('warning'); // exp_hot 播报位附 warning 震(G2)
} else {
this._nightAnnounce = null;
}
this._settleGraceUntil = performance.now() + SETTLE_GRACE_MS; // NIGHT 链入口死窗
this._save(); // 写入时机:NIGHT 进入
this._playNightChain();
}
/** NIGHT 链尾待播档位句 + 上一封抉择的纸面标记音(R2 F1:世界因你而动可感知)。 */
_playNightChain() {
const nNode = NODES[`n${this.tel}`];
const ids = this._nodeVoices(`n${this.tel}`);
if (this._nightAnnounce) ids.push(this._nightAnnounce);
if (this._paperSfx) { // 压日志/暗处理/呈报的纸面痕迹在夜结算垫一记(现有 sfx 零新增)
this.audio.play(this._paperSfx);
this._paperSfx = null;
}
this.seqToken++;
this.audio.stopVoice();
this._playSeq(ids.length ? ids : [], () => {
this.voicePlaying = false;
if (this.phase !== 'NIGHT') return;
if (nNode && nNode.next) this.advance(nNode.next);
});
}
/** ENDING:e*(expose 档 × p9)→ ep 回响链(逐封点名,flags 单源选播),结算态。 */
enterEnding(forced) {
const ending = forced
|| (this.endingPre <= 4 ? (this.picks.p9 === 'left' ? 'e1' : 'e2')
: (this.picks.p9 === 'left' ? 'e3' : 'e4'));
this.endingId = ending;
this.endings[ending] = true;
this.phase = 'ENDING';
this.nodeId = ending;
this.awaitingRestart = false;
this._stopStatic();
this.guide.state('settle');
this.audio.play('bgm/ending', { fadeMs: 1500 }); // d9 结果链播完后淡入
this._save(); // 写入时机:ENDING 进入
this._playEndingChain();
}
_playEndingChain() {
const def = ENDINGS[this.endingId] || ENDINGS.e4;
if (def.sfx) this.audio.play(def.sfx); // e1/e2 dawn_bell;e3/e4 lock_click(§6.2 承诺绑定)
const chain = [...def.voices, 'voice/ep_lead'];
// 尾声回响:每一封的译与错都被点名(§4.4 P2;flags 单源,ep_*=播报=存储)
chain.push(this.picks.p2 === 'left' ? 'voice/ep_med_ok' : 'voice/ep_med_no');
chain.push(this.picks.p4 === 'left' ? 'voice/ep_list_ok' : 'voice/ep_list_no');
chain.push(this.t.t3 === 'hand' ? 'voice/ep_hand_ok' : 'voice/ep_hand_no');
chain.push(this.t.t4 === 'spy' ? 'voice/ep_spy_ok' : 'voice/ep_spy_no');
// R2 F3:结局收集计数(数字规避分档整句;endings 账含本局,1-4 档)
chain.push(`voice/end_c${Math.min(4, Object.keys(this.endings).length)}`);
this._settleGraceUntil = performance.now() + SETTLE_GRACE_MS; // e* 链入口死窗
this._playSeq(chain, () => {
this.voicePlaying = false;
this.awaitingRestart = true;
this._armIdleWatchdog('ENDING');
});
}
// ---------------------------------------------------------------- 存档续玩
_askContinue(save) {
this.saved = save;
this.phase = 'ASK';
this._playSeq(['voice/sys_continue_ask'], () => {
this.voicePlaying = false;
this._armIdleWatchdog('ASK');
});
}
/** 断点续玩:恢复 phase+node+全部数值并局面复述(一句话 ≤5s 回到可玩,L5)。 */
_resume(s) {
if (!s) { this.newNight(); return; }
this.saved = null;
this.expose = s.expose | 0;
this.hints = s.hints != null ? s.hints : 3;
this.t = { t1: null, t2: null, t3: null, t4: null, ...(s.t || {}) };
this.picks = { p2: null, p4: null, p6: null, p8: null, p9: null, ...(s.picks || {}) };
this.hintUsedT1 = !!s.hintUsedT1;
this.endingPre = s.endingPre | 0;
this.endings = { ...(s.endings || {}) };
this.runs = (s.runs || 0) + 1; // 继续上次同样 +1(统一口径)
this.lastAnnouncedTier = tierOf(this.expose); // 恢复不重播跨档句(账目已播过)
this.tel = s.tel | 0;
this.leaving = false;
this.seqToken++;
this.audio.stopVoice();
this.morse.cancel();
this._clampMorseLog();
this._stopStatic();
this._resumeGuard = true; // F4:恢复链首句播完才武装跳句
const phase = s.phase;
if (phase === 'DECODE' && (s.tel | 0) >= 1 && (s.tel | 0) <= 4) {
// DECODE 恢复:prog_t → book 档 → gd_tune2 → 重挂浏览 + 自动预听(§8.1)
this.phase = 'DECODE';
this.decodeTel = s.tel;
this.candIdx = Math.min(2, s.cand | 0);
this._startStatic();
this.guide.state('choice');
this._playSeq(
[PROG_VOICES[this.decodeTel - 1], BOOK_VOICES[this.hints], 'voice/gd_tune2'],
() => {
this.voicePlaying = false;
if (this.phase === 'DECODE') { this._armBrowse(); this._requestPreview(); }
},
);
} else if (phase === 'NIGHT') {
this.phase = 'NIGHT';
this.audio.play('bgm/night', { fadeMs: 600 });
this.guide.state('settle');
this._playNightChain();
} else if (phase === 'ENDING') {
this.enterEnding(s.ending && ENDINGS[s.ending] ? s.ending : undefined);
} else if (phase === 'RX' && s.tel >= 1) {
this.enterRX(s.tel);
} else {
this.phase = phase === 'RELAY' || phase === 'INTRO' ? phase : 'INTRO';
this.guide.state('explore');
this.playNode(NODES[s.node] ? s.node : 'b_open');
}
}
// ---------------------------------------------------------------- 节点链驱动
playNode(id) {
const node = NODES[id];
if (!node) {
console.error('[CipherGame] 未知节点:', id);
return;
}
this.nodeId = id;
this.choiceArmed = false;
this.pendingChoice = null;
this.awaitingRestart = false;
if (id === 'b_open' && !this._ambSrc) this._startAmb('sfx/amb_room'); // 值班室夜境
let seq = this._nodeVoices(id);
// F8 先例:操作口令 gd_brief 前置开场链最前(仅 full 档)
if (id === 'b_open' && this.guide.level() === 'full') seq = ['voice/gd_brief', ...seq];
if (id === 'b_open' && !this.sysSkipShown) {
this.sysSkipShown = true;
seq = [...seq, 'voice/sys_skip'];
}
if (node.prompt) seq = [...seq, node.prompt];
// T4 揭晓 danger 拍(§3.4 R4 / §6.2 预警窗纪律):仅 rev_spy 一次——step_halt 前置
// 2s 渐强 + danger 签名/震同窗,危机链整段自动流(radio 危机拍同款);误读分支常规流入。
const rev4Spy = id === 'rev4' && (this.t.t4 || 'spy') === 'spy';
if (node.danger || rev4Spy) {
this.guide.state('danger');
vibrate('danger');
if (node.sfx) this.audio.play(node.sfx);
if (rev4Spy) this.audio.play('sfx/step_halt');
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)));
}, rev4Spy ? 2000 : (node.preMs || 0));
return;
}
if (node.sfx) this.audio.play(node.sfx);
this._playSeq(seq, () => this._onNodeSeqDone(node, seq));
}
/** 节点基础语音链(节点内条件选播:rev2/rev3/rev4/r3bsel/dv1_wrong 归因)。 */
_nodeVoices(id) {
const node = NODES[id];
if (!node) return [];
if (node.dyn === 'rev2') {
const sel = this.t.t2 || 'top';
return [`voice/rev_${sel}`];
}
if (node.dyn === 'rev3') {
const sel = this.t.t3 || 'hand';
return [`voice/rev_${sel}`];
}
if (node.dyn === 'rev4') {
const sel = this.t.t4 || 'spy';
return [`voice/rev_${sel}`];
}
if (node.dyn === 'r3b') {
return [this.t.t3 === 'hand' ? 'voice/r3b_e_ok' : 'voice/r3b_e_no'];
}
return [...node.voices];
}
/** 双击重听当前节点台词(含抉择提示);ENDING 态重听结局链。 */
replayNode() {
this.seqToken++;
this.audio.stopVoice();
this._previewToken++;
if (this.phase === 'ENDING') {
this.awaitingRestart = false;
this._playEndingChain();
return;
}
const node = NODES[this.nodeId];
if (!node) return;
this.choiceArmed = false;
this.pendingChoice = null;
this.awaitingRestart = false;
const seq = this._nodeVoices(this.nodeId);
if (node.prompt) seq.push(node.prompt);
if (node.sfx) this.audio.play(node.sfx);
this._playSeq(seq, () => this._onNodeSeqDone(node, seq));
}
/** '@x' 虚拟目标 = 过场转移(链完自动衔接,§3.2)。 */
_isTransition(target) {
return typeof target === 'string' && target.startsWith('@');
}
/**
* 节点链自然播完的公共收尾:抉择武装 / 自动过场 / 链末等待。
* INTRO/RX 链 auto 流(含电文首播与提示句)直通;RELAY 叙事节点链末等待单击
* (§3.2)并挂 gd_continue/gd_again 看门狗;dv1_wrong 归因回浏览(教学无限重选)。
*/
_onNodeSeqDone(node, seq) {
this.voicePlaying = false;
if (node.dyn === 'rev4' && (this.t.t4 || 'spy') !== 'spy' && node.next === '@p8') {
// 误读分支常规流入 p8_blind(danger 拍仅 rev_spy 一次)
}
if (this.nodeId === 'dv1_wrong') {
// T1 译错:即时归因后回浏览(无限重选零惩罚,§2.3)
if (this.phase === 'DECODE') { this._armBrowse(); this._requestPreview(); }
return;
}
if (node.prompt != null) {
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.auto || this._isTransition(node.next)) {
this.advance(node.next);
return;
}
this._armIdleWatchdog(this.phase); // 链末等待单击(tap=继续)
}
/** 链完转移:'@x' 虚拟目标 = 过场自动衔接;普通节点 = 播下一节点。 */
advance(target) {
if (typeof target === 'string' && target.startsWith('@')) {
const [kind, arg] = target.slice(1).split(':');
switch (kind) {
case 'rx': this.enterRX(Number(arg)); return;
case 'night': this.enterNIGHT(Number(arg)); return;
case 'morse': this._broadcastMorse(Number(arg)); return;
case 'decode': this._enterDecode(); return;
case 'signoff': this.playNode('g9_in'); return;
case 'ending': this.enterEnding(); return;
case 'p4': this.playNode(this.t.t2 === 'top' ? 'p4_known' : 'p4_blind'); return;
case 'p8': this.playNode(this.t.t4 === 'spy' ? 'p8_known' : 'p8_blind'); return;
case 'p9': this.playNode(this.t.t4 === 'spy' ? 'p9_known' : 'p9_blind'); return;
case 'd9e': this.playNode(this.endingPre <= 4 ? 'd9_send_e' : 'd9_caught_e'); return;
default: return;
}
}
this.playNode(target);
}
/** 抉择:计分(§5.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._touchProgress();
this._save(); // 写入时机:每个抉择结算后
this.advance(g === 'left' ? node.left : node.right);
}
_applyChoice(nodeId, g) {
const L = g === 'left';
const key = nodeId.replace(/_(known|blind)$/, ''); // p4_known/p4_blind → p4(known/blind 差分在提示句)
switch (key) {
case 'p2':
this.picks.p2 = g;
if (!L) { this.expose += 1; this._paperSfx = 'sfx/paper_page'; } // 压日志:值机簿翻页痕(F1)
break;
case 'p4':
this.picks.p4 = g;
if (L) { this.expose += 1; this._paperSfx = 'sfx/paper_page'; } // 暗处理:抽屉/缺页(F1)
else this._paperSfx = 'sfx/pen_scratch'; // 呈报:卷宗落笔(F1)
break;
case 'p6':
this.picks.p6 = g;
if (L) this.expose += 2; // 亲自去:值班室空岗一刻钟
break;
case 'p8':
this.picks.p8 = g;
if (L) this.expose += 2; // 反喂:拍假电的机器记录抹不掉
break;
case 'p9':
this.picks.p9 = g;
this.endingPre = this.expose; // 结局判定用 p9 前值(§5.2)
if (L) this.expose += 2; // 开机信号进了值机室登记(结算后计入)
this._settleGraceUntil = performance.now() + SETTLE_GRACE_MS; // d9 链入口死窗
break;
default:
break;
}
}
// ---------------------------------------------------------------- 帮助 / 复述
/** G2:exp_hot 整句播报位附 warning 震(含上滑复听)。 */
_tierCue(tierId) {
if (tierId === TIER_VOICES.hot) vibrate('warning');
}
/**
* G1 帮助链去重(radio 同款):同一等待点第 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;
}
/** 上滑:状态(第几封 + 密码本 + 暴露档)在前 + 帮助垫后,随后重播当前节点。 */
_playHelpAndResume() {
this.seqToken++;
this.audio.stopVoice();
this._previewToken++;
const ids = [];
if (this.tel >= 1) {
ids.push(PROG_VOICES[this.tel - 1]);
ids.push(BOOK_VOICES[this.hints]);
const tier = TIER_VOICES[tierOf(this.expose)];
this._tierCue(tier);
ids.push(tier);
}
this._withHelp(ids, `S:${this.nodeId}`);
this._playSeq(ids, () => {
this.voicePlaying = false;
if (this.phase === 'INTRO' || this.phase === 'RX' || this.phase === 'RELAY') {
this.playNode(this.nodeId);
}
});
}
// ---------------------------------------------------------------- 看门狗
/** 叙事等待点(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: (n) => {
if (full) return;
this.audio.play(n === 1 ? 'voice/gd_continue' : 'voice/gd_again', { queue: true });
},
cond: () => this.phase === phaseName && !this.voicePlaying,
});
}
/** 抉择点静默兜底:25s/50s 重播当前 p* 提示句本身(复述选项 = 合法帮助)。 */
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.choiceArmed && !this.voicePlaying
&& (this.phase === 'RELAY' || this.phase === 'ENDING'),
});
}
// ---------------------------------------------------------------- 语音链(radio/storyline 范式)
/**
* 顺序播放一组对白,全部自然播完后回调。被打断时回调作废(seqToken 机制)。
* 单句 30s 看门狗:超时且 isVoicePlaying()===false(资产缺失)才自动续链;
* voice 真在播(长句)则续约等待,不得掐断(radio N1 先例照抄)。
*/
_playSeq(ids, onDone) {
const token = ++this.seqToken;
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;
onDone();
return;
}
if (i > 0) this._skipLockId = null; // F4:首句播完,武装跳句
this.currentVoiceId = ids[i];
this.promptPlaying = PROMPT_RE.test(ids[i]);
if (this.promptPlaying) this.guide.state('choice'); // 选项句本身即 turn cue(W2)
if (ids[i] === 'voice/book_1') vibrate('warning'); // R2 F2:密码本只剩最后一页,链内到句即震
let advanced = false;
const armWd = () => setTimeout(() => {
if (advanced || token !== this.seqToken) return;
if (this.audio.isVoicePlaying()) { wd = armWd(); return; } // 长句在播:续约不掐
advanced = true;
console.warn('[CipherGame] 语音看门狗超时(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);
step(i + 1);
},
});
};
step(0);
}
// ---------------------------------------------------------------- 环境音(游戏层循环)
/** 电台静电底噪 loop(RX/DECODE;buffer.loop=true,§3.4 引擎边界表同款)。 */
_startStatic() {
this._staticWant = true;
if (this._staticSrc) return;
const ctx = this.audio.ctx;
if (!ctx) return;
this.audio._load('sfx/static_bed').then((buf) => {
if (!buf || this._staticSrc || !this._staticWant) return;
const src = ctx.createBufferSource();
src.buffer = buf;
src.loop = true;
const gain = ctx.createGain();
gain.gain.value = 0.55 * (this.audio.sfxVolume != null ? this.audio.sfxVolume : 1);
src.connect(gain);
gain.connect(this.audio._sfxBus);
src.start();
this._staticSrc = { src, gain };
}).catch(() => { /* 资产缺失降级:电文仍可听(摩尔斯零依赖) */ });
}
_stopStatic() {
this._staticWant = false;
if (this._staticSrc) {
try { this._staticSrc.src.stop(); } catch (_) { /* 已停止 */ }
this._staticSrc = null;
}
}
/** 值班室 amb loop(挂钟+雨+暖炉,INTRO 起全程底噪)。 */
_startAmb(id) {
this._ambWant = true;
if (this._ambSrc) return;
const ctx = this.audio.ctx;
if (!ctx) return;
this.audio._load(id).then((buf) => {
if (!buf || this._ambSrc || !this._ambWant) return;
const src = ctx.createBufferSource();
src.buffer = buf;
src.loop = true;
const gain = ctx.createGain();
gain.gain.value = 0.5 * (this.audio.sfxVolume != null ? this.audio.sfxVolume : 1);
src.connect(gain);
gain.connect(this.audio._sfxBus);
src.start();
this._ambSrc = { src, gain };
}).catch(() => { /* 资产缺失降级 */ });
}
_stopAmb() {
this._ambWant = false;
if (this._ambSrc) {
try { this._ambSrc.src.stop(); } catch (_) { /* 已停止 */ }
this._ambSrc = null;
}
}
// ---------------------------------------------------------------- 存档(§8.1)
/** data 结构照 §8.1;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 {
const head = this.tel >= 1 ? `第${this.tel}封·${TEL_LABEL[this.tel - 1]}` : '夜班开场';
summary = `${head} · 暴露:${TIER_NAMES[tierOf(this.expose)]} · 结局${k}/4`;
}
const data = {
schema: 1,
updatedAt: new Date().toISOString(),
summary,
phase: this.phase,
node: this.nodeId,
tel: this.tel,
expose: this.expose,
hints: this.hints,
t: { ...this.t },
picks: { ...this.picks },
cand: this.candIdx,
hintUsedT1: this.hintUsedT1,
endingPre: this.endingPre,
ending: this.endingId,
endings: { ...this.endings },
runs: this.runs,
};
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: 'cipher', 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 CipherGame;