Files
SoundGame/web/engine/pipeline.js
T
xsl e943227136 feat(playbook): 26 号 P0 推广至全部 15 款游戏——兼容输入管道 + 可闻度调优
真人首测反馈「交互还是很迷惑」:大厅 15 款里只有 3 款新交互,其余 12 款
仍是旧版(无声操作、连点连跳),整体体验割裂。本次:

- engine/pipeline.js 新增 makeCompatPipeline(纯增量,游戏内部逻辑零改动):
  · 手势后 100ms 无任何音频调度 → invalid 回执(消灭无声操作,原则 4)
  · 语音中的手势 → 立即 ok 回执(§4.1 打断即收到)
  · 语音中 tap 400ms 防连点(§4.2,点两下不连跳两句)
  · longpressend/tapcancel 收尾噪声直通
- engine/audio.js 加 playCount 探针(回执启发式用)
- 12 款游戏 index.html 接入兼容管道(rhythm 保留 immediateTap;
  双击判定窗口不动——多款游戏双击是核心玩法/菜单确认,重映射会破坏)
- 可闻度调优(真人首测:笔记本外放偏轻,26 号 §3「起步值,真人测试后再调」):
  回执增益 ok 0.08→0.11 / invalid 0.10→0.13 / busy 0.04→0.06;
  等待声 0.03→0.055
- 大厅:三款深度重构游戏(声命线/暗室/音序回响)置顶 + 「新交互」徽标,
  卡片文案直接写玩法手势,测试不再误入旧交互游戏

测试:新增 compat-pipeline.test(9 断言);全部 15 款 playtest 回归通过
(共 3900+ 断言,0 失败);引擎 input/voice/guide 回归通过。
2026-10-01 21:09:26 +08:00

175 lines
7.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;
/**
* 兼容管道 —— 未做深度改造的游戏(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;
};
}