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