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 置完成。
1818 lines
75 KiB
JavaScript
1818 lines
75 KiB
JavaScript
/**
|
||
* 《雨夜专案》R3 —— 纯音频本格推理,按 docs/design/26-audio-interaction-playbook.md 深度重构。
|
||
* 设计单正文见 18-detective.md 附录 C;施工接线/偏差记录见 26-apply-detective.md。
|
||
*
|
||
* 交互层(对齐 26 号手册,主范式 = web/storyline):
|
||
* - 发言权管理器(§4.1):证词/旁白/判定链 = floor.speak(音乐压低跨链、句间不掉回
|
||
* 「轮到你」听感);链完 → yieldTurn:交接提示音 → 400ms 后等待声。不存在安静态。
|
||
* - 听取 = ACTION(玩法本身的声音就是状态):证词链由游戏层逐句推进(0.8s 句间 +
|
||
* 15s 单句兜底),tap=停、左右滑=打断切条目都直达游戏层(§4.1 裁定「滑动有明确
|
||
* 意图」);压低由游戏层 holdDuck 自管。
|
||
* - 输入管道(§4.2/§4.3):ok/invalid/busy 三件套;doubleTapWindow=0;双击未解锁按
|
||
* 单击处理;上滑=我在哪(阶段 1,听完第一份证词解锁)、下滑=菜单(阶段 2,结案后)、
|
||
* 双击=重听当前句(阶段 3);长按=新手 invalid(不绑指证——指证是案卷列表里的一项)。
|
||
* - 三档提示(§4.4):每个等待点 full → short → 提示音;主动重听=「再说一遍:」+完整句;
|
||
* 条目/选项报名走 30s 台账(刚报过就只给签名 earcon,原则 9)。
|
||
* - 迷茫检测(§4.5):乱点/无效/静默信号 → 三级接管(短句 → 只留期待手势 → 演示)。
|
||
* - 开场(§4.10):第一句就是 critical 指令「点一下屏幕。」,砍掉旧 intro_1..3 规则
|
||
* 念白与七步教学;教学嵌进真实案卷(听第一份 → 教右滑+解锁上滑 → 听第二份 → 教
|
||
* 「滑到指证」→ 首次指证=练习,判定截获 tut_cf_ok,零账目污染)。
|
||
* - 断点复述(§4.12):进展即存档;回来 sys_continue_ask 问继续/重开,续玩先播
|
||
* 状态复述链(已问 X。疑点 n。左右滑)。
|
||
* - VERDICT 死窗+链锁(18 号 §7.4):认罪/结算链 critical——链完成前一切手势只给
|
||
* busy 回执(原 800ms 显式死窗被 critical 全链覆盖,语义保留且更强)。
|
||
* - 无进展轨(18 号 §5「必结案保底」):45s earcon → 90s 短句 → 150s 直接给可指证
|
||
* 矛盾对;只由进展事件(听完新条目/判定/结案推进)重置,乱按饿不死。
|
||
*
|
||
* 玩法核心不动:案卷六份 + 指证入口 + 结案陈词共 8 个条目;判定 = 两句同轴异值 ≡
|
||
* 矛盾表(矩阵不变式生成期断言);seed/pack(mulberry32,pack A 周先生 / pack B 小顺,
|
||
* 结案后强制翻包);评级 S-C 六变量;结算播报值=运行值。
|
||
*
|
||
* 状态 × 手势矩阵(新手期,18 号附录 C §4):
|
||
* | 状态 \ 手势 | 单击 | 左/右滑 | 上滑 | 下滑(未解锁) | 双击(未解锁) | 连点 |
|
||
* |------------------|-------------------|------------------------|-----------------|--------------|--------------|-------------|
|
||
* | 证词在播(ACTION) | 停止+ok | 打断,切条目 | 打断,我在哪 | 打断,菜单 | 按单击=停止 | 400ms busy |
|
||
* | 板上空闲 | 听取;在指证=进指证;在结案=闸→三问 | 换条目+签名+报名 | 我在哪(状态链)| invalid+解释 | (阶段3=重听末句)| busy |
|
||
* | 结案闸 | 进 FINISH | 滑走撤闸+换条目 | 撤闸,我在哪 | invalid | 按单击 | busy |
|
||
* | 指证选条目/句 | 确认进入下一阶段 | 换条目/句(位置记忆) | 「正在指证。左右滑。」| invalid | 按单击 | 计入迷茫 |
|
||
* | 指证确认 | 定下这句 | 取消回预览 | 简状态(疑点数)| invalid | 按单击 | busy |
|
||
* | 结案三问 | 确认/答题 | 换选项(人名/选项名) | 重听问句 | invalid | 按单击 | busy |
|
||
* | 裁决链 | busy(critical 吞)| busy | busy | busy | busy | busy |
|
||
* | 结算 | 接新案 | invalid+解释 | 评级一句 | 菜单(阶段2) | 按单击 | 第一下 |
|
||
* | 菜单 | 确认当前项 | 左右切项并报名 | 重听菜单说明 | 关闭 | 按单击 | busy |
|
||
*/
|
||
import { Game } from '../engine/game.js';
|
||
import { Floor } from '../engine/floor.js';
|
||
import { Receipts } from '../engine/receipts.js';
|
||
import { Confusion } from '../engine/confusion.js';
|
||
import { WaitPoint, InvalidHints, hintSaidRecently, markHintSaid } from '../engine/hints.js';
|
||
import { GestureLadder } from '../engine/novice.js';
|
||
import { vibrate } from '../engine/haptics.js';
|
||
|
||
// ================================ 常量表 ================================
|
||
|
||
const SENTENCE_GAP_MS = 800; // 证词链播句间停顿(18 号 §2)
|
||
// 单句兜底(资产缺失/引擎异常时告警续链;兜底必须高于全部真实资产,只防缺失不当节奏)
|
||
const SENTENCE_FALLBACK_MS = 15000;
|
||
const SIG_LEAD_MS = 450; // 签名 earcon → 条目名语音 串行间隔(18 号 §5)
|
||
const VERDICT_GRACE_MS = 800; // VERDICT 死窗(18 号 §7.4;critical 全链覆盖,此值=语义留档)
|
||
const PROG_LEVELS = [45000, 90000, 150000]; // 无进展轨三级(earcon→短句→保底矛盾对)
|
||
const PROG_POLL_MS = 5000; // 无进展轨轮询周期
|
||
const HINT_MAX_TIMES = 2; // 每句保底提示全局限 2 次(不轰炸)
|
||
const BOARD_TAP_GUARD_MS = 400; // 听取中 tap 起停防抖(ACTION 态管道不设防,游戏层自管)
|
||
|
||
/** 6 份听取卷宗(st_asked 计账)+ 指证入口 + 结案陈词(均为列表项,18 号 §2)。 */
|
||
const LISTEN_ITEMS = ['w1', 'w2', 'w3', 'w4', 'scene', 'fore'];
|
||
const ITEM_ORDER = [...LISTEN_ITEMS, 'cf', 'case'];
|
||
|
||
/** 模态菜单(00 号 §5.4:左右滑切换、单击确认;重启=破坏性二次确认)。 */
|
||
const MENU_ITEMS = ['sys_menu_resume', 'sys_menu_save', 'sys_menu_restart'];
|
||
const TITLE_MENU = ['sys_menu_help', 'sys_menu_resume'];
|
||
|
||
/** 结案三问·真凶问选项 = 四位证人(纯人名 item_who_w1..w4)。 */
|
||
const WHO_ITEMS = ['w1', 'w2', 'w3', 'w4'];
|
||
|
||
const GRADE_RANK = { S: 4, A: 3, B: 2, C: 1 };
|
||
|
||
const SAVE_KEY = 'soundgame.progress.detective';
|
||
const AUTH_KEY = 'soundgame.auth';
|
||
|
||
const PREWARM_SFX = [
|
||
'sfx/paper_turn',
|
||
'sfx/sig_ge', 'sfx/sig_shun', 'sfx/sig_zhou', 'sfx/sig_wen', 'sfx/sig_fore', 'sfx/sig_scene',
|
||
'sfx/contra_hit', 'sfx/contra_miss', 'sfx/gavel', 'sfx/rain_swell', 'sfx/turn_tick',
|
||
];
|
||
|
||
// 等待点静默阈值(18 号附录 C §5;剧情类低频催促)
|
||
const SILENCE = {
|
||
title: [12000, 30000],
|
||
board: [18000, 40000],
|
||
caseGate: [12000, 30000],
|
||
cfItem: [15000, 30000],
|
||
cfLine: [15000, 30000],
|
||
cfConfirm: [15000, 30000],
|
||
finish: [20000, 45000],
|
||
settle: [20000, 45000],
|
||
menu: [20000, 45000],
|
||
};
|
||
|
||
// 无效操作解释(原则 7:每个「状态 × 手势」只解释一次;键由 pipeline 拼接)
|
||
const INVALID_LINES = {
|
||
'locked:up': 'voice/inv_up_locked',
|
||
'locked:down': 'voice/inv_down_locked',
|
||
'locked:longpress': 'voice/inv_longpress',
|
||
};
|
||
|
||
// ================================ 案卷数据(18 号 §3/§6) ================================
|
||
|
||
/** pack A《雨夜·聚宝斋》全量(真凶=周先生)。台词全文见 18-detective-voice-ids.md。 */
|
||
const PACK_A = {
|
||
id: 0, name: '雨夜·聚宝斋', culprit: 'w3', culpritName: '周先生',
|
||
items: [
|
||
{ id: 'w1', kind: 'witness', name: 'item_w1', sig: 'sig_ge', intro: null, lines: [
|
||
{ id: 'w1_1', shuffle: false, clues: {} },
|
||
{ id: 'w1_2', shuffle: true, clues: { rain: '2140', dep_s: '2140' } },
|
||
{ id: 'w1_3', shuffle: false, clues: { visitor: 'male_tall_hunched' } },
|
||
{ id: 'w1_4', shuffle: true, clues: { dep_z: '2150' } },
|
||
{ id: 'w1_5', shuffle: true, clues: {} },
|
||
{ id: 'w1_6', shuffle: false, clues: { motive: 'short' } },
|
||
] },
|
||
{ id: 'w2', kind: 'witness', name: 'item_w2', sig: 'sig_shun', intro: null, lines: [
|
||
{ id: 'w2_1', shuffle: false, clues: { away_s: 'south' } },
|
||
{ id: 'w2_2', shuffle: true, clues: { away_s: 'south' } },
|
||
{ id: 'w2_3', shuffle: false, clues: { key: 'hook' } },
|
||
{ id: 'w2_4', shuffle: true, clues: {} },
|
||
{ id: 'w2_5', shuffle: false, clues: { door: 'victim_opened' } },
|
||
{ id: 'w2_6', shuffle: true, clues: {} },
|
||
] },
|
||
{ id: 'w3', kind: 'witness', name: 'item_w3', sig: 'sig_zhou', intro: 'intro_w3', lines: [
|
||
{ id: 'z_1', shuffle: false, clues: { dep_z: '2150' } },
|
||
{ id: 'z_2', shuffle: false, clues: { lamp: 'off' } },
|
||
{ id: 'z_3', shuffle: false, clues: { key: 'body' } },
|
||
{ id: 'z_4', shuffle: false, clues: { weapon: 'wood', scale: 'sold' } },
|
||
{ id: 'z_5', shuffle: true, clues: {} },
|
||
{ id: 'z_6', shuffle: true, clues: {} },
|
||
] },
|
||
{ id: 'w4', kind: 'witness', name: 'item_w4', sig: 'sig_wen', intro: null, lines: [
|
||
{ id: 'wen_1', shuffle: false, clues: { rain: '2140' } },
|
||
{ id: 'wen_2', shuffle: false, clues: { lamp: 'on_2220' } },
|
||
{ id: 'wen_3', shuffle: false, clues: { wall: 'climbed' } },
|
||
{ id: 'wen_4', shuffle: true, clues: {} },
|
||
{ id: 'wen_5', shuffle: false, clues: { motive: 'short' } },
|
||
{ id: 'wen_6', shuffle: true, clues: {} },
|
||
] },
|
||
{ id: 'scene', kind: 'scene', name: 'item_scene', sig: 'sig_scene', intro: null, lines: [
|
||
{ id: 'sc_0', shuffle: false, clues: {} },
|
||
{ id: 'sc_1', shuffle: false, clues: { struggle: 'none' } },
|
||
{ id: 'sc_2', shuffle: false, clues: { door: 'victim_opened' } },
|
||
{ id: 'sc_3', shuffle: false, clues: { wall: 'climbed' } },
|
||
{ id: 'sc_4', shuffle: false, clues: { scale: 'present' } },
|
||
{ id: 'sc_5', shuffle: false, clues: {} },
|
||
] },
|
||
{ id: 'fore', kind: 'forensic', name: 'item_fore', sig: 'sig_fore', intro: null, lines: [
|
||
{ id: 'fo_1', shuffle: false, clues: {} },
|
||
{ id: 'fo_2', shuffle: false, clues: { death: '2200_2230' } },
|
||
{ id: 'fo_3', shuffle: false, clues: { weapon: 'bronze' } },
|
||
{ id: 'fo_4', shuffle: false, clues: { struggle: 'none' } },
|
||
{ id: 'fo_5', shuffle: false, clues: {} },
|
||
] },
|
||
],
|
||
contradictions: [
|
||
{ id: 'm1', a: 'z_2', b: 'wen_2', axis: 'lamp', hint: 'hint_c1' },
|
||
{ id: 'm2', a: 'z_3', b: 'w2_3', axis: 'key', hint: null },
|
||
{ id: 'm3', a: 'z_4', b: 'fo_3', axis: 'weapon', hint: null },
|
||
{ id: 'm4', a: 'z_4', b: 'sc_4', axis: 'scale', hint: 'hint_c2' },
|
||
],
|
||
verdict: { accuseLine: 'fin_right', break: ['z_break_1', 'z_break_2', 'z_break_3'] },
|
||
wrongAccuse: { w1: 'acc_ge', w2: 'acc_shun', w4: 'acc_wen' },
|
||
reacts: { lamp: 'z_react_1', key: 'z_react_2', weapon: 'z_react_w', scale: 'z_react_3' },
|
||
reactFinal: 'z_react_4', // react 按轴取句(慌在刚戳中的事上);4/4 齐追加收官句
|
||
questions: {
|
||
weapon: { options: ['wp_1', 'wp_2', 'wp_3', 'wp_4'], answer: 'wp_2' },
|
||
motive: { options: ['mv_1', 'mv_2', 'mv_3'], answer: 'mv_1' },
|
||
},
|
||
};
|
||
|
||
/** pack B《雨夜·聚宝斋(变体)》(fork 12 句,真凶=学徒小顺)。 */
|
||
const PACK_B = {
|
||
id: 1, name: '雨夜·聚宝斋(变体)', culprit: 'w2', culpritName: '小顺',
|
||
base: 0,
|
||
overrides: {
|
||
w2: { lines: [
|
||
{ id: 'w2_1', shuffle: false, clues: { away_s: 'south' } },
|
||
{ id: 'w2_2', shuffle: true, clues: { away_s: 'south' } },
|
||
{ id: 'b_shun_1', shuffle: false, clues: { lamp: 'off' } },
|
||
{ id: 'b_shun_2', shuffle: false, clues: { key: 'body' } },
|
||
{ id: 'b_shun_3', shuffle: false, clues: { weapon: 'wood', scale: 'sold' } },
|
||
{ id: 'b_shun_4', shuffle: true, clues: {} },
|
||
] },
|
||
w3: { lines: [
|
||
{ id: 'z_1', shuffle: false, clues: { dep_z: '2150' } },
|
||
{ id: 'b_zhou_1', shuffle: true, clues: {} },
|
||
{ id: 'b_zhou_2', shuffle: false, clues: { key: 'hook' } },
|
||
{ id: 'b_zhou_3', shuffle: true, clues: {} },
|
||
{ id: 'z_5', shuffle: true, clues: {} },
|
||
{ id: 'z_6', shuffle: true, clues: {} },
|
||
] },
|
||
contradictions: [
|
||
{ id: 'm1b', a: 'b_shun_1', b: 'wen_2', axis: 'lamp', hint: 'b_hint_c1' },
|
||
{ id: 'm2b', a: 'b_shun_2', b: 'b_zhou_2', axis: 'key', hint: null },
|
||
{ id: 'm3b', a: 'b_shun_3', b: 'fo_3', axis: 'weapon', hint: null },
|
||
{ id: 'm4b', a: 'b_shun_3', b: 'sc_4', axis: 'scale', hint: 'b_hint_c2' },
|
||
],
|
||
verdict: { accuseLine: 'fin_right_b', break: ['b_shun_break_1', 'b_shun_break_2', 'b_shun_break_3'] },
|
||
wrongAccuse: { w1: 'acc_ge', w3: 'acc_zhou', w4: 'acc_wen' },
|
||
reacts: { lamp: 'b_shun_react_1', key: 'b_shun_react_2', weapon: 'b_shun_react_2', scale: 'b_shun_react_2' },
|
||
reactFinal: 'b_shun_react_2', // pack B 仅 2 条 react(内容弱绑定,非轴假话)
|
||
},
|
||
};
|
||
|
||
function deepClone(o) { return JSON.parse(JSON.stringify(o)); }
|
||
|
||
/** 解析案包(pack B = pack A 模板 + overrides fork)。 */
|
||
function resolvePack(idx) {
|
||
if (idx === 0) return deepClone(PACK_A);
|
||
const base = deepClone(PACK_A);
|
||
const ov = PACK_B.overrides;
|
||
return {
|
||
...base,
|
||
id: PACK_B.id, name: PACK_B.name, culprit: PACK_B.culprit, culpritName: PACK_B.culpritName,
|
||
items: base.items.map((it) => (ov[it.id] && ov[it.id].lines ? { ...it, lines: deepClone(ov[it.id].lines) } : it)),
|
||
contradictions: deepClone(ov.contradictions),
|
||
verdict: deepClone(ov.verdict),
|
||
wrongAccuse: deepClone(ov.wrongAccuse),
|
||
reacts: { ...ov.reacts },
|
||
reactFinal: ov.reactFinal,
|
||
};
|
||
}
|
||
|
||
// ================================ 主类 ================================
|
||
|
||
export class DetectiveGame extends Game {
|
||
constructor(audio, root, opts = {}) {
|
||
super(audio, root);
|
||
// ---- 26 号交互层六件套(构造并暴露,管道/harness 直取)----
|
||
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); // 新手期长按=invalid 不快进(§4.3;加速留阶段 4)
|
||
|
||
this._fixedSeed = opts.seed != null ? opts.seed : null; // playtest 注入(仅首个新案生效)
|
||
this._fixedPack = opts.pack != null ? opts.pack : null;
|
||
this.state = 'TITLE'; // TITLE/BOARD/CONFRONT/FINISH/VERDICT/SETTLE
|
||
this.menuOpen = false; // MENU = 挂起 overlay(记录返回态 menuFrom)
|
||
this.menuFrom = null;
|
||
this._menuItems = MENU_ITEMS;
|
||
this._menuIdx = 0;
|
||
this._menuGate = false; // 菜单「重新查」二次确认闸(重报项名=确认)
|
||
this._session = 0; // 会话令牌:状态大跳转作废旧计时器/链回调
|
||
this._timers = [];
|
||
this.meta = this._defaultMeta();
|
||
this.run = null;
|
||
this._case = null; // 生成期定局:items/layout/contradictions
|
||
this.itemIdx = 0; // 证词板当前条目(0..7,含指证/结案陈词)
|
||
this._play = null; // 证词链播放会话 {itemIdx, cur, seq};null=空闲(floor=ACTION)
|
||
this._cf = null; // 指证子相位(item1/line1/confirm1/item2/line2/confirm2/judged)
|
||
this._fin = null; // 结案三问子相位(who/whoConfirm/weapon/motive)
|
||
this._ob = null; // 开场教学步(§4.10:listen1→nav→cf,嵌在真实案卷里)
|
||
this._annSeq = 0; // 条目名播报令牌(快速滑动时旧报名作废)
|
||
this._settleChain = []; // SETTLE 重听=整条结算段
|
||
this._gradeInfo = null; // 本局评级快照(SETTLE 上滑重听评级段)
|
||
this._verdictGraceUntil = 0; // VERDICT 死窗(critical 全链覆盖,双保险留档)
|
||
this._menuOpenedAt = null; // 菜单挂起计时(_progAdjustMenuPause 消费)
|
||
this._caseGate = false; // 结案陈词确认闸(armed=再单击才进 FINISH)
|
||
this._cfLineMem = {}; // 选句预览位置按条目记忆(本 case 内;新案清空)
|
||
this._goalMet = false; // 第一个小目标(听完第一份证词)→ 解锁上滑
|
||
this._teachQueue = null; // 待挂到下一段链尾的解锁教学句(§4.3 阶梯)
|
||
this._lastBoardTapAt = -Infinity; // 听取中 tap 起停防抖
|
||
// 无进展轨(必结案保底):只由进展事件重置(_progressEvent),任何手势都不重置。
|
||
this._prog = { lastAt: 0, l1: false, l2: false, l3: false, counts: {}, armed: false, armedSession: -1 };
|
||
}
|
||
|
||
_defaultMeta() {
|
||
return { drillDone: false, casesClosed: 0, bestGrade: null, lastPack: null };
|
||
}
|
||
|
||
// ================================ 案卷生成(玩法核心不动) ================================
|
||
|
||
_mulberry32(a) {
|
||
return function () {
|
||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
/** 生成期一次性洗牌(Fisher-Yates by rng)。 */
|
||
_shuffled(arr, rng) {
|
||
const a = [...arr];
|
||
for (let i = a.length - 1; i > 0; i--) {
|
||
const j = Math.floor(rng() * (i + 1));
|
||
[a[i], a[j]] = [a[j], a[i]];
|
||
}
|
||
return a;
|
||
}
|
||
|
||
/**
|
||
* 构造案局(纯函数 of (seed, pack)):filler 句槽位置换(首句与全部矛盾承载句
|
||
* 固定——数据侧 shuffle 标志即单源)、凶器/动机选项序、保底提示轮询序;
|
||
* 随后断言矩阵不变式(同轴异值对集合 ≡ 矛盾表,防文案事故变归因假话)。
|
||
*/
|
||
_buildCase(seed, packIdx) {
|
||
const pack = resolvePack(packIdx);
|
||
const rng = this._mulberry32(seed >>> 0);
|
||
const items = pack.items.map((it) => ({
|
||
...it, lines: it.lines.map((l) => ({ id: l.id, shuffle: l.shuffle, clues: { ...l.clues } })),
|
||
}));
|
||
// 指证 = 案卷列表第 7 项;结案陈词 = 第 8 项(虚拟条目,不参与听取计账与判定)
|
||
items.push({ id: 'cf', kind: 'cf', name: 'item_cf', sig: null, intro: null, lines: [] });
|
||
items.push({ id: 'case', kind: 'case', name: 'item_case', sig: null, intro: null, lines: [] });
|
||
const layout = { lines: {}, wpOpts: null, mvOpts: null, hintOrder: null };
|
||
for (const it of items) {
|
||
// filler 置换:shuffle:true 的句对象在其槽位集合内置换(线索随句走)
|
||
const idxs = [];
|
||
for (let i = 0; i < it.lines.length; i++) if (it.lines[i].shuffle) idxs.push(i);
|
||
const objs = this._shuffled(idxs.map((i) => it.lines[i]), rng);
|
||
idxs.forEach((pos, k) => { it.lines[pos] = objs[k]; });
|
||
layout.lines[it.id] = it.lines.map((l) => l.id);
|
||
}
|
||
layout.wpOpts = this._shuffled(pack.questions.weapon.options, rng);
|
||
layout.mvOpts = this._shuffled(pack.questions.motive.options, rng);
|
||
layout.hintOrder = this._shuffled(pack.contradictions.map((c) => c.id), rng);
|
||
|
||
// 矩阵不变式(dev 断言;playtest 另有独立重算双保险)
|
||
const all = items.flatMap((it) => it.lines);
|
||
const norm = (x, y, axis) => (x < y ? x + '|' + y + '|' + axis : y + '|' + x + '|' + axis);
|
||
const derived = new Set();
|
||
for (let i = 0; i < all.length; i++) {
|
||
for (let j = i + 1; j < all.length; j++) {
|
||
const A = all[i], B = all[j];
|
||
for (const ax of Object.keys(A.clues)) {
|
||
if (B.clues[ax] !== undefined && B.clues[ax] !== A.clues[ax]) derived.add(norm(A.id, B.id, ax));
|
||
}
|
||
}
|
||
}
|
||
const wanted = new Set(pack.contradictions.map((c) => norm(c.a, c.b, c.axis)));
|
||
if (derived.size !== wanted.size || [...derived].some((k) => !wanted.has(k))) {
|
||
throw new Error(`detective: 矩阵不变式破裂(pack ${packIdx})——derived=${[...derived].join(';')} want=${[...wanted].join(';')}`);
|
||
}
|
||
// 运行期查表:lineId → 线索 / 所属条目
|
||
const lineClues = {};
|
||
const lineItem = {};
|
||
for (const it of items) for (const l of it.lines) { lineClues[l.id] = l.clues; lineItem[l.id] = it.id; }
|
||
return {
|
||
seed, pack: packIdx, name: pack.name, culprit: pack.culprit, culpritName: pack.culpritName,
|
||
items, layout, lineClues, lineItem,
|
||
contradictions: pack.contradictions, verdict: pack.verdict,
|
||
wrongAccuse: pack.wrongAccuse, reacts: pack.reacts, reactFinal: pack.reactFinal,
|
||
questions: pack.questions,
|
||
};
|
||
}
|
||
|
||
// ---------------- 派生账目(播报值=运行值) ----------------
|
||
|
||
_itemAt(idx) { return this._case.items[Math.min(Math.max(idx, 0), this._case.items.length - 1)]; }
|
||
_itemOf(itemId) { return this._case.items.find((it) => it.id === itemId); }
|
||
_itemName(item) { return 'voice/' + item.name; }
|
||
_doubtCount() { return this.run ? this.run.doubts.filter(Boolean).length : 0; }
|
||
_heardCount() { return this.run ? this.run.heard.length : 0; }
|
||
_lineIds(itemId) { return this._case.layout.lines[itemId] || []; }
|
||
|
||
/** 上滑状态链 = 断点复述链同构(已问 n → 未听 ≤3 时逐个条目名 → 疑点 m → 指路尾句)。 */
|
||
_statusChain() {
|
||
const ids = ['voice/st_asked_' + this._heardCount()];
|
||
const unheard = LISTEN_ITEMS.filter((id) => !this.run.heard.includes(id));
|
||
if (unheard.length <= 3) for (const id of unheard) ids.push(this._itemName(this._itemOf(id)));
|
||
ids.push('voice/st_doubt_' + this._doubtCount());
|
||
const d = this._doubtCount();
|
||
ids.push(d === 4 ? 'voice/hint_fin' : (this._heardCount() >= 6 ? 'voice/recap_cf' : 'voice/recap_go'));
|
||
return ids;
|
||
}
|
||
|
||
// ================================ 生命周期 ================================
|
||
|
||
start() {
|
||
this.audio.prewarm(PREWARM_SFX);
|
||
const save = this._loadSave();
|
||
if (save && save.meta) this.meta = { ...this._defaultMeta(), ...save.meta };
|
||
this._enterTitle();
|
||
}
|
||
|
||
stop() {
|
||
this._save(); // 退出自动存档(原则 16)
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.floor.interrupt();
|
||
this._play = null;
|
||
this.audio.holdDuck(false); // 释放游戏层自管的链级压低
|
||
this.audio.stopAll();
|
||
}
|
||
|
||
_goHome() {
|
||
this.stop();
|
||
window.location.href = '../';
|
||
}
|
||
|
||
// ---------------- TITLE(含冷启动 §4.10 第 0~3 秒) ----------------
|
||
|
||
_enterTitle() {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this._play = null; this._cf = null; this._fin = null; this._ob = null;
|
||
this.menuOpen = false; this.menuFrom = null;
|
||
this.state = 'TITLE';
|
||
this.floor.interrupt();
|
||
this.audio.holdDuck(false);
|
||
this.audio.stopVoice();
|
||
this.audio.play('bgm/menu');
|
||
// 冷启动第一句 = critical 指令「点一下屏幕。」(3 秒内动手,F7/§4.10);
|
||
// 有档 = 断点复述问句(§4.12)。入口句登 30s 台账:静默提示自动降档(原则 9)
|
||
const entry = this.run ? 'voice/sys_continue_ask' : 'voice/sys_first_tap';
|
||
markHintSaid(entry);
|
||
this.floor.speak([entry], {
|
||
critical: !this.run,
|
||
onDone: () => this.floor.yieldTurn(this._titleWp()),
|
||
});
|
||
}
|
||
|
||
_onTitleGesture(g) {
|
||
switch (g) {
|
||
case 'tap':
|
||
if (this.run) this._resumeRun();
|
||
else this._newCase();
|
||
return 'ok';
|
||
case 'right':
|
||
this._newCase(); // 换一桩新案:重掷 seed/pack,meta 保留
|
||
return 'ok';
|
||
case 'doubletap': { // 阶段 3:主动重听 TITLE 句(「再说一遍:」前缀)
|
||
const wp = this.floor.waitPoint;
|
||
if (wp) wp.requestReplay();
|
||
return 'hold';
|
||
}
|
||
case 'up': // 案情简介(TITLE 上滑重听,intro 链不占开场)
|
||
this.floor.speak(['voice/intro_1', 'voice/intro_2', 'voice/intro_3'], {
|
||
onDone: () => this.floor.yieldTurn(this._titleWp()),
|
||
});
|
||
return 'ok';
|
||
case 'down':
|
||
return this._openMenu();
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
// ---------------- 新案 / 读档(存档与断点复述 §4.12) ----------------
|
||
|
||
_newCase(seedOverride, packOverride) {
|
||
this.floor.interrupt();
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.menuOpen = false; this.menuFrom = null;
|
||
this._breakPlayback();
|
||
const seed = seedOverride != null ? seedOverride
|
||
: this._fixedSeed != null ? this._fixedSeed : Math.floor(Math.random() * 1e9);
|
||
this._fixedSeed = null;
|
||
const pack = packOverride != null ? packOverride
|
||
: this._fixedPack != null ? this._fixedPack
|
||
: this.meta.lastPack == null ? 0 : (Math.random() < 0.5 ? 0 : 1); // 首局=0;换新案重掷
|
||
this._fixedPack = null;
|
||
this._case = this._buildCase(seed, pack);
|
||
this.run = {
|
||
seed, pack,
|
||
layout: deepClone(this._case.layout),
|
||
heard: [], heardLines: {}, lastLine: {},
|
||
doubts: this._case.contradictions.map(() => false),
|
||
cfMiss: 0, wrongWho: 0, zHits: 0,
|
||
hintsGiven: [], hintFinShown: false,
|
||
weaponAns: null, motiveAns: null,
|
||
};
|
||
this.itemIdx = 0;
|
||
this._play = null; this._cf = null; this._fin = null;
|
||
this._caseGate = false;
|
||
this._cfLineMem = {}; // 新案清空选句记忆
|
||
this._ob = this.meta.drillDone ? null : { step: 'listen1' }; // 教学嵌进真实案卷
|
||
this._save();
|
||
this._afterCaseOpen();
|
||
}
|
||
|
||
/** 开卷落板:新案一句 + (新手)开场教学链(§4.10 首 3 分钟)。 */
|
||
_afterCaseOpen() {
|
||
this.state = 'BOARD';
|
||
this.menuOpen = false;
|
||
this.audio.play('bgm/board', { fadeMs: 600 });
|
||
this._progressEvent(); // 新案进板 = 基线重置
|
||
this._progArm();
|
||
if (this._ob && this._ob.step === 'listen1') {
|
||
// 3~8 秒:极短案情一句(intro_1)→ 签名+「老葛的证词」→「单击,听取。」
|
||
this.floor.speak(['voice/sys_new_case', 'voice/intro_1'], { onDone: () => this._announceFirstItem() });
|
||
return;
|
||
}
|
||
this.floor.speak(['voice/sys_new_case'], { onDone: () => this.floor.yieldTurn(this._boardWp()) });
|
||
}
|
||
|
||
/** 开场条目播报 + 听取指令 + 「轮到你了」状态教学(原则 2:状态声第一次出现配解释)。 */
|
||
_announceFirstItem() {
|
||
const item = this._itemAt(0);
|
||
this.audio.play('sfx/' + item.sig);
|
||
const seq = ++this._annSeq;
|
||
this._after(SIG_LEAD_MS, () => {
|
||
if (seq !== this._annSeq) return;
|
||
markHintSaid(this._itemName(item)); // 台账:后续静默/重访自动降档(原则 9)
|
||
this.floor.speak([this._itemName(item), 'voice/tut_listen', 'voice/sys_turn_teach'], {
|
||
onDone: () => this.floor.yieldTurn(this._boardWp()),
|
||
});
|
||
});
|
||
}
|
||
|
||
/** 菜单「重新查这桩案子」:同 pack 重掷 seed(换洗牌不换案情)。 */
|
||
_restartCase() {
|
||
const pack = this._case ? this._case.pack : (this.meta.lastPack ?? 0);
|
||
this.run = null;
|
||
this._newCase(null, pack);
|
||
}
|
||
|
||
_resumeRun() {
|
||
const r = this.run;
|
||
this._case = this._buildCase(r.seed, r.pack);
|
||
// 读档复用存档 layout(确定性生成下二者相等;防御性信任存档,不重洗)
|
||
if (r.layout && r.layout.lines) {
|
||
this._case.layout = deepClone(r.layout);
|
||
this.run.layout = deepClone(r.layout);
|
||
}
|
||
this.itemIdx = 0;
|
||
this._play = null; this._cf = null; this._fin = null;
|
||
this._caseGate = false;
|
||
this._ob = this.meta.drillDone ? null : { step: 'cf' }; // 旧档教学只差指证练习
|
||
this._progressEvent(); // 读档进板 = 基线重置
|
||
this._enterBoard(this._statusChain()); // 断点复述链(§4.12:≤10s 回到可操作)
|
||
}
|
||
|
||
// ================================ 手势总入口(pipeline 之后) ================================
|
||
|
||
/**
|
||
* 管道收口后的游戏处理。返回 'ok' | 'hold' | 'invalid' | 'silent-ok'。
|
||
* 约定:onGesture 内不得同步 yieldTurn(管道会在返回后立刻结算 YOUR_TURN 等待点)——
|
||
* 同步路径要么起链(floor.speak → SPEAKING),要么 _yieldSoon 延迟一拍交话轮。
|
||
*/
|
||
onGesture(g) {
|
||
if (g === 'longpressend' || g === 'tapcancel') return 'silent-ok'; // 收尾噪声(管道已滤,双保险)
|
||
if (this.state === 'VERDICT') return 'silent-ok'; // 死窗+链锁:链完成前手势全吞(critical 给 busy)
|
||
if (this.menuOpen) return this._onMenuGesture(g);
|
||
switch (this.state) {
|
||
case 'TITLE': return this._onTitleGesture(g);
|
||
case 'BOARD': return this._onBoardGesture(g);
|
||
case 'CONFRONT': return this._onConfrontGesture(g);
|
||
case 'FINISH': return this._onFinishGesture(g);
|
||
case 'SETTLE': return this._onSettleGesture(g);
|
||
default: return 'invalid';
|
||
}
|
||
}
|
||
|
||
stateName() {
|
||
if (this.menuOpen) return 'menu';
|
||
switch (this.state) {
|
||
case 'TITLE': return 'title';
|
||
case 'VERDICT': return 'verdict';
|
||
case 'SETTLE': return 'settle';
|
||
case 'CONFRONT': {
|
||
const cf = this._cf;
|
||
if (!cf || cf.phase === 'judged') return 'confront_judged';
|
||
if (cf.phase === 'item1' || cf.phase === 'item2') return 'confront_item';
|
||
if (cf.phase === 'line1' || cf.phase === 'line2') return 'confront_line';
|
||
return 'confront_confirm';
|
||
}
|
||
case 'FINISH': return 'finish';
|
||
default: return this._play ? 'listen' : 'board'; // BOARD:听取中=listen、空闲=board
|
||
}
|
||
}
|
||
|
||
invalidHint(state, g) {
|
||
if (state === 'locked') return INVALID_LINES['locked:' + g] || null;
|
||
if (g === 'left' || g === 'right') {
|
||
if (state === 'title') return this.run ? 'voice/sys_continue_ask' : 'voice/sys_first_tap';
|
||
if (state === 'settle') return 'voice/hint_tap_short'; // 结案了。单击,接新案。
|
||
return null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ================================ BOARD 证词板 ================================
|
||
|
||
_enterBoard(chain) {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this._play = null; this._cf = null; this._fin = null;
|
||
this.menuOpen = false; this.menuFrom = null;
|
||
this._caseGate = false; // 回板即撤闸
|
||
this.state = 'BOARD';
|
||
this.floor.interrupt();
|
||
this.audio.holdDuck(false);
|
||
this.audio.stopVoice();
|
||
this.audio.play('bgm/board');
|
||
this._progArm();
|
||
if (chain && chain.length) {
|
||
this.floor.speak(chain, { onDone: () => this.floor.yieldTurn(this._boardWp()) });
|
||
} else {
|
||
this.floor.yieldTurn(this._boardWp());
|
||
}
|
||
}
|
||
|
||
/** 判定/错指后回板(异步链尾调用,yieldTurn 安全)。 */
|
||
_backToBoard() { this._enterBoard(); }
|
||
|
||
_onBoardGesture(g) {
|
||
if (this._play) return this._onListenGesture(g); // 听取中(floor=ACTION)
|
||
// 结案闸:非单击/双击手势先撤闸再走正常映射(滑走=继续查)
|
||
if (this._caseGate && g !== 'tap' && g !== 'doubletap') this._caseGate = false;
|
||
switch (g) {
|
||
case 'tap': {
|
||
// 防连点(矩阵「连点=400ms busy」):板上任何单击(起播/停/闸)后 400ms 内
|
||
// 的第二下只给 busy——起停不抖、闸不误进(ACTION 态管道不设防,游戏层自管)
|
||
if (performance.now() - this._lastBoardTapAt < BOARD_TAP_GUARD_MS) {
|
||
this.receipts.play('busy');
|
||
return 'silent-ok';
|
||
}
|
||
this._lastBoardTapAt = performance.now();
|
||
const cur = ITEM_ORDER[this.itemIdx];
|
||
if (cur === 'case') {
|
||
// 「结案陈词」是循环列表必经站——第一次单击只进确认闸(fin_pre),
|
||
// 闸内再单击才进三问;滑走撤闸留板
|
||
if (this._caseGate) { this._caseGate = false; this._enterFinish(); return 'ok'; }
|
||
this._caseGate = true;
|
||
markHintSaid('voice/fin_pre'); // 闸句登台账:12s 静默提示降短句,不原样重播(原则 9)
|
||
this.floor.speak(['voice/fin_pre'], { onDone: () => this.floor.yieldTurn(this._caseGateWp()) });
|
||
return 'ok';
|
||
}
|
||
if (cur === 'cf') { this._enterConfront(); return 'ok'; } // 滑到「指证」点一下(18 号 §1)
|
||
return this._startPlayback();
|
||
}
|
||
case 'doubletap': { // 阶段 3 解锁后:空闲重听当前条目最后一句
|
||
if (this._caseGate) {
|
||
const wp = this.floor.waitPoint;
|
||
if (wp) wp.requestReplay(); // 闸内重听闸句
|
||
return 'hold';
|
||
}
|
||
this._replayLastLine();
|
||
return 'silent-ok';
|
||
}
|
||
case 'left':
|
||
this._navItem(-1);
|
||
return 'ok';
|
||
case 'right':
|
||
this._navItem(1);
|
||
return 'ok';
|
||
case 'up':
|
||
this._speakStatus();
|
||
return 'ok';
|
||
case 'down':
|
||
return this._openMenu();
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
/** 听取中(floor=ACTION:玩法声即状态,手势直达游戏层)。 */
|
||
_onListenGesture(g) {
|
||
switch (g) {
|
||
case 'tap': { // 停止(听取中不设等待点=「播放中不催听」由 ACTION 态实现)
|
||
if (performance.now() - this._lastBoardTapAt < BOARD_TAP_GUARD_MS) {
|
||
this.receipts.play('busy'); // 连点:起停不抖(矩阵「连点=400ms busy」)
|
||
return 'silent-ok';
|
||
}
|
||
this._lastBoardTapAt = performance.now();
|
||
this._stopPlayback();
|
||
return 'ok';
|
||
}
|
||
case 'doubletap': // 阶段 3:重听当前句(链从当前句继续)
|
||
this._replayCurrentInChain();
|
||
return 'silent-ok';
|
||
case 'left':
|
||
case 'right': { // 打断并切条目(滑动有明确意图,floor 裁定)
|
||
this._breakPlayback();
|
||
this._navItem(g === 'right' ? 1 : -1);
|
||
return 'ok';
|
||
}
|
||
case 'up': // 打断,答「我在哪」
|
||
this._breakPlayback();
|
||
this._speakStatus();
|
||
return 'ok';
|
||
case 'down':
|
||
this._breakPlayback();
|
||
return this._openMenu();
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
_navItem(d) {
|
||
this._caseGate = false; // 换条目即撤闸
|
||
this.itemIdx = (this.itemIdx + d + ITEM_ORDER.length) % ITEM_ORDER.length;
|
||
this._announceItem(this.itemIdx);
|
||
}
|
||
|
||
/**
|
||
* 条目播报:翻页音 + 专属签名 earcon → 条目名整句(串行)→ 交话轮(空闲 BOARD
|
||
* = YOUR_TURN 等待声,18 号 §2「翻卷宗」态)。指证/结案陈词无签名音:交接提示音
|
||
* 代位。30s 台账:刚报过就只给签名+提示音(原则 9 同一信息更短)。
|
||
*/
|
||
_announceItem(idx) {
|
||
const item = this._itemAt(idx);
|
||
this.floor.interrupt();
|
||
this.audio.play('sfx/paper_turn');
|
||
if (item.sig) this.audio.play('sfx/' + item.sig);
|
||
else this.floor.chime();
|
||
const seq = ++this._annSeq;
|
||
this._after(SIG_LEAD_MS, () => {
|
||
if (seq !== this._annSeq) return;
|
||
const id = this._itemName(item);
|
||
let ids = [];
|
||
if (!hintSaidRecently(id)) { markHintSaid(id); ids = [id]; }
|
||
this.floor.speak(ids, { onDone: () => this.floor.yieldTurn(this._boardWp()) });
|
||
});
|
||
}
|
||
|
||
/** 单击听取当前卷宗:从首个未听句起;全部听过=整体重听。 */
|
||
_startPlayback() {
|
||
const item = this._itemAt(this.itemIdx);
|
||
if (item.id === 'case' || item.id === 'cf' || !item.lines.length) return 'invalid';
|
||
const n = this._lineIds(item.id).length;
|
||
const heardN = this.run.heardLines[item.id] || 0;
|
||
const from = heardN >= n ? 0 : heardN;
|
||
this._play = { itemIdx: this.itemIdx, cur: -1, seq: 0 };
|
||
// 听取 = ACTION:玩法声即状态;tap=停/滑动=切条目直达游戏层;压低自管跨整链
|
||
this.floor.enterAction();
|
||
this.audio.holdDuck(true);
|
||
if (item.sig) this.audio.play('sfx/' + item.sig);
|
||
const p = this._play;
|
||
const s = this._session;
|
||
const seq0 = ++p.seq;
|
||
this._after(SIG_LEAD_MS, () => {
|
||
if (s !== this._session || this._play !== p || p.seq !== seq0) return;
|
||
if (item.intro && from === 0) {
|
||
this._playSentence(item.intro, () => {
|
||
if (s !== this._session || this._play !== p || p.seq !== seq0) return;
|
||
this._after(SENTENCE_GAP_MS, () => this._playLine(from));
|
||
});
|
||
} else {
|
||
this._playLine(from);
|
||
}
|
||
});
|
||
return 'ok';
|
||
}
|
||
|
||
/** 证词链步进:句粒度可停(tap)/可重听(doubletap 重播当前句续链)。 */
|
||
_playLine(idx) {
|
||
const p = this._play;
|
||
if (!p) return;
|
||
const item = this._itemAt(p.itemIdx);
|
||
const ids = this._lineIds(item.id);
|
||
if (idx >= ids.length) { this._finishItemPlayback(); return; }
|
||
p.cur = idx;
|
||
this.run.lastLine[item.id] = ids[idx];
|
||
const seq = ++p.seq;
|
||
this._playSentence(ids[idx], () => {
|
||
if (!this._play || this._play !== p || p.seq !== seq) return;
|
||
this.run.heardLines[item.id] = Math.max(this.run.heardLines[item.id] || 0, idx + 1);
|
||
this._after(SENTENCE_GAP_MS, () => {
|
||
if (!this._play || this._play !== p || p.seq !== seq) return;
|
||
this._playLine(idx + 1);
|
||
});
|
||
});
|
||
}
|
||
|
||
/** 停止证词链(不清账;交话轮延后一拍——见 onGesture 头注释)。 */
|
||
_stopPlayback() {
|
||
const heardAny = this._play && this._play.cur >= 0;
|
||
const obFirst = this._ob && this._ob.step === 'listen1' && heardAny; // 「听完或点停」都教右滑
|
||
this._breakPlayback();
|
||
this._after(0, () => {
|
||
if (obFirst) this._obTeachNav(false);
|
||
else this.floor.yieldTurn(this._boardWp());
|
||
});
|
||
}
|
||
|
||
/** 立即停链(不交话轮;调用方继续起链/开菜单)。 */
|
||
_breakPlayback() {
|
||
this._play = null;
|
||
this.audio.holdDuck(false);
|
||
this.audio.stopVoice();
|
||
}
|
||
|
||
_finishItemPlayback() {
|
||
const item = this._itemAt(this._play.itemIdx);
|
||
const wasUnheard = item.id !== 'case' && item.id !== 'cf' && !this.run.heard.includes(item.id);
|
||
this._breakPlayback();
|
||
if (wasUnheard) {
|
||
this.run.heard.push(item.id); // 听完新条目 = 进展事件(无进展轨基线重置)
|
||
this._save();
|
||
}
|
||
this._progressEvent();
|
||
const nHeard = this.run.heard.length;
|
||
// 第一个小目标(听完第一份证词)→ 解锁上滑(§4.3 阶梯),教学句挂下一段链尾
|
||
if (!this._goalMet && nHeard >= 1) {
|
||
this._goalMet = true;
|
||
const got = this.ladder.onGoal();
|
||
if (got) this._teachQueue = this.ladder.teachLine(got);
|
||
}
|
||
if (this._ob) {
|
||
if (this._ob.step === 'listen1' && nHeard >= 1) { this._obTeachNav(true); return; }
|
||
if (this._ob.step === 'nav' && nHeard >= 2) { this._obTeachCf(); return; }
|
||
}
|
||
if (this._teachQueue) {
|
||
const t = this._teachQueue;
|
||
this._teachQueue = null;
|
||
this.floor.speak([t], { onDone: () => this.floor.yieldTurn(this._boardWp()) }); // teachLine 已带 voice/ 前缀
|
||
return;
|
||
}
|
||
this.floor.yieldTurn(this._boardWp());
|
||
}
|
||
|
||
/** 教学·换卷宗步(§4.10 第 10~50 秒):「向右滑,下一份。」(+首次达成时上滑解锁句)。 */
|
||
_obTeachNav(withUnlockLine) {
|
||
this._ob.step = 'nav';
|
||
const ids = ['voice/tut_next_full'];
|
||
if (withUnlockLine && this._teachQueue) {
|
||
ids.push(this._teachQueue); // teachLine 已带 voice/ 前缀,直接进链
|
||
this._teachQueue = null;
|
||
}
|
||
this.floor.speak(ids, { onDone: () => this.floor.yieldTurn(this._boardWp()) });
|
||
}
|
||
|
||
/** 教学·指证入口步(§4.10 第 50~90 秒):「滑到指证,点一下。」 */
|
||
_obTeachCf() {
|
||
this._ob.step = 'cf';
|
||
this.floor.speak(['voice/tut_confront'], { onDone: () => this.floor.yieldTurn(this._boardWp()) });
|
||
}
|
||
|
||
/** 播放中双击(阶段 3):重播当前句,链从当前句继续(seq 递增作废在途句间停顿)。 */
|
||
_replayCurrentInChain() {
|
||
const p = this._play;
|
||
if (!p || p.cur < 0) return; // 首句起播前无「当前句」
|
||
this._playLine(p.cur);
|
||
}
|
||
|
||
/** 空闲双击(阶段 3):重新听当前条目最后一句(免费零消耗)。 */
|
||
_replayLastLine() {
|
||
const item = this._itemAt(this.itemIdx);
|
||
const ids = this._lineIds(item.id);
|
||
if (!ids.length) { this._announceItem(this.itemIdx); return; } // 指证/结案陈词=重报条目名
|
||
const last = this.run.lastLine[item.id];
|
||
const lineId = last && ids.includes(last) ? last : ids[0];
|
||
this.floor.speak(['voice/' + lineId], { onDone: () => this.floor.yieldTurn(this._boardWp()) });
|
||
}
|
||
|
||
/** 上滑状态链(打断并回答一句,原则 3)。 */
|
||
_speakStatus() {
|
||
this.floor.speak(this._statusChain(), { onDone: () => this.floor.yieldTurn(this._boardWp()) });
|
||
}
|
||
|
||
// ================================ CONFRONT 指证 ================================
|
||
|
||
/** 首次指证 = 练习(判定截获 tut_cf_ok,零账目污染;教学完成后不再练习)。 */
|
||
_isPractice() { return !this.meta.drillDone; }
|
||
|
||
_enterConfront() {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this._breakPlayback();
|
||
this.menuOpen = false;
|
||
this._caseGate = false;
|
||
this.state = 'CONFRONT';
|
||
this.floor.interrupt();
|
||
this.audio.stopVoice();
|
||
const startIdx = Math.min(this.itemIdx, LISTEN_ITEMS.length - 1);
|
||
this._cf = { phase: 'item1', idx: startIdx, lineIdx: 0, fixed: null, pickSaid: false };
|
||
const chain = ['voice/cf_start'];
|
||
if (this._isPractice()) chain.push('voice/tut_cf_try'); // 练习句接在入口链上
|
||
this.floor.speak(chain, { onDone: () => this._announceCfItem() });
|
||
}
|
||
|
||
/** 指证条目选择播报(签名前置;30s 台账降档)。 */
|
||
_announceCfItem() {
|
||
const item = this._itemOf(LISTEN_ITEMS[this._cf.idx]);
|
||
if (!item) return;
|
||
this.audio.play('sfx/' + item.sig);
|
||
const seq = ++this._annSeq;
|
||
this._after(SIG_LEAD_MS, () => {
|
||
if (seq !== this._annSeq) return;
|
||
const id = this._itemName(item);
|
||
let ids = [];
|
||
if (!hintSaidRecently(id)) { markHintSaid(id); ids = [id]; }
|
||
this.floor.speak(ids, { onDone: () => this.floor.yieldTurn(this._cfItemWp()) });
|
||
});
|
||
}
|
||
|
||
_onConfrontGesture(g) {
|
||
const cf = this._cf;
|
||
if (!cf || cf.phase === 'judged') return 'silent-ok'; // 判定链 critical,到不了这里(保险)
|
||
switch (g) {
|
||
case 'tap':
|
||
if (cf.phase === 'item1' || cf.phase === 'item2') { this._cfConfirmItem(); return 'ok'; }
|
||
if (cf.phase === 'line1' || cf.phase === 'line2') { this._cfConfirmLine(); return 'ok'; }
|
||
this._cfFixLine(); // confirm1/confirm2
|
||
return 'ok';
|
||
case 'left':
|
||
case 'right': {
|
||
const d = g === 'right' ? 1 : -1;
|
||
if (cf.phase === 'item1' || cf.phase === 'item2') {
|
||
cf.idx = (cf.idx + d + LISTEN_ITEMS.length) % LISTEN_ITEMS.length;
|
||
this._announceCfItem();
|
||
return 'hold';
|
||
}
|
||
if (cf.phase === 'line1' || cf.phase === 'line2') {
|
||
this._cfMoveLine(d);
|
||
return 'hold';
|
||
}
|
||
this._cfCancelConfirm(); // 确认态滑动=取消回预览
|
||
return 'ok';
|
||
}
|
||
case 'doubletap': // 阶段 3:重听当前预览句/条目名
|
||
if (cf.phase === 'item1' || cf.phase === 'item2') { this._announceCfItem(); return 'hold'; }
|
||
this._cfPreviewLine();
|
||
return 'silent-ok';
|
||
case 'up': // 选条目/句=「正在指证。左右滑。」;确认态=简状态(疑点数)
|
||
if (cf.phase === 'confirm1' || cf.phase === 'confirm2') this._speakCfStatus();
|
||
else this.floor.speak(['voice/st_confront'], { onDone: () => this.floor.yieldTurn(this._cfWpFor()) });
|
||
return 'ok';
|
||
case 'down':
|
||
return this._openMenu();
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
_cfCurLineId() {
|
||
const cf = this._cf;
|
||
return this._lineIds(LISTEN_ITEMS[cf.idx])[cf.lineIdx];
|
||
}
|
||
|
||
_cfWpFor() {
|
||
const ph = this._cf.phase;
|
||
if (ph === 'item1' || ph === 'item2') return this._cfItemWp();
|
||
if (ph === 'line1' || ph === 'line2') return this._cfLineWp();
|
||
return this._cfConfirmWp();
|
||
}
|
||
|
||
_cfConfirmItem() {
|
||
const cf = this._cf;
|
||
cf.phase = cf.phase === 'item1' ? 'line1' : 'line2';
|
||
// 选句入口从该条目上次预览/定下的位置接着走(不再回回从第一句扫起)
|
||
cf.lineIdx = this._cfLineMem[LISTEN_ITEMS[cf.idx]] || 0;
|
||
const lead = cf.pickSaid ? [] : ['voice/cf_pick_line']; // 选句相位入口(首次)
|
||
cf.pickSaid = true;
|
||
this.floor.speak([...lead, 'voice/' + this._cfCurLineId()], {
|
||
onDone: () => this.floor.yieldTurn(this._cfLineWp()),
|
||
});
|
||
}
|
||
|
||
_cfMoveLine(d) {
|
||
const cf = this._cf;
|
||
const n = this._lineIds(LISTEN_ITEMS[cf.idx]).length;
|
||
cf.lineIdx = (cf.lineIdx + d + n) % n;
|
||
this._cfLineMem[LISTEN_ITEMS[cf.idx]] = cf.lineIdx; // 预览位置记忆
|
||
this._cfPreviewLine();
|
||
}
|
||
|
||
/** 重播当前预览句(句序号不播——交互侧拼接禁令)。 */
|
||
_cfPreviewLine() {
|
||
this.floor.speak(['voice/' + this._cfCurLineId()], {
|
||
onDone: () => this.floor.yieldTurn(this._cfLineWp()),
|
||
});
|
||
}
|
||
|
||
_cfConfirmLine() {
|
||
const cf = this._cf;
|
||
cf.phase = cf.phase === 'line1' ? 'confirm1' : 'confirm2';
|
||
markHintSaid('voice/cf_confirm'); // 确认句登台账:等待点静默提示降档(原则 9)
|
||
this.floor.speak(['voice/' + this._cfCurLineId(), 'voice/cf_confirm'], {
|
||
onDone: () => this.floor.yieldTurn(this._cfConfirmWp()),
|
||
}); // 重播该句 + 确认句
|
||
}
|
||
|
||
/** 确认态左右滑 = 取消回预览(重播当前句)。 */
|
||
_cfCancelConfirm() {
|
||
const cf = this._cf;
|
||
cf.phase = cf.phase === 'confirm1' ? 'line1' : 'line2';
|
||
this._cfPreviewLine();
|
||
}
|
||
|
||
/** 确认态上滑 = 简状态(疑点数)。 */
|
||
_speakCfStatus() {
|
||
this.floor.speak(['voice/st_doubt_' + this._doubtCount()], {
|
||
onDone: () => this.floor.yieldTurn(this._cfConfirmWp()),
|
||
});
|
||
}
|
||
|
||
/** 确认态单击=定下这句:第一份定下自动重播一次再选第二份(记忆契约)。 */
|
||
_cfFixLine() {
|
||
const cf = this._cf;
|
||
const itemId = LISTEN_ITEMS[cf.idx];
|
||
const lineId = this._cfCurLineId();
|
||
this._cfLineMem[itemId] = cf.lineIdx; // 定下=最强记忆点
|
||
if (cf.phase === 'confirm1') {
|
||
cf.fixed = { item: itemId, line: lineId };
|
||
cf.phase = 'item2';
|
||
cf.lineIdx = 0;
|
||
this.floor.speak(['voice/cf_second', 'voice/' + lineId], {
|
||
critical: true, // 链锁:onDone 驱动相位推进,误触不打断
|
||
onDone: () => this._announceCfItem(),
|
||
});
|
||
return;
|
||
}
|
||
// 第二份定下:练习判定截获(不算数、不计 cfMiss、不动 doubts)
|
||
if (this._isPractice()) {
|
||
cf.phase = 'judged';
|
||
vibrate('success');
|
||
this.floor.speak(['voice/tut_cf_ok'], { critical: true, onDone: () => this._finishPractice() });
|
||
return;
|
||
}
|
||
this._judge(cf.fixed.line, lineId);
|
||
}
|
||
|
||
/** 练习完成:教学收官(做对过的事不再教第二遍),回板自由查案。 */
|
||
_finishPractice() {
|
||
this._cf = null;
|
||
this.meta.drillDone = true;
|
||
this._ob = null;
|
||
this._teachQueue = null;
|
||
this._save();
|
||
this._backToBoard();
|
||
}
|
||
|
||
/**
|
||
* 指证判定:判定与归因全部从线索轴数据单源推导。
|
||
* hit 链 = contra_hit(sfx)→ cf_hit → react_按轴;miss 链 = contra_miss(sfx)→
|
||
* cf_self / cf_same / cf_diff(三档真话)。链 critical(链锁,尾驱回板)。
|
||
*/
|
||
_judge(lineA, lineB) {
|
||
this._cf.phase = 'judged';
|
||
const cl = this._case.lineClues;
|
||
if (lineA === lineB) { this._cfMiss('cf_self'); return; }
|
||
const ca = cl[lineA] || {};
|
||
const cb = cl[lineB] || {};
|
||
const shared = Object.keys(ca).filter((ax) => cb[ax] !== undefined);
|
||
if (!shared.length) { this._cfMiss('cf_diff'); return; }
|
||
const conflict = shared.find((ax) => ca[ax] !== cb[ax]);
|
||
if (!conflict) { this._cfMiss('cf_same'); return; }
|
||
const contra = this._case.contradictions.find((c) => c.axis === conflict
|
||
&& ((c.a === lineA && c.b === lineB) || (c.a === lineB && c.b === lineA)));
|
||
if (!contra) {
|
||
// 数据错误(矩阵不变式在生成期已断言排除;此处防御性兜底不崩)
|
||
console.error('[detective] 矩阵外冲突对:', lineA, lineB, conflict);
|
||
this._cfMiss('cf_diff');
|
||
return;
|
||
}
|
||
this._cfHit(contra);
|
||
}
|
||
|
||
_cfMiss(voiceId) {
|
||
this.run.cfMiss++;
|
||
this._progressEvent(); // 指证判定 = 进展事件
|
||
this._save();
|
||
this.audio.play('sfx/contra_miss');
|
||
this.floor.speak(['voice/' + voiceId], { critical: true, onDone: () => this._backToBoard() });
|
||
}
|
||
|
||
_cfHit(contra) {
|
||
const idx = this._case.contradictions.indexOf(contra);
|
||
let newDoubt = false;
|
||
if (!this.run.doubts[idx]) {
|
||
this.run.doubts[idx] = true;
|
||
newDoubt = true;
|
||
this.run.zHits++;
|
||
}
|
||
// react 按轴取句——慌要慌在刚戳中的那条轴上(换顺序坐实不答非所问)
|
||
const axisReact = this._case.reacts[contra.axis] || this._case.reactFinal;
|
||
const chain = ['voice/cf_hit', 'voice/' + axisReact];
|
||
// 疑点 4/4 齐:追加收官句 reactFinal + 自动播一次 hint_fin(引导去结案;
|
||
// 庆祝性播报不计入保底提示账)
|
||
if (newDoubt && this._doubtCount() === 4) {
|
||
if (this._case.reactFinal && this._case.reactFinal !== axisReact) chain.push('voice/' + this._case.reactFinal);
|
||
if (!this.run.hintFinShown) {
|
||
this.run.hintFinShown = true;
|
||
chain.push('voice/hint_fin');
|
||
}
|
||
}
|
||
this._progressEvent();
|
||
this._save();
|
||
this.audio.play('sfx/contra_hit'); // 全游戏最强确认音先响
|
||
vibrate('success');
|
||
this.floor.speak(chain, { critical: true, onDone: () => this._backToBoard() });
|
||
}
|
||
|
||
// ================================ FINISH 结案三问 ================================
|
||
|
||
_enterFinish() {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this._breakPlayback();
|
||
this.menuOpen = false;
|
||
this._caseGate = false;
|
||
this.state = 'FINISH';
|
||
this.floor.interrupt();
|
||
this.audio.stopVoice();
|
||
this._fin = { phase: 'who', optIdx: 0 };
|
||
markHintSaid('voice/fin_start'); // 问句登台账:finish 等待点静默提示降短句(原则 9)
|
||
this.floor.speak(['voice/fin_start'], {
|
||
onDone: () => this._announceFinOption(),
|
||
});
|
||
}
|
||
|
||
/** 结案选项语音:真凶问=纯人名(item_who_*),凶器/动机=选项名。 */
|
||
_finOptionVoice(idx) {
|
||
const fin = this._fin;
|
||
if (fin.phase === 'weapon') return 'voice/' + this._case.layout.wpOpts[idx];
|
||
if (fin.phase === 'motive') return 'voice/' + this._case.layout.mvOpts[idx];
|
||
return 'voice/item_who_' + WHO_ITEMS[idx];
|
||
}
|
||
|
||
/** 问句链(up/双击重听、等待点 full 用)。 */
|
||
_finQuestionIds() {
|
||
const fin = this._fin;
|
||
const opt = this._finOptionVoice(fin.optIdx);
|
||
if (fin.phase === 'weapon') return ['voice/fin_ask_weapon', opt];
|
||
if (fin.phase === 'motive') return ['voice/fin_ask_motive', opt];
|
||
return ['voice/fin_start', opt];
|
||
}
|
||
|
||
/** 问句播完交话轮(只报首选项名——问句刚由入口链说过,不原样重播,原则 9)。 */
|
||
_announceFinOption() {
|
||
const id = this._finOptionVoice(this._fin.optIdx);
|
||
markHintSaid(id); // 选项名登台账:换项滑回同选项时只给提示音(原则 9)
|
||
this.floor.speak([id], { onDone: () => this.floor.yieldTurn(this._finWp()) });
|
||
}
|
||
|
||
_onFinishGesture(g) {
|
||
const fin = this._fin;
|
||
if (!fin) return 'invalid';
|
||
switch (g) {
|
||
case 'tap': {
|
||
if (fin.phase === 'who') {
|
||
fin.phase = 'whoConfirm'; // 两段确认:重播人名 + fin_who_confirm(中性「就指认这位?」)
|
||
this.floor.speak([this._finOptionVoice(fin.optIdx), 'voice/fin_who_confirm'], {
|
||
onDone: () => this.floor.yieldTurn(this._finWp()),
|
||
});
|
||
return 'ok';
|
||
}
|
||
if (fin.phase === 'whoConfirm') { this._resolveWho(WHO_ITEMS[fin.optIdx]); return 'ok'; }
|
||
if (fin.phase === 'weapon') { this._resolveWeapon(this._case.layout.wpOpts[fin.optIdx]); return 'ok'; }
|
||
this._resolveMotive(this._case.layout.mvOpts[fin.optIdx]);
|
||
return 'ok';
|
||
}
|
||
case 'left':
|
||
case 'right':
|
||
this._finSwitch(g === 'right' ? 1 : -1);
|
||
return 'hold';
|
||
case 'doubletap': // 阶段 3:重听当前问句
|
||
case 'up': // 矩阵:结案三问上滑=重听问句
|
||
this.floor.speak(this._finQuestionIds(), { onDone: () => this.floor.yieldTurn(this._finWp()) });
|
||
return g === 'up' ? 'ok' : 'silent-ok';
|
||
case 'down':
|
||
return this._openMenu();
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
/** 换选项并报名(链化:被打断也由下一次报名自愈交话轮;30s 台账刚报过就只给提示音)。 */
|
||
_finSwitch(d) {
|
||
const fin = this._fin;
|
||
const opts = fin.phase === 'motive' ? this._case.layout.mvOpts
|
||
: fin.phase === 'weapon' ? this._case.layout.wpOpts : WHO_ITEMS;
|
||
fin.optIdx = (fin.optIdx + d + opts.length) % opts.length;
|
||
const id = this._finOptionVoice(fin.optIdx);
|
||
if (hintSaidRecently(id)) {
|
||
this.floor.chime();
|
||
this._orphanTurnHeal(() => this._finWp());
|
||
return;
|
||
}
|
||
markHintSaid(id);
|
||
this.floor.speak([id], { onDone: () => this.floor.yieldTurn(this._finWp()) });
|
||
}
|
||
|
||
_resolveWho(itemId) {
|
||
if (itemId === this._case.culprit) {
|
||
// 指认对 → 凶器问(三问顺序 真凶→凶器→动机)
|
||
this._progressEvent();
|
||
this._save();
|
||
this._fin.phase = 'weapon';
|
||
this._fin.optIdx = 0;
|
||
markHintSaid('voice/fin_ask_weapon'); // 问句登台账:静默提示降短句(原则 9)
|
||
this.floor.speak(['voice/fin_ask_weapon'], {
|
||
critical: true,
|
||
onDone: () => this._announceFinOption(),
|
||
});
|
||
return;
|
||
}
|
||
// 错指:不在场证明复述 + fin_clear + st_wrong 当场记账 → 回 BOARD(无限再机会)
|
||
this.run.wrongWho++;
|
||
this._progressEvent();
|
||
this._save();
|
||
this.floor.speak(
|
||
['voice/' + this._case.wrongAccuse[itemId], 'voice/fin_clear', 'voice/st_wrong'],
|
||
{ critical: true, onDone: () => this._backToBoard() },
|
||
);
|
||
}
|
||
|
||
_resolveWeapon(optId) {
|
||
this.run.weaponAns = optId;
|
||
this._progressEvent();
|
||
this._save();
|
||
const ok = optId === this._case.questions.weapon.answer;
|
||
this.floor.speak(['voice/' + (ok ? 'fin_wp_right' : 'fin_wp_wrong')], {
|
||
critical: true,
|
||
onDone: () => {
|
||
this._fin.phase = 'motive';
|
||
this._fin.optIdx = 0;
|
||
markHintSaid('voice/fin_ask_motive'); // 问句登台账:静默提示降短句(原则 9)
|
||
this.floor.speak(['voice/fin_ask_motive'], {
|
||
critical: true,
|
||
onDone: () => this._announceFinOption(),
|
||
});
|
||
},
|
||
});
|
||
}
|
||
|
||
_resolveMotive(optId) {
|
||
this.run.motiveAns = optId;
|
||
this._progressEvent();
|
||
this._save();
|
||
const ok = optId === this._case.questions.motive.answer;
|
||
this.floor.speak(['voice/' + (ok ? 'fin_mv_right' : 'fin_mv_wrong')], {
|
||
critical: true,
|
||
onDone: () => this._enterVerdict(),
|
||
});
|
||
}
|
||
|
||
// ================================ 评级与裁决 ================================
|
||
|
||
/** 评级六变量条件式(自上而下首个命中;纯函数供测试注入矩阵)。 */
|
||
_gradeOf(o) {
|
||
if (o.doubts === 4 && o.weaponOk && o.motiveOk && o.wrongWho === 0 && o.cfMiss <= 2 && o.hints === 0) return 'S';
|
||
if (o.doubts >= 3 && o.weaponOk && o.motiveOk && o.wrongWho <= 1 && o.hints <= 1) return 'A';
|
||
if (o.doubts >= 2 && o.wrongWho <= 2) return 'B';
|
||
return 'C';
|
||
}
|
||
|
||
_gradeOutcome() {
|
||
return {
|
||
doubts: this._doubtCount(),
|
||
weaponOk: this.run.weaponAns === this._case.questions.weapon.answer,
|
||
motiveOk: this.run.motiveAns === this._case.questions.motive.answer,
|
||
wrongWho: this.run.wrongWho,
|
||
cfMiss: this.run.cfMiss,
|
||
hints: this.run.hintsGiven.length,
|
||
};
|
||
}
|
||
|
||
/** 结算账目段(播报值=运行值,措辞档位与账目一一映射)。 */
|
||
_settlePartVoices(o) {
|
||
return [
|
||
'voice/settle_open',
|
||
'voice/rate_' + this._gradeOf(o).toLowerCase(),
|
||
'voice/st_doubt_' + o.doubts,
|
||
'voice/st_miss_' + (o.cfMiss === 0 ? 0 : o.cfMiss <= 2 ? 1 : 2),
|
||
'voice/settle_hook', // 键盘态引擎自动路由 key/ 变体(游戏代码用普通 id)
|
||
];
|
||
}
|
||
|
||
/** 结案 → 阶梯阶段 2/3 教学句(挂在结算链尾,onGameDone 由 ladder 计数)。 */
|
||
_gameDoneTeach() {
|
||
const got = this.ladder.onGameDone();
|
||
return got ? [this.ladder.teachLine(got)] : []; // teachLine 已带 voice/ 前缀
|
||
}
|
||
|
||
/** 裁决仪式:fin_right → rain_swell → 认罪三段 → gavel → 结算链(全链 critical=死窗+链锁)。 */
|
||
_enterVerdict() {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this._breakPlayback();
|
||
this.menuOpen = false;
|
||
this.state = 'VERDICT';
|
||
this.floor.interrupt();
|
||
this.audio.stopVoice();
|
||
this._verdictGraceUntil = performance.now() + VERDICT_GRACE_MS; // 死窗(critical 全链覆盖,留档)
|
||
this.audio.play('bgm/verdict', { fadeMs: 800 });
|
||
vibrate('success');
|
||
// 元进度入账(结案必达:无失败态,C 档覆盖一切);结案=教学收官(不再练习)
|
||
const outcome = this._gradeOutcome();
|
||
const grade = this._gradeOf(outcome);
|
||
this.meta.casesClosed++;
|
||
this.meta.lastPack = this._case.pack;
|
||
this.meta.drillDone = true;
|
||
this.meta.bestGrade = !this.meta.bestGrade || GRADE_RANK[grade] > GRADE_RANK[this.meta.bestGrade]
|
||
? grade : this.meta.bestGrade;
|
||
this._gradeInfo = { outcome, grade };
|
||
this.run = null;
|
||
this._save();
|
||
const settlePart = this._settlePartVoices(outcome);
|
||
markHintSaid('voice/settle_hook'); // 钩子句登台账:settle 等待点静默提示降短句(原则 9)
|
||
this._settleChain = settlePart;
|
||
this.floor.speak(['voice/' + this._case.verdict.accuseLine], {
|
||
critical: true,
|
||
onDone: () => {
|
||
this.audio.play('sfx/rain_swell'); // 认罪链垫(one-shot)
|
||
this.floor.speak(this._case.verdict.break.map((x) => 'voice/' + x), {
|
||
critical: true,
|
||
onDone: () => {
|
||
this.audio.play('sfx/gavel'); // 法槌
|
||
this.audio.play('bgm/menu', { volume: 0.5 }); // SETTLE 结算曲
|
||
this.floor.speak([...settlePart, ...this._gameDoneTeach()], {
|
||
critical: true,
|
||
onDone: () => {
|
||
this.state = 'SETTLE'; // 链完成才解锁手势(链锁解除后单击=接新案)
|
||
this.floor.yieldTurn(this._settleWp());
|
||
},
|
||
});
|
||
},
|
||
});
|
||
},
|
||
});
|
||
}
|
||
|
||
_onSettleGesture(g) {
|
||
switch (g) {
|
||
case 'tap':
|
||
// 接新案:pack 强制翻转 + seed 重掷(开环钩子:二周目必遇变体真凶)
|
||
this._newCase(null, this.meta.lastPack == null ? 1 : 1 - this.meta.lastPack);
|
||
return 'ok';
|
||
case 'doubletap': // 阶段 3:重听整条结算段
|
||
this.floor.speak([...this._settleChain], { onDone: () => this.floor.yieldTurn(this._settleWp()) });
|
||
return 'silent-ok';
|
||
case 'up': // 评级一句(账目段)
|
||
if (this._gradeInfo) {
|
||
this.floor.speak(this._settlePartVoices(this._gradeInfo.outcome).slice(1, 4), {
|
||
onDone: () => this.floor.yieldTurn(this._settleWp()),
|
||
});
|
||
}
|
||
return 'ok';
|
||
case 'down':
|
||
return this._openMenu();
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
// ================================ 模态菜单(00 号 §5.4) ================================
|
||
|
||
_openMenu() {
|
||
if (this.menuOpen) return 'invalid';
|
||
this.menuFrom = this.state;
|
||
this.menuOpen = true;
|
||
this._menuItems = (this.state === 'TITLE' || this.state === 'SETTLE') ? TITLE_MENU : MENU_ITEMS;
|
||
this._menuIdx = 0;
|
||
this._menuGate = false;
|
||
this._menuOpenedAt = performance.now(); // 无进展轨挂起计时(关菜单时剔除时长)
|
||
this._caseGate = false; // 开菜单撤结案闸
|
||
this._session++; // 打开即停链/停计时(菜单=天然暂停,本作无实时玩法)
|
||
this._clearTimers();
|
||
this._breakPlayback();
|
||
this.floor.interrupt();
|
||
this.audio.play('bgm/menu');
|
||
markHintSaid('voice/sys_menu'); // 菜单句登台账:menu 等待点静默提示降档(原则 9)
|
||
markHintSaid('voice/' + this._menuItems[0]); // 首项报名登台账:滑走滑回不重播
|
||
this.floor.speak(['voice/sys_menu', 'voice/' + this._menuItems[0]], {
|
||
onDone: () => this.floor.yieldTurn(this._menuWp()),
|
||
});
|
||
return 'ok';
|
||
}
|
||
|
||
/** 菜单项报名(链化:被打断也自愈交话轮;gate=true 强制重报=二次确认闸的确认线索)。 */
|
||
_menuAnnounce(force = false) {
|
||
const id = 'voice/' + this._menuItems[this._menuIdx];
|
||
if (!force && hintSaidRecently(id)) {
|
||
this.floor.chime();
|
||
this._orphanTurnHeal(() => this._menuWp());
|
||
return;
|
||
}
|
||
markHintSaid(id);
|
||
this.floor.speak([id], { onDone: () => this.floor.yieldTurn(this._menuWp()) });
|
||
}
|
||
|
||
_onMenuGesture(g) {
|
||
const items = this._menuItems;
|
||
switch (g) {
|
||
case 'left': // 左右滑切换(每切报名),单击确认——与探索同构
|
||
this._menuGate = false;
|
||
this._menuIdx = (this._menuIdx + items.length - 1) % items.length;
|
||
this._menuAnnounce();
|
||
return 'hold';
|
||
case 'right':
|
||
this._menuGate = false;
|
||
this._menuIdx = (this._menuIdx + 1) % items.length;
|
||
this._menuAnnounce();
|
||
return 'hold';
|
||
case 'tap':
|
||
case 'doubletap': { // 双击按单击(=确认当前项)
|
||
const item = items[this._menuIdx];
|
||
if (this._menuGate) { this._menuGate = false; this._menuExec(item); return 'ok'; }
|
||
if (item === 'sys_menu_restart') {
|
||
this._menuGate = true; // 破坏性操作二次确认:重报项名「重新查这桩案子。」
|
||
this._menuAnnounce(true);
|
||
return 'hold';
|
||
}
|
||
this._menuExec(item);
|
||
return 'ok';
|
||
}
|
||
case 'up': // 重听菜单说明
|
||
this.floor.speak(['voice/sys_help', 'voice/settings_hint'], {
|
||
onDone: () => { if (this.menuOpen) this._ensureTurn(() => this._menuWp()); },
|
||
});
|
||
return 'hold';
|
||
case 'down':
|
||
this._closeMenu();
|
||
return 'ok';
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
_menuExec(item) {
|
||
if (item === 'sys_menu_resume') {
|
||
if (this.menuFrom === 'CONFRONT' || this.menuFrom === 'FINISH') {
|
||
// 「返回证词板」:从指证/结案退出回板(报当前条目名)
|
||
this.menuOpen = false; this.menuFrom = null;
|
||
this._progAdjustMenuPause();
|
||
this._enterBoard([this._itemName(this._itemAt(this.itemIdx))]);
|
||
return;
|
||
}
|
||
this._closeMenu();
|
||
return;
|
||
}
|
||
if (item === 'sys_menu_save') {
|
||
this._save();
|
||
this.floor.speak(['voice/sys_saved'], { critical: true, onDone: () => this._goHome() });
|
||
return;
|
||
}
|
||
if (item === 'sys_menu_restart') {
|
||
this.menuOpen = false; this.menuFrom = null;
|
||
this._menuOpenedAt = null;
|
||
this._restartCase();
|
||
return;
|
||
}
|
||
if (item === 'sys_menu_help') {
|
||
this.floor.speak(['voice/sys_help', 'voice/settings_hint'], {
|
||
onDone: () => { if (this.menuOpen) this._ensureTurn(() => this._menuWp()); },
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
_closeMenu() {
|
||
this.menuOpen = false;
|
||
const from = this.menuFrom;
|
||
this.menuFrom = null;
|
||
this._progAdjustMenuPause(); // 菜单挂起时长从无进展轨剔除(分级保留)
|
||
this.floor.interrupt();
|
||
if (from === 'TITLE' || from === 'SETTLE') {
|
||
this.state = from;
|
||
this.audio.play('bgm/menu', { volume: from === 'SETTLE' ? 0.5 : 1 });
|
||
this._yieldSoon(() => (from === 'TITLE' ? this._titleWp() : this._settleWp()));
|
||
return;
|
||
}
|
||
if (from === 'CONFRONT' && this._cf && this._cf.phase !== 'judged') {
|
||
this.state = 'CONFRONT';
|
||
this.audio.play('bgm/board');
|
||
this._announceCfItem(); // 回指证:重报当前条目(相位与选句记忆原样保留)
|
||
return;
|
||
}
|
||
if (from === 'FINISH' && this._fin) {
|
||
this.state = 'FINISH';
|
||
this.audio.play('bgm/board');
|
||
this.floor.speak(this._finQuestionIds(), { onDone: () => this.floor.yieldTurn(this._finWp()) });
|
||
return;
|
||
}
|
||
// BOARD:回板报当前条目名
|
||
this.state = 'BOARD';
|
||
this.audio.play('bgm/board');
|
||
this._progArm();
|
||
this._announceItem(this.itemIdx);
|
||
}
|
||
|
||
/**
|
||
* 菜单挂起时长从无进展轨剔除:lastAt 前移 = 时钟续走、分级保留——
|
||
* 反复开菜单不再能重置救援轨(进展事件清单不含菜单)。
|
||
*/
|
||
_progAdjustMenuPause() {
|
||
if (this._menuOpenedAt == null) return;
|
||
this._prog.lastAt += performance.now() - this._menuOpenedAt;
|
||
this._menuOpenedAt = null;
|
||
}
|
||
|
||
// ================================ 等待点(三档提示 + 迷茫接管) ================================
|
||
|
||
_wp(conf) {
|
||
return new WaitPoint(conf, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_titleWp() {
|
||
return this._wp({
|
||
id: 'title',
|
||
expected: ['tap', 'right'],
|
||
full: this.run ? 'voice/sys_continue_ask' : 'voice/sys_first_tap',
|
||
short: 'voice/sys_first_tap_short',
|
||
silenceMs: SILENCE.title,
|
||
onDemo: () => this.floor.chime(),
|
||
});
|
||
}
|
||
|
||
_boardWp() {
|
||
return this._wp({
|
||
id: 'board',
|
||
expected: ['left', 'right'],
|
||
full: 'voice/hint_board_full',
|
||
short: 'voice/hint_board_short',
|
||
silenceMs: SILENCE.board,
|
||
onDemo: () => this._navItem(1), // L3 演示:替玩家滑到下一份
|
||
});
|
||
}
|
||
|
||
_caseGateWp() {
|
||
return this._wp({
|
||
id: 'caseGate',
|
||
expected: ['tap'],
|
||
full: 'voice/fin_pre',
|
||
short: 'voice/hint_tap_short',
|
||
silenceMs: SILENCE.caseGate,
|
||
onDemo: () => this.floor.chime(),
|
||
});
|
||
}
|
||
|
||
_cfItemWp() {
|
||
return this._wp({
|
||
id: 'cf_item',
|
||
expected: ['left', 'right'],
|
||
full: 'voice/hint_cf_item_full',
|
||
short: 'voice/hint_board_short', // 「左右滑。」共用短句档
|
||
silenceMs: SILENCE.cfItem,
|
||
onDemo: () => { // L3 演示:选第一份
|
||
if (!this._cf) return;
|
||
this._cf.idx = 0;
|
||
this._announceCfItem();
|
||
},
|
||
});
|
||
}
|
||
|
||
_cfLineWp() {
|
||
return this._wp({
|
||
id: 'cf_line',
|
||
expected: ['tap'],
|
||
full: 'voice/cf_confirm',
|
||
short: 'voice/hint_tap_short',
|
||
silenceMs: SILENCE.cfLine,
|
||
onDemo: () => { if (this._cf) this._cfConfirmLine(); }, // L3 替确认
|
||
});
|
||
}
|
||
|
||
_cfConfirmWp() {
|
||
return this._wp({
|
||
id: 'cf_confirm',
|
||
expected: ['tap'],
|
||
full: 'voice/cf_confirm',
|
||
short: 'voice/hint_tap_short',
|
||
silenceMs: SILENCE.cfConfirm,
|
||
onDemo: () => { if (this._cf) this._cfFixLine(); }, // L3 替定下
|
||
});
|
||
}
|
||
|
||
_finWp() {
|
||
const fin = this._fin;
|
||
const full = fin && fin.phase === 'weapon' ? 'voice/fin_ask_weapon'
|
||
: fin && fin.phase === 'motive' ? 'voice/fin_ask_motive' : 'voice/fin_start';
|
||
return this._wp({
|
||
id: 'finish',
|
||
expected: ['left', 'right'],
|
||
full, // 当前问句(18 号 §5:finish 等待点 full=当前问句)
|
||
short: 'voice/hint_fin_short',
|
||
silenceMs: SILENCE.finish,
|
||
onDemo: () => { // L3 演示正确项(保底必结,不代答)
|
||
const f = this._fin;
|
||
if (!f) return;
|
||
const opts = f.phase === 'motive' ? this._case.layout.mvOpts
|
||
: f.phase === 'weapon' ? this._case.layout.wpOpts : WHO_ITEMS;
|
||
const want = f.phase === 'motive' ? this._case.questions.motive.answer
|
||
: f.phase === 'weapon' ? this._case.questions.weapon.answer : this._case.culprit;
|
||
f.optIdx = Math.max(0, opts.indexOf(want));
|
||
this.audio.play(this._finOptionVoice(f.optIdx));
|
||
this.floor.resumeTurn();
|
||
},
|
||
});
|
||
}
|
||
|
||
_settleWp() {
|
||
return this._wp({
|
||
id: 'settle',
|
||
expected: ['tap'],
|
||
full: 'voice/settle_hook',
|
||
short: 'voice/hint_tap_short',
|
||
silenceMs: SILENCE.settle,
|
||
onDemo: () => this.floor.chime(),
|
||
});
|
||
}
|
||
|
||
_menuWp() {
|
||
return this._wp({
|
||
id: 'menu',
|
||
expected: ['tap', 'left', 'right'],
|
||
full: 'voice/sys_menu',
|
||
short: null,
|
||
silenceMs: SILENCE.menu,
|
||
onDemo: () => this.floor.chime(),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 延迟一拍交话轮:onGesture 的同步路径不能 yieldTurn(管道在返回后会立刻结算
|
||
* YOUR_TURN 等待点,把刚挂上的静默提示作废)——排到下一个宏任务再交。
|
||
*/
|
||
_yieldSoon(wpFactory) {
|
||
this._after(0, () => this.floor.yieldTurn(wpFactory()));
|
||
}
|
||
|
||
/**
|
||
* 话轮自愈(链尾 onDone 里调用):等待点还在就 resume(保留三档计数,原则 9);
|
||
* 被打断的过渡链没来得及交话轮(waitPoint 为空、BUSY 悬空)就补挂一个——
|
||
* 任何路径被手势打断都不留「只剩 busy 回执」的软锁。
|
||
*/
|
||
_ensureTurn(wpFactory) {
|
||
if (this.floor.speaking || this.floor.state === 'ACTION') return;
|
||
if (this.floor.waitPoint) { this.floor.resumeTurn(); return; }
|
||
this.floor.yieldTurn(wpFactory());
|
||
}
|
||
|
||
/** 同步手势路径的话轮自愈:等待点仍在时 'hold' 收口会自愈;悬空态延迟一拍补交。 */
|
||
_orphanTurnHeal(wpFactory) {
|
||
if (this.floor.speaking || this.floor.state === 'ACTION') return;
|
||
if (!this.floor.waitPoint) this._yieldSoon(wpFactory);
|
||
}
|
||
|
||
// ================================ 无进展轨(必结案保底,18 号 §5) ================================
|
||
|
||
_progressEvent() {
|
||
const p = this._prog;
|
||
p.lastAt = performance.now();
|
||
p.l1 = false; p.l2 = false; p.l3 = false;
|
||
}
|
||
|
||
_progArm() {
|
||
if (this._prog.armed && this._prog.armedSession === this._session) return;
|
||
this._prog.armed = true;
|
||
this._prog.armedSession = this._session;
|
||
const s = this._session;
|
||
const loop = () => {
|
||
if (s !== this._session) { this._prog.armed = false; return; }
|
||
if (this.state === 'BOARD' && !this.menuOpen) this._progCheck();
|
||
this._after(PROG_POLL_MS, loop);
|
||
};
|
||
this._after(PROG_POLL_MS, loop);
|
||
}
|
||
|
||
/**
|
||
* 无进展轨分级:t = 墙钟距上次进展事件(语音在播不冻结)。
|
||
* - L1 turn_tick 是 sfx 层 earcon「不占台词通道」——到点即发(证词播中也照发);
|
||
* - L2/L3 是台词句——只在空闲(无证词链、无语音链、非菜单)时发(不掐断玩家
|
||
* 主动听取的证词句;否则乱按者自己触发的短语音会把救援句饿死)。
|
||
*/
|
||
_progCheck() {
|
||
const p = this._prog;
|
||
const t = performance.now() - p.lastAt;
|
||
const chainBusy = !!this._play || this.menuOpen || this.floor.speaking;
|
||
if (p.l3) { // L3 后每 150s 重复(每句全局限 2 次,之后静默)
|
||
if (t >= PROG_LEVELS[2] && !chainBusy) { this._fireL3(); p.lastAt = performance.now(); }
|
||
return;
|
||
}
|
||
if (t >= PROG_LEVELS[2]) {
|
||
if (!chainBusy) { this._fireL3(); p.l3 = true; p.lastAt = performance.now(); }
|
||
return;
|
||
}
|
||
if (t >= PROG_LEVELS[1] && !p.l2) {
|
||
if (chainBusy) return; // 链空了再发(不标记,顺延不丢级)
|
||
p.l2 = true;
|
||
// 疑点已齐还播「逐句比对」是指引过时——此情直指结案(hint_fin 限次口径沿用)
|
||
const id = this._doubtCount() === 4 ? 'hint_fin'
|
||
: LISTEN_ITEMS.some((x) => !this.run.heard.includes(x)) ? 'hint_a' : 'hint_b';
|
||
this._hintSpeak(id);
|
||
return;
|
||
}
|
||
if (t >= PROG_LEVELS[0] && !p.l1) {
|
||
p.l1 = true;
|
||
this.audio.play('sfx/turn_tick', { volume: 0.7 }); // L1 earcon:sfx 层,语音在播也照发
|
||
}
|
||
}
|
||
|
||
/** L3 保底:直接给出可指证的矛盾对(seed 定序轮询未发现清单)。 */
|
||
_fireL3() {
|
||
const run = this.run;
|
||
if (this._doubtCount() === 4) { this._hintSpeak('hint_fin'); return; }
|
||
for (const cid of this._case.layout.hintOrder) {
|
||
const c = this._case.contradictions.find((x) => x.id === cid);
|
||
const idx = this._case.contradictions.indexOf(c);
|
||
if (run.doubts[idx] || !c.hint) continue;
|
||
if ((this._prog.counts[c.hint] || 0) >= HINT_MAX_TIMES) continue;
|
||
this._prog.counts[c.hint] = (this._prog.counts[c.hint] || 0) + 1;
|
||
this._hintSpeak(c.hint);
|
||
return;
|
||
}
|
||
// 可播提示耗尽(m2/m3 无提示句 / 全局 2 次用完):退回 L2 口径
|
||
if ((this._prog.counts.hint_b || 0) < HINT_MAX_TIMES) {
|
||
this._hintSpeak('hint_b');
|
||
}
|
||
}
|
||
|
||
_hintSpeak(id) {
|
||
// L2/L3 每句全局限 2 次(不轰炸)
|
||
if (id === 'hint_a' || id === 'hint_b' || id === 'hint_fin') {
|
||
this._prog.counts[id] = (this._prog.counts[id] || 0) + 1;
|
||
if (this._prog.counts[id] > HINT_MAX_TIMES) { this._prog.counts[id] = HINT_MAX_TIMES; return; }
|
||
}
|
||
vibrate('confirm');
|
||
this.run.hintsGiven.push(id); // 保底提示入账(评级变量 hints)
|
||
this._save();
|
||
this.floor.speak(['voice/' + id], {
|
||
kind: 'hint', // §4.6:空闲才播,播不出去就丢(绝不排到长链后面迟到)
|
||
onDone: () => {
|
||
if (this.state === 'BOARD' && !this.menuOpen && !this._play) {
|
||
this.floor.yieldTurn(this._boardWp());
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
// ================================ 语音链 / 计时器基础设施 ================================
|
||
|
||
/** 单句播放(听取 ACTION 态的游戏层链):自然播完推进;15s 兜底续链。 */
|
||
_playSentence(lineId, onDone) {
|
||
const s = this._session;
|
||
let fired = false;
|
||
const fin = () => {
|
||
if (fired || s !== this._session) return;
|
||
fired = true;
|
||
if (onDone) onDone();
|
||
};
|
||
this.audio.play('voice/' + lineId, { onComplete: fin });
|
||
this._after(SENTENCE_FALLBACK_MS, fin);
|
||
}
|
||
|
||
_after(ms, fn) {
|
||
const s = this._session;
|
||
const id = setTimeout(() => {
|
||
this._timers = this._timers.filter((t) => t !== id);
|
||
if (s === this._session) fn();
|
||
}, ms);
|
||
this._timers.push(id);
|
||
return id;
|
||
}
|
||
|
||
_clearTimers() {
|
||
for (const id of this._timers) clearTimeout(id);
|
||
this._timers = [];
|
||
}
|
||
|
||
// ================================ 存档(§4.12 / 原则 16) ================================
|
||
|
||
_summary() {
|
||
if (!this.run) return `已结案${this.meta.casesClosed}起 · 最佳${this.meta.bestGrade ?? '—'}`;
|
||
return `${this._case ? this._case.name : '雨夜'} · 已问${this.run.heard.length}/6 · 疑点${this._doubtCount()}/4`;
|
||
}
|
||
|
||
_save() {
|
||
const r = this.run;
|
||
const data = {
|
||
schema: 1,
|
||
game: 'detective',
|
||
updatedAt: new Date().toISOString(),
|
||
summary: this._summary(),
|
||
meta: {
|
||
drillDone: this.meta.drillDone, casesClosed: this.meta.casesClosed,
|
||
bestGrade: this.meta.bestGrade, lastPack: this.meta.lastPack,
|
||
},
|
||
run: r ? {
|
||
seed: r.seed, pack: r.pack, layout: deepClone(r.layout),
|
||
heard: [...r.heard], heardLines: { ...r.heardLines }, lastLine: { ...r.lastLine },
|
||
doubts: [...r.doubts], cfMiss: r.cfMiss, wrongWho: r.wrongWho, zHits: r.zHits,
|
||
hintsGiven: [...r.hintsGiven], hintFinShown: r.hintFinShown,
|
||
weaponAns: r.weaponAns, motiveAns: r.motiveAns,
|
||
} : null,
|
||
};
|
||
try { localStorage.setItem(SAVE_KEY, JSON.stringify(data)); } catch (_) { /* 隐私模式 */ }
|
||
try {
|
||
const auth = JSON.parse(localStorage.getItem(AUTH_KEY));
|
||
if (!auth || !auth.token) return;
|
||
fetch('/api/progress', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + auth.token },
|
||
body: JSON.stringify({ game: 'detective', data }),
|
||
}).catch(() => { /* 离线静默 */ });
|
||
} catch (_) { /* 静默 */ }
|
||
}
|
||
|
||
_loadSave() {
|
||
try {
|
||
const data = JSON.parse(localStorage.getItem(SAVE_KEY));
|
||
if (!data || data.schema !== 1 || !data.meta) return null;
|
||
if (data.run && typeof data.run.seed === 'number' && Array.isArray(data.run.doubts)) {
|
||
this.run = {
|
||
seed: data.run.seed, pack: data.run.pack ?? 0,
|
||
layout: data.run.layout || null,
|
||
heard: [...(data.run.heard || [])],
|
||
heardLines: { ...(data.run.heardLines || {}) },
|
||
lastLine: { ...(data.run.lastLine || {}) },
|
||
doubts: [...data.run.doubts],
|
||
cfMiss: data.run.cfMiss || 0, wrongWho: data.run.wrongWho || 0, zHits: data.run.zHits || 0,
|
||
hintsGiven: [...(data.run.hintsGiven || [])],
|
||
hintFinShown: !!data.run.hintFinShown,
|
||
weaponAns: data.run.weaponAns ?? null, motiveAns: data.run.motiveAns ?? null,
|
||
};
|
||
}
|
||
return data;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|