9. 音效与视觉特效
2026/4/13大约 9 分钟
音效与视觉特效
到现在为止,你的游戏功能已经完整了——能开车、能射击、有敌人、有关卡、能救人。但它还像是一个"无声的灰色世界"。这一章我们来给游戏加上声音和视觉效果,让它变得有声有色。
音效系统
需要哪些音效
| 音效类型 | 具体音效 | 触发时机 | 文件格式 |
|---|---|---|---|
| 武器 | 机枪射击 | 每次射击 | .wav |
| 手雷投掷 | 投掷手雷 | .wav | |
| 武器升级 | 救人达到奖励阈值 | .wav | |
| 爆炸 | 小型爆炸 | 子弹命中、步兵死亡 | .wav |
| 大型爆炸 | 手雷爆炸、坦克爆炸 | .wav | |
| 建筑倒塌 | 建筑物被摧毁 | .wav | |
| 载具 | 引擎声(循环) | 车辆移动时 | .wav (循环) |
| 轮胎摩擦 | 急转弯 | .wav | |
| 人质 | 欢呼声 | 人质上车 | .wav |
| 呼救声 | 关卡开始时 | .wav | |
| UI | 任务完成 | 任务目标达成 | .wav |
| 关卡通过 | 到达撤离点 | .wav | |
| BGM | 战斗音乐 | 游戏进行中 | .ogg (循环) |
音效管理器
用统一的管理器播放音效,避免到处写播放代码:
C
using Godot;
/// <summary>
/// 音效管理器 - 统一管理游戏音效播放
/// 作为 AutoLoad 全局单例使用
/// </summary>
public partial class SoundManager : Node
{
[Export] public float MasterVolume { get; set; } = 1.0f; // 主音量
[Export] public float SfxVolume { get; set; } = 0.8f; // 音效音量
[Export] public float BgmVolume { get; set; } = 0.5f; // 背景音乐音量
private AudioStreamPlayer _bgmPlayer;
private readonly AudioStreamPlayer[] _sfxPlayers = new AudioStreamPlayer[8]; // 音效播放器池
// 音效资源缓存
private readonly System.Collections.Generic.Dictionary<string, AudioStream> _sfxCache = new();
public override void _Ready()
{
// 创建背景音乐播放器
_bgmPlayer = new AudioStreamPlayer
{
VolumeDb = Mathf.LinearToDb(BgmVolume * MasterVolume)
};
AddChild(_bgmPlayer);
// 创建音效播放器池(同时播放多个音效)
for (int i = 0; i < _sfxPlayers.Length; i++)
{
_sfxPlayers[i] = new AudioStreamPlayer
{
VolumeDb = Mathf.LinearToDb(SfxVolume * MasterVolume)
};
AddChild(_sfxPlayers[i]);
}
}
/// <summary>
/// 播放音效
/// </summary>
public void PlaySfx(string sfxName)
{
var stream = LoadSfx(sfxName);
if (stream == null) return;
// 找一个空闲的播放器
foreach (var player in _sfxPlayers)
{
if (!player.Playing)
{
player.Stream = stream;
player.Play();
return;
}
}
// 所有播放器都在用,用第一个(强制中断)
_sfxPlayers[0].Stream = stream;
_sfxPlayers[0].Play();
}
/// <summary>
/// 播放背景音乐
/// </summary>
public void PlayBgm(string bgmPath)
{
if (_bgmPlayer.Playing && _bgmPlayer.Stream?.ResourcePath == bgmPath)
return; // 同一首歌不重复播放
var stream = GD.Load<AudioStream>(bgmPath);
if (stream == null) return;
if (stream is AudioStreamOggVorbis ogg)
{
ogg.Loop = true; // OGG 音乐循环播放
}
_bgmPlayer.Stream = stream;
_bgmPlayer.Play();
}
/// <summary>
/// 停止背景音乐
/// </summary>
public void StopBgm()
{
_bgmPlayer.Stop();
}
/// <summary>
/// 加载并缓存音效
/// </summary>
private AudioStream LoadSfx(string sfxName)
{
if (_sfxCache.ContainsKey(sfxName))
return _sfxCache[sfxName];
string path = $"res://assets/sounds/sfx/{sfxName}.wav";
var stream = GD.Load<AudioStream>(path);
if (stream != null)
_sfxCache[sfxName] = stream;
else
GD.PrintErr($"[SoundManager] 找不到音效:{path}");
return stream;
}
}GDScript
# 音效管理器 - 统一管理游戏音效播放
# 作为 AutoLoad 全局单例使用
extends Node
@export var master_volume: float = 1.0 # 主音量
@export var sfx_volume: float = 0.8 # 音效音量
@export var bgm_volume: float = 0.5 # 背景音乐音量
var _bgm_player: AudioStreamPlayer
var _sfx_players: Array[AudioStreamPlayer] = []
var _sfx_cache: Dictionary = {} # 音效资源缓存
const SFX_POOL_SIZE = 8
func _ready():
# 创建背景音乐播放器
_bgm_player = AudioStreamPlayer.new()
_bgm_player.volume_db = linear_to_db(bgm_volume * master_volume)
add_child(_bgm_player)
# 创建音效播放器池
for i in range(SFX_POOL_SIZE):
var player = AudioStreamPlayer.new()
player.volume_db = linear_to_db(sfx_volume * master_volume)
add_child(player)
_sfx_players.append(player)
## 播放音效
func play_sfx(sfx_name: String):
var stream = _load_sfx(sfx_name)
if stream == null:
return
# 找一个空闲的播放器
for player in _sfx_players:
if not player.playing:
player.stream = stream
player.play()
return
# 所有播放器都在用,用第一个
_sfx_players[0].stream = stream
_sfx_players[0].play()
## 播放背景音乐
func play_bgm(bgm_path: String):
if _bgm_player.playing and _bgm_player.stream.resource_path == bgm_path:
return # 同一首歌不重复播放
var stream = load(bgm_path) as AudioStream
if stream == null:
return
if stream is AudioStreamOggVorbis:
stream.loop = true # OGG 音乐循环播放
_bgm_player.stream = stream
_bgm_player.play()
## 停止背景音乐
func stop_bgm():
_bgm_player.stop()
## 加载并缓存音效
func _load_sfx(sfx_name: String) -> AudioStream:
if _sfx_cache.has(sfx_name):
return _sfx_cache[sfx_name]
var path = "res://assets/sounds/sfx/%s.wav" % sfx_name
var stream = load(path) as AudioStream
if stream:
_sfx_cache[sfx_name] = stream
else:
push_error("[SoundManager] 找不到音效:" + path)
return stream3D 爆炸特效
爆炸是赤色要塞最有视觉冲击力的效果。用 CPUParticles3D 或 GPUParticles3D 来实现。
爆炸特效场景
Explosion (Node3D)
├── FireParticles (CPUParticles3D) ← 火焰粒子
├── SmokeParticles (CPUParticles3D) ← 烟雾粒子
├── DebrisParticles (CPUParticles3D) ← 碎片粒子
├── PointLight3D ← 爆炸闪光
└── Timer ← 自动销毁计时器爆炸特效脚本
C
using Godot;
/// <summary>
/// 爆炸特效 - 包含火焰、烟雾、碎片
/// </summary>
public partial class Explosion : Node3D
{
[Export] public float Duration { get; set; } = 1.5f; // 特效持续时间
[Export] public float LightIntensity { get; set; } = 5.0f; // 闪光强度
[Export] public float LightFadeTime { get; set; } = 0.3f; // 闪光消退时间
private CPUParticles3D _fire;
private CPUParticles3D _smoke;
private CPUParticles3D _debris;
private OmniLight3D _light;
public override void _Ready()
{
_fire = GetNode<CPUParticles3D>("FireParticles");
_smoke = GetNode<CPUParticles3D>("SmokeParticles");
_debris = GetNode<CPUParticles3D>("DebrisParticles");
_light = GetNode<OmniLight3D>("PointLight3D");
// 播放爆炸音效
var soundManager = GetNodeOrNull<SoundManager>("/root/SoundManager");
soundManager?.PlaySfx("explosion_large");
// 闪光消退
var tween = CreateTween();
tween.TweenProperty(_light, "light_energy", 0.0f, LightFadeTime);
// 定时自毁
GetTree().CreateTimer(Duration).Timeout += () => QueueFree();
}
}GDScript
# 爆炸特效 - 包含火焰、烟雾、碎片
extends Node3D
@export var duration: float = 1.5 # 特效持续时间
@export var light_intensity: float = 5.0 # 闪光强度
@export var light_fade_time: float = 0.3 # 闪光消退时间
func _ready():
# 播放爆炸音效
var sound_manager = get_node_or_null("/root/SoundManager")
if sound_manager:
sound_manager.play_sfx("explosion_large")
# 闪光消退
var light = $PointLight3D
var tween = create_tween()
tween.tween_property(light, "light_energy", 0.0, light_fade_time)
# 定时自毁
get_tree().create_timer(duration).timeout.connect(func(): queue_free())CPUParticles3D 火焰参数配置
在编辑器中设置 FireParticles 的参数:
| 参数 | 火焰 | 烟雾 | 碎片 |
|---|---|---|---|
| Amount | 40 | 20 | 15 |
| Lifetime | 0.5 | 1.5 | 1.0 |
| Explosiveness | 0.8 | 0.3 | 0.9 |
| Direction | (0, 1, 0) | (0, 1, 0) | 随机 |
| Spread | 30° | 15° | 180° |
| Initial Velocity | 5~10 | 2~4 | 8~15 |
| Gravity | (0, -5, 0) | (0, 2, 0) | (0, -9.8, 0) |
| Scale | 2.0 → 0.0 | 1.0 → 3.0 | 0.3 |
| Color | 黄→橙→红 | 深灰→浅灰 | 棕色 |
CPUParticles3D vs GPUParticles3D
- CPUParticles3D:在CPU上计算,粒子数量少(几百个以内)时性能好,设置简单
- GPUParticles3D:在GPU上计算,可以处理成千上万个粒子,但设置稍复杂
对于赤色要塞这种粒子数量不多的游戏,CPUParticles3D 完全够用。
车辆移动扬尘效果
车辆移动时,地面应该扬起灰尘。这可以用一个小型粒子发射器实现。
C
using Godot;
/// <summary>
/// 车辆扬尘效果 - 挂在吉普车上
/// </summary>
public partial class DustEffect : CPUParticles3D
{
private CharacterBody3D _vehicle;
public override void _Ready()
{
_vehicle = GetParent<CharacterBody3D>();
// 基础粒子设置
Amount = 10;
Lifetime = 0.8;
Explosiveness = 0.2;
Direction = new Vector3(0, 1, 0);
Spread = 20f;
Gravity = new Vector3(0, -1, 0);
Emitting = false; // 默认不发射
}
public override void _PhysicsProcess(double delta)
{
if (_vehicle == null) return;
float speed = _vehicle.Velocity.Length();
// 只在移动时扬尘
if (speed > 1.0f)
{
Emitting = true;
// 速度越快,扬尘越多
AmountRatio = Mathf.Clamp(speed / 8.0f, 0.2f, 1.0f);
}
else
{
Emitting = false;
}
}
}GDScript
# 车辆扬尘效果 - 挂在吉普车上
extends CPUParticles3D
var _vehicle: CharacterBody3D
func _ready():
_vehicle = get_parent() as CharacterBody3D
# 基础粒子设置
amount = 10
lifetime = 0.8
explosiveness = 0.2
direction = Vector3(0, 1, 0)
spread = 20.0
gravity = Vector3(0, -1, 0)
emitting = false # 默认不发射
func _physics_process(delta):
if _vehicle == null:
return
var speed = _vehicle.velocity.length()
# 只在移动时扬尘
if speed > 1.0:
emitting = true
# 速度越快,扬尘越多
amount_ratio = clampf(speed / 8.0, 0.2, 1.0)
else:
emitting = false建筑物摧毁动画
建筑物被摧毁时不应该直接消失,而是有一个倒塌的过程:
C
using Godot;
/// <summary>
/// 建筑物摧毁动画 - 倒塌+碎裂效果
/// </summary>
public partial class BuildingDestroyEffect : Node3D
{
[Export] public PackedScene RubbleScene { get; set; } // 废墟模型
[Export] public float CollapseTime { get; set; } = 0.5f;
public void Play(Vector3 buildingPosition)
{
GlobalPosition = buildingPosition;
// 生成碎片
for (int i = 0; i < 8; i++)
{
var debris = new RigidBody3D();
var mesh = new MeshInstance3D();
mesh.Mesh = new BoxMesh
{
Size = new Vector3(
(float)GD.RandRange(0.2, 0.6),
(float)GD.RandRange(0.2, 0.6),
(float)GD.RandRange(0.2, 0.6)
)
};
debris.AddChild(mesh);
var collision = new CollisionShape3D
{
Shape = new BoxShape3D { Size = mesh.Mesh.GetAabb().Size }
};
debris.AddChild(collision);
AddChild(debris);
// 随机位置和速度
debris.GlobalPosition = buildingPosition + new Vector3(
(float)GD.RandRange(-1, 1),
(float)GD.RandRange(0.5, 2),
(float)GD.RandRange(-1, 1)
);
debris.ApplyCentralImpulse(new Vector3(
(float)GD.RandRange(-5, 5),
(float)GD.RandRange(3, 8),
(float)GD.RandRange(-5, 5)
));
}
// 定时清理碎片
GetTree().CreateTimer(3.0).Timeout += () => QueueFree();
}
}GDScript
# 建筑物摧毁动画 - 倒塌+碎裂效果
extends Node3D
@export var rubble_scene: PackedScene # 废墟模型
@export var collapse_time: float = 0.5
func play(building_position: Vector3):
global_position = building_position
# 生成碎片
for i in range(8):
var debris = RigidBody3D.new()
var mesh = MeshInstance3D.new()
var box_mesh = BoxMesh.new()
box_mesh.size = Vector3(
randf_range(0.2, 0.6),
randf_range(0.2, 0.6),
randf_range(0.2, 0.6)
)
mesh.mesh = box_mesh
debris.add_child(mesh)
var collision = CollisionShape3D.new()
var box_shape = BoxShape3D.new()
box_shape.size = box_mesh.size
collision.shape = box_shape
debris.add_child(collision)
add_child(debris)
# 随机位置和速度
debris.global_position = building_position + Vector3(
randf_range(-1, 1),
randf_range(0.5, 2),
randf_range(-1, 1)
)
debris.apply_central_impulse(Vector3(
randf_range(-5, 5),
randf_range(3, 8),
randf_range(-5, 5)
))
# 定时清理碎片
get_tree().create_timer(3.0).timeout.connect(func(): queue_free())屏幕震动效果
爆炸和重大事件时的屏幕震动,可以用摄像机抖动来实现:
C
using Godot;
/// <summary>
/// 屏幕震动效果 - 挂在摄像机上
/// </summary>
public partial class ScreenShake : Node
{
[Export] public float Decay { get; set; } = 5.0f; // 震动衰减速度
private Vector3 _offset = Vector3.Zero;
private float _currentStrength;
public override void _PhysicsProcess(double delta)
{
if (_currentStrength <= 0) return;
// 随机偏移
_offset = new Vector3(
(float)GD.RandRange(-_currentStrength, _currentStrength),
(float)GD.RandRange(-_currentStrength, _currentStrength),
0
);
// 衰减
_currentStrength -= (float)(Decay * delta);
if (_currentStrength < 0) _currentStrength = 0;
}
/// <summary>
/// 触发屏幕震动
/// </summary>
/// <param name="strength">震动强度</param>
public void Shake(float strength)
{
_currentStrength = Mathf.Max(_currentStrength, strength);
}
/// <summary>
/// 获取当前震动偏移
/// </summary>
public Vector3 GetOffset() => _offset;
}GDScript
# 屏幕震动效果 - 挂在摄像机上
extends Node
@export var decay: float = 5.0 # 震动衰减速度
var _offset: Vector3 = Vector3.ZERO
var _current_strength: float = 0.0
func _physics_process(delta):
if _current_strength <= 0:
return
# 随机偏移
_offset = Vector3(
randf_range(-_current_strength, _current_strength),
randf_range(-_current_strength, _current_strength),
0
)
# 衰减
_current_strength -= decay * delta
if _current_strength < 0:
_current_strength = 0
## 触发屏幕震动
func shake(strength: float):
_current_strength = maxf(_current_strength, strength)
## 获取当前震动偏移
func get_offset() -> Vector3:
return _offset在摄像机脚本中应用震动偏移:
// 在 GameCamera._PhysicsProcess 中添加:
var shakeOffset = GetNode<ScreenShake>("ScreenShake").GetOffset();
_camera.Position = new Vector3(
shakeOffset.X,
CameraHeight + shakeOffset.Y,
shakeOffset.Z
);击中反馈效果
当敌人被击中时,除了闪红,还可以显示伤害数字:
C
using Godot;
/// <summary>
/// 伤害数字 - 击中时飘出数字
/// </summary>
public partial class DamageNumber : Label3D
{
public void ShowDamage(Vector3 position, int damage)
{
GlobalPosition = position + Vector3.Up * 1.5f;
Text = damage.ToString();
Modulate = Colors.Red;
// 上飘+淡出
var tween = CreateTween();
tween.SetParallel(true);
tween.TweenProperty(this, "global_position:y", GlobalPosition.Y + 2.0f, 0.8f);
tween.TweenProperty(this, "modulate", new Color(1, 0, 0, 0), 0.8f);
tween.Chain().TweenCallback(Callable.From(() => QueueFree()));
}
}GDScript
# 伤害数字 - 击中时飘出数字
extends Label3D
func show_damage(position: Vector3, damage: int):
global_position = position + Vector3.UP * 1.5
text = str(damage)
modulate = Color.RED
# 上飘+淡出
var tween = create_tween()
tween.set_parallel(true)
tween.tween_property(self, "global_position:y", global_position.y + 2.0, 0.8)
tween.tween_property(self, "modulate", Color(1, 0, 0, 0), 0.8)
tween.chain().tween_callback(queue_free)特效规模对照表
不同规模的爆炸应该有不同的效果:
| 爆炸类型 | 粒子数 | 闪光强度 | 屏幕震动 | 范围 |
|---|---|---|---|---|
| 子弹命中 | 5~10 | 无 | 无 | 小 |
| 步兵死亡 | 10~15 | 弱 | 微弱(0.05) | 小 |
| 手雷爆炸 | 30~40 | 中(3.0) | 中(0.15) | 中 |
| 坦克爆炸 | 50~60 | 强(5.0) | 强(0.3) | 大 |
| 建筑倒塌 | 60~80 | 强(5.0) | 强(0.4) | 大 |
性能注意事项
| 注意点 | 说明 | 解决方案 |
|---|---|---|
| 粒子数量 | 太多粒子会卡 | 每种特效粒子数不超过60 |
| 音效同时播放 | 太多音效叠加会嘈杂 | 使用播放器池,限制同时播放数 |
| 特效残留 | 爆炸特效不清理会占内存 | 所有特效都有自动销毁计时器 |
| 光源数量 | 太多动态光源会卡 | 爆炸闪光快速衰减 |
本章检查清单
下一章
游戏有声有色了,最后一步是打磨细节、优化性能、准备发布。
