If you want to know how this game works, you can find a source code walkthrough video here: https://youtu.be/bTk6dcAckuI
Follow me on twitter
A Pen by HARUN PEHLİVAN on CodePen.
| <div id="center"> | |
| <div id="game"> | |
| <div id="maze"> | |
| <div id="end"></div> | |
| </div> | |
| <div id="joystick"> | |
| <div class="joystick-arrow"></div> | |
| <div class="joystick-arrow"></div> | |
| <div class="joystick-arrow"></div> | |
| <div class="joystick-arrow"></div> | |
| <div id="joystick-head"></div> | |
| </div> | |
| <div id="note"> | |
| Click the joystick to start! | |
| <p>Move every ball to the center. Ready for hard mode? Press H</p> | |
| </div> | |
| </div> | |
| </div> | |
| <a id="youtube" href="https://www.youtube.com/user/harunpehlivan1" target="_blank"> | |
| <span>HARUN PEHLİVAN </span> | |
| </a> | |
| <div id="youtube-card"> | |
| HARUN PEHLİVAN YouTube | |
| </div> |
| Math.minmax = (value, limit) => { | |
| return Math.max(Math.min(value, limit), -limit); | |
| }; | |
| const distance2D = (p1, p2) => { | |
| return Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2); | |
| }; | |
| // Angle between the two points | |
| const getAngle = (p1, p2) => { | |
| let angle = Math.atan((p2.y - p1.y) / (p2.x - p1.x)); | |
| if (p2.x - p1.x < 0) angle += Math.PI; | |
| return angle; | |
| }; | |
| // The closest a ball and a wall cap can be | |
| const closestItCanBe = (cap, ball) => { | |
| let angle = getAngle(cap, ball); | |
| const deltaX = Math.cos(angle) * (wallW / 2 + ballSize / 2); | |
| const deltaY = Math.sin(angle) * (wallW / 2 + ballSize / 2); | |
| return { x: cap.x + deltaX, y: cap.y + deltaY }; | |
| }; | |
| // Roll the ball around the wall cap | |
| const rollAroundCap = (cap, ball) => { | |
| // The direction the ball can't move any further because the wall holds it back | |
| let impactAngle = getAngle(ball, cap); | |
| // The direction the ball wants to move based on it's velocity | |
| let heading = getAngle( | |
| { x: 0, y: 0 }, | |
| { x: ball.velocityX, y: ball.velocityY } | |
| ); | |
| // The angle between the impact direction and the ball's desired direction | |
| // The smaller this angle is, the bigger the impact | |
| // The closer it is to 90 degrees the smoother it gets (at 90 there would be no collision) | |
| let impactHeadingAngle = impactAngle - heading; | |
| // Velocity distance if not hit would have occurred | |
| const velocityMagnitude = distance2D( | |
| { x: 0, y: 0 }, | |
| { x: ball.velocityX, y: ball.velocityY } | |
| ); | |
| // Velocity component diagonal to the impact | |
| const velocityMagnitudeDiagonalToTheImpact = | |
| Math.sin(impactHeadingAngle) * velocityMagnitude; | |
| // How far should the ball be from the wall cap | |
| const closestDistance = wallW / 2 + ballSize / 2; | |
| const rotationAngle = Math.atan( | |
| velocityMagnitudeDiagonalToTheImpact / closestDistance | |
| ); | |
| const deltaFromCap = { | |
| x: Math.cos(impactAngle + Math.PI - rotationAngle) * closestDistance, | |
| y: Math.sin(impactAngle + Math.PI - rotationAngle) * closestDistance | |
| }; | |
| const x = ball.x; | |
| const y = ball.y; | |
| const velocityX = ball.x - (cap.x + deltaFromCap.x); | |
| const velocityY = ball.y - (cap.y + deltaFromCap.y); | |
| const nextX = x + velocityX; | |
| const nextY = y + velocityY; | |
| return { x, y, velocityX, velocityY, nextX, nextY }; | |
| }; | |
| // Decreases the absolute value of a number but keeps it's sign, doesn't go below abs 0 | |
| const slow = (number, difference) => { | |
| if (Math.abs(number) <= difference) return 0; | |
| if (number > difference) return number - difference; | |
| return number + difference; | |
| }; | |
| const mazeElement = document.getElementById("maze"); | |
| const joystickHeadElement = document.getElementById("joystick-head"); | |
| const noteElement = document.getElementById("note"); // Note element for instructions and game won, game failed texts | |
| let hardMode = false; | |
| let previousTimestamp; | |
| let gameInProgress; | |
| let mouseStartX; | |
| let mouseStartY; | |
| let accelerationX; | |
| let accelerationY; | |
| let frictionX; | |
| let frictionY; | |
| const pathW = 25; // Path width | |
| const wallW = 10; // Wall width | |
| const ballSize = 10; // Width and height of the ball | |
| const holeSize = 18; | |
| const debugMode = false; | |
| let balls = []; | |
| let ballElements = []; | |
| let holeElements = []; | |
| resetGame(); | |
| // Draw balls for the first time | |
| balls.forEach(({ x, y }) => { | |
| const ball = document.createElement("div"); | |
| ball.setAttribute("class", "ball"); | |
| ball.style.cssText = `left: ${x}px; top: ${y}px; `; | |
| mazeElement.appendChild(ball); | |
| ballElements.push(ball); | |
| }); | |
| // Wall metadata | |
| const walls = [ | |
| // Border | |
| { column: 0, row: 0, horizontal: true, length: 10 }, | |
| { column: 0, row: 0, horizontal: false, length: 9 }, | |
| { column: 0, row: 9, horizontal: true, length: 10 }, | |
| { column: 10, row: 0, horizontal: false, length: 9 }, | |
| // Horizontal lines starting in 1st column | |
| { column: 0, row: 6, horizontal: true, length: 1 }, | |
| { column: 0, row: 8, horizontal: true, length: 1 }, | |
| // Horizontal lines starting in 2nd column | |
| { column: 1, row: 1, horizontal: true, length: 2 }, | |
| { column: 1, row: 7, horizontal: true, length: 1 }, | |
| // Horizontal lines starting in 3rd column | |
| { column: 2, row: 2, horizontal: true, length: 2 }, | |
| { column: 2, row: 4, horizontal: true, length: 1 }, | |
| { column: 2, row: 5, horizontal: true, length: 1 }, | |
| { column: 2, row: 6, horizontal: true, length: 1 }, | |
| // Horizontal lines starting in 4th column | |
| { column: 3, row: 3, horizontal: true, length: 1 }, | |
| { column: 3, row: 8, horizontal: true, length: 3 }, | |
| // Horizontal lines starting in 5th column | |
| { column: 4, row: 6, horizontal: true, length: 1 }, | |
| // Horizontal lines starting in 6th column | |
| { column: 5, row: 2, horizontal: true, length: 2 }, | |
| { column: 5, row: 7, horizontal: true, length: 1 }, | |
| // Horizontal lines starting in 7th column | |
| { column: 6, row: 1, horizontal: true, length: 1 }, | |
| { column: 6, row: 6, horizontal: true, length: 2 }, | |
| // Horizontal lines starting in 8th column | |
| { column: 7, row: 3, horizontal: true, length: 2 }, | |
| { column: 7, row: 7, horizontal: true, length: 2 }, | |
| // Horizontal lines starting in 9th column | |
| { column: 8, row: 1, horizontal: true, length: 1 }, | |
| { column: 8, row: 2, horizontal: true, length: 1 }, | |
| { column: 8, row: 3, horizontal: true, length: 1 }, | |
| { column: 8, row: 4, horizontal: true, length: 2 }, | |
| { column: 8, row: 8, horizontal: true, length: 2 }, | |
| // Vertical lines after the 1st column | |
| { column: 1, row: 1, horizontal: false, length: 2 }, | |
| { column: 1, row: 4, horizontal: false, length: 2 }, | |
| // Vertical lines after the 2nd column | |
| { column: 2, row: 2, horizontal: false, length: 2 }, | |
| { column: 2, row: 5, horizontal: false, length: 1 }, | |
| { column: 2, row: 7, horizontal: false, length: 2 }, | |
| // Vertical lines after the 3rd column | |
| { column: 3, row: 0, horizontal: false, length: 1 }, | |
| { column: 3, row: 4, horizontal: false, length: 1 }, | |
| { column: 3, row: 6, horizontal: false, length: 2 }, | |
| // Vertical lines after the 4th column | |
| { column: 4, row: 1, horizontal: false, length: 2 }, | |
| { column: 4, row: 6, horizontal: false, length: 1 }, | |
| // Vertical lines after the 5th column | |
| { column: 5, row: 0, horizontal: false, length: 2 }, | |
| { column: 5, row: 6, horizontal: false, length: 1 }, | |
| { column: 5, row: 8, horizontal: false, length: 1 }, | |
| // Vertical lines after the 6th column | |
| { column: 6, row: 4, horizontal: false, length: 1 }, | |
| { column: 6, row: 6, horizontal: false, length: 1 }, | |
| // Vertical lines after the 7th column | |
| { column: 7, row: 1, horizontal: false, length: 4 }, | |
| { column: 7, row: 7, horizontal: false, length: 2 }, | |
| // Vertical lines after the 8th column | |
| { column: 8, row: 2, horizontal: false, length: 1 }, | |
| { column: 8, row: 4, horizontal: false, length: 2 }, | |
| // Vertical lines after the 9th column | |
| { column: 9, row: 1, horizontal: false, length: 1 }, | |
| { column: 9, row: 5, horizontal: false, length: 2 } | |
| ].map((wall) => ({ | |
| x: wall.column * (pathW + wallW), | |
| y: wall.row * (pathW + wallW), | |
| horizontal: wall.horizontal, | |
| length: wall.length * (pathW + wallW) | |
| })); | |
| // Draw walls | |
| walls.forEach(({ x, y, horizontal, length }) => { | |
| const wall = document.createElement("div"); | |
| wall.setAttribute("class", "wall"); | |
| wall.style.cssText = ` | |
| left: ${x}px; | |
| top: ${y}px; | |
| width: ${wallW}px; | |
| height: ${length}px; | |
| transform: rotate(${horizontal ? -90 : 0}deg); | |
| `; | |
| mazeElement.appendChild(wall); | |
| }); | |
| const holes = [ | |
| { column: 0, row: 5 }, | |
| { column: 2, row: 0 }, | |
| { column: 2, row: 4 }, | |
| { column: 4, row: 6 }, | |
| { column: 6, row: 2 }, | |
| { column: 6, row: 8 }, | |
| { column: 8, row: 1 }, | |
| { column: 8, row: 2 } | |
| ].map((hole) => ({ | |
| x: hole.column * (wallW + pathW) + (wallW / 2 + pathW / 2), | |
| y: hole.row * (wallW + pathW) + (wallW / 2 + pathW / 2) | |
| })); | |
| joystickHeadElement.addEventListener("mousedown", function (event) { | |
| if (!gameInProgress) { | |
| mouseStartX = event.clientX; | |
| mouseStartY = event.clientY; | |
| gameInProgress = true; | |
| window.requestAnimationFrame(main); | |
| noteElement.style.opacity = 0; | |
| joystickHeadElement.style.cssText = ` | |
| animation: none; | |
| cursor: grabbing; | |
| `; | |
| } | |
| }); | |
| window.addEventListener("mousemove", function (event) { | |
| if (gameInProgress) { | |
| const mouseDeltaX = -Math.minmax(mouseStartX - event.clientX, 15); | |
| const mouseDeltaY = -Math.minmax(mouseStartY - event.clientY, 15); | |
| joystickHeadElement.style.cssText = ` | |
| left: ${mouseDeltaX}px; | |
| top: ${mouseDeltaY}px; | |
| animation: none; | |
| cursor: grabbing; | |
| `; | |
| const rotationY = mouseDeltaX * 0.8; // Max rotation = 12 | |
| const rotationX = mouseDeltaY * 0.8; | |
| mazeElement.style.cssText = ` | |
| transform: rotateY(${rotationY}deg) rotateX(${-rotationX}deg) | |
| `; | |
| const gravity = 2; | |
| const friction = 0.01; // Coefficients of friction | |
| accelerationX = gravity * Math.sin((rotationY / 180) * Math.PI); | |
| accelerationY = gravity * Math.sin((rotationX / 180) * Math.PI); | |
| frictionX = gravity * Math.cos((rotationY / 180) * Math.PI) * friction; | |
| frictionY = gravity * Math.cos((rotationX / 180) * Math.PI) * friction; | |
| } | |
| }); | |
| window.addEventListener("keydown", function (event) { | |
| // If not an arrow key or space or H was pressed then return | |
| if (![" ", "H", "h", "E", "e"].includes(event.key)) return; | |
| // If an arrow key was pressed then first prevent default | |
| event.preventDefault(); | |
| // If space was pressed restart the game | |
| if (event.key == " ") { | |
| resetGame(); | |
| return; | |
| } | |
| // Set Hard mode | |
| if (event.key == "H" || event.key == "h") { | |
| hardMode = true; | |
| resetGame(); | |
| return; | |
| } | |
| // Set Easy mode | |
| if (event.key == "E" || event.key == "e") { | |
| hardMode = false; | |
| resetGame(); | |
| return; | |
| } | |
| }); | |
| function resetGame() { | |
| previousTimestamp = undefined; | |
| gameInProgress = false; | |
| mouseStartX = undefined; | |
| mouseStartY = undefined; | |
| accelerationX = undefined; | |
| accelerationY = undefined; | |
| frictionX = undefined; | |
| frictionY = undefined; | |
| mazeElement.style.cssText = ` | |
| transform: rotateY(0deg) rotateX(0deg) | |
| `; | |
| joystickHeadElement.style.cssText = ` | |
| left: 0; | |
| top: 0; | |
| animation: glow; | |
| cursor: grab; | |
| `; | |
| if (hardMode) { | |
| noteElement.innerHTML = `Click the joystick to start! | |
| <p>Hard mode, Avoid black holes. Back to easy mode? Press E</p>`; | |
| } else { | |
| noteElement.innerHTML = `Click the joystick to start! | |
| <p>Move every ball to the center. Ready for hard mode? Press H</p>`; | |
| } | |
| noteElement.style.opacity = 1; | |
| balls = [ | |
| { column: 0, row: 0 }, | |
| { column: 9, row: 0 }, | |
| { column: 0, row: 8 }, | |
| { column: 9, row: 8 } | |
| ].map((ball) => ({ | |
| x: ball.column * (wallW + pathW) + (wallW / 2 + pathW / 2), | |
| y: ball.row * (wallW + pathW) + (wallW / 2 + pathW / 2), | |
| velocityX: 0, | |
| velocityY: 0 | |
| })); | |
| if (ballElements.length) { | |
| balls.forEach(({ x, y }, index) => { | |
| ballElements[index].style.cssText = `left: ${x}px; top: ${y}px; `; | |
| }); | |
| } | |
| // Remove previous hole elements | |
| holeElements.forEach((holeElement) => { | |
| mazeElement.removeChild(holeElement); | |
| }); | |
| holeElements = []; | |
| // Reset hole elements if hard mode | |
| if (hardMode) { | |
| holes.forEach(({ x, y }) => { | |
| const ball = document.createElement("div"); | |
| ball.setAttribute("class", "black-hole"); | |
| ball.style.cssText = `left: ${x}px; top: ${y}px; `; | |
| mazeElement.appendChild(ball); | |
| holeElements.push(ball); | |
| }); | |
| } | |
| } | |
| function main(timestamp) { | |
| // It is possible to reset the game mid-game. This case the look should stop | |
| if (!gameInProgress) return; | |
| if (previousTimestamp === undefined) { | |
| previousTimestamp = timestamp; | |
| window.requestAnimationFrame(main); | |
| return; | |
| } | |
| const maxVelocity = 1.5; | |
| // Time passed since last cycle divided by 16 | |
| // This function gets called every 16 ms on average so dividing by 16 will result in 1 | |
| const timeElapsed = (timestamp - previousTimestamp) / 16; | |
| try { | |
| // If mouse didn't move yet don't do anything | |
| if (accelerationX != undefined && accelerationY != undefined) { | |
| const velocityChangeX = accelerationX * timeElapsed; | |
| const velocityChangeY = accelerationY * timeElapsed; | |
| const frictionDeltaX = frictionX * timeElapsed; | |
| const frictionDeltaY = frictionY * timeElapsed; | |
| balls.forEach((ball) => { | |
| if (velocityChangeX == 0) { | |
| // No rotation, the plane is flat | |
| // On flat surface friction can only slow down, but not reverse movement | |
| ball.velocityX = slow(ball.velocityX, frictionDeltaX); | |
| } else { | |
| ball.velocityX = ball.velocityX + velocityChangeX; | |
| ball.velocityX = Math.max(Math.min(ball.velocityX, 1.5), -1.5); | |
| ball.velocityX = | |
| ball.velocityX - Math.sign(velocityChangeX) * frictionDeltaX; | |
| ball.velocityX = Math.minmax(ball.velocityX, maxVelocity); | |
| } | |
| if (velocityChangeY == 0) { | |
| // No rotation, the plane is flat | |
| // On flat surface friction can only slow down, but not reverse movement | |
| ball.velocityY = slow(ball.velocityY, frictionDeltaY); | |
| } else { | |
| ball.velocityY = ball.velocityY + velocityChangeY; | |
| ball.velocityY = | |
| ball.velocityY - Math.sign(velocityChangeY) * frictionDeltaY; | |
| ball.velocityY = Math.minmax(ball.velocityY, maxVelocity); | |
| } | |
| // Preliminary next ball position, only becomes true if no hit occurs | |
| // Used only for hit testing, does not mean that the ball will reach this position | |
| ball.nextX = ball.x + ball.velocityX; | |
| ball.nextY = ball.y + ball.velocityY; | |
| if (debugMode) console.log("tick", ball); | |
| walls.forEach((wall, wi) => { | |
| if (wall.horizontal) { | |
| // Horizontal wall | |
| if ( | |
| ball.nextY + ballSize / 2 >= wall.y - wallW / 2 && | |
| ball.nextY - ballSize / 2 <= wall.y + wallW / 2 | |
| ) { | |
| // Ball got within the strip of the wall | |
| // (not necessarily hit it, could be before or after) | |
| const wallStart = { | |
| x: wall.x, | |
| y: wall.y | |
| }; | |
| const wallEnd = { | |
| x: wall.x + wall.length, | |
| y: wall.y | |
| }; | |
| if ( | |
| ball.nextX + ballSize / 2 >= wallStart.x - wallW / 2 && | |
| ball.nextX < wallStart.x | |
| ) { | |
| // Ball might hit the left cap of a horizontal wall | |
| const distance = distance2D(wallStart, { | |
| x: ball.nextX, | |
| y: ball.nextY | |
| }); | |
| if (distance < ballSize / 2 + wallW / 2) { | |
| if (debugMode && wi > 4) | |
| console.warn("too close h head", distance, ball); | |
| // Ball hits the left cap of a horizontal wall | |
| const closest = closestItCanBe(wallStart, { | |
| x: ball.nextX, | |
| y: ball.nextY | |
| }); | |
| const rolled = rollAroundCap(wallStart, { | |
| x: closest.x, | |
| y: closest.y, | |
| velocityX: ball.velocityX, | |
| velocityY: ball.velocityY | |
| }); | |
| Object.assign(ball, rolled); | |
| } | |
| } | |
| if ( | |
| ball.nextX - ballSize / 2 <= wallEnd.x + wallW / 2 && | |
| ball.nextX > wallEnd.x | |
| ) { | |
| // Ball might hit the right cap of a horizontal wall | |
| const distance = distance2D(wallEnd, { | |
| x: ball.nextX, | |
| y: ball.nextY | |
| }); | |
| if (distance < ballSize / 2 + wallW / 2) { | |
| if (debugMode && wi > 4) | |
| console.warn("too close h tail", distance, ball); | |
| // Ball hits the right cap of a horizontal wall | |
| const closest = closestItCanBe(wallEnd, { | |
| x: ball.nextX, | |
| y: ball.nextY | |
| }); | |
| const rolled = rollAroundCap(wallEnd, { | |
| x: closest.x, | |
| y: closest.y, | |
| velocityX: ball.velocityX, | |
| velocityY: ball.velocityY | |
| }); | |
| Object.assign(ball, rolled); | |
| } | |
| } | |
| if (ball.nextX >= wallStart.x && ball.nextX <= wallEnd.x) { | |
| // The ball got inside the main body of the wall | |
| if (ball.nextY < wall.y) { | |
| // Hit horizontal wall from top | |
| ball.nextY = wall.y - wallW / 2 - ballSize / 2; | |
| } else { | |
| // Hit horizontal wall from bottom | |
| ball.nextY = wall.y + wallW / 2 + ballSize / 2; | |
| } | |
| ball.y = ball.nextY; | |
| ball.velocityY = -ball.velocityY / 3; | |
| if (debugMode && wi > 4) | |
| console.error("crossing h line, HIT", ball); | |
| } | |
| } | |
| } else { | |
| // Vertical wall | |
| if ( | |
| ball.nextX + ballSize / 2 >= wall.x - wallW / 2 && | |
| ball.nextX - ballSize / 2 <= wall.x + wallW / 2 | |
| ) { | |
| // Ball got within the strip of the wall | |
| // (not necessarily hit it, could be before or after) | |
| const wallStart = { | |
| x: wall.x, | |
| y: wall.y | |
| }; | |
| const wallEnd = { | |
| x: wall.x, | |
| y: wall.y + wall.length | |
| }; | |
| if ( | |
| ball.nextY + ballSize / 2 >= wallStart.y - wallW / 2 && | |
| ball.nextY < wallStart.y | |
| ) { | |
| // Ball might hit the top cap of a horizontal wall | |
| const distance = distance2D(wallStart, { | |
| x: ball.nextX, | |
| y: ball.nextY | |
| }); | |
| if (distance < ballSize / 2 + wallW / 2) { | |
| if (debugMode && wi > 4) | |
| console.warn("too close v head", distance, ball); | |
| // Ball hits the left cap of a horizontal wall | |
| const closest = closestItCanBe(wallStart, { | |
| x: ball.nextX, | |
| y: ball.nextY | |
| }); | |
| const rolled = rollAroundCap(wallStart, { | |
| x: closest.x, | |
| y: closest.y, | |
| velocityX: ball.velocityX, | |
| velocityY: ball.velocityY | |
| }); | |
| Object.assign(ball, rolled); | |
| } | |
| } | |
| if ( | |
| ball.nextY - ballSize / 2 <= wallEnd.y + wallW / 2 && | |
| ball.nextY > wallEnd.y | |
| ) { | |
| // Ball might hit the bottom cap of a horizontal wall | |
| const distance = distance2D(wallEnd, { | |
| x: ball.nextX, | |
| y: ball.nextY | |
| }); | |
| if (distance < ballSize / 2 + wallW / 2) { | |
| if (debugMode && wi > 4) | |
| console.warn("too close v tail", distance, ball); | |
| // Ball hits the right cap of a horizontal wall | |
| const closest = closestItCanBe(wallEnd, { | |
| x: ball.nextX, | |
| y: ball.nextY | |
| }); | |
| const rolled = rollAroundCap(wallEnd, { | |
| x: closest.x, | |
| y: closest.y, | |
| velocityX: ball.velocityX, | |
| velocityY: ball.velocityY | |
| }); | |
| Object.assign(ball, rolled); | |
| } | |
| } | |
| if (ball.nextY >= wallStart.y && ball.nextY <= wallEnd.y) { | |
| // The ball got inside the main body of the wall | |
| if (ball.nextX < wall.x) { | |
| // Hit vertical wall from left | |
| ball.nextX = wall.x - wallW / 2 - ballSize / 2; | |
| } else { | |
| // Hit vertical wall from right | |
| ball.nextX = wall.x + wallW / 2 + ballSize / 2; | |
| } | |
| ball.x = ball.nextX; | |
| ball.velocityX = -ball.velocityX / 3; | |
| if (debugMode && wi > 4) | |
| console.error("crossing v line, HIT", ball); | |
| } | |
| } | |
| } | |
| }); | |
| // Detect is a ball fell into a hole | |
| if (hardMode) { | |
| holes.forEach((hole, hi) => { | |
| const distance = distance2D(hole, { | |
| x: ball.nextX, | |
| y: ball.nextY | |
| }); | |
| if (distance <= holeSize / 2) { | |
| // The ball fell into a hole | |
| holeElements[hi].style.backgroundColor = "red"; | |
| throw Error("The ball fell into a hole"); | |
| } | |
| }); | |
| } | |
| // Adjust ball metadata | |
| ball.x = ball.x + ball.velocityX; | |
| ball.y = ball.y + ball.velocityY; | |
| }); | |
| // Move balls to their new position on the UI | |
| balls.forEach(({ x, y }, index) => { | |
| ballElements[index].style.cssText = `left: ${x}px; top: ${y}px; `; | |
| }); | |
| } | |
| // Win detection | |
| if ( | |
| balls.every( | |
| (ball) => distance2D(ball, { x: 350 / 2, y: 315 / 2 }) < 65 / 2 | |
| ) | |
| ) { | |
| noteElement.innerHTML = `Congrats, you did it! | |
| ${!hardMode ? "<p>Press H for hard mode</p>" : ""} | |
| <p> | |
| Follow me | |
| <a href="https://hpitgroup.glitch.me/" , target="_blank" | |
| >HARUN PEHLİVAN </a | |
| > | |
| </p>`; | |
| noteElement.style.opacity = 1; | |
| gameInProgress = false; | |
| } else { | |
| previousTimestamp = timestamp; | |
| window.requestAnimationFrame(main); | |
| } | |
| } catch (error) { | |
| if (error.message == "The ball fell into a hole") { | |
| noteElement.innerHTML = `A ball fell into a black hole! Press space to reset the game. | |
| <p> | |
| Back to easy? Press E | |
| </p>`; | |
| noteElement.style.opacity = 1; | |
| gameInProgress = false; | |
| } else throw error; | |
| } | |
| } |
| body { | |
| https://coolors.co/f06449-ede6e3-7d82b8-36382e-613f75 | |
| --background-color: #ede6e3; | |
| --wall-color: #36382e; | |
| --joystick-color: #210124; | |
| --joystick-head-color: #f06449; | |
| --ball-color: #f06449; | |
| --end-color: #7d82b8; | |
| --text-color: #210124; | |
| font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; | |
| background-color: var(--background-color); | |
| } | |
| html, | |
| body { | |
| height: 100%; | |
| margin: 0; | |
| } | |
| #center { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| height: 100%; | |
| } | |
| #game { | |
| display: grid; | |
| grid-template-columns: auto 150px; | |
| grid-template-rows: 1fr auto 1fr; | |
| gap: 30px; | |
| perspective: 600px; | |
| } | |
| #maze { | |
| position: relative; | |
| grid-row: 1 / -1; | |
| grid-column: 1; | |
| width: 350px; | |
| height: 315px; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| #end { | |
| width: 65px; | |
| height: 65px; | |
| border: 5px dashed var(--end-color); | |
| border-radius: 50%; | |
| } | |
| #joystick { | |
| position: relative; | |
| background-color: var(--joystick-color); | |
| border-radius: 50%; | |
| width: 50px; | |
| height: 50px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| margin: 10px 50px; | |
| grid-row: 2; | |
| } | |
| #joystick-head { | |
| position: relative; | |
| background-color: var(--joystick-head-color); | |
| border-radius: 50%; | |
| width: 20px; | |
| height: 20px; | |
| cursor: grab; | |
| animation-name: glow; | |
| animation-duration: 0.6s; | |
| animation-iteration-count: infinite; | |
| animation-direction: alternate; | |
| animation-timing-function: ease-in-out; | |
| animation-delay: 4s; | |
| } | |
| @keyframes glow { | |
| 0% { | |
| transform: scale(1); | |
| } | |
| 100% { | |
| transform: scale(1.2); | |
| } | |
| } | |
| .joystick-arrow:nth-of-type(1) { | |
| position: absolute; | |
| bottom: 55px; | |
| width: 0; | |
| height: 0; | |
| border-left: 10px solid transparent; | |
| border-right: 10px solid transparent; | |
| border-bottom: 10px solid var(--joystick-color); | |
| } | |
| .joystick-arrow:nth-of-type(2) { | |
| position: absolute; | |
| top: 55px; | |
| width: 0; | |
| height: 0; | |
| border-left: 10px solid transparent; | |
| border-right: 10px solid transparent; | |
| border-top: 10px solid var(--joystick-color); | |
| } | |
| .joystick-arrow:nth-of-type(3) { | |
| position: absolute; | |
| left: 55px; | |
| width: 0; | |
| height: 0; | |
| border-top: 10px solid transparent; | |
| border-bottom: 10px solid transparent; | |
| border-left: 10px solid var(--joystick-color); | |
| } | |
| .joystick-arrow:nth-of-type(4) { | |
| position: absolute; | |
| right: 55px; | |
| width: 0; | |
| height: 0; | |
| border-top: 10px solid transparent; | |
| border-bottom: 10px solid transparent; | |
| border-right: 10px solid var(--joystick-color); | |
| } | |
| #note { | |
| grid-row: 3; | |
| grid-column: 2; | |
| text-align: center; | |
| font-size: 0.8em; | |
| color: var(--text-color); | |
| transition: opacity 2s; | |
| } | |
| a:visited { | |
| color: inherit; | |
| } | |
| .ball { | |
| position: absolute; | |
| margin-top: -5px; | |
| margin-left: -5px; | |
| border-radius: 50%; | |
| background-color: var(--ball-color); | |
| width: 10px; | |
| height: 10px; | |
| } | |
| .wall { | |
| position: absolute; | |
| background-color: var(--wall-color); | |
| transform-origin: top center; | |
| margin-left: -5px; | |
| } | |
| .wall::before, | |
| .wall::after { | |
| display: block; | |
| content: ""; | |
| width: 10px; | |
| height: 10px; | |
| background-color: inherit; | |
| border-radius: 50%; | |
| position: absolute; | |
| } | |
| .wall::before { | |
| top: -5px; | |
| } | |
| .wall::after { | |
| bottom: -5px; | |
| } | |
| .black-hole { | |
| position: absolute; | |
| margin-top: -9px; | |
| margin-left: -9px; | |
| border-radius: 50%; | |
| background-color: black; | |
| width: 18px; | |
| height: 18px; | |
| } | |
| #youtube, | |
| #youtube-card { | |
| display: none; | |
| } | |
| @media (min-height: 425px) { | |
| /** Youtube logo by https://codepen.io/alvaromontoro */ | |
| #youtube { | |
| z-index: 2; | |
| display: block; | |
| width: 100px; | |
| height: 70px; | |
| position: absolute; | |
| bottom: 20px; | |
| right: 20px; | |
| background: red; | |
| border-radius: 50% / 11%; | |
| transform: scale(0.8); | |
| transition: transform 0.5s; | |
| } | |
| #youtube:hover, | |
| #youtube:focus { | |
| transform: scale(0.9); | |
| } | |
| #youtube::before { | |
| content: ""; | |
| display: block; | |
| position: absolute; | |
| top: 7.5%; | |
| left: -6%; | |
| width: 112%; | |
| height: 85%; | |
| background: red; | |
| border-radius: 9% / 50%; | |
| } | |
| #youtube::after { | |
| content: ""; | |
| display: block; | |
| position: absolute; | |
| top: 20px; | |
| left: 40px; | |
| width: 45px; | |
| height: 30px; | |
| border: 15px solid transparent; | |
| box-sizing: border-box; | |
| border-left: 30px solid white; | |
| } | |
| #youtube span { | |
| font-size: 0; | |
| position: absolute; | |
| width: 0; | |
| height: 0; | |
| overflow: hidden; | |
| } | |
| #youtube:hover + #youtube-card { | |
| display: block; | |
| position: absolute; | |
| bottom: 12px; | |
| right: 10px; | |
| padding: 25px 130px 25px 25px; | |
| width: 300px; | |
| background-color: white; | |
| } | |
| } |
If you want to know how this game works, you can find a source code walkthrough video here: https://youtu.be/bTk6dcAckuI
Follow me on twitter
A Pen by HARUN PEHLİVAN on CodePen.