Genomförande av Tetris Kollisionsdetektion

Jag är säker på att det är möjligt att skapa ett Tetris-spel med ett pek-och-klicka-gamedev-verktyg, men jag kunde aldrig räkna ut hur. Idag är jag mer bekväm att tänka på en högre abstraktionsnivå, där tetrominoen du ser på skärmen är bara en representation av vad som händer i det underliggande spelet. I den här handledningen visar jag vad jag menar, genom att demonstrera hur man hanterar kollisionsdetektering i Tetris.

Notera: Även om koden i denna handledning skrivs med AS3, borde du kunna använda samma tekniker och begrepp i nästan vilken spelutvecklingsmiljö som helst.


Rutnätet

Ett vanligt Tetris-spelfält har 16 rader och 10 kolumner. Vi kan representera detta i en multidimensionell grupp, innehållande 16 delrader av 10 element:


Grafik från denna stora Vectortuts + handledning.

Tänk dig att bilden till vänster är en skärmdump från spelet - det är hur spelet kan se till spelaren, efter att en tetromino har landat, men innan en annan har sprungits.

Till höger är en array representation av spelets nuvarande tillstånd. Låt oss kalla det landat[], som det hänvisar till alla block som landat. Ett element av 0 betyder att inget block upptar det rummet 1 innebär att ett block har landat i detta utrymme.

Låt oss nu kasta en O-tetromino i mitten på toppen av fältet:

 tetromino.shape = [[1,1], [1,1]]; tetromino.topLeft = rad: 0, col: 4;

De form egenskapen är en annan flerdimensionell grupprepresentation av formen av denna tetromino. övre vänstra ger positionen för det vänstra blocket av tetromino: i den övre raden och den femte kolumnen i.

Vi gör allt. För det första drar vi bakgrunden - det här är enkelt, det är bara en statisk rutnätbild.

Därefter ritar vi varje kvarter från landat[] array:

 för (var rad = 0; rad < landed.length; row++)  for (var col = 0; col < landed[row].length; col++)  if (landed[row][col] != 0)  //draw block at position corresponding to row and col //remember, row gives y-position, col gives x-position   

Mina blockbilder är 20x20px, så för att rita blocken kunde jag bara sätta in en ny blockbild på (kol * 20, rad * 20). Detaljerna spelar ingen roll.

Därefter ritar vi varje block i den nuvarande tetromino:

 för (var rad = 0; rad < tetromino.shape.length; row++)  for (var col = 0; col < tetromino.shape[row].length; col++)  if (tetromino.shape[row][col] != 0)  //draw block at position corresponding to //row + topLeft.row, and //col + topLeft.col   

Vi kan använda samma ritningskod här, men vi måste kompensera blocken med övre vänstra.

Här är resultatet:

Observera att den nya O-tetromino inte visas i landat[] array - det beror på, ja det har inte landat än.


Faller

Antag att spelaren inte rör kontrollerna. Med jämna mellanrum - låt oss säga varje halv sekund - O-tetromino behöver falla ner en rad.

Det är frestande att bara ringa:

 tetromino.topLeft.row ++;

... och gör sedan allt igen, men det här kommer inte att upptäcka överlappningar mellan O-tetromino och de block som redan har landat.

Istället kontrollerar vi eventuella kollisioner först och flyttar bara tetromino om det är "säkert".

För detta måste vi definiera en potential ny position för tetromino:

 tetromino.potentialTopLeft = rad: 1, kol: 4;

Nu söker vi efter kollisioner. Det enklaste sättet att göra detta är att slinga igenom alla utrymmen i gallret som tetromino skulle ta upp i sin potentiella nya position och kontrollera landat[] array för att se om de redan är tagna:

 för (var rad = 0; rad < tetromino.shape.length; row++)  for (var col = 0; col < tetromino.shape[row].length; col++)  if (tetromino.shape[row][col] != 0)  if (landed[row + tetromino.potentialTopLeft.row] != 0 && landed[col + tetromino.potentialTopLeft.col] != 0)  //the space is taken    

Låt oss testa detta:

 tetromino.shape = [[1,1], [1,1]]; tetromino.potentialTopLeft: rad: 1, kol: 4 ------------------------------------- ------- rad: 0, col: 0, tetromino.shape [0] [0]: 1, landade [0 + 1] [0 + 4]: 0 rad: 0, col: 1, tetromino. form [0] [1]: 1, landade [0 + 1] [1 + 4]: 0 rad: 1, col: 0, tetromino.shape [1] [0]: 1, landade [1 + 1] [ 0 + 4]: 0 rad: 1, kol: 1, tetromino.shape [1] [1]: 1, landade [1 + 1] [1 + 4]: 0

Alla nollor! Det betyder att det inte finns några kollisioner, så tetromino kan röra sig.

Vi sätter:

 tetromino.topLeft = tetromino.potentialTopLeft;

... och gör sedan allt igen:

Bra!


Landning

Anta nu att spelaren låter tetromino falla till denna punkt:

Den övre vänstra är på rad: 11, kol: 4. Vi kan se att tetromino skulle kollidera med de landade blocken om det föll längre - men visar vår kod det? Låt oss se:

 tetromino.shape = [[1,1], [1,1]]; tetromino.potentialTopLeft: rad: 12, kol: 4 ------------------------------------- ------- rad: 0, col: 0, tetromino.shape [0] [0]: 1, landade [0 + 12] [0 + 4]: 0 rad: 0, col: 1, tetromino. form [0] [1]: 1, landade [0 + 12] [1 + 4]: 0 rad: 1, col: 0, tetromino.shape [1] [0]: 1, landade [1 + 12] [ 0 + 4]: 1 rad: 1, kol: 1, tetromino.shape [1] [1]: 1, landade [1 + 12] [1 + 4]: 0

Det finns en 1, vilket innebär att det finns en kollision - specifikt skulle tetromino kollidera med blocket vid landade [13] [4].

Det betyder att tetromino har landat, vilket innebär att vi måste lägga till det i landat[] array. Vi kan göra det med en mycket liknande slinga till den vi brukade kontrollera för potentiella kollisioner:

 för (var rad = 0; rad < tetromino.shape.length; row++)  for (var col = 0; col < tetromino.shape[row].length; col++)  if (tetromino.shape[row][col] != 0)  landed[row + tetromino.topLeft.row][col + tetromino.topLeft.col] = tetromino.shape[row][col];   

Här är resultatet:

Än så länge är allt bra. Men du kanske har märkt att vi inte hanterar fallet där tetromino landar på "marken" - vi handlar bara om tetrominoer landning på toppen av andra tetrominoer.

Det finns en ganska enkel lösning för detta: När vi kontrollerar potentiella kollisioner kontrollerar vi också om den potentiella nya positionen för varje block skulle ligga under spelets botten:

 för (var rad = 0; rad < tetromino.shape.length; row++)  for (var col = 0; col < tetromino.shape[row].length; col++)  if (tetromino.shape[row][col] != 0)  if (row + tetromino.potentialTopLeft.row >= landed.length) // det här blocket skulle ligga under spelfältet annars om (landade [rad + tetromino.potentialTopLeft.row]! = 0 && landade [col + tetromino.potentialTopLeft.col]! = 0) / utrymmet är taget

Självklart, om något block i tetromino skulle hamna under spelets botten om det föll längre, gör vi tetromino "land", precis som om ett block skulle överlappa ett block som redan hade landat.

Nu kan vi börja nästa runda med en ny tetromino.


Flytta och rotera

Den här gången, låt oss gissa en J-tetromino:

 tetromino.shape = [[0,1], [0,1], [1,1]]; tetromino.topLeft = rad: 0, col: 4;

Lämna det:

Kom ihåg, varje halv sekund kommer tetromino att falla av en rad. Låt oss anta att spelaren träffar vänster-knappen fyra gånger innan en halv sekund passerar; vi vill flytta tetromino kvar med en kolumn varje gång.

Hur kan vi se till att tetromino inte kolliderar med någon av de landade blocken? Vi kan faktiskt använda samma kod från tidigare!

Först ändrar vi den potentiella nya positionen:

 tetromino.potentialTopLeft = rad: tetromino.topLeft, col: tetromino.topLeft - 1;

Nu kontrollerar vi om några av blocken i tetromino överlappar varandra med de landade blocken, med samma grundläggande kontroll som tidigare (utan att störa om det är något block som har gått under spelplanen):

 för (var rad = 0; rad < tetromino.shape.length; row++)  for (var col = 0; col < tetromino.shape[row].length; col++)  if (tetromino.shape[row][col] != 0)  if (landed[row + tetromino.potentialTopLeft.row] != 0 && landed[col + tetromino.potentialTopLeft.col] != 0)  //the space is taken    

Kör det genom samma kontroller som vi brukar utföra, så ser du att det fungerar bra. Den stora skillnaden är att vi måste komma ihåg inte att lägga till tetrominoens block till landat[] array om det finns en potentiell kollision - i stället bör vi helt enkelt inte ändra värdet på tetromino.topLeft.

Varje gång spelaren flyttar tetromino borde vi återskapa allt. Här är det slutliga resultatet:

Vad händer om spelaren träffar kvar en gång till? När vi kallar detta:

 tetromino.potentialTopLeft = rad: tetromino.topLeft, col: tetromino.topLeft - 1;

... vi kommer sluta försöka ställa in tetromino.potentialTopLeft.col till -1 - och det kommer att leda till alla möjliga problem senare.

Låt oss ändra vår existerande kollisionskontroll för att hantera detta:

 för (var rad = 0; rad < tetromino.shape.length; row++)  for (var col = 0; col < tetromino.shape[row].length; col++)  if (tetromino.shape[row][col] != 0)  if (col + tetromino.potentialTopLeft.col < 0)  //this block would be to the left of the playing field  if (landed[row + tetromino.potentialTopLeft.row] != 0 && landed[col + tetromino.potentialTopLeft.col] != 0)  //the space is taken    

Enkelt - det är samma idé som när vi kontrollerar om någon av blocken skulle falla under spelplanen.

Låt oss ta itu med höger sida också:

 för (var rad = 0; rad < tetromino.shape.length; row++)  for (var col = 0; col < tetromino.shape[row].length; col++)  if (tetromino.shape[row][col] != 0)  if (col + tetromino.potentialTopLeft.col < 0)  //this block would be to the left of the playing field  if (col + tetromino.potentialTopLeft.col >= landade [0] .length) // det här blocket skulle vara till höger om spelområdet om (landade [rad + tetromino.potentialTopLeft.row]! = 0 && landade [col + tetromino.potentialTopLeft.col]! = 0) // utrymmet är taget

Återigen, om tetromino skulle flytta utanför spelplanen förändras vi bara inte tetromino.topLeft - inget behov av att göra något annat.

Okej, en halv sekund måste ha gått förr nu, så låt oss låta den tetromino falla en rad:

 tetromino.shape = [[0,1], [0,1], [1,1]]; tetromino.topLeft = rad: 1, kol: 0;

Antag nu att spelaren träffar knappen för att få tetromino att rotera medsols. Det här är faktiskt ganska lätt att hantera - vi ändrar bara tetromino.shape, utan att ändra tetromino.topLeft:

 tetromino.shape = [[1,0,0], [1,1,1]]; tetromino.topLeft = rad: 1, kol: 0;

Vi skulle kunna använd några matriser för att rotera innehållet i arrayen av block ... men det är mycket enklare bara att lagra de fyra möjliga rotationerna av varje tetromino någonstans, så här:

 jTetromino.rotations = [[[0,1], [0,1], [1,1]], [[1,0,0], [1,1,1]], [[1,1] [1,0], [1,0]], [[1,1,1], [0,0,1]]];

(Jag låter dig räkna ut var bäst att lagra det i din kod!)

Hur som helst, när vi gör allt igen så kommer det att se ut så här:

Vi kan rotera det igen (och låt oss anta att vi gör båda dessa rotationer inom en halv sekund):

 tetromino.shape = [[1,1], [1,0], [1,0]]; tetromino.topLeft = rad: 1, kol: 0;

Återvänd igen:

Underbar. Låt oss släppa några rader tills vi kommer till det här läget:

 tetromino.shape = [[1,1], [1,0], [1,0]]; tetromino.topLeft = rad: 10, col: 0;

Plötsligt träffar spelaren knappen Rotate Clockwise igen, utan någon uppenbar anledning. Vi kan se från att titta på bilden att detta inte bör tillåta något att hända, men vi har inga kontroller på plats än för att förhindra det.

Du kan nog gissa hur vi ska lösa detta. Vi presenterar en tetromino.potentialShape, sätt den på formen av den roterade tetrominoen och leta efter eventuella överlappningar med block som redan har landat.

 tetromino.shape = [[1,1], [1,0], [1,0]]; tetromino.topLeft = rad: 10, col: 0; tetromino.potentialShape = [[1,1,1], [0,0,1]];
 för (var rad = 0; rad < tetromino.potentialShape.length; row++)  for (var col = 0; col < tetromino.potentialShape[row].length; col++)  if (tetromino.potentialShape[row][col] != 0)  if (col + tetromino.topLeft.col < 0)  //this block would be to the left of the playing field  if (col + tetromino.topLeft.col >= landade [0] .length) // det här blocket skulle vara till höger om spelområdet om (rad + tetromino.topLeft.row> = landed.length) // det här blocket skulle ligga under spelplanen om (landade [rad + tetromino.topLeft.row]! = 0 && landade [col + tetromino.topLeft.col]! = 0) // utrymmet är taget

Om det finns en överlappning (eller om den roterade formen skulle vara delvis ur gränsen), låter vi helt enkelt inte blocket rotera. Således kan den falla på plats en halv sekund senare och läggas till landat[] array:

Excellent.


Håller det helt rakt

För att vara tydlig har vi nu tre separata kontroller.

Den första kontrollen gäller när en tetromino faller och heter varje halv sekund:

 // set tetromino.potentialTopLeft att vara en rad under tetromino.topLever då: för (var rad = 0; rad < tetromino.shape.length; row++)  for (var col = 0; col < tetromino.shape[row].length; col++)  if (tetromino.shape[row][col] != 0)  if (row + tetromino.potentialTopLeft.row >= landed.length) // det här blocket skulle ligga under spelfältet annars om (landade [rad + tetromino.potentialTopLeft.row]! = 0 && landade [col + tetromino.potentialTopLeft.col]! = 0) / utrymmet är taget

Om alla kontroller passerar, så ställer vi in tetromino.topLeft till tetromino.potentialTopLeft.

Om någon av kontrollerna misslyckas, gör vi tetromino-landet, som så:

 för (var rad = 0; rad < tetromino.shape.length; row++)  for (var col = 0; col < tetromino.shape[row].length; col++)  if (tetromino.shape[row][col] != 0)  landed[row + tetromino.topLeft.row][col + tetromino.topLeft.col] = tetromino.shape[row][col];   

Den andra kontrollen är för när spelaren försöker flytta tetromino till vänster eller höger och kallas när spelaren träffar rörelseknappen:

 // set tetromino.potentialTopLeft att vara en kolumn till höger eller vänster // av tetromino.topLeft, i förekommande fall då: för (var rad = 0; rad < tetromino.shape.length; row++)  for (var col = 0; col < tetromino.shape[row].length; col++)  if (tetromino.shape[row][col] != 0)  if (col + tetromino.potentialTopLeft.col < 0)  //this block would be to the left of the playing field  if (col + tetromino.potentialTopLeft.col >= landade [0] .length) // det här blocket skulle vara till höger om spelområdet om (landade [rad + tetromino.potentialTopLeft.row]! = 0 && landade [col + tetromino.potentialTopLeft.col]! = 0) // utrymmet är taget

Om (och endast om) alla dessa kontroller passerar ställer vi in tetromino.topLeft till tetromino.potentialTopLeft.

Den tredje kontrollen är för när spelaren försöker rotera tetromino medurs eller moturs och kallas när spelaren träffar nyckeln för att göra det:

 // set tetromino.potentialShape som den roterade versionen av tetromino.shape // (medurs eller moturs, beroende på vad som är lämpligt), sedan: för (var rad = 0; rad < tetromino.potentialShape.length; row++)  for (var col = 0; col < tetromino.potentialShape[row].length; col++)  if (tetromino.potentialShape[row][col] != 0)  if (col + tetromino.topLeft.col < 0)  //this block would be to the left of the playing field  if (col + tetromino.topLeft.col >= landade [0] .length) // det här blocket skulle vara till höger om spelområdet om (rad + tetromino.topLeft.row> = landed.length) // det här blocket skulle ligga under spelplanen om (landade [rad + tetromino.topLeft.row]! = 0 && landade [col + tetromino.topLeft.col]! = 0) // utrymmet är taget

Om (och endast om) alla dessa kontroller passerar ställer vi in tetromino.shape till tetromino.potentialShape.

Jämför dessa tre kontroller - det är lätt att få dem blandade, eftersom koden är väldigt lik.


Andra problem

Formmått

Hittills har jag använt olika storlekar av arrays för att representera olika former av tetrominoer (och de olika rotationerna av dessa former): O-tetromino användes en 2x2-grupp och J-tetromino använde en 3x2 eller en 2x3-array.

För konsistens rekommenderar jag att du använder samma storleksordning för alla tetrominoer (och rotationer därav). Förutsatt att du klibbar med de sju standard tetrominoerna kan du göra det med en 4x4-serie.

Det finns flera olika sätt att ordna rotationerna inom den här 4x4-kvadraten. ta en titt på Tetris Wiki för mer information om vilka olika spel som används.

Wall Kicking

Antag att du representerar en vertikal I-tetromino så här:

 [[0,1,0,0], [0,1,0,0], [0,1,0,0], [0,1,0,0]];

... och du representerar dess rotation så här:

 [[0,0,0,0], [0,0,0,0], [1,1,1,1], [0,0,0,0]];

Antag nu att en vertikal I-tetromino pressas upp mot en vägg så här:

Vad händer om spelaren träffar rotationsnyckeln?

Tja, med vår nuvarande kollisionsdetekteringskod händer ingenting - det vänstra blocket av den horisontella I-tetromino skulle ligga utanför gränserna.

Det här är förmodligen bra - det var så det fungerade i NES-versionen av Tetris - men det finns ett alternativ: rotera tetrominoen och flytta den en gång till höger, så här:

Jag ska låta dig räkna ut detaljerna, men i huvudsak måste du kontrollera om roterande tetromino skulle flytta den ur gränsen och, om så är fallet, flytta den åt vänster eller höger ett eller två utrymmen efter behov. Du måste dock komma ihåg att kontrollera eventuella kollisioner med andra block efter att du har tillämpat både rotationen och rörelsen!

Olika färgade block

Jag har använt block av samma färg i hela denna handledning för att hålla saker enkelt, men det är lätt att byta färger.

För varje färg, välj ett nummer för att representera det; använd dessa nummer i din form[] och landat[] arrayer; ändra sedan din renderingskod till färgblock baserat på deras nummer.

Resultatet kan se ut så här:


Slutsats

Att skilja den visuella representationen av ett objekt i spelet från dess data är ett väldigt viktigt begrepp att förstå; det kommer upp igen och igen i andra spel, särskilt när det gäller kollisionsdetektering.

I mitt nästa inlägg tittar vi på hur man implementerar den andra kärnfunktionen i Tetris: tar bort linjer när de fylls. Tack för att du läser!