From c45ff4525c0e45e4b8e762b81fd180c53d206d66 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 8 Nov 2026 12:24:25 +0100 Subject: web: match the desktop layout and remove play-money copy The browser now uses the desktop's full-width game split: no extra game header or card frame, a full-height sidebar with a pinned action block, and the same lobby grid, topbar account/balance blocks, and bottom-left notices. Slots uses the desktop symbol colours and gold win border; roulette gets the flat felt, column bets under the dozens, the wheel, circular chips, and a LAST line; blackjack gets a centred dealer/player table and divider; crash gets the chart panel, phase label and empty ready state. Also drops every play-money/real-money disclaimer from the page copy. --- web/play/src/roulette.ts | 339 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 229 insertions(+), 110 deletions(-) (limited to 'web/play/src/roulette.ts') diff --git a/web/play/src/roulette.ts b/web/play/src/roulette.ts index a42686a..0493d04 100644 --- a/web/play/src/roulette.ts +++ b/web/play/src/roulette.ts @@ -20,12 +20,10 @@ type Hotspot = { rank: number; }; -let observer: ResizeObserver | null = null; -let lastSpin = 0; - type Layout = { width: number; height: number; + pad: number; cellW: number; cellH: number; zeroX: number; @@ -33,42 +31,50 @@ type Layout = { gridX: number; gridY: number; gridW: number; - columnX: 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_DARK = "#09472c"; const FELT_LINE = "#1f7d52"; const TEXT = "#ecf7f0"; const GOLD = "#e0b64a"; +const SURFACE = "#11171d"; -function colorOf(number: number): string { +let observer: ResizeObserver | null = null; + +function colorOf(number: number): "red" | "black" | "green" { 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"; + return RED_NUMBERS.has(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 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 * 2 - gap * 2) / 3; + const cellH = (height - pad * 2 - outsideH * 3) / 3; const gridX = pad + zeroW; const gridY = pad; - const dozensY = gridY + cellH * 3 + gap; - const outsideY = dozensY + outsideH + gap; + const dozensY = gridY + cellH * 3; + const columnsY = dozensY + outsideH; + const outsideY = columnsY + outsideH; const zones: Zone[] = []; for (let column = 0; column < 12; column += 1) { @@ -98,19 +104,6 @@ function layout(width: number, height: number): Layout { 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, @@ -121,6 +114,15 @@ function layout(width: number, height: number): Layout { 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 = [ @@ -143,8 +145,6 @@ function layout(width: number, height: number): Layout { }); }); - // 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); @@ -193,19 +193,19 @@ function layout(width: number, height: number): Layout { } 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 }, + target: { bet: "split", a: 0, b: 3 - row }, }); } return { width, height, + pad, cellW, cellH, zeroX: pad, @@ -213,8 +213,8 @@ function layout(width: number, height: number): Layout { gridX, gridY, gridW, - columnX: gridX + gridW, dozensY, + columnsY, outsideY, outsideH, zones, @@ -222,27 +222,21 @@ function layout(width: number, height: number): Layout { }; } -function roundRect( +function rect( 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.rect(x, y, w, h); ctx.closePath(); } -function draw(canvas: HTMLCanvasElement, view: View): Layout { - const width = canvas.clientWidth || 800; - const height = canvas.clientHeight || 540; +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); @@ -253,18 +247,23 @@ function draw(canvas: HTMLCanvasElement, view: View): Layout { 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); + + // 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 = zone.number === 0 ? ZERO : colorOf(zone.number!) === "red" ? RED : BLACK; - else if (zone.kind === "zero") fill = ZERO; + 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; - roundRect(ctx, zone.x + 1, zone.y + 1, zone.w - 2, zone.h - 2, 3); + rect(ctx, zone.x + 0.5, zone.y + 0.5, zone.w - 1, zone.h - 1); ctx.fill(); ctx.stroke(); @@ -272,10 +271,12 @@ function draw(canvas: HTMLCanvasElement, view: View): Layout { 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`; + ctx.font = `500 ${Math.min(18, map.cellH * 0.16)}px "Open Sans", sans-serif`; } else { - ctx.font = `600 ${Math.min(12, map.outsideH * 0.4)}px ui-monospace, monospace`; + 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); } @@ -292,12 +293,10 @@ function centerFor(layout: Layout, target: any): { x: number; y: number } | null 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!), + (zone) => zone.kind === "number" && numbers.includes(zone.number!), ); if (cells.length > 0) { return { @@ -314,7 +313,7 @@ function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void 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)); + 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; @@ -325,11 +324,91 @@ function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void ctx.fillStyle = "#221a05"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.font = `700 ${radius > 15 ? 11 : 10}px ui-monospace, monospace`; + 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, @@ -340,43 +419,44 @@ export function renderRoulette( 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); + 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 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); + 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")); } - board.append(wrap); + result.append( + node("span", "note", "Click to place a chip · right-click to take one back"), + ); + board.append(result); - // The canvas must be in the document before its client size is read. - const map = draw(canvas, view); + const map = drawTable(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; + 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))) { + if ( + !best || + hotspot.rank < best.rank || + (hotspot.rank === best.rank && + distance < Math.hypot(x - best.x, y - best.y)) + ) { best = hotspot; } } @@ -418,66 +498,105 @@ export function renderRoulette( }); observer?.disconnect(); observer = new ResizeObserver(() => { - current = draw(canvas, view); + current = drawTable(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("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, 500]) { - const button = node("button", `chip-btn ${Number(view.chip) === value ? "active" : ""}`, String(value)); + 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); - 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); + 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…"; + 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."; + 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 WHEEL"); - spin.disabled = spinning || total === 0; + 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 bets"); - clearBets.disabled = spinning || total === 0; + 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", () => { - if (spinning) return; - send("Leave"); - }); + 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 ""; + } +} -- cgit v1.2.3