Rozwój SI w Unity: samouczek dotyczący maszyny skończonej

Opublikowany: 2022-03-11

W konkurencyjnym świecie gier deweloperzy starają się oferować zabawne wrażenia dla użytkowników, którzy wchodzą w interakcje z postaciami niezależnymi (NPC), które tworzymy. Deweloperzy mogą zapewnić tę interaktywność, używając maszyn skończonych (FSM) do tworzenia rozwiązań AI, które symulują inteligencję naszych NPC.

Trendy AI przesunęły się na drzewa behawioralne, ale FSM pozostają aktualne. Są one wbudowane – w takim czy innym charakterze – w praktycznie każdej grze elektronicznej.

Anatomia FSM

FSM to model obliczeniowy, w którym tylko jeden ze skończonej liczby hipotetycznych stanów może być jednocześnie aktywny. FSM przechodzi z jednego stanu do drugiego, reagując na warunki lub dane wejściowe. Jego główne elementy obejmują:

Składnik Opis
Stan Jedna ze skończonego zestawu opcji wskazujących aktualny ogólny stan FSM; każdy dany stan zawiera powiązany zestaw działań
Akcja Co stan robi, gdy FSM pyta o to?
Decyzja Logika ustalająca, kiedy następuje przejście
Przemiana Proces zmiany stanów

Chociaż skupimy się na FSM z perspektywy implementacji AI, koncepcje takie jak automaty stanów animacji i ogólne stany gry również wchodzą w zakres FSM.

Wizualizacja FSM

Rozważmy przykład klasycznej gry zręcznościowej Pac-Man. W początkowym stanie gry (stan „pościgu”) NPC są kolorowymi duchami, które ścigają gracza i ostatecznie go wyprzedzają. Duchy przechodzą w stan uniku za każdym razem, gdy gracz zjada kulkę mocy i doświadcza wzmocnienia, zyskując możliwość zjadania duchów. Duchy, teraz w kolorze niebieskim, wymykają się graczowi, aż upłynie czas doładowania i duchy przejdą z powrotem do stanu pościgu, w którym przywracane są ich oryginalne zachowania i kolory.

Duch Pac-Mana jest zawsze w jednym z dwóch stanów: pościg lub unik. Oczywiście musimy zapewnić dwa przejścia – jedno od pościgu do uchylania się, drugie od uchylania się do pościgu:

Schemat: Po lewej stronie znajduje się stan pościgu. Strzałka (wskazująca, że ​​gracz zjadł kulkę mocy) prowadzi do stanu uniku po prawej stronie. Druga strzałka (wskazująca, że ​​upłynął limit czasu kulki mocy) prowadzi z powrotem do stanu pościgu po lewej stronie.
Przejścia między stanami duchów Pac-Mana

Maszyna skończona, z założenia, odpytuje bieżący stan, który odpytuje decyzje i działania tego stanu. Poniższy diagram przedstawia nasz przykład Pac-Mana i pokazuje decyzję, która sprawdza stan zasilania gracza. Jeśli wzmocnienie się rozpoczęło, NPC przechodzą z pościgu na unik. Jeśli wzmocnienie się zakończyło, NPC przechodzą z unikania do pościgu. Wreszcie, jeśli nie ma zmiany zasilania, przejście nie następuje.

Diagram w kształcie rombu przedstawiający cykl: Począwszy od lewej, następuje stan pościgu implikujący odpowiednią akcję. Następnie stan pościgu wskazuje na górę, gdzie jest decyzja: jeśli gracz zjadł śrut mocy, przechodzimy do stanu uniku i akcji uniku po prawej stronie. Stan uniku wskazuje na decyzję na dole: jeśli przekroczono limit czasu, kontynuujemy powrót do punktu wyjścia.
Komponenty Pac-Man Ghost FSM

Skalowalność

FSM pozwalają nam budować modułową sztuczną inteligencję. Na przykład za pomocą tylko jednej nowej akcji możemy stworzyć NPC z nowym zachowaniem. W ten sposób możemy przypisać nową akcję – zjedzenie kulki mocy – jednemu z naszych duchów Pac-Mana, dając mu możliwość zjedzenia kulki mocy podczas unikania gracza. Możemy ponownie wykorzystać istniejące działania, decyzje i przejścia, aby wesprzeć to zachowanie.

Ponieważ zasoby wymagane do stworzenia unikalnego NPC są minimalne, jesteśmy dobrze przygotowani do spełnienia zmieniających się wymagań projektowych wielu unikalnych NPC. Z drugiej strony nadmierna liczba stanów i przejść może nas zaplątać w maszynę stanów spaghetti — FSM, którego nadmiar połączeń utrudnia debugowanie i konserwację.

Wdrażanie FSM w Unity

Aby zademonstrować, jak zaimplementować maszynę skończoną w Unity, stwórzmy prostą grę typu stealth. Nasza architektura będzie zawierać ScriptableObject , które są kontenerami danych, które mogą przechowywać i udostępniać informacje w całej aplikacji, dzięki czemu nie musimy ich odtwarzać. ScriptableObject są zdolne do ograniczonego przetwarzania, takiego jak wywoływanie akcji i decyzje dotyczące zapytań. Oprócz oficjalnej dokumentacji Unity, starsza dyskusja o architekturze gry z obiektami skryptowymi pozostaje doskonałym źródłem, jeśli chcesz zagłębić się głębiej.

Zanim dodamy sztuczną inteligencję do tego wstępnego, gotowego do skompilowania projektu, rozważ proponowaną architekturę:

Diagram: Siedem połączonych ze sobą pudeł, opisanych w kolejności występowania, od lewej/góry: Pudełko oznaczone BaseStateMachine zawiera + CurrentState: BaseState. BaseStateMachine łączy się z BaseState za pomocą strzałki dwukierunkowej. Pole oznaczone BaseState zawiera + Execute(BaseStateMachine): void. BaseState łączy się z BaseStateMachine za pomocą strzałki dwukierunkowej. Strzałki jednokierunkowe z State i RemainInState łączą się z BaseState. Pole Stan zawiera + Execute(BaseStateMachine): void, + Actions: List<Action> oraz + Transition: List<Transition>. State łączy się z BaseState za pomocą strzałki jednokierunkowej, z Action za pomocą strzałki jednokierunkowej oznaczonej „1” i z przejściem za pomocą strzałki jednokierunkowej oznaczonej „1”. Pole oznaczone RemainInState zawiera + Execute(BaseStateMachine): void. RemainInState łączy się z BaseState za pomocą strzałki jednokierunkowej. Pole oznaczone Action zawiera + Execute(BaseStateMachine): void. Strzałka jednokierunkowa oznaczona „1” od Stanu łączy się z Akcją. Pole oznaczone jako Transition zawiera + Decide(BaseStateMachine): void, + TransitionDecision: Decision, + TrueState: BaseState i + FalseState: BaseState. Przejście łączy się z Decyzją za pomocą strzałki jednokierunkowej. Strzałka jednokierunkowa oznaczona „1” od stanu łączy się z przejściem. Pole oznaczone jako Decyzja zawiera + Decide(BaseStateMachine): bool.
Proponowana architektura FSM

W naszej przykładowej grze wróg (NPC reprezentowany przez niebieską kapsułę) patroluje. Kiedy wróg widzi gracza (reprezentowanego przez szarą kapsułkę), wróg zaczyna podążać za graczem:

Rysunek: Pięć pól, które łączą się ze sobą, opisanych w kolejności pojawiania się, od lewej/góry: Pole oznaczone jako Patrol łączy się z polem oznaczonym IF player jest w polu widzenia ze strzałką jednokierunkową oraz z polem oznaczonym Patrol Action ze strzałką. strzałka jednokierunkowa oznaczona jako „stan”. Pudełko z napisem IF player jest w zasięgu wzroku, z dodatkową „decyzją” e-etykietą, tuż pod pudełkiem. Pudełko oznaczone IF player jest w zasięgu wzroku łączy się z pudełkiem oznaczonym Chase ze strzałką jednokierunkową. Strzałka jednokierunkowa z pola oznaczonego Patrol łączy się z polem oznaczonym IF player jest w zasięgu wzroku. Pole oznaczone jako Chase łączy się z polem oznaczonym Chase Action za pomocą strzałki jednokierunkowej, która jest oznaczona jako „stan”. Strzałka jednokierunkowa z pudełka oznaczonego IF player jest w linii wzroku łączy się z polem oznaczonym Chase. Strzałka jednokierunkowa z pola Patrol łączy się z polem Patrol Action. Strzałka jednokierunkowa z pola oznaczonego Chase łączy się z polem oznaczonym Akcja Chase.
Podstawowe elementy naszej przykładowej gry Stealth FSM

W przeciwieństwie do Pac-Mana, wróg w naszej grze nie powróci do stanu domyślnego („patrol”), gdy zacznie podążać za graczem.

Tworzenie klas

Zacznijmy od stworzenia naszych klas. W nowym folderze scripts dodamy wszystkie proponowane elementy architektoniczne jako skrypty C#.

Implementacja klasy BaseStateMachine

Klasa BaseStateMachine jest jedynym MonoBehavior , który dodamy, aby uzyskać dostęp do naszych NPC z włączoną sztuczną inteligencją. Dla uproszczenia nasz BaseStateMachine będzie prosty. Gdybyśmy jednak chcieli, moglibyśmy dodać odziedziczony niestandardowy FSM, który przechowuje dodatkowe parametry i odniesienia do dodatkowych komponentów. Zauważ, że kod nie skompiluje się poprawnie, dopóki nie dodamy naszej klasy BaseState , co zrobimy w dalszej części naszego samouczka.

Kod dla BaseStateMachine odwołuje się do bieżącego stanu i wykonuje go w celu wykonania działań i sprawdzenia, czy przejście jest uzasadnione:

 using UnityEngine; namespace Demo.FSM { public class BaseStateMachine : MonoBehaviour { [SerializeField] private BaseState _initialState; private void Awake() { CurrentState = _initialState; } public BaseState CurrentState { get; set; } private void Update() { CurrentState.Execute(this); } } }

Implementacja klasy BaseState

Nasz stan jest typu BaseState , który wywodzimy z obiektu ScriptableObject . BaseState zawiera pojedynczą metodę Execute , przyjmującą jako argument BaseStateMachine i przekazującą do niej akcje i przejścia. Tak wygląda BaseState :

 using UnityEngine; namespace Demo.FSM { public class BaseState : ScriptableObject { public virtual void Execute(BaseStateMachine machine) { } } }

Implementacja klas State i RemainInState

Teraz wyprowadzamy dwie klasy z BaseState . Po pierwsze mamy klasę State , która przechowuje odniesienia do akcji i przejść, zawiera dwie listy (jedna dla akcji, druga dla przejść) oraz nadpisuje i wywołuje bazę Execute dla akcji i przejść:

 using System.Collections.Generic; using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/State")] public sealed class State : BaseState { public List<FSMAction> Action = new List<FSMAction>(); public List<Transition> Transitions = new List<Transition>(); public override void Execute(BaseStateMachine machine) { foreach (var action in Action) action.Execute(machine); foreach(var transition in Transitions) transition.Execute(machine); } } }

Po drugie, mamy klasę RemainInState , która informuje FSM, kiedy ma nie wykonywać przejścia:

 using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Remain In State", fileName = "RemainInState")] public sealed class RemainInState : BaseState { } }

Należy zauważyć, że te klasy nie będą się kompilować, dopóki nie dodamy FSMAction , Decision i Transition .

Implementacja klasy FSMAction

Na diagramie proponowanej architektury FSM podstawowa klasa FSMAction jest oznaczona jako „Akcja”. Utworzymy jednak podstawową klasę FSMAction i użyjemy nazwy FSMAction (ponieważ Action jest już używane przez przestrzeń nazw System .NET).

FSMAction , ScriptableObject , nie może przetwarzać funkcji niezależnie, więc zdefiniujemy go jako klasę abstrakcyjną. W miarę postępu naszego rozwoju możemy wymagać pojedynczego działania, aby służyć więcej niż jednemu państwu. Na szczęście możemy powiązać FSMAction z dowolną liczbą stanów z tylu FSM, ile chcemy.

Klasa abstrakcyjna FSMAction wygląda tak:

 using UnityEngine; namespace Demo.FSM { public abstract class FSMAction : ScriptableObject { public abstract void Execute(BaseStateMachine stateMachine); } }

Implementacja klas Decision i Transition

Aby zakończyć nasz FSM, zdefiniujemy jeszcze dwie klasy. Po pierwsze, mamy Decision , abstrakcyjną klasę, z której wszystkie inne decyzje definiują swoje niestandardowe zachowanie:

 using UnityEngine; namespace Demo.FSM { public abstract class Decision : ScriptableObject { public abstract bool Decide(BaseStateMachine state); } }

Druga klasa, Transition , zawiera obiekt Decision i dwa stany:

  • Stan, do którego należy przejść, jeśli Decision okaże się prawdą.
  • Kolejny stan, do którego należy przejść, jeśli Decision okaże się fałszywa.

To wygląda tak:

 using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Transition")] public sealed class Transition : ScriptableObject { public Decision Decision; public BaseState TrueState; public BaseState FalseState; public void Execute(BaseStateMachine stateMachine) { if(Decision.Decide(stateMachine) && !(TrueState is RemainInState)) stateMachine.CurrentState = TrueState; else if(!(FalseState is RemainInState)) stateMachine.CurrentState = FalseState; } } }

Wszystko, co zbudowaliśmy do tego momentu, powinno się skompilować bez żadnych błędów. Jeśli wystąpią problemy, sprawdź wersję Unity Editor, która może powodować błędy, jeśli jest nieaktualna. Upewnij się, że wszystkie pliki zostały poprawnie sklonowane z oryginalnego folderu projektu i że wszystkie publicznie dostępne zmienne nie są zadeklarowane jako prywatne.

Tworzenie niestandardowych działań i decyzji

Teraz, po zakończeniu ciężkich prac, jesteśmy gotowi do wdrożenia niestandardowych działań i decyzji w nowym folderze scripts .

Implementacja klas Patrol i Chase

Kiedy przeanalizujemy podstawowe komponenty naszego diagramu FSM przykładowej gry skradanki, widzimy, że nasz NPC może znajdować się w jednym z dwóch stanów:

  1. Państwo patrolowe — Z państwem związane są:
    • Jedna akcja: NPC odwiedza losowe punkty patrolowe na całym świecie.
    • Jedno przejście: NPC sprawdza, czy gracz jest w zasięgu wzroku, a jeśli tak, przechodzi w stan pościgu.
    • Jedna decyzja: NPC sprawdza, czy gracz jest w zasięgu wzroku.
  2. Stan pościgu — powiązany ze stanem jest:
    • Jedna akcja: NPC goni gracza.

Możemy ponownie wykorzystać naszą istniejącą implementację przejścia za pośrednictwem graficznego interfejsu użytkownika Unity, co omówimy później. Pozostają nam dwie akcje ( PatrolAction i ChaseAction ) i decyzja o kodowaniu.

Akcja stanu patrolu (pochodząca z podstawowego FSMAction ) zastępuje metodę Execute , aby uzyskać dwa składniki:

  1. PatrolPoints , który śledzi punkty patrolowe.
  2. NavMeshAgent , implementacja Unity do nawigacji w przestrzeni 3D.

Pominięcie sprawdza następnie, czy agent AI dotarł do miejsca docelowego, a jeśli tak, przechodzi do następnego miejsca docelowego. To wygląda tak:

 using Demo.Enemy; using Demo.FSM; using UnityEngine; using UnityEngine.AI; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Actions/Patrol")] public class PatrolAction : FSMAction { public override void Execute(BaseStateMachine stateMachine) { var navMeshAgent = stateMachine.GetComponent<NavMeshAgent>(); var patrolPoints = stateMachine.GetComponent<PatrolPoints>(); if (patrolPoints.HasReached(navMeshAgent)) navMeshAgent.SetDestination(patrolPoints.GetNext().position); } } }

Możemy rozważyć buforowanie komponentów PatrolPoints i NavMeshAgent . Buforowanie umożliwiłoby nam udostępnianie ScriptableObject dla działań między agentami bez wpływu na wydajność uruchamiania GetComponent w przypadku każdego zapytania maszyny o skończonych stanach.

Żeby było jasne, nie możemy buforować instancji komponentów w metodzie Execute . Zamiast tego dodamy niestandardową metodę GetComponent do BaseStateMachine . Nasz niestandardowy GetComponent buforowałby instancję przy pierwszym wywołaniu, zwracając zbuforowaną instancję przy kolejnych wywołaniach. Dla porównania, jest to implementacja BaseStateMachine z buforowaniem:

 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() { CurrentState = _initialState; _cachedComponents = new Dictionary<Type, Component>(); } public BaseState CurrentState { get; set; } private void Update() { CurrentState.Execute(this); } 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; } } }

Podobnie jak jej odpowiednik PatrolAction , klasa ChaseAction przesłania metodę Execute w celu uzyskania składników PatrolPoints i NavMeshAgent . Natomiast po sprawdzeniu, czy agent AI dotarł do celu, akcja klasy ChaseAction ustawia miejsce docelowe na Player.position :

 using Demo.Enemy; using Demo.FSM; using UnityEngine; using UnityEngine.AI; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Actions/Chase")] public class ChaseAction : FSMAction { public override void Execute(BaseStateMachine stateMachine) { var navMeshAgent = stateMachine.GetComponent<NavMeshAgent>(); var enemySightSensor = stateMachine.GetComponent<EnemySightSensor>(); navMeshAgent.SetDestination(enemySightSensor.Player.position); } } }

Implementacja klasy InLineOfSightDecision

Ostatnim elementem jest klasa InLineOfSightDecision , która dziedziczy podstawową Decision i pobiera komponent EnemySightSensor , aby sprawdzić, czy gracz znajduje się w zasięgu wzroku NPC:

 using Demo.Enemy; using Demo.FSM; using UnityEngine; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Decisions/In Line Of Sight")] public class InLineOfSightDecision : Decision { public override bool Decide(BaseStateMachine stateMachine) { var enemyInLineOfSight = stateMachine.GetComponent<EnemySightSensor>(); return enemyInLineOfSight.Ping(); } } }

Dołączanie zachowań do stanów

Jesteśmy w końcu gotowi do przypisania zachowań agentowi Enemy . Są one tworzone w oknie Projektu Unity Editor.

Dodawanie stanów Patrol i Chase

Stwórzmy dwa stany i nazwijmy je „Patrol” i „Chase”:

  • Kliknij prawym przyciskiem myszy > Utwórz > FSM > Stan

W tym miejscu stwórzmy również obiekt RemainInState :

  • Kliknij prawym przyciskiem myszy > Utwórz > FSM > Pozostań w stanie

Teraz nadszedł czas, aby stworzyć akcje, które właśnie zakodowaliśmy:

  • Kliknij prawym przyciskiem myszy > Utwórz > FSM > Akcja > Patrol
  • Kliknij prawym przyciskiem myszy > Utwórz > FSM > Akcja > Chase

Aby zakodować Decision :

  • Kliknij prawym przyciskiem myszy > Utwórz > FSM > Decyzje > W zasięgu wzroku

Aby umożliwić przejście z PatrolState do ChaseState , stwórzmy najpierw obiekt skryptowy przejścia:

  • Kliknij prawym przyciskiem myszy > Utwórz > FSM > Przejście
  • Wybierz nazwę, którą lubisz. Zadzwoniłem do mojego Spotted Enemy.

Wypełnimy wynikowe okno inspektora w następujący sposób:

Ekran Spotted Enemy (Transition) zawiera cztery linie: Wartość skryptu jest ustawiona na „Transition” i jest wyszarzona. Wartość decyzji jest ustawiona na „LineOfSightDecision (w linii wzroku)”. Wartość True State jest ustawiona na „ChaseState (State)”. Wartość False State jest ustawiona na „RemainInState (pozostań w stanie)”.
Wypełnianie okna inspektora dostrzeżonego wroga (przejścia)

Następnie wypełnimy okno Inspektora stanu Chase w następujący sposób:

Ekran Chase State (State) zaczyna się od etykiety „Open”. Obok etykiety „Skrypt” wybrane jest „Stan”. Obok etykiety „Działanie” wybrane jest „1”. Z listy rozwijanej "Akcja" wybierana jest "Akcja pościgu elementu 0 (Akcja pościgu)". Po nim następuje znak plus i minus. Obok etykiety „Przejścia” wybrane jest „0”. Z menu „Przejścia” wyświetla się „Lista jest pusta”. Po nim następuje znak plus i minus.
Wypełnianie okna pościgu państwowego inspektora

Następnie zakończymy okno dialogowe Stan patrolowania:

Ekran Patrol State (State) zaczyna się od etykiety „Open”. Obok etykiety „Skrypt” wybrane jest „Stan”. Obok etykiety „Działanie” wybrane jest „1”. Z listy rozwijanej „Akcja” wybierana jest „Akcja patrolowa elementu 0 (Akcja patrolowa)”. Następuje znak plus i minus. Obok etykiety „Przejścia” wybrane jest „1”. Z menu „Przejścia” wyświetla się „Element 0 SpottedEnemy (przejście)”. Po nim następuje znak plus i minus.
Wypełnianie okienka Patrolowego Państwowego Inspektora

Na koniec dodamy komponent BaseStateMachine do obiektu wroga: W oknie projektu Unity Editor's otwórz zasób SampleScene, wybierz obiekt Enemy z panelu Hierarchy i w oknie Inspektora wybierz Dodaj komponent > Automat stanów bazowych :

Ekran Base State Machine (Script): Obok wyszarzonej etykiety „Skrypt” zaznaczony i wyszarzony jest „BaseStateMachine”. Obok etykiety „Stan początkowy” zaznaczony jest „PatrolState (State)”.
Dodawanie składnika podstawowego automatu stanowego (skryptu)

W przypadku jakichkolwiek problemów sprawdź, czy obiekty gry są poprawnie skonfigurowane. Na przykład potwierdź, że obiekt Enemy zawiera komponent skryptu PatrolPoints oraz obiekty Point1 , Point2 itd. Te informacje mogą zostać utracone w przypadku nieprawidłowego wersjonowania edytora.

Teraz jesteś gotowy, aby zagrać w przykładową grę i obserwować, że wróg będzie podążał za graczem, gdy ten wejdzie w jego pole widzenia.

Wykorzystanie FSM do stworzenia zabawnego, interaktywnego doświadczenia użytkownika

W tym samouczku dotyczącym maszyny skończonej stworzyliśmy wysoce modułową sztuczną inteligencję opartą na FSM (i odpowiadające jej repozytorium GitHub), którą możemy ponownie wykorzystać w przyszłych projektach. Dzięki tej modułowości zawsze możemy zwiększyć moc naszej sztucznej inteligencji, wprowadzając nowe komponenty.

Ale nasza architektura toruje również drogę dla pierwszego graficznego projektowania FSM, które podniosłoby nasze doświadczenie programistyczne na nowy poziom profesjonalizmu. Moglibyśmy wtedy tworzyć FSM dla naszych gier szybciej i z większą precyzją twórczą.