Sono sicuro che è possibile creare un gioco Tetris con uno strumento gamedev point-and-click, ma non riuscirò mai a capire come. Oggi, mi sento più a mio agio a pensare a un livello più alto di astrazione, in cui il tetromino che vedi sullo schermo è solo un rappresentazione di quello che sta succedendo nel gioco sottostante. In questo tutorial ti mostrerò cosa intendo, dimostrando come gestire il rilevamento delle collisioni in Tetris.
Nota: Sebbene il codice in questo tutorial sia stato scritto usando AS3, dovresti essere in grado di utilizzare le stesse tecniche e concetti in quasi tutti gli ambienti di sviluppo di giochi.
Un campo di gioco standard di Tetris ha 16 righe e 10 colonne. Possiamo rappresentarlo in un array multidimensionale, contenente 16 sotto-array di 10 elementi:
Immagina che l'immagine a sinistra sia uno screenshot del gioco - è come il gioco potrebbe sembrare al giocatore, dopo che un tetromino è atterrato, ma prima che un altro sia stato generato.
Sulla destra c'è una rappresentazione in serie dello stato attuale del gioco. Chiamiamolo atterrato[]
, come si riferisce a tutti i blocchi che sono atterrati. Un elemento di 0
significa che nessun blocco occupa quello spazio; 1
significa che un blocco è atterrato in quello spazio.
Ora creiamo un O-tetromino al centro nella parte superiore del campo:
tetromino.shape = [[1,1], [1,1]]; tetromino.topLeft = row: 0, col: 4;
Il forma
la proprietà è un'altra rappresentazione multidimensionale della forma di questo tetromino. in alto a sinistra
indica la posizione del blocco in alto a sinistra del tetromino: nella riga superiore e la quinta colonna in.
Rendiamo tutto. Per prima cosa, disegniamo lo sfondo: è facile, è solo un'immagine della griglia statica.
Successivamente, disegniamo ogni blocco dal atterrato[]
array:
per (var row = 0; row < 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
Le mie immagini di blocco sono 20x20 px, quindi per disegnare i blocchi posso semplicemente inserire una nuova immagine di blocco su (col * 20, riga * 20)
. I dettagli non contano davvero.
Successivamente, disegniamo ogni blocco nell'attuale tetromino:
per (var row = 0; row < 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
Possiamo usare lo stesso codice di disegno qui, ma abbiamo bisogno di compensare i blocchi di in alto a sinistra
.
Ecco il risultato:
Si noti che il nuovo O-tetromino non appare nel atterrato[]
array - è perché, beh, non è ancora atterrato.
Supponiamo che il giocatore non tocchi i comandi. A intervalli regolari - diciamo ogni mezzo secondo - l'O-tetromino deve cadere una riga.
È allettante chiamare semplicemente:
tetromino.topLeft.row ++;
... e poi renderà di nuovo tutto, ma questo non rileverà alcuna sovrapposizione tra l'O-tetromino e i blocchi che sono già atterrati.
Invece, controlleremo prima potenziali collisioni e poi sposteremo il tetromino solo se è "sicuro".
Per questo, dovremo definire a potenziale nuova posizione per il tetromino:
tetromino.potentialTopLeft = row: 1, col: 4;
Ora controlliamo le collisioni. Il modo più semplice per farlo è quello di scorrere tutti gli spazi nella griglia che il tetromino avrebbe preso nella sua potenziale nuova posizione, e controllare atterrato[]
array per vedere se sono già stati presi:
per (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 (landed[row + tetromino.potentialTopLeft.row] != 0 && landed[col + tetromino.potentialTopLeft.col] != 0) //the space is taken
Proviamo questo:
tetromino.shape = [[1,1], [1,1]]; tetromino.potentialTopLeft: row: 1, col: 4 ------------------------------------- ------- row: 0, col: 0, tetromino.shape [0] [0]: 1, atterrato [0 + 1] [0 + 4]: 0 row: 0, col: 1, tetromino. shape [0] [1]: 1, atterrato [0 + 1] [1 + 4]: 0 row: 1, col: 0, tetromino.shape [1] [0]: 1, atterrato [1 + 1] [ 0 + 4]: 0 row: 1, col: 1, tetromino.shape [1] [1]: 1, atterrato [1 + 1] [1 + 4]: 0
Tutti gli zeri! Questo significa che non c'è collisione, quindi il tetromino può muoversi.
Prepariamo:
tetromino.topLeft = tetromino.potentialTopLeft;
... e poi renderlo di nuovo tutto:
grande!
Ora, supponiamo che il giocatore faccia cadere il tetromino fino a questo punto:
L'alto a sinistra è a row: 11, col: 4
. Possiamo vedere che il tetromino si scontrerebbe con i blocchi sbarcati se cadesse ancora - ma il nostro codice lo capisce? Vediamo:
tetromino.shape = [[1,1], [1,1]]; tetromino.potentialTopLeft: row: 12, col: 4 ------------------------------------- ------- row: 0, col: 0, tetromino.shape [0] [0]: 1, atterrato [0 + 12] [0 + 4]: 0 row: 0, col: 1, tetromino. shape [0] [1]: 1, atterrato [0 + 12] [1 + 4]: 0 row: 1, col: 0, tetromino.shape [1] [0]: 1, atterrato [1 + 12] [ 0 + 4]: 1 riga: 1, colonna: 1, tetromino.shape [1] [1]: 1, atterrato [1 + 12] [1 + 4]: 0
C'è un 1
, il che significa che c'è una collisione - in particolare, il tetromino si scontrerebbe con il blocco a sbarcati [13] [4]
.
Ciò significa che il tetromino è atterrato, il che significa che dobbiamo aggiungerlo al atterrato[]
array. Possiamo farlo con un ciclo molto simile a quello usato per verificare potenziali collisioni:
per (var row = 0; row < 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];
Ecco il risultato:
Fin qui tutto bene. Ma potresti aver notato che non abbiamo a che fare con il caso in cui il tetromino atterra sul "terreno" - abbiamo a che fare solo con i tetramini che atterrano su altri tetrominoes.
C'è una soluzione piuttosto semplice per questo: quando controlliamo potenziali collisioni, controlliamo anche se la potenziale nuova posizione di ogni blocco si trovi sotto il fondo del campo di gioco:
per (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) // questo blocco si trova sotto il campo di gioco else if (atterrato [row + tetromino.potentialTopLeft.row]! = 0 && landed [col + tetromino.potentialTopLeft.col]! = 0) / / lo spazio è occupato
Ovviamente, se qualsiasi blocco nel tetromino finisse sotto il fondo del campo di gioco se dovesse cadere ulteriormente, facciamo il "terreno" del tetromino, proprio come se qualsiasi blocco si sovrapponesse a un blocco che era già atterrato.
Ora possiamo iniziare il prossimo round, con un nuovo tetromino.
Questa volta, generiamo un J-tetromino:
tetromino.shape = [[0,1], [0,1], [1,1]]; tetromino.topLeft = row: 0, col: 4;
Renderlo:
Ricorda, ogni mezzo secondo, il tetromino cadrà di una riga. Supponiamo che il giocatore prenda il tasto sinistro quattro volte prima che passi mezzo secondo; vogliamo spostare il tetromino lasciato da una colonna ogni volta.
Come possiamo essere sicuri che il tetromino non si scontrerà con nessuno dei blocchi sbarcati? Possiamo effettivamente utilizzare lo stesso codice di prima!
Innanzitutto, alteriamo la potenziale nuova posizione:
tetromino.potentialTopLeft = row: tetromino.topLeft, col: tetromino.topLeft - 1;
Ora controlliamo se uno qualsiasi dei blocchi nel tetromino si sovrappone ai blocchi sbarcati, usando lo stesso controllo base di prima (senza preoccuparsi di controllare se un blocco è andato sotto il campo di gioco):
per (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 (landed[row + tetromino.potentialTopLeft.row] != 0 && landed[col + tetromino.potentialTopLeft.col] != 0) //the space is taken
Corri attraverso gli stessi controlli che usiamo di solito e vedrai che funziona bene. La grande differenza è, dobbiamo ricordare non aggiungere i blocchi del tetromino al atterrato[]
array se c'è una potenziale collisione - invece, dovremmo semplicemente non cambiare il valore di tetromino.topLeft
.
Ogni volta che il giocatore muove il tetromino, dovremmo ri-rendere tutto. Ecco il risultato finale:
Cosa succede se il giocatore colpisce ancora una volta? Quando lo chiamiamo:
tetromino.potentialTopLeft = row: tetromino.topLeft, col: tetromino.topLeft - 1;
... finiremo per provare a impostare tetromino.potentialTopLeft.col
a -1
- e questo porterà a problemi di ogni genere in seguito.
Modifichiamo il nostro controllo collisione esistente per affrontare questo:
per (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 (landed[row + tetromino.potentialTopLeft.row] != 0 && landed[col + tetromino.potentialTopLeft.col] != 0) //the space is taken
Semplice: è la stessa idea di quando controlliamo se uno qualsiasi dei blocchi cadrà al di sotto del campo di gioco.
Affrontiamo anche il lato destro:
per (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) // questo blocco si trova a destra del campo di gioco if (atterrato [row + tetromino.potentialTopLeft.row]! = 0 && landed [col + tetromino.potentialTopLeft.col]! = 0) // lo spazio è occupato
Di nuovo, se il tetromino si muoverà fuori dal campo di gioco, non cambieremo tetromino.topLeft
- non c'è bisogno di fare altro.
Va bene, deve essere passato mezzo secondo, quindi lascia che il tetromino cada una riga:
tetromino.shape = [[0,1], [0,1], [1,1]]; tetromino.topLeft = row: 1, col: 0;
Ora, supponiamo che il giocatore preme il pulsante per far ruotare il tetromino in senso orario. Questo è in realtà abbastanza facile da gestire - semplicemente alteriamo tetromino.shape
, senza alterare tetromino.topLeft
:
tetromino.shape = [[1,0,0], [1,1,1]]; tetromino.topLeft = row: 1, col: 0;
Noi poteva usa alcuni math per ruotare il contenuto dell'array di blocchi ... ma è molto più semplice solo memorizzare le quattro possibili rotazioni di ciascun tetromino da qualche parte, in questo modo:
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]]];
(Ti consentirò di capire dove è meglio memorizzarlo nel tuo codice!)
Ad ogni modo, una volta che renderizziamo di nuovo tutto, sembrerà questo:
Possiamo ruotarlo di nuovo (e supponiamo di eseguire entrambe queste rotazioni in mezzo secondo):
tetromino.shape = [[1,1], [1,0], [1,0]]; tetromino.topLeft = row: 1, col: 0;
Render di nuovo:
Meraviglioso. Lasciamo cadere qualche riga in più, finché non arriviamo a questo stato:
tetromino.shape = [[1,1], [1,0], [1,0]]; tetromino.topLeft = row: 10, col: 0;
Improvvisamente, il giocatore tocca di nuovo il pulsante Ruota in senso orario, senza alcun motivo apparente. Possiamo vedere dal guardare l'immagine che questo non dovrebbe permettere che qualcosa accada, ma non abbiamo ancora alcun controllo in atto per impedirlo.
Probabilmente puoi indovinare come risolveremo questo problema. Presenteremo un tetromino.potentialShape
, impostalo sulla forma del tetromino ruotato e cerca eventuali sovrapposizioni potenziali con blocchi già atterrati.
tetromino.shape = [[1,1], [1,0], [1,0]]; tetromino.topLeft = row: 10, col: 0; tetromino.potentialShape = [[1,1,1], [0,0,1]];
per (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 >= landed [0] .length) // questo blocco si trova a destra del campo di gioco if (row + tetromino.topLeft.row> = landed.length) // questo blocco si trova sotto il campo di gioco if (atterrato [row + tetromino.topLeft.row]! = 0 && landed [col + tetromino.topLeft.col]! = 0) // lo spazio è occupato
Se c'è una sovrapposizione (o se la forma ruotata sarebbe parzialmente fuori limite), semplicemente non permettiamo al blocco di ruotare. Quindi, può cadere in posto mezzo secondo dopo e essere aggiunto al atterrato[]
array:
Eccellente.
Per essere chiari, ora abbiamo tre controlli separati.
Il primo controllo riguarda quando un tetromino cade e viene chiamato ogni mezzo secondo:
// imposta tetromino.potentialTopLeft su una riga sotto tetromino.topLeft, quindi: 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) // questo blocco si trova sotto il campo di gioco else if (atterrato [row + tetromino.potentialTopLeft.row]! = 0 && landed [col + tetromino.potentialTopLeft.col]! = 0) / / lo spazio è occupato
Se tutti i controlli passano, allora abbiamo impostato tetromino.topLeft
a tetromino.potentialTopLeft
.
Se uno qualsiasi dei controlli fallisce, allora facciamo il terreno del tetromino, in questo modo:
per (var row = 0; row < 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];
Il secondo controllo è per quando il giocatore cerca di spostare il tetromino a sinistra o a destra, e viene chiamato quando il giocatore preme il tasto di movimento:
// imposta tetromino.potentialTopLeft come una colonna a destra o a sinistra // di tetromino.topLeft, come appropriato, quindi: 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 (col + tetromino.potentialTopLeft.col < 0) //this block would be to the left of the playing field if (col + tetromino.potentialTopLeft.col >= landed [0] .length) // questo blocco si trova a destra del campo di gioco if (atterrato [row + tetromino.potentialTopLeft.row]! = 0 && landed [col + tetromino.potentialTopLeft.col]! = 0) // lo spazio è occupato
Se (e solo se) passano tutti questi controlli, abbiamo impostato tetromino.topLeft
a tetromino.potentialTopLeft
.
Il terzo controllo è per quando il giocatore tenta di ruotare il tetromino in senso orario o antiorario, e viene chiamato quando il giocatore preme il tasto per farlo:
// imposta tetromino.potentialShape come versione ruotata di tetromino.shape // (in senso orario o antiorario come appropriato), quindi: for (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 >= landed [0] .length) // questo blocco si trova a destra del campo di gioco if (row + tetromino.topLeft.row> = landed.length) // questo blocco si trova sotto il campo di gioco if (atterrato [row + tetromino.topLeft.row]! = 0 && landed [col + tetromino.topLeft.col]! = 0) // lo spazio è occupato
Se (e solo se) passano tutti questi controlli, abbiamo impostato tetromino.shape
a tetromino.potentialShape
.
Confronta questi tre assegni: è facile confonderli, perché il codice è molto simile.
Finora, ho usato diverse dimensioni di matrici per rappresentare le diverse forme di tetromino (e le diverse rotazioni di quelle forme): l'O-tetromino usava un array 2x2, e il J-tetromino usava un array 3x2 o 2x3.
Per coerenza, consiglio di utilizzare la stessa dimensione di matrice per tutti i tetramini (e le loro rotazioni). Supponendo che tu stia attaccando con i sette tetromino standard, puoi farlo con un array 4x4.
Ci sono diversi modi in cui puoi organizzare le rotazioni all'interno di questo quadrato 4x4; dai un'occhiata al Wiki di Tetris per maggiori informazioni su cosa usano i diversi giochi.
Supponiamo che tu rappresenti un I-tetromino verticale come questo:
[[0,1,0,0], [0,1,0,0], [0,1,0,0], [0,1,0,0]];
... e tu rappresenti la sua rotazione in questo modo:
[[0,0,0,0], [0,0,0,0], [1,1,1,1], [0,0,0,0]];
Supponiamo ora che un I-tetromino verticale venga schiacciato contro un muro come questo:
Cosa succede se il giocatore colpisce il tasto Ruota?
Bene, usando il nostro attuale codice di rilevamento delle collisioni, non succede niente - il blocco più a sinistra del tetromino orizzontale sarebbe fuori dai confini.
Questo è indubbiamente buono - è così che ha funzionato nella versione NES di Tetris - ma c'è un'alternativa: ruotare il tetromino e spostarlo una volta spazio a destra, in questo modo:
Ti lascio capire i dettagli, ma in sostanza devi controllare se ruotare il tetromino lo sposterebbe fuori limite e, in tal caso, muoverlo a sinistra oa destra di uno o due spazi, se necessario. Tuttavia, è necessario ricordare di verificare potenziali collisioni con altri blocchi dopo aver applicato entrambe le rotazioni e il movimento!
Ho usato blocchi dello stesso colore in questo tutorial per mantenere le cose semplici, ma è facile cambiare i colori.
Per ogni colore, scegli un numero per rappresentarlo; usa quei numeri nel tuo forma[]
e atterrato[]
array; quindi modifica il tuo codice di rendering per colorare i blocchi in base ai loro numeri.
Il risultato potrebbe essere simile a questo:
Separare la rappresentazione visiva di un oggetto di gioco dai suoi dati è un concetto molto importante da comprendere; si ripresenta ancora e ancora in altri giochi, in particolare quando si ha a che fare con il rilevamento delle collisioni.
Nel mio prossimo post, vedremo come implementare l'altra funzionalità di base di Tetris: rimuovere le righe quando sono piene. Grazie per aver letto!