LPAI AI 游戏编辑器文档

特效系统

客户端音效系统

特效系统 给控件、对象和页面加上可编程的前端音效 `客户端音效系统` 负责前端声音表现,例如点击音、命中音、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');
`);

这两层不是一回事。

# 外层脚本里常用什么

  • player
  • objects
  • tools

# 内层音频脚本里常用什么

  • Tone
  • audio
  • payload
  • runtime
  • session
  • pointer

内层音频脚本只能使用这些注入变量;运行时会遮蔽 windowdocumentlocalStoragefetchpostMessage 等浏览器能力,但不额外拦截普通 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.notespayload.chordpayload.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()
  • 音符必须写完整音高,例如 C4F#4Bb3
  • 同一时刻多音请优先用 createPolySynth()
  • 循环和背景音乐要重视 label,也要知道什么时候 dispose
  • 玩家可以在悬浮设置里统一调节或静音全部前端音效

# 下一步

如果你还没看过视觉表现入口,可以继续看 客户端特效系统
如果你准备把声音挂到移入、移出、移动、长按上,可以再看 交互特效脚本