Files
SoundGame/web/engine/pipeline.js
T
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

125 lines
5.9 KiB
JavaScript
Raw 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.
/**
* 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;