/** * 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;