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 回归通过。
This commit is contained in:
xsl
2026-10-01 21:09:26 +08:00
parent ee9d7efe57
commit e943227136
18 changed files with 322 additions and 37 deletions
+5 -1
View File
@@ -16,6 +16,8 @@
<script type="module"> <script type="module">
import { WebAudioEngine } from '../engine/audio.js'; import { WebAudioEngine } from '../engine/audio.js';
import { attachGestures } from '../engine/gesture.js'; import { attachGestures } from '../engine/gesture.js';
import { Receipts } from '../engine/receipts.js';
import { makeCompatPipeline } from '../engine/pipeline.js';
import { AbyssGame } from './game.js'; import { AbyssGame } from './game.js';
async function main() { async function main() {
@@ -28,7 +30,9 @@
await audio.showStartOverlay({ cover: 'cover.png' }); await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了 // 走到这里 AudioContext 已解锁,可以出声了
game.start(); game.start();
attachGestures(root, (g) => game.onGesture(g)); // 26 号 P0:兼容输入管道(无声操作回执 + 防连点;游戏手势语义不变)
const pipe = makeCompatPipeline({ audio, receipts: new Receipts(audio), game });
attachGestures(root, pipe);
} }
main(); main();
+5 -1
View File
@@ -14,6 +14,8 @@
<script type="module"> <script type="module">
import { WebAudioEngine } from '../engine/audio.js'; import { WebAudioEngine } from '../engine/audio.js';
import { attachGestures } from '../engine/gesture.js'; import { attachGestures } from '../engine/gesture.js';
import { Receipts } from '../engine/receipts.js';
import { makeCompatPipeline } from '../engine/pipeline.js';
import { BridgeGame } from './game.js'; import { BridgeGame } from './game.js';
async function main() { async function main() {
@@ -23,7 +25,9 @@
await audio.showStartOverlay({ cover: 'cover.png' }); await audio.showStartOverlay({ cover: 'cover.png' });
game.start(); game.start();
attachGestures(root, (g) => game.onGesture(g)); // 26 号 P0:兼容输入管道(无声操作回执 + 防连点;游戏手势语义不变)
const pipe = makeCompatPipeline({ audio, receipts: new Receipts(audio), game });
attachGestures(root, pipe);
} }
main(); main();
+5 -1
View File
@@ -16,6 +16,8 @@
<script type="module"> <script type="module">
import { WebAudioEngine } from '../engine/audio.js'; import { WebAudioEngine } from '../engine/audio.js';
import { attachGestures } from '../engine/gesture.js'; import { attachGestures } from '../engine/gesture.js';
import { Receipts } from '../engine/receipts.js';
import { makeCompatPipeline } from '../engine/pipeline.js';
import { CipherGame } from './game.js'; import { CipherGame } from './game.js';
async function main() { async function main() {
@@ -26,7 +28,9 @@
await audio.showStartOverlay({ cover: 'cover.png' }); await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了 // 走到这里 AudioContext 已解锁,可以出声了
game.start(); game.start();
attachGestures(root, (g) => game.onGesture(g)); // 26 号 P0:兼容输入管道(无声操作回执 + 防连点;游戏手势语义不变)
const pipe = makeCompatPipeline({ audio, receipts: new Receipts(audio), game });
attachGestures(root, pipe);
} }
main(); main();
+5 -1
View File
@@ -18,6 +18,8 @@
<script type="module"> <script type="module">
import { WebAudioEngine } from '../engine/audio.js'; import { WebAudioEngine } from '../engine/audio.js';
import { attachGestures } from '../engine/gesture.js'; import { attachGestures } from '../engine/gesture.js';
import { Receipts } from '../engine/receipts.js';
import { makeCompatPipeline } from '../engine/pipeline.js';
import { DetectiveGame } from './game.js'; import { DetectiveGame } from './game.js';
async function main() { async function main() {
@@ -27,7 +29,9 @@
await audio.showStartOverlay({ cover: 'cover.png' }); await audio.showStartOverlay({ cover: 'cover.png' });
game.start(); game.start();
attachGestures(root, (g) => game.onGesture(g)); // 26 号 P0:兼容输入管道(无声操作回执 + 防连点;游戏手势语义不变)
const pipe = makeCompatPipeline({ audio, receipts: new Receipts(audio), game });
attachGestures(root, pipe);
} }
main(); main();
+5 -1
View File
@@ -17,6 +17,8 @@
<script type="module"> <script type="module">
import { WebAudioEngine } from '../engine/audio.js'; import { WebAudioEngine } from '../engine/audio.js';
import { attachGestures } from '../engine/gesture.js'; import { attachGestures } from '../engine/gesture.js';
import { Receipts } from '../engine/receipts.js';
import { makeCompatPipeline } from '../engine/pipeline.js';
import { DungeonGame } from './game.js'; import { DungeonGame } from './game.js';
async function main() { async function main() {
@@ -26,7 +28,9 @@
await audio.showStartOverlay({ cover: 'cover.png' }); await audio.showStartOverlay({ cover: 'cover.png' });
game.start(); game.start();
attachGestures(root, (g) => game.onGesture(g)); // 26 号 P0:兼容输入管道(无声操作回执 + 防连点;游戏手势语义不变)
const pipe = makeCompatPipeline({ audio, receipts: new Receipts(audio), game });
attachGestures(root, pipe);
} }
main(); main();
+2
View File
@@ -113,6 +113,7 @@ export class WebAudioEngine {
this._rateBoostEnabled = true; // 长按快进开关(setRateBoostEnabled per-game 控制) this._rateBoostEnabled = true; // 长按快进开关(setRateBoostEnabled per-game 控制)
this._voiceFuseLog = []; // [{ id, t }] 保险丝调度流水(play 入口记录) this._voiceFuseLog = []; // [{ id, t }] 保险丝调度流水(play 入口记录)
this._voiceFuseErrored = new Set(); // 已报过 console.error 的 id(每 id 一次) this._voiceFuseErrored = new Set(); // 已报过 console.error 的 id(每 id 一次)
this.playCount = 0; // play() 累计调用数(输入管道回执启发式探针,26 号 §4.2)
// 设置变更实时生效 // 设置变更实时生效
settings.subscribe((_, changed) => this._onSettingsChange(changed)); settings.subscribe((_, changed) => this._onSettingsChange(changed));
@@ -239,6 +240,7 @@ export class WebAudioEngine {
play(id, opts = {}) { play(id, opts = {}) {
const slash = id.indexOf('/'); const slash = id.indexOf('/');
const layer = slash > 0 ? id.slice(0, slash) : ''; const layer = slash > 0 ? id.slice(0, slash) : '';
this.playCount++; // 管道回执探针:本次手势是否带来任何音频(含资产缺失的尝试调度)
if (!this.unlocked) { if (!this.unlocked) {
console.warn('[WebAudioEngine] 音频尚未解锁(需先 showStartOverlay/unlock),跳过:', id); console.warn('[WebAudioEngine] 音频尚未解锁(需先 showStartOverlay/unlock),跳过:', id);
return; return;
+2 -1
View File
@@ -31,7 +31,8 @@ const BED = {
f1: 220, f2: 330, // 两声「嘟—嘟」 f1: 220, f2: 330, // 两声「嘟—嘟」
toneMs: 60, gapMs: 180, // 每声 60ms,两声间隔 180ms toneMs: 60, gapMs: 180, // 每声 60ms,两声间隔 180ms
loopMs: 1400, slowMs: 2500,// 默认每 1.4s 重复;20s 后 2.5s loopMs: 1400, slowMs: 2500,// 默认每 1.4s 重复;20s 后 2.5s
gain: 0.03, // ≈ guide 提示音增益 0.12 的四分之一 gain: 0.055, // ≈ guide 提示音增益 0.12 的一半(0.03 笔记本外放偏轻,
// 真人首测调高;手机外放仍远轻于语音)
slowAfterMs: 20000, slowAfterMs: 20000,
fadeOutMs: 80, // 玩家一操作,等待声 80ms 内淡出 fadeOutMs: 80, // 玩家一操作,等待声 80ms 内淡出
}; };
+50
View File
@@ -122,3 +122,53 @@ export function makeInputPipeline({ floor, confusion, receipts, invalidHints, ga
} }
export default makeInputPipeline; 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;
};
}
+7 -6
View File
@@ -18,12 +18,13 @@
/** 合成参数(26 号文档 §4.2 表:合成参数(起步值))。 */ /** 合成参数(26 号文档 §4.2 表:合成参数(起步值))。 */
const SPECS = { const SPECS = {
// 三角波 1600Hz,35ms,起音 2ms,快速衰减,增益 0.08 —— 清脆的「嗒」 // 三角波 1600Hz,35ms,起音 2ms,快速衰减 —— 清脆的「嗒」
ok: { wave: 'triangle', from: 1600, to: 1600, dur: 0.035, attack: 0.002, gain: 0.08 }, // (增益 0.08→0.11:真人首测反馈笔记本外放偏轻,26 号 §3「参数是起步值,真人测试后再调」)
// 正弦波 260Hz 滑到 200Hz,120ms,起音 10ms,增益 0.10 —— 低沉柔和的「咚」 ok: { wave: 'triangle', from: 1600, to: 1600, dur: 0.035, attack: 0.002, gain: 0.11 },
invalid: { wave: 'sine', from: 260, to: 200, dur: 0.12, attack: 0.01, gain: 0.10 }, // 正弦波 260Hz 滑到 200Hz,120ms,起音 10ms —— 低沉柔和的「咚」(0.10→0.13)
// 正弦波 520Hz,40ms,增益 0.04 —— 很轻的「嗯」 invalid: { wave: 'sine', from: 260, to: 200, dur: 0.12, attack: 0.01, gain: 0.13 },
busy: { wave: 'sine', from: 520, to: 520, dur: 0.04, attack: 0.005, gain: 0.04 }, // 正弦波 520Hz,40ms —— 很轻的「嗯」(0.04→0.06)
busy: { wave: 'sine', from: 520, to: 520, dur: 0.04, attack: 0.005, gain: 0.06 },
}; };
export class Receipts { export class Receipts {
+183
View File
@@ -0,0 +1,183 @@
/**
* compat-pipeline.test.mjs — 兼容输入管道单测(26 号 §4.2 / §5 P0 推广层)。
*
* 覆盖:
* - 手势后游戏有内容音 → 不叠回执(§4.2「已有明显内容音可省 ok」);
* - 手势后 100ms 内无任何音频调度 → invalid 回执(消灭无声操作,原则 4);
* - 语音播放中的手势 → 立即 ok 回执(§4.1 打断即收到);
* - 语音中 tap 的 400ms 防连点(§4.2);
* - longpressend / tapcancel 收尾噪声直通,不吃回执;
* - audio.playCount 探针计数。
*
* 运行:tools/node/bin/node web/harness/compat-pipeline.test.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
const GAME_DIR = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..', 'simon');
const realLog = console.log.bind(console);
let passCount = 0;
const failures = [];
function assert(cond, msg) {
if (cond) { passCount++; realLog(` ✓ ${msg}`); }
else { failures.push(msg); realLog(` ✗ ${msg}`); }
}
console.error = () => {};
console.warn = () => {};
console.log = () => {};
let now = 0;
let timerSeq = 1;
const timers = new Map();
const realSetImmediate = setImmediate;
globalThis.setTimeout = (cb, ms = 0, ...args) => {
const id = timerSeq++;
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb, args });
return id;
};
globalThis.clearTimeout = (id) => { timers.delete(id); };
Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true });
async function flush(rounds = 6) {
for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r));
}
async function advance(cond = null) {
for (let n = 0; n < 10000; n++) {
await flush(2);
if (cond && cond()) return 'cond';
let nextId = -1; let nextAt = Infinity;
for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; }
if (nextId < 0) return 'idle';
now = nextAt;
const t = timers.get(nextId);
timers.delete(nextId);
t.cb(...t.args);
}
throw new Error('虚拟时钟超上限');
}
globalThis.window = { addEventListener: () => {}, AudioContext: null };
const storageData = new Map();
globalThis.localStorage = {
getItem: (k) => (storageData.has(k) ? storageData.get(k) : null),
setItem: (k, v) => { storageData.set(k, String(v)); },
removeItem: (k) => { storageData.delete(k); },
};
Object.defineProperty(globalThis, 'navigator', {
value: { maxTouchPoints: 1, vibrate: () => true }, configurable: true,
});
class MockParam {
constructor(v = 1) { this.value = v; }
setValueAtTime() {} linearRampToValueAtTime() {} cancelScheduledValues() {}
}
class MockGainNode {
constructor() { this.gain = new MockParam(); }
connect() {} disconnect() {}
}
class MockAudioContext {
constructor() { this.state = 'running'; this.currentTime = 0; this.destination = {}; }
createGain() { return new MockGainNode(); }
createDynamicsCompressor() {
const n = new MockGainNode();
n.threshold = new MockParam(); n.ratio = new MockParam();
n.attack = new MockParam(); n.release = new MockParam();
return n;
}
createBufferSource() { return { buffer: null, loop: false, connect() {}, start() {}, stop() {} }; }
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
createOscillator() { return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} }; }
decodeAudioData() { return Promise.resolve({ duration: 2.0, numberOfChannels: 1, sampleRate: 44100, getChannelData: () => new Float32Array(16) }); }
resume() { this.state = 'running'; return Promise.resolve(); }
}
globalThis.window.AudioContext = MockAudioContext;
globalThis.fetch = async (u) => {
const p = path.join(GAME_DIR, decodeURIComponent(String(u)));
if (!fs.existsSync(p)) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
const data = fs.readFileSync(p);
return { ok: true, status: 200, arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) };
};
const { WebAudioEngine } = await import('../engine/audio.js');
const { settings } = await import('../engine/settings.js');
const { makeCompatPipeline } = await import('../engine/pipeline.js');
const { Receipts } = await import('../engine/receipts.js');
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
const audio = new WebAudioEngine('audio/');
await audio.unlock();
const receipts = new Receipts(audio);
const receiptLog = [];
const origPlay = receipts.play.bind(receipts);
receipts.play = (kind) => { receiptLog.push({ kind, t: now }); return origPlay(kind); };
// 假游戏:按手势决定是否有内容音
const fakeGame = {
lastGesture: null,
loudGestures: new Set(), // 这些手势会立即播 sfx
onGesture(g) {
this.lastGesture = g;
if (this.loudGestures.has(g)) audio.play('sfx/tone_tap');
return undefined; // 旧游戏通常无返回值
},
};
const pipe = makeCompatPipeline({ audio, receipts, game: fakeGame });
realLog('== 兼容管道(P0 推广层)==');
{
// 1. 内容音即反馈:不叠回执
fakeGame.loudGestures.add('left');
const n0 = receiptLog.length;
pipe('left'); await flush();
await advance(() => now >= 150);
assert(receiptLog.length === n0, '手势带内容音 → 不叠回执(内容音即反馈)');
// 2. 无声操作 → 100ms 时给 invalid
const n1 = receiptLog.length;
pipe('right'); await flush();
assert(receiptLog.length === n1, '手势后 100ms 内不抢先出声(等结果)');
await advance(() => receiptLog.length > n1);
const r = receiptLog[receiptLog.length - 1];
assert(r.kind === 'invalid' && r.t <= 100 + 500, `无声手势 → invalid 回执(实际 ${r && r.kind}@${r && r.t}ms)`);
// 3. playCount 探针
const c0 = audio.playCount;
audio.play('sfx/tone_tap');
assert(audio.playCount === c0 + 1, 'audio.playCount 每次 play 递增(回执启发式探针)');
// 4. 语音中手势 → 立即 ok
audio.play('voice/t0_new'); await flush();
assert(audio.isVoicePlaying(), '假游戏对白在播');
const n2 = receiptLog.length;
pipe('up'); await flush();
assert(receiptLog.length === n2 + 1 && receiptLog[receiptLog.length - 1].kind === 'ok',
'语音中的手势 → 立即 ok 回执(打断即收到)');
await advance(() => !audio.isVoicePlaying());
// 5. 语音中 tap 防连点:第二下只给 busy
audio.play('voice/t0_new'); await flush();
const n3 = receiptLog.length;
pipe('tap'); await flush();
assert(receiptLog[n3] && receiptLog[n3].kind === 'ok', '语音中 tap → ok');
pipe('tap'); await flush(); // 虚拟时钟未推进 = 400ms 内
assert(receiptLog[n3 + 1] && receiptLog[n3 + 1].kind === 'busy', '400ms 内第二下 tap → busy(不连跳)');
now += 500; // 跨过防连点窗口
await advance(() => !audio.isVoicePlaying());
// 6. 收尾噪声直通
const n4 = receiptLog.length;
let passed = false;
fakeGame.loudGestures.delete('left');
fakeGame.onGesture = (g) => { if (g === 'longpressend') passed = true; };
pipe('longpressend'); await flush();
await advance(() => now >= 150);
assert(passed && receiptLog.length === n4, 'longpressend 直通游戏、不吃回执');
}
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
if (failures.length > 0) {
realLog('失败清单:');
for (const f of failures) realLog(` - ${f}`);
process.exit(1);
}
realLog('全部断言通过。');
+5 -1
View File
@@ -24,6 +24,8 @@
<script type="module"> <script type="module">
import { WebAudioEngine } from '../engine/audio.js'; import { WebAudioEngine } from '../engine/audio.js';
import { attachGestures } from '../engine/gesture.js'; import { attachGestures } from '../engine/gesture.js';
import { Receipts } from '../engine/receipts.js';
import { makeCompatPipeline } from '../engine/pipeline.js';
import { HuntGame } from './game.js'; import { HuntGame } from './game.js';
async function main() { async function main() {
@@ -34,7 +36,9 @@
await audio.showStartOverlay({ cover: 'cover.png' }); await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了 // 走到这里 AudioContext 已解锁,可以出声了
game.start(); game.start();
attachGestures(root, (g) => game.onGesture(g)); // 26 号 P0:兼容输入管道(无声操作回执 + 防连点;游戏手势语义不变)
const pipe = makeCompatPipeline({ audio, receipts: new Receipts(audio), game });
attachGestures(root, pipe);
} }
main(); main();
+18 -18
View File
@@ -271,6 +271,24 @@
</section> </section>
<main class="grid"> <main class="grid">
<a class="card" href="storyline/" data-game="storyline">
<h2>声命线 <span class="badge-new">新交互</span></h2>
<span class="slug">STORYLINE</span>
<p>末世互动广播剧。单击继续,左右滑做选择,向着妹妹的方向活下去。</p>
<p class="progress"></p>
</a>
<a class="card" href="escape/" data-game="escape">
<h2>暗室 <span class="badge-new">新交互</span></h2>
<span class="slug">ESCAPE</span>
<p>密室逃脱。四向滑动移动,单击检查,按住屏幕问路。</p>
<p class="progress"></p>
</a>
<a class="card" href="simon/" data-game="simon">
<h2>音序回响 <span class="badge-new">新交互</span></h2>
<span class="slug">SIMON</span>
<p>音频记忆游戏。听一串音色,原样复现;新音色随实力解锁。</p>
<p class="progress"></p>
</a>
<a class="card" href="hunt/" data-game="hunt"> <a class="card" href="hunt/" data-game="hunt">
<h2>围猎 <span class="badge-new">新作</span></h2> <h2>围猎 <span class="badge-new">新作</span></h2>
<span class="slug">HUNT · NIGHT HUNT</span> <span class="slug">HUNT · NIGHT HUNT</span>
@@ -331,24 +349,6 @@
<p>盲剑客武侠 RPG。眼盲,耳即眼——听声辨招,双击格挡,踏平黑风寨。</p> <p>盲剑客武侠 RPG。眼盲,耳即眼——听声辨招,双击格挡,踏平黑风寨。</p>
<p class="progress"></p> <p class="progress"></p>
</a> </a>
<a class="card" href="storyline/" data-game="storyline">
<h2>声命线</h2>
<span class="slug">STORYLINE</span>
<p>末世互动广播剧。在声音里做选择,决定故事的走向。</p>
<p class="progress"></p>
</a>
<a class="card" href="escape/" data-game="escape">
<h2>暗室</h2>
<span class="slug">ESCAPE</span>
<p>密室逃脱。一片漆黑中,只靠声音摸索出路。</p>
<p class="progress"></p>
</a>
<a class="card" href="simon/" data-game="simon">
<h2>音序回响</h2>
<span class="slug">SIMON</span>
<p>音频记忆游戏。听一段音序,用手势原样复现。</p>
<p class="progress"></p>
</a>
<a class="card" href="rhythm/" data-game="rhythm"> <a class="card" href="rhythm/" data-game="rhythm">
<h2>节拍行者</h2> <h2>节拍行者</h2>
<span class="slug">RHYTHM</span> <span class="slug">RHYTHM</span>
+5 -1
View File
@@ -16,6 +16,8 @@
<script type="module"> <script type="module">
import { WebAudioEngine } from '../engine/audio.js'; import { WebAudioEngine } from '../engine/audio.js';
import { attachGestures } from '../engine/gesture.js'; import { attachGestures } from '../engine/gesture.js';
import { Receipts } from '../engine/receipts.js';
import { makeCompatPipeline } from '../engine/pipeline.js';
import { KeeperGame } from './game.js'; import { KeeperGame } from './game.js';
async function main() { async function main() {
@@ -26,7 +28,9 @@
await audio.showStartOverlay({ cover: 'cover.png' }); await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了 // 走到这里 AudioContext 已解锁,可以出声了
game.start(); game.start();
attachGestures(root, (g) => game.onGesture(g)); // 26 号 P0:兼容输入管道(无声操作回执 + 防连点;游戏手势语义不变)
const pipe = makeCompatPipeline({ audio, receipts: new Receipts(audio), game });
attachGestures(root, pipe);
} }
main(); main();
+5 -1
View File
@@ -16,6 +16,8 @@
<script type="module"> <script type="module">
import { WebAudioEngine } from '../engine/audio.js'; import { WebAudioEngine } from '../engine/audio.js';
import { attachGestures } from '../engine/gesture.js'; import { attachGestures } from '../engine/gesture.js';
import { Receipts } from '../engine/receipts.js';
import { makeCompatPipeline } from '../engine/pipeline.js';
import { RadioGame } from './game.js'; import { RadioGame } from './game.js';
async function main() { async function main() {
@@ -26,7 +28,9 @@
await audio.showStartOverlay({ cover: 'cover.png' }); await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了 // 走到这里 AudioContext 已解锁,可以出声了
game.start(); game.start();
attachGestures(root, (g) => game.onGesture(g)); // 26 号 P0:兼容输入管道(无声操作回执 + 防连点;游戏手势语义不变)
const pipe = makeCompatPipeline({ audio, receipts: new Receipts(audio), game });
attachGestures(root, pipe);
} }
main(); main();
+5 -1
View File
@@ -17,6 +17,8 @@
<script type="module"> <script type="module">
import { WebAudioEngine } from '../engine/audio.js'; import { WebAudioEngine } from '../engine/audio.js';
import { attachGestures } from '../engine/gesture.js'; import { attachGestures } from '../engine/gesture.js';
import { Receipts } from '../engine/receipts.js';
import { makeCompatPipeline } from '../engine/pipeline.js';
import { RhythmGame } from './game.js'; import { RhythmGame } from './game.js';
async function main() { async function main() {
@@ -31,7 +33,9 @@
// 双击窗关闭;坐标经引擎第二参 info 传入(24 号鼓手模式坐标透传, // 双击窗关闭;坐标经引擎第二参 info 传入(24 号鼓手模式坐标透传,
// docs/web-engine-api.md §gesture)。其他游戏勿开 immediateTap—— // docs/web-engine-api.md §gesture)。其他游戏勿开 immediateTap——
// 每次按下都会先发 tap。 // 每次按下都会先发 tap。
attachGestures(root, (g, info) => game.onGesture(g, info), { immediateTap: true }); // 26 号 P0:兼容输入管道(无声操作回执 + 防连点;游戏手势语义不变)
const pipe = makeCompatPipeline({ audio, receipts: new Receipts(audio), game });
attachGestures(root, pipe, { immediateTap: true });
// 切后台 AudioContext 会被系统挂起,回前台时恢复(节拍时钟基于 // 切后台 AudioContext 会被系统挂起,回前台时恢复(节拍时钟基于
// ctx.currentTime,挂起期间时钟也停,回前台后排程自然续上) // ctx.currentTime,挂起期间时钟也停,回前台后排程自然续上)
+5 -1
View File
@@ -16,6 +16,8 @@
<script type="module"> <script type="module">
import { WebAudioEngine } from '../engine/audio.js'; import { WebAudioEngine } from '../engine/audio.js';
import { attachGestures } from '../engine/gesture.js'; import { attachGestures } from '../engine/gesture.js';
import { Receipts } from '../engine/receipts.js';
import { makeCompatPipeline } from '../engine/pipeline.js';
import { RpgGame } from './game.js'; import { RpgGame } from './game.js';
async function main() { async function main() {
@@ -26,7 +28,9 @@
await audio.showStartOverlay({ cover: 'cover.png' }); await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了 // 走到这里 AudioContext 已解锁,可以出声了
game.start(); game.start();
attachGestures(root, (g) => game.onGesture(g)); // 26 号 P0:兼容输入管道(无声操作回执 + 防连点;游戏手势语义不变)
const pipe = makeCompatPipeline({ audio, receipts: new Receipts(audio), game });
attachGestures(root, pipe);
} }
main(); main();
+5 -1
View File
@@ -17,6 +17,8 @@
<script type="module"> <script type="module">
import { WebAudioEngine } from '../engine/audio.js'; import { WebAudioEngine } from '../engine/audio.js';
import { attachGestures } from '../engine/gesture.js'; import { attachGestures } from '../engine/gesture.js';
import { Receipts } from '../engine/receipts.js';
import { makeCompatPipeline } from '../engine/pipeline.js';
import { SpaceGame } from './game.js'; import { SpaceGame } from './game.js';
async function main() { async function main() {
@@ -27,7 +29,9 @@
await audio.showStartOverlay({ cover: 'cover.png' }); await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了 // 走到这里 AudioContext 已解锁,可以出声了
game.start(); game.start();
attachGestures(root, (g) => game.onGesture(g)); // 26 号 P0:兼容输入管道(无声操作回执 + 防连点;游戏手势语义不变)
const pipe = makeCompatPipeline({ audio, receipts: new Receipts(audio), game });
attachGestures(root, pipe);
} }
main(); main();
+5 -1
View File
@@ -16,6 +16,8 @@
<script type="module"> <script type="module">
import { WebAudioEngine } from '../engine/audio.js'; import { WebAudioEngine } from '../engine/audio.js';
import { attachGestures } from '../engine/gesture.js'; import { attachGestures } from '../engine/gesture.js';
import { Receipts } from '../engine/receipts.js';
import { makeCompatPipeline } from '../engine/pipeline.js';
import { TeahouseGame } from './game.js'; import { TeahouseGame } from './game.js';
async function main() { async function main() {
@@ -26,7 +28,9 @@
await audio.showStartOverlay({ cover: 'cover.png' }); await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了 // 走到这里 AudioContext 已解锁,可以出声了
game.start(); game.start();
attachGestures(root, (g) => game.onGesture(g)); // 26 号 P0:兼容输入管道(无声操作回执 + 防连点;游戏手势语义不变)
const pipe = makeCompatPipeline({ audio, receipts: new Receipts(audio), game });
attachGestures(root, pipe);
} }
main(); main();