恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Unity塔防游戏开发实战:波次驱动、炮塔组合与性能优化
首页
资讯中心
/
Unity塔防游戏开发实战:波次驱动、炮塔组合与性能优化
Unity塔防游戏开发实战:波次驱动、炮塔组合与性能优化
发布时间:2026/10/1 9:13:04
1. 塔防游戏开发第二阶段的整体设计思路1.1 从“能跑”到“好玩”的关键跨越Part1 阶段我们搭好了地基地图网格、路径点系统、基础敌人移动、炮塔放置逻辑。但说实话那个版本只能叫“能跑”离“好玩”还差着十万八千里。Part2 要解决的核心问题就一个——让游戏真正具备可玩性。什么叫可玩性我自己的判断标准很简单玩家愿不愿意主动再开一局。要做到这一点至少得补齐三块拼图战斗反馈打中了要有感觉、成长曲线越玩越强但敌人也越强、策略深度不同炮塔有不同用法不是无脑堆一种。这一阶段的技术选型上我坚持了两个原则。第一所有数值必须可配置不能硬编码在脚本里否则调平衡的时候你会想砸键盘。第二视觉反馈优先于数值反馈玩家先看到“打中了”才会去关心“掉了多少血”。这两个原则贯穿了整个 Part2 的开发过程。1.2 为什么选择“波次驱动”而非“计时驱动”塔防游戏的敌人投放机制常见的有两种计时驱动每隔 N 秒生成一个敌人和波次驱动一波敌人全部生成完清完或超时后进入下一波。我最终选了波次驱动理由有三。第一节奏感更强。波次之间有明确的间歇期玩家可以喘口气、升级炮塔、调整布局这种“战斗-休整-战斗”的循环是塔防游戏的核心体验。第二难度曲线更好控制。每一波的敌人数量、类型、间隔都可以独立配置想在哪一波加难度就在哪一波加。第三玩家预期更清晰。UI 上显示“第 3/10 波”玩家心里有数知道还剩多少这种确定感对留存很重要。具体实现上我设计了一个WaveData的 ScriptableObject每一波包含敌人类型列表、生成间隔、波次间隔、是否 Boss 波。这样策划或者你自己在 Inspector 里就能调不用改代码。[CreateAssetMenu(fileName WaveData, menuName TD/WaveData)] public class WaveData : ScriptableObject { public ListEnemySpawnInfo enemies; public float spawnInterval 0.8f; public float waveInterval 5f; public bool isBossWave false; } [System.Serializable] public class EnemySpawnInfo { public GameObject enemyPrefab; public int count 5; }注意ScriptableObject 的数据在编辑器里改了之后运行时是共享的。如果你在运行时修改了波次数据比如动态调整难度记得先 Instantiate 一份副本否则退出播放模式后数据就乱了。这个坑我踩过调了半天以为代码有 bug结果是数据被运行时改掉了。1.3 炮塔系统的扩展思路Part1 只有一种基础炮塔Part2 我扩展到了四种机枪塔高射速低伤害、火炮塔低射速高伤害范围攻击、冰霜塔减速、激光塔持续伤害。每种炮塔的差异不只是数值而是攻击逻辑不同。这里的关键设计决策是用继承还是用组合我最终选了组合。具体来说炮塔的“瞄准”和“射击”逻辑放在基类TowerBase里而“伤害计算”和“特效表现”通过IAttackBehavior接口注入。这样做的好处是新增炮塔类型时不需要改基类代码只需要实现一个新的 Behavior 就行。public interface IAttackBehavior { void Execute(GameObject target, TowerBase tower); } public class SingleTargetAttack : IAttackBehavior { public void Execute(GameObject target, TowerBase tower) { var enemy target.GetComponentEnemy(); enemy.TakeDamage(tower.damage); // 生成命中特效 EffectPool.Instance.Spawn(HitEffect, target.transform.position); } } public class AreaAttack : IAttackBehavior { public float radius 2f; public void Execute(GameObject target, TowerBase tower) { var hits Physics.OverlapSphere(target.transform.position, radius); foreach (var hit in hits) { var enemy hit.GetComponentEnemy(); if (enemy ! null) enemy.TakeDamage(tower.damage); } } }这种设计在后期加新炮塔时优势非常明显。比如后来我想加一个“毒液塔”只需要写一个PoisonAttack实现挂上去就行基类一行不用改。2. 核心战斗细节与实操要点2.1 敌人血条与伤害数字的实现细节血条看起来简单但要做好有几个坑。最常见的做法是在敌人头顶挂一个 World Space 的 Canvas但敌人多了之后 Draw Call 会爆炸。我的优化方案是用 SpriteRenderer 做血条配合Camera.main.WorldToScreenPoint做位置同步或者更简单——直接让血条作为敌人的子物体用transform.localScale控制宽度。public class HealthBar : MonoBehaviour { public Transform fillBar; private float maxWidth 1f; public void SetHealth(float current, float max) { float ratio current / max; fillBar.localScale new Vector3(maxWidth * ratio, fillBar.localScale.y, 1); // 根据血量变色 var renderer fillBar.GetComponentSpriteRenderer(); renderer.color Color.Lerp(Color.red, Color.green, ratio); } }伤害数字我用的是对象池 TextMeshPro。每次造成伤害时从池里取一个设置文字和位置然后做一个向上飘动 淡出的动画。这里的关键是不要每次 new 一个 GameObject否则 GC 会在敌人多的时候造成明显卡顿。实操心得伤害数字的飘动方向加一点随机偏移看起来更自然。另外同一位置的多个伤害数字可以合并显示比如“-15 x3”避免刷屏。这个优化在敌人密集时效果非常明显。2.2 炮塔瞄准与旋转的平滑处理炮塔瞄准敌人时如果直接LookAt会非常生硬。我的做法是计算目标方向然后用Quaternion.RotateTowards做插值旋转。void Update() { if (currentTarget null) return; Vector3 dir currentTarget.position - turretHead.position; dir.y 0; // 保持水平 Quaternion targetRot Quaternion.LookRotation(dir); turretHead.rotation Quaternion.RotateTowards( turretHead.rotation, targetRot, rotationSpeed * Time.deltaTime ); }这里有个细节炮塔的旋转速度要和射速匹配。如果旋转太慢敌人跑过去了炮塔还没转过来玩家会觉得“这塔怎么这么笨”。我的经验值是旋转速度至少要让炮塔在 0.3 秒内转 180 度否则手感会很差。另外炮塔的枪口位置不要用炮塔中心要用一个单独的firePoint空物体。这样子弹从枪口飞出视觉上更合理。火炮塔的炮弹还要加一个抛物线轨迹用Vector3.Lerp 高度偏移模拟。2.3 敌人路径跟随的优化Part1 用的是简单的Vector3.MoveTowards逐点移动Part2 我换成了更平滑的方案。具体来说用CatmullRom曲线对路径点做插值让敌人的移动轨迹更圆润。但这里有个性能考量如果每个敌人每帧都计算曲线敌人多了会卡。我的解决方案是路径在关卡加载时预计算一次生成一条平滑的路径点数组敌人只需要按索引移动就行。public class PathManager : MonoBehaviour { public Transform[] waypoints; private Vector3[] smoothPath; public int resolution 10; // 每段插值点数 void Awake() { GenerateSmoothPath(); } void GenerateSmoothPath() { var path new ListVector3(); for (int i 0; i waypoints.Length - 1; i) { Vector3 p0 i 0 ? waypoints[i].position : waypoints[i - 1].position; Vector3 p1 waypoints[i].position; Vector3 p2 waypoints[i 1].position; Vector3 p3 i 2 waypoints.Length ? waypoints[i 2].position : p2; for (int j 0; j resolution; j) { float t j / (float)resolution; path.Add(CatmullRom(p0, p1, p2, p3, t)); } } path.Add(waypoints[waypoints.Length - 1].position); smoothPath path.ToArray(); } Vector3 CatmullRom(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { float t2 t * t; float t3 t2 * t; return 0.5f * ( 2f * p1 (-p0 p2) * t (2f * p0 - 5f * p1 4f * p2 - p3) * t2 (-p0 3f * p1 - 3f * p2 p3) * t3 ); } }敌人移动时维护一个pathIndex和pathProgress每帧根据速度推进。这样所有敌人共享同一条路径数据内存和 CPU 都很省。注意路径平滑后敌人的实际移动距离会比折线路径略短所以到达终点的时间会提前。如果你的关卡难度是基于时间计算的记得把路径长度也纳入考量。我一般会在路径生成后计算总长度然后根据敌人速度反推到达时间用来校准波次节奏。3. 完整实操流程与核心环节实现3.1 波次管理器的完整实现波次管理器是整个游戏节奏的“指挥家”。我的实现思路是用一个状态机管理“准备中-生成中-等待清场-波次结束”四个状态。public class WaveManager : MonoBehaviour { public WaveData[] waves; public Transform spawnPoint; public PathManager pathManager; private int currentWaveIndex 0; private int enemiesAlive 0; private int enemiesSpawned 0; private bool isSpawning false; private float timer 0f; public event System.Actionint, int OnWaveChanged; // 当前波, 总波数 public event System.Action OnAllWavesComplete; void Start() { StartCoroutine(WaveLoop()); } IEnumerator WaveLoop() { while (currentWaveIndex waves.Length) { var wave waves[currentWaveIndex]; OnWaveChanged?.Invoke(currentWaveIndex 1, waves.Length); // 等待波次间隔 yield return new WaitForSeconds(wave.waveInterval); // 生成敌人 foreach (var info in wave.enemies) { for (int i 0; i info.count; i) { SpawnEnemy(info.enemyPrefab); yield return new WaitForSeconds(wave.spawnInterval); } } // 等待所有敌人被消灭 while (enemiesAlive 0) yield return null; currentWaveIndex; } OnAllWavesComplete?.Invoke(); } void SpawnEnemy(GameObject prefab) { var enemy Instantiate(prefab, spawnPoint.position, Quaternion.identity); enemy.GetComponentEnemy().Init(pathManager); enemy.GetComponentEnemy().OnDeath () enemiesAlive--; enemiesAlive; } }这里有个细节enemiesAlive的递减要在敌人死亡时触发而不是在Destroy时。因为Destroy是延迟到帧末执行的如果依赖OnDestroy来递减可能会出现“敌人已经死了但计数还没减”的情况导致波次卡住。3.2 经济系统与炮塔升级经济系统是塔防游戏的“第二战场”。我的设计是击杀敌人获得金币金币用来建造和升级炮塔。升级不是简单的数值翻倍而是有明确的功能提升。等级机枪塔伤害射速特殊效果152/s无282.5/s无3123/s解锁穿甲无视 20% 护甲4183.5/s解锁双管同时攻击两个目标升级消耗的金币按baseCost * 1.6^level递增。这个公式是我试了几次之后定下来的1.6 的系数能让玩家在“攒钱升级”和“多建新塔”之间做出有意义的取舍。如果系数太低比如 1.2玩家会倾向于一直升级同一座塔如果太高比如 2.0升级就变得不划算玩家只会铺满一级塔。public int GetUpgradeCost(int currentLevel) { return Mathf.RoundToInt(baseCost * Mathf.Pow(1.6f, currentLevel)); }实操心得升级时加一个短暂的“升级动画”——炮塔缩放一下 粒子特效玩家会更有满足感。这个反馈很重要我测试的时候发现加了动画之后玩家升级的意愿明显提高了。3.3 游戏胜负判定与 UI 联动胜利条件是“所有波次清完且基地血量大于 0”失败条件是“基地血量归零”。这两个判定要放在GameManager里统一管理避免逻辑分散。public class GameManager : MonoBehaviour { public int baseHealth 20; public WaveManager waveManager; public GameObject winPanel; public GameObject losePanel; void OnEnable() { waveManager.OnAllWavesComplete HandleWin; } public void OnEnemyReachBase(int damage) { baseHealth - damage; if (baseHealth 0) { baseHealth 0; HandleLose(); } } void HandleWin() { Time.timeScale 0f; winPanel.SetActive(true); } void HandleLose() { Time.timeScale 0f; losePanel.SetActive(true); } }UI 方面我用的是 Unity 的 UGUI血条、金币、波次信息都放在 Canvas 上。这里有个小技巧金币数字变化时加一个缩放动画让玩家注意到“我赚钱了”。这个反馈在快节奏战斗中很重要否则玩家可能根本没注意到金币在涨。4. 常见问题与排查技巧实录4.1 敌人卡住不动或路径异常这是塔防游戏最常见的问题之一。原因通常有三种路径点顺序错了、敌人到达路径点后没有正确切换到下一个、或者路径点被场景物体挡住了。排查步骤首先在 Scene 视图里把路径点用 Gizmos 画出来确认顺序正确。然后在敌人脚本里加一个OnDrawGizmos显示当前目标点。如果敌人到了目标点但没切换检查pathIndex的递增逻辑是不是在distance threshold时执行的。void Update() { if (pathIndex pathManager.smoothPath.Length) return; Vector3 target pathManager.smoothPath[pathIndex]; transform.position Vector3.MoveTowards( transform.position, target, speed * Time.deltaTime); if (Vector3.Distance(transform.position, target) 0.05f) { pathIndex; } }注意threshold不要设得太小否则敌人可能会在目标点附近“抖动”。0.05 到 0.1 之间比较合适。另外如果路径点非常密集可以考虑用sqrMagnitude比较距离省去开方运算。4.2 炮塔不攻击或攻击目标错误炮塔不攻击的常见原因目标列表为空、目标已死亡但引用还在、或者攻击冷却没重置。我的排查清单是这样的现象可能原因解决方法炮塔完全不转目标列表为空检查敌人是否加入了Enemy层炮塔转但不射击冷却时间未初始化在Start里把fireCooldown 0射击但没伤害目标已死亡攻击前检查target.activeInHierarchy攻击错误目标目标选择逻辑有误用“最靠近终点”而非“最近”作为优先级目标选择我推荐用“最靠近终点的敌人”而不是“最近的敌人”。因为塔防的核心是阻止敌人到达终点优先打最前面的敌人更符合策略直觉。4.3 性能问题与优化建议敌人数量超过 50 个之后如果没做优化帧率会明显下降。我实测下来以下几个优化最有效第一对象池。敌人、子弹、特效全部用对象池避免频繁Instantiate和Destroy。第二关闭不必要的碰撞检测。敌人之间不需要碰撞把它们的碰撞层设置好只和子弹、炮塔检测。第三血条用 SpriteRenderer 而非 Canvas。第四伤害数字合并显示。// 简单的对象池实现 public class ObjectPool : MonoBehaviour { private Dictionarystring, QueueGameObject pools new(); public GameObject Spawn(string key, GameObject prefab, Vector3 pos) { if (!pools.ContainsKey(key)) pools[key] new QueueGameObject(); GameObject obj; if (pools[key].Count 0) { obj pools[key].Dequeue(); obj.SetActive(true); } else { obj Instantiate(prefab); } obj.transform.position pos; return obj; } public void Recycle(string key, GameObject obj) { obj.SetActive(false); pools[key].Enqueue(obj); } }实操心得对象池的 key 建议用 prefab 的名字简单直接。回收时记得重置对象的状态血量、速度、动画等否则下次取出时会带着上次的“残留数据”。这个 bug 很隐蔽我当初调了半小时才发现是对象池没重置。4.4 数值平衡的调试技巧数值平衡是塔防游戏最耗时的部分。我的做法是先做一张 Excel 表把所有炮塔的 DPS、敌人的 HP、每波的总 HP 都算出来然后看曲线是否合理。具体来说第 N 波的总 HP 应该和玩家在第 N 波时的总 DPS 大致匹配略高一点让玩家有压力但不至于绝望。如果某一波突然难度飙升检查是不是敌人数量或 HP 加太多了。另外金币产出要略高于消耗让玩家有“攒钱”的余地。如果金币刚好够用玩家就没有策略选择的空间了。我的经验是每波结束后玩家手里的金币应该能买 1.5 到 2 个新塔或者升级 1 个塔到下一级。最后分享一个调试技巧在编辑器里加一个“作弊面板”可以一键加金币、跳波、秒杀敌人。这个面板在开发阶段能省大量时间发布前记得禁用或移除。#if UNITY_EDITOR void OnGUI() { if (GUILayout.Button(1000 Gold)) GameManager.Instance.AddGold(1000); if (GUILayout.Button(Skip Wave)) waveManager.SkipCurrentWave(); } #endif这个#if UNITY_EDITOR宏确保作弊代码不会被打包到正式版本里安全又方便。4.5 打包与发布注意事项最后一步是打包。Unity 打包时有几个坑要注意Player Settings 里的分辨率设置要适配目标平台Quality Settings里关掉不必要的特效Scripting Backend选 IL2CPP 性能更好但编译更慢。如果目标是移动端记得在Quality Settings里把阴影关掉或降到最低纹理质量也适当降低。我实测下来中低端手机上关掉实时阴影能提升 20% 到 30% 的帧率。另外AAB 格式是 Google Play 要求的Unity 2018 以上版本都支持。打包前记得在Publishing Settings里配置好 Keystore否则上传会被拒。注意打包前一定要在真机上测试一遍尤其是触摸操作和 UI 适配。编辑器里跑得好好的真机上可能按钮点不到或者 UI 错位。这个坑我踩过不止一次现在每次打包前都会在至少两台设备上过一遍。整个 Part2 做下来最大的体会是塔防游戏的乐趣不在于画面多炫而在于“决策-反馈”的循环是否紧凑。玩家放一个塔立刻看到敌人被打金币在涨这种即时反馈才是留住玩家的关键。技术实现上对象池、数据驱动、组合优于继承这三条原则贯穿始终让后期加内容和调平衡变得轻松很多。如果你也在做类似的项目建议先把波次管理和经济系统跑通再慢慢加炮塔类型和特效不要一开始就追求大而全。