diff options
Diffstat (limited to 'gui/src/app.rs')
| -rw-r--r-- | gui/src/app.rs | 1963 |
1 files changed, 1464 insertions, 499 deletions
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 | } |
