diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 21:09:56 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 21:09:56 +0100 |
| commit | bf4cf78ab26896faf457273e37d30fc96381c4a4 (patch) | |
| tree | 71d67c300b6be191016a988ed5338e15c8b1bdd3 | |
| parent | v4.1.1: pointer-first betting and visible payouts in the GUI (diff) | |
| download | zagan-bet-bf4cf78ab26896faf457273e37d30fc96381c4a4.tar.gz zagan-bet-bf4cf78ab26896faf457273e37d30fc96381c4a4.zip | |
v4.2.0: wire the GUI to the core, transport and all four games
Phase 4. The egui frontend now shares ClientCore and the client database with the terminal: one background worker owns TLS, trust answers go through a channel, and a 10 s request deadline mirrors the terminal socket timeout. First contact, a changed key and a version mismatch are explicit screens; login can remember the credential and auto-login signs in before the login form appears.
Adds the lobby with an update notice, a full-width scrollable history table, and the four games painted from View: slots with reel animation and win lines that stop at the last matching reel, roulette with the hover lattice and wheel spin, blackjack with vector-drawn suits, and a smooth crash curve. Sidebars pin their action block; leaving a live hand or round asks before forfeiting.
The header balance holds the staked amount while the reels or wheel turn and only shows the payout when they stop. Refused spins cancel the animation. Live crash multipliers keep full precision for the chart; the terminal still displays two decimals.
Verified live against a 4.2.0 server: register, login, auto-login, trust, all four games settling, history (50 rows), logout, disconnect, forfeit confirmation, and the version-mismatch screen. Release: 8.3 MB Linux, 6.8 MB Windows, 61 ms to window, 131 MB idle RSS.
26 files changed, 3935 insertions, 562 deletions
| @@ -77,6 +77,14 @@ | |||
| 77 | (cell centre, edge, crossing), left click adds the chip and right or middle | 77 | (cell centre, edge, crossing), left click adds the chip and right or middle |
| 78 | click takes one back. The terminal keeps its arrow-key cursor; the GUI does | 78 | click takes one back. The terminal keeps its arrow-key cursor; the GUI does |
| 79 | not paint it. | 79 | not paint it. |
| 80 | - The GUI network worker (`gui/src/net.rs`) owns the TLS stream and the request | ||
| 81 | deadline; `ClientCore` stays I/O-free. One thread with channels, no async | ||
| 82 | runtime. | ||
| 83 | - Game sidebars pin their action block with `widgets::sidebar_layout`. An | ||
| 84 | action that moves as the bet slip grows is a bug. | ||
| 85 | - The embedded fonts have no suit or arrow glyphs. Draw card suits as shapes | ||
| 86 | and use plain words for navigation; a glyph that is not in Open Sans or Hack | ||
| 87 | renders as tofu. | ||
| 80 | 88 | ||
| 81 | ## Client TUI | 89 | ## Client TUI |
| 82 | - `render_lines()` and `render()` must agree. If `render()` uses `\r\n`, | 90 | - `render_lines()` and `render()` must agree. If `render()` uses `\r\n`, |
| @@ -3,7 +3,7 @@ members = ["gui"] | |||
| 3 | 3 | ||
| 4 | [package] | 4 | [package] |
| 5 | name = "zagan-bet" | 5 | name = "zagan-bet" |
| 6 | version = "4.1.1" | 6 | version = "4.2.0" |
| 7 | edition = "2024" | 7 | edition = "2024" |
| 8 | license = "GPL-3.0-or-later" | 8 | license = "GPL-3.0-or-later" |
| 9 | 9 | ||
diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md index bb63530..4279626 100644 --- a/docs/GUI-PLAN.md +++ b/docs/GUI-PLAN.md | |||
| @@ -67,9 +67,7 @@ crash board from its local clock. | |||
| 67 | 67 | ||
| 68 | **Gate:** all four games playable in the TUI through `Command`/`View`. | 68 | **Gate:** all four games playable in the TUI through `Command`/`View`. |
| 69 | 69 | ||
| 70 | ## Phase 3 — `gui/` skeleton and the style spike | 70 | ## Phase 3 — `gui/` skeleton and the style spike (done) |
| 71 | |||
| 72 | **Code complete in v4.1.0; awaiting the screenshot sign-off.** | ||
| 73 | 71 | ||
| 74 | - `gui/` is a workspace member (`zagan-bet-gui`). It depends on the core with | 72 | - `gui/` is a workspace member (`zagan-bet-gui`). It depends on the core with |
| 75 | `default-features = false`, so neither egui nor native-tls enters the TUI or | 73 | `default-features = false`, so neither egui nor native-tls enters the TUI or |
| @@ -104,24 +102,65 @@ Measured on Linux at 1080p with llvmpipe software GL: | |||
| 104 | | Idle RSS | 137 MB | | 102 | | Idle RSS | 137 MB | |
| 105 | | Idle CPU | 0% | | 103 | | Idle CPU | 0% | |
| 106 | 104 | ||
| 107 | The approved reference lives at `docs/screenshots/gui-roulette-1080p.png`, with | 105 | The Phase 4 screenshots in `docs/screenshots/` supersede the spike captures; |
| 108 | the result state at `docs/screenshots/gui-roulette-result-1080p.png`. | 106 | they show the same layout with the real session and the pinned action bars. |
| 109 | 107 | ||
| 110 | **Gate:** the screenshot needs your approval. Frame rate on integrated graphics | 108 | **Gate:** met. The style was approved and the spike grew into Phase 4. |
| 111 | still needs a run on real hardware. | 109 | |
| 110 | ## Phase 4 — GUI wiring (code complete, v4.2.0) | ||
| 111 | |||
| 112 | - `gui/src/net.rs` is the adapter's network worker. One blocking thread owns the | ||
| 113 | TLS stream; `Command` goes in, `Event` comes out (connected, failed, trust, | ||
| 114 | line, closed), and every event calls `Context::request_repaint`. There is no | ||
| 115 | async runtime. | ||
| 116 | - First contact is a modal that answers the transport's trust callback through | ||
| 117 | a channel. A changed key is a hard stop with an explicit forget-and-retry; | ||
| 118 | a version mismatch links to the download page; a saved credential auto-logs | ||
| 119 | in before the login screen is ever shown. | ||
| 120 | - Screens: connect (remembered hosts, forget trust), login/register with a | ||
| 121 | "remember this login" choice, lobby with the update notice, history table, | ||
| 122 | and the four games. "Log out" and "forget saved login" are separate. Leaving | ||
| 123 | a live hand or round asks first and forfeits the reservation. | ||
| 124 | - Games are painted from `View`: slots with a 0.9 s reel animation and payline | ||
| 125 | highlights, roulette with the wheel spin, the hover lattice, chip placement | ||
| 126 | and payouts, blackjack with vector-drawn suits and legal actions, crash with | ||
| 127 | a sampled curve, cashout and a server-drawn crash point. A refused spin shows | ||
| 128 | the error and never runs the animation: the reels and the wheel wait for the | ||
| 129 | server's answer, not for the click. | ||
| 130 | - Sidebars pin their action block below the content, so SPIN, CLEAR, DEAL and | ||
| 131 | START never move as the bet slip or a hand grows. The embedded fonts have no | ||
| 132 | suit or arrow glyphs, so suits are shapes and navigation uses plain words. | ||
| 133 | - The header balance is held at the staked amount while the reels or wheel | ||
| 134 | turn, then jumps to the settled value: the GUI never reveals a payout before | ||
| 135 | the animation does. Refused spins cancel the animation and show the error. | ||
| 136 | The crash multiplier keeps full precision in the game state so the chart is | ||
| 137 | a smooth line; the terminal still formats it to two decimals. | ||
| 138 | - `gui/tests/layout.rs` covers money, wheel angles, lattice snapping, table | ||
| 139 | aspect and bet names. | ||
| 140 | |||
| 141 | Verified live against a v4.2.0 server: register, login, auto-login, trust, | ||
| 142 | all four games settling, history, logout, disconnect, the leave-forfeit | ||
| 143 | confirmation, and the version-mismatch screen (new client against a 4.1.1 | ||
| 144 | server). | ||
| 145 | |||
| 146 | Screenshots: `docs/screenshots/gui-{lobby,slots,roulette,blackjack,crash, | ||
| 147 | history}-1080p.png`, plus the roulette result state. | ||
| 112 | 148 | ||
| 113 | ## Phase 4 — GUI wiring | 149 | Measured on Linux at 1080p with llvmpipe software GL: |
| 114 | ## Phase 4 — GUI wiring | ||
| 115 | 150 | ||
| 116 | - Network thread with channels, request deadlines in the shared adapter. | 151 | | Check | Result | |
| 117 | - Trust modal, version-mismatch screen, login/register, lobby, history panel. | 152 | | --- | --- | |
| 118 | - All four games, per-frame painting and tier 1–2 effects. | 153 | | Linux binary | 8.3 MB | |
| 119 | - Update notice from the handshake in the lobby. | 154 | | Windows x86_64 binary | 6.8 MB | |
| 155 | | Cold start to window | 61 ms | | ||
| 156 | | Idle RSS | 131 MB | | ||
| 157 | | Idle CPU | 0% | | ||
| 120 | 158 | ||
| 121 | **Gate:** manual checklist per game at 60 fps, `egui_kittest` wiring tests, | 159 | **Gate:** the screenshot set is ready for review. Remaining for the gate: |
| 122 | modem-throttled run. | 160 | frame rate on integrated graphics and the Windows interactive pass. |
| 123 | 161 | ||
| 124 | ## Phase 5 — packaging | 162 | ## Phase 5 — packaging |
| 163 | ## Phase 5 — packaging | ||
| 125 | 164 | ||
| 126 | - `publish.sh` emits `zagan-bet-gui-<version>-<platform>-<arch>[.exe]`. | 165 | - `publish.sh` emits `zagan-bet-gui-<version>-<platform>-<arch>[.exe]`. |
| 127 | - Bash and PowerShell installers that verify the SHA-256, install to the user | 166 | - Bash and PowerShell installers that verify the SHA-256, install to the user |
diff --git a/docs/TESTING.md b/docs/TESTING.md index 32fc628..817cb3d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md | |||
| @@ -46,7 +46,7 @@ or a vendored OpenSSL. | |||
| 46 | | `tests/slots.rs` | Server slots payout rules and overflow handling. | | 46 | | `tests/slots.rs` | Server slots payout rules and overflow handling. | |
| 47 | | `tests/storage.rs` | Client/server SQLite storage helpers. | | 47 | | `tests/storage.rs` | Client/server SQLite storage helpers. | |
| 48 | | `tests/tofu.rs` | SPKI fingerprint and TOFU decisions. | | 48 | | `tests/tofu.rs` | SPKI fingerprint and TOFU decisions. | |
| 49 | | `gui/tests/layout.rs` | GUI presentation maths: money grouping, wheel angle, table aspect, bet names. | | 49 | | `gui/tests/layout.rs` | GUI presentation maths: money grouping, wheel angle, table aspect, lattice snapping (straight/split/corner/outside), bet names. | |
| 50 | 50 | ||
| 51 | `tests/tofu.rs` and `tests/server_setup.rs` shell out to `openssl`, so run them | 51 | `tests/tofu.rs` and `tests/server_setup.rs` shell out to `openssl`, so run them |
| 52 | on a machine where the `openssl` CLI is available. | 52 | on a machine where the `openssl` CLI is available. |
| @@ -118,34 +118,36 @@ Use an 80-column terminal because several output lines are width-sensitive. | |||
| 118 | 118 | ||
| 119 | ## Manual GUI checklist | 119 | ## Manual GUI checklist |
| 120 | 120 | ||
| 121 | The GUI is a style spike until Phase 4: the table and wheel are painted from a | 121 | The desktop frontend shares `ClientCore` and the data directory with the |
| 122 | demo `RouletteView`, and no network connection is made. Run it on Linux and on | 122 | terminal client; a saved login from one signs in the other. |
| 123 | Windows 10 before approving the look. | 123 | |
| 124 | 124 | 1. `cargo run -p zagan-bet-gui` (or `-d <dir>` / `--unsafe`). The connect | |
| 125 | 1. `cargo run -p zagan-bet-gui` at 1440x900, then at 1920x1080. The header | 125 | screen lists remembered hosts with short fingerprints. |
| 126 | keeps its right margin at both sizes and shows ONLINE, host, ping, account | 126 | 2. First contact: the fingerprint modal appears before any credentials. Cancel |
| 127 | and balance; the table is centred and the sidebar is full height. | 127 | leaves the connect screen; Trust continues to the login screen. |
| 128 | 2. The felt shows 0, the 3x12 numbers, dozens, columns (each a quarter of the | 128 | 3. Register, then log in with "Remember this login". The lobby shows the host, |
| 129 | row) and the six outside cells. Chips sit on their targets: a straight in | 129 | ping, account, balance and the four games; quit and reconnect: the stored |
| 130 | the cell, a corner on the lattice crossing, an outside bet in its box. | 130 | login signs in without asking. |
| 131 | 3. Hover a number: a translucent chip of the selected denomination appears. | 131 | 4. Each game: place a bet or deal, play a round, and confirm the balance and the |
| 132 | In the inner half of a cell it is a straight (one cell tints); near an edge | 132 | header update. Slots shows reels and win lines; roulette spins the wheel and |
| 133 | it snaps to a split (two cells tint) and near a corner to a corner (four | 133 | reports the payout; blackjack draws suits and settles; crash draws a smooth |
| 134 | cells tint). Hovering the dozens, columns and outside boxes moves the | 134 | curve and cashes out. While the reels or wheel turn, the balance shows the |
| 135 | preview there. | 135 | stake already deducted; the payout appears only when they stop. A spin or |
| 136 | 4. Left click adds the chip where the preview is; right or middle click takes | 136 | deal the server refuses (for example an insufficient balance) shows the |
| 137 | one chip of that value back, dropping the row when it reaches zero. The | 137 | error and must not animate the reels, the wheel or the curve. |
| 138 | slip and total follow every change. C or CLEAR empties the slip. | 138 | 5. Sidebar actions never move while the slip grows. CLEAR empties the slip; |
| 139 | 5. Space or SPIN animates the wheel for about 2.4 s and stops with the winning | 139 | right or middle click on the felt takes one chip back; C clears and space |
| 140 | pocket under the pointer. The winning cell gets a gold outline and LAST | 140 | spins in roulette. |
| 141 | gains a dot. RESULT names the number and colour, WON shows the amount paid | 141 | 6. Leaving a live blackjack hand or crash round asks for confirmation, then |
| 142 | and the net for the round, and a losing spin says NO WIN. SPIN, CLEAR and | 142 | forfeits. Leaving an idle table returns to the lobby. |
| 143 | the chips are disabled during the spin. | 143 | 7. HISTORY shows the last plays; SHOW changes the limit. LOG OUT returns to |
| 144 | 6. Placing a chip after a result starts the next round: the payout row goes | 144 | the login screen but stays connected; Disconnect drops the session (the |
| 145 | back to PLACE YOUR BETS while the wheel stays where it stopped. Resize to | 145 | saved login survives). |
| 146 | 1100x720: the sidebar scrolls, nothing overlaps the header, and the table | 146 | 8. Run the client against a server of another MAJOR.MINOR version: the |
| 147 | keeps its aspect ratio. | 147 | mismatch screen names both versions and the download page. A changed host |
| 148 | 7. Compare the 1080p window against the approved reference screenshots. | 148 | key stops the connection and offers forget-and-reconnect. |
| 149 | 9. Resize to 1100x720: the sidebar scrolls, the actions stay reachable, and | ||
| 150 | the board keeps its aspect ratio. | ||
| 149 | 151 | ||
| 150 | ## Manual roulette checklist | 152 | ## Manual roulette checklist |
| 151 | 153 | ||
diff --git a/docs/screenshots/gui-blackjack-1080p.png b/docs/screenshots/gui-blackjack-1080p.png new file mode 100644 index 0000000..b0fcc19 --- /dev/null +++ b/docs/screenshots/gui-blackjack-1080p.png | |||
| Binary files differ | |||
diff --git a/docs/screenshots/gui-crash-1080p.png b/docs/screenshots/gui-crash-1080p.png new file mode 100644 index 0000000..245a3a1 --- /dev/null +++ b/docs/screenshots/gui-crash-1080p.png | |||
| Binary files differ | |||
diff --git a/docs/screenshots/gui-history-1080p.png b/docs/screenshots/gui-history-1080p.png new file mode 100644 index 0000000..a979dcd --- /dev/null +++ b/docs/screenshots/gui-history-1080p.png | |||
| Binary files differ | |||
diff --git a/docs/screenshots/gui-lobby-1080p.png b/docs/screenshots/gui-lobby-1080p.png new file mode 100644 index 0000000..a258967 --- /dev/null +++ b/docs/screenshots/gui-lobby-1080p.png | |||
| Binary files differ | |||
diff --git a/docs/screenshots/gui-roulette-1080p.png b/docs/screenshots/gui-roulette-1080p.png index 2f30aa7..1d7af3d 100644 --- a/docs/screenshots/gui-roulette-1080p.png +++ b/docs/screenshots/gui-roulette-1080p.png | |||
| Binary files differ | |||
diff --git a/docs/screenshots/gui-roulette-result-1080p.png b/docs/screenshots/gui-roulette-result-1080p.png index a7d425c..fe4c9cd 100644 --- a/docs/screenshots/gui-roulette-result-1080p.png +++ b/docs/screenshots/gui-roulette-result-1080p.png | |||
| Binary files differ | |||
diff --git a/docs/screenshots/gui-slots-1080p.png b/docs/screenshots/gui-slots-1080p.png new file mode 100644 index 0000000..9d79321 --- /dev/null +++ b/docs/screenshots/gui-slots-1080p.png | |||
| Binary files differ | |||
diff --git a/gui/Cargo.toml b/gui/Cargo.toml index a0dfb86..d3d9c36 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml | |||
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet-gui" | 2 | name = "zagan-bet-gui" |
| 3 | version = "4.1.1" | 3 | version = "4.2.0" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | license = "GPL-3.0-or-later" | 5 | license = "GPL-3.0-or-later" |
| 6 | description = "Desktop frontend for zagan-bet" | 6 | description = "Desktop frontend for zagan-bet" |
| @@ -19,3 +19,5 @@ eframe = { version = "0.37", default-features = false, features = [ | |||
| 19 | "wayland", | 19 | "wayland", |
| 20 | "x11", | 20 | "x11", |
| 21 | ] } | 21 | ] } |
| 22 | rusqlite = { version = "0.29", features = ["bundled"] } | ||
| 23 | sha2 = "0.10" | ||
diff --git a/gui/src/app.rs b/gui/src/app.rs index b5a50b3..f0efd3e 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs | |||
| @@ -1,75 +1,916 @@ | |||
| 1 | //! The desktop shell: header, roulette table, and betting sidebar. | 1 | //! The desktop shell: screens, session state, and the bridge to the core. |
| 2 | //! | ||
| 3 | //! Phase 3 is a style spike. The table is painted from a demo `RouletteView` | ||
| 4 | //! built with the core's own types, but no command is sent yet: Phase 4 wires | ||
| 5 | //! the transport and replaces the demo state with `ClientCore`. | ||
| 6 | 2 | ||
| 7 | use std::f32::consts::TAU; | 3 | use std::collections::VecDeque; |
| 4 | use std::path::PathBuf; | ||
| 5 | use std::sync::mpsc::Sender; | ||
| 6 | use std::time::{Duration, Instant}; | ||
| 8 | 7 | ||
| 9 | use eframe::egui::{ | 8 | use eframe::egui::{ |
| 10 | self, Align, Button, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel, | 9 | self, Align, Align2, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel, |
| 11 | RichText, Sense, Stroke, Ui, Vec2, vec2, | 10 | RichText, Sense, Stroke, TextEdit, Ui, Vec2, vec2, |
| 12 | }; | 11 | }; |
| 13 | use zagan_bet::client::games::roulette::Cursor; | 12 | use rusqlite::Connection; |
| 14 | use zagan_bet::client::view::{RoulettePhase, RouletteView}; | 13 | use sha2::{Digest, Sha256}; |
| 15 | use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color}; | 14 | use zagan_bet::client::core::{ClientCore, Effect}; |
| 16 | use zagan_bet::version::VERSION; | 15 | use zagan_bet::client::database; |
| 16 | use zagan_bet::client::paths; | ||
| 17 | use zagan_bet::client::view::{Command, LobbyView, View}; | ||
| 18 | use zagan_bet::history::PlayRecord; | ||
| 19 | use zagan_bet::version::{VERSION, is_newer}; | ||
| 17 | 20 | ||
| 18 | use crate::roulette::FeltAction; | 21 | use crate::net::{Command as NetCommand, Connected, Event, Failure, Network}; |
| 19 | use crate::{roulette, theme}; | 22 | use crate::{blackjack, crash, roulette, slots, theme, widgets}; |
| 20 | 23 | ||
| 21 | const HEADER_HEIGHT: f32 = 64.0; | 24 | /// A request that has no reply after this long kills the connection, matching |
| 22 | const SIDEBAR_WIDTH: f32 = 336.0; | 25 | /// the terminal adapter's socket timeout. |
| 23 | const SPIN_SECONDS: f64 = 2.4; | 26 | const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); |
| 24 | /// Demo winners, cycled: a red, a zero, a red, a black, a black, a black. | 27 | const NOTICE_SECONDS: u64 = 6; |
| 25 | const DEMO_TARGETS: [u8; 6] = [17, 0, 32, 26, 8, 11]; | 28 | const DEFAULT_BET: u64 = 10; |
| 26 | const CHIP_SIZES: [u64; 4] = [10, 25, 50, 100]; | 29 | |
| 30 | /// Where the app keeps its files and whether TLS is bypassed. | ||
| 31 | pub struct Startup { | ||
| 32 | pub data_dir: PathBuf, | ||
| 33 | pub unsafe_mode: bool, | ||
| 34 | } | ||
| 27 | 35 | ||
| 28 | pub struct ZaganApp { | 36 | pub struct ZaganApp { |
| 29 | demo: Demo, | 37 | core: ClientCore, |
| 38 | net: Network, | ||
| 39 | db: Option<Connection>, | ||
| 40 | db_error: Option<String>, | ||
| 41 | unsafe_mode: bool, | ||
| 42 | |||
| 43 | session: Option<Session>, | ||
| 44 | connect: ConnectState, | ||
| 45 | login: LoginState, | ||
| 46 | trust: Option<TrustState>, | ||
| 47 | key_changed: Option<KeyChanged>, | ||
| 48 | leave_confirm: bool, | ||
| 49 | notices: VecDeque<Notice>, | ||
| 50 | awaiting: Option<Instant>, | ||
| 51 | need_info: bool, | ||
| 52 | pending_login: Option<(String, String)>, | ||
| 53 | /// Displayed balance while a result animation runs: stake deducted, no | ||
| 54 | /// winnings yet. The view already knows the outcome; the player must not. | ||
| 55 | balance_hold: Option<i64>, | ||
| 56 | balance: Option<i64>, | ||
| 57 | account: Option<String>, | ||
| 58 | fx: Fx, | ||
| 59 | } | ||
| 60 | |||
| 61 | struct Session { | ||
| 62 | host: String, | ||
| 63 | fingerprint: Option<String>, | ||
| 64 | ping_ms: f32, | ||
| 65 | server_version: String, | ||
| 66 | latest: String, | ||
| 67 | url: String, | ||
| 68 | user: Option<String>, | ||
| 69 | } | ||
| 70 | |||
| 71 | #[derive(Default)] | ||
| 72 | struct ConnectState { | ||
| 73 | addr: String, | ||
| 74 | error: Option<String>, | ||
| 75 | version_issue: bool, | ||
| 76 | pending: bool, | ||
| 77 | } | ||
| 78 | |||
| 79 | #[derive(Default)] | ||
| 80 | struct LoginState { | ||
| 81 | user: String, | ||
| 82 | password: String, | ||
| 83 | error: Option<String>, | ||
| 84 | remember: bool, | ||
| 85 | } | ||
| 86 | |||
| 87 | impl LoginState { | ||
| 88 | fn new() -> Self { | ||
| 89 | Self { | ||
| 90 | remember: true, | ||
| 91 | ..Self::default() | ||
| 92 | } | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | struct TrustState { | ||
| 97 | addr: String, | ||
| 98 | fingerprint: String, | ||
| 99 | reply: Sender<bool>, | ||
| 100 | } | ||
| 101 | |||
| 102 | struct KeyChanged { | ||
| 103 | addr: String, | ||
| 104 | expected: String, | ||
| 105 | observed: String, | ||
| 106 | } | ||
| 107 | |||
| 108 | struct Notice { | ||
| 109 | text: String, | ||
| 110 | at: Instant, | ||
| 111 | tone: Tone, | ||
| 112 | } | ||
| 113 | |||
| 114 | #[derive(Clone, Copy, PartialEq, Eq)] | ||
| 115 | enum Tone { | ||
| 116 | Info, | ||
| 117 | Good, | ||
| 118 | Bad, | ||
| 119 | } | ||
| 120 | |||
| 121 | #[derive(Default)] | ||
| 122 | struct Fx { | ||
| 123 | game: Option<String>, | ||
| 124 | roulette: roulette::Fx, | ||
| 125 | slots: slots::Fx, | ||
| 126 | crash: crash::Fx, | ||
| 127 | } | ||
| 128 | |||
| 129 | impl Fx { | ||
| 130 | /// Reset the animation state when the player enters or leaves a game. | ||
| 131 | fn sync(&mut self, game: Option<&str>) -> bool { | ||
| 132 | if self.game.as_deref() != game { | ||
| 133 | let game = game.map(str::to_string); | ||
| 134 | *self = Fx { | ||
| 135 | game, | ||
| 136 | ..Fx::default() | ||
| 137 | }; | ||
| 138 | return true; | ||
| 139 | } | ||
| 140 | false | ||
| 141 | } | ||
| 30 | } | 142 | } |
| 31 | 143 | ||
| 32 | impl ZaganApp { | 144 | impl ZaganApp { |
| 33 | pub fn new(cc: &eframe::CreationContext<'_>) -> Self { | 145 | pub fn new(cc: &eframe::CreationContext<'_>, startup: Startup) -> Self { |
| 34 | theme::install(&cc.egui_ctx); | 146 | theme::install(&cc.egui_ctx); |
| 35 | Self { demo: Demo::new() } | 147 | |
| 148 | let db_path = paths::database_path(&startup.data_dir); | ||
| 149 | let (db, db_error) = match Connection::open(&db_path) { | ||
| 150 | Ok(conn) => { | ||
| 151 | let _ = conn.busy_timeout(Duration::from_secs(2)); | ||
| 152 | database::init_db(&conn); | ||
| 153 | (Some(conn), None) | ||
| 154 | } | ||
| 155 | Err(error) => ( | ||
| 156 | None, | ||
| 157 | Some(format!("Cannot open {}: {error}", db_path.display())), | ||
| 158 | ), | ||
| 159 | }; | ||
| 160 | let addr = db | ||
| 161 | .as_ref() | ||
| 162 | .and_then(database::get_last_host) | ||
| 163 | .unwrap_or_else(|| "127.0.0.1:11525".to_string()); | ||
| 164 | |||
| 165 | let net = Network::spawn( | ||
| 166 | cc.egui_ctx.clone(), | ||
| 167 | startup.data_dir.clone(), | ||
| 168 | startup.unsafe_mode, | ||
| 169 | ); | ||
| 170 | |||
| 171 | Self { | ||
| 172 | core: ClientCore::new(), | ||
| 173 | net, | ||
| 174 | db, | ||
| 175 | db_error, | ||
| 176 | unsafe_mode: startup.unsafe_mode, | ||
| 177 | session: None, | ||
| 178 | connect: ConnectState { | ||
| 179 | addr, | ||
| 180 | ..ConnectState::default() | ||
| 181 | }, | ||
| 182 | login: LoginState::new(), | ||
| 183 | trust: None, | ||
| 184 | key_changed: None, | ||
| 185 | leave_confirm: false, | ||
| 186 | notices: VecDeque::new(), | ||
| 187 | awaiting: None, | ||
| 188 | need_info: false, | ||
| 189 | pending_login: None, | ||
| 190 | balance_hold: None, | ||
| 191 | balance: None, | ||
| 192 | account: None, | ||
| 193 | fx: Fx::default(), | ||
| 194 | } | ||
| 195 | } | ||
| 196 | |||
| 197 | fn screen(&mut self, ui: &mut Ui) { | ||
| 198 | if let Some(error) = self.db_error.clone() { | ||
| 199 | fatal(ui, &error); | ||
| 200 | return; | ||
| 201 | } | ||
| 202 | |||
| 203 | let view = self.core.view(); | ||
| 204 | if self.session.is_none() { | ||
| 205 | connect_screen(ui, self); | ||
| 206 | return; | ||
| 207 | } | ||
| 208 | |||
| 209 | match view { | ||
| 210 | Some(View::Lobby(lobby)) if lobby.user.is_none() => login_screen(ui, self, &lobby), | ||
| 211 | Some(View::Lobby(lobby)) => lobby_screen(ui, self, &lobby), | ||
| 212 | Some(View::History(history)) => history_screen(ui, self, &history.records), | ||
| 213 | Some(View::Slots(view)) => { | ||
| 214 | let command = slots::show(ui, &view, &mut self.fx.slots); | ||
| 215 | if command == Some(Command::Spin) { | ||
| 216 | self.balance_hold = self | ||
| 217 | .balance | ||
| 218 | .map(|balance| (balance - view.bet as i64).max(0)); | ||
| 219 | } | ||
| 220 | if let Some(command) = command { | ||
| 221 | self.command(ui.ctx(), command); | ||
| 222 | } | ||
| 223 | } | ||
| 224 | Some(View::Roulette(view)) => { | ||
| 225 | let command = roulette::show(ui, &view, &mut self.fx.roulette); | ||
| 226 | if command == Some(Command::Spin) { | ||
| 227 | self.balance_hold = self | ||
| 228 | .balance | ||
| 229 | .map(|balance| (balance - view.bets.total() as i64).max(0)); | ||
| 230 | } | ||
| 231 | if let Some(command) = command { | ||
| 232 | self.command(ui.ctx(), command); | ||
| 233 | } | ||
| 234 | } | ||
| 235 | Some(View::Blackjack(view)) => { | ||
| 236 | if let Some(command) = blackjack::show(ui, &view) { | ||
| 237 | self.command(ui.ctx(), command); | ||
| 238 | } | ||
| 239 | } | ||
| 240 | Some(View::Crash(view)) => { | ||
| 241 | let command = crash::show(ui, &view, &mut self.fx.crash); | ||
| 242 | if let Some(command) = command { | ||
| 243 | self.command(ui.ctx(), command); | ||
| 244 | } | ||
| 245 | } | ||
| 246 | None => {} | ||
| 247 | } | ||
| 248 | } | ||
| 249 | |||
| 250 | /// Run one core command, holding back a leave that would forfeit a live | ||
| 251 | /// round until the player confirms. | ||
| 252 | fn command(&mut self, ctx: &Context, command: Command) { | ||
| 253 | if command == Command::Leave && self.core.is_live_game() { | ||
| 254 | self.leave_confirm = true; | ||
| 255 | return; | ||
| 256 | } | ||
| 257 | if command == Command::Logout { | ||
| 258 | self.need_info = false; | ||
| 259 | self.account = None; | ||
| 260 | self.balance = None; | ||
| 261 | self.pending_login = None; | ||
| 262 | if let Some(session) = &mut self.session { | ||
| 263 | session.user = None; | ||
| 264 | } | ||
| 265 | } | ||
| 266 | let effects = self.core.command(command); | ||
| 267 | self.apply(ctx, effects); | ||
| 268 | } | ||
| 269 | |||
| 270 | fn apply(&mut self, ctx: &Context, effects: Vec<Effect>) { | ||
| 271 | for effect in effects { | ||
| 272 | match effect { | ||
| 273 | Effect::Print(lines) => { | ||
| 274 | // Multi-line prints are terminal tables (history, help, | ||
| 275 | // info); the GUI draws those from `View` instead. | ||
| 276 | if lines.len() == 1 { | ||
| 277 | if let Some(line) = lines.first() { | ||
| 278 | self.notice(line); | ||
| 279 | } | ||
| 280 | } | ||
| 281 | } | ||
| 282 | Effect::SendLine(line) => { | ||
| 283 | self.net.send(NetCommand::Send(line)); | ||
| 284 | self.awaiting = Some(Instant::now()); | ||
| 285 | } | ||
| 286 | Effect::Disconnect => { | ||
| 287 | self.net.send(NetCommand::Disconnect); | ||
| 288 | self.core.reset_after_disconnect(); | ||
| 289 | self.session = None; | ||
| 290 | self.awaiting = None; | ||
| 291 | self.need_info = false; | ||
| 292 | self.account = None; | ||
| 293 | self.balance = None; | ||
| 294 | self.balance_hold = None; | ||
| 295 | } | ||
| 296 | Effect::ClearRegion | Effect::Render(_) => {} | ||
| 297 | Effect::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), | ||
| 298 | } | ||
| 299 | } | ||
| 300 | } | ||
| 301 | |||
| 302 | fn notice(&mut self, text: &str) { | ||
| 303 | // `info` is fetched to fill the header; its terminal lines are not | ||
| 304 | // news to the GUI. | ||
| 305 | if text.starts_with("Connected to:") | ||
| 306 | || text.starts_with("Fingerprint:") | ||
| 307 | || text.starts_with("User:") | ||
| 308 | || text.starts_with("Account:") | ||
| 309 | || text.starts_with("Balance:") | ||
| 310 | { | ||
| 311 | return; | ||
| 312 | } | ||
| 313 | let tone = if text.starts_with("ERROR") | ||
| 314 | || text.contains("failed") | ||
| 315 | || text.contains("Failed") | ||
| 316 | || text.contains("lost") | ||
| 317 | { | ||
| 318 | Tone::Bad | ||
| 319 | } else if text.starts_with("Logged in") | ||
| 320 | || text.starts_with("Account created") | ||
| 321 | || text.contains("saved") | ||
| 322 | { | ||
| 323 | Tone::Good | ||
| 324 | } else { | ||
| 325 | Tone::Info | ||
| 326 | }; | ||
| 327 | self.notices.push_back(Notice { | ||
| 328 | text: text.to_string(), | ||
| 329 | at: Instant::now(), | ||
| 330 | tone, | ||
| 331 | }); | ||
| 332 | while self.notices.len() > 3 { | ||
| 333 | self.notices.pop_front(); | ||
| 334 | } | ||
| 335 | } | ||
| 336 | |||
| 337 | fn pump(&mut self, ctx: &Context) { | ||
| 338 | while let Some(event) = self.net.try_recv() { | ||
| 339 | match event { | ||
| 340 | Event::Trust { | ||
| 341 | addr, | ||
| 342 | fingerprint, | ||
| 343 | reply, | ||
| 344 | } => { | ||
| 345 | self.trust = Some(TrustState { | ||
| 346 | addr, | ||
| 347 | fingerprint, | ||
| 348 | reply, | ||
| 349 | }); | ||
| 350 | } | ||
| 351 | Event::Connected(connected) => self.connected(ctx, connected), | ||
| 352 | Event::Failed { | ||
| 353 | addr, | ||
| 354 | failure, | ||
| 355 | message, | ||
| 356 | } => self.failed(addr, failure, message), | ||
| 357 | Event::Line(line) => { | ||
| 358 | if self.core.is_async(&line) { | ||
| 359 | if let Some(effects) = self.core.handle_async_line(&line) { | ||
| 360 | self.apply(ctx, effects); | ||
| 361 | } | ||
| 362 | } else { | ||
| 363 | self.awaiting = None; | ||
| 364 | let effects = self.core.handle_reply(&line); | ||
| 365 | self.apply(ctx, effects); | ||
| 366 | } | ||
| 367 | } | ||
| 368 | Event::Closed => { | ||
| 369 | self.session = None; | ||
| 370 | self.awaiting = None; | ||
| 371 | self.core.reset_after_disconnect(); | ||
| 372 | self.notice("Connection lost"); | ||
| 373 | ctx.request_repaint(); | ||
| 374 | } | ||
| 375 | } | ||
| 376 | } | ||
| 377 | } | ||
| 378 | |||
| 379 | fn connected(&mut self, ctx: &Context, connected: Connected) { | ||
| 380 | self.connect.pending = false; | ||
| 381 | self.connect.error = None; | ||
| 382 | self.core.set_connected(true); | ||
| 383 | self.core | ||
| 384 | .set_host_info(Some(connected.addr.clone()), connected.fingerprint.clone()); | ||
| 385 | self.core.set_logged_in(connected.user.clone()); | ||
| 386 | self.login.error = connected.auth_error.clone(); | ||
| 387 | self.login.user = connected.user.clone().unwrap_or_default(); | ||
| 388 | |||
| 389 | if let Some(db) = &self.db { | ||
| 390 | database::save_host(db, &connected.addr); | ||
| 391 | } | ||
| 392 | self.session = Some(Session { | ||
| 393 | host: connected.addr, | ||
| 394 | fingerprint: connected.fingerprint, | ||
| 395 | ping_ms: connected.elapsed_ms, | ||
| 396 | server_version: connected.server_version, | ||
| 397 | latest: connected.latest, | ||
| 398 | url: connected.url, | ||
| 399 | user: connected.user, | ||
| 400 | }); | ||
| 401 | |||
| 402 | self.need_info = self.session.as_ref().is_some_and(|s| s.user.is_some()); | ||
| 403 | self.account = None; | ||
| 404 | if self.need_info { | ||
| 405 | let effects = self.core.command(Command::Info); | ||
| 406 | self.apply(ctx, effects); | ||
| 407 | self.need_info = false; | ||
| 408 | } | ||
| 409 | } | ||
| 410 | |||
| 411 | fn failed(&mut self, addr: String, failure: Failure, message: String) { | ||
| 412 | self.connect.pending = false; | ||
| 413 | self.session = None; | ||
| 414 | self.core.set_connected(false); | ||
| 415 | self.awaiting = None; | ||
| 416 | match failure { | ||
| 417 | Failure::KeyChanged { expected, observed } => { | ||
| 418 | self.key_changed = Some(KeyChanged { | ||
| 419 | addr, | ||
| 420 | expected, | ||
| 421 | observed, | ||
| 422 | }); | ||
| 423 | } | ||
| 424 | Failure::VersionMismatch => { | ||
| 425 | self.connect.version_issue = true; | ||
| 426 | self.connect.error = Some(message); | ||
| 427 | } | ||
| 428 | Failure::TrustDeclined => { | ||
| 429 | self.trust = None; | ||
| 430 | self.connect.error = Some("Certificate was not trusted.".to_string()); | ||
| 431 | } | ||
| 432 | Failure::Timeout => { | ||
| 433 | self.trust = None; | ||
| 434 | self.connect.error = Some("Connection timed out.".to_string()); | ||
| 435 | } | ||
| 436 | Failure::Other => { | ||
| 437 | self.trust = None; | ||
| 438 | self.connect.error = Some(message); | ||
| 439 | } | ||
| 440 | } | ||
| 441 | } | ||
| 442 | |||
| 443 | fn begin_connect(&mut self, addr: &str) { | ||
| 444 | let addr = normalize_addr(addr); | ||
| 445 | self.connect.addr = addr.clone(); | ||
| 446 | self.connect.error = None; | ||
| 447 | self.connect.version_issue = false; | ||
| 448 | self.connect.pending = true; | ||
| 449 | self.login.error = None; | ||
| 450 | self.net.send(NetCommand::Connect { addr }); | ||
| 451 | } | ||
| 452 | |||
| 453 | fn disconnect(&mut self) { | ||
| 454 | self.net.send(NetCommand::Disconnect); | ||
| 455 | self.core.reset_after_disconnect(); | ||
| 456 | self.session = None; | ||
| 457 | self.awaiting = None; | ||
| 458 | self.trust = None; | ||
| 459 | self.key_changed = None; | ||
| 460 | self.need_info = false; | ||
| 461 | self.account = None; | ||
| 462 | self.balance = None; | ||
| 463 | self.balance_hold = None; | ||
| 464 | } | ||
| 465 | |||
| 466 | fn trust_decision(&mut self, accept: bool) { | ||
| 467 | if let Some(trust) = self.trust.take() { | ||
| 468 | let _ = trust.reply.send(accept); | ||
| 469 | if !accept { | ||
| 470 | self.connect.error = Some("Certificate was not trusted.".to_string()); | ||
| 471 | } | ||
| 472 | } | ||
| 473 | } | ||
| 474 | |||
| 475 | fn forget_trusted_host(&mut self, addr: &str) { | ||
| 476 | let Some(db) = &self.db else { return }; | ||
| 477 | match database::forget_host(db, addr) { | ||
| 478 | Ok(_) => { | ||
| 479 | self.key_changed = None; | ||
| 480 | self.begin_connect(addr); | ||
| 481 | } | ||
| 482 | Err(error) => self.connect.error = Some(format!("Failed to forget host: {error}")), | ||
| 483 | } | ||
| 484 | } | ||
| 485 | |||
| 486 | fn forget_login(&mut self) { | ||
| 487 | let Some(fingerprint) = self | ||
| 488 | .session | ||
| 489 | .as_ref() | ||
| 490 | .and_then(|session| session.fingerprint.clone()) | ||
| 491 | else { | ||
| 492 | return; | ||
| 493 | }; | ||
| 494 | let Some(db) = &self.db else { return }; | ||
| 495 | match database::forget_credentials(db, &fingerprint) { | ||
| 496 | Ok(true) => self.notice("Saved login forgotten. Trust is unchanged."), | ||
| 497 | Ok(false) => self.notice("No saved login for this server."), | ||
| 498 | Err(error) => self.notice(&format!("Failed to forget the login: {error}")), | ||
| 499 | } | ||
| 500 | } | ||
| 501 | |||
| 502 | fn login(&mut self, ctx: &Context, registering: bool) { | ||
| 503 | let user = self.login.user.trim().to_string(); | ||
| 504 | if user.is_empty() || self.login.password.is_empty() { | ||
| 505 | self.login.error = Some("Enter a username and a password.".to_string()); | ||
| 506 | return; | ||
| 507 | } | ||
| 508 | let credential = format!("{:x}", Sha256::digest(self.login.password.as_bytes())); | ||
| 509 | self.login.password.clear(); | ||
| 510 | self.login.error = None; | ||
| 511 | let command = if registering { | ||
| 512 | Command::Register { user, credential } | ||
| 513 | } else { | ||
| 514 | self.need_info = true; | ||
| 515 | if self.login.remember { | ||
| 516 | self.pending_login = Some((user.clone(), credential.clone())); | ||
| 517 | } | ||
| 518 | Command::Login { user, credential } | ||
| 519 | }; | ||
| 520 | let effects = self.core.command(command); | ||
| 521 | self.apply(ctx, effects); | ||
| 522 | } | ||
| 523 | |||
| 524 | fn tick(&mut self, ctx: &Context) { | ||
| 525 | // Request deadline, mirroring the terminal's socket timeout. | ||
| 526 | if self.need_info | ||
| 527 | && self | ||
| 528 | .session | ||
| 529 | .as_ref() | ||
| 530 | .is_some_and(|session| session.user.is_some()) | ||
| 531 | && self.account.is_none() | ||
| 532 | { | ||
| 533 | let effects = self.core.command(Command::Info); | ||
| 534 | self.apply(ctx, effects); | ||
| 535 | self.need_info = false; | ||
| 536 | } | ||
| 537 | |||
| 538 | if let Some(sent) = self.awaiting { | ||
| 539 | if sent.elapsed() > REQUEST_TIMEOUT { | ||
| 540 | self.awaiting = None; | ||
| 541 | self.net.send(NetCommand::Disconnect); | ||
| 542 | self.session = None; | ||
| 543 | self.core.reset_after_disconnect(); | ||
| 544 | let effects = self.core.handle_reply("ERROR Request timed out"); | ||
| 545 | self.apply(ctx, effects); | ||
| 546 | self.notice("Request timed out. Disconnected."); | ||
| 547 | } | ||
| 548 | } | ||
| 549 | |||
| 550 | let game = self.core.current_game_type().map(str::to_string); | ||
| 551 | if self.fx.sync(game.as_deref()) { | ||
| 552 | self.balance_hold = None; | ||
| 553 | } | ||
| 554 | |||
| 555 | match game.as_deref() { | ||
| 556 | Some("crash") => { | ||
| 557 | let effects = self.core.tick(); | ||
| 558 | self.apply(ctx, effects); | ||
| 559 | if let Some(View::Crash(view)) = self.core.view() { | ||
| 560 | self.fx.crash.tick(ctx, &view); | ||
| 561 | if view.status == "running" || view.status == "starting" { | ||
| 562 | ctx.request_repaint_after(Duration::from_millis(16)); | ||
| 563 | } | ||
| 564 | } | ||
| 565 | } | ||
| 566 | Some("slots") => { | ||
| 567 | if let Some(View::Slots(view)) = self.core.view() { | ||
| 568 | self.fx.slots.tick(ctx, &view); | ||
| 569 | } | ||
| 570 | } | ||
| 571 | Some("roulette") => { | ||
| 572 | if let Some(View::Roulette(view)) = self.core.view() { | ||
| 573 | self.fx.roulette.tick(ctx, &view); | ||
| 574 | } | ||
| 575 | } | ||
| 576 | _ => {} | ||
| 577 | } | ||
| 578 | |||
| 579 | // Hold the balance at the staked amount until the reels or wheel stop, | ||
| 580 | // so the payout is not visible before the animation reveals it. | ||
| 581 | if self.balance_hold.is_some() && !self.result_animating(ctx, game.as_deref()) { | ||
| 582 | self.balance_hold = None; | ||
| 583 | } | ||
| 584 | |||
| 585 | self.keys(ctx, game.as_deref()); | ||
| 586 | |||
| 587 | // A successful manual login can be remembered for the next connect. | ||
| 588 | if let Some((user, credential)) = self.pending_login.clone() { | ||
| 589 | if self | ||
| 590 | .core | ||
| 591 | .view() | ||
| 592 | .as_ref() | ||
| 593 | .is_some_and(|view| matches!(view, View::Lobby(lobby) if lobby.user.is_some())) | ||
| 594 | { | ||
| 595 | if let Some(fingerprint) = self | ||
| 596 | .session | ||
| 597 | .as_ref() | ||
| 598 | .and_then(|session| session.fingerprint.as_deref()) | ||
| 599 | { | ||
| 600 | if let Some(db) = &self.db { | ||
| 601 | let _ = database::save_credentials(db, fingerprint, &user, &credential); | ||
| 602 | self.notice("Login saved for this server."); | ||
| 603 | } | ||
| 604 | } | ||
| 605 | self.pending_login = None; | ||
| 606 | } | ||
| 607 | } | ||
| 608 | |||
| 609 | // Cache the header data, which game views do not all carry. | ||
| 610 | if let Some(view) = self.core.view() { | ||
| 611 | match view { | ||
| 612 | View::Lobby(lobby) => { | ||
| 613 | if let Some(session) = &mut self.session { | ||
| 614 | session.user = lobby.user.clone(); | ||
| 615 | } | ||
| 616 | if lobby.user.is_none() { | ||
| 617 | self.balance = None; | ||
| 618 | self.account = None; | ||
| 619 | } else { | ||
| 620 | if let Some(account) = lobby.account { | ||
| 621 | self.account = Some(account); | ||
| 622 | } | ||
| 623 | if let Some(balance) = lobby.balance { | ||
| 624 | if self.balance_hold.is_none() { | ||
| 625 | self.balance = Some(balance); | ||
| 626 | } | ||
| 627 | } | ||
| 628 | } | ||
| 629 | } | ||
| 630 | View::History(_) => {} | ||
| 631 | other => { | ||
| 632 | if self.balance_hold.is_none() { | ||
| 633 | if let Some(balance) = balance_of(&other) { | ||
| 634 | self.balance = Some(balance); | ||
| 635 | } | ||
| 636 | } | ||
| 637 | } | ||
| 638 | } | ||
| 639 | } | ||
| 640 | |||
| 641 | self.notices | ||
| 642 | .retain(|notice| notice.at.elapsed() < Duration::from_secs(NOTICE_SECONDS)); | ||
| 643 | if !self.notices.is_empty() { | ||
| 644 | ctx.request_repaint_after(Duration::from_millis(500)); | ||
| 645 | } | ||
| 646 | } | ||
| 647 | |||
| 648 | /// True while a spin's result is still being animated. | ||
| 649 | fn result_animating(&self, ctx: &Context, game: Option<&str>) -> bool { | ||
| 650 | match game { | ||
| 651 | Some("slots") => match self.core.view() { | ||
| 652 | Some(View::Slots(view)) if view.error.is_none() => { | ||
| 653 | !self.fx.slots.revealed(ctx, &view) | ||
| 654 | } | ||
| 655 | _ => false, | ||
| 656 | }, | ||
| 657 | Some("roulette") => match self.core.view() { | ||
| 658 | Some(View::Roulette(view)) if view.error.is_none() => { | ||
| 659 | !self.fx.roulette.revealed(&view) | ||
| 660 | } | ||
| 661 | _ => false, | ||
| 662 | }, | ||
| 663 | _ => false, | ||
| 664 | } | ||
| 665 | } | ||
| 666 | |||
| 667 | /// The few keys that stay useful when the pointer is elsewhere. Buttons | ||
| 668 | /// keep focus, so this only fires when nothing is focused. | ||
| 669 | fn keys(&mut self, ctx: &Context, game: Option<&str>) { | ||
| 670 | if self.session.is_none() || !ctx.memory(|memory| memory.focused().is_none()) { | ||
| 671 | return; | ||
| 672 | } | ||
| 673 | let (space, clear, cash, hit, stand, double) = ctx.input(|input| { | ||
| 674 | ( | ||
| 675 | input.key_pressed(Key::Space), | ||
| 676 | input.key_pressed(Key::C), | ||
| 677 | input.key_pressed(Key::C), | ||
| 678 | input.key_pressed(Key::H), | ||
| 679 | input.key_pressed(Key::S), | ||
| 680 | input.key_pressed(Key::D), | ||
| 681 | ) | ||
| 682 | }); | ||
| 683 | let command = match game { | ||
| 684 | Some("roulette") => { | ||
| 685 | if space { | ||
| 686 | self.fx.roulette.spin_requested(); | ||
| 687 | Some(Command::Spin) | ||
| 688 | } else if clear { | ||
| 689 | Some(Command::ClearBets) | ||
| 690 | } else { | ||
| 691 | None | ||
| 692 | } | ||
| 693 | } | ||
| 694 | Some("slots") if space => Some(Command::Spin), | ||
| 695 | Some("crash") => { | ||
| 696 | if cash { | ||
| 697 | Some(Command::CashOut) | ||
| 698 | } else if space { | ||
| 699 | Some(Command::Start) | ||
| 700 | } else { | ||
| 701 | None | ||
| 702 | } | ||
| 703 | } | ||
| 704 | Some("blackjack") => { | ||
| 705 | if hit { | ||
| 706 | Some(Command::Hit) | ||
| 707 | } else if stand { | ||
| 708 | Some(Command::Stand) | ||
| 709 | } else if double { | ||
| 710 | Some(Command::Double) | ||
| 711 | } else if space { | ||
| 712 | Some(Command::Deal) | ||
| 713 | } else { | ||
| 714 | None | ||
| 715 | } | ||
| 716 | } | ||
| 717 | _ => None, | ||
| 718 | }; | ||
| 719 | if let Some(command) = command { | ||
| 720 | self.command(ctx, command); | ||
| 721 | } | ||
| 722 | } | ||
| 723 | |||
| 724 | fn modals(&mut self, ctx: &Context) { | ||
| 725 | if let Some(trust) = &self.trust { | ||
| 726 | let addr = trust.addr.clone(); | ||
| 727 | let fingerprint = trust.fingerprint.clone(); | ||
| 728 | let mut decision = None; | ||
| 729 | egui::Modal::new(egui::Id::unique("trust")).show(ctx, |ui| { | ||
| 730 | ui.set_width(460.0); | ||
| 731 | ui.label( | ||
| 732 | RichText::new("New server certificate") | ||
| 733 | .font(theme::sans(20.0)) | ||
| 734 | .color(theme::TEXT), | ||
| 735 | ); | ||
| 736 | ui.add_space(theme::SPACE_2); | ||
| 737 | widgets::note( | ||
| 738 | ui, | ||
| 739 | "This is the first time this client has seen this server. \ | ||
| 740 | Check the fingerprint before trusting it.", | ||
| 741 | ); | ||
| 742 | ui.add_space(theme::SPACE_3); | ||
| 743 | widgets::row(ui, "Address", &addr, theme::TEXT); | ||
| 744 | widgets::mono_text(ui, &fingerprint, theme::ACCENT); | ||
| 745 | ui.add_space(theme::SPACE_3); | ||
| 746 | widgets::note( | ||
| 747 | ui, | ||
| 748 | "Future connections must present the same key. A changed key is refused.", | ||
| 749 | ); | ||
| 750 | ui.add_space(theme::SPACE_4); | ||
| 751 | ui.horizontal(|ui| { | ||
| 752 | if widgets::primary(ui, "TRUST", true, 130.0, 40.0) { | ||
| 753 | decision = Some(true); | ||
| 754 | } | ||
| 755 | ui.add_space(theme::SPACE_2); | ||
| 756 | if widgets::secondary(ui, "CANCEL", true, 110.0, 40.0) { | ||
| 757 | decision = Some(false); | ||
| 758 | } | ||
| 759 | }); | ||
| 760 | }); | ||
| 761 | if let Some(accept) = decision { | ||
| 762 | self.trust_decision(accept); | ||
| 763 | } | ||
| 764 | } | ||
| 765 | |||
| 766 | if let Some(changed) = &self.key_changed { | ||
| 767 | let addr = changed.addr.clone(); | ||
| 768 | let expected = changed.expected.clone(); | ||
| 769 | let observed = changed.observed.clone(); | ||
| 770 | let mut action = None; | ||
| 771 | egui::Modal::new(egui::Id::unique("key-changed")).show(ctx, |ui| { | ||
| 772 | ui.set_width(520.0); | ||
| 773 | ui.label( | ||
| 774 | RichText::new("SERVER KEY CHANGED") | ||
| 775 | .font(theme::mono(16.0)) | ||
| 776 | .color(theme::LOSS), | ||
| 777 | ); | ||
| 778 | ui.add_space(theme::SPACE_2); | ||
| 779 | widgets::note( | ||
| 780 | ui, | ||
| 781 | "A server that used to present a different certificate is answering. \ | ||
| 782 | This can mean the server was reinstalled, or that someone is \ | ||
| 783 | intercepting the connection.", | ||
| 784 | ); | ||
| 785 | ui.add_space(theme::SPACE_3); | ||
| 786 | widgets::row(ui, "Address", &addr, theme::TEXT); | ||
| 787 | widgets::mono_text(ui, &format!("expected {expected}"), theme::TEXT_DIM); | ||
| 788 | widgets::mono_text(ui, &format!("observed {observed}"), theme::LOSS); | ||
| 789 | ui.add_space(theme::SPACE_4); | ||
| 790 | ui.horizontal(|ui| { | ||
| 791 | if widgets::primary(ui, "FORGET AND RECONNECT", true, 220.0, 40.0) { | ||
| 792 | action = Some(true); | ||
| 793 | } | ||
| 794 | ui.add_space(theme::SPACE_2); | ||
| 795 | if widgets::secondary(ui, "CANCEL", true, 110.0, 40.0) { | ||
| 796 | action = Some(false); | ||
| 797 | } | ||
| 798 | }); | ||
| 799 | }); | ||
| 800 | match action { | ||
| 801 | Some(true) => self.forget_trusted_host(&addr), | ||
| 802 | Some(false) => { | ||
| 803 | self.key_changed = None; | ||
| 804 | self.connect.error = Some("Server key changed. Trust not reset.".to_string()); | ||
| 805 | } | ||
| 806 | None => {} | ||
| 807 | } | ||
| 808 | } | ||
| 809 | |||
| 810 | if self.leave_confirm { | ||
| 811 | let mut action = None; | ||
| 812 | egui::Modal::new(egui::Id::unique("leave-confirm")).show(ctx, |ui| { | ||
| 813 | ui.set_width(400.0); | ||
| 814 | ui.label( | ||
| 815 | RichText::new("Leave the table?") | ||
| 816 | .font(theme::sans(19.0)) | ||
| 817 | .color(theme::TEXT), | ||
| 818 | ); | ||
| 819 | ui.add_space(theme::SPACE_2); | ||
| 820 | widgets::note( | ||
| 821 | ui, | ||
| 822 | "A live hand or round is forfeited. The reserved bet is lost.", | ||
| 823 | ); | ||
| 824 | ui.add_space(theme::SPACE_4); | ||
| 825 | ui.horizontal(|ui| { | ||
| 826 | if widgets::primary(ui, "FORFEIT AND LEAVE", true, 190.0, 40.0) { | ||
| 827 | action = Some(true); | ||
| 828 | } | ||
| 829 | ui.add_space(theme::SPACE_2); | ||
| 830 | if widgets::secondary(ui, "STAY", true, 100.0, 40.0) { | ||
| 831 | action = Some(false); | ||
| 832 | } | ||
| 833 | }); | ||
| 834 | }); | ||
| 835 | if let Some(leave) = action { | ||
| 836 | self.leave_confirm = false; | ||
| 837 | if leave { | ||
| 838 | let effects = self.core.command(Command::Leave); | ||
| 839 | self.apply(ctx, effects); | ||
| 840 | } | ||
| 841 | } | ||
| 842 | } | ||
| 843 | } | ||
| 844 | |||
| 845 | fn toasts(&self, ctx: &Context) { | ||
| 846 | if self.notices.is_empty() { | ||
| 847 | return; | ||
| 848 | } | ||
| 849 | egui::Area::new(egui::Id::unique("toasts")) | ||
| 850 | .anchor(Align2::LEFT_BOTTOM, vec2(24.0, -24.0)) | ||
| 851 | .order(egui::Order::Foreground) | ||
| 852 | .interactable(false) | ||
| 853 | .show(ctx, |ui| { | ||
| 854 | ui.set_min_width(220.0); | ||
| 855 | ui.set_max_width(460.0); | ||
| 856 | ui.spacing_mut().item_spacing.y = theme::SPACE_2; | ||
| 857 | for notice in &self.notices { | ||
| 858 | let color = match notice.tone { | ||
| 859 | Tone::Bad => theme::LOSS, | ||
| 860 | Tone::Good => theme::WIN, | ||
| 861 | Tone::Info => theme::TEXT_DIM, | ||
| 862 | }; | ||
| 863 | Frame::new() | ||
| 864 | .fill(theme::SURFACE_RAISED) | ||
| 865 | .stroke(Stroke::new(1.0, theme::LINE)) | ||
| 866 | .corner_radius(theme::RADIUS_MD) | ||
| 867 | .inner_margin(Margin::symmetric(14, 8)) | ||
| 868 | .show(ui, |ui| { | ||
| 869 | ui.set_min_width(226.0); | ||
| 870 | ui.set_max_width(440.0); | ||
| 871 | ui.label( | ||
| 872 | RichText::new(¬ice.text) | ||
| 873 | .font(theme::sans(12.0)) | ||
| 874 | .color(color), | ||
| 875 | ); | ||
| 876 | }); | ||
| 877 | } | ||
| 878 | }); | ||
| 36 | } | 879 | } |
| 37 | } | 880 | } |
| 38 | 881 | ||
| 39 | impl eframe::App for ZaganApp { | 882 | impl eframe::App for ZaganApp { |
| 40 | fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { | 883 | fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { |
| 41 | self.demo.tick(ui.ctx()); | 884 | let ctx = ui.ctx().clone(); |
| 42 | self.demo.keys(ui.ctx()); | 885 | self.pump(&ctx); |
| 43 | 886 | self.tick(&ctx); | |
| 44 | Panel::top("header") | 887 | header(ui, self); |
| 45 | .exact_size(HEADER_HEIGHT) | 888 | self.screen(ui); |
| 46 | .frame( | 889 | self.modals(&ctx); |
| 47 | Frame::new() | 890 | self.toasts(&ctx); |
| 48 | .fill(theme::SURFACE) | 891 | } |
| 49 | .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 0)), | 892 | } |
| 50 | ) | ||
| 51 | .show(ui, |ui| header(ui, &self.demo)); | ||
| 52 | |||
| 53 | Panel::right("sidebar") | ||
| 54 | .exact_size(SIDEBAR_WIDTH) | ||
| 55 | .frame( | ||
| 56 | Frame::new() | ||
| 57 | .fill(theme::SURFACE) | ||
| 58 | .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 20)), | ||
| 59 | ) | ||
| 60 | .show(ui, |ui| { | ||
| 61 | egui::ScrollArea::vertical() | ||
| 62 | .auto_shrink([false, false]) | ||
| 63 | .show(ui, |ui| sidebar(ui, &mut self.demo)); | ||
| 64 | }); | ||
| 65 | 893 | ||
| 66 | CentralPanel::default() | 894 | fn balance_of(view: &View) -> Option<i64> { |
| 67 | .frame( | 895 | match view { |
| 68 | Frame::new() | 896 | View::Lobby(lobby) => lobby.balance, |
| 69 | .fill(theme::BG) | 897 | View::Slots(view) => view.balance, |
| 70 | .inner_margin(Margin::same(theme::SPACE_5 as i8)), | 898 | View::Roulette(view) => view.balance, |
| 71 | ) | 899 | View::Blackjack(view) => view.balance, |
| 72 | .show(ui, |ui| table(ui, &mut self.demo)); | 900 | View::Crash(view) => view.balance, |
| 901 | View::History(_) => None, | ||
| 902 | } | ||
| 903 | } | ||
| 904 | |||
| 905 | fn normalize_addr(addr: &str) -> String { | ||
| 906 | let addr = addr.trim(); | ||
| 907 | if addr.is_empty() { | ||
| 908 | return "127.0.0.1:11525".to_string(); | ||
| 909 | } | ||
| 910 | if addr.contains(':') { | ||
| 911 | addr.to_string() | ||
| 912 | } else { | ||
| 913 | format!("{addr}:11525") | ||
| 73 | } | 914 | } |
| 74 | } | 915 | } |
| 75 | 916 | ||
| @@ -77,50 +918,76 @@ impl eframe::App for ZaganApp { | |||
| 77 | // Header | 918 | // Header |
| 78 | // --------------------------------------------------------------------------- | 919 | // --------------------------------------------------------------------------- |
| 79 | 920 | ||
| 80 | fn header(ui: &mut Ui, demo: &Demo) { | 921 | fn header(ui: &mut Ui, app: &ZaganApp) { |
| 81 | ui.with_layout(Layout::left_to_right(Align::Center), |ui| { | 922 | Panel::top("header") |
| 82 | let (mark, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); | 923 | .exact_size(64.0) |
| 83 | ui.painter().rect_filled(mark, theme::RADIUS_SM, theme::WIN); | 924 | .frame( |
| 84 | ui.add_space(theme::SPACE_2); | 925 | Frame::new() |
| 85 | ui.label( | 926 | .fill(theme::SURFACE) |
| 86 | RichText::new("ZAGAN") | 927 | .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 0)), |
| 87 | .font(theme::mono(18.0)) | 928 | ) |
| 88 | .color(theme::TEXT), | 929 | .show(ui, |ui| { |
| 89 | ); | 930 | ui.with_layout(Layout::left_to_right(Align::Center), |ui| { |
| 90 | ui.label( | 931 | let (mark, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); |
| 91 | RichText::new("BET") | 932 | ui.painter().rect_filled(mark, theme::RADIUS_SM, theme::WIN); |
| 92 | .font(theme::mono(18.0)) | 933 | ui.add_space(theme::SPACE_2); |
| 93 | .color(theme::WIN), | 934 | ui.label( |
| 94 | ); | 935 | RichText::new("ZAGAN") |
| 95 | ui.add_space(theme::SPACE_3); | 936 | .font(theme::mono(18.0)) |
| 96 | pill(ui, &format!("v{VERSION}"), theme::TEXT_FAINT); | 937 | .color(theme::TEXT), |
| 97 | ui.add_space(theme::SPACE_1); | 938 | ); |
| 98 | pill(ui, "PREVIEW", theme::GOLD); | 939 | ui.label( |
| 99 | ui.add_space(theme::SPACE_5); | 940 | RichText::new("BET") |
| 100 | connection_pill(ui, demo); | 941 | .font(theme::mono(18.0)) |
| 942 | .color(theme::WIN), | ||
| 943 | ); | ||
| 944 | ui.add_space(theme::SPACE_3); | ||
| 945 | pill(ui, &format!("v{VERSION}"), theme::TEXT_FAINT); | ||
| 946 | if app.unsafe_mode { | ||
| 947 | ui.add_space(theme::SPACE_1); | ||
| 948 | pill(ui, "UNSAFE", theme::LOSS); | ||
| 949 | } | ||
| 950 | ui.add_space(theme::SPACE_5); | ||
| 951 | match &app.session { | ||
| 952 | Some(session) => connection_pill(ui, session), | ||
| 953 | None => pill(ui, "OFFLINE", theme::TEXT_FAINT), | ||
| 954 | } | ||
| 101 | 955 | ||
| 102 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | 956 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { |
| 103 | stat_block( | 957 | let signed_in = app |
| 104 | ui, | 958 | .session |
| 105 | 150.0, | 959 | .as_ref() |
| 106 | "Balance", | 960 | .is_some_and(|session| session.user.is_some()); |
| 107 | &theme::money(demo.balance), | 961 | if signed_in { |
| 108 | theme::mono(20.0), | 962 | if let Some(balance) = app.balance_hold.or(app.balance) { |
| 109 | theme::TEXT, | 963 | stat_block( |
| 110 | None, | 964 | ui, |
| 111 | ); | 965 | 150.0, |
| 112 | ui.add_space(theme::SPACE_5); | 966 | "Balance", |
| 113 | stat_block( | 967 | &theme::money(balance), |
| 114 | ui, | 968 | theme::mono(20.0), |
| 115 | 190.0, | 969 | theme::TEXT, |
| 116 | "Account", | 970 | None, |
| 117 | demo.user, | 971 | ); |
| 118 | theme::sans(theme::FONT_BODY), | 972 | ui.add_space(theme::SPACE_5); |
| 119 | theme::TEXT, | 973 | } |
| 120 | Some(demo.account), | 974 | if let Some(user) = app.session.as_ref().and_then(|s| s.user.as_deref()) { |
| 121 | ); | 975 | let account = |
| 976 | app.account.as_deref().map(|account| format!("#{account}")); | ||
| 977 | stat_block( | ||
| 978 | ui, | ||
| 979 | 190.0, | ||
| 980 | "Account", | ||
| 981 | user, | ||
| 982 | theme::sans(theme::FONT_BODY), | ||
| 983 | theme::TEXT, | ||
| 984 | account.as_deref(), | ||
| 985 | ); | ||
| 986 | } | ||
| 987 | } | ||
| 988 | }); | ||
| 989 | }); | ||
| 122 | }); | 990 | }); |
| 123 | }); | ||
| 124 | } | 991 | } |
| 125 | 992 | ||
| 126 | fn pill(ui: &mut Ui, text: &str, color: Color32) { | 993 | fn pill(ui: &mut Ui, text: &str, color: Color32) { |
| @@ -133,7 +1000,7 @@ fn pill(ui: &mut Ui, text: &str, color: Color32) { | |||
| 133 | }); | 1000 | }); |
| 134 | } | 1001 | } |
| 135 | 1002 | ||
| 136 | fn connection_pill(ui: &mut Ui, demo: &Demo) { | 1003 | fn connection_pill(ui: &mut Ui, session: &Session) { |
| 137 | Frame::new() | 1004 | Frame::new() |
| 138 | .fill(theme::SURFACE_RAISED) | 1005 | .fill(theme::SURFACE_RAISED) |
| 139 | .corner_radius(theme::RADIUS_SM) | 1006 | .corner_radius(theme::RADIUS_SM) |
| @@ -148,12 +1015,12 @@ fn connection_pill(ui: &mut Ui, demo: &Demo) { | |||
| 148 | .color(theme::WIN), | 1015 | .color(theme::WIN), |
| 149 | ); | 1016 | ); |
| 150 | ui.label( | 1017 | ui.label( |
| 151 | RichText::new(demo.host) | 1018 | RichText::new(&session.host) |
| 152 | .font(theme::mono(12.0)) | 1019 | .font(theme::mono(12.0)) |
| 153 | .color(theme::TEXT_DIM), | 1020 | .color(theme::TEXT_DIM), |
| 154 | ); | 1021 | ); |
| 155 | ui.label( | 1022 | ui.label( |
| 156 | RichText::new(format!("{} ms", demo.ping_ms)) | 1023 | RichText::new(format!("{:.1} ms", session.ping_ms)) |
| 157 | .font(theme::mono(12.0)) | 1024 | .font(theme::mono(12.0)) |
| 158 | .color(theme::TEXT_FAINT), | 1025 | .color(theme::TEXT_FAINT), |
| 159 | ); | 1026 | ); |
| @@ -161,8 +1028,6 @@ fn connection_pill(ui: &mut Ui, demo: &Demo) { | |||
| 161 | }); | 1028 | }); |
| 162 | } | 1029 | } |
| 163 | 1030 | ||
| 164 | /// A label over a value, right-aligned in a fixed slot so the header's right | ||
| 165 | /// edge stays put as the numbers change. | ||
| 166 | fn stat_block( | 1031 | fn stat_block( |
| 167 | ui: &mut Ui, | 1032 | ui: &mut Ui, |
| 168 | width: f32, | 1033 | width: f32, |
| @@ -187,454 +1052,554 @@ fn stat_block( | |||
| 187 | } | 1052 | } |
| 188 | 1053 | ||
| 189 | // --------------------------------------------------------------------------- | 1054 | // --------------------------------------------------------------------------- |
| 190 | // Sidebar | 1055 | // Connect |
| 191 | // --------------------------------------------------------------------------- | 1056 | // --------------------------------------------------------------------------- |
| 192 | 1057 | ||
| 193 | fn sidebar(ui: &mut Ui, demo: &mut Demo) { | 1058 | fn connect_screen(ui: &mut Ui, app: &mut ZaganApp) { |
| 194 | ui.label( | 1059 | CentralPanel::default() |
| 195 | RichText::new("Roulette") | 1060 | .frame( |
| 196 | .font(theme::sans(22.0)) | 1061 | Frame::new() |
| 197 | .color(theme::TEXT), | 1062 | .fill(theme::BG) |
| 198 | ); | 1063 | .inner_margin(Margin::same(theme::SPACE_5 as i8)), |
| 199 | ui.label( | 1064 | ) |
| 200 | RichText::new("European · single zero") | 1065 | .show(ui, |ui| { |
| 201 | .font(theme::sans(12.0)) | 1066 | ui.vertical_centered(|ui| { |
| 202 | .color(theme::TEXT_DIM), | 1067 | ui.add_space(60.0); |
| 203 | ); | 1068 | let width = 560.0; |
| 1069 | ui.allocate_ui_with_layout(vec2(width, 0.0), Layout::top_down(Align::Min), |ui| { | ||
| 1070 | Frame::new() | ||
| 1071 | .fill(theme::SURFACE) | ||
| 1072 | .corner_radius(theme::RADIUS_LG) | ||
| 1073 | .inner_margin(Margin::same(theme::SPACE_5 as i8)) | ||
| 1074 | .show(ui, |ui| { | ||
| 1075 | ui.label( | ||
| 1076 | RichText::new("Connect to a server") | ||
| 1077 | .font(theme::sans(24.0)) | ||
| 1078 | .color(theme::TEXT), | ||
| 1079 | ); | ||
| 1080 | ui.add_space(theme::SPACE_1); | ||
| 1081 | widgets::note( | ||
| 1082 | ui, | ||
| 1083 | "TLS with certificate pinning. First contact asks before \ | ||
| 1084 | any credential is sent.", | ||
| 1085 | ); | ||
| 1086 | ui.add_space(theme::SPACE_4); | ||
| 204 | 1087 | ||
| 205 | ui.add_space(theme::SPACE_4); | 1088 | let response = ui.add( |
| 206 | let view = demo.view(); | 1089 | TextEdit::singleline(&mut app.connect.addr) |
| 207 | let wheel_size = ui.available_width().min(210.0); | 1090 | .hint_text("host:port") |
| 208 | ui.horizontal(|ui| { | 1091 | .desired_width(f32::INFINITY) |
| 209 | let pad = ((ui.available_width() - wheel_size) * 0.5).max(0.0); | 1092 | .font(theme::mono(14.0)), |
| 210 | ui.add_space(pad); | 1093 | ); |
| 211 | roulette::wheel(ui, wheel_size, &view, demo.rotation); | 1094 | let submitted = response.lost_focus() |
| 212 | }); | 1095 | && ui.input(|input| input.key_pressed(Key::Enter)); |
| 213 | ui.add_space(theme::SPACE_1); | ||
| 214 | result_line(ui, demo); | ||
| 215 | 1096 | ||
| 216 | ui.add_space(theme::SPACE_3); | 1097 | ui.add_space(theme::SPACE_3); |
| 217 | ui.separator(); | 1098 | let connect = widgets::primary( |
| 218 | ui.add_space(theme::SPACE_3); | 1099 | ui, |
| 1100 | if app.connect.pending { | ||
| 1101 | "CONNECTING…" | ||
| 1102 | } else { | ||
| 1103 | "CONNECT" | ||
| 1104 | }, | ||
| 1105 | !app.connect.pending, | ||
| 1106 | ui.available_width(), | ||
| 1107 | 44.0, | ||
| 1108 | ); | ||
| 1109 | if (connect || submitted) && !app.connect.pending { | ||
| 1110 | let addr = app.connect.addr.clone(); | ||
| 1111 | app.begin_connect(&addr); | ||
| 1112 | } | ||
| 219 | 1113 | ||
| 220 | ui.label(theme::label("Chip")); | 1114 | if let Some(error) = &app.connect.error { |
| 221 | ui.add_space(theme::SPACE_2); | 1115 | ui.add_space(theme::SPACE_3); |
| 222 | let spinning = demo.phase == RoulettePhase::Spinning; | 1116 | widgets::error(ui, error); |
| 223 | ui.horizontal(|ui| { | 1117 | } |
| 224 | ui.spacing_mut().item_spacing.x = theme::SPACE_2; | 1118 | if app.connect.version_issue { |
| 225 | for amount in CHIP_SIZES { | 1119 | ui.add_space(theme::SPACE_2); |
| 226 | if chip_button(ui, amount, demo.chip == amount, !spinning).clicked() { | 1120 | widgets::note( |
| 227 | demo.chip = amount; | 1121 | ui, |
| 228 | } | 1122 | &format!( |
| 229 | } | 1123 | "Download the matching client from {}", |
| 230 | }); | 1124 | zagan_bet::version::DEFAULT_DOWNLOAD_URL |
| 1125 | ), | ||
| 1126 | ); | ||
| 1127 | } | ||
| 1128 | }); | ||
| 231 | 1129 | ||
| 232 | ui.add_space(theme::SPACE_3); | 1130 | ui.add_space(theme::SPACE_4); |
| 233 | ui.label(theme::label("Bets")); | 1131 | Frame::new() |
| 234 | ui.add_space(theme::SPACE_1); | 1132 | .fill(theme::SURFACE) |
| 235 | if demo.bets.chips.is_empty() { | 1133 | .corner_radius(theme::RADIUS_LG) |
| 1134 | .inner_margin(Margin::same(theme::SPACE_4 as i8)) | ||
| 1135 | .show(ui, |ui| { | ||
| 1136 | ui.label(theme::label("Remembered hosts")); | ||
| 1137 | ui.add_space(theme::SPACE_2); | ||
| 1138 | let hosts = app | ||
| 1139 | .db | ||
| 1140 | .as_ref() | ||
| 1141 | .map(database::get_all_hosts) | ||
| 1142 | .unwrap_or_default(); | ||
| 1143 | if hosts.is_empty() { | ||
| 1144 | widgets::note(ui, "No remembered hosts yet."); | ||
| 1145 | } | ||
| 1146 | for (host, fingerprint) in hosts { | ||
| 1147 | remembered_host(ui, app, &host, fingerprint.as_deref()); | ||
| 1148 | } | ||
| 1149 | }); | ||
| 1150 | }); | ||
| 1151 | }); | ||
| 1152 | }); | ||
| 1153 | } | ||
| 1154 | |||
| 1155 | fn remembered_host(ui: &mut Ui, app: &mut ZaganApp, host: &str, fingerprint: Option<&str>) { | ||
| 1156 | ui.horizontal(|ui| { | ||
| 236 | ui.label( | 1157 | ui.label( |
| 237 | RichText::new("No chips on the felt") | 1158 | RichText::new(host) |
| 238 | .font(theme::sans(12.0)) | 1159 | .font(theme::mono(13.0)) |
| 239 | .color(theme::TEXT_FAINT), | 1160 | .color(theme::TEXT), |
| 240 | ); | 1161 | ); |
| 241 | } else { | 1162 | if let Some(fingerprint) = fingerprint { |
| 242 | for chip in &demo.bets.chips { | 1163 | ui.label( |
| 243 | bet_row(ui, &roulette::target_name(&chip.target), chip.amount); | 1164 | RichText::new(short_fingerprint(fingerprint)) |
| 1165 | .font(theme::mono(10.0)) | ||
| 1166 | .color(theme::TEXT_FAINT), | ||
| 1167 | ); | ||
| 244 | } | 1168 | } |
| 245 | } | 1169 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { |
| 1170 | if widgets::ghost(ui, "Forget") { | ||
| 1171 | if let Some(db) = &app.db { | ||
| 1172 | if let Err(error) = database::forget_host(db, host) { | ||
| 1173 | app.connect.error = Some(format!("Failed to forget host: {error}")); | ||
| 1174 | } | ||
| 1175 | } | ||
| 1176 | } | ||
| 1177 | if widgets::ghost(ui, "Connect") { | ||
| 1178 | app.begin_connect(host); | ||
| 1179 | } | ||
| 1180 | }); | ||
| 1181 | }); | ||
| 1182 | } | ||
| 246 | 1183 | ||
| 247 | ui.add_space(theme::SPACE_3); | 1184 | fn short_fingerprint(fingerprint: &str) -> String { |
| 248 | ui.separator(); | 1185 | let head: String = fingerprint.chars().take(16).collect(); |
| 249 | ui.add_space(theme::SPACE_2); | 1186 | format!("{head}…") |
| 250 | bet_row(ui, "Total", demo.bets.total()); | 1187 | } |
| 251 | 1188 | ||
| 252 | ui.add_space(theme::SPACE_4); | 1189 | // --------------------------------------------------------------------------- |
| 253 | let actions = ui.available_width(); | 1190 | // Login |
| 254 | let clear_width = 108.0; | 1191 | // --------------------------------------------------------------------------- |
| 255 | ui.horizontal(|ui| { | ||
| 256 | ui.spacing_mut().item_spacing.x = theme::SPACE_2; | ||
| 257 | 1192 | ||
| 258 | let spin = Button::new( | 1193 | fn login_screen(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) { |
| 259 | RichText::new("SPIN") | 1194 | CentralPanel::default() |
| 260 | .font(theme::mono(theme::FONT_MONO)) | 1195 | .frame( |
| 261 | .color(theme::BG), | 1196 | Frame::new() |
| 1197 | .fill(theme::BG) | ||
| 1198 | .inner_margin(Margin::same(theme::SPACE_5 as i8)), | ||
| 262 | ) | 1199 | ) |
| 263 | .fill(if spinning { | 1200 | .show(ui, |ui| { |
| 264 | theme::WIN.gamma_multiply(0.45) | 1201 | ui.vertical_centered(|ui| { |
| 265 | } else { | 1202 | ui.add_space(100.0); |
| 266 | theme::WIN | 1203 | ui.allocate_ui_with_layout(vec2(420.0, 0.0), Layout::top_down(Align::Min), |ui| { |
| 267 | }) | 1204 | Frame::new() |
| 268 | .corner_radius(theme::RADIUS_MD) | 1205 | .fill(theme::SURFACE) |
| 269 | .min_size(vec2(actions - clear_width - theme::SPACE_2, 44.0)); | 1206 | .corner_radius(theme::RADIUS_LG) |
| 270 | let can_spin = !spinning && !demo.bets.is_empty(); | 1207 | .inner_margin(Margin::same(theme::SPACE_5 as i8)) |
| 271 | if ui.add_enabled(can_spin, spin).clicked() { | 1208 | .show(ui, |ui| { |
| 272 | demo.start_spin(ui.ctx()); | 1209 | ui.label( |
| 273 | } | 1210 | RichText::new("Sign in") |
| 1211 | .font(theme::sans(24.0)) | ||
| 1212 | .color(theme::TEXT), | ||
| 1213 | ); | ||
| 1214 | if let Some(session) = &app.session { | ||
| 1215 | widgets::mono_text( | ||
| 1216 | ui, | ||
| 1217 | &format!( | ||
| 1218 | "{} · {}", | ||
| 1219 | session.host, | ||
| 1220 | short_fingerprint( | ||
| 1221 | session.fingerprint.as_deref().unwrap_or("unknown") | ||
| 1222 | ) | ||
| 1223 | ), | ||
| 1224 | theme::TEXT_DIM, | ||
| 1225 | ); | ||
| 1226 | } | ||
| 1227 | ui.add_space(theme::SPACE_4); | ||
| 274 | 1228 | ||
| 275 | let clear = Button::new( | 1229 | ui.label(theme::label("Username")); |
| 276 | RichText::new("CLEAR") | 1230 | ui.add( |
| 277 | .font(theme::mono(12.0)) | 1231 | TextEdit::singleline(&mut app.login.user) |
| 278 | .color(theme::TEXT_DIM), | 1232 | .desired_width(f32::INFINITY), |
| 279 | ) | 1233 | ); |
| 280 | .fill(theme::SURFACE_RAISED) | 1234 | ui.add_space(theme::SPACE_3); |
| 281 | .stroke(Stroke::new(1.0, theme::LINE)) | 1235 | ui.label(theme::label("Password")); |
| 282 | .corner_radius(theme::RADIUS_MD) | 1236 | ui.add( |
| 283 | .min_size(vec2(clear_width, 44.0)); | 1237 | TextEdit::singleline(&mut app.login.password) |
| 284 | if ui | 1238 | .password(true) |
| 285 | .add_enabled(!demo.bets.is_empty() && !spinning, clear) | 1239 | .desired_width(f32::INFINITY), |
| 286 | .clicked() | 1240 | ); |
| 287 | { | ||
| 288 | demo.begin_placing(); | ||
| 289 | demo.bets = Bets::default(); | ||
| 290 | } | ||
| 291 | }); | ||
| 292 | } | ||
| 293 | 1241 | ||
| 294 | fn result_line(ui: &mut Ui, demo: &Demo) { | 1242 | ui.add_space(theme::SPACE_2); |
| 295 | ui.horizontal(|ui| match (demo.phase, demo.number) { | 1243 | ui.checkbox( |
| 296 | (RoulettePhase::Result, Some(number)) => { | 1244 | &mut app.login.remember, |
| 297 | ui.label(theme::label("Result")); | 1245 | RichText::new("Remember this login") |
| 298 | ui.label( | 1246 | .font(theme::sans(12.0)) |
| 299 | RichText::new(format!("{number} {}", wheel_color(number).to_uppercase())) | 1247 | .color(theme::TEXT_DIM), |
| 300 | .font(theme::mono(14.0)) | 1248 | ); |
| 301 | .color(color_of(number)), | 1249 | |
| 302 | ); | 1250 | let enter = ui.input(|input| input.key_pressed(Key::Enter)); |
| 303 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | 1251 | ui.add_space(theme::SPACE_4); |
| 304 | payout(ui, demo); | 1252 | if widgets::primary(ui, "LOG IN", true, ui.available_width(), 44.0) |
| 1253 | || enter | ||
| 1254 | { | ||
| 1255 | app.login(ui.ctx(), false); | ||
| 1256 | } | ||
| 1257 | ui.add_space(theme::SPACE_2); | ||
| 1258 | if widgets::secondary( | ||
| 1259 | ui, | ||
| 1260 | "CREATE ACCOUNT", | ||
| 1261 | true, | ||
| 1262 | ui.available_width(), | ||
| 1263 | 38.0, | ||
| 1264 | ) { | ||
| 1265 | app.login(ui.ctx(), true); | ||
| 1266 | } | ||
| 1267 | |||
| 1268 | if let Some(error) = | ||
| 1269 | lobby.login_error.as_ref().or(app.login.error.as_ref()) | ||
| 1270 | { | ||
| 1271 | ui.add_space(theme::SPACE_3); | ||
| 1272 | widgets::error(ui, error); | ||
| 1273 | } | ||
| 1274 | }); | ||
| 1275 | |||
| 1276 | ui.add_space(theme::SPACE_3); | ||
| 1277 | ui.allocate_ui_with_layout( | ||
| 1278 | vec2(420.0, 24.0), | ||
| 1279 | Layout::top_down(Align::Center), | ||
| 1280 | |ui| { | ||
| 1281 | if widgets::ghost(ui, "Disconnect") { | ||
| 1282 | app.disconnect(); | ||
| 1283 | } | ||
| 1284 | }, | ||
| 1285 | ); | ||
| 1286 | }); | ||
| 305 | }); | 1287 | }); |
| 306 | } | 1288 | }); |
| 307 | (RoulettePhase::Spinning, _) => { | 1289 | } |
| 308 | ui.label( | 1290 | |
| 309 | RichText::new("SPINNING…") | 1291 | // --------------------------------------------------------------------------- |
| 310 | .font(theme::mono(12.0)) | 1292 | // Lobby |
| 311 | .color(theme::GOLD), | 1293 | // --------------------------------------------------------------------------- |
| 312 | ); | 1294 | |
| 313 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | 1295 | fn lobby_screen(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) { |
| 1296 | CentralPanel::default() | ||
| 1297 | .frame( | ||
| 1298 | Frame::new() | ||
| 1299 | .fill(theme::BG) | ||
| 1300 | .inner_margin(Margin::same(theme::SPACE_5 as i8)), | ||
| 1301 | ) | ||
| 1302 | .show(ui, |ui| { | ||
| 1303 | ui.horizontal(|ui| { | ||
| 314 | ui.label( | 1304 | ui.label( |
| 315 | RichText::new(format!("{} chips", demo.bets.chips.len())) | 1305 | RichText::new("Lobby") |
| 316 | .font(theme::mono(10.0)) | 1306 | .font(theme::sans(24.0)) |
| 317 | .color(theme::TEXT_FAINT), | 1307 | .color(theme::TEXT), |
| 318 | ); | 1308 | ); |
| 319 | }); | ||
| 320 | } | ||
| 321 | _ => { | ||
| 322 | ui.label( | ||
| 323 | RichText::new("Place your bets") | ||
| 324 | .font(theme::sans(12.0)) | ||
| 325 | .color(theme::TEXT_DIM), | ||
| 326 | ); | ||
| 327 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 328 | ui.label( | 1309 | ui.label( |
| 329 | RichText::new(format!("{} chips", demo.bets.chips.len())) | 1310 | RichText::new(format!("{} games", lobby.games.len())) |
| 330 | .font(theme::mono(10.0)) | 1311 | .font(theme::sans(12.0)) |
| 331 | .color(theme::TEXT_FAINT), | 1312 | .color(theme::TEXT_DIM), |
| 332 | ); | 1313 | ); |
| 1314 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 1315 | if widgets::secondary(ui, "LOG OUT", true, 100.0, 34.0) { | ||
| 1316 | app.command(ui.ctx(), Command::Logout); | ||
| 1317 | } | ||
| 1318 | if widgets::secondary(ui, "HISTORY", true, 104.0, 34.0) { | ||
| 1319 | let effects = app.core.command(Command::History { limit: 20 }); | ||
| 1320 | app.apply(ui.ctx(), effects); | ||
| 1321 | } | ||
| 1322 | }); | ||
| 333 | }); | 1323 | }); |
| 334 | } | 1324 | |
| 335 | }); | 1325 | if let Some(session) = &app.session { |
| 1326 | if is_newer(&session.latest, VERSION) { | ||
| 1327 | ui.add_space(theme::SPACE_3); | ||
| 1328 | Frame::new() | ||
| 1329 | .fill(theme::GOLD.gamma_multiply(0.10)) | ||
| 1330 | .stroke(Stroke::new(1.0, theme::GOLD.gamma_multiply(0.5))) | ||
| 1331 | .corner_radius(theme::RADIUS_MD) | ||
| 1332 | .inner_margin(Margin::symmetric(14, 10)) | ||
| 1333 | .show(ui, |ui| { | ||
| 1334 | ui.horizontal(|ui| { | ||
| 1335 | ui.label( | ||
| 1336 | RichText::new(format!("Update available: v{}", session.latest)) | ||
| 1337 | .font(theme::mono(12.0)) | ||
| 1338 | .color(theme::GOLD), | ||
| 1339 | ); | ||
| 1340 | widgets::mono_text(ui, &session.url, theme::TEXT_DIM); | ||
| 1341 | }); | ||
| 1342 | }); | ||
| 1343 | } | ||
| 1344 | } | ||
| 1345 | |||
| 1346 | ui.add_space(theme::SPACE_4); | ||
| 1347 | games_grid(ui, app, lobby); | ||
| 1348 | |||
| 1349 | ui.add_space(theme::SPACE_4); | ||
| 1350 | ui.separator(); | ||
| 1351 | ui.add_space(theme::SPACE_2); | ||
| 1352 | ui.horizontal(|ui| { | ||
| 1353 | if let Some(session) = &app.session { | ||
| 1354 | widgets::note( | ||
| 1355 | ui, | ||
| 1356 | &format!( | ||
| 1357 | "Signed in to {} · server v{}", | ||
| 1358 | session.host, session.server_version | ||
| 1359 | ), | ||
| 1360 | ); | ||
| 1361 | } | ||
| 1362 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 1363 | if widgets::ghost(ui, "Forget saved login") { | ||
| 1364 | app.forget_login(); | ||
| 1365 | } | ||
| 1366 | }); | ||
| 1367 | }); | ||
| 1368 | }); | ||
| 336 | } | 1369 | } |
| 337 | 1370 | ||
| 338 | /// What the round paid, and the net for the spin. | 1371 | fn games_grid(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) { |
| 339 | fn payout(ui: &mut Ui, demo: &Demo) { | 1372 | const ORDER: [&str; 4] = ["slots", "roulette", "blackjack", "crash"]; |
| 340 | let winnings = demo.winnings.unwrap_or(0); | ||
| 341 | if winnings == 0 { | ||
| 342 | ui.label( | ||
| 343 | RichText::new("NO WIN") | ||
| 344 | .font(theme::mono(13.0)) | ||
| 345 | .color(theme::TEXT_FAINT), | ||
| 346 | ); | ||
| 347 | return; | ||
| 348 | } | ||
| 349 | 1373 | ||
| 350 | let profit = winnings as i64 - demo.spent as i64; | 1374 | for row in ORDER.chunks(2) { |
| 351 | ui.label( | 1375 | ui.columns(2, |columns| { |
| 352 | RichText::new(format!("({})", theme::signed_money(profit))) | 1376 | for (index, game) in row.iter().enumerate() { |
| 353 | .font(theme::sans(11.0)) | 1377 | let Some((key, name)) = lobby.games.iter().find(|(key, _)| key == game) else { |
| 354 | .color(if profit >= 0 { theme::WIN } else { theme::LOSS }), | 1378 | continue; |
| 355 | ); | 1379 | }; |
| 356 | ui.label( | 1380 | game_card(&mut columns[index], app, key, name); |
| 357 | RichText::new(theme::money(winnings as i64)) | 1381 | } |
| 358 | .font(theme::mono(15.0)) | 1382 | }); |
| 359 | .color(theme::WIN), | 1383 | ui.add_space(theme::SPACE_4); |
| 360 | ); | 1384 | } |
| 361 | ui.label(theme::label("Won")); | ||
| 362 | } | 1385 | } |
| 363 | 1386 | ||
| 364 | fn bet_row(ui: &mut Ui, name: &str, amount: u64) { | 1387 | fn game_card(ui: &mut Ui, app: &mut ZaganApp, key: &str, name: &str) { |
| 365 | ui.horizontal(|ui| { | 1388 | Frame::new() |
| 366 | ui.label( | 1389 | .fill(theme::SURFACE) |
| 367 | RichText::new(name) | 1390 | .corner_radius(theme::RADIUS_LG) |
| 368 | .font(theme::sans(13.0)) | 1391 | .inner_margin(Margin::same(theme::SPACE_4 as i8)) |
| 369 | .color(theme::TEXT_DIM), | 1392 | .show(ui, |ui| { |
| 370 | ); | 1393 | ui.set_width(ui.available_width()); |
| 371 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 372 | ui.label( | 1394 | ui.label( |
| 373 | RichText::new(theme::money(amount as i64)) | 1395 | RichText::new(name) |
| 374 | .font(theme::mono(13.0)) | 1396 | .font(theme::sans(22.0)) |
| 375 | .color(theme::GOLD), | 1397 | .color(theme::TEXT), |
| 376 | ); | 1398 | ); |
| 1399 | ui.add_space(2.0); | ||
| 1400 | widgets::note(ui, tagline(key)); | ||
| 1401 | ui.add_space(theme::SPACE_1); | ||
| 1402 | ui.label( | ||
| 1403 | RichText::new(meta(key)) | ||
| 1404 | .font(theme::mono(10.0)) | ||
| 1405 | .color(theme::TEXT_FAINT), | ||
| 1406 | ); | ||
| 1407 | ui.add_space(theme::SPACE_3); | ||
| 1408 | if widgets::primary(ui, "PLAY", true, 110.0, 36.0) { | ||
| 1409 | let effects = app.core.command(Command::Play { | ||
| 1410 | game: key.to_string(), | ||
| 1411 | bet: Some(DEFAULT_BET), | ||
| 1412 | }); | ||
| 1413 | app.apply(ui.ctx(), effects); | ||
| 1414 | } | ||
| 377 | }); | 1415 | }); |
| 378 | }); | ||
| 379 | } | 1416 | } |
| 380 | 1417 | ||
| 381 | fn chip_button(ui: &mut Ui, amount: u64, selected: bool, enabled: bool) -> egui::Response { | 1418 | fn tagline(game: &str) -> &'static str { |
| 382 | let sense = if enabled { | 1419 | match game { |
| 383 | Sense::click() | 1420 | "slots" => "Five reels, five paylines, one lever.", |
| 384 | } else { | 1421 | "roulette" => "The European wheel, thirty-seven pockets.", |
| 385 | Sense::hover() | 1422 | "blackjack" => "Six decks, dealer stands on seventeen. Pays 3:2.", |
| 386 | }; | 1423 | "crash" => "The multiplier rises. Cash out before it busts.", |
| 387 | let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), sense); | 1424 | _ => "", |
| 388 | let center = rect.center(); | ||
| 389 | let radius = 20.0; | ||
| 390 | let painter = ui.painter(); | ||
| 391 | if selected || response.hovered() { | ||
| 392 | painter.circle_stroke( | ||
| 393 | center, | ||
| 394 | radius + 3.0, | ||
| 395 | Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT), | ||
| 396 | ); | ||
| 397 | } | 1425 | } |
| 398 | if enabled { | ||
| 399 | roulette::paint_chip(painter, center, radius, amount); | ||
| 400 | } else { | ||
| 401 | roulette::paint_chip_preview(painter, center, radius, amount); | ||
| 402 | } | ||
| 403 | response | ||
| 404 | } | 1426 | } |
| 405 | 1427 | ||
| 406 | fn color_of(number: u8) -> Color32 { | 1428 | fn meta(game: &str) -> &'static str { |
| 407 | match wheel_color(number) { | 1429 | match game { |
| 408 | "red" => theme::ROULETTE_RED, | 1430 | "slots" => "MIN 10 KR · RTP 96%", |
| 409 | "green" => theme::WIN, | 1431 | "roulette" => "MIN 10 KR · SINGLE ZERO", |
| 410 | _ => theme::TEXT, | 1432 | "blackjack" => "MIN 10 KR · S17 · 3:2", |
| 1433 | "crash" => "MIN 10 KR · 1% EDGE", | ||
| 1434 | _ => "", | ||
| 411 | } | 1435 | } |
| 412 | } | 1436 | } |
| 413 | 1437 | ||
| 414 | // --------------------------------------------------------------------------- | 1438 | // --------------------------------------------------------------------------- |
| 415 | // Table | 1439 | // History |
| 416 | // --------------------------------------------------------------------------- | 1440 | // --------------------------------------------------------------------------- |
| 417 | 1441 | ||
| 418 | fn table(ui: &mut Ui, demo: &mut Demo) { | 1442 | fn history_screen(ui: &mut Ui, app: &mut ZaganApp, records: &[PlayRecord]) { |
| 419 | ui.horizontal(|ui| { | 1443 | CentralPanel::default() |
| 420 | ui.label( | 1444 | .frame( |
| 421 | RichText::new("Table 01") | 1445 | Frame::new() |
| 422 | .font(theme::sans(15.0)) | 1446 | .fill(theme::BG) |
| 423 | .color(theme::TEXT), | 1447 | .inner_margin(Margin::same(theme::SPACE_5 as i8)), |
| 424 | ); | 1448 | ) |
| 425 | ui.label( | 1449 | .show(ui, |ui| { |
| 426 | RichText::new("European · single zero") | 1450 | ui.horizontal(|ui| { |
| 427 | .font(theme::sans(12.0)) | 1451 | if widgets::ghost(ui, "Back to lobby") { |
| 428 | .color(theme::TEXT_DIM), | 1452 | app.command(ui.ctx(), Command::ShowLobby); |
| 429 | ); | 1453 | } |
| 430 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | 1454 | ui.label( |
| 431 | ui.label( | 1455 | RichText::new("Play history") |
| 432 | RichText::new("Limits 10 – 500 Kr") | 1456 | .font(theme::sans(24.0)) |
| 433 | .font(theme::mono(12.0)) | 1457 | .color(theme::TEXT), |
| 434 | .color(theme::TEXT_FAINT), | 1458 | ); |
| 435 | ); | 1459 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { |
| 436 | }); | 1460 | for limit in [50, 20, 10] { |
| 437 | }); | 1461 | if widgets::ghost(ui, &limit.to_string()) { |
| 438 | ui.add_space(theme::SPACE_4); | 1462 | let effects = app.core.command(Command::History { limit }); |
| 439 | 1463 | app.apply(ui.ctx(), effects); | |
| 440 | let view = demo.view(); | 1464 | } |
| 441 | let strip_height = 104.0; | 1465 | } |
| 442 | let available = ui.available_size(); | 1466 | ui.label(theme::label("Show")); |
| 443 | let size = roulette::size_for(vec2(available.x, (available.y - strip_height).max(220.0))); | 1467 | }); |
| 444 | let side_pad = ((available.x - size.x) * 0.5).max(0.0); | 1468 | }); |
| 445 | let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0); | 1469 | ui.add_space(theme::SPACE_4); |
| 446 | let chip = demo.chip; | 1470 | |
| 447 | let placing = demo.phase != RoulettePhase::Spinning; | 1471 | if records.is_empty() { |
| 448 | ui.add_space(top_pad); | 1472 | widgets::note(ui, "No plays recorded yet."); |
| 449 | ui.horizontal(|ui| { | 1473 | return; |
| 450 | ui.add_space(side_pad); | ||
| 451 | match roulette::felt(ui, size, &view) { | ||
| 452 | Some(FeltAction::Place(target)) if placing => { | ||
| 453 | demo.begin_placing(); | ||
| 454 | demo.bets.add(target, chip); | ||
| 455 | } | ||
| 456 | Some(FeltAction::Reduce(target)) if placing => { | ||
| 457 | demo.begin_placing(); | ||
| 458 | demo.bets.reduce(&target, chip); | ||
| 459 | } | 1474 | } |
| 460 | _ => {} | ||
| 461 | } | ||
| 462 | }); | ||
| 463 | 1475 | ||
| 464 | ui.add_space(theme::SPACE_4); | 1476 | Frame::new() |
| 465 | ui.horizontal(|ui| { | 1477 | .fill(theme::SURFACE) |
| 466 | ui.label(theme::label("Last")); | 1478 | .corner_radius(theme::RADIUS_LG) |
| 467 | if demo.last.is_empty() { | 1479 | .inner_margin(Margin::same(theme::SPACE_3 as i8)) |
| 468 | ui.label( | 1480 | .show(ui, |ui| { |
| 469 | RichText::new("No spins yet") | 1481 | ui.set_width(ui.available_width()); |
| 470 | .font(theme::sans(12.0)) | 1482 | ui.set_min_height(ui.available_height()); |
| 471 | .color(theme::TEXT_FAINT), | ||
| 472 | ); | ||
| 473 | } | ||
| 474 | for number in &demo.last { | ||
| 475 | let (dot, _) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::hover()); | ||
| 476 | roulette::paint_result_dot(ui.painter(), dot.center(), 11.0, *number); | ||
| 477 | ui.add_space(theme::SPACE_1); | ||
| 478 | } | ||
| 479 | }); | ||
| 480 | ui.add_space(theme::SPACE_2); | ||
| 481 | ui.label( | ||
| 482 | RichText::new( | ||
| 483 | "Click to place · right or middle click takes back · C clears · space spins", | ||
| 484 | ) | ||
| 485 | .font(theme::sans(12.0)) | ||
| 486 | .color(theme::TEXT_FAINT), | ||
| 487 | ); | ||
| 488 | } | ||
| 489 | 1483 | ||
| 490 | // --------------------------------------------------------------------------- | 1484 | let widths = history_columns(ui.available_width()); |
| 491 | // Demo state | 1485 | history_header(ui, &widths); |
| 492 | // --------------------------------------------------------------------------- | 1486 | ui.add_space(theme::SPACE_1); |
| 1487 | ui.separator(); | ||
| 493 | 1488 | ||
| 494 | struct Spin { | 1489 | egui::ScrollArea::vertical() |
| 495 | started: f64, | 1490 | .auto_shrink([false, false]) |
| 496 | from: f32, | 1491 | .show(ui, |ui| { |
| 497 | delta: f32, | 1492 | for (index, record) in records.iter().enumerate() { |
| 498 | target: u8, | 1493 | history_row(ui, &widths, record, index % 2 == 1); |
| 499 | } | 1494 | } |
| 500 | 1495 | }); | |
| 501 | struct Demo { | 1496 | }); |
| 502 | bets: Bets, | 1497 | }); |
| 503 | chip: u64, | 1498 | } |
| 504 | phase: RoulettePhase, | ||
| 505 | number: Option<u8>, | ||
| 506 | rotation: f32, | ||
| 507 | spin: Option<Spin>, | ||
| 508 | spins: usize, | ||
| 509 | last: Vec<u8>, | ||
| 510 | winnings: Option<u64>, | ||
| 511 | spent: u64, | ||
| 512 | balance: i64, | ||
| 513 | host: &'static str, | ||
| 514 | ping_ms: u32, | ||
| 515 | user: &'static str, | ||
| 516 | account: &'static str, | ||
| 517 | } | ||
| 518 | |||
| 519 | impl Demo { | ||
| 520 | fn new() -> Self { | ||
| 521 | let mut bets = Bets::default(); | ||
| 522 | bets.add(BetTarget::Straight { number: 17 }, 50); | ||
| 523 | bets.add( | ||
| 524 | BetTarget::Corner { | ||
| 525 | numbers: [17, 18, 20, 21], | ||
| 526 | }, | ||
| 527 | 25, | ||
| 528 | ); | ||
| 529 | bets.add(BetTarget::Outside { kind: Outside::Red }, 25); | ||
| 530 | 1499 | ||
| 531 | Self { | 1500 | /// Column widths for the play log, filling the available width. |
| 532 | bets, | 1501 | fn history_columns(width: f32) -> [f32; 6] { |
| 533 | chip: 25, | 1502 | let usable = (width - theme::SPACE_3 * 5.0).max(320.0); |
| 534 | phase: RoulettePhase::Placing, | 1503 | [ |
| 535 | number: None, | 1504 | usable * 0.18, |
| 536 | rotation: 0.0, | 1505 | usable * 0.12, |
| 537 | spin: None, | 1506 | usable * 0.10, |
| 538 | spins: 0, | 1507 | usable * 0.10, |
| 539 | last: Vec::new(), | 1508 | usable * 0.12, |
| 540 | winnings: None, | 1509 | usable * 0.38, |
| 541 | spent: 0, | 1510 | ] |
| 542 | balance: 1000, | 1511 | } |
| 543 | host: "127.0.0.1:11525", | ||
| 544 | ping_ms: 41, | ||
| 545 | user: "alice", | ||
| 546 | account: "#4161673306", | ||
| 547 | } | ||
| 548 | } | ||
| 549 | 1512 | ||
| 550 | fn view(&self) -> RouletteView { | 1513 | fn history_header(ui: &mut Ui, widths: &[f32; 6]) { |
| 551 | RouletteView { | 1514 | history_line( |
| 552 | bet_amount: 10, | 1515 | ui, |
| 553 | balance: Some(self.balance), | 1516 | widths, |
| 554 | chip: self.chip, | 1517 | [ |
| 555 | bets: self.bets.clone(), | 1518 | theme::label("When"), |
| 556 | cursor: Cursor::default(), | 1519 | theme::label("Game"), |
| 557 | phase: self.phase, | 1520 | theme::label("Bet"), |
| 558 | number: self.number, | 1521 | theme::label("Won"), |
| 559 | color: self.number.map(|number| wheel_color(number).to_string()), | 1522 | theme::label("Balance"), |
| 560 | winnings: self.winnings, | 1523 | theme::label("Result"), |
| 561 | error: None, | 1524 | ], |
| 562 | } | 1525 | false, |
| 563 | } | 1526 | ); |
| 1527 | } | ||
| 564 | 1528 | ||
| 565 | /// A bet change after a result starts the next round: the wheel stays | 1529 | fn history_row(ui: &mut Ui, widths: &[f32; 6], record: &PlayRecord, stripe: bool) { |
| 566 | /// where it stopped, but the payout and the winning highlight do not. | 1530 | history_line( |
| 567 | fn begin_placing(&mut self) { | 1531 | ui, |
| 568 | if self.phase == RoulettePhase::Result { | 1532 | widths, |
| 569 | self.phase = RoulettePhase::Placing; | 1533 | [ |
| 570 | self.winnings = None; | 1534 | RichText::new(record.short_time()) |
| 571 | } | 1535 | .font(theme::mono(11.0)) |
| 572 | } | 1536 | .color(theme::TEXT_DIM), |
| 1537 | RichText::new(record.game.to_uppercase()) | ||
| 1538 | .font(theme::mono(11.0)) | ||
| 1539 | .color(theme::TEXT), | ||
| 1540 | RichText::new(theme::money(record.bet)) | ||
| 1541 | .font(theme::mono(12.0)) | ||
| 1542 | .color(theme::TEXT_DIM), | ||
| 1543 | RichText::new(theme::money(record.winnings)) | ||
| 1544 | .font(theme::mono(12.0)) | ||
| 1545 | .color(if record.winnings > 0 { | ||
| 1546 | theme::WIN | ||
| 1547 | } else { | ||
| 1548 | theme::TEXT_FAINT | ||
| 1549 | }), | ||
| 1550 | RichText::new(theme::money(record.balance_after)) | ||
| 1551 | .font(theme::mono(12.0)) | ||
| 1552 | .color(theme::TEXT), | ||
| 1553 | RichText::new(record.summary()) | ||
| 1554 | .font(theme::sans(12.0)) | ||
| 1555 | .color(theme::TEXT_DIM), | ||
| 1556 | ], | ||
| 1557 | stripe, | ||
| 1558 | ); | ||
| 1559 | } | ||
| 573 | 1560 | ||
| 574 | fn tick(&mut self, ctx: &Context) { | 1561 | fn history_line(ui: &mut Ui, widths: &[f32; 6], cells: [RichText; 6], stripe: bool) { |
| 575 | let now = ctx.input(|input| input.time); | 1562 | let spacing = theme::SPACE_3; |
| 576 | let mut finished = None; | 1563 | let width = widths.iter().sum::<f32>() + spacing * 5.0; |
| 577 | if let Some(spin) = &self.spin { | 1564 | let (rect, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover()); |
| 578 | let t = (((now - spin.started) / SPIN_SECONDS) as f32).clamp(0.0, 1.0); | 1565 | if stripe { |
| 579 | let eased = 1.0 - (1.0 - t).powi(3); | 1566 | ui.painter().rect_filled( |
| 580 | self.rotation = spin.from + spin.delta * eased; | 1567 | rect, |
| 581 | if t >= 1.0 { | 1568 | theme::RADIUS_SM, |
| 582 | finished = Some(spin.target); | 1569 | theme::SURFACE_RAISED.gamma_multiply(0.6), |
| 583 | } | 1570 | ); |
| 584 | ctx.request_repaint(); | ||
| 585 | } | ||
| 586 | if let Some(number) = finished { | ||
| 587 | self.spin = None; | ||
| 588 | self.phase = RoulettePhase::Result; | ||
| 589 | self.number = Some(number); | ||
| 590 | self.winnings = Some(self.bets.winnings(number)); | ||
| 591 | self.last.insert(0, number); | ||
| 592 | self.last.truncate(12); | ||
| 593 | } | ||
| 594 | } | 1571 | } |
| 595 | 1572 | let mut child = ui.new_child( | |
| 596 | /// Pointer-first: the felt reads the mouse. The keyboard keeps the two | 1573 | eframe::egui::UiBuilder::new() |
| 597 | /// verbs that have no pointer equivalent in profile (spin and clear). | 1574 | .max_rect(rect.shrink2(vec2(6.0, 0.0))) |
| 598 | fn keys(&mut self, ctx: &Context) { | 1575 | .layout(Layout::left_to_right(Align::Center)), |
| 599 | if self.phase == RoulettePhase::Spinning { | 1576 | ); |
| 600 | return; | 1577 | child.spacing_mut().item_spacing.x = spacing; |
| 601 | } | 1578 | for (width, cell) in widths.iter().zip(cells) { |
| 602 | if ctx.memory(|memory| memory.focused().is_none()) { | 1579 | child.add_sized( |
| 603 | if ctx.input(|input| input.key_pressed(Key::Space)) { | 1580 | vec2(*width, 18.0), |
| 604 | self.start_spin(ctx); | 1581 | egui::Label::new(cell).selectable(false).truncate(), |
| 605 | } | 1582 | ); |
| 606 | if ctx.input(|input| input.key_pressed(Key::C)) { | ||
| 607 | self.begin_placing(); | ||
| 608 | self.bets = Bets::default(); | ||
| 609 | } | ||
| 610 | } | ||
| 611 | } | 1583 | } |
| 1584 | } | ||
| 612 | 1585 | ||
| 613 | fn start_spin(&mut self, ctx: &Context) { | 1586 | fn fatal(ui: &mut Ui, message: &str) { |
| 614 | if self.phase == RoulettePhase::Spinning || self.bets.is_empty() { | 1587 | CentralPanel::default() |
| 615 | return; | 1588 | .frame( |
| 616 | } | 1589 | Frame::new() |
| 617 | let target = DEMO_TARGETS[self.spins % DEMO_TARGETS.len()]; | 1590 | .fill(theme::BG) |
| 618 | self.spins += 1; | 1591 | .inner_margin(Margin::same(theme::SPACE_5 as i8)), |
| 619 | self.spent = self.bets.total(); | 1592 | ) |
| 620 | self.winnings = None; | 1593 | .show(ui, |ui| { |
| 621 | 1594 | ui.vertical_centered(|ui| { | |
| 622 | let segment = TAU / WHEEL.len() as f32; | 1595 | ui.add_space(120.0); |
| 623 | let index = WHEEL | 1596 | ui.label( |
| 624 | .iter() | 1597 | RichText::new("Cannot start") |
| 625 | .position(|number| *number == target) | 1598 | .font(theme::sans(24.0)) |
| 626 | .unwrap_or(0) as f32; | 1599 | .color(theme::LOSS), |
| 627 | let desired = -index * segment; | 1600 | ); |
| 628 | let delta = (desired - self.rotation).rem_euclid(TAU) + 3.0 * TAU; | 1601 | ui.add_space(theme::SPACE_2); |
| 629 | 1602 | widgets::error(ui, message); | |
| 630 | self.spin = Some(Spin { | 1603 | }); |
| 631 | started: ctx.input(|input| input.time), | ||
| 632 | from: self.rotation, | ||
| 633 | delta, | ||
| 634 | target, | ||
| 635 | }); | 1604 | }); |
| 636 | self.phase = RoulettePhase::Spinning; | ||
| 637 | self.number = None; | ||
| 638 | ctx.request_repaint(); | ||
| 639 | } | ||
| 640 | } | 1605 | } |
diff --git a/gui/src/blackjack.rs b/gui/src/blackjack.rs new file mode 100644 index 0000000..d42f000 --- /dev/null +++ b/gui/src/blackjack.rs | |||
| @@ -0,0 +1,428 @@ | |||
| 1 | //! Blackjack screen: cards painted from `BlackjackView`. | ||
| 2 | |||
| 3 | use eframe::egui::{ | ||
| 4 | Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, | ||
| 5 | pos2, vec2, | ||
| 6 | }; | ||
| 7 | use zagan_bet::blackjack::is_red_suit; | ||
| 8 | use zagan_bet::client::view::{BlackjackView, Command}; | ||
| 9 | |||
| 10 | use crate::{theme, widgets}; | ||
| 11 | |||
| 12 | const CARD: Vec2 = vec2(92.0, 128.0); | ||
| 13 | |||
| 14 | pub fn show(ui: &mut Ui, view: &BlackjackView) -> Option<Command> { | ||
| 15 | let mut command = None; | ||
| 16 | eframe::egui::CentralPanel::default() | ||
| 17 | .frame(eframe::egui::Frame::new().fill(theme::BG)) | ||
| 18 | .show(ui, |ui| { | ||
| 19 | let full = ui.max_rect(); | ||
| 20 | let sidebar_rect = eframe::egui::Rect::from_min_max( | ||
| 21 | eframe::egui::pos2(full.right() - 336.0, full.top()), | ||
| 22 | full.right_bottom(), | ||
| 23 | ); | ||
| 24 | let board_rect = eframe::egui::Rect::from_min_max( | ||
| 25 | full.min, | ||
| 26 | eframe::egui::pos2(sidebar_rect.left(), full.bottom()), | ||
| 27 | ); | ||
| 28 | ui.painter().rect_filled( | ||
| 29 | sidebar_rect, | ||
| 30 | eframe::egui::CornerRadius::ZERO, | ||
| 31 | theme::SURFACE, | ||
| 32 | ); | ||
| 33 | ui.scope_builder( | ||
| 34 | eframe::egui::UiBuilder::new() | ||
| 35 | .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0))) | ||
| 36 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 37 | |ui| board(ui, view), | ||
| 38 | ); | ||
| 39 | ui.scope_builder( | ||
| 40 | eframe::egui::UiBuilder::new() | ||
| 41 | .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0))) | ||
| 42 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 43 | |ui| { | ||
| 44 | command = sidebar(ui, view); | ||
| 45 | }, | ||
| 46 | ); | ||
| 47 | }); | ||
| 48 | command | ||
| 49 | } | ||
| 50 | |||
| 51 | fn live(view: &BlackjackView) -> bool { | ||
| 52 | view.status == "player" | ||
| 53 | } | ||
| 54 | |||
| 55 | fn sidebar(ui: &mut Ui, view: &BlackjackView) -> Option<Command> { | ||
| 56 | let mut command = None; | ||
| 57 | let betting = !live(view) && view.status != "dealing" && view.status != "forfeiting"; | ||
| 58 | let (content, actions) = widgets::sidebar_layout(ui, 200.0); | ||
| 59 | |||
| 60 | ui.scope_builder( | ||
| 61 | eframe::egui::UiBuilder::new() | ||
| 62 | .max_rect(content) | ||
| 63 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 64 | |ui| { | ||
| 65 | eframe::egui::ScrollArea::vertical() | ||
| 66 | .auto_shrink([false, false]) | ||
| 67 | .show(ui, |ui| { | ||
| 68 | ui.label( | ||
| 69 | eframe::egui::RichText::new("Blackjack") | ||
| 70 | .font(theme::sans(22.0)) | ||
| 71 | .color(theme::TEXT), | ||
| 72 | ); | ||
| 73 | ui.label( | ||
| 74 | eframe::egui::RichText::new("6 decks · dealer stands on 17 · 3:2") | ||
| 75 | .font(theme::sans(12.0)) | ||
| 76 | .color(theme::TEXT_DIM), | ||
| 77 | ); | ||
| 78 | |||
| 79 | ui.add_space(theme::SPACE_5); | ||
| 80 | ui.label(theme::label("Bet")); | ||
| 81 | ui.add_space(theme::SPACE_2); | ||
| 82 | if let Some(delta) = widgets::bet_control(ui, view.bet, betting) { | ||
| 83 | command = Some(Command::AdjustBet(delta)); | ||
| 84 | } | ||
| 85 | |||
| 86 | ui.add_space(theme::SPACE_4); | ||
| 87 | match view.status.as_str() { | ||
| 88 | "player" => widgets::note(ui, "Hit, stand or double."), | ||
| 89 | "done" => { | ||
| 90 | if let Some(hand) = &view.hand { | ||
| 91 | let (text, color) = result_text(&hand.result, hand.winnings); | ||
| 92 | ui.label( | ||
| 93 | eframe::egui::RichText::new(text) | ||
| 94 | .font(theme::mono(15.0)) | ||
| 95 | .color(color), | ||
| 96 | ); | ||
| 97 | } | ||
| 98 | } | ||
| 99 | "dealing" => widgets::note(ui, "Dealing…"), | ||
| 100 | _ => widgets::note(ui, "Press DEAL to start a hand."), | ||
| 101 | } | ||
| 102 | |||
| 103 | if let Some(error) = &view.error { | ||
| 104 | ui.add_space(theme::SPACE_3); | ||
| 105 | widgets::error(ui, error); | ||
| 106 | } | ||
| 107 | |||
| 108 | ui.add_space(theme::SPACE_4); | ||
| 109 | ui.separator(); | ||
| 110 | ui.add_space(theme::SPACE_2); | ||
| 111 | widgets::note(ui, "Blackjack pays 3:2. The dealer stands on soft 17."); | ||
| 112 | widgets::note(ui, "Double only on the first two cards."); | ||
| 113 | }); | ||
| 114 | }, | ||
| 115 | ); | ||
| 116 | |||
| 117 | ui.scope_builder( | ||
| 118 | eframe::egui::UiBuilder::new() | ||
| 119 | .max_rect(actions) | ||
| 120 | .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)), | ||
| 121 | |ui| { | ||
| 122 | if live(view) { | ||
| 123 | if widgets::ghost(ui, "Forfeit hand") { | ||
| 124 | command = Some(Command::Leave); | ||
| 125 | } | ||
| 126 | } else if widgets::ghost(ui, "Leave table") { | ||
| 127 | command = Some(Command::Leave); | ||
| 128 | } | ||
| 129 | |||
| 130 | match view.status.as_str() { | ||
| 131 | "player" => { | ||
| 132 | if view | ||
| 133 | .hand | ||
| 134 | .as_ref() | ||
| 135 | .is_some_and(|hand| hand.actions.iter().any(|action| action == "double")) | ||
| 136 | { | ||
| 137 | ui.add_space(theme::SPACE_2); | ||
| 138 | if widgets::secondary(ui, "DOUBLE", true, ui.available_width(), 38.0) { | ||
| 139 | command = Some(Command::Double); | ||
| 140 | } | ||
| 141 | } | ||
| 142 | ui.add_space(theme::SPACE_2); | ||
| 143 | if widgets::secondary(ui, "STAND", true, ui.available_width(), 38.0) { | ||
| 144 | command = Some(Command::Stand); | ||
| 145 | } | ||
| 146 | ui.add_space(theme::SPACE_2); | ||
| 147 | if widgets::primary(ui, "HIT", true, ui.available_width(), 44.0) { | ||
| 148 | command = Some(Command::Hit); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | "dealing" | "forfeiting" => { | ||
| 152 | ui.label( | ||
| 153 | eframe::egui::RichText::new(if view.status == "dealing" { | ||
| 154 | "DEALING…" | ||
| 155 | } else { | ||
| 156 | "FORFEITING…" | ||
| 157 | }) | ||
| 158 | .font(theme::mono(12.0)) | ||
| 159 | .color(theme::GOLD), | ||
| 160 | ); | ||
| 161 | } | ||
| 162 | _ => { | ||
| 163 | if widgets::primary(ui, "DEAL", true, ui.available_width(), 46.0) { | ||
| 164 | command = Some(Command::Deal); | ||
| 165 | } | ||
| 166 | } | ||
| 167 | } | ||
| 168 | }, | ||
| 169 | ); | ||
| 170 | |||
| 171 | command | ||
| 172 | } | ||
| 173 | |||
| 174 | fn result_text(result: &str, winnings: u64) -> (String, Color32) { | ||
| 175 | match result { | ||
| 176 | "win" => ( | ||
| 177 | format!("YOU WIN {}", theme::money(winnings as i64)), | ||
| 178 | theme::WIN, | ||
| 179 | ), | ||
| 180 | "blackjack" => ( | ||
| 181 | format!("BLACKJACK {}", theme::money(winnings as i64)), | ||
| 182 | theme::GOLD, | ||
| 183 | ), | ||
| 184 | "push" => ("PUSH".to_string(), theme::TEXT), | ||
| 185 | "bust" => ("BUST".to_string(), theme::LOSS), | ||
| 186 | "forfeit" => ("FORFEITED".to_string(), theme::TEXT_DIM), | ||
| 187 | _ => ("DEALER WINS".to_string(), theme::LOSS), | ||
| 188 | } | ||
| 189 | } | ||
| 190 | |||
| 191 | fn board(ui: &mut Ui, view: &BlackjackView) { | ||
| 192 | ui.horizontal(|ui| { | ||
| 193 | ui.label( | ||
| 194 | eframe::egui::RichText::new("Table 01") | ||
| 195 | .font(theme::sans(15.0)) | ||
| 196 | .color(theme::TEXT), | ||
| 197 | ); | ||
| 198 | ui.label( | ||
| 199 | eframe::egui::RichText::new("Blackjack · six decks") | ||
| 200 | .font(theme::sans(12.0)) | ||
| 201 | .color(theme::TEXT_DIM), | ||
| 202 | ); | ||
| 203 | ui.with_layout( | ||
| 204 | eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), | ||
| 205 | |ui| { | ||
| 206 | ui.label( | ||
| 207 | eframe::egui::RichText::new("3:2 · S17") | ||
| 208 | .font(theme::mono(12.0)) | ||
| 209 | .color(theme::TEXT_FAINT), | ||
| 210 | ); | ||
| 211 | }, | ||
| 212 | ); | ||
| 213 | }); | ||
| 214 | ui.add_space(theme::SPACE_5); | ||
| 215 | |||
| 216 | let Some(hand) = &view.hand else { | ||
| 217 | centered_note(ui, "Place a bet and deal a hand."); | ||
| 218 | return; | ||
| 219 | }; | ||
| 220 | |||
| 221 | let width = ui.available_width(); | ||
| 222 | let hidden = hand.dealer.iter().any(|card| card == "?"); | ||
| 223 | hand_header(ui, "Dealer", hand.dealer_total, hidden); | ||
| 224 | paint_hand(ui, &hand.dealer, width); | ||
| 225 | ui.add_space(theme::SPACE_5); | ||
| 226 | |||
| 227 | let (line, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover()); | ||
| 228 | ui.painter() | ||
| 229 | .rect_filled(line, CornerRadius::ZERO, theme::LINE); | ||
| 230 | ui.add_space(theme::SPACE_5); | ||
| 231 | |||
| 232 | hand_header(ui, "You", hand.player_total, false); | ||
| 233 | paint_hand(ui, &hand.player, width); | ||
| 234 | |||
| 235 | ui.add_space(theme::SPACE_4); | ||
| 236 | ui.label( | ||
| 237 | eframe::egui::RichText::new(match view.status.as_str() { | ||
| 238 | "player" => "Your move.", | ||
| 239 | "dealing" => "Dealing…", | ||
| 240 | "done" => "Hand complete.", | ||
| 241 | _ => "Place your bet.", | ||
| 242 | }) | ||
| 243 | .font(theme::sans(13.0)) | ||
| 244 | .color(theme::TEXT_DIM), | ||
| 245 | ); | ||
| 246 | } | ||
| 247 | |||
| 248 | fn hand_header(ui: &mut Ui, label: &str, total: u8, hidden: bool) { | ||
| 249 | ui.horizontal(|ui| { | ||
| 250 | ui.label(theme::label(label)); | ||
| 251 | ui.label( | ||
| 252 | eframe::egui::RichText::new(if hidden { | ||
| 253 | format!("{total} + ?") | ||
| 254 | } else { | ||
| 255 | total.to_string() | ||
| 256 | }) | ||
| 257 | .font(theme::mono(15.0)) | ||
| 258 | .color(if label == "You" { | ||
| 259 | theme::TEXT | ||
| 260 | } else { | ||
| 261 | theme::TEXT_DIM | ||
| 262 | }), | ||
| 263 | ); | ||
| 264 | }); | ||
| 265 | ui.add_space(theme::SPACE_2); | ||
| 266 | } | ||
| 267 | |||
| 268 | fn paint_hand(ui: &mut Ui, cards: &[String], width: f32) { | ||
| 269 | if cards.is_empty() { | ||
| 270 | ui.add_space(CARD.y); | ||
| 271 | return; | ||
| 272 | } | ||
| 273 | let step = CARD.x * 0.74; | ||
| 274 | let hand_width = CARD.x + step * (cards.len() as f32 - 1.0); | ||
| 275 | let (rect, _) = ui.allocate_exact_size(vec2(width.max(hand_width), CARD.y), Sense::hover()); | ||
| 276 | let left = rect.left() + ((rect.width() - hand_width) * 0.5).max(0.0); | ||
| 277 | let painter = ui.painter(); | ||
| 278 | for (index, card) in cards.iter().enumerate() { | ||
| 279 | let rect = Rect::from_min_size(pos2(left + step * index as f32, rect.top()), CARD); | ||
| 280 | if card == "?" { | ||
| 281 | paint_back(painter, rect); | ||
| 282 | } else { | ||
| 283 | paint_card(painter, rect, card); | ||
| 284 | } | ||
| 285 | } | ||
| 286 | } | ||
| 287 | |||
| 288 | fn paint_card(painter: &Painter, rect: Rect, card: &str) { | ||
| 289 | painter.rect_filled(rect, theme::RADIUS_MD, Color32::from_rgb(0xe8, 0xe4, 0xd8)); | ||
| 290 | painter.rect_stroke( | ||
| 291 | rect, | ||
| 292 | theme::RADIUS_MD, | ||
| 293 | Stroke::new(1.0, Color32::from_rgb(0x9a, 0x94, 0x86)), | ||
| 294 | StrokeKind::Inside, | ||
| 295 | ); | ||
| 296 | let color = if is_red_suit(card) { | ||
| 297 | Color32::from_rgb(0xb0, 0x31, 0x3a) | ||
| 298 | } else { | ||
| 299 | Color32::from_rgb(0x15, 0x1b, 0x21) | ||
| 300 | }; | ||
| 301 | let suit = card.chars().last().unwrap_or('?'); | ||
| 302 | let rank = &card[..card.len().saturating_sub(suit.len_utf8())]; | ||
| 303 | painter.text( | ||
| 304 | rect.left_top() + vec2(9.0, 6.0), | ||
| 305 | Align2::LEFT_TOP, | ||
| 306 | rank, | ||
| 307 | theme::mono(20.0), | ||
| 308 | color, | ||
| 309 | ); | ||
| 310 | painter.text( | ||
| 311 | rect.right_bottom() - vec2(9.0, 6.0), | ||
| 312 | Align2::RIGHT_BOTTOM, | ||
| 313 | rank, | ||
| 314 | theme::mono(20.0), | ||
| 315 | color, | ||
| 316 | ); | ||
| 317 | paint_suit(painter, rect.center(), rect.width() * 0.55, suit, color); | ||
| 318 | } | ||
| 319 | |||
| 320 | /// Card suits are drawn, not typed: neither embedded font carries the glyphs. | ||
| 321 | /// | ||
| 322 | /// The circles must overlap the stem, or the shape reads as three dots above a | ||
| 323 | /// detached bar. | ||
| 324 | fn paint_suit(painter: &Painter, center: Pos2, size: f32, suit: char, color: Color32) { | ||
| 325 | let s = size; | ||
| 326 | match suit { | ||
| 327 | 'H' => { | ||
| 328 | painter.circle_filled(center + vec2(-s * 0.22, -s * 0.12), s * 0.31, color); | ||
| 329 | painter.circle_filled(center + vec2(s * 0.22, -s * 0.12), s * 0.31, color); | ||
| 330 | painter.add(Shape::convex_polygon( | ||
| 331 | vec![ | ||
| 332 | center + vec2(-s * 0.50, 0.0), | ||
| 333 | center + vec2(s * 0.50, 0.0), | ||
| 334 | center + vec2(0.0, s * 0.56), | ||
| 335 | ], | ||
| 336 | color, | ||
| 337 | Stroke::NONE, | ||
| 338 | )); | ||
| 339 | } | ||
| 340 | 'D' => { | ||
| 341 | painter.add(Shape::convex_polygon( | ||
| 342 | vec![ | ||
| 343 | center + vec2(0.0, -s * 0.56), | ||
| 344 | center + vec2(s * 0.42, 0.0), | ||
| 345 | center + vec2(0.0, s * 0.56), | ||
| 346 | center + vec2(-s * 0.42, 0.0), | ||
| 347 | ], | ||
| 348 | color, | ||
| 349 | Stroke::NONE, | ||
| 350 | )); | ||
| 351 | } | ||
| 352 | 'S' => { | ||
| 353 | painter.circle_filled(center + vec2(-s * 0.24, s * 0.10), s * 0.32, color); | ||
| 354 | painter.circle_filled(center + vec2(s * 0.24, s * 0.10), s * 0.32, color); | ||
| 355 | painter.add(Shape::convex_polygon( | ||
| 356 | vec![ | ||
| 357 | center + vec2(-s * 0.50, s * 0.02), | ||
| 358 | center + vec2(s * 0.50, s * 0.02), | ||
| 359 | center + vec2(0.0, -s * 0.56), | ||
| 360 | ], | ||
| 361 | color, | ||
| 362 | Stroke::NONE, | ||
| 363 | )); | ||
| 364 | painter.add(Shape::convex_polygon( | ||
| 365 | vec![ | ||
| 366 | center + vec2(-s * 0.11, s * 0.08), | ||
| 367 | center + vec2(s * 0.11, s * 0.08), | ||
| 368 | center + vec2(s * 0.05, s * 0.52), | ||
| 369 | center + vec2(-s * 0.05, s * 0.52), | ||
| 370 | ], | ||
| 371 | color, | ||
| 372 | Stroke::NONE, | ||
| 373 | )); | ||
| 374 | } | ||
| 375 | 'C' => { | ||
| 376 | painter.circle_filled(center + vec2(0.0, -s * 0.26), s * 0.33, color); | ||
| 377 | painter.circle_filled(center + vec2(-s * 0.30, s * 0.10), s * 0.33, color); | ||
| 378 | painter.circle_filled(center + vec2(s * 0.30, s * 0.10), s * 0.33, color); | ||
| 379 | painter.add(Shape::convex_polygon( | ||
| 380 | vec![ | ||
| 381 | center + vec2(-s * 0.13, s * 0.08), | ||
| 382 | center + vec2(s * 0.13, s * 0.08), | ||
| 383 | center + vec2(s * 0.05, s * 0.52), | ||
| 384 | center + vec2(-s * 0.05, s * 0.52), | ||
| 385 | ], | ||
| 386 | color, | ||
| 387 | Stroke::NONE, | ||
| 388 | )); | ||
| 389 | } | ||
| 390 | _ => {} | ||
| 391 | } | ||
| 392 | } | ||
| 393 | |||
| 394 | fn paint_back(painter: &Painter, rect: Rect) { | ||
| 395 | painter.rect_filled(rect, theme::RADIUS_MD, Color32::from_rgb(0x1b, 0x3a, 0x2c)); | ||
| 396 | painter.rect_stroke( | ||
| 397 | rect, | ||
| 398 | theme::RADIUS_MD, | ||
| 399 | Stroke::new(1.0, Color32::from_rgb(0x2b, 0x5a, 0x43)), | ||
| 400 | StrokeKind::Inside, | ||
| 401 | ); | ||
| 402 | let inner = rect.shrink(8.0); | ||
| 403 | painter.rect_stroke( | ||
| 404 | inner, | ||
| 405 | theme::RADIUS_SM, | ||
| 406 | Stroke::new(1.0, Color32::from_rgb(0x2b, 0x5a, 0x43)), | ||
| 407 | StrokeKind::Inside, | ||
| 408 | ); | ||
| 409 | painter.text( | ||
| 410 | rect.center(), | ||
| 411 | Align2::CENTER_CENTER, | ||
| 412 | "Z", | ||
| 413 | theme::mono(30.0), | ||
| 414 | Color32::from_rgb(0x4e, 0x8a, 0x68), | ||
| 415 | ); | ||
| 416 | } | ||
| 417 | |||
| 418 | fn centered_note(ui: &mut Ui, text: &str) { | ||
| 419 | let available = ui.available_size(); | ||
| 420 | ui.add_space(available.y * 0.35); | ||
| 421 | ui.vertical_centered(|ui| { | ||
| 422 | ui.label( | ||
| 423 | eframe::egui::RichText::new(text) | ||
| 424 | .font(theme::sans(15.0)) | ||
| 425 | .color(theme::TEXT_DIM), | ||
| 426 | ); | ||
| 427 | }); | ||
| 428 | } | ||
diff --git a/gui/src/crash.rs b/gui/src/crash.rs new file mode 100644 index 0000000..14197ae --- /dev/null +++ b/gui/src/crash.rs | |||
| @@ -0,0 +1,416 @@ | |||
| 1 | //! Crash screen: a live multiplier painted from `CrashView`. | ||
| 2 | |||
| 3 | use eframe::egui::{Align2, Color32, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; | ||
| 4 | use zagan_bet::client::view::{Command, CrashView}; | ||
| 5 | |||
| 6 | use crate::{theme, widgets}; | ||
| 7 | |||
| 8 | /// One sample of the running curve. | ||
| 9 | struct Sample { | ||
| 10 | seconds: f32, | ||
| 11 | multiplier: f64, | ||
| 12 | } | ||
| 13 | |||
| 14 | #[derive(Default)] | ||
| 15 | pub struct Fx { | ||
| 16 | points: Vec<Sample>, | ||
| 17 | started: Option<f64>, | ||
| 18 | status: String, | ||
| 19 | peak: f64, | ||
| 20 | last_crash: Option<f64>, | ||
| 21 | } | ||
| 22 | |||
| 23 | impl Fx { | ||
| 24 | pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &CrashView) { | ||
| 25 | let now = ctx.input(|input| input.time); | ||
| 26 | |||
| 27 | if view.status == "starting" && self.status != "starting" { | ||
| 28 | self.points.clear(); | ||
| 29 | self.started = None; | ||
| 30 | self.peak = 1.0; | ||
| 31 | } | ||
| 32 | |||
| 33 | if view.status == "running" { | ||
| 34 | let started = *self.started.get_or_insert(now); | ||
| 35 | let seconds = (now - started) as f32; | ||
| 36 | let last = self.points.last().map(|sample| sample.seconds); | ||
| 37 | if last.is_none_or(|last| seconds - last >= 0.016) { | ||
| 38 | self.points.push(Sample { | ||
| 39 | seconds, | ||
| 40 | multiplier: view.multiplier, | ||
| 41 | }); | ||
| 42 | self.peak = self.peak.max(view.multiplier); | ||
| 43 | } | ||
| 44 | ctx.request_repaint_after(std::time::Duration::from_millis(16)); | ||
| 45 | } else if view.status == "starting" { | ||
| 46 | ctx.request_repaint_after(std::time::Duration::from_millis(50)); | ||
| 47 | } | ||
| 48 | |||
| 49 | if view.status == "crashed" { | ||
| 50 | if let Some(point) = view.crash_point { | ||
| 51 | self.last_crash = Some(point); | ||
| 52 | } | ||
| 53 | } | ||
| 54 | if view.status != self.status { | ||
| 55 | self.status = view.status.clone(); | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | fn reset(&mut self) { | ||
| 60 | self.points.clear(); | ||
| 61 | self.started = None; | ||
| 62 | self.peak = 1.0; | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | pub fn show(ui: &mut Ui, view: &CrashView, fx: &mut Fx) -> Option<Command> { | ||
| 67 | let mut command = None; | ||
| 68 | eframe::egui::CentralPanel::default() | ||
| 69 | .frame(eframe::egui::Frame::new().fill(theme::BG)) | ||
| 70 | .show(ui, |ui| { | ||
| 71 | let full = ui.max_rect(); | ||
| 72 | let sidebar_rect = eframe::egui::Rect::from_min_max( | ||
| 73 | eframe::egui::pos2(full.right() - 336.0, full.top()), | ||
| 74 | full.right_bottom(), | ||
| 75 | ); | ||
| 76 | let board_rect = eframe::egui::Rect::from_min_max( | ||
| 77 | full.min, | ||
| 78 | eframe::egui::pos2(sidebar_rect.left(), full.bottom()), | ||
| 79 | ); | ||
| 80 | ui.painter().rect_filled( | ||
| 81 | sidebar_rect, | ||
| 82 | eframe::egui::CornerRadius::ZERO, | ||
| 83 | theme::SURFACE, | ||
| 84 | ); | ||
| 85 | ui.scope_builder( | ||
| 86 | eframe::egui::UiBuilder::new() | ||
| 87 | .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0))) | ||
| 88 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 89 | |ui| chart(ui, view, fx), | ||
| 90 | ); | ||
| 91 | ui.scope_builder( | ||
| 92 | eframe::egui::UiBuilder::new() | ||
| 93 | .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0))) | ||
| 94 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 95 | |ui| { | ||
| 96 | command = sidebar(ui, view, fx); | ||
| 97 | }, | ||
| 98 | ); | ||
| 99 | }); | ||
| 100 | command | ||
| 101 | } | ||
| 102 | |||
| 103 | fn sidebar(ui: &mut Ui, view: &CrashView, fx: &mut Fx) -> Option<Command> { | ||
| 104 | let mut command = None; | ||
| 105 | let running = view.status == "running"; | ||
| 106 | let starting = view.status == "starting"; | ||
| 107 | let (content, actions) = widgets::sidebar_layout(ui, 130.0); | ||
| 108 | |||
| 109 | ui.scope_builder( | ||
| 110 | eframe::egui::UiBuilder::new() | ||
| 111 | .max_rect(content) | ||
| 112 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 113 | |ui| { | ||
| 114 | eframe::egui::ScrollArea::vertical() | ||
| 115 | .auto_shrink([false, false]) | ||
| 116 | .show(ui, |ui| { | ||
| 117 | ui.label( | ||
| 118 | eframe::egui::RichText::new("Crash") | ||
| 119 | .font(theme::sans(22.0)) | ||
| 120 | .color(theme::TEXT), | ||
| 121 | ); | ||
| 122 | ui.label( | ||
| 123 | eframe::egui::RichText::new("Rise · cash out · don't bust") | ||
| 124 | .font(theme::sans(12.0)) | ||
| 125 | .color(theme::TEXT_DIM), | ||
| 126 | ); | ||
| 127 | |||
| 128 | ui.add_space(theme::SPACE_5); | ||
| 129 | ui.label(theme::label("Bet")); | ||
| 130 | ui.add_space(theme::SPACE_2); | ||
| 131 | if let Some(delta) = widgets::bet_control(ui, view.bet, !running && !starting) { | ||
| 132 | command = Some(Command::AdjustBet(delta)); | ||
| 133 | } | ||
| 134 | |||
| 135 | ui.add_space(theme::SPACE_4); | ||
| 136 | widgets::row( | ||
| 137 | ui, | ||
| 138 | "Potential", | ||
| 139 | &theme::money(view.potential as i64), | ||
| 140 | theme::GOLD, | ||
| 141 | ); | ||
| 142 | |||
| 143 | ui.add_space(theme::SPACE_3); | ||
| 144 | status(ui, view); | ||
| 145 | |||
| 146 | if let Some(point) = fx.last_crash { | ||
| 147 | ui.add_space(theme::SPACE_2); | ||
| 148 | widgets::row(ui, "Last crash", &format!("{point:.2}x"), theme::TEXT_DIM); | ||
| 149 | } | ||
| 150 | |||
| 151 | if let Some(error) = &view.error { | ||
| 152 | ui.add_space(theme::SPACE_3); | ||
| 153 | widgets::error(ui, error); | ||
| 154 | } | ||
| 155 | |||
| 156 | ui.add_space(theme::SPACE_4); | ||
| 157 | ui.separator(); | ||
| 158 | ui.add_space(theme::SPACE_2); | ||
| 159 | widgets::note( | ||
| 160 | ui, | ||
| 161 | "The crash point is server-timed. The curve here is display only.", | ||
| 162 | ); | ||
| 163 | }); | ||
| 164 | }, | ||
| 165 | ); | ||
| 166 | |||
| 167 | ui.scope_builder( | ||
| 168 | eframe::egui::UiBuilder::new() | ||
| 169 | .max_rect(actions) | ||
| 170 | .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)), | ||
| 171 | |ui| { | ||
| 172 | if widgets::ghost( | ||
| 173 | ui, | ||
| 174 | if running { | ||
| 175 | "Forfeit round" | ||
| 176 | } else { | ||
| 177 | "Leave table" | ||
| 178 | }, | ||
| 179 | ) { | ||
| 180 | command = Some(Command::Leave); | ||
| 181 | } | ||
| 182 | ui.add_space(theme::SPACE_2); | ||
| 183 | if running { | ||
| 184 | if widgets::primary(ui, "CASH OUT", true, ui.available_width(), 46.0) { | ||
| 185 | command = Some(Command::CashOut); | ||
| 186 | } | ||
| 187 | } else if !starting && widgets::primary(ui, "START", true, ui.available_width(), 46.0) { | ||
| 188 | fx.reset(); | ||
| 189 | command = Some(Command::Start); | ||
| 190 | } | ||
| 191 | }, | ||
| 192 | ); | ||
| 193 | |||
| 194 | command | ||
| 195 | } | ||
| 196 | |||
| 197 | fn status(ui: &mut Ui, view: &CrashView) { | ||
| 198 | match view.status.as_str() { | ||
| 199 | "starting" => widgets::note(ui, "Round starting…"), | ||
| 200 | "running" => widgets::note(ui, "Rising. Cash out before it crashes."), | ||
| 201 | "cashed" => { | ||
| 202 | ui.label( | ||
| 203 | eframe::egui::RichText::new(format!( | ||
| 204 | "CASHED OUT {:.2}x +{}", | ||
| 205 | view.multiplier, | ||
| 206 | theme::money(view.payout as i64) | ||
| 207 | )) | ||
| 208 | .font(theme::mono(14.0)) | ||
| 209 | .color(theme::WIN), | ||
| 210 | ); | ||
| 211 | } | ||
| 212 | "crashed" => { | ||
| 213 | ui.label( | ||
| 214 | eframe::egui::RichText::new(format!( | ||
| 215 | "CRASHED at {:.2}x", | ||
| 216 | view.crash_point.unwrap_or(view.multiplier) | ||
| 217 | )) | ||
| 218 | .font(theme::mono(14.0)) | ||
| 219 | .color(theme::LOSS), | ||
| 220 | ); | ||
| 221 | } | ||
| 222 | "forfeit" => { | ||
| 223 | ui.label( | ||
| 224 | eframe::egui::RichText::new("FORFEITED") | ||
| 225 | .font(theme::mono(14.0)) | ||
| 226 | .color(theme::TEXT_DIM), | ||
| 227 | ); | ||
| 228 | } | ||
| 229 | _ => widgets::note(ui, "Press START to play."), | ||
| 230 | } | ||
| 231 | } | ||
| 232 | |||
| 233 | fn chart(ui: &mut Ui, view: &CrashView, fx: &Fx) { | ||
| 234 | ui.horizontal(|ui| { | ||
| 235 | ui.label( | ||
| 236 | eframe::egui::RichText::new("Table 01") | ||
| 237 | .font(theme::sans(15.0)) | ||
| 238 | .color(theme::TEXT), | ||
| 239 | ); | ||
| 240 | ui.label( | ||
| 241 | eframe::egui::RichText::new("Crash · server-timed") | ||
| 242 | .font(theme::sans(12.0)) | ||
| 243 | .color(theme::TEXT_DIM), | ||
| 244 | ); | ||
| 245 | ui.with_layout( | ||
| 246 | eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), | ||
| 247 | |ui| { | ||
| 248 | ui.label( | ||
| 249 | eframe::egui::RichText::new("Cash out any time before the crash") | ||
| 250 | .font(theme::mono(11.0)) | ||
| 251 | .color(theme::TEXT_FAINT), | ||
| 252 | ); | ||
| 253 | }, | ||
| 254 | ); | ||
| 255 | }); | ||
| 256 | ui.add_space(theme::SPACE_4); | ||
| 257 | |||
| 258 | let available = ui.available_size(); | ||
| 259 | let size = vec2(available.x, (available.y - 70.0).max(260.0)); | ||
| 260 | let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); | ||
| 261 | let painter = ui.painter(); | ||
| 262 | |||
| 263 | painter.rect_filled(rect, theme::RADIUS_LG, theme::SURFACE); | ||
| 264 | painter.rect_stroke( | ||
| 265 | rect, | ||
| 266 | theme::RADIUS_LG, | ||
| 267 | Stroke::new(1.0, theme::LINE), | ||
| 268 | StrokeKind::Inside, | ||
| 269 | ); | ||
| 270 | |||
| 271 | let plot = rect.shrink2(vec2(56.0, 48.0)); | ||
| 272 | let max_seconds = fx | ||
| 273 | .points | ||
| 274 | .last() | ||
| 275 | .map(|sample| sample.seconds) | ||
| 276 | .unwrap_or(0.0) | ||
| 277 | .max(6.0) | ||
| 278 | * 1.08; | ||
| 279 | let max_multiplier = fx.peak.max(2.0) * 1.15; | ||
| 280 | |||
| 281 | painter.text( | ||
| 282 | plot.left_top() + vec2(0.0, 2.0), | ||
| 283 | Align2::LEFT_TOP, | ||
| 284 | format!("{:.2}x", view.multiplier), | ||
| 285 | theme::mono(46.0), | ||
| 286 | curve_color(view), | ||
| 287 | ); | ||
| 288 | painter.text( | ||
| 289 | plot.left_top() + vec2(2.0, 58.0), | ||
| 290 | Align2::LEFT_TOP, | ||
| 291 | status_label(view), | ||
| 292 | theme::mono(10.0), | ||
| 293 | theme::TEXT_FAINT, | ||
| 294 | ); | ||
| 295 | |||
| 296 | // Grid: whole multipliers and two-second columns. | ||
| 297 | let grid = Stroke::new(1.0, theme::LINE.gamma_multiply(0.55)); | ||
| 298 | let mut step = 1.0; | ||
| 299 | while max_multiplier / step > 6.0 { | ||
| 300 | step += 1.0; | ||
| 301 | } | ||
| 302 | let mut value = step; | ||
| 303 | while value <= max_multiplier { | ||
| 304 | let y = map_y(plot, value, max_multiplier); | ||
| 305 | painter.hline(plot.x_range(), y, grid); | ||
| 306 | painter.text( | ||
| 307 | pos2(plot.left() - 10.0, y), | ||
| 308 | Align2::RIGHT_CENTER, | ||
| 309 | format!("{value:.0}x"), | ||
| 310 | theme::mono(10.0), | ||
| 311 | theme::TEXT_FAINT, | ||
| 312 | ); | ||
| 313 | value += step; | ||
| 314 | } | ||
| 315 | let mut second = 2.0; | ||
| 316 | while f64::from(second) <= f64::from(max_seconds) { | ||
| 317 | let x = plot.left() + plot.width() * (second / max_seconds); | ||
| 318 | painter.vline(x, plot.y_range(), grid); | ||
| 319 | painter.text( | ||
| 320 | pos2(x, plot.bottom() + 8.0), | ||
| 321 | Align2::CENTER_TOP, | ||
| 322 | format!("{second:.0}s"), | ||
| 323 | theme::mono(10.0), | ||
| 324 | theme::TEXT_FAINT, | ||
| 325 | ); | ||
| 326 | second += 2.0; | ||
| 327 | } | ||
| 328 | |||
| 329 | if fx.points.is_empty() { | ||
| 330 | painter.text( | ||
| 331 | plot.center(), | ||
| 332 | Align2::CENTER_CENTER, | ||
| 333 | if view.status == "running" { | ||
| 334 | "…" | ||
| 335 | } else { | ||
| 336 | "Press START to play" | ||
| 337 | }, | ||
| 338 | theme::sans(14.0), | ||
| 339 | theme::TEXT_FAINT, | ||
| 340 | ); | ||
| 341 | } else { | ||
| 342 | let points: Vec<Pos2> = fx | ||
| 343 | .points | ||
| 344 | .iter() | ||
| 345 | .map(|sample| { | ||
| 346 | pos2( | ||
| 347 | plot.left() + plot.width() * (sample.seconds / max_seconds), | ||
| 348 | map_y(plot, sample.multiplier, max_multiplier), | ||
| 349 | ) | ||
| 350 | }) | ||
| 351 | .collect(); | ||
| 352 | |||
| 353 | // A soft fill under the curve. | ||
| 354 | if points.len() >= 2 { | ||
| 355 | let mut fill = points.clone(); | ||
| 356 | fill.push(pos2(points[points.len() - 1].x, plot.bottom())); | ||
| 357 | fill.push(pos2(points[0].x, plot.bottom())); | ||
| 358 | painter.add(Shape::convex_polygon( | ||
| 359 | fill, | ||
| 360 | curve_color(view).gamma_multiply(0.10), | ||
| 361 | Stroke::NONE, | ||
| 362 | )); | ||
| 363 | } | ||
| 364 | painter.add(Shape::line(points, Stroke::new(2.5, curve_color(view)))); | ||
| 365 | |||
| 366 | if view.status == "crashed" { | ||
| 367 | if let Some(point) = view.crash_point { | ||
| 368 | let y = map_y(plot, point, max_multiplier); | ||
| 369 | let x = plot.left() | ||
| 370 | + plot.width() * (fx.points.last().map_or(0.0, |s| s.seconds) / max_seconds); | ||
| 371 | painter.circle_filled(pos2(x, y), 5.0, theme::LOSS); | ||
| 372 | painter.text( | ||
| 373 | pos2(x + 10.0, y - 8.0), | ||
| 374 | Align2::LEFT_BOTTOM, | ||
| 375 | format!("{point:.2}x"), | ||
| 376 | theme::mono(12.0), | ||
| 377 | theme::LOSS, | ||
| 378 | ); | ||
| 379 | } | ||
| 380 | } | ||
| 381 | } | ||
| 382 | |||
| 383 | ui.add_space(theme::SPACE_4); | ||
| 384 | ui.label( | ||
| 385 | eframe::egui::RichText::new( | ||
| 386 | "The crash point is drawn from the server; the multiplier here is display only.", | ||
| 387 | ) | ||
| 388 | .font(theme::sans(11.0)) | ||
| 389 | .color(theme::TEXT_FAINT), | ||
| 390 | ); | ||
| 391 | } | ||
| 392 | |||
| 393 | fn map_y(plot: Rect, multiplier: f64, max_multiplier: f64) -> f32 { | ||
| 394 | let range = (max_multiplier - 1.0).max(0.1); | ||
| 395 | plot.bottom() - ((multiplier - 1.0) / range) as f32 * plot.height() | ||
| 396 | } | ||
| 397 | |||
| 398 | fn status_label(view: &CrashView) -> &'static str { | ||
| 399 | match view.status.as_str() { | ||
| 400 | "starting" => "STARTING", | ||
| 401 | "running" => "LIVE", | ||
| 402 | "cashed" => "CASHED OUT", | ||
| 403 | "crashed" => "CRASHED", | ||
| 404 | "forfeit" => "FORFEITED", | ||
| 405 | _ => "READY", | ||
| 406 | } | ||
| 407 | } | ||
| 408 | |||
| 409 | fn curve_color(view: &CrashView) -> Color32 { | ||
| 410 | match view.status.as_str() { | ||
| 411 | "running" => theme::WIN, | ||
| 412 | "cashed" => theme::GOLD, | ||
| 413 | "crashed" => theme::LOSS, | ||
| 414 | _ => theme::TEXT_DIM, | ||
| 415 | } | ||
| 416 | } | ||
diff --git a/gui/src/lib.rs b/gui/src/lib.rs index e0f5bc9..e5895fc 100644 --- a/gui/src/lib.rs +++ b/gui/src/lib.rs | |||
| @@ -5,5 +5,10 @@ | |||
| 5 | //! layout. It never enters the TUI or server build graph. | 5 | //! layout. It never enters the TUI or server build graph. |
| 6 | 6 | ||
| 7 | pub mod app; | 7 | pub mod app; |
| 8 | pub mod blackjack; | ||
| 9 | pub mod crash; | ||
| 10 | pub mod net; | ||
| 8 | pub mod roulette; | 11 | pub mod roulette; |
| 12 | pub mod slots; | ||
| 9 | pub mod theme; | 13 | pub mod theme; |
| 14 | pub mod widgets; | ||
diff --git a/gui/src/main.rs b/gui/src/main.rs index cf1c67d..82babb6 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs | |||
| @@ -1,12 +1,36 @@ | |||
| 1 | //! Desktop frontend for zagan-bet. | 1 | //! Desktop frontend for zagan-bet. |
| 2 | //! | 2 | //! |
| 3 | //! Phase 3 of `docs/GUI-PLAN.md`: the style spike. It owns the window and the | 3 | //! Phase 4: the egui shell over the shared `ClientCore`, with the transport on |
| 4 | //! roulette reference screen; the shared core and transport are wired in | 4 | //! a worker thread. The same data directory and flags as the terminal client. |
| 5 | //! Phase 4. | ||
| 6 | 5 | ||
| 7 | use zagan_bet_gui::app::ZaganApp; | 6 | use zagan_bet::client::paths::{default_data_dir, parse_args}; |
| 7 | use zagan_bet_gui::app::{Startup, ZaganApp}; | ||
| 8 | 8 | ||
| 9 | fn main() -> eframe::Result { | 9 | fn main() -> eframe::Result { |
| 10 | let args = match parse_args(std::env::args().skip(1)) { | ||
| 11 | Ok(args) => args, | ||
| 12 | Err(message) => { | ||
| 13 | eprintln!("{message}"); | ||
| 14 | std::process::exit(2); | ||
| 15 | } | ||
| 16 | }; | ||
| 17 | if args.help { | ||
| 18 | println!( | ||
| 19 | "usage: zagan-bet-gui [-d <dir>] [--unsafe] | ||
| 20 | |||
| 21 | -d, --data-dir <dir> where to keep client.db (default: XDG data dir) | ||
| 22 | --unsafe skip certificate verification | ||
| 23 | -h, --help show this message" | ||
| 24 | ); | ||
| 25 | return Ok(()); | ||
| 26 | } | ||
| 27 | |||
| 28 | let data_dir = args.data_dir.unwrap_or_else(default_data_dir); | ||
| 29 | if let Err(error) = std::fs::create_dir_all(&data_dir) { | ||
| 30 | eprintln!("Cannot create {}: {error}", data_dir.display()); | ||
| 31 | std::process::exit(1); | ||
| 32 | } | ||
| 33 | |||
| 10 | let options = eframe::NativeOptions { | 34 | let options = eframe::NativeOptions { |
| 11 | viewport: eframe::egui::ViewportBuilder::default() | 35 | viewport: eframe::egui::ViewportBuilder::default() |
| 12 | .with_title("Zagan-Bet") | 36 | .with_title("Zagan-Bet") |
| @@ -17,9 +41,13 @@ fn main() -> eframe::Result { | |||
| 17 | ..Default::default() | 41 | ..Default::default() |
| 18 | }; | 42 | }; |
| 19 | 43 | ||
| 44 | let startup = Startup { | ||
| 45 | data_dir, | ||
| 46 | unsafe_mode: args.unsafe_mode, | ||
| 47 | }; | ||
| 20 | eframe::run_native( | 48 | eframe::run_native( |
| 21 | "Zagan-Bet", | 49 | "Zagan-Bet", |
| 22 | options, | 50 | options, |
| 23 | Box::new(|cc| Ok(Box::new(ZaganApp::new(cc)))), | 51 | Box::new(move |cc| Ok(Box::new(ZaganApp::new(cc, startup)))), |
| 24 | ) | 52 | ) |
| 25 | } | 53 | } |
diff --git a/gui/src/net.rs b/gui/src/net.rs new file mode 100644 index 0000000..d2bc7e4 --- /dev/null +++ b/gui/src/net.rs | |||
| @@ -0,0 +1,220 @@ | |||
| 1 | //! Background network worker for the desktop frontend. | ||
| 2 | //! | ||
| 3 | //! The core is I/O-free, so the adapter owns the TLS stream. One blocking | ||
| 4 | //! thread keeps the connection, forwards every server line, and wakes the UI | ||
| 5 | //! through `egui::Context::request_repaint`. There is no async runtime: the | ||
| 6 | //! protocol has one request in flight plus server pushes. | ||
| 7 | |||
| 8 | use std::path::PathBuf; | ||
| 9 | use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; | ||
| 10 | use std::thread; | ||
| 11 | use std::time::Duration; | ||
| 12 | |||
| 13 | use eframe::egui; | ||
| 14 | use rusqlite::Connection; | ||
| 15 | use zagan_bet::client::transport::{self, TlsStream, TransportError, TrustPrompt}; | ||
| 16 | use zagan_bet::client::{database, paths}; | ||
| 17 | |||
| 18 | /// How often the worker checks the socket for pushes when idle. | ||
| 19 | const POLL: Duration = Duration::from_millis(8); | ||
| 20 | /// How long a first-contact prompt may stay unanswered. | ||
| 21 | const TRUST_TIMEOUT: Duration = Duration::from_secs(120); | ||
| 22 | |||
| 23 | /// What the UI asks the worker to do. | ||
| 24 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 25 | pub enum Command { | ||
| 26 | Connect { addr: String }, | ||
| 27 | Send(String), | ||
| 28 | Disconnect, | ||
| 29 | Stop, | ||
| 30 | } | ||
| 31 | |||
| 32 | /// Why a connection attempt failed. The UI picks a screen from this. | ||
| 33 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 34 | pub enum Failure { | ||
| 35 | TrustDeclined, | ||
| 36 | KeyChanged { expected: String, observed: String }, | ||
| 37 | VersionMismatch, | ||
| 38 | Timeout, | ||
| 39 | Other, | ||
| 40 | } | ||
| 41 | |||
| 42 | /// A connection that survived trust, version and auto-login. | ||
| 43 | #[derive(Debug, Clone, PartialEq)] | ||
| 44 | pub struct Connected { | ||
| 45 | pub addr: String, | ||
| 46 | pub user: Option<String>, | ||
| 47 | pub auth_error: Option<String>, | ||
| 48 | pub fingerprint: Option<String>, | ||
| 49 | pub server_version: String, | ||
| 50 | pub latest: String, | ||
| 51 | pub url: String, | ||
| 52 | pub elapsed_ms: f32, | ||
| 53 | } | ||
| 54 | |||
| 55 | /// What the worker tells the UI. | ||
| 56 | pub enum Event { | ||
| 57 | /// First contact: the UI must answer through the sender. | ||
| 58 | Trust { | ||
| 59 | addr: String, | ||
| 60 | fingerprint: String, | ||
| 61 | reply: Sender<bool>, | ||
| 62 | }, | ||
| 63 | Connected(Connected), | ||
| 64 | Failed { | ||
| 65 | addr: String, | ||
| 66 | failure: Failure, | ||
| 67 | message: String, | ||
| 68 | }, | ||
| 69 | Line(String), | ||
| 70 | Closed, | ||
| 71 | } | ||
| 72 | |||
| 73 | fn classify(error: &TransportError) -> Failure { | ||
| 74 | match error { | ||
| 75 | TransportError::TrustDeclined => Failure::TrustDeclined, | ||
| 76 | TransportError::KeyChanged { expected, observed } => Failure::KeyChanged { | ||
| 77 | expected: expected.clone(), | ||
| 78 | observed: observed.clone(), | ||
| 79 | }, | ||
| 80 | TransportError::VersionMismatch(_) => Failure::VersionMismatch, | ||
| 81 | TransportError::Timeout => Failure::Timeout, | ||
| 82 | _ => Failure::Other, | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | /// Handle held by the app. Dropping it stops the worker. | ||
| 87 | pub struct Network { | ||
| 88 | commands: Sender<Command>, | ||
| 89 | events: Receiver<Event>, | ||
| 90 | } | ||
| 91 | |||
| 92 | impl Network { | ||
| 93 | pub fn spawn(ctx: egui::Context, data_dir: PathBuf, unsafe_mode: bool) -> Self { | ||
| 94 | let (commands, command_rx) = mpsc::channel(); | ||
| 95 | let (event_tx, events) = mpsc::channel(); | ||
| 96 | thread::spawn(move || worker(ctx, data_dir, unsafe_mode, command_rx, event_tx)); | ||
| 97 | Self { commands, events } | ||
| 98 | } | ||
| 99 | |||
| 100 | pub fn send(&self, command: Command) { | ||
| 101 | let _ = self.commands.send(command); | ||
| 102 | } | ||
| 103 | |||
| 104 | pub fn try_recv(&self) -> Option<Event> { | ||
| 105 | self.events.try_recv().ok() | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | impl Drop for Network { | ||
| 110 | fn drop(&mut self) { | ||
| 111 | let _ = self.commands.send(Command::Stop); | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | fn worker( | ||
| 116 | ctx: egui::Context, | ||
| 117 | data_dir: PathBuf, | ||
| 118 | unsafe_mode: bool, | ||
| 119 | commands: Receiver<Command>, | ||
| 120 | events: Sender<Event>, | ||
| 121 | ) { | ||
| 122 | let db_path = paths::database_path(&data_dir); | ||
| 123 | let conn = match Connection::open(&db_path) { | ||
| 124 | Ok(conn) => { | ||
| 125 | let _ = conn.busy_timeout(Duration::from_secs(2)); | ||
| 126 | database::init_db(&conn); | ||
| 127 | Some(conn) | ||
| 128 | } | ||
| 129 | Err(_) => None, | ||
| 130 | }; | ||
| 131 | |||
| 132 | let mut stream: Option<TlsStream> = None; | ||
| 133 | |||
| 134 | loop { | ||
| 135 | match commands.recv_timeout(POLL) { | ||
| 136 | Ok(Command::Connect { addr }) => { | ||
| 137 | stream = None; | ||
| 138 | let Some(conn) = &conn else { | ||
| 139 | let _ = events.send(Event::Failed { | ||
| 140 | addr, | ||
| 141 | failure: Failure::Other, | ||
| 142 | message: format!("cannot open {}", db_path.display()), | ||
| 143 | }); | ||
| 144 | ctx.request_repaint(); | ||
| 145 | continue; | ||
| 146 | }; | ||
| 147 | let mut trust = |prompt: &TrustPrompt| { | ||
| 148 | let (reply, decision) = mpsc::channel(); | ||
| 149 | let _ = events.send(Event::Trust { | ||
| 150 | addr: prompt.addr.clone(), | ||
| 151 | fingerprint: prompt.fingerprint.clone(), | ||
| 152 | reply, | ||
| 153 | }); | ||
| 154 | ctx.request_repaint(); | ||
| 155 | decision.recv_timeout(TRUST_TIMEOUT).unwrap_or(false) | ||
| 156 | }; | ||
| 157 | match transport::connect(&addr, conn, unsafe_mode, &mut trust) { | ||
| 158 | Ok(connected) => { | ||
| 159 | let fingerprint = database::get_fingerprint(conn, &addr); | ||
| 160 | let _ = events.send(Event::Connected(Connected { | ||
| 161 | addr: addr.clone(), | ||
| 162 | user: connected.user, | ||
| 163 | auth_error: connected.auth_error, | ||
| 164 | fingerprint, | ||
| 165 | server_version: connected.server_version, | ||
| 166 | latest: connected.latest, | ||
| 167 | url: connected.url, | ||
| 168 | elapsed_ms: connected.elapsed.as_secs_f64() as f32 * 1000.0, | ||
| 169 | })); | ||
| 170 | stream = Some(connected.stream); | ||
| 171 | } | ||
| 172 | Err(error) => { | ||
| 173 | let _ = events.send(Event::Failed { | ||
| 174 | addr, | ||
| 175 | failure: classify(&error), | ||
| 176 | message: error.to_string(), | ||
| 177 | }); | ||
| 178 | } | ||
| 179 | } | ||
| 180 | ctx.request_repaint(); | ||
| 181 | } | ||
| 182 | Ok(Command::Send(line)) => { | ||
| 183 | if let Some(open) = &mut stream { | ||
| 184 | if open.write_line(&line).is_err() { | ||
| 185 | stream = None; | ||
| 186 | let _ = events.send(Event::Closed); | ||
| 187 | } | ||
| 188 | } | ||
| 189 | ctx.request_repaint(); | ||
| 190 | } | ||
| 191 | Ok(Command::Disconnect) => { | ||
| 192 | stream = None; | ||
| 193 | ctx.request_repaint(); | ||
| 194 | } | ||
| 195 | Ok(Command::Stop) => break, | ||
| 196 | Err(RecvTimeoutError::Timeout) => {} | ||
| 197 | Err(RecvTimeoutError::Disconnected) => break, | ||
| 198 | } | ||
| 199 | |||
| 200 | // Drain pushes and replies without blocking the command channel. | ||
| 201 | if let Some(open) = &mut stream { | ||
| 202 | loop { | ||
| 203 | match open.read_available_line() { | ||
| 204 | Ok(Some(line)) => { | ||
| 205 | if events.send(Event::Line(line)).is_err() { | ||
| 206 | return; | ||
| 207 | } | ||
| 208 | } | ||
| 209 | Ok(None) => break, | ||
| 210 | Err(_) => { | ||
| 211 | stream = None; | ||
| 212 | let _ = events.send(Event::Closed); | ||
| 213 | break; | ||
| 214 | } | ||
| 215 | } | ||
| 216 | } | ||
| 217 | ctx.request_repaint(); | ||
| 218 | } | ||
| 219 | } | ||
| 220 | } | ||
diff --git a/gui/src/roulette.rs b/gui/src/roulette.rs index f674251..c791693 100644 --- a/gui/src/roulette.rs +++ b/gui/src/roulette.rs | |||
| @@ -553,3 +553,434 @@ pub fn paint_result_dot(painter: &Painter, center: Pos2, radius: f32, number: u8 | |||
| 553 | theme::FELT_TEXT, | 553 | theme::FELT_TEXT, |
| 554 | ); | 554 | ); |
| 555 | } | 555 | } |
| 556 | |||
| 557 | // --------------------------------------------------------------------------- | ||
| 558 | // The screen | ||
| 559 | // --------------------------------------------------------------------------- | ||
| 560 | |||
| 561 | /// Wheel animation and result history. Kept across frames; reset when the | ||
| 562 | /// player leaves the table. | ||
| 563 | pub struct Fx { | ||
| 564 | rotation: f32, | ||
| 565 | spin: Option<WheelSpin>, | ||
| 566 | awaiting_result: bool, | ||
| 567 | settled: Option<u8>, | ||
| 568 | last: Vec<u8>, | ||
| 569 | } | ||
| 570 | |||
| 571 | struct WheelSpin { | ||
| 572 | started: f64, | ||
| 573 | from: f32, | ||
| 574 | delta: f32, | ||
| 575 | target: u8, | ||
| 576 | } | ||
| 577 | |||
| 578 | impl Default for Fx { | ||
| 579 | fn default() -> Self { | ||
| 580 | Self { | ||
| 581 | rotation: 0.0, | ||
| 582 | spin: None, | ||
| 583 | awaiting_result: false, | ||
| 584 | settled: None, | ||
| 585 | last: Vec::new(), | ||
| 586 | } | ||
| 587 | } | ||
| 588 | } | ||
| 589 | |||
| 590 | impl Fx { | ||
| 591 | /// Called when the player asks for a spin, before the reply can arrive. | ||
| 592 | pub fn spin_requested(&mut self) { | ||
| 593 | self.awaiting_result = true; | ||
| 594 | } | ||
| 595 | |||
| 596 | pub fn ticking(&self) -> bool { | ||
| 597 | self.spin.is_some() | ||
| 598 | } | ||
| 599 | |||
| 600 | /// Advance the wheel; starts a spin when a new result lands. | ||
| 601 | pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &RouletteView) { | ||
| 602 | let now = ctx.input(|input| input.time); | ||
| 603 | if view.error.is_some() { | ||
| 604 | // The request was refused, so no result is coming. | ||
| 605 | self.awaiting_result = false; | ||
| 606 | } | ||
| 607 | if self.awaiting_result && view.phase == RoulettePhase::Result { | ||
| 608 | if let Some(target) = view.number { | ||
| 609 | self.awaiting_result = false; | ||
| 610 | self.start(now, target); | ||
| 611 | } | ||
| 612 | } | ||
| 613 | |||
| 614 | let Some(spin) = &self.spin else { | ||
| 615 | return; | ||
| 616 | }; | ||
| 617 | let t = (((now - spin.started) / 1.7) as f32).clamp(0.0, 1.0); | ||
| 618 | let eased = 1.0 - (1.0 - t).powi(3); | ||
| 619 | self.rotation = spin.from + spin.delta * eased; | ||
| 620 | if t >= 1.0 { | ||
| 621 | let target = spin.target; | ||
| 622 | self.spin = None; | ||
| 623 | self.settled = Some(target); | ||
| 624 | self.last.insert(0, target); | ||
| 625 | self.last.truncate(12); | ||
| 626 | } | ||
| 627 | ctx.request_repaint(); | ||
| 628 | } | ||
| 629 | |||
| 630 | fn start(&mut self, now: f64, target: u8) { | ||
| 631 | let segment = TAU / WHEEL.len() as f32; | ||
| 632 | let index = WHEEL | ||
| 633 | .iter() | ||
| 634 | .position(|number| *number == target) | ||
| 635 | .unwrap_or(0) as f32; | ||
| 636 | let desired = -index * segment; | ||
| 637 | let delta = (desired - self.rotation).rem_euclid(TAU) + 3.0 * TAU; | ||
| 638 | self.spin = Some(WheelSpin { | ||
| 639 | started: now, | ||
| 640 | from: self.rotation, | ||
| 641 | delta, | ||
| 642 | target, | ||
| 643 | }); | ||
| 644 | } | ||
| 645 | |||
| 646 | fn spinning(&self) -> bool { | ||
| 647 | self.spin.is_some() | ||
| 648 | } | ||
| 649 | |||
| 650 | /// The payout is held back until the wheel has stopped. | ||
| 651 | pub fn revealed(&self, view: &RouletteView) -> bool { | ||
| 652 | view.phase == RoulettePhase::Result && !self.spinning() | ||
| 653 | } | ||
| 654 | } | ||
| 655 | |||
| 656 | pub fn show( | ||
| 657 | ui: &mut Ui, | ||
| 658 | view: &RouletteView, | ||
| 659 | fx: &mut Fx, | ||
| 660 | ) -> Option<zagan_bet::client::view::Command> { | ||
| 661 | let mut command = None; | ||
| 662 | // While the wheel turns, keep the result hidden: no winning cell and no | ||
| 663 | // payout until it stops under the pointer. | ||
| 664 | let mut shown = view.clone(); | ||
| 665 | if fx.spinning() && view.phase == RoulettePhase::Result { | ||
| 666 | shown.phase = RoulettePhase::Spinning; | ||
| 667 | } | ||
| 668 | eframe::egui::CentralPanel::default() | ||
| 669 | .frame(eframe::egui::Frame::new().fill(theme::BG)) | ||
| 670 | .show(ui, |ui| { | ||
| 671 | let full = ui.max_rect(); | ||
| 672 | let sidebar_rect = eframe::egui::Rect::from_min_max( | ||
| 673 | eframe::egui::pos2(full.right() - 336.0, full.top()), | ||
| 674 | full.right_bottom(), | ||
| 675 | ); | ||
| 676 | let board_rect = eframe::egui::Rect::from_min_max( | ||
| 677 | full.min, | ||
| 678 | eframe::egui::pos2(sidebar_rect.left(), full.bottom()), | ||
| 679 | ); | ||
| 680 | ui.painter().rect_filled( | ||
| 681 | sidebar_rect, | ||
| 682 | eframe::egui::CornerRadius::ZERO, | ||
| 683 | theme::SURFACE, | ||
| 684 | ); | ||
| 685 | |||
| 686 | ui.scope_builder( | ||
| 687 | eframe::egui::UiBuilder::new() | ||
| 688 | .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0))) | ||
| 689 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 690 | |ui| { | ||
| 691 | if let Some(clicked) = board(ui, &shown, fx) { | ||
| 692 | command = Some(clicked); | ||
| 693 | } | ||
| 694 | }, | ||
| 695 | ); | ||
| 696 | ui.scope_builder( | ||
| 697 | eframe::egui::UiBuilder::new() | ||
| 698 | .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0))) | ||
| 699 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 700 | |ui| { | ||
| 701 | if let Some(clicked) = sidebar(ui, &shown, fx) { | ||
| 702 | command = Some(clicked); | ||
| 703 | } | ||
| 704 | }, | ||
| 705 | ); | ||
| 706 | }); | ||
| 707 | command | ||
| 708 | } | ||
| 709 | |||
| 710 | fn sidebar( | ||
| 711 | ui: &mut Ui, | ||
| 712 | view: &RouletteView, | ||
| 713 | fx: &mut Fx, | ||
| 714 | ) -> Option<zagan_bet::client::view::Command> { | ||
| 715 | use zagan_bet::client::view::Command; | ||
| 716 | |||
| 717 | let mut command = None; | ||
| 718 | let (content, actions) = super::widgets::sidebar_layout(ui, 150.0); | ||
| 719 | |||
| 720 | ui.scope_builder( | ||
| 721 | eframe::egui::UiBuilder::new() | ||
| 722 | .max_rect(content) | ||
| 723 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 724 | |ui| { | ||
| 725 | eframe::egui::ScrollArea::vertical() | ||
| 726 | .auto_shrink([false, false]) | ||
| 727 | .show(ui, |ui| { | ||
| 728 | ui.label( | ||
| 729 | eframe::egui::RichText::new("Roulette") | ||
| 730 | .font(theme::sans(22.0)) | ||
| 731 | .color(theme::TEXT), | ||
| 732 | ); | ||
| 733 | ui.label( | ||
| 734 | eframe::egui::RichText::new("European · single zero") | ||
| 735 | .font(theme::sans(12.0)) | ||
| 736 | .color(theme::TEXT_DIM), | ||
| 737 | ); | ||
| 738 | |||
| 739 | ui.add_space(theme::SPACE_4); | ||
| 740 | let wheel_size = ui.available_width().min(210.0); | ||
| 741 | ui.horizontal(|ui| { | ||
| 742 | let pad = ((ui.available_width() - wheel_size) * 0.5).max(0.0); | ||
| 743 | ui.add_space(pad); | ||
| 744 | wheel(ui, wheel_size, view, fx.rotation); | ||
| 745 | }); | ||
| 746 | ui.add_space(theme::SPACE_1); | ||
| 747 | |||
| 748 | let spinning = view.phase == RoulettePhase::Spinning || fx.spinning(); | ||
| 749 | if spinning { | ||
| 750 | ui.label( | ||
| 751 | eframe::egui::RichText::new("SPINNING…") | ||
| 752 | .font(theme::mono(12.0)) | ||
| 753 | .color(theme::GOLD), | ||
| 754 | ); | ||
| 755 | } else if fx.revealed(view) { | ||
| 756 | ui.horizontal(|ui| { | ||
| 757 | let number = view.number.unwrap_or(0); | ||
| 758 | ui.label(theme::label("Result")); | ||
| 759 | ui.label( | ||
| 760 | eframe::egui::RichText::new(format!( | ||
| 761 | "{number} {}", | ||
| 762 | wheel_color(number).to_uppercase() | ||
| 763 | )) | ||
| 764 | .font(theme::mono(14.0)) | ||
| 765 | .color(number_color(number)), | ||
| 766 | ); | ||
| 767 | ui.with_layout( | ||
| 768 | eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), | ||
| 769 | |ui| { | ||
| 770 | let winnings = view.winnings.unwrap_or(0); | ||
| 771 | if winnings == 0 { | ||
| 772 | ui.label( | ||
| 773 | eframe::egui::RichText::new("NO WIN") | ||
| 774 | .font(theme::mono(13.0)) | ||
| 775 | .color(theme::TEXT_FAINT), | ||
| 776 | ); | ||
| 777 | } else { | ||
| 778 | let profit = winnings as i64 - view.bets.total() as i64; | ||
| 779 | ui.label( | ||
| 780 | eframe::egui::RichText::new(format!( | ||
| 781 | "({})", | ||
| 782 | theme::signed_money(profit) | ||
| 783 | )) | ||
| 784 | .font(theme::sans(11.0)) | ||
| 785 | .color( | ||
| 786 | if profit >= 0 { theme::WIN } else { theme::LOSS }, | ||
| 787 | ), | ||
| 788 | ); | ||
| 789 | ui.label( | ||
| 790 | eframe::egui::RichText::new(theme::money( | ||
| 791 | winnings as i64, | ||
| 792 | )) | ||
| 793 | .font(theme::mono(15.0)) | ||
| 794 | .color(theme::WIN), | ||
| 795 | ); | ||
| 796 | ui.label(theme::label("Won")); | ||
| 797 | } | ||
| 798 | }, | ||
| 799 | ); | ||
| 800 | }); | ||
| 801 | } else { | ||
| 802 | ui.horizontal(|ui| { | ||
| 803 | ui.label( | ||
| 804 | eframe::egui::RichText::new("Place your bets") | ||
| 805 | .font(theme::sans(12.0)) | ||
| 806 | .color(theme::TEXT_DIM), | ||
| 807 | ); | ||
| 808 | ui.with_layout( | ||
| 809 | eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), | ||
| 810 | |ui| { | ||
| 811 | ui.label( | ||
| 812 | eframe::egui::RichText::new(match view.bets.chips.len() { | ||
| 813 | 1 => "1 chip".to_string(), | ||
| 814 | count => format!("{count} chips"), | ||
| 815 | }) | ||
| 816 | .font(theme::mono(10.0)) | ||
| 817 | .color(theme::TEXT_FAINT), | ||
| 818 | ); | ||
| 819 | }, | ||
| 820 | ); | ||
| 821 | }); | ||
| 822 | } | ||
| 823 | |||
| 824 | ui.add_space(theme::SPACE_3); | ||
| 825 | ui.separator(); | ||
| 826 | ui.add_space(theme::SPACE_3); | ||
| 827 | |||
| 828 | ui.label(theme::label("Chip")); | ||
| 829 | ui.add_space(theme::SPACE_2); | ||
| 830 | if let Some(chip) = | ||
| 831 | super::widgets::chip_row(ui, [10, 25, 50, 100], view.chip, !spinning) | ||
| 832 | { | ||
| 833 | command = Some(Command::SetChip(chip)); | ||
| 834 | } | ||
| 835 | |||
| 836 | ui.add_space(theme::SPACE_3); | ||
| 837 | ui.label(theme::label("Bets")); | ||
| 838 | ui.add_space(theme::SPACE_1); | ||
| 839 | if view.bets.chips.is_empty() { | ||
| 840 | ui.label( | ||
| 841 | eframe::egui::RichText::new("No chips on the felt") | ||
| 842 | .font(theme::sans(12.0)) | ||
| 843 | .color(theme::TEXT_FAINT), | ||
| 844 | ); | ||
| 845 | } else { | ||
| 846 | for chip in &view.bets.chips { | ||
| 847 | super::widgets::row( | ||
| 848 | ui, | ||
| 849 | &target_name(&chip.target), | ||
| 850 | &theme::money(chip.amount as i64), | ||
| 851 | theme::GOLD, | ||
| 852 | ); | ||
| 853 | } | ||
| 854 | } | ||
| 855 | |||
| 856 | ui.add_space(theme::SPACE_3); | ||
| 857 | ui.separator(); | ||
| 858 | ui.add_space(theme::SPACE_2); | ||
| 859 | super::widgets::row( | ||
| 860 | ui, | ||
| 861 | "Total", | ||
| 862 | &theme::money(view.bets.total() as i64), | ||
| 863 | theme::GOLD, | ||
| 864 | ); | ||
| 865 | }); | ||
| 866 | }, | ||
| 867 | ); | ||
| 868 | |||
| 869 | let spinning = view.phase == RoulettePhase::Spinning || fx.spinning(); | ||
| 870 | ui.scope_builder( | ||
| 871 | eframe::egui::UiBuilder::new() | ||
| 872 | .max_rect(actions) | ||
| 873 | .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)), | ||
| 874 | |ui| { | ||
| 875 | if super::widgets::ghost(ui, "Leave table") { | ||
| 876 | command = Some(Command::Leave); | ||
| 877 | } | ||
| 878 | ui.add_space(theme::SPACE_2); | ||
| 879 | if super::widgets::secondary( | ||
| 880 | ui, | ||
| 881 | "CLEAR", | ||
| 882 | !spinning && !view.bets.is_empty(), | ||
| 883 | ui.available_width(), | ||
| 884 | 36.0, | ||
| 885 | ) { | ||
| 886 | command = Some(Command::ClearBets); | ||
| 887 | } | ||
| 888 | ui.add_space(theme::SPACE_2); | ||
| 889 | let clicked = super::widgets::primary( | ||
| 890 | ui, | ||
| 891 | "SPIN", | ||
| 892 | !spinning && !view.bets.is_empty(), | ||
| 893 | ui.available_width(), | ||
| 894 | 44.0, | ||
| 895 | ); | ||
| 896 | if clicked { | ||
| 897 | fx.spin_requested(); | ||
| 898 | command = Some(Command::Spin); | ||
| 899 | } | ||
| 900 | }, | ||
| 901 | ); | ||
| 902 | |||
| 903 | command | ||
| 904 | } | ||
| 905 | |||
| 906 | fn board(ui: &mut Ui, view: &RouletteView, fx: &Fx) -> Option<zagan_bet::client::view::Command> { | ||
| 907 | use zagan_bet::client::view::Command; | ||
| 908 | |||
| 909 | ui.horizontal(|ui| { | ||
| 910 | ui.label( | ||
| 911 | eframe::egui::RichText::new("Table 01") | ||
| 912 | .font(theme::sans(15.0)) | ||
| 913 | .color(theme::TEXT), | ||
| 914 | ); | ||
| 915 | ui.label( | ||
| 916 | eframe::egui::RichText::new("European · single zero") | ||
| 917 | .font(theme::sans(12.0)) | ||
| 918 | .color(theme::TEXT_DIM), | ||
| 919 | ); | ||
| 920 | ui.with_layout( | ||
| 921 | eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), | ||
| 922 | |ui| { | ||
| 923 | ui.label( | ||
| 924 | eframe::egui::RichText::new("Limits 10 – 500 Kr") | ||
| 925 | .font(theme::mono(12.0)) | ||
| 926 | .color(theme::TEXT_FAINT), | ||
| 927 | ); | ||
| 928 | }, | ||
| 929 | ); | ||
| 930 | }); | ||
| 931 | ui.add_space(theme::SPACE_4); | ||
| 932 | |||
| 933 | let strip_height = 104.0; | ||
| 934 | let available = ui.available_size(); | ||
| 935 | let size = size_for(vec2(available.x, (available.y - strip_height).max(220.0))); | ||
| 936 | let side_pad = ((available.x - size.x) * 0.5).max(0.0); | ||
| 937 | let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0); | ||
| 938 | ui.add_space(top_pad); | ||
| 939 | |||
| 940 | let enabled = view.phase != RoulettePhase::Spinning && !fx.spinning(); | ||
| 941 | let action = ui | ||
| 942 | .horizontal(|ui| { | ||
| 943 | ui.add_space(side_pad); | ||
| 944 | felt(ui, size, view) | ||
| 945 | }) | ||
| 946 | .inner; | ||
| 947 | let command = match action { | ||
| 948 | Some(FeltAction::Place(target)) if enabled => Some(Command::PlaceBetAt(target)), | ||
| 949 | Some(FeltAction::Reduce(target)) if enabled => Some(Command::RemoveBetAt(target)), | ||
| 950 | _ => None, | ||
| 951 | }; | ||
| 952 | |||
| 953 | ui.add_space(theme::SPACE_4); | ||
| 954 | ui.horizontal(|ui| { | ||
| 955 | ui.label(theme::label("Last")); | ||
| 956 | if fx.last.is_empty() { | ||
| 957 | ui.label( | ||
| 958 | eframe::egui::RichText::new("No spins yet") | ||
| 959 | .font(theme::sans(12.0)) | ||
| 960 | .color(theme::TEXT_FAINT), | ||
| 961 | ); | ||
| 962 | } | ||
| 963 | for number in &fx.last { | ||
| 964 | let (dot, _) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::hover()); | ||
| 965 | paint_result_dot(ui.painter(), dot.center(), 11.0, *number); | ||
| 966 | ui.add_space(theme::SPACE_1); | ||
| 967 | } | ||
| 968 | }); | ||
| 969 | ui.add_space(theme::SPACE_2); | ||
| 970 | ui.label( | ||
| 971 | eframe::egui::RichText::new( | ||
| 972 | "Click to place · right or middle click takes back · C clears · space spins", | ||
| 973 | ) | ||
| 974 | .font(theme::sans(12.0)) | ||
| 975 | .color(theme::TEXT_FAINT), | ||
| 976 | ); | ||
| 977 | command | ||
| 978 | } | ||
| 979 | |||
| 980 | fn number_color(number: u8) -> Color32 { | ||
| 981 | match wheel_color(number) { | ||
| 982 | "red" => theme::ROULETTE_RED, | ||
| 983 | "green" => theme::WIN, | ||
| 984 | _ => theme::TEXT, | ||
| 985 | } | ||
| 986 | } | ||
diff --git a/gui/src/slots.rs b/gui/src/slots.rs new file mode 100644 index 0000000..6574626 --- /dev/null +++ b/gui/src/slots.rs | |||
| @@ -0,0 +1,447 @@ | |||
| 1 | //! Slots screen: a 5x3 board painted from `SlotsView`. | ||
| 2 | |||
| 3 | use eframe::egui::{ | ||
| 4 | Align2, Color32, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, vec2, | ||
| 5 | }; | ||
| 6 | use zagan_bet::client::view::{Command, SlotsPhase, SlotsView}; | ||
| 7 | use zagan_bet::slots::{LINE_NAMES, PAYLINES, REELS, ROWS, SYMBOLS}; | ||
| 8 | |||
| 9 | use crate::{theme, widgets}; | ||
| 10 | |||
| 11 | /// Reel animation. The server answers at once, so the UI spins the reels for | ||
| 12 | /// this long before revealing the result it already has. | ||
| 13 | const SPIN_SECONDS: f64 = 0.9; | ||
| 14 | |||
| 15 | #[derive(Default)] | ||
| 16 | pub struct Fx { | ||
| 17 | started: Option<f64>, | ||
| 18 | } | ||
| 19 | |||
| 20 | impl Fx { | ||
| 21 | pub fn spin_requested(&mut self, ctx: &eframe::egui::Context) { | ||
| 22 | self.started = Some(ctx.input(|input| input.time)); | ||
| 23 | } | ||
| 24 | |||
| 25 | fn spinning(&self, view: &SlotsView, now: f64) -> bool { | ||
| 26 | // A rejected spin (no balance, server error) never reaches the reels. | ||
| 27 | if view.error.is_some() { | ||
| 28 | return false; | ||
| 29 | } | ||
| 30 | match self.started { | ||
| 31 | Some(started) => now - started < SPIN_SECONDS || view.phase == SlotsPhase::Spinning, | ||
| 32 | None => view.phase == SlotsPhase::Spinning, | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | /// True once the reels have stopped on the server's answer. | ||
| 37 | pub fn revealed(&self, ctx: &eframe::egui::Context, view: &SlotsView) -> bool { | ||
| 38 | let now = ctx.input(|input| input.time); | ||
| 39 | !self.spinning(view, now) | ||
| 40 | } | ||
| 41 | |||
| 42 | pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &SlotsView) { | ||
| 43 | if view.error.is_some() { | ||
| 44 | self.started = None; | ||
| 45 | return; | ||
| 46 | } | ||
| 47 | let now = ctx.input(|input| input.time); | ||
| 48 | if self.spinning(view, now) { | ||
| 49 | ctx.request_repaint(); | ||
| 50 | } else { | ||
| 51 | self.started = None; | ||
| 52 | } | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | pub fn show(ui: &mut Ui, view: &SlotsView, fx: &mut Fx) -> Option<Command> { | ||
| 57 | let mut command = None; | ||
| 58 | eframe::egui::CentralPanel::default() | ||
| 59 | .frame(eframe::egui::Frame::new().fill(theme::BG)) | ||
| 60 | .show(ui, |ui| { | ||
| 61 | let full = ui.max_rect(); | ||
| 62 | let sidebar_rect = eframe::egui::Rect::from_min_max( | ||
| 63 | eframe::egui::pos2(full.right() - 336.0, full.top()), | ||
| 64 | full.right_bottom(), | ||
| 65 | ); | ||
| 66 | let board_rect = eframe::egui::Rect::from_min_max( | ||
| 67 | full.min, | ||
| 68 | eframe::egui::pos2(sidebar_rect.left(), full.bottom()), | ||
| 69 | ); | ||
| 70 | ui.painter().rect_filled( | ||
| 71 | sidebar_rect, | ||
| 72 | eframe::egui::CornerRadius::ZERO, | ||
| 73 | theme::SURFACE, | ||
| 74 | ); | ||
| 75 | ui.scope_builder( | ||
| 76 | eframe::egui::UiBuilder::new() | ||
| 77 | .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0))) | ||
| 78 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 79 | |ui| board(ui, view, fx), | ||
| 80 | ); | ||
| 81 | ui.scope_builder( | ||
| 82 | eframe::egui::UiBuilder::new() | ||
| 83 | .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0))) | ||
| 84 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 85 | |ui| { | ||
| 86 | command = sidebar(ui, view, fx); | ||
| 87 | }, | ||
| 88 | ); | ||
| 89 | }); | ||
| 90 | command | ||
| 91 | } | ||
| 92 | |||
| 93 | fn sidebar(ui: &mut Ui, view: &SlotsView, fx: &mut Fx) -> Option<Command> { | ||
| 94 | let now = ui.input(|input| input.time); | ||
| 95 | let spinning = fx.spinning(view, now); | ||
| 96 | let mut command = None; | ||
| 97 | let (content, actions) = widgets::sidebar_layout(ui, 150.0); | ||
| 98 | |||
| 99 | ui.scope_builder( | ||
| 100 | eframe::egui::UiBuilder::new() | ||
| 101 | .max_rect(content) | ||
| 102 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 103 | |ui| { | ||
| 104 | eframe::egui::ScrollArea::vertical() | ||
| 105 | .auto_shrink([false, false]) | ||
| 106 | .show(ui, |ui| { | ||
| 107 | ui.label( | ||
| 108 | eframe::egui::RichText::new("Slots") | ||
| 109 | .font(theme::sans(22.0)) | ||
| 110 | .color(theme::TEXT), | ||
| 111 | ); | ||
| 112 | ui.label( | ||
| 113 | eframe::egui::RichText::new("5 reels · 5 paylines") | ||
| 114 | .font(theme::sans(12.0)) | ||
| 115 | .color(theme::TEXT_DIM), | ||
| 116 | ); | ||
| 117 | |||
| 118 | ui.add_space(theme::SPACE_5); | ||
| 119 | ui.label(theme::label("Bet")); | ||
| 120 | ui.add_space(theme::SPACE_2); | ||
| 121 | if let Some(delta) = widgets::bet_control(ui, view.bet, !spinning) { | ||
| 122 | command = Some(Command::AdjustBet(delta)); | ||
| 123 | } | ||
| 124 | |||
| 125 | ui.add_space(theme::SPACE_4); | ||
| 126 | status(ui, view, spinning); | ||
| 127 | if let Some(error) = &view.error { | ||
| 128 | ui.add_space(theme::SPACE_3); | ||
| 129 | widgets::error(ui, error); | ||
| 130 | } | ||
| 131 | |||
| 132 | ui.add_space(theme::SPACE_4); | ||
| 133 | ui.separator(); | ||
| 134 | ui.add_space(theme::SPACE_3); | ||
| 135 | ui.label(theme::label("Paytable")); | ||
| 136 | ui.add_space(theme::SPACE_2); | ||
| 137 | ui.label( | ||
| 138 | eframe::egui::RichText::new("Pays bet × symbol × shape") | ||
| 139 | .font(theme::sans(11.0)) | ||
| 140 | .color(theme::TEXT_FAINT), | ||
| 141 | ); | ||
| 142 | ui.horizontal(|ui| { | ||
| 143 | ui.spacing_mut().item_spacing.x = theme::SPACE_2; | ||
| 144 | for (symbol, multiplier) in zagan_bet::slots::SYMBOL_MULTIPLIERS { | ||
| 145 | ui.label( | ||
| 146 | eframe::egui::RichText::new(format!("{symbol}{multiplier}")) | ||
| 147 | .font(theme::mono(12.0)) | ||
| 148 | .color(symbol_color(symbol)), | ||
| 149 | ); | ||
| 150 | } | ||
| 151 | }); | ||
| 152 | ui.label( | ||
| 153 | eframe::egui::RichText::new("3×1 4×3 5×14") | ||
| 154 | .font(theme::mono(12.0)) | ||
| 155 | .color(theme::TEXT_DIM), | ||
| 156 | ); | ||
| 157 | }); | ||
| 158 | }, | ||
| 159 | ); | ||
| 160 | |||
| 161 | ui.scope_builder( | ||
| 162 | eframe::egui::UiBuilder::new() | ||
| 163 | .max_rect(actions) | ||
| 164 | .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)), | ||
| 165 | |ui| { | ||
| 166 | if widgets::ghost(ui, "Leave machine") { | ||
| 167 | command = Some(Command::Leave); | ||
| 168 | } | ||
| 169 | ui.add_space(theme::SPACE_2); | ||
| 170 | if widgets::primary(ui, "SPIN", !spinning, ui.available_width(), 46.0) { | ||
| 171 | fx.spin_requested(ui.ctx()); | ||
| 172 | command = Some(Command::Spin); | ||
| 173 | } | ||
| 174 | }, | ||
| 175 | ); | ||
| 176 | |||
| 177 | command | ||
| 178 | } | ||
| 179 | |||
| 180 | fn status(ui: &mut Ui, view: &SlotsView, spinning: bool) { | ||
| 181 | if spinning { | ||
| 182 | ui.label( | ||
| 183 | eframe::egui::RichText::new("SPINNING…") | ||
| 184 | .font(theme::mono(12.0)) | ||
| 185 | .color(theme::GOLD), | ||
| 186 | ); | ||
| 187 | return; | ||
| 188 | } | ||
| 189 | |||
| 190 | match view.phase { | ||
| 191 | SlotsPhase::Result => { | ||
| 192 | let winnings = view.winnings.unwrap_or(0); | ||
| 193 | if winnings == 0 { | ||
| 194 | ui.label( | ||
| 195 | eframe::egui::RichText::new("NO WIN") | ||
| 196 | .font(theme::mono(13.0)) | ||
| 197 | .color(theme::TEXT_FAINT), | ||
| 198 | ); | ||
| 199 | } else { | ||
| 200 | let label = match view.win_type.as_str() { | ||
| 201 | "mega" => "MEGA WIN", | ||
| 202 | "big" => "BIG WIN", | ||
| 203 | _ => "WON", | ||
| 204 | }; | ||
| 205 | ui.horizontal(|ui| { | ||
| 206 | ui.label(theme::label(label)); | ||
| 207 | ui.label( | ||
| 208 | eframe::egui::RichText::new(theme::money(winnings as i64)) | ||
| 209 | .font(theme::mono(18.0)) | ||
| 210 | .color(theme::WIN), | ||
| 211 | ); | ||
| 212 | }); | ||
| 213 | if !view.win_lines.is_empty() { | ||
| 214 | let names: Vec<String> = view | ||
| 215 | .win_lines | ||
| 216 | .iter() | ||
| 217 | .map(|line| { | ||
| 218 | line.checked_sub(1) | ||
| 219 | .and_then(|index| LINE_NAMES.get(index).copied()) | ||
| 220 | .map(|name| format!("{line} ({name})")) | ||
| 221 | .unwrap_or_else(|| line.to_string()) | ||
| 222 | }) | ||
| 223 | .collect(); | ||
| 224 | ui.label( | ||
| 225 | eframe::egui::RichText::new(names.join(" · ")) | ||
| 226 | .font(theme::sans(11.0)) | ||
| 227 | .color(theme::TEXT_DIM), | ||
| 228 | ); | ||
| 229 | } | ||
| 230 | } | ||
| 231 | } | ||
| 232 | _ => { | ||
| 233 | ui.label( | ||
| 234 | eframe::egui::RichText::new("Press SPIN to play") | ||
| 235 | .font(theme::sans(12.0)) | ||
| 236 | .color(theme::TEXT_DIM), | ||
| 237 | ); | ||
| 238 | } | ||
| 239 | } | ||
| 240 | } | ||
| 241 | |||
| 242 | fn board(ui: &mut Ui, view: &SlotsView, fx: &Fx) { | ||
| 243 | ui.horizontal(|ui| { | ||
| 244 | ui.label( | ||
| 245 | eframe::egui::RichText::new("Machine 01") | ||
| 246 | .font(theme::sans(15.0)) | ||
| 247 | .color(theme::TEXT), | ||
| 248 | ); | ||
| 249 | ui.label( | ||
| 250 | eframe::egui::RichText::new("5 reels · 5 paylines") | ||
| 251 | .font(theme::sans(12.0)) | ||
| 252 | .color(theme::TEXT_DIM), | ||
| 253 | ); | ||
| 254 | ui.with_layout( | ||
| 255 | eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), | ||
| 256 | |ui| { | ||
| 257 | ui.label( | ||
| 258 | eframe::egui::RichText::new("Bet 10 – 500 Kr") | ||
| 259 | .font(theme::mono(12.0)) | ||
| 260 | .color(theme::TEXT_FAINT), | ||
| 261 | ); | ||
| 262 | }, | ||
| 263 | ); | ||
| 264 | }); | ||
| 265 | ui.add_space(theme::SPACE_5); | ||
| 266 | |||
| 267 | let now = ui.input(|input| input.time); | ||
| 268 | let spinning = fx.spinning(view, now); | ||
| 269 | let available = ui.available_size(); | ||
| 270 | let gap = theme::SPACE_2; | ||
| 271 | let tile_width = ((available.x - gap * (REELS as f32 - 1.0)) / REELS as f32) | ||
| 272 | .min((available.y - 150.0) / 3.0) | ||
| 273 | .min(190.0); | ||
| 274 | let tile = vec2(tile_width, tile_width * 0.92); | ||
| 275 | let board_width = tile.x * REELS as f32 + gap * (REELS as f32 - 1.0); | ||
| 276 | let board_height = tile.y * 3.0 + gap * 2.0; | ||
| 277 | let left = ((available.x - board_width) * 0.5).max(0.0); | ||
| 278 | let top = ((available.y - board_height) * 0.35).max(0.0); | ||
| 279 | |||
| 280 | ui.add_space(top); | ||
| 281 | let origin = ui | ||
| 282 | .horizontal(|ui| { | ||
| 283 | ui.add_space(left); | ||
| 284 | ui.allocate_exact_size(vec2(board_width, board_height), Sense::hover()) | ||
| 285 | .0 | ||
| 286 | .min | ||
| 287 | }) | ||
| 288 | .inner; | ||
| 289 | |||
| 290 | let painter = ui.painter(); | ||
| 291 | let cycling = (now * 18.0) as usize; | ||
| 292 | for col in 0..REELS { | ||
| 293 | for row in 0..ROWS { | ||
| 294 | let rect = Rect::from_min_size( | ||
| 295 | origin + vec2(col as f32 * (tile.x + gap), row as f32 * (tile.y + gap)), | ||
| 296 | tile, | ||
| 297 | ); | ||
| 298 | let index = col * ROWS + row; | ||
| 299 | let stocked = | ||
| 300 | view.reels.len() == REELS * ROWS || (view.reels.len() == REELS && row == 0); | ||
| 301 | let symbol = if spinning { | ||
| 302 | SYMBOLS[(cycling + index * 3) % SYMBOLS.len()] | ||
| 303 | } else if stocked { | ||
| 304 | if view.reels.len() == REELS * ROWS { | ||
| 305 | view.reels[index] | ||
| 306 | } else { | ||
| 307 | view.reels[col] | ||
| 308 | } | ||
| 309 | } else { | ||
| 310 | SYMBOLS[(index * 5 + col) % SYMBOLS.len()] | ||
| 311 | }; | ||
| 312 | let winning = !spinning && view.win_cells.contains(&index); | ||
| 313 | paint_tile(painter, rect, symbol, spinning, winning, !stocked); | ||
| 314 | } | ||
| 315 | } | ||
| 316 | |||
| 317 | if !spinning && view.phase == SlotsPhase::Result { | ||
| 318 | paint_win_lines(painter, origin, tile, gap, view); | ||
| 319 | } | ||
| 320 | |||
| 321 | ui.add_space(theme::SPACE_5); | ||
| 322 | ui.horizontal(|ui| { | ||
| 323 | ui.label(theme::label("Result")); | ||
| 324 | if spinning { | ||
| 325 | ui.label( | ||
| 326 | eframe::egui::RichText::new("Spinning…") | ||
| 327 | .font(theme::mono(12.0)) | ||
| 328 | .color(theme::GOLD), | ||
| 329 | ); | ||
| 330 | } else if view.phase == SlotsPhase::Result { | ||
| 331 | let winnings = view.winnings.unwrap_or(0); | ||
| 332 | if winnings == 0 { | ||
| 333 | ui.label( | ||
| 334 | eframe::egui::RichText::new("No win") | ||
| 335 | .font(theme::mono(12.0)) | ||
| 336 | .color(theme::TEXT_DIM), | ||
| 337 | ); | ||
| 338 | } else { | ||
| 339 | let label = match view.win_type.as_str() { | ||
| 340 | "mega" => "MEGA WIN", | ||
| 341 | "big" => "BIG WIN", | ||
| 342 | _ => "WIN", | ||
| 343 | }; | ||
| 344 | ui.label( | ||
| 345 | eframe::egui::RichText::new(format!( | ||
| 346 | "{label} +{}", | ||
| 347 | theme::money(winnings as i64) | ||
| 348 | )) | ||
| 349 | .font(theme::mono(12.0)) | ||
| 350 | .color(theme::WIN), | ||
| 351 | ); | ||
| 352 | } | ||
| 353 | } else { | ||
| 354 | ui.label( | ||
| 355 | eframe::egui::RichText::new("Ready") | ||
| 356 | .font(theme::mono(12.0)) | ||
| 357 | .color(theme::TEXT_DIM), | ||
| 358 | ); | ||
| 359 | } | ||
| 360 | }); | ||
| 361 | } | ||
| 362 | |||
| 363 | fn paint_tile( | ||
| 364 | painter: &Painter, | ||
| 365 | rect: Rect, | ||
| 366 | symbol: char, | ||
| 367 | spinning: bool, | ||
| 368 | winning: bool, | ||
| 369 | placeholder: bool, | ||
| 370 | ) { | ||
| 371 | let fill = if winning { | ||
| 372 | theme::GOLD.gamma_multiply(0.14) | ||
| 373 | } else { | ||
| 374 | theme::SURFACE | ||
| 375 | }; | ||
| 376 | painter.rect_filled(rect, theme::RADIUS_MD, fill); | ||
| 377 | let stroke = if winning { | ||
| 378 | Stroke::new(2.0, theme::GOLD) | ||
| 379 | } else { | ||
| 380 | Stroke::new(1.0, theme::LINE) | ||
| 381 | }; | ||
| 382 | painter.rect_stroke(rect, theme::RADIUS_MD, stroke, StrokeKind::Inside); | ||
| 383 | |||
| 384 | let color = if spinning || placeholder { | ||
| 385 | symbol_color(symbol).gamma_multiply(0.35) | ||
| 386 | } else { | ||
| 387 | symbol_color(symbol) | ||
| 388 | }; | ||
| 389 | painter.text( | ||
| 390 | rect.center(), | ||
| 391 | Align2::CENTER_CENTER, | ||
| 392 | symbol, | ||
| 393 | theme::mono(rect.width() * 0.42), | ||
| 394 | color, | ||
| 395 | ); | ||
| 396 | } | ||
| 397 | |||
| 398 | fn paint_win_lines(painter: &Painter, origin: Pos2, tile: Vec2, gap: f32, view: &SlotsView) { | ||
| 399 | for line in &view.win_lines { | ||
| 400 | let columns = winning_columns(view, *line); | ||
| 401 | if columns.len() < 2 { | ||
| 402 | continue; | ||
| 403 | } | ||
| 404 | let points: Vec<Pos2> = columns | ||
| 405 | .iter() | ||
| 406 | .map(|col| { | ||
| 407 | let row = PAYLINES[*line - 1][*col]; | ||
| 408 | Rect::from_min_size( | ||
| 409 | origin + vec2(*col as f32 * (tile.x + gap), row as f32 * (tile.y + gap)), | ||
| 410 | tile, | ||
| 411 | ) | ||
| 412 | .center() | ||
| 413 | }) | ||
| 414 | .collect(); | ||
| 415 | painter.add(eframe::egui::Shape::line( | ||
| 416 | points, | ||
| 417 | Stroke::new(2.0, theme::GOLD.gamma_multiply(0.8)), | ||
| 418 | )); | ||
| 419 | } | ||
| 420 | } | ||
| 421 | |||
| 422 | /// The reels that actually matched on a payline. | ||
| 423 | /// | ||
| 424 | /// A win is a prefix of matching symbols from reel 1, so the line must stop | ||
| 425 | /// at the last winning cell instead of crossing all five reels. | ||
| 426 | pub fn winning_columns(view: &SlotsView, line: usize) -> Vec<usize> { | ||
| 427 | let Some(rows) = line.checked_sub(1).and_then(|index| PAYLINES.get(index)) else { | ||
| 428 | return Vec::new(); | ||
| 429 | }; | ||
| 430 | rows.iter() | ||
| 431 | .enumerate() | ||
| 432 | .filter(|(col, row)| view.win_cells.contains(&(col * ROWS + *row))) | ||
| 433 | .map(|(col, _)| col) | ||
| 434 | .collect() | ||
| 435 | } | ||
| 436 | |||
| 437 | pub fn symbol_color(symbol: char) -> Color32 { | ||
| 438 | match symbol { | ||
| 439 | 'C' => Color32::from_rgb(0xe0, 0x5a, 0x5a), | ||
| 440 | 'L' => Color32::from_rgb(0xe0, 0xb6, 0x4a), | ||
| 441 | 'O' => Color32::from_rgb(0xe0, 0x8a, 0x3c), | ||
| 442 | 'G' => Color32::from_rgb(0xb5, 0x7e, 0xdc), | ||
| 443 | 'D' => Color32::from_rgb(0x4f, 0xb8, 0xc9), | ||
| 444 | 'S' => Color32::from_rgb(0x4e, 0xc2, 0x7b), | ||
| 445 | _ => theme::TEXT, | ||
| 446 | } | ||
| 447 | } | ||
diff --git a/gui/src/widgets.rs b/gui/src/widgets.rs new file mode 100644 index 0000000..f7d1512 --- /dev/null +++ b/gui/src/widgets.rs | |||
| @@ -0,0 +1,255 @@ | |||
| 1 | //! Small shared controls. Every button and panel comes from here so the | ||
| 2 | //! screens cannot drift apart. | ||
| 3 | |||
| 4 | use eframe::egui::{ | ||
| 5 | Align, Button, Color32, Frame, Layout, Margin, RichText, Sense, Stroke, Ui, Vec2, vec2, | ||
| 6 | }; | ||
| 7 | |||
| 8 | use crate::theme; | ||
| 9 | |||
| 10 | pub fn panel_frame() -> Frame { | ||
| 11 | Frame::new() | ||
| 12 | .fill(theme::SURFACE) | ||
| 13 | .corner_radius(theme::RADIUS_LG) | ||
| 14 | .inner_margin(Margin::same(theme::SPACE_4 as i8)) | ||
| 15 | } | ||
| 16 | |||
| 17 | pub fn inset_frame() -> Frame { | ||
| 18 | Frame::new() | ||
| 19 | .fill(theme::SURFACE_RAISED) | ||
| 20 | .corner_radius(theme::RADIUS_MD) | ||
| 21 | .inner_margin(Margin::same(theme::SPACE_3 as i8)) | ||
| 22 | } | ||
| 23 | |||
| 24 | /// The one green action on a screen. | ||
| 25 | pub fn primary(ui: &mut Ui, text: &str, enabled: bool, width: f32, height: f32) -> bool { | ||
| 26 | let fill = if enabled { | ||
| 27 | theme::WIN | ||
| 28 | } else { | ||
| 29 | theme::WIN.gamma_multiply(0.35) | ||
| 30 | }; | ||
| 31 | let response = ui.add_enabled( | ||
| 32 | enabled, | ||
| 33 | Button::new(RichText::new(text).font(theme::mono(13.0)).color(theme::BG)) | ||
| 34 | .fill(fill) | ||
| 35 | .corner_radius(theme::RADIUS_MD) | ||
| 36 | .min_size(vec2(width, height)), | ||
| 37 | ); | ||
| 38 | if response.hovered() { | ||
| 39 | ui.painter().rect_filled( | ||
| 40 | response.rect, | ||
| 41 | theme::RADIUS_MD, | ||
| 42 | Color32::WHITE.gamma_multiply(0.10), | ||
| 43 | ); | ||
| 44 | ui.ctx() | ||
| 45 | .set_cursor_icon(eframe::egui::CursorIcon::PointingHand); | ||
| 46 | } | ||
| 47 | response.clicked() | ||
| 48 | } | ||
| 49 | |||
| 50 | /// A bordered action that is not the main verb. | ||
| 51 | pub fn secondary(ui: &mut Ui, text: &str, enabled: bool, width: f32, height: f32) -> bool { | ||
| 52 | let stroke = if enabled { | ||
| 53 | theme::LINE | ||
| 54 | } else { | ||
| 55 | theme::LINE.gamma_multiply(0.5) | ||
| 56 | }; | ||
| 57 | let response = ui.add_enabled( | ||
| 58 | enabled, | ||
| 59 | Button::new( | ||
| 60 | RichText::new(text) | ||
| 61 | .font(theme::mono(12.0)) | ||
| 62 | .color(theme::TEXT_DIM), | ||
| 63 | ) | ||
| 64 | .fill(theme::SURFACE_RAISED) | ||
| 65 | .stroke(Stroke::new(1.0, stroke)) | ||
| 66 | .corner_radius(theme::RADIUS_MD) | ||
| 67 | .min_size(vec2(width, height)), | ||
| 68 | ); | ||
| 69 | if response.hovered() { | ||
| 70 | ui.painter().rect_filled( | ||
| 71 | response.rect, | ||
| 72 | theme::RADIUS_MD, | ||
| 73 | Color32::WHITE.gamma_multiply(0.06), | ||
| 74 | ); | ||
| 75 | ui.ctx() | ||
| 76 | .set_cursor_icon(eframe::egui::CursorIcon::PointingHand); | ||
| 77 | } | ||
| 78 | response.clicked() | ||
| 79 | } | ||
| 80 | |||
| 81 | /// A quiet text action, for "back" and "forget". | ||
| 82 | pub fn ghost(ui: &mut Ui, text: &str) -> bool { | ||
| 83 | let response = ui.add( | ||
| 84 | Button::new( | ||
| 85 | RichText::new(text) | ||
| 86 | .font(theme::sans(12.0)) | ||
| 87 | .color(theme::TEXT_DIM), | ||
| 88 | ) | ||
| 89 | .frame(false), | ||
| 90 | ); | ||
| 91 | if response.hovered() { | ||
| 92 | ui.painter().rect_filled( | ||
| 93 | response.rect.expand(2.0), | ||
| 94 | theme::RADIUS_SM, | ||
| 95 | Color32::WHITE.gamma_multiply(0.05), | ||
| 96 | ); | ||
| 97 | ui.ctx() | ||
| 98 | .set_cursor_icon(eframe::egui::CursorIcon::PointingHand); | ||
| 99 | } | ||
| 100 | response.clicked() | ||
| 101 | } | ||
| 102 | |||
| 103 | /// Split a game sidebar into a flexible content area and a pinned action bar, | ||
| 104 | /// so the primary action never moves as the bet slip grows. | ||
| 105 | pub fn sidebar_layout(ui: &Ui, actions_height: f32) -> (eframe::egui::Rect, eframe::egui::Rect) { | ||
| 106 | let full = ui.max_rect(); | ||
| 107 | let actions_top = (full.bottom() - actions_height).max(full.top()); | ||
| 108 | let actions = eframe::egui::Rect::from_min_max( | ||
| 109 | eframe::egui::pos2(full.left(), actions_top), | ||
| 110 | full.right_bottom(), | ||
| 111 | ); | ||
| 112 | let content = eframe::egui::Rect::from_min_max( | ||
| 113 | full.min, | ||
| 114 | eframe::egui::pos2(full.right(), (actions_top - theme::SPACE_3).max(full.top())), | ||
| 115 | ); | ||
| 116 | (content, actions) | ||
| 117 | } | ||
| 118 | |||
| 119 | pub fn square_button(ui: &mut Ui, text: &str, enabled: bool) -> bool { | ||
| 120 | ui.add_enabled( | ||
| 121 | enabled, | ||
| 122 | Button::new( | ||
| 123 | RichText::new(text) | ||
| 124 | .font(theme::mono(16.0)) | ||
| 125 | .color(theme::TEXT), | ||
| 126 | ) | ||
| 127 | .fill(theme::SURFACE_RAISED) | ||
| 128 | .stroke(Stroke::new(1.0, theme::LINE)) | ||
| 129 | .corner_radius(theme::RADIUS_MD) | ||
| 130 | .min_size(Vec2::splat(34.0)), | ||
| 131 | ) | ||
| 132 | .clicked() | ||
| 133 | } | ||
| 134 | |||
| 135 | /// `- [ 50 Kr ] +`; returns the change in the bet. | ||
| 136 | pub fn bet_control(ui: &mut Ui, bet: u64, enabled: bool) -> Option<i64> { | ||
| 137 | let mut delta = None; | ||
| 138 | ui.horizontal(|ui| { | ||
| 139 | if square_button(ui, "−", enabled) { | ||
| 140 | delta = Some(-10); | ||
| 141 | } | ||
| 142 | Frame::new() | ||
| 143 | .fill(theme::BG) | ||
| 144 | .corner_radius(theme::RADIUS_SM) | ||
| 145 | .inner_margin(Margin::symmetric( | ||
| 146 | theme::SPACE_3 as i8, | ||
| 147 | theme::SPACE_2 as i8, | ||
| 148 | )) | ||
| 149 | .show(ui, |ui| { | ||
| 150 | ui.label( | ||
| 151 | RichText::new(theme::money(bet as i64)) | ||
| 152 | .font(theme::mono(15.0)) | ||
| 153 | .color(theme::TEXT), | ||
| 154 | ); | ||
| 155 | }); | ||
| 156 | if square_button(ui, "+", enabled) { | ||
| 157 | delta = Some(10); | ||
| 158 | } | ||
| 159 | }); | ||
| 160 | delta | ||
| 161 | } | ||
| 162 | |||
| 163 | /// Label on the left, value on the right, for slips and summaries. | ||
| 164 | pub fn row(ui: &mut Ui, label: &str, value: &str, color: Color32) { | ||
| 165 | ui.horizontal(|ui| { | ||
| 166 | ui.label( | ||
| 167 | RichText::new(label) | ||
| 168 | .font(theme::sans(13.0)) | ||
| 169 | .color(theme::TEXT_DIM), | ||
| 170 | ); | ||
| 171 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 172 | ui.label(RichText::new(value).font(theme::mono(13.0)).color(color)); | ||
| 173 | }); | ||
| 174 | }); | ||
| 175 | } | ||
| 176 | |||
| 177 | pub fn note(ui: &mut Ui, text: &str) { | ||
| 178 | ui.label( | ||
| 179 | RichText::new(text) | ||
| 180 | .font(theme::sans(12.0)) | ||
| 181 | .color(theme::TEXT_DIM), | ||
| 182 | ); | ||
| 183 | } | ||
| 184 | |||
| 185 | pub fn error(ui: &mut Ui, text: &str) { | ||
| 186 | Frame::new() | ||
| 187 | .fill(theme::LOSS.gamma_multiply(0.12)) | ||
| 188 | .corner_radius(theme::RADIUS_SM) | ||
| 189 | .inner_margin(Margin::symmetric( | ||
| 190 | theme::SPACE_3 as i8, | ||
| 191 | theme::SPACE_2 as i8, | ||
| 192 | )) | ||
| 193 | .show(ui, |ui| { | ||
| 194 | ui.label( | ||
| 195 | RichText::new(text) | ||
| 196 | .font(theme::sans(12.0)) | ||
| 197 | .color(theme::LOSS), | ||
| 198 | ); | ||
| 199 | }); | ||
| 200 | } | ||
| 201 | |||
| 202 | /// A monospaced label that can be selected (fingerprints, addresses, URLs). | ||
| 203 | pub fn mono_text(ui: &mut Ui, text: &str, color: Color32) { | ||
| 204 | ui.add( | ||
| 205 | eframe::egui::Label::new( | ||
| 206 | RichText::new(text) | ||
| 207 | .font(theme::mono(theme::FONT_SMALL)) | ||
| 208 | .color(color), | ||
| 209 | ) | ||
| 210 | .selectable(true), | ||
| 211 | ); | ||
| 212 | } | ||
| 213 | |||
| 214 | pub fn chip_button(ui: &mut Ui, amount: u64, selected: bool, enabled: bool) -> bool { | ||
| 215 | let sense = if enabled { | ||
| 216 | Sense::click() | ||
| 217 | } else { | ||
| 218 | Sense::hover() | ||
| 219 | }; | ||
| 220 | let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), sense); | ||
| 221 | let center = rect.center(); | ||
| 222 | let radius = 20.0; | ||
| 223 | let painter = ui.painter(); | ||
| 224 | if selected || response.hovered() { | ||
| 225 | painter.circle_stroke( | ||
| 226 | center, | ||
| 227 | radius + 3.0, | ||
| 228 | Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT), | ||
| 229 | ); | ||
| 230 | } | ||
| 231 | if enabled { | ||
| 232 | super::roulette::paint_chip(painter, center, radius, amount); | ||
| 233 | } else { | ||
| 234 | super::roulette::paint_chip_preview(painter, center, radius, amount); | ||
| 235 | } | ||
| 236 | response.clicked() | ||
| 237 | } | ||
| 238 | |||
| 239 | pub fn chip_row<const N: usize>( | ||
| 240 | ui: &mut Ui, | ||
| 241 | amounts: [u64; N], | ||
| 242 | selected: u64, | ||
| 243 | enabled: bool, | ||
| 244 | ) -> Option<u64> { | ||
| 245 | let mut picked = None; | ||
| 246 | ui.horizontal(|ui| { | ||
| 247 | ui.spacing_mut().item_spacing.x = theme::SPACE_2; | ||
| 248 | for amount in amounts { | ||
| 249 | if chip_button(ui, amount, selected == amount, enabled) { | ||
| 250 | picked = Some(amount); | ||
| 251 | } | ||
| 252 | } | ||
| 253 | }); | ||
| 254 | picked | ||
| 255 | } | ||
diff --git a/gui/tests/layout.rs b/gui/tests/layout.rs index ab5a62e..5678665 100644 --- a/gui/tests/layout.rs +++ b/gui/tests/layout.rs | |||
| @@ -5,8 +5,10 @@ | |||
| 5 | use std::f32::consts::TAU; | 5 | use std::f32::consts::TAU; |
| 6 | 6 | ||
| 7 | use eframe::egui::{Pos2, Rect, pos2, vec2}; | 7 | use eframe::egui::{Pos2, Rect, pos2, vec2}; |
| 8 | use zagan_bet::client::view::{SlotsPhase, SlotsView}; | ||
| 8 | use zagan_bet::roulette::{BetTarget, Outside}; | 9 | use zagan_bet::roulette::{BetTarget, Outside}; |
| 9 | use zagan_bet_gui::roulette::{Felt, number_under, size_for, target_name}; | 10 | use zagan_bet_gui::roulette::{Felt, number_under, size_for, target_name}; |
| 11 | use zagan_bet_gui::slots::winning_columns; | ||
| 10 | use zagan_bet_gui::theme::{group_digits, money}; | 12 | use zagan_bet_gui::theme::{group_digits, money}; |
| 11 | 13 | ||
| 12 | #[test] | 14 | #[test] |
| @@ -154,3 +156,36 @@ fn targets_read_as_bet_slips() { | |||
| 154 | "Black" | 156 | "Black" |
| 155 | ); | 157 | ); |
| 156 | } | 158 | } |
| 159 | |||
| 160 | fn slots_view(win_cells: Vec<usize>, win_lines: Vec<usize>) -> SlotsView { | ||
| 161 | SlotsView { | ||
| 162 | bet: 10, | ||
| 163 | balance: Some(1000), | ||
| 164 | reels: "OOOOODDDDDOOOOOD".chars().collect(), | ||
| 165 | win_cells, | ||
| 166 | win_lines, | ||
| 167 | win_type: "win".to_string(), | ||
| 168 | winnings: Some(30), | ||
| 169 | phase: SlotsPhase::Result, | ||
| 170 | error: None, | ||
| 171 | } | ||
| 172 | } | ||
| 173 | |||
| 174 | #[test] | ||
| 175 | fn a_payline_stops_at_the_last_matching_reel() { | ||
| 176 | // Middle line (line 2), four Os from reel 0: cells 1, 4, 7, 10. | ||
| 177 | let view = slots_view(vec![1, 4, 7, 10], vec![2]); | ||
| 178 | assert_eq!(winning_columns(&view, 2), vec![0, 1, 2, 3]); | ||
| 179 | |||
| 180 | // Three of a kind stops after reel 2. | ||
| 181 | let view = slots_view(vec![1, 4, 7], vec![2]); | ||
| 182 | assert_eq!(winning_columns(&view, 2), vec![0, 1, 2]); | ||
| 183 | |||
| 184 | // Cells on another line do not extend this one. | ||
| 185 | let view = slots_view(vec![0, 1, 4, 7], vec![2]); | ||
| 186 | assert_eq!(winning_columns(&view, 2), vec![0, 1, 2]); | ||
| 187 | |||
| 188 | // A line number that does not exist draws nothing. | ||
| 189 | let view = slots_view(vec![1, 4, 7], vec![9]); | ||
| 190 | assert!(winning_columns(&view, 9).is_empty()); | ||
| 191 | } | ||
diff --git a/src/client/core.rs b/src/client/core.rs index 21c1cb2..6d8294c 100644 --- a/src/client/core.rs +++ b/src/client/core.rs | |||
| @@ -62,6 +62,8 @@ pub struct ClientCore { | |||
| 62 | logged_in: Option<String>, | 62 | logged_in: Option<String>, |
| 63 | pending: Option<Pending>, | 63 | pending: Option<Pending>, |
| 64 | balance: Option<i64>, | 64 | balance: Option<i64>, |
| 65 | /// Account number from the last `INFO`, shown by both frontends. | ||
| 66 | account: Option<String>, | ||
| 65 | host: Option<String>, | 67 | host: Option<String>, |
| 66 | fingerprint: Option<String>, | 68 | fingerprint: Option<String>, |
| 67 | /// Local anchor for the crash multiplier, set when a round starts. | 69 | /// Local anchor for the crash multiplier, set when a round starts. |
| @@ -82,6 +84,7 @@ impl ClientCore { | |||
| 82 | logged_in: None, | 84 | logged_in: None, |
| 83 | pending: None, | 85 | pending: None, |
| 84 | balance: None, | 86 | balance: None, |
| 87 | account: None, | ||
| 85 | host: None, | 88 | host: None, |
| 86 | fingerprint: None, | 89 | fingerprint: None, |
| 87 | crash_started: None, | 90 | crash_started: None, |
| @@ -275,15 +278,18 @@ impl ClientCore { | |||
| 275 | } | 278 | } |
| 276 | Some(Pending::Echo) => print_line(format!("Echo: {}", reply.trim())), | 279 | Some(Pending::Echo) => print_line(format!("Echo: {}", reply.trim())), |
| 277 | Some(Pending::Info) => { | 280 | Some(Pending::Info) => { |
| 281 | // The same reply feeds the balance and the account number. | ||
| 282 | let balance = info_balance(reply); | ||
| 283 | if let Some(account) = info_account(reply) { | ||
| 284 | self.account = Some(account); | ||
| 285 | } | ||
| 286 | if let Some(balance) = balance { | ||
| 287 | self.balance = Some(balance); | ||
| 288 | self.game.set_balance(balance); | ||
| 289 | } | ||
| 278 | if self.game.is_in_game() { | 290 | if self.game.is_in_game() { |
| 279 | // Balance fetch while entering a game. A failure is not | 291 | // A failure is not fatal: the board keeps showing `?`. |
| 280 | // fatal: the board keeps showing `?`. | 292 | self.board_effects() |
| 281 | if let Some(balance) = info_balance(reply) { | ||
| 282 | self.balance = Some(balance); | ||
| 283 | self.game.set_balance(balance); | ||
| 284 | return self.board_effects(); | ||
| 285 | } | ||
| 286 | Vec::new() | ||
| 287 | } else { | 293 | } else { |
| 288 | vec![Effect::Print(info_lines(reply))] | 294 | vec![Effect::Print(info_lines(reply))] |
| 289 | } | 295 | } |
| @@ -405,6 +411,7 @@ impl ClientCore { | |||
| 405 | None => Vec::new(), | 411 | None => Vec::new(), |
| 406 | }, | 412 | }, |
| 407 | Command::PlaceBetAt(target) => self.place_roulette_chip_on(target), | 413 | Command::PlaceBetAt(target) => self.place_roulette_chip_on(target), |
| 414 | Command::RemoveBetAt(target) => self.remove_roulette_chip_on(target), | ||
| 408 | Command::ClearBets => { | 415 | Command::ClearBets => { |
| 409 | self.game.set_roulette_bets(&Bets::default()); | 416 | self.game.set_roulette_bets(&Bets::default()); |
| 410 | self.game.set_roulette_status("place"); | 417 | self.game.set_roulette_status("place"); |
| @@ -489,6 +496,12 @@ impl ClientCore { | |||
| 489 | self.place_roulette_bets(bets) | 496 | self.place_roulette_bets(bets) |
| 490 | } | 497 | } |
| 491 | 498 | ||
| 499 | fn remove_roulette_chip_on(&mut self, target: BetTarget) -> Vec<Effect> { | ||
| 500 | let mut bets = self.game.roulette_bets(); | ||
| 501 | bets.reduce(&target, self.game.roulette_chip()); | ||
| 502 | self.place_roulette_bets(bets) | ||
| 503 | } | ||
| 504 | |||
| 492 | fn place_roulette_bets(&mut self, bets: Bets) -> Vec<Effect> { | 505 | fn place_roulette_bets(&mut self, bets: Bets) -> Vec<Effect> { |
| 493 | if let Some(balance) = self.balance { | 506 | if let Some(balance) = self.balance { |
| 494 | if bets.total() > balance.max(0) as u64 { | 507 | if bets.total() > balance.max(0) as u64 { |
| @@ -782,6 +795,7 @@ impl ClientCore { | |||
| 782 | Screen::Lobby => View::Lobby(LobbyView { | 795 | Screen::Lobby => View::Lobby(LobbyView { |
| 783 | connected: self.connected, | 796 | connected: self.connected, |
| 784 | user: self.logged_in.clone(), | 797 | user: self.logged_in.clone(), |
| 798 | account: self.account.clone(), | ||
| 785 | balance: self.balance, | 799 | balance: self.balance, |
| 786 | games: self.games(), | 800 | games: self.games(), |
| 787 | login_error: self.login_error.clone(), | 801 | login_error: self.login_error.clone(), |
| @@ -824,6 +838,15 @@ fn print_line(message: impl Into<String>) -> Vec<Effect> { | |||
| 824 | vec![Effect::Print(vec![message.into()])] | 838 | vec![Effect::Print(vec![message.into()])] |
| 825 | } | 839 | } |
| 826 | 840 | ||
| 841 | fn info_account(reply: &str) -> Option<String> { | ||
| 842 | let parts: Vec<&str> = reply.split_whitespace().collect(); | ||
| 843 | if parts.first() == Some(&"INFO") && parts.len() == 4 { | ||
| 844 | Some(parts[2].to_string()).filter(|account| account != "-") | ||
| 845 | } else { | ||
| 846 | None | ||
| 847 | } | ||
| 848 | } | ||
| 849 | |||
| 827 | fn info_balance(reply: &str) -> Option<i64> { | 850 | fn info_balance(reply: &str) -> Option<i64> { |
| 828 | let parts: Vec<&str> = reply.split_whitespace().collect(); | 851 | let parts: Vec<&str> = reply.split_whitespace().collect(); |
| 829 | if parts.first() == Some(&"INFO") && parts.len() == 4 { | 852 | if parts.first() == Some(&"INFO") && parts.len() == 4 { |
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs index bb2a8b5..24ceeaa 100644 --- a/src/client/games/manager.rs +++ b/src/client/games/manager.rs | |||
| @@ -353,7 +353,7 @@ impl GameManager { | |||
| 353 | .insert("balance".to_string(), state.balance.to_string()); | 353 | .insert("balance".to_string(), state.balance.to_string()); |
| 354 | session | 354 | session |
| 355 | .data | 355 | .data |
| 356 | .insert("multiplier".to_string(), format!("{:.2}", state.multiplier)); | 356 | .insert("multiplier".to_string(), state.multiplier.to_string()); |
| 357 | session | 357 | session |
| 358 | .data | 358 | .data |
| 359 | .insert("potential".to_string(), state.potential.to_string()); | 359 | .insert("potential".to_string(), state.potential.to_string()); |
| @@ -373,9 +373,11 @@ impl GameManager { | |||
| 373 | return; | 373 | return; |
| 374 | }; | 374 | }; |
| 375 | let bet = session.bet_amount; | 375 | let bet = session.bet_amount; |
| 376 | // Keep the full value: the terminal rounds for display, but the GUI | ||
| 377 | // charts it and must not draw a staircase. | ||
| 376 | session | 378 | session |
| 377 | .data | 379 | .data |
| 378 | .insert("multiplier".to_string(), format!("{multiplier:.2}")); | 380 | .insert("multiplier".to_string(), multiplier.to_string()); |
| 379 | session | 381 | session |
| 380 | .data | 382 | .data |
| 381 | .insert("potential".to_string(), payout(bet, multiplier).to_string()); | 383 | .insert("potential".to_string(), payout(bet, multiplier).to_string()); |
diff --git a/src/client/view.rs b/src/client/view.rs index d2ccadb..4615720 100644 --- a/src/client/view.rs +++ b/src/client/view.rs | |||
| @@ -246,6 +246,7 @@ impl CrashView { | |||
| 246 | pub struct LobbyView { | 246 | pub struct LobbyView { |
| 247 | pub connected: bool, | 247 | pub connected: bool, |
| 248 | pub user: Option<String>, | 248 | pub user: Option<String>, |
| 249 | pub account: Option<String>, | ||
| 249 | pub balance: Option<i64>, | 250 | pub balance: Option<i64>, |
| 250 | pub games: Vec<(String, String)>, | 251 | pub games: Vec<(String, String)>, |
| 251 | /// Why the last login or registration attempt failed. | 252 | /// Why the last login or registration attempt failed. |
| @@ -297,6 +298,7 @@ pub enum Command { | |||
| 297 | MoveCursor(Direction), | 298 | MoveCursor(Direction), |
| 298 | PlaceBet, | 299 | PlaceBet, |
| 299 | PlaceBetAt(BetTarget), | 300 | PlaceBetAt(BetTarget), |
| 301 | RemoveBetAt(BetTarget), | ||
| 300 | ClearBets, | 302 | ClearBets, |
| 301 | SetChip(u64), | 303 | SetChip(u64), |
| 302 | AdjustChip(i64), | 304 | AdjustChip(i64), |
diff --git a/tests/client_core.rs b/tests/client_core.rs index 49abca2..2f43d47 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs | |||
| @@ -17,7 +17,7 @@ use zagan_bet::client::games::roulette::{Cursor, Direction}; | |||
| 17 | use zagan_bet::client::ui::command_names; | 17 | use zagan_bet::client::ui::command_names; |
| 18 | use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View}; | 18 | use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View}; |
| 19 | use zagan_bet::roulette::BetTarget; | 19 | use zagan_bet::roulette::BetTarget; |
| 20 | use zagan_bet::crash::CrashState; | 20 | use zagan_bet::crash::{CrashState, multiplier_at}; |
| 21 | 21 | ||
| 22 | const WIN3: &str = "CCCCLLCLODGSDGS"; | 22 | const WIN3: &str = "CCCCLLCLODGSDGS"; |
| 23 | const LOSS: &str = "CLOOGDSLCDCOGLS"; | 23 | const LOSS: &str = "CLOOGDSLCDCOGLS"; |
| @@ -538,6 +538,45 @@ fn info_shows_the_host_and_the_server_fields() { | |||
| 538 | assert!(h.screen.lines.iter().any(|line| line == "User: alice")); | 538 | assert!(h.screen.lines.iter().any(|line| line == "User: alice")); |
| 539 | assert!(h.screen.lines.iter().any(|line| line == "Account: 42")); | 539 | assert!(h.screen.lines.iter().any(|line| line == "Account: 42")); |
| 540 | assert!(h.screen.lines.iter().any(|line| line == "Balance: 1234 Kr")); | 540 | assert!(h.screen.lines.iter().any(|line| line == "Balance: 1234 Kr")); |
| 541 | |||
| 542 | let Some(View::Lobby(lobby)) = h.core.view() else { | ||
| 543 | panic!("the lobby view is always available"); | ||
| 544 | }; | ||
| 545 | assert_eq!(lobby.account.as_deref(), Some("42")); | ||
| 546 | assert_eq!(lobby.balance, Some(1234)); | ||
| 547 | } | ||
| 548 | |||
| 549 | #[test] | ||
| 550 | fn a_roulette_chip_can_be_taken_back_through_a_command() { | ||
| 551 | let mut h = Harness::new(); | ||
| 552 | h.enter_roulette(); | ||
| 553 | h.core.command(Command::SetChip(25)); | ||
| 554 | |||
| 555 | h.core | ||
| 556 | .command(Command::PlaceBetAt(BetTarget::Straight { number: 17 })); | ||
| 557 | h.core | ||
| 558 | .command(Command::PlaceBetAt(BetTarget::Straight { number: 17 })); | ||
| 559 | let Some(View::Roulette(view)) = h.core.view() else { | ||
| 560 | panic!("roulette is open"); | ||
| 561 | }; | ||
| 562 | assert_eq!(view.bets.total(), 50); | ||
| 563 | |||
| 564 | h.core | ||
| 565 | .command(Command::RemoveBetAt(BetTarget::Straight { number: 17 })); | ||
| 566 | let Some(View::Roulette(view)) = h.core.view() else { | ||
| 567 | panic!("roulette is open"); | ||
| 568 | }; | ||
| 569 | assert_eq!(view.bets.total(), 25); | ||
| 570 | assert_eq!(view.phase, RoulettePhase::Placing); | ||
| 571 | |||
| 572 | h.core | ||
| 573 | .command(Command::RemoveBetAt(BetTarget::Straight { number: 17 })); | ||
| 574 | h.core | ||
| 575 | .command(Command::RemoveBetAt(BetTarget::Straight { number: 17 })); | ||
| 576 | let Some(View::Roulette(view)) = h.core.view() else { | ||
| 577 | panic!("roulette is open"); | ||
| 578 | }; | ||
| 579 | assert_eq!(view.bets.total(), 0); | ||
| 541 | } | 580 | } |
| 542 | 581 | ||
| 543 | #[test] | 582 | #[test] |
| @@ -1581,3 +1620,29 @@ fn login_requires_a_connection() { | |||
| 1581 | 1620 | ||
| 1582 | assert!(!h.sent.iter().any(|line| line.starts_with("AUTH"))); | 1621 | assert!(!h.sent.iter().any(|line| line.starts_with("AUTH"))); |
| 1583 | } | 1622 | } |
| 1623 | |||
| 1624 | #[test] | ||
| 1625 | fn the_crash_multiplier_is_not_rounded_for_the_chart() { | ||
| 1626 | let mut h = Harness::new(); | ||
| 1627 | h.enter_crash(); | ||
| 1628 | h.key(Key::Char(' ')); | ||
| 1629 | h.reply(&crash_line( | ||
| 1630 | "CRASH_START", | ||
| 1631 | &crash_state("running", 1.0, None, 0, 990), | ||
| 1632 | )); | ||
| 1633 | |||
| 1634 | h.tick(Duration::from_millis(1234)); | ||
| 1635 | let expected = multiplier_at(Duration::from_millis(1234)); | ||
| 1636 | let Some(View::Crash(view)) = h.core.view() else { | ||
| 1637 | panic!("expected a crash view"); | ||
| 1638 | }; | ||
| 1639 | assert!( | ||
| 1640 | (view.multiplier - expected).abs() < 1e-9, | ||
| 1641 | "the GUI chart needs the full precision value" | ||
| 1642 | ); | ||
| 1643 | let rounded = (expected * 100.0).round() / 100.0; | ||
| 1644 | assert!( | ||
| 1645 | (view.multiplier - rounded).abs() > 1e-6, | ||
| 1646 | "a two-decimal store would flatten the curve into steps" | ||
| 1647 | ); | ||
| 1648 | } | ||
