☰
Unity塔防游戏开发实战:波次驱动、炮塔组合与性能优化
2026/10/1 9:13:03 网站建设 项目流程

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 List<EnemySpawnInfo> 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.GetComponent<Enemy>(); 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.GetComponent<Enemy>(); 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.GetComponent<SpriteRenderer>(); 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 List<Vector3>(); 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.Action<int, 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.GetComponent<Enemy>().Init(pathManager); enemy.GetComponent<Enemy>().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 Dictionary<string, Queue<GameObject>> pools = new(); public GameObject Spawn(string key, GameObject prefab, Vector3 pos) { if (!pools.ContainsKey(key)) pools[key] = new Queue<GameObject>(); 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 做下来,最大的体会是:塔防游戏的乐趣不在于画面多炫,而在于“决策-反馈”的循环是否紧凑。玩家放一个塔,立刻看到敌人被打,金币在涨,这种即时反馈才是留住玩家的关键。技术实现上,对象池、数据驱动、组合优于继承,这三条原则贯穿始终,让后期加内容和调平衡变得轻松很多。如果你也在做类似的项目,建议先把波次管理和经济系统跑通,再慢慢加炮塔类型和特效,不要一开始就追求大而全。

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

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

立即咨询