HTML源代码:
<!DOCTYPE html> <html lang="en" > <head> <meta charset="UTF-8"> <title>HTML5倾斜迷宫</title> <link rel="stylesheet" href="css/style.css"> </head> <body> <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"> 点击操纵杆开始! <p>把每个球移到中心。准备好进入硬模式了吗?按H</p> </div> </div> </div> <div id="youtube-card"> 如何用JavaScript模拟迷宫中的球运动 </div> <script src="js/script.js"></script> </body> </html>
CSS(css/style.css)源代码:
1 body { 2 /* https://coolors.co/f06449-ede6e3-7d82b8-36382e-613f75 */ 3 --background-color: #ede6e3; 4 --wall-color: #36382e; 5 --joystick-color: #210124; 6 --joystick-head-color: #f06449; 7 --ball-color: #f06449; 8 --end-color: #7d82b8; 9 --text-color: #210124; 10 11 font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; 12 background-color: var(--background-color); 13 } 14 15 html, 16 body { 17 height: 100%; 18 margin: 0; 19 } 20 21 #center { 22 display: flex; 23 align-items: center; 24 justify-content: center; 25 height: 100%; 26 } 27 28 #game { 29 display: grid; 30 grid-template-columns: auto 150px; 31 grid-template-rows: 1fr auto 1fr; 32 gap: 30px; 33 perspective: 600px; 34 } 35 36 #maze { 37 position: relative; 38 grid-row: 1 / -1; 39 grid-column: 1; 40 width: 350px; 41 height: 315px; 42 display: flex; 43 justify-content: center; 44 align-items: center; 45 } 46 47 #end { 48 width: 65px; 49 height: 65px; 50 border: 5px dashed var(--end-color); 51 border-radius: 50%; 52 } 53 54 #joystick { 55 position: relative; 56 background-color: var(--joystick-color); 57 border-radius: 50%; 58 width: 50px; 59 height: 50px; 60 display: flex; 61 align-items: center; 62 justify-content: center; 63 margin: 10px 50px; 64 grid-row: 2; 65 } 66 67 #joystick-head { 68 position: relative; 69 background-color: var(--joystick-head-color); 70 border-radius: 50%; 71 width: 20px; 72 height: 20px; 73 cursor: grab; 74 75 animation-name: glow; 76 animation-duration: 0.6s; 77 animation-iteration-count: infinite; 78 animation-direction: alternate; 79 animation-timing-function: ease-in-out; 80 animation-delay: 4s; 81 } 82 83 @keyframes glow { 84 0% { 85 transform: scale(1); 86 } 87 100% { 88 transform: scale(1.2); 89 } 90 } 91 92 .joystick-arrow:nth-of-type(1) { 93 position: absolute; 94 bottom: 55px; 95 96 width: 0; 97 height: 0; 98 border-left: 10px solid transparent; 99 border-right: 10px solid transparent; 100 101 border-bottom: 10px solid var(--joystick-color); 102 } 103 104 .joystick-arrow:nth-of-type(2) { 105 position: absolute; 106 top: 55px; 107 108 width: 0; 109 height: 0; 110 border-left: 10px solid transparent; 111 border-right: 10px solid transparent; 112 113 border-top: 10px solid var(--joystick-color); 114 } 115 116 .joystick-arrow:nth-of-type(3) { 117 position: absolute; 118 left: 55px; 119 120 width: 0; 121 height: 0; 122 border-top: 10px solid transparent; 123 border-bottom: 10px solid transparent; 124 125 border-left: 10px solid var(--joystick-color); 126 } 127 128 .joystick-arrow:nth-of-type(4) { 129 position: absolute; 130 right: 55px; 131 132 width: 0; 133 height: 0; 134 border-top: 10px solid transparent; 135 border-bottom: 10px solid transparent; 136 137 border-right: 10px solid var(--joystick-color); 138 } 139 140 #note { 141 grid-row: 3; 142 grid-column: 2; 143 text-align: center; 144 font-size: 0.8em; 145 color: var(--text-color); 146 transition: opacity 2s; 147 } 148 149 a:visited { 150 color: inherit; 151 } 152 153 .ball { 154 position: absolute; 155 margin-top: -5px; 156 margin-left: -5px; 157 border-radius: 50%; 158 background-color: var(--ball-color); 159 width: 10px; 160 height: 10px; 161 } 162 163 .wall { 164 position: absolute; 165 background-color: var(--wall-color); 166 transform-origin: top center; 167 margin-left: -5px; 168 } 169 170 .wall::before, 171 .wall::after { 172 display: block; 173 content: ""; 174 width: 10px; 175 height: 10px; 176 background-color: inherit; 177 border-radius: 50%; 178 position: absolute; 179 } 180 181 .wall::before { 182 top: -5px; 183 } 184 185 .wall::after { 186 bottom: -5px; 187 } 188 189 .black-hole { 190 position: absolute; 191 margin-top: -9px; 192 margin-left: -9px; 193 border-radius: 50%; 194 background-color: black; 195 width: 18px; 196 height: 18px; 197 } 198 199 #youtube, 200 #youtube-card { 201 display: none; 202 } 203 204 @media (min-height: 425px) { 205 /** Youtube logo by https://codepen.io/alvaromontoro */ 206 #youtube { 207 z-index: 2; 208 display: block; 209 width: 100px; 210 height: 70px; 211 position: absolute; 212 bottom: 20px; 213 right: 20px; 214 background: red; 215 border-radius: 50% / 11%; 216 transform: scale(0.8); 217 transition: transform 0.5s; 218 } 219 220 #youtube:hover, 221 #youtube:focus { 222 transform: scale(0.9); 223 } 224 225 #youtube::before { 226 content: ""; 227 display: block; 228 position: absolute; 229 top: 7.5%; 230 left: -6%; 231 width: 112%; 232 height: 85%; 233 background: red; 234 border-radius: 9% / 50%; 235 } 236 237 #youtube::after { 238 content: ""; 239 display: block; 240 position: absolute; 241 top: 20px; 242 left: 40px; 243 width: 45px; 244 height: 30px; 245 border: 15px solid transparent; 246 box-sizing: border-box; 247 border-left: 30px solid white; 248 } 249 250 #youtube span { 251 font-size: 0; 252 position: absolute; 253 width: 0; 254 height: 0; 255 overflow: hidden; 256 } 257 258 #youtube:hover + #youtube-card { 259 display: block; 260 position: absolute; 261 bottom: 12px; 262 right: 10px; 263 padding: 25px 130px 25px 25px; 264 width: 300px; 265 background-color: white; 266 } 267 }
JS(js/script.js)源代码:
1 /* 2 3 If you want to know how this game works, you can find a source code walkthrough video here: https://youtu.be/bTk6dcAckuI 4 5 Follow me on twitter for more: https://twitter.com/HunorBorbely 6 7 */ 8 9 Math.minmax = (value, limit) => { 10 return Math.max(Math.min(value, limit), -limit); 11 }; 12 13 const distance2D = (p1, p2) => { 14 return Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2); 15 }; 16 17 // Angle between the two points 18 const getAngle = (p1, p2) => { 19 let angle = Math.atan((p2.y - p1.y) / (p2.x - p1.x)); 20 if (p2.x - p1.x < 0) angle += Math.PI; 21 return angle; 22 }; 23 24 // The closest a ball and a wall cap can be 25 const closestItCanBe = (cap, ball) => { 26 let angle = getAngle(cap, ball); 27 28 const deltaX = Math.cos(angle) * (wallW / 2 + ballSize / 2); 29 const deltaY = Math.sin(angle) * (wallW / 2 + ballSize / 2); 30 31 return { x: cap.x + deltaX, y: cap.y + deltaY }; 32 }; 33 34 // Roll the ball around the wall cap 35 const rollAroundCap = (cap, ball) => { 36 // The direction the ball can't move any further because the wall holds it back 37 let impactAngle = getAngle(ball, cap); 38 39 // The direction the ball wants to move based on it's velocity 40 let heading = getAngle( 41 { x: 0, y: 0 }, 42 { x: ball.velocityX, y: ball.velocityY } 43 ); 44 45 // The angle between the impact direction and the ball's desired direction 46 // The smaller this angle is, the bigger the impact 47 // The closer it is to 90 degrees the smoother it gets (at 90 there would be no collision) 48 let impactHeadingAngle = impactAngle - heading; 49 50 // Velocity distance if not hit would have occurred 51 const velocityMagnitude = distance2D( 52 { x: 0, y: 0 }, 53 { x: ball.velocityX, y: ball.velocityY } 54 ); 55 // Velocity component diagonal to the impact 56 const velocityMagnitudeDiagonalToTheImpact = 57 Math.sin(impactHeadingAngle) * velocityMagnitude; 58 59 // How far should the ball be from the wall cap 60 const closestDistance = wallW / 2 + ballSize / 2; 61 62 const rotationAngle = Math.atan( 63 velocityMagnitudeDiagonalToTheImpact / closestDistance 64 ); 65 66 const deltaFromCap = { 67 x: Math.cos(impactAngle + Math.PI - rotationAngle) * closestDistance, 68 y: Math.sin(impactAngle + Math.PI - rotationAngle) * closestDistance 69 }; 70 71 const x = ball.x; 72 const y = ball.y; 73 const velocityX = ball.x - (cap.x + deltaFromCap.x); 74 const velocityY = ball.y - (cap.y + deltaFromCap.y); 75 const nextX = x + velocityX; 76 const nextY = y + velocityY; 77 78 return { x, y, velocityX, velocityY, nextX, nextY }; 79 }; 80 81 // Decreases the absolute value of a number but keeps it's sign, doesn't go below abs 0 82 const slow = (number, difference) => { 83 if (Math.abs(number) <= difference) return 0; 84 if (number > difference) return number - difference; 85 return number + difference; 86 }; 87 88 const mazeElement = document.getElementById("maze"); 89 const joystickHeadElement = document.getElementById("joystick-head"); 90 const noteElement = document.getElementById("note"); // Note element for instructions and game won, game failed texts 91 92 let hardMode = false; 93 let previousTimestamp; 94 let gameInProgress; 95 let mouseStartX; 96 let mouseStartY; 97 let accelerationX; 98 let accelerationY; 99 let frictionX; 100 let frictionY; 101 102 const pathW = 25; // Path width 103 const wallW = 10; // Wall width 104 const ballSize = 10; // Width and height of the ball 105 const holeSize = 18; 106 107 const debugMode = false; 108 109 let balls = []; 110 let ballElements = []; 111 let holeElements = []; 112 113 resetGame(); 114 115 // Draw balls for the first time 116 balls.forEach(({ x, y }) => { 117 const ball = document.createElement("div"); 118 ball.setAttribute("class", "ball"); 119 ball.style.cssText = `left: ${x}px; top: ${y}px; `; 120 121 mazeElement.appendChild(ball); 122 ballElements.push(ball); 123 }); 124 125 // Wall metadata 126 const walls = [ 127 // Border 128 { column: 0, row: 0, horizontal: true, length: 10 }, 129 { column: 0, row: 0, horizontal: false, length: 9 }, 130 { column: 0, row: 9, horizontal: true, length: 10 }, 131 { column: 10, row: 0, horizontal: false, length: 9 }, 132 133 // Horizontal lines starting in 1st column 134 { column: 0, row: 6, horizontal: true, length: 1 }, 135 { column: 0, row: 8, horizontal: true, length: 1 }, 136 137 // Horizontal lines starting in 2nd column 138 { column: 1, row: 1, horizontal: true, length: 2 }, 139 { column: 1, row: 7, horizontal: true, length: 1 }, 140 141 // Horizontal lines starting in 3rd column 142 { column: 2, row: 2, horizontal: true, length: 2 }, 143 { column: 2, row: 4, horizontal: true, length: 1 }, 144 { column: 2, row: 5, horizontal: true, length: 1 }, 145 { column: 2, row: 6, horizontal: true, length: 1 }, 146 147 // Horizontal lines starting in 4th column 148 { column: 3, row: 3, horizontal: true, length: 1 }, 149 { column: 3, row: 8, horizontal: true, length: 3 }, 150 151 // Horizontal lines starting in 5th column 152 { column: 4, row: 6, horizontal: true, length: 1 }, 153 154 // Horizontal lines starting in 6th column 155 { column: 5, row: 2, horizontal: true, length: 2 }, 156 { column: 5, row: 7, horizontal: true, length: 1 }, 157 158 // Horizontal lines starting in 7th column 159 { column: 6, row: 1, horizontal: true, length: 1 }, 160 { column: 6, row: 6, horizontal: true, length: 2 }, 161 162 // Horizontal lines starting in 8th column 163 { column: 7, row: 3, horizontal: true, length: 2 }, 164 { column: 7, row: 7, horizontal: true, length: 2 }, 165 166 // Horizontal lines starting in 9th column 167 { column: 8, row: 1, horizontal: true, length: 1 }, 168 { column: 8, row: 2, horizontal: true, length: 1 }, 169 { column: 8, row: 3, horizontal: true, length: 1 }, 170 { column: 8, row: 4, horizontal: true, length: 2 }, 171 { column: 8, row: 8, horizontal: true, length: 2 }, 172 173 // Vertical lines after the 1st column 174 { column: 1, row: 1, horizontal: false, length: 2 }, 175 { column: 1, row: 4, horizontal: false, length: 2 }, 176 177 // Vertical lines after the 2nd column 178 { column: 2, row: 2, horizontal: false, length: 2 }, 179 { column: 2, row: 5, horizontal: false, length: 1 }, 180 { column: 2, row: 7, horizontal: false, length: 2 }, 181 182 // Vertical lines after the 3rd column 183 { column: 3, row: 0, horizontal: false, length: 1 }, 184 { column: 3, row: 4, horizontal: false, length: 1 }, 185 { column: 3, row: 6, horizontal: false, length: 2 }, 186 187 // Vertical lines after the 4th column 188 { column: 4, row: 1, horizontal: false, length: 2 }, 189 { column: 4, row: 6, horizontal: false, length: 1 }, 190 191 // Vertical lines after the 5th column 192 { column: 5, row: 0, horizontal: false, length: 2 }, 193 { column: 5, row: 6, horizontal: false, length: 1 }, 194 { column: 5, row: 8, horizontal: false, length: 1 }, 195 196 // Vertical lines after the 6th column 197 { column: 6, row: 4, horizontal: false, length: 1 }, 198 { column: 6, row: 6, horizontal: false, length: 1 }, 199 200 // Vertical lines after the 7th column 201 { column: 7, row: 1, horizontal: false, length: 4 }, 202 { column: 7, row: 7, horizontal: false, length: 2 }, 203 204 // Vertical lines after the 8th column 205 { column: 8, row: 2, horizontal: false, length: 1 }, 206 { column: 8, row: 4, horizontal: false, length: 2 }, 207 208 // Vertical lines after the 9th column 209 { column: 9, row: 1, horizontal: false, length: 1 }, 210 { column: 9, row: 5, horizontal: false, length: 2 } 211 ].map((wall) => ({ 212 x: wall.column * (pathW + wallW), 213 y: wall.row * (pathW + wallW), 214 horizontal: wall.horizontal, 215 length: wall.length * (pathW + wallW) 216 })); 217 218 // Draw walls 219 walls.forEach(({ x, y, horizontal, length }) => { 220 const wall = document.createElement("div"); 221 wall.setAttribute("class", "wall"); 222 wall.style.cssText = ` 223 left: ${x}px; 224 top: ${y}px; 225 width: ${wallW}px; 226 height: ${length}px; 227 transform: rotate(${horizontal ? -90 : 0}deg); 228 `; 229 230 mazeElement.appendChild(wall); 231 }); 232 233 const holes = [ 234 { column: 0, row: 5 }, 235 { column: 2, row: 0 }, 236 { column: 2, row: 4 }, 237 { column: 4, row: 6 }, 238 { column: 6, row: 2 }, 239 { column: 6, row: 8 }, 240 { column: 8, row: 1 }, 241 { column: 8, row: 2 } 242 ].map((hole) => ({ 243 x: hole.column * (wallW + pathW) + (wallW / 2 + pathW / 2), 244 y: hole.row * (wallW + pathW) + (wallW / 2 + pathW / 2) 245 })); 246 247 joystickHeadElement.addEventListener("mousedown", function (event) { 248 if (!gameInProgress) { 249 mouseStartX = event.clientX; 250 mouseStartY = event.clientY; 251 gameInProgress = true; 252 window.requestAnimationFrame(main); 253 noteElement.style.opacity = 0; 254 joystickHeadElement.style.cssText = ` 255 animation: none; 256 cursor: grabbing; 257 `; 258 } 259 }); 260 261 window.addEventListener("mousemove", function (event) { 262 if (gameInProgress) { 263 const mouseDeltaX = -Math.minmax(mouseStartX - event.clientX, 15); 264 const mouseDeltaY = -Math.minmax(mouseStartY - event.clientY, 15); 265 266 joystickHeadElement.style.cssText = ` 267 left: ${mouseDeltaX}px; 268 top: ${mouseDeltaY}px; 269 animation: none; 270 cursor: grabbing; 271 `; 272 273 const rotationY = mouseDeltaX * 0.8; // Max rotation = 12 274 const rotationX = mouseDeltaY * 0.8; 275 276 mazeElement.style.cssText = ` 277 transform: rotateY(${rotationY}deg) rotateX(${-rotationX}deg) 278 `; 279 280 const gravity = 2; 281 const friction = 0.01; // Coefficients of friction 282 283 accelerationX = gravity * Math.sin((rotationY / 180) * Math.PI); 284 accelerationY = gravity * Math.sin((rotationX / 180) * Math.PI); 285 frictionX = gravity * Math.cos((rotationY / 180) * Math.PI) * friction; 286 frictionY = gravity * Math.cos((rotationX / 180) * Math.PI) * friction; 287 } 288 }); 289 290 window.addEventListener("keydown", function (event) { 291 // If not an arrow key or space or H was pressed then return 292 if (![" ", "H", "h", "E", "e"].includes(event.key)) return; 293 294 // If an arrow key was pressed then first prevent default 295 event.preventDefault(); 296 297 // If space was pressed restart the game 298 if (event.key == " ") { 299 resetGame(); 300 return; 301 } 302 303 // Set Hard mode 304 if (event.key == "H" || event.key == "h") { 305 hardMode = true; 306 resetGame(); 307 return; 308 } 309 310 // Set Easy mode 311 if (event.key == "E" || event.key == "e") { 312 hardMode = false; 313 resetGame(); 314 return; 315 } 316 }); 317 318 function resetGame() { 319 previousTimestamp = undefined; 320 gameInProgress = false; 321 mouseStartX = undefined; 322 mouseStartY = undefined; 323 accelerationX = undefined; 324 accelerationY = undefined; 325 frictionX = undefined; 326 frictionY = undefined; 327 328 mazeElement.style.cssText = ` 329 transform: rotateY(0deg) rotateX(0deg) 330 `; 331 332 joystickHeadElement.style.cssText = ` 333 left: 0; 334 top: 0; 335 animation: glow; 336 cursor: grab; 337 `; 338 339 if (hardMode) { 340 noteElement.innerHTML = `Click the joystick to start! 341 <p>Hard mode, Avoid black holes. Back to easy mode? Press E</p>`; 342 } else { 343 noteElement.innerHTML = `Click the joystick to start! 344 <p>Move every ball to the center. Ready for hard mode? Press H</p>`; 345 } 346 noteElement.style.opacity = 1; 347 348 balls = [ 349 { column: 0, row: 0 }, 350 { column: 9, row: 0 }, 351 { column: 0, row: 8 }, 352 { column: 9, row: 8 } 353 ].map((ball) => ({ 354 x: ball.column * (wallW + pathW) + (wallW / 2 + pathW / 2), 355 y: ball.row * (wallW + pathW) + (wallW / 2 + pathW / 2), 356 velocityX: 0, 357 velocityY: 0 358 })); 359 360 if (ballElements.length) { 361 balls.forEach(({ x, y }, index) => { 362 ballElements[index].style.cssText = `left: ${x}px; top: ${y}px; `; 363 }); 364 } 365 366 // Remove previous hole elements 367 holeElements.forEach((holeElement) => { 368 mazeElement.removeChild(holeElement); 369 }); 370 holeElements = []; 371 372 // Reset hole elements if hard mode 373 if (hardMode) { 374 holes.forEach(({ x, y }) => { 375 const ball = document.createElement("div"); 376 ball.setAttribute("class", "black-hole"); 377 ball.style.cssText = `left: ${x}px; top: ${y}px; `; 378 379 mazeElement.appendChild(ball); 380 holeElements.push(ball); 381 }); 382 } 383 } 384 385 function main(timestamp) { 386 // It is possible to reset the game mid-game. This case the look should stop 387 if (!gameInProgress) return; 388 389 if (previousTimestamp === undefined) { 390 previousTimestamp = timestamp; 391 window.requestAnimationFrame(main); 392 return; 393 } 394 395 const maxVelocity = 1.5; 396 397 // Time passed since last cycle divided by 16 398 // This function gets called every 16 ms on average so dividing by 16 will result in 1 399 const timeElapsed = (timestamp - previousTimestamp) / 16; 400 401 try { 402 // If mouse didn't move yet don't do anything 403 if (accelerationX != undefined && accelerationY != undefined) { 404 const velocityChangeX = accelerationX * timeElapsed; 405 const velocityChangeY = accelerationY * timeElapsed; 406 const frictionDeltaX = frictionX * timeElapsed; 407 const frictionDeltaY = frictionY * timeElapsed; 408 409 balls.forEach((ball) => { 410 if (velocityChangeX == 0) { 411 // No rotation, the plane is flat 412 // On flat surface friction can only slow down, but not reverse movement 413 ball.velocityX = slow(ball.velocityX, frictionDeltaX); 414 } else { 415 ball.velocityX = ball.velocityX + velocityChangeX; 416 ball.velocityX = Math.max(Math.min(ball.velocityX, 1.5), -1.5); 417 ball.velocityX = 418 ball.velocityX - Math.sign(velocityChangeX) * frictionDeltaX; 419 ball.velocityX = Math.minmax(ball.velocityX, maxVelocity); 420 } 421 422 if (velocityChangeY == 0) { 423 // No rotation, the plane is flat 424 // On flat surface friction can only slow down, but not reverse movement 425 ball.velocityY = slow(ball.velocityY, frictionDeltaY); 426 } else { 427 ball.velocityY = ball.velocityY + velocityChangeY; 428 ball.velocityY = 429 ball.velocityY - Math.sign(velocityChangeY) * frictionDeltaY; 430 ball.velocityY = Math.minmax(ball.velocityY, maxVelocity); 431 } 432 433 // Preliminary next ball position, only becomes true if no hit occurs 434 // Used only for hit testing, does not mean that the ball will reach this position 435 ball.nextX = ball.x + ball.velocityX; 436 ball.nextY = ball.y + ball.velocityY; 437 438 if (debugMode) console.log("tick", ball); 439 440 walls.forEach((wall, wi) => { 441 if (wall.horizontal) { 442 // Horizontal wall 443 444 if ( 445 ball.nextY + ballSize / 2 >= wall.y - wallW / 2 && 446 ball.nextY - ballSize / 2 <= wall.y + wallW / 2 447 ) { 448 // Ball got within the strip of the wall 449 // (not necessarily hit it, could be before or after) 450 451 const wallStart = { 452 x: wall.x, 453 y: wall.y 454 }; 455 const wallEnd = { 456 x: wall.x + wall.length, 457 y: wall.y 458 }; 459 460 if ( 461 ball.nextX + ballSize / 2 >= wallStart.x - wallW / 2 && 462 ball.nextX < wallStart.x 463 ) { 464 // Ball might hit the left cap of a horizontal wall 465 const distance = distance2D(wallStart, { 466 x: ball.nextX, 467 y: ball.nextY 468 }); 469 if (distance < ballSize / 2 + wallW / 2) { 470 if (debugMode && wi > 4) 471 console.warn("too close h head", distance, ball); 472 473 // Ball hits the left cap of a horizontal wall 474 const closest = closestItCanBe(wallStart, { 475 x: ball.nextX, 476 y: ball.nextY 477 }); 478 const rolled = rollAroundCap(wallStart, { 479 x: closest.x, 480 y: closest.y, 481 velocityX: ball.velocityX, 482 velocityY: ball.velocityY 483 }); 484 485 Object.assign(ball, rolled); 486 } 487 } 488 489 if ( 490 ball.nextX - ballSize / 2 <= wallEnd.x + wallW / 2 && 491 ball.nextX > wallEnd.x 492 ) { 493 // Ball might hit the right cap of a horizontal wall 494 const distance = distance2D(wallEnd, { 495 x: ball.nextX, 496 y: ball.nextY 497 }); 498 if (distance < ballSize / 2 + wallW / 2) { 499 if (debugMode && wi > 4) 500 console.warn("too close h tail", distance, ball); 501 502 // Ball hits the right cap of a horizontal wall 503 const closest = closestItCanBe(wallEnd, { 504 x: ball.nextX, 505 y: ball.nextY 506 }); 507 const rolled = rollAroundCap(wallEnd, { 508 x: closest.x, 509 y: closest.y, 510 velocityX: ball.velocityX, 511 velocityY: ball.velocityY 512 }); 513 514 Object.assign(ball, rolled); 515 } 516 } 517 518 if (ball.nextX >= wallStart.x && ball.nextX <= wallEnd.x) { 519 // The ball got inside the main body of the wall 520 if (ball.nextY < wall.y) { 521 // Hit horizontal wall from top 522 ball.nextY = wall.y - wallW / 2 - ballSize / 2; 523 } else { 524 // Hit horizontal wall from bottom 525 ball.nextY = wall.y + wallW / 2 + ballSize / 2; 526 } 527 ball.y = ball.nextY; 528 ball.velocityY = -ball.velocityY / 3; 529 530 if (debugMode && wi > 4) 531 console.error("crossing h line, HIT", ball); 532 } 533 } 534 } else { 535 // Vertical wall 536 537 if ( 538 ball.nextX + ballSize / 2 >= wall.x - wallW / 2 && 539 ball.nextX - ballSize / 2 <= wall.x + wallW / 2 540 ) { 541 // Ball got within the strip of the wall 542 // (not necessarily hit it, could be before or after) 543 544 const wallStart = { 545 x: wall.x, 546 y: wall.y 547 }; 548 const wallEnd = { 549 x: wall.x, 550 y: wall.y + wall.length 551 }; 552 553 if ( 554 ball.nextY + ballSize / 2 >= wallStart.y - wallW / 2 && 555 ball.nextY < wallStart.y 556 ) { 557 // Ball might hit the top cap of a horizontal wall 558 const distance = distance2D(wallStart, { 559 x: ball.nextX, 560 y: ball.nextY 561 }); 562 if (distance < ballSize / 2 + wallW / 2) { 563 if (debugMode && wi > 4) 564 console.warn("too close v head", distance, ball); 565 566 // Ball hits the left cap of a horizontal wall 567 const closest = closestItCanBe(wallStart, { 568 x: ball.nextX, 569 y: ball.nextY 570 }); 571 const rolled = rollAroundCap(wallStart, { 572 x: closest.x, 573 y: closest.y, 574 velocityX: ball.velocityX, 575 velocityY: ball.velocityY 576 }); 577 578 Object.assign(ball, rolled); 579 } 580 } 581 582 if ( 583 ball.nextY - ballSize / 2 <= wallEnd.y + wallW / 2 && 584 ball.nextY > wallEnd.y 585 ) { 586 // Ball might hit the bottom cap of a horizontal wall 587 const distance = distance2D(wallEnd, { 588 x: ball.nextX, 589 y: ball.nextY 590 }); 591 if (distance < ballSize / 2 + wallW / 2) { 592 if (debugMode && wi > 4) 593 console.warn("too close v tail", distance, ball); 594 595 // Ball hits the right cap of a horizontal wall 596 const closest = closestItCanBe(wallEnd, { 597 x: ball.nextX, 598 y: ball.nextY 599 }); 600 const rolled = rollAroundCap(wallEnd, { 601 x: closest.x, 602 y: closest.y, 603 velocityX: ball.velocityX, 604 velocityY: ball.velocityY 605 }); 606 607 Object.assign(ball, rolled); 608 } 609 } 610 611 if (ball.nextY >= wallStart.y && ball.nextY <= wallEnd.y) { 612 // The ball got inside the main body of the wall 613 if (ball.nextX < wall.x) { 614 // Hit vertical wall from left 615 ball.nextX = wall.x - wallW / 2 - ballSize / 2; 616 } else { 617 // Hit vertical wall from right 618 ball.nextX = wall.x + wallW / 2 + ballSize / 2; 619 } 620 ball.x = ball.nextX; 621 ball.velocityX = -ball.velocityX / 3; 622 623 if (debugMode && wi > 4) 624 console.error("crossing v line, HIT", ball); 625 } 626 } 627 } 628 }); 629 630 // Detect is a ball fell into a hole 631 if (hardMode) { 632 holes.forEach((hole, hi) => { 633 const distance = distance2D(hole, { 634 x: ball.nextX, 635 y: ball.nextY 636 }); 637 638 if (distance <= holeSize / 2) { 639 // The ball fell into a hole 640 holeElements[hi].style.backgroundColor = "red"; 641 throw Error("The ball fell into a hole"); 642 } 643 }); 644 } 645 646 // Adjust ball metadata 647 ball.x = ball.x + ball.velocityX; 648 ball.y = ball.y + ball.velocityY; 649 }); 650 651 // Move balls to their new position on the UI 652 balls.forEach(({ x, y }, index) => { 653 ballElements[index].style.cssText = `left: ${x}px; top: ${y}px; `; 654 }); 655 } 656 657 // Win detection 658 if ( 659 balls.every( 660 (ball) => distance2D(ball, { x: 350 / 2, y: 315 / 2 }) < 65 / 2 661 ) 662 ) { 663 noteElement.innerHTML = `Congrats, you did it! 664 ${!hardMode && "<p>Press H for hard mode</p>"} 665 <p> 666 Follow me 667 <a href="https://www.17sucai.com" , target="_blank" 668 >@HunorBorbely</a 669 > 670 </p>`; 671 noteElement.style.opacity = 1; 672 gameInProgress = false; 673 } else { 674 previousTimestamp = timestamp; 675 window.requestAnimationFrame(main); 676 } 677 } catch (error) { 678 if (error.message == "The ball fell into a hole") { 679 noteElement.innerHTML = `A ball fell into a black hole! Press space to reset the game. 680 <p> 681 Back to easy? Press E 682 </p>`; 683 noteElement.style.opacity = 1; 684 gameInProgress = false; 685 } else throw error; 686 } 687 }