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 置完成。
183 lines
8.6 KiB
JavaScript
183 lines
8.6 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
|
||
* @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;
|
||
};
|
||
}
|