diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-08 12:24:25 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-08 12:24:25 +0100 |
| commit | c45ff4525c0e45e4b8e762b81fd180c53d206d66 (patch) | |
| tree | bd0ffca5835d612a884d973e65f17cf954d69239 /web/play/src | |
| parent | web: remove .git from repo link (diff) | |
| download | zagan-bet-c45ff4525c0e45e4b8e762b81fd180c53d206d66.tar.gz zagan-bet-c45ff4525c0e45e4b8e762b81fd180c53d206d66.zip | |
web: match the desktop layout and remove play-money copy
The browser now uses the desktop's full-width game split: no extra game header or card frame, a full-height sidebar with a pinned action block, and the same lobby grid, topbar account/balance blocks, and bottom-left notices. Slots uses the desktop symbol colours and gold win border; roulette gets the flat felt, column bets under the dozens, the wheel, circular chips, and a LAST line; blackjack gets a centred dealer/player table and divider; crash gets the chart panel, phase label and empty ready state.
Also drops every play-money/real-money disclaimer from the page copy.
Diffstat (limited to 'web/play/src')
| -rw-r--r-- | web/play/src/blackjack.ts | 58 | ||||
| -rw-r--r-- | web/play/src/crash.ts | 56 | ||||
| -rw-r--r-- | web/play/src/history.ts | 15 | ||||
| -rw-r--r-- | web/play/src/main.ts | 97 | ||||
| -rw-r--r-- | web/play/src/roulette.ts | 339 | ||||
| -rw-r--r-- | web/play/src/slots.ts | 32 |
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 | ||
| 25 | function handBlock(label: string, cards: string[], total: number): HTMLElement { | 25 | function 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 { | |||
| 249 | function update(p: Parts, view: View, now: Clock): void { | 247 | function 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 @@ | |||
| 1 | import type { View } from "./types"; | 1 | import type { View } from "./types"; |
| 2 | import { clear, formatTime, money, node, signedMoney } from "./dom"; | 2 | import { clear, money, node } from "./dom"; |
| 3 | 3 | ||
| 4 | function summary(record: any): string { | 4 | function 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"); | |||
| 17 | const gameGrid = byId("game-grid"); | 17 | const gameGrid = byId("game-grid"); |
| 18 | const gameBoard = byId("game-board"); | 18 | const gameBoard = byId("game-board"); |
| 19 | const gameSide = byId("game-side"); | 19 | const gameSide = byId("game-side"); |
| 20 | const gameTitle = byId("game-title"); | 20 | const versionPill = byId("version-pill"); |
| 21 | const gameBadge = byId("game-badge"); | 21 | const accountNumber = byId("account-number"); |
| 22 | const lobbyAccount = byId("lobby-account"); | 22 | const lobbyCount = byId("lobby-count"); |
| 23 | |||
| 23 | const historyBody = byId("history-body"); | 24 | const historyBody = byId("history-body"); |
| 24 | const authForm = byId<HTMLFormElement>("auth-form"); | 25 | const authForm = byId<HTMLFormElement>("auth-form"); |
| 25 | const authUser = byId<HTMLInputElement>("auth-user"); | 26 | const authUser = byId<HTMLInputElement>("auth-user"); |
| 26 | const authPass = byId<HTMLInputElement>("auth-pass"); | 27 | const authPass = byId<HTMLInputElement>("auth-pass"); |
| 27 | const authError = byId("auth-error"); | 28 | const authError = byId("auth-error"); |
| 28 | const authSubmit = byId<HTMLButtonElement>("auth-submit"); | 29 | const authSubmit = byId<HTMLButtonElement>("auth-submit"); |
| 29 | const tabLogin = byId<HTMLButtonElement>("tab-login"); | 30 | const authSwitch = byId<HTMLButtonElement>("auth-switch"); |
| 30 | const tabRegister = byId<HTMLButtonElement>("tab-register"); | 31 | const authTitle = byId("auth-title"); |
| 31 | const reconnectOverlay = byId("reconnect-overlay"); | 32 | const reconnectOverlay = byId("reconnect-overlay"); |
| 32 | const reconnectMessage = byId("reconnect-message"); | 33 | const reconnectMessage = byId("reconnect-message"); |
| 33 | const replacedOverlay = byId("replaced-overlay"); | 34 | const replacedOverlay = byId("replaced-overlay"); |
| @@ -39,6 +40,7 @@ let authMode: "login" | "register" = "login"; | |||
| 39 | let infoFor: string | null = null; | 40 | let infoFor: string | null = null; |
| 40 | let serverOffset = 0; | 41 | let serverOffset = 0; |
| 41 | let reconnectAttempts = 0; | 42 | let reconnectAttempts = 0; |
| 43 | let lastScreen: string | null = null; | ||
| 42 | const bets = new Map<string, number>([ | 44 | const 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 | ||
| 56 | const descriptions: Record<string, string> = { | 58 | const 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 | ||
| 63 | const basePath = location.pathname.includes("/play/") | 65 | const 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 | ||
| 167 | function setAuthMode(mode: "login" | "register"): void { | 173 | function 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 | ||
| 234 | function 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 | |||
| 242 | function renderLobby(lobby: View): void { | 239 | function 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 ------------------------------------------------------- |
| 284 | tabLogin.addEventListener("click", () => setAuthMode("login")); | 265 | authSwitch.addEventListener("click", () => |
| 285 | tabRegister.addEventListener("click", () => setAuthMode("register")); | 266 | setAuthMode(authMode === "login" ? "register" : "login"), |
| 267 | ); | ||
| 286 | 268 | ||
| 287 | authForm.addEventListener("submit", async (event) => { | 269 | authForm.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 ---------------------------------------------------- |
| 313 | byId("logout").addEventListener("click", () => send("Logout")); | 295 | byId("logout").addEventListener("click", () => send("Logout")); |
| 314 | byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } })); | 296 | byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } })); |
| 315 | byId("refresh-lobby").addEventListener("click", () => send("Info")); | ||
| 316 | byId("history-back").addEventListener("click", () => send("ShowLobby")); | 297 | byId("history-back").addEventListener("click", () => send("ShowLobby")); |
| 317 | byId("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 | }); | ||
| 325 | byId("reload-replaced").addEventListener("click", () => location.reload()); | 298 | byId("reload-replaced").addEventListener("click", () => location.reload()); |
| 326 | byId("reload-reconnect").addEventListener("click", () => location.reload()); | 299 | byId("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 | ||
| 23 | let observer: ResizeObserver | null = null; | ||
| 24 | let lastSpin = 0; | ||
| 25 | |||
| 26 | type Layout = { | 23 | type 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 | ||
| 42 | const 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 | ]; | ||
| 46 | const 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 | |||
| 44 | const RED = "#b0313a"; | 50 | const RED = "#b0313a"; |
| 45 | const BLACK = "#151b21"; | 51 | const BLACK = "#151b21"; |
| 46 | const ZERO = "#106b45"; | 52 | const ZERO = "#106b45"; |
| 47 | const FELT = "#0d5c39"; | 53 | const FELT = "#0d5c39"; |
| 48 | const FELT_DARK = "#09472c"; | ||
| 49 | const FELT_LINE = "#1f7d52"; | 54 | const FELT_LINE = "#1f7d52"; |
| 50 | const TEXT = "#ecf7f0"; | 55 | const TEXT = "#ecf7f0"; |
| 51 | const GOLD = "#e0b64a"; | 56 | const GOLD = "#e0b64a"; |
| 57 | const SURFACE = "#11171d"; | ||
| 52 | 58 | ||
| 53 | function colorOf(number: number): string { | 59 | let observer: ResizeObserver | null = null; |
| 60 | |||
| 61 | function 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 | ||
| 59 | function layout(width: number, height: number): Layout { | 66 | function 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 | ||
| 225 | function roundRect( | 225 | function 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 | ||
| 243 | function draw(canvas: HTMLCanvasElement, view: View): Layout { | 237 | function 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 | ||
| 332 | function 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 | |||
| 333 | export function renderRoulette( | 412 | export 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 | |||
| 583 | let lastSpin = 0; | ||
| 584 | |||
| 585 | function 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"); |
