Unity游戏AI开发:基于NavMeshAgent与状态机实现敌人巡逻与追踪系统
1. 项目概述:从“傻站着”到“聪明追”的AI进化
在独立游戏开发或者小型团队项目中,敌人AI的行为逻辑往往是决定游戏体验好坏的关键一环。想象一下,你精心设计了一个充满细节的关卡,结果敌人要么像木头一样杵在原地,要么一发现玩家就无视地形、穿墙而过,这种“出戏感”会瞬间摧毁玩家的沉浸体验。早期,很多开发者会使用简单的Transform.Translate配合射线检测来实现移动和追踪,但这种方法在复杂地形中极易“翻车”——敌人可能会卡在墙角,或者从悬崖边直挺挺地走下去。
Unity引擎内置的NavMeshAgent(导航网格代理)组件,就是为了解决这类寻路问题而生的“官方外挂”。它不再是让物体简单地朝一个坐标点直线移动,而是让角色能够理解你所构建的虚拟世界的“可行走区域”。通过预先烘焙(Bake)的导航网格(NavMesh),NavMeshAgent可以自动计算从A点到B点的最优路径,并绕开障碍物、攀爬斜坡、跨越沟壑(在网格允许的情况下)。这对于实现诸如敌人巡逻、追踪玩家、包围、撤退等复杂AI行为来说,是基石般的存在。
本次分享,我将以一个典型的第三人称或俯视角动作/冒险游戏中的敌人AI为例,手把手带你实现一个兼具“日常巡逻”和“警觉追踪”双状态的敌人。我们将深入NavMeshAgent的核心参数,理解每一行代码背后的逻辑,并附上可直接集成到你项目中的完整代码模块。无论你是刚接触Unity不久的新手,还是想优化现有AI系统的开发者,这篇内容都能提供清晰的路径和实用的技巧。
2. 核心思路与系统设计:状态机驱动AI行为
在开始写代码之前,我们必须为敌人的AI设计一个清晰的大脑。最直观、最易于管理和扩展的方法就是使用有限状态机(Finite State Machine, FSM)。你可以把FSM理解为敌人AI的“情绪模式”,它在任一时刻只处于一种状态,并根据特定条件在不同状态间切换。
对于我们的巡逻与追踪敌人,至少需要设计以下三个核心状态:
- 巡逻状态(PatrolState):敌人的默认状态。在此状态下,敌人会在预先设定的一系列路径点(Waypoints)之间按顺序或随机移动,模拟日常的巡视行为。
- 追踪状态(ChaseState):当敌人发现玩家(例如,玩家进入其视野范围或触发区域)时,立即切换到此状态。在此状态下,敌人的唯一目标就是尽可能快地接近玩家。
- 返回状态(ReturnState):这是一个容易被忽略但至关重要的状态。当玩家脱离敌人的追踪范围(例如跑得太远)后,敌人不应该永远卡在最后一次看到玩家的位置,也不应该瞬间“传送”回巡逻点。更合理的行为是,让敌人先回到它最后失去玩家踪迹的位置(Last Known Position)稍作停留(模拟困惑或搜寻),然后再有序地回归到巡逻路径上。
为什么选择状态机?因为它结构清晰,将复杂的行为逻辑分解为独立的模块。每个状态只关心自己该做什么(Update逻辑)和什么条件下应该退出(切换条件)。这比把所有if-else判断都堆在一个Update函数里要易于维护和调试得多。未来如果你想增加一个“攻击状态”或“受伤逃跑状态”,只需要新增一个状态类并修改切换条件即可,不会影响原有逻辑。
NavMeshAgent的角色: 在我们的设计中,NavMeshAgent是状态机的“执行器”。每个状态通过调用NavMeshAgent.SetDestination()来告诉Agent它应该去哪里。状态机负责决策“目标点是什么”,而NavMeshAgent则负责解决“如何到达目标点”这个复杂的路径寻找和移动问题。这种职责分离让我们的AI逻辑既强大又简洁。
3. 环境准备与NavMesh烘焙
3.1 场景搭建与静态物体标记
首先,你需要一个测试场景。创建一个平面(Plane)作为地面,并摆放一些立方体(Cube)或导入的模型作为障碍物,比如墙壁、箱子、柱子等。关键的一步来了:所有不希望被敌人穿过的静态障碍物,都必须被标记为Navigation Static。
- 在Hierarchy中选择你的地面和所有障碍物。
- 在Inspector窗口的右上角,找到“Static”下拉复选框,点击它。
- 在弹出的菜单中,确保“Navigation Static”被勾选。这意味着Unity在烘焙导航网格时,会将这些物体的几何形状考虑在内,将其排除在可行走区域之外。
注意:对于会移动的物体(如可推动的箱子、开关门),不能标记为Navigation Static。对于这类动态障碍物,需要使用
NavMeshObstacle组件,这属于更高级的用法,本篇暂不展开。
3.2 导航网格烘焙参数详解
导航网格(NavMesh)本质上是一张覆盖在场景可行走区域上的蓝色“网格”。NavMeshAgent只能在这张蓝色的网格上移动。
- 打开导航窗口:
Window->AI->Navigation。 - 切换到“Bake”标签页。这里有很多参数,但初期我们重点关注这几个:
- Agent Radius(代理半径):想象成敌人的“身体宽度”。这个值决定了路径与障碍物之间会保持多大距离。如果敌人老是在拐角处卡住,可以适当调小此值;如果敌人之间容易重叠,可以调大。
- Agent Height(代理高度):敌人能通过的最低通道高度。如果你在场景中设计了一个需要蹲下才能通过的洞口,这个高度值就决定了敌人是否会尝试通过。
- Max Slope(最大坡度):敌人能爬上的最大斜坡角度(单位:度)。超过这个角度的斜坡会被视为不可行走的“墙”。
- Step Height(台阶高度):敌人能直接迈上去的最大台阶高度。这对于楼梯或门槛类地形非常有用。
- 设置好参数后,点击右下角的“Bake”按钮。烘焙完成后,Scene视图中会出现蓝色的NavMesh覆盖区域。确保你的巡逻路径点都设置在蓝色区域内部。
实操心得: 烘焙是一个迭代过程。第一次烘焙后,经常需要回到场景中调整障碍物位置或烘焙参数,然后重新烘焙。一个常见的技巧是,在复杂场景中,可以分层级烘焙多个NavMesh(通过Layer),分别用于不同体型的敌人(如人类士兵和大型怪物),但这需要用到NavMesh的进阶功能。
4. 敌人预制体与组件配置
创建一个敌人模型或用一个胶囊体(Capsule)代替,将其命名为“Enemy”。
- 添加刚体(Rigidbody):虽然
NavMeshAgent自身会处理移动,但添加一个刚体组件(勾选Is Kinematic)可以方便地与其他物理系统交互(比如被击退)。 - 添加并配置NavMeshAgent组件:这是核心组件。
Speed:移动速度。在巡逻状态和追踪状态可以使用不同的速度值,通过代码动态修改。Angular Speed:转向速度。值太低会导致敌人转弯时显得迟钝。Acceleration:加速度。值越大,启动和停止越快,移动感更“灵敏”。Stopping Distance:停止距离。当敌人到达目标点多远时,就认为“已到达”。对于追踪玩家,这个值可以设为攻击距离;对于巡逻点,可以设为一个较小的值(如0.5)。Auto Braking:自动制动。勾选后,接近目标时会自动减速,移动更平滑。通常建议勾选。
- 添加自定义脚本:我们将创建一个名为
EnemyAI的脚本,挂载到敌人身上,它将承载我们整个状态机逻辑。
5. 完整代码实现与逐行解析
下面是我们EnemyAI脚本的完整实现。代码中包含了详细的注释,解释了每一部分的作用。
using UnityEngine; using UnityEngine.AI; // 引入AI命名空间 using System.Collections.Generic; public class EnemyAI : MonoBehaviour { // ---------- 状态机相关 ---------- public enum AIState { Patrol, Chase, Return } private AIState _currentState = AIState.Patrol; // ---------- 巡逻相关 ---------- public List<Transform> patrolPoints; // 在Inspector中拖拽赋值 private int _currentPatrolIndex = 0; public float patrolSpeed = 3.5f; public float waitTimeAtPoint = 2f; // 在每个巡逻点停留的时间 private float _waitCounter = 0f; private bool _isWaiting = false; // ---------- 视觉/感知相关 ---------- public Transform playerTarget; // 玩家Transform,可拖拽或代码查找 public float sightRange = 10f; // 视觉范围 public float chaseRange = 15f; // 最大追踪范围(超过则丢失目标) public float fieldOfViewAngle = 90f; // 前方视野角度 private Vector3 _lastKnownPlayerPosition; private bool _hasLastKnownPosition = false; // ---------- 追踪相关 ---------- public float chaseSpeed = 5f; public float stoppingDistanceToPlayer = 1.5f; // 追踪时希望保持的距离 // ---------- 返回相关 ---------- public float returnSpeed = 4f; private Vector3 _startPosition; // 敌人的初始位置,作为回归的备选点 private Transform _originalPatrolParent; // 巡逻点的父物体,用于计算本地位置 // ---------- 组件引用 ---------- private NavMeshAgent _agent; private Animator _animator; // 可选,用于控制动画 void Start() { _agent = GetComponent<NavMeshAgent>(); _animator = GetComponent<Animator>(); // 如果有的话 _startPosition = transform.position; // 初始化巡逻点:如果未手动赋值,尝试查找标签为“PatrolPoint”的物体 if (patrolPoints == null || patrolPoints.Count == 0) { GameObject[] points = GameObject.FindGameObjectsWithTag("PatrolPoint"); foreach (GameObject point in points) { patrolPoints.Add(point.transform); } // 按名字排序,确保巡逻顺序 patrolPoints.Sort((a, b) => a.name.CompareTo(b.name)); } if (patrolPoints.Count > 0) { // 记录巡逻点的原始父物体,用于处理动态移动的巡逻路径(可选) _originalPatrolParent = patrolPoints[0].parent; SetNextPatrolPoint(); } else { Debug.LogWarning("EnemyAI: No patrol points assigned. Enemy will idle at start position."); } // 如果未指定玩家,尝试按标签查找 if (playerTarget == null) { GameObject playerObj = GameObject.FindGameObjectWithTag("Player"); if (playerObj != null) playerTarget = playerObj.transform; } } void Update() { // 每帧检查状态切换条件 CheckStateTransitions(); // 执行当前状态的行为 switch (_currentState) { case AIState.Patrol: UpdatePatrolState(); break; case AIState.Chase: UpdateChaseState(); break; case AIState.Return: UpdateReturnState(); break; } // 可选:更新动画参数 UpdateAnimation(); } // 检查是否满足切换状态的条件 void CheckStateTransitions() { bool canSeePlayer = CanSeePlayer(); switch (_currentState) { case AIState.Patrol: if (canSeePlayer) { SwitchState(AIState.Chase); Debug.Log($"{gameObject.name} spotted player! Switching to Chase."); } break; case AIState.Chase: if (!canSeePlayer) { // 看不见玩家,但可能还在追踪范围内? float distanceToPlayer = Vector3.Distance(transform.position, playerTarget.position); if (distanceToPlayer > chaseRange) { // 玩家超出最大追踪范围,记录最后已知位置并返回 if (_hasLastKnownPosition) { SwitchState(AIState.Return); Debug.Log($"{gameObject.name} lost player beyond range. Returning to last known position."); } else { // 罕见情况:没看到就直接超出范围,直接回巡逻 SwitchState(AIState.Patrol); } } // 如果只是暂时看不见但还在范围内,则保持Chase状态,继续前往_lastKnownPlayerPosition } break; case AIState.Return: // 如果返回过程中又看到了玩家,立即中断返回,继续追踪 if (canSeePlayer) { SwitchState(AIState.Chase); } // 返回状态是否完成,在UpdateReturnState中判断 break; } } // 巡逻状态更新逻辑 void UpdatePatrolState() { _agent.speed = patrolSpeed; if (_isWaiting) { _waitCounter -= Time.deltaTime; if (_waitCounter <= 0) { _isWaiting = false; SetNextPatrolPoint(); } // 等待时,可以播放 idle 动画 return; } // 检查是否到达当前巡逻点 if (!_agent.pathPending && _agent.remainingDistance <= _agent.stoppingDistance) { if (!_agent.hasPath || _agent.velocity.sqrMagnitude == 0f) { // 已到达,开始等待 StartWaiting(); } } } // 追踪状态更新逻辑 void UpdateChaseState() { _agent.speed = chaseSpeed; _agent.stoppingDistance = stoppingDistanceToPlayer; if (playerTarget != null) { // 持续更新目标为玩家当前位置 _agent.SetDestination(playerTarget.position); _lastKnownPlayerPosition = playerTarget.position; _hasLastKnownPosition = true; // 可选:如果追上了(距离足够近),可以在这里触发攻击逻辑 // if (_agent.remainingDistance <= attackRange) { /* Attack */ } } } // 返回状态更新逻辑 void UpdateReturnState() { _agent.speed = returnSpeed; _agent.stoppingDistance = 0.5f; // 回到点位时停止距离可以小一些 // 如果还没有设置目标,则设置为最后已知位置或起始位置 if (_agent.destination != _lastKnownPlayerPosition && _hasLastKnownPosition) { _agent.SetDestination(_lastKnownPlayerPosition); } // 检查是否到达最后已知位置 if (!_agent.pathPending && _agent.remainingDistance <= _agent.stoppingDistance) { if (!_agent.hasPath || _agent.velocity.sqrMagnitude == 0f) { // 到达最后已知位置,可以在这里添加一个“搜寻”的短暂停顿 Debug.Log($"{gameObject.name} reached last known position. Resuming patrol."); // 停顿2秒后,切换回巡逻状态 Invoke("ResumePatrolFromReturn", 2f); } } } void ResumePatrolFromReturn() { // 回归巡逻时,可以随机选择一个巡逻点,或者回到最近的巡逻点 // 这里选择回到起始的巡逻序列 _currentPatrolIndex = 0; // 或 FindNearestPatrolPointIndex(); SwitchState(AIState.Patrol); SetNextPatrolPoint(); } // 辅助方法:设置下一个巡逻点 void SetNextPatrolPoint() { if (patrolPoints.Count == 0) return; _agent.SetDestination(patrolPoints[_currentPatrolIndex].position); _currentPatrolIndex = (_currentPatrolIndex + 1) % patrolPoints.Count; // 循环索引 } void StartWaiting() { _isWaiting = true; _waitCounter = waitTimeAtPoint; } // 核心感知方法:判断敌人是否“看到”玩家 bool CanSeePlayer() { if (playerTarget == null) return false; Vector3 directionToPlayer = playerTarget.position - transform.position; float distanceToPlayer = directionToPlayer.magnitude; // 1. 距离检查 if (distanceToPlayer > sightRange) return false; // 2. 视野角度检查 float angle = Vector3.Angle(transform.forward, directionToPlayer); if (angle > fieldOfViewAngle / 2) return false; // 3. 射线检测,排除墙壁遮挡 RaycastHit hit; if (Physics.Raycast(transform.position + Vector3.up * 0.5f, // 从胸口高度发射 directionToPlayer.normalized, out hit, sightRange)) { if (hit.transform == playerTarget || hit.transform.IsChildOf(playerTarget)) { return true; } } return false; } // 状态切换方法 void SwitchState(AIState newState) { // 退出当前状态的清理工作(如果有) OnExitState(_currentState); _currentState = newState; Debug.Log($"{gameObject.name} state changed to: {_currentState}"); // 进入新状态的初始化工作 OnEnterState(newState); } void OnEnterState(AIState state) { switch (state) { case AIState.Patrol: _agent.isStopped = false; _agent.stoppingDistance = 0.5f; break; case AIState.Chase: _agent.isStopped = false; break; case AIState.Return: _agent.isStopped = false; break; } } void OnExitState(AIState state) { // 例如,离开Chase状态时,可以停止播放追击音效 } // 可选:更新Animator参数 void UpdateAnimation() { if (_animator != null) { // 根据Agent的速度设置动画的Speed参数 _animator.SetFloat("Speed", _agent.velocity.magnitude); // 可以根据状态设置不同的动画Bool,如“IsChasing” _animator.SetBool("IsChasing", _currentState == AIState.Chase); } } // 在Scene视图中绘制调试信息,非常有用! void OnDrawGizmosSelected() { // 绘制视觉范围 Gizmos.color = Color.yellow; Gizmos.DrawWireSphere(transform.position, sightRange); // 绘制追踪范围 Gizmos.color = Color.red; Gizmos.DrawWireSphere(transform.position, chaseRange); // 绘制视野锥形 Vector3 leftBoundary = Quaternion.Euler(0, -fieldOfViewAngle / 2, 0) * transform.forward; Vector3 rightBoundary = Quaternion.Euler(0, fieldOfViewAngle / 2, 0) * transform.forward; Gizmos.color = Color.cyan; Gizmos.DrawRay(transform.position, leftBoundary * sightRange); Gizmos.DrawRay(transform.position, rightBoundary * sightRange); // 绘制最后已知位置 if (_hasLastKnownPosition) { Gizmos.color = Color.magenta; Gizmos.DrawSphere(_lastKnownPlayerPosition, 0.5f); Gizmos.DrawLine(transform.position, _lastKnownPlayerPosition); } } }代码核心解析与技巧:
- 感知系统(
CanSeePlayer方法):这是AI的“眼睛”。我们做了三层过滤:距离、角度、物理射线。Vector3.up * 0.5f是为了从角色腰部高度发射射线,更符合视觉逻辑。射线检测是性能消耗点,对于大量敌人,可以考虑使用Physics.SphereCast或分帧检测进行优化。 - 状态切换的粒度:注意在
CheckStateTransitions中,从Chase切换到Return的条件是“看不见且超出chaseRange”。如果只是看不见但距离很近,敌人会继续朝_lastKnownPlayerPosition移动,模拟一种“搜寻”行为,这比立刻放弃追踪要真实得多。 NavMeshAgent的remainingDistance与pathPending:在判断是否到达目的地时,必须同时检查!agent.pathPending和agent.remainingDistance <= agent.stoppingDistance。因为SetDestination是异步的,路径计算需要时间,在计算完成前remainingDistance可能不准确。- 调试可视化(
OnDrawGizmosSelected):这是开发AI时的神器。它能让你在Scene视图中直观地看到敌人的视觉范围、视野角度和最后已知位置,极大方便了参数调整和逻辑调试。
6. 参数调优与高级技巧
一套代码写出来只是开始,让AI行为显得“聪明”和“自然”,全靠参数调优和一些细节处理。
6.1 关键参数调优指南
NavMeshAgent.radiusvsstoppingDistance:radius影响路径规划,确保敌人不会贴着墙走。stoppingDistance影响最终停下的位置。对于追踪玩家,stoppingDistance应等于你希望敌人保持的攻击距离。- 速度与角速度:
chaseSpeed应明显大于patrolSpeed,制造紧迫感。Angular Speed不宜过低,否则敌人在拐弯追你时会显得很笨拙,像在“漂移”。 sightRange与fieldOfViewAngle:这两个值共同决定了敌人的警觉性。一个负责外围哨兵(大范围、小角度),一个负责正面警卫(小范围、大角度)。通常,正面视野角度(fieldOfViewAngle)设置在90-120度,侧面和背后的感知可以通过其他触发器(Trigger Collider)或听觉系统来补充。chaseRange:这个值应该远大于sightRange。它决定了敌人的“执着”程度。一旦开始追踪,即使玩家暂时跑出视野,只要没超过这个范围,敌人就会尝试追到最后的已知位置。这避免了玩家利用一个拐角就轻易甩掉敌人的情况。
6.2 行为自然化技巧
- 随机化巡逻等待时间:不要使用固定的
waitTimeAtPoint。可以改为Random.Range(minWaitTime, maxWaitTime),让敌人的停顿时间有变化,更不易被玩家摸清规律。 - 巡逻点随机顺序:除了顺序巡逻,可以增加一个“随机巡逻”模式。在
SetNextPatrolPoint中,不采用循环递增,而是从列表中随机选择一个非当前点的目标。 - 丢失目标后的“困惑”行为:在
Return状态中,敌人到达_lastKnownPlayerPosition后,不要立刻直线返回巡逻点。可以让他原地旋转360度看看(播放一个搜寻动画),或者以该点为圆心,在一个小范围内随机走动几步,模拟“刚才明明在这里”的困惑感,然后再回归巡逻。我们在代码中使用Invoke延迟2秒切换状态,就是一种简单的实现。 - 分层追迹速度:不要让追踪速度一成不变。可以实现:当玩家在视野内时,用最大速度
chaseSpeed;当玩家刚离开视野,但还在chaseRange内时,速度降为chaseSpeed * 0.7,模拟一种不确定的搜寻状态。
6.3 性能优化考量
- 感知系统分帧更新:如果你的场景中有几十上百个敌人,每帧每个敌人都执行一次
CanSeePlayer(内含Raycast)将是巨大的性能开销。一个标准的优化方案是:将敌人分组,每帧只更新其中一部分的感知状态。例如,使用一个管理器,在Update中按索引轮流更新敌人,确保每个敌人每秒更新感知数次(如5-10次),而不是每秒60次。 - 禁用远处敌人的NavMeshAgent:对于距离玩家非常远、根本不可能与玩家交互的敌人,可以直接禁用其
NavMeshAgent组件和EnemyAI脚本,等到玩家进入一定范围后再启用。这能节省大量的路径计算资源。 - 使用
NavMeshAgent.autoRepath:这个属性默认为true,当路径被动态障碍物(如其他移动的NPC或玩家打开的们)阻挡时,Agent会自动重新计算路径。在大多数情况下保持开启即可。
7. 常见问题与排查实录
即使代码逻辑正确,在实际运行中还是会遇到各种奇怪的问题。下面是我在项目中踩过的一些坑和解决方案。
| 问题现象 | 可能原因 | 排查与解决方案 |
|---|---|---|
| 敌人原地抖动或打转 | 1. 目标点设置在NavMesh边缘或不可行走区域。 2. stoppingDistance设置过小,Agent在试图精确到达一个无法到达的点。3. 两个路径点距离太近,且 stoppingDistance大于其间距。 | 1. 在Scene视图的Navigation窗口显示NavMesh,确保所有目标点都在蓝色区域内。 2. 适当增大 stoppingDistance,或检查目标点坐标的Y值是否与Agent所在平面一致。3. 检查路径点位置,或使用 Debug.DrawLine绘制路径。 |
| 敌人不追踪玩家,或追踪一下又停下 | 1.CanSeePlayer方法条件太苛刻,玩家短暂离开视野就立即丢失目标。2. chaseRange设置过小,玩家稍微跑远就触发返回。3. 状态切换逻辑有误,在 Chase状态的Update中错误地调用了CheckStateTransitions,导致刚切到Chase又被切走。 | 1. 使用OnDrawGizmosSelected可视化视野范围,确认玩家是否真的在范围内。可以临时放宽条件测试。2. 适当增大 chaseRange,或引入一个“丢失目标计时器”,短暂丢失后不立即切换状态。3. 仔细检查状态机切换条件,特别是 Chase切换到Return的逻辑。添加详细的Debug.Log输出状态和切换原因。 |
| 敌人穿墙或掉下悬崖 | 1. NavMesh烘焙不正确,墙壁或悬崖边没有被标记为障碍。 2. 障碍物未标记为 Navigation Static。3. Agent的 Base Offset或角色碰撞体设置不当。 | 1. 重新检查并烘焙NavMesh,确保所有障碍物下方都有清晰的蓝色网格断开。 2. 确认所有静态障碍物的 Navigation Static已勾选。3. 检查Agent组件的 Base Offset(Y轴偏移),确保其与角色模型底部对齐。检查角色是否拥有合适的碰撞体(如Capsule Collider)。 |
| 多个敌人追踪时堆叠在一起 | Agent之间没有避让逻辑。 | NavMeshAgent本身具有简单的避障能力,但效果有限。对于密集群体,需要考虑:1. 使用Unity的AI Navigation Obstacle组件( NavMeshObstacle)并设置为动态避障。2. 在代码中实现简单的分离力(Separation Force):每个Agent检查周围一定距离内的其他Agent,并施加一个远离的力。 3. 使用更高级的第三方AI插件,如A* Pathfinding Project或RAIN AI,它们内置了更完善的群体移动和避障算法。 |
| 帧率下降,尤其是敌人多的时候 | 1. 每帧所有敌人都进行昂贵的感知计算(如射线检测)。 2. 复杂的场景导致NavMesh路径计算负担重。 3. 敌人数量太多。 | 1. 实现感知分帧更新,如前文所述。 2. 优化NavMesh:减少网格精度(在Bake面板调整 Voxel Size和Cell Size),但会降低路径精细度。3. 实现LOD(Level of Detail)系统:远离摄像机的敌人使用更简单的AI逻辑或更低的更新频率。 4. 考虑使用对象池管理敌人,而非频繁实例化/销毁。 |
一个典型的调试流程: 当敌人行为异常时,我首先会打开OnDrawGizmosSelected的视觉辅助,确认感知范围是否正确。然后,在SwitchState方法里加入Debug.Log,打印出每次状态切换的原因和时间。接着,在Update函数里,我会打印当前的目标点坐标和NavMeshAgent的pathStatus,看看路径计算是否成功。90%的问题都能通过这三步定位到。
8. 功能扩展与思路启发
基础的巡逻与追踪系统搭建完成后,你可以以此为骨架,添加更多血肉,让敌人AI更具个性和挑战性。
- 引入听觉系统:除了视觉,敌人还可以有“听觉”。为玩家脚步、枪声等添加一个发出噪音的脚本。在敌人AI中,增加一个
HearSound(Vector3 soundPosition)方法。当噪音源在一定距离内触发时,即使玩家不在视野内,敌人也会将_lastKnownPlayerPosition设置为声源位置,并进入Return或Chase状态(取决于声音大小)。这能让玩家体验“潜行”玩法。 - 增加攻击状态:在
ChaseState中,当_agent.remainingDistance <= attackRange时,切换到AttackState。在攻击状态下,停止移动,播放攻击动画,并调用伤害逻辑。攻击结束后,根据玩家是否还在视野/范围内,决定返回Chase还是Patrol。 - 组队与通信:实现一个简单的“警报”系统。当一名敌人发现玩家后,可以通知一定范围内的其他同伴(通过
Physics.OverlapSphere查找同类)。接收到警报的敌人,即使没看到玩家,也会直接朝玩家最后已知位置或警报发起者的位置移动。这能瞬间提升游戏的紧张感。 - 差异化AI:不要所有敌人都用同一套参数。可以创建不同的ScriptableObject作为“AI配置资产”,为“敏捷型”、“坦克型”、“侦察兵型”敌人配置不同的速度、视野、追踪距离等。这样能丰富敌人的种类和行为。
- 与动画系统深度集成:目前我们只是简单设置了
Speed参数。你可以做得更细:根据_agent.velocity的大小和方向,在Animator Controller中混合“走”、“跑”、“急转弯”等动画。还可以根据当前状态,播放不同的姿态动画(如巡逻时放松,追踪时身体前倾)。
这套基于NavMeshAgent和状态机的AI框架,其优势在于清晰的逻辑和良好的扩展性。它可能不是性能极致或行为最复杂的解决方案,但对于绝大多数中小型Unity项目来说,它提供了在功能、性能和开发效率之间一个极佳的平衡点。从实现第一个能巡逻和追你的“傻”敌人开始,逐步为他添加听觉、攻击、协作等能力,这个过程本身就是游戏开发中最有乐趣的环节之一。
