Tunnel run game
WARNING! This tutorial contains explicit math! Viewer discretion advised!
The idea of the game is simple: run and jump through the maze avoiding the black areas.
What makes it look cool is that the maze is wrapped inside a tunnel.
The whole trick is handled by the function tunnelCoords.
If you don't feel like digging into the details of the math, feel free to
skip to the trigonometry-free section.
Our game is really played on a 2d grid, but this function displays it as a 3d tunnel.
It's easier to understand when you play around with the two canvases below: if you click on the top one, a white rectangle will be drawn where you clicked.
At the same time, a smaller corresponding rectangle will be drawn on the bottom canvas.
The top canvas uses the standard JS coordinate system - the point (0,0) is in the upper left corner. The x coordinate increases as you move to the right. The y coordinate increases as you move down.
The bottom canvas uses our tunnel 'coordinates'. The tunnel consists of concentric "circles" (actually they're 16-angle polygons - "regular hexadecagons", but who gives a damn?). For x=0, the point is at the bottom (6pm) of the circle. As the x coordinate increases, the point moves counterclockwise on the circle.
The y coordinate determines which of the circles the point is on. The higher the y coordinate, the smaller the circle.
The circles for y=0 and y=1 do not fit on our canvas (they're too big). The largest circle you see is for y=2.
Try drawing a line on the top canvas from (0,1) [upper left corner] to (0,10) [bottom left corner]. (The coordinates of the points on both canvases are shown without any parenthesis.) You should see corresponding points going from 6pm towards the center of the tunnel.
Here is the program that draws these canvases:
<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> |
Here's what the program does: it draws lines from (0,0) to (1,0), then to (2,0) etc on the top canvas - these create a horizontal line.
Then it gets the corresponding tunnel coordinates and draws them on the bottom canvas - they form a circle.
The rotation based on the original x coordinate happens using the sin/cos functions in lines [28-29].
The size of the circle is determined in lines [23-24] and [26]. [26] is what changes the distance between the circles - as the circles gets smaller (farther away from you), the distance between also decreases. This creates the illusion of the perspective.
Then the program increases the y coordinate and draws the lines again, in a darker color.
I purposefully stopped at x=14, so that you can see the incomplete circles to better understand how the horizontal lines correspond to the circles. In the game of course we're drawing the full circles to make sure our tunnel looks real.
Congratulations! You've battled through the hard part!
Let's look at the simpler parts of the code.
Lines [1-24] set up the HTML5 Canvas and declare the constants and the variables.
[26-29] Resize the canvas in case the user resizes the window or rotates the mobile device.
[44-67] (Re-)set the variables to the initial values (beginning of game)
[69-96] Draw a single tile. Point1 is in the upper left corner of the tile, using the classic coordinates system. Three more points are calculated, creating a square with a side length equal to 1. These points are then converted to tunnel coords and the tile is drawn.
[98-156] Main animation loop:
[100-104] The sprite is moved on the x axis (resulting in rotation of the tunnel)
[108-114] Draw all the tiles starting from one row below the sprite.
[115-122] Vertical jump
[124-132] Calculate and draw the current sprite animation frame from the spritesheet
[133-138] If the sprite is on a 'hole' in the grid, game over.
[141-146] If the sprite reached the finish line, you win.
[158-171] Handle the mouse and touch even - trigger left/right/jump based only on the x coordinate of the click/touch
[173-189] Handle the keyboard input
[191-194] Key/click/touch released
[202] Let's go!!!
Here's the full code:
<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> |
GitHub repo
All the code is provided under
the MIT license
Check out these programming tutorials:
JavaScript:
Optical illusion (18 lines)
Spinning squares - visual effect (25 lines)
Oldschool fire effect (20 lines)
Fireworks (60 lines)
Animated fractal (32 lines)
Physics engine for beginners
Physics engine - interactive sandbox
Physics engine - silly contraption
Starfield (21 lines)
Yin Yang with a twist (4 circles and 20 lines)
Tile map editor (70 lines)
Sine scroller (30 lines)
Interactive animated sprites
Image transition effect (16 lines)
Your first program in JavaScript: you need 5 minutes and a notepad
Fractals in Excel
Python in Blender 3d:
Domino effect (10 lines)
Wrecking ball effect (14 lines)
3d fractal in Blender Python