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

资讯详情

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

Unity游戏开发实战:从零复刻植物大战僵尸核心系统

Unity游戏开发实战:从零复刻植物大战僵尸核心系统 1. 项目缘起与目标设定最近在整理自己的技术栈发现虽然用过Unity做过不少小Demo但总感觉缺一个能串起UI、动画、状态机、对象池和简单游戏逻辑的完整项目。直接上手复杂项目又容易迷失在细节里于是就想找个经典、结构清晰又足够有趣的游戏来“复刻”一下。植物大战僵尸Plants vs. Zombies 简称PVZ几乎是完美的选择它的核心玩法直观每个单位植物/僵尸的行为明确非常适合用来练习面向对象的设计、有限状态机的实现以及Unity中各种基础组件的协同工作。这个系列笔记就是我这次“仿作”之旅的实录。我不会追求做一个功能百分百还原的复刻版那既无必要也耗费精力。我的核心目标是以PVZ的核心玩法为蓝本从零开始搭建一个可运行的简化版本并在此过程中深入理解并实践Unity游戏开发中那些“高频”且“关键”的技术点。比如如何优雅地管理战场上大量的游戏对象如何设计植物和僵尸的伤害、攻击逻辑如何实现那个看似简单实则有趣的“向日葵产阳光”的经济系统这些才是本次学习的重点。在第一篇笔记里我们将从最基础的场景搭建和核心框架设计开始。我会分享我如何规划项目结构如何创建第一个可交互的“格子”以及如何为后续的植物种植、僵尸生成打下坚实的数据和逻辑基础。过程中遇到的坑、走过的弯路以及最终我认为比较优雅的解决方案都会毫无保留地记录下来。如果你也对Unity感兴趣或者正想找一个项目来练手希望这篇笔记能给你带来一些实实在在的启发。2. 项目结构与核心数据模型设计在动手写第一行代码之前花点时间思考整体架构是绝对值得的。一个清晰的结构能让后续的功能添加和调试事半功倍。我的项目结构主要分为以下几个核心部分Scripts/Runtime: 存放所有运行时脚本这是我们的主战场。Core/: 核心管理器和单例如游戏管理器、资源管理器、对象池管理器。Data/: 数据模型和ScriptableObject资产用于配置植物、僵尸的属性。Entities/: 游戏实体如PlantBase、ZombieBase及其各种派生类向日葵、豌豆射手、普通僵尸等。GridSystem/: 网格系统相关负责战场格子的管理、交互和表现。UI/: 用户界面相关的控制器和组件。Art: 存放所有美术资源包括Sprites、动画、预制体等。我会尽量使用免费或自己制作的简单素材核心是逻辑实现。Settings: 存放ScriptableObject创建的各种配置资产。接下来我们重点聊聊数据模型。在PVZ中每一种植物和僵尸都有其固定的属性如生命值、攻击力、攻击间隔、生产阳光的间隔等。如果把这些数值硬编码在脚本里后续调整平衡性将是噩梦。Unity提供的ScriptableObject是解决这个问题的神器。我首先创建了一个基础的数据容器EntityData// Scripts/Runtime/Data/EntityData.cs using UnityEngine; [CreateAssetMenu(fileName NewEntityData, menuName PVZ/Entity Data)] public class EntityData : ScriptableObject { public string entityName; public int cost; // 种植或召唤消耗 public float health; public Sprite idleSprite; // 待机状态精灵图 // 其他通用属性... }然后分别创建植物和僵尸的专属数据类继承自EntityData// Scripts/Runtime/Data/PlantData.cs [CreateAssetMenu(fileName NewPlantData, menuName PVZ/Plant Data)] public class PlantData : EntityData { public float attackDamage; // 攻击力 public float attackCooldown; // 攻击冷却时间秒 public float produceSunInterval; // 生产阳光间隔秒仅对向日葵类有效 public int sunProducedAmount; // 每次生产阳光数量 public GameObject projectilePrefab; // 发射物预制体如豌豆 // 植物特有的其他属性如是否为防御性植物等 } // Scripts/Runtime/Data/ZombieData.cs [CreateAssetMenu(fileName NewZombieData, menuName PVZ/Zombie Data)] public class ZombieData : EntityData { public float moveSpeed; // 移动速度 public float attackDamage; public float attackCooldown; // 僵尸特有的属性如是否携带路障、铁桶等 }这样设计的好处显而易见策划或者自己可以在Unity编辑器里直观地创建和修改各种植物、僵尸的配置资产无需修改代码。比如你觉得豌豆射手攻击力太强直接打开PeaShooterData.asset文件把attackDamage从20调到15即可游戏运行时自动生效。注意在使用ScriptableObject时一个常见的坑是误以为修改了资产文件预制体或场景中的实例就会自动更新。实际上如果你在运行时通过脚本修改了ScriptableObject实例的字段这些修改默认是临时的退出播放模式后会恢复。如果希望持久化需要处理数据的保存与加载这通常不是配置数据的用途。我们的用法是将其视为只读的运行时配置模板。3. 战场网格系统交互与逻辑的基石PVZ的核心玩法建立在那个5x9或者更多行的草坪网格上。这个网格系统需要处理两件事1.视觉表现格子高亮2.逻辑容器记录哪个格子上种了什么植物。我选择将两者分离。逻辑网格用一个二维数组GridCell[,]在内存中维护而视觉表现则由一套GridTile游戏对象来处理。首先定义GridCell逻辑单元// Scripts/Runtime/GridSystem/GridCell.cs [System.Serializable] public class GridCell { public Vector2Int GridPosition { get; private set; } // 网格坐标如(0,0) public PlantBase Plant { get; private set; } // 当前格子上种植的植物 public bool IsOccupied Plant ! null; public GridCell(int x, int y) { GridPosition new Vector2Int(x, y); } public bool TryPlacePlant(PlantData data, out PlantBase plantInstance) { plantInstance null; if (IsOccupied) { Debug.LogWarning($格子{GridPosition}已被占用); return false; } // 这里会调用对象池或实例化植物预制体 // plantInstance InstantiatePlant(data); // Plant plantInstance; return true; } public void RemovePlant() { Plant null; } }然后创建GridManager单例来管理整个网格// Scripts/Runtime/GridSystem/GridManager.cs public class GridManager : MonoBehaviour { public static GridManager Instance { get; private set; } [SerializeField] private int gridWidth 9; [SerializeField] private int gridHeight 5; [SerializeField] private float cellSize 1.0f; // 每个格子的Unity单位尺寸 [SerializeField] private GameObject gridTilePrefab; // 格子视觉预制体 private GridCell[,] _grid; private DictionaryVector2Int, GridTile _gridTileMap; // 关联逻辑格子与视觉格子 private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; InitializeGrid(); } private void InitializeGrid() { _grid new GridCell[gridWidth, gridHeight]; _gridTileMap new DictionaryVector2Int, GridTile(); for (int x 0; x gridWidth; x) { for (int y 0; y gridHeight; y) { _grid[x, y] new GridCell(x, y); // 实例化视觉格子 Vector3 worldPos GridToWorldPosition(new Vector2Int(x, y)); GameObject tileGo Instantiate(gridTilePrefab, worldPos, Quaternion.identity, transform); GridTile tile tileGo.GetComponentGridTile(); tile.Initialize(_grid[x, y]); _gridTileMap[new Vector2Int(x, y)] tile; } } } public Vector3 GridToWorldPosition(Vector2Int gridPos) { // 计算格子中心点的世界坐标 return new Vector3(gridPos.x * cellSize, gridPos.y * cellSize, 0); } public bool TryGetGridCell(Vector2Int gridPos, out GridCell cell) { cell null; if (gridPos.x 0 || gridPos.x gridWidth || gridPos.y 0 || gridPos.y gridHeight) return false; cell _grid[gridPos.x, gridPos.y]; return true; } // 通过屏幕坐标或世界坐标获取格子用于鼠标点击 public bool TryGetGridCellAtWorldPosition(Vector3 worldPos, out GridCell cell) { Vector2Int gridPos WorldToGridPosition(worldPos); return TryGetGridCell(gridPos, out cell); } private Vector2Int WorldToGridPosition(Vector3 worldPos) { int x Mathf.FloorToInt(worldPos.x / cellSize); int y Mathf.FloorToInt(worldPos.y / cellSize); return new Vector2Int(x, y); } }视觉格子GridTile需要处理鼠标交互// Scripts/Runtime/GridSystem/GridTile.cs public class GridTile : MonoBehaviour { [SerializeField] private SpriteRenderer highlightSprite; // 用于高亮的SpriteRenderer private GridCell _linkedCell; public void Initialize(GridCell cell) { _linkedCell cell; highlightSprite.enabled false; } // 当鼠标悬停时由GridManager或单独的输入管理器调用 public void SetHighlight(bool isOn) { highlightSprite.enabled isOn; } // 这里可以处理OnMouseDown但更推荐通过一个统一的InputManager来分发点击事件 private void OnMouseDown() { // 通知游戏状态玩家点击了这个格子可能想种植植物 GameManager.Instance.OnGridTileClicked(_linkedCell); } }实操心得在实现网格坐标与世界坐标转换时要特别注意原点00的位置。我最初没有考虑格子中心点导致实例化的植物总是落在格子角落。后来调整了GridToWorldPosition的计算公式(gridPos.x 0.5f) * cellSize才让植物完美居中。另外将交互逻辑放在GridTile上虽然简单但在项目复杂后更推荐使用一个全局的InputManager来监听输入然后查询GridManager获取被点击的格子这样输入逻辑更集中也便于处理UI遮挡等问题。4. 游戏核心循环与状态管理一个游戏需要有条不紊地运转起来。我们需要一个大脑来协调各个系统这就是GameManager。它通常被设计成单例负责游戏的整体流程、状态切换和核心资源的派发。首先定义游戏状态// Scripts/Runtime/Core/GameState.cs public enum GameState { MainMenu, // 主菜单 Planting, // 准备/种植阶段玩家可以拖放植物卡牌 WaveInProgress, // 波次进行中僵尸生成植物自动攻击 WaveCompleted, // 波次完成 GameOver, // 游戏结束胜利或失败 }然后实现GameManager// Scripts/Runtime/Core/GameManager.cs public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } public GameState CurrentState { get; private set; } public int SunAmount { get; private set; } // 当前阳光数量 // 阳光数量变化事件用于UI更新 public event Actionint OnSunAmountChanged; private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; // 可以在这里进行一些初始化比如加载玩家存档、设置初始阳光等 SetSunAmount(50); // 初始阳光 ChangeState(GameState.Planting); } public void ChangeState(GameState newState) { // 退出旧状态 switch (CurrentState) { case GameState.Planting: // 清理种植阶段的临时状态 break; case GameState.WaveInProgress: // 停止僵尸生成器等 break; } CurrentState newState; Debug.Log($游戏状态切换至: {newState}); // 进入新状态 switch (newState) { case GameState.Planting: // 激活植物卡牌选择UI等 UIManager.Instance.ShowPlantSelection(true); break; case GameState.WaveInProgress: UIManager.Instance.ShowPlantSelection(false); // 开始生成僵尸波次 ZombieSpawner.Instance.StartNextWave(); break; case GameState.GameOver: // 显示游戏结束UI Time.timeScale 0; // 暂停游戏 break; } } public bool TrySpendSun(int amount) { if (SunAmount amount) { SetSunAmount(SunAmount - amount); return true; } else { // 提示阳光不足 UIManager.Instance.ShowMessage(阳光不足); return false; } } public void AddSun(int amount) { SetSunAmount(SunAmount amount); } private void SetSunAmount(int newAmount) { SunAmount newAmount; OnSunAmountChanged?.Invoke(SunAmount); // 通知UI更新 } // 当格子被点击时调用来自GridTile或InputManager public void OnGridTileClicked(GridCell cell) { if (CurrentState ! GameState.Planting) return; // 检查玩家当前是否选择了植物卡牌 PlantData selectedPlant UIManager.Instance.GetSelectedPlantCard(); if (selectedPlant null) return; // 检查阳光是否足够 if (!TrySpendSun(selectedPlant.cost)) return; // 尝试在格子上放置植物 if (cell.TryPlacePlant(selectedPlant, out PlantBase plant)) { // 放置成功清除卡牌选择状态 UIManager.Instance.ClearSelectedPlantCard(); Debug.Log($在{cell.GridPosition}种植了{selectedPlant.entityName}); } } }GameManager像一根线把网格系统、UI系统、资源系统阳光和即将实现的实体系统串了起来。它定义了游戏规则什么状态下可以做什么。例如在Planting状态下玩家点击格子并消耗阳光才能种植物在WaveInProgress状态下植物和僵尸开始自动交互。踩坑实录事件Action的使用需要小心内存泄漏。在GameManager中我使用OnSunAmountChanged事件来更新UI。如果UI控制器在注册事件后在销毁时没有取消注册那么这个UI对象的引用会一直被GameManager持有导致无法被垃圾回收。我的做法是在UI控制器的OnEnable和OnDisable方法中分别注册和取消注册事件。另一个更稳健的模式是使用UnityEvent它在Inspector中可视化但性能稍差。对于简单的通信Action足够高效但务必管理好生命周期。5. 实体基类与对象池初步构想游戏中的植物和僵尸会频繁创建和销毁尤其是僵尸和豌豆子弹。如果每次都使用Instantiate和Destroy会产生大量的内存分配与回收可能引发GC垃圾回收卡顿这是移动端或性能要求高的游戏的大忌。对象池是解决这个问题的标准答案。在实现具体的植物和僵尸之前我们先为所有游戏实体创建一个基类EntityBase并为其融入对象池的思想。// Scripts/Runtime/Entities/EntityBase.cs public abstract class EntityBase : MonoBehaviour { public EntityData Data { get; protected set; } protected float currentHealth; // 关联的逻辑格子植物有僵尸通常没有 protected GridCell occupiedCell; public virtual void Initialize(EntityData data, GridCell spawnCell null) { Data data; currentHealth data.health; occupiedCell spawnCell; if (spawnCell ! null) { // 将自己与逻辑格子关联 // 这里需要根据是PlantBase还是ZombieBase做具体处理基类只提供接口 } } public virtual void TakeDamage(float damage) { currentHealth - damage; if (currentHealth 0) { Die(); } } protected virtual void Die() { // 死亡通用逻辑播放动画、音效、产生奖励等 Debug.Log(${Data.entityName} 被摧毁了); // 通知对象池回收自己而不是直接Destroy ObjectPoolManager.Instance.ReturnToPool(this); } // 对象池相关当从池中取出时调用 public virtual void OnSpawn() { gameObject.SetActive(true); // 重置状态如血量、动画状态等 currentHealth Data.health; } // 对象池相关当放回池中时调用 public virtual void OnDespawn() { gameObject.SetActive(false); // 清理状态如取消所有协程、停止粒子效果等 if (occupiedCell ! null) { occupiedCell.RemovePlant(); occupiedCell null; } } }有了实体基类我们就可以创建PlantBase和ZombieBase来继承它并添加各自特有的行为如攻击、移动、生产阳光。接着我们需要一个简单的ObjectPoolManager来管理这些对象的复用。这里先展示一个最基础的框架// Scripts/Runtime/Core/ObjectPoolManager.cs public class ObjectPoolManager : MonoBehaviour { public static ObjectPoolManager Instance { get; private set; } [System.Serializable] public class Pool { public string tag; // 标识符如PeaShooter, NormalZombie public GameObject prefab; public int initialSize 10; // 初始池大小 } public ListPool pools; private Dictionarystring, QueueGameObject _poolDictionary; private void Awake() { Instance this; InitializePools(); } private void InitializePools() { _poolDictionary new Dictionarystring, QueueGameObject(); foreach (Pool pool in pools) { QueueGameObject objectPool new QueueGameObject(); for (int i 0; i pool.initialSize; i) { GameObject obj Instantiate(pool.prefab); obj.SetActive(false); obj.transform.SetParent(transform); // 统一管理保持场景整洁 objectPool.Enqueue(obj); } _poolDictionary.Add(pool.tag, objectPool); } } public GameObject SpawnFromPool(string tag, Vector3 position, Quaternion rotation) { if (!_poolDictionary.ContainsKey(tag)) { Debug.LogError($对象池中不存在标签为 {tag} 的预制体); return null; } GameObject objectToSpawn; // 如果池子空了就实例化一个新的动态扩容 if (_poolDictionary[tag].Count 0) { Pool targetPool pools.Find(p p.tag tag); if (targetPool null) return null; objectToSpawn Instantiate(targetPool.prefab); } else { objectToSpawn _poolDictionary[tag].Dequeue(); } objectToSpawn.SetActive(true); objectToSpawn.transform.position position; objectToSpawn.transform.rotation rotation; // 调用实体自身的OnSpawn方法 EntityBase entity objectToSpawn.GetComponentEntityBase(); if (entity ! null) { entity.OnSpawn(); } return objectToSpawn; } public void ReturnToPool(EntityBase entity) { // 这里需要一个机制根据实体实例找到其对应的池标签。 // 一个简单的方法是在预制体上挂一个脚本或者通过实体Data的name映射。 // 此处为简化假设实体组件上有一个public string poolTag字段。 string tag entity.GetPoolTag(); // 这是一个需要在自己实体类中实现的方法 if (!_poolDictionary.ContainsKey(tag)) { Debug.LogError($无法放回未找到标签 {tag} 对应的对象池); Destroy(entity.gameObject); return; } entity.OnDespawn(); _poolDictionary[tag].Enqueue(entity.gameObject); } }为什么选择这样的对象池设计市面上有很多优秀的对象池插件但自己实现一个简单的版本能让你更深刻地理解其原理。我设计的这个管理器通过tag来区分不同类型的对象用Queue来管理闲置对象实现了“借”和“还”的基本逻辑。动态扩容池空时实例化新对象保证了游戏不会因为对象不足而崩溃但这也意味着池的大小不是绝对固定的在性能敏感的场景需要仔细设定initialSize。一个更高级的实现可能会加入“最大容量”限制以及定期清理长时间未使用对象的功能。6. 实现第一个可种植植物向日葵理论铺垫了这么多是时候让第一个游戏实体登场了。我们从最简单的生产单位——向日葵开始。向日葵的逻辑相对简单种下后每隔一段时间自动生产一定数量的阳光。首先创建Sunflower脚本继承自PlantBase// Scripts/Runtime/Entities/Plants/Sunflower.cs public class Sunflower : PlantBase { private float _sunProductionTimer; private bool _isProducing false; public override void Initialize(EntityData data, GridCell spawnCell null) { base.Initialize(data, spawnCell); // 将逻辑格子与植物关联在PlantBase中实现具体逻辑 if (spawnCell ! null) { // 假设PlantBase有一个方法处理这个 OccupyCell(spawnCell); } _sunProductionTimer (data as PlantData).produceSunInterval; _isProducing true; } private void Update() { if (!_isProducing) return; if (CurrentState ! GameState.WaveInProgress) return; // 通常只在游戏进行中生产 _sunProductionTimer - Time.deltaTime; if (_sunProductionTimer 0) { ProduceSun(); _sunProductionTimer (Data as PlantData).produceSunInterval; // 重置计时器 } } private void ProduceSun() { PlantData plantData Data as PlantData; if (plantData ! null) { // 通知GameManager增加阳光 GameManager.Instance.AddSun(plantData.sunProducedAmount); // 可以在这里播放一个阳光产生的动画或粒子效果 Debug.Log($向日葵生产了{plantData.sunProducedAmount}点阳光); } } protected override void Die() { _isProducing false; base.Die(); // 调用基类方法处理对象池回收等 } // 对象池相关 public override void OnSpawn() { base.OnSpawn(); _isProducing true; _sunProductionTimer (Data as PlantData).produceSunInterval; } public override void OnDespawn() { base.OnDespawn(); _isProducing false; } // 用于对象池识别 public string GetPoolTag() { return Sunflower; // 与ObjectPoolManager中配置的tag一致 } }接下来我们需要完善PlantBase让它处理与GridCell的关联并提供一个抽象的OccupyCell方法// Scripts/Runtime/Entities/PlantBase.cs public abstract class PlantBase : EntityBase { // 植物特有的行为比如攻击对攻击型植物 public abstract void PerformAction(); protected void OccupyCell(GridCell cell) { // 这里需要一种方式将PlantBase实例设置到GridCell中。 // 我们可以修改GridCell的TryPlacePlant方法让它接受PlantBase实例。 // 或者在PlantBase初始化后由外部如GridManager来设置关联。 // 这是一个设计选择。我选择在GridCell中提供一个SetPlant方法。 if (cell ! null !cell.IsOccupied) { cell.SetPlant(this); // 需要在GridCell类中添加此方法 occupiedCell cell; } } }然后在GridCell类中添加对应的方法// 在GridCell.cs中添加 public void SetPlant(PlantBase plant) { if (IsOccupied) return; Plant plant; // 可能还需要设置植物的位置到格子中心 }最后在Unity编辑器中完成配置创建一个SunflowerData的ScriptableObject资产设置cost50,health100,produceSunInterval24,sunProducedAmount25。制作一个简单的向日葵Sprite创建一个空GameObject挂上Sunflower脚本和SpriteRenderer组件做成预制体SunflowerPrefab。在ObjectPoolManager的Inspector中添加一个Pooltag填Sunflowerprefab拖入SunflowerPrefabinitialSize设为5。修改GridCell的TryPlacePlant方法使其能通过对象池获取植物实例并初始化。// GridCell.cs中TryPlacePlant方法的修改 public bool TryPlacePlant(PlantData data, out PlantBase plantInstance) { plantInstance null; if (IsOccupied) return false; // 通过对象池获取植物实例 GameObject plantGo ObjectPoolManager.Instance.SpawnFromPool(data.entityName, GridToWorldPosition(GridPosition), Quaternion.identity); if (plantGo null) { // 如果对象池没有对应tag尝试直接实例化备用方案 plantGo Instantiate(data.prefab, GridToWorldPosition(GridPosition), Quaternion.identity); } plantInstance plantGo.GetComponentPlantBase(); if (plantInstance ! null) { plantInstance.Initialize(data, this); SetPlant(plantInstance); return true; } else { Debug.LogError($实例化的预制体上没有PlantBase组件); Destroy(plantGo); // 或者返还给对象池 return false; } }现在运行游戏你应该可以在Planting状态下点击植物卡牌UI部分我们下一篇详述消耗阳光然后在草地上成功种下一株向日葵。过一会儿屏幕左上角的阳光数量就会自动增加。第一个可交互的游戏循环就此跑通。注意事项在Update中处理计时如_sunProductionTimer是初学者常用的方法但当场景中有成百上千个需要计时的对象时每个对象每帧都执行Update会带来不小的开销。对于像向日葵生产阳光、豌豆射手发射间隔这类周期性行为更高效的做法是使用协程Coroutine配合WaitForSeconds。例如在Sunflower的OnSpawn中启动一个ProduceSunRoutine协程在OnDespawn中停止它。这样在等待期间该对象不会占用Update的开销。对于大量实体性能提升会非常明显。我将在后续实现攻击植物时展示协程的用法。
返回列表