简介:一份基于Unity引擎的《植物大战僵尸》完整源码项目,面向想深入Unity游戏开发的中初级开发者,尤其适合对塔防玩法实现感兴趣的玩家型程序员。项目基于C#脚本驱动,完整复刻了植物种植、僵尸进攻、子弹发射以及阳光资源管理等经典机制,并配置了多关卡结构,从零搭建出可运行的塔防游戏框架。资源包以rar格式压缩,大小约127.12MB,文件清单暂未单独提供,但目录按功能模块划分,便于对照场景、脚本和资源文件学习。目前已有943人学习/下载,经社区验证后认可度较高。通过分析源码,可以掌握Unity中游戏对象的管理与更新、子弹与僵尸的碰撞检测、动画状态机切换以及关卡进度存取等核心实现;同时还能从多个关卡的设计中领悟难度递进、敌人AI差异化和策略资源投放等设计思路,借助物理引擎与动画系统的配合理解游戏表现层的搭建方法,帮助自己从零编写完整的Unity塔防项目。
1. 植物大战僵尸源码 Unity:这套克隆工程能跑起来之前,先想清楚这三件事
“植物大战僵尸源码 Unity”这种包,下载回来的人十有八九不是冲着通关去的,而是想拆出一套 2D 塔防的骨架:卡片冷却、阳光经济、僵尸波次、格子种植。我拿到这类克隆工程后的第一反应不是双击开跑,而是先确认版本号、场景相机和 GameManager 挂载情况——这三样只要有一个不对劲,后面全是玄学。这套资源更适合已经有 C# 基础的初中级 Unity 从业者:照着预制体和代码把玩法主线走一遍,比从零写快得多;如果你连脚本都不会挂,建议先补一个月基础再碰。
2. 项目结构先行:从版本选型到第一帧运行的完整顺序
很多人在下载植物大战僵尸源码后习惯性双击工程文件夹,看到 Unity Hub 问要不要升级就一路“Yes”。这个习惯放在 2D 克隆工程里很容易埋雷:升级管线、改光源、动画重新绑定,全在你不会注意到的配置里发生。正确顺序是先把工程结构扫一遍,再决定用哪个编辑器版本打开。
2.1 工程目录:先分清六个目录再说话
一套完整的 Unity 植物大战僵尸克隆工程,Assets 下通常按这样组织:
| 目录 | 职责 | 关键内容示例 |
|---|---|---|
| Assets/Scenes | 主场景与启动场景 | Main.unity、StartMenu.unity |
| Assets/Scripts | 全部 C# 逻辑 | GameManager.cs、Plant.cs、Zombie.cs、Bullet.cs、WaveManager.cs |
| Assets/Prefabs | 预制体 | 向日葵/豌豆射手/坚果墙、不同僵尸、豌豆子弹、阳光收集物 |
| Assets/ScriptableObjects | 静态配置 | 植物数值、波次配置、关卡配置 |
| Assets/Art | 美术资源 | Sprites、Animations、Animator Controller |
| Assets/Audio | 音效与音乐 | 射击、被啃、爆炸、背景音乐 |
我的习惯是先看 Scripts 目录里的命名,判断这套工程是“教学型单场景工程”还是“模块化多人开发工程”。单场景教学工程通常一个 GameManager 挂满全局逻辑,适合整体阅读;模块化工程则是 PlantSystem、ZombieSystem 分开,改起来更安全但上手成本高。植物大战僵尸源码绝大多数属于前者,所以阅读主线就是 GameManager 里那几段:阳光余额、卡片冷却、僵尸波次、游戏状态切换。
2.2 Unity 版本选型与安装注意点
第一步永远是看版本文件,不是猜:
cat ProjectSettings/ProjectVersion.txt输出通常长这样:
m_EditorVersion: 2021.3.10f1这个字符串决定了你该在 Unity Hub 里安装哪个版本。植物大战僵尸这类 2D 塔防工程,绝大多数是基于 2021.3 LTS 或 2022.3 LTS 写的;如果项目版本低于你本地安装的版本,Unity 会弹升级提示,能升,但升完一定要重新检查 Lighting 和动画状态机。如果项目版本高于你本地版本,就别强行打开,直接去 Unity Hub 的“Archive”列表装对应编辑器。
安装时除了编辑器本体,要勾选 Windows Build Support(Mono)和 Windows Build Support(IL2CPP),做安卓就再加 Android Build Support。很多报错“FileNotFoundException”其实不是代码问题,是打包模块没装。
还有两个常见坑提前说清楚。第一个是 Unity Trial Version 水印:Editor 右上角一直挂着的半透明水印,说明当前许可证没激活成功,打包出来也会带上。去 Unity Hub 里重新登录并激活 Personal License 就能消掉,不是工程问题。第二个是平台宏定义——工程里如果出现类似“编辑器里刷新的辅助按钮,打包后要隐藏”的需求,标准做法是预编译指令:
#if UNITY_EDITOR Debug.Log("这段只在编辑器里执行,打包进 Player 后被裁剪"); #else Debug.Log("这段只在打包后运行"); #endif参数说明:宏定义是编译期行为,不是运行期判断,别指望它控制游戏内逻辑。UNITY_EDITOR 只在编辑器进程里定义;UNITY_ANDROID、UNITY_IOS 对应发布平台。
另外按需在 Package Manager 里补 2D Tilemap Editor 和 Input System。如果原工程用的是旧输入系统,而你本地默认新建工程是新的 Input System,打开后会出现“输入管理冲突”的红字——Package Manager 里禁用新输入系统或改 Player Settings 的 Active Input Handling 为 Both 即可。
2.3 打开场景到第一帧运行
版本对齐后,按这个顺序走,能避开八成的“打开即翻车”:
- 用 Unity Hub 打开工程,等编译完成,Console 里的报错先截图再处理。
- 找到 Assets/Scenes/Main.unity,双击加载主场景。
- 在 Hierarchy 面板确认三个对象必须存在:Main Camera、GameManager、Canvas。
- 选中 GameManager,看 Inspector 里阳光初始值、波次配置是否被正确引用。
- 点 Play 前,把 Console 面板打开,过滤 Error,再进入运行模式。
补一段最常见的启动前自检,直接放在场景里一个空物体上有用:
public class StartupCheck : MonoBehaviour { private void Awake() { var gm = FindFirstObjectByType<GameManager>(); if (gm == null) { Debug.LogError("当前场景缺少 GameManager,阳光与波次逻辑不生效"); return; } if (Camera.main == null) { Debug.LogError("场景缺少 Main Camera,玩家什么都看不到"); } } }逻辑说明:Awake 在场景加载时执行,FindFirstObjectByType 是 Unity 2021.3 后的新查找 API;老工程里如果写的是 FindObjectOfType,Unity 会标过时警告,不影响运行,但建议顺手替换。参数说明:这两行检查只做存在性验证,不修数据——真正的修复还得回到场景里手动拖引用。跑通第一帧后,再开始动业务代码不迟。
3. 核心玩法拆解:种植、寻路、碰撞三条主线的代码落点
植物大战僵尸源码的价值不在美术,在玩法循环:阳光是货币、植物是防御塔、僵尸是敌人、胜负判定是终点线。这一章把三条主线拆开看,每条都给到能直接抄的代码形态和参数建议。
3.1 Plant 种植系统:从点击卡片到格子生成
种植的完整链路是:点击卡片 → 检查阳光与冷却 → 鼠标位置转格子坐标 → 检查格子占用 → 扣阳光并生成植物。常见做法是把格子系统封装成 Board 单例,Plant 脚本只负责生长和攻击。核心方法长这样:
public bool TryPlant(Vector2 worldPos, PlantData data) { if (currentSun < data.sunCost) { AudioManager.Instance.PlayNotEnoughSun(); return false; } Vector2Int cell = Board.Instance.WorldToCell(worldPos); if (Board.Instance.IsOccupied(cell)) { Debug.Log("该格子已被占用"); return false; } currentSun -= data.sunCost; UIManager.Instance.RefreshSunText(currentSun); GameObject plant = Instantiate(data.prefab, Board.Instance.CellToWorld(cell), Quaternion.identity); plant.GetComponent<Plant>().Init(data, cell); StartCoroutine(CardCooldown(data.cooldown)); return true; }逻辑说明:先扣阳光再实例化,避免生成过程中再次点击造成重复扣费。WorldToCell 把鼠标的世界坐标映射到棋盘二维数组下标,CellToWorld 再换算回生成位置——这两个转换内部要处理格子大小(常见是 0.8x0.8 或 1x1),改棋盘尺寸时只需要动 Board 里的 CellSize 参数。
参数说明:currentSun 是 GameManager 里的公共字段,初始值一般给 200,向日葵每 10 秒产 25 点阳光;data.sunCost 来自 ScriptableObject 配置。CardCooldown 协程负责把 UI 上卡片的遮罩值从 1 扣到 0,冷却结束才允许再次种植。这里最容易出问题的不是逻辑,而是“点击被 UI 吃掉”和“格子坐标算偏”,这两种情况放第 5 章单独说。
3.2 Zombie 生成与寻路:不用 NavMesh 的直线推进方案
植物大战僵尸里的僵尸不需要寻路,每行固定一条直线从左往右走。做 Unity 克隆工程时,最稳的方案不是 NavMesh,而是“设定行的 Y 坐标 + 向负 X 移动”。波次生成由 WaveManager 统一调度:
[System.Serializable] public class WaveConfig { public float delayBeforeWave; // 波次开始的等待时间 public int zombieCount; // 本波僵尸数量 public float spawnInterval; // 每只僵尸的生成间隔 public ZombieType zombieType; // 普通/路障/铁桶 } public IEnumerator SpawnRoutine(WaveConfig wave) { yield return new WaitForSeconds(wave.delayBeforeWave); for (int i = 0; i < wave.zombieCount; i++) { float row = Random.Range(0, Board.Instance.RowCount); Zombie zombie = SpawnAtRow(row, wave.zombieType); zombie.Init(); yield return new WaitForSeconds(wave.spawnInterval); } }逻辑说明:随机行用 Random.Range(0, RowCount),注意这个重载是左闭右开,能取到 0 到 RowCount-1,永远取不到 RowCount,不会出现下标越界。SpawnAtRow 里负责实例化僵尸并设置初始 X 坐标在屏幕右边缘之外,Z 轴固定为行索引对应值。
参数说明:zombieCount 和 spawnInterval 共同决定压力曲线。初学者常犯的错误是把生成间隔设成固定值,导致每波节奏像节拍器一样平。想要自然波动,可以在间隔里加随机:Random.Range(1.5f, 3.5f),也可以用 Mathf.PerlinNoise 生成平滑的密度变化——把 Perlin 噪声的返回值映射到间隔区间,波次进攻的火力就会有一阵松、一阵紧的起伏,观感比纯随机好很多。“Unity Mathf.PerlinNoise”经常被人拿来做地形高度,实际做波次随机密度一样顺手。
僵尸移动本体不需要 AI,一行代码:
transform.position += speed * Time.deltaTime * Vector3.left;speed 暴露成公共字段,每个 Zombie 预制体里单独调。普通僵尸建议 0.5,路障僵尸 0.4,铁桶僵尸 0.35,具体数值以关卡手感为准。
3.3 子弹碰撞与伤害结算:把伤害收口到一个方法里
豌豆子弹的碰撞处理看起来简单,但实现方式直接决定后期好不好扩展冰冻、减速。常见做法是子弹带电时检查碰撞,命中后调用僵尸身上的 TakeDamage 方法,而不是在子弹里直接改生命值:
private void OnTriggerStay2D(Collider2D other) { if (isDead) return; if (!other.CompareTag("Zombie")) return; Zombie zombie = other.GetComponent<Zombie>(); if (zombie != null) { zombie.TakeDamage(damage); isDead = true; gameObject.SetActive(false); // 配合对象池回收,而不是 Destroy } }逻辑说明:用 CompareTag 而不是 GetComponent 判类型,是因为标签比较开销更小。isDead 防止子弹在禁用前的一帧里重复结算多次伤害。SetActive(false) 是对象池标准回收方式,对象池的内容放到第 6 章。
参数说明:damage 在豌豆子弹上是 20,僵尸血量普通 200、路障 380、铁桶 600。伤害数值不要散落在各处脚本里,统一放进 PlantData 或 BulletData 配置,后期调关卡平衡只在 Inspector 里改。TakeDamage 内部要顺便处理受击动画、音效、死亡状态切换,这些逻辑集中在僵尸脚本里,而不是散在子弹里——否则以后加冰冻效果,你得同时改子弹和僵尸两边。樱桃炸弹那类范围伤害也是走 TakeDamage,只是遍历范围内所有僵尸。
4. 视觉与交互补齐:摄像机、UI 遮罩、音效动画的细节
玩法主线跑通后,离“能给别人展示”还差三块:摄像机看着对不对、UI 反馈跟不跟手、音效动画有没有糊弄过去。这一章讲清楚参数怎么设,以及常见偷懒做法和正式做法之间的差别。
4.1 摄像机跟随与边界限制
植物大战僵尸是固定棋盘游戏,摄像机理论上不用跟随。但很多工程为了让关卡有“推近拉远”的演出效果,会给摄像机加一个跟随逻辑,跟随目标一般是决策点或 Corn Cannon 的落点。实现时最需要注意的是边界:
public float smooth = 5f; public float minX, maxX, minY, maxY; private void LateUpdate() { float targetX = Mathf.Clamp(followTarget.position.x, minX, maxX); float targetY = Mathf.Clamp(followTarget.position.y, minY, maxY); Vector3 targetPos = new Vector3(targetX, targetY, -10f); float t = 1f - Mathf.Exp(-smooth * Time.deltaTime); transform.position = Vector3.Lerp(transform.position, targetPos, t); }逻辑说明:相机 Z 轴锁定 -10,这是 2D 正交相机的标准做法——Z 决定相机在 XY 平面上的观察距离,改这个值会影响正交投影的裁剪关系,一般不用动。
参数说明:Mathf.Clamp 把目标位置限制在棋盘范围内,minX 示例 -1.5,maxX 示例 9.2,minY -3.2,maxY 3.2,具体数值取决于你 Board 的尺寸。这里有个新手很容易忽略的细节:Vector3.Lerp(a, b, t)的 t 是插值比例而不是速度系数,直接写smooth * Time.deltaTime会在帧率不同时手感不一致。正确写法是1 - Mathf.Exp(-smooth * Time.deltaTime),这是帧率无关的平滑公式,比乘 deltaTime 再钳制到 1 的做法稳定。摄像机跟随逻辑写在 LateUpdate 而不是 Update,是因为要等目标对象移动完成后再跟随,避免画面抖动。
4.2 UI 布局:阳光数量、卡片冷却与反向遮罩
Canvas 下的层级按照清晰度排序:底部是棋盘渲染层,上面是 Canvas 的 Screen Space 层,再往上是阳光计数器、卡片栏、铲子按钮、暂停按钮。一套合理的层级结构是这样:
Canvas (Screen Space - Camera) ├── SunCounter (Text) // 右上角阳光数量 ├── CardContainer (Horizontal Layout) │ ├── Card_0 (Button + Image + CooldownMask) │ ├── Card_1 │ └── Card_2 ├── ShovelButton └── StartPanel卡片冷却的 UI 实现有很多种,最常见的是把冷却遮罩做成一张半透明 Image,覆盖在卡片按钮上方,用 Image.fillAmount 控制显示比例。这里大部分人第一次就栽在“遮罩方向不对”——Unity 的 Image Filled 类型默认从右往左填充,你要的是“从有到无、从上到下消失”,于是去调 Fill Origin 和 Clockwise,调半天也不对。
更省事的方案是反向遮罩组件思路:给遮罩 Image 单独建一层,用 RectMask2D 配合一个自定义反向遮罩脚本来裁剪,或者直接用两层 Image:下层显示冷却中的暗色,上层用 fillAmount 控制亮色区域从 1 到 0,视觉上就是卡片从亮到暗的完整冷却过程。代码控制就一行:
public Image cooldownOverlay; public void SetCooldownProgress(float current, float total) { float progress = 1f - current / total; // 1 → 0 逐渐变暗 cooldownOverlay.fillAmount = progress; cooldownOverlay.raycastTarget = false; // 避免遮罩挡住按钮点击 }逻辑说明:fillAmount 从 1 扣到 0,刚好匹配冷却结束卡点亮起的效果。raycastTarget 必须设成 false,否则遮罩 Image 会吃掉 Button 的点击事件,出现“点了没反应、卡片却进冷却”的问题。
参数说明:current 是协程里记的已冷却时间,total 是 PlantData 里的 cooldown 字段。如果工程用的是新 Input System,还要确认 EventSystem 里 Input System UI Input Module 已经挂上;老项目直接拖进去用旧输入系统的,需要在 Player Settings 把 Active Input Handling 改成 Both。
4.3 音效触发与动画事件
克隆工程最常见的音效问题是“所有声音都挂在 Update 里用 Play 方法轮询”,结果就是同一帧播放十几次射击声。正确做法是让动画帧来触发音效。拿豌豆射手举例:生成子弹的瞬间应该播放射击声,而不是靠碰撞触发。在 Animator 里给“Shoot”动画帧加 AnimationEvent,事件函数写在 Plant 脚本上:
public void OnShootEvent() { audioSource.PlayOneShot(shootClip, 0.6f); }参数说明:PlayOneShot 的第二个参数是音量系数,0.6 表示在原始音量的 60% 播放,避免每次射击声音太炸。别用 Play 方法直接播,Play 会打断当前正在播的音频再重头放,连续射击时声音会被反复切断,听起来像卡碟。
AnimationEvent 的时间点设置注意一个坑:事件绑定的动画帧和实际视觉效果之间有一个帧的延迟,如果声音比子弹生成早或者晚,去 Animator 窗口里把事件往左或往右拖一格就行,不用改代码。僵尸死亡音效同理,放在“Die”动画的起始帧,让声音先出来,倒地动作跟进,比反过来自然得多。
5. 避坑指南:跑不起来、种植失灵、水印与马赛克的五个真实翻车现场
这套工程我前前后后也帮别人排过不少问题,真正高频的坑集中在五个位置。每条都按“现象 → 原因 → 解决”的顺序写。
5.1 场景打开全黑:Game 视图看不到草地和植物
现象:双击 Main.unity 后,Game 视图一片黑,Scene 视图里能看到物体,运行后却什么都看不见。
原因:Main Camera 的 Clear Flags 被改成了 Skybox,而 2D 场景里没有天空盒;或者相机 Position 被卡在某个有遮挡的位置。还有一种情况是之前有人调试时改了 Culling Mask,把世界层屏蔽了。
解决:在 Hierarchy 里选中 Main Camera,把 Clear Flags 设为 Solid Color,Background 保持默认灰黑色即可。相机 Position 是正交 2D 的,重置为 (0, 0, -10)。如果你不想每次手动检查,可以在菜单里挂一键修复:
[MenuItem("Tools/Fix MainCamera")] static void FixCamera() { Camera cam = Camera.main; cam.transform.position = new Vector3(0f, 0f, -10f); cam.orthographic = true; cam.orthographicSize = 6f; // 6 是 2D 塔防常用的半高,按棋盘尺寸调 cam.clearFlags = CameraClearFlags.SolidColor; }逻辑说明:Orthographic Size 表示相机可视范围的一半,数值越大看到的世界越宽。棋盘高 5 格的工程,Size 取 5.5 到 6 之间刚好留出上下边距。
5.2 点击卡片后种植没反应,卡片却进入冷却
现象:点击豌豆射手卡片,卡片立刻变灰进入冷却,但棋盘上没有任何植物生成。
原因:点击被 UI 层的 Image 挡住,Board 的格子射线没触发;或者 Physics2D.Raycast 没有按 Layer 过滤,射线打在了 Collider 以外的物体上;更隐蔽的原因是场景里生成了两个 EventSystem,鼠标点击被第一个消费掉,第二个收不到事件。
解决:先确认 Hierarchy 里只有一个 EventSystem。然后把 Board 的每个格子 Collider 单独放到 “PlantSlot” Layer,种植检测用 LayerMask 过滤:
int plantableLayer = 1 << LayerMask.NameToLayer("PlantSlot"); RaycastHit2D hit = Physics2D.Raycast( Camera.main.ScreenToWorldPoint(Input.mousePosition), Vector2.zero, 0f, plantableLayer );逻辑说明:Vector2.zero 作为方向,Raycast 会做点检测,只返回鼠标位置的 Collider;0f 距离表示无限远也可以,但结合 LayerMask 后效率更高。ScreenToWorldPoint 把屏幕坐标换算成世界坐标,如果你忘了这一步,射线会从相机位置出发,永远打不到格子。
参数说明:LayerMask 的1 << Layer写法是位运算,不要直接写成LayerMask.GetMask("PlantSlot")以外的形式;两种写法都行,但要保证 Instance 的植物格子和 Board 检测用的是同一个 Layer。网格的 CellSize 和 Collider 尺寸不匹配,也会出现“鼠标在格子上却点到隔壁”的偏移感,这个靠调 Board Inspector 里的 CellSize 解决,不用改代码。
5.3 水印一直挂在右上角,打包后还在
现象:编辑器里右上角有 Unity 水印,打出来的包运行时也带着。
原因:Unity Trial Version 水印出现代表许可证未激活成功,或者当前登录的账号是试用状态。还有一部分情况是你下载的资源包里自带的启动画面 Logo 是 Unity 默认图标,这个属于 Player Settings 里的 Splash Screen 设置,和水印不是一回事。
解决:Unity Hub 右上角头像里重新登录,进入 Preferences → Licenses 确认状态是 Personal;如果之前用过 Unity 2022 LTS 的试用许可,过期了也会出现水印。打包前检查 Player Settings → Splash Screen → Show Splash Screen 取消勾选。克隆工程如果自带了一堆推广素材,那些属于作者加的启动展示画面,删除相关 Prefab 和 Splash 图片即可,不需要重新连接任何资源。
5.4 素材放大发糊像马赛克,植物叶片背面透明穿帮
现象:精灵图放大后出现明显锯齿和模糊,运行时植物背面能看穿,或者叶子从背面看是透明的。
原因:两个问题叠加。锯齿是导入设置的问题:Filter Mode 用了 Point,而材质默认没有做双面渲染,Unity 2D 精灵材质默认剔除背面,Vertices 转过去的那一面直接消失。
解决:在 Project 窗口选中所有 Sprite,打开 Import Settings,Filter Mode 改 Bilinear,Compression 改 High Quality,Max Size 按原图尺寸设置。材质方面,如果工程里用的是内置渲染管线,可以直接改用双面 Sprite Shader:
Shader "Custom/DoubleSideSprite" { Properties { _MainTex ("Sprite Texture", 2D) = "white" {} } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" } Cull Off Blend SrcAlpha OneMinusSrcAlpha Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag // 标准 Sprite Shader 的顶点片元实现 ENDCG } } }逻辑说明:Cull Off是关键,告诉 GPU 不要裁剪背面。内置管线的默认 Sprite Shader 是单面渲染,叶子模型转了 180 度后另一面变成透明,就是这个原因。用 URP 的工程则直接换上 URP 的 Sprite-Lit-Default 并勾选 Double Sided 选项。
这个现象就是“Unity 游戏去马赛克”问题最常见的源头:不是代码问题,是导入设置问题。改完导入设置后记得点 Apply All,否则只改当前选中的一张图。
5.5 僵尸倒地之后又站起来,动画和状态机“打架”
现象:僵尸血量归零触发了死亡动画,倒下后过了一两秒又站起来继续走。
原因:Zombie 脚本里没有做死亡状态锁定。TakeDamage 在最后一击后仍然被继续调用,Animator 的 Death 状态没有设 Exit Time,或者代码里反复 SetTrigger("Die") 把状态又拉回 Idle。
解决:给 Zombie 加一个 isDead 守卫字段:
public void TakeDamage(int amount) { if (isDead) return; health -= amount; if (health <= 0) { isDead = true; GetComponent<Collider2D>().enabled = false; GetComponent<Animator>().SetTrigger("Die"); Destroy(gameObject, 5f); // 等死亡动画播完再销毁 return; } animator.SetTrigger("Hurt"); }逻辑说明:isDead 必须放在最前面,防止同一帧内多颗子弹同时结算。禁用 Collider 是防止死亡后遮挡其他子弹射线。Destroy 延时 5 秒是给死亡动画留时间,如果工程里用了对象池,改成 ReturnToPool(gameObject) 并把延时逻辑放进池子里。
参数说明:Destroy 的延迟参数要和死亡动画长度匹配,动画 3 秒就销毁 3.2 秒,留 0.2 秒余量避免动画播到一半物体消失。Animator 里死亡状态的 Has Exit Time 保持勾选,但“任何状态 → Death”的切换条件只需要 isDead 为真,过渡时间和过渡偏移都调成 0,这样死亡切换瞬时完成,不会出现插帧导致的“鬼畜”。
6. 从能玩到能改:对象池与 ScriptableObject 两个扩展入口
6.1 对象池优化:子弹与僵尸的生成回收
豌豆射手每 1.5 秒射一次,满屏 20 株就是每秒十几发 Instantiate;僵尸高峰波次同时 30 个预制体进出。这个量在 PC 上不痛不痒,但一上手机立刻掉帧。对象池是成本最低的优化,把 Instantiate 换成从池里取:
public class ObjectPool<T> where T : Component { private readonly Queue<T> pool = new(); private readonly T prefab; public T Get(Vector3 position) { if (pool.Count > 0) { T item = pool.Dequeue(); item.gameObject.SetActive(true); item.transform.position = position; return item; } return Instantiate(prefab, position, Quaternion.identity); } public void Return(T item) { item.gameObject.SetActive(false); pool.Enqueue(item); } }逻辑说明:取对象时先看池里有没有空闲实例,有就激活并复用,没有才实例化新对象。归还没用 Destroy 而是 SetActive(false),排到队尾等下次复用。这套工程里子弹、阳光、僵尸生成器都可以各建一个池,游戏运行时生成的瞬时物体全部走池子,速度比 Instantiate/Destroy 高一个量级。
参数说明:池子默认预热的数量建议和峰值并发量对齐——豌豆子弹池预热 50 发,僵尸池预热 20 只。池容量不要设太大,否则内存换帧率,得不偿失。
6.2 用 ScriptableObject 扩展自定义植物
把植物数值写死在代码里是后期改起来最痛的地方。标准做法是建一个 PlantData 资产文件,所有植物预制体共享这一份配置:
[CreateAssetMenu(menuName = "PvZ/PlantData")] public class PlantData : ScriptableObject { public string plantName; public int sunCost; public float cooldown; public int maxHealth; public float attackInterval; public GameObject bulletPrefab; public AudioClip attackClip; }逻辑说明:每个植物做成一个 .asset 文件,比如 Peashooter.asset、Sunflower.asset,字段在 Inspector 里直接改,不需要改代码。GameManager 的卡片列表只引用这些资产,新增植物就是在 Assets 里右键创建资产,拖好预制体引用,再把它拖进卡槽配置数组里。
| PlantData 字段 | 类型 | 示例值 | 说明 |
|---|---|---|---|
| sunCost | int | 100 | 种植消耗阳光数 |
| cooldown | float | 7.5 | 卡片冷却秒数 |
| maxHealth | int | 300 | 被僵尸啃食的生命上限 |
| attackInterval | float | 1.5 | 两次攻击之间的间隔秒数 |
| bulletPrefab | GameObject | PeaBullet | 子弹预制体引用 |
想给樱桃炸弹做爆炸范围指示器时,可以借鉴 Unity Skill Attack Indicators 那套思路:把爆炸半径做成一个临时 Sprite 预制体,种植时显示一圈红色范围,1 秒后消失再引爆,视觉效果比直接炸出来清楚得多。这套工程做完这些,还能试着把 Canvas 改成 World Space 移植到 PICO 4 做 VR 桌面投影,那时 UI 距离、射线交互和手部悬停判断会是新的排坑点。
我的习惯是拿到任何克隆工程,先花十分钟查 ProjectVersion、Camera、EventSystem 这三个黑匣子再动手改逻辑——这套流程至少帮我省掉了一半的“为什么我这里能跑你那里不行”。希望帮到你。
本文还有配套的精品资源,点击获取