/** * 《雨夜专案》—— 纯音频本格推理游戏(按 docs/design/18-detective.md 实现; * 台词 id 见 18-detective-voice-ids.md,R2 起 171 句 ≤176 上限)。 * * 你是专案组探长:翻听六份卷宗(4 证人证词 + 现场勘验 + 法医报告),逐句比对, * 指出「同轴异值」的证词对,锁定真凶,结案三问(真凶/凶器/动机)。 * * 核心动词=指证(§3):每句证词带线索轴标签(0-2 个),判定 = 两句是否共享轴 * 且值不同——判定与三档归因(cf_same/cf_diff/cf_self)全部从数据单源推导, * 机制上不存在「台词说对得上、逻辑上对不上」的假话。矩阵不变式(§6.4)在 * _buildCase 生成期断言:全案卷同轴异值对集合 ≡ 预设计矛盾表(4 条)。 * * 状态机(§4):TITLE / DRILL / BOARD(空闲|播放) / CONFRONT / FINISH / VERDICT / * SETTLE + MENU 挂起 overlay。证词链 = onComplete 串行(句间 0.8s)+ 单句 15s * 兜底(资产缺失告警续链,§4.3 契约:屏蔽任意 voice 资产流程不卡死;R2 F1: * 兜底须高于全部真实资产,原 8s 会掐 male/intro_1 8.8s 等真实长句)。 * * 手势(§2.5 总表,全游戏零手势偏离,仅 2 条已教学豁免): * - E1:BOARD 单击=播放/停止(「检查/翻阅」类非即时操作,escape/dungeon 先例); * R2 F5 起 BOARD 停在「结案陈词」单击=确认闸(fin_pre),闸内再单击才进 FINISH。 * - E2:BOARD 空闲长按=进入指证(核心动词快捷入口;绑定条件=无证词链会话在播, * 与长按快进(仅播放中生效)时间互斥零冲突——播放中长按走引擎快进不受理)。 * R2 F7 起 CONFRONT 选句相位长按同样放行引擎快进;选句位置按条目记忆。 * - 上滑状态链 / 下滑菜单 / 双击重听全状态保留(手势宪法三件套不豁免)。 * * 随机性(§6.2):mulberry32(seed) 生成期一次性洗牌(filler 句位置 / 凶器·动机 * 选项序 / 保底提示轮询序),存 run.layout——同 (seed,pack) 两次构造 _case 深相等。 * pack A 真凶=周先生 / pack B 真凶=小顺(fork 12 句,§6.3);结案后新案强制翻包。 * * 评级 S-C(§7.2)六变量条件式(doubts/weaponOk/motiveOk/wrongWho/cfMiss/hints), * 播报值=运行值(账目真实);VERDICT 认罪链 800ms 手势死窗+链锁(dungeon H1 同款, * §7.4:链完成前一切手势吞掉,settle_hook「单击接新案」言行一致)。 * R2 F3 起 hit 链 react 按轴取句(慌在刚戳中的那条轴上),4/4 齐追加 reactFinal。 * * 看门狗双轨(§9):无操作轨(guide.watchdog:BOARD [10,25] / CONFRONT [15,30] / * FINISH [20,40],手势重置;R2 F6 起听齐未破时一级提示换指路词 gd_board_cf) * + 无进展轨(游戏层 [45,90,150],只由进展事件重置——听完新条目/指证判定/结案 * 相位推进;乱按饿不死,150s L3 直接给出可指证的矛盾对;R2 F11 起疑点齐时 L2 * 直指 hint_fin)。 */ import { Game } from '../engine/game.js'; import { Guide } from '../engine/guide.js'; import { vibrate } from '../engine/haptics.js'; // ================================ 常量表 ================================ const SENTENCE_GAP_MS = 800; // 证词链播句间停顿(§3.1) // 单句兜底(§4.3:资产缺失/引擎异常时告警续链)。R2 F1:原 8s 会掐真实长句 // (male/intro_1 实测 8.8s、male/sys_help 9.28s,R1 live 开场白丢「川死了」)—— // 兜底必须高于全部真实资产,只防缺失/异常,不当节奏装置。 const SENTENCE_FALLBACK_MS = 15000; const SIG_LEAD_MS = 450; // 签名 earcon → 条目名语音 串行间隔(§8.2) const VERDICT_GRACE_MS = 800; // VERDICT 手势死窗(§7.4:450ms 长按判定+300ms 双击窗全盖) const PROG_LEVELS = [45000, 90000, 150000]; // 无进展轨三级(§9.3:earcon→短句→保底矛盾对) const PROG_POLL_MS = 5000; // 无进展轨轮询周期 const HINT_MAX_TIMES = 2; // 每句保底提示全局限 2 次(§9.3:不轰炸) const DRILL_WATCHDOG_AT = [30000, 60000, 90000]; // 教学步无进展轨(§9.2:nudge→短句→自动滚出) /** 6 份听取卷宗(st_asked 计账)+ 结案陈词入口(虚拟条目,单列)。 */ const LISTEN_ITEMS = ['w1', 'w2', 'w3', 'w4', 'scene', 'fore']; const ITEM_ORDER = [...LISTEN_ITEMS, 'case']; /** 模态菜单(§2.4,5 项;标题/结算用精简 2 项——dungeon 先例)。 */ const MENU_ITEMS = ['sys_menu_resume', 'sys_menu_cf', 'sys_menu_save', 'sys_menu_help', 'sys_menu_restart']; const TITLE_MENU = ['sys_menu_help', 'sys_menu_resume']; /** 结案三问·真凶问选项 = 四位证人(选项名复用条目名句 item_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', ]; // ================================ 案卷数据(§6.1) ================================ /** 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', // R2 F3: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《雨夜·聚宝斋(变体)》(§6.3: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(内容弱绑定,非轴假话;§6.3 成本口径) }, }; function deepClone(o) { return JSON.parse(JSON.stringify(o)); } /** 解析案包(pack B = pack A 模板 + overrides fork;§6.1/§6.3)。 */ 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); this.guide = new Guide(audio); // guide='off' 时全部 API 静默 no-op 且零定时器 this._fixedSeed = opts.seed != null ? opts.seed : null; // playtest 注入(仅首个新案生效) this._fixedPack = opts.pack != null ? opts.pack : null; this.state = 'boot'; // TITLE/DRILL/BOARD/CONFRONT/FINISH/VERDICT/SETTLE this.menuOpen = false; // MENU = 挂起 overlay(记录返回态 menuFrom) this.menuFrom = null; this._menuItems = MENU_ITEMS; this._menuIdx = 0; this._session = 0; // 会话令牌:状态大跳转作废旧计时器/链回调 this._timers = []; this.meta = this._defaultMeta(); this.run = null; this._case = null; // 生成期定局:items/layout/contradictions(§6) this.itemIdx = 0; // 证词板当前条目(0..6,含结案陈词) this._play = null; // 证词链播放会话 {itemIdx, cur, seq};null=空闲 this._cf = null; // 指证子相位(item1/line1/confirm1/item2/line2/confirm2/judged) this._fin = null; // 结案三问子相位(who/whoConfirm/weapon/motive) this._drill = null; // 教学步(0..6) this._annSeq = 0; // 条目名播报令牌(快速滑动时旧报名作废) this._titleChain = []; this._settleChain = []; // SETTLE 双击重听=整条结算段 this._gradeInfo = null; // 本局评级快照(SETTLE 上滑重听评级段) this._verdictGraceUntil = 0; this._menuOpenedAt = null; // 菜单挂起计时(_progAdjustMenuPause 消费) this._caseGate = false; // R2 F5:结案陈词确认闸(armed=再单击才进 FINISH) this._cfLineMem = {}; // R2 F7:选句预览位置按条目记忆(本 case 内;新案清空) this._drillPassName = null; // R2 F2:D0 报名移交 D1 链串行播(防报名顶掉 tut_listen) // 无进展轨(§9.3):只由进展事件重置(_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 }; } // ================================ 案卷生成(§6) ================================ _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 标志即单源)、凶器/动机选项序、保底提示轮询序; * 随后断言矩阵不变式(§6.4:同轴异值对集合 ≡ 矛盾表,防文案事故变归因假话)。 */ _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 项,单击进 FINISH;不参与听取计账与判定) 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); // 矩阵不变式(§6.4,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, }; } // ---------------- 派生账目(§3.1:播报值=运行值) ---------------- _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] || []; } /** 上滑状态链 = 中断复述链同构(§10:st_asked_n → 未听 ≤3 时逐个条目名 → st_doubt_m → 指路尾句)。 * R2 F6:尾句按局面指路——疑点齐=hint_fin(去结案);六份听齐但疑点未齐=recap_cf * (「挑一句你不信的,按住指证」,治「都听完了不知道该干嘛」空档,R1 三人设同撞); * 其余=recap_go「接着查」。 */ _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._session++; this._clearTimers(); this.audio.stopAll(); } _goHome() { this.stop(); window.location.href = '../'; } // ---------------- TITLE(§2.3) ---------------- _enterTitle() { this._session++; this._clearTimers(); this._play = null; this._cf = null; this._fin = null; this._drill = null; this.menuOpen = false; this.menuFrom = null; this.state = 'TITLE'; this.audio.stopVoice(); this.audio.play('bgm/menu'); this.guide.state('menu'); this._titleChain = this.run ? ['voice/sys_continue_ask'] : []; if (this._titleChain.length) this._speakChain(this._titleChain); } _onTitleGesture(g) { if (g === 'tap') { if (this.run) this._resumeRun(); else this._newCase(); } else if (g === 'right') { this._newCase(); // 换一桩新案:重掷 seed/pack,meta 保留(§2.3) } else if (g === 'doubletap') { this._speakChain([...this._titleChain]); // 重听 continue_ask(无档时静默) } else if (g === 'up') { this._speakChain(['voice/intro_1', 'voice/intro_2', 'voice/intro_3']); // 案情简介 } else if (g === 'down') { this._openMenu(); } else { this._invalid(); } } // ---------------- 新案 / 读档(§4.2/§10) ---------------- _newCase(seedOverride, packOverride) { this._session++; this._clearTimers(); this.menuOpen = false; this.menuFrom = null; this.audio.stopVoice(); 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._drill = null; this._caseGate = false; this._cfLineMem = {}; // 新案清空选句记忆(R2 F7) this._save(); this._speakChain(['voice/sys_new_case'], () => { if (!this.meta.drillDone) this._enterDrill(); else { this._progressEvent(); this._enterBoard(); } // 新案进板 = 基线重置 }); } /** 菜单「重新查这桩案子」:同 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(确定性生成下二者相等;防御性信任存档,不重洗 §10) 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._progressEvent(); // 读档进板 = 基线重置 this._enterBoard(this._statusChain()); // 局面复述链(§10:≤10s 回到可操作) } // ================================ 手势总入口 ================================ onGesture(g) { // VERDICT 链锁 + 800ms 死窗(§7.4):链完成前一切手势吞掉——ghost 手势不算 // 输入、不碰看门狗(在途 tap 按新状态路由会把认罪链掐死在半句,dungeon H1)。 if (this.state === 'VERDICT') return; if (g === 'longpressend') return; // 仅引擎快进收尾信号,不承载语义 this.guide.disarm(); // 任何手势复位向导看门狗(off 档 no-op) if (this.menuOpen) { this._onMenuGesture(g); return; } switch (this.state) { case 'TITLE': this._onTitleGesture(g); break; case 'DRILL': this._onDrillGesture(g); break; case 'BOARD': this._onBoardGesture(g); break; case 'CONFRONT': this._onConfrontGesture(g); break; case 'FINISH': this._onFinishGesture(g); break; case 'SETTLE': this._onSettleGesture(g); break; default: break; } // 各态无操作看门狗重挂(手势即重置起点,§9.2) if (this.menuOpen) return; if (this.state === 'BOARD' && !this._play) this._armBoardWatchdog(); else if (this.state === 'CONFRONT' && this._cf && this._cf.phase !== 'judged') this._armCfWatchdog(); else if (this.state === 'FINISH') this._armFinWatchdog(); else if (this.state === 'DRILL' && this._drill) this._armDrillWatchdog(); } _invalid() { vibrate('error'); this.guide.error({ voice: 'gd_invalid', onceKey: 'detective_invalid', skipHaptic: true }); } // ================================ BOARD 证词板(§2.1) ================================ _enterBoard(chain) { this._session++; this._clearTimers(); this._play = null; this._cf = null; this._fin = null; this.menuOpen = false; this._caseGate = false; // 回板即撤闸(R2 F5) this.state = 'BOARD'; this.audio.stopVoice(); this.audio.play('bgm/board'); this.guide.state('explore'); // 无进展轨基线由各入口显式落(新案/读档/出教学=重置;判定回板=判定已重置; // 菜单回板=只剔除挂起时长不重置——见 _closeMenu/_progAdjustMenuPause) this._progArm(); this._armBoardWatchdog(); if (chain && chain.length) this._speakChain(chain); } /** 判定/错指后回板(§4.2:指证判定后返回,explore 签名同点)。 */ _backToBoard() { this._enterBoard(); } _onBoardGesture(g) { if (this._play) { // 播放中子行为(§2.1:纯聆听保护) if (g === 'tap') { this._stopPlayback(); } else if (g === 'doubletap') { this._replayCurrentInChain(); } else if (g === 'up') { this._stopPlayback(); this._speakChain(this._statusChain()); } else if (g === 'down') { this._stopPlayback(); this._openMenu(); } // left/right 静默忽略(无 error 震);longpress 走引擎快进(E2 时间互斥) return; } // R2 F5:结案确认闸在播时,非单击/双击手势先撤闸再走正常映射(滑走=继续查) if (this._caseGate && g !== 'tap' && g !== 'doubletap') this._caseGate = false; if (g === 'tap') { if (ITEM_ORDER[this.itemIdx] === 'case') { // R2 F5:「结案陈词」是循环列表必经站——第一次单击只进确认闸(fin_pre), // 闸内再单击才进三问;滑走撤闸留板(R1:压力线误闯 6-7 次,代价真实) if (this._caseGate) { this._caseGate = false; this._enterFinish(); return; } this._caseGate = true; this._speakChain(['voice/fin_pre']); return; } this._startPlayback(); } else if (g === 'doubletap') { if (this._caseGate) { this._speakChain(['voice/fin_pre']); return; } // 闸内双击=重听闸句 this._replayLastLine(); } else if (g === 'left') { this._navItem(-1); } else if (g === 'right') { this._navItem(1); } else if (g === 'up') { this._speakChain(this._statusChain()); } else if (g === 'down') { this._openMenu(); } else if (g === 'longpress') { // E2 豁免:空闲(无证词链会话)长按=进入指证;与播放中长按快进时间互斥 if (this.audio.isVoicePlaying()) return; // 报名等在播:不受理,引擎快进接管 this._enterConfront(); } } _navItem(d) { this._caseGate = false; // 换条目即撤闸 this.itemIdx = (this.itemIdx + d + ITEM_ORDER.length) % ITEM_ORDER.length; this._announceItem(this.itemIdx); } /** 条目播报:翻页音 + 专属签名 earcon → 条目名整句(串行,§8.2 并发纪律)。 * 结案陈词无签名音:turn earcon(guide 层,off 档静默)代位——不复用 * sfx/turn_tick(那是无进展轨 L1 的注意音,语义不可混用)。 */ _announceItem(idx) { const item = this._itemAt(idx); this.audio.play('sfx/paper_turn'); if (item.sig) this.audio.play('sfx/' + item.sig); else this.guide.turn({}); const seq = ++this._annSeq; this._after(SIG_LEAD_MS, () => { if (seq !== this._annSeq) return; this.audio.play(this._itemName(item)); }); } /** 单击听取当前卷宗:从首个未听句起;全部听过=整体重听(§2.1)。 */ _startPlayback() { const item = this._itemAt(this.itemIdx); if (item.id === 'case' || !item.lines.length) return; 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 }; const p = this._play; const s = this._session; const seq0 = ++p.seq; if (item.sig) this.audio.play('sfx/' + item.sig); this._after(SIG_LEAD_MS, () => { if (s !== this._session || this._play !== p || p.seq !== seq0) return; const lead = (item.intro && from === 0) ? ['voice/' + item.intro] : []; this._speakChain(lead, () => this._playLine(from)); }); } /** 证词链步进:句粒度可停(tap)/可重听(doubletap 重播当前句后续链,§2.1)。 */ _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); }); }); } _stopPlayback() { this._play = null; this.audio.stopVoice(); // E1「单击=停止播报」:真停声(链会话作废 + 当前句静音), // 也是 E2 的前置——停后即空闲,长按指证立即可受理 this._armBoardWatchdog(); } _finishItemPlayback() { const item = this._itemAt(this._play.itemIdx); this._play = null; if (item.id !== 'case' && !this.run.heard.includes(item.id)) { this.run.heard.push(item.id); // 听完新条目 = 进展事件(§9.3) this._save(); } if (this._drill && this._drill.step === 1) { this._drillAdvance(2); return; } // D1 教学=听完整份 this._progressEvent(); this._armBoardWatchdog(); } /** 播放中双击:重播当前句,链从当前句继续(seq 递增作废在途句间停顿)。 */ _replayCurrentInChain() { const p = this._play; if (!p || p.cur < 0) return; // 首句起播前无「当前句」 this._playLine(p.cur); } /** 空闲双击:重新听当前条目最后一句(≤8s,免费零消耗,§2.1)。 */ _replayLastLine() { const item = this._itemAt(this.itemIdx); const ids = this._lineIds(item.id); if (!ids.length) return; const last = this.run.lastLine[item.id]; const lineId = last && ids.includes(last) ? last : ids[0]; if (this._drill && this._drill.step === 2) { this._playSentence(lineId, () => this._drillAdvance(3)); return; } this._playSentence(lineId); } // ================================ CONFRONT 指证(§2.2/§3.5) ================================ _enterConfront() { this._session++; this._clearTimers(); this._play = null; this._fin = null; this.menuOpen = false; this._caseGate = false; this.state = 'CONFRONT'; this.audio.stopVoice(); this.guide.state('choice'); this.guide.turn({ haptic: true }); // 等待点 turn cue(§9.2) 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._drill && this._drill.step === 4) { this._drill.step = 5; // D4 长按即通过 → D5 练习(tut_cf_try 接在 cf_start 链上) chain.push('voice/tut_cf_try'); } this._armCfWatchdog(); this._speakChain(chain, () => { if (this._cf && this._cf.phase === 'item1') this._announceCfItem(); }); } /** 指证条目选择播报(签名前置,§3.7)。 */ _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; this.audio.play(this._itemName(item)); }); } _onConfrontGesture(g) { const cf = this._cf; if (!cf || cf.phase === 'judged') return; // 判定链播报中:吞掉(§4.3 独占链轻量版) if (g === 'tap') { if (cf.phase === 'item1' || cf.phase === 'item2') this._cfConfirmItem(); else if (cf.phase === 'line1' || cf.phase === 'line2') this._cfConfirmLine(); else if (cf.phase === 'confirm1' || cf.phase === 'confirm2') this._cfFixLine(); } else if (g === 'left' || g === '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(); } else if (cf.phase === 'line1' || cf.phase === 'line2') { this._cfMoveLine(d); } else { this._invalid(); // 确认态滑动手势无映射(§2.2) } } else if (g === 'doubletap') { this._cfReplayPreview(); } else if (g === 'up') { this._speakChain(['voice/st_doubt_' + this._doubtCount()]); // 状态链简版(§2.2) } else if (g === 'down') { this._openMenu(); } else if (g === 'longpress') { // R2 F7:选句相位(line1/line2)长按放行=引擎快进(仅语音在播生效——预览句也是 // 语音,宪法内加速通道;空闲则无可快进,error 震)。条目/确认相位维持不作输入。 if (cf.phase === 'line1' || cf.phase === 'line2') { if (!this.audio.isVoicePlaying()) this._invalid(); } else { this._invalid(); // 选择语境长按不作输入(§2.2) } } } _cfCurLineId() { const cf = this._cf; return this._lineIds(LISTEN_ITEMS[cf.idx])[cf.lineIdx]; } _cfConfirmItem() { const cf = this._cf; cf.phase = cf.phase === 'item1' ? 'line1' : 'line2'; // R2 F7:选句入口从该条目上次预览/定下的位置接着走(不再回回从第一句扫起) cf.lineIdx = this._cfLineMem[LISTEN_ITEMS[cf.idx]] || 0; const lead = cf.pickSaid ? [] : ['voice/cf_pick_line']; // 选句相位入口(首次) cf.pickSaid = true; this._speakChain(lead, () => this._playSentence(this._cfCurLineId())); // 播该句全文(句序号不播) } _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; // 预览位置记忆(R2 F7) this._playSentence(this._cfCurLineId()); } _cfConfirmLine() { const cf = this._cf; cf.phase = cf.phase === 'line1' ? 'confirm1' : 'confirm2'; this._speakChain(['voice/' + this._cfCurLineId(), 'voice/cf_confirm']); // 重播该句 + 确认句 } _cfReplayPreview() { const cf = this._cf; if (cf.phase === 'item1' || cf.phase === 'item2') { this._announceCfItem(); return; } this._playSentence(this._cfCurLineId()); // 双击=重听当前预览句(宪法语义保留) } /** 确认态单击=定下这句:第一份定下自动重播一次再选第二份(P1 记忆契约,§3.7)。 */ _cfFixLine() { const cf = this._cf; const itemId = LISTEN_ITEMS[cf.idx]; const lineId = this._cfCurLineId(); this._cfLineMem[itemId] = cf.lineIdx; // 定下=最强记忆点(R2 F7) if (cf.phase === 'confirm1') { cf.fixed = { item: itemId, line: lineId }; cf.phase = 'item2'; cf.lineIdx = 0; this._speakChain(['voice/cf_second', 'voice/' + lineId]); return; } // 第二份定下 → 判定(教学 D5 截获:练习不算数、不计 cfMiss、不动 doubts,§5) if (this._drill && this._drill.step === 5) { cf.phase = 'judged'; vibrate('success'); this._speakChain(['voice/tut_cf_ok'], () => { this._cf = null; this.state = 'DRILL'; this._drillAdvance(6); }); return; } this._judge(cf.fixed.line, lineId); } /** * 指证判定(§3.5):判定与归因全部从线索轴数据单源推导。 * hit 链 = contra_hit(sfx)→ cf_hit → react_n(真凶累计被坐实次数递进); * miss 链 = contra_miss(sfx)→ cf_self / cf_same / cf_diff(三档真话)。 */ _judge(lineA, lineB) { this._cf.phase = 'judged'; const cl = this._case.lineClues; if (lineA === lineB) { this._cfMiss('voice/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('voice/cf_diff'); return; } const conflict = shared.find((ax) => ca[ax] !== cb[ax]); if (!conflict) { this._cfMiss('voice/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('voice/cf_diff'); return; } this._cfHit(contra); } _cfMiss(voiceId) { this.run.cfMiss++; this._progressEvent(); // 指证判定 = 进展事件(§9.3) this._save(); this.audio.play('sfx/contra_miss'); this._speakChain([voiceId], () => 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++; } // R2 F3:react 按轴取句——慌要慌在刚戳中的那条轴上;按累计次数取句会在换顺序 // 坐实时答非所问(R1 人设 P1:先戳凶器却慌「灯」= 演绎层假话)。 const axisReact = this._case.reacts[contra.axis] || this._case.reactFinal; const chain = ['voice/cf_hit', 'voice/' + axisReact]; // 疑点 4/4 齐:追加收官句 reactFinal(与轴句不同才不重复)+ 自动播一次 hint_fin // (引导去结案;庆祝性播报不计入保底提示账,§3.5) 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'); // 全游戏最强确认音先响(§8.2) vibrate('success'); this._speakChain(chain, () => this._backToBoard()); } // ================================ FINISH 结案三问(§7.1) ================================ _enterFinish() { this._session++; this._clearTimers(); this._play = null; this._cf = null; this.menuOpen = false; this._caseGate = false; this.state = 'FINISH'; this.audio.stopVoice(); this.guide.state('choice'); this.guide.turn({ haptic: true }); this._fin = { phase: 'who', optIdx: 0, busy: false }; this._armFinWatchdog(); this._speakChain(['voice/fin_start'], () => { this.audio.play(this._whoName(WHO_ITEMS[0])); // R2 F4:指认的是人,播人名不播卷宗名 }); } /** 真凶问选项名=纯人名(R2 F4:item_who_*;原复用条目名「学徒小顺的证词」语义错位)。 */ _whoName(itemId) { return 'voice/item_who_' + itemId; } _onFinishGesture(g) { const fin = this._fin; if (!fin) return; if (fin.busy) return; // 归因/反馈链播报中:吞掉(防重复答题重复记账) if (g === 'tap') { if (fin.phase === 'who') { fin.phase = 'whoConfirm'; // 两段确认(§2.2:重播人名 + fin_who_confirm「就指认这位?」R2 F4) this._speakChain([this._whoName(WHO_ITEMS[fin.optIdx]), 'voice/fin_who_confirm']); } else if (fin.phase === 'whoConfirm') { this._resolveWho(WHO_ITEMS[fin.optIdx]); } else if (fin.phase === 'weapon') { this._resolveWeapon(this._case.layout.wpOpts[fin.optIdx]); } else if (fin.phase === 'motive') { this._resolveMotive(this._case.layout.mvOpts[fin.optIdx]); } } else if (g === 'left' || g === 'right') { const d = g === 'right' ? 1 : -1; 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; if (fin.phase === 'who' || fin.phase === 'whoConfirm') { this.audio.play(this._whoName(WHO_ITEMS[fin.optIdx])); } else { this.audio.play('voice/' + opts[fin.optIdx]); } } else if (g === 'doubletap') { // 重听当前问句/选项(宪法语义保留) if (fin.phase === 'who' || fin.phase === 'whoConfirm') { this.audio.play(this._whoName(WHO_ITEMS[fin.optIdx])); } else if (fin.phase === 'weapon') { this._speakChain(['voice/fin_ask_weapon', 'voice/' + this._case.layout.wpOpts[fin.optIdx]]); } else if (fin.phase === 'motive') { this._speakChain(['voice/fin_ask_motive', 'voice/' + this._case.layout.mvOpts[fin.optIdx]]); } } else if (g === 'up') { this._speakChain(['voice/st_doubt_' + this._doubtCount()]); } else if (g === 'down') { this._openMenu(); } else if (g === 'longpress') { this._invalid(); } } _resolveWho(itemId) { const fin = this._fin; if (itemId === this._case.culprit) { // 指认对 → 凶器问(§7.1:三问顺序 真凶→凶器→动机) fin.busy = true; this._progressEvent(); // 结案相位推进(§9.3) this._save(); fin.phase = 'weapon'; fin.optIdx = 0; this.guide.turn({ haptic: true }); this._armFinWatchdog(); this._speakChain(['voice/fin_ask_weapon'], () => { fin.busy = false; this.audio.play('voice/' + this._case.layout.wpOpts[0]); }); return; } // 错指:不在场证明复述 + fin_clear + st_wrong 当场记账 → 回 BOARD(无限再机会,§7.1) // R2 F9:st_wrong「错指一回,记档。」让评级账目当场可闻(原结算才揭账,S/A 差距靠悟) fin.busy = true; this.run.wrongWho++; this._progressEvent(); this._save(); this._speakChain(['voice/' + this._case.wrongAccuse[itemId], 'voice/fin_clear', 'voice/st_wrong'], () => this._backToBoard()); } _resolveWeapon(optId) { const fin = this._fin; fin.busy = true; this.run.weaponAns = optId; this._progressEvent(); this._save(); const ok = optId === this._case.questions.weapon.answer; this._speakChain(['voice/' + (ok ? 'fin_wp_right' : 'fin_wp_wrong')], () => { fin.phase = 'motive'; fin.optIdx = 0; fin.busy = false; this.guide.turn({ haptic: true }); this._armFinWatchdog(); this._speakChain(['voice/fin_ask_motive'], () => { this.audio.play('voice/' + this._case.layout.mvOpts[0]); }); }); } _resolveMotive(optId) { this._fin.busy = true; this.run.motiveAns = optId; this._progressEvent(); this._save(); const ok = optId === this._case.questions.motive.answer; this._speakChain(['voice/' + (ok ? 'fin_mv_right' : 'fin_mv_wrong')], () => this._enterVerdict()); } // ================================ 评级与裁决(§7) ================================ /** 评级六变量条件式(§7.2,自上而下首个命中;纯函数供测试注入矩阵)。 */ _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, }; } /** 结算账目段(§7.3:播报值=运行值,措辞档位与账目一一映射)。 */ _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) ]; } /** 裁决仪式(§7.4):fin_right → rain_swell → 认罪三段 → gavel → 结算链。 */ _enterVerdict() { this._session++; this._clearTimers(); this._play = null; this._cf = null; this._fin = null; this.menuOpen = false; this.state = 'VERDICT'; this.audio.stopVoice(); this._verdictGraceUntil = performance.now() + VERDICT_GRACE_MS; // 手势死窗(onGesture 已全吞,双保险留档) this.audio.play('bgm/verdict', { fadeMs: 800 }); this.guide.state('settle'); vibrate('success'); // 元进度入账(结案必达:无失败态,C 档覆盖一切) const outcome = this._gradeOutcome(); const grade = this._gradeOf(outcome); this.meta.casesClosed++; this.meta.lastPack = this._case.pack; 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); this._settleChain = settlePart; this._speakChain(['voice/' + this._case.verdict.accuseLine], () => { this.audio.play('sfx/rain_swell'); // 认罪链垫(6s one-shot) this._speakChain(this._case.verdict.break.map((x) => 'voice/' + x), () => { this.audio.play('sfx/gavel'); // 法槌 this.audio.play('bgm/menu', { volume: 0.5 }); // SETTLE 结算曲 this._speakChain(settlePart, () => { this.state = 'SETTLE'; // 链完成才解锁手势(链锁,§7.4) this.guide.state('menu'); }); }); }); } _onSettleGesture(g) { if (g === 'tap') { // 接新案:pack 强制翻转 + seed 重掷(§12 开环钩子:二周目必遇变体真凶) this._newCase(null, this.meta.lastPack == null ? 1 : 1 - this.meta.lastPack); } else if (g === 'doubletap') { this._speakChain([...this._settleChain]); // 重听结算链 } else if (g === 'up') { if (this._gradeInfo) this._speakChain(this._settlePartVoices(this._gradeInfo.outcome).slice(1, 4)); // 评级段 } else if (g === 'down') { this._openMenu(); } else { this._invalid(); } } // ================================ DRILL 教学(§5) ================================ _enterDrill() { this.state = 'DRILL'; this._session++; this._clearTimers(); this.audio.play('bgm/board'); this.guide.state('explore'); this._drill = { step: -1, fails: 0 }; this._drillEnter(0); } _drillEnter(step) { const d = this._drill; d.step = step; d.fails = 0; this._armDrillWatchdog(); if (step === 0) { // FTUE:intro 三句(~13s)+ D0 指令 → 首输入达成点 ≈t+20s(§5) this._speakChain(['voice/intro_1', 'voice/intro_2', 'voice/intro_3', 'voice/tut_d0']); } else if (step === 1) { // D1 听取老葛真实证词:无论 D0 滑到哪,回正到第一份并重新报名。 // R2 F2:D0 划到的条目名(_drillPassName)接在链首串行补播——原实现里 // _navItem 的 450ms 报名与 tut_listen 并行互掐(live R1:指令句只播 0.4s)。 const lead = this._drillPassName ? ['voice/' + this._drillPassName] : []; this._drillPassName = null; this.itemIdx = 0; this._speakChain([...lead, 'voice/tut_listen'], () => this._announceItem(0)); } else if (step === 2) { this._speakChain(['voice/tut_replay']); } else if (step === 3) { this._speakChain(['voice/tut_status']); } else if (step === 4) { this._speakChain(['voice/tut_confront']); // E2 豁免教学 } else if (step === 5) { // 兜底路径(D4 连错 2 次自动通过):直接开练习局 this._enterConfront(); } else if (step === 6) { this._speakChain(['voice/tut_done'], () => this._drillDone()); } // step 5 的旁白(tut_cf_try)由 _enterConfront 接在 cf_start 链上(CONFRONT 态) } _drillWrong() { const d = this._drill; if (!d) return; d.fails++; vibrate('error'); if (d.fails >= 2) this._drillPass(); // 做错 ≤2 次后自动演示通过(§5) } _drillPass() { const d = this._drill; if (!d) return; d.fails = 0; this._drillAdvance(d.step + 1); } _drillAdvance(step) { if (!this._drill) return; if (step > 6) { this._drillDone(); return; } this._drillEnter(step); } _drillDone() { this.meta.drillDone = true; // 持久化:二周目不再进(§5) this._drill = null; this._save(); this._progressEvent(); // 出教学进板 = 基线重置 this._enterBoard(); } _drillSkip() { // 下滑=跳过教学(§5) this.meta.drillDone = true; this._drill = null; this._cf = null; this._save(); this._speakChain(['voice/tut_skip'], () => { this._progressEvent(); this._enterBoard(); }); } _onDrillGesture(g) { const d = this._drill; if (!d) { this._onBoardGesture(g); return; } if (g === 'down') { this._drillSkip(); return; } if (this._play) { // D1 老葛证词播放中:播放子行为优先(tap 停/双击重听),其余忽略 if (g === 'tap') this._stopPlayback(); else if (g === 'doubletap') this._replayCurrentInChain(); return; } switch (d.step) { case 0: if (g === 'left' || g === 'right') { // 签名 earcon + 条目名(真实内容);R2 F2:报名移交 D1 链串行播(不再互掐) this.itemIdx = (this.itemIdx + (g === 'right' ? 1 : -1) + ITEM_ORDER.length) % ITEM_ORDER.length; const item = this._itemAt(this.itemIdx); this.audio.play('sfx/paper_turn'); if (item.sig) this.audio.play('sfx/' + item.sig); this._drillPassName = item.name; vibrate('confirm'); this._drillPass(); } else this._drillWrong(); break; case 1: if (g === 'tap') { if (ITEM_ORDER[this.itemIdx] === 'case') this._drillWrong(); else this._startPlayback(); // 单击听取当前卷宗(真实证词链播) } else if (g === 'doubletap') this._replayLastLine(); else this._drillWrong(); break; case 2: if (g === 'doubletap') this._replayLastLine(); // 重新听这一句(免费) else this._drillWrong(); break; case 3: if (g === 'up') { this._speakChain(this._statusChain(), () => this._drillPass()); // st_asked_1 → st_doubt_0 } else this._drillWrong(); break; case 4: if (g === 'longpress') this._enterConfront(); // E2:按住进入指证 else this._drillWrong(); break; case 6: break; // tut_done 链播中,链完自动进 BOARD default: this._drillWrong(); } } // ================================ 模态菜单(§2.4/00 §5.2) ================================ _openMenu() { if (this.menuOpen) return; this.menuFrom = this.state; this.menuOpen = true; this._menuItems = (this.state === 'TITLE' || this.state === 'SETTLE') ? TITLE_MENU : MENU_ITEMS; this._menuIdx = 0; this._menuOpenedAt = performance.now(); // 无进展轨挂起计时(关菜单时剔除时长,§9.3) this._caseGate = false; // 开菜单撤结案闸(R2 F5) this._session++; // 打开即停播报/停看门狗(菜单=天然暂停,本作无实时玩法) this._clearTimers(); this._play = null; this.audio.stopVoice(); this.audio.play('bgm/menu'); this.guide.state('menu'); this._speakChain(['voice/sys_menu', 'voice/' + this._menuItems[0]]); } /** 菜单挂起时长从无进展轨剔除:lastAt 前移 = 时钟续走、分级保留—— * 反复开菜单不再能重置救援轨(§9.3 进展事件清单不含菜单;dungeon R3 E0-1 * 「菜单时间不计入」的正确形态是剔除时长,不是整轨重置)。 */ _progAdjustMenuPause() { if (this._menuOpenedAt == null) return; this._prog.lastAt += performance.now() - this._menuOpenedAt; this._menuOpenedAt = null; } _closeMenu() { this.menuOpen = false; const from = this.menuFrom; this.menuFrom = null; // 关闭回执=状态签名(dungeon R2 D1-1 先例:盲手可感知开关) if (from === 'TITLE' || from === 'SETTLE') { this._menuOpenedAt = null; this.state = from; this.guide.state('menu'); this.audio.play('bgm/menu', { volume: from === 'SETTLE' ? 0.5 : 1 }); return; } if (from === 'CONFRONT') { if (this._drill && (this._drill.step === 4 || this._drill.step === 5)) { // D4/D5 练习被菜单打断:退回 D4 重新按长按进指证(教学不丢步) this._menuOpenedAt = null; this._cf = null; this.state = 'DRILL'; this._drill.step = 4; this.audio.play('bgm/board'); this.guide.state('explore'); this._speakChain(['voice/tut_confront']); return; } this._menuOpenedAt = null; this.state = 'CONFRONT'; this.audio.play('bgm/board'); this.guide.state('choice'); if (this._cf && this._cf.phase !== 'judged') this._armCfWatchdog(); return; } if (from === 'FINISH') { this._menuOpenedAt = null; this.state = 'FINISH'; this.audio.play('bgm/board'); this.guide.state('choice'); if (this._fin) this._fin.busy = false; this._armFinWatchdog(); return; } if (from === 'DRILL') { this._menuOpenedAt = null; this.state = 'DRILL'; this.audio.play('bgm/board'); this.guide.state('explore'); this._armDrillWatchdog(); return; } // BOARD:只剔除挂起时长(不重置分级——乱按开菜单饿不死救援,§9.3) this.state = 'BOARD'; this.audio.play('bgm/board'); this.guide.state('explore'); this._progAdjustMenuPause(); this._progArm(); this._armBoardWatchdog(); } _onMenuGesture(g) { const items = this._menuItems; if (g === 'tap' || g === 'right') { this._menuIdx = (this._menuIdx + 1) % items.length; // 单击循环切项(每切播项名) this.audio.play('voice/' + items[this._menuIdx]); } else if (g === 'left') { this._menuIdx = (this._menuIdx + items.length - 1) % items.length; this.audio.play('voice/' + items[this._menuIdx]); } else if (g === 'doubletap') { this._menuExec(items[this._menuIdx]); } else if (g === 'up') { this._speakChain(['voice/sys_help', 'voice/settings_hint']); // 帮助(键盘态引擎路由 key 变体) } else if (g === 'down') { this._closeMenu(); } } _menuExec(item) { if (item === 'sys_menu_resume') { // 「返回证词板」:从 CONFRONT/FINISH = 退出指证/结案回板(§2.2/§2.4) if (this.menuFrom === 'CONFRONT' || this.menuFrom === 'FINISH') { if (this._drill && (this._drill.step === 4 || this._drill.step === 5)) { // D4/D5 练习被菜单打断:退回 D4 重新按长按进指证(教学不丢步) this.menuOpen = false; this.menuFrom = null; this._menuOpenedAt = null; this._cf = null; this.state = 'DRILL'; this._drill.step = 4; this.audio.play('bgm/board'); this.guide.state('explore'); this._speakChain(['voice/tut_confront']); return; } this.menuOpen = false; this.menuFrom = null; this._cf = null; this._fin = null; this._progAdjustMenuPause(); // 菜单挂起时长剔除(分级保留,指证耗时照计) this._backToBoard(); } else { this._closeMenu(); } } else if (item === 'sys_menu_cf') { // 去指证 = 长按捷径的菜单冗余入口(P4 容错,§2.4) if (this.state === 'BOARD' && !this._play) { this.menuOpen = false; this.menuFrom = null; this._menuOpenedAt = null; this._enterConfront(); } else { this._closeMenu(); } } else if (item === 'sys_menu_save') { this._save(); this._speakChain(['voice/sys_saved'], () => this._goHome()); } else if (item === 'sys_menu_help') { this._speakChain(['voice/sys_help', 'voice/settings_hint']); } else if (item === 'sys_menu_restart') { this.menuOpen = false; this.menuFrom = null; this._menuOpenedAt = null; this._restartCase(); } } // ================================ 看门狗(§9) ================================ // ---- 无操作轨(guide.watchdog:手势重置;guide=off 全静零定时器) ---- _armBoardWatchdog() { // R2 F6:六份听齐且疑点未齐时,一级提示换指路词(「换卷宗」此时是错指令) const say0 = (this.run && this._heardCount() >= 6 && this._doubtCount() < 4) ? 'gd_board_cf' : 'gd_board'; this.guide.watchdog({ arm: true, at: [10000, 25000], say: [say0, null], cond: () => this.state === 'BOARD' && !this.menuOpen && !this._play, }); } _armCfWatchdog() { this.guide.watchdog({ arm: true, at: [15000, 30000], say: ['gd_cf', null], cond: () => this.state === 'CONFRONT' && !this.menuOpen && this._cf && this._cf.phase !== 'judged', }); } _armFinWatchdog() { this.guide.watchdog({ arm: true, at: [20000, 40000], say: ['gd_fin', null], cond: () => this.state === 'FINISH' && !this.menuOpen, }); } _armDrillWatchdog() { // 教学步无进展轨(§9.2:nudge → 重述指令 → 自动滚出) const d = this._drill; if (!d) return; const stepVoice = ['tut_d0', 'tut_listen', 'tut_replay', 'tut_status', 'tut_confront', 'tut_cf_try', 'tut_done'][d.step] || null; this.guide.watchdog({ arm: true, at: DRILL_WATCHDOG_AT, say: [null, stepVoice, null], cond: () => this.state === 'DRILL' && this._drill === d, onEscalate: (n) => { if (n === 3) this._drillSkip(); }, }); } // ---- 无进展轨(游戏层 [45/90/150];只由进展事件重置——乱按饿不死,§9.3) ---- _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; } // 播放会话期间照常检查(计时挂墙钟,只由 _progressEvent 重置,§9.3)—— // 听新条目会在链尾自然重置;重听旧条目照样攒时间,乱按饿不死 if (this.state === 'BOARD' && !this.menuOpen) this._progCheck(); this._after(PROG_POLL_MS, loop); }; this._after(PROG_POLL_MS, loop); } /** * 无进展轨分级(§9.3):t = 墙钟距上次进展事件(语音在播不冻结)。 * - L1 turn_tick 是 sfx 层 earcon「不占台词通道」——到点即发(证词播中也照发); * - L2/L3 是台词句——阈值判定不冻结,但只在**无证词链会话**时发(不掐断玩家 * 主动听取的证词句;报名/重听类短句可被顶掉——可重复触发,无内容损失。 * 否则乱按者自己触发的短语音会把救援句饿死,违背「三级必达」)。 */ _progCheck() { const p = this._prog; const t = performance.now() - p.lastAt; const chainBusy = !!this._play; // 证词链在播 = 保护;短语音不构成保护 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; // R2 F11:疑点已齐还播「逐句比对」是指引过时——此情直指结案(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 定序轮询未发现清单,§9.3)。 */ _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 次(§9.3「不轰炸」口径统一) 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'); // R2 F8:递答案不用 warning 三连震(像训人),轻震即可 this.run.hintsGiven.push(id); // 保底提示入账(评级变量 hints,§7.2) this._save(); this._speakChain(['voice/' + id]); } // ================================ 语音链 / 计时器基础设施 ================================ /** 单句播放:自然播完推进;15s 兜底(资产缺失告警续链,§4.3 契约;R2 F1:高于全部真实资产)。 */ _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); } /** 串行链:逐句 onComplete(或 8s 兜底)推进;session 变更(打断)整条作废。 */ _speakChain(ids, onDone) { if (!ids || !ids.length) { if (onDone) onDone(); return; } const s = this._session; const head = ids[0].replace(/^voice\//, ''); this._playSentence(head, () => { if (s !== this._session) return; this._speakChain(ids.slice(1), onDone); }); } _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 = []; } // ================================ 存档(§10) ================================ _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; } } }