In questa serie di tutorial, ti mostrerò come creare uno sparatutto twin-stick ispirato a Geometry Wars, con grafica al neon, effetti pazzeschi e musica fantastica, per iOS che utilizza C ++ e OpenGL ES 2.0.
Piuttosto che fare affidamento su una struttura di gioco esistente o su una libreria di sprite, tenteremo di programmare il più vicino possibile all'hardware (o "bare metal"). Poiché i dispositivi che eseguono iOS funzionano su hardware di dimensioni più ridotte rispetto a un PC desktop o una console di gioco, questo ci consentirà di ottenere il massimo per il nostro buck possibile.
Post correlatiL'obiettivo di queste esercitazioni è quello di andare oltre gli elementi necessari che ti permetteranno di creare il tuo gioco mobile di alta qualità per iOS, da zero o basato su un gioco desktop esistente. Ti incoraggio a scaricare e giocare con il codice, o anche a usarlo come base per i tuoi progetti.
Tratteremo i seguenti argomenti durante questa serie:
Ecco cosa avremo alla fine della serie:
Ed ecco cosa avremo alla fine di questa prima parte:
La musica e gli effetti sonori che puoi sentire in questi video sono stati creati da RetroModular e puoi leggere come l'ha fatto nella nostra sezione audio.
Gli sprite sono di Jacob Zinman-Jeanes, il nostro tutor + designer residente.
Il font che useremo è un font bitmap (in altre parole, non un vero "font", ma un file immagine), che è qualcosa che ho creato per questo tutorial.
Tutto il materiale illustrativo può essere trovato nei file sorgente.
Iniziamo.
Prima di approfondire le specificità del gioco, parliamo del codice Bootstrap della libreria di utilità e dell'applicazione che ho fornito per supportare lo sviluppo del nostro gioco.
Anche se utilizzeremo principalmente C ++ e OpenGL per codificare il nostro gioco, avremo bisogno di alcune classi di utilità aggiuntive. Queste sono tutte le classi che ho scritto per aiutare lo sviluppo in altri progetti, quindi sono testate nel tempo e utilizzabili per nuovi progetti come questo.
package.h
: Un'intestazione pratica utilizzata per includere tutti gli header rilevanti dalla libreria Utility. Lo includeremo affermando #include "Utility / package.h"
senza dover includere altro.Sfrutteremo alcuni schemi di programmazione già sperimentati e veri usati in C ++ e in altri linguaggi.
tSingleton
: Implementa una classe singleton utilizzando un pattern "Meyers Singleton". È basato su modelli ed estensibile, quindi è possibile astrarre tutti i codici singleton in una singola classe.tOptional
: Questa è una funzionalità di C ++ 14 (chiamata std :: opzionale
) che non è ancora disponibile nelle attuali versioni di C ++ (siamo ancora al C ++ 11). È anche una funzionalità disponibile in XNA e C # (dove è chiamata nullable
.) Ci consente di avere parametri "opzionali" per i metodi. È usato nel tSpriteBatch
classe.Dato che non stiamo usando un framework di gioco esistente, avremo bisogno di alcune classi per affrontare la matematica dietro le quinte.
tMath
: Una classe statica fornisce alcuni metodi oltre a quelli disponibili in C ++, come la conversione da gradi a radianti o numeri di arrotondamento a potenze di due.tVector
: Un set base di classi Vector, che fornisce varianti a 2 elementi, 3 elementi e 4 elementi. Digitiamo anche questa struttura per Punti e Colori.tMatrix
: Due definizioni di matrice, una variante 2x2 (per le operazioni di rotazione) e un'opzione 4x4 (per la matrice di proiezione necessaria per ottenere le cose sullo schermo),tRect
: Una classe rettangolo che fornisce posizione, dimensioni e un metodo per determinare se i punti si trovano all'interno o meno di rettangoli.Sebbene OpenGL sia una potente API, è basata su C e la gestione degli oggetti può essere piuttosto difficile da fare in pratica. Quindi, avremo un piccolo numero di classi per gestire gli oggetti OpenGL per noi.
tSurface
: Offre un modo per creare una bitmap basata su un'immagine caricata dal pacchetto dell'applicazione.tTexture
: Avvolge l'interfaccia per i comandi di trama di OpenGL e carica tSurfaces
in trame.tShader
: Avvolge l'interfaccia del compilatore shader di OpenGL, semplificando la compilazione degli shader.tProgram
: Avvolge l'interfaccia nell'interfaccia del programma shader di OpenGL, che è essenzialmente la combinazione di due tShader
classi.Queste classi rappresentano la cosa più vicina alla quale avremo un "framework di gioco"; forniscono alcuni concetti di alto livello che non sono tipici di OpenGL, ma che sono utili per scopi di sviluppo del gioco.
tViewport
: Contiene lo stato del viewport. Lo usiamo principalmente per gestire le modifiche all'orientamento del dispositivo.tAutosizeViewport
: Una classe che gestisce le modifiche alla vista. Gestisce direttamente le modifiche all'orientamento del dispositivo e ridimensiona il viewport per adattarlo allo schermo del dispositivo in modo che le proporzioni rimangano invariate, ovvero che le cose non vengano allungate o schiacciate.tSpriteFont
: Ci consente di caricare un "font bitmap" dal pacchetto di applicazioni e usarlo per scrivere del testo sullo schermo.tSpriteBatch
: Ispirato da XNA SpriteBatch
classe, ho scritto questa lezione per incapsulare il meglio di ciò che serve al nostro gioco. Ci consente di ordinare gli sprite quando si disegna in modo tale da ottenere i migliori guadagni di velocità possibili sull'hardware che abbiamo. Lo useremo anche direttamente per scrivere del testo sullo schermo.Un insieme minimo di classi per risolvere le cose.
TTimer
: Un timer di sistema, utilizzato principalmente per le animazioni.tInputEvent
: Definizioni di classi base per fornire modifiche all'orientamento (inclinazione del dispositivo), eventi tocco e un evento "tastiera virtuale" per emulare un gamepad in modo più discreto.tSound
: Una classe dedicata al caricamento e alla riproduzione di effetti sonori e musica.Avremo anche bisogno di quello che chiamo codice "Boostrap", cioè codice che astrae il modo in cui un'applicazione inizia o "avvia".
Ecco cosa c'è dentro bootstrap
:
AppDelegate
: Questa classe gestisce l'avvio dell'applicazione, nonché la sospensione e la ripresa degli eventi per quando l'utente preme il pulsante Home.ViewController
: Questa classe gestisce gli eventi di orientamento del dispositivo e crea la nostra vista OpenGLOpenGLView
: Questa classe inizializza OpenGL, indica al dispositivo di effettuare l'aggiornamento a 60 fotogrammi al secondo e gestisce gli eventi di tocco.In questo tutorial creeremo uno sparatutto twin-stick; il giocatore controllerà la nave usando i controlli multi-touch sullo schermo.
Useremo un certo numero di classi per realizzare questo:
Entità
: La classe base per nemici, proiettili e la nave del giocatore. Le entità possono muoversi e essere disegnate.proiettile
e PlayerShip
.EntityManager
: Tiene traccia di tutte le entità nel gioco ed esegue il rilevamento delle collisioni.Ingresso
: Aiuta a gestire gli input dal touch screen.Arte
: Carica e mantiene i riferimenti alle trame necessarie per il gioco.Suono
: Carica e mantiene i riferimenti ai suoni e alla musica.MathUtil
e estensioni
: Contiene alcuni metodi statici utili eGameRoot
: Controlla il ciclo principale del gioco. Questa è la nostra classe principale.Il codice in questo tutorial si propone di essere semplice e facile da capire. Non avrà tutte le funzionalità progettate per supportare ogni possibile esigenza; piuttosto, farà solo ciò che deve fare. Mantenerlo semplice renderà più facile la comprensione dei concetti, quindi modificarli ed espanderli nel tuo gioco unico.
Apri il progetto Xcode esistente. GameRoot è la classe principale della nostra applicazione.
Inizieremo creando una classe base per le nostre entità di gioco. Dai un'occhiata al
class Entity public: enum Kind kDontCare = 0, kBullet, kEnemy, kBlackHole,; protetto: tTexture * mImage; tColor4f mColor; tPoint2f mPosition; tVector2f mVelocity; float mOrientation; float mRadius; bool mIsExpired; Tipo mKind; pubblico: Entity (); virtual ~ Entity (); tDimension2f getSize () const; virtual void update () = 0; virtual void draw (tSpriteBatch * spriteBatch); tPoint2f getPosition () const; tVector2f getVelocity () const; void setVelocity (const tVector2f & nv); float getRadius () const; bool isExpired () const; Kind getKind () const; void setExpired (); ;
Tutte le nostre entità (nemici, proiettili e la nave del giocatore) hanno alcune proprietà di base, come un'immagine e una posizione. mIsExpired
sarà usato per indicare che l'entità è stata distrutta e dovrebbe essere rimossa da qualsiasi lista che abbia un riferimento ad essa.
Quindi creiamo un EntityManager
per monitorare le nostre entità e per aggiornarle e disegnarle:
class EntityManager: tSingleton pubblicoprotected: std :: list mEntities; std :: list mAddedEntities; std :: list mBullets; bool mIsUpdating; protetto: EntityManager (); public: int getCount () const; void add (Entità * entità); void addEntity (Entity * entity); void update (); void draw (tSpriteBatch * spriteBatch); bool isColliding (Entity * a, Entity * b); classe di amici tSingleton ; ; void EntityManager :: add (Entità * entità) if (! mIsUpdating) addEntity (entity); else mAddedEntities.push_back (entity); void EntityManager :: update () mIsUpdating = true; per (std :: list :: iterator iter = mEntities.begin (); iter! = mEntities.end (); iter ++) (* iter) -> update (); if ((* iter) -> isExpired ()) * iter = NULL; mIsUpdating = false; per (std :: list :: iterator iter = mAddedEntities.begin (); iter! = mAddedEntities.end (); iter ++) addEntity (* iter); mAddedEntities.clear (); mEntities.remove (NULL); per (std :: list :: 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 :: list :: iterator iter = mEntities.begin (); iter! = mEntities.end (); iter ++) (* iter) -> draw (spriteBatch);
Ricorda che se si modifica un elenco mentre si esegue l'iterazione su di esso, si otterrà un'eccezione di runtime. Il codice sopra si occupa di questo accodando le entità aggiunte durante l'aggiornamento in un elenco separato e aggiungendole dopo aver completato l'aggiornamento delle entità esistenti.
Dovremo caricare alcune trame se vogliamo disegnare qualcosa, quindi faremo una classe statica per contenere i riferimenti a tutte le nostre trame:
classe Arte: pubblico tSingletonprotected: tTexture * mPlayer; tTexture * mSeeker; tTexture * mWanderer; tTexture * mBullet; tTexture * mPointer; protetto: Art (); public: tTexture * getPlayer () const; tTexture * getSeeker () const; tTexture * getWanderer () const; tTexture * getBullet () const; tTexture * getPointer () const; classe di amici tSingleton ; ; Art :: Art () mPlayer = new tTexture (tSurface ("player.png")); mSeeker = new tTexture (tSurface ("seeker.png")); mWanderer = new tTexture (tSurface ("wanderer.png")); mBullet = new tTexture (tSurface ("bullet.png")); mPointer = new tTexture (tSurface ("pointer.png"));
Carichiamo l'arte chiamando Arte :: getInstance ()
nel GameRoot :: onInitView ()
. Questo causa il Arte
singleton per essere costruito e chiamare il costruttore, Arte :: Art ()
.
Inoltre, un certo numero di classi dovrà conoscere le dimensioni dello schermo, quindi abbiamo i seguenti membri in GameRoot
:
tDimension2f mViewportSize; tSpriteBatch * mSpriteBatch; tAutosizeViewport * mViewport;
E nel GameRoot
costruttore, abbiamo impostato la dimensione:
GameRoot :: GameRoot (): mViewportSize (800, 600), mSpriteBatch (NULL)
La risoluzione 800x600px è ciò che utilizzava lo Shape Blaster basato su XNA originale. Potremmo usare qualsiasi risoluzione che desideriamo (ad esempio una più vicina all'iPhone o alla risoluzione specifica dell'iPad), ma ci atteniamo alla risoluzione originale solo per assicurarci che il nostro gioco corrisponda all'aspetto e all'originale dell'originale.
Ora andremo oltre il PlayerShip
classe:
classe PlayerShip: entità pubblica, tSingleton pubblicoprotected: static const int kCooldownFrames; int mCooldowmRemaining; int mFramesUntilRespawn; protected: PlayerShip (); pubblico: void update (); void draw (tSpriteBatch * spriteBatch); bool getIsDead (); void kill (); classe di amici tSingleton ; ; PlayerShip :: PlayerShip (): mCooldowmRemaining (0), mFramesUntilRespawn (0) mImage = Art :: getInstance () -> getPlayer (); mPosition = tPoint2f (GameRoot :: getInstance () -> getViewportSize (). x / 2, GameRoot :: getInstance () -> getViewportSize (). y / 2); mRadius = 10;
Abbiamo fatto PlayerShip
un singleton, imposta la sua immagine e la posiziona al centro dello schermo.
Infine, aggiungiamo la nave giocatore al EntityManager
. Il codice in GameRoot :: onInitView
Somiglia a questo:
// In GameRoot :: onInitView EntityManager :: getInstance () -> add (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);
Stiamo disegnando gli sprite con miscelazione additiva, che è parte di ciò che darà loro il loro aspetto "neon". Inoltre, non vogliamo alcuna sfocatura o fusione, quindi usiamo GL_NEAREST
per i nostri filtri. Non abbiamo bisogno di cure approfondite o di abbattimento del backface (non aggiungiamo comunque inutili spese generali), quindi lo disattiviamo.
Il codice in GameRoot :: onRedrawView
Somiglia a questo:
// In GameRoot :: onRedrawView EntityManager :: getInstance () -> update (); 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-> end (); glFlush ();
Se esegui il gioco a questo punto, dovresti vedere la tua nave al centro dello schermo. Tuttavia, non risponde all'input. Aggiungiamo un po 'di input al gioco successivo.
Per il movimento, useremo un'interfaccia multi-touch. Prima di andare a pieno regime con i gamepad su schermo, avremo solo un'interfaccia touch di base attiva e funzionante.
Nell'originale Shape Blaster per Windows, il movimento del giocatore può essere eseguito con i tasti WASD della tastiera. Per mirare, potevano usare i tasti freccia o il mouse. Questo ha lo scopo di emulare i controlli a doppio bastone di Geometry Wars: uno stick analogico per il movimento, uno per il puntamento.
Poiché Shape Blaster utilizza già il concetto di movimento della tastiera e del mouse, il modo più semplice per aggiungere input è quello di emulare i comandi della tastiera e del mouse tramite il tocco. Inizieremo con il movimento del mouse, poiché sia il touch che il mouse condividono un componente simile: un punto contenente le coordinate X e Y..
Creeremo una classe statica per tenere traccia dei vari dispositivi di input e per occuparci del passaggio tra i diversi tipi di mira:
classe Input: pubblico tSingleton protected: tPoint2f mMouseState; tPoint2f mLastMouseState; tPoint2f mFreshMouseState; std :: vectormKeyboardState; std :: vector mLastKeyboardState; std :: vector mFreshKeyboardState; bool mIsAimingWithMouse; uint8_t mLeftEngaged; uint8_t mRightEngaged; public: enum KeyType kUp = 0, kLeft, kDown, kRight, kW, kA, kS, kD,; protected: tVector2f GetMouseAimDirection () const; protected: Input (); public: tPoint2f getMousePosition () const; void update (); // Controlla se una chiave è stata appena premuta bool wasKeyPressed (KeyType) const; tVector2f getMovementDirection () const; tVector2f getAimDirection () const; void onKeyboard (const tKeyboardEvent & msg); void onTouch (const tTouchEvent & msg); classe di amici tSingleton; ; void Input :: update () mLastKeyboardState = mKeyboardState; mLastMouseState = mMouseState; mKeyboardState = mFreshKeyboardState; mMouseState = mFreshMouseState; if (mKeyboardState [kLeft] || mKeyboardState [kRight] || mKeyboardState [kUp] || mKeyboardState [kDown]) mIsAimingWithMouse = false; else if (mMouseState! = mLastMouseState) mIsAimingWithMouse = true;
Noi chiamiamo Ingresso :: update ()
all'inizio di GameRoot :: onRedrawView ()
perché la classe di input funzioni.
Come affermato in precedenza, useremo il tastiera
dichiari più tardi nella serie per spiegare movimento.
Adesso facciamo sparare la nave.
Innanzitutto, abbiamo bisogno di una classe per i proiettili.
classe Bullet: public Entity public: Bullet (const tPoint2f & position, const tVector2f & velocity); void update (); ; Bullet :: Bullet (const tPoint2f & position, const tVector2f & velocity) mImage = Art :: getInstance () -> getBullet (); mPosition = posizione; mVelocity = velocity; mOrientation = atan2f (mVelocity.y, mVelocity.x); mRadius = 8; mKind = kBullet; void Bullet :: update () if (mVelocity.lengthSquared ()> 0) mOrientation = atan2f (mVelocity.y, mVelocity.x); mPosition + = mVelocity; if (! tRectf (0, 0, GameRoot :: getInstance () -> getViewportSize ()). contiene (tPoint2f ((int32_t) mPosition.x, (int32_t) mPosition.y))) mIsExpired = true;
Vogliamo un breve periodo di cooldown tra i proiettili, quindi avremo una costante per questo:
const int PlayerShip :: kCooldownFrames = 6;
Inoltre, aggiungeremo il seguente codice a PlayerShip :: Update ()
:
tVector2f aim = Input :: getInstance () -> getAimDirection (); if (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()->aggiungi (nuovo Bullet (mPosition + offset, vel)); offset = aimMat * tVector2f (35, 8); EntityManager :: getInstance () -> add (nuovo Bullet (mPosition + offset, vel)); tSound * curShot = Sound :: getInstance () -> getShot (); if (! curShot-> isPlaying ()) curShot-> play (0, 1); if (mCooldowmRemaining> 0) mCooldowmRemaining--;
Questo codice crea due punti elenco che viaggiano paralleli l'uno all'altro. Aggiunge una piccola quantità di casualità alla direzione, il che rende gli scatti un po 'come una mitragliatrice. Aggiungiamo insieme due numeri casuali perché questo rende più probabile che la somma sia centrata (intorno allo zero) e meno probabilità di inviare proiettili lontano. Usiamo una matrice bidimensionale per ruotare la posizione iniziale dei proiettili nella direzione in cui viaggiano.
Abbiamo anche utilizzato due nuovi metodi di supporto:
Estensioni :: NextFloat ()
: Restituisce un float casuale tra un valore minimo e massimo.MathUtil :: FromPolar ()
: Crea a tVector2f
da un angolo e grandezza.Quindi vediamo come sono:
// In Estensioni float Estensioni :: nextFloat (float minValue, float maxValue) return (float) tMath :: random () * (maxValue - minValue) + minValue; // In MathUtil tVector2f MathUtil :: fromPolar (float angle, float magnitude) return magnitude * tVector2f ((float) cosf (angle), (float) sinf (angle));
C'è un'altra cosa che dovremmo fare ora che abbiamo il inital Ingresso
classe: disegniamo un cursore del mouse personalizzato per rendere più facile vedere dove punta la nave. Nel GameRoot.Draw
, semplicemente disegnare Art's mPointer
nella posizione "del mouse".
mSpriteBatch-> draw (0, Art :: getInstance () -> getPointer (), Input :: getInstance () -> getMousePosition (), tOptional());
Se stai testando il gioco ora, sarai in grado di toccare ovunque sullo schermo per mirare al flusso continuo di proiettili, il che è un buon inizio.
Nella parte successiva, completeremo il gameplay iniziale aggiungendo nemici e un punteggio.