/** * 《雨夜专案》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; } } }