9. 音效与特效
2026/4/14大约 10 分钟
9. 保卫萝卜——音效与特效
为什么音效和特效很重要?
想象你看一部没有配乐和音效的电影——打斗没有声音、爆炸没有响动,是不是觉得很"干"?游戏也一样。音效和特效(Visual Effects,简称 VFX)虽然不影响游戏逻辑,但它们是让游戏"活起来"的关键。
| 类别 | 作用 | 示例 |
|---|---|---|
| 音效(SFX) | 听觉反馈,让操作有"手感" | 放塔的"咚"声、金币的"叮"声 |
| 特效(VFX) | 视觉反馈,让操作有"冲击力" | 爆炸粒子、攻击光线 |
| 动画 | 让角色有"生命力" | 怪物走路动画、塔旋转 |
音效管理器
Godot 中播放音效使用 AudioStreamPlayer 节点。但每次播放一个音效就创建一个节点很浪费,所以我们需要一个"音效管理器"来统一管理。
音效管理器实现
C
using Godot;
using System.Collections.Generic;
/// <summary>
/// 音效管理器 —— 游戏的"音响师"
/// 统一管理所有音效的加载和播放
/// </summary>
public partial class AudioManager : Node
{
// ===== 单例 =====
public static AudioManager Instance { get; private set; }
// ===== 音效引用 =====
[ExportGroup("Sound Effects")]
[Export] public AudioStream BuildTowerSfx { get; set; }
[Export] public AudioStream UpgradeSfx { get; set; }
[Export] public AudioStream SellSfx { get; set; }
[Export] public AudioStream TowerShootSfx { get; set; }
[Export] public AudioStream MonsterDeathSfx { get; set; }
[Export] public AudioStream MonsterReachSfx { get; set; }
[Export] public AudioStream CoinPickupSfx { get; set; }
[Export] public AudioStream ButtonClickSfx { get; set; }
[Export] public AudioStream WaveStartSfx { get; set; }
[Export] public AudioStream BossAppearSfx { get; set; }
[Export] public AudioStream VictorySfx { get; set; }
[Export] public AudioStream DefeatSfx { get; set; }
// ===== 背景音乐 =====
[ExportGroup("Music")]
[Export] public AudioStream BgmMenu { get; set; }
[Export] public AudioStream BgmBattle { get; set; }
[Export] public AudioStream BgmBoss { get; set; }
// ===== 音量设置 =====
[ExportGroup("Volume")]
[Export(PropertyHint.Range, "0,1,0.01")]
public float MasterVolume { get; set; } = 1.0f;
[Export(PropertyHint.Range, "0,1,0.01")]
public float SfxVolume { get; set; } = 0.8f;
[Export(PropertyHint.Range, "0,1,0.01")]
public float MusicVolume { get; set; } = 0.5f;
// ===== 内部节点 =====
private AudioStreamPlayer _sfxPlayer;
private AudioStreamPlayer _musicPlayer;
// 音效对象池(避免频繁创建/销毁)
private readonly List<AudioStreamPlayer> _sfxPool = new();
private const int MaxPoolSize = 10;
public override void _EnterTree()
{
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
public override void _Ready()
{
// 创建主音效播放器
_sfxPlayer = new AudioStreamPlayer();
_sfxPlayer.Bus = "SFX";
AddChild(_sfxPlayer);
// 创建背景音乐播放器
_musicPlayer = new AudioStreamPlayer();
_musicPlayer.Bus = "Music";
AddChild(_musicPlayer);
}
/// <summary>
/// 播放指定音效
/// </summary>
public void PlaySfx(AudioStream sfx)
{
if (sfx == null) return;
// 从池中获取一个空闲的播放器
var player = GetPooledPlayer();
if (player == null) return;
player.Stream = sfx;
player.VolumeDb = Mathf.LinearToDb(SfxVolume);
player.Play();
// 播放完成后自动回收
_ = WaitForFinish(player);
}
/// <summary>
/// 播放预定义音效
/// </summary>
public void PlayBuildSfx() => PlaySfx(BuildTowerSfx);
public void PlayUpgradeSfx() => PlaySfx(UpgradeSfx);
public void PlaySellSfx() => PlaySfx(SellSfx);
public void PlayShootSfx() => PlaySfx(TowerShootSfx);
public void PlayMonsterDeathSfx() => PlaySfx(MonsterDeathSfx);
public void PlayCoinSfx() => PlaySfx(CoinPickupSfx);
public void PlayWaveStartSfx() => PlaySfx(WaveStartSfx);
public void PlayBossSfx() => PlaySfx(BossAppearSfx);
public void PlayVictorySfx() => PlaySfx(VictorySfx);
public void PlayDefeatSfx() => PlaySfx(DefeatSfx);
/// <summary>
/// 播放背景音乐
/// </summary>
public void PlayMusic(AudioStream music, float fadeIn = 1.0f)
{
if (music == null) return;
_musicPlayer.Stream = music;
_musicPlayer.VolumeDb = Mathf.LinearToDb(MusicVolume);
_musicPlayer.Play();
// 淡入效果
if (fadeIn > 0)
{
_musicPlayer.VolumeDb = -80f;
var tween = CreateTween();
tween.TweenProperty(_musicPlayer, "volume_db",
Mathf.LinearToDb(MusicVolume), fadeIn);
}
}
/// <summary>
/// 停止背景音乐(淡出)
/// </summary>
public void StopMusic(float fadeOut = 1.0f)
{
if (fadeOut > 0)
{
var tween = CreateTween();
tween.TweenProperty(_musicPlayer, "volume_db", -80f, fadeOut);
tween.TweenCallback(Callable.From(() =>
_musicPlayer.Stop()));
}
else
{
_musicPlayer.Stop();
}
}
/// <summary>
/// 从对象池获取播放器
/// </summary>
private AudioStreamPlayer GetPooledPlayer()
{
// 查找空闲的播放器
foreach (var player in _sfxPool)
{
if (!player.Playing)
return player;
}
// 池满了就不再创建
if (_sfxPool.Count >= MaxPoolSize) return null;
// 创建新的播放器
var newPlayer = new AudioStreamPlayer();
newPlayer.Bus = "SFX";
AddChild(newPlayer);
_sfxPool.Add(newPlayer);
return newPlayer;
}
/// <summary>
/// 等待播放完成
/// </summary>
private async System.Threading.Tasks.Task WaitForFinish(
AudioStreamPlayer player)
{
await ToSignal(player,
AudioStreamPlayer.SignalName.Finished);
}
}GDScript
extends Node
class_name AudioManager
## 音效管理器 —— 游戏的"音响师"
## 统一管理所有音效的加载和播放
# ===== 单例 =====
static var instance: AudioManager
# ===== 音效引用 =====
@export_group("Sound Effects")
@export var build_tower_sfx: AudioStream
@export var upgrade_sfx: AudioStream
@export var sell_sfx: AudioStream
@export var tower_shoot_sfx: AudioStream
@export var monster_death_sfx: AudioStream
@export var monster_reach_sfx: AudioStream
@export var coin_pickup_sfx: AudioStream
@export var button_click_sfx: AudioStream
@export var wave_start_sfx: AudioStream
@export var boss_appear_sfx: AudioStream
@export var victory_sfx: AudioStream
@export var defeat_sfx: AudioStream
# ===== 背景音乐 =====
@export_group("Music")
@export var bgm_menu: AudioStream
@export var bgm_battle: AudioStream
@export var bgm_boss: AudioStream
# ===== 音量 =====
@export_group("Volume")
@export_range(0, 1, 0.01) var master_volume: float = 1.0
@export_range(0, 1, 0.01) var sfx_volume: float = 0.8
@export_range(0, 1, 0.01) var music_volume: float = 0.5
var _sfx_player: AudioStreamPlayer
var _music_player: AudioStreamPlayer
var _sfx_pool: Array[AudioStreamPlayer] = []
const MAX_POOL_SIZE: int = 10
func _enter_tree() -> void:
instance = self
func _exit_tree() -> void:
if instance == self:
instance = null
func _ready() -> void:
_sfx_player = AudioStreamPlayer.new()
_sfx_player.bus = "SFX"
add_child(_sfx_player)
_music_player = AudioStreamPlayer.new()
_music_player.bus = "Music"
add_child(_music_player)
## 播放指定音效
func play_sfx(sfx: AudioStream) -> void:
if not sfx:
return
var player = _get_pooled_player()
if not player:
return
player.stream = sfx
player.volume_db = linear_to_db(sfx_volume)
player.play()
## 播放预定义音效
func play_build_sfx() -> void: play_sfx(build_tower_sfx)
func play_upgrade_sfx() -> void: play_sfx(upgrade_sfx)
func play_sell_sfx() -> void: play_sfx(sell_sfx)
func play_shoot_sfx() -> void: play_sfx(tower_shoot_sfx)
func play_monster_death_sfx() -> void: play_sfx(monster_death_sfx)
func play_coin_sfx() -> void: play_sfx(coin_pickup_sfx)
func play_wave_start_sfx() -> void: play_sfx(wave_start_sfx)
func play_boss_sfx() -> void: play_sfx(boss_appear_sfx)
func play_victory_sfx() -> void: play_sfx(victory_sfx)
func play_defeat_sfx() -> void: play_sfx(defeat_sfx)
## 播放背景音乐
func play_music(music: AudioStream, fade_in: float = 1.0) -> void:
if not music:
return
_music_player.stream = music
_music_player.volume_db = linear_to_db(music_volume)
_music_player.play()
if fade_in > 0:
_music_player.volume_db = -80.0
var tween = create_tween()
tween.tween_property(
_music_player, "volume_db",
linear_to_db(music_volume), fade_in
)
## 停止背景音乐
func stop_music(fade_out: float = 1.0) -> void:
if fade_out > 0:
var tween = create_tween()
tween.tween_property(_music_player, "volume_db", -80.0, fade_out)
tween.tween_callback(_music_player.stop)
else:
_music_player.stop()
## 从对象池获取播放器
func _get_pooled_player() -> AudioStreamPlayer:
for player in _sfx_pool:
if not player.playing:
return player
if _sfx_pool.size() >= MAX_POOL_SIZE:
return null
var new_player = AudioStreamPlayer.new()
new_player.bus = "SFX"
add_child(new_player)
_sfx_pool.append(new_player)
return new_player音频总线设置
在 Godot 编辑器中设置音频总线(Audio Bus),可以分别控制不同类型声音的音量:
| 总线名称 | 用途 | 说明 |
|---|---|---|
| Master | 主音量 | 控制所有声音 |
| SFX | 音效 | 放塔、攻击、怪物死亡等 |
| Music | 背景音乐 | 循环播放的背景音乐 |
| UI | 界面音效 | 按钮点击等 |
设置方法:打开编辑器底部的 Audio 面板,点击 添加总线 创建以上总线。
攻击线特效
当防御塔攻击怪物时,可以画一条从塔到怪物的"攻击线",让玩家清楚看到塔在打谁。
C
using Godot;
/// <summary>
/// 攻击线特效 —— 从塔到怪物的"激光线"
/// 用 Draw 方法实时绘制
/// </summary>
public partial class AttackLineEffect : Node2D
{
private Vector2 _startPos;
private Vector2 _endPos;
private float _lifetime = 0.15f;
private float _elapsed;
private Color _lineColor = Colors.Yellow;
private float _lineWidth = 2.0f;
/// <summary>
/// 初始化攻击线
/// </summary>
public void Initialize(
Vector2 from, Vector2 to,
Color color, float width = 2.0f,
float duration = 0.15f)
{
_startPos = from;
_endPos = to;
_lineColor = color;
_lineWidth = width;
_lifetime = duration;
_elapsed = 0;
}
public override void _Process(double delta)
{
_elapsed += (float)delta;
if (_elapsed >= _lifetime)
{
QueueFree();
}
QueueRedraw();
}
public override void _Draw()
{
// 根据已过时间计算透明度(渐隐效果)
float alpha = 1.0f - (_elapsed / _lifetime);
var color = new Color(
_lineColor.R, _lineColor.G, _lineColor.B, alpha);
// 画外发光
DrawLine(_startPos, _endPos,
new Color(color.R, color.G, color.B, alpha * 0.3f),
_lineWidth + 4);
// 画主线
DrawLine(_startPos, _endPos, color, _lineWidth);
}
}GDScript
extends Node2D
class_name AttackLineEffect
## 攻击线特效 —— 从塔到怪物的"激光线"
var _start_pos: Vector2
var _end_pos: Vector2
var _lifetime: float = 0.15
var _elapsed: float = 0.0
var _line_color: Color = Color.YELLOW
var _line_width: float = 2.0
## 初始化攻击线
func initialize(
from: Vector2, to: Vector2,
color: Color, width: float = 2.0,
duration: float = 0.15
) -> void:
_start_pos = from
_end_pos = to
_line_color = color
_line_width = width
_lifetime = duration
_elapsed = 0.0
func _process(delta: float) -> void:
_elapsed += delta
if _elapsed >= _lifetime:
queue_free()
queue_redraw()
func _draw() -> void:
var alpha: float = 1.0 - (_elapsed / _lifetime)
var color = Color(_line_color.r, _line_color.g, _line_color.b, alpha)
# 外发光
draw_line(
_start_pos, _end_pos,
Color(color.r, color.g, color.b, alpha * 0.3),
_line_width + 4
)
# 主线
draw_line(_start_pos, _end_pos, color, _line_width)爆炸粒子特效
火箭塔爆炸时需要播放粒子特效。Godot 4 使用 GPUParticles2D 来实现粒子效果。
创建爆炸粒子场景
在编辑器中:
- 创建 GPUParticles2D 节点
- 设置以下参数:
| 参数 | 值 | 说明 |
|---|---|---|
| Amount | 30 | 粒子数量 |
| OneShot | true | 只播放一次 |
| Lifetime | 0.5 | 每个粒子存活时间 |
| Explosiveness | 0.8 | 爆炸程度(越大约集中) |
| Direction | (0, -1) | 粒子方向(向上) |
| Spread | 180 | 扩散角度 |
| Gravity | (0, 98) | 重力 |
| Speed Min | 50 | 最小速度 |
| Speed Max | 200 | 最大速度 |
爆炸特效脚本
C
using Godot;
/// <summary>
/// 爆炸特效控制器
/// 播放粒子特效后自动销毁
/// </summary>
public partial class ExplosionEffect : Node2D
{
[Export] public GpuParticles2D Particles { get; set; }
[Export] public float Duration { get; set; } = 1.0f;
/// <summary>
/// 在指定位置播放爆炸
/// </summary>
public static void Spawn(
Node parent, Vector2 position,
float radius = 1.0f)
{
// 加载爆炸场景
var scene = GD.Load<PackedScene>(
"res://scenes/entities/effects/explosion.tscn");
if (scene == null) return;
var explosion = scene.Instantiate<ExplosionEffect>();
parent.AddChild(explosion);
explosion.GlobalPosition = position;
// 根据范围缩放
explosion.Scale = new Vector2(radius, radius);
}
public override void _Ready()
{
if (Particles != null)
{
Particles.Emitting = true;
}
// 自动销毁
var timer = GetTree().CreateTimer(Duration);
timer.Timeout += QueueFree;
}
}GDScript
extends Node2D
class_name ExplosionEffect
## 爆炸特效控制器
## 播放粒子特效后自动销毁
## 粒子节点
@export var particles: GpuParticles2D
## 持续时间
@export var duration: float = 1.0
## 在指定位置播放爆炸
static func spawn(parent: Node, position: Vector2, radius: float = 1.0) -> void:
var scene = load("res://scenes/entities/effects/explosion.tscn") as PackedScene
if not scene:
return
var explosion = scene.instantiate() as ExplosionEffect
parent.add_child(explosion)
explosion.global_position = position
explosion.scale = Vector2(radius, radius)
func _ready() -> void:
if particles:
particles.emitting = true
var timer = get_tree().create_timer(duration)
timer.timeout.connect(queue_free)金币飘字特效
怪物被击杀时,在怪物位置显示 "+20G" 这样的飘字效果。
C
using Godot;
/// <summary>
/// 飘字特效 —— "+20G" 这样的浮动文字
/// </summary>
public partial class FloatingText : Node2D
{
[Export] public float FloatSpeed { get; set; } = 50.0f;
[Export] public float FadeDuration { get; set; } = 1.0f;
private Label _label;
private float _elapsed;
/// <summary>
/// 显示飘字
/// </summary>
public static void Show(
Node parent, Vector2 position,
string text, Color color)
{
var ft = new FloatingText();
parent.AddChild(ft);
ft.GlobalPosition = position;
ft._label = new Label
{
Text = text,
HorizontalAlignment = HorizontalAlignment.Center
};
ft._label.AddThemeFontSizeOverride(
"font_size", 20);
ft._label.AddThemeColorOverride(
"font_color", color);
ft._label.Position = new Vector2(-30, -20);
ft.AddChild(ft._label);
}
public override void _Process(double delta)
{
_elapsed += (float)delta;
// 向上飘
Position = new Vector2(
Position.X,
Position.Y - FloatSpeed * (float)delta);
// 淡出
float alpha = 1.0f - (_elapsed / FadeDuration);
if (_label != null)
{
_label.Modulate = new Color(1, 1, 1, alpha);
}
if (_elapsed >= FadeDuration)
{
QueueFree();
}
}
}GDScript
extends Node2D
class_name FloatingText
## 飘字特效 —— "+20G" 这样的浮动文字
## 上飘速度
@export var float_speed: float = 50.0
## 淡出时间
@export var fade_duration: float = 1.0
var _label: Label = null
var _elapsed: float = 0.0
## 显示飘字
static func show(
parent: Node, position: Vector2,
text: String, color: Color
) -> void:
var ft = FloatingText.new()
parent.add_child(ft)
ft.global_position = position
ft._label = Label.new()
ft._label.text = text
ft._label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
ft._label.add_theme_font_size_override("font_size", 20)
ft._label.add_theme_color_override("font_color", color)
ft._label.position = Vector2(-30, -20)
ft.add_child(ft._label)
func _process(delta: float) -> void:
_elapsed += delta
# 向上飘
position = Vector2(position.x, position.y - float_speed * delta)
# 淡出
var alpha: float = 1.0 - (_elapsed / fade_duration)
if _label:
_label.modulate = Color(1, 1, 1, alpha)
if _elapsed >= fade_duration:
queue_free()萝卜受伤动画
萝卜被怪物碰到时,播放一个受伤抖动+表情变化动画。
C
using Godot;
/// <summary>
/// 萝卜控制器 —— 游戏要保护的对象
/// 处理受伤动画和表情变化
/// </summary>
public partial class Carrot : Node2D
{
[Export] public Sprite2D FaceSprite { get; set; }
// 不同表情的贴图
[Export] public Texture2D NormalFace { get; set; }
[Export] public Texture2D HurtFace { get; set; }
[Export] public Texture2D SadFace { get; set; }
[Export] public Texture2D HappyFace { get; set; }
/// <summary>
/// 播放受伤动画
/// </summary>
public void PlayHurtAnimation()
{
// 切换为受伤表情
if (FaceSprite != null && HurtFace != null)
{
FaceSprite.Texture = HurtFace;
}
// 抖动效果
var tween = CreateTween();
tween.TweenProperty(this, "position:x",
Position.X - 5, 0.05f);
tween.TweenProperty(this, "position:x",
Position.X + 5, 0.05f);
tween.TweenProperty(this, "position:x",
Position.X - 3, 0.05f);
tween.TweenProperty(this, "position:x",
Position.X + 3, 0.05f);
tween.TweenProperty(this, "position:x",
Position.X, 0.05f);
// 0.5秒后恢复表情
GetTree().CreateTimer(0.5).Timeout += () =>
{
if (FaceSprite != null && NormalFace != null)
{
FaceSprite.Texture = NormalFace;
}
};
}
/// <summary>
/// 播放胜利动画
/// </summary>
public void PlayVictoryAnimation()
{
if (FaceSprite != null && HappyFace != null)
{
FaceSprite.Texture = HappyFace;
}
// 跳跃动画
var tween = CreateTween();
tween.TweenProperty(this, "position:y",
Position.Y - 20, 0.2f)
.SetTrans(Tween.TransitionType.Bounce);
tween.TweenProperty(this, "position:y",
Position.Y, 0.3f);
}
/// <summary>
/// 播放失败动画
/// </summary>
public void PlayDefeatAnimation()
{
if (FaceSprite != null && SadFace != null)
{
FaceSprite.Texture = SadFace;
}
// 缩小+旋转
var tween = CreateTween();
tween.TweenProperty(this, "scale",
new Vector2(0.5f, 0.5f), 0.5f);
tween.Parallel().TweenProperty(this, "rotation",
Mathf.Pi / 4, 0.5f);
}
}GDScript
extends Node2D
class_name Carrot
## 萝卜控制器 —— 游戏要保护的对象
## 处理受伤动画和表情变化
## 脸部精灵
@export var face_sprite: Sprite2D
## 表情贴图
@export var normal_face: Texture2D
@export var hurt_face: Texture2D
@export var sad_face: Texture2D
@export var happy_face: Texture2D
## 播放受伤动画
func play_hurt_animation() -> void:
if face_sprite and hurt_face:
face_sprite.texture = hurt_face
var tween = create_tween()
tween.tween_property(self, "position:x", position.x - 5, 0.05)
tween.tween_property(self, "position:x", position.x + 5, 0.05)
tween.tween_property(self, "position:x", position.x - 3, 0.05)
tween.tween_property(self, "position:x", position.x + 3, 0.05)
tween.tween_property(self, "position:x", position.x, 0.05)
await get_tree().create_timer(0.5).timeout
if face_sprite and normal_face:
face_sprite.texture = normal_face
## 播放胜利动画
func play_victory_animation() -> void:
if face_sprite and happy_face:
face_sprite.texture = happy_face
var tween = create_tween()
tween.tween_property(self, "position:y", position.y - 20, 0.2)\
.set_trans(Tween.TRANS_BOUNCE)
tween.tween_property(self, "position:y", position.y, 0.3)
## 播放失败动画
func play_defeat_animation() -> void:
if face_sprite and sad_face:
face_sprite.texture = sad_face
var tween = create_tween()
tween.tween_property(self, "scale", Vector2(0.5, 0.5), 0.5)
tween.parallel().tween_property(self, "rotation", PI / 4, 0.5)集成音效到游戏逻辑
将音效和特效集成到已有的游戏逻辑中,只需要在关键事件发生时调用对应的播放方法。
| 事件 | 音效 | 特效 |
|---|---|---|
| 放置塔 | 建塔音效 | 塔出现缩放动画 |
| 塔攻击 | 射击音效 | 攻击线 |
| 怪物受伤 | - | 闪红 |
| 怪物死亡 | 死亡音效 | 爆炸粒子 + "+20G"飘字 |
| 怪物到达萝卜 | 到达音效 | 萝卜受伤抖动 |
| 升级塔 | 升级音效 | 金色闪光 + "Lv.2"飘字 |
| 出售塔 | 出售音效 | 淡出 |
| 波次开始 | 波次音效 | "Wave 3"预告 |
| Boss出场 | Boss音效 | 屏幕震动 |
| 胜利 | 胜利音乐 | 萝卜开心跳跃 |
| 失败 | 失败音乐 | 萝卜悲伤缩小 |
本节小结
| 组件 | 说明 |
|---|---|
| AudioManager | 音效管理器,对象池播放,支持SFX和音乐分离 |
| AttackLineEffect | 攻击线特效,从塔到怪物的渐隐线 |
| ExplosionEffect | 爆炸粒子特效,GPUParticles2D |
| FloatingText | 飘字特效,"+20G"上飘淡出 |
| Carrot | 萝卜受伤/胜利/失败动画 |
音效和特效让游戏从一个"能玩的原型"变成了"有感觉的游戏"。最后一节我们将进行打磨和发布准备。
