惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Threat Research - Cisco Blogs
H
Hacker News: Front Page
IT之家
IT之家
I
Intezer
GbyAI
GbyAI
MongoDB | Blog
MongoDB | Blog
博客园_首页
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
S
SegmentFault 最新的问题
D
Darknet – Hacking Tools, Hacker News & Cyber Security
T
Threatpost
Cisco Talos Blog
Cisco Talos Blog
C
Check Point Blog
P
Proofpoint News Feed
P
Privacy International News Feed
有赞技术团队
有赞技术团队
T
Tailwind CSS Blog
Scott Helme
Scott Helme
U
Unit 42
J
Java Code Geeks
W
WeLiveSecurity
H
Hackread – Cybersecurity News, Data Breaches, AI and More
C
CERT Recently Published Vulnerability Notes
小众软件
小众软件
The Hacker News
The Hacker News
L
LINUX DO - 热门话题
博客园 - 【当耐特】
G
Google Developers Blog
Latest news
Latest news
AWS News Blog
AWS News Blog
NISL@THU
NISL@THU
S
Secure Thoughts
P
Proofpoint News Feed
L
Lohrmann on Cybersecurity
F
Full Disclosure
S
Securelist
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
Engineering at Meta
Engineering at Meta
Security Archives - TechRepublic
Security Archives - TechRepublic
人人都是产品经理
人人都是产品经理
T
Tor Project blog
Recent Announcements
Recent Announcements
Security Latest
Security Latest
N
News | PayPal Newsroom
A
About on SuperTechFans
Hugging Face - Blog
Hugging Face - Blog
Y
Y Combinator Blog
大猫的无限游戏
大猫的无限游戏
博客园 - Franky
T
The Blog of Author Tim Ferriss

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Почему if/else убивает ИИ врагов в Unity и как от этого уйти
badcasedaily · 2026-05-07 · via Все публикации подряд на Хабре

Уровень сложностиСредний

Время на прочтение10 мин

Охват и читатели790

Туториал

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

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