arcadia / backgammon-club

Backgammon Club

game.js43.7 KB
1// Backgammon Club — an Arcadia seed game. Rules and AI live in engine.js (window.BG); this file is the canvas UI.
2(() => {
3 const E = window.BG;
4 const W = 420, H = 800;
5 const c = document.getElementById("c");
6 const ctx = c.getContext("2d");
7 let scale = 1, dpr = 1, boardLayer = null;
8 function fit() {
9 scale = Math.min(innerWidth / W, innerHeight / H); dpr = window.devicePixelRatio || 1;
10 c.style.width = W * scale + "px"; c.style.height = H * scale + "px";
11 c.width = Math.round(W * scale * dpr); c.height = Math.round(H * scale * dpr);
12 ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
13 boardLayer = null;
14 }
15 addEventListener("resize", fit); fit();
16
17 // ---------- layout ----------
18 const PW = 30, R = 13, BX = 6, BY = 112, BW = 408, PAD = 14, QH = 232, BAND = 72;
19 const Y0 = BY + PAD, BANDY = Y0 + QH, Y1 = BANDY + BAND, BH = PAD * 2 + QH * 2 + BAND;
20 const LX0 = 15, RX0 = 405, SPINE0 = 195, SPINE1 = 225, MIDX = 210;
21 const TOP_Y = 60, STRIP_H = 48, BOT_Y = BY + BH + 4, BTN_Y = BOT_Y + STRIP_H + 8, BTN_H = 54;
22 // upright board, seen from White's seat: points 1-12 run along the near (bottom) edge right-to-left, 13-24 along the far edge left-to-right
23 const isTop = (i) => i >= 12;
24 const colOf = (i) => (i >= 12 ? i - 12 : 11 - i);
25 const colX = (c) => (c < 6 ? LX0 + c * PW : SPINE1 + (c - 6) * PW);
26 const ptX = (i) => colX(colOf(i)) + PW / 2;
27 const TRAY_X = 214, TRAY_W = 192;
28 const stripY = (p) => (p === 1 ? TOP_Y : BOT_Y);
29
30 // ---------- state ----------
31 const store = {
32 get(k, d) { try { const v = localStorage.getItem("backgammon." + k); return v == null ? d : JSON.parse(v); } catch (e) { return d; } },
33 set(k, v) { try { localStorage.setItem("backgammon." + k, JSON.stringify(v)); } catch (e) {} },
34 };
35 const cfg = Object.assign({ mode: "cpu", level: "medium", target: 3, cubeOn: true }, store.get("cfg", {}));
36 const stats = Object.assign({ wins: 0, losses: 0, matches: 0 }, store.get("stats", {}));
37 let G = null; // current match/game
38 let phase = "title"; // title | opening | roll | rolling | move | noMoves | cpu | offer | gameEnd | matchEnd
39 let t = 0, paused = false, timers = [], tok = 0, pendCounter = 0;
40 let flights = [], hidden = {}, ui = [], toast = null, diceAnim = null, drag = null, sel = null, hint = null;
41 let pointerXY = { x: 0, y: 0 };
42 const isCpu = () => cfg.mode === "cpu";
43 const isHuman = (p) => !isCpu() || p === 0;
44 const nameOf = (p) => (isCpu() ? (p === 0 ? "You" : "Computer") : p === 0 ? "White" : "Black");
45 const longName = (p) => (isCpu() ? (p === 0 ? "You (White)" : "Computer · " + cap(cfg.level)) : p === 0 ? "Player 1 · White" : "Player 2 · Black");
46 const cap = (s) => s[0].toUpperCase() + s.slice(1);
47
48 function after(ms, fn) { timers.push({ ms, fn, tok }); }
49 function toastMsg(text, dur) { toast = { text, life: dur || 1.5, max: dur || 1.5 }; }
50
51 function newMatch() {
52 tok++; timers = []; flights = []; hidden = {}; sel = null; hint = null; drag = null;
53 G = { score: [0, 0], cube: { v: 1, owner: -1 }, game: 0, s: null, turn: 0, dice: [], rem: [], maxLen: 0, played: [], undo: [], last: null, queue: false, result: null, status: "" };
54 newGame();
55 }
56 function newGame() {
57 tok++; timers = []; flights = []; hidden = {}; sel = null; hint = null; drag = null;
58 G.s = E.newState(); G.cube = { v: 1, owner: -1 }; G.game++; G.dice = []; G.rem = []; G.played = []; G.undo = []; G.last = null; G.result = null; G.queue = false;
59 phase = "opening"; G.status = "Opening roll";
60 const o = E.openingRoll();
61 diceAnim = { t: 0, dur: 0.9, vals: o.dice, opening: true };
62 after(1000, () => {
63 diceAnim = null; G.dice = o.dice; G.turn = o.first;
64 toastMsg(nameOf(o.first) + (o.first === 0 && isCpu() ? " go first" : " goes first"), 1.6);
65 G.openDice = true;
66 after(900, () => beginMoves());
67 });
68 }
69
70 // ---------- turn flow ----------
71 function startTurn() {
72 G.dice = []; G.rem = []; G.played = []; G.undo = []; G.queue = false; sel = null; hint = null; G.openDice = false;
73 phase = "roll";
74 G.status = isHuman(G.turn) ? (isCpu() ? "Your turn — roll the dice" : nameOf(G.turn) + " to roll") : "Computer's turn";
75 if (!isHuman(G.turn)) after(650, cpuStartTurn);
76 }
77 function canDouble(p) {
78 return cfg.cubeOn && cfg.target > 1 && phase === "roll" && G.turn === p && (G.cube.owner === -1 || G.cube.owner === p) && G.cube.v < 64;
79 }
80 function cpuStartTurn() {
81 if (canDouble(G.turn) && E.aiShouldDouble(G.s, G.turn, cfg.level)) return offerDouble(G.turn);
82 rollDice();
83 }
84 function offerDouble(d) {
85 const r = 1 - d, nv = G.cube.v * 2;
86 if (isHuman(r)) { phase = "offer"; G.offer = { from: d, to: r, nv }; G.status = nameOf(d) + (d === 1 && isCpu() ? " doubles you" : " doubles") + " to " + nv; return; }
87 // computer decides
88 phase = "cpu"; G.status = "Computer is considering the double…"; toastMsg("Double to " + nv + "!", 1.4);
89 after(1500, () => {
90 if (E.aiShouldTake(G.s, r, cfg.level)) resolveDouble(d, true);
91 else resolveDouble(d, false);
92 });
93 }
94 function resolveDouble(d, take) {
95 const r = 1 - d;
96 if (take) {
97 G.cube.v *= 2; G.cube.owner = r; toastMsg((isHuman(r) && isCpu() ? "You take" : nameOf(r) + " takes") + " — cube at " + G.cube.v, 1.6);
98 phase = "roll"; G.status = "Cube at " + G.cube.v;
99 after(900, () => { if (!isHuman(d)) rollDice(); else { G.status = "Your turn — roll the dice"; } });
100 } else {
101 toastMsg(nameOf(r) + " drops", 1.4);
102 after(900, () => endGame(d, 1, "drop"));
103 }
104 }
105 function rollDice() {
106 if (phase !== "roll") return;
107 phase = "rolling"; hint = null;
108 const vals = E.rollDice();
109 diceAnim = { t: 0, dur: 0.85, vals };
110 G.status = nameOf(G.turn) + " rolling…";
111 after(900, () => { diceAnim = null; G.dice = vals; beginMoves(); });
112 }
113 function beginMoves() {
114 G.rem = E.turnDice(G.dice); G.played = []; G.undo = []; G.maxLen = E.maxPlay(G.s, G.turn, G.rem); sel = null;
115 if (G.maxLen === 0) {
116 phase = "noMoves"; G.status = "No legal moves"; toastMsg("No legal moves!", 1.7);
117 after(1800, endTurn); return;
118 }
119 if (isHuman(G.turn)) { phase = "move"; G.status = (isCpu() ? "Your move" : nameOf(G.turn) + " to move") + " — " + G.maxLen + (G.maxLen === 1 ? " move" : " moves"); }
120 else {
121 phase = "cpu"; G.status = "Computer is thinking…";
122 const start = performance.now(), myTok = tok;
123 E.choosePlayAsync(G.s, G.turn, G.dice, cfg.level, { budgetMs: 1100 }, (play) => {
124 if (myTok !== tok) return;
125 const wait = Math.max(250, 700 - (performance.now() - start));
126 after(wait / 1, () => runMoves(play.moves, () => after(650, endTurn), 620));
127 });
128 }
129 }
130 function runMoves(list, done, gap) {
131 G.queue = true; hint = null;
132 let i = 0;
133 (function step() {
134 if (i >= list.length) { G.queue = false; return done && done(); }
135 const m = list[i++]; doMove(m);
136 if (phase === "gameEndPending") { G.queue = false; return; }
137 after(gap || 260, step);
138 })();
139 }
140 function doMove(m) {
141 const p = G.turn;
142 G.undo.push({ s: G.s, rem: G.rem.slice() });
143 const from = m.f === E.BAR ? slotXY("bar" + p, G.s.bar[p] - 1, G.s.bar[p]) : slotXY("p" + m.f, Math.abs(G.s.pts[m.f]) - 1, Math.abs(G.s.pts[m.f]));
144 let hitFrom = null;
145 if (m.hit) hitFrom = slotXY("p" + m.t, 0, 1);
146 const ns = E.applyMove(G.s, p, m);
147 G.s = ns; G.played.push(m);
148 const di = G.rem.indexOf(m.d); if (di >= 0) G.rem.splice(di, 1);
149 if (m.t === E.OFF) fly(from, "off" + p, ns.off[p] - 1, ns.off[p], p);
150 else fly(from, "p" + m.t, Math.abs(ns.pts[m.t]) - 1, Math.abs(ns.pts[m.t]), p);
151 if (m.hit) { fly(hitFrom, "bar" + (1 - p), ns.bar[1 - p] - 1, ns.bar[1 - p], 1 - p, 0.05); toastMsg("Hit!", 1.0); }
152 sel = null; hint = null;
153 if (E.winner(ns) >= 0) {
154 phase = "gameEndPending"; G.status = "";
155 G.last = { p, moves: G.played.slice() };
156 after(900, () => endGame(E.winner(ns), E.gameResult(ns).mult, "win"));
157 return;
158 }
159 if (isHuman(p)) checkAutoEnd();
160 }
161 function checkAutoEnd() {
162 const lm = E.legalMoves(G.s, G.turn, G.rem, G.maxLen, G.played.length);
163 if (lm.length === 0) {
164 const mine = (G.pend = ++pendCounter);
165 G.status = "Turn complete";
166 after(1300, () => { if (G.pend === mine && phase === "move") endTurn(); });
167 } else G.status = (isCpu() ? "Your move" : nameOf(G.turn) + " to move") + " — " + (G.maxLen - G.played.length) + " left";
168 }
169 function endTurn() {
170 G.pend = 0;
171 if (E.winner(G.s) >= 0) return;
172 G.last = { p: G.turn, moves: G.played.slice() };
173 G.turn = 1 - G.turn; sel = null; hint = null;
174 startTurn();
175 }
176 function undo() {
177 if (phase !== "move" || G.queue || !G.undo.length) return;
178 const u = G.undo.pop(); G.s = u.s; G.rem = u.rem; G.played.pop();
179 G.pend = 0; // cancels a pending auto end
180 flights = []; hidden = {}; sel = null; hint = null;
181 G.status = (isCpu() ? "Your move" : nameOf(G.turn) + " to move") + " — " + (G.maxLen - G.played.length) + " left";
182 }
183 function endGame(w, mult, why) {
184 const res = { winner: w, mult, why, kind: mult === 3 ? "backgammon" : mult === 2 ? "gammon" : "single", cube: G.cube.v, pts: G.cube.v * mult };
185 G.score[w] += res.pts; G.result = res;
186 const done = G.score[w] >= cfg.target;
187 phase = done ? "matchEnd" : "gameEnd"; G.status = "";
188 if (done) {
189 if (isCpu()) {
190 const won = w === 0, mm = { easy: 1, medium: 2, hard: 3 }[cfg.level];
191 stats.matches++; if (won) stats.wins++; else stats.losses++; store.set("stats", stats);
192 const score = mm * (G.score[0] * 100 + (won ? 500 : 50));
193 if (window.Arcadia) { try { Arcadia.submitScore(score); Arcadia.gameOver(); } catch (e) {} }
194 G.finalScore = score;
195 }
196 }
197 }
198
199 // ---------- geometry ----------
200 function slotXY(key, i, n) {
201 if (key[0] === "p") {
202 const idx = +key.slice(1);
203 const sp = Math.min(2 * R + 1, (QH - 2 * R - 12) / Math.max(1, n - 1));
204 const off = R + 5 + i * sp;
205 return { x: ptX(idx), y: isTop(idx) ? Y0 + off : Y0 + 2 * QH + BAND - off };
206 }
207 if (key.slice(0, 3) === "bar") {
208 const p = +key[3], sp = Math.min(2 * R + 2, (QH - 2 * R - 12) / Math.max(1, n - 1)), off = 18 + R + i * sp, my = BANDY + BAND / 2;
209 return { x: MIDX, y: p === 0 ? my + off : my - off };
210 }
211 const p = +key[3]; // off tray
212 return { x: TRAY_X + 8 + (i + 0.5) * ((TRAY_W - 16) / 15), y: stripY(p) + STRIP_H / 2, flat: true };
213 }
214 function hitTest(x, y) {
215 for (let p = 0; p < 2; p++) if (y >= stripY(p) && y <= stripY(p) + STRIP_H && x >= 130) return { kind: "off", p };
216 if (y < Y0 || y > Y0 + 2 * QH + BAND || x < LX0 || x > RX0) return null;
217 if (x >= SPINE0 && x <= SPINE1) return { kind: "bar", p: y > BANDY + BAND / 2 ? 0 : 1 };
218 if (y >= BANDY && y < Y1) return { kind: "dice" };
219 const c = x < SPINE0 ? Math.floor((x - LX0) / PW) : 6 + Math.floor((x - SPINE1) / PW);
220 return { kind: "pt", idx: y < BANDY ? 12 + c : 11 - c };
221 }
222
223 // ---------- selection / destinations ----------
224 function movableFrom() {
225 if (phase !== "move" || G.queue) return new Set();
226 const lm = E.legalMoves(G.s, G.turn, G.rem, G.maxLen, G.played.length);
227 return new Set(lm.map((m) => m.f));
228 }
229 function computeDests(from) {
230 const res = new Map();
231 let frontier = [{ s: G.s, rem: G.rem, pl: G.played.length, cur: from, path: [] }];
232 for (let depth = 0; depth < 4 && frontier.length; depth++) {
233 const next = [];
234 for (const n of frontier) {
235 const lm = E.legalMoves(n.s, G.turn, n.rem, G.maxLen, n.pl).filter((m) => m.f === n.cur);
236 for (const m of lm) {
237 const path = n.path.concat([m]), key = m.t === E.OFF ? "off" : m.t;
238 const ex = res.get(key);
239 const midHit = (pa) => pa.slice(0, -1).some((x) => x.hit);
240 if (!ex || (ex.length === path.length && midHit(ex) && !midHit(path)) || (m.t === E.OFF && ex.length === path.length && m.d < ex[ex.length - 1].d)) res.set(key, path);
241 if (m.t !== E.OFF) {
242 const rem = n.rem.slice(); rem.splice(rem.indexOf(m.d), 1);
243 next.push({ s: E.applyMove(n.s, G.turn, m), rem, pl: n.pl + 1, cur: m.t, path });
244 }
245 }
246 }
247 frontier = next;
248 }
249 return res;
250 }
251 function select(from) { sel = { from, dests: computeDests(from) }; }
252 function playPath(path) {
253 sel = null; runMoves(path, null, 240);
254 }
255 function tapAt(x, y) {
256 if (phase === "roll" && isHuman(G.turn)) { const h = hitTest(x, y); if (h && h.kind === "dice") return rollDice(); }
257 if (phase !== "move" || G.queue || !isHuman(G.turn)) return;
258 const h = hitTest(x, y); if (!h) { sel = null; return; }
259 const p = G.turn;
260 if (sel) {
261 const key = h.kind === "pt" ? h.idx : h.kind === "off" && h.p === p ? "off" : null;
262 if (key !== null && sel.dests.has(key)) return playPath(sel.dests.get(key));
263 }
264 const mv = movableFrom();
265 let from = h.kind === "pt" && G.s.pts[h.idx] * (p === 0 ? 1 : -1) > 0 ? h.idx : h.kind === "bar" && h.p === p && G.s.bar[p] > 0 ? E.BAR : null;
266 if (from === null) { sel = null; return; }
267 if (!mv.has(from)) {
268 sel = null;
269 toastMsg(G.s.bar[p] > 0 && from !== E.BAR ? "Enter from the bar first" : "That checker can't move", 1.2);
270 return;
271 }
272 if (sel && sel.from === from) { // second tap: play its biggest single move
273 let best = null;
274 for (const [k, path] of sel.dests) if (path.length === 1 && (!best || path[0].d > best[0].d)) best = path;
275 if (best) return playPath(best);
276 sel = null; return;
277 }
278 select(from);
279 }
280
281 // ---------- input ----------
282 function toLogical(e) { const r = c.getBoundingClientRect(); return { x: ((e.clientX - r.left) * W) / r.width, y: ((e.clientY - r.top) * H) / r.height }; }
283 let press = null;
284 c.addEventListener("pointerdown", (e) => {
285 e.preventDefault(); try { c.setPointerCapture(e.pointerId); } catch (er) {}
286 const q = toLogical(e); pointerXY = q;
287 press = { x: q.x, y: q.y, btn: ui.find((b) => b.enabled !== false && q.x >= b.x && q.x <= b.x + b.w && q.y >= b.y && q.y <= b.y + b.h) || null, drag: null };
288 if (!press.btn && phase === "move" && !G.queue && isHuman(G.turn)) {
289 const h = hitTest(q.x, q.y), p = G.turn;
290 if (h) {
291 const from = h.kind === "pt" && G.s.pts[h.idx] * (p === 0 ? 1 : -1) > 0 ? h.idx : h.kind === "bar" && h.p === p && G.s.bar[p] > 0 ? E.BAR : null;
292 if (from !== null && movableFrom().has(from)) press.cand = from;
293 }
294 }
295 });
296 c.addEventListener("pointermove", (e) => {
297 const q = toLogical(e); pointerXY = q;
298 if (!press || !press.cand && press.cand !== 0) return;
299 if (!press.drag && Math.hypot(q.x - press.x, q.y - press.y) > 10 && press.cand !== undefined) {
300 const from = press.cand;
301 if (!sel || sel.from !== from) select(from);
302 const key = from === E.BAR ? "bar" + G.turn : "p" + from;
303 hidden[key] = (hidden[key] || 0) + 1;
304 press.drag = { key, from };
305 }
306 });
307 function endDrag() { if (press && press.drag) { hidden[press.drag.key]--; } }
308 c.addEventListener("pointerup", (e) => {
309 const q = toLogical(e); pointerXY = q;
310 if (!press) return;
311 const pr = press; press = null;
312 if (pr.drag) {
313 hidden[pr.drag.key]--;
314 const h = hitTest(q.x, q.y - 22), p = G.turn;
315 const key = h && h.kind === "pt" ? h.idx : h && h.kind === "off" && h.p === p ? "off" : null;
316 if (sel && key !== null && sel.dests.has(key)) return playPath(sel.dests.get(key));
317 sel = null; return;
318 }
319 if (pr.btn) {
320 const b = ui.find((x) => x.id === pr.btn.id && q.x >= x.x && q.x <= x.x + x.w && q.y >= x.y && q.y <= x.y + x.h);
321 if (b && b.enabled !== false) b.fn();
322 return;
323 }
324 tapAt(q.x, q.y);
325 });
326 c.addEventListener("pointercancel", () => { endDrag(); press = null; });
327 addEventListener("keydown", (e) => {
328 if (e.key === " " || e.key === "Enter") { if (phase === "roll" && isHuman(G.turn)) rollDice(); else if (phase === "move" && G.pend) endTurn(); }
329 else if (e.key === "u" || e.key === "Backspace") undo();
330 });
331 if (window.Arcadia) { try { Arcadia.onPause(() => (paused = true)); Arcadia.onResume(() => (paused = false)); } catch (e) {} }
332 document.addEventListener("visibilitychange", () => { if (document.hidden) paused = true; else if (!window.Arcadia) paused = false; });
333
334 function showHint() {
335 if (phase !== "move" || G.queue) return;
336 const plays = E.genPlays(G.s, G.turn, G.rem);
337 let best = null, bv = -Infinity;
338 for (const pl of plays) { if (pl.moves.length < G.maxLen - G.played.length) continue; const v = E.evaluate(pl.state, G.turn); if (v > bv) { bv = v; best = pl; } }
339 if (best) { hint = best.moves[0]; select(hint.f); sel.hint = true; }
340 }
341
342 // ---------- update ----------
343 function ease(u) { return u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2; }
344 function update(dt) {
345 t += dt;
346 for (const tm of timers) tm.ms -= dt * 1000;
347 const due = timers.filter((x) => x.ms <= 0);
348 if (due.length) { timers = timers.filter((x) => x.ms > 0); for (const d of due) if (d.tok === tok) d.fn(); }
349 if (diceAnim) diceAnim.t += dt;
350 if (toast) { toast.life -= dt; if (toast.life <= 0) toast = null; }
351 for (const f of flights) { f.t += dt; }
352 for (const f of flights) if (f.t >= f.dur) { hidden[f.key] = Math.max(0, (hidden[f.key] || 0) - 1); }
353 flights = flights.filter((f) => f.t < f.dur);
354 }
355 function fly(from, key, i, n, p, delay) {
356 const to = slotXY(key.replace(/^(p\d+|bar\d|off\d)$/, "$1"), i, n);
357 hidden[key] = (hidden[key] || 0) + 1;
358 flights.push({ x0: from.x, y0: from.y, x1: to.x, y1: to.y, key, p, t: -(delay || 0), dur: 0.3, flat: to.flat });
359 }
360
361 // ---------- drawing helpers ----------
362 const rng = (function () { let a = 1234567; return () => { a = (a * 1664525 + 1013904223) >>> 0; return a / 4294967296; }; })();
363 function rr(g, x, y, w, h, r) { g.beginPath(); g.roundRect(x, y, w, h, r); }
364 function buildBoard() {
365 boardLayer = document.createElement("canvas"); boardLayer.width = c.width; boardLayer.height = c.height;
366 const g = boardLayer.getContext("2d"); g.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
367 // room background
368 let bg = g.createLinearGradient(0, 0, 0, H); bg.addColorStop(0, "#22160d"); bg.addColorStop(1, "#0f0906");
369 g.fillStyle = bg; g.fillRect(0, 0, W, H);
370 const vg = g.createRadialGradient(W / 2, H / 2, 200, W / 2, H / 2, 640); vg.addColorStop(0, "rgba(0,0,0,0)"); vg.addColorStop(1, "rgba(0,0,0,.55)");
371 g.fillStyle = vg; g.fillRect(0, 0, W, H);
372 // wood frame
373 g.save(); g.shadowColor = "rgba(0,0,0,.6)"; g.shadowBlur = 18; g.shadowOffsetY = 6;
374 let wd = g.createLinearGradient(BX, BY, BX + BW, BY + BH); wd.addColorStop(0, "#8b5a32"); wd.addColorStop(0.5, "#5f3819"); wd.addColorStop(1, "#7f4f29");
375 g.fillStyle = wd; rr(g, BX, BY, BW, BH, 16); g.fill(); g.restore();
376 g.save(); rr(g, BX, BY, BW, BH, 16); g.clip();
377 for (let i = 0; i < 160; i++) {
378 const y = BY + rng() * BH, x = BX + rng() * BW, len = 60 + rng() * 240;
379 g.strokeStyle = rng() < 0.5 ? "rgba(30,12,2,.16)" : "rgba(255,210,150,.07)"; g.lineWidth = 0.6 + rng() * 1.6;
380 g.beginPath(); g.moveTo(x, y); g.bezierCurveTo(x + len / 3, y + rng() * 5 - 2.5, x + (2 * len) / 3, y + rng() * 5 - 2.5, x + len, y + rng() * 4 - 2); g.stroke();
381 }
382 g.restore();
383 g.strokeStyle = "rgba(255,225,170,.35)"; g.lineWidth = 1.5; rr(g, BX + 1, BY + 1, BW - 2, BH - 2, 15); g.stroke();
384 g.strokeStyle = "rgba(0,0,0,.5)"; g.lineWidth = 1; rr(g, BX + 6, BY + 6, BW - 12, BH - 12, 11); g.stroke();
385 // leather playfield halves
386 for (const [x0, x1] of [[LX0 - 4, SPINE0], [SPINE1, RX0 + 4]]) {
387 const px = x0, py = Y0 - 4, pw = x1 - x0, ph = QH * 2 + BAND + 8;
388 g.save(); rr(g, px, py, pw, ph, 6); g.clip();
389 let lg = g.createLinearGradient(0, py, 0, py + ph); lg.addColorStop(0, "#33231a"); lg.addColorStop(0.5, "#2a1d15"); lg.addColorStop(1, "#33231a");
390 g.fillStyle = lg; g.fillRect(px, py, pw, ph);
391 for (let i = 0; i < 900; i++) { g.fillStyle = rng() < 0.5 ? "rgba(255,230,190,.045)" : "rgba(0,0,0,.09)"; g.fillRect(px + rng() * pw, py + rng() * ph, 1.4, 1.4); }
392 g.restore();
393 g.strokeStyle = "rgba(0,0,0,.65)"; g.lineWidth = 2; rr(g, px, py, pw, ph, 6); g.stroke();
394 g.strokeStyle = "rgba(215,175,110,.32)"; g.lineWidth = 1; g.setLineDash([4, 4]); rr(g, px + 3, py + 3, pw - 6, ph - 6, 4); g.stroke(); g.setLineDash([]);
395 }
396 // triangles
397 for (let i = 0; i < 24; i++) {
398 const top = isTop(i), c = colOf(i), x = colX(c);
399 const by = top ? Y0 : Y0 + 2 * QH + BAND, ty = top ? Y0 + QH : Y0 + QH + BAND;
400 const light = (c + (top ? 0 : 1)) % 2 === 0;
401 const tg = g.createLinearGradient(0, by, 0, ty);
402 if (light) { tg.addColorStop(0, "#f0dcae"); tg.addColorStop(1, "#c9ae76"); } else { tg.addColorStop(0, "#a2432f"); tg.addColorStop(1, "#6b2418"); }
403 g.fillStyle = tg; g.beginPath(); g.moveTo(x + 1.5, by); g.lineTo(x + PW / 2, ty); g.lineTo(x + PW - 1.5, by); g.closePath(); g.fill();
404 g.strokeStyle = "rgba(0,0,0,.28)"; g.lineWidth = 1; g.stroke();
405 }
406 // spine
407 const sg = g.createLinearGradient(SPINE0, 0, SPINE1, 0); sg.addColorStop(0, "#5a3618"); sg.addColorStop(0.5, "#8a5a30"); sg.addColorStop(1, "#5a3618");
408 g.fillStyle = sg; g.fillRect(SPINE0, BY + 4, SPINE1 - SPINE0, BH - 8);
409 g.strokeStyle = "rgba(0,0,0,.45)"; g.lineWidth = 1; g.strokeRect(SPINE0 + 0.5, BY + 4.5, SPINE1 - SPINE0 - 1, BH - 9);
410 g.fillStyle = "rgba(255,220,160,.25)"; g.fillRect(SPINE0 + 2, BY + 4, 2, BH - 8);
411 }
412 function triPath(g, i) {
413 const top = isTop(i), x = colX(colOf(i)), by = top ? Y0 : Y0 + 2 * QH + BAND, ty = top ? Y0 + QH : Y0 + QH + BAND;
414 g.beginPath(); g.moveTo(x + 1.5, by); g.lineTo(x + PW / 2, ty); g.lineTo(x + PW - 1.5, by); g.closePath();
415 }
416 function checker(g, x, y, r, p, o) {
417 o = o || {};
418 g.save(); g.globalAlpha = o.alpha == null ? 1 : o.alpha;
419 g.fillStyle = "rgba(0,0,0,.38)"; g.beginPath(); g.ellipse(x + 1.5, y + 3, r, r * 0.92, 0, 0, 7); g.fill();
420 const base = g.createRadialGradient(x - r * 0.35, y - r * 0.4, r * 0.1, x, y, r);
421 if (p === 0) { base.addColorStop(0, "#fffdf6"); base.addColorStop(0.55, "#eadfc4"); base.addColorStop(1, "#a8946a"); }
422 else { base.addColorStop(0, "#6b6b78"); base.addColorStop(0.5, "#2b2b34"); base.addColorStop(1, "#050508"); }
423 if (o.glow) { g.shadowColor = o.glow; g.shadowBlur = 14; }
424 g.fillStyle = base; g.beginPath(); g.arc(x, y, r, 0, 7); g.fill(); g.shadowBlur = 0;
425 g.strokeStyle = p === 0 ? "rgba(90,70,40,.8)" : "rgba(0,0,0,.9)"; g.lineWidth = 1.2; g.stroke();
426 g.strokeStyle = p === 0 ? "rgba(120,95,55,.35)" : "rgba(255,255,255,.16)"; g.lineWidth = 1.3; g.beginPath(); g.arc(x, y, r * 0.68, 0, 7); g.stroke();
427 const dish = g.createRadialGradient(x + r * 0.2, y + r * 0.25, 1, x, y, r * 0.66);
428 dish.addColorStop(0, "rgba(255,255,255,0)"); dish.addColorStop(1, p === 0 ? "rgba(120,95,55,.18)" : "rgba(0,0,0,.35)");
429 g.fillStyle = dish; g.beginPath(); g.arc(x, y, r * 0.66, 0, 7); g.fill();
430 const gl = g.createLinearGradient(x - r * 0.6, y - r * 0.8, x + r * 0.1, y - r * 0.1);
431 gl.addColorStop(0, p === 0 ? "rgba(255,255,255,.95)" : "rgba(255,255,255,.55)"); gl.addColorStop(1, "rgba(255,255,255,0)");
432 g.fillStyle = gl; g.beginPath(); g.ellipse(x - r * 0.28, y - r * 0.42, r * 0.5, r * 0.28, -0.6, 0, 7); g.fill();
433 g.restore();
434 }
435 function flatChecker(g, x, y, p) { // edge-on checker for off trays
436 const gr = g.createLinearGradient(x - 5, 0, x + 5, 0);
437 if (p === 0) { gr.addColorStop(0, "#c9b78e"); gr.addColorStop(0.4, "#fff8e8"); gr.addColorStop(1, "#b7a37a"); } else { gr.addColorStop(0, "#111"); gr.addColorStop(0.4, "#5c5c68"); gr.addColorStop(1, "#0a0a0d"); }
438 g.fillStyle = gr; rr(g, x - 4.5, y - 15, 9, 30, 3); g.fill();
439 g.strokeStyle = "rgba(0,0,0,.55)"; g.lineWidth = 1; g.stroke();
440 }
441 const PIPS = { 1: [[0, 0]], 2: [[-1, -1], [1, 1]], 3: [[-1, -1], [0, 0], [1, 1]], 4: [[-1, -1], [1, -1], [-1, 1], [1, 1]], 5: [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]], 6: [[-1, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [1, 1]] };
442 function die(g, x, y, s, v, p, alpha, rot) {
443 g.save(); g.translate(x, y); g.rotate(rot || 0); g.globalAlpha = alpha == null ? 1 : alpha;
444 g.fillStyle = "rgba(0,0,0,.4)"; rr(g, -s / 2 + 2, -s / 2 + 4, s, s, s * 0.2); g.fill();
445 const gr = g.createLinearGradient(-s / 2, -s / 2, s / 2, s / 2);
446 if (p === 0) { gr.addColorStop(0, "#fffdf4"); gr.addColorStop(1, "#d8ccae"); } else { gr.addColorStop(0, "#4a4a55"); gr.addColorStop(1, "#15151a"); }
447 g.fillStyle = gr; rr(g, -s / 2, -s / 2, s, s, s * 0.2); g.fill();
448 g.strokeStyle = p === 0 ? "rgba(90,70,40,.7)" : "rgba(0,0,0,.9)"; g.lineWidth = 1.2; g.stroke();
449 g.strokeStyle = "rgba(255,255,255,.35)"; g.lineWidth = 1; rr(g, -s / 2 + 1.5, -s / 2 + 1.5, s - 3, s - 3, s * 0.17); g.stroke();
450 g.fillStyle = p === 0 ? "#2a1a10" : "#f3e7c9";
451 for (const [px, py] of PIPS[v]) { g.beginPath(); g.arc(px * s * 0.25, py * s * 0.25, s * 0.09, 0, 7); g.fill(); }
452 g.restore();
453 }
454 function btn(id, x, y, w, h, label, fn, o) {
455 o = o || {}; const en = o.enabled !== false;
456 ui.push({ id, x, y, w, h, fn, enabled: en });
457 ctx.save(); ctx.globalAlpha = en ? 1 : 0.45;
458 const g = ctx.createLinearGradient(0, y, 0, y + h);
459 if (o.primary) { g.addColorStop(0, "#f6d67e"); g.addColorStop(1, "#c08a2c"); } else if (o.on) { g.addColorStop(0, "#8a6a3a"); g.addColorStop(1, "#5e4322"); } else { g.addColorStop(0, "#4b3220"); g.addColorStop(1, "#2e1d11"); }
460 ctx.shadowColor = "rgba(0,0,0,.5)"; ctx.shadowBlur = 8; ctx.shadowOffsetY = 3;
461 ctx.fillStyle = g; rr(ctx, x, y, w, h, o.r || 12); ctx.fill(); ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
462 ctx.strokeStyle = o.primary ? "#fff0b8" : o.on ? "#e8c47a" : "rgba(230,190,120,.35)"; ctx.lineWidth = o.on ? 2 : 1.2; ctx.stroke();
463 ctx.fillStyle = o.primary ? "#2b1a08" : "#f6e6c4"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
464 ctx.font = "800 " + (o.font || 17) + "px system-ui";
465 const lines = String(label).split("\n");
466 lines.forEach((ln, i) => ctx.fillText(ln, x + w / 2, y + h / 2 + (i - (lines.length - 1) / 2) * ((o.font || 17) + 2)));
467 ctx.restore();
468 }
469 function text(s, x, y, size, color, align, weight) {
470 ctx.fillStyle = color; ctx.font = (weight || 700) + " " + size + "px system-ui"; ctx.textAlign = align || "left"; ctx.textBaseline = "middle"; ctx.fillText(s, x, y);
471 }
472
473 // ---------- main draw ----------
474 function stackCount(key, actual) { return Math.max(0, actual - (hidden[key] || 0)); }
475 function draw() {
476 ui = [];
477 if (!boardLayer) buildBoard();
478 ctx.drawImage(boardLayer, 0, 0, W, H);
479 if (!G) G = { score: [0, 0], cube: { v: 1, owner: -1 }, s: E.newState(), turn: 0, dice: [], rem: [], played: [], undo: [], last: null };
480 const s = G.s, viewer = isCpu() ? 0 : phase === "title" ? 0 : G.turn;
481 const moving = phase === "move" && isHuman(G.turn) && !G.queue;
482 const mv = moving ? movableFrom() : new Set();
483
484 // last move highlight
485 if (G.last && phase !== "title") {
486 ctx.save();
487 for (const m of G.last.moves) {
488 if (m.f !== E.BAR) { triPath(ctx, m.f); ctx.fillStyle = "rgba(255,225,120,.16)"; ctx.fill(); ctx.strokeStyle = "rgba(255,225,120,.5)"; ctx.setLineDash([4, 3]); ctx.lineWidth = 1.5; ctx.stroke(); ctx.setLineDash([]); }
489 if (m.t !== E.OFF) { triPath(ctx, m.t); ctx.fillStyle = "rgba(255,215,90,.34)"; ctx.fill(); ctx.strokeStyle = "rgba(255,225,120,.9)"; ctx.lineWidth = 2; ctx.stroke(); }
490 }
491 ctx.restore();
492 }
493 // selection highlights on triangles
494 if (sel) {
495 for (const [k, path] of sel.dests) {
496 if (k === "off") continue;
497 triPath(ctx, k); const hit = path[path.length - 1].hit;
498 ctx.fillStyle = hit ? "rgba(255,90,70,.30)" : "rgba(110,255,150,.30)"; ctx.fill();
499 }
500 }
501 // point numbers
502 ctx.font = "800 9px system-ui"; ctx.textBaseline = "middle"; ctx.textAlign = "center";
503 for (let i = 0; i < 24; i++) {
504 ctx.fillStyle = "rgba(255,232,190,.7)";
505 ctx.fillText(String(E.pointNo(viewer, i)), ptX(i), isTop(i) ? Y0 - 6 : Y0 + 2 * QH + BAND + 6);
506 }
507 // checkers on points
508 const glowT = 0.5 + 0.5 * Math.sin(t * 5);
509 for (let i = 0; i < 24; i++) {
510 const total = Math.abs(s.pts[i]); if (!total) continue;
511 const p = s.pts[i] > 0 ? 0 : 1, n = stackCount("p" + i, total), key = "p" + i;
512 for (let k = 0; k < n; k++) {
513 const pos = slotXY(key, k, total);
514 const top = k === n - 1, isSel = sel && sel.from === i && top;
515 checker(ctx, pos.x, pos.y - (isSel ? 3 : 0), R, p, { glow: isSel ? "#ffe27a" : top && mv.has(i) && !sel ? "rgba(255,226,122," + (0.4 + 0.5 * glowT) + ")" : null });
516 }
517 if (total > 6) { const pos = slotXY(key, n - 1, total); text(String(total), pos.x, pos.y + 1, 13, p === 0 ? "#2a1a10" : "#f3e7c9", "center", 900); }
518 }
519 // bar
520 for (let p = 0; p < 2; p++) {
521 const total = s.bar[p], n = stackCount("bar" + p, total);
522 for (let k = 0; k < n; k++) { const pos = slotXY("bar" + p, k, total); const isSel = sel && sel.from === E.BAR && G.turn === p && k === n - 1; checker(ctx, pos.x, pos.y, R, p, { glow: isSel ? "#ffe27a" : mv.has(E.BAR) && G.turn === p && !sel && k === n - 1 ? "rgba(255,226,122," + (0.4 + 0.5 * glowT) + ")" : null }); }
523 }
524 // destinations
525 if (sel) {
526 for (const [k, path] of sel.dests) {
527 if (k === "off") continue;
528 const cnt = Math.abs(s.pts[k]), own = s.pts[k] * (G.turn === 0 ? 1 : -1) > 0;
529 const pos = slotXY("p" + k, own ? cnt : cnt === 1 ? 0 : 0, own ? cnt + 1 : cnt);
530 const hit = path[path.length - 1].hit;
531 const pulse = 1 + 0.08 * Math.sin(t * 8);
532 ctx.save(); ctx.globalAlpha = 0.9;
533 ctx.strokeStyle = hit ? "#ff6a5a" : "#7dff9b"; ctx.fillStyle = hit ? "rgba(255,90,70,.28)" : "rgba(110,255,150,.25)"; ctx.lineWidth = 3;
534 ctx.beginPath(); ctx.arc(pos.x, pos.y, (R - 2) * pulse, 0, 7); ctx.fill(); ctx.stroke();
535 if (path.length > 1) { ctx.fillStyle = "#fff"; ctx.font = "800 11px system-ui"; ctx.textAlign = "center"; ctx.fillText(String(path.reduce((a, m) => a + m.d, 0)), pos.x, pos.y + 1); }
536 ctx.restore();
537 }
538 }
539 // hint arrow marker
540 if (hint && sel && sel.hint) {
541 const dst = hint.t;
542 if (dst !== E.OFF) { const pos = slotXY("p" + dst, 0, 1); ctx.strokeStyle = "#ffd75e"; ctx.lineWidth = 3; ctx.setLineDash([5, 4]); ctx.beginPath(); ctx.arc(pos.x + (colOf(dst) ? -0 : 0), pos.y, R + 3, 0, 7); ctx.stroke(); ctx.setLineDash([]); }
543 }
544 // flights
545 for (const f of flights) {
546 if (f.t < 0) continue;
547 const u = ease(Math.min(1, f.t / f.dur));
548 const x = f.x0 + (f.x1 - f.x0) * u, y = f.y0 + (f.y1 - f.y0) * u - Math.sin(u * Math.PI) * 16;
549 if (f.flat) { flatChecker(ctx, x, y, f.p); } else checker(ctx, x, y, R * (1 + 0.1 * Math.sin(u * Math.PI)), f.p);
550 }
551 // drag ghost
552 if (press && press.drag) checker(ctx, pointerXY.x, pointerXY.y - 22, R * 1.1, G.turn, { glow: "#ffe27a" });
553
554 drawStrips();
555 drawDiceArea();
556 drawHeader();
557 drawButtons();
558 if (toast) {
559 const a = Math.min(1, toast.life * 3, (toast.max - toast.life) * 6 + 0.2);
560 ctx.save(); ctx.globalAlpha = a; ctx.font = "900 20px system-ui";
561 const tw = ctx.measureText(toast.text).width + 36, ty = BANDY - 34 - (1 - a) * 6;
562 ctx.fillStyle = "rgba(20,10,4,.88)"; rr(ctx, W / 2 - tw / 2, ty - 18, tw, 36, 18); ctx.fill();
563 ctx.strokeStyle = "#e8c47a"; ctx.lineWidth = 1.5; ctx.stroke();
564 ctx.fillStyle = "#ffe9b0"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(toast.text, W / 2, ty + 1); ctx.restore();
565 }
566 if (phase === "offer") drawOffer();
567 if (phase === "gameEnd" || phase === "matchEnd") drawResult();
568 if (phase === "title") drawTitle();
569 }
570
571 function drawStrips() {
572 const s = G.s;
573 for (let p = 0; p < 2; p++) {
574 const y = stripY(p), active = phase !== "title" && G.turn === p && phase !== "gameEnd" && phase !== "matchEnd" && phase !== "opening";
575 const g = ctx.createLinearGradient(0, y, 0, y + STRIP_H); g.addColorStop(0, active ? "rgba(90,58,28,.95)" : "rgba(50,32,18,.9)"); g.addColorStop(1, active ? "rgba(60,38,18,.95)" : "rgba(34,21,12,.9)");
576 ctx.fillStyle = g; rr(ctx, BX, y, BW, STRIP_H, 12); ctx.fill();
577 ctx.strokeStyle = active ? "#e8c47a" : "rgba(215,175,110,.25)"; ctx.lineWidth = active ? 1.8 : 1; ctx.stroke();
578 checker(ctx, BX + 26, y + STRIP_H / 2, 13, p);
579 text(longName(p), BX + 48, y + 16, 14, active ? "#ffe9b0" : "#dcc9a3", "left", 800);
580 text(E.pips(s, p) + " pips · " + G.score[p] + "/" + cfg.target, BX + 48, y + 34, 12, "rgba(230,205,160,.85)", "left", 700);
581 // off tray
582 const canOff = sel && sel.dests.has("off") && G.turn === p;
583 ctx.fillStyle = "rgba(0,0,0,.42)"; rr(ctx, TRAY_X, y + 5, TRAY_W, STRIP_H - 10, 8); ctx.fill();
584 ctx.strokeStyle = canOff ? "#7dff9b" : "rgba(215,175,110,.25)"; ctx.lineWidth = canOff ? 2.5 + Math.sin(t * 8) : 1; ctx.stroke();
585 if (canOff) { ctx.fillStyle = "rgba(110,255,150,.12)"; ctx.fill(); }
586 const total = s.off[p], n = stackCount("off" + p, total);
587 if (!n && !canOff) text("OFF", TRAY_X + TRAY_W / 2, y + STRIP_H / 2, 11, "rgba(230,200,150,.3)", "center", 800);
588 if (canOff && !n) text("BEAR OFF", TRAY_X + TRAY_W / 2, y + STRIP_H / 2, 13, "#9dffb4", "center", 900);
589 for (let k = 0; k < n; k++) { const pos = slotXY("off" + p, k, total); flatChecker(ctx, pos.x, pos.y, p); }
590 }
591 }
592 function drawDiceArea() {
593 const cx = (SPINE1 + RX0) / 2, cy = BANDY + BAND / 2;
594 if (diceAnim) {
595 const a = diceAnim, u = Math.min(1, a.t / a.dur), n = 2, size = 42;
596 const who = a.opening ? null : G.turn;
597 for (let i = 0; i < n; i++) {
598 const settle = u > 0.82;
599 const v = settle ? a.vals[i] : 1 + Math.floor((t * 22 + i * 3.7) % 6);
600 const jx = settle ? 0 : Math.sin(t * 40 + i) * 5 * (1 - u), jy = settle ? 0 : Math.cos(t * 33 + i * 2) * 8 * (1 - u);
601 const x = cx + (i ? 1 : -1) * 32 + jx, y = cy + jy - (settle ? 0 : Math.abs(Math.sin(u * 9 + i)) * 10 * (1 - u));
602 die(ctx, x, y, size, v, a.opening ? i : who, 1, settle ? 0 : Math.sin(t * 25 + i * 2) * 0.5 * (1 - u));
603 }
604 return;
605 }
606 if (G.dice && G.dice.length && phase !== "roll") {
607 const list = E.turnDice(G.dice), pool = (G.rem || []).slice(), dbl = list.length === 4, size = dbl ? 31 : 42, gap = dbl ? 6 : 14;
608 const totalW = list.length * size + (list.length - 1) * gap, x0 = cx - totalW / 2 + size / 2;
609 const p = G.openDice ? null : G.turn;
610 list.forEach((v, i) => {
611 const k = pool.indexOf(v), used = k < 0; if (!used) pool.splice(k, 1);
612 die(ctx, x0 + i * (size + gap), cy, size, v, G.openDice ? (G.dice[0] > G.dice[1] ? (i === 0 ? 0 : 1) : (i === 0 ? 0 : 1)) : p, used ? 0.35 : 1, used ? 0 : (i % 2 ? 0.05 : -0.05));
613 });
614 } else if (phase === "roll" && isHuman(G.turn)) {
615 const pulse = 0.7 + 0.3 * Math.sin(t * 4);
616 ctx.globalAlpha = pulse; text("TAP TO ROLL", cx, cy, 16, "#ffe9b0", "center", 900); ctx.globalAlpha = 1;
617 }
618 }
619 function drawHeader() {
620 btn("menu", 10, 10, 64, 40, "Menu", () => { tok++; timers = []; phase = "title"; sel = null; diceAnim = null; flights = []; hidden = {}; }, { font: 14 });
621 let line1;
622 if (isCpu()) line1 = "You " + G.score[0] + " – " + G.score[1] + " CPU"; else line1 = "White " + G.score[0] + " – " + G.score[1] + " Black";
623 text(line1, W / 2, 20, 18, "#ffe9b0", "center", 900);
624 const st = phase === "title" ? "" : G.status || "";
625 text(st, W / 2, 44, 13, "rgba(240,220,180,.9)", "center", 700);
626 if (cfg.cubeOn && cfg.target > 1) {
627 const x = 350, y = 5, sz = 40, cb = G.cube, can = canDouble(0) && isHuman(0) || (canDouble(1) && isHuman(1));
628 const cp = cb.owner === -1 ? null : cb.owner;
629 ctx.save();
630 const g = ctx.createLinearGradient(x, y, x + sz, y + sz); g.addColorStop(0, "#fff8e4"); g.addColorStop(1, "#d6c69c");
631 ctx.shadowColor = "rgba(0,0,0,.5)"; ctx.shadowBlur = 6; ctx.shadowOffsetY = 2; ctx.fillStyle = g; rr(ctx, x, y, sz, sz, 8); ctx.fill(); ctx.shadowBlur = 0;
632 ctx.strokeStyle = can ? "#ffcf4a" : "#8a7752"; ctx.lineWidth = can ? 3 : 1.5; ctx.stroke();
633 ctx.fillStyle = "#2b1a08"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = "900 " + (cb.v > 9 ? 20 : 26) + "px system-ui"; ctx.fillText(String(cb.owner === -1 && cb.v === 1 ? 64 : cb.v), x + sz / 2, y + sz / 2 + 1);
634 ctx.restore();
635 text(cp === null ? "cube: centre" : "cube: " + nameOf(cp).toLowerCase(), x + sz / 2 - 6, y + sz + 8, 10, "rgba(230,205,160,.75)", "center", 700);
636 }
637 }
638 function drawButtons() {
639 const y = BTN_Y, hum = isHuman(G.turn);
640 btn("undo", BX, y, 112, BTN_H, "Undo", undo, { enabled: phase === "move" && !G.queue && G.undo.length > 0 && hum });
641 let label = "…", fn = () => {}, en = false, primary = false;
642 if (phase === "roll" && hum) { label = "ROLL DICE"; fn = rollDice; en = true; primary = true; }
643 else if (phase === "move" && hum) { if (G.pend) { label = "END TURN"; fn = endTurn; en = true; primary = true; } else { const left = G.maxLen - G.played.length; label = left + (left === 1 ? " move left" : " moves left"); } }
644 else if (phase === "rolling" || phase === "opening") label = "Rolling…";
645 else if (phase === "noMoves") label = "No legal moves";
646 else if (phase === "cpu" || (phase === "roll" && !hum)) label = "Computer…";
647 else if (phase === "offer") label = "Your decision";
648 btn("main", BX + 122, y, 164, BTN_H, label, fn, { enabled: en, primary, font: 18 });
649 const dbl = canDouble(G.turn) && hum;
650 if (dbl) btn("dbl", BX + 296, y, 112, BTN_H, "Double\n×" + G.cube.v * 2, () => offerDouble(G.turn), { font: 16 });
651 else btn("hint", BX + 296, y, 112, BTN_H, "Hint", showHint, { enabled: phase === "move" && !G.queue && hum && !G.pend });
652 }
653 function overlay(a) { ctx.fillStyle = "rgba(8,4,2," + a + ")"; ctx.fillRect(0, 0, W, H); }
654 function card(x, y, w, h) {
655 ctx.save(); ctx.shadowColor = "rgba(0,0,0,.6)"; ctx.shadowBlur = 24; ctx.shadowOffsetY = 8;
656 const g = ctx.createLinearGradient(0, y, 0, y + h); g.addColorStop(0, "#5b3a1f"); g.addColorStop(1, "#33200f");
657 ctx.fillStyle = g; rr(ctx, x, y, w, h, 20); ctx.fill(); ctx.restore();
658 ctx.strokeStyle = "rgba(240,200,130,.55)"; ctx.lineWidth = 1.5; rr(ctx, x, y, w, h, 20); ctx.stroke();
659 }
660 function drawOffer() {
661 overlay(0.55); ui = ui.filter((b) => false);
662 const o = G.offer, x = 30, y = 300, w = W - 60, h = 250;
663 card(x, y, w, h);
664 text("Doubling cube", W / 2, y + 36, 22, "#ffe9b0", "center", 900);
665 text(nameOf(o.from) + (o.from === 1 && isCpu() ? " doubles the stakes to " : " doubles the stakes to ") + o.nv, W / 2, y + 74, 15, "#f0dcb5", "center", 700);
666 text("Take: play on for " + o.nv + " points.", W / 2, y + 104, 13, "rgba(240,220,180,.85)", "center", 600);
667 text("Drop: concede and lose " + G.cube.v + (G.cube.v === 1 ? " point." : " points."), W / 2, y + 124, 13, "rgba(240,220,180,.85)", "center", 600);
668 if (!isCpu()) text(nameOf(o.to) + ", choose:", W / 2, y + 150, 13, "#ffe9b0", "center", 800);
669 btn("take", x + 20, y + 168, (w - 60) / 2, 56, "Take", () => { phase = "roll"; resolveDouble(o.from, true); }, { primary: true, font: 20 });
670 btn("drop", x + 40 + (w - 60) / 2, y + 168, (w - 60) / 2, 56, "Drop", () => { phase = "roll"; resolveDouble(o.from, false); }, { font: 20 });
671 }
672 function drawResult() {
673 overlay(0.6); ui = ui.filter((b) => false);
674 const r = G.result, match = phase === "matchEnd", x = 24, y = match ? 220 : 250, w = W - 48, h = match ? 440 : 330;
675 card(x, y, w, h);
676 const winName = isCpu() ? (r.winner === 0 ? "You win" : "Computer wins") : nameOf(r.winner) + " wins";
677 text(match ? (isCpu() ? (r.winner === 0 ? "Match won!" : "Match lost") : nameOf(r.winner) + " wins the match!") : winName + " the game", W / 2, y + 42, 26, "#ffe9b0", "center", 900);
678 checker(ctx, W / 2, y + 100, 26, r.winner);
679 const kind = r.why === "drop" ? "Opponent dropped the double" : r.kind === "backgammon" ? "Backgammon! (×3)" : r.kind === "gammon" ? "Gammon! (×2)" : "Single game";
680 text(kind, W / 2, y + 152, 17, "#ffd57a", "center", 800);
681 text("+" + r.pts + (r.pts === 1 ? " point" : " points") + (r.cube > 1 ? " (cube ×" + r.cube + ")" : ""), W / 2, y + 180, 16, "#f0dcb5", "center", 700);
682 text((isCpu() ? "You " : "White ") + G.score[0] + " – " + G.score[1] + (isCpu() ? " Computer" : " Black") + " (to " + cfg.target + ")", W / 2, y + 214, 15, "#ffe9b0", "center", 800);
683 if (match && isCpu()) {
684 text("Score submitted: " + G.finalScore, W / 2, y + 246, 14, "#9dffb4", "center", 800);
685 text("Record vs computer: " + stats.wins + " W – " + stats.losses + " L", W / 2, y + 268, 12, "rgba(240,220,180,.75)", "center", 600);
686 }
687 if (match) {
688 btn("again", x + 24, y + h - 130, w - 48, 54, "Play again", () => newMatch(), { primary: true, font: 19 });
689 btn("tomenu", x + 24, y + h - 66, w - 48, 48, "Menu", () => { phase = "title"; }, { font: 16 });
690 } else btn("next", x + 24, y + h - 84, w - 48, 60, "Next game", () => { newGame(); }, { primary: true, font: 20 });
691 }
692 function chip(id, x, y, w, label, on, fn, en) { btn(id, x, y, w, 46, label, fn, { on, font: 15, r: 10, enabled: en !== false }); }
693 function drawTitle() {
694 overlay(0.95); ui = [];
695 text("BACKGAMMON", W / 2, 84, 40, "#ffe9b0", "center", 900);
696 text("CLUB", W / 2, 122, 22, "#d9b26a", "center", 900);
697 checker(ctx, W / 2 - 34, 168, 20, 0); checker(ctx, W / 2 + 34, 168, 20, 1);
698 die(ctx, W / 2, 170, 30, 5, 0, 1, 0.2);
699 let y = 236;
700 text("OPPONENT", 40, y, 12, "#c9a86c", "left", 800);
701 chip("m-cpu", 40, y + 14, 156, "vs Computer", cfg.mode === "cpu", () => { cfg.mode = "cpu"; store.set("cfg", cfg); });
702 chip("m-two", 210, y + 14, 170, "2 Players", cfg.mode === "two", () => { cfg.mode = "two"; store.set("cfg", cfg); });
703 y += 78;
704 text(cfg.mode === "cpu" ? "DIFFICULTY" : "DIFFICULTY (vs computer only)", 40, y, 12, "#c9a86c", "left", 800);
705 ["easy", "medium", "hard"].forEach((l, i) => chip("l-" + l, 40 + i * 114, y + 14, 106, cap(l), cfg.level === l && cfg.mode === "cpu", () => { cfg.level = l; store.set("cfg", cfg); }, cfg.mode === "cpu"));
706 y += 78;
707 text("MATCH LENGTH", 40, y, 12, "#c9a86c", "left", 800);
708 [1, 3, 5].forEach((n, i) => chip("t-" + n, 40 + i * 114, y + 14, 106, n + (n === 1 ? " point" : " points"), cfg.target === n, () => { cfg.target = n; store.set("cfg", cfg); }));
709 y += 78;
710 text("DOUBLING CUBE", 40, y, 12, "#c9a86c", "left", 800);
711 chip("c-on", 40, y + 14, 156, "On", cfg.cubeOn && cfg.target > 1, () => { cfg.cubeOn = true; store.set("cfg", cfg); }, cfg.target > 1);
712 chip("c-off", 210, y + 14, 170, "Off", !cfg.cubeOn || cfg.target === 1, () => { cfg.cubeOn = false; store.set("cfg", cfg); });
713 if (cfg.target === 1) text("(no cube in a 1-point match)", 40, y + 76, 11, "rgba(230,200,150,.6)", "left", 600);
714 y += 92;
715 btn("start", 40, y, W - 80, 62, "PLAY", () => { store.set("cfg", cfg); newMatch(); }, { primary: true, font: 26 });
716 text("Gammon = 2 pts · Backgammon = 3 pts", W / 2, y + 90, 12, "rgba(230,205,160,.75)", "center", 700);
717 if (stats.matches) text("Record vs computer: " + stats.wins + " W – " + stats.losses + " L", W / 2, y + 112, 12, "rgba(230,205,160,.6)", "center", 600);
718 }
719
720 let last = performance.now(), speed = 1;
721 function loop(now) {
722 const dt = Math.min(0.05, (now - last) / 1000); last = now;
723 if (!paused) { if (speed > 1) for (let i = 0; i < speed; i++) update(dt); else update(dt); }
724 draw();
725 requestAnimationFrame(loop);
726 }
727 // Debug/test hook (harmless in production)
728 window.__bg = { force(st, turn, dice) { tok++; timers = []; flights = []; hidden = {}; G.s = st; G.turn = turn; G.dice = dice; G.openDice = false; G.played = []; G.undo = []; G.queue = false; beginMoves(); }, offerDouble, endGame, get G() { return G; }, get phase() { return phase; }, get sel() { return sel; }, get ui() { return ui; }, cfg, slotXY, hitTest, ptXY: (i) => slotXY("p" + i, 0, 1), BANDY, BTN_Y, BOT_Y, TOP_Y, H, W, set paused(v) { paused = v; }, set speed(v) { speed = v; } };
729 requestAnimationFrame(loop);
730})();
731