summaryrefslogtreecommitdiffstats
path: root/gui
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--gui/Cargo.toml4
-rw-r--r--gui/src/app.rs1963
-rw-r--r--gui/src/blackjack.rs428
-rw-r--r--gui/src/crash.rs416
-rw-r--r--gui/src/lib.rs5
-rw-r--r--gui/src/main.rs38
-rw-r--r--gui/src/net.rs220
-rw-r--r--gui/src/roulette.rs431
-rw-r--r--gui/src/slots.rs447
-rw-r--r--gui/src/widgets.rs255
-rw-r--r--gui/tests/layout.rs35
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]
2name = "zagan-bet-gui" 2name = "zagan-bet-gui"
3version = "4.1.1" 3version = "4.2.0"
4edition = "2024" 4edition = "2024"
5license = "GPL-3.0-or-later" 5license = "GPL-3.0-or-later"
6description = "Desktop frontend for zagan-bet" 6description = "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] }
22rusqlite = { version = "0.29", features = ["bundled"] }
23sha2 = "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
7use std::f32::consts::TAU; 3use std::collections::VecDeque;
4use std::path::PathBuf;
5use std::sync::mpsc::Sender;
6use std::time::{Duration, Instant};
8 7
9use eframe::egui::{ 8use 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};
13use zagan_bet::client::games::roulette::Cursor; 12use rusqlite::Connection;
14use zagan_bet::client::view::{RoulettePhase, RouletteView}; 13use sha2::{Digest, Sha256};
15use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color}; 14use zagan_bet::client::core::{ClientCore, Effect};
16use zagan_bet::version::VERSION; 15use zagan_bet::client::database;
16use zagan_bet::client::paths;
17use zagan_bet::client::view::{Command, LobbyView, View};
18use zagan_bet::history::PlayRecord;
19use zagan_bet::version::{VERSION, is_newer};
17 20
18use crate::roulette::FeltAction; 21use crate::net::{Command as NetCommand, Connected, Event, Failure, Network};
19use crate::{roulette, theme}; 22use crate::{blackjack, crash, roulette, slots, theme, widgets};
20 23
21const HEADER_HEIGHT: f32 = 64.0; 24/// A request that has no reply after this long kills the connection, matching
22const SIDEBAR_WIDTH: f32 = 336.0; 25/// the terminal adapter's socket timeout.
23const SPIN_SECONDS: f64 = 2.4; 26const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
24/// Demo winners, cycled: a red, a zero, a red, a black, a black, a black. 27const NOTICE_SECONDS: u64 = 6;
25const DEMO_TARGETS: [u8; 6] = [17, 0, 32, 26, 8, 11]; 28const DEFAULT_BET: u64 = 10;
26const CHIP_SIZES: [u64; 4] = [10, 25, 50, 100]; 29
30/// Where the app keeps its files and whether TLS is bypassed.
31pub struct Startup {
32 pub data_dir: PathBuf,
33 pub unsafe_mode: bool,
34}
27 35
28pub struct ZaganApp { 36pub 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
61struct 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)]
72struct ConnectState {
73 addr: String,
74 error: Option<String>,
75 version_issue: bool,
76 pending: bool,
77}
78
79#[derive(Default)]
80struct LoginState {
81 user: String,
82 password: String,
83 error: Option<String>,
84 remember: bool,
85}
86
87impl LoginState {
88 fn new() -> Self {
89 Self {
90 remember: true,
91 ..Self::default()
92 }
93 }
94}
95
96struct TrustState {
97 addr: String,
98 fingerprint: String,
99 reply: Sender<bool>,
100}
101
102struct KeyChanged {
103 addr: String,
104 expected: String,
105 observed: String,
106}
107
108struct Notice {
109 text: String,
110 at: Instant,
111 tone: Tone,
112}
113
114#[derive(Clone, Copy, PartialEq, Eq)]
115enum Tone {
116 Info,
117 Good,
118 Bad,
119}
120
121#[derive(Default)]
122struct Fx {
123 game: Option<String>,
124 roulette: roulette::Fx,
125 slots: slots::Fx,
126 crash: crash::Fx,
127}
128
129impl 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
32impl ZaganApp { 144impl 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(&notice.text)
873 .font(theme::sans(12.0))
874 .color(color),
875 );
876 });
877 }
878 });
36 } 879 }
37} 880}
38 881
39impl eframe::App for ZaganApp { 882impl 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() 894fn 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
905fn 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
80fn header(ui: &mut Ui, demo: &Demo) { 921fn 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
126fn pill(ui: &mut Ui, text: &str, color: Color32) { 993fn 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
136fn connection_pill(ui: &mut Ui, demo: &Demo) { 1003fn 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.
166fn stat_block( 1031fn 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
193fn sidebar(ui: &mut Ui, demo: &mut Demo) { 1058fn 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
1155fn 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); 1184fn 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( 1193fn 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
294fn 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| { 1295fn 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. 1371fn games_grid(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) {
339fn 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
364fn bet_row(ui: &mut Ui, name: &str, amount: u64) { 1387fn 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
381fn chip_button(ui: &mut Ui, amount: u64, selected: bool, enabled: bool) -> egui::Response { 1418fn 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
406fn color_of(number: u8) -> Color32 { 1428fn 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
418fn table(ui: &mut Ui, demo: &mut Demo) { 1442fn 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
494struct 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 });
501struct 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
519impl 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, 1501fn 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 { 1513fn 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 1529fn 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) { 1561fn 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) { 1586fn 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
3use eframe::egui::{
4 Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2,
5 pos2, vec2,
6};
7use zagan_bet::blackjack::is_red_suit;
8use zagan_bet::client::view::{BlackjackView, Command};
9
10use crate::{theme, widgets};
11
12const CARD: Vec2 = vec2(92.0, 128.0);
13
14pub 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
51fn live(view: &BlackjackView) -> bool {
52 view.status == "player"
53}
54
55fn 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
174fn 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
191fn 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
248fn 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
268fn 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
288fn 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.
324fn 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
394fn 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
418fn 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
3use eframe::egui::{Align2, Color32, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
4use zagan_bet::client::view::{Command, CrashView};
5
6use crate::{theme, widgets};
7
8/// One sample of the running curve.
9struct Sample {
10 seconds: f32,
11 multiplier: f64,
12}
13
14#[derive(Default)]
15pub struct Fx {
16 points: Vec<Sample>,
17 started: Option<f64>,
18 status: String,
19 peak: f64,
20 last_crash: Option<f64>,
21}
22
23impl 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
66pub 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
103fn 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
197fn 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
233fn 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
393fn 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
398fn 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
409fn 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
7pub mod app; 7pub mod app;
8pub mod blackjack;
9pub mod crash;
10pub mod net;
8pub mod roulette; 11pub mod roulette;
12pub mod slots;
9pub mod theme; 13pub mod theme;
14pub 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
7use zagan_bet_gui::app::ZaganApp; 6use zagan_bet::client::paths::{default_data_dir, parse_args};
7use zagan_bet_gui::app::{Startup, ZaganApp};
8 8
9fn main() -> eframe::Result { 9fn 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
8use std::path::PathBuf;
9use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
10use std::thread;
11use std::time::Duration;
12
13use eframe::egui;
14use rusqlite::Connection;
15use zagan_bet::client::transport::{self, TlsStream, TransportError, TrustPrompt};
16use zagan_bet::client::{database, paths};
17
18/// How often the worker checks the socket for pushes when idle.
19const POLL: Duration = Duration::from_millis(8);
20/// How long a first-contact prompt may stay unanswered.
21const TRUST_TIMEOUT: Duration = Duration::from_secs(120);
22
23/// What the UI asks the worker to do.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub 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)]
34pub 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)]
44pub 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.
56pub 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
73fn 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.
87pub struct Network {
88 commands: Sender<Command>,
89 events: Receiver<Event>,
90}
91
92impl 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
109impl Drop for Network {
110 fn drop(&mut self) {
111 let _ = self.commands.send(Command::Stop);
112 }
113}
114
115fn 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.
563pub struct Fx {
564 rotation: f32,
565 spin: Option<WheelSpin>,
566 awaiting_result: bool,
567 settled: Option<u8>,
568 last: Vec<u8>,
569}
570
571struct WheelSpin {
572 started: f64,
573 from: f32,
574 delta: f32,
575 target: u8,
576}
577
578impl 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
590impl 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
656pub 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
710fn 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
906fn 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
980fn 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
3use eframe::egui::{
4 Align2, Color32, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, vec2,
5};
6use zagan_bet::client::view::{Command, SlotsPhase, SlotsView};
7use zagan_bet::slots::{LINE_NAMES, PAYLINES, REELS, ROWS, SYMBOLS};
8
9use 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.
13const SPIN_SECONDS: f64 = 0.9;
14
15#[derive(Default)]
16pub struct Fx {
17 started: Option<f64>,
18}
19
20impl 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
56pub 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
93fn 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
180fn 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
242fn 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
363fn 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
398fn 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.
426pub 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
437pub 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
4use eframe::egui::{
5 Align, Button, Color32, Frame, Layout, Margin, RichText, Sense, Stroke, Ui, Vec2, vec2,
6};
7
8use crate::theme;
9
10pub 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
17pub 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.
25pub 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.
51pub 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".
82pub 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.
105pub 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
119pub 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.
136pub 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.
164pub 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
177pub 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
185pub 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).
203pub 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
214pub 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
239pub 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 @@
5use std::f32::consts::TAU; 5use std::f32::consts::TAU;
6 6
7use eframe::egui::{Pos2, Rect, pos2, vec2}; 7use eframe::egui::{Pos2, Rect, pos2, vec2};
8use zagan_bet::client::view::{SlotsPhase, SlotsView};
8use zagan_bet::roulette::{BetTarget, Outside}; 9use zagan_bet::roulette::{BetTarget, Outside};
9use zagan_bet_gui::roulette::{Felt, number_under, size_for, target_name}; 10use zagan_bet_gui::roulette::{Felt, number_under, size_for, target_name};
11use zagan_bet_gui::slots::winning_columns;
10use zagan_bet_gui::theme::{group_digits, money}; 12use 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
160fn 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]
175fn 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}