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.
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+.
Laten we eens kijken hoe we ons Unity-project voor deze tutorial hebben georganiseerd.
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.
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.
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.
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.
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
.
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.
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.
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.
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.
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.
Woordenboekinzittenden; // 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 gebruikerKeyUp
evenementen en vergelijken met onze invoertoetsen die zijn opgeslagen in deuserInputKeys
matrix. Nadat de vereiste bewegingsrichting is bepaald, noemen we deTryMoveHero
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); // leftDe
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 bereiktOm 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 deRemoveOccupant
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 beweegtAls we een vinden
heroTile
ofballTile
bij de opgegeven index moeten we dit instellengroundTile
. Als we een vindenheroOnDestinationTile
ofballOnDestinationTile
dan moeten we het instellendestinationTile
.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 onzelevelData
array en tel het aantalballOnDestinationTile
voorvallen. Als dit aantal gelijk is aan ons totaal aantal ballen bepaald doorballCount
, 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.