From bf4cf78ab26896faf457273e37d30fc96381c4a4 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 21:09:56 +0100 Subject: v4.2.0: wire the GUI to the core, transport and all four games Phase 4. The egui frontend now shares ClientCore and the client database with the terminal: one background worker owns TLS, trust answers go through a channel, and a 10 s request deadline mirrors the terminal socket timeout. First contact, a changed key and a version mismatch are explicit screens; login can remember the credential and auto-login signs in before the login form appears. Adds the lobby with an update notice, a full-width scrollable history table, and the four games painted from View: slots with reel animation and win lines that stop at the last matching reel, roulette with the hover lattice and wheel spin, blackjack with vector-drawn suits, and a smooth crash curve. Sidebars pin their action block; leaving a live hand or round asks before forfeiting. The header balance holds the staked amount while the reels or wheel turn and only shows the payout when they stop. Refused spins cancel the animation. Live crash multipliers keep full precision for the chart; the terminal still displays two decimals. Verified live against a 4.2.0 server: register, login, auto-login, trust, all four games settling, history (50 rows), logout, disconnect, forfeit confirmation, and the version-mismatch screen. Release: 8.3 MB Linux, 6.8 MB Windows, 61 ms to window, 131 MB idle RSS. --- gui/src/blackjack.rs | 428 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 gui/src/blackjack.rs (limited to 'gui/src/blackjack.rs') diff --git a/gui/src/blackjack.rs b/gui/src/blackjack.rs new file mode 100644 index 0000000..d42f000 --- /dev/null +++ b/gui/src/blackjack.rs @@ -0,0 +1,428 @@ +//! Blackjack screen: cards painted from `BlackjackView`. + +use eframe::egui::{ + Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, + pos2, vec2, +}; +use zagan_bet::blackjack::is_red_suit; +use zagan_bet::client::view::{BlackjackView, Command}; + +use crate::{theme, widgets}; + +const CARD: Vec2 = vec2(92.0, 128.0); + +pub fn show(ui: &mut Ui, view: &BlackjackView) -> Option { + let mut command = None; + eframe::egui::CentralPanel::default() + .frame(eframe::egui::Frame::new().fill(theme::BG)) + .show(ui, |ui| { + let full = ui.max_rect(); + let sidebar_rect = eframe::egui::Rect::from_min_max( + eframe::egui::pos2(full.right() - 336.0, full.top()), + full.right_bottom(), + ); + let board_rect = eframe::egui::Rect::from_min_max( + full.min, + eframe::egui::pos2(sidebar_rect.left(), full.bottom()), + ); + ui.painter().rect_filled( + sidebar_rect, + eframe::egui::CornerRadius::ZERO, + theme::SURFACE, + ); + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0))) + .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), + |ui| board(ui, view), + ); + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0))) + .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), + |ui| { + command = sidebar(ui, view); + }, + ); + }); + command +} + +fn live(view: &BlackjackView) -> bool { + view.status == "player" +} + +fn sidebar(ui: &mut Ui, view: &BlackjackView) -> Option { + let mut command = None; + let betting = !live(view) && view.status != "dealing" && view.status != "forfeiting"; + let (content, actions) = widgets::sidebar_layout(ui, 200.0); + + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(content) + .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), + |ui| { + eframe::egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.label( + eframe::egui::RichText::new("Blackjack") + .font(theme::sans(22.0)) + .color(theme::TEXT), + ); + ui.label( + eframe::egui::RichText::new("6 decks · dealer stands on 17 · 3:2") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + + ui.add_space(theme::SPACE_5); + ui.label(theme::label("Bet")); + ui.add_space(theme::SPACE_2); + if let Some(delta) = widgets::bet_control(ui, view.bet, betting) { + command = Some(Command::AdjustBet(delta)); + } + + ui.add_space(theme::SPACE_4); + match view.status.as_str() { + "player" => widgets::note(ui, "Hit, stand or double."), + "done" => { + if let Some(hand) = &view.hand { + let (text, color) = result_text(&hand.result, hand.winnings); + ui.label( + eframe::egui::RichText::new(text) + .font(theme::mono(15.0)) + .color(color), + ); + } + } + "dealing" => widgets::note(ui, "Dealing…"), + _ => widgets::note(ui, "Press DEAL to start a hand."), + } + + if let Some(error) = &view.error { + ui.add_space(theme::SPACE_3); + widgets::error(ui, error); + } + + ui.add_space(theme::SPACE_4); + ui.separator(); + ui.add_space(theme::SPACE_2); + widgets::note(ui, "Blackjack pays 3:2. The dealer stands on soft 17."); + widgets::note(ui, "Double only on the first two cards."); + }); + }, + ); + + ui.scope_builder( + eframe::egui::UiBuilder::new() + .max_rect(actions) + .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)), + |ui| { + if live(view) { + if widgets::ghost(ui, "Forfeit hand") { + command = Some(Command::Leave); + } + } else if widgets::ghost(ui, "Leave table") { + command = Some(Command::Leave); + } + + match view.status.as_str() { + "player" => { + if view + .hand + .as_ref() + .is_some_and(|hand| hand.actions.iter().any(|action| action == "double")) + { + ui.add_space(theme::SPACE_2); + if widgets::secondary(ui, "DOUBLE", true, ui.available_width(), 38.0) { + command = Some(Command::Double); + } + } + ui.add_space(theme::SPACE_2); + if widgets::secondary(ui, "STAND", true, ui.available_width(), 38.0) { + command = Some(Command::Stand); + } + ui.add_space(theme::SPACE_2); + if widgets::primary(ui, "HIT", true, ui.available_width(), 44.0) { + command = Some(Command::Hit); + } + } + "dealing" | "forfeiting" => { + ui.label( + eframe::egui::RichText::new(if view.status == "dealing" { + "DEALING…" + } else { + "FORFEITING…" + }) + .font(theme::mono(12.0)) + .color(theme::GOLD), + ); + } + _ => { + if widgets::primary(ui, "DEAL", true, ui.available_width(), 46.0) { + command = Some(Command::Deal); + } + } + } + }, + ); + + command +} + +fn result_text(result: &str, winnings: u64) -> (String, Color32) { + match result { + "win" => ( + format!("YOU WIN {}", theme::money(winnings as i64)), + theme::WIN, + ), + "blackjack" => ( + format!("BLACKJACK {}", theme::money(winnings as i64)), + theme::GOLD, + ), + "push" => ("PUSH".to_string(), theme::TEXT), + "bust" => ("BUST".to_string(), theme::LOSS), + "forfeit" => ("FORFEITED".to_string(), theme::TEXT_DIM), + _ => ("DEALER WINS".to_string(), theme::LOSS), + } +} + +fn board(ui: &mut Ui, view: &BlackjackView) { + ui.horizontal(|ui| { + ui.label( + eframe::egui::RichText::new("Table 01") + .font(theme::sans(15.0)) + .color(theme::TEXT), + ); + ui.label( + eframe::egui::RichText::new("Blackjack · six decks") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + ui.with_layout( + eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), + |ui| { + ui.label( + eframe::egui::RichText::new("3:2 · S17") + .font(theme::mono(12.0)) + .color(theme::TEXT_FAINT), + ); + }, + ); + }); + ui.add_space(theme::SPACE_5); + + let Some(hand) = &view.hand else { + centered_note(ui, "Place a bet and deal a hand."); + return; + }; + + let width = ui.available_width(); + let hidden = hand.dealer.iter().any(|card| card == "?"); + hand_header(ui, "Dealer", hand.dealer_total, hidden); + paint_hand(ui, &hand.dealer, width); + ui.add_space(theme::SPACE_5); + + let (line, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover()); + ui.painter() + .rect_filled(line, CornerRadius::ZERO, theme::LINE); + ui.add_space(theme::SPACE_5); + + hand_header(ui, "You", hand.player_total, false); + paint_hand(ui, &hand.player, width); + + ui.add_space(theme::SPACE_4); + ui.label( + eframe::egui::RichText::new(match view.status.as_str() { + "player" => "Your move.", + "dealing" => "Dealing…", + "done" => "Hand complete.", + _ => "Place your bet.", + }) + .font(theme::sans(13.0)) + .color(theme::TEXT_DIM), + ); +} + +fn hand_header(ui: &mut Ui, label: &str, total: u8, hidden: bool) { + ui.horizontal(|ui| { + ui.label(theme::label(label)); + ui.label( + eframe::egui::RichText::new(if hidden { + format!("{total} + ?") + } else { + total.to_string() + }) + .font(theme::mono(15.0)) + .color(if label == "You" { + theme::TEXT + } else { + theme::TEXT_DIM + }), + ); + }); + ui.add_space(theme::SPACE_2); +} + +fn paint_hand(ui: &mut Ui, cards: &[String], width: f32) { + if cards.is_empty() { + ui.add_space(CARD.y); + return; + } + let step = CARD.x * 0.74; + let hand_width = CARD.x + step * (cards.len() as f32 - 1.0); + let (rect, _) = ui.allocate_exact_size(vec2(width.max(hand_width), CARD.y), Sense::hover()); + let left = rect.left() + ((rect.width() - hand_width) * 0.5).max(0.0); + let painter = ui.painter(); + for (index, card) in cards.iter().enumerate() { + let rect = Rect::from_min_size(pos2(left + step * index as f32, rect.top()), CARD); + if card == "?" { + paint_back(painter, rect); + } else { + paint_card(painter, rect, card); + } + } +} + +fn paint_card(painter: &Painter, rect: Rect, card: &str) { + painter.rect_filled(rect, theme::RADIUS_MD, Color32::from_rgb(0xe8, 0xe4, 0xd8)); + painter.rect_stroke( + rect, + theme::RADIUS_MD, + Stroke::new(1.0, Color32::from_rgb(0x9a, 0x94, 0x86)), + StrokeKind::Inside, + ); + let color = if is_red_suit(card) { + Color32::from_rgb(0xb0, 0x31, 0x3a) + } else { + Color32::from_rgb(0x15, 0x1b, 0x21) + }; + let suit = card.chars().last().unwrap_or('?'); + let rank = &card[..card.len().saturating_sub(suit.len_utf8())]; + painter.text( + rect.left_top() + vec2(9.0, 6.0), + Align2::LEFT_TOP, + rank, + theme::mono(20.0), + color, + ); + painter.text( + rect.right_bottom() - vec2(9.0, 6.0), + Align2::RIGHT_BOTTOM, + rank, + theme::mono(20.0), + color, + ); + paint_suit(painter, rect.center(), rect.width() * 0.55, suit, color); +} + +/// Card suits are drawn, not typed: neither embedded font carries the glyphs. +/// +/// The circles must overlap the stem, or the shape reads as three dots above a +/// detached bar. +fn paint_suit(painter: &Painter, center: Pos2, size: f32, suit: char, color: Color32) { + let s = size; + match suit { + 'H' => { + painter.circle_filled(center + vec2(-s * 0.22, -s * 0.12), s * 0.31, color); + painter.circle_filled(center + vec2(s * 0.22, -s * 0.12), s * 0.31, color); + painter.add(Shape::convex_polygon( + vec![ + center + vec2(-s * 0.50, 0.0), + center + vec2(s * 0.50, 0.0), + center + vec2(0.0, s * 0.56), + ], + color, + Stroke::NONE, + )); + } + 'D' => { + painter.add(Shape::convex_polygon( + vec![ + center + vec2(0.0, -s * 0.56), + center + vec2(s * 0.42, 0.0), + center + vec2(0.0, s * 0.56), + center + vec2(-s * 0.42, 0.0), + ], + color, + Stroke::NONE, + )); + } + 'S' => { + painter.circle_filled(center + vec2(-s * 0.24, s * 0.10), s * 0.32, color); + painter.circle_filled(center + vec2(s * 0.24, s * 0.10), s * 0.32, color); + painter.add(Shape::convex_polygon( + vec![ + center + vec2(-s * 0.50, s * 0.02), + center + vec2(s * 0.50, s * 0.02), + center + vec2(0.0, -s * 0.56), + ], + color, + Stroke::NONE, + )); + painter.add(Shape::convex_polygon( + vec![ + center + vec2(-s * 0.11, s * 0.08), + center + vec2(s * 0.11, s * 0.08), + center + vec2(s * 0.05, s * 0.52), + center + vec2(-s * 0.05, s * 0.52), + ], + color, + Stroke::NONE, + )); + } + 'C' => { + painter.circle_filled(center + vec2(0.0, -s * 0.26), s * 0.33, color); + painter.circle_filled(center + vec2(-s * 0.30, s * 0.10), s * 0.33, color); + painter.circle_filled(center + vec2(s * 0.30, s * 0.10), s * 0.33, color); + painter.add(Shape::convex_polygon( + vec![ + center + vec2(-s * 0.13, s * 0.08), + center + vec2(s * 0.13, s * 0.08), + center + vec2(s * 0.05, s * 0.52), + center + vec2(-s * 0.05, s * 0.52), + ], + color, + Stroke::NONE, + )); + } + _ => {} + } +} + +fn paint_back(painter: &Painter, rect: Rect) { + painter.rect_filled(rect, theme::RADIUS_MD, Color32::from_rgb(0x1b, 0x3a, 0x2c)); + painter.rect_stroke( + rect, + theme::RADIUS_MD, + Stroke::new(1.0, Color32::from_rgb(0x2b, 0x5a, 0x43)), + StrokeKind::Inside, + ); + let inner = rect.shrink(8.0); + painter.rect_stroke( + inner, + theme::RADIUS_SM, + Stroke::new(1.0, Color32::from_rgb(0x2b, 0x5a, 0x43)), + StrokeKind::Inside, + ); + painter.text( + rect.center(), + Align2::CENTER_CENTER, + "Z", + theme::mono(30.0), + Color32::from_rgb(0x4e, 0x8a, 0x68), + ); +} + +fn centered_note(ui: &mut Ui, text: &str) { + let available = ui.available_size(); + ui.add_space(available.y * 0.35); + ui.vertical_centered(|ui| { + ui.label( + eframe::egui::RichText::new(text) + .font(theme::sans(15.0)) + .color(theme::TEXT_DIM), + ); + }); +} -- cgit v1.2.3