arcadia / tile-fusion

Tile Fusion

game.js7.5 KB
1// Tile Fusion — an Arcadia seed game. Slide, merge, reach 2048. Fork it and make it better!
2(() => {
3 const W = 520, H = 680, N = 4, PAD = 14, BOARD = 480, OX = 20, OY = 170, CS = (BOARD - PAD * (N + 1)) / N;
4 const c = document.getElementById("c");
5 const ctx = c.getContext("2d");
6 function fit() {
7 const s = Math.min(innerWidth / W, innerHeight / H), d = window.devicePixelRatio || 1;
8 c.style.width = W * s + "px"; c.style.height = H * s + "px";
9 c.width = Math.round(W * s * d); c.height = Math.round(H * s * d);
10 ctx.setTransform(s * d, 0, 0, s * d, 0, 0);
11 }
12 addEventListener("resize", fit); fit();
13
14 const COLORS = { 2: "#eee4da", 4: "#ede0c8", 8: "#f2b179", 16: "#f59563", 32: "#f67c5f", 64: "#f65e3b", 128: "#edcf72", 256: "#edcc61", 512: "#9d4edd", 1024: "#7b2cbf", 2048: "#ff006e" };
15 const best = () => Number(localStorage.getItem("fusion.best") || 0);
16 let tiles, score, over, won, keepGoing, nextId = 1;
17
18 // Resume a game in progress if the player has one saved.
19 function saveGame() {
20 localStorage.setItem("fusion.game", JSON.stringify({ tiles: tiles.map(({ r, c, v }) => ({ r, c, v })), score }));
21 }
22 function newGame() {
23 tiles = []; score = 0; over = false; won = false; keepGoing = false;
24 spawn(); spawn(); saveGame();
25 }
26 function load() {
27 try {
28 const g = JSON.parse(localStorage.getItem("fusion.game") || "null");
29 if (g && g.tiles && g.tiles.length) {
30 tiles = g.tiles.map((t) => ({ ...t, id: nextId++, x: t.c, y: t.r, pop: 0 }));
31 score = g.score || 0; over = false; won = false; keepGoing = tiles.some((t) => t.v >= 2048);
32 return;
33 }
34 } catch (e) {}
35 newGame();
36 }
37 const at = (r, c) => tiles.find((t) => t.r === r && t.c === c && !t.dead);
38 function spawn() {
39 const free = [];
40 for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) if (!at(r, c)) free.push([r, c]);
41 if (!free.length) return;
42 const [r, c] = free[(Math.random() * free.length) | 0];
43 tiles.push({ id: nextId++, r, c, x: c, y: r, v: Math.random() < 0.9 ? 2 : 4, pop: 0.01 });
44 }
45
46 function move(dr, dc) {
47 if (over || (won && !keepGoing)) return;
48 tiles = tiles.filter((t) => !t.dead);
49 let moved = false;
50 const order = [];
51 for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) order.push([dr === 1 ? N - 1 - i : i, dc === 1 ? N - 1 - j : j]);
52 const merged = new Set();
53 for (const [r, c] of order) {
54 const t = at(r, c);
55 if (!t) continue;
56 let nr = r, nc = c;
57 for (;;) {
58 const tr = nr + dr, tc = nc + dc;
59 if (tr < 0 || tc < 0 || tr >= N || tc >= N) break;
60 const o = at(tr, tc);
61 if (!o) { nr = tr; nc = tc; continue; }
62 if (o.v === t.v && !merged.has(o.id) && !merged.has(t.id)) {
63 nr = tr; nc = tc;
64 o.dead = true; t.v *= 2; t.pop = 1; merged.add(t.id);
65 score += t.v;
66 if (t.v === 2048 && !keepGoing) won = true;
67 }
68 break;
69 }
70 if (nr !== r || nc !== c) { t.r = nr; t.c = nc; moved = true; }
71 }
72 if (!moved) return;
73 spawn();
74 if (score > best()) localStorage.setItem("fusion.best", String(score));
75 if (!canMove()) {
76 over = true;
77 localStorage.removeItem("fusion.game");
78 if (window.Arcadia) { Arcadia.submitScore(score); Arcadia.gameOver(); }
79 } else saveGame();
80 if (won && window.Arcadia) Arcadia.submitScore(score);
81 }
82 function canMove() {
83 const live = tiles.filter((t) => !t.dead);
84 if (live.length < N * N) return true;
85 for (const t of live) {
86 const r = at(t.r, t.c + 1), d = at(t.r + 1, t.c);
87 if ((r && r.v === t.v) || (d && d.v === t.v)) return true;
88 }
89 return false;
90 }
91
92 const KEYS = { ArrowUp: [-1, 0], w: [-1, 0], ArrowDown: [1, 0], s: [1, 0], ArrowLeft: [0, -1], a: [0, -1], ArrowRight: [0, 1], d: [0, 1] };
93 addEventListener("keydown", (e) => {
94 if (KEYS[e.key]) { move(...KEYS[e.key]); e.preventDefault(); }
95 if (e.key === "r") newGame();
96 if ((e.key === "Enter" || e.key === " ") && (over || won)) { if (won && !over) keepGoing = true; else newGame(); }
97 });
98 let start = null;
99 function local(e) { const r = c.getBoundingClientRect(); return { x: ((e.clientX - r.left) / r.width) * W, y: ((e.clientY - r.top) / r.height) * H }; }
100 c.addEventListener("pointerdown", (e) => {
101 const p = local(e);
102 if (p.x > W - 150 && p.y > 100 && p.y < 150) { newGame(); return; }
103 if (over) { newGame(); return; }
104 if (won && !keepGoing) { keepGoing = true; return; }
105 start = { x: e.clientX, y: e.clientY };
106 });
107 addEventListener("pointerup", (e) => {
108 if (!start) return;
109 const dx = e.clientX - start.x, dy = e.clientY - start.y;
110 start = null;
111 if (Math.max(Math.abs(dx), Math.abs(dy)) < 25) return;
112 Math.abs(dx) > Math.abs(dy) ? move(0, Math.sign(dx)) : move(Math.sign(dy), 0);
113 });
114
115 function rr(x, y, w, h, r, color) { ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, w, h, r); ctx.fill(); }
116
117 function draw(dt) {
118 ctx.fillStyle = "#1a1426"; ctx.fillRect(0, 0, W, H);
119 ctx.fillStyle = "#fff"; ctx.textBaseline = "middle"; ctx.textAlign = "left";
120 ctx.font = "900 40px system-ui"; ctx.fillText("Tile Fusion", OX, 58);
121 ctx.font = "500 16px system-ui"; ctx.fillStyle = "#b8a9d9"; ctx.fillText("Merge matching tiles to reach 2048.", OX, 102);
122 ctx.fillText("Arrows / WASD / swipe", OX, 126);
123 rr(W - 230, 30, 100, 56, 10, "#2f2545"); rr(W - 120, 30, 100, 56, 10, "#2f2545");
124 ctx.textAlign = "center"; ctx.fillStyle = "#b8a9d9"; ctx.font = "700 12px system-ui";
125 ctx.fillText("SCORE", W - 180, 46); ctx.fillText("BEST", W - 70, 46);
126 ctx.fillStyle = "#fff"; ctx.font = "800 22px system-ui";
127 ctx.fillText(score, W - 180, 70); ctx.fillText(Math.max(best(), score), W - 70, 70);
128 rr(W - 150, 104, 130, 42, 10, "#ff006e"); ctx.fillStyle = "#fff"; ctx.font = "800 16px system-ui"; ctx.fillText("New game", W - 85, 126);
129
130 rr(OX, OY, BOARD, BOARD, 16, "#2f2545");
131 for (let r = 0; r < N; r++) for (let c2 = 0; c2 < N; c2++) rr(OX + PAD + c2 * (CS + PAD), OY + PAD + r * (CS + PAD), CS, CS, 10, "#3d3259");
132 const k = Math.min(1, dt * 22);
133 for (const t of [...tiles].sort((a, b) => (a.dead ? -1 : 0) - (b.dead ? -1 : 0))) {
134 t.x += (t.c - t.x) * k; t.y += (t.r - t.y) * k;
135 if (Math.abs(t.c - t.x) < 0.01) t.x = t.c;
136 if (Math.abs(t.r - t.y) < 0.01) t.y = t.r;
137 if (t.pop) t.pop = Math.max(0, t.pop - dt * 5);
138 const grow = t.pop > 0 && t.pop < 1 ? 1 + Math.sin(t.pop * Math.PI) * 0.12 : 1;
139 const size = CS * grow;
140 const x = OX + PAD + t.x * (CS + PAD) + (CS - size) / 2, y = OY + PAD + t.y * (CS + PAD) + (CS - size) / 2;
141 rr(x, y, size, size, 10, COLORS[t.v] || "#3c096c");
142 ctx.fillStyle = t.v <= 4 ? "#5b4d6e" : "#fff";
143 ctx.font = `900 ${t.v < 100 ? 48 : t.v < 1000 ? 40 : 32}px system-ui`;
144 ctx.fillText(t.v, x + size / 2, y + size / 2 + 2);
145 }
146 if (tiles.some((t) => t.dead && t.x === t.c && t.y === t.r)) tiles = tiles.filter((t) => !t.dead || t.x !== t.c || t.y !== t.r);
147
148 if (over || (won && !keepGoing)) {
149 rr(OX, OY, BOARD, BOARD, 16, "rgba(26,20,38,.8)");
150 ctx.fillStyle = "#fff"; ctx.font = "900 48px system-ui";
151 ctx.fillText(over ? "No moves left" : "You made 2048!", W / 2, OY + BOARD / 2 - 20);
152 ctx.font = "600 18px system-ui"; ctx.fillStyle = "#e0d4ff";
153 ctx.fillText(over ? "Tap to try again" : "Tap to keep going", W / 2, OY + BOARD / 2 + 30);
154 }
155 }
156
157 load();
158 let last = performance.now();
159 function loop(now) { const dt = Math.min(0.05, (now - last) / 1000); last = now; draw(dt); requestAnimationFrame(loop); }
160 requestAnimationFrame(loop);
161})();
162