Gioco Sokoban basato su tessere 2D Unity

Cosa starai creando

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.

1. Il gioco Sokoban

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+.

2. Preparazione del progetto Unity

Vediamo come abbiamo organizzato il nostro progetto Unity per questo tutorial.

L'arte

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.

I dati di livello

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.

3. Creazione di un livello di gioco Sokoban

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.

Dati speciali a livello

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. 

Analisi del file di testo di livello

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.

Livello di disegno

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.

4. Logica Sokoban

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.

  • C'è una tessera nella scena in quella posizione, o è fuori dalla nostra griglia?
  • TileK è una tessera calpestabile?
  • La tesseraK è occupata da una palla?

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.

  • È tileL fuori dalla griglia?
  • Tegola una tessera calpestabile?
  • La tessera è occupata da una palla?

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.

Funzioni di supporto

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.

Dizionario occupanti; // 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'utente KeyUp eventi e confronti con le nostre chiavi di input memorizzate nel userInputKeys array. Una volta determinata la direzione del movimento richiesta, chiamiamo il TryMoveHero 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); // left

Il 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 destinazioni

Per 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 il RemoveOccupant 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 destinazione

Se troviamo un heroTile o ballTile al dato indice, dobbiamo impostarlo su groundTile. Se troviamo un heroOnDestinationTile o ballOnDestinationTile allora dobbiamo impostarlo destinationTile.

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 nostro levelData matrice e contare il numero di ballOnDestinationTile occorrenze. Se questo numero è uguale al nostro numero totale di palle determinato da ballCount, 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.