In questo tutorial esploreremo un approccio per la creazione di un gioco Sokoban o Crate-Pusher utilizzando la logica basata su tile e un array bidimensionale per contenere i dati di livello. Stiamo utilizzando Unity per lo sviluppo con C # come linguaggio di scripting. Si prega di scaricare i file sorgente forniti con questo tutorial per seguire.
Ci possono essere pochi tra noi che potrebbero non aver giocato una variante di gioco Sokoban. La versione originale potrebbe persino essere più vecchia di alcuni di voi. Si prega di consultare la pagina wiki per alcuni dettagli. Essenzialmente, abbiamo un personaggio o un elemento controllato dall'utente che deve spingere casse o elementi simili sul suo riquadro di destinazione.
Il livello consiste in una griglia quadrata o rettangolare di tessere in cui una tessera può essere non pedonabile o calpestabile. Possiamo camminare sulle tessere pedonabili e spingerci sopra le casse. Le tessere speciali percorribili sarebbero contrassegnate come tessere destinazione, che è dove la cassa dovrebbe riposare per completare il livello. Il personaggio è solitamente controllato usando una tastiera. Una volta che tutte le casse hanno raggiunto un riquadro di destinazione, il livello è completo.
Lo sviluppo basato sulle tessere significa essenzialmente che il nostro gioco è composto da un numero di tessere distribuite in modo predeterminato. Un elemento di dati di livello rappresenterà il modo in cui le tessere dovrebbero essere distribuite per creare il nostro livello. Nel nostro caso, utilizzeremo una griglia quadrata a riquadri. Puoi leggere ulteriori informazioni sui giochi basati su tile qui su Envato Tuts+.
Vediamo come abbiamo organizzato il nostro progetto Unity per questo tutorial.
Per questo progetto tutorial, non utilizziamo risorse artistiche esterne, ma useremo le primitive sprite create con l'ultima versione di Unity 2017.1. L'immagine sotto mostra come possiamo creare diversi sprite a forma di unità in Unity.
Useremo il Piazza sprite per rappresentare una singola tessera nella nostra griglia di livello sokoban. Useremo il Triangolo sprite per rappresentare il nostro personaggio, e useremo il Cerchio sprite per rappresentare una cassa, o in questo caso una palla. Le normali tessere terreno sono bianche, mentre le tessere destinazione hanno un colore diverso per distinguersi.
Rappresenteremo i nostri dati di livello sotto forma di un array bidimensionale che fornisce la perfetta correlazione tra la logica e gli elementi visivi. Usiamo un semplice file di testo per archiviare i dati di livello, il che rende più facile per noi modificare il livello al di fuori di Unity o cambiare i livelli semplicemente cambiando i file caricati. Il risorse la cartella ha un livello
file di testo, che ha il nostro livello predefinito.
1,1,1,1,1,1,1 1,3,1,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
Il livello ha sette colonne e cinque righe. Un valore di 1
significa che abbiamo una tessera terreno in quella posizione. Un valore di -1
significa che è una tessera non percorribile, mentre un valore di 0
significa che è una tessera di destinazione. Il valore 2
rappresenta il nostro eroe, e 3
rappresenta una palla pushable. Solo osservando i dati di livello, possiamo visualizzare come sarebbe il nostro livello.
Per mantenere le cose semplici, e dato che non è una logica molto complicata, abbiamo solo un singolo Sokoban.cs
file di script per il progetto ed è collegato alla telecamera di scena. Tienilo aperto nel tuo editor mentre segui il resto del tutorial.
I dati di livello rappresentati dall'array 2D non vengono utilizzati solo per creare la griglia iniziale, ma vengono utilizzati anche durante il gioco per tenere traccia delle variazioni di livello e dell'avanzamento del gioco. Ciò significa che i valori correnti non sono sufficienti per rappresentare alcuni degli stati di livello durante il gioco.
Ogni valore rappresenta lo stato della tessera corrispondente nel livello. Abbiamo bisogno di valori aggiuntivi per rappresentare una palla sul riquadro di destinazione e l'eroe sul riquadro di destinazione, che sono rispettivamente -3
e -2
. Questi valori potrebbero essere qualsiasi valore assegnato nello script di gioco, non necessariamente gli stessi valori che abbiamo usato qui.
Il primo passo è caricare i dati di livello in un array 2D dal file di testo esterno. Noi usiamo il ParseLevel
metodo per caricare il stringa
valore e dividerlo per popolare il nostro levelData
Array 2D.
void ParseLevel () TextAsset textFile = Resources.Load (levelName) come TextAsset; string [] lines = textFile.text.Split (new [] '\ r', '\ n', System.StringSplitOptions.RemoveEmptyEntries); // diviso per nuova riga, restituisce string [] nums = lines [0] .Split (new [] ','); // split by, rows = lines.Length; // numero di righe cols = nums.Length; // numero di colonne levelData = new int [rows, cols]; per (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;
Durante l'analisi, determiniamo il numero di righe e colonne del nostro livello mentre compiliamo il nostro levelData
.
Una volta che abbiamo i nostri dati di livello, possiamo disegnare il nostro livello sullo schermo. Usiamo il metodo CreateLevel per fare proprio questo.
void CreateLevel () // calcola l'offset per allineare il livello intero alla scena middle middleOffset.x = cols * tileSize * 0.5f-tileSize * 0.5f; middleOffset.y = righe * * tileSize 0.5f-tileSize * 0.5f ;; Tile GameObject; SpriteRenderer sr; Palla GameObject; int destinationCount = 0; per (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(); // aggiungi un renderizzatore di sprite sr.sprite = tileSprite; // assegna tile sprite tile.transform.position = GetScreenPointFromLevelIndices (i, j); // inserisce nella scena in base agli indici di livello if (val == destinationTile) // se è un riquadro di destinazione, dai un altro colore sr.color = destinationColor; destinationCount ++; // conta le destinazioni else if (val == heroTile) // hero hero tile = new GameObject ("hero"); hero.transform.localScale = Vector2.one * (tileSize-1); sr = hero.AddComponent (); sr.sprite = heroSprite; sr.sortingOrder = 1; // l'eroe deve essere sopra la tessera terreno sr.color = Color.red; hero.transform.position = GetScreenPointFromLevelIndices (i, j); occupants.Add (hero, new Vector2 (i, j)); // memorizza gli indici di livello dell'eroe in dict else if (val == ballTile) // ball tile ballCount ++; // incrementa il numero di palle in level ball = nuovo GameObject ("ball" + ballCount.ToString ()); ball.transform.localScale = Vector2.one * (tileSize-1); sr = ball.AddComponent (); sr.sprite = ballSprite; sr.sortingOrder = 1; // palla deve essere sopra la tessera terreno sr.color = Color.black; ball.transform.position = GetScreenPointFromLevelIndices (i, j); occupants.Add (palla, nuovo Vector2 (i, j)); // memorizza gli indici di livello della palla in dict if (ballCount> destinationCount) Debug.LogError ("ci sono più palle che destinazioni");
Per il nostro livello, abbiamo impostato a tileSize
valore di 50
, che è la lunghezza del lato di una tessera quadrata nella nostra griglia di livello. Effettuiamo un ciclo attraverso il nostro array 2D e determiniamo il valore memorizzato in ognuno dei io
e j
indici dell'array. Se questo valore non è un invalidTile
(-1) quindi creiamo un nuovo GameObject
di nome piastrella
. Alleghiamo un SpriteRenderer
componente a piastrella
e assegnare il corrispondente folletto
o Colore
in base al valore nell'indice dell'array.
Mentre si posiziona il eroe
o il palla
, dobbiamo prima creare una tessera terreno e poi creare queste tessere. Dato che l'eroe e la palla devono essere sovrapposti al terreno, diamo il loro SpriteRenderer
un più alto sortingOrder
. Tutte le tessere sono assegnate a localScale
di tileSize
così sono 50x50
nella nostra scena.
Teniamo traccia del numero di palline nella nostra scena usando il ballCount
variabile e ci dovrebbe essere lo stesso numero o un numero maggiore di tessere di destinazione nel nostro livello per rendere possibile il completamento del livello. La magia avviene in una singola riga di codice dove determiniamo la posizione di ogni tessera usando il GetScreenPointFromLevelIndices (int row, int col)
metodo.
// ... tile.transform.position = GetScreenPointFromLevelIndices (i, j); // inserisce nella scena in base agli indici di livello // ... Vector2 GetScreenPointFromLevelIndices (int row, int col) // conversione degli indici in valori di posizione, col determina x & riga determina y restituisce nuovo Vector2 (col * tileSize-middleOffset.x, row * -tileSize + middleOffset.y);
La posizione del mondo di una tessera è determinata moltiplicando gli indici di livello con il tileSize
valore. Il middleOffset
la variabile viene utilizzata per allineare il livello al centro dello schermo. Si noti che il riga
il valore è moltiplicato per un valore negativo per supportare l'invertito y
asse in Unity.
Ora che abbiamo mostrato il nostro livello, passiamo alla logica del gioco. Abbiamo bisogno di ascoltare l'input per la pressione dei tasti dell'utente e spostare il eroe
in base all'input. La pressione del tasto determina la direzione richiesta del movimento e il tasto eroe
deve essere spostato in quella direzione Ci sono vari scenari da considerare una volta determinata la direzione del movimento richiesta. Diciamo che la tessera accanto a eroe
in questa direzione è tileK.
Se la posizione di tileK è esterna alla griglia, non è necessario fare nulla. Se tileK è valido ed è calpestabile, allora dobbiamo spostarci eroe
a quella posizione e aggiornare il nostro levelData
array. Se tileK ha una palla, allora dobbiamo considerare il prossimo vicino nella stessa direzione, per esempio tileL.
Solo nel caso in cui tileL è una tessera percorribile, non occupata, dovremmo spostare la eroe
e la palla a tileK a tileK e tileL rispettivamente. Dopo il movimento riuscito, dobbiamo aggiornare il levelData
schieramento.
La logica sopra indica che abbiamo bisogno di sapere quale tessera la nostra eroe
è attualmente a Abbiamo anche bisogno di determinare se una certa tessera ha una palla e dovrebbe avere accesso a quella palla.
Per facilitare questo, usiamo a Dizionario
chiamato occupanti
che memorizza un GameObject
come chiave e gli indici dell'array memorizzati come Vector2
come valore. Nel CreateLevel
metodo, popoliamo occupanti
quando creiamo eroe
o palla. Una volta che abbiamo il dizionario popolato, possiamo usare il GetOccupantAtPosition
per riavere il GameObject
a un determinato indice di array.
Dizionariooccupanti; // riferimento a palle ed eroe // ... occupanti. Aggiungi (eroe, nuovo Vector2 (i, j)); // memorizza gli indici di livello dell'eroe in dict // ... occupanti. Aggiungi (palla, nuovo Vector2 (i , j)); // memorizza gli indici di livello della palla in dict // ... private GameObject GetOccupantAtPosition (Vector2 heroPos) // attraversa gli occupanti per trovare la palla nella posizione data. Palla GameObject; foreach (KeyValuePair coppia in occupanti) if (pair.Value == heroPos) ball = pair.Key; palla di ritorno; restituisce null;
Il IsOccupied
metodo determina se il levelData
valore agli indici forniti rappresenta una palla.
bool privato IsOccupied (Vector2 objPos) // controlla se c'è una palla in corrispondenza di una determinata posizione dell'array return (levelData [(int) objPos.x, (int) objPos.y] == ballTile || levelData [(int) objPos. x, (int) objPos.y] == ballOnDestinationTile);
Abbiamo anche bisogno di un modo per verificare se una data posizione è all'interno della nostra griglia e se quella tessera è percorribile. Il IsValidPosition
metodo controlla gli indici di livello passati come parametri per determinare se rientra nelle nostre dimensioni di livello. Controlla anche se abbiamo un invalidTile
come tale indice nel levelData
.
private bool IsValidPosition (Vector2 objPos) // controlla se gli indici dati rientrano nelle dimensioni dell'array if (objPos.x> -1 && objPos.x-1 && objPos.y Risposta all'input dell'utente
Nel
Aggiornare
metodo del nostro script di gioco, controlliamo l'utenteKeyUp
eventi e confronti con le nostre chiavi di input memorizzate neluserInputKeys
array. Una volta determinata la direzione del movimento richiesta, chiamiamo ilTryMoveHero
metodo con la direzione come parametro.void Update () if (gameOver) return; ApplyUserInput (); // check & use input dell'utente per spostare hero e 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); // leftIl
TryMoveHero
il metodo è dove viene implementata la nostra logica di gioco principale all'inizio di questa sezione. Si prega di leggere attentamente il seguente metodo per vedere come viene implementata la logica come spiegato sopra.private void TryMoveHero (int direction) Vector2 heroPos; Vector2 oldHeroPos; Vector2 nextPos; occupants.TryGetValue (hero, out oldHeroPos); heroPos = GetNextPositionAlong (oldHeroPos, direction); // trova la posizione dell'array successivo in una determinata direzione se (IsValidPosition (heroPos)) // controlla se è una posizione valida e cade all'interno dell'array di livelli if (! IsOccupied (heroPos)) // controlla se è occupato da una palla // sposta eroe RemoveOccupant (oldHeroPos); // ripristina i vecchi dati di livello nella vecchia posizione hero.transform.position = GetScreenPointFromLevelIndices ((int) heroPos.x, (int) heroPos.y ); occupanti [eroe] = heroPos; if (levelData [(int) heroPos.x, (int) heroPos.y] == groundTile) // spostamento su una piastrella di base levelData [(int) heroPos.x, (int) heroPos.y] = heroTile; else if (levelData [(int) heroPos.x, (int) heroPos.y] == destinationTile) // spostamento su una tile di destinazione levelData [(int) heroPos.x, (int) heroPos.y] = heroOnDestinationTile ; else // abbiamo una palla accanto all'eroe, controlla se è vuota dall'altra parte della palla nextPos = GetNextPositionAlong (heroPos, direction); if (IsValidPosition (nextPos)) if (! IsOccupied (nextPos)) // abbiamo trovato un vicino vuoto, quindi dobbiamo spostare sia palla che hero GameObject ball = GetOccupantAtPosition (heroPos); // trova la palla in questa posizione se (palla == null) Debug.Log ("no ball"); RemoveOccupant (heroPos); // palla deve essere spostata prima di spostare l'eroe ball.transform.position = GetScreenPointFromLevelIndices ((int) nextPos.x, (int) nextPos.y); occupanti [palla] = 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); occupanti [eroe] = 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 (); // controlla se tutte le sfere hanno raggiunto le destinazioniPer ottenere la prossima posizione lungo una determinata direzione in base a una posizione prestabilita, usiamo il
GetNextPositionAlong
metodo. Si tratta solo di incrementare o decrementare uno degli indici in base alla direzione.private Vector2 GetNextPositionAlong (Vector2 objPos, int direction) switch (direction) case 0: objPos.x- = 1; // up break; caso 1: objPos.y + = 1; // a destra; caso 2: objPos.x + = 1; // down break; caso 3: objPos.y- = 1; // interruzione sinistra; return objPos;Prima di spostare un eroe o una palla, dobbiamo cancellare la loro posizione attualmente occupata nel
levelData
array. Questo è fatto usando ilRemoveOccupant
metodo.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; // palla che si muove da 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; // palla che si sposta dal riquadro di destinazioneSe troviamo un
heroTile
oballTile
al dato indice, dobbiamo impostarlo sugroundTile
. Se troviamo unheroOnDestinationTile
oballOnDestinationTile
allora dobbiamo impostarlodestinationTile
.Completamento del livello
Il livello è completo quando tutte le palle sono alla loro destinazione.
Dopo ogni movimento riuscito, chiamiamo il
CheckCompletion
metodo per vedere se il livello è completato. Passiamo attraverso il nostrolevelData
matrice e contare il numero diballOnDestinationTile
occorrenze. Se questo numero è uguale al nostro numero totale di palle determinato daballCount
, il livello è completo.private void CheckCompletion () int ballsOnDestination = 0; per (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;Conclusione
Questa è un'implementazione semplice ed efficiente della logica sokoban. Puoi creare i tuoi livelli modificando il file di testo o creandone uno nuovo e modificando il file
levelName
variabile per puntare al tuo nuovo file di testo.L'implementazione corrente utilizza la tastiera per controllare l'eroe. Ti invito a provare a cambiare il controllo in base al tocco in modo da poter supportare i dispositivi basati su touch. Ciò comporterebbe l'aggiunta di alcuni percorsi 2D anche se ti piace giocare su qualsiasi tessera per guidare l'eroe.
Ci sarà un tutorial di follow-up in cui esploreremo come il progetto attuale può essere usato per creare versioni isometriche ed esagonali di sokoban con modifiche minime.