Sexkantig karaktärsrörelse med axiella koordinater

Vad du ska skapa

I den första delen av serien undersökte vi de olika koordinatsystemen för sexkantiga kakelbaserade spel med hjälp av ett hexagonalt Tetris-spel. En sak du kanske har märkt är att vi fortfarande litar på offsetkoordinaterna för att dra nivån på skärmen med hjälp av levelData array. 

Du kan också vara nyfiken på hur vi kunde bestämma de axiella koordinaterna för en hexagonal kakel från pixelkoordinaterna på skärmen. Metoden som används i hexagonal minesweeper-handledningen bygger på offsetkoordinaterna och är inte en enkel lösning. När vi räknar ut detta kommer vi fortsätta att skapa lösningar för sexkantig karaktärsrörelse och pathfinding.

1. Konvertera koordinater mellan pixel och axiell

Detta kommer att innebära lite matte. Vi kommer att använda den horisontella layouten för hela handledningen. Låt oss börja med att hitta ett mycket användbart förhållande mellan den vanliga sexkantens bredd och höjd. Vänligen se bilden nedan.

Tänk på den blåa regelbundna hexagonen till vänster om bilden. Vi vet redan att alla sidor är lika långa. Alla inre vinklar är 120 grader vardera. Att ansluta varje hörn till mitten av hexagonen ger sex trianglar, varav en visas med röda linjer. Denna triangel har alla inre vinklar lika med 60 grader. 

Eftersom den röda linjen delar upp de två hörnvinklarna i mitten får vi 120/2 = 60. Den tredje vinkeln är 180- (60 + 60) = 60 som summan av alla vinklar i triangeln bör vara 180 grader. Således är triangeln väsentligen en liksidig triangel, vilket ytterligare innebär att varje sida av triangeln har samma längd. Så i den blå hexagonen är de två röda linjerna, den gröna linjen och varje blå linjesegment av samma längd. Från bilden är det klart att den gröna linjen är hexTileHeight / 2.

När vi går till hexagonen till höger kan vi se att sidlängden är lika med hexTileHeight / 2, Höjden på den övre triangulära delen ska vara hexTileHeight / 4 och längden på den nedre triangulära delen ska vara hexTileHeight / 4, vilket uppgår till sexkantens fulla höjd, hexTileHeight

Överväg nu den lilla rätvinkliga triangeln längst upp till vänster med en grön och en blå vinkel. Den blå vinkeln är 60 grader eftersom det är halvan av hörnvinkeln, vilket i sin tur betyder att den gröna vinkeln är 30 grader (180- (60 + 90)). Med hjälp av denna information kommer vi fram till ett förhållande mellan höjd och bredd på den vanliga sexkanten.

solbrun 30 = motsatt sida / intilliggande sida; 1 / sqrt (3) = (hexTileHeight / 4) / (hexTileWidth / 2); hexTileWidth = sqrt (3) * hexTileHeight / 2; hexTileHeight = 2 * hexTileWidth / sqrt (3);

Konvertera axiell till pixelkoordinater

Innan vi närmar oss omvandlingen, låt vi se bilden av den horisontella hexagonala layouten där vi har markerat raden och kolumnen där en av koordinaterna förblir densamma.

Med tanke på skärm y-värdet kan vi se att varje rad har en y-förskjutning av 3 * hexTileHeight / 4, När du går ner på den gröna linjen är det enda värdet som ändras jag. Därför kan vi dra slutsatsen att y-pixelvärdet bara beror på axialen jag samordna.

y = (3 * hexTileHeight / 4) * i; y = 3/2 * s * i;

Var s är sidolängden, som visat sig vara hexTileHeight / 2.

Skärm x-värdet är lite mer komplicerat än detta. När man betraktar plattorna inom en enda rad har varje kakel en x-offset av hexTileWidth, vilket helt klart beror endast på den axiella j samordna. Men varje alternativ rad har ytterligare en kompensation av hexTileWidth / 2 beroende på axialen jag samordna.

Återigen med tanke på den gröna linjen, om vi tänker oss att det var ett kvadratiskt rutnät skulle linjen ha varit vertikal, vilket motsvarade ekvationen x = j * hexTileWidth. Eftersom den enda koordinaten som ändras längs den gröna linjen är jag, förskjutningen beror på den. Detta leder oss till följande ekvation.

x = j * hexTileWidth + (i * hexTileWidth / 2); = j * sqrt (3) * hexTileHeight / 2 + i * sqrt (3) * hexTileHeight / 4; = sqrt (3) * s * (j + (i / 2));

Så här har vi dem: ekvationerna för att konvertera axiella koordinater till skärmkoordinater. Den motsvarande omvandlingsfunktionen är som nedan.

var rootThree = Math.sqrt (3); var sidlängd = hexTileHeight / 2; funktion axialToScreen (axialPoint) var tileX = rootThree * sidlängd * (axialPoint.y + (axialPoint.x / 2)); var tileY = 3 * sidlängd / 2 * axialPoint.x; axialPoint.x = Tilex; axialPoint.y = Tiley; returnera axialPoint; 

Den reviderade koden för ritning av sexkantiga rutnätet är som följer.

för (var i = 0; i < levelData.length; i++)  for (var j = 0; j < levelData[0].length; j++)  axialPoint.x=i; axialPoint.y=j; axialPoint=offsetToAxial(axialPoint); screenPoint=axialToScreen(axialPoint); if(levelData[i][j]!=-1) hexTile= new HexTileNode(game, screenPoint.x, screenPoint.y, 'hex', false,i,j,levelData[i][j]); hexGrid.add(hexTile);   

Konvertera pixel till axiella koordinater

Omvända dessa ekvationer med den enkla substitutionen av en variabel leder oss till skärmen till axiella omvandlingsekvationer.

i = y / (3/2 * s); j = (x- (y / sqrt (3))) / s * sqrt (3);

Även om de nödvändiga axiella koordinaterna är heltal, kommer ekvationerna att resultera i flytande punktnummer. Så vi kommer att behöva runda dem och tillämpa några korrigeringar, med utgångspunkt i vår huvudsakliga ekvation x + y + z = 0. Konverteringsfunktionen är som nedan.

funktion screenToAxial (screenPoint) var axialPoint = nya Phaser.Point (); axialPoint.x = screenPoint.y / (1,5 * sideLength); axialPoint.y = (screenPoint.x- (screenPoint.y / rootThree)) / (rootThree * sideLength); var cubicZ = calculateCubicZ (axialPoint); var round_x = Math.round (axialPoint.x); var round_y = Math.round (axialPoint.y); var round_z = Math.round (cubicZ); om (round_x + round_y + round_z === 0) screenPoint.x = round_x; screenPoint.y = round_y;  annars var delta_x = Math.abs (axialPoint.x-round_x); var delta_y = Math.abs (axialPoint.y-round_y); var delta_z = Math.abs (cubicZ-round_z); om (delta_x> delta_y && delta_x> delta_z) screenPoint.x = -round_y-round_z; screenPoint.y = round_y;  annars om (delta_y> delta_x && delta_y> delta_z) screenPoint.x = round_x; screenPoint.y = -round_x-round_z;  annars om (delta_z> delta_x && delta_z> delta_y) screenPoint.x = round_x screenPoint.y = round_y;  returnera screenpoint; 

Kolla in det interaktiva elementet, som använder dessa metoder för att visa kakel och upptäcka kranar.

2. Karaktärsrörelse

Kärnkonceptet för karaktärsrörelse i något galler är liknande. Vi pollar efter användarinmatning, bestämmer riktningen, hittar den resulterande positionen, kontrollerar om den resulterande positionen faller in i en vägg i rutnätet, annars flytta karaktären till den positionen. Du kan referera till min isometriska teckenrörelsehandledning för att se detta i åtgärd med avseende på isometrisk koordinatomvandling. 

Det enda som är annorlunda här är koordinatomvandlingen och rörelsens riktningar. För ett horisontellt inriktat sexkantigt rutnät finns sex tillgängliga riktningar för rörelse. Vi kan använda tangentbordstangenterna en, W, E, D, X, och Z för styrning av varje riktning. Standardtangentbordslayouten matchar riktningarna perfekt, och de relaterade funktionerna är enligt nedan.

funktion moveLeft () motionVector.x = movementVector.y = 0; movementVector.x = -1 * hastighet; CheckCollisionAndMove ();  funktion moveRight () motionVector.x = movementVector.y = 0; movementVector.x = hastighet; CheckCollisionAndMove ();  funktion moveTopLeft () movementVector.x = -0.5 * speed; // Cos60 movementVector.y = -0.866 * hastighet; // sine60 CheckCollisionAndMove ();  funktion moveTopRight () motionVector.x = 0.5 * hastighet; // Cos60 movementVector.y = -0.866 * speed; // sine60 CheckCollisionAndMove ();  funktion moveBottomRight () motionVector.x = 0.5 * hastighet; // Cos60 movementVector.y = 0.866 * hastighet; // sine60 CheckCollisionAndMove ();  funktion moveBottomLeft () movementVector.x = -0.5 * speed; // Cos60 movementVector.y = 0.866 * hastighet; // sine60 CheckCollisionAndMove (); 

De diagonala riktningsriktningarna ger en vinkel på 60 grader med den horisontella riktningen. Så vi kan direkt beräkna den nya positionen med trigonometri genom att använda Cos 60 och Sine 60. Från detta movementVector, vi får reda på den nya resulterande positionen och kontrollera om den faller inuti en vägg i gallret enligt nedan.

funktion CheckCollisionAndMove () var tempPos = ny Phaser.Point (); tempPos.x = hero.x + movementVector.x; tempPos.y = hero.y + movementVector.y; varhörn = ny Phaser.Point (); // kolla tl corner.x = tempPos.x-heroSize / 2; corner.y = tempPos.y-heroSize / 2; if (checkCorner (hörnet)) avkastning; // check tr corner.x = tempPos.x + heroSize / 2; corner.y = tempPos.y-heroSize / 2; if (checkCorner (hörnet)) avkastning; // kolla bl corner.x = tempPos.x-heroSize / 2; corner.y = tempPos.y + heroSize / 2; if (checkCorner (hörnet)) avkastning; // check br corner.x = tempPos.x + heroSize / 2; corner.y = tempPos.y + heroSize / 2; if (checkCorner (hörnet)) avkastning; hero.x = tempPos.x; hero.y = tempPos.y;  funktionskontrollCorner (hörn) hörn = screenToAxial (corner); hörn = axialToOffset (hörnet); om (checkForOccuppancy (corner.x, corner.y)) return true;  returnera false; 

Vi lägger till movementVector till hjältepositionsvektorn för att få den nya positionen för hjältesprites centrum. Därefter hittar vi positionen för hjältespritets fyra hörn och kontrollerar om de kolliderar. Om det inte finns några kollisioner ställer vi den nya positionen till hjältespriten. Låt oss se det i aktion.

Vanligtvis är denna typ av fritt flytande rörelse inte tillåtet i ett nätbaserat spel. Vanligtvis flyttar karaktärer från kakel till kakel, det vill säga kakelcentret till kakelcentret, baserat på kommandon eller kran. Jag litar på att du kan hitta lösningen själv.

3. pathfinding

Så här är vi på ämnet pathfinding, ett mycket läskigt ämne för vissa. I mina tidigare handledningar försökte jag aldrig skapa nya pathfinding-lösningar men föredrog alltid att använda lättillgängliga lösningar som slaget testas. 

Den här gången gör jag ett undantag och kommer att återuppfinna hjulet, främst på grund av att det finns olika spelmekanismer och ingen enda lösning skulle gynna alla. Så det är praktiskt att veta hur det hela är gjort för att churn ut dina egna lösningar för din spelmekaniker. 

Den mest grundläggande algoritmen som används för att hitta i grids är Dijkstras algoritm. Vi börjar vid den första noden och beräknar kostnaderna för att flytta till alla möjliga grannnoder. Vi stänger den första noden och flyttar till grannnoden med den lägsta kostnaden. Detta upprepas för alla icke-slutna noder tills vi når destinationen. En variant av detta är A * -algoritm, där vi också använder en heuristisk utöver kostnaden. 

En heuristisk används för att beräkna det ungefärliga avståndet från den aktuella noden till destinationsnoden. Eftersom vi inte riktigt känner till vägen är denna distansberäkning alltid en approximation. Så en bättre heuristisk kommer alltid att ge en bättre väg. Det sägs att den bästa lösningen inte behöver vara den som ger den bästa vägen eftersom vi måste överväga algoritmens resursanvändning och prestanda när alla beräkningar måste göras i realtid eller en gång per uppdatering slinga. 

Den enklaste och enklaste heuristiska är Manhattan heuristisk eller Manhattan avstånd. I ett 2D-rutnät är detta faktiskt avståndet mellan startnoden och slutnodet i kråken, eller antalet block vi behöver gå.

Heksagonal Manhattan Variant

För vårt sexkantiga galler behöver vi hitta en variant för Manhattan-heuristiken för att approximera avståndet. När vi går på de sexkantiga plattorna är tanken att hitta antalet kakel vi behöver gå över för att nå destinationen. Låt mig visa dig lösningen först. Vänligen flytta musen över det interaktiva elementet nedan för att se hur långt de andra plattorna är från plattan under musen.

I exemplet ovan hittar vi kakel under musen och hittar avståndet från alla andra kakel från det. Logiken är att hitta skillnaden mellan jag och j axiella koordinater för båda plattorna först, säg di och dj. Hitta de absoluta värdena för dessa skillnader, absi och absj, som avstånd är alltid positiva. 

Vi märker det när båda di och dj är positiva och när båda di och dj är negativa, avståndet är absi + absj. När di och dj är av motsatta tecken, avståndet är det större värdet bland absi och absj. Detta leder till den heuristiska beräkningsfunktionen getHeuristic som nedan.

getHeuristic = funktion (i, j) j = (j- (Math.floor (i / 2))); var di = jag-this.originali; var dj = j-this.convertedj; var si = Math.sign (di); var sj = Math.sign (dj); var absi = di * si; var absj = dj * sj; om (si! = sj) this.heuristic = Math.max (absi, absj);  annars this.heuristic = (absi + absj); 

En sak att märka är att vi inte överväger om vägen är riktigt promenadbar eller inte; vi antar bara att det är walkable och sätta avståndet värde. 

Hitta den sexkantiga vägen

Låt oss fortsätta med pathfinding för vårt sexkantiga rutnät med den nyligen hittade heuristiska metoden. Eftersom vi kommer att använda rekursion, blir det lättare att förstå när vi bryter ner kärnlogiken i vårt tillvägagångssätt. Varje hexagonal kakel kommer att ha ett heuristiskt avstånd och ett kostnadsvärde associerat med det.

  • Vi har en rekursiv funktion, säger findPath (kakel), som tar in en hexagonal kakel, vilken är den nuvarande kakel. Ursprungligen kommer detta att vara startbrickan.
  • Om plattan är lika med slutkakan, slutar rekursionen och vi har hittat banan. Annars fortsätter vi med beräkningen.
  • Vi hittar alla flikar som kan gås i närheten. Vi kommer att slinga igenom alla grannplattor och tillämpa ytterligare logik på var och en av dem, såvida de inte är stängd.
  • Om en granne inte är tidigare besökt och inte stängd, finner vi avståndet från grannplattan till ändplattan med vår heuristik. Vi satte grannplattan kosta till nuvarande kakel kostar + 10. Vi satte grannplattan som besökt. Vi satte grannplattan tidigare kakel som nuvarande kakel. Vi gör det även för en tidigare besökt granne, om nuvarande kakel kostar + 10 är mindre än grannens kostnad.
  • Vi beräknar den totala kostnaden som summan av grannlocks kostnadsvärde och det heuristiska distansvärdet. Bland alla grannar väljer vi grann som ger lägsta totala kostnad och samtal findPath på den granne kakel.
  • Vi ställer in nuvarande kakel så att den inte kommer att övervägas längre.
  • I vissa fall kommer vi inte att hitta någon kakel som uppfyller villkoren, och sedan stänger vi nuvarande kakel, öppnar föregående kakel och gör om.

Det finns ett uppenbart fel i logiken när mer än en kakel uppfyller villkoren. En bättre algoritm hittar alla olika vägar och väljer den med kortast längd, men det gör vi inte här. Kontrollera sökvägen i åtgärd nedan.

För detta exempel beräknar jag grannar annorlunda än i Tetris-exemplet. Vid användning av axiella koordinater har grannytorna koordinater som är högre eller lägre med ett värde av 1.

funktion getNeighbors (i, j) // koordinaterna är i axiell var tempArray = []; var axialPoint = ny Phaser.Point (i, j); var neighbourPoint = nya Phaser.Point (); neighbourPoint.x = axialPoint.x-1; // tr neighbourPoint.y = axialPoint.y; populateNeighbor (neighbourPoint.x, neighbourPoint.y, tempArray); neighbourPoint.x = axialPoint.x + 1; // bl neighbourPoint.y = axialPoint.y; populateNeighbor (neighbourPoint.x, neighbourPoint.y, tempArray); neighbourPoint.x = axialPoint.x; // l neighbourPoint.y = axialPoint.y-1; populateNeighbor (neighbourPoint.x, neighbourPoint.y, tempArray); neighbourPoint.x = axialPoint.x; // r neighbourPoint.y = axialPoint.y + 1; populateNeighbor (neighbourPoint.x, neighbourPoint.y, tempArray); neighbourPoint.x = axialPoint.x-1; // tr neighbourPoint.y = axialPoint.y + 1; populateNeighbor (neighbourPoint.x, neighbourPoint.y, tempArray); neighbourPoint.x = axialPoint.x + 1; // bl neighbourPoint.y = axialPoint.y-1; populateNeighbor (neighbourPoint.x, neighbourPoint.y, tempArray); returnera tempArray; 

De findPath rekursiv funktion är som nedan.

funktion findPath (kakel) // passerar i en hexTileNode om (Phaser.Point.equals (kakel, endTile)) // framgång, destination nått console.log ('success'); // målar nu vägen. paintPath (kakel);  annat // hitta alla grannar var grannar = getNeighbors (tile.originali, tile.convertedj); var newPt = ny Phaser.Point (); var hexTile; var totalCost = 0; var currentLowestCost = 100000; var nextTile; // hitta heuristik och kostnad för alla grannar medan (grannar. längd) newPt = grannar.skift (); hexTile = hexGrid.getByName ( "tile" + newPt.x + "_" + newPt.y); om (! hexTile.nodeClosed) // om nod inte redan beräknades om ((hexTile.nodeVisited && (tile.cost + 10)

Det kan kräva ytterligare och flera läsningar för att förstå vad som händer, men tro mig, det är värt ansträngningen. Detta är bara en mycket grundläggande lösning och kan förbättras mycket. För att flytta karaktären längs den beräknade sökvägen kan du referera till min isometriska sökväg efter handledning. 

Markeringen av sökvägen görs med en annan enkel rekursiv funktion, paintPath (kakel), som först kallas med ändplattan. Vi markerar bara previousNode av plattan om den är närvarande.

funktion paintPath (platt) tile.markDirty (); om (tile.previousNode! == null) paintPath (tile.previousNode); 

Slutsats

Med hjälp av alla de tre sexkantiga handledningarna jag har delat, borde du kunna komma igång med ditt nästa fantastiska hexagonala kakelbaserade spel. 

Var vänlig uppmärksam på att det finns andra tillvägagångssätt också, och det finns mycket mer att läsa där ute om du är redo för det. Vänligen låt mig veta genom kommentarer om du behöver något mer att utforska i förhållande till sexkantiga plattor-baserade spel.