Files
xsl ee9d7efe57 feat(playbook): 26 号交互手册首批落地——storyline/escape/simon 全面重构 + 引擎六模块
按 docs/design/26-audio-interaction-playbook.md 重构三款游戏(对白重写 + 双性别语音重生成 580 文件)。

引擎层(web/engine/,其余 12 款游戏零改动):
- floor.js 发言权管理器:SPEAKING/YOUR_TURN/BUSY/ACTION 四态、交接提示音、
  等待声(220/330Hz 嘟-嘟,20s 后放缓)、跨链音乐压低(audio.js holdDuck)
- receipts.js 输入回执三件套(ok/invalid/busy,100ms 内,原则 4)
- confusion.js 迷茫检测(乱点/无效/静默信号 → 三级接管,§4.5)
- hints.js 等待点 + 三档提示(full→short→提示音,30s 台账跨点去重,原则 9)
- novice.js 手势解锁阶梯(up/down/doubletap 跨局持久,§4.3)
- pipeline.js 输入管道(回执收口、防连点 400ms、双击折算、explainOnce)

游戏层:
- storyline R3:开场 71.7s 规则念白 → 3 秒内动手;双击=重播(反模式5)废除;
  抉择点三档提示+主动重听;断点存档复述(§4.12)
- escape R3:单击=检查+使用合并(双击用法废除);长按=我在哪(四向玩法裁定);
  撞墙归因每方向一次(原则 7);迷茫 L2 缩小/L3 演示;坐标存档续玩
- simon R3:新手三音色,按实力渐进解锁(+up/down/dt);失败表替代机会账
  (归因 2s 内→免费重听→放慢→结算,原则 15);播放期手势=busy 回执

测试:三款 playtest 重写(含 §6.2 乱点机器人:无声操作/被动重复/打断失败全 0,
首操作 ~2s);guide.test 改为 Guide 层单测+rhythm/space 段。全部通过
(38+33+22+72+19+35 断言)。设计单见 docs/design/26-apply-*.md。
2026-10-01 08:11:12 +08:00

90 lines
3.5 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.
/**
* 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;