/** * confusion.js — 迷茫检测(26 号文档 §4.5,P2 第 9 项)。 * * 迷茫不只看「不动」,更要从「乱点」里识别。信号(10s 滑动窗口): * | 信号 | 判定 | 权重 | * |----------|----------------------------|------| * | 连续无效 | 10 秒内 ≥3 次 invalid | 2 | * | 反复重听 | 10 秒内主动重听 ≥2 次 | 1 | * | 乱滑 | 2 秒内 ≥3 个不同滑动方向 | 1 | * | 同步失败 | 同一等待点失败 ≥2 次 | 2 | * | 静默 | 动作类 6s / 剧情类 18s 无操作 | 1 | * * 权重累计 ≥2 触发接管,15s 冷却期内不再触发。三级接管由当前等待点实现 * (onConfused(level)):1 提示(_short 句)→ 2 缩小(只保留一个正确手势)→ * 3 演示(游戏自己做一遍再交回)。同一等待点每触发一次升一级;第 3 级用过后 * 该等待点停止接管,只保留等待声。 */ const WINDOW_MS = 10000; const COOLDOWN_MS = 15000; const SCORE_TRIGGER = 2; const MAX_LEVEL = 3; export class Confusion { /** * @param {object} opts * @param {(level: number) => void} opts.onConfused 三级接管回调(由当前等待点实现) */ constructor({ onConfused } = {}) { this.events = []; // { t, kind, dir? } this.cooldownUntil = 0; this.onConfused = onConfused || null; this.level = 0; // 当前等待点已接管到的级别 this.silenceMs = 18000; // 静默阈值:动作类 6s / 剧情类 18s(游戏设定) } /** 玩家输入(任意手势;dir=手势名,乱滑检测用)。 */ record(g) { this._push({ kind: 'input', dir: g }); } /** 无效操作结果(pipeline 在游戏返回 invalid 时喂入)。 */ recordInvalid() { this._push({ kind: 'invalid' }); } /** 玩家主动重听。 */ recordReplay() { this._push({ kind: 'replay' }); } /** 同一步骤失败(游戏判定:答错/走错,喂入)。 */ recordFail() { this._push({ kind: 'fail' }); } /** 静默(guide.watchdog 到点回调喂入;权重 1)。 */ recordSilence() { this._push({ kind: 'silence' }); } /** 换等待点:清事件、级别归零。 */ resetForWaitPoint() { this.events = []; this.level = 0; } /** 当前级别(等待点第 3 级用过后不再升)。 */ get exhausted() { return this.level >= MAX_LEVEL; } _push(e) { const now = performance.now(); this.events.push({ t: now, ...e }); this.events = this.events.filter((x) => now - x.t < WINDOW_MS); if (now < this.cooldownUntil) return; if (this._score(now) >= SCORE_TRIGGER && this.level < MAX_LEVEL) { this.level++; this.cooldownUntil = now + COOLDOWN_MS; if (this.onConfused) this.onConfused(this.level); } } _score(now) { const n = (k) => this.events.filter((x) => x.kind === k).length; const recentDirs = new Set(this.events .filter((x) => x.kind === 'input' && now - x.t < 2000 && x.dir && x.dir !== 'tap') .map((x) => x.dir)); let s = 0; if (n('invalid') >= 3) s += 2; if (n('replay') >= 2) s += 1; if (recentDirs.size >= 3) s += 1; if (n('fail') >= 2) s += 2; if (n('silence') >= 1 && this._onlySilenceRecently(now)) s += 1; return s; } /** 静默只在「窗口内除静默外几乎没有输入」时计分(有输入就不算静默)。 */ _onlySilenceRecently(now) { const inputs = this.events.filter((x) => x.kind === 'input' && now - x.t < WINDOW_MS); return inputs.length === 0; } } export default Confusion;