资讯详情 Unity塔防开发实战:从植物大战僵尸骨架学状态机与对象池
📅 2026/10/8 4:47:23
简介本资源为Unity引擎实现的《植物大战僵尸》塔防游戏完整源码项目面向Unity初学者、游戏开发学习者及塔防类游戏机制研究者旨在帮助读者深入理解策略游戏的核心逻辑与工程实践。项目包含多关卡设计、植物与僵尸对象管理、碰撞检测、动画状态机控制及游戏进度系统等关键模块覆盖塔防游戏开发全流程。压缩包为RAR格式大小127.12MB虽文件总数未提供但根据描述可确认含C#脚本实现游戏逻辑与AI、场景预制体关卡布局、动画控制器及资源引用配置等典型Unity工程结构。已有944人学习下载适合通过可运行项目反向学习Unity物理引擎应用、MonoBehaviour事件驱动机制及资源管理规范。读者可直接导入Unity编辑器运行调试快速掌握塔防游戏从对象行为建模到关卡流程编排的落地方法。1. 植物大战僵尸源码用 Unity 实现不是复刻原版而是掌握塔防逻辑闭环的实战入口你搜“植物大战僵尸源码 unity”大概率不是想拿一份能直接上线的商业级复刻——那根本不存在版权红线清晰也不是为了跑通一个花哨但逻辑断裂的 Demo。你真正要的是一套可调试、可打断点、可改规则、可加新植物/僵尸的塔防骨架子弹怎么飞、阳光怎么生成、冷却怎么计时、碰撞怎么判定、波次怎么调度……这些在 Unity 里不是靠拖拽组件就能自然发生的黑匣子而是必须亲手串起来的数据流与事件链。我带过 7 个实习生从零搭塔防框架90% 的人卡在“为什么豌豆打不中僵尸”或“阳光一刷新就卡死”上根源不是 C# 不熟而是没把“游戏状态机 对象池 时间驱动”这三根柱子立稳。这篇笔记不讲美术资源怎么切图、不讲 Shader 怎么做卡通渲染只聚焦于用 Unity 2021.3 LTSLTS 版本最稳避坑首选 C# 脚本 原生 UI 系统从空项目开始6 小时内跑通核心循环阳光采集 → 种植植物 → 发射子弹 → 击中僵尸 → 僵尸移动 → 植物死亡 → 波次推进。适合刚写完“Hello World”脚本、想用真实项目倒逼工程能力的 Unity 新手也适合做过简单小游戏、但没碰过“多对象生命周期管理”的进阶者——因为这里每个Destroy()、每个InvokeRepeating()、每个ListZombie的增删都藏着你下次做 MMO 或开放世界时会踩的同款深坑。2. 搭建最小可运行塔防骨架从空场景到第一颗豌豆命中僵尸2.1 创建基础场景与对象层级别急着写代码先画清数据边界Unity 里塔防的混乱80% 源于对象关系没理清。不要一上来就建 50 个 Prefab。按职责隔离原则只建 4 个核心 GameObjectGameController空对象挂GameController.cs全局状态中枢管阳光总数、当前波次、游戏暂停/结束。SunManager空对象挂SunManager.cs只管阳光生成、收集、UI 更新绝不碰植物逻辑。LaneManager空对象挂LaneManager.cs管理 5 条横向车道Y 轴位置固定每条车道存一个ListZombie和ListPlant不存具体实例只存引用。ObjectPool空对象挂ObjectPool.cs预加载豌豆、阳光、爆炸特效等高频创建/销毁对象用Dictionarystring, QueueGameObject管理。提示所有 Manager 类都设为DontDestroyOnLoad错。只有GameController需要跨场景保留比如加主菜单SunManager和LaneManager必须随关卡场景销毁重建。否则下一关阳光数继承上一关或者僵尸列表越积越多——这是新手最常翻车的玄学 Bug。2.2 实现阳光系统用协程控制生成节奏用事件解耦收集逻辑阳光不是“自动出现”而是由SunManager按规则投放。关键不是Instantiate()而是生成时机与收集反馈的分离// SunManager.cs public class SunManager : MonoBehaviour { public int initialSun 50; public float sunSpawnInterval 10f; // 秒 public int sunPerSpawn 25; private int _currentSun 0; private ListGameObject _sunObjects new ListGameObject(); void Start() { _currentSun initialSun; UpdateSunUI(); // 刷新 UI 文本 StartCoroutine(SpawnSunRoutine()); } IEnumerator SpawnSunRoutine() { while (true) { yield return new WaitForSeconds(sunSpawnInterval); if (_currentSun 1000) // 阳光上限 { var sunObj ObjectPool.Instance.GetPooledObject(Sun); if (sunObj ! null) { sunObj.transform.position GetRandomSpawnPosition(); sunObj.SetActive(true); _sunObjects.Add(sunObj); } } } } // 收集阳光时调用由 Sun.cs 脚本触发 public void CollectSun(int amount) { _currentSun amount; UpdateSunUI(); // 发布事件让 UI 或音效系统响应 EventManager.TriggerEvent(SunCollected, amount); } void UpdateSunUI() { // 假设 UI Text 组件挂在此对象下 if (sunText ! null) sunText.text _currentSun.ToString(); } }参数说明sunSpawnInterval 10f不是固定值后期可按波次动态调整第 3 波缩短到 7 秒GetRandomSpawnPosition()返回屏幕顶部随机 X 坐标 固定 Y如Camera.main.pixelHeight * 0.8f确保阳光从天而降ObjectPool.Instance.GetPooledObject(Sun)避免Instantiate/Destroy频繁 GC池子里预存 5 个 Sun PrefabEventManager.TriggerEvent()用自定义事件总线非 UnityEvent解耦SunManager与AudioManager或VFXManager后续加音效不用改阳光逻辑。2.3 植物种植与冷却机制用字典存冷却时间用协程驱动倒计时植物不能无限种冷却不是“种完等 10 秒”而是每个植物实例独立计时。PlantManager挂载在GameController下用DictionaryPlantType, float存类型级冷却再用DictionaryGameObject, float存实例级剩余时间// PlantManager.cs public class PlantManager : MonoBehaviour { public DictionaryPlantType, float baseCooldown new DictionaryPlantType, float { { PlantType.PeaShooter, 10f }, { PlantType.Sunflower, 15f } }; private DictionaryGameObject, float _instanceCooldown new DictionaryGameObject, float(); private DictionaryPlantType, bool _isTypeAvailable new DictionaryPlantType, bool(); void Start() { foreach (var kvp in baseCooldown) _isTypeAvailable[kvp.Key] true; } // 点击植物图标时调用 public bool CanPlant(PlantType type, int cost) { return _isTypeAvailable[type] SunManager.Instance.CurrentSun cost; } // 种植后立即调用 public void StartCooldown(GameObject plantInstance, PlantType type) { _isTypeAvailable[type] false; _instanceCooldown[plantInstance] baseCooldown[type]; // 启动实例倒计时协程 StartCoroutine(CooldownCoroutine(plantInstance, type)); } IEnumerator CooldownCoroutine(GameObject plant, PlantType type) { while (_instanceCooldown[plant] 0) { _instanceCooldown[plant] - Time.deltaTime; yield return null; } _instanceCooldown.Remove(plant); _isTypeAvailable[type] true; // 触发 UI 更新图标变亮 EventManager.TriggerEvent(PlantCooldownEnd, type); } }关键设计点baseCooldown是配置表后期可导出为 ScriptableObject美术改数值不用程序员改代码_instanceCooldown用GameObject作 Key而非Plant组件因为植物可能被销毁如被吃掉Key 失效导致字典异常协程CooldownCoroutine里用Time.deltaTime而非WaitForSeconds确保帧率波动时倒计时精准60fps 和 30fps 下都是 10 秒。3. 僵尸与子弹的物理交互绕开 Rigidbody 的性能陷阱用射线检测替代碰撞体3.1 僵尸移动用 Transform.Translate 状态机禁用 RigidbodyUnity 新手常给僵尸加Rigidbody再AddForce结果 20 个僵尸同时跑CPU 直接飙到 90%。塔防里僵尸移动是确定性路径沿固定车道向左匀速走完全不需要物理引擎// Zombie.cs public class Zombie : MonoBehaviour { public float moveSpeed 0.5f; public int health 10; private Vector3 _targetPosition; private bool _isAlive true; void Update() { if (!_isAlive) return; // 沿 X 轴负方向移动向左 transform.Translate(Vector3.left * moveSpeed * Time.deltaTime, Space.World); // 检查是否到达房子X -5f if (transform.position.x -5f) { GameController.Instance.GameOver(); Destroy(gameObject); } } public void TakeDamage(int damage) { health - damage; if (health 0) { _isAlive false; // 播放死亡动画、掉落阳光... Die(); } } void Die() { // 重置状态归还对象池 ObjectPool.Instance.ReturnToPool(gameObject, Zombie); } }为什么不用 RigidbodyRigidbody会触发 Physics.Update每帧计算碰撞、重力僵尸又不跳不摔Transform.Translate是纯数学位移CPU 开销近乎为 0后续加“冰西瓜减速”效果只需改moveSpeed变量无需处理物理材质摩擦力。3.2 子弹发射与命中用 SphereCast 检测前方僵尸规避 Collider 重叠误判豌豆不是“发射一个带 Collider 的预制体然后等 OnCollisionEnter”而是每帧主动扫描前方是否有僵尸。这样既避免子弹穿模僵尸移动快时 Collider 没撞上又杜绝多颗豌豆同时击中同一僵尸的重复扣血// PeaShooter.cs public class PeaShooter : Plant { public float fireRate 1.5f; // 秒/发 public float peaSpeed 8f; public LayerMask zombieLayer; // 只检测 Zombie 层 private float _nextFireTime 0f; void Update() { if (Time.time _nextFireTime) return; // 向正前方发射射线长度 10 单位 RaycastHit hit; if (Physics.SphereCast(transform.position, 0.2f, Vector3.right, out hit, 10f, zombieLayer)) { // 命中僵尸直接扣血 var zombie hit.collider.GetComponentZombie(); if (zombie ! null) { zombie.TakeDamage(1); _nextFireTime Time.time fireRate; // 播放射击音效、粒子特效 PlayShootEffect(); } } } void PlayShootEffect() { var effect ObjectPool.Instance.GetPooledObject(PeaEffect); if (effect ! null) { effect.transform.position transform.position Vector3.right * 1.5f; effect.SetActive(true); } } }参数与技巧SphereCast比Raycast更鲁棒豌豆有体积0.2f 半径能覆盖僵尸 Collider 的微小偏移zombieLayer必须单独建层如 “Zombie”并在 Project Settings Tags and Layers 中设置不能用默认 “Everything”否则射线会打中地面、房子甚至其他植物fireRate 1.5f是基础值可被“樱桃炸弹”全局减速 Buff 修改体现状态驱动设计。3.3 僵尸与植物的碰撞逻辑用 OnTriggerStay 替代 OnCollisionEnter解决“贴脸不攻击”问题僵尸走到植物面前需要触发啃食。但OnCollisionEnter在僵尸刚接触植物 Collider 时触发一次之后就没了。而OnTriggerStay每帧检测适合持续伤害// Zombie.cs 续 private void OnTriggerStay(Collider other) { if (!other.CompareTag(Plant)) return; // 每 0.5 秒扣一次血避免帧率高时扣血过快 if (Time.time - _lastBiteTime 0.5f) { var plant other.GetComponentPlant(); if (plant ! null plant.IsAlive) { plant.TakeDamage(1); _lastBiteTime Time.time; // 播放啃食音效 AudioManager.Play(ZombieBite); } } }注意植物和僵尸的 Collider 都需勾选Is TriggerTime.time - _lastBiteTime 0.5f是硬性保护否则 144fps 下每秒啃 288 次植物瞬间消失AudioManager.Play()用单例管理音效避免每帧GetComponentAudioSource().Play()导致 GC。4. 波次系统与对象池用 ScriptableObject 配置关卡用泛型池管理百个对象4.1 用 ScriptableObject 定义波次把“第 3 波出 5 个普通僵尸 1 个铁桶”写成数据硬编码波次if (wave 3) SpawnZombie(Conehead, 1)会让关卡策划崩溃。正确做法是创建WaveData.asset// WaveData.cs [CreateAssetMenu(fileName NewWave, menuName TowerDefense/Wave Data)] public class WaveData : ScriptableObject { public int waveNumber; public float spawnInterval; // 该波内僵尸间隔秒数 public ListZombieSpawnEntry zombieSpawns; [System.Serializable] public class ZombieSpawnEntry { public ZombieType type; // 枚举Normal, Conehead, Buckethead public int count; // 该类型出几个 public float delay; // 相对波次开始的延迟秒数 } }在 Unity 编辑器中右键 Create → TowerDefense → Wave Data填入waveNumberspawnIntervalzombieSpawns.typezombieSpawns.countzombieSpawns.delay15.0Normal3015.0Normal23WaveManager.cs读取并执行// WaveManager.cs public class WaveManager : MonoBehaviour { public WaveData[] waveDatas; private int _currentWaveIndex 0; private float _nextSpawnTime 0f; private int _spawnedCount 0; void Update() { if (_currentWaveIndex waveDatas.Length) return; var currentWave waveDatas[_currentWaveIndex]; if (Time.time _nextSpawnTime _spawnedCount GetTotalZombiesInWave(currentWave)) { SpawnZombieFromWave(currentWave); _nextSpawnTime Time.time currentWave.spawnInterval; _spawnedCount; } // 波次结束检查该波僵尸全死亡 or 全进屋 if (_spawnedCount GetTotalZombiesInWave(currentWave) LaneManager.Instance.GetAllZombies().Count 0) { NextWave(); } } void SpawnZombieFromWave(WaveData wave) { var entry wave.zombieSpawns[_spawnedCount % wave.zombieSpawns.Count]; var zombiePrefab ZombiePrefabs.GetPrefab(entry.type); var zombie Instantiate(zombiePrefab, GetSpawnPosition(), Quaternion.identity); // 设置初始血量、速度等从 ScriptableObject 读取 zombie.GetComponentZombie().health ZombieStats.GetHealth(entry.type); } }优势策划双击.asset文件即可改数值无需程序员介入GetTotalZombiesInWave()计算zombieSpawns.Sum(e e.count)波次总僵尸数一目了然SpawnZombieFromWave中entry.type是枚举编译期校验不怕字符串拼错。4.2 对象池泛型化一个池子管豌豆、阳光、爆炸避免 10 个 Pool 脚本ObjectPool.cs必须支持任意 Prefab不能为每种对象写一个池// ObjectPool.cs public class ObjectPool : MonoBehaviour { public static ObjectPool Instance; [System.Serializable] public class Pool { public string tag; public GameObject prefab; public int size; } public ListPool pools; private Dictionarystring, QueueGameObject _poolDictionary; void Awake() { Instance this; _poolDictionary new Dictionarystring, QueueGameObject(); foreach (var pool in pools) { var queue new QueueGameObject(); for (int i 0; i pool.size; i) { var obj Instantiate(pool.prefab); obj.SetActive(false); obj.transform.parent transform; queue.Enqueue(obj); } _poolDictionary[pool.tag] queue; } } public GameObject GetPooledObject(string tag) { if (!_poolDictionary.ContainsKey(tag)) return null; var queue _poolDictionary[tag]; if (queue.Count 0) return null; // 池空可选择扩容或返回 null var obj queue.Dequeue(); obj.SetActive(true); return obj; } public void ReturnToPool(GameObject obj, string tag) { if (!_poolDictionary.ContainsKey(tag)) return; obj.SetActive(false); _poolDictionary[tag].Enqueue(obj); } }配置方式在 Inspector 中给pools添加 3 项tagPea、prefabPea.prefab、size20tagSun、prefabSun.prefab、size10tagExplosion、prefabExplosion.prefab、size5GetPooledObject(Pea)返回一个激活的豌豆ReturnToPool(pea, Pea)归还关键obj.transform.parent transform确保所有池对象挂载到ObjectPool下场景视图不杂乱且DontDestroyOnLoad时一并保留。5. 避坑指南那些让塔防项目卡在 90% 进度的致命细节5.1 现象阳光数字 UI 闪烁跳变有时显示负数原因SunManager的CollectSun()和SpawnSun()并发修改_currentSun未加锁且UpdateSunUI()在Start()中调用但 UI Text 组件尚未赋值。解决_currentSun改为private int _currentSun 0;所有修改走CollectSun()和SpendSun()方法SpendSun()返回bool表示是否扣款成功植物种植前先调用if (!SunManager.Instance.SpendSun(cost)) return;UpdateSunUI()前加空检查if (sunText ! null) sunText.text _currentSun.ToString();并在Awake()中用sunText GetComponentInChildrenText();初始化。5.2 现象僵尸跑到植物面前不动啃食音效不响原因僵尸和植物的 Collider 尺寸不匹配如植物 Collider 高 1.5m僵尸高 2m垂直方向未重叠或OnTriggerStay中other.CompareTag(Plant)的 Tag 未在 Inspector 中设置。解决在 Scene 视图开启Gizmos→Colliders观察两者是否绿色重叠植物 Collider 改用BoxColliderSize 设为(0.8f, 1.2f, 0.8f)Center(0, 0.6f, 0)僵尸 Collider Size(0.6f, 1.8f, 0.6f)Center(0, 0.9f, 0)选中植物 GameObject在 Inspector 顶部 Tag 下拉框选Plant需先在Edit Project Settings Tags and Layers中添加该 Tag。5.3 现象第 2 波僵尸刚出第 1 波还没死完LaneManager的ListZombie报NullReferenceException原因僵尸死亡时调用ObjectPool.Instance.ReturnToPool(gameObject, Zombie)但LaneManager仍持有已销毁对象的引用foreach (var z in zombies)时z为 null。解决LaneManager中存储ListZombie改为ListZombie _zombies new ListZombie();僵尸Die()方法末尾加LaneManager.Instance.RemoveZombie(this);RemoveZombie(Zombie z)内部用zombies.Remove(z)并加空检查if (zombies.Contains(z)) zombies.Remove(z);血泪经验永远不要在Update()中遍历可能被外部修改的 List改用for (int i zombies.Count - 1; i 0; i--)倒序遍历删除。5.4 现象打包 WebGL 后阳光生成停止控制台报Cannot access a destroyed object原因WebGL 不支持DontDestroyOnLoadSunManager被销毁后SpawnSunRoutine()协程仍在运行尝试访问已销毁对象。解决所有 Manager 脚本OnDisable()中加StopAllCoroutines();SpawnSunRoutine()开头加if (this null) yield break;更彻底方案用SceneManager.sceneLoaded事件监听场景加载完成再启动协程确保对象存活。5.5 现象切换不同分辨率如 1440x810后阳光从屏幕外生成或僵尸移动速度忽快忽慢原因GetRandomSpawnPosition()用Camera.main.pixelHeight计算 Y 坐标但未适配 Canvas 的Scale FactormoveSpeed是绝对值未乘Time.deltaTime。解决GetRandomSpawnPosition()改用世界坐标var screenPos Camera.main.ViewportToWorldPoint(new Vector3(Random.value, 0.9f, 10f));moveSpeed改为public float moveSpeed 0.5f;Update()中transform.Translate(Vector3.left * moveSpeed * Time.deltaTime, Space.World);Canvas 的 Render Mode 设为Screen Space - OverlayCanvas Scaler的 UI Scale Mode 设为Scale With Screen SizeReference Resolution 设为1440x810。6. 进阶验证与调试技巧用编辑器扩展实时监控塔防状态6.1 在 Scene 视图绘制调试信息一眼看清僵尸血量与植物冷却Unity 编辑器不显示运行时变量但可以用OnDrawGizmos()在 Scene 视图画文字和线// Zombie.cs 编辑器扩展 #if UNITY_EDITOR using UnityEditor; #endif public class Zombie : MonoBehaviour { // ... 原有代码 ... #if UNITY_EDITOR void OnDrawGizmosSelected() { // 显示血量文本 Handles.Label(transform.position Vector3.up * 1.2f, $HP: {health}); // 显示攻击范围如果有的话 if (attackRange 0) { Handles.color Color.red; Handles.DrawWireDisc(transform.position, Vector3.forward, attackRange); } } #endif }效果选中僵尸时Scene 视图上方显示HP: 7周围画红色圆圈表示攻击半径。同理PeaShooter.cs中画绿色射线表示SphereCast方向与长度。6.2 用 Profiler 定位性能瓶颈重点看 GC Alloc 和 Physics.Processing塔防性能杀手不是 Draw Call而是 GC 和 PhysicsGC Alloc 高检查ListT.Add()是否在Update()中频繁调用如每帧zombies.Add(z)改用ListT.Clear()复用或预分配容量new ListZombie(50)Physics.Processing 高确认所有SphereCast/Raycast都指定了LayerMask避免检测无关层zombieLayer值用LayerMask.GetMask(Zombie)预计算而非每帧LayerMask.NameToLayer(Zombie)MonoBehaviour.Update 高给Zombie.cs加[RequireComponent(typeof(Rigidbody))]删掉僵尸不用 RigidbodyPlant.cs的Update()只做冷却检查不执行逻辑。6.3 用自定义 Inspector 简化波次配置让策划 3 步配出新关卡WaveData.cs默认 Inspector 是原始 JSON 风格。加一个编辑器脚本让它友好// WaveDataEditor.cs #if UNITY_EDITOR [CustomEditor(typeof(WaveData))] public class WaveDataEditor : Editor { public override void OnInspectorGUI() { var wave (WaveData)target; EditorGUILayout.LabelField(波次配置, EditorStyles.boldLabel); wave.waveNumber EditorGUILayout.IntField(波次数, wave.waveNumber); wave.spawnInterval EditorGUILayout.FloatField(僵尸间隔(秒), wave.spawnInterval); EditorGUILayout.LabelField(僵尸列表, EditorStyles.boldLabel); if (GUILayout.Button(添加僵尸)) { wave.zombieSpawns.Add(new WaveData.ZombieSpawnEntry()); } for (int i 0; i wave.zombieSpawns.Count; i) { EditorGUILayout.BeginHorizontal(); wave.zombieSpawns[i].type (ZombieType)EditorGUILayout.EnumPopup(wave.zombieSpawns[i].type); wave.zombieSpawns[i].count EditorGUILayout.IntField(wave.zombieSpawns[i].count); wave.zombieSpawns[i].delay EditorGUILayout.FloatField(wave.zombieSpawns[i].delay); if (GUILayout.Button(-, GUILayout.Width(20))) { wave.zombieSpawns.RemoveAt(i); break; } EditorGUILayout.EndHorizontal(); } if (GUILayout.Button(保存)) EditorUtility.SetDirty(target); } } #endif效果策划在 Inspector 中看到“添加僵尸”按钮点一下新增一行下拉选僵尸类型、填数量、设延迟比手敲 JSON 快 10 倍。6.4 用 Addressable Assets 管理资源为未来加 DLC 预留接口现在所有 Prefab 都拖进Resources文件夹立刻停手。Resources.Load()无法卸载内存只增不减。改用 AddressablesWindow → Package Manager → 安装Addressable Asset System右键Pea.prefab→Addressable Assets → Set Address填peaObjectPool.cs中GetPooledObject()改为AsyncOperationHandleGameObject handle Addressables.LoadAssetAsyncGameObject(tag); handle.Completed (op) { /* 实例化并加入池 */ };好处打包时资源可分组zombies组、plants组热更新只替换一组Addressables.Release(handle)可精确卸载内存可控后期加“植物大战僵尸 2”DLC只需新地址peashooter_v2旧代码不用改。我带团队做第一个塔防项目时坚持用 Addressables结果上线后第 3 个月接到需求替换全部植物音效。运维同事 5 分钟上传新音频包玩家重启游戏即生效没动一行代码。这种“后悔药”能力值得你在第一天就埋下。希望帮到你。本文还有配套的精品资源点击获取