arcadia / checkers-classic

Checkers Classic

game.js41.6 KB
1// Checkers Classic — an Arcadia seed game. English/American draughts vs the computer or a friend.
2(() => {
3 "use strict";
4 const E = window.CheckersEngine;
5 const W = 540, H = 860, N = 8, CELL = 60, OX = 30, OY = 186, R = CELL * 0.4, FRAME = 22;
6 const BLACK = E.BLACK, RED = E.RED;
7 const GOLD = "#f4c95d", CREAM = "#f6e7c8";
8 const c = document.getElementById("c");
9 const ctx = c.getContext("2d");
10 function fit() {
11 const s = Math.min(innerWidth / W, innerHeight / H), d = window.devicePixelRatio || 1;
12 c.style.width = W * s + "px"; c.style.height = H * s + "px";
13 c.width = Math.round(W * s * d); c.height = Math.round(H * s * d);
14 ctx.setTransform(s * d, 0, 0, s * d, 0, 0);
15 }
16 addEventListener("resize", fit); fit();
17
18 // ---------- storage (Arcadia shim or plain localStorage; always guarded) ----------
19 const store = {
20 get(k, d) { try { const v = localStorage.getItem("checkers." + k); return v == null ? d : JSON.parse(v); } catch (e) { return d; } },
21 set(k, v) { try { localStorage.setItem("checkers." + k, JSON.stringify(v)); } catch (e) {} },
22 };
23 let cfg = Object.assign({ mode: "ai", human: BLACK, level: 1, sound: true }, store.get("cfg", {}));
24 let stats = Object.assign({ wins: [0, 0, 0, 0], best: [0, 0, 0, 0] }, store.get("stats", {}));
25 const saveCfg = () => store.set("cfg", cfg), saveStats = () => store.set("stats", stats);
26
27 // ---------- state ----------
28 let scr = "menu", paused = false, t = 0;
29 let st, legal = [], hashes = [], undoStack = [], lastMove = null, sprites = [], humanMoves = 0;
30 let over = null, overAt = 0, submitted = false, flip = false;
31 let sel = null, partial = null, thinking = false, cancelThink = null, busy = 0, drag = null, kc = null;
32 let tweens = [], particles = [], confetti = [], popups = [], rings = [];
33 let flashText = "", flashT = 0, shakeT = 0, pulseT = 0, restartArmed = 0, gameStarted = false;
34
35 // ---------- helpers ----------
36 const rnd = (a, b) => a + Math.random() * (b - a);
37 const easeInOut = (k) => (k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2);
38 const easeOutBack = (k) => { const c1 = 1.9, c3 = c1 + 1; return 1 + c3 * Math.pow(k - 1, 3) + c1 * Math.pow(k - 1, 2); };
39 function tween(dur, fn, ease) {
40 return new Promise((res) => tweens.push({ t: 0, dur, fn, res, ease: ease || easeInOut }));
41 }
42 // engine square -> canvas px (center)
43 function sqPx(x, y) { // x = col, y = row (floats, engine space)
44 const vx = flip ? 7 - x : x, vy = flip ? 7 - y : y;
45 return { x: OX + (vx + 0.5) * CELL, y: OY + (vy + 0.5) * CELL };
46 }
47 function pxSq(px, py) {
48 const vc = Math.floor((px - OX) / CELL), vr = Math.floor((py - OY) / CELL);
49 if (vc < 0 || vr < 0 || vc > 7 || vr > 7) return -1;
50 return flip ? (7 - vr) * 8 + (7 - vc) : vr * 8 + vc;
51 }
52 const humanSide = () => cfg.human;
53 const bottomSide = () => (flip ? BLACK : RED);
54 const sideName = (s) => (s === BLACK ? "Black" : "Red");
55
56 // ---------- sound ----------
57 let actx = null;
58 function beep(freq, dur, type, vol, slideTo, delay) {
59 if (!cfg.sound) return;
60 try {
61 if (!actx) actx = new (window.AudioContext || window.webkitAudioContext)();
62 if (actx.state === "suspended") actx.resume();
63 const t0 = actx.currentTime + (delay || 0), o = actx.createOscillator(), g = actx.createGain();
64 o.type = type || "sine"; o.frequency.setValueAtTime(freq, t0);
65 if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, t0 + dur);
66 g.gain.setValueAtTime(vol || 0.08, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
67 o.connect(g); g.connect(actx.destination); o.start(t0); o.stop(t0 + dur + 0.02);
68 } catch (e) {}
69 }
70 const sfx = {
71 move() { beep(300, 0.09, "triangle", 0.1, 180); },
72 hop() { beep(520, 0.1, "square", 0.05, 260); },
73 capture() { beep(200, 0.16, "sawtooth", 0.07, 70); beep(760, 0.08, "triangle", 0.05, 380, 0.03); },
74 king() { [523, 659, 784, 1047].forEach((f, i) => beep(f, 0.18, "triangle", 0.08, 0, i * 0.07)); },
75 win() { [523, 659, 784, 1047, 1319].forEach((f, i) => beep(f, 0.3, "triangle", 0.09, 0, i * 0.11)); },
76 lose() { [392, 330, 262].forEach((f, i) => beep(f, 0.3, "sine", 0.09, 0, i * 0.16)); },
77 bad() { beep(140, 0.16, "square", 0.05, 90); },
78 tick() { beep(660, 0.05, "sine", 0.05); },
79 };
80
81 // ---------- board image (procedural wood) ----------
82 function mulberry(seed) { return () => { seed = (seed + 0x6D2B79F5) | 0; let x = Math.imul(seed ^ (seed >>> 15), 1 | seed); x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; return ((x ^ (x >>> 14)) >>> 0) / 4294967296; }; }
83 function woodRect(g, x, y, w, h, base, dark, light, rand, lines, amp, vertical) {
84 g.save(); g.beginPath(); g.rect(x, y, w, h); g.clip();
85 const bg = g.createLinearGradient(x, y, x + (vertical ? w : 0), y + (vertical ? 0 : h));
86 bg.addColorStop(0, base[0]); bg.addColorStop(1, base[1]); g.fillStyle = bg; g.fillRect(x, y, w, h);
87 for (let i = 0; i < lines; i++) {
88 const p = rand() * 6.28, f = 0.015 + rand() * 0.035, a = amp * (0.4 + rand() * 0.9), off = ((i + rand()) / lines) * (vertical ? w : h);
89 g.beginPath();
90 const len = vertical ? h : w;
91 for (let s = -4; s <= len + 4; s += 4) {
92 const wob = Math.sin(s * f + p) * a + Math.sin(s * f * 2.7 + p * 1.7) * a * 0.3;
93 if (vertical) g.lineTo(x + off + wob, y + s); else g.lineTo(x + s, y + off + wob);
94 }
95 g.lineWidth = 0.4 + rand() * 1.4;
96 g.strokeStyle = rand() < 0.75 ? `rgba(${dark},${0.05 + rand() * 0.14})` : `rgba(${light},${0.05 + rand() * 0.1})`;
97 g.stroke();
98 }
99 if (rand() < 0.22) { // a knot
100 const kx = x + rand() * w, ky = y + rand() * h;
101 for (let k = 5; k >= 1; k--) { g.beginPath(); g.ellipse(kx, ky, k * 3.2, k * 1.7, 0, 0, 6.28); g.strokeStyle = `rgba(${dark},${0.05 + (5 - k) * 0.03})`; g.lineWidth = 0.8; g.stroke(); }
102 }
103 g.restore();
104 }
105 let boardImg = null;
106 function makeBoard() {
107 const S = 2, BW = N * CELL, T = BW + FRAME * 2;
108 const cv = document.createElement("canvas"); cv.width = T * S; cv.height = T * S;
109 const g = cv.getContext("2d"); g.scale(S, S);
110 const rand = mulberry(7);
111 // frame
112 g.save(); g.beginPath(); g.roundRect(0, 0, T, T, 18); g.clip();
113 woodRect(g, 0, 0, T, T, ["#6b4426", "#3f2612"], "20,10,2", "255,200,140", rand, 70, 2.2, false);
114 g.restore();
115 g.lineWidth = 2; g.strokeStyle = "rgba(255,225,170,.35)"; g.beginPath(); g.roundRect(1.5, 1.5, T - 3, T - 3, 17); g.stroke();
116 g.strokeStyle = "rgba(0,0,0,.55)"; g.beginPath(); g.roundRect(3.5, 3.5, T - 7, T - 7, 15); g.stroke();
117 g.strokeStyle = "rgba(244,201,93,.5)"; g.lineWidth = 1.2; g.strokeRect(FRAME - 5, FRAME - 5, BW + 10, BW + 10);
118 // squares
119 for (let r = 0; r < N; r++) for (let col = 0; col < N; col++) {
120 const x = FRAME + col * CELL, y = FRAME + r * CELL, dark = (r + col) % 2 === 1, j = rand() * 14 - 7;
121 if (dark) woodRect(g, x, y, CELL, CELL, [`rgb(${96 + j},${56 + j * 0.6},${32 + j * 0.4})`, `rgb(${70 + j},${38 + j * 0.5},${20 + j * 0.3})`], "15,5,0", "230,160,100", rand, 16, 2.2, rand() < 0.5);
122 else woodRect(g, x, y, CELL, CELL, [`rgb(${244 + j * 0.5},${222 + j * 0.6},${172 + j})`, `rgb(${226 + j * 0.5},${196 + j * 0.6},${140 + j})`], "150,90,30", "255,255,235", rand, 12, 2.0, rand() < 0.5);
123 g.fillStyle = "rgba(255,255,255,.12)"; g.fillRect(x, y, CELL, 1); g.fillRect(x, y, 1, CELL);
124 g.fillStyle = "rgba(0,0,0,.14)"; g.fillRect(x, y + CELL - 1, CELL, 1); g.fillRect(x + CELL - 1, y, 1, CELL);
125 }
126 // inner shadow at the recess edge
127 const sh = 9;
128 const edge = (x, y, w, h, x2, y2) => { const lg = g.createLinearGradient(x, y, x2, y2); lg.addColorStop(0, "rgba(0,0,0,.45)"); lg.addColorStop(1, "rgba(0,0,0,0)"); g.fillStyle = lg; g.fillRect(x, y, w, h); };
129 edge(FRAME, FRAME, BW, sh, FRAME, FRAME + sh);
130 edge(FRAME, FRAME, sh, BW, FRAME + sh, FRAME);
131 const lg2 = g.createLinearGradient(0, FRAME + BW - 5, 0, FRAME + BW); lg2.addColorStop(0, "rgba(0,0,0,0)"); lg2.addColorStop(1, "rgba(0,0,0,.25)"); g.fillStyle = lg2; g.fillRect(FRAME, FRAME + BW - 5, BW, 5);
132 // brass studs
133 for (const [x, y] of [[11, 11], [T - 11, 11], [11, T - 11], [T - 11, T - 11]]) {
134 const rg = g.createRadialGradient(x - 1.5, y - 1.5, 0.5, x, y, 5); rg.addColorStop(0, "#fff2b0"); rg.addColorStop(1, "#a8761a");
135 g.fillStyle = rg; g.beginPath(); g.arc(x, y, 4.5, 0, 6.28); g.fill();
136 }
137 boardImg = cv;
138 }
139
140 // ---------- disc drawing ----------
141 const PAL = {
142 [RED]: { hi: "#ff8a78", mid: "#d8281f", lo: "#8b0e0e", edge: "#5c0808", edgeHi: "#b31c1c", ring: "rgba(255,210,190,.35)" },
143 [BLACK]: { hi: "#7c8092", mid: "#2b2c35", lo: "#0d0d11", edge: "#040406", edgeHi: "#2a2b33", ring: "rgba(170,190,255,.22)" },
144 };
145 function crownPath(x, y, s) {
146 ctx.beginPath();
147 ctx.moveTo(x - s, y + s * 0.55); ctx.lineTo(x - s * 1.05, y - s * 0.55); ctx.lineTo(x - s * 0.5, y - s * 0.05); ctx.lineTo(x, y - s * 0.75);
148 ctx.lineTo(x + s * 0.5, y - s * 0.05); ctx.lineTo(x + s * 1.05, y - s * 0.55); ctx.lineTo(x + s, y + s * 0.55); ctx.closePath();
149 }
150 function drawDisc(x, y, side, king, o) {
151 o = o || {};
152 const lift = o.lift || 0, sc = (o.scale || 1), r = R * sc, th = (king ? 7 : 4.5) * sc, p = PAL[side];
153 const fy = y - lift - th * 0.5; // top face center
154 ctx.save();
155 if (o.alpha != null) ctx.globalAlpha = o.alpha;
156 // shadow
157 ctx.fillStyle = `rgba(0,0,0,${0.32 - lift * 0.005})`;
158 ctx.beginPath(); ctx.ellipse(x + 2 + lift * 0.4, y + th * 0.6 + 3 + lift * 0.9, r * 1.02, r * 0.86, 0, 0, 6.28); ctx.fill();
159 // edge (cylinder side)
160 const eg = ctx.createLinearGradient(x - r, 0, x + r, 0); eg.addColorStop(0, p.edgeHi); eg.addColorStop(0.5, p.edge); eg.addColorStop(1, p.edgeHi);
161 ctx.fillStyle = eg; ctx.beginPath(); ctx.arc(x, fy + th, r, 0, Math.PI); ctx.lineTo(x - r, fy); ctx.lineTo(x + r, fy); ctx.closePath(); ctx.fill();
162 ctx.beginPath(); ctx.arc(x, fy + th, r, 0, 6.28); ctx.fill();
163 // top face
164 const fg = ctx.createRadialGradient(x - r * 0.35, fy - r * 0.4, r * 0.1, x, fy, r * 1.05);
165 fg.addColorStop(0, p.hi); fg.addColorStop(0.55, p.mid); fg.addColorStop(1, p.lo);
166 ctx.fillStyle = fg; ctx.beginPath(); ctx.arc(x, fy, r, 0, 6.28); ctx.fill();
167 ctx.lineWidth = 1.2; ctx.strokeStyle = "rgba(0,0,0,.45)"; ctx.stroke();
168 // grooves
169 ctx.strokeStyle = p.ring; ctx.lineWidth = 1.4;
170 ctx.beginPath(); ctx.arc(x, fy, r * 0.8, 0, 6.28); ctx.stroke();
171 ctx.strokeStyle = "rgba(0,0,0,.28)"; ctx.beginPath(); ctx.arc(x, fy, r * 0.62, 0, 6.28); ctx.stroke();
172 ctx.strokeStyle = p.ring; ctx.beginPath(); ctx.arc(x, fy, r * 0.6, 0.4, 5.5); ctx.stroke();
173 if (king) {
174 const cs = r * 0.42 * (o.crown == null ? 1 : Math.max(0.01, o.crown));
175 const cy = fy - (o.crown != null && o.crown < 1 ? (1 - o.crown) * 22 : 0);
176 ctx.globalAlpha = (o.alpha == null ? 1 : o.alpha) * (o.crown == null ? 1 : Math.min(1, o.crown * 1.6));
177 crownPath(x, cy, cs);
178 const gg = ctx.createLinearGradient(0, cy - cs, 0, cy + cs); gg.addColorStop(0, "#fff3a8"); gg.addColorStop(0.5, "#f2b632"); gg.addColorStop(1, "#b97a0d");
179 ctx.shadowColor = "rgba(0,0,0,.5)"; ctx.shadowBlur = 3; ctx.shadowOffsetY = 1.5;
180 ctx.fillStyle = gg; ctx.fill(); ctx.shadowColor = "transparent"; ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
181 ctx.lineWidth = 1.1; ctx.strokeStyle = "#7a4d00"; ctx.stroke();
182 ctx.fillStyle = "#fff8d0";
183 for (const [dx, dy] of [[-1.05, -0.55], [0, -0.75], [1.05, -0.55]]) { ctx.beginPath(); ctx.arc(x + dx * cs, cy + dy * cs, cs * 0.16, 0, 6.28); ctx.fill(); }
184 ctx.fillStyle = "rgba(122,77,0,.55)"; ctx.fillRect(x - cs, cy + cs * 0.22, cs * 2, cs * 0.12);
185 ctx.globalAlpha = o.alpha == null ? 1 : o.alpha;
186 }
187 // specular highlight
188 ctx.save(); ctx.beginPath(); ctx.arc(x, fy, r, 0, 6.28); ctx.clip();
189 const sg = ctx.createLinearGradient(x - r * 0.6, fy - r, x + r * 0.2, fy + r * 0.1);
190 sg.addColorStop(0, "rgba(255,255,255,.55)"); sg.addColorStop(1, "rgba(255,255,255,0)");
191 ctx.fillStyle = sg; ctx.beginPath(); ctx.ellipse(x - r * 0.28, fy - r * 0.5, r * 0.58, r * 0.3, -0.5, 0, 6.28); ctx.fill();
192 ctx.restore();
193 ctx.restore();
194 }
195
196 // ---------- sprites & effects ----------
197 function newSprite(p, sq) { return { side: p > 0 ? BLACK : RED, king: Math.abs(p) === 2, x: sq & 7, y: sq >> 3, z: 0, scale: 1, crown: null, alpha: 1 }; }
198 function syncSprites() { sprites = new Array(64).fill(null); for (let i = 0; i < 64; i++) if (st.b[i]) sprites[i] = newSprite(st.b[i], i); }
199 function burst(px, py, colors, n, spd) {
200 for (let i = 0; i < n; i++) { const a = rnd(0, 6.28), s = rnd(40, spd || 200); particles.push({ x: px, y: py, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 60, life: rnd(0.5, 1), max: 1, color: colors[(Math.random() * colors.length) | 0], size: rnd(2.5, 6), g: 500 }); }
201 }
202 function killSprite(sq) {
203 const sp = sprites[sq]; if (!sp) return;
204 sprites[sq] = null;
205 const p = sqPx(sp.x, sp.y), pal = PAL[sp.side];
206 burst(p.x, p.y, [pal.hi, pal.mid, pal.lo, "#fff"], 22, 240);
207 rings.push({ x: p.x, y: p.y, life: 0.45, max: 0.45, color: sp.side === RED ? "255,120,100" : "190,200,255" });
208 sfx.capture();
209 }
210 async function animStep(sp, from, to, capSq) {
211 busy++;
212 const jump = capSq >= 0, fx = sp.x, fy = sp.y, tx = to & 7, ty = to >> 3;
213 sprites[from] = null; sprites[to] = sp; let killed = false;
214 if (jump) sfx.hop(); else sfx.move();
215 await tween(jump ? 320 : 230, (k) => {
216 sp.x = fx + (tx - fx) * k; sp.y = fy + (ty - fy) * k;
217 sp.z = Math.sin(k * Math.PI) * (jump ? 16 : 5); sp.scale = 1 + Math.sin(k * Math.PI) * (jump ? 0.08 : 0.02);
218 if (jump && !killed && k > 0.5) { killed = true; killSprite(capSq); }
219 });
220 sp.x = tx; sp.y = ty; sp.z = 0; sp.scale = 1; busy--;
221 }
222 async function promoteAnim(sq) {
223 const sp = sprites[sq]; if (!sp) return;
224 busy++; sp.king = true; sp.crown = 0; sfx.king();
225 const p = sqPx(sp.x, sp.y);
226 burst(p.x, p.y - 10, ["#fff3a8", "#f2b632", "#ffffff"], 26, 220);
227 rings.push({ x: p.x, y: p.y, life: 0.6, max: 0.6, color: "255,225,120" });
228 await tween(520, (k) => { sp.crown = easeOutBack(k); sp.scale = 1 + Math.sin(k * Math.PI) * 0.16; });
229 sp.crown = null; sp.scale = 1; busy--;
230 }
231 function popup(text, x, y, color) { popups.push({ text, x, y, life: 1.1, color: color || "#fff" }); }
232 function flash(text) { flashText = text; flashT = 1.6; shakeT = 0.35; sfx.bad(); }
233
234 // ---------- game flow ----------
235 function startGame() {
236 if (cancelThink) { cancelThink(); cancelThink = null; }
237 thinking = false; busy = 0; tweens.length = 0;
238 flip = cfg.mode === "ai" && cfg.human === BLACK;
239 st = E.newState(); legal = E.genMoves(st); hashes = [E.keyOf(st)]; undoStack = []; lastMove = null; humanMoves = 0;
240 over = null; submitted = false; sel = null; partial = null; drag = null; particles = []; confetti = []; popups = []; rings = [];
241 syncSprites(); scr = "play"; gameStarted = true; restartArmed = 0; kc = null;
242 turnStart();
243 }
244 function isAiTurn() { return cfg.mode === "ai" && st.turn !== cfg.human; }
245 function turnStart() {
246 legal = E.genMoves(st);
247 if (isAiTurn()) think();
248 }
249 function think() {
250 thinking = true;
251 const lvl = E.LEVELS[cfg.level];
252 cancelThink = E.chooseMoveAsync(st, lvl, hashes, async (r) => {
253 thinking = false; cancelThink = null;
254 if (!r || !r.move) return;
255 await playMove(r.move, 0);
256 }, { isPaused: () => paused });
257 }
258 async function playMove(m, fromStep) {
259 // animate the remaining jumps/steps of a whole move, then commit
260 const sp = sprites[m.path[fromStep]];
261 busy++;
262 for (let k = fromStep; k < m.path.length - 1; k++) await animStep(sprites[m.path[k]], m.path[k], m.path[k + 1], m.caps.length ? m.caps[k] : -1);
263 busy--;
264 await commit(m);
265 void sp;
266 }
267 async function commit(m) {
268 busy++;
269 undoStack.push({ st, lastMove, hlen: hashes.length, hm: humanMoves });
270 const mover = st.turn;
271 st = E.applyMove(st, m); hashes.push(E.keyOf(st)); lastMove = m;
272 if (cfg.mode === "ai" && mover === cfg.human) humanMoves++;
273 partial = null; sel = null;
274 if (m.promo) await promoteAnim(m.to);
275 busy--;
276 legal = E.genMoves(st);
277 const res = E.gameResult(st, hashes, legal);
278 if (res.over) endGame(res); else turnStart();
279 }
280 function canUndo() {
281 if (over || !undoStack.length || busy) return false;
282 return cfg.mode === "pvp" || undoStack.some((u) => u.st.turn === cfg.human);
283 }
284 function undo() {
285 if (scr !== "play" || !canUndo()) { if (scr === "play") sfx.bad(); return; }
286 if (cancelThink) { cancelThink(); cancelThink = null; } thinking = false;
287 let u = undoStack.pop();
288 if (cfg.mode === "ai") while (u.st.turn !== cfg.human && undoStack.length) u = undoStack.pop();
289 st = u.st; lastMove = u.lastMove; hashes.length = u.hlen; humanMoves = u.hm;
290 sel = null; partial = null; drag = null; tweens.length = 0; busy = 0;
291 syncSprites(); legal = E.genMoves(st); sfx.tick();
292 if (isAiTurn()) think();
293 }
294 function scoreFor(res) {
295 const lvl = cfg.level, base = [300, 700, 1500, 3000][lvl];
296 if (res.winner === 0) return Math.round(base * 0.2);
297 if (res.winner !== cfg.human) return 0;
298 const n = E.counts(st), mine = cfg.human === BLACK ? n.black : n.red;
299 return base + Math.max(0, 50 - humanMoves) * [4, 8, 15, 30][lvl] + mine * [10, 20, 40, 80][lvl];
300 }
301 function endGame(res) {
302 over = res; overAt = t + 1.1; sel = null; partial = null;
303 const humanWon = cfg.mode === "ai" && res.winner === cfg.human, aiWon = cfg.mode === "ai" && res.winner === -cfg.human;
304 if (res.winner === 0) { sfx.tick(); }
305 else if (cfg.mode === "pvp" || humanWon) { sfx.win(); celebrate(res.winner); }
306 else sfx.lose();
307 if (cfg.mode === "ai") {
308 const score = scoreFor(res); over.score = score;
309 if (humanWon) { stats.wins[cfg.level]++; }
310 if (score > stats.best[cfg.level]) stats.best[cfg.level] = score;
311 saveStats();
312 if (!submitted) { submitted = true; try { if (window.Arcadia) { Arcadia.submitScore(score); Arcadia.gameOver(); } } catch (e) {} }
313 }
314 void aiWon;
315 }
316 function celebrate(winner) { celeb = { until: t + 4.5, winner }; }
317 let celeb = null;
318
319 // ---------- input ----------
320 const dests = () => {
321 const k = partial ? partial.k : 0, cands = partial ? partial.cands : sel != null ? legal.filter((m) => m.from === sel) : [];
322 const map = new Map();
323 for (const m of cands) if (m.path.length > k + 1) { const d = m.path[k + 1]; if (!map.has(d)) map.set(d, []); map.get(d).push(m); }
324 return map;
325 };
326 const forced = () => legal.length > 0 && legal[0].caps.length > 0;
327 const movableSet = () => new Set(partial ? [partial.sq] : legal.map((m) => m.from));
328 function canAct() { return scr === "play" && !over && !busy && !thinking && st && (cfg.mode === "pvp" || st.turn === cfg.human); }
329
330 async function step(dest) {
331 const k = partial ? partial.k : 0, from = partial ? partial.sq : sel;
332 const cands = (partial ? partial.cands : legal.filter((m) => m.from === from)).filter((m) => m.path.length > k + 1 && m.path[k + 1] === dest);
333 if (!cands.length) return;
334 const m0 = cands[0], sp = sprites[from], capSq = m0.caps.length ? m0.caps[k] : -1;
335 const trail = partial ? partial.trail.slice() : [from];
336 trail.push(dest); sel = null; drag = null;
337 busy++;
338 await animStep(sp, from, dest, capSq);
339 busy--;
340 if (m0.path.length === k + 2) { await commit(m0); }
341 else { partial = { sq: dest, k: k + 1, cands, trail }; sel = dest; sfx.tick(); }
342 }
343 async function returnHome(sq) {
344 const sp = sprites[sq]; if (!sp) return;
345 const fx = sp.x, fy = sp.y; busy++; sp.drag = false;
346 await tween(140, (k) => { sp.x = fx + ((sq & 7) - fx) * k; sp.y = fy + ((sq >> 3) - fy) * k; });
347 busy--;
348 }
349 function tapCell(sq, wasSel) {
350 if (!canAct() || sq < 0) return;
351 const d = dests();
352 if (sel != null && d.has(sq)) { step(sq); return; }
353 const p = st.b[sq];
354 if (p * st.turn > 0) {
355 if (partial) { flash("Keep jumping with the same piece!"); return; }
356 if (movableSet().has(sq)) { sel = wasSel ? null : sq; if (sel != null) sfx.tick(); }
357 else { sel = null; pulseT = 1.2; flash(forced() ? "Capture is mandatory! Use a glowing piece." : "That piece is blocked."); }
358 } else if (partial) flash("Keep jumping with the same piece!");
359 else sel = null;
360 }
361
362 let ptr = { x: -1, y: -1 }, pressed = null, hits = [];
363 function pt(e) { const r = c.getBoundingClientRect(); return { x: ((e.clientX - r.left) / r.width) * W, y: ((e.clientY - r.top) / r.height) * H }; }
364 const inRect = (p, h) => p.x >= h.x && p.x <= h.x + h.w && p.y >= h.y && p.y <= h.y + h.h;
365 c.addEventListener("pointerdown", (e) => {
366 ptr = pt(e); c.focus();
367 try { if (actx && actx.state === "suspended") actx.resume(); else if (!actx && cfg.sound) actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (er) {}
368 for (let i = hits.length - 1; i >= 0; i--) if (inRect(ptr, hits[i]) && !hits[i].disabled) { pressed = hits[i]; return; }
369 pressed = null;
370 if (scr === "play" && canAct()) {
371 const sq = pxSq(ptr.x, ptr.y);
372 if (sq >= 0 && st.b[sq] * st.turn > 0 && movableSet().has(sq) && (!partial || partial.sq === sq)) {
373 const wasSel = sel === sq; if (!partial) sel = sq;
374 drag = { sq, x0: ptr.x, y0: ptr.y, active: false, wasSel };
375 try { c.setPointerCapture(e.pointerId); } catch (er) {}
376 } else drag = { sq, x0: ptr.x, y0: ptr.y, active: false, tap: true };
377 }
378 });
379 c.addEventListener("pointermove", (e) => {
380 ptr = pt(e);
381 if (drag && !drag.tap) {
382 if (!drag.active && Math.hypot(ptr.x - drag.x0, ptr.y - drag.y0) > 9) { drag.active = true; if (!partial) sel = drag.sq; const sp = sprites[drag.sq]; if (sp) sp.drag = true; }
383 }
384 });
385 c.addEventListener("pointerup", (e) => {
386 ptr = pt(e);
387 if (pressed) { const h = pressed; pressed = null; if (inRect(ptr, h) && !h.disabled) h.fn(); drag = null; return; }
388 if (!drag) return;
389 const d = drag; drag = null;
390 if (d.tap) { tapCell(pxSq(ptr.x, ptr.y), false); return; }
391 const sp = sprites[d.sq];
392 if (d.active && sp) {
393 const target = pxSq(ptr.x, ptr.y);
394 // put the sprite where the finger is, then glide to the destination or home
395 const vx = (ptr.x - OX) / CELL - 0.5, vy = (ptr.y - OY) / CELL - 0.5;
396 sp.x = flip ? 7 - vx : vx; sp.y = flip ? 7 - vy : vy; sp.drag = false;
397 if (sel != null && dests().has(target)) { sel = partial ? partial.sq : d.sq; step(target); }
398 else { returnHome(d.sq); }
399 } else tapCell(d.sq, d.wasSel);
400 });
401 c.addEventListener("pointercancel", () => { if (drag && drag.active) returnHome(drag.sq); drag = null; pressed = null; });
402 addEventListener("keydown", (e) => {
403 const k = e.key;
404 if (scr === "menu") { if (k === "Enter" || k === " ") { e.preventDefault(); startGame(); } return; }
405 if (k === "u" || k === "U") undo();
406 else if (k === "n" || k === "N") startGame();
407 else if (k === "Escape") toMenu();
408 else if (k === "m" || k === "M") { cfg.sound = !cfg.sound; saveCfg(); }
409 else if (k.startsWith("Arrow")) {
410 e.preventDefault(); if (!kc) kc = { r: 5, c: 0 };
411 const dv = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] }[k];
412 kc.r = Math.max(0, Math.min(7, kc.r + dv[0])); kc.c = Math.max(0, Math.min(7, kc.c + dv[1]));
413 } else if ((k === "Enter" || k === " ") && kc) { e.preventDefault(); const sq = flip ? (7 - kc.r) * 8 + (7 - kc.c) : kc.r * 8 + kc.c; tapCell(sq, false); }
414 });
415 if (window.Arcadia) { try { Arcadia.onPause(() => (paused = true)); Arcadia.onResume(() => (paused = false)); } catch (e) {} }
416 document.addEventListener("visibilitychange", () => {});
417 function toMenu() { if (cancelThink && !gameStarted) { cancelThink(); } scr = "menu"; sel = null; drag = null; }
418
419 // ---------- UI drawing helpers ----------
420 function rr(x, y, w, h, r) { ctx.beginPath(); ctx.roundRect(x, y, w, h, r); }
421 function text(str, x, y, size, color, weight, align, spacing) {
422 ctx.font = `${weight || 700} ${size}px system-ui, -apple-system, "Segoe UI", sans-serif`;
423 ctx.fillStyle = color || "#fff"; ctx.textAlign = align || "center"; ctx.textBaseline = "middle";
424 try { ctx.letterSpacing = (spacing || 0) + "px"; } catch (e) {}
425 ctx.fillText(str, x, y);
426 try { ctx.letterSpacing = "0px"; } catch (e) {}
427 }
428 function button(x, y, w, h, label, fn, o) {
429 o = o || {};
430 const hit = { x, y, w, h, fn, disabled: !!o.disabled }; hits.push(hit);
431 const hover = inRect(ptr, hit) && !o.disabled, down = pressed && pressed.x === x && pressed.y === y && hover;
432 const active = !!o.active, dy = down ? 2 : 0;
433 ctx.save();
434 ctx.globalAlpha = o.disabled ? 0.4 : 1;
435 ctx.fillStyle = "rgba(0,0,0,.4)"; rr(x, y + 4, w, h, h * 0.28); ctx.fill();
436 const g = ctx.createLinearGradient(0, y, 0, y + h);
437 if (o.primary) { g.addColorStop(0, "#ffe089"); g.addColorStop(1, "#d9962b"); }
438 else if (active) { g.addColorStop(0, "#8a5a32"); g.addColorStop(1, "#5c3a1e"); }
439 else { g.addColorStop(0, hover ? "#5d3d24" : "#4b2f1b"); g.addColorStop(1, hover ? "#3f2714" : "#33200f"); }
440 ctx.fillStyle = g; rr(x, y + dy, w, h, h * 0.28); ctx.fill();
441 ctx.lineWidth = active ? 2.5 : 1.5; ctx.strokeStyle = active ? GOLD : o.primary ? "#fff1bd" : "rgba(255,225,170,.28)"; ctx.stroke();
442 ctx.fillStyle = "rgba(255,255,255,.12)"; rr(x + 3, y + dy + 2, w - 6, h * 0.42, h * 0.22); ctx.fill();
443 if (o.icon) o.icon(x + h * 0.55, y + h / 2 + dy);
444 text(label, x + w / 2 + (o.icon ? h * 0.22 : 0), y + h / 2 + dy + 1, o.size || 20, o.primary ? "#3b2107" : active ? "#fff4d6" : CREAM, 800);
445 ctx.restore();
446 }
447 function miniDisc(x, y, side, r) {
448 const p = PAL[side], g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.4, r * 0.1, x, y, r * 1.05);
449 g.addColorStop(0, p.hi); g.addColorStop(0.55, p.mid); g.addColorStop(1, p.lo);
450 ctx.fillStyle = "rgba(0,0,0,.35)"; ctx.beginPath(); ctx.arc(x + 1, y + 2, r, 0, 6.28); ctx.fill();
451 ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, 6.28); ctx.fill(); ctx.strokeStyle = "rgba(0,0,0,.5)"; ctx.lineWidth = 1; ctx.stroke();
452 }
453
454 function drawBackdrop() {
455 const bg = ctx.createLinearGradient(0, 0, 0, H); bg.addColorStop(0, "#2c190d"); bg.addColorStop(0.5, "#22130a"); bg.addColorStop(1, "#1a0e06");
456 ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
457 // faint felt-like radial light
458 const rg = ctx.createRadialGradient(W / 2, 430, 40, W / 2, 430, 560); rg.addColorStop(0, "rgba(255,190,110,.16)"); rg.addColorStop(1, "rgba(0,0,0,0)");
459 ctx.fillStyle = rg; ctx.fillRect(0, 0, W, H);
460 }
461
462 // ---------- menu ----------
463 function drawMenu() {
464 drawBackdrop();
465 // decorative discs
466 const xs = [70, 150, 230, 310, 390, 470];
467 xs.forEach((x, i) => drawDisc(x, 262 + Math.sin(t * 2 + i) * 3, i % 2 ? RED : BLACK, i === 2 || i === 3, { lift: 3 + Math.sin(t * 2 + i) * 3 }));
468 text("CHECKERS", W / 2, 96, 70, GOLD, 900, "center", 4);
469 ctx.save(); ctx.shadowColor = "rgba(0,0,0,.6)"; ctx.shadowBlur = 8; text("CLASSIC", W / 2, 158, 30, CREAM, 800, "center", 14); ctx.restore();
470 text("MODE", 30, 342, 14, "#c9a878", 800, "left", 3);
471 button(30, 356, 235, 54, "vs Computer", () => { cfg.mode = "ai"; saveCfg(); }, { active: cfg.mode === "ai" });
472 button(275, 356, 235, 54, "2 Players", () => { cfg.mode = "pvp"; saveCfg(); }, { active: cfg.mode === "pvp" });
473 if (cfg.mode === "ai") {
474 text("YOU PLAY", 30, 446, 14, "#c9a878", 800, "left", 3);
475 button(30, 460, 235, 54, "Black", () => { cfg.human = BLACK; saveCfg(); }, { active: cfg.human === BLACK, icon: (x, y) => miniDisc(x, y, BLACK, 13) });
476 button(275, 460, 235, 54, "Red", () => { cfg.human = RED; saveCfg(); }, { active: cfg.human === RED, icon: (x, y) => miniDisc(x, y, RED, 13) });
477 text("Black always moves first", W / 2, 534, 14, "#a58a68", 600);
478 text("DIFFICULTY", 30, 570, 14, "#c9a878", 800, "left", 3);
479 E.LEVELS.forEach((l, i) => button(30 + i * 122, 584, 116, 50, l.name, () => { cfg.level = i; saveCfg(); }, { active: cfg.level === i, size: 17 }));
480 text(`Wins ${stats.wins[cfg.level]} · Best score ${stats.best[cfg.level]}`, W / 2, 668, 15, "#c9a878", 700);
481 } else {
482 text("Pass the device back and forth.", W / 2, 490, 18, CREAM, 700);
483 text("Black moves first, Red answers.", W / 2, 522, 15, "#a58a68", 600);
484 }
485 button(80, 704, 380, 76, gameStarted && scr === "menu" && over === null && st && undoStack.length ? "Restart Game" : "Play", startGame, { primary: true, size: 32 });
486 if (gameStarted && over === null && st && undoStack.length) button(150, 796, 240, 44, "Resume game", () => (scr = "play"), { size: 17 });
487 else text("Capture is mandatory · Kings move both ways", W / 2, 818, 14, "#8f7656", 600);
488 }
489
490 // ---------- play screen ----------
491 function drawChip(x, y, w, h, side, active) {
492 const n = E.counts(st), mine = side === BLACK ? n.black : n.red, theirs = side === BLACK ? n.red : n.black, kings = side === BLACK ? n.blackKings : n.redKings;
493 ctx.save();
494 if (active && !over) { ctx.shadowColor = GOLD; ctx.shadowBlur = 16 + Math.sin(t * 5) * 5; }
495 const g = ctx.createLinearGradient(0, y, 0, y + h); g.addColorStop(0, active ? "#6a4527" : "#43291a"); g.addColorStop(1, active ? "#4a2e17" : "#2e1c10");
496 ctx.fillStyle = g; rr(x, y, w, h, 16); ctx.fill(); ctx.shadowBlur = 0;
497 ctx.lineWidth = active && !over ? 2.5 : 1.2; ctx.strokeStyle = active && !over ? GOLD : "rgba(255,225,170,.22)"; ctx.stroke();
498 ctx.restore();
499 drawDisc(x + 34, y + 30, side, false, { scale: 0.72 });
500 let label = sideName(side);
501 if (cfg.mode === "ai") label = side === cfg.human ? "You" : "Computer";
502 text(label, x + 66, y + 20, 19, CREAM, 800, "left");
503 text(`${mine} left${kings ? ` · ${kings} king${kings > 1 ? "s" : ""}` : ""}`, x + 66, y + 41, 13, "#c9a878", 600, "left");
504 // captured pieces
505 const cap = 12 - theirs;
506 for (let i = 0; i < Math.min(12, cap); i++) miniDisc(x + w - 14 - (Math.min(12, cap) - 1 - i) * 6.5, y + 17, -side, 5.5);
507 }
508 function statusInfo() {
509 if (over) {
510 if (over.winner === 0) return ["Draw by " + over.reason, GOLD];
511 if (cfg.mode === "ai") return [over.winner === cfg.human ? "You win!" : "Computer wins", over.winner === cfg.human ? "#8ff0a4" : "#ff9c8f"];
512 return [sideName(over.winner) + " wins!", GOLD];
513 }
514 if (isAiTurn()) return [thinking ? "Computer is thinking" + ".".repeat(1 + ((t * 3) | 0) % 3) : "Computer moves", "#e8d2ac"];
515 if (flashT > 0) return [flashText, "#ffb08a"];
516 if (partial) return ["Keep jumping! Take the next piece", GOLD];
517 const who = cfg.mode === "ai" ? "Your move" : sideName(st.turn) + "'s move";
518 if (forced()) return ["Capture required! " + (cfg.mode === "ai" ? "" : sideName(st.turn) + " must jump"), GOLD].map((v, i) => (i ? v : v.trim()));
519 return [who, "#f1e2c4"];
520 }
521 function drawPlay() {
522 drawBackdrop();
523 text("CHECKERS CLASSIC", W / 2, 26, 18, GOLD, 900, "center", 5);
524 const bs = bottomSide(), ts = -bs;
525 drawChip(14, 48, 250, 66, bs, st.turn === bs);
526 drawChip(276, 48, 250, 66, ts, st.turn === ts);
527 // status banner
528 const [msg, col] = statusInfo();
529 const sx = shakeT > 0 ? Math.sin(t * 60) * 5 * shakeT : 0;
530 ctx.fillStyle = "rgba(0,0,0,.28)"; rr(60 + sx, 128, W - 120, 36, 18); ctx.fill();
531 text(msg, W / 2 + sx, 147, 19, col, 800);
532
533 ctx.save();
534 const bx = OX - FRAME, by = OY - FRAME;
535 ctx.shadowColor = "rgba(0,0,0,.6)"; ctx.shadowBlur = 22; ctx.shadowOffsetY = 10;
536 ctx.drawImage(boardImg, bx, by, N * CELL + FRAME * 2, N * CELL + FRAME * 2);
537 ctx.restore();
538
539 // last move highlight
540 if (lastMove) {
541 for (const s of lastMove.path) {
542 const p = sqPx(s & 7, s >> 3);
543 ctx.fillStyle = s === lastMove.from || s === lastMove.to ? "rgba(255,214,90,.38)" : "rgba(255,214,90,.2)";
544 ctx.fillRect(p.x - CELL / 2, p.y - CELL / 2, CELL, CELL);
545 }
546 for (const s of lastMove.caps) { const p = sqPx(s & 7, s >> 3); ctx.strokeStyle = "rgba(255,110,90,.55)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(p.x - 9, p.y - 9); ctx.lineTo(p.x + 9, p.y + 9); ctx.moveTo(p.x + 9, p.y - 9); ctx.lineTo(p.x - 9, p.y + 9); ctx.stroke(); }
547 }
548 const canA = canAct(), d = canA || partial ? dests() : new Map(), mov = canA ? movableSet() : new Set(), isForced = canA && forced();
549 // selected square
550 const selSq = partial ? partial.sq : sel;
551 if (selSq != null && (canA || partial)) { const p = sqPx(selSq & 7, selSq >> 3); ctx.fillStyle = "rgba(120,255,160,.32)"; ctx.fillRect(p.x - CELL / 2, p.y - CELL / 2, CELL, CELL); ctx.strokeStyle = "rgba(190,255,205,.9)"; ctx.lineWidth = 2.5; ctx.strokeRect(p.x - CELL / 2 + 1.5, p.y - CELL / 2 + 1.5, CELL - 3, CELL - 3); }
552 // chain trail
553 if (partial) {
554 ctx.save(); ctx.setLineDash([7, 6]); ctx.lineDashOffset = -t * 30; ctx.strokeStyle = GOLD; ctx.lineWidth = 3; ctx.lineCap = "round"; ctx.beginPath();
555 partial.trail.forEach((s, i) => { const p = sqPx(s & 7, s >> 3); if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y); });
556 ctx.stroke(); ctx.restore();
557 }
558 // forced-capture glow under movable pieces
559 if (isForced || (partial && canA)) for (const s of mov) {
560 const sp = sprites[s]; if (!sp) continue; const p = sqPx(sp.x, sp.y), a = 0.55 + 0.35 * Math.sin(t * 6) + (pulseT > 0 ? 0.3 : 0);
561 const g = ctx.createRadialGradient(p.x, p.y, R * 0.5, p.x, p.y, R * 1.55); g.addColorStop(0, `rgba(255,214,90,${0.7 * a})`); g.addColorStop(1, "rgba(255,214,90,0)");
562 ctx.fillStyle = g; ctx.beginPath(); ctx.arc(p.x, p.y, R * 1.6, 0, 6.28); ctx.fill();
563 ctx.strokeStyle = `rgba(255,226,120,${a})`; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(p.x, p.y, R * 1.25 + Math.sin(t * 6) * 1.5, 0, 6.28); ctx.stroke();
564 }
565 // destination dots
566 for (const [ds, ms] of d) {
567 const p = sqPx(ds & 7, ds >> 3), cap = ms[0].caps.length > 0, pulse = 1 + Math.sin(t * 6) * 0.08;
568 if (cap) {
569 ctx.fillStyle = "rgba(255,190,60,.28)"; ctx.fillRect(p.x - CELL / 2, p.y - CELL / 2, CELL, CELL);
570 ctx.strokeStyle = "rgba(255,210,90,.95)"; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(p.x, p.y, 13 * pulse, 0, 6.28); ctx.stroke();
571 ctx.fillStyle = "rgba(255,210,90,.9)"; ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, 6.28); ctx.fill();
572 } else {
573 ctx.fillStyle = "rgba(120,255,160,.22)"; ctx.fillRect(p.x - CELL / 2, p.y - CELL / 2, CELL, CELL);
574 ctx.fillStyle = "rgba(190,255,205,.95)"; ctx.shadowColor = "rgba(0,0,0,.4)"; ctx.shadowBlur = 4;
575 ctx.beginPath(); ctx.arc(p.x, p.y, 9 * pulse, 0, 6.28); ctx.fill(); ctx.shadowBlur = 0;
576 }
577 }
578 // keyboard cursor
579 if (kc) { ctx.strokeStyle = "#fff"; ctx.setLineDash([5, 4]); ctx.lineWidth = 2; ctx.strokeRect(OX + kc.c * CELL + 3, OY + kc.r * CELL + 3, CELL - 6, CELL - 6); ctx.setLineDash([]); }
580
581 // pieces (top to bottom so lower pieces overlap correctly)
582 const list = [];
583 for (let i = 0; i < 64; i++) if (sprites[i]) list.push(sprites[i]);
584 list.sort((a, b) => (flip ? -(a.y + a.z * 0.01) : a.y + a.z * 0.01) - (flip ? -(b.y + b.z * 0.01) : b.y + b.z * 0.01) || 0);
585 let dragged = null;
586 for (const sp of list) {
587 if (sp.drag) { dragged = sp; continue; }
588 const p = sqPx(sp.x, sp.y);
589 const isSel = selSq != null && sprites[selSq] === sp;
590 const winPop = over && celeb && over.winner === sp.side && t < celeb.until ? Math.abs(Math.sin(t * 5 + sp.x * 1.3 + sp.y)) * 9 : 0;
591 drawDisc(p.x, p.y, sp.side, sp.king, { lift: sp.z + (isSel ? 5 : 0) + winPop, scale: sp.scale * (isSel ? 1.06 : 1), crown: sp.crown, alpha: over && over.winner !== 0 && over.winner !== sp.side ? 0.85 : 1 });
592 }
593 if (dragged) { dragged.x = flip ? 7 - ((ptr.x - OX) / CELL - 0.5) : (ptr.x - OX) / CELL - 0.5; dragged.y = flip ? 7 - ((ptr.y - OY) / CELL - 0.5) : (ptr.y - OY) / CELL - 0.5; drawDisc(ptr.x, ptr.y, dragged.side, dragged.king, { lift: 14, scale: 1.12 }); }
594 // capture target rings for the selected piece's next jump
595 for (const [, ms] of d) {
596 const k = partial ? partial.k : 0, cs = ms[0].caps.length ? ms[0].caps[k] : -1;
597 if (cs < 0 || !sprites[cs]) continue;
598 const p = sqPx(cs & 7, cs >> 3), a = 0.5 + 0.4 * Math.sin(t * 7);
599 ctx.strokeStyle = `rgba(255,90,70,${a})`; ctx.lineWidth = 3; ctx.setLineDash([6, 5]); ctx.beginPath(); ctx.arc(p.x, p.y, R * 1.15, 0, 6.28); ctx.stroke(); ctx.setLineDash([]);
600 }
601
602 // effects
603 for (const r of rings) { const k = 1 - r.life / r.max; ctx.strokeStyle = `rgba(${r.color},${(1 - k) * 0.9})`; ctx.lineWidth = 4 * (1 - k) + 1; ctx.beginPath(); ctx.arc(r.x, r.y, 10 + k * 46, 0, 6.28); ctx.stroke(); }
604 for (const p of particles) { ctx.globalAlpha = Math.max(0, p.life / p.max); ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.size * (0.5 + 0.5 * p.life / p.max), 0, 6.28); ctx.fill(); }
605 ctx.globalAlpha = 1;
606 for (const p of popups) { ctx.globalAlpha = Math.min(1, p.life * 2); text(p.text, p.x, p.y, 24, p.color, 900); }
607 ctx.globalAlpha = 1;
608
609 // bottom buttons
610 const dis = !canUndo();
611 button(20, 728, 156, 56, "Undo", undo, { disabled: dis, size: 19 });
612 button(192, 728, 156, 56, restartArmed > 0 ? "Sure?" : "New Game", () => { if (restartArmed > 0 || over || !undoStack.length) startGame(); else restartArmed = 2.2; }, { size: 19, active: restartArmed > 0 });
613 button(364, 728, 156, 56, "Menu", toMenu, { size: 19 });
614 button(W - 58, 8, 44, 34, cfg.sound ? "♫" : "✕", () => { cfg.sound = !cfg.sound; saveCfg(); }, { size: 18 });
615 text(cfg.mode === "ai" ? `${E.LEVELS[cfg.level].name} level · Black moves first` : "2-player · Black moves first", W / 2, 816, 14, "#8f7656", 600);
616
617 // celebration + end panel
618 for (const f of confetti) { ctx.save(); ctx.translate(f.x, f.y); ctx.rotate(f.rot); ctx.fillStyle = f.color; ctx.fillRect(-f.w / 2, -f.h / 2, f.w, f.h); ctx.restore(); }
619 if (over && t > overAt) drawEndPanel();
620 }
621 function drawEndPanel() {
622 hits = [];
623 const k = Math.min(1, (t - overAt) * 3), e = easeOutBack(k);
624 ctx.fillStyle = `rgba(10,5,2,${0.55 * k})`; ctx.fillRect(0, 0, W, H);
625 const pw = 400, ph = over.score != null ? 330 : 290, px = (W - pw) / 2, py = 250 + (1 - e) * 40;
626 ctx.save(); ctx.globalAlpha = k;
627 ctx.shadowColor = "rgba(0,0,0,.7)"; ctx.shadowBlur = 30;
628 const g = ctx.createLinearGradient(0, py, 0, py + ph); g.addColorStop(0, "#5b3a20"); g.addColorStop(1, "#331f10");
629 ctx.fillStyle = g; rr(px, py, pw, ph, 26); ctx.fill(); ctx.shadowBlur = 0;
630 ctx.lineWidth = 2.5; ctx.strokeStyle = GOLD; ctx.stroke();
631 const humanWon = cfg.mode === "ai" && over.winner === cfg.human;
632 let title, sub, col = GOLD;
633 if (over.winner === 0) { title = "Draw"; sub = over.reason === "repetition" ? "Same position three times" : "40 moves without a capture or a man move"; }
634 else if (cfg.mode === "ai") { title = humanWon ? "You win!" : "Computer wins"; col = humanWon ? "#8ff0a4" : "#ff9c8f"; sub = humanWon ? "Beautifully played." : "Try an easier level or undo earlier." ; if (over.reason === "blocked") sub = humanWon ? "The computer has no moves left." : "You have no legal moves left."; }
635 else { title = sideName(over.winner) + " wins!"; sub = "The other side has no moves left."; }
636 if (over.winner !== 0) drawDisc(W / 2, py + 62 + Math.sin(t * 4) * 3, over.winner, true, { scale: 1.1, lift: 4 });
637 else { drawDisc(W / 2 - 24, py + 62, BLACK, false, { scale: 0.9 }); drawDisc(W / 2 + 24, py + 62, RED, false, { scale: 0.9 }); }
638 text(title, W / 2, py + 122, 44, col, 900);
639 text(sub, W / 2, py + 158, 15, "#d9c1a0", 600);
640 let by = py + 184;
641 if (over.score != null) { text(`Score ${over.score}`, W / 2, py + 192, 26, CREAM, 900); by = py + 222; }
642 button(px + 24, by, (pw - 60) / 2, 58, "Play Again", startGame, { primary: true, size: 20 });
643 button(px + 36 + (pw - 60) / 2, by, (pw - 60) / 2, 58, "Menu", toMenu, { size: 20 });
644 ctx.restore();
645 }
646
647 // ---------- loop ----------
648 function update(dt) {
649 t += dt;
650 for (let i = tweens.length - 1; i >= 0; i--) {
651 const tw = tweens[i]; tw.t += dt * 1000; const k = Math.min(1, tw.t / tw.dur);
652 tw.fn(tw.ease(k));
653 if (k >= 1) { tweens.splice(i, 1); tw.res(); }
654 }
655 for (const p of particles) { p.x += p.vx * dt; p.y += p.vy * dt; p.vy += p.g * dt; p.life -= dt; }
656 particles = particles.filter((p) => p.life > 0);
657 for (const r of rings) r.life -= dt; rings = rings.filter((r) => r.life > 0);
658 for (const p of popups) { p.life -= dt; p.y -= 30 * dt; } popups = popups.filter((p) => p.life > 0);
659 for (const f of confetti) { f.x += f.vx * dt; f.y += f.vy * dt; f.vy += 120 * dt; f.rot += f.vr * dt; }
660 confetti = confetti.filter((f) => f.y < H + 30);
661 if (celeb && t < celeb.until && confetti.length < 160) for (let i = 0; i < 4; i++) confetti.push({ x: rnd(0, W), y: -20, vx: rnd(-40, 40), vy: rnd(60, 200), rot: rnd(0, 6), vr: rnd(-8, 8), w: rnd(6, 11), h: rnd(4, 8), color: ["#f4c95d", "#ff5a4d", "#8ff0a4", "#7cc4ff", "#ffffff", "#c58bff"][(Math.random() * 6) | 0] });
662 if (flashT > 0) flashT -= dt; if (shakeT > 0) shakeT -= dt; if (pulseT > 0) pulseT -= dt; if (restartArmed > 0) restartArmed -= dt;
663 }
664 let last = performance.now();
665 function loop(now) {
666 const dt = Math.min(0.05, (now - last) / 1000); last = now;
667 if (!paused) update(dt);
668 hits = [];
669 ctx.save();
670 try { if (scr === "menu") drawMenu(); else drawPlay(); } catch (e) { console.error(e); }
671 ctx.restore();
672 requestAnimationFrame(loop);
673 }
674 makeBoard();
675 st = E.newState(); syncSprites();
676 requestAnimationFrame(loop);
677
678 // test/debug hook (harmless in production)
679 window.__checkers = { load(rows, turn) { startGame(); if (cancelThink) { cancelThink(); cancelThink = null; } thinking = false; st = E.fromRows(rows, turn); hashes = [E.keyOf(st)]; syncSprites(); legal = E.genMoves(st); if (isAiTurn()) think(); }, celebrateNow() { const r = { over: true, winner: cfg.human, reason: 'blocked' }; endGame(r); }, get state() { return st; }, get legal() { return legal; }, get over() { return over; }, get busy() { return busy || thinking; }, cfg, startGame, sqPx, get flip() { return flip; }, get scr() { return scr; }, get partial() { return partial; } };
680})();
681