Unity游戏AI开发:基于有限状态机实现敌人巡逻追踪攻击系统

发布时间:2026/7/9 21:43:30
Unity游戏AI开发:基于有限状态机实现敌人巡逻追踪攻击系统 1. 项目概述与核心价值在游戏开发中尤其是动作、射击、RPG等类型一个行为逻辑清晰、反应灵敏的敌人AI是提升玩家沉浸感的关键。很多新手开发者甚至是有一定经验的同行在初次接触AI逻辑时常常会陷入“if-else地狱”——用一堆嵌套的条件判断来控制敌人的行为比如“如果看到玩家就追如果距离够近就攻击如果受伤了就逃跑……”。代码写起来费劲改起来更是牵一发而动全身调试起来更是噩梦。这正是我们今天要解决的问题。这个项目就是带你用Unity引擎从零开始构建一个基于**有限状态机Finite State Machine, FSM**的敌人AI系统。我们将实现一个经典的“巡逻-追踪-攻击”行为循环。这不仅仅是写几行代码让敌人动起来而是教你一套在工业级游戏开发中广泛应用的、可维护、可扩展的架构设计思想。通过手把手的实战你将理解如何将复杂的AI行为拆解成独立的“状态”每个状态只关心自己该做什么状态之间的切换清晰明了。最终你会得到一个行为自然、代码整洁的敌人并且掌握了能够复用到任何复杂AI场景中的核心方法论。2. 有限状态机FSM设计思路解析在开始写代码之前我们必须先把设计思路理清楚。有限状态机是一种数学模型也是软件工程中常用的设计模式它特别适合用来描述那些拥有有限个状态、并且会根据特定条件在这些状态间切换的系统。2.1 为什么是状态机而不是一堆if-else想象一下你要控制一个智能灯泡。最简单的写法可能是void Update() { if (有人移动 环境光暗) { 开灯 } else if (没人 灯已开超过10分钟) { 关灯 } else if (遥控器按下) { 切换颜色 } // ... 更多的else if }当逻辑变复杂比如加入定时、光线传感器、声音触发、不同场景模式这段代码会迅速膨胀难以阅读和维护。添加一个新功能比如“闪烁报警”状态可能会影响到所有其他条件的判断。而状态机的思路是定义状态关闭、开启、报警闪烁。定义切换条件从“关闭”到“开启”的条件是“有人且光暗”从“开启”到“关闭”的条件是“无人超时”从任何状态到“报警闪烁”的条件是“烟雾传感器触发”。每个状态独立在“开启”状态下只关心维持亮度、检查超时条件在“报警闪烁”状态下只关心控制灯泡闪烁频率、检查报警是否解除。这样一来每个状态都是一个独立的模块职责单一。增加新状态比如“彩色渐变”只需要新增一个状态类并定义它如何进入、退出、更新以及从其他状态切换到它的条件不会搅乱原有逻辑。这就是状态机在管理复杂逻辑时的巨大优势。2.2 我们的敌人AI状态机设计对应到我们的敌人AI我们首先定义三个核心状态巡逻状态PatrolState敌人在预设的路径点之间移动。核心任务是寻路到下一个点并在到达后切换目标点。同时它需要持续检测是否“看到”了玩家。追踪状态ChaseState当敌人发现玩家后进入此状态。核心任务是持续向玩家的当前位置移动。它需要检测1. 是否丢失了玩家玩家跑出视野或距离过远2. 是否进入了攻击范围。攻击状态AttackState当敌人足够接近玩家时进入此状态。核心任务是执行攻击动作播放动画、产生伤害判定等并在攻击间隔或完成后判断玩家是否仍在攻击范围内以决定是继续攻击还是切回追踪。状态之间的切换条件如下图所示概念图[巡逻状态] | | (条件检测到玩家进入视野) v [追踪状态] | ^ | (条件玩家进入攻击范围) | (条件玩家退出攻击范围但仍在视野) v | [攻击状态] ------------ | | (条件玩家丢失/超出追踪距离) v [巡逻状态] (或 [闲置状态])这个设计清晰地划分了职责和转换逻辑为我们的编码打下了坚实的基础。注意这里我们设计的是最简单的线性循环。在实际项目中你很可能需要加入“受伤状态”被攻击时硬直或后退、“死亡状态”播放死亡动画、移除碰撞体、“闲置状态”原地发呆或播放待机动画等。状态机的优势就在于你可以像搭积木一样把这些状态轻松地添加进来。3. 核心模块实现与代码详解接下来我们进入实战环节在Unity中一步步实现这个状态机。我们将采用基于继承的面向对象设计这是实现清晰状态机架构的经典方式。3.1 项目准备与基础组件首先创建一个新的Unity项目建议使用较新的LTS版本如2022.3。我们需要准备以下游戏对象玩家Player一个简单的胶囊体Capsule挂载CharacterController组件以便移动。为其创建一个Tag命名为“Player”方便敌人识别。敌人Enemy另一个胶囊体我们将为其创建AI逻辑。巡逻路径点在场景中创建几个空物体Empty GameObject命名为“Waypoint_1”、“Waypoint_2”等将它们摆放在敌人需要巡逻的路径上。将这些路径点拖拽到一个数组或列表中供敌人使用。为敌人对象添加必要的组件NavMeshAgent这是Unity内置的导航组件负责路径计算和移动。在Window - AI - Navigation 中烘焙导航网格NavMesh确保敌人和玩家所在的平面被烘焙进去。CapsuleCollider或SphereCollider用于物理碰撞和作为视觉检测的触发器。一个我们即将创建的脚本例如EnemyAI_FSM作为敌人的主控制器。3.2 构建状态机基类与状态接口这是整个系统的骨架决定了状态如何被定义和切换。// IEnemyState.cs - 状态接口 public interface IEnemyState { // 进入该状态时调用一次 void EnterState(EnemyAI_FSM enemy); // 每帧更新该状态的逻辑 void UpdateState(EnemyAI_FSM enemy); // 退出该状态时调用一次 void ExitState(EnemyAI_FSM enemy); } // EnemyAI_FSM.cs - 状态机控制器主脚本 public class EnemyAI_FSM : MonoBehaviour { // 当前状态 private IEnemyState _currentState; // 状态实例缓存避免频繁创建 private DictionarySystem.Type, IEnemyState _stateCache new DictionarySystem.Type, IEnemyState(); // 公共属性供各个状态访问 public NavMeshAgent Agent { get; private set; } public Transform Player { get; private set; } public Transform[] PatrolWaypoints { get; set; } // 在Inspector中赋值 // 配置参数 public float sightRange 10f; // 视野范围 public float attackRange 2f; // 攻击范围 public float chaseStopDistance 1.5f; // 追踪时与玩家的停止距离 public float patrolSpeed 3.5f; public float chaseSpeed 5f; void Start() { Agent GetComponentNavMeshAgent(); // 通过Tag查找玩家确保场景中有Tag为Player的对象 Player GameObject.FindGameObjectWithTag(Player).transform; if (Player null) { Debug.LogError(未找到Tag为‘Player’的对象); } // 初始化状态缓存 _stateCache[typeof(PatrolState)] new PatrolState(); _stateCache[typeof(ChaseState)] new ChaseState(); _stateCache[typeof(AttackState)] new AttackState(); // 初始状态设置为巡逻 SwitchState(typeof(PatrolState)); } void Update() { // 每帧更新当前状态 _currentState?.UpdateState(this); } // 状态切换的核心方法 public void SwitchState(System.Type newStateType) { if (!_stateCache.ContainsKey(newStateType)) { Debug.LogError($状态 {newStateType} 未在缓存中注册); return; } // 退出旧状态 _currentState?.ExitState(this); // 切换状态引用 _currentState _stateCache[newStateType]; // 进入新状态 _currentState.EnterState(this); // 可选在控制台输出当前状态便于调试 Debug.Log($[EnemyAI] 切换到状态: {newStateType.Name}); } // 公共方法检查是否看到玩家基于距离和视线 public bool CanSeePlayer() { if (Player null) return false; float distanceToPlayer Vector3.Distance(transform.position, Player.position); if (distanceToPlayer sightRange) return false; // 射线检测避免隔墙“看见”玩家 Vector3 directionToPlayer (Player.position - transform.position).normalized; if (Physics.Raycast(transform.position Vector3.up, directionToPlayer, out RaycastHit hit, sightRange)) { if (hit.transform.CompareTag(Player)) { return true; } } return false; } // 公共方法检查玩家是否在攻击范围内 public bool IsPlayerInAttackRange() { if (Player null) return false; return Vector3.Distance(transform.position, Player.position) attackRange; } }代码解析与心得接口Interface的使用IEnemyState接口定义了所有状态类必须实现的三个方法。这强制了统一的结构是面向对象中“针对接口编程而非实现编程”原则的体现极大地提高了系统的灵活性和可扩展性。状态缓存在Start中初始化所有状态实例并缓存起来。在SwitchState时直接取用而不是每次切换都new一个新对象。这是一个重要的性能优化点特别是对于频繁切换的状态。主控制器职责EnemyAI_FSM类是整个AI的大脑。它持有所有状态共享的数据如Agent、Player引用、参数和公共方法如CanSeePlayer。状态类通过传入的enemy参数来访问这些资源避免了单例或全局变量的滥用耦合度更低。视觉检测CanSeePlayer方法结合了距离判断和射线检测。单纯的距离判断会导致敌人能“穿墙”发现玩家加入射线检测后只有真正在视线范围内无遮挡的玩家才会被发现更加真实。3.3 实现巡逻状态PatrolState巡逻状态是敌人的默认状态逻辑相对独立。// PatrolState.cs public class PatrolState : IEnemyState { private int _currentWaypointIndex 0; private bool _isWaiting false; private float _waitTimer 0f; private const float WAIT_TIME_AT_WAYPOINT 1f; // 在每个路径点等待1秒 public void EnterState(EnemyAI_FSM enemy) { // 进入巡逻状态设置移动速度 enemy.Agent.speed enemy.patrolSpeed; enemy.Agent.stoppingDistance 0.2f; // 靠近路径点即可 // 如果路径点数组有效则设置第一个目标 if (enemy.PatrolWaypoints ! null enemy.PatrolWaypoints.Length 0) { SetNextWaypoint(enemy); } else { Debug.LogWarning(敌人未设置巡逻路径点); } } public void UpdateState(EnemyAI_FSM enemy) { // 首要任务检查是否发现玩家 if (enemy.CanSeePlayer()) { enemy.SwitchState(typeof(ChaseState)); return; // 发现玩家立即切换状态后续逻辑不再执行 } // 巡逻逻辑 if (_isWaiting) { // 等待计时 _waitTimer - Time.deltaTime; if (_waitTimer 0) { _isWaiting false; SetNextWaypoint(enemy); } } else { // 检查是否到达当前路径点 if (!enemy.Agent.pathPending enemy.Agent.remainingDistance enemy.Agent.stoppingDistance) { if (!enemy.Agent.hasPath || enemy.Agent.velocity.sqrMagnitude 0f) { // 到达路径点开始等待 StartWaiting(); } } } } public void ExitState(EnemyAI_FSM enemy) { // 退出巡逻状态时可以停止移动或清理 // enemy.Agent.ResetPath(); // 可选立即停止寻路 _isWaiting false; } private void SetNextWaypoint(EnemyAI_FSM enemy) { if (enemy.PatrolWaypoints.Length 0) return; // 设置下一个路径点为导航目标 enemy.Agent.SetDestination(enemy.PatrolWaypoints[_currentWaypointIndex].position); // 更新索引循环遍历 _currentWaypointIndex (_currentWaypointIndex 1) % enemy.PatrolWaypoints.Length; } private void StartWaiting() { _isWaiting true; _waitTimer WAIT_TIME_AT_WAYPOINT; // 可以在这里触发一个“原地张望”的动画 } }实操要点与避坑指南路径点循环使用取模运算%来实现路径点的循环遍历这是一个简洁高效的技巧。到达判定不要只依赖remainingDistance stoppingDistance。因为NavMeshAgent在计算路径或遇到微小障碍时remainingDistance可能瞬间满足条件但并非真正到达。加上!agent.hasPath或agent.velocity.sqrMagnitude 0f的判断会更稳健。等待机制在路径点增加一个短暂的等待时间可以让敌人的巡逻行为看起来更自然而不是一个精确的“点到点”机器人。你可以将WAIT_TIME_AT_WAYPOINT做成一个可配置的变量甚至为每个路径点设置不同的等待时间。状态切换的及时性在UpdateState的最开始就检查切换条件这里是CanSeePlayer。一旦条件满足立即调用SwitchState并return确保旧状态的逻辑不会在本帧继续执行避免状态逻辑冲突。3.4 实现追踪状态ChaseState追踪状态是响应性最强的状态需要持续更新目标。// ChaseState.cs public class ChaseState : IEnemyState { // 丢失玩家计时器 private float _playerLostTimer 0f; private const float MAX_LOST_TIME 2f; // 丢失玩家2秒后放弃追踪 public void EnterState(EnemyAI_FSM enemy) { // 进入追逐状态提高速度 enemy.Agent.speed enemy.chaseSpeed; enemy.Agent.stoppingDistance enemy.chaseStopDistance; // 设置一个较小的停止距离为攻击做准备 Debug.Log(敌人开始追击玩家); } public void UpdateState(EnemyAI_FSM enemy) { // 核心持续将玩家位置设为导航目标 if (enemy.Player ! null) { enemy.Agent.SetDestination(enemy.Player.position); } // 状态切换判断 // 1. 优先判断是否进入攻击范围 if (enemy.IsPlayerInAttackRange()) { enemy.SwitchState(typeof(AttackState)); return; } // 2. 判断是否丢失玩家 if (!enemy.CanSeePlayer()) { _playerLostTimer Time.deltaTime; if (_playerLostTimer MAX_LOST_TIME) { // 丢失玩家超时返回巡逻 enemy.SwitchState(typeof(PatrolState)); return; } } else { // 如果能看到玩家重置丢失计时器 _playerLostTimer 0f; } // 3. (可选) 增加一个最大追逐距离限制防止敌人被引到天涯海角 // if (Vector3.Distance(enemy.transform.position, initialChasePosition) 50f) { ... } } public void ExitState(EnemyAI_FSM enemy) { // 退出时重置计时器 _playerLostTimer 0f; // 可以在这里让敌人发出一个疑惑的音效或播放一个“寻找”的动画 } }经验技巧停止距离stoppingDistance在追踪状态我们将stoppingDistance设置为略小于attackRange的值例如攻击范围2停止距离1.5。这样当敌人接近到1.5米时就会停止移动为接下来的攻击动作留出空间防止敌人“贴脸”移动导致穿模或行为怪异。丢失玩家的延迟判断使用_playerLostTimer是一个关键技巧。如果一帧没看到玩家就立刻切回巡逻敌人会显得非常“神经质”在墙角或障碍物边缘频繁切换状态。给予一个短暂的缓冲时间如2秒敌人会在丢失目标后继续朝最后已知位置移动或短暂寻找行为更加拟人。性能考量每帧都调用SetDestination可能会带来一定的性能开销尤其是在大量敌人同时追踪时。一个优化方案是只有当玩家位置移动超过一定阈值如0.5米时才重新设置目标。这可以通过比较Player.position和上一次设置的目标点来实现。3.5 实现攻击状态AttackState攻击状态通常与动画和游戏逻辑如伤害计算紧密关联。// AttackState.cs public class AttackState : IEnemyState { private float _attackCooldownTimer 0f; public float attackInterval 1.5f; // 攻击间隔可考虑移到EnemyAI_FSM中配置 private bool _isAttacking false; public void EnterState(EnemyAI_FSM enemy) { // 进入攻击状态停止移动 enemy.Agent.isStopped true; // 完全停止导航代理 _attackCooldownTimer 0f; // 重置冷却可以立即发动第一次攻击 Debug.Log(敌人进入攻击状态); // 这里可以触发攻击开始的动画或音效 } public void UpdateState(EnemyAI_FSM enemy) { // 确保面朝玩家增强表现力 if (enemy.Player ! null) { Vector3 lookDirection (enemy.Player.position - enemy.transform.position).normalized; lookDirection.y 0; // 保持Y轴不变仅水平旋转 if (lookDirection ! Vector3.zero) { enemy.transform.rotation Quaternion.Slerp(enemy.transform.rotation, Quaternion.LookRotation(lookDirection), Time.deltaTime * 10f); } } // 攻击冷却计时 _attackCooldownTimer - Time.deltaTime; // 状态切换判断玩家是否离开了攻击范围 if (!enemy.IsPlayerInAttackRange()) { // 玩家跑出范围切回追踪 enemy.SwitchState(typeof(ChaseState)); return; } // 攻击逻辑冷却完毕且不在攻击动画中则执行攻击 if (_attackCooldownTimer 0 !_isAttacking) { PerformAttack(enemy); _attackCooldownTimer attackInterval; // 重置冷却 } } public void ExitState(EnemyAI_FSM enemy) { // 退出攻击状态恢复移动 enemy.Agent.isStopped false; _isAttacking false; // 这里可以强制停止攻击动画 } private void PerformAttack(EnemyAI_FSM enemy) { _isAttacking true; // 1. 触发攻击动画 // Animator anim enemy.GetComponentAnimator(); // anim.SetTrigger(Attack); // 2. 实际伤害判定通常通过动画事件触发这里用简单延迟模拟 Debug.Log($敌人对玩家造成了伤害); // 例如调用一个协程在动画的某个时刻进行伤害判定 // enemy.StartCoroutine(DoDamageAfterDelay(0.5f)); // 3. 攻击动作结束后重置标志位通常由动画事件调用 // 这里为了简化我们用一个简单的延迟模拟 enemy.StartCoroutine(ResetAttackFlag(1.0f)); } private System.Collections.IEnumerator ResetAttackFlag(float delay) { yield return new WaitForSeconds(delay); _isAttacking false; } }核心细节与扩展停止导航 vs 停止速度使用enemy.Agent.isStopped true来彻底停止NavMeshAgent的路径计算和移动这比设置速度为0更彻底。退出状态时务必设为false。攻击动画与逻辑解耦在实际项目中攻击动作通常由动画控制器Animator管理。我们会在PerformAttack中设置一个Animator触发器如Attack然后由动画状态机播放攻击动画。伤害判定不应该在Update中直接进行而应该通过**动画事件Animation Event**来触发。在攻击动画的特定帧如武器挥到最高点时添加事件调用一个如OnAttackHit的方法来进行射线检测或触发碰撞体这样能保证伤害时机与视觉表现同步。面向玩家在UpdateState中让敌人缓慢转向玩家这是一个提升体验的细节。使用Quaternion.Slerp进行平滑插值旋转看起来更自然。攻击冷却与状态锁_isAttacking标志位用于防止在攻击动画播放期间再次触发攻击逻辑。这是一种简单的“状态锁”确保攻击动作的完整性。4. 调试、优化与常见问题排查即使代码写完了敌人也可能出现各种怪异行为。下面是一些实战中必然会遇到的问题和解决方法。4.1 视觉与感知调试敌人“看不见”或“不该看见却看见了”是最常见的问题。绘制调试图形在EnemyAI_FSM的OnDrawGizmosSelected方法中添加代码在Scene视图中可视化感知范围。void OnDrawGizmosSelected() { // 用红色线框球体表示攻击范围 Gizmos.color Color.red; Gizmos.DrawWireSphere(transform.position, attackRange); // 用黄色线框球体表示视野范围 Gizmos.color Color.yellow; Gizmos.DrawWireSphere(transform.position, sightRange); // 绘制朝向玩家的视线如果正在追踪/攻击 if (Player ! null (currentState is ChaseState || currentState is AttackState)) { Gizmos.color Color.green; Gizmos.DrawLine(transform.position Vector3.up, Player.position); } }射线检测调试在CanSeePlayer方法中无论是否检测到都用Debug.DrawRay绘制出射线可以清晰看到视线是否被阻挡。Debug.DrawRay(transform.position Vector3.up, directionToPlayer * sightRange, hit.collider ! null hit.transform.CompareTag(Player) ? Color.green : Color.red);4.2 导航与移动问题敌人卡住或抖动检查导航网格NavMesh确保敌人和玩家所在区域已正确烘焙且坡度、台阶高度等参数设置合理。在复杂地形中可能需要手动标记不可行走区域或设置跳跃链接Off-Mesh Link。调整Agent参数在NavMeshAgent组件中Radius半径和Height高度需要与敌人模型的碰撞体匹配。Steering下的Speed、Angular Speed角速度和Acceleration加速度会影响移动的流畅度。Obstacle Avoidance避障质量设置过低或Priority优先级冲突可能导致多个敌人互相卡住。停止距离Stopping Distance的玄机这个参数对状态切换流畅度影响巨大。如果stoppingDistance设置过大敌人可能永远无法满足“到达”路径点或“进入攻击范围”的条件。需要根据模型大小和游戏感觉精细调整。4.3 状态逻辑与切换问题状态频繁抖动Z-fighting例如敌人在攻击和追踪边缘来回切换。这是因为IsPlayerInAttackRange()的判断每帧都在进行而玩家位置可能恰好在边界波动。解决方案引入“迟滞”或“缓冲”机制。例如进入攻击范围需要 2m但退出攻击范围需要 2.5m。这为状态切换提供了一个重叠区域避免了边界抖动。状态切换后旧逻辑残留确保在SwitchState中先调用旧状态的ExitState再调用新状态的EnterState。并且在状态Update中一旦触发切换条件并调用SwitchState后立即return防止本帧内执行剩余代码。4.4 性能优化备忘录当场景中有大量敌人时AI计算可能成为性能瓶颈。感知更新频率不是每个敌人都需要每帧检测玩家。可以为CanSeePlayer这样的感知函数设置一个更新间隔如0.2秒一次使用InvokeRepeating或一个计时器来控制。这能显著减少每帧的射线检测和距离计算次数。距离计算优化Vector3.Distance内部会进行开方运算开销较大。在只需要比较距离大小时如判断是否大于视野范围可以使用sqrMagnitude平方距离进行比较避免开方。例如if ((playerPos - myPos).sqrMagnitude sightRange * sightRange)。对象查找优化在Start或Awake中通过FindGameObjectWithTag查找玩家并缓存引用绝对不要在Update中调用Find系列方法。5. 超越基础状态机的进阶扩展一个健壮的AI系统绝不会只有三个状态。掌握了基础框架后你可以轻松地扩展它。5.1 新增“受伤”与“死亡”状态受伤状态HurtState当敌人受到玩家攻击时从当前状态强制切换到HurtState。在这个状态里播放受击动画可能有一个短暂的硬直时间无法行动并扣减生命值。硬直结束后根据情况切换回之前的状态如追踪或进入死亡状态。关键点需要一个机制来中断当前状态并记录它以便受伤结束后能回去。可以在EnemyAI_FSM中增加一个IEnemyState _previousState的字段。死亡状态DeadState当生命值0时进入。播放死亡动画禁用NavMeshAgent和碰撞体一段时间后销毁对象或触发死亡事件如掉落物品。关键点进入死亡状态后应禁用所有其他状态切换的检测。5.2 使用动画状态机Animator驱动行为我们目前的状态机控制的是“逻辑状态”。而模型的“动画状态”待机、行走、奔跑、攻击、受伤、死亡通常由Unity的Animator Controller动画状态机来管理。两者需要同步。简单同步在每个逻辑状态的EnterState和UpdateState中设置Animator的参数Parameters。例如在PatrolState.EnterState中设置animator.SetBool(IsWalking, true)在ChaseState中设置animator.SetBool(IsRunning, true)等。高级同步使用动画事件。在攻击动画的特定帧触发事件调用逻辑脚本中的方法来执行伤害判定。这样逻辑和表现完美结合。5.3 引入行为树Behavior Tree的思考对于极其复杂、需要大量条件分支和并行行为的AI比如一个具有巡逻、警戒、呼叫同伴、寻找掩体、包抄等多种行为的智能小队有限状态机可能会变得难以维护。这时行为树是更高级的选择。行为树 vs 状态机状态机是“事件驱动”的关注“状态”和“转换”行为树是“任务导向”的关注“行为”和“决策”。行为树通过节点选择、序列、并行、条件、动作等组成一棵树从根节点开始依次评估和执行更适合描述复杂的决策流程。如何选择对于大多数中小型游戏项目尤其是行为模式相对固定、状态数量有限的敌人如我们实现的巡逻-追踪-攻击有限状态机完全够用且更直观。当你的AI逻辑需要大量的“if-else-if”或者需要频繁添加新的、可能并行执行的行为时再考虑引入行为树库如Unity Asset Store中的Node Canvas, Behavior Designer等。这套基于继承的状态机框架其价值在于它提供了一种清晰、可维护的代码组织方式。当你下次需要为一个NPC添加“对话”、“交易”、“逃跑”等状态时你只需要新建对应的状态类实现接口并在适当的地方添加切换条件即可。原有的巡逻、追踪、攻击代码完全不需要改动这就是良好架构带来的力量。