Rozwój SI w Unity: samouczek dotyczący maszyny skończonej
Opublikowany: 2022-03-11W 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:
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.
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ę:
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:
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
Decisionokaże się prawdą. - Kolejny stan, do którego należy przejść, jeśli
Decisionokaż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:
- 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.
- 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:
-
PatrolPoints, który śledzi punkty patrolowe. -
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:
Następnie wypełnimy okno Inspektora stanu Chase w następujący sposób:
Następnie zakończymy okno dialogowe Stan patrolowania:
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 :
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ą.
