Article

Unity 入门实战 05:敌人波次生成与生命周期管理(稳定刷怪不穿帮)

路线阶段:Unity 入门实战第 5 章。
本章目标:把“生成几个敌人”升级为可长期扩展的波次系统,支撑后续 WebGL 小游戏玩法。

学习目标

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

  1. 设计可配置的波次数据结构(批次、间隔、敌人类型)。
  2. 实现稳定刷怪流程(开始、生成、存活追踪、结束)。
  3. 管理敌人生命周期(激活、死亡、回收、复用)。
  4. 避免高频 Instantiate/Destroy 导致的性能抖动。

常见错误实现

入门阶段常见写法:

for (int i = 0; i < 10; i++)
{
    Instantiate(enemyPrefab, spawnPoint.position, Quaternion.identity);
}

问题:

  1. 同帧生成过多导致卡顿。
  2. 没有波次节奏,战斗体验单调。
  3. 敌人死亡后直接 Destroy,GC 和 CPU 峰值明显。
  4. 难做“当前第几波/剩余敌人数”等 UI 与胜利判定。

波次配置结构

[Serializable]
public sealed class WaveConfig
{
    public int WaveId;
    public float StartDelay;
    public float SpawnInterval;
    public List<SpawnBatchConfig> Batches;
}

[Serializable]
public sealed class SpawnBatchConfig
{
    public string EnemyType;
    public int Count;
    public float BatchDelay;
}

示例配置思路:

  1. 第一波:近战兵 8 个,分 4 批
  2. 第二波:远程兵 6 个 + 近战兵 6 个
  3. 第三波:精英 2 个 + 普通兵 10 个

核心系统拆分

  1. WaveDirector:波次状态机
  2. SpawnService:出生点选择与实例生成
  3. EnemyRuntime:单个敌人生命周期
  4. EnemyPoolRegistry:按类型管理对象池

WaveDirector 实现

public enum WaveState
{
    Idle = 0,
    WaitingStart = 1,
    Spawning = 2,
    Fighting = 3,
    Completed = 4
}

public sealed class WaveDirector : IUpdatable
{
    public int Order { get { return 210; } }

    private readonly List<WaveConfig> _waves;
    private readonly SpawnService _spawn;
    private readonly EnemyTracker _tracker;
    private readonly EventBus _eventBus;

    private int _waveIndex;
    private int _batchIndex;
    private float _timer;
    private WaveState _state;

    public WaveDirector(List<WaveConfig> waves, SpawnService spawn, EnemyTracker tracker, EventBus eventBus)
    {
        _waves = waves;
        _spawn = spawn;
        _tracker = tracker;
        _eventBus = eventBus;

        _waveIndex = 0;
        _batchIndex = 0;
        _timer = 0f;
        _state = WaveState.Idle;
    }

    public void StartBattle()
    {
        if (_waves == null || _waves.Count == 0)
        {
            _state = WaveState.Completed;
            return;
        }

        _state = WaveState.WaitingStart;
        _timer = _waves[0].StartDelay;
        _eventBus.Publish("WaveStarted", _waves[0].WaveId);
    }

    public void Tick(float dt, float unscaledDt)
    {
        if (_state == WaveState.Idle || _state == WaveState.Completed)
        {
            return;
        }

        _timer -= dt;

        if (_state == WaveState.WaitingStart)
        {
            if (_timer <= 0f)
            {
                _state = WaveState.Spawning;
                _batchIndex = 0;
                _timer = 0f;
            }
            return;
        }

        if (_state == WaveState.Spawning)
        {
            RunSpawn(dt);
            return;
        }

        if (_state == WaveState.Fighting)
        {
            if (_tracker.AliveCount == 0)
            {
                NextWaveOrComplete();
            }
        }
    }

    private void RunSpawn(float dt)
    {
        var wave = _waves[_waveIndex];
        if (_batchIndex >= wave.Batches.Count)
        {
            _state = WaveState.Fighting;
            return;
        }

        if (_timer > 0f)
        {
            return;
        }

        var batch = wave.Batches[_batchIndex];
        for (var i = 0; i < batch.Count; i++)
        {
            var enemy = _spawn.Spawn(batch.EnemyType);
            _tracker.Register(enemy);
        }

        _batchIndex++;
        _timer = batch.BatchDelay > 0f ? batch.BatchDelay : wave.SpawnInterval;
    }

    private void NextWaveOrComplete()
    {
        _eventBus.Publish("WaveCleared", _waves[_waveIndex].WaveId);

        _waveIndex++;
        if (_waveIndex >= _waves.Count)
        {
            _state = WaveState.Completed;
            _eventBus.Publish("BattleCompleted", null);
            FoundationLog.Info("Wave", "battle_completed");
            return;
        }

        _state = WaveState.WaitingStart;
        _batchIndex = 0;
        _timer = _waves[_waveIndex].StartDelay;
        _eventBus.Publish("WaveStarted", _waves[_waveIndex].WaveId);
    }
}

出生点选择与刷怪服务

public sealed class SpawnService
{
    private readonly List<Transform> _spawnPoints;
    private readonly EnemyPoolRegistry _pools;
    private int _rrIndex;

    public SpawnService(List<Transform> spawnPoints, EnemyPoolRegistry pools)
    {
        _spawnPoints = spawnPoints;
        _pools = pools;
        _rrIndex = 0;
    }

    public EnemyRuntime Spawn(string enemyType)
    {
        var point = SelectPoint();
        var enemy = _pools.Get(enemyType);

        enemy.Transform.position = point.position;
        enemy.Transform.rotation = point.rotation;
        enemy.Activate();

        return enemy;
    }

    private Transform SelectPoint()
    {
        if (_spawnPoints == null || _spawnPoints.Count == 0)
        {
            throw new InvalidOperationException("Spawn points empty");
        }

        var point = _spawnPoints[_rrIndex % _spawnPoints.Count];
        _rrIndex++;
        return point;
    }
}

敌人生命周期

public sealed class EnemyRuntime : IPoolResettable
{
    public int EnemyId;
    public string EnemyType;
    public Transform Transform;
    public bool Alive;
    public int Hp;

    private Action<EnemyRuntime> _onDeath;

    public void BindDeathCallback(Action<EnemyRuntime> onDeath)
    {
        _onDeath = onDeath;
    }

    public void Activate()
    {
        Alive = true;
        Hp = 100;
        // 这里可启用视图、碰撞、AI
    }

    public void ApplyDamage(int value)
    {
        if (!Alive)
        {
            return;
        }

        Hp -= value;
        if (Hp <= 0)
        {
            Alive = false;
            if (_onDeath != null)
            {
                _onDeath(this);
            }
        }
    }

    public void ResetForPool()
    {
        EnemyId = 0;
        EnemyType = null;
        Alive = false;
        Hp = 0;
        _onDeath = null;
    }
}

存活追踪与回收

public sealed class EnemyTracker
{
    private readonly HashSet<int> _alive = new HashSet<int>();
    private readonly EnemyPoolRegistry _pools;

    public int AliveCount
    {
        get { return _alive.Count; }
    }

    public EnemyTracker(EnemyPoolRegistry pools)
    {
        _pools = pools;
    }

    public void Register(EnemyRuntime enemy)
    {
        _alive.Add(enemy.EnemyId);

        enemy.BindDeathCallback(delegate(EnemyRuntime dead)
        {
            _alive.Remove(dead.EnemyId);
            _pools.Release(dead.EnemyType, dead);
        });
    }
}

UI 与事件

建议至少暴露以下事件:

  1. WaveStarted(waveId)
  2. WaveCleared(waveId)
  3. AliveEnemyChanged(count)
  4. BattleCompleted()

UI 可显示:

  • 当前波次
  • 当前存活敌人数
  • 下一波倒计时

与前面系统联动

  1. 命令系统:玩家输入仍走命令,不与刷怪系统耦合。
  2. 伤害管线:敌人死亡只通过伤害结算触发,不直接改状态。
  3. 仇恨系统:敌人激活时初始化仇恨表,死亡时清理。
  4. 生命周期治理:战斗结束统一停用波次与敌人对象。

WebGL 兼容要点

  1. 避免同帧大量实例化,使用池化 + 批次间隔。
  2. 控制同屏活跃敌人上限,保障中低端设备帧率。
  3. 出生点逻辑避免复杂寻路计算,先用简单策略。

验收清单

  1. 三波战斗可完整进行并触发结束事件。
  2. 敌人死亡后正确回收到池,不产生重复实例。
  3. 长时间运行下刷怪路径无明显 GC 峰值。
  4. 波次 UI 与真实状态一致,无延迟错位。

常见坑

坑 1:AliveCount 只增不减

会导致永远无法进入下一波。死亡回调必须可靠触发。

坑 2:敌人回池后未重置

下一次复用出现“半血出生”或旧目标残留。必须执行 ResetForPool

坑 3:刷怪事件和 UI 强耦合

应通过事件总线传递,不要让 WaveDirector 直接操作具体 UI 组件。

本月作业

实现“动态难度波次”:

  1. 根据玩家通关时间动态调整下一波数量与类型。
  2. 连续无伤时提高精英怪概率。
  3. 回放模式下验证同 seed 的波次变化一致。

下一章进入 Unity 入门实战 06:UI 架构与状态同步(战斗 HUD、面板切换、事件驱动刷新)。