diff options
Diffstat (limited to '')
| -rw-r--r-- | web/play/src/roulette.ts | 474 |
1 files changed, 474 insertions, 0 deletions
diff --git a/web/play/src/roulette.ts b/web/play/src/roulette.ts new file mode 100644 index 0000000..cbb7c6e --- /dev/null +++ b/web/play/src/roulette.ts | |||
| @@ -0,0 +1,474 @@ | |||
| 1 | import type { View } from "./types"; | ||
| 2 | import { clear, money, node } from "./dom"; | ||
| 3 | |||
| 4 | type Zone = { | ||
| 5 | x: number; | ||
| 6 | y: number; | ||
| 7 | w: number; | ||
| 8 | h: number; | ||
| 9 | target: any; | ||
| 10 | label: string; | ||
| 11 | kind: "number" | "zero" | "dozen" | "column" | "outside"; | ||
| 12 | number?: number; | ||
| 13 | }; | ||
| 14 | |||
| 15 | type Hotspot = { | ||
| 16 | x: number; | ||
| 17 | y: number; | ||
| 18 | radius: number; | ||
| 19 | target: any; | ||
| 20 | rank: number; | ||
| 21 | }; | ||
| 22 | |||
| 23 | type Layout = { | ||
| 24 | width: number; | ||
| 25 | height: number; | ||
| 26 | cellW: number; | ||
| 27 | cellH: number; | ||
| 28 | zeroX: number; | ||
| 29 | zeroW: number; | ||
| 30 | gridX: number; | ||
| 31 | gridY: number; | ||
| 32 | gridW: number; | ||
| 33 | columnX: number; | ||
| 34 | dozensY: number; | ||
| 35 | outsideY: number; | ||
| 36 | outsideH: number; | ||
| 37 | zones: Zone[]; | ||
| 38 | hotspots: Hotspot[]; | ||
| 39 | }; | ||
| 40 | |||
| 41 | const RED = "#b0313a"; | ||
| 42 | const BLACK = "#151b21"; | ||
| 43 | const ZERO = "#106b45"; | ||
| 44 | const FELT = "#0d5c39"; | ||
| 45 | const FELT_DARK = "#09472c"; | ||
| 46 | const FELT_LINE = "#1f7d52"; | ||
| 47 | const TEXT = "#ecf7f0"; | ||
| 48 | const GOLD = "#e0b64a"; | ||
| 49 | |||
| 50 | function colorOf(number: number): string { | ||
| 51 | if (number === 0) return "green"; | ||
| 52 | const reds = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36]; | ||
| 53 | return reds.includes(number) ? "red" : "black"; | ||
| 54 | } | ||
| 55 | |||
| 56 | function layout(width: number, height: number): Layout { | ||
| 57 | const pad = 14; | ||
| 58 | const zeroW = Math.max(38, width * 0.055); | ||
| 59 | const columnW = Math.max(34, width * 0.05); | ||
| 60 | const outsideH = Math.max(30, height * 0.07); | ||
| 61 | const gap = 6; | ||
| 62 | const gridW = width - pad * 2 - zeroW - columnW; | ||
| 63 | const cellW = gridW / 12; | ||
| 64 | const cellH = (height - pad * 2 - outsideH * 2 - gap * 2) / 3; | ||
| 65 | const gridX = pad + zeroW; | ||
| 66 | const gridY = pad; | ||
| 67 | const dozensY = gridY + cellH * 3 + gap; | ||
| 68 | const outsideY = dozensY + outsideH + gap; | ||
| 69 | const zones: Zone[] = []; | ||
| 70 | |||
| 71 | for (let column = 0; column < 12; column += 1) { | ||
| 72 | for (let row = 0; row < 3; row += 1) { | ||
| 73 | const number = column * 3 + (3 - row); | ||
| 74 | zones.push({ | ||
| 75 | x: gridX + column * cellW, | ||
| 76 | y: gridY + row * cellH, | ||
| 77 | w: cellW, | ||
| 78 | h: cellH, | ||
| 79 | target: { bet: "straight", number }, | ||
| 80 | label: String(number), | ||
| 81 | kind: "number", | ||
| 82 | number, | ||
| 83 | }); | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | zones.push({ | ||
| 88 | x: pad, | ||
| 89 | y: gridY, | ||
| 90 | w: zeroW, | ||
| 91 | h: cellH * 3, | ||
| 92 | target: { bet: "straight", number: 0 }, | ||
| 93 | label: "0", | ||
| 94 | kind: "zero", | ||
| 95 | number: 0, | ||
| 96 | }); | ||
| 97 | |||
| 98 | for (let top = 0; top < 3; top += 1) { | ||
| 99 | const index = 2 - top; | ||
| 100 | zones.push({ | ||
| 101 | x: gridX + gridW, | ||
| 102 | y: gridY + top * cellH, | ||
| 103 | w: columnW, | ||
| 104 | h: cellH, | ||
| 105 | target: { bet: "column", index }, | ||
| 106 | label: "2:1", | ||
| 107 | kind: "column", | ||
| 108 | }); | ||
| 109 | } | ||
| 110 | |||
| 111 | for (let index = 0; index < 3; index += 1) { | ||
| 112 | zones.push({ | ||
| 113 | x: gridX + (gridW / 3) * index, | ||
| 114 | y: dozensY, | ||
| 115 | w: gridW / 3, | ||
| 116 | h: outsideH, | ||
| 117 | target: { bet: "dozen", index }, | ||
| 118 | label: ["1st 12", "2nd 12", "3rd 12"][index], | ||
| 119 | kind: "dozen", | ||
| 120 | }); | ||
| 121 | } | ||
| 122 | |||
| 123 | const outside = [ | ||
| 124 | { kind: "low", label: "1–18" }, | ||
| 125 | { kind: "even", label: "EVEN" }, | ||
| 126 | { kind: "red", label: "RED" }, | ||
| 127 | { kind: "black", label: "BLACK" }, | ||
| 128 | { kind: "odd", label: "ODD" }, | ||
| 129 | { kind: "high", label: "19–36" }, | ||
| 130 | ]; | ||
| 131 | outside.forEach((item, index) => { | ||
| 132 | zones.push({ | ||
| 133 | x: gridX + (gridW / 6) * index, | ||
| 134 | y: outsideY, | ||
| 135 | w: gridW / 6, | ||
| 136 | h: outsideH, | ||
| 137 | target: { bet: "outside", kind: item.kind }, | ||
| 138 | label: item.label, | ||
| 139 | kind: "outside", | ||
| 140 | }); | ||
| 141 | }); | ||
| 142 | |||
| 143 | // Split and corner hotspots sit on the lines between cells, like the real | ||
| 144 | // felt (and the desktop fine lattice). Corners rank ahead of splits. | ||
| 145 | const hotspots: Hotspot[] = []; | ||
| 146 | const cellCenter = (number: number) => { | ||
| 147 | const cell = zones.find((zone) => zone.kind === "number" && zone.number === number); | ||
| 148 | return cell ? { x: cell.x + cell.w / 2, y: cell.y + cell.h / 2 } : null; | ||
| 149 | }; | ||
| 150 | |||
| 151 | for (let column = 0; column < 11; column += 1) { | ||
| 152 | for (let row = 0; row < 2; row += 1) { | ||
| 153 | const top = column * 3 + (3 - row); | ||
| 154 | hotspots.push({ | ||
| 155 | x: gridX + (column + 1) * cellW, | ||
| 156 | y: gridY + (row + 1) * cellH, | ||
| 157 | radius: 12, | ||
| 158 | rank: 0, | ||
| 159 | target: { bet: "corner", numbers: [top, top - 1, top + 3, top + 2] }, | ||
| 160 | }); | ||
| 161 | } | ||
| 162 | } | ||
| 163 | |||
| 164 | for (let column = 0; column < 12; column += 1) { | ||
| 165 | for (let row = 0; row < 2; row += 1) { | ||
| 166 | const top = column * 3 + (3 - row); | ||
| 167 | const center = cellCenter(top); | ||
| 168 | if (!center) continue; | ||
| 169 | hotspots.push({ | ||
| 170 | x: center.x, | ||
| 171 | y: gridY + (row + 1) * cellH, | ||
| 172 | radius: 10, | ||
| 173 | rank: 1, | ||
| 174 | target: { bet: "split", a: top, b: top - 1 }, | ||
| 175 | }); | ||
| 176 | } | ||
| 177 | } | ||
| 178 | |||
| 179 | for (let column = 0; column < 11; column += 1) { | ||
| 180 | for (let row = 0; row < 3; row += 1) { | ||
| 181 | const left = column * 3 + (3 - row); | ||
| 182 | hotspots.push({ | ||
| 183 | x: gridX + (column + 1) * cellW, | ||
| 184 | y: gridY + row * cellH + cellH / 2, | ||
| 185 | radius: 10, | ||
| 186 | rank: 1, | ||
| 187 | target: { bet: "split", a: left, b: left + 3 }, | ||
| 188 | }); | ||
| 189 | } | ||
| 190 | } | ||
| 191 | |||
| 192 | for (let row = 0; row < 3; row += 1) { | ||
| 193 | const number = 3 - row; | ||
| 194 | hotspots.push({ | ||
| 195 | x: gridX, | ||
| 196 | y: gridY + row * cellH + cellH / 2, | ||
| 197 | radius: 10, | ||
| 198 | rank: 1, | ||
| 199 | target: { bet: "split", a: 0, b: number }, | ||
| 200 | }); | ||
| 201 | } | ||
| 202 | |||
| 203 | return { | ||
| 204 | width, | ||
| 205 | height, | ||
| 206 | cellW, | ||
| 207 | cellH, | ||
| 208 | zeroX: pad, | ||
| 209 | zeroW, | ||
| 210 | gridX, | ||
| 211 | gridY, | ||
| 212 | gridW, | ||
| 213 | columnX: gridX + gridW, | ||
| 214 | dozensY, | ||
| 215 | outsideY, | ||
| 216 | outsideH, | ||
| 217 | zones, | ||
| 218 | hotspots, | ||
| 219 | }; | ||
| 220 | } | ||
| 221 | |||
| 222 | function roundRect( | ||
| 223 | ctx: CanvasRenderingContext2D, | ||
| 224 | x: number, | ||
| 225 | y: number, | ||
| 226 | w: number, | ||
| 227 | h: number, | ||
| 228 | radius: number, | ||
| 229 | ): void { | ||
| 230 | const r = Math.min(radius, w / 2, h / 2); | ||
| 231 | ctx.beginPath(); | ||
| 232 | ctx.moveTo(x + r, y); | ||
| 233 | ctx.arcTo(x + w, y, x + w, y + h, r); | ||
| 234 | ctx.arcTo(x + w, y + h, x, y + h, r); | ||
| 235 | ctx.arcTo(x, y + h, x, y, r); | ||
| 236 | ctx.arcTo(x, y, x + w, y, r); | ||
| 237 | ctx.closePath(); | ||
| 238 | } | ||
| 239 | |||
| 240 | function draw(canvas: HTMLCanvasElement, view: View): Layout { | ||
| 241 | const width = canvas.clientWidth || 800; | ||
| 242 | const height = canvas.clientHeight || 540; | ||
| 243 | const dpr = window.devicePixelRatio || 1; | ||
| 244 | if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { | ||
| 245 | canvas.width = Math.round(width * dpr); | ||
| 246 | canvas.height = Math.round(height * dpr); | ||
| 247 | } | ||
| 248 | const ctx = canvas.getContext("2d"); | ||
| 249 | const map = layout(width, height); | ||
| 250 | if (!ctx) return map; | ||
| 251 | ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | ||
| 252 | ctx.clearRect(0, 0, width, height); | ||
| 253 | ctx.fillStyle = FELT_DARK; | ||
| 254 | roundRect(ctx, 0, 0, width, height, 10); | ||
| 255 | ctx.fill(); | ||
| 256 | |||
| 257 | for (const zone of map.zones) { | ||
| 258 | let fill = FELT; | ||
| 259 | if (zone.kind === "number") fill = zone.number === 0 ? ZERO : colorOf(zone.number!) === "red" ? RED : BLACK; | ||
| 260 | else if (zone.kind === "zero") fill = ZERO; | ||
| 261 | ctx.fillStyle = fill; | ||
| 262 | ctx.strokeStyle = FELT_LINE; | ||
| 263 | ctx.lineWidth = 1; | ||
| 264 | roundRect(ctx, zone.x + 1, zone.y + 1, zone.w - 2, zone.h - 2, 3); | ||
| 265 | ctx.fill(); | ||
| 266 | ctx.stroke(); | ||
| 267 | |||
| 268 | ctx.fillStyle = TEXT; | ||
| 269 | ctx.textAlign = "center"; | ||
| 270 | ctx.textBaseline = "middle"; | ||
| 271 | if (zone.kind === "number" || zone.kind === "zero") { | ||
| 272 | ctx.font = `700 ${Math.min(24, map.cellH * 0.3)}px ui-monospace, monospace`; | ||
| 273 | } else { | ||
| 274 | ctx.font = `600 ${Math.min(12, map.outsideH * 0.4)}px ui-monospace, monospace`; | ||
| 275 | } | ||
| 276 | ctx.fillText(zone.label, zone.x + zone.w / 2, zone.y + zone.h / 2); | ||
| 277 | } | ||
| 278 | |||
| 279 | drawChips(ctx, map, view); | ||
| 280 | return map; | ||
| 281 | } | ||
| 282 | |||
| 283 | function targetKey(target: any): string { | ||
| 284 | return JSON.stringify(target); | ||
| 285 | } | ||
| 286 | |||
| 287 | function centerFor(layout: Layout, target: any): { x: number; y: number } | null { | ||
| 288 | const key = targetKey(target); | ||
| 289 | const exact = layout.zones.find((zone) => targetKey(zone.target) === key); | ||
| 290 | if (exact) return { x: exact.x + exact.w / 2, y: exact.y + exact.h / 2 }; | ||
| 291 | |||
| 292 | // Split and corner bets are reachable from the desktop's fine lattice. A | ||
| 293 | // browser session can still show them: average the covered number cells. | ||
| 294 | if (target?.bet === "split" || target?.bet === "corner") { | ||
| 295 | const numbers: number[] = target.bet === "split" ? [target.a, target.b] : target.numbers; | ||
| 296 | const cells = layout.zones.filter( | ||
| 297 | (zone) => (zone.kind === "number" || zone.kind === "zero") && numbers.includes(zone.number!), | ||
| 298 | ); | ||
| 299 | if (cells.length > 0) { | ||
| 300 | return { | ||
| 301 | x: cells.reduce((sum, cell) => sum + cell.x + cell.w / 2, 0) / cells.length, | ||
| 302 | y: cells.reduce((sum, cell) => sum + cell.y + cell.h / 2, 0) / cells.length, | ||
| 303 | }; | ||
| 304 | } | ||
| 305 | } | ||
| 306 | return null; | ||
| 307 | } | ||
| 308 | |||
| 309 | function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void { | ||
| 310 | const chips: any[] = view.bets?.chips ?? []; | ||
| 311 | for (const chip of chips) { | ||
| 312 | const center = centerFor(map, chip.target); | ||
| 313 | if (!center) continue; | ||
| 314 | const radius = Math.max(12, Math.min(18, map.cellW * 0.26)); | ||
| 315 | ctx.beginPath(); | ||
| 316 | ctx.arc(center.x, center.y, radius, 0, Math.PI * 2); | ||
| 317 | ctx.fillStyle = GOLD; | ||
| 318 | ctx.fill(); | ||
| 319 | ctx.strokeStyle = "#7a5a12"; | ||
| 320 | ctx.lineWidth = 2; | ||
| 321 | ctx.stroke(); | ||
| 322 | ctx.fillStyle = "#221a05"; | ||
| 323 | ctx.textAlign = "center"; | ||
| 324 | ctx.textBaseline = "middle"; | ||
| 325 | ctx.font = `700 ${radius > 15 ? 11 : 10}px ui-monospace, monospace`; | ||
| 326 | ctx.fillText(String(chip.amount), center.x, center.y + 0.5); | ||
| 327 | } | ||
| 328 | } | ||
| 329 | |||
| 330 | export function renderRoulette( | ||
| 331 | board: HTMLElement, | ||
| 332 | side: HTMLElement, | ||
| 333 | view: View, | ||
| 334 | send: (command: unknown) => void, | ||
| 335 | ): void { | ||
| 336 | clear(board); | ||
| 337 | clear(side); | ||
| 338 | |||
| 339 | const spinning = view.phase === "spinning"; | ||
| 340 | board.append(node("h2", undefined, "Roulette")); | ||
| 341 | board.append( | ||
| 342 | node( | ||
| 343 | "p", | ||
| 344 | "note", | ||
| 345 | "European single-zero wheel. Click a number, line or corner to add a chip; right-click takes one back.", | ||
| 346 | ), | ||
| 347 | ); | ||
| 348 | |||
| 349 | const wrap = node("div", "roulette-wrap"); | ||
| 350 | const canvas = node("canvas", "roulette-canvas") as HTMLCanvasElement; | ||
| 351 | wrap.append(canvas); | ||
| 352 | |||
| 353 | if (view.phase === "result" && view.number !== null && view.number !== undefined) { | ||
| 354 | const result = node("div", `wheel-result ${view.color ?? ""}`); | ||
| 355 | result.append(node("div", "label", "Result")); | ||
| 356 | result.append(node("div", "num", String(view.number))); | ||
| 357 | const winnings = Number(view.winnings ?? 0); | ||
| 358 | result.append(node("div", "note", winnings > 0 ? `Won ${money(winnings)}` : "No win")); | ||
| 359 | wrap.append(result); | ||
| 360 | } | ||
| 361 | board.append(wrap); | ||
| 362 | |||
| 363 | // The canvas must be in the document before its client size is read. | ||
| 364 | const map = draw(canvas, view); | ||
| 365 | let current = map; | ||
| 366 | |||
| 367 | const hit = (clientX: number, clientY: number): Zone | null => { | ||
| 368 | const rect = canvas.getBoundingClientRect(); | ||
| 369 | const x = clientX - rect.left; | ||
| 370 | const y = clientY - rect.top; | ||
| 371 | |||
| 372 | let best: Hotspot | null = null; | ||
| 373 | for (const hotspot of current.hotspots) { | ||
| 374 | const distance = Math.hypot(x - hotspot.x, y - hotspot.y); | ||
| 375 | if (distance > hotspot.radius) continue; | ||
| 376 | if (!best || hotspot.rank < best.rank || (hotspot.rank === best.rank && distance < Math.hypot(x - best.x, y - best.y))) { | ||
| 377 | best = hotspot; | ||
| 378 | } | ||
| 379 | } | ||
| 380 | if (best) { | ||
| 381 | return { | ||
| 382 | x: best.x - best.radius, | ||
| 383 | y: best.y - best.radius, | ||
| 384 | w: best.radius * 2, | ||
| 385 | h: best.radius * 2, | ||
| 386 | target: best.target, | ||
| 387 | label: "", | ||
| 388 | kind: "number", | ||
| 389 | }; | ||
| 390 | } | ||
| 391 | |||
| 392 | for (let index = current.zones.length - 1; index >= 0; index -= 1) { | ||
| 393 | const zone = current.zones[index]; | ||
| 394 | if (x >= zone.x && x <= zone.x + zone.w && y >= zone.y && y <= zone.y + zone.h) { | ||
| 395 | return zone; | ||
| 396 | } | ||
| 397 | } | ||
| 398 | return null; | ||
| 399 | }; | ||
| 400 | |||
| 401 | canvas.addEventListener("click", (event) => { | ||
| 402 | if (spinning) return; | ||
| 403 | const zone = hit(event.clientX, event.clientY); | ||
| 404 | if (zone) send({ PlaceBetAt: zone.target }); | ||
| 405 | }); | ||
| 406 | canvas.addEventListener("contextmenu", (event) => { | ||
| 407 | event.preventDefault(); | ||
| 408 | if (spinning) return; | ||
| 409 | const zone = hit(event.clientX, event.clientY); | ||
| 410 | if (zone) send({ RemoveBetAt: zone.target }); | ||
| 411 | }); | ||
| 412 | canvas.addEventListener("mousemove", (event) => { | ||
| 413 | const zone = hit(event.clientX, event.clientY); | ||
| 414 | canvas.style.cursor = zone && !spinning ? "pointer" : "default"; | ||
| 415 | }); | ||
| 416 | new ResizeObserver(() => { | ||
| 417 | current = draw(canvas, view); | ||
| 418 | }).observe(canvas); | ||
| 419 | |||
| 420 | // Sidebar | ||
| 421 | side.append(node("div", "label", "Roulette")); | ||
| 422 | side.append(node("p", "note", "Chips sit on the felt. The total is reserved when you spin.")); | ||
| 423 | |||
| 424 | side.append(node("div", "label", "Chip")); | ||
| 425 | const chipRow = node("div", "chip-row"); | ||
| 426 | for (const value of [10, 25, 50, 100, 500]) { | ||
| 427 | const button = node("button", `chip-btn ${Number(view.chip) === value ? "active" : ""}`, String(value)); | ||
| 428 | button.disabled = spinning; | ||
| 429 | button.addEventListener("click", () => send({ SetChip: value })); | ||
| 430 | chipRow.append(button); | ||
| 431 | } | ||
| 432 | side.append(chipRow); | ||
| 433 | |||
| 434 | const total = (view.bets?.chips ?? []).reduce( | ||
| 435 | (sum: number, chip: any) => sum + Number(chip.amount ?? 0), | ||
| 436 | 0, | ||
| 437 | ); | ||
| 438 | const totalRow = node("div", "stat"); | ||
| 439 | totalRow.append(node("span", "muted", "On the felt"), node("strong", "mono", money(total))); | ||
| 440 | side.append(totalRow); | ||
| 441 | |||
| 442 | if (view.balance !== null && view.balance !== undefined) { | ||
| 443 | const balanceRow = node("div", "stat"); | ||
| 444 | balanceRow.append(node("span", "muted", "Balance"), node("strong", "mono", money(Number(view.balance)))); | ||
| 445 | side.append(balanceRow); | ||
| 446 | } | ||
| 447 | |||
| 448 | if (view.error) side.append(node("p", "error-text", String(view.error))); | ||
| 449 | |||
| 450 | const status = node("p", "note"); | ||
| 451 | if (spinning) status.textContent = "Spinning…"; | ||
| 452 | else if (view.phase === "result") { | ||
| 453 | const winnings = Number(view.winnings ?? 0); | ||
| 454 | status.textContent = `${view.number} ${view.color} · ${winnings > 0 ? `won ${money(winnings)}` : "no win"}`; | ||
| 455 | status.className = winnings > 0 ? "note win-text" : "note"; | ||
| 456 | } else status.textContent = total > 0 ? "Ready to spin." : "Place at least one chip."; | ||
| 457 | side.append(status); | ||
| 458 | |||
| 459 | const actions = node("div", "actions"); | ||
| 460 | const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN WHEEL"); | ||
| 461 | spin.disabled = spinning || total === 0; | ||
| 462 | spin.addEventListener("click", () => send("Spin")); | ||
| 463 | const clearBets = node("button", "btn wide", "Clear bets"); | ||
| 464 | clearBets.disabled = spinning || total === 0; | ||
| 465 | clearBets.addEventListener("click", () => send("ClearBets")); | ||
| 466 | const leave = node("button", "btn ghost wide", "Leave table"); | ||
| 467 | leave.disabled = spinning; | ||
| 468 | leave.addEventListener("click", () => { | ||
| 469 | if (spinning) return; | ||
| 470 | send("Leave"); | ||
| 471 | }); | ||
| 472 | actions.append(spin, clearBets, leave); | ||
| 473 | side.append(actions); | ||
| 474 | } | ||
