按 docs/design/26-audio-interaction-playbook.md 重构三款游戏(对白重写 + 双性别语音重生成 580 文件)。 引擎层(web/engine/,其余 12 款游戏零改动): - floor.js 发言权管理器:SPEAKING/YOUR_TURN/BUSY/ACTION 四态、交接提示音、 等待声(220/330Hz 嘟-嘟,20s 后放缓)、跨链音乐压低(audio.js holdDuck) - receipts.js 输入回执三件套(ok/invalid/busy,100ms 内,原则 4) - confusion.js 迷茫检测(乱点/无效/静默信号 → 三级接管,§4.5) - hints.js 等待点 + 三档提示(full→short→提示音,30s 台账跨点去重,原则 9) - novice.js 手势解锁阶梯(up/down/doubletap 跨局持久,§4.3) - pipeline.js 输入管道(回执收口、防连点 400ms、双击折算、explainOnce) 游戏层: - storyline R3:开场 71.7s 规则念白 → 3 秒内动手;双击=重播(反模式5)废除; 抉择点三档提示+主动重听;断点存档复述(§4.12) - escape R3:单击=检查+使用合并(双击用法废除);长按=我在哪(四向玩法裁定); 撞墙归因每方向一次(原则 7);迷茫 L2 缩小/L3 演示;坐标存档续玩 - simon R3:新手三音色,按实力渐进解锁(+up/down/dt);失败表替代机会账 (归因 2s 内→免费重听→放慢→结算,原则 15);播放期手势=busy 回执 测试:三款 playtest 重写(含 §6.2 乱点机器人:无声操作/被动重复/打断失败全 0, 首操作 ~2s);guide.test 改为 Guide 层单测+rhythm/space 段。全部通过 (38+33+22+72+19+35 断言)。设计单见 docs/design/26-apply-*.md。
62 lines
2.9 KiB
JavaScript
62 lines
2.9 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 —— 清脆的「嗒」
|
||
ok: { wave: 'triangle', from: 1600, to: 1600, dur: 0.035, attack: 0.002, gain: 0.08 },
|
||
// 正弦波 260Hz 滑到 200Hz,120ms,起音 10ms,增益 0.10 —— 低沉柔和的「咚」
|
||
invalid: { wave: 'sine', from: 260, to: 200, dur: 0.12, attack: 0.01, gain: 0.10 },
|
||
// 正弦波 520Hz,40ms,增益 0.04 —— 很轻的「嗯」
|
||
busy: { wave: 'sine', from: 520, to: 520, dur: 0.04, attack: 0.005, gain: 0.04 },
|
||
};
|
||
|
||
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;
|