Каждый, кто делал ИИ для врагов в 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-проекта. Записаться на занятие

Полный список бесплатных уроков мая смотрите в дайджесте.

Комментарии (2)


  1. leschenko
    07.05.2026 16:18

    Я бы еще добавил, что практика if/else в любой логике, в которую часто вносятся изменения/дополнения рано или поздно приведет к состоянию чемодана без ручки.

    Очень часто такие if-чики добавляются, когда нет полного понимания что и как именно делает код, но вот в данном конкретном случае что-то не так + над душой висят - сделать надо неделю назад. Добавили 1 if - "прокатило". Добавили еще и еще - всё сломалось, а переписать - год - никто не даст.


  1. HexGrimm
    07.05.2026 16:18

    На моей практике Blackboard это жестокий антипаттерн. Нет ограничений кто что пишет, и какие сигнатуры у данных. Не рекомендую.