summaryrefslogtreecommitdiffstats
path: root/web/play/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--web/play/src/blackjack.ts58
-rw-r--r--web/play/src/crash.ts56
-rw-r--r--web/play/src/history.ts15
-rw-r--r--web/play/src/main.ts97
-rw-r--r--web/play/src/roulette.ts339
-rw-r--r--web/play/src/slots.ts32
6 files changed, 362 insertions, 235 deletions
diff --git a/web/play/src/blackjack.ts b/web/play/src/blackjack.ts
index 49d46fa..3288244 100644
--- a/web/play/src/blackjack.ts
+++ b/web/play/src/blackjack.ts
@@ -24,12 +24,11 @@ function cardElement(card: string): HTMLElement {
24 24
25function handBlock(label: string, cards: string[], total: number): HTMLElement { 25function handBlock(label: string, cards: string[], total: number): HTMLElement {
26 const block = node("div", "hand"); 26 const block = node("div", "hand");
27 block.append(node("div", "label", label)); 27 const head = node("div", "hand-head");
28 head.append(node("span", "label", label), node("strong", "total", String(total)));
28 const row = node("div", "cards"); 29 const row = node("div", "cards");
29 for (const card of cards) row.append(cardElement(card)); 30 for (const card of cards) row.append(cardElement(card));
30 const sum = node("div", "total"); 31 block.append(head, row);
31 sum.append(document.createTextNode("Total "), node("strong", undefined, String(total)));
32 block.append(row, sum);
33 return block; 32 return block;
34} 33}
35 34
@@ -70,36 +69,31 @@ export function renderBlackjack(
70 const done = hand?.phase === "done" || view.status === "done"; 69 const done = hand?.phase === "done" || view.status === "done";
71 const canDeal = !hand || done; 70 const canDeal = !hand || done;
72 71
73 board.append(node("h2", undefined, "Blackjack")); 72 const table = node("div", "bj-table");
74 board.append(
75 node("p", "note", "Six decks. Dealer stands on all 17. Blackjack pays 3:2."),
76 );
77
78 if (hand) { 73 if (hand) {
79 board.append(handBlock("Dealer", hand.dealer ?? [], Number(hand.dealer_total ?? 0))); 74 table.append(handBlock("DEALER", hand.dealer ?? [], Number(hand.dealer_total ?? 0)));
80 board.append(handBlock("You", hand.player ?? [], Number(hand.player_total ?? 0))); 75 table.append(node("div", "bj-divider"));
76 table.append(handBlock("YOU", hand.player ?? [], Number(hand.player_total ?? 0)));
81 } else { 77 } else {
82 const empty = node("div", "outcome"); 78 const empty = node("div", "empty-table", "Place a bet and deal a hand.");
83 empty.textContent = "Press DEAL to start a hand."; 79 table.append(empty);
84 board.append(empty);
85 } 80 }
81 board.append(table);
86 82
87 if (done || (hand && !playerPhase)) { 83 const foot = node("div", "board-foot");
88 const result = node("div", "outcome"); 84 foot.append(
89 result.textContent = outcome(view, hand); 85 node("span", "label", "Hand"),
90 if (String(hand?.result) === "win" || String(hand?.result) === "blackjack" || String(hand?.result) === "push") { 86 node("span", undefined, hand ? outcome(view, hand) : "Waiting"),
91 result.classList.add("win-text"); 87 );
92 } 88 board.append(foot);
93 board.append(result);
94 }
95 89
96 if (view.error) board.append(node("p", "error-text", String(view.error))); 90 if (view.error) board.append(node("p", "error-text", String(view.error)));
97 91
98 // Sidebar 92 // Sidebar
99 side.append(node("div", "label", "Blackjack")); 93 side.append(node("h2", undefined, "Blackjack"));
100 const bet = Number(hand?.bet ?? view.bet ?? 0); 94 const bet = Number(hand?.bet ?? view.bet ?? 0);
101 const balance = view.balance ?? view.hand?.balance; 95 const balance = view.balance ?? view.hand?.balance;
102 side.append(node("p", "note", "Hit, stand or double. Forfeiting loses the reserved bet.")); 96 side.append(node("p", "note", "Get closer to 21 without going over."));
103 97
104 if (canDeal) { 98 if (canDeal) {
105 const betRow = node("div", "row"); 99 const betRow = node("div", "row");
@@ -122,6 +116,22 @@ export function renderBlackjack(
122 side.append(stat); 116 side.append(stat);
123 } 117 }
124 118
119 if (done || (hand && !playerPhase)) {
120 const resultLine = node("p", "note win-text");
121 resultLine.textContent = `${String(hand?.result ?? "").toUpperCase()} ${money(Number(hand?.winnings ?? 0))}`;
122 if (!["win", "blackjack", "push"].includes(String(hand?.result))) {
123 resultLine.className = "note";
124 }
125 side.append(resultLine);
126 } else if (playerPhase) {
127 const actionsText = (hand?.actions ?? []).includes("double")
128 ? "Hit, stand or double."
129 : "Hit or stand.";
130 side.append(node("p", "note", actionsText));
131 } else if (!hand) {
132 side.append(node("p", "note", "Press DEAL to start a hand."));
133 }
134
125 const actions = node("div", "actions"); 135 const actions = node("div", "actions");
126 if (playerPhase) { 136 if (playerPhase) {
127 const actionsRow = node("div", "row wrap"); 137 const actionsRow = node("div", "row wrap");
diff --git a/web/play/src/crash.ts b/web/play/src/crash.ts
index a683dcd..0f18bd2 100644
--- a/web/play/src/crash.ts
+++ b/web/play/src/crash.ts
@@ -44,26 +44,22 @@ function build(
44 sendCommand = send; 44 sendCommand = send;
45 clock = now; 45 clock = now;
46 46
47 board.append(node("h2", undefined, "Crash"));
48 board.append(
49 node("p", "note", "The multiplier rises from 1.00×. Cash out before it crashes."),
50 );
51
52 const wrap = node("div", "crash-wrap"); 47 const wrap = node("div", "crash-wrap");
53 const number = node("div", "crash-number", "1.00×"); 48 const number = node("div", "crash-number", "1.00×");
49 const phase = node("div", "label", "READY");
54 const meta = node("div", "crash-meta"); 50 const meta = node("div", "crash-meta");
55 const canvas = node("canvas", "crash-canvas") as HTMLCanvasElement; 51 const canvas = node("canvas", "crash-canvas") as HTMLCanvasElement;
56 const verify = node("div", "verify", "Waiting for a round commitment."); 52 const verify = node("div", "verify", "Waiting for a round commitment.");
57 wrap.append(number, meta, canvas, verify); 53 wrap.append(number, phase, meta, canvas, verify);
58 board.append(wrap); 54 board.append(wrap);
59 55
60 side.append(node("div", "label", "Crash")); 56 side.append(node("h2", undefined, "Crash"));
61 side.append(node("p", "note", "The server commits to a seed before the round. The seed is revealed when it ends.")); 57 side.append(node("p", "note", "Cash out before the multiplier crashes."));
62 58
63 const betRow = node("div", "row"); 59 const betRow = node("div", "row");
64 const minus = node("button", "btn small", "−10") as HTMLButtonElement; 60 const minus = node("button", "btn small", "−") as HTMLButtonElement;
65 const bet = node("strong", "mono", "—"); 61 const bet = node("strong", "mono", "—");
66 const plus = node("button", "btn small", "+10") as HTMLButtonElement; 62 const plus = node("button", "btn small", "+") as HTMLButtonElement;
67 minus.addEventListener("click", () => sendCommand?.({ AdjustBet: -10 })); 63 minus.addEventListener("click", () => sendCommand?.({ AdjustBet: -10 }));
68 plus.addEventListener("click", () => sendCommand?.({ AdjustBet: 10 })); 64 plus.addEventListener("click", () => sendCommand?.({ AdjustBet: 10 }));
69 betRow.append(minus, bet, plus); 65 betRow.append(minus, bet, plus);
@@ -80,7 +76,7 @@ function build(
80 side.append(row); 76 side.append(row);
81 } 77 }
82 78
83 const status = node("p", "note", "Ready."); 79 const status = node("p", "note", "Press START to play.");
84 const lastCrash = node("p", "note", ""); 80 const lastCrash = node("p", "note", "");
85 const error = node("p", "error-text"); 81 const error = node("p", "error-text");
86 side.append(status, lastCrash, error); 82 side.append(status, lastCrash, error);
@@ -188,7 +184,7 @@ export function renderCrash(
188 p.status.textContent = "Round forfeited."; 184 p.status.textContent = "Round forfeited.";
189 p.status.className = "note error-text"; 185 p.status.className = "note error-text";
190 } else { 186 } else {
191 p.status.textContent = "Ready. Start a round when you want."; 187 p.status.textContent = "Press START to play.";
192 p.status.className = "note"; 188 p.status.className = "note";
193 } 189 }
194 190
@@ -197,7 +193,9 @@ export function renderCrash(
197 } 193 }
198 194
199 const commit = view.commit ? String(view.commit) : ""; 195 const commit = view.commit ? String(view.commit) : "";
200 p.meta.textContent = commit ? `commit ${shortHash(commit, 12)}` : "no commitment yet"; 196 p.meta.textContent = commit ? `commit ${shortHash(commit, 12)}` : "";
197 p.meta.style.display = commit ? "" : "none";
198 p.verify.style.display = commit ? "" : "none";
201 199
202 if (view.seed && commit) { 200 if (view.seed && commit) {
203 const token = ++verifyToken; 201 const token = ++verifyToken;
@@ -249,6 +247,19 @@ function currentMultiplier(view: View, now: Clock): number {
249function update(p: Parts, view: View, now: Clock): void { 247function update(p: Parts, view: View, now: Clock): void {
250 const multiplier = currentMultiplier(view, now); 248 const multiplier = currentMultiplier(view, now);
251 p.number.textContent = `${multiplier.toFixed(2)}×`; 249 p.number.textContent = `${multiplier.toFixed(2)}×`;
250 const phase = p.number.nextElementSibling as HTMLElement | null;
251 if (phase) {
252 phase.textContent =
253 view.status === "running"
254 ? "RUNNING"
255 : view.status === "cashed"
256 ? "CASHED OUT"
257 : view.status === "crashed"
258 ? "CRASHED"
259 : view.status === "starting"
260 ? "STARTING"
261 : "READY";
262 }
252 p.number.className = `crash-number ${ 263 p.number.className = `crash-number ${
253 view.status === "running" 264 view.status === "running"
254 ? "running" 265 ? "running"
@@ -296,6 +307,8 @@ function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, no
296 ctx.strokeStyle = "#242e38"; 307 ctx.strokeStyle = "#242e38";
297 ctx.fillStyle = "#8a98a6"; 308 ctx.fillStyle = "#8a98a6";
298 ctx.font = "11px ui-monospace, monospace"; 309 ctx.font = "11px ui-monospace, monospace";
310 ctx.textAlign = "left";
311 ctx.textBaseline = "middle";
299 ctx.lineWidth = 1; 312 ctx.lineWidth = 1;
300 for (let step = 0; step <= 4; step += 1) { 313 for (let step = 0; step <= 4; step += 1) {
301 const value = 1 + ((yMax - 1) * step) / 4; 314 const value = 1 + ((yMax - 1) * step) / 4;
@@ -304,7 +317,7 @@ function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, no
304 ctx.moveTo(padL, py); 317 ctx.moveTo(padL, py);
305 ctx.lineTo(width - padR, py); 318 ctx.lineTo(width - padR, py);
306 ctx.stroke(); 319 ctx.stroke();
307 ctx.fillText(`${value.toFixed(1)}×`, 6, py + 4); 320 ctx.fillText(`${value.toFixed(1)}×`, 10, py);
308 } 321 }
309 for (let step = 0; step <= 4; step += 1) { 322 for (let step = 0; step <= 4; step += 1) {
310 const seconds = (xMax * step) / 4; 323 const seconds = (xMax * step) / 4;
@@ -315,7 +328,20 @@ function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, no
315 ctx.strokeStyle = "#1b232c"; 328 ctx.strokeStyle = "#1b232c";
316 ctx.stroke(); 329 ctx.stroke();
317 ctx.fillStyle = "#5b6773"; 330 ctx.fillStyle = "#5b6773";
318 ctx.fillText(`${seconds.toFixed(0)}s`, px - 10, height - 12); 331 ctx.textAlign = "center";
332 ctx.textBaseline = "alphabetic";
333 ctx.fillText(`${seconds.toFixed(0)}s`, px, height - 12);
334 ctx.textAlign = "left";
335 ctx.textBaseline = "middle";
336 }
337
338 if (!["running", "cashed", "crashed"].includes(String(view.status))) {
339 ctx.fillStyle = "#5b6773";
340 ctx.textAlign = "center";
341 ctx.textBaseline = "middle";
342 ctx.font = '13px "Open Sans", sans-serif';
343 ctx.fillText("Press START to play", padL + plotW / 2, padT + plotH / 2);
344 return;
319 } 345 }
320 346
321 const line = 347 const line =
diff --git a/web/play/src/history.ts b/web/play/src/history.ts
index 9e7a564..943c8b8 100644
--- a/web/play/src/history.ts
+++ b/web/play/src/history.ts
@@ -1,5 +1,5 @@
1import type { View } from "./types"; 1import type { View } from "./types";
2import { clear, formatTime, money, node, signedMoney } from "./dom"; 2import { clear, money, node } from "./dom";
3 3
4function summary(record: any): string { 4function summary(record: any): string {
5 const details = record.details ?? {}; 5 const details = record.details ?? {};
@@ -37,21 +37,20 @@ export function renderHistory(root: HTMLElement, view: View): void {
37 const table = node("table"); 37 const table = node("table");
38 const head = node("thead"); 38 const head = node("thead");
39 const headRow = node("tr"); 39 const headRow = node("tr");
40 for (const label of ["Time", "Game", "Bet", "Result", "Net", "Balance"]) { 40 for (const label of ["When", "Game", "Bet", "Won", "Balance", "Result"]) {
41 headRow.append(node("th", undefined, label)); 41 headRow.append(node("th", undefined, label));
42 } 42 }
43 head.append(headRow); 43 head.append(headRow);
44 const body = node("tbody"); 44 const body = node("tbody");
45 for (const record of records) { 45 for (const record of records) {
46 const row = node("tr"); 46 const row = node("tr");
47 row.append(node("td", "mono", formatTime(String(record.at ?? "")))); 47 row.append(node("td", "mono", String(record.at ?? "").slice(0, 16)));
48 row.append(node("td", undefined, String(record.game ?? ""))); 48 row.append(node("td", "mono", String(record.game ?? "").toUpperCase()));
49 row.append(node("td", "num", money(Number(record.bet ?? 0)))); 49 row.append(node("td", "num", money(Number(record.bet ?? 0))));
50 row.append(node("td", "muted", summary(record))); 50 const winnings = Number(record.winnings ?? 0);
51 const net = Number(record.winnings ?? 0) - Number(record.bet ?? 0); 51 row.append(node("td", `num ${winnings > 0 ? "pos" : ""}`, money(winnings)));
52 const netCell = node("td", `num ${net > 0 ? "pos" : net < 0 ? "neg" : ""}`, net === 0 ? "0 Kr" : signedMoney(net));
53 row.append(netCell);
54 row.append(node("td", "num", money(Number(record.balance_after ?? 0)))); 52 row.append(node("td", "num", money(Number(record.balance_after ?? 0))));
53 row.append(node("td", "muted", summary(record)));
55 body.append(row); 54 body.append(row);
56 } 55 }
57 table.append(head, body); 56 table.append(head, body);
diff --git a/web/play/src/main.ts b/web/play/src/main.ts
index 690d827..2ca4ab5 100644
--- a/web/play/src/main.ts
+++ b/web/play/src/main.ts
@@ -17,17 +17,18 @@ const accountBalance = byId("account-balance");
17const gameGrid = byId("game-grid"); 17const gameGrid = byId("game-grid");
18const gameBoard = byId("game-board"); 18const gameBoard = byId("game-board");
19const gameSide = byId("game-side"); 19const gameSide = byId("game-side");
20const gameTitle = byId("game-title"); 20const versionPill = byId("version-pill");
21const gameBadge = byId("game-badge"); 21const accountNumber = byId("account-number");
22const lobbyAccount = byId("lobby-account"); 22const lobbyCount = byId("lobby-count");
23
23const historyBody = byId("history-body"); 24const historyBody = byId("history-body");
24const authForm = byId<HTMLFormElement>("auth-form"); 25const authForm = byId<HTMLFormElement>("auth-form");
25const authUser = byId<HTMLInputElement>("auth-user"); 26const authUser = byId<HTMLInputElement>("auth-user");
26const authPass = byId<HTMLInputElement>("auth-pass"); 27const authPass = byId<HTMLInputElement>("auth-pass");
27const authError = byId("auth-error"); 28const authError = byId("auth-error");
28const authSubmit = byId<HTMLButtonElement>("auth-submit"); 29const authSubmit = byId<HTMLButtonElement>("auth-submit");
29const tabLogin = byId<HTMLButtonElement>("tab-login"); 30const authSwitch = byId<HTMLButtonElement>("auth-switch");
30const tabRegister = byId<HTMLButtonElement>("tab-register"); 31const authTitle = byId("auth-title");
31const reconnectOverlay = byId("reconnect-overlay"); 32const reconnectOverlay = byId("reconnect-overlay");
32const reconnectMessage = byId("reconnect-message"); 33const reconnectMessage = byId("reconnect-message");
33const replacedOverlay = byId("replaced-overlay"); 34const replacedOverlay = byId("replaced-overlay");
@@ -39,6 +40,7 @@ let authMode: "login" | "register" = "login";
39let infoFor: string | null = null; 40let infoFor: string | null = null;
40let serverOffset = 0; 41let serverOffset = 0;
41let reconnectAttempts = 0; 42let reconnectAttempts = 0;
43let lastScreen: string | null = null;
42const bets = new Map<string, number>([ 44const bets = new Map<string, number>([
43 ["slots", 10], 45 ["slots", 10],
44 ["roulette", 10], 46 ["roulette", 10],
@@ -54,10 +56,10 @@ const titles: Record<string, string> = {
54}; 56};
55 57
56const descriptions: Record<string, string> = { 58const descriptions: Record<string, string> = {
57 slots: "Five reels, five paylines. Match symbols from the left.", 59 slots: "Spin the reels. Three or more matching symbols from the left win.",
58 roulette: "European single-zero wheel with inside and outside bets.", 60 roulette: "Put chips on the table, then spin. Straight numbers pay the most.",
59 blackjack: "Six-deck shoe. Blackjack pays 3:2.", 61 blackjack: "Get closer to 21 than the dealer without going over.",
60 crash: "A rising multiplier. Cash out before it crashes.", 62 crash: "Start the round, then cash out before the multiplier crashes.",
61}; 63};
62 64
63const basePath = location.pathname.includes("/play/") 65const basePath = location.pathname.includes("/play/")
@@ -83,9 +85,13 @@ const gateway = new Gateway(wsUrl, {
83 serverOffset = hello.time - Date.now(); 85 serverOffset = hello.time - Date.now();
84 currentUser = hello.user ?? null; 86 currentUser = hello.user ?? null;
85 if (typeof hello.balance === "number") lastBalance = hello.balance; 87 if (typeof hello.balance === "number") lastBalance = hello.balance;
86 gameBadge.textContent = `v${hello.version}`; 88 versionPill.textContent = `v${hello.version}`;
87 }, 89 },
88 onView(next) { 90 onView(next) {
91 if (next.screen !== lastScreen) {
92 byId("toasts").replaceChildren();
93 lastScreen = next.screen;
94 }
89 view = next; 95 view = next;
90 if (next.screen !== "slots") stopSlotsAnimation(); 96 if (next.screen !== "slots") stopSlotsAnimation();
91 if (next.screen !== "crash") stopCrashAnimation(); 97 if (next.screen !== "crash") stopCrashAnimation();
@@ -124,12 +130,12 @@ const gateway = new Gateway(wsUrl, {
124 authUser.focus(); 130 authUser.focus();
125 } 131 }
126 authSubmit.disabled = false; 132 authSubmit.disabled = false;
127 authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; 133 authSubmit.textContent = authMode === "login" ? "LOG IN" : "CREATE ACCOUNT";
128 }, 134 },
129 onError(text) { 135 onError(text) {
130 toast(text, "bad"); 136 toast(text, "bad");
131 authSubmit.disabled = false; 137 authSubmit.disabled = false;
132 authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; 138 authSubmit.textContent = authMode === "login" ? "LOG IN" : "CREATE ACCOUNT";
133 }, 139 },
134 onReplaced() { 140 onReplaced() {
135 replacedOverlay.hidden = false; 141 replacedOverlay.hidden = false;
@@ -166,9 +172,9 @@ function serverNow(): number {
166 172
167function setAuthMode(mode: "login" | "register"): void { 173function setAuthMode(mode: "login" | "register"): void {
168 authMode = mode; 174 authMode = mode;
169 tabLogin.classList.toggle("active", mode === "login"); 175 authTitle.textContent = mode === "login" ? "Sign in" : "Create account";
170 tabRegister.classList.toggle("active", mode === "register"); 176 authSubmit.textContent = mode === "login" ? "LOG IN" : "CREATE ACCOUNT";
171 authSubmit.textContent = mode === "login" ? "Log in" : "Create account"; 177 authSwitch.textContent = mode === "login" ? "CREATE ACCOUNT" : "BACK TO SIGN IN";
172 authPass.autocomplete = mode === "login" ? "current-password" : "new-password"; 178 authPass.autocomplete = mode === "login" ? "current-password" : "new-password";
173 authError.textContent = ""; 179 authError.textContent = "";
174} 180}
@@ -192,6 +198,7 @@ function render(): void {
192 accountName.textContent = String(user); 198 accountName.textContent = String(user);
193 } else { 199 } else {
194 sessionBar.hidden = true; 200 sessionBar.hidden = true;
201 accountNumber.textContent = "—";
195 } 202 }
196 203
197 setBalance(lastBalance); 204 setBalance(lastBalance);
@@ -219,8 +226,6 @@ function render(): void {
219 case "blackjack": 226 case "blackjack":
220 case "crash": 227 case "crash":
221 show("game"); 228 show("game");
222 gameTitle.textContent = titles[view.screen] ?? view.screen;
223 gameBadge.textContent = badge(view);
224 if (view.screen === "slots") renderSlots(gameBoard, gameSide, view, send); 229 if (view.screen === "slots") renderSlots(gameBoard, gameSide, view, send);
225 else if (view.screen === "roulette") renderRoulette(gameBoard, gameSide, view, send); 230 else if (view.screen === "roulette") renderRoulette(gameBoard, gameSide, view, send);
226 else if (view.screen === "blackjack") renderBlackjack(gameBoard, gameSide, view, send); 231 else if (view.screen === "blackjack") renderBlackjack(gameBoard, gameSide, view, send);
@@ -231,47 +236,23 @@ function render(): void {
231 } 236 }
232} 237}
233 238
234function badge(view: View): string {
235 if (view.screen === "crash") return String(view.status ?? "");
236 if (view.screen === "blackjack") return String(view.status ?? "");
237 if (view.screen === "slots") return String(view.phase ?? "");
238 if (view.screen === "roulette") return String(view.phase ?? "");
239 return "";
240}
241
242function renderLobby(lobby: View): void { 239function renderLobby(lobby: View): void {
243 lobbyAccount.textContent = lobby.account ? `Account ${lobby.account}` : ""; 240 accountNumber.textContent = lobby.account ? String(lobby.account) : "—";
241 const order = ["slots", "roulette", "blackjack", "crash"];
242 const list: [string, string][] = [...(lobby.games ?? [])].sort(
243 (a, b) => order.indexOf(a[0]) - order.indexOf(b[0]),
244 );
245 lobbyCount.textContent = `${list.length} ${list.length === 1 ? "game" : "games"}`;
244 gameGrid.replaceChildren(); 246 gameGrid.replaceChildren();
245 const list: [string, string][] = lobby.games ?? [];
246 for (const [id, name] of list) { 247 for (const [id, name] of list) {
247 const card = node("article", `game-card ${id}`); 248 const card = node("article", `game-card ${id}`);
248 card.append(node("h3", undefined, name || titles[id] || id)); 249 card.append(node("h3", undefined, name || titles[id] || id));
249 card.append(node("p", undefined, descriptions[id] ?? "Play a round.")); 250 card.append(node("p", undefined, descriptions[id] ?? "Play a round."));
250 251 const play = node("button", "btn primary wide", "PLAY");
251 const row = node("div", "row wrap");
252 const label = node("label", "field");
253 label.style.flex = "1";
254 label.append(node("span", "label", "Bet"));
255 const input = node("input", "input bet-input") as HTMLInputElement;
256 input.type = "number";
257 input.min = "1";
258 input.step = "10";
259 input.value = String(bets.get(id) ?? 10);
260 input.addEventListener("change", () => {
261 const value = Math.max(1, Math.floor(Number(input.value) || 1));
262 input.value = String(value);
263 bets.set(id, value);
264 });
265 label.append(input);
266
267 const play = node("button", "btn primary", id === "roulette" ? "Open table" : "Play");
268 play.addEventListener("click", () => { 252 play.addEventListener("click", () => {
269 const bet = Math.max(1, Math.floor(Number(input.value) || 1)); 253 send({ Play: { game: id, bet: bets.get(id) ?? 10 } });
270 bets.set(id, bet);
271 send({ Play: { game: id, bet } });
272 }); 254 });
273 row.append(label, play); 255 card.append(play);
274 card.append(row);
275 gameGrid.append(card); 256 gameGrid.append(card);
276 } 257 }
277 258
@@ -280,9 +261,10 @@ function renderLobby(lobby: View): void {
280 } 261 }
281} 262}
282 263
283// Auth tabs and form ------------------------------------------------------- 264// Auth mode and form -------------------------------------------------------
284tabLogin.addEventListener("click", () => setAuthMode("login")); 265authSwitch.addEventListener("click", () =>
285tabRegister.addEventListener("click", () => setAuthMode("register")); 266 setAuthMode(authMode === "login" ? "register" : "login"),
267);
286 268
287authForm.addEventListener("submit", async (event) => { 269authForm.addEventListener("submit", async (event) => {
288 event.preventDefault(); 270 event.preventDefault();
@@ -312,16 +294,7 @@ authForm.addEventListener("submit", async (event) => {
312// Chrome and navigation ---------------------------------------------------- 294// Chrome and navigation ----------------------------------------------------
313byId("logout").addEventListener("click", () => send("Logout")); 295byId("logout").addEventListener("click", () => send("Logout"));
314byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } })); 296byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } }));
315byId("refresh-lobby").addEventListener("click", () => send("Info"));
316byId("history-back").addEventListener("click", () => send("ShowLobby")); 297byId("history-back").addEventListener("click", () => send("ShowLobby"));
317byId("leave-game").addEventListener("click", () => {
318 if (!view) return;
319 const live =
320 (view.screen === "blackjack" && view.status === "player") ||
321 (view.screen === "crash" && view.status === "running");
322 if (live && !window.confirm("Leaving now forfeits the active bet. Continue?")) return;
323 send("Leave");
324});
325byId("reload-replaced").addEventListener("click", () => location.reload()); 298byId("reload-replaced").addEventListener("click", () => location.reload());
326byId("reload-reconnect").addEventListener("click", () => location.reload()); 299byId("reload-reconnect").addEventListener("click", () => location.reload());
327 300
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}
diff --git a/web/play/src/slots.ts b/web/play/src/slots.ts
index 8b821cf..cfc9070 100644
--- a/web/play/src/slots.ts
+++ b/web/play/src/slots.ts
@@ -49,10 +49,6 @@ export function renderSlots(
49 clear(board); 49 clear(board);
50 clear(side); 50 clear(side);
51 51
52 const title = node("h2", undefined, "Slots");
53 const note = node("p", "note", "Match three or more symbols from the left. Five paylines.");
54 board.append(title, note);
55
56 const reels = node("div", "reels"); 52 const reels = node("div", "reels");
57 const tiles: HTMLElement[] = []; 53 const tiles: HTMLElement[] = [];
58 const flat = Array.isArray(view.reels) 54 const flat = Array.isArray(view.reels)
@@ -81,24 +77,28 @@ export function renderSlots(
81 board.append(reels); 77 board.append(reels);
82 78
83 const lines = node("div", "win-lines"); 79 const lines = node("div", "win-lines");
80 let resultText = "Ready";
84 if (!active && view.phase === "result") { 81 if (!active && view.phase === "result") {
85 const winnings = Number(view.winnings ?? 0); 82 const winnings = Number(view.winnings ?? 0);
86 if (winnings > 0) { 83 if (winnings > 0) {
87 const names = (view.win_lines ?? []) 84 const names = (view.win_lines ?? [])
88 .map((line: number) => `${line} (${LINE_NAMES[line - 1] ?? "line"})`) 85 .map((line: number) => `${line} (${LINE_NAMES[line - 1] ?? "line"})`)
89 .join(" · "); 86 .join(" · ");
90 lines.textContent = `${view.win_type === "mega" ? "MEGA WIN" : view.win_type === "big" ? "BIG WIN" : "WIN"} ${money(winnings)}${names ? ` · ${names}` : ""}`; 87 resultText = `${view.win_type === "mega" ? "MEGA WIN" : view.win_type === "big" ? "BIG WIN" : "WIN"} ${money(winnings)}${names ? ` · ${names}` : ""}`;
88 lines.textContent = names;
91 lines.classList.add("win-text"); 89 lines.classList.add("win-text");
92 } else { 90 } else {
93 lines.textContent = "No win — spin again."; 91 resultText = "No win";
94 } 92 }
95 } else if (active) { 93 } else if (active) {
96 lines.textContent = "Spinning…"; 94 resultText = "Spinning…";
97 } else {
98 lines.textContent = "Pick a bet and spin.";
99 } 95 }
100 board.append(lines); 96 board.append(lines);
101 97
98 const foot = node("div", "board-foot");
99 foot.append(node("span", "label", "Result"), node("span", undefined, resultText));
100 board.append(foot);
101
102 if (active) { 102 if (active) {
103 let tick = 0; 103 let tick = 0;
104 animation = window.setInterval(() => { 104 animation = window.setInterval(() => {
@@ -117,13 +117,13 @@ export function renderSlots(
117 } 117 }
118 118
119 // Sidebar 119 // Sidebar
120 side.append(node("div", "label", "Slots")); 120 side.append(node("h2", undefined, "Slots"));
121 side.append(node("p", "note", "5 reels × 3 rows, 5 paylines.")); 121 side.append(node("p", "note", "Match three or more symbols from the left."));
122 122
123 const betRow = node("div", "row"); 123 const betRow = node("div", "row");
124 const minus = node("button", "btn small", "−10"); 124 const minus = node("button", "btn small", "−");
125 const bet = node("strong", "mono", money(Number(view.bet ?? 0))); 125 const bet = node("strong", "mono", money(Number(view.bet ?? 0)));
126 const plus = node("button", "btn small", "+10"); 126 const plus = node("button", "btn small", "+");
127 minus.disabled = active; 127 minus.disabled = active;
128 plus.disabled = active; 128 plus.disabled = active;
129 minus.addEventListener("click", () => send({ AdjustBet: -10 })); 129 minus.addEventListener("click", () => send({ AdjustBet: -10 }));
@@ -136,12 +136,12 @@ export function renderSlots(
136 } 136 }
137 137
138 const status = node("p", "note"); 138 const status = node("p", "note");
139 if (active) status.textContent = "Spinning…"; 139 if (active) status.textContent = "SPINNING…";
140 else if (view.phase === "result") { 140 else if (view.phase === "result") {
141 const winnings = Number(view.winnings ?? 0); 141 const winnings = Number(view.winnings ?? 0);
142 status.textContent = winnings > 0 ? `Won ${money(winnings)}` : "No win this spin."; 142 status.textContent = winnings > 0 ? `WON ${money(winnings)}` : "NO WIN";
143 if (winnings > 0) status.classList.add("win-text"); 143 if (winnings > 0) status.classList.add("win-text");
144 } else status.textContent = "Ready when you are."; 144 } else status.textContent = "Press SPIN to play.";
145 side.append(status); 145 side.append(status);
146 146
147 const actions = node("div", "actions"); 147 const actions = node("div", "actions");