Introduzione alle coordinate assiali per giochi a mattonelle esagonali

Cosa starai creando

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.

1. Coordinate assiali

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.

2. Coordinate cubo o cubico

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. 

3. Vantaggi del nuovo sistema di coordinate

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.

Movimento

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.

Vicinato

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 + 1x, 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.

Muoversi intorno a una tessera

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, yz da esso e da tutte le altre tessere.

Euristico

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.

4. Creazione di un tetris esagonale

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.

  • Inizia con una griglia bidimensionale vuota.
  • Diversi blocchi compaiono in alto e si spostano verso il basso di una tessera alla volta fino a raggiungere il fondo.
  • Quando raggiungono il fondo, vengono cementati o diventano non interattivi. Fondamentalmente, diventano parte della griglia.
  • Mentre si abbassa, il blocco può essere spostato lateralmente, ruotato in senso orario / antiorario e lasciato cadere.
  • L'obiettivo è riempire tutte le tessere in qualsiasi fila, su cui scompare l'intera riga, collassando su di essa il resto della griglia piena.
  • Il gioco termina quando non ci sono più tessere libere in cima a un nuovo blocco per entrare nella griglia.

Rappresentare i diversi blocchi

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.

Rotating the Blocks

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.

Spostare il blocco

Teniamo traccia del ioj 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 è blockMidRowValueblockMidColumnValue

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.

Controllo delle operazioni valide

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.

Rendering del gioco

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.

5. Il gioco completato

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.

Conclusione

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.