
简介《植物大战僵尸》Unity源码是一份面向游戏开发学习者、Unity初学者及塔防玩法爱好者的完整项目工程。该项目复现了经典塔防玩法包含多关卡设计重点覆盖植物种植、僵尸生成、子弹射击、碰撞判定与关卡进度保存等核心逻辑也涉及不同僵尸的AI行动模式与阳光资源管理机制。资源以RAR压缩包形式打包整体大小约127.12MB目前已有943人学习下载。通过分析源码可以系统掌握Unity中的游戏对象管理、MonoBehaviour脚本控制、动画状态机切换、碰撞检测与事件机制还能理解基于C#的UI交互、场景切换以及存档读档的写法。对于想从零拆解商业级游戏框架、提升实战能力的开发者这份源码提供了可运行的项目结构和清晰的模块边界适合结合Unity编辑器逐步断点调试也能作为塔防游戏原型改造的起点。从植物攻击到僵尸行进路径每个环节都能看到Unity物理引擎与脚本系统如何协同帮助读者把书本上的C#和Unity知识落到实际项目中。1. 植物大战僵尸源码unity别急着找包先搞懂这套代码的骨架很多人搜“植物大战僵尸源码unity”其实是想要一个能直接打开、能跑的Unity工程最好还能改改植物属性、加个新僵尸。这类需求在游戏开发学习者和独立游戏作者里特别常见因为植物大战僵尸PVZ玩法足够经典塔防策略、2D碰撞、对象池、状态机、UI事件、关卡波次几乎把Unity入门到进阶的考点全占了。但直接找一个完整源码包往往有两个问题一是下载的工程版本老旧打开全是报错二是代码结构混乱改一行都得顺着调用链查半天。我自己的经验是与其找一个“能跑的包”不如照着PVZ的玩法拆出几大系统逐步搭一个自己的版本。这套思路比源码本身值钱因为它让你在Unity的2D Tilemap、Physics、UI、对象池这些模块上都能落地一遍。这篇笔记就把我搭这套东西时的架构、核心代码、参数设置和踩过的坑完整讲一遍适合已经会基本Unity操作、想动手复刻完整玩法的读者。2. 复刻前先定架构2D Tilemap还是预制体拼场景直接影响后面所有代码2.1 为什么PVZ适合用Tilemap做网格底盘植物大战僵尸的场景本质是一张网格地图草坪被分成若干行若干列植物只能种在格子里僵尸沿着行前进。用Unity复刻时最自然的方案有两种一种是纯预制体每个格子摆一个空对象当锚点另一种是使用Tilemap组件画地面格子。我一般推荐Tilemap原因有两点。第一Tilemap自带网格对齐能力配合TilemapCollider2D可以省掉手动对齐坐标的精力第二后续做草地类型切换比如夜晚、屋顶、水池只需要换TileBase资源不需要动代码逻辑。PVZ里不同关卡的草地障碍条件本质就是“哪些格子能种、哪些不能种”Tilemap可以单独用一张可碰撞的Tile层来标记禁止种植区域运行时读取该格子的Tile是否为空即可判断。但要注意Tilemap只是负责“显示地面格子”植物和僵尸不推荐放在Tilemap里当Tile对象。因为植物需要响应点击、播放动画、被僵尸啃咬、产生子弹这些行为用挂在预制体上的MonoBehaviour来驱动远比Tilemap的Tile对象方便。Tilemap的定位是静态地图层动态单位全部走独立预制体体系。这样的拆分让后续的碰撞检测、排序渲染和对象池都更干净。2.2 设计数据模型用ScriptableObject管理植物和僵尸属性别写死在代码里PVZ的植物种类很多每种植物有阳光成本、冷却时间、攻击范围、伤害、子弹速度等参数。如果把这些参数写死在类里每加一个植物都要改代码改完还要担心影响其他逻辑。常见做法是定义一个PlantData的ScriptableObject资产把植物的外观预制体、阳光消耗、冷却时间、攻击方式、子弹预制体、属性数值全部放在里面。运行时种植面板读取这些资产来显示卡片种植时实例化对应的植物预制体并把PlantData引用挂到实例脚本上。为什么用ScriptableObject而不是JSON或Excel因为ScriptableObject可以直接在Unity编辑器里拖拽预制体引用改完立刻生效不需要处理外部文件的加载路径和解析逻辑。对于PVZ这种中等规模的数据量ScriptableObject是最省心的选择。僵尸同理定义ZombieData包含血量、移动速度、攻击力、啃咬间隔、掉落物等字段。波次配置也可以做成一个WaveData里面是一个僵尸类型列表和生成间隔。这样后续调平衡只需要改资产不需要重新编译代码。2.3 实体状态机植物至少有“待机-攻击-被啃”三态僵尸有“行走-攻击-死亡”三态PVZ里植物和僵尸的行为都可以用有限状态机来描述。比如向日葵待机状态下定期产生阳光被僵尸啃咬时进入受击状态血量清零进入死亡状态射手类植物待机状态下检测当前行前方是否有僵尸有则进入攻击状态发射子弹。僵尸更典型默认行走碰到植物后切换成攻击状态按固定频率啃咬植物植物死亡后恢复行走。把状态机写清楚后续添加新植物会非常方便——新植物只需要继承同一个基类重写状态切换条件即可。我用Unity的C#实现时不会引入复杂的状态机插件而是用枚举Switch写在基类Update里。因为PVZ的状态数量少每帧状态切换逻辑简单插件的状态图反而让代码跳转变多调试麻烦。核心结构是基类持有currentState枚举Update里调用当前状态的OnUpdate检测切换条件后调用OnExit再进入新状态。这样每个状态逻辑都在各自的region里代码可读性好也方便排查问题。3. 核心玩法落地种植、阳光、子弹、碰撞一段代码打通整局3.1 搭建网格与种植系统坐标换算和射线检测是关键当你把地图做成Tilemap后第一件事是实现屏幕点击坐标转网格坐标。这里有个常见的坑Camera.ScreenToWorldPoint需要传入一个带Z值的屏幕坐标否则返回的坐标永远是摄像机位置。正确做法是先通过相机把屏幕坐标转成世界坐标再用世界坐标减去Tilemap原点除以格子大小向下取整得到格子的行列值。public Vector2Int WorldToGrid(Vector3 worldPos) { // 假设tilemapOrigin是地图左下角的transform.position // cellSize是每个格子的世界大小例如 (0.8f, 0.8f) Vector3 offset worldPos - tilemapOrigin.position; int x Mathf.FloorToInt(offset.x / cellSize.x); int y Mathf.FloorToInt(offset.y / cellSize.y); return new Vector2Int(x, y); } public Vector3 GridToWorld(Vector2Int gridPos) { // 格子中心点给植物放置提供准确位置 Vector3 center tilemapOrigin.position new Vector3( (gridPos.x 0.5f) * cellSize.x, (gridPos.y 0.5f) * cellSize.y, 0f ); return center; }这段代码的核心维度是必须先定义tilemapOrigin和cellSize两个字段。tilemapOrigin设置为Tilemap物体左下角的位置cellSize与Tilemap的格子大小保持一致。如果你的地图是每行每列固定间距也可以直接用Grid组件的Cell Size数值。转换后的Grid坐标用于查找当前格子是否已经种植、是否为禁止种植区域、是否在水池上等。种植操作具体流程是点击后先做UI判断确认点在可种植区域而不是UI按钮上然后WorldToGrid拿到格子坐标检查该格子没有植物且地图允许种植最后从对象池拿植物预制体并放置到GridToWorld返回的位置。这里还有一个容易忽略的排序问题植物和僵尸的Y坐标不同但同一种植物的Y坐标是固定的Unity的2D渲染排序看Sorting Order或Sorting Layer。我的做法是给所有植物和僵尸的SpriteRenderer设置同一个Sorting Layer然后根据Y坐标动态调整Order in Layer这样Y小的靠上会先渲染Y大的靠下会被遮挡。如果不做这一步后排植物可能会被前排植物的透明区域盖住视觉效果非常奇怪。3.2 阳光系统生成、拾取和计数用事件解耦UI更新阳光是PVZ的经济系统。向日葵每隔一段时间生成阳光阳光落到地上后玩家点击拾取同时阳光数量增加。这听起来简单但拆到代码里要注意两点第一阳光掉落位置的随机范围要控制在植物周围一定半径内不能跑到格子里去否则会导致点击误判第二阳光数量变化需要通知UI刷新但UI脚本不该直接引用向日葵或太阳花对象。我一般全程用C#的Action事件来广播阳光数量变化。定义EventCenter单例内含一个public event Action OnSunChanged阳光生成、拾取、收集阳光道具、过关奖励时机统一调用EventCenter.Instance.SunChanged(sunCount)触发事件。UI的SunCounter脚本在OnEnable时订阅事件在OnDisable时取消订阅防止重复调用。这样做的好处是阳光系统的逻辑和UI彻底解耦后期加个“阳光翻倍”的道具或者修改初始阳光都不需要去UI脚本里改。阳光拾取的点击检测建议不要用Physics2D而是给阳光预制体挂一个CircleCollider2D并设置IsTrigger用OnMouseDown事件响应。OnMouseDown在Unity移动端和PC端都能用不需要额外写触控兼容代码。注意一点如果阳光有下落动画在移动或下落过程中不该被点击等落地后才开始响应点击。实现上可以让阳光脚本自带一个状态字段初始状态为Falling落地后置为ReadyOnMouseDown里先判断状态。3.3 植物攻击与子弹对象池别每帧Instantiate和Destroy射手类植物每间隔一段时间创建一颗子弹子弹沿所在行向右飞行碰到僵尸造成伤害。如果没有对象池5个射手一秒射两颗子弹30秒就产生300个GameObject。手机的GC会频繁触发游戏明显卡顿。正确做法是给子弹做对象池。Unity没有内置对象池API虽然新版有UnityEngine.Pool但很多人用的项目版本未必升级所以我用最原始的方式手写一个通用池满足PVZ这种低频率生成需求足够。public class BulletPool : MonoBehaviour { public GameObject bulletPrefab; public int preloadCount 20; private QueueGameObject pool new QueueGameObject(); void Awake() { for (int i 0; i preloadCount; i) { GameObject go Instantiate(bulletPrefab, transform); go.SetActive(false); pool.Enqueue(go); } } public GameObject Get(Vector3 pos) { GameObject go pool.Count 0 ? pool.Dequeue() : Instantiate(bulletPrefab, transform); go.transform.position pos; go.SetActive(true); return go; } public void Return(GameObject go) { go.SetActive(false); go.GetComponentRigidbody2D().velocity Vector2.zero; pool.Enqueue(go); } }参数说明preloadCount根据场上同时最多出现的子弹数定PVZ一波僵尸密集时单行最多5颗子弹全场最多约30颗预载20、动态扩充即可。Return方法里必须复位子弹位置和速度否则从池里取出的旧子弹会带上一帧的移动趋势造成刚出生就飞出去的现象。子弹的移动我推荐用Rigidbody2D的velocity而不是Transform.Translate。原因是在物理引擎中velocity能保证碰撞检测的连续性和准确性尤其是高速弹体用Transform移动可能出现穿过碰撞体的问题。但用Rigidbody2D时一定要将子弹的Interpolate设置为Interpolate不然低帧率下子弹运动会有顿挫感。子弹与僵尸的碰撞判定最省性能的做法是给子弹挂一个OnTriggerEnter2D僵尸的Collider2D设为Trigger。但注意PVZ的子弹是单行飞行的如果只靠Physics2D子弹可能会碰到本行之外或者已经倒地的僵尸。稳妥做法是子弹只在自身Y坐标与僵尸预设的行的Y坐标差值小于某个阈值时才算命中物理碰撞只是辅助。我在碰撞回调里还会先检查僵尸是否已经死亡避免一枪打中两具尸体导致重复伤害。3.4 僵尸生成与移动沿着行前进用“距离阈值”切换攻击状态僵尸的逻辑核心是一路向左移动遇到植物后停下啃咬。将其拆解僵尸挂一个ZombieController持有speed、attackInterval、damagePerHit、health等字段。Update里依据当前状态行动——行走时以speed向左移动攻击状态下停止移动每attackInterval秒对前方植物造成一次伤害。要判断前方是否有植物最直接的方法是射线检测每帧从僵尸位置向左发射一条长度固定的Physics2D.Raycast检测层只包含Plant层。如果命中植物切换到攻击状态。如果植物死亡立即恢复行走。用Raycast而不是遍历所有植物进行坐标判断更高效也更容易处理多个植物叠放的情况。但要注意Raycast的原点要放在僵尸的嘴部位置也就是Sprite的中心偏左而不是玩家脚下的中心否则可能出现僵尸已经碰到植物但射线起点还在植物右边的情况。射线的长度设为单元格宽度的一半即可因为僵尸只要贴近植物就应该开始啃。层级方面给所有植物预制体添加名为“Plant”的Layer僵尸的Raycast的LayerMask只检测这一层避免误射到地图或其他单位。僵尸死亡动画后要销毁或回收到对象池。PVZ的死亡有两种被子弹打死和啃植物时被打死。前者要做身体后倒动画和消失后者可能只做消失。我建议死亡动画用一个单独的死亡状态处理动画结束后触发事件通知对象池回收。如果直接Destroy后续该僵尸的掉落物比如阳光生成逻辑还没执行就丢失了。所以把掉落逻辑放在僵尸的OnDeath事件里先生成掉落奖励再播放动画动画走完再回收。4. 避坑笔记坐标、物理、UI和Tilemap的四个高频翻车点4.1 点击种植永远偏位请检查ScreenToWorldPoint的Z值第一次在视角倾斜或Orthographic相机上做种植点击很容易出现点击的是底层泥土植物却种到了画面上的其他位置。原因是ScreenToWorldPoint需要传入的屏幕坐标的Z值代表距离相机平面的距离。在2D场景中如果相机是OrthographicZ可以写0或写-相机Z。很多人直接写Camera.main.ScreenToWorldPoint(Input.mousePosition)此时得到的world point总在相机的位置偏移量视相机位置而定。解决方法是Vector3 screenPos Input.mousePosition; screenPos.z -Camera.main.transform.position.z; Vector3 worldPos Camera.main.ScreenToWorldPoint(screenPos);如果你用场景中有多个相机或者相机有旋转尽量用Camera.ScreenToWorldPoint加平面距离计算。更稳的方案是在地面层放一个PlaneCollider2D或Grid从相机发射射线与地面相交取交点。不过对于PVZ这种固定视角游戏我习惯直接在Orthographic相机下用上面的公式简单且不依赖物理。4.2 对象池子弹碰到“幽灵碰撞体”需要区分死亡僵尸和已回收子弹玩过PVZ复刻版的人大概率见过这种怪现象子弹明明穿过了僵尸但僵尸没掉血或者子弹在飞过僵尸尸体后凭空消失。原因有二。第一僵尸被子弹击中死亡后Collider2D没有立即禁用尸体依然存在后续子弹继续触发碰撞但代码里判断僵尸已死亡直接return没做其他处理子弹也被消耗。第二对象池里的子弹在回收后碰撞体仍处于激活状态在下一次取出时忘记重置碰撞体导致旧触发器先触发新子弹立刻被判定碰撞。踩坑后的修复方案是子弹的碰撞回调里如果是僵尸已经死亡僵尸对象的IsAlive字段为false则忽略该碰撞并且不让子弹回收让子弹继续飞行。僵尸死亡动画开始时立即将Collider2D.enabled置为false。这样子弹不会撞空气。至于对象池Get方法里取出子弹后要强制重置Collider2D.enabled true并在Return时设置enabled false。两者合起来才能彻底消除幽灵碰撞。4.3 Tilemap的Cell Swizzle与缩放影响坐标换算很多人在Unity Tilemap里画好地图运行时代码里写死cellSize为1结果点击种植物总错位一格。排查后发现Tilemap的transform.localScale可能被设置成了0.8或者1.5导致实际格子大小不是1。另外Tilemap组件上有个Cell Swizzle属性默认是XYZ如果改成YXZ会让X和Y轴互换坐标换算结果完全不对。正确做法是先用Grid组件的Cell Size值乘以Tilemap的transform.localScale得到实际格子尺寸在Awake里读取并缓存不要自己假设。Grid grid tilemap.GetComponentInParentGrid(); Vector3 cellSize grid.cellSize; float actualCellX cellSize.x * tilemap.transform.localScale.x; float actualCellY cellSize.y * tilemap.transform.localScale.y;这段代码还顺带解决了地图放大的问题如果你的整个地图对象挂在某个节点下并且父节点有缩放那么需要连父节点的缩放一起乘。最省心的方法是避免对Tilemap做任何Scale操作直接用Grid的Cell Size调整格子大小。如果地图有整体缩放请把缩放放在Grid节点上这样Tilemap的子缩放会自动计算你读取Grid的信息时得到的是缩放后的值代码更简单。4.4 UI点击事件与游戏场景点击冲突用EventSystem.isPointerOverGameObjectPVZ需要点击屏幕种植植物但如果你把阳光值、卡片、铲子都做在Canvas的UI上点击UI区域会同时触发布局里的射线检测导致“点右上角阳光数字”却种了一棵向日葵。这是Unity里UI与游戏世界点击冲突最常见的表现。解决方法是在种植操作的入口处先判断当前指针是否在UI对象上。if (EventSystem.current.IsPointerOverGameObject()) { // 点在UI上不执行种植 return; }在移动端需要写成IsPointerOverGameObject(Input.GetTouch(0).fingerId)否则判断失效。另外种植卡片的点击事件本身不应该是“点击卡片立即种植”正确流程是先点击卡片选中它此时卡片旁显示一个跟随指针的提示再点击地图判定种植。如果你把UI点击、地图点击都响应在同一个Input事件里两件事很容易互相干扰。我的习惯卡片选中后用一个private bool isPlantingReady变量标识地图点击时先判断这个变量再执行种植逻辑执行完或点击右键/再次点击卡片则取消选中。这套流程能让UI和种植区域的边界清晰不会出现误种。5. 进阶优化数据驱动关卡波次让内容与代码彻底分离PVZ拆到最后你会发现玩法代码只是骨架真正让它耐玩的是几百种植物和几十个僵尸的组合。我给自己的项目加了两个进阶设计一个是关卡波次配置化一个是植物冷却和阳光收益的数值收敛。先说波次配置。我不再把大波僵尸的逻辑写在Update里而是做成一个WaveManager读取一个TextAsset或ScriptableObject列表里面每一行配置是“第几波、生成哪些僵尸、间隔多少秒、数量多少”。运行时WaveManager根据游戏时间判断当前是否应该生成某波僵尸并按间隔从对象池取出对应僵尸预制体放置在地图最右侧的行位置上。这样做的好处是以后调整关卡难度只需要改配置不需要动一行代码。波次配置文件的格式建议用JSON或ScriptableObject都行。如果是JSON需要在Unity中引用Newtonsoft或使用JsonUtility。JsonUtility要求字段名与JSON键完全匹配且不支持字典类型所以波次数据结构要设计得简单一些。个人更推荐ScriptableObject立即可视化多人协作时也不会产生文本冲突。配置项至少包括僵尸类型ID、生成波数、每波数量、出怪间隔、特殊标记比如是否携带路障。有了这个你甚至可以做一个编辑器扩展在Inspector里拖拽赋值。关于数值收敛这是很多复刻版PVZ后期难度失衡的根源。植物升级和僵尸强化没有上限导致农场数值爆炸。我的做法是所有植物的伤害、血量、阳光成本都集中放在PlantData资产里且设定一条硬规则——每个数值字段在资产里只能有一个来源不允许代码运行时动态翻倍。如果确实需要临时Buff比如南瓜头挡刀用独立的临时状态组件去叠加不动基础数值。这样一来平衡性调整只需要打开PlantData资产改数字并且保证同一定位植物如豌豆射手和寒冰射手共享同一套伤害模板减少数值溢出风险。最后一章说点个人习惯我自己复刻PVZ到可玩状态大约花了三周每天晚上抽两小时。最初也是想找现成源码但下载了三四个包都有版本不兼容和代码冗余的问题最后干脆自己写。回头看不亏因为写一遍的过程把Unity的协程、对象池、碰撞、Tilemap、UI事件全打通了。如果让我从头再来一遍我会先花一天把Grid坐标换算和对象池两个基础设施做好——这两块是后续所有内容的根基根基歪了后面全得返工。所有代码都遵循“能用简单数组解决就不用泛型复杂结构”的原则PVZ的复杂度还远没到需要ECS和DOTS的地步过度设计只会增加你的调试成本。希望这篇拆解能让你少走我走过的弯路也祝你早日在这个玩法上做出自己的版本。本文还有配套的精品资源点击获取