Article

Unity 入门实战 08:音频反馈系统(战斗节奏、命中手感与UI提示统一)

路线阶段:Unity 入门实战第 8 章。
本章目标:把“零散播放音效”升级为统一音频系统,确保战斗反馈清楚且不会拖垮性能。

学习目标

完成本章后,你应该能做到:

  1. 设计 BGM/SFX/Voice 三类音频总线并统一控制音量。
  2. 通过事件驱动播放音效,避免业务脚本直接 AudioSource.Play
  3. 管理 2D 与 3D 音效播放策略,兼顾沉浸感与可辨识度。
  4. 控制并发与复用,避免高频战斗导致音频爆炸。

背景问题

初期项目常见写法:

audioSource.clip = hitClip;
audioSource.Play();

问题:

  1. 所有脚本都能直接播音,资源与时机不可控。
  2. 同类事件重复触发,音效堆叠刺耳。
  3. 场景切换后 BGM 与 SFX 状态错乱。
  4. WebGL 下并发过高会出现明显卡顿与延迟。

音频架构

分四层:

  1. AudioEvent:标准化播放请求。
  2. AudioRouter:事件到具体音轨的路由。
  3. AudioBus:分组音量与静音控制。
  4. AudioPool:音源对象池与并发限制。

事件模型

public enum AudioCategory
{
    Bgm = 0,
    Sfx = 1,
    Voice = 2
}

public struct AudioEvent
{
    public string Key;
    public AudioCategory Category;
    public Vector3 Position;
    public bool Is3D;
    public float Volume;
    public float Pitch;
}

音频配置表

[Serializable]
public sealed class AudioClipConfig
{
    public string Key;
    public AudioCategory Category;
    public AudioClip Clip;

    public bool IsLoop;
    public bool Is3D;
    public float BaseVolume = 1f;
    public float MinPitch = 1f;
    public float MaxPitch = 1f;
    public int MaxConcurrent = 4;
}

AudioBus

public sealed class AudioBus
{
    public float Master = 1f;
    public float Bgm = 1f;
    public float Sfx = 1f;
    public float Voice = 1f;

    public float Resolve(AudioCategory category, float volume)
    {
        var categoryVolume = 1f;

        if (category == AudioCategory.Bgm) categoryVolume = Bgm;
        else if (category == AudioCategory.Sfx) categoryVolume = Sfx;
        else if (category == AudioCategory.Voice) categoryVolume = Voice;

        return Mathf.Clamp01(Master * categoryVolume * volume);
    }
}

音频池与播放

public sealed class AudioSourcePool
{
    private readonly Queue<AudioSource> _idle = new Queue<AudioSource>(32);
    private readonly List<AudioSource> _active = new List<AudioSource>(64);
    private readonly Transform _root;

    public AudioSourcePool(Transform root, int initial)
    {
        _root = root;

        for (var i = 0; i < initial; i++)
        {
            _idle.Enqueue(CreateSource());
        }
    }

    public AudioSource Get()
    {
        var src = _idle.Count > 0 ? _idle.Dequeue() : CreateSource();
        _active.Add(src);
        return src;
    }

    public void TickRecycle()
    {
        for (var i = _active.Count - 1; i >= 0; i--)
        {
            var src = _active[i];
            if (src.isPlaying)
            {
                continue;
            }

            src.clip = null;
            src.transform.position = Vector3.zero;
            src.spatialBlend = 0f;
            src.loop = false;

            _active.RemoveAt(i);
            _idle.Enqueue(src);
        }
    }

    private AudioSource CreateSource()
    {
        var go = new GameObject("AudioSourceRuntime");
        go.transform.SetParent(_root, false);
        return go.AddComponent<AudioSource>();
    }
}

AudioRouter

public sealed class AudioRouter : IUpdatable
{
    public int Order { get { return 320; } }

    private readonly Dictionary<string, AudioClipConfig> _map;
    private readonly Dictionary<string, int> _concurrent;
    private readonly AudioBus _bus;
    private readonly AudioSourcePool _pool;

    public AudioRouter(List<AudioClipConfig> configs, AudioBus bus, AudioSourcePool pool)
    {
        _map = new Dictionary<string, AudioClipConfig>(configs.Count);
        _concurrent = new Dictionary<string, int>(configs.Count);
        _bus = bus;
        _pool = pool;

        for (var i = 0; i < configs.Count; i++)
        {
            _map[configs[i].Key] = configs[i];
            _concurrent[configs[i].Key] = 0;
        }
    }

    public void Tick(float dt, float unscaledDt)
    {
        _pool.TickRecycle();
    }

    public void Play(AudioEvent e)
    {
        AudioClipConfig cfg;
        if (!_map.TryGetValue(e.Key, out cfg))
        {
            FoundationLog.Warn("Audio", "config_missing key=" + e.Key);
            return;
        }

        var count = _concurrent[e.Key];
        if (count >= cfg.MaxConcurrent)
        {
            return;
        }

        var src = _pool.Get();
        src.clip = cfg.Clip;
        src.loop = cfg.IsLoop;
        src.spatialBlend = (cfg.Is3D || e.Is3D) ? 1f : 0f;
        src.transform.position = e.Position;
        src.volume = _bus.Resolve(cfg.Category, cfg.BaseVolume * (e.Volume <= 0f ? 1f : e.Volume));

        var pitchMin = cfg.MinPitch;
        var pitchMax = cfg.MaxPitch;
        if (pitchMin > pitchMax)
        {
            var temp = pitchMin;
            pitchMin = pitchMax;
            pitchMax = temp;
        }

        src.pitch = UnityEngine.Random.Range(pitchMin, pitchMax);
        src.Play();

        _concurrent[e.Key] = count + 1;
        if (!cfg.IsLoop)
        {
            var key = e.Key;
            CoroutineRunner.Instance.Delay(src.clip.length, delegate
            {
                _concurrent[key] = Mathf.Max(0, _concurrent[key] - 1);
            });
        }
    }
}

业务事件接入

建议统一映射:

  1. SkillCastAccepted -> sfx_skill_cast
  2. DamageResolved -> sfx_hit_light / sfx_hit_heavy
  3. WaveStarted -> sfx_wave_start
  4. BattleCompleted -> sfx_victory + BGM 切换
public sealed class CombatAudioPresenter : IDisposable
{
    private readonly EventSubscriptionGroup _subs;
    private readonly AudioRouter _router;

    public CombatAudioPresenter(EventBus eventBus, AudioRouter router)
    {
        _router = router;
        _subs = new EventSubscriptionGroup(eventBus);
    }

    public void Bind()
    {
        _subs.Bind("SkillCastAccepted", OnSkillCast);
        _subs.Bind("DamageResolved", OnDamage);
        _subs.Bind("WaveStarted", OnWaveStarted);
        _subs.Bind("BattleCompleted", OnBattleCompleted);
    }

    public void Dispose()
    {
        _subs.OnDisable();
        _subs.OnDestroy();
    }

    private void OnSkillCast(object payload)
    {
        _router.Play(new AudioEvent { Key = "sfx_skill_cast", Category = AudioCategory.Sfx, Volume = 1f });
    }

    private void OnDamage(object payload)
    {
        _router.Play(new AudioEvent { Key = "sfx_hit_light", Category = AudioCategory.Sfx, Volume = 0.9f });
    }

    private void OnWaveStarted(object payload)
    {
        _router.Play(new AudioEvent { Key = "sfx_wave_start", Category = AudioCategory.Sfx, Volume = 1f });
    }

    private void OnBattleCompleted(object payload)
    {
        _router.Play(new AudioEvent { Key = "sfx_victory", Category = AudioCategory.Sfx, Volume = 1f });
    }
}

BGM 状态机

public enum BgmState
{
    Menu = 0,
    Battle = 1,
    Result = 2
}

public sealed class BgmController
{
    private readonly AudioRouter _router;
    private BgmState _state;

    public BgmController(AudioRouter router)
    {
        _router = router;
        _state = BgmState.Menu;
    }

    public void Change(BgmState next)
    {
        if (_state == next)
        {
            return;
        }

        _state = next;

        if (next == BgmState.Menu)
            _router.Play(new AudioEvent { Key = "bgm_menu", Category = AudioCategory.Bgm, Volume = 1f });
        else if (next == BgmState.Battle)
            _router.Play(new AudioEvent { Key = "bgm_battle", Category = AudioCategory.Bgm, Volume = 1f });
        else if (next == BgmState.Result)
            _router.Play(new AudioEvent { Key = "bgm_result", Category = AudioCategory.Bgm, Volume = 1f });
    }
}

WebGL 注意点

  1. 预加载核心音频,避免首帧播放延迟。
  2. 控制并发上限,避免低端设备音频线程压力过高。
  3. 长音频使用压缩流式策略,短音效使用内存常驻。

验收清单

  1. 技能释放、命中、波次开始都能触发对应音效。
  2. BGM 在菜单/战斗/结算间切换稳定,无重叠播放。
  3. 音量设置可实时生效并持久化。
  4. 长时间战斗下无明显音频堆叠与性能抖动。

常见坑

坑 1:每个对象挂一个 AudioSource 随便播

会快速失控,难以统一调优。必须通过集中路由。

坑 2:忽略并发限制

连击场景会爆音。给每个音效键配置 MaxConcurrent

坑 3:BGM 切换时不停止旧轨道

会出现多轨叠加。BGM 需要独立管理通道。

本月作业

实现“动态战斗音乐”:

  1. 敌人数量超过阈值自动切换高强度层。
  2. Boss 出场时叠加单独主题段。
  3. 回放模式复现同一时刻音乐切换节点。

下一章进入 Unity 入门实战 09:资源加载与预热(WebGL 首包与运行时加载优化)。