尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity复刻经典塔防:植物大战僵尸核心系统拆解与实战

Unity复刻经典塔防:植物大战僵尸核心系统拆解与实战 简介本资源为基于Unity引擎实现的《植物大战僵尸》塔防游戏完整源码项目面向Unity初学者、游戏开发入门者及塔防类游戏机制研究者旨在帮助开发者深入理解策略性2D游戏的核心架构与实现逻辑。压缩包共含多个C#脚本、预制体Prefab、场景文件.unity、动画控制器及UI资源涵盖植物种植、僵尸AI行为、阳光经济系统、多关卡流程控制等关键模块整体大小为127.12MB。目前已有937人学习下载反映出其在实践教学与项目复现中的高参考价值。读者可直接导入Unity 2020版本运行调试从中掌握游戏对象生命周期管理、碰撞检测逻辑、状态机驱动动画、关卡数据序列化保存等真实开发技能并快速迁移至同类塔防或策略游戏的二次开发中。 《植物大战僵尸》能火这么多年除了玩法本身的魔力还因为它是一个非常适合拿来拆解和复刻的塔防样本。单看每个系统——种植、阳光、僵尸AI、波次调度——都不算复杂但它们咬合在一起后就形成了教科书级别的经济循环和对抗张力。用Unity重做一遍不只是“抄一个游戏”更是把游戏设计里的对象管理、状态机、数据驱动这些基本功彻底练一遍。这篇文章我不会教你怎么去下别人的整合包或者扒现成资源而是把整个项目从零拆开讲清楚框架怎么搭、核心系统怎么实现、性能怎么优化最后附上我实际开发中踩过的坑。无论你是想用Unity练手的学生还是想转行做游戏开发的新人按这条路径走一遍你对塔防类游戏的理解会上一个台阶。1. 先想清楚再动手整体框架与系统划分1.1 为什么用Unity复盘一个老游戏很多新手一上来就想着做3A大作的玩法结果被物理引擎、动画状态机、网络同步这些复杂模块劝退。植物大战僵尸的妙处在于它的核心玩法完全建立在2D格子逻辑上不依赖复杂物理不需要联网没有角色动画的硬骨头僵尸走路用平移翻转就能糊弄过去却包含了游戏开发里最常遇到的几类问题——UI交互、对象生命周期、AI行为、数值平衡。用Unity来做这个项目正好能把精力集中在“游戏逻辑怎么组织”上而不是被引擎特性淹没。Unity本身也很适合这类项目组件式开发天然契合植物和僵尸的“实体”概念ScriptableObject适合做数值配置UGUI做卡槽、阳光数这些界面的效率也很高。就算你以后转向别的引擎这套“场景管理 数据驱动 状态机”的骨架也能迁移过去。1.2 五个核心系统缺一不可我把整个项目拆成了五个模块各管一摊互不越界GridSystem负责格子坐标换算、种植状态记录是整个游戏的“空间地基”。PlantManager管理植物的种植、移除、技能触发所有植物实体都归它统辖。ZombieManager负责僵尸的生成、波次调度、状态流转。SunManager管理阳光的生成、拾取和货币数值这是整个游戏的经济核心。GameManager全局状态机管游戏暂停、胜负判定、UI更新。这个划分看起来简单但很多人做项目时会犯“上帝类”的毛病——一个GameManager里塞了几百行既管阳光又管僵尸还管UI。我的建议是每个系统一个单例或者一个挂在场景里的MonoBehaviour它们之间通过事件或接口通信。比如SunManager只管发“阳光数量变化”的事件UI那边自己订阅事件去刷新数字这样改UI样式时完全不用动游戏逻辑。1.3 数据驱动别把数值写死在代码里我见过不少新手项目植物的血量、攻击力、攻击间隔全写在Plant子类的字段里调平衡时要打开脚本改代码改完还得重新编译。这在小项目里还能忍一旦植物种类超过十种调整起来就是灾难。正确的做法是用ScriptableObject做配置。我在项目里建了PlantConfig和ZombieConfig两个配置类里面只放纯数据生命值、伤害、攻击间隔、移动速度、阳光花费、冷却时间这些。每种植物和僵尸对应一个配置资产文件策划或者你自己在Inspector里就能改数值改完直接生效不用碰代码。[CreateAssetMenu(fileName PlantConfig, menuName Game/PlantConfig)] public class PlantConfig : ScriptableObject { public string plantName; public int health; public int sunCost; public float fireRate; public float attackDamage; public GameObject prefab; public Sprite cardIcon; }这么做还有一个额外好处如果你以后想加新植物只需要新建一个配置资产、写一个新的Plant子类或者用组合的方式配置行为就能非常快地加入游戏整个项目的扩展性一下就打开了。2. 核心系统拆解种植、阳光与僵尸AI2.1 种植格判定与坐标换算种植是玩家最频繁的操作手感好不好决定了游戏的第一印象。实现上要做两件事一是把鼠标点击的世界坐标换算成格子坐标二是判断这个格子能不能种。格子坐标换算的原理不复杂就是“减去原点、除以格宽、向下取整”。我在GridSystem里维护一个二维数组记录每个格子状态同时用两个方法做坐标转换public class GridSystem : MonoBehaviour { public Vector2 origin; // 网格原点左下角 public float cellWidth 1f; public float cellHeight 1f; public int columnCount 9; public int rowCount 5; private PlantBase[,] plants; public bool CanPlantAt(int col, int row) { if (col 0 || col columnCount || row 0 || row rowCount) return false; return plants[col, row] null; } public Vector3 GetCellWorldPos(int col, int row) { return new Vector3( origin.x col * cellWidth, origin.y row * cellHeight, 0); } public Vector2Int WorldToCell(Vector3 worldPos) { int col Mathf.FloorToInt((worldPos.x - origin.x) / cellWidth); int row Mathf.FloorToInt((worldPos.y - origin.y) / cellHeight); return new Vector2Int(col, row); } public void SetPlant(int col, int row, PlantBase plant) { if (plant null) plants[col, row] null; else plants[col, row] plant; } }这里有几个细节容易踩坑。FloorToInt一定要用向下取整而不是强制转换(int)因为Unity的坐标系原点不一定在格子左上角负数坐标时强制转换会往0方向截断导致格子偏一格。另外种植判定要在CanPlantAt里做而不是在点击事件里直接判断坐标范围这样代码逻辑更集中后面要做“显示可种植区域”时也方便。2.2 阳光经济让玩家永远在掂量植物大战僵尸的经济设计非常经典阳光就是所有决策的“货币”玩家需要在“多种向日葵”和“多种攻击植物”之间做权衡。复刻这个系统时我分了三个部分阳光生成、阳光拾取、货币校验。阳光生成有两种来源自然掉落和向日葵产出。自然掉落我用一个协程定时在场上随机位置生成阳光向日葵产出则在向日葵自己的逻辑里处理。两者的本质都是“生成一个Sun实体”所以我抽象了一个SunManager来统一管理public class SunManager : MonoBehaviour { public GameObject sunPrefab; public int currentSun 50; public event Actionint OnSunChanged; public void AddSun(int amount) { currentSun amount; OnSunChanged?.Invoke(currentSun); } public bool TrySpendSun(int amount) { if (currentSun amount) return false; currentSun - amount; OnSunChanged?.Invoke(currentSun); return true; } public void SpawnSun(Vector3 position, int value) { var sun SimpleObjectPool.Instance.Get(sunPrefab, position); sun.GetComponentSun().Init(value); } }我特别建议把“尝试花费”和“直接增加”拆成两个方法因为种植时一定是先校验再扣费而不是扣完才发现阳光不够。如果直接写currentSun - cost然后在小于零时回滚代码很容易在多个系统同时扣费时出BUG。移动端的玩家可能体会过阳光叠在一起很难精确点击的烦躁感。我的解决方案是给阳光增加一个“磁吸半径”当阳光落到地面后如果玩家点击了阳光附近一定范围内的区域视为拾取。这个在移动端小游戏上尤其重要因为手指的点击精度远不如鼠标。2.3 僵尸状态机与波次调度僵尸的行为看起来五花八门其实就三种状态走路、啃植物、死亡。我用一个简单的状态机来管理public class ZombieBase : MonoBehaviour { public ZombieConfig config; public float health; public float moveSpeed; public float attackDamage; public float attackInterval; private ZombieState currentState; private PlantBase targetPlant; private float attackTimer; void Start() { health config.health; moveSpeed config.moveSpeed; attackDamage config.attackDamage; attackInterval config.attackInterval; ChangeState(ZombieState.Walk); } void Update() { switch (currentState) { case ZombieState.Walk: transform.position Vector3.left * moveSpeed * Time.deltaTime; break; case ZombieState.Attack: attackTimer Time.deltaTime; if (attackTimer attackInterval) { attackTimer 0f; targetPlant.TakeDamage(attackDamage); } break; case ZombieState.Dead: break; } } public void ChangeState(ZombieState newState) { currentState newState; if (newState ZombieState.Dead) { SimpleObjectPool.Instance.Recycle(gameObject); } } private void OnTriggerEnter2D(Collider2D other) { if (currentState ZombieState.Walk other.TryGetComponentPlantBase(out var plant)) { targetPlant plant; ChangeState(ZombieState.Attack); } } }这里有个我特别想强调的经验僵尸“啃植物”的判定不要用物理碰撞驱动状态最好在PlantManager里做逻辑判断。因为物理碰撞需要碰撞体设置正确、Layer配对正确一旦加了几十个僵尸和植物后物理开销会明显上升。经典版本的做法是植物在GridSystem里登记了格子位置僵尸每帧判断自己当前所在格子是否有植物有就切换攻击状态。这种方式可控性更好也方便做“多个僵尸啃同一株植物”时的伤害分配。波次调度我用协程实现核心就是按配置表延时生成僵尸public class WaveManager : MonoBehaviour { public ListWaveConfig waves; public IEnumerator StartWave(int index) { var wave waves[index]; yield return new WaitForSeconds(wave.delay); for (int i 0; i wave.zombieList.Count; i) { SpawnZombie(wave.zombieList[i].prefab, wave.zombieList[i].row); yield return new WaitForSeconds(wave.spawnInterval); } } }WaveConfig同样是一个ScriptableObject里面存储每个僵尸的生成行、生成间隔。这样做的好处是后续想做无尽模式只需要动态生成WaveConfig列表或者复用同一组配置循环叠加难度即可。2.4 植物攻击与子弹的对象管理射击类植物豌豆射手、寒冰射手的攻击逻辑是每1/fireRate秒生成一个子弹子弹往右移动碰到僵尸造成伤害。这里比较关键的是子弹的碰撞检测方式。如果每个子弹都用独立的OnTriggerEnter2D几十颗子弹互相检测时性能会浪费不少。我用的方案是子弹不做物理碰撞而是每帧检测“当前x坐标是否越过某个僵尸的x坐标”一旦越过就找该行最靠左的僵尸结算伤害。不过这个方案有一个边界情况如果子弹速度很快可能会从僵尸模型的碰撞体边缘“穿过去”。这时候可以给子弹增加一个“上一帧位置”记录用Physics2D.Raycast做一帧内的线段检测。对老版植物大战僵尸来说子弹速度不算特别快直接用每帧检测基本够用我做了两千多颗子弹同时在场上的压测帧率仍然稳定。3. 实战搭建从空场景到可玩DEMO3.1 场景组织与预制体规范动工写代码之前先把场景层级结构规划好。我的场景结构大概是这样的场景根节点GameManagerGridSystemPlantManagerZombieManagerSunManagerWaveManagerUI层阳光数字Text种植卡槽Panel快捷菜单所有管理器都在场景里挂好而不是用FindObjectOfType到处找。预制体的命名规范也要统一植物就叫Plant_PeaShooter、Plant_Sunflower僵尸就叫Zombie_Normal、Zombie_Conehead避免之后资源多了分不清。我在项目里还要求所有预制体的根节点Position都归零实际的生成位置由生成逻辑设置这样排查问题的时候不会被预制体里的偏移干扰。3.2 种植与拾取交互实现种植交互的完整流程是点击卡槽选择植物 → 鼠标进入待种植状态 → 点击网格区域 → 判断可种植且阳光充足 → 生成植物并扣费。这里的重点是待种植状态的管理我用一个枚举来表示当前输入状态public enum InputState { Idle, ReadyToPlant, PickingSun }当玩家选中卡槽时把状态切到ReadyToPlant同时在高亮区域显示一个半透明的植物跟随鼠标移动。点击鼠标时如果CanPlantAt返回true并且SunManager.TrySpendSun成功就把植物实例化出来状态切回Idle。如果阳光不足界面弹一个轻量的抖动反馈而不是弹窗打断操作。阳光拾取我用的是点击检测加磁吸补偿在Sun的脚本里记录一个半径值当鼠标点击位置到阳光中心的距离小于这个半径时视为拾取。这个半径在移动端可以设大一点比如0.8个格子宽度PC端可以设小一点保证操作的精准度。3.3 对象池为什么必须用这个项目里阳光、子弹、僵尸都是高频创建和销毁的对象。直接用Instantiate和Destroy在PC上跑Demo问题不大但一旦僵尸数量上到几十个、子弹上百颗频繁的内存分配会导致明显的卡顿。所以我在项目一开始就写了SimpleObjectPool用队列存闲置对象public class SimpleObjectPool : MonoBehaviour { public static SimpleObjectPool Instance; private Dictionarystring, QueueGameObject poolDict new Dictionarystring, QueueGameObject(); void Awake() { Instance this; } public GameObject Get(GameObject prefab, Vector3 position) { string key prefab.name; GameObject obj null; if (poolDict.ContainsKey(key) poolDict[key].Count 0) { obj poolDict[key].Dequeue(); obj.transform.position position; obj.SetActive(true); } else { obj Instantiate(prefab, position, Quaternion.identity); obj.name key; } return obj; } public void Recycle(GameObject obj) { obj.SetActive(false); string key obj.name; if (!poolDict.ContainsKey(key)) poolDict[key] new QueueGameObject(); poolDict[key].Enqueue(obj); } }这里有个容易踩的坑如果直接对预制体改名或者对池化后的对象执行了会改变名字的操作Recycle的时候就会存到错误的池里。我在项目里定了一条规则——所有从池里取出的对象禁止修改它的name属性统一用预制体名字作为池的key。另外池化对象需要重置状态比如僵尸的health、currentState在Init方法里统一赋值。3.4 冷却系统与UI联动种植卡槽的冷却显示是游戏手感的另一重要环节。我的实现思路是每张卡有独立的冷却时间选中后进入冷却用Image.fillAmount显示冷却进度。数据层我写在PlantCardData里UI层只负责显示这样哪怕以后要做不同的卡槽布局样式也不用改逻辑代码。public class PlantCard : MonoBehaviour { public PlantConfig config; public Image cooldownMask; public Text sunCostText; private float cooldownRemain; private bool isOnCooldown; void Update() { if (isOnCooldown) { cooldownRemain - Time.deltaTime; cooldownMask.fillAmount cooldownRemain / config.cooldownTime; if (cooldownRemain 0) { isOnCooldown false; cooldownMask.fillAmount 0; } } } }卡槽的选取状态和Grid种植状态联动时我建议不要直接操作对方组件。卡槽发出一个“选中植物”事件PlantManager接收事件后切换InputState这样任何模块都能监听事件而不需要硬引用。4. 性能优化与多平台适配4.1 从卡顿点反推优化方向我复刻这个小项目时遇到的最大卡顿来自三个方面一是大量Instantiate/Destroy导致的内存碎片二是每个植物和僵尸每帧都在执行Update逻辑三是UGUI的元素数量过多导致的重绘压力。针对这三个卡顿点我的优化顺序是先怼对象池再减Update调用最后优化UI。对象池在上面已经写了它解决的是第一个卡顿点。对于第二点我用了两个手段植物不需要每帧执行逻辑的就用协程延时执行或者把固定间隔攻击改成在Start里用InvokeRepeating僵尸的路径检测从每帧改为每0.2秒执行一次这个频率对玩家来说完全无感知。4.2 Draw Call与图集合并植物大战僵尸的素材规模对现代Unity来说压力不大但如果不注意Draw Call手机上还是会掉帧。我的做法是把植物、僵尸、子弹的精灵图全部打到一个图集里Unity的Sprite Atlas功能并且把相同材质的对象尽可能排在同一层级减少批次切换。4.3 Update开销治理定时器代替逐帧轮询逐帧Update最大的问题不是CPU占用高而是“每帧都在重复做没必要的事”。比如阳光的磁吸判定、僵尸的目标植物检测、子弹的越界检查这些功能都不需要每秒60次判断。我给项目写了一个简单的Timer工具类在需要周期性判断的地方调用既省了性能又让代码意图更清晰。4.4 移动端与小游戏平台适配如果你想把成品发到微信小游戏或者手机上有两点必须注意一是对象池的预创建数量要足够小游戏的首次加载比PC慢如果不在启动时提前实例化一批阳光和子弹玩家玩到第一波僵尸时会有明显的卡顿二是屏幕适配经典版本的UI布局在竖屏手机上问题不大但横屏游戏需要考虑刘海屏和异形屏的安全区尽量用锚点约束UI位置别写死坐标。5. 常见问题与排查技巧实录5.1 种植位置总偏一格怎么回事这几乎是我见过的最常见问题。原因大多是坐标换算用了Mathf.RoundToInt或者(int)强转而不是Mathf.FloorToInt。记住一个原则格子坐标永远是向下取整。如果你发现偏移正好是半个格宽多半是原点没对齐检查一下GridSystem.origin是不是格子的左下角而不是中心点。5.2 子弹打不到僵尸子弹打不到的常见原因有三个碰撞体Layer不一致、子弹移动速度太快导致穿透、以及子弹生成时坐标在僵尸碰撞体外面。先检查Layer的碰撞矩阵再用Debug.DrawLine画出子弹每帧的移动线段通常一眼就能定位问题。如果还是查不出来就把子弹速度调低、碰撞体调大做对照测试。5.3 对象池复用时状态残留对象池复用的经典坑向日葵被回收后再生成时仍然保留上一次的currentSun值或者fireTimer。我的经验是对象池取出的对象默认调用一个Init方法在Init里重置所有字段。特别注意协程的状态如果植物在协程执行中被回收协程可能还在跑需要在OnDisable里停掉所有协程。5.4 UI挡住了种植的点击这个问题在电脑上玩的时候不明显一旦在移动端加了各种悬浮按钮就很容易出现。原因是用UGUI的EventSystem做射线检测时UI层会拦截掉所有点击。解决方案是在网格区域放一个透明的Image并且关闭Raycast Target让事件穿透到场景或者在种植状态下临时禁用UI的GraphicRaycaster。我个人推荐后者简单粗暴且稳定。5.5 游戏暂停后逻辑还在跑很多新手在写暂停功能时直接Time.timeScale 0就完事。但Update里的逻辑仍然会执行只是Time.deltaTime变成了0。如果代码里用了WaitForSeconds和WaitForSecondsRealtime混用就会出问题。我的方案是在各个系统的Update开头加一行if (GameManager.Instance.IsPaused) return;暂停时只需要让普通逻辑不推进UI动画用自己的UnscaledDeltaTime继续播放。做这个项目最有价值的收获其实不是“能跑出一个塔防游戏”而是学会如何把一堆散落的逻辑组织成清晰的模块以及在高频对象场景下如何维护性能和状态。我做完一遍之后再回去看市面上的塔防游戏脑子里会自动浮现出它们的格子系统、对象池和数值配置是怎么做的。如果你照着这条路走一遍之后去做任何带实体互动玩法的项目都会觉得顺手很多。如果你是第一次做这种完整游戏建议先别急着加花里胡哨的植物技能和僵尸种类把基础的五种植物的三种僵尸跑通再逐步扩展。做游戏最怕的就是一开始就想做一个“完美的大作”结果连核心循环都玩不起来。把最简单的完整闭环做出来再谈扩展这才是正确的节奏。本文还有配套的精品资源点击获取
返回列表