Unity AI Development: graficzny samouczek FSM oparty na xNode
Opublikowany: 2022-08-12W „Unity AI Development: A Finite-state Machine Tutorial” stworzyliśmy prostą grę typu stealth — modułową sztuczną inteligencję opartą na FSM. W grze wrogi agent patroluje przestrzeń gry. Kiedy dostrzeże gracza, wróg zmienia swój stan i podąża za graczem zamiast patrolować.
W tym drugim etapie naszej podróży do Unity zbudujemy graficzny interfejs użytkownika (GUI), aby szybciej tworzyć podstawowe komponenty naszej maszyny skończonej (FSM) i z ulepszonym doświadczeniem programisty Unity.
Szybkie przypomnienie
FSM szczegółowo opisany w poprzednim samouczku został zbudowany z bloków architektonicznych jako skryptów C#. Dodaliśmy niestandardowe akcje i decyzje ScriptableObject
jako klasy. Podejście ScriptableObject
umożliwiło łatwy w utrzymaniu i konfigurowalny FSM. W tym samouczku zastąpimy przeciągane i upuszczane ScriptableObject
w FSM opcją graficzną.
Napisałem również zaktualizowany scenariusz dla tych z Was, którzy chcą ułatwić wygrywanie gry. Aby wdrożyć, wystarczy zastąpić skrypt wykrywania gracza tym, który zawęża pole widzenia wroga.
Pierwsze kroki z xNode
Zbudujemy nasz edytor graficzny przy użyciu xNode, frameworka dla drzew zachowań opartych na węzłach, które będą wizualnie wyświetlać przepływ naszego FSM. Chociaż GraphView firmy Unity może wykonać to zadanie, jego interfejs API jest zarówno eksperymentalny, jak i słabo udokumentowany. Interfejs użytkownika xNode zapewnia doskonałe wrażenia programistyczne, ułatwiając tworzenie prototypów i szybką rozbudowę naszego FSM.
Dodajmy xNode do naszego projektu jako zależność Git za pomocą Unity Package Manager:
- W Unity kliknij Okno > Menedżer pakietów, aby uruchomić okno Menedżera pakietów.
- Kliknij + (znak plus) w lewym górnym rogu okna i wybierz Dodaj pakiet z adresu URL git , aby wyświetlić pole tekstowe.
- Wpisz lub wklej
https://github.com/siccity/xNode.git
w nieoznakowanym polu tekstowym i kliknij przycisk Dodaj .
Teraz jesteśmy gotowi zagłębić się w szczegóły i zrozumieć kluczowe elementy xNode:
Klasa Node | Reprezentuje węzeł, najbardziej podstawową jednostkę grafu. W tym samouczku xNode wywodzimy z klasy Node nowe klasy, które deklarują węzły wyposażone w niestandardowe funkcje i role. |
Klasa NodeGraph | Reprezentuje kolekcję węzłów ( instancji klasy Node ) i krawędzi, które je łączą. W tym samouczku xNode wywodzimy z NodeGraph nową klasę, która manipuluje i ocenia węzły. |
Klasa NodePort | Reprezentuje bramkę komunikacyjną, port typu input lub type output, znajdujący się między instancjami Node w NodeGraph . Klasa NodePort jest unikalna dla xNode. |
[Input] atrybut | Dodanie atrybutu [Input] do portu oznacza go jako wejście, umożliwiając portowi przekazywanie wartości do węzła, którego jest częścią. Pomyśl o atrybucie [Input] jako o parametrze funkcji. |
[Output] atrybut | Dodanie atrybutu [Output] do portu oznacza go jako wyjście, umożliwiając portowi przekazywanie wartości z węzła, którego jest częścią. Pomyśl o atrybucie [Output] jako wartości zwracanej przez funkcję. |
Wizualizacja środowiska budynku xNode
W xNode pracujemy z grafami, w których każdy State
i Transition
mają postać węzła. Połączenia wejściowe i/lub wyjściowe umożliwiają powiązanie węzła z dowolnym lub wszystkimi innymi węzłami na naszym wykresie.
Wyobraźmy sobie węzeł z trzema wartościami wejściowymi: dwiema arbitralnymi i jedną wartością logiczną. Węzeł wypisze jedną z dwóch arbitralnych wartości wejściowych, w zależności od tego, czy dane logiczne są prawdziwe czy fałszywe.
Aby przekonwertować nasz istniejący FSM na graf, modyfikujemy klasy State
i Transition
, aby dziedziczyły klasę Node
zamiast klasy ScriptableObject
. Tworzymy obiekt wykresu typu NodeGraph
, który zawiera wszystkie nasze obiekty State
i Transition
.
Modyfikowanie BaseStateMachine
do użycia jako typ podstawowy
Rozpocznij tworzenie interfejsu graficznego, dodając dwie nowe metody wirtualne do naszej istniejącej klasy BaseStateMachine
:
Init | Przypisuje stan początkowy do właściwości CurrentState |
Execute | Wykonuje aktualny stan |
Zadeklarowanie tych metod jako wirtualnych pozwala nam je nadpisać, dzięki czemu możemy zdefiniować niestandardowe zachowania klas dziedziczących klasę BaseStateMachine
do inicjalizacji i wykonania:
using System; using System.Collections.Generic; using UnityEngine; namespace Demo.FSM { public class BaseStateMachine : MonoBehaviour { [SerializeField] private BaseState _initialState; private Dictionary<Type, Component> _cachedComponents; private void Awake() { Init(); _cachedComponents = new Dictionary<Type, Component>(); } public BaseState CurrentState { get; set; } private void Update() { Execute(); } public virtual void Init() { CurrentState = _initialState; } public virtual void Execute() { CurrentState.Execute(this); } // Allows us to execute consecutive calls of GetComponent in O(1) time public new T GetComponent<T>() where T : Component { if(_cachedComponents.ContainsKey(typeof(T))) return _cachedComponents[typeof(T)] as T; var component = base.GetComponent<T>(); if(component != null) { _cachedComponents.Add(typeof(T), component); } return component; } } }
Następnie w naszym folderze FSM
:
FSMGraph | Folder |
BaseStateMachineGraph | Klasa AC# w FSMGraph |
Na razie BaseStateMachineGraph
odziedziczy tylko klasę BaseStateMachine
:
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { } }
Nie możemy dodać funkcjonalności do BaseStateMachineGraph
, dopóki nie utworzymy naszego podstawowego typu węzła; zróbmy to dalej.
Implementacja NodeGraph
i tworzenie podstawowego typu węzła
W naszym nowo utworzonym folderze FSMGraph
utworzymy:
FSMGraph | Klasa |
Na razie FSMGraph
odziedziczy tylko klasę NodeGraph
(bez dodatkowej funkcjonalności):
using UnityEngine; using XNode; namespace Demo.FSM.Graph { [CreateAssetMenu(menuName = "FSM/FSM Graph")] public class FSMGraph : NodeGraph { } }
Zanim stworzymy klasy dla naszych węzłów, dodajmy:
FSMNodeBase | Klasa, która ma być używana jako klasa bazowa przez wszystkie nasze węzły |
Klasa FSMNodeBase
będzie zawierać dane wejściowe o nazwie Entry
typu FSMNodeBase
, aby umożliwić nam łączenie węzłów ze sobą.
Dodamy również dwie funkcje pomocnicze:
GetFirst | Pobiera pierwszy węzeł podłączony do żądanego wyjścia |
GetAllOnPort | Pobiera wszystkie pozostałe węzły, które łączą się z żądanym wyjściem |
using System.Collections.Generic; using XNode; namespace Demo.FSM.Graph { public abstract class FSMNodeBase : Node { [Input(backingValue = ShowBackingValue.Never)] public FSMNodeBase Entry; protected IEnumerable<T> GetAllOnPort<T>(string fieldName) where T : FSMNodeBase { NodePort port = GetOutputPort(fieldName); for (var portIndex = 0; portIndex < port.ConnectionCount; portIndex++) { yield return port.GetConnection(portIndex).node as T; } } protected T GetFirst<T>(string fieldName) where T : FSMNodeBase { NodePort port = GetOutputPort(fieldName); if (port.ConnectionCount > 0) return port.GetConnection(0).node as T; return null; } } }
Ostatecznie będziemy mieli dwa typy węzłów stanu; dodajmy klasę do obsługi tych:
BaseStateNode | Klasa bazowa do obsługi zarówno StateNode , jak i RemainInStateNode |
namespace Demo.FSM.Graph { public abstract class BaseStateNode : FSMNodeBase { } }
Następnie zmodyfikuj klasę BaseStateMachineGraph
:
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { public new BaseStateNode CurrentState { get; set; } } }
Tutaj ukryliśmy właściwość CurrentState
odziedziczoną z klasy bazowej i zmieniliśmy jej typ z BaseState
na BaseStateNode
.
Tworzenie bloków konstrukcyjnych dla naszego wykresu FSM
Następnie, aby utworzyć główne bloki konstrukcyjne naszego FSM, dodajmy trzy nowe klasy do naszego folderu FSMGraph
:
StateNode | Reprezentuje stan agenta. Podczas wykonywania StateNode iteruje po TransitionNode podłączonych do portu wyjściowego StateNode (pobieranego metodą pomocniczą). StateNode pyta każdy z nich, czy przenieść węzeł do innego stanu, czy pozostawić stan węzła bez zmian. |
RemainInStateNode | Wskazuje, że węzeł powinien pozostać w bieżącym stanie. |
TransitionNode | Podejmuje decyzję o przejściu do innego stanu lub pozostaniu w tym samym stanie. |
W poprzednim samouczku Unity FSM klasa State
iteruje po liście przejść. Tutaj, w xNode, StateNode
służy jako odpowiednik State
do iteracji po węzłach pobranych za pomocą naszej metody pomocniczej GetAllOnPort
.
Teraz dodaj atrybut [Output]
do połączeń wychodzących (węzłów przejściowych), aby wskazać, że powinny one być częścią GUI. Zgodnie z projektem xNode, wartość atrybutu pochodzi z węzła źródłowego: węzła zawierającego pole oznaczone atrybutem [Output]
. Ponieważ używamy atrybutów [Output]
i [Input]
do opisywania relacji i połączeń, które zostaną ustawione przez GUI xNode, nie możemy traktować tych wartości w normalny sposób. Zastanów się, jak iterujemy między Actions
a Transitions
:
using System.Collections.Generic; namespace Demo.FSM.Graph { [CreateNodeMenu("State")] public sealed class StateNode : BaseStateNode { public List<FSMAction> Actions; [Output] public List<TransitionNode> Transitions; public void Execute(BaseStateMachineGraph baseStateMachine) { foreach (var action in Actions) action.Execute(baseStateMachine); foreach (var transition in GetAllOnPort<TransitionNode>(nameof(Transitions))) transition.Execute(baseStateMachine); } } }
W takim przypadku dane wyjściowe Transitions
mogą mieć wiele dołączonych do niego węzłów; musimy wywołać metodę pomocniczą GetAllOnPort
, aby uzyskać listę połączeń [Output]
.
RemainInStateNode
to zdecydowanie nasza najprostsza klasa. Nie wykonując żadnej logiki, RemainInStateNode
jedynie wskazuje naszemu agentowi – w przypadku naszej gry, wrogowi – aby pozostał w obecnym stanie:
namespace Demo.FSM.Graph { [CreateNodeMenu("Remain In State")] public sealed class RemainInStateNode : BaseStateNode { } }
W tym momencie klasa TransitionNode
jest nadal niekompletna i nie będzie się kompilować. Powiązane błędy zostaną usunięte, gdy zaktualizujemy klasę.
Aby zbudować TransitionNode
, musimy obejść wymaganie xNode, aby wartość wyjścia pochodziła z węzła źródłowego — tak jak zrobiliśmy to podczas budowania StateNode
. Główna różnica między StateNode
i TransitionNode
polega na tym, że dane wyjściowe TransitionNode
mogą być dołączone tylko do jednego węzła. W naszym przypadku GetFirst
pobierze jeden węzeł dołączony do każdego z naszych portów (jeden węzeł stanu do przejścia w prawdziwym przypadku i drugi do przejścia w przypadku fałszywego):
namespace Demo.FSM.Graph { [CreateNodeMenu("Transition")] public sealed class TransitionNode : FSMNodeBase { public Decision Decision; [Output] public BaseStateNode TrueState; [Output] public BaseStateNode FalseState; public void Execute(BaseStateMachineGraph stateMachine) { var trueState = GetFirst<BaseStateNode>(nameof(TrueState)); var falseState = GetFirst<BaseStateNode>(nameof(FalseState)); var decision = Decision.Decide(stateMachine); if (decision && !(trueState is RemainInStateNode)) { stateMachine.CurrentState = trueState; } else if(!decision && !(falseState is RemainInStateNode)) stateMachine.CurrentState = falseState; } } }
Przyjrzyjmy się graficznym wynikom naszego kodu.
Tworzenie wizualnego wykresu
Po uporządkowaniu wszystkich klas FSM możemy przystąpić do tworzenia naszego wykresu FSM dla wrogiego agenta gry. W oknie projektu Unity kliknij prawym przyciskiem myszy folder EnemyAI
i wybierz: Utwórz > FSM > Wykres FSM . Aby ułatwić identyfikację naszego wykresu, zmieńmy jego nazwę na EnemyGraph
.
W oknie edytora wykresów xNode kliknij prawym przyciskiem myszy, aby wyświetlić rozwijane menu z listą State , Transition i RemainInState . Jeśli okno nie jest widoczne, kliknij dwukrotnie plik EnemyGraph
, aby uruchomić okno edytora xNode Graph.
Aby utworzyć stany
Chase
iPatrol
:Kliknij prawym przyciskiem myszy i wybierz Stan, aby utworzyć nowy węzeł.
Nazwij węzeł
Chase
.Wróć do menu rozwijanego, ponownie wybierz Stan , aby utworzyć drugi węzeł.
Nazwij węzeł
Patrol
.Przeciągnij i upuść istniejące akcje
Chase
iPatrol
do ich nowo utworzonych odpowiednich stanów.
Aby utworzyć przejście:
Kliknij prawym przyciskiem myszy i wybierz Przejście , aby utworzyć nowy węzeł.
Przypisz obiekt
LineOfSightDecision
do polaDecision
przejścia.
Aby utworzyć węzeł
RemainInState
:- Kliknij prawym przyciskiem myszy i wybierz RemainInState , aby utworzyć nowy węzeł.
Aby połączyć wykres:
Połącz wyjście
Transitions
węzłaPatrol
z wejściemEntry
węzłaTransition
.Połącz wyjście
True State
węzłaTransition
z wejściemEntry
węzłaChase
.Połącz wyjście
False State
węzłaTransition
z wejściemEntry
węzłaRemain In State
.
Wykres powinien wyglądać tak:
Nic na wykresie nie wskazuje, który węzeł — stan Patrol
lub Chase
— jest naszym węzłem początkowym. Klasa BaseStateMachineGraph
wykrywa cztery węzły, ale bez obecnych wskaźników nie może wybrać stanu początkowego.
Aby rozwiązać ten problem, utwórzmy:
FSMInitialNode | Klasa, której pojedyncze wyjście typu StateNode nosi nazwę InitialNode |
Nasze dane wyjściowe InitialNode
oznaczają stan początkowy. Następnie w FSMInitialNode
utwórz:
NextNode | Właściwość umożliwiająca nam pobranie węzła podłączonego do wyjścia InitialNode |
using XNode; namespace Demo.FSM.Graph { [CreateNodeMenu("Initial Node"), NodeTint("#00ff52")] public class FSMInitialNode : Node { [Output] public StateNode InitialNode; public StateNode NextNode { get { var port = GetOutputPort("InitialNode"); if (port == null || port.ConnectionCount == 0) return null; return port.GetConnection(0).node as StateNode; } } } }
Teraz, gdy utworzyliśmy klasę FSMInitialNode
, możemy połączyć ją z wejściem Entry
stanu początkowego i zwrócić stan początkowy za pomocą właściwości NextNode
.
Wróćmy do naszego wykresu i dodajmy początkowy węzeł. W oknie edytora xNode:
- Kliknij prawym przyciskiem myszy i wybierz węzeł początkowy , aby utworzyć nowy węzeł.
- Dołącz wyjście węzła FSM do
Entry
wejścia węzłaPatrol
.
Wykres powinien teraz wyglądać tak:
Aby ułatwić nam życie, dodamy do FSMGraph
:
InitialState | Własność |
Gdy po raz pierwszy spróbujemy pobrać wartość właściwości InitialState
, getter właściwości przemierzy wszystkie węzły na naszym grafie, próbując znaleźć FSMInitialNode
. Po zlokalizowaniu FSMInitialNode
używamy właściwości NextNode
, aby znaleźć nasz węzeł stanu początkowego:
using System.Linq; using UnityEngine; using XNode; namespace Demo.FSM.Graph { [CreateAssetMenu(menuName = "FSM/FSM Graph")] public sealed class FSMGraph : NodeGraph { private StateNode _initialState; public StateNode InitialState { get { if (_initialState == null) _initialState = FindInitialStateNode(); return _initialState; } } private StateNode FindInitialStateNode() { var initialNode = nodes.FirstOrDefault(x => x is FSMInitialNode); if (initialNode != null) { return (initialNode as FSMInitialNode).NextNode; } return null; } } }
Następnie w naszym BaseStateMachineGraph
odwołajmy się do FSMGraph
i zastąpmy nasze metody Init
i Execute
BaseStateMachine
. Zastąpienie Init
ustawia CurrentState
jako stan początkowy wykresu, a Execute
Execute
CurrentState
:
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { [SerializeField] private FSMGraph _graph; public new BaseStateNode CurrentState { get; set; } public override void Init() { CurrentState = _graph.InitialState; } public override void Execute() { ((StateNode)CurrentState).Execute(this); } } }
Teraz zastosujmy wykres do obiektu Enemy i zobaczmy, jak działa.
Testowanie wykresu FSM
W ramach przygotowań do testów, w oknie Projektu Unity Editor:
Otwórz zasób SampleScene.
Znajdź nasz obiekt gry
Enemy
w oknie hierarchii Unity.Zastąp składnik
BaseStateMachine
składnikiemBaseStateMachineGraph
:Kliknij Dodaj składnik i wybierz poprawny skrypt
BaseStateMachineGraph
.Przypisz nasz wykres FSM,
EnemyGraph
, do polaGraph
składnikaBaseStateMachineGraph
.Usuń komponent
BaseStateMachine
(ponieważ nie jest już potrzebny), klikając prawym przyciskiem myszy i wybierając opcję Usuń komponent .
Obiekt gry Enemy
powinien wyglądać tak:
Otóż to! Teraz mamy modułowy FSM z edytorem graficznym. Kliknięcie przycisku Odtwórz pokazuje, że graficznie stworzona sztuczna inteligencja wroga działa dokładnie tak, jak nasz wcześniej stworzony wróg ScriptableObject
.
Idąc naprzód: optymalizacja naszego FSM
Słowo ostrzeżenia: gdy rozwijasz bardziej wyrafinowaną sztuczną inteligencję do swojej gry, liczba stanów i przejść rośnie, a FSM staje się zagmatwany i trudny do odczytania. Edytor graficzny rozrasta się, by przypominać sieć linii, które pochodzą z wielu stanów i kończą się wieloma przejściami — i odwrotnie, co utrudnia debugowanie FSM.
Podobnie jak w poprzednim samouczku, zachęcamy Cię do stworzenia własnego kodu, optymalizacji gry skradanki i rozwiązania tych problemów. Wyobraź sobie, jak pomocne byłoby kodowanie kolorami węzłów stanu, aby wskazać, czy węzeł jest aktywny, czy nieaktywny, lub zmienić rozmiar węzłów RemainInState
i Initial
, aby ograniczyć ich powierzchnię ekranu.
Takie ulepszenia to nie tylko kosmetyka. Odniesienia do kolorów i rozmiarów pomogłyby określić, gdzie i kiedy należy debugować. Wykres, który jest przyjemny dla oka, jest również łatwiejszy do oceny, analizy i zrozumienia. Wszelkie dalsze kroki zależą od Ciebie — z podstawą naszego edytora graficznego nie ma ograniczeń co do ulepszeń środowiska programisty, które możesz wprowadzić.
Dalsza lektura na blogu Toptal Engineering:
- 10 najczęstszych błędów popełnianych przez twórców Unity
- Jedność z MVC: jak podnieść poziom rozwoju gry
- Opanowanie kamer 2D w Unity: samouczek dla twórców gier
- Najlepsze praktyki i wskazówki Unity od Toptal Developers
Blog Toptal Engineering wyraża wdzięczność Goranowi Lalicowi za jego wiedzę fachową i techniczną recenzję tego artykułu.