Het ordenen van je spelcode in componentgebaseerde entiteiten, in plaats van alleen te vertrouwen op klasse-overerving, is een populaire benadering in game-ontwikkeling. In deze zelfstudie zullen we bekijken waarom u dit zou kunnen doen en een eenvoudige game-engine opzetten met behulp van deze techniek.
In deze tutorial ga ik componentgebaseerde game-entiteiten verkennen, kijk waarom je ze misschien zou willen gebruiken en stel een pragmatische benadering voor om je teen in het water te dopen.
Omdat het een verhaal is over code-organisatie en architectuur, zal ik beginnen met het laten vallen van de gebruikelijke "uit de gevangenis" disclaimer: dit is slechts één manier om dingen te doen, het is niet "de enige manier" of misschien zelfs de beste manier, maar het zou voor u kunnen werken. Persoonlijk vind ik het leuk om zoveel mogelijk benaderingen te ontdekken en vervolgens uit te zoeken wat bij mij past.
In deze tweedelige tutorial maken we dit Asteroids-spel. (De volledige broncode is beschikbaar op GitHub.) In dit eerste deel zullen we ons concentreren op de kernbegrippen en de algemene game-engine.
In een spel als Asteroids hebben we misschien een paar basistypen op het scherm: kogels, asteroïden, spelersschepen en vijandenschepen. We kunnen deze basistypen voorstellen als vier afzonderlijke klassen, die elk alle code bevatten die we nodig hebben om dat object te tekenen, animeren, verplaatsen en besturen.
Hoewel dit werkt, is het misschien beter om de. Te volgen Do not Repeat Yourself (DRY) -principe en probeer een deel van de code tussen elke klasse opnieuw te gebruiken - de code voor het verplaatsen en tekenen van een opsomming zal immers sterk lijken op, of zelfs identiek zijn aan, de code om te verplaatsen en een tekening te maken asteroïde of een schip.
Dus we kunnen onze rendering- en bewegingsfuncties refactoren naar een basisklasse waar alles vanaf uitstrekt. Maar Schip
en EnemyShip
moet ook kunnen schieten. Op dit punt kunnen we de schieten
functioneer naar de basisklasse en creëer een "Giant Blob" -klasse die in principe alles kan, en zorg ervoor dat asteroïden en kogels hun schieten
functie. Deze basisklasse zou snel erg groot worden en steeds groter worden als entiteiten nieuwe dingen moeten kunnen doen. Dit is niet noodzakelijk verkeerd, maar ik vind kleinere, meer gespecialiseerde klassen gemakkelijker te onderhouden.
Als alternatief kunnen we de wortel van diepe erfenis achterhalen en zoiets als hebben EnemyShip breidt uit met Ship extends ShootingEntity breidt Entity uit
. Nogmaals, deze aanpak is niet verkeerd en zal ook behoorlijk goed werken, maar naarmate je meer soorten entiteiten toevoegt, zul je merken dat je constant de overervingshiërarchie opnieuw moet afstellen om alle mogelijke scenario's af te handelen, en je kunt jezelf een hoekje inleggen waarbij een nieuw type entiteit de functionaliteit van twee verschillende basisklassen moet hebben, waarvoor meerdere overerving vereist is (die de meeste programmeertalen niet bieden).
Ik heb mezelf vaak de diepe hiërarchiebenadering gebruikt, maar ik geef eigenlijk de voorkeur aan de Giant Blob-benadering, want dan hebben alle entiteiten een gemeenschappelijke interface en kunnen nieuwe entiteiten gemakkelijker worden toegevoegd (dus wat als al je bomen een * padvinden hebben? !)
Er is echter een derde manier ...
Als we denken aan het Asteroids-probleem in termen van dingen die objecten mogelijk moeten doen, kunnen we een lijst als deze krijgen:
bewegen ()
schieten()
takeDamage ()
dood gaan()
render ()
In plaats van een gecompliceerde overervingshiërarchie uit te werken waarvoor objecten die dingen kunnen doen, laten we het probleem modelleren in termen van componenten die deze acties kunnen uitvoeren.
We kunnen bijvoorbeeld een maken Gezondheid
klasse, met de methoden takeDamage ()
, genezen()
en dood gaan()
. Elk object dat schade moet kunnen oplopen en kan sterven, kan een exemplaar van het bestand 'samenstellen' Gezondheid
class - waarbij "componeren" in feite betekent "een verwijzing naar zijn eigen exemplaar van deze klasse houden".
We zouden een andere klasse kunnen maken genaamd Uitzicht
om te zorgen voor de renderingfunctionaliteit, één opgeroepen Lichaam
om bewegingen te verwerken en één genaamd Wapen
om opnamen te maken.
De meeste Entity-systemen zijn gebaseerd op het hierboven beschreven principe, maar verschillen in hoe u de functionaliteit van een component opent.
Een benadering is bijvoorbeeld om de API van elke component in de entiteit te spiegelen, dus een entiteit die schade kan oplopen, heeft een takeDamage ()
functioneer dat zelf belt gewoon de takeDamage ()
functie van zijn Gezondheid
bestanddeel.
klasse Entiteit private var _health: Health; // ... andere code ... // openbare functie takeDamage (dmg: int) _health.takeDamage (dmg);
Je moet dan een interface maken die zoiets heet iHealth
voor uw entiteit om te implementeren, zodat andere objecten toegang hebben tot de takeDamage ()
functie. Dit is hoe een Java OOP-gids u kan adviseren om het te doen.
getComponent ()
Een andere benadering is om eenvoudig elke component op te slaan in een sleutelwaarde-opzoeking, zodat elke entiteit een functie heeft die zoiets heet getComponent ( "componentnaam")
die een verwijzing naar de specifieke component retourneert. Je moet dan de referentie gebruiken om terug te komen naar het type component dat je wilt - zoiets als:
var health: Health = Health (getComponent ("Health"));
Dit is in feite hoe Unity's entiteit / gedragssysteem werkt. Het is heel flexibel, omdat je nieuwe soorten componenten kunt blijven toevoegen zonder je basisklasse te veranderen of nieuwe subklassen of interfaces te maken. Het kan ook handig zijn als u configuratiebestanden wilt gebruiken om entiteiten te maken zonder uw code opnieuw te compileren, maar ik laat dat over aan iemand anders om erachter te komen.
De benadering die ik prefereer is om alle entiteiten een openbare eigenschap voor elk hoofdtype component te laten hebben en de velden null te laten als de entiteit die functionaliteit niet heeft. Wanneer u een bepaalde methode wilt aanroepen, bereikt u eenvoudigweg de entiteit om het onderdeel met die functionaliteit te krijgen, bijvoorbeeld enemy.health.takeDamage (5)
om een vijand aan te vallen.
Als je probeert te bellen health.takeDamage ()
op een entiteit die geen a heeft Gezondheid
component compileert, maar je krijgt een runtime-foutmelding die je laat weten dat je iets raars hebt gedaan. In de praktijk gebeurt dit zelden, omdat het vrij duidelijk is welke soorten entiteiten welke componenten zullen hebben (bijvoorbeeld, een boom heeft natuurlijk geen wapen!).
Sommige strenge OOP-voorstanders zouden kunnen beweren dat mijn aanpak sommige OOP-principes overtreedt, maar ik vind dat het heel goed werkt, en er is een echt goed precedent uit de geschiedenis van Adobe Flash.
In ActionScript 2, de Filmclip
klasse had methoden voor het tekenen van vectorafbeeldingen: je zou bijvoorbeeld kunnen bellen myMovieClip.lineTo ()
om een lijn te tekenen. In ActionScript 3 zijn deze tekenmethoden verplaatst naar de grafiek
klasse en elk Filmclip
krijgt een grafiek
component, die u bijvoorbeeld opent door te bellen, myMovieClip.graphics.lineTo ()
op dezelfde manier als waarvoor ik heb beschreven enemy.health.takeDamage ()
. Als het goed genoeg is voor de taalontwerpers van ActionScript, is het goed genoeg voor mij.
Hieronder ga ik een zeer vereenvoudigde versie van het systeem beschrijven dat ik gebruik in al mijn spellen. In termen van hoe vereenvoudigd, het is zoiets als 300 regels code hiervoor, vergeleken met 6.000 voor mijn volledige motor. Maar we kunnen eigenlijk heel veel doen met slechts deze 300 lijnen!
Ik heb net genoeg functionaliteit overgehouden om een werkgame te maken, terwijl ik de code zo kort mogelijk houd, zodat het gemakkelijker te volgen is. De code bevindt zich in ActionScript 3, maar een vergelijkbare structuur is mogelijk in de meeste talen. Er zijn een paar openbare variabelen die eigenschappen kunnen zijn (dat wil zeggen achteropgezet krijgen
en reeks
accessor-functies), maar omdat dit nogal uitgebreid is in ActionScript, heb ik ze als openbare variabelen gelaten om het lezen te vergemakkelijken.
IEntity
InterfaceLaten we beginnen met het definiëren van een interface die alle entiteiten zullen implementeren:
pakket engine import org.osflash.signals.Signal; / ** * ... * @auteur Iain Lobb - [email protected] * / public interface IEntity // ACTIONS function destroy (): void; functie-update (): ongeldig; function render (): void; // COMPONENTS functie get body (): Body; function set body (value: Body): void; function get physics (): Physics; function set physics (waarde: Physics): void functie get health (): Health function set health (waarde: Health): void functie get weapon (): Weapon; functieset wapen (waarde: Wapen): ongeldig; functie get view (): Weergave; functieset weergave (waarde: weergave): ongeldig; // SIGNALS functie get entityCreated (): Signaal; functieset entityCreated (waarde: signaal): void; functie wordt vernietigd (): signaal; functieset vernietigd (waarde: signaal): ongeldig; // DEPENDENCIES-functie krijgt doelen (): Vector.; functieset doelen (waarde: Vector. ): Void; functie get group (): Vector. ; functiesetgroep (waarde: Vector. ): Void;
Alle entiteiten kunnen drie acties uitvoeren: u kunt ze bijwerken, ze renderen en vernietigen.
Ze hebben elk "slots" voor vijf componenten:
lichaam
, hantering van positie en afmeting.fysica
, omgaan met beweging.Gezondheid
, omgaan met gewond raken.wapen
, omgaan met aanvallen.uitzicht
, zodat je de entiteit kunt renderen.Al deze componenten zijn optioneel en kunnen nul blijven, maar in de praktijk zullen de meeste entiteiten ten minste een aantal componenten bevatten.
Een stuk statisch landschap waar de speler geen interactie mee heeft (bijvoorbeeld een boom, bijvoorbeeld), zou alleen een lichaam en een weergave nodig hebben. Het zou geen natuurkunde nodig hebben, het beweegt niet, het zou geen gezondheid nodig hebben, je kunt het niet aanvallen en het zou zeker geen wapen nodig hebben. Het schip van de speler in Asteroids, aan de andere kant, zou alle vijf de componenten nodig hebben, omdat het kan bewegen, schieten en gewond raken.
Door deze vijf basiscomponenten te configureren, kunt u de meeste eenvoudige objecten maken die u mogelijk nodig hebt. Soms zijn ze echter niet genoeg, en op dat moment kunnen we de basiscomponenten uitbreiden of nieuwe extra componenten maken - die we later zullen bespreken.
Vervolgens hebben we twee signalen: entityCreated
en vernietigd
.
Signalen zijn een open source alternatief voor de native events van ActionScript, gemaakt door Robert Penner. Ze zijn erg leuk om te gebruiken omdat ze u in staat stellen om gegevens door te sturen tussen de dispatcher en de luisteraar zonder dat u veel aangepaste gebeurtenisklassen hoeft te maken. Raadpleeg de documentatie voor meer informatie over het gebruik ervan.
De entityCreated
Signaal stelt een entiteit in staat om het spel te vertellen dat er nog een nieuwe entiteit is die moet worden toegevoegd - een klassiek voorbeeld is wanneer een geweer een kogel creëert. De vernietigd
Signaal laat de game (en alle andere luisterobjecten) weten dat deze entiteit vernietigd is.
Ten slotte heeft de entiteit nog twee andere optionele afhankelijkheden: doelen
, wat een lijst is van entiteiten die het zou willen aanvallen, en groep
, Dit is een lijst met entiteiten waarvan het deel uitmaakt. Een spelersschip heeft bijvoorbeeld een lijst met doelen, die alle vijanden in het spel zijn, en mogelijk tot een groep behoren die ook andere spelers en vriendelijke eenheden bevat.
Entiteit
KlasseLaten we nu kijken naar de Entiteit
klasse die deze interface implementeert.
pakket engine import org.osflash.signals.Signal; / ** * ... * @auteur Iain Lobb - [email protected] * / openbare klasse Entity implementeert IEntity private var _body: Body; private var _physics: Physics; private var _health: Gezondheid; privé var _weapon: Wapen; private var _view: View; privé var _entityCreated: Signaal; privé var _destroyed: signaal; privé var _targets: Vector.; private var _group: Vector. ; / * * Alles wat in je spel bestaat, is een entiteit! * / public function Entity () entityCreated = new Signal (Entity); destroyed = nieuw Signaal (entiteit); 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 function set body (value: Body): void _body = value; public function get physics (): Physics return _physics; public function set physics (waarde: Physics): void _physics = value; public function get health (): Health return _health; public function set health (value: Health): void _health = value; public function get weapon (): Weapon return _weapon; public function set weapon (waarde: Weapon): void _weapon = value; public function get view (): View return _view; openbare functieset weergave (waarde: weergave): 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; openbare functieset vernietigd (waarde: signaal): void _destroyed = value; public function get targets (): Vector. return _targets; openbare functiesetdoelen (waarde: Vector. ): void _targets = value; public function get group (): Vector. return _group; openbare functiesetgroep (waarde: Vector. ): void _group = value;
Het ziet er lang uit, maar het zijn vooral die uitgebreide getter- en setterfuncties (boe!). Het belangrijkste onderdeel om naar te kijken is de eerste vier functies: de constructor, waar we onze signalen creëren; vernietigen()
, waar we het vernietigde signaal verzenden en de entiteit van zijn groepslijst verwijderen; bijwerken()
, waar we alle componenten bijwerken die elke gamelus moeten uitvoeren - hoewel in dit eenvoudige voorbeeld dit alleen het fysica
component - en tot slot render ()
, waar we het uitzicht vertellen om zijn ding te doen.
U zult opmerken dat we de componenten hier niet automatisch in de Entity-klasse instantiëren - dit komt omdat, zoals ik eerder heb uitgelegd, elk onderdeel optioneel is.
Laten we nu de componenten één voor één bekijken. Eerst de lichaamscomponent:
pakket engine / ** * ... * @auteur Iain Lobb - [email protected] * / public class Body public var entity: Entity; public var x: Number = 0; public var y: Number = 0; public var angle: Number = 0; public var radius: Number = 10; / * * Als je een entiteit een lichaam geeft, kan het een fysieke vorm aannemen in de wereld *, hoewel je het moet kunnen zien als je het wilt zien. * / public function Body (entity: Entity) 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;
Al onze componenten hebben een verwijzing nodig naar hun eigenaarentiteit, die we doorgeven aan de constructeur. Het lichaam heeft dan vier eenvoudige velden: een x- en y-positie, een rotatiehoek en een straal om de grootte op te slaan. (In dit eenvoudige voorbeeld zijn alle entiteiten rond!)
Dit onderdeel heeft ook een enkele methode: TestCollision ()
, waarbij Pythagoras wordt gebruikt om de afstand tussen twee entiteiten te berekenen en deze te vergelijken met hun gecombineerde straal. (Meer info hier.)
Laten we vervolgens kijken naar de Fysica
component:
pakketmotor / ** * ... * @auteur Iain Lobb - [email protected] * / public class Physics public var entity: Entity; public var drag: Number = 1; public var velocityX: Number = 0; public var velocityY: Number = 0; / * * Biedt een eenvoudige natuurkundige stap zonder botsingsdetectie. * Uitbreiden om botsing afhandeling toe te voegen. * / public function Physics (entiteit: Entiteit) this.entity = entity; public function update (): void entity.body.x + = velocityX; entity.body.y + = velocityY; velocityX * = slepen; velocityY * = slepen; openbare functie-stuwkracht (vermogen: getal): void velocityX + = Math.sin (-entity.body.angle) * vermogen; velocityY + = Math.cos (-entity.body.angle) * vermogen;
Kijken naar de bijwerken()
functie, je kunt zien dat de velocityX
en velocityY
waarden worden toegevoegd aan de positie van de entiteit, die deze verplaatst, en de snelheid wordt vermenigvuldigd met slepen
, wat het effect heeft dat het object geleidelijk wordt vertraagd. De stuwkracht ()
functie maakt een snelle manier mogelijk om de entiteit te versnellen in de richting waarin deze wordt geconfronteerd.
Laten we vervolgens kijken naar de Gezondheid
component:
pakket engine import org.osflash.signals.Signal; / ** * ... * @auteur Iain Lobb - [email protected] * / public class Health public var entity: Entity; public var hits: int; public var died: Signal; public var pained: Signal; publieke functie Gezondheid (entiteit: Entiteit) this.entity = entity; gestorven = nieuw signaal (entiteit); pijn = nieuw signaal (entiteit); openbare functie hit (damage: int): void hits - = damage; hurt.dispatch (entiteit); als (hits < 0) died.dispatch(entity);
De Gezondheid
component heeft een functie genaamd raken()
, waardoor de entiteit gewond kan raken. Wanneer dit gebeurt, de treffers
de waarde wordt verlaagd en eventuele luisterobjecten worden op de hoogte gebracht door de pijn doen
Signaal. Als treffers
zijn minder dan nul, de entiteit is dood en we verzenden de ging dood
Signaal.
Laten we eens kijken wat er in zit Wapen
component:
pakket engine import org.osflash.signals.Signal; / ** * ... * @auteur Iain Lobb - [email protected] * / public class Weapon public var entity: Entity; public var munitie: int; / * * Wapen is de basisklasse voor alle wapens. * / public function Weapon (entity: Entity) this.entity = entity; public function fire (): void ammo--;
Hier niet veel! Dat komt omdat dit eigenlijk gewoon een basisklasse is voor de eigenlijke wapens - zoals je zult zien in de geweer
voorbeeld later. Er is een brand()
methode die subklassen moeten overbruggen, maar hier wordt gewoon de waarde van verminderd ammunitie
.
Het laatste onderdeel om te onderzoeken is Uitzicht
:
pakket engine import flash.display.Sprite; / ** * ... * @auteur Iain Lobb - [email protected] * / public class View public var entity: Entity; public var scale: Number = 1; public var alpha: Number = 1; public var sprite: Sprite; / * * Weergave is weergavecomponent waardoor een entiteit wordt weergegeven met behulp van de standaard weergavelijst. * / public function View (entity: Entity) this.entity = entity; public function render (): void sprite.x = entity.body.x; sprite.y = entity.body.y; sprite.rotation = entity.body.angle * (180 / Math.PI); sprite.alpha = alpha; sprite.scaleX = schaal; sprite.scaleY = schaal;
Dit onderdeel is heel specifiek voor Flash. Het belangrijkste evenement hier is het render ()
functie, die een Flash-sprite bijwerkt met de positie- en rotatiewaarden van het lichaam en de alfa- en schaalwaarden die het zelf opslaat. Als u een ander weergavesysteem wilt gebruiken zoals copyPixels
blitting of Stage3D (of zelfs een systeem dat relevant is voor een andere platformkeuze), zou je deze klasse aanpassen.
Spel
KlasseNu weten we hoe een entiteit en alle onderdelen er uit zien. Voordat we deze engine gaan gebruiken om een voorbeeldspel te maken, laten we het laatste stuk van de engine bekijken: de klasse Game die het hele systeem bestuurt:
pakket engine import flash.display.Sprite; import flash.display.Stage; import flash.events.Event; / ** * ... * @auteur Iain Lobb - [email protected] * / public class Game breidt uit public var entities: Vector.= nieuwe Vector. (); public var isPaused: Boolean; static public var stage: Stage; / * * Game is de basisklasse voor games. * / public function Game () addEventListener (Event.ENTER_FRAME, onEnterFrame); addEventListener (Event.ADDED_TO_STAGE, onAddedToStage); beschermde functie onEnterFrame (event: Event): void if (isPaused) return; bijwerken(); render (); protected function update (): void for each (var entity: Entity in entities) entity.update (); protected function render (): void for each (var entity: Entity in entities) entity.render (); beschermde functie opAddedToStage (event: Event): void Game.stage = stage; start het spel(); beschermde functie startGame (): void beschermde functie stopGame (): void voor elk (var entiteit: Entiteit in entiteiten) 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); entiteit teruggeven; beschermde functie onEntityDestroyed (entity: Entity): void entities.splice (entities.indexOf (entity), 1); if (entity.view) removeChild (entity.view.sprite); entity.destroyed.remove (onEntityDestroyed);
Er zijn hier veel implementatiedetails, maar laten we gewoon de hoogtepunten uitzoeken.
Elk kader, de Spel
klasse doorloopt alle entiteiten en roept hun update- en weergavemethoden aan. In de addEntity
functie, we voegen de nieuwe entiteit toe aan de entiteitenlijst, luisteren naar zijn signalen en als hij een weergave heeft, voeg dan zijn sprite toe aan het podium.
Wanneer onEntityDestroyed
wordt geactiveerd, we verwijderen de entiteit uit de lijst en verwijderen de sprite ervan uit het werkgebied. In de StopGame
functie, die u alleen aanroept als u het spel wilt beëindigen, verwijderen we de sprites van alle entiteiten van het werkvlak en wissen de entiteitenlijst door de lengte in te stellen op nul.
Wauw, we hebben het gehaald! Dat is de hele game-engine! Vanaf dit startpunt konden we veel eenvoudige 2D-arcadegames maken zonder veel extra code. In de volgende zelfstudie gebruiken we deze engine om een ruimteschiet-em up van de asteroïdenstijl te maken.