Gra "Tunel"
OSTRZEŻENIE! Ten samouczek zawiera elementy matematyki! Programistów z alergią na trygonometrię prosimy o ostrożność!
Pomysł na grę jest prosty: biegnij i skacz przez labirynt, unikając czarnych pól.
To, co sprawia, że wygląda fajnie, to fakt, że labirynt jest zawinięty wewnątrz tunelu.
Cały trik obsługiwany jest przez funkcję tunnelCoords.
Jeśli nie masz ochoty zagłębiać się w szczegóły matematyki, możesz
przejść do sekcji wolnej od trygonometrii.
Nasza gra jest tak naprawdę rozgrywana na siatce 2D, ale ta funkcja wyświetla ją jako tunel 3D.
Łatwiej to zrozumieć, gdy pobawisz się dwoma poniższymi canvasami: jeśli klikniesz na górne, w miejscu kliknięcia zostanie narysowany biały prostokąt.
W tym samym czasie mniejszy odpowiadający mu prostokąt zostanie narysowany na dolnym canvasie.
Górne canvas używa standardowego układu współrzędnych JS - punkt (0,0) znajduje się w lewym górnym rogu. Współrzędna x rośnie w miarę przesuwania się w prawo. Współrzędna y rośnie w miarę przesuwania się w dół.
Dolne canvas używa naszych 'współrzędnych' tunelu. Tunel składa się z koncentrycznych „okręgów” (właściwie to wielokąty o 16 kątach - „regularne heksadekagony”, ale kogo to obchodzi?). Dla x=0 punkt znajduje się na dole (godzina 6) okręgu. W miarę wzrostu współrzędnej x punkt porusza się przeciwnie do ruchu wskazówek zegara po okręgu.
Współrzędna y określa, na którym z okręgów znajduje się punkt. Im wyższa współrzędna y, tym mniejszy okrąg.
Okręgi dla y=0 i y=1 nie mieszczą się na naszym canvasie (są za duże). Największy okrąg, jaki widzisz, jest dla y=2.
Spróbuj narysować linię na górnym płótnie od (0,1) [lewy górny róg] do (0,10) [lewy dolny róg]. (Współrzędne punktów na obu płótnach są pokazane bez nawiasów). Powinieneś zobaczyć odpowiadające im punkty idące od godziny 6 w stronę środka tunelu.
Oto program, który rysuje te płótna:
| <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> |
Oto co robi program: rysuje linie od (0,0) do (1,0), potem do (2,0) itd. na górnym płótnie - tworzą one linię poziomą.
Następnie pobiera odpowiednie współrzędne tunelu i rysuje je na dolnym płótnie - tworzą one okrąg.
Rotacja oparta na oryginalnej współrzędnej x odbywa się za pomocą funkcji sin/cos w liniach [28-29].
Rozmiar okręgu jest określany w liniach [23-24] i [26]. [26] to to, co zmienia odległość między okręgami - gdy okręgi stają się mniejsze (dalej od ciebie), odległość między nimi również maleje. Tworzy to iluzję perspektywy.
Następnie program zwiększa współrzędnę y i ponownie rysuje linie, w ciemniejszym kolorze.
Celowo zatrzymałem się na x=14, abyś mógł zobaczyć niepełne okręgi i lepiej zrozumieć, jak linie poziome odpowiadają okręgom. W grze oczywiście rysujemy pełne okręgi, aby upewnić się, że nasz tunel wygląda realistycznie.
Gratulacje! Przebrnąłeś przez trudną część!
Przyjrzyjmy się prostszym częściom kodu.
Linie [1-24] konfigurują HTML5 Canvas i deklarują stałe oraz zmienne.
[26-29] Zmień rozmiar płótna na wypadek, gdyby użytkownik zmienił rozmiar okna lub obrócił urządzenie mobilne.
[44-67] (Z)resetuj zmienne do wartości początkowych (początek gry)
[69-96] Narysuj pojedynczy kafel. Point1 znajduje się w lewym górnym rogu kafla, przy użyciu klasycznego układu współrzędnych. Obliczane są trzy kolejne punkty, tworząc kwadrat o boku równym 1. Punkty te są następnie konwertowane na współrzędne tunelu i kafel zostaje narysowany.
[98-156] Główna pętla animacji:
[100-104] Sprite porusza się wzdłuż osi x (co skutkuje obrotem tunelu)
[108-114] Narysuj wszystkie kafle, zaczynając od jednego rzędu poniżej sprite'a.
[115-122] Skok pionowy
[124-132] Oblicz i narysuj bieżącą klatkę animacji sprite'a z arkusza sprite'ów
[133-138] Jeśli sprite znajduje się na „dziurze” w siatce, koniec gry.
[141-146] Jeśli sprite dotarł do linii mety, wygrywasz.
[158-171] Obsługa myszy i dotyku - wyzwalaj lewo/prawo/skok na podstawie samej współrzędnej x kliknięcia/dotyku
[173-189] Obsługa wejścia klawiatury
[191-194] Klawisz/kliknięcie/dotyk zwolniony
[202] Ruszamy!!!
Oto pełny kod:
| <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> |
Więcej samouczków JS
Gra "Wieża" - 84 linie JavaScript
Fraktale - 25 linii
Sinus scroll - 30 linii
Gra "Angry Chickens"
Animowane krzywe kwadratowe - 40 linii
Animowane konstelacje cząsteczek - 42 linie
Eksperyment z enginem fizyki