Pentru că o mare parte dintre participanții de la atelierele CoderDojo Iași și-au exprimat dorința de a dezvolta jocuri, astăzi vom face un prim pas, începând cu unul simplu, foarte cunoscut sub numele de Breakout. Vom folosi în acest sens tehnologii specifice dezvoltării web, cum ar fi cele studiate în cadrul edițiilor CoderDojo Iași, folosind, printre altele, și platforma Codecademy. Astfel, pentru a înțelege cu adevărat implementarea, este recomandat să aveți cunoștințe fundamentale de HTML, CSS și, în principal, puțin mai avansate de JavaScript.
Vom urmări în acest tutorial ce implică dezoltarea unui joc ca cel din acest exemplu, pas cu pas, cu explicații pentru principalele elemente folosite. Vom mai avea și unele mici exerciții care, deși nu sunt necesare în terminarea jocului nostru, vi le recomand pentru a înțelege cu adevărat pașii urmați. Acest articol se bazează într-o proporție mare pe un altul găsit aici, pe care îl puteți urmări dacă aveți un nivel de cunoaștere al limbii engleze cât de cât avansat. De asemenea în această versiune am încercat să personalizez ceva mai mult jocul, să elimin unele lucruri de o relevanță mai scăzută și să adaug unele lucruri care, cred eu, pot face experiența mai plăcută.
Structura HTML
Într-un nou fișier index.html pe calculatorul vostru putem începe cu o structură clasică de fișier HTML, în care vom include un singur element care s-ar putea să vi se pară nou, un tag cu numele „canvas”. Acesta este folosit în special din cauza faptului că pe suprafața sa se pot desena elemente, figuri geometrice. Deci prima variantă a paginii noastre va arăta cam așa:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
<!DOCTYPE html> <html> <head> <title>Joc Breakout</title> <style> * { padding: 0; margin: 0; } canvas { background: #eee; display: block; margin: 0 auto; } </style> </head> <body> <canvas id="spatiuJoc" width="825" height="600"></canvas> <script> // codul nostru JavaScript va fi scris aici </script> </body> </html> |
Dacă deschideți fișierul cu acest conținut într-un browser veți vedea doar un chenar gri. Acesta este elementul nostru de tip canvas, pe care vom desena. Deși nu am făcut încă mai nimic, avem deja elementele de bază pe care vom putea construi jocul nostru.
Elementul canvas
Pentru a putea „desena” în elementul nostru de tip canvas va trebui în JavaScript să salvăm o referință către el. Pentru a face asta scrieți următoarele linii în interiorul tag-ului script:
1 2 |
var spatiuJoc = document.getElementById("spatiuJoc"); var context = spatiuJoc.getContext("2d"); |
În aceste linii obținem o referință la elementul nostru și apoi creăm o variabilă denumită context, care va fi unealta folosită să afișăm elemente. Doar pentru a vedea ce putem face cu acesta puteți copia următoarele linii în cadrul scriptului nostru, sub cele două deja existente:
1 2 3 4 5 |
context.beginPath(); context.rect(20, 40, 50, 50); context.fillStyle = "#FF0000"; context.fill(); context.closePath(); |
Dacă reîncărcați pagina în browser ar trebui să vedeți ceva de genul:
Toate instrucțiunile care desenează efectiv pătratul roșu sunt cuprinse între metodele beginPath și closePath. Întâi am definit pătratul folosind metoda rect, căreia i-am dat 4 parametri. Primii doi definesc de la ce coordonate să se înceapă figura noastră, numărătoarea începând din colțul stânga sus. Următorii doi specifică lățimea și înălțimea elementului desenat. Cu alte cuvinte, am instruit programul nostru să deseneze o figură începând la 20px distanță de marginea stânga și 40 de cea de sus și am specificat că ne dorim ca lățimea și înălțimea să fie de câte 50px, obținând astfel un pătrat la acea poziție.
Prin proprietatea fillStyle am decis că vrem să „umplem” figura noastră folosind culoarea roșu, iar metoda fill a desenat efectiv acest lucru. Desigur, nu suntem limitați la a folosi doar forme de acest gen. Copiați aceste linii la finalul scriptului nostru:
1 2 3 4 5 |
context.beginPath(); context.arc(240, 160, 20, 0, Math.PI * 2); context.fillStyle = "green"; context.fill(); context.closePath(); |
După ce reîncărcați pagina ar trebui să vedeți un cerc verde în apropierea pătratului roșu desenat mai devreme. După cum vedeți, procedeul este foarte similar. Diferența notabilă ar fi faptul că am folosit metoda arc, în loc de rect. Cu primii doi parametri am stabilit unde va fi centrul cercului nostru, al treilea, 20, a determinat raza cercului. Următorii doi parametri definesc unghiul de plecare și de sfârșit pentru desenul nostru. Nu vă faceți griji dacă nu sunteți familiarizați cu sensul acestor valori, cele folosite aici sunt standard dacă vrem să desenăm un cerc complet.
De asemenea, în loc să folosim metoda fill pentru a desena o formă „plină”, putem folosi o alta, denumită stroke, cu ajutorul căreia vom obține doar un contur. De exemplu, dacă veți copia următoarele linii la finalul scriptului nostru:
1 2 3 4 5 |
context.beginPath(); context.rect(160, 10, 100, 40); context.strokeStyle = "rgba(0, 0, 255, 0.5)"; context.stroke(); context.closePath(); |
ar trebui să obțineți un dreptunghi cu muchiile albastre.
Exercițiu: Înainte de a trece mai departe puteți petrece puțin timp încercând să schimbați dimensiunile și culorile formelor definite mai sus, pentru a vă familiariza cu ele.
Din ce am făcut până acum deja știți să desenați mingea din jocul nostru. Urmează să o facem să se miște. De fapt vom simula acest lucru, desenând-o, ștergând-o și desenând-o din nou la o altă poziție la fiecare pas.
Bucla de desenare
Jocul nostru va avea diverse elemente care se mișcă, apar sau dispar de pe ecran, așa că aproape tot timpul va trebui să schimbăm conținutul elementului nostru de tip canvas. Unii dintre voi v-ați familiarizat deja din anumite tutoriale folosite în precedentele ateliere cu ideea de buclă infinită, un set de inscrucțiuni menit să ruleze continuu, atâta timp cât programul funcționează. Ceva foarte similar vom face și acum. Ștergeți tot din interiorul scriptului nostru, în afară de primele două linii, și copiați în loc următoarele instrucțiuni:
1 2 3 4 |
function deseneaza() { // codul folosit pentru desenare } setInterval(deseneaza, 10); |
Am început prin a defini o funcție pe care o vom folosi pentru a desena conținutul nostru la fiecare pas, denumit și frame. Apoi folosind funcția JavaScript setInterval am făcut în așa fel încât tot ce vom scrie noi în deseneaza să fie executat din nou la fiecare 10 milisecunde. Acesta este un interval de timp foarte scurt, de o sută de ori mai mic decât o secundă, fiind aproape insesizabil pentru ochi, fiind astfel potrivit pentru a da senzația de mișcare contiuă.
Acum, în interiorul funcției deseneaza, copiați următoarele linii:
1 2 3 4 5 |
context.beginPath(); context.arc(50, 50, 10, 0, Math.PI * 2); context.fillStyle = "#0095DD"; context.fill(); context.closePath(); |
Aceste linii ar trebui să vă fie familiare deja din pașii precedenți. Dacă salvați fișierul și reîncărcați pagina, mingea noastră ar trebui să fie desenată pe ecran la fiecare 10 milisecunde. Desigur, nu vom avea senzația de mișcare pentru că o desenăm în același loc de fiecare dată.
Mișcarea mingii
Inițial am desenat mingea noastră la coordonatele (50, 50), dar acum le vom salva într-o pereche de variabile pentru a le putea schimba valorile. Vom folosi o convenție standard pentru coordonate și vom denumi aceste variabile x și y. Deasupra funcției noastre adăugați următoarele linii:
1 2 |
var x = spatiuJoc.width / 2; var y = spatiuJoc.height - 30; |
Deci pentru x am folosit o valoare egală cu jumătate din lățimea spațiului nostru de joc, elementul canvas, iar pentru y am folosit înălțimea, din care am scăzut 30px, pentru a avea suficient spațiu să desenăm mingea. Acum înlocuiți conținutul funcției noastre cu:
1 2 3 4 5 |
context.beginPath(); context.arc(x, y, 10, 0, Math.PI * 2); context.fillStyle = "#0095DD"; context.fill(); context.closePath(); |
Până aici nu am făcut decât să desenăm mingea într-un alt loc. Acum, pentru partea mai importantă, vom mai defini două variabile deasupra funcției noastre, imediat sub cele două existente, x și y:
1 2 |
var dx = 2; var dy = -2; |
Această notație, care folosește litera d înaintea unei valori, este una destul de comună pentru a reprezenta modificarea unei valori (pentru cei familiarizați cu geometria, vine de la delta). Deci aceste două variabile vor reprezenta valorile cu care modificăm poziția mingii la fiecare pas, una pentru lățime (dx) și una pentru înălțime (dy). În concluzie tot ce mai trebuie să facem acum este să folosim aceste două valori pentru a modifica cele două coordonate, x și y, la fiecare pas. Adăugați două linii la finalul funcție noastre astfel încât conținutul său să devină:
1 2 3 4 5 6 7 |
context.beginPath(); context.arc(x, y, 10, 0, Math.PI * 2); context.fillStyle = "#0095DD"; context.fill(); context.closePath(); x = x + dx; y = y + dy; |
Dacă salvați fișierul și reîncărcați pagina ar trebui să vedeți că mișcarea a început să funcționeze. Desigur, un inconvenient rămas ar fi faptul că mingea noastră pare să lase o urmă pe traseul său.
Eliberarea spațiului de joc
Mingea lasă o urmă din cauză că la fiecare pas o desenăm într-o nouă poziție, dar precedenta rămâne acolo de asemenea. Din fericire acest lucru este foarte ușor de rezolvat folosind metoda clearRect, care ne va ajuta să eliberăm spațiul nostru de joc. Adăugați următoarea linie la începutul funcției noastre, înainte de tot conținutul deja existent:
1 |
context.clearRect(0, 0, spatiuJoc.width, spatiuJoc.height); |
Această funcție elimită conținutul din elementrul nostru de tip canvas între coordonatele specificate. Folosind 0 pentru primii doi parametri i-am transmis să înceapă din colțul stânga sus, iar folosind lățimea și înălțimea elementului pentru următorii doi i-am descris colțul dreapta jos. Deci înainte de a desena ceva, funcția noastră va șterge conținutul spațiului de joc în întregime, permițându-ne să definim fiecare pas (frame) independent.
Organizarea codului
Este foarte normal pentru programatori ca uneori să se vadă nevoiți să își restructureze codul pentru a-l face mai eficient sau mai ușor de înțeles. Înlocuiți funcția deseneaza cu următoarele două:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function deseneazaMinge() { context.beginPath(); context.arc(x, y, 10, 0, Math.PI * 2); context.fillStyle = "#0095DD"; context.fill(); context.closePath(); } function deseneaza() { context.clearRect(0, 0, spatiuJoc.width, spatiuJoc.height); deseneazaMinge(); x += dx; y += dy; } |
După ce salvați veți observa că rezultatul este exact același. Întradevăr, nu am schimbat comportamentul jocului nostru, dar am organizat sursele puțin. Am extras logica necesară desenării mingii într-o funcție separată. Astfel este mai ușor să înțelegem funcția deseneaza și am pregătit terenul pentru următorii pași, în care vom avea de desenat și alte obiecte.
Exercițiu: încercați să schimbați viteza mingii sau direcția în care se mișcă.
Deja am făcut primele progrese, dar momentan mingea noastră dispare după ce ajunge la limita din dreapta a spațiului de joc. În continuare vom implementa o simplă detectare a coliziunii cu peretele și vom schimba direcția de mișcare când observăm acest lucru.
Detectarea atingerii pereților
Pentru a face calculele mai ușoare vom defini o variabilă în care vom reține raza mingii. Așa că în zona în care ați declarat varabile, sub cele existente, înainte de funcții, adăugați următoarea linie:
1 |
var razaMinge = 10; |
Iar în funcția deseneazaMinge, înlocuiți apelul care desenează mingea în așa fel încât să folosească noua variabilă în loc de valoarea statică 10:
1 |
context.arc(x, y, razaMinge, 0, Math.PI * 2); |
Există patru „pereți” în spațiul nostru de joc, să începem cu cel de sus. Deci trebuie să verificăm dacă mingea atinge marginea de sus, iar dacă da să îi schimbăm direcția, păstrând-o astfel în interiorul elementului nostru. Având în vedere că sistemul de coordonate începe din colțul stânga sus putem adăuga următorul bloc la finalul funcției deseneaza:
1 2 3 |
if (y + dy < 0) { dy = -dy; } |
Cu alte cuvinte: dacă poziția pe verticală (y) urmează să devină mai mică decât 0, schimbăm direcția de mișcare transformând valoarea lui dx în ea însăși inversată. Dacă acest cod acoperă coliziunea cu marginea de sus, atunci ceva similar am putea folosi și pentru cea de jos:
1 2 3 |
if (y + dy > spatiuJoc.height) { dy = -dy; } |
Deci și în cazul în care poziția pe aceeași axă devine mai mare decât cea maxim disponibilă vom schimba din nou direcția de mișcare pe verticală. Din cauză că ambele cazuri vor face același lucru, probabil știți că putem simplifica cele două expresii înlocuindu-le pe ambele cu:
1 2 3 |
if (y + dy > spatiuJoc.height || y + dy < 0) { dy = -dy; } |
Pentru a trata și cazurile de pe axa orizontală, deci peretele din stânga și cel din dreapta, vom folosi un bloc foarte similar, înlocuind y cu x, dy cu dx și înălțimea spațiului de joc cu lățimea sa:
1 2 3 |
if (x + dx > spatiuJoc.width || x + dx < 0) { dx = -dx; } |
În acest moment mingea ar trebui deja să își mențină mișcarea în interiorul elementului canvas și să își schimbe direcția la fieacre coliziune cu un perete. Totuși, dacă sunteți foarte atenți, ați putea observa că în momentul în care mingea ajunge la margine, pare să dispară parțial pentru foarte puțin timp înainte de a schimba direcția. Acest lucru se întâmplă din cauză că poziția noastră curentă, determinată de x și y, se referă la centrul mingii, deci jumătate din ea iese din spațiul de joc înainte să schimbăm direcția. Pentru a trata acest caz trebuie să luăm în calcul raza mingii și vom folosi variabila definită înainte să adăugăm regulile de coliziune. Practic în marginea de jos și cea din dreapta vom compara cu înălțimea și lățimea, dar din care scadem raza, iar pentru cele de sus și stânga vom compara cu (0+) raza mingii. Deci cele două reguli devin:
1 2 3 4 5 6 |
if (x + dx > spatiuJoc.width - razaMinge || x + dx < razaMinge) { dx = -dx; } if (y + dy > spatiuJoc.height - razaMinge || y + dy < razaMinge) { dy = -dy; } |
Exercițiu: încercați să schimbați culoarea mingii cu una aleatoare de fiecare dată când aceasta atinge un perete.
Momentan mingea noastră se respinge de cei pentru pereți, dar nu putem interacționa cu ea, iar un joc nu poate exista fără interacțiune din partea noastră. Așa că vom defini o paletă în partea de jos a ecranului, pe care o vom putea muta și de care mingea se va respinge.
Definirea unei palete pentru a lovi mingea
Să definim câteva variabile utile pentru paleta noastră. Adăugați următoarele definiții sub celelalte variabile existente, înaintea funcțiilor:
1 2 3 |
var inaltimePaleta = 10; var latimePaleta = 75; var paletaX = (spatiuJoc.width - latimePaleta) / 2; |
În aceste linii am definit o variabilă pentru a reține înălțimea paletei, una pentru lățimea sa și o a treia care va reține poziția pe axa orizontală unde începe paleta, pentru că ea nu va putea fi mutată pe verticală, ca mingea. Acum, imediat sub funcția deseneazaMinge și înainte de deseneaza adăugați o nouă funcție astfel:
1 2 3 4 5 6 7 |
function deseneazaPaleta() { context.beginPath(); context.rect(paletaX, spatiuJoc.height - inaltimePaleta, latimePaleta, inaltimePaleta); context.fillStyle = "#0095DD"; context.fill(); context.closePath(); } |
Paleta poate fi desenată oriunde, dar avem nevoie de interacțiune pentru a o muta. Pentru a obține acest lucru vom avea nevoie de:
- două variabile care să rețină dacă a fost apăsat butonul pentru deplasare la stânga sau cel pentru deplasare la dreapta
- un mod de a detecta dacă un buton a fost apăsat sau eliberat
- două funcții: una care să pornească mișcarea când butonul este apăsat și una care să o oprească atunci când este eliberat
Deci putem începe prin a defini două variabile de tip Boolean (true/false) care să rețină dacă butonul stânga sau butonul dreapta a fost apăsat. Adăugați următoarele două linii în zona în care ați definit variabile:
1 2 |
var apasatDreapta = false; var apasatStanga = false; |
Adăugați următoarele linii în partea de jos a scriptului nostru, deasupra apelului către funcția setInterval:
1 2 |
document.addEventListener("keydown", trateazaApasare, false); document.addEventListener("keyup", trateazaEliberare, false); |
Prin acestea doar am spus ca atunci când o tastă este apăsată funcția trateazaApasare trebuie să fie apelată, iar atunci când o tastă este eliberată funcția trateazaEliberare va fi folosită. Acum va trebui să definim aceste funcții, adăugând următoarele linii imediat sub cele definite puțin mai sus:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function trateazaApasare(e) { if (e.keyCode == 39) { apasatDreapta = true; } else if (e.keyCode == 37) { apasatStanga = true; } } function trateazaEliberare(e) { if (e.keyCode == 39) { apasatDreapta = false; } else if (e.keyCode == 37) { apasatStanga = false; } } |
Când un buton de care suntem interesați este apăsat atunci variabila relevantă (dreapta sau stânga) va fi schimbată în true. Fiecare dintre cele două funcții primesc ca și parametru în variabila „e” ceea ce numește un eveniment. Acesta descrie interacțiunea întâlnită. 39 și 37 nu sunt decât niște coduri pentru tastele săgeată dreapta și săgeată stânga.
Acum tot ce rămâne să facem este ca în funcția deseneaza, unde decidem deplasarea mingii, să adăugam și logica pentru deplasarea paletei. Astfel, la finalul funcției puteți adăuga:
1 2 3 4 5 6 |
if (apasatDreapta) { paletaX += 7; } else if (apasatStanga) { paletaX -= 7; } |
Dar, din ce am învățat când am definit mișcarea mingii, acest test nu este tocmai corect, din cauză că nu tratează atingerea marginii spațiului de joc. În acest caz este mult mai ușor, pur și simplu nu mai trebuie să lăsăm paleta să se miște când a ajuns la unul dintre capete, chiar dacă butonul este apăsat. Așa că vom înlocui liniile tocmai adăugate cu:
1 2 3 4 5 6 |
if (apasatDreapta && paletaX < spatiuJoc.width - latimePaleta) { paletaX += 7; } else if (apasatStanga && paletaX > 0) { paletaX -= 7; } |
Acum tot ce ne-a rămas de făcut este să apelăm funcția care desenează paleta în cea care desenează toate elementele din spațiul de joc. Așa că acum trebuie să adăugați un apel:
1 |
deseneazaPaleta(); |
în interiorul funcției deseneaza, imediat după apelul către funcția deseneazaMinge, de exemplu.
Dacă salvați și reîncărcați pagina veți observa că acum aveți paleta în partea de jos a ecranului și că atunci când apăsați pe butoanele stânga sau dreapta aceasta se mișcă în direcția dorită.
Exercițiu: înainte de a trece mai departe încercați să faceți paleta să se miște mai repede, mai încet, sau să îi schimbați dimensiunea.
Deși am progresat considerabil în dezvoltarea jocului nostru, momentan nu conține un aspect foarte important în totate jocurile: posibilitatea de a pierde. În breakout condiția este foarte ușoară: utilizatorul pierde dacă mingea „cade” prin partea de jos a spațiului de joc, fără a fi lovită de paletă.
Implementarea condiției de a pierde
Localizați următoarea zonă din codul deja existent:
1 2 3 4 5 6 |
if (x + dx > spatiuJoc.width - razaMinge || x + dx < razaMinge) { dx = -dx; } if (y + dy > spatiuJoc.height - razaMinge || y + dy < razaMinge) { dy = -dy; } |
În loc să lăsăm mingea să se respingă de cei patru pereți va trebui să permitem doar trei: stânga, sus și dreapta. Deci va trebui să edităm al doilea bloc din exemplul de mai sus, astfel încât să declanșeze starea de „game over” atunci când peretele de jos este atins. Momentan vom trata cazul foarte simplu: vom afișa un mesaj. În primul rând vom pregăti un element în pagină, în care vom scrie mesajul. În partea de HTML, sub elementul de tip canvas adăugați următoarea linie:
1 |
<div id="mesaj" style="width: 100%; text-align: center; margin-top: 10px; height: 1em;"></div> |
Acum avem un element fără conținut, dar care are un ID care poate fi folosit pentru a îi adăuga. De asemenea ar fi util să oprim desenarea atunci când jocul este pierdut. Pentru a face acest lucru editați linia din partea de jos a scriptului în care se folosește funcția setInterval, astfel încât să captați un identificator pentru intervalul definit într-o variabilă, astfel:
1 |
var interval = setInterval(deseneaza, 10); |
Acum putem înlocui al doilea bloc if dintre cele două menționate puțin mai sus cu:
1 2 3 4 5 6 7 |
if (y + dy < razaMinge) { dy = -dy; } else if (y + dy > spatiuJoc.height - razaMinge) { document.getElementById("mesaj").innerHTML = "GAME OVER"; document.getElementById("mesaj").style.color = "red"; clearInterval(interval); } |
Astfel vom păstra logica de schimbare a direcției atunci când mingea lovește peretele de sus, dar atunci când ajunge la cel de jos vom scrie mesajul „GAME OVER”, cu roșu, în elementul nostru de tip div. De asemenea am oprit bucla noastră infinită, pentru a menține elementul de tip canvas în starea curentă.
Totuși în acest moment jocul este pierdut chiar dacă poziționăm paleta în așa fel încât mingea să o lovească.
Coliziunea mingii cu paleta
Așa cum am definit coliziunea cu pereții va trebui acum să implementăm ceva similar pentru interacțiunea dintre minge și paletă. Un mod simplu pentru a defini acest caz ar fi să verificăm dacă centrul mingii se află între capetele paletei. Deci va trebui să adăugăm o excepție în condiția de terminare a jocului. Editați blocul de mai sus astfel încât să arate așa:
1 2 3 4 5 6 7 8 9 10 11 12 |
if (y + dy < razaMinge) { dy = -dy; } else if (y + dy > spatiuJoc.height - razaMinge) { if (x > paletaX && x < paletaX + latimePaleta) { dy = -dy; } else { document.getElementById("mesaj").innerHTML = "GAME OVER"; document.getElementById("mesaj").style.color = "red"; clearInterval(interval); } } |
Adăugând acest bloc if ne-am asigurat că atunci când mingea ajunge în partea de jos a spațiului de joc, dacă atinge și paleta îi schimbăm direcția, dacă nu jocul este pierdut.
În acest moment ne apropiem de ceea ce s-ar putea numi cu adevărat un „joc”. Avem o condiție de a pierde, momentul în care mingea lovește peretele de jos, și ceva ce trebuie să facem pentru a evita asta, să poziționăm paleta în așa fel încât mingea să se lovească de ea și să își schimbe direcția din nou.
Exercițiu: înainte de a trece mai departe puteți încerca să vedeți dacă puteți să faceți în așa fel încât mingea să își mărească viteza de fiecare dată când lovește paleta, dând astfel un nivel sporit de dificultate jocului.
După cum ziceam, am putea deja considera că avem deja un joc. Dar în starea curentă ne vom plictisi repede de el. O parte foarte importantă în jocul breakout este să avem câteva rânduri de „cărămizi” care să fie „sparte” de către minge atunci când le lovește.
Definirea setului de cărămizi
În zona în care ați definit variabile, înaintea funcțiilor adăugați următoarele linii:
1 2 3 4 5 6 7 |
var numarLiniiCaramizi = 3; var numarColoaneCaramizi = 9; var latimeCaramida = 75; var inaltimeCaramida = 20; var distantaCaramizi = 10; var margineSusCaramizi = 30; var margineStangaCaramizi = 30; |
Prin acestea am definit numărul de linii și coloane de cărămizi, dimensiunile (înălțime și lățime) pentru fiecare dintre ele, distanța dintre două cărămizi alăturate, distanța lor față de marginea de sus și distanța față de marginea din stânga.
Acum avem pregătite valorile care definesc conceptele importante necesare, va trebui să pregătim și o structură în care să reținem fiecare cărămidă în parte. Pentru acest lucru vom putea folosi un Array. Mai exact ar fi un tablou cu două nivele, din cauza faptului că avem un număr de linii și de coloane. Unii dintre voi poate au auzit despre acest tip de structură și sub numele de matrice. Imediat sub variabilele definite mai sus adăugați:
1 2 3 4 5 6 7 |
var caramizi = []; for (var c = 0; c < numarColoaneCaramizi; c++) { caramizi[c] = []; for (var l = 0; l < numarLiniiCaramizi; l++) { caramizi[c][l] = { x: 0, y: 0 }; } } |
Am folosit variabila c pentru a parcurge coloanele de cărămizi și l pentru a parcurge liniile. Pe baza lor nu am făcut decât să inițializăm un obiect pentru fiecare cărămidă în parte. Aceste date vor fi folosite în continuare pentru a desena cărămizile și pentru a detecta coliziunea dintre minge și fiecare dintre ele.
Logica desenării cărămizilor
Așa cum am definit o funcție care desenează mingea si una pentru paletă, vom defeni una nouă pentru a desena cărămizile. În zona în care am definit funcții adăugați:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function deseneazaCaramizi() { for (var c = 0; c < numarColoaneCaramizi; c++) { for (var l = 0; l < numarLiniiCaramizi; l++) { caramizi[c][l].x = 0; caramizi[c][l].y = 0; context.beginPath(); context.rect(0, 0, latimeCaramida, inaltimeCaramida); context.fillStyle = "#0095DD"; context.fill(); context.closePath(); } } } |
Din nou, parcurgem pașii folosind numărul de coloane și de linii și desenăm un chenar cu lățimea și înălțimea egale cu cele definite pentru cărămizi. Acum problema rămâne că le desenăm pe toate în punctul de coordonate (0, 0), deci în colțul stânga-sus. Pentru a corecta acest lucru vom adăuga următoarele două linii în interiorul blocurilor noastre for:
1 2 |
var caramidaX = (c * (latimeCaramida + distantaCaramizi)) + margineStangaCaramizi; var caramidaY = (l * (inaltimeCaramida + distantaCaramizi)) + margineSusCaramizi; |
Deci poziția pe axa orizontală, caramidaX, este calculată folosind lățimea unei cărămizi + distanța dintre ele înmulțite cu numărul coloanei curente. La valoarea obținută vom adăuga distanța care trebuie să o păstrăm față de marginea din stânga. Într-un mod similar, poziție pe axa verticală, caramidaY, va fi calculată adunând înălțimea cărămizii și distanța dintre ele, înmulțind rezultatul cu numărul liniei curente și adunând la final distanța pe care trebuie să o păstrăm față de marginea de sus.
Acum trebuie doar să folosim valorile calculate mai sus. Editați funcția deseneazaCaramizi în așa fel încât să arate astfel:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function deseneazaCaramizi() { for (var c = 0; c < numarColoaneCaramizi; c++) { for (var l = 0; l < numarLiniiCaramizi; l++) { var caramidaX = (c * (latimeCaramida + distantaCaramizi)) + margineStangaCaramizi; var caramidaY = (l * (inaltimeCaramida + distantaCaramizi)) + margineSusCaramizi; caramizi[c][l].x = caramidaX; caramizi[c][l].y = caramidaY; context.beginPath(); context.rect(caramidaX, caramidaY, latimeCaramida, inaltimeCaramida); context.fillStyle = "#0095DD"; context.fill(); context.closePath(); } } } |
Acum tot ce trebuie să mai facem pentru a vedea cărămizile este să apelăm funcția care le desenează în cadrul celei care generează tot spațiul de joc. Deci, în interiorul funcției deseneaza, imediat după ce ați apelat deseneazaMinge și deseneazaPaleta adăugați și
1 |
deseneazaCaramizi(); |
Dacă salvați fișierul și reîncărcați pagina acum ar trebui să vedeți și cărămizile desenate în partea de sus a spațiului de joc. Desigur, momentan mingea nu interacționează cu ele, urmează să implementăm acest lucru.
Exercițiu: încercați să schimbați numărul de linii de cărămizi, numărul de coloane, culoarea sau dimensiunea lor.
În acest moment avem cărămizile desenate pe ecran, dar mingea trece „prin” ele. Așa cum a mai fost cazul pentru pereți și paletă, va trebui să adăugăm funcționalitatea pentru a detecta coliziunea dintre minge și o cărămidă.
Detectarea coliziunii
Pentru început vom crea o funcție care parcurge lista de cărămizi, în care vom implementa apoi verificarea condiției de coliziune. Vom folosi același tip de parcurgere a listei de cărămizi și vom salva la fiecare pas elementul curent într-o variabilă denumită caramida. Putem începe prin a defini funcția noastră astfel:
1 2 3 4 5 6 7 8 |
function detecteazaColiziune() { for (var c = 0; c < numarColoaneCaramizi; c++) { for (var l = 0; l < numarLiniiCaramizi; l++) { var caramida = caramizi[c][r]; // calcule } } } |
Dacă centrul mingii se va afla în interiorul unei cărămizi îi vom schimba direcția de mișcare. Pentru ca centrul să fie într-o cărămidă următoarele condiții trebuie să fie satisfăcute simultan:
- poziția x a mingii trebuie să fie mai mare decât poziția x a cărămizii
- poziția x a mingii trebuie să fie mai mică decât poziția x a cărămizii plus lățimea unei cărămizi (latimeCaramida)
- poziția y a mingii trebuie să fie mai mare decât poziția y a cărămizii
- poziția y a mingii trebuie să fie mai mică decât poziția y a cărămizii plus înălțimea unei cărămizi (inaltimeCaramida)
Pentru a „traduce” aceste condiții în cod vom edita funcția de mai sus astfel încât să aibă următoarea definiție:
1 2 3 4 5 6 7 8 9 10 |
function detecteazaColiziune() { for (var c = 0; c < numarColoaneCaramizi; c++) { for (var l = 0; l < numarLiniiCaramizi; l++) { var caramida = caramizi[c][r]; if (x > caramida.x && x < caramida.x + latimeCaramida && y > caramida.y && y < caramida.y + inaltimeCaramida) { dy = -dy; } } } } |
Eliminare cărămidă la impact
Vom începe prin a mai adăuga încă un câmp în definiția cărămizilor, pe lângă cele existente, coordonatele x și y. Vom denumi acest câmp status. Editați zona în care am definit inițial lista de cărămizi, în zona de declarare a variabilelor astfel încât să arate astfel:
1 2 3 4 5 6 7 |
var caramizi = []; for (var c = 0; c < numarColoaneCaramizi; c++) { caramizi[c] = []; for (var l = 0; l < numarLiniiCaramizi; l++) { caramizi[c][l] = { x: 0, y: 0, status: 1 }; } } |
Apoi vom actualiza funcția deseneazaCaramizi astfel încât să afișeze doar cărămizile cu statusul 1:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function deseneazaCaramizi() { for (var c = 0; c < numarColoaneCaramizi; c++) { for (var l = 0; l < numarLiniiCaramizi; l++) { if (caramizi[c][l]["status"]) { var caramidaX = (c * (latimeCaramida + distantaCaramizi)) + margineStangaCaramizi; var caramidaY = (l * (inaltimeCaramida + distantaCaramizi)) + margineSusCaramizi; caramizi[c][l].x = caramidaX; caramizi[c][l].y = caramidaY; context.beginPath(); context.rect(caramidaX, caramidaY, latimeCaramida, inaltimeCaramida); context.fillStyle = "#0095DD"; context.fill(); context.closePath(); } } } } |
Folosirea și actualizarea statusului în detectarea coliziunii
În continuare vom actualiza funcția detecteazaColiziune în așa fel încât să ia în considerare doar cărămizile cu status 1 și să îl actualizeze în 0 la impact.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function detecteazaColiziune() { for (var c = 0; c < numarColoaneCaramizi; c++) { for (var l = 0; l < numarLiniiCaramizi; l++) { var caramida = caramizi[c][l]; if (caramida.status) { if (x > caramida.x && x < caramida.x + latimeCaramida && y > caramida.y && y < caramida.y + inaltimeCaramida) { dy = -dy; caramida.status = 0; } } } } } |
Activarea detectării coliziunii
Acum tot ce a rămas de făcut este să apelăm funcția noastră detecteazaColiziune în cadrul celei ce generează întregul spațiu de joc, deseneaza. Adăugați următoarea linie imediat după apelul către deseneazaPaleta:
1 |
detecteazaColiziune(); |
Exercițiu: încercați să schimbați culoarea mingii de fiecare dată când lovește o cărămidă.
Faptul că putem „sparge” cărămizi face jocul mai interesant, dar un element care ar putea adăuga interes ar fi să ținem un scor, numărând cărămizile atinse.
Ținerea scorului
Pentru a reține un scor vom avea nevoie de o variabilă. Adăugați următoarea linie în zona de declarare a variabilelor, sub cele existente, imediat deasupra funcțiilor:
1 |
var scor = 0; |
Vom avea nevoie și de o funcție pentru desenarea scorului pe spațiul de joc. Adăugați următoarea funcție imediat după detecteazaColiziune:
1 2 3 4 5 |
function deseneazaScor() { context.font = "16px Arial"; context.fillStyle = "#0095DD"; context.fillText("Scor: " + scor, 8, 20); } |
Desenarea textului pe canvas este similară cu desenarea formelor folosite până acum. Definirea font-ului este asemănătoare cu modul în care ați face acest lucru în CSS. Funcția fillStyle va determina culoarea textului, iar fillText va adăuga textul efectiv. Următorii doi parametri reprezintă coordonatele de la care va începe scrisul, deci am ales o poziție în apropiere de colțul stânga-sus.
Pentru a ține evidența scorului editați funcția detecteazaColiziune, adăugând linia evidențiată mai jos pentru a incrementa valoarea variabilei scor de fiecare dată când „spargem” o cărămidă.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function detecteazaColiziune() { for (var c = 0; c < numarColoaneCaramizi; c++) { for (var l = 0; l < numarLiniiCaramizi; l++) { var caramida = caramizi[c][l]; if (caramida.status) { if (x > caramida.x && x < caramida.x + latimeCaramida && y > caramida.y && y < caramida.y + inaltimeCaramida) { dy = -dy; caramida.status = 0; scor++; } } } } } |
Apelând funcția deseneazaScor în interiorul deseneaza vom ține scorul actualizat la fiecare pas. Adăugați următoarea linie imediat după apelul către funcția deseneazaPaleta:
1 |
deseneazaScor(); |
Afișarea unui mesaj pentru câștigarea jocului
Așa cum am implementat o condiție pentru pierderea jocului, acum că avem o evidență a scorului, putem adăuga și o condiție pentru câștigare. Verificarea este foarte ușoară: atunci când scorul este egal cu numărul total de cărămizi înseamnă că jocul a fost câștigat. În funcția detecteazaColiziune, imediat după linia adăugată mai sus, adăugați cele cinci evidențiate aici:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
function detecteazaColiziune() { for (var c = 0; c < numarColoaneCaramizi; c++) { for (var l = 0; l < numarLiniiCaramizi; l++) { var caramida = caramizi[c][l]; if (caramida.status) { if (x > caramida.x && x < caramida.x + latimeCaramida && y > caramida.y && y < caramida.y + inaltimeCaramida) { dy = -dy; caramida.status = 0; scor++; if (scor == numarLiniiCaramizi * numarColoaneCaramizi) { document.getElementById("mesaj").innerHTML = "Ai castigat!"; document.getElementById("mesaj").style.color = "green"; clearInterval(interval); } } } } } } |
După cum vedeți, tratarea cazului este similară cu cea de atunci când se pierde. Am schimbat mesajul și culoarea, din roșu, în verde.
Exercițiu: schimbați numărul de puncte acordate pentru eliminarea unei cărămizi.
Jocul nostru poate fi considerat deja terminat, dar îi mai putem adăuga unele elemente pentru a-l face mai interesant.
Acordarea unui numări de „vieți”
Jocul ar putea fi mai ușor de jucat dacă ni s-ar permite să facem una sau două greșeli. Implementarea unui număr de vieți disponibile este destul de simplă. Vom porni cu o variabilă care va reține numărul de vieți. Adăugați următoarea linie în zona în care am definit variabilele:
1 |
var vieti = 3; |
Apoi vom defini o funcție foarte similară cele ce afișează scorul, pe care o vom folosi pentru a ține evidența numărului de vieți:
1 2 3 4 5 |
function deseneazaVieti() { context.font = "16px Arial"; context.fillStyle = "#0095DD"; context.fillText("Vieti: " + vieti, spatiuJoc.width - 65, 20); } |
În loc să terminăm jocul atunci când mingea ajunge la limita de jos, vom scădea numărul de vieți. Schimbați zona în care se afișează mesajul „GAME OVER” astfel:
1 2 3 4 5 6 7 8 9 10 11 12 |
vieti--; if (!vieti) { document.getElementById("mesaj").innerHTML = "GAME OVER"; document.getElementById("mesaj").style.color = "red"; clearInterval(interval); } else { x = spatiuJoc.width / 2; y = spatiuJoc.height - 30; dx = 2; dy = -2; paletaX = (spatiuJoc.width - latimePaleta) / 2; } |
Numărul de vieți scade de fiecare dată când mingea ajunge jos. Dacă nu mai avem vieți disponibile atunci afișăm mesajul de joc pierdut, dar dacă mai avem doar repoziționăm mingea și paleta în locurile în care se aflau inițial.
După cum v-ați obșnuit deja, tot ce mai trebuie să facem este să apelăm funcția deseneazaVieti în cadrul deseneaza, imediat după apelul către deseneazaScor, adăugând următoarea linie:
1 |
deseneazaVieti(); |
Funcționalitatea jocului nostru este terminată. La final putem să îl facem puțin mai interesant adăugându-i unele sunete.
Adăugare sunete
Vom defini o funcție care va reda un sunet primit ca parametru, pe care o vom folosi în unele puncte critice ale jocului:
1 2 3 4 |
function faSunet(fisier) { var sunet = new Audio(fisier); sunet.play(); } |
Ca și exemplu am pregătit 5 fișiere de sunet, ale căror adrese le vom salva în variabile în partea de sus a scriptului nostru:
1 2 3 4 5 |
var sunetLovitura = "https://www.coderdojoiasi.ro/wp-content/uploads/2017/11/hit-paddle.wav"; var sunetPerete = "https://www.coderdojoiasi.ro/wp-content/uploads/2017/11/hit-wall.wav"; var sunetJocTerminat = "https://www.coderdojoiasi.ro/wp-content/uploads/2017/11/game-over.wav"; var sunetCaramida = "https://www.coderdojoiasi.ro/wp-content/uploads/2017/11/break-brick.wav"; var sunetCastig = "https://www.coderdojoiasi.ro/wp-content/uploads/2017/11/winner.wav"; |
Putem edita funcția detecteazaColiziune astfel încât să reproducem sunetul relevant atunci când o cărămidă este atinsă și atunci când jocul este câștigat:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function detecteazaColiziune() { for (var c = 0; c < numarColoaneCaramizi; c++) { for (var l = 0; l < numarLiniiCaramizi; l++) { var caramida = caramizi[c][l]; if (caramida.status) { if (x > caramida.x && x < caramida.x + latimeCaramida && y > caramida.y && y < caramida.y + inaltimeCaramida) { dy = -dy; caramida.status = 0; scor++; faSunet(sunetCaramida); if (scor == numarLiniiCaramizi * numarColoaneCaramizi) { document.getElementById("mesaj").innerHTML = "Ai castigat!"; document.getElementById("mesaj").style.color = "green"; clearInterval(interval); faSunet(sunetCastig); } } } } } } |
Într-un mod similar puteți adăuga un apel faSunet(sunetPerete); atunci când mingea atinge peretele, faSunet(sunetLovitura); atunci când este lovită de paletă sau faSunet(sunetJocTerminat); atunci când jocul este pierdut. Ca un exercițiu final încercați să faceți acest lucru singuri.
Optimizare performanță
Așa cum spuneam mai sus, este normal atunci când programăm să îmbunătățim calitatea codului nostru, chair dacă acest lucru nu schimbă modul în care funcționează. Acum, că jocul nostru este practic terminat, putem face o mică schimbare pentru a-l face mai performant. Până în acest punct am redesenat conținutul spațiului de joc la fiecare 10 milisecunde, obținând astfel senzația de mișcare continuă. Deși acest interval este suficient de mic, nu știm dacă este cel mai bun care poate fi obținut în browserul nostru. Pentru a renunța la abordarea care folosește un interval de timp vom începe prin a defini o variabilă care reține dacă jocul nostru s-a terminat, indiferent dacă a fost câștigat sau pierdut. Adăugați următoarea linie în zona de declarare a variabilelor:
1 |
var stop = false; |
Îi vom actualiza valoarea în cele două locuri în care jocul nostru este terminat, înlocuind apelul către clearInterval(interval) cu:
1 |
stop = true; |
Apoi vom înlocui linia care pornește intervalul nostru folosind funcția setInterval printr-un simplu apel către funcția deseneaza:
1 |
deseneaza(); |
Până aici funcția noastră a fost apelată o singură dată, deci va desena doar primul frame din joc. Pentru a continua, chiar în interiorul funcției deseneaza, vom adăuga un apel către ea însăși, dar doar dacă jocul nu s-a terminat. La finalul funcției „principale” adăugați:
1 2 3 |
if (!stop) { requestAnimationFrame(deseneaza); } |
Folosind această nouă funcție, requestAnimationFrame, practic i-am cerut browserului să reînceapă desenarea imediat după ce un nou frame poate fi afișat, în loc de intevalul fix de 10 milisecunde. Prin această mică modificare am sporit performanța jocului, din cauză că lăsăm browserul să calculeze când chiar este necesară o regenerare a spațiului de joc și când poate face asta.
Încheire
Jocul nostru poate fi considerat încheiat acum, dar, dacă doriți, puteți continua implementarea pe cont propriu. Poate ați observat că, deși am încheiat tutorialul, jocul obținut nu este chiar la fel cu cel din exemplul dat. Asta este din cauză că aș vrea să vă provoc să faceți ultima parte fără îndrumare, dar cu ajutor din partea mentorilor dacă este nevoie, desigur. Un alt motiv ar fi faptul că, deși breakout pare un joc previzibil, care se comportă la fel în fiecare implementare, puteți să continuați în așa fel încât varianta voastră să fie diferită de a mea sau a celorlalți colegi. Voi termina prin a vă da câteva idei în acest sens.
Voi începe cu cele din exemplul meu:
- când mingea lovește paleta viteza este mărită puțin, adăugând dificultate
- dacă o cărămidă este spartă fără ca mingea să ajungă jos, la paletă, ci imediat după o alta, se adaugă două puncte în loc de unu. Astfel scorul poate varia mai mult, față de scorul maxim standard de 27
- nu tot timpul, dar cu o probabilitate destul de mare, atunci când mingea lovește paleta scad lățimea paletei cu 1px, jocul devenind astfel din ce în ce mai greu
- de asemenea, cu o probabilitate scăzută de această dată, paleta își poate diminua lățima mai drastic, scăzând 10px, dar doar pentru o perioadă de 10 secunde, timp în care paleta are o altă culoare, marcând starea de penalizare
- am implementat o funcție de reîncepere a jocului, pentru a nu mai reîncârca pagina când vrem să reluăm jocul
- am reținut scorurile pentru a afișa un clasament
Nu uitați că pentru a vedea implementarea mea nu trebui decât să faceți click dreapta în pagină și să vedeți sursa. Puteți să vă inspirați de acolo pentru a dezvolta jocul vostru în continuare. Nu vă feriți să faceți lucrurile puțin diferit, puteți obține ceva mai interesant.
Alte idei care nu fac parte din exemplul meu, dar care ar trebui să le puteți implementa destul de ușor ar fi:
- cărămizile pot avea nivele, unele dintre ele nu trebuie să dispară când sunt atinse, pot doar să își schimbe culoarea și să fie nevoie să fie lovite din nou
- puteți adăuga conceptul de nivele de dificultate; de fiecare dată când jocul este câștigat puteți reîncepe, dar în așa fel încât să fie puțin mai dificil
- la un nivel ridicat de dificultate puteți folosi două mingi simultan
- cu o anumită probabilitate, pentru o perioadă scurtă de timp controalele paletei pot fi inversate; astfel, când se va apăsa butonul stânga paleta se va mișca în dreapta și invers
De fapt aveți foarte multe posibilități, trebuie doar să vă folosiți imaginația și apoi să vă puneți în practică planul.
De asemenea, dacă ați înțeles destul de bine acest tutorial, ar trebui să puteți cu ușurință să implementați un joc diferit, dar cu o logică asemănătoare. De exemplu un joc „tenis” ar putea să renunțe la rândul de cărămizi și să aibă două palete: una sus și una jos. Puteți face în așa fel încât cea de jos să fie controlată ca și în acest joc, iar cea de sus să folosească alte taste, de exemplu Z și C. În acest fel doi jucători diferiți vor putea juca de la aceeași tastatură. Veți putea ține scorul separat pentru fiecare jucător, acesta fiind definit ca numărul de jocuri în care celălat a „scăpat” mingea afară.