import type { View } from "./types"; import { clear, money, node } from "./dom"; type Zone = { x: number; y: number; w: number; h: number; target: any; label: string; kind: "number" | "zero" | "dozen" | "column" | "outside"; number?: number; }; type Hotspot = { x: number; y: number; radius: number; target: any; rank: number; }; let observer: ResizeObserver | null = null; let lastSpin = 0; type Layout = { width: number; height: number; cellW: number; cellH: number; zeroX: number; zeroW: number; gridX: number; gridY: number; gridW: number; columnX: number; dozensY: number; outsideY: number; outsideH: number; zones: Zone[]; hotspots: Hotspot[]; }; const RED = "#b0313a"; const BLACK = "#151b21"; const ZERO = "#106b45"; const FELT = "#0d5c39"; const FELT_DARK = "#09472c"; const FELT_LINE = "#1f7d52"; const TEXT = "#ecf7f0"; const GOLD = "#e0b64a"; function colorOf(number: number): string { if (number === 0) return "green"; const reds = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36]; return reds.includes(number) ? "red" : "black"; } function layout(width: number, height: number): Layout { const pad = 14; const zeroW = Math.max(38, width * 0.055); const columnW = Math.max(34, width * 0.05); const outsideH = Math.max(30, height * 0.07); const gap = 6; const gridW = width - pad * 2 - zeroW - columnW; const cellW = gridW / 12; const cellH = (height - pad * 2 - outsideH * 2 - gap * 2) / 3; const gridX = pad + zeroW; const gridY = pad; const dozensY = gridY + cellH * 3 + gap; const outsideY = dozensY + outsideH + gap; const zones: Zone[] = []; for (let column = 0; column < 12; column += 1) { for (let row = 0; row < 3; row += 1) { const number = column * 3 + (3 - row); zones.push({ x: gridX + column * cellW, y: gridY + row * cellH, w: cellW, h: cellH, target: { bet: "straight", number }, label: String(number), kind: "number", number, }); } } zones.push({ x: pad, y: gridY, w: zeroW, h: cellH * 3, target: { bet: "straight", number: 0 }, label: "0", kind: "zero", number: 0, }); for (let top = 0; top < 3; top += 1) { const index = 2 - top; zones.push({ x: gridX + gridW, y: gridY + top * cellH, w: columnW, h: cellH, target: { bet: "column", index }, label: "2:1", kind: "column", }); } for (let index = 0; index < 3; index += 1) { zones.push({ x: gridX + (gridW / 3) * index, y: dozensY, w: gridW / 3, h: outsideH, target: { bet: "dozen", index }, label: ["1st 12", "2nd 12", "3rd 12"][index], kind: "dozen", }); } const outside = [ { kind: "low", label: "1–18" }, { kind: "even", label: "EVEN" }, { kind: "red", label: "RED" }, { kind: "black", label: "BLACK" }, { kind: "odd", label: "ODD" }, { kind: "high", label: "19–36" }, ]; outside.forEach((item, index) => { zones.push({ x: gridX + (gridW / 6) * index, y: outsideY, w: gridW / 6, h: outsideH, target: { bet: "outside", kind: item.kind }, label: item.label, kind: "outside", }); }); // Split and corner hotspots sit on the lines between cells, like the real // felt (and the desktop fine lattice). Corners rank ahead of splits. const hotspots: Hotspot[] = []; const cellCenter = (number: number) => { const cell = zones.find((zone) => zone.kind === "number" && zone.number === number); return cell ? { x: cell.x + cell.w / 2, y: cell.y + cell.h / 2 } : null; }; for (let column = 0; column < 11; column += 1) { for (let row = 0; row < 2; row += 1) { const top = column * 3 + (3 - row); hotspots.push({ x: gridX + (column + 1) * cellW, y: gridY + (row + 1) * cellH, radius: 12, rank: 0, target: { bet: "corner", numbers: [top, top - 1, top + 3, top + 2] }, }); } } for (let column = 0; column < 12; column += 1) { for (let row = 0; row < 2; row += 1) { const top = column * 3 + (3 - row); const center = cellCenter(top); if (!center) continue; hotspots.push({ x: center.x, y: gridY + (row + 1) * cellH, radius: 10, rank: 1, target: { bet: "split", a: top, b: top - 1 }, }); } } for (let column = 0; column < 11; column += 1) { for (let row = 0; row < 3; row += 1) { const left = column * 3 + (3 - row); hotspots.push({ x: gridX + (column + 1) * cellW, y: gridY + row * cellH + cellH / 2, radius: 10, rank: 1, target: { bet: "split", a: left, b: left + 3 }, }); } } for (let row = 0; row < 3; row += 1) { const number = 3 - row; hotspots.push({ x: gridX, y: gridY + row * cellH + cellH / 2, radius: 10, rank: 1, target: { bet: "split", a: 0, b: number }, }); } return { width, height, cellW, cellH, zeroX: pad, zeroW, gridX, gridY, gridW, columnX: gridX + gridW, dozensY, outsideY, outsideH, zones, hotspots, }; } function roundRect( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, radius: number, ): void { const r = Math.min(radius, w / 2, h / 2); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } function draw(canvas: HTMLCanvasElement, view: View): Layout { const width = canvas.clientWidth || 800; const height = canvas.clientHeight || 540; const dpr = window.devicePixelRatio || 1; if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); } const ctx = canvas.getContext("2d"); const map = layout(width, height); if (!ctx) return map; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); ctx.fillStyle = FELT_DARK; roundRect(ctx, 0, 0, width, height, 10); ctx.fill(); for (const zone of map.zones) { let fill = FELT; if (zone.kind === "number") fill = zone.number === 0 ? ZERO : colorOf(zone.number!) === "red" ? RED : BLACK; else if (zone.kind === "zero") fill = ZERO; ctx.fillStyle = fill; ctx.strokeStyle = FELT_LINE; ctx.lineWidth = 1; roundRect(ctx, zone.x + 1, zone.y + 1, zone.w - 2, zone.h - 2, 3); ctx.fill(); ctx.stroke(); ctx.fillStyle = TEXT; ctx.textAlign = "center"; ctx.textBaseline = "middle"; if (zone.kind === "number" || zone.kind === "zero") { ctx.font = `700 ${Math.min(24, map.cellH * 0.3)}px ui-monospace, monospace`; } else { ctx.font = `600 ${Math.min(12, map.outsideH * 0.4)}px ui-monospace, monospace`; } ctx.fillText(zone.label, zone.x + zone.w / 2, zone.y + zone.h / 2); } drawChips(ctx, map, view); return map; } function targetKey(target: any): string { return JSON.stringify(target); } function centerFor(layout: Layout, target: any): { x: number; y: number } | null { const key = targetKey(target); const exact = layout.zones.find((zone) => targetKey(zone.target) === key); if (exact) return { x: exact.x + exact.w / 2, y: exact.y + exact.h / 2 }; // Split and corner bets are reachable from the desktop's fine lattice. A // browser session can still show them: average the covered number cells. if (target?.bet === "split" || target?.bet === "corner") { const numbers: number[] = target.bet === "split" ? [target.a, target.b] : target.numbers; const cells = layout.zones.filter( (zone) => (zone.kind === "number" || zone.kind === "zero") && numbers.includes(zone.number!), ); if (cells.length > 0) { return { x: cells.reduce((sum, cell) => sum + cell.x + cell.w / 2, 0) / cells.length, y: cells.reduce((sum, cell) => sum + cell.y + cell.h / 2, 0) / cells.length, }; } } return null; } function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void { const chips: any[] = view.bets?.chips ?? []; for (const chip of chips) { const center = centerFor(map, chip.target); if (!center) continue; const radius = Math.max(12, Math.min(18, map.cellW * 0.26)); ctx.beginPath(); ctx.arc(center.x, center.y, radius, 0, Math.PI * 2); ctx.fillStyle = GOLD; ctx.fill(); ctx.strokeStyle = "#7a5a12"; ctx.lineWidth = 2; ctx.stroke(); ctx.fillStyle = "#221a05"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = `700 ${radius > 15 ? 11 : 10}px ui-monospace, monospace`; ctx.fillText(String(chip.amount), center.x, center.y + 0.5); } } export function renderRoulette( board: HTMLElement, side: HTMLElement, view: View, send: (command: unknown) => void, ): void { clear(board); clear(side); const spinning = view.phase === "spinning"; board.append(node("h2", undefined, "Roulette")); board.append( node( "p", "note", "European single-zero wheel. Click a number, line or corner to add a chip; right-click takes one back.", ), ); const wrap = node("div", "roulette-wrap"); const canvas = node("canvas", "roulette-canvas") as HTMLCanvasElement; wrap.append(canvas); if (view.phase === "result" && view.number !== null && view.number !== undefined) { const result = node("div", `wheel-result ${view.color ?? ""}`); result.append(node("div", "label", "Result")); result.append(node("div", "num", String(view.number))); const winnings = Number(view.winnings ?? 0); result.append(node("div", "note", winnings > 0 ? `Won ${money(winnings)}` : "No win")); wrap.append(result); } board.append(wrap); // The canvas must be in the document before its client size is read. const map = draw(canvas, view); let current = map; const hit = (clientX: number, clientY: number): Zone | null => { const rect = canvas.getBoundingClientRect(); const x = clientX - rect.left; const y = clientY - rect.top; let best: Hotspot | null = null; for (const hotspot of current.hotspots) { const distance = Math.hypot(x - hotspot.x, y - hotspot.y); if (distance > hotspot.radius) continue; if (!best || hotspot.rank < best.rank || (hotspot.rank === best.rank && distance < Math.hypot(x - best.x, y - best.y))) { best = hotspot; } } if (best) { return { x: best.x - best.radius, y: best.y - best.radius, w: best.radius * 2, h: best.radius * 2, target: best.target, label: "", kind: "number", }; } for (let index = current.zones.length - 1; index >= 0; index -= 1) { const zone = current.zones[index]; if (x >= zone.x && x <= zone.x + zone.w && y >= zone.y && y <= zone.y + zone.h) { return zone; } } return null; }; canvas.addEventListener("click", (event) => { if (spinning) return; const zone = hit(event.clientX, event.clientY); if (zone) send({ PlaceBetAt: zone.target }); }); canvas.addEventListener("contextmenu", (event) => { event.preventDefault(); if (spinning) return; const zone = hit(event.clientX, event.clientY); if (zone) send({ RemoveBetAt: zone.target }); }); canvas.addEventListener("mousemove", (event) => { const zone = hit(event.clientX, event.clientY); canvas.style.cursor = zone && !spinning ? "pointer" : "default"; }); observer?.disconnect(); observer = new ResizeObserver(() => { current = draw(canvas, view); }); observer.observe(canvas); // Sidebar side.append(node("div", "label", "Roulette")); side.append(node("p", "note", "Chips sit on the felt. The total is reserved when you spin.")); side.append(node("div", "label", "Chip")); const chipRow = node("div", "chip-row"); for (const value of [10, 25, 50, 100, 500]) { const button = node("button", `chip-btn ${Number(view.chip) === value ? "active" : ""}`, String(value)); button.disabled = spinning; button.addEventListener("click", () => send({ SetChip: value })); chipRow.append(button); } side.append(chipRow); const total = (view.bets?.chips ?? []).reduce( (sum: number, chip: any) => sum + Number(chip.amount ?? 0), 0, ); const totalRow = node("div", "stat"); totalRow.append(node("span", "muted", "On the felt"), node("strong", "mono", money(total))); side.append(totalRow); if (view.balance !== null && view.balance !== undefined) { const balanceRow = node("div", "stat"); balanceRow.append(node("span", "muted", "Balance"), node("strong", "mono", money(Number(view.balance)))); side.append(balanceRow); } if (view.error) side.append(node("p", "error-text", String(view.error))); const status = node("p", "note"); if (spinning) status.textContent = "Spinning…"; else if (view.phase === "result") { const winnings = Number(view.winnings ?? 0); status.textContent = `${view.number} ${view.color} · ${winnings > 0 ? `won ${money(winnings)}` : "no win"}`; status.className = winnings > 0 ? "note win-text" : "note"; } else status.textContent = total > 0 ? "Ready to spin." : "Place at least one chip."; side.append(status); const actions = node("div", "actions"); const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN WHEEL"); spin.disabled = spinning || total === 0; spin.addEventListener("click", () => { if (Date.now() - lastSpin < 600) return; lastSpin = Date.now(); send("Spin"); }); const clearBets = node("button", "btn wide", "Clear bets"); clearBets.disabled = spinning || total === 0; clearBets.addEventListener("click", () => send("ClearBets")); const leave = node("button", "btn ghost wide", "Leave table"); leave.disabled = spinning; leave.addEventListener("click", () => { if (spinning) return; send("Leave"); }); actions.append(spin, clearBets, leave); side.append(actions); }