arcadia / gem-swap

Gem Swap

game.js10.7 KB
1// Gem Swap — an Arcadia seed game. Match 3 or more before the clock runs out. Fork it and make it better!
2(() => {
3 const W = 560, H = 740, N = 8, CELL = 64, OX = (W - N * CELL) / 2, OY = 170, TYPES = 6, ROUND = 90;
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 GEM = [
15 { color: "#ff4d6d", shape: "diamond" }, { color: "#ffd23f", shape: "star" }, { color: "#3ddc97", shape: "circle" },
16 { color: "#4cc9f0", shape: "square" }, { color: "#b56cff", shape: "hex" }, { color: "#ff9f1c", shape: "tri" },
17 ];
18 const best = () => Number(localStorage.getItem("gemswap.best") || 0);
19 let grid, state = "title", score = 0, timeLeft = ROUND, sel = null, busy = false, combo = 0, particles = [], popups = [], t = 0, paused = false, idle = 0, hint = null;
20
21 const rnd = () => (Math.random() * TYPES) | 0;
22 function newGrid() {
23 grid = [];
24 for (let r = 0; r < N; r++) {
25 grid.push([]);
26 for (let col = 0; col < N; col++) {
27 let tp;
28 do tp = rnd();
29 while ((col >= 2 && grid[r][col - 1].t === tp && grid[r][col - 2].t === tp) || (r >= 2 && grid[r - 1][col].t === tp && grid[r - 2][col].t === tp));
30 grid[r].push({ t: tp, y: r - N - 2, x: col, pop: 0 });
31 }
32 }
33 if (!findMove()) newGrid();
34 }
35 function start() { score = 0; timeLeft = ROUND; combo = 0; sel = null; busy = false; newGrid(); state = "play"; }
36
37 function allMatches() {
38 const hit = new Set();
39 for (let r = 0; r < N; r++) {
40 let run = 1;
41 for (let col = 1; col <= N; col++) {
42 if (col < N && grid[r][col].t === grid[r][col - 1].t) run++;
43 else { if (run >= 3) for (let k = col - run; k < col; k++) hit.add(r * N + k); run = 1; }
44 }
45 }
46 for (let col = 0; col < N; col++) {
47 let run = 1;
48 for (let r = 1; r <= N; r++) {
49 if (r < N && grid[r][col].t === grid[r - 1][col].t) run++;
50 else { if (run >= 3) for (let k = r - run; k < r; k++) hit.add(k * N + col); run = 1; }
51 }
52 }
53 return hit;
54 }
55 function swap(a, b) { const tmp = grid[a.r][a.c]; grid[a.r][a.c] = grid[b.r][b.c]; grid[b.r][b.c] = tmp; }
56 function findMove() {
57 for (let r = 0; r < N; r++) for (let col = 0; col < N; col++) for (const [dr, dc] of [[0, 1], [1, 0]]) {
58 const r2 = r + dr, c2 = col + dc;
59 if (r2 >= N || c2 >= N) continue;
60 swap({ r, c: col }, { r: r2, c: c2 });
61 const ok = allMatches().size > 0;
62 swap({ r, c: col }, { r: r2, c: c2 });
63 if (ok) return [{ r, c: col }, { r: r2, c: c2 }];
64 }
65 return null;
66 }
67 const wait = (ms) => new Promise((res) => setTimeout(res, ms));
68
69 async function trySwap(a, b) {
70 busy = true; idle = 0; hint = null;
71 swap(a, b);
72 await wait(160);
73 if (!allMatches().size) { swap(a, b); await wait(160); busy = false; return; }
74 combo = 0;
75 for (;;) {
76 const hit = allMatches();
77 if (!hit.size) break;
78 combo++;
79 const gained = hit.size * 10 * combo;
80 score += gained;
81 if (hit.size >= 4) timeLeft = Math.min(ROUND, timeLeft + hit.size - 2);
82 let cx = 0, cy = 0;
83 for (const k of hit) {
84 const r = (k / N) | 0, col = k % N, g = grid[r][col];
85 cx += col; cy += r;
86 for (let i = 0; i < 6; i++) { const an = Math.random() * 6.28, s = 60 + Math.random() * 160; particles.push({ x: OX + col * CELL + CELL / 2, y: OY + r * CELL + CELL / 2, vx: Math.cos(an) * s, vy: Math.sin(an) * s, life: 0.5, color: GEM[g.t].color }); }
87 g.t = -1;
88 }
89 popups.push({ x: OX + (cx / hit.size) * CELL + CELL / 2, y: OY + (cy / hit.size) * CELL, text: combo > 1 ? `+${gained} x${combo}` : `+${gained}`, life: 0.9 });
90 await wait(140);
91 // Gravity: drop gems down, fill from the top.
92 for (let col = 0; col < N; col++) {
93 let write = N - 1;
94 for (let r = N - 1; r >= 0; r--) if (grid[r][col].t >= 0) { const g = grid[r][col]; grid[write][col] = g; write--; }
95 for (let r = write; r >= 0; r--) grid[r][col] = { t: rnd(), y: r - (write + 1) - 1, x: col, pop: 0 };
96 }
97 await wait(260);
98 }
99 if (!findMove()) { popups.push({ x: W / 2, y: OY + N * CELL / 2, text: "Shuffle!", life: 1.2 }); await wait(400); newGrid(); }
100 busy = false;
101 }
102
103 function cellAt(e) {
104 const r = c.getBoundingClientRect();
105 const x = ((e.clientX - r.left) / r.width) * W - OX, y = ((e.clientY - r.top) / r.height) * H - OY;
106 if (x < 0 || y < 0 || x >= N * CELL || y >= N * CELL) return null;
107 return { r: (y / CELL) | 0, c: (x / CELL) | 0 };
108 }
109 let drag = null;
110 c.addEventListener("pointerdown", (e) => {
111 if (state !== "play") { start(); return; }
112 if (busy) return;
113 const p = cellAt(e);
114 if (!p) return;
115 if (sel && Math.abs(sel.r - p.r) + Math.abs(sel.c - p.c) === 1) { const a = sel; sel = null; trySwap(a, p); return; }
116 sel = p; drag = { p, x: e.clientX, y: e.clientY };
117 });
118 addEventListener("pointermove", (e) => {
119 if (!drag || busy) return;
120 const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
121 if (Math.hypot(dx, dy) < 18) return;
122 const b = Math.abs(dx) > Math.abs(dy) ? { r: drag.p.r, c: drag.p.c + Math.sign(dx) } : { r: drag.p.r + Math.sign(dy), c: drag.p.c };
123 const a = drag.p; drag = null;
124 if (b.r >= 0 && b.c >= 0 && b.r < N && b.c < N) { sel = null; trySwap(a, b); }
125 });
126 addEventListener("pointerup", () => (drag = null));
127 addEventListener("keydown", (e) => { if ((e.key === " " || e.key === "Enter") && state !== "play") start(); });
128 if (window.Arcadia) { Arcadia.onPause(() => (paused = true)); Arcadia.onResume(() => (paused = false)); }
129
130 function update(dt) {
131 t += dt;
132 for (const p of particles) { p.x += p.vx * dt; p.y += p.vy * dt; p.vy += 300 * dt; p.life -= dt; }
133 particles = particles.filter((p) => p.life > 0);
134 for (const p of popups) { p.life -= dt; p.y -= 40 * dt; }
135 popups = popups.filter((p) => p.life > 0);
136 if (!grid) return;
137 for (let r = 0; r < N; r++) for (let col = 0; col < N; col++) {
138 const g = grid[r][col];
139 g.y += (r - g.y) * Math.min(1, dt * 14); g.x += (col - g.x) * Math.min(1, dt * 14);
140 }
141 if (state !== "play") return;
142 timeLeft -= dt;
143 idle += dt;
144 if (idle > 6 && !hint && !busy) hint = findMove();
145 if (timeLeft <= 0 && !busy) {
146 timeLeft = 0; state = "over";
147 if (score > best()) localStorage.setItem("gemswap.best", String(score));
148 if (window.Arcadia) { Arcadia.submitScore(score); Arcadia.gameOver(); }
149 }
150 }
151
152 function gemPath(shape, x, y, s) {
153 ctx.beginPath();
154 if (shape === "circle") ctx.arc(x, y, s, 0, Math.PI * 2);
155 else if (shape === "square") ctx.roundRect(x - s * 0.85, y - s * 0.85, s * 1.7, s * 1.7, 6);
156 else if (shape === "diamond") { ctx.moveTo(x, y - s); ctx.lineTo(x + s, y); ctx.lineTo(x, y + s); ctx.lineTo(x - s, y); }
157 else if (shape === "tri") { ctx.moveTo(x, y - s); ctx.lineTo(x + s, y + s * 0.8); ctx.lineTo(x - s, y + s * 0.8); }
158 else if (shape === "hex") for (let i = 0; i < 6; i++) { const a = (i / 6) * Math.PI * 2; ctx[i ? "lineTo" : "moveTo"](x + Math.cos(a) * s, y + Math.sin(a) * s); }
159 else for (let i = 0; i < 10; i++) { const a = (i / 10) * Math.PI * 2 - Math.PI / 2, rr = i % 2 ? s * 0.45 : s; ctx[i ? "lineTo" : "moveTo"](x + Math.cos(a) * rr, y + Math.sin(a) * rr); }
160 ctx.closePath();
161 }
162
163 function draw() {
164 const bg = ctx.createLinearGradient(0, 0, 0, H); bg.addColorStop(0, "#241643"); bg.addColorStop(1, "#140d24");
165 ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
166 ctx.fillStyle = "#fff"; ctx.textBaseline = "middle"; ctx.textAlign = "left"; ctx.font = "900 44px system-ui"; ctx.fillText("Gem Swap", OX, 56);
167 ctx.font = "700 20px system-ui"; ctx.fillStyle = "#d9c8ff"; ctx.fillText(`Score ${score}`, OX, 108);
168 ctx.textAlign = "right"; ctx.fillText(`Best ${Math.max(best(), score)}`, OX + N * CELL, 108);
169 // timer bar
170 ctx.fillStyle = "#3a2766"; ctx.beginPath(); ctx.roundRect(OX, 132, N * CELL, 14, 7); ctx.fill();
171 ctx.fillStyle = timeLeft < 10 ? "#ff4d6d" : "#3ddc97"; ctx.beginPath(); ctx.roundRect(OX, 132, (N * CELL * Math.max(0, timeLeft)) / ROUND, 14, 7); ctx.fill();
172
173 ctx.fillStyle = "rgba(0,0,0,.25)"; ctx.beginPath(); ctx.roundRect(OX - 8, OY - 8, N * CELL + 16, N * CELL + 16, 16); ctx.fill();
174 for (let r = 0; r < N; r++) for (let col = 0; col < N; col++) {
175 ctx.fillStyle = (r + col) % 2 ? "rgba(255,255,255,.04)" : "rgba(255,255,255,.08)";
176 ctx.fillRect(OX + col * CELL, OY + r * CELL, CELL, CELL);
177 }
178 if (grid) {
179 ctx.save(); ctx.beginPath(); ctx.rect(OX, OY, N * CELL, N * CELL); ctx.clip();
180 for (let r = 0; r < N; r++) for (let col = 0; col < N; col++) {
181 const g = grid[r][col];
182 if (g.t < 0) continue;
183 const x = OX + g.x * CELL + CELL / 2, y = OY + g.y * CELL + CELL / 2;
184 const isSel = sel && sel.r === r && sel.c === col;
185 const isHint = hint && hint.some((h) => h.r === r && h.c === col);
186 const s = 22 * (isSel ? 1.12 : 1) * (isHint ? 1 + Math.sin(t * 8) * 0.08 : 1);
187 ctx.shadowColor = GEM[g.t].color; ctx.shadowBlur = isSel ? 22 : 8;
188 ctx.fillStyle = GEM[g.t].color; gemPath(GEM[g.t].shape, x, y, s); ctx.fill();
189 ctx.shadowBlur = 0; ctx.fillStyle = "rgba(255,255,255,.35)"; gemPath(GEM[g.t].shape, x - 4, y - 5, s * 0.4); ctx.fill();
190 }
191 ctx.restore();
192 }
193 for (const p of particles) { ctx.globalAlpha = p.life * 2; ctx.fillStyle = p.color; ctx.fillRect(p.x - 3, p.y - 3, 6, 6); }
194 ctx.globalAlpha = 1; ctx.textAlign = "center"; ctx.font = "900 24px system-ui";
195 for (const p of popups) { ctx.globalAlpha = Math.min(1, p.life * 2); ctx.fillStyle = "#fff"; ctx.fillText(p.text, p.x, p.y); }
196 ctx.globalAlpha = 1;
197
198 if (state !== "play") {
199 ctx.fillStyle = "rgba(20,13,36,.8)"; ctx.fillRect(0, 0, W, H);
200 ctx.fillStyle = "#fff"; ctx.font = "900 56px system-ui"; ctx.fillText(state === "over" ? "Time's up!" : "Gem Swap", W / 2, H / 2 - 50);
201 ctx.font = "600 20px system-ui"; ctx.fillStyle = "#d9c8ff";
202 ctx.fillText(state === "over" ? `Score ${score} · Best ${best()}` : `Swap gems to match 3+. You have ${ROUND} seconds.`, W / 2, H / 2 + 10);
203 ctx.fillText("Big matches add time. Chains multiply points.", W / 2, H / 2 + 44);
204 ctx.fillStyle = "#fff"; ctx.fillText("Tap to " + (state === "over" ? "play again" : "start"), W / 2, H / 2 + 96);
205 }
206 }
207
208 let last = performance.now();
209 function loop(now) { const dt = Math.min(0.05, (now - last) / 1000); last = now; if (!paused) update(dt); draw(); requestAnimationFrame(loop); }
210 requestAnimationFrame(loop);
211})();
212