Handlingslistan Datastruktur Bra för användargränssnitt, AI, animationer och mer

De åtgärdslista är en enkel datastruktur som är användbar för många olika uppgifter inom en spelmotor. Man kan hävda att handlingslistan alltid ska användas istället för någon form av statlig maskin.

Den vanligaste formen (och enklaste formen) av beteendeorganisationen är en ändlig statlig maskin. Vanligtvis implementeras med växlar eller arrays i C eller C ++, eller slews of om och annan uttalanden på andra språk, statliga maskiner är styva och oflexibla. Handlingslistan är ett starkare organisationssystem genom att det på ett tydligt sätt modellerar hur saker och ting normalt sker i verkligheten. Av denna anledning är handlingslistan mer intuitiv och flexibel än en ändlig statlig maskin.


snabb överblick

Handlingslistan är bara ett organisationsschema för begreppet a tidsinriktad åtgärd. Åtgärder lagras i en första i första ut (FIFO) beställning. Det innebär att när en åtgärd sätts in i en åtgärdslista kommer den sista åtgärden som sätts in i fronten att vara den första som ska tas bort. Åtgärdslistan följer inte explicit FIFO-formatet, men är kärnan de är desamma.

Varje spelslinga är aktionslistan uppdaterad och varje åtgärd i listan uppdateras i ordning. När en åtgärd är klar tas den bort från listan.

En verkan är någon form av funktion att ringa vilket gör någon form av arbete på något sätt. Här är några olika typer av områden och det arbete som åtgärder kan utföra inom dem:

  • Användargränssnitt: Visar korta sekvenser som "prestationer", spelar sekvenser av animeringar, bläddrar genom Windows, visar dynamiskt innehåll: Flytta; rotera; flip; blekna; allmän tweening.
  • Konstgjord intelligens: Köttbeteende: Flytta; vänta; patrullera; fly; ge sig på.
  • Nivålogik eller beteende: Flytta plattformar; hinder rörelser; skiftande nivåer.
  • Animation / Ljud: Spela; sluta.

Lågnivå saker som sökväg eller flockning representeras inte effektivt av en åtgärdslista. Bekämpning och andra högspecialiserade spelspecifika spelområden är också saker som man förmodligen inte bör genomföra via en handlingslista.


Handlingsklass

Här är en snabb titt på vad som borde ligga i aktionslistans datastruktur. Observera att mer specifika detaljer följer senare i artikeln.

 klass ActionList public: void Update (float dt); void PushFront (Action * action); Void PushBack (Action * Action); void InsertBefore (Action * action); void InsertAfter (Action * action); Åtgärd * Ta bort (Åtgärd * åtgärd); Åtgärd * Börja (tomrum); Åtgärd * Slut (tomrum); bool IsEmpty (void) const; flyta TimeLeft (void) const; bool IsBlocking (void) const; privat: float duration; float timeElapsed; float percentDone; bool blockering; osignerade banor; Åtgärd ** åtgärder // kan vara en vektor eller länkad lista;

Det är viktigt att notera att den faktiska lagringen av varje åtgärd inte behöver vara en faktisk länkad lista - något som C++ std :: vektor skulle fungera bra bra. Min egen preferens är att samla alla handlingar inuti en tilldelare och länklistor tillsammans med påträngande länkar. Vanligtvis används åtgärdslistor i mindre prestanda-känsliga områden, så stor datainriktad optimering kommer sannolikt att vara onödig när man utvecklar en aktionslista datastruktur.


Åtgärden

Kärnan i denna hela shebang är själva handlingarna. Varje åtgärd borde vara helt självständig så att åtgärdslistan själv inte vet någonting om handlingens inre. Detta gör handlingslistan ett extremt flexibelt verktyg. En åtgärdslista bryr sig inte om det går att använda gränssnittsåtgärder eller hantera rörelserna i ett 3D-modellerat tecken.

Ett bra sätt att genomföra åtgärder är genom ett abstrakt gränssnitt. Några specifika funktioner exponeras från åtgärdsobjektet till handlingslistan. Här är ett exempel på hur en basåtgärd kan se ut:

 klass Action public: virtuell uppdatering (float dt); virtuell OnStart (void); virtuell onEnd (void); bool isFinished; bool isBlocking; osignerade banor; float förflutit; float duration; privat: ActionList * ownerList; ;

De ONSTART () och I sträck() funktionerna är integrerade här. Dessa två funktioner ska utföras när en åtgärd sätts in i en lista och när åtgärden avslutas. Dessa funktioner gör att handlingar kan vara helt självständiga.

Blockering och icke-blockerande åtgärder

En viktig förlängning till åtgärdslistan är möjligheten att beteckna åtgärder som antingen blockering och icke-blockerande. Skillnaden är enkel: En blockerande åtgärd slutar handlingslistans uppdateringsrutin och inga ytterligare åtgärder uppdateras. En icke-blockerande åtgärd möjliggör att den efterföljande åtgärden uppdateras.

Ett enda booleskt värde kan användas för att avgöra om en åtgärd blockerar eller blockerar. Här är en del psuedocode som visar en handlingslista uppdatering rutin:

 void ActionList :: Update (float dt) int i = 0; medan (i! = numActions) Action * action = actions + i; action-> Uppdatering (dt); om (action-> isBlocking) brytning; om (action-> isFinished) action-> OnEnd (); action = this-> Ta bort (åtgärd);  ++ i; 

Ett bra exempel på användningen av icke-blockerande åtgärder skulle vara att låta vissa beteenden alla springa samtidigt. Till exempel, om vi har en kö av åtgärder för att springa och vinka händer, borde karaktären som utför dessa åtgärder kunna göra både på en gång. Om en fiende löper från karaktären skulle det vara väldigt goofigt om det var tvungen att springa, stanna sedan och vinka händerna frantiskt och fortsätt sedan springa.

Som det visar sig matchar konceptet blockering och non-blocking actions intuitivt de flesta typer av enkla beteenden som krävs för att implementeras inom ett spel.


Exempel på fall

Låter ett exempel på vad som kör en åtgärdslista skulle se ut i ett verkligt scenario. Detta kommer att bidra till att utveckla intuition om hur man använder en åtgärdslista, och varför handlingslistor är användbara.

Problem

En fiende inom ett enkelt topp-down 2D-spel behöver patrullera fram och tillbaka. När denna fiende ligger inom spelarens räckvidd måste man kasta en bomb mot spelaren och pausa sin patrull. Det borde finnas en liten nedkylning efter en bomb kastas där fienden står helt stillastående. Om spelaren fortfarande är inom räckhåll bör en annan bomb följt av en kylning kastas. Om spelaren är utom räckhåll bör patrullen fortsätta exakt var den slutade.

Varje bombe ska flyta genom 2D-världen och följa lagarna i den kakelbaserade fysiken som implementeras inom spelet. Bomben väntar bara tills dess säkringstimern slutar och blåser sedan upp. Explosionen ska bestå av en animering, ett ljud och en borttagning av bombens kollisionsbox och visuell sprite.

Att bygga en statlig maskin för detta beteende kommer att vara möjligt och inte för svårt, men det tar lite tid. Övergångar från varje stat måste kodas för hand, och att spara tidigare tillstånd för att fortsätta senare kan orsaka huvudvärk.

Åtgärdslösning

Lyckligtvis är detta ett idealiskt problem att lösa med åtgärdslistor. Låt oss först se en tom åtgärdslista. Denna tomma handlingslista kommer att representera en lista över "att göra" saker för fienden att slutföra; en tom lista anger en inaktiv fiende.

Det är viktigt att tänka på hur man "avdelar" önskat beteende i små nuggets. Det första att göra är att få ner patrullbeteenden. Låt oss anta att fienden ska patrullera förbi avstånd, patrullera sedan rätt av samma avstånd och upprepa.

Här är vad patruljera till vänster åtgärden kan se ut:

 klass PatrolLeft: public Action virtual Update (float dt) // Flytta fienden vänster fiende-> position.MoveLeft (); // Timer tills åtgärdsförloppet har förflutit + = dt; om (förflutit> = varaktighet) isFinished = true;  virtuell OnStart (void); // gör ingenting virtuellt OnEnd (void) // Sätt in en ny åtgärd i listan listan-> Infoga (nya PatrolRight ());  bool isFinished = false; bool isBlocking = true; Fiende * fiende; float duration = 10; // sekunder till slutet float förflutit = 0; // sekunder;

PatrolRight kommer att se nästan identiskt ut, med anvisningarna vända. När en av dessa handlingar placeras i fiendens handlingslista, kommer fienden verkligen att patrullera åt vänster och höger oändligt.

Här är ett kort diagram som visar flödet av en åtgärdslista med fyra snapshots av tillståndet för den aktuella handlingslistan för patrullering:

Nästa tillägg bör vara upptäckt när spelaren är i närheten. Detta kan göras med en icke-blockerande åtgärd som aldrig slutar. Denna åtgärd skulle kontrollera om spelaren är nära fienden, och om så kommer att skapa en ny åtgärd som heter ThrowBomb direkt framför sig i handlingslistan. Det kommer också att placera en Fördröjning åtgärder strax efter ThrowBomb verkan.

Den icke-blockerande åtgärden kommer att sitta där och uppdateras, men åtgärdslistan fortsätter att uppdatera alla efterföljande åtgärder utanför den. Blockera åtgärder (t.ex. Patrullera) kommer att uppdateras och åtgärdslistan upphör att uppdatera några efterföljande åtgärder. Kom ihåg, den här åtgärden är här för att se om spelaren är inom räckvidd och lämnar aldrig handlingslistan!

Här är vad den här åtgärden kan se ut:

 class DetectPlayer: public Action virtuell uppdatering (float dt) // Kasta en bomb och pausa om spelaren är i närheten om (PlayerNearby ()) this-> InsertInFrontOfMe (new ThrowBomb ()); // Paus i 2 sekunder här-> InsertInFrontOfMe (ny paus (2.0));  virtuell OnStart (void); // gör ingenting virtuellt OnEnd (void) // gör ingenting bool isFinished = false; bool isBlocking = false; ;

De ThrowBomb åtgärd kommer att bli en blockering som slår en bomb mot spelaren. Det borde troligen följas av a ThrowBombAnimation, som blockerar och spelar en fiendens animering, men jag har lämnat det här för kortfattadhet. Pausen bakom bomben kommer att ske för animationen och vänta lite innan färdigställandet.

Låt oss ta en titt på ett diagram över vad denna åtgärdslista kan se ut när du uppdaterar:


Blå cirklar blockerar åtgärder. Vita cirklar är icke-blockerande åtgärder.

Själva bomben ska vara ett helt nytt spelobjekt och ha tre eller så handlingar i sin egen handlingslista. Den första åtgärden är en blockering Paus verkan. Följande bör vara en åtgärd för att spela en animering för en explosion. Bombspriten själv, tillsammans med kollisionsboxen, måste tas bort. Slutligen bör en explosionsljudseffekt spelas.

Sammantaget borde det finnas runt sex till tio olika typer av handlingar som alla används tillsammans för att bygga upp det nödvändiga beteendet. Det bästa med dessa åtgärder är att de kan vara återanvändas i uppförandet av vilken typ av fiende som helst, inte bara den som demonstrerats här.


Mer om åtgärder

Åtgärdsbanor

Varje aktionslista i sin nuvarande form har en enda körfält i vilka åtgärder kan existera En bana är en följd av åtgärder som ska uppdateras. En körfält kan antingen blockeras eller inte blockeras.

Det perfekta genomförandet av körfält använder sig av bitmasks. (För detaljer om vad en bitmask är, se Snabb Bitmask-How-To för programmerare och Wikipedia-sidan för en snabb introduktion.) Med ett enda 32-bitars heltal kan 32 olika banor byggas.

En åtgärd borde ha ett heltal för att representera alla de olika banor som den ligger på. Detta möjliggör för 32 olika banor att representera olika kategorier av åtgärder. Varje fil kan antingen blockeras eller inte blockeras under uppdateringsrutinen i listan själv.

Här är ett snabbt exempel på Uppdatering Metod för en aktionslista med bitmaskbanor:

 void ActionList :: Update (float dt) int i = 0; unsigned lanes = 0; medan (i! = numActions) Action * action = actions + i; om (banor och action-> banor) fortsätt; action-> Uppdatering (dt); om (action-> isBlocking) banor | = action-> banor; om (action-> isFinished) action-> OnEnd (); action = this-> Ta bort (åtgärd);  ++ i; 

Detta ger en ökad flexibilitet, eftersom en åtgärdslista nu kan köra 32 olika typer av åtgärder, där 32 olika åtgärdslistor i förväg skulle behövas för att uppnå samma sak.

Fördröjningsåtgärd

En åtgärd som inte gör annat än att fördröja alla åtgärder under en viss tid är en mycket användbar sak att ha. Tanken är att fördröja alla efterföljande åtgärder från att äga rum tills en timer har löpt ut.

Genomförandet av förseningsåtgärden är mycket enkelt:

 klassfördröjning: offentlig åtgärd public: void Update (float dt) elapsed + = dt; om (förflutit> varaktighet) isFinished = true; ;

Synkronisera åtgärd

En användbar typ av åtgärd är en som blockerar tills den är den första åtgärden i listan. Det här är användbart när ett par olika icke-blockerande åtgärder körs, men du är inte säker på vilken ordning de kommer att slutföra i synkronisera Åtgärd säkerställer att inga tidigare icke-blockerande åtgärder för närvarande körs innan de fortsätter.

Genomförandet av synkroniseringsåtgärden är så enkelt som man kan tänka sig:

 klass Sync: public Action public: void Update (float dt) om (ownerList-> Börja () == detta) isFinished = true; ;

Avancerade funktioner

Handlingslistan som hittills beskrivits är ett ganska kraftfullt verktyg. Det finns dock ett par tillägg som kan göras för att verkligen låta handlingslistan lysa. Dessa är lite avancerade och jag rekommenderar inte att implementera dem om du inte kan göra det utan för mycket besvär.

Messaging

Möjligheten att skicka ett meddelande direkt till en åtgärd, eller tillåta en åtgärd att skicka meddelanden till andra åtgärder och spelobjekt, är extremt användbar. Detta gör att åtgärderna kan vara utomordentligt flexibla. Ofta kan en aktionslista av denna kvalitet fungera som en "fattig mans skriptspråk".

Några mycket användbara meddelanden att skicka från en åtgärd kan innehålla följande: startade; slutade; paus; återupptas; avslutad; avbokad; blockerad. Den blockerade är intressant - när en ny åtgärd sätts i en lista kan den blockera andra åtgärder. Dessa andra åtgärder kommer att vilja veta om det, och möjligen låta andra abonnenter veta om evenemanget också.

Implementeringsinformationen för meddelanden är språkspecifik och ganska icke-trivial. Som sådan kommer detaljerna i genomförandet inte att diskuteras här, eftersom meddelandet inte är fokus för denna artikel.

Hierarkiska åtgärder

Det finns några olika sätt att representera åtgärdernas hierarkier. Ett sätt är att låta en åtgärdslista vara en handling inom en annan handlingslista. Detta möjliggör byggandet av åtgärdslistor för att förena stora grupper av åtgärder under en enda identifierare. Detta ökar användbarheten och gör det lättare att utveckla och felsöka en mer komplex åtgärdslista.

En annan metod är att ha handlingar vars enda syfte är att gissa andra handlingar strax före sig själv inom ägarhandlingslistan. Jag föredrar själv den här metoden till det ovan nämnda, men det kan vara lite svårare att genomföra.


Slutsats

Begreppet handlingslista och dess genomförande har diskuterats i detalj för att ge ett alternativ till styva ad hoc-statliga maskiner. Handlingslistan ger ett enkelt och flexibelt sätt att snabbt utveckla ett brett spektrum av dynamiska beteenden. Handlingslistan är en ideell datastruktur för spelprogrammering i allmänhet.