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。
This commit is contained in:
xsl
2026-10-01 08:11:12 +08:00
parent d15a9b9635
commit ee9d7efe57
1183 changed files with 3945 additions and 4588 deletions
+124
View File
@@ -0,0 +1,124 @@
/**
* pipeline.js — 输入管道(26 号文档 §4.2 接线方式 + §4.3 新手手势模式 + 防连点)。
*
* 每款改造游戏的 attachGestures 回调都换成这个管道(P0 第 1/2/3/4 项一次落地):
*
* const pipe = makeInputPipeline({ floor, confusion, receipts, invalidHints,
* game, ladder });
* attachGestures(root, pipe, { doubleTapWindow: 0 }); // 关掉双击 + 去掉 300ms 延迟
*
* 管道顺序:
* 1. 新手手势门(§4.3):未解锁 doubletap 按一次 tap 处理(键盘回车发的
* doubletap 同样折算);未解锁 down / 非玩法 longpress → invalid 回执 +
* 首次附一句解释(原则 7)。
* 2. confusion.record(g)(§4.5 乱点信号)。
* 3. floor.handleInput(g)(§4.1):SPEAKING 中 tap=跳句(consumed)、滑动=打断并
* 执行、critical=busy;BUSY=busy 回执;YOUR_TURN 停等待声。
* 4. 防连点(§4.2):tap 刚执行过「继续/跳过」后的 400ms 内,再来的 tap 只给
* busy 回执(点两下不连跳两句)。ACTION 态(答题)不设防。
* 5. 等待点第 2 级限制(§4.5 缩小):只放行 expected 手势,其余 invalid 回执。
* 6. game.onGesture(g) 返回值收口:
* 'ok' 接受(完成当前等待点)
* 'hold' 接受但等待点继续等(如抉择点单击=再听选项)
* 'silent-ok' 接受且已有明显内容音(省 ok 回执,§4.2)
* 'invalid' 拒绝(invalid 回执 + explainOnce + 迷茫信号;等待点计时复位)
*/
import { GestureLadder } from './novice.js';
/** 防连点窗口:tap 执行「继续/跳过」后 400ms 内的 tap 只给 busy。 */
const TAP_GUARD_MS = 400;
/** 管道要喂给迷茫检测的手势(收尾/撤销类噪声不计)。 */
const REAL_GESTURES = new Set(['tap', 'doubletap', 'up', 'down', 'left', 'right', 'longpress']);
/**
* @param {object} deps
* @param {import('./floor.js').Floor} floor
* @param {import('./confusion.js').Confusion} confusion
* @param {import('./receipts.js').Receipts} receipts
* @param {import('./hints.js').InvalidHints} invalidHints
* @param {{ onGesture: (g: string, info?: object) => string|void,
* stateName?: () => string,
* invalidHint?: (state: string, g: string) => string|null }} game
* @param {import('./novice.js').GestureLadder} [deps.ladder] 手势解锁阶梯(缺省=全新手)
* @param {object} [deps.opts]
* @param {boolean} [deps.opts.longpressCore=false] 本游戏长按是核心玩法(四向滑动类
* 游戏的帮助键,§4.3 裁定);false 时 longpress → invalid
* @param {boolean} [deps.opts.swipeCore=false] 四向滑动是本游戏核心玩法(§4.3 裁定):
* up/down 不按阶梯锁(它们是移动,不是帮助/菜单);阶梯只管 doubletap
*/
export function makeInputPipeline({ floor, confusion, receipts, invalidHints, game, ladder, opts = {} }) {
const lad = ladder || new GestureLadder();
const longpressCore = (opts && opts.longpressCore) === true;
const swipeCore = (opts && opts.swipeCore) === true;
let lastTapActedAt = -Infinity;
return (g, info) => {
if (g === 'longpressend' || g === 'tapcancel') return; // 手势收尾/撤销噪声,不吃回执
// ---- 1. 新手手势门(§4.3)----
if (g === 'doubletap' && !lad.has('doubletap')) g = 'tap'; // 双击按一次单击处理
if (!swipeCore && g === 'up' && !lad.has('up')) {
receipts.play('invalid');
invalidHints.explainOnce('locked:up', game.invalidHint ? game.invalidHint('locked', 'up') : null);
return;
}
if (!swipeCore && g === 'down' && !lad.has('down')) {
receipts.play('invalid');
invalidHints.explainOnce('locked:down', game.invalidHint ? game.invalidHint('locked', 'down') : null);
return;
}
if (g === 'longpress' && !longpressCore) {
receipts.play('invalid');
invalidHints.explainOnce('locked:longpress', game.invalidHint ? game.invalidHint('locked', 'longpress') : null);
return;
}
// ---- 2. 迷茫检测(§4.5)----
if (REAL_GESTURES.has(g)) confusion.record(g);
// ---- 3. 防连点(§4.2):tap 刚执行过「继续/跳过」后的 400ms 内,再来的 tap
// 只给 busy 回执(无论它将被解释成跳句还是继续——都不执行)----
if (g === 'tap' && floor.state !== 'ACTION' && performance.now() - lastTapActedAt < TAP_GUARD_MS) {
receipts.play('busy');
return;
}
// ---- 4. 发言权(§4.1)----
const preState = floor.state;
const pre = floor.handleInput(g);
if (pre === 'busy') { receipts.play('busy'); return; }
if (pre === 'consumed') {
receipts.play('ok'); // tap 跳句 = 一次「继续」,进防连点窗口
lastTapActedAt = performance.now();
return;
}
// ---- 5. 迷茫第 2 级:等待点只放行 expected(§4.5 缩小)----
const wp = floor.waitPoint;
if (wp && wp.restricted && !wp.allows(g)) {
receipts.play('invalid');
confusion.recordInvalid();
return;
}
// ---- 6. 游戏处理 + 回执收口 ----
const r = game.onGesture(g, info) || 'ok';
if (r === 'invalid') {
receipts.play('invalid');
const state = game.stateName ? game.stateName() : '';
invalidHints.explainOnce(`${state}:${g}`, game.invalidHint ? game.invalidHint(state, g) : null);
confusion.recordInvalid();
// 等待点仍在(无效操作没完成它):静默计时复位、回到等待态(含打断后的 BUSY)
if (floor.waitPoint && !floor.speaking) floor.settleWaitPoint(false);
return;
}
if (g === 'tap' && r !== 'hold' && preState !== 'ACTION') {
lastTapActedAt = performance.now(); // tap「继续」进防连点窗口
}
if (r !== 'silent-ok') receipts.play('ok');
if (floor.state === 'YOUR_TURN') floor.settleWaitPoint(r !== 'hold');
};
}
export default makeInputPipeline;