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. In questa parte finale, aggiungeremo la griglia di sfondo che si deforma in base all'azione di gioco.
Nella serie finora abbiamo creato il gameplay, il gamepad virtuale e gli effetti particellari. In questa parte finale creeremo una griglia di sfondo dinamica e deformante.
Come accennato nella parte precedente, noterai un drastico calo nel framerate se stai ancora eseguendo il codice in modalità di debug. Vedi questo tutorial per i dettagli su come passare alla modalità di rilascio per l'ottimizzazione completa del compilatore (e una build più veloce).
Uno degli effetti più interessanti di Geometry Wars è la griglia di sfondo di warping. Esamineremo come creare un effetto simile in Shape Blaster. La griglia reagirà a proiettili, buchi neri e il giocatore che respawning. Non è difficile da fare e sembra fantastico.
Creeremo la griglia utilizzando una simulazione a molla. Ad ogni intersezione della griglia, metteremo un piccolo peso e attacceremo una molla su ciascun lato. Queste molle tirano e non spingono mai, proprio come un elastico. Per mantenere la griglia in posizione, le masse al bordo della griglia saranno ancorate al loro posto. Di seguito è riportato un diagramma del layout.
Creeremo una classe chiamata Griglia
per creare questo effetto. Tuttavia, prima di lavorare sulla griglia stessa, dobbiamo creare due classi helper: Primavera
e PointMass
.
Il PointMass
la classe rappresenta le masse alle quali legheremo le molle. Le molle non si collegano mai direttamente ad altre molle; invece, applicano una forza alle masse che collegano, che a loro volta possono allungare altre molle.
class PointMass protected: tVector3f mAcceleration; float mDamping; public: tVector3f mPosition; tVector3f mVelocity; float mInverseMass; pubblico: PointMass (); PointMass (const tVector3f e position, float invMass); void applyForce (const tVector3f & force); void increaseDamping (float factor); void update (); ; PointMass :: PointMass (): mAcceleration (0,0,0), mDamping (0.98f), mPosition (0), mVelocity (0,0,0), mInverseMass (0) PointMass :: PointMass (const tVector3f e posizione , float invMass): mAcceleration (0,0,0), mDamping (0.98f), mPosition (posizione), mVelocity (0,0,0), mInverseMass (invMass) void PointMass :: applyForce (const tVector3f & force) mAcceleration + = force * mInverseMass; void PointMass :: increaseDamping (float factor) mDamping * = factor; void PointMass :: update () mVelocity + = mAcceleration; mPosition + = mVelocity; mAcceleration = tVector3f (0,0,0); if (mVelocity.lengthSquared () < 0.001f * 0.001f) mVelocity = tVector3f(0,0,0); mVelocity *= mDamping; mDamping = 0.98f;
Ci sono alcuni punti interessanti su questa classe. Innanzitutto, nota che memorizza il inverso della massa, 1 / massa
. Questa è spesso una buona idea nelle simulazioni di fisica perché le equazioni fisiche tendono a utilizzare l'inverso della massa più spesso e perché ci offre un modo semplice per rappresentare oggetti infinitamente pesanti e immobili impostando la massa inversa a zero.
In secondo luogo, la classe contiene anche a smorzamento variabile. Questo è usato approssimativamente come attrito o resistenza all'aria; rallenta gradualmente la massa. Questo aiuta a far riposare la griglia e aumenta anche la stabilità della simulazione della molla.
Il PointMass :: update ()
metodo fa il lavoro di spostare il punto di massa ogni fotogramma. Inizia facendo un'integrazione Eulero simplettale, il che significa che aggiungiamo l'accelerazione alla velocità e quindi aggiungiamo la velocità aggiornata alla posizione. Questo differisce dall'integrazione standard di Eulero in cui aggiorneremmo la velocità dopo aver aggiornato la posizione.
Mancia: Eulero simplettico è migliore per le simulazioni di primavera perché risparmia energia. Se si utilizza l'integrazione Eulero regolare e si creano molle senza smorzamento, tenderanno ad allungarsi ulteriormente ogni volta rimbalzando man mano che guadagnano energia, rompendo alla fine la simulazione.
Dopo aver aggiornato la velocità e la posizione, controlliamo se la velocità è molto piccola, e in tal caso lo impostiamo a zero. Questo può essere importante per le prestazioni a causa della natura dei numeri in virgola mobile denormalizzati.
(Quando i numeri a virgola mobile diventano molto piccoli, usano una rappresentazione speciale chiamata a numero denormalizzato. Questo ha il vantaggio di consentire ai galleggianti di rappresentare numeri più piccoli, ma ha un prezzo. La maggior parte dei chipset non può usare le loro operazioni aritmetiche standard su numeri denormalizzati e invece deve emularli usando una serie di passaggi. Questo può essere da decine a centinaia di volte più lento di eseguire operazioni su numeri in virgola mobile normalizzati. Dal momento che moltiplichiamo la nostra velocità per il nostro fattore di smorzamento ogni fotogramma, alla fine diventerà molto piccolo. In realtà non ci interessano queste velocità così piccole, quindi lo impostiamo su zero.)
Il PointMass :: increaseDamping ()
il metodo è usato per aumentare temporaneamente la quantità di smorzamento. Lo useremo più tardi per determinati effetti.
Una molla collega due masse puntiformi e, se allungata oltre la sua lunghezza naturale, applica una forza che unisce le masse. Le molle seguono una versione modificata della legge di Hooke con smorzamento:
\ [f = -kx - bv \]
Il codice per il Primavera
la classe è la seguente:
classe Spring public: PointMass * mEnd1; PointMass * mEnd2; float mTargetLength; float mStiffness; float mDamping; public: Spring (PointMass * end1, PointMass * end2, rigidità mobile, smorzamento float); void update (); ; Molla :: Molla (PointMass * end1, PointMass * end2, rigidità flottante, smorzamento flottante): mEnd1 (end1), mEnd2 (end2), mTargetLength (mEnd1-> mPosition.distance (mEnd2-> mPosition) * 0.95f), mStiffness (rigidità), mDamping (damping) void Spring :: update () tVector3f x = mEnd1-> mPosition - mEnd2-> mPosition; float length = x.length (); if (length> mTargetLength) x = (x / length) * (length - mTargetLength); tVector3f dv = mEnd2-> mVelocity - mEnd1-> mVelocity; tVector3f force = mStiffness * x - dv * mDamping; mEnd1-> applyForce (force); mEnd2-> applyForce (forza);
Quando creiamo una molla, impostiamo la lunghezza naturale della molla leggermente inferiore alla distanza tra i due punti finali. Ciò mantiene la griglia ben tesa anche a riposo e migliora un po 'l'aspetto.
Il Primavera :: update ()
il metodo controlla innanzitutto se la molla è allungata oltre la sua lunghezza naturale. Se non è allungato, non succede nulla. Se lo è, usiamo la legge di Hooke modificata per trovare la forza dalla molla e applicarla alle due masse connesse.
Ora che abbiamo le classi nidificate necessarie, siamo pronti a creare la griglia. Iniziamo creando PointMass
oggetti ad ogni intersezione sulla griglia. Creiamo anche un'ancora immobile PointMass
oggetti per mantenere la griglia in posizione. Quindi colleghiamo le masse con le molle.
std :: vectormSprings; PointMass * mPoints; Grid :: Grid (const tRectf & rect, const tVector2f e spaziatura) mScreenSize = tVector2f (GameRoot :: getInstance () -> getViewportSize (). Width, GameRoot :: getInstance () -> getViewportSize (). Height); int numColumns = (int) ((float) rect.size.width / spacing.x) + 1; int numRows = (int) ((float) rect.size.height / spacing.y) + 1; mPoints = new PointMass [numColumns * numRows]; mCols = numColumns; mRows = numRows; PointMass * fixedPoints = new PointMass [numColumns * numRows]; int column = 0, row = 0; per (float y = rect.location.y; y <= rect.location.y + rect.size.height; y += spacing.y) for (float x = rect.location.x; x <= rect.location.x + rect.size.width; x += spacing.x) SetPointMass(mPoints, column, row, PointMass(tVector3f(x, y, 0), 1)); SetPointMass(fixedPoints, column, row, PointMass(tVector3f(x, y, 0), 0)); column++; row++; column = 0; // link the point masses with springs for (int y = 0; y < numRows; y++) for (int x = 0; x < numColumns; x++) if (x == 0 || y == 0 || x == numColumns - 1 || y == numRows - 1) mSprings.push_back(Spring(GetPointMass(fixedPoints, x, y), GetPointMass(mPoints, x, y), 0.1f, 0.1f)); else if (x % 3 == 0 && y % 3 == 0) mSprings.push_back( Spring(GetPointMass(fixedPoints, x, y), GetPointMass(mPoints, x, y), 0.002f, 0.02f)); if (x > 0) mSprings.push_back (Spring (GetPointMass (mPoints, x - 1, y), GetPointMass (mPoints, x, y), 0.28f, 0.06f)); if (y> 0) mSprings.push_back (Spring (GetPointMass (mPoints, x, y - 1), GetPointMass (mPoints, x, y), 0.28f, 0.06f));
Il primo per
loop crea sia masse regolari che masse immobili a ogni intersezione della griglia. In realtà non useremo tutte le masse inamovibili e le masse inutilizzate saranno semplicemente spazzatura raccolte qualche tempo dopo la fine del costruttore. Potremmo ottimizzare evitando di creare oggetti non necessari, ma dato che la griglia di solito viene creata solo una volta, non farà molta differenza.
Oltre a utilizzare le masse del punto di ancoraggio attorno al bordo della griglia, utilizzeremo anche alcune masse di ancoraggio all'interno della griglia. Questi saranno usati per aiutare molto delicatamente a riportare la griglia nella sua posizione originale dopo essere stata deformata.
Poiché i punti di ancoraggio non si spostano mai, non è necessario aggiornarli ogni fotogramma; possiamo semplicemente collegarli alle sorgenti e dimenticarli. Pertanto, non abbiamo una variabile membro in Griglia
classe per queste masse.
Ci sono un certo numero di valori che puoi modificare nella creazione della griglia. I più importanti sono la rigidità e lo smorzamento delle molle. (La rigidità e lo smorzamento degli ancoraggi e degli ancoraggi interni sono regolati indipendentemente dalle molle principali.) Valori di rigidezza più elevati faranno oscillare le molle più rapidamente, e valori di smorzamento più elevati faranno rallentare le molle prima.
Affinché la griglia si muova, dobbiamo aggiornarla ogni fotogramma. Questo è molto semplice dato che abbiamo già fatto tutto il duro lavoro nel PointMass
e Primavera
classi:
void Grid :: update () for (size_t i = 0; i < mSprings.size(); i++) mSprings[i].update(); for(int i = 0; i < mCols * mRows; i++) mPoints[i].update();
Ora aggiungeremo alcuni metodi che manipolano la griglia. Puoi aggiungere metodi per qualsiasi tipo di manipolazione che puoi immaginare. Implementeremo tre tipi di manipolazioni qui: spingendo parte della griglia in una data direzione, spingendo la griglia verso l'esterno da qualche punto e tirando la griglia verso un certo punto. Tutti e tre influenzeranno la griglia all'interno di un dato raggio da qualche punto bersaglio. Di seguito sono riportate alcune immagini di queste manipolazioni in azione:
Pallottole che respingono la griglia verso l'esterno.
Succhiare la griglia verso l'interno.
Onda creata spingendo la griglia lungo l'asse z.
void Grid :: applyDirectedForce (const tVector3f e force, const tVector3f e position, float radius) for (int i = 0; i < mCols * mRows; i++) if (position.distanceSquared(mPoints[i].mPosition) < radius * radius) mPoints[i].applyForce(10.0f * force / (10 + position.distance(mPoints[i].mPosition))); void Grid::applyImplosiveForce(float force, const tVector3f& position, float radius) for (int i = 0; i < mCols * mRows; i++) float dist2 = position.distanceSquared(mPoints[i].mPosition); if (dist2 < radius * radius) mPoints[i].applyForce(10.0f * force * (position - mPoints[i].mPosition) / (100 + dist2)); mPoints[i].increaseDamping(0.6f); void Grid::applyExplosiveForce(float force, const tVector3f& position, float radius) for (int i = 0; i < mCols * mRows; i++) float dist2 = position.distanceSquared(mPoints[i].mPosition); if (dist2 < radius * radius) mPoints[i].applyForce(100 * force * (mPoints[i].mPosition - position) / (10000 + dist2)); mPoints[i].increaseDamping(0.6f);
Useremo tutti e tre questi metodi in Shape Blaster per effetti diversi.
Disegneremo la griglia disegnando segmenti di linea tra ciascuna coppia di punti adiacenti. Innanzitutto, aggiungeremo un metodo di estensione prendendo un tSpriteBatch
puntatore come parametro che ci consente di disegnare segmenti di linea prendendo una texture di un singolo pixel e allungandola in una linea.
Apri il Arte
classificare e dichiarare una trama per il pixel:
classe Arte: pubblico tSingleton; protected: tTexture * mPixel; ... public: tTexture * getPixel () const; ...;
Puoi impostare la trama dei pixel nello stesso modo in cui configuriamo le altre immagini, quindi aggiungeremo pixel.png
(un'immagine 1x1px con l'unico pixel impostato su bianco) per il progetto e caricarlo nel tTexture
:
mPixel = new tTexture (tSurface ("pixel.png"));
Ora aggiungiamo il seguente metodo al estensioni
classe:
estensioni void :: drawLine (tSpriteBatch * spriteBatch, const tVector2f e start, const tVector2f e end, const tColor4f & color, float thickness) tVector2f delta = end - start; spriteBatch-> draw (0, Art :: getInstance () -> getPixel (), tPoint2f ((int32_t) start.x, (int32_t) start.y), tOptional(), color, toAngle (delta), tPoint2f (0, 0), tVector2f (delta.length (), thickness));
Questo metodo allunga, ruota e tinge la trama dei pixel per produrre la linea che desideriamo.
Successivamente, abbiamo bisogno di un metodo per proiettare i punti della griglia 3D sul nostro schermo 2D. Normalmente questo può essere fatto usando le matrici, ma qui trasformeremo le coordinate manualmente.
Aggiungi il seguente al Griglia
classe:
tVector2f Grid :: toVec2 (const tVector3f & v) float factor = (v.z + 2000.0f) * 0.0005f; return (tVector2f (v.x, v.y) - mScreenSize * 0.5f) * factor + mScreenSize * 0.5f;
Questa trasformazione darà alla griglia una vista prospettica in cui punti lontani appaiono più vicini sullo schermo. Ora possiamo disegnare la griglia ripetendo le righe e le colonne e disegnando le linee tra loro:
void Grid :: draw (tSpriteBatch * spriteBatch) int width = mCols; int height = mRows; colore tColor4f (0,12f, 0,12f, 0,55f, 0,33f); per (int y = 1; y < height; y++) for (int x = 1; x < width; x++) tVector2f left, up; tVector2f p = toVec2(GetPointMass(mPoints, x, y)->mPosition); if (x> 1) left = toVec2 (GetPointMass (mPoints, x - 1, y) -> mPosition); float thickness = (y% 3 == 1)? 3.0f: 1.0f; Estensioni :: drawLine (spriteBatch, left, p, color, thickness); if (y> 1) up = toVec2 (GetPointMass (mPoints, x, y - 1) -> mPosition); float thickness = (x% 3 == 1)? 3.0f: 1.0f; Estensioni :: drawLine (spriteBatch, su, p, colore, spessore);
Nel codice sopra, p
è il nostro punto attuale sulla griglia, sinistra
è il punto direttamente alla sua sinistra e su
è il punto direttamente sopra di esso. Disegniamo ogni terza linea più spesso sia orizzontalmente che verticalmente per ottenere un effetto visivo.
Possiamo ottimizzare la griglia migliorando la qualità visiva per un dato numero di molle senza aumentare significativamente il costo delle prestazioni. Faremo due di questi ottimizzazioni.
Renderemo la griglia più densa aggiungendo segmenti di linea all'interno delle celle della griglia esistenti. Lo facciamo disegnando linee dal punto medio di un lato della cella al punto medio del lato opposto. L'immagine sotto mostra le nuove linee interpolate in rosso.
Disegnare le linee interpolate è semplice. Se hai due punti, un
e B
, il loro punto medio è (a + b) / 2
. Quindi, per disegnare le linee interpolate, aggiungiamo il seguente codice all'interno di per
loop dei nostri Griglia :: draw ()
metodo:
if (x> 1 && y> 1) tVector2f upLeft = toVec2 (GetPointMass (mPoints, x - 1, y - 1) -> mPosition); Estensioni :: drawLine (spriteBatch, 0.5f * (upLeft + su), 0.5f * (left + p), color, 1.0f); // Estensioni linea verticale :: drawLine (spriteBatch, 0.5f * (upLeft + left), 0.5f * (up + p), colore, 1.0f); // linea orizzontale
Il secondo miglioramento consiste nell'eseguire l'interpolazione sui nostri segmenti rettilinei per renderli più morbidi. Nella versione originale di XNA di questo gioco, il codice si basava su XNA Vector2.CatmullRom ()
metodo che esegue l'interpolazione Catmull-Rom. Passi il metodo con quattro punti sequenziali su una linea curva e restituirà punti lungo una curva uniforme tra il secondo e il terzo punto che hai fornito.
Poiché questo algoritmo non esiste nella libreria standard di C o C ++, dovremo implementarlo da soli. Fortunatamente è disponibile un'implementazione di riferimento. Ho fornito un MathUtil :: catmullRom ()
metodo basato su questa implementazione di riferimento:
float MathUtil :: catmullRom (const float value1, const float value2, const float value3, const float value4, float amount) // Utilizzo della formula da http://www.mvps.org/directx/articles/catmull/ float amountSquared = importo * importo; float amountCubed = amountSquared * amount; return (float) (0.5f * (2.0f * valore2 + (valore3 - valore1) * importo + (2.0f * valore1 - 5.0f * valore2 + 4.0f * valore3 - valore4) * importoSquared + (3.0f * valore2 - valore1 - 3.0f * value3 + value4) * amountCubed)); tVector2f MathUtil :: catmullRom (const tVector2f e value1, const tVector2f & value2, const tVector2f e value3, const tVector2f e value4, float amount) return tVector2f (MathUtil :: catmullRom (valore1.x, valore2.x, valore3.x, valore4.x , quantità), MathUtil :: catmullRom (valore1.y, valore2.y, valore3.y, valore4.y, quantità));
Il quinto argomento di MathUtil :: catmullRom ()
è un fattore di ponderazione che determina quale punto della curva interpolata restituisce. Un fattore di ponderazione di 0
o 1
restituirà rispettivamente il secondo o il terzo punto che hai fornito e un fattore di ponderazione di 0.5
restituirà il punto sulla curva interpolata a metà strada tra i due punti. Spostando gradualmente il fattore di ponderazione da zero a uno e disegnando le linee tra i punti restituiti, possiamo produrre una curva perfettamente liscia. Tuttavia, per mantenere bassi i costi delle prestazioni, prenderemo in considerazione un solo punto interpolato, con un fattore di ponderazione di 0.5
. Sostituiamo quindi la linea retta originale nella griglia con due linee che si incontrano nel punto interpolato.
Lo schema seguente mostra l'effetto di questa interpolazione:
Poiché i segmenti di linea nella griglia sono già piccoli, l'uso di più di un punto interpolato generalmente non fa una differenza evidente.
Spesso, le linee nella nostra griglia saranno molto dritte e non richiedono alcuna levigatura. Possiamo verificarlo ed evitare di dover disegnare due righe anziché una: controlliamo se la distanza tra il punto interpolato e il punto medio della retta è maggiore di un pixel; se lo è, assumiamo che la linea sia curva e disegniamo due segmenti di linea.
La modifica al nostro Griglia :: draw ()
Il metodo per aggiungere l'interpolazione Catmull-Rom per le linee orizzontali è mostrato sotto.
left = toVec2 (GetPointMass (mPoints, x - 1, y) -> mPosition); float thickness = (y% 3 == 1)? 3.0f: 1.0f; int clampedX = (int) tMath :: min (x + 1, width - 1); tVector2f mid = MathUtil :: catmullRom (toVec2 (GetPointMass (mPoints, x - 2, y) -> mPosition), sinistra, p, toVec2 (GetPointMass (mPoints, clampedX, y) -> mPosition), 0,5f); if (mid.distanceSquared ((left + p) / 2)> 1) Extensions :: drawLine (spriteBatch, left, mid, color, thickness); Estensioni :: drawLine (spriteBatch, mid, p, color, thickness); else Extensions :: drawLine (spriteBatch, left, p, color, thickness);
L'immagine sotto mostra gli effetti della levigatura. Un punto verde viene disegnato in corrispondenza di ciascun punto interpolato per illustrare meglio dove vengono smussate le linee.
Ora è il momento di usare la griglia nel nostro gioco. Iniziamo dichiarando pubblico, statico Griglia
variabile in GameRoot
e creando la griglia nel GameRoot :: onInitView
. Creeremo una griglia con circa 600 punti in questo modo.
const int maxGridPoints = 600; tVector2f gridSpacing = tVector2f ((float) sqrtf (mViewportSize.width * mViewportSize.height / maxGridPoints)); mGrid = new Grid (tRectf (0,0, mViewportSize), gridSpacing);
Anche se la versione XNA originale del gioco utilizza 1.600 punti (anziché 600), questo diventa troppo da gestire anche per il potente hardware dell'iPhone. Fortunatamente, il codice originale ha lasciato la quantità di punti personalizzabili e, a circa 600 punti griglia, possiamo ancora renderli e mantenere comunque una frequenza fotogrammi ottimale.
Quindi chiamiamo Griglia :: update ()
e Griglia :: draw ()
dal GameRoot :: onRedrawView ()
metodo in GameRoot
. Questo ci permetterà di vedere la griglia quando eseguiremo il gioco. Tuttavia, abbiamo ancora bisogno di far interagire vari oggetti di gioco con la griglia.
I proiettili respingono la griglia. Abbiamo già creato un metodo per farlo Griglia :: applyExplosiveForce ()
. Aggiungi la seguente riga al Proiettile :: update ()
metodo.
GameRoot :: getInstance () -> getGrid () -> applyExplosiveForce (0.5f * mVelocity.length (), mPosition, 80);
In questo modo i proiettili respingono la griglia proporzionalmente alla loro velocità. E 'stato abbastanza facile.
Ora lavoriamo sui buchi neri. Aggiungi questa linea a BlackHole :: update ()
:
GameRoot :: getInstance () -> getGrid () -> applyImplosiveForce ((float) sinf (mSprayAngle / 2.0f) * 10 + 20, mPosition, 200);
Questo fa sì che il buco nero succhi nella griglia con una quantità variabile di forza. Abbiamo riutilizzato il mSprayAngle
variabile, che farà sì che la forza sulla griglia pulsi in sincronia con l'angolo che irradia particelle (sebbene a metà della frequenza dovuta alla divisione per due). La forza passata varierà sinusoidalmente tra 10
e 30
.
Infine, creeremo un'onda d'urto nella griglia quando la nave del giocatore respawn dopo la morte. Lo faremo tirando la griglia lungo l'asse z e permettendo alla forza di propagarsi e rimbalzare attraverso le molle. Di nuovo, questo richiede solo una piccola modifica a PlayerShip :: update ()
.
if (getIsDead ()) mFramesUntilRespawn--; if (mFramesUntilRespawn == 0) GameRoot :: getInstance () -> getGrid () -> applyDirectedForce (tVector3f (0, 0, 5000), tVector3f (mPosition.x, mPosition.y, 0), 50);
Abbiamo il gameplay di base e gli effetti implementati. Spetta a te trasformarlo in un gioco completo e raffinato con il tuo gusto. Prova ad aggiungere alcune interessanti nuove meccaniche, alcuni fantastici nuovi effetti o una storia unica. Nel caso in cui non sei sicuro di dove cominciare, ecco alcuni suggerimenti:
Il cielo è il limite!