特效系统
客户端音效系统
特效系统 给控件、对象和页面加上可编程的前端音效 `客户端音效系统` 负责前端声音表现,例如点击音、命中音、hover 音、鼓点循环、短旋律和程序化背景音乐。它只负责“听起来发生了什么”,不负责改属性和推进剧情。 # 最常见能做什么 点击音 命中音 确认音 / 失败音 hover 音 / 长按音 鼓点循环 “
特效系统
给控件、对象和页面加上可编程的前端音效
`客户端音效系统` 负责前端声音表现,例如点击音、命中音、hover 音、鼓点循环、短旋律和程序化背景音乐。它只负责“听起来发生了什么”,不负责改属性和推进剧情。
# 最常见能做什么
- 点击音
- 命中音
- 确认音 / 失败音
- hover 音 / 长按音
- 鼓点循环
- “输入一组音符,然后按规则循环播放”的程序化音乐
- 稳定
label的背景音乐
# 1. 最常用的 3 个方法
| 方法 | 用途 |
|---|---|
tools.runClientAudio(script, options?) |
启动一段 Tone.js 前端音频脚本 |
tools.disposeClientAudio(label) |
按 label 结束一个仍在运行中的音频会话 |
tools.clearClientAudio() |
清空当前页面全部前端音频会话 |
# 2. 最重要的理解:有外层脚本和内层脚本
你平时在编辑器里写的,是外层脚本:
tools.runClientAudio("...");
真正负责发声的,是传进去的那段字符串脚本:
tools.runClientAudio(`
await audio.resume();
const synth = audio.createSynth();
synth.triggerAttackRelease('C5', '8n');
`);
这两层不是一回事。
# 外层脚本里常用什么
playerobjectstools
# 内层音频脚本里常用什么
Toneaudiopayloadruntimesessionpointer
内层音频脚本只能使用这些注入变量;运行时会遮蔽 window、document、localStorage、fetch、postMessage 等浏览器能力,但不额外拦截普通 JavaScript 写法。需要运行时数据时,先在外层脚本算好,再通过 payload 传入。
# 3. 最简单的启动方式
tools.runClientAudio(`
await audio.resume();
const synth = audio.createSynth({
oscillator: { type: "triangle" },
envelope: { attack: 0.005, decay: 0.08, sustain: 0.02, release: 0.12 }
});
synth.setVolume(-10);
synth.triggerAttackRelease("C5", "16n");
`, {
label: "ui_click_confirm"
});
# 4. 生命周期怎么理解
每次 tools.runClientAudio(...) 都会创建一个独立音频会话。
- 一次性短音效在尾音播放结束后,通常会自动销毁
- 循环、长音、背景音乐这类长期声音,要在内层脚本里
await audio.keepAlive() - 如果新的音频会话复用了同一个
label,旧会话会先被销毁,再启动新的 - 切换场景不会自动清理音频;如果要停,自己调用
tools.disposeClientAudio(label)或tools.clearClientAudio()
一个很实用的习惯是:
- 短提示音:可以只播完就结束
- hover 音、循环节奏、背景音乐:写稳定
label
# 5. options 里最常用的字段
| 字段 | 用途 |
|---|---|
payload |
传音高、和弦、速度、数组、模式等数据给音频脚本 |
label |
给这次音频会话起一个稳定名字,方便替换和清理 |
# label 为什么重要
如果没有稳定的 label:
- 很难准确结束某段循环或背景音乐
- hover / move 这类高频音效容易叠在一起
- 重复播放的会话不容易替换
在交互脚本 js_enter / js_leave / js_move / js_long 里,如果你没传 label,运行时通常会按当前控件自动生成一个默认音频 label。
但只要你明确知道这段声音要长期存在、要可控停止,最好还是自己传。
# 6. 内层音频脚本里最常用什么
# 基础控制
await audio.resume();
audio.now();
audio.toSeconds("8n");
await audio.wait(200);
await audio.keepAlive();
audio.dispose(handle);
audio.cleanup("stop");
audio.log("debug");
# 乐器
const synth = audio.createSynth(options?)
const poly = audio.createPolySynth(options?)
const noise = audio.createNoiseSynth(options?)
const membrane = audio.createMembraneSynth(options?)
推荐这样理解:
createSynth():单音旋律、提示音、电子音createPolySynth():和弦、多音同时触发createNoiseSynth():擦过、喷射、噪点、沙锤感createMembraneSynth():kick、tom、低频咚的一下
# 调度器
const loop = audio.createLoop("8n", (time, step) => {})
const sequence = audio.createSequence(["C4", "E4", "G4"], "8n", (time, note, index) => {})
const pattern = audio.createPattern(["C4", "E4", "G4"], "upDown", (time, note, index) => {})
const once = audio.scheduleOnce("+0.25", (time) => {})
推荐这样理解:
createLoop():固定间隔循环createSequence():按数组顺序轮播createPattern():按模式遍历音列,适合琶音、上下行、来回扫描scheduleOnce():安排一个尾音或补拍
# Tone 现在也能直接用
音频脚本里现在会直接注入 Tone,所以像这样是可以的:
const midi = Tone.Frequency("C3").toMidi();
const hertz = Tone.Frequency(38, "midi").toFrequency();
更推荐把它用在这些“音乐工具逻辑”上:
- 音高和 MIDI 互转
- 按规则移调
- 生成和弦音、低八度音、上方五度音
- 做少量时值换算
例如:
const root = payload.root || "C4";
const fifth = Tone.Frequency(root).transpose(7).toNote();
const bass = Tone.Frequency(root).transpose(-12).toNote();
但要注意:
- 真正需要被宿主自动清理的乐器、循环、模式调度,还是优先走
audio.createSynth()、audio.createPolySynth()、audio.createLoop()、audio.createSequence()、audio.createPattern() - 不要优先用裸
new Tone.Synth()、new Tone.Pattern()来替代宿主包装,否则资源不一定会被当前音频会话追踪到
# 7. 音符必须写完整音高
Tone.js 里的音符,不要只写字母。
错误写法:
"E""G"["C", "E", "G"]
正确写法:
"E4""G4"["C4", "E4", "G4"]"F#4""Bb3"
一定要记住:
- 音高要写成“音名 + 升降号可选 + 八度”
- 如果音符来自
payload.notes、payload.chord、payload.scale,数组里的值也必须是完整音高 - 只写裸字母时,底层很容易出现
Invalid argument(s) to setValueAtTime
# 8. 同一个单音乐器,开始时间必须严格递增
如果看到这类报错:
Start time must be strictly greater than previous start time
通常不是系统坏了,而是脚本把同一个单音乐器安排到了相同时间。
最常见的错误场景:
- 对同一个
createSynth(),在同一时刻连续调用两次triggerAttackRelease(..., time) - 在
createLoop()/createSequence()回调里,把单音 synth 当成和弦器来用
错误示例:
const synth = audio.createSynth();
audio.createLoop("4n", (time) => {
synth.triggerAttackRelease("C4", "8n", time);
synth.triggerAttackRelease("E4", "8n", time);
});
更合适的写法有两种。
# 如果本意是同一拍发多个音,改用 createPolySynth()
const poly = audio.createPolySynth();
audio.createLoop("4n", (time) => {
poly.triggerAttackRelease(["C4", "E4"], "8n", time);
});
# 如果本意是分解,后一个时间必须更晚
const synth = audio.createSynth();
audio.createLoop("4n", (time) => {
synth.triggerAttackRelease("C4", "8n", time);
synth.triggerAttackRelease("E4", "8n", time + "+0.03");
});
# 9. 最常见的几种写法
# 点击音
tools.runClientAudio(`
await audio.resume();
const synth = audio.createSynth({
oscillator: { type: "triangle" },
envelope: { attack: 0.003, decay: 0.05, sustain: 0.01, release: 0.08 }
});
synth.setVolume(-18);
synth.triggerAttackRelease("E5", "32n");
`, {
label: "ui_click_light"
});
# 成功音 / 确认音
tools.runClientAudio(`
await audio.resume();
const poly = audio.createPolySynth({
oscillator: { type: "sine" },
envelope: { attack: 0.01, decay: 0.12, sustain: 0.1, release: 0.24 }
});
poly.setVolume(-12);
poly.triggerAttackRelease(["C5", "E5", "G5"], "8n");
`, {
label: "ui_confirm_success"
});
# 失败 / 警告音
tools.runClientAudio(`
await audio.resume();
const synth = audio.createSynth({
oscillator: { type: "sawtooth" },
envelope: { attack: 0.005, decay: 0.08, sustain: 0.03, release: 0.18 }
});
synth.setVolume(-14);
synth.triggerAttackRelease("E4", "16n");
synth.triggerAttackRelease("C4", "8n", "+0.08");
`, {
label: "ui_warning_fail"
});
# 按音符数组循环播放
tools.runClientAudio(`
await audio.resume();
const synth = audio.createSynth({
oscillator: { type: "triangle" },
envelope: { attack: 0.01, decay: 0.12, sustain: 0.05, release: 0.15 }
});
synth.setVolume(-12);
const notes = payload.notes || ["C4", "E4", "G4", "B4"];
audio.createSequence(notes, "8n", (time, note) => {
if (!note) return;
synth.triggerAttackRelease(note, "8n", time, 0.9);
});
await audio.wait(payload.lengthMs || 3200);
`, {
payload: { notes: ["C4", "D4", "G4", "A4"], lengthMs: 3200 },
label: "preview_melody"
});
# 按模式做琶音
tools.runClientAudio(`
await audio.resume();
const synth = audio.createSynth({
oscillator: { type: "triangle" },
envelope: { attack: 0.01, decay: 0.1, sustain: 0.04, release: 0.12 }
});
synth.setVolume(-14);
const notes = payload.notes || ["C4", "E4", "G4", "B4"];
audio.createPattern(notes, payload.pattern || "upDown", (time, note) => {
synth.triggerAttackRelease(note, "16n", time, 0.85);
}, {
interval: payload.interval || "16n"
});
await audio.wait(payload.lengthMs || 2800);
`, {
payload: {
notes: ["A3", "C4", "E4", "G4"],
pattern: "upDown",
interval: "16n",
lengthMs: 2800
},
label: "preview_pattern_arp"
});
# 程序化背景音乐
tools.runClientAudio(`
await audio.resume();
const bass = audio.createSynth({
oscillator: { type: "sine" },
envelope: { attack: 0.01, decay: 0.08, sustain: 0.08, release: 0.2 }
});
bass.setVolume(-18);
const lead = audio.createPolySynth({
oscillator: { type: "triangle" },
envelope: { attack: 0.02, decay: 0.12, sustain: 0.06, release: 0.22 }
});
lead.setVolume(-20);
audio.createLoop("2n", (time, step) => {
const roots = ["C3", "A2", "F2", "G2"];
const root = roots[step % roots.length];
bass.triggerAttackRelease(root, "8n", time, 0.75);
});
audio.createLoop("1n", (time, step) => {
const chords = [
["C4", "E4", "G4"],
["A3", "C4", "E4"],
["F3", "A3", "C4"],
["G3", "B3", "D4"]
];
lead.triggerAttackRelease(chords[step % chords.length], "2n", time, 0.45);
});
await audio.keepAlive();
`, {
label: "scene_bgm_town"
});
停止这段背景音乐:
tools.disposeClientAudio("scene_bgm_town");
# 10. 交互脚本里也能直接做音效
例如鼠标移入时播一个轻音,移出时停掉长音,长按时再补一段确认感更强的提示音:
# js_enter
tools.runClientAudio(`
await audio.resume();
const synth = audio.createSynth({
oscillator: { type: "triangle" },
envelope: { attack: 0.003, decay: 0.05, sustain: 0.01, release: 0.08 }
});
synth.setVolume(-18);
synth.triggerAttackRelease("E5", "32n");
`, {
label: `hover-audio-${tools.kwargs.itemID}`
});
# js_leave
tools.disposeClientAudio(`hover-audio-${tools.kwargs.itemID}`);
# js_long
tools.runClientAudio(`
await audio.resume();
const synth = audio.createSynth({
oscillator: { type: "sine" },
envelope: { attack: 0.01, decay: 0.12, sustain: 0.04, release: 0.2 }
});
synth.setVolume(-14);
synth.triggerAttackRelease("C5", "8n");
`, {
label: `long-press-audio-${tools.kwargs.itemID}`
});
如果是 js_move 这类高频触发位置,最好固定 label,不要每次移动都开一条新音频会话。
js_long 则更适合放“按住一会儿才出现”的确认音、蓄力音或展开音。
# 11. 这类内容通常不放在音效系统里
下面这些更适合写在普通脚本、事件或 AI 联动里:
- 扣金币
- 改属性
- 创建对象
- 推进剧情
- 决定 AI 能不能输入
另外也不适合用它来做:
- 任意外部样本播放器
- 依赖浏览器全局对象的随意音频脚本
# 使用时要注意的几件事
runClientAudio的第一个参数必须是一段字符串脚本- 第一次发声前,通常先
await audio.resume() - 音符必须写完整音高,例如
C4、F#4、Bb3 - 同一时刻多音请优先用
createPolySynth() - 循环和背景音乐要重视
label,也要知道什么时候dispose - 玩家可以在悬浮设置里统一调节或静音全部前端音效
# 下一步
如果你还没看过视觉表现入口,可以继续看 客户端特效系统。
如果你准备把声音挂到移入、移出、移动、长按上,可以再看 交互特效脚本。