UtilityAI - реализация для LeoECS Proto

UtilityAI - еще один популярный подход наравне с GOAP для реализации поведения ИИ на основе настраиваемых правил. В чем же разница?

Введение

GOAP представляет собой набор действий, выполняя которые бот должен достичь определенной цели из его текущего состояния. Если нет последовательности действий, по которым можно “пройти” до цели - данный подход не предлагает ничего в качестве запасного поведения по умолчанию. Т.е мы или можем достичь цели одним самым оптимальным путем, либо у нас нет решения в принципе - мы не можем пройти половину пути, а потом на месте решить что делать дальше. Можно так же почитать статью AI GOAP - планировщик целеориентированного ИИ

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

Это основное отличие этих двух решений - GOAP выполняет планирование действий для достижения цели, UtilityAI просто выбирает действие прямо сейчас без любого дальнейшего планирования.

Основные определения UtilityAI

  • Решение. Какое-то абстрактное поведение, имеющее “ценность”. UtilityAI ничего не знает о том, что происходит в Решении и может только запрашивать у него оценку целесообразности.
  • Параметры. Какие-то внешние данные, на основе которых происходит оценка целесообразности Решения. Параметров может не быть (например, для какого-то действия по умолчанию типа “Ожидания” нет в них нужды), может быть 1, 2, 3 или больше. В случае 2-х Параметров и более, оценка все-равно должна быть представлена одним значением, по которому будет производиться сравнительная оценка между Решениями для выбора лучшего.
  • Оценщик. Сердце UtilityAI и одновременно - ее самая простая часть: принимает запрос на вычисление следующего лучшего Решения, вызывает оценку всех действий с передачей Параметров, выбирает вариант с максимальным значением и возвращает его как результат своей работы.

Пример

Рассмотрим простой пример для LeoECS Proto - бот знает о своем “Голоде”, знает количество “Еды” в карманах, умеет “Ждать”, “Искать еду” и “Есть еду”.

“Голод” и “Еда” - это Параметры.

“Ждать”, “Искать еду” и “Есть еду” - это Решения.

Давайте переведем это в код.

Параметры

Параметры будут храниться в ECS-компоненте как его поля:

1
2
3
4
5
// Компонент
struct Unit {
public int Hunger;
public int Food;
}

Для использования в мире нам потребуется аспект с описанным компонентом:

1
2
3
4
5
// Аспект с пулами.
class UnitAspect : ProtoAspectInject {
// В аспекте указываем пул с компонентом "Unit".
public readonly ProtoPool<Unit> Units = default;
}

Решения

Решения должны реализовывать специальный интерфейс (в рамках реализации UtilityAI для LeoECS Proto).

Решение “Поиск еды”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class SearchFoodSolver : IAiUtilitySolver {
// Аспект с компонентами будет автоматически подключен в поле.
[DI] readonly UnitAspect _unit = default;

// Это метод "оценки", на вход подается идентификатор сущности
// с компонентом, содержащим "Параметры". На выходе - оценка
// важности данного "Решения" в условных единицах.
public float Solve (int entity) {
ref var unit = ref _unit.Units.Get (entity);
// Будем искать еду только если голод >= 100 и еды нет.
if (unit.Hunger >= 100 && unit.Food == 0) {
return 1f;
}
return 0f;
}
// Этот метод "применения" решения вызовется уже после
// признания этого "Решения" лучшим.
public void Apply (int entity) {
// Ищем и находим еду.
ref var unit = ref _unit.Units.Get (entity);
unit.Food++;
}
}

Решение “Употребление еды”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class EatFoodSolver : IAiUtilitySolver {
[DI] readonly UnitAspect _unit = default;

public float Solve (int entity) {
ref var unit = ref _unit.Units.Get (entity);
// Будем есть только если голодны и еда в наличии.
if (unit.Hunger >= 100 && unit.Food > 0) {
return 1f;
}
return 0f;
}

public void Apply (int entity) {
// Употребляем еду.
ref var unit = ref _unit.Units.Get (entity);
unit.Food--;
unit.Hunger = 0;
}
}

Решение “Ожидание”:

1
2
3
4
5
6
7
8
9
10
class WaitSolver : IAiUtilitySolver {
public float Solve (int entity) {
// Действие по умолчанию, ничего не проверяем.
return 0.5f;
}

public void Apply (int entity) {
// Действие по умолчанию, ничего не делаем.
}
}

ECS-системы настройки, изменения и применения изменений

Система создания и инициализации бота:

1
2
3
4
5
6
7
8
9
10
11
12
class UnitInitSystem : IProtoInitSystem {
// Подключаем аспект с данными.
[DI] readonly UnitAspect _unit = default;

public void Init (IProtoSystems systems) {
// Создаем новую сущность с компонентом "Unit"
// и инициализируем ее поля.
ref var unit = ref _unit.Units.NewEntity ();
unit.Hunger = 0;
unit.Food = 0;
}
}

Система начисления “голода” всем ботам:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class HungerSystem : IProtoRunSystem {
// Подключаем аспект с данными.
[DI] readonly UnitAspect _unit = default;
// Подключаем аспект модуля UtilityAI для управления запросами.
[DI] readonly AiUtilityModuleAspect _aiUtility = default;

public void Run () {
// добавляем голод для всех юнитов и запрашиваем расчет "Решения".
foreach (var entity in _unit.Iter ()) {
ref var unit = ref _unit.Units.Get (entity);
// Увеличиваем голод у каждого бота.
unit.Hunger += 50;
// Вызываем расчет. Этот вспомогательный метод вызывает
// добавление скрытого компонента-запроса на сущность бота.
_aiUtility.Request (entity);
}
}
}

Система получения и применения найденного Решения:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class CheckSystem : IProtoRunSystem {
// Подключаем данные бота.
[DI] readonly UnitAspect _unit = default;
// Подключаем данные модуля UtilityAI.
[DI] readonly AiUtilityModuleAspect _aiUtility = default;
// Создаем итератор по данным из модуля ИИ по специальному компоненту-ответу.
[DI] readonly ProtoIt _responseIt = new(It.Inc<Unit, AiUtilityResponseEvent> ());
// Отладочный список примененных "Решений".
readonly List<string> _results;

public CheckSystem (List<string> result) {
_results = result;
}

public void Run () {
// Проходим по всем сущностям с результатом работы UtilityAI.
foreach (var entity in _responseIt) {
ref var unit = ref _unit.Units.Get (entity);
// Читаем компонент-ответ.
ref var res = ref _aiUtility.ResponseEvent.Get (entity);
// Применяем лучшее найденное "Решение".
res.Solver.Apply (entity);
// Записываем в отладочный список примененное "Решение".
_results.Add (res.Solver.GetType ().Name);
}
}
}

Модуль UtilityAI для LeoECS Proto работает по схеме “Запрос-Ответ” - мы вешаем специальный компонент-запрос на сущность с данными бота и ожидаем компонента-ответа на той же сущности. За компонентами запроса-ответа специально следить не нужно - они добавляются и удаляются модулем UtilityAI автоматически.

Давайте свяжем все эти ECS-системы и Решения кодом запуска ECS Proto в виде теста (ECS позволяет относительно легко писать unit-тесты):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
[Test]
public void WaitSearchEatWaitTest () {
var modules = new ProtoModules (
// Подключаем модуль автоинъекции полей.
new AutoInjectModule (),
// Подключаем модуль UtilityAI.
new AiUtilityModule (
// Имя мира для данных.
default,
// Точка монтирования вызовов модуля.
"utility-ai",
// Перечень доступных "Решений"
// в произвольном порядке.
new WaitSolver (),
new SearchFoodSolver (),
new EatFoodSolver ()
))
// Подключаем наш дополнительный аспект.
.AddAspect (new UnitAspect ());
// Создаем мир с использованием композитного аспекта
// из аспектов всех подключенных модулей.
var world = new ProtoWorld (modules.BuildAspect ());
var systems = new ProtoSystems (world);
var results = new List<string> ();
systems
// Подключаем все модули как единый
// композитный модуль.
.AddModule (modules.BuildModule ())
// Подключаем тестовые системы.
.AddSystem (new UnitInitSystem ())
.AddSystem (new HungerSystem ())
.AddSystem (new CheckSystem (results), "check-ai")
// Указываем точки монтирования систем.
.AddPoint ("utility-ai")
.AddPoint ("check-ai")
.Init ();
// Вызов систем в результате будет следующим:
// UnitInitSystem -> HungerSystem -> UtilityAI -> CheckSystem.

// Прокручиваем вызов всех систем 4 раза для проверки
// принятия "Решений" и корректности их применения.
for (var i = 0; i < 4; i++) {
systems.Run ();
}
// К этому моменту в "results" будет список принятых "Решений", проверим их.
Assert.AreEqual ("WaitSolver,SearchFoodSolver,EatFoodSolver,WaitSolver", string.Join (",", results));
// Очистка окружения теста.
systems.Destroy ();
world.Destroy ();
}

Тест успешно пройден, были приняты и выполнены решения:

  • Голод=50, Еда=0: самое ценное решение WaitSolver имеет важность 0.5 - “ничего не делаем” -> Голод=50, Еда=0.
  • Голод=100, Еда=0: самое ценное решение SearchFoodSolver имеет важность 1.0 - “ищем и находим еду” -> Голод=100, Еда=1.
  • Голод=150, Еда=1: самое ценное решение EatFoodSolver имеет важность 1.0 - “едим” -> Голод=0, Еда=0.
  • Голод=50, Еда=0: самое ценное решение WaitSolver имеет важность 0.5 - “ничего не делаем” -> Голод=50, Еда=0.

Специально для юнити был добавлен пакет, позволяющий редактировать данные в виде кривых в ScriptableObject и использовать их в Решениях:

Можно использовать готовые типы на 1-2-3 параметра, можно реализовать свои, а затем любым способом передать в Решение (инъекция через конструктор или [DI]). “Поиск еды” тогда преобразуется в следующий вид:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class SearchFoodSolver : IAiUtilitySolver {
[DI] readonly UnitAspect _unit = default;
readonly UnityAiUtilityData2 _data;

// Передаем настройки через конструктор.
public SearchFoodSolver (UnityAiUtilityData2 data) {
_data = data;
}

public float Solve (int entity) {
ref var unit = ref _unit.Units.Get (entity);
// Вызываем метод оценки с передачей 2 параметров.
return _data.Evaluate (unit.Hunger, unit.Food);
}

public void Apply (int entity) {
// ищем еду.
ref var unit = ref _unit.Units.Get (entity);
unit.Food++;
}
}

И мы можем настраивать параметры через кривые в инспекторе:

Финальная оценка тоже может быть настроена как математическая операция для данных со всех кривых:

Актуальные версии пакетов доступны в закрытом telegram-сервере для vk/boosty-подписчиков.
Оформить подписку можно здесь: