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.
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+.
La oss se hvordan vi har organisert vårt Unity-prosjekt for denne opplæringen.
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.
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.
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.
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.
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
.
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.
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.
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.
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.
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.
Ordbokbeboere; // 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 brukerenKeyUp
hendelser og sammenligne mot våre inngangstaster lagret iuserInputKeys
array. Når den nødvendige bevegelsesretningen er bestemt, kaller viTryMoveHero
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); // leftDe
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 destinasjonerFor å 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 avRemoveOccupant
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 destinasjonsflisHvis vi finner en
heroTile
ellerballTile
på den oppgitte indeksen må vi sette den pågroundTile
. Hvis vi finner enheroOnDestinationTile
ellerballOnDestinationTile
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årlevelData
array og telle antallballOnDestinationTile
forekomster. Hvis dette tallet er lik vårt totale antall baller bestemt avballCount
, 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.