Elementaire 2D Platformer-fysica, deel 2

In dit deel van de 2D-platformeraardeseries maken we een tilemap en implementeren we deels object-tilemap-botsingdetectie en -respons..

Level Geometry

Er zijn twee basisbenaderingen voor het bouwen van platformerniveaus. Een daarvan is om een ​​raster te gebruiken en de juiste tegels in cellen te plaatsen, en de andere is een meer vrije vorm, waarin je de niveaugeometrie losjes kunt plaatsen, waar en wanneer je maar wilt. 

Er zijn voor- en nadelen aan beide benaderingen. We gebruiken het raster, dus laten we eens kijken wat voor voordelen het heeft voor de andere methode:

  • Betere prestaties-botsdetectie tegen het raster is in de meeste gevallen goedkoper dan tegen los geplaatste objecten.
  • Maakt het veel eenvoudiger om pathfinding aan te pakken.
  • Tegels zijn nauwkeuriger en voorspelbaarder dan los geplaatste objecten, vooral als het gaat om zaken als vernietigbaar terrein.

Een kaartklasse bouwen

Laten we beginnen met het maken van een kaartklasse. Het bevat alle kaartspecifieke gegevens.

openbare klaskaart 

Nu moeten we alle tegels definiëren die de kaart bevat, maar voordat we dat doen, moeten we weten welke tegeltypen er in onze game bestaan. Voorlopig plannen we er slechts drie: een lege tegel, een stevige tegel en een eenrichtingsplatform.

public enum TileType Empty, Block, OneWay 

In de demo komen tegeltypes overeen met het type botsing dat we met een tegel willen hebben, maar in een echte game is dat niet noodzakelijk het geval. Aangezien u meer visueel verschillende tegels heeft, is het beter om nieuwe typen toe te voegen, zoals GrassBlock, GrassOneWay enzovoort, om het TileType-enum niet alleen het botsingstype, maar ook het uiterlijk van de tegel te laten definiëren.

Nu kunnen we in de kaartklasse een reeks tegels toevoegen.

openbare klasse Map private TileType [,] mTiles; 

Natuurlijk is een tilemap die we niet kunnen zien, niet echt nuttig, dus we hebben ook sprites nodig om een ​​back-up van de tegelgegevens te maken. Normaal gesproken is het in Unity uiterst inefficiënt om elke tegel een afzonderlijk object te laten zijn, maar omdat we dit alleen gebruiken om onze fysica te testen, is het OK om het op deze manier in de demo te maken.

privé SpriteRenderer [,] mTilesSprites;

De kaart heeft ook een positie in de wereldruimte nodig, zodat we deze uit elkaar kunnen schuiven als we meer dan één moeten hebben.

openbare Vector3 mPosition;

Breedte en hoogte, in tegels.

public int mWidth = 80; public int mHeight = 60;

En de tegelgrootte: in de demo werken we met een vrij kleine tegelgrootte, die 16 bij 16 pixels is.

public const int cTileSize = 16;

Dat zou het zijn. Nu hebben we een aantal hulpfuncties nodig om ons eenvoudig toegang te geven tot de gegevens van de kaart. Laten we beginnen met het maken van een functie die de wereldcoördinaten omzet naar de tegelcoördinaten van de kaart.

openbare Vector2i GetMapTileAtPoint (Vector2-punt) 

Zoals je ziet, duurt deze functie a Vector2 als een parameter en retourneert een Vector2i, wat in feite een 2D-vector is die werkt op gehele getallen in plaats van drijvers.

Het omzetten van de wereldpositie naar de positie op de kaart is heel eenvoudig - we hoeven alleen maar de punt door mPosition dus we geven de tegel terug ten opzichte van de positie van de kaart en verdelen het resultaat vervolgens met de tegelgrootte.

openbare Vector2i GetMapTileAtPoint (Vector2 punt) retourneer nieuwe Vector2i ((int) ((point.x - mPosition.x + cTileSize / 2.0f) / (float) (cTileSize)), (int) ((point.y - mPosition. y + cTileSize / 2.0f) / (float) (cTileSize))); 

Merk op dat we de punt bovendien door cTileSize / 2.0f, omdat de spil van de tegel in het midden staat. Laten we ook twee extra functies maken die alleen de X- en Y-component van de positie in de kaartruimte retourneren. Het zal later nuttig zijn.

openbare int GetMapTileYAtPoint (float y) return (int) ((y - mPosition.y + cTileSize / 2.0f) / (float) (cTileSize));  openbare int GetMapTileXAtPoint (float x) return (int) ((x - mPosition.x + cTileSize / 2.0f) / (float) (cTileSize)); 

We zouden ook een complementaire functie moeten creëren die, gegeven een tegel, zijn positie in de wereldruimte zal teruggeven.

openbare Vector2 GetMapTilePosition (int tileIndexX, int tileIndexY) return new Vector2 ((float) (tileIndexX * cTileSize) + mPosition.x, (float) (tileIndexY * cTileSize) + mPosition.y);  public Vector2 GetMapTilePosition (Vector2i tileCoords) return new Vector2 ((float) (tileCoords.x * cTileSize) + mPosition.x, (float) (tileCoords.y * cTileSize) + mPosition.y); 

Naast het vertalen van posities, moeten we ook een aantal functies hebben om te zien of een tegel op een bepaalde positie leeg is, een solide tegel is of een eenrichtingsplatform. Laten we beginnen met een erg generieke GetTile-functie, die een type van een specifieke tegel teruggeeft.

public TileType GetTile (int x, int y) if (x < 0 || x >= mWidth || Y < 0 || y >= mHeight) retourneer TileType.Block; keer terug mTiles [x, y]; 

Zoals je kunt zien, controleren we voordat we het type tegel retourneren of de opgegeven positie buiten de grenzen valt. Als dat zo is, willen we het behandelen als een solide blok, anders keren we een echt type terug.

De volgende in wachtrij is een functie om te controleren of een tegel een obstakel is. 

public bool IsObstacle (int x, int y) if (x < 0 || x >= mWidth || Y < 0 || y >= mHeight) return true; return (mTiles [x, y] == TileType.Block); 

Op dezelfde manier als hiervoor, controleren we of de tegel buiten de grenzen is, en als het dan is keren we terug naar waar, dus elke tegel buiten de grenzen wordt behandeld als een obstakel.

Laten we nu eens kijken of de tegel een grondtegel is. We kunnen zowel op een blok als op een eenrichtingsplatform staan, dus we moeten terugkeren als de tegel een van deze twee is.

public bool IsGround (int x, int y) if (x < 0 || x >= mWidth || Y < 0 || y >= mHeight) return false; return (mTiles [x, y] == TileType.OneWay || mTiles [x, y] == TileType.Block); 

Eindelijk, laten we toevoegen IsOneWayPlatform en Is leeg functies op dezelfde manier.

public bool IsOneWayPlatform (int x, int y) if (x < 0 || x >= mWidth || Y < 0 || y >= mHeight) return false; return (mTiles [x, y] == TileType.OneWay);  public bool IsEmpty (int x, int y) if (x < 0 || x >= mWidth || Y < 0 || y >= mHeight) return false; return (mTiles [x, y] == TileType.Empty); 

Dat is alles dat we onze kaartklasse nodig hebben. Nu kunnen we verder gaan en de personagebotsing ertegenaan implementeren.

Karakterkaartbotsing

Laten we teruggaan naar de MovingObject klasse. We moeten een paar functies maken die detecteren of het personage botst met de tilemap.

De methode waarmee we weten of het personage tegen een steen botst, is heel eenvoudig. We controleren alle stenen die zich buiten de AABB van het bewegende object bevinden.


Het gele vak geeft de AABB van het personage weer en we controleren de tegels langs de rode lijnen. Als een van deze overlapt met een tegel, stellen we een bijbehorende botsingsvariabele in op true (zoals mOnGround, mPushesLeftWall, mAtCeiling of mPushesRightWall).

Laten we beginnen met het maken van een functie HasGround, die zal controleren of het personage tegen een grondtegel botst. 

public bool HasGround (Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) 

Deze functie geeft true als teken overlapt met een van de onderste tegels. Het neemt de oude positie, de huidige positie en de huidige snelheid als parameters, en retourneert ook de Y-positie van de bovenkant van de tegel waarmee we botsen en of de aangevallen tegel een eenrichtingsplatform is of niet.

Het eerste wat we willen doen, is het centrum van AABB berekenen.

public bool HasGround (Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) var center = position + mAABBOffset; 

Nu we dat hebben, moeten we voor de onderste botsingcontrole het begin en het einde van de onderste sensorlijn berekenen. De sensorlijn is slechts één pixel onder de onderste contour van de AABB.

public bool HasGround (Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) var center = position + mAABBOffset; var bottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right; var bottomRight = new Vector2 (bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y); 

De linksonder en rechts onder vertegenwoordigen de twee uiteinden van de sensor. Nu we deze hebben, kunnen we berekenen welke tegels we moeten controleren. Laten we beginnen met het creëren van een lus waarin we van links naar rechts door de tegels gaan.

for (var checkedTile = bottomLeft;; checkedTile.x + = Map.cTileSize) 

Merk op dat er geen voorwaarde is om de lus hier te verlaten-we zullen dat doen aan het einde van de lus. 

Het eerste dat we moeten doen, is ervoor zorgen dat de checkedTile.x is niet groter dan het rechteruiteinde van de sensor. Dit kan het geval zijn omdat we het gecontroleerde punt verplaatsen met veelvouden van de tegelgrootte, dus als het teken bijvoorbeeld 1,5 tegels breed is, moeten we de tegel aan de linkerkant van de sensor controleren en vervolgens een tegel naar rechts , en dan 1,5 tegels naar rechts in plaats van 2.

for (var checkedTile = bottomLeft;; checkedTile.x + = Map.cTileSize) checkedTile.x = Mathf.Min (checkedTile.x, bottomRight.x); 

Nu moeten we de tegelcoördinaten in de kaartruimte krijgen om het type van de tegel te kunnen controleren.

int tileIndexX, tileIndexY; for (var checkedTile = bottomLeft;; checkedTile.x + = Map.cTileSize) checkedTile.x = Mathf.Min (checkedTile.x, bottomRight.x); tileIndexX = mMap.GetMapTileXAtPoint (checkedTile.x); tileIndexY = mMap.GetMapTileYAtPoint (checkedTile.y); 

Laten we eerst de bovenste positie van de tegel berekenen.

int tileIndexX, tileIndexY; for (var checkedTile = bottomLeft;; checkedTile.x + = Map.cTileSize) checkedTile.x = Mathf.Min (checkedTile.x, bottomRight.x); tileIndexX = mMap.GetMapTileXAtPoint (checkedTile.x); tileIndexY = mMap.GetMapTileYAtPoint (checkedTile.y); groundY = (float) tileIndexY * Map.cTileSize + Map.cTileSize / 2.0f + mMap.mPosition.y; 

Als de momenteel aangevinkte tegel een obstakel is, kunnen we eenvoudig waar retourneren.

int tileIndexX, tileIndexY; for (var checkedTile = bottomLeft;; checkedTile.x + = Map.cTileSize) checkedTile.x = Mathf.Min (checkedTile.x, bottomRight.x); tileIndexX = mMap.GetMapTileXAtPoint (checkedTile.x); tileIndexY = mMap.GetMapTileYAtPoint (checkedTile.y); groundY = (float) tileIndexY * Map.cTileSize + Map.cTileSize / 2.0f + mMap.mPosition.y; if (mMap.IsObstacle (tileIndexX, tileIndexY)) return true; 

Laten we tot slot controleren of we al de tegels hebben bekeken die de sensor kruisen. Als dat het geval is, dan kunnen we veilig de lus verlaten. Nadat we de lus hebben verlaten en geen tegel gevonden hebben waarmee we in botsing kwamen, moeten we terugkeren vals om de beller te laten weten dat er zich geen grond onder het object bevindt.

int tileIndexX, tileIndexY; for (var checkedTile = bottomLeft;; checkedTile.x + = Map.cTileSize) checkedTile.x = Mathf.Min (checkedTile.x, bottomRight.x); tileIndexX = mMap.GetMapTileXAtPoint (checkedTile.x); tileIndexY = mMap.GetMapTileYAtPoint (checkedTile.y); groundY = (float) tileIndexY * Map.cTileSize + Map.cTileSize / 2.0f + mMap.mPosition.y; if (mMap.IsObstacle (tileIndexX, tileIndexY)) return true; if (checkedTile.x> = bottomRight.x) pauze;  return false; 

Dat is de meest eenvoudige versie van de cheque. Laten we proberen het nu aan de gang te krijgen. Terug in de UpdatePhysics functie, onze oude grondcontrole ziet er zo uit.

if (mPosition.y <= 0.0f)  mPosition.y = 0.0f; mOnGround = true;  else mOnGround = false;

Laten we het vervangen met de nieuw gemaakte methode. Als het personage naar beneden valt en we onderweg een obstakel hebben gevonden, moeten we het uit de botsing verwijderen en ook de mOnGround naar waar. Laten we beginnen met de voorwaarde.

float groundY = 0; if (mSpeed.y <= 0.0f && HasGround(mOldPosition, mPosition, mSpeed, out groundY))  

Als aan de voorwaarde is voldaan, moeten we het personage verplaatsen op de bovenkant van de tegel waarop we botsten.

float groundY = 0; if (mSpeed.y <= 0.0f && HasGround(mOldPosition, mPosition, mSpeed, out groundY))  mPosition.y = groundY + mAABB.halfSize.y - mAABBOffset.y; 

Zoals u kunt zien, is het heel eenvoudig omdat de functie het grondniveau retourneert waarnaar we het object zouden moeten uitlijnen. Hierna hoeven we alleen de verticale snelheid op nul te zetten en in te stellen mOnGround naar waar.

float groundY = 0; if (mSpeed.y <= 0.0f && HasGround(mOldPosition, mPosition, mSpeed, out groundY))  mPosition.y = groundY + mAABB.halfSize.y - mAABBOffset.y; mSpeed.y = 0.0f; mOnGround = true; 

Als onze verticale snelheid groter is dan nul of als we geen grond raken, moeten we de mOnGround naar vals.

float groundY = 0; if (mSpeed.y <= 0.0f && HasGround(mOldPosition, mPosition, mSpeed, out groundY))  mPosition.y = groundY + mAABB.halfSize.y - mAABBOffset.y; mSpeed.y = 0.0f; mOnGround = true;  else mOnGround = false;

Laten we nu kijken hoe dit werkt.

Zoals je kunt zien, werkt het goed! De botsingdetectie voor de wanden aan beide zijden en aan de bovenkant van het personage zijn er nog steeds niet, maar het personage stopt telkens wanneer het de grond raakt. We moeten nog een beetje meer werk doen in de botscontrole-functie om het robuust te maken.

Een van de problemen die we moeten oplossen, is zichtbaar als de verplaatsing van het personage van het ene frame naar het andere te groot is om de botsing goed te kunnen detecteren. Dit wordt geïllustreerd in de volgende afbeelding.

Deze situatie gebeurt niet nu omdat we de maximale valsnelheid op een redelijke waarde hebben vergrendeld en de fysica hebben bijgewerkt met 60 FPS-frequentie, dus de verschillen in posities tussen de frames zijn vrij klein. Laten we eens kijken wat er gebeurt als we de fysica slechts 30 keer per seconde bijwerken. 

Zoals u ziet, mislukt in dit scenario onze grondbotsingscheck ons. Om dit te verhelpen, kunnen we niet eenvoudig controleren of het personage op de huidige positie onder hem heeft geslagen, maar we moeten liever zien of er obstakels waren vanaf de positie van het vorige frame.

Laten we teruggaan naar onze HasGround functie. Hier, naast het berekenen van het centrum, willen we ook het midden van het vorige frame berekenen.

public bool HasGround (Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) var oldCenter = oldPosition + mAABBOffset; var center = positie + mAABBOffset;

We moeten ook de sensorpositie van het vorige frame ophalen.

public bool HasGround (Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) var oldCenter = oldPosition + mAABBOffset; var center = positie + mAABBOffset; var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right; var bottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right; var bottomRight = new Vector2 (bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y);

Nu moeten we berekenen op welke tegel we verticaal beginnen te controleren of er een botsing is of niet, en waar we zullen stoppen.

public bool HasGround (Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) var oldCenter = oldPosition + mAABBOffset; var center = positie + mAABBOffset; var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right; var bottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right; var bottomRight = new Vector2 (bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y); int endY = mMap.GetMapTileYAtPoint (bottomLeft.y); int begY = Mathf.Max (mMap.GetMapTileYAtPoint (oldBottomLeft.y) - 1, endY);

We starten de zoekactie vanaf de tegel op de sensorpositie van het vorige frame en eindigen deze op de sensorpositie van het huidige frame. Dat is natuurlijk omdat wanneer we naar een grondbotsing kijken, we aannemen dat we naar beneden vallen, en dat betekent dat we van de hogere positie naar de lagere gaan.

Ten slotte moeten we nog een iteratielus hebben. Laten we, voordat we de code voor deze buitenste lus invullen, het volgende scenario bekijken.

Hier zie je een pijl die snel beweegt. Dit voorbeeld laat zien dat we niet alleen alle tegels hoeven te doorlopen die we verticaal moeten passeren, maar ook om de positie van het object te interpoleren voor elke tegel waar we doorheen gaan om het pad van de positie van het vorige frame naar het huidige te benaderen. Als we gewoon de positie van het huidige object blijven gebruiken, dan wordt in het bovenstaande geval een botsing gedetecteerd, ook al zou dit niet zo moeten zijn.

Laten we de naam wijzigen linksonder en rechts onder zoals newBottomLeft en newBottomRight, dus we weten dat dit de sensorposities van het nieuwe frame zijn.

public bool HasGround (Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) var oldCenter = oldPosition + mAABBOffset; var center = positie + mAABBOffset; var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomRight = new Vector2 (newBottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, newBottomLeft.y); int endY = mMap.GetMapTileYAtPoint (newBottomLeft.y); int begY = Mathf.Max (mMap.GetMapTileYAtPoint (oldBottomLeft.y) - 1, endY); int tileIndexX; for (int tileIndexY = begY; tileIndexY> = endY; --tileIndexY)  return false; 

Laten we nu binnen deze nieuwe lus de sensorposities interpoleren, zodat we aan het begin van de lus aannemen dat de sensor zich in de positie van het vorige frame bevindt en aan het einde in de positie van het huidige frame staan.

public bool HasGround (Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) var oldCenter = oldPosition + mAABBOffset; var center = positie + mAABBOffset; var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomRight = new Vector2 (newBottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, newBottomLeft.y); int endY = mMap.GetMapTileYAtPoint (newBottomLeft.y); int begY = Mathf.Max (mMap.GetMapTileYAtPoint (oldBottomLeft.y) - 1, endY); int dist = Mathf.Max (Mathf.Abs (endY - begY), 1); int tileIndexX; for (int tileIndexY = begY; tileIndexY> = endY; --tileIndexY) var bottomLeft = Vector2.Lerp (newBottomLeft, oldBottomLeft, (float) Mathf.Abs (endY - tileIndexY) / dist); var bottomRight = new Vector2 (bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y);  return false; 

Merk op dat we de vectoren interpoleren op basis van het verschil in tegels op de Y-as. Wanneer oude en nieuwe posities binnen dezelfde tegel liggen, is de verticale afstand nul, dus in dat geval zouden we niet in staat zijn om te delen door de afstand. Dus om dit probleem op te lossen, willen we dat de afstand een minimumwaarde van 1 heeft, zodat als een dergelijk scenario zou gebeuren (en dit zal heel vaak gebeuren), we gewoon de nieuwe positie voor botsingdetectie zullen gebruiken. 

Ten slotte moeten we voor elke iteratie dezelfde code uitvoeren die we al hebben uitgevoerd voor het controleren van de botsing op de grond langs de breedte van het object. 

public bool HasGround (Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) var oldCenter = oldPosition + mAABBOffset; var center = positie + mAABBOffset; var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomRight = new Vector2 (newBottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, newBottomLeft.y); int endY = mMap.GetMapTileYAtPoint (newBottomLeft.y); int begY = Mathf.Max (mMap.GetMapTileYAtPoint (oldBottomLeft.y) - 1, endY); int dist = Mathf.Max (Mathf.Abs (endY - begY), 1); int tileIndexX; for (int tileIndexY = begY; tileIndexY> = endY; --tileIndexY) var bottomLeft = Vector2.Lerp (newBottomLeft, oldBottomLeft, (float) Mathf.Abs (endY - tileIndexY) / dist); var bottomRight = new Vector2 (bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y); for (var checkedTile = bottomLeft;; checkedTile.x + = Map.cTileSize) checkedTile.x = Mathf.Min (checkedTile.x, bottomRight.x); tileIndexX = mMap.GetMapTileXAtPoint (checkedTile.x); groundY = (float) tileIndexY * Map.cTileSize + Map.cTileSize / 2.0f + mMap.mPosition.y; if (mMap.IsObstacle (tileIndexX, tileIndexY)) return true; if (checkedTile.x> = bottomRight.x) pauze;  return false; 

Dat is het eigenlijk wel. Zoals je je misschien kunt voorstellen, als de objecten van de game heel snel bewegen, kan deze manier om botsingen te controleren een stuk duurder zijn, maar het stelt ons ook gerust dat er geen rare glitches zijn met objecten die door stevige muren bewegen.

Samenvatting

Pff, dat was meer code dan we dachten dat we nodig zouden hebben, toch? Als je fouten of mogelijke snelkoppelingen opmerkt, laat het mij dan en iedereen weten in de reacties! De botsproef moet robuust genoeg zijn, zodat we ons geen zorgen hoeven te maken over ongelukkige gebeurtenissen van voorwerpen die door de blokken van de tilemap slippen.. 

Veel van de code is geschreven om ervoor te zorgen dat er geen voorwerpen met grote snelheden door de tegels gaan, maar als dat geen probleem is voor een bepaald spel, kunnen we veilig de aanvullende code verwijderen om de prestaties te verbeteren. Het kan zelfs een goed idee zijn om een ​​vlag te hebben voor specifieke snelbewegende objecten, zodat alleen die de duurdere versies van de cheques gebruiken.

We moeten nog veel dingen overbruggen, maar we zijn erin geslaagd een betrouwbare botsproef voor de grond te maken, die vrij duidelijk naar de andere drie richtingen kan worden gespiegeld. We doen dat in het volgende deel.