From bf4cf78ab26896faf457273e37d30fc96381c4a4 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 21:09:56 +0100 Subject: 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. --- gui/src/app.rs | 1963 +++++++++++++++++++++++++++++++++++++------------- gui/src/blackjack.rs | 428 +++++++++++ gui/src/crash.rs | 416 +++++++++++ gui/src/lib.rs | 5 + gui/src/main.rs | 38 +- gui/src/net.rs | 220 ++++++ gui/src/roulette.rs | 431 +++++++++++ gui/src/slots.rs | 447 ++++++++++++ gui/src/widgets.rs | 255 +++++++ 9 files changed, 3699 insertions(+), 504 deletions(-) create mode 100644 gui/src/blackjack.rs create mode 100644 gui/src/crash.rs create mode 100644 gui/src/net.rs create mode 100644 gui/src/slots.rs create mode 100644 gui/src/widgets.rs (limited to 'gui/src') 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 @@ -//! The desktop shell: header, roulette table, and betting sidebar. -//! -//! Phase 3 is a style spike. The table is painted from a demo `RouletteView` -//! built with the core's own types, but no command is sent yet: Phase 4 wires -//! the transport and replaces the demo state with `ClientCore`. +//! The desktop shell: screens, session state, and the bridge to the core. -use std::f32::consts::TAU; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::mpsc::Sender; +use std::time::{Duration, Instant}; use eframe::egui::{ - self, Align, Button, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel, - RichText, Sense, Stroke, Ui, Vec2, vec2, + self, Align, Align2, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel, + RichText, Sense, Stroke, TextEdit, Ui, Vec2, vec2, }; -use zagan_bet::client::games::roulette::Cursor; -use zagan_bet::client::view::{RoulettePhase, RouletteView}; -use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color}; -use zagan_bet::version::VERSION; +use rusqlite::Connection; +use sha2::{Digest, Sha256}; +use zagan_bet::client::core::{ClientCore, Effect}; +use zagan_bet::client::database; +use zagan_bet::client::paths; +use zagan_bet::client::view::{Command, LobbyView, View}; +use zagan_bet::history::PlayRecord; +use zagan_bet::version::{VERSION, is_newer}; -use crate::roulette::FeltAction; -use crate::{roulette, theme}; +use crate::net::{Command as NetCommand, Connected, Event, Failure, Network}; +use crate::{blackjack, crash, roulette, slots, theme, widgets}; -const HEADER_HEIGHT: f32 = 64.0; -const SIDEBAR_WIDTH: f32 = 336.0; -const SPIN_SECONDS: f64 = 2.4; -/// Demo winners, cycled: a red, a zero, a red, a black, a black, a black. -const DEMO_TARGETS: [u8; 6] = [17, 0, 32, 26, 8, 11]; -const CHIP_SIZES: [u64; 4] = [10, 25, 50, 100]; +/// A request that has no reply after this long kills the connection, matching +/// the terminal adapter's socket timeout. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const NOTICE_SECONDS: u64 = 6; +const DEFAULT_BET: u64 = 10; + +/// Where the app keeps its files and whether TLS is bypassed. +pub struct Startup { + pub data_dir: PathBuf, + pub unsafe_mode: bool, +} pub struct ZaganApp { - demo: Demo, + core: ClientCore, + net: Network, + db: Option, + db_error: Option, + unsafe_mode: bool, + + session: Option, + connect: ConnectState, + login: LoginState, + trust: Option, + key_changed: Option, + leave_confirm: bool, + notices: VecDeque, + awaiting: Option, + need_info: bool, + pending_login: Option<(String, String)>, + /// Displayed balance while a result animation runs: stake deducted, no + /// winnings yet. The view already knows the outcome; the player must not. + balance_hold: Option, + balance: Option, + account: Option, + fx: Fx, +} + +struct Session { + host: String, + fingerprint: Option, + ping_ms: f32, + server_version: String, + latest: String, + url: String, + user: Option, +} + +#[derive(Default)] +struct ConnectState { + addr: String, + error: Option, + version_issue: bool, + pending: bool, +} + +#[derive(Default)] +struct LoginState { + user: String, + password: String, + error: Option, + remember: bool, +} + +impl LoginState { + fn new() -> Self { + Self { + remember: true, + ..Self::default() + } + } +} + +struct TrustState { + addr: String, + fingerprint: String, + reply: Sender, +} + +struct KeyChanged { + addr: String, + expected: String, + observed: String, +} + +struct Notice { + text: String, + at: Instant, + tone: Tone, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Tone { + Info, + Good, + Bad, +} + +#[derive(Default)] +struct Fx { + game: Option, + roulette: roulette::Fx, + slots: slots::Fx, + crash: crash::Fx, +} + +impl Fx { + /// Reset the animation state when the player enters or leaves a game. + fn sync(&mut self, game: Option<&str>) -> bool { + if self.game.as_deref() != game { + let game = game.map(str::to_string); + *self = Fx { + game, + ..Fx::default() + }; + return true; + } + false + } } impl ZaganApp { - pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + pub fn new(cc: &eframe::CreationContext<'_>, startup: Startup) -> Self { theme::install(&cc.egui_ctx); - Self { demo: Demo::new() } + + let db_path = paths::database_path(&startup.data_dir); + let (db, db_error) = match Connection::open(&db_path) { + Ok(conn) => { + let _ = conn.busy_timeout(Duration::from_secs(2)); + database::init_db(&conn); + (Some(conn), None) + } + Err(error) => ( + None, + Some(format!("Cannot open {}: {error}", db_path.display())), + ), + }; + let addr = db + .as_ref() + .and_then(database::get_last_host) + .unwrap_or_else(|| "127.0.0.1:11525".to_string()); + + let net = Network::spawn( + cc.egui_ctx.clone(), + startup.data_dir.clone(), + startup.unsafe_mode, + ); + + Self { + core: ClientCore::new(), + net, + db, + db_error, + unsafe_mode: startup.unsafe_mode, + session: None, + connect: ConnectState { + addr, + ..ConnectState::default() + }, + login: LoginState::new(), + trust: None, + key_changed: None, + leave_confirm: false, + notices: VecDeque::new(), + awaiting: None, + need_info: false, + pending_login: None, + balance_hold: None, + balance: None, + account: None, + fx: Fx::default(), + } + } + + fn screen(&mut self, ui: &mut Ui) { + if let Some(error) = self.db_error.clone() { + fatal(ui, &error); + return; + } + + let view = self.core.view(); + if self.session.is_none() { + connect_screen(ui, self); + return; + } + + match view { + Some(View::Lobby(lobby)) if lobby.user.is_none() => login_screen(ui, self, &lobby), + Some(View::Lobby(lobby)) => lobby_screen(ui, self, &lobby), + Some(View::History(history)) => history_screen(ui, self, &history.records), + Some(View::Slots(view)) => { + let command = slots::show(ui, &view, &mut self.fx.slots); + if command == Some(Command::Spin) { + self.balance_hold = self + .balance + .map(|balance| (balance - view.bet as i64).max(0)); + } + if let Some(command) = command { + self.command(ui.ctx(), command); + } + } + Some(View::Roulette(view)) => { + let command = roulette::show(ui, &view, &mut self.fx.roulette); + if command == Some(Command::Spin) { + self.balance_hold = self + .balance + .map(|balance| (balance - view.bets.total() as i64).max(0)); + } + if let Some(command) = command { + self.command(ui.ctx(), command); + } + } + Some(View::Blackjack(view)) => { + if let Some(command) = blackjack::show(ui, &view) { + self.command(ui.ctx(), command); + } + } + Some(View::Crash(view)) => { + let command = crash::show(ui, &view, &mut self.fx.crash); + if let Some(command) = command { + self.command(ui.ctx(), command); + } + } + None => {} + } + } + + /// Run one core command, holding back a leave that would forfeit a live + /// round until the player confirms. + fn command(&mut self, ctx: &Context, command: Command) { + if command == Command::Leave && self.core.is_live_game() { + self.leave_confirm = true; + return; + } + if command == Command::Logout { + self.need_info = false; + self.account = None; + self.balance = None; + self.pending_login = None; + if let Some(session) = &mut self.session { + session.user = None; + } + } + let effects = self.core.command(command); + self.apply(ctx, effects); + } + + fn apply(&mut self, ctx: &Context, effects: Vec) { + for effect in effects { + match effect { + Effect::Print(lines) => { + // Multi-line prints are terminal tables (history, help, + // info); the GUI draws those from `View` instead. + if lines.len() == 1 { + if let Some(line) = lines.first() { + self.notice(line); + } + } + } + Effect::SendLine(line) => { + self.net.send(NetCommand::Send(line)); + self.awaiting = Some(Instant::now()); + } + Effect::Disconnect => { + self.net.send(NetCommand::Disconnect); + self.core.reset_after_disconnect(); + self.session = None; + self.awaiting = None; + self.need_info = false; + self.account = None; + self.balance = None; + self.balance_hold = None; + } + Effect::ClearRegion | Effect::Render(_) => {} + Effect::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + } + } + } + + fn notice(&mut self, text: &str) { + // `info` is fetched to fill the header; its terminal lines are not + // news to the GUI. + if text.starts_with("Connected to:") + || text.starts_with("Fingerprint:") + || text.starts_with("User:") + || text.starts_with("Account:") + || text.starts_with("Balance:") + { + return; + } + let tone = if text.starts_with("ERROR") + || text.contains("failed") + || text.contains("Failed") + || text.contains("lost") + { + Tone::Bad + } else if text.starts_with("Logged in") + || text.starts_with("Account created") + || text.contains("saved") + { + Tone::Good + } else { + Tone::Info + }; + self.notices.push_back(Notice { + text: text.to_string(), + at: Instant::now(), + tone, + }); + while self.notices.len() > 3 { + self.notices.pop_front(); + } + } + + fn pump(&mut self, ctx: &Context) { + while let Some(event) = self.net.try_recv() { + match event { + Event::Trust { + addr, + fingerprint, + reply, + } => { + self.trust = Some(TrustState { + addr, + fingerprint, + reply, + }); + } + Event::Connected(connected) => self.connected(ctx, connected), + Event::Failed { + addr, + failure, + message, + } => self.failed(addr, failure, message), + Event::Line(line) => { + if self.core.is_async(&line) { + if let Some(effects) = self.core.handle_async_line(&line) { + self.apply(ctx, effects); + } + } else { + self.awaiting = None; + let effects = self.core.handle_reply(&line); + self.apply(ctx, effects); + } + } + Event::Closed => { + self.session = None; + self.awaiting = None; + self.core.reset_after_disconnect(); + self.notice("Connection lost"); + ctx.request_repaint(); + } + } + } + } + + fn connected(&mut self, ctx: &Context, connected: Connected) { + self.connect.pending = false; + self.connect.error = None; + self.core.set_connected(true); + self.core + .set_host_info(Some(connected.addr.clone()), connected.fingerprint.clone()); + self.core.set_logged_in(connected.user.clone()); + self.login.error = connected.auth_error.clone(); + self.login.user = connected.user.clone().unwrap_or_default(); + + if let Some(db) = &self.db { + database::save_host(db, &connected.addr); + } + self.session = Some(Session { + host: connected.addr, + fingerprint: connected.fingerprint, + ping_ms: connected.elapsed_ms, + server_version: connected.server_version, + latest: connected.latest, + url: connected.url, + user: connected.user, + }); + + self.need_info = self.session.as_ref().is_some_and(|s| s.user.is_some()); + self.account = None; + if self.need_info { + let effects = self.core.command(Command::Info); + self.apply(ctx, effects); + self.need_info = false; + } + } + + fn failed(&mut self, addr: String, failure: Failure, message: String) { + self.connect.pending = false; + self.session = None; + self.core.set_connected(false); + self.awaiting = None; + match failure { + Failure::KeyChanged { expected, observed } => { + self.key_changed = Some(KeyChanged { + addr, + expected, + observed, + }); + } + Failure::VersionMismatch => { + self.connect.version_issue = true; + self.connect.error = Some(message); + } + Failure::TrustDeclined => { + self.trust = None; + self.connect.error = Some("Certificate was not trusted.".to_string()); + } + Failure::Timeout => { + self.trust = None; + self.connect.error = Some("Connection timed out.".to_string()); + } + Failure::Other => { + self.trust = None; + self.connect.error = Some(message); + } + } + } + + fn begin_connect(&mut self, addr: &str) { + let addr = normalize_addr(addr); + self.connect.addr = addr.clone(); + self.connect.error = None; + self.connect.version_issue = false; + self.connect.pending = true; + self.login.error = None; + self.net.send(NetCommand::Connect { addr }); + } + + fn disconnect(&mut self) { + self.net.send(NetCommand::Disconnect); + self.core.reset_after_disconnect(); + self.session = None; + self.awaiting = None; + self.trust = None; + self.key_changed = None; + self.need_info = false; + self.account = None; + self.balance = None; + self.balance_hold = None; + } + + fn trust_decision(&mut self, accept: bool) { + if let Some(trust) = self.trust.take() { + let _ = trust.reply.send(accept); + if !accept { + self.connect.error = Some("Certificate was not trusted.".to_string()); + } + } + } + + fn forget_trusted_host(&mut self, addr: &str) { + let Some(db) = &self.db else { return }; + match database::forget_host(db, addr) { + Ok(_) => { + self.key_changed = None; + self.begin_connect(addr); + } + Err(error) => self.connect.error = Some(format!("Failed to forget host: {error}")), + } + } + + fn forget_login(&mut self) { + let Some(fingerprint) = self + .session + .as_ref() + .and_then(|session| session.fingerprint.clone()) + else { + return; + }; + let Some(db) = &self.db else { return }; + match database::forget_credentials(db, &fingerprint) { + Ok(true) => self.notice("Saved login forgotten. Trust is unchanged."), + Ok(false) => self.notice("No saved login for this server."), + Err(error) => self.notice(&format!("Failed to forget the login: {error}")), + } + } + + fn login(&mut self, ctx: &Context, registering: bool) { + let user = self.login.user.trim().to_string(); + if user.is_empty() || self.login.password.is_empty() { + self.login.error = Some("Enter a username and a password.".to_string()); + return; + } + let credential = format!("{:x}", Sha256::digest(self.login.password.as_bytes())); + self.login.password.clear(); + self.login.error = None; + let command = if registering { + Command::Register { user, credential } + } else { + self.need_info = true; + if self.login.remember { + self.pending_login = Some((user.clone(), credential.clone())); + } + Command::Login { user, credential } + }; + let effects = self.core.command(command); + self.apply(ctx, effects); + } + + fn tick(&mut self, ctx: &Context) { + // Request deadline, mirroring the terminal's socket timeout. + if self.need_info + && self + .session + .as_ref() + .is_some_and(|session| session.user.is_some()) + && self.account.is_none() + { + let effects = self.core.command(Command::Info); + self.apply(ctx, effects); + self.need_info = false; + } + + if let Some(sent) = self.awaiting { + if sent.elapsed() > REQUEST_TIMEOUT { + self.awaiting = None; + self.net.send(NetCommand::Disconnect); + self.session = None; + self.core.reset_after_disconnect(); + let effects = self.core.handle_reply("ERROR Request timed out"); + self.apply(ctx, effects); + self.notice("Request timed out. Disconnected."); + } + } + + let game = self.core.current_game_type().map(str::to_string); + if self.fx.sync(game.as_deref()) { + self.balance_hold = None; + } + + match game.as_deref() { + Some("crash") => { + let effects = self.core.tick(); + self.apply(ctx, effects); + if let Some(View::Crash(view)) = self.core.view() { + self.fx.crash.tick(ctx, &view); + if view.status == "running" || view.status == "starting" { + ctx.request_repaint_after(Duration::from_millis(16)); + } + } + } + Some("slots") => { + if let Some(View::Slots(view)) = self.core.view() { + self.fx.slots.tick(ctx, &view); + } + } + Some("roulette") => { + if let Some(View::Roulette(view)) = self.core.view() { + self.fx.roulette.tick(ctx, &view); + } + } + _ => {} + } + + // Hold the balance at the staked amount until the reels or wheel stop, + // so the payout is not visible before the animation reveals it. + if self.balance_hold.is_some() && !self.result_animating(ctx, game.as_deref()) { + self.balance_hold = None; + } + + self.keys(ctx, game.as_deref()); + + // A successful manual login can be remembered for the next connect. + if let Some((user, credential)) = self.pending_login.clone() { + if self + .core + .view() + .as_ref() + .is_some_and(|view| matches!(view, View::Lobby(lobby) if lobby.user.is_some())) + { + if let Some(fingerprint) = self + .session + .as_ref() + .and_then(|session| session.fingerprint.as_deref()) + { + if let Some(db) = &self.db { + let _ = database::save_credentials(db, fingerprint, &user, &credential); + self.notice("Login saved for this server."); + } + } + self.pending_login = None; + } + } + + // Cache the header data, which game views do not all carry. + if let Some(view) = self.core.view() { + match view { + View::Lobby(lobby) => { + if let Some(session) = &mut self.session { + session.user = lobby.user.clone(); + } + if lobby.user.is_none() { + self.balance = None; + self.account = None; + } else { + if let Some(account) = lobby.account { + self.account = Some(account); + } + if let Some(balance) = lobby.balance { + if self.balance_hold.is_none() { + self.balance = Some(balance); + } + } + } + } + View::History(_) => {} + other => { + if self.balance_hold.is_none() { + if let Some(balance) = balance_of(&other) { + self.balance = Some(balance); + } + } + } + } + } + + self.notices + .retain(|notice| notice.at.elapsed() < Duration::from_secs(NOTICE_SECONDS)); + if !self.notices.is_empty() { + ctx.request_repaint_after(Duration::from_millis(500)); + } + } + + /// True while a spin's result is still being animated. + fn result_animating(&self, ctx: &Context, game: Option<&str>) -> bool { + match game { + Some("slots") => match self.core.view() { + Some(View::Slots(view)) if view.error.is_none() => { + !self.fx.slots.revealed(ctx, &view) + } + _ => false, + }, + Some("roulette") => match self.core.view() { + Some(View::Roulette(view)) if view.error.is_none() => { + !self.fx.roulette.revealed(&view) + } + _ => false, + }, + _ => false, + } + } + + /// The few keys that stay useful when the pointer is elsewhere. Buttons + /// keep focus, so this only fires when nothing is focused. + fn keys(&mut self, ctx: &Context, game: Option<&str>) { + if self.session.is_none() || !ctx.memory(|memory| memory.focused().is_none()) { + return; + } + let (space, clear, cash, hit, stand, double) = ctx.input(|input| { + ( + input.key_pressed(Key::Space), + input.key_pressed(Key::C), + input.key_pressed(Key::C), + input.key_pressed(Key::H), + input.key_pressed(Key::S), + input.key_pressed(Key::D), + ) + }); + let command = match game { + Some("roulette") => { + if space { + self.fx.roulette.spin_requested(); + Some(Command::Spin) + } else if clear { + Some(Command::ClearBets) + } else { + None + } + } + Some("slots") if space => Some(Command::Spin), + Some("crash") => { + if cash { + Some(Command::CashOut) + } else if space { + Some(Command::Start) + } else { + None + } + } + Some("blackjack") => { + if hit { + Some(Command::Hit) + } else if stand { + Some(Command::Stand) + } else if double { + Some(Command::Double) + } else if space { + Some(Command::Deal) + } else { + None + } + } + _ => None, + }; + if let Some(command) = command { + self.command(ctx, command); + } + } + + fn modals(&mut self, ctx: &Context) { + if let Some(trust) = &self.trust { + let addr = trust.addr.clone(); + let fingerprint = trust.fingerprint.clone(); + let mut decision = None; + egui::Modal::new(egui::Id::unique("trust")).show(ctx, |ui| { + ui.set_width(460.0); + ui.label( + RichText::new("New server certificate") + .font(theme::sans(20.0)) + .color(theme::TEXT), + ); + ui.add_space(theme::SPACE_2); + widgets::note( + ui, + "This is the first time this client has seen this server. \ + Check the fingerprint before trusting it.", + ); + ui.add_space(theme::SPACE_3); + widgets::row(ui, "Address", &addr, theme::TEXT); + widgets::mono_text(ui, &fingerprint, theme::ACCENT); + ui.add_space(theme::SPACE_3); + widgets::note( + ui, + "Future connections must present the same key. A changed key is refused.", + ); + ui.add_space(theme::SPACE_4); + ui.horizontal(|ui| { + if widgets::primary(ui, "TRUST", true, 130.0, 40.0) { + decision = Some(true); + } + ui.add_space(theme::SPACE_2); + if widgets::secondary(ui, "CANCEL", true, 110.0, 40.0) { + decision = Some(false); + } + }); + }); + if let Some(accept) = decision { + self.trust_decision(accept); + } + } + + if let Some(changed) = &self.key_changed { + let addr = changed.addr.clone(); + let expected = changed.expected.clone(); + let observed = changed.observed.clone(); + let mut action = None; + egui::Modal::new(egui::Id::unique("key-changed")).show(ctx, |ui| { + ui.set_width(520.0); + ui.label( + RichText::new("SERVER KEY CHANGED") + .font(theme::mono(16.0)) + .color(theme::LOSS), + ); + ui.add_space(theme::SPACE_2); + widgets::note( + ui, + "A server that used to present a different certificate is answering. \ + This can mean the server was reinstalled, or that someone is \ + intercepting the connection.", + ); + ui.add_space(theme::SPACE_3); + widgets::row(ui, "Address", &addr, theme::TEXT); + widgets::mono_text(ui, &format!("expected {expected}"), theme::TEXT_DIM); + widgets::mono_text(ui, &format!("observed {observed}"), theme::LOSS); + ui.add_space(theme::SPACE_4); + ui.horizontal(|ui| { + if widgets::primary(ui, "FORGET AND RECONNECT", true, 220.0, 40.0) { + action = Some(true); + } + ui.add_space(theme::SPACE_2); + if widgets::secondary(ui, "CANCEL", true, 110.0, 40.0) { + action = Some(false); + } + }); + }); + match action { + Some(true) => self.forget_trusted_host(&addr), + Some(false) => { + self.key_changed = None; + self.connect.error = Some("Server key changed. Trust not reset.".to_string()); + } + None => {} + } + } + + if self.leave_confirm { + let mut action = None; + egui::Modal::new(egui::Id::unique("leave-confirm")).show(ctx, |ui| { + ui.set_width(400.0); + ui.label( + RichText::new("Leave the table?") + .font(theme::sans(19.0)) + .color(theme::TEXT), + ); + ui.add_space(theme::SPACE_2); + widgets::note( + ui, + "A live hand or round is forfeited. The reserved bet is lost.", + ); + ui.add_space(theme::SPACE_4); + ui.horizontal(|ui| { + if widgets::primary(ui, "FORFEIT AND LEAVE", true, 190.0, 40.0) { + action = Some(true); + } + ui.add_space(theme::SPACE_2); + if widgets::secondary(ui, "STAY", true, 100.0, 40.0) { + action = Some(false); + } + }); + }); + if let Some(leave) = action { + self.leave_confirm = false; + if leave { + let effects = self.core.command(Command::Leave); + self.apply(ctx, effects); + } + } + } + } + + fn toasts(&self, ctx: &Context) { + if self.notices.is_empty() { + return; + } + egui::Area::new(egui::Id::unique("toasts")) + .anchor(Align2::LEFT_BOTTOM, vec2(24.0, -24.0)) + .order(egui::Order::Foreground) + .interactable(false) + .show(ctx, |ui| { + ui.set_min_width(220.0); + ui.set_max_width(460.0); + ui.spacing_mut().item_spacing.y = theme::SPACE_2; + for notice in &self.notices { + let color = match notice.tone { + Tone::Bad => theme::LOSS, + Tone::Good => theme::WIN, + Tone::Info => theme::TEXT_DIM, + }; + Frame::new() + .fill(theme::SURFACE_RAISED) + .stroke(Stroke::new(1.0, theme::LINE)) + .corner_radius(theme::RADIUS_MD) + .inner_margin(Margin::symmetric(14, 8)) + .show(ui, |ui| { + ui.set_min_width(226.0); + ui.set_max_width(440.0); + ui.label( + RichText::new(¬ice.text) + .font(theme::sans(12.0)) + .color(color), + ); + }); + } + }); } } impl eframe::App for ZaganApp { fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { - self.demo.tick(ui.ctx()); - self.demo.keys(ui.ctx()); - - Panel::top("header") - .exact_size(HEADER_HEIGHT) - .frame( - Frame::new() - .fill(theme::SURFACE) - .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 0)), - ) - .show(ui, |ui| header(ui, &self.demo)); - - Panel::right("sidebar") - .exact_size(SIDEBAR_WIDTH) - .frame( - Frame::new() - .fill(theme::SURFACE) - .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 20)), - ) - .show(ui, |ui| { - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .show(ui, |ui| sidebar(ui, &mut self.demo)); - }); + let ctx = ui.ctx().clone(); + self.pump(&ctx); + self.tick(&ctx); + header(ui, self); + self.screen(ui); + self.modals(&ctx); + self.toasts(&ctx); + } +} - CentralPanel::default() - .frame( - Frame::new() - .fill(theme::BG) - .inner_margin(Margin::same(theme::SPACE_5 as i8)), - ) - .show(ui, |ui| table(ui, &mut self.demo)); +fn balance_of(view: &View) -> Option { + match view { + View::Lobby(lobby) => lobby.balance, + View::Slots(view) => view.balance, + View::Roulette(view) => view.balance, + View::Blackjack(view) => view.balance, + View::Crash(view) => view.balance, + View::History(_) => None, + } +} + +fn normalize_addr(addr: &str) -> String { + let addr = addr.trim(); + if addr.is_empty() { + return "127.0.0.1:11525".to_string(); + } + if addr.contains(':') { + addr.to_string() + } else { + format!("{addr}:11525") } } @@ -77,50 +918,76 @@ impl eframe::App for ZaganApp { // Header // --------------------------------------------------------------------------- -fn header(ui: &mut Ui, demo: &Demo) { - ui.with_layout(Layout::left_to_right(Align::Center), |ui| { - let (mark, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); - ui.painter().rect_filled(mark, theme::RADIUS_SM, theme::WIN); - ui.add_space(theme::SPACE_2); - ui.label( - RichText::new("ZAGAN") - .font(theme::mono(18.0)) - .color(theme::TEXT), - ); - ui.label( - RichText::new("BET") - .font(theme::mono(18.0)) - .color(theme::WIN), - ); - ui.add_space(theme::SPACE_3); - pill(ui, &format!("v{VERSION}"), theme::TEXT_FAINT); - ui.add_space(theme::SPACE_1); - pill(ui, "PREVIEW", theme::GOLD); - ui.add_space(theme::SPACE_5); - connection_pill(ui, demo); +fn header(ui: &mut Ui, app: &ZaganApp) { + Panel::top("header") + .exact_size(64.0) + .frame( + Frame::new() + .fill(theme::SURFACE) + .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 0)), + ) + .show(ui, |ui| { + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + let (mark, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); + ui.painter().rect_filled(mark, theme::RADIUS_SM, theme::WIN); + ui.add_space(theme::SPACE_2); + ui.label( + RichText::new("ZAGAN") + .font(theme::mono(18.0)) + .color(theme::TEXT), + ); + ui.label( + RichText::new("BET") + .font(theme::mono(18.0)) + .color(theme::WIN), + ); + ui.add_space(theme::SPACE_3); + pill(ui, &format!("v{VERSION}"), theme::TEXT_FAINT); + if app.unsafe_mode { + ui.add_space(theme::SPACE_1); + pill(ui, "UNSAFE", theme::LOSS); + } + ui.add_space(theme::SPACE_5); + match &app.session { + Some(session) => connection_pill(ui, session), + None => pill(ui, "OFFLINE", theme::TEXT_FAINT), + } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - stat_block( - ui, - 150.0, - "Balance", - &theme::money(demo.balance), - theme::mono(20.0), - theme::TEXT, - None, - ); - ui.add_space(theme::SPACE_5); - stat_block( - ui, - 190.0, - "Account", - demo.user, - theme::sans(theme::FONT_BODY), - theme::TEXT, - Some(demo.account), - ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let signed_in = app + .session + .as_ref() + .is_some_and(|session| session.user.is_some()); + if signed_in { + if let Some(balance) = app.balance_hold.or(app.balance) { + stat_block( + ui, + 150.0, + "Balance", + &theme::money(balance), + theme::mono(20.0), + theme::TEXT, + None, + ); + ui.add_space(theme::SPACE_5); + } + if let Some(user) = app.session.as_ref().and_then(|s| s.user.as_deref()) { + let account = + app.account.as_deref().map(|account| format!("#{account}")); + stat_block( + ui, + 190.0, + "Account", + user, + theme::sans(theme::FONT_BODY), + theme::TEXT, + account.as_deref(), + ); + } + } + }); + }); }); - }); } fn pill(ui: &mut Ui, text: &str, color: Color32) { @@ -133,7 +1000,7 @@ fn pill(ui: &mut Ui, text: &str, color: Color32) { }); } -fn connection_pill(ui: &mut Ui, demo: &Demo) { +fn connection_pill(ui: &mut Ui, session: &Session) { Frame::new() .fill(theme::SURFACE_RAISED) .corner_radius(theme::RADIUS_SM) @@ -148,12 +1015,12 @@ fn connection_pill(ui: &mut Ui, demo: &Demo) { .color(theme::WIN), ); ui.label( - RichText::new(demo.host) + RichText::new(&session.host) .font(theme::mono(12.0)) .color(theme::TEXT_DIM), ); ui.label( - RichText::new(format!("{} ms", demo.ping_ms)) + RichText::new(format!("{:.1} ms", session.ping_ms)) .font(theme::mono(12.0)) .color(theme::TEXT_FAINT), ); @@ -161,8 +1028,6 @@ fn connection_pill(ui: &mut Ui, demo: &Demo) { }); } -/// A label over a value, right-aligned in a fixed slot so the header's right -/// edge stays put as the numbers change. fn stat_block( ui: &mut Ui, width: f32, @@ -187,454 +1052,554 @@ fn stat_block( } // --------------------------------------------------------------------------- -// Sidebar +// Connect // --------------------------------------------------------------------------- -fn sidebar(ui: &mut Ui, demo: &mut Demo) { - ui.label( - RichText::new("Roulette") - .font(theme::sans(22.0)) - .color(theme::TEXT), - ); - ui.label( - RichText::new("European · single zero") - .font(theme::sans(12.0)) - .color(theme::TEXT_DIM), - ); +fn connect_screen(ui: &mut Ui, app: &mut ZaganApp) { + CentralPanel::default() + .frame( + Frame::new() + .fill(theme::BG) + .inner_margin(Margin::same(theme::SPACE_5 as i8)), + ) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(60.0); + let width = 560.0; + ui.allocate_ui_with_layout(vec2(width, 0.0), Layout::top_down(Align::Min), |ui| { + Frame::new() + .fill(theme::SURFACE) + .corner_radius(theme::RADIUS_LG) + .inner_margin(Margin::same(theme::SPACE_5 as i8)) + .show(ui, |ui| { + ui.label( + RichText::new("Connect to a server") + .font(theme::sans(24.0)) + .color(theme::TEXT), + ); + ui.add_space(theme::SPACE_1); + widgets::note( + ui, + "TLS with certificate pinning. First contact asks before \ + any credential is sent.", + ); + ui.add_space(theme::SPACE_4); - ui.add_space(theme::SPACE_4); - let view = demo.view(); - let wheel_size = ui.available_width().min(210.0); - ui.horizontal(|ui| { - let pad = ((ui.available_width() - wheel_size) * 0.5).max(0.0); - ui.add_space(pad); - roulette::wheel(ui, wheel_size, &view, demo.rotation); - }); - ui.add_space(theme::SPACE_1); - result_line(ui, demo); + let response = ui.add( + TextEdit::singleline(&mut app.connect.addr) + .hint_text("host:port") + .desired_width(f32::INFINITY) + .font(theme::mono(14.0)), + ); + let submitted = response.lost_focus() + && ui.input(|input| input.key_pressed(Key::Enter)); - ui.add_space(theme::SPACE_3); - ui.separator(); - ui.add_space(theme::SPACE_3); + ui.add_space(theme::SPACE_3); + let connect = widgets::primary( + ui, + if app.connect.pending { + "CONNECTING…" + } else { + "CONNECT" + }, + !app.connect.pending, + ui.available_width(), + 44.0, + ); + if (connect || submitted) && !app.connect.pending { + let addr = app.connect.addr.clone(); + app.begin_connect(&addr); + } - ui.label(theme::label("Chip")); - ui.add_space(theme::SPACE_2); - let spinning = demo.phase == RoulettePhase::Spinning; - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = theme::SPACE_2; - for amount in CHIP_SIZES { - if chip_button(ui, amount, demo.chip == amount, !spinning).clicked() { - demo.chip = amount; - } - } - }); + if let Some(error) = &app.connect.error { + ui.add_space(theme::SPACE_3); + widgets::error(ui, error); + } + if app.connect.version_issue { + ui.add_space(theme::SPACE_2); + widgets::note( + ui, + &format!( + "Download the matching client from {}", + zagan_bet::version::DEFAULT_DOWNLOAD_URL + ), + ); + } + }); - ui.add_space(theme::SPACE_3); - ui.label(theme::label("Bets")); - ui.add_space(theme::SPACE_1); - if demo.bets.chips.is_empty() { + ui.add_space(theme::SPACE_4); + Frame::new() + .fill(theme::SURFACE) + .corner_radius(theme::RADIUS_LG) + .inner_margin(Margin::same(theme::SPACE_4 as i8)) + .show(ui, |ui| { + ui.label(theme::label("Remembered hosts")); + ui.add_space(theme::SPACE_2); + let hosts = app + .db + .as_ref() + .map(database::get_all_hosts) + .unwrap_or_default(); + if hosts.is_empty() { + widgets::note(ui, "No remembered hosts yet."); + } + for (host, fingerprint) in hosts { + remembered_host(ui, app, &host, fingerprint.as_deref()); + } + }); + }); + }); + }); +} + +fn remembered_host(ui: &mut Ui, app: &mut ZaganApp, host: &str, fingerprint: Option<&str>) { + ui.horizontal(|ui| { ui.label( - RichText::new("No chips on the felt") - .font(theme::sans(12.0)) - .color(theme::TEXT_FAINT), + RichText::new(host) + .font(theme::mono(13.0)) + .color(theme::TEXT), ); - } else { - for chip in &demo.bets.chips { - bet_row(ui, &roulette::target_name(&chip.target), chip.amount); + if let Some(fingerprint) = fingerprint { + ui.label( + RichText::new(short_fingerprint(fingerprint)) + .font(theme::mono(10.0)) + .color(theme::TEXT_FAINT), + ); } - } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::ghost(ui, "Forget") { + if let Some(db) = &app.db { + if let Err(error) = database::forget_host(db, host) { + app.connect.error = Some(format!("Failed to forget host: {error}")); + } + } + } + if widgets::ghost(ui, "Connect") { + app.begin_connect(host); + } + }); + }); +} - ui.add_space(theme::SPACE_3); - ui.separator(); - ui.add_space(theme::SPACE_2); - bet_row(ui, "Total", demo.bets.total()); +fn short_fingerprint(fingerprint: &str) -> String { + let head: String = fingerprint.chars().take(16).collect(); + format!("{head}…") +} - ui.add_space(theme::SPACE_4); - let actions = ui.available_width(); - let clear_width = 108.0; - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = theme::SPACE_2; +// --------------------------------------------------------------------------- +// Login +// --------------------------------------------------------------------------- - let spin = Button::new( - RichText::new("SPIN") - .font(theme::mono(theme::FONT_MONO)) - .color(theme::BG), +fn login_screen(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) { + CentralPanel::default() + .frame( + Frame::new() + .fill(theme::BG) + .inner_margin(Margin::same(theme::SPACE_5 as i8)), ) - .fill(if spinning { - theme::WIN.gamma_multiply(0.45) - } else { - theme::WIN - }) - .corner_radius(theme::RADIUS_MD) - .min_size(vec2(actions - clear_width - theme::SPACE_2, 44.0)); - let can_spin = !spinning && !demo.bets.is_empty(); - if ui.add_enabled(can_spin, spin).clicked() { - demo.start_spin(ui.ctx()); - } + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(100.0); + ui.allocate_ui_with_layout(vec2(420.0, 0.0), Layout::top_down(Align::Min), |ui| { + Frame::new() + .fill(theme::SURFACE) + .corner_radius(theme::RADIUS_LG) + .inner_margin(Margin::same(theme::SPACE_5 as i8)) + .show(ui, |ui| { + ui.label( + RichText::new("Sign in") + .font(theme::sans(24.0)) + .color(theme::TEXT), + ); + if let Some(session) = &app.session { + widgets::mono_text( + ui, + &format!( + "{} · {}", + session.host, + short_fingerprint( + session.fingerprint.as_deref().unwrap_or("unknown") + ) + ), + theme::TEXT_DIM, + ); + } + ui.add_space(theme::SPACE_4); - let clear = Button::new( - RichText::new("CLEAR") - .font(theme::mono(12.0)) - .color(theme::TEXT_DIM), - ) - .fill(theme::SURFACE_RAISED) - .stroke(Stroke::new(1.0, theme::LINE)) - .corner_radius(theme::RADIUS_MD) - .min_size(vec2(clear_width, 44.0)); - if ui - .add_enabled(!demo.bets.is_empty() && !spinning, clear) - .clicked() - { - demo.begin_placing(); - demo.bets = Bets::default(); - } - }); -} + ui.label(theme::label("Username")); + ui.add( + TextEdit::singleline(&mut app.login.user) + .desired_width(f32::INFINITY), + ); + ui.add_space(theme::SPACE_3); + ui.label(theme::label("Password")); + ui.add( + TextEdit::singleline(&mut app.login.password) + .password(true) + .desired_width(f32::INFINITY), + ); -fn result_line(ui: &mut Ui, demo: &Demo) { - ui.horizontal(|ui| match (demo.phase, demo.number) { - (RoulettePhase::Result, Some(number)) => { - ui.label(theme::label("Result")); - ui.label( - RichText::new(format!("{number} {}", wheel_color(number).to_uppercase())) - .font(theme::mono(14.0)) - .color(color_of(number)), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - payout(ui, demo); + ui.add_space(theme::SPACE_2); + ui.checkbox( + &mut app.login.remember, + RichText::new("Remember this login") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + + let enter = ui.input(|input| input.key_pressed(Key::Enter)); + ui.add_space(theme::SPACE_4); + if widgets::primary(ui, "LOG IN", true, ui.available_width(), 44.0) + || enter + { + app.login(ui.ctx(), false); + } + ui.add_space(theme::SPACE_2); + if widgets::secondary( + ui, + "CREATE ACCOUNT", + true, + ui.available_width(), + 38.0, + ) { + app.login(ui.ctx(), true); + } + + if let Some(error) = + lobby.login_error.as_ref().or(app.login.error.as_ref()) + { + ui.add_space(theme::SPACE_3); + widgets::error(ui, error); + } + }); + + ui.add_space(theme::SPACE_3); + ui.allocate_ui_with_layout( + vec2(420.0, 24.0), + Layout::top_down(Align::Center), + |ui| { + if widgets::ghost(ui, "Disconnect") { + app.disconnect(); + } + }, + ); + }); }); - } - (RoulettePhase::Spinning, _) => { - ui.label( - RichText::new("SPINNING…") - .font(theme::mono(12.0)) - .color(theme::GOLD), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + }); +} + +// --------------------------------------------------------------------------- +// Lobby +// --------------------------------------------------------------------------- + +fn lobby_screen(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) { + CentralPanel::default() + .frame( + Frame::new() + .fill(theme::BG) + .inner_margin(Margin::same(theme::SPACE_5 as i8)), + ) + .show(ui, |ui| { + ui.horizontal(|ui| { ui.label( - RichText::new(format!("{} chips", demo.bets.chips.len())) - .font(theme::mono(10.0)) - .color(theme::TEXT_FAINT), + RichText::new("Lobby") + .font(theme::sans(24.0)) + .color(theme::TEXT), ); - }); - } - _ => { - ui.label( - RichText::new("Place your bets") - .font(theme::sans(12.0)) - .color(theme::TEXT_DIM), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.label( - RichText::new(format!("{} chips", demo.bets.chips.len())) - .font(theme::mono(10.0)) - .color(theme::TEXT_FAINT), + RichText::new(format!("{} games", lobby.games.len())) + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::secondary(ui, "LOG OUT", true, 100.0, 34.0) { + app.command(ui.ctx(), Command::Logout); + } + if widgets::secondary(ui, "HISTORY", true, 104.0, 34.0) { + let effects = app.core.command(Command::History { limit: 20 }); + app.apply(ui.ctx(), effects); + } + }); }); - } - }); + + if let Some(session) = &app.session { + if is_newer(&session.latest, VERSION) { + ui.add_space(theme::SPACE_3); + Frame::new() + .fill(theme::GOLD.gamma_multiply(0.10)) + .stroke(Stroke::new(1.0, theme::GOLD.gamma_multiply(0.5))) + .corner_radius(theme::RADIUS_MD) + .inner_margin(Margin::symmetric(14, 10)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Update available: v{}", session.latest)) + .font(theme::mono(12.0)) + .color(theme::GOLD), + ); + widgets::mono_text(ui, &session.url, theme::TEXT_DIM); + }); + }); + } + } + + ui.add_space(theme::SPACE_4); + games_grid(ui, app, lobby); + + ui.add_space(theme::SPACE_4); + ui.separator(); + ui.add_space(theme::SPACE_2); + ui.horizontal(|ui| { + if let Some(session) = &app.session { + widgets::note( + ui, + &format!( + "Signed in to {} · server v{}", + session.host, session.server_version + ), + ); + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::ghost(ui, "Forget saved login") { + app.forget_login(); + } + }); + }); + }); } -/// What the round paid, and the net for the spin. -fn payout(ui: &mut Ui, demo: &Demo) { - let winnings = demo.winnings.unwrap_or(0); - if winnings == 0 { - ui.label( - RichText::new("NO WIN") - .font(theme::mono(13.0)) - .color(theme::TEXT_FAINT), - ); - return; - } +fn games_grid(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) { + const ORDER: [&str; 4] = ["slots", "roulette", "blackjack", "crash"]; - let profit = winnings as i64 - demo.spent as i64; - ui.label( - RichText::new(format!("({})", theme::signed_money(profit))) - .font(theme::sans(11.0)) - .color(if profit >= 0 { theme::WIN } else { theme::LOSS }), - ); - ui.label( - RichText::new(theme::money(winnings as i64)) - .font(theme::mono(15.0)) - .color(theme::WIN), - ); - ui.label(theme::label("Won")); + for row in ORDER.chunks(2) { + ui.columns(2, |columns| { + for (index, game) in row.iter().enumerate() { + let Some((key, name)) = lobby.games.iter().find(|(key, _)| key == game) else { + continue; + }; + game_card(&mut columns[index], app, key, name); + } + }); + ui.add_space(theme::SPACE_4); + } } -fn bet_row(ui: &mut Ui, name: &str, amount: u64) { - ui.horizontal(|ui| { - ui.label( - RichText::new(name) - .font(theme::sans(13.0)) - .color(theme::TEXT_DIM), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { +fn game_card(ui: &mut Ui, app: &mut ZaganApp, key: &str, name: &str) { + Frame::new() + .fill(theme::SURFACE) + .corner_radius(theme::RADIUS_LG) + .inner_margin(Margin::same(theme::SPACE_4 as i8)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); ui.label( - RichText::new(theme::money(amount as i64)) - .font(theme::mono(13.0)) - .color(theme::GOLD), + RichText::new(name) + .font(theme::sans(22.0)) + .color(theme::TEXT), ); + ui.add_space(2.0); + widgets::note(ui, tagline(key)); + ui.add_space(theme::SPACE_1); + ui.label( + RichText::new(meta(key)) + .font(theme::mono(10.0)) + .color(theme::TEXT_FAINT), + ); + ui.add_space(theme::SPACE_3); + if widgets::primary(ui, "PLAY", true, 110.0, 36.0) { + let effects = app.core.command(Command::Play { + game: key.to_string(), + bet: Some(DEFAULT_BET), + }); + app.apply(ui.ctx(), effects); + } }); - }); } -fn chip_button(ui: &mut Ui, amount: u64, selected: bool, enabled: bool) -> egui::Response { - let sense = if enabled { - Sense::click() - } else { - Sense::hover() - }; - let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), sense); - let center = rect.center(); - let radius = 20.0; - let painter = ui.painter(); - if selected || response.hovered() { - painter.circle_stroke( - center, - radius + 3.0, - Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT), - ); +fn tagline(game: &str) -> &'static str { + match game { + "slots" => "Five reels, five paylines, one lever.", + "roulette" => "The European wheel, thirty-seven pockets.", + "blackjack" => "Six decks, dealer stands on seventeen. Pays 3:2.", + "crash" => "The multiplier rises. Cash out before it busts.", + _ => "", } - if enabled { - roulette::paint_chip(painter, center, radius, amount); - } else { - roulette::paint_chip_preview(painter, center, radius, amount); - } - response } -fn color_of(number: u8) -> Color32 { - match wheel_color(number) { - "red" => theme::ROULETTE_RED, - "green" => theme::WIN, - _ => theme::TEXT, +fn meta(game: &str) -> &'static str { + match game { + "slots" => "MIN 10 KR · RTP 96%", + "roulette" => "MIN 10 KR · SINGLE ZERO", + "blackjack" => "MIN 10 KR · S17 · 3:2", + "crash" => "MIN 10 KR · 1% EDGE", + _ => "", } } // --------------------------------------------------------------------------- -// Table +// History // --------------------------------------------------------------------------- -fn table(ui: &mut Ui, demo: &mut Demo) { - ui.horizontal(|ui| { - ui.label( - RichText::new("Table 01") - .font(theme::sans(15.0)) - .color(theme::TEXT), - ); - ui.label( - RichText::new("European · single zero") - .font(theme::sans(12.0)) - .color(theme::TEXT_DIM), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label( - RichText::new("Limits 10 – 500 Kr") - .font(theme::mono(12.0)) - .color(theme::TEXT_FAINT), - ); - }); - }); - ui.add_space(theme::SPACE_4); - - let view = demo.view(); - let strip_height = 104.0; - let available = ui.available_size(); - let size = roulette::size_for(vec2(available.x, (available.y - strip_height).max(220.0))); - let side_pad = ((available.x - size.x) * 0.5).max(0.0); - let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0); - let chip = demo.chip; - let placing = demo.phase != RoulettePhase::Spinning; - ui.add_space(top_pad); - ui.horizontal(|ui| { - ui.add_space(side_pad); - match roulette::felt(ui, size, &view) { - Some(FeltAction::Place(target)) if placing => { - demo.begin_placing(); - demo.bets.add(target, chip); - } - Some(FeltAction::Reduce(target)) if placing => { - demo.begin_placing(); - demo.bets.reduce(&target, chip); +fn history_screen(ui: &mut Ui, app: &mut ZaganApp, records: &[PlayRecord]) { + CentralPanel::default() + .frame( + Frame::new() + .fill(theme::BG) + .inner_margin(Margin::same(theme::SPACE_5 as i8)), + ) + .show(ui, |ui| { + ui.horizontal(|ui| { + if widgets::ghost(ui, "Back to lobby") { + app.command(ui.ctx(), Command::ShowLobby); + } + ui.label( + RichText::new("Play history") + .font(theme::sans(24.0)) + .color(theme::TEXT), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + for limit in [50, 20, 10] { + if widgets::ghost(ui, &limit.to_string()) { + let effects = app.core.command(Command::History { limit }); + app.apply(ui.ctx(), effects); + } + } + ui.label(theme::label("Show")); + }); + }); + ui.add_space(theme::SPACE_4); + + if records.is_empty() { + widgets::note(ui, "No plays recorded yet."); + return; } - _ => {} - } - }); - ui.add_space(theme::SPACE_4); - ui.horizontal(|ui| { - ui.label(theme::label("Last")); - if demo.last.is_empty() { - ui.label( - RichText::new("No spins yet") - .font(theme::sans(12.0)) - .color(theme::TEXT_FAINT), - ); - } - for number in &demo.last { - let (dot, _) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::hover()); - roulette::paint_result_dot(ui.painter(), dot.center(), 11.0, *number); - ui.add_space(theme::SPACE_1); - } - }); - ui.add_space(theme::SPACE_2); - ui.label( - RichText::new( - "Click to place · right or middle click takes back · C clears · space spins", - ) - .font(theme::sans(12.0)) - .color(theme::TEXT_FAINT), - ); -} + Frame::new() + .fill(theme::SURFACE) + .corner_radius(theme::RADIUS_LG) + .inner_margin(Margin::same(theme::SPACE_3 as i8)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.set_min_height(ui.available_height()); -// --------------------------------------------------------------------------- -// Demo state -// --------------------------------------------------------------------------- + let widths = history_columns(ui.available_width()); + history_header(ui, &widths); + ui.add_space(theme::SPACE_1); + ui.separator(); -struct Spin { - started: f64, - from: f32, - delta: f32, - target: u8, -} - -struct Demo { - bets: Bets, - chip: u64, - phase: RoulettePhase, - number: Option, - rotation: f32, - spin: Option, - spins: usize, - last: Vec, - winnings: Option, - spent: u64, - balance: i64, - host: &'static str, - ping_ms: u32, - user: &'static str, - account: &'static str, -} - -impl Demo { - fn new() -> Self { - let mut bets = Bets::default(); - bets.add(BetTarget::Straight { number: 17 }, 50); - bets.add( - BetTarget::Corner { - numbers: [17, 18, 20, 21], - }, - 25, - ); - bets.add(BetTarget::Outside { kind: Outside::Red }, 25); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + for (index, record) in records.iter().enumerate() { + history_row(ui, &widths, record, index % 2 == 1); + } + }); + }); + }); +} - Self { - bets, - chip: 25, - phase: RoulettePhase::Placing, - number: None, - rotation: 0.0, - spin: None, - spins: 0, - last: Vec::new(), - winnings: None, - spent: 0, - balance: 1000, - host: "127.0.0.1:11525", - ping_ms: 41, - user: "alice", - account: "#4161673306", - } - } +/// Column widths for the play log, filling the available width. +fn history_columns(width: f32) -> [f32; 6] { + let usable = (width - theme::SPACE_3 * 5.0).max(320.0); + [ + usable * 0.18, + usable * 0.12, + usable * 0.10, + usable * 0.10, + usable * 0.12, + usable * 0.38, + ] +} - fn view(&self) -> RouletteView { - RouletteView { - bet_amount: 10, - balance: Some(self.balance), - chip: self.chip, - bets: self.bets.clone(), - cursor: Cursor::default(), - phase: self.phase, - number: self.number, - color: self.number.map(|number| wheel_color(number).to_string()), - winnings: self.winnings, - error: None, - } - } +fn history_header(ui: &mut Ui, widths: &[f32; 6]) { + history_line( + ui, + widths, + [ + theme::label("When"), + theme::label("Game"), + theme::label("Bet"), + theme::label("Won"), + theme::label("Balance"), + theme::label("Result"), + ], + false, + ); +} - /// A bet change after a result starts the next round: the wheel stays - /// where it stopped, but the payout and the winning highlight do not. - fn begin_placing(&mut self) { - if self.phase == RoulettePhase::Result { - self.phase = RoulettePhase::Placing; - self.winnings = None; - } - } +fn history_row(ui: &mut Ui, widths: &[f32; 6], record: &PlayRecord, stripe: bool) { + history_line( + ui, + widths, + [ + RichText::new(record.short_time()) + .font(theme::mono(11.0)) + .color(theme::TEXT_DIM), + RichText::new(record.game.to_uppercase()) + .font(theme::mono(11.0)) + .color(theme::TEXT), + RichText::new(theme::money(record.bet)) + .font(theme::mono(12.0)) + .color(theme::TEXT_DIM), + RichText::new(theme::money(record.winnings)) + .font(theme::mono(12.0)) + .color(if record.winnings > 0 { + theme::WIN + } else { + theme::TEXT_FAINT + }), + RichText::new(theme::money(record.balance_after)) + .font(theme::mono(12.0)) + .color(theme::TEXT), + RichText::new(record.summary()) + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ], + stripe, + ); +} - fn tick(&mut self, ctx: &Context) { - let now = ctx.input(|input| input.time); - let mut finished = None; - if let Some(spin) = &self.spin { - let t = (((now - spin.started) / SPIN_SECONDS) as f32).clamp(0.0, 1.0); - let eased = 1.0 - (1.0 - t).powi(3); - self.rotation = spin.from + spin.delta * eased; - if t >= 1.0 { - finished = Some(spin.target); - } - ctx.request_repaint(); - } - if let Some(number) = finished { - self.spin = None; - self.phase = RoulettePhase::Result; - self.number = Some(number); - self.winnings = Some(self.bets.winnings(number)); - self.last.insert(0, number); - self.last.truncate(12); - } +fn history_line(ui: &mut Ui, widths: &[f32; 6], cells: [RichText; 6], stripe: bool) { + let spacing = theme::SPACE_3; + let width = widths.iter().sum::() + spacing * 5.0; + let (rect, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover()); + if stripe { + ui.painter().rect_filled( + rect, + theme::RADIUS_SM, + theme::SURFACE_RAISED.gamma_multiply(0.6), + ); } - - /// Pointer-first: the felt reads the mouse. The keyboard keeps the two - /// verbs that have no pointer equivalent in profile (spin and clear). - fn keys(&mut self, ctx: &Context) { - if self.phase == RoulettePhase::Spinning { - return; - } - if ctx.memory(|memory| memory.focused().is_none()) { - if ctx.input(|input| input.key_pressed(Key::Space)) { - self.start_spin(ctx); - } - if ctx.input(|input| input.key_pressed(Key::C)) { - self.begin_placing(); - self.bets = Bets::default(); - } - } + let mut child = ui.new_child( + eframe::egui::UiBuilder::new() + .max_rect(rect.shrink2(vec2(6.0, 0.0))) + .layout(Layout::left_to_right(Align::Center)), + ); + child.spacing_mut().item_spacing.x = spacing; + for (width, cell) in widths.iter().zip(cells) { + child.add_sized( + vec2(*width, 18.0), + egui::Label::new(cell).selectable(false).truncate(), + ); } +} - fn start_spin(&mut self, ctx: &Context) { - if self.phase == RoulettePhase::Spinning || self.bets.is_empty() { - return; - } - let target = DEMO_TARGETS[self.spins % DEMO_TARGETS.len()]; - self.spins += 1; - self.spent = self.bets.total(); - self.winnings = None; - - let segment = TAU / WHEEL.len() as f32; - let index = WHEEL - .iter() - .position(|number| *number == target) - .unwrap_or(0) as f32; - let desired = -index * segment; - let delta = (desired - self.rotation).rem_euclid(TAU) + 3.0 * TAU; - - self.spin = Some(Spin { - started: ctx.input(|input| input.time), - from: self.rotation, - delta, - target, +fn fatal(ui: &mut Ui, message: &str) { + CentralPanel::default() + .frame( + Frame::new() + .fill(theme::BG) + .inner_margin(Margin::same(theme::SPACE_5 as i8)), + ) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(120.0); + ui.label( + RichText::new("Cannot start") + .font(theme::sans(24.0)) + .color(theme::LOSS), + ); + ui.add_space(theme::SPACE_2); + widgets::error(ui, message); + }); }); - self.phase = RoulettePhase::Spinning; - self.number = None; - ctx.request_repaint(); - } } 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 @@ +//! Blackjack screen: cards painted from `BlackjackView`. + +use eframe::egui::{ + Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, + pos2, vec2, +}; +use zagan_bet::blackjack::is_red_suit; +use zagan_bet::client::view::{BlackjackView, Command}; + +use crate::{theme, widgets}; + +const CARD: Vec2 = vec2(92.0, 128.0); + +pub fn show(ui: &mut Ui, view: &BlackjackView) -> Option { + let mut command = None; + eframe::egui::CentralPanel::default() + .frame(eframe::egui::Frame::new().fill(theme::BG)) + .show(ui, |ui| { + let full = ui.max_rect(); + let sidebar_rect = eframe::egui::Rect::from_min_max( + eframe::egui::pos2(full.right() - 336.0, full.top()), + full.right_bottom(), + ); + let board_rect = eframe::egui::Rect::from_min_max( + full.min, + eframe::egui::pos2(sidebar_rect.left(), full.bottom()), + ); + ui.painter().rect_filled( + sidebar_rect, + eframe::egui::CornerRadius::ZERO, + theme::SURFACE, + ); + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0))) + .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), + |ui| board(ui, view), + ); + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0))) + .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), + |ui| { + command = sidebar(ui, view); + }, + ); + }); + command +} + +fn live(view: &BlackjackView) -> bool { + view.status == "player" +} + +fn sidebar(ui: &mut Ui, view: &BlackjackView) -> Option { + let mut command = None; + let betting = !live(view) && view.status != "dealing" && view.status != "forfeiting"; + let (content, actions) = widgets::sidebar_layout(ui, 200.0); + + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(content) + .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), + |ui| { + eframe::egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.label( + eframe::egui::RichText::new("Blackjack") + .font(theme::sans(22.0)) + .color(theme::TEXT), + ); + ui.label( + eframe::egui::RichText::new("6 decks · dealer stands on 17 · 3:2") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + + ui.add_space(theme::SPACE_5); + ui.label(theme::label("Bet")); + ui.add_space(theme::SPACE_2); + if let Some(delta) = widgets::bet_control(ui, view.bet, betting) { + command = Some(Command::AdjustBet(delta)); + } + + ui.add_space(theme::SPACE_4); + match view.status.as_str() { + "player" => widgets::note(ui, "Hit, stand or double."), + "done" => { + if let Some(hand) = &view.hand { + let (text, color) = result_text(&hand.result, hand.winnings); + ui.label( + eframe::egui::RichText::new(text) + .font(theme::mono(15.0)) + .color(color), + ); + } + } + "dealing" => widgets::note(ui, "Dealing…"), + _ => widgets::note(ui, "Press DEAL to start a hand."), + } + + if let Some(error) = &view.error { + ui.add_space(theme::SPACE_3); + widgets::error(ui, error); + } + + ui.add_space(theme::SPACE_4); + ui.separator(); + ui.add_space(theme::SPACE_2); + widgets::note(ui, "Blackjack pays 3:2. The dealer stands on soft 17."); + widgets::note(ui, "Double only on the first two cards."); + }); + }, + ); + + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(actions) + .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)), + |ui| { + if live(view) { + if widgets::ghost(ui, "Forfeit hand") { + command = Some(Command::Leave); + } + } else if widgets::ghost(ui, "Leave table") { + command = Some(Command::Leave); + } + + match view.status.as_str() { + "player" => { + if view + .hand + .as_ref() + .is_some_and(|hand| hand.actions.iter().any(|action| action == "double")) + { + ui.add_space(theme::SPACE_2); + if widgets::secondary(ui, "DOUBLE", true, ui.available_width(), 38.0) { + command = Some(Command::Double); + } + } + ui.add_space(theme::SPACE_2); + if widgets::secondary(ui, "STAND", true, ui.available_width(), 38.0) { + command = Some(Command::Stand); + } + ui.add_space(theme::SPACE_2); + if widgets::primary(ui, "HIT", true, ui.available_width(), 44.0) { + command = Some(Command::Hit); + } + } + "dealing" | "forfeiting" => { + ui.label( + eframe::egui::RichText::new(if view.status == "dealing" { + "DEALING…" + } else { + "FORFEITING…" + }) + .font(theme::mono(12.0)) + .color(theme::GOLD), + ); + } + _ => { + if widgets::primary(ui, "DEAL", true, ui.available_width(), 46.0) { + command = Some(Command::Deal); + } + } + } + }, + ); + + command +} + +fn result_text(result: &str, winnings: u64) -> (String, Color32) { + match result { + "win" => ( + format!("YOU WIN {}", theme::money(winnings as i64)), + theme::WIN, + ), + "blackjack" => ( + format!("BLACKJACK {}", theme::money(winnings as i64)), + theme::GOLD, + ), + "push" => ("PUSH".to_string(), theme::TEXT), + "bust" => ("BUST".to_string(), theme::LOSS), + "forfeit" => ("FORFEITED".to_string(), theme::TEXT_DIM), + _ => ("DEALER WINS".to_string(), theme::LOSS), + } +} + +fn board(ui: &mut Ui, view: &BlackjackView) { + ui.horizontal(|ui| { + ui.label( + eframe::egui::RichText::new("Table 01") + .font(theme::sans(15.0)) + .color(theme::TEXT), + ); + ui.label( + eframe::egui::RichText::new("Blackjack · six decks") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + ui.with_layout( + eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), + |ui| { + ui.label( + eframe::egui::RichText::new("3:2 · S17") + .font(theme::mono(12.0)) + .color(theme::TEXT_FAINT), + ); + }, + ); + }); + ui.add_space(theme::SPACE_5); + + let Some(hand) = &view.hand else { + centered_note(ui, "Place a bet and deal a hand."); + return; + }; + + let width = ui.available_width(); + let hidden = hand.dealer.iter().any(|card| card == "?"); + hand_header(ui, "Dealer", hand.dealer_total, hidden); + paint_hand(ui, &hand.dealer, width); + ui.add_space(theme::SPACE_5); + + let (line, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover()); + ui.painter() + .rect_filled(line, CornerRadius::ZERO, theme::LINE); + ui.add_space(theme::SPACE_5); + + hand_header(ui, "You", hand.player_total, false); + paint_hand(ui, &hand.player, width); + + ui.add_space(theme::SPACE_4); + ui.label( + eframe::egui::RichText::new(match view.status.as_str() { + "player" => "Your move.", + "dealing" => "Dealing…", + "done" => "Hand complete.", + _ => "Place your bet.", + }) + .font(theme::sans(13.0)) + .color(theme::TEXT_DIM), + ); +} + +fn hand_header(ui: &mut Ui, label: &str, total: u8, hidden: bool) { + ui.horizontal(|ui| { + ui.label(theme::label(label)); + ui.label( + eframe::egui::RichText::new(if hidden { + format!("{total} + ?") + } else { + total.to_string() + }) + .font(theme::mono(15.0)) + .color(if label == "You" { + theme::TEXT + } else { + theme::TEXT_DIM + }), + ); + }); + ui.add_space(theme::SPACE_2); +} + +fn paint_hand(ui: &mut Ui, cards: &[String], width: f32) { + if cards.is_empty() { + ui.add_space(CARD.y); + return; + } + let step = CARD.x * 0.74; + let hand_width = CARD.x + step * (cards.len() as f32 - 1.0); + let (rect, _) = ui.allocate_exact_size(vec2(width.max(hand_width), CARD.y), Sense::hover()); + let left = rect.left() + ((rect.width() - hand_width) * 0.5).max(0.0); + let painter = ui.painter(); + for (index, card) in cards.iter().enumerate() { + let rect = Rect::from_min_size(pos2(left + step * index as f32, rect.top()), CARD); + if card == "?" { + paint_back(painter, rect); + } else { + paint_card(painter, rect, card); + } + } +} + +fn paint_card(painter: &Painter, rect: Rect, card: &str) { + painter.rect_filled(rect, theme::RADIUS_MD, Color32::from_rgb(0xe8, 0xe4, 0xd8)); + painter.rect_stroke( + rect, + theme::RADIUS_MD, + Stroke::new(1.0, Color32::from_rgb(0x9a, 0x94, 0x86)), + StrokeKind::Inside, + ); + let color = if is_red_suit(card) { + Color32::from_rgb(0xb0, 0x31, 0x3a) + } else { + Color32::from_rgb(0x15, 0x1b, 0x21) + }; + let suit = card.chars().last().unwrap_or('?'); + let rank = &card[..card.len().saturating_sub(suit.len_utf8())]; + painter.text( + rect.left_top() + vec2(9.0, 6.0), + Align2::LEFT_TOP, + rank, + theme::mono(20.0), + color, + ); + painter.text( + rect.right_bottom() - vec2(9.0, 6.0), + Align2::RIGHT_BOTTOM, + rank, + theme::mono(20.0), + color, + ); + paint_suit(painter, rect.center(), rect.width() * 0.55, suit, color); +} + +/// Card suits are drawn, not typed: neither embedded font carries the glyphs. +/// +/// The circles must overlap the stem, or the shape reads as three dots above a +/// detached bar. +fn paint_suit(painter: &Painter, center: Pos2, size: f32, suit: char, color: Color32) { + let s = size; + match suit { + 'H' => { + painter.circle_filled(center + vec2(-s * 0.22, -s * 0.12), s * 0.31, color); + painter.circle_filled(center + vec2(s * 0.22, -s * 0.12), s * 0.31, color); + painter.add(Shape::convex_polygon( + vec![ + center + vec2(-s * 0.50, 0.0), + center + vec2(s * 0.50, 0.0), + center + vec2(0.0, s * 0.56), + ], + color, + Stroke::NONE, + )); + } + 'D' => { + painter.add(Shape::convex_polygon( + vec![ + center + vec2(0.0, -s * 0.56), + center + vec2(s * 0.42, 0.0), + center + vec2(0.0, s * 0.56), + center + vec2(-s * 0.42, 0.0), + ], + color, + Stroke::NONE, + )); + } + 'S' => { + painter.circle_filled(center + vec2(-s * 0.24, s * 0.10), s * 0.32, color); + painter.circle_filled(center + vec2(s * 0.24, s * 0.10), s * 0.32, color); + painter.add(Shape::convex_polygon( + vec![ + center + vec2(-s * 0.50, s * 0.02), + center + vec2(s * 0.50, s * 0.02), + center + vec2(0.0, -s * 0.56), + ], + color, + Stroke::NONE, + )); + painter.add(Shape::convex_polygon( + vec![ + center + vec2(-s * 0.11, s * 0.08), + center + vec2(s * 0.11, s * 0.08), + center + vec2(s * 0.05, s * 0.52), + center + vec2(-s * 0.05, s * 0.52), + ], + color, + Stroke::NONE, + )); + } + 'C' => { + painter.circle_filled(center + vec2(0.0, -s * 0.26), s * 0.33, color); + painter.circle_filled(center + vec2(-s * 0.30, s * 0.10), s * 0.33, color); + painter.circle_filled(center + vec2(s * 0.30, s * 0.10), s * 0.33, color); + painter.add(Shape::convex_polygon( + vec![ + center + vec2(-s * 0.13, s * 0.08), + center + vec2(s * 0.13, s * 0.08), + center + vec2(s * 0.05, s * 0.52), + center + vec2(-s * 0.05, s * 0.52), + ], + color, + Stroke::NONE, + )); + } + _ => {} + } +} + +fn paint_back(painter: &Painter, rect: Rect) { + painter.rect_filled(rect, theme::RADIUS_MD, Color32::from_rgb(0x1b, 0x3a, 0x2c)); + painter.rect_stroke( + rect, + theme::RADIUS_MD, + Stroke::new(1.0, Color32::from_rgb(0x2b, 0x5a, 0x43)), + StrokeKind::Inside, + ); + let inner = rect.shrink(8.0); + painter.rect_stroke( + inner, + theme::RADIUS_SM, + Stroke::new(1.0, Color32::from_rgb(0x2b, 0x5a, 0x43)), + StrokeKind::Inside, + ); + painter.text( + rect.center(), + Align2::CENTER_CENTER, + "Z", + theme::mono(30.0), + Color32::from_rgb(0x4e, 0x8a, 0x68), + ); +} + +fn centered_note(ui: &mut Ui, text: &str) { + let available = ui.available_size(); + ui.add_space(available.y * 0.35); + ui.vertical_centered(|ui| { + ui.label( + eframe::egui::RichText::new(text) + .font(theme::sans(15.0)) + .color(theme::TEXT_DIM), + ); + }); +} 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 @@ +//! Crash screen: a live multiplier painted from `CrashView`. + +use eframe::egui::{Align2, Color32, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; +use zagan_bet::client::view::{Command, CrashView}; + +use crate::{theme, widgets}; + +/// One sample of the running curve. +struct Sample { + seconds: f32, + multiplier: f64, +} + +#[derive(Default)] +pub struct Fx { + points: Vec, + started: Option, + status: String, + peak: f64, + last_crash: Option, +} + +impl Fx { + pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &CrashView) { + let now = ctx.input(|input| input.time); + + if view.status == "starting" && self.status != "starting" { + self.points.clear(); + self.started = None; + self.peak = 1.0; + } + + if view.status == "running" { + let started = *self.started.get_or_insert(now); + let seconds = (now - started) as f32; + let last = self.points.last().map(|sample| sample.seconds); + if last.is_none_or(|last| seconds - last >= 0.016) { + self.points.push(Sample { + seconds, + multiplier: view.multiplier, + }); + self.peak = self.peak.max(view.multiplier); + } + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } else if view.status == "starting" { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + + if view.status == "crashed" { + if let Some(point) = view.crash_point { + self.last_crash = Some(point); + } + } + if view.status != self.status { + self.status = view.status.clone(); + } + } + + fn reset(&mut self) { + self.points.clear(); + self.started = None; + self.peak = 1.0; + } +} + +pub fn show(ui: &mut Ui, view: &CrashView, fx: &mut Fx) -> Option { + let mut command = None; + eframe::egui::CentralPanel::default() + .frame(eframe::egui::Frame::new().fill(theme::BG)) + .show(ui, |ui| { + let full = ui.max_rect(); + let sidebar_rect = eframe::egui::Rect::from_min_max( + eframe::egui::pos2(full.right() - 336.0, full.top()), + full.right_bottom(), + ); + let board_rect = eframe::egui::Rect::from_min_max( + full.min, + eframe::egui::pos2(sidebar_rect.left(), full.bottom()), + ); + ui.painter().rect_filled( + sidebar_rect, + eframe::egui::CornerRadius::ZERO, + theme::SURFACE, + ); + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0))) + .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), + |ui| chart(ui, view, fx), + ); + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0))) + .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), + |ui| { + command = sidebar(ui, view, fx); + }, + ); + }); + command +} + +fn sidebar(ui: &mut Ui, view: &CrashView, fx: &mut Fx) -> Option { + let mut command = None; + let running = view.status == "running"; + let starting = view.status == "starting"; + let (content, actions) = widgets::sidebar_layout(ui, 130.0); + + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(content) + .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), + |ui| { + eframe::egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.label( + eframe::egui::RichText::new("Crash") + .font(theme::sans(22.0)) + .color(theme::TEXT), + ); + ui.label( + eframe::egui::RichText::new("Rise · cash out · don't bust") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + + ui.add_space(theme::SPACE_5); + ui.label(theme::label("Bet")); + ui.add_space(theme::SPACE_2); + if let Some(delta) = widgets::bet_control(ui, view.bet, !running && !starting) { + command = Some(Command::AdjustBet(delta)); + } + + ui.add_space(theme::SPACE_4); + widgets::row( + ui, + "Potential", + &theme::money(view.potential as i64), + theme::GOLD, + ); + + ui.add_space(theme::SPACE_3); + status(ui, view); + + if let Some(point) = fx.last_crash { + ui.add_space(theme::SPACE_2); + widgets::row(ui, "Last crash", &format!("{point:.2}x"), theme::TEXT_DIM); + } + + if let Some(error) = &view.error { + ui.add_space(theme::SPACE_3); + widgets::error(ui, error); + } + + ui.add_space(theme::SPACE_4); + ui.separator(); + ui.add_space(theme::SPACE_2); + widgets::note( + ui, + "The crash point is server-timed. The curve here is display only.", + ); + }); + }, + ); + + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(actions) + .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)), + |ui| { + if widgets::ghost( + ui, + if running { + "Forfeit round" + } else { + "Leave table" + }, + ) { + command = Some(Command::Leave); + } + ui.add_space(theme::SPACE_2); + if running { + if widgets::primary(ui, "CASH OUT", true, ui.available_width(), 46.0) { + command = Some(Command::CashOut); + } + } else if !starting && widgets::primary(ui, "START", true, ui.available_width(), 46.0) { + fx.reset(); + command = Some(Command::Start); + } + }, + ); + + command +} + +fn status(ui: &mut Ui, view: &CrashView) { + match view.status.as_str() { + "starting" => widgets::note(ui, "Round starting…"), + "running" => widgets::note(ui, "Rising. Cash out before it crashes."), + "cashed" => { + ui.label( + eframe::egui::RichText::new(format!( + "CASHED OUT {:.2}x +{}", + view.multiplier, + theme::money(view.payout as i64) + )) + .font(theme::mono(14.0)) + .color(theme::WIN), + ); + } + "crashed" => { + ui.label( + eframe::egui::RichText::new(format!( + "CRASHED at {:.2}x", + view.crash_point.unwrap_or(view.multiplier) + )) + .font(theme::mono(14.0)) + .color(theme::LOSS), + ); + } + "forfeit" => { + ui.label( + eframe::egui::RichText::new("FORFEITED") + .font(theme::mono(14.0)) + .color(theme::TEXT_DIM), + ); + } + _ => widgets::note(ui, "Press START to play."), + } +} + +fn chart(ui: &mut Ui, view: &CrashView, fx: &Fx) { + ui.horizontal(|ui| { + ui.label( + eframe::egui::RichText::new("Table 01") + .font(theme::sans(15.0)) + .color(theme::TEXT), + ); + ui.label( + eframe::egui::RichText::new("Crash · server-timed") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + ui.with_layout( + eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), + |ui| { + ui.label( + eframe::egui::RichText::new("Cash out any time before the crash") + .font(theme::mono(11.0)) + .color(theme::TEXT_FAINT), + ); + }, + ); + }); + ui.add_space(theme::SPACE_4); + + let available = ui.available_size(); + let size = vec2(available.x, (available.y - 70.0).max(260.0)); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + let painter = ui.painter(); + + painter.rect_filled(rect, theme::RADIUS_LG, theme::SURFACE); + painter.rect_stroke( + rect, + theme::RADIUS_LG, + Stroke::new(1.0, theme::LINE), + StrokeKind::Inside, + ); + + let plot = rect.shrink2(vec2(56.0, 48.0)); + let max_seconds = fx + .points + .last() + .map(|sample| sample.seconds) + .unwrap_or(0.0) + .max(6.0) + * 1.08; + let max_multiplier = fx.peak.max(2.0) * 1.15; + + painter.text( + plot.left_top() + vec2(0.0, 2.0), + Align2::LEFT_TOP, + format!("{:.2}x", view.multiplier), + theme::mono(46.0), + curve_color(view), + ); + painter.text( + plot.left_top() + vec2(2.0, 58.0), + Align2::LEFT_TOP, + status_label(view), + theme::mono(10.0), + theme::TEXT_FAINT, + ); + + // Grid: whole multipliers and two-second columns. + let grid = Stroke::new(1.0, theme::LINE.gamma_multiply(0.55)); + let mut step = 1.0; + while max_multiplier / step > 6.0 { + step += 1.0; + } + let mut value = step; + while value <= max_multiplier { + let y = map_y(plot, value, max_multiplier); + painter.hline(plot.x_range(), y, grid); + painter.text( + pos2(plot.left() - 10.0, y), + Align2::RIGHT_CENTER, + format!("{value:.0}x"), + theme::mono(10.0), + theme::TEXT_FAINT, + ); + value += step; + } + let mut second = 2.0; + while f64::from(second) <= f64::from(max_seconds) { + let x = plot.left() + plot.width() * (second / max_seconds); + painter.vline(x, plot.y_range(), grid); + painter.text( + pos2(x, plot.bottom() + 8.0), + Align2::CENTER_TOP, + format!("{second:.0}s"), + theme::mono(10.0), + theme::TEXT_FAINT, + ); + second += 2.0; + } + + if fx.points.is_empty() { + painter.text( + plot.center(), + Align2::CENTER_CENTER, + if view.status == "running" { + "…" + } else { + "Press START to play" + }, + theme::sans(14.0), + theme::TEXT_FAINT, + ); + } else { + let points: Vec = fx + .points + .iter() + .map(|sample| { + pos2( + plot.left() + plot.width() * (sample.seconds / max_seconds), + map_y(plot, sample.multiplier, max_multiplier), + ) + }) + .collect(); + + // A soft fill under the curve. + if points.len() >= 2 { + let mut fill = points.clone(); + fill.push(pos2(points[points.len() - 1].x, plot.bottom())); + fill.push(pos2(points[0].x, plot.bottom())); + painter.add(Shape::convex_polygon( + fill, + curve_color(view).gamma_multiply(0.10), + Stroke::NONE, + )); + } + painter.add(Shape::line(points, Stroke::new(2.5, curve_color(view)))); + + if view.status == "crashed" { + if let Some(point) = view.crash_point { + let y = map_y(plot, point, max_multiplier); + let x = plot.left() + + plot.width() * (fx.points.last().map_or(0.0, |s| s.seconds) / max_seconds); + painter.circle_filled(pos2(x, y), 5.0, theme::LOSS); + painter.text( + pos2(x + 10.0, y - 8.0), + Align2::LEFT_BOTTOM, + format!("{point:.2}x"), + theme::mono(12.0), + theme::LOSS, + ); + } + } + } + + ui.add_space(theme::SPACE_4); + ui.label( + eframe::egui::RichText::new( + "The crash point is drawn from the server; the multiplier here is display only.", + ) + .font(theme::sans(11.0)) + .color(theme::TEXT_FAINT), + ); +} + +fn map_y(plot: Rect, multiplier: f64, max_multiplier: f64) -> f32 { + let range = (max_multiplier - 1.0).max(0.1); + plot.bottom() - ((multiplier - 1.0) / range) as f32 * plot.height() +} + +fn status_label(view: &CrashView) -> &'static str { + match view.status.as_str() { + "starting" => "STARTING", + "running" => "LIVE", + "cashed" => "CASHED OUT", + "crashed" => "CRASHED", + "forfeit" => "FORFEITED", + _ => "READY", + } +} + +fn curve_color(view: &CrashView) -> Color32 { + match view.status.as_str() { + "running" => theme::WIN, + "cashed" => theme::GOLD, + "crashed" => theme::LOSS, + _ => theme::TEXT_DIM, + } +} 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 @@ //! layout. It never enters the TUI or server build graph. pub mod app; +pub mod blackjack; +pub mod crash; +pub mod net; pub mod roulette; +pub mod slots; pub mod theme; +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 @@ //! Desktop frontend for zagan-bet. //! -//! Phase 3 of `docs/GUI-PLAN.md`: the style spike. It owns the window and the -//! roulette reference screen; the shared core and transport are wired in -//! Phase 4. +//! Phase 4: the egui shell over the shared `ClientCore`, with the transport on +//! a worker thread. The same data directory and flags as the terminal client. -use zagan_bet_gui::app::ZaganApp; +use zagan_bet::client::paths::{default_data_dir, parse_args}; +use zagan_bet_gui::app::{Startup, ZaganApp}; fn main() -> eframe::Result { + let args = match parse_args(std::env::args().skip(1)) { + Ok(args) => args, + Err(message) => { + eprintln!("{message}"); + std::process::exit(2); + } + }; + if args.help { + println!( + "usage: zagan-bet-gui [-d ] [--unsafe] + + -d, --data-dir where to keep client.db (default: XDG data dir) + --unsafe skip certificate verification + -h, --help show this message" + ); + return Ok(()); + } + + let data_dir = args.data_dir.unwrap_or_else(default_data_dir); + if let Err(error) = std::fs::create_dir_all(&data_dir) { + eprintln!("Cannot create {}: {error}", data_dir.display()); + std::process::exit(1); + } + let options = eframe::NativeOptions { viewport: eframe::egui::ViewportBuilder::default() .with_title("Zagan-Bet") @@ -17,9 +41,13 @@ fn main() -> eframe::Result { ..Default::default() }; + let startup = Startup { + data_dir, + unsafe_mode: args.unsafe_mode, + }; eframe::run_native( "Zagan-Bet", options, - Box::new(|cc| Ok(Box::new(ZaganApp::new(cc)))), + Box::new(move |cc| Ok(Box::new(ZaganApp::new(cc, startup)))), ) } 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 @@ +//! Background network worker for the desktop frontend. +//! +//! The core is I/O-free, so the adapter owns the TLS stream. One blocking +//! thread keeps the connection, forwards every server line, and wakes the UI +//! through `egui::Context::request_repaint`. There is no async runtime: the +//! protocol has one request in flight plus server pushes. + +use std::path::PathBuf; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::thread; +use std::time::Duration; + +use eframe::egui; +use rusqlite::Connection; +use zagan_bet::client::transport::{self, TlsStream, TransportError, TrustPrompt}; +use zagan_bet::client::{database, paths}; + +/// How often the worker checks the socket for pushes when idle. +const POLL: Duration = Duration::from_millis(8); +/// How long a first-contact prompt may stay unanswered. +const TRUST_TIMEOUT: Duration = Duration::from_secs(120); + +/// What the UI asks the worker to do. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + Connect { addr: String }, + Send(String), + Disconnect, + Stop, +} + +/// Why a connection attempt failed. The UI picks a screen from this. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Failure { + TrustDeclined, + KeyChanged { expected: String, observed: String }, + VersionMismatch, + Timeout, + Other, +} + +/// A connection that survived trust, version and auto-login. +#[derive(Debug, Clone, PartialEq)] +pub struct Connected { + pub addr: String, + pub user: Option, + pub auth_error: Option, + pub fingerprint: Option, + pub server_version: String, + pub latest: String, + pub url: String, + pub elapsed_ms: f32, +} + +/// What the worker tells the UI. +pub enum Event { + /// First contact: the UI must answer through the sender. + Trust { + addr: String, + fingerprint: String, + reply: Sender, + }, + Connected(Connected), + Failed { + addr: String, + failure: Failure, + message: String, + }, + Line(String), + Closed, +} + +fn classify(error: &TransportError) -> Failure { + match error { + TransportError::TrustDeclined => Failure::TrustDeclined, + TransportError::KeyChanged { expected, observed } => Failure::KeyChanged { + expected: expected.clone(), + observed: observed.clone(), + }, + TransportError::VersionMismatch(_) => Failure::VersionMismatch, + TransportError::Timeout => Failure::Timeout, + _ => Failure::Other, + } +} + +/// Handle held by the app. Dropping it stops the worker. +pub struct Network { + commands: Sender, + events: Receiver, +} + +impl Network { + pub fn spawn(ctx: egui::Context, data_dir: PathBuf, unsafe_mode: bool) -> Self { + let (commands, command_rx) = mpsc::channel(); + let (event_tx, events) = mpsc::channel(); + thread::spawn(move || worker(ctx, data_dir, unsafe_mode, command_rx, event_tx)); + Self { commands, events } + } + + pub fn send(&self, command: Command) { + let _ = self.commands.send(command); + } + + pub fn try_recv(&self) -> Option { + self.events.try_recv().ok() + } +} + +impl Drop for Network { + fn drop(&mut self) { + let _ = self.commands.send(Command::Stop); + } +} + +fn worker( + ctx: egui::Context, + data_dir: PathBuf, + unsafe_mode: bool, + commands: Receiver, + events: Sender, +) { + let db_path = paths::database_path(&data_dir); + let conn = match Connection::open(&db_path) { + Ok(conn) => { + let _ = conn.busy_timeout(Duration::from_secs(2)); + database::init_db(&conn); + Some(conn) + } + Err(_) => None, + }; + + let mut stream: Option = None; + + loop { + match commands.recv_timeout(POLL) { + Ok(Command::Connect { addr }) => { + stream = None; + let Some(conn) = &conn else { + let _ = events.send(Event::Failed { + addr, + failure: Failure::Other, + message: format!("cannot open {}", db_path.display()), + }); + ctx.request_repaint(); + continue; + }; + let mut trust = |prompt: &TrustPrompt| { + let (reply, decision) = mpsc::channel(); + let _ = events.send(Event::Trust { + addr: prompt.addr.clone(), + fingerprint: prompt.fingerprint.clone(), + reply, + }); + ctx.request_repaint(); + decision.recv_timeout(TRUST_TIMEOUT).unwrap_or(false) + }; + match transport::connect(&addr, conn, unsafe_mode, &mut trust) { + Ok(connected) => { + let fingerprint = database::get_fingerprint(conn, &addr); + let _ = events.send(Event::Connected(Connected { + addr: addr.clone(), + user: connected.user, + auth_error: connected.auth_error, + fingerprint, + server_version: connected.server_version, + latest: connected.latest, + url: connected.url, + elapsed_ms: connected.elapsed.as_secs_f64() as f32 * 1000.0, + })); + stream = Some(connected.stream); + } + Err(error) => { + let _ = events.send(Event::Failed { + addr, + failure: classify(&error), + message: error.to_string(), + }); + } + } + ctx.request_repaint(); + } + Ok(Command::Send(line)) => { + if let Some(open) = &mut stream { + if open.write_line(&line).is_err() { + stream = None; + let _ = events.send(Event::Closed); + } + } + ctx.request_repaint(); + } + Ok(Command::Disconnect) => { + stream = None; + ctx.request_repaint(); + } + Ok(Command::Stop) => break, + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + + // Drain pushes and replies without blocking the command channel. + if let Some(open) = &mut stream { + loop { + match open.read_available_line() { + Ok(Some(line)) => { + if events.send(Event::Line(line)).is_err() { + return; + } + } + Ok(None) => break, + Err(_) => { + stream = None; + let _ = events.send(Event::Closed); + break; + } + } + } + ctx.request_repaint(); + } + } +} 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 theme::FELT_TEXT, ); } + +// --------------------------------------------------------------------------- +// The screen +// --------------------------------------------------------------------------- + +/// Wheel animation and result history. Kept across frames; reset when the +/// player leaves the table. +pub struct Fx { + rotation: f32, + spin: Option, + awaiting_result: bool, + settled: Option, + last: Vec, +} + +struct WheelSpin { + started: f64, + from: f32, + delta: f32, + target: u8, +} + +impl Default for Fx { + fn default() -> Self { + Self { + rotation: 0.0, + spin: None, + awaiting_result: false, + settled: None, + last: Vec::new(), + } + } +} + +impl Fx { + /// Called when the player asks for a spin, before the reply can arrive. + pub fn spin_requested(&mut self) { + self.awaiting_result = true; + } + + pub fn ticking(&self) -> bool { + self.spin.is_some() + } + + /// Advance the wheel; starts a spin when a new result lands. + pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &RouletteView) { + let now = ctx.input(|input| input.time); + if view.error.is_some() { + // The request was refused, so no result is coming. + self.awaiting_result = false; + } + if self.awaiting_result && view.phase == RoulettePhase::Result { + if let Some(target) = view.number { + self.awaiting_result = false; + self.start(now, target); + } + } + + let Some(spin) = &self.spin else { + return; + }; + let t = (((now - spin.started) / 1.7) as f32).clamp(0.0, 1.0); + let eased = 1.0 - (1.0 - t).powi(3); + self.rotation = spin.from + spin.delta * eased; + if t >= 1.0 { + let target = spin.target; + self.spin = None; + self.settled = Some(target); + self.last.insert(0, target); + self.last.truncate(12); + } + ctx.request_repaint(); + } + + fn start(&mut self, now: f64, target: u8) { + let segment = TAU / WHEEL.len() as f32; + let index = WHEEL + .iter() + .position(|number| *number == target) + .unwrap_or(0) as f32; + let desired = -index * segment; + let delta = (desired - self.rotation).rem_euclid(TAU) + 3.0 * TAU; + self.spin = Some(WheelSpin { + started: now, + from: self.rotation, + delta, + target, + }); + } + + fn spinning(&self) -> bool { + self.spin.is_some() + } + + /// The payout is held back until the wheel has stopped. + pub fn revealed(&self, view: &RouletteView) -> bool { + view.phase == RoulettePhase::Result && !self.spinning() + } +} + +pub fn show( + ui: &mut Ui, + view: &RouletteView, + fx: &mut Fx, +) -> Option { + let mut command = None; + // While the wheel turns, keep the result hidden: no winning cell and no + // payout until it stops under the pointer. + let mut shown = view.clone(); + if fx.spinning() && view.phase == RoulettePhase::Result { + shown.phase = RoulettePhase::Spinning; + } + eframe::egui::CentralPanel::default() + .frame(eframe::egui::Frame::new().fill(theme::BG)) + .show(ui, |ui| { + let full = ui.max_rect(); + let sidebar_rect = eframe::egui::Rect::from_min_max( + eframe::egui::pos2(full.right() - 336.0, full.top()), + full.right_bottom(), + ); + let board_rect = eframe::egui::Rect::from_min_max( + full.min, + eframe::egui::pos2(sidebar_rect.left(), full.bottom()), + ); + ui.painter().rect_filled( + sidebar_rect, + eframe::egui::CornerRadius::ZERO, + theme::SURFACE, + ); + + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0))) + .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), + |ui| { + if let Some(clicked) = board(ui, &shown, fx) { + command = Some(clicked); + } + }, + ); + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0))) + .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), + |ui| { + if let Some(clicked) = sidebar(ui, &shown, fx) { + command = Some(clicked); + } + }, + ); + }); + command +} + +fn sidebar( + ui: &mut Ui, + view: &RouletteView, + fx: &mut Fx, +) -> Option { + use zagan_bet::client::view::Command; + + let mut command = None; + let (content, actions) = super::widgets::sidebar_layout(ui, 150.0); + + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(content) + .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), + |ui| { + eframe::egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.label( + eframe::egui::RichText::new("Roulette") + .font(theme::sans(22.0)) + .color(theme::TEXT), + ); + ui.label( + eframe::egui::RichText::new("European · single zero") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + + ui.add_space(theme::SPACE_4); + let wheel_size = ui.available_width().min(210.0); + ui.horizontal(|ui| { + let pad = ((ui.available_width() - wheel_size) * 0.5).max(0.0); + ui.add_space(pad); + wheel(ui, wheel_size, view, fx.rotation); + }); + ui.add_space(theme::SPACE_1); + + let spinning = view.phase == RoulettePhase::Spinning || fx.spinning(); + if spinning { + ui.label( + eframe::egui::RichText::new("SPINNING…") + .font(theme::mono(12.0)) + .color(theme::GOLD), + ); + } else if fx.revealed(view) { + ui.horizontal(|ui| { + let number = view.number.unwrap_or(0); + ui.label(theme::label("Result")); + ui.label( + eframe::egui::RichText::new(format!( + "{number} {}", + wheel_color(number).to_uppercase() + )) + .font(theme::mono(14.0)) + .color(number_color(number)), + ); + ui.with_layout( + eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), + |ui| { + let winnings = view.winnings.unwrap_or(0); + if winnings == 0 { + ui.label( + eframe::egui::RichText::new("NO WIN") + .font(theme::mono(13.0)) + .color(theme::TEXT_FAINT), + ); + } else { + let profit = winnings as i64 - view.bets.total() as i64; + ui.label( + eframe::egui::RichText::new(format!( + "({})", + theme::signed_money(profit) + )) + .font(theme::sans(11.0)) + .color( + if profit >= 0 { theme::WIN } else { theme::LOSS }, + ), + ); + ui.label( + eframe::egui::RichText::new(theme::money( + winnings as i64, + )) + .font(theme::mono(15.0)) + .color(theme::WIN), + ); + ui.label(theme::label("Won")); + } + }, + ); + }); + } else { + ui.horizontal(|ui| { + ui.label( + eframe::egui::RichText::new("Place your bets") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + ui.with_layout( + eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), + |ui| { + ui.label( + eframe::egui::RichText::new(match view.bets.chips.len() { + 1 => "1 chip".to_string(), + count => format!("{count} chips"), + }) + .font(theme::mono(10.0)) + .color(theme::TEXT_FAINT), + ); + }, + ); + }); + } + + ui.add_space(theme::SPACE_3); + ui.separator(); + ui.add_space(theme::SPACE_3); + + ui.label(theme::label("Chip")); + ui.add_space(theme::SPACE_2); + if let Some(chip) = + super::widgets::chip_row(ui, [10, 25, 50, 100], view.chip, !spinning) + { + command = Some(Command::SetChip(chip)); + } + + ui.add_space(theme::SPACE_3); + ui.label(theme::label("Bets")); + ui.add_space(theme::SPACE_1); + if view.bets.chips.is_empty() { + ui.label( + eframe::egui::RichText::new("No chips on the felt") + .font(theme::sans(12.0)) + .color(theme::TEXT_FAINT), + ); + } else { + for chip in &view.bets.chips { + super::widgets::row( + ui, + &target_name(&chip.target), + &theme::money(chip.amount as i64), + theme::GOLD, + ); + } + } + + ui.add_space(theme::SPACE_3); + ui.separator(); + ui.add_space(theme::SPACE_2); + super::widgets::row( + ui, + "Total", + &theme::money(view.bets.total() as i64), + theme::GOLD, + ); + }); + }, + ); + + let spinning = view.phase == RoulettePhase::Spinning || fx.spinning(); + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(actions) + .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)), + |ui| { + if super::widgets::ghost(ui, "Leave table") { + command = Some(Command::Leave); + } + ui.add_space(theme::SPACE_2); + if super::widgets::secondary( + ui, + "CLEAR", + !spinning && !view.bets.is_empty(), + ui.available_width(), + 36.0, + ) { + command = Some(Command::ClearBets); + } + ui.add_space(theme::SPACE_2); + let clicked = super::widgets::primary( + ui, + "SPIN", + !spinning && !view.bets.is_empty(), + ui.available_width(), + 44.0, + ); + if clicked { + fx.spin_requested(); + command = Some(Command::Spin); + } + }, + ); + + command +} + +fn board(ui: &mut Ui, view: &RouletteView, fx: &Fx) -> Option { + use zagan_bet::client::view::Command; + + ui.horizontal(|ui| { + ui.label( + eframe::egui::RichText::new("Table 01") + .font(theme::sans(15.0)) + .color(theme::TEXT), + ); + ui.label( + eframe::egui::RichText::new("European · single zero") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + ui.with_layout( + eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), + |ui| { + ui.label( + eframe::egui::RichText::new("Limits 10 – 500 Kr") + .font(theme::mono(12.0)) + .color(theme::TEXT_FAINT), + ); + }, + ); + }); + ui.add_space(theme::SPACE_4); + + let strip_height = 104.0; + let available = ui.available_size(); + let size = size_for(vec2(available.x, (available.y - strip_height).max(220.0))); + let side_pad = ((available.x - size.x) * 0.5).max(0.0); + let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0); + ui.add_space(top_pad); + + let enabled = view.phase != RoulettePhase::Spinning && !fx.spinning(); + let action = ui + .horizontal(|ui| { + ui.add_space(side_pad); + felt(ui, size, view) + }) + .inner; + let command = match action { + Some(FeltAction::Place(target)) if enabled => Some(Command::PlaceBetAt(target)), + Some(FeltAction::Reduce(target)) if enabled => Some(Command::RemoveBetAt(target)), + _ => None, + }; + + ui.add_space(theme::SPACE_4); + ui.horizontal(|ui| { + ui.label(theme::label("Last")); + if fx.last.is_empty() { + ui.label( + eframe::egui::RichText::new("No spins yet") + .font(theme::sans(12.0)) + .color(theme::TEXT_FAINT), + ); + } + for number in &fx.last { + let (dot, _) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::hover()); + paint_result_dot(ui.painter(), dot.center(), 11.0, *number); + ui.add_space(theme::SPACE_1); + } + }); + ui.add_space(theme::SPACE_2); + ui.label( + eframe::egui::RichText::new( + "Click to place · right or middle click takes back · C clears · space spins", + ) + .font(theme::sans(12.0)) + .color(theme::TEXT_FAINT), + ); + command +} + +fn number_color(number: u8) -> Color32 { + match wheel_color(number) { + "red" => theme::ROULETTE_RED, + "green" => theme::WIN, + _ => theme::TEXT, + } +} 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 @@ +//! Slots screen: a 5x3 board painted from `SlotsView`. + +use eframe::egui::{ + Align2, Color32, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, vec2, +}; +use zagan_bet::client::view::{Command, SlotsPhase, SlotsView}; +use zagan_bet::slots::{LINE_NAMES, PAYLINES, REELS, ROWS, SYMBOLS}; + +use crate::{theme, widgets}; + +/// Reel animation. The server answers at once, so the UI spins the reels for +/// this long before revealing the result it already has. +const SPIN_SECONDS: f64 = 0.9; + +#[derive(Default)] +pub struct Fx { + started: Option, +} + +impl Fx { + pub fn spin_requested(&mut self, ctx: &eframe::egui::Context) { + self.started = Some(ctx.input(|input| input.time)); + } + + fn spinning(&self, view: &SlotsView, now: f64) -> bool { + // A rejected spin (no balance, server error) never reaches the reels. + if view.error.is_some() { + return false; + } + match self.started { + Some(started) => now - started < SPIN_SECONDS || view.phase == SlotsPhase::Spinning, + None => view.phase == SlotsPhase::Spinning, + } + } + + /// True once the reels have stopped on the server's answer. + pub fn revealed(&self, ctx: &eframe::egui::Context, view: &SlotsView) -> bool { + let now = ctx.input(|input| input.time); + !self.spinning(view, now) + } + + pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &SlotsView) { + if view.error.is_some() { + self.started = None; + return; + } + let now = ctx.input(|input| input.time); + if self.spinning(view, now) { + ctx.request_repaint(); + } else { + self.started = None; + } + } +} + +pub fn show(ui: &mut Ui, view: &SlotsView, fx: &mut Fx) -> Option { + let mut command = None; + eframe::egui::CentralPanel::default() + .frame(eframe::egui::Frame::new().fill(theme::BG)) + .show(ui, |ui| { + let full = ui.max_rect(); + let sidebar_rect = eframe::egui::Rect::from_min_max( + eframe::egui::pos2(full.right() - 336.0, full.top()), + full.right_bottom(), + ); + let board_rect = eframe::egui::Rect::from_min_max( + full.min, + eframe::egui::pos2(sidebar_rect.left(), full.bottom()), + ); + ui.painter().rect_filled( + sidebar_rect, + eframe::egui::CornerRadius::ZERO, + theme::SURFACE, + ); + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0))) + .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), + |ui| board(ui, view, fx), + ); + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0))) + .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), + |ui| { + command = sidebar(ui, view, fx); + }, + ); + }); + command +} + +fn sidebar(ui: &mut Ui, view: &SlotsView, fx: &mut Fx) -> Option { + let now = ui.input(|input| input.time); + let spinning = fx.spinning(view, now); + let mut command = None; + let (content, actions) = widgets::sidebar_layout(ui, 150.0); + + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(content) + .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), + |ui| { + eframe::egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.label( + eframe::egui::RichText::new("Slots") + .font(theme::sans(22.0)) + .color(theme::TEXT), + ); + ui.label( + eframe::egui::RichText::new("5 reels · 5 paylines") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + + ui.add_space(theme::SPACE_5); + ui.label(theme::label("Bet")); + ui.add_space(theme::SPACE_2); + if let Some(delta) = widgets::bet_control(ui, view.bet, !spinning) { + command = Some(Command::AdjustBet(delta)); + } + + ui.add_space(theme::SPACE_4); + status(ui, view, spinning); + if let Some(error) = &view.error { + ui.add_space(theme::SPACE_3); + widgets::error(ui, error); + } + + ui.add_space(theme::SPACE_4); + ui.separator(); + ui.add_space(theme::SPACE_3); + ui.label(theme::label("Paytable")); + ui.add_space(theme::SPACE_2); + ui.label( + eframe::egui::RichText::new("Pays bet × symbol × shape") + .font(theme::sans(11.0)) + .color(theme::TEXT_FAINT), + ); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = theme::SPACE_2; + for (symbol, multiplier) in zagan_bet::slots::SYMBOL_MULTIPLIERS { + ui.label( + eframe::egui::RichText::new(format!("{symbol}{multiplier}")) + .font(theme::mono(12.0)) + .color(symbol_color(symbol)), + ); + } + }); + ui.label( + eframe::egui::RichText::new("3×1 4×3 5×14") + .font(theme::mono(12.0)) + .color(theme::TEXT_DIM), + ); + }); + }, + ); + + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(actions) + .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)), + |ui| { + if widgets::ghost(ui, "Leave machine") { + command = Some(Command::Leave); + } + ui.add_space(theme::SPACE_2); + if widgets::primary(ui, "SPIN", !spinning, ui.available_width(), 46.0) { + fx.spin_requested(ui.ctx()); + command = Some(Command::Spin); + } + }, + ); + + command +} + +fn status(ui: &mut Ui, view: &SlotsView, spinning: bool) { + if spinning { + ui.label( + eframe::egui::RichText::new("SPINNING…") + .font(theme::mono(12.0)) + .color(theme::GOLD), + ); + return; + } + + match view.phase { + SlotsPhase::Result => { + let winnings = view.winnings.unwrap_or(0); + if winnings == 0 { + ui.label( + eframe::egui::RichText::new("NO WIN") + .font(theme::mono(13.0)) + .color(theme::TEXT_FAINT), + ); + } else { + let label = match view.win_type.as_str() { + "mega" => "MEGA WIN", + "big" => "BIG WIN", + _ => "WON", + }; + ui.horizontal(|ui| { + ui.label(theme::label(label)); + ui.label( + eframe::egui::RichText::new(theme::money(winnings as i64)) + .font(theme::mono(18.0)) + .color(theme::WIN), + ); + }); + if !view.win_lines.is_empty() { + let names: Vec = view + .win_lines + .iter() + .map(|line| { + line.checked_sub(1) + .and_then(|index| LINE_NAMES.get(index).copied()) + .map(|name| format!("{line} ({name})")) + .unwrap_or_else(|| line.to_string()) + }) + .collect(); + ui.label( + eframe::egui::RichText::new(names.join(" · ")) + .font(theme::sans(11.0)) + .color(theme::TEXT_DIM), + ); + } + } + } + _ => { + ui.label( + eframe::egui::RichText::new("Press SPIN to play") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + } + } +} + +fn board(ui: &mut Ui, view: &SlotsView, fx: &Fx) { + ui.horizontal(|ui| { + ui.label( + eframe::egui::RichText::new("Machine 01") + .font(theme::sans(15.0)) + .color(theme::TEXT), + ); + ui.label( + eframe::egui::RichText::new("5 reels · 5 paylines") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + ui.with_layout( + eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), + |ui| { + ui.label( + eframe::egui::RichText::new("Bet 10 – 500 Kr") + .font(theme::mono(12.0)) + .color(theme::TEXT_FAINT), + ); + }, + ); + }); + ui.add_space(theme::SPACE_5); + + let now = ui.input(|input| input.time); + let spinning = fx.spinning(view, now); + let available = ui.available_size(); + let gap = theme::SPACE_2; + let tile_width = ((available.x - gap * (REELS as f32 - 1.0)) / REELS as f32) + .min((available.y - 150.0) / 3.0) + .min(190.0); + let tile = vec2(tile_width, tile_width * 0.92); + let board_width = tile.x * REELS as f32 + gap * (REELS as f32 - 1.0); + let board_height = tile.y * 3.0 + gap * 2.0; + let left = ((available.x - board_width) * 0.5).max(0.0); + let top = ((available.y - board_height) * 0.35).max(0.0); + + ui.add_space(top); + let origin = ui + .horizontal(|ui| { + ui.add_space(left); + ui.allocate_exact_size(vec2(board_width, board_height), Sense::hover()) + .0 + .min + }) + .inner; + + let painter = ui.painter(); + let cycling = (now * 18.0) as usize; + for col in 0..REELS { + for row in 0..ROWS { + let rect = Rect::from_min_size( + origin + vec2(col as f32 * (tile.x + gap), row as f32 * (tile.y + gap)), + tile, + ); + let index = col * ROWS + row; + let stocked = + view.reels.len() == REELS * ROWS || (view.reels.len() == REELS && row == 0); + let symbol = if spinning { + SYMBOLS[(cycling + index * 3) % SYMBOLS.len()] + } else if stocked { + if view.reels.len() == REELS * ROWS { + view.reels[index] + } else { + view.reels[col] + } + } else { + SYMBOLS[(index * 5 + col) % SYMBOLS.len()] + }; + let winning = !spinning && view.win_cells.contains(&index); + paint_tile(painter, rect, symbol, spinning, winning, !stocked); + } + } + + if !spinning && view.phase == SlotsPhase::Result { + paint_win_lines(painter, origin, tile, gap, view); + } + + ui.add_space(theme::SPACE_5); + ui.horizontal(|ui| { + ui.label(theme::label("Result")); + if spinning { + ui.label( + eframe::egui::RichText::new("Spinning…") + .font(theme::mono(12.0)) + .color(theme::GOLD), + ); + } else if view.phase == SlotsPhase::Result { + let winnings = view.winnings.unwrap_or(0); + if winnings == 0 { + ui.label( + eframe::egui::RichText::new("No win") + .font(theme::mono(12.0)) + .color(theme::TEXT_DIM), + ); + } else { + let label = match view.win_type.as_str() { + "mega" => "MEGA WIN", + "big" => "BIG WIN", + _ => "WIN", + }; + ui.label( + eframe::egui::RichText::new(format!( + "{label} +{}", + theme::money(winnings as i64) + )) + .font(theme::mono(12.0)) + .color(theme::WIN), + ); + } + } else { + ui.label( + eframe::egui::RichText::new("Ready") + .font(theme::mono(12.0)) + .color(theme::TEXT_DIM), + ); + } + }); +} + +fn paint_tile( + painter: &Painter, + rect: Rect, + symbol: char, + spinning: bool, + winning: bool, + placeholder: bool, +) { + let fill = if winning { + theme::GOLD.gamma_multiply(0.14) + } else { + theme::SURFACE + }; + painter.rect_filled(rect, theme::RADIUS_MD, fill); + let stroke = if winning { + Stroke::new(2.0, theme::GOLD) + } else { + Stroke::new(1.0, theme::LINE) + }; + painter.rect_stroke(rect, theme::RADIUS_MD, stroke, StrokeKind::Inside); + + let color = if spinning || placeholder { + symbol_color(symbol).gamma_multiply(0.35) + } else { + symbol_color(symbol) + }; + painter.text( + rect.center(), + Align2::CENTER_CENTER, + symbol, + theme::mono(rect.width() * 0.42), + color, + ); +} + +fn paint_win_lines(painter: &Painter, origin: Pos2, tile: Vec2, gap: f32, view: &SlotsView) { + for line in &view.win_lines { + let columns = winning_columns(view, *line); + if columns.len() < 2 { + continue; + } + let points: Vec = columns + .iter() + .map(|col| { + let row = PAYLINES[*line - 1][*col]; + Rect::from_min_size( + origin + vec2(*col as f32 * (tile.x + gap), row as f32 * (tile.y + gap)), + tile, + ) + .center() + }) + .collect(); + painter.add(eframe::egui::Shape::line( + points, + Stroke::new(2.0, theme::GOLD.gamma_multiply(0.8)), + )); + } +} + +/// The reels that actually matched on a payline. +/// +/// A win is a prefix of matching symbols from reel 1, so the line must stop +/// at the last winning cell instead of crossing all five reels. +pub fn winning_columns(view: &SlotsView, line: usize) -> Vec { + let Some(rows) = line.checked_sub(1).and_then(|index| PAYLINES.get(index)) else { + return Vec::new(); + }; + rows.iter() + .enumerate() + .filter(|(col, row)| view.win_cells.contains(&(col * ROWS + *row))) + .map(|(col, _)| col) + .collect() +} + +pub fn symbol_color(symbol: char) -> Color32 { + match symbol { + 'C' => Color32::from_rgb(0xe0, 0x5a, 0x5a), + 'L' => Color32::from_rgb(0xe0, 0xb6, 0x4a), + 'O' => Color32::from_rgb(0xe0, 0x8a, 0x3c), + 'G' => Color32::from_rgb(0xb5, 0x7e, 0xdc), + 'D' => Color32::from_rgb(0x4f, 0xb8, 0xc9), + 'S' => Color32::from_rgb(0x4e, 0xc2, 0x7b), + _ => theme::TEXT, + } +} 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 @@ +//! Small shared controls. Every button and panel comes from here so the +//! screens cannot drift apart. + +use eframe::egui::{ + Align, Button, Color32, Frame, Layout, Margin, RichText, Sense, Stroke, Ui, Vec2, vec2, +}; + +use crate::theme; + +pub fn panel_frame() -> Frame { + Frame::new() + .fill(theme::SURFACE) + .corner_radius(theme::RADIUS_LG) + .inner_margin(Margin::same(theme::SPACE_4 as i8)) +} + +pub fn inset_frame() -> Frame { + Frame::new() + .fill(theme::SURFACE_RAISED) + .corner_radius(theme::RADIUS_MD) + .inner_margin(Margin::same(theme::SPACE_3 as i8)) +} + +/// The one green action on a screen. +pub fn primary(ui: &mut Ui, text: &str, enabled: bool, width: f32, height: f32) -> bool { + let fill = if enabled { + theme::WIN + } else { + theme::WIN.gamma_multiply(0.35) + }; + let response = ui.add_enabled( + enabled, + Button::new(RichText::new(text).font(theme::mono(13.0)).color(theme::BG)) + .fill(fill) + .corner_radius(theme::RADIUS_MD) + .min_size(vec2(width, height)), + ); + if response.hovered() { + ui.painter().rect_filled( + response.rect, + theme::RADIUS_MD, + Color32::WHITE.gamma_multiply(0.10), + ); + ui.ctx() + .set_cursor_icon(eframe::egui::CursorIcon::PointingHand); + } + response.clicked() +} + +/// A bordered action that is not the main verb. +pub fn secondary(ui: &mut Ui, text: &str, enabled: bool, width: f32, height: f32) -> bool { + let stroke = if enabled { + theme::LINE + } else { + theme::LINE.gamma_multiply(0.5) + }; + let response = ui.add_enabled( + enabled, + Button::new( + RichText::new(text) + .font(theme::mono(12.0)) + .color(theme::TEXT_DIM), + ) + .fill(theme::SURFACE_RAISED) + .stroke(Stroke::new(1.0, stroke)) + .corner_radius(theme::RADIUS_MD) + .min_size(vec2(width, height)), + ); + if response.hovered() { + ui.painter().rect_filled( + response.rect, + theme::RADIUS_MD, + Color32::WHITE.gamma_multiply(0.06), + ); + ui.ctx() + .set_cursor_icon(eframe::egui::CursorIcon::PointingHand); + } + response.clicked() +} + +/// A quiet text action, for "back" and "forget". +pub fn ghost(ui: &mut Ui, text: &str) -> bool { + let response = ui.add( + Button::new( + RichText::new(text) + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ) + .frame(false), + ); + if response.hovered() { + ui.painter().rect_filled( + response.rect.expand(2.0), + theme::RADIUS_SM, + Color32::WHITE.gamma_multiply(0.05), + ); + ui.ctx() + .set_cursor_icon(eframe::egui::CursorIcon::PointingHand); + } + response.clicked() +} + +/// Split a game sidebar into a flexible content area and a pinned action bar, +/// so the primary action never moves as the bet slip grows. +pub fn sidebar_layout(ui: &Ui, actions_height: f32) -> (eframe::egui::Rect, eframe::egui::Rect) { + let full = ui.max_rect(); + let actions_top = (full.bottom() - actions_height).max(full.top()); + let actions = eframe::egui::Rect::from_min_max( + eframe::egui::pos2(full.left(), actions_top), + full.right_bottom(), + ); + let content = eframe::egui::Rect::from_min_max( + full.min, + eframe::egui::pos2(full.right(), (actions_top - theme::SPACE_3).max(full.top())), + ); + (content, actions) +} + +pub fn square_button(ui: &mut Ui, text: &str, enabled: bool) -> bool { + ui.add_enabled( + enabled, + Button::new( + RichText::new(text) + .font(theme::mono(16.0)) + .color(theme::TEXT), + ) + .fill(theme::SURFACE_RAISED) + .stroke(Stroke::new(1.0, theme::LINE)) + .corner_radius(theme::RADIUS_MD) + .min_size(Vec2::splat(34.0)), + ) + .clicked() +} + +/// `- [ 50 Kr ] +`; returns the change in the bet. +pub fn bet_control(ui: &mut Ui, bet: u64, enabled: bool) -> Option { + let mut delta = None; + ui.horizontal(|ui| { + if square_button(ui, "−", enabled) { + delta = Some(-10); + } + Frame::new() + .fill(theme::BG) + .corner_radius(theme::RADIUS_SM) + .inner_margin(Margin::symmetric( + theme::SPACE_3 as i8, + theme::SPACE_2 as i8, + )) + .show(ui, |ui| { + ui.label( + RichText::new(theme::money(bet as i64)) + .font(theme::mono(15.0)) + .color(theme::TEXT), + ); + }); + if square_button(ui, "+", enabled) { + delta = Some(10); + } + }); + delta +} + +/// Label on the left, value on the right, for slips and summaries. +pub fn row(ui: &mut Ui, label: &str, value: &str, color: Color32) { + ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .font(theme::sans(13.0)) + .color(theme::TEXT_DIM), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label(RichText::new(value).font(theme::mono(13.0)).color(color)); + }); + }); +} + +pub fn note(ui: &mut Ui, text: &str) { + ui.label( + RichText::new(text) + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); +} + +pub fn error(ui: &mut Ui, text: &str) { + Frame::new() + .fill(theme::LOSS.gamma_multiply(0.12)) + .corner_radius(theme::RADIUS_SM) + .inner_margin(Margin::symmetric( + theme::SPACE_3 as i8, + theme::SPACE_2 as i8, + )) + .show(ui, |ui| { + ui.label( + RichText::new(text) + .font(theme::sans(12.0)) + .color(theme::LOSS), + ); + }); +} + +/// A monospaced label that can be selected (fingerprints, addresses, URLs). +pub fn mono_text(ui: &mut Ui, text: &str, color: Color32) { + ui.add( + eframe::egui::Label::new( + RichText::new(text) + .font(theme::mono(theme::FONT_SMALL)) + .color(color), + ) + .selectable(true), + ); +} + +pub fn chip_button(ui: &mut Ui, amount: u64, selected: bool, enabled: bool) -> bool { + let sense = if enabled { + Sense::click() + } else { + Sense::hover() + }; + let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), sense); + let center = rect.center(); + let radius = 20.0; + let painter = ui.painter(); + if selected || response.hovered() { + painter.circle_stroke( + center, + radius + 3.0, + Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT), + ); + } + if enabled { + super::roulette::paint_chip(painter, center, radius, amount); + } else { + super::roulette::paint_chip_preview(painter, center, radius, amount); + } + response.clicked() +} + +pub fn chip_row( + ui: &mut Ui, + amounts: [u64; N], + selected: u64, + enabled: bool, +) -> Option { + let mut picked = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = theme::SPACE_2; + for amount in amounts { + if chip_button(ui, amount, selected == amount, enabled) { + picked = Some(amount); + } + } + }); + picked +} -- cgit v1.2.3