godot-game-one 里没有任何 .wav、.ogg、.mp3 文件。所有音效——射击、爆炸、受击、UI 提示——全部在运行时用 GDScript 计算生成。两个脚本 sfx_manager.gd 和 bgm_manager.gd 承担了全部音频职责。

三种波形,覆盖全部场景

整个音效系统只用了三种基础波形,全部通过计算 PCM 采样值实现:

波形 原理 用途
Tone(正弦波) sin(phase * TAU) + 指数衰减 UI 音效、受击反馈
Sweep(频率扫描) 频率线性插值 lerp(start, end, t) 射击、敌人死亡
Noise(白噪声) randf_range(-1, 1) + 快速衰减 爆炸

核心实现(Tone 生成器):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# sfx_manager.gd
func _generate_tone(freq: float, duration: float, volume: float = 0.3, sample_rate: int = 44100) -> AudioStreamWAV:
var num_samples := int(duration * sample_rate)
var data := PackedByteArray()
data.resize(num_samples * 2)
var phase := 0.0
var phase_step: float = freq / sample_rate
var decay: float = 1.0
var decay_step: float = 1.0 / num_samples
for i in num_samples:
decay = maxf(decay - decay_step * 1.2, 0.0)
var sv: float = sin(phase * TAU) * volume * decay
var si: int = clampi(int(sv * 32767), -32768, 32767)
data[i * 2] = si & 0xFF
data[i * 2 + 1] = (si >> 8) & 0xFF
phase = fmod(phase + phase_step, 1.0)
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.stereo = false
stream.mix_rate = 44100
stream.data = data
return stream

Sweep 和 Noise 的实现几乎相同,区别仅在于核心采样公式。6 个游戏音效就是这三种波形的不同参数组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# sfx_manager.gd 中的公开接口
func play_shoot() -> void:
# 独立的射击音效池,避免高频射击抢占其他音效
var p := _shoot_players[_shoot_index % _shoot_players.size()]
_shoot_index += 1
p.stop()
p.stream = _stream_shoot
p.play()

func play_enemy_death() -> void:
_play_stream(_stream_enemy_death, _players)

func play_player_hurt() -> void:
_play_stream(_stream_player_hurt, _players)

func play_ui_confirm() -> void:
_play_stream(_stream_ui_confirm, _players)

func play_ui_select() -> void:
_play_stream(_stream_ui_select, _players)

func play_explosion() -> void:
_play_stream(_stream_explosion, _players)

预生成的 6 个音效参数(在 _ready 中初始化):

1
2
3
4
5
6
_stream_shoot = _generate_sweep(1200.0, 400.0, 0.06, 0.15)
_stream_enemy_death = _generate_sweep(600.0, 100.0, 0.15, 0.3)
_stream_player_hurt = _generate_tone(200.0, 0.2, 0.4)
_stream_ui_confirm = _generate_tone(800.0, 0.08, 0.25)
_stream_ui_select = _generate_tone(500.0, 0.05, 0.15)
_stream_explosion = _generate_noise(0.3, 0.4)

BGM:立体声合成器 + pitch 切换

背景音乐同样是程序化生成,但比音效复杂得多——bgm_manager.gd 的 _generate_bgm() 生成一段 12 秒的立体声音频,内部有三层声部叠加:

1
2
3
4
# bgm_manager.gd
var pad_notes := [130.81, 164.81, 196.0, 246.94] # 和弦 Pad
var bass_notes := [65.41, 82.41, 73.42, 87.31] # 低音 Bass
var melody_notes := [261.63, 293.66, 329.63, 349.23, 392.0, 349.23, 329.63, 293.66, 261.63, 293.66, 349.23, 392.0] # 旋律线

三层声部在每个采样点叠加输出:Pad 提供和声铺底(sine + 泛音),Bass 提供低频节奏(soft attack/decay),Melody 提供主旋律(带 auto-pan 的 12 音符循环),还加了一个正弦 kick 模拟鼓点。最终输出立体声 PCM 数据,总时长 12 秒,AudioStreamWAV.loop_mode 自动循环。

Boss 模式不是独立音符数组——同一个 _bgm_stream,Boss 战通过 pitch_scale = 1.18 加速播放 + volume_db = -3.5 调整音量,听感自然变紧张:

1
2
3
4
5
6
7
func play_bgm() -> void:
_boss_mode = false
_play_with_profile(-6.0, 1.0)

func play_boss_bgm() -> void:
_boss_mode = true
_play_with_profile(-3.5, 1.18)

游戏中音效调用点分布广泛,覆盖了所有交互场景:

为什么不用音频文件?

  1. 零依赖:不需要找素材、处理版权、管理资源文件
  2. 体积极小:APK 不用打包任何音频资源
  3. 参数可调:想让射击音更高?改一个频率数值的事
  4. 学习成本低:不需要音频编辑器,不需要理解音频格式

局限与适用场景

程序化音效的表现力有限——没有混响、没有和声、没有复杂的音色层次。但对于复古风格的弹幕游戏来说,这种”干净的电子感”恰好是风格的一部分。

适合用程序化音效的场景:

  • Game Jam / 原型验证阶段,不想花时间找素材
  • 复古 / 像素风格游戏,音效本身就是”低保真”美学
  • 嵌入式 / 极小体积要求的项目

不适合的场景:

  • 需要沉浸式音效的 3A / 叙事向游戏
  • 需要多层混音、空间音频的 VR/AR 项目
  • 音效是核心卖点的音乐游戏

最好的工程方案不是最强的,是最适合当前阶段的。