☰
Unity塔防游戏开发实战:从核心系统拆解到性能调优的完整指南
2026/10/1 5:16:56 网站建设 项目流程

1. 塔防游戏核心系统拆解与架构思路

1.1 为什么塔防游戏是检验Unity综合能力的试金石

塔防这个品类,看起来简单——放塔、打怪、守家,三个动作就概括完了。但真正动手做过的人都知道,它几乎把Unity日常开发中所有高频系统都串了一遍:寻路与目标选择、对象池管理、UI与场景交互、数据配置与数值平衡、动画状态机、音效反馈、存档与关卡进度。你很难找到第二个品类,能用这么小的体量把这些东西全塞进去。

Part1里我们把基础框架搭完了——地图网格、防御塔的放置逻辑、敌人的基础移动。Part2要解决的是“让它真正像个游戏”的问题:敌人怎么找到正确的路、塔怎么判断打谁、子弹怎么飞、伤害怎么算、波次怎么调度、UI怎么跟游戏状态同步。这些才是决定一个塔防Demo能不能变成可玩作品的分水岭。

我见过太多人卡在Part1和Part2之间——基础功能跑通了,但一加敌人种类、一加塔的类型,代码就开始互相打架。根本原因在于Part1阶段没有把数据驱动和事件解耦这两件事想清楚。所以Part2的第一件事,不是急着写新功能,而是回头审视架构:你的塔和敌人之间,是直接引用还是通过管理器通信?你的波次数据是硬编码在脚本里还是走ScriptableObject?这些选择直接决定了后面加内容时是“改一行配置”还是“重写半个项目”。

1.2 目标选择与优先级:塔防的“大脑”在哪里

塔防游戏最核心的决策逻辑,全在目标选择这一块。一个敌人进入塔的射程,塔要回答三个问题:打不打?打谁?什么时候打?

“打不打”取决于射程检测。这里有个容易踩的坑:很多人直接用Vector3.Distance每帧算距离,敌人一多,CPU直接起飞。正确的做法是用Physics.OverlapSphere配合LayerMask做粗筛,再用距离做精筛。更激进一点,可以用空间哈希把地图切成格子,塔只需要查询自己所在格子和相邻格子里的敌人——这个优化在敌人数量超过50个之后效果非常明显。

“打谁”就是优先级策略。常见的几种:

优先级策略适用场景实现要点
最先进入射程单体高伤塔记录进入时间戳,取最小值
距离终点最近拦截型塔需要敌人暴露路径进度参数
血量最低收割型塔每帧排序开销大,建议用堆
血量最高破甲型塔同上,注意与“距离最近”组合
随机娱乐向用Random.Range做权重轮盘

我个人的经验是:不要每帧重新选目标。塔在目标死亡或离开射程时才重新选择,中间用缓存引用。这样既省性能,又避免塔的炮管疯狂抖动——玩家看到炮管来回摆,体验非常差。

“什么时候打”就是攻击冷却。这里建议用计时器累加而不是InvokeRepeating,因为你需要动态修改攻速(比如被减速、被加速),InvokeRepeating改起来很别扭。用一个float attackTimer,每帧attackTimer += Time.deltaTime,大于1f / attackSpeed就触发攻击并归零。

1.3 对象池:为什么你的塔防一到后期就卡

塔防游戏有个特点:子弹和敌人是高频生成销毁的。一发子弹飞出去,命中,销毁;一个敌人死亡,销毁。如果每次都用Instantiate和Destroy,GC(垃圾回收)会每隔几秒就触发一次,表现就是游戏每隔一段时间卡一下。

对象池的思路很简单:用完不销毁,藏起来下次再用。但实现上有几个细节决定成败:

  • 池子的容量要动态增长,但要有上限。我一般设初始容量20,最大200,超过上限就真的销毁。
  • 取出时一定要重置状态——位置、旋转、速度、血量、动画状态,一个都不能漏。我踩过最坑的一次是子弹从池子里取出来还带着上次的trail拖尾,视觉上像一条蛇。
  • 归还时不要直接SetActive(false)就完事,先把它从所有管理列表里移除,再禁用。否则你的目标选择逻辑可能还会引用到一个“已经死了但还没藏起来”的敌人。

Unity 2021之后官方提供了UnityEngine.Pool.ObjectPool,但说实话,自己写一个泛型池更可控。下面是我常用的简化版:

public class ObjectPool<T> where T : Component { private readonly T _prefab; private readonly Transform _parent; private readonly Stack<T> _stack = new Stack<T>(); private readonly int _maxSize; public ObjectPool(T prefab, Transform parent, int initialSize = 20, int maxSize = 200) { _prefab = prefab; _parent = parent; _maxSize = maxSize; for (int i = 0; i < initialSize; i++) { var obj = Object.Instantiate(_prefab, _parent); obj.gameObject.SetActive(false); _stack.Push(obj); } } public T Get() { T obj; if (_stack.Count > 0) { obj = _stack.Pop(); } else { obj = Object.Instantiate(_prefab, _parent); } obj.gameObject.SetActive(true); return obj; } public void Return(T obj) { if (_stack.Count >= _maxSize) { Object.Destroy(obj.gameObject); return; } obj.gameObject.SetActive(false); _stack.Push(obj); } }

这个池子够用,但注意:它不负责重置状态。重置逻辑要写在子弹或敌人自己的OnSpawn和OnDespawn里。这是设计上的取舍——池子只管存取,业务逻辑自己管自己。

2. 敌人寻路与波次调度的实战细节

2.1 A*还是Waypoint?塔防寻路的选型逻辑

塔防游戏的寻路,和RPG、RTS完全不是一回事。RPG里角色要绕开障碍物、走最短路径;塔防里敌人只走固定路线,玩家不能改变路线,只能沿路布防。这个前提决定了:你根本不需要A*算法。

Waypoint(路点)系统才是塔防的正解。在地图上摆一串空物体作为路径点,敌人从起点出发,依次朝下一个路点移动,到达后切换到再下一个。简单、高效、可控。而且路点系统有个隐藏好处:你可以精确控制敌人的行进节奏。比如在某个路点设置“停留2秒”,就能做出“敌人在这里集结”的效果。

但Waypoint有个变种值得注意:分支路径。有些塔防允许敌人走到某个路口时随机选左或选右。实现方式是在路点数据里加一个nextPoints数组,到达时随机取一个。这个机制能让同一波敌人从不同方向进攻,大幅提升策略深度。

路点移动的核心代码大概长这样:

public class WaypointMover : MonoBehaviour { public Transform[] waypoints; public float moveSpeed = 3f; public float reachThreshold = 0.1f; private int _currentIndex = 0; private int _direction = 1; private void Update() { if (_currentIndex >= waypoints.Length) return; Transform target = waypoints[_currentIndex]; Vector3 dir = (target.position - transform.position).normalized; transform.position += dir * moveSpeed * Time.deltaTime; transform.rotation = Quaternion.LookRotation(dir); if (Vector3.Distance(transform.position, target.position) < reachThreshold) { _currentIndex++; if (_currentIndex >= waypoints.Length) { OnReachEnd(); } } } private void OnReachEnd() { // 到达终点,扣血、播放动画、归还对象池 } }

注意reachThreshold这个值。设太小,敌人可能因为浮点误差永远到不了;设太大,敌人会在路点附近“抖”一下才转向。我一般用0.1f到0.3f之间,具体看移动速度——速度越快,阈值可以适当放大。

2.2 波次系统的数据驱动设计

波次调度是塔防的“节奏控制器”。第一波几个小怪,第二波加两个快速怪,第五波来个Boss——这个节奏感直接决定游戏好不好玩。硬编码波次数据是新手最容易犯的错,因为改一个数字就要重新编译,调平衡时痛不欲生。

正确的做法是用ScriptableObject做波次配置。每个波次是一个资产,里面包含:

  • 敌人类型和数量
  • 生成间隔
  • 波次之间的等待时间
  • 特殊规则(比如“本波敌人移速+20%”)
[CreateAssetMenu(fileName = "WaveData", menuName = "TD/WaveData")] public class WaveData : ScriptableObject { public EnemySpawnEntry[] entries; public float timeBetweenWaves = 5f; public bool isBossWave; } [System.Serializable] public class EnemySpawnEntry { public GameObject enemyPrefab; public int count = 5; public float spawnInterval = 1f; public float delayBeforeStart = 0f; }

波次管理器的逻辑就是遍历entries,按spawnInterval生成敌人,全部生成完后等timeBetweenWaves,再进入下一波。这里有个细节:“本波是否清空”和“本波是否生成完毕”是两个概念。有些塔防允许玩家提前召唤下一波(给奖励),这时候就要区分“生成完毕”和“场上清空”。我建议用两个事件:OnWaveSpawnComplete和OnWaveClear,UI分别响应。

2.3 敌人状态机:别让一个bool管所有事

敌人身上有很多状态:移动中、被减速、被眩晕、死亡中、已死亡。新手容易用一堆bool来管:isMoving、isSlowed、isStunned、isDead。然后你会发现,当敌人同时被减速和眩晕时,代码里到处是if (isSlowed && !isStunned)这种判断,改一个状态要动十个地方。

用枚举状态机会清爽很多:

public enum EnemyState { Moving, Slowed, Stunned, Dying, Dead }

但枚举状态机也有局限:它只能表示“当前处于哪个状态”,不能表示“同时受哪些效果影响”。所以更实用的做法是状态机管主流程,Buff列表管效果。敌人有一个List<StatusEffect>,每个效果有自己的持续时间和每帧逻辑。减速效果就是修改moveSpeed的乘数,眩晕就是禁止移动,中毒就是每帧扣血。效果到期自动移除,主状态机只关心“活着还是死了”。

这个设计的好处是:加新效果不用改状态机。以后要加“燃烧”“冰冻”“易伤”,只需要写一个新的StatusEffect子类,挂上去就行。

3. 防御塔攻击逻辑与伤害计算

3.1 从索敌到开火:一次完整攻击的生命周期

一颗子弹从塔里飞出去,背后是一整条链路。我把这条链路拆成六个阶段,每个阶段都有坑:

阶段一:索敌。塔在Update里检查attackTimer,冷却好了就调用FindTarget()。FindTarget遍历当前射程内的敌人列表,按优先级策略选一个。这里的关键是射程内的敌人列表要提前维护好,不要每次索敌都做OverlapSphere。我一般用触发器:塔身上挂一个SphereCollider(IsTrigger),敌人进出时更新列表。

阶段二:转向。塔的炮管要朝向目标。用Quaternion.LookRotation做插值,不要瞬间转过去。Quaternion.Slerp的插值系数用Time.deltaTime * turnSpeed,turnSpeed设在5到10之间比较自然。

阶段三:开火。从对象池取一颗子弹,设置初始位置为炮口,方向为目标方向。如果是追踪弹,把目标引用传给子弹;如果是直线弹,只传方向。

阶段四:飞行。子弹每帧朝目标移动。追踪弹要注意:目标可能在飞行途中死亡。所以子弹每帧要检查目标是否还活着,如果死了就继续朝最后已知位置飞,或者直接归还池子。

阶段五:命中。子弹到达目标位置或与目标碰撞,触发伤害计算。伤害计算要考虑:基础伤害、暴击、护甲减免、易伤加成。我建议把伤害计算封装成一个静态方法:

public static class DamageCalculator { public static float Calculate(float baseDamage, float armor, float critMultiplier, bool isCrit, float vulnerability) { float damage = baseDamage; if (isCrit) damage *= critMultiplier; damage *= (1f - armor / (armor + 100f)); // 护甲公式 damage *= (1f + vulnerability); return Mathf.Max(damage, 1f); // 至少造成1点伤害 } }

护甲公式用armor / (armor + 100)是常见做法,好处是护甲永远不可能减到0伤害,但收益递减。100这个常数可以根据游戏数值调整。

阶段六:归还。子弹命中后播放命中特效,然后归还对象池。注意:先播放特效再归还,因为特效是独立的对象,不归子弹池管。

3.2 范围伤害与穿透:AOE塔的实现要点

单体塔的逻辑相对简单,AOE塔就麻烦一些。核心问题是:什么时候判定范围伤害?

两种方案:

  • 命中时判定:子弹飞到目标位置,以该位置为中心做OverlapSphere,对所有敌人造成伤害。优点是直观,缺点是如果目标在飞行途中移动了,爆炸位置会偏。
  • 到达时判定:子弹追踪目标,到达目标身边时爆炸。优点是准,缺点是如果目标死了,子弹就白飞了。

我一般用第一种,但加一个预判:根据目标当前速度和子弹飞行时间,算一个提前量。这样子弹会朝目标“将要在的位置”飞,命中率更高。

穿透塔的逻辑又不一样。穿透弹在命中第一个敌人后不消失,继续飞行,对路径上的每个敌人造成伤害。实现方式是用Raycast或者OnTriggerEnter,维护一个“已命中敌人”的HashSet,避免同一个敌人被重复伤害。

3.3 塔的升级与出售:经济系统的闭环

塔防的经济循环是:杀敌赚钱 → 造塔/升级 → 杀更强的敌 → 赚更多钱。这个循环要转起来,升级和出售是两个关键阀门。

升级的逻辑通常是:等级+1,攻击力/射程/攻速按配置表提升,外观可能变化。这里的数据结构建议用数组或列表,每个元素对应一级的属性:

[System.Serializable] public class TowerLevelData { public float damage; public float range; public float attackSpeed; public int upgradeCost; public GameObject visualPrefab; } public TowerLevelData[] levels;

升级时currentLevel++,然后从levels[currentLevel]读取新属性。出售时返还总投入的70%左右——这个比例不能太高,否则玩家会频繁“卖了重造”来调整布局;也不能太低,否则玩家不敢尝试新策略。70%是我试过比较舒服的数值。

出售还有一个细节:出售时要把塔占用的格子标记为空,否则玩家卖了之后发现原地造不了新塔,会非常困惑。

4. UI同步、音效反馈与性能调优

4.1 游戏状态与UI的事件驱动同步

UI和游戏逻辑的同步,新手最容易写成“每帧从管理器拉数据”。比如Update里写goldText.text = GameManager.Instance.Gold.ToString()。这样写能跑,但有两个问题:一是每帧都在做字符串转换,GC压力大;二是逻辑和UI耦合,以后想换UI框架要改一堆地方。

正确做法是事件驱动。游戏状态变化时发事件,UI订阅事件更新。Unity自带的UnityEvent或者C#的event都可以。我一般用静态事件中心:

public static class GameEvents { public static event Action<int> OnGoldChanged; public static event Action<int> OnWaveChanged; public static event Action<float> OnBaseHealthChanged; public static void GoldChanged(int value) => OnGoldChanged?.Invoke(value); public static void WaveChanged(int value) => OnWaveChanged?.Invoke(value); public static void BaseHealthChanged(float value) => OnBaseHealthChanged?.Invoke(value); }

UI脚本在OnEnable里订阅,OnDisable里取消订阅。这样即使UI对象被销毁重建,也不会出现空引用。

注意:静态事件在编辑器里跨场景会残留。如果你在编辑器里反复播放停止,可能会发现事件被重复订阅。解决办法是在OnDisable里确保取消订阅,或者用[RuntimeInitializeOnLoadMethod]在游戏启动时清空事件。

4.2 音效与打击感:小细节决定手感

塔防游戏的打击感,一半来自音效。子弹命中、敌人死亡、塔升级、金币入账——每个动作都要有声音反馈。但音效不是随便挂个AudioSource就完事。

几个实操要点:

  • 音效要随机化。同一个命中音效连续播放十次,玩家会听腻。准备2到3个变体,随机选一个,同时随机微调pitch(0.95到1.05之间)。
  • 音效要限流。敌人密集死亡时,十个死亡音效同时播放会爆音。用一个简单的计数器,同一帧最多播放3个同类音效。
  • 音效要分层。命中音效分“打中护甲”和“打中血肉”两种,根据敌人类型切换。这个细节玩家不一定能说出来,但能感觉到“打起来不一样”。

4.3 性能调优:从60帧到120帧的实操记录

塔防游戏后期,屏幕上可能有50个敌人、20座塔、上百颗子弹。如果不做优化,帧率会从60掉到30。我记录了一次实际的优化过程:

问题一:每帧大量GetComponent调用。塔在索敌时对每个敌人调用GetComponent<Enemy>()。优化:敌人在Awake时把自己的引用注册到EnemyManager的列表里,塔直接查列表。

问题二:UI文本每帧更新。金币文本每帧刷新。优化:只在金币变化时更新,用事件驱动。

问题三:子弹的Update开销。每颗子弹都有自己的Update,100颗子弹就是100次Update调用。优化:把子弹的逻辑集中到BulletManager的Update里,用一个List<Bullet>遍历更新。这样只有一次Update调用,缓存友好性也更好。

问题四:阴影和实时光。塔防场景通常不需要实时阴影。把Quality Settings里的阴影关掉,或者只对主要单位开启。光照用Baked,不要用Realtime。

优化前后对比:

指标优化前优化后
平均帧率32 FPS118 FPS
GC触发频率每3秒每30秒
Draw Call28095
子弹Update调用100次/帧1次/帧

这些优化不需要什么高深技术,就是把每帧要做的事减到最少。塔防的逻辑更新频率其实不需要每帧——索敌可以每0.1秒做一次,路径点检测可以每0.05秒做一次。用Coroutine或者计时器做分帧更新,能省下大量CPU。

5. 常见问题与排查技巧实录

5.1 敌人卡在路点不动怎么办

这是Waypoint系统最常见的问题。原因通常有三个:

  • 阈值太小:敌人移动速度慢,每帧移动距离小于reachThreshold,导致永远判定为“未到达”。解决:阈值设为moveSpeed * Time.deltaTime * 2左右。
  • 路点Y轴不一致:敌人和路点的Y轴不同,Vector3.Distance算出来永远大于阈值。解决:比较时把Y轴归零,只用XZ平面距离。
  • 路点数组为空:waypoints没赋值,或者赋值了但元素是null。解决:在Start里做空检查,打印警告。

5.2 塔不攻击的排查清单

塔不攻击,按这个顺序查:

  1. 射程内有没有敌人?打印enemiesInRange.Count。
  2. attackTimer有没有累加?打印attackTimer。
  3. attackSpeed是不是0?检查配置表。
  4. 目标引用是不是null?敌人死亡后有没有从列表移除?
  5. 子弹池是不是空了?池子取不出对象时会返回null。

我遇到最隐蔽的一次是:塔的SphereCollider半径设成了0.1,敌人从旁边走过根本没触发OnTriggerEnter。把半径改成和射程一致就好了。

5.3 对象池导致的“幽灵子弹”

子弹归还池子后,如果还有协程或Invoke在引用它,下次取出来时会出现“幽灵行为”——比如子弹还没发射就自己飞了。解决办法:归还时停止所有协程和Invoke。在OnDespawn里调用StopAllCoroutines()和CancelInvoke()。

5.4 波次数据配置错误的快速定位

波次不生成敌人,检查:

  • WaveData资产有没有赋值到WaveManager?
  • entries数组是不是空的?
  • enemyPrefab是不是null?
  • spawnInterval是不是0?如果是0,所有敌人会在同一帧生成,可能瞬间卡死。

建议在WaveManager里加一个Debug.Log,每次生成敌人时打印“Wave X, Spawn Y, Enemy Z”。出问题时看日志,一目了然。

5.5 伤害数字不显示的排查

伤害数字(飘字)不显示,通常是:

  • Canvas的Sort Order太低,被其他UI挡住。
  • 飘字对象的RectTransform位置没转换到屏幕空间。
  • 对象池取出的飘字没有重置alpha,上次淡出后alpha是0。

飘字系统我建议用世界空间Canvas,每个飘字是一个独立对象,从池子里取,设置位置和文字,播放一个“上浮+淡出”的动画,然后归还。不要用屏幕空间Canvas,因为世界坐标转屏幕坐标在摄像机移动时会很麻烦。

6. 从Demo到可玩作品的最后一步

6.1 关卡数据的序列化与存档

一个塔防游戏如果每次打开都从第一关开始,玩家会疯。存档系统不需要复杂,用JsonUtility把关卡进度、金币、已解锁塔的类型存到Application.persistentDataPath就行。

[System.Serializable] public class SaveData { public int currentLevel; public int totalGold; public bool[] unlockedTowers; } public static class SaveSystem { private static string Path => Application.persistentDataPath + "/save.json"; public static void Save(SaveData data) { string json = JsonUtility.ToJson(data, true); File.WriteAllText(Path, json); } public static SaveData Load() { if (!File.Exists(Path)) return new SaveData(); string json = File.ReadAllText(Path); return JsonUtility.FromJson<SaveData>(json); } }

注意:JsonUtility不支持字典,所以unlockedTowers用数组而不是Dictionary。如果要存更复杂的数据,考虑用Newtonsoft.Json或者自己写序列化。

6.2 打包发布前的检查清单

在出包之前,过一遍这个清单:

  • Player Settings里的Company Name和Product Name改了吗?
  • 分辨率设置有没有适配不同屏幕比例?
  • 所有Debug.Log有没有移除或条件编译?
  • 对象池的初始容量够不够?第一波会不会因为池子为空而卡顿?
  • 音效的AudioSource有没有设置spatialBlend?2D还是3D?
  • 有没有在Update里做字符串拼接?有的话改成StringBuilder或缓存。

6.3 后续扩展方向

这个框架搭完之后,能扩展的方向很多:

  • 塔的羁绊系统:相邻的同类塔获得加成,鼓励玩家规划布局。
  • 敌人技能:比如“死亡时分裂成两个小怪”“周期性免疫物理伤害”。
  • 天气系统:雨天减速、晴天加攻速,增加随机性。
  • 无尽模式:波次无限递增,比谁守得久。

我个人在实际操作中的体会是:塔防的乐趣不在于塔有多强,而在于选择有多难。每加一种新塔或新敌人,都要问自己:这个新内容会让玩家的决策变得更丰富,还是只是数值膨胀?如果只是数值变大,那不如不加。

最后再分享一个小技巧:在Tower脚本里加一个OnDrawGizmosSelected,把射程画成线框。调数值的时候在Scene视图里直接看到射程范围,比反复运行游戏试要快十倍。这个习惯我从第一个塔防项目保持到现在,每次调平衡都省下大量时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询