如果你正在学习 Unity 3D 游戏开发,想做一个能上线的移动端游戏,但卡在了“如何把零散功能整合成一个完整项目”这一步,那么这篇文章就是为你准备的。
很多教程会教你如何实现一个“防御塔”或一个“抽卡界面”,但当你试图将它们组合起来,加上敌人、关卡、经济系统时,会发现各种脚本冲突、数据不互通、UI 错乱。最终,你的项目文件夹里堆满了无法协同工作的“功能孤岛”。本文要解决的,正是这个从“功能模块”到“完整游戏”的鸿沟。
我们将以“防御塔+抽卡”为核心玩法,从零开始,构建一个结构清晰、可扩展、适合移动端的完整游戏项目。这不是一个简单的功能演示,而是一个工程化的实战指南。你会学到如何设计游戏管理器(GameManager)来统筹全局,如何用 ScriptableObject 优雅地管理塔和卡牌数据,如何构建一个健壮的事件系统来解耦各个模块,以及如何为移动端优化 UI 和性能。
读完本文,你将获得一个可以直接作为模板的完整项目结构,并掌握将任何创意系统化实现为可玩游戏的能力。
1. 核心玩法拆解:为什么是“塔防”加“抽卡”?
在开始写代码之前,我们必须明确游戏的核心循环。本项目的核心是“塔防”与“抽卡”的结合:
- 塔防部分:玩家在路径旁放置防御塔,自动攻击沿固定路线行进的敌人,阻止其到达终点。
- 抽卡部分:玩家通过游戏内货币(如金币)进行抽卡,随机获得不同品质和能力的防御塔卡牌,用于在战场上部署。
这种结合带来了独特的策略深度和成长感。抽卡引入了随机性和资源管理——你无法在开局就拥有最强塔阵,需要根据抽到的卡牌来调整布防策略。同时,它也天然适合移动端的碎片化体验和商业化设计(如内购抽卡)。
从技术实现角度看,这要求我们设计两套既独立又关联的系统:
- 卡牌收集与随机系统:负责卡牌数据的存储、卡池的构建、抽卡逻辑和结果展示。
- 塔防战斗系统:负责战场网格、塔的放置、升级、攻击逻辑,以及敌人的生成与移动。
而连接这两者的,就是游戏经济系统(金币)和全局状态管理。理解这个架构,是避免代码混乱的第一步。
2. 项目架构设计与核心概念
一个混乱的项目往往始于混乱的架构。我们采用分层和模块化的思想来设计本项目。
2.1 核心模块划分
- 数据层 (Data)
ScriptableObject资产:用于定义塔(TowerData)、敌人(EnemyData)、卡牌(CardData)的静态属性(如攻击力、血量、成本、图标)。这是游戏内容的“配置表”。GameConfig:存储游戏全局参数,如初始金币、每波敌人数量、抽卡成本等。
- 管理层 (Manager)
GameManager:单例模式,游戏总指挥。负责游戏状态(开始、进行中、结束)、回合控制、金币管理、分数计算。UIManager:管理所有UI面板的打开、关闭和更新。PoolManager:对象池管理器,高效管理频繁创建销毁的物体,如子弹、敌人、特效。
- 逻辑层 (Logic)
Tower/Enemy:战斗实体的核心逻辑脚本,挂在Prefab上。Card/Deck:卡牌和卡组的逻辑,处理抽卡、洗牌、手牌管理。GridSystem:战场网格系统,处理塔的放置位置判定。
- 表现层 (View)
- 各种UI面板(
MainUI,CardDrawUI,GameOverUI)。 - 塔和敌人的模型、动画、粒子特效。
- 各种UI面板(
2.2 关键通信机制:事件系统 (Event System)
为了避免GameManager、UIManager、Tower、Card之间产生复杂的直接引用和耦合,我们引入一个基于 C# 事件的简易事件系统。例如:
- 当塔消灭一个敌人时,它不直接调用
GameManager.AddGold(10),而是发布一个事件:OnEnemyKilled。 GameManager订阅了这个事件,在收到通知后执行加金币的逻辑。UIManager也订阅了同一事件,用于更新UI上的金币显示。
这样做的好处是,Tower完全不知道谁会对敌人死亡感兴趣,系统扩展性极强。我们创建一个EventManager静态类或使用Action/UnityEvent来实现。
3. 环境准备与项目设置
- Unity 版本:推荐使用Unity 2022.3 LTS或更新版本。LTS(长期支持版)稳定性最佳。通过 Unity Hub 安装。
- 项目模板:创建项目时,选择3D (Built-in Render Pipeline)。对于移动端项目,Built-in RP 在绝大多数情况下已足够,且兼容性最好。暂不需要涉及 URP/HDRP。
- 初始场景设置:
- 创建以下基本文件夹结构:
Assets/ ├── Scripts/ │ ├── Data │ ├── Managers │ ├── Systems (Grid, Card, Tower, Enemy) │ └── UI ├── Prefabs/ ├── Scenes/ ├── ScriptableObjects/ ├── Sprites/ (或 Textures/) └── Materials/ - 在场景中创建一个空的
GameObject,命名为_Managers,用于挂载各种管理器脚本。 - 设置适合移动设备的屏幕分辨率(如 9:16 竖屏或 16:9 横屏),在
Game视图中选择相应的预设。
- 创建以下基本文件夹结构:
4. 构建数据基石:使用 ScriptableObject
这是避免“硬编码”,实现内容可配置化的关键一步。
4.1 创建塔数据资产
首先,创建塔的数据类。
// 文件路径:Assets/Scripts/Data/TowerData.cs using UnityEngine; // 创建可序列化的数据类 [System.Serializable] public class TowerStats { public float damage; public float attackRange; public float attackSpeed; // 攻击间隔,秒 public int cost; } // 创建 ScriptableObject [CreateAssetMenu(fileName = "NewTowerData", menuName = "Game Data/Tower Data")] public class TowerData : ScriptableObject { public string towerName; public string description; public GameObject towerPrefab; // 对应的塔Prefab public Sprite icon; // 用于UI显示 public TowerStats baseStats; public TowerStats[] upgradeStats; // 数组长度代表可升级次数,每个元素是该级别的数据 }在 Unity Editor 中,右键Assets/ScriptableObjects/文件夹,选择Create -> Game Data -> Tower Data,即可创建一个具体的塔数据资产(如ArrowTowerData.asset),并在 Inspector 中可视化地填写所有属性。
4.2 创建卡牌数据资产
卡牌数据关联塔数据。
// 文件路径:Assets/Scripts/Data/CardData.cs using UnityEngine; public enum CardRarity { Common, Rare, Epic, Legendary } [CreateAssetMenu(fileName = "NewCardData", menuName = "Game Data/Card Data")] public class CardData : ScriptableObject { public string cardName; public string description; public CardRarity rarity; public Sprite cardImage; public TowerData towerData; // 这张卡牌对应哪种塔 public int manaCost; // 部署所需费用(可能不同于塔的建造费用) }用同样的方法创建多个CardData资产,构成你的卡池。
5. 实现核心系统:从卡牌到战场
5.1 卡牌与抽卡系统
我们需要一个Deck类来管理卡组和抽卡逻辑。
// 文件路径:Assets/Scripts/Systems/Card/Deck.cs using System.Collections.Generic; using UnityEngine; public class Deck : MonoBehaviour { public List<CardData> allCardsInPool; // 所有可能的卡牌(卡池) private List<CardData> currentDeck = new List<CardData>(); private List<CardData> hand = new List<CardData>(); public int handSize = 5; void Start() { InitializeDeck(); DrawInitialHand(); } // 初始化卡组,例如从allCardsInPool中复制一定数量构成初始卡组 void InitializeDeck() { currentDeck.Clear(); // 示例:简单地将卡池所有卡加入卡组(实际可能根据规则筛选) currentDeck.AddRange(allCardsInPool); ShuffleDeck(); } // 洗牌 void ShuffleDeck() { for (int i = 0; i < currentDeck.Count; i++) { CardData temp = currentDeck[i]; int randomIndex = Random.Range(i, currentDeck.Count); currentDeck[i] = currentDeck[randomIndex]; currentDeck[randomIndex] = temp; } } // 抽一张卡 public CardData DrawCard() { if (currentDeck.Count == 0) { Debug.LogWarning("卡组已空!"); // 可以在这里实现重新洗入手牌弃牌堆等规则 return null; } CardData drawnCard = currentDeck[0]; currentDeck.RemoveAt(0); hand.Add(drawnCard); // 通知UI更新手牌显示 EventManager.OnHandUpdated?.Invoke(hand); return drawnCard; } // 抽初始手牌 void DrawInitialHand() { for (int i = 0; i < handSize; i++) { DrawCard(); } } // 使用手牌中的一张卡(部署塔) public bool UseCardFromHand(int handIndex, Vector3 spawnPosition) { if (handIndex < 0 || handIndex >= hand.Count) return false; CardData cardToUse = hand[handIndex]; // 检查金币是否足够(通过GameManager) if (GameManager.Instance.SpendGold(cardToUse.manaCost)) { // 实例化塔 GameObject towerObj = Instantiate(cardToUse.towerData.towerPrefab, spawnPosition, Quaternion.identity); Tower tower = towerObj.GetComponent<Tower>(); tower.Initialize(cardToUse.towerData); // 从手牌中移除该卡 hand.RemoveAt(handIndex); EventManager.OnHandUpdated?.Invoke(hand); return true; } else { Debug.Log("金币不足!"); return false; } } }5.2 塔防战斗系统
塔的逻辑需要处理搜索敌人、攻击和升级。
// 文件路径:Assets/Scripts/Systems/Tower/Tower.cs using System.Collections; using UnityEngine; public class Tower : MonoBehaviour { public TowerData data; private int currentLevel = 0; private float attackTimer = 0f; private Transform currentTarget; public void Initialize(TowerData towerData) { data = towerData; // 应用初始等级(0级)的属性 ApplyStats(data.baseStats); } void ApplyStats(TowerStats stats) { // 这里可以将stats应用到具体的攻击组件等,示例中仅作演示 // 例如:GetComponent<AttackComponent>().SetStats(stats); } void Update() { if (currentTarget == null) { FindTarget(); } else { // 检查目标是否在攻击范围内或已死亡 if (Vector3.Distance(transform.position, currentTarget.position) > data.baseStats.attackRange || !currentTarget.gameObject.activeInHierarchy) { currentTarget = null; return; } // 攻击冷却 attackTimer -= Time.deltaTime; if (attackTimer <= 0) { Attack(); attackTimer = data.baseStats.attackSpeed; } } } void FindTarget() { // 简单实现:找到场景中所有敌人,选择距离最近的一个 GameObject[] enemies = GameObject.FindGameObjectsWithTag("Enemy"); Transform closest = null; float closestDistance = Mathf.Infinity; foreach (GameObject enemy in enemies) { float dist = Vector3.Distance(transform.position, enemy.transform.position); if (dist < data.baseStats.attackRange && dist < closestDistance) { closestDistance = dist; closest = enemy.transform; } } currentTarget = closest; } void Attack() { if (currentTarget != null) { // 创建子弹或直接造成伤害 Debug.Log($"{data.towerName} 攻击了 {currentTarget.name},造成 {data.baseStats.damage} 点伤害。"); // 假设敌人有Enemy脚本和TakeDamage方法 Enemy enemy = currentTarget.GetComponent<Enemy>(); if (enemy != null) { enemy.TakeDamage(data.baseStats.damage); } } } public void Upgrade() { if (currentLevel < data.upgradeStats.Length) { currentLevel++; ApplyStats(data.upgradeStats[currentLevel - 1]); // 应用升级后属性 Debug.Log($"{data.towerName} 升级到 {currentLevel} 级!"); } else { Debug.Log("已达最大等级!"); } } }6. 游戏管理器与事件系统
GameManager作为中枢,协调所有系统。
// 文件路径:Assets/Scripts/Managers/GameManager.cs using System; using UnityEngine; public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } public int CurrentGold { get; private set; } = 100; // 初始金币 public int PlayerHealth { get; private set; } = 10; public bool IsGameOver { get; private set; } = false; public event Action<int> OnGoldChanged; public event Action<int> OnHealthChanged; public event Action<bool> OnGameOver; // true=胜利,false=失败 void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } void Start() { // 初始化其他系统... UIManager.Instance.UpdateGoldUI(CurrentGold); UIManager.Instance.UpdateHealthUI(PlayerHealth); } public bool SpendGold(int amount) { if (CurrentGold >= amount) { CurrentGold -= amount; OnGoldChanged?.Invoke(CurrentGold); return true; } return false; } public void AddGold(int amount) { CurrentGold += amount; OnGoldChanged?.Invoke(CurrentGold); } public void TakeDamage(int damage) { PlayerHealth -= damage; OnHealthChanged?.Invoke(PlayerHealth); if (PlayerHealth <= 0) { GameOver(false); } } void GameOver(bool isWin) { IsGameOver = true; OnGameOver?.Invoke(isWin); Time.timeScale = 0; // 暂停游戏 UIManager.Instance.ShowGameOverUI(isWin); } }一个简易的EventManager可以这样实现:
// 文件路径:Assets/Scripts/Managers/EventManager.cs using System; public static class EventManager { // 金币变化事件 public static event Action<int> OnGoldChanged; public static void GoldChanged(int newAmount) => OnGoldChanged?.Invoke(newAmount); // 手牌更新事件 public static event Action<System.Collections.Generic.List<CardData>> OnHandUpdated; public static void HandUpdated(System.Collections.Generic.List<CardData> hand) => OnHandUpdated?.Invoke(hand); // 敌人死亡事件(参数可传递敌人类型、奖励金币等) public static event Action<Enemy> OnEnemyKilled; public static void EnemyKilled(Enemy enemy) => OnEnemyKilled?.Invoke(enemy); }在Enemy脚本的死亡方法中调用EventManager.EnemyKilled(this);,在GameManager中订阅这个事件来增加金币。
7. 移动端适配与UI搭建
移动端UI需要注重触控体验。
- Canvas 设置:使用
Screen Space - Overlay模式,UI Scale Mode设置为Scale With Screen Size,参考分辨率设为 1080x1920(竖屏)或 1920x1080(横屏)。 - 手牌区域:在屏幕底部创建一个水平布局组(
Horizontal Layout Group),用于动态显示手牌。每张卡牌是一个Button,背景是卡牌图像,点击后进入“待放置”状态,再点击战场网格放置。 - 抽卡按钮:一个明显的按钮,点击后调用
Deck.DrawCard()并消耗金币。 - 信息显示:在屏幕顶部或角落,用
Text组件显示当前金币、生命值、波次等信息。通过订阅GameManager的事件来更新。 - 触控反馈:为所有按钮添加简单的缩放或颜色变化动画,提升操作感。
// 文件路径:Assets/Scripts/UI/CardUI.cs using UnityEngine; using UnityEngine.UI; using System.Collections.Generic; public class CardUI : MonoBehaviour { public GameObject cardPrefab; // 卡牌UI预制体 public Transform handContainer; // 手牌布局的父物体 private List<GameObject> cardInstances = new List<GameObject>(); void OnEnable() { EventManager.OnHandUpdated += UpdateHandUI; } void OnDisable() { EventManager.OnHandUpdated -= UpdateHandUI; } void UpdateHandUI(List<CardData> hand) { // 清除旧卡牌 foreach (var cardUI in cardInstances) { Destroy(cardUI); } cardInstances.Clear(); // 生成新卡牌UI for (int i = 0; i < hand.Count; i++) { GameObject cardObj = Instantiate(cardPrefab, handContainer); CardInstanceUI instanceUI = cardObj.GetComponent<CardInstanceUI>(); instanceUI.Setup(hand[i], i); // 传入卡牌数据和在手牌中的索引 cardInstances.Add(cardObj); } } }8. 完整流程串联与测试
现在,让我们把各个模块串联起来,形成一个可玩的游戏循环:
- 场景启动:
GameManager初始化,设置初始金币和生命值。Deck初始化并抽取初始手牌,UI 更新。 - 游戏进行:
- 玩家点击手牌中的卡牌,进入“选塔放置”模式(UI高亮,可能显示一个塔的幽灵轮廓)。
- 玩家点击战场上的有效网格位置,
GridSystem检查位置是否可用。如果可用且金币足够,调用Deck.UseCardFromHand()放置塔,消耗金币。 - 敌人波次开始生成,沿着路径移动。塔自动搜索并攻击范围内的敌人。
- 敌人被击败时,触发
EventManager.EnemyKilled。GameManager订阅此事件,为玩家增加金币,并更新UI。
- 资源循环:玩家用金币抽卡,获得新塔牌,继续部署,形成“战斗获金 -> 抽卡 -> 部署强化防御”的正向循环。
- 胜利/失败条件:所有敌人都被消灭且玩家基地生命值大于0则胜利;玩家基地生命值降至0则失败。触发
GameManager.GameOver()。
测试你的游戏:
- 放置不同类型的塔,观察其攻击范围和攻击动作。
- 让敌人走到终点,检查玩家生命值是否正确减少。
- 击杀敌人,检查金币是否增加。
- 进行抽卡,检查手牌UI是否更新,金币是否扣除。
- 尝试在无效位置(如路径上)放置塔,确保被拒绝。
9. 常见问题与性能优化
9.1 常见问题排查
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 塔不攻击敌人 | 1. 敌人没有Enemy标签。2. FindTarget逻辑错误或范围太小。3. 攻击冷却 ( attackTimer) 逻辑有误。 | 1. 检查敌人 GameObject 的 Tag。 2. 在 Scene 视图中用 Gizmos绘制攻击范围。3. 在 Attack()方法内加Debug.Log打印。 | 1. 为敌人设置正确的 Tag。 2. 调试 FindTarget方法,确保能正确找到目标。3. 检查 attackSpeed和attackTimer的更新逻辑。 |
| 抽卡后UI不更新 | 1.EventManager.OnHandUpdated事件未正确触发或订阅。2. UI 脚本未挂载或未启用。 3. cardPrefab或handContainer未在 Inspector 中赋值。 | 1. 在DrawCard和UpdateHandUI方法开头加Debug.Log。2. 检查 CardUI游戏对象是否激活。 | 1. 确认事件发布和订阅的时机。 2. 在 Inspector 中正确关联 UI 组件的引用。 |
| 游戏卡顿,随着敌人/子弹增多变慢 | 1. 频繁实例化/销毁对象(如子弹)。 2. FindGameObjectsWithTag或GetComponent在 Update 中频繁调用。3. 没有使用对象池。 | 使用 Unity Profiler 窗口分析性能瓶颈。 | 1. 为子弹、敌人等创建对象池 (PoolManager)。2. 将 Find等耗时操作缓存结果,或改用触发式(如进入范围)而非每帧检测。 |
| 移动端点击无反应 | 1. UI 按钮的 Raycast Target 未开启。 2. 有其他全屏UI遮挡。 3. 事件系统 ( EventSystem) 缺失。 | 1. 检查按钮组件的设置。 2. 检查 Canvas 的渲染顺序。 | 1. 确保按钮的Raycast Target勾选。2. 场景中确保有一个 EventSystemGameObject(通常创建 Canvas 时会自动生成)。 |
9.2 移动端性能优化建议
- 对象池:务必为子弹、敌人、特效实现对象池。这是移动端性能提升最有效的手段之一。
- Draw Call 合并:对大量相同的塔或敌人,使用相同的材质和纹理,以便 Unity 进行动态批处理。
- LOD (Level of Detail):对于复杂的塔或敌人模型,可以制作不同精度的模型,根据距离切换。
- 避免每帧
Find和GetComponent:在Start或Awake中缓存引用。 - 纹理压缩:将所有图片纹理的压缩格式设置为移动端适用的格式(如 ASTC)。
- 减少物理计算:如果不需要复杂的物理交互,避免使用 Rigidbody。本项目的子弹追踪可以使用简单的
Vector3.MoveTowards。
10. 项目扩展与后续方向
至此,一个具备核心玩法的框架已经完成。你可以在此基础上进行深度扩展:
- 丰富游戏内容:
- 更多塔类型:溅射塔、减速塔、召唤塔等。在
TowerData中添加枚举类型,在Tower脚本中根据类型执行不同的攻击逻辑。 - 更多敌人类型:飞行敌人、高血量敌人、快速敌人。为
EnemyData添加移动类型、抗性等属性。 - 技能系统:为玩家添加全局技能,如冰冻全场、金币爆发等。
- 更多塔类型:溅射塔、减速塔、召唤塔等。在
- 深化策略系统:
- 卡组构建:允许玩家在局外编辑携带哪些卡牌进入战斗。
- 塔升级分支:提供不同的升级路线选择。
- 地形影响:某些网格提供攻击力或攻速加成。
- 完善商业化与留存设计:
- 每日任务/成就系统。
- 塔皮肤/卡牌皮肤。
- 观看广告获得额外抽卡机会(需接入相应 SDK)。
- 加入更多视觉和听觉反馈:
- 为抽卡设计华丽的动画。
- 为塔的攻击和敌人的死亡添加屏幕震动、音效和粒子特效。
- 添加背景音乐和环境音效。
这个项目为你提供了一个坚实且结构清晰的起点。记住,在游戏开发中,可运行的最小化产品 (MVP)远比一个充满幻想但无法运行的半成品有价值。先让核心循环跑起来,再逐步添加功能,每次迭代都进行测试,这是通往成功上线的唯一可靠路径。