summaryrefslogtreecommitdiffstats
path: root/gui/src/blackjack.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 21:09:56 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 21:09:56 +0100
commitbf4cf78ab26896faf457273e37d30fc96381c4a4 (patch)
tree71d67c300b6be191016a988ed5338e15c8b1bdd3 /gui/src/blackjack.rs
parentv4.1.1: pointer-first betting and visible payouts in the GUI (diff)
downloadzagan-bet-bf4cf78ab26896faf457273e37d30fc96381c4a4.tar.gz
zagan-bet-bf4cf78ab26896faf457273e37d30fc96381c4a4.zip
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.
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}