|
Professor Seleznov
|
Каждый, кто делал ИИ для врагов в 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 PatrolPoints { get; } public int CurrentPatrolIndex { get; set; } public EnemyContext(MonoBehaviour owner) { Transform = owner.transform; Agent = owner.GetComponent(); Animator = owner.GetComponent(); PatrolPoints = new List(); } }
Selector и Sequence:
public class Selector : BTNode { private readonly List 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 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 data = new(); public void Set(string key, T value) => data[key] = value; public T Get(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("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-проекта. Записаться на занятие
-Источник
|