Каждый, кто делал ИИ для врагов в Unity, начинал одинаково. Враг стоит на точке, видит игрока — бежит к нему, подбегает — бьёт, здоровье мало — убегает. Пять условий, двадцать строк, всё работает. Через неделю гейм-дизайнер просит добавить патрулирование. Ещё через неделю — чтобы враг звал подкрепление. Ещё через неделю — второй тип врага, который стреляет издалека.
И вот у вас уже 300 строк вложенных if-ов, которые не может прочитать даже тот, кто их написал, а каждое новое поведение ломает два старых.
Как выглядит ИИ на if/else и почему он ломается
Базовый контроллер врага, который все писали:
void Update() { if (health < 20) { Flee(); } else if (CanSeePlayer()) { if (DistanceToPlayer() < attackRange) { Attack(); } else { ChasePlayer(); } } else { Patrol(); } }
Пять веток, читается нормально. Теперь добавляем: прятаться за укрытие при здоровье ниже 50%, подбирать аптечку, отступать к союзникам, кидать гранату на расстоянии, переключаться в ближний бой вблизи, звать подкрепление, если один. Каждое условие втыкается куда-то в середину цепочки. Через месяц враг иногда игнорирует игрока и бежит к аптечке при полном здоровье — одно условие перекрыло другое, и найти это в 300-строчном if/else тяжело.
FSM (т.е конечный автомат) помогает отчасти: состояния явные (Patrol, Chase, Attack, Flee), переходы между ними тоже. Но у FSM своя проблема — взрыв переходов. Пять состояний, каждое может перейти в каждое — 20 переходов. Десять состояний — 90. На десяти состояниях граф превращается в клубок, на который больно смотреть, а добавление одиннадцатого состояния требует прописать до десяти новых переходов.
HFSM (иерархический конечный автомат) облегчает ситуацию вложенными состояниями, но не решает фундаментальную проблему: каждый новый тип поведения требует ручного описания переходов ко всем остальным.
Behaviour Tree: дерево решений вместо графа переходов
Behaviour Tree (BT) подходит к задаче принципиально иначе. Вместо «состояние + переходы» вы описываете дерево, которое обходится сверху вниз каждый тик. Дерево состоит из трёх типов узлов.
Composite — узлы с детьми. Два основных:
Selector пробует детей по очереди, пока один не вернёт Success (как оператор OR). Если все дети вернули Failure, сам возвращает Failure.
Sequence выполняет детей по очереди, пока все не вернут Success (как AND). Если один вернул Failure, останавливается.
Decorator — обёртка над одним узлом. Инвертирует результат (Inverter), повторяет N раз (Repeater), выполняет только при условии (Guard).
Leaf — конечный узел. Либо выполняет действие (бежать к игроку, ударить, проиграть анимацию), либо проверяет условие (вижу ли игрока, мало ли здоровья).
Каждый узел возвращает одно из трёх: Success, Failure, Running (ещё выполняется, вернусь к этому узлу в следующем тике).
Дерево для простого врага:
Selector (корень) ├── Sequence [убегать] │ ├── Condition: здоровье < 20 │ └── Action: бежать от игрока ├── Sequence [сражаться] │ ├── Condition: вижу игрока │ ├── Selector [как именно] │ │ ├── Sequence: в радиусе удара → ударить │ │ └── Action: бежать к игроку └── Action: патрулировать
Корневой Selector пробует ветки сверху вниз. Здоровье мало? Убегаем, дальше не идём. Здоровье нормальное, но вижу игрока? Если в радиусе — бью, если нет — бегу. Ничего из этого? Патрулирую.
Реализация на C# в Unity
Начнём с базовых классов. Каждый узел — абстрактный BTNode с одним методом Tick:
public enum NodeStatus { Success, Failure, Running } public abstract class BTNode { public abstract NodeStatus Tick(EnemyContext ctx); }
EnemyContext — контейнер с данными, которые нужны узлам. Передаём его явно, чтобы узлы не лезли за данными через Singleton и GetComponent:
public class EnemyContext { public Transform Transform { get; } public NavMeshAgent Agent { get; } public Transform Player { get; set; } public float Health { get; set; } public float AttackRange { get; set; } public float SightRange { get; set; } public Animator Animator { get; } public List<Transform> PatrolPoints { get; } public int CurrentPatrolIndex { get; set; } public EnemyContext(MonoBehaviour owner) { Transform = owner.transform; Agent = owner.GetComponent<NavMeshAgent>(); Animator = owner.GetComponent<Animator>(); PatrolPoints = new List<Transform>(); } }
Selector и Sequence:
public class Selector : BTNode { private readonly List<BTNode> children; public Selector(params BTNode[] nodes) => children = nodes.ToList(); public override NodeStatus Tick(EnemyContext ctx) { foreach (var child in children) { var status = child.Tick(ctx); if (status != NodeStatus.Failure) return status; // Success или Running — возвращаем } return NodeStatus.Failure; } } public class Sequence : BTNode { private readonly List<BTNode> children; public Sequence(params BTNode[] nodes) => children = nodes.ToList(); public override NodeStatus Tick(EnemyContext ctx) { foreach (var child in children) { var status = child.Tick(ctx); if (status != NodeStatus.Success) return status; // Failure или Running — возвращаем } return NodeStatus.Success; } }
Теперь условия — leaf-узлы, которые проверяют состояние мира:
public class CheckHealth : BTNode { private readonly float threshold; public CheckHealth(float t) => threshold = t; public override NodeStatus Tick(EnemyContext ctx) => ctx.Health < threshold ? NodeStatus.Success : NodeStatus.Failure; } public class CanSeePlayer : BTNode { public override NodeStatus Tick(EnemyContext ctx) { if (ctx.Player == null) return NodeStatus.Failure; float dist = Vector3.Distance(ctx.Transform.position, ctx.Player.position); if (dist > ctx.SightRange) return NodeStatus.Failure; // Проверяем прямую видимость (raycast) Vector3 direction = ctx.Player.position - ctx.Transform.position; if (Physics.Raycast(ctx.Transform.position + Vector3.up, direction.normalized, out RaycastHit hit, ctx.SightRange)) { return hit.transform == ctx.Player ? NodeStatus.Success : NodeStatus.Failure; } return NodeStatus.Failure; } } public class InAttackRange : BTNode { public override NodeStatus Tick(EnemyContext ctx) { if (ctx.Player == null) return NodeStatus.Failure; float dist = Vector3.Distance(ctx.Transform.position, ctx.Player.position); return dist <= ctx.AttackRange ? NodeStatus.Success : NodeStatus.Failure; } }
И действия — leaf-узлы, которые что-то делают:
public class ChasePlayer : BTNode { public override NodeStatus Tick(EnemyContext ctx) { if (ctx.Player == null) return NodeStatus.Failure; ctx.Agent.isStopped = false; ctx.Agent.SetDestination(ctx.Player.position); ctx.Animator.SetBool("isRunning", true); float dist = Vector3.Distance(ctx.Transform.position, ctx.Player.position); return dist <= ctx.AttackRange ? NodeStatus.Success : NodeStatus.Running; } } public class AttackPlayer : BTNode { private float lastAttackTime; private readonly float cooldown; public AttackPlayer(float cooldown = 1f) => this.cooldown = cooldown; public override NodeStatus Tick(EnemyContext ctx) { if (ctx.Player == null) return NodeStatus.Failure; ctx.Agent.isStopped = true; ctx.Animator.SetBool("isRunning", false); // Поворачиваемся к игроку Vector3 lookDir = (ctx.Player.position - ctx.Transform.position).normalized; lookDir.y = 0; ctx.Transform.rotation = Quaternion.LookRotation(lookDir); if (Time.time - lastAttackTime >= cooldown) { ctx.Animator.SetTrigger("attack"); lastAttackTime = Time.time; } return NodeStatus.Running; } } public class Patrol : BTNode { public override NodeStatus Tick(EnemyContext ctx) { if (ctx.PatrolPoints.Count == 0) return NodeStatus.Failure; var target = ctx.PatrolPoints[ctx.CurrentPatrolIndex]; ctx.Agent.isStopped = false; ctx.Agent.SetDestination(target.position); ctx.Animator.SetBool("isRunning", true); float dist = Vector3.Distance(ctx.Transform.position, target.position); if (dist < 1f) { ctx.CurrentPatrolIndex = (ctx.CurrentPatrolIndex + 1) % ctx.PatrolPoints.Count; } return NodeStatus.Running; } } public class FleeFromPlayer : BTNode { private readonly float fleeDistance; public FleeFromPlayer(float dist = 15f) => fleeDistance = dist; public override NodeStatus Tick(EnemyContext ctx) { if (ctx.Player == null) return NodeStatus.Failure; Vector3 fleeDir = (ctx.Transform.position - ctx.Player.position).normalized; Vector3 fleeTarget = ctx.Transform.position + fleeDir * fleeDistance; if (NavMesh.SamplePosition(fleeTarget, out NavMeshHit hit, fleeDistance, NavMesh.AllAreas)) { ctx.Agent.isStopped = false; ctx.Agent.SetDestination(hit.position); ctx.Animator.SetBool("isRunning", true); } float dist = Vector3.Distance(ctx.Transform.position, ctx.Player.position); return dist > fleeDistance ? NodeStatus.Success : NodeStatus.Running; } }
Собираем всё в EnemyBrain:
public class EnemyBrain : MonoBehaviour { private BTNode root; private EnemyContext ctx; [SerializeField] private float tickInterval = 0.2f; private float nextTickTime; void Start() { ctx = new EnemyContext(this) { Health = 100f, AttackRange = 2f, SightRange = 20f, }; // Находим точки патрулирования foreach (Transform child in transform.parent.Find("PatrolPoints")) ctx.PatrolPoints.Add(child); root = new Selector( new Sequence( new CheckHealth(20f), new FleeFromPlayer() ), new Sequence( new CanSeePlayer(), new Selector( new Sequence(new InAttackRange(), new AttackPlayer()), new ChasePlayer() ) ), new Patrol() ); } void Update() { // Находим игрока (можно закешировать) var player = GameObject.FindWithTag("Player"); ctx.Player = player != null ? player.transform : null; // Тикаем не каждый кадр, а с интервалом if (Time.time >= nextTickTime) { root.Tick(ctx); nextTickTime = Time.time + tickInterval; } } }
Тик не каждый кадр, а раз в 0.2 секунды. Если враг патрулирует и игрока не видно, пересчитывать дерево 60 раз в секунду бессмысленно.
Decorator: обёртки для переиспользования
Decorator оборачивает один узел и модифицирует его поведение. Самые полезные:
public class Inverter : BTNode { private readonly BTNode child; public Inverter(BTNode child) => this.child = child; public override NodeStatus Tick(EnemyContext ctx) { var status = child.Tick(ctx); return status switch { NodeStatus.Success => NodeStatus.Failure, NodeStatus.Failure => NodeStatus.Success, _ => status, // Running остаётся Running }; } } public class Cooldown : BTNode { private readonly BTNode child; private readonly float interval; private float lastRunTime = float.MinValue; public Cooldown(float interval, BTNode child) { this.interval = interval; this.child = child; } public override NodeStatus Tick(EnemyContext ctx) { if (Time.time - lastRunTime < interval) return NodeStatus.Failure; var status = child.Tick(ctx); if (status != NodeStatus.Failure) lastRunTime = Time.time; return status; } } public class RepeatUntilFail : BTNode { private readonly BTNode child; public RepeatUntilFail(BTNode child) => this.child = child; public override NodeStatus Tick(EnemyContext ctx) { var status = child.Tick(ctx); return status == NodeStatus.Failure ? NodeStatus.Success : NodeStatus.Running; } }
Inverter полезен, когда нужно «если НЕ видит игрока»:
new Sequence( new Inverter(new CanSeePlayer()), // если НЕ вижу игрока new Patrol() // патрулирую )
Cooldown не даёт узлу выполняться чаще раза в N секунд. Полезно для крика о подкреплении (не кричать каждый тик):
new Cooldown(10f, new CallForBackup()) // звать подкрепление не чаще чем раз в 10 секунд
Blackboard: общая доска данных
EnemyContext, который мы передаём в узлы, это простейший вариант хранения данных. В более сложных проектах используют Blackboard — словарь, в который узлы могут писать и читать произвольные данные:
public class Blackboard { private readonly Dictionary<string, object> data = new(); public void Set<T>(string key, T value) => data[key] = value; public T Get<T>(string key, T defaultValue = default) { if (data.TryGetValue(key, out var value) && value is T typed) return typed; return defaultValue; } public bool Has(string key) => data.ContainsKey(key); public void Remove(string key) => data.Remove(key); }
Узел поиска укрытия записывает на доску позицию найденного укрытия, а узел движения читает:
public class FindCover : BTNode { public override NodeStatus Tick(EnemyContext ctx) { // Ищем ближайшее укрытие var covers = Physics.OverlapSphere(ctx.Transform.position, 20f, coverLayer); if (covers.Length == 0) return NodeStatus.Failure; var nearest = covers.OrderBy(c => Vector3.Distance(c.transform.position, ctx.Transform.position)).First(); ctx.Blackboard.Set("cover_position", nearest.transform.position); return NodeStatus.Success; } } public class MoveToCover : BTNode { public override NodeStatus Tick(EnemyContext ctx) { if (!ctx.Blackboard.Has("cover_position")) return NodeStatus.Failure; var target = ctx.Blackboard.Get<Vector3>("cover_position"); ctx.Agent.SetDestination(target); float dist = Vector3.Distance(ctx.Transform.position, target); if (dist < 1f) { ctx.Blackboard.Remove("cover_position"); return NodeStatus.Success; } return NodeStatus.Running; } }
В дереве:
new Sequence( new CheckHealth(50f), new FindCover(), new MoveToCover() )
Blackboard позволяет узлам обмениваться данными, оставаясь при этом независимыми: FindCover не знает про MoveToCover, а MoveToCover не знает, кто записал позицию на доску.
Как добавить новый тип врага
Вот где BT окупается. Допустим, нужен лучник, который стреляет издалека, но убегает в ближний бой.
public class ArcherBrain : MonoBehaviour { void Start() { ctx = new EnemyContext(this) { Health = 60f, AttackRange = 15f, // стреляет издалека SightRange = 25f, }; root = new Selector( // Убежать если здоровье мало new Sequence(new CheckHealth(15f), new FleeFromPlayer()), // Если игрок слишком близко — отбежать new Sequence( new CanSeePlayer(), new InRange(5f), // игрок ближе 5 метров new FleeFromPlayer(10f) ), // Стрелять если вижу и в радиусе new Sequence( new CanSeePlayer(), new InAttackRange(), new Cooldown(2f, new RangedAttack()) ), // Подойти на расстояние выстрела new Sequence( new CanSeePlayer(), new ChasePlayer() ), new Patrol() ); } }
Узлы CheckHealth, CanSeePlayer, FleeFromPlayer, ChasePlayer, Patrol — те же, что у мечника. Новые только RangedAttack и InRange. Структура дерева другая (лучник отбегает вблизи, а мечник наоборот атакует), но строительные блоки переиспользуются.
Добавить третий тип врага — мага, который лечит союзников — это ещё одно дерево из тех же блоков плюс пара новых (FindWoundedAlly, HealAlly). Ни один существующий узел не меняется.
Ошибки, которые делают все
Состояние внутри composite-узлов. Selector и Sequence не должны хранить, какой ребёнок выполнялся в прошлом тике. Каждый тик дерево обходится с нуля, от корня. Если нужен узел, который помнит текущего ребёнка (например, Sequence, продолжающий с того места, где остановился на Running), это отдельный тип — MemSequence, и его нужно использовать осознанно.
Отсутствие обработки прерывания. ChasePlayer вернул Running. На следующем тике Selector переключился на FleeFromPlayer. Но NavMeshAgent всё ещё бежит к игроку, потому что SetDestination не был сброшен. При выходе из Running нужно вызывать Reset на узле:
public abstract class BTNode { public abstract NodeStatus Tick(EnemyContext ctx); public virtual void Reset(EnemyContext ctx) { } } public class ChasePlayer : BTNode { public override NodeStatus Tick(EnemyContext ctx) { /* ... */ } public override void Reset(EnemyContext ctx) { ctx.Agent.isStopped = true; ctx.Animator.SetBool("isRunning", false); } }
Тик каждый кадр. Враг патрулирует, игрока нет рядом, а дерево пересчитывается 60 раз в секунду. Используйте интервал 0.1–0.5 секунды, или тикайте по событию (враг получил урон, игрок вошёл в триггер-зону). На сцене с 50 врагами разница в производительности будет заметной.
Слишком глубокое дерево. Больше 5-6 уровней вложенности — уже тяжело читать. Выносите поддеревья в методы:
BTNode CombatSubtree() => new Sequence( new CanSeePlayer(), new Selector( new Sequence(new InAttackRange(), new AttackPlayer()), new ChasePlayer() ) ); root = new Selector( new Sequence(new CheckHealth(20f), new FleeFromPlayer()), CombatSubtree(), new Patrol() );
Behaviour Tree — не единственный способ делать ИИ врагов. Для простого врага с двумя состояниями хватит и if/else. Но если в проекте больше трёх типов врагов, если поведение регулярно меняется по ходу разработки и если вы устали от того, что добавление одного нового действия ломает два старых — BT сэкономит кучу нервов. Основная идея простая: узлы независимы, переиспользуемы и тестируемы по отдельности, а дерево читается сверху вниз как список приоритетов.
Тема зависимостей в Unity продолжится на демо-уроке «Zenject в разработке игр на Unity», который пройдёт 21 мая в 20:00 в рамках курса «Unity-разработчик. Продвинутый уровень».
На нём разберемся, зачем DI нужен в игровых проектах, как Zenject помогает уменьшить связность кода и какие ошибки чаще всего появляются при его внедрении. Урок бесплатный: можно протестировать формат обучения и задать свои вопросы по архитектуре Unity-проекта. Записаться на занятие
Полный список бесплатных уроков мая смотрите в дайджесте.
leschenko
Я бы еще добавил, что практика if/else в любой логике, в которую часто вносятся изменения/дополнения рано или поздно приведет к состоянию чемодана без ручки.
Очень часто такие if-чики добавляются, когда нет полного понимания что и как именно делает код, но вот в данном конкретном случае что-то не так + над душой висят - сделать надо неделю назад. Добавили 1 if - "прокатило". Добавили еще и еще - всё сломалось, а переписать - год - никто не даст.