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.
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+.
Mal sehen, wie wir unser Unity-Projekt für dieses Tutorial organisiert haben.
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.
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.
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.
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.
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
.
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.
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.
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.
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.
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örterbuchInsassen; // 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 BenutzerKeyUp
Ereignisse und vergleichen Sie mit unseren Eingabetasten, die im gespeichert sinduserInputKeys
Array. Sobald die gewünschte Bewegungsrichtung festgelegt ist, nennen wir dasTryMoveHero
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); // leftDas
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 habenUm 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 derRemoveOccupant
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 ZielfeldWenn wir eine finden
heroTile
oderballTile
Bei dem angegebenen Index müssen wir ihn auf setzenGroundTile
. Wenn wir eine findenheroOnDestinationTile
oderballOnDestinationTile
dann müssen wir es auf setzendestinationTile
.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 unserelevelData
Array und zählen die Anzahl vonballOnDestinationTile
Vorkommen Wenn diese Anzahl gleich unserer Gesamtzahl der Bälle ist, bestimmt durchballCount
, 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.