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

日记详情

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

Unity 2D游戏AI实战:动态敌人追踪光标系统设计与优化

Unity 2D游戏AI实战:动态敌人追踪光标系统设计与优化

1. 项目概述与核心价值

最近在捣鼓一个2D射击游戏的小样,核心玩法就是让玩家操控一个角色,在场景里躲避并射击敌人。为了让游戏更有挑战性和“智能感”,我决定让敌人不再是傻乎乎地直线冲过来,而是能动态地追踪玩家的鼠标光标。这个想法听起来简单,不就是让敌人朝着鼠标位置移动嘛?但真做起来,你会发现里面门道不少:怎么让追踪看起来自然不突兀?怎么处理障碍物?怎么让不同类型的敌人有不同的“性格”?怎么保证游戏跑得流畅不卡顿?这背后其实是一套完整的AI行为系统设计。

这个“动态敌人追踪光标系统”,本质上是在游戏AI领域一个非常经典且实用的课题。它不仅仅是计算一个方向向量那么简单,而是涉及到感知(Perception)、决策(Decision Making)、移动(Movement)三个核心环节的闭环。对于刚入门的游戏开发者来说,这是理解游戏AI最直观的切入点;对于有经验的开发者,如何优化其性能、丰富其行为模式,也是一个常做常新的挑战。无论你是想做一个简单的躲避球游戏,还是一个复杂的俯视角射击游戏,这套系统都能作为你AI模块的坚实基石。

接下来,我会基于在Unity引擎中使用C#的实战经验,从头拆解如何构建这样一个系统。我会重点讲清楚“为什么”要这么做,而不仅仅是“怎么做”,并分享那些在官方文档里不会写的踩坑经验和调优技巧。

2. 系统架构与核心思路拆解

在动手写代码之前,我们先得把整个系统的骨架搭起来。一个健壮的追踪系统不能把所有逻辑都塞在一个Update函数里,那样后期维护和扩展会是噩梦。我的设计思路是采用分层状态机(Hierarchical State Machine)结合面向数据的设计思想

2.1 整体架构设计

我把整个敌人AI控制器分为几个相对独立的模块:

  1. 感知模块(Perception Module):负责“看”和“听”。它的核心任务是获取玩家光标的世界坐标。但高级的感知还包括侦测玩家与敌人之间的视线(Line of Sight)是否被障碍物阻挡,甚至“听到”玩家开枪的声音大致方位。
  2. 决策模块(Decision Making Module):这是AI的“大脑”。它根据感知模块输入的信息,结合敌人自身的类型和状态,决定当前应该执行哪种行为(例如:简单追击、预测拦截、巡逻、躲避)。
  3. 移动模块(Movement Module):这是AI的“腿”。它接收决策模块输出的目标点或方向指令,并计算出具体的移动力(Velocity),同时处理与物理世界的交互,如碰撞避免、路径寻找。
  4. 黑板(Blackboard):一个共享的数据容器。各个模块之间不直接通信,而是通过读写黑板上的数据(如PlayerWorldPositionCurrentStateIsPathBlocked)来协作。这极大地降低了模块间的耦合度。
// 一个简化的黑板数据结构示例 public class AIBlackboard : MonoBehaviour { public Vector2 PlayerPositionWorld { get; set; } public Vector2 PlayerVelocity { get; set; } // 用于预测 public bool HasLineOfSightToPlayer { get; set; } public Vector2 CurrentTargetPosition { get; set; } public AIState CurrentState { get; set; } // ... 其他共享数据 } public enum AIState { Idle, Chase_Simple, Chase_Predictive, Flank, Evade, FindPath }

2.2 为什么选择Unity和C#

你可能会问,为什么不用Godot或者自己写引擎?对于快速原型开发和独立开发者而言,Unity仍然是目前最均衡的选择。

  • 成熟的2D工具链:Unity的2D物理(Box2D集成)、Sprite渲染器、Tilemap系统都是开箱即用且经过大量项目验证的,能节省大量底层开发时间。
  • 强大的性能分析工具:Profiler是优化AI性能的神器,可以清晰看到每一帧CPU时间花在了哪个函数的哪行代码上。
  • 丰富的社区资源:几乎你遇到的任何问题,都能在社区找到相关的讨论或解决方案。
  • C#的语言优势:相比C++更安全易用,相比JavaScript/TypeScript(如某些引擎)在大型游戏项目管理和性能上更有优势。其事件委托、接口等特性非常适合构建松耦合的AI系统。

注意:虽然Unity好用,但也要警惕“过度依赖”。理解其底层机制(如Update调用顺序、物理更新帧)对于解决一些诡异bug至关重要。例如,在FixedUpdate中处理移动,在Update中处理决策,是常见的做法,但需要理解为什么。

3. 核心模块深度解析与实现

3.1 感知模块:如何“看见”光标

获取鼠标光标位置听起来就是一句Input.mousePosition,但这里有个关键转换:屏幕坐标到世界坐标

public class PerceptionModule : MonoBehaviour { private Camera _mainCamera; private AIBlackboard _blackboard; void Start() { _mainCamera = Camera.main; // 缓存主相机,避免每帧查找 _blackboard = GetComponent<AIBlackboard>(); } void Update() { UpdatePlayerPosition(); UpdateLineOfSight(); } private void UpdatePlayerPosition() { // 1. 获取屏幕坐标(原点在左下角) Vector3 mouseScreenPos = Input.mousePosition; // 2. 转换为世界坐标。Z轴深度很重要!对于2D正交相机,通常用相机Z坐标的负值。 // 假设你的游戏在Z=0的平面上运行,相机在Z=-10的位置 mouseScreenPos.z = -_mainCamera.transform.position.z; Vector3 mouseWorldPos = _mainCamera.ScreenToWorldPoint(mouseScreenPos); // 3. 写入黑板 _blackboard.PlayerPositionWorld = mouseWorldPos; } private void UpdateLineOfSight() { Vector2 origin = transform.position; Vector2 direction = (_blackboard.PlayerPositionWorld - (Vector2)transform.position).normalized; float distance = Vector2.Distance(transform.position, _blackboard.PlayerPositionWorld); // 使用Raycast2D检测视线是否被障碍物阻挡 // 注意:只检测障碍物所在的Layer,避免打到敌人或玩家自身 RaycastHit2D hit = Physics2D.Raycast(origin, direction, distance, LayerMask.GetMask("Obstacle")); _blackboard.HasLineOfSightToPlayer = (hit.collider == null); // 可视化调试线(仅在开发时启用) Debug.DrawRay(origin, direction * distance, _blackboard.HasLineOfSightToPlayer ? Color.green : Color.red); } }

实操心得

  • 缓存(Cache)是金:像Camera.mainGetComponent这类调用,在StartAwake中缓存起来,不要在Update里每次都调用,性能提升立竿见影。
  • LayerMask的使用:精确设置射线检测的层掩码,是避免奇怪碰撞检测结果的关键。为你的障碍物、敌人、玩家、子弹分别设置不同的Layer。
  • 调试可视化Debug.DrawRayDebug.DrawLine是调试感知系统的生命线。用不同颜色表示不同状态(如绿色可见,红色被挡),能让你快速定位问题。

3.2 决策模块:AI的“大脑”与状态机

决策模块的核心是一个状态机。我实现了三种主要的追踪状态:

3.2.1 简单追踪状态

这是最基础的状态。逻辑就是:如果看见玩家,就朝着玩家当前位置移动。

public class ChaseSimpleState : IAIState { private AIBlackboard _bb; private MovementModule _movement; public void Enter(AIBlackboard blackboard) { _bb = blackboard; _movement = _bb.GetComponent<MovementModule>(); Debug.Log($"{_bb.gameObject.name} 进入简单追踪状态"); } public void Execute() { if (!_bb.HasLineOfSightToPlayer) { // 丢失视线,可能切换到寻路或巡逻状态 _bb.CurrentState = AIState.FindPath; return; } // 决策:目标点就是玩家当前位置 _bb.CurrentTargetPosition = _bb.PlayerPositionWorld; // 将决策传递给移动模块 _movement.SetTarget(_bb.CurrentTargetPosition); } public void Exit() { // 清理状态,例如停止移动指令 _movement.Stop(); } }
3.2.2 预测追踪状态

这个状态让AI显得更聪明。核心思想是:不追玩家现在的位置,而是追玩家将要到达的位置

public class ChasePredictiveState : IAIState { private AIBlackboard _bb; private MovementModule _movement; private Queue<Vector2> _playerPositionHistory = new Queue<Vector2>(); private int _historyFrameCount = 10; // 记录最近10帧的位置 private Vector2 _predictedPosition; public void Enter(AIBlackboard blackboard) { /* 初始化 */ } public void Execute() { // 1. 记录历史位置 _playerPositionHistory.Enqueue(_bb.PlayerPositionWorld); if (_playerPositionHistory.Count > _historyFrameCount) _playerPositionHistory.Dequeue(); // 2. 计算平均速度(预测的关键) if (_playerPositionHistory.Count >= 2) { Vector2[] positions = _playerPositionHistory.ToArray(); Vector2 totalDisplacement = Vector2.zero; for (int i = 1; i < positions.Length; i++) { totalDisplacement += (positions[i] - positions[i - 1]); } Vector2 averageVelocity = totalDisplacement / (positions.Length - 1); // 3. 预测未来位置:当前位置 + 平均速度 * 预测时间 float predictionTime = 0.5f; // 预测0.5秒后的位置,这个值可以动态调整 _predictedPosition = _bb.PlayerPositionWorld + averageVelocity * predictionTime; } else { _predictedPosition = _bb.PlayerPositionWorld; } // 4. 设置目标 _bb.CurrentTargetPosition = _predictedPosition; _movement.SetTarget(_bb.CurrentTargetPosition); // 可视化预测点(调试用) Debug.DrawLine(_bb.PlayerPositionWorld, _predictedPosition, Color.cyan); Debug.DrawRay(_predictedPosition, Vector2.up * 0.2f, Color.yellow); } }

为什么这样预测?这是一种最简单的线性预测。它假设玩家在接下来的一小段时间内保持近期的平均速度。在玩家进行规律性移动(如匀速逃跑)时效果极佳,但在玩家突然变向时会显得AI很蠢。你可以引入更复杂的算法,如考虑加速度或使用贝塞尔曲线拟合,但复杂度也会剧增。游戏AI的黄金法则是:看起来聪明比实际上复杂更重要。这个简单的预测已经能让玩家感到压力了。

3.2.3 包抄行为状态

这不是一个独立的状态,而是一种群体协同策略。需要在敌人管理器(Enemy Manager)层面实现。

  • 思路:当多个敌人同时追踪一个玩家时,让它们不要挤在一起。可以指定一个敌人进行正面追击,其他敌人则计算从玩家侧翼或后方接近的路径点。
  • 实现:为每个敌人分配一个“包抄角度偏移量”。例如,敌人A偏移0度(正面),敌人B偏移45度,敌人C偏移-45度。目标点不再是玩家位置,而是玩家位置 + (玩家前方向量).Rotate(偏移角度) * 包抄距离
  • 关键:需要动态调整,避免多个敌人选择了相同的包抄点。

3.3 移动模块与障碍物躲避

移动模块接收一个目标位置,并负责安全、高效地到达那里。Unity自带的NavMeshAgent对于2D和复杂动态障碍物支持不够原生,所以很多2D游戏选择自己实现或使用A* Pathfinding Project等资产。这里我介绍结合转向力(Steering Force)局部避障的轻量级方案。

3.3.1 基础移动与转向力

转向力模型模拟了真实物体的运动惯性,移动更平滑自然。

public class MovementModule : MonoBehaviour { public float maxSpeed = 5f; public float maxForce = 10f; private Rigidbody2D _rb; private Vector2 _desiredVelocity; private Vector2 _steeringForce; private Vector2 _currentTarget; void Start() { _rb = GetComponent<Rigidbody2D>(); } public void SetTarget(Vector2 target) { _currentTarget = target; CalculateSteering(); } private void CalculateSteering() { // 1. 计算期望速度:指向目标的方向,大小是最大速度 Vector2 desiredDirection = (_currentTarget - (Vector2)transform.position).normalized; _desiredVelocity = desiredDirection * maxSpeed; // 2. 计算转向力:期望速度减去当前速度,并限制最大力 Vector2 force = _desiredVelocity - _rb.velocity; _steeringForce = Vector2.ClampMagnitude(force, maxForce); } void FixedUpdate() { // 3. 在物理更新中应用力 _rb.AddForce(_steeringForce); // 4. 限制最终速度,防止因合力超出最大速度 if (_rb.velocity.magnitude > maxSpeed) { _rb.velocity = _rb.velocity.normalized * maxSpeed; } } }
3.3.2 局部障碍物躲避

在转向力的基础上,我们可以加入避障力。一个经典算法是射线投射法:在移动方向的前方和侧前方发射多条射线,检测障碍物。

private Vector2 CalculateObstacleAvoidanceForce() { Vector2 avoidanceForce = Vector2.zero; int rayCount = 5; // 前方扇形发射5条射线 float rayRange = 3f; // 射线长度 float angleStep = 30f; // 射线之间的角度 for (int i = 0; i < rayCount; i++) { // 计算射线方向(从正前方向两侧展开) float angle = -angleStep * (rayCount - 1) / 2 + i * angleStep; Vector2 direction = Quaternion.Euler(0, 0, angle) * transform.right; // 假设敌人面朝右 RaycastHit2D hit = Physics2D.Raycast(transform.position, direction, rayRange, obstacleLayer); if (hit.collider != null) { // 障碍物越近,排斥力越大 float strength = 1.0f - (hit.distance / rayRange); // 排斥力方向是射线击中点法线方向(或从击中点指向自身的方向) Vector2 forceDir = ((Vector2)transform.position - hit.point).normalized; avoidanceForce += forceDir * strength * avoidanceWeight; Debug.DrawRay(transform.position, direction * hit.distance, Color.magenta); } } return avoidanceForce; } // 然后在 CalculateSteering 中,将 avoidanceForce 加到 _steeringForce 上

注意事项

  • 性能:每条射线都有成本。要控制射线数量和长度,并确保只在需要时(如靠近障碍物)进行计算。
  • 与寻路的结合:对于复杂的迷宫场景,局部避障是不够的,需要全局路径规划(如A*)。一个常见的架构是:A*负责计算从A点到B点的全局路径(一组路点),移动模块则用转向力和局部避障在这些路点间导航。

4. 性能优化与60FPS保障

游戏卡顿是体验杀手。AI计算,尤其是寻路和物理查询,是常见的性能瓶颈。以下是我确保稳定60FPS(即每帧计算时间低于16ms)的关键策略。

4.1 分帧更新与负载均衡

不要所有敌人的AI都在同一帧更新。将敌人分成若干组,分散到不同的帧去处理。

public class AIManager : MonoBehaviour { private List<EnemyAI> _allEnemies = new List<EnemyAI>(); private int _updateIndex = 0; public int enemiesPerFrame = 5; // 每帧更新5个敌人 void Update() { int count = Mathf.Min(enemiesPerFrame, _allEnemies.Count); for (int i = 0; i < count; i++) { int index = (_updateIndex + i) % _allEnemies.Count; _allEnemies[index].UpdateAI(); // 只更新AI决策,不包含移动(移动在FixedUpdate) } _updateIndex = (_updateIndex + count) % _allEnemies.Count; } }

4.2 对象池管理敌人实例

频繁地实例化(Instantiate)和销毁(Destroy)敌人GameObject会产生内存碎片和GC(垃圾回收)压力,导致卡顿。对象池是解决方案。

public class EnemyPool : MonoBehaviour { public GameObject enemyPrefab; public int poolSize = 20; private Queue<GameObject> _pool = new Queue<GameObject>(); void Start() { for (int i = 0; i < poolSize; i++) { GameObject obj = Instantiate(enemyPrefab); obj.SetActive(false); obj.transform.SetParent(transform); _pool.Enqueue(obj); } } public GameObject GetEnemy() { if (_pool.Count > 0) { GameObject obj = _pool.Dequeue(); obj.SetActive(true); return obj; } // 池子空了,可以选择动态扩容或返回null GameObject newObj = Instantiate(enemyPrefab); newObj.SetActive(true); return newObj; } public void ReturnEnemy(GameObject obj) { obj.SetActive(false); _pool.Enqueue(obj); } }

4.3 距离和视锥裁剪

对于远处的、屏幕外的敌人,根本不需要进行精细的AI计算。

  • 距离裁剪:只对一定距离内的敌人进行预测追踪等昂贵计算,之外的敌人用简单追踪甚至静止。
  • 视锥裁剪:利用GeometryUtility.TestPlanesAABB函数,判断敌人的包围盒是否在相机视锥体内。不在视野内的敌人可以大幅降低更新频率(比如每秒更新一次)。

4.4 使用高效的物理查询

  • 避免在Update中使用非必要的Physics2D.Raycast:如果射线检测频率可以降低,考虑在协程(Coroutine)中每隔几帧检测一次。
  • 使用Physics2D.OverlapCircleNonAlloc等非分配版本函数:这些函数不会产生垃圾内存,避免了GC。
  • 合理设置物理更新频率(Fixed Timestep):在Project Settings -> Time中,默认的0.02s(50Hz)对大多数2D游戏足够。调高它会增加CPU负担。

5. 可视化调试工具搭建

“看不见”的AI逻辑是调试的噩梦。花点时间搭建可视化调试工具,长期来看效率提升十倍不止。

5.1 绘制Gizmos与Debug图形

Unity的OnDrawGizmosOnDrawGizmosSelected是内置的调试绘图函数。

void OnDrawGizmosSelected() { if (!Application.isPlaying) return; // 绘制当前目标点 Gizmos.color = Color.yellow; Gizmos.DrawWireSphere(_blackboard.CurrentTargetPosition, 0.2f); // 绘制视线检测 Gizmos.color = _blackboard.HasLineOfSightToPlayer ? Color.green : Color.red; Gizmos.DrawLine(transform.position, _blackboard.PlayerPositionWorld); // 绘制移动的期望速度方向 Gizmos.color = Color.blue; Gizmos.DrawRay(transform.position, _desiredVelocity.normalized * 2); }

5.2 自定义编辑器窗口

你可以创建更强大的调试窗口,实时显示和修改所有敌人的内部状态。

  1. 创建一个继承自EditorWindow的类。
  2. 使用GUILayoutEditorGUILayout绘制界面。
  3. 在窗口中,可以遍历场景中所有敌人,显示其当前状态、目标位置、速度等信息。
  4. 甚至可以加入滑块,实时调整全局的AI参数(如预测时间、躲避权重),实现“运行时调参”,这是平衡游戏手感的神器。

5.3 性能统计面板

在屏幕一角显示简单的性能数据。

void OnGUI() { GUIStyle style = new GUIStyle(); style.fontSize = 20; style.normal.textColor = Color.white; GUI.Label(new Rect(10, 10, 200, 50), $"FPS: {1.0f / Time.deltaTime:F1}", style); GUI.Label(new Rect(10, 40, 200, 50), $"Enemies: {_allEnemies.Count}", style); // 可以添加更多信息,如每帧AI更新耗时 }

6. 常见问题与排查实录

在开发过程中,我踩过不少坑,这里记录几个最有代表性的。

6.1 敌人抖动或运动不平滑

  • 可能原因1:移动计算在Update中,但物理应用在FixedUpdate中,两者帧率不同步导致。
    • 解决:确保所有读取Rigidbody2D位置、速度以及施加力的操作都在FixedUpdate中进行。决策计算可以在Update,但最终产生的移动指令应作为一个“请求”,在FixedUpdate中统一应用。
  • 可能原因2:转向力maxForce值太大,导致速度反复过冲和刹车。
    • 解决:降低maxForce,让转向更柔和。或者使用更平滑的插值(如Vector2.SmoothDamp)。
  • 可能原因3:每帧设置的目标点变化过于剧烈(如预测算法不稳定)。
    • 解决:对计算出的目标位置进行平滑滤波(如取最近几帧的平均值)。

6.2 预测追踪的AI显得很“蠢”,总是跑过头

  • 可能原因:预测时间(predictionTime)是固定值,不适合所有情况。
    • 解决:动态调整预测时间。例如,当玩家距离远时,预测时间可以长一些;距离近时,预测时间短一些甚至为零(直接追击)。也可以根据玩家速度的大小来调整。

6.3 多个敌人卡在角落或门口

  • 可能原因:局部避障的排斥力让它们互相推开,同时又都想往同一个点挤,形成死锁。
    • 解决
      1. 增加个体空间:在避障检测中,把其他敌人也视为临时障碍物。
      2. 引入随机扰动:当检测到长时间速度接近零时,给AI一个随机的微小转向力,帮助其“挣脱”僵局。
      3. 使用分层避障:优先避开静态障碍物,其次才是其他动态单位。

6.4 游戏在敌人数量多时明显变卡

  • 排查步骤
    1. 打开Unity Profiler (Window -> Analysis -> Profiler)。
    2. 观察CPU使用率。找到占用最高的函数。
    3. 如果是AIManager.Update或某个AI的Update占用高,说明是AI计算开销大。应用4.1 分帧更新
    4. 如果是Physics2D相关函数占用高,说明物理查询太多。检查射线数量、碰撞体复杂度,应用4.4 使用高效的物理查询
    5. 如果GC Alloc(垃圾分配)很高,说明每帧产生了大量临时对象(如new Vector2()RaycastHit2D数组的分配)。使用非分配(NonAlloc)版本的物理函数对象池

6.5 调试线或Gizmos在Game视图看不到

  • 可能原因OnDrawGizmos只在Scene视图绘制。Debug.DrawRay在Game视图默认可见,但如果相机背景是纯色,线条颜色可能与之相近。
  • 解决:确保在Game视图的右上角,将“Gizmos”下拉菜单中的选项打开(通常有“3D Gizmos”等)。对于Debug.DrawRay,可以绘制更粗或颜色更鲜明的线。

这套动态敌人追踪光标系统,从简单的向量计算到包含预测、避障、优化的完整框架,其开发过程本身就是一个对游戏AI设计思维的很好训练。记住,最好的AI不一定是算法最复杂的,而是能让玩家觉得有趣、公平、有挑战性的那个。多玩自己的游戏,从玩家视角感受AI的行为,不断迭代和调整参数,才是打磨出优秀游戏体验的不二法门。

← 返回列表