Fisica di base 2D per piattaforme, parte 2

In questa parte della serie di fisica del platform 2D, creeremo una tilemap e implementeremo parzialmente il rilevamento e la risposta delle collisioni oggetti-mappa.

Geometria di livello

Ci sono due approcci di base per costruire i livelli del platformer. Uno di questi è usare una griglia e posizionare le tessere appropriate nelle celle, e l'altra è una più libera, in cui è possibile posizionare liberamente la geometria del livello e ovunque si desideri. 

Ci sono pro-contro per entrambi gli approcci. Useremo la griglia, quindi vediamo quale tipo di pro ha rispetto all'altro metodo:

  • Nella maggior parte dei casi, il rilevamento delle collisioni con prestazioni migliori rispetto alla rete è più economico rispetto agli oggetti posizionati in modo approssimativo.
  • Rende molto più facile la gestione dei percorsi.
  • Le piastrelle sono più precise e prevedibili rispetto agli oggetti disposti in modo approssimativo, specialmente se si considerano cose come il terreno distruttibile.

Costruire una classe di mappa

Iniziamo creando una classe Map. Terrà tutti i dati specifici della mappa.

Mappa della classe pubblica 

Ora dobbiamo definire tutte le tessere che contiene la mappa, ma prima di farlo, dobbiamo sapere quali tipi di tessere esistono nel nostro gioco. Per ora, stiamo pianificando solo tre: una tessera vuota, una tessera solida e una piattaforma unidirezionale.

public enum TileType Empty, Block, OneWay 

Nella demo, i tipi di tessere corrispondono direttamente al tipo di collisione che vorremmo avere con una tessera, ma in un gioco reale che non è necessariamente così. Dato che hai più tessere visivamente diverse, sarebbe meglio aggiungere nuovi tipi come GrassBlock, GrassOneWay e così via, per consentire all'enumer TileType di definire non solo il tipo di collisione ma anche l'aspetto della tessera.

Ora nella classe map possiamo aggiungere una serie di tessere.

mappa di classe pubblica private TileType [,] mTiles; 

Ovviamente, una tilemap che non possiamo vedere non è di grande utilità per noi, quindi abbiamo bisogno anche di sprite per eseguire il backup dei dati delle tessere. Normalmente in Unity è estremamente inefficiente che ogni tile sia un oggetto separato, ma dal momento che stiamo usando questo per testare la nostra fisica, è OK farlo in questo modo nella demo.

private SpriteRenderer [,] mTilesSprites;

La mappa ha bisogno anche di una posizione nello spazio mondiale, così che se abbiamo bisogno di avere più di una singola, possiamo allontanarli.

pubblico Vector3 mPosition;

Larghezza e altezza, in piastrelle.

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

E le dimensioni della piastrella: nella demo lavoreremo con una dimensione di piastrella piuttosto piccola, che è 16 per 16 pixel.

public const int cTileSize = 16;

Quello sarebbe. Ora abbiamo bisogno di un paio di funzioni di supporto per permetterci di accedere facilmente ai dati della mappa. Iniziamo facendo una funzione che convertirà le coordinate del mondo in coordinate della mappa della mappa.

pubblico Vector2i GetMapTileAtPoint (punto Vector2) 

Come puoi vedere, questa funzione richiede a Vector2 come parametro e restituisce a Vector2i, che è fondamentalmente un vettore 2D che funziona su interi invece che su float.

Convertire la posizione del mondo nella posizione della mappa è molto semplice: abbiamo semplicemente bisogno di spostare la posizione punto di mPosition quindi restituiamo la tessera relativa alla posizione della mappa e quindi dividiamo il risultato per la dimensione della tessera.

public Vector2i GetMapTileAtPoint (punto Vector2) return new Vector2i ((int) ((point.x - mPosition.x + cTileSize / 2.0f) / (float) (cTileSize)), (int) ((point.y - mPosition. y + cTileSize / 2.0f) / (float) (cTileSize))); 

Si noti che abbiamo dovuto spostare il punto inoltre da cTileSize / 2.0f, perché il perno della piastrella è al centro. Facciamo anche due funzioni aggiuntive che restituiranno solo il componente X e Y della posizione nello spazio della mappa. Sarà utile in seguito.

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

Dovremmo anche creare una funzione complementare che, data una tessera, restituirà la sua posizione nello spazio del mondo.

public 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); 

Oltre alla traduzione delle posizioni, abbiamo anche bisogno di un paio di funzioni per vedere se una tessera in una certa posizione è vuota, è una tessera solida o una piattaforma unidirezionale. Iniziamo con una funzione GetTile molto generica, che restituirà un tipo di tile specifico.

TileType pubblico GetTile (int x, int y) if (x < 0 || x >= mWidth || y < 0 || y >= mHeight restituisce TileType.Block; return mTiles [x, y]; 

Come puoi vedere, prima di restituire il tipo di tile, controlliamo se la posizione data è fuori dai limiti. Se lo è, allora vogliamo trattarlo come un blocco solido, altrimenti restituiremo un tipo vero.

Il prossimo in coda è una funzione per verificare se una tessera è un ostacolo. 

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

Allo stesso modo di prima, controlliamo se la tessera è fuori dai limiti, e se è così torniamo veri, quindi ogni tessera fuori limite viene considerata come un ostacolo.

Ora controlliamo se la tessera è una tessera terreno. Possiamo stare su un blocco e su una piattaforma a senso unico, quindi dobbiamo restituire true se il riquadro è uno di questi due.

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); 

Infine, aggiungiamo IsOneWayPlatform e È vuoto funziona allo stesso modo.

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); 

Questo è tutto ciò di cui abbiamo bisogno per fare la nostra lezione di mappa. Ora possiamo andare avanti e implementare la collisione del personaggio contro di essa.

Collisione di mappa caratteri

Torniamo al MovingObject classe. Dobbiamo creare un paio di funzioni che rileveranno se il personaggio si scontrerà con la tilemap.

Il metodo con cui sapremo se il personaggio si scontra con una tessera o meno è molto semplice. Controlleremo tutte le tessere esistenti al di fuori dell'oggetto AABB dell'oggetto in movimento.


La casella gialla rappresenta l'AABB del personaggio e controlleremo le tessere lungo le linee rosse. Se qualcuno di questi si sovrappone a una tessera, impostiamo una corrispondente variabile di collisione su true (ad esempio mOnGround, mPushesLeftWall, mAtCeiling o mPushesRightWall).

Cominciamo creando una funzione HasGround, che controllerà se il personaggio si scontra con una tessera terreno. 

hasGround pubblico booleano (Vector2 oldPosition, posizione Vector2, velocità Vector2, galleggiante esternoY) 

Questa funzione restituisce true se il carattere si sovrappone a uno dei riquadri inferiori. Prende la vecchia posizione, la posizione corrente e la velocità corrente come parametri, e restituisce anche la posizione Y della parte superiore della tessera con cui si scontrano e se la tessera in conflitto è una piattaforma a senso unico o no.

La prima cosa che vogliamo fare è calcolare il centro di AABB.

hasGround pubblico booleano (Vector2 oldPosition, posizione Vector2, velocità Vector2, galleggiante esternoY) var center = position + mAABBOffset; 

Ora che ce l'abbiamo, per il controllo della collisione inferiore dovremo calcolare l'inizio e la fine della linea inferiore del sensore. La linea del sensore è solo un pixel sotto il contorno inferiore dell'AABB.

hasGround pubblico booleano (Vector2 oldPosition, posizione Vector2, velocità Vector2, galleggiante esternoY) 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); 

Il in basso a sinistra e in basso a destra rappresentano le due estremità del sensore. Ora che li abbiamo, possiamo calcolare quali tessere dobbiamo controllare. Iniziamo creando un loop in cui passeremo attraverso le tessere da sinistra a destra.

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

Nota che non ci sono condizioni per uscire da questo ciclo: lo faremo alla fine del ciclo. 

La prima cosa da fare nel ciclo è assicurarsi che il checkedTile.x non è più grande della parte destra del sensore. Questo potrebbe essere il caso in cui spostiamo il punto di controllo per multipli della dimensione della tessera, quindi per esempio, se il carattere è largo 1.5 tessere, dobbiamo controllare la piastrella sul lato sinistro del sensore, quindi una tessera a destra e quindi 1,5 tessere a destra invece di 2.

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

Ora dobbiamo ottenere la coordinata della tessera nello spazio della mappa per poter controllare il tipo di tessera.

int tileIndexX, tileIndexY; per (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); 

Innanzitutto, calcoliamo la posizione in alto della tessera.

int tileIndexX, tileIndexY; per (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; 

Ora, se il riquadro attualmente selezionato è un ostacolo, possiamo facilmente restituire true.

int tileIndexX, tileIndexY; per (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)) restituisce true; 

Infine, controlliamo se abbiamo già esaminato tutte le tessere che si intersecano con il sensore. Se questo è il caso, allora possiamo uscire in sicurezza dal ciclo. Dopo aver chiuso il ciclo non trovando una tessera con la quale siamo entrati in collisione, dobbiamo tornare falso per far sapere al chiamante che non c'è terreno sotto l'oggetto.

int tileIndexX, tileIndexY; per (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)) restituisce true; se break (checkedTile.x> = bottomRight.x);  return false; 

Questa è la versione più semplice dell'assegno. Proviamo a farlo funzionare ora. Indietro nel UpdatePhysics funzione, il nostro vecchio controllo a terra sembra così.

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

Sostituiamolo usando il metodo appena creato. Se il personaggio sta cadendo e abbiamo trovato un ostacolo sulla nostra strada, allora dobbiamo spostarlo fuori dalla collisione e anche impostare il mOnGround al vero Iniziamo con la condizione.

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

Se la condizione è soddisfatta, dobbiamo spostare il personaggio sulla parte superiore della tessera con cui siamo entrati in collisione.

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

Come puoi vedere, è molto semplice perché la funzione restituisce il livello del suolo al quale dovremmo allineare l'oggetto. Dopo questo, abbiamo solo bisogno di impostare la velocità verticale a zero e impostare mOnGround al vero.

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

Se la nostra velocità verticale è maggiore di zero o non tocchiamo alcun terreno, dobbiamo impostare il parametro mOnGround a falso.

float groundY = 0; se (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;

Ora vediamo come funziona.

Come puoi vedere, funziona bene! Il rilevamento delle collisioni per i muri su entrambi i lati e nella parte superiore del personaggio non è ancora presente, ma il personaggio si ferma ogni volta che incontra il terreno. Dobbiamo ancora lavorare un po 'di più nella funzione di controllo delle collisioni per renderla solida.

Uno dei problemi che dobbiamo risolvere è visibile se l'offset del personaggio da un fotogramma all'altro è troppo grande per rilevare correttamente la collisione. Questo è illustrato nella figura seguente.

Questa situazione non accade ora perché abbiamo bloccato la velocità massima di caduta ad un valore ragionevole e aggiorniamo la fisica con una frequenza di 60 FPS, quindi le differenze nelle posizioni tra i frame sono piuttosto piccole. Vediamo cosa succede se aggiorniamo la fisica solo 30 volte al secondo. 

Come puoi vedere, in questo scenario il nostro test di collisione sul terreno non ci riesce. Per risolvere questo problema, non possiamo semplicemente controllare se il personaggio ha il terreno sotto di lui nella posizione corrente, ma abbiamo piuttosto bisogno di vedere se ci sono stati ostacoli lungo la strada dalla posizione del frame precedente.

Torniamo al nostro HasGround funzione. Qui, oltre al calcolo del centro, vorremmo anche calcolare il centro del frame precedente.

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

Avremo anche bisogno di ottenere la posizione del sensore del frame precedente.

public bool HasGround (Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) var oldCenter = oldPosition + mAABBOffset; var center = position + 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);

Ora abbiamo bisogno di calcolare a quale tessera in verticale inizieremo a controllare se c'è una collisione o meno, e alla quale ci fermeremo.

public bool HasGround (Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) var oldCenter = oldPosition + mAABBOffset; var center = position + 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);

Iniziamo la ricerca dalla tessera alla posizione del sensore del frame precedente e terminiamo nella posizione attuale del sensore del frame. Ovviamente, quando controlliamo una collisione di terra, assumiamo che stiamo cadendo, e questo significa che ci stiamo spostando dalla posizione più alta a quella più bassa.

Infine, abbiamo bisogno di un altro ciclo di iterazione. Ora, prima di riempire il codice per questo ciclo esterno, consideriamo il seguente scenario.

Qui puoi vedere una freccia muoversi velocemente. Questo esempio mostra che abbiamo bisogno non solo di scorrere tutte le tessere che avremmo bisogno di passare verticalmente, ma anche di interpolare la posizione dell'oggetto per ogni piastrella che attraversiamo per approssimare il percorso dalla posizione del frame precedente a quella corrente. Se continuassimo semplicemente ad usare la posizione dell'oggetto corrente, nel caso precedente verrebbe rilevata una collisione, anche se non dovrebbe esserlo.

Rinominiamo il in basso a sinistra e in basso a destra come newBottomLeft e newBottomRight, quindi sappiamo che queste sono le posizioni dei sensori del nuovo frame.

public bool HasGround (Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) var oldCenter = oldPosition + mAABBOffset; var center = position + 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; 

Ora, all'interno di questo nuovo ciclo, cerchiamo di interpolare le posizioni del sensore, in modo che all'inizio del loop assumiamo che il sensore si trovi nella posizione del frame precedente e alla sua fine sarà nella posizione corrente del frame.

public bool HasGround (Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) var oldCenter = oldPosition + mAABBOffset; var center = position + 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; 

Si noti che interpoliamo i vettori in base alla differenza di tessere sull'asse Y. Quando vecchie e nuove posizioni si trovano all'interno della stessa tessera, la distanza verticale sarà zero, quindi in tal caso non saremmo in grado di dividere per la distanza. Quindi, per risolvere questo problema, vogliamo che la distanza abbia un valore minimo di 1, in modo che se questo scenario dovesse accadere (e succederà molto spesso), utilizzeremo semplicemente la nuova posizione per il rilevamento delle collisioni. 

Infine, per ogni iterazione, abbiamo bisogno di eseguire lo stesso codice che abbiamo già fatto per controllare la collisione a terra lungo la larghezza dell'oggetto. 

public bool HasGround (Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) var oldCenter = oldPosition + mAABBOffset; var center = position + 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); per (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)) restituisce true; se break (checkedTile.x> = bottomRight.x);  return false; 

Questo è praticamente tutto. Come puoi immaginare, se gli oggetti del gioco si muovono molto velocemente, questo modo di controllare la collisione può essere un po 'più costoso, ma ci rassicura anche che non ci saranno strane anomalie con oggetti che si muovono attraverso pareti solide.

Sommario

Phew, quello era più codice di quello che pensavamo avremmo avuto bisogno, no? Se riscontri eventuali errori o scorciatoie da portare, fammelo sapere a tutti nei commenti! Il controllo della collisione dovrebbe essere abbastanza robusto in modo da non doverci preoccupare di eventuali sfortunati eventi di oggetti che scivolano attraverso i blocchi della tilemap. 

Molto codice è stato scritto per assicurarsi che non ci siano oggetti che passano attraverso le tessere a grandi velocità, ma se questo non è un problema per un particolare gioco, possiamo tranquillamente rimuovere il codice aggiuntivo per aumentare le prestazioni. Potrebbe anche essere una buona idea avere un flag per specifici oggetti in rapido movimento, in modo che solo quelli usino le versioni più costose degli assegni.

Abbiamo ancora un sacco di cose da coprire, ma siamo riusciti a effettuare un controllo di collisione affidabile per il terreno, che può essere rispecchiato piuttosto semplicemente nelle altre tre direzioni. Lo faremo nella prossima parte.