路线阶段:Unity 入门实战第 5 章。
本章目标:把“生成几个敌人”升级为可长期扩展的波次系统,支撑后续 WebGL 小游戏玩法。
学习目标
完成本章后,你应该能做到:
- 设计可配置的波次数据结构(批次、间隔、敌人类型)。
- 实现稳定刷怪流程(开始、生成、存活追踪、结束)。
- 管理敌人生命周期(激活、死亡、回收、复用)。
- 避免高频 Instantiate/Destroy 导致的性能抖动。
常见错误实现
入门阶段常见写法:
for (int i = 0; i < 10; i++)
{
Instantiate(enemyPrefab, spawnPoint.position, Quaternion.identity);
}
问题:
- 同帧生成过多导致卡顿。
- 没有波次节奏,战斗体验单调。
- 敌人死亡后直接 Destroy,GC 和 CPU 峰值明显。
- 难做“当前第几波/剩余敌人数”等 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;
}
示例配置思路:
- 第一波:近战兵 8 个,分 4 批
- 第二波:远程兵 6 个 + 近战兵 6 个
- 第三波:精英 2 个 + 普通兵 10 个
核心系统拆分
WaveDirector:波次状态机SpawnService:出生点选择与实例生成EnemyRuntime:单个敌人生命周期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 与事件
建议至少暴露以下事件:
WaveStarted(waveId)WaveCleared(waveId)AliveEnemyChanged(count)BattleCompleted()
UI 可显示:
- 当前波次
- 当前存活敌人数
- 下一波倒计时
与前面系统联动
- 命令系统:玩家输入仍走命令,不与刷怪系统耦合。
- 伤害管线:敌人死亡只通过伤害结算触发,不直接改状态。
- 仇恨系统:敌人激活时初始化仇恨表,死亡时清理。
- 生命周期治理:战斗结束统一停用波次与敌人对象。
WebGL 兼容要点
- 避免同帧大量实例化,使用池化 + 批次间隔。
- 控制同屏活跃敌人上限,保障中低端设备帧率。
- 出生点逻辑避免复杂寻路计算,先用简单策略。
验收清单
- 三波战斗可完整进行并触发结束事件。
- 敌人死亡后正确回收到池,不产生重复实例。
- 长时间运行下刷怪路径无明显 GC 峰值。
- 波次 UI 与真实状态一致,无延迟错位。
常见坑
坑 1:AliveCount 只增不减
会导致永远无法进入下一波。死亡回调必须可靠触发。
坑 2:敌人回池后未重置
下一次复用出现“半血出生”或旧目标残留。必须执行 ResetForPool。
坑 3:刷怪事件和 UI 强耦合
应通过事件总线传递,不要让 WaveDirector 直接操作具体 UI 组件。
本月作业
实现“动态难度波次”:
- 根据玩家通关时间动态调整下一波数量与类型。
- 连续无伤时提高精英怪概率。
- 回放模式下验证同 seed 的波次变化一致。
下一章进入 Unity 入门实战 06:UI 架构与状态同步(战斗 HUD、面板切换、事件驱动刷新)。