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; }; type Layout = { width: number; height: number; pad: number; cellW: number; cellH: number; zeroX: number; zeroW: number; gridX: number; gridY: number; gridW: number; dozensY: number; columnsY: number; outsideY: number; outsideH: number; zones: Zone[]; hotspots: Hotspot[]; }; const WHEEL = [ 0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10, 5, 24, 16, 33, 1, 20, 14, 31, 9, 22, 18, 29, 7, 28, 12, 35, 3, 26, ]; const RED_NUMBERS = new Set([ 1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36, ]); const RED = "#b0313a"; const BLACK = "#151b21"; const ZERO = "#106b45"; const FELT = "#0d5c39"; const FELT_LINE = "#1f7d52"; const TEXT = "#ecf7f0"; const GOLD = "#e0b64a"; const SURFACE = "#11171d"; let observer: ResizeObserver | null = null; function colorOf(number: number): "red" | "black" | "green" { if (number === 0) return "green"; return RED_NUMBERS.has(number) ? "red" : "black"; } function layout(width: number, height: number): Layout { const pad = 14; const zeroW = Math.max(44, width * 0.058); const outsideH = Math.max(28, Math.min(38, height * 0.075)); const gridW = width - pad * 2 - zeroW; const cellW = gridW / 12; const cellH = (height - pad * 2 - outsideH * 3) / 3; const gridX = pad + zeroW; const gridY = pad; const dozensY = gridY + cellH * 3; const columnsY = dozensY + outsideH; const outsideY = columnsY + outsideH; 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 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", }); zones.push({ x: gridX + (gridW / 3) * index, y: columnsY, w: gridW / 3, h: outsideH, target: { bet: "column", index: 2 - index }, label: "2:1", kind: "column", }); } 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", }); }); 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) { hotspots.push({ x: gridX, y: gridY + row * cellH + cellH / 2, radius: 10, rank: 1, target: { bet: "split", a: 0, b: 3 - row }, }); } return { width, height, pad, cellW, cellH, zeroX: pad, zeroW, gridX, gridY, gridW, dozensY, columnsY, outsideY, outsideH, zones, hotspots, }; } function rect( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, ): void { ctx.beginPath(); ctx.rect(x, y, w, h); ctx.closePath(); } function drawTable(canvas: HTMLCanvasElement, view: View): Layout { const width = canvas.clientWidth || 900; const height = canvas.clientHeight || 500; 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); // The whole felt is green; cells sit on it with thin lines, like the desktop. ctx.fillStyle = FELT; rect(ctx, map.pad, map.gridY, map.zeroW + map.gridW, map.cellH * 3 + map.outsideH * 3); ctx.fill(); for (const zone of map.zones) { let fill = FELT; if (zone.kind === "number") { fill = colorOf(zone.number!) === "red" ? RED : BLACK; } else if (zone.kind === "zero") { fill = ZERO; } ctx.fillStyle = fill; ctx.strokeStyle = FELT_LINE; ctx.lineWidth = 1; rect(ctx, zone.x + 0.5, zone.y + 0.5, zone.w - 1, zone.h - 1); ctx.fill(); ctx.stroke(); ctx.fillStyle = TEXT; ctx.textAlign = "center"; ctx.textBaseline = "middle"; if (zone.kind === "number" || zone.kind === "zero") { ctx.font = `500 ${Math.min(18, map.cellH * 0.16)}px "Open Sans", sans-serif`; } else { ctx.font = `500 ${Math.min(12, map.outsideH * 0.4)}px "Open Sans", sans-serif`; } if (zone.label === "RED") ctx.fillStyle = "#e05a5a"; if (zone.label === "BLACK") ctx.fillStyle = "#8a98a6"; 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 }; 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" && 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(17, map.cellW * 0.28)); 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 "Hack", monospace`; ctx.fillText(String(chip.amount), center.x, center.y + 0.5); } } function drawWheel(canvas: HTMLCanvasElement, view: View): void { const size = 170; const dpr = window.devicePixelRatio || 1; canvas.width = Math.round(size * dpr); canvas.height = Math.round(size * dpr); canvas.style.width = `${size}px`; canvas.style.height = `${size}px`; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const result = view.phase === "result" && view.number !== null && view.number !== undefined ? Number(view.number) : null; const targetIndex = result === null ? -1 : WHEEL.indexOf(result); const step = (Math.PI * 2) / WHEEL.length; const targetRotation = targetIndex < 0 ? 0 : -targetIndex * step; const start = performance.now(); const duration = 650; const paint = (rotation: number) => { const cx = size / 2; const cy = size / 2; const outer = size * 0.46; const inner = size * 0.29; ctx.clearRect(0, 0, size, size); ctx.fillStyle = SURFACE; ctx.beginPath(); ctx.arc(cx, cy, outer + 6, 0, Math.PI * 2); ctx.fill(); WHEEL.forEach((number, index) => { const angle = -Math.PI / 2 + index * step + rotation; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, outer, angle - step / 2, angle + step / 2); ctx.closePath(); ctx.fillStyle = colorOf(number) === "red" ? RED : colorOf(number) === "green" ? ZERO : BLACK; ctx.fill(); ctx.strokeStyle = FELT_LINE; ctx.lineWidth = 0.8; ctx.stroke(); }); ctx.beginPath(); ctx.arc(cx, cy, inner, 0, Math.PI * 2); ctx.fillStyle = "#0a0e12"; ctx.fill(); ctx.strokeStyle = FELT_LINE; ctx.lineWidth = 1; ctx.stroke(); // Pointer at twelve o'clock. ctx.beginPath(); ctx.moveTo(cx, 8); ctx.lineTo(cx - 5, 18); ctx.lineTo(cx + 5, 18); ctx.closePath(); ctx.fillStyle = TEXT; ctx.fill(); if (result !== null) { ctx.fillStyle = TEXT; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = '600 15px "Hack", monospace'; ctx.fillText(String(result), cx, cy); } }; const animate = (now: number) => { const progress = Math.min(1, (now - start) / duration); const eased = 1 - (1 - progress) ** 3; paint(targetRotation * eased); if (progress < 1) requestAnimationFrame(animate); }; requestAnimationFrame(animate); } export function renderRoulette( board: HTMLElement, side: HTMLElement, view: View, send: (command: unknown) => void, ): void { clear(board); clear(side); const spinning = view.phase === "spinning"; const canvas = node("canvas", "roulette-canvas") as HTMLCanvasElement; board.append(canvas); const result = node("div", "board-foot tight"); result.append(node("span", "label", "Last")); if (view.phase === "result" && view.number !== null && view.number !== undefined) { const value = node( "span", view.winnings && Number(view.winnings) > 0 ? "win-text" : undefined, `${view.number} ${view.color} · ${Number(view.winnings ?? 0) > 0 ? money(Number(view.winnings)) : "no win"}`, ); result.append(value); } else { result.append(node("span", undefined, "No spins yet")); } result.append( node("span", "note", "Click to place a chip · right-click to take one back"), ); board.append(result); const map = drawTable(canvas, view); let current = map; const hit = (clientX: number, clientY: number): Zone | null => { const bounds = canvas.getBoundingClientRect(); const x = clientX - bounds.left; const y = clientY - bounds.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 = drawTable(canvas, view); }); observer.observe(canvas); // Sidebar side.append(node("h2", undefined, "Roulette")); side.append(node("p", "note", "Pick numbers or colours, then spin.")); const wheel = node("canvas", "wheel-canvas") as HTMLCanvasElement; side.append(wheel); drawWheel(wheel, view); side.append(node("div", "label", "Place your bets")); const chips = (view.bets?.chips ?? []).reduce( (sum: number, chip: any) => sum + Number(chip.amount ?? 0), 0, ); side.append(node("p", "note", `${chips} ${chips === 1 ? "chip" : "chips"}`)); side.append(node("div", "label", "Chip")); const chipRow = node("div", "chip-row"); for (const value of [10, 25, 50, 100]) { const button = node( "button", `chip-btn ${Number(view.chip) === value ? "active" : ""}`, String(value), ) as HTMLButtonElement; button.dataset.chip = String(value); button.disabled = spinning; button.addEventListener("click", () => send({ SetChip: value })); chipRow.append(button); } side.append(chipRow); side.append(node("div", "label", "Bets")); const bets: any[] = view.bets?.chips ?? []; if (bets.length === 0) { side.append(node("p", "note", "No chips on the felt")); } else { for (const chip of bets) { const row = node("div", "stat"); row.append( node("span", "muted", chipLabel(chip.target)), node("strong", "mono", money(Number(chip.amount ?? 0))), ); side.append(row); } } const total = node("div", "stat"); total.append(node("span", "muted", "Total"), node("strong", "mono", money(chips))); side.append(total); 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 = winnings > 0 ? `WON ${money(winnings)}` : "NO WIN"; if (winnings > 0) status.classList.add("win-text"); } else status.textContent = chips > 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"); spin.disabled = spinning || chips === 0; spin.addEventListener("click", () => { if (Date.now() - lastSpin < 600) return; lastSpin = Date.now(); send("Spin"); }); const clearBets = node("button", "btn wide", "CLEAR"); clearBets.disabled = spinning || chips === 0; clearBets.addEventListener("click", () => send("ClearBets")); const leave = node("button", "btn ghost wide", "Leave table"); leave.disabled = spinning; leave.addEventListener("click", () => send("Leave")); actions.append(spin, clearBets, leave); side.append(actions); } let lastSpin = 0; function chipLabel(target: any): string { switch (target?.bet) { case "straight": return String(target.number); case "split": return `${target.a}/${target.b}`; case "corner": return target.numbers.join("/"); case "dozen": return ["1st 12", "2nd 12", "3rd 12"][target.index] ?? "dozen"; case "column": return `col ${Number(target.index) + 1}`; case "outside": return String(target.kind ?? ""); default: return ""; } }