简介:《植物大战僵尸》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,落地后置为Ready,OnMouseDown里先判断状态。
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 Queue<GameObject> pool = new Queue<GameObject>(); 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.GetComponent<Rigidbody2D>().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场景中,如果相机是Orthographic,Z可以写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.GetComponentInParent<Grid>(); 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.isPointerOverGameObject
PVZ需要点击屏幕种植植物,但如果你把阳光值、卡片、铲子都做在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的地步,过度设计只会增加你的调试成本。希望这篇拆解能让你少走我走过的弯路,也祝你早日在这个玩法上做出自己的版本。
本文还有配套的精品资源,点击获取