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是游戏大脑,它至少需要管理:
- 游戏当前状态(GameState):
Menu,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 Action<GameState> OnGameStateChanged; public event Action<int> 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 ObjectPool<T> where T : MonoBehaviour { private Queue<T> _pool = new Queue<T>(); 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.GetComponent<Enemy>(); 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 List<CardItem> 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.GetComponent<BuildPoint>(); 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 Settings:
- Company Name和Product Name:设置你的应用名称。
- Default Icon和Splash Image:设置应用图标和启动图。
- Other Settings:
- Package Name:遵循反向域名规则(如
com.YourCompany.YourGame)。 - Minimum API Level:根据你的目标用户群设置(如 Android 8.0 / API Level 26)。
- Target API Level:通常选择最新的稳定版。
- Scripting Backend:对于新项目,建议使用IL2CPP,它性能更好,且是发布到商店的必需项。开发阶段可以用 Mono 快速迭代。
- Target Architectures:勾选ARMv7和ARM64以覆盖绝大多数设备。
- Package Name:遵循反向域名规则(如
- 构建: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:纹理、网格、音频是否加载过多?是否有内存泄漏(对象池中的对象是否真的被回收了)?
- CPU Usage:哪个函数的
- 检查是否使用了对象池,以及
Instantiate和Destroy的调用频率。
- 打开 Development Build,用 Profiler 连接手机。重点看:
打包失败:
- 错误信息通常很明确。常见原因有:脚本编译错误、缺少 KEYSTORE、API 级别不兼容、插件冲突。
- 查看 Console 窗口中的完整错误日志,逐条解决。
- 尝试创建一个全新的空场景打包,以确定问题是出在项目设置还是某个特定场景或资源上。
这个项目从零到一的过程,本质上是在练习如何将游戏设计文档转化为稳定、可维护、性能合格的代码。我建议你不要追求一次性写完所有功能,而是采用迭代开发:先做出一个最简可玩版本(一个塔、一种敌人、一关、一次抽卡),确保核心循环跑通。然后再逐步添加更多塔的类型、敌人种类、关卡、抽卡动画和特效。每完成一个迭代,都在真机上测试一下手感和性能,这样能及早发现问题,避免在错误的方向上走得太远。