I den här serien av handledningar visar jag dig hur man gör en Geometry Wars-inspirerad tvillingstickskytte med neongrafik, galen partikeleffekter och fantastisk musik, för iOS med C ++ och OpenGL ES 2.0. I den här sista delen lägger vi till bakgrundsramen som varnar baserat på in-game-åtgärden.
I serien hittills har vi skapat gameplay, virtuell gamepad och partikel effekter. I den här sista delen kommer vi att skapa ett dynamiskt, krusande bakgrundsrör.
Som nämnts i den föregående delen kommer du att märka en dramatisk droppe i framerate om du fortfarande kör koden i debug-läge. Se den handledningen för detaljer om hur man byter till släppläge för fullständig kompilatoroptimering (och en snabbare byggning).
En av de coolaste effekterna i Geometry Wars är klingningsbakgrundet. Vi undersöker hur du skapar en liknande effekt i Shape Blaster. Nätet kommer att reagera på kulor, svarta hål och spelare som respekterar. Det är inte svårt att göra och det ser fantastiskt ut.
Vi gör nätet med en fjädersimulering. Vid varje korsning av gallret lägger vi en liten vikt och bifogar en fjäder på varje sida. Dessa fjädrar kommer bara att dra och aldrig trycka, som ett gummiband. För att hålla nätet i läge, kommer massorna vid gridens gräns förankras på plats. Nedan är ett diagram över layouten.
Vi skapar en klass som heter Rutnät
för att skapa denna effekt. Men innan vi arbetar på själva nätet måste vi göra två hjälparklasser: Vår
och PointMass
.
De PointMass
klassen representerar de massor som vi kommer att fästa fjädrarna på. Fjädrar kopplas aldrig direkt till andra fjädrar; i stället applicerar de en kraft till de massor de förbinder, vilket i sin tur kan sträcka andra fjädrar.
klass PointMass protected: tVector3f mAcceleration; float mDamping; allmänhet: tVector3f mPosition; tVector3f mVelocity; float mInverseMass; allmänhet: PointMass (); PointMass (const tVector3f & position, float invMass); void applyForce (const tVector3f & force); void increaseDamping (float factor); tomt uppdatering (); ; 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 :: increaseDamping (floatfaktor) mDamping * = faktor; void PointMass :: uppdatering () mVelocity + = mAcceleration; mPosition + = mVelocity; mAcceleration = tVector3f (0,0,0); om (mVelocity.lengthSquared () < 0.001f * 0.001f) mVelocity = tVector3f(0,0,0); mVelocity *= mDamping; mDamping = 0.98f;
Det finns några intressanta punkter om den här klassen. Först märker du att det lagrar omvänd av massan, 1 / massa
. Det här är ofta en bra idé i fysiksimuleringar, eftersom fysikekvationer tenderar att använda massens inversare oftare, och eftersom det ger oss ett enkelt sätt att representera oändligt tunga, obotliga föremål genom att ställa in den omvända massan till noll.
För det andra innehåller klassen också en dämpning variabel. Detta används ungefär som friktion eller luftmotstånd; det sakta gradvis massan ner. Detta bidrar till att nätet i slutändan kommer att vila och ökar också stabiliteten i vårens simulering.
De PointMass :: uppdatering ()
Metoden gör arbetet med att flytta punktmassan varje ram. Det börjar med att göra en symplektisk Euler-integration, vilket bara innebär att vi lägger till accelerationen till hastigheten och sedan lägger till den uppdaterade hastigheten på positionen. Detta skiljer sig från standard Euler integration där vi skulle uppdatera hastigheten efter uppdatering av positionen.
Tips: Symplectic Euler är bättre för vårsimuleringar eftersom det sparar energi. Om du använder regelbunden Euler-integration och skapar fjädrar utan dämpning, kommer de att tendera att sträcka sig längre och mer varje studs då de får energi, så småningom bryter din simulering.
Efter uppdatering av hastighet och position kontrollerar vi om hastigheten är väldigt liten, och om så sätter vi den till noll. Detta kan vara viktigt för prestanda på grund av arten av denormaliserade flytpunkten.
(När flytpunkterna blir mycket små använder de en särskild representation som heter a denormaliserat tal. Detta har fördelen att floats representerar mindre antal, men det kommer till ett pris. De flesta chipset kan inte använda sina standardräkningstransaktioner på detormaliserade tal och istället måste emulera dem med en rad steg. Detta kan vara tiotals gånger långsammare än att utföra operationer på normaliserade flytpunkter. Eftersom vi multiplicerar vår hastighet med vår dämpningsfaktor varje ram blir den så småningom mycket liten. Vi bryr oss inte om sådana små hastigheter, så vi ställer det helt enkelt till noll.)
De PointMass :: increaseDamping ()
Metoden används för att tillfälligt öka mängden dämpning. Vi kommer att använda detta senare för vissa effekter.
En fjäder ansluter två punktmassor och, om den sträcker sig över sin naturliga längd, applicerar en kraft som drar massorna samman. Springs följer en modifierad version av Hooke's Law med dämpning:
\ [f = -kx - bv \]
Koden för Vår
klassen är som följer:
klass våren (offentlig: PointMass * mEnd1; PointMass * mEnd2; float mTargetLength; float mStiffness; float mDamping; allmänhet: Vår (PointMass * end1, PointMass * end2, float-styvhet, flytdämpning); tomt uppdatering (); ; Våren :: Våren (PointMass * end1, PointMass * end2, float-styvhet, flytdämpning): mEnd1 (end1), mEnd2 (end2), mTargetLength (mEnd1-> mPosition.distance (mEnd2-> mPosition) * 0.95f), mStiffness (styvhet), mDamping (dämpning) tom Spring :: uppdatering () tVector3f x = mEnd1-> mPosition - mEnd2-> mPosition; floatlängd = x.length (); om (längd> mTargetLength) x = (x / längd) * (längd - mTargetLength); tVector3f dv = mEnd2-> mVelocity - mEnd1-> mVelocity; tVector3f force = mStiffness * x - dv * mDamping; mEnd1-> applyForce (-force); mEnd2-> applyForce (kraft);
När vi skapar en fjäder sätter vi vårens naturliga längd på bara något mindre än avståndet mellan de två ändpunkterna. Detta håller gallret stramt även vid vila och förbättrar utseendet något.
De Fjäder :: update ()
Metoden kontrollerar först om fjädern sträcker sig bortom sin naturliga längd. Om det inte sträcker sig, händer ingenting. Om det är så använder vi den modifierade Hooke's Law för att hitta kraften från våren och tillämpa den på de två anslutna massorna.
Nu när vi har nödvändiga kapslade klasser, är vi redo att skapa nätet. Vi börjar med att skapa PointMass
objekt vid varje korsning på gallret. Vi skapar också ett antal fasta ankar PointMass
föremål för att hålla nätet på plats. Vi kopplar sedan massorna med fjädrar.
std :: vektormSprings; PointMass * mPoints; Grid :: Grid (const tRectf & rect, const tVector2f och mellanslag) mScreenSize = tVector2f (GameRoot :: getInstance () -> getViewportSize (). Bredd, GameRoot :: getInstance () -> getViewportSize (). 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 = nya PointMass [numColumns * numRows]; int kolumn = 0, rad = 0; för (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)); om (y> 0) mSprings.push_back (Spring (GetPointMass (mPoints, x, y - 1), GetPointMass (mPoints, x, y), 0.28f, 0.06f));
Den första för
slinga skapar både vanliga massor och obevekliga massor vid varje korsning av gallret. Vi kommer inte att använda alla de obevekliga massorna, och de oanvända massorna kommer helt enkelt att vara sopor insamlade någon gång efter att konstruktören slutar. Vi kan optimera genom att undvika att skapa onödiga objekt, men eftersom rutnätet vanligtvis bara skapas en gång kommer det inte att göra stor skillnad.
Förutom att använda ankarpunktsmassor runt gridens gräns, kommer vi också att använda några ankermassor inuti gallret. Dessa kommer att användas för att försiktigt hjälpa till att dra tillbaka gallret till dess ursprungliga läge efter att deformeras.
Eftersom ankarpunkterna aldrig rör sig behöver de inte uppdateras varje ram; vi kan helt enkelt koppla upp dem till fjädrarna och glömma dem. Därför har vi ingen medlemsvariabel i Rutnät
klass för dessa massor.
Det finns ett antal värden du kan tweak i skapandet av rutnätet. De viktigaste är fjädrarnas styvhet och dämpning. (Styvheten och dämpningen av gränsankarna och inre ankare ställs in oberoende av huvudfjädrarna.) Högre styvhetsvärden gör att fjädrarna svänger snabbare och högre dämpningsvärden leder till att fjädrarna saktar fortare.
För att nätet ska kunna flyttas måste vi uppdatera det varje ram. Det här är väldigt enkelt eftersom vi redan gjort allt det hårda arbetet i PointMass
och Vår
klasser:
tomrumsgrid :: uppdatering () för (size_t i = 0; i < mSprings.size(); i++) mSprings[i].update(); for(int i = 0; i < mCols * mRows; i++) mPoints[i].update();
Nu lägger vi till några metoder som manipulerar nätet. Du kan lägga till metoder för vilken typ av manipulation du kan tänka dig. Vi kommer att genomföra tre typer av manipuleringar här: trycka en del av gallret i en given riktning, trycka gallret utåt från en viss punkt och dra in gallret in i någon punkt. Alla tre kommer att påverka nätet inom en given radie från någon målpunkt. Nedan följer några bilder av dessa manipulationer:
Kulor avvisar gallret utåt.
Suger gallret inåt.
Våg skapad genom att trycka gallret längs z-axeln.
void Grid :: applyDirectedForce (const tVector3f & force, const tVector3f & position, floatradie) 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);
Vi kommer att använda alla tre av dessa metoder i Shape Blaster för olika effekter.
Vi ska rita rutnätet genom att rita linjesegment mellan varje angränsande par punkter. Först lägger vi till en förlängningsmetod som tar en tSpriteBatch
pekaren som en parameter som tillåter oss att rita linjesegment genom att ta en textur av en enda pixel och sträcka den i en linje.
Öppna Konst
klass och förklara en textur för pixeln:
klass Art: public tSingleton; protected: tTexture * mPixel; ... public: tTexture * getPixel () const; ...;
Du kan ställa in pixeltexturen på samma sätt som vi ställer in de andra bilderna, så vi lägger till pixel.png
(en 1x1px bild med den enda pixeln som är inställd på vit) till projektet och ladda den i tTexture
:
mPixel = ny tTexture (tSurface ("pixel.png"));
Låt oss nu lägga till följande metod för Extensions
klass:
void Extensions :: drawLine (tSpriteBatch * spriteBatch, const tVector2f & start, const tVector2f & end, const tColor4f & färg, flyt tjocklek) tVector2f delta = end-start; spriteBatch-> draw (0, Art :: getInstance () -> getPixel (), tPoint2f ((int32_t) start.x, (int32_t) start.y), tOptional(), färg, toAngle (delta), tPoint2f (0, 0), tVector2f (delta.length (), tjocklek));
Denna metod sträcker, roterar och tonar pixeltexturen för att producera den linje vi önskar.
Därefter behöver vi en metod för att projicera 3D-gridpunkterna på vår 2D-skärm. Normalt kan detta göras med matriser, men här omvandlar vi koordinaterna manuellt istället.
Lägg till följande i Rutnät
klass:
tVector2f Grid :: toVec2 (const tVector3f & v) floatfaktor = (v.z + 2000.0f) * 0.0005f; returnera (tVector2f (v.x, v.y) - mScreenSize * 0.5f) * faktor + mScreenSize * 0.5f;
Denna transformation ger grid en perspektivvy där långt borta punkter dyker närmare varandra på skärmen. Nu kan vi rita rutnätet genom att iterera genom raderna och kolumnerna och ritningslinjerna mellan dem:
void Grid :: draw (tSpriteBatch * spriteBatch) int bredd = mCols; int höjd = mRows; tColor4f-färg (0,12f, 0,12f, 0,55f, 0,33f); för (int y = 1; y < height; y++) for (int x = 1; x < width; x++) tVector2f left, up; tVector2f p = toVec2(GetPointMass(mPoints, x, y)->mställning); om (x> 1) vänster = toVec2 (GetPointMass (mPoints, x - 1, y) -> mPosition); float tjocklek = (y% 3 == 1)? 3,0f: 1,0f; Extensions :: drawLine (spriteBatch, vänster, p, färg, tjocklek); om (y> 1) up = toVec2 (GetPointMass (mPoints, x, y - 1) -> mPosition); float tjocklek = (x% 3 == 1)? 3,0f: 1,0f; Extensions :: drawLine (spriteBatch, upp, p, färg, tjocklek);
I ovanstående kod, p
är vår nuvarande punkt på rutnätet, vänster
är punkten direkt till vänster och upp
är punkten direkt ovanför den. Vi drar varje tredje linje tjockare både horisontellt och vertikalt för visuell effekt.
Vi kan optimera nätet genom att förbättra den visuella kvaliteten för ett visst antal fjädrar utan att avsevärt öka prestandakostnaden. Vi ska göra två sådana optimeringar.
Vi kommer att göra nätet tätare genom att lägga till linjesegment i de befintliga rutorna. Vi gör det genom att rita linjer från mittpunkten på den ena sidan av cellen till mittpunkten på motsatt sida. Bilden nedan visar de nya interpolerade linjerna i rött.
Att rita de interpolerade linjerna är rakt framåt. Om du har två poäng, en
och b
, deras mittpunkt är (a + b) / 2
. Så, för att rita de interpolerade linjerna lägger vi till följande kod inuti för
slingor av vår Grid :: draw ()
metod:
om (x> 1 && y> 1) tVector2f upLeft = toVec2 (GetPointMass (mPoints, x - 1, y - 1) -> mPosition); Extensions :: drawLine (spriteBatch, 0.5f * (upLeft + upp), 0.5f * (vänster + p), färg, 1.0f); // vertikal linje Extensions :: drawLine (spriteBatch, 0.5f * (upLeft + vänster), 0.5f * (upp + p), färg, 1.0f); // vågrät linje
Den andra förbättringen är att utföra interpolering på våra raka segment för att göra dem till jämnare kurvor. I den ursprungliga XNA-versionen av detta spel åberopade koden XNAs Vector2.CatmullRom ()
metod som utför Catmull-Rom interpolation. Du överför metoden fyra sekventiella punkter på en krökt linje, och den kommer att returnera punkter längs en jämn kurva mellan andra och tredje poängen du angav.
Eftersom denna algoritm inte existerar i C eller C: s standardbibliotek, måste vi själva implementera det. Lyckligtvis finns en referensimplementation tillgänglig för användning. Jag har gett en MathUtil :: catmullRom ()
metod baserad på denna referensimplementering:
float MathUtil :: catmullRom (const float value1, const float value2, const float value3, const float value4, float mängd) // Använd formel från http://www.mvps.org/directx/articles/catmull/ float amountSquared = belopp * mängd float amountCubed = amountSquared * mängd; returnera (float) (0.5f * (2.0f * värde2 + (värde3 - värde1) * mängd + (2.0f * värde1 - 5.0f * värde2 + 4,0f * värde3 - värde4) * amountSquared + (3.0f * value2 - value1 - 3.0f * value3 + value4) * amountCubed)); tVector2f MathUtil :: catmullRom (const tVector2f & value1, const tVector2f & value2, const tVector2f & value3, const tVector2f och value4, float mängd) return tVector2f (MathUtil :: catmullRom (value1.x, value2.x, value3.x, value4.x , mängd), MathUtil :: catmullRom (value1.y, value2.y, value3.y, value4.y, mängd));
Det femte argumentet till MathUtil :: catmullRom ()
är en viktningsfaktor som bestämmer vilken punkt på den interpolerade kurvan som den återvänder. En viktningsfaktor av 0
eller 1
kommer att återge den andra eller tredje punkten du angav och en viktningsfaktor på 0,5
kommer att returnera punkten på den interpolerade kurvan halvvägs mellan de två punkterna. Genom att gradvis flytta viktningsfaktorn från noll till en och rita linjer mellan de återvändande punkterna, kan vi producera en perfekt jämn kurva. För att hålla prestandokostnaden låg, tar vi emellertid endast en enda interpolerad punkt i beaktande, med en viktningsfaktor på 0,5
. Vi ersätter sedan den ursprungliga raka linjen i rutnätet med två linjer som möts vid den interpolerade punkten.
Diagrammet nedan visar effekten av denna interpolering:
Eftersom linjesegmenten i rutnätet redan är små gör det inte en märkbar skillnad att använda mer än en interpolerad punkt.
Ofta kommer linjerna i vårt nät vara väldigt raka och kräver ingen utjämning. Vi kan kontrollera detta och undvika att rita två linjer istället för en: Vi kontrollerar om avståndet mellan den interpolerade punkten och mittlinjen på den raka linjen är större än en pixel; Om det är, antar vi att linjen är krökt och vi ritar två linjesegment.
Modifieringen till vår Grid :: draw ()
Metod för att lägga till Catmull-Rom-interpolering för de horisontella linjerna visas nedan.
left = toVec2 (GetPointMass (mPoints, x - 1, y) -> mPosition); float tjocklek = (y% 3 == 1)? 3,0f: 1,0f; int clampedX = (int) tMath :: min (x + 1, bredd - 1); tVector2f mid = MathUtil :: catmullRom (toVec2 (GetPointMass (mPoints, x - 2, y) -> mPosition), vänster, p, toVec2 (GetPointMass (mPoints, clampedX, y) -> mPosition), 0,5f); if (mid.distanceSquared ((left + p) / 2)> 1) Extensions :: drawLine (spriteBatch, vänster, mitten, färg, tjocklek); Extensions :: drawLine (spriteBatch, mitt, p, färg, tjocklek); else Extensions :: drawLine (spriteBatch, left, p, color, thickness);
Bilden nedan visar effekterna av utjämningen. En grön punkt ritas vid varje interpolerad punkt för att bättre illustrera var linjerna slätas.
Nu är det dags att använda gallret i vårt spel. Vi börjar med att förklara en offentlig, statisk Rutnät
variabel i GameRoot
och skapa nätet i GameRoot :: onInitView
. Vi skapar ett rutnät med ungefär 600 poäng som så.
const int maxGridPoints = 600; tVector2f gridSpacing = tVector2f ((float) sqrtf (mViewportSize.width * mViewportSize.height / maxGridPoints)); mGrid = nytt grid (tRectf (0,0, mViewportSize), gridSpacing);
Även om den ursprungliga XNA-versionen av spelet använder 1.600 punkter (snarare än 600), blir det alltför mycket att hantera, även för den kraftfulla hårdvaran i iPhone. Lyckligtvis lämnade den ursprungliga koden mängden poäng anpassningsbara och vid ca 600 gridpunkter kan vi fortfarande göra dem och ändå behålla en optimal bildhastighet.
Då ringer vi Grid :: update ()
och Grid :: draw ()
från GameRoot :: onRedrawView ()
metod i GameRoot
. Detta gör det möjligt för oss att se rutnätet när vi kör spelet. Vi måste emellertid fortfarande göra olika spelobjekt interagera med rutnätet.
Kulor kommer att avvisa gallret. Vi har redan gjort en metod för att göra detta kallat Grid :: applyExplosiveForce ()
. Lägg till följande rad i Bullet :: update ()
metod.
GameRoot :: getInstance () -> getGrid () -> applyExplosiveForce (0.5f * mVelocity.length (), mPosition, 80);
Detta kommer att göra kulor avstänger gallret proportionellt till deras hastighet. Det var ganska enkelt.
Låt oss nu arbeta på svarta hål. Lägg till den här raden till Blackhole :: uppdatering ()
:
GameRoot :: getInstance () -> getGrid () -> applyImplosiveForce ((float) sinf (mSprayAngle / 2.0f) * 10 + 20, mPosition, 200);
Detta gör att det svarta hålet suger i gallret med en varierande mängd kraft. Vi har återanvända mSprayAngle
variabel, vilket kommer att orsaka kraften på gallret att pulsera i synk med vinkeln som sprutar partiklar (även om den är halva frekvensen beroende på uppdelningen med två). Kraften som passerar in varierar sinusformigt mellan 10
och 30
.
Slutligen skapar vi en shockwave i nätet när spelarens skepp respekterar efter döden. Vi kommer att göra det genom att dra gallret längs z-axeln och sedan låta kraften sprida sig och studsa genom fjädrarna. Återigen kräver detta bara en liten ändring till PlayerShip :: uppdatering ()
.
om (getIsDead ()) mFramesUntilRespawn--; om (mFramesUntilRespawn == 0) GameRoot :: getInstance () -> getGrid () -> applyDirectedForce (tVector3f (0, 0, 5000), tVector3f (mPosition.x, mPosition.y, 0), 50);
Vi har de grundläggande spel och effekter som genomförts. Det är upp till dig att göra det till ett komplett och polerat spel med din egen smak. Prova att lägga till några intressanta nya mekaniker, några coola nya effekter eller en unik historia. Om du inte är säker på var du ska börja, här är några förslag:
Himlen är gränsen!