Unity 2D Tile-basert 'Sokoban' Game

Hva du skal skape

I denne opplæringen vil vi undersøke en tilnærming for å lage et sokoban- eller crate-pusher-spill ved hjelp av fliserbasert logikk og et todimensjonalt array for å holde nivådata. Vi bruker Unity for utvikling med C # som skriptspråk. Vennligst last ned kildefilene som følger med denne opplæringen.

1. Sokoban-spillet

Det kan være få blant oss som kanskje ikke har spilt en Sokoban-spillvariant. Den opprinnelige versjonen kan til og med være eldre enn noen av dere. Vennligst sjekk ut wiki siden for noen detaljer. I det vesentlige har vi et karakter- eller brukerstyrt element som må skyve kasser eller lignende elementer på bestemmelsesflisen. 

Nivået består av et kvadratisk eller rektangulært fliser av fliser hvor en flis kan være en ikke-walkable en eller en walkable en. Vi kan gå på walkable fliser og skyve kasser på dem. Spesielle gangbare fliser vil bli merket som destinasjonsfliser, som er hvor kassen til slutt skal hvile for å fullføre nivået. Tegnet styres vanligvis ved hjelp av et tastatur. Når alle kasser har nådd en destinasjon, er nivået fullført.

Tile-basert utvikling betyr i hovedsak at vårt spill består av en rekke fliser spredt på forhåndsbestemt måte. Et nivådataelement representerer hvordan flisene må spres ut for å skape nivå. I vårt tilfelle bruker vi et firkantet fliserett. Du kan lese mer om fliserbaserte spill her på Envato Tuts+.

2. Forberede Unity Project

La oss se hvordan vi har organisert vårt Unity-prosjekt for denne opplæringen.

Kunsten

For dette opplæringsprosjektet bruker vi ikke noen eksterne kunstverdier, men vil bruke sprite-primitiver opprettet med den nyeste Unity-versjonen 2017.1. Bildet nedenfor viser hvordan vi kan lage forskjellige formede sprites innen Unity.

Vi vil bruke Torget Sprite å representere en enkelt flis i vårt sokoban nivå grid. Vi vil bruke Triangel Sprite å representere vår karakter, og vi vil bruke Sirkel Sprite å representere en kasse, eller i dette tilfellet en ball. De normale flisene er hvite, mens destinasjonene har en annen farge som skiller seg ut.

Nivådataene

Vi representerer våre nivådata i form av en todimensjonal matrise som gir den perfekte korrelasjonen mellom logikk og visuelle elementer. Vi bruker en enkel tekstfil for å lagre nivådataene, noe som gjør det lettere for oss å redigere nivået utenfor Unity eller endre nivåer ved å endre filene lastet. De ressurser mappen har a nivå tekstfil, som har standardnivået vårt.

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ået har syv kolonner og fem rader. En verdi på 1 betyr at vi har en bakke på denne posisjonen. En verdi på -1 betyr at det er en ikke-walkable flis, mens en verdi på 0 betyr at det er en destinasjon flis. Verdien 2 representerer vår helt, og 3 representerer en trykkbar ball. Bare ved å se på nivådata, kan vi visualisere hva vårt nivå vil se ut.

3. Opprette et Sokoban-spillnivå

For å holde ting enkelt, og som det ikke er en veldig komplisert logikk, har vi bare en enkelt Sokoban.cs skriptfil for prosjektet, og det er festet til scenekameraet. Vennligst hold den åpen i editoren din mens du følger resten av opplæringen.

Spesialnivådata

Nivådataene representert av 2D-arrayet er ikke bare brukt til å lage det opprinnelige gridet, men brukes også i hele spillet for å spore nivåendringer og spillfremdrift. Dette betyr at nåværende verdier ikke er tilstrekkelige til å representere noen av nivåstatusene i spillet. 

Hver verdi representerer tilstanden til tilhørende fliser i nivået. Vi trenger ytterligere verdier for å representere en ball på destinasjonsflisen og helten på destinasjonsflisen, som henholdsvis er -3 og -2. Disse verdiene kan være noen verdi du tildeler i spillskriptet, ikke nødvendigvis de samme verdiene som vi har brukt her. 

Parsing nivået tekstfilen

Det første trinnet er å laste våre nivådata til et 2D-array fra den eksterne tekstfilen. Vi bruker ParseLevel metode for å laste inn string verdien og del den for å fylle vår levelData 2D-array.

void ParseLevel () TextAsset textFile = Resources.Load (levelName) som TextAsset; streng [] linjer = textFile.text.Split (ny [] '\ r', '\ n', System.StringSplitOptions.RemoveEmptyEntries); // splitt etter ny linje, returstreng [] nums = linjer [0] .Split (nytt [] ','); // splitt av, rader = linjer.Lengde; // antall rader cols = nums.Length; // antall kolonner levelData = new int [rader cols]; for (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;    

Mens du analyserer, bestemmer vi antall rader og kolonner som vårt nivå har når vi fyller i levelData.

Tegningsnivå

Når vi har våre nivådata, kan vi tegne vårt nivå på skjermen. Vi bruker CreateLevel-metoden til å gjøre nettopp det.

void CreateLevel () // beregne offset for å justere hele nivået til scene midt middleOffset.x = kols * tileSize * 0.5f-tileSize * 0.5f; middleOffset.y = p * tileSize * 0.5f-tileSize * 0.5f ;; GameObject fliser; SpriteRenderer sr; GameObject ball; int destinationCount = 0; for (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(); // legge til en sprite renderer sr.sprite = tileSprite; // tildele tegne sprite tile.transform.position = GetScreenPointFromLevelIndices (i, j); // plassere scenen basert på nivåindekser hvis (val == destinationTile)  // hvis det er en destinasjon flis, gi en annen farge sr.color = destinasjonColor; destinationCount ++; // count destinasjoner else if (val == heroTile) // helten fliser helten = ny GameObject ("hero"); hero.transform.localScale = Vector2.one * (tileSize-1); sr = hero.AddComponent(); sr.sprite = heroSprite; sr.sortingOrder = 1; // hero må være over bakken flis sr.color = Color.red; hero.transform.position = GetScreenPointFromLevelIndices (i, j); occupants.Add (helten, ny Vector2 (i, j)); // lagre nivåindeksene til helt i dikt ellers hvis (val == ballTile) // ball flis ballCount ++; // trinn antall baller i nivå ball = ny GameObject ("ball" + ballCount.ToString ()); ball.transform.localScale = Vector2.one * (tileSize-1); sr = ball.AddComponent(); sr.sprite = ballSprite; sr.sortingOrder = 1; // ball må være over bakken flis sr.color = Color.black; ball.transform.position = GetScreenPointFromLevelIndices (i, j); occupants.Add (ball, ny Vector2 (i, j)); // lagre nivåindeksene for ballen i dikt hvis (ballCount> destinationCount) Debug.LogError ("det er flere baller enn destinasjoner"); 

For vårt nivå har vi satt a tileSize verdien av 50, som er lengden på siden av en firkant flis i vårt nivå grid. Vi går gjennom vårt 2D-array og bestemmer verdien som er lagret på hver av de Jeg og j indekser av matrisen. Hvis denne verdien ikke er en invalidTile (-1) så lager vi en ny GameObject oppkalt flis. Vi legger til a SpriteRenderer komponent til flis og tilordne tilsvarende Sprite eller Farge avhengig av verdien på arrayindeksen. 

Mens du plasserer helt eller ball, vi må først lage en flis og deretter lage disse fliser. Som helten og ballen må overlegges bakken, gir vi dem SpriteRenderer en høyere sortingOrder. Alle fliser er tildelt a localScale av tileSize så de er 50x50 i vår scene. 

Vi holder styr på antall baller i vår scene ved hjelp av ballCount variabel, og det skal være det samme eller et høyere antall destinasjonsfliser i vårt nivå for å gjøre det mulig å fullføre nivået. Magien skjer i en enkelt linje med kode hvor vi bestemmer plasseringen av hver flis ved hjelp av GetScreenPointFromLevelIndices (int rad, int col) metode.

// // tile.transform.position = GetScreenPointFromLevelIndices (i, j); // plassere scenen basert på nivåindekser // ... Vector2 GetScreenPointFromLevelIndices (int rad, int col) // konvertere indekser til posisjonsverdier, kol bestemmer x & rad bestemme y returnere ny Vector2 (col * tileSize-middleOffset.x, rad * -tileSize + middleOffset.y); 

Verdensposisjonen til en flis bestemmes ved å multiplisere nivåindeksene med tileSize verdi. De middleOffset variabel brukes til å justere nivået midt på skjermen. Legg merke til at rad verdien multipliseres med en negativ verdi for å støtte den inverterte y akse i enhet.

4. Sokoban Logic

Nå som vi har vist vårt nivå, la oss fortsette til spilllogikken. Vi trenger å lytte etter brukernøkkelpressing og flytte helt basert på inngangen. Tastetrykket bestemmer en ønsket bevegelsesretning, og helt må flyttes i den retningen. Det er ulike scenarier å vurdere når vi har bestemt den nødvendige bevegelsesretningen. La oss si at flisen ved siden av helt i denne retningen er tileK.

  • Er det en flis i scenen på den posisjonen, eller er den utenfor vårt rutenett?
  • Er tileK en walkable flis?
  • Er tileK okkupert av en ball?

Hvis stillingen til tileK er utenfor rutenettet, trenger vi ikke å gjøre noe. Hvis tileK er gyldig og er walkable, må vi flytte helt til den posisjonen og oppdatere vår levelData array. Hvis tileK har en ball, må vi vurdere neste nabo i samme retning, si tileL.

  • Er fliser utenfor rutenettet?
  • Er fliser en walkable flis?
  • Er flisen okkupert av en ball?

Bare i tilfellet der fliser er en walkable, ikke-okkupert flis bør vi flytte helt og ballen på tegl til tegl og flis henholdsvis. Etter vellykket bevegelse må vi oppdatere levelData matrise.

Støttefunksjoner

Ovennevnte logikk betyr at vi må vite hvilken fliser vi har helt er for tiden på. Vi må også avgjøre om en viss flis har en ball og burde ha tilgang til den ballen. 

For å lette dette, bruker vi en Ordbok kalt beboere som lagrer a GameObject som nøkkel og dets arrayindekser lagret som Vektor2 som verdi. I CreateLevel metode, vi befolker beboere når vi lager helt eller ball. Når vi har ordboken fylt, kan vi bruke GetOccupantAtPosition å komme tilbake GameObject ved en gitt arrayindeks.

Ordbok beboere; // referanse til baller og helter // ... beboere.Add (helt, nytt Vector2 (i, j)); // lagre nivåindeksene til helt i dikt // ... beboere. Legg til (ball, ny Vector2 , j)); // lagre nivåindeksene til ball i dict // ... private GameObject GetOccupantAtPosition (Vector2 heroPos) // gå gjennom beboerne for å finne ballen i gitt posisjon GameObject ball; foreach (KeyValuePair par i beboere) if (pair.Value == heroPos) ball = pair.Key; retur ballen;  returnere null; 

De IsOccupied Metode bestemmer om levelData Verdien på de angitte indeksene representerer en ball.

privat bool IsOccupied (Vector2 objPos) // sjekk om det er en ball i gitt array posisjon returnering (levelData [(int) objPos.x, (int) objPos.y] == ballTile || levelData [(int) objPos. x, (int) objPos.y] == ballOnDestinationTile); 

Vi trenger også en måte å kontrollere om en bestemt posisjon er inne i vårt rutenett og hvis den flisen er walkable. De IsValidPosition Metoden kontrollerer nivåindeksene som er sendt inn som parametere for å avgjøre om det faller innenfor våre nivådimensjoner. Det sjekker også om vi har en invalidTile som den indeksen i levelData.

privat bool IsValidPosition (Vector2 objPos) // se om de angitte indeksene faller innenfor array dimensjonene hvis (objPos.x> -1 && objPos.x-1 && objPos.y

Å svare på brukerinngang

I Oppdater Metoden til vårt spillskript, vi sjekker for brukeren KeyUp hendelser og sammenligne mot våre inngangstaster lagret i userInputKeys array. Når den nødvendige bevegelsesretningen er bestemt, kaller vi TryMoveHero metode med retning som en parameter.

ugyldig oppdatering () hvis (gameOver) returnere; ApplyUserInput (); // sjekke og bruk brukerinngang for å flytte helten og ballene private void ApplyUserInput () if (Input.GetKeyUp (userInputKeys [0])) TryMoveHero (0); // up else if (Input. GetKeyUp (userInputKeys [1])) TryMoveHero (1); // right else hvis (Input.GetKeyUp (userInputKeys [2])) TryMoveHero (2); // down else if (Input.GetKeyUp (userInputKeys [ 3])) TryMoveHero (3); // left

De TryMoveHero Metoden er der vår kjernevilkårlogikk forklart i begynnelsen av denne delen er implementert. Vennligst følg nøye gjennom følgende metode for å se hvordan logikken implementeres som forklart ovenfor.

privat tomrom TryMoveHero (int retning) Vector2 heroPos; Vector2 oldHeroPos; Vector2 nextPos; occupants.TryGetValue (helt, ut oldHeroPos); heroPos = GetNextPositionAlong (oldHeroPos, retning); // finn neste array posisjon i gitt retning hvis (IsValidPosition (heroPos)) // sjekk om det er en gyldig posisjon og faller innenfor nivået array hvis (! IsOccupied (heroPos)) // sjekke om det er opptatt av en ball // flytte helten RemoveOccupant (oldHeroPos); // tilbakestille gamle nivådata i gammel stilling hero.transform.position = GetScreenPointFromLevelIndices ((int) heroPos.x, (int) heroPos.y ); beboere [helten] = heroPos; hvis (levelData [(int) heroPos.x, (int) heroPos.y] == groundTile) // flytter på en bakkenivånivåData [(int) heroPos.x, (int) heroPos.y] = heroTile;  ellers hvis (levelData [(int) heroPos.x, (int) heroPos.y] == destinationTile) // flytter på en destinasjon flis levelData [(int) heroPos.x, (int) heroPos.y] = heroOnDestinationTile ;  ellers // vi har en ball ved siden av helten, sjekk om den er tom på den andre siden av ballen nextPos = GetNextPositionAlong (heroPos, retning); hvis (IsValidPosition (nextPos)) if (! IsOccupied (nextPos)) // vi fant tom nabo, så vi må flytte både ball og helt GameObject ball = GetOccupantAtPosition (heroPos); // finn ballen i denne posisjonen hvis (ball == null) Debug.Log ("no ball"); RemoveOccupant (heroPos); // ball bør flyttes først før du flytter helten ball.transform.position = GetScreenPointFromLevelIndices ((int) nextPos.x, (int) nextPos.y); beboere [ball] = nextPos; hvis (levelData [(int) nextPos.x, (int) nextPos.y] == groundTile) levelData [(int) nextPos.x, (int) nextPos.y] = ballTile;  annet hvis (levelData [(int) nextPos.x, (int) nextPos.y] == destinationTile) levelData [(int) nextPos.x, (int) nextPos.y] = ballOnDestinationTile;  RemoveOccupant (oldHeroPos); // nå flytte helt hero.transform.position = GetScreenPointFromLevelIndices ((int) heroPos.x, (int) heroPos.y); beboere [helten] = heroPos; hvis (levelData [(int) heroPos.x, (int) heroPos.y] == groundTile) levelData [(int) heroPos.x, (int) heroPos.y] = heroTile;  annet hvis (levelData [(int) heroPos.x, (int) heroPos.y] == destinationTile) levelData [(int) heroPos.x, (int) heroPos.y] = heroOnDestinationTile;  CheckCompletion (); // Sjekk om alle ballene har nådd destinasjoner

For å få den neste stillingen langs en bestemt retning basert på en gitt posisjon, bruker vi GetNextPositionAlong metode. Det handler bare om å øke eller dekrementere en av indeksene i henhold til retningen.

privat Vector2 GetNextPositionAlong (Vector2 objPos, int retning) bryter (retning) tilfelle 0: objPos.x- = 1; // up break; tilfelle 1: objPos.y + = 1; // høyre pause; tilfelle 2: objPos.x + = 1; // nedbryting; tilfelle 3: objPos.y- = 1; // left break;  returner objPos; 

Før du flytter helt eller ball, må vi rydde deres nåværende posisjon i levelData array. Dette gjøres ved hjelp av RemoveOccupant metode.

private void RemoveOccupant (Vector2 objPos) hvis (levelData [(int) objPos.x, (int) objPos.y] == heroTile || levelData [(int) objPos.x, (int) objPos.y] == ballTile ) levelData [(int) objPos.x, (int) objPos.y] = groundTile; // ball beveger seg fra bakken annet hvis (levelData [(int) objPos.x, (int) objPos.y] == heroOnDestinationTile) levelData [(int) objPos.x, (int) objPos.y] = destinasjonTile; // helt flyttet fra destinasjonsflis ellers hvis (levelData [(int) objPos.x, (int) objPos.y] = = ballOnDestinationTile) levelData [(int) objPos.x, (int) objPos.y] = destinationTile; // ball beveger seg fra destinasjonsflis

Hvis vi finner en heroTile eller ballTile på den oppgitte indeksen må vi sette den på groundTile. Hvis vi finner en heroOnDestinationTile eller ballOnDestinationTile da må vi sette den på destinationTile.

Nivåavslutning

Nivået er fullført når alle ballene er på deres destinasjoner.

Etter hver vellykket bevegelse kaller vi CheckCompletion metode for å se om nivået er fullført. Vi sløyfe gjennom vår levelData array og telle antall ballOnDestinationTile forekomster. Hvis dette tallet er lik vårt totale antall baller bestemt av ballCount, nivået er fullført.

privat ugyldig CheckCompletion () int ballsOnDestination = 0; for (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;  

Konklusjon

Dette er en enkel og effektiv implementering av sokoban logikk. Du kan opprette dine egne nivåer ved å endre tekstfilen eller opprette en ny og endre levelName variabel for å peke på den nye tekstfilen din. 

Den nåværende implementeringen bruker tastaturet til å styre helten. Jeg vil invitere deg til å prøve og endre kontrollen til trykkbasert slik at vi kan støtte berøringsbaserte enheter. Dette ville innebære å legge til noen 2D-banebetingelser også hvis du har lyst på å tappe på en flis for å lede helten der.

Det vil bli en oppfølgingstutorial der vi skal undersøke hvordan det nåværende prosjektet kan brukes til å lage isometriske og sekskantede versjoner av sokoban med små endringer.