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 }