按 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。
90 lines
3.5 KiB
JavaScript
90 lines
3.5 KiB
JavaScript
/**
|
||
* 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;
|