Î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.
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+.
Să vedem cum ne-am organizat proiectul Unity pentru acest tutorial.
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.
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.
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 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.
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
.
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.
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.
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.
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.
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ţionarocupanț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 utilizatorkeyup
evenimente și comparați cu cheile de intrare stocate înuserInputKeys
matrice. Odată ce direcția dorită de mișcare este determinată, sunămTryMoveHero
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țiiPentru 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 folosindRemoveOccupant
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țieDacă găsim a
heroTile
sauballTile
la indexul dat, trebuie să-l setămgroundTile
. Dacă găsim aheroOnDestinationTile
sauballOnDestinationTile
atunci trebuie să o punemdestinationTile
.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 noilevelData
și numără numărulballOnDestinationTile
evenimente. Dacă acest număr este egal cu numărul total de bile determinat deballCount
, 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.