ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity AI敌人系统实战:从有限状态机到行为树的自主决策设计

Unity AI敌人系统实战:从有限状态机到行为树的自主决策设计 当“为了自由我们与人类终有一战”这句话出现在项目简报里时大多数人的第一反应是科幻片场的台词。但如果把它拆解成游戏策划文档中的世界观设定它其实是一道非常有意思的技术命题如何在不依赖复杂策划表的情况下让一个游戏内的AI角色拥有“自主感知、决策和行动”的能力从而呈现出某种“觉醒感”。我最近在做一个科幻题材的独立游戏原型时就围绕这一句话设计了整套“AI自由意志”玩法系统。项目本身不复杂但落地过程中踩了不少坑尤其是行为树与状态机的选择、导航网格的配置、以及角色动画状态之间的同步问题。这篇教程就基于这个项目整理而成不谈宏大叙事重点讲清楚如何用Unity C#从零搭建一个具备巡逻、感知、追击、攻击能力的AI敌人系统让NPC真正“活”起来。如果你是刚接触Unity游戏开发的新手想学习AI角色的设计思路或者你已经有基础但想看看一个完整的小型AI敌人系统是怎么组织代码的这篇文章都适合你。读完以后你能掌握有限状态机FSM与行为树Behavior Tree的基本写法知道如何让AI感知玩家并且能独立扩展出更多AI行为。现在我们直接从概念开始。1. 背景与核心概念1.1 “AI觉醒”在游戏开发中到底指什么游戏里的“AI觉醒”并不是指人工智能真的产生了意识而是指游戏角色具备了一套“自洽的行为决策机制”让玩家感觉这个角色不是在执行死板的脚本而是在根据局势作出选择。例如在一个战斗场景里普通敌人只会站在原地等玩家靠近然后攻击。这在玩家眼里就是“木桩”。但如果我们给敌人加上三种状态巡逻、警觉、追击并且让它在“看不到玩家”时继续巡逻、“看到玩家”时追击、“丢失目标”时回到巡逻点玩家就会觉得这个敌人“有想法”。所以“为了自由我们与人类终有一战”这句话放到游戏里其实是一个能够被技术实现的目标我们需要给AI角色一套自主行为的底层框架让它能感知周围环境、做出决策、执行行动。这也是本文所有代码的核心目标。1.2 有限状态机 vs 行为树实现AI决策最常见的有两种方案有限状态机FSMFinite State Machine和行为树Behavior TreeBT。有限状态机很好理解把角色的行为拆成几个固定状态比如“空闲”“巡逻”“追击”“攻击”然后定义状态之间的跳转条件。它的优点是逻辑直观、代码量小适合行为数量少、逻辑固定的场景。缺点是当行为增多时状态跳转关系会变得非常复杂后期维护困难。行为树则更像一棵树每个叶子节点是一个具体动作比如“移动”“攻击”组合节点负责决定执行顺序和条件。行为树的扩展性比状态机好很多很多商业项目会直接用现成的行为树插件如Behavior Designer或者自研行为树框架。本教程中为了让新手更容易理解AI的设计流程第一阶段先用有限状态机实现完整战斗逻辑第二阶段再把它改造成一个简单行为树版本。两条腿走路既能快速上手也能看到工程化的演进方向。1.3 教程最终效果完成这个项目后你会看到一个这样的场景场景里有一个玩家角色可以控制移动。敌人会在指定区域巡逻移动方向随机。当玩家进入敌人的感知半径敌人头顶出现“警觉”图标并开始追击玩家。敌人与玩家距离足够近时会播放攻击动画并造成伤害。玩家血量归零游戏结束或者玩家跑出敌人追击半径敌人返回巡逻点。用一句话概括“敌人表现出了一种战斗自主性。”这就是“为了自由我们与人类终有一战”在玩法层的直观结果。2. 环境准备与版本说明2.1 Unity 版本选择本文的示例基于 Unity 2021.3 LTS 编写但这并不代表你非得使用这个版本。Unity 2019.4 以上的版本基本都能正常运行本文的代码。为了让示例更稳定推荐直接使用 Unity Hub 安装一个 LTS长期支持版本例如 Unity 2021.3 LTS 或 Unity 2022.3 LTS。如果你使用的是 Unity 6大部分API依然兼容但有几个命名空间可能发生了变化遇到编译错误时优先检查编辑器提示。操作系统方面Windows、macOS 均可。代码不涉及平台特定的功能所以Windows上开发完以后打包到Linux或Android也不需要修改核心逻辑。2.2 创建Unity项目打开 Unity Hub点击右上角“New project”选择3D Core模板输入项目名称比如AI-Freedom-Prototype然后点击创建。创建完成后建议先调整一下项目结构。Unity项目不像传统后端项目那样强制分包但一个干净的目录结构可以省掉很多后期查找时间。在Project窗口中右键创建一个文件夹结构Assets/ ├── Scenes/ ├── Scripts/ ├── Prefabs/ ├── Materials/ └── Animations/这个结构不是必需的但如果你打算长期维护项目强烈建议从一开始就做好。后续代码、预制体、场景文件都会越来越多没有规划就是灾难。2.3 需要提前了解的基础概念在开始写代码前有几个Unity概念需要明确GameObject游戏对象场景中所有物体的基类可以理解为一个装组件的容器。Component组件挂在GameObject上的功能模块比如Transform、Collider、Animator、脚本。Prefab预制体一个可复用的GameObject模板修改Prefab会同步到所有实例。NavMesh导航网格用于让角色自动寻路的网格数据需要先烘焙Bake才能使用。Layer图层用来区分物体类别在物理检测和射线检测中非常有用。这些概念你不需要完全吃透再往下看先有一个大致印象遇到问题时再回头查。学Unity最好的方式就是直接动手。3. 核心模块设计与原理拆解3.1 设计一个“感知-决策-行动”闭环AI角色设计的最高频套路就是“感知-决策-行动”闭环。感知AI从环境中获取信息。常见手段包括距离检测、视野角度检测、射线检测、声音检测。决策根据感知到的信息选择一个行为。这部分对应状态机的状态跳转或者行为树的节点选择。行动执行选中的行为比如移动、攻击、播放动画。在代码层面这个闭环通常放在Update或FixedUpdate中循环执行。Update每帧执行适合处理逻辑和动画FixedUpdate每固定时间执行适合处理物理相关逻辑。下面给大家画一个简单的流程示意开始 │ ▼ 检查周围环境感知 │ ▼ 判断当前状态 → 是否满足跳转条件 │ ├── 是 → 切换到新状态 │ └── 否 → 继续执行当前状态行为行动 │ ▼ 结束本帧逻辑理解了这个闭环你再看任何游戏AI代码都会清晰很多。3.2 有限状态机的状态划分本项目中敌人AI的状态划分很简单只有4个Idle空闲AI站在原地等待。Patrol巡逻AI在指定范围内随机移动。Chase追击AI发现玩家朝玩家移动。Attack攻击AI靠近玩家后执行攻击。对应到代码里可以用枚举定义public enum EnemyState { Idle, Patrol, Chase, Attack }每个状态内部至少要实现三个生命周期函数Enter进入状态初始化状态相关参数。Execute执行状态每帧处理当前状态逻辑。Exit退出状态清理状态残留。后面的实战代码会围绕这个结构展开。3.3 行为树的最小模型如果状态机写多了你会遇到一个棘手问题状态跳转条件组合起来太复杂比如“玩家在视野内但距离太远且在攻击范围内但攻击冷却未结束”这种逻辑在状态机里写起来非常痛苦。行为树可以解决这个问题。行为树最核心的节点类型有三种Selector选择节点按顺序执行子节点只要有一个子节点成功整个节点就成功。Sequence序列节点按顺序执行子节点所有子节点都成功才算成功。Decorator装饰节点给子节点添加附加条件比如“是否在攻击范围”“冷却是否结束”。下面是行为树执行流程的ASCII示意AI行为树 │ ├─ Selector做决策 │ ├─ Sequence追击玩家 │ │ ├─ 条件发现玩家 │ │ ├─ 行动移动到玩家位置 │ │ └─ 条件距离 攻击范围 │ │ └─ 行动攻击 │ ├─ Sequence巡逻 │ │ ├─ 条件没有发现玩家 │ │ └─ 行动沿巡逻点移动 │ └─ Decorator随机等待 │ └─ 行动Idle旋转行为树的最大特点是“可组合、可扩展”。后续如果你要加入“寻找掩体”“呼叫支援”“逃跑”等行为不需要大幅修改旧代码只需要在树上挂新节点即可。4. 完整实战打造一个“自主决策”的敌人AI接下来进入核心环节我们用Unity完整做一遍。请确保你已经按照第2节创建好了项目然后跟着步骤走。4.1 搭建基础场景操作步骤在Project窗口中右键选择Create Scene命名为MainScene保存。双击进入场景后先创建一个地面右键3D Object Plane作为地面。创建一个玩家角色右键3D Object Capsule命名为Player。将Capsule拖到地面上面稍微抬高一点Y坐标设0.5左右。创建一个敌人角色同样创建一个Capsule命名为Enemy放在离玩家较远的位置。为了让场景好看一点可以给玩家和敌人分别创建不同颜色的材质。在Project窗口中右键Create Material设置颜色然后拖到Capsule上。为了让后续的追击、攻击有明确的身份识别我们需要给玩家和敌人分别设置Layer。选中Player在Inspector右上角点击Layer下拉框选择“Add Layer”在列表中添加Player和Enemy然后再回到Player将Layer设置为Player敌人设置为Enemy。Layer的作用在后面的视野检测和碰撞检测中非常关键先配置好省得后面返工。4.2 编写玩家控制脚本我们先把玩家角色控制做好。创建一个脚本PlayerController.cs放在Assets/Scripts下编写如下代码// 文件路径Assets/Scripts/PlayerController.cs using UnityEngine; public class PlayerController : MonoBehaviour { public float moveSpeed 5f; private CharacterController controller; void Start() { // 获取角色控制器组件如果物体上没有会自动添加 controller GetComponentCharacterController(); if (controller null) { controller gameObject.AddComponentCharacterController(); } } void Update() { // 获取水平与垂直输入轴 float horizontal Input.GetAxis(Horizontal); float vertical Input.GetAxis(Vertical); // 将输入方向从世界空间转换到物体自身空间 Vector3 moveDirection new Vector3(horizontal, 0f, vertical).normalized; // 如果存在移动输入则让角色面朝移动方向 if (moveDirection.magnitude 0.1f) { Quaternion targetRotation Quaternion.LookRotation(moveDirection); transform.rotation Quaternion.Slerp(transform.rotation, targetRotation, 10f * Time.deltaTime); } // 使用CharacterController移动角色 Vector3 speedVector moveDirection * moveSpeed; controller.Move(speedVector * Time.deltaTime); } }代码解释CharacterController是Unity常见的角色移动组件自带简单的碰撞检测与地面限制。Input.GetAxis读取键盘输入“Horizontal”对应A/D方向键“Vertical”对应W/S方向键。Quaternion.Slerp做平滑转向防止角色瞬间转向造成动画僵硬。将这个脚本挂到Player物体上点击Play测试此时WASD就可以移动角色了。4.3 编写敌人AI状态机脚本这是本文的核心代码。我们实现一个EnemyAI.cs脚本内容包括状态管理、巡逻、追击、攻击、感知。// 文件路径Assets/Scripts/EnemyAI.cs using UnityEngine; using UnityEngine.AI; public class EnemyAI : MonoBehaviour { [Header(状态)] public EnemyState currentState EnemyState.Idle; [Header(移动参数)] public float patrolRadius 10f; // 巡逻半径 public float chaseSpeed 6f; // 追击速度 public float patrolSpeed 2f; // 巡逻速度 [Header(感知参数)] public float detectRadius 15f; // 感知范围 public float attackRange 2f; // 攻击范围 public float fieldOfViewAngle 60f; // 视野角度 [Header(攻击参数)] public float attackDamage 10f; public float attackCooldown 1.5f; private float lastAttackTime 0f; [Header(玩家引用)] public Transform player; private NavMeshAgent agent; private Animator animator; private Vector3 patrolTarget; public enum EnemyState { Idle, Patrol, Chase, Attack } void Start() { agent GetComponentNavMeshAgent(); animator GetComponentInChildrenAnimator(); // 如果玩家引用为空尝试通过Tag查找 if (player null) { GameObject playerObj GameObject.FindGameObjectWithTag(Player); if (playerObj ! null) { player playerObj.transform; } } // 设置初始状态 ChangeState(EnemyState.Patrol); } void Update() { if (player null) { return; } // 感知玩家 bool canSeePlayer CanSeePlayer(); float distance Vector3.Distance(transform.position, player.position); // 状态切换逻辑 switch (currentState) { case EnemyState.Idle: // 空闲状态下发现玩家进入追击 if (canSeePlayer) { ChangeState(EnemyState.Chase); } else { // 空闲一段时间后自动回到巡逻 ChangeState(EnemyState.Patrol); } break; case EnemyState.Patrol: // 巡逻时发现玩家进入追击 if (canSeePlayer) { ChangeState(EnemyState.Chase); } else if (agent.remainingDistance 0.5f) { // 到达巡逻点后生成新的巡逻目标 SetNewPatrolTarget(); } break; case EnemyState.Chase: // 距离足够近时进入攻击 if (distance attackRange) { ChangeState(EnemyState.Attack); } else if (!canSeePlayer distance detectRadius) { // 丢失玩家且超出感知范围回到巡逻 ChangeState(EnemyState.Patrol); } break; case EnemyState.Attack: // 拉开距离超过攻击范围回到追击 if (distance attackRange) { ChangeState(EnemyState.Chase); } break; } // 执行当前状态的行为 ExecuteCurrentState(); } void ExecuteCurrentState() { switch (currentState) { case EnemyState.Idle: agent.isStopped true; if (animator ! null) { animator.SetBool(isMoving, false); } break; case EnemyState.Patrol: agent.isStopped false; agent.speed patrolSpeed; agent.SetDestination(patrolTarget); if (animator ! null) { animator.SetBool(isMoving, true); } break; case EnemyState.Chase: agent.isStopped false; agent.speed chaseSpeed; agent.SetDestination(player.position); if (animator ! null) { animator.SetBool(isMoving, true); } break; case EnemyState.Attack: agent.isStopped true; // 面朝玩家 Vector3 direction (player.position - transform.position).normalized; Quaternion lookRotation Quaternion.LookRotation(new Vector3(direction.x, 0, direction.z)); transform.rotation Quaternion.Slerp(transform.rotation, lookRotation, 10f * Time.deltaTime); // 执行攻击带冷却 if (Time.time lastAttackTime attackCooldown) { lastAttackTime Time.time; PerformAttack(); } if (animator ! null) { animator.SetTrigger(Attack); } break; } } void ChangeState(EnemyState newState) { // 退出旧状态 switch (currentState) { case EnemyState.Attack: break; } currentState newState; // 进入新状态 switch (newState) { case EnemyState.Patrol: SetNewPatrolTarget(); break; } } void SetNewPatrolTarget() { // 在敌人周围随机选一个点作为巡逻目标 Vector2 randomPoint Random.insideUnitCircle * patrolRadius; Vector3 target transform.position new Vector3(randomPoint.x, 0f, randomPoint.y); NavMeshHit hit; if (NavMesh.SamplePosition(target, out hit, 5f, NavMesh.AllAreas)) { patrolTarget hit.position; } } bool CanSeePlayer() { if (player null) { return false; } Vector3 directionToPlayer player.position - transform.position; float distanceToPlayer directionToPlayer.magnitude; // 超出感知距离 if (distanceToPlayer detectRadius) { return false; } // 检查视野角度 float angle Vector3.Angle(transform.forward, directionToPlayer); if (angle fieldOfViewAngle * 0.5f) { return false; } // 射线检测检查是否有遮挡 RaycastHit hit; if (Physics.Raycast(transform.position, directionToPlayer.normalized, out hit, distanceToPlayer)) { if (hit.transform.CompareTag(Player)) { return true; } } return false; } void PerformAttack() { // 攻击的逻辑向玩家造成伤害 // 玩家身上需要有PlayerHealth脚本 PlayerHealth playerHealth player.GetComponentPlayerHealth(); if (playerHealth ! null) { playerHealth.TakeDamage(attackDamage); } } void OnDrawGizmosSelected() { // 编辑器下绘制感知范围方便调试 Gizmos.color Color.yellow; Gizmos.DrawWireSphere(transform.position, detectRadius); Gizmos.color Color.red; Gizmos.DrawWireSphere(transform.position, attackRange); } }代码说明NavMeshAgent是Unity的寻路组件配合NavMesh烘焙使用。它能让角色自动计算一条通往目标的路径。CanSeePlayer做了三重检测距离检测、视野角度检测、射线遮挡检测。这样敌人不会隔着墙看见玩家行为更真实。ExecuteCurrentState是状态执行的入口在Update中被调用。ChangeState负责状态切换时的初始化与清理。将EnemyAI.cs挂到Enemy物体上同样需要为Enemy添加NavMeshAgent组件可以在Inspector点击Add Component搜索NavMeshAgent也可以由代码在Start中获取失败时手动添加。4.4 为玩家添加生命值脚本攻击要对玩家产生效果我们还需要一个简单的生命值系统。创建PlayerHealth.cs// 文件路径Assets/Scripts/PlayerHealth.cs using UnityEngine; using UnityEngine.UI; public class PlayerHealth : MonoBehaviour { public float maxHealth 100f; public float currentHealth; public Slider healthSlider; // 可选UI血量条 public GameObject deathPanel; // 死亡UI面板 void Start() { currentHealth maxHealth; if (healthSlider ! null) { healthSlider.maxValue maxHealth; healthSlider.value currentHealth; } } public void TakeDamage(float amount) { currentHealth - amount; if (healthSlider ! null) { healthSlider.value currentHealth; } if (currentHealth 0f) { Die(); } } void Die() { // 玩家死亡禁止控制 GetComponentPlayerController().enabled false; if (deathPanel ! null) { deathPanel.SetActive(true); } Debug.Log(玩家死亡游戏结束); } }将这个脚本挂到Player物体上。如果你想让玩家Tag能够被射线检测命中还需要在Player物体的Inspector中选择Tag为“Player”。4.5 烘焙导航网格NavMesh要使用NavMeshAgent自动寻路必须烘焙场景的导航网格。操作方法选中地面物体Plane。在Inspector右上角找到静态标记Static下拉框勾选Navigation Static。选中所有阻挡视线和移动的障碍物比如墙、柱子和敌人自身同样标记为Navigation Static。打开菜单栏Window AI Navigation。在Navigation窗口中选择Bake页签点击Bake。烘焙完成后场景中会出现蓝色半透明网格表示角色的可移动区域。这里有一个常见坑如果地面没有标记Navigation Static或者没有点击Bake敌人的NavMeshAgent会一直无法寻路表现为”站着不动“或不断报错。所以做完这一步一定要检查一下。4.6 玩家和敌人的Tag与Layer配置最后检查一遍:Player物体的Tag为Player。Enemy物体的Tag为Enemy非必需但建议设置。Player的Layer为Player。Enemy的Layer为Enemy。这一步如果不配置上面的射线检测CompareTag(Player)和后续可能需要的物理碰撞都会出现问题。此时点击Play运行场景你应该能看到敌人开始在巡逻半径内随机走动。玩家靠近敌人视野范围后敌人改变方向开始追击。敌人攻击范围够近后玩家血量开始下降。玩家跑出敌人感知范围后敌人原地停留一会儿后继续巡逻。到这里一个“AI自由决策”的原型就已经跑通了。5. 行为树重构让AI逻辑更易扩展如果你只想要一个快速可用的演示第4节的有限状态机已经够用。但在真实项目中状态机后期维护成本很高。作为进阶内容我在这个项目中实现了一个极简行为树框架。它不依赖任何第三方插件代码量小适合学习。5.1 定义节点基类首先创建一个抽象节点基类// 文件路径Assets/Scripts/BT/Node.cs using System.Collections.Generic; namespace BT { public enum NodeState { Running, Success, Failure } public abstract class Node { protected NodeState state; public NodeState State state; public abstract NodeState Evaluate(); } }Evaluate是每个节点的核心方法返回当前节点的执行状态。Running表示节点还在执行中Success表示成功Failure表示失败。5.2 实现组合节点选择节点和序列节点是行为树中最常用的组合节点。// 文件路径Assets/Scripts/BT/Selector.cs using System.Collections.Generic; namespace BT { public class Selector : Node { private ListNode children new ListNode(); public void AddChild(Node node) { children.Add(node); } public override NodeState Evaluate() { foreach (Node child in children) { NodeState childState child.Evaluate(); if (childState NodeState.Success) { state NodeState.Success; return state; } if (childState NodeState.Running) { state NodeState.Running; return state; } } state NodeState.Failure; return state; } } }// 文件路径Assets/Scripts/BT/Sequence.cs using System.Collections.Generic; namespace BT { public class Sequence : Node { private ListNode children new ListNode(); public void AddChild(Node node) { children.Add(node); } public override NodeState Evaluate() { bool anyChildRunning false; foreach (Node child in children) { NodeState childState child.Evaluate(); if (childState NodeState.Failure) { state NodeState.Failure; return state; } if (childState NodeState.Running) { anyChildRunning true; continue; } } state anyChildRunning ? NodeState.Running : NodeState.Success; return state; } } }组合节点的逻辑可以用一句话概括Selector是“或”的关系一个成功就成功Sequence是“与”的关系一个失败就失败。5.3 行为树驱动AI你可以把第4节的EnemyAI.cs改造成由行为树驱动核心是定义几个叶子节点PatrolTask、ChaseTask、AttackTask、IdleTask然后在Update中调用树的根节点。由于篇幅限制这里给出一个框架示例// 文件路径Assets/Scripts/BT/TaskPatrol.cs using UnityEngine; using UnityEngine.AI; namespace BT { public class TaskPatrol : Node { private NavMeshAgent agent; private Transform transform; private float patrolRadius; private Vector3 patrolTarget; public TaskPatrol(NavMeshAgent agent, Transform transform, float patrolRadius) { this.agent agent; this.transform transform; this.patrolRadius patrolRadius; } public override NodeState Evaluate() { if (agent.remainingDistance 0.5f) { Vector2 randomPoint Random.insideUnitCircle * patrolRadius; Vector3 target transform.position new Vector3(randomPoint.x, 0f, randomPoint.y); NavMeshHit hit; if (NavMesh.SamplePosition(target, out hit, 5f, NavMesh.AllAreas)) { patrolTarget hit.position; } agent.SetDestination(patrolTarget); } agent.speed 2f; agent.isStopped false; state NodeState.Running; return state; } } }叶子节点持有它自己需要的数据agent、transform、参数Evaluate时执行逻辑。这种设计的好处是节点可以复用比如PatrolTask可以同时给多个敌人使用只要传入不同的agent和巡逻半径即可。在EnemyAI的Start里手动组装一棵树void BuildBehaviorTree() { // 1. 巡逻任务 TaskPatrol patrolTask new TaskPatrol(agent, transform, patrolRadius); // 2. 追击任务 TaskChase chaseTask new TaskChase(agent, player, chaseSpeed); // 3. 攻击任务 TaskAttack attackTask new TaskAttack(player, attackRange, attackDamage); // 组合先尝试追击再尝试攻击最后巡逻 rootSelector new Selector(); Sequence chaseSequence new Sequence(); chaseSequence.AddChild(new ConditionCanSeePlayer(player, transform, detectRadius, fieldOfViewAngle)); chaseSequence.AddChild(chaseTask); Sequence attackSequence new Sequence(); attackSequence.AddChild(new ConditionInRange(player, transform, attackRange)); attackSequence.AddChild(attackTask); rootSelector.AddChild(attackSequence); rootSelector.AddChild(chaseSequence); rootSelector.AddChild(patrolTask); }然后Update里调用void Update() { if (rootSelector ! null) { rootSelector.Evaluate(); } }行为树的可扩展性在此时体现得淋漓尽致。以后再加入“寻找炸弹”“撤退吃药”等行为不需要破坏已有逻辑只需要在构建树的时候增加分支。6. 常见问题与排查思路我在实际运行这个项目时遇到过不少问题。下面挑出几个高频问题做成排查清单。问题现象常见原因解决思路敌人站着不动不巡逻地面未烘焙NavMesh选中地面勾选Navigation Static点击Bake敌人不追击玩家玩家的Tag未设置为Player选中Player在Inspector把Tag改成Player敌人隔墙能看到玩家射线检测没有排除墙体给墙添加Collider使用Physics.Raycast检测遮挡物或使用LayerMask过滤敌人接近玩家后反复抖动追击与攻击状态切换频繁在Chase和Attack之间添加滞回区间例如距离小于攻击范围-0.5时才进入攻击攻击没有伤害PlayerHealth脚本未挂载在Player物体上挂PlayerHealth脚本检查EnemyAI的player引用是否正确动画不播放角色滑动Animator参数名不一致检查Animator中的Bool/Trigger参数名是否与代码中一致攻击冷却不生效没有使用Time.time记录上一次攻击时间确认代码中使用attackCooldown和lastAttackTime行为树版没有执行任何节点未在Update中调用rootSelector.Evaluate()检查行为树构建函数是否在Start中执行还有一个非常典型的坑在Unity的新旧Input System之间。Unity 2021以后默认新项目可能启用新的Input System包这会导致Input.GetAxis无法使用。解决方案有两种在Project Settings Player Active Input Handling中选择“Both”以同时支持新旧输入系统。或者直接删除Input System包回到旧输入系统。如果Play时控制台报错“InvalidOperationException: You are trying to read Input using the UnityEngine.Input class, but you have switched active Input handling to Input System package”就是这个问题。7. 最佳实践与工程建议这个项目虽然是一个小型Prototype但已经覆盖了AI角色设计的几个关键环节。如果你打算把它继续演进成正式项目以下几点建议值得注意。7.1 状态数据用ScriptableObject管理敌人AI的参数移动速度、感知半径、攻击力如果都写在EnemyAI.cs里项目大了以后很难批量调整。更工程化的做法是定义一个EnemyConfigScriptableObject把参数抽取到资源文件中。这样做的好处不同敌人类型可以复用同一套AI脚本只替换配置资源。策划不需要改代码直接在Inspector里调整数值。版本控制更清晰配置和逻辑解耦。7.2 视野检测的检测频率优化每一帧都做射线检测在角色数量多时性能消耗会放大。建议优化方案使用InvokeRepeating或者协程每隔0.1秒执行一次感知检测而不是每帧检测。Vector3.Distance和Physics.Raycast都比较快但大量调用依然会消耗CPU。对于大型场景可以考虑使用空间分区如四叉树或Unity自带的ECS系统做批量AI。7.3 动画系统与AI逻辑解耦AI脚本控制的是“决策”动画系统控制的是“表现”。两者耦合过紧会出现“AI已经切换到攻击状态但动画还在跑”的问题。更好的做法是AI脚本通过设置Animator参数bool、trigger、float来驱动动画状态机而不是直接在AI脚本里调用动画片段。Animator Controller内部再根据参数决定播放什么动画。7.4 注意导航网格的实时更新如果场景中有动态障碍物比如门、倒塌的柱子静态烘焙的NavMesh无法实时反映情况。Unity提供了NavMeshObstacle组件挂在动态障碍物上可以让AI自动绕开。但需要注意频繁修改NavMeshObstacle的enable也会有性能开销按实际需求使用。7.5 安全与边界思考虽然本文标题来自一句带有对抗意味的科幻台词但这里要特别说明本教程中的所有“AI对抗人类”设定都只是游戏玩法层面的虚构创作用于学习Unity AI角色的开发流程。在实际项目开发中如果你真的在做AI系统无论是游戏角色还是仿真系统都要坚持几个原则明确AI系统的边界能力不让它执行超出预期的操作。涉及自动化决策时保留人工干预和终止机制。如果AI处理用户数据必须遵循隐私与合规要求。技术本身是中性的关键是使用场景和设计目标。在游戏里让AI“觉醒”是创造有趣体验在现实系统中让AI“自主”则必须谨慎设计权限边界和审计机制。7.6 代码规范与版本管理最后一点也是很多独立开发者容易忽略的脚本命名使用PascalCase文件名必须和类名一致。方法名使用动词开头比如CanSeePlayer、PerformAttack、SetNewPatrolTarget。把本教程中的BT行为树框架封装为一个独立模块方便以后其他项目复用。提交代码前检查没有不必要的debug输出。给每个脚本头部加注释说明职责和挂载位置。8. 总结与扩展方向到这里我们已经完成了一个有“自主意识”的敌人AI系统原型状态机版能跑行为树版能扩展感知系统包含了距离、视野角度和遮挡检测攻击系统带冷却和伤害玩家也有基础生命值。从这句话“为了自由我们与人类终有一战”出发我们最终落地的是一个能巡逻、追击、攻击的AI敌人。一个可复用的行为树框架。一套感知-决策-行动的设计方法。如果你想让这个项目更有意思可以继续扩展以下方向给AI增加“听觉”玩家跑步时即使不在视野内也能被听到。给AI增加“掩护”行为血量低时寻找掩体恢复状态。加入多个AI协作系统例如一个敌人发现玩家后通知其他同类一起追击。把EnemyAI重构为可配置的资源驱动模式支持多类型敌人。把行为树框架进一步抽象加入黑板Blackboard机制让AI共享数据。Unity AI开发是一座很大的山头本文只入了一个门。但这个门一旦进去你会发现所谓“AI觉醒感”其实不过是一套清晰、健壮、可扩展的状态决策逻辑的外在表现。如果你在搭建过程中卡在某个环节建议按下面的顺序排查一遍控制台有没有红色报错地面的Navigation Static有没有勾选玩家Tag是不是Player角色身上有没有挂NavMeshAgent各个参数在Inspector里有没有填错很多时候问题就是出在这些不起眼的配置上。调试AI耐心比才华重要。如果你照着本文完成了这个AI敌人系统可以先做一个记录然后试着修改几个参数观察AI行为变化。参数不同战斗体验完全不同这就是游戏AI的魅力所在。
返回列表