Po około miesiącu pracy po godzinach, skończyłem dodawanie szkieletu drzewa behawioralnego dla NPC w mojej grze. Jest dobrze :D Efekty wraz z opisem można zobaczyć na poniższym wideo, a dla zainteresowanych bardziej, jest też trochę technicznego pierdololo.
Czym w ogóle są te całe drzewa behawioralne?
Dla uproszczenia będę używał nazwy BT (Behavioral tree). BT składają się z węzłów, które reprezentują różne typy zachowań, takie jak: warunki, akcje, sekwencje, selektory, dekoratory i inne. Węzły są połączone w hierarchiczną strukturę, która określa kolejność i sposób wykonywania zachowań. Drzewa behawioralne są łatwe do tworzenia, modyfikowania i debugowania, ponieważ są czytelne i posiadają modularną formę. Jeśli umisz w angielski, to gorąco polecam artykuł który pomógł mi zrozumieć jak to wszystko działa w praktyce https://www.gamedeveloper.com/programming/behavior-trees-for-ai-how-they-work.
Jak to zaimplementowałem?
Jestem prostym programistą, gdy trzeba coś zrobić, sprawdzam czy ktoś to zrobił przede mną i czy znajdę to w internecie za friko. Tym prostym sposobem, trafiłem na kilka bibliotek z implementacją BT. Najbardziej rozbudowaną była taka, której dokumentacja jest po chińsku… Dalej była jedna fajna, gdzie wszystko było odpisane, niestety sposób w jaki węzły zwracały informację (return true
), czy zadanie jest wykonane, nie odpowiadał mi.
Na końcu znalazłem tę bibliotekę https://github.com/tanema/behaviourtree.lua. Jednakże, posiada ona jeden minus. Węzły można zarejestrować
, tak aby używać je ponownie w innych drzewach. Niestety, nie są tworzone ich nowe instancje, dlatego taki zarejestrowanych węzeł w kilku różnych BT potrafi sprawić wiele nieakceptowalnych problemów. Byłem załamany, do momentu przeczytania kodu biblioteki, okazało się że jest całkiem prosty do zrozumienia. Dlatego zmieniłem lokalnie dużo rzeczy aby to naprawić, niektóre zaproponowałem twórcy jako PR, zgodził się na nie, niestety wymagają one dodania testów aby zostać finalnie połączone. Więc póki co PR czeka na moją aktualizację.
Informacja czy zadanie zostało wykonane jest podawana przez funkcję task:success()
, dzięki czemu można przypisać referencję do zadania poza drzewem. W silniku Defold, który polega na wysyłaniu wiadomości między obiektami, jest to nieocenione rozwiązanie.
Jak to wygląda w mojej grze?
Obecnie zaimplementowałem tak zwany szkielet BT i dodałem parę prostych zachowań. Póki co, w grze występuje tylko jeden typ NPC, dlatego jego drzewu nadałem nazwę SIMPLE_SAILOR
. Wrzucam tu kawałek kodu z objaśnieniami, może kogoś to zainteresuje.
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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
SIMPLE_SAILOR = {
--[[
Typ węzła poniżej będzie po kolei
odpalał wrzucone mu węzły, dopóki
któryś z nich zwróci wykonanie zadanie
jako `failure`.
]]--
type = BehaviourTree.RepeatUntilFail,
nodes = {
--[[
Proste sprawdzenie czy statek NPC
w ogóle żyje. TASKS reprezentuje
obiekt z referencjami do węzłów
gdzie jest kod. Taki `task` może
zwrócić `success`, `failure` lub
`running`.
]]--
TASKS.IS_ALIVE, --
{
--[[
Poniżej jest sekwencja, będzie wykonywać
zadania po kolei, dopóki któreś z nich
nie zwróci `failure`. Ogólnie ma ona za zadanie
sprawdzić, czy okręt NPC jest atakowany. Jeśli
nie jest, sekwencja zwróci `failure`, przez co
przerwie działanie pętli wyżej. Dlatego jest
tu dodany dekorator, który wynik sekwencji
ma gdzieś i zawsze zwróci `success`, dzięki
czemu pętla działa dalej.
--]]
decorator = BehaviourTree.AlwaysSucceedDecorator, --
node = {
type = BehaviourTree.Sequence,
nodes = {
TASKS.IS_UNDER_ATTACK,
TASKS.STOP_SAILING,
TASKS.SELECT_RANDOM_AGGRESSOR_AS_ATTACKED_ENTITY,
{
--[[
Węzeł wyżej - IS_UNDER_ATTACK sprawdza czy
NPC jest atakowany, jeśli tak, odpala się
kolejny - STOP_SAILING, który zatrzymuje
NPC po okresie 1.5 sekundy. Następnie
jest wybierany losowy przeciwnik który
atakuje NPC i jest przypisany jako cel
ataku. Gdy to się stanie, odpala się
kolejna pętla, gdzie NPC będzie strzelało
z armat, dopóki przeciwnik żyje, lub
przestał atakować.
--]]
type = BehaviourTree.RepeatUntilFail,
nodes = {
TASKS.SHOOT_FROM_CANNONS_ATTACKED_ENTITY,
TASKS.IS_ENEMY_ALIVE,
TASKS.IS_ENEMY_STILL_ATTACKING,
},
},
},
},
},
{
--[[
W momencie gdy NPC uporało się z wrogiem,
albo w ogóle go nie było, odbędzie się
losowanie, co robić dalej :D Do tego
służy węzeł `Random`. Ma pod sobą dwa
węzły, jeden SAIL_TO który wysyła okręt
w losowe miejsce na mapie, drugi natomiast,
wybiera losowego przeciwnika i atakuje go aż
do jego śmierci. Szanse na wylosowanie
konkretnego węzła, są przypisane w `chances`
odpowiednio do kolejności węzłów w `nodes`.
--]]
type = BehaviourTree.Random,
chances = { 85, 15 },
nodes = {
TASKS.SAIL_TO,
{
type = BehaviourTree.Sequence,
nodes = {
TASKS.STOP_SAILING,
TASKS.SELECT_CLOSEST_SHIP_AS_ATTACKED_ENTITY,
{
type = BehaviourTree.RepeatUntilFail,
nodes = {
TASKS.SHOOT_FROM_CANNONS_ATTACKED_ENTITY,
TASKS.IS_ENEMY_ALIVE,
},
},
},
},
},
},
},
},
Podsumowanie
Dodanie BT to duży krok, pozwoli dodawać modularne zachowania w szybkim czasie. Samo zaimplementowanie drzewa zajęło mi z jakieś 3 tygodnie a napisanie zachowań to kilka dni. Dodam, że pracuje nad grą z 1h dziennie, czyli niewiele. Domyślnie w grze będzie więcej drzew, każde dla innego typu NPC z bardziej lub mniej złożonymi akcjami.
Teraz zabieram się za wdrożenie wyszukiwania ścieżek, dzięki temu NPC będą omijały wyspy i miejsca na które nie powinno się wpływać statkiem.
Do usłyszenia!