//! 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), ); widgets::note(ui, "Get close to 21 without going over."); 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.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.add_space(theme::SPACE_2); 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), ); }); }