From c45ff4525c0e45e4b8e762b81fd180c53d206d66 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 8 Nov 2026 12:24:25 +0100 Subject: web: match the desktop layout and remove play-money copy The browser now uses the desktop's full-width game split: no extra game header or card frame, a full-height sidebar with a pinned action block, and the same lobby grid, topbar account/balance blocks, and bottom-left notices. Slots uses the desktop symbol colours and gold win border; roulette gets the flat felt, column bets under the dozens, the wheel, circular chips, and a LAST line; blackjack gets a centred dealer/player table and divider; crash gets the chart panel, phase label and empty ready state. Also drops every play-money/real-money disclaimer from the page copy. --- web/play/src/blackjack.ts | 58 ++++---- web/play/src/crash.ts | 56 ++++++-- web/play/src/history.ts | 15 +- web/play/src/main.ts | 97 +++++-------- web/play/src/roulette.ts | 339 +++++++++++++++++++++++++++++++--------------- web/play/src/slots.ts | 32 ++--- 6 files changed, 362 insertions(+), 235 deletions(-) (limited to 'web/play/src') 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 { function handBlock(label: string, cards: string[], total: number): HTMLElement { const block = node("div", "hand"); - block.append(node("div", "label", label)); + const head = node("div", "hand-head"); + head.append(node("span", "label", label), node("strong", "total", String(total))); const row = node("div", "cards"); for (const card of cards) row.append(cardElement(card)); - const sum = node("div", "total"); - sum.append(document.createTextNode("Total "), node("strong", undefined, String(total))); - block.append(row, sum); + block.append(head, row); return block; } @@ -70,36 +69,31 @@ export function renderBlackjack( const done = hand?.phase === "done" || view.status === "done"; const canDeal = !hand || done; - board.append(node("h2", undefined, "Blackjack")); - board.append( - node("p", "note", "Six decks. Dealer stands on all 17. Blackjack pays 3:2."), - ); - + const table = node("div", "bj-table"); if (hand) { - board.append(handBlock("Dealer", hand.dealer ?? [], Number(hand.dealer_total ?? 0))); - board.append(handBlock("You", hand.player ?? [], Number(hand.player_total ?? 0))); + table.append(handBlock("DEALER", hand.dealer ?? [], Number(hand.dealer_total ?? 0))); + table.append(node("div", "bj-divider")); + table.append(handBlock("YOU", hand.player ?? [], Number(hand.player_total ?? 0))); } else { - const empty = node("div", "outcome"); - empty.textContent = "Press DEAL to start a hand."; - board.append(empty); + const empty = node("div", "empty-table", "Place a bet and deal a hand."); + table.append(empty); } + board.append(table); - if (done || (hand && !playerPhase)) { - const result = node("div", "outcome"); - result.textContent = outcome(view, hand); - if (String(hand?.result) === "win" || String(hand?.result) === "blackjack" || String(hand?.result) === "push") { - result.classList.add("win-text"); - } - board.append(result); - } + const foot = node("div", "board-foot"); + foot.append( + node("span", "label", "Hand"), + node("span", undefined, hand ? outcome(view, hand) : "Waiting"), + ); + board.append(foot); if (view.error) board.append(node("p", "error-text", String(view.error))); // Sidebar - side.append(node("div", "label", "Blackjack")); + side.append(node("h2", undefined, "Blackjack")); const bet = Number(hand?.bet ?? view.bet ?? 0); const balance = view.balance ?? view.hand?.balance; - side.append(node("p", "note", "Hit, stand or double. Forfeiting loses the reserved bet.")); + side.append(node("p", "note", "Get closer to 21 without going over.")); if (canDeal) { const betRow = node("div", "row"); @@ -122,6 +116,22 @@ export function renderBlackjack( side.append(stat); } + if (done || (hand && !playerPhase)) { + const resultLine = node("p", "note win-text"); + resultLine.textContent = `${String(hand?.result ?? "").toUpperCase()} ${money(Number(hand?.winnings ?? 0))}`; + if (!["win", "blackjack", "push"].includes(String(hand?.result))) { + resultLine.className = "note"; + } + side.append(resultLine); + } else if (playerPhase) { + const actionsText = (hand?.actions ?? []).includes("double") + ? "Hit, stand or double." + : "Hit or stand."; + side.append(node("p", "note", actionsText)); + } else if (!hand) { + side.append(node("p", "note", "Press DEAL to start a hand.")); + } + const actions = node("div", "actions"); if (playerPhase) { 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( sendCommand = send; clock = now; - board.append(node("h2", undefined, "Crash")); - board.append( - node("p", "note", "The multiplier rises from 1.00×. Cash out before it crashes."), - ); - const wrap = node("div", "crash-wrap"); const number = node("div", "crash-number", "1.00×"); + const phase = node("div", "label", "READY"); const meta = node("div", "crash-meta"); const canvas = node("canvas", "crash-canvas") as HTMLCanvasElement; const verify = node("div", "verify", "Waiting for a round commitment."); - wrap.append(number, meta, canvas, verify); + wrap.append(number, phase, meta, canvas, verify); board.append(wrap); - side.append(node("div", "label", "Crash")); - side.append(node("p", "note", "The server commits to a seed before the round. The seed is revealed when it ends.")); + side.append(node("h2", undefined, "Crash")); + side.append(node("p", "note", "Cash out before the multiplier crashes.")); const betRow = node("div", "row"); - const minus = node("button", "btn small", "−10") as HTMLButtonElement; + const minus = node("button", "btn small", "−") as HTMLButtonElement; const bet = node("strong", "mono", "—"); - const plus = node("button", "btn small", "+10") as HTMLButtonElement; + const plus = node("button", "btn small", "+") as HTMLButtonElement; minus.addEventListener("click", () => sendCommand?.({ AdjustBet: -10 })); plus.addEventListener("click", () => sendCommand?.({ AdjustBet: 10 })); betRow.append(minus, bet, plus); @@ -80,7 +76,7 @@ function build( side.append(row); } - const status = node("p", "note", "Ready."); + const status = node("p", "note", "Press START to play."); const lastCrash = node("p", "note", ""); const error = node("p", "error-text"); side.append(status, lastCrash, error); @@ -188,7 +184,7 @@ export function renderCrash( p.status.textContent = "Round forfeited."; p.status.className = "note error-text"; } else { - p.status.textContent = "Ready. Start a round when you want."; + p.status.textContent = "Press START to play."; p.status.className = "note"; } @@ -197,7 +193,9 @@ export function renderCrash( } const commit = view.commit ? String(view.commit) : ""; - p.meta.textContent = commit ? `commit ${shortHash(commit, 12)}` : "no commitment yet"; + p.meta.textContent = commit ? `commit ${shortHash(commit, 12)}` : ""; + p.meta.style.display = commit ? "" : "none"; + p.verify.style.display = commit ? "" : "none"; if (view.seed && commit) { const token = ++verifyToken; @@ -249,6 +247,19 @@ function currentMultiplier(view: View, now: Clock): number { function update(p: Parts, view: View, now: Clock): void { const multiplier = currentMultiplier(view, now); p.number.textContent = `${multiplier.toFixed(2)}×`; + const phase = p.number.nextElementSibling as HTMLElement | null; + if (phase) { + phase.textContent = + view.status === "running" + ? "RUNNING" + : view.status === "cashed" + ? "CASHED OUT" + : view.status === "crashed" + ? "CRASHED" + : view.status === "starting" + ? "STARTING" + : "READY"; + } p.number.className = `crash-number ${ view.status === "running" ? "running" @@ -296,6 +307,8 @@ function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, no ctx.strokeStyle = "#242e38"; ctx.fillStyle = "#8a98a6"; ctx.font = "11px ui-monospace, monospace"; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; ctx.lineWidth = 1; for (let step = 0; step <= 4; step += 1) { const value = 1 + ((yMax - 1) * step) / 4; @@ -304,7 +317,7 @@ function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, no ctx.moveTo(padL, py); ctx.lineTo(width - padR, py); ctx.stroke(); - ctx.fillText(`${value.toFixed(1)}×`, 6, py + 4); + ctx.fillText(`${value.toFixed(1)}×`, 10, py); } for (let step = 0; step <= 4; step += 1) { const seconds = (xMax * step) / 4; @@ -315,7 +328,20 @@ function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, no ctx.strokeStyle = "#1b232c"; ctx.stroke(); ctx.fillStyle = "#5b6773"; - ctx.fillText(`${seconds.toFixed(0)}s`, px - 10, height - 12); + ctx.textAlign = "center"; + ctx.textBaseline = "alphabetic"; + ctx.fillText(`${seconds.toFixed(0)}s`, px, height - 12); + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + } + + if (!["running", "cashed", "crashed"].includes(String(view.status))) { + ctx.fillStyle = "#5b6773"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = '13px "Open Sans", sans-serif'; + ctx.fillText("Press START to play", padL + plotW / 2, padT + plotH / 2); + return; } 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 @@ import type { View } from "./types"; -import { clear, formatTime, money, node, signedMoney } from "./dom"; +import { clear, money, node } from "./dom"; function summary(record: any): string { const details = record.details ?? {}; @@ -37,21 +37,20 @@ export function renderHistory(root: HTMLElement, view: View): void { const table = node("table"); const head = node("thead"); const headRow = node("tr"); - for (const label of ["Time", "Game", "Bet", "Result", "Net", "Balance"]) { + for (const label of ["When", "Game", "Bet", "Won", "Balance", "Result"]) { headRow.append(node("th", undefined, label)); } head.append(headRow); const body = node("tbody"); for (const record of records) { const row = node("tr"); - row.append(node("td", "mono", formatTime(String(record.at ?? "")))); - row.append(node("td", undefined, String(record.game ?? ""))); + row.append(node("td", "mono", String(record.at ?? "").slice(0, 16))); + row.append(node("td", "mono", String(record.game ?? "").toUpperCase())); row.append(node("td", "num", money(Number(record.bet ?? 0)))); - row.append(node("td", "muted", summary(record))); - const net = Number(record.winnings ?? 0) - Number(record.bet ?? 0); - const netCell = node("td", `num ${net > 0 ? "pos" : net < 0 ? "neg" : ""}`, net === 0 ? "0 Kr" : signedMoney(net)); - row.append(netCell); + const winnings = Number(record.winnings ?? 0); + row.append(node("td", `num ${winnings > 0 ? "pos" : ""}`, money(winnings))); row.append(node("td", "num", money(Number(record.balance_after ?? 0)))); + row.append(node("td", "muted", summary(record))); body.append(row); } 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"); const gameGrid = byId("game-grid"); const gameBoard = byId("game-board"); const gameSide = byId("game-side"); -const gameTitle = byId("game-title"); -const gameBadge = byId("game-badge"); -const lobbyAccount = byId("lobby-account"); +const versionPill = byId("version-pill"); +const accountNumber = byId("account-number"); +const lobbyCount = byId("lobby-count"); + const historyBody = byId("history-body"); const authForm = byId("auth-form"); const authUser = byId("auth-user"); const authPass = byId("auth-pass"); const authError = byId("auth-error"); const authSubmit = byId("auth-submit"); -const tabLogin = byId("tab-login"); -const tabRegister = byId("tab-register"); +const authSwitch = byId("auth-switch"); +const authTitle = byId("auth-title"); const reconnectOverlay = byId("reconnect-overlay"); const reconnectMessage = byId("reconnect-message"); const replacedOverlay = byId("replaced-overlay"); @@ -39,6 +40,7 @@ let authMode: "login" | "register" = "login"; let infoFor: string | null = null; let serverOffset = 0; let reconnectAttempts = 0; +let lastScreen: string | null = null; const bets = new Map([ ["slots", 10], ["roulette", 10], @@ -54,10 +56,10 @@ const titles: Record = { }; const descriptions: Record = { - slots: "Five reels, five paylines. Match symbols from the left.", - roulette: "European single-zero wheel with inside and outside bets.", - blackjack: "Six-deck shoe. Blackjack pays 3:2.", - crash: "A rising multiplier. Cash out before it crashes.", + slots: "Spin the reels. Three or more matching symbols from the left win.", + roulette: "Put chips on the table, then spin. Straight numbers pay the most.", + blackjack: "Get closer to 21 than the dealer without going over.", + crash: "Start the round, then cash out before the multiplier crashes.", }; const basePath = location.pathname.includes("/play/") @@ -83,9 +85,13 @@ const gateway = new Gateway(wsUrl, { serverOffset = hello.time - Date.now(); currentUser = hello.user ?? null; if (typeof hello.balance === "number") lastBalance = hello.balance; - gameBadge.textContent = `v${hello.version}`; + versionPill.textContent = `v${hello.version}`; }, onView(next) { + if (next.screen !== lastScreen) { + byId("toasts").replaceChildren(); + lastScreen = next.screen; + } view = next; if (next.screen !== "slots") stopSlotsAnimation(); if (next.screen !== "crash") stopCrashAnimation(); @@ -124,12 +130,12 @@ const gateway = new Gateway(wsUrl, { authUser.focus(); } authSubmit.disabled = false; - authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; + authSubmit.textContent = authMode === "login" ? "LOG IN" : "CREATE ACCOUNT"; }, onError(text) { toast(text, "bad"); authSubmit.disabled = false; - authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; + authSubmit.textContent = authMode === "login" ? "LOG IN" : "CREATE ACCOUNT"; }, onReplaced() { replacedOverlay.hidden = false; @@ -166,9 +172,9 @@ function serverNow(): number { function setAuthMode(mode: "login" | "register"): void { authMode = mode; - tabLogin.classList.toggle("active", mode === "login"); - tabRegister.classList.toggle("active", mode === "register"); - authSubmit.textContent = mode === "login" ? "Log in" : "Create account"; + authTitle.textContent = mode === "login" ? "Sign in" : "Create account"; + authSubmit.textContent = mode === "login" ? "LOG IN" : "CREATE ACCOUNT"; + authSwitch.textContent = mode === "login" ? "CREATE ACCOUNT" : "BACK TO SIGN IN"; authPass.autocomplete = mode === "login" ? "current-password" : "new-password"; authError.textContent = ""; } @@ -192,6 +198,7 @@ function render(): void { accountName.textContent = String(user); } else { sessionBar.hidden = true; + accountNumber.textContent = "—"; } setBalance(lastBalance); @@ -219,8 +226,6 @@ function render(): void { case "blackjack": case "crash": show("game"); - gameTitle.textContent = titles[view.screen] ?? view.screen; - gameBadge.textContent = badge(view); if (view.screen === "slots") renderSlots(gameBoard, gameSide, view, send); else if (view.screen === "roulette") renderRoulette(gameBoard, gameSide, view, send); else if (view.screen === "blackjack") renderBlackjack(gameBoard, gameSide, view, send); @@ -231,47 +236,23 @@ function render(): void { } } -function badge(view: View): string { - if (view.screen === "crash") return String(view.status ?? ""); - if (view.screen === "blackjack") return String(view.status ?? ""); - if (view.screen === "slots") return String(view.phase ?? ""); - if (view.screen === "roulette") return String(view.phase ?? ""); - return ""; -} - function renderLobby(lobby: View): void { - lobbyAccount.textContent = lobby.account ? `Account ${lobby.account}` : ""; + accountNumber.textContent = lobby.account ? String(lobby.account) : "—"; + const order = ["slots", "roulette", "blackjack", "crash"]; + const list: [string, string][] = [...(lobby.games ?? [])].sort( + (a, b) => order.indexOf(a[0]) - order.indexOf(b[0]), + ); + lobbyCount.textContent = `${list.length} ${list.length === 1 ? "game" : "games"}`; gameGrid.replaceChildren(); - const list: [string, string][] = lobby.games ?? []; for (const [id, name] of list) { const card = node("article", `game-card ${id}`); card.append(node("h3", undefined, name || titles[id] || id)); card.append(node("p", undefined, descriptions[id] ?? "Play a round.")); - - const row = node("div", "row wrap"); - const label = node("label", "field"); - label.style.flex = "1"; - label.append(node("span", "label", "Bet")); - const input = node("input", "input bet-input") as HTMLInputElement; - input.type = "number"; - input.min = "1"; - input.step = "10"; - input.value = String(bets.get(id) ?? 10); - input.addEventListener("change", () => { - const value = Math.max(1, Math.floor(Number(input.value) || 1)); - input.value = String(value); - bets.set(id, value); - }); - label.append(input); - - const play = node("button", "btn primary", id === "roulette" ? "Open table" : "Play"); + const play = node("button", "btn primary wide", "PLAY"); play.addEventListener("click", () => { - const bet = Math.max(1, Math.floor(Number(input.value) || 1)); - bets.set(id, bet); - send({ Play: { game: id, bet } }); + send({ Play: { game: id, bet: bets.get(id) ?? 10 } }); }); - row.append(label, play); - card.append(row); + card.append(play); gameGrid.append(card); } @@ -280,9 +261,10 @@ function renderLobby(lobby: View): void { } } -// Auth tabs and form ------------------------------------------------------- -tabLogin.addEventListener("click", () => setAuthMode("login")); -tabRegister.addEventListener("click", () => setAuthMode("register")); +// Auth mode and form ------------------------------------------------------- +authSwitch.addEventListener("click", () => + setAuthMode(authMode === "login" ? "register" : "login"), +); authForm.addEventListener("submit", async (event) => { event.preventDefault(); @@ -312,16 +294,7 @@ authForm.addEventListener("submit", async (event) => { // Chrome and navigation ---------------------------------------------------- byId("logout").addEventListener("click", () => send("Logout")); byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } })); -byId("refresh-lobby").addEventListener("click", () => send("Info")); byId("history-back").addEventListener("click", () => send("ShowLobby")); -byId("leave-game").addEventListener("click", () => { - if (!view) return; - const live = - (view.screen === "blackjack" && view.status === "player") || - (view.screen === "crash" && view.status === "running"); - if (live && !window.confirm("Leaving now forfeits the active bet. Continue?")) return; - send("Leave"); -}); byId("reload-replaced").addEventListener("click", () => location.reload()); byId("reload-reconnect").addEventListener("click", () => location.reload()); diff --git a/web/play/src/roulette.ts b/web/play/src/roulette.ts index a42686a..0493d04 100644 --- a/web/play/src/roulette.ts +++ b/web/play/src/roulette.ts @@ -20,12 +20,10 @@ type Hotspot = { rank: number; }; -let observer: ResizeObserver | null = null; -let lastSpin = 0; - type Layout = { width: number; height: number; + pad: number; cellW: number; cellH: number; zeroX: number; @@ -33,42 +31,50 @@ type Layout = { gridX: number; gridY: number; gridW: number; - columnX: number; dozensY: number; + columnsY: number; outsideY: number; outsideH: number; zones: Zone[]; hotspots: Hotspot[]; }; +const WHEEL = [ + 0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10, 5, + 24, 16, 33, 1, 20, 14, 31, 9, 22, 18, 29, 7, 28, 12, 35, 3, 26, +]; +const RED_NUMBERS = new Set([ + 1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36, +]); + const RED = "#b0313a"; const BLACK = "#151b21"; const ZERO = "#106b45"; const FELT = "#0d5c39"; -const FELT_DARK = "#09472c"; const FELT_LINE = "#1f7d52"; const TEXT = "#ecf7f0"; const GOLD = "#e0b64a"; +const SURFACE = "#11171d"; -function colorOf(number: number): string { +let observer: ResizeObserver | null = null; + +function colorOf(number: number): "red" | "black" | "green" { if (number === 0) return "green"; - const reds = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36]; - return reds.includes(number) ? "red" : "black"; + return RED_NUMBERS.has(number) ? "red" : "black"; } function layout(width: number, height: number): Layout { const pad = 14; - const zeroW = Math.max(38, width * 0.055); - const columnW = Math.max(34, width * 0.05); - const outsideH = Math.max(30, height * 0.07); - const gap = 6; - const gridW = width - pad * 2 - zeroW - columnW; + const zeroW = Math.max(44, width * 0.058); + const outsideH = Math.max(28, Math.min(38, height * 0.075)); + const gridW = width - pad * 2 - zeroW; const cellW = gridW / 12; - const cellH = (height - pad * 2 - outsideH * 2 - gap * 2) / 3; + const cellH = (height - pad * 2 - outsideH * 3) / 3; const gridX = pad + zeroW; const gridY = pad; - const dozensY = gridY + cellH * 3 + gap; - const outsideY = dozensY + outsideH + gap; + const dozensY = gridY + cellH * 3; + const columnsY = dozensY + outsideH; + const outsideY = columnsY + outsideH; const zones: Zone[] = []; for (let column = 0; column < 12; column += 1) { @@ -98,19 +104,6 @@ function layout(width: number, height: number): Layout { number: 0, }); - for (let top = 0; top < 3; top += 1) { - const index = 2 - top; - zones.push({ - x: gridX + gridW, - y: gridY + top * cellH, - w: columnW, - h: cellH, - target: { bet: "column", index }, - label: "2:1", - kind: "column", - }); - } - for (let index = 0; index < 3; index += 1) { zones.push({ x: gridX + (gridW / 3) * index, @@ -121,6 +114,15 @@ function layout(width: number, height: number): Layout { label: ["1st 12", "2nd 12", "3rd 12"][index], kind: "dozen", }); + zones.push({ + x: gridX + (gridW / 3) * index, + y: columnsY, + w: gridW / 3, + h: outsideH, + target: { bet: "column", index: 2 - index }, + label: "2:1", + kind: "column", + }); } const outside = [ @@ -143,8 +145,6 @@ function layout(width: number, height: number): Layout { }); }); - // Split and corner hotspots sit on the lines between cells, like the real - // felt (and the desktop fine lattice). Corners rank ahead of splits. const hotspots: Hotspot[] = []; const cellCenter = (number: number) => { const cell = zones.find((zone) => zone.kind === "number" && zone.number === number); @@ -193,19 +193,19 @@ function layout(width: number, height: number): Layout { } for (let row = 0; row < 3; row += 1) { - const number = 3 - row; hotspots.push({ x: gridX, y: gridY + row * cellH + cellH / 2, radius: 10, rank: 1, - target: { bet: "split", a: 0, b: number }, + target: { bet: "split", a: 0, b: 3 - row }, }); } return { width, height, + pad, cellW, cellH, zeroX: pad, @@ -213,8 +213,8 @@ function layout(width: number, height: number): Layout { gridX, gridY, gridW, - columnX: gridX + gridW, dozensY, + columnsY, outsideY, outsideH, zones, @@ -222,27 +222,21 @@ function layout(width: number, height: number): Layout { }; } -function roundRect( +function rect( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, - radius: number, ): void { - const r = Math.min(radius, w / 2, h / 2); ctx.beginPath(); - ctx.moveTo(x + r, y); - ctx.arcTo(x + w, y, x + w, y + h, r); - ctx.arcTo(x + w, y + h, x, y + h, r); - ctx.arcTo(x, y + h, x, y, r); - ctx.arcTo(x, y, x + w, y, r); + ctx.rect(x, y, w, h); ctx.closePath(); } -function draw(canvas: HTMLCanvasElement, view: View): Layout { - const width = canvas.clientWidth || 800; - const height = canvas.clientHeight || 540; +function drawTable(canvas: HTMLCanvasElement, view: View): Layout { + const width = canvas.clientWidth || 900; + const height = canvas.clientHeight || 500; const dpr = window.devicePixelRatio || 1; if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { canvas.width = Math.round(width * dpr); @@ -253,18 +247,23 @@ function draw(canvas: HTMLCanvasElement, view: View): Layout { if (!ctx) return map; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); - ctx.fillStyle = FELT_DARK; - roundRect(ctx, 0, 0, width, height, 10); + + // The whole felt is green; cells sit on it with thin lines, like the desktop. + ctx.fillStyle = FELT; + rect(ctx, map.pad, map.gridY, map.zeroW + map.gridW, map.cellH * 3 + map.outsideH * 3); ctx.fill(); for (const zone of map.zones) { let fill = FELT; - if (zone.kind === "number") fill = zone.number === 0 ? ZERO : colorOf(zone.number!) === "red" ? RED : BLACK; - else if (zone.kind === "zero") fill = ZERO; + if (zone.kind === "number") { + fill = colorOf(zone.number!) === "red" ? RED : BLACK; + } else if (zone.kind === "zero") { + fill = ZERO; + } ctx.fillStyle = fill; ctx.strokeStyle = FELT_LINE; ctx.lineWidth = 1; - roundRect(ctx, zone.x + 1, zone.y + 1, zone.w - 2, zone.h - 2, 3); + rect(ctx, zone.x + 0.5, zone.y + 0.5, zone.w - 1, zone.h - 1); ctx.fill(); ctx.stroke(); @@ -272,10 +271,12 @@ function draw(canvas: HTMLCanvasElement, view: View): Layout { ctx.textAlign = "center"; ctx.textBaseline = "middle"; if (zone.kind === "number" || zone.kind === "zero") { - ctx.font = `700 ${Math.min(24, map.cellH * 0.3)}px ui-monospace, monospace`; + ctx.font = `500 ${Math.min(18, map.cellH * 0.16)}px "Open Sans", sans-serif`; } else { - ctx.font = `600 ${Math.min(12, map.outsideH * 0.4)}px ui-monospace, monospace`; + ctx.font = `500 ${Math.min(12, map.outsideH * 0.4)}px "Open Sans", sans-serif`; } + if (zone.label === "RED") ctx.fillStyle = "#e05a5a"; + if (zone.label === "BLACK") ctx.fillStyle = "#8a98a6"; ctx.fillText(zone.label, zone.x + zone.w / 2, zone.y + zone.h / 2); } @@ -292,12 +293,10 @@ function centerFor(layout: Layout, target: any): { x: number; y: number } | null const exact = layout.zones.find((zone) => targetKey(zone.target) === key); if (exact) return { x: exact.x + exact.w / 2, y: exact.y + exact.h / 2 }; - // Split and corner bets are reachable from the desktop's fine lattice. A - // browser session can still show them: average the covered number cells. if (target?.bet === "split" || target?.bet === "corner") { const numbers: number[] = target.bet === "split" ? [target.a, target.b] : target.numbers; const cells = layout.zones.filter( - (zone) => (zone.kind === "number" || zone.kind === "zero") && numbers.includes(zone.number!), + (zone) => zone.kind === "number" && numbers.includes(zone.number!), ); if (cells.length > 0) { return { @@ -314,7 +313,7 @@ function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void for (const chip of chips) { const center = centerFor(map, chip.target); if (!center) continue; - const radius = Math.max(12, Math.min(18, map.cellW * 0.26)); + const radius = Math.max(12, Math.min(17, map.cellW * 0.28)); ctx.beginPath(); ctx.arc(center.x, center.y, radius, 0, Math.PI * 2); ctx.fillStyle = GOLD; @@ -325,11 +324,91 @@ function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void ctx.fillStyle = "#221a05"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.font = `700 ${radius > 15 ? 11 : 10}px ui-monospace, monospace`; + ctx.font = `700 ${radius > 15 ? 11 : 10}px "Hack", monospace`; ctx.fillText(String(chip.amount), center.x, center.y + 0.5); } } +function drawWheel(canvas: HTMLCanvasElement, view: View): void { + const size = 170; + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(size * dpr); + canvas.height = Math.round(size * dpr); + canvas.style.width = `${size}px`; + canvas.style.height = `${size}px`; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const result = view.phase === "result" && view.number !== null && view.number !== undefined + ? Number(view.number) + : null; + const targetIndex = result === null ? -1 : WHEEL.indexOf(result); + const step = (Math.PI * 2) / WHEEL.length; + const targetRotation = targetIndex < 0 ? 0 : -targetIndex * step; + const start = performance.now(); + const duration = 650; + + const paint = (rotation: number) => { + const cx = size / 2; + const cy = size / 2; + const outer = size * 0.46; + const inner = size * 0.29; + ctx.clearRect(0, 0, size, size); + + ctx.fillStyle = SURFACE; + ctx.beginPath(); + ctx.arc(cx, cy, outer + 6, 0, Math.PI * 2); + ctx.fill(); + + WHEEL.forEach((number, index) => { + const angle = -Math.PI / 2 + index * step + rotation; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.arc(cx, cy, outer, angle - step / 2, angle + step / 2); + ctx.closePath(); + ctx.fillStyle = colorOf(number) === "red" ? RED : colorOf(number) === "green" ? ZERO : BLACK; + ctx.fill(); + ctx.strokeStyle = FELT_LINE; + ctx.lineWidth = 0.8; + ctx.stroke(); + }); + + ctx.beginPath(); + ctx.arc(cx, cy, inner, 0, Math.PI * 2); + ctx.fillStyle = "#0a0e12"; + ctx.fill(); + ctx.strokeStyle = FELT_LINE; + ctx.lineWidth = 1; + ctx.stroke(); + + // Pointer at twelve o'clock. + ctx.beginPath(); + ctx.moveTo(cx, 8); + ctx.lineTo(cx - 5, 18); + ctx.lineTo(cx + 5, 18); + ctx.closePath(); + ctx.fillStyle = TEXT; + ctx.fill(); + + if (result !== null) { + ctx.fillStyle = TEXT; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = '600 15px "Hack", monospace'; + ctx.fillText(String(result), cx, cy); + } + }; + + const animate = (now: number) => { + const progress = Math.min(1, (now - start) / duration); + const eased = 1 - (1 - progress) ** 3; + paint(targetRotation * eased); + if (progress < 1) requestAnimationFrame(animate); + }; + requestAnimationFrame(animate); +} + export function renderRoulette( board: HTMLElement, side: HTMLElement, @@ -340,43 +419,44 @@ export function renderRoulette( clear(side); const spinning = view.phase === "spinning"; - board.append(node("h2", undefined, "Roulette")); - board.append( - node( - "p", - "note", - "European single-zero wheel. Click a number, line or corner to add a chip; right-click takes one back.", - ), - ); - - const wrap = node("div", "roulette-wrap"); const canvas = node("canvas", "roulette-canvas") as HTMLCanvasElement; - wrap.append(canvas); + board.append(canvas); + const result = node("div", "board-foot tight"); + result.append(node("span", "label", "Last")); if (view.phase === "result" && view.number !== null && view.number !== undefined) { - const result = node("div", `wheel-result ${view.color ?? ""}`); - result.append(node("div", "label", "Result")); - result.append(node("div", "num", String(view.number))); - const winnings = Number(view.winnings ?? 0); - result.append(node("div", "note", winnings > 0 ? `Won ${money(winnings)}` : "No win")); - wrap.append(result); + const value = node( + "span", + view.winnings && Number(view.winnings) > 0 ? "win-text" : undefined, + `${view.number} ${view.color} · ${Number(view.winnings ?? 0) > 0 ? money(Number(view.winnings)) : "no win"}`, + ); + result.append(value); + } else { + result.append(node("span", undefined, "No spins yet")); } - board.append(wrap); + result.append( + node("span", "note", "Click to place a chip · right-click to take one back"), + ); + board.append(result); - // The canvas must be in the document before its client size is read. - const map = draw(canvas, view); + const map = drawTable(canvas, view); let current = map; const hit = (clientX: number, clientY: number): Zone | null => { - const rect = canvas.getBoundingClientRect(); - const x = clientX - rect.left; - const y = clientY - rect.top; + const bounds = canvas.getBoundingClientRect(); + const x = clientX - bounds.left; + const y = clientY - bounds.top; let best: Hotspot | null = null; for (const hotspot of current.hotspots) { const distance = Math.hypot(x - hotspot.x, y - hotspot.y); if (distance > hotspot.radius) continue; - if (!best || hotspot.rank < best.rank || (hotspot.rank === best.rank && distance < Math.hypot(x - best.x, y - best.y))) { + if ( + !best || + hotspot.rank < best.rank || + (hotspot.rank === best.rank && + distance < Math.hypot(x - best.x, y - best.y)) + ) { best = hotspot; } } @@ -418,66 +498,105 @@ export function renderRoulette( }); observer?.disconnect(); observer = new ResizeObserver(() => { - current = draw(canvas, view); + current = drawTable(canvas, view); }); observer.observe(canvas); // Sidebar - side.append(node("div", "label", "Roulette")); - side.append(node("p", "note", "Chips sit on the felt. The total is reserved when you spin.")); + side.append(node("h2", undefined, "Roulette")); + side.append(node("p", "note", "Pick numbers or colours, then spin.")); + + const wheel = node("canvas", "wheel-canvas") as HTMLCanvasElement; + side.append(wheel); + drawWheel(wheel, view); + + side.append(node("div", "label", "Place your bets")); + const chips = (view.bets?.chips ?? []).reduce( + (sum: number, chip: any) => sum + Number(chip.amount ?? 0), + 0, + ); + side.append(node("p", "note", `${chips} ${chips === 1 ? "chip" : "chips"}`)); side.append(node("div", "label", "Chip")); const chipRow = node("div", "chip-row"); - for (const value of [10, 25, 50, 100, 500]) { - const button = node("button", `chip-btn ${Number(view.chip) === value ? "active" : ""}`, String(value)); + for (const value of [10, 25, 50, 100]) { + const button = node( + "button", + `chip-btn ${Number(view.chip) === value ? "active" : ""}`, + String(value), + ) as HTMLButtonElement; + button.dataset.chip = String(value); button.disabled = spinning; button.addEventListener("click", () => send({ SetChip: value })); chipRow.append(button); } side.append(chipRow); - const total = (view.bets?.chips ?? []).reduce( - (sum: number, chip: any) => sum + Number(chip.amount ?? 0), - 0, - ); - const totalRow = node("div", "stat"); - totalRow.append(node("span", "muted", "On the felt"), node("strong", "mono", money(total))); - side.append(totalRow); - - if (view.balance !== null && view.balance !== undefined) { - const balanceRow = node("div", "stat"); - balanceRow.append(node("span", "muted", "Balance"), node("strong", "mono", money(Number(view.balance)))); - side.append(balanceRow); + side.append(node("div", "label", "Bets")); + const bets: any[] = view.bets?.chips ?? []; + if (bets.length === 0) { + side.append(node("p", "note", "No chips on the felt")); + } else { + for (const chip of bets) { + const row = node("div", "stat"); + row.append( + node("span", "muted", chipLabel(chip.target)), + node("strong", "mono", money(Number(chip.amount ?? 0))), + ); + side.append(row); + } } + const total = node("div", "stat"); + total.append(node("span", "muted", "Total"), node("strong", "mono", money(chips))); + side.append(total); + if (view.error) side.append(node("p", "error-text", String(view.error))); const status = node("p", "note"); - if (spinning) status.textContent = "Spinning…"; + if (spinning) status.textContent = "SPINNING…"; else if (view.phase === "result") { const winnings = Number(view.winnings ?? 0); - status.textContent = `${view.number} ${view.color} · ${winnings > 0 ? `won ${money(winnings)}` : "no win"}`; - status.className = winnings > 0 ? "note win-text" : "note"; - } else status.textContent = total > 0 ? "Ready to spin." : "Place at least one chip."; + status.textContent = winnings > 0 ? `WON ${money(winnings)}` : "NO WIN"; + if (winnings > 0) status.classList.add("win-text"); + } else status.textContent = chips > 0 ? "Ready to spin." : "Place at least one chip."; side.append(status); const actions = node("div", "actions"); - const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN WHEEL"); - spin.disabled = spinning || total === 0; + const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN"); + spin.disabled = spinning || chips === 0; spin.addEventListener("click", () => { if (Date.now() - lastSpin < 600) return; lastSpin = Date.now(); send("Spin"); }); - const clearBets = node("button", "btn wide", "Clear bets"); - clearBets.disabled = spinning || total === 0; + const clearBets = node("button", "btn wide", "CLEAR"); + clearBets.disabled = spinning || chips === 0; clearBets.addEventListener("click", () => send("ClearBets")); const leave = node("button", "btn ghost wide", "Leave table"); leave.disabled = spinning; - leave.addEventListener("click", () => { - if (spinning) return; - send("Leave"); - }); + leave.addEventListener("click", () => send("Leave")); actions.append(spin, clearBets, leave); side.append(actions); } + +let lastSpin = 0; + +function chipLabel(target: any): string { + switch (target?.bet) { + case "straight": + return String(target.number); + case "split": + return `${target.a}/${target.b}`; + case "corner": + return target.numbers.join("/"); + case "dozen": + return ["1st 12", "2nd 12", "3rd 12"][target.index] ?? "dozen"; + case "column": + return `col ${Number(target.index) + 1}`; + case "outside": + return String(target.kind ?? ""); + default: + return ""; + } +} 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( clear(board); clear(side); - const title = node("h2", undefined, "Slots"); - const note = node("p", "note", "Match three or more symbols from the left. Five paylines."); - board.append(title, note); - const reels = node("div", "reels"); const tiles: HTMLElement[] = []; const flat = Array.isArray(view.reels) @@ -81,24 +77,28 @@ export function renderSlots( board.append(reels); const lines = node("div", "win-lines"); + let resultText = "Ready"; if (!active && view.phase === "result") { const winnings = Number(view.winnings ?? 0); if (winnings > 0) { const names = (view.win_lines ?? []) .map((line: number) => `${line} (${LINE_NAMES[line - 1] ?? "line"})`) .join(" · "); - lines.textContent = `${view.win_type === "mega" ? "MEGA WIN" : view.win_type === "big" ? "BIG WIN" : "WIN"} ${money(winnings)}${names ? ` · ${names}` : ""}`; + resultText = `${view.win_type === "mega" ? "MEGA WIN" : view.win_type === "big" ? "BIG WIN" : "WIN"} ${money(winnings)}${names ? ` · ${names}` : ""}`; + lines.textContent = names; lines.classList.add("win-text"); } else { - lines.textContent = "No win — spin again."; + resultText = "No win"; } } else if (active) { - lines.textContent = "Spinning…"; - } else { - lines.textContent = "Pick a bet and spin."; + resultText = "Spinning…"; } board.append(lines); + const foot = node("div", "board-foot"); + foot.append(node("span", "label", "Result"), node("span", undefined, resultText)); + board.append(foot); + if (active) { let tick = 0; animation = window.setInterval(() => { @@ -117,13 +117,13 @@ export function renderSlots( } // Sidebar - side.append(node("div", "label", "Slots")); - side.append(node("p", "note", "5 reels × 3 rows, 5 paylines.")); + side.append(node("h2", undefined, "Slots")); + side.append(node("p", "note", "Match three or more symbols from the left.")); const betRow = node("div", "row"); - const minus = node("button", "btn small", "−10"); + const minus = node("button", "btn small", "−"); const bet = node("strong", "mono", money(Number(view.bet ?? 0))); - const plus = node("button", "btn small", "+10"); + const plus = node("button", "btn small", "+"); minus.disabled = active; plus.disabled = active; minus.addEventListener("click", () => send({ AdjustBet: -10 })); @@ -136,12 +136,12 @@ export function renderSlots( } const status = node("p", "note"); - if (active) status.textContent = "Spinning…"; + if (active) status.textContent = "SPINNING…"; else if (view.phase === "result") { const winnings = Number(view.winnings ?? 0); - status.textContent = winnings > 0 ? `Won ${money(winnings)}` : "No win this spin."; + status.textContent = winnings > 0 ? `WON ${money(winnings)}` : "NO WIN"; if (winnings > 0) status.classList.add("win-text"); - } else status.textContent = "Ready when you are."; + } else status.textContent = "Press SPIN to play."; side.append(status); const actions = node("div", "actions"); -- cgit v1.2.3