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.
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+.
Låt oss se hur vi har organiserat vårt Unity-projekt för denna handledning.
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.
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.
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.
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.
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
.
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.
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.
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.
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.
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.
Ordbokpassagerare; // 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ändarenkeyUp
händelser och jämföra med våra inmatningsnycklar som lagras iuserInputKeys
array. När den erforderliga rörelseriktningen är bestämd kallar viTryMoveHero
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); // leftDe
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 destinationerFö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 avRemoveOccupant
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 destinationsbrickanOm vi hittar en
heroTile
ellerballTile
vid det angivna indexet måste vi ställa in detgroundTile
. Om vi hittar enheroOnDestinationTile
ellerballOnDestinationTile
då måste vi bestämma detdestinationTile
.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årtlevelData
array och räkna antaletballOnDestinationTile
händelser. Om detta nummer är lika med vårt totala antal bollar bestämda avballCount
, 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.