Introduktion till axiella koordinater för hexagonala plattbaserade spel

Vad du ska skapa

Den grundläggande hexagonala plattan-baserade tillvägagångssätt som förklaras i den hexagonala minesweeperhandledningen får arbetet gjort men det är inte särskilt effektivt. Den använder direkt omvandling från de tvådimensionella arraybaserade nivådata och skärmkoordinaterna, vilket gör det onödigt komplicerat att bestämma tappade plattor. 

Dessutom är behovet av att använda olika logik beroende på udda eller jämn rad / kolumn på en kakel inte lämplig. Denna tutorial-serie undersöker alternativa skärmkoordinatsystem som kan användas för att underlätta logiken och göra sakerna mer praktiska. Jag rekommenderar starkt att du läser den sexkantiga minesweeperhandledningen innan du går vidare med den här handledningen som den förklarar rutnivån baserat på en tvådimensionell array.

1. Axialkoordinater

Standardinställningen som används för skärmkoordinater i den sexkantiga minesweeper-handledningen kallas offset-koordinatinriktningen. Detta beror på att de alternativa raderna eller kolumnerna kompenseras av ett värde medan du anpassar det sexkantiga gallret. 

För att uppdatera ditt minne, se bilden nedan, som visar den horisontella inriktningen med de förskjutna koordinatvärdena som visas.

I bilden ovan, en rad med samma jag värdet är markerat i rött och en kolumn med samma j värdet är markerat i grönt. För att göra allt enkelt kommer vi inte att diskutera de udda och jämna förskjutna varianterna, eftersom båda bara är olika sätt att få samma resultat. 

Låt mig introducera ett bättre skärmkoordinatalternativ, den axiella koordinaten. Att konvertera en kompensationskoordinat till en axiell variant är mycket enkel. De jag värdet förblir detsamma, men det j värdet konverteras med hjälp av formeln axialJ = i - golv (j / 2). En enkel metod kan användas för att konvertera en förskjutning Phaser.Point till sin axiella variant, såsom visas nedan.

funktion offsetToAxial (offsetPoint) offsetPoint.y = (offsetPoint.y- (Math.floor (offsetPoint.x / 2))); returnera offsetPoint; 

Den omvända omvandlingen skulle vara som visas nedan.

funktion axialToOffset (axialPoint) axialPoint.y = (axialPoint.y + (Math.floor (axialPoint.x / 2))); returnera axialPoint; 

Här x värdet är jag värde och y värdet är j värde för den tvådimensionella matrisen. Efter konvertering skulle de nya värdena se ut som bilden nedan.

Observera att den gröna linjen där j värdet förblir detsamma, inte zigzag längre, men snarare är det nu en diagonal mot vårt sexkantiga rutnät.

För det vertikalt inriktade sexkantiga gallret visas offsetkoordinaterna i bilden nedan.

Omvandlingen till axiella koordinater följer samma ekvationer, med den skillnad som vi behåller j värdera samma och ändra jag värde. Metoden nedan visar omvandlingen.

funktion offsetToAxial (offsetPoint) offsetPoint.x = (offsetPoint.x- (Math.floor (offsetPoint.y / 2))); returnera offsetPoint; 

Resultatet är som visas nedan.

Innan vi använder de nya koordinaterna för att lösa problem, låt mig snabbt presentera dig för ett annat skärmkoordinatalternativ: kubokoordinater.

2. Kub eller kubiska koordinater

Att räta upp siggensen själv har potentiellt löst de flesta besvär som vi hade med offset-koordinatsystemet. Kub eller kubiska koordinater skulle ytterligare hjälpa oss att förenkla komplicerad logik som heuristik eller rotera runt en hexagonal cell. 

Som du kanske har gissat från namnet har det kubiska systemet tre värden. Den tredje k eller z värdet härrör från ekvationen x + y + z = 0, var x och y är de axiella koordinaterna. Detta leder oss till denna enkla metod för att beräkna z värde.

funktionen beräknaCubicZ (axialPoint) return -axialPoint.x-axialPoint.y; 

Ekvationen x + y + z = 0 är faktiskt ett 3D-plan som passerar genom diagonalen i ett tredimensionellt kubrutenät. Visa alla tre värdena för rutnätet resulterar i följande bilder för de olika hexagonala inriktningarna.

Den blå linjen indikerar de plattor där z värdet förblir detsamma. 

3. Fördelar med det nya koordinatsystemet

Du kanske undrar hur dessa nya koordinatsystem hjälper oss med sexkantig logik. Jag kommer att förklara några fördelar innan vi går vidare för att skapa en sexkantig Tetris med hjälp av vår nya kunskap.

Rörelse

Låt oss betrakta mittkakan i bilden ovan, som har kubiska koordinatvärden för 3,6, -9. Vi har märkt att ett samordningsvärde förblir detsamma för plattorna på de färgade linjerna. Vidare kan vi se att de återstående koordinaterna antingen ökar eller minskar med 1 medan vi spårar någon av de färgade linjerna. Till exempel, om x värdet förblir detsamma och y värdet ökar med 1 längs en riktning, den z värdet minskar med 1 för att tillfredsställa vår styrande ekvation x + y + z = 0. Denna funktion gör det mycket lättare att styra rörelsen. Vi kommer att använda detta i den andra delen av serien.

Grannar

Av samma logik är det lätt att hitta grannarna för kakel x, y, z. Genom att hålla x Detsamma får vi två diagonala grannar, x, y-1, z + 1 och x, y + 1, z-1. Genom att hålla y samma får vi två vertikala grannar, x-1, y, z + 1 och x + 1, y, z-1. Genom att hålla z samma får vi de återstående två diagonala grannarna, x + 1, y-1, z och x-1, y + 1, z. Bilden nedan illustrerar detta för en kakel vid ursprunget.

Det är så mycket lättare nu att vi inte behöver använda olika logik baserat på jämn eller udda rader / kolumner.

Flytta runt en kakel

En intressant sak att märka i bilden ovan är en slags cyklisk symmetri för alla plattor runt den röda kakel. Om vi ​​tar koordinaterna för någon angränsande kakel kan koordinaterna för den närmaste angränsande plattan erhållas genom att cykla koordinatvärdena antingen vänster eller höger och multiplicerar sedan med -1. 

Till exempel har den bästa grannen ett värde av -1,0,1, vilken som roterar höger blir en gång 1, -1,0 och efter multiplicering med -1 blir -1,1,0, vilken är koordinaten till rätt granne. Roterande vänster och multiplicera med -1 avkastning 0, -1,1, vilken är koordinaten för den vänstra grannen. Genom att upprepa detta kan vi hoppa mellan alla angränsande plattor runt mitten av plattan. Detta är en mycket intressant funktion som kan hjälpa till med logik och algoritmer. 

Observera att detta sker endast på grund av att mittenplattan anses vara ursprungslandet. Vi kunde enkelt göra någon kakel x, y, z att vara vid ursprunget genom att subtrahera värdena  x, y och z från det och alla andra plattor.

Heuristik

Beräkning av effektiv heuristik är nyckeln när det gäller att hitta sökningar eller liknande algoritmer. Kubiska koordinater gör det lättare att hitta enkla heuristik för sexkantiga nät tack vare de ovan nämnda aspekterna. Vi kommer att diskutera detta i detalj i den andra delen av denna serie.

Det här är några av fördelarna med det nya koordinatsystemet. Vi kunde använda en blandning av de olika koordinatsystemen i våra praktiska implementeringar. Till exempel är den tvådimensionella gruppen fortfarande det bästa sättet att spara nivådata, vars koordinater är offsetkoordinaterna. 

Låt oss försöka skapa en hexagonal version av det berömda Tetris-spelet med denna nya kunskap.

4. Skapa en hexagonal tetris

Vi har alla spelat Tetris, och om du är en spelutvecklare kan du ha skapat din egen version också. Tetris är ett av de enklaste kakelbaserade spelen som man kan implementera, förutom tic tac toe eller checkers, med en enkel tvådimensionell array. Låt oss först lista funktionerna i Tetris.

  • Det börjar med ett tomt tvådimensionellt rutnät.
  • Olika block visas uppe och flyttar ner en kakel åt gången tills de når botten.
  • När de når botten blir de cementerade där eller blir icke-interaktiva. I grund och botten blir de en del av gallret.
  • När du släpper ner kan blocket flyttas i sidled, roteras medurs / moturs och släpps ner.
  • Målet är att fylla upp alla plattor i vilken rad som helst, där hela raden försvinner, kollapsar resten av det fyllda gallret på det.
  • Spelet slutar när det inte finns några fler fria plattor på toppen för att ett nytt block ska komma in i rutnätet.

Representerar de olika blocken

Eftersom spelet har block som faller vertikalt, använder vi ett vertikalt inriktat sexkantigt rutnät. Detta innebär att de rör sig i sidled gör att de rör sig på ett zigzag sätt. En hel rad i gallret består av en uppsättning kakel i zigzag-ordning. Från och med denna tidpunkt kan du börja hänvisa till källkoden som medföljer denna handledning. 

Nivådata lagras i en tvådimensionell array som heter levelData, och utförandet görs med hjälp av offsetkoordinaterna, som förklaras i den sexkantiga minesweeperhandledningen. Vänligen hänvisa till det om du har svårt att följa koden. 

Det interaktiva elementet i nästa avsnitt visar de olika blocken som vi ska använda. Det finns ytterligare ett ytterligare block, som består av tre fyllda plattor inriktat vertikalt som en pelare. BlockData används för att skapa de olika blocken. 

funktion 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; 

En blank blockmall är en uppsättning av sju kakel som består av en mitt kakel omgiven av sina sex grannar. För varje Tetris-block fylls medeltegeln alltid med ett värde av 1, medan en tom kakel skulle betecknas med ett värde av 0. De olika blocken skapas genom att fylla plattorna av BlockData som nedan.

var block1 = ny BlockData (1,1,0,0,1,1); var block2 = ny BlockData (0,1,0,0,0,1); var block3 = ny BlockData (1,1,0,0,0,0); var block4 = ny BlockData (1,1,0,1,0,0); var block5 = ny BlockData (1,0,0,1,0,1); var block6 = ny BlockData (0,1,1,0,1,1); var block7 = ny BlockData (1,0,0,1,0,0);

Vi har totalt sju olika block.

Roterar blocken

Låt mig visa dig hur blocken roterar med hjälp av det interaktiva elementet nedan. Tryck och håll ned för att rotera blocken och tryck på x för att ändra rotationsriktningen.

För att rotera blocket måste vi hitta alla plattor som har ett värde av 1, sätt värdet till 0, rotera en gång runt mitten av plattan för att hitta grannytan och ställ in dess värde 1. För att rotera en kakel runt en annan kakel kan vi använda logiken som förklaras i flytta runt en kakel avsnitt ovan. Vi kommer fram till nedanstående metod för detta ändamål.

funktionen rotateTileAroundTile (tileToRotate, anchorTile) tileToRotate =; convert to axial var tileToRotateZ = calculateCubicZ (tileToRotate); // hitta z värdet anchorTile = offsetToAxial (anchorTile); // konvertera till axial var anchorTileZ = calculateCubicZ anchorTile); // hitta z värde tileToRotate.x = tileToRotate.x-anchorTile.x; // hitta x skillnad tileToRotate.y = tileToRotate.y-anchorTile.y; // hitta y skillnad tileToRotateZ = tileToRotateZ-anchorTileZ; // hitta z skill var pointArr = [tileToRotate.x, tileToRotate.y, tileToRotateZ]; // fylla array för att rotera pointArr = arrayRotate (pointArr, clockWise); // rotera array, true for clockwise tileToRotate.x = (- 1 * pointArr [0]) + anchorTile.x; // multiplicera med -1 och ta bort x-skillnaden tileToRotate.y = (- 1 * pointArr [1]) + anchorTile.y; // multiplicera med -1 och ta bort y-skillnaden = axialToOffset (tileToRotate); // konvertera till offset-retur-flikToRotate;  // ... funktion arrayRotate (arr, reverse) // nifty metod för att rotera arrayelement om (omvänd) arr.unshift (arr.pop ()) annars arr.push (arr.shift ()) return ar 

Variabeln medurs används för att rotera medurs eller moturs, vilket uppnås genom att flytta matrisvärdena i motsatta riktningar in arrayRotate.

Flytta blocket

Vi håller koll på jag och j förskjutningskoordinater för blockets mellersta kakel med hjälp av variablerna blockMidRowValue och blockMidColumnValue respektive. För att flytta blocket ökar eller minskar vi dessa värden. Vi uppdaterar motsvarande värden i levelData med blockvärdena med hjälp av paintBlock metod. Den uppdaterade levelData används för att göra scenen efter varje tillståndsändring.

var blockMidRowValue; var blockMidColumnValue; // ... funktion moveLeft () blockMidColumnValue--;  funktion moveRight () blockMidColumnValue ++;  funktion dropDown () paintBlock (true); blockMidRowValue ++;  funktion paintBlock () clockWise = true; varval = 1; changeLevelData (blockMidRowValue, blockMidColumnValue, Val); var rotatingTile = ny Phaser.Point (blockMidRowValue-1, blockMidColumnValue); om (currentBlock.tBlock == 1) changeLevelData (rotatingTile.x, rotatingTile.y, val * currentBlock.tBlock);  var midPoint = ny Phaser.Point (blockMidRowValue, blockMidColumnValue); rotatingTile = rotateTileAroundTile (rotatingTile, mittpunkt); om (currentBlock.trBlock == 1) changeLevelData (rotatingTile.x, rotatingTile.y, val * currentBlock.trBlock);  midPoint.x = blockMidRowValue; midPoint.y = blockMidColumnValue; rotatingTile = rotateTileAroundTile (rotatingTile, mittpunkt); om (currentBlock.brBlock == 1) changeLevelData (rotatingTile.x, rotatingTile.y, val * currentBlock.brBlock);  midPoint.x = blockMidRowValue; midPoint.y = blockMidColumnValue; rotatingTile = rotateTileAroundTile (rotatingTile, mittpunkt); om (currentBlock.bBlock == 1) changeLevelData (rotatingTile.x, rotatingTile.y, val * currentBlock.bBlock);  midPoint.x = blockMidRowValue; midPoint.y = blockMidColumnValue; rotatingTile = rotateTileAroundTile (rotatingTile, mittpunkt); om (currentBlock.blBlock == 1) changeLevelData (rotatingTile.x, rotatingTile.y, val * currentBlock.blBlock);  midPoint.x = blockMidRowValue; midPoint.y = blockMidColumnValue; rotatingTile = rotateTileAroundTile (rotatingTile, mittpunkt); om (currentBlock.tlBlock == 1) changeLevelData (rotatingTile.x, rotatingTile.y, val * currentBlock.tlBlock);  funktionen ändraLevelData (iVal, jVal, newValue, radera) if (! validIndexes (iVal, jVal)) returnera; om (radera) if (levelData [iVal] [jVal] == 1) levelData [iVal] [jVal] = 0;  else levelData [iVal] [jVal] = newValue;  funktion validIndexes (iVal, jVal) if (iVal<0 || jVal<0 || iVal>= levelData.length || jVal> = levelData [0] .length) return false;  returnera sant;  

Här, currentBlock pekar på blockData i scenen. I paintBlock, först ställer vi in levelData värdet för mitten av kvarteret till 1 som det alltid är 1 för alla block. Mittpunktens index är blockMidRowValueblockMidColumnValue

Då flytta vi till levelData index av kakel på toppen av mitten kakel  blockMidRowValue-1,  blockMidColumnValue, och ställ den till 1 om blocket har denna kakel som 1. Sedan roterar vi medurs om en halv kakel för att få nästa kakel och upprepa samma process. Detta görs för alla plattor runt mitten av plattan till blocket.

Kontrollera giltiga operationer

När vi flyttar eller roterar blocket måste vi kontrollera om det är en giltig operation. Till exempel kan vi inte flytta eller rotera blocket om de plattor som den behöver uppta redan är upptagna. Vi kan inte heller flytta blocket utanför vårt tvådimensionella rutnät. Vi måste också kontrollera om blocket kan släppa längre, vilket skulle avgöra om vi behöver cementera blocket eller inte. 

För alla dessa använder jag en metod CAnMove (i, j), vilket returnerar en booleska som indikerar om blocket placeras vid I j är ett giltigt drag. För varje operation, innan du ändrar faktiskt levelData värden kontrollerar vi om den nya positionen för blocket är en giltig position med den här metoden.

funktion canMove (iVal, jVal) var validMove = true; var butik = clockWise; var newBlockMidPoint = nya Phaser.Point (blockMidRowValue + iVal, blockMidColumnValue + jVal); Medurs = true; om (! validAndEmpty (newBlockMidPoint.x, newBlockMidPoint.y)) // check mitten, alltid 1 validMove = false;  var rotatingTile = ny Phaser.Point (newBlockMidPoint.x-1, newBlockMidPoint.y); om (currentBlock.tBlock == 1) if (! validAndEmpty (rotatingTile.x, rotatingTile.y)) // check top validMove = false;  newBlockMidPoint.x = blockMidRowValue + iVal; newBlockMidPoint.y = blockMidColumnValue + JVAL; rotatingTile = rotateTileAroundTile (rotatingTile, newBlockMidPoint); om (currentBlock.trBlock == 1) if (! validAndEmpty (rotatingTile.x, rotatingTile.y)) validMove = false;  newBlockMidPoint.x = blockMidRowValue + iVal; newBlockMidPoint.y = blockMidColumnValue + JVAL; rotatingTile = rotateTileAroundTile (rotatingTile, newBlockMidPoint); om (currentBlock.brBlock == 1) if (! validAndEmpty (rotatingTile.x, rotatingTile.y)) validMove = false;  newBlockMidPoint.x = blockMidRowValue + iVal; newBlockMidPoint.y = blockMidColumnValue + JVAL; rotatingTile = rotateTileAroundTile (rotatingTile, newBlockMidPoint); om (currentBlock.bBlock == 1) if (! validAndEmpty (rotatingTile.x, rotatingTile.y)) validMove = false;  newBlockMidPoint.x = blockMidRowValue + iVal; newBlockMidPoint.y = blockMidColumnValue + JVAL; rotatingTile = rotateTileAroundTile (rotatingTile, newBlockMidPoint); om (currentBlock.blBlock == 1) if (! validAndEmpty (rotatingTile.x, rotatingTile.y)) validMove = false;  newBlockMidPoint.x = blockMidRowValue + iVal; newBlockMidPoint.y = blockMidColumnValue + JVAL; rotatingTile = rotateTileAroundTile (rotatingTile, newBlockMidPoint); om (currentBlock.tlBlock == 1) if (! validAndEmpty (rotatingTile.x, rotatingTile.y)) validMove = false;  clockWise = store; returnera validMove;  funktion validAndEmpty (iVal, jVal) if (! validIndexes (iVal, jVal)) return false;  annars om (levelData [iVal] [jVal]> 1) // occuppied return false;  returnera sant; 

Processen här är densamma som paintBlock, men istället för att ändra några värden returnerar detta bara en booleska som anger ett giltigt drag. Även om jag använder vridning runt en mittplatta logik för att hitta grannarna, är det enklare och ganska effektiva alternativet att använda de direkta koordinatvärdena för grannarna, vilket lätt kan bestämmas från mittenplattskoordinaterna.

Rendering av spelet

Spelnivån representeras visuellt av a RenderTexture som heter gameScene. I matrisen levelData, en obebyggd kakel skulle ha ett värde av 0, och en ockuperad kakel skulle ha ett värde av 2 eller högre. 

Ett cementerat block betecknas med ett värde av 2, och ett värde av 5 betecknar en kakel som måste avlägsnas eftersom den är en del av en färdig rad. Ett värde av 1 innebär att plattan är en del av blocket. Efter varje spelstatusändring gör vi nivån med informationen i levelData, enligt nedanstående.

// ... hexSprite.tint = '0xffffff'; om (levelData [i] [j]> - 1) axialPoint = offsetToAxial (axialPoint); cubicZ = calculateCubicZ (axialPoint); om (levelData [i] [j] == 1) hexSprite.tint = '0xff0000';  annars om (levelData [i] [j] == 2) hexSprite.tint = '0x0000ff';  annars om (levelData [i] [j]> 2) hexSprite.tint = '0x00ff00';  gameScene.renderXY (hexSprite, startX, startY, false);  // ... 

Därför ett värde av 0 görs utan någon nyans, ett värde av 1 är gjord med röd nyans, ett värde av 2 är gjord med blå nyans och ett värde av 5 är gjord med grön nyans.

5. Det färdiga spelet

När vi sätter allt ihop, får vi det färdiga sexkantiga Tetris-spelet. Vänligen gå igenom källkoden för att förstå det fullständiga genomförandet. Du kommer märka att vi använder både offsetkoordinater och kubiska koordinater för olika ändamål. Till exempel, för att hitta om en rad är klar använder vi offsetkoordinater och kontrollerar levelData rader.

Slutsats

Detta avslutar den första delen av serien. Vi har framgångsrikt skapat ett hexagonalt Tetris-spel med en kombination av offsetkoordinater, axiella koordinater och kubokoordinater. 

I den avslutande delen av serien lär vi oss om karaktärsrörelse med de nya koordinaterna på ett horisontellt inriktat sexkantigt rutnät.