summaryrefslogtreecommitdiffstats
path: root/gui/src/blackjack.rs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--gui/src/blackjack.rs428
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
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}