/** * 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 * @param {boolean} [deps.opts.doubletapCore=false] 双击是本游戏核心玩法输入(如护盾/ * 旋风斩,§4.3 阶段 4「游戏专属高级手势」):doubletap 不折算成 tap,直接 * 放行给游戏(此类游戏 attachGestures 保留双击判定窗口) */ 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; const doubletapCore = (opts && opts.doubletapCore) === true; let lastTapActedAt = -Infinity; return (g, info) => { if (g === 'longpressend' || g === 'tapcancel') return; // 手势收尾/撤销噪声,不吃回执 // ---- 1. 迷茫检测(§4.5):所有真实手势先喂信号(含被锁手势——乱点就是乱点; // 2026-10 修复:此前锁定门在 record 之前 return,锁定手势的乱点不触发接管)---- if (REAL_GESTURES.has(g)) confusion.record(g); // ---- 2. 新手手势门(§4.3)---- if (g === 'doubletap' && !doubletapCore && !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); confusion.recordInvalid(); return; } if (!swipeCore && g === 'down' && !lad.has('down')) { receipts.play('invalid'); invalidHints.explainOnce('locked:down', game.invalidHint ? game.invalidHint('locked', 'down') : null); confusion.recordInvalid(); return; } if (g === 'longpress' && !longpressCore) { receipts.play('invalid'); invalidHints.explainOnce('locked:longpress', game.invalidHint ? game.invalidHint('locked', 'longpress') : null); confusion.recordInvalid(); return; } // ---- 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; /** * 兼容管道 —— 未做深度改造的游戏(P0 层,26 号 §5「先把通用部分抽进引擎,推广到 * 其他游戏」)。纯增量:游戏内部逻辑、手势语义、双击判定窗口一概不动,只在外围 * 补三件事: * * 1. 消灭无声操作(原则 4):手势后 100ms 内游戏没有调度任何音频 → invalid 回执 * (旧版「点了没反应」是真人测试的头号迷惑源)。 * 2. 语音中的手势 → 立即 ok 回执(§4.1 打断即收到);tap 额外进 400ms 防连点窗口 * (点两下不连跳两句,§4.2)。 * 3. longpressend / tapcancel 收尾噪声直通,不吃回执。 * * 游戏自带的内容音(脚步、音色、语音)即反馈,不叠回执(§4.2「已有明显内容音可 * 省 ok」)。深度改造(发言权/等待点/接管)仍按 26-apply-* 设计单逐款推进。 * * @param {{ audio: import('./audio.js').WebAudioEngine, * receipts: import('./receipts.js').Receipts, * game: { onGesture: Function } }} deps */ export function makeCompatPipeline({ audio, receipts, game }) { let lastSkipAt = -Infinity; return (g, info) => { if (g === 'longpressend' || g === 'tapcancel') return game.onGesture(g, info); const voicePlaying = audio.isVoicePlaying(); // 防连点:语音中 tap=跳过/继续(各游戏既有语义),400ms 内的第二下只给 busy if (g === 'tap' && voicePlaying && performance.now() - lastSkipAt < 400) { receipts.play('busy'); return; } const before = audio.playCount; const r = game.onGesture(g, info); if (voicePlaying) { // 语音中的任何手势 = 打断/跳过语义:立即 ok(§4.1 打断即收到) receipts.play('ok'); if (g === 'tap') lastSkipAt = performance.now(); return r; } // 100ms 内无任何音频调度 → invalid(消灭无声操作) setTimeout(() => { if (audio.playCount === before) receipts.play('invalid'); }, 100); return r; }; }