Evitarea antipatternului de sânge o abordare pragmatică a compoziției entității

Organizarea codului dvs. de joc în entități bazate pe componente, în loc să se bazeze numai pe moștenirea de clasă, este o abordare populară în dezvoltarea jocurilor. În acest tutorial, vom examina de ce ați putea face acest lucru și ați înființat un motor simplu de joc utilizând această tehnică.


Introducere

În acest tutorial voi explora entități bazate pe componente, vă uitați la motivul pentru care ați putea dori să le folosiți și sugerați o abordare pragmatică pentru a vă scufunda degetul în apă.

Deoarece este o poveste despre organizarea codului și arhitectura, voi începe prin a renunța la renunțarea obișnuită "a ieși din închisoare": aceasta este doar o modalitate de a face lucrurile, nu este "singura cale" sau poate chiar cel mai bun mod, dar ar putea să funcționeze pentru dvs. Personal, îmi place să aflu despre cât mai multe abordări posibil și apoi să-mi dau seama ce mi se potrivește.


Rezultatul final al rezultatelor

În tot acest tutorial în două părți, vom crea acest joc Asteroids. (Codul sursă complet este disponibil pe GitHub.) În această primă parte, ne vom concentra pe conceptele de bază și pe motorul general de joc.


Ce problemă rezolvăm?

Într-un joc precum Asteroizii, am putea avea câteva tipuri de bază de "lucru" pe ecran: gloanțe, asteroizi, nave de jucători și nave inamice. S-ar putea să dorim să reprezentăm aceste tipuri de bază ca patru clase separate, fiecare conținând tot codul de care trebuie să desenezi, să animăm, să mutăm și să controlem acel obiect.

În timp ce acest lucru va funcționa, ar fi mai bine să urmați Nu vă repetați (DRY) și încercați să reutilizați unele dintre codurile dintre fiecare clasă - în definitiv, codul pentru deplasarea și desenarea unui glonț va fi foarte similar cu, dacă nu chiar exact același cu cel al codului pentru a muta și a desena un asteroid sau o navă.

Așadar, putem să refactorizăm funcțiile noastre de redare și mișcare într-o clasă de bază pe care se întinde totul. Dar Navă și EnemyShip de asemenea, trebuie să fie capabil să tragă. În acest moment am putea adăuga trage funcția la clasa de bază, creând o clasă "Giant Blob" care poate face practic totul, și trebuie doar să vă asigurați că asteroizii și gloanțele nu își apelează niciodată trage funcţie. Această clasă de bază ar deveni în curând foarte mare, umflat în dimensiune de fiecare dată când entitățile trebuie să poată face noi lucruri. Acest lucru nu este neapărat greșit, dar găsesc clase mai mici, mai specializate, pentru a fi mai ușor de întreținut.

În mod alternativ, putem să scăpăm de rădăcina moștenirii adânci și să avem ceva asemănător EnemyShip extinde nava extinde ShootingEntity extinde Entitatea. Din nou, această abordare nu este greșită și va funcționa destul de bine, dar pe măsură ce adăugați mai multe tipuri de Entități, veți găsi în mod constant necesitatea de a readapta ierarhia de moștenire pentru a face față tuturor scenariilor posibile și vă puteți plasa într-un colț unde un nou tip de entitate trebuie să aibă funcționalitatea a două clase de bază diferite, care necesită mai multe moșteniri (pe care cele mai multe limbi de programare nu le oferă).

Am folosit abordarea de ierarhie profundă de multe ori pe mine, dar prefer de fapt abordarea Giant Blob, deoarece cel puțin atunci toate entitățile au o interfață comună și noile entități pot fi adăugate mai ușor (deci dacă toți copacii au A * pathfinding? !)

Există însă oa treia cale ...


Compoziție peste moștenire

Dacă ne gândim la problema asteroizilor în ceea ce privește lucrurile pe care obiectele ar trebui să le facă, am putea obține o listă de felul următor:

  • mișcare()
  • trage()
  • takeDamage ()
  • a muri()
  • face()

În locul elaborării unei ierarhii complexe de moștenire pentru care obiectele pot face lucrurile, să modelăm problema în termeni de componente care pot efectua aceste acțiuni.

De exemplu, am putea crea un Sănătate clasa, cu metodele takeDamage (), vindeca() și a muri(). Apoi, orice obiect care trebuie să poată da daune și să moară poate "compune" o instanță a lui Sănătate clasa - în cazul în care "compune" înseamnă în principiu "păstrați o referință la propria instanță a acestei clase".

Am putea crea o altă clasă numită Vedere să aibă grijă de funcționalitatea de randare, unul apelat Corp să se ocupe de mișcare și de unul chemat Armă să se ocupe de fotografiere.

Cele mai multe sisteme Entity se bazează pe principiul descris mai sus, dar diferă în modul în care accesați funcționalitatea conținută într-o componentă.

Oglindirea API

De exemplu, o abordare este de a reflecta API-ul fiecărei componente din Entitate, astfel încât o entitate care poate avea daune ar avea o takeDamage () funcția asta în sine doar sună takeDamage () funcția lui Sănătate component.

 Entitatea de clasă private var _health: Health; // ... alt cod ... // funcția publică takeDamage (dmg: int) _health.takeDamage (dmg); 

Apoi trebuie să creați o interfață numită ceva asemănător IHealth pentru implementarea entității dvs., astfel încât alte obiecte să poată accesa takeDamage () funcţie. Acesta este modul în care un ghid Java OOP vă poate sfătui să faceți acest lucru.

getComponent ()

O altă abordare este aceea de a stoca pur și simplu fiecare componentă într-o căutare cheie-valoare, astfel încât fiecare entitate să aibă o funcție numită ceva getComponent ( "componentName") care returnează o referință la componenta respectivă. Apoi trebuie să aruncați referința pe care o veți întoarce la tipul de componentă pe care doriți - ceva de genul:

 var sănătate: sănătate = sănătate (getComponent ("Sănătate"));

Acesta este modul în care funcționează entitatea / comportamentul Unității. Este foarte flexibil, deoarece puteți adăuga noi tipuri de componente fără a schimba clasa de bază sau pentru a crea noi subclase sau interfețe. Ar putea fi de asemenea util atunci când doriți să utilizați fișierele de configurare pentru a crea entități fără a recompila codul, dar o las pe altcineva să-și dea seama.

Componente publice

Abordarea pe care o prefer să fie permite tuturor entităților să dețină o proprietate publică pentru fiecare tip important de componentă și să lase câmpurile nulă dacă entitatea nu are această funcționalitate. Când doriți să apelați o anumită metodă, pur și simplu "ajungeți" în entitate pentru a obține componenta cu această funcționalitate - de exemplu, apelați enemy.health.takeDamage (5) pentru a ataca un inamic.

Dacă încercați să apelați health.takeDamage () pe o entitate care nu are a Sănătate componentă, se va compila, dar veți obține o eroare de execuție, permițându-vă să știți că ați făcut ceva prostește. În practică, acest lucru se întâmplă foarte rar, deoarece este destul de evident ce tip de entitate va avea componentele (de exemplu, un copac nu are o armă!).

Unii susținători stricți ai PPE ar putea susține că abordarea mea încalcă anumite principii OOP, dar cred că funcționează foarte bine și că există un precedent foarte bun din istoria Adobe Flash.

În ActionScript 2, MovieClip clasa a avut metode pentru a desena grafică vectorială: de exemplu, ați putea apela myMovieClip.lineTo () pentru a desena o linie. În ActionScript 3, aceste metode de desen au fost mutate în Grafică clasă și fiecare MovieClip primește a Grafică componente, pe care le accesați prin apelarea, de exemplu, myMovieClip.graphics.lineTo () în același mod în care am descris enemy.health.takeDamage (). Dacă este suficient de bun pentru designerii de limbi ActionScript, este suficient pentru mine.


Sistemul meu (simplificat)

Mai jos voi detalia o versiune foarte simplificată a sistemului pe care îl folosesc în toate jocurile mele. În ceea ce privește modul de simplificare, este ceva de genul 300 de linii de cod pentru asta, în comparație cu 6.000 pentru motorul meu complet. Dar putem face destul de mult cu doar 300 de linii!

Am lăsat în suficientă funcționalitate pentru a crea un joc de lucru, păstrând în același timp codul cât mai scurt posibil, astfel încât să fie mai ușor de urmărit. Codul va fi în ActionScript 3, însă o structură similară este posibilă în majoritatea limbilor. Există câteva variabile publice care ar putea fi proprietăți (adică în spatele lor obține și a stabilit funcții accesoriu), dar deoarece aceasta este destul de verbală în ActionScript, i-am lăsat ca variabile publice pentru a le citi mai ușor.

IEntity Interfață

Să începem prin definirea unei interfețe pe care toate entitățile o vor implementa:

 pachet motor import org.osflash.signals.Signal; / ** * ... * @author Iain Lobb - [email protected] * / interfață publică IEntity // ACTIONS function destroy (): void; function update (): void; funcția rendere (): void; // COMPONENTS function get body (): Body; funcția corp set (valoare: Body): void; functie obtine fizica (): fizica; funcția fizică setată (valoare: fizică): funcția voidă obține sănătatea (): funcția Sănătate setată sănătatea (valoare: sănătate): funcția voidă obține arma (): arma; arma set de funcții (valoare: armă): vid; functie vizualizare (): Vizualizare; Afișare set de funcții (valoare: Vizualizare): void; // funcția SIGNALS get entityCreated (): Semnal; set set de funcțiiCreată (valoare: semnal): void; funcția se distruge (): Semnal; setul de funcții distrus (valoare: Semnal): void; // DEPENDENCIES funcția obține obiecte (): Vector.; set de obiective stabilite (valoare: Vector.): Void; funcția obține grup (): Vector.; set grup de funcții (valoare: Vector.): Void; 

Toate entitățile pot efectua trei acțiuni: le puteți actualiza, le puteți face și le puteți distruge.

Fiecare dintre ele are "sloturi" pentru cinci componente:

  • A corp, poziție și dimensiune de manipulare.
  • fizică, manipularea mișcării.
  • sănătate, manipularea rănirii.
  • A armă, manipularea ataca.
  • Și în cele din urmă a vedere, permițându-vă să redați entitatea.

Toate aceste componente sunt opționale și pot fi lăsate în nul, dar în practică majoritatea entităților vor avea cel puțin două componente.

O bucată de peisaj static pe care playerul nu poate să o interacționeze (de exemplu, un copac, de exemplu) ar avea nevoie doar de un corp și o vedere. Nu ar avea nevoie de fizică, deoarece nu se mișcă, nu ar avea nevoie de sănătate, deoarece nu o puteți ataca, și cu siguranță nu ar avea nevoie de o armă. Nava jucătorului din Asteroizi, pe de altă parte, avea nevoie de toate cele cinci componente, deoarece se poate mișca, trage și se răni.

Prin configurarea acestor cinci componente de bază, puteți crea cele mai simple obiecte de care aveți nevoie. Uneori însă nu vor fi suficiente și, la acel moment, putem fie să extindem componentele de bază, fie să creăm altele noi - amândouă vom discuta mai târziu.

Apoi avem două semnale: entityCreated și distrus.

Semnalele sunt o alternativă open source la evenimentele native ale ActionScript, create de Robert Penner. Sunt foarte drăguțe pentru a le utiliza, deoarece acestea vă permit să transmiteți date între dispecer și ascultător, fără a fi nevoie să creați o mulțime de clase personalizate de eveniment. Pentru mai multe informații despre modul de utilizare a acestora, consultați documentația.

entityCreated Semnalul permite unei entități să spună jocului că există o altă entitate nouă care trebuie adăugată - un exemplu clasic fiind când un pistol creează un glonț. distrus Semnalul permite jocului (și oricărui alt obiect de ascultare) să știe că această entitate a fost distrusă.

În cele din urmă, entitatea are alte două dependințe opționale: obiective, care este o listă de entități pe care ar putea să le atace și grup, care este o listă a entităților din care face parte. De exemplu, o navă de jucător ar putea avea o listă de ținte, care ar fi toți dușmanii din joc și ar putea aparține unui grup care conține și alți jucători și unități prietenoase.

Entitate Clasă

Acum, să ne uităm la Entitate clasa care implementează această interfață.

 pachet motor import org.osflash.signals.Signal; / ** * ... * @author Iain Lobb - [email protected] * / Entitatea publică de clasă implementează IEntity private var _body: Body; fizică privată: fizică; private var _health: Sănătate; private var _weapon: Arme; privat var _view: Vizualizare; private var _entityCreated: Semnal; privat var _destroyed: Semnal; private var _targets: Vector.; privat var _group: Vector.; / * * Orice lucru care există în jocul dvs. este o Entitate! * / Entitatea funcției publice () entityCreated = Signal nou (entitate); distrus = semnal nou (entitate);  funcția publică distruge (): void destroyed.dispatch (this); dacă (grupul) group.splice (group.indexOf (this), 1);  funcția publică funcțională (): void if (physics) physics.update ();  funcția publică public render (): void if (view) view.render ();  funcția publică get body (): Body return _body;  funcția publică set organism (valoare: Body): void _body = valoare;  funcția publică obține fizica (): fizică return_physics;  funcția publică set fizica (valoare: fizică): void _physics = valoare;  funcția publică obține sănătate (): Sănătate revenire _health;  funcția publică setată de sănătate (valoare: Sănătate): void _health = value;  funcția publică obține arma (): Arma return _weapon;  arma funcției publice setată (valoare: Weapon): void _weapon = value;  funcția publică obține vizualizare (): View return _view;  vizualizare set de funcții publice (valoare: Vizualizare): void _view = value;  funcția publică get entityCreated (): Signal return _entityCreated;  entitate publică set entitateCreată (valoare: Semnal): void _entityCreated = value;  funcția publică este distrusă (): Semnal return _destroyed;  set de funcții publice distrus (valoare: Semnal): void _destroyed = value;  funcția publică obține obiective (): Vector. returnați obiectele;  funcții publice setate ținte (valoare: Vector.): void _targets = valoare;  funcția publică obține grup (): Vector. return _group;  grup de seturi de funcții publice (valoare: Vector.): void _group = valoare; 

Arata mult, dar majoritatea sunt doar acele functii de getter si setter (boo!). Partea importantă de care trebuie să ne uităm este primele patru funcții: constructorul, unde ne creăm Semnalele; distruge(), unde trimitem semnalul distrus și eliminăm entitatea din lista de grupuri; Actualizați(), unde actualizăm toate componentele care trebuie să acționeze în fiecare buclă de jocuri - deși în acest exemplu simplu acest lucru este doar fizică componentă - și în cele din urmă face(), unde îi spunem să-și facă treaba.

Veți observa că nu instanțiăm în mod automat componentele aici în clasa entității - aceasta deoarece, așa cum am explicat mai devreme, fiecare componentă este opțională.

Componentele individuale

Acum, să examinăm componentele unul câte unul. În primul rând, componenta corpului:

 pachet motor / ** * ... * @author Iain Lobb - [email protected] * / clasa publica Corp public public entity: Entity; public var x: Număr = 0; public var: Numărul = 0; public var angle: Number = 0; rază publică var: Număr = 10; / * * Dacă oferiți unei entități un organism, acesta poate lua forma fizică în lume, * deși pentru ao vedea, veți avea nevoie de o viziune. * / functie publica Organism (entitate: Entitate) this.entity = entity;  funcția publică testCollision (otherEntity: Entity): Boolean var dx: Număr; var dy: Număr; dx = x - otherEntity.body.x; dy = y - otherEntity.body.y; retur Math.sqrt ((dx * dx) + (dy * dy)) <= radius + otherEntity.body.radius;   

Toate componentele noastre au nevoie de o referință la entitatea proprietarului, pe care le transmitem constructorului. Corpul are apoi patru câmpuri simple: o poziție x și y, un unghi de rotație și o rază pentru a-și stoca dimensiunea. (În acest exemplu simplu, toate entitățile sunt circulare!)

Această componentă are, de asemenea, o singură metodă: testCollision (), care utilizează Pythagoras pentru a calcula distanța dintre două entități și o compară cu razele lor combinate. (Mai multe informații aici.)

Apoi, să ne uităm la Fizică componente:

 motor pachet / ** * ... * @author Iain Lobb - [email protected] * / clasa publica Fizica public public entity: Entity; public drag drag: Number = 1; public var velocityX: Număr = 0; public var velocityY: Număr = 0; / * * Oferă un pas fizic de bază fără detectarea coliziunilor. * Extindeți pentru a adăuga manipularea coliziunilor. * / funcția publică Fizică (entitate: entitate) this.entity = entitate;  funcția publică funcțională (): void entity.body.x + = velocityX; entity.body.y + = vitezaY; velocityX * = trageți; velocityY * = trageți;  forța funcției publice (putere: Număr): void velocityX + = Math.sin (-entity.body.angle) * putere; velocityY + = Math.cos (-entity.body.angle) * putere; 

Privind la Actualizați() funcția, puteți vedea că velocityX și velocityY valorile sunt adăugate pe poziția entității, care o mută, iar viteza este înmulțită cu trage, care are ca efect încetinirea treptată a obiectului în jos. împingere () permite o modalitate rapidă de a accelera entitatea în direcția în care se află.

Apoi, să ne uităm la Sănătate componente:

 pachet motor import org.osflash.signals.Signal; / ** * ... * @autor Iain Lobb - [email protected] * / clasa publica Health public entity: Entity; public var hits: int; public var a murit: Semnal; public var hurt: semnal; funcția publică Sănătate (entitate: Entitate) this.entity = entity; a murit = semnal nou (entitate); rănit = semnal nou (entitate);  funcția publică lovită (daune: int): void hits - = damage; hurt.dispatch (entitate); dacă (hit-uri < 0)  died.dispatch(entity);    

Sănătate componenta are o funcție numită lovit(), permițând entității să fie rănită. Când se întâmplă acest lucru, hit-uri valoarea este redusă și orice obiecte de ascultare sunt notificate prin expedierea mesajului rănit Semnal. Dacă hit-uri sunt mai mici de zero, entitatea este mortă și trimitem decedat Semnal.

Să vedem ce este înăuntru Armă componente:

 pachet motor import org.osflash.signals.Signal; / ** * ... * @author Iain Lobb - [email protected] * / clasa publica Weapon public public entity: Entitate; public var ammo: int; / * * Arma este clasa de bază pentru toate armele. * / funcție publică Weapon (entitate: Entitate) this.entity = entity;  funcția publică (): void muniție; 

Nu prea mult aici! Asta pentru ca aceasta este de fapt doar o clasa de baza pentru armele reale - asa cum veti vedea in armă exemplu mai târziu. E a foc() metoda pe care subclasele ar trebui să o înlocuiască, dar aici doar reduce valoarea muniții.

Componenta finală de examinat este Vedere:

 pachet motor import flash.display.Sprite; / ** * ... * @author Iain Lobb - [email protected] * / clasa publica View public entity: Entity; public var scale: Number = 1; public var alpha: Număr = 1; public var sprite: Sprite; / * * View este o componentă care afișează o entitate utilizând lista de afișare standard. * / funcția publică View (entitate: entitate) this.entity = entity;  funcția publică render (): void sprite.x = entity.body.x; sprite.y = entity.body.y; sprite.ro = entitate.body.angle * (180 / Math.PI); sprite.alpha = alfa; sprite.scaleX = scară; sprite.scaleY = scară; 

Această componentă este foarte specifică pentru Flash. Evenimentul principal este aici face() , care actualizează o sprite Flash cu valorile de poziționare și rotație ale corpului și valorile alpha și scară pe care le stochează. Dacă doriți să utilizați un alt sistem de redare, cum ar fi copyPixels blitz sau Stage3D (sau chiar un sistem relevant pentru o alegere diferită de platformă), ați adapta această clasă.

Joc Clasă

Acum știm ce arată o Entitate și toate componentele acesteia. Înainte de a începe să folosim acest motor pentru a face un exemplu de joc, să aruncăm o privire asupra piesei finale a motorului - clasa de joc care controlează întregul sistem:

 pachet motor import flash.display.Sprite; import flash.display.Stage; importul flash.events.Event; / ** * ... * @author Iain Lobb - [email protected] * / joc public de clasă extinde Sprite public entities: Vector. = Vector nou.(); public var isPaused: Boolean; statică statică publică var: Stage; / * * Jocul este clasa de bază pentru jocuri. * / funcția publică funcțională () addEventListener (Event.ENTER_FRAME, onEnterFrame); addEventListener (Event.ADDED_TO_STAGE, onAddedToStage);  funcția protejată onEnterFrame (eveniment: Eveniment): void if (isPaused) retur; Actualizați(); face();  funcția protejată update (): void pentru fiecare (entitate var: Entitate în entități) entity.update ();  funcția protejată render (): void pentru fiecare (entitate var: Entitate în entități) entity.render ();  funcția protejată onAddedToStage (eveniment: eveniment): void Game.stage = stage; incepe jocul();  funcția protejată startGame (): void  funcția protejată stopGame (): void pentru fiecare (entitate var: Entitate în entități) if (entity.view) removeChild (entity.view.sprite);  entities.length = 0;  funcția publică addEntity (entitate: entitate): Entitatea entities.push (entity); entity.destroyed.add (onEntityDestroyed); entity.entityCreated.add (addEntity); dacă (entity.view) addChild (entity.view.sprite); entitate de returnare;  funcția protejată onEntityDestroyed (entitate: entitate): void entities.splice (entities.indexOf (entity), 1); dacă (entity.view) removeChild (entity.view.sprite); entity.destroyed.remove (onEntityDestroyed); 

Sunt multe detalii de implementare aici, dar hai să alegem cele mai importante lucruri.

Fiecare cadru, Joc buclele de clasă prin toate entitățile și le solicită actualizarea și metodele de redare. În addEntity funcția, adăugăm entitatea nouă în lista de entități, ascultăm semnalele sale și, dacă are o vizualizare, adăugăm sprite-ul la scenă.

Cand onEntityDestroyed este declanșat, eliminăm entitatea din listă și eliminăm sprite-ul din etapă. În stopGame funcția pe care o apelați numai dacă doriți să opriți jocul, eliminăm spritele tuturor entităților din stadiu și ștergeți lista entităților stabilind lungimea sa la zero.


Data viitoare…

Wow, am reușit! Acesta este tot motorul de joc! Din acest punct de pornire, am putea face multe jocuri simple arcade 2D fără prea multe coduri suplimentare. În tutorialul următor, vom folosi acest motor pentru a crea un spațiu de tip shoot -'em-up în stil "Asteroids".