Ik weet zeker dat het mogelijk is om een Tetris-spel te maken met een point-and-click gamedev-tool, maar ik heb nooit kunnen achterhalen hoe. Vandaag voel ik me meer op mijn gemak op een hoger niveau van abstractie te denken, waarbij de tetromino die je op het scherm ziet, slechts een is vertegenwoordiging van wat er gaande is in het onderliggende spel. In deze tutorial laat ik je zien wat ik bedoel, door te laten zien hoe botsingsdetectie in Tetris moet worden aangepakt.
Notitie: Hoewel de code in deze tutorial met AS3 is geschreven, zou je in bijna elke game-ontwikkelomgeving dezelfde technieken en concepten moeten kunnen gebruiken.
Een standaard Tetris-speelveld heeft 16 rijen en 10 kolommen. We kunnen dit weergeven in een multidimensionale array, die 16 sub-arrays van 10 elementen bevat:
Stel je voor dat het beeld aan de linkerkant een screenshot is van het spel - het is hoe het spel eruit kan zien voor de speler, nadat een tetromino is geland, maar voordat een ander is voortgebracht.
Aan de rechterkant is een array-weergave van de huidige status van het spel. Laten we het noemen landde []
, omdat het verwijst naar alle blokken die zijn geland. Een element van 0
betekent dat geen enkel blok die ruimte inneemt; 1
betekent dat er een blok in die ruimte is geland.
Laten we nu een O-tetromino in het midden bovenaan het veld spawnen:
tetromino.shape = [[1,1], [1,1]]; tetromino.topLeft = row: 0, col: 4;
De vorm
property is een andere multidimensionale arrayrepresentatie van de vorm van deze tetromino. linksboven
geeft de positie van het blok linksboven van de tetromino: op de bovenste rij en de vijfde kolom in.
We maken alles. Eerst tekenen we de achtergrond - dit is gemakkelijk, het is gewoon een statisch rasterbeeld.
Vervolgens tekenen we elk blok van de landde []
array:
voor (var row = 0; rij < 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
Mijn blokafbeeldingen zijn 20x20px, dus om de blokken te tekenen kon ik zojuist een nieuwe blokafbeelding invoegen (col * 20, rij * 20)
. De details doen er niet echt toe.
Vervolgens tekenen we elk blok in de huidige tetromino:
voor (var row = 0; rij < 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
We kunnen hier dezelfde tekencode gebruiken, maar we moeten de blokken verschuiven met linksboven
.
Dit is het resultaat:
Merk op dat de nieuwe O-tetromino niet voorkomt in de landde []
array - dat is omdat, nou ja, het is nog niet geland.
Stel dat de speler de bedieningselementen niet aanraakt. Met regelmatige tussenpozen - laten we zeggen elke halve seconde - moet de O-tetromino één rij naar beneden vallen.
Het is verleidelijk om gewoon te bellen:
tetromino.topLeft.row ++;
... en vervolgens alles opnieuw weergeven, maar dit zal geen overlappingen detecteren tussen de O-tetromino en de blokken die al zijn geland.
In plaats daarvan controleren we eerst mogelijke botsingen en verplaatsen we de tetromino alleen als deze "veilig" is.
Hiervoor moeten we een potentieel nieuwe positie voor de tetromino:
tetromino.potentialTopLeft = row: 1, col: 4;
Nu controleren we op botsingen. De eenvoudigste manier om dit te doen, is door alle spaties in het raster door te lopen die de tetromino zou nemen in zijn potentiële nieuwe positie en controleer de landde []
array om te zien of ze al zijn gemaakt:
voor (var row = 0; rij < 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
Laten we dit uittesten:
tetromino.shape = [[1,1], [1,1]]; tetromino.potentialTopLeft: row: 1, col: 4 ------------------------------------- ------- row: 0, col: 0, tetromino.shape [0] [0]: 1, landed [0 + 1] [0 + 4]: 0 row: 0, col: 1, tetromino. shape [0] [1]: 1, landed [0 + 1] [1 + 4]: 0 rij: 1, col: 0, tetromino.shape [1] [0]: 1, landed [1 + 1] [ 0 + 4]: 0 rij: 1, col: 1, tetromino.shape [1] [1]: 1, geland [1 + 1] [1 + 4]: 0
Alle nullen! Dit betekent dat er geen botsing is, dus de tetromino kan bewegen.
We zetten:
tetromino.topLeft = tetromino.potentialTopLeft;
... en vervolgens alles opnieuw weergeven:
Super goed!
Stel nu dat de speler de tetromino laat vallen tot dit punt:
De linkerbovenhoek staat op row: 11, col: 4
. We kunnen zien dat de tetromino zou botsen met de gelande blokken als deze nog meer zou vallen - maar komt onze code er wel uit? Laten we eens kijken:
tetromino.shape = [[1,1], [1,1]]; tetromino.potentialTopLeft: row: 12, col: 4 ------------------------------------- ------- row: 0, col: 0, tetromino.shape [0] [0]: 1, landed [0 + 12] [0 + 4]: 0 row: 0, col: 1, tetromino. shape [0] [1]: 1, landed [0 + 12] [1 + 4]: 0 rij: 1, col: 0, tetromino.shape [1] [0]: 1, landed [1 + 12] [ 0 + 4]: 1 rij: 1, col: 1, tetromino.shape [1] [1]: 1, geland [1 + 12] [1 + 4]: 0
Er is een 1
, wat betekent dat er een botsing is - in het bijzonder zou de tetromino tegen het blok botsen aangevoerd [13] [4]
.
Dit betekent dat de tetromino is geland, wat betekent dat we het moeten toevoegen aan de landde []
matrix. We kunnen dit doen met een zeer vergelijkbare lus als degene die we gebruikten om te controleren op mogelijke botsingen:
voor (var row = 0; rij < 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];
Dit is het resultaat:
Tot zover goed. Maar je hebt misschien gemerkt dat we niet omgaan met het geval waarin de tetromino op de "grond" terechtkomt - we behandelen alleen tetromino's die bovenop andere tetromino's landen.
Er is een vrij eenvoudige oplossing hiervoor: wanneer we controleren op mogelijke botsingen, controleren we ook of de potentiële nieuwe positie van elk blok onder de onderkant van het speelveld ligt:
voor (var row = 0; rij < 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) // dit blok zou onder het speelveld liggen else if (landed [row + tetromino.potentialTopLeft.row]! = 0 && landed [col + tetromino.potentialTopLeft.col]! = 0) / / de spatie is genomen
Natuurlijk, als een blok in de tetromino zou eindigen onder de bodem van het speelveld als het verder zou vallen, maken we de tetromino "land", net alsof een blok een blok zou overlappen dat al was geland.
Nu kunnen we de volgende ronde starten met een nieuwe tetromino.
Laten we deze keer een J-tetromino spawnen:
tetromino.shape = [[0,1], [0,1], [1,1]]; tetromino.topLeft = row: 0, col: 4;
Render het:
Onthoud dat elke halve seconde de tetromino één rij zal vallen. Laten we aannemen dat de speler vier keer op de linkertoets slaat voordat een halve seconde voorbij is; we willen de tetromino elke keer met één kolom verlaten.
Hoe kunnen we ervoor zorgen dat de tetromino niet botst met een van de aangelande blokken? We kunnen eigenlijk dezelfde code van tevoren gebruiken!
Eerst veranderen we de potentiële nieuwe positie:
tetromino.potentialTopLeft = row: tetromino.topLeft, col: tetromino.topLeft - 1;
We controleren nu of een van de blokken in de tetromino overlappen met de gelande blokken, met dezelfde basiscontrole als eerder (zonder te controleren of een blok onder het speelveld is gegaan):
voor (var row = 0; rij < 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
Voer het uit met dezelfde controles die we gewoonlijk uitvoeren en je zult zien dat dit prima werkt. Het grote verschil is dat we ons dat moeten herinneren niet om de tetromino's blokken toe te voegen aan de landde []
array als er een potentiële botsing is - in plaats daarvan moeten we simpelweg de waarde van niet wijzigen tetromino.topLeft
.
Telkens wanneer de speler de tetromino verplaatst, moeten we alles opnieuw renderen. Dit is het uiteindelijke resultaat:
Wat gebeurt er als de speler nogmaals links raakt? Wanneer we dit noemen:
tetromino.potentialTopLeft = row: tetromino.topLeft, col: tetromino.topLeft - 1;
... zullen we uiteindelijk proberen in te stellen tetromino.potentialTopLeft.col
naar -1
- en dat zal later tot allerlei problemen leiden.
Laten we onze bestaande collisioncheck aanpassen om hiermee om te gaan:
voor (var row = 0; rij < 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
Eenvoudig - het is hetzelfde idee als wanneer we controleren of een van de blokken onder het speelveld zou vallen.
Laten we ook de rechterkant bespreken:
voor (var row = 0; rij < 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 >= landed [0] .length) // dit blok zou zich rechts van het speelveld bevinden als (geland [row + tetromino.potentialTopLeft.row]! = 0 && landed [col + tetromino.potentialTopLeft.col]! = 0) // de spatie is genomen
Nogmaals, als de tetromino buiten het speelveld zou komen, veranderen we gewoon niet tetromino.topLeft
- geen behoefte om iets anders te doen.
Oké, een halve seconde moet nu voorbij zijn, dus laten we die tetromino één rij laten vallen:
tetromino.shape = [[0,1], [0,1], [1,1]]; tetromino.topLeft = rij: 1, col: 0;
Stel nu dat de speler de knop raakt om de tetromino met de klok mee te laten draaien. Dit is eigenlijk best gemakkelijk om mee om te gaan - we veranderen gewoon tetromino.shape
, zonder te veranderen tetromino.topLeft
:
tetromino.shape = [[1,0,0], [1,1,1]]; tetromino.topLeft = rij: 1, col: 0;
Wij kon gebruik wat wiskunde om de inhoud van de reeks blokken te roteren ... maar het is veel eenvoudiger om de vier mogelijke rotaties van elke tetromino ergens op te slaan, zoals deze:
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]]];
(Ik zal je laten weten waar je dat het beste kunt opslaan in je code!)
Hoe dan ook, als we alles opnieuw hebben weergegeven, ziet het er als volgt uit:
We kunnen het opnieuw draaien (en laten we aannemen dat we beide rotaties binnen een halve seconde doen):
tetromino.shape = [[1,1], [1,0], [1,0]]; tetromino.topLeft = rij: 1, col: 0;
Render opnieuw:
Geweldig. Laten we het nog een paar rijen laten vallen, totdat we in deze toestand komen:
tetromino.shape = [[1,1], [1,0], [1,0]]; tetromino.topLeft = row: 10, col: 0;
Plots slaat de speler de Rotate Clockwise-knop opnieuw aan, zonder aanwijsbare reden. We kunnen aan de hand van de afbeelding zien dat dit niets zou toestaan, maar we hebben nog geen controles om dit te voorkomen.
Je kunt waarschijnlijk raden hoe we dit gaan oplossen. We introduceren een tetromino.potentialShape
, zet het in de gedaante van de geroteerde tetromino en zoek naar eventuele overlappingen met blokken die al zijn geland.
tetromino.shape = [[1,1], [1,0], [1,0]]; tetromino.topLeft = row: 10, col: 0; tetromino.potentialShape = [[1,1,1], [0,0,1]];
voor (var row = 0; rij < 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 >= geland [0] .lengte) // dit blok zou rechts van het speelveld staan als (row + tetromino.topLeft.row> = landed.length) // dit blok zou onder het speelveld liggen if (landed [row + tetromino.topLeft.row]! = 0 && landed [col + tetromino.topLeft.col]! = 0) // de spatie is genomen
Als er een overlapping is (of als de geroteerde vorm gedeeltelijk buiten de grenzen zou vallen), laten we het blok gewoonweg niet roteren. Zodoende kan het een halve seconde later op zijn plaats vallen en worden toegevoegd aan de landde []
array:
Uitstekend.
Voor de duidelijkheid, we hebben nu drie afzonderlijke controles.
De eerste controle is voor wanneer een tetromino valt, en wordt elke halve seconde genoemd:
// set tetromino.potentialTopLeft een rij te zijn onder tetromino.topLeft, then: for (var row = 0; row < 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) // dit blok zou onder het speelveld liggen else if (landed [row + tetromino.potentialTopLeft.row]! = 0 && landed [col + tetromino.potentialTopLeft.col]! = 0) / / de spatie is genomen
Als alle controles passeren, dan zetten we tetromino.topLeft
naar tetromino.potentialTopLeft
.
Als een van de controles mislukt, maken we de tetromino, zoals zo:
voor (var row = 0; rij < 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];
De tweede controle is voor wanneer de speler de tetromino naar links of rechts probeert te verplaatsen en wordt gebeld wanneer de speler de bewegingssleutel raakt:
// stel tetromino.potentialTopLeft in als één kolom rechts of links // van tetromino.topLeft, indien van toepassing, dan: voor (var row = 0; row < 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 >= landed [0] .length) // dit blok zou zich rechts van het speelveld bevinden als (geland [row + tetromino.potentialTopLeft.row]! = 0 && landed [col + tetromino.potentialTopLeft.col]! = 0) // de spatie is genomen
Als (en alleen als) al deze controles verlopen, stellen we in tetromino.topLeft
naar tetromino.potentialTopLeft
.
De derde controle is voor wanneer de speler probeert de tetromino met de klok mee of tegen de klok in te draaien en wordt gebeld wanneer de speler de sleutel raakt om dit te doen:
// stel tetromino.potential Shape in als de geroteerde versie van tetromino.shape // (met de klok mee of tegen de klok in, indien van toepassing), en dan: voor (var row = 0; row < 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 >= geland [0] .lengte) // dit blok zou rechts van het speelveld staan als (row + tetromino.topLeft.row> = landed.length) // dit blok zou onder het speelveld liggen if (landed [row + tetromino.topLeft.row]! = 0 && landed [col + tetromino.topLeft.col]! = 0) // de spatie is genomen
Als (en alleen als) al deze controles verlopen, stellen we in tetromino.shape
naar tetromino.potentialShape
.
Vergelijk deze drie controles - het is gemakkelijk om ze door elkaar te halen, omdat de code erg op elkaar lijkt.
Tot nu toe heb ik verschillende groottes van arrays gebruikt voor het representeren van de verschillende vormen van tetromino's (en de verschillende rotaties van die vormen): de O-tetromino gebruikte een 2x2 array en de J-tetromino gebruikte een 3x2 of een 2x3 array.
Voor consistentie, raad ik aan om dezelfde array te gebruiken voor alle tetromino's (en rotaties daarvan). Ervan uitgaande dat u vasthoudt aan de zeven standaardtetromino's, kunt u dit doen met een 4x4-array.
Er zijn verschillende manieren om de rotaties binnen dit 4x4 vierkant te rangschikken; Kijk eens naar de Tetris Wiki voor meer informatie over wat verschillende spellen gebruiken.
Stel dat je een verticale I-tetromino als dit voorstelt:
[[0,1,0,0], [0,1,0,0], [0,1,0,0], [0,1,0,0]];
... en je vertegenwoordigt zijn rotatie als volgt:
[[0,0,0,0], [0,0,0,0], [1,1,1,1], [0,0,0,0]];
Stel nu dat een verticale I-tetromino tegen een muur als deze wordt gedrukt:
Wat gebeurt er als de speler de Rotate-toets raakt??
Welnu, met onze huidige collision detection code gebeurt er niets - het meest linkse blok van de horizontale I-tetromino zou buiten de grenzen vallen.
Dit is aantoonbaar prima - zo werkte het in de NES-versie van Tetris - maar er is een alternatief: draai de tetromino en verplaats deze eenmaal rechts naar rechts, zoals zo:
Ik zal je de details laten uitzoeken, maar in wezen moet je controleren of het roteren van de tetromino het buiten de grenzen zou brengen en, indien dit zo is, het naar links of rechts verplaatsen als dat nodig is. Vergeet echter niet om te controleren op mogelijke botsingen met andere blokken na het toepassen van beide rotaties en de beweging!
Ik heb tijdens deze tutorial blokken van dezelfde kleur gebruikt om dingen eenvoudig te houden, maar het is gemakkelijk om de kleuren te wijzigen.
Kies voor elke kleur een getal om het weer te geven; gebruik die nummers in uw vorm[]
en landde []
arrays; verander vervolgens uw weergavecode in kleurblokken op basis van hun nummers.
Het resultaat ziet er ongeveer zo uit:
Het scheiden van de visuele weergave van een object in de game van zijn gegevens is een heel belangrijk begrip om te begrijpen; het komt steeds weer terug in andere spellen, vooral als het gaat om botsingsdetectie.
In mijn volgende bericht zullen we bekijken hoe we de andere kernfunctie van Tetris kunnen implementeren: lijnen verwijderen wanneer ze zijn gevuld. Bedankt voor het lezen!