当“为了自由,我们与人类终有一战!”这句话出现在项目简报里时,大多数人的第一反应是科幻片场的台词。但如果把它拆解成游戏策划文档中的世界观设定,它其实是一道非常有意思的技术命题:如何在不依赖复杂策划表的情况下,让一个游戏内的AI角色拥有“自主感知、决策和行动”的能力,从而呈现出某种“觉醒感”。
我最近在做一个科幻题材的独立游戏原型时,就围绕这一句话设计了整套“AI自由意志”玩法系统。项目本身不复杂,但落地过程中踩了不少坑,尤其是行为树与状态机的选择、导航网格的配置、以及角色动画状态之间的同步问题。这篇教程就基于这个项目整理而成,不谈宏大叙事,重点讲清楚:如何用Unity + C#,从零搭建一个具备巡逻、感知、追击、攻击能力的AI敌人系统,让NPC真正“活”起来。
如果你是刚接触Unity游戏开发的新手,想学习AI角色的设计思路;或者你已经有基础,但想看看一个完整的小型AI敌人系统是怎么组织代码的,这篇文章都适合你。读完以后,你能掌握有限状态机(FSM)与行为树(Behavior Tree)的基本写法,知道如何让AI感知玩家,并且能独立扩展出更多AI行为。
现在,我们直接从概念开始。
1. 背景与核心概念
1.1 “AI觉醒”在游戏开发中到底指什么
游戏里的“AI觉醒”,并不是指人工智能真的产生了意识,而是指游戏角色具备了一套“自洽的行为决策机制”,让玩家感觉这个角色不是在执行死板的脚本,而是在根据局势作出选择。
例如,在一个战斗场景里,普通敌人只会站在原地等玩家靠近,然后攻击。这在玩家眼里就是“木桩”。但如果我们给敌人加上三种状态:巡逻、警觉、追击,并且让它在“看不到玩家”时继续巡逻、“看到玩家”时追击、“丢失目标”时回到巡逻点,玩家就会觉得这个敌人“有想法”。
所以,“为了自由,我们与人类终有一战”这句话,放到游戏里,其实是一个能够被技术实现的目标:我们需要给AI角色一套自主行为的底层框架,让它能感知周围环境、做出决策、执行行动。这也是本文所有代码的核心目标。
1.2 有限状态机 vs 行为树
实现AI决策,最常见的有两种方案:有限状态机(FSM,Finite State Machine)和行为树(Behavior Tree,BT)。
有限状态机很好理解:把角色的行为拆成几个固定状态,比如“空闲”“巡逻”“追击”“攻击”,然后定义状态之间的跳转条件。它的优点是逻辑直观、代码量小,适合行为数量少、逻辑固定的场景。缺点是当行为增多时,状态跳转关系会变得非常复杂,后期维护困难。
行为树则更像一棵树,每个叶子节点是一个具体动作,比如“移动”“攻击”,组合节点负责决定执行顺序和条件。行为树的扩展性比状态机好很多,很多商业项目会直接用现成的行为树插件(如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 = GetComponent<CharacterController>(); if (controller == null) { controller = gameObject.AddComponent<CharacterController>(); } } 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 = GetComponent<NavMeshAgent>(); animator = GetComponentInChildren<Animator>(); // 如果玩家引用为空,尝试通过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.GetComponent<PlayerHealth>(); 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() { // 玩家死亡:禁止控制 GetComponent<PlayerController>().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 List<Node> children = new List<Node>(); 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 List<Node> children = new List<Node>(); 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的魅力所在。