Organisering av spillkoden til komponentbaserte enheter, i stedet for å stole på klassearv, er en populær tilnærming i spillutvikling. I denne opplæringen ser vi på hvorfor du kan gjøre dette, og sette opp en enkel spillmotor ved hjelp av denne teknikken.
I denne opplæringen skal jeg utforske komponentbaserte spill-enheter, se på hvorfor du kanskje vil bruke dem, og foreslå en pragmatisk tilnærming til å dyppe tåen i vannet.
Som det er en historie om kodeorganisasjon og arkitektur, begynner jeg å slippe i den vanlige "få ut av fengsel" ansvarsfraskrivelse: dette er bare en måte å gjøre ting på, det er ikke "en vei" eller kanskje til og med den beste måten, men det kan fungere for deg. Personlig liker jeg å finne ut om så mange tilnærminger som mulig og deretter utarbeide hva som passer meg.
Gjennom denne todelte opplæringen lager vi dette Asteroids-spillet. (Full kildekoden er tilgjengelig på GitHub.) I denne første delen vil vi fokusere på kjernekonsepter og generell spillmotor.
I et spill som Asteroider, kan vi ha noen grunnleggende typer "ting" på skjermen: kuler, asteroider, spillerskip og fiendtlig skip. Vi vil kanskje representere disse grunnleggende typene som fire separate klasser, som hver inneholder all koden vi trenger å tegne, animere, flytte og kontrollere objektet.
Mens dette vil fungere, kan det være bedre å følge Ikke gjenta deg selv (DRY) -prinsippet, og prøv å gjenbruke noen av koden mellom hver klasse. Koden for å flytte og tegne en kule vil trolig være svært lik, om ikke akkurat det samme som koden som skal flyttes og tegne en asteroide eller et skip.
Så vi kan refactor våre gjengivelses- og bevegelsesfunksjoner i en grunnklasse som alt strekker seg fra. Men Skip
og EnemyShip
må også kunne skyte. På dette punktet kunne vi legge til skyte
fungere til grunnklassen, skape en "Giant Blob" klasse som kan gjøre i utgangspunktet alt, og bare sørg for at asteroider og kuler aldri ringer deres skyte
funksjon. Denne basen klassen vil snart bli veldig stor, hevelse i størrelse hver gang enheter må kunne gjøre nye ting. Dette er ikke nødvendigvis feil, men jeg finner mindre, mer spesialiserte klasser for å være lettere å vedlikeholde.
Alternativt kan vi gå ned på roten av dyp arv og ha noe som helst EnemyShip utvider Ship extends ShootingEntity utvider Entity
. Igjen er denne tilnærmingen ikke feil, og vil også fungere ganske bra, men når du legger til flere typer enheter, vil du finne deg selv hele tiden nødt til å justere arvshierarkiet for å håndtere alle mulige scenarier, og du kan sette deg inn i et hjørne hvor en ny type enhet må ha funksjonaliteten til to forskjellige grunnklasser, som krever flere arv (som de fleste programmeringsspråk ikke tilbyr).
Jeg har brukt den dyre hierarki-tilnærmingen mange ganger selv, men jeg foretrekker egentlig Giant Blob-tilnærmingen, da minst alle enheter har et felles grensesnitt og nye enheter kan legges lettere (så hva hvis alle trærne har A * -oppdagelse? !)
Det er imidlertid en tredje vei ...
Hvis vi tenker på Asteroids-problemet når det gjelder ting som objekter måtte trenge, kan vi få en liste som dette:
bevege seg()
skyte()
takeDamage ()
dø()
render ()
I stedet for å utarbeide et komplisert arven hierarki for hvilke objekter som kan gjøre hvilke ting, la oss modellere problemet når det gjelder komponenter som kan utføre disse handlingene.
For eksempel kan vi lage en Helse
klasse, med metodene takeDamage ()
, helbrede()
og dø()
. Deretter kan ethvert objekt som skal kunne ta skade og dø, "komponere" en forekomst av Helse
klasse - hvor "komponere" betyr i utgangspunktet "holde en referanse til sin egen forekomst av denne klassen".
Vi kunne opprette en annen klasse som heter Utsikt
å se etter gjengivelsesfunksjonaliteten, en som heter Kropp
å håndtere bevegelse og en kalt Våpen
å håndtere skyting.
De fleste Entity-systemer er basert på prinsippet beskrevet ovenfor, men varierer i hvordan du får tilgang til funksjonalitet som finnes i en komponent.
For eksempel er en tilnærming å speile API for hver komponent i Entity, så et enhet som kan ta skade ville ha en takeDamage ()
fungere som seg selv bare ringer til takeDamage ()
funksjon av sin Helse
komponent.
Klasse Enhet privat var _helse: Helse; // ... annen kode ... // offentlig funksjon takeDamage (dmg: int) _health.takeDamage (dmg);
Du må da opprette et grensesnitt som heter noe som IHealth
for din enhet å implementere, slik at andre objekter kan få tilgang til takeDamage ()
funksjon. Slik hjelper en Java OOP-guide deg til å gjøre det.
getComponent ()
En annen tilnærming er å bare lagre hver komponent i en nøkkelverdi oppslag, slik at hver enhet har en funksjon som heter noe som getComponent ( "componentName")
som returnerer en referanse til den aktuelle komponenten. Du må da kaste referansen du kommer tilbake til typen komponent du vil ha - noe som:
var helse: Helse = Helse (getComponent ("Helse"));
Dette er i utgangspunktet hvordan Unitys enhet / oppførsel fungerer. Den er veldig fleksibel, fordi du kan fortsette å legge til nye typer komponenter uten å endre grunnklassen, eller opprette nye undergrupper eller grensesnitt. Det kan også være nyttig når du vil bruke konfigurasjonsfiler for å opprette enheter uten å kompilere koden din, men jeg vil la det til noen andre finne ut.
Tilgangen jeg favoriserer er å la alle enheter ha en offentlig eiendom for hver større type komponent, og la feltene null hvis enheten ikke har den funksjonaliteten. Når du vil ringe en bestemt metode, kommer du bare inn i enheten for å få komponenten med den funksjonaliteten - for eksempel, ring enemy.health.takeDamage (5)
å angripe en fiende.
Hvis du prøver å ringe health.takeDamage ()
på et foretak som ikke har en Helse
komponent, den vil kompilere, men du får en runtime feil som forteller deg at du har gjort noe dumt. I praksis skjer dette sjelden, da det er ganske opplagt hvilke typer enheter som vil ha hvilke komponenter (for eksempel, et tre har selvfølgelig ikke et våpen!).
Noen strenge OOP-advokater kan hevde at min tilnærming bryter noen OOP-prinsipper, men jeg finner det fungerer veldig bra, og det er en veldig god presedent fra Adobe Flash-historien.
I ActionScript 2, Filmklipp
klassen hadde metoder for å tegne vektorgrafikk: for eksempel kan du ringe myMovieClip.lineTo ()
å tegne en linje. I ActionScript 3 ble disse tegne metodene flyttet til grafikk
klasse, og hver Filmklipp
får en grafikk
komponent, som du får tilgang til ved å ringe, for eksempel, myMovieClip.graphics.lineTo ()
på samme måte som jeg beskrev for enemy.health.takeDamage ()
. Hvis det er bra nok for ActionScript-språkdesignerne, er det bra nok for meg.
Nedenfor skal jeg detaljere en svært forenklet versjon av systemet jeg bruker på tvers av alle spillene mine. Når det gjelder hvordan forenklet, er det noe som 300 linjer med kode for dette, sammenlignet med 6000 for min fulle motor. Men vi kan faktisk gjøre ganske mye med bare disse 300 linjene!
Jeg har igjen i akkurat nok funksjonalitet for å skape et fungerende spill, samtidig som koden holdes så kort som mulig, slik at det er lettere å følge. Koden kommer til å være i ActionScript 3, men en lignende struktur er mulig på de fleste språk. Det er noen offentlige variabler som kan være egenskaper (det vil si sette seg bak få
og sett
Accessor-funksjoner), men da dette er ganske ordentlig i ActionScript, har jeg forlatt dem som offentlige variabler for enkel lesing.
IEntity
InterfaceLa oss begynne med å definere et grensesnitt som alle enheter skal implementere:
pakkemotor import org.osflash.signals.Signal; / ** * ... * @author Iain Lobb - [email protected] * / offentlig grensesnitt IEntity // ACTIONS funksjon ødelegge (): void; funksjon oppdatering (): void; funksjon gjengivelse (): void; // COMPONENTS funksjonen få kropp (): Kropp; funksjonssett kropp (verdi: kropp): tomrom; funksjon få fysikk (): Fysikk; funksjonssett fysikk (verdi: fysikk): ugyldighetsfunksjon få helse (): Helsefunksjon sett helse (verdi: helse): ugyldighetsfunksjon få våpen (): Våpen; funksjonssett våpen (verdi: våpen): tomrom; funksjon få se (): Vis; funksjonsinnstilling (verdi: visning): tomrom; // SIGNALS funksjonen få entityCreated (): Signal; funksjonssett entityCreated (verdi: Signal): void; funksjonen blir ødelagt (): Signal; funksjonssett ødelagt (verdi: Signal): tomrom; // DEPENDENCIES funksjonen få mål (): Vector.; Funksjonsmål (verdi: Vector. ):tomrom; funksjonen få gruppe (): Vector. ; Funksjonsgruppe (verdi: Vector. ):tomrom;
Alle enheter kan utføre tre handlinger: du kan oppdatere dem, gjøre dem og ødelegge dem.
De har hver "slots" for fem komponenter:
kropp
, håndtering posisjon og størrelse.fysikk
, håndtering bevegelse.Helse
, Håndtering blir skadet.våpen
, håndtering angrep.utsikt
, slik at du kan gjengi enheten.Alle disse komponentene er valgfrie og kan være null, men i praksis vil de fleste enheter ha minst et par komponenter.
Et stykke statisk natur som spilleren ikke kan samhandle med (kanskje et tre, for eksempel), trenger bare en kropp og en visning. Det ville ikke trenge fysikk ettersom det ikke beveger seg, det ville ikke trenge helse, da du ikke kan angripe det, og det ville absolutt ikke trenge et våpen. Spillerens skip i Asteroider, derimot, vil trenge alle fem komponentene, da det kan bevege seg, skyte og bli skadet.
Ved å konfigurere disse fem grunnleggende komponentene, kan du opprette mest enkle objekter du trenger. Noen ganger vil de ikke være nok, og på det tidspunktet kan vi enten utvide de grunnleggende komponentene, eller opprette nye flere - begge diskuteres senere.
Deretter har vi to signaler: entityCreated
og ødelagt
.
Signaler er et åpen kildekode-alternativ til ActionScript sine innfødte hendelser, opprettet av Robert Penner. De er veldig hyggelige å bruke, ettersom de lar deg sende data mellom senderen og lytteren uten å måtte lage mange tilpassede hendelsesklasser. For mer informasjon om hvordan du bruker dem, sjekk ut dokumentasjonen.
De entityCreated
Signal lar en enhet fortelle spillet at det er en annen ny enhet som må legges til - et klassisk eksempel er når et pistol skaper en kulde. De ødelagt
Signal lar spillet (og andre lytteobjekter) vite at denne enheten er ødelagt.
Til slutt har enheten to andre valgfrie avhengigheter: mål
, som er en liste over enheter som det kanskje vil angripe, og gruppe
, som er en liste over enheter som den tilhører. For eksempel kan et spillerskip ha en liste over mål, som ville være alle fiender i spillet, og kan tilhøre en gruppe som også inneholder andre spillere og vennlige enheter.
Entity
KlasseLa oss nå se på Entity
klasse som implementerer dette grensesnittet.
pakkemotor import org.osflash.signals.Signal; / ** * ... * @author Iain Lobb - [email protected] * / offentlig klasse Entity implementerer IEntity private var _body: Body; privat var _fysikk: fysikk; privat var _helse: Helse; privat var _weapon: våpen; privat var _view: View; privat varighet; opprettet: signal; privat var _destroyed: Signal; private var _mål: Vector.; privat var _group: Vector. ; / * * Alt som eksisterer i spillet ditt er en Entity! * / offentlig funksjon Entity () entityCreated = nytt signal (Entity); ødelagt = nytt signal (enhet); offentlig funksjon ødelegge (): void destroyed.dispatch (this); hvis (gruppe) group.splice (group.indexOf (this), 1); offentlig funksjon oppdatering (): void if (physics) physics.update (); offentlig funksjon gjengi (): void if (view) view.render (); Offentlig funksjon få kropp (): Kropp return _body; Offentlig funksjon sett kropp (verdi: Kropp): void _body = value; offentlig funksjon få fysikk (): fysikk return _physics; offentlig funksjon sett fysikk (verdi: fysikk): void _physics = value; offentlig funksjon få helse (): helse return _health; offentlig funksjon sett helse (verdi: helse): void _health = value; offentlig funksjon få våpen (): våpen retur _weapon; offentlig funksjon satt våpen (verdi: våpen): void _weapon = value; offentlig funksjon få se (): Se return _view; offentlig funksjonsinnstilling (verdi: visning): void _view = value; offentlig funksjon få entityCreated (): Signal return _entityCreated; offentlig funksjon sett enhetCreated (verdi: Signal): void _entityCreated = value; Offentlig funksjon bli ødelagt (): Signal return _destroyed; Offentlig funksjon sett ødelagt (verdi: Signal): void _destroyed = value; offentlig funksjon få mål (): Vector. return _targets; offentlige funksjonssett mål (verdi: Vector. ): void _targets = value; offentlig funksjon få gruppe (): Vector. return _group; offentlig funksjon satt gruppe (verdi: Vector. ): void _group = value;
Det ser lenge ut, men det meste er bare de gode ordene for getter og setter (boo!). Den viktige delen å se på er de fire første funksjonene: Konstruktøren, hvor vi lager våre Signaler; ødelegge()
, hvor vi sender det ødelagte signalet og fjerner enheten fra gruppelisten; Oppdater()
, der vi oppdaterer noen komponenter som trenger å opptre hver spillsløyfe, men i dette enkle eksempelet er dette bare fysikk
komponent - og til slutt render ()
, hvor vi forteller visningen å gjøre dens ting.
Du vil legge merke til at vi ikke automatiserer komponentene automatisk her i Entity-klassen - dette er fordi, som jeg forklarte tidligere, er hver komponent valgfri.
La oss nå se på komponentene en etter en. Først, kroppsdelen:
pakkemotor / ** * ... * @author Iain Lobb - [email protected] * / offentlig klasse Body public var entity: Entity; offentlig var x: tall = 0; offentlig var y: tall = 0; offentlig varvinkel: tall = 0; offentlig var radius: tall = 10; / * * Hvis du gir en enhet en kropp, kan den ta fysisk form i verden, * selv om du vil se det, trenger du en visning. * / offentlig funksjon Body (entity: Entity) this.entity = entity; Offentlig funksjon testCollision (annenEntity: Entity): Boolean var dx: Number; var dy: nummer; dx = x - otherEntity.body.x; dy = y - otherEntity.body.y; returner Math.sqrt ((dx * dx) + (dy * dy)) <= radius + otherEntity.body.radius;
Alle komponentene våre trenger en referanse til eierens enhet, som vi sender til konstruktøren. Kroppen har da fire enkle felt: en x- og y-posisjon, en rotasjonsvinkel og en radius for å lagre størrelsen. (I dette enkle eksemplet er alle enheter sirkulære!)
Denne komponenten har også en enkelt metode: testCollision ()
, som bruker Pythagoras til å beregne avstanden mellom to enheter, og sammenligner dette med deres kombinerte radius. (Mer info her.)
Neste, la oss se på fysikk
komponent:
pakkemotor / ** * ... * @author Iain Lobb - [email protected] * / offentlig klasse Fysikk public var entity: Entity; offentlig var dra: tall = 1; offentlig var hastighetX: tall = 0; offentlig var hastighet: Nummer = 0; / * * Gir et grunnleggende fysikk trinn uten kollisjon gjenkjenning. * Utvid for å legge til kollisionshåndtering. * / offentlig funksjon Fysikk (enhet: Entitet) this.entity = entity; offentlig funksjon oppdatering (): void entity.body.x + = velocityX; entity.body.y + = hastighetY; hastighetX * = dra; hastighet * * dra; offentlig funksjonstrykk (kraft: tall): void velocityX + = Math.sin (-entity.body.angle) * kraft; hastighetY + = Math.cos (-entity.body.angle) * kraft;
Ser på Oppdater()
funksjon, kan du se at velocityX
og velocityY
verdier legges på enhetens posisjon, som beveger det, og hastigheten multipliseres med dra
, noe som gradvis reduserer objektet nedover. De fremstøt()
funksjonen gir en rask måte å akselerere enheten i retningen den står overfor.
Neste, la oss se på Helse
komponent:
pakkemotor import org.osflash.signals.Signal; / ** * ... * @author Iain Lobb - [email protected] * / offentlig klasse Helse public var entity: Entity; offentlige var treff: int; offentlig var død: Signal; Offentlig var skadet: Signal; offentlig funksjon Helse (enhet: Entitet) this.entity = entity; døde = nytt signal (enhet); skade = nytt signal (enhet); offentlig funksjon hit (skade: int): void hits - = damage; hurt.dispatch (enhet); hvis (treff < 0) died.dispatch(entity);
De Helse
komponent har en funksjon som kalles truffet()
, slik at enheten kan bli skadet. Når dette skjer, vil treff
verdien reduseres, og eventuelle lyttingsobjekter blir varslet ved å sende skade
Signal. Hvis treff
er mindre enn null, enheten er død og vi sender ut døde
Signal.
La oss se hva som er inni Våpen
komponent:
pakkemotor import org.osflash.signals.Signal; / ** * ... * @author Iain Lobb - [email protected] * / offentlig klasse Våpen public var entity: Entity; offentlig var ammo: int; / * * Våpen er grunnklassen for alle våpen. * / offentlig funksjon Våpen (enhet: Entitet) this.entity = entity; offentlig funksjon brann (): void ammo--;
Ikke mye her! Det er fordi dette er egentlig bare en grunnklasse for de faktiske våpnene - som du vil se i Våpen
eksempel senere. Det er en Brann()
Metode som underklasser bør overstyre, men her reduseres bare verdien av ammo
.
Den endelige komponenten å undersøke er Utsikt
:
pakkemotor import flash.display.Sprite; / ** * ... * @author Iain Lobb - [email protected] * / offentlig klasse Vis public var entity: Entity; offentlig var skala: tall = 1; offentlig var alfa: tall = 1; offentlig var sprite: Sprite; / * * Vis er skjermkomponent som gjør en Enhet ved hjelp av standard visningsliste. * / offentlig funksjon Vis (enhet: Entitet) this.entity = entity; offentlig funksjon gjengivelse (): void sprite.x = entity.body.x; sprite.y = entity.body.y; sprite.rotation = entity.body.angle * (180 / Math.PI); sprite.alpha = alpha; sprite.scaleX = skala; sprite.scaleY = skala;
Denne komponenten er veldig spesifikk for Flash. Hovedarrangementet her er render ()
funksjon, som oppdaterer et Flash-sprite med kroppens posisjon og rotasjonsverdier, og alfa- og skalaverdiene det lagrer seg selv. Hvis du vil bruke et annet gjengisystem som copyPixels
blitting eller Stage3D (eller et system som er relevant for et annet valg av plattform), vil du tilpasse denne klassen.
Spill
KlasseNå vet vi hva en enhet og alle dens komponenter ser ut som. Før vi begynner å bruke denne motoren til å lage et eksempelspill, la oss se på det endelige stykket av motoren - Spillklassen som styrer hele systemet:
pakkemotor import flash.display.Sprite; importer flash.display.Stage; importere flash.events.Event; / ** * ... * @author Iain Lobb - [email protected] * / offentlig klasse Spillet utvider Sprite public var entities: Vector.= Ny Vector. (); offentlig var erPaused: boolsk; statisk offentlig var fase: scenen; / * * Spillet er grunnklassen for spill. * / offentlig funksjon Spill () addEventListener (Event.ENTER_FRAME, onEnterFrame); addEventListener (Event.ADDED_TO_STAGE, påAddedToStage); beskyttet funksjon onEnterFrame (event: Event): void if (isPaused) return; Oppdater(); render (); beskyttet funksjon oppdatering (): void for hver (var enhet: enhet i enheter) entity.update (); beskyttet funksjon gjengivelse (): void for hver (var enhet: enhet i enheter) entity.render (); beskyttet funksjon onAddedToStage (event: Event): void Game.stage = stage; start spill(); beskyttet funksjon startGame (): void beskyttet funksjon stopGame (): void for hver (var enhet: Enhet i enheter) hvis (entity.view) removeChild (entity.view.sprite); entities.length = 0; offentlig funksjon addEntity (enhet: Entity): Entity entities.push (entity); entity.destroyed.add (onEntityDestroyed); entity.entityCreated.add (addEntity); hvis (entity.view) addChild (entity.view.sprite); returnere enhet; beskyttet funksjon onEntityDestroyed (enhet: Entity): void entities.splice (entities.indexOf (entity), 1); hvis (entity.view) removeChild (entity.view.sprite); entity.destroyed.remove (onEntityDestroyed);
Det er mye implementeringsdetaljer her, men la oss bare plukke ut høydepunktene.
Hver ramme, den Spill
klasse looper gjennom alle enhetene, og kaller deres oppdaterings- og gjengivelsesmetoder. I addEntity
funksjon, legger vi til den nye enheten i enhetslisten, lytter til signaler, og hvis den har en visning, legger du til sprite til scenen.
Når onEntityDestroyed
utløses, fjerner vi enheten fra listen og fjerner sprite fra scenen. I stopGame
funksjon, som du bare ringer hvis du vil avslutte spillet, fjerner vi alle enheters sprites fra scenen og fjerner entitetslisten ved å sette lengden til null.
Wow, vi gjorde det! Det er hele spillmotoren! Fra dette utgangspunktet kan vi lage mange enkle 2D-arkadespill uten mye ekstra kode. I neste veiledning bruker vi denne motoren til å lage en Asteroids-stil space shoot-'em-up.