Jocul Tunnel Run
AVERTISMENT! Acest tutorial conține matematică explicită! Se recomandă discreția privitorului!
Ideea jocului este simplă: aleargă și sari prin labirint evitând zonele negre.
Ceea ce îl face să arate grozav este faptul că labirintul este înfășurat într-un tunel.
Tot trucul este gestionat de funcția tunnelCoords.
Dacă nu aveți chef să intrați în detaliile matematice, nu ezitați să
săriți la secțiunea fără trigonometrie.
Jocul nostru se joacă de fapt pe o grilă 2D, dar această funcție îl afișează ca un tunel 3D.
Este mai ușor de înțeles când te joci cu cele două pânze (canvas-uri) de mai jos: dacă dai clic pe cea de sus, va fi desenat un dreptunghi alb acolo unde ai dat clic.
În același timp, un dreptunghi corespunzător mai mic va fi desenat pe pânza de jos.
Pânza de sus folosește sistemul de coordonate standard JS - punctul (0,0) este în colțul din stânga sus. Coordonata x crește pe măsură ce vă deplasați spre dreapta. Coordonata y crește pe măsură ce coborâți.
Pânza de jos folosește "coordonatele" noastre de tunel. Tunelul este format din "cercuri" concentrice (de fapt sunt poligoane cu 16 unghiuri - "hexadecagoane regulate", dar cui îi pasă?). Pentru x=0, punctul este în partea de jos (ora 6) a cercului. Pe măsură ce coordonata x crește, punctul se mișcă în sens invers acelor de ceasornic pe cerc.
Coordonata y determină pe care dintre cercuri se află punctul. Cu cât coordonata y este mai mare, cu atât cercul este mai mic.
Cercurile pentru y=0 și y=1 nu încap pe pânza noastră (sunt prea mari). Cel mai mare cerc pe care îl vedeți este pentru y=2.
Încercați să desenați o linie pe pânza de sus de la (0,1) [colțul din stânga sus] la (0,10) [colțul din stânga jos]. (Coordonatele punctelor de pe ambele pânze sunt afișate fără paranteze.) Ar trebui să vedeți punctele corespunzătoare mergând de la ora 6 spre centrul tunelului.
Iată programul care desenează aceste pânze (canvas-uri):
| <html> |
| <style> |
| body { |
| background-color: black; |
| } |
| </style> |
| <body> |
| <canvas id="myCanvas1" width="600" height="300" style="touch-action: none"></canvas> |
| <br> |
| <br> |
| <canvas id="myCanvas2" width="600" height="300" style="touch-action: none"></canvas> |
| <script> |
| const scale = 30; |
| let canvas1 = document.getElementById('myCanvas1'); |
| let context1 = canvas1.getContext('2d'); |
| let canvas2 = document.getElementById('myCanvas2'); |
| let context2 = canvas2.getContext('2d'); |
| let draw = false; |
| |
| function tunnelCoords(point) { |
| let x = point.x; |
| let y = point.y; |
| if (y < 0.9) |
| y = 0.9; |
| let x2 = x * Math.PI / 2; |
| let factor = 500 / y; |
| return ({ |
| x: canvas1.width / 2 + factor * Math.sin(x2 / 4), |
| y: canvas1.height / 2 + factor * Math.cos(x2 / 4) |
| }); |
| } |
| |
| function handleTouch(event) { |
| if (draw) { |
| |
| let x = event.offsetX; |
| let y = event.offsetY; |
| context1.fillRect(x, y, 10, 10); |
| let x2 = (x / scale); |
| let y2 = (y / scale); |
| let tunnelPoint = tunnelCoords({ |
| x: x2, |
| y: y2 |
| }); |
| context2.fillRect(tunnelPoint.x, tunnelPoint.y, 2, 2); |
| } |
| } |
| |
| function redraw() { |
| context1.fillStyle = 'gray'; |
| context2.fillStyle = 'gray'; |
| context1.fillRect(0, 0, canvas1.width, canvas1.height); |
| context2.fillRect(0, 0, canvas2.width, canvas2.height); |
| context1.fillStyle = 'white'; |
| context2.fillStyle = 'white'; |
| for (let y = 0; y < 12; y++) { |
| let color = 255 - 20 * y; |
| context1.strokeStyle = `rgb(${color}, ${color}, ${color})`; |
| context2.strokeStyle = `rgb(${color}, ${color}, ${color})`; |
| context1.beginPath(); |
| context2.beginPath(); |
| let point = tunnelCoords({ |
| x: 0, |
| y: y |
| }); |
| context1.moveTo(0, y * scale); |
| context2.moveTo(point.x, point.y); |
| for (let x = 0; x < 15; x++) { |
| let point = tunnelCoords({ |
| x: x, |
| y: y |
| }); |
| context1.lineTo(x * scale, y * scale); |
| context1.fillText(x + ',' + y, x * scale, y * scale); |
| context2.lineTo(point.x, point.y); |
| context2.fillText(x + ',' + y, point.x, point.y); |
| } |
| context1.stroke(); |
| context2.stroke(); |
| } |
| } |
| |
| window.onload = redraw; |
| canvas2.onclick = redraw; |
| canvas1.onpointerdown = function() { |
| draw = true; |
| }; |
| canvas1.onpointermove = handleTouch; |
| window.onpointerup = function() { |
| draw = false; |
| }; |
| </script> |
| </body> |
| </html> |
Iată ce face programul: desenează linii de la (0,0) la (1,0), apoi la (2,0) etc. pe pânza de sus - acestea creează o linie orizontală.
Apoi obține coordonatele de tunel corespunzătoare și le desenează pe pânza de jos - acestea formează un cerc.
Rotația bazată pe coordonata x originală se întâmplă folosind funcțiile sin/cos în liniile [28-29].
Dimensiunea cercului este determinată în liniile [23-24] și [26]. [26] este cea care modifică distanța dintre cercuri - pe măsură ce cercurile devin mai mici (mai departe de tine), distanța dintre ele scade, de asemenea. Acest lucru creează iluzia de perspectivă.
Apoi programul mărește coordonata y și desenează liniile din nou, într-o culoare mai închisă.
M-am oprit intenționat la x=14, astfel încât să puteți vedea cercurile incomplete pentru a înțelege mai bine cum liniile orizontale corespund cercurilor. În joc, desigur, desenăm cercurile complete pentru a ne asigura că tunelul nostru arată real.
Felicitări! Te-ai luptat cu partea grea!
Să ne uităm la părțile mai simple ale codului.
Liniile [1-24] configurează pânza HTML5 (Canvas) și declară constantele și variabilele.
[26-29] Redimensionează pânza în cazul în care utilizatorul redimensionează fereastra sau rotește dispozitivul mobil.
[44-67] (Re-)setează variabilele la valorile inițiale (începutul jocului)
[69-96] Desenează o singură dală (tile). Punctul 1 este în colțul din stânga sus al dalei, folosind sistemul clasic de coordonate. Încă trei puncte sunt calculate, creând un pătrat cu lungimea laturii egală cu 1. Aceste puncte sunt apoi convertite în coordonate de tunel și dala este desenată.
[98-156] Bucla principală de animație:
[100-104] Sprite-ul este mutat pe axa x (rezultând rotația tunelului)
[108-114] Desenează toate dalele începând de la un rând sub sprite.
[115-122] Săritură verticală
[124-132] Calculează și desenează cadrul actual de animație al sprite-ului din spritesheet
[133-138] Dacă sprite-ul este pe o "gaură" în grilă, joc terminat (game over).
[141-146] Dacă sprite-ul a ajuns la linia de sosire, ai câștigat.
[158-171] Gestionează evenimentul de mouse și atingere - declanșează stânga/dreapta/săritură bazat doar pe coordonata x a clicului/atingerii
[173-189] Gestionează intrarea de la tastatură
[191-194] Tastă/clic/atingere eliberată
[202] Să-i dăm drumul!!!
Iată codul complet:
| <html> |
| <style> |
| body { |
| background-color: black; |
| } |
| </style> |
| <body> |
| <canvas id="myCanvas" width="800" height="600" style='position:absolute; left:0; top:0'></canvas> |
| <script> |
| const canvas = document.getElementById('myCanvas'); |
| const context = canvas.getContext('2d'); |
| context.font = 'bold 30px sans-serif'; |
| const image = new Image(); |
| image.src = "sprite.png"; |
| const buttons = new Image(); |
| buttons.src = "buttons.png"; |
| const spriteSize = 40, |
| jumpPixels = 40, |
| maxJump = 3, |
| maxRows = 25, |
| jumpSpeed = 0.05; |
| let sprite, jump, currentJumpSpeed, counter, arrowUp, xSpeed, mode, runSpeed; |
| let frame = 0; |
| let grid = []; |
| |
| function resize() { |
| canvas.style.width = window.innerWidth; |
| canvas.style.height = window.innerHeight; |
| } |
| |
| function tunnelCoords(point) { |
| let x = point.x; |
| let y = point.y + counter; |
| if (y < 0.9) |
| y = 0.9; |
| let x2 = (x - sprite.x) * Math.PI / 2; |
| let factor = 500 / y; |
| return ({ |
| x: canvas.width / 2 + factor * Math.sin(x2 / 4), |
| y: canvas.height / 2 + factor * Math.cos(x2 / 4) |
| }); |
| } |
| |
| function restart() { |
| sprite = { |
| x: 0.4, |
| y: 2 |
| }; |
| jump = 0; |
| xSpeed = 0; |
| currentJumpSpeed = 0; |
| runSpeed = 0.005; |
| counter = 0; |
| mode = 'init'; |
| for (let row = 0; row < maxRows; row++) { |
| let randomRow = []; |
| for (let column = 0; column < 16; column++) { |
| if (row < 4) |
| randomRow[column] = 1; |
| else |
| randomRow[column] = Math.floor(Math.random() * 1.5); |
| } |
| grid[row] = randomRow; |
| } |
| context.fillStyle = 'white'; |
| context.fillText('Press space or touch here to start', 100, 200); |
| } |
| |
| function drawTile(point1, color) { |
| if (color == 1) { |
| let point2 = { |
| x: point1.x + 1, |
| y: point1.y |
| }; |
| let point3 = { |
| x: point1.x + 1, |
| y: point1.y + 1 |
| }; |
| let point4 = { |
| x: point1.x, |
| y: point1.y + 1 |
| }; |
| let tunnelP1 = tunnelCoords(point1); |
| let tunnelP2 = tunnelCoords(point2); |
| let tunnelP3 = tunnelCoords(point3); |
| let tunnelP4 = tunnelCoords(point4); |
| context.fillStyle = 'gray'; |
| context.beginPath(); |
| context.moveTo(tunnelP1.x, tunnelP1.y); |
| context.lineTo(tunnelP2.x, tunnelP2.y); |
| context.lineTo(tunnelP3.x, tunnelP3.y); |
| context.lineTo(tunnelP4.x, tunnelP4.y); |
| context.lineTo(tunnelP1.x, tunnelP1.y); |
| context.fill(); |
| } |
| } |
| |
| function animate() { |
| if (mode == 'play') { |
| sprite.x = sprite.x + xSpeed; |
| if (sprite.x < 0) |
| sprite.x = 16 + sprite.x; |
| if (sprite.x >= 16) |
| sprite.x = sprite.x - 16; |
| if (arrowUp && jump <= 0) |
| currentJumpSpeed = jumpSpeed; |
| context.clearRect(0, 0, canvas.width, canvas.height); |
| for (let row = Math.floor(sprite.y - 1); row < grid.length; row++) |
| for (let x = 0; x < grid[0].length; x++) { |
| drawTile({ |
| x: x, |
| y: row |
| }, grid[row][x] > 0); |
| } |
| if (currentJumpSpeed) { |
| jump = jump + currentJumpSpeed; |
| if (jump >= maxJump) { |
| currentJumpSpeed = -jumpSpeed; |
| } |
| if (jump <= 0) |
| currentJumpSpeed = 0; |
| } |
| let spriteFrame; |
| if (jump > 0) |
| spriteFrame = 6; |
| else |
| spriteFrame = Math.floor(frame); |
| context.drawImage(image, spriteFrame * spriteSize, 0, spriteSize, spriteSize, (canvas.width - spriteSize) / 2, 550 - spriteSize - jump * jumpPixels, spriteSize, spriteSize); |
| let spriteTile = { |
| x: Math.floor(sprite.x), |
| y: Math.floor(sprite.y) |
| }; |
| if (jump <= 0 && !grid[spriteTile.y][spriteTile.x]) { |
| restart(); |
| context.fillStyle = 'white'; |
| context.fillText('Game over!', 100, 100); |
| mode = 'init'; |
| } |
| counter = counter - runSpeed; |
| sprite.y = sprite.y + runSpeed; |
| if (sprite.y >= maxRows) { |
| context.fillStyle = 'white'; |
| context.fillText('You win!', 100, 100); |
| restart(); |
| mode = 'init'; |
| } |
| runSpeed = runSpeed + 0.00001; |
| frame = frame + 0.05; |
| if (frame >= 6) |
| frame = 0; |
| context.fillStyle = 'white'; |
| context.fillText('Score: ' + (spriteTile.y - 2), 650, 50); |
| } |
| context.drawImage(buttons, 0, 500); |
| window.requestAnimationFrame(animate); |
| } |
| |
| function handleTouch(event) { |
| if (mode == 'init') { |
| mode = 'play'; |
| } else { |
| let x = event.clientX / window.innerWidth; |
| if (x < .5) |
| arrowUp = true; |
| if (x > .875) |
| xSpeed = 0.05; |
| if (x < .875 && x > .75) |
| xSpeed = -0.05; |
| } |
| event.preventDefault(); |
| } |
| |
| window.onkeydown = function(event) { |
| let key = event.key; |
| if (mode == 'init' && key == ' ') { |
| mode = 'play'; |
| } else { |
| switch (key) { |
| case 'ArrowUp': |
| arrowUp = true; |
| break; |
| case 'ArrowRight': |
| xSpeed = 0.05; |
| break; |
| case 'ArrowLeft': |
| xSpeed = -0.05; |
| } |
| } |
| }; |
| |
| function handleUp() { |
| arrowUp = false; |
| xSpeed = 0; |
| } |
| |
| window.onresize = resize; |
| window.onkeyup = handleUp; |
| window.onpointerdown = handleTouch; |
| window.onpointerup = handleUp; |
| resize(); |
| restart(); |
| animate(); |
| </script> |
| </body> |
| </html> |