Tutorial MineSweeper JS

@mike

Mi-am dat seama că-i o idee mai bună decât X și 0. O să facem MineSweeper cum nu se poate mai vanilla, doar cu JavaScript și un minim de HTML și CSS. Chiar sper să înveți ceva 🧠

Avem nevoie de trei fișiere mari și late:

  • index.html
  • style.css
  • script.js

🏠 Setup HTML

Sper că știi cum funcționează MineSweeper — avem nevoie de o matrice de n×nn \times n. Pe acest nn îl vom inițializa direct în fișierul JS că mi-e lene să mai fac un input separat pentru el, deși așa ar fi frumos — poate faci tu asta! În rest, vom avea nevoie așadar de niște tag-uri pentru matricea respectivă.

Cum matricea e literalmente un grid și tu ziceai că știi CSS, probabil că și tu te gândești să punem celulele într-un container cu display: grid. Bună idee! Alte variante ar fi <table> (foarte proastă din punct de vedere semantic — noi nu facem un tabel totuși) și nn <div>-uri, fiecare la rândul său având nn <div>-uri, însă ar fi mult de muncă și ca HTML și ca CSS.

Hai să facem mai întâi restul HTML-ului. Scrii ! în index.html și dai enter. Păstrezi doar tag-urile <head> și <body>, pe care din păcate trebuie să le indentezi manual, pentru că Emmet e idiot. Ca titlu pui MineSweeper. Pentru restul <body>-ului trebuie să incluzi CSS-ul și JS-ul. Până și eu uit adesea cum se face asta, așa că pur și simplu cauți pe net.

După toate acestea, mai trebuie doar să scriem codul efectiv din <body>. Cum nn-ul este o constantă pe care am vrea să o putem modifica ușor din cod (în JS mai exact, după cum spuneam și mai sus), vom genera nodurile aferente celulelor dinamic, adică în JS, în funcție de nn. Aceste celule trebuie ținute însă într-un container — de ce nu, un <div> cu id-ul table. De ce id și nu clasă? Pentru că va trebui să ne putem referi la acest nod în JS atunci când vrem să-i creăm copiii corespunzători celulelor tablei de joc.

<!DOCTYPE html>
<html>
  <head>
    <link rel="stylesheet" href="style.css">
    <script src="script.js"></script>
    <title>MineSweeper</title>
  </head>
  <body>
    <div id="table"></div>
  </body>
</html>

🏡 Setup CSS

Continuăm cu un minim de CSS ca să ne putem uita la ceea ce facem fără să ne doară ochii. Asta înseamnă în primul rând să facem background negru și foreground alb:

body {
  background-color: #222;
}

button {
  color: #222;
  background-color: #ddd;
}

Apoi, hai să centrăm matricea, atât vertical cât și orizontal, pe tot ecranul:

body {
  display: flex;
  justify-content: center;
  align-items: center;
  margin: 0;
  height: 100vh;
}

Trecem la celule. Acestea trebuie să fie clickable, deci ar fi o idee bună să fie butoane. Pentru a fi pătrate vom folosi aceeași valoare pentru lățime și lungime — 2rem să zicem. Am folosit rem pentru că ele vor conține doar un emoji (bombă sau steag) sau număr — în orice caz, text, mai precis un singur caracter. Deci cred că are sens ca dimensiunea unui buton să aibă legătură cu dimensiunea unei litere. Setăm și font-size la 1rem, pentru că default-ul în cazul butoanelor este o valoare idioată:

button {
  width: 2rem;
  height: 2rem;
  font-size: 1rem;
}

Ar fi frumos să și centrăm textul. Pe verticală observăm că este deja centrat (cred că e ceva default la butoane), însă pentru orizontală trebuie să scriem noi text-align: center. Ar trebui scos padding-ul — pentru o lățime prea mică strică lucrurile făcând un overflow intern, iar pentru o lățime prea mare este inutil.

button {
  padding: 0;
  text-align: center;
}

Să corectăm și cele două default-uri tâmpite ale butoanelor:

button {
  border: none;
  cursor: pointer;
}

Trecem și la container. Ne vom referi la id-ul table, deci folosim sintaxa cu #. Îl facem grid cu un gap cinstit (tot în rem, ca să fie proporțional cu celulele). Scoatem și user-select-ul așa cum îți place ție.

#table {
  display: grid;
  gap: .25rem;
  user-select: none;
}

Poate te întrebi de ce n-am setat încă grid-template-columns, adică structura (dimensiunile) grid-ului. Ei bine, din nou, încă nu cunoaștem nn, așa că vom seta și acest aspect din JS.

Iată CSS-ul final:

body {
  display: flex;
  justify-content: center;
  align-items: center;
  margin: 0;
  height: 100vh;
  background-color: #222;
}

#table {
  display: grid;
  gap: .25rem;
  user-select: none;
}

button {
  padding: 0;
  width: 2rem;
  height: 2rem;
  font-size: 1rem;
  text-align: center;
  color: #222;
  background-color: #ddd;
  border: none;
  cursor: pointer;
}

🏓 Setup JS

Hai să setăm odată nn-ul ăla. Evident, în cod n-o să-i spunem n. E prea puțin sugestiv. O să-i spunem GRID_SIZE. De ce SCREAMING_SNAKE_CASE? Pentru că e genul ăla de constantă — o constantă în sens matematic — o primitivă (număr, bool sau string) care există pe tot parcursul programului (sau măcar al unei funcții) și a cărei valoare nu se schimbă. A nu se confunda cu contextul general în care folosim const, mai ales că în 90%90\% dintre cazuri acele const-uri sunt obiecte (și nu primitive).

const GRID_SIZE = 5;

În restul codului vom manipula noduri din DOM (printre altele), deci ar fi bine ca acestea să existe înainte să lucrăm cu ele. Cu alte cuvinte, înainte să executăm codul, ar trebui să așteptăm ca nodurile din puținul HTML de mai sus să fie create. Așadar, acesta va fi scris în funcția window.onload, care beneficiază de un nume foarte sugestiv:

window.onload = () => {
  // TODO
};

OK, hai să vorbim oleacă despre toată treaba cu onload. Abia acum, scriind tutorialul ăsta, am realizat exact când și de ce este posibil să nu avem nevoie de el, și de ce eu sunt singurul om din lume care-l folosește pentru simplul fapt că vrea să facă un document.getElementById. Adică știam prea bine dar mi-era lene să pun lucrurile cap la cap.

Deci, când browser-ul parsează o pagină HTML, o face caracter cu caracter, evident. Ceea ce nu este evident însă, este ce se întâmplă când dă de un tag <script>. Ei bine, parsează (și execută simultan!) codul JS din scriptul respectiv, fie că se află într-un fișier .js separat, fie că este scris chiar acolo în HTML. Cu alte cuvinte, randează HTML-ul de până la linia ii (exclusiv), apoi execută scriptul de la linia ii, iar abia apoi continuă cu randatul HTML-ului, de la linia i+1i + 1 încolo.

Eu ziceam că avem nevoie să executăm tot codul în care accesăm elemente din DOM funcția de callback* pasată lui window.onload. Cea din urmă execută funcția pe care i-o dai după ce s-a randat tot HTML-ul, deci după ce s-a ajuns cu parsarea la linia </html>. Astfel, suntem siguri că elementele pe care le manipulăm în JS există înainte să ne atingem de ele.

Lumea în general nu face asta cu onload. Cum așa? Având în vedere cele de mai sus, ar trebui să te prinzi, dar o să-ți zic și eu. Pun linia cu <script> la finalul <body>-ului. Astfel, scriptul va fi rulat după ce toate tag-urile din <body> au fost randate. Anyway, nu mai am chef să modific codul așa că o să las așa.

De fapt, o să fac o chestie și mai bună. O să adaug atributul defer la tag-ul <script>. Ce face defer? Îi spune browser-ului că, imediat cum ajunge la <script>, poate începe să parseze scriptul respectiv în timp ce continuă și parsarea HTML-ului. Desigur, procesarea JS-ului se va efectua într-un thread separat (dacă nu știi ce e un thread, te poți gândi că un procesor se va ocupa de HTML și altul de JS, dar în același timp). Astfel, pagina se va termina de încărcat mai repede.

<script src="script.js" defer></script>

* Numim funcție de callback o funcție care este pasată ca parametru unei alte funcții. În cazul nostru, într-un apel de genul window.onload(fun), fun este funcția de callback. De ce am pasa o funcție ca parametru la o altă funcție? Pentru ca funcția principală să o poată executa pe cea dată de noi în implementarea sa, în diverse locuri cheie.


Notă: Am tot interschimbat cuvintele parsare, randare și executare — poate că este confusing. Parsarea este procesul prin care calculatorul citește niște cod și îl… înțelege cum ar veni, transformându-l într-o reprezentare cu care poate lucra mai ușor. În cazul limbajelor compilate, codul este executat abia la final, după un set complex de prelucrări efectuate pe reprezentarea aia generată de parsare.

HTML, CSS și JS nu sunt limbaje compilate, mai ales primele două, care nici nu sunt limbaje de programare. Așadar, în cazul lor, codul este „executat” în timp ce este parsat — asta se numește interpretare. În cazul HTML îi zic randare, pentru că interpretarea HTML-ului constă în a afișa niște noduri pe ecran. La CSS îi zic executare pentru că vorbim despre aplicarea unor reguli pe ceva randat. La JS cred că e și mai clar de ce îi zic executare.

🍫 Crearea Tablei de Joc

Mai întâi preluăm container-ul, ca să știm unde să inserăm celule:

const tableNode = document.getElementById('table');

Apoi, facem ce ziceam mai sus prin secțiunea despre CSS, și anume specificăm valoarea lui grid-template-columns. Ea trebuie să conțină un șir de lățimi, a ii-a fiind lățimea celei de-a ii-a coloane (în pixeli sau ce mai vrei). Noi vrem ca ele să fie egale, deci vom scrie 1fr (one fraction) la fiecare. Putem repeta valoarea asta de nn ori scriind niște JS, dar putem rezolva problema mai ușor bazându-ne pe funcția repeat din CSS, care ne cere (din JS) doar numărul de repetiții:

tableNode.style.gridTemplateColumns = `repeat(${GRID_SIZE}, 1fr)`;

În JS, proprietățile CSS sunt accesate nu cu kebab-case, pentru că nici n-ar compila, ci cu camelCase. Iar sintaxa cu ${...} se numește template literal, pune valoarea expresiei dinăuntru în string, iar acesta trebuie să fie mărginit de backtick și nu de ' sau ".

Următorul pas constă în a crea și a adăuga cele n2n^2 celule la tablă. Codul de mai jos ar trebui să fie foarte self-explanatory:

for (let i = 0; i < GRID_SIZE * GRID_SIZE; i++) {
  const cellNode = document.createElement('button');
  cellNode.innerText = i;
  tableNode.appendChild(cellNode);
}

A treia linie este OK, chiar dacă i este număr, pentru că acesta va fi convertit automat la string (având în vedere că innerText trebuie să fie de tip string). Momentan am vrut doar ca fiecare celulă să conțină indicele ei în container.


Ce sunt cellNode și tableNode? Niște referințe la niște noduri din DOM. Cum așa? Păi, nodurile din DOM clar nu sunt reprezentate ca niște primitive, ci ca niște obiecte complexe, deci are sens să fie niște referințe (și conceptual și efectiv). Ce însemna o referință? Un tip de variabilă care ne indică o altă variabilă (deci care pointează către o zonă de memorie).

Revenind la cazul nostru concret, insist pe ideea de referință pentru că, poți spune că, uite, cellNode e o variabilă locală, deci după } ea dispare. Cum de prelucrările efectuate asupra ei rămân și după }? Sau, în primul rând, cum de au efect și în nodul efectiv din DOM? Păi, lucrurile funcționează pentru că childNode este o referință către nodul real din DOM.

💣 Plasarea Bombelor

Înainte să calculăm numerele ascunse de celule, trebuie să plasăm bombele pe hartă. În acest sens, vom crea o matrice de n×nn \times n, implicit cu linii și coloane de la 00 la n1n - 1, și o vom umple cu valori booleene — true înseamnă că în celula respectivă avem o bombă, false înseamnă că nu.

O matrice este un vector de vectori. Din păcate, pentru a crea o matrice în JS, cea mai simplă metodă constă în a adăuga rând pe rând câte un vector (linie) la finalul vectorului principal. Apoi, la fiecare astfel de vector, inițial gol, adăugăm rând pe rând câte o celulă, deci câte un bool în cazul nostru.

Bombele sunt generate random, deci vom alege la întâmplare dacă să punem bombă în celula curentă sau nu. Probabilitatea să avem bombă ar trebui să fie relativ mică, ca să nu umplem harta prea tare — hai să zicem .25.25. Generăm deci un număr random între 00 și 11 (exclusiv), folosind Math.random, iar dacă această valoare este mai mică decât .25.25 punem true, altfel punem false. Cu alte cuvinte, punem valoarea de adevăr a propoziției Math.random() < .25.

const bombs = [];
for (let i = 0; i < GRID_SIZE; i++) {
  bombs.push([]);
  for (let j = 0; j < GRID_SIZE; j++) {
    bombs[i].push(Math.random() < .25);
  }
}

Ca să vedem rezultatul frumos, va trebui să mapăm (să facem binding) matricea bombs la nodurile din DOM. Problema este că nu am reținut nicăieri referințele lor. Prin urmare, ne vom întoarce la prima parte din script, cea unde le cream, și o vom edita un pic, astfel încât să le reținem într-o matrice:

const tableNode = document.getElementById('table');
tableNode.style.gridTemplateColumns = `repeat(${GRID_SIZE}, 1fr)`;

const cellNodes = [];
for (let i = 0; i < GRID_SIZE; i++) {
  cellNodes.push([]);
  for (let j = 0; j < GRID_SIZE; j++) {
    cellNodes[i].push(document.createElement('button'));
    tableNode.appendChild(cellNodes[i][j]);
  }
}

Acum ar fi frumos să facem o funcție care să facă binding-ul de care vorbeam mai sus. Nimic mai simplu!

const bindBombsToDOM = () => {
  for (let i = 0; i < GRID_SIZE; i++) {
    for (let j = 0; j < GRID_SIZE; j++) {
      cellNodes[i][j].innerText = bombs[i][j] ? '💣' : '';
    }
  }
};

Momentan am vrut doar să vedem bombele, însă funcția va deveni ceva mai complexă, într-un viitor foarte apropiat. Hai s-o apelăm ca să vedem că merge:

bindBombsToDOM();

Ups! În loc de bombe apar niște caractere dubioase. Mi-am dat seama că la începutul HTML-ului am șters din ignoranță linia de mai jos din <head>, care este crucială pentru a putea afișa fără bătăi de cap caractere precum emoji-uri sau diacritice:

<meta charset="UTF-8">

A mers!! Asta dacă ai fost în stare să copiezi secvențele de cod în locurile potrivite. Sunt convins că ai fost în stare 🦆

🏘️ Analiza Vecinilor

Având bombele la dispoziție, trebuie ca pentru fiecare celulă să calculăm numărul de vecini ai săi care conțin bombe. Vom crea o nouă matrice matrix, care va conține valorile mai devreme menționate pentru celulele fără bombă și 1-1 pentru celelalte.

const matrix = [];
for (let i = 0; i < GRID_SIZE; i++) {
  matrix.push([]);
  for (let j = 0; j < GRID_SIZE; j++) {
    if (bombs[i][j]) {
      matrix[i].push(-1);
    }
    else {
      // TODO
    }
  }
}

Pentru calcularea numărului de vecini ai unei celule fixate, ne uităm mai întâi la coordonatele vecinilor scrise în funcție de celula curentă:

(i1,j1)(i - 1, j - 1) (i1,j)(i - 1, j) (i1,j+1)(i - 1, j + 1)
(i,j1)(i, j - 1) (i,j)\cancel{\bcancel{(i, j)}} (i,j+1)(i, j + 1)
(i+1,j1)(i + 1, j - 1) (i+1,j)(i + 1, j) (i+1,j+1)(i + 1, j + 1)

Astfel, deducem valorile Δx\Delta x și Δy\Delta y ale fiecăruia dintre vecini — cu cât se modifică ii-ul și respectiv jj-ul celulei curente atunci când ne deplasăm în vecinul respectiv. Putem itera printre aceste perechi (Δx,Δy)(\Delta x, \Delta y) astfel:

for (let deltaX = -1; deltaX <= +1; deltaX++) {
  for (let deltaY = -1; deltaY <= +1; deltaY++) {
    if (deltaX === 0 && deltaY === 0) continue; // celula curentă
    const newX = i + deltaX;
    const newY = j + deltaY;
    // TODO
  }
}

Ăsta-i un exemplu bun ca să vezi ce frumos este să folosim continue-uri la locul și momentul potrivit, ca să evităm prea multe if-uri și, în general, prea multă indentare.

După ce am calculat coordonatele vecinului, trebuie să verificăm că acesta există cu adevărat, altfel spus că nu iese în afara matricei:

if (!(0 <= newX && newX < GRID_SIZE)) continue;
if (!(0 <= newY && newY < GRID_SIZE)) continue;

În rest, nu avem decât să incrementăm un contor count de fiecare dată când găsim un vecin ce conține bombă:

let count = 0;
for (let deltaX = -1; deltaX <= +1; deltaX++) {
  for (let deltaY = -1; deltaY <= +1; deltaY++) {
    if (deltaX === 0 && deltaY === 0) continue;
    const newX = i + deltaX;
    const newY = j + deltaY;
    if (!(0 <= newX && newX < GRID_SIZE)) continue;
    if (!(0 <= newY && newY < GRID_SIZE)) continue;
    count += bombs[newX][newY];
  }
}
matrix[i].push(count);

Sper că-i OK expresia count += bombs[newX][newY], am mai discutat noi despre ea. Valoarea din dreapta este un bool, dar se convertește singur la număr (deci la valoarea 11) când vede acel += precedat de un număr.

Mai schimbăm innerText-ul în funcția de binding și putem testa:

cellNodes[i][j].innerText = matrix[i][j];

🎨 Randarea Tablei de Joc

Acum vrem să afișăm corect tabla de joc. În primul rând, avem nevoie de o nouă matrice, state care să ne indice starea fiecărei celule în parte. O celulă poate fi 'visible' (dacă a fost deblocată), 'hidden' (dacă nu a fost încă deblocată) sau 'marked' (dacă am pus un steguleț pe ea). Inițial, toate celulele sunt 'hidden', dar noi vom pune momentan 'visible', pentru a vedea rezultatul mai repede:

const state = [];
for (let i = 0; i < GRID_SIZE; i++) {
  state.push([]);
  for (let j = 0; j < GRID_SIZE; j++) {
    state[i].push('visible');
  }
}

Restul codului se va petrece din nou în funcția de binding. Să vedem cum ar arăta o celulă în funcție de state. Păi, una ascunsă ar fi albă și n-ar conține nimic. Una vizibilă ar conține un număr sau o bombă. Una marcată ar conține un steag și ar fi tot albă. Ca să fie mai șmecher, hai să le facem pe alea vizibile verzi, ca iarba, ba chiar nuanța de verde să depindă de conținut. Numerele mai mici să aibă un verde mai deschis, iar cele mai mari unul mai închis. Bomba va avea cel mai deschis verde. Ah, încă un detaliu, cele cu numărul 00 nu vor mai conține niciun text.

const bindBombsToDOM = () => {
  for (let i = 0; i < GRID_SIZE; i++) {
    for (let j = 0; j < GRID_SIZE; j++) {
      if (state[i][j] === 'visible') {
        cellNodes[i][j].innerText = matrix[i][j] === -1 ? '💣' : matrix[i][j] > 0 ? matrix[i][j] : '';
        const green = 255 - (matrix[i][j] === -1 ? 0 : (matrix[i][j] + 1) / 9 * 255);
        cellNodes[i][j].style.backgroundColor = `rgb(0, ${green}, 0)`;
      }
      else if (state[i][j] === 'marked') {
        cellNodes[i][j].innerText = '🚩';
      }
    }
  }
};

Linia aia cu green e doar mate, așa că te las s-o aprofundezi singur. Ce ar mai fi de zis? Nu știu, expresia aia de la backgroundColor este o funcție din CSS, de care sper că n-ai uitat, care-ți creează o culoare cu componentele RGB date. Evident, noi lucrăm doar cu verde, deci o să-i dăm verdele (un număr în intervalul [0,255][0, 255]) iar roșul și albastrul vor fi zero.

⛹️‍♂️ Interacțiunea cu Jucătorul

Setăm stările înapoi la 'hidden' și ne ocupăm de preluarea click-urilor jucătorului. Când acesta dă click pe o celulă trebuie să se întâmple ceva — să se execute o funcție. Faza e că funcția aia diferă de la celulă la celulă, pentru că în fiecare caz vom lucra cu coordonate diferite. Deci, fiecare celulă va primi propria ei funcție.

Cum nodurile au fost create dinamic în JS, evident că și listenerele de evenimente vor fi adăugate dinamic. Cu alte cuvinte, nu putem scrie onclick="ceva cod JS" în HTML, așa că vom scrie:

for (let i = 0; i < GRID_SIZE; i++) {
  for (let j = 0; j < GRID_SIZE; j++) {
    cellNodes[i][j].addEventListener('click', () => {
      if (state[i][j] === 'hidden') {
        state[i][j] = 'visible';
        bindBombsToDOM();
      }
    });
  }
}

Funcția addEventListener primește ca parametri tipul de eveniment și o funcție de callback, care va fi executată (de către browser) atunci când evenimentul respectiv are loc (pe elementul unde atașăm listener-ul).

De remarcat că variabilele i și j sunt capturate de funcția de callback (cea cu săgeată) chiar dacă ele sunt declarate în afara ei. OK, asta de fapt nu-i ceva nou sau spectaculos. Ce este de fapt interesant este că, atunci când funcția de callback va fi apelată, variabilele i și j nu vor mai fi existat de mult timp. Însă codul funcționează, deoarece, atunci când funcția de callback este creată, variabilele de genul sunt copiate într-o zonă de memorie specială, asociată funcției. Astfel, ele vor exista atâta vreme cât va exista și funcția de callback — în cazul nostru, pe tot restul execuției programului. Chestia asta se numește closure.

Acum, revenind la codul de mai sus, ce am făcut: Păi, am zis că dacă omul dă click pe celula (i,j)(i, j) și aceasta era ascunsă, atunci va deveni vizibilă și desigur vom redesena tabla de joc, apelând bindBombsToDOM.

Mai trebuie să ne ocupăm de evenimentul click-dreapta. La un search pe Google vedem că în JS acesta se numește de fapt 'contextmenu'. Super. În această funcție de callback trebuie să marcăm sau să demarcăm o celulă vizibilă:

cellNodes[i][j].addEventListener('contextmenu', () => {
  if (state[i][j] === 'hidden') {
    state[i][j] = 'marked';
    bindBombsToDOM();
  }
  else if (state[i][j] === 'marked') {
    state[i][j] = 'hidden';
    bindBombsToDOM();
  }
});

Testăm asta și vedem că funcționează, însă la finalul click-ului ne apare un… context-menu. Ne dăm seama că ăsta e ceva comportament default al browser-ului, deci vrem să-l anulăm. Pentru asta, și pentru multe alte chestii de genul, în general, trebuie să capturăm evenimentul (este vorba de un obiect care conține diverse informații legate de eveniment în sine) și să-i apelăm metoda preventDefault — lucru care de obicei se face la finalul funcției, dar este irelevant:

cellNodes[i][j].addEventListener('contextmenu', event => {
  if (state[i][j] === 'hidden') {
    state[i][j] = 'marked';
    bindBombsToDOM();
  }
  else if (state[i][j] === 'marked') {
    state[i][j] = 'hidden';
    bindBombsToDOM();
  }
  event.preventDefault();
});

Cu ocazia asta, îți amintesc ceva important legat de funcțiile din JS: O funcție poate fi apelată cu mai mulți parametri decât cere ea de fapt — surplusul se duce doar în neant. În cazul nostru, este vorba de funcția de callback, care inițial primea zero parametri. Aceasta era apelată de către browser cu parametrul event, însă inițial noi nu-l capturam pentru că nu aveam nevoie de el, însă el era transmis oricum. Asta era ca să înțelegi de ce puteam da și funcție fără parametri, dar și ca să vezi conceptual cum e făcut apelul ăla intern — nu e vorba de vreun if care vede câți parametri primește funcția dată ca parametru. Din câte știu, nici nu ai cum să faci asta.

Ultima chestie care a rămas de făcut este să anunțăm când este game over. Mergem în handler-ul pentru 'click' și scriem:

cellNodes[i][j].addEventListener('click', () => {
  if (state[i][j] === 'hidden') {
    state[i][j] = 'visible';
    bindBombsToDOM();
    if (bombs[i][j]) {
      alert('GAME OVER 😭');
    }
  }
});

Merge! Dar observăm ceva ciudat. Întâi apare alerta și abia după ce o închidem apare și bomba. De ce? E ceva ce am intuit pe moment: Modificările DOM-ului (în cazul nostru, o modificare a lui innerText de exemplu) nu se execută instant, ci ajung într-o coadă de unde sunt executate din xx în xx milisecunde — este mai eficient. OK, faza-i că înainte ca acel interval de xx milisecunde să se termine, codul nostru ajunge la apelul lui alert, care nu are de ce să nu se execute instant. După cu bine știi, ca utilizator de browser zic, atunci când ai o alertă, firul (unic) de execuție al JS-ului este blocat până când o închizi. Așadar, ar trebui să lăsăm ca instrucțiunile din coadă (inclusiv actualizarea reală a DOM-ului) să se execute, și abia dup-aia să apelăm alert. Iată soluția:

if (bombs[i][j]) {
  setTimeout(() => alert('GAME OVER 😭'), 0);
}

Probabil te întrebi cum de este ok să punem 0 acolo. Ei bine, te invit să citești partea despre event loop din acest articol al Taniei.

🫡 Sfârșit

Ce urmează? Să-ți iasă totul, să înțelegi totul, apoi să implementezi tu alerta pentru YOU WIN, că mie mi-era lene, și poate să mai adaugi chestii sau idk.

Pasul următor ar fi să refacem codul în Vue ca să înțelegi și tu diferența dintre vanilla și framework-uri. O să apreciezi Vue la adevărata sa valoare și o să înțelegi de ce avem nevoie de el, mai ales într-un proiect mare ca site-ul Domnului Cartof 🥔 Și o să înțelegi diferența între cod imperativ (vanilla) și cod declarativ (Vue).

Până una alta, na codul final 🫐-n 🫦:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="style.css">
    <script src="script.js" defer></script>
    <title>MineSweeper</title>
  </head>
  <body>
    <div id="table"></div>
  </body>
</html>
body {
  display: flex;
  justify-content: center;
  align-items: center;
  margin: 0;
  height: 100vh;
  background-color: #222;
}

#table {
  display: grid;
  gap: .25rem;
  user-select: none;
}

button {
  padding: 0;
  width: 2rem;
  height: 2rem;
  font-size: 1rem;
  text-align: center;
  color: #222;
  background-color: #ddd;
  border: none;
  cursor: pointer;
}
const GRID_SIZE = 5;

window.onload = () => {
  const tableNode = document.getElementById('table');
  tableNode.style.gridTemplateColumns = `repeat(${GRID_SIZE}, 1fr)`;

  const cellNodes = [];
  for (let i = 0; i < GRID_SIZE; i++) {
    cellNodes.push([]);
    for (let j = 0; j < GRID_SIZE; j++) {
      cellNodes[i].push(document.createElement('button'));
      tableNode.appendChild(cellNodes[i][j]);
    }
  }

  const bombs = [];
  for (let i = 0; i < GRID_SIZE; i++) {
    bombs.push([]);
    for (let j = 0; j < GRID_SIZE; j++) {
      bombs[i].push(Math.random() < .25);
    }
  }

  const matrix = [];
  for (let i = 0; i < GRID_SIZE; i++) {
    matrix.push([]);
    for (let j = 0; j < GRID_SIZE; j++) {
      if (bombs[i][j]) {
        matrix[i].push(-1);
      }
      else {
        let count = 0;
        for (let deltaX = -1; deltaX <= +1; deltaX++) {
          for (let deltaY = -1; deltaY <= +1; deltaY++) {
            if (deltaX === 0 && deltaY === 0) continue;
            const newX = i + deltaX;
            const newY = j + deltaY;
            if (!(0 <= newX && newX < GRID_SIZE)) continue;
            if (!(0 <= newY && newY < GRID_SIZE)) continue;
            count += bombs[newX][newY];
          }
        }
        matrix[i].push(count);
      }
    }
  }

  const state = [];
  for (let i = 0; i < GRID_SIZE; i++) {
    state.push([]);
    for (let j = 0; j < GRID_SIZE; j++) {
      state[i].push('hidden');
    }
  }

  const bindBombsToDOM = () => {
    for (let i = 0; i < GRID_SIZE; i++) {
      for (let j = 0; j < GRID_SIZE; j++) {
        if (state[i][j] === 'visible') {
          cellNodes[i][j].innerText = matrix[i][j] === -1 ? '💣' : matrix[i][j] > 0 ? matrix[i][j] : '';
          const green = 255 - (matrix[i][j] === -1 ? 0 : (matrix[i][j] + 1) / 9 * 255);
          cellNodes[i][j].style.backgroundColor = `rgb(0, ${green}, 0)`;
        }
        else if (state[i][j] === 'marked') {
          cellNodes[i][j].innerText = '🚩';
        }
      }
    }
  };

  for (let i = 0; i < GRID_SIZE; i++) {
    for (let j = 0; j < GRID_SIZE; j++) {
      cellNodes[i][j].addEventListener('click', () => {
        if (state[i][j] === 'hidden') {
          state[i][j] = 'visible';
          bindBombsToDOM();
          if (bombs[i][j]) {
            setTimeout(() => alert('GAME OVER 😭'), 0);
          }
        }
      });
      cellNodes[i][j].addEventListener('contextmenu', event => {
        if (state[i][j] === 'hidden') {
          state[i][j] = 'marked';
          bindBombsToDOM();
        }
        else if (state[i][j] === 'marked') {
          state[i][j] = 'hidden';
          bindBombsToDOM();
        }
        event.preventDefault();
      });
    }
  }
};