真人首测反馈「交互还是很迷惑」:大厅 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 回归通过。
175 lines
7.9 KiB
JavaScript
175 lines
7.9 KiB
JavaScript
/**
|
||
* 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;
|
||
};
|
||
}
|