Unitatea 2D pe bază de țiglă Sokoban Joc

Ce veți crea

În acest tutorial vom explora o abordare pentru crearea unui joc sokoban sau cartuș-împingător folosind logica bazată pe tiglă și o matrice bidimensională pentru a ține datele la nivel. Folosim Unity pentru dezvoltare cu C # ca limbaj de scripting. Descărcați fișierele sursă furnizate împreună cu acest tutorial pentru a le urmări.

1. Jocul Sokoban

S-ar putea să existe puțini dintre noi care să nu fi jucat o variantă de joc Sokoban. Versiunea originală poate fi chiar mai veche decât unii dintre voi. Vă rugăm să verificați pagina wiki pentru detalii. În esență, avem un caracter sau un element controlat de utilizator care trebuie să împingă cutii sau elemente similare pe placa de destinație. 

Nivelul constă într-o grilă pătrată sau dreptunghiulară a plăcilor în cazul în care o piesă poate fi una care nu se poate înclina sau una care poate fi plută. Putem să mergem pe gresie și să împingem cutiile pe ele. Pietrele speciale de pătrundere vor fi marcate ca plăci de destinație, în cazul în care cutia ar trebui să se odihnească în cele din urmă pentru a finaliza nivelul. Caracterul este controlat, de obicei, folosind o tastatură. Odată ce toate cutiile au ajuns la o țiglă destinație, nivelul este complet.

Dezvoltarea pe bază de țiglă înseamnă, în esență, că jocul nostru este compus dintr-un număr de plăci răspândite într-un mod predeterminat. Un element de nivel de nivel va reprezenta modul în care plăcile ar trebui să fie împrăștiate pentru a crea nivelul nostru. În cazul nostru, vom folosi o grilă pătrată pe plăci. Puteți citi mai multe despre jocurile pe bază de țiglă aici pe Envato Tuts+.

2. Pregătirea proiectului Unity

Să vedem cum ne-am organizat proiectul Unity pentru acest tutorial.

Arta

Pentru acest proiect tutorial, nu folosim niciun element extern de artă, ci vom folosi primitivele sprite create cu ultima versiune Unity 2017.1. Imaginea de mai jos arată modul în care putem crea sprite în formă de diferite în unitate.

Vom folosi Pătrat sprite pentru a reprezenta o singură țiglă în grila noastră de nivel sokoban. Vom folosi Triunghi sprite pentru a reprezenta caracterul nostru, iar noi vom folosi Cerc sprite pentru a reprezenta o cutie, sau în acest caz o minge. Placile obisnuite sunt alb, in timp ce placile de destinatie au o culoare diferita pentru a iesi in evidenta.

Datele de nivel

Vom reprezenta datele noastre de nivel sub forma unei matrice bidimensionale care oferă corelația perfectă între elementele logice și vizuale. Folosim un fișier text simplu pentru a stoca datele de nivel, ceea ce face mai ușor pentru noi să editați nivelul în afara Unității sau nivelurile de schimbare pur și simplu prin schimbarea fișierelor încărcate. Resurse dosarul are a nivel fișier text, care are nivelul implicit.

1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,2,1,1,1,1,1,1,3, 1,3,1 1,1,0,1,1,1,1

Nivelul are șapte coloane și cinci rânduri. O valoare de 1 înseamnă că avem o placă de bază în acea poziție. O valoare de -1 înseamnă că este o țiglă care nu se poate înclina, în timp ce o valoare de 0 înseamnă că este o țiglă destinație. Valoarea 2 reprezintă eroul nostru și 3 reprezintă o minge împingabilă. Privind doar la datele de nivel, putem vizualiza ceea ce arata nivelul nostru.

3. Crearea unui nivel de joc Sokoban

Pentru a păstra lucrurile simple, și întrucât nu este o logică foarte complicată, avem doar un singur Sokoban.cs script pentru proiect și este atașat camerei de scenă. Păstrați-l deschis în editorul dvs. în timp ce urmați restul tutorialului.

Datele speciale de nivel

Datele de nivel reprezentate de matricea 2D nu sunt folosite doar pentru a crea grila inițială, dar sunt de asemenea folosite pe tot parcursul jocului pentru a urmări schimbările de nivel și progresul jocului. Aceasta înseamnă că valorile curente nu sunt suficiente pentru a reprezenta unele dintre stările de nivel în timpul jocului. 

Fiecare valoare reprezintă starea plăcii corespunzătoare în nivel. Avem nevoie de valori suplimentare pentru a reprezenta o minge pe placa de destinație și pe eroul de pe placa de destinație, respectiv respectivă -3 și -2. Aceste valori ar putea fi orice valoare pe care o atribuiți în scriptul jocului, nu neapărat aceleași valori pe care le-am folosit aici. 

Parsarea fișierului text de nivel

Primul pas este să încărcați datele de nivel într-o matrice 2D din fișierul text extern. Noi folosim ParseLevel metoda de încărcare şir și l-am împărțit pentru a ne popula levelData 2D matrice.

void ParseLevel () TextAsset textFile = Resources.Load (nivelNumele) ca TextAsset; șir [] numere = linii [0] șir [] linii = textFile.text.Split (noi [] '\ r', '\ n', System.StringSplitOptions.RemoveEmptyEntries) .Split (new [] ','); // împărțit după, rânduri = linii.Lungime; // număr de rânduri cols = num.Length; // numărul de coloane levelData = new int [rânduri, colți]; pentru (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;    

În timp ce analizăm algoritmii, determinăm numărul de rânduri și coloane pe care le are nivelul nostru pe măsură ce ne ocupăm levelData.

Nivel de desenare

Odată ce avem datele noastre de nivel, putem extrage nivelul nostru pe ecran. Folosim metoda CreateLevel pentru a face exact asta.

void CreateLevel () // calcula offset-ul pentru a alinia întregul nivel la mijlocul scenei middleOffset.x = cols * tileSize * 0.5f-tileSize * 0.5f; middleOffset.y = rânduri * tileSize * 0.5f-tileSize * 0.5f ;; Tabelul GameObject; SpriteRenderer sr; GameObject ball; int destinationCount = 0; pentru (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(); // adăugați un renderer sprite sr.sprite = tileSprite; // atribuiți tile sprite tile.transform.position = GetScreenPointFromLevelIndices (i, j); // plasați în scenă pe baza indicilor de nivel dacă (val == destinationTile)  // dacă este o țiglă destinație, dați culoare diferită sr.color = destinationColor; destinațieCount ++; // numără destinații altceva if (val == heroTile) // eroul țiglă eroi = new GameObject ("erou"); hero.transform.localScale = Vector2.one * (tileSize-1); sr = hero.AddComponent(); sr.sprite = heroSprite; sr.sortingOrder = 1; // eroul trebuie să fie peste tigla solului sr.color = Color.red; hero.transform.position = GetScreenPointFromLevelIndices (i, j); (//) ()); / / păstrează indicele de nivel al eroului în dict altceva dacă (val == ballTile) // bile de țiglă cu bileCount ++; // numărul incrementului de bile în nivel de minge = nou jocObject ("ball" + ballCount.ToString ()); ball.transform.localScale = Vector2.one * (tileSize-1); sr = minge.AddComponent(); sr.sprite = ballSprite; sr.sortingOrder = 1; // mingea trebuie să fie peste tigla de sol sr.color = Color.black; ball.transform.position = GetScreenPointFromLevelIndices (i, j); (BallCount> destinationCount) Debug.LogError ("există mai multe bile decât destinații");). 

Pentru nivelul nostru, am stabilit o tileSize valoarea 50, care este lungimea laturii unei plăci pătrate în grila de nivel. Ne confruntăm prin matricea noastră 2D și determinăm valoarea stocată la fiecare dintre ele eu și j indici ai matricei. Dacă această valoare nu este o valoare invalidTile (-1) atunci vom crea un nou GameObject numit ţiglă. Am atașat a SpriteRenderer componente pentru ţiglă și alocați codul corespunzător spiriduș sau Culoare în funcție de valoarea indicelui matricei. 

În timp ce plasați erou sau minge, trebuie să creați mai întâi o țiglă de sol și apoi să creați aceste plăci. Deoarece eroul și mingea trebuie să suprapună țiglele, le dăm SpriteRenderer o mai mare sortingOrder. Toate plăcile sunt atribuite a localScale de tileSize așa sunt 50x50 în scena noastră. 

Urmărim numărul de bile în scena noastră folosind ballCount variabilă și ar trebui să existe acelasi sau mai mare număr de plăci de destinație în nivelul nostru pentru a face posibilă finalizarea nivelului. Magia se întâmplă într-o singură linie de cod în care determinăm poziția fiecărui țiglă utilizând GetScreenPointFromLevelIndices (int rând, int col) metodă.

// ... tile.transform.position = GetScreenPointFromLevelIndices (i, j); // loc în scena bazat pe indici de nivel // // Vector2 GetScreenPointFromLevelIndices (int rând, int col) // convertirea indiciilor la valorile de poziție, col determină x & rândul determina y retur nou Vector2 (col * tileSize-middleOffset.x, rând * -tileSize + middleOffset.y); 

Poziția mondială a unei plăci este determinată prin înmulțirea indicilor de nivel cu tileSize valoare. middleOffset variabila este folosită pentru alinierea nivelului în mijlocul ecranului. Observați că rând valoarea este înmulțită cu o valoare negativă pentru a susține inversul y axă în unitate.

4. Sokoban Logic

Acum, că ne-am arătat nivelul nostru, să mergem la logica jocului. Trebuie să ascultați pentru introducerea tastei de utilizator și să mutați erou pe baza intrărilor. Presa cheie determină direcția dorită de mișcare și erou trebuie să fie mișcat în această direcție. Există diferite scenarii care trebuie luate în considerare odată ce am determinat direcția dorită de mișcare. Să spunem că țigla de lângă erou în această direcție este Tilek.

  • Există o piesă în scenă în acea poziție sau este în afara rețelei noastre?
  • Este o faianta pliabila?
  • Tigla este ocupată de o minge?

Dacă poziția plăcii este în afara rețelei, nu trebuie să facem nimic. Dacă tileK este validă și este în mișcare, atunci trebuie să ne mișcăm erou la acea pozitie si actualizati-ne levelData matrice. Dacă tileK are o minge, atunci trebuie să luăm în considerare următorul vecin în aceeași direcție, să zicem tileL.

  • Este în afara grilei?
  • Tigla este o placuta?
  • Tigla este ocupata de o minge?

Numai în cazul în care tileL este o țiglă, nefolosită, trebuie mutată erou și mingea de la tileK la tileK și tileL respectiv. După mișcarea de succes, trebuie să actualizăm levelData mulțime.

Sprijinirea funcțiilor

Logica de mai sus înseamnă că trebuie să știm care este țigla noastră erou este în prezent la. De asemenea, trebuie să determinăm dacă o anumită țiglă are o minge și ar trebui să aibă acces la acea minge. 

Pentru a facilita acest lucru, folosim a Dicţionar denumit ocupanți care stochează a GameObject ca cheie și indici de matrice stocate ca Vector2 ca valoare. În CreateLevel metoda, vom popula ocupanți când creăm erou sau minge. După ce avem dicționarul populat, putem folosi funcția GetOccupantAtPosition pentru a reveni GameObject la un indice de matrice dat.

Dicţionar ocupanții; // referință la bile & eroi // ... ocupants.Add (erou, new Vector2 (i, j)); // păstrați indicii nivelului eroului în dict // ... occupants.Add (minge, new Vector2 (i , j)); // a stoca indicele de nivel al mingii in dict // ... privat GameObject GetOccupantAtPosition (Vector2 heroPos) // bucla prin ocupanti pentru a gasi mingea la o pozitie pozitiva GameObject ball; foreach (KeyValuePair pereche în ocupanți) if (pair.Value == heroPos) ball = pair.Key; mingea de întoarcere;  întoarce null; 

IsOccupied metoda determină dacă levelData valoarea la indicii furnizați reprezintă o minge.

privat bool IsOccupied (Vector2 objPos) // a verifica dacă există o minge la poziția dată de întoarcere a matricei (levelData [(int) objPos.x, (int) objPos.y] == ballTile || levelData [(int) objPos. x, (int) objPos.y] == ballOnDestinationTile); 

De asemenea, avem nevoie de o modalitate de a verifica dacă o anumită poziție este în interiorul grilajului nostru și dacă acea țiglă este în mișcare. IsValidPosition metoda verifică indicii de nivel transmiși ca parametri pentru a determina dacă acestea intră în interiorul dimensiunilor noastre de nivel. De asemenea, verifică dacă avem un invalidTile ca acel indice în levelData.

privat bool IsValidPosition (Vector2 objPos) // verifica dacă indicii dat se încadrează în dimensiunile matricei dacă (objPos.x> -1 && objPos.x-1 && objPos.y

Răspunsul la intrarea utilizatorului

În Actualizați metoda de script-ul nostru de joc, vom verifica pentru utilizator keyup evenimente și comparați cu cheile de intrare stocate în userInputKeys matrice. Odată ce direcția dorită de mișcare este determinată, sunăm TryMoveHero metoda cu direcția ca parametru.

void Update () if (gameOver) retur; (Input.GetKeyUp (userInputKeys [0])) TryMoveHero (0); // up altfel dacă (Input. GetKeyUp (userInputKeys [1])) TryMoveHero (1); // right else dacă (Input.GetKeyUp (userInputKeys [2])) TryMoveHero (2) 3])) TryMoveHero (3); // stânga

TryMoveHero metoda este în cazul în care logica noastră de joc de bază explicată la începutul acestei secțiuni este pusă în aplicare. Vă rugăm să treceți cu atenție următoarea metodă pentru a vedea cum este implementată logica așa cum este explicat mai sus.

void privat TryMoveHero (direcția int) Vector2 heroPos; Vector2 oldHeroPos; Vector2 nextPos; occupants.TryGetValue (erou, out oldHeroPos); hepPos = GetNextPositionAlong (oldHeroPos, direcție); // găsiți următoarea poziție a matricei în direcția dată dacă (IsValidPosition (heroPos)) // verificați dacă este o poziție validă și cade în interiorul matricei de nivel dacă (! IsOccupied (heroPos)) // verifica dacă este ocupată de o minge // muta eroul RemoveOccupant (oldHeroPos); // reinițializează datele de nivel vechi la poziția veche hero.transform.position = GetScreenPointFromLevelIndices ((int) heroPos.x, (int) heroPos.y ); ocupanților [erou] = heroPos; dacă (levelData [(int) heroPos.x, (int) heroPos.y] == groundTile) // se deplasează pe un nivel de țiglă de terenData [(int) heroPos.x, (int) heroPos.y] = heroTile;  altfel dacă (levelData [(int) heroPos.x, (int) heroPos.y] == destinationTile) // deplasarea pe un nivel de destinație a dalelorData [(int) heroPos.x, (int) heroPos.y] = heroOnDestinationTile ;  altfel // avem o minge lângă erou, verificați dacă este gol de cealaltă parte a mingii nextPos = GetNextPositionAlong (heroPos, direcție); dacă (IsValidPosition (nextPos)) if (! IsOccupied (nextPos)) // am găsit vecinul gol, deci trebuie să mutați ambele minge și erou GameObject ball = GetOccupantAtPosition (heroPos) (bila == null) Debug.Log ("fără minge"); RemoveOccupant (heroPos); / / mingea trebuie să fie mutată mai întâi înainte de mutarea eroului ball.transform.position = GetScreenPointFromLevelIndices ((int) nextPos.x, (int) nextPos.y); ocupanți [minge] = nextPos; dacă (nivelData [(int) nextPos.x, (int) nextPos.y] == groundTile) nivelData [(int) nextPos.x, (int) nextPos.y] = ballTile;  altfel dacă (nivelData [(int) nextPos.x, (int) nextPos.y] == destinațieTilă) levelData [(int) nextPos.x, (int) nextPos.y] = ballOnDestinationTile;  RemoveOccupant (oldHeroPos); // acum mutați eroul hero.transform.position = GetScreenPointFromLevelIndices ((int) heroPos.x, (int) heroPos.y); ocupanților [erou] = heroPos; dacă (nivelData [(int) heroPos.x, (int) heroPos.y] == groundTile) nivelData [(int) heroPos.x, (int) heroPos.y] = heroTile;  altfel dacă (levelData [(int) heroPos.x, (int) heroPos.y] == destinațieTilă) levelData [(int) heroPos.x, (int) heroPos.y] = heroOnDestinationTile;  CheckCompletion (); // verificați dacă toate bilele au ajuns la destinații

Pentru a obține următoarea poziție pe o anumită direcție bazată pe o poziție furnizată, folosim GetNextPositionAlong metodă. Este doar o chestiune de creștere sau de diminuare a fiecăruia dintre indicii în funcție de direcție.

privat Vector2 GetNextPositionAlong (Vector2 objPos, direcție int) comutare (direcție) caz 0: objPos.x- = 1; // pauză sus; cazul 1: objPos.y + = 1; // pauza dreapta; cazul 2: objPos.x + = 1; // pauză jos; cazul 3: objPos.y- = 1; // pauză la stânga;  retur objPos; 

Înainte de a muta un erou sau o minge, trebuie să ștergem poziția lor ocupată în prezent în levelData matrice. Acest lucru se face folosind RemoveOccupant metodă.

(int) objPos.y] == ballProgramul de instrucțiuni de instrucțiuni de instruire, ) levelData [(int) objPos.x, (int) objPos.y] = groundTile; // mutarea mingii din tigla de sol altfel daca (levelData [(int) objPos.x, (int) objPos.y] == heroOnDestinationTile) levelData [(int) objPos.x, (int) objPos.y] = destinationTile; // eroul se deplasează din placa destinație altceva dacă (levelData [(int) objPos.x, (int) objPos.y] = = ballOnDestinationTile) levelData [(int) objPos.x, (int) objPos.y] = destinationTile; // mutarea mingii de la placa de destinație

Dacă găsim a heroTile sau ballTile la indexul dat, trebuie să-l setăm groundTile. Dacă găsim a heroOnDestinationTile sau ballOnDestinationTile atunci trebuie să o punem destinationTile.

Completarea nivelului

Nivelul este completat atunci când toate bilele sunt la destinație.

După fiecare mișcare de succes, sunăm CheckCompletion pentru a vedea dacă nivelul este finalizat. Ne străduim prin noi levelData și numără numărul ballOnDestinationTile evenimente. Dacă acest număr este egal cu numărul total de bile determinat de ballCount, nivelul este complet.

void privat CheckCompletion () int ballsOnDestination = 0; pentru (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;  

Concluzie

Aceasta este o implementare simplă și eficientă a logicii sokoban. Puteți crea propriile niveluri prin modificarea fișierului text sau crearea unui nou și modificarea acestuia levelName pentru a indica noul fișier text. 

Actuala implementare folosește tastatura pentru a controla eroul. Vă invit să încercați să schimbați controlul pe bază de robinet, astfel încât să putem sprijini dispozitivele touch-based. Acest lucru ar presupune adăugarea unor descoperiri 2D de traseu, de asemenea, dacă vă place să atingeți pe orice țiglă pentru a conduce eroul acolo.

Va exista un tutorial de urmărire în care vom explora modul în care proiectul curent poate fi folosit pentru a crea versiuni izometrice și hexagonale ale sokobanului cu modificări minime.