Pull to refresh

Rule-based AI + Unity

Level of difficultyEasy
Reading time8 min
Views2.2K

Привет! В прошлой статье мы уже говорили про искусственный интеллект и трудности его выбора. В этой мы поговорим как начать его писать так чтобы потом не выстрелить себе в жопу ногу.

Возвращаясь к пресловутой шкале “сложности искусственного интеллекта”, по классике все что находится левее State Machine, называют каким-нибудь Scripting AI/Non-scripted AI/No-framework AI и так далее. Так же как и названия, сами они представляют из себя хаотичный набор условий и поведений, реализованный без какого-либо централизованного подхода. Там нет выделенных состояний, переходов и любых других переиспользуемых компонентов-кирпичиков, из которых состоят все остальные подходы.

В качестве примера - птица в open-world игре. Она летит через всю карту по заданному маршруту, при попадании в нее падает, а через 5 секунд после падения исчезает с каким-нибудь эффектом. Для такого поведения городить фреймворки будет себе дороже.

Но есть один подход, который находится между вышеуказанным “подходом” и остальными классическими подходами для создания игрового ИИ. В книжке Game AI Pro он именуется Rule-Based AI (не путать Rule Based AI, применяемый на совершенно другом уровне ИИ).

Его плюсы:

  • Гораздо проще более “серьезных” подходов типа FSM, BT, Utility AI и GOAP

  • Легко трансформируется в любой из этих подходов при необходимости (хотя не понимаю кому в здравом уме придет мысль “о, теперь мы все обдумали и решили, что будем писать ИИ на стейт машине”)

  • Очень гибкий и достаточно долго может существовать как самостоятельное решение, если игра не требует огромного количества поведений или продвинутых алгоритмов их смены

В этой части статьи разберемся что это и как реализовать в Unity, а в следующих - как от него при необходимости переходить к более серьезным подходам. Вместе с причинами, по которым это стоит или не стоит делать.

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

Определение

Rule-based AI состоит из коллекции пар предикат-действие. Мы проверяем все предикаты и для первого, у которого этот предикат истинный, выполняем действие.

Поехали!

1. Создаем “фреймворк”

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

Сначала определим наши основные классы для ИИ:

public interface IRule
{
    bool CanExecute { get; }
    void Execute();
}

И сам актор, с которым будет взаимодействовать игры:

public class Actor
{
    private readonly List<IRule> _rules;

    public Actor(params IRule[] rules) => 
        _rules = rules.ToList();

    public void Act() =>
        _rules
            .Find(x => x.CanExecute)
            ?.Execute();
}

Проще некуда, но за этой простотой скрывается большой потенциал.

2. Создаем точку входа и игровые сервисы

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

public class EntryPoint : MonoBehaviour
{
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Alpha0))
            CreateActor(teamId:0);
        
        if (Input.GetKeyDown(KeyCode.Alpha1))
            CreateActor(teamId:1);

        UpdateActors();
    }
}

Теперь посмотрим, что нам нужно для реализации этой логики. Метод “CreateActor” подсказывает нам о создании фабрики для персонажей, а метод “UpdateActors” - репозитория с ними.

Начнем наоборот с репозитория. Внутри нам нужны только методы добавления, удаления сущностей, выполнения какого-нибудь метода для каждого из них и сама коллекция для выборки. Сделаем также класс обобщенным, чтобы хранить Actor’ов и персонажей отдельно:

public class Repository<T>
{
    public IEnumerable<T> Items => _items.Values;

    private readonly Dictionary<Guid, T> _items = new();

    public void Register(Guid id, T item) => 
        _items.Add(id, item);

    public void Unregister(Guid id) => 
        _items.Remove(id);

    public void ForEach(Action<T> action)
    {
        foreach (T item in _items.Values.ToArray())
            action(item);
    }
}

Обратите внимание, что при переборе объектов в ForEach мы делаем дополнительный массив методом ToArray(). Иначе при большом количестве персонажей мы можем поймать ошибку, что в момент перебора массива извне из него будут добавляться или удаляться элементы.

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

  • персонажа будем загружать из ресурсов

  • создавать будем в рандомной точке, максимальный разброс для этой точке зададим константой сразу внутри фабрики (и наплевать, что так неправильно)

  • потом прокинем teamId в нашего персонажа, чтобы в дальнейшем в зависимости от него могли выбирать противника

  • Создадим Actor’а, которого далее заполним возможными действиями персонажа

  • Класс Character будет содержать данные персонажа и выполнять методы для движения и атаки (немного перегруженный, но в контексте статьи мне это не важно)

  • Зарегистрируем персонажа в Repository<Character>, а Actor’a в Repository<Actor>

public class CharacterFactory
{
    private const float FIELD_SIZE = 15f;
    
    private readonly Repository<Character> _characters;
    private readonly Repository<Actor> _actors;

    public CharacterFactory(Repository<Character> characters, Repository<Actor> actors)
    {
        _characters = characters;
        _actors = actors;
    }

    public void Create(int team)
    {
        Guid id = Guid.NewGuid();
        Vector3 startPosition = new(Range(-FIELD_SIZE, FIELD_SIZE), 0f, Range(-FIELD_SIZE, FIELD_SIZE));
        Character instance = Instantiate(
		        Resources.Load<Character>("Character"), 
		        startPosition, 
		        Quaternion.identity);

        instance.Setup(team);
        
        Actor actor = new();
        
        _characters.Register(id, instance);
        _actors.Register(id, actor);
    }
}

3. Напишем персонажа

Теперь перейдем к классу персонажа. Я сразу напишу в нем все что понадобится нашим будущим правилам, а потом перейду к написанию ИИ. Следующий пример кода еще 10 раз можно переделать и разделить на меньшие по их ответственностям. Например, отдельный компонент для движения, другой держит ссылку на противника, третий для атаки, четвертый для получения урона. Таким образом каждое правило смогло бы работать с меньшим контекстом персонажа и быть более переиспользуемым. Но это опять же не тема статьи. В ней все что умеет делать персонаж будет лежать в одном огромном (не таком уж и огромном, всего 60 строк) классе:

public class Character : MonoBehaviour
{
    public event Action OnDamage;

    public bool HasEnemy => _enemy is { IsAlive : true };
    public bool CloseToEnemy => HasEnemy && Distance(Position, _enemy.Position) < 1f;
    public bool InCooldown => time - _attackTime < _attackCooldown;
    public float Health { get; private set; }
    public Vector3 Position => transform.position;
    public int Team { get; private set; }

    [field: SerializeField] public float MaxHealth { get; private set; } = 5f;

    private bool IsAlive => Health > 0f;

    [SerializeField] private Rigidbody _rigidbody;
    [SerializeField] private float _speed = 2.5f;
    [SerializeField] private float _attackCooldown = 1f;
    [SerializeField] private float _damage = 1f;
    
    private Character _enemy;
    private float _attackTime;

    public void Setup(int team)
    {
        Team = team;
        Health = MaxHealth;
    }

    public void MoveToEnemyPosition()
    {
        Vector3 target = MoveTowards(Position, _enemy.Position, _speed * deltaTime);
        Vector3 direction = (_enemy.Position - Position).normalized;
        _rigidbody.MovePosition(target);
        transform.forward = direction;
    }

    public void SetEnemy(Character enemy) => 
        _enemy = enemy;

    public void AttackEnemy()
    {
        if (!_enemy.IsAlive)
        {
            _enemy = null;
            return;
        }
        
        _enemy.TakeDamage(_damage);
        _attackTime = time;

        if (!_enemy.IsAlive)
            _enemy = null;
    }

    private void TakeDamage(float damage)
    {
        Health = Max(Health - damage, 0f);
        OnDamage?.Invoke();
    }
}

4. Правила

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

public class FindEnemy : IRule
{
    public bool CanExecute => !_context.HasEnemy;
    
    private readonly Character _context;
    private readonly Repository<Character> _characters;

    public FindEnemy(Character context, Repository<Character> characters)
    {
        _context = context;
        _characters = characters;
    }

    public void Execute() => 
        _context.SetEnemy(_characters
            .Items
            .Where(x => x != _context && x.Team != _context.Team)
            .OrderBy(x => Distance(x.Position, _context.Position))
            .FirstOrDefault());
}

Следующее правило будет двигать персонажа к выбранному противнику, при наличии последнего:

public class FollowEnemy : IRule
{
    public bool CanExecute => _context.HasEnemy && !_context.CloseToEnemy;
    
    private readonly Character _context;
    
    public FollowEnemy(Character context) => 
        _context = context;

    public void Execute() => 
        _context.MoveToEnemyPosition();
}

Если персонаж достаточно близко к противнику и не находится в кулдауне атаки, то выполняется следующее правило:

public class AttackEnemy : IRule
{
    public bool CanExecute => _context.CloseToEnemy && !_context.InCooldown;

    private readonly Character _context;

    public AttackEnemy(Character context) => 
        _context = context;

    public void Execute() => 
        _context.AttackEnemy();
}

И если здоровье игрока равно нулю, то следующее правило его уничтожит и сотрет из всех репозиториев:

public class Die : IRule
{
    public bool CanExecute => _context.Health.ApproximatelyEqual(0f);

    private readonly Guid _id;
    private readonly Character _context;
    private readonly Repository<Character> _characters;
    private readonly Repository<Actor> _actors;

    public Die(Guid id, Character context, Repository<Character> characters, Repository<Actor> actors)
    {
        _id = id;
        _context = context;
        _characters = characters;
        _actors = actors;
    }

    public void Execute()
    {
        _characters.Unregister(_id);
        _actors.Unregister(_id);
        Object.Destroy(_context.gameObject);
    }
}

Все что нам остается сделать это нацепить скрипт Character на префаб с персонажем, положить на сцену пустой объект со скриптом EntryPoint и готово.

5. Красивости

Также красоты ради напишем небольшой скрипт, который будет отображать команду персонажа, его очки здоровья и сам факт нанесения урона (не забудьте импортировать пакет DoTween для анимации получения урона в методе HandleDamage()):

public class CharacterView : MonoBehaviour
{
    [SerializeField] private Color[] _colors;
    [SerializeField] private Renderer _renderer;
    [SerializeField] private Slider _slider;
    
    private Character _character;

    public void Setup(Character character)
    {
        _character = character;
        _renderer.material.color = _colors[character.Team];
        character.OnDamage += HandleDamage;

        SetupHealthValue();
    }

    private void OnDestroy() => 
        _character.OnDamage -= HandleDamage;

    private void HandleDamage()
    {
        SetupHealthValue();
        _renderer 
            .material
            .DOColor(red, .15f)
            .SetLoops(2, Yoyo);
    }

    private void SetupHealthValue() => 
        _slider.value = _character.Health / _character.MaxHealth;
}

После запуска по нажатию клавиш 0 и 1, будут создаваться персонажи. При достаточно активном нажатии, картина будет похожа на это:

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

  • сделать обобщенный абстрактный класс Rule<T>, где T было бы контекстом, с которым бы работало правило. Тогда не пришлось бы в каждом правиле писать одинаковый конструктор с инициализацией поля Character

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

  • наоборот в рамках оптимизации или создания более ECS-подобной архитектуры, можно было бы делать правила “чистыми”. То есть, контекст в них мы бы передавали не через конструктор, а напрямую в методы. Это бы означало, что нам не будет смысла плодить объекты правил для каждого персонажа. На нашем примере это не страшно, но представьте, если таких правил будет 15-20, а игроков одновременно на сцене 500-1000. То есть 20*1000 = 20000 лишних объектов для бедного сборщика мусора.

  • добавить декораторы для правил: обертку, которая запускает несколько правил по очереди или запускает их параллельно

Но при каждом таком усовершенствовании в первую очередь надо будет задать себе вопрос: “А не пытаюсь ли я на самом деле реализовать какой-либо уже существующий фреймворк?”. Ведь декораторы взяты напрямую из Behavior Tree, а состояния в правилах - из State Machine. И нужно ли тебе на самом деле писать велосипед или лучше будет разом перейти на другой подход к написанию ИИ.

Подробнее об этом мы поговорим в следующей статье! Если кто захочет узнать еще больше о разработке, добро пожаловать на мой канал в telegram и youtube, на котором эта статья скоро выйдет в видео формате.

А если кто-то захочет видеть все мои работы раньше остальных или просто поддержать, то добро пожаловать на Boosty!

Спасибо!

Tags:
Hubs:
Total votes 3: ↑1 and ↓2+1
Comments6

Articles