├── .github
└── workflows
│ └── static.yml
├── README.md
├── index.html
├── script.js
└── style.css
/.github/workflows/static.yml:
--------------------------------------------------------------------------------
1 | # Simple workflow for deploying static content to GitHub Pages
2 | name: Deploy static content to Pages
3 |
4 | on:
5 | # Runs on pushes targeting the default branch
6 | push:
7 | branches: ["main"]
8 |
9 | # Allows you to run this workflow manually from the Actions tab
10 | workflow_dispatch:
11 |
12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
13 | permissions:
14 | contents: read
15 | pages: write
16 | id-token: write
17 |
18 | # Allow one concurrent deployment
19 | concurrency:
20 | group: "pages"
21 | cancel-in-progress: true
22 |
23 | jobs:
24 | # Single deploy job since we're just deploying
25 | deploy:
26 | environment:
27 | name: github-pages
28 | url: ${{ steps.deployment.outputs.page_url }}
29 | runs-on: ubuntu-latest
30 | steps:
31 | - name: Checkout
32 | uses: actions/checkout@v3
33 | - name: Setup Pages
34 | uses: actions/configure-pages@v3
35 | - name: Upload artifact
36 | uses: actions/upload-pages-artifact@v1
37 | with:
38 | # Upload entire repository
39 | path: '.'
40 | - name: Deploy to GitHub Pages
41 | id: deployment
42 | uses: actions/deploy-pages@v1
43 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
گیگیلی | Gigily 🤩😉
2 |
3 | ###
4 |
5 |
6 |
7 | ###
8 |
9 | دمو | Demo 😁
http://gigily2.0hi.me
10 |
11 | ###
12 |
13 |
14 |
15 | ###
16 |
17 |
18 |

19 |
20 |
21 | ###
22 |
23 |
24 |
25 | ###
26 |
27 |
28 |

29 |
30 |
31 | ###
32 |
33 |
34 |
35 | ###
36 |
37 |
38 |
39 | ###
40 |
41 |
42 |
43 | ###
44 |
45 |
59 |
60 | ###
61 |
62 |
63 |
64 | ###
65 |
66 | توسعه داده شده توسط برنامه نویسی با لذت
67 |
68 | ###
69 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Gigily | گیگیلی
8 |
9 |
10 |
11 |
12 |
13 |
14 |
24 |
25 |
46 |
47 |
48 |
49 |
50 |
53 |
54 |
--------------------------------------------------------------------------------
/script.js:
--------------------------------------------------------------------------------
1 | let gameSpeed = 1;
2 |
3 | const BLUE = { r: 0x67, g: 0xd7, b: 0xf0 };
4 | const GREEN = { r: 0xa6, g: 0xe0, b: 0x2c };
5 | const PINK = { r: 0xfa, g: 0x24, b: 0x73 };
6 | const ORANGE = { r: 0xfe, g: 0x95, b: 0x22 };
7 | const allColors = [BLUE, GREEN, PINK, ORANGE];
8 |
9 | const getSpawnDelay = () => {
10 | const spawnDelayMax = 1400;
11 | const spawnDelayMin = 550;
12 | const spawnDelay = spawnDelayMax - state.game.cubeCount * 3.1;
13 | return Math.max(spawnDelay, spawnDelayMin);
14 | };
15 | const doubleStrongEnableScore = 2000;
16 | const slowmoThreshold = 10;
17 | const strongThreshold = 25;
18 | const spinnerThreshold = 25;
19 |
20 | let pointerIsDown = false;
21 | let pointerScreen = { x: 0, y: 0 };
22 | let pointerScene = { x: 0, y: 0 };
23 | const minPointerSpeed = 60;
24 | const hitDampening = 0.1;
25 | const backboardZ = -400;
26 | const shadowColor = "#090d14";
27 | const airDrag = 0.022;
28 | const gravity = 0.3;
29 | const sparkColor = "rgba(170,221,255,.9)";
30 | const sparkThickness = 2.2;
31 | const airDragSpark = 0.1;
32 | const touchTrailColor = "rgba(170,221,255,.62)";
33 | const touchTrailThickness = 7;
34 | const touchPointLife = 120;
35 | const touchPoints = [];
36 | const targetRadius = 40;
37 | const targetHitRadius = 50;
38 | const makeTargetGlueColor = (target) => {
39 | return "rgb(170,221,255)";
40 | };
41 | const fragRadius = targetRadius / 3;
42 |
43 | const canvas = document.querySelector("#c");
44 |
45 | const cameraDistance = 900;
46 | const sceneScale = 1;
47 | const cameraFadeStartZ = 0.45 * cameraDistance;
48 | const cameraFadeEndZ = 0.65 * cameraDistance;
49 | const cameraFadeRange = cameraFadeEndZ - cameraFadeStartZ;
50 |
51 | const allVertices = [];
52 | const allPolys = [];
53 | const allShadowVertices = [];
54 | const allShadowPolys = [];
55 |
56 | const GAME_MODE_RANKED = Symbol("GAME_MODE_RANKED");
57 | const GAME_MODE_CASUAL = Symbol("GAME_MODE_CASUAL");
58 |
59 | const MENU_MAIN = Symbol("MENU_MAIN");
60 | const MENU_PAUSE = Symbol("MENU_PAUSE");
61 | const MENU_SCORE = Symbol("MENU_SCORE");
62 |
63 | const state = {
64 | game: {
65 | mode: GAME_MODE_RANKED,
66 | time: 0,
67 | score: 0,
68 | cubeCount: 0,
69 | },
70 | menus: {
71 | active: MENU_MAIN,
72 | },
73 | };
74 |
75 | const isInGame = () => !state.menus.active;
76 | const isMenuVisible = () => !!state.menus.active;
77 | const isCasualGame = () => state.game.mode === GAME_MODE_CASUAL;
78 | const isPaused = () => state.menus.active === MENU_PAUSE;
79 |
80 | const highScoreKey = "__menja__highScore";
81 | const getHighScore = () => {
82 | const raw = localStorage.getItem(highScoreKey);
83 | return raw ? parseInt(raw, 10) : 0;
84 | };
85 |
86 | let _lastHighscore = getHighScore();
87 | const setHighScore = (score) => {
88 | _lastHighscore = getHighScore();
89 | localStorage.setItem(highScoreKey, String(score));
90 | };
91 |
92 | const isNewHighScore = () => state.game.score > _lastHighscore;
93 |
94 | const invariant = (condition, message) => {
95 | if (!condition) throw new Error(message);
96 | };
97 |
98 | const $ = (selector) => document.querySelector(selector);
99 | const handleClick = (element, handler) =>
100 | element.addEventListener("click", handler);
101 | const handlePointerDown = (element, handler) => {
102 | element.addEventListener("touchstart", handler);
103 | element.addEventListener("mousedown", handler);
104 | };
105 |
106 | const formatNumber = (num) => num.toLocaleString();
107 |
108 | const PI = Math.PI;
109 | const TAU = Math.PI * 2;
110 | const ETA = Math.PI * 0.5;
111 |
112 | const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
113 |
114 | const lerp = (a, b, mix) => (b - a) * mix + a;
115 |
116 | const random = (min, max) => Math.random() * (max - min) + min;
117 |
118 | const randomInt = (min, max) => ((Math.random() * (max - min + 1)) | 0) + min;
119 |
120 | const pickOne = (arr) => arr[(Math.random() * arr.length) | 0];
121 |
122 | const colorToHex = (color) => {
123 | return (
124 | "#" +
125 | (color.r | 0).toString(16).padStart(2, "0") +
126 | (color.g | 0).toString(16).padStart(2, "0") +
127 | (color.b | 0).toString(16).padStart(2, "0")
128 | );
129 | };
130 |
131 | const shadeColor = (color, lightness) => {
132 | let other, mix;
133 | if (lightness < 0.5) {
134 | other = 0;
135 | mix = 1 - lightness * 2;
136 | } else {
137 | other = 255;
138 | mix = lightness * 2 - 1;
139 | }
140 | return (
141 | "#" +
142 | (lerp(color.r, other, mix) | 0).toString(16).padStart(2, "0") +
143 | (lerp(color.g, other, mix) | 0).toString(16).padStart(2, "0") +
144 | (lerp(color.b, other, mix) | 0).toString(16).padStart(2, "0")
145 | );
146 | };
147 |
148 | const _allCooldowns = [];
149 |
150 | const makeCooldown = (rechargeTime, units = 1) => {
151 | let timeRemaining = 0;
152 | let lastTime = 0;
153 |
154 | const initialOptions = { rechargeTime, units };
155 |
156 | const updateTime = () => {
157 | const now = state.game.time;
158 | if (now < lastTime) {
159 | timeRemaining = 0;
160 | } else {
161 | timeRemaining -= now - lastTime;
162 | if (timeRemaining < 0) timeRemaining = 0;
163 | }
164 | lastTime = now;
165 | };
166 |
167 | const canUse = () => {
168 | updateTime();
169 | return timeRemaining <= rechargeTime * (units - 1);
170 | };
171 |
172 | const cooldown = {
173 | canUse,
174 | useIfAble() {
175 | const usable = canUse();
176 | if (usable) timeRemaining += rechargeTime;
177 | return usable;
178 | },
179 | mutate(options) {
180 | if (options.rechargeTime) {
181 | timeRemaining -= rechargeTime - options.rechargeTime;
182 | if (timeRemaining < 0) timeRemaining = 0;
183 | rechargeTime = options.rechargeTime;
184 | }
185 | if (options.units) units = options.units;
186 | },
187 | reset() {
188 | timeRemaining = 0;
189 | lastTime = 0;
190 | this.mutate(initialOptions);
191 | },
192 | };
193 |
194 | _allCooldowns.push(cooldown);
195 |
196 | return cooldown;
197 | };
198 |
199 | const resetAllCooldowns = () =>
200 | _allCooldowns.forEach((cooldown) => cooldown.reset());
201 |
202 | const makeSpawner = ({ chance, cooldownPerSpawn, maxSpawns }) => {
203 | const cooldown = makeCooldown(cooldownPerSpawn, maxSpawns);
204 | return {
205 | shouldSpawn() {
206 | return Math.random() <= chance && cooldown.useIfAble();
207 | },
208 | mutate(options) {
209 | if (options.chance) chance = options.chance;
210 | cooldown.mutate({
211 | rechargeTime: options.cooldownPerSpawn,
212 | units: options.maxSpawns,
213 | });
214 | },
215 | };
216 | };
217 |
218 | const normalize = (v) => {
219 | const mag = Math.hypot(v.x, v.y, v.z);
220 | return {
221 | x: v.x / mag,
222 | y: v.y / mag,
223 | z: v.z / mag,
224 | };
225 | };
226 |
227 | const add = (a) => (b) => a + b;
228 | const scaleVector = (scale) => (vector) => {
229 | vector.x *= scale;
230 | vector.y *= scale;
231 | vector.z *= scale;
232 | };
233 |
234 | function cloneVertices(vertices) {
235 | return vertices.map((v) => ({ x: v.x, y: v.y, z: v.z }));
236 | }
237 |
238 | function copyVerticesTo(arr1, arr2) {
239 | const len = arr1.length;
240 | for (let i = 0; i < len; i++) {
241 | const v1 = arr1[i];
242 | const v2 = arr2[i];
243 | v2.x = v1.x;
244 | v2.y = v1.y;
245 | v2.z = v1.z;
246 | }
247 | }
248 |
249 | function computeTriMiddle(poly) {
250 | const v = poly.vertices;
251 | poly.middle.x = (v[0].x + v[1].x + v[2].x) / 3;
252 | poly.middle.y = (v[0].y + v[1].y + v[2].y) / 3;
253 | poly.middle.z = (v[0].z + v[1].z + v[2].z) / 3;
254 | }
255 |
256 | function computeQuadMiddle(poly) {
257 | const v = poly.vertices;
258 | poly.middle.x = (v[0].x + v[1].x + v[2].x + v[3].x) / 4;
259 | poly.middle.y = (v[0].y + v[1].y + v[2].y + v[3].y) / 4;
260 | poly.middle.z = (v[0].z + v[1].z + v[2].z + v[3].z) / 4;
261 | }
262 |
263 | function computePolyMiddle(poly) {
264 | if (poly.vertices.length === 3) {
265 | computeTriMiddle(poly);
266 | } else {
267 | computeQuadMiddle(poly);
268 | }
269 | }
270 |
271 | function computePolyDepth(poly) {
272 | computePolyMiddle(poly);
273 | const dX = poly.middle.x;
274 | const dY = poly.middle.y;
275 | const dZ = poly.middle.z - cameraDistance;
276 | poly.depth = Math.hypot(dX, dY, dZ);
277 | }
278 |
279 | function computePolyNormal(poly, normalName) {
280 | const v1 = poly.vertices[0];
281 | const v2 = poly.vertices[1];
282 | const v3 = poly.vertices[2];
283 | const ax = v1.x - v2.x;
284 | const ay = v1.y - v2.y;
285 | const az = v1.z - v2.z;
286 | const bx = v1.x - v3.x;
287 | const by = v1.y - v3.y;
288 | const bz = v1.z - v3.z;
289 | const nx = ay * bz - az * by;
290 | const ny = az * bx - ax * bz;
291 | const nz = ax * by - ay * bx;
292 | const mag = Math.hypot(nx, ny, nz);
293 | const polyNormal = poly[normalName];
294 | polyNormal.x = nx / mag;
295 | polyNormal.y = ny / mag;
296 | polyNormal.z = nz / mag;
297 | }
298 |
299 | function transformVertices(
300 | vertices,
301 | target,
302 | tX,
303 | tY,
304 | tZ,
305 | rX,
306 | rY,
307 | rZ,
308 | sX,
309 | sY,
310 | sZ
311 | ) {
312 | const sinX = Math.sin(rX);
313 | const cosX = Math.cos(rX);
314 | const sinY = Math.sin(rY);
315 | const cosY = Math.cos(rY);
316 | const sinZ = Math.sin(rZ);
317 | const cosZ = Math.cos(rZ);
318 |
319 | vertices.forEach((v, i) => {
320 | const targetVertex = target[i];
321 | const x1 = v.x;
322 | const y1 = v.z * sinX + v.y * cosX;
323 | const z1 = v.z * cosX - v.y * sinX;
324 | const x2 = x1 * cosY - z1 * sinY;
325 | const y2 = y1;
326 | const z2 = x1 * sinY + z1 * cosY;
327 | const x3 = x2 * cosZ - y2 * sinZ;
328 | const y3 = x2 * sinZ + y2 * cosZ;
329 | const z3 = z2;
330 |
331 | targetVertex.x = x3 * sX + tX;
332 | targetVertex.y = y3 * sY + tY;
333 | targetVertex.z = z3 * sZ + tZ;
334 | });
335 | }
336 |
337 | const projectVertex = (v) => {
338 | const focalLength = cameraDistance * sceneScale;
339 | const depth = focalLength / (cameraDistance - v.z);
340 | v.x = v.x * depth;
341 | v.y = v.y * depth;
342 | };
343 |
344 | const projectVertexTo = (v, target) => {
345 | const focalLength = cameraDistance * sceneScale;
346 | const depth = focalLength / (cameraDistance - v.z);
347 | target.x = v.x * depth;
348 | target.y = v.y * depth;
349 | };
350 |
351 | const PERF_START = () => {};
352 | const PERF_END = () => {};
353 | const PERF_UPDATE = () => {};
354 |
355 | function makeCubeModel({ scale = 1 }) {
356 | return {
357 | vertices: [
358 | { x: -scale, y: -scale, z: scale },
359 | { x: scale, y: -scale, z: scale },
360 | { x: scale, y: scale, z: scale },
361 | { x: -scale, y: scale, z: scale },
362 | { x: -scale, y: -scale, z: -scale },
363 | { x: scale, y: -scale, z: -scale },
364 | { x: scale, y: scale, z: -scale },
365 | { x: -scale, y: scale, z: -scale },
366 | ],
367 | polys: [
368 | { vIndexes: [0, 1, 2, 3] },
369 | { vIndexes: [7, 6, 5, 4] },
370 | { vIndexes: [3, 2, 6, 7] },
371 | { vIndexes: [4, 5, 1, 0] },
372 | { vIndexes: [5, 6, 2, 1] },
373 | { vIndexes: [0, 3, 7, 4] },
374 | ],
375 | };
376 | }
377 |
378 | function makeRecursiveCubeModel({ recursionLevel, splitFn, color, scale = 1 }) {
379 | const getScaleAtLevel = (level) => 1 / 3 ** level;
380 |
381 | let cubeOrigins = [{ x: 0, y: 0, z: 0 }];
382 |
383 | for (let i = 1; i <= recursionLevel; i++) {
384 | const scale = getScaleAtLevel(i) * 2;
385 | const cubeOrigins2 = [];
386 | cubeOrigins.forEach((origin) => {
387 | cubeOrigins2.push(...splitFn(origin, scale));
388 | });
389 | cubeOrigins = cubeOrigins2;
390 | }
391 |
392 | const finalModel = { vertices: [], polys: [] };
393 |
394 | const cubeModel = makeCubeModel({ scale: 1 });
395 | cubeModel.vertices.forEach(scaleVector(getScaleAtLevel(recursionLevel)));
396 |
397 | const maxComponent =
398 | getScaleAtLevel(recursionLevel) * (3 ** recursionLevel - 1);
399 |
400 | cubeOrigins.forEach((origin, cubeIndex) => {
401 | const occlusion =
402 | Math.max(Math.abs(origin.x), Math.abs(origin.y), Math.abs(origin.z)) /
403 | maxComponent;
404 | const occlusionLighter =
405 | recursionLevel > 2 ? occlusion : (occlusion + 0.8) / 1.8;
406 | finalModel.vertices.push(
407 | ...cubeModel.vertices.map((v) => ({
408 | x: (v.x + origin.x) * scale,
409 | y: (v.y + origin.y) * scale,
410 | z: (v.z + origin.z) * scale,
411 | }))
412 | );
413 | finalModel.polys.push(
414 | ...cubeModel.polys.map((poly) => ({
415 | vIndexes: poly.vIndexes.map(add(cubeIndex * 8)),
416 | }))
417 | );
418 | });
419 |
420 | return finalModel;
421 | }
422 |
423 | function mengerSpongeSplit(o, s) {
424 | return [
425 | { x: o.x + s, y: o.y - s, z: o.z + s },
426 | { x: o.x + s, y: o.y - s, z: o.z + 0 },
427 | { x: o.x + s, y: o.y - s, z: o.z - s },
428 | { x: o.x + 0, y: o.y - s, z: o.z + s },
429 | { x: o.x + 0, y: o.y - s, z: o.z - s },
430 | { x: o.x - s, y: o.y - s, z: o.z + s },
431 | { x: o.x - s, y: o.y - s, z: o.z + 0 },
432 | { x: o.x - s, y: o.y - s, z: o.z - s },
433 | { x: o.x + s, y: o.y + s, z: o.z + s },
434 | { x: o.x + s, y: o.y + s, z: o.z + 0 },
435 | { x: o.x + s, y: o.y + s, z: o.z - s },
436 | { x: o.x + 0, y: o.y + s, z: o.z + s },
437 | { x: o.x + 0, y: o.y + s, z: o.z - s },
438 | { x: o.x - s, y: o.y + s, z: o.z + s },
439 | { x: o.x - s, y: o.y + s, z: o.z + 0 },
440 | { x: o.x - s, y: o.y + s, z: o.z - s },
441 | { x: o.x + s, y: o.y + 0, z: o.z + s },
442 | { x: o.x + s, y: o.y + 0, z: o.z - s },
443 | { x: o.x - s, y: o.y + 0, z: o.z + s },
444 | { x: o.x - s, y: o.y + 0, z: o.z - s },
445 | ];
446 | }
447 |
448 | function optimizeModel(model, threshold = 0.0001) {
449 | const { vertices, polys } = model;
450 |
451 | const compareVertices = (v1, v2) =>
452 | Math.abs(v1.x - v2.x) < threshold &&
453 | Math.abs(v1.y - v2.y) < threshold &&
454 | Math.abs(v1.z - v2.z) < threshold;
455 |
456 | const comparePolys = (p1, p2) => {
457 | const v1 = p1.vIndexes;
458 | const v2 = p2.vIndexes;
459 | return (
460 | (v1[0] === v2[0] ||
461 | v1[0] === v2[1] ||
462 | v1[0] === v2[2] ||
463 | v1[0] === v2[3]) &&
464 | (v1[1] === v2[0] ||
465 | v1[1] === v2[1] ||
466 | v1[1] === v2[2] ||
467 | v1[1] === v2[3]) &&
468 | (v1[2] === v2[0] ||
469 | v1[2] === v2[1] ||
470 | v1[2] === v2[2] ||
471 | v1[2] === v2[3]) &&
472 | (v1[3] === v2[0] || v1[3] === v2[1] || v1[3] === v2[2] || v1[3] === v2[3])
473 | );
474 | };
475 |
476 | vertices.forEach((v, i) => {
477 | v.originalIndexes = [i];
478 | });
479 |
480 | for (let i = vertices.length - 1; i >= 0; i--) {
481 | for (let ii = i - 1; ii >= 0; ii--) {
482 | const v1 = vertices[i];
483 | const v2 = vertices[ii];
484 | if (compareVertices(v1, v2)) {
485 | vertices.splice(i, 1);
486 | v2.originalIndexes.push(...v1.originalIndexes);
487 | break;
488 | }
489 | }
490 | }
491 |
492 | vertices.forEach((v, i) => {
493 | polys.forEach((p) => {
494 | p.vIndexes.forEach((vi, ii, arr) => {
495 | const vo = v.originalIndexes;
496 | if (vo.includes(vi)) {
497 | arr[ii] = i;
498 | }
499 | });
500 | });
501 | });
502 |
503 | polys.forEach((p) => {
504 | const vi = p.vIndexes;
505 | p.sum = vi[0] + vi[1] + vi[2] + vi[3];
506 | });
507 | polys.sort((a, b) => b.sum - a.sum);
508 |
509 | for (let i = polys.length - 1; i >= 0; i--) {
510 | for (let ii = i - 1; ii >= 0; ii--) {
511 | const p1 = polys[i];
512 | const p2 = polys[ii];
513 | if (p1.sum !== p2.sum) break;
514 | if (comparePolys(p1, p2)) {
515 | polys.splice(i, 1);
516 | polys.splice(ii, 1);
517 | i--;
518 | break;
519 | }
520 | }
521 | }
522 |
523 | return model;
524 | }
525 |
526 | class Entity {
527 | constructor({ model, color, wireframe = false }) {
528 | const vertices = cloneVertices(model.vertices);
529 | const shadowVertices = cloneVertices(model.vertices);
530 | const colorHex = colorToHex(color);
531 | const darkColorHex = shadeColor(color, 0.4);
532 |
533 | const polys = model.polys.map((p) => ({
534 | vertices: p.vIndexes.map((vIndex) => vertices[vIndex]),
535 | color: color,
536 | wireframe: wireframe,
537 | strokeWidth: wireframe ? 2 : 0,
538 | strokeColor: colorHex,
539 | strokeColorDark: darkColorHex,
540 | depth: 0,
541 | middle: { x: 0, y: 0, z: 0 },
542 | normalWorld: { x: 0, y: 0, z: 0 },
543 | normalCamera: { x: 0, y: 0, z: 0 },
544 | }));
545 |
546 | const shadowPolys = model.polys.map((p) => ({
547 | vertices: p.vIndexes.map((vIndex) => shadowVertices[vIndex]),
548 | wireframe: wireframe,
549 | normalWorld: { x: 0, y: 0, z: 0 },
550 | }));
551 |
552 | this.projected = {};
553 | this.model = model;
554 | this.vertices = vertices;
555 | this.polys = polys;
556 | this.shadowVertices = shadowVertices;
557 | this.shadowPolys = shadowPolys;
558 | this.reset();
559 | }
560 |
561 | reset() {
562 | this.x = 0;
563 | this.y = 0;
564 | this.z = 0;
565 | this.xD = 0;
566 | this.yD = 0;
567 | this.zD = 0;
568 |
569 | this.rotateX = 0;
570 | this.rotateY = 0;
571 | this.rotateZ = 0;
572 | this.rotateXD = 0;
573 | this.rotateYD = 0;
574 | this.rotateZD = 0;
575 |
576 | this.scaleX = 1;
577 | this.scaleY = 1;
578 | this.scaleZ = 1;
579 |
580 | this.projected.x = 0;
581 | this.projected.y = 0;
582 | }
583 |
584 | transform() {
585 | transformVertices(
586 | this.model.vertices,
587 | this.vertices,
588 | this.x,
589 | this.y,
590 | this.z,
591 | this.rotateX,
592 | this.rotateY,
593 | this.rotateZ,
594 | this.scaleX,
595 | this.scaleY,
596 | this.scaleZ
597 | );
598 |
599 | copyVerticesTo(this.vertices, this.shadowVertices);
600 | }
601 |
602 | project() {
603 | projectVertexTo(this, this.projected);
604 | }
605 | }
606 |
607 | const targets = [];
608 |
609 | const targetPool = new Map(allColors.map((c) => [c, []]));
610 | const targetWireframePool = new Map(allColors.map((c) => [c, []]));
611 |
612 | const getTarget = (() => {
613 | const slowmoSpawner = makeSpawner({
614 | chance: 0.5,
615 | cooldownPerSpawn: 10000,
616 | maxSpawns: 1,
617 | });
618 |
619 | let doubleStrong = false;
620 | const strongSpawner = makeSpawner({
621 | chance: 0.3,
622 | cooldownPerSpawn: 12000,
623 | maxSpawns: 1,
624 | });
625 |
626 | const spinnerSpawner = makeSpawner({
627 | chance: 0.1,
628 | cooldownPerSpawn: 10000,
629 | maxSpawns: 1,
630 | });
631 |
632 | const axisOptions = [
633 | ["x", "y"],
634 | ["y", "z"],
635 | ["z", "x"],
636 | ];
637 |
638 | function getTargetOfStyle(color, wireframe) {
639 | const pool = wireframe ? targetWireframePool : targetPool;
640 | let target = pool.get(color).pop();
641 | if (!target) {
642 | target = new Entity({
643 | model: optimizeModel(
644 | makeRecursiveCubeModel({
645 | recursionLevel: 1,
646 | splitFn: mengerSpongeSplit,
647 | scale: targetRadius,
648 | })
649 | ),
650 | color: color,
651 | wireframe: wireframe,
652 | });
653 |
654 | target.color = color;
655 | target.wireframe = wireframe;
656 | target.hit = false;
657 | target.maxHealth = 0;
658 | target.health = 0;
659 | }
660 | return target;
661 | }
662 |
663 | return function getTarget() {
664 | if (doubleStrong && state.game.score <= doubleStrongEnableScore) {
665 | doubleStrong = false;
666 | } else if (!doubleStrong && state.game.score > doubleStrongEnableScore) {
667 | doubleStrong = true;
668 | strongSpawner.mutate({ maxSpawns: 2 });
669 | }
670 |
671 | let color = pickOne([BLUE, GREEN, ORANGE]);
672 | let wireframe = false;
673 | let health = 1;
674 | let maxHealth = 3;
675 | const spinner =
676 | state.game.cubeCount >= spinnerThreshold &&
677 | isInGame() &&
678 | spinnerSpawner.shouldSpawn();
679 |
680 | if (
681 | state.game.cubeCount >= slowmoThreshold &&
682 | slowmoSpawner.shouldSpawn()
683 | ) {
684 | color = BLUE;
685 | wireframe = true;
686 | } else if (
687 | state.game.cubeCount >= strongThreshold &&
688 | strongSpawner.shouldSpawn()
689 | ) {
690 | color = PINK;
691 | health = 3;
692 | }
693 |
694 | const target = getTargetOfStyle(color, wireframe);
695 | target.hit = false;
696 | target.maxHealth = maxHealth;
697 | target.health = health;
698 | updateTargetHealth(target, 0);
699 |
700 | const spinSpeeds = [Math.random() * 0.1 - 0.05, Math.random() * 0.1 - 0.05];
701 |
702 | if (spinner) {
703 | spinSpeeds[0] = -0.25;
704 | spinSpeeds[1] = 0;
705 | target.rotateZ = random(0, TAU);
706 | }
707 |
708 | const axes = pickOne(axisOptions);
709 |
710 | spinSpeeds.forEach((spinSpeed, i) => {
711 | switch (axes[i]) {
712 | case "x":
713 | target.rotateXD = spinSpeed;
714 | break;
715 | case "y":
716 | target.rotateYD = spinSpeed;
717 | break;
718 | case "z":
719 | target.rotateZD = spinSpeed;
720 | break;
721 | }
722 | });
723 |
724 | return target;
725 | };
726 | })();
727 |
728 | const updateTargetHealth = (target, healthDelta) => {
729 | target.health += healthDelta;
730 | if (!target.wireframe) {
731 | const strokeWidth = target.health - 1;
732 | const strokeColor = makeTargetGlueColor(target);
733 | for (let p of target.polys) {
734 | p.strokeWidth = strokeWidth;
735 | p.strokeColor = strokeColor;
736 | }
737 | }
738 | };
739 |
740 | const returnTarget = (target) => {
741 | target.reset();
742 | const pool = target.wireframe ? targetWireframePool : targetPool;
743 | pool.get(target.color).push(target);
744 | };
745 |
746 | function resetAllTargets() {
747 | while (targets.length) {
748 | returnTarget(targets.pop());
749 | }
750 | }
751 |
752 | const frags = [];
753 | const fragPool = new Map(allColors.map((c) => [c, []]));
754 | const fragWireframePool = new Map(allColors.map((c) => [c, []]));
755 |
756 | const createBurst = (() => {
757 | const basePositions = mengerSpongeSplit({ x: 0, y: 0, z: 0 }, fragRadius * 2);
758 | const positions = cloneVertices(basePositions);
759 | const prevPositions = cloneVertices(basePositions);
760 | const velocities = cloneVertices(basePositions);
761 |
762 | const basePositionNormals = basePositions.map(normalize);
763 | const positionNormals = cloneVertices(basePositionNormals);
764 |
765 | const fragCount = basePositions.length;
766 |
767 | function getFragForTarget(target) {
768 | const pool = target.wireframe ? fragWireframePool : fragPool;
769 | let frag = pool.get(target.color).pop();
770 | if (!frag) {
771 | frag = new Entity({
772 | model: makeCubeModel({ scale: fragRadius }),
773 | color: target.color,
774 | wireframe: target.wireframe,
775 | });
776 | frag.color = target.color;
777 | frag.wireframe = target.wireframe;
778 | }
779 | return frag;
780 | }
781 |
782 | return (target, force = 1) => {
783 | transformVertices(
784 | basePositions,
785 | positions,
786 | target.x,
787 | target.y,
788 | target.z,
789 | target.rotateX,
790 | target.rotateY,
791 | target.rotateZ,
792 | 1,
793 | 1,
794 | 1
795 | );
796 | transformVertices(
797 | basePositions,
798 | prevPositions,
799 | target.x - target.xD,
800 | target.y - target.yD,
801 | target.z - target.zD,
802 | target.rotateX - target.rotateXD,
803 | target.rotateY - target.rotateYD,
804 | target.rotateZ - target.rotateZD,
805 | 1,
806 | 1,
807 | 1
808 | );
809 |
810 | for (let i = 0; i < fragCount; i++) {
811 | const position = positions[i];
812 | const prevPosition = prevPositions[i];
813 | const velocity = velocities[i];
814 |
815 | velocity.x = position.x - prevPosition.x;
816 | velocity.y = position.y - prevPosition.y;
817 | velocity.z = position.z - prevPosition.z;
818 | }
819 |
820 | transformVertices(
821 | basePositionNormals,
822 | positionNormals,
823 | 0,
824 | 0,
825 | 0,
826 | target.rotateX,
827 | target.rotateY,
828 | target.rotateZ,
829 | 1,
830 | 1,
831 | 1
832 | );
833 |
834 | for (let i = 0; i < fragCount; i++) {
835 | const position = positions[i];
836 | const velocity = velocities[i];
837 | const normal = positionNormals[i];
838 |
839 | const frag = getFragForTarget(target);
840 |
841 | frag.x = position.x;
842 | frag.y = position.y;
843 | frag.z = position.z;
844 | frag.rotateX = target.rotateX;
845 | frag.rotateY = target.rotateY;
846 | frag.rotateZ = target.rotateZ;
847 |
848 | const burstSpeed = 2 * force;
849 | const randSpeed = 2 * force;
850 | const rotateScale = 0.015;
851 | frag.xD = velocity.x + normal.x * burstSpeed + Math.random() * randSpeed;
852 | frag.yD = velocity.y + normal.y * burstSpeed + Math.random() * randSpeed;
853 | frag.zD = velocity.z + normal.z * burstSpeed + Math.random() * randSpeed;
854 | frag.rotateXD = frag.xD * rotateScale;
855 | frag.rotateYD = frag.yD * rotateScale;
856 | frag.rotateZD = frag.zD * rotateScale;
857 |
858 | frags.push(frag);
859 | }
860 | };
861 | })();
862 |
863 | const returnFrag = (frag) => {
864 | frag.reset();
865 | const pool = frag.wireframe ? fragWireframePool : fragPool;
866 | pool.get(frag.color).push(frag);
867 | };
868 |
869 | const sparks = [];
870 | const sparkPool = [];
871 |
872 | function addSpark(x, y, xD, yD) {
873 | const spark = sparkPool.pop() || {};
874 |
875 | spark.x = x + xD * 0.5;
876 | spark.y = y + yD * 0.5;
877 | spark.xD = xD;
878 | spark.yD = yD;
879 | spark.life = random(200, 300);
880 | spark.maxLife = spark.life;
881 |
882 | sparks.push(spark);
883 |
884 | return spark;
885 | }
886 |
887 | function sparkBurst(x, y, count, maxSpeed) {
888 | const angleInc = TAU / count;
889 | for (let i = 0; i < count; i++) {
890 | const angle = i * angleInc + angleInc * Math.random();
891 | const speed = (1 - Math.random() ** 3) * maxSpeed;
892 | addSpark(x, y, Math.sin(angle) * speed, Math.cos(angle) * speed);
893 | }
894 | }
895 |
896 | let glueShedVertices;
897 | function glueShedSparks(target) {
898 | if (!glueShedVertices) {
899 | glueShedVertices = cloneVertices(target.vertices);
900 | } else {
901 | copyVerticesTo(target.vertices, glueShedVertices);
902 | }
903 |
904 | glueShedVertices.forEach((v) => {
905 | if (Math.random() < 0.4) {
906 | projectVertex(v);
907 | addSpark(v.x, v.y, random(-12, 12), random(-12, 12));
908 | }
909 | });
910 | }
911 |
912 | function returnSpark(spark) {
913 | sparkPool.push(spark);
914 | }
915 |
916 | const hudContainerNode = $(".hud");
917 |
918 | function setHudVisibility(visible) {
919 | if (visible) {
920 | hudContainerNode.style.display = "block";
921 | } else {
922 | hudContainerNode.style.display = "none";
923 | }
924 | }
925 |
926 | const scoreNode = $(".score-lbl");
927 | const cubeCountNode = $(".cube-count-lbl");
928 |
929 | function renderScoreHud() {
930 | if (isCasualGame()) {
931 | scoreNode.style.display = "none";
932 | cubeCountNode.style.opacity = 1;
933 | } else {
934 | scoreNode.innerText = `امتیاز: ${state.game.score}`;
935 | scoreNode.style.display = "block";
936 | cubeCountNode.style.opacity = 0.65;
937 | }
938 | cubeCountNode.innerText = `مکعب های خرد شده : ${state.game.cubeCount}`;
939 | }
940 |
941 | renderScoreHud();
942 |
943 | handlePointerDown($(".pause-btn"), () => pauseGame());
944 |
945 | const slowmoNode = $(".slowmo");
946 | const slowmoBarNode = $(".slowmo__bar");
947 |
948 | function renderSlowmoStatus(percentRemaining) {
949 | slowmoNode.style.opacity = percentRemaining === 0 ? 0 : 1;
950 | slowmoBarNode.style.transform = `scaleX(${percentRemaining.toFixed(3)})`;
951 | }
952 |
953 | const menuContainerNode = $(".menus");
954 | const menuMainNode = $(".menu--main");
955 | const menuPauseNode = $(".menu--pause");
956 | const menuScoreNode = $(".menu--score");
957 |
958 | const finalScoreLblNode = $(".final-score-lbl");
959 | const highScoreLblNode = $(".high-score-lbl");
960 |
961 | function showMenu(node) {
962 | node.classList.add("active");
963 | }
964 |
965 | function hideMenu(node) {
966 | node.classList.remove("active");
967 | }
968 |
969 | function renderMenus() {
970 | hideMenu(menuMainNode);
971 | hideMenu(menuPauseNode);
972 | hideMenu(menuScoreNode);
973 |
974 | switch (state.menus.active) {
975 | case MENU_MAIN:
976 | showMenu(menuMainNode);
977 | break;
978 | case MENU_PAUSE:
979 | showMenu(menuPauseNode);
980 | break;
981 | case MENU_SCORE:
982 | finalScoreLblNode.textContent = formatNumber(state.game.score);
983 | if (isNewHighScore()) {
984 | highScoreLblNode.textContent = "بالاترین امتیاز جدید";
985 | } else {
986 | highScoreLblNode.textContent = `بالاترین امتیاز : ${formatNumber(
987 | getHighScore()
988 | )}`;
989 | }
990 | showMenu(menuScoreNode);
991 | break;
992 | }
993 |
994 | setHudVisibility(!isMenuVisible());
995 | menuContainerNode.classList.toggle("has-active", isMenuVisible());
996 | menuContainerNode.classList.toggle(
997 | "interactive-mode",
998 | isMenuVisible() && pointerIsDown
999 | );
1000 | }
1001 |
1002 | renderMenus();
1003 |
1004 | handleClick($(".play-normal-btn"), () => {
1005 | setGameMode(GAME_MODE_RANKED);
1006 | setActiveMenu(null);
1007 | resetGame();
1008 | });
1009 |
1010 | handleClick($(".play-casual-btn"), () => {
1011 | setGameMode(GAME_MODE_CASUAL);
1012 | setActiveMenu(null);
1013 | resetGame();
1014 | });
1015 |
1016 | handleClick($(".resume-btn"), () => resumeGame());
1017 | handleClick($(".menu-btn--pause"), () => setActiveMenu(MENU_MAIN));
1018 |
1019 | handleClick($(".play-again-btn"), () => {
1020 | setActiveMenu(null);
1021 | resetGame();
1022 | });
1023 |
1024 | handleClick($(".menu-btn--score"), () => setActiveMenu(MENU_MAIN));
1025 |
1026 | handleClick($(".play-normal-btn"), () => {
1027 | setGameMode(GAME_MODE_RANKED);
1028 | setActiveMenu(null);
1029 | resetGame();
1030 | });
1031 |
1032 | handleClick($(".play-casual-btn"), () => {
1033 | setGameMode(GAME_MODE_CASUAL);
1034 | setActiveMenu(null);
1035 | resetGame();
1036 | });
1037 |
1038 | handleClick($(".resume-btn"), () => resumeGame());
1039 | handleClick($(".menu-btn--pause"), () => setActiveMenu(MENU_MAIN));
1040 |
1041 | handleClick($(".play-again-btn"), () => {
1042 | setActiveMenu(null);
1043 | resetGame();
1044 | });
1045 |
1046 | handleClick($(".menu-btn--score"), () => setActiveMenu(MENU_MAIN));
1047 |
1048 | function setActiveMenu(menu) {
1049 | state.menus.active = menu;
1050 | renderMenus();
1051 | }
1052 |
1053 | function setScore(score) {
1054 | state.game.score = score;
1055 | renderScoreHud();
1056 | }
1057 |
1058 | function incrementScore(inc) {
1059 | if (isInGame()) {
1060 | state.game.score += inc;
1061 | if (state.game.score < 0) {
1062 | state.game.score = 0;
1063 | }
1064 | renderScoreHud();
1065 | }
1066 | }
1067 |
1068 | function setCubeCount(count) {
1069 | state.game.cubeCount = count;
1070 | renderScoreHud();
1071 | }
1072 |
1073 | function incrementCubeCount(inc) {
1074 | if (isInGame()) {
1075 | state.game.cubeCount += inc;
1076 | renderScoreHud();
1077 | }
1078 | }
1079 |
1080 | function setGameMode(mode) {
1081 | state.game.mode = mode;
1082 | }
1083 |
1084 | function resetGame() {
1085 | resetAllTargets();
1086 | state.game.time = 0;
1087 | resetAllCooldowns();
1088 | setScore(0);
1089 | setCubeCount(0);
1090 | spawnTime = getSpawnDelay();
1091 | }
1092 |
1093 | function pauseGame() {
1094 | isInGame() && setActiveMenu(MENU_PAUSE);
1095 | }
1096 |
1097 | function resumeGame() {
1098 | isPaused() && setActiveMenu(null);
1099 | }
1100 |
1101 | function endGame() {
1102 | handleCanvasPointerUp();
1103 | if (isNewHighScore()) {
1104 | setHighScore(state.game.score);
1105 | }
1106 | setActiveMenu(MENU_SCORE);
1107 | }
1108 |
1109 | window.addEventListener("keydown", (event) => {
1110 | if (event.key === "p") {
1111 | isPaused() ? resumeGame() : pauseGame();
1112 | }
1113 | });
1114 |
1115 | let spawnTime = 0;
1116 | const maxSpawnX = 450;
1117 | const pointerDelta = { x: 0, y: 0 };
1118 | const pointerDeltaScaled = { x: 0, y: 0 };
1119 |
1120 | const slowmoDuration = 1500;
1121 | let slowmoRemaining = 0;
1122 | let spawnExtra = 0;
1123 | const spawnExtraDelay = 300;
1124 | let targetSpeed = 1;
1125 |
1126 | function tick(width, height, simTime, simSpeed, lag) {
1127 | PERF_START("frame");
1128 | PERF_START("tick");
1129 |
1130 | state.game.time += simTime;
1131 |
1132 | if (slowmoRemaining > 0) {
1133 | slowmoRemaining -= simTime;
1134 | if (slowmoRemaining < 0) {
1135 | slowmoRemaining = 0;
1136 | }
1137 | targetSpeed = pointerIsDown ? 0.075 : 0.3;
1138 | } else {
1139 | const menuPointerDown = isMenuVisible() && pointerIsDown;
1140 | targetSpeed = menuPointerDown ? 0.025 : 1;
1141 | }
1142 |
1143 | renderSlowmoStatus(slowmoRemaining / slowmoDuration);
1144 |
1145 | gameSpeed += ((targetSpeed - gameSpeed) / 22) * lag;
1146 | gameSpeed = clamp(gameSpeed, 0, 1);
1147 |
1148 | const centerX = width / 2;
1149 | const centerY = height / 2;
1150 |
1151 | const simAirDrag = 1 - airDrag * simSpeed;
1152 | const simAirDragSpark = 1 - airDragSpark * simSpeed;
1153 |
1154 | const forceMultiplier = 1 / (simSpeed * 0.75 + 0.25);
1155 | pointerDelta.x = 0;
1156 | pointerDelta.y = 0;
1157 | pointerDeltaScaled.x = 0;
1158 | pointerDeltaScaled.y = 0;
1159 | const lastPointer = touchPoints[touchPoints.length - 1];
1160 |
1161 | if (pointerIsDown && lastPointer && !lastPointer.touchBreak) {
1162 | pointerDelta.x = pointerScene.x - lastPointer.x;
1163 | pointerDelta.y = pointerScene.y - lastPointer.y;
1164 | pointerDeltaScaled.x = pointerDelta.x * forceMultiplier;
1165 | pointerDeltaScaled.y = pointerDelta.y * forceMultiplier;
1166 | }
1167 | const pointerSpeed = Math.hypot(pointerDelta.x, pointerDelta.y);
1168 | const pointerSpeedScaled = pointerSpeed * forceMultiplier;
1169 |
1170 | touchPoints.forEach((p) => (p.life -= simTime));
1171 |
1172 | if (pointerIsDown) {
1173 | touchPoints.push({
1174 | x: pointerScene.x,
1175 | y: pointerScene.y,
1176 | life: touchPointLife,
1177 | });
1178 | }
1179 |
1180 | while (touchPoints[0] && touchPoints[0].life <= 0) {
1181 | touchPoints.shift();
1182 | }
1183 |
1184 | PERF_START("entities");
1185 |
1186 | spawnTime -= simTime;
1187 | if (spawnTime <= 0) {
1188 | if (spawnExtra > 0) {
1189 | spawnExtra--;
1190 | spawnTime = spawnExtraDelay;
1191 | } else {
1192 | spawnTime = getSpawnDelay();
1193 | }
1194 | const target = getTarget();
1195 | const spawnRadius = Math.min(centerX * 0.8, maxSpawnX);
1196 | target.x = Math.random() * spawnRadius * 2 - spawnRadius;
1197 | target.y = centerY + targetHitRadius * 2;
1198 | target.z = Math.random() * targetRadius * 2 - targetRadius;
1199 | target.xD = Math.random() * ((target.x * -2) / 120);
1200 | target.yD = -20;
1201 | targets.push(target);
1202 | }
1203 |
1204 | const leftBound = -centerX + targetRadius;
1205 | const rightBound = centerX - targetRadius;
1206 | const ceiling = -centerY - 120;
1207 | const boundDamping = 0.4;
1208 |
1209 | targetLoop: for (let i = targets.length - 1; i >= 0; i--) {
1210 | const target = targets[i];
1211 | target.x += target.xD * simSpeed;
1212 | target.y += target.yD * simSpeed;
1213 |
1214 | if (target.y < ceiling) {
1215 | target.y = ceiling;
1216 | target.yD = 0;
1217 | }
1218 |
1219 | if (target.x < leftBound) {
1220 | target.x = leftBound;
1221 | target.xD *= -boundDamping;
1222 | } else if (target.x > rightBound) {
1223 | target.x = rightBound;
1224 | target.xD *= -boundDamping;
1225 | }
1226 |
1227 | if (target.z < backboardZ) {
1228 | target.z = backboardZ;
1229 | target.zD *= -boundDamping;
1230 | }
1231 |
1232 | target.yD += gravity * simSpeed;
1233 | target.rotateX += target.rotateXD * simSpeed;
1234 | target.rotateY += target.rotateYD * simSpeed;
1235 | target.rotateZ += target.rotateZD * simSpeed;
1236 | target.transform();
1237 | target.project();
1238 |
1239 | if (target.y > centerY + targetHitRadius * 2) {
1240 | targets.splice(i, 1);
1241 | returnTarget(target);
1242 | if (isInGame()) {
1243 | if (isCasualGame()) {
1244 | incrementScore(-25);
1245 | } else {
1246 | endGame();
1247 | }
1248 | }
1249 | continue;
1250 | }
1251 |
1252 | const hitTestCount = Math.ceil((pointerSpeed / targetRadius) * 2);
1253 | for (let ii = 1; ii <= hitTestCount; ii++) {
1254 | const percent = 1 - ii / hitTestCount;
1255 | const hitX = pointerScene.x - pointerDelta.x * percent;
1256 | const hitY = pointerScene.y - pointerDelta.y * percent;
1257 | const distance = Math.hypot(
1258 | hitX - target.projected.x,
1259 | hitY - target.projected.y
1260 | );
1261 |
1262 | if (distance <= targetHitRadius) {
1263 | if (!target.hit) {
1264 | target.hit = true;
1265 |
1266 | target.xD += pointerDeltaScaled.x * hitDampening;
1267 | target.yD += pointerDeltaScaled.y * hitDampening;
1268 | target.rotateXD += pointerDeltaScaled.y * 0.001;
1269 | target.rotateYD += pointerDeltaScaled.x * 0.001;
1270 |
1271 | const sparkSpeed = 7 + pointerSpeedScaled * 0.125;
1272 |
1273 | if (pointerSpeedScaled > minPointerSpeed) {
1274 | target.health--;
1275 | incrementScore(10);
1276 |
1277 | if (target.health <= 0) {
1278 | incrementCubeCount(1);
1279 | createBurst(target, forceMultiplier);
1280 | sparkBurst(hitX, hitY, 8, sparkSpeed);
1281 | if (target.wireframe) {
1282 | slowmoRemaining = slowmoDuration;
1283 | spawnTime = 0;
1284 | spawnExtra = 2;
1285 | }
1286 | targets.splice(i, 1);
1287 | returnTarget(target);
1288 | } else {
1289 | sparkBurst(hitX, hitY, 8, sparkSpeed);
1290 | glueShedSparks(target);
1291 | updateTargetHealth(target, 0);
1292 | }
1293 | } else {
1294 | incrementScore(5);
1295 | sparkBurst(hitX, hitY, 3, sparkSpeed);
1296 | }
1297 | }
1298 | continue targetLoop;
1299 | }
1300 | }
1301 |
1302 | target.hit = false;
1303 | }
1304 |
1305 | const fragBackboardZ = backboardZ + fragRadius;
1306 | const fragLeftBound = -width;
1307 | const fragRightBound = width;
1308 |
1309 | for (let i = frags.length - 1; i >= 0; i--) {
1310 | const frag = frags[i];
1311 | frag.x += frag.xD * simSpeed;
1312 | frag.y += frag.yD * simSpeed;
1313 | frag.z += frag.zD * simSpeed;
1314 |
1315 | frag.xD *= simAirDrag;
1316 | frag.yD *= simAirDrag;
1317 | frag.zD *= simAirDrag;
1318 |
1319 | if (frag.y < ceiling) {
1320 | frag.y = ceiling;
1321 | frag.yD = 0;
1322 | }
1323 |
1324 | if (frag.z < fragBackboardZ) {
1325 | frag.z = fragBackboardZ;
1326 | frag.zD *= -boundDamping;
1327 | }
1328 |
1329 | frag.yD += gravity * simSpeed;
1330 | frag.rotateX += frag.rotateXD * simSpeed;
1331 | frag.rotateY += frag.rotateYD * simSpeed;
1332 | frag.rotateZ += frag.rotateZD * simSpeed;
1333 | frag.transform();
1334 | frag.project();
1335 |
1336 | if (
1337 | frag.projected.y > centerY + targetHitRadius ||
1338 | frag.projected.x < fragLeftBound ||
1339 | frag.projected.x > fragRightBound ||
1340 | frag.z > cameraFadeEndZ
1341 | ) {
1342 | frags.splice(i, 1);
1343 | returnFrag(frag);
1344 | continue;
1345 | }
1346 | }
1347 |
1348 | for (let i = sparks.length - 1; i >= 0; i--) {
1349 | const spark = sparks[i];
1350 | spark.life -= simTime;
1351 | if (spark.life <= 0) {
1352 | sparks.splice(i, 1);
1353 | returnSpark(spark);
1354 | continue;
1355 | }
1356 | spark.x += spark.xD * simSpeed;
1357 | spark.y += spark.yD * simSpeed;
1358 | spark.xD *= simAirDragSpark;
1359 | spark.yD *= simAirDragSpark;
1360 | spark.yD += gravity * simSpeed;
1361 | }
1362 |
1363 | PERF_END("entities");
1364 |
1365 | PERF_START("3D");
1366 |
1367 | allVertices.length = 0;
1368 | allPolys.length = 0;
1369 | allShadowVertices.length = 0;
1370 | allShadowPolys.length = 0;
1371 | targets.forEach((entity) => {
1372 | allVertices.push(...entity.vertices);
1373 | allPolys.push(...entity.polys);
1374 | allShadowVertices.push(...entity.shadowVertices);
1375 | allShadowPolys.push(...entity.shadowPolys);
1376 | });
1377 |
1378 | frags.forEach((entity) => {
1379 | allVertices.push(...entity.vertices);
1380 | allPolys.push(...entity.polys);
1381 | allShadowVertices.push(...entity.shadowVertices);
1382 | allShadowPolys.push(...entity.shadowPolys);
1383 | });
1384 |
1385 | allPolys.forEach((p) => computePolyNormal(p, "normalWorld"));
1386 | allPolys.forEach(computePolyDepth);
1387 | allPolys.sort((a, b) => b.depth - a.depth);
1388 |
1389 | allVertices.forEach(projectVertex);
1390 |
1391 | allPolys.forEach((p) => computePolyNormal(p, "normalCamera"));
1392 |
1393 | PERF_END("3D");
1394 |
1395 | PERF_START("shadows");
1396 |
1397 | transformVertices(
1398 | allShadowVertices,
1399 | allShadowVertices,
1400 | 0,
1401 | 0,
1402 | 0,
1403 | TAU / 8,
1404 | 0,
1405 | 0,
1406 | 1,
1407 | 1,
1408 | 1
1409 | );
1410 |
1411 | allShadowPolys.forEach((p) => computePolyNormal(p, "normalWorld"));
1412 |
1413 | const shadowDistanceMult = Math.hypot(1, 1);
1414 | const shadowVerticesLength = allShadowVertices.length;
1415 | for (let i = 0; i < shadowVerticesLength; i++) {
1416 | const distance = allVertices[i].z - backboardZ;
1417 | allShadowVertices[i].z -= shadowDistanceMult * distance;
1418 | }
1419 | transformVertices(
1420 | allShadowVertices,
1421 | allShadowVertices,
1422 | 0,
1423 | 0,
1424 | 0,
1425 | -TAU / 8,
1426 | 0,
1427 | 0,
1428 | 1,
1429 | 1,
1430 | 1
1431 | );
1432 | allShadowVertices.forEach(projectVertex);
1433 |
1434 | PERF_END("shadows");
1435 |
1436 | PERF_END("tick");
1437 | }
1438 |
1439 | function draw(ctx, width, height, viewScale) {
1440 | PERF_START("draw");
1441 |
1442 | const halfW = width / 2;
1443 | const halfH = height / 2;
1444 |
1445 | ctx.lineJoin = "bevel";
1446 |
1447 | PERF_START("drawShadows");
1448 | ctx.fillStyle = shadowColor;
1449 | ctx.strokeStyle = shadowColor;
1450 | allShadowPolys.forEach((p) => {
1451 | if (p.wireframe) {
1452 | ctx.lineWidth = 2;
1453 | ctx.beginPath();
1454 | const { vertices } = p;
1455 | const vCount = vertices.length;
1456 | const firstV = vertices[0];
1457 | ctx.moveTo(firstV.x, firstV.y);
1458 | for (let i = 1; i < vCount; i++) {
1459 | const v = vertices[i];
1460 | ctx.lineTo(v.x, v.y);
1461 | }
1462 | ctx.closePath();
1463 | ctx.stroke();
1464 | } else {
1465 | ctx.beginPath();
1466 | const { vertices } = p;
1467 | const vCount = vertices.length;
1468 | const firstV = vertices[0];
1469 | ctx.moveTo(firstV.x, firstV.y);
1470 | for (let i = 1; i < vCount; i++) {
1471 | const v = vertices[i];
1472 | ctx.lineTo(v.x, v.y);
1473 | }
1474 | ctx.closePath();
1475 | ctx.fill();
1476 | }
1477 | });
1478 | PERF_END("drawShadows");
1479 |
1480 | PERF_START("drawPolys");
1481 |
1482 | allPolys.forEach((p) => {
1483 | if (!p.wireframe && p.normalCamera.z < 0) return;
1484 |
1485 | if (p.strokeWidth !== 0) {
1486 | ctx.lineWidth =
1487 | p.normalCamera.z < 0 ? p.strokeWidth * 0.5 : p.strokeWidth;
1488 | ctx.strokeStyle =
1489 | p.normalCamera.z < 0 ? p.strokeColorDark : p.strokeColor;
1490 | }
1491 |
1492 | const { vertices } = p;
1493 | const lastV = vertices[vertices.length - 1];
1494 | const fadeOut = p.middle.z > cameraFadeStartZ;
1495 |
1496 | if (!p.wireframe) {
1497 | const normalLight = p.normalWorld.y * 0.5 + p.normalWorld.z * -0.5;
1498 | const lightness =
1499 | normalLight > 0
1500 | ? 0.1
1501 | : ((normalLight ** 32 - normalLight) / 2) * 0.9 + 0.1;
1502 | ctx.fillStyle = shadeColor(p.color, lightness);
1503 | }
1504 |
1505 | if (fadeOut) {
1506 | ctx.globalAlpha = Math.max(
1507 | 0,
1508 | 1 - (p.middle.z - cameraFadeStartZ) / cameraFadeRange
1509 | );
1510 | }
1511 |
1512 | ctx.beginPath();
1513 | ctx.moveTo(lastV.x, lastV.y);
1514 | for (let v of vertices) {
1515 | ctx.lineTo(v.x, v.y);
1516 | }
1517 |
1518 | if (!p.wireframe) {
1519 | ctx.fill();
1520 | }
1521 | if (p.strokeWidth !== 0) {
1522 | ctx.stroke();
1523 | }
1524 |
1525 | if (fadeOut) {
1526 | ctx.globalAlpha = 1;
1527 | }
1528 | });
1529 | PERF_END("drawPolys");
1530 |
1531 | PERF_START("draw2D");
1532 |
1533 | ctx.strokeStyle = sparkColor;
1534 | ctx.lineWidth = sparkThickness;
1535 | ctx.beginPath();
1536 | sparks.forEach((spark) => {
1537 | ctx.moveTo(spark.x, spark.y);
1538 | const scale = (spark.life / spark.maxLife) ** 0.5 * 1.5;
1539 | ctx.lineTo(spark.x - spark.xD * scale, spark.y - spark.yD * scale);
1540 | });
1541 | ctx.stroke();
1542 |
1543 | ctx.strokeStyle = touchTrailColor;
1544 | const touchPointCount = touchPoints.length;
1545 | for (let i = 1; i < touchPointCount; i++) {
1546 | const current = touchPoints[i];
1547 | const prev = touchPoints[i - 1];
1548 | if (current.touchBreak || prev.touchBreak) {
1549 | continue;
1550 | }
1551 | const scale = current.life / touchPointLife;
1552 | ctx.lineWidth = scale * touchTrailThickness;
1553 | ctx.beginPath();
1554 | ctx.moveTo(prev.x, prev.y);
1555 | ctx.lineTo(current.x, current.y);
1556 | ctx.stroke();
1557 | }
1558 |
1559 | PERF_END("draw2D");
1560 |
1561 | PERF_END("draw");
1562 | PERF_END("frame");
1563 |
1564 | PERF_UPDATE();
1565 | }
1566 |
1567 | function setupCanvases() {
1568 | const ctx = canvas.getContext("2d");
1569 | const dpr = window.devicePixelRatio || 1;
1570 | let viewScale;
1571 | let width, height;
1572 |
1573 | function handleResize() {
1574 | const w = window.innerWidth;
1575 | const h = window.innerHeight;
1576 | viewScale = h / 1000;
1577 | width = w / viewScale;
1578 | height = h / viewScale;
1579 | canvas.width = w * dpr;
1580 | canvas.height = h * dpr;
1581 | canvas.style.width = w + "px";
1582 | canvas.style.height = h + "px";
1583 | }
1584 |
1585 | handleResize();
1586 | window.addEventListener("resize", handleResize);
1587 |
1588 | let lastTimestamp = 0;
1589 | function frameHandler(timestamp) {
1590 | let frameTime = timestamp - lastTimestamp;
1591 | lastTimestamp = timestamp;
1592 |
1593 | raf();
1594 |
1595 | if (isPaused()) return;
1596 |
1597 | if (frameTime < 0) {
1598 | frameTime = 17;
1599 | } else if (frameTime > 68) {
1600 | frameTime = 68;
1601 | }
1602 |
1603 | const halfW = width / 2;
1604 | const halfH = height / 2;
1605 |
1606 | pointerScene.x = pointerScreen.x / viewScale - halfW;
1607 | pointerScene.y = pointerScreen.y / viewScale - halfH;
1608 |
1609 | const lag = frameTime / 16.6667;
1610 | const simTime = gameSpeed * frameTime;
1611 | const simSpeed = gameSpeed * lag;
1612 | tick(width, height, simTime, simSpeed, lag);
1613 |
1614 | ctx.clearRect(0, 0, canvas.width, canvas.height);
1615 | const drawScale = dpr * viewScale;
1616 | ctx.scale(drawScale, drawScale);
1617 | ctx.translate(halfW, halfH);
1618 | draw(ctx, width, height, viewScale);
1619 | ctx.setTransform(1, 0, 0, 1, 0, 0);
1620 | }
1621 | const raf = () => requestAnimationFrame(frameHandler);
1622 | raf();
1623 | }
1624 |
1625 | function handleCanvasPointerDown(x, y) {
1626 | if (!pointerIsDown) {
1627 | pointerIsDown = true;
1628 | pointerScreen.x = x;
1629 | pointerScreen.y = y;
1630 | if (isMenuVisible()) renderMenus();
1631 | }
1632 | }
1633 |
1634 | function handleCanvasPointerUp() {
1635 | if (pointerIsDown) {
1636 | pointerIsDown = false;
1637 | touchPoints.push({
1638 | touchBreak: true,
1639 | life: touchPointLife,
1640 | });
1641 | if (isMenuVisible()) renderMenus();
1642 | }
1643 | }
1644 |
1645 | function handleCanvasPointerMove(x, y) {
1646 | if (pointerIsDown) {
1647 | pointerScreen.x = x;
1648 | pointerScreen.y = y;
1649 | }
1650 | }
1651 |
1652 | if ("PointerEvent" in window) {
1653 | canvas.addEventListener("pointerdown", (event) => {
1654 | event.isPrimary && handleCanvasPointerDown(event.clientX, event.clientY);
1655 | });
1656 |
1657 | canvas.addEventListener("pointerup", (event) => {
1658 | event.isPrimary && handleCanvasPointerUp();
1659 | });
1660 |
1661 | canvas.addEventListener("pointermove", (event) => {
1662 | event.isPrimary && handleCanvasPointerMove(event.clientX, event.clientY);
1663 | });
1664 |
1665 | document.body.addEventListener("mouseleave", handleCanvasPointerUp);
1666 | } else {
1667 | let activeTouchId = null;
1668 | canvas.addEventListener("touchstart", (event) => {
1669 | if (!pointerIsDown) {
1670 | const touch = event.changedTouches[0];
1671 | activeTouchId = touch.identifier;
1672 | handleCanvasPointerDown(touch.clientX, touch.clientY);
1673 | }
1674 | });
1675 | canvas.addEventListener("touchend", (event) => {
1676 | for (let touch of event.changedTouches) {
1677 | if (touch.identifier === activeTouchId) {
1678 | handleCanvasPointerUp();
1679 | break;
1680 | }
1681 | }
1682 | });
1683 | canvas.addEventListener(
1684 | "touchmove",
1685 | (event) => {
1686 | for (let touch of event.changedTouches) {
1687 | if (touch.identifier === activeTouchId) {
1688 | handleCanvasPointerMove(touch.clientX, touch.clientY);
1689 | event.preventDefault();
1690 | break;
1691 | }
1692 | }
1693 | },
1694 | { passive: false }
1695 | );
1696 | }
1697 |
1698 | setupCanvases();
1699 |
--------------------------------------------------------------------------------
/style.css:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@100;200;300;400;500;600;700&display=swap");
2 |
3 | body {
4 | margin: 0;
5 | background-color: #000;
6 | background: #1e3144;
7 | height: 100vh;
8 | overflow: hidden;
9 | font-family: "IBM Plex Sans Arabic", sans-serif;
10 | font-weight: bold;
11 | letter-spacing: 0.06em;
12 | color: rgb(255, 255, 255);
13 | max-width: 100%;
14 | }
15 |
16 | #c {
17 | display: block;
18 | touch-action: none;
19 | transform: translateZ(0);
20 | }
21 |
22 | .hud__score,
23 | .pause-btn {
24 | position: fixed;
25 | font-size: calc(14px + 2vw + 1vh);
26 | }
27 |
28 | .hud__score {
29 | top: 0.65em;
30 | left: 0.65em;
31 | pointer-events: none;
32 | user-select: none;
33 | }
34 |
35 | .cube-count-lbl {
36 | font-size: 0.46em;
37 | }
38 |
39 | .pause-btn {
40 | position: fixed;
41 | top: 0;
42 | right: 0;
43 | padding: 0.8em 0.65em;
44 | }
45 |
46 | .pause-btn > div {
47 | position: relative;
48 | width: 0.8em;
49 | height: 0.8em;
50 | opacity: 1;
51 | }
52 |
53 | .pause-btn > div::before,
54 | .pause-btn > div::after {
55 | content: "";
56 | display: block;
57 | width: 34%;
58 | height: 100%;
59 | position: absolute;
60 | background-color: #fff;
61 | }
62 |
63 | .pause-btn > div::after {
64 | right: 0;
65 | }
66 |
67 | .slowmo {
68 | position: fixed;
69 | bottom: 0;
70 | width: 100%;
71 | pointer-events: none;
72 | opacity: 0;
73 | transition: opacity 0.4s;
74 | will-change: opacity;
75 | }
76 |
77 | .slowmo::before {
78 | content: "اسلوموشن";
79 | display: block;
80 | font-size: calc(8px + 1vw + 0.5vh);
81 | margin-left: 0.5em;
82 | margin-bottom: 8px;
83 | }
84 |
85 | .slowmo::after {
86 | content: "";
87 | display: block;
88 | position: fixed;
89 | bottom: 0;
90 | width: 100%;
91 | height: 1.5vh;
92 | background-color: rgba(0, 0, 0, 0.25);
93 | z-index: -1;
94 | }
95 |
96 | .slowmo__bar {
97 | height: 1.5vh;
98 | background-color: rgba(255, 255, 255, 0.75);
99 | transform-origin: 0 0;
100 | }
101 |
102 | .menus::before {
103 | content: "";
104 | pointer-events: none;
105 | position: fixed;
106 | top: 0;
107 | right: 0;
108 | bottom: 0;
109 | left: 0;
110 | background-color: #000;
111 | opacity: 0;
112 | transition: opacity 0.2s;
113 | transition-timing-function: ease-in;
114 | }
115 |
116 | .menus.has-active::before {
117 | opacity: 0.08;
118 | transition-duration: 0.4s;
119 | transition-timing-function: ease-out;
120 | }
121 |
122 | .menus.interactive-mode::before {
123 | opacity: 0.02;
124 | }
125 |
126 | .menu {
127 | background: rgba(0, 0, 0, 0.63);
128 | pointer-events: none;
129 | position: fixed;
130 | top: 0;
131 | right: 0;
132 | bottom: 0;
133 | left: 0;
134 | display: flex;
135 | flex-direction: column;
136 | justify-content: center;
137 | align-items: center;
138 | user-select: none;
139 | text-align: center;
140 | color: rgba(255, 255, 255, 0.9);
141 | opacity: 0;
142 | visibility: hidden;
143 | transform: translateY(30px);
144 | transition-property: opacity, visibility, transform;
145 | transition-duration: 0.2s;
146 | transition-timing-function: ease-in;
147 | }
148 |
149 | .menu.active {
150 | opacity: 1;
151 | visibility: visible;
152 | transform: translateY(0);
153 | transition-duration: 0.4s;
154 | transition-timing-function: ease-out;
155 | }
156 |
157 | .menus.interactive-mode .menu.active {
158 | opacity: 0.6;
159 | }
160 |
161 | .menus:not(.interactive-mode) .menu.active > * {
162 | pointer-events: auto;
163 | }
164 |
165 | h1 {
166 | font-size: 4rem;
167 | line-height: 0.95;
168 | text-align: center;
169 | font-weight: bold;
170 | margin: 0 0.65em 1em;
171 | }
172 |
173 | h2 {
174 | font-size: 1.2rem;
175 | line-height: 1;
176 | text-align: center;
177 | font-weight: bold;
178 | margin: -1em 0.65em 1em;
179 | }
180 |
181 | .final-score-lbl {
182 | font-size: 5rem;
183 | margin: -0.2em 0 0;
184 | }
185 |
186 | .high-score-lbl {
187 | font-size: 1.2rem;
188 | margin: 0 0 2.5em;
189 | }
190 |
191 | button {
192 | display: block;
193 | position: relative;
194 | width: 200px;
195 | padding: 12px 20px;
196 | background: transparent;
197 | border: none;
198 | outline: none;
199 | user-select: none;
200 | font-family: "IBM Plex Sans Arabic", sans-serif;
201 | font-weight: bold;
202 | font-size: 1.8rem;
203 | color: #fff;
204 | opacity: 0.75;
205 | transition: opacity 0.3s;
206 | }
207 |
208 | button::before {
209 | content: "";
210 | position: absolute;
211 | top: 0;
212 | right: 0;
213 | bottom: 0;
214 | left: 0;
215 | background-color: rgba(255, 255, 255, 0.15);
216 | transform: scale(0, 0);
217 | opacity: 0;
218 | transition: opacity 0.3s, transform 0.3s;
219 | }
220 |
221 | button:active {
222 | opacity: 1;
223 | }
224 |
225 | button:active::before {
226 | transform: scale(1, 1);
227 | opacity: 1;
228 | }
229 |
230 | .credits {
231 | position: fixed;
232 | width: 100%;
233 | left: 0;
234 | bottom: 20px;
235 | }
236 |
237 | a {
238 | color: white;
239 | }
240 |
241 | @media (min-width: 1101px) {
242 | button:hover {
243 | opacity: 1;
244 | }
245 |
246 | button:hover::before {
247 | transform: scale(1, 1);
248 | opacity: 1;
249 | border-radius: 10px;
250 | }
251 | }
252 |
253 | @media (max-width: 1100px) {
254 | h1 {
255 | font-size: 4.2rem;
256 | }
257 | button {
258 | font-size: 2.3rem;
259 | width: 300vw;
260 | padding: 12px 20px;
261 | border-radius: 10px;
262 | }
263 | .credits {
264 | font-size: 1.1rem;
265 | margin-bottom: 10px;
266 | }
267 | .menu--score h2,
268 | .final-score-lbl,
269 | .high-score-lbl {
270 | font-size: 1.1rem;
271 | }
272 |
273 | .score-lbl,
274 | .cube-count-lbl {
275 | font-size: 1.1rem;
276 | }
277 |
278 | .pause-btn div::before{
279 | display: none;
280 | }
281 | .pause-btn div::before,
282 | .pause-btn div::after {
283 | content: "منو";
284 | width: 0;
285 | height: 0;
286 | margin-right: 1rem;
287 | font-size: 1.1rem;
288 | }
289 | }
290 | @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@100;200;300;400;500;600;700&display=swap");
291 |
292 | body {
293 | margin: 0;
294 | background-color: #000;
295 | background: #1e3144;
296 | height: 100vh;
297 | overflow: hidden;
298 | font-family: "IBM Plex Sans Arabic", sans-serif;
299 | font-weight: bold;
300 | letter-spacing: 0.06em;
301 | color: rgb(255, 255, 255);
302 | max-width: 100%;
303 | }
304 |
305 | #c {
306 | display: block;
307 | touch-action: none;
308 | transform: translateZ(0);
309 | }
310 |
311 | .hud__score,
312 | .pause-btn {
313 | position: fixed;
314 | font-size: calc(14px + 2vw + 1vh);
315 | }
316 |
317 | .hud__score {
318 | top: 0.65em;
319 | left: 0.65em;
320 | pointer-events: none;
321 | user-select: none;
322 | }
323 |
324 | .cube-count-lbl {
325 | font-size: 0.46em;
326 | }
327 |
328 | .pause-btn {
329 | position: fixed;
330 | top: 0;
331 | right: 0;
332 | padding: 0.8em 0.65em;
333 | }
334 |
335 | .pause-btn > div {
336 | position: relative;
337 | width: 0.8em;
338 | height: 0.8em;
339 | opacity: 1;
340 | }
341 |
342 | .pause-btn > div::before,
343 | .pause-btn > div::after {
344 | content: "";
345 | display: block;
346 | width: 34%;
347 | height: 100%;
348 | position: absolute;
349 | background-color: #fff;
350 | }
351 |
352 | .pause-btn > div::after {
353 | right: 0;
354 | }
355 |
356 | .slowmo {
357 | position: fixed;
358 | bottom: 0;
359 | width: 100%;
360 | pointer-events: none;
361 | opacity: 0;
362 | transition: opacity 0.4s;
363 | will-change: opacity;
364 | }
365 |
366 | .slowmo::before {
367 | content: "اسلوموشن";
368 | display: block;
369 | font-size: calc(8px + 1vw + 0.5vh);
370 | margin-left: 0.5em;
371 | margin-bottom: 8px;
372 | }
373 |
374 | .slowmo::after {
375 | content: "";
376 | display: block;
377 | position: fixed;
378 | bottom: 0;
379 | width: 100%;
380 | height: 1.5vh;
381 | background-color: rgba(0, 0, 0, 0.25);
382 | z-index: -1;
383 | }
384 |
385 | .slowmo__bar {
386 | height: 1.5vh;
387 | background-color: rgba(255, 255, 255, 0.75);
388 | transform-origin: 0 0;
389 | }
390 |
391 | .menus::before {
392 | content: "";
393 | pointer-events: none;
394 | position: fixed;
395 | top: 0;
396 | right: 0;
397 | bottom: 0;
398 | left: 0;
399 | background-color: #000;
400 | opacity: 0;
401 | transition: opacity 0.2s;
402 | transition-timing-function: ease-in;
403 | }
404 |
405 | .menus.has-active::before {
406 | opacity: 0.08;
407 | transition-duration: 0.4s;
408 | transition-timing-function: ease-out;
409 | }
410 |
411 | .menus.interactive-mode::before {
412 | opacity: 0.02;
413 | }
414 |
415 | .menu {
416 | background: rgba(0, 0, 0, 0.63);
417 | pointer-events: none;
418 | position: fixed;
419 | top: 0;
420 | right: 0;
421 | bottom: 0;
422 | left: 0;
423 | display: flex;
424 | flex-direction: column;
425 | justify-content: center;
426 | align-items: center;
427 | user-select: none;
428 | text-align: center;
429 | color: rgba(255, 255, 255, 0.9);
430 | opacity: 0;
431 | visibility: hidden;
432 | transform: translateY(30px);
433 | transition-property: opacity, visibility, transform;
434 | transition-duration: 0.2s;
435 | transition-timing-function: ease-in;
436 | }
437 |
438 | .menu.active {
439 | opacity: 1;
440 | visibility: visible;
441 | transform: translateY(0);
442 | transition-duration: 0.4s;
443 | transition-timing-function: ease-out;
444 | }
445 |
446 | .menus.interactive-mode .menu.active {
447 | opacity: 0.6;
448 | }
449 |
450 | .menus:not(.interactive-mode) .menu.active > * {
451 | pointer-events: auto;
452 | }
453 |
454 | h1 {
455 | font-size: 4rem;
456 | line-height: 0.95;
457 | text-align: center;
458 | font-weight: bold;
459 | margin: 0 0.65em 1em;
460 | }
461 |
462 | h2 {
463 | font-size: 1.2rem;
464 | line-height: 1;
465 | text-align: center;
466 | font-weight: bold;
467 | margin: -1em 0.65em 1em;
468 | }
469 |
470 | .final-score-lbl {
471 | font-size: 5rem;
472 | margin: -0.2em 0 0;
473 | }
474 |
475 | .high-score-lbl {
476 | font-size: 1.2rem;
477 | margin: 0 0 2.5em;
478 | }
479 |
480 | button {
481 | display: block;
482 | position: relative;
483 | width: 200px;
484 | padding: 12px 20px;
485 | background: transparent;
486 | border: none;
487 | outline: none;
488 | user-select: none;
489 | font-family: "IBM Plex Sans Arabic", sans-serif;
490 | font-weight: bold;
491 | font-size: 1.8rem;
492 | color: #fff;
493 | opacity: 0.75;
494 | transition: opacity 0.3s;
495 | }
496 |
497 | button::before {
498 | content: "";
499 | position: absolute;
500 | top: 0;
501 | right: 0;
502 | bottom: 0;
503 | left: 0;
504 | background-color: rgba(255, 255, 255, 0.15);
505 | transform: scale(0, 0);
506 | opacity: 0;
507 | transition: opacity 0.3s, transform 0.3s;
508 | }
509 |
510 | button:active {
511 | opacity: 1;
512 | }
513 |
514 | button:active::before {
515 | transform: scale(1, 1);
516 | opacity: 1;
517 | }
518 |
519 | .credits {
520 | position: fixed;
521 | width: 100%;
522 | left: 0;
523 | bottom: 20px;
524 | }
525 |
526 | a {
527 | color: white;
528 | }
529 |
530 | @media (min-width: 1101px) {
531 | button:hover {
532 | opacity: 1;
533 | }
534 |
535 | button:hover::before {
536 | transform: scale(1, 1);
537 | opacity: 1;
538 | border-radius: 10px;
539 | }
540 | }
541 |
542 | @media (max-width: 1100px) {
543 | h1 {
544 | font-size: 4.2rem;
545 | }
546 | button {
547 | font-size: 2.3rem;
548 | width: 300vw;
549 | padding: 12px 20px;
550 | border-radius: 10px;
551 | }
552 | .credits {
553 | font-size: 1.1rem;
554 | margin-bottom: 10px;
555 | }
556 | .menu--score h2,
557 | .final-score-lbl,
558 | .high-score-lbl {
559 | font-size: 1.1rem;
560 | }
561 |
562 | .score-lbl,
563 | .cube-count-lbl {
564 | font-size: 1.1rem;
565 | }
566 |
567 | .pause-btn div::before{
568 | display: none;
569 | }
570 | .pause-btn div::before,
571 | .pause-btn div::after {
572 | content: "منو";
573 | width: 0;
574 | height: 0;
575 | margin-right: 1rem;
576 | font-size: 1.1rem;
577 | }
578 | }
579 |
--------------------------------------------------------------------------------