Unity 2D Tile-basiertes 'Sokoban'-Spiel
German (Deutsch) translation by Valentina (you can also view the original English article)



In diesem Tutorial werden wir einen Ansatz zum Erstellen eines Sokoban- oder Crate-Pusher-Spiels untersuchen, bei dem kachelbasierte Logik und ein zweidimensionales Array zum Speichern von Level-Daten verwendet werden. Wir verwenden Unity für die Entwicklung mit C# als Skriptsprache. Bitte laden Sie die Quelldateien herunter, die in diesem Tutorial enthalten sind.
1. Das Sokoban-Spiel
Es gibt vielleicht nur wenige unter uns, die möglicherweise keine Sokoban-Spielvariante gespielt haben. Die Originalversion ist möglicherweise sogar älter als einige von Ihnen. Bitte überprüfen Sie die Wiki-Seite für einige Details. Im Wesentlichen haben wir einen Charakter oder ein benutzergesteuertes Element, das Kisten oder ähnliche Elemente auf seine Zielkachel schieben muss.
Die Ebene besteht aus einem quadratischen oder rechteckigen Raster von Kacheln, wobei eine Kachel nicht begehbar oder begehbar sein kann. Wir können auf den begehbaren Fliesen gehen und die Kisten darauf schieben. Spezielle begehbare Kacheln werden als Zielkacheln markiert. Dort sollte die Kiste schließlich ruhen, um das Level zu beenden. Das Zeichen wird normalerweise über eine Tastatur gesteuert. Sobald alle Kisten ein Zielplättchen erreicht haben, ist das Level abgeschlossen.
Kachelbasierte Entwicklung bedeutet im Wesentlichen, dass unser Spiel aus einer Reihe von Kacheln besteht, die auf eine vorgegebene Weise verteilt sind. Ein Level-Datenelement gibt an, wie die Kacheln verteilt werden müssten, um unser Level zu erstellen. In unserem Fall verwenden wir ein quadratisches Raster. Weitere Informationen zu kachelbasierten Spielen finden Sie hier auf Envato Tuts+.
2. Vorbereiten des Unity-Projekts
Mal sehen, wie wir unser Unity-Projekt für dieses Tutorial organisiert haben.
Die Kunst
Für dieses Tutorial-Projekt verwenden wir keine externen Kunstobjekte, sondern verwenden die Sprite-Grundelemente, die mit der neuesten Unity-Version 2017.1 erstellt wurden. Das Bild unten zeigt, wie wir in Unity unterschiedlich geformte Sprites erstellen können.



Wir werden das Square-Sprite verwenden, um eine einzelne Kachel in unserem Sokoban-Level-Raster darzustellen. Wir werden das Dreieck-Sprite verwenden, um unseren Charakter darzustellen, und wir werden das Kreis-Sprite verwenden, um eine Kiste oder in diesem Fall einen Ball darzustellen. Die normalen Bodenkacheln sind weiß, während die Zielkacheln eine andere Farbe haben, um hervorzuheben.
Die Level-Daten
Wir werden unsere Level-Daten in Form eines zweidimensionalen Arrays darstellen, das die perfekte Korrelation zwischen den logischen und visuellen Elementen bietet. Wir verwenden eine einfache Textdatei, um die Ebenendaten zu speichern. Dies erleichtert es uns, die Ebene außerhalb von Unity zu bearbeiten oder Ebenen zu ändern, indem Sie einfach die geladenen Dateien ändern. Der Ordner "Ressourcen" enthält eine Textdatei auf level
, die unsere Standard-Level hat.
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
Die Ebene hat sieben Spalten und fünf Zeilen. Ein Wert von 1
bedeutet, dass wir an dieser Position eine Bodenplatte haben. Ein Wert von -1
bedeutet, dass es sich um eine nicht begehbare Kachel handelt, während ein Wert von 0
bedeutet, dass es sich um eine Zielkachel handelt. Der Wert 2
repräsentiert unseren Helden und 3
repräsentiert einen schiebbaren Ball. Wenn wir uns nur die Level-Daten ansehen, können wir uns vorstellen, wie unser Level aussehen würde.
3. Erstellen eines Sokoban-Spiel-Levels
Um die Dinge einfach zu halten, und da dies keine sehr komplizierte Logik ist, haben wir nur eine einzige Sokoban.cs
-Skriptdatei für das Projekt, die an die Szenenkamera angehängt ist. Bitte lassen Sie es in Ihrem Editor offen, während Sie dem Rest des Tutorials folgen.
Spezielle Level-Daten
Die durch das 2D-Array dargestellten Level-Daten werden nicht nur zum Erstellen des anfänglichen Rasters verwendet, sondern auch während des Spiels, um Level-Änderungen und Spielfortschritte zu verfolgen. Dies bedeutet, dass die aktuellen Werte nicht ausreichen, um einige der Levelzustände während des Spiels darzustellen.
Jeder Wert repräsentiert den Status der entsprechenden Kachel in der Ebene. Wir benötigen zusätzliche Werte für die Darstellung eines Balls auf dem Zielplättchen und des Helden auf dem Zielplättchen, die jeweils -3
und -2
sind. Diese Werte können beliebige Werte sein, die Sie im Spieleskript zuweisen, nicht unbedingt dieselben Werte, die wir hier verwendet haben.
Analysieren der Level-Textdatei
Der erste Schritt besteht darin, unsere Level-Daten aus der externen Textdatei in ein 2D-Array zu laden. Wir verwenden die ParseLevel
-Methode, um den string
-Wert zu laden und zu teilen, um unser 2D-Array levelData
zu füllen.
void ParseLevel(){ TextAsset textFile = Resources.Load (levelName) as TextAsset; string[] lines = textFile.text.Split (new[] { '\r', '\n' }, System.StringSplitOptions.RemoveEmptyEntries);//split by new line, return string[] nums = lines[0].Split(new[] { ',' });//split by , rows=lines.Length;//number of rows cols=nums.Length;//number of columns levelData = new int[rows, 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; } } } }
Während des Parsens bestimmen wir die Anzahl der Zeilen und Spalten, die unsere Ebene hat, wenn wir unsere levelData
füllen.
Zeichnung-Level
Sobald wir unsere Leveldaten haben, können wir unseren Level auf dem Bildschirm zeichnen. Wir verwenden dazu die CreateLevel-Methode.
void CreateLevel(){ //calculate the offset to align whole level to scene middle middleOffset.x=cols*tileSize*0.5f-tileSize*0.5f; middleOffset.y=rows*tileSize*0.5f-tileSize*0.5f;; GameObject tile; 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<SpriteRenderer>();//add a sprite renderer sr.sprite=tileSprite;//assign tile sprite tile.transform.position=GetScreenPointFromLevelIndices(i,j);//place in scene based on level indices if(val==destinationTile){//if it is a destination tile, give different color sr.color=destinationColor; destinationCount++;//count destinations }else{ if(val==heroTile){//the hero tile hero = new GameObject("hero"); hero.transform.localScale=Vector2.one*(tileSize-1); sr = hero.AddComponent<SpriteRenderer>(); sr.sprite=heroSprite; sr.sortingOrder=1;//hero needs to be over the ground tile sr.color=Color.red; hero.transform.position=GetScreenPointFromLevelIndices(i,j); occupants.Add(hero, new Vector2(i,j));//store the level indices of hero in dict }else if(val==ballTile){//ball tile ballCount++;//increment number of balls in level ball = new GameObject("ball"+ballCount.ToString()); ball.transform.localScale=Vector2.one*(tileSize-1); sr = ball.AddComponent<SpriteRenderer>(); sr.sprite=ballSprite; sr.sortingOrder=1;//ball needs to be over the ground tile sr.color=Color.black; ball.transform.position=GetScreenPointFromLevelIndices(i,j); occupants.Add(ball, new Vector2(i,j));//store the level indices of ball in dict } } } } } if(ballCount>destinationCount)Debug.LogError("there are more balls than destinations"); }
Für unser Level haben wir einen tileSize
-Wert von 50
festgelegt. Dies ist die Länge der Seite einer quadratischen Kachel in unserem Level-Raster. Wir durchlaufen unser 2D-Array und bestimmen den Wert, der an jedem der i
- und j
-Indizes des Arrays gespeichert ist. Wenn dieser Wert keine invalidTile
(-1) ist, erstellen wir ein neues GameObject
mit dem Namen tile
. Wir fügen tile
eine SpriteRenderer
-Komponente hinzu und weisen das entsprechende Sprite
oder die entsprechende Color
abhängig vom Wert am Array-Index zu.
Während wir den hero
oder den ball
platzieren, müssen wir zuerst ein Bodenplättchen erstellen und dann diese Plättchen erstellen. Da der Held und der Ball über dem Bodenplättchen liegen müssen, geben wir ihrem SpriteRenderer
eine höhere sortingOrder
. Allen Kacheln wird eine localScale
von tileSize
zugewiesen, sodass sie in unserer Szene 50x50
sind.
Wir verfolgen die Anzahl der Bälle in unserer Szene mithilfe der Variablen ballCount
. In unserem Level sollte sich die gleiche oder eine höhere Anzahl von Zielplättchen befinden, um den Abschluss des Levels zu ermöglichen. Die Magie geschieht in einer einzelnen Codezeile, in der wir die Position jeder Kachel mithilfe der GetScreenPointFromLevelIndices-Methode(int row,int col)
bestimmen.
//... tile.transform.position=GetScreenPointFromLevelIndices(i,j);//place in scene based on level indices //... Vector2 GetScreenPointFromLevelIndices(int row,int col){ //converting indices to position values, col determines x & row determine y return new Vector2(col*tileSize-middleOffset.x,row*-tileSize+middleOffset.y); }
Die Weltposition einer Kachel wird durch Multiplizieren der Ebenenindizes mit dem Wert von tileSize
bestimmt. Die Variable middleOffset
wird verwendet, um die Ebene in der Mitte des Bildschirms auszurichten. Beachten Sie, dass der row
-Wert mit einem negativen Wert multipliziert wird, um die invertierte y
-Achse in Unity zu unterstützen.
4. Sokoban-Logik
Nachdem wir unser Level angezeigt haben, fahren wir mit der Spiellogik fort. Wir müssen auf die Eingabe der Benutzertaste warten und den hero
basierend auf der Eingabe bewegen. Der Tastendruck bestimmt eine erforderliche Bewegungsrichtung, und der hero
muss in diese Richtung bewegt werden. Sobald wir die erforderliche Bewegungsrichtung festgelegt haben, sind verschiedene Szenarien zu berücksichtigen. Nehmen wir an, das Plättchen neben dem hero
in dieser Richtung ist tileK.
- Befindet sich an dieser Position eine Kachel in der Szene oder befindet sie sich außerhalb unseres Gitters?
- Ist tileK eine begehbare Fliese?
- Ist tileK von einem Ball besetzt?
Wenn sich die Position von tileK außerhalb des Rasters befindet, müssen wir nichts tun. Wenn tileK gültig und begehbar ist, müssen wir den hero
an diese Position bringen und unser levelData
-Array aktualisieren. Wenn tileK einen Ball hat, müssen wir den nächsten Nachbarn in die gleiche Richtung betrachten, sagen wir tileL.
- Befindet sich tileL außerhalb des Rasters?
- Ist Fliese eine begehbare Fliese?
- Ist die Fliese von einem Ball besetzt?
Nur in dem Fall, in dem TileL ein begehbares, nicht besetztes Plättchen ist, sollten wir den hero
und den Ball bei TileK auf TileK bzw. TileL bewegen. Nach erfolgreicher Verschiebung müssen wir das levelData
-Array aktualisieren.
Unterstützende Funktionen
Die obige Logik bedeutet, dass wir wissen müssen, auf welchem Plättchen sich unser hero
gerade befindet. Wir müssen auch feststellen, ob ein bestimmtes Plättchen einen Ball hat und Zugriff auf diesen Ball haben soll.
Um dies zu erleichtern, verwenden wir ein Dictionary
namens occupants
, in dem ein GameObject
als Schlüssel und seine Array-Indizes als Vector2
als Wert gespeichert sind. In der CreateLevel
-Methode füllen wir occupants
, wenn wir hero
oder Bälle erstellen. Sobald wir das Wörterbuch ausgefüllt haben, können wir GetOccupantAtPosition
verwenden, um das GameObject
an einem bestimmten Array-Index zurückzugewinnen.
Dictionary<GameObject,Vector2> occupants;//reference to balls & hero //.. occupants.Add(hero, new Vector2(i,j));//store the level indices of hero in dict //.. occupants.Add(ball, new Vector2(i,j));//store the level indices of ball in dict //.. private GameObject GetOccupantAtPosition(Vector2 heroPos) {//loop through the occupants to find the ball at given position GameObject ball; foreach (KeyValuePair<GameObject, Vector2> pair in occupants) { if (pair.Value == heroPos) { ball = pair.Key; return ball; } } return null; }
Die IsOccupied
-Methode bestimmt, ob der levelData
-Wert an den angegebenen Indizes eine Kugel darstellt.
private bool IsOccupied(Vector2 objPos) {//check if there is a ball at given array position return (levelData[(int)objPos.x,(int)objPos.y]==ballTile || levelData[(int)objPos.x,(int)objPos.y]==ballOnDestinationTile); }
Wir müssen auch überprüfen können, ob sich eine bestimmte Position in unserem Raster befindet und ob diese Kachel begehbar ist. Die IsValidPosition
-Methode überprüft die als Parameter übergebenen Ebenenindizes, um festzustellen, ob sie in unsere Ebenendimensionen fallen. Es wird auch geprüft, ob wir eine invalidTile
als diesen Index in den levelData
haben.
private bool IsValidPosition(Vector2 objPos) {//check if the given indices fall within the array dimensions if(objPos.x>-1&&objPos.x<rows&&objPos.y>-1&&objPos.y<cols){ return levelData[(int)objPos.x,(int)objPos.y]!=invalidTile; }else return false; }
Auf Benutzereingaben reagieren
In der Update
-Methode unseres Spielskripts suchen wir nach den KeyUp
-Ereignissen des Benutzers und vergleichen sie mit unseren im Array userInputKeys
gespeicherten Eingabeschlüsseln. Sobald die gewünschte Bewegungsrichtung bestimmt ist, rufen wir die TryMoveHero
-Methode mit der Richtung als Parameter auf.
void Update(){ if(gameOver)return; ApplyUserInput();//check & use user input to move hero and balls } 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 } }
In der TryMoveHero
-Methode wird unsere zu Beginn dieses Abschnitts erläuterte Kernspiellogik implementiert. Bitte gehen Sie die folgende Methode sorgfältig durch, um zu sehen, wie die Logik wie oben erläutert implementiert wird.
private void TryMoveHero(int direction) { Vector2 heroPos; Vector2 oldHeroPos; Vector2 nextPos; occupants.TryGetValue(hero,out oldHeroPos); heroPos=GetNextPositionAlong(oldHeroPos,direction);//find the next array position in given direction if(IsValidPosition(heroPos)){//check if it is a valid position & falls inside the level array if(!IsOccupied(heroPos)){//check if it is occupied by a ball //move hero RemoveOccupant(oldHeroPos);//reset old level data at old position hero.transform.position=GetScreenPointFromLevelIndices((int)heroPos.x,(int)heroPos.y); occupants[hero]=heroPos; if(levelData[(int)heroPos.x,(int)heroPos.y]==groundTile){//moving onto a ground tile levelData[(int)heroPos.x,(int)heroPos.y]=heroTile; }else if(levelData[(int)heroPos.x,(int)heroPos.y]==destinationTile){//moving onto a destination tile levelData[(int)heroPos.x,(int)heroPos.y]=heroOnDestinationTile; } }else{ //we have a ball next to hero, check if it is empty on the other side of the ball nextPos=GetNextPositionAlong(heroPos,direction); if(IsValidPosition(nextPos)){ if(!IsOccupied(nextPos)){//we found empty neighbor, so we need to move both ball & hero GameObject ball=GetOccupantAtPosition(heroPos);//find the ball at this position if(ball==null)Debug.Log("no ball"); RemoveOccupant(heroPos);//ball should be moved first before moving the hero ball.transform.position=GetScreenPointFromLevelIndices((int)nextPos.x,(int)nextPos.y); occupants[ball]=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);//now move hero hero.transform.position=GetScreenPointFromLevelIndices((int)heroPos.x,(int)heroPos.y); occupants[hero]=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();//check if all balls have reached destinations } }
Um die nächste Position entlang einer bestimmten Richtung basierend auf einer angegebenen Position zu erhalten, verwenden wir die GetNextPositionAlong
-Methode. Es geht nur darum, einen der Indizes entsprechend der Richtung zu erhöhen oder zu verringern.
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; } return objPos; }
Bevor wir Helden oder Bälle bewegen, müssen wir ihre aktuell belegte Position im levelData
-Array löschen. Dies erfolgt mit der 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;//ball moving from ground tile }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;//ball moving from destination tile } }
Wenn wir am angegebenen Index eine heroTile
oder ballTile
finden, müssen wir sie auf groundTile
setzen. Wenn wir eine heroOnDestinationTile
oder eine ballOnDestinationTile
finden, müssen wir sie auf destinationTile
setzen.
Level-Abschluss
Das Level ist beendet, wenn alle Bälle an ihrem Ziel sind.



Nach jeder erfolgreichen Bewegung rufen wir die CheckCompletion
-Methode auf, um festzustellen, ob das Level abgeschlossen ist. Wir durchlaufen unser levelData
-Array und zählen die Anzahl der Vorkommen von ballOnDestinationTile
. Wenn diese Anzahl unserer von ballCount
ermittelten Gesamtzahl von Bällen entspricht, ist das Level abgeschlossen.
private void 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; } }
Abschluss
Dies ist eine einfache und effiziente Implementierung der Sokoban-Logik. Sie können Ihre eigenen Ebenen erstellen, indem Sie die Textdatei ändern oder eine neue erstellen und die Variable levelName
so ändern, dass sie auf Ihre neue Textdatei verweist.
Die aktuelle Implementierung verwendet die Tastatur, um den Helden zu steuern. Ich möchte Sie einladen, zu versuchen, das Steuerelement auf tippenbasiert zu ändern, damit wir berührungsbasierte Geräte unterstützen können. Dies würde auch das Hinzufügen einer 2D-Pfadfindung beinhalten, wenn Sie auf eine Kachel tippen möchten, um den Helden dorthin zu führen.
In einem anschließenden Tutorial wird untersucht, wie mit dem aktuellen Projekt isometrische und hexagonale Versionen von Sokoban mit minimalen Änderungen erstellt werden können.