L'approccio base basato sulla tessera esagonale spiegato nell'esercitazione del dragamine esagonale fa sì che il lavoro sia svolto, ma non è molto efficiente. Utilizza la conversione diretta dai dati di livello bidimensionale basati su array e le coordinate dello schermo, il che rende inutilmente complicato determinare le tessere tappate.
Inoltre, la necessità di utilizzare una logica diversa a seconda della riga o colonna dispari o pari di una tessera non è conveniente. Questa serie di tutorial esplora i sistemi di coordinate dello schermo alternativi che potrebbero essere utilizzati per facilitare la logica e rendere le cose più convenienti. Suggerisco caldamente di leggere il tutorial esagonale del dragamine prima di andare avanti con questo tutorial in quanto quello spiega il rendering della griglia basato su un array bidimensionale.
L'approccio di default usato per le coordinate dello schermo nell'esercitazione del campo minato esagonale è chiamato l'approccio delle coordinate di offset. Questo perché le righe o le colonne alternative sono sfalsate di un valore allineando la griglia esagonale.
Per rinfrescare la memoria, fare riferimento all'immagine sottostante, che mostra l'allineamento orizzontale con i valori delle coordinate di offset visualizzati.
Nell'immagine sopra, una riga con lo stesso io
il valore è evidenziato in rosso e una colonna con lo stesso j
il valore è evidenziato in verde. Per rendere tutto semplice, non discuteremo le varianti di offset dispari e pari in quanto entrambi sono solo modi diversi per ottenere lo stesso risultato.
Permettetemi di introdurre un'alternativa di coordinate dello schermo migliore, la coordinata assiale. La conversione di una coordinata di offset in una variante assiale è molto semplice. Il io
il valore rimane lo stesso, ma il j
il valore viene convertito usando la formula axialJ = i - floor (j / 2)
. Un semplice metodo può essere utilizzato per convertire un offset Phaser.Point
alla sua variante assiale, come mostrato di seguito.
function offsetToAxial (offsetPoint) offsetPoint.y = (offsetPoint.y- (Math.floor (offsetPoint.x / 2))); return offsetPoint;
La conversione inversa sarebbe come mostrato di seguito.
function axialToOffset (axialPoint) axialPoint.y = (axialPoint.y + (Math.floor (axialPoint.x / 2))); return axialPoint;
Qui il X
il valore è il io
valore, e y
il valore è il j
valore per l'array bidimensionale. Dopo la conversione, i nuovi valori apparirebbero come nell'immagine qui sotto.
Si noti che la linea verde in cui il j
il valore rimane lo stesso non zigzag più, ma piuttosto è ora una diagonale alla nostra griglia esagonale.
Per la griglia esagonale allineata verticalmente, le coordinate di offset sono visualizzate nell'immagine sottostante.
La conversione in coordinate assiali segue le stesse equazioni, con la differenza che manteniamo il j
valore uguale e modificare il io
valore. Il metodo seguente mostra la conversione.
function offsetToAxial (offsetPoint) offsetPoint.x = (offsetPoint.x- (Math.floor (offsetPoint.y / 2))); return offsetPoint;
Il risultato è come mostrato di seguito.
Prima di utilizzare le nuove coordinate per risolvere i problemi, permettimi di presentarti rapidamente un'altra alternativa di coordinate dello schermo: coordinate del cubo.
Raddrizzare lo stesso zig-zag ha potenzialmente risolto la maggior parte degli inconvenienti riscontrati con il sistema di coordinate offset. Le coordinate cubiche o cubiche ci aiuteranno ulteriormente a semplificare la logica complicata come l'euristica o la rotazione attorno a una cella esagonale.
Come avrai intuito dal nome, il sistema cubico ha tre valori. Il terzo K
o z
il valore è derivato dall'equazione x + y + z = 0
, dove X
e y
sono le coordinate assiali. Questo ci porta a questo semplice metodo per calcolare il z
valore.
function calculateCubicZ (axialPoint) return -axialPoint.x-axialPoint.y;
L'equazione x + y + z = 0
è in realtà un piano 3D che passa attraverso la diagonale di una griglia cubica tridimensionale. La visualizzazione di tutti e tre i valori per la griglia produrrà le seguenti immagini per i diversi allineamenti esagonali.
La linea blu indica le tessere dove il z
il valore rimane lo stesso.
Ci si potrebbe chiedere come questi nuovi sistemi di coordinate ci aiutino con la logica esagonale. Spiegherò alcuni benefici prima di passare a creare un Tetris esagonale usando la nostra nuova conoscenza.
Consideriamo la tessera centrale nell'immagine sopra, che ha valori di coordinate cubiche di 3,6, -9
. Abbiamo notato che un valore di coordinata rimane lo stesso per le tessere sulle linee colorate. Inoltre, possiamo vedere che le coordinate rimanenti aumentano o diminuiscono di 1 mentre tracciano una qualsiasi delle linee colorate. Ad esempio, se il X
il valore rimane lo stesso e il y
valore aumenta di 1 lungo una direzione, il z
il valore diminuisce di 1 per soddisfare la nostra equazione di riferimento x + y + z = 0
. Questa caratteristica rende il controllo del movimento molto più semplice. Lo metteremo in uso nella seconda parte della serie.
Con la stessa logica, è facile trovare i vicini per le tessere x, y, z
. Tenendo X
lo stesso, otteniamo due vicini diagonali, x, y-1, z + 1
e x, y + 1, z-1
. Tenendo lo stesso, otteniamo due vicini verticali, x-1, y, z + 1
e x + 1, y, z-1
. Tenendo z lo stesso, otteniamo i restanti due vicini diagonali, x + 1, y-1, z
e x-1, y + 1, z
. L'immagine sotto illustra questo per una piastrella all'origine.
È molto più facile ora che non abbiamo bisogno di usare una logica diversa basata su righe / colonne pari o dispari.
Una cosa interessante da notare nell'immagine qui sopra è una specie di simmetria ciclica per tutte le tessere intorno alla tessera rossa. Se prendiamo le coordinate di una tessera attigua, le coordinate della tessera vicina immediata possono essere ottenute spostando i valori delle coordinate a sinistra o a destra e quindi moltiplicando per -1.
Ad esempio, il vicino superiore ha un valore di -1,0,1
, che a rotazione a destra diventa una volta 1, -1,0
e dopo aver moltiplicato per -1 diventa -1,1,0
, che è la coordinata del vicino giusto. Ruotando a sinistra e moltiplicando per -1 i rendimenti 0, -1,1
, che è la coordinata del vicino di sinistra. Ripetendo, possiamo saltare tra tutte le tessere vicine attorno al riquadro centrale. Questa è una caratteristica molto interessante che potrebbe aiutare nella logica e negli algoritmi.
Nota che questo sta accadendo solo perché il riquadro centrale è considerato come all'origine. Potremmo facilmente fare qualsiasi piastrella x, y, z
essere all'origine sottraendo i valori X
, y
e z
da esso e da tutte le altre tessere.
Il calcolo dell'euristica efficiente è fondamentale quando si tratta di pathfinding o algoritmi simili. Le coordinate cubiche semplificano la ricerca di euristiche semplici per le griglie esagonali a causa degli aspetti menzionati sopra. Ne parleremo in dettaglio nella seconda parte di questa serie.
Questi sono alcuni dei vantaggi del nuovo sistema di coordinate. Potremmo utilizzare un mix dei diversi sistemi di coordinate nelle nostre implementazioni pratiche. Ad esempio, l'array bidimensionale è ancora il modo migliore per salvare i dati di livello, le cui coordinate sono le coordinate di offset.
Proviamo a creare una versione esagonale del famoso gioco Tetris usando questa nuova conoscenza.
Abbiamo tutti giocato a Tetris e, se sei uno sviluppatore di giochi, potresti aver creato anche la tua versione. Tetris è uno dei giochi a tessere più facili che si possano implementare, a parte tic tac toe o dama, usando un semplice array bidimensionale. Prima elenchiamo le caratteristiche di Tetris.
Dato che il gioco ha blocchi droppati verticalmente, useremo una griglia esagonale allineata verticalmente. Ciò significa che spostarli lateralmente li farà muovere a zigzag. Una riga completa nella griglia è composta da un insieme di tessere in ordine zigzag. Da questo punto in poi, potresti iniziare a fare riferimento al codice sorgente fornito insieme a questo tutorial.
I dati di livello sono memorizzati in una matrice bidimensionale denominata levelData
, e il rendering viene eseguito utilizzando le coordinate di offset, come spiegato nell'esercitazione del dragamine esagonale. Si prega di fare riferimento ad esso se hai difficoltà a seguire il codice.
L'elemento interattivo nella prossima sezione mostra i diversi blocchi che useremo. C'è un altro blocco aggiuntivo, che consiste di tre tessere riempite allineate verticalmente come un pilastro. BlockData
è usato per creare i diversi blocchi.
funzione BlockData (topB, topRightB, bottomRightB, bottomB, bottomLeftB, topLeftB) this.tBlock = topB; this.trBlock = topRightB; this.brBlock = bottomRightB; this.bBlock = bottomB; this.blBlock = bottomLeftB; this.tlBlock = topLeftB; this.mBlock = 1;
Un modello di blocco vuoto è un insieme di sette tessere composto da una tessera centrale circondata dai suoi sei vicini. Per ogni blocco di Tetris, la tessera intermedia è sempre piena denotata da un valore di 1
, mentre una tessera vuota sarebbe denotata da un valore di 0
. I diversi blocchi vengono creati popolando le tessere di BlockData
come sotto.
var block1 = new BlockData (1,1,0,0,0,1); var block2 = new BlockData (0,1,0,0,0,1,1); var block3 = new BlockData (1,1,0,0,0,0); var block4 = new BlockData (1,1,0,1,0,0); var block5 = new BlockData (1,0,0,1,0,1); var block6 = new BlockData (0,1,1,0,1,1); var block7 = new BlockData (1,0,0,1,0,0);
Abbiamo un totale di sette blocchi diversi.
Lascia che ti mostri come ruotano i blocchi usando l'elemento interattivo sotto. Tocca e tieni premuto per ruotare i blocchi e tocca X
per cambiare il senso di rotazione.
Per ruotare il blocco, dobbiamo trovare tutte le tessere che hanno un valore di 1
, imposta il valore a 0
, ruotare una volta attorno alla tessera centrale per trovare la tessera attigua e impostarne il valore 1
. Per ruotare una tessera attorno ad un'altra tessera, possiamo usare la logica spiegata nel muoversi intorno a una tessera sezione sopra. Arriviamo al metodo sottostante per questo scopo.
function rotateTileAroundTile (tileToRotate, anchorTile) tileToRotate = offsetToAxial (tileToRotate); // converti in axial var tileToRotateZ = calculateCubicZ (tileToRotate); // trova valore z anchorTile = offsetToAxial (anchorTile); // converti in axial var anchorTileZ = calculateCubicZ ( anchorTile); // trova il valore z tileToRotate.x = tileToRotate.x-anchorTile.x; // trova la differenza x tileToRotate.y = tileToRotate.y-anchorTile.y; // trova la differenza tileToRotateZ = tileToRotateZ-anchorTileZ; // find z difference var pointArr = [tileToRotate.x, tileToRotate.y, tileToRotateZ]; // popola array per ruotare pointArr = arrayRotate (pointArr, clockWise); // ruota array, true per clockwise tileToRotate.x = (- 1 * pointArr [0]) + anchorTile.x; // moltiplicare per -1 e rimuovere la differenza x tileToRotate.y = (- 1 * pointArr [1]) + anchorTile.y; // moltiplicare per -1 e rimuovere la differenza di y tileToRotate = axialToOffset (tileToRotate); // converti in offset return tileToRotate; // ... function arrayRotate (arr, reverse) // nifty metodo per ruotare gli elementi dell'array if (reverse) arr.unshift (arr.pop ()) else arr.push (arr.shift ()) return arr
La variabile senso orario
viene utilizzato per ruotare in senso orario o antiorario, cosa che si ottiene spostando i valori dell'array in direzioni opposte in arrayRotate
.
Teniamo traccia del io
e j
compensare le coordinate per il riquadro centrale del blocco usando le variabili blockMidRowValue
e blockMidColumnValue
rispettivamente. Per spostare il blocco, incrementiamo o diminuiamo questi valori. Aggiorniamo i valori corrispondenti in levelData
con i valori del blocco usando il paintBlock
metodo. L'aggiornato levelData
è usato per rendere la scena dopo ogni cambio di stato.
var blockMidRowValue; var blockMidColumnValue; // ... function moveLeft () blockMidColumnValue--; function moveRight () blockMidColumnValue ++; function dropDown () paintBlock (true); blockMidRowValue ++; function paintBlock () clockWise = true; var val = 1; changeLevelData (blockMidRowValue, blockMidColumnValue, val); var rotatingTile = new Phaser.Point (blockMidRowValue-1, blockMidColumnValue); if (currentBlock.tBlock == 1) changeLevelData (rotatingTile.x, rotatingTile.y, val * currentBlock.tBlock); var midPoint = new Phaser.Point (blockMidRowValue, blockMidColumnValue); rotatingTile = rotateTileAroundTile (rotatingTile, punto medio); if (currentBlock.trBlock == 1) changeLevelData (rotatingTile.x, rotatingTile.y, val * currentBlock.trBlock); midPoint.x = blockMidRowValue; midPoint.y = blockMidColumnValue; rotatingTile = rotateTileAroundTile (rotatingTile, punto medio); if (currentBlock.brBlock == 1) changeLevelData (rotatingTile.x, rotatingTile.y, val * currentBlock.brBlock); midPoint.x = blockMidRowValue; midPoint.y = blockMidColumnValue; rotatingTile = rotateTileAroundTile (rotatingTile, punto medio); if (currentBlock.bBlock == 1) changeLevelData (rotatingTile.x, rotatingTile.y, val * currentBlock.bBlock); midPoint.x = blockMidRowValue; midPoint.y = blockMidColumnValue; rotatingTile = rotateTileAroundTile (rotatingTile, punto medio); if (currentBlock.blBlock == 1) changeLevelData (rotatingTile.x, rotatingTile.y, val * currentBlock.blBlock); midPoint.x = blockMidRowValue; midPoint.y = blockMidColumnValue; rotatingTile = rotateTileAroundTile (rotatingTile, punto medio); if (currentBlock.tlBlock == 1) changeLevelData (rotatingTile.x, rotatingTile.y, val * currentBlock.tlBlock); function changeLevelData (iVal, jVal, newValue, cancella) if (! validIndexes (iVal, jVal)) return; if (cancella) if (levelData [iVal] [jVal] == 1) levelData [iVal] [jVal] = 0; else levelData [iVal] [jVal] = newValue; function validIndexes (iVal, jVal) if (iVal<0 || jVal<0 || iVal>= levelData.length || jVal> = levelData [0] .length) return false; return true;
Qui, currentBlock
punta al blockData
nella scena Nel paintBlock
, per prima cosa impostiamo il levelData
valore per la tessera intermedia del blocco a 1
come sempre 1
per tutti i blocchi. L'indice del punto medio è blockMidRowValue
, blockMidColumnValue
.
Quindi passiamo al levelData
indice della tessera in cima alla tessera centrale blockMidRowValue-1
, blockMidColumnValue
, e impostarlo 1
se il blocco ha questa tessera come 1
. Quindi ruotiamo in senso orario una volta attorno alla tessera centrale per ottenere la tessera successiva e ripetere la stessa procedura. Questo è fatto per tutte le tessere intorno alla tessera media per il blocco.
Durante lo spostamento o la rotazione del blocco, è necessario verificare se si tratta di un'operazione valida. Ad esempio, non possiamo spostare o ruotare il blocco se le tessere che deve occupare sono già occupate. Inoltre, non possiamo spostare il blocco fuori dalla nostra griglia bidimensionale. Dobbiamo anche controllare se il blocco può cadere ulteriormente, il che determinerebbe se abbiamo bisogno di cementare il blocco o meno.
Per tutti questi, uso un metodo canMove (i, j)
, che restituisce un valore booleano che indica se si posiziona il blocco a i, j
è una mossa valida. Per ogni operazione, prima di cambiare effettivamente il levelData
valori, controlliamo se la nuova posizione per il blocco è una posizione valida utilizzando questo metodo.
function canMove (iVal, jVal) var validMove = true; var store = clockWise; var newBlockMidPoint = new Phaser.Point (blockMidRowValue + iVal, blockMidColumnValue + jVal); In senso orario = true; if (! validAndEmpty (newBlockMidPoint.x, newBlockMidPoint.y)) // verifica mid, sempre 1 validMove = false; var rotatingTile = new Phaser.Point (newBlockMidPoint.x-1, newBlockMidPoint.y); if (currentBlock.tBlock == 1) if (! validAndEmpty (rotatingTile.x, rotatingTile.y)) // controlla top validMove = false; newBlockMidPoint.x = blockMidRowValue + iVal; newBlockMidPoint.y = blockMidColumnValue + jVal; rotatingTile = rotateTileAroundTile (rotatingTile, newBlockMidPoint); if (currentBlock.trBlock == 1) if (! validAndEmpty (rotatingTile.x, rotatingTile.y)) validMove = false; newBlockMidPoint.x = blockMidRowValue + iVal; newBlockMidPoint.y = blockMidColumnValue + jVal; rotatingTile = rotateTileAroundTile (rotatingTile, newBlockMidPoint); if (currentBlock.brBlock == 1) if (! validAndEmpty (rotatingTile.x, rotatingTile.y)) validMove = false; newBlockMidPoint.x = blockMidRowValue + iVal; newBlockMidPoint.y = blockMidColumnValue + jVal; rotatingTile = rotateTileAroundTile (rotatingTile, newBlockMidPoint); if (currentBlock.bBlock == 1) if (! validAndEmpty (rotatingTile.x, rotatingTile.y)) validMove = false; newBlockMidPoint.x = blockMidRowValue + iVal; newBlockMidPoint.y = blockMidColumnValue + jVal; rotatingTile = rotateTileAroundTile (rotatingTile, newBlockMidPoint); if (currentBlock.blBlock == 1) if (! validAndEmpty (rotatingTile.x, rotatingTile.y)) validMove = false; newBlockMidPoint.x = blockMidRowValue + iVal; newBlockMidPoint.y = blockMidColumnValue + jVal; rotatingTile = rotateTileAroundTile (rotatingTile, newBlockMidPoint); if (currentBlock.tlBlock == 1) if (! validAndEmpty (rotatingTile.x, rotatingTile.y)) validMove = false; clockWise = store; return validMove; function validAndEmpty (iVal, jVal) if (! validIndexes (iVal, jVal)) return false; else if (levelData [iVal] [jVal]> 1) // occuppied return false; return true;
Il processo qui è lo stesso di paintBlock
, ma invece di alterare qualsiasi valore, questo restituisce solo un valore booleano che indica una mossa valida. Anche se sto usando il rotazione attorno a una tessera centrale la logica per trovare i vicini, l'alternativa più semplice e abbastanza efficiente è quella di usare i valori di coordinate dirette dei vicini, che possono essere facilmente determinati dalle coordinate delle caselle centrali.
Il livello di gioco è visivamente rappresentato da a RenderTexture
di nome gameScene
. Nell'array levelData
, una tessera non occupata avrebbe un valore di 0
, e una tessera occupata avrebbe un valore di 2
o più alto.
Un blocco cementato è denotato da un valore di 2
, e un valore di 5
indica una tessera che deve essere rimossa poiché fa parte di una riga completata. Un valore di 1
significa che la tessera fa parte del blocco. Dopo ogni cambio di stato del gioco, rendiamo il livello usando le informazioni in levelData
, come mostrato di seguito.
// ... hexSprite.tint = '0xffffff'; if (levelData [i] [j]> - 1) axialPoint = offsetToAxial (axialPoint); cubicZ = calculateCubicZ (axialPoint); if (levelData [i] [j] == 1) hexSprite.tint = '0xff0000'; else if (levelData [i] [j] == 2) hexSprite.tint = '0x0000ff'; else if (levelData [i] [j]> 2) hexSprite.tint = '0x00ff00'; gameScene.renderXY (hexSprite, startX, startY, false); // ...
Quindi un valore di 0
è reso senza tinta, un valore di 1
è reso con tinta rossa, un valore di 2
è reso con tinta blu, e un valore di 5
è reso con tinta verde.
Mettendo tutto insieme, otteniamo il gioco esagonale completo di Tetris. Si prega di consultare il codice sorgente per comprendere l'implementazione completa. Noterai che stiamo usando sia le coordinate offset che le coordinate cubiche per scopi diversi. Ad esempio, per scoprire se una riga è completata, facciamo uso di coordinate offset e controlliamo il levelData
righe.
Questo conclude la prima parte della serie. Abbiamo creato con successo un gioco Tetris esagonale usando una combinazione di coordinate offset, coordinate assiali e coordinate cubo.
Nella parte conclusiva della serie, impareremo a conoscere il movimento dei personaggi usando le nuove coordinate su una griglia esagonale allineata orizzontalmente.