Unity 2D Kachel-basiertes 'Sokoban' Spiel

Was Sie erstellen werden

In diesem Tutorial werden wir einen Ansatz zum Erstellen eines Sokoban- oder Crate-Pusher-Spiels mit Kachel-basierter Logik und einem zweidimensionalen Array zum Speichern von Pegeldaten untersuchen. Wir verwenden Unity für die Entwicklung mit C # als Skriptsprache. Laden Sie bitte die Quelldateien herunter, die im Lieferumfang dieses Tutorials enthalten sind.

1. Das Sokoban-Spiel

Es mag wenige von uns geben, die möglicherweise keine Sokoban-Spielvariante gespielt haben. Die Originalversion ist möglicherweise älter als einige von Ihnen. Bitte besuchen Sie die Wiki-Seite für weitere Details. Im Wesentlichen haben wir ein zeichen- oder benutzergesteuertes Element, das Kisten oder ähnliche Elemente auf die Zielkachel schieben muss. 

Die Ebene besteht aus einem quadratischen oder rechteckigen Kachelnetz, in dem eine Kachel nicht begehbar oder begehbar sein kann. Wir können auf den begehbaren Fliesen laufen und die Kisten darauf schieben. Spezielle begehbare Plättchen werden als Zielplättchen markiert, auf denen die Kiste schließlich ruhen sollte, um das Level abzuschließen. Das Zeichen wird normalerweise über eine Tastatur gesteuert. Sobald alle Kisten ein Zielplättchen erreicht haben, ist der Level abgeschlossen.

Fliesenbasierte Entwicklung bedeutet im Wesentlichen, dass unser Spiel aus einer Anzahl von Plättchen besteht, die auf vorbestimmte Weise verteilt sind. Ein Level-Datenelement repräsentiert, wie die Kacheln verteilt werden müssten, um unser Level zu erstellen. In unserem Fall verwenden wir ein quadratisches Gitter. Weitere Informationen zu Kachel-basierten Spielen finden Sie hier bei 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 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 die verwenden Quadrat Sprite, um ein einzelnes Plättchen in unserem Sokoban-Raster darzustellen. Wir werden die verwenden Dreieck Sprite, um unseren Charakter darzustellen, und wir werden das verwenden Kreis Sprite, um eine Kiste oder in diesem Fall eine Kugel darzustellen. Die normalen Bodenplättchen sind weiß, während die Zielplättchen eine andere Farbe haben, um hervorzuheben.

Die Level-Daten

Wir werden unsere Pegeldaten in Form eines zweidimensionalen Arrays darstellen, das die perfekte Korrelation zwischen den logischen und visuellen Elementen liefert. Wir verwenden eine einfache Textdatei zum Speichern der Ebenendaten, sodass wir die Ebene außerhalb von Unity leichter bearbeiten oder Ebenen ändern können, indem Sie einfach die geladenen Dateien ändern. Das Ressourcen Ordner hat eine Niveau Textdatei, die unsere Standardstufe 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 ein Bodenplättchen haben. Ein Wert von -1 bedeutet, dass es sich um ein nicht begehbares Plättchen handelt, während ein Wert von 0 bedeutet, dass es sich um ein Zielplättchen handelt. Der Wert 2 repräsentiert unseren Helden und 3 repräsentiert eine drückbare Kugel. Wenn Sie sich nur die Niveaudaten ansehen, können wir uns vorstellen, wie unser Niveau aussehen würde.

3. Erstellen eines Sokoban-Spiellevels

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, und es wird an die Szenenkamera angehängt. Bitte lassen Sie es in Ihrem Editor geöffnet, während Sie den Rest des Tutorials verfolgen.

Daten auf Spezialebene

Die durch das 2D-Array dargestellten Pegeldaten werden nicht nur zum Erstellen des anfänglichen Gitters verwendet, sondern auch während des gesamten Spiels zum Nachverfolgen von Leveländerungen und des Spielfortschritts. Dies bedeutet, dass die aktuellen Werte nicht ausreichen, um einige der Pegelzustände während des Spiels darzustellen. 

Jeder Wert steht für den Status der entsprechenden Kachel in der Ebene. Wir benötigen zusätzliche Werte, um einen Ball auf dem Zielfeld und den Helden auf dem Zielfeld darzustellen -3 und -2. Bei diesen Werten kann es sich um einen beliebigen Wert handeln, den Sie im Spielskript zuweisen, und nicht unbedingt um dieselben Werte, die wir hier verwendet haben. 

Analysieren der Ebenentextdatei

Der erste Schritt besteht darin, unsere Ebenendaten aus der externen Textdatei in ein 2D-Array zu laden. Wir nehmen das ParseLevel Methode zum Laden der Schnur Wert und teilen Sie es, um unsere zu bevölkern levelData 2D-Array.

void ParseLevel () TextAsset textFile = Resources.Load (levelName) als TextAsset; string [] lines = textFile.text.Split (new [] '\ r', '\ n', System.StringSplitOptions.RemoveEmptyEntries); // nach neuer Zeile aufteilen, String [] nums = Zeilen [0] zurückgeben .Split (new [] ','); // geteilt nach, Zeilen = Zeilen.Länge; // Anzahl der Zeilen cols = nums.Length; // Anzahl der Spalten levelData = new int [Zeilen, Spalten]; für (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 unser Level hat, während wir unsere Zeilen füllen levelData.

Zeichnungsebene

Sobald wir unsere Level-Daten haben, können wir unser Level auf dem Bildschirm zeichnen. Wir verwenden dazu die CreateLevel-Methode.

void CreateLevel () // Berechne den Offset, um die gesamte Ebene an der Szene auszurichten. middleOffset.x = cols * tileSize * 0.5f-tileSize * 0.5f; middleOffset.y = Zeilen * tileSize * 0.5f-tileSize * 0.5f ;; GameObject-Kachel; SpriteRenderer sr; GameObject-Ball; int destinationCount = 0; für (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(); // füge einen Sprite-Renderer hinzu sr.sprite = tileSprite; // ordne Sprite-Sprite tile.transform.position = GetScreenPointFromLevelIndices (i, j); // Platziere in Szene basierend auf Ebenenindizes if (val == destinationTile)  // Wenn es sich um eine Zielkachel handelt, geben Sie eine andere Farbe an sr.color = destinationColor; destinationCount ++; // zählt Ziele else if (val == heroTile) // das Heldenplättchen hero = new GameObject ("hero"); hero.transform.localScale = Vector2.one * (tileSize-1); sr = hero.AddComponent(); sr.sprite = heroSprite; sr.sortingOrder = 1; // Der Held muss über dem Boden liegen. sr.color = Color.red; hero.transform.position = GetScreenPointFromLevelIndices (i, j); occupants.Add (hero, new Vector2 (i, j)); // speichert die Levelindizes des Helden in dict else if (val == ballTile) // ball tile ballCount ++; // erhöht die Anzahl der Bälle im level ball = neues GameObject ("ball" + ballCount.ToString ()); ball.transform.localScale = Vector2.one * (tileSize-1); sr = ball.AddComponent(); sr.sprite = ballSprite; sr.sortingOrder = 1; // Ball muss über der Bodenplatte liegen sr.color = Color.black; ball.transform.position = GetScreenPointFromLevelIndices (i, j); occupants.Add (Ball, neuer Vector2 (i, j)); // Die Ebenenindizes des Balls in dict speichern if (ballCount> destinationCount) Debug.LogError ("es gibt mehr Bälle als Ziele"); 

Für unser Niveau haben wir ein gesetzt Fliesengröße Wert von 50, Dies ist die Länge der Seite einer quadratischen Kachel in unserem Ebenenraster. Wir durchlaufen unser 2D-Array und bestimmen den jeweils gespeicherten Wert ich und j Indizes des Arrays. Wenn dieser Wert kein ist invalidTile (-1) dann erstellen wir ein neues GameObject genannt Fliese. Wir hängen ein SpriteRenderer Komponente zu Fliese und ordnen Sie das entsprechende zu Sprite oder Farbe abhängig vom Wert am Array-Index. 

Beim Platzieren der Held oder der Ball, Wir müssen zuerst eine Bodenplatte erstellen und dann diese Fliesen erstellen. Da der Held und der Ball die Bodenplatte überlagern müssen, geben wir ihre SpriteRenderer ein höheres Sortierreihenfolge. Allen Kacheln wird ein zugewiesen localScale von Fliesengröße also sind sie 50 x 50 in unserer Szene. 

Wir verfolgen die Anzahl der Bälle in unserer Szene mit der ballCount variabel, und es sollte die gleiche oder eine höhere Anzahl von Ziel-Kacheln in unserer Ebene geben, um die Level-Fertigstellung zu ermöglichen. Die Magie geschieht in einer einzigen Codezeile, in der wir die Position der einzelnen Kacheln mithilfe von bestimmen GetScreenPointFromLevelIndices (int row, int col) Methode.

//… tile.transform.position = GetScreenPointFromLevelIndices (i, j); // in Szene basierend auf Ebenenindizes platzieren //… Vector2 GetScreenPointFromLevelIndices (int row, int col) // Indizes in Positionswerte konvertieren, col bestimmt x & Zeile bestimmen und Rückgabe neuer Vector2 (col * tileSize-middleOffset.x, Zeile * -tileSize + middleOffset.y); 

Die Weltposition einer Kachel wird durch Multiplizieren der Ebenenindizes mit der Zahl bestimmt Fliesengröße Wert. Das middleOffset Variable wird verwendet, um den Pegel in der Mitte des Bildschirms auszurichten. Beachten Sie, dass die Reihe Wert wird mit einem negativen Wert multipliziert, um das invertierte zu unterstützen y Achse in Einheit.

4. Sokoban-Logik

Nun, da wir unser Level angezeigt haben, gehen wir zur Spielelogik über. Wir müssen auf die Eingabetaste des Benutzers warten und die Taste verschieben Held basierend auf der Eingabe. Der Tastendruck bestimmt die gewünschte Bewegungsrichtung und die Held muss in diese Richtung bewegt werden. Es gibt verschiedene Szenarien, die zu berücksichtigen sind, sobald wir die erforderliche Bewegungsrichtung festgelegt haben. Nehmen wir an, die Kachel neben Held in diese Richtung geht tileK.

  • Befindet sich an dieser Position ein Plättchen in der Szene oder ist es außerhalb unseres Gitters??
  • Ist Fliese eine begehbare Fliese?
  • Ist tileK von einer Kugel besetzt?

Wenn die Position von tileK außerhalb des Gitters liegt, müssen wir nichts tun. Wenn tileK gültig ist und begehbar ist, müssen wir uns bewegen Held zu dieser Position und aktualisieren Sie unsere levelData Array. Wenn TileK einen Ball hat, müssen wir den nächsten Nachbarn in dieselbe Richtung betrachten, zum Beispiel tileL.

  • Ist tileL außerhalb des Gitters?
  • Ist eine begehbare Kachel?
  • Ist tileL von einer Kugel besetzt?

Nur wenn Fliese ein begehbares, nicht besetztes Plättchen ist, sollten wir das bewegen Held und der Ball bei tileK to tileK und tileL. Nach erfolgreicher Bewegung müssen wir die aktualisieren levelData Array.

Unterstützende Funktionen

Die obige Logik bedeutet, dass wir wissen müssen, welche Kachel unsere sind Held ist derzeit bei. 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 a Wörterbuch namens Insassen die speichert a GameObject als Schlüssel und seine Array-Indizes gespeichert als Vector2 als Wert. In dem CreateLevel Methode bevölkern wir Insassen wenn wir schaffen Held oder Kugel. Sobald wir das Wörterbuch gefüllt haben, können wir das verwenden GetOccupantAtPosition das zurückbekommen GameObject an einem bestimmten Array-Index.

Wörterbuch Insassen; // Verweis auf Bälle und Helden //… Insassen.Add (Held, neuer Vector2 (i, j)); // Speichern der Ebenenindizes des Helden im Diktat //… Insassen.Add (Ball, neuer Vector2 (i , j)); // die Levelindizes des Balls im Diktat speichern //… private GameObject GetOccupantAtPosition (Vector2 heroPos) // Schleife durch die Insassen, um den Ball an einer bestimmten Position zu finden GameObject Ball; foreach (KeyValuePair Paar in Insassen) if (pair.Value == heroPos) ball = pair.Key; Rückkehr Ball;  null zurückgeben; 

Das Ist besetzt Methode bestimmt, ob die levelData Der Wert bei den angegebenen Indizes steht für eine Kugel.

private bool IsOccupied (Vector2 objPos) // prüfe, ob sich an gegebener Arrayposition ein Ball befindet return (levelData [(int) objPos.x, (int) objPos.y] == ballTile || levelData [(int) objPos. x, (int) objPos.y] == ballOnDestinationTile); 

Wir brauchen auch eine Möglichkeit, um zu überprüfen, ob sich eine bestimmte Position in unserem Raster befindet und ob diese Kachel begehbar ist. Das IsValidPosition Diese Methode überprüft die als Parameter übergebenen Ebenenindizes, um festzustellen, ob sie in unsere Ebenendimensionen fallen. Es wird auch geprüft, ob wir eine haben invalidTile wie dieser Index in der levelData.

private bool IsValidPosition (Vector2 objPos) // prüfe, ob die angegebenen Indizes innerhalb der Array-Dimensionen if (objPos.x> -1 && objPos.x liegen-1 && objPos.y

Antworten auf Benutzereingaben

In dem Aktualisieren Methode unseres Spielskripts prüfen wir den Benutzer KeyUp Ereignisse und vergleichen Sie mit unseren Eingabetasten, die im gespeichert sind userInputKeys Array. Sobald die gewünschte Bewegungsrichtung festgelegt ist, nennen wir das TryMoveHero Methode mit der Richtung als Parameter.

void Update () if (gameOver) zurückkehren; ApplyUserInput (); // Benutzereingaben prüfen und verwenden, um den Helden und die Kugeln zu bewegen 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

Das TryMoveHero In dieser Methode wird unsere zu Beginn dieses Abschnitts erläuterte Kernlogik implementiert. Gehen Sie die folgende Methode sorgfältig durch, um zu sehen, wie die Logik wie oben beschrieben implementiert wird.

private void TryMoveHero (int direction) Vector2 heroPos; Vector2 oldHeroPos; Vector2 nextPos; Insassen.TryGetValue (Held, out oldHeroPos); heroPos = GetNextPositionAlong (oldHeroPos, direction); // Findet die nächste Array-Position in der angegebenen Richtung, wenn (IsValidPosition (heroPos)) // überprüft, ob es sich um eine gültige Position handelt und in das Level-Array fällt, wenn (! IsOccupied (heroPos)) // prüfe, ob er mit einem Ball besetzt ist // Helden bewegen RemoveOccupant (oldHeroPos); // alte Daten an der alten Position zurücksetzen hero.transform.position = GetScreenPointFromLevelIndices ((int) heroPos.x, (int) heroPos.y) ); Insassen [Held] = HeldPos; if (levelData [(int) heroPos.x, (int) heroPos.y] == groundTile) // auf ein Bodenplättchen levelData [(int) heroPos.x, (int) heroPos.y] = heroTile;  else if (levelData [(int) heroPos.x, (int) heroPos.y] == destinationTile) // bewegt sich auf eine Ziel-Kachel levelData [(int) heroPos.x, (int) heroPos.y] = heroOnDestinationTile ;  else // Wir haben einen Ball neben dem Helden. Überprüfen Sie, ob der Ball auf der anderen Seite des Balls leer ist nextPos = GetNextPositionAlong (heroPos, Richtung); if (IsValidPosition (nextPos)) if (! IsOccupied (nextPos)) // Wir fanden einen leeren Nachbarn, also müssen wir sowohl Ball als auch Helden bewegen. GameObject Ball = GetOccupantAtPosition (heroPos); // Wenn der Ball an dieser Position ist, (Ball == null) Debug.Log ("kein Ball"); RemoveOccupant (heroPos); // Ball sollte zuerst verschoben werden, bevor der Held verschoben wird ball.transform.position = GetScreenPointFromLevelIndices ((int) nextPos.x, (int) nextPos.y); Insassen [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); Insassen [Held] = HeldPos; 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 (); // Überprüfen Sie, ob alle Bälle Ziele erreicht haben

Um die nächste Position entlang einer bestimmten Richtung basierend auf einer bereitgestellten Position zu erhalten, verwenden wir die GetNextPositionAlong Methode. Es ist nur eine Frage des Inkrementierens oder Dekrementierens eines der Indizes gemäß der Richtung.

private Vector2 GetNextPositionAlong (Vector2 objPos, int direction) Schalter (Richtung) Fall 0: objPos.x- = 1; // up break; Fall 1: objPos.y + = 1; // rechter Bruch; Fall 2: objPos.x + = 1; // Abbruch; Fall 3: objPos.y- = 1; // linker Bruch;  return objPos; 

Bevor wir einen Helden oder Ball bewegen, müssen wir die derzeit besetzte Position im Feld löschen levelData Array. Dies geschieht 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] = Bodenkachel; // Ball bewegt sich vom Bodenkachel else if (levelData [(int) objPos.x, (int) objPos.y] == heroOnDestinationTile) levelData [(int) objPos.x, (int) objPos.y] = destinationTile; // Held bewegt sich von der Zielkachel else if (levelData [(int) objPos.x, (int) objPos.y] = = ballOnDestinationTile) levelData [(int) objPos.x, (int) objPos.y] = destinationTile; // Ball bewegt sich vom Zielfeld

Wenn wir eine finden heroTile oder ballTile Bei dem angegebenen Index müssen wir ihn auf setzen GroundTile. Wenn wir eine finden heroOnDestinationTile oder ballOnDestinationTile dann müssen wir es auf setzen destinationTile.

Level Fertigstellung

Das Level ist abgeschlossen, wenn alle Bälle am Ziel sind.

Nach jeder erfolgreichen Bewegung nennen wir die CheckCompletion Methode, um zu sehen, ob der Level abgeschlossen ist. Wir durchlaufen unsere levelData Array und zählen die Anzahl von ballOnDestinationTile Vorkommen Wenn diese Anzahl gleich unserer Gesamtzahl der Bälle ist, bestimmt durch ballCount, Das Level ist abgeschlossen.

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

Fazit

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 levelName Variable, um auf Ihre neue Textdatei zu zeigen. 

Die aktuelle Implementierung verwendet die Tastatur zur Steuerung des Helden. Ich möchte Sie einladen, das Steuerelement auf tap-based umzustellen und zu ändern, damit wir Touch-basierte Geräte unterstützen können. Dazu müssen Sie auch 2D-Pfadfindung hinzufügen, wenn Sie auf ein Plättchen tippen möchten, um den Helden dorthin zu führen.

Es wird ein Folientutorial geben, in dem wir untersuchen werden, wie das aktuelle Projekt zum Erstellen von isometrischen und sechseckigen Versionen von Sokoban mit minimalen Änderungen verwendet werden kann.