diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 21:09:56 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 21:09:56 +0100 |
| commit | bf4cf78ab26896faf457273e37d30fc96381c4a4 (patch) | |
| tree | 71d67c300b6be191016a988ed5338e15c8b1bdd3 /gui/src/slots.rs | |
| parent | v4.1.1: pointer-first betting and visible payouts in the GUI (diff) | |
| download | zagan-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/slots.rs | 447 |
1 files changed, 447 insertions, 0 deletions
diff --git a/gui/src/slots.rs b/gui/src/slots.rs new file mode 100644 index 0000000..6574626 --- /dev/null +++ b/gui/src/slots.rs | |||
| @@ -0,0 +1,447 @@ | |||
| 1 | //! Slots screen: a 5x3 board painted from `SlotsView`. | ||
| 2 | |||
| 3 | use eframe::egui::{ | ||
| 4 | Align2, Color32, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, vec2, | ||
| 5 | }; | ||
| 6 | use zagan_bet::client::view::{Command, SlotsPhase, SlotsView}; | ||
| 7 | use zagan_bet::slots::{LINE_NAMES, PAYLINES, REELS, ROWS, SYMBOLS}; | ||
| 8 | |||
| 9 | use crate::{theme, widgets}; | ||
| 10 | |||
| 11 | /// Reel animation. The server answers at once, so the UI spins the reels for | ||
| 12 | /// this long before revealing the result it already has. | ||
| 13 | const SPIN_SECONDS: f64 = 0.9; | ||
| 14 | |||
| 15 | #[derive(Default)] | ||
| 16 | pub struct Fx { | ||
| 17 | started: Option<f64>, | ||
| 18 | } | ||
| 19 | |||
| 20 | impl Fx { | ||
| 21 | pub fn spin_requested(&mut self, ctx: &eframe::egui::Context) { | ||
| 22 | self.started = Some(ctx.input(|input| input.time)); | ||
| 23 | } | ||
| 24 | |||
| 25 | fn spinning(&self, view: &SlotsView, now: f64) -> bool { | ||
| 26 | // A rejected spin (no balance, server error) never reaches the reels. | ||
| 27 | if view.error.is_some() { | ||
| 28 | return false; | ||
| 29 | } | ||
| 30 | match self.started { | ||
| 31 | Some(started) => now - started < SPIN_SECONDS || view.phase == SlotsPhase::Spinning, | ||
| 32 | None => view.phase == SlotsPhase::Spinning, | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | /// True once the reels have stopped on the server's answer. | ||
| 37 | pub fn revealed(&self, ctx: &eframe::egui::Context, view: &SlotsView) -> bool { | ||
| 38 | let now = ctx.input(|input| input.time); | ||
| 39 | !self.spinning(view, now) | ||
| 40 | } | ||
| 41 | |||
| 42 | pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &SlotsView) { | ||
| 43 | if view.error.is_some() { | ||
| 44 | self.started = None; | ||
| 45 | return; | ||
| 46 | } | ||
| 47 | let now = ctx.input(|input| input.time); | ||
| 48 | if self.spinning(view, now) { | ||
| 49 | ctx.request_repaint(); | ||
| 50 | } else { | ||
| 51 | self.started = None; | ||
| 52 | } | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | pub fn show(ui: &mut Ui, view: &SlotsView, fx: &mut Fx) -> Option<Command> { | ||
| 57 | let mut command = None; | ||
| 58 | eframe::egui::CentralPanel::default() | ||
| 59 | .frame(eframe::egui::Frame::new().fill(theme::BG)) | ||
| 60 | .show(ui, |ui| { | ||
| 61 | let full = ui.max_rect(); | ||
| 62 | let sidebar_rect = eframe::egui::Rect::from_min_max( | ||
| 63 | eframe::egui::pos2(full.right() - 336.0, full.top()), | ||
| 64 | full.right_bottom(), | ||
| 65 | ); | ||
| 66 | let board_rect = eframe::egui::Rect::from_min_max( | ||
| 67 | full.min, | ||
| 68 | eframe::egui::pos2(sidebar_rect.left(), full.bottom()), | ||
| 69 | ); | ||
| 70 | ui.painter().rect_filled( | ||
| 71 | sidebar_rect, | ||
| 72 | eframe::egui::CornerRadius::ZERO, | ||
| 73 | theme::SURFACE, | ||
| 74 | ); | ||
| 75 | ui.scope_builder( | ||
| 76 | eframe::egui::UiBuilder::new() | ||
| 77 | .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0))) | ||
| 78 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 79 | |ui| board(ui, view, fx), | ||
| 80 | ); | ||
| 81 | ui.scope_builder( | ||
| 82 | eframe::egui::UiBuilder::new() | ||
| 83 | .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0))) | ||
| 84 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 85 | |ui| { | ||
| 86 | command = sidebar(ui, view, fx); | ||
| 87 | }, | ||
| 88 | ); | ||
| 89 | }); | ||
| 90 | command | ||
| 91 | } | ||
| 92 | |||
| 93 | fn sidebar(ui: &mut Ui, view: &SlotsView, fx: &mut Fx) -> Option<Command> { | ||
| 94 | let now = ui.input(|input| input.time); | ||
| 95 | let spinning = fx.spinning(view, now); | ||
| 96 | let mut command = None; | ||
| 97 | let (content, actions) = widgets::sidebar_layout(ui, 150.0); | ||
| 98 | |||
| 99 | ui.scope_builder( | ||
| 100 | eframe::egui::UiBuilder::new() | ||
| 101 | .max_rect(content) | ||
| 102 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 103 | |ui| { | ||
| 104 | eframe::egui::ScrollArea::vertical() | ||
| 105 | .auto_shrink([false, false]) | ||
| 106 | .show(ui, |ui| { | ||
| 107 | ui.label( | ||
| 108 | eframe::egui::RichText::new("Slots") | ||
| 109 | .font(theme::sans(22.0)) | ||
| 110 | .color(theme::TEXT), | ||
| 111 | ); | ||
| 112 | ui.label( | ||
| 113 | eframe::egui::RichText::new("5 reels · 5 paylines") | ||
| 114 | .font(theme::sans(12.0)) | ||
| 115 | .color(theme::TEXT_DIM), | ||
| 116 | ); | ||
| 117 | |||
| 118 | ui.add_space(theme::SPACE_5); | ||
| 119 | ui.label(theme::label("Bet")); | ||
| 120 | ui.add_space(theme::SPACE_2); | ||
| 121 | if let Some(delta) = widgets::bet_control(ui, view.bet, !spinning) { | ||
| 122 | command = Some(Command::AdjustBet(delta)); | ||
| 123 | } | ||
| 124 | |||
| 125 | ui.add_space(theme::SPACE_4); | ||
| 126 | status(ui, view, spinning); | ||
| 127 | if let Some(error) = &view.error { | ||
| 128 | ui.add_space(theme::SPACE_3); | ||
| 129 | widgets::error(ui, error); | ||
| 130 | } | ||
| 131 | |||
| 132 | ui.add_space(theme::SPACE_4); | ||
| 133 | ui.separator(); | ||
| 134 | ui.add_space(theme::SPACE_3); | ||
| 135 | ui.label(theme::label("Paytable")); | ||
| 136 | ui.add_space(theme::SPACE_2); | ||
| 137 | ui.label( | ||
| 138 | eframe::egui::RichText::new("Pays bet × symbol × shape") | ||
| 139 | .font(theme::sans(11.0)) | ||
| 140 | .color(theme::TEXT_FAINT), | ||
| 141 | ); | ||
| 142 | ui.horizontal(|ui| { | ||
| 143 | ui.spacing_mut().item_spacing.x = theme::SPACE_2; | ||
| 144 | for (symbol, multiplier) in zagan_bet::slots::SYMBOL_MULTIPLIERS { | ||
| 145 | ui.label( | ||
| 146 | eframe::egui::RichText::new(format!("{symbol}{multiplier}")) | ||
| 147 | .font(theme::mono(12.0)) | ||
| 148 | .color(symbol_color(symbol)), | ||
| 149 | ); | ||
| 150 | } | ||
| 151 | }); | ||
| 152 | ui.label( | ||
| 153 | eframe::egui::RichText::new("3×1 4×3 5×14") | ||
| 154 | .font(theme::mono(12.0)) | ||
| 155 | .color(theme::TEXT_DIM), | ||
| 156 | ); | ||
| 157 | }); | ||
| 158 | }, | ||
| 159 | ); | ||
| 160 | |||
| 161 | ui.scope_builder( | ||
| 162 | eframe::egui::UiBuilder::new() | ||
| 163 | .max_rect(actions) | ||
| 164 | .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)), | ||
| 165 | |ui| { | ||
| 166 | if widgets::ghost(ui, "Leave machine") { | ||
| 167 | command = Some(Command::Leave); | ||
| 168 | } | ||
| 169 | ui.add_space(theme::SPACE_2); | ||
| 170 | if widgets::primary(ui, "SPIN", !spinning, ui.available_width(), 46.0) { | ||
| 171 | fx.spin_requested(ui.ctx()); | ||
| 172 | command = Some(Command::Spin); | ||
| 173 | } | ||
| 174 | }, | ||
| 175 | ); | ||
| 176 | |||
| 177 | command | ||
| 178 | } | ||
| 179 | |||
| 180 | fn status(ui: &mut Ui, view: &SlotsView, spinning: bool) { | ||
| 181 | if spinning { | ||
| 182 | ui.label( | ||
| 183 | eframe::egui::RichText::new("SPINNING…") | ||
| 184 | .font(theme::mono(12.0)) | ||
| 185 | .color(theme::GOLD), | ||
| 186 | ); | ||
| 187 | return; | ||
| 188 | } | ||
| 189 | |||
| 190 | match view.phase { | ||
| 191 | SlotsPhase::Result => { | ||
| 192 | let winnings = view.winnings.unwrap_or(0); | ||
| 193 | if winnings == 0 { | ||
| 194 | ui.label( | ||
| 195 | eframe::egui::RichText::new("NO WIN") | ||
| 196 | .font(theme::mono(13.0)) | ||
| 197 | .color(theme::TEXT_FAINT), | ||
| 198 | ); | ||
| 199 | } else { | ||
| 200 | let label = match view.win_type.as_str() { | ||
| 201 | "mega" => "MEGA WIN", | ||
| 202 | "big" => "BIG WIN", | ||
| 203 | _ => "WON", | ||
| 204 | }; | ||
| 205 | ui.horizontal(|ui| { | ||
| 206 | ui.label(theme::label(label)); | ||
| 207 | ui.label( | ||
| 208 | eframe::egui::RichText::new(theme::money(winnings as i64)) | ||
| 209 | .font(theme::mono(18.0)) | ||
| 210 | .color(theme::WIN), | ||
| 211 | ); | ||
| 212 | }); | ||
| 213 | if !view.win_lines.is_empty() { | ||
| 214 | let names: Vec<String> = view | ||
| 215 | .win_lines | ||
| 216 | .iter() | ||
| 217 | .map(|line| { | ||
| 218 | line.checked_sub(1) | ||
| 219 | .and_then(|index| LINE_NAMES.get(index).copied()) | ||
| 220 | .map(|name| format!("{line} ({name})")) | ||
| 221 | .unwrap_or_else(|| line.to_string()) | ||
| 222 | }) | ||
| 223 | .collect(); | ||
| 224 | ui.label( | ||
| 225 | eframe::egui::RichText::new(names.join(" · ")) | ||
| 226 | .font(theme::sans(11.0)) | ||
| 227 | .color(theme::TEXT_DIM), | ||
| 228 | ); | ||
| 229 | } | ||
| 230 | } | ||
| 231 | } | ||
| 232 | _ => { | ||
| 233 | ui.label( | ||
| 234 | eframe::egui::RichText::new("Press SPIN to play") | ||
| 235 | .font(theme::sans(12.0)) | ||
| 236 | .color(theme::TEXT_DIM), | ||
| 237 | ); | ||
| 238 | } | ||
| 239 | } | ||
| 240 | } | ||
| 241 | |||
| 242 | fn board(ui: &mut Ui, view: &SlotsView, fx: &Fx) { | ||
| 243 | ui.horizontal(|ui| { | ||
| 244 | ui.label( | ||
| 245 | eframe::egui::RichText::new("Machine 01") | ||
| 246 | .font(theme::sans(15.0)) | ||
| 247 | .color(theme::TEXT), | ||
| 248 | ); | ||
| 249 | ui.label( | ||
| 250 | eframe::egui::RichText::new("5 reels · 5 paylines") | ||
| 251 | .font(theme::sans(12.0)) | ||
| 252 | .color(theme::TEXT_DIM), | ||
| 253 | ); | ||
| 254 | ui.with_layout( | ||
| 255 | eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), | ||
| 256 | |ui| { | ||
| 257 | ui.label( | ||
| 258 | eframe::egui::RichText::new("Bet 10 – 500 Kr") | ||
| 259 | .font(theme::mono(12.0)) | ||
| 260 | .color(theme::TEXT_FAINT), | ||
| 261 | ); | ||
| 262 | }, | ||
| 263 | ); | ||
| 264 | }); | ||
| 265 | ui.add_space(theme::SPACE_5); | ||
| 266 | |||
| 267 | let now = ui.input(|input| input.time); | ||
| 268 | let spinning = fx.spinning(view, now); | ||
| 269 | let available = ui.available_size(); | ||
| 270 | let gap = theme::SPACE_2; | ||
| 271 | let tile_width = ((available.x - gap * (REELS as f32 - 1.0)) / REELS as f32) | ||
| 272 | .min((available.y - 150.0) / 3.0) | ||
| 273 | .min(190.0); | ||
| 274 | let tile = vec2(tile_width, tile_width * 0.92); | ||
| 275 | let board_width = tile.x * REELS as f32 + gap * (REELS as f32 - 1.0); | ||
| 276 | let board_height = tile.y * 3.0 + gap * 2.0; | ||
| 277 | let left = ((available.x - board_width) * 0.5).max(0.0); | ||
| 278 | let top = ((available.y - board_height) * 0.35).max(0.0); | ||
| 279 | |||
| 280 | ui.add_space(top); | ||
| 281 | let origin = ui | ||
| 282 | .horizontal(|ui| { | ||
| 283 | ui.add_space(left); | ||
| 284 | ui.allocate_exact_size(vec2(board_width, board_height), Sense::hover()) | ||
| 285 | .0 | ||
| 286 | .min | ||
| 287 | }) | ||
| 288 | .inner; | ||
| 289 | |||
| 290 | let painter = ui.painter(); | ||
| 291 | let cycling = (now * 18.0) as usize; | ||
| 292 | for col in 0..REELS { | ||
| 293 | for row in 0..ROWS { | ||
| 294 | let rect = Rect::from_min_size( | ||
| 295 | origin + vec2(col as f32 * (tile.x + gap), row as f32 * (tile.y + gap)), | ||
| 296 | tile, | ||
| 297 | ); | ||
| 298 | let index = col * ROWS + row; | ||
| 299 | let stocked = | ||
| 300 | view.reels.len() == REELS * ROWS || (view.reels.len() == REELS && row == 0); | ||
| 301 | let symbol = if spinning { | ||
| 302 | SYMBOLS[(cycling + index * 3) % SYMBOLS.len()] | ||
| 303 | } else if stocked { | ||
| 304 | if view.reels.len() == REELS * ROWS { | ||
| 305 | view.reels[index] | ||
| 306 | } else { | ||
| 307 | view.reels[col] | ||
| 308 | } | ||
| 309 | } else { | ||
| 310 | SYMBOLS[(index * 5 + col) % SYMBOLS.len()] | ||
| 311 | }; | ||
| 312 | let winning = !spinning && view.win_cells.contains(&index); | ||
| 313 | paint_tile(painter, rect, symbol, spinning, winning, !stocked); | ||
| 314 | } | ||
| 315 | } | ||
| 316 | |||
| 317 | if !spinning && view.phase == SlotsPhase::Result { | ||
| 318 | paint_win_lines(painter, origin, tile, gap, view); | ||
| 319 | } | ||
| 320 | |||
| 321 | ui.add_space(theme::SPACE_5); | ||
| 322 | ui.horizontal(|ui| { | ||
| 323 | ui.label(theme::label("Result")); | ||
| 324 | if spinning { | ||
| 325 | ui.label( | ||
| 326 | eframe::egui::RichText::new("Spinning…") | ||
| 327 | .font(theme::mono(12.0)) | ||
| 328 | .color(theme::GOLD), | ||
| 329 | ); | ||
| 330 | } else if view.phase == SlotsPhase::Result { | ||
| 331 | let winnings = view.winnings.unwrap_or(0); | ||
| 332 | if winnings == 0 { | ||
| 333 | ui.label( | ||
| 334 | eframe::egui::RichText::new("No win") | ||
| 335 | .font(theme::mono(12.0)) | ||
| 336 | .color(theme::TEXT_DIM), | ||
| 337 | ); | ||
| 338 | } else { | ||
| 339 | let label = match view.win_type.as_str() { | ||
| 340 | "mega" => "MEGA WIN", | ||
| 341 | "big" => "BIG WIN", | ||
| 342 | _ => "WIN", | ||
| 343 | }; | ||
| 344 | ui.label( | ||
| 345 | eframe::egui::RichText::new(format!( | ||
| 346 | "{label} +{}", | ||
| 347 | theme::money(winnings as i64) | ||
| 348 | )) | ||
| 349 | .font(theme::mono(12.0)) | ||
| 350 | .color(theme::WIN), | ||
| 351 | ); | ||
| 352 | } | ||
| 353 | } else { | ||
| 354 | ui.label( | ||
| 355 | eframe::egui::RichText::new("Ready") | ||
| 356 | .font(theme::mono(12.0)) | ||
| 357 | .color(theme::TEXT_DIM), | ||
| 358 | ); | ||
| 359 | } | ||
| 360 | }); | ||
| 361 | } | ||
| 362 | |||
| 363 | fn paint_tile( | ||
| 364 | painter: &Painter, | ||
| 365 | rect: Rect, | ||
| 366 | symbol: char, | ||
| 367 | spinning: bool, | ||
| 368 | winning: bool, | ||
| 369 | placeholder: bool, | ||
| 370 | ) { | ||
| 371 | let fill = if winning { | ||
| 372 | theme::GOLD.gamma_multiply(0.14) | ||
| 373 | } else { | ||
| 374 | theme::SURFACE | ||
| 375 | }; | ||
| 376 | painter.rect_filled(rect, theme::RADIUS_MD, fill); | ||
| 377 | let stroke = if winning { | ||
| 378 | Stroke::new(2.0, theme::GOLD) | ||
| 379 | } else { | ||
| 380 | Stroke::new(1.0, theme::LINE) | ||
| 381 | }; | ||
| 382 | painter.rect_stroke(rect, theme::RADIUS_MD, stroke, StrokeKind::Inside); | ||
| 383 | |||
| 384 | let color = if spinning || placeholder { | ||
| 385 | symbol_color(symbol).gamma_multiply(0.35) | ||
| 386 | } else { | ||
| 387 | symbol_color(symbol) | ||
| 388 | }; | ||
| 389 | painter.text( | ||
| 390 | rect.center(), | ||
| 391 | Align2::CENTER_CENTER, | ||
| 392 | symbol, | ||
| 393 | theme::mono(rect.width() * 0.42), | ||
| 394 | color, | ||
| 395 | ); | ||
| 396 | } | ||
| 397 | |||
| 398 | fn paint_win_lines(painter: &Painter, origin: Pos2, tile: Vec2, gap: f32, view: &SlotsView) { | ||
| 399 | for line in &view.win_lines { | ||
| 400 | let columns = winning_columns(view, *line); | ||
| 401 | if columns.len() < 2 { | ||
| 402 | continue; | ||
| 403 | } | ||
| 404 | let points: Vec<Pos2> = columns | ||
| 405 | .iter() | ||
| 406 | .map(|col| { | ||
| 407 | let row = PAYLINES[*line - 1][*col]; | ||
| 408 | Rect::from_min_size( | ||
| 409 | origin + vec2(*col as f32 * (tile.x + gap), row as f32 * (tile.y + gap)), | ||
| 410 | tile, | ||
| 411 | ) | ||
| 412 | .center() | ||
| 413 | }) | ||
| 414 | .collect(); | ||
| 415 | painter.add(eframe::egui::Shape::line( | ||
| 416 | points, | ||
| 417 | Stroke::new(2.0, theme::GOLD.gamma_multiply(0.8)), | ||
| 418 | )); | ||
| 419 | } | ||
| 420 | } | ||
| 421 | |||
| 422 | /// The reels that actually matched on a payline. | ||
| 423 | /// | ||
| 424 | /// A win is a prefix of matching symbols from reel 1, so the line must stop | ||
| 425 | /// at the last winning cell instead of crossing all five reels. | ||
| 426 | pub fn winning_columns(view: &SlotsView, line: usize) -> Vec<usize> { | ||
| 427 | let Some(rows) = line.checked_sub(1).and_then(|index| PAYLINES.get(index)) else { | ||
| 428 | return Vec::new(); | ||
| 429 | }; | ||
| 430 | rows.iter() | ||
| 431 | .enumerate() | ||
| 432 | .filter(|(col, row)| view.win_cells.contains(&(col * ROWS + *row))) | ||
| 433 | .map(|(col, _)| col) | ||
| 434 | .collect() | ||
| 435 | } | ||
| 436 | |||
| 437 | pub fn symbol_color(symbol: char) -> Color32 { | ||
| 438 | match symbol { | ||
| 439 | 'C' => Color32::from_rgb(0xe0, 0x5a, 0x5a), | ||
| 440 | 'L' => Color32::from_rgb(0xe0, 0xb6, 0x4a), | ||
| 441 | 'O' => Color32::from_rgb(0xe0, 0x8a, 0x3c), | ||
| 442 | 'G' => Color32::from_rgb(0xb5, 0x7e, 0xdc), | ||
| 443 | 'D' => Color32::from_rgb(0x4f, 0xb8, 0xc9), | ||
| 444 | 'S' => Color32::from_rgb(0x4e, 0xc2, 0x7b), | ||
| 445 | _ => theme::TEXT, | ||
| 446 | } | ||
| 447 | } | ||
