storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行 (发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/ 菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。 游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio / dungeon / abyss / detective / cipher / teahouse / keeper / bridge / hunt(联机切片): - 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0); 四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt 旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore) - 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动 原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿 - 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/ 台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写 引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record 前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前 状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在 speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。 语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110 废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器 tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a 落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。 大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README / HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
1691 lines
66 KiB
JavaScript
1691 lines
66 KiB
JavaScript
/**
|
||
* 《暗码》R4 —— 1940 密战叙事 RPG,按 docs/design/26-audio-interaction-playbook.md 深度重构。
|
||
*
|
||
* 玩法核心不动(19 号正文):电文生成(4 封固定字符)、摩尔斯运行时合成(morse.js)、
|
||
* 暴露度隐藏账本、4 结局、OP(你)/WIND(北风+系统)双音色。交互层整体换成 26 号模式
|
||
* (设计单正文见 docs/design/19-cipher.md 附录 C;施工接线与偏差见 docs/design/26-apply-cipher.md):
|
||
* - 发言权管理器(§4.1):整条语音链 = SPEAKING(BGM 压低跨链、句间不掉回「轮到你」);
|
||
* 链完 → yieldTurn:交接提示音 → 400ms 后起等待声。摩尔斯排程 = BUSY(手势 busy 回执)。
|
||
* - 输入回执 + 输入管道(§4.2):ok/invalid/busy 三件套;防连点 400ms;滑动打断并执行。
|
||
* - 新手手势宪法(§4.3):doubleTapWindow=0;双击未解锁按单击;上滑=「我在哪」(阶段 1,
|
||
* T1 译对解锁)、下滑=菜单(阶段 2,一局结束)、双击=重听电文原速→慢放(阶段 3)。
|
||
* **废除** DECODE 双击重听/慢放教学、长按翻密码本(翻页走菜单项或迷茫 L3 演示)。
|
||
* - 三档提示(§4.4):每个等待点 full → short → 提示音;主动重听 =「再说一遍:」+ 完整句。
|
||
* - 迷茫检测(§4.5):乱点/无效/反复重听 → 三级接管;L3 = 慢放电文(T1 附免费教学页)。
|
||
* - 断点复述(§4.12):自动存档 + 回来问继续/重开;DECODE 恢复 = prog_t → book 档 →
|
||
* 重挂浏览 + 自动预听当前候选。
|
||
* - 开场 §4.10:第一句 = 「点一下屏幕。」(critical),砍规则念白(b_rule 停用;
|
||
* b_open/b_goal/b_chief 移驻 NIGHT(1) 链首补全叙事)。
|
||
*
|
||
* 摩尔斯与语音互斥(morseGate,19 号 §3.4):摩尔斯只在整条语音链外起振(floor.speaking
|
||
* 为准,句间空隙也不抢发);摩尔斯期间 BGM −6dB(游戏层 _bgmBus dip,R3);u 恒定不受
|
||
* settings.speed/快进影响。
|
||
*
|
||
* 状态 × 手势矩阵(新手期,完整版见 19 号附录 C §4):
|
||
* | 状态 | 单击 | 左/右滑 | 上滑 | 下滑(未解锁) | 双击(未解锁) | 连点 |
|
||
* |---------------|-----------------|------------------------|-----------|--------------|--------------|------------|
|
||
* | 游戏在说 | 跳句+ok | 抉择节点跳去选项/否则 invalid | 我在哪 | invalid+解释 | 按单击 | 400ms busy |
|
||
* | RX 首播摩尔斯 | busy | busy | busy | busy | busy | busy |
|
||
* | DECODE 浏览 | 进确认(电文重播)| 切候选:预听对照档(ok+摩尔斯)| 状态链 | invalid | (阶段3=重听电文)| 迷茫 |
|
||
* | DECODE 确认 | 定下(→揭晓) | 取消回浏览 | busy* | invalid | 按单击 | busy |
|
||
* | 叙事等继续 | 继续+ok | invalid(inv_nosel) | 我在哪 | invalid | (阶段3=重听)| busy |
|
||
* | 叙事等选择 | 再听选项(hold) | 抉择+ok | 「左右滑。」| invalid | (阶段3=重听)| 迷茫 |
|
||
* | 夜结算(自动流)| 跳句+ok | invalid | 状态链 | invalid | 重播夜链 | busy |
|
||
* | 结算 | 新夜班+ok | invalid | 结局+档 | 菜单(阶段2) | 按单击 | 第一下 |
|
||
* | 菜单 | 确认当前项 | 切项并报名 | 重听说明 | 关闭 | 按单击 | busy |
|
||
*/
|
||
import { Game } from '../engine/game.js';
|
||
import { Guide } from '../engine/guide.js';
|
||
import { Floor } from '../engine/floor.js';
|
||
import { Receipts } from '../engine/receipts.js';
|
||
import { Confusion } from '../engine/confusion.js';
|
||
import { WaitPoint, InvalidHints, markHintSaid, hintSaidRecently } from '../engine/hints.js';
|
||
import { GestureLadder } from '../engine/novice.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 非空 = 抉择节点(提示句单独成链,播完即选)。 */
|
||
function N(voices, opts = {}) {
|
||
return {
|
||
voices, prompt: null, left: null, right: null, next: null,
|
||
sfx: null, dyn: null, danger: false, ...opts,
|
||
};
|
||
}
|
||
/** 抉择节点:本体无台词,提示句单独成链(26 号:链完 yieldTurn 抉择等待点)。 */
|
||
function P(prompt, left, right) {
|
||
return { voices: [], prompt, left, right, next: null, sfx: null, dyn: null, danger: false };
|
||
}
|
||
|
||
/**
|
||
* 叙事节点(id = 首句 voice id;台词全文见 19-cipher-voice-ids.md)。
|
||
* '@rx:k'/'@night:k'/'@signoff'/'@ending'/'@p4'/'@p8'/'@p9'/'@d9e' = 过场转移,由 advance() 解析。
|
||
* 开场四句(b_open/b_goal/b_chief)与 sys_skip 移驻 NIGHT(1) 链首/尾(§4.10 砍规则念白,
|
||
* b_rule 停用);rx / dv*_ask 各句由 enterRX/_continueRx 直接驱动,不占节点表。
|
||
*/
|
||
const NODES = {
|
||
// ---- T1 船期·教学(译对 dv1_right 进 p2;译错 dv1_wrong 即时归因回浏览无限重选)
|
||
dv1_right: N(['voice/dv1_right'], { next: 'p2' }),
|
||
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'], { 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'], { 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'], { 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'], { next: 'g9_knot' }),
|
||
g9_knot: N(['voice/g9_knot'], { 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'], { next: '@ending' }),
|
||
d9_caught_e: N(['voice/d9_caught_e'], { danger: true, next: '@ending' }),
|
||
d9_sleep: N(['voice/d9_sleep'], { next: 'd9_sleepe' }),
|
||
d9_sleepe: N(['voice/d9_sleepe'], { 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';
|
||
|
||
/** 揭晓/收尾类节点:链首句 critical(判定出口 800ms 手势死窗的 26 号等价物,§10)。 */
|
||
const PAYOFF_FIRST = new Set(['dv1_right', 'd9_send', 'd9_send_e', 'd9_caught_e', 'd9_sleep', 'd9_sleepe']);
|
||
|
||
const MORSE_GATE_POLL_MS = 150; // morseGate 句间轮询补发间隔(abyss sonarGate 同款)
|
||
const PREVIEW_GAP_MS = 500; // 候选摩尔斯 → 词义句的静默一拍(§3.4 R1 ≥500ms)
|
||
|
||
// 等待点静默阈值(19 号附录 C §5:剧情类 13s;抉择 25s;DECODE 10s;确认 10s;开场 6s)
|
||
const SILENCE = {
|
||
intro: [6000, 15000],
|
||
decode: [10000, 25000],
|
||
confirm: [10000, 20000],
|
||
choice: [25000, 50000],
|
||
continue: [13000, 30000],
|
||
settle: [20000, 45000],
|
||
ask: [12000, 30000],
|
||
menu: [20000, 45000],
|
||
};
|
||
|
||
/** 暴露度档位(§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);
|
||
|
||
// ---- 26 号六模块(构造并暴露,index.html 用 makeInputPipeline 接线)----
|
||
this.receipts = new Receipts(audio);
|
||
this.floor = new Floor(audio);
|
||
this.confusion = new Confusion({
|
||
onConfused: (lvl) => { const wp = this.floor.waitPoint; if (wp) wp.takeover(lvl); },
|
||
});
|
||
this.invalidHints = new InvalidHints().bind(this.floor);
|
||
this.ladder = new GestureLadder();
|
||
this.audio.setRateBoostEnabled(false); // 新手期长按不快进(长按=无效手势,§4.3)
|
||
|
||
// guide 仅保留 danger 签名 earcon + harness 转写钩子(看门狗/纠错语音全部由等待点接管)
|
||
this.guide = new Guide(audio);
|
||
this.guide.setEventHook((ev) => { if (this._earconHook) this._earconHook(ev); });
|
||
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 = null;
|
||
this.tel = 0; // 当前电文号(0=开场)
|
||
this.endingId = null;
|
||
|
||
this.choiceArmed = false; // 抉择提示句播完、等待点左右滑
|
||
this.promptHeard = false; // 当前抉择提示句已开始播(滑=直接抉择;此前滑=跳去选项)
|
||
this.awaitingRestart = false;
|
||
this.leaving = false;
|
||
this.seqToken = 0; // 游戏层异步流作废令牌(摩尔斯尾跳/夜链推进)
|
||
this.goalMet = false; // 第一个小目标(T1 译对)已达成 → 解锁上滑(§4.3 阶梯)
|
||
this.sysSkipShown = false;
|
||
this._gameDoneCounted = false; // 一局只向阶梯报一次 onGameDone
|
||
this._earconHook = null; // 测试钩子:guide 事件外泄
|
||
|
||
// 跨电文标量(§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; // 阶段 3 双击连击计数(第 2 次=慢放 0.5×;其他手势重置)
|
||
this.browseArmed = false; // DECODE 浏览武装(harness/会话判据)
|
||
|
||
this.lastAnnouncedTier = 'calm'; // NIGHT 跨档播报基准(开局 expose=0 → calm)
|
||
this._nightAnnounce = null; // 本次 NIGHT 链尾待播档位句
|
||
this._paperSfx = null; // R2 F1:上一封抉择的纸面标记音(NIGHT 链首垫)
|
||
this.saved = null;
|
||
|
||
// 首播保护窗(会话/直调兜底;管道路径由 floor BUSY 拦截给 busy 回执)
|
||
this._rxGuard = false;
|
||
|
||
// 双循环垫句柄(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, onEnd} morseGate 排队(R1)
|
||
this._gateTimer = null;
|
||
this.morseLog = []; // 摩尔斯排程记录(playtest 固定字符/时值断言挂点,§9)
|
||
this._bgmDipMenu = false; // MENU 低响度淡入(§6.1 省一首:同曲低响度)
|
||
this._bgmDipMorse = false; // R3 摩尔斯期间 −6dB
|
||
}
|
||
|
||
// 兼容访问器(旧字段语义 → 26 号发言权层;harness/会话与 describe 判据沿用)
|
||
|
||
/** 语音链在播(整条链含句间空隙,比 audio.isVoicePlaying 更严)。 */
|
||
get voicePlaying() { return this.floor.speaking; }
|
||
/** 当前在播句 id。 */
|
||
get currentVoiceId() { return this.floor.currentId(); }
|
||
|
||
// ---------------------------------------------------------------- Game
|
||
|
||
start() {
|
||
this.audio.play('bgm/radio_room'); // 开局淡入(bgm=0 时引擎只记意图)
|
||
this.audio.prewarm(ALL_SFX);
|
||
const local = this._loadLocal();
|
||
if (local && local.phase && local.node) { this._askContinue(local); return; }
|
||
let auth = null;
|
||
try { auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); } catch (_) { /* 静默 */ }
|
||
if (!(auth && auth.token)) { this.newNight(); return; }
|
||
let settled = false;
|
||
const goFresh = () => { if (settled) return; settled = true; this.newNight(); };
|
||
fetch('/api/progress?game=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++; // 作废一切在途链回调(摩尔斯尾跳/夜链推进)
|
||
this.floor.interrupt();
|
||
this.morse.cancel();
|
||
this._clampMorseLog();
|
||
this._stopStatic();
|
||
this._stopAmb();
|
||
this._cancelGate();
|
||
this.audio.stopAll();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势(pipeline 之后)
|
||
|
||
/**
|
||
* pipeline 收口后的游戏处理;返回 'ok' | 'hold' | 'invalid' | 'silent-ok'。
|
||
* 语音链中的 tap=跳句、滑动=打断并执行,均由 floor 层统一裁定,到不了这里。
|
||
*/
|
||
onGesture(g) {
|
||
if (this._rxGuard) return 'silent-ok'; // 首播保护窗(管道路径由 floor BUSY 拦截;直调兜底)
|
||
switch (this.phase) {
|
||
case 'ASK': return this._onAskGesture(g);
|
||
case 'MENU': return this._onMenuGesture(g);
|
||
case 'DECODE': return this._onDecodeGesture(g);
|
||
case 'NIGHT': return this._onNightGesture(g);
|
||
case 'ENDING': return this._onEndingGesture(g);
|
||
default: return this._onStoryGesture(g); // INTRO / RX / RELAY
|
||
}
|
||
}
|
||
|
||
stateName() {
|
||
if (this.phase === 'MENU') return 'menu';
|
||
if (this.phase === 'ASK') return 'ask';
|
||
if (this.phase === 'DECODE') return this.confirming ? 'confirm' : 'decode';
|
||
if (this.phase === 'ENDING') return this.awaitingRestart ? 'settle' : 'story';
|
||
if (this.phase === 'RELAY' && this.choiceArmed) return 'choice';
|
||
if (this.phase === 'NIGHT') return 'night';
|
||
return 'story';
|
||
}
|
||
|
||
invalidHint(state, g) {
|
||
if (state === 'locked') {
|
||
if (g === 'up') return 'voice/inv_up_locked';
|
||
if (g === 'down') return 'voice/inv_down_locked';
|
||
return 'voice/gd_invalid';
|
||
}
|
||
if (g === 'left' || g === 'right') {
|
||
if (state === 'menu') return null; // 菜单里左右滑=切项(合法)
|
||
if (state === 'settle') return 'voice/sys_hint_again_full';
|
||
return 'voice/inv_nosel';
|
||
}
|
||
return 'voice/gd_invalid';
|
||
}
|
||
|
||
/** 离开当前等待点:完成它(清静默计时),防旧计时在后续演出中误触发提示。 */
|
||
_detachWp() {
|
||
if (this.floor.waitPoint) this.floor.settleWaitPoint(true);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:ASK
|
||
|
||
_onAskGesture(g) {
|
||
switch (g) {
|
||
case 'tap':
|
||
this._resume(this.saved);
|
||
return 'ok';
|
||
case 'right':
|
||
this.newNight();
|
||
return 'ok';
|
||
case 'up':
|
||
this.floor.speak(['voice/sys_continue_ask'], {
|
||
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
|
||
});
|
||
return 'hold';
|
||
case 'doubletap':
|
||
return this._onAskGesture('tap');
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:INTRO / RX / RELAY
|
||
|
||
_onStoryGesture(g) {
|
||
// 开场前奏(§4.10):第一下单击 → 状态教学 → 第一封电文;其余手势 invalid
|
||
// (双击按单击处理——阶段 3 的重听语义在等待点上,开场还没有「上一句」可重听)
|
||
if (this.phase === 'INTRO' && this.nodeId == null) {
|
||
if (g === 'tap' || g === 'doubletap') { this._afterFirstTap(); return 'ok'; }
|
||
return 'invalid';
|
||
}
|
||
const node = NODES[this.nodeId];
|
||
switch (g) {
|
||
case 'tap': {
|
||
if (!node) return 'invalid';
|
||
const wp = this.floor.waitPoint;
|
||
if (wp && wp.conf.id === 'choice') {
|
||
wp.requestReplay(); // 抉择点空点 = 主动再听选项(「再说一遍:」+ 完整句)
|
||
return 'hold';
|
||
}
|
||
if (node.prompt != null && !this.promptHeard) { this._playPrompt(node); return 'ok'; }
|
||
if (node.next != null) { this.advance(node.next); return 'ok'; }
|
||
return 'invalid';
|
||
}
|
||
case 'doubletap': {
|
||
// 阶段 3 解锁的重听(00 号 §5.2):等待点 → 完整句;否则重播当前节点
|
||
if (this.phase === 'RX') return 'invalid';
|
||
const wp = this.floor.waitPoint;
|
||
if (wp) { wp.requestReplay(); return 'hold'; }
|
||
this._replayNode();
|
||
return 'ok';
|
||
}
|
||
case 'left':
|
||
case 'right': {
|
||
if (!node || node.prompt == null) return 'invalid'; // inv_nosel:现在不用选
|
||
if (this.promptHeard) { this.choose(g); return 'ok'; }
|
||
// 旁白没听完就滑(打断并执行的落点):只跳去选项句,不当场盲选
|
||
this._playPrompt(node);
|
||
return 'ok';
|
||
}
|
||
case 'up': {
|
||
if (!node) return 'invalid';
|
||
if (this.tel < 1) return 'invalid';
|
||
this._detachWp();
|
||
this.floor.speak(this._statusIds(), {
|
||
onDone: () => {
|
||
if (this.phase === 'RX') { this._playRxChain(); return; } // 接回截获链(被上滑打断)
|
||
if (this.phase !== 'RELAY') return;
|
||
if (this.choiceArmed) { this._playPrompt(NODES[this.nodeId]); return; }
|
||
this.playNode(this.nodeId);
|
||
},
|
||
});
|
||
return 'ok';
|
||
}
|
||
case 'down':
|
||
this.openMenu();
|
||
return 'ok';
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:DECODE 解码小局(§2.3)
|
||
|
||
_onDecodeGesture(g) {
|
||
switch (g) {
|
||
case 'tap': {
|
||
this.dtCount = 0;
|
||
if (this.confirming) { this._settleDecode(); return 'ok'; } // 第 2 击:定下
|
||
this._confirmEntry(); // 第 1 击:进确认
|
||
return 'ok';
|
||
}
|
||
case 'doubletap': {
|
||
// 阶段 3(pipeline 已把未解锁双击折算成 tap):重听电文原速;再请求=慢放 0.5×
|
||
if (this.confirming) { this._settleDecode(); return 'ok'; }
|
||
this.dtCount += 1;
|
||
this.confusion.recordReplay();
|
||
const tel = TELS[this.decodeTel - 1];
|
||
this._previewToken++;
|
||
this.morse.cancel();
|
||
this._clampMorseLog();
|
||
this._morseTx(tel.word, this.dtCount >= 2 ? MORSE_PRESET.slow : MORSE_PRESET.tx);
|
||
return 'hold'; // 电文滴答即内容反馈;等待点仍在(重听没完成这个点)
|
||
}
|
||
case 'left':
|
||
case 'right': {
|
||
this.dtCount = 0;
|
||
if (this.confirming) { this._cancelConfirm(); return 'hold'; } // 取消后同步重挂浏览等待点 → hold 保留
|
||
this._switchCandidate(g === 'right' ? 1 : -1);
|
||
// 偏差记录:设计单为 silent-ok(摩尔斯即内容音),但管道无 silent-hold 档,
|
||
// 为保留等待点(三档提示计数)取 hold(附 ok 回执)。见 26-apply-cipher.md §2。
|
||
return 'hold';
|
||
}
|
||
case 'up': {
|
||
this.dtCount = 0;
|
||
this._previewToken++;
|
||
this.morse.cancel();
|
||
this._clampMorseLog();
|
||
this._cancelGate();
|
||
this._detachWp();
|
||
this.floor.speak(this._statusIds(), {
|
||
onDone: () => {
|
||
if (this.phase === 'DECODE') {
|
||
this._armBrowse();
|
||
this._requestPreview({ auto: true }); // 30s 内刚说过的不原样重复(原则 9)
|
||
}
|
||
},
|
||
});
|
||
return 'ok';
|
||
}
|
||
case 'down':
|
||
this.dtCount = 0;
|
||
this.openMenu();
|
||
return 'ok';
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
/** 两段确认第 1 击:cf_confirm → 电文原速重播 + 确认等待点(重播期间即可定下/取消)。 */
|
||
_confirmEntry() {
|
||
this._detachWp();
|
||
this.confirming = true;
|
||
this._previewToken++;
|
||
this.morse.cancel();
|
||
this._clampMorseLog();
|
||
this._cancelGate();
|
||
const tel = TELS[this.decodeTel - 1];
|
||
this.floor.speak(['voice/cf_confirm'], {
|
||
onDone: () => {
|
||
if (this.phase !== 'DECODE' || !this.confirming) return;
|
||
this.floor.yieldTurn(this._confirmWp());
|
||
this._morseTx(tel.word, MORSE_PRESET.tx);
|
||
},
|
||
});
|
||
}
|
||
|
||
/** 确认态取消:掐电文重播,回浏览并预听当前候选。 */
|
||
_cancelConfirm() {
|
||
this.confirming = false;
|
||
this.floor.interrupt();
|
||
this.morse.cancel();
|
||
this._clampMorseLog();
|
||
this._cancelGate();
|
||
this._armBrowse();
|
||
this._requestPreview();
|
||
}
|
||
|
||
/** 判定落定(译词):T1 教学即时反馈/无限重选零惩罚;T2-T4 不揭穿直入剧情。 */
|
||
_settleDecode() {
|
||
const tel = TELS[this.decodeTel - 1];
|
||
const sel = tel.cands[this.candIdx];
|
||
this._detachWp();
|
||
this.browseArmed = false;
|
||
this._previewToken++;
|
||
this.morse.cancel();
|
||
this._clampMorseLog();
|
||
this._cancelGate();
|
||
if (this.decodeTel === 1) {
|
||
this.t.t1 = 'bay'; // T1 恒收敛真词(教学期零惩罚,A5)
|
||
if (sel.id === 'bay') {
|
||
vibrate('success'); // 首个正反馈(§10 ≈t+2min)
|
||
this.phase = 'RELAY';
|
||
this._save();
|
||
const seq = ['voice/dv1_right'];
|
||
// 完成第一个小目标 → 解锁上滑(§4.3 阶梯阶段 1),教学句挂揭晓链尾
|
||
if (!this.goalMet) {
|
||
this.goalMet = true;
|
||
const got = this.ladder.onGoal();
|
||
if (got) seq.push(this.ladder.teachLine(got));
|
||
}
|
||
this.nodeId = 'dv1_right';
|
||
this._speakPayoff(seq, () => this._onNodeSeqDone(NODES.dv1_right));
|
||
} else {
|
||
// 即时真话归因(字母/码写死句内,与 §2.2 电码表一致)→ 回浏览无限重选
|
||
this.nodeId = 'dv1_wrong';
|
||
this._speakPayoff(['voice/dv1_wrong'], () => {
|
||
if (this.phase === 'DECODE') this._armBrowse();
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
// 正片:记录认知(t2..t4),译错 +1/封(§5.2 单源事件表);不揭穿,直入 rev_* 剧情
|
||
this.t[`t${this.decodeTel}`] = sel.id;
|
||
if (sel.id !== tel.trueId) this.expose += 1;
|
||
this.phase = 'RELAY';
|
||
if (this.decodeTel === 4) this.audio.play('bgm/closing', { fadeMs: 800 }); // RELAY(4) 起
|
||
this._save();
|
||
this.playNode(`rev${this.decodeTel}`);
|
||
}
|
||
|
||
/**
|
||
* 揭晓/收尾链:首句 critical(<3s)。判定出口 800ms 手势死窗的 26 号等价物——
|
||
* 决定性单击后的抢拍 tap 给 busy 回执,不吞掉整段揭晓(旧死窗是静默吞)。
|
||
*/
|
||
_speakPayoff(ids, onDone) {
|
||
if (!ids || !ids.length) { if (onDone) onDone(); return; }
|
||
this.floor.speak([ids[0]], {
|
||
critical: true,
|
||
onDone: () => {
|
||
if (ids.length > 1) this.floor.speak(ids.slice(1), { onDone });
|
||
else if (onDone) onDone();
|
||
},
|
||
});
|
||
}
|
||
|
||
/** 左右滑切候选(循环),每切自动预听(候选摩尔斯对照档 → 一拍 → 词义句)。 */
|
||
_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;语音在播则排队补发)。auto=true:30s 内说过的不重复。 */
|
||
_requestPreview(opts = {}) {
|
||
const tel = TELS[this.decodeTel - 1];
|
||
const cand = tel.cands[this.candIdx];
|
||
if (opts.auto && hintSaidRecently(cand.cand)) return; // 原则 9:不原样重复
|
||
this._requestMorse({ kind: 'preview', word: cand.word, preset: MORSE_PRESET.cand });
|
||
}
|
||
|
||
/** 两段确认后的电文重播 / 阶段 3 重听(原速/慢放),走 morseGate。 */
|
||
_morseTx(word, preset, onEnd) {
|
||
this._requestMorse({ kind: 'tx', word, preset, onEnd });
|
||
}
|
||
|
||
/** 摩尔斯与语音互斥的判据:整条链(含句间空隙)都算「语音在播」。 */
|
||
_busyVoice() {
|
||
return this.floor.speaking || this.audio.isVoicePlaying();
|
||
}
|
||
|
||
/**
|
||
* morseGate(§3.4 R1):摩尔斯只在语音链外起振;在播则挂队列表、句间轮询补发
|
||
* (abyss sonarGate 同款)。候选预听链 = 候选摩尔斯 → 静默一拍(≥500ms)→
|
||
* cand_* 词义句(给耳朵留对照时间)。
|
||
*/
|
||
_requestMorse(req) {
|
||
this._morsePending = req;
|
||
if (!this._busyVoice()) { this._fireMorse(); return; }
|
||
if (this._gateTimer) clearTimeout(this._gateTimer);
|
||
const poll = () => {
|
||
this._gateTimer = null;
|
||
if (!this._morsePending) return;
|
||
if (this._busyVoice()) {
|
||
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) { if (req.onEnd) req.onEnd(); 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 });
|
||
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);
|
||
if (req.kind === 'preview') {
|
||
const token = this._previewToken;
|
||
setTimeout(() => {
|
||
if (token !== this._previewToken || this.phase !== 'DECODE' || this.confirming) return;
|
||
if (this.floor.speaking) return; // 提示/状态在播:词义句让路(下次滑词再听)
|
||
const candVoice = TELS[this.decodeTel - 1].cands[this.candIdx].cand;
|
||
markHintSaid(candVoice); // 自动重挂(菜单关闭/状态链尾)不原样重复的判据
|
||
this.floor.speak([candVoice], {
|
||
onDone: () => {
|
||
if (this.floor.waitPoint && this.phase === 'DECODE' && !this.confirming) {
|
||
this.floor.resumeTurn();
|
||
}
|
||
},
|
||
});
|
||
}, waitMs + PREVIEW_GAP_MS);
|
||
} else if (req.onEnd) {
|
||
setTimeout(() => { req.onEnd(); }, waitMs + 40); // 调用方自带 phase 守卫
|
||
}
|
||
}
|
||
|
||
/** 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 (_) { /* 静默 */ }
|
||
}
|
||
}
|
||
|
||
/** 浏览武装:交出 DECODE 等待点(交接提示音 → 等待声 → 三档提示/迷茫检测)。 */
|
||
_armBrowse() {
|
||
this.browseArmed = true;
|
||
this.confirming = false;
|
||
this.floor.yieldTurn(this._decodeWp());
|
||
}
|
||
|
||
/** 翻密码本(菜单项 / 迷茫 L3 入口;账目逐值为真;页尽 = error 震 + 翻空本声零惩罚)。 */
|
||
_useHint() {
|
||
if (this.phase !== 'DECODE') return 'invalid';
|
||
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 'hold'; // 等待点仍在(翻页失败没完成这个点)
|
||
}
|
||
this._detachWp();
|
||
this.confirming = false;
|
||
this._previewToken++;
|
||
this.morse.cancel();
|
||
this._clampMorseLog();
|
||
this._cancelGate();
|
||
if (free) this.hintUsedT1 = true; // T1 教学首次翻页免费(Prime→Teach)
|
||
else this.hints -= 1;
|
||
this.audio.play('sfx/paper_page');
|
||
this.floor.speak([tel.hint], {
|
||
onDone: () => {
|
||
if (this.phase === 'DECODE') {
|
||
this._armBrowse();
|
||
this._requestPreview({ auto: true });
|
||
}
|
||
},
|
||
});
|
||
this._save();
|
||
return 'ok';
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:NIGHT
|
||
|
||
_onNightGesture(g) {
|
||
switch (g) {
|
||
case 'tap':
|
||
return 'invalid'; // 链中 tap=跳句(floor 层);夜链自动流转,无空点等待
|
||
case 'doubletap':
|
||
this._playNightChain(); // 阶段 3:重播夜结算链
|
||
return 'ok';
|
||
case 'up': {
|
||
// 状态在前(G6):prog_t → book 档 → exp 档,随后续播夜链
|
||
this._detachWp();
|
||
this.floor.speak(this._statusIds(), {
|
||
onDone: () => { if (this.phase === 'NIGHT') this._playNightChain(); },
|
||
});
|
||
return 'ok';
|
||
}
|
||
case 'down':
|
||
this.openMenu();
|
||
return 'ok';
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:ENDING
|
||
|
||
_onEndingGesture(g) {
|
||
switch (g) {
|
||
case 'tap':
|
||
if (this.awaitingRestart) { this.newNight(); return 'ok'; } // 新夜班(runs+1)
|
||
return 'invalid';
|
||
case 'doubletap':
|
||
if (this.awaitingRestart) { this.newNight(); return 'ok'; }
|
||
this._playEndingChain(); // 阶段 3:重听结局链
|
||
return 'ok';
|
||
case 'up': {
|
||
// 结局主句 + 暴露档(§4 结算行);原链被打断即视为听完,直接进结算等待
|
||
this._detachWp();
|
||
const def = ENDINGS[this.endingId] || ENDINGS.e1;
|
||
const tier = TIER_VOICES[tierOf(this.endingPre)];
|
||
this._tierCue(tier);
|
||
this.floor.speak([def.voices[0], tier], {
|
||
onDone: () => {
|
||
if (this.phase === 'ENDING') {
|
||
this.awaitingRestart = true;
|
||
this.floor.yieldTurn(this._settleWp());
|
||
}
|
||
},
|
||
});
|
||
return 'ok';
|
||
}
|
||
case 'down':
|
||
this.openMenu();
|
||
return 'ok';
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:MENU(00 号 §5.4 模态约定)
|
||
|
||
_onMenuGesture(g) {
|
||
const items = MENU_ITEMS;
|
||
const announce = () => {
|
||
this.floor.speak([items[this.menuIdx].voice], {
|
||
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
|
||
});
|
||
};
|
||
switch (g) {
|
||
case 'left':
|
||
case 'right':
|
||
this.menuIdx = (this.menuIdx + (g === 'right' ? 1 : items.length - 1)) % items.length;
|
||
announce();
|
||
return 'hold';
|
||
case 'tap':
|
||
case 'doubletap':
|
||
return this._execMenuItem(items[this.menuIdx].id);
|
||
case 'up': // 重听菜单说明 + 设置提示 + 当前项
|
||
this.floor.speak(['voice/sys_menu', 'voice/sys_settings', items[this.menuIdx].voice], {
|
||
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
|
||
});
|
||
return 'hold';
|
||
case 'down':
|
||
this.closeMenu();
|
||
return 'hold'; // 关闭回原状态会同步重挂等待点(如 DECODE 浏览)→ hold 保留
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
/** 执行菜单项;返回回执档:resume 同步重挂等待点 → hold,其余 ok(语音链接管)。 */
|
||
_execMenuItem(id) {
|
||
if (id === 'resume') {
|
||
this.closeMenu();
|
||
return 'hold';
|
||
} else if (id === 'book') {
|
||
// 「翻密码本」菜单项(P4 冗余入口;长按捷径已废止)
|
||
if (this.returnPhase === 'DECODE') {
|
||
this.closeMenu(true);
|
||
this._useHint();
|
||
} else {
|
||
this.floor.speak(['voice/gd_invalid'], {
|
||
kind: 'feedback',
|
||
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
|
||
});
|
||
}
|
||
} else if (id === 'restart') {
|
||
this.newNight();
|
||
} else if (id === 'lobby') {
|
||
this.leaving = true;
|
||
this._detachWp();
|
||
this.floor.interrupt();
|
||
this._save();
|
||
this.floor.speak(['voice/sys_menu_lobby'], {
|
||
onDone: () => {
|
||
try {
|
||
if (typeof window !== 'undefined' && window.location) window.location.href = '../';
|
||
} catch (_) { /* 无头环境无 location:留档 leaving 标记 */ }
|
||
},
|
||
});
|
||
}
|
||
return 'ok';
|
||
}
|
||
|
||
openMenu() {
|
||
this.returnPhase = this.phase;
|
||
this.returnNode = this.nodeId;
|
||
this.phase = 'MENU';
|
||
this.menuIdx = 0;
|
||
this.floor.interrupt();
|
||
this.morse.cancel();
|
||
this._clampMorseLog();
|
||
this._cancelGate();
|
||
this._previewToken++;
|
||
this._rxGuard = false;
|
||
this.browseArmed = false;
|
||
this.confirming = false;
|
||
this.audio.play('sfx/ui_menu');
|
||
this._bgmDipMenu = true; // 同曲低响度(§6.1)
|
||
this._applyBgmGain();
|
||
this.floor.speak(['voice/sys_menu', MENU_ITEMS[0].voice], {
|
||
onDone: () => this.floor.yieldTurn(this._menuWp()),
|
||
});
|
||
}
|
||
|
||
/** 关闭菜单回原状态。skipArm=true:不重挂浏览(调用方接管,如翻密码本)。 */
|
||
closeMenu(skipArm = false) {
|
||
this.floor.interrupt();
|
||
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 });
|
||
if (back === 'DECODE') {
|
||
// 局面复述:重挂浏览并自动预听当前候选(§3.2;30s 内说过的不重复)
|
||
this._startStatic();
|
||
if (skipArm) return;
|
||
this._armBrowse();
|
||
this._requestPreview({ auto: true });
|
||
} else if (back === 'NIGHT') {
|
||
this._playNightChain();
|
||
} else if (back === 'ENDING') {
|
||
this._playEndingChain();
|
||
} else if (back === 'ASK') {
|
||
this.floor.speak(['voice/sys_continue_ask'], {
|
||
onDone: () => this.floor.yieldTurn(this._askWp()),
|
||
});
|
||
} else if (back === 'RX') {
|
||
this._playRxChain(); // RX 无可重播节点:接回截获链
|
||
} else if (back === 'INTRO') {
|
||
this.floor.yieldTurn(this._introWp()); // 开场前奏:仍等第一下单击
|
||
} else if (this.returnNode && NODES[this.returnNode]) {
|
||
this.playNode(this.returnNode); // INTRO/RX/RELAY:重播当前节点
|
||
} else {
|
||
this.newNight(); // 兜底:无有效返回节点(理论上不可达)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 夜班流转
|
||
|
||
/**
|
||
* 新夜班:重置跨电文标量(结局收集跨夜保留)。首局第一句 = 「点一下屏幕。」
|
||
* (critical,§4.10);此后只播 sys_new_night(做对过的事不教第二遍,原则 11)。
|
||
*/
|
||
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._nightAnnounce = null;
|
||
this._paperSfx = null;
|
||
this.leaving = false;
|
||
this.saved = null;
|
||
this.choiceArmed = false;
|
||
this.promptHeard = false;
|
||
this.browseArmed = false;
|
||
this.confirming = false;
|
||
this.dtCount = 0;
|
||
this._rxGuard = false;
|
||
this.goalMet = this.ladder.stage() >= 1; // 跨局记住的解锁不重教
|
||
this._gameDoneCounted = false;
|
||
this.seqToken++;
|
||
this.floor.interrupt();
|
||
this.audio.stopVoice();
|
||
this.morse.cancel();
|
||
this._clampMorseLog();
|
||
this._stopStatic();
|
||
this._cancelGate();
|
||
this.phase = 'INTRO';
|
||
this.nodeId = null;
|
||
this.audio.play('bgm/radio_room');
|
||
if (this.ambLoopEnabled) this._startAmb('sfx/amb_room'); // 值班室夜境(雨+挂钟+暖炉)
|
||
if (this.runs <= 1) {
|
||
// 0~3 秒:第一句就让玩家动手(§4.10);指令句 critical:抢拍单击 = busy 回执
|
||
this.floor.speak(['voice/sys_first_tap'], {
|
||
critical: true,
|
||
onDone: () => this.floor.yieldTurn(this._introWp()),
|
||
});
|
||
} else {
|
||
this.floor.speak(['voice/sys_new_night'], {
|
||
onDone: () => this.floor.yieldTurn(this._introWp()),
|
||
});
|
||
}
|
||
}
|
||
|
||
/** 首次单击之后:教「轮到你了」状态声(原则 2),随即第一封电文进频道。 */
|
||
_afterFirstTap() {
|
||
this.floor.speak(['voice/sys_turn_teach'], {
|
||
onDone: () => this.enterRX(1),
|
||
});
|
||
}
|
||
|
||
/** RX:截获第 k 封。调谐 + 引入链,静电底噪 loop 起,进入即存档。 */
|
||
enterRX(k) {
|
||
this.tel = k;
|
||
this.phase = 'RX';
|
||
this._startStatic();
|
||
if (k > 1) this.audio.play('bgm/radio_room', { fadeMs: 600 }); // NIGHT 淡回
|
||
this._save(); // 写入时机:每封 RX 进入
|
||
this._playRxChain();
|
||
}
|
||
|
||
/** RX 引入链:调谐标记音 + 引入句 → 电文摩尔斯首播(上滑打断后也走这里接回)。 */
|
||
_playRxChain() {
|
||
const k = this.tel;
|
||
this._detachWp();
|
||
this.audio.play('sfx/tune_seek');
|
||
this.floor.speak([`voice/${TELS[k - 1].rx}`], { onDone: () => this._broadcastMorse(k) });
|
||
}
|
||
|
||
/**
|
||
* 电文摩尔斯首播(RX 链内):原速 750Hz,不可跳——floor=BUSY,玩法手势一律
|
||
* busy 回执(不判无效不惩罚,G4 等价物)。首播完接教学/提示句,链完进 DECODE。
|
||
*/
|
||
_broadcastMorse(k) {
|
||
const tel = TELS[k - 1];
|
||
const ctx = this.audio.ctx;
|
||
this._rxGuard = true;
|
||
this.browseArmed = false;
|
||
this._detachWp();
|
||
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._continueRx(k);
|
||
}, waitMs + 40);
|
||
};
|
||
// morseGate:首播在引入链尾(语音链已完),空闲直发,否则轮询
|
||
if (!this._busyVoice()) { fire(); return; }
|
||
const poll = () => {
|
||
if (token !== this.seqToken) return;
|
||
if (this._busyVoice()) { setTimeout(poll, MORSE_GATE_POLL_MS); return; }
|
||
fire();
|
||
};
|
||
setTimeout(poll, MORSE_GATE_POLL_MS);
|
||
}
|
||
|
||
/** 首播后的提示句链(T1 = 教学句 critical;正片 = dv*_ask),链完进 DECODE 浏览。 */
|
||
_continueRx(k) {
|
||
if (k === 1) {
|
||
this.floor.speak(['voice/dv1_teach'], { critical: true, onDone: () => this._enterDecode() });
|
||
} else {
|
||
this.floor.speak([`voice/${TELS[k - 1].ask}`], { onDone: () => this._enterDecode() });
|
||
}
|
||
}
|
||
|
||
/** 进入 DECODE 浏览:候选索引归零、交出等待点、自动预听候选 1。 */
|
||
_enterDecode() {
|
||
this.phase = 'DECODE';
|
||
this.decodeTel = this.tel;
|
||
this.candIdx = 0;
|
||
this.dtCount = 0;
|
||
this._rxGuard = false;
|
||
this._save();
|
||
this._armBrowse();
|
||
this._requestPreview(); // 进浏览自动预听候选 1(新词,必播)
|
||
}
|
||
|
||
/** NIGHT:夜结算。开场叙事移驻链首 + n_k 过渡句 + 跨档 exp_* 播报(跨档才播)。 */
|
||
enterNIGHT(k) {
|
||
this.phase = 'NIGHT';
|
||
this._stopStatic();
|
||
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._save(); // 写入时机:NIGHT 进入
|
||
this._playNightChain();
|
||
}
|
||
|
||
/** NIGHT 链:T1 夜补全开场叙事(b_rule 停用)→ n_k → 跨档句 → sys_skip(每夜一次)。 */
|
||
_playNightChain() {
|
||
this._detachWp();
|
||
const nNode = NODES[`n${this.tel}`];
|
||
const ids = this._nodeVoices(`n${this.tel}`);
|
||
if (this.tel === 1) {
|
||
ids.unshift('voice/b_open', 'voice/b_goal', 'voice/b_chief');
|
||
this.audio.play('sfx/tea_cup'); // b_chief 场标记音(链首近似,floor 链无逐句钩子)
|
||
}
|
||
if (this._nightAnnounce) {
|
||
ids.push(this._nightAnnounce);
|
||
this._nightAnnounce = null;
|
||
}
|
||
if (this.tel === 1 && !this.sysSkipShown) {
|
||
this.sysSkipShown = true;
|
||
ids.push('voice/sys_skip'); // 每夜一次:教「单击跳句」
|
||
}
|
||
if (this._paperSfx) { // 压日志/暗处理/呈报的纸面痕迹在夜结算垫一记(现有 sfx 零新增)
|
||
this.audio.play(this._paperSfx);
|
||
this._paperSfx = null;
|
||
}
|
||
this.floor.speak(ids, {
|
||
onDone: () => {
|
||
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.audio.play('bgm/ending', { fadeMs: 1500 });
|
||
this._save(); // 写入时机:ENDING 进入
|
||
this._playEndingChain();
|
||
}
|
||
|
||
_playEndingChain() {
|
||
this._detachWp();
|
||
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)}`);
|
||
// 一局结束 → 阶梯阶段 2/3 解锁教学挂链尾(§4.3;一局只报一次)
|
||
if (!this._gameDoneCounted) {
|
||
this._gameDoneCounted = true;
|
||
const got = this.ladder.onGameDone();
|
||
if (got) chain.push(this.ladder.teachLine(got));
|
||
}
|
||
this._speakPayoff(chain, () => {
|
||
this.awaitingRestart = true;
|
||
this.floor.yieldTurn(this._settleWp());
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 存档续玩
|
||
|
||
_askContinue(save) {
|
||
this.saved = save;
|
||
this.phase = 'ASK';
|
||
this.floor.speak(['voice/sys_continue_ask'], {
|
||
onDone: () => this.floor.yieldTurn(this._askWp()),
|
||
});
|
||
}
|
||
|
||
/** 断点续玩:恢复 phase+node+全部数值并局面复述(prog_t → book 档 → 重挂浏览+预听)。 */
|
||
_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.goalMet = this.ladder.stage() >= 1;
|
||
this.leaving = false;
|
||
this.seqToken++;
|
||
this.floor.interrupt();
|
||
this.morse.cancel();
|
||
this._clampMorseLog();
|
||
this._stopStatic();
|
||
const phase = s.phase;
|
||
if (phase === 'DECODE' && (s.tel | 0) >= 1 && (s.tel | 0) <= 4) {
|
||
// DECODE 恢复:prog_t → book 档 → 重挂浏览 + 自动预听(§8.1;gd_tune2 已停用)
|
||
this.phase = 'DECODE';
|
||
this.decodeTel = s.tel;
|
||
this.candIdx = Math.min(2, s.cand | 0);
|
||
this._startStatic();
|
||
this._save();
|
||
this.floor.speak([PROG_VOICES[this.decodeTel - 1], BOOK_VOICES[this.hints]], {
|
||
onDone: () => {
|
||
if (this.phase === 'DECODE') {
|
||
this._armBrowse();
|
||
this._requestPreview({ auto: true });
|
||
}
|
||
},
|
||
});
|
||
} else if (phase === 'NIGHT') {
|
||
this.phase = 'NIGHT';
|
||
this.audio.play('bgm/night', { fadeMs: 600 });
|
||
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 if ((phase === 'RELAY') && NODES[s.node]) {
|
||
this.phase = 'RELAY';
|
||
this.playNode(s.node);
|
||
} else {
|
||
this.newNight(); // 兜底:存档不可解析(理论上不可达)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 节点链驱动
|
||
|
||
playNode(id) {
|
||
const node = NODES[id];
|
||
if (!node) {
|
||
console.error('[CipherGame] 未知节点:', id);
|
||
return;
|
||
}
|
||
this.nodeId = id;
|
||
this.promptHeard = false;
|
||
this.choiceArmed = false;
|
||
this.awaitingRestart = false;
|
||
this._detachWp();
|
||
const seq = this._nodeVoices(id);
|
||
|
||
// T4 揭晓 danger 拍(§3.4 R4 / §6.2 预警窗纪律):仅 rev_spy 一次——step_halt 前置
|
||
// 2s 渐强 + danger 签名/震同窗,危机链整段自动流;误读分支常规流入。
|
||
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.floor.state = 'BUSY'; // 演出前置 2s(引擎无公开 API,直置;手势=busy 回执)
|
||
const token = ++this.seqToken;
|
||
setTimeout(() => {
|
||
if (token !== this.seqToken) return;
|
||
this._speakPayoff(this._nodeVoices(id), () => this._onNodeSeqDone(node));
|
||
}, rev4Spy ? 2000 : 0);
|
||
return;
|
||
}
|
||
if (node.sfx) this.audio.play(node.sfx);
|
||
const done = () => {
|
||
if (node.prompt != null) this._playPrompt(node);
|
||
else this._onNodeSeqDone(node);
|
||
};
|
||
if (!seq.length) { done(); return; }
|
||
if (PAYOFF_FIRST.has(id) || node.dyn) this._speakPayoff(seq, done);
|
||
else this.floor.speak(seq, { onDone: done });
|
||
}
|
||
|
||
/** 只播抉择提示句并交出抉择(旁白被打断跳来这里;W2:播完当刻即可选)。 */
|
||
_playPrompt(node) {
|
||
this._detachWp();
|
||
this.promptHeard = true;
|
||
markHintSaid(node.prompt); // 刚播过:等待点三档提示自动降档(不原样重复,原则 9)
|
||
this.floor.speak([node.prompt], {
|
||
onDone: () => {
|
||
this.choiceArmed = true;
|
||
this.floor.yieldTurn(this._choiceWp(node));
|
||
},
|
||
});
|
||
}
|
||
|
||
/** 阶段 3 双击:重播当前节点(含抉择提示句)。 */
|
||
_replayNode() {
|
||
const node = NODES[this.nodeId];
|
||
if (!node) return;
|
||
this.playNode(this.nodeId);
|
||
}
|
||
|
||
/** 节点基础语音链(节点内条件选播:rev2/rev3/rev4/r3bsel 归因)。 */
|
||
_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];
|
||
}
|
||
|
||
/** '@x' 虚拟目标 = 过场转移(链完自动衔接,§3.2)。 */
|
||
_isTransition(target) {
|
||
return typeof target === 'string' && target.startsWith('@');
|
||
}
|
||
|
||
/**
|
||
* 节点链自然播完的公共收尾:抉择提示句单独成链;auto/过场直通;其余链末交出
|
||
* 「继续」等待点(tap=继续,13s/30s 三档提示)。
|
||
*/
|
||
_onNodeSeqDone(node) {
|
||
if (this.nodeId === 'dv1_wrong') {
|
||
// T1 译错:即时归因后回浏览(无限重选零惩罚,§2.3)
|
||
if (this.phase === 'DECODE') this._armBrowse();
|
||
return;
|
||
}
|
||
if (node.prompt != null) { this._playPrompt(node); return; }
|
||
if (node.next == null) return;
|
||
if (this._isTransition(node.next)) { this.advance(node.next); return; }
|
||
this.floor.yieldTurn(this._continueWp());
|
||
}
|
||
|
||
/** 链完转移:'@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 '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._detachWp();
|
||
this.choiceArmed = false;
|
||
this.promptHeard = false;
|
||
this.audio.play('sfx/ui_confirm');
|
||
this._applyChoice(this.nodeId, g);
|
||
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(差分在提示句)
|
||
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; // 开机信号进了值机室登记(结算后计入)
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 状态链(上滑「我在哪」)
|
||
|
||
/** G2:exp_hot 整句播报位附 warning 震(book_1 为链首近似,floor 链无逐句钩子)。 */
|
||
_tierCue(tierId) {
|
||
if (tierId === TIER_VOICES.hot) vibrate('warning');
|
||
}
|
||
|
||
/** 上滑状态链(原则 3):第几封 → 密码本余页 → 暴露档(分档整句,逐值为真)。 */
|
||
_statusIds() {
|
||
const n = this.phase === 'DECODE' ? this.decodeTel : Math.min(4, Math.max(1, this.tel));
|
||
const book = BOOK_VOICES[this.hints];
|
||
const tier = TIER_VOICES[tierOf(this.expose)];
|
||
this._tierCue(tier);
|
||
if (book === 'voice/book_1') vibrate('warning'); // R2 F2:最后一页预警(链首近似)
|
||
return [PROG_VOICES[n - 1], book, tier];
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 等待点(26 号附录 A)
|
||
|
||
_introWp() {
|
||
return new WaitPoint({
|
||
id: 'intro',
|
||
expected: ['tap'],
|
||
full: null, // 指令句刚说过:静默第一档直接短句(§4.10 找猫示例同型)
|
||
short: 'voice/sys_first_tap_short',
|
||
silenceMs: SILENCE.intro,
|
||
onDemo: () => {
|
||
this.floor.speak(['voice/sys_demo_continue'], {
|
||
onDone: () => this._afterFirstTap(), // 演示「继续」:直接替玩家走一步
|
||
});
|
||
},
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_continueWp() {
|
||
return new WaitPoint({
|
||
id: 'continue',
|
||
expected: ['tap'],
|
||
full: 'voice/sys_hint_continue_full',
|
||
short: 'voice/sys_hint_continue_short',
|
||
silenceMs: SILENCE.continue,
|
||
onDemo: () => {
|
||
this.floor.speak(['voice/sys_demo_continue'], {
|
||
onDone: () => {
|
||
const node = NODES[this.nodeId];
|
||
if (node && node.next != null && node.prompt == null) this.advance(node.next);
|
||
else this.floor.yieldTurn(this._continueWp());
|
||
},
|
||
});
|
||
},
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_decodeWp() {
|
||
return new WaitPoint({
|
||
id: 'decode',
|
||
expected: ['left', 'right'],
|
||
full: 'voice/gd_tune1',
|
||
short: 'voice/gd_tune1_short',
|
||
silenceMs: SILENCE.decode,
|
||
onDemo: () => this._demoDecode(),
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
/** 迷茫 L3(§3/§5):T1 = 慢放 + 揭示辨析位(首次免费页);正片 = 慢放电文一遍再交还。 */
|
||
_demoDecode() {
|
||
const tel = TELS[this.decodeTel - 1];
|
||
this._detachWp();
|
||
this._previewToken++;
|
||
this.morse.cancel();
|
||
this._clampMorseLog();
|
||
this._cancelGate();
|
||
this.floor.state = 'BUSY'; // 演出中:手势 busy(引擎无公开 API,直置)
|
||
const free = this.decodeTel === 1 && !this.hintUsedT1;
|
||
if (free) {
|
||
this.hintUsedT1 = true; // 教学首次翻页免费(系统自动给一页,§3)
|
||
this.audio.play('sfx/paper_page');
|
||
}
|
||
this._morseTx(tel.word, MORSE_PRESET.slow, () => {
|
||
if (this.phase !== 'DECODE') return;
|
||
if (free) {
|
||
this.floor.speak([tel.hint], {
|
||
onDone: () => { if (this.phase === 'DECODE') this._armBrowse(); },
|
||
});
|
||
} else {
|
||
this._armBrowse();
|
||
}
|
||
});
|
||
}
|
||
|
||
_confirmWp() {
|
||
return new WaitPoint({
|
||
id: 'confirm',
|
||
expected: ['tap'],
|
||
full: 'voice/cf_hint_full',
|
||
short: 'voice/cf_hint_short',
|
||
silenceMs: SILENCE.confirm,
|
||
onDemo: () => this._settleDecode(), // 替确认(设计单 §5;迷茫兜底要能推进)
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_choiceWp(node) {
|
||
return new WaitPoint({
|
||
id: 'choice',
|
||
expected: ['left', 'right'],
|
||
full: node.prompt,
|
||
short: 'voice/sys_hint_choice_short',
|
||
restrictLine: 'voice/sys_only_choice',
|
||
silenceMs: SILENCE.choice,
|
||
onDemo: () => {
|
||
// 演示不替玩家做有后果的抉择:复述两个方向,交回
|
||
this.floor.speak(['voice/sys_demo_choice'], {
|
||
onDone: () => this.floor.yieldTurn(this._choiceWp(node)),
|
||
});
|
||
},
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_settleWp() {
|
||
return new WaitPoint({
|
||
id: 'settle',
|
||
expected: ['tap'],
|
||
full: 'voice/sys_hint_again_full',
|
||
short: 'voice/sys_hint_continue_short',
|
||
silenceMs: SILENCE.settle,
|
||
onDemo: () => this.floor.chime(), // 不替玩家开新夜班
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_askWp() {
|
||
return new WaitPoint({
|
||
id: 'ask',
|
||
expected: ['tap', 'right'],
|
||
full: 'voice/sys_continue_ask',
|
||
short: 'voice/sys_hint_continue_short',
|
||
silenceMs: SILENCE.ask,
|
||
onDemo: () => this.floor.chime(),
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_menuWp() {
|
||
return new WaitPoint({
|
||
id: 'menu',
|
||
expected: ['tap', 'left', 'right'],
|
||
full: 'voice/sys_menu',
|
||
short: 'voice/gd_tune1_short', // 「左右滑。」(与 DECODE 短句同文案复用资产)
|
||
silenceMs: SILENCE.menu,
|
||
onDemo: () => this.floor.chime(),
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 环境音(游戏层循环)
|
||
|
||
/** 电台静电底噪 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(挂钟+雨+暖炉,开局起全程底噪)。 */
|
||
_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;
|