De datalijst van de actielijst goed voor UI, AI, animaties en meer

De actie lijst is een eenvoudige gegevensstructuur die nuttig is voor veel verschillende taken binnen een game-engine. Men zou kunnen stellen dat de actielijst altijd moet worden gebruikt in plaats van een of andere staatsmachine.

De meest voorkomende vorm (en eenvoudigste vorm) van gedragsorganisatie is een eindigetoestandsautomaat. Meestal geïmplementeerd met switches of arrays in C of C ++, of slews van als en anders uitspraken in andere talen, state machines zijn rigide en inflexibel. De actielijst is een sterker organisatieschema, omdat het op een heldere manier modelleert hoe dingen meestal in de realiteit gebeuren. Om deze reden is de actielijst meer intuïtief en flexibel dan een eindige toestandsmachine.


Snel overzicht

De actielijst is slechts een organisatieschema voor het concept van a getimede actie. Acties worden opgeslagen in een eerste in first out (FIFO) volgorde. Dit betekent dat wanneer een actie in een actielijst wordt ingevoegd, de laatste actie die aan de voorzijde wordt ingevoegd de eerste actie is die wordt verwijderd. De actielijst volgt het FIFO-formaat niet expliciet, maar in de kern blijven ze hetzelfde.

Elke spellus, de actielijst is bijgewerkt en elke actie in de lijst wordt op volgorde bijgewerkt. Zodra een actie is voltooid, wordt deze uit de lijst verwijderd.

Een actie is een soort van functie die op de een of andere manier een soort van werk doet. Hier zijn een paar verschillende soorten gebieden en het werk dat acties daarin zouden kunnen uitvoeren:

  • Gebruikersinterface: korte reeksen weergeven zoals "prestaties", reeksen van animaties afspelen, door vensters bladeren, dynamische inhoud weergeven: verplaatsen; draaien; omdraaien; vervagen; algemene tweening.
  • Kunstmatige intelligentie: gedrag in de rij: verplaatsen; Wacht; patrouille; vluchten; aanval.
  • Niveaulogica of -gedrag: bewegende platforms; obstakel bewegingen; verschuivingsniveaus.
  • Animatie / Audio: spelen; hou op.

Dingen op een laag niveau, zoals padvinden of massaal adverteren, worden niet effectief weergegeven in een actielijst. Combat en andere zeer gespecialiseerde gamespecifieke spelsegmenten zijn ook zaken die je waarschijnlijk niet via een actielijst zou moeten implementeren.


Action List Class

Hier volgt een korte blik op wat er in de datastructuur van de actielijst zou moeten liggen. Houd er rekening mee dat meer specifieke details later in het artikel zullen volgen.

 class ActionList public: void Update (float dt); void PushFront (actie * actie); ongeldig PushBack (actie * actie); void InsertBefore (Actie * actie); void InsertAfter (Actie * actie); Actie * Verwijderen (actie * actie); Actie * Begin (ongeldig); Actie * Einde (ongeldig); bool IsEmpty (void) const; float TimeLeft (void) const; bool IsBlocking (void) const; privé: float-duur; float timeElapsed; float percentDone; bool-blokkering; niet-ondertekende rijstroken; Actie ** acties; // kan een vector of een gekoppelde lijst zijn;

Het is belangrijk op te merken dat de daadwerkelijke opslag van elke actie geen echte gekoppelde lijst hoeft te zijn - zoiets als de C++ std :: vector zou prima werken. Mijn eigen voorkeur is om alle acties binnen een allocator en koppelingslijsten samen te voegen met intrusief gekoppelde lijsten. Meestal worden actielijsten gebruikt in minder prestatiegevoelige gebieden, dus zware gegevensgeoriënteerde optimalisatie is waarschijnlijk niet nodig bij het ontwikkelen van een gegevenslijst voor actielijsten..


De actie

De crux van deze hele shebang is de acties zelf. Elke actie moet volledig op zichzelf staan, zodat de actielijst zelf niets weet over de interne onderdelen van de actie. Dit maakt de actielijst een extreem flexibel hulpmiddel. Een actielijst maakt het niet uit of het gebruikersinterfaceacties uitvoert of de bewegingen van een 3D-gemodelleerd teken beheert.

Een goede manier om acties te implementeren is via een enkele abstracte interface. Een paar specifieke functies worden weergegeven van het actieobject naar de actielijst. Hier is een voorbeeld van hoe een basisactie eruit kan zien:

 class Action public: virtual Update (float dt); virtuele OnStart (void); virtuele OnEnd (void); bool is voltooid; bool isBlocking; niet-ondertekende rijstroken; float is verstreken; zweefduur; privé: ActionList * ownerList; ;

De OnStart () en Eindeloos() functies zijn hier integraal. Deze twee functies moeten worden uitgevoerd wanneer een actie in een lijst wordt ingevoegd en wanneer de actie eindigt, respectievelijk. Met deze functies kunnen acties volledig onafhankelijk zijn.

Blokkerende en niet-blokkerende acties

Een belangrijke uitbreiding van de actielijst is de mogelijkheid om ook acties aan te geven blokkeren en non-blocking. Het onderscheid is eenvoudig: een blokkerende actie beëindigt de updateprocedure van de actielijst en geen verdere acties worden bijgewerkt; een niet-blokkerende actie zorgt ervoor dat de volgende actie kan worden bijgewerkt.

Een enkele Booleaanse waarde kan worden gebruikt om te bepalen of een actie blokkeert of niet-blokkeert. Hier is een aantal psuedocode die een actielijst demonstreert bijwerken routine:

 void ActionList :: Update (float dt) int i = 0; while (i! = numActions) Actie * actie = acties + i; actie-> Update (dt); if (action-> isBlocking) pauze; if (action-> is Finished) action-> OnEnd (); actie = dit-> Verwijderen (actie);  ++ i; 

Een goed voorbeeld van het gebruik van niet-blokkerende acties zou zijn om sommige gedragingen allemaal tegelijkertijd mogelijk te maken. Als we bijvoorbeeld een wachtrij hebben met acties voor zwaaiende en zwaaiende handen, zou het personage dat deze acties uitvoert beide tegelijk moeten kunnen doen. Als een vijand voor het personage wegrent, zou het erg malle zijn als het moest rennen, stop dan en zwaai met zijn handen verwoed, blijf dan rennen.

Het blijkt dat het concept van blokkerende en niet-blokkerende acties intuïtief overeenkomt met de meeste soorten eenvoudig gedrag die vereist zijn om te worden geïmplementeerd in een game.


Voorbeeld van een zaak

Laten we een voorbeeld bekijken van hoe een actielijst eruit zou zien in een real-world scenario. Dit helpt de intuïtie te ontwikkelen over het gebruik van een actielijst en waarom actielijsten nuttig zijn.

Probleem

Een vijand binnen een eenvoudig 2D-spel van bovenaf moet heen en weer patrouilleren. Telkens wanneer deze vijand zich binnen het bereik van de speler bevindt, moet hij een bom werpen naar de speler en de patrouille pauzeren. Er moet een kleine cooldown zijn nadat een bom is gegooid waar de vijand volledig stil staat. Als de speler nog steeds binnen bereik is, moet nog een bom gevolgd worden door een cooldown. Als de speler buiten bereik is, moet de patrouille doorgaan waar hij gebleven was.

Elke bom moet door de 2D-wereld zweven en zich houden aan de wetten van de op tegels gebaseerde natuurkunde die in het spel is geïmplementeerd. De bom wacht gewoon totdat de smelttimer is afgelopen en blaast dan op. De explosie moet bestaan ​​uit een animatie, een geluid en een verwijdering van de aanvalsbox en visuele sprite van de bom.

Het bouwen van een staatsmachine voor dit gedrag is mogelijk en niet te moeilijk, maar het zal enige tijd duren. Overgangen van elke staat moeten met de hand worden gecodeerd en het opslaan van eerdere toestanden om later door te gaan kan hoofdpijn veroorzaken.

Action List-oplossing

Gelukkig is dit een ideaal probleem om op te lossen met actielijsten. Laten we eerst een lege actielijst voorstellen. Deze lege actielijst vertegenwoordigt een lijst met 'te doen'-items die de vijand moet voltooien; een lege lijst geeft een inactieve vijand aan.

Het is belangrijk om na te denken over hoe je het gewenste gedrag kunt "compartimenteren" in kleine klompjes. Het eerste dat je zou moeten doen, is patrouillegedrag te beëindigen. Laten we aannemen dat de vijand over een afstand moet patrouilleren, dan op dezelfde afstand patrouilleren en herhalen.

Dit is wat de patrouille verlaten actie kan er als volgt uitzien:

 class PatrolLeft: public Action virtual Update (float dt) // Verplaats de vijand links vijand-> positie.MoveLeft (); // Timer totdat actie voltooid is + = dt; if (verstreken> = duur) is Voltooid = waar;  virtuele OnStart (void); // doe niets virtueel OnEnd (void) // Voeg een nieuwe actie toe aan de lijst met lijst-> Insert (nieuwe PatrolRight ());  bool isFinished = false; bool isBlocking = true; Vijand * vijand; zweefduur = 10; // seconden tot finish float verstreken = 0; // seconden;

PatrolRight ziet er bijna identiek uit, met de aanwijzingen omgedraaid. Wanneer een van deze acties in de actielijst van de vijand wordt geplaatst, zal de vijand inderdaad oneindig rechts en rechts patrouilleren.

Hier is een kort diagram dat de stroom van een actielijst toont, met vier snapshots van de status van de huidige actielijst voor patrouilleren:

De volgende toevoeging zou de detectie moeten zijn van wanneer de speler in de buurt is. Dit kan worden gedaan met een niet-blokkerende actie die nooit wordt voltooid. Deze actie zou controleren om te zien of de speler in de buurt van de vijand is, en zo ja, een nieuwe actie creëren genaamd ThrowBomb direct voor zich in de actielijst. Het zal ook een plaatsen Vertraging actie direct na de ThrowBomb actie.

De niet-blokkerende actie blijft daar staan ​​en wordt bijgewerkt, maar de actielijst blijft alle volgende acties die daarbuiten volgen updaten. Blokkerende acties (zoals Patrouille) wordt bijgewerkt en de actielijst stopt met het bijwerken van alle volgende acties. Vergeet niet dat deze actie hier alleen is om te zien of de speler binnen bereik is en nooit de actielijst zal verlaten!

Hier is wat deze actie zou kunnen zien als:

 class DetectPlayer: public Action virtual Update (float dt) // Gooi een bom en pauzeer als speler in de buurt is als (PlayerNearby ()) this-> InsertInFrontOfMe (new ThrowBomb ()); // Pauzeer gedurende 2 seconden this-> InsertInFrontOfMe (new Pause (2.0));  virtuele OnStart (void); // doe niets virtueel OnEnd (void) // doe niets bool isFinished = false; bool isBlocking = false; ;

De ThrowBomb actie zal een blokkerende actie zijn die een bom gooit naar de speler. Het moet waarschijnlijk worden gevolgd door een ThrowBombAnimation, die blokkeert en een vijandelijke animatie speelt, maar ik heb dit achtergelaten voor beknoptheid. De pauze achter de bom vindt plaats van de animatie en wacht even voordat deze wordt voltooid.

Laten we eens kijken naar een schema van hoe deze actielijst er kan uitzien tijdens het updaten:


Blauwe cirkels blokkeren acties. Witte cirkels zijn niet-blokkerende acties.

De bom zelf zou een volledig nieuw spelobject moeten zijn en drie of zo acties in zijn eigen actielijst hebben. De eerste actie is een blokkering Pauze actie. Hierna zou het een actie moeten zijn om een ​​animatie voor een explosie te spelen. De bomsprite zelf, samen met de aanvaringsdoos, moet worden verwijderd. Ten slotte moet een explosie geluidseffect worden gespeeld.

In totaal moeten er ongeveer zes tot tien verschillende soorten acties zijn die allemaal samen worden gebruikt om het benodigde gedrag te construeren. Het beste deel over deze acties is dat ze kunnen zijn hergebruikt in het gedrag van een vijandig type, niet alleen degene die hier wordt getoond.


Meer over acties

Actiestroken

Elke actielijst in zijn huidige vorm heeft een single rijbaan waarin acties kunnen bestaan. Een rijstrook is een reeks acties die moet worden bijgewerkt. Een rijstrook kan worden geblokkeerd of niet worden geblokkeerd.

De perfecte implementatie van rijstroken maakt gebruik van bitmasks. (Zie A Quick Bitmask How-To voor programmeurs en de Wikipedia-pagina voor een korte introductie.) Met behulp van een enkel 32-bits geheel getal kunnen 32 verschillende rijstroken worden geconstrueerd (voor details over wat een bitmasker is)..

Een actie moet een geheel getal hebben om alle verschillende rijstroken weer te geven waarop het zich bevindt. Dit laat 32 verschillende rijstroken toe om verschillende categorieën van acties weer te geven. Elke rijstrook kan tijdens de updateprocedure van de lijst zelf worden geblokkeerd of niet worden geblokkeerd.

Hier is een snel voorbeeld van de Bijwerken methode van een actielijst met bitmaskerbanen:

 void ActionList :: Update (float dt) int i = 0; niet-ondertekende rijstroken = 0; while (i! = numActions) Actie * actie = acties + i; als (rijstroken & actie-> rijstroken) doorgaan; actie-> Update (dt); if (action-> isBlocking) rijstroken | = actie-> rijstroken; if (action-> is Finished) action-> OnEnd (); actie = dit-> Verwijderen (actie);  ++ i; 

Dit biedt een verhoogde mate van flexibiliteit, omdat nu een actielijst 32 verschillende soorten acties kan uitvoeren, waarbij van tevoren 32 verschillende actielijsten nodig zouden zijn om hetzelfde te bereiken.

Vertraag actie

Een actie die niets anders doet dan alle acties gedurende een bepaalde tijd uitstellen, is heel nuttig. Het idee is om alle volgende acties uit te stellen totdat een timer is verstreken.

De implementatie van de vertragingsactie is heel eenvoudig:

 class Delay: public Action public: void Update (float dt) verstreken + = dt; if (verstreken> duur) is voltooid = true; ;

Actie synchroniseren

Een handig type actie is een actie die blokkeert totdat het de eerste actie in de lijst is. Dit is handig als een paar verschillende niet-blokkerende acties worden uitgevoerd, maar je weet niet zeker in welke volgorde ze eindigen synchroniseren actie zorgt ervoor dat er geen eerdere niet-blokkerende acties worden uitgevoerd voordat u doorgaat.

De implementatie van de synchronisatie-actie is zo eenvoudig als men zou denken:

 class Sync: public Action public: void Update (float dt) if (ownerList-> Begin () == this) is Finished = true; ;

Geavanceerde functies

De tot nu toe beschreven actielijst is een vrij krachtig hulpmiddel. Er zijn echter een paar toevoegingen die kunnen worden aangebracht om de actielijst echt te laten schijnen. Deze zijn een beetje geavanceerd en ik raad ze af om ze te implementeren, tenzij je dit zonder al te veel moeite kunt doen.

messaging

De mogelijkheid om een ​​bericht rechtstreeks naar een actie te sturen of een actie toe te staan ​​om berichten naar andere acties en game-objecten te verzenden, is uiterst nuttig. Hierdoor kunnen acties buitengewoon flexibel zijn. Vaak kan een actielijst van deze kwaliteit fungeren als een 'arme scripttaal'.

Enkele zeer nuttige berichten om berichten van een actie te plaatsen kunnen het volgende bevatten: gestart; eindigde; gepauzeerd; hervat; voltooid; geannuleerd; geblokkeerd. De geblokkeerde is best interessant - wanneer een nieuwe actie in een lijst wordt geplaatst, kan deze andere acties blokkeren. Deze andere acties zullen het willen weten, en mogelijk ook andere abonnees over het evenement informeren.

De implementatiedetails van berichtenverkeer zijn taalspecifiek en eerder niet-triviaal. Als zodanig worden de details van de implementatie hier niet besproken, omdat messaging niet centraal staat in dit artikel.

Hiërarchische acties

Er zijn een paar verschillende manieren om hiërarchieën van acties weer te geven. Eén manier is om een ​​actielijst zelf een actie te laten zijn binnen een andere actielijst. Dit maakt het opstellen van actielijsten mogelijk om grote groepen acties samen te verpakken onder een enkele identificator. Dit verhoogt de bruikbaarheid en maakt een complexere actielijst eenvoudiger te ontwikkelen en te debuggen.

Een andere methode is om acties te hebben waarvan het enige doel is om andere acties vlak vóór zichzelf uit te zetten binnen de eigen actielijst. Zelf geef ik de voorkeur aan deze methode boven het bovengenoemde, hoewel het misschien een beetje moeilijker is om het te implementeren.


Conclusie

Het concept van een actielijst en de implementatie ervan zijn in detail besproken om een ​​alternatief te bieden voor starre ad-hoc staatsmachines. De actielijst biedt een eenvoudige en flexibele manier om snel een breed scala aan dynamisch gedrag te ontwikkelen. De actielijst is een ideale datastructuur voor het programmeren van spellen in het algemeen.