简介:面向Unity开发者的AI入门演示项目,用一个小型工程展示如何把导航网格、行为树、C#脚本、动画状态机等模块组合到角色决策流程中,同时覆盖机器学习代理的强化学习训练与多智能体协作。压缩包共有一百七十八个文件,以C#脚本、Unity场景与资产文件、动态链接库以及工程配置为主,包体仅1.35MB,轻量紧凑,适合直接查阅工程结构和核心代码,已有两百六十六人学习或下载。通过这个Demo,既可以对照NavMeshAgent调整寻路参数、烘焙导航网格,也可以查看行为树节点如何设计追击、逃跑、攻击等分支。还能了解ML-Agents训练环境的搭建、奖励设置与决策输出,以及与物理碰撞、传感器感知、动画状态机和粒子系统如何围绕AI决策协同表现的具体示例。开发者能在此基础上迁移这些脚本和配置,快速搭建自己的AI原型,是Unity游戏AI入门与选型参考的实用资料。
1. 这个 Unity AI demo 到底能干什么:从巡逻到追踪,一个可改的 AI 行为样板
很多人第一次在 Unity 里做 AI,都是从一个会追着玩家跑的 NPC 开始的。但当敌人越来越多、行为越来越复杂,才发现散落一地的if else和杂乱的Update根本撑不住。这个 AI demo 不是一个完整的游戏,而是一套可复用的 AI 行为框架:它用有限状态机(FSM)管理 NPC 的基础状态,用行为树(Behavior Tree)做高层决策,再叠加上 NavMesh 导航和一套简单的感知系统,让 AI 能巡逻、发现玩家、追击、攻击,最后回到巡逻点。适合三类人:刚学 Unity AI 想看看规范写法的学生,做小游戏原型需要快速给 NPC 加脑子的独立开发者,以及想在现有项目里把敌人 AI 从“一坨脚本”重构成分层结构的熟手。整个项目跑通只需十分钟,但看懂它的分层思路,足够让你在下一个项目里少写一半 AI 屎山。
2. 架构与目录:AI 行为不是堆在 Update 里的,而是分了三层
2.1 三层决策模型:状态机、行为树、感知各管一摊
这个 demo 的核心思想是把“感知 - 决策 - 执行”拆开。感知是输入,决策是大脑,执行是手脚。如果你把这三件事全塞进一个脚本,你会得到一大坨既难读又难调的代码;但拆开之后,每个脚本都只干一件事,出错时你只需要盯住那一个脚本。
打开项目目录,你会看到 Assets/Scripts 下面分成了三个子目录:
Assets/Scripts/ ├── FSM/ // 有限状态机基础类与具体状态 │ ├── AIState.cs │ ├── AIController.cs │ ├── PatrolState.cs │ ├── ChaseState.cs │ └── AttackState.cs ├── BehaviorTree/ // 行为树节点与黑板 │ ├── BehaviorTree.cs │ ├── Blackboard.cs │ ├── SelectorNode.cs │ └── SequenceNode.cs ├── Perception/ // 视觉听觉感知 │ └── AIPerception.cs └── Navigation/ // 导航辅助 └── AIMover.csFSM 是这道菜的主干。AIController持有当前状态,并在每个Update里调用当前状态的Execute和Transition。每个状态自己判断“我什么时候该切换到别的状态”,例如巡逻状态发现玩家在视野内,就切换到追击状态。状态之间的切换逻辑不写在控制器里,而是写在状态内部,这样新增状态不会把控制器撑爆。
行为树在这个 demo 里扮演的是“高层指挥官”的角色。它并不直接控制 NPC,而是通过黑板(Blackboard)向 FSM 下发意图。比如行为树里一个选择节点(Selector)判断“玩家是否可见”,如果可见就推送一个Chase意图给 AIController,否则推送Patrol。FSM 再根据收到的意图做具体的状态迁移。这种分层的好处是:如果你想把 AI 改成“先搜索再攻击”,只需要改行为树,FSM 状态可以原封不动。
感知系统是最容易被人忽略的一层。AIPerception脚本用Physics.OverlapSphere检测周围带标签的对象,然后做角度和遮挡测试。它输出的是布尔值和目标引用,至于拿到这个目标之后干什么,感知层不管。我当初写 AI 时最常犯的错就是把“看到玩家”和“追玩家”写在一起,导致 NPC 在墙角反复横跳——因为玩家被墙挡住了一瞬间,感知丢失导致状态切回巡逻,然后走出来又切追击。感知层单独输出“是否可见”,决策层再决定怎么反应,这个问题就自然消失了。
2.2 关键脚本的职责边界与控制流
这个 demo 的控制流非常干净,值得你照着抄进自己的项目。每个Update帧里,执行顺序是:
graph LR A[感知层 每帧更新] --> B[行为树 高频率决策] B --> C[FSM 状态迁移] C --> D[执行层 移动/动画]可惜我们不能用 mermaid,那就用文字说清楚。AIPerception.Update()负责刷新感知数据并写入黑板;BehaviorTree.Tick()根据黑板数据选择行为分支,把决策结果写入;AIController.Update()读取决策结果,决定是否切换状态;当前状态Execute里调用AIMover驱动 NavMeshAgent 移动,同时设置 Animator 参数切换动画。
我在写这个 demo 时偷懒过一段时间,让 FSM 直接调用感知脚本,后来发现一旦感知逻辑改动,状态也跟着遭殃。现在这个版本,状态里只认黑板上的布尔值,比如blackboard.canSeePlayer。改感知算法、加听觉甚至加气味,状态代码一行都不用动。
2.3 黑板的写法为什么要用字符串索引而不是直接引用
黑板(Blackboard)是这个 AI demo 里比较有意思的设计。它本质上是一个字典:
using System.Collections.Generic; using UnityEngine; public class Blackboard { private Dictionary<string, object> _values = new Dictionary<string, object>(); public void Set<T>(string key, T value) { _values[key] = value; } public T Get<T>(string key) { if (_values.TryGetValue(key, out object value)) { return (T)value; } return default; } public bool HasKey(string key) { return _values.ContainsKey(key); } public void Clear() { _values.Clear(); } }代码逻辑很简单:Set<T>往字典里写值,Get<T>取值。但这里有个参数设计的讲究——为什么用字符串键而不是直接把感知脚本的引用传给行为树?因为行为树节点是通用的,不能让它依赖某个具体感知脚本类型。一旦它依赖了具体类型,你就得为每个不同感知方案写一套节点,行为树就失去复用价值了。用字符串键后,行为树节点只关心“canSeePlayer 这个键是不是 true”,至于这个键是谁写的,它不关心。
实际跑起来时,你可能会在Get<T>时收到InvalidCastException,那是因为你在Set时用了float,取的时候却用了int。这是字典方案的通病。我一般会在Set里加一层类型检查,或者在黑板上维护一个真实类型字典,调试时能定位到是谁写错了类型。这个 demo 里没做这么重,但你在二次开发时值得加上。
3. 核心模块拆解:一个敌人从巡逻到攻击的完整链路
3.1 AIController:状态机的中枢,但不存具体状态逻辑
AIController 是 FSM 的大脑。它不关心每个状态内部怎么执行,只负责持有当前状态、提供切换接口,以及在每帧驱动状态。下面是精简后的代码:
using UnityEngine; public class AIController : MonoBehaviour { public AIState initialState; public Blackboard blackboard; private AIState _currentState; void Start() { Blackboard = blackboard ?? new Blackboard(); ChangeState(initialState); } void Update() { _currentState.OnUpdate(blackboard); } public void ChangeState(AIState newState) { if (_currentState != null) { _currentState.OnExit(blackboard); } _currentState = newState; _currentState.OnEnter(blackboard); } }这里的Blackboard属性在完整的代码里是一个带 getter 的公共属性,用来让状态通过控制器访问黑板。实际使用时,你会把initialState和blackboard直接在 Inspector 里拖拽赋值。注意Update只调用了当前状态的OnUpdate,状态切换动作发生在OnUpdate内部,通过调用控制器上的ChangeState方法完成。
参数说明:initialState是暴露在 Inspector 上的 AIState 对象,它通常是一个MonoBehaviour或普通 C# 实例。如果你用普通 C# 类而不是 MonoBehaviour,可以省去一些 Unity 生命周期开销,但失去 Inspector 上可视化调整的便利。我倾向于让每个状态作为普通 C# 类,用代码配置状态参数,因为状态之间的参数传递比较频繁,Inspector 上拖来拖去反而麻烦。
3.2 巡逻状态:让 NPC 按随机点移动,而不是傻站
巡逻状态是这个 demo 里最基础的一环。它做的事情是:从设定好的巡逻点数组中选一个目标,用 NavMeshAgent 走过去,到达后再选下一个。这里直接穿过 AIMover 操作 NavMeshAgent:
using UnityEngine; using UnityEngine.AI; public class PatrolState : AIState { public Transform[] waypoints; public float arriveDistance = 1.5f; private int _currentIndex = 0; private NavMeshAgent _agent; public override void OnEnter(Blackboard blackboard) { _agent = blackboard.Get<NavMeshAgent>("agent"); PickNextWaypoint(); } public override void OnUpdate(Blackboard blackboard) { if (_agent.remainingDistance <= arriveDistance) { PickNextWaypoint(); } if (blackboard.Get<bool>("canSeePlayer")) { controller.ChangeState(new ChaseState()); } _agent.SetDestination(_targetPosition); } private void PickNextWaypoint() { _currentIndex = (_currentIndex + 1) % waypoints.Length; _targetPosition = waypoints[_currentIndex].position; } }这段代码里有个值得注意的细节:remainingDistance的判定用的是<= arriveDistance,而不是== 0。因为 NavMeshAgent 在接近终点时速度会逐渐放缓,但很少会精确停在 0 位置,尤其在浮点误差和转弯惯性影响下。arriveDistance允许我们控制“多近就算到达”,你把它调成 0.5 会更贴目标,但角色会反复走最短路径导致抽动;调成 2.0 则会在离目标还有两步远时就转向下一个点,看起来有点敷衍。我一般给近战型 NPC 设 1.0 到 1.5,给远程型设 3.0,方便拉开距离输出。
状态切换条件是blackboard.Get<bool>("canSeePlayer"),这个值由感知层写入。注意OnEnter里从黑板取agent引用,这个引用在AIController的Start里已经被初始化为GetComponent<NavMeshAgent>(),所以你不需要在每个状态里重复找组件。
3.3 追击与攻击状态:切状态不是一句话的事
追击状态必须处理“追过头”或“目标丢失”两种情况。目标丢失是一个回退逻辑:如果一定时间内没看到玩家,就回到巡逻点,而不是站在原地发呆。这个 demo 里用一个 seenTimer 倒计时来模拟“记忆”:
using UnityEngine; using UnityEngine.AI; public class ChaseState : AIState { public float loseSightTime = 3.0f; public float stopDistance = 2.0f; private NavMeshAgent _agent; private Transform _target; private float _timer; public override void OnEnter(Blackboard blackboard) { _agent = blackboard.Get<NavMeshAgent>("agent"); _target = blackboard.Get<Transform>("target"); _timer = 0f; } public override void OnUpdate(Blackboard blackboard) { if (_target == null) { controller.ChangeState(new PatrolState()); return; } float distance = Vector3.Distance(transform.position, _target.position); if (blackboard.Get<bool>("canSeePlayer")) { _timer = 0f; } else { _timer += Time.deltaTime; if (_timer >= loseSightTime) { controller.ChangeState(new PatrolState()); return; } } if (distance > stopDistance) { _agent.SetDestination(_target.position); } else { _agent.isStopped = true; controller.ChangeState(new AttackState()); } } public override void OnExit(Blackboard blackboard) { _agent.isStopped = false; } }追逐状态里最容易踩的坑是isStopped的处理。我见过不少同学在追击后切到攻击状态,发现 NPC 还在滑步,原因就是没在OnExit里把isStopped重置为 false。NavMeshAgent 一旦设成isStopped = true,它会保持这个状态直到你手动改回来,不会因为切换到新状态就自动恢复。所以我在OnExit里强制复位,确保攻击结束后回到巡逻状态时 agent 还能正常走。
攻击状态最简版只是播放攻击动画和伤害判定。真实项目里你会在攻击前做一个冷却计时,并用NavMeshAgent.velocity或动画事件控制伤害帧。这个 demo 的攻击状态保留了接口位,你在改造时可以挂一个攻击范围检测的Collider,在动画事件里启用它。
3.4 感知系统:不要用 OnTriggerEnter 做视野判断
感知脚本是这个 demo 里最需要你自己看懂的。很多人做 AI 视野,习惯用OnTriggerEnter然后看到就追,但这样无法处理“视野角度”和“遮挡”问题。这里用的是每帧主动检测:
using UnityEngine; public class AIPerception : MonoBehaviour { public float viewRadius = 10f; public float viewAngle = 90f; public Transform target; // 玩家 private Blackboard _blackboard; void Start() { _blackboard = GetComponentInParent<AIController>().blackboard; } void Update() { Vector3 dirToTarget = (target.position - transform.position).normalized; float distance = Vector3.Distance(transform.position, target.position); bool canSee = false; if (distance <= viewRadius) { float angle = Vector3.Angle(transform.forward, dirToTarget); if (angle <= viewAngle / 2) // 在视野角内 { if (Physics.Raycast(transform.position, dirToTarget, out RaycastHit hit, distance)) { if (hit.transform == target) { canSee = true; } } } } _blackboard.Set<bool>("canSeePlayer", canSee); if (canSee) { _blackboard.Set<Transform>("target", target); } } }这里有个参数坑:viewAngle是总角度,比如 90 度表示左右各 45 度,所以比较时用angle <= viewAngle / 2。如果你直接把 90 度和Vector3.Angle的结果比,你会发现 NPC 的视野只有实际的一半。这是新手最容易怀疑“我明明看见了为什么不追”的原因。另外,Physics.Raycast的hit.transform == target判断要求玩家身上没有子物体挡射线,如果你用了带子物体的玩家模型,需要比较hit.transform.root或者给玩家单独设置一个Target层。
Blackboard.Set每帧都会写值,即使看不到玩家也会写false。这是故意的,为了让行为树和状态们拿到的数据永远是前一帧最新状态。如果你只在看到时写,看不到时保留旧值,那 AI 会永久记得玩家在哪,追过去结果发现是个幻影——这就是所谓“AI 记忆残留”的奇葩现象,这个 demo 里通过每帧强制写值避免了。
4. 运行配置与避坑指南:跑不通和跑起来像傻子,大多是这几个原因
4.1 从导入到跑通,十分钟完成的配置步骤
拿到项目后,直接用 Unity Hub 打开。我这边用的编辑器版本是 2021.3 LTS,只要你的版本不低于 2020.3,大概率能直接打开。打开以后,你需要做的第一件事不是按 Play,而是检查场景里的 NavMesh 是否已经烘焙好。这个 demo 自带烘焙数据,但如果你的机器升级过 Unity 版本,NavMesh 数据有可能失效,需要重新烘焙。
烘焙步骤:Window → AI → Navigation,打开面板后选择所有带有NavMeshObstacle的墙壁和地面,在 Object 标签页里勾选 Navigation Static,然后到 Bake 标签页设置 Agent Radius 和 Step Height,点击 Bake。我通常设置 Agent Radius 0.5,Step Height 0.3,这样能防止角色卡在路缘或在狭窄通道里绕远路。
跑通的关键检查项有三个:
- 场景里是否有一个 GameObject 挂载了
AIController,并正确引用了Blackboard和initialState。 NavMeshAgent是否作为独立组件挂载在 AI 角色上,并且Agent Type与烘焙时一致。- 感知系统的
target是否指向了玩家胶囊体,且玩家拥有Tag=Player(如果你的射线判定用的是 Tag 而不是 Transform 引用)。
如果按 Play 后 AI 站在原地不动,优先看 Console 有没有红字异常。最常见的红字是MissingReferenceException,几乎都是某个引用没拖进去。另一个隐蔽的坑是AIController上的Blackboard字段没有指定实例,导致blackboard为 null,但因为你写的是blackboard ?? new Blackboard(),所以不会立刻炸,而是创建了一个空黑板,然后后面所有Get<T>都返回默认值,AI 就像个瞎子一样乱走。想快速排查这个问题,可以在OnEnter里加一条 Debug.Log 打印黑板键是否齐全。
4.2 避坑记录:四个真实踩过的坑,现象、原因、解决
坑一:AI 在墙角来回抖动,像抽风。
现象:NPC 沿着墙边巡逻时,接近墙角会突然转向另一个方向,然后马上又转回来,导致角色在墙角来回滑动。
原因:NavMeshAgent 的UpdateRotation和自动转向在接近墙角时,由于剩余距离小于arriveDistance,状态提前切换到下一个点,下一个点的路径需要转弯,agent 在转弯过程中继续前进,撞到墙角后沿障碍物边缘滑动,路径更新频繁,造成抖动。
解决:将arriveDistance从 1.0 调到 2.0,并给NavMeshAgent设置更大的Angular Speed(建议 720 以上),让转向更果断。另外检查PickNextWaypoint里不要选与当前点太近的下一个点。
坑二:AI 可以看到墙后的玩家,透视眼。
现象:玩家躲在墙后,NPC 仍然能追过来。
原因:AIPerception里做Physics.Raycast时,只检测了从 NPC 到玩家的一条射线。但如果玩家的胶囊体与射线起点之间隔着墙壁,而墙面较薄或碰撞体没设置好,射线可能穿透;还有一个常见原因是你用了Physics.OverlapSphere检测到玩家后,没有做射线遮挡判断,而我的代码里虽然做了,但你把viewAngle设成了 360,于是任何方向都能“看到”。
解决:确认viewAngle小于 180,并且给墙壁添加NavMeshObstacle或普通 Collider。射线层建议设置LayerMask,只检测玩家所在层和墙壁层,避免被无关物体挡住。
坑三:AI 追击到一半突然原地发呆,几秒后回到巡逻点。
现象:玩家绕到柱子后面一两秒,NPC 就放弃追击走回去了。
原因:loseSightTime设得太短(我最初设了 1 秒)。因为玩家绕柱子时,射线被柱子挡住一瞬间,canSeePlayer变为 false,计时器开始走,1 秒还没走出柱子范围,NPC 就判定目标丢失了。现实中敌人不会这么快放弃。
解决:把loseSightTime调到 3~5 秒,或者在ChaseState的OnEnter时记录玩家最后位置,在失去视野后先走向最后位置,到达后再启动计时器。更进阶的写法是增加一个SearchState,让 AI 先到上次看到玩家的位置附近转了转再放弃。
坑四:切换状态时动画不连贯,角色瞬移或滑步。
现象:从巡逻切到追击的瞬间,角色像被拽了一下,动画腿还在走但身体移动很突兀。
原因:状态切换时,OnEnter里重新取了 agent 和 target,但没重置 agent 的velocity。NavMeshAgent 在上一帧结束时还有惯性速度,切到追击状态后直接SetDestination到新目标,agent 的转向和前向速度会产生一个瞬间的方向变化,看起来就像被拽了一下。
解决:在AIController.ChangeState里,切换前执行_currentState.OnExit,并在OnExit中调用_agent.ResetPath()清空路径,或设置_agent.velocity = Vector3.zero。注意ResetPath会停掉移动,所以需要在进入下一个状态后重新设置目的地。我一般只在目标点变化大的状态切换时重置,比如巡逻到追击重置,追击到攻击因为距离很近反而不重置效果更好。
4.3 性能注意:别让每帧 Raycast 拖垮你的项目
AI 在场景里多了之后,每帧的感知射线和 NavMesh 路径更新需要掂量掂量。如果场景里同时存在 20 个 AI,每个每帧做一条 Raycast,开销其实不大。但如果你在感知里做了OverlapSphere加多条 Raycast,再叠加行为树每帧 tick,NPC 数量超过 30 时就会有明显的帧率下降。
这个 demo 没有做任何优化,但你可以用两个手段轻松改善:第一,把感知更新频率从每帧改成每 0.2 秒执行一次,用Time.time累积计时。视觉感知对人类来说 0.2 秒延迟完全无感,但性能直接省了五倍。第二,行为树不要在每帧 tick,改成在感知更新的同一步调执行,反正决策依赖感知数据,感知没刷新时 tick 也是白费 CPU。这些改动不影响 AI 行为正确性,却能让你的敌人数量上限翻两倍。
5. 二次开发改造思路:如何把巡逻 AI 改成小队行为,以及一个必改的参数
5.1 把状态机换成行为树主导:FSM 退居执行层
如果你想给 AI 增加更多复杂行为,比如“受伤后先跑远再回身反打”或者“队友死亡后进入狂暴状态”,纯 FSM 会越来越膨胀。这时候推荐把决策权从状态机转移到行为树,FSM 只保留执行动作的能力。改动路径很清晰:行为树的每个行为节点(比如“逃跑”“攻击”“巡逻”)不是直接写逻辑,而是给 FSM 发一个指令,例如blackboard.Set<string>("intent", "Flee")。AIController 在Update里根据 intent 字段决定要不要切状态。
这样改的好处是,你新增 AI 行为时,只需要写一个行为树分支,比如一个“距离小于 5 且血量低于 30%”的选择节点,指向执行逃跑意图的节点。原来的 FSM 状态不用删,不加,只是被复用。我在一个模拟项目里就是这么干的,把原来 8 个状态的行为缩减到 4 个执行状态,决策逻辑全放行为树,后续调整 AI 难度时只需要编辑树,不用改 C# 代码。
5.2 给 AI 加听觉:一行事件触发的改造示例
这个 demo 的感知层目前只有视觉。实际项目中,视觉很容易被遮挡,配合听觉才靠谱。我给这个项目加过听觉,你可以在AIPerception里增加一个外部触发器方法:
public class AIPerception : MonoBehaviour { public void OnHearNoise(Vector3 noisePosition, float volume) { if (volume > 0.1f) { _blackboard.Set<Vector3>("lastNoisePosition", noisePosition); _blackboard.Set<bool>("hasHeardNoise", true); // 让 NPC 至少朝噪音方向看一眼 transform.LookAt(noisePosition); } } }然后在某个状态里处理这个事件——比如在巡逻状态中,如果hasHeardNoise为 true,就先走过去查看,而不是直接切追击。你可以在AIController上暴露一个公共方法供场景内的事件源调用,比如子弹壳落地的音效事件。这个改造只需要十几行代码,但立刻让 AI 显得“耳聪目明”不少。
5.3 小团队协作时的参数标准化:把 AI 参数抽到 ScriptableObject
如果你有同事一起做 AI,你会发现每个人改不同 NPC 的时候,都直接在 Inspector 里拖数值,最后所有人都在问“你这个怪为什么视野比我的多 5 米”。这个 demo 最初的参数都是硬编码在状态类里的,比如viewRadius = 10f。我在第二次重构时把这些参数全抽到了一个AIStatsScriptableObject 里,每种敌人一个预设,用鼠标右键创建。字段包括viewRadius、viewAngle、loseSightTime、attackCooldown等。状态从黑板上拿AIStats引用,而不是自己定义数值。
这带来的直接好处是:调整怪物强度,只需要改动资源文件,不需要重新编译脚本。对于非程序员同事来说,改一个资产文件比改代码安全得多。你甚至可以在 Inspector 上做一个滑动条,把viewAngle的取值范围限制在 30 到 180 之间,防止有人填出 300 度这种“全视野”怪物。
5.4 验证与调试技巧:用 Gizmos 把 AI 的“脑内状态”画出来
最后聊聊调试习惯。AI 行为看不见摸不着,最容易出玄学问题。我每次改造完都会在OnDrawGizmos里画两条辅助线:一条是视野范围扇形,一条是从 AI 到目标的射线,射线的颜色根据canSeePlayer变化——看到是绿色,没看到是红色。效果立竿见影:你按下 Play 后能立刻观察到 AI 在什么角度、什么距离开始“看到”玩家,还能看到状态切换时射线的颜色抖动,用来判断是不是感知断层导致的抽搐。
从那次以后,我每次写 AI 都会强制走一遍这个流程:先画 Gizmos 验证感知,再打印状态切换日志,确认行为树意图和 FSM 状态一致,最后才调整参数。这套习惯帮我避开了无数肉眼难以察觉的逻辑坑。希望这个 demo 的分层思路和这些避坑记录,也能让你少走一段弯路。
本文还有配套的精品资源,点击获取