9. 音效与特效
2026/4/14大约 9 分钟
9. 俄罗斯方块——音效与特效
9.1 为什么需要音效和特效?
想象你看一部无声电影——虽然能看懂故事,但总觉得少了什么。音效和特效就是游戏的"调味料":
- 音效让操作有"手感"——移动方块时发出的"咔嗒"声、消行时的"嗖"声
- 粒子特效让画面有"冲击力"——消行时的碎片飞溅、硬降时的烟尘
- 屏幕震动让反馈有"力度感"——Tetris(消4行)时屏幕剧烈抖动
好的音效和特效不需要多么华丽,但每一个操作都应该有对应的反馈。
9.2 音效管理器
在Godot中管理音效,我们使用AudioStreamPlayer节点来播放音效。为了方便管理,我们创建一个音效管理器来统一处理所有音效的加载和播放。
C
using Godot;
/// <summary>
/// 音效管理器——统一管理所有游戏音效
/// </summary>
public partial class AudioManager : Node
{
// 音效播放器池(避免创建太多节点)
private const int MaxPlayers = 8;
private AudioStreamPlayer[] _players;
private int _currentIndex = 0;
// 音效资源字典
private Dictionary<string, AudioStream> _sfx = new();
// 背景音乐播放器
private AudioStreamPlayer _musicPlayer;
public override void _Ready()
{
// 创建音效播放器池
_players = new AudioStreamPlayer[MaxPlayers];
for (int i = 0; i < MaxPlayers; i++)
{
_players[i] = new AudioStreamPlayer();
AddChild(_players[i]);
}
// 创建音乐播放器
_musicPlayer = new AudioStreamPlayer();
_musicPlayer.Bus = "Music";
AddChild(_musicPlayer);
// 加载音效文件
LoadSFX();
}
/// <summary>
/// 加载所有音效文件
/// </summary>
private void LoadSFX()
{
_sfx["move"] = GD.Load<AudioStream>(
"res://assets/audio/sfx/move.wav");
_sfx["rotate"] = GD.Load<AudioStream>(
"res://assets/audio/sfx/rotate.wav");
_sfx["drop"] = GD.Load<AudioStream>(
"res://assets/audio/sfx/drop.wav");
_sfx["hard_drop"] = GD.Load<AudioStream>(
"res://assets/audio/sfx/hard_drop.wav");
_sfx["line_clear"] = GD.Load<AudioStream>(
"res://assets/audio/sfx/line_clear.wav");
_sfx["tetris"] = GD.Load<AudioStream>(
"res://assets/audio/sfx/tetris.wav");
_sfx["level_up"] = GD.Load<AudioStream>(
"res://assets/audio/sfx/level_up.wav");
_sfx["game_over"] = GD.Load<AudioStream>(
"res://assets/audio/sfx/game_over.wav");
_sfx["hold"] = GD.Load<AudioStream>(
"res://assets/audio/sfx/hold.wav");
}
/// <summary>
/// 播放指定音效
/// </summary>
public void PlaySFX(string name, float volumeDb = 0f, float pitch = 1f)
{
if (!_sfx.ContainsKey(name)) return;
// 从池中取一个空闲的播放器
var player = _players[_currentIndex];
_currentIndex = (_currentIndex + 1) % MaxPlayers;
player.Stream = _sfx[name];
player.VolumeDb = volumeDb;
player.PitchScale = pitch;
player.Play();
}
/// <summary>
/// 播放背景音乐
/// </summary>
public void PlayMusic(string path)
{
var music = GD.Load<AudioStream>(path);
_musicPlayer.Stream = music;
_musicPlayer.Play();
}
/// <summary>
/// 停止背景音乐
/// </summary>
public void StopMusic()
{
_musicPlayer.Stop();
}
/// <summary>
/// 设置音效总音量
/// </summary>
public void SetSFXVolume(float volumeDb)
{
foreach (var player in _players)
{
player.VolumeDb = volumeDb;
}
}
/// <summary>
/// 设置音乐音量
/// </summary>
public void SetMusicVolume(float volumeDb)
{
_musicPlayer.VolumeDb = volumeDb;
}
}GDScript
extends Node
## 音效管理器——统一管理所有游戏音效
# 音效播放器池(避免创建太多节点)
const MAX_PLAYERS: int = 8
var _players: Array = []
var _current_index: int = 0
# 音效资源字典
var _sfx: Dictionary = {}
# 背景音乐播放器
var _music_player: AudioStreamPlayer
func _ready() -> void:
# 创建音效播放器池
for i in range(MAX_PLAYERS):
var player = AudioStreamPlayer.new()
add_child(player)
_players.append(player)
# 创建音乐播放器
_music_player = AudioStreamPlayer.new()
_music_player.bus = "Music"
add_child(_music_player)
# 加载音效文件
_load_sfx()
## 加载所有音效文件
func _load_sfx() -> void:
_sfx["move"] = load("res://assets/audio/sfx/move.wav")
_sfx["rotate"] = load("res://assets/audio/sfx/rotate.wav")
_sfx["drop"] = load("res://assets/audio/sfx/drop.wav")
_sfx["hard_drop"] = load("res://assets/audio/sfx/hard_drop.wav")
_sfx["line_clear"] = load("res://assets/audio/sfx/line_clear.wav")
_sfx["tetris"] = load("res://assets/audio/sfx/tetris.wav")
_sfx["level_up"] = load("res://assets/audio/sfx/level_up.wav")
_sfx["game_over"] = load("res://assets/audio/sfx/game_over.wav")
_sfx["hold"] = load("res://assets/audio/sfx/hold.wav")
## 播放指定音效
func play_sfx(name: String, volume_db: float = 0.0, pitch: float = 1.0) -> void:
if not _sfx.has(name):
return
# 从池中取一个播放器
var player = _players[_current_index]
_current_index = (_current_index + 1) % MAX_PLAYERS
player.stream = _sfx[name]
player.volume_db = volume_db
player.pitch_scale = pitch
player.play()
## 播放背景音乐
func play_music(path: String) -> void:
var music = load(path)
_music_player.stream = music
_music_player.play()
## 停止背景音乐
func stop_music() -> void:
_music_player.stop()
## 设置音效总音量
func set_sfx_volume(volume_db: float) -> void:
for player in _players:
player.volume_db = volume_db
## 设置音乐音量
func set_music_volume(volume_db: float) -> void:
_music_player.volume_db = volume_db9.3 音效触发时机
每个操作都有对应的音效,下面是完整的音效触发表:
| 事件 | 音效名称 | 音量 | 说明 |
|---|---|---|---|
| 方块左/右移动 | move | -5dB | 轻微的咔嗒声 |
| 方块旋转 | rotate | -3dB | 快速的旋转声 |
| 软降一格 | drop | -5dB | 滴答声 |
| 硬降 | hard_drop | 0dB | 沉重的落地声 |
| Hold暂存 | hold | -3dB | 存取的提示声 |
| 消1-3行 | line_clear | 0dB | 消除的嗖声 |
| 消4行(Tetris) | tetris | 3dB | 更强烈的效果声 |
| 升级 | level_up | 3dB | 欢快的升级声 |
| 游戏结束 | game_over | 0dB | 低沉的结束声 |
C
/// <summary>
/// 音效触发——在GameBoard中调用
/// </summary>
public partial class GameBoard : Node2D
{
private AudioManager _audioManager;
public override void _Ready()
{
_audioManager = GetNode<AudioManager>("../AudioManager");
}
/// <summary>
/// 方块移动时播放音效
/// </summary>
private void OnPieceMoved()
{
_audioManager.PlaySFX("move", -5f);
}
/// <summary>
/// 方块旋转时播放音效
/// </summary>
private void OnPieceRotated()
{
_audioManager.PlaySFX("rotate", -3f);
}
/// <summary>
/// 硬降时播放音效
/// </summary>
private void OnHardDrop()
{
_audioManager.PlaySFX("hard_drop", 0f);
// 触发屏幕震动
ScreenShake.Instance.Shake(3f, 0.15f);
}
/// <summary>
/// 消行时播放音效
/// </summary>
private void OnLinesCleared(int lineCount)
{
if (lineCount >= 4)
{
// Tetris!用更强烈的音效
_audioManager.PlaySFX("tetris", 3f);
// 更强的屏幕震动
ScreenShake.Instance.Shake(6f, 0.3f);
}
else
{
_audioManager.PlaySFX("line_clear", 0f);
ScreenShake.Instance.Shake(2f, 0.1f);
}
}
/// <summary>
/// 升级时播放音效
/// </summary>
private void OnLevelUp(int newLevel)
{
_audioManager.PlaySFX("level_up", 3f);
}
/// <summary>
/// 游戏结束时播放音效
/// </summary>
private void OnGameOver()
{
_audioManager.PlaySFX("game_over", 0f);
// 停止背景音乐
_audioManager.StopMusic();
}
}GDScript
## 音效触发——在GameBoard中调用
extends Node2D
var _audio_manager: AudioManager
func _ready() -> void:
_audio_manager = get_node("../AudioManager")
## 方块移动时播放音效
func _on_piece_moved() -> void:
_audio_manager.play_sfx("move", -5.0)
## 方块旋转时播放音效
func _on_piece_rotated() -> void:
_audio_manager.play_sfx("rotate", -3.0)
## 硬降时播放音效
func _on_hard_drop() -> void:
_audio_manager.play_sfx("hard_drop", 0.0)
# 触发屏幕震动
ScreenShake.shake(3.0, 0.15)
## 消行时播放音效
func _on_lines_cleared(line_count: int) -> void:
if line_count >= 4:
# Tetris!用更强烈的音效
_audio_manager.play_sfx("tetris", 3.0)
# 更强的屏幕震动
ScreenShake.shake(6.0, 0.3)
else:
_audio_manager.play_sfx("line_clear", 0.0)
ScreenShake.shake(2.0, 0.1)
## 升级时播放音效
func _on_level_up(new_level: int) -> void:
_audio_manager.play_sfx("level_up", 3.0)
## 游戏结束时播放音效
func _on_game_over() -> void:
_audio_manager.play_sfx("game_over", 0.0)
# 停止背景音乐
_audio_manager.stop_music()9.4 粒子特效系统
粒子特效让消行和硬降等操作更有视觉冲击力。Godot内置了强大的GPUParticles2D粒子系统。
9.4.1 消行粒子效果
当行被消除时,该行的方块碎片向四周飞溅。
C
using Godot;
/// <summary>
/// 粒子特效管理器
/// </summary>
public partial class VFXManager : Node
{
// 预加载的粒子场景
private PackedScene _lineClearParticles;
private PackedScene _hardDropParticles;
private PackedScene _levelUpParticles;
public override void _Ready()
{
// 加载粒子场景
_lineClearParticles = GD.Load<PackedScene>(
"res://scenes/vfx/line_clear_particles.tscn");
_hardDropParticles = GD.Load<PackedScene>(
"res://scenes/vfx/hard_drop_particles.tscn");
_levelUpParticles = GD.Load<PackedScene>(
"res://scenes/vfx/level_up_particles.tscn");
}
/// <summary>
/// 在指定行播放消行粒子效果
/// </summary>
public void PlayLineClearEffect(int lineY, Node2D parent)
{
var particles = _lineClearParticles.Instantiate<GpuParticles2D>();
particles.Position = new Vector2(
GameConstants.GridColumns * GameConstants.CellSize / 2f,
lineY * GameConstants.CellSize + GameConstants.CellSize / 2f
);
// 设置粒子参数
var material = (ParticleProcessMaterial)particles.ProcessMaterial;
material.EmissionBoxExtents = new Vector3(
GameConstants.GridColumns * GameConstants.CellSize / 2f,
GameConstants.CellSize / 2f,
1
);
parent.AddChild(particles);
// 自动销毁
var timer = GetTree().CreateTimer(1.0f);
timer.Timeout += () => particles.QueueFree();
}
/// <summary>
/// 在硬降位置播放冲击粒子
/// </summary>
public void PlayHardDropEffect(Vector2 position, Node2D parent)
{
var particles = _hardDropParticles.Instantiate<GpuParticles2D>();
particles.Position = position;
parent.AddChild(particles);
// 自动销毁
var timer = GetTree().CreateTimer(0.5f);
timer.Timeout += () => particles.QueueFree();
}
/// <summary>
/// 播放升级粒子效果
/// </summary>
public void PlayLevelUpEffect(Node2D parent)
{
var particles = _levelUpParticles.Instantiate<GpuParticles2D>();
particles.Position = new Vector2(
GameConstants.GridColumns * GameConstants.CellSize / 2f,
GameConstants.GridRows * GameConstants.CellSize / 2f
);
parent.AddChild(particles);
var timer = GetTree().CreateTimer(2.0f);
timer.Timeout += () => particles.QueueFree();
}
}GDScript
extends Node
## 粒子特效管理器
# 预加载的粒子场景
var _line_clear_particles: PackedScene
var _hard_drop_particles: PackedScene
var _level_up_particles: PackedScene
func _ready() -> void:
# 加载粒子场景
_line_clear_particles = load(
"res://scenes/vfx/line_clear_particles.tscn")
_hard_drop_particles = load(
"res://scenes/vfx/hard_drop_particles.tscn")
_level_up_particles = load(
"res://scenes/vfx/level_up_particles.tscn")
## 在指定行播放消行粒子效果
func play_line_clear_effect(line_y: int, parent: Node2D) -> void:
var particles = _line_clear_particles.instantiate() as GPUParticles2D
particles.position = Vector2(
GameConstants.GRID_COLUMNS * GameConstants.CELL_SIZE / 2.0,
line_y * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2.0
)
# 设置粒子参数
var material = particles.process_material as ParticleProcessMaterial
material.emission_box_extents = Vector3(
GameConstants.GRID_COLUMNS * GameConstants.CELL_SIZE / 2.0,
GameConstants.CELL_SIZE / 2.0,
1.0
)
parent.add_child(particles)
# 自动销毁
var timer = get_tree().create_timer(1.0)
timer.timeout.connect(particles.queue_free)
## 在硬降位置播放冲击粒子
func play_hard_drop_effect(pos: Vector2, parent: Node2D) -> void:
var particles = _hard_drop_particles.instantiate() as GPUParticles2D
particles.position = pos
parent.add_child(particles)
var timer = get_tree().create_timer(0.5)
timer.timeout.connect(particles.queue_free)
## 播放升级粒子效果
func play_level_up_effect(parent: Node2D) -> void:
var particles = _level_up_particles.instantiate() as GPUParticles2D
particles.position = Vector2(
GameConstants.GRID_COLUMNS * GameConstants.CELL_SIZE / 2.0,
GameConstants.GRID_ROWS * GameConstants.CELL_SIZE / 2.0
)
parent.add_child(particles)
var timer = get_tree().create_timer(2.0)
timer.timeout.connect(particles.queue_free)9.4.2 创建粒子场景
在Godot编辑器中创建粒子场景的步骤:
- 新建场景 → 选择 Node2D 作为根节点
- 添加子节点 → GPUParticles2D
- 配置粒子参数:
| 参数 | 消行粒子 | 硬降粒子 | 升级粒子 |
|---|---|---|---|
| Amount | 50 | 20 | 100 |
| Lifetime | 0.5-1.0s | 0.3s | 1.5s |
| Speed | 100-200 | 50-100 | 200-300 |
| Direction | 随机 | 向上 | 向上扩散 |
| Color | 彩虹色 | 白色 | 金色 |
| Size | 2-6px | 3-5px | 4-8px |
9.5 屏幕震动
屏幕震动(Screen Shake) 是游戏开发中非常常用的反馈技巧。当发生"大事"(硬降、消4行)时,让整个画面抖一下,增强冲击感。
C
using Godot;
/// <summary>
/// 屏幕震动控制器
/// </summary>
public partial class ScreenShake : Node
{
// 单例模式
public static ScreenShake Instance { get; private set; }
// 震动的目标节点(通常是Camera2D或游戏根节点)
private Node2D _target;
private Vector2 _originalPosition;
// 震动参数
private float _strength = 0f;
private float _duration = 0f;
private float _timer = 0f;
// 随机数生成器
private Random _random = new();
public override void _Ready()
{
Instance = this;
_target = GetParent() as Node2D;
if (_target != null)
{
_originalPosition = _target.Position;
}
}
public override void _Process(double delta)
{
if (_timer <= 0f) return;
_timer -= (float)delta;
// 生成随机偏移
float offsetX = _random.NextSingle() * 2f - 1f;
float offsetY = _random.NextSingle() * 2f - 1f;
// 强度随时间衰减
float currentStrength = _strength * (_timer / _duration);
_target.Position = _originalPosition + new Vector2(
offsetX * currentStrength,
offsetY * currentStrength
);
// 震动结束,恢复原位
if (_timer <= 0f)
{
_target.Position = _originalPosition;
}
}
/// <summary>
/// 触发屏幕震动
/// </summary>
/// <param name="strength">震动强度(像素)</param>
/// <param name="duration">震动持续时间(秒)</param>
public void Shake(float strength, float duration)
{
_strength = strength;
_duration = duration;
_timer = duration;
}
}GDScript
extends Node
## 屏幕震动控制器
# 单例模式
static var instance: ScreenShake
# 震动的目标节点
var _target: Node2D
var _original_position: Vector2
# 震动参数
var _strength: float = 0.0
var _duration: float = 0.0
var _timer: float = 0.0
func _ready() -> void:
instance = self
_target = get_parent() as Node2D
if _target != null:
_original_position = _target.position
func _process(delta: float) -> void:
if _timer <= 0.0:
return
_timer -= delta
# 生成随机偏移
var offset_x = randf() * 2.0 - 1.0
var offset_y = randf() * 2.0 - 1.0
# 强度随时间衰减
var current_strength = _strength * (_timer / _duration)
_target.position = _original_position + Vector2(
offset_x * current_strength,
offset_y * current_strength
)
# 震动结束,恢复原位
if _timer <= 0.0:
_target.position = _original_position
## 触发屏幕震动
## strength: 震动强度(像素)
## duration: 震动持续时间(秒)
static func shake(strength: float, duration: float) -> void:
if instance == null:
return
instance._strength = strength
instance._duration = duration
instance._timer = duration9.6 方块锁定特效
方块落定时,加一个短暂的"闪光"效果,让锁定感觉更明确。
C
/// <summary>
/// 方块锁定闪光效果
/// </summary>
public partial class LockFlash : Node2D
{
private float _duration = 0.15f;
public void ShowFlash(int[,] shape, int gridX, int gridY, Color color)
{
int rows = shape.GetLength(0);
int cols = shape.GetLength(1);
Position = new Vector2(
gridX * GameConstants.CellSize,
gridY * GameConstants.CellSize
);
// 创建白色闪光
var flashColor = new Color(1, 1, 1, 0.8f);
var tween = CreateTween();
tween.TweenCallback(Callable.From(QueueRedraw));
tween.TweenProperty(this, "modulate:a", 0.0f, _duration);
tween.TweenCallback(Callable.From(QueueFree));
}
public override void _Draw()
{
// 白色闪光覆盖在方块上
// 具体绘制逻辑取决于当前方块形状
}
}GDScript
extends Node2D
## 方块锁定闪光效果
var _duration: float = 0.15
func show_flash(shape: Array, grid_x: int, grid_y: int, color: Color) -> void:
position = Vector2(
grid_x * GameConstants.CELL_SIZE,
grid_y * GameConstants.CELL_SIZE
)
# 创建白色闪光
modulate = Color(1, 1, 1, 0.8)
var tween = create_tween()
tween.tween_callback(queue_redraw)
tween.tween_property(self, "modulate:a", 0.0, _duration)
tween.tween_callback(queue_free)
func _draw() -> void:
# 白色闪光覆盖在方块上
pass9.7 音效资源获取
如果你没有现成的音效素材,可以使用以下免费资源网站获取:
| 网站 | 说明 |
|---|---|
| freesound.org | 大量免费音效,CC协议 |
| kenney.nl/assets/category:Audio | 游戏音效包,免费商用 |
| opengameart.org | 开源游戏美术资源 |
| bfxr.net | 在线生成复古游戏音效 |
9.8 本章小结
| 组件 | 说明 |
|---|---|
| AudioManager | 统一管理音效和音乐的加载与播放 |
| 音效触发表 | 每个操作对应不同的音效 |
| VFXManager | 管理粒子特效的创建和销毁 |
| GPUParticles2D | Godot内置的粒子系统 |
| ScreenShake | 屏幕震动效果,增强冲击感 |
| LockFlash | 方块锁定时的闪光反馈 |
关键设计点:
- 音效使用对象池避免频繁创建节点
- 粒子特效播放后自动销毁,不会造成内存泄漏
- 屏幕震动的强度和持续时间根据事件类型分级
- 所有特效都可以通过设置菜单关闭
下一章我们将进行最后的打磨和发布准备。
