Il lista di azioni è una semplice struttura dati utile per molte attività diverse all'interno di un motore di gioco. Si potrebbe sostenere che l'elenco delle azioni dovrebbe sempre essere utilizzato al posto di una qualche forma di macchina di stato.
La forma più comune (e la forma più semplice) di organizzazione del comportamento è a macchina a stati finiti. Solitamente implementato con switch o matrici in C o C ++, o slews di Se
e altro
dichiarazioni in altre lingue, le macchine statali sono rigide e inflessibili. L'elenco delle azioni è uno schema organizzativo più forte in quanto modella in modo chiaro come le cose accadono di solito nella realtà. Per questo motivo, l'elenco delle azioni è più intuitivo e flessibile di una macchina a stati finiti.
L'elenco delle azioni è solo uno schema organizzativo per il concetto di a azione temporizzata. Le azioni sono memorizzate in un ordine FIFO (first in first out). Ciò significa che quando un'azione viene inserita in un elenco di azioni, l'ultima azione inserita in primo piano sarà la prima a essere rimossa. L'elenco delle azioni non segue esplicitamente il formato FIFO, ma al suo interno rimane lo stesso.
Ogni ciclo di gioco, l'elenco delle azioni è aggiornato e ogni azione nell'elenco viene aggiornata in ordine. Una volta che un'azione è finita, viene rimossa dall'elenco.
Un azione è una sorta di funzione da chiamare che in qualche modo funziona in qualche modo. Qui ci sono alcuni diversi tipi di aree e il lavoro che le azioni potrebbero svolgere al loro interno:
Cose di basso livello come la ricerca di percorsi o il floccaggio non sono effettivamente rappresentate con un elenco di azioni. Il combattimento e altre aree di gioco altamente specializzate specifiche del gioco sono anche cose che probabilmente non dovrebbero essere implementate tramite un elenco di azioni.
Ecco una rapida occhiata a cosa dovrebbe trovarsi all'interno della struttura dati dell'elenco azioni. Si prega di notare che più dettagli specifici seguiranno più avanti nell'articolo.
class ActionList public: void Update (float dt); void PushFront (azione * azione); invalido PushBack (azione * azione); void InsertBefore (Azione * azione); void InsertAfter (azione * azione); Azione * Rimuovi (Azione * azione); Azione * Inizia (vuoto); Azione * Fine (vuoto); bool IsEmpty (void) const; float TimeLeft (void) const; bool IsBlocking (void) const; privato: durata variabile; float timeElapsed; float percentDone; blocco del bool; corsie senza segno; Azione ** azioni; // può essere un vettore o una lista collegata;
È importante notare che la memorizzazione effettiva di ogni azione non deve essere una lista concatenata reale - qualcosa come la C++ std :: vector
funzionerebbe perfettamente La mia preferenza è di raggruppare tutte le azioni all'interno di un allocatore e collegare elenchi insieme a elenchi collegati in modo intrusivo. Solitamente gli elenchi di azioni vengono utilizzati in aree meno sensibili alle prestazioni, quindi un'intensa ottimizzazione basata sui dati non sarà probabilmente necessaria quando si sviluppa una struttura dati dell'elenco di azioni.
Il punto cruciale di questo intero shebang sono le azioni stesse. Ogni azione dovrebbe essere interamente autonoma in modo tale che la lista azioni non sappia nulla degli interni dell'azione. Questo rende l'elenco delle azioni uno strumento estremamente flessibile. Un elenco di azioni non si preoccuperà se sta eseguendo azioni dell'interfaccia utente o gestendo i movimenti di un personaggio modellato in 3D.
Un buon modo per implementare le azioni è attraverso una singola interfaccia astratta. Alcune funzioni specifiche vengono esposte dall'oggetto azione alla lista azioni. Ecco un esempio di come può essere un'azione di base:
class Action public: virtual Update (float dt); virtuale OnStart (vuoto); virtual OnEnd (void); bool isFinished; bool isBlocking; corsie senza segno; float trascorso; durata fluttuante; private: ActionList * ownerList; ;
Il OnStart ()
e Alla fine()
le funzioni sono integrali qui. Queste due funzioni devono essere eseguite ogni volta che un'azione viene inserita in un elenco e al termine dell'azione, rispettivamente. Queste funzioni consentono alle azioni di essere completamente autonome.
Un'estensione importante dell'elenco delle azioni è la capacità di indicare le azioni come entrambe blocco e non bloccante. La distinzione è semplice: un'azione di blocco termina la routine di aggiornamento dell'elenco di azioni e non vengono aggiornate ulteriori azioni; un'azione non bloccante consente di aggiornare l'azione successiva.
È possibile utilizzare un singolo valore booleano per determinare se un'azione è bloccante o non bloccante. Ecco alcuni psuedocode che dimostrano una lista di azioni aggiornare
routine:
void ActionList :: Update (float dt) int i = 0; while (i! = numActions) Azione * azione = azioni + i; azione-> Aggiorna (dt); se (azione-> isBlocking) si interrompe; if (action-> isFinished) action-> OnEnd (); action = this-> Remove (action); ++ i;
Un buon esempio dell'uso di azioni non di blocco sarebbe consentire a tutti i comportamenti di funzionare tutti allo stesso tempo. Ad esempio, se abbiamo una coda di azioni per eseguire e agitare le mani, il personaggio che esegue queste azioni dovrebbe essere in grado di fare entrambe le cose contemporaneamente. Se un nemico sta scappando dal personaggio sarebbe molto sciocco se dovesse correre, quindi fermarsi e agitare le mani freneticamente, quindi continuare a correre.
A quanto pare, il concetto di azioni di blocco e non bloccanti combacia intuitivamente con la maggior parte dei tipi di comportamenti semplici che devono essere implementati all'interno di un gioco.
Consente di fornire un esempio di come dovrebbe essere eseguito un elenco di azioni in uno scenario reale. Ciò contribuirà a sviluppare l'intuizione su come utilizzare un elenco di azioni e perché gli elenchi di azioni sono utili.
Un nemico all'interno di un semplice gioco 2D top-down deve pattugliare avanti e indietro. Ogni volta che questo nemico è nel raggio d'azione del giocatore, deve lanciare una bomba contro il giocatore e sospendere la sua pattuglia. Ci dovrebbe essere un piccolo cooldown dopo che una bomba è stata lanciata dove il nemico sta completamente fermo. Se il giocatore è ancora nel raggio d'azione, deve essere lanciata un'altra bomba seguita da un cooldown. Se il giocatore è fuori portata, la pattuglia dovrebbe continuare esattamente da dove era stata interrotta.
Ogni bomba dovrebbe fluttuare nel mondo 2D e rispettare le leggi della fisica basata sulle tessere implementate all'interno del gioco. La bomba attende solo fino al termine del suo timer di fusibile, quindi esplode. L'esplosione dovrebbe consistere in un'animazione, un suono e una rimozione della scatola di collisione della bomba e dello sprite visivo.
Costruire una macchina a stati per questo comportamento sarà possibile e non troppo difficile, ma ci vorrà del tempo. Le transizioni da ogni stato devono essere codificate a mano e salvare gli stati precedenti per continuare in seguito potrebbe causare mal di testa.
Fortunatamente questo è un problema ideale da risolvere con le liste di azioni. Per prima cosa, immaginiamo una lista di azioni vuota. Questa lista di azioni vuote rappresenta un elenco di elementi da "fare" per il nemico da completare; una lista vuota indica un nemico inattivo.
È importante pensare a come "compartimentare" il comportamento desiderato in piccole pepite. La prima cosa da fare sarebbe quella di abbattere i comportamenti di pattuglia. Supponiamo che il nemico debba pattugliare a distanza, quindi pattugliare alla stessa distanza e ripetere.
Ecco cosa pattuglia a sinistra l'azione potrebbe essere simile a:
class PatrolLeft: public Action virtual Update (float dt) // Sposta il nemico left enemy-> position.MoveLeft (); // Timer fino al termine del completamento dell'azione + = dt; se (trascorso> = durata) isFinished = true; virtuale OnStart (void); // non fare nulla di virtuale OnEnd (void) // Inserisci una nuova azione nella lista list-> Insert (new PatrolRight ()); bool isFinished = false; bool isBlocking = true; Nemico * nemico; durata flottante = 10; // secondi fino all'arrivo float trascorso = 0; // secondi;
PatrolRight
sembrerà quasi identico, con le direzioni capovolte. Quando una di queste azioni viene inserita nella lista delle azioni del nemico, il nemico effettuerà effettivamente la perlustrazione a sinistra e a destra all'infinito.
Ecco un breve diagramma che mostra il flusso di un elenco di azioni, con quattro istantanee dello stato dell'elenco di azioni corrente per il pattugliamento:
La prossima aggiunta dovrebbe essere il rilevamento di quando il giocatore si trova nelle vicinanze. Questo potrebbe essere fatto con un'azione non bloccante che non si completa mai. Questa azione controllerebbe se il giocatore è vicino al nemico, e in tal caso creerà una nuova azione chiamata ThrowBomb
direttamente di fronte a se stesso nella lista delle azioni. Metterà anche a Ritardo
azione subito dopo ThrowBomb
azione.
L'azione non bloccante sarà presente e verrà aggiornata, ma l'elenco delle azioni continuerà ad aggiornare tutte le azioni successive oltre. Azioni di blocco (come Pattuglia
) verrà aggiornato e l'elenco delle azioni cesserà di aggiornare eventuali azioni successive. Ricorda, questa azione è qui solo per vedere se il giocatore è nel raggio d'azione, e non lascerà mai l'elenco delle azioni!
Ecco come potrebbe essere questa azione:
class DetectPlayer: public Action virtual Update (float dt) // Getta una bomba e metti in pausa se il giocatore si trova nelle vicinanze se (PlayerNearby ()) this-> InsertInFrontOfMe (new ThrowBomb ()); // Metti in pausa per 2 secondi questo-> InsertInFrontOfMe (new Pause (2.0)); OnStart virtuale (vuoto); // non fare nulla di virtuale OnEnd (void) // non fare il bool isFinished = false; bool isBlocking = false; ;
Il ThrowBomb
l'azione sarà un'azione bloccante che lancia una bomba verso il giocatore. Dovrebbe probabilmente essere seguito da a ThrowBombAnimation
, che sta bloccando e gioca un'animazione nemica, ma l'ho lasciato per concisione. La pausa dietro la bomba avrà luogo dell'animazione e attenderà un po 'prima di finire.
Diamo un'occhiata a un diagramma di come potrebbe essere questo elenco azioni durante l'aggiornamento:
La stessa bomba dovrebbe essere un oggetto di gioco completamente nuovo e contenere tre o più azioni nella propria lista di azioni. La prima azione è un blocco Pausa
azione. Di seguito dovrebbe essere un'azione per riprodurre un'animazione per un'esplosione. Lo sprite della bomba, insieme alla scatola di collisione, dovrà essere rimosso. Infine, dovrebbe essere riprodotto un effetto sonoro esplosivo.
In tutto dovrebbero esserci da sei a dieci diversi tipi di azioni che vengono tutte utilizzate insieme per costruire il comportamento necessario. La parte migliore di queste azioni è che possono essere riutilizzato nel comportamento di qualsiasi tipo di nemico, non solo quello mostrato qui.
Ogni lista di azioni nella sua forma attuale ha un singolo corsia in cui possono esistere azioni. Una corsia è una sequenza di azioni da aggiornare. Una corsia può essere bloccata o non bloccata.
L'implementazione perfetta delle corsie fa uso di bitmasks. (Per i dettagli su cosa è una maschera di bit, si prega di vedere una Quick Bitmask How-To per i programmatori e la pagina di Wikipedia per una rapida introduzione.) Utilizzando un singolo numero intero a 32 bit, possono essere costruite 32 diverse corsie.
Un'azione dovrebbe avere un numero intero per rappresentare tutte le varie corsie su cui si trova. Ciò consente a 32 diverse corsie di rappresentare diverse categorie di azioni. Ogni corsia può essere bloccata o non bloccata durante la routine di aggiornamento della lista stessa.
Ecco un rapido esempio di Aggiornare
metodo di una lista di azioni con corsie bitmask:
void ActionList :: Update (float dt) int i = 0; corsie senza segno = 0; while (i! = numActions) Azione * azione = azioni + i; se (corsie e azioni-> corsie) continua; azione-> Aggiorna (dt); se (azione-> isBlocking) corsie | = azione-> corsie; if (action-> isFinished) action-> OnEnd (); action = this-> Remove (action); ++ i;
Ciò fornisce un livello elevato di flessibilità, poiché ora un elenco di azioni può eseguire 32 diversi tipi di azioni, dove in precedenza sarebbero necessari 32 diversi elenchi di azioni per ottenere la stessa cosa.
Un'azione che non fa altro che ritardare tutte le azioni per un determinato periodo di tempo è una cosa molto utile da avere. L'idea è di ritardare tutte le azioni successive da quando è trascorso un timer.
L'implementazione dell'azione di ritardo è molto semplice:
class Delay: public Action public: void Update (float dt) trascorso + = dt; se (trascorso> durata) isFinished = true; ;
Un tipo di azione utile è quello che blocca fino a quando non è la prima azione nell'elenco. Ciò è utile quando vengono eseguite alcune azioni diverse non bloccanti, ma non sei sicuro di quale ordine finiranno sincronizzare l'azione garantisce che nessuna azione precedente non bloccante sia attualmente in esecuzione prima di continuare.
L'implementazione dell'azione di sincronizzazione è semplice come si potrebbe immaginare:
class Sync: public Action public: void Update (float dt) if (ownerList-> Begin () == this) isFinished = true; ;
L'elenco delle azioni descritto finora è uno strumento piuttosto potente. Tuttavia ci sono un paio di aggiunte che possono essere fatte per far brillare la lista delle azioni. Questi sono un po 'avanzati e non consiglio di implementarli a meno che non si possa farlo senza troppi problemi.
La possibilità di inviare un messaggio direttamente a un'azione o di consentire un'azione di inviare messaggi ad altre azioni e oggetti di gioco, è estremamente utile. Ciò consente alle azioni di essere straordinariamente flessibili. Spesso un elenco di azioni di questa qualità può fungere da "linguaggio di scripting del povero uomo".
Alcuni messaggi molto utili da pubblicare da un'azione possono includere quanto segue: iniziato; conclusa; pausa; ripresa; completato; annullato; bloccata. Quello bloccato è piuttosto interessante: ogni volta che una nuova azione viene inserita in una lista, può bloccare altre azioni. Queste altre azioni vorrebbero sapere su di esso, e forse lasciare che anche altri abbonati sappiano dell'evento.
I dettagli di implementazione della messaggistica sono specifici della lingua e piuttosto non banali. In quanto tale, i dettagli dell'implementazione non saranno discussi qui, poiché la messaggistica non è al centro di questo articolo.
Esistono diversi modi per rappresentare le gerarchie delle azioni. Un modo è consentire a un elenco di azioni stesso di essere un'azione all'interno di un altro elenco di azioni. Ciò consente la costruzione di elenchi di azioni per raggruppare grandi gruppi di azioni con un unico identificatore. Ciò accresce l'usabilità e rende più facile sviluppare ed eseguire il debug di un elenco di azioni più complesso.
Un altro metodo consiste nell'avere azioni il cui unico scopo è generare altre azioni appena prima di se stesse all'interno dell'elenco di azioni proprietario. Io stesso preferisco questo metodo al suddetto, anche se potrebbe essere un po 'più difficile da implementare.
Il concetto di una lista di azioni e la sua implementazione sono stati discussi in dettaglio al fine di fornire un'alternativa alle rigide macchine di stato ad-hoc. L'elenco delle azioni fornisce un mezzo semplice e flessibile per sviluppare rapidamente un'ampia gamma di comportamenti dinamici. L'elenco delle azioni è una struttura dati ideale per la programmazione di giochi in generale.