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 全项目全绿
1546 lines
66 KiB
JavaScript
1546 lines
66 KiB
JavaScript
/**
|
||
* 《雨夜专案》—— 纯音频本格推理游戏(按 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;
|
||
}
|
||
}
|
||
}
|