资讯动态

Unity敌人AI手搓指南:从感知到死亡,不依赖行为树

发布时间:2026/10/26 12:17:08 来源:尧图企业网站定制
做 Unity 的朋友大概都有过这个阶段场景搭好了角色能跑了美术资源也塞进去了结果一进游戏发现整个关卡安静得可怕没有对手、没有威胁玩起来像在逛空场景。这时候你需要的不是什么复杂的寻路网格、行为树框架或者状态机插件而是一个能跑、能打、能追、能撤的简单敌人 AI。我这次要聊的就是怎么在 Unity 里从零手搓一个够用、好调、不臃肿的敌人 AI把感知、追击、攻击、受击、死亡这条链路完整跑通。这篇内容适合两类人一类是刚接触 Unity 不久、想搞明白 AI 到底是怎么活起来的新手另一类是做过一些项目、但每次写 AI 都写成一大坨 if-else 想找个更清爽写法的老手。我会把核心思路、状态划分、代码结构、参数调优、踩坑经验全部摊开讲代码可以直接抄思路可以照着改。整篇围绕一个核心问题展开在不引入重型框架的前提下怎么让一个敌人 AI 表现得像个活物而不是一个只会直线冲脸的木头人。1. 先想清楚简单敌人 AI 到底要解决什么问题1.1 从玩家体验反推 AI 需求很多人一上来就写代码结果写着写着发现不知道该加什么功能。我的习惯是先站在玩家角度问一句这个敌人出现的时候玩家应该感受到什么答案通常就三件事——它发现我了、它冲我来了、它打我了。再往深一层还有我打它它有反应和它被打死会消失。把这五件事拆开AI 的需求清单基本就出来了。具体落到功能点上一个最小可用的敌人 AI 需要具备这些能力感知玩家知道玩家在哪、在不在范围内、状态切换待机、追击、攻击、受击、死亡之间能正确流转、移动控制朝目标靠近且不能穿墙、不能抖、攻击判定到范围内触发伤害有冷却、受击反馈掉血、硬直、击退、死亡处理播放动画、禁用逻辑、清理对象。这六块拼起来就是一个完整的闭环。我见过不少新手把感知直接写成Vector3.Distance(player.position, transform.position) viewRange能跑但很粗糙——玩家在墙后面它也照样追过来。简单 AI 不要求做视线遮挡检测但至少要把距离判定和朝向判定分开让敌人有个视野锥的概念行为会自然很多。这一点后面会细讲。1.2 为什么不用行为树和寻路插件这里得说清楚一个取舍。行为树Behavior Tree、GOAP、以及各种 AI 插件确实强大但它们解决的是复杂决策问题。一个只有追击和攻击两种行为的敌人用行为树属于杀鸡用牛刀节点配置的复杂度反而超过手写代码。我的判断标准很简单状态数量在 5 个以内、状态转移关系清晰、没有嵌套决策就老老实实用有限状态机FSM代码量小、调试直观、性能也好。寻路也是同理。Unity 自带的 NavMesh 很好用但它需要烘焙、需要场景配合对于在一个平坦房间里追玩家这种需求直接朝目标点做移动插值就够了。什么时候该上 NavMesh当地图有障碍物、有高低差、需要绕路的时候。所以我的建议是先用最简单的直线追击把逻辑跑通等地图复杂了再替换移动层因为移动层和决策层是解耦的换起来不伤筋动骨。1.3 状态机的核心设计思路有限状态机的本质就一句话同一时刻只有一个状态在生效状态之间通过明确的条件切换。落到代码上我推荐用枚举 switch 的写法而不是给每个状态写一个类。原因很实际——简单 AI 的状态逻辑都很短一个 switch 分支几行就写完了拆成类反而要维护一堆文件、还要处理类之间的引用得不偿失。状态划分我一般这么定Idle待机巡逻、Chase追击、Attack攻击、Hurt受击硬直、Dead死亡。注意Hurt和Dead是被动状态由外部事件触发不是 AI 自己决策出来的这一点在写切换逻辑时要特别小心否则容易出现正在攻击时被打断但状态没切过去的 bug。状态转移的条件我用一个表格先理清楚写代码时照着填就行当前状态触发条件目标状态Idle玩家进入视野范围ChaseChase玩家离开视野范围一定时间IdleChase与玩家距离小于攻击范围AttackAttack攻击动画结束且玩家仍在范围Chase任意状态受到伤害HurtHurt硬直时间结束Chase任意状态血量归零Dead这张表就是整个 AI 的宪法代码里所有的状态切换都必须能在这张表里找到依据。我踩过的坑是一开始没把Hurt的优先级定清楚结果敌人在攻击前摇时被打状态切到Hurt硬直结束后又回到Attack继续打逻辑上没错但表现很怪。后来改成硬直结束统一回Chase由Chase重新判断距离决定要不要攻击行为就自然多了。2. 核心模块拆解感知、移动、攻击怎么落地2.1 感知模块距离加朝向的双重判定感知是 AI 的眼睛写得好不好直接决定敌人聪不聪明。最基础的写法是纯距离判定但这样敌人会背后长眼。我用的方案是距离 朝向夹角双重判定逻辑是先算玩家和敌人的距离如果在视野半径内再算玩家方向与敌人正前方的夹角夹角小于视野角度的一半才算看见。public bool CanSeePlayer() { if (player null) return false; Vector3 toPlayer player.position - transform.position; float distance toPlayer.magnitude; if (distance viewRadius) return false; float angle Vector3.Angle(transform.forward, toPlayer); return angle viewAngle * 0.5f; }这里有个细节值得说viewAngle我一般设成 120 度也就是左右各 60 度这个范围在俯视角和第三人称里都比较自然。设成 360 度就退化成纯距离判定设成 60 度以下敌人会显得很瞎玩家绕个圈它就丢了。另外Vector3.Angle返回的是 0 到 180 的无符号角度不用自己处理正负省事。注意感知判定不要每帧都算尤其是敌人数量多的时候。我一般用InvokeRepeating或者计时器做成每 0.1 到 0.2 秒检测一次视觉上完全看不出延迟但 CPU 开销能降一大截。这个技巧在敌人超过 10 个以后效果非常明显。还有个进阶小技巧给感知加一个记忆时间。玩家离开视野后敌人不会立刻回 Idle而是朝玩家最后出现的位置追一段时间追不到才放弃。这个改动只需要几行代码但 AI 的智商感会提升一个档次玩家会觉得它记得我。2.2 移动模块别让敌人抖成筛子移动看起来简单Vector3.MoveTowards一行就搞定但实际写起来问题不少。最常见的就是抖动敌人追到攻击范围边缘时一会儿进范围切 Attack一会儿出范围切 Chase状态疯狂横跳角色原地抽搐。解决办法是加滞回区间hysteresis也就是进入攻击范围用一个值退出攻击范围用另一个更大的值。// 进入攻击的距离 public float attackEnterRange 1.5f; // 退出攻击的距离要比进入距离大 public float attackExitRange 2.0f; bool ShouldAttack() { float d Vector3.Distance(transform.position, player.position); if (currentState ! State.Attack) return d attackEnterRange; else return d attackExitRange; }这个 0.5 的差值就是缓冲带敌人进了攻击范围后玩家稍微退一点它不会立刻放弃行为连贯很多。这个思路在 UI 的 hover 判定、摄像机跟随的边界处理里都通用是个很值钱的模式。移动的另一个坑是朝向。敌人追击时应该面朝移动方向但攻击时又应该面朝玩家。我的处理是Chase 状态下用Quaternion.LookRotation平滑转向移动方向Attack 状态下强制朝向玩家。转向用Quaternion.Slerp做插值别直接赋值否则转身会像瞬移一样生硬。void FaceTarget(Vector3 target, float speed) { Vector3 dir (target - transform.position); dir.y 0; // 锁死 Y 轴防止角色低头抬头 if (dir.sqrMagnitude 0.001f) return; Quaternion targetRot Quaternion.LookRotation(dir); transform.rotation Quaternion.Slerp(transform.rotation, targetRot, speed * Time.deltaTime); }dir.y 0这行千万别漏尤其是玩家和敌人高度不一致时不锁 Y 轴角色会朝天上或地下倾斜看起来像在爬坡。这个坑我踩过不止一次。2.3 攻击模块冷却、前摇与判定时机攻击模块的核心不是打出去这个动作而是什么时候算打中。新手常犯的错是按下攻击键立刻扣血结果动画还没挥到玩家身上血就掉了观感很假。正确做法是把攻击拆成三段前摇windup、判定hit、后摇recovery伤害判定放在动画事件或者计时器的中间点触发。IEnumerator AttackRoutine() { isAttacking true; animator.SetTrigger(Attack); // 前摇等动画挥到一半 yield return new WaitForSeconds(attackWindup); // 判定瞬间范围检测 Collider[] hits Physics.OverlapSphere(transform.position transform.forward * attackReach, attackRadius); foreach (var hit in hits) { if (hit.CompareTag(Player)) { hit.GetComponentPlayerHealth()?.TakeDamage(attackDamage); } } // 后摇 yield return new WaitForSeconds(attackRecovery); isAttacking false; }Physics.OverlapSphere做范围判定比碰撞体触发更可控因为你能精确控制判定的时机和位置。attackReach是攻击点往前推的距离attackRadius是判定球半径两个参数配合能调出剑挥出去打到人的手感。我一般把判定球画成 Gizmo 在 Scene 视图里看调起来直观。攻击冷却用计时器控制别用协程嵌套协程容易乱。冷却时间要略大于前摇 后摇否则敌人会像连发机关枪一样疯狂攻击玩家根本没有反应窗口。我的经验值是冷却 1.2 到 1.5 秒前摇 0.3 秒后摇 0.4 秒剩下的时间就是玩家的操作空间。3. 完整实现从零把 AI 跑起来3.1 场景与组件准备动手写代码前先把场景和组件理清楚。我需要的对象有一个地面Plane 就行、一个玩家带 CharacterController 或 Rigidbody、一个敌人带 Animator、Collider、以及我们写的 AI 脚本。敌人身上挂一个EnemyAI脚本玩家身上挂一个带TakeDamage方法的血量脚本标签记得设成Player因为判定里要用CompareTag。组件挂载清单我列一下照着配不会漏敌人根节点EnemyAI脚本、Animator、CapsuleCollider、Rigidbody勾选 Is Kinematic 或冻结旋转玩家PlayerHealth脚本、标签设为Player场景至少一个Plane作为地面方便观察移动提示敌人的 Rigidbody 一定要冻结旋转Freeze Rotation 的 XYZ 全勾否则物理引擎会和我们的转向代码打架角色会自己乱转甚至翻倒。这个坑极其常见尤其是新手用AddForce移动时。3.2 状态机主循环代码整个 AI 的骨架就是一个Update里跑状态机。我把完整结构写出来你可以直接拿去改public enum State { Idle, Chase, Attack, Hurt, Dead } public class EnemyAI : MonoBehaviour { public State currentState State.Idle; public Transform player; public float viewRadius 8f; public float viewAngle 120f; public float moveSpeed 3f; public float attackEnterRange 1.5f; public float attackExitRange 2f; public float attackCooldown 1.3f; public float hurtDuration 0.4f; public int maxHealth 100; private int health; private float lastAttackTime; private float hurtTimer; private float loseSightTimer; private Animator animator; void Start() { health maxHealth; animator GetComponentAnimator(); } void Update() { if (currentState State.Dead) return; switch (currentState) { case State.Idle: UpdateIdle(); break; case State.Chase: UpdateChase(); break; case State.Attack: UpdateAttack(); break; case State.Hurt: UpdateHurt(); break; } } void UpdateIdle() { if (CanSeePlayer()) SwitchState(State.Chase); } void UpdateChase() { if (!CanSeePlayer()) { loseSightTimer Time.deltaTime; if (loseSightTimer 2f) { loseSightTimer 0; SwitchState(State.Idle); return; } } else loseSightTimer 0; float d Vector3.Distance(transform.position, player.position); if (d attackEnterRange Time.time - lastAttackTime attackCooldown) { SwitchState(State.Attack); return; } MoveTowards(player.position); } void UpdateAttack() { float d Vector3.Distance(transform.position, player.position); if (d attackExitRange) { SwitchState(State.Chase); return; } FaceTarget(player.position, 8f); } void UpdateHurt() { hurtTimer - Time.deltaTime; if (hurtTimer 0) SwitchState(State.Chase); } void SwitchState(State next) { if (currentState next) return; currentState next; // 状态进入时的初始化 if (next State.Attack) { lastAttackTime Time.time; StartCoroutine(AttackRoutine()); } if (next State.Hurt) hurtTimer hurtDuration; } public void TakeDamage(int dmg) { if (currentState State.Dead) return; health - dmg; if (health 0) { SwitchState(State.Dead); animator.SetTrigger(Die); Destroy(gameObject, 2f); return; } SwitchState(State.Hurt); } }这段代码有几个设计点值得展开。第一SwitchState里做了同状态不重复进入的判断避免每帧调用导致状态进入逻辑重复执行。第二攻击的协程在状态进入时启动而不是在UpdateAttack里每帧判断这样攻击节奏由协程控制不会被帧率影响。第三TakeDamage里先判死亡再判受击保证血量归零时不会先进入 Hurt 再进 Dead。3.3 受击与死亡的处理细节受击这块最容易出问题的是打断逻辑。敌人正在攻击前摇时被打如果只是简单切到 Hurt攻击协程还在后台跑前摇结束照样扣玩家血这就穿帮了。解决办法是在TakeDamage里停掉攻击协程或者用一个isAttacking标志位在协程里检查。public void TakeDamage(int dmg) { if (currentState State.Dead) return; StopAllCoroutines(); // 打断当前攻击 health - dmg; // ... 后续逻辑 }StopAllCoroutines简单粗暴但有效简单 AI 用这个完全够。如果以后有别的协程比如巡逻路径就得改成只停攻击协程用Coroutine变量存引用再StopCoroutine。死亡处理要注意两点一是禁用后续逻辑Update开头判 Dead 直接 return防止尸体还在追人二是延迟销毁给死亡动画留时间Destroy(gameObject, 2f)里的 2 秒要大于死亡动画时长。另外死亡时最好把 Collider 关掉否则玩家还能打到尸体判定会很怪。3.4 参数调优让 AI 手感对味代码跑通只是第一步参数调不好 AI 照样像木头。我整理了一份常用参数的参考区间这些值是我在多个小项目里反复调出来的可以直接当起点参数参考值调优说明viewRadius8-12太小敌人反应迟钝太大玩家没安全感viewAngle100-140120 最自然低于 90 会显得很瞎moveSpeed2.5-4略慢于玩家追击有压迫感但不绝望attackEnterRange1.2-1.8取决于武器长度和角色体型attackExitRange进入值 0.4-0.6缓冲带防抖动attackCooldown1.2-1.5给玩家留反应窗口hurtDuration0.3-0.5太短没反馈太长像卡住调参的顺序我建议是先调moveSpeed和viewRadius让发现和追击的感觉对再调攻击范围让打得到但不会贴脸最后调冷却和硬直控制战斗节奏。一次只调一个参数调完进游戏跑一圈别一次改一堆否则出了问题不知道是哪个参数导致的。4. 实战踩坑与问题排查4.1 敌人原地抖动或状态横跳这是最高频的问题根源几乎都是状态切换没有缓冲。除了前面说的攻击范围滞回追击和待机之间也要加缓冲。玩家在视野边缘反复横跳时敌人会 Chase 和 Idle 之间疯狂切换。解决办法是给丢失目标加一个计时器连续丢失超过 1 到 2 秒才回 Idle这就是前面代码里loseSightTimer的作用。还有一种抖动来自移动和转向的冲突。如果移动方向每帧都在变因为玩家在动而转向又用LookRotation直接赋值角色会左右摇摆。转向一定要用Slerp插值并且插值速度别设太高8 到 10 是比较稳的值。4.2 攻击打不中或判定太宽判定打不中先检查三件事判定球的位置对不对transform.forward * attackReach有没有偏、玩家标签是不是Player、OverlapSphere的 LayerMask 有没有把玩家层排除掉。我遇到过最隐蔽的一次是 LayerMask 设错了判定球明明罩住玩家但OverlapSphere返回空数组查了半天才发现是层的问题。判定太宽则是参数问题。attackRadius设太大敌人隔着老远就能打到人玩家会觉得这游戏判定有毒。我的经验是判定球半径不要超过角色胶囊体半径的 1.5 倍配合attackReach往前推能调出比较精准的手感。调试时把判定球用Gizmos.DrawWireSphere画出来Scene 视图里一目了然。void OnDrawGizmosSelected() { Gizmos.color Color.red; Gizmos.DrawWireSphere(transform.position transform.forward * attackReach, attackRadius); Gizmos.color Color.yellow; Gizmos.DrawWireSphere(transform.position, viewRadius); }4.3 常见问题速查表我把实际开发中遇到的高频问题整理成表出问题时对着查能省不少时间现象可能原因排查方向敌人不动状态卡在 Attack 或 Hurt检查协程是否正常结束、hurtTimer 是否归零敌人穿墙用 Transform 直接移动无碰撞改用 CharacterController 或加碰撞检测攻击无伤害标签或 LayerMask 错误打印 OverlapSphere 结果确认死亡后还能被打Collider 未禁用死亡时关 Collider 或设层转向生硬直接赋值 rotation改用 Slerp 插值多个敌人同时卡顿感知每帧计算改成定时检测加检测间隔4.4 几个提升智商感的小技巧最后分享几个不增加多少代码、但效果立竿见影的技巧。第一加攻击预警攻击前摇时让敌人闪一下或者播放一个预备动作玩家有反应时间战斗体验会好很多。第二加击退受击时给敌人一个反向的位移哪怕只有 0.2 米打击感立刻不一样。第三加巡逻Idle 状态别让敌人傻站着给几个路径点让它来回走场景会活起来。第四加死亡溶解死亡后材质逐渐透明再销毁比直接消失自然得多。这些技巧每一个都只需要十几行代码但组合起来一个简单敌人 AI就能表现出远超其代码量的完成度。我个人在实际项目里的体会是玩家对 AI 的评价往往不取决于它多聪明而取决于它的反馈是否及时、是否可预测、是否有生命感。把受击反馈、攻击预警、死亡表现这三块做扎实比堆一堆复杂决策逻辑更能提升体验。后续如果想让这个 AI 再进一步可以往两个方向扩展一是把移动层换成 NavMesh支持复杂地形二是把状态机升级成带优先级的行为选择支持更多状态。但那是另一个话题了当前这套东西足够撑起一个能玩的小 Demo。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑