Files
SoundGame/web/engine/pipeline.js
T
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

183 lines
8.6 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
* @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;
};
}