Unity AI Development: graficzny samouczek FSM oparty na xNode

Opublikowany: 2022-08-12

W „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:

  1. W Unity kliknij Okno > Menedżer pakietów, aby uruchomić okno Menedżera pakietów.
  2. Kliknij + (znak plus) w lewym górnym rogu okna i wybierz Dodaj pakiet z adresu URL git , aby wyświetlić pole tekstowe.
  3. 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.

Węzeł rozgałęzienia, reprezentowany przez duży prostokąt w środku, zawiera pseudokod „If C == True A Else B”. Po lewej stronie znajdują się trzy prostokąty, z których każdy ma strzałkę wskazującą węzeł gałęzi: „A (arbitralnie),” „B (arbitralnie)” i „C (boolean)”. Węzeł rozgałęzienia ma wreszcie strzałkę wskazującą prostokąt „Wyjście”.
Przykładowy węzeł Branch

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.

  1. Aby utworzyć stany Chase i Patrol :

    1. Kliknij prawym przyciskiem myszy i wybierz Stan, aby utworzyć nowy węzeł.

    2. Nazwij węzeł Chase .

    3. Wróć do menu rozwijanego, ponownie wybierz Stan , aby utworzyć drugi węzeł.

    4. Nazwij węzeł Patrol .

    5. Przeciągnij i upuść istniejące akcje Chase i Patrol do ich nowo utworzonych odpowiednich stanów.

  2. Aby utworzyć przejście:

    1. Kliknij prawym przyciskiem myszy i wybierz Przejście , aby utworzyć nowy węzeł.

    2. Przypisz obiekt LineOfSightDecision do pola Decision przejścia.

  3. Aby utworzyć węzeł RemainInState :

    1. Kliknij prawym przyciskiem myszy i wybierz RemainInState , aby utworzyć nowy węzeł.
  4. Aby połączyć wykres:

    1. Połącz wyjście Transitions węzła Patrol z wejściem Entry węzła Transition .

    2. Połącz wyjście True State węzła Transition z wejściem Entry węzła Chase .

    3. Połącz wyjście False State węzła Transition z wejściem Entry węzła Remain In State .

Wykres powinien wyglądać tak:

Cztery węzły reprezentowane jako cztery prostokąty, każdy z kółkami wejściowymi w lewym górnym rogu. Od lewej do prawej, węzeł stanu Patrol wyświetla jedną akcję: Akcja patrolowania. Węzeł stanu Patrol zawiera również okrąg wyjściowy przejść w prawym dolnym rogu, który łączy się z kołem wejściowym węzła przejścia. Węzeł przejścia wyświetla jedną decyzję: LineOfSight. Ma dwa kółka wyjściowe w prawym dolnym rogu, stan rzeczywisty i stan fałszywy. True State łączy się z kręgiem wejściowym naszej trzeciej struktury, węzłem stanu Chase. Węzeł stanu Chase wyświetla jedną akcję: Akcja Chase. Węzeł stanu Chase ma okrąg wyjściowy Transitions. Drugi z dwóch okręgów wyjściowych Transition, False State, łączy się z okręgiem wejściowym naszej czwartej i ostatniej struktury, węzłem RemainInState (który pojawia się poniżej węzła stanu Chase).
Wstępne spojrzenie na nasz wykres FSM

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:

  1. Kliknij prawym przyciskiem myszy i wybierz węzeł początkowy , aby utworzyć nowy węzeł.
  2. Dołącz wyjście węzła FSM do Entry wejścia węzła Patrol .

Wykres powinien teraz wyglądać tak:

Ten sam wykres, co na naszym poprzednim obrazku, z jednym dodanym zielonym prostokątem węzła FSM na lewo od pozostałych czterech prostokątów. Posiada wyjście węzła początkowego (reprezentowane przez niebieskie kółko), które łączy się z wejściem „Entry” węzła patrolu (reprezentowanym przez ciemnoczerwone kółko).
Nasz wykres FSM z początkowym węzłem dołączonym do stanu patrolowania

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:

  1. Otwórz zasób SampleScene.

  2. Znajdź nasz obiekt gry Enemy w oknie hierarchii Unity.

  3. Zastąp składnik BaseStateMachine składnikiem BaseStateMachineGraph :

    1. Kliknij Dodaj składnik i wybierz poprawny skrypt BaseStateMachineGraph .

    2. Przypisz nasz wykres FSM, EnemyGraph , do pola Graph składnika BaseStateMachineGraph .

    3. 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:

Od góry do dołu, na ekranie Inspektora, obok Wroga znajduje się znacznik. „Gracz” jest wybrany w menu rozwijanym Tag, a „Wróg” jest wybrany w menu rozwijanym Warstwa. Lista rozwijana Przekształć pokazuje pozycję, obrót i skalę. Menu rozwijane Capsule jest skompresowane, a menu rozwijane Mesh Renderer, Capsule Collider i Nav Mesh Agent są skompresowane z zaznaczeniem po lewej stronie. Lista rozwijana Enemy Sight Sensor pokazuje Skrypt i Ignoruj ​​Maskę. Lista rozwijana PatrolPoints pokazuje skrypt i cztery PatrolPoints. Obok listy rozwijanej Base State Machine Graph (Script) znajduje się znacznik wyboru. Skrypt pokazuje „BaseStateMachineGraph”, Stan początkowy pokazuje „Brak (stan bazowy), a Wykres pokazuje „EnemyGraph (wykres FSM).”. Na koniec menu rozwijane Niebieski wróg (materiał) jest skompresowane, a poniżej pojawia się przycisk „Dodaj składnik”. to.
Nasz Enemy obiekt gry

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.