9. 音效与打击感
2026/4/14大约 5 分钟
9. 音效与打击感
9.1 横版动作游戏的音效特点
横版动作游戏的音效和RPG完全不同。RPG的音效是"配合菜单操作",而横版动作的音效是让玩家感受到打击感。
| 类别 | 示例 | 作用 |
|---|---|---|
| 打击音效 | 普攻1击、2击、3击、4击 | 每一击都有不同音调,连招有节奏感 |
| 技能音效 | 战士冲锋、法师爆炸、盗贼闪现 | 技能释放的爽快感 |
| 受击音效 | 普通受击、被击飞、倒地 | 让玩家感受到"打到了" |
| 环境音效 | 地下城背景音、火把声、水滴声 | 沉浸感 |
| UI音效 | 区域清空、救援成功、关卡通关 | 操作反馈 |
| 背景音乐 | 普通关卡BGM、Boss战BGM | 情绪引导 |
打击感的核心:音效 + 画面停顿(Hit Stop)+ 屏幕震动,三者缺一不可。
9.2 音效管理器
C
using Godot;
/// <summary>
/// 地下城与勇士音效管理器
/// </summary>
public partial class DnfAudioManager : Node
{
public static DnfAudioManager Instance { get; private set; }
// ===== 打击音效(连招各击音调不同)=====
[Export] public AudioStream[] ComboHitSfx { get; set; } // 4个连击音效
[Export] public AudioStream SkillHitSfx { get; set; } // 技能命中
[Export] public AudioStream LaunchHitSfx { get; set; } // 击飞命中
// ===== 受击音效 =====
[Export] public AudioStream TakeDamageSfx { get; set; }
[Export] public AudioStream KnockDownSfx { get; set; } // 倒地
[Export] public AudioStream ReviveSfx { get; set; } // 被救援
// ===== 技能音效 =====
[Export] public AudioStream WarriorChargeSfx { get; set; }
[Export] public AudioStream MageExplosionSfx { get; set; }
[Export] public AudioStream RogueFlashSfx { get; set; }
[Export] public AudioStream PaladinShieldSfx { get; set; }
// ===== 关卡音效 =====
[Export] public AudioStream ZoneClearedSfx { get; set; } // 区域清空
[Export] public AudioStream BossEnterSfx { get; set; } // Boss登场
[Export] public AudioStream StageClearSfx { get; set; } // 关卡通关
// ===== 背景音乐 =====
[Export] public AudioStream StageBgm { get; set; }
[Export] public AudioStream BossBgm { get; set; }
private AudioStreamPlayer _bgmPlayer;
private AudioStreamPlayer[] _sfxPool;
private int _poolIndex = 0;
private const int PoolSize = 8;
public override void _EnterTree() => Instance = this;
public override void _ExitTree() { if (Instance == this) Instance = null; }
public override void _Ready()
{
_bgmPlayer = new AudioStreamPlayer { Bus = "Music" };
AddChild(_bgmPlayer);
_sfxPool = new AudioStreamPlayer[PoolSize];
for (int i = 0; i < PoolSize; i++)
{
_sfxPool[i] = new AudioStreamPlayer { Bus = "SFX" };
AddChild(_sfxPool[i]);
}
}
/// <summary>
/// 播放连招音效(comboIndex 0~3 对应四连击)
/// </summary>
public void PlayComboHit(int comboIndex)
{
if (ComboHitSfx == null || comboIndex >= ComboHitSfx.Length) return;
PlaySfx(ComboHitSfx[comboIndex]);
}
public void PlaySkillHit() => PlaySfx(SkillHitSfx);
public void PlayLaunchHit() => PlaySfx(LaunchHitSfx);
public void PlayTakeDamage() => PlaySfx(TakeDamageSfx);
public void PlayKnockDown() => PlaySfx(KnockDownSfx);
public void PlayRevive() => PlaySfx(ReviveSfx);
public void PlayZoneCleared() => PlaySfx(ZoneClearedSfx);
public void PlayBossEnter() => PlaySfx(BossEnterSfx);
public void PlayStageClear() => PlaySfx(StageClearSfx);
public void PlayStageBgm() => CrossfadeMusic(StageBgm, 1.5f);
public void PlayBossBgm() => CrossfadeMusic(BossBgm, 1.0f);
private void CrossfadeMusic(AudioStream newMusic, float duration)
{
if (newMusic == null) return;
var tween = CreateTween();
tween.TweenProperty(_bgmPlayer, "volume_db", -80f, duration * 0.5f);
tween.TweenCallback(Callable.From(() =>
{
_bgmPlayer.Stream = newMusic;
_bgmPlayer.Play();
}));
tween.TweenProperty(_bgmPlayer, "volume_db", 0f, duration * 0.5f);
}
private void PlaySfx(AudioStream sfx)
{
if (sfx == null) return;
var player = _sfxPool[_poolIndex % PoolSize];
_poolIndex++;
player.Stream = sfx;
player.Play();
}
}GDScript
extends Node
class_name DnfAudioManager
## 地下城与勇士音效管理器
static var instance: DnfAudioManager
# 打击音效(连招各击音调不同)
@export var combo_hit_sfx: Array[AudioStream] # 4个连击音效
@export var skill_hit_sfx: AudioStream
@export var launch_hit_sfx: AudioStream
# 受击音效
@export var take_damage_sfx: AudioStream
@export var knock_down_sfx: AudioStream
@export var revive_sfx: AudioStream
# 技能音效
@export var warrior_charge_sfx: AudioStream
@export var mage_explosion_sfx: AudioStream
@export var rogue_flash_sfx: AudioStream
@export var paladin_shield_sfx: AudioStream
# 关卡音效
@export var zone_cleared_sfx: AudioStream
@export var boss_enter_sfx: AudioStream
@export var stage_clear_sfx: AudioStream
# 背景音乐
@export var stage_bgm: AudioStream
@export var boss_bgm: AudioStream
var _bgm_player: AudioStreamPlayer
var _sfx_pool: Array[AudioStreamPlayer] = []
var _pool_index: int = 0
const POOL_SIZE: int = 8
func _enter_tree() -> void:
instance = self
func _exit_tree() -> void:
if instance == self:
instance = null
func _ready() -> void:
_bgm_player = AudioStreamPlayer.new()
_bgm_player.bus = "Music"
add_child(_bgm_player)
for i in POOL_SIZE:
var p = AudioStreamPlayer.new()
p.bus = "SFX"
add_child(p)
_sfx_pool.append(p)
## 播放连招音效(combo_index 0~3 对应四连击)
func play_combo_hit(combo_index: int) -> void:
if combo_index < combo_hit_sfx.size():
play_sfx(combo_hit_sfx[combo_index])
func play_skill_hit() -> void: play_sfx(skill_hit_sfx)
func play_launch_hit() -> void: play_sfx(launch_hit_sfx)
func play_take_damage() -> void: play_sfx(take_damage_sfx)
func play_knock_down() -> void: play_sfx(knock_down_sfx)
func play_revive() -> void: play_sfx(revive_sfx)
func play_zone_cleared() -> void: play_sfx(zone_cleared_sfx)
func play_boss_enter() -> void: play_sfx(boss_enter_sfx)
func play_stage_clear() -> void: play_sfx(stage_clear_sfx)
func play_stage_bgm() -> void: _crossfade_music(stage_bgm, 1.5)
func play_boss_bgm() -> void: _crossfade_music(boss_bgm, 1.0)
func _crossfade_music(new_music: AudioStream, duration: float) -> void:
if not new_music:
return
var tween = create_tween()
tween.tween_property(_bgm_player, "volume_db", -80.0, duration * 0.5)
tween.tween_callback(func():
_bgm_player.stream = new_music
_bgm_player.play()
)
tween.tween_property(_bgm_player, "volume_db", 0.0, duration * 0.5)
func play_sfx(sfx: AudioStream) -> void:
if not sfx:
return
var player: AudioStreamPlayer = _sfx_pool[_pool_index % POOL_SIZE]
_pool_index += 1
player.stream = sfx
player.play()9.3 打击感三要素
打击感不只是音效,还需要Hit Stop(画面停顿)和屏幕震动配合:
C
using Godot;
/// <summary>
/// 打击感管理器——Hit Stop + 屏幕震动
/// </summary>
public partial class HitFeel : Node
{
public static HitFeel Instance { get; private set; }
public override void _EnterTree() => Instance = this;
public override void _ExitTree() { if (Instance == this) Instance = null; }
/// <summary>
/// Hit Stop:命中瞬间暂停几帧,让打击感更强
/// duration 通常 0.05~0.1 秒
/// </summary>
public async void TriggerHitStop(float duration = 0.06f)
{
Engine.TimeScale = 0.05f; // 几乎暂停
await ToSignal(GetTree().CreateTimer(duration * 0.05f), "timeout");
Engine.TimeScale = 1.0f; // 恢复正常
}
/// <summary>
/// 屏幕震动——Boss攻击、击飞时使用
/// </summary>
public void ShakeCamera(float intensity = 8f, float duration = 0.3f)
{
var camera = GetTree().GetFirstNodeInGroup("camera") as Camera2D;
if (camera == null) return;
var tween = CreateTween();
float elapsed = 0f;
float step = 0.05f;
while (elapsed < duration)
{
var offset = new Vector2(
GD.RandRange(-intensity, intensity),
GD.RandRange(-intensity, intensity)
);
tween.TweenProperty(camera, "offset", offset, step);
elapsed += step;
}
tween.TweenProperty(camera, "offset", Vector2.Zero, step);
}
/// <summary>
/// 伤害数字飘出——显示造成的伤害
/// </summary>
public void SpawnDamageNumber(Vector2 worldPos, int damage, bool isCrit = false)
{
var label = new Label
{
Text = isCrit ? $"!!{damage}!!" : damage.ToString(),
Position = worldPos + new Vector2(-20, -40),
};
label.AddThemeFontSizeOverride("font_size", isCrit ? 28 : 20);
label.AddThemeColorOverride("font_color",
isCrit ? new Color(1f, 0.3f, 0f) : Colors.White);
GetTree().CurrentScene.AddChild(label);
var tween = CreateTween();
tween.TweenProperty(label, "position",
worldPos + new Vector2(-20, -80), 0.6f);
tween.Parallel().TweenProperty(label, "modulate:a", 0f, 0.6f);
tween.TweenCallback(Callable.From(() => label.QueueFree()));
}
}GDScript
extends Node
class_name HitFeel
## 打击感管理器——Hit Stop + 屏幕震动
static var instance: HitFeel
func _enter_tree() -> void:
instance = self
func _exit_tree() -> void:
if instance == self:
instance = null
## Hit Stop:命中瞬间暂停几帧,让打击感更强
func trigger_hit_stop(duration: float = 0.06) -> void:
Engine.time_scale = 0.05 # 几乎暂停
await get_tree().create_timer(duration * 0.05).timeout
Engine.time_scale = 1.0 # 恢复正常
## 屏幕震动——Boss攻击、击飞时使用
func shake_camera(intensity: float = 8.0, duration: float = 0.3) -> void:
var camera: Camera2D = get_tree().get_first_node_in_group("camera")
if not camera:
return
var tween = create_tween()
var elapsed: float = 0.0
var step: float = 0.05
while elapsed < duration:
var offset = Vector2(
randf_range(-intensity, intensity),
randf_range(-intensity, intensity)
)
tween.tween_property(camera, "offset", offset, step)
elapsed += step
tween.tween_property(camera, "offset", Vector2.ZERO, step)
## 伤害数字飘出
func spawn_damage_number(world_pos: Vector2, damage: int, is_crit: bool = false) -> void:
var label = Label.new()
label.text = "!!%d!!" % damage if is_crit else str(damage)
label.position = world_pos + Vector2(-20, -40)
label.add_theme_font_size_override("font_size", 28 if is_crit else 20)
label.add_theme_color_override("font_color",
Color(1.0, 0.3, 0.0) if is_crit else Color.WHITE)
get_tree().current_scene.add_child(label)
var tween = create_tween()
tween.tween_property(label, "position", world_pos + Vector2(-20, -80), 0.6)
tween.parallel().tween_property(label, "modulate:a", 0.0, 0.6)
tween.tween_callback(label.queue_free)9.4 在战斗中集成音效
在 Hitbox 命中时,同时触发音效、Hit Stop 和伤害数字:
玩家攻击 → Hitbox 检测到命中
├── body.TakeDamage(damage) ← 扣血
├── DnfAudioManager.PlayComboHit(n) ← 播放连击音效
├── HitFeel.TriggerHitStop() ← 画面停顿
└── HitFeel.SpawnDamageNumber(pos, damage) ← 伤害数字Boss 登场时的特殊处理:
进入 Boss 房间
├── DnfAudioManager.PlayBossEnter() ← 播放 Boss 登场音效
├── DnfAudioManager.PlayBossBgm() ← 切换 Boss BGM
└── HitFeel.ShakeCamera(12, 0.5) ← 屏幕震动9.5 本章小结
| 概念 | 说明 |
|---|---|
| 连招音效 | 四连击各有不同音调,节奏感强 |
| Hit Stop | 命中瞬间 TimeScale 降到 0.05,持续约 0.06 秒 |
| 屏幕震动 | Camera2D offset 随机抖动,Boss 攻击时使用 |
| 伤害数字 | 命中位置飘出白色/橙色数字,暴击有感叹号 |
| BGM 切换 | 进入 Boss 房间时淡出普通 BGM,淡入 Boss BGM |
下一章我们将进行游戏打磨和发布准备。
