Agent Sterowany StanamiProgramownie A.I. Gier

Projekt agenta sterowanego stanami

Automaty skończone lub FSM, jak się je zwykle określa, od wielu lat są ulubionym narzędziem kodera AI, aby nasycić agenta gry iluzją inteligencji. Znajdziesz FSM tego lub innego rodzaju w niemal każdej grze, która trafia na półki od wczesnych czasów gier wideo, i pomimo rosnącej popularności bardziej ezoterycznych architektur agentów, będą one dostępne jeszcze długo. Oto tylko niektóre z powodów, dla których:

•  Są szybkie i łatwe do kodowania. Istnieje wiele sposobów programowania skończonej maszyny stanów i prawie wszystkie z nich są dość łatwe do wdrożenia. Zobaczysz kilka alternatyw opisanych tu wraz z zaletami i wadami ich używania.
•  Są łatwe do debugowania. Ponieważ zachowanie agenta gry jest podzielone na łatwe do zarządzania części, jeśli agent zacznie dziwnie działać, można go debugować, dodając kod śledzenia do każdego stanu. W ten sposób programista AI może łatwo śledzić sekwencję zdarzeń poprzedzających zachowanie błędne i odpowiednio podejmować działania.
•  Mają niewielki narzut obliczeniowy. Maszyny stanów skończonych nie wykorzystują prawie żadnego cennego czasu procesora, ponieważ zasadniczo przestrzegają ustalonych reguł. Nie ma w tym prawdziwego "myślenia" poza procesem myślowym tego typu.
•  Są intuicyjne. Ludzką naturą jest myśleć o tym, że jest w środku w takim czy innym stanie i często nazywamy się sobą w takim i takim stanie. Ile razy "wpadłeś w stan" lub znajdowałeś się w "odpowiednim stanie umysłu"? Oczywiście ludzie tak naprawdę nie działają jak maszyny skończone, ale czasem uważamy, że warto myśleć o naszym zachowaniu w ten sposób. Podobnie dość łatwo jest rozbić zachowanie agenta gry na kilka stanów i stworzyć reguły wymagane do manipulowania nimi. Z tego samego powodu maszyny stanów skończonych ułatwiają także omawianie projektu sztucznej inteligencji z programistami (na przykład z producentami gier i projektantami poziomów), zapewniając lepszą komunikację i wymianę pomysłów.
•  Są elastyczne. Skończona maszyna stanu agenta gry może być łatwo dostosowana i poprawiona przez programistę, aby zapewnić zachowanie wymagane przez projektanta gry. Prostą sprawą jest również rozszerzenie zakresu działania agenta poprzez dodanie nowych stanów i reguł. Ponadto wraz ze wzrostem umiejętności AI przekonasz się, że maszyny o skończonym stanie stanowią solidny szkielet, dzięki któremu możesz łączyć inne techniki, takie jak logika rozmyta lub sieci neuronowe.

Czym dokładnie jest automat skończony?

Historycznie automat skończony jest sztywno sformalizowanym urządzeniem używanym przez matematyków do rozwiązywania problemów. Najbardziej znanym automatem skończonym jest prawdopodobnie hipotetyczne urządzenie Alana Turinga: maszyna Turinga, o której pisał w swoim artykule z 1936 r. "O liczbach obliczalnych". Była to maszyna zapowiadająca współczesne programowalne komputery, które mogłyby wykonywać dowolną logiczną operację poprzez czytanie , pisanie i usuwanie symboli na nieskończenie długim pasku taśmy. Na szczęście, jako programiści AI, możemy zrezygnować z formalnej definicji matematycznej skończonej maszyny stanów; opisowy wystarczy jedna: Automatem skończonym jest urządzenie lub model urządzenia, które ma skończoną liczbę stanów, w których może się ona znajdować w danym momencie i może działać na wejściu, aby albo przechodzić z jednego stanu do drugiego, albo powodować wyjście lub działanie. Maszyna stanów skończonych może znajdować się tylko w jednym stanie w dowolnym momencie. Ideą automatu skończonego w jest zatem rozkład zachowanie obiektu w łatwe do zarządzania "fragmenty" lub stany. Na przykład włącznik światła na ścianie jest bardzo prostą maszyną skończoną. Ma dwa stany: włączony i wyłączony. Przejścia między stanami dokonuje się za pomocą palca. Przesunięcie przełącznika w górę powoduje przejście od wyłączenia do włączenia, a przesunięcie przełącznika w dół powoduje przejście od włączenia do wyłączenia. Nie ma wyjścia ani działania związanego ze stanem wyłączenia (chyba że uważasz, że żarówka jest wyłączona jako działanie), ale gdy jest ona w stanie włączenia, prąd może przepływać przez przełącznik i oświetlać pomieszczenie przez żarnik w żarówka. Oczywiście zachowanie agenta gry jest zwykle znacznie bardziej złożone niż żarówka (dzięki Bogu!). Oto kilka przykładów wykorzystania automatów skończonych w grach.

•  Zachowanie duchów w Pac-Manie zostało zaimplementowane jako automat skończony . Istnieje jeden stan Unikania, który jest taki sam dla wszystkich duchów, a następnie każdy duch ma swój własny Stan Chase, którego działania są uzupełniane inaczej dla każdego ducha. Wkład gracza jedzącego jedną z tabletek mocy jest warunkiem przejścia z Chase do Evade. Wprowadzenie upływającego timera jest warunkiem przejścia z Evade do Chase.
•  Gracze w symulacjach sportowych, takich jak mecz piłki nożnej FIFA2002, są implementowani jako automaty skończone. Mają stany takie jak Strike, Dribble, ChaseBall i MarkPlayer. Ponadto same zespoły są często wdrażane jako FSM i mogą mieć takie stany, jak KickOff, Defend lub WalkOutOnField.
•  NPC (postacie inne niż gracz) w RTS (gry strategiczne w czasie rzeczywistym), takie jak Warcraft, korzystają z automatów skończonych. Mają stany takie jak MoveToPosition, Patrol i FollowPath.

Implementowanie automatu skończonego

Istnieje wiele sposobów implementacji automatów skończonych. Naiwnym podejściem jest stosowanie szeregu instrukcji if-then lub nieco bardziej uporządkowanego mechanizmu instrukcji switch. Używanie przełącznika z typem wyliczonym do reprezentowania stanów wygląda mniej więcej tak:

enum StateType{RunAway, Patrol, Attack};
void Agent::UpdateState(StateType CurrentState)
{
switch(CurrentState)
{
case state_RunAway:
EvadeEnemy();
if (Safe())
{
ChangeState(state_Patrol);
}
break;
case state_Patrol:
FollowPatrolPath();
if (Threatened())
{
if (StrongerThanEnemy())
{
ChangeState(state_Attack);
}
else
{
ChangeState(state_RunAway);
}
}
break;
case state_Attack:
if (WeakerThanEnemy())
{
ChangeState(state_RunAway);
}
else
{
BashEnemyOverHead();
}
break;
}//end switch
}

Chociaż na pierwszy rzut oka takie podejście wydaje się rozsądne, gdy zastosuje się je praktycznie do rzeczy bardziej skomplikowanych niż najprostsze obiekty w grze, rozwiązanie switch / if - then staje się potworem czającym się w cieniu, czekającym na rzucenie się. W miarę dodawania kolejnych stanów i warunków, ten rodzaj struktury bardzo szybko wygląda jak spaghetti, co sprawia, że program jest trudny do zrozumienia i tworzy koszmar debugowania. Ponadto jest nieelastyczny i trudny do wykroczenia poza zakres jego pierwotnej konstrukcji, jeśli jest to pożądane… i jak wszyscy wiemy, najczęściej tak jest. O ile nie projektujesz automatu stanów do implementacji bardzo prostego zachowania (lub jesteś geniuszem), prawie na pewno znajdziesz się w pierwszej kolejności, dostosowując agenta, aby poradził sobie z nieplanowanymi okolicznościami, zanim dopracujesz zachowanie, aby uzyskać wyniki, o których myślałeś, że idziesz do zdobycia, kiedy pierwszy raz planowałeś maszynę stanową! Dodatkowo, jako koder AI, często będziesz wymagać, aby stan wykonał określoną akcję (lub akcje), kiedy zostanie wprowadzony lub gdy stan zostanie opuszczony. Na przykład, gdy agent wejdzie w stan RunAway, możesz chcieć, aby machał rękami w powietrzu i krzyczał "Arghhhhhhh!". Kiedy w końcu ucieknie i zmieni stan na Patrol, możesz chcieć, by wydał westchnienie, wytarł czoło i powiedział "Uff!" Są to akcje, które występują tylko wtedy, gdy stan RunAway zostanie wprowadzony lub zakończony, a nie podczas zwykłego kroku aktualizacji. W związku z tym ta dodatkowa funkcjonalność musi być idealnie wbudowana w architekturę maszyny stanów. Aby to zrobić w ramach przełącznika lub architektury "if-then", towarzyszyłoby wiele zgrzytania zębami i fale mdłości, a także wytwarzanie naprawdę brzydkiego kodu

Tabela stanów przejściowych

Lepszym mechanizmem organizowania stanów i wpływania na przejścia stanów jest tabela stanów przejściowych. To jest właśnie, co mówi: tabela warunków i stany, do których prowadzą te warunki. Tabela 1 pokazuje przykład odwzorowania stanów i warunków pokazanych w poprzednim przykładzie.



Tabela może być sprawdzana przez agenta w regularnych odstępach czasu, umożliwiając mu dokonywanie niezbędnych zmian stanu w oparciu o bodziec, który otrzymuje ze środowiska gry. Każdy stan można modelować jako osobny obiekt lub funkcję istniejącą na zewnątrz agenta, zapewniając czystą i elastyczną architekturę. Taki, który jest znacznie mniej podatny na sprefryfikację niż podejście "jeśli-to / zamiana" omówione w poprzednim rozdziale. Ktoś kiedyś powiedział , że żywa i głupia wizualizacja może pomóc ludziom zrozumieć abstrakcyjną koncepcję. Zobaczmy, czy to działa… Wyobraź sobie kotka robota. Jest błyszczący, ale słodki, ma drut do wąsów i otwór w brzuchu, w którym wkładki - analogiczne do jego stanów - można podłączyć. Każda z tych wkładek jest zaprogramowana logicznie, umożliwiając kotkowi wykonywanie określonego zestawu czynności. Każdy zestaw działań koduje inne zachowanie; na przykład "baw się sznurkiem", "jedz rybę" lub "kupa na dywanie". Bez wkładki wbity w brzuch kotka jest nieożywioną metaliczną rzeźbą, która tylko może tam siedzieć i wyglądać uroczo… w rodzaju Metalowego Mickeya. Kociak jest bardzo zręczny i ma możliwość samodzielnej wymiany wkładu na inny, jeśli zostanie o to poproszony. Zapewniając reguły, które określają, kiedy kaseta powinna zostać przełączona, możliwe jest zestawienie sekwencji wstawień kasety, umożliwiając tworzenie różnego rodzaju interesujących i skomplikowanych zachowań. Reguły te są zaprogramowane na niewielkim chipie umieszczonym w głowie kociaka, co jest analogiczne do tabeli stanu przejścia , o której mówiliśmy wcześniej. Chip komunikuje się z wewnętrznymi funkcjami kociaka w celu uzyskania informacji niezbędnych do przetworzenia zasad (takich jak głodna Kitty lub jak się czuje). W wyniku tego układ przejściowy stanu można zaprogramować według reguł takich jak:

IF Kitty_Hungry AND NOT Kitty_Playful
SWITCH_CARTRIDGE eat_fish

Wszystkie reguły w tabeli są testowane za każdym razem, a instrukcje są wysyłane do Kitty, aby odpowiednio zmienić kasety. Ten rodzaj architektury jest bardzo elastyczny, dzięki czemu łatwo poszerza repertuar kociąt poprzez dodanie nowych wkładów. Za każdym razem, gdy dodawany jest nowy nabój, właściciel musi tylko wziąć śrubokręt do główki kotka, aby usunąć i przeprogramować układ reguł zmiany stanu. Nie ma potrzeby ingerowania w żadne inne wewnętrzne obwody

Reguły osadzone

Alternatywnym podejściem jest osadzenie zasad dotyczących przejść między stanami w samych stanach. Stosując tę koncepcję do Robo-Kitty, można zrezygnować z układu zmiany stanu, a reguły przenieść bezpośrednio do kartridży. Na przykład kartridż do "zabawy sznurkiem" może monitorować poziom głodu kotka i poinstruować go, aby zmienił naboje na nabój "jedz rybę", gdy wyczuje wzrost głodu. Z kolei kaseta "jedz rybę" może monitorować jelita kotka i instruować go, aby przeszedł na kasetę "kupa na dywanie", gdy wyczuje niebezpiecznie wysoki poziom kupek. Chociaż każdy wkład może być świadomy istnienia któregokolwiek z pozostałych wkładów, każdy z nich jest samodzielnym urządzeniem i nie jest zależny od żadnej zewnętrznej logiki, aby zdecydować, czy powinien pozwolić sobie na zamianę na alternatywę. W związku z tym łatwo jest dodać stany, a nawet zamienić cały zestaw wkładów na zupełnie nowy zestaw (może takie, które sprawiają, że mała Kitty zachowuje się jak ptak drapieżny). Nie ma potrzeby, aby wkręcać śrubokręt w głowę kota, tylko do kilku wkładów. Przyjrzyjmy się, jak to podejście jest wdrażane w kontekście gry wideo. Podobnie jak wkładki Kitty, stany są enkapsulowane jako obiekty i zawierają logikę wymaganą do ułatwienia przejścia stanów. Ponadto wszystkie obiekty stanu mają wspólny interfejs: czystą klasę wirtualną o nazwie State. Oto wersja zapewniająca prosty interfejs:

class State
{
public:
virtual void Execute (Troll* troll) = 0;
};

Teraz wyobraź sobie klasę Troll, która ma zmienne składowe dla atrybutów takich jak zdrowie, złość, wytrzymałość itp. oraz interfejs pozwalający klientowi na zapytanie i dostosowanie tych wartości. Trollowi można nadać funkcjonalność automatu skończonego poprzez dodanie wskaźnika do instancji obiektu pochodnego klasy State oraz metodę umożliwiającą klientowi zmianę instancji, na którą wskazuje wskaźnik.

class Troll
{
/* ATTRIBUTES OMITTED */
State* m_pCurrentState;
public:
/* INTERFACE TO ATTRIBUTES OMITTED */
void Update()
{
m_pCurrentState->Execute(this);
}
void ChangeState(const State* pNewState)
{
delete m_pCurrentState;
> m_pCurrentState = pNewState;
}
};

Gdy wywoływana jest metoda aktualizacji trolla, to z kolei wywołuje metodę wykonania bieżącego typu stanu za pomocą tego wskaźnika. Bieżący stan może następnie użyć interfejsu Troll do zapytania właściciela, dostosowania atrybutów właściciela lub zmiany stanu. Innymi słowy to, jak zachowuje się Troll po aktualizacji, może być całkowicie zależne od logiki w jego obecnym stanie. Najlepiej ilustruje to przykład, więc stwórzmy kilka stanów, aby umożliwić trollowi ucieczkę przed wrogami, gdy czuje się zagrożony, i spać, gdy czuje się bezpiecznie.

//----------------------------------State_RunAway
class State_RunAway : public State
{
public:
void Execute(Troll* troll)
{
if (troll->isSafe())
{
troll->ChangeState(new State_Sleep());
}
else
{
troll->MoveAwayFromEnemy();
}
}
};
//----------------------------------State_Sleep
class State_Sleep : public State
{
public:
void Execute(Troll* troll)
{
if (troll->isThreatened())
{
troll->ChangeState(new State_RunAway())
}
else
{
troll->Snore();
}
}
};

Jak widać, po aktualizacji troll zachowuje się inaczej w zależności od tego, do którego stanu wskazuje m_pCurrentState. Oba stany są enkapsulowane jako obiekty i oba zapewniają reguły wpływające na zmianę stanu. Wszystko bardzo schludne i schludne. Ta architektura jest znana jako wzorzec projektowania stanu i zapewnia elegancki sposób implementacji zachowania zależnego od stanu. Chociaż jest to odejście od matematycznej formalizacji FSM, jest ono intuicyjne, łatwe do kodowania i łatwe do rozszerzenia. Ułatwia także dodawanie akcji wejścia i wyjścia do każdego stanu; wszystko, co musisz zrobić, to utworzyć metody Enter i Exit i odpowiednio dostosować metodę ChangeState agenta. Wkrótce zobaczysz kod, który robi to dokładnie.

The West World Project

Jako praktyczny przykład tworzenia agentów wykorzystujących automaty skończone, przyjrzymy się środowisku gry, w którym agenci zamieszkują miasto wydobywające złoto w starym stylu zachodnim o nazwie West World. Początkowo będzie tylko jeden mieszkaniec - górnik złota o imieniu Miner Bob - ale później w części pojawi się także jego żona. Będziesz musiał wyobrazić sobie miażdżące kłębowisko, skrzypiące moje rekwizyty i pustynny pył kurzu, ponieważ West World jest zaimplementowany jako prosta aplikacja tekstowa na konsolę. Wszelkie zmiany stanu lub dane wyjściowe z akcji stanu zostaną wysłane jako tekst do okna konsoli. Używam tego podejścia opartego tylko na zwykłym tekście, ponieważ wyraźnie pokazuje mechanizm automatu skończonego bez dodawania bałaganu w bardziej złożonym środowisku. W West World są cztery lokalizacje: kopalnia złota, bank, w którym Bob może zdeponować znalezione samorodki, salon, w którym może zaspokoić pragnienie, i słodki dom, w którym może spać zmęczony dniem. To, dokąd idzie i co robi, kiedy tam dotrze, zależy od obecnego stanu Boba. Zmieni stany w zależności od zmiennych, takich jak pragnienie, zmęczenie i ile złota znalazł, hackując się w kopalni złota. Zanim zagłębimy się w kod źródłowy, sprawdź następujące przykładowe dane wyjściowe z pliku wykonywalnego WestWorld1.

Miner Bob: Zdobywam samorodek
Miner Bob: Zdobywam samorodek
Miner Bob: Opuszczam kopalnię złota z kieszeniami mah pełnymi słodkiego złota
Miner Bob: Idę do banku. Tak siree
Miner Bob: Złoto depozytowe. Łączne oszczędności teraz: 3
Miner Bob: Opuszcza bank
Miner Bob: Spacer do kopalni złota
Miner Bob: Zdobywam samorodek
Miner Bob: Opuszczam kopalnię złota z kieszeniami mah pełnymi słodkiego złota
Miner Bob: Chłopcze, ah, na pewno jest gruby! Wchodzę do salonu
Miner Bob: To naprawdę świetny trunek
Miner Bob: Opuszczam salon, czuję się dobrze
Miner Bob: Spacer do kopalni złota
Miner Bob: Zdobywam samorodek
Miner Bob: Zdobywam samorodek
Miner Bob: Opuszczam kopalnię złota z kieszeniami mah pełnymi słodkiego złota
Miner Bob: Idę do banku. Tak siree
Miner Bob: Deponowanie złota. Łączne oszczędności teraz: 4
Miner Bob: Opuszcza bank
Miner Bob: Spacer do kopalni złota
Miner Bob: Zdobywam samorodek
Miner Bob: Zdobywam samorodek
Miner Bob: Opuszczam kopalnię złota z kieszeniami mah pełnymi słodkiego złota
Miner Bob: Chłopcze, ah, na pewno jest gruby! Wchodzę do salonu
Miner Bob: To świetny trunek popijający alkohol
Miner Bob: Opuszczam salon, czuję się dobrze
Miner Bob: Spacer do kopalni złota
Miner Bob: Zdobywam samorodek
Miner Bob: Opuszczam kopalnię złota z kieszeniami mah pełnymi słodkiego złota
Miner Bob: Idę do banku. Tak siree
Miner Bob: Deponowanie złota. Łączne oszczędności teraz: 5
Miner Bob: Woohoo! Na razie wystarczająco bogaty. Powrót do domu mah li'l lady
Miner Bob: Opuszcza bank
Miner Bob: Wracam do domu
Miner Bob: ZZZZ ...
Miner Bob: ZZZZ ...
Miner Bob: ZZZZ ...
Miner Bob: ZZZZ ...
Miner Bob: Co za cholerna fantastyczna drzemka! Czas znaleźć więcej złota

W danych wyjściowych programu za każdym razem, gdy widzisz Miner Boba zmieniającego lokalizację, zmienia stan. Wszystkie pozostałe wydarzenia są działaniami, które mają miejsce w obrębie stanów. Za chwilę zbadamy każdy z potencjalnych stanów Minera Boba, ale na razie wyjaśnię nieco strukturę kodu wersji demonstracyjnej.

Klasa BaseGameEntity

Wszyscy mieszkańcy West World wywodzą się z klasy podstawowej BaseGameEntity. Jest to prosta klasa z prywatnym członkiem do przechowywania numeru identyfikacyjnego. Określa również funkcję wirtualnego elementu członkowskiego, Aktualizacja, która musi zostać zaimplementowana przez wszystkie podklasy. Aktualizacja to funkcja, która jest wywoływana na każdym etapie aktualizacji i będzie używana przez podklasy do aktualizacji ich automatu stanów wraz z innymi danymi, które muszą być aktualizowane za każdym razem. Deklaracja klasy BaseGameEntity wygląda następująco:

class BaseGameEntity
{
private:
//every entity has a unique identifying number
int m_ID;
//this is the next valid ID. Each time a BaseGameEntity is instantiated
//this value is updated
static int m_iNextValidID;
//this is called within the constructor to make sure the ID is set
//correctly. It verifies that the value passed to the method is greater
//or equal to the next valid ID, before setting the ID and incrementing
//the next valid ID
void SetID(int val);
public:
BaseGameEntity(int id)
{
SetID(id);
}
virtual ~BaseGameEntity(){}
//all entities must implement an update function
virtual void Update()=0;
int ID()const{return m_ID;}
};

Z powodów, które staną się oczywiste w dalszej części, bardzo ważne jest, aby każda istota w grze miała unikalny identyfikator. Dlatego podczas tworzenia instancji identyfikator przekazywany do konstruktora jest testowany metodą SetID, aby upewnić się, że jest unikalny. Jeśli tak nie jest, program zakończy działanie z błędem potwierdzenia. W przykładzie podanym tu, podmioty wykorzystają wyliczoną wartość jako swój unikalny identyfikator. Można je znaleźć jako ent_Miner_Bob i ent_Els

Klasa Miner

Klasa Miner wywodzi się z klasy BaseGameEntity i zawiera elementy danych reprezentujące różne atrybuty, które posiada Miner, takie jak zdrowie, poziom zmęczenia, pozycja i tak dalej. Podobnie jak przykład trolla pokazany wcześniej, Górnik posiada wskaźnik do instancji klasy State oprócz metody zmiany stanu, na który wskazuje ten wskaźnik.

class Miner : public BaseGameEntity
{
private:
//wskaźnik do wystąpienia stanu
State* m_pCurrentState;
// miejsce, w którym obecnie znajduje się górnik
location_type m_Location;
// ile bryłek górnik ma w kieszeniach
int m_iGoldCarried;
//ile pieniędzy górnik zdeponował w banku
int m_iMoneyInBank;
//im wyższa wartość, tym bardziej pragniesz górnika
int m_iThirst;
//im wyższa wartość, tym bardziej zmęczony górnik
int m_iFatigue;
public:
Miner(int ID);
//to musi zostać zaimplementowane
void Update();
//ta metoda zmienia aktualny stan na nowy
void ChangeState(State* pNewState);
/* większość interfejsu została pominięta */
};

Metoda Miner :: Update jest prosta; po prostu zwiększa m_iThirst wartość przed wywołaniem metody Execute bieżącego stanu. To wygląda tak:

void Miner::Update()
{
m_iThirst += 1;
if (m_pCurrentState)
{
m_pCurrentState->Execute(this);
}
}

Teraz, gdy zobaczyłeś, jak działa klasa Miner, spójrzmy na każdy ze stanów, w których może się znaleźć górnik.

Stany Górnika

Górnik złota będzie mógł wejść do jednego z czterech stanów. Oto nazwy tych stanów, a następnie opis działań i przejść między nimi zachodzących w tych stanach:

•  EnterMineAndDigForNugget: Jeśli górnik nie znajduje się w kopalni złota, zmienia lokalizację. Jeśli już jest w kopalni złota, szuka bryłek złota. Kiedy kieszenie są pełne, Bob zmienia stan na VisitBankAndDepositGold, a jeśli podczas kopania poczuje pragnienie, zatrzyma się i zmieni stan na QuenchThirst.
•  VisitBankAndDepositGold: W tym stanie górnik podejdzie do banku i zdeponuje wszystkie bryłki, które ma przy sobie. Jeśli uzna się za wystarczająco bogatego, zmieni stan na GoHomeAnd- SleepTilRested. W przeciwnym razie zmieni stan na EnterMine-AndDigForNugget.
•  GoHomeAndSleepTilRested: W tym stanie górnik powróci do swojego miejsca i będzie spał, dopóki jego poziom zmęczenia nie spadnie poniżej dopuszczalnego poziomu. Następnie zmieni stan na EnterMineAndDigForNugget.
•  QuenchThirst: Jeśli w dowolnym momencie górnik odczuwa pragnienie (kopanie złota jest pracochłonną pracą, nie wiesz), przechodzi w ten stan i odwiedza salon, aby kupić whisky. Kiedy jego pragnienie zostanie zaspokojone, zmienia stan na EnterMineAndDigForNugget.

Czasami trudno jest śledzić przepływ logiki stanu po przeczytaniu takiego opisu tekstowego, więc często pomocne jest wybranie pióra i papieru i narysowanie diagramu przejścia stanu dla agentów gier.

Wznowiony wzorzec projektu stanu

Wcześniej zobaczyłeś krótki opis tego wzoru, ale podsumowanie go nie zaszkodzi. Każdy ze stanów agenta gry jest zaimplementowany jako unikalna klasa, a każdy agent posiada wskaźnik do instancji swojego bieżącego stanu. Agent implementuje również funkcję członka ChangeState, którą można wywołać w celu ułatwienia przełączania stanów za każdym razem, gdy wymagana jest zmiana stanu. Logika określania jakichkolwiek przejść stanu jest zawarta w każdej klasie stanu. Wszystkie klasy stanów wywodzą się z abstrakcyjnej klasy bazowej, definiując w ten sposób wspólny interfejs. Jak na razie dobrze. Już to dużo wiesz. Wcześniej w sekcji wspomniano, że zazwyczaj korzystne jest, aby każde państwo miało powiązane działania wejścia i wyjścia. Pozwala to programiście pisać logikę, która jest wykonywana tylko raz przy wejściu lub wyjściu ze stanu i znacznie zwiększa elastyczność FSM. Mając na uwadze te funkcje, spójrzmy na ulepszoną klasę podstawową State.

class State
{
public:
virtual ~State(){}
//this will execute when the state is entered
virtual void Enter(Miner*)=0;
//this is called by the miner's update function each update step
virtual void Execute(Miner*)=0;
//this will execute when the state is exited
virtual void Exit(Miner*)=0;
}

Te dodatkowe metody są wywoływane tylko wtedy, gdy stan Górnika zmienia się. Kiedy następuje przejście do stanu, metoda Miner :: ChangeState najpierw wywołuje metodę Exit bieżącego stanu, a następnie przypisuje nowy stan do bieżącego stanu, a kończy przez wywołanie metody Enter nowego stanu (który jest teraz bieżącym stan). Myślę, że kod jest wyraźniejszy niż słowa w tym przypadku, więc oto lista metod ChangeState:

void Miner::ChangeState(State* pNewState)
{
//make sure both states are valid before attempting to
//call their methods
assert (m_pCurrentState && pNewState);
//call the exit method of the existing state
m_pCurrentState->Exit(this);
//change state to the new state
m_pCurrentState = pNewState;
//call the entry method of the new state
m_pCurrentState->Enter(this);
}

Zwróć uwagę, w jaki sposób Górnik przekazuje ten wskaźnik do każdego stanu, umożliwiając mu korzystanie z interfejsu Górnika w celu uzyskania dostępu do odpowiednich danych. Wzorzec projektowania stanu jest również przydatny do konstruowania głównych elementów przepływu gry. Na przykład możesz mieć stan menu, stan zapisu, stan wstrzymania, stan opcji, stan uruchomienia itp. Każdy z czterech możliwych stanów, do których Górnik może uzyskać dostęp, pochodzi z klasy State, co daje nam te konkretne klasy: EnterMineAndDigForNugget, VisitBankAndDepositGold, GoHomeAndSleepTilRested i QuenchThirst. Wskaźnik Miner :: m_pCurrentState może wskazywać dowolny z tych stanów. Kiedy wywoływana jest metoda aktualizacji Minera, to z kolei wywołuje metodę Execute aktualnie aktywnego stanu z tym wskaźnikiem jako parametrem. Każdy konkretny stan jest implementowany jako obiekt singletonu. Ma to zapewnić, że istnieje tylko jedna instancja każdego stanu, którą współużytkują agenci. Korzystanie z singletonów sprawia, że projekt jest bardziej wydajny, ponieważ eliminuje potrzebę przydzielania i zwalniania pamięci przy każdej zmianie stanu. Jest to szczególnie ważne, jeśli masz wielu agentów współużytkujących złożony FSM i / lub pracujesz dla maszyny z ograniczonymi zasobami.

UWAGA : Wolę używać singletonów dla stanów z powodów, które już podałem, ale jest jedna wada. Ponieważ są one współużytkowane przez klientów, stany singletonu nie są w stanie korzystać z własnych lokalnych danych specyficznych dla agenta. Na przykład, jeśli agent używa stanu, który po wprowadzeniu powinien przenieść go na dowolną pozycję, pozycji nie można zapisać w samym stanie (ponieważ pozycja może być inna dla każdego agenta, który używa tego stanu). Zamiast tego musiałby być przechowywany gdzieś na zewnątrz i dostępny przez państwo za pośrednictwem interfejsu agenta. Nie jest to tak naprawdę problemem, jeśli twoje stany uzyskują dostęp tylko do jednej lub dwóch części danych, ale jeśli okaże się, że stany, które zaprojektowałeś, często uzyskują dostęp do wielu danych zewnętrznych, prawdopodobnie warto rozważyć usunięcie projektu singletonu i napisanie kilku wiersze kodu do zarządzania przydzielaniem i zwalnianiem pamięci stanu.

Wzorzec projektowy Singleton

Często warto zagwarantować, że obiekt zostanie utworzony tylko raz i / lub że jest globalnie dostępny. Na przykład w projektach gier, które mają środowiska składające się z wielu różnych typów bytów - graczy, potworów, pocisków, doniczek itp. - zwykle istnieje obiekt "menedżera", który obsługuje tworzenie, usuwanie i zarządzanie takimi obiektami. Konieczne jest tylko jedno wystąpienie tego obiektu - singleton - i wygodnie jest uczynić go globalnie dostępnym, ponieważ wiele innych obiektów będzie wymagało dostępu do niego. Wzór singletonu zapewnia obie te cechy. Istnieje wiele sposobów implementacji singletonu (wyszukaj na google.com, a zobaczysz, co mam na myśli). Wolę używać metody statycznej, Instance, która zwraca wskaźnik do statycznej instancji klasy. Oto przykład:

/* ------------------ MyClass.h -------------------- */
#ifndef MY_SINGLETON
#define MY_SINGLETON
class MyClass
{
private:
// member data
int m_iNum;
//constructor is private
MyClass(){}
//copy ctor and assignment should be private
MyClass(const MyClass &);
MyClass& operator=(const MyClass &);
public:
//strictly speaking, the destructor of a singleton should be private but some
//compilers have problems with this so I've left them as public in all the
//examples in this book
~MyClass();
//methods
int GetVal()const{return m_iNum;}
static MyClass* Instance();
};
#endif
/* -------------------- MyClass.cpp ------------------- */
//this must reside in the cpp file; otherwise, an instance will be created
//for every file in which the header is included
MyClass* MyClass::Instance()
{
static MyClass instance;
return &instance;
}

Dostęp do zmiennych i metod składowych można teraz uzyskać za pomocą metody Instance w następujący sposób:

int num = MyClass :: Instance () -> GetVal ();

Ponieważ jestem leniwy i nie lubię zapisywać całej tej składni za każdym razem chcę uzyskać dostęp do singletonu, zwykle #define coś takiego:

#define MyCls MyClass::Instance()

Korzystając z tej nowej składni, mogę po prostu napisać:

int num = MyCls-> GetVal ();

O wiele łatwiej, nie sądzisz? Ok, zobaczmy, jak wszystko do siebie pasuje, sprawdzając całość kod jednego ze stanów górnika.

Stan EnterMineAndDigForNugget

W tym stanie górnik powinien zmienić lokalizację na kopalnię złota. Będąc w kopalni złota, powinien kopać po złoto, dopóki kieszenie się nie zapełnią, kiedy powinien zmienić stan na VisitBankAndDepositNugget. Jeśli górnik będzie spragniony podczas kopania, powinien zmienić stan na QuenchThirst. Ponieważ konkretne stany po prostu implementują interfejs zdefiniowany w stanie wirtualnej klasy bazowej, ich deklaracje są bardzo proste:

class EnterMineAndDigForNugget : public State
{
private:
EnterMineAndDigForNugget(){}
/* copy ctor and assignment op omitted */
public:
//this is a singleton
static EnterMineAndDigForNugget* Instance();
virtual void Enter(Miner* pMiner);
virtual void Execute(Miner* pMiner);
virtual void Exit(Miner* pMiner);
};

Jak widać, to tylko formalność. Rzućmy okiem na każdą z metod po kolei.

EnterMineAndDigForNugget :: Enter

Kod dla metody Enter EnterMineAndDigForNugget jest następujący:

void EnterMineAndDigForNugget::Enter(Miner* pMiner)
{
//if the miner is not already located at the gold mine, he must
//change location to the gold mine
if (pMiner->Location() != goldmine)
{
cout << "\n" << GetNameOfEntity(pMiner->ID()) << ": "
<< "Walkin' to the gold mine";
pMiner->ChangeLocation(goldmine);
}
}

Ta metoda jest wywoływana, gdy górnik po raz pierwszy wchodzi w stan EnterMineAndDig-ForNugget. Zapewnia, że górnik złota znajduje się w kopalni złota. Agent przechowuje swoją lokalizację jako typ wyliczony, a metoda ChangeLocation zmienia tę wartość, aby zmienić lokalizację

EnterMineAndDigForNugget :: Execute

Metoda wykonania jest nieco bardziej skomplikowana i zawiera logikę, która może zmienić stan górnika. (Nie zapominaj, że Execute to metoda nazywana każdym krokiem aktualizacji z Miner :: Update).

void EnterMineAndDigForNugget::Execute(Miner* pMiner)
{
//the miner digs for gold until he is carrying in excess of MaxNuggets.
//If he gets thirsty during his digging he stops work and
//changes state to go to the saloon for a whiskey.
pMiner->AddToGoldCarried(1);
//diggin' is hard work
pMiner->IncreaseFatigue();
cout << "\n" << GetNameOfEntity(pMiner->ID()) << ": "
<< "Pickin' up a nugget";
//if enough gold mined, go and put it in the bank
if (pMiner->PocketsFull())
{
pMiner->ChangeState(VisitBankAndDepositGold::Instance());
}
//if thirsty go and get a whiskey
if (pMiner->Thirsty())
{
pMiner->ChangeState(QuenchThirst::Instance());
}
}

Zwróć uwagę, jak wywoływana jest metoda Miner :: ChangeState przy użyciu elementu QuenchThirst lub VisitBankAndDepositGold, który zapewnia wskaźnik do unikalnego wystąpienia tej klasy.

EnterMineAndDigForNugget :: Exit

Metoda wyjścia EnterMineAndDigForNugget wyświetla komunikat informujący, że górnik opuszcza kopalnię.

void EnterMineAndDigForNugget::Exit(Miner* pMiner)
{
cout << "\n" << GetNameOfEntity(pMiner->ID()) << ": "
<< "Ah'm leavin' the gold mine with mah pockets full o' sweet gold";
}

Mam nadzieję, że analiza powyższych trzech metod pomoże usunąć wszelkie nieporozumienia, które mogły wystąpić, i że możesz teraz zobaczyć, jak każdy stan może modyfikować zachowanie agenta lub spowodować przejście do innego stanu. Na tym etapie może okazać się przydatne załadowanie projektu WestWorld1 do IDE i zeskanowanie kodu. W szczególności sprawdź wszystkie stany w MinerOwnedStates.cpp i sprawdź klasę Miner, aby zapoznać się z jej zmiennymi składowymi. Przede wszystkim upewnij się, że rozumiesz, jak działa wzorzec projektowania stanu, zanim zaczniesz czytać dalej. Jeśli nie masz pewności, przejrzyj kilka poprzednich stron, aż poczujesz się swobodnie z tą koncepcją. Widziałeś, jak użycie wzorca projektowania stanu zapewnia bardzo elastyczny mechanizm dla agentów kierowanych przez stan. Bardzo łatwo jest dodawać dodatkowe stany w razie potrzeby. Rzeczywiście, jeśli chcesz, możesz zmienić całą architekturę stanu agenta na alternatywną. Może to być przydatne, jeśli masz bardzo skomplikowany projekt, który byłby lepiej zorganizowany jako zbiór kilku oddzielnych mniejszych maszyn stanu. Na przykład automat stanowy dla strzelanek FPS, takich jak Unreal 2, jest zwykle duży i złożony. Projektując sztuczną inteligencję dla gry tego rodzaju, może się okazać, że lepiej jest pomyśleć o kilku mniejszych automatach stanowych reprezentujących funkcje takie jak "broń flagi" lub "eksploruj mapę", które można włączać i wyłączać w razie potrzeby. Stanowy wzór projektu ułatwia to.

Sprawienie, aby podstawowa klasa stanu mogła być ponownie użyta

Na obecnym etapie konieczne jest utworzenie osobnej klasy bazowej State dla każdego typu znaków, z którego wywodzą się jej stany. Zamiast tego sprawmy, że będzie można go ponownie wykorzystać, zamieniając go w szablon klasy.

template
class State
{
public:
virtual void Enter(entity_type*)=0;
virtual void Execute(entity_type*)=0;
virtual void Exit(entity_type*)=0;
virtual ~State(){}
};

Deklaracja dla konkretnego stanu - przy użyciu EnterMineAndDigForNugget stan górnika jako przykład - teraz wygląda następująco:

class EnterMineAndDigForNugget : public State
{
public:
/* OMITTED */
};

To, jak wkrótce zobaczycie, ułatwia życie na dłuższą metę.

Stany globalne i blipy stanu

Częściej niż nie, przy projektowaniu automatów skończonych kończy się kod, który jest powielany w każdym stanie. Na przykład, w popularnej grze The Sims autorstwa Maxisa, Sim może poczuć nadciągającą potrzebę natury i musi odwiedzić łazienkę, aby sobie ulżyć. Potrzeba ta może wystąpić w dowolnym stanie, w którym Sim może być iw dowolnym momencie. Biorąc pod uwagę obecny projekt, aby nadać górnikowi złota tego rodzaju zachowanie, do każdego z jego stanów należy dodać zduplikowaną logikę warunkową lub alternatywnie umieścić ją w funkcji Miner :: Update. Chociaż to drugie rozwiązanie jest akceptowalne, lepiej jest utworzyć stan globalny, który jest wywoływany przy każdej aktualizacji FSM. W ten sposób cała logika dla FSM jest zawarta w stanach, a nie w klasie agenta, która jest właścicielem FSM. Aby zaimplementować stan globalny, wymagana jest dodatkowa zmienna składowa: // zauważ, że teraz ten stan jest szablonem klasy, musimy zadeklarować typ jednostki

Stan * m_pGlobalState;

Oprócz globalnego zachowania, czasami będzie to wygodne dla agenta, aby wejść w stan z warunkiem, że po opuszczeniu stanu agent powraca do poprzedniego stanu. To zachowanie nazywam blipem stanu. Na przykład, tak jak w The Sims, możesz nalegać, aby Twój agent mógł odwiedzić łazienkę w dowolnym momencie, ale upewnij się, że zawsze wraca do poprzedniego stanu. Aby zapewnić FSM tego typu funkcjonalność, musi on prowadzić rejestr poprzedniego stanu, aby blip stanu mógł się do niego przywrócić. Jest to łatwe, ponieważ wymagana jest tylko inna zmienna składowa i dodatkowa logika w metodzie Miner :: ChangeState. Do tej pory jednak, aby zaimplementować te dodatki, klasa Miner nabyła dwie dodatkowe zmienne składowe i jedną dodatkową metodę. Skończyło się na tym, że wygląda tak (pominięto obce szczegóły):

class Miner : public BaseGameEntity
{
private:
State* m_pCurrentState;
State* m_pPreviousState;
State* m_pGlobalState;

public:
void ChangeState(State* pNewState);
void RevertToPreviousState();

};

Hmm, wygląda na to, że czas trochę posprzątać.

Tworzenie klasy Stanu Maszynowego

Projekt można uczynić o wiele czystszym, kapsułkując wszystkie dane i metody związane ze stanem do klasy stanu maszynowego. W ten sposób agent może posiadać instancję automatu stanów i przekazać mu zarządzanie stanami bieżącymi, globalnymi i poprzednimi. Mając to na uwadze, spójrz na następujący szablon klasy StateMachine:

template
class StateMachine
{
private:
// wskaźnik do agenta, który jest właścicielem tego wystąpienia
entity_type* m_pOwner;
State* m_pCurrentState;
// rekord ostatniego stanu, w którym agent był
State* m_pPreviousState;
// ta logika stanu jest wywoływana przy każdej aktualizacji FSM
State* m_pGlobalState;
public:
StateMachine(entity_type* owner):m_pOwner(owner),
m_pCurrentState(NULL),
m_pPreviousState(NULL),
m_pGlobalState(NULL)
{}
// użyj tych metod do zainicjowania FSM
void SetCurrentState(State* s){m_pCurrentState = s;}
void SetGlobalState(State* s) {m_pGlobalState = s;}
void SetPreviousState(State* s){m_pPreviousState = s;}
// wywołaj to, aby zaktualizować FSM
void Update()const
{
// jeśli istnieje stan globalny, wywołaj jego metodę wykonywania
if (m_pGlobalState) m_pGlobalState->Execute(m_pOwner);
// to samo dla bieżącego stanu
if (m_pCurrentState) m_pCurrentState->Execute(m_pOwner);
}
// zmiana na nowy stan
void ChangeState(State* pNewState)
{
assert(pNewState &&
": trying to change to a null state");
// prowadzić rejestr poprzedniego stanu
m_pPreviousState = m_pCurrentState;
// wywołaj metodę wyjścia istniejącego stanu
m_pCurrentState->Exit(m_pOwner);
// zmiana stanu na nowy stan
m_pCurrentState = pNewState;
// wywołaj metodę wprowadzania nowego stanu
m_pCurrentState->Enter(m_pOwner);
}
// powrót do poprzedniego stanu
void RevertToPreviousState()
{
ChangeState(m_pPreviousState);
}
// akcesoria
State* CurrentState() const{return m_pCurrentState;}
State* GlobalState() const{return m_pGlobalState;}
State* PreviousState() const{return m_pPreviousState;}
// zwraca true, jeśli typ bieżącego stanu jest równy typowi
// klasa przekazana jako parametr.
bool isInState(const State& st)const;
};

Teraz wszystko, co agent musi zrobić, to posiadać instancję StateMachine i wdrożyć metodę aktualizacji automatu stanów w celu uzyskania pełnej funkcjonalności FSM. Ulepszona klasa Miner wygląda teraz tak:

class Miner : public BaseGameEntity
{
private:
// instancja klasy maszyny stanów
StateMachine* m_pStateMachine;
/* EXTRANEOUS DETAIL OMITTED */
public:
Miner(int id):m_Location(shack),
m_iGoldCarried(0),
m_iMoneyInBank(0),
m_iThirst(0),
m_iFatigue(0),
BaseGameEntity(id)
{
// skonfiguruj maszynę stanu
m_pStateMachine = new StateMachine(this);
m_pStateMachine->SetCurrentState(GoHomeAndSleepTilRested::Instance());
m_pStateMachine->SetGlobalState(MinerGlobalState::Instance());
}
~Miner(){delete m_pStateMachine;}
void Update()
{
++m_iThirst;
m_pStateMachine->Update();
}
StateMachine* GetFSM()const{return m_pStateMachine;}
/* EXTRANEOUS DETAIL OMITTED */
};

Zauważ, jak należy ustawić jawnie stan obecny i globalny, gdy utworzono instancję StateMachine.



Przedstawiamy Elsę

Aby zademonstrować te ulepszenia, stworzyłem drugi projekt dla tej części: WestWorldWithWoman. W tym projekcie West World zyskał kolejnego mieszkańca, Elsę, żonę górnika złota. Elsa jeszcze niewiele robi; zajmuje się głównie czyszczeniem budy i opróżnianiem pęcherza (pije zdecydowanie za dużo kawy). Kiedy uruchamiasz projekt w swoim IDE, zwróć uwagę, w jaki sposób stan VisitBathroom jest implementowany jako stan zerowy (tj. zawsze powraca do poprzedniego stanu). Należy również pamiętać, że zdefiniowano stan globalny WifesGlobalState, który zawiera logikę wymaganą podczas wizyt Elsy w łazience. Ta logika jest zawarta w stanie globalnym, ponieważ Elsa może odczuwać zew natury w dowolnym stanie i czasie.

Dodawanie możliwości przesyłania wiadomości do FSM

Dobrze zaprojektowane gry są zazwyczaj oparte na zdarzeniach. To znaczy, gdy zdarzenie ma miejsce - wystrzeliwana jest broń, pociągana jest dźwignia, uruchamiany jest alarm itp. - zdarzenie jest transmitowane do odpowiednich obiektów w grze, aby mogły odpowiednio zareagować. Zdarzenia te są zazwyczaj wysyłane w postaci pakietu danych, który zawiera informacje o zdarzeniu, takie jak to, co je wysłało, jakie obiekty powinny na nie odpowiedzieć, jakie jest rzeczywiste zdarzenie, znacznik czasu i tak dalej. Powodem, dla którego generalnie preferowane są architektury oparte na zdarzeniach, jest to, że są wydajne. Bez obsługi zdarzeń obiekty muszą stale przeglądać świat gry, aby sprawdzić, czy nastąpiło jakieś działanie. Dzięki obsłudze zdarzeń obiekty mogą po prostu zacząć działać, dopóki nie pojawi się komunikat o zdarzeniu które jest im nadawane. Następnie, jeśli ta wiadomość jest trafna, mogą działać na nim. Inteligentni agenci gier mogą używać tego samego pomysłu do komunikowania się ze sobą. Po wyposażeniu w możliwość wysyłania, obsługi i reagowania na zdarzenia łatwo jest zaprojektować takie zachowanie:

•  Czarodziej rzuca kulą ognia w orka. Czarodziej wysyła do orka wiadomość informującą o zbliżającym się przeznaczeniu, aby mógł odpowiednio zareagować, tj. umrzeć straszliwie i we wspaniałym stylu.
•  Piłkarz wykonuje podanie do kolegi z drużyny. Przechodzący może wysłać wiadomość do odbiorcy, informując go, gdzie ma się poruszyć, aby przechwycić piłkę i o której godzinie powinna być w tej pozycji.
•  Pracownik jest ranny. Wysyła wiadomość do każdego z towarzyszy z prośbą o pomoc. Kiedy ktoś przybywa z pomocą, nadawany jest inny komunikat, aby poinformować innych, że mogą wznowić swoją działalność.
•  Postać odpala zapałkę, aby pomóc jej rozejrzeć się w mroku korytarza. Wysyłana jest opóźniona wiadomość z ostrzeżeniem, że zapałka spali się w palcach za trzydzieści sekund. Jeśli nadal trzyma zapałkę, gdy otrzymuje wiadomość, reaguje, rzucając zapałkę i krzycząc z bólu.

Dobrze, prawda? Pozostała część tej pokaże, w jaki sposób agenci mogą mieć możliwość obsługi takich wiadomości. Ale zanim będziemy mogli dowiedzieć się, jak je przesyłać i obsługiwać, pierwszą rzeczą do zrobienia jest dokładne określenie, czym jest wiadomość.

Struktura Ttelegramu

Wiadomość jest po prostu wyliczonym typem. To może być prawie wszystko. Możesz mieć agentów wysyłających wiadomości, takie jak Msg_ReturnToBase, Msg_MoveToPosition lub Msg_HelpNeeded. Wraz z wiadomością należy również spakować dodatkowe informacje. Na przykład powinniśmy zapisać informacje o tym, kto je wysłał, kim jest odbiorca, jaka jest rzeczywista wiadomość, znacznik czasu i tak dalej. Aby to zrobić, wszystkie istotne informacje są przechowywane razem w strukturze o nazwie Telegram. Kod pokazano poniżej. Sprawdź każdą zmienną składową i dowiedz się, jakie informacje będą przekazywać agenci gry.

struct Telegram
{
//the entity that sent this telegram
int Sender;
//the entity that is to receive this telegram
int Receiver;
//the message itself. These are all enumerated in the file
//"MessageTypes.h"
int Msg;
//messages can be dispatched immediately or delayed for a specified amount
//of time. If a delay is necessary, this field is stamped with the time
//the message should be dispatched.
double DispatchTime;
//any additional information that may accompany the message
void* ExtraInfo;
/* CONSTRUCTORS OMITTED */
};

Struktura telegramu powinna nadawać się do wielokrotnego użytku, ale ponieważ nie można z góry wiedzieć, jakie dodatkowe informacje przyszłe projekty gier będą musiały przekazać w wiadomości, zapewniono nieważny wskaźnik ExtraInfo. Można to wykorzystać do przekazania dowolnej ilości dodatkowych informacji między postaciami. Na przykład, jeśli lider plutonu wysyła wiadomość Msg_MoveToPosition do wszystkich swoich ludzi, ExtraInfo może być użyte do przechowywania współrzędnych tej pozycji.

Miner Bob i Elsa Komunikują się

Na nasze potrzeby utrzymałem prostą komunikację między Miner Bob i Elsą. Mają tylko dwie wiadomości, których mogą użyć, i są wymienione jako:

enum message_type
{
Msg_HiHoneyImHome,
Msg_StewReady
};

Górnik wyśle Msg_HiHoneyImHome do swojej żony, aby poinformowała ją, że wrócił do chaty. Msg_StewReady jest wykorzystywana przez żonę, aby dać sobie znać, kiedy ma wyjąć obiad z piekarnika, i aby powiadomić Minera Boba, że jedzenie jest na stole. Zanim pokażę Ci, jak agent obsługuje zdarzenia telegramowe, pozwól mi pokazać, jak są tworzone, zarządzane i wysyłane

Wysyłanie i zarządzanie wiadomościami

Tworzenie, wysyłanie i zarządzanie telegramami jest obsługiwane przez klasę o nazwie MessageDispatcher. Ilekroć agent musi wysłać wiadomość, wywołuje MessageDispatcher :: DispatchMessage ze wszystkimi niezbędnymi informacjami, takimi jak typ wiadomości, czas wysłania wiadomości, identyfikator odbiorcy i tak dalej. MessageDispatcher wykorzystuje te informacje do utworzenia telegramu, który albo wysyła natychmiast, albo przechowuje w kolejce gotowej do wysłania we właściwym czasie. Aby móc wysłać wiadomość, MessageDispatcher musi uzyskać wskaźnik do encji określonej przez nadawcę. Dlatego musi istnieć jakaś baza danych instancji tworzonych przez instancję, aby odsyłacz MessageDispatcher mógł się odnosić do pewnego rodzaju książki telefonicznej, w której wskaźniki do agentów są odsyłane przez ich identyfikatory. Bazą danych używaną do demonstracji jest klasa singleton o nazwie EntityManager. Deklaracja wygląda następująco:

class EntityManager
{
private:
//by ocalić stare palce
typedef std::map EntityMap;
private:
//aby ułatwić szybkie wyszukiwanie, encje są przechowywane na std :: map,
// które wskaźniki do encji są powiązane poprzez ich identyfikację
//liczby
EntityMap m_EntityMap;
EntityManager(){}
//skopiuj ctor i przypisanie powinny być prywatne
EntityManager(const EntityManager&);
EntityManager& operator=(const EntityManager&);
public:
static EntityManager* Instance();
//ta metoda przechowuje wskaźnik do encji w std :: vector
// m_Entities w pozycji indeksu wskazanej przez identyfikator jednostki
// (zapewnia szybszy dostęp)
void RegisterEntity(BaseGameEntity* NewEntity);
//zwraca wskaźnik do encji z identyfikatorem podanym jako parametr
BaseGameEntity* GetEntityFromID(int id)const;
//ta metoda usuwa byt z listy
void RemoveEntity(BaseGameEntity* pEntity);
};
//zapewnia łatwy dostęp do instancji EntityManager
#define EntityMgr EntityManager::Instance()

Po utworzeniu encji jest on rejestrowany w menedżerze encji w następujący sposób:

Miner * Bob = nowy Miner (ent_Miner_Bob); // wyliczony identyfikator
EntityMgr-> RegisterEntity (Bob);

Klient może teraz zażądać wskaźnika do określonej encji, przekazując swój identyfikator do metody EntityManager :: GetEntityFromID w następujący sposób:

Entity * pBob = EntityMgr-> GetEntityFromID (ent_Miner_Bob);

Klient może następnie użyć tego wskaźnika, aby wywołać moduł obsługi komunikatów dla tego konkretnego obiektu. Więcej o tym za chwilę, ale najpierw przyjrzyjmy się, w jaki sposób wiadomości są tworzone i kierowane między jednostkami.

Klasa MessageDispatcher

Klasą zarządzającą wysyłaniem wiadomości jest singleton o nazwie MessageDispatcher. Spójrz na deklarację tej klasy:

class MessageDispatcher
{
private:
// std :: set służy jako kontener dla opóźnionych komunikatów
// ze względu na zaletę automatycznego sortowania i unikania
// duplikatów. Wiadomości są sortowane według czasu wysyłki.
std::set PriorityQ;
// ta metoda jest używana przez DispatchMessage lub DispatchDelayedMessages.
// Ta metoda wywołuje funkcję członka obsługi komunikatu odbiorcy
// byt, pReceiver, z nowo utworzonym telegramem
void Discharge(Entity* pReceiver, const Telegram& msg);
MessageDispatcher(){}
public:
//ta klasa to singleton
static MessageDispatcher* Instance();
//wyślij wiadomość do innego agenta.
void DispatchMessage(double delay,
int sender,
int receiver,
int msg,
void* ExtraInfo);
// wysyłanie opóźnionych wiadomości. Ta metoda jest wywoływana za każdym razem
// główna pętla gry.
void DispatchDelayedMessages();
};
// aby ułatwić życie ...
#define Dispatch MessageDispatcher::Instance()

Klasa MessageDispatcher obsługuje wiadomości wysyłane natychmiast i wiadomości ze znacznikiem czasu, które są wiadomościami dostarczanymi w określonym czasie w przyszłości. Oba typy wiadomości są tworzone i zarządzane za pomocą tej samej metody: DispatchMessage. Przejdźmy do źródła. (W pliku towarzyszącym ta metoda zawiera kilka dodatkowych wierszy kodu do wysyłania tekstów informacyjnych do konsoli. Pominąłem je tutaj dla jasności).

void MessageDispatcher::DispatchMessage(double delay,
int sender,
int receiver,
int msg,
void* ExtraInfo)
{

Ta metoda jest wywoływana, gdy jednostka wysyła komunikat do innej jednostki. Nadawca wiadomości musi podać jako parametry szczegóły wymagane do utworzenia struktury telegramu. Oprócz identyfikatora nadawcy, identyfikatora odbiorcy i samej wiadomości funkcja ta musi mieć opóźnienie czasowe i wskaźnik do wszelkich dodatkowych informacji, jeśli takie istnieją. Jeśli wiadomość ma zostać wysłana natychmiast, metodę należy wywołać z zerowym lub ujemnym opóźnieniem.

//uzyskaj wskaźnik do odbiorcy wiadomości

Entity* pReceiver = EntityMgr->GetEntityFromID(receiver);
//utwórz telegram
Telegram telegram(0, sender, receiver, msg, ExtraInfo);
//jeśli nie ma opóźnienia, natychmiast prześlij telegram
if (delay <= 0.0)
{
// wyślij telegram do odbiorcy
Discharge(pReceiver, telegram);
}

Po uzyskaniu wskaźnika do odbiorcy za pośrednictwem menedżera encji i utworzeniu telegramu przy użyciu odpowiednich informacji wiadomość jest gotowa do wysłania. Jeśli wiadomość jest przeznaczona do natychmiastowej wysyłki, metoda Discharge jest natychmiast wywoływana. Metoda Discharge przekazuje nowo utworzony telegram do metody obsługi wiadomości jednostki odbierającej (więcej na ten temat wkrótce). Większość wiadomości wysyłanych przez agentów zostanie utworzona i natychmiast wysłana w ten sposób. Na przykład, jeśli troll uderzy pałką w głowę człowieka, może wysłać do człowieka natychmiastową wiadomość z informacją, że został trafiony. Człowiek zareagowałby wtedy za pomocą odpowiedniej akcji, dźwięku i animacji.

// else oblicz czas, kiedy telegram powinien zostać wysłany

else
{
double CurrentTime = Clock->GetCurrentTime();
telegram.DispatchTime = CurrentTime + delay;
// i umieść go w kolejce
PriorityQ.insert(telegram);
}
}
Jeśli wiadomość ma zostać wysłana w przyszłości, te kilka wierszy kodu oblicza czas, jaki powinien zostać dostarczony przed wstawieniem nowego telegramu do kolejki priorytetowej - struktury danych, która utrzymuje elementy posortowane w kolejności pierwszeństwa. W tym przykładzie wykorzystałem std :: set jako kolejkę priorytetową, ponieważ automatycznie odrzuca zduplikowane telegramy. Telegramy są sortowane według znacznika czasu i w tym celu, jeśli spojrzysz na Telegram.h, przekonasz się, że operatory < i == zostały przeciążone. Zauważ również, że telegramy ze znacznikami czasu mniejszymi niż ćwierć sekundy należy traktować jako identyczne. Zapobiega to gromadzeniu się wielu podobnych telegramów w kolejce i ich masowemu dostarczaniu, tym samym zalewając agenta identycznymi komunikatami. Oczywiście to opóźnienie będzie się różnić w zależności od gry. Gry z dużą ilością akcji generujące dużą częstotliwość wiadomości prawdopodobnie będą wymagać mniejszej luki. Telegramy w kolejce są sprawdzane na każdym etapie aktualizacji za pomocą metody DispatchDelayedMessages. Ta funkcja sprawdza przód kolejki priorytetowej, aby sprawdzić, czy jakieś telegramy wygasły. Jeśli tak, są wysyłane do odbiorcy i usuwane z kolejki. Kod dla tej metody wygląda następująco:

void MessageDispatcher::DispatchDelayedMessages()
{
// najpierw uzyskaj aktualny czas
double CurrentTime = Clock->GetCurrentTime();
// teraz zajrzyj do kolejki, aby sprawdzić, czy jakieś telegramy wymagają wysyłki.
// usuń wszystkie telegramy z przodu kolejki, które zniknęły
// upłynął termin ich sprzedaży
while( (PriorityQ.begin()->DispatchTime < CurrentTime) &&
(PriorityQ.begin()->DispatchTime > 0) )
{
// przeczytaj telegram z przodu kolejki
Telegram telegram = *PriorityQ.begin();
// znajdź odbiorcę
Entity* pReceiver = EntityMgr->GetEntityFromID(telegram.Receiver);
// wyślij telegram do odbiorcy
Discharge(pReceiver, telegram);
// i usuń go z kolejk
PriorityQ.erase(PriorityQ.begin());
}
}

Wywołanie do tej metody musi zostać umieszczone w głównej pętli aktualizacji gry, aby ułatwić prawidłowe i terminowe wysyłanie opóźnionych wiadomości

Obsługa wiadomości

Po wdrożeniu systemu do tworzenia i wysyłania wiadomości ich obsługa jest stosunkowo łatwa. Klasa BaseGameEntity musi zostać zmodyfikowana, aby każda podklasa mogła odbierać wiadomości. Osiąga się to poprzez zadeklarowanie kolejnej czystej funkcji wirtualnej, HandleMessage, którą wszystkie klasy pochodne muszą zaimplementować. Zmieniona klasa podstawowa BaseGameEntity wygląda teraz tak:

class BaseGameEntity
{
private:
int m_ID;
/* EXTRANEOUS DETAIL REMOVED FOR CLARITY*/
public:
// wszystkie podklasy mogą komunikować się za pomocą komunikatów
virtual bool HandleMessage(const Telegram& msg)=0;
/* EXTRANEOUS DETAIL REMOVED FOR CLARITY*/
};

Ponadto klasę podstawową State należy również zmodyfikować, aby stany BaseGameEntity mogły akceptować i obsługiwać wiadomości. Zmieniona klasa State zawiera dodatkową metodę OnMessage w następujący sposób:

template
class State
{
public:
// wykonuje się, jeśli agent otrzyma wiadomość od
// dyspozytor wiadomości
virtual bool OnMessage(entity_type*, const Telegram&)=0;
/* DODATKOWY SZCZEGÓŁ USUNIĘTY DLA JASNOŚCI */
};

Wreszcie klasa StateMachine jest modyfikowana, aby zawierała metodę HandleMessage. Gdy telegram jest odbierany przez jednostkę, jest najpierw kierowany do jej aktualnego stanu. Jeśli bieżący stan nie ma kodu do obsługi wiadomości, zostaje przekierowany do globalnego modułu obsługi wiadomości stanu podmiotu. Prawdopodobnie zauważyłeś, że OnMessage zwraca bool. Ma to na celu wskazanie, czy wiadomość została pomyślnie obsłużona, i umożliwia odpowiednie jej trasowanie przez kod. Oto lista metody StateMachine :: HandleMessage:

bool StateMachine::HandleMessage(const Telegram& msg)const
{
// najpierw sprawdź, czy aktualny stan jest prawidłowy i czy może to obsłużyć
//wiadomość
if (m_pCurrentState && m_pCurrentState->OnMessage(m_pOwner, msg))
{
return true;
}
// jeśli nie, a jeśli stan globalny został zaimplementowany, wyślij
// wiadomość do stanu globalnego
if (m_pGlobalState && m_pGlobalState->OnMessage(m_pOwner, msg))
{
return true;
}
return false;
}

A oto, w jaki sposób klasa Miner kieruje wysłane do niego wiadomości:

bool Miner::HandleMessage(const Telegram& msg)
{
return m_pStateMachine->HandleMessage(msg);
}

Elsa Gotuje Obiad

W tym momencie prawdopodobnie dobrym pomysłem jest przyjrzenie się konkretnemu przykładowi działania wiadomości, więc przyjrzyjmy się, jak można je włączyć do projektu West World. W ostatecznej wersji tego demo, WestWorldWith-Messaging, sekwencja komunikatów przebiega w następujący sposób:

1. Miner Bob wchodzi do chaty i wysyła do Elsy wiadomość Msg_HiHoneyImHome, aby poinformować ją, że wrócił do domu.
2. Elsa otrzymuje wiadomość Msg_HiHoneyImHome, zatrzymuje to, co obecnie robi, i zmienia stan na CookStew.
3. Kiedy Elsa wchodzi w stan CookStew, wkłada gulasz do piecykaa i wysyła do niej opóźnioną wiadomość Msg_StewReady, aby przypomnieć sobie, że gulasz należy wyjąć z piekarnika w określonym czasie w przyszłości. (Zwykle gotowanie dobrego gulaszu zajmuje co najmniej godzinę, ale w cyberprzestrzeni Elsa potrafi szeleścić w zaledwie ułamek sekundy!)
4. Elsa odbiera komunikat Msg_StewReady. Odpowiada na tę wiadomość, wyjmując gulasz z piekarnika i wysyłając wiadomość do Minera Boba, aby poinformować go, że obiad jest na stole. Miner Bob odpowie na tę wiadomość tylko wtedy, gdy będzie w stanie GoHomeAndSleepTilRested (ponieważ w tym stanie zawsze znajduje się w chacie). Jeśli jest gdziekolwiek indziej, na przykład w kopalni złota lub w salonie, wiadomość ta zostanie wysłana i odrzucona.
5. Miner Bob otrzymuje komunikat Msg_StewReady i zmienia stan na EatStew.
Pozwól mi przejść przez kod, który wykonuje każdy z tych kroków.

Krok pierwszy

Górnik Bob wchodzi do chaty i wysyła do Elsy wiadomość Msg_HiHoneyImHome, aby poinformować ją, że wrócił do domu. Dodano dodatkowy kod do metody Enter stanu GoHomeAndSleepTilRested, aby ułatwić wysyłanie wiadomości do Elsy. Oto kod:

void GoHomeAndSleepTilRested::Enter(Miner* pMiner)
{
if (pMiner->Location() != shack)
{
cout << "\n" << GetNameOfEntity(pMiner->ID()) << ": "
<< "Walkin' home";
pMiner->ChangeLocation(shack);
// powiadom żonę, że jestem w domu
Dispatch->DispatchMessage(SEND_MSG_IMMEDIATELY, //time delay
pMiner->ID(), //ID of sender
ent_Elsa, //ID/name of recipient
Msg_HiHoneyImHome, //the message
NO_ADDITIONAL_INFO); //no extra info attached
}
}

Jak widać, kiedy Miner Bob przechodzi do tego stanu, pierwszą rzeczą, którą robi, jest zmiana lokalizacji. Następnie wysyła Msg_HiHoneyImHome do Elsy, wywołując metodę DispatchMessage klasy singletonowej MessageDispatcher. Ponieważ komunikat ma zostać wysłany natychmiast, pierwszy parametr DispatchMessage jest ustawiony na zero. Do telegramu nie są dołączane żadne dodatkowe informacje. (Stałe SEND_MSG_IMMEDIATELY i NO_ADDITIONAL_INFO są zdefiniowane z wartością 0 w pliku MessageDispatcher.h, aby zwiększyć czytelność).

PORADA Nie musisz ograniczać systemu przesyłania wiadomości do postaci w grze, takich jak orki, łucznicy i czarodzieje. Pod warunkiem, że obiekt pochodzi z klasy, która wymusza unikalny identyfikator (np. BaseGameEntity), możliwe jest wysyłanie do niego wiadomości. Przedmioty takie jak skrzynie skarbów, pułapki, magiczne drzwi, a nawet drzewa są przedmiotami, które mogą korzystać z możliwości odbierania i przetwarzania wiadomości. Na przykład można wyprowadzić klasę OakTree z klasy BaseGameEntity i zaimplementować funkcję obsługi komunikatów, aby reagować na komunikaty takie jak HitWithAxe lub StormyWeather. Dąb może wówczas reagować na te wiadomości, przewracając się lub szeleszcząc liśćmi i skrzypiąc. Możliwości, jakie możesz stworzyć za pomocą tego rodzaju systemu przesyłania wiadomości, są prawie nieograniczone.

Krok drugi

Elsa otrzymuje wiadomość Msg_HiHoneyImHome, zatrzymuje to, co obecnie robi, i zmienia stan na CookStew. Ponieważ nigdy nie opuszcza baraku, Elsa powinna odpowiedzieć na Msg_HiHoneyImHome w dowolnym stanie. Najłatwiejszym sposobem na wdrożenie tego jest pozwolenie, by jej globalny stan zajął się tym przesłaniem. (Pamiętaj, że stan globalny jest wykonywany przy każdej aktualizacji wraz z bieżącym stanem).

bool WifesGlobalState::OnMessage(MinersWife* wife, const Telegram& msg)
{
switch(msg.Msg)
{
case Msg_HiHoneyImHome:
{
cout << "\n Wiadomość obsługiwana przez " << GetNameOfEntity(wife->ID())
<< " at time: " << Clock->GetCurrentTime();
cout << "\n" << GetNameOfEntity(wife->ID()) <<
": Cześć kochanie. Pozwól, że przygotuję ci trochę dobrego wiejskiego gulaszu ";
wife->GetFSM()->ChangeState(CookStew::Instance());
}
return true;
}//end switch
return false;
}

Krok trzeci

Kiedy Elsa wchodzi w stan CookStew, wkłada gulasz do piekarnika i wysyła do niej wiadomość opóźnienia Msg_StewReady, aby przypomnieć sobie o wyjęciu gulaszu, zanim się wypali i zdenerwuje Boba. Jest to demonstracja wykorzystania wiadomości opóźnienia. W tym przykładzie Elsa wkłada gulasz do piekarnika, a następnie wysyła do niej opóźnioną wiadomość jako przypomnienie o wyjęciu gulaszu. Jak omówiliśmy wcześniej, wiadomość ta zostanie opatrzona odpowiednim czasem wysyłki i zapisana w kolejce priorytetowej. Za każdym razem w pętli gry następuje wywołanie MessageDispatcher :: DispatchDelayedMessages. Ta metoda sprawdza, czy jakieś telegramy przekroczyły znacznik czasu, i w razie potrzeby wysyła je do odpowiednich odbiorców.

void CookStew::Enter(MinersWife* wife)
{
// jeśli jeszcze nie gotujesz, włóż gulasz do piekarnika
if (!wife->Cooking())
{
cout << "\n" << GetNameOfEntity(wife->ID())
<< ": Puttin' the stew in the oven";
// wysłać do mnie opóźnioną wiadomość, aby wiedzieć, kiedy wziąć gulasz
// z piekarnika
Dispatch->DispatchMessage(1.5, //time delay
wife->ID(), //sender ID
wife->ID(), //receiver ID
Msg_StewReady, //the message
NO_ADDITIONAL_INFO); //no extra info attached
wife->SetCooking(true); }
}

Krok czwarty

Elsa otrzymuje wiadomość Msg_StewReady. Odpowiada, wyjmując gulasz z piekarnika i wysyłając wiadomość do Minera Boba, aby poinformować go, że obiad jest na stole. Miner Bob odpowie na tę wiadomość tylko wtedy, gdy będzie w stanie GoHomeAndSleepTilRested (aby upewnić się, że znajduje się w chacie). Ponieważ Miner Bob nie ma bionicznych uszu, będzie mógł usłyszeć, jak Elsa wzywa go na obiad, jeśli jest w domu. Dlatego Bob odpowie na tę wiadomość tylko wtedy, gdy będzie w stanie GoHomeAndSleepTilRested.

bool CookStew::OnMessage(MinersWife* wife, const Telegram& msg)
{
switch(msg.Msg)
{
case Msg_StewReady:
{
cout << "\n Wiadomość otrzymana przez " << GetNameOfEntity(wife->ID()) <<
" at time: " << Clock->GetCurrentTime();
cout << "\n" << GetNameOfEntity(wife->ID())
<< ": Gulasz gotowy! Jedzmy ";
// daj mężowi do zrozumienia, że gulasz jest gotowy
Dispatch->DispatchMessage(SEND_MSG_IMMEDIATELY,
wife->ID(),
ent_Miner_Bob,
Msg_StewReady,
NO_ADDITIONAL_INFO);
wife->SetCooking(false);
wife->GetFSM()->ChangeState(DoHouseWork::Instance());
}
return true;
}//end switch
return false;
}

Krok piąty

Miner Bob otrzymuje komunikat Msg_StewReady i zmienia stan na EatStew. Kiedy Miner Bob otrzymuje Msg_StewReady, zatrzymuje wszystko, co robi, zmienia stan na EatStew i siada przy stole, gotów zjeść potężną porcję i napełnia miskę gulaszem.

bool GoHomeAndSleepTilRested::OnMessage(Miner* pMiner, const Telegram& msg)
{
switch(msg.Msg)
{
case Msg_StewReady:
cout << "\n Wiadomość obsługiwana przez y " << GetNameOfEntity(pMiner->ID())
<< " at time: " << Clock->GetCurrentTime();
cout << "\n" << GetNameOfEntity(pMiner->ID())
<< ": Dobra, chodź, nadchodzi! '!";
pMiner->GetFSM()->ChangeState(EatStew::Instance());
return true;
}//end switch
return false; //send message to global message handler
}