Пишем ИИ для игры. Часть 1: Как найти противников в поле зрения

Всем привет. С сегодняшнего дня я решил начать написание цикла статей по работе с ИИ противника. В ходе данного цикла я хочу показать на простых примерах, как можно сделать ИИ в игре, который будет учитывать поле зрения противника, уровень издаваемого шума, умеет патрулировать местность, ставить приоритетные цели и обладать различными типами поведения.

Field of View
Field of View

В конце цикла статей наш ИИ будет уметь:

  • Получать список целей по FOV;
  • Искать путь до ближайшей цели, будь то враг или что-то другое;
  • Обладать типом (враг, союзник, болванка);
  • Обладать поведением (патруль, поиск цели, бой, следование, убегание);
  • Учитывать уровень шума и освещенности;
  • Работать с инверсной кинематикой (получать Impact конечностей, смотреть на врагов);

Для чего это нужно?

В сегодняшней статье я расскажу о простой реализации поля зрения противника. Дабы исключить архитектурные особенности - мы сделаем все на обычных монобехах.

Итак, начнем с того, что должно делать наше поле зрения:

  • Находить цели, которые попадают в угол обзора;
  • Ставить приоритет на ближайшую цель в области обзора;
  • При выходе текущей цели из поля обзора - сохранять её до определенной дистанции;
  • При полной потере цели - переключиться на другие;

Интерфейс Field of View

Начнем с того, что нам нужен некий интерфейс, который сможет просто обрабатывать наше поле зрения:

public interface IFOV { float Radius { get; } float Angle { get; } Transform CurrentTargetTransform { get; } bool HasTargets(); List<Transform> GetAllTargets(); Transform GetNearestTarget(); void ForceRecalculate(); }

Рассмотрим, что содержит наш интерфейс:

  • Параметры Radius, Angle - для того, чтобы получить возможность узнать информацию о FOV;
  • Параметр CurrentTarget (в моем случае для простоты используется Transform, но лучше сделать интерфейс ITarget и работать с ним);
  • Методы для проверки целей - HasTargets, GetAllTargets, GetNearestTarget и ForceRecalculate.

Почему здесь нет метода поиска целей, а сделан только ForceRecalculate? Мы делаем просчет внутри самого компонента FOV, а все его данные получаем через его методы обработки целей. ForceRecalculate нужен нам только тогда, когда к примеру текущая цель умерла и хочет оповестить наш объект об этом.

Базовая реализация FOV

Теперь приступим к самой реализации FOV. По своей сути он работает через оверлап коллайдера, однако вы можете использовать Raycast. Также у компонента FOV есть таймер пересчета целей и чем меньше он будет, тем больше будет нагрузки и тем выше точность поиска. Таймер полезен тогда, когда игрок непосредственно видит ИИ противника и для него выставляется наименьший таймер, а для противников в далеке - наибольший.

internal class FieldOfView : MonoBehaviour, IFOV { [Header("Field of View Parameters")] [SerializeField] private float _viewRadius = 3f; [SerializeField] [Range(0,360)] private float _viewAngle = 90f; [SerializeField] private float _distanceToLostTarget = 2f; [SerializeField] private double _updateTimer = 0.2; [Header("Layer Mask")] [SerializeField] private LayerMask _targetMask; [SerializeField] private LayerMask _obstructionMask; // Targets Data private bool _hasTargets = false; private bool _isLostTargets = false; private Transform _currentTarget = null; private List<Transform> _targetsList = new List<Transform>(); private IDisposable _searchTargets; public GameEvent<Transform> OnMainTargetChanged = new GameEvent<Transform>(); public GameEvent OnTargetsFound = new GameEvent(); public GameEvent OnTargetsLost = new GameEvent(); // Public Fields public float Radius => _viewRadius; public float Angle => _viewAngle; public Transform CurrentTargetTransform => _currentTarget; private void Start() { _searchTargets = Observable.Interval(TimeSpan.FromSeconds(_updateTimer)).Subscribe(l => { FieldOfViewCheck(); }); } private void FieldOfViewCheck() { // Check Current FOV if (!_hasTargets || _currentTarget==null) { _targetsList.Clear(); Collider[] rangeChecks = Physics.OverlapSphere(transform.position, _viewRadius, _targetMask); if (rangeChecks.Length > 0) { for (int i = 0; i < rangeChecks.Length; i++) { bool isSeeTarget = false; Transform target = rangeChecks[i].transform; Vector3 directionToTarget = (target.position - transform.position).normalized; if (Vector3.Angle(transform.forward, directionToTarget) < _viewAngle / 2) { float distanceToTarget = Vector3.Distance(transform.position, target.position); if (!Physics.Raycast(transform.position, directionToTarget, distanceToTarget, _obstructionMask)) { isSeeTarget = true; } else isSeeTarget = false; } else isSeeTarget = false; if (isSeeTarget) { _targetsList.Add(target); } } } } // Get Nearest Target if (!_hasTargets && _targetsList.Count > 0) { Transform nearestTarget = GetNearestTarget(); if (_currentTarget != nearestTarget && nearestTarget!=null) { _hasTargets = true; _currentTarget = nearestTarget; OnMainTargetChanged?.Invoke(_currentTarget); OnTargetsFound?.Invoke(); } } // Check Distance to Current Targets if (_hasTargets) { float distanceToTarget = Vector3.Distance(_currentTarget.position, transform.position); if (distanceToTarget > _distanceToLostTarget) { _hasTargets = false; _currentTarget = null; return; } _isLostTargets = false; } if (!_hasTargets && _targetsList.Count < 1) { if (!_isLostTargets) { _isLostTargets = true; OnTargetsLost?.Invoke(); } } } public List<Transform> GetAllTargets() { return _targetsList; } public Transform GetNearestTarget() { Transform nearestTarget = null; float nearestDistance = 0f; if (_targetsList.Count < 1) return null; for (int i = 0; i < _targetsList.Count; i++) { float currentDistance = Vector3.Distance(transform.position, _targetsList[i].position); if (i == 0) { nearestTarget = _targetsList[i]; nearestDistance = currentDistance; continue; } if (currentDistance < nearestDistance) { nearestTarget = _targetsList[i]; nearestDistance = currentDistance; } } return nearestTarget; } public bool HasTargets() { return _hasTargets; } public void ForceRecalculate() { FieldOfViewCheck(); } }

Теперь разберем подробнее составляющие кода:

  • В методе Start() мы запускаем наш счетчик проверки FieldOfView через интервал. В моем случае используется UniRx, но вы можете сделать реализацию таймера по-другому.
  • Метод FieldOfViewCheck() запускает процесс проверки целей внутри поля зрения. Если изначально у нас нет никаких целей - мы проверяем есть ли кто-то по нужному слою в физике, затем смотрим ближайшую цель и добавляем её в список. Если же цель есть - мы смотрим дистанцию до неё вне зависимости от поля зрения и если главная цель слишком далеко - пересчитываем снова список целей.
  • Дополнительные методы GetAllTargets, GetNearestTarget, HasTargets и ForceRecalculate служат вспомогательными. Они могут использоваться в нашем контроллере ИИ.

Теперь мы научили нашего противника определять ближайшую цель в поле зрения:

Пишем ИИ для игры. Часть 1: Как найти противников в поле зрения

Так же важно, что если цель покинет поле зрения, то герой останется на ней сфокусирован, пока её дистанция не увеличиться больше допустимой, в этом случае он опять будет переключаться на ближайшую цель в поле зрения. Так же нужно учесть, что пока здесь не реализовано поведение, которое учитывает урон от ближайших целей (о чем мы поговорим в следующих частях цикла).

Итог

Проверка поля зрения позволяет нам убедиться, что наш ИИ вообще видит кого-то перед собой, цель не перекрывается и задать приоритет по целям исходя из дистанции. В дальнейшем, мы будем комбинировать FieldOfView с показателями шума, издаваемого целями, а также задавать поведение для нашего ИИ.

Следующая часть статьи будет включать в себя обработку целей в зависимости от паттерна поведения нашего ИИ, а также же учитывать инверсную кинематику для того, чтобы наш герой поворачивал голову и туловище к ближайшей цели.

Буду рад пообщаться на эту тему и послушать о ваших реализациях FOV.

3333
18 комментариев

Lead Unity Developer

if (!Physics.Raycast(transform.position, directionToTarget, distanceToTarget,
_obstructionMask))
{
isSeeTarget = true;
}
else
isSeeTarget = false;

Ну серьезно?
isSeeTarget = !Physics.Raycast(transform.position, directionToTarget, distanceToTarget, _obstructionMask) же.

4
Ответить

Там еще флаги изначально стояли, я их вырезал и забыл поменять

Ответить

Интерфейс IFOV выполняет несколько задач. Там пробел в одну строку как раз там где следует его порезать на два разных интерфейса. Тот что является Fov реализацией и тот что отвечает за поиск целей в реализаций.
Есть версия overlap sphere non alloc. Текущаягенерит каждый раз новый лист, даже если он ре нужен. Еще, не уверен до конца, но вроде бы вызов этих методов следует в FixedUpdate засунуть, но это не точно.
IsSeeTarget просто не нужен - добавляй в список и все. Код чище будет.
Не нравится проверка на попадание в угол зрения - normalized - высчитывает корень квадратный, лучше бы этого избежать.
GetNearestTarget делает расчеты при каждом вызове. Следует хранить таргеты в отсортированном по дистанции виде и просто возвращать первый.

3
Ответить

Со всем согласен, кроме GetNearestTarget. Позиция ближайшего может измениться на следующий вызов метода, так что его следует пересчитать. В целом это просто на понимание как это работает, код естественно написан за 5 минут без всякой оптимизации

1
Ответить

Очень грубое решение задачи в лоб, но как пример для тех кто не делал ИИ никогда - сойдет. Такое можно использовать в первом приближении новичку понимая что это не оптимальный код и нужна оптимизация. Кстати делать какие либо проверки визуального контакта с игроком когда он на большем расстоянии чем может видеть ИИ не имеет смысла даже раз в минуту. В данном случае оверлап используется именно для определения того что игрок не спрятался и находится в поле зрения. Также скрипт который будут вешать на всех врагов должен использовать подсистему ИИ, которая и должна будет уже делать все самые сложные расчеты выбирая для них только те объекты которые могут встретится хотя бы теоретически. Но это уже способы оптимизации и они будут важны для конкретных случаев использования ИИ. Будем ждать продолжения. Спасибо за статью. Тем более тоже было желание подобное написать, но так и не определился для какой аудитории.

3
Ответить

Рейкаст на проверку видимости кидается из пивота объекта, в данном случае из точки на земле (видно на скриншоте). Если персонаж будет стоять рядом с ящиком или забором, который ему по пояс, он "не увидит" врага, стоящего за ним, потому что рейкас из ног будет сразу упираться в препятствие.

Как вариант можно добавить чайлд-пустышку на уровне глаз персонажа и кидать рейкаст из него. При таком подходе пивот может быть где угодно и не повлияет на качество проверки рейкаста.

2
Ответить

Вот почему все нужно дебажить, да.

Ответить