Files
SoundGame/web/cipher/game.js
T
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
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 置完成。
2026-10-02 12:33:20 +08:00

1691 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《暗码》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;