AI GOAP - планировщик целеориентированного ИИ

AI Goap - это реализация GOAP (Goal Oriented Action Planning) планировщика последовательности не связанных явно между собой действий для достижения определенной цели. Звучит не очень понятно, давайте разбираться.

Введение

Деревья Поведения (BehaviourTree) - это самая распространенная реализация для игрового ИИ, с которой все начинают, а часто и заканчивают, так как предоставляемого функционала для простых ботов хватает. Когда же схемы разрастаются, то возникает проблема по настройке старых и добавлению новых взаимосвязей между узлами действий - на сцену выходит GOAP.

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

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

  • Условие. Флаг-признак чего-то, может быть либо в состоянии “Да”, либо “Нет”.
  • Действие. Абстрактный элемент-узел плана действий. GOAP ничего не знает о том, что происходит в Действии и требует явно переключать Условия, на которые влияет это Действие.
  • Требования. Набор Условий, которые должны выполниться для того, чтобы Действие стало возможным к использованию.
  • Планировщик. Сердце GOAP, получает список действий, стартовое состояние Условий, цель, состоящую из таких же Условий. На выход подает список действий в нужной последовательности для достижения указанной цели. Если цель недостижима - информирует об этом.

Пример

Ну и давайте рассмотрим простой пример - бот может быть “голодным”, он умеет “заказывать пиццу”, умеет “готовить еду” сам, умеет “ждать” и умеет “есть”.

“Заказывать”, “Ждать”, “Готовить” и “Есть” - это Действия.

“Голодный”, “Есть номер телефона”, “Позвонил в пиццерию”, “Жду заказ”, “Есть ингредиенты”, “Еда в наличии” - это Условия.

Переведем это в код:
Условия

1
2
3
4
5
6
7
enum Conditions {
Hungry, // Наличие голода
IngredientsPresents, // Наличие ингредиентов для еды
PhoneNumberPresents, // Наличие номера пиццерии
PizzaOrdered, // Признак заказа пиццы
FoodPresents, // Наличие еды
}

Действия

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// Действие "Позвонить в пиццерию и сделать заказ"
class CallPhoneAction : IGoapAction<string> {
// Идентификатор действия, по которому мы его сможем потом определить.
public string Id () => "Call";

public GoapClaims OnInit (GoapPlanner<string> planner) {
// Описываем требования, которые должны быть выполнены:
// бот должен быть голоден и у него должен быть номер пиццерии.
return new GoapClaims ()
.Inc ((int) Conditions.Hungry)
.Inc ((int) Conditions.PhoneNumberPresents);
}

public GoapState OnExit (in GoapState state) {
// Если действие выполнено - устанавливаем условие "Пицца заказана".
return state.Set ((int) Conditions.PizzaOrdered);
}
}
// Действие "Ждем заказа".
class WaitOrderAction : IGoapAction<string> {
public string Id () => "WaitOrder";

public GoapClaims OnInit (GoapPlanner<string> planner) {
// Требование только одно - пицца была заказана.
return new GoapClaims ()
.Inc ((int) Conditions.PizzaOrdered);
}

public GoapState OnExit (in GoapState state) {
// Если действие выполнено - устанавливаем условие "Еда в наличии".
return state.Set ((int) Conditions.FoodPresents);
}
}
// Действие "Приготовление пищи".
class CookAction : IGoapAction<string> {
public string Id () => "Cook";

public GoapClaims OnInit (GoapPlanner<string> planner) {
// Устанавливаем требования: бот должен быть голодным
// и у него должны быть ингредиенты для приготовления пищи.
return new GoapClaims ()
.Inc ((int) Conditions.Hungry)
.Inc ((int) Conditions.IngredientsPresents);
}

public GoapState OnExit (in GoapState state) {
// Если действие выполнено - устанавливаем условие "Еда в наличии"
return state.Set ((int) Conditions.FoodPresents);
}
}
// Действие "Употребить еду".
class EatAction : IGoapAction<string> {
public string Id () => "Eat";

public GoapClaims OnInit (GoapPlanner<string> planner) {
// Устанавливаем требования: бот должен быть голодным
// и у него должна быть еда.
return new GoapClaims ()
.Inc ((int) Conditions.Hungry);
.Inc ((int) Conditions.FoodPresents);
}

public GoapState OnExit (in GoapState state) {
// Если действие выполнено - сбрасываем условие "Голода".
return state.Unset ((int) Conditions.Hungry);
}
}

Теперь давайте попробуем построить план. Пусть бот будет голодным, у него будет номер пиццерии, задача перестать испытывать голод:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
List<string> result = new();
GoapPlanner<string> planner = new(
// Просто перечисляем доступные действия,
// по которым можно будет строить план.
new CallPhoneAction (),
new WaitOrderAction (),
new CookAction (),
new EatAction ()
);
var valid = planner.Run (
// Собираем стартовое состояние - "Голод" + "Есть номер пиццерии".
new GoapState ()
.Set ((int) Conditions.Hungry)
.Set ((int) Conditions.PhoneNumberPresents),
// Собираем задачу - отсутствие условия "Голод".
new GoapClaims ()
.Exc ((int) Conditions.Hungry),
result
);

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

1
2
3
if (valid) {
var planItems = string.Join (",", result);
}

Результат:
“Call,WaitOrder,Eat” - “Позвонить в пиццерию”, “Подождать заказа”, “Съесть”.

Так, а что если номера нет, а есть ингредиенты для еды?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Инициализация планировщика та же, отличаются только параметры у Run().
// ...
var valid = planner.Run (
// Собираем стартовое состояние - "Голод" + "Есть номер пиццерии".
new GoapState ()
.Set ((int) Conditions.Hungry)
.Set ((int) Conditions.IngredientsPresents),
// Собираем задачу - отсутствие условия "Голод".
new GoapClaims ()
.Exc ((int) Conditions.Hungry),
result
);
if (valid) {
var planItems = string.Join (",", result);
}

Результат:
“Cook,Eat” - “Приготовить еду”, “Съесть”.

Отлично. Но что будет, если у нас есть и номер пиццерии и ингредиенты?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Инициализация планировщика та же, отличаются только параметры у Run().
// ...
var valid = planner.Run (
// Собираем стартовое состояние - "Голод" + "Есть номер пиццерии".
new GoapState ()
.Set ((int) Conditions.Hungry)
.Set ((int) Conditions.PhoneNumberPresents)
.Set ((int) Conditions.IngredientsPresents),
// Собираем задачу - отсутствие условия "Голод".
new GoapClaims ()
.Exc ((int) Conditions.Hungry),
result
);
if (valid) {
var planItems = string.Join (",", result);
}

Результат:
“Cook,Eat” - “Приготовить еду”, “Съесть”.

Тот же результат, но почему? У каждого действия есть “сложность/стоимость”, которая по умолчанию равна 1.0f. Получается "Call,WaitOrder,Eat" => 1+1+1=3, а "Cook,Eat" => 1+1=2 - самый “дешевый/простой” вариант победил. Мы можем настраивать этот параметр у каждого действия отдельно, давайте сделаем сложность приготовления пищи равной 10.0f:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class CookAction : IGoapAction<string> {
public string Id () => "Cook";

public GoapClaims OnInit (GoapPlanner<string> planner) {
return new GoapClaims ()
.Inc ((int) Conditions.Hungry)
.Inc ((int) Conditions.IngredientsPresents);
}

public GoapState OnExit (in GoapState state) {
return state.Set ((int) Conditions.FoodPresents);
}

public float Cost () => 10f;
}

Реализация действия осталась прежней, добавился метод Cost(), который возвращает новую “сложность/стоимость” действия. Запускаем тест еще раз:

Результат:
“Call,WaitOrder,Eat” - “Позвонить в пиццерию”, “Подождать заказа”, “Съесть”.

Так получилось потому, что "Call,WaitOrder,Eat" => 1+1+1=3, а "Cook,Eat" => 10+1=11 - победил заказ еды в пиццерии.

А что если у нас нет ни номера пиццерии, ни ингредиентов?

1
2
3
4
5
6
7
8
9
10
11
12
13
var valid = planner.Run (
new GoapState ()
.Set ((int) Conditions.Hungry),
new GoapClaims ()
.Exc ((int) Conditions.Hungry),
result
);
string planItems;
if (valid) {
planItems = string.Join (",", result);
} else {
planItems = "Нет плана";
}

Результат:
“Нет плана” - цель недостижима.

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

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