公司动态

Unity塔防抽卡手游实战:从数据驱动到移动端性能优化

📅 2026/8/18 21:25:59
Unity塔防抽卡手游实战:从数据驱动到移动端性能优化
1. 这个项目到底在做什么以及它为什么值得你花时间如果你正在找一个能串联起 Unity 3D 核心玩法、UI 交互和移动端适配的实战项目这个“防御塔与抽卡机制”的组合是个非常典型的选择。它解决的不是一个单一功能而是如何把两个看似独立的系统——塔防的实时策略和抽卡的养成收集——有机地结合在一个移动游戏项目里。这比单纯做一个塔防或者一个抽卡模拟器要复杂得多也更贴近商业手游的实际开发流程。适合谁看主要分两类人一是已经学完 Unity 基础想找一个中等复杂度的完整项目来巩固技能、建立工程化思维的开发者二是对移动游戏商业化机制如通过抽卡获取和升级防御塔感兴趣想了解其技术实现细节的人。这个项目的价值不在于某个炫酷的算法而在于工程整合能力你如何设计数据驱动的地图、管理塔的建造与升级逻辑、实现流畅的抽卡动画与数据反馈并最终打包成一个能在手机上流畅运行的 APK 或 IPA 文件。最值得关注的几个点第一数据分离。塔的属性攻击力、范围、攻击速度、敌人的属性血量、速度、奖励、抽卡的概率池这些绝不应该硬编码在脚本里。第二状态管理。游戏有多个状态如建造模式、战斗模式、抽卡界面UI 和输入响应需要清晰切换。第三性能考量。移动设备上同时存在大量敌人、子弹和特效时对象池Object Pooling和合理的更新Update逻辑是必须的。第四手感与反馈。抽卡时的视觉表现、塔攻击的命中反馈这些细节直接关系到游戏的核心体验。下面我会按照一个实际项目的开发顺序从框架搭建到核心机制再到移动端适配一步步拆解。我不会只给代码片段而是会解释每一步“为什么”要这么做以及在实际测试中容易踩的坑。2. 搭建项目框架先想清楚再动手写代码在打开 Unity Hub 创建项目之前最重要的一步是规划你的项目结构。一个混乱的文件夹 hierarchy 会在后期让你寸步难行。我建议从一开始就建立清晰的目录规范。2.1 项目结构与资源管理在 Assets 文件夹下我通常会创建以下核心目录Assets/ ├── _Scripts/ # 所有C#脚本 │ ├── Managers/ # 单例管理器GameManager, UIManager, PoolManager等 │ ├── Core/ # 核心逻辑Tower, Enemy, Projectile, Card等 │ ├── UI/ # 界面控制脚本 │ └── Utilities/ # 工具类扩展方法、常量定义等 ├── _Prefabs/ # 预制体 │ ├── Towers/ # 各种防御塔 │ ├── Enemies/ # 各种敌人 │ ├── UI/ # UI预制体按钮、卡牌等 │ └── Effects/ # 子弹、命中特效等 ├── _Art/ # 美术资源临时或最终 │ ├── Sprites/ │ ├── Models/ │ └── Materials/ ├── _Audio/ # 音效与音乐 ├── _Scenes/ # 场景文件 ├── _Resources/ # 需要动态加载的资源谨慎使用 └── _ScriptableObjects/# 最重要的可编程脚本对象Scriptable Object资产 ├── TowerData/ ├── EnemyWaveData/ └── CardPoolData/为什么这么分_Scripts前面的下划线让它排在前面方便查找。Managers使用单例模式负责游戏状态、资源、UI 调度等全局事务。ScriptableObjects是这个项目的灵魂。它将塔的属性、敌人的波次信息、抽卡卡池数据都变成可在 Inspector 中编辑的资产文件实现真正的数据与逻辑分离。修改平衡性时你不需要改代码只需要调整这些资产文件。2.2 核心管理器的创建与职责划分游戏一开始就需要几个全局的管理器。我会先创建GameManager、UIManager和PoolManager。GameManager是游戏大脑它至少需要管理游戏当前状态GameStateMenu,Building,Fighting,Paused,GameOver。玩家资源金币、宝石等。当前关卡/波次信息。提供全局的事件Event或委托Delegate例如OnGoldChanged,OnGameStateChanged让其他系统如UI可以订阅并响应而不是紧耦合地互相调用。// GameManager 简例单例模式注意线程安全简化版 public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } public enum GameState { Menu, Building, Fighting, Paused, GameOver } private GameState _currentState; public GameState CurrentState { get _currentState; set { _currentState value; OnGameStateChanged?.Invoke(value); // 触发状态改变事件 } } private int _gold; public int Gold { get _gold; set { _gold value; OnGoldChanged?.Invoke(value); // 触发金币改变事件 } } // 定义事件 public event ActionGameState OnGameStateChanged; public event Actionint OnGoldChanged; private void Awake() { if (Instance ! null Instance ! this) { Destroy(this.gameObject); } else { Instance this; DontDestroyOnLoad(this.gameObject); // 跨场景持久化 } InitializeGame(); } private void InitializeGame() { Gold 100; // 初始金币 CurrentState GameState.Building; } }UIManager负责所有 UI 面板的显示、隐藏和更新。它监听GameManager发出的事件并更新对应的 UI 元素如金币文本。这样UI 逻辑和游戏逻辑就解耦了。PoolManager对象池管理器对于移动端塔防游戏至关重要。当敌人被击败、子弹命中目标时直接Destroy和Instantiate会产生大量 GC垃圾回收导致游戏卡顿。对象池预先创建好一批对象使用时激活不用时禁用并放回池中。// 一个简单的泛型对象池示例 public class ObjectPoolT where T : MonoBehaviour { private QueueT _pool new QueueT(); private T _prefab; private Transform _parent; public ObjectPool(T prefab, int initialSize, Transform parent null) { _prefab prefab; _parent parent; for (int i 0; i initialSize; i) { T obj GameObject.Instantiate(prefab, parent); obj.gameObject.SetActive(false); _pool.Enqueue(obj); } } public T Get() { if (_pool.Count 0) { T obj _pool.Dequeue(); obj.gameObject.SetActive(true); return obj; } else { // 池空了动态创建一个应尽量避免频繁发生 T obj GameObject.Instantiate(_prefab, _parent); return obj; } } public void Return(T obj) { obj.gameObject.SetActive(false); _pool.Enqueue(obj); } }3. 实现防御塔核心逻辑从数据到行为防御塔系统是塔防游戏的核心其设计好坏直接决定游戏的可玩性和扩展性。3.1 使用 ScriptableObject 定义塔数据首先为塔创建一个数据容器TowerData_SO[CreateAssetMenu(fileName NewTowerData, menuName Tower Defense/Tower Data)] public class TowerData_SO : ScriptableObject { public string towerName; public GameObject towerPrefab; // 对应的预制体 public GameObject projectilePrefab; // 子弹预制体 public Sprite icon; // UI图标 [Header(Attributes)] public int cost; public float damage; public float attackRange; public float attackRate; // 攻击间隔秒 public float projectileSpeed; [Header(Upgrade Paths)] public TowerData_SO[] upgradeOptions; // 可升级到的塔数据 public int upgradeCost; }在 Unity Editor 中右键创建不同的塔数据资产如ArrowTowerData,CannonTowerData分别配置属性。这样策划或你自己调整数值时只需要修改这些资产文件无需触碰代码。3.2 塔的实体与控制脚本塔的实体Tower脚本需要引用其数据并实现攻击逻辑。public class Tower : MonoBehaviour { public TowerData_SO data; private float _attackTimer; private Enemy _currentTarget; void Update() { if (GameManager.Instance.CurrentState ! GameManager.GameState.Fighting) return; _attackTimer - Time.deltaTime; // 寻找目标 if (_currentTarget null || !IsTargetInRange(_currentTarget)) { FindNewTarget(); } // 攻击目标 if (_currentTarget ! null _attackTimer 0f) { Attack(); _attackTimer data.attackRate; } } private void FindNewTarget() { // 简单实现寻找范围内最近的敌人 Collider[] colliders Physics.OverlapSphere(transform.position, data.attackRange, LayerMask.GetMask(Enemy)); float closestDistance float.MaxValue; Enemy closestEnemy null; foreach (var col in colliders) { Enemy enemy col.GetComponentEnemy(); if (enemy ! null) { float dist Vector3.Distance(transform.position, enemy.transform.position); if (dist closestDistance) { closestDistance dist; closestEnemy enemy; } } } _currentTarget closestEnemy; } private bool IsTargetInRange(Enemy target) { return Vector3.Distance(transform.position, target.transform.position) data.attackRange; } private void Attack() { if (data.projectilePrefab ! null) { // 使用对象池获取子弹而不是Instantiate Projectile projectile PoolManager.Instance.GetProjectile(); projectile.transform.position transform.position Vector3.up * 1f; // 发射点 projectile.SetTarget(_currentTarget, data.damage, data.projectileSpeed); } else { // 直接伤害如激光塔 _currentTarget.TakeDamage(data.damage); } // 播放攻击音效、动画等 } // 在Scene视图中绘制攻击范围便于调试 private void OnDrawGizmosSelected() { if (data ! null) { Gizmos.color Color.red; Gizmos.DrawWireSphere(transform.position, data.attackRange); } } }关键点解析状态依赖塔只在Fighting状态下才进行攻击判断。目标查找优化Physics.OverlapSphere每帧调用对性能有压力。在实际项目中可以考虑使用网格Grid或四叉树Quadtree进行空间分区或者降低查找频率如每0.5秒查找一次。对象池子弹的生成必须通过对象池。PoolManager.Instance.GetProjectile()是从池中获取一个可用的子弹对象。可视化调试OnDrawGizmosSelected方法可以让你在 Unity Editor 中选中塔时看到其攻击范围对于关卡设计至关重要。3.3 敌人的移动与伤害承受敌人脚本Enemy需要沿着预设路径点移动并拥有生命值。public class Enemy : MonoBehaviour { public EnemyData_SO data; // 类似TowerData_SO定义血量、速度、金币奖励等 private int _currentHealth; private int _currentWaypointIndex 0; private Transform[] _waypoints; private bool _isDead false; public void Initialize(Transform[] path) { _waypoints path; _currentHealth data.maxHealth; transform.position _waypoints[0].position; } void Update() { if (_isDead || GameManager.Instance.CurrentState ! GameManager.GameState.Fighting) return; MoveAlongPath(); } private void MoveAlongPath() { if (_currentWaypointIndex _waypoints.Length) { ReachDestination(); return; } Vector3 targetPos _waypoints[_currentWaypointIndex].position; transform.position Vector3.MoveTowards(transform.position, targetPos, data.moveSpeed * Time.deltaTime); if (Vector3.Distance(transform.position, targetPos) 0.1f) { _currentWaypointIndex; } } public void TakeDamage(float damage) { if (_isDead) return; _currentHealth - (int)damage; // 显示伤害数字通过UIManager或对象池 UIManager.Instance.ShowDamageText(transform.position, damage.ToString()); if (_currentHealth 0) { Die(); } } private void Die() { _isDead true; GameManager.Instance.Gold data.goldReward; // 奖励金币 // 播放死亡动画、音效 // ... // 回收到对象池而不是Destroy PoolManager.Instance.ReturnEnemy(this); } private void ReachDestination() { // 敌人到达终点扣减玩家生命值 GameManager.Instance.PlayerHealth - data.damageToPlayer; PoolManager.Instance.ReturnEnemy(this); } }敌人波次管理需要一个WaveManager或Spawner脚本根据EnemyWaveData_SO定义了每波敌人的类型、数量、间隔来生成敌人。生成敌人时同样要从PoolManager获取。4. 构建抽卡系统数据、表现与反馈抽卡机制是游戏的另一个核心循环它负责提供新的防御塔或升级材料。其实现关键在于概率权重、视觉反馈和数据联动。4.1 设计卡池数据与概率创建一个CardPoolData_SO来定义卡池[System.Serializable] public class CardItem { public TowerData_SO towerData; // 抽中的物品这里以塔为例 public int weight; // 权重概率 此卡权重 / 总权重 public Rarity rarity; // 稀有度用于UI显示 } public enum Rarity { Common, Rare, Epic, Legendary } [CreateAssetMenu(fileName NewCardPool, menuName Tower Defense/Card Pool)] public class CardPoolData_SO : ScriptableObject { public string poolName; public ListCardItem cardItems; public int costPerDraw; // 单抽消耗如宝石 public TowerData_SO DrawCard() { int totalWeight 0; foreach (var item in cardItems) { totalWeight item.weight; } int randomPoint UnityEngine.Random.Range(0, totalWeight); int currentWeight 0; foreach (var item in cardItems) { currentWeight item.weight; if (randomPoint currentWeight) { return item.towerData; } } // 理论上不会走到这里但保底返回第一个 return cardItems[0].towerData; } }这个DrawCard方法实现了经典的权重随机算法。你可以创建多个卡池如“新手池”、“高级池”并在不同阶段或通过不同货币抽取。4.2 实现抽卡界面与动画抽卡 UI 通常包括一个按钮点击后消耗资源并触发抽卡逻辑。一个展示抽卡结果的区域如卡牌翻转、光芒特效。一个显示抽到物品的详情面板。UI 控制脚本GachaPanel示例public class GachaPanel : MonoBehaviour { [SerializeField] private CardPoolData_SO _cardPool; [SerializeField] private Button _drawButton; [SerializeField] private Image _resultCardImage; [SerializeField] private Text _resultNameText; [SerializeField] private GameObject _cardFlipAnimationPrefab; private void OnEnable() { _drawButton.onClick.AddListener(OnDrawButtonClicked); // 检查资源是否足够更新按钮状态 UpdateButtonState(); } private void OnDisable() { _drawButton.onClick.RemoveListener(OnDrawButtonClicked); } private void UpdateButtonState() { bool canDraw GameManager.Instance.Gem _cardPool.costPerDraw; // 假设消耗宝石 _drawButton.interactable canDraw; } private void OnDrawButtonClicked() { if (GameManager.Instance.Gem _cardPool.costPerDraw) { UIManager.Instance.ShowToast(宝石不足); return; } // 1. 扣除资源 GameManager.Instance.Gem - _cardPool.costPerDraw; // 2. 执行抽卡逻辑可以放在协程中配合动画 StartCoroutine(DrawCardRoutine()); } private IEnumerator DrawCardRoutine() { // 禁用按钮防止连续点击 _drawButton.interactable false; // 播放抽卡动画例如卡牌翻转特效 GameObject animObj Instantiate(_cardFlipAnimationPrefab, transform); // 等待动画播放一段时间 yield return new WaitForSeconds(1.5f); // 3. 从卡池中获取结果 TowerData_SO drawnTowerData _cardPool.DrawCard(); // 4. 更新UI显示结果 _resultCardImage.sprite drawnTowerData.icon; _resultNameText.text ${drawnTowerData.towerName} ({drawnTowerData.rarity}); // 可以播放获得音效、粒子特效等 // 5. 将抽到的塔添加到玩家的“仓库”或“可用塔列表”中 InventoryManager.Instance.AddTower(drawnTowerData); // 6. 销毁动画对象 Destroy(animObj); // 重新启用按钮并更新状态 _drawButton.interactable true; UpdateButtonState(); // 7. 可以弹出详情面板让玩家确认 UIManager.Instance.ShowCardDetailPanel(drawnTowerData); } }经验之谈反馈要及时扣资源、播放动画、显示结果、添加到仓库这几个步骤的视觉和逻辑反馈要清晰、快速。防连点抽卡过程中一定要禁用按钮防止玩家快速点击导致逻辑错误或资源多次扣除。数据持久化InventoryManager负责管理玩家拥有的塔牌。这些数据需要在游戏退出时保存如使用PlayerPrefs、JsonUtility序列化到文件或连接服务器下次进入游戏时加载。4.3 抽卡与塔防的联动抽卡获得的塔牌需要在建造模式下可供玩家放置。这需要修改塔的建造逻辑。通常玩家在建造模式下点击一个空的“塔基”建造点会弹出一个 UI 面板显示当前已解锁的塔来自抽卡和初始解锁。玩家选择其中一个消耗金币完成建造。BuildManager或BuildModeController需要从InventoryManager获取可建造的塔列表而不是一个固定的列表。5. 移动端适配与性能优化让游戏真正能在手机上跑起来从 PC 编辑器到移动设备是很多新手开发者容易翻车的一步。以下是一些关键的适配和优化点。5.1 输入控制触屏代替鼠标塔防游戏在移动端的主要操作是拖拽放置塔、点击升级塔、点击抽卡按钮。放置塔使用Input.touchCount和Touch.phase来判断触屏状态。通过Camera.main.ScreenPointToRay将触屏坐标转换为世界坐标进行射线检测来判断是否点击到了可建造点。if (Input.touchCount 0 Input.GetTouch(0).phase TouchPhase.Began) { Ray ray Camera.main.ScreenPointToRay(Input.GetTouch(0).position); RaycastHit hit; if (Physics.Raycast(ray, out hit, Mathf.Infinity, LayerMask.GetMask(BuildPoint))) { BuildPoint buildPoint hit.collider.GetComponentBuildPoint(); if (buildPoint ! null buildPoint.IsEmpty) { // 弹出建造UI UIManager.Instance.ShowBuildMenu(buildPoint); } } }UI 交互Unity 的 EventSystem 和 UI Button 对触屏有很好的原生支持确保按钮有足够的点击区域建议不小于 100x100 像素。5.2 性能优化实战清单移动设备性能有限必须进行针对性优化。Draw Call 合并对于大量相同的敌人、子弹、地形使用相同的材质球和贴图。尽可能使用 Sprite Atlas合图来合并 UI 和 2D 精灵的 Draw Call。对于静态场景物体如地图背景勾选Static属性让 Unity 进行静态批处理。GPU Instancing对于大量相同的模型如相同类型的树、石头如果材质支持可以开启 GPU Instancing能极大降低 Draw Call。对象池Object Pooling如前所述敌人、子弹、特效、伤害数字等频繁生成销毁的对象必须使用对象池。这是移动端性能提升最有效的手段之一。降低 Update 开销不是所有脚本都需要每帧Update。对于寻敌逻辑可以改为每 0.3-0.5 秒执行一次使用InvokeRepeating或一个计时器。敌人移动计算如果非常复杂可以考虑使用FixedUpdate或在Update中根据距离相机远近调整更新频率LOD 思路。纹理与模型优化纹理尺寸不要超过必要大小使用压缩格式如 ASTC。模型面数要精简特别是远处看不到的细节。使用 Profiler 和 Frame Debugger在 Unity Editor 中使用Window Analysis Profiler和Window Analysis Frame Debugger。Profiler 帮你找到 CPU/GPU/内存的瓶颈Frame Debugger 帮你看清每一个 Draw Call 是怎么产生的。这是性能调优的“眼睛”。5.3 适配不同屏幕分辨率UI 布局使用 Unity 的 Canvas Scaler设置为Scale With Screen Size并设定一个参考分辨率如 1920x1080。UI 元素使用锚点Anchors进行定位而不是绝对坐标。游戏场景摄像机视角FOV/Orthographic Size可能需要根据屏幕宽高比进行微调确保游戏区域在所有设备上都显示完整。安全区Notch/Dynamic Island在 Player Settings 中确保勾选了相应的选项以适配刘海屏和挖孔屏。对于关键 UI如资源显示、按钮要留出安全边距。6. 项目打包、测试与常见问题排查6.1 打包设置以 Android 为例安装环境确保安装了对应版本的 Android SDK NDK 和 OpenJDK。可以通过 Unity Hub 安装。Player SettingsCompany Name和Product Name设置你的应用名称。Default Icon和Splash Image设置应用图标和启动图。Other SettingsPackage Name遵循反向域名规则如com.YourCompany.YourGame。Minimum API Level根据你的目标用户群设置如 Android 8.0 / API Level 26。Target API Level通常选择最新的稳定版。Scripting Backend对于新项目建议使用IL2CPP它性能更好且是发布到商店的必需项。开发阶段可以用 Mono 快速迭代。Target Architectures勾选ARMv7和ARM64以覆盖绝大多数设备。构建File Build Settings添加当前场景选择 Android 平台点击Build或Build And Run。6.2 真机测试与调试ADB 日志在手机上运行游戏时通过 USB 连接电脑在命令行使用adb logcat -s Unity可以查看 Unity 输出的日志这对于排查崩溃和错误至关重要。Development Build在 Build Settings 中勾选Development Build和Autoconnect Profiler。这样打包的版本可以在 Editor 的 Profiler 窗口中实时看到手机端的性能数据是性能调优的黄金手段。触控测试一定要在真机上测试触控手感按钮大小、滑动灵敏度是否合适。6.3 开发过程中高频问题排查塔不攻击/敌人不移动首先检查GameManager.Instance.CurrentState是否为Fighting。检查塔和敌人的 Layer 设置是否正确Physics.OverlapSphere或射线检测的 LayerMask 是否包含了目标层。在 Scene 视图中选中塔查看 Gizmos 绘制的攻击范围是否覆盖了敌人。给Enemy脚本添加 Debug.Log看Update和MoveAlongPath是否在执行。抽卡 UI 没反应/资源没扣除检查按钮的onClick事件是否绑定了正确的方法。检查GameManager.Instance.Gem的数值是否正确扣除逻辑是否在抽卡前执行。在OnDrawButtonClicked方法开始和结束处加Debug.Log看协程是否正常启动。游戏在手机上非常卡顿打开 Development Build用 Profiler 连接手机。重点看CPU Usage哪个函数的Update耗时最长是否是FindObjectsOfType、复杂的射线检测或循环导致GPU Usage是否 Draw Call 过高是否存在 Overdraw过度绘制Memory纹理、网格、音频是否加载过多是否有内存泄漏对象池中的对象是否真的被回收了检查是否使用了对象池以及Instantiate和Destroy的调用频率。打包失败错误信息通常很明确。常见原因有脚本编译错误、缺少 KEYSTORE、API 级别不兼容、插件冲突。查看 Console 窗口中的完整错误日志逐条解决。尝试创建一个全新的空场景打包以确定问题是出在项目设置还是某个特定场景或资源上。这个项目从零到一的过程本质上是在练习如何将游戏设计文档转化为稳定、可维护、性能合格的代码。我建议你不要追求一次性写完所有功能而是采用迭代开发先做出一个最简可玩版本一个塔、一种敌人、一关、一次抽卡确保核心循环跑通。然后再逐步添加更多塔的类型、敌人种类、关卡、抽卡动画和特效。每完成一个迭代都在真机上测试一下手感和性能这样能及早发现问题避免在错误的方向上走得太远。