9. 音效与特效
2026/4/14大约 9 分钟
开心消消乐——音效与特效
为什么需要音效与特效?
想象你看一部没有声音也没有特效的电影——虽然故事还在,但总感觉少了点什么。游戏也一样:音效让操作有"手感",特效让消除有"爽感"。这两者合在一起,就是游戏的"调味料"——菜本身已经很好吃了,加上调味料就更上一层楼。
音效的作用
| 音效类型 | 作用 | 比喻 |
|---|---|---|
| 点击音效 | 确认操作已生效 | 按门锁的"咔嗒"声 |
| 消除音效 | 正反馈——你做对了! | 踩到气泡的"噗"声 |
| 连击音效 | 加强爽感 | 打连击时的"嗖嗖"声 |
| 特殊方块音效 | 强化"特殊感" | 释放大招的轰鸣声 |
| 过关音效 | 成就感 | 赢得比赛后的欢呼声 |
音频管理器
在 Godot 中管理音效,推荐创建一个单例音频管理器。它就像一个"DJ控制台"——所有音效都通过它来播放,方便统一管理音量、暂停和切换。
C
using Godot;
/// <summary>
/// 音频管理器(单例)
/// 统一管理所有游戏音效的播放
/// </summary>
public partial class AudioManager : Node
{
/// <summary>单例实例</summary>
public static AudioManager Instance { get; private set; }
// ===== 音效资源 =====
/// <summary>点击音效</summary>
[Export] public AudioStream ClickSound { get; set; }
/// <summary>交换音效</summary>
[Export] public AudioStream SwapSound { get; set; }
/// <summary>消除音效(3消)</summary>
[Export] public AudioStream MatchSound { get; set; }
/// <summary>4消音效</summary>
[Export] public AudioStream Match4Sound { get; set; }
/// <summary>5消音效</summary>
[Export] public AudioStream Match5Sound { get; set; }
/// <summary>连击音效</summary>
[Export] public AudioStream ComboSound { get; set; }
/// <summary>无效交换音效</summary>
[Export] public AudioStream InvalidSwapSound { get; set; }
/// <summary>过关音效</summary>
[Export] public AudioStream WinSound { get; set; }
/// <summary>失败音效</summary>
[Export] public AudioStream LoseSound { get; set; }
/// <summary>背景音乐</summary>
[Export] public AudioStream BGMusic { get; set; }
// ===== 音量设置 =====
/// <summary>主音量(0.0 - 1.0)</summary>
[Export] public float MasterVolume { get; set; } = 1.0f;
/// <summary>音效音量(0.0 - 1.0)</summary>
[Export] public float SFXVolume { get; set; } = 0.8f;
/// <summary>背景音乐音量(0.0 - 1.0)</summary>
[Export] public float MusicVolume { get; set; } = 0.5f;
// ===== 音频播放器 =====
private AudioStreamPlayer _sfxPlayer;
private AudioStreamPlayer _musicPlayer;
public override void _EnterTree()
{
// 设置为单例
if (Instance == null)
{
Instance = this;
}
else
{
QueueFree(); // 已有实例,销毁重复的
}
}
public override void _Ready()
{
// 创建音频播放器
_sfxPlayer = new AudioStreamPlayer();
_sfxPlayer.Bus = "SFX";
AddChild(_sfxPlayer);
_musicPlayer = new AudioStreamPlayer();
_musicPlayer.Bus = "Music";
AddChild(_musicPlayer);
// 设置音量
UpdateVolumes();
GD.Print("[AudioManager] 音频管理器初始化完成");
}
// ===== 音效播放方法 =====
/// <summary>播放点击音效</summary>
public void PlayClick() => PlaySFX(ClickSound);
/// <summary>播放交换音效</summary>
public void PlaySwap() => PlaySFX(SwapSound);
/// <summary>播放消除音效(根据消除数量)</summary>
public void PlayMatch(int count)
{
if (count >= 5) PlaySFX(Match5Sound);
else if (count >= 4) PlaySFX(Match4Sound);
else PlaySFX(MatchSound);
}
/// <summary>播放连击音效</summary>
public void PlayCombo() => PlaySFX(ComboSound);
/// <summary>播放无效交换音效</summary>
public void PlayInvalidSwap() => PlaySFX(InvalidSwapSound);
/// <summary>播放过关音效</summary>
public void PlayWin() => PlaySFX(WinSound);
/// <summary>播放失败音效</summary>
public void PlayLose() => PlaySFX(LoseSound);
// ===== 背景音乐控制 =====
/// <summary>播放背景音乐</summary>
public void PlayMusic()
{
if (_musicPlayer != null && BGMusic != null)
{
_musicPlayer.Stream = BGMusic;
_musicPlayer.Play();
}
}
/// <summary>停止背景音乐</summary>
public void StopMusic()
{
_musicPlayer?.Stop();
}
// ===== 内部方法 =====
/// <summary>
/// 播放音效(通用方法)
/// </summary>
private void PlaySFX(AudioStream sound)
{
if (sound == null) return;
_sfxPlayer.Stream = sound;
_sfxPlayer.VolumeDb = Mathf.LinearToDb(SFXVolume * MasterVolume);
_sfxPlayer.Play();
}
/// <summary>更新所有音量</summary>
private void UpdateVolumes()
{
if (_musicPlayer != null)
{
_musicPlayer.VolumeDb = Mathf.LinearToDb(MusicVolume * MasterVolume);
}
}
}GDScript
extends Node
## 单例实例
static var instance: AudioManager
# ===== 音效资源 =====
## 点击音效
@export var click_sound: AudioStream
## 交换音效
@export var swap_sound: AudioStream
## 消除音效(3消)
@export var match_sound: AudioStream
## 4消音效
@export var match4_sound: AudioStream
## 5消音效
@export var match5_sound: AudioStream
## 连击音效
@export var combo_sound: AudioStream
## 无效交换音效
@export var invalid_swap_sound: AudioStream
## 过关音效
@export var win_sound: AudioStream
## 失败音效
@export var lose_sound: AudioStream
## 背景音乐
@export var bg_music: AudioStream
# ===== 音量设置 =====
## 主音量(0.0 - 1.0)
@export var master_volume: float = 1.0
## 音效音量(0.0 - 1.0)
@export var sfx_volume: float = 0.8
## 背景音乐音量(0.0 - 1.0)
@export var music_volume: float = 0.5
# ===== 音频播放器 =====
var _sfx_player: AudioStreamPlayer
var _music_player: AudioStreamPlayer
func _enter_tree() -> void:
# 设置为单例
if instance == null:
instance = self
else:
queue_free() # 已有实例,销毁重复的
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)
# 设置音量
_update_volumes()
print("[AudioManager] 音频管理器初始化完成")
# ===== 音效播放方法 =====
## 播放点击音效
func play_click() -> void:
_play_sfx(click_sound)
## 播放交换音效
func play_swap() -> void:
_play_sfx(swap_sound)
## 播放消除音效(根据消除数量)
func play_match(count: int) -> void:
if count >= 5:
_play_sfx(match5_sound)
elif count >= 4:
_play_sfx(match4_sound)
else:
_play_sfx(match_sound)
## 播放连击音效
func play_combo() -> void:
_play_sfx(combo_sound)
## 播放无效交换音效
func play_invalid_swap() -> void:
_play_sfx(invalid_swap_sound)
## 播放过关音效
func play_win() -> void:
_play_sfx(win_sound)
## 播放失败音效
func play_lose() -> void:
_play_sfx(lose_sound)
# ===== 背景音乐控制 =====
## 播放背景音乐
func play_music() -> void:
if _music_player != null and bg_music != null:
_music_player.stream = bg_music
_music_player.play()
## 停止背景音乐
func stop_music() -> void:
if _music_player != null:
_music_player.stop()
# ===== 内部方法 =====
## 播放音效(通用方法)
func _play_sfx(sound: AudioStream) -> void:
if sound == null:
return
_sfx_player.stream = sound
_sfx_player.volume_db = linear_to_db(sfx_volume * master_volume)
_sfx_player.play()
## 更新所有音量
func _update_volumes() -> void:
if _music_player != null:
_music_player.volume_db = linear_to_db(music_volume * master_volume)音频总线设置
在 Godot 编辑器中,打开 项目 → 项目设置 → 音频总线,添加以下总线:
| 总线名称 | 说明 | 默认音量 |
|---|---|---|
| Master | 主总线(控制所有声音) | 0 dB |
| Music | 背景音乐 | -6 dB |
| SFX | 音效 | -3 dB |
设置总线后,玩家可以在游戏中独立调节音乐和音效的音量。
粒子特效
Godot 内置了强大的 GPUParticles2D 粒子系统。我们可以用它来做消除时的"爆炸"效果——一堆小光点从消除位置向四周散开。
消除粒子特效
C
using Godot;
/// <summary>
/// 粒子特效管理器
/// 管理消除、连击等粒子效果
/// </summary>
public partial class ParticleManager : Node
{
/// <summary>消除粒子场景</summary>
[Export] public PackedScene MatchParticleScene { get; set; }
/// <summary>连击粒子场景</summary>
[Export] public PackedScene ComboParticleScene { get; set; }
/// <summary>特殊方块粒子场景</summary>
[Export] public PackedScene SpecialParticleScene { get; set; }
/// <summary>
/// 在指定位置播放消除粒子效果
/// </summary>
/// <param name="position">世界坐标</param>
/// <param name="color">粒子颜色</param>
public void EmitMatchParticles(Vector2 position, Color color)
{
if (MatchParticleScene == null) return;
var particles = MatchParticleScene.Instantiate<GPUParticles2D>();
particles.Position = position;
// 设置粒子颜色
var mat = particles.ProcessMaterial as ParticleProcessMaterial;
if (mat != null)
{
mat.ColorRamp = CreateColorRamp(color);
}
AddChild(particles);
// 播放一次后自动销毁
particles.Emitting = true;
particles.OneShot = true;
// 粒子播放完毕后自动销毁
var timer = GetTree().CreateTimer(2.0);
timer.Timeout += () => particles.QueueFree();
}
/// <summary>
/// 在指定位置播放连击粒子效果
/// </summary>
public void EmitComboParticles(Vector2 position, int combo)
{
if (ComboParticleScene == null) return;
var particles = ComboParticleScene.Instantiate<GPUParticles2D>();
particles.Position = position;
// 连击越高,粒子越多、范围越大
particles.Amount = 20 + combo * 10;
particles.Scale = Vector2.One * (1.0f + combo * 0.2f);
AddChild(particles);
particles.Emitting = true;
particles.OneShot = true;
var timer = GetTree().CreateTimer(2.0);
timer.Timeout += () => particles.QueueFree();
}
/// <summary>
/// 创建颜色渐变
/// </summary>
private GradientTexture1D CreateColorRamp(Color color)
{
var gradient = new Gradient();
gradient.AddPoint(0.0f, color);
gradient.AddPoint(0.5f, new Color(color.R, color.G, color.B, 0.5f));
gradient.AddPoint(1.0f, new Color(color, 0.0f));
var texture = new GradientTexture1D();
texture.Gradient = gradient;
texture.Width = 256;
return texture;
}
}GDScript
extends Node
## 消除粒子场景
@export var match_particle_scene: PackedScene
## 连击粒子场景
@export var combo_particle_scene: PackedScene
## 特殊方块粒子场景
@export var special_particle_scene: PackedScene
## 在指定位置播放消除粒子效果
## position: 世界坐标, color: 粒子颜色
func emit_match_particles(position: Vector2, color: Color) -> void:
if match_particle_scene == null:
return
var particles: GPUParticles2D = match_particle_scene.instantiate()
particles.position = position
# 设置粒子颜色
var mat = particles.process_material as ParticleProcessMaterial
if mat != null:
mat.color_ramp = _create_color_ramp(color)
add_child(particles)
# 播放一次后自动销毁
particles.emitting = true
particles.one_shot = true
# 粒子播放完毕后自动销毁
var timer := get_tree().create_timer(2.0)
timer.timeout.connect(particles.queue_free)
## 在指定位置播放连击粒子效果
func emit_combo_particles(position: Vector2, combo: int) -> void:
if combo_particle_scene == null:
return
var particles: GPUParticles2D = combo_particle_scene.instantiate()
particles.position = position
# 连击越高,粒子越多、范围越大
particles.amount = 20 + combo * 10
particles.scale = Vector2.ONE * (1.0 + combo * 0.2)
add_child(particles)
particles.emitting = true
particles.one_shot = true
var timer := get_tree().create_timer(2.0)
timer.timeout.connect(particles.queue_free)
## 创建颜色渐变
func _create_color_ramp(color: Color) -> GradientTexture1D:
var gradient := Gradient.new()
gradient.add_point(0.0, color)
gradient.add_point(0.5, Color(color.r, color.g, color.b, 0.5))
gradient.add_point(1.0, Color(color, 0.0))
var texture := GradientTexture1D.new()
texture.gradient = gradient
texture.width = 256
return texture屏幕震动
屏幕震动是游戏开发中常用的"打击感"增强手段。当发生连击或特殊方块触发时,整个画面轻微抖动,让玩家感受到"力量感"。
C
using Godot;
/// <summary>
/// 屏幕震动控制器
/// </summary>
public partial class ScreenShake : Camera2D
{
/// <summary>震动强度</summary>
private float _shakeStrength = 0f;
/// <summary>震动持续时间</summary>
private float _shakeDuration = 0f;
/// <summary>剩余震动时间</summary>
private float _shakeTimer = 0f;
/// <summary>随机数生成器</summary>
private static readonly Random _random = new Random();
public override void _Process(double delta)
{
if (_shakeTimer > 0)
{
_shakeTimer -= (float)delta;
// 生成随机偏移
float offsetX = _random.NextSingle() * 2 - 1 * _shakeStrength;
float offsetY = _random.NextSingle() * 2 - 1 * _shakeStrength;
Offset = new Vector2(offsetX, offsetY);
// 震动强度随时间衰减
_shakeStrength *= 0.9f;
}
else
{
// 震动结束,复位
Offset = Vector2.Zero;
}
}
/// <summary>
/// 开始屏幕震动
/// </summary>
/// <param name="strength">震动强度(像素)</param>
/// <param name="duration">震动持续时间(秒)</param>
public void Shake(float strength, float duration)
{
_shakeStrength = strength;
_shakeDuration = duration;
_shakeTimer = duration;
GD.Print($"[ScreenShake] 震动: 强度={strength}, 持续={duration}秒");
}
/// <summary>
/// 根据连击数自动调整震动参数
/// </summary>
public void ComboShake(int combo)
{
float strength = Mathf.Min(3 + combo * 2, 15); // 最大15像素
float duration = Mathf.Min(0.1f + combo * 0.05f, 0.5f); // 最大0.5秒
Shake(strength, duration);
}
}GDScript
extends Camera2D
## 震动强度
var _shake_strength: float = 0.0
## 震动持续时间
var _shake_duration: float = 0.0
## 剩余震动时间
var _shake_timer: float = 0.0
func _process(delta: float) -> void:
if _shake_timer > 0:
_shake_timer -= delta
# 生成随机偏移
var offset_x: float = randf_range(-1, 1) * _shake_strength
var offset_y: float = randf_range(-1, 1) * _shake_strength
offset = Vector2(offset_x, offset_y)
# 震动强度随时间衰减
_shake_strength *= 0.9
else:
# 震动结束,复位
offset = Vector2.ZERO
## 开始屏幕震动
## strength: 震动强度(像素), duration: 震动持续时间(秒)
func shake(strength: float, duration: float) -> void:
_shake_strength = strength
_shake_duration = duration
_shake_timer = duration
print("[ScreenShake] 震动: 强度=%d, 持续=%.1f秒" % [strength, duration])
## 根据连击数自动调整震动参数
func combo_shake(combo: int) -> void:
var strength: float = minf(3.0 + combo * 2.0, 15.0)
var duration: float = minf(0.1 + combo * 0.05, 0.5)
shake(strength, duration)在游戏流程中集成音效和特效
最后,把音效和特效集成到游戏的主流程中。
C
/// <summary>
/// 处理匹配消除(集成音效和特效的版本)
/// </summary>
public async void ProcessMatchesWithEffects(List<MatchInfo> matches)
{
_gameManager.CurrentState = GameState.Removing;
int totalScore = 0;
int combo = _gameManager.GetCombo() + 1;
var cellsToRemove = new HashSet<Vector2I>();
foreach (var match in matches)
{
foreach (var cell in match.Cells)
{
cellsToRemove.Add(cell);
}
totalScore += match.Length * 50;
}
int comboMultiplier = Mathf.Min(combo, 4);
totalScore *= comboMultiplier;
// ---- 播放音效 ----
if (combo > 1)
{
AudioManager.Instance.PlayCombo();
}
AudioManager.Instance.PlayMatch(cellsToRemove.Count);
// ---- 播放粒子特效 ----
foreach (var cell in cellsToRemove)
{
Vector2 pos = GridToPixel(cell.X, cell.Y);
Color color = GetPieceColor(cell.X, cell.Y);
ParticleManager.Instance.EmitMatchParticles(pos, color);
}
// ---- 屏幕震动 ----
if (combo >= 2)
{
ScreenShake.Instance.ComboShake(combo);
}
// ---- 消除动画 ----
await PlayRemoveAnimation(cellsToRemove);
foreach (var cell in cellsToRemove)
{
RemovePiece(cell.X, cell.Y);
}
_gameManager.AddScore(totalScore);
ApplyGravity();
}GDScript
## 处理匹配消除(集成音效和特效的版本)
func _process_matches_with_effects(matches: Array) -> void:
_game_manager.current_state = GameManager.GameState.REMOVING
var total_score: int = 0
var combo: int = _game_manager.get_combo() + 1
var cells_to_remove: Dictionary = {}
for match_info in matches:
for cell in match_info.cells:
cells_to_remove[cell] = true
total_score += match_info.length * 50
var combo_multiplier: int = mini(combo, 4)
total_score *= combo_multiplier
# ---- 播放音效 ----
if combo > 1:
AudioManager.instance.play_combo()
AudioManager.instance.play_match(cells_to_remove.size())
# ---- 播放粒子特效 ----
for cell in cells_to_remove.keys():
var pos: Vector2 = grid_to_pixel(cell.x, cell.y)
var color: Color = _get_piece_color(cell.x, cell.y)
AudioManager.instance.emit_match_particles(pos, color)
# ---- 屏幕震动 ----
if combo >= 2:
ScreenShake.instance.combo_shake(combo)
# ---- 消除动画 ----
await _play_remove_animation(cells_to_remove.keys())
for cell in cells_to_remove.keys():
_remove_piece(cell.x, cell.y)
_game_manager.add_score(total_score)
apply_gravity()本章小结
| 完成项 | 说明 |
|---|---|
| 音频管理器 | 单例模式,统一管理所有音效播放 |
| 音效类型 | 点击、交换、消除、连击、特殊、胜负 |
| 音频总线 | Master、Music、SFX 独立控制 |
| 粒子特效 | 消除时散开的彩色光点 |
| 连击粒子 | 连击越高,粒子越多越大 |
| 屏幕震动 | 连击和特殊方块触发时的画面抖动 |
| 效果集成 | 音效、粒子、震动在游戏流程中协同工作 |
现在游戏有了完整的视听体验。最后一章,我们将处理打磨与发布——无解检测、最终优化和导出。
