三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity塔防游戏开发:从零构建数据驱动与模块化框架

Unity塔防游戏开发:从零构建数据驱动与模块化框架

1. 项目概述与核心价值

如果你一直想用Unity做点东西,但又觉得那些复杂的3A大作或者联网游戏门槛太高,那塔防游戏绝对是你上手Unity 3D游戏开发的最佳“敲门砖”。我自己带过不少新人项目,发现塔防这个品类,它麻雀虽小,五脏俱全。从最基础的场景搭建、UI交互,到核心的敌人AI寻路、炮塔攻击逻辑、游戏经济系统,再到进阶的资源管理、数据配置,它几乎涵盖了游戏开发中80%的通用模块。更重要的是,它的逻辑相对独立,你可以一个模块一个模块地啃下来,成就感来得特别快。今天,我就带你用五个核心步骤,从零开始,搭建一个结构清晰、可扩展性强的专业塔防游戏框架。我们不止是“做出来”,更要理解每一步“为什么这么做”,以及如何避免那些新手常踩的坑。无论你是刚学完C#语法想找项目练手,还是有一定基础想系统梳理游戏架构,这篇内容都能给你一套可以直接“抄作业”的完整方案。

2. 核心系统设计与架构思路

在动手写第一行代码之前,花点时间想清楚整体架构,能让你在后续开发中省下大量调试和重构的时间。塔防游戏虽然玩法固定,但代码组织方式千差万别。一个糟糕的架构会让添加一个新炮塔类型变得异常痛苦。

2.1 为什么选择“数据驱动”与“模块化”设计

很多新手教程会教你把所有逻辑——比如敌人属性、炮塔伤害、关卡波次——直接硬编码在脚本里。这在小原型阶段很快,但一旦你想调整数值、添加新内容,就得在代码里到处翻找,极易出错。我们这次采用更专业的“数据驱动”思路。

数据驱动,简单说就是把游戏里会频繁变动的部分(数值、配置)从代码里剥离出来,用外部文件(如ScriptableObject、JSON、Excel)来管理。比如,一个敌人的生命值、移动速度、金币奖励,我们不应该写在Enemy.cspublic float health = 100;里,而是应该创建一个EnemyData的ScriptableObject资源,在里面定义这些属性。这样做的好处是,策划或你自己调整平衡性时,不需要重新编译代码,直接在Unity编辑器里修改资源文件即可,甚至可以让非程序员通过配置表来参与内容创作。

模块化设计则是将游戏功能分解为高内聚、低耦合的独立模块。我们的核心模块可以规划为:

  • 游戏管理模块 (GameManager):总管游戏状态(开始、进行、暂停、结束)、关卡流程、分数金币。
  • 敌人系统模块 (Enemy System):负责敌人的生成、移动、生命管理、死亡处理。核心是寻路AI。
  • 炮塔系统模块 (Tower System):这是塔防的灵魂,负责炮塔的建造、升级、攻击逻辑(搜索目标、攻击冷却、发射子弹)。
  • 路径系统模块 (Path System):定义敌人行走的路线,这是塔防游戏的“地图”。
  • UI管理模块 (UIManager):处理所有界面交互,如炮塔商店、升级面板、游戏状态显示。
  • 数据配置模块 (Data Config):使用ScriptableObject管理所有静态数据。

各个模块之间通过定义清晰的接口(如事件、委托、管理器单例)进行通信,而不是直接互相引用。比如,炮塔击中敌人时,不是直接调用enemy.TakeDamage(),而是可能触发一个OnEnemyHit事件,由敌人系统模块来监听并处理伤害。这大大提升了代码的可维护性和可测试性。

2.2 MVC框架在塔防游戏中的轻量级应用

在一些复杂的商业教程(如搜索资料中提到的弦小风课程)里,可能会引入完整的MVC(Model-View-Controller)框架。对于初学者,完全照搬可能有点重。我们可以借鉴其思想,做一个简化版:

  • Model (数据模型):就是我们的EnemyDataTowerData这些ScriptableObject。它们只负责存储数据,不包含任何逻辑。
  • View (视图):Unity场景中的GameObject,如炮塔模型、敌人模型、UI按钮。它们负责显示,以及与玩家的直接交互(如点击)。
  • Controller (控制器):我们的各种Manager脚本和逻辑脚本(如TowerControllerEnemyController)。它们从Model获取数据,控制View的表现,并处理核心游戏逻辑。

这样做,当你想把炮塔的图标从方形换成圆形,只需要修改View(预制体);想调整攻击力,只需修改Model(ScriptableObject);想改变攻击算法,则修改Controller。三者互不干扰。

实操心得:在项目初期,不要过度设计。先确保核心循环(敌人生成-移动-炮塔攻击)能跑通。但至少要把数据和逻辑分开这个习惯养好。我见过太多项目因为初期图省事,后期改一个数值要全局搜索,苦不堪言。

3. 五大核心步骤实现详解

接下来,我们进入实战环节。我将把这五个步骤拆解得非常细,确保你每一步都知道在做什么,以及可能遇到的问题。

3.1 第一步:搭建场景与定义敌人路径

这一步是给游戏打造一个舞台。很多新手会忽略路径设计的灵活性,导致后期想做个岔路或者环形路径时无从下手。

1. 场景基础搭建:

  • 创建一个新3D项目。
  • 导入一些基础资源(可以使用Unity自带的原型资产,或从Asset Store找免费的Low Poly风格资源)。
  • 创建一个平面(Plane)作为地面,并赋予它一个草地纹理。
  • 使用立方体(Cube)或地形工具,简单搭建一些不可建造的障碍物区域,增加策略性。

2. 实现灵活的路径系统:

  • 在场景中空创建一个GameObject,命名为Path
  • Path下创建一系列的空子物体(GameObject),命名为Waypoint_01Waypoint_02... 这些就是路径点。
  • 将这些路径点按照你期望的敌人行进路线,在场景中摆好位置。
  • 编写一个PathManager脚本,挂载在Path物体上。它的核心工作是提供一个公开的方法,让敌人能按顺序获取这些路径点的位置。
// PathManager.cs using System.Collections.Generic; using UnityEngine; public class PathManager : MonoBehaviour { // 在Inspector中按顺序拖入路径点 public List<Transform> waypoints = new List<Transform>(); // 单例模式,方便其他脚本访问 public static PathManager Instance { get; private set; } private void Awake() { if (Instance != null && Instance != this) { Destroy(this.gameObject); } else { Instance = this; } // 也可以在这里自动获取子物体作为路径点,但手动拖拽更可控 // if (waypoints.Count == 0) { // foreach (Transform child in transform) { // waypoints.Add(child); // } // } } // 提供给敌人调用的方法,获取第index个点的位置 public Vector3 GetWaypointPosition(int index) { if (index >= 0 && index < waypoints.Count) { return waypoints[index].position; } // 如果索引超出范围,返回最后一个点(终点) return waypoints[waypoints.Count - 1].position; } public int GetPathLength() { return waypoints.Count; } }

3. 可视化路径(可选但推荐):为了方便在编辑器中直观地看到和编辑路径,我们可以添加一个简单的编辑器脚本,在Scene视图中绘制连线。

// PathEditor.cs (放在Editor文件夹下) using UnityEditor; using UnityEngine; [CustomEditor(typeof(PathManager))] public class PathEditor : Editor { private void OnSceneGUI() { PathManager path = target as PathManager; if (path.waypoints == null || path.waypoints.Count < 2) return; Handles.color = Color.red; for (int i = 0; i < path.waypoints.Count - 1; i++) { if (path.waypoints[i] != null && path.waypoints[i + 1] != null) { Handles.DrawLine(path.waypoints[i].position, path.waypoints[i + 1].position); // 在路径点位置画一个球 Handles.SphereHandleCap(0, path.waypoints[i].position, Quaternion.identity, 0.5f, EventType.Repaint); } } // 绘制最后一个点 if (path.waypoints[path.waypoints.Count - 1] != null) { Handles.SphereHandleCap(0, path.waypoints[path.waypoints.Count - 1].position, Quaternion.identity, 0.5f, EventType.Repaint); } } }

注意事项:路径点的Y轴坐标要统一,最好和敌人模型的脚底高度一致,否则敌人移动时会上下漂浮。通常我会把路径点放在一个稍高于地面的位置。

3.2 第二步:创建数据驱动的敌人与炮塔配置

这是实现“数据驱动”的关键一步。我们将大量使用ScriptableObject。

1. 创建敌人数据资产 (EnemyData):

  • 在Project窗口右键 -> Create -> C# Script, 命名为EnemyData
  • 修改脚本,让它继承自ScriptableObject
// EnemyData.cs using UnityEngine; [CreateAssetMenu(fileName = "New Enemy Data", menuName = "Tower Defense/Enemy Data")] public class EnemyData : ScriptableObject { public string enemyName; public GameObject prefab; // 敌人预制体 public float maxHealth; public float moveSpeed; public int goldReward; // 击败后奖励的金币 public int damageToPlayerBase; // 到达终点后对玩家基地造成的伤害 // 可以扩展:护甲类型、抗性、死亡特效、音效等 }
  • 在Project窗口右键 -> Create -> Tower Defense -> Enemy Data, 创建一个新的数据文件,比如Enemy_Orc。然后在Inspector面板中填写各项数值。

2. 创建炮塔数据资产 (TowerData):

  • 同理,创建TowerData脚本。
// TowerData.cs using UnityEngine; [CreateAssetMenu(fileName = "New Tower Data", menuName = "Tower Defense/Tower Data")] public class TowerData : ScriptableObject { public string towerName; public GameObject prefab; // 炮塔预制体 public int buildCost; public int upgradeCost; // 升级到下一级的费用 public TowerData nextLevelData; // 指向下一级的数据,实现链式升级 // 攻击属性 public float attackRange; public float attackRate; // 每秒攻击次数 public int attackDamage; public GameObject projectilePrefab; // 子弹预制体(对于投射型炮塔) public float projectileSpeed; // 可以扩展:攻击类型(单体/溅射)、减速效果、特殊效果等 }
  • 创建对应的数据文件,如Tower_Arrow_Lv1Tower_Arrow_Lv2, 并将Tower_Arrow_Lv1nextLevelData字段拖入Tower_Arrow_Lv2

3. 创建关卡波次配置 (WaveData):

  • 一个关卡由多波敌人组成,每波敌人又有不同的类型、数量和生成间隔。
// WaveData.cs using System.Collections.Generic; using UnityEngine; [CreateAssetMenu(fileName = "New Wave Data", menuName = "Tower Defense/Wave Data")] public class WaveData : ScriptableObject { [System.Serializable] public class Wave { public EnemyData enemyType; public int count; public float spawnInterval; // 同一波内,每个敌人生成的间隔 public float delayBeforeNextWave; // 这波结束后,下一波开始前的等待时间 } public List<Wave> waves = new List<Wave>(); }

实操心得:ScriptableObject在编辑器里修改后是实时生效的,但如果你在运行时(Play Mode)修改了数值,停止运行后会恢复。这不是Bug,而是为了保护你的原始数据。如果你需要动态调整并保存,需要考虑结合PlayerPrefs或JSON序列化到磁盘。

3.3 第三步:实现敌人AI与移动逻辑

敌人是塔防游戏的“活”元素,它的智能程度直接影响游戏体验。

1. 敌人控制器 (EnemyController):

  • 创建一个敌人预制体,包含模型、碰撞体(用于被子弹击中)和EnemyController脚本。
// EnemyController.cs using UnityEngine; using UnityEngine.Events; public class EnemyController : MonoBehaviour { public EnemyData data; private float currentHealth; private int currentWaypointIndex = 0; private bool isDead = false; // 事件:用于通知其他系统敌人死亡或到达终点 public UnityEvent<EnemyController> OnDeath; public UnityEvent<EnemyController> OnReachedEnd; private void Start() { if (data == null) { Debug.LogError("EnemyData not assigned!"); return; } currentHealth = data.maxHealth; // 开始向第一个路径点移动 MoveToNextWaypoint(); } private void Update() { if (isDead || PathManager.Instance == null) return; // 获取当前目标点位置 Vector3 targetPos = PathManager.Instance.GetWaypointPosition(currentWaypointIndex); // 计算移动方向 Vector3 moveDirection = (targetPos - transform.position).normalized; // 移动 transform.position += moveDirection * data.moveSpeed * Time.deltaTime; // 让敌人面朝移动方向(可选,看起来更自然) if (moveDirection != Vector3.zero) { transform.rotation = Quaternion.LookRotation(moveDirection); } // 检查是否到达当前路径点 if (Vector3.Distance(transform.position, targetPos) < 0.1f) { currentWaypointIndex++; // 检查是否到达终点 if (currentWaypointIndex >= PathManager.Instance.GetPathLength()) { ReachedEnd(); return; } } } private void MoveToNextWaypoint() { // 逻辑主要在Update中处理,这里可以初始化或处理特殊情况 } public void TakeDamage(int damage) { if (isDead) return; currentHealth -= damage; // 可以在这里触发受伤特效或音效 if (currentHealth <= 0) { Die(); } } private void Die() { isDead = true; // 触发死亡事件,通知GameManager奖励金币等 OnDeath?.Invoke(this); // 播放死亡动画(如果有) // Destroy(gameObject, 1f); // 延迟销毁以播放动画 Destroy(gameObject); // 简单起见直接销毁 } private void ReachedEnd() { isDead = true; // 防止重复触发 // 触发到达终点事件,通知GameManager扣除玩家生命 OnReachedEnd?.Invoke(this); Destroy(gameObject); } }

2. 敌人生成器 (EnemySpawner):

  • 创建一个空物体EnemySpawner,挂载生成脚本。它负责根据WaveData,在指定位置(通常是路径起点)按节奏生成敌人。
// EnemySpawner.cs using System.Collections; using System.Collections.Generic; using UnityEngine; public class EnemySpawner : MonoBehaviour { public WaveData waveData; private int currentWaveIndex = 0; private bool isSpawning = false; // 生成点位置 public Transform spawnPoint; void Start() { if (waveData == null || waveData.waves.Count == 0) { Debug.LogWarning("No wave data assigned!"); return; } StartCoroutine(SpawnWaves()); } IEnumerator SpawnWaves() { isSpawning = true; for (int i = 0; i < waveData.waves.Count; i++) { WaveData.Wave currentWave = waveData.waves[i]; Debug.Log($"开始生成第 {i + 1} 波敌人,类型:{currentWave.enemyType.enemyName}, 数量:{currentWave.count}"); for (int j = 0; j < currentWave.count; j++) { SpawnEnemy(currentWave.enemyType); // 等待生成间隔 yield return new WaitForSeconds(currentWave.spawnInterval); } // 等待下一波开始前的延迟(最后一波不需要) if (i < waveData.waves.Count - 1) { yield return new WaitForSeconds(currentWave.delayBeforeNextWave); } } isSpawning = false; Debug.Log("所有波次生成完毕!"); // 可以在这里触发一个事件,通知GameManager进入“清理剩余敌人”或“胜利判断”阶段 } void SpawnEnemy(EnemyData enemyData) { if (enemyData.prefab == null || spawnPoint == null) return; GameObject enemyObj = Instantiate(enemyData.prefab, spawnPoint.position, Quaternion.identity); EnemyController enemy = enemyObj.GetComponent<EnemyController>(); if (enemy != null) { enemy.data = enemyData; // 订阅敌人事件 enemy.OnDeath.AddListener(OnEnemyDeath); enemy.OnReachedEnd.AddListener(OnEnemyReachedEnd); } } void OnEnemyDeath(EnemyController enemy) { // 这里可以通知GameManager增加金币 GameManager.Instance.AddGold(enemy.data.goldReward); // 更新UI等 UIManager.Instance.UpdateGoldDisplay(GameManager.Instance.CurrentGold); } void OnEnemyReachedEnd(EnemyController enemy) { // 这里可以通知GameManager减少玩家生命值 GameManager.Instance.PlayerTakeDamage(enemy.data.damageToPlayerBase); // 更新UI等 UIManager.Instance.UpdateHealthDisplay(GameManager.Instance.CurrentPlayerHealth); } }

注意事项:使用协程IEnumeratoryield return来控制生成节奏是Unity中的标准做法,比在Update里用计时器更清晰。记得在敌人销毁时,要从事件监听列表中移除对应的方法,否则可能导致内存泄漏。在上面的简单示例中,因为敌人对象被销毁了,其上的脚本组件也不复存在,所以关联的委托也会被GC回收。但在更复杂的对象池管理中,需要手动移除。

3.4 第四步:构建模块化与可扩展的炮塔系统

炮塔系统是塔防游戏逻辑最复杂的部分,设计的好坏直接决定了游戏玩法的深度和添加新炮塔的难度。

1. 炮塔基类设计 (TowerBase):创建一个抽象的基类,定义所有炮塔的通用行为和接口。这是实现多态和扩展性的关键。

// TowerBase.cs using System.Collections; using UnityEngine; public abstract class TowerBase : MonoBehaviour { [Header("Tower Data")] public TowerData data; // 当前等级的数据 [Header("Runtime State")] protected Transform currentTarget; protected bool canAttack = true; protected float attackCooldownTimer = 0f; void Update() { if (data == null) return; // 攻击冷却 if (!canAttack) { attackCooldownTimer -= Time.deltaTime; if (attackCooldownTimer <= 0) { canAttack = true; } } // 搜索目标 if (currentTarget == null || !IsTargetInRange(currentTarget)) { FindTarget(); } // 攻击逻辑 if (canAttack && currentTarget != null) { Attack(); StartCooldown(); } } // 搜索目标:这里实现一个简单的最近敌人搜索 protected virtual void FindTarget() { currentTarget = null; float shortestDistance = Mathf.Infinity; // 假设我们有一个管理器可以获取所有存活的敌人 foreach (GameObject enemy in EnemyManager.Instance.GetAllEnemies()) { if (enemy == null) continue; float distanceToEnemy = Vector3.Distance(transform.position, enemy.transform.position); if (distanceToEnemy <= data.attackRange && distanceToEnemy < shortestDistance) { shortestDistance = distanceToEnemy; currentTarget = enemy.transform; } } } protected bool IsTargetInRange(Transform target) { if (target == null) return false; return Vector3.Distance(transform.position, target.position) <= data.attackRange; } // 开始冷却 protected void StartCooldown() { canAttack = false; attackCooldownTimer = 1f / data.attackRate; // 根据攻击频率计算冷却时间 } // 攻击行为:这是一个抽象方法,由具体炮塔子类实现 protected abstract void Attack(); // 升级:更换数据并更新外观(如果有) public virtual bool Upgrade() { if (data.nextLevelData == null) { Debug.Log("已是最高等级!"); return false; } // 检查玩家金币是否足够等逻辑应由GameManager处理,这里假设已经通过 data = data.nextLevelData; // 可以在这里触发升级特效、更新模型等 Debug.Log($"炮塔升级为 {data.towerName}"); return true; } // 出售 public virtual int Sell() { // 通常返还部分建造费用 int sellValue = Mathf.FloorToInt(data.buildCost * 0.7f); Destroy(gameObject); return sellValue; } // 在Scene视图中绘制攻击范围,便于调试和设计 private void OnDrawGizmosSelected() { if (data != null) { Gizmos.color = Color.yellow; Gizmos.DrawWireSphere(transform.position, data.attackRange); } } }

2. 实现具体炮塔类型 (如弓箭塔 ArrowTower):现在,我们可以轻松地创建不同类型的炮塔,只需继承TowerBase并实现具体的Attack方法。

// ArrowTower.cs public class ArrowTower : TowerBase { public Transform firePoint; // 子弹发射点 protected override void Attack() { if (currentTarget == null || data.projectilePrefab == null) return; // 1. 创建子弹 GameObject projectileObj = Instantiate(data.projectilePrefab, firePoint.position, Quaternion.identity); Projectile projectile = projectileObj.GetComponent<Projectile>(); // 2. 初始化子弹,设置目标、伤害、速度等 if (projectile != null) { projectile.Initialize(currentTarget, data.attackDamage, data.projectileSpeed); } // 3. 可以在这里播放攻击音效或动画 // GetComponent<AudioSource>().Play(); } }

3. 实现子弹逻辑 (Projectile):

// Projectile.cs using UnityEngine; public class Projectile : MonoBehaviour { private Transform target; private int damage; private float speed; public void Initialize(Transform targetTransform, int projectileDamage, float projectileSpeed) { target = targetTransform; damage = projectileDamage; speed = projectileSpeed; } void Update() { if (target == null) { Destroy(gameObject); return; } Vector3 dir = (target.position - transform.position).normalized; float distanceThisFrame = speed * Time.deltaTime; // 如果这一帧移动的距离已经超过到目标的距离,则认为命中 if (Vector3.Distance(transform.position, target.position) <= distanceThisFrame) { HitTarget(); return; } // 移动并朝向目标 transform.Translate(dir * distanceThisFrame, Space.World); transform.LookAt(target); } void HitTarget() { // 对敌人造成伤害 EnemyController enemy = target.GetComponent<EnemyController>(); if (enemy != null) { enemy.TakeDamage(damage); } // 命中特效 // Instantiate(hitEffect, transform.position, transform.rotation); Destroy(gameObject); } }

4. 炮塔建造与交互:

  • 在场景中创建一些“炮塔位置”(TowerPosition),通常是带有碰撞体的透明立方体。
  • 玩家点击这些位置时,弹出UI菜单,显示可建造的炮塔列表。
  • 建造逻辑通常在GameManager或一个专门的BuildManager中处理,检查金币,然后在指定位置实例化炮塔预制体,并为其分配对应的TowerData

实操心得:炮塔的搜索算法是性能优化的重点。在FindTarget中遍历所有敌人,在敌人数量多时(如上百个)可能成为性能瓶颈。优化方法包括:使用空间划分(如网格、四叉树)、为每个炮塔维护一个潜在目标列表、或者使用Physics.OverlapSphere并配合图层(Layer)过滤。对于中小型塔防游戏,直接遍历通常可以接受,但要有这个优化意识。

3.5 第五步:集成游戏管理、UI与核心循环

最后一步,我们把所有模块串联起来,形成完整的游戏体验。

1. 游戏管理器 (GameManager):这是一个单例,充当游戏的大脑。

// GameManager.cs using UnityEngine; public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } [Header("Game State")] public GameState currentGameState = GameState.Preparing; // 准备, 战斗中, 暂停, 胜利, 失败 public enum GameState { Preparing, Fighting, Paused, Win, Lose } [Header("Player Resources")] public int startingGold = 100; public int startingHealth = 20; private int currentGold; private int currentHealth; [Header("References")] public UIManager uiManager; void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } InitializeGame(); } void InitializeGame() { currentGold = startingGold; currentHealth = startingHealth; currentGameState = GameState.Preparing; uiManager.UpdateGoldDisplay(currentGold); uiManager.UpdateHealthDisplay(currentHealth); uiManager.UpdateGameStateDisplay(currentGameState); } public void StartWave() { if (currentGameState != GameState.Preparing) return; currentGameState = GameState.Fighting; uiManager.UpdateGameStateDisplay(currentGameState); // 通知EnemySpawner开始生成敌人 FindObjectOfType<EnemySpawner>()?.StartSpawning(); } public void AddGold(int amount) { currentGold += amount; uiManager.UpdateGoldDisplay(currentGold); } public bool SpendGold(int amount) { if (currentGold >= amount) { currentGold -= amount; uiManager.UpdateGoldDisplay(currentGold); return true; } else { // 提示金币不足 Debug.Log("金币不足!"); return false; } } public void PlayerTakeDamage(int damage) { currentHealth -= damage; uiManager.UpdateHealthDisplay(currentHealth); if (currentHealth <= 0) { GameOver(false); // 失败 } } public void CheckWinCondition() { // 在所有波次生成完毕,且场景中所有敌人都被消灭后调用 // 需要EnemySpawner和EnemyManager配合 if (currentGameState == GameState.Fighting && EnemyManager.Instance.GetEnemyCount() == 0) { // 假设有一个方法判断是否所有波次完成 if (FindObjectOfType<EnemySpawner>().IsWaveFinished()) { GameOver(true); // 胜利 } } } void GameOver(bool isWin) { currentGameState = isWin ? GameState.Win : GameState.Lose; uiManager.UpdateGameStateDisplay(currentGameState); // 弹出胜利/失败界面 uiManager.ShowGameOverPanel(isWin); Time.timeScale = 0; // 暂停游戏 } // 属性访问器 public int CurrentGold => currentGold; public int CurrentPlayerHealth => currentHealth; }

2. UI管理器 (UIManager):负责更新所有界面元素,处理按钮点击事件。

// UIManager.cs using UnityEngine; using UnityEngine.UI; public class UIManager : MonoBehaviour { public static UIManager Instance { get; private set; } [Header("UI Elements")] public Text goldText; public Text healthText; public Text waveText; public GameObject buildMenuPanel; public GameObject gameOverPanel; public Text gameOverText; void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } public void UpdateGoldDisplay(int gold) { if (goldText != null) goldText.text = $"金币: {gold}"; } public void UpdateHealthDisplay(int health) { if (healthText != null) healthText.text = $"生命: {health}"; } public void UpdateGameStateDisplay(GameManager.GameState state) { if (waveText != null) { waveText.text = state switch { GameManager.GameState.Preparing => "准备阶段", GameManager.GameState.Fighting => "战斗中", GameManager.GameState.Paused => "已暂停", GameManager.GameState.Win => "胜利!", GameManager.GameState.Lose => "失败...", _ => "未知状态" }; } } public void ToggleBuildMenu(bool show, Vector3 position) { if (buildMenuPanel != null) { buildMenuPanel.SetActive(show); if (show) { // 将UI面板移动到世界坐标对应的屏幕位置(需要转换) buildMenuPanel.transform.position = Camera.main.WorldToScreenPoint(position); } } } public void ShowGameOverPanel(bool isWin) { if (gameOverPanel != null) { gameOverPanel.SetActive(true); gameOverText.text = isWin ? "关卡胜利!" : "基地被摧毁..."; } } // 按钮点击事件处理方法 public void OnStartWaveButtonClicked() { GameManager.Instance.StartWave(); } public void OnPauseButtonClicked() { // 暂停逻辑 } }

3. 炮塔建造交互:

  • TowerPosition添加一个脚本TowerSpot, 它有一个OnMouseDown方法(需要挂载Collider)。
  • 当玩家点击时,通知UIManager在对应位置打开建造菜单。
  • 建造菜单上的每个按钮对应一种TowerData。点击按钮时,调用GameManager.Instance.SpendGold(data.buildCost),如果成功,则在TowerSpot位置实例化炮塔,并为其设置data

4. 性能优化、调试与扩展方向

一个能跑起来的原型和一款流畅的游戏之间,隔着性能优化和细节打磨。

4.1 性能优化关键点

  1. 对象池 (Object Pooling):这是塔防游戏必须做的优化。敌人和子弹会频繁地创建和销毁,这对垃圾回收(GC)是巨大压力。你需要实现一个ObjectPool类,在游戏开始时预先实例化一定数量的敌人和子弹预制体,禁用并存入池中。需要时从池中取用并激活,不需要时禁用并放回池中,而不是DestroyInstantiate
  2. 搜索算法优化:如前所述,炮塔的FindTarget函数是热点。可以考虑:
    • 分层检测:使用Physics.OverlapSphere并指定LayerMask只检测“Enemy”层,比遍历列表更快。
    • 距离平方比较Vector3.Distance内部会进行开方运算,比较耗时。在比较距离时,使用sqrMagnitude(平方距离)进行比较。例如:if ((transform.position - enemyPos).sqrMagnitude <= range * range)
    • 空间划分:对于大型地图,可以将地图划分为网格,每个炮塔只检测所在网格及相邻网格内的敌人。
  3. Update中的逻辑精简:确保Update方法里的代码尽可能高效。例如,炮塔可以在没有目标时,每0.5秒搜索一次,而不是每帧都搜索。
  4. 批处理与合批:确保炮塔、敌人的材质和纹理尽可能使用图集(Atlas),促进Unity进行动态批处理或静态批处理,减少Draw Call。

4.2 调试技巧与常见问题

  1. 敌人不移动或移动诡异
    • 检查路径点:确保PathManagerwaypoints列表顺序正确,且每个路径点Transform的位置有效。
    • 检查Y轴:敌人和路径点的Y轴高度不一致会导致计算距离出错。确保移动逻辑只在XZ平面计算,或统一高度。
    • 使用Gizmos调试:在EnemyControllerUpdate中,用Debug.DrawLine画出敌人当前朝向和目标点的连线,一目了然。
  2. 炮塔不攻击
    • 检查攻击范围:在炮塔上使用OnDrawGizmosSelected绘制攻击范围球体,在Scene视图中检查敌人是否在圈内。
    • 检查目标搜索逻辑:在FindTarget方法中加入Debug.Log,打印搜索到的敌人数量和距离,看逻辑是否正确。
    • 检查冷却和状态:确认canAttackattackCooldownTimer变量是否按预期更新。
  3. 子弹穿透敌人或无法命中
    • 检查碰撞体:确保敌人和子弹预制体都有合适的碰撞体(如BoxColliderCapsuleCollider)。
    • 检查子弹速度:如果子弹速度过快(比如一帧移动100个单位),可能会从薄弱的敌人模型中间“穿”过去。可以在子弹逻辑中使用Raycast进行碰撞检测,或者使用Rigidbody并设置Collision DetectionContinuous
    • 命中判定时机:我们的示例代码是在子弹“预计”会命中时触发伤害。更稳健的做法是使用OnTriggerEnterOnCollisionEnter物理回调。
  4. UI显示异常或点击无响应
    • 检查Canvas渲染模式:对于需要跟随世界物体(如炮塔位置)的UI,使用Screen Space - CameraWorld Space模式,并正确设置Event Camera
    • 检查UI层级:确保可点击的UI元素在层级视图中位于上层,没有被其他Panel遮挡。
    • 检查射线阻挡:如果有3D物体(如地面)在UI前面,并且它的碰撞体阻挡了射线,会导致UI无法被点击。可以设置UI的Graphic Raycaster的优先级,或调整3D物体的图层。

4.3 项目扩展与进阶思路

当你完成了基础版本,可以尝试以下方向让游戏变得更专业、更有趣:

  1. 更丰富的炮塔类型
    • 溅射攻击:修改Attack逻辑,命中后对目标周围一定范围内的所有敌人造成伤害。
    • 减速效果:子弹命中后,给敌人附加一个持续数秒的减速Debuff(通过修改敌人的moveSpeed实现)。
    • 持续伤害 (DOT):如毒塔,命中后让敌人每秒掉血。
    • 光环效果:范围内所有友方炮塔攻击力提升,或敌人攻速降低。
  2. 更复杂的敌人AI
    • 不同类型敌人:高速但脆皮的“侦察兵”,高血量但慢速的“坦克”,对某种攻击有抗性的“护甲兵”。
    • 移动模式:不是所有敌人都必须走固定路径。可以设计会飞的敌人(无视地形)、会挖地道的敌人(随机出现)、甚至能短暂隐身或加速的敌人。
  3. 技能系统:为玩家添加全局技能,如“全屏冰冻”、“金币雨”、“临时火力增强”,增加策略维度和操作感。
  4. 地图编辑器:允许玩家自己设计路径和障碍物,极大提升游戏可玩性。这需要将路径数据序列化保存(如保存为JSON),并在运行时加载。
  5. 数据持久化:使用PlayerPrefsJSON文件或ScriptableObject保存玩家的金币、解锁的关卡和炮塔等信息。
  6. 引入状态机:如搜索资料中提到的,使用有限状态机(FSM)来管理游戏状态(准备、战斗、暂停等)或敌人状态(移动、攻击、死亡),能让代码逻辑更清晰,尤其是状态复杂时。
  7. 资源加载优化:使用AssetBundle进行资源动态加载,减少初始包体大小,实现热更新。

这套从场景搭建到核心逻辑,再到优化扩展的流程,基本涵盖了一个可玩塔防游戏的核心。最重要的是理解每个模块的职责和它们之间的通信方式。当你把这些都跑通后,你会发现Unity游戏开发的基本套路已经掌握了,再去尝试其他类型的游戏,会变得容易很多。游戏开发最大的乐趣就在于,你可以从一个简单的想法开始,一点点添加功能,看着它变得越来越像你心目中的那个游戏。

← 返回列表