I denne serien av opplæringsprogrammer viser jeg deg hvordan du lager en Geometry Wars-inspirert tvillingpokerskytespill, med neongrafik, galne partikkeleffekter og fantastisk musikk, for iOS med C ++ og OpenGL ES 2.0..
I stedet for å stole på et eksisterende spillramme eller sprite-bibliotek, forsøker vi å programmere så nær maskinvaren (eller "bare metall") som vi muligens kan. Siden enheter som kjører iOS, kjøres på mindre maskinvare enn en stasjonær PC eller spillkonsoll, vil dette gjøre det mulig for oss å få så mye bang for pengene som mulig.
Relaterte innleggMålet med disse opplæringsprogrammene er å gå over de nødvendige elementene som gjør at du kan lage ditt eget høykvalitets mobilspill for iOS, enten fra grunnen av eller basert på et eksisterende skrivebordsspill. Jeg oppfordrer deg til å laste ned og spille med koden, eller til og med å bruke den som grunnlag for dine egne prosjekter.
Vi vil dekke følgende emner i denne serien:
Her er hva vi får ved slutten av serien:
Og her er hva vi får ved slutten av denne første delen:
Musikken og lydeffekter du kan høre i disse videoene ble laget av RetroModular, og du kan lese om hvordan han gjorde det over på lydavsnittet.
De sprites er av Jacob Zinman-Jeanes, vår bosatt Tuts + designer.
Skriften vi skal bruke, er en bitmap-skrift (med andre ord, ikke en faktisk "skrifttype", men en bildefil), noe jeg har laget for denne opplæringen.
Alt illustrasjonen finnes i kildefilene.
La oss komme i gang.
Før vi dykker inn i spesifikasjonene til spillet, la oss snakke om verktøyet Bibliotek og program Bootstrap-kode jeg har gitt for å støtte utviklingen av spillet vårt.
Selv om vi primært skal bruke C ++ og OpenGL til å kode vårt spill, trenger vi noen ekstra verktøysklasser. Dette er alle klassene jeg har skrevet for å hjelpe utviklingen i andre prosjekter, så de er testet tid og brukbar for nye prosjekter som denne.
package.h
: En bekvemmelighetsoverskrift brukes til å inkludere alle relevante overskrifter fra verktøybiblioteket. Vi vil inkludere det ved å si #include "Utility / package.h"
uten å måtte inkludere noe annet.Vi vil utnytte noen eksisterende prøvde og sanne programmeringsmønstre som brukes i C ++ og andre språk.
tSingleton
: Implementerer en singleton klasse ved hjelp av et "Meyers Singleton" mønster. Det er mal basert, og utvidbart, slik at vi kan abonnere all singleton-kode til en enkelt klasse.tOptional
: Dette er en funksjon fra C ++ 14 (kalt std :: valgfritt
) som ikke er helt tilgjengelig i nåværende versjoner av C ++ ennå (vi er fortsatt på C + + 11). Det er også en funksjon tilgjengelig i XNA og C # (hvor den kalles ha nullverdier
.) Det tillater oss å ha "valgfrie" parametere for metoder. Den brukes i tSpriteBatch
klasse.Siden vi ikke bruker et eksisterende spillramme, trenger vi noen klasser for å håndtere matematikken bak kulissene.
tMath
: En statisk klasse-taht gir noen metoder utover det som er tilgjengelig i C ++, for eksempel konvertering fra grader til radianer eller avrundingstall til makter på to.tVector
: Et grunnleggende sett med Vector klasser, som gir 2-element, 3-element og 4-element varianter. Vi skriver også denne strukturen for poeng og farger.tMatrix
: To matriksdefinisjoner, en 2x2-variant (for rotasjonsoperasjoner) og et 4x4-alternativ (for projeksjonsmatrisen kreves for å få ting på skjermen),tRect
: En rektangelklasse som gir plassering, størrelse og en metode for å bestemme om poeng ligger i rektangler eller ikke.Selv om OpenGL er en kraftig API, er den C-basert, og administrerende objekter kan være noe vanskelig å gjøre i praksis. Så, vi har en liten håndfull klasser for å administrere OpenGL-objektene for oss.
tSurface
: Gir en måte å lage en bitmap basert på et bilde lastet fra programmets bunt.tTexture
: Wraps grensesnittet til OpenGLs tekstkommandoer, og belastninger tSurfaces
inn i teksturer.tShader
: Wraps grensesnittet til OpenGLs skyggekompiler, noe som gjør det enkelt å kompilere shaders.tProgram
: Wraps grensesnittet til OpenGLs shader programgrensesnitt, som egentlig er kombinasjonen av to tShader
klasser.Disse klassene representerer det nærmeste vi får til å ha et "spillramme"; De gir noen konsepter på høyt nivå som ikke er typiske for OpenGL, men som er nyttige for spillutviklingsformål.
tViewport
: Inneholder visningsportens status. Vi bruker dette primært til å håndtere endringer i enhetens orientering.tAutosizeViewport
: En klasse som styrer endringer i visningsporten. Den håndterer endringer i enhetsretningen direkte, og skalerer visningsporten slik at den passer på skjermen på enheten, slik at aspektforholdet forblir det samme - noe som betyr at ting ikke blir strukket eller knust.tSpriteFont
: Lar oss laste inn en "bitmap font" fra applikasjonspakken, og bruk den til å skrive tekst på skjermen.tSpriteBatch
: Inspirert av XNAs SpriteBatch
klasse, skrev jeg denne klassen for å inkapslere det beste av det som trengs av spillet vårt. Det tillater oss å sortere sprites når de tegnes på en slik måte at vi får best mulig hastighetsgevinst på maskinvaren vi har. Vi bruker også den direkte til å skrive tekst på skjermen.Et minimalt sett med klasser for å rulle ut ting.
tTimer
: En systemtimer, som primært brukes til animasjoner.tInputEvent
: Definisjoner av grunnleggende klasser for å gi orienteringsendringer (vippe enheten), berøre hendelser og et "virtuelt tastatur" -hendelse for å etterligne en gamepad mer diskret.tSound
: En klasse dedikert til å laste inn og spille av lydeffekter og musikk.Vi trenger også det jeg kaller "Boostrap" -kode, det vil si kode som abstrakterer hvordan et program starter, eller "støveler opp".
Her er hva som er i Bootstrap
:
AppDelegate
: Denne klassen håndterer programlansering, samt suspender og gjenoppta hendelser for når brukeren trykker på Hjem-knappen.ViewController
: Denne klassen håndterer hendelser for enhet orientering, og lager vår OpenGL-visningOpenGLView
: Denne klassen initialiserer OpenGL, forteller enheten å oppdatere med 60 bilder per sekund, og håndterer berøringshendelser.I denne opplæringen vil vi lage en tvillingpinne shooter; spilleren vil styre skipet ved hjelp av multi-touch kontroller på skjermen.
Vi bruker en rekke klasser for å oppnå dette:
Entity
: Baseklassen for fiender, kuler og spillerens skip. Enheter kan bevege seg og bli tegnet.Kule
og PlayerShip
.EntityManager
: Holder styr på alle enheter i spillet og utfører kollisjonsdeteksjon.Input
: Hjelper med å administrere innspill fra berøringsskjermen.Kunst
: Laster og inneholder referanser til teksturen som trengs for spillet.Lyd
: Laster og holder referanser til lydene og musikken.MathUtil
og utvidelser
: Inneholder noen nyttige statiske metoder ogGameRoot
: Kontrollerer hovedløkken i spillet. Dette er vår hovedklasse.Koden i denne opplæringen tar sikte på å være enkel og lett å forstå. Det vil ikke ha alle funksjoner designet for å støtte alle mulige behov; heller, det vil bare gjøre hva den trenger å gjøre. Å holde det enkelt gjør det lettere for deg å forstå konseptene, og deretter endre og utvide dem til ditt eget unike spill.
Åpne det eksisterende Xcode-prosjektet. GameRoot er vår søknad hovedklasse.
Vi starter med å lage en grunnklasse for våre spill enheter. Ta en titt på
klassenhet offentlig: enum Kind kDontCare = 0, kBullet, kEnemy, kBlackHole,; beskyttet: tTexture * mImage; tColor4f mColor; tPoint2f mPosition; tVector2f mVelocity; float mOrientation; flyte mRadius; bool mIsExpired; Vennlig mKind; offentlig: Entitet (); virtuell ~ Entitet (); tDimension2f getSize () const; virtuell ugyldig oppdatering () = 0; virtuell ugyldig tegning (tSpriteBatch * spriteBatch); tPoint2f getPosition () const; tVector2f getVelocity () const; void setVelocity (const tVector2f & nv); flyte getRadius () const; bool isExpired () const; Kind getKind () const; tomt settExpired (); ;
Alle våre enheter (fiender, kuler og spillerens skip) har noen grunnleggende egenskaper, for eksempel et bilde og en posisjon. mIsExpired
vil bli brukt til å indikere at enheten har blitt ødelagt og bør fjernes fra noen lister med referanse til den.
Deretter lager vi en EntityManager
å spore våre enheter og å oppdatere og tegne dem:
klassen EntityManager: public tSingletonbeskyttet: std :: liste mEntities; std :: liste mAddedEntities; std :: liste mBullets; bool mIsUpdating; beskyttet: EntityManager (); offentlig: int getCount () const; void add (Entity * entity); void addEntity (Entity * entity); ugyldig oppdatering (); void draw (tSpriteBatch * spriteBatch); bool isColliding (Entity * a, Entity * b); venneklasse tSingleton ; ; void EntityManager :: legg til (Entity * entity) if (! mIsUpdating) addEntity (entity); ellers mAddedEntities.push_back (enhet); tomt EntityManager :: oppdatering () mIsUpdating = true; for (std :: liste :: iterator iter = mEntities.begin (); iter! = mEntities.end (); iter ++) (* iter) -> oppdatering (); hvis ((* iter) -> isExpired ()) * iter = NULL; mIsUpdating = false; for (std :: liste :: iterator iter = mAddedEntities.begin (); iter! = mAddedEntities.end (); iter ++) addEntity (* iter); mAddedEntities.clear (); mEntities.remove (NULL); for (std :: liste :: iterator iter = mBullets.begin (); iter! = mBullets.end (); iter ++) if ((* iter) -> isExpired ()) delete * iter; * iter = NULL; mBullets.remove (NULL); void EntityManager :: draw (tSpriteBatch * spriteBatch) for (std :: liste :: iterator iter = mEntities.begin (); iter! = mEntities.end (); iter ++) (* iter) -> draw (spriteBatch);
Husk at hvis du endrer en liste mens den overhører det, får du et runtime unntak. Ovennevnte kode tar vare på dette ved å kjøre opp noen enheter lagt til under oppdatering i en egen liste, og legge dem til etter at det er ferdig med å oppdatere eksisterende enheter.
Vi må laste inn noen teksturer hvis vi ønsker å tegne noe, så vi skal lage en statisk klasse for å holde referanser til alle våre teksturer:
klasse kunst: offentlig tsingletonbeskyttet: tTexture * mPlayer; tTexture * mSeeker; tTexture * mWanderer; tTexture * mBullet; tTexture * mPointer; beskyttet: kunst (); offentlig: tTexture * getPlayer () const; tTexture * getSeeker () const; tTexture * getWanderer () const; tTexture * getBullet () const; tTexture * getPointer () const; venneklasse tSingleton ; ; Art :: Art () mPlayer = new tTexture (tSurface ("player.png")); mSeeker = ny tTexture (tSurface ("seeker.png")); mWanderer = ny tTexture (tSurface ("wanderer.png")); mBullet = ny tTexture (tSurface ("bullet.png")); mPointer = ny tTexture (tSurface ("pointer.png"));
Vi laster opp kunsten ved å ringe Art :: getInstance ()
i GameRoot :: onInitView ()
. Dette fører til Kunst
singleton å bli konstruert og å ringe til konstruktøren, Art :: Art ()
.
Et antall klasser må også kjenne skjermdimensjonene, så vi har følgende medlemmer i GameRoot
:
tDimension2f mViewportSize; tSpriteBatch * mSpriteBatch; tAutosizeViewport * mViewport;
Og i GameRoot
konstruktør, setter vi størrelsen:
GameRoot :: GameRoot (): mViewportSize (800, 600), mSpriteBatch (NULL)
Oppløsningen 800x600px er hva den originale XNA-baserte Shape Blaster brukte. Vi kan bruke en hvilken som helst oppløsning vi ønsker (som en nærmere en iPhone eller iPads spesifikke oppløsning), men vi holder fast ved den opprinnelige oppløsningen bare for å sikre at vårt spill samsvarer med utseendet på originalen.
Nå går vi over PlayerShip
klasse:
klasse PlayerShip: offentlig enhet, offentlig tSingletonprotected: static const int kCooldownFrames; int mCooldowmRemaining; int mFramesUntilRespawn; beskyttet: PlayerShip (); offentlig: ugyldig oppdatering (); void draw (tSpriteBatch * spriteBatch); bool getIsDead (); tomrom (); venneklasse tSingleton ; ; SpillerShip :: PlayerShip (): mCooldowmRemaining (0), mFramesUntilRespawn (0) mImage = Art :: getInstance () -> getPlayer (); mPosition = tPoint2f (GameRoot :: getInstance () -> getViewportSize (). x / 2, GameRoot :: getInstance () -> getViewportSize (). y / 2); mRadius = 10;
Vi lagde PlayerShip
en singleton, sette bildet sitt og plasserte det i midten av skjermen.
Til slutt, la oss legge til spillerens skip til EntityManager
. Koden i GameRoot :: onInitView
ser slik ut:
// I GameRoot :: onInitView EntityManager :: getInstance () -> legg til (PlayerShip :: getInstance ()); ... glClearColor (0,0,0,1); glenable (GL_BLEND); glBlendFunc (GL_SRC_ALPHA, GL_ONE); glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glHint (GL_GENERATE_MIPMAP_HINT, GL_DONT_CARE); glDisable (GL_DEPTH_TEST); glDisable (GL_CULL_FACE);
Vi tegner sprites med additiv blanding, som er en del av det som vil gi dem deres "neon" utseende. Vi vil heller ikke ha noen bluring eller blanding, så vi bruker GL_NEAREST
for våre filtre. Vi trenger ikke eller bryr oss om dybdeprøving eller backside culling (det legger bare unødvendig overhead uansett), så vi slår den av.
Koden i GameRoot :: onRedrawView
ser slik ut:
// I GameRoot :: onRedrawView EntityManager :: getInstance () -> oppdatering (); EntityManager :: getInstance () -> draw (mSpriteBatch); mSpriteBatch-> draw (0, Art :: getInstance () -> getPointer (), Input :: getInstance () -> getMousePosition (), tOptional()); mViewport-> run (); glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); mSpriteBatch-> ende (); glFlush ();
Hvis du kjører spillet på dette tidspunktet, bør du se skipet ditt i midten av skjermen. Det svarer imidlertid ikke på innspill. La oss legge til litt innspilling til spillet neste.
For bevegelse bruker vi et multi-touch-grensesnitt. Før vi går i full kraft med skjermspillene, får vi bare et grunnleggende touch-grensesnitt oppe.
I den originale Shape Blaster for Windows kan spilleren bevegelse gjøres med WASD-tastene på tastaturet. For sikte kunne de bruke piltastene eller musen. Dette er ment å etterligne Geometry Wars tvillingpinne kontroller: en analog pinne for bevegelse, en for sikte.
Siden Shape Blaster allerede bruker begrepet tastatur- og musebevegelse, vil den enkleste måten å legge til innspill ved å emulere tastatur- og musekommandoer gjennom berøring. Vi starter med musebevegelse, da både berøring og mus deler en lignende komponent: et punkt som inneholder X- og Y-koordinater.
Vi lager en statisk klasse for å holde oversikt over de ulike inngangsenhetene og å ta vare på å skifte mellom de forskjellige typer sikter:
klasse Input: offentlig tSingleton beskyttet: tPoint2f mMouseState; tPoint2f mLastMouseState; tPoint2f mFreshMouseState; std :: vektormKeyboardState; std :: vektor mLastKeyboardState; std :: vektor mFreshKeyboardState; bool mIsAimingWithMouse; uint8_t mLeftEngasjert; uint8_t mRightEngaged; offentlig: enum KeyType kUp = 0, kLeft, kDown, kRight, kW, kA, kS, kD,; beskyttet: tVector2f GetMouseAimDirection () const; beskyttet: Input (); offentlig: tPoint2f getMousePosition () const; ugyldig oppdatering (); // Kontrollerer om en nøkkel bare ble trykket ned bool wasKeyPressed (KeyType) const; tVector2f getMovementDirection () const; tVector2f getAimDirection () const; void onKeyboard (const tKeyboardEvent & msg); void onTouch (const tTouchEvent & msg); venneklasse tSingleton; ; void Input :: update () mLastKeyboardState = mKeyboardState; mLastMouseState = mMouseState; mKeyboardState = mFreshKeyboardState; mMouseState = mFreshMouseState; hvis (mKeyboardState [kLeft] || mKeyboardState [kRight] || mKeyboardState [kUp] || mKeyboardState [kDown]) mIsAimingWithMouse = false; annet hvis (mMouseState! = mLastMouseState) mIsAimingWithMouse = true;
Vi ringer Input :: oppdatering ()
i begynnelsen av GameRoot :: onRedrawView ()
for inngangsklassen til arbeid.
Som nevnt tidligere bruker vi tastatur
Oppgi senere i serien for å redegjøre for bevegelse.
La oss nå gjøre skipet skyte.
Først trenger vi en klasse for kuler.
klasse Bullet: offentlig enhet offentlig: Bullet (const tPoint2f & posisjon, const tVector2f & hastighet); ugyldig oppdatering (); ; Bullet :: Bullet (const tPoint2f og posisjon, const tVector2f og hastighet) mImage = Art :: getInstance () -> getBullet (); mPosition = posisjon; mVelocity = hastighet; mOrientation = atan2f (mVelocity.y, mVelocity.x); mRadius = 8; mKind = kBullet; void Bullet :: update () if (mVelocity.lengthSquared ()> 0) mOrientation = atan2f (mVelocity.y, mVelocity.x); mPosisjon + = mVelocity; hvis (! tRectf (0, 0, GameRoot :: getInstance () -> getViewportSize ()) inneholder (tPoint2f ((int32_t) mPosition.x, (int32_t) mPosition.y))) mIsExpired = true;
Vi ønsker en kort nedkjølingsperiode mellom kuler, så vi får en konstant for det:
const int PlayerShip :: kCooldownFrames = 6;
Vi legger også til følgende kode til PlayerShip :: Update ()
:
tVector2f aim = Input :: getInstance () -> getAimDirection (); hvis (aim.lengthSquared ()> 0 && mCooldowmRemaining <= 0) mCooldowmRemaining = kCooldownFrames; float aimAngle = atan2f(aim.y, aim.x); float cosA = cosf(aimAngle); float sinA = sinf(aimAngle); tMatrix2x2f aimMat(tVector2f(cosA, sinA), tVector2f(-sinA, cosA)); float randomSpread = tMath::random() * 0.08f + tMath::random() * 0.08f - 0.08f; tVector2f vel = 11.0f * (tVector2f(cosA, sinA) + tVector2f(randomSpread, randomSpread)); tVector2f offset = aimMat * tVector2f(35, -8); EntityManager::getInstance()->legg til (ny Bullet (mPosition + offset, vel)); offset = aimMat * tVector2f (35, 8); EntityManager :: getInstance () -> add (ny Bullet (mPosition + offset, vel)); tSound * curShot = Lyd :: getInstance () -> getShot (); hvis (! curShot-> isPlaying ()) curShot-> play (0, 1); hvis (mCooldowmRemaining> 0) mCooldowmRemaining--;
Denne koden oppretter to kuler som reiser parallelt med hverandre. Det legger til en liten tilfeldighet i retningen, noe som gjør at skuddene spredes ut litt som en maskinpistol. Vi legger til to tilfeldige tall sammen, fordi dette gjør at summen er mer sannsynlig å være sentrert (rundt null) og mindre sannsynlig å sende kuler langt unna. Vi bruker en todimensjonal matrise for å rotere den opprinnelige posisjonen til kulene i den retningen de reiser.
Vi brukte også to nye hjelpemetoder:
Extensions :: NextFloat ()
: Returnerer en tilfeldig flyt mellom minimum og maksimum verdi.MathUtil :: FromPolar ()
: Oppretter en tVector2f
fra en vinkel og størrelse.Så la oss se hvordan de ser ut:
// I Extensions float Extensions :: nextFloat (float minValue, float maxValue) return (float) tMath :: tilfeldig () * (maxValue - minValue) + minValue; // I MathUtil tVector2f MathUtil :: fraPolar (flytvinkel, flytestørrelse) returstørrelse * tVector2f ((float) cosf (vinkel), (float) sinf (vinkel));
Det er en ting vi skal gjøre nå, da vi har det første Input
klasse: la oss tegne en egendefinert musemarkør for å gjøre det lettere å se hvor skipet sikter mot. I GameRoot.Draw
, bare tegne Art mPointer
på musens posisjon.
mSpriteBatch-> draw (0, Art :: getInstance () -> getPointer (), Input :: getInstance () -> getMousePosition (), tOptional());
Hvis du tester spillet nå, vil du kunne røre hvor som helst på skjermen for å sikte på kontinuerlig strøm av kuler, noe som er en god start.
I neste del, vil vi fullføre det opprinnelige spillet ved å legge til fiender og en poengsum.