Files
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

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