1. 从Part1的骨架到Part2的血肉:塔防游戏收尾阶段到底在做什么
很多人做塔防游戏,Part1把地图、路径、炮塔基类、敌人移动这些跑通之后,就觉得“差不多了”,结果一到Part2就卡住——因为真正让一个塔防游戏“能玩”而不是“能动”的东西,全在收尾阶段。Part2要处理的是:炮塔攻击逻辑的完整闭环、敌人血量与死亡结算、波次系统的节奏控制、UI与游戏状态的联动、以及最容易被忽视的数值平衡与性能收口。这些东西单独拎出来都不难,但要在Unity里把它们串成一条稳定的流水线,需要你对MonoBehaviour生命周期、协程调度、对象池、事件解耦有比较清晰的认知。
我自己做塔防的时候,Part1通常只花三成时间,剩下七成全砸在Part2。原因很简单:Part1是“搭台子”,Part2是“唱戏”。台子搭歪了还能凑合,戏唱砸了玩家三秒就关掉。所以这篇内容我会围绕一个核心问题展开——如何把Part1留下的半成品,改造成一个帧率稳定、逻辑清晰、方便后续加关卡加炮塔的完整塔防Demo。适合已经跟着Part1走完、手里有一个能跑但不好玩的项目的开发者,也适合想理解“游戏收尾工程”到底包含哪些环节的中级Unity使用者。
关键词里提到的Unity、塔防、游戏开发是主线,但我会把热词里那些零散的技术点——比如对象池、协程、UI适配、性能优化、摄像机跟随——自然地嵌进具体问题的解决过程里,而不是单独开一节泛泛而谈。毕竟做项目不是背API,是遇到问题去找工具。
2. 炮塔攻击闭环:从“能打”到“打得对”之间差了什么
2.1 攻击判定为什么不能直接写在Update里
Part1里很多人的炮塔是这样写的:在Update里检测范围内有没有敌人,有就扣血。跑起来好像没问题,但敌人一多、炮塔一多,帧率就开始抖。根本原因是每帧对每个炮塔做一次范围检测,复杂度是O(炮塔数×敌人数),20个炮塔配30个敌人就是600次距离计算,每帧600次,加上Instantiate和Destroy的GC,卡顿是必然的。
正确的做法是把“检测”和“攻击”拆开。检测用定时器+物理查询,攻击用协程或状态机。我通常给炮塔挂一个TurretController,内部维护一个target引用和一个fireCooldown计时器。检测频率不需要每帧,0.1秒一次足够,因为塔防的敌人移动速度通常不快,0.1秒的检测间隔不会导致明显漏判。
private void Update() { detectTimer -= Time.deltaTime; if (detectTimer <= 0f) { detectTimer = detectInterval; // 0.1f AcquireTarget(); } if (target != null && target.IsAlive) { fireTimer -= Time.deltaTime; if (fireTimer <= 0f) { Fire(); fireTimer = fireRate; } } }这里有个细节:AcquireTarget里不要用Physics.OverlapSphere返回数组再遍历,那个API每次调用都会分配新数组,GC压力大。用Physics.OverlapSphereNonAlloc配合一个预分配的Collider[]缓冲区,或者更轻量的做法——因为塔防敌人通常沿固定路径走,你可以直接遍历敌人管理器里的活跃列表,用平方距离比较,省掉物理查询。
2.2 目标选择策略:为什么“最近”不一定是最好的
最朴素的策略是选最近的敌人,但实际玩起来你会发现,敌人扎堆的时候炮塔会反复切换目标,导致每发子弹都打在不同敌人身上,谁都打不死。这就是目标抖动问题。解决办法是加一个目标锁定阈值:如果当前目标还活着且在射程内,就不换目标;只有当前目标死亡或离开射程,才重新选。
更进一步,塔防常见的策略有几种:最近、最远、血量最高、血量最低、最靠近终点。我建议在TurretController里用一个枚举TargetPriority,让不同炮塔类型可以配置不同策略。比如单体高伤炮塔选“血量最高”,AOE炮塔选“最靠近终点”,减速炮塔选“最近”。这样玩家在布防时才有策略深度,而不是所有炮塔一个行为。
public enum TargetPriority { Nearest, Farthest, HighestHP, LowestHP, ClosestToEnd }选目标的时候,遍历活跃敌人列表,按优先级打分,取最高分。注意不要每帧遍历,只在AcquireTarget调用时遍历,频率已经降到0.1秒一次,性能完全可控。
2.3 子弹与命中:用对象池替代Instantiate
Part1里子弹大概率是Instantiate出来的,打一枪生成一个,命中后Destroy。这在Demo阶段没问题,但一旦射速上去,GC就会频繁触发。Unity的GC是Stop-the-world的,每次触发都会造成帧率尖峰。解决办法是对象池。
对象池的核心逻辑很简单:预生成一批子弹对象,禁用后放回池子,需要时从池子取,用完还回去。Unity 2021之后自带的ObjectPool<T>可以用,但自己写一个更可控。关键点是重置状态——从池子里取出来的子弹,速度、目标、伤害都要重新赋值,否则会出现“子弹飞到一半突然拐弯”的诡异现象。
public class BulletPool : MonoBehaviour { [SerializeField] private Bullet bulletPrefab; [SerializeField] private int initialSize = 30; private Queue<Bullet> pool = new Queue<Bullet>(); private void Awake() { for (int i = 0; i < initialSize; i++) { var b = Instantiate(bulletPrefab, transform); b.gameObject.SetActive(false); pool.Enqueue(b); } } public Bullet Get() { if (pool.Count == 0) { var b = Instantiate(bulletPrefab, transform); b.gameObject.SetActive(false); pool.Enqueue(b); } var bullet = pool.Dequeue(); bullet.gameObject.SetActive(true); return bullet; } public void Return(Bullet b) { b.gameObject.SetActive(false); b.transform.SetParent(transform); pool.Enqueue(b); } }子弹的移动我倾向于用协程做追踪而不是物理刚体,因为塔防子弹不需要真实物理碰撞,追踪逻辑更可控。协程里每帧朝目标位置插值,到达阈值内就判定命中,调用target.TakeDamage(damage),然后归还池子。注意目标可能在子弹飞行途中死亡,这时候要判空,子弹直接归还,不要报NullReference。
提示:对象池的初始大小要根据最大同时子弹数估算。射速2发/秒、飞行时间0.5秒、10个炮塔同时开火,峰值大约10发,初始30足够。池子不够时会动态扩容,但扩容本身有Instantiate开销,所以初始值宁可大一点。
3. 敌人生命周期:血量、死亡、结算与对象回收
3.1 血量系统为什么不该用float直接减
敌人血量用float没问题,但要注意伤害结算的时序。如果多个子弹同一帧命中同一个敌人,每个子弹都调用TakeDamage,敌人可能在第一次调用时就死了,后续调用会打到“尸体”上。解决办法是在TakeDamage里先判断isDead,死亡后直接return。同时死亡处理只执行一次,用一个bool标记或者把currentHP <= 0作为唯一入口。
public void TakeDamage(float amount) { if (isDead) return; currentHP -= amount; if (currentHP <= 0f) { isDead = true; Die(); } }Die()里做三件事:播放死亡特效(如果有)、通知波次系统敌人已清除、把自身归还对象池。注意不要在Die里直接Destroy,因为对象池需要复用。归还之前要把血量重置、状态重置、协程停止。
3.2 敌人到达终点后的处理与生命值扣减
塔防的核心失败条件是“漏怪”。敌人走到路径终点时,要扣玩家生命值,然后销毁敌人。这里有个容易忽略的点:扣血和销毁的顺序。如果先销毁再扣血,万一销毁过程中触发了其他逻辑(比如波次系统检测到敌人列表变化),可能导致状态不一致。我的做法是先在GameManager里扣血,再调用敌人的Recycle。
private void OnReachEnd() { GameManager.Instance.LoseLife(1); Recycle(); }LoseLife里判断生命值是否归零,归零就触发游戏结束。游戏结束不要直接Time.timeScale = 0,因为UI动画也会停。更好的做法是用一个GameState枚举,Playing、Paused、GameOver,在Update里根据状态决定是否执行游戏逻辑。UI用unscaledDeltaTime做动画,这样暂停时UI还能动。
3.3 对象池回收时的状态重置清单
敌人回收时最容易出的bug是“复用的敌人带着上一局的状态”。我列一个必重置清单:
| 状态项 | 重置值 | 不重置的后果 |
|---|---|---|
| currentHP | maxHP | 新敌人一出来就残血 |
| isDead | false | 新敌人无法受伤 |
| 路径索引 | 0 | 新敌人从半路开始走 |
| 移动速度 | baseSpeed | 被减速后永久减速 |
| 协程 | StopAllCoroutines | 旧协程继续跑,位置错乱 |
| 渲染器颜色 | 默认色 | 带着受击闪红 |
这个清单我建议直接写成一个ResetState()方法,在OnEnable里调用,而不是在回收时调用。因为对象池取出时也会触发OnEnable,这样无论从哪条路径来,状态都是干净的。
4. 波次系统:节奏感是塔防好玩与否的分水岭
4.1 波次数据结构怎么设计才方便扩展
Part1的波次大概率是硬编码的,比如for循环生成10个敌人。Part2要把它数据化。我推荐用ScriptableObject定义波次配置,每个波次包含若干“生成组”,每组指定敌人类型、数量、生成间隔、组间延迟。
[CreateAssetMenu(fileName = "WaveConfig", menuName = "TD/WaveConfig")] public class WaveConfig : ScriptableObject { public List<SpawnGroup> groups; } [System.Serializable] public class SpawnGroup { public EnemyType enemyType; public int count; public float spawnInterval; public float delayAfterGroup; }这样做的好处是加关卡不用改代码,策划(或者你自己)在Inspector里配就行。而且ScriptableObject可以在多个场景间共享,做无尽模式的时候可以循环读取波次列表。
4.2 波次之间的间隔与“提前召唤”机制
波次之间要有间隔,让玩家有时间建塔、升级。但纯等待很无聊,所以很多塔防会加一个“提前召唤下一波”的按钮,给额外奖励。实现方式是:波次间隔期间启动一个倒计时,如果玩家点击按钮,倒计时立即结束,同时给一笔金币奖励。
private IEnumerator WaveInterval() { float timer = intervalBetweenWaves; while (timer > 0f) { timer -= Time.deltaTime; UIManager.Instance.UpdateWaveTimer(timer); if (callNextWaveRequested) { GameManager.Instance.AddGold(earlyCallBonus); callNextWaveRequested = false; yield break; } yield return null; } }注意callNextWaveRequested这个flag要在波次开始时重置,否则会连续触发。另外提前召唤的奖励金额要随波次递增,不然后期玩家不在乎那点钱。
4.3 波次进度与UI的同步:别让玩家猜还剩多少怪
玩家需要知道当前波次还剩多少敌人、下一波什么时候来。UI上至少要有:当前波次编号、剩余敌人数、下一波倒计时。剩余敌人数不能简单用“已生成数-已死亡数”,因为还有未生成的。正确做法是维护一个remainingEnemies计数器,生成时加、死亡或漏怪时减,归零且所有生成组执行完毕才判定波次结束。
public int RemainingEnemies => spawnedCount - deadCount - leakedCount;这里有个边界情况:如果敌人被漏掉(到达终点),它既不算死亡也不算存活,但波次必须能结束。所以leakedCount也要计入。我见过有人只统计死亡,结果漏怪后波次永远不结束,游戏卡死。
5. UI与游戏状态的联动:别让界面和逻辑各跑各的
5.1 金币、生命、波次的UI更新为什么不该每帧刷新
新手最容易犯的错是在Update里每帧调用text.text = gold.ToString()。Text的赋值会触发网格重建,每帧重建就是每帧GC。正确做法是事件驱动:金币变化时触发OnGoldChanged事件,UI订阅事件更新。Unity的UnityEvent或者C#的Action都可以。
public static event Action<int> OnGoldChanged; private int gold; public int Gold { get => gold; set { if (gold == value) return; gold = value; OnGoldChanged?.Invoke(gold); } }UI脚本在OnEnable里订阅,OnDisable里取消订阅。注意一定要取消订阅,否则场景切换时旧UI对象还被事件引用,造成内存泄漏和空引用。
5.2 炮塔选择与建造模式的UI交互
塔防的建造流程通常是:点击炮塔按钮→进入建造模式→地图上显示可放置区域→点击空地放置→扣钱→退出建造模式。这个流程用状态机管理最清晰。BuildManager维护一个currentTurretPrefab,进入建造模式时把地图上的建造点高亮,点击建造点时实例化炮塔。
这里有个手感细节:建造模式下点击已有炮塔应该取消建造,而不是弹出升级菜单。很多Demo没处理这个,导致玩家想取消建造却点开了升级面板,体验很割裂。我的做法是在建造模式下,所有点击优先交给BuildManager处理,只有退出建造模式后,点击炮塔才触发升级/出售逻辑。
5.3 游戏结束与重开的完整状态清理
游戏结束时,场上还有敌人、子弹、特效在跑。重开的时候如果不清理,新一局会带着旧对象。最稳妥的做法是重开时重新加载场景,SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex)。这样所有状态归零,不用手动清理。代价是加载时间,但塔防场景通常不大,加载很快。
如果不想重载场景,那就必须手动清理:停止所有协程、归还所有池对象、重置所有管理器单例状态、清空事件订阅。这个清单很长,容易漏。所以我个人强烈建议重开就重载场景,省心且不会出bug。
6. 数值平衡与性能收口:让Demo从“能跑”变成“能玩”
6.1 炮塔DPS与敌人HP的匹配公式
塔防的数值平衡有个简单公式:炮塔DPS × 有效输出时间 ≥ 敌人HP × 敌人数量。有效输出时间取决于敌人经过炮塔射程的时间。假设敌人速度2单位/秒,炮塔射程5单位,路径穿过射程的长度约8单位,那有效输出时间就是4秒。炮塔DPS如果是10,4秒能打40伤害。如果一波有5个敌人,每个敌人HP是30,那单个炮塔不够,需要至少2个炮塔覆盖这段路径。
我通常先定敌人HP和速度,再反推炮塔DPS。第一波敌人HP=20,速度1.5,让玩家用初始金币能建2个炮塔,每个DPS=8,刚好能清掉。后续波次HP按1.15倍递增,炮塔DPS按1.2倍递增(因为炮塔可以升级和增建),这样难度曲线是缓慢上升的。
6.2 用Profiler定位塔防的性能瓶颈
Unity Profiler是收口阶段最重要的工具。塔防常见的性能热点有三个:Instantiate/Destroy的GC、Physics查询、UI重建。打开Profiler,看CPU区域的GC Alloc列,如果每帧有几百B到几KB的分配,基本就是Instantiate或者字符串拼接导致的。
我实测过一个20炮塔30敌人的场景,优化前每帧GC Alloc约2KB,帧率在移动端掉到40。把子弹和敌人全部对象池化、UI改成事件驱动后,GC Alloc降到接近0,帧率稳定60。这个优化幅度在移动端是决定性的。
6.3 摄像机跟随与地图适配的取舍
塔防的摄像机通常是固定俯视角,不需要跟随。但如果地图比屏幕大,就需要摄像机平移。热词里提到的unity摄像机跟随在塔防里更多是“边缘滚动”或者“拖拽平移”。我的建议是塔防不要做跟随,因为玩家需要全局视野来规划布防。如果地图确实大,用拖拽平移+边界限制,不要用跟随敌人。
分辨率适配方面,UI用Canvas Scaler的Scale With Screen Size,参考分辨率设1920×1080,Match设0.5。游戏世界用正交摄像机,orthographicSize根据地图高度调整。这样在不同比例的手机上,地图都能完整显示,UI也不会错位。
7. 我在收尾阶段踩过的几个真实坑
第一个坑是协程在对象禁用时不会自动停止。我用协程做子弹追踪,子弹命中后归还池子,SetActive(false),但协程还在跑,下一帧访问已经禁用的Transform,报了一堆MissingReference。解决办法是在OnDisable里StopAllCoroutines,或者用while (gameObject.activeInHierarchy)做循环条件。
第二个坑是事件订阅导致的重开bug。第一局结束后UI订阅了OnGoldChanged,重开时旧UI没取消订阅,新UI又订阅一次,结果金币变化时旧UI也更新,报空引用。后来我强制在OnDisable里取消所有订阅,并且重开用场景重载,彻底解决。
第三个坑是波次结束判定漏算漏怪。有次测试发现第5波打完后不进入下一波,查了半天发现是漏了一个敌人到终点,deadCount没加,remainingEnemies永远大于0。后来把漏怪也计入leakedCount,波次结束条件改成spawnedCount == deadCount + leakedCount,问题消失。
第四个坑是对象池的子弹带着旧目标。从池子取出的子弹没有清空target引用,结果新子弹飞向上一局已经销毁的敌人,报NullReference。后来在Bullet.OnEnable里强制target = null,由发射时重新赋值,问题解决。
这些坑的共同点是:它们都不会在Part1出现,只在Part2的收尾阶段暴露。因为Part1的逻辑是线性的、单次的,Part2引入了复用、并发、状态切换,复杂度上了一个台阶。所以如果你在Part2遇到各种诡异bug,不要怀疑自己,这是收尾工程的常态。把状态重置、事件解耦、对象池这三件事做扎实,大部分问题都会消失。