Почему if/else убивает ИИ врагов в Unity и как от этого уйти

Страницы:  1

Ответить
 

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-проекта. Записаться на занятие
Полный список бесплатных уроков маясмотрите в дайджесте.
-Источник
 
Loading...
Error