真人首测反馈「交互还是很迷惑」:大厅 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 回归通过。
63 lines
3.1 KiB
JavaScript
63 lines
3.1 KiB
JavaScript
/**
|
||
* receipts.js — 输入回执三件套(26 号文档 §4.2,P0 第 2 项)。
|
||
*
|
||
* 每次玩家操作 100ms 内出声,告知「这下算没算」。三种回执(合成参数为文档起步值):
|
||
* | kind | 用在哪里 | 听感 |
|
||
* |----------|------------------------|----------------------|
|
||
* | ok | 操作被接受并生效 | 清脆的「嗒」 |
|
||
* | invalid | 收到了,但现在用不上 | 低沉柔和的「咚」 |
|
||
* | busy | 关键句在说 / 正在处理 | 很轻的「嗯」 |
|
||
*
|
||
* 规则(文档 §4.2 / 原则 4):
|
||
* - 回执由引擎统一播,游戏只告诉管道这次操作的结果是哪一种(见 pipeline.js)。
|
||
* - 游戏已为这次操作播了明显内容音(脚步、开门)时可以省 ok,invalid/busy 永远要播。
|
||
* - 音色避开 guide.js 的 turn(1318Hz 风铃)/ nudge(880Hz 木鱼);系统音一律合成音
|
||
* (原则 10),接 master 总线吃限幅、不被 BGM duck 压低(§4.11 优先级 1)。
|
||
* - 任何设备都出声,不依赖震动;震动由 gesture/haptics 照常叠加。
|
||
*/
|
||
|
||
/** 合成参数(26 号文档 §4.2 表:合成参数(起步值))。 */
|
||
const SPECS = {
|
||
// 三角波 1600Hz,35ms,起音 2ms,快速衰减 —— 清脆的「嗒」
|
||
// (增益 0.08→0.11:真人首测反馈笔记本外放偏轻,26 号 §3「参数是起步值,真人测试后再调」)
|
||
ok: { wave: 'triangle', from: 1600, to: 1600, dur: 0.035, attack: 0.002, gain: 0.11 },
|
||
// 正弦波 260Hz 滑到 200Hz,120ms,起音 10ms —— 低沉柔和的「咚」(0.10→0.13)
|
||
invalid: { wave: 'sine', from: 260, to: 200, dur: 0.12, attack: 0.01, gain: 0.13 },
|
||
// 正弦波 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 {
|
||
/** @param {import('./audio.js').WebAudioEngine} audio */
|
||
constructor(audio) {
|
||
this.audio = audio;
|
||
}
|
||
|
||
/**
|
||
* 播一种回执。未解锁 / 无 ctx(playtest mock)时静默跳过。
|
||
* @param {'ok'|'invalid'|'busy'} kind
|
||
*/
|
||
play(kind) {
|
||
const spec = SPECS[kind];
|
||
const ctx = this.audio && this.audio.ctx;
|
||
if (!spec || !ctx || !this.audio._master || !this.audio.unlocked) return;
|
||
const t0 = ctx.currentTime + 0.005;
|
||
const osc = ctx.createOscillator();
|
||
osc.type = spec.wave;
|
||
osc.frequency.setValueAtTime(spec.from, t0);
|
||
if (spec.to !== spec.from) osc.frequency.linearRampToValueAtTime(spec.to, t0 + spec.dur);
|
||
const g = ctx.createGain();
|
||
g.gain.setValueAtTime(0.0001, t0);
|
||
g.gain.linearRampToValueAtTime(spec.gain, t0 + spec.attack);
|
||
// 快速衰减:attack 后线性落到接近 0(听感接近指数衰减)
|
||
g.gain.linearRampToValueAtTime(spec.gain * 0.35, t0 + spec.attack + (spec.dur - spec.attack) * 0.45);
|
||
g.gain.linearRampToValueAtTime(0.0001, t0 + spec.dur);
|
||
osc.connect(g);
|
||
g.connect(this.audio._master);
|
||
osc.start(t0);
|
||
osc.stop(t0 + spec.dur + 0.02);
|
||
}
|
||
}
|
||
|
||
export default Receipts;
|