Dans cette série de didacticiels, je vais vous montrer comment créer un jeu de tir double-stick inspiré de Geometry Wars, avec des graphismes au néon, des effets de particules incroyables et une musique géniale, pour iOS avec C ++ et OpenGL ES 2.0. Dans cette dernière partie, nous ajouterons la grille d’arrière-plan qui se déforme en fonction de l’action du jeu..
Jusqu'à présent, nous avons créé le gameplay, le gamepad virtuel et les effets de particules dans la série. Dans cette dernière partie, nous allons créer une grille d’arrière-plan dynamique.
Comme mentionné dans la partie précédente, vous remarquerez une baisse spectaculaire du nombre d'images par seconde si vous exécutez toujours le code en mode débogage. Consultez ce didacticiel pour savoir comment passer en mode publication pour une optimisation complète du compilateur (et une compilation plus rapide)..
L'un des effets les plus intéressants de Geometry Wars est la grille d'arrière-plan qui déforme. Nous examinerons comment créer un effet similaire dans Shape Blaster. La grille réagira aux balles, aux trous noirs et au joueur qui se réveille. Ce n'est pas difficile à faire et ça a l'air génial.
Nous allons faire la grille en utilisant une simulation de printemps. À chaque intersection de la grille, nous allons mettre un petit poids et attacher un ressort de chaque côté. Ces ressorts ne feront que tirer et ne jamais pousser, un peu comme un élastique. Pour maintenir la grille en position, les masses à la limite de la grille seront ancrées sur place. Ci-dessous un schéma de la mise en page.
Nous allons créer une classe appelée la grille
pour créer cet effet. Cependant, avant de travailler sur la grille elle-même, nous devons créer deux classes d'assistance: Printemps
et PointMass
.
le PointMass
classe représente les masses auxquelles nous attacherons les ressorts. Les sources ne se connectent jamais directement aux autres sources; au lieu de cela, ils appliquent une force aux masses qu’ils connectent, ce qui peut étirer d’autres sources.
classe PointMass protected: tVector3f mAcceleration; float mDamping; public: tVector3f mPosition; tVector3f mVelocity; float mInverseMass; public: PointMass (); PointMass (const tVector3f & position, float invMass); void applyForce (const tVector3f & force); void augmentationDamping (facteur de flottement); void update (); ; PointMass :: PointMass (): mAcceleration (0,0,0), mDamping (0.98f), mPosition (0), mVelocity (0,0,0), mInverseMass (0) PointMass :: PointMass (const tVector3f & position , float invMass): mAcceleration (0,0,0), mDamping (0.98f), mPosition (position), mVelocity (0,0,0), mInverseMass (invMass) void PointMass :: applyForce (const tVector3f & force) mAcceleration + = force * mInverseMass; void PointMass :: augmentationDamping (facteur de flottement) mDamping * = facteur; 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;
Il y a quelques points intéressants à propos de cette classe. Tout d'abord, notez qu'il stocke le inverse de la masse, 1 / masse
. C'est souvent une bonne idée dans les simulations de physique, car les équations de physique ont tendance à utiliser l'inverse de la masse plus souvent, et parce qu'elles nous permettent de représenter facilement des objets immuables et infiniment lourds en réglant la masse inverse à zéro..
Deuxièmement, la classe contient également un amortissement variable. Ceci est utilisé à peu près comme friction ou résistance à l'air; il ralentit progressivement la masse. Cela permet de calmer la grille et augmente également la stabilité de la simulation de printemps.
le PointMass :: update ()
méthode effectue le travail de déplacement de la masse ponctuelle de chaque image. Cela commence par l'intégration symplectique d'Euler, ce qui signifie simplement que nous ajoutons l'accélération à la vélocité, puis la vélocité mise à jour à la position. Cela diffère de l'intégration standard d'Euler dans laquelle nous mettrions à jour la vitesse après la mise à jour de la position..
Pointe: Symplectic Euler est préférable pour les simulations printanières car il conserve de l’énergie. Si vous utilisez une intégration Euler régulière et créez des ressorts sans amortissement, ils auront tendance à s'étirer de plus en plus à chaque rebond à mesure qu'ils gagnent de l'énergie, ce qui finira par interrompre votre simulation..
Après avoir mis à jour la vélocité et la position, nous vérifions si la vélocité est très petite et, le cas échéant, nous la mettons à zéro. Cela peut être important pour les performances en raison de la nature des nombres à virgule flottante dénormalisés.
(Lorsque les nombres en virgule flottante deviennent très petits, ils utilisent une représentation spéciale appelée numéro dénormalisé. Cela a l'avantage de permettre aux flottants de représenter des nombres plus petits, mais cela a un prix. La plupart des jeux de puces ne peuvent pas utiliser leurs opérations arithmétiques standard sur des nombres dénormalisés et doivent les émuler en utilisant une série d'étapes. Cela peut être des dizaines à des centaines de fois plus lent que l'exécution d'opérations sur des nombres à virgule flottante normalisés. Puisque nous multiplions notre vitesse par notre facteur d’amortissement à chaque image, elle finira par devenir très petite. Nous ne nous soucions pas vraiment de ces vitesses minuscules, nous le mettons donc à zéro.)
le PointMass :: augmentationDamping ()
Cette méthode est utilisée pour augmenter temporairement l’amortissement. Nous l'utiliserons plus tard pour certains effets.
Un ressort relie deux masses ponctuelles et, s’il est tendu au-delà de sa longueur naturelle, applique une force qui tire les masses ensemble. Les ressorts suivent une version modifiée de la loi de Hooke avec amortissement:
\ [f = −kx - bv \]
Le code pour le Printemps
la classe est la suivante:
classe Spring public: PointMass * mEnd1; PointMass * mEnd2; float mTargetLength; float mStiffness; float mDamping; public: Spring (PointMass * end1, PointMass * end2, rigidité du flotteur, amortissement du flotteur); void update (); ; Spring :: Spring (PointMass * end1, PointMass * end2, rigidité de flottement, amortissement de flottement): mEnd1 (end1), mEnd2 (end2), mTargetLength (mEnd1-> mPosition.distance (mEnd2-> mPosition) * 0.95f), mStiffness (rigidité), mDamping (amortissement) void Spring :: update () tVector3f x = mEnd1-> mPosition - mEnd2-> mPosition; longueur de float = x.length (); if (longueur> mTargetLength) x = (x / longueur) * (longueur - mTargetLength); tVector3f dv = mEnd2-> mVelocity - mEnd1-> mVelocity; tVector3f force = mStiffness * x - dv * mDamping; mEnd1-> applyForce (-force); mEnd2-> applyForce (force);
Lorsque nous créons un ressort, nous fixons sa longueur naturelle à un peu moins de la distance entre les deux points d'extrémité. Cela maintient la grille tendue même au repos et améliore quelque peu l'aspect.
le Spring :: update ()
méthode vérifie d’abord si le ressort est étiré au-delà de sa longueur naturelle. Si ce n'est pas étiré, rien ne se passe. Si c'est le cas, nous utilisons la loi de Hooke modifiée pour trouver la force du ressort et l'appliquer aux deux masses connectées..
Maintenant que nous avons les classes imbriquées nécessaires, nous sommes prêts à créer la grille. Nous commençons par créer PointMass
objets à chaque intersection sur la grille. Nous créons également des ancres inamovibles PointMass
objets pour maintenir la grille en place. Nous relions ensuite les masses avec des ressorts.
std :: vectormSprings; PointMass * mPoints; Grid :: Grid (const tRectf & rect, const tVector2f & spacing) 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; pour (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));
La première pour
La boucle crée des masses régulières et des masses fixes à chaque intersection de la grille. Nous n'utiliserons pas réellement toutes les masses immobiles, et les masses non utilisées seront simplement des ordures ramassées quelque temps après la fin du constructeur. Nous pourrions optimiser en évitant de créer des objets inutiles, mais comme la grille n'est généralement créée qu'une fois, cela ne fera pas beaucoup de différence.
En plus d'utiliser des masses de points d'ancrage autour de la bordure de la grille, nous utiliserons également certaines masses d'ancrage à l'intérieur de la grille. Ceux-ci seront utilisés pour aider très doucement à ramener la grille dans sa position initiale après avoir été déformée.
Comme les points d'ancrage ne bougent jamais, il n'est pas nécessaire de mettre à jour chaque image. nous pouvons simplement les brancher aux sources et les oublier. Par conséquent, nous n'avons pas de variable membre dans le la grille
classe pour ces masses.
Vous pouvez modifier un certain nombre de valeurs lors de la création de la grille. Les plus importants sont la rigidité et l’amortissement des ressorts. (La rigidité et l'amortissement des ancrages de bordure et des ancrages intérieurs sont réglés indépendamment des ressorts principaux.) Des valeurs de rigidité plus élevées font osciller les ressorts plus rapidement et des valeurs d'amortissement plus élevées provoquent un ralentissement plus rapide des ressorts..
Pour que la grille se déplace, nous devons la mettre à jour à chaque image. C’est très simple, car nous avons déjà fait tout le travail difficile dans le PointMass
et Printemps
Des classes:
void Grid :: update () pour (size_t i = 0; i < mSprings.size(); i++) mSprings[i].update(); for(int i = 0; i < mCols * mRows; i++) mPoints[i].update();
Maintenant, nous allons ajouter quelques méthodes qui manipulent la grille. Vous pouvez ajouter des méthodes pour tout type de manipulation à laquelle vous pouvez penser. Nous allons implémenter ici trois types de manipulations: pousser une partie de la grille dans une direction donnée, pousser la grille vers l’extérieur à partir d’un point donné et tirer la grille vers un certain point. Les trois affecteront la grille dans un rayon donné à partir d'un point cible. Voici quelques images de ces manipulations en action:
Balles repoussant la grille vers l'extérieur.
Sucer la grille vers l'intérieur.
Vague créée en poussant la grille le long de l'axe z.
void Grid :: applyDirectedForce (const tVector3f & force, const tVector3f & position, rayon de flottement) pour (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);
Nous utiliserons ces trois méthodes dans Shape Blaster pour différents effets.
Nous allons dessiner la grille en traçant des segments de ligne entre chaque paire de points voisins. Tout d'abord, nous allons ajouter une méthode d'extension prenant un tSpriteBatch
le pointeur en tant que paramètre nous permettant de tracer des segments de ligne en prenant une texture d'un pixel et en l'étirant dans une ligne.
Ouvrez le Art
classe et déclare une texture pour le pixel:
classe Art: tSingleton public; protected: tTexture * mPixel;… public: tTexture * getPixel () const;…;
Vous pouvez définir la texture en pixels de la même manière que les autres images. Nous allons donc ajouter pixel.png
(une image 1x1px avec le seul pixel défini sur blanc) au projet et chargez-le dans le texture
:
mPixel = new tTexture (tSurface ("pixel.png"));
Ajoutons maintenant la méthode suivante à la Les extensions
classe:
void Extensions :: drawLine (tSpriteBatch * spriteBatch, const tVector2f & start, const tVector2f & end, const tColor4f & couleur, épaisseur flottante) tVector2f delta = end - start; spriteBatch-> draw (0, Art :: getInstance () -> getPixel (), tPoint2f ((int32_t) start.x, (int32_t) start.y), tOptional(), couleur, toAngle (delta), tPoint2f (0, 0), tVector2f (delta.length (), épaisseur));
Cette méthode étire, fait pivoter et teinte la texture en pixels pour produire la ligne souhaitée..
Ensuite, nous avons besoin d’une méthode pour projeter les points de grille 3D sur notre écran 2D. Normalement, cela pourrait être fait en utilisant des matrices, mais ici nous allons transformer les coordonnées manuellement à la place.
Ajouter ce qui suit au la grille
classe:
tVector2f Grid :: toVec2 (const tVector3f & v) facteur de flottement = (v.z + 2000.0f) * 0.0005f; return (tVector2f (v.x, v.y) - mScreenSize * 0.5f) * facteur + mScreenSize * 0.5f;
Cette transformation donnera à la grille une vue en perspective où les points les plus éloignés apparaîtront plus rapprochés sur l'écran. Maintenant, nous pouvons dessiner la grille en parcourant les lignes et les colonnes et en traçant des lignes entre elles:
void Grid :: draw (tSpriteBatch * spriteBatch) int width = mCols; int height = mRows; couleur tColor4f (0,12f, 0,12f, 0,55f, 0,33f); pour (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); épaisseur du flotteur = (y% 3 == 1)? 3,0f: 1,0f; Extensions :: drawLine (spriteBatch, left, p, couleur, épaisseur); if (y> 1) up = toVec2 (GetPointMass (mPoints, x, y - 1) -> mPosition); épaisseur du flotteur = (x% 3 == 1)? 3,0f: 1,0f; Extensions :: drawLine (spriteBatch, up, p, couleur, épaisseur);
Dans le code ci-dessus, p
est notre point actuel sur la grille, la gauche
est le point directement à sa gauche et en haut
est le point directement au-dessus de lui. Nous dessinons chaque troisième ligne plus épaisse à la fois horizontalement et verticalement pour un effet visuel.
Nous pouvons optimiser le réseau en améliorant la qualité visuelle d'un nombre donné de ressorts sans augmenter de manière significative le coût des performances. Nous allons faire deux de ces optimisations.
Nous allons densifier la grille en ajoutant des segments de ligne à l'intérieur des cellules de grille existantes. Nous le faisons en traçant des lignes à partir du centre d'un côté de la cellule jusqu'au milieu du côté opposé. L'image ci-dessous montre les nouvelles lignes interpolées en rouge.
Dessiner les lignes interpolées est simple. Si vous avez deux points, une
et b
, leur point médian est (a + b) / 2
. Donc, pour dessiner les lignes interpolées, nous ajoutons le code suivant dans le pour
boucles de notre Grille :: draw ()
méthode:
if (x> 1 && y> 1) tVector2f upLeft = toVec2 (GetPointMass (mPoints, x - 1, y - 1) -> mPosition); Extensions :: drawLine (spriteBatch, 0.5f * (upLeft + up), 0.5f * (gauche + p), couleur, 1.0f); // ligne verticale Extensions :: drawLine (spriteBatch, 0.5f * (upLeft + left), 0.5f * (up + p), color, 1.0f); // ligne horizontale
La deuxième amélioration consiste à effectuer une interpolation sur nos segments de droite pour les transformer en courbes plus lisses. Dans la version XNA originale de ce jeu, le code s’appuyait sur celui de XNA. Vector2.CatmullRom ()
méthode qui effectue une interpolation Catmull-Rom. Vous passez la méthode quatre points séquentiels sur une ligne courbe, et il retournera des points le long d'une courbe lisse entre les deuxième et troisième points que vous avez fournis.
Comme cet algorithme n'existe pas dans la bibliothèque standard de C ou C ++, nous devrons le mettre en œuvre nous-mêmes. Heureusement, une implémentation de référence est disponible. J'ai fourni un MathUtil :: catmullRom ()
Méthode basée sur cette implémentation de référence:
float MathUtil :: catmullRom (const float value1, const float value2, const float value3, const float value4, montant float) // Utilisation de la formule à partir de http://www.mvps.org/directx/articles/catmull/ float amountSquared = montant * montant; float amountCubed = amountSquared * montant; return (float) (0.5f * (2.0f * valeur2 + (valeur3 - valeur1) * montant + (2.0f * valeur1 - 5.0f * valeur2 + 4.0f * valeur3 - valeur4) * montantSquared + (3.0f * valeur2 - valeur1 - 3.0f * valeur3 + valeur4) * montantCubé)); tVector2f MathUtil :: catmullRom (const tVector2f & value1, const tVector2f & value2, const tVector2f & value3, const tVector2f & value4, montant en virgule flottante) return tVector2f (MathUtil :: catmullRom (value1.x, valeur1.x, valeur3.x, valeur, x , montant), MathUtil :: catmullRom (valeur1.y, valeur2.y, valeur3.y, valeur4.y, montant));
Le cinquième argument à MathUtil :: catmullRom ()
est un facteur de pondération qui détermine le point sur la courbe interpolée qu’elle renvoie. Un facteur de pondération de 0
ou 1
retournera respectivement le deuxième ou troisième point que vous avez fourni, et un facteur de pondération de 0.5
renverra le point sur la courbe interpolée à mi-chemin entre les deux points. En déplaçant progressivement le facteur de pondération de zéro à un et en traçant des lignes entre les points renvoyés, nous pouvons produire une courbe parfaitement lisse. Cependant, pour que le coût de performance reste faible, nous ne prendrons en compte qu'un seul point interpolé, avec un facteur de pondération de 0.5
. Nous remplaçons ensuite la ligne droite originale dans la grille par deux lignes qui se rejoignent au point interpolé.
Le diagramme ci-dessous montre l'effet de cette interpolation:
Comme les segments de ligne dans la grille sont déjà petits, l’utilisation de plusieurs points interpolés ne fait généralement pas une différence notable..
Souvent, les lignes de notre grille seront très droites et ne nécessiteront aucun lissage. Nous pouvons vérifier cela et éviter de tracer deux lignes au lieu d'une: nous vérifions si la distance entre le point interpolé et le milieu de la droite est supérieure à un pixel; si c'est le cas, nous supposons que la ligne est courbe et nous dessinons deux segments.
La modification de notre Grille :: draw ()
La méthode d'ajout d'une interpolation Catmull-Rom pour les lignes horizontales est présentée ci-dessous..
left = toVec2 (GetPointMass (mPoints, x - 1, y) -> mPosition); épaisseur du flotteur = (y% 3 == 1)? 3,0f: 1,0f; int clampedX = (int) tMath :: min (x + 1, largeur - 1); tVector2f mid = MathUtil :: catmullRom (toVec2 (GetPointMass (mPoints, x - 2, y) -> mPosition), gauche, p, toVec2 (GetPointMass (mPoints, clampedX, y) -> mPosition), 0.5f); if (mid.distanceSquared ((left + p) / 2)> 1) Extensions :: drawLine (spriteBatch, left, mid, couleur, épaisseur); Extensions :: drawLine (spriteBatch, mid, p, couleur, épaisseur); else Extensions :: drawLine (spriteBatch, gauche, p, couleur, épaisseur);
L'image ci-dessous montre les effets du lissage. Un point vert est dessiné à chaque point interpolé pour mieux illustrer où les lignes sont lissées.
Il est maintenant temps d'utiliser la grille dans notre jeu. Nous commençons par déclarer un public, statique la grille
variable dans GameRoot
et créer la grille dans le GameRoot :: onInitView
. Nous allons créer une grille d'environ 600 points comme.
const int maxGridPoints = 600; tVector2f gridSpacing = tVector2f ((float) sqrtf (mViewportSize.width * mViewportSize.height / maxGridPoints)); mGrid = nouvelle grille (tRectf (0,0, mViewportSize), gridSpacing);
Bien que la version XNA originale du jeu utilise 1 600 points (au lieu de 600), cela devient beaucoup trop difficile à gérer, même pour le puissant matériel de l'iPhone. Heureusement, le code d'origine laissait une quantité de points personnalisable et, à environ 600 points de grille, nous pouvons toujours les restituer tout en maintenant une cadence optimale.
Puis on appelle Grille :: update ()
et Grille :: draw ()
du GameRoot :: onRedrawView ()
méthode en GameRoot
. Cela nous permettra de voir la grille lorsque nous lancerons le jeu. Cependant, il reste encore à faire interagir divers objets du jeu avec la grille.
Les balles vont repousser la grille. Nous avons déjà fait une méthode pour le faire appelée Grille :: applyExplosiveForce ()
. Ajouter la ligne suivante au Bullet :: update ()
méthode.
GameRoot :: getInstance () -> getGrid () -> applyExplosiveForce (0.5f * mVelocity.length (), mPosition, 80);
Cela fera en sorte que les balles repoussent la grille proportionnellement à leur vitesse. C'était assez facile.
Maintenant, travaillons sur les trous noirs. Ajouter cette ligne à BlackHole :: update ()
:
GameRoot :: getInstance () -> getGrid () -> applyImplosiveForce ((float) sinf (mSprayAngle / 2.0f) * 10 + 20, mPosition, 200);
Cela fait que le trou noir aspire la grille avec une force variable. Nous avons réutilisé le mSprayAngle
variable, ce qui provoque la pulsation de la force sur la grille en fonction de l’angle auquel elle pulvérise les particules (mais à la moitié de la fréquence en raison de la division par deux). La force transmise variera de manière sinusoïdale entre dix
et 30
.
Enfin, nous créerons une onde de choc dans la grille lorsque le vaisseau du joueur réapparaîtra après sa mort. Nous allons le faire en tirant la grille le long de l'axe z, puis en laissant la force se propager et rebondir à travers les ressorts. Encore une fois, cela nécessite seulement une petite modification de PlayerShip :: update ()
.
if (getIsDead ()) mFramesUntilRespawn--; if (mFramesUntilRespawn == 0) GameRoot :: getInstance () -> getGrid () -> applyDirectedForce (tVector3f (0, 0, 5000), tVector3f (mPosition.x, mPosition.y, 0), 50);
Nous avons mis en place le gameplay et les effets de base. C'est à vous de le transformer en un jeu complet et raffiné à votre goût. Essayez d’ajouter de nouveaux mécanismes intéressants, de nouveaux effets sympas ou une histoire unique. Si vous ne savez pas par où commencer, voici quelques suggestions:
Le ciel est la limite!