Organizzare il proprio codice di gioco in entità basate sui componenti, piuttosto che affidarsi solo all'ereditarietà delle classi, è un approccio popolare nello sviluppo del gioco. In questo tutorial, vedremo perché potresti farlo e impostare un semplice motore di gioco usando questa tecnica.
In questo tutorial ho intenzione di esplorare entità di gioco basate su componenti, esaminare il motivo per cui potresti volere usarle e suggerire un approccio pragmatico per immergere le dita in acqua.
Poiché si tratta di una storia sull'organizzazione del codice e l'architettura, inizierò con il solito disclaimer "uscire di prigione": questo è solo un modo di fare le cose, non è "la strada" o forse anche il modo migliore, ma potrebbe funzionare per te. Personalmente, mi piace scoprire il maggior numero di approcci possibili e quindi capire cosa mi va bene.
In questo tutorial in due parti, creeremo questo gioco Asteroids. (Il codice sorgente completo è disponibile su GitHub.) In questa prima parte, ci concentreremo sui concetti di base e sul motore di gioco generale.
In un gioco come Asteroids, potremmo avere alcuni tipi fondamentali di "cose" sullo schermo: proiettili, asteroidi, navi dei giocatori e navi nemiche. Potremmo voler rappresentare questi tipi di base come quattro classi separate, ognuna contenente tutto il codice di cui abbiamo bisogno per disegnare, animare, spostare e controllare quell'oggetto.
Mentre questo funzionerà, potrebbe essere meglio seguire il Non ripetere te stesso (ASCIUTTO) principio e provare a riutilizzare parte del codice tra ogni classe - dopo tutto, il codice per lo spostamento e il disegno di un proiettile sarà molto simile, se non esattamente lo stesso, al codice da spostare e disegnare un asteroide o una nave.
Quindi possiamo refactoring le nostre funzioni di rendering e movimento in una classe base da cui tutto si estende. Ma Nave
e EnemyShip
anche bisogno di essere in grado di sparare. A questo punto potremmo aggiungere il sparare
funzione alla classe base, creando una classe "Giant Blob" che può fare praticamente tutto e assicurarsi che asteroidi e punti elenco non chiamino mai sparare
funzione. Questa classe base diventerebbe presto molto grande, con dimensioni crescenti ogni volta che le entità devono essere in grado di fare nuove cose. Questo non è necessariamente sbagliato, ma trovo che classi più piccole e più specializzate siano più facili da mantenere.
In alternativa, possiamo scendere alla radice dell'ereditarietà profonda e avere qualcosa di simile EnemyShip estende la nave estende ShootingEntity estende Entity
. Ancora una volta questo approccio non è sbagliato, e funzionerà anche abbastanza bene, ma man mano che aggiungi altri tipi di Entità, ti troverai costantemente a dover regolare la gerarchia dell'ereditarietà per gestire tutti gli scenari possibili, e puoi boxare in un angolo dove un nuovo tipo di Entità deve avere la funzionalità di due diverse classi base, che richiedono l'ereditarietà multipla (che la maggior parte dei linguaggi di programmazione non offre).
Ho usato l'approccio della gerarchia profonda molte volte, ma in realtà preferisco l'approccio Giant Blob, poiché almeno tutte le entità hanno un'interfaccia comune e nuove entità possono essere aggiunte più facilmente (quindi cosa succede se tutti gli alberi hanno un pathfinder A *? !)
C'è, tuttavia, una terza via ...
Se pensiamo al problema di Asteroids in termini di cose che gli oggetti potrebbero aver bisogno di fare, potremmo ottenere una lista come questa:
mossa()
sparare()
takeDamage ()
morire()
render ()
Invece di elaborare una complicata gerarchia di ereditarietà per la quale gli oggetti possono fare quali cose, modelliamo il problema in termini di componenti che possono eseguire queste azioni.
Ad esempio, potremmo creare un Salute
classe, con i metodi takeDamage ()
, guarire()
e morire()
. Quindi qualsiasi oggetto che deve essere in grado di subire danni e morire può "comporre" un'istanza del Salute
class - dove "compose" significa fondamentalmente "mantenere un riferimento alla propria istanza di questa classe".
Potremmo creare un'altra classe chiamata vista
per occuparsi della funzionalità di rendering, una chiamata Corpo
per gestire il movimento e uno chiamato Arma
per gestire le riprese.
La maggior parte dei sistemi Entity si basa sul principio sopra descritto, ma si differenziano per il modo in cui si accede alle funzionalità contenute in un componente.
Ad esempio, un approccio consiste nel rispecchiare l'API di ciascun componente nell'entità, quindi un'entità che può subire un danno avrebbe a takeDamage ()
funziona così si chiama semplicemente il takeDamage ()
sua funzione Salute
componente.
classe Entity private var _health: Health; // ... altro codice ... // public function takeDamage (dmg: int) _health.takeDamage (dmg);
Devi quindi creare un'interfaccia chiamata qualcosa di simile iHealth
per l'implementazione della tua entità, in modo che altri oggetti possano accedere a takeDamage ()
funzione. Ecco come una guida OOP di Java potrebbe consigliarti di farlo.
getComponent ()
Un altro approccio consiste semplicemente nel memorizzare ciascun componente in una ricerca di valori-chiave, in modo che ogni Entità abbia una funzione chiamata qualcosa di simile getComponent ( "componentName")
che restituisce un riferimento al particolare componente. È quindi necessario eseguire il cast del riferimento per tornare al tipo di componente desiderato, ad esempio:
var health: Health = Health (getComponent ("Health"));
Questo è fondamentalmente il funzionamento del sistema di entità / comportamento di Unity. È molto flessibile, perché puoi continuare ad aggiungere nuovi tipi di componenti senza modificare la tua classe base o creare nuove sottoclassi o interfacce. Potrebbe anche essere utile quando si desidera utilizzare i file di configurazione per creare entità senza ricompilare il codice, ma lo lascerò a qualcun altro per capire.
L'approccio che preferisco è lasciare che tutte le entità abbiano una proprietà pubblica per ciascun tipo di componente principale e lasciare i campi null se l'entità non ha quella funzionalità. Quando si desidera chiamare un metodo particolare, basta "raggiungere" l'entità per ottenere il componente con quella funzionalità, ad esempio, chiama enemy.health.takeDamage (5)
attaccare un nemico.
Se provi a chiamare health.takeDamage ()
su un'entità che non ha un Salute
componente, verrà compilato, ma riceverai un errore di runtime che ti farà sapere che hai fatto qualcosa di sciocco. In pratica accade raramente, poiché è abbastanza ovvio quali tipi di entità avranno quali componenti (ad esempio, ovviamente un albero non ha un'arma!).
Alcuni sostanziali sostenitori dell'OOP potrebbero sostenere che il mio approccio infrange alcuni principi OOP, ma trovo che funzioni molto bene e che ci sia un precedente molto buono nella storia di Adobe Flash.
In ActionScript 2, il Un filmato
la classe aveva metodi per disegnare la grafica vettoriale: per esempio, si poteva chiamare myMovieClip.lineTo ()
disegnare una linea. In ActionScript 3, questi metodi di disegno sono stati spostati nel Grafica
classe, e ciascuno Un filmato
ottiene un Grafica
componente, a cui si accede chiamando, ad esempio, myMovieClip.graphics.lineTo ()
nello stesso modo in cui ho descritto enemy.health.takeDamage ()
. Se è abbastanza buono per i progettisti di linguaggi ActionScript, è abbastanza buono per me.
Di seguito illustrerò una versione molto semplificata del sistema che utilizzo in tutti i miei giochi. In termini di quanto semplificata, è qualcosa come 300 linee di codice per questo, rispetto a 6.000 per il mio motore completo. Ma possiamo fare davvero molto con solo queste 300 linee!
Ho lasciato solo le funzionalità sufficienti per creare un gioco funzionante, mantenendo il codice il più breve possibile, quindi è più facile da seguire. Il codice sarà in ActionScript 3, ma una struttura simile è possibile nella maggior parte delle lingue. Ci sono alcune variabili pubbliche che potrebbero essere proprietà (ad es ottenere
e impostato
funzioni accessorie), ma poiché questo è abbastanza dettagliato in ActionScript, li ho lasciati come variabili pubbliche per facilitare la lettura.
IEntity
InterfacciaIniziamo definendo un'interfaccia che tutte le entità implementeranno:
pacchetto motore import org.osflash.signals.Signal; / ** * ... * @author Iain Lobb - [email protected] * / IEntity interfaccia pubblica // Funzione ACTIONS destroy (): void; function update (): void; function render (): void; // funzione COMPONENTS get body (): Body; function set body (value: Body): void; function get physics (): fisica; function set physics (value: Physics): void function get health (): Health set set health (value: Health): void function get weapon (): Arma; function set weapon (value: Weapon): void; funzione get view (): View; funzione set view (valore: View): void; // La funzione SIGNALS ottiene entityCreated (): Signal; function set entityCreated (value: Signal): void; funzione get destroyed (): Signal; set di funzioni distrutto (valore: Signal): void; // funzione DEPENDENCIES get targets (): Vector.; funzione impostata obiettivi (valore: Vector. ): Void; function get group (): Vector. ; gruppo di funzioni (valore: Vector. ): Void;
Tutte le entità possono eseguire tre azioni: puoi aggiornarle, renderle e distruggerle.
Ognuno di essi ha "slot" per cinque componenti:
corpo
, maneggiando posizione e dimensioni.fisica
, movimentazione del movimento.Salute
, maneggiare il dolore.arma
, gestire l'attacco.vista
, permettendoti di rendere l'entità.Tutti questi componenti sono opzionali e possono essere lasciati nulli, ma in pratica la maggior parte delle entità avrà almeno un paio di componenti.
Un pezzo di scenario statico con cui il giocatore non può interagire (ad esempio un albero), avrebbe bisogno solo di un corpo e di una vista. Non avrebbe bisogno di fisica perché non si muove, non avrebbe bisogno di salute perché non è possibile attaccarla, e certamente non avrebbe bisogno di un'arma. La nave del giocatore in Asteroids, d'altra parte, avrebbe bisogno di tutti e cinque i componenti, in quanto può muoversi, sparare e farsi male.
Configurando questi cinque componenti di base, è possibile creare la maggior parte degli oggetti semplici che potrebbero essere necessari. A volte, tuttavia, non saranno sufficienti e a quel punto potremo estendere i componenti di base o crearne di nuovi aggiuntivi, entrambi dei quali parleremo in seguito.
Successivamente abbiamo due segnali: entityCreated
e distrutto
.
I segnali sono un'alternativa open source agli eventi nativi di ActionScript, creati da Robert Penner. Sono davvero piacevoli da usare in quanto consentono di passare i dati tra il dispatcher e il listener senza dover creare molte classi di eventi personalizzate. Per ulteriori informazioni su come usarli, consultare la documentazione.
Il entityCreated
Il segnale consente a un'entità di dire al gioco che c'è un'altra nuova entità che deve essere aggiunta - un esempio classico è quando una pistola crea un proiettile. Il distrutto
Il segnale consente al gioco (e agli altri oggetti in ascolto) di sapere che questa entità è stata distrutta.
Infine, l'entità ha altre due dipendenze opzionali: obiettivi
, che è una lista di entità che potrebbe voler attaccare, e gruppo
, che è una lista di entità a cui appartiene. Ad esempio, una nave giocatore potrebbe avere una lista di bersagli, che sarebbero tutti i nemici nel gioco, e potrebbe appartenere ad un gruppo che contiene anche altri giocatori e unità amichevoli.
Entità
ClasseOra diamo un'occhiata al Entità
classe che implementa questa interfaccia.
pacchetto motore import org.osflash.signals.Signal; / ** * ... * @author Iain Lobb - [email protected] * / public class Entity implementa IEntity private var _body: Body; private var _physics: fisica; private var _health: Health; private var _weapon: Weapon; private var _view: View; private var _entityCreated: Signal; private var _destroyed: Signal; private var _targets: Vector.; private var _group: Vector. ; / * * Tutto ciò che esiste nel tuo gioco è un'entità! * / funzione pubblica Entity () entityCreated = new Signal (Entity); distrutto = nuovo segnale (entità); public function destroy (): void destroyed.dispatch (this); if (group) group.splice (group.indexOf (this), 1); public function update (): void if (physics) physics.update (); public function render (): void if (view) view.render (); public function get body (): Body return _body; public set set body (value: Body): void _body = value; public function get physics (): Physics return _physics; public set set physics (value: Physics): void _physics = value; public function get health (): Health return _health; public set set health (valore: Health): void _health = value; public function get weapon (): Weapon return _weapon; public set set weapon (valore: Weapon): void _weapon = value; public function get view (): Visualizza return _view; view set di funzioni pubbliche (valore: View): void _view = value; public function get entityCreated (): Signal return _entityCreated; public function set entityCreated (value: Signal): void _entityCreated = value; public function get destroyed (): Signal return _destroyed; public function set destroyed (value: Signal): void _destroyed = value; public function get targets (): Vector. return _targets; obiettivi di set di funzioni pubbliche (valore: Vector. ): void _targets = value; public function get group (): Vector. return _group; gruppo di set di funzioni pubbliche (valore: Vector. ): void _group = value;
Sembra lungo, ma la maggior parte sono solo quelle verbose getter e setter (boo!). La parte importante da guardare sono le prime quattro funzioni: il costruttore, dove creiamo i nostri segnali; distruggere()
, dove spediamo il Segnale distrutto e rimuoviamo l'entità dalla sua lista di gruppi; aggiornare()
, dove aggiorniamo tutti i componenti che devono agire per ogni ciclo di gioco - anche se in questo semplice esempio questo è solo il fisica
componente - e infine render ()
, dove diciamo la vista di fare la sua cosa.
Noterai che non istanziamo automaticamente i componenti qui nella classe Entity - questo perché, come ho spiegato in precedenza, ogni componente è opzionale.
Ora guardiamo i componenti uno per uno. Innanzitutto, la componente del corpo:
motore del pacchetto / ** * ... * @author Iain Lobb - [email protected] * / public class Body entità var pubblica: Entity; public var x: Number = 0; public var y: Number = 0; angolo var pubblico: Number = 0; raggio var per pubblico: Number = 10; / * * Se dai un'entità a un corpo, esso può prendere forma fisica nel mondo, * sebbene per vederlo avrai bisogno di una vista. * / funzione pubblica Corpo (entità: entità) this.entity = entity; public function testCollision (otherEntity: Entity): Boolean var dx: Number; var dy: Number; dx = x - otherEntity.body.x; dy = y - otherEntity.body.y; return Math.sqrt ((dx * dx) + (dy * dy)) <= radius + otherEntity.body.radius;
Tutti i nostri componenti hanno bisogno di un riferimento alla loro entità proprietaria, che passiamo al costruttore. Il corpo ha quindi quattro campi semplici: una posizione xey, un angolo di rotazione e un raggio per memorizzare le sue dimensioni. (In questo semplice esempio, tutte le entità sono circolari!)
Questo componente ha anche un unico metodo: testCollision ()
, che usa Pitagora per calcolare la distanza tra due entità e la confronta con i loro raggi combinati. (Maggiori informazioni qui.)
Ora diamo un'occhiata al Fisica
componente:
motore del pacchetto / ** * ... * @author Iain Lobb - [email protected] * / public class Physics entità var pubblica: Entity; trascinamento var pubblico: Number = 1; public var velocityX: Number = 0; public var velocityY: Number = 0; / * * Fornisce una fase fisica di base senza rilevamento delle collisioni. * Estendere per aggiungere la gestione delle collisioni. * / funzione pubblica Fisica (entità: entità) this.entity = entity; public function update (): void entity.body.x + = velocityX; entity.body.y + = velocityY; velocityX * = trascina; velocityY * = trascina; public function thrust (power: Number): void velocityX + = Math.sin (-entity.body.angle) * power; velocityY + = Math.cos (-entity.body.angle) * potenza;
Guardando il aggiornare()
funzione, puoi vedere che il velocityX
e velocityY
i valori vengono aggiunti alla posizione dell'entità, che la sposta e la velocità viene moltiplicata per trascinare
, che ha l'effetto di rallentare gradualmente l'oggetto verso il basso. Il spinta()
la funzione consente un modo rapido per accelerare l'entità nella direzione in cui è rivolta.
Ora diamo un'occhiata al Salute
componente:
pacchetto motore import org.osflash.signals.Signal; / ** * ... * @author Iain Lobb - [email protected] * / public class Health entità var pubblica: Entity; hit pubblici var: int; pubblico var è morto: Signal; public var hurt: Signal; funzione pubblica Salute (entità: entità) this.entity = entity; morto = nuovo segnale (entità); hurt = new Signal (Entity); hit funzione pubblica (danno: int): void hits - = damage; hurt.dispatch (entità); se (colpisce < 0) died.dispatch(entity);
Il Salute
il componente ha una funzione chiamata colpire()
, permettendo all'entità di essere ferita. Quando ciò accade, il colpi
il valore viene ridotto e tutti gli oggetti in ascolto vengono avvisati inviando il male
Segnale. Se colpi
sono meno di zero, l'entità è morta e noi spediamo il morto
Segnale.
Vediamo cosa c'è dentro Arma
componente:
pacchetto motore import org.osflash.signals.Signal; / ** * ... * @author Iain Lobb - [email protected] * / public class Weapon entità var pubblica: Entity; public var ammo: int; / * * L'arma è la classe base per tutte le armi. * / funzione pubblica Arma (entità: entità) this.entity = entity; public function fire (): void ammo--;
Non molto qui! Questo perché questa è davvero solo una classe base per le armi reali - come vedrai nel Pistola
esempio dopo. C'è un fuoco()
metodo che le sottoclassi dovrebbero sovrascrivere, ma qui riduce il valore di munizioni
.
Il componente finale da esaminare è vista
:
pacchetto motore import flash.display.Sprite; / ** * ... * @author Iain Lobb - [email protected] * / public class Visualizza public var entity: Entity; scala var pubblica: Number = 1; public var alpha: Number = 1; public var sprite: Sprite; / * * La vista è un componente di visualizzazione che esegue il rendering di un'entità utilizzando l'elenco di visualizzazione standard. * / funzione pubblica Visualizza (entità: entità) this.entity = entità; public function render (): void sprite.x = entity.body.x; sprite.y = entity.body.y; sprite.rotation = entity.body.angle * (180 / Math.PI); sprite.alpha = alfa; sprite.scaleX = scale; sprite.scaleY = scale;
Questo componente è molto specifico per Flash. L'evento principale qui è il render ()
funzione, che aggiorna uno sprite Flash con i valori di posizione e rotazione del corpo e i valori alfa e scala che si memorizza. Se si desidera utilizzare un diverso sistema di rendering come copyPixels
blitting o Stage3D (o in effetti un sistema rilevante per una diversa scelta di piattaforma), si adatterebbe questa classe.
Gioco
ClasseOra sappiamo che aspetto hanno un'entità e tutti i suoi componenti. Prima di iniziare a utilizzare questo motore per fare un esempio di gioco, diamo un'occhiata all'ultima parte del motore: la classe Game che controlla l'intero sistema:
pacchetto motore import flash.display.Sprite; import flash.display.Stage; import flash.events.Event; / ** * ... * @author Iain Lobb - [email protected] * / public class Game estende Sprite public var entity: Vector.= nuovo vettore. (); public var isPaused: Boolean; fase statica pubblica: stage; / * * Il gioco è la classe base per i giochi. * / public function Game () addEventListener (Event.ENTER_FRAME, onEnterFrame); addEventListener (Event.ADDED_TO_STAGE, onAddedToStage); funzione protetta onEnterFrame (event: Event): void if (isPaused) return; aggiornare(); render (); protected function update (): void per ciascuna (entità var: Entity in entity) entity.update (); funzione protetta render (): void per ciascuna (entità var: Entity in entity) entity.render (); funzione protetta su AddedToStage (event: Event): void Game.stage = stage; inizia il gioco(); funzione protetta startGame (): void funzione protetta stopGame (): void per ciascuna (entità var: Entità in entità) if (entity.view) removeChild (entity.view.sprite); entities.length = 0; public function addEntity (entity: Entity): Entity entities.push (entity); entity.destroyed.add (onEntityDestroyed); entity.entityCreated.add (addEntity); if (entity.view) addChild (entity.view.sprite); entità di ritorno; funzione protetta onEntityDestroyed (entità: Entity): void entities.splice (entities.indexOf (entity), 1); if (entity.view) removeChild (entity.view.sprite); entity.destroyed.remove (onEntityDestroyed);
Ci sono molti dettagli di implementazione qui, ma vediamo solo i punti salienti.
Ogni fotogramma, il Gioco
class loop attraverso tutte le entità e chiama i loro metodi di aggiornamento e rendering. Nel addEntity
funzione, aggiungiamo la nuova entità all'elenco delle entità, ascoltiamo i suoi segnali, e se ha una vista, aggiungi il suo sprite allo stage.
quando onEntityDestroyed
viene attivato, rimuoviamo l'entità dall'elenco e rimuoviamo il suo sprite dallo stage. Nel StopGame
funzione, che chiami solo se vuoi terminare il gioco, rimuoviamo tutti gli sprite delle entità dallo stage e cancelliamo l'elenco delle entità impostandone la lunghezza a zero.
Wow, ce l'abbiamo fatta! Questo è l'intero motore di gioco! Da questo punto di partenza, potremmo realizzare molti semplici giochi arcade 2D senza molto codice aggiuntivo. Nel prossimo tutorial, useremo questo motore per creare uno sparatutto spaziale in stile Asteroids.