Gör en Neon Vector Shooter för iOS Första steget

I den här serien av handledningar visar jag dig hur man gör en Geometry Wars-inspirerad tvillingstickskytte med neongrafik, galna partikeleffekter och fantastisk musik, för iOS med C ++ och OpenGL ES 2.0.

Snarare än att förlita sig på ett befintligt spelramverk eller sprite-bibliotek, försöker vi programmera så nära hårdvaran (eller "bare metal") som vi eventuellt kan. Eftersom enheter som kör iOS körs på mindre hårdvaru jämfört med en stationär dator eller spelkonsol, kommer det att göra det möjligt för oss att få så mycket bang för våra pengar som möjligt.

relaterade inlägg
Dessa handledning bygger på Michael Hoffmans ursprungliga XNA-serie, som har översatts till andra plattformar:
  • Gör en Neon Vector Shooter i XNA
  • Gör en Neon Vector Shooter i jMonkeyEngine

Målet med dessa handledning är att gå över de nödvändiga elementen som gör att du kan skapa ditt eget högkvalitativa mobilspel för iOS, antingen från början eller baserat på ett befintligt skrivbordsspel. Jag uppmuntrar dig att ladda ner och spela med koden, eller till och med använda den som underlag för dina egna projekt.

Vi kommer att täcka följande ämnen under denna serie:

  1. Första steget, introducera verktyget Bibliotek, ställa in den grundläggande gameplayen, skapa spelarens skepp, ljud och musik.
  2. Avsluta implementeringen av spelmekaniken genom att lägga till fiender, hantera kollisionsdetektering och spåra spelarens poäng och liv.
  3. Lägg till en virtuell gamepad på skärmen, så vi kan styra spelet med multi-touch-inmatning.
  4. Lägg till galen, över-the-top partikel effekter.
  5. Lägg till bakgrundsröret för vridning.

Här är vad vi får i slutet av serien:


Varning: Högt!

Och här är vad vi får vid slutet av den här första delen:


Varning: Högt!

Musiken och ljudeffekterna som du kan höra i dessa videoklipp skapades av RetroModular, och du kan läsa om hur han gjorde det över på vår ljudavdelning.

De sprites är av Jacob Zinman-Jeanes, vår invånare Tuts + designer.

Teckensnittet vi ska använda är en bitmap-typsnitt (med andra ord, inte en faktisk "font", men en bildfil), vilket är något jag har skapat för denna handledning.

Allt konstverket finns i källfilerna.

Låt oss börja.


Översikt

Innan vi dyker in i spelets specifika egenskaper, låt oss prata om verktyget Bibliotek och program Bootstrap som jag har tillhandahållit för att stödja utvecklingen av vårt spel.

Verktygsbiblioteket

Även om vi främst använder C ++ och OpenGL för att koda vårt spel behöver vi några ytterligare verktygskurser. Det här är alla klasser jag har skrivit för att hjälpa till med utveckling i andra projekt, så de är tidtestade och användbara för nya projekt som den här.

  • package.h: En bekvämhetsrubrik brukade innehålla alla relevanta rubriker från verktyget Bibliotek. Vi kommer att inkludera det genom att ange #include "Utility / package.h" utan att behöva ta med något annat.

Mönster

Vi utnyttjar vissa befintliga försök och sanna programmeringsmönster som används i C ++ och andra språk.

  • tSingleton: Implementerar en singleton-klass med ett "Meyers Singleton" -mönster. Det är mallbaserat och utvidgat, så vi kan sammanfatta all singleton-kod till en enda klass.
  • tOptional: Detta är en funktion från C ++ 14 (kallad std :: valfritt) som inte är helt tillgänglig i nuvarande versioner av C ++ ännu (vi är fortfarande på C ++ 11). Det är också en funktion som finns i XNA och C # (där den heter null.) Det tillåter oss att ha "valfria" parametrar för metoder. Den används i tSpriteBatch klass.

Vector Math

Eftersom vi inte använder ett befintligt spelramverk behöver vi några klasser för att hantera matematiken bakom kulisserna.

  • tMath: En statisk klass taht ger några metoder utöver vad som är tillgängligt i C ++, såsom konvertering från grader till radianer eller avrundningsnummer till två.
  • tVector: En grundläggande uppsättning av vektorklasser, som tillhandahåller 2-element, 3-element och 4-elementvarianter. Vi skriver också in denna struktur för punkter och färger.
  • tMatrix: Två matrisdefinitioner, en 2x2-variant (för rotationsoperationer) och ett 4x4-alternativ (för projektionsmatrisen krävs för att få saker på skärmen),
  • Trekt: En rektangelklass som ger plats, storlek och en metod för att bestämma om punkter ligger i rektanglar eller ej.

OpenGL Wrapper Classes

Även om OpenGL är ett kraftfullt API, är det C-baserat, och hantering av objekt kan vara lite svårt att göra i praktiken. Så vi har en liten handfull klasser för att hantera OpenGL-objekten för oss.

  • Tyta: Ger ett sätt att skapa en bitmapp baserat på en bild som laddas från programmets bunt.
  • tTexture: Wraps gränssnittet till OpenGLs texturkommandon och belastningar tSurfaces in i texturer.
  • tShader: Wraps gränssnittet till OpenGLs shader-kompilator, vilket gör det enkelt att kompilera shaders.
  • tProgram: Wraps gränssnittet till OpenGLs skärmprogram, som i huvudsak är kombinationen av två tShader klasser.

Spelstödsklasser

Dessa klasser representerar det närmaste vi kommer att få till att ha en "spelram"; de ger några högnivåkoncept som inte är typiska för OpenGL, men som är användbara för spelutvecklingsändamål.

  • tViewport: Innehåller visningsportens status. Vi använder detta främst för att hantera förändringar i enhetens orientering.
  • tAutosizeViewport: En klass som hanterar ändringar i visningsporten. Det hanterar ändringar av enhetsorientering direkt och vågar utporten så att den passar skärmens skärm så att bildförhållandet blir detsamma, vilket betyder att sakerna inte blir utsträckta eller krossade.
  • tSpriteFont: Låt oss ladda en "bitmap font" från programbuntet och använda den för att skriva text på skärmen.
  • tSpriteBatch: Inspirerad av XNAs SpriteBatch klass, skrev jag denna klass för att inkapsla det bästa av vad som behövs av vårt spel. Det gör det möjligt för oss att sortera sprites när de ritas på ett sådant sätt att vi får de bästa möjliga hastighetsvinsterna på hårdvaran vi har. Vi använder den också direkt för att skriva text på skärmen.

Diverse klasser

En minimal uppsättning klasser för att runda ut saker.

  • tTimer: En system timer, som används främst för animeringar.
  • tInputEvent: Grundklassdefinitioner för att ge orienteringsändringar (lutning av enheten), peka på händelser och ett "virtuellt tangentbord" -händelse för att emulera en gamepad mer diskret.
  • tSound: En klass dedikerad till att ladda och spela ljud och musik.

Program Bootstrap

Vi behöver också det jag kallar "Boostrap" -koden, det vill säga kod som abstraherar hur en applikation startar eller "stövlar upp".

Här är vad som händer bootstrap:

  • AppDelegate: Den här klassen hanterar applikationslansering, samt avbryter och återupptar händelser för när användaren trycker på hemknappen.
  • ViewController: Den här klassen hanterar händelser för enhetorientering och skapar vår OpenGL-vy
  • OpenGLView: Den här klassen initierar OpenGL, berättar att enheten uppdateras med 60 bilder per sekund och hanterar beröringshändelser.

Översikt över spelet

I denna handledning skapar vi en tvillingstickskytte; Spelaren kommer att styra skeppet genom att använda skärmar med flera knappar.

Vi använder ett antal klasser för att uppnå detta:

  • Entitet: Basklassen för fiender, kulor och spelarens fartyg. Entiteter kan flytta och dras.
  • Kula och PlayerShip.
  • EntityManager: Håller koll på alla enheter i spelet och utför kollisionsdetektering.
  • Inmatning: Hjälper hantera inmatning från pekskärmen.
  • Konst: Laddar och innehåller referenser till de texturer som behövs för spelet.
  • Ljud: Laddar och innehåller referenser till ljud och musik.
  • MathUtil och Extensions: Innehåller några användbara statiska metoder och
    förlängningsmetoder.
  • GameRoot: Kontrollerar huvudslingan i spelet. Detta är vår huvudklass.

Koden i denna handledning syftar till att vara enkel och lätt att förstå. Det har inte alla funktioner utformade för att stödja alla möjliga behov. snarare, det kommer bara att göra vad den behöver göra. Att hålla det enkelt gör det lättare för dig att förstå koncepten, och sedan ändra och expandera dem i ditt eget unika spel.


Enheter och spelarens fartyg

Öppna det existerande Xcode-projektet. GameRoot är vår ansökans huvudklass.

Vi börjar med att skapa en bas klass för våra spel enheter. Ta en titt på Enhetsklass:

 klassenhet public: enum Kind kDontCare = 0, kBullet, kEnemy, kBlackHole,; skyddad: tTexture * mImage; tColor4f mColor; tPoint2f mPosition; tVector2f mVelocity; float mOrientation; float mRadius; bool mIsExpired; Snälla mKind; allmänhet: Enhet (); virtuell ~ Entitet (); tDimension2f getSize () const; virtuell tomgångsuppdatering () = 0; virtuell tomgång (tSpriteBatch * spriteBatch); tPoint2f getPosition () const; tVector2f getVelocity () const; void setVelocity (const tVector2f & nv); flyta getRadius () const; bool isExpired () const; Kind getKind () const; void setExpired (); ;

Alla våra enheter (fiender, kulor och spelarens fartyg) har några grundläggande egenskaper, till exempel en bild och en position. mIsExpired kommer att användas för att indikera att enheten har förstörts och bör tas bort från alla listor som innehåller en hänvisning till den.

Nästa skapar vi en EntityManager att spåra våra enheter och att uppdatera och rita dem:

 klassen EntityManager: public tSingleton skyddad: std :: lista mEntities; std :: listan mAddedEntities; std :: listan mBullets; bool mIsUpdating; skyddad: EntityManager (); allmänhet: int getCount () const; void add (Entity * entity); void addEntity (Entity * entity); tomt uppdatering (); void draw (tSpriteBatch * spriteBatch); bool isColliding (Entity * a, Entity * b); vän klass tSingleton; ; void EntityManager :: lägg till (Entity * entity) if (! mIsUpdating) addEntity (entity);  annat mAddedEntities.push_back (enhet);  void EntityManager :: uppdatering () mIsUpdating = true; för (std :: listan:: iterator iter = mEntities.begin (); iter! = mEntities.end (); iter ++) (* iter) -> uppdatering (); om ((* iter) -> isExpired ()) * iter = NULL;  mIsUpdating = false; för (std :: listan:: iterator iter = mAddedEntities.begin (); iter! = mAddedEntities.end (); iter ++) addEntity (* iter);  mAddedEntities.clear (); mEntities.remove (NULL); för (std :: listan:: iterator iter = mBullets.begin (); iter! = mBullets.end (); iter ++) if ((* iter) -> isExpired ()) delete * iter; * iter = NULL;  mBullets.remove (NULL);  void EntityManager :: draw (tSpriteBatch * spriteBatch) för (std :: lista:: iterator iter = mEntities.begin (); iter! = mEntities.end (); iter ++) (* iter) -> draw (spriteBatch); 

Kom ihåg att om du ändrar en lista medan du tar över det, får du ett runtime-undantag. Ovanstående kod tar hand om detta genom att köra upp alla enheter som läggs till under uppdatering i en separat lista och lägger till dem efter att det har slutförts uppdatering av befintliga enheter.

Göra dem synliga

Vi måste ladda några texturer om vi vill rita något, så vi ska göra en statisk klass för att hålla referenser till alla våra texturer:

 klass Art: public tSingleton skyddad: tTexture * mPlayer; tTexture * mSeeker; tTexture * mWanderer; tTexture * mBullet; tTexture * mPointer; skyddad: Art (); allmänhet: tTexture * getPlayer () const; tTexture * getSeeker () const; tTexture * getWanderer () const; tTexture * getBullet () const; tTexture * getPointer () const; vän klass tSingleton; ; Art :: Art () mPlayer = ny 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 laddar konsten genom att ringa Art :: getInstance () i GameRoot :: onInitView (). Detta orsakar Konst singleton att byggas och ringa till konstruktören, Art :: art ().

Dessutom måste ett antal klasser känna till skärmdimensionerna, så vi har följande medlemmar i GameRoot:

 tDimension2f mViewportSize; tSpriteBatch * mSpriteBatch; tAutosizeViewport * mViewport;

Och i GameRoot konstruktör, bestämmer vi storleken:

 GameRoot :: GameRoot (): mViewportSize (800, 600), mSpriteBatch (NULL) 

Upplösningen 800x600px är vad den ursprungliga XNA-baserade Shape Blaster används. Vi skulle kunna använda vilken upplösning vi önskar (som en närmare en iPhone eller iPads specifika upplösning), men vi klarar av den ursprungliga upplösningen för att se till att vårt spel matchar originalets utseende och känsla.

Nu ska vi gå över PlayerShip klass:

 klass PlayerShip: offentlig enhet, offentlig tSingleton protected: static const int kCooldownFrames; int mCooldowmRemaining; int mFramesUntilRespawn; skyddad: PlayerShip (); allmänhet: tomt uppdatering (); void draw (tSpriteBatch * spriteBatch); bool getIsDead (); tomrumsdöd (); vän klass tSingleton; ; PlayerShip :: PlayerShip (): mCooldowmRemaining (0), mFramesUntilRespawn (0) mImage = Art :: getInstance () -> getPlayer (); mPosition = tPoint2f (GameRoot :: getInstance () -> getViewportSize (). x / 2, GameRoot :: getInstance () -> getViewportSize () .j / 2); mRadius = 10; 

Vi gjorde PlayerShip en singleton, sätta sin bild och placera den i mitten av skärmen.

Slutligen, låt oss lägga till spelarskeppet till EntityManager. Koden i GameRoot :: onInitView ser så här ut:

 // I GameRoot :: onInitView EntityManager :: getInstance () -> lägg till (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 drar spritesna med tillsatsblandning, som är en del av vad som kommer att ge dem deras "neon" look. Vi vill inte ha någon bluring eller blandning, så vi använder GL_NEAREST för våra filter. Vi behöver inte eller bryr oss om djuptestning eller backfyllning (det lägger bara onödigt överhuvud taget ändå), så vi stänger av det.

Koden i GameRoot :: onRedrawView ser så här ut:

 // I GameRoot :: onRedrawView EntityManager :: getInstance () -> uppdatering (); 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-> änden (); glFlush ();

Om du kör spelet vid denna tidpunkt ska du se ditt skepp i mitten av skärmen. Det svarar emellertid inte på inmatning. Låt oss lägga till lite inmatning till spelet nästa.


Inmatning

För rörelse använder vi ett multi-touch-gränssnitt. Innan vi går i full kraft med skärmspelsprogram, får vi bara ett grundläggande touchgränssnitt.

I den ursprungliga Shape Blaster för Windows kan spelarrörelsen göras med WASD-tangenterna på tangentbordet. För syftet kunde de använda piltangenterna eller musen. Detta är tänkt att emulera Geometry Wars tvillingstavskontroller: en analog pinne för rörelse, en för sikte.

Eftersom Shape Blaster redan använder konceptet tangentbord och musrörelse, skulle det enklaste sättet att lägga till inmatning genom att emulera tangentbord och muskommandon genom beröring. Vi börjar med musrörelse, eftersom både peka och mus delar en liknande komponent: en punkt som innehåller X- och Y-koordinater.

Vi gör en statisk klass för att hålla reda på de olika inmatningsenheterna och att ta hand om att växla mellan olika typer av sikter:

 klass Input: public tSingleton protected: tPoint2f mMouseState; tPoint2f mLastMouseState; tPoint2f mFreshMouseState; std :: vektor mKeyboardState; std :: vektor mLastKeyboardState; std :: vektor mFreshKeyboardState; bool mIsAimingWithMouse; uint8_t mLeftEngaged; uint8_t mRightEngaged; allmänhet: enum KeyType kUp = 0, kLeft, kDown, kRight, kW, kA, kS, kD,; skyddad: tVector2f GetMouseAimDirection () const; skyddad: Input (); allmänhet: tPoint2f getMousePosition () const; tomt uppdatering (); // Kontrollerar om en knapp bara trycktes ner bool wasKeyPressed (KeyType) const; tVector2f getMovementDirection () const; tVector2f getAimDirection () const; ogiltig på Keyboard (const tKeyboardEvent & msg); void onTouch (const tTouchEvent & msg); vän klass tSingleton; ; void Input :: update () mLastKeyboardState = mKeyboardState; mLastMouseState = mMouseState; mKeyboardState = mFreshKeyboardState; mMouseState = mFreshMouseState; om (mKeyboardState [kLeft] || mKeyboardState [kRight] || mKeyboardState [kUp] || mKeyboardState [kDown]) mIsAimingWithMouse = false;  annars om (mMouseState! = mLastMouseState) mIsAimingWithMouse = true; 

Vi ringer Input :: update () i början av GameRoot :: onRedrawView () för inmatningsklassen att arbeta.

Som sagt tidigare använder vi tangentbord Ange senare i serien för att ta hänsyn till rörelsen.

Skytte

Låt oss nu göra fartyget skjuta.

Först behöver vi en klass för kulor.

 klass Bullet: offentlig enhet public: Bullet (const tPoint2f & position, const tVector2f & hastighet); tomt uppdatering (); ; Bullet :: Bullet (const tPoint2f & position, const tVector2f & hastighet) mImage = Art :: getInstance () -> getBullet (); mPosition = position; mVelocity = hastighet; mOrientation = atan2f (mVelocity.y, mVelocity.x); mRadius = 8; mKind = kBullet;  void Bullet :: uppdatering () om (mVelocity.lengthSquared ()> 0) mOrientation = atan2f (mVelocity.y, mVelocity.x);  mPosition + = mVelocity; Om (! tRectf (0, 0, GameRoot :: getInstance () -> getViewportSize ()) innehåller (tPoint2f ((int32_t) mPosition.x, (int32_t) mPosition.y))) mIsExpired = true; 

Vi vill ha en kort nedkylningstid mellan kulor, så vi får en konstant för det:

 const int PlayerShip :: kCooldownFrames = 6;

Dessutom lägger vi till följande kod till PlayerShip :: Update ():

 tVector2f aim = Input :: getInstance () -> getAimDirection (); om (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()->lägg till (ny Bullet (mPosition + offset, vel)); offset = aimMat * tVector2f (35, 8); EntityManager :: getInstance () -> lägg till (ny Bullet (mPosition + offset, vel)); tSound * curShot = Ljud :: getInstance () -> getShot (); om (! curShot-> isPlaying ()) curShot-> play (0, 1);  om (mCooldowmRemaining> 0) mCooldowmRemaining--; 

Denna kod skapar två kulor som reser parallellt med varandra. Det lägger till en liten slumpmässighet i riktningen, vilket gör att skotten sprids ut lite som en maskingevär. Vi lägger till två slumptal tillsammans eftersom det gör att deras summa sannolikt kommer att vara centrerad (runt noll) och mindre sannolikt att skicka kulor långt borta. Vi använder en tvådimensionell matris för att rotera kollens ursprungliga läge i den riktning de reser.

Vi använde också två nya hjälpar metoder:

  • Förlängningar :: NextFloat (): Returnerar en slumpmässig float mellan ett minimum och ett maximalt värde.
  • MathUtil :: FromPolar (): Skapar a tVector2f från en vinkel och storleksordning.

Så låt oss se hur de ser ut:

 // I Extensions Float Extensions :: nextFloat (float minValue, float maxValue) return (float) tMath :: slumpmässig () * (maxValue - minValue) + minValue;  // I MathUtil tVector2f MathUtil :: fromPolar (floatvinkel, float magnitude) returstorlek * tVector2f ((float) cosf (vinkel), (float) sinf (vinkel)); 

Anpassad markör

Det är en sak vi borde göra nu när vi har det ursprungliga Inmatning klass: Låt oss rita en anpassad muspekare för att göra det enklare att se var fartyget syftar. I GameRoot.Draw, helt enkelt dra Art mPointer i musens läge.

 mSpriteBatch-> draw (0, Art :: getInstance () -> getPointer (), Input :: getInstance () -> getMousePosition (), tOptional());

Slutsats

Om du testar spelet nu, kommer du att kunna röra var som helst på skärmen för att sikta på den kontinuerliga strömmen av kulor, vilket är en bra start.


Varning: Högt!

I nästa del kommer vi att slutföra den ursprungliga spelningen genom att lägga till fiender och en poäng.