summaryrefslogtreecommitdiffstats
path: root/web/play/src/roulette.ts
diff options
context:
space:
mode:
Diffstat (limited to 'web/play/src/roulette.ts')
-rw-r--r--web/play/src/roulette.ts339
1 files changed, 229 insertions, 110 deletions
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 = {
20 rank: number; 20 rank: number;
21}; 21};
22 22
23let observer: ResizeObserver | null = null;
24let lastSpin = 0;
25
26type Layout = { 23type Layout = {
27 width: number; 24 width: number;
28 height: number; 25 height: number;
26 pad: number;
29 cellW: number; 27 cellW: number;
30 cellH: number; 28 cellH: number;
31 zeroX: number; 29 zeroX: number;
@@ -33,42 +31,50 @@ type Layout = {
33 gridX: number; 31 gridX: number;
34 gridY: number; 32 gridY: number;
35 gridW: number; 33 gridW: number;
36 columnX: number;
37 dozensY: number; 34 dozensY: number;
35 columnsY: number;
38 outsideY: number; 36 outsideY: number;
39 outsideH: number; 37 outsideH: number;
40 zones: Zone[]; 38 zones: Zone[];
41 hotspots: Hotspot[]; 39 hotspots: Hotspot[];
42}; 40};
43 41
42const WHEEL = [
43 0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10, 5,
44 24, 16, 33, 1, 20, 14, 31, 9, 22, 18, 29, 7, 28, 12, 35, 3, 26,
45];
46const RED_NUMBERS = new Set([
47 1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36,
48]);
49
44const RED = "#b0313a"; 50const RED = "#b0313a";
45const BLACK = "#151b21"; 51const BLACK = "#151b21";
46const ZERO = "#106b45"; 52const ZERO = "#106b45";
47const FELT = "#0d5c39"; 53const FELT = "#0d5c39";
48const FELT_DARK = "#09472c";
49const FELT_LINE = "#1f7d52"; 54const FELT_LINE = "#1f7d52";
50const TEXT = "#ecf7f0"; 55const TEXT = "#ecf7f0";
51const GOLD = "#e0b64a"; 56const GOLD = "#e0b64a";
57const SURFACE = "#11171d";
52 58
53function colorOf(number: number): string { 59let observer: ResizeObserver | null = null;
60
61function colorOf(number: number): "red" | "black" | "green" {
54 if (number === 0) return "green"; 62 if (number === 0) return "green";
55 const reds = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36]; 63 return RED_NUMBERS.has(number) ? "red" : "black";
56 return reds.includes(number) ? "red" : "black";
57} 64}
58 65
59function layout(width: number, height: number): Layout { 66function layout(width: number, height: number): Layout {
60 const pad = 14; 67 const pad = 14;
61 const zeroW = Math.max(38, width * 0.055); 68 const zeroW = Math.max(44, width * 0.058);
62 const columnW = Math.max(34, width * 0.05); 69 const outsideH = Math.max(28, Math.min(38, height * 0.075));
63 const outsideH = Math.max(30, height * 0.07); 70 const gridW = width - pad * 2 - zeroW;
64 const gap = 6;
65 const gridW = width - pad * 2 - zeroW - columnW;
66 const cellW = gridW / 12; 71 const cellW = gridW / 12;
67 const cellH = (height - pad * 2 - outsideH * 2 - gap * 2) / 3; 72 const cellH = (height - pad * 2 - outsideH * 3) / 3;
68 const gridX = pad + zeroW; 73 const gridX = pad + zeroW;
69 const gridY = pad; 74 const gridY = pad;
70 const dozensY = gridY + cellH * 3 + gap; 75 const dozensY = gridY + cellH * 3;
71 const outsideY = dozensY + outsideH + gap; 76 const columnsY = dozensY + outsideH;
77 const outsideY = columnsY + outsideH;
72 const zones: Zone[] = []; 78 const zones: Zone[] = [];
73 79
74 for (let column = 0; column < 12; column += 1) { 80 for (let column = 0; column < 12; column += 1) {
@@ -98,19 +104,6 @@ function layout(width: number, height: number): Layout {
98 number: 0, 104 number: 0,
99 }); 105 });
100 106
101 for (let top = 0; top < 3; top += 1) {
102 const index = 2 - top;
103 zones.push({
104 x: gridX + gridW,
105 y: gridY + top * cellH,
106 w: columnW,
107 h: cellH,
108 target: { bet: "column", index },
109 label: "2:1",
110 kind: "column",
111 });
112 }
113
114 for (let index = 0; index < 3; index += 1) { 107 for (let index = 0; index < 3; index += 1) {
115 zones.push({ 108 zones.push({
116 x: gridX + (gridW / 3) * index, 109 x: gridX + (gridW / 3) * index,
@@ -121,6 +114,15 @@ function layout(width: number, height: number): Layout {
121 label: ["1st 12", "2nd 12", "3rd 12"][index], 114 label: ["1st 12", "2nd 12", "3rd 12"][index],
122 kind: "dozen", 115 kind: "dozen",
123 }); 116 });
117 zones.push({
118 x: gridX + (gridW / 3) * index,
119 y: columnsY,
120 w: gridW / 3,
121 h: outsideH,
122 target: { bet: "column", index: 2 - index },
123 label: "2:1",
124 kind: "column",
125 });
124 } 126 }
125 127
126 const outside = [ 128 const outside = [
@@ -143,8 +145,6 @@ function layout(width: number, height: number): Layout {
143 }); 145 });
144 }); 146 });
145 147
146 // Split and corner hotspots sit on the lines between cells, like the real
147 // felt (and the desktop fine lattice). Corners rank ahead of splits.
148 const hotspots: Hotspot[] = []; 148 const hotspots: Hotspot[] = [];
149 const cellCenter = (number: number) => { 149 const cellCenter = (number: number) => {
150 const cell = zones.find((zone) => zone.kind === "number" && zone.number === number); 150 const cell = zones.find((zone) => zone.kind === "number" && zone.number === number);
@@ -193,19 +193,19 @@ function layout(width: number, height: number): Layout {
193 } 193 }
194 194
195 for (let row = 0; row < 3; row += 1) { 195 for (let row = 0; row < 3; row += 1) {
196 const number = 3 - row;
197 hotspots.push({ 196 hotspots.push({
198 x: gridX, 197 x: gridX,
199 y: gridY + row * cellH + cellH / 2, 198 y: gridY + row * cellH + cellH / 2,
200 radius: 10, 199 radius: 10,
201 rank: 1, 200 rank: 1,
202 target: { bet: "split", a: 0, b: number }, 201 target: { bet: "split", a: 0, b: 3 - row },
203 }); 202 });
204 } 203 }
205 204
206 return { 205 return {
207 width, 206 width,
208 height, 207 height,
208 pad,
209 cellW, 209 cellW,
210 cellH, 210 cellH,
211 zeroX: pad, 211 zeroX: pad,
@@ -213,8 +213,8 @@ function layout(width: number, height: number): Layout {
213 gridX, 213 gridX,
214 gridY, 214 gridY,
215 gridW, 215 gridW,
216 columnX: gridX + gridW,
217 dozensY, 216 dozensY,
217 columnsY,
218 outsideY, 218 outsideY,
219 outsideH, 219 outsideH,
220 zones, 220 zones,
@@ -222,27 +222,21 @@ function layout(width: number, height: number): Layout {
222 }; 222 };
223} 223}
224 224
225function roundRect( 225function rect(
226 ctx: CanvasRenderingContext2D, 226 ctx: CanvasRenderingContext2D,
227 x: number, 227 x: number,
228 y: number, 228 y: number,
229 w: number, 229 w: number,
230 h: number, 230 h: number,
231 radius: number,
232): void { 231): void {
233 const r = Math.min(radius, w / 2, h / 2);
234 ctx.beginPath(); 232 ctx.beginPath();
235 ctx.moveTo(x + r, y); 233 ctx.rect(x, y, w, h);
236 ctx.arcTo(x + w, y, x + w, y + h, r);
237 ctx.arcTo(x + w, y + h, x, y + h, r);
238 ctx.arcTo(x, y + h, x, y, r);
239 ctx.arcTo(x, y, x + w, y, r);
240 ctx.closePath(); 234 ctx.closePath();
241} 235}
242 236
243function draw(canvas: HTMLCanvasElement, view: View): Layout { 237function drawTable(canvas: HTMLCanvasElement, view: View): Layout {
244 const width = canvas.clientWidth || 800; 238 const width = canvas.clientWidth || 900;
245 const height = canvas.clientHeight || 540; 239 const height = canvas.clientHeight || 500;
246 const dpr = window.devicePixelRatio || 1; 240 const dpr = window.devicePixelRatio || 1;
247 if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { 241 if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
248 canvas.width = Math.round(width * dpr); 242 canvas.width = Math.round(width * dpr);
@@ -253,18 +247,23 @@ function draw(canvas: HTMLCanvasElement, view: View): Layout {
253 if (!ctx) return map; 247 if (!ctx) return map;
254 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 248 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
255 ctx.clearRect(0, 0, width, height); 249 ctx.clearRect(0, 0, width, height);
256 ctx.fillStyle = FELT_DARK; 250
257 roundRect(ctx, 0, 0, width, height, 10); 251 // The whole felt is green; cells sit on it with thin lines, like the desktop.
252 ctx.fillStyle = FELT;
253 rect(ctx, map.pad, map.gridY, map.zeroW + map.gridW, map.cellH * 3 + map.outsideH * 3);
258 ctx.fill(); 254 ctx.fill();
259 255
260 for (const zone of map.zones) { 256 for (const zone of map.zones) {
261 let fill = FELT; 257 let fill = FELT;
262 if (zone.kind === "number") fill = zone.number === 0 ? ZERO : colorOf(zone.number!) === "red" ? RED : BLACK; 258 if (zone.kind === "number") {
263 else if (zone.kind === "zero") fill = ZERO; 259 fill = colorOf(zone.number!) === "red" ? RED : BLACK;
260 } else if (zone.kind === "zero") {
261 fill = ZERO;
262 }
264 ctx.fillStyle = fill; 263 ctx.fillStyle = fill;
265 ctx.strokeStyle = FELT_LINE; 264 ctx.strokeStyle = FELT_LINE;
266 ctx.lineWidth = 1; 265 ctx.lineWidth = 1;
267 roundRect(ctx, zone.x + 1, zone.y + 1, zone.w - 2, zone.h - 2, 3); 266 rect(ctx, zone.x + 0.5, zone.y + 0.5, zone.w - 1, zone.h - 1);
268 ctx.fill(); 267 ctx.fill();
269 ctx.stroke(); 268 ctx.stroke();
270 269
@@ -272,10 +271,12 @@ function draw(canvas: HTMLCanvasElement, view: View): Layout {
272 ctx.textAlign = "center"; 271 ctx.textAlign = "center";
273 ctx.textBaseline = "middle"; 272 ctx.textBaseline = "middle";
274 if (zone.kind === "number" || zone.kind === "zero") { 273 if (zone.kind === "number" || zone.kind === "zero") {
275 ctx.font = `700 ${Math.min(24, map.cellH * 0.3)}px ui-monospace, monospace`; 274 ctx.font = `500 ${Math.min(18, map.cellH * 0.16)}px "Open Sans", sans-serif`;
276 } else { 275 } else {
277 ctx.font = `600 ${Math.min(12, map.outsideH * 0.4)}px ui-monospace, monospace`; 276 ctx.font = `500 ${Math.min(12, map.outsideH * 0.4)}px "Open Sans", sans-serif`;
278 } 277 }
278 if (zone.label === "RED") ctx.fillStyle = "#e05a5a";
279 if (zone.label === "BLACK") ctx.fillStyle = "#8a98a6";
279 ctx.fillText(zone.label, zone.x + zone.w / 2, zone.y + zone.h / 2); 280 ctx.fillText(zone.label, zone.x + zone.w / 2, zone.y + zone.h / 2);
280 } 281 }
281 282
@@ -292,12 +293,10 @@ function centerFor(layout: Layout, target: any): { x: number; y: number } | null
292 const exact = layout.zones.find((zone) => targetKey(zone.target) === key); 293 const exact = layout.zones.find((zone) => targetKey(zone.target) === key);
293 if (exact) return { x: exact.x + exact.w / 2, y: exact.y + exact.h / 2 }; 294 if (exact) return { x: exact.x + exact.w / 2, y: exact.y + exact.h / 2 };
294 295
295 // Split and corner bets are reachable from the desktop's fine lattice. A
296 // browser session can still show them: average the covered number cells.
297 if (target?.bet === "split" || target?.bet === "corner") { 296 if (target?.bet === "split" || target?.bet === "corner") {
298 const numbers: number[] = target.bet === "split" ? [target.a, target.b] : target.numbers; 297 const numbers: number[] = target.bet === "split" ? [target.a, target.b] : target.numbers;
299 const cells = layout.zones.filter( 298 const cells = layout.zones.filter(
300 (zone) => (zone.kind === "number" || zone.kind === "zero") && numbers.includes(zone.number!), 299 (zone) => zone.kind === "number" && numbers.includes(zone.number!),
301 ); 300 );
302 if (cells.length > 0) { 301 if (cells.length > 0) {
303 return { 302 return {
@@ -314,7 +313,7 @@ function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void
314 for (const chip of chips) { 313 for (const chip of chips) {
315 const center = centerFor(map, chip.target); 314 const center = centerFor(map, chip.target);
316 if (!center) continue; 315 if (!center) continue;
317 const radius = Math.max(12, Math.min(18, map.cellW * 0.26)); 316 const radius = Math.max(12, Math.min(17, map.cellW * 0.28));
318 ctx.beginPath(); 317 ctx.beginPath();
319 ctx.arc(center.x, center.y, radius, 0, Math.PI * 2); 318 ctx.arc(center.x, center.y, radius, 0, Math.PI * 2);
320 ctx.fillStyle = GOLD; 319 ctx.fillStyle = GOLD;
@@ -325,11 +324,91 @@ function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void
325 ctx.fillStyle = "#221a05"; 324 ctx.fillStyle = "#221a05";
326 ctx.textAlign = "center"; 325 ctx.textAlign = "center";
327 ctx.textBaseline = "middle"; 326 ctx.textBaseline = "middle";
328 ctx.font = `700 ${radius > 15 ? 11 : 10}px ui-monospace, monospace`; 327 ctx.font = `700 ${radius > 15 ? 11 : 10}px "Hack", monospace`;
329 ctx.fillText(String(chip.amount), center.x, center.y + 0.5); 328 ctx.fillText(String(chip.amount), center.x, center.y + 0.5);
330 } 329 }
331} 330}
332 331
332function drawWheel(canvas: HTMLCanvasElement, view: View): void {
333 const size = 170;
334 const dpr = window.devicePixelRatio || 1;
335 canvas.width = Math.round(size * dpr);
336 canvas.height = Math.round(size * dpr);
337 canvas.style.width = `${size}px`;
338 canvas.style.height = `${size}px`;
339 const ctx = canvas.getContext("2d");
340 if (!ctx) return;
341 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
342
343 const result = view.phase === "result" && view.number !== null && view.number !== undefined
344 ? Number(view.number)
345 : null;
346 const targetIndex = result === null ? -1 : WHEEL.indexOf(result);
347 const step = (Math.PI * 2) / WHEEL.length;
348 const targetRotation = targetIndex < 0 ? 0 : -targetIndex * step;
349 const start = performance.now();
350 const duration = 650;
351
352 const paint = (rotation: number) => {
353 const cx = size / 2;
354 const cy = size / 2;
355 const outer = size * 0.46;
356 const inner = size * 0.29;
357 ctx.clearRect(0, 0, size, size);
358
359 ctx.fillStyle = SURFACE;
360 ctx.beginPath();
361 ctx.arc(cx, cy, outer + 6, 0, Math.PI * 2);
362 ctx.fill();
363
364 WHEEL.forEach((number, index) => {
365 const angle = -Math.PI / 2 + index * step + rotation;
366 ctx.beginPath();
367 ctx.moveTo(cx, cy);
368 ctx.arc(cx, cy, outer, angle - step / 2, angle + step / 2);
369 ctx.closePath();
370 ctx.fillStyle = colorOf(number) === "red" ? RED : colorOf(number) === "green" ? ZERO : BLACK;
371 ctx.fill();
372 ctx.strokeStyle = FELT_LINE;
373 ctx.lineWidth = 0.8;
374 ctx.stroke();
375 });
376
377 ctx.beginPath();
378 ctx.arc(cx, cy, inner, 0, Math.PI * 2);
379 ctx.fillStyle = "#0a0e12";
380 ctx.fill();
381 ctx.strokeStyle = FELT_LINE;
382 ctx.lineWidth = 1;
383 ctx.stroke();
384
385 // Pointer at twelve o'clock.
386 ctx.beginPath();
387 ctx.moveTo(cx, 8);
388 ctx.lineTo(cx - 5, 18);
389 ctx.lineTo(cx + 5, 18);
390 ctx.closePath();
391 ctx.fillStyle = TEXT;
392 ctx.fill();
393
394 if (result !== null) {
395 ctx.fillStyle = TEXT;
396 ctx.textAlign = "center";
397 ctx.textBaseline = "middle";
398 ctx.font = '600 15px "Hack", monospace';
399 ctx.fillText(String(result), cx, cy);
400 }
401 };
402
403 const animate = (now: number) => {
404 const progress = Math.min(1, (now - start) / duration);
405 const eased = 1 - (1 - progress) ** 3;
406 paint(targetRotation * eased);
407 if (progress < 1) requestAnimationFrame(animate);
408 };
409 requestAnimationFrame(animate);
410}
411
333export function renderRoulette( 412export function renderRoulette(
334 board: HTMLElement, 413 board: HTMLElement,
335 side: HTMLElement, 414 side: HTMLElement,
@@ -340,43 +419,44 @@ export function renderRoulette(
340 clear(side); 419 clear(side);
341 420
342 const spinning = view.phase === "spinning"; 421 const spinning = view.phase === "spinning";
343 board.append(node("h2", undefined, "Roulette"));
344 board.append(
345 node(
346 "p",
347 "note",
348 "European single-zero wheel. Click a number, line or corner to add a chip; right-click takes one back.",
349 ),
350 );
351
352 const wrap = node("div", "roulette-wrap");
353 const canvas = node("canvas", "roulette-canvas") as HTMLCanvasElement; 422 const canvas = node("canvas", "roulette-canvas") as HTMLCanvasElement;
354 wrap.append(canvas); 423 board.append(canvas);
355 424
425 const result = node("div", "board-foot tight");
426 result.append(node("span", "label", "Last"));
356 if (view.phase === "result" && view.number !== null && view.number !== undefined) { 427 if (view.phase === "result" && view.number !== null && view.number !== undefined) {
357 const result = node("div", `wheel-result ${view.color ?? ""}`); 428 const value = node(
358 result.append(node("div", "label", "Result")); 429 "span",
359 result.append(node("div", "num", String(view.number))); 430 view.winnings && Number(view.winnings) > 0 ? "win-text" : undefined,
360 const winnings = Number(view.winnings ?? 0); 431 `${view.number} ${view.color} · ${Number(view.winnings ?? 0) > 0 ? money(Number(view.winnings)) : "no win"}`,
361 result.append(node("div", "note", winnings > 0 ? `Won ${money(winnings)}` : "No win")); 432 );
362 wrap.append(result); 433 result.append(value);
434 } else {
435 result.append(node("span", undefined, "No spins yet"));
363 } 436 }
364 board.append(wrap); 437 result.append(
438 node("span", "note", "Click to place a chip · right-click to take one back"),
439 );
440 board.append(result);
365 441
366 // The canvas must be in the document before its client size is read. 442 const map = drawTable(canvas, view);
367 const map = draw(canvas, view);
368 let current = map; 443 let current = map;
369 444
370 const hit = (clientX: number, clientY: number): Zone | null => { 445 const hit = (clientX: number, clientY: number): Zone | null => {
371 const rect = canvas.getBoundingClientRect(); 446 const bounds = canvas.getBoundingClientRect();
372 const x = clientX - rect.left; 447 const x = clientX - bounds.left;
373 const y = clientY - rect.top; 448 const y = clientY - bounds.top;
374 449
375 let best: Hotspot | null = null; 450 let best: Hotspot | null = null;
376 for (const hotspot of current.hotspots) { 451 for (const hotspot of current.hotspots) {
377 const distance = Math.hypot(x - hotspot.x, y - hotspot.y); 452 const distance = Math.hypot(x - hotspot.x, y - hotspot.y);
378 if (distance > hotspot.radius) continue; 453 if (distance > hotspot.radius) continue;
379 if (!best || hotspot.rank < best.rank || (hotspot.rank === best.rank && distance < Math.hypot(x - best.x, y - best.y))) { 454 if (
455 !best ||
456 hotspot.rank < best.rank ||
457 (hotspot.rank === best.rank &&
458 distance < Math.hypot(x - best.x, y - best.y))
459 ) {
380 best = hotspot; 460 best = hotspot;
381 } 461 }
382 } 462 }
@@ -418,66 +498,105 @@ export function renderRoulette(
418 }); 498 });
419 observer?.disconnect(); 499 observer?.disconnect();
420 observer = new ResizeObserver(() => { 500 observer = new ResizeObserver(() => {
421 current = draw(canvas, view); 501 current = drawTable(canvas, view);
422 }); 502 });
423 observer.observe(canvas); 503 observer.observe(canvas);
424 504
425 // Sidebar 505 // Sidebar
426 side.append(node("div", "label", "Roulette")); 506 side.append(node("h2", undefined, "Roulette"));
427 side.append(node("p", "note", "Chips sit on the felt. The total is reserved when you spin.")); 507 side.append(node("p", "note", "Pick numbers or colours, then spin."));
508
509 const wheel = node("canvas", "wheel-canvas") as HTMLCanvasElement;
510 side.append(wheel);
511 drawWheel(wheel, view);
512
513 side.append(node("div", "label", "Place your bets"));
514 const chips = (view.bets?.chips ?? []).reduce(
515 (sum: number, chip: any) => sum + Number(chip.amount ?? 0),
516 0,
517 );
518 side.append(node("p", "note", `${chips} ${chips === 1 ? "chip" : "chips"}`));
428 519
429 side.append(node("div", "label", "Chip")); 520 side.append(node("div", "label", "Chip"));
430 const chipRow = node("div", "chip-row"); 521 const chipRow = node("div", "chip-row");
431 for (const value of [10, 25, 50, 100, 500]) { 522 for (const value of [10, 25, 50, 100]) {
432 const button = node("button", `chip-btn ${Number(view.chip) === value ? "active" : ""}`, String(value)); 523 const button = node(
524 "button",
525 `chip-btn ${Number(view.chip) === value ? "active" : ""}`,
526 String(value),
527 ) as HTMLButtonElement;
528 button.dataset.chip = String(value);
433 button.disabled = spinning; 529 button.disabled = spinning;
434 button.addEventListener("click", () => send({ SetChip: value })); 530 button.addEventListener("click", () => send({ SetChip: value }));
435 chipRow.append(button); 531 chipRow.append(button);
436 } 532 }
437 side.append(chipRow); 533 side.append(chipRow);
438 534
439 const total = (view.bets?.chips ?? []).reduce( 535 side.append(node("div", "label", "Bets"));
440 (sum: number, chip: any) => sum + Number(chip.amount ?? 0), 536 const bets: any[] = view.bets?.chips ?? [];
441 0, 537 if (bets.length === 0) {
442 ); 538 side.append(node("p", "note", "No chips on the felt"));
443 const totalRow = node("div", "stat"); 539 } else {
444 totalRow.append(node("span", "muted", "On the felt"), node("strong", "mono", money(total))); 540 for (const chip of bets) {
445 side.append(totalRow); 541 const row = node("div", "stat");
446 542 row.append(
447 if (view.balance !== null && view.balance !== undefined) { 543 node("span", "muted", chipLabel(chip.target)),
448 const balanceRow = node("div", "stat"); 544 node("strong", "mono", money(Number(chip.amount ?? 0))),
449 balanceRow.append(node("span", "muted", "Balance"), node("strong", "mono", money(Number(view.balance)))); 545 );
450 side.append(balanceRow); 546 side.append(row);
547 }
451 } 548 }
452 549
550 const total = node("div", "stat");
551 total.append(node("span", "muted", "Total"), node("strong", "mono", money(chips)));
552 side.append(total);
553
453 if (view.error) side.append(node("p", "error-text", String(view.error))); 554 if (view.error) side.append(node("p", "error-text", String(view.error)));
454 555
455 const status = node("p", "note"); 556 const status = node("p", "note");
456 if (spinning) status.textContent = "Spinning…"; 557 if (spinning) status.textContent = "SPINNING…";
457 else if (view.phase === "result") { 558 else if (view.phase === "result") {
458 const winnings = Number(view.winnings ?? 0); 559 const winnings = Number(view.winnings ?? 0);
459 status.textContent = `${view.number} ${view.color} · ${winnings > 0 ? `won ${money(winnings)}` : "no win"}`; 560 status.textContent = winnings > 0 ? `WON ${money(winnings)}` : "NO WIN";
460 status.className = winnings > 0 ? "note win-text" : "note"; 561 if (winnings > 0) status.classList.add("win-text");
461 } else status.textContent = total > 0 ? "Ready to spin." : "Place at least one chip."; 562 } else status.textContent = chips > 0 ? "Ready to spin." : "Place at least one chip.";
462 side.append(status); 563 side.append(status);
463 564
464 const actions = node("div", "actions"); 565 const actions = node("div", "actions");
465 const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN WHEEL"); 566 const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN");
466 spin.disabled = spinning || total === 0; 567 spin.disabled = spinning || chips === 0;
467 spin.addEventListener("click", () => { 568 spin.addEventListener("click", () => {
468 if (Date.now() - lastSpin < 600) return; 569 if (Date.now() - lastSpin < 600) return;
469 lastSpin = Date.now(); 570 lastSpin = Date.now();
470 send("Spin"); 571 send("Spin");
471 }); 572 });
472 const clearBets = node("button", "btn wide", "Clear bets"); 573 const clearBets = node("button", "btn wide", "CLEAR");
473 clearBets.disabled = spinning || total === 0; 574 clearBets.disabled = spinning || chips === 0;
474 clearBets.addEventListener("click", () => send("ClearBets")); 575 clearBets.addEventListener("click", () => send("ClearBets"));
475 const leave = node("button", "btn ghost wide", "Leave table"); 576 const leave = node("button", "btn ghost wide", "Leave table");
476 leave.disabled = spinning; 577 leave.disabled = spinning;
477 leave.addEventListener("click", () => { 578 leave.addEventListener("click", () => send("Leave"));
478 if (spinning) return;
479 send("Leave");
480 });
481 actions.append(spin, clearBets, leave); 579 actions.append(spin, clearBets, leave);
482 side.append(actions); 580 side.append(actions);
483} 581}
582
583let lastSpin = 0;
584
585function chipLabel(target: any): string {
586 switch (target?.bet) {
587 case "straight":
588 return String(target.number);
589 case "split":
590 return `${target.a}/${target.b}`;
591 case "corner":
592 return target.numbers.join("/");
593 case "dozen":
594 return ["1st 12", "2nd 12", "3rd 12"][target.index] ?? "dozen";
595 case "column":
596 return `col ${Number(target.index) + 1}`;
597 case "outside":
598 return String(target.kind ?? "");
599 default:
600 return "";
601 }
602}