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