Files
SoundGame/web/detective/game.js
T
xsl c8843233b0 research(new8/batch2): abyss + detective 迭代闭环收口——双双 R1-R2 连续达标裁定可交付
abyss《深海回音》:
- R1 补 synthesis(5/5 愿意玩 P0=0)+ 补录 R1 缺失裁决材料
- R2 十修全部现场坐实:F1 ping迟到/尾句保护首播制/链末看门狗不拆/回波静默一拍/dv4_dual扣账口播/crit水面变体/看门狗13s/震循环1.1s/震去重/状态链人数句
- 补录语音18段(flock串行,QC无离群);playtest 242→278
- 终裁:4/4 人设满分档,连续 2 轮达标 → 可交付 ✅

detective《雨夜专案》:
- R1 取证轮:补跑 live 两会话(认真新手33分钟全弧线/乱按200s三级保底抢救),5/5 愿意玩 P0=0
- R2 十一修全部坐实:intro完整落声/tut不被掐/按轴取句/人名指认/结案确认闸/指路句/选句记忆位/hint轻震/st_wrong当场记账/tut_skip补动词/L2疑点齐分支
- 补录+改词33条(29新增+4重录);playtest 265→279
- 终裁:6/6 愿意玩,连续 2 轮达标 → 可交付 ✅

公共:00-program 批次2进度账收账、13号文档 R2 断言关键词、gen_voice_dual 两游戏段、voice_text.json
验证:abyss 278/278 + detective 279/279 + guide 122/122 + check_all_assets 全项目全绿
2026-09-14 00:05:42 +08:00

1546 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《雨夜专案》—— 纯音频本格推理游戏(按 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;
}
}
}