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 knot100 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 // frame112 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 // squares119 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 edge127 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 studs133 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 center154 ctx.save();155 if (o.alpha != null) ctx.globalAlpha = o.alpha;156 // shadow157 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 face164 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 // grooves169 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 highlight188 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 commit260 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 home395 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 light458 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 discs466 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 pieces505 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 banner528 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 highlight540 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 square550 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 trail553 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 pieces559 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 dots566 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 cursor579 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 jump595 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 // effects603 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 buttons610 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 panel618 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