四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 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(压缩候选)
1679 lines
65 KiB
JavaScript
1679 lines
65 KiB
JavaScript
/**
|
||
* 《暗码》——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;
|