Unity 2D Tile-based 'Sokoban' Game

Wat je gaat creëren

In deze tutorial verkennen we een aanpak voor het maken van een sokoban- of crate-pusher-spel met behulp van op tegels gebaseerde logica en een tweedimensionale array om gegevens op niveau te houden. We gebruiken Unity voor ontwikkeling met C # als de scriptingtaal. Download de bronbestanden die bij deze zelfstudie zijn meegeleverd om te volgen.

1. Het Sokoban-spel

Er zijn er onder ons misschien geen Sokoban-spelvariant gespeeld. De originele versie kan zelfs ouder zijn dan sommigen van jullie. Bekijk de wiki-pagina voor meer informatie. In wezen hebben we een personage of een door de gebruiker bestuurd element dat kratten of soortgelijke elementen op zijn bestemmingspanel moet duwen. 

Het niveau bestaat uit een vierkant of rechthoekig raster met tegels waarbij een tegel een niet-beloopbare of een beloopbare tegel kan zijn. We kunnen over de beloopbare tegels lopen en de kratten erop duwen. Speciale beloopbare tegels worden gemarkeerd als bestemmingspanelen, waar de kist uiteindelijk moet rusten om het niveau te voltooien. Het karakter wordt meestal bestuurd met behulp van een toetsenbord. Zodra alle kratten een bestemmingstegel hebben bereikt, is het niveau voltooid.

Tegelgebaseerde ontwikkeling betekent in feite dat onze game is samengesteld uit een aantal tegels die op een vooraf bepaalde manier zijn uitgespreid. Een niveau data-element geeft weer hoe de tegels moeten worden uitgespreid om ons niveau te creëren. In ons geval gebruiken we een vierkant op tegels gebaseerd raster. Je kunt meer over tegel-gebaseerde spellen lezen op Envato Tuts+.

2. Voorbereiden van het Unity-project

Laten we eens kijken hoe we ons Unity-project voor deze tutorial hebben georganiseerd.

De kunst

Voor dit zelfstudieproject gebruiken we geen externe kunstitems, maar worden de sprite-primitieven gebruikt die zijn gemaakt met de nieuwste Unity-versie 2017.1. De afbeelding hieronder laat zien hoe we verschillende gevormde sprites binnen Unity kunnen maken.

We zullen de gebruiken Plein sprite om een ​​enkele tegel in ons sokoban-raster te vertegenwoordigen. We zullen de gebruiken Driehoek sprite om ons karakter te vertegenwoordigen, en we zullen de Cirkel sprite om een ​​krat te vertegenwoordigen, of in dit geval een bal. De normale grondtegels zijn wit, terwijl de bestemmingspannen een andere kleur hebben om op te vallen.

De niveaugegevens

We zullen onze niveaugegevens voorstellen in de vorm van een tweedimensionale array die de perfecte correlatie biedt tussen de logische en visuele elementen. We gebruiken een eenvoudig tekstbestand om de niveaudata op te slaan, wat het voor ons gemakkelijker maakt om het niveau buiten Unity te bewerken of om niveaus te veranderen door simpelweg de geladen bestanden te veranderen. De Middelen map heeft een niveau tekstbestand, dat ons standaardniveau heeft.

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

Het niveau heeft zeven kolommen en vijf rijen. Een waarde van 1 betekent dat we een grondtegel op die positie hebben. Een waarde van -1 betekent dat het een niet-beloopbare tegel is, terwijl een waarde van 0 betekent dat het een bestemmingstegel is. De waarde 2 vertegenwoordigt onze held, en 3 vertegenwoordigt een duwbare bal. Alleen al door naar de niveau-gegevens te kijken, kunnen we visualiseren hoe ons niveau eruit zou zien.

3. Een Sokoban-spelniveau maken

Om dingen eenvoudig te houden, en omdat het geen erg gecompliceerde logica is, hebben we slechts een single Sokoban.cs scriptbestand voor het project en het is gekoppeld aan de scènecamera. Houd het alstublieft open in uw editor terwijl u de rest van de tutorial volgt.

Speciale niveaugegevens

De niveaugegevens die door de 2D-array worden weergegeven, worden niet alleen gebruikt om het eerste raster te maken, maar worden ook gedurende het spel gebruikt om niveauveranderingen en voortgang van het spel te volgen. Dit betekent dat de huidige waarden niet voldoende zijn om sommige niveaus tijdens het spelen te vertegenwoordigen. 

Elke waarde vertegenwoordigt de staat van de corresponderende tegel in het niveau. We hebben aanvullende waarden nodig voor het weergeven van een bal op de bestemmingspanel en de held op de bestemmingspanel, die respectievelijk zijn -3 en -2. Deze waarden kunnen elke waarde zijn die u in het spelscript toewijst, niet noodzakelijk dezelfde waarden die we hier hebben gebruikt. 

Het niveautekstbestand parseren

De eerste stap is om onze niveaudata in een 2D-array te laden vanuit het externe tekstbestand. Wij gebruiken de ParseLevel methode om de draad waarde en deel het om ons te vullen levelData 2D-array.

void ParseLevel () TextAsset textFile = Resources.Load (levelName) als TextAsset; string [] lines = textFile.text.Split (nieuw [] '\ r', '\ n', System.StringSplitOptions.RemoveEmptyEntries); // gesplitst op nieuwe regel, return string [] nums = lines [0] .Split (nieuw [] ','); // gedeeld door, rijen = lijnen. Lengte; // aantal rijen kolommen = aantal.Lengte; // aantal kolommen niveauData = nieuwe int [rijen, kolommen]; voor (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;    

Tijdens het parseren, bepalen we het aantal rijen en kolommen dat ons niveau heeft wanneer we onze niveau vullen levelData.

Tekenniveau

Zodra we onze niveau-gegevens hebben, kunnen we ons niveau op het scherm tekenen. We gebruiken de CreateLevel-methode om dat te doen.

void CreateLevel () // bereken de offset om het hele niveau uit te lijnen met scène middle middleOffset.x = cols * tileSize * 0.5f-tileSize * 0.5f; middleOffset.y = tr * * tileSize 0.5f-tileSize * 0.5f ;; GameObject-tegel; SpriteRenderer sr; GameObject-bal; int destinationCount = 0; voor (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(); // een sprite-renderer toevoegen sr.sprite = tileSprite; // tegel sprite toewijzen tile.transform.position = GetScreenPointFromLevelIndices (i, j); // plaats in scène op basis van niveau-indices if (val == destinationTile)  // als het een doeltegel is, geef dan een andere kleur sr.color = destinationColor; destinationCount ++; // count destinations else if (val == heroTile) // hero held hero = new GameObject ("hero"); hero.transform.localScale = Vector2.one * (tileSize-1); sr = hero.AddComponent(); sr.sprite = heroSprite; sr.sortingOrder = 1; // held moet over de grondtegel zijn sr.color = Color.red; hero.transform.position = GetScreenPointFromLevelIndices (i, j); occupants.Add (hero, new Vector2 (i, j)); // sla de levelindices van hero op in dict else if (val == ballTile) // ball tile ballCount ++; // verhoog het aantal ballen in level ball = nieuw GameObject ("ball" + ballCount.ToString ()); ball.transform.localScale = Vector2.one * (tileSize-1); sr = ball.AddComponent(); sr.sprite = ballSprite; sr.sortingOrder = 1; // bal moet over de grond worden geplaatst sr.color = Color.black; ball.transform.position = GetScreenPointFromLevelIndices (i, j); occupants.Add (bal, nieuwe Vector2 (i, j)); // sla de niveau-indexen van de bal op in dict if (ballCount> destinationCount) Debug.LogError ("er zijn meer ballen dan bestemmingen"); 

Voor ons niveau hebben we een tileSize waarde van 50, welke de lengte is van de zijkant van een vierkante tegel in ons niveau raster. We doorlopen onze 2D-array en bepalen de waarde die is opgeslagen in elk van de ik en j indices van de array. Als deze waarde geen is invalidTile (-1) dan maken we een nieuwe GameObject genaamd tegel. We hechten een SpriteRenderer component voor tegel en wijs het overeenkomstige toe sprite of Kleur afhankelijk van de waarde in de array-index. 

Tijdens het plaatsen van de held of de bal, we moeten eerst een grondtegel maken en vervolgens deze tegels maken. Omdat de held en bal de grondtegel moeten overlappen, geven we hun SpriteRenderer een hogere sortingOrder. Alle tegels krijgen een localScale van tileSize dus zij zijn 50x50 in onze scène. 

We houden het aantal ballen in onze scène bij met behulp van de ballCount variabele, en er moet hetzelfde of een hoger aantal bestemmingspijlen in ons niveau zijn om het voltooien van niveaus mogelijk te maken. De magie gebeurt in een enkele coderegel waarbij we de positie van elke tegel bepalen met behulp van de GetScreenPointFromLevelIndices (int row, int col) methode.

// ... tile.transform.position = GetScreenPointFromLevelIndices (i, j); // plaats in scene op basis van niveau-indices // ... Vector2 GetScreenPointFromLevelIndices (int row, int col) // indices omzetten naar positiewaarden, col bepaalt x & rij bepalen y retourneer nieuwe Vector2 (col * tileSize-middleOffset.x, row * -tileSize + middleOffset.y); 

De wereldpositie van een tegel wordt bepaald door de niveau-indices te vermenigvuldigen met de tileSize waarde. De middleOffset variabele wordt gebruikt om het niveau in het midden van het scherm uit te lijnen. Merk op dat de rij waarde wordt vermenigvuldigd met een negatieve waarde om de omgekeerde te ondersteunen Y as in Unity.

4. Sokoban Logic

Nu we ons niveau hebben weergegeven, gaan we verder met de spellogica. We moeten luisteren naar invoer van gebruikerssleutelpersen en de held gebaseerd op de invoer. De toetsdruk bepaalt de vereiste bewegingsrichting en de held moet in die richting worden verplaatst. Er zijn verschillende scenario's om te overwegen als we eenmaal de vereiste bewegingsrichting hebben bepaald. Laten we zeggen dat de tegel er naast staat held in deze richting is tileK.

  • Is er een tegel in de scène op die positie of bevindt deze zich buiten ons raster?
  • Is tegel een betegelbare tegel?
  • Wordt tileK bezet door een bal?

Als de positie van tileK buiten het raster ligt, hoeven we niets te doen. Als tileK geldig is en beloopbaar is, moeten we verplaatsen held naar die positie en update onze levelData matrix. Als tileK een bal heeft, dan moeten we de volgende buur in dezelfde richting beschouwen, zeg maar tileL.

  • Staat tegelL buiten het raster?
  • Is tegelL een beloopbare tegel?
  • Wordt tegel bezet door een bal?

Alleen in het geval dat tileL een beloopbare, niet-gebruikte tegel is, moeten we de. Verplaatsen held en de bal op tileK op respectievelijk tileK en tileL. Na een succesvolle beweging, moeten we de levelData rangschikking.

Ondersteunende functies

De bovenstaande logica betekent dat we moeten weten welke tegels we hebben held is momenteel op. We moeten ook bepalen of een bepaalde tegel een bal heeft en toegang moet hebben tot die bal. 

Om dit te vergemakkelijken, gebruiken we een Woordenboek riep inzittenden welke winkels een GameObject als sleutel en de array-indexen worden opgeslagen als Vector2 als waarde. In de CreateLevel methode, we vullen inzittenden wanneer we creëren held of bal. Zodra we het woordenboek hebben ingevuld, kunnen we de GetOccupantAtPosition om de. terug te krijgen GameObject bij een gegeven array-index.

Woordenboek inzittenden; // verwijzing naar ballen & held // ... bewoners. Toevoegen (held, nieuwe Vector2 (i, j)) / // sla de niveau-indexen van de held op in dict // ... bewoners. Toevoegen (bal, nieuwe Vector2 (i , j)); // sla de niveau-indexen van de bal op in dict // ... private GameObject GetOccupantAtPosition (Vector2 heroPos) // loop door de inzittenden om de bal op een bepaalde positie te vinden GameObject-bal; foreach (KeyValuePair paar in gebruikers) if (pair.Value == heroPos) ball = pair.Key; terugkeer bal;  return null; 

De Is bezet methode bepaalt of het levelData waarde bij de opgegeven indices vertegenwoordigt een bal.

private bool IsOccupied (Vector2 objPos) // controleer of er een bal is bij gegeven arraypositie-teruggave (levelData [(int) objPos.x, (int) objPos.y] == ballTile || levelData [(int) objPos. x, (int) objPos.y] == ballOnDestinationTile); 

We hebben ook een manier nodig om te controleren of een bepaalde positie in ons rooster ligt en of die tegel beloopbaar is. De IsValidPosition methode controleert de niveau-indices die als parameters zijn doorgegeven om te bepalen of deze binnen onze niveaudimensies vallen. Het controleert ook of we een hebben invalidTile zoals die index in de levelData.

private bool IsValidPosition (Vector2 objPos) // controleer of de gegeven indices binnen de matrixdimensies vallen als (objPos.x> -1 && objPos.x-1 && objPos.y

Reageren op gebruikersinvoer

In de Bijwerken methode van ons spelscript controleren we op de gebruiker KeyUp evenementen en vergelijken met onze invoertoetsen die zijn opgeslagen in de userInputKeys matrix. Nadat de vereiste bewegingsrichting is bepaald, noemen we de TryMoveHero methode met de richting als een parameter.

void Update () if (gameOver) return; ApplyUserInput (); // controleer en gebruik gebruikersinvoer om held en ballen te verplaatsen private void ApplyUserInput () if (Input.GetKeyUp (userInputKeys [0])) TryMoveHero (0); // up else if (Input. GetKeyUp (userInputKeys [1])) TryMoveHero (1); // right else if (Input.GetKeyUp (userInputKeys [2])) TryMoveHero (2); // down else if (Input.GetKeyUp (userInputKeys [ 3])) TryMoveHero (3); // left

De TryMoveHero methode is waar onze kernlogica, uitgelegd aan het begin van dit hoofdstuk, geïmplementeerd is. Ga alsjeblieft zorgvuldig door de volgende methode om te zien hoe de logica wordt geïmplementeerd zoals hierboven uitgelegd.

private void TryMoveHero (int-direction) Vector2 heroPos; Vector2 oldHeroPos; Vector2 nextPos; occupants.TryGetValue (hero, out oldHeroPos); heroPos = GetNextPositionAlong (oldHeroPos, direction); // zoek de volgende arraypositie in de opgegeven richting if (IsValidPosition (heroPos)) // controleer of het een geldige positie is & valt binnen de levelreeks als (! IsOccupied (heroPos)) // controleer of deze bezet is door een bal // verplaats held RemoveOccupant (oldHeroPos); // reset oude levelgegevens op oude positie hero.transform.position = GetScreenPointFromLevelIndices ((int) heroPos.x, (int) heroPos.y ); inzittenden [held] = heroPos; if (levelData [(int) heroPos.x, (int) heroPos.y] == groundTile) // verplaatsen naar een ground tile levelData [(int) heroPos.x, (int) heroPos.y] = heroTile;  else if (levelData [(int) heroPos.x, (int) heroPos.y] == destinationTile) // verplaatsen naar een bestemmingspanelniveauData [(int) heroPos.x, (int) heroPos.y] = heroOnDestinationTile ;  else // we hebben een bal naast held, controleer of het leeg is aan de andere kant van de bal nextPos = GetNextPositionAlong (heroPos, richting); if (IsValidPosition (nextPos)) if (! IsOccupied (nextPos)) // we hebben lege buur gevonden, dus we moeten zowel de bal als de held verplaatsen GameObject ball = GetOccupantAtPosition (heroPos); // vind de bal op deze positie als (ball == null) Debug.Log ("geen bal"); RemoveOccupant (heroPos); // bal moet eerst worden verplaatst voordat de held wordt verplaatst ball.transform.position = GetScreenPointFromLevelIndices ((int) nextPos.x, (int) nextPos.y); inzittenden [bal] = nextPos; if (levelData [(int) nextPos.x, (int) nextPos.y] == groundTile) levelData [(int) nextPos.x, (int) nextPos.y] = ballTile;  else if (levelData [(int) nextPos.x, (int) nextPos.y] == destinationTile) levelData [(int) nextPos.x, (int) nextPos.y] = ballOnDestinationTile;  RemoveOccupant (oldHeroPos); // verplaats nu hero hero.transform.position = GetScreenPointFromLevelIndices ((int) heroPos.x, (int) heroPos.y); inzittenden [held] = heroPos; if (levelData [(int) heroPos.x, (int) heroPos.y] == groundTile) levelData [(int) heroPos.x, (int) heroPos.y] = heroTile;  else if (levelData [(int) heroPos.x, (int) heroPos.y] == destinationTile) levelData [(int) heroPos.x, (int) heroPos.y] = heroOnDestinationTile;  CheckCompletion (); // controleer of alle ballen bestemmingen hebben bereikt

Om de volgende positie langs een bepaalde richting te krijgen op basis van een opgegeven positie, gebruiken we de GetNextPositionAlong methode. Het is gewoon een kwestie van het verhogen of verlagen van een van de indices volgens de richting.

private Vector2 GetNextPositionAlong (Vector2 objPos, int direction) switch (direction) case 0: objPos.x- = 1; // up break; case 1: objPos.y + = 1; // right break; case 2: objPos.x + = 1; // down break; case 3: objPos.y- = 1; // left break;  retourneer objPos; 

Voordat we held of bal verplaatsen, moeten we hun huidige positie in de levelData matrix. Dit wordt gedaan met behulp van de RemoveOccupant methode.

private void RemoveOccupant (Vector2 objPos) if (levelData [(int) objPos.x, (int) objPos.y] == heroTile || levelData [(int) objPos.x, (int) objPos.y] == ballTile ) levelData [(int) objPos.x, (int) objPos.y] = groundTile; // bal die van de grondtegel beweegt else if (levelData [(int) objPos.x, (int) objPos.y] == heroOnDestinationTile) levelData [(int) objPos.x, (int) objPos.y] = destinationTile; // hero moving from destination tile else if (levelData [(int) objPos.x, (int) objPos.y] = = ballOnDestinationTile) levelData [(int) objPos.x, (int) objPos.y] = destinationTile; // bal die van de bestemmingstegel beweegt

Als we een vinden heroTile of ballTile bij de opgegeven index moeten we dit instellen groundTile. Als we een vinden heroOnDestinationTile of ballOnDestinationTile dan moeten we het instellen destinationTile.

Voltooiing van het level

Het niveau is voltooid als alle ballen op hun bestemming zijn.

Na elke succesvolle beweging, noemen we de CheckCompletion methode om te zien of het niveau is voltooid. We lopen door onze levelData array en tel het aantal ballOnDestinationTile voorvallen. Als dit aantal gelijk is aan ons totaal aantal ballen bepaald door ballCount, het niveau is voltooid.

private void CheckCompletion () int ballsOnDestination = 0; voor (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;  

Conclusie

Dit is een eenvoudige en efficiënte implementatie van sokoban-logica. U kunt uw eigen niveaus maken door het tekstbestand te wijzigen of een nieuw te maken en het te wijzigen levelName variabele om naar uw nieuwe tekstbestand te wijzen. 

De huidige implementatie gebruikt het toetsenbord om de held te besturen. Ik zou je willen uitnodigen om het besturingselement te veranderen in tap-gebaseerd, zodat we op aanraking gebaseerde apparaten kunnen ondersteunen. Dit zou ook het toevoegen van 2D-padvinden impliceren, als je op een tegel tikt om de held daar te leiden.

Er zal een vervolg-zelfstudie zijn waarin we zullen onderzoeken hoe het huidige project kan worden gebruikt om isometrische en zeshoekige versies van sokoban te maken met minimale veranderingen.