În această parte a seriei de platforme fizice 2D, vom crea o mapă tile și vom implementa parțial detectarea și răspunsul coliziunii obiectelor tilemap.
Există două abordări de bază pentru construirea nivelelor platformerului. Unul dintre ele este de a utiliza o grilă și așezați plăcile corespunzătoare în celule, iar cealaltă este o formă mai liberă, în care puteți plasa liber geometria nivelului oricum și oriunde doriți.
Există argumente pro-contra pentru ambele abordări. Vom folosi grila, așa că să vedem ce fel de profesioniști are față de cealaltă metodă:
Să începem prin crearea unei clase Map. Va păstra toate datele specifice hărții.
hartă publică a clasei
Acum trebuie să definim toate plăcile pe care harta conține, dar înainte de a face acest lucru, trebuie să știm ce tipuri de plăci există în jocul nostru. Pentru moment, noi planificăm doar trei: o țiglă goală, o faianță solidă și o platformă cu sens unic.
public enum TileType Gol, Blocare, OneWay
În demo, tipurile de plăci corespund direct tipului de coliziune pe care am dori să o avem cu o țiglă, dar într-un joc real care nu este neapărat așa. Pe măsură ce aveți mai multe plăci diferite, ar fi mai bine să adăugați noi tipuri, cum ar fi GrassBlock, GrassOneWay și așa mai departe, pentru a permite modelului TileType să definească nu numai tipul coliziunii, ci și aspectul plăcii.
Acum, în clasa de hărți putem adăuga o serie de plăci.
clasa publica hartă private TileType [,] mTiles;
Bineînțeles, o tabelă pe care nu o putem vedea nu este foarte folositoare pentru noi, așa că avem nevoie și de sprites pentru a face copii de rezervă ale datelor. În mod normal, în Unitate, este extrem de ineficient ca fiecare piesă să fie un obiect separat, dar din moment ce folosim doar acest lucru pentru a testa fizica noastră, este bine să o facem astfel în demo.
privat SpriteRenderer [,] mTilesSprites;
Harta are nevoie, de asemenea, de o poziție în spațiul mondial, astfel încât, dacă trebuie să avem mai mult decât o singură, putem să-i mutăm.
public Vector3 mPoziția;
Lățime și înălțime, în plăci.
public int mWidth = 80; public int mHeight = 60;
Și mărimea dalelor: în demonstrație vom lucra cu o dimensiune destul de mică a dalelor, care este de 16 x 16 pixeli.
public const int cTileSize = 16;
Asta ar fi. Acum avem nevoie de câteva funcții ajutoare pentru a ne permite să accesăm cu ușurință datele hărții. Să începem prin realizarea unei funcții care va transforma coordonatele lumii în coordonatele țiglelor.
public Vector2i GetMapTileAtPoint (Vector2 punct)
După cum puteți vedea, această funcție durează a Vector2
ca parametru și returnează a Vector2i
, care este practic un vector 2D care operează pe numere întregi în loc de plutitoare.
Transformarea poziției lumii în poziția hărții este foarte simplă - pur și simplu trebuie să schimbăm poziția punct
de mPosition
așa că vom întoarce țigla în raport cu poziția hărții și apoi vom împărți rezultatul cu mărimea dalelor.
publica Vector2i GetMapTileAtPoint (Vector2 punct) returnează vectorul Vector2i ((int) ((point.x - mPosition.x + cTileSize / 2.0f) / (float) (cTileSize)), (int) ((point.y - mPosition. y + cTileSize / 2.0f) / (float) (cTileSize));
Rețineți că trebuia să schimbăm punct
suplimentar prin cTileSize / 2.0f
, deoarece pivotul plăcilor este în centrul său. Să facem, de asemenea, două funcții suplimentare care vor returna numai componentele X și Y ale poziției în spațiul hărții. Va fi util mai târziu.
public int int GetMapTileYAtPoint (float y) retur (int) ((y - mPosition.y + cTileSize / 2.0f) / (float) (cTileSize)); int public GetMapTileXAtPoint (float x) întoarcere (int) ((x - mPosition.x + cTileSize / 2.0f) / (float) (cTileSize));
De asemenea, ar trebui să creăm o funcție complementară care, dată fiind o țiglă, își va reveni poziția în spațiul mondial.
publicul Vector2 GetMapTilePosition (int tileIndexX, int tileIndexY) returnează vectorul Vector2 ((float) (tileIndexX * cTileSize) + mPosition.x, (float) (tileIndexY * cTileSize) + mPosition.y); publicul Vector2 GetMapTilePosition (Vector2i tileCoords) returnează vectorul Vector2 ((float) (tileCoords.x * cTileSize) + mPosition.x, (float) (tileCoords.y * cTileSize) + mPosition.y);
În afară de pozițiile de traducere, trebuie să avem și câteva funcții pentru a vedea dacă o țiglă dintr-o anumită poziție este goală, este o faianță solidă sau este o platformă unidirecțională. Să începem cu o funcție GetTile foarte generică, care va returna un tip de țiglă specifică.
public TileType GetTile (int x, int y) dacă (x < 0 || x >= mWidth || y < 0 || y >= mHeight) returnați TileType.Block; retur mTiles [x, y];
După cum puteți vedea, înainte de a reveni la tipul de țiglă, verificăm dacă poziția dată nu este limitată. Dacă este, atunci vrem să o tratăm ca un bloc solid, altfel vom întoarce un tip adevărat.
Următorul în coadă este o funcție pentru a verifica dacă o piesă este un obstacol.
bootul public IsObstacle (int x, int y) if (x < 0 || x >= mWidth || y < 0 || y >= mHeight) return true; întoarcere (mTiles [x, y] == TileType.Block);
In acelasi mod ca si inainte, verificam daca placa este in afara limitelor, si daca este atunci vom reveni la adevarat, asa ca orice piesa din afara limitelor este tratata ca un obstacol.
Acum să verificăm dacă placa este o faianță. Putem sta pe un bloc și pe o platformă unidirecțională, așa că trebuie să revenim la adevărat dacă țigla este una dintre aceste două.
boolul public IsGround (int x, int y) if (x < 0 || x >= mWidth || y < 0 || y >= mHeight) return false; întoarcere (mTiles [x, y] == TileType.OneWay || mTiles [x, y] == TileType.Block);
În cele din urmă, să adăugăm IsOneWayPlatform
și Este gol
funcționează în același mod.
boolul public IsOneWayPlatform (int x, int y) if (x < 0 || x >= mWidth || y < 0 || y >= mHeight) return false; retur (mTiles [x, y] == TileType.OneWay); boolean public IsEmpty (int x, int y) if (x < 0 || x >= mWidth || y < 0 || y >= mHeight) return false; întoarcere (mTiles [x, y] == TileType.Empty);
Asta e tot ce avem nevoie de clasa noastră de hărți. Acum putem trece mai departe și putem implementa coliziunea de caracter împotriva lui.
Să ne întoarcem la MovingObject
clasă. Trebuie să creăm câteva funcții care să detecteze dacă personajul se ciocnește cu tabloul tilelor.
Metoda prin care vom ști dacă caracterul se ciocnește cu o țiglă sau nu este foarte simplu. Vom verifica toate plăcile care există chiar în fața AABB al obiectului în mișcare.
Caseta galbenă reprezintă AABB-ul personajului și vom verifica dalele de-a lungul liniilor roșii. Dacă oricare dintre cele care se suprapun cu o țiglă, am setat o variabilă de coliziune corespunzătoare la adevărat (cum ar fi mOnGround
, mPushesLeftWall
, mAtCeiling
sau mPushesRightWall
).
Să începem prin crearea unei funcții HasGround, care va verifica dacă personajul se ciocnește cu o piesă de sol.
public bool HasGround (Vector2 oldPosition, Vector2 pozitie, Vector2 viteza, out float groundY)
Această funcție revine adevărat dacă caracterul se suprapune cu oricare dintre plăcile de jos. Aceasta ia poziția veche, poziția curentă și viteza actuală ca parametri și, de asemenea, returnează poziția Y a vârfului plăcii cu care ne confruntăm și dacă țigla colizată este o platformă unică sau nu.
Primul lucru pe care dorim să-l facem este să calculam centrul AABB.
boolul public HasGround (Vector2 oldPosition, pozitia Vector2, viteza Vector2, terenul plutitor Y) var center = position + mAABBOffset;
Acum că avem asta, pentru verificarea de coliziune de jos va trebui să calculam începutul și sfârșitul liniei senzorului de fund. Linia senzorului este la doar un pixel sub conturul fundului AABB.
boolul public HasGround (Vector2 oldPosition, pozitia Vector2, viteza Vector2, terenul plutitor Y) var center = position + mAABBOffset; var bottomLeft = centru - mAABB.halfSize - Vector2.up + Vector2.right; var bottomRight = nou Vector2 (bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y);
stânga jos
și dreapta-jos
reprezintă cele două capete ale senzorului. Acum, când avem aceste informații, putem calcula ce dale trebuie să verificăm. Să începem prin crearea unei bucla în care vom trece prin gresie de la stânga la dreapta.
pentru (var checkedTile = bottomLeft;; checkedTile.x + = Map.cTileSize)
Rețineți că nu există nicio condiție pentru a ieși din bucla aici - o vom face la sfârșitul bucla.
Primul lucru pe care ar trebui să-l facem în buclă este să ne asigurăm că checkedTile.x
nu este mai mare decât capătul drept al senzorului. Este posibil să se întâmple acest lucru deoarece deplasăm punctul verificat prin multiplii dimensiunii plăcii, de exemplu, dacă caracterul are o lățime de 1,5 tigle, trebuie să verificăm țigla de pe marginea din stânga a senzorului, apoi o piesă la dreapta , iar apoi 1,5 dale spre dreapta în loc de 2.
pentru (var checkedTile = bottomLeft;; checkedTile.x + = Map.cTileSize) checkedTile.x = Mathf.Min (checkTile.x, bottomRight.x);
Acum trebuie să obținem coordonatele țiglelor în spațiul hărții pentru a putea verifica tipul plăcilor.
int tileIndexX, tileIndexY; pentru (var checkedTile = bottomLeft;; checkedTile.x + = Map.cTileSize) checkedTile.x = Mathf.Min (checkTile.x, bottomRight.x); tileIndexX = mMap.GetMapTileXAtPoint (checkTile.x); tileIndexY = mMap.GetMapTileYAtPoint (checkedTile.y);
Mai întâi, să calculam poziția de top a plăcilor.
int tileIndexX, tileIndexY; pentru (var checkedTile = bottomLeft;; checkedTile.x + = Map.cTileSize) checkedTile.x = Mathf.Min (checkTile.x, bottomRight.x); tileIndexX = mMap.GetMapTileXAtPoint (checkTile.x); tileIndexY = mMap.GetMapTileYAtPoint (checkedTile.y); groundY = (float) tileIndexY * map.cTileSize + map.cTileSize / 2.0f + mMap.mPosition.y;
Acum, dacă plăcile verificate în prezent reprezintă un obstacol, putem reveni cu adevărat la adevărat.
int tileIndexX, tileIndexY; pentru (var checkedTile = bottomLeft;; checkedTile.x + = Map.cTileSize) checkedTile.x = Mathf.Min (checkTile.x, bottomRight.x); tileIndexX = mMap.GetMapTileXAtPoint (checkTile.x); tileIndexY = mMap.GetMapTileYAtPoint (checkedTile.y); groundY = (float) tileIndexY * map.cTileSize + map.cTileSize / 2.0f + mMap.mPosition.y; dacă (mMap.IsObstacle (tileIndexX, tileIndexY)) returnează adevărat;
În cele din urmă, să verificăm dacă am analizat deja toate plăcile care se intersectează cu senzorul. Dacă este cazul, atunci putem ieși din siguranță în bucla. După ce ieșim din buclă și nu găsim o plăcuță cu care ne-am ciocnit, trebuie să ne întoarcem fals
pentru a lăsa apelantul să știe că nu există un teren sub obiect.
int tileIndexX, tileIndexY; pentru (var checkedTile = bottomLeft;; checkedTile.x + = Map.cTileSize) checkedTile.x = Mathf.Min (checkTile.x, bottomRight.x); tileIndexX = mMap.GetMapTileXAtPoint (checkTile.x); tileIndexY = mMap.GetMapTileYAtPoint (checkedTile.y); groundY = (float) tileIndexY * map.cTileSize + map.cTileSize / 2.0f + mMap.mPosition.y; dacă (mMap.IsObstacle (tileIndexX, tileIndexY)) returnează adevărat; dacă (checkedTile.x> = bottomRight.x) pauză; return false;
Aceasta este versiunea cea mai de bază a cecului. Să încercăm să mergem la lucru acum. Înapoi în UpdatePhysics
funcția noastră, verificarea veche a solului arată așa.
dacă (mPosition.y <= 0.0f) mPosition.y = 0.0f; mOnGround = true; else mOnGround = false;
Să o înlocuim folosind metoda nou creată. Dacă personajul se încadrează și am găsit un obstacol pe calea noastră, atunci trebuie să-l mutăm din coliziune și să stabilim și mOnGround
la adevărat. Să începem cu condiția.
float groundY = 0; dacă (mSpeed.y <= 0.0f && HasGround(mOldPosition, mPosition, mSpeed, out groundY))
Dacă condiția este îndeplinită, atunci trebuie să mutăm caracterul de pe partea superioară a plăcii cu care s-au ciocnit.
float groundY = 0; dacă (mSpeed.y <= 0.0f && HasGround(mOldPosition, mPosition, mSpeed, out groundY)) mPosition.y = groundY + mAABB.halfSize.y - mAABBOffset.y;
După cum puteți vedea, este foarte simplu deoarece funcția readuce nivelul la sol la care ar trebui să aliniem obiectul. După aceasta, trebuie doar să setăm viteza verticală la zero și să setăm mOnGround
la adevărat.
float groundY = 0; dacă (mSpeed.y <= 0.0f && HasGround(mOldPosition, mPosition, mSpeed, out groundY)) mPosition.y = groundY + mAABB.halfSize.y - mAABBOffset.y; mSpeed.y = 0.0f; mOnGround = true;
Dacă viteza noastră verticală este mai mare decât zero sau nu atingem niciun punct, trebuie să setăm mOnGround
la fals
.
float groundY = 0; dacă (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;
Acum, să vedem cum funcționează acest lucru.
După cum puteți vedea, funcționează bine! Detectarea coliziunii pentru pereți de pe ambele părți și în partea de sus a personajului nu este încă acolo, dar caracterul se oprește de fiecare dată când se întâlnește cu solul. Încă mai trebuie să punem un pic mai mult în funcția de verificare a coliziunilor pentru ao face robustă.
Una dintre problemele pe care trebuie să le rezolvăm este vizibilă dacă decalajul caracterului de la un cadru la altul este prea mare pentru a detecta corect coliziunea. Acest lucru este ilustrat în următoarea imagine.
Această situație nu se întâmplă acum pentru că am blocat viteza maximă care se încadrează la o valoare rezonabilă și actualizăm fizica cu o frecvență de 60 FPS, astfel încât diferențele dintre pozițiile dintre cadre sunt destul de mici. Să vedem ce se întâmplă dacă actualizăm fizica doar de 30 de ori pe secundă.
După cum puteți vedea, în acest scenariu, controlul nostru de coliziune la sol ne-a eșuat. Pentru a rezolva acest lucru, nu putem verifica pur și simplu dacă personajul se află sub el în poziția actuală, dar mai degrabă trebuie să vedem dacă există obstacole de-a lungul drumului de la poziția cadrului anterior.
Să ne întoarcem la noi HasGround
funcţie. Aici, pe lângă calculul centrului, vom dori, de asemenea, să calculam centrul anterioare al cadrului.
public bool HasGround (Vector2 oldPosition, Vector2 pozitie, Vector2 viteza, out float groundY) var oldCenter = oldPosition + mAABBOffset; var centru = poziție + mAABBOffset;
De asemenea, trebuie să obținem poziția senzorului anterior a cadrului.
public bool HasGround (Vector2 oldPosition, Vector2 pozitie, Vector2 viteza, out float groundY) var oldCenter = oldPosition + mAABBOffset; var centru = poziție + mAABBOffset; var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right; var bottomLeft = centru - mAABB.halfSize - Vector2.up + Vector2.right; var bottomRight = nou Vector2 (bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y);
Acum trebuie să calculam la ce tiglă pe verticală vom începe să verificăm dacă există sau nu o coliziune și la care ne oprim.
public bool HasGround (Vector2 oldPosition, Vector2 pozitie, Vector2 viteza, out float groundY) var oldCenter = oldPosition + mAABBOffset; var centru = poziție + mAABBOffset; var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right; var bottomLeft = centru - mAABB.halfSize - Vector2.up + Vector2.right; var bottomRight = nou 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);
Începem căutarea de pe placă la poziția senzorului anterior a cadrului și o terminăm la poziția senzorului cadrului curent. Desigur, pentru că atunci când verificăm o coliziune la sol, presupunem că cadem, ceea ce înseamnă că ne mișcăm de la poziția superioară la cel inferior.
În cele din urmă, trebuie să avem o altă buclă de iterație. Acum, înainte de a umple codul pentru această bucla exterioară, să luăm în considerare următorul scenariu.
Aici puteți vedea o săgeată care se mișcă repede. Acest exemplu arată că este necesar nu numai să iteram prin toate plăcile pe care ar trebui să le trecem vertical, ci și să interpolam poziția obiectului pentru fiecare țiglă pe care o parcurgem pentru a aproxima calea de la poziția cadrului anterior la cea actuală. Dacă pur și simplu am continuat să folosim poziția obiectului curent, atunci în cazul de mai sus ar fi detectată o coliziune, chiar dacă nu ar trebui să fie.
Să redenumim stânga jos
și dreapta-jos
la fel de newBottomLeft
și newBottomRight
, astfel încât știm că acestea sunt pozițiile senzorului noului cadru.
public bool HasGround (Vector2 oldPosition, Vector2 pozitie, Vector2 viteza, out float groundY) var oldCenter = oldPosition + mAABBOffset; var centru = poziție + mAABBOffset; var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomLeft = centru - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomRight = nou 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; pentru (int tileIndexY = begY; tileIndexY> = endY; --tilIndexY) return false;
Acum, în această buclă nouă, să interpolam pozițiile senzorului, astfel încât la începutul buclei să presupunem că senzorul se află în poziția anterioară a cadrului și că la sfârșitul acestuia va fi în poziția cadrului curent.
public bool HasGround (Vector2 oldPosition, Vector2 pozitie, Vector2 viteza, out float groundY) var oldCenter = oldPosition + mAABBOffset; var centru = poziție + mAABBOffset; var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomLeft = centru - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomRight = nou 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; pentru (int tileIndexY = begY; tileIndexY> = endY; --IndIndexY) var bottomLeft = Vector2.Lerp (newBottomLeft, oldBottomLeft, (float) Mathf.Abs (endY-tileIndexY) / dist); var bottomRight = nou Vector2 (bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y); return false;
Rețineți că interpolam vectorii bazându-ne pe diferența de dale pe axa Y. Când pozițiile vechi și noi se află în aceeași placă, distanța verticală va fi zero, deci în acest caz nu am fi putut să împărțim distanța. Așadar, pentru a rezolva această problemă, dorim ca distanța să aibă o valoare minimă de 1, astfel încât, dacă se va întâmpla un astfel de scenariu (și se va întâmpla foarte des), pur și simplu vom folosi noua poziție pentru detectarea coliziunilor.
În cele din urmă, pentru fiecare iterație, trebuie să executăm același cod pe care l-am făcut deja pentru verificarea coliziunii de-a lungul lățimii obiectului.
public bool HasGround (Vector2 oldPosition, Vector2 pozitie, Vector2 viteza, out float groundY) var oldCenter = oldPosition + mAABBOffset; var centru = poziție + mAABBOffset; var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomLeft = centru - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomRight = nou 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; pentru (int tileIndexY = begY; tileIndexY> = endY; --IndIndexY) var bottomLeft = Vector2.Lerp (newBottomLeft, oldBottomLeft, (float) Mathf.Abs (endY-tileIndexY) / dist); var bottomRight = nou Vector2 (bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y); pentru (var checkedTile = bottomLeft;; checkedTile.x + = Map.cTileSize) checkedTile.x = Mathf.Min (checkTile.x, bottomRight.x); tileIndexX = mMap.GetMapTileXAtPoint (checkTile.x); groundY = (float) tileIndexY * map.cTileSize + map.cTileSize / 2.0f + mMap.mPosition.y; dacă (mMap.IsObstacle (tileIndexX, tileIndexY)) returnează adevărat; dacă (checkedTile.x> = bottomRight.x) pauză; return false;
Cam asta e tot. Așa cum vă puteți imagina, dacă obiectele jocului se mișcă foarte repede, acest mod de a verifica coliziunea poate fi destul de costisitor, dar ne asigură și că nu vor exista probleme ciudate cu obiecte care se deplasează prin ziduri solide.
Phew, a fost mai mult decât am crezut că avem nevoie, nu-i așa? Dacă observați orice erori sau posibile comenzi rapide pe care le puteți lua, permiteți-mi și tuturor să știți în comentariile! Verificarea coliziunii ar trebui să fie suficient de robustă, astfel încât să nu ne temem de eventualele evenimente nefericite de obiecte alunecând prin blocurile tilemap.
O mare parte a codului a fost scris pentru a vă asigura că nu există obiecte care trec prin plăci la viteze mari, dar dacă aceasta nu este o problemă pentru un anumit joc, am putea elimina în siguranță codul suplimentar pentru a crește performanța. S-ar putea chiar să fie o idee bună să ai un steag pentru anumite obiecte care se mișcă rapid, astfel încât numai cei care folosesc versiunile mai costisitoare ale verificărilor.
Avem încă multe lucruri de rezolvat, dar am reușit să facem o verificare fiabilă a coliziunii, care poate fi oglindită destul de clar în celelalte trei direcții. Vom face asta în următoarea parte.