diff options
Diffstat (limited to 'gui/src/blackjack.rs')
| -rw-r--r-- | gui/src/blackjack.rs | 428 |
1 files changed, 428 insertions, 0 deletions
diff --git a/gui/src/blackjack.rs b/gui/src/blackjack.rs new file mode 100644 index 0000000..d42f000 --- /dev/null +++ b/gui/src/blackjack.rs | |||
| @@ -0,0 +1,428 @@ | |||
| 1 | //! Blackjack screen: cards painted from `BlackjackView`. | ||
| 2 | |||
| 3 | use eframe::egui::{ | ||
| 4 | Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, | ||
| 5 | pos2, vec2, | ||
| 6 | }; | ||
| 7 | use zagan_bet::blackjack::is_red_suit; | ||
| 8 | use zagan_bet::client::view::{BlackjackView, Command}; | ||
| 9 | |||
| 10 | use crate::{theme, widgets}; | ||
| 11 | |||
| 12 | const CARD: Vec2 = vec2(92.0, 128.0); | ||
| 13 | |||
| 14 | pub fn show(ui: &mut Ui, view: &BlackjackView) -> Option<Command> { | ||
| 15 | let mut command = None; | ||
| 16 | eframe::egui::CentralPanel::default() | ||
| 17 | .frame(eframe::egui::Frame::new().fill(theme::BG)) | ||
| 18 | .show(ui, |ui| { | ||
| 19 | let full = ui.max_rect(); | ||
| 20 | let sidebar_rect = eframe::egui::Rect::from_min_max( | ||
| 21 | eframe::egui::pos2(full.right() - 336.0, full.top()), | ||
| 22 | full.right_bottom(), | ||
| 23 | ); | ||
| 24 | let board_rect = eframe::egui::Rect::from_min_max( | ||
| 25 | full.min, | ||
| 26 | eframe::egui::pos2(sidebar_rect.left(), full.bottom()), | ||
| 27 | ); | ||
| 28 | ui.painter().rect_filled( | ||
| 29 | sidebar_rect, | ||
| 30 | eframe::egui::CornerRadius::ZERO, | ||
| 31 | theme::SURFACE, | ||
| 32 | ); | ||
| 33 | ui.scope_builder( | ||
| 34 | eframe::egui::UiBuilder::new() | ||
| 35 | .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0))) | ||
| 36 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 37 | |ui| board(ui, view), | ||
| 38 | ); | ||
| 39 | ui.scope_builder( | ||
| 40 | eframe::egui::UiBuilder::new() | ||
| 41 | .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0))) | ||
| 42 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 43 | |ui| { | ||
| 44 | command = sidebar(ui, view); | ||
| 45 | }, | ||
| 46 | ); | ||
| 47 | }); | ||
| 48 | command | ||
| 49 | } | ||
| 50 | |||
| 51 | fn live(view: &BlackjackView) -> bool { | ||
| 52 | view.status == "player" | ||
| 53 | } | ||
| 54 | |||
| 55 | fn sidebar(ui: &mut Ui, view: &BlackjackView) -> Option<Command> { | ||
| 56 | let mut command = None; | ||
| 57 | let betting = !live(view) && view.status != "dealing" && view.status != "forfeiting"; | ||
| 58 | let (content, actions) = widgets::sidebar_layout(ui, 200.0); | ||
| 59 | |||
| 60 | ui.scope_builder( | ||
| 61 | eframe::egui::UiBuilder::new() | ||
| 62 | .max_rect(content) | ||
| 63 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 64 | |ui| { | ||
| 65 | eframe::egui::ScrollArea::vertical() | ||
| 66 | .auto_shrink([false, false]) | ||
| 67 | .show(ui, |ui| { | ||
| 68 | ui.label( | ||
| 69 | eframe::egui::RichText::new("Blackjack") | ||
| 70 | .font(theme::sans(22.0)) | ||
| 71 | .color(theme::TEXT), | ||
| 72 | ); | ||
| 73 | ui.label( | ||
| 74 | eframe::egui::RichText::new("6 decks · dealer stands on 17 · 3:2") | ||
| 75 | .font(theme::sans(12.0)) | ||
| 76 | .color(theme::TEXT_DIM), | ||
| 77 | ); | ||
| 78 | |||
| 79 | ui.add_space(theme::SPACE_5); | ||
| 80 | ui.label(theme::label("Bet")); | ||
| 81 | ui.add_space(theme::SPACE_2); | ||
| 82 | if let Some(delta) = widgets::bet_control(ui, view.bet, betting) { | ||
| 83 | command = Some(Command::AdjustBet(delta)); | ||
| 84 | } | ||
| 85 | |||
| 86 | ui.add_space(theme::SPACE_4); | ||
| 87 | match view.status.as_str() { | ||
| 88 | "player" => widgets::note(ui, "Hit, stand or double."), | ||
| 89 | "done" => { | ||
| 90 | if let Some(hand) = &view.hand { | ||
| 91 | let (text, color) = result_text(&hand.result, hand.winnings); | ||
| 92 | ui.label( | ||
| 93 | eframe::egui::RichText::new(text) | ||
| 94 | .font(theme::mono(15.0)) | ||
| 95 | .color(color), | ||
| 96 | ); | ||
| 97 | } | ||
| 98 | } | ||
| 99 | "dealing" => widgets::note(ui, "Dealing…"), | ||
| 100 | _ => widgets::note(ui, "Press DEAL to start a hand."), | ||
| 101 | } | ||
| 102 | |||
| 103 | if let Some(error) = &view.error { | ||
| 104 | ui.add_space(theme::SPACE_3); | ||
| 105 | widgets::error(ui, error); | ||
| 106 | } | ||
| 107 | |||
| 108 | ui.add_space(theme::SPACE_4); | ||
| 109 | ui.separator(); | ||
| 110 | ui.add_space(theme::SPACE_2); | ||
| 111 | widgets::note(ui, "Blackjack pays 3:2. The dealer stands on soft 17."); | ||
| 112 | widgets::note(ui, "Double only on the first two cards."); | ||
| 113 | }); | ||
| 114 | }, | ||
| 115 | ); | ||
| 116 | |||
| 117 | ui.scope_builder( | ||
| 118 | eframe::egui::UiBuilder::new() | ||
| 119 | .max_rect(actions) | ||
| 120 | .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)), | ||
| 121 | |ui| { | ||
| 122 | if live(view) { | ||
| 123 | if widgets::ghost(ui, "Forfeit hand") { | ||
| 124 | command = Some(Command::Leave); | ||
| 125 | } | ||
| 126 | } else if widgets::ghost(ui, "Leave table") { | ||
| 127 | command = Some(Command::Leave); | ||
| 128 | } | ||
| 129 | |||
| 130 | match view.status.as_str() { | ||
| 131 | "player" => { | ||
| 132 | if view | ||
| 133 | .hand | ||
| 134 | .as_ref() | ||
| 135 | .is_some_and(|hand| hand.actions.iter().any(|action| action == "double")) | ||
| 136 | { | ||
| 137 | ui.add_space(theme::SPACE_2); | ||
| 138 | if widgets::secondary(ui, "DOUBLE", true, ui.available_width(), 38.0) { | ||
| 139 | command = Some(Command::Double); | ||
| 140 | } | ||
| 141 | } | ||
| 142 | ui.add_space(theme::SPACE_2); | ||
| 143 | if widgets::secondary(ui, "STAND", true, ui.available_width(), 38.0) { | ||
| 144 | command = Some(Command::Stand); | ||
| 145 | } | ||
| 146 | ui.add_space(theme::SPACE_2); | ||
| 147 | if widgets::primary(ui, "HIT", true, ui.available_width(), 44.0) { | ||
| 148 | command = Some(Command::Hit); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | "dealing" | "forfeiting" => { | ||
| 152 | ui.label( | ||
| 153 | eframe::egui::RichText::new(if view.status == "dealing" { | ||
| 154 | "DEALING…" | ||
| 155 | } else { | ||
| 156 | "FORFEITING…" | ||
| 157 | }) | ||
| 158 | .font(theme::mono(12.0)) | ||
| 159 | .color(theme::GOLD), | ||
| 160 | ); | ||
| 161 | } | ||
| 162 | _ => { | ||
| 163 | if widgets::primary(ui, "DEAL", true, ui.available_width(), 46.0) { | ||
| 164 | command = Some(Command::Deal); | ||
| 165 | } | ||
| 166 | } | ||
| 167 | } | ||
| 168 | }, | ||
| 169 | ); | ||
| 170 | |||
| 171 | command | ||
| 172 | } | ||
| 173 | |||
| 174 | fn result_text(result: &str, winnings: u64) -> (String, Color32) { | ||
| 175 | match result { | ||
| 176 | "win" => ( | ||
| 177 | format!("YOU WIN {}", theme::money(winnings as i64)), | ||
| 178 | theme::WIN, | ||
| 179 | ), | ||
| 180 | "blackjack" => ( | ||
| 181 | format!("BLACKJACK {}", theme::money(winnings as i64)), | ||
| 182 | theme::GOLD, | ||
| 183 | ), | ||
| 184 | "push" => ("PUSH".to_string(), theme::TEXT), | ||
| 185 | "bust" => ("BUST".to_string(), theme::LOSS), | ||
| 186 | "forfeit" => ("FORFEITED".to_string(), theme::TEXT_DIM), | ||
| 187 | _ => ("DEALER WINS".to_string(), theme::LOSS), | ||
| 188 | } | ||
| 189 | } | ||
| 190 | |||
| 191 | fn board(ui: &mut Ui, view: &BlackjackView) { | ||
| 192 | ui.horizontal(|ui| { | ||
| 193 | ui.label( | ||
| 194 | eframe::egui::RichText::new("Table 01") | ||
| 195 | .font(theme::sans(15.0)) | ||
| 196 | .color(theme::TEXT), | ||
| 197 | ); | ||
| 198 | ui.label( | ||
| 199 | eframe::egui::RichText::new("Blackjack · six decks") | ||
| 200 | .font(theme::sans(12.0)) | ||
| 201 | .color(theme::TEXT_DIM), | ||
| 202 | ); | ||
| 203 | ui.with_layout( | ||
| 204 | eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), | ||
| 205 | |ui| { | ||
| 206 | ui.label( | ||
| 207 | eframe::egui::RichText::new("3:2 · S17") | ||
| 208 | .font(theme::mono(12.0)) | ||
| 209 | .color(theme::TEXT_FAINT), | ||
| 210 | ); | ||
| 211 | }, | ||
| 212 | ); | ||
| 213 | }); | ||
| 214 | ui.add_space(theme::SPACE_5); | ||
| 215 | |||
| 216 | let Some(hand) = &view.hand else { | ||
| 217 | centered_note(ui, "Place a bet and deal a hand."); | ||
| 218 | return; | ||
| 219 | }; | ||
| 220 | |||
| 221 | let width = ui.available_width(); | ||
| 222 | let hidden = hand.dealer.iter().any(|card| card == "?"); | ||
| 223 | hand_header(ui, "Dealer", hand.dealer_total, hidden); | ||
| 224 | paint_hand(ui, &hand.dealer, width); | ||
| 225 | ui.add_space(theme::SPACE_5); | ||
| 226 | |||
| 227 | let (line, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover()); | ||
| 228 | ui.painter() | ||
| 229 | .rect_filled(line, CornerRadius::ZERO, theme::LINE); | ||
| 230 | ui.add_space(theme::SPACE_5); | ||
| 231 | |||
| 232 | hand_header(ui, "You", hand.player_total, false); | ||
| 233 | paint_hand(ui, &hand.player, width); | ||
| 234 | |||
| 235 | ui.add_space(theme::SPACE_4); | ||
| 236 | ui.label( | ||
| 237 | eframe::egui::RichText::new(match view.status.as_str() { | ||
| 238 | "player" => "Your move.", | ||
| 239 | "dealing" => "Dealing…", | ||
| 240 | "done" => "Hand complete.", | ||
| 241 | _ => "Place your bet.", | ||
| 242 | }) | ||
| 243 | .font(theme::sans(13.0)) | ||
| 244 | .color(theme::TEXT_DIM), | ||
| 245 | ); | ||
| 246 | } | ||
| 247 | |||
| 248 | fn hand_header(ui: &mut Ui, label: &str, total: u8, hidden: bool) { | ||
| 249 | ui.horizontal(|ui| { | ||
| 250 | ui.label(theme::label(label)); | ||
| 251 | ui.label( | ||
| 252 | eframe::egui::RichText::new(if hidden { | ||
| 253 | format!("{total} + ?") | ||
| 254 | } else { | ||
| 255 | total.to_string() | ||
| 256 | }) | ||
| 257 | .font(theme::mono(15.0)) | ||
| 258 | .color(if label == "You" { | ||
| 259 | theme::TEXT | ||
| 260 | } else { | ||
| 261 | theme::TEXT_DIM | ||
| 262 | }), | ||
| 263 | ); | ||
| 264 | }); | ||
| 265 | ui.add_space(theme::SPACE_2); | ||
| 266 | } | ||
| 267 | |||
| 268 | fn paint_hand(ui: &mut Ui, cards: &[String], width: f32) { | ||
| 269 | if cards.is_empty() { | ||
| 270 | ui.add_space(CARD.y); | ||
| 271 | return; | ||
| 272 | } | ||
| 273 | let step = CARD.x * 0.74; | ||
| 274 | let hand_width = CARD.x + step * (cards.len() as f32 - 1.0); | ||
| 275 | let (rect, _) = ui.allocate_exact_size(vec2(width.max(hand_width), CARD.y), Sense::hover()); | ||
| 276 | let left = rect.left() + ((rect.width() - hand_width) * 0.5).max(0.0); | ||
| 277 | let painter = ui.painter(); | ||
| 278 | for (index, card) in cards.iter().enumerate() { | ||
| 279 | let rect = Rect::from_min_size(pos2(left + step * index as f32, rect.top()), CARD); | ||
| 280 | if card == "?" { | ||
| 281 | paint_back(painter, rect); | ||
| 282 | } else { | ||
| 283 | paint_card(painter, rect, card); | ||
| 284 | } | ||
| 285 | } | ||
| 286 | } | ||
| 287 | |||
| 288 | fn paint_card(painter: &Painter, rect: Rect, card: &str) { | ||
| 289 | painter.rect_filled(rect, theme::RADIUS_MD, Color32::from_rgb(0xe8, 0xe4, 0xd8)); | ||
| 290 | painter.rect_stroke( | ||
| 291 | rect, | ||
| 292 | theme::RADIUS_MD, | ||
| 293 | Stroke::new(1.0, Color32::from_rgb(0x9a, 0x94, 0x86)), | ||
| 294 | StrokeKind::Inside, | ||
| 295 | ); | ||
| 296 | let color = if is_red_suit(card) { | ||
| 297 | Color32::from_rgb(0xb0, 0x31, 0x3a) | ||
| 298 | } else { | ||
| 299 | Color32::from_rgb(0x15, 0x1b, 0x21) | ||
| 300 | }; | ||
| 301 | let suit = card.chars().last().unwrap_or('?'); | ||
| 302 | let rank = &card[..card.len().saturating_sub(suit.len_utf8())]; | ||
| 303 | painter.text( | ||
| 304 | rect.left_top() + vec2(9.0, 6.0), | ||
| 305 | Align2::LEFT_TOP, | ||
| 306 | rank, | ||
| 307 | theme::mono(20.0), | ||
| 308 | color, | ||
| 309 | ); | ||
| 310 | painter.text( | ||
| 311 | rect.right_bottom() - vec2(9.0, 6.0), | ||
| 312 | Align2::RIGHT_BOTTOM, | ||
| 313 | rank, | ||
| 314 | theme::mono(20.0), | ||
| 315 | color, | ||
| 316 | ); | ||
| 317 | paint_suit(painter, rect.center(), rect.width() * 0.55, suit, color); | ||
| 318 | } | ||
| 319 | |||
| 320 | /// Card suits are drawn, not typed: neither embedded font carries the glyphs. | ||
| 321 | /// | ||
| 322 | /// The circles must overlap the stem, or the shape reads as three dots above a | ||
| 323 | /// detached bar. | ||
| 324 | fn paint_suit(painter: &Painter, center: Pos2, size: f32, suit: char, color: Color32) { | ||
| 325 | let s = size; | ||
| 326 | match suit { | ||
| 327 | 'H' => { | ||
| 328 | painter.circle_filled(center + vec2(-s * 0.22, -s * 0.12), s * 0.31, color); | ||
| 329 | painter.circle_filled(center + vec2(s * 0.22, -s * 0.12), s * 0.31, color); | ||
| 330 | painter.add(Shape::convex_polygon( | ||
| 331 | vec![ | ||
| 332 | center + vec2(-s * 0.50, 0.0), | ||
| 333 | center + vec2(s * 0.50, 0.0), | ||
| 334 | center + vec2(0.0, s * 0.56), | ||
| 335 | ], | ||
| 336 | color, | ||
| 337 | Stroke::NONE, | ||
| 338 | )); | ||
| 339 | } | ||
| 340 | 'D' => { | ||
| 341 | painter.add(Shape::convex_polygon( | ||
| 342 | vec![ | ||
| 343 | center + vec2(0.0, -s * 0.56), | ||
| 344 | center + vec2(s * 0.42, 0.0), | ||
| 345 | center + vec2(0.0, s * 0.56), | ||
| 346 | center + vec2(-s * 0.42, 0.0), | ||
| 347 | ], | ||
| 348 | color, | ||
| 349 | Stroke::NONE, | ||
| 350 | )); | ||
| 351 | } | ||
| 352 | 'S' => { | ||
| 353 | painter.circle_filled(center + vec2(-s * 0.24, s * 0.10), s * 0.32, color); | ||
| 354 | painter.circle_filled(center + vec2(s * 0.24, s * 0.10), s * 0.32, color); | ||
| 355 | painter.add(Shape::convex_polygon( | ||
| 356 | vec![ | ||
| 357 | center + vec2(-s * 0.50, s * 0.02), | ||
| 358 | center + vec2(s * 0.50, s * 0.02), | ||
| 359 | center + vec2(0.0, -s * 0.56), | ||
| 360 | ], | ||
| 361 | color, | ||
| 362 | Stroke::NONE, | ||
| 363 | )); | ||
| 364 | painter.add(Shape::convex_polygon( | ||
| 365 | vec![ | ||
| 366 | center + vec2(-s * 0.11, s * 0.08), | ||
| 367 | center + vec2(s * 0.11, s * 0.08), | ||
| 368 | center + vec2(s * 0.05, s * 0.52), | ||
| 369 | center + vec2(-s * 0.05, s * 0.52), | ||
| 370 | ], | ||
| 371 | color, | ||
| 372 | Stroke::NONE, | ||
| 373 | )); | ||
| 374 | } | ||
| 375 | 'C' => { | ||
| 376 | painter.circle_filled(center + vec2(0.0, -s * 0.26), s * 0.33, color); | ||
| 377 | painter.circle_filled(center + vec2(-s * 0.30, s * 0.10), s * 0.33, color); | ||
| 378 | painter.circle_filled(center + vec2(s * 0.30, s * 0.10), s * 0.33, color); | ||
| 379 | painter.add(Shape::convex_polygon( | ||
| 380 | vec![ | ||
| 381 | center + vec2(-s * 0.13, s * 0.08), | ||
| 382 | center + vec2(s * 0.13, s * 0.08), | ||
| 383 | center + vec2(s * 0.05, s * 0.52), | ||
| 384 | center + vec2(-s * 0.05, s * 0.52), | ||
| 385 | ], | ||
| 386 | color, | ||
| 387 | Stroke::NONE, | ||
| 388 | )); | ||
| 389 | } | ||
| 390 | _ => {} | ||
| 391 | } | ||
| 392 | } | ||
| 393 | |||
| 394 | fn paint_back(painter: &Painter, rect: Rect) { | ||
| 395 | painter.rect_filled(rect, theme::RADIUS_MD, Color32::from_rgb(0x1b, 0x3a, 0x2c)); | ||
| 396 | painter.rect_stroke( | ||
| 397 | rect, | ||
| 398 | theme::RADIUS_MD, | ||
| 399 | Stroke::new(1.0, Color32::from_rgb(0x2b, 0x5a, 0x43)), | ||
| 400 | StrokeKind::Inside, | ||
| 401 | ); | ||
| 402 | let inner = rect.shrink(8.0); | ||
| 403 | painter.rect_stroke( | ||
| 404 | inner, | ||
| 405 | theme::RADIUS_SM, | ||
| 406 | Stroke::new(1.0, Color32::from_rgb(0x2b, 0x5a, 0x43)), | ||
| 407 | StrokeKind::Inside, | ||
| 408 | ); | ||
| 409 | painter.text( | ||
| 410 | rect.center(), | ||
| 411 | Align2::CENTER_CENTER, | ||
| 412 | "Z", | ||
| 413 | theme::mono(30.0), | ||
| 414 | Color32::from_rgb(0x4e, 0x8a, 0x68), | ||
| 415 | ); | ||
| 416 | } | ||
| 417 | |||
| 418 | fn centered_note(ui: &mut Ui, text: &str) { | ||
| 419 | let available = ui.available_size(); | ||
| 420 | ui.add_space(available.y * 0.35); | ||
| 421 | ui.vertical_centered(|ui| { | ||
| 422 | ui.label( | ||
| 423 | eframe::egui::RichText::new(text) | ||
| 424 | .font(theme::sans(15.0)) | ||
| 425 | .color(theme::TEXT_DIM), | ||
| 426 | ); | ||
| 427 | }); | ||
| 428 | } | ||
