Unity 2D Tile-Based Sokoban Game

Vad du ska skapa

I denna handledning kommer vi att undersöka ett tillvägagångssätt för att skapa ett sokoban- eller crate-pusher-spel med kakelbaserad logik och en tvådimensionell array för att hålla nivådata. Vi använder Unity för utveckling med C # som skriptspråk. Vänligen ladda ner källfilerna med denna handledning för att följa med.

1. Sokoban-spelet

Det kan finnas få bland oss ​​som kanske inte har spelat en Sokoban-spelvariant. Den ursprungliga versionen kan till och med vara äldre än några av er. Vänligen kolla in wikisidan för några detaljer. I huvudsak har vi ett tecken eller ett användarstyrt element som måste skjuta lådor eller liknande element på dess måltavla. 

Nivån består av ett kvadratiskt eller rektangulärt galler av kakel där en kakel kan vara en icke-walkable eller en walkable one. Vi kan gå på gångbara plattorna och trycka på lådorna på dem. Speciella gångbara plattor skulle markeras som målplattor, vilket är där kistan i slutändan vilar för att slutföra nivån. Karaktären styrs vanligen med hjälp av ett tangentbord. När alla kasser har nått en destination, är nivån färdig.

Tegelbaserad utveckling innebär i huvudsak att vårt spel består av ett antal plattor som sprids på ett förutbestämt sätt. Ett jämnt dataelement representerar hur kakelarna skulle behöva sprida sig för att skapa vår nivå. I vårt fall använder vi en kvadratisk plattplatta. Du kan läsa mer om kakelbaserade spel här på Envato Tuts+.

2. Förbereda Unity Project

Låt oss se hur vi har organiserat vårt Unity-projekt för denna handledning.

Konsten

För detta handledningsprojekt använder vi inte några externa konsttillgångar, men kommer att använda sprite-primitiverna skapade med den senaste Unity-versionen 2017.1. Bilden nedan visar hur vi kan skapa olika formade sprites inom Unity.

Vi kommer att använda Fyrkant sprite för att representera en enda kakel i vårt sokoban nivå nät. Vi kommer att använda Triangel sprite för att representera vår karaktär, och vi kommer att använda Cirkel sprite för att representera en kista, eller i detta fall en boll. De normala markplattorna är vita, medan destinationens plattor har en annan färg som sticker ut.

Nivådata

Vi representerar våra nivådata i form av en tvådimensionell uppsättning som ger den perfekta korrelationen mellan de logiska och visuella elementen. Vi använder en enkel textfil för att lagra nivådata, vilket gör det lättare för oss att redigera nivån utanför Unity eller ändra nivåer helt enkelt genom att ändra de filer som laddas. De Medel mappen har a nivå textfil, som har vår standardnivå.

1,1,1,1,1,1,1 1,3,1, -1,1,0,1 -1,0,1,2,1,1, -1 1,1,1,3, 1,3,1 1,1,0, -1,1,1,1

Nivån har sju kolumner och fem rader. Ett värde av 1 innebär att vi har en markplatta vid den positionen. Ett värde av -1 betyder att det är en icke-gångbar kakel medan ett värde av 0 betyder att det är en målplatta. Värdet 2 representerar vår hjälte och 3 representerar en tryckbar boll. Bara genom att titta på nivådata kan vi visualisera hur vår nivå skulle se ut.

3. Skapa en Sokoban-spelnivå

För att hålla sakerna enkla, och eftersom det inte är en väldigt komplicerad logik, har vi bara en enda Sokoban.cs skriptfil för projektet, och det är anslutet till scenkamera. Vänligen håll den öppen i din redaktör medan du följer resten av handledningen.

Specialnivådata

Nivådata som representeras av 2D-raden används inte bara för att skapa det ursprungliga gallret men används även i hela spelet för att spåra nivåändringar och spelframsteg. Det betyder att nuvarande värden inte är tillräckliga för att representera några nivånivåer under spel. 

Varje värde representerar tillståndet för motsvarande kakel i nivån. Vi behöver ytterligare värden för att representera en boll på målplattan och hjälten på målplattan, som respektive är -3 och -2. Dessa värden kan vara något värde som du tilldelar i spelskriptet, inte nödvändigtvis samma värden som vi har använt här. 

Parsa nivån på textfilen

Det första steget är att ladda våra nivådata till en 2D-array från den externa textfilen. Vi använder ParseLevel Metod för att ladda sträng värdera och dela upp det för att fylla vårt levelData 2D-array.

void ParseLevel () TextAsset textFile = Resources.Load (levelName) som TextAsset; sträng [] lines = textFile.text.Split (new [] '\ r', '\ n', System.StringSplitOptions.RemoveEmptyEntries); // delad med ny rad, retursträng [] nums = linjer [0] .Split (nytt [] ','); // split by, rader = lines.Length; // antal rader cols = nums.Length; // antal kolumner levelData = new int [rader, cols]; för (int i = 0; i < rows; i++)  string st = lines[i]; nums = st.Split(new[]  ',' ); for (int j = 0; j < cols; j++)  int val; if (int.TryParse (nums[j], out val)) levelData[i,j] = val;  else levelData[i,j] = invalidTile;    

Medan vi analyserar bestämmer vi antalet rader och kolumner som vår nivå har när vi fyller i vårt levelData.

Ritningsnivå

När vi har vår nivådata kan vi dra vår nivå på skärmen. Vi använder CreateLevel-metoden för att göra just det.

void CreateLevel () // beräkna förskjutningen för att justera hela nivån till scenen middleOffset.x = cols * tileSize * 0.5f-tileSize * 0.5f; middleOffset.y = rader * tileSize * 0.5f-tileSize * 0.5f ;; GameObject kakel; SpriteRenderer sr; GameObject ball; int destinationCount = 0; för (int i = 0; i < rows; i++)  for (int j = 0; j < cols; j++)  int val=levelData[i,j]; if(val!=invalidTile)//a valid tile tile = new GameObject("tile"+i.ToString()+"_"+j.ToString());//create new tile tile.transform.localScale=Vector2.one*(tileSize-1);//set tile size sr = tile.AddComponent(); // lägg till en sprite renderer sr.sprite = tileSprite; // tilldela tegel sprite tile.transform.position = GetScreenPointFromLevelIndices (i, j); // placera scenen baserat på nivåindex om (val == destinationTile)  // om det är en målplatta, ge annan färg sr.color = destinationColor; destinationCount ++; // count destinations annars om (val == heroTile) // hjälten kakel hjälten = nya GameObject ("hjälte"); hero.transform.localScale = Vector2.one * (tileSize-1); sr = hero.AddComponent(); sr.sprite = heroSprite; sr.sortingOrder = 1; // hjälten måste vara över marken kakel sr.color = Color.red; hero.transform.position = GetScreenPointFromLevelIndices (i, j); occupy.Add (hjälte, ny vektor2 (i, j)); // lagra hjälteens nivåindikatorer i dikt annars om (val == ballTile) // kula kakel ballCount ++; // inkrement antal bollar i nivåboll = nytt GameObject ("boll" + ballCount.ToString ()); ball.transform.localScale = Vector2.one * (tileSize-1); sr = ball.AddComponent(); sr.sprite = ballSprite; sr.sortingOrder = 1; // bollen måste vara över marken kakel sr.color = Color.black; ball.transform.position = GetScreenPointFromLevelIndices (i, j); occupants.Add (boll, ny Vector2 (i, j)); // lagra nivåindikatorerna för boll i dikt om (ballCount> destinationCount) Debug.LogError ("det finns fler bollar än destinationer"); 

För vår nivå har vi satt en tileSize värdet av 50, vilken är längden på sidan av en fyrkantig kakel i vårt nivånät. Vi går igenom vår 2D-array och bestämmer värdet som lagras på var och en av jag och j index av matrisen. Om detta värde inte är ett invalidTile (-1) då skapar vi en ny GameObject som heter bricka. Vi bifogar a SpriteRenderer komponent till bricka och tilldela motsvarande Sprite eller Färg beroende på värdet vid matrisindexet. 

Medan du placerar hjälte eller den boll, vi måste först skapa en markplatta och sedan skapa dessa plattor. Eftersom hjälten och bollen måste överlägga markplattan, ger vi dem SpriteRenderer en högre sortingOrder. Alla plattor tilldelas a localScale av tileSize så de är 50x50 i vår scen. 

Vi håller koll på antalet bollar i vår scen med hjälp av ballCount variabel, och det borde finnas samma eller ett högre antal måltavlor i vår nivå för att möjliggöra nivåhantering. Magien händer i en enda kodlinje där vi bestämmer positionen för varje kakel med hjälp av GetScreenPointFromLevelIndices (int rad, int col) metod.

// ... tile.transform.position = GetScreenPointFromLevelIndices (i, j); // placera scenen baserat på nivåindex // ... Vector2 GetScreenPointFromLevelIndices (int rad, int col) // konvertera index till positionsvärden, kol bestämmer x & rad bestämma y returnera ny Vector2 (kol * tileSize-middleOffset.x, rad * -tileSize + middleOffset.y); 

Världspositionen för en kakel bestäms genom att multiplicera nivåindexen med tileSize värde. De middleOffset variabel används för att anpassa nivån mitt på skärmen. Observera att rad värdet multipliceras med ett negativt värde för att stödja den inverterade y axeln i enhet.

4. Sokoban Logic

Nu när vi har visat vår nivå, låt oss fortsätta till spellogiken. Vi måste lyssna på användarnyckelinsignal och flytta hjälte baserat på ingången. Nyckelpressen bestämmer en önskad rörelsesriktning och hjälte behöver flyttas i den riktningen. Det finns olika scenarier att överväga när vi har bestämt den önskade rörelseriktningen. Låt oss säga att kakel bredvid hjälte i denna riktning är tileK.

  • Finns det en kakel i scenen på den positionen, eller ligger den utanför vårt rutnät?
  • Är tileK en gångbar kakel?
  • Är plattan ockuperad av en boll?

Om kakelpositionen ligger utanför gallret behöver vi inte göra någonting. Om kakel är giltig och går att gå, måste vi flytta hjälte till den positionen och uppdatera vår levelData array. Om tileK har en boll, måste vi överväga nästa granne i samma riktning, säg tileL.

  • Är plattan utanför gallret?
  • Är tileL en gångbar kakel?
  • Är tileL upptagen av en boll?

Endast i det fall där kakel är en walkable, icke-upptagen kakel ska vi flytta hjälte och bollen vid kakel till kakel respektive kakel. Efter framgångsrik rörelse behöver vi uppdatera levelData array.

Stödfunktioner

Ovanstående logik innebär att vi behöver veta vilken sida vi har hjälte är för närvarande på. Vi måste också bestämma om en viss kakel har en boll och borde ha tillgång till den bollen. 

För att underlätta detta använder vi en Ordbok kallad åkande som lagrar a GameObject som nyckel och dess matrisindex sparas som Vector2 som värde. I CreateLevel metod, vi fyller åkande när vi skapar hjälte eller boll. När vi har ordlistan befolkade kan vi använda GetOccupantAtPosition att få tillbaka GameObject vid ett givet matrisindex.

Ordbok passagerare; // referens till bollar och hjälte // ... occupants.Add (hjälte, ny vektor2 (i, j)); // lagra nivåindikatorerna för hjälten i dict // ... occupants.Add (boll, ny Vector2 , j)); // lagra nivåindikatorerna för boll i dict // ... privat GameObject GetOccupantAtPosition (Vector2 heroPos) // gå igenom passagerarna för att hitta bollen på en given position GameObject ball; foreach (KeyValuePair par i passagerare) if (pair.Value == heroPos) ball = pair.Key; returboll;  returnera null; 

De IsOccupied metod bestämmer huruvida levelData värdet vid de angivna indexerna representerar en boll.

privata bool IsOccupied (Vector2 objPos) // kolla om det finns en boll vid given positionspositionsavkastning (levelData [(int) objPos.x, (int) objPos.y] == ballTile || levelData [(int) objPos. x, (int) objPos.y] == ballOnDestinationTile); 

Vi behöver också ett sätt att kontrollera om en viss position är inne i vårt rutnät och om den plattan är walkable. De IsValidPosition Metoden kontrollerar de nivåindikatorer som skickas in som parametrar för att avgöra om det faller inom våra nivådimensioner. Det kontrollerar också om vi har en invalidTile som det indexet i levelData.

privat bool IsValidPosition (Vector2 objPos) // kolla om de givna indexen faller inom arraydimensionerna om (objPos.x> -1 && objPos.x-1 && objPos.y

Svara på användarinmatning

I Uppdatering Metod för vårt spelskript, vi kontrollerar användaren keyUp händelser och jämföra med våra inmatningsnycklar som lagras i userInputKeys array. När den erforderliga rörelseriktningen är bestämd kallar vi TryMoveHero metod med riktningen som en parameter.

void Update () om (gameOver) returnera; ApplyUserInput (); // check och använd användarinmatning för att flytta hjälte och bollar privat void ApplyUserInput () if (Input.GetKeyUp (userInputKeys [0])) TryMoveHero (0); // up annat om (Input. GetKeyUp (userInputKeys [1])) TryMoveHero (1); // right annars om (Input.GetKeyUp (userInputKeys [2])) TryMoveHero (2); // down annars om (Input.GetKeyUp (userInputKeys [ 3])) TryMoveHero (3); // left

De TryMoveHero Metod är där vår kärnspelslogik som förklarades i början av det här avsnittet har implementerats. Vänligen gå igenom följande metod noggrant för att se hur logiken implementeras som förklaras ovan.

privat tomt TryMoveHero (int riktning) Vector2 heroPos; Vector2 oldHeroPos; Vector2 nextPos; occupants.TryGetValue (hjälte, ut oldHeroPos); heroPos = GetNextPositionAlong (oldHeroPos, direction); // hitta nästa array position i given riktning om (IsValidPosition (heroPos)) // kolla om det är ett giltigt läge och faller inuti nivån array om (! IsOccupied (heroPos)) // kolla om det är upptaget av en boll // flytta hjälten RemoveOccupant (oldHeroPos); // återställ gamla data i gammal position hero.transform.position = GetScreenPointFromLevelIndices ((int) heroPos.x, (int) heroPos.y ); åkande [hjälte] = heroPos; om (levelData [(int) heroPos.x, (int) heroPos.y] == groundTile) // flyttar till en markplatta nivåData [(int) heroPos.x, (int) heroPos.y] = heroTile;  annars om (levelData [(int) heroPos.x, (int) heroPos.y] == destinationTile) // flytta sig till en destinationskantenivåData [(int) heroPos.x, (int) heroPos.y] = heroOnDestinationTile ;  else // vi har en boll bredvid hjälten, kontrollera om den är tom på andra sidan bollen nextPos = GetNextPositionAlong (heroPos, direction); om (IsValidPosition (nextPos)) if (! IsOccupied (nextPos)) // vi hittade en tom granne, så vi måste flytta både boll & hjälte GameObject ball = GetOccupantAtPosition (heroPos); // hitta bollen i denna position om (boll == null) Debug.Log ("no ball"); RemoveOccupant (heroPos); // bollen ska flyttas först innan du flyttar hjälten ball.transform.position = GetScreenPointFromLevelIndices ((int) nextPos.x, (int) nextPos.y); åkande [bollen] = nextPos; om (levelData [(int) nextPos.x, (int) nextPos.y] == groundTile) levelData [(int) nextPos.x, (int) nextPos.y] = ballTile;  annars om (levelData [(int) nextPos.x, (int) nextPos.y] == destinationTile) levelData [(int) nextPos.x, (int) nextPos.y] = ballOnDestinationTile;  RemoveOccupant (oldHeroPos); // nu flytta hjälten hero.transform.position = GetScreenPointFromLevelIndices ((int) heroPos.x, (int) heroPos.y); åkande [hjälte] = heroPos; om (levelData [(int) heroPos.x, (int) heroPos.y] == groundTile) levelData [(int) heroPos.x, (int) heroPos.y] = heroTile;  annars om (levelData [(int) heroPos.x, (int) heroPos.y] == destinationTile) levelData [(int) heroPos.x, (int) heroPos.y] = heroOnDestinationTile;  CheckCompletion (); // kontrollera om alla bollar har nått destinationer

För att få nästa position längs en viss riktning baserat på en angiven position använder vi GetNextPositionAlong metod. Det handlar bara om att öka eller minska någon av indexen enligt riktningen.

privat Vector2 GetNextPositionAlong (Vector2 objPos, int riktning) switch (riktning) fall 0: objPos.x- = 1; // up break; fall 1: objPos.y + = 1; // right break; fall 2: objPos.x + = 1; // nedbrytning; fall 3: objPos.y- = 1; // left break;  returnera objPos; 

Innan vi flyttar hjälten eller bollen, måste vi rensa sin nuvarande befattning i levelData array. Detta görs med hjälp av RemoveOccupant metod.

privat void RemoveOccupant (Vector2 objPos) om (levelData [(int) objPos.x, (int) objPos.y] == heroTile || levelData [(int) objPos.x, (int) objPos.y] == ballTile ) levelData [(int) objPos.x, (int) objPos.y] = groundTile; // kula flyttar från markplattan annars om (levelData [(int) objPos.x, (int) objPos.y] == HeroOnDestinationTile) levelData [(int) objPos.x, (int) objPos.y] = destinationTile; // hjälte flyttar från destinationsbrickan annars om (levelData [(int) objPos.x, (int) objPos.y] = = ballOnDestinationTile) levelData [(int) objPos.x, (int) objPos.y] = destinationTile; // kula som flyttas från destinationsbrickan

Om vi ​​hittar en heroTile eller ballTile vid det angivna indexet måste vi ställa in det groundTile. Om vi ​​hittar en heroOnDestinationTile eller ballOnDestinationTile då måste vi bestämma det destinationTile.

Nivåavslutning

Nivån är komplett när alla bollar är på sina destinationer.

Efter varje framgångsrik rörelse kallar vi CheckCompletion metod för att se om nivån är klar. Vi gick igenom vårt levelData array och räkna antalet ballOnDestinationTile händelser. Om detta nummer är lika med vårt totala antal bollar bestämda av ballCount, nivån är klar.

privat tomt CheckCompletion () int ballsOnDestination = 0; för (int i = 0; i < rows; i++)  for (int j = 0; j < cols; j++)  if(levelData[i,j]==ballOnDestinationTile) ballsOnDestination++;    if(ballsOnDestination==ballCount) Debug.Log("level complete"); gameOver=true;  

Slutsats

Detta är ett enkelt och effektivt genomförande av sokoban logik. Du kan skapa egna nivåer genom att ändra textfilen eller skapa en ny och ändra levelName variabel för att peka på din nya textfil. 

Nuvarande implementering använder tangentbordet för att styra hjälten. Jag vill bjuda in dig att försöka ändra kontrollen till kretsbaserad så att vi kan stödja beröringsbaserade enheter. Det här skulle innebära att du skulle lägga till 2D-stråkfinnande också om du vill knacka på någon kakel för att leda hjälten där.

Det kommer att finnas en uppföljningstutorial där vi ska undersöka hur det aktuella projektet kan användas för att skapa isometriska och sexkantiga versioner av sokoban med minimala förändringar.