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/crash.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 'gui/src/crash.rs')
| -rw-r--r-- | gui/src/crash.rs | 416 |
1 files changed, 416 insertions, 0 deletions
diff --git a/gui/src/crash.rs b/gui/src/crash.rs new file mode 100644 index 0000000..14197ae --- /dev/null +++ b/gui/src/crash.rs | |||
| @@ -0,0 +1,416 @@ | |||
| 1 | //! Crash screen: a live multiplier painted from `CrashView`. | ||
| 2 | |||
| 3 | use eframe::egui::{Align2, Color32, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; | ||
| 4 | use zagan_bet::client::view::{Command, CrashView}; | ||
| 5 | |||
| 6 | use crate::{theme, widgets}; | ||
| 7 | |||
| 8 | /// One sample of the running curve. | ||
| 9 | struct Sample { | ||
| 10 | seconds: f32, | ||
| 11 | multiplier: f64, | ||
| 12 | } | ||
| 13 | |||
| 14 | #[derive(Default)] | ||
| 15 | pub struct Fx { | ||
| 16 | points: Vec<Sample>, | ||
| 17 | started: Option<f64>, | ||
| 18 | status: String, | ||
| 19 | peak: f64, | ||
| 20 | last_crash: Option<f64>, | ||
| 21 | } | ||
| 22 | |||
| 23 | impl Fx { | ||
| 24 | pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &CrashView) { | ||
| 25 | let now = ctx.input(|input| input.time); | ||
| 26 | |||
| 27 | if view.status == "starting" && self.status != "starting" { | ||
| 28 | self.points.clear(); | ||
| 29 | self.started = None; | ||
| 30 | self.peak = 1.0; | ||
| 31 | } | ||
| 32 | |||
| 33 | if view.status == "running" { | ||
| 34 | let started = *self.started.get_or_insert(now); | ||
| 35 | let seconds = (now - started) as f32; | ||
| 36 | let last = self.points.last().map(|sample| sample.seconds); | ||
| 37 | if last.is_none_or(|last| seconds - last >= 0.016) { | ||
| 38 | self.points.push(Sample { | ||
| 39 | seconds, | ||
| 40 | multiplier: view.multiplier, | ||
| 41 | }); | ||
| 42 | self.peak = self.peak.max(view.multiplier); | ||
| 43 | } | ||
| 44 | ctx.request_repaint_after(std::time::Duration::from_millis(16)); | ||
| 45 | } else if view.status == "starting" { | ||
| 46 | ctx.request_repaint_after(std::time::Duration::from_millis(50)); | ||
| 47 | } | ||
| 48 | |||
| 49 | if view.status == "crashed" { | ||
| 50 | if let Some(point) = view.crash_point { | ||
| 51 | self.last_crash = Some(point); | ||
| 52 | } | ||
| 53 | } | ||
| 54 | if view.status != self.status { | ||
| 55 | self.status = view.status.clone(); | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | fn reset(&mut self) { | ||
| 60 | self.points.clear(); | ||
| 61 | self.started = None; | ||
| 62 | self.peak = 1.0; | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | pub fn show(ui: &mut Ui, view: &CrashView, fx: &mut Fx) -> Option<Command> { | ||
| 67 | let mut command = None; | ||
| 68 | eframe::egui::CentralPanel::default() | ||
| 69 | .frame(eframe::egui::Frame::new().fill(theme::BG)) | ||
| 70 | .show(ui, |ui| { | ||
| 71 | let full = ui.max_rect(); | ||
| 72 | let sidebar_rect = eframe::egui::Rect::from_min_max( | ||
| 73 | eframe::egui::pos2(full.right() - 336.0, full.top()), | ||
| 74 | full.right_bottom(), | ||
| 75 | ); | ||
| 76 | let board_rect = eframe::egui::Rect::from_min_max( | ||
| 77 | full.min, | ||
| 78 | eframe::egui::pos2(sidebar_rect.left(), full.bottom()), | ||
| 79 | ); | ||
| 80 | ui.painter().rect_filled( | ||
| 81 | sidebar_rect, | ||
| 82 | eframe::egui::CornerRadius::ZERO, | ||
| 83 | theme::SURFACE, | ||
| 84 | ); | ||
| 85 | ui.scope_builder( | ||
| 86 | eframe::egui::UiBuilder::new() | ||
| 87 | .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0))) | ||
| 88 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 89 | |ui| chart(ui, view, fx), | ||
| 90 | ); | ||
| 91 | ui.scope_builder( | ||
| 92 | eframe::egui::UiBuilder::new() | ||
| 93 | .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0))) | ||
| 94 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 95 | |ui| { | ||
| 96 | command = sidebar(ui, view, fx); | ||
| 97 | }, | ||
| 98 | ); | ||
| 99 | }); | ||
| 100 | command | ||
| 101 | } | ||
| 102 | |||
| 103 | fn sidebar(ui: &mut Ui, view: &CrashView, fx: &mut Fx) -> Option<Command> { | ||
| 104 | let mut command = None; | ||
| 105 | let running = view.status == "running"; | ||
| 106 | let starting = view.status == "starting"; | ||
| 107 | let (content, actions) = widgets::sidebar_layout(ui, 130.0); | ||
| 108 | |||
| 109 | ui.scope_builder( | ||
| 110 | eframe::egui::UiBuilder::new() | ||
| 111 | .max_rect(content) | ||
| 112 | .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), | ||
| 113 | |ui| { | ||
| 114 | eframe::egui::ScrollArea::vertical() | ||
| 115 | .auto_shrink([false, false]) | ||
| 116 | .show(ui, |ui| { | ||
| 117 | ui.label( | ||
| 118 | eframe::egui::RichText::new("Crash") | ||
| 119 | .font(theme::sans(22.0)) | ||
| 120 | .color(theme::TEXT), | ||
| 121 | ); | ||
| 122 | ui.label( | ||
| 123 | eframe::egui::RichText::new("Rise · cash out · don't bust") | ||
| 124 | .font(theme::sans(12.0)) | ||
| 125 | .color(theme::TEXT_DIM), | ||
| 126 | ); | ||
| 127 | |||
| 128 | ui.add_space(theme::SPACE_5); | ||
| 129 | ui.label(theme::label("Bet")); | ||
| 130 | ui.add_space(theme::SPACE_2); | ||
| 131 | if let Some(delta) = widgets::bet_control(ui, view.bet, !running && !starting) { | ||
| 132 | command = Some(Command::AdjustBet(delta)); | ||
| 133 | } | ||
| 134 | |||
| 135 | ui.add_space(theme::SPACE_4); | ||
| 136 | widgets::row( | ||
| 137 | ui, | ||
| 138 | "Potential", | ||
| 139 | &theme::money(view.potential as i64), | ||
| 140 | theme::GOLD, | ||
| 141 | ); | ||
| 142 | |||
| 143 | ui.add_space(theme::SPACE_3); | ||
| 144 | status(ui, view); | ||
| 145 | |||
| 146 | if let Some(point) = fx.last_crash { | ||
| 147 | ui.add_space(theme::SPACE_2); | ||
| 148 | widgets::row(ui, "Last crash", &format!("{point:.2}x"), theme::TEXT_DIM); | ||
| 149 | } | ||
| 150 | |||
| 151 | if let Some(error) = &view.error { | ||
| 152 | ui.add_space(theme::SPACE_3); | ||
| 153 | widgets::error(ui, error); | ||
| 154 | } | ||
| 155 | |||
| 156 | ui.add_space(theme::SPACE_4); | ||
| 157 | ui.separator(); | ||
| 158 | ui.add_space(theme::SPACE_2); | ||
| 159 | widgets::note( | ||
| 160 | ui, | ||
| 161 | "The crash point is server-timed. The curve here is display only.", | ||
| 162 | ); | ||
| 163 | }); | ||
| 164 | }, | ||
| 165 | ); | ||
| 166 | |||
| 167 | ui.scope_builder( | ||
| 168 | eframe::egui::UiBuilder::new() | ||
| 169 | .max_rect(actions) | ||
| 170 | .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)), | ||
| 171 | |ui| { | ||
| 172 | if widgets::ghost( | ||
| 173 | ui, | ||
| 174 | if running { | ||
| 175 | "Forfeit round" | ||
| 176 | } else { | ||
| 177 | "Leave table" | ||
| 178 | }, | ||
| 179 | ) { | ||
| 180 | command = Some(Command::Leave); | ||
| 181 | } | ||
| 182 | ui.add_space(theme::SPACE_2); | ||
| 183 | if running { | ||
| 184 | if widgets::primary(ui, "CASH OUT", true, ui.available_width(), 46.0) { | ||
| 185 | command = Some(Command::CashOut); | ||
| 186 | } | ||
| 187 | } else if !starting && widgets::primary(ui, "START", true, ui.available_width(), 46.0) { | ||
| 188 | fx.reset(); | ||
| 189 | command = Some(Command::Start); | ||
| 190 | } | ||
| 191 | }, | ||
| 192 | ); | ||
| 193 | |||
| 194 | command | ||
| 195 | } | ||
| 196 | |||
| 197 | fn status(ui: &mut Ui, view: &CrashView) { | ||
| 198 | match view.status.as_str() { | ||
| 199 | "starting" => widgets::note(ui, "Round starting…"), | ||
| 200 | "running" => widgets::note(ui, "Rising. Cash out before it crashes."), | ||
| 201 | "cashed" => { | ||
| 202 | ui.label( | ||
| 203 | eframe::egui::RichText::new(format!( | ||
| 204 | "CASHED OUT {:.2}x +{}", | ||
| 205 | view.multiplier, | ||
| 206 | theme::money(view.payout as i64) | ||
| 207 | )) | ||
| 208 | .font(theme::mono(14.0)) | ||
| 209 | .color(theme::WIN), | ||
| 210 | ); | ||
| 211 | } | ||
| 212 | "crashed" => { | ||
| 213 | ui.label( | ||
| 214 | eframe::egui::RichText::new(format!( | ||
| 215 | "CRASHED at {:.2}x", | ||
| 216 | view.crash_point.unwrap_or(view.multiplier) | ||
| 217 | )) | ||
| 218 | .font(theme::mono(14.0)) | ||
| 219 | .color(theme::LOSS), | ||
| 220 | ); | ||
| 221 | } | ||
| 222 | "forfeit" => { | ||
| 223 | ui.label( | ||
| 224 | eframe::egui::RichText::new("FORFEITED") | ||
| 225 | .font(theme::mono(14.0)) | ||
| 226 | .color(theme::TEXT_DIM), | ||
| 227 | ); | ||
| 228 | } | ||
| 229 | _ => widgets::note(ui, "Press START to play."), | ||
| 230 | } | ||
| 231 | } | ||
| 232 | |||
| 233 | fn chart(ui: &mut Ui, view: &CrashView, fx: &Fx) { | ||
| 234 | ui.horizontal(|ui| { | ||
| 235 | ui.label( | ||
| 236 | eframe::egui::RichText::new("Table 01") | ||
| 237 | .font(theme::sans(15.0)) | ||
| 238 | .color(theme::TEXT), | ||
| 239 | ); | ||
| 240 | ui.label( | ||
| 241 | eframe::egui::RichText::new("Crash · server-timed") | ||
| 242 | .font(theme::sans(12.0)) | ||
| 243 | .color(theme::TEXT_DIM), | ||
| 244 | ); | ||
| 245 | ui.with_layout( | ||
| 246 | eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), | ||
| 247 | |ui| { | ||
| 248 | ui.label( | ||
| 249 | eframe::egui::RichText::new("Cash out any time before the crash") | ||
| 250 | .font(theme::mono(11.0)) | ||
| 251 | .color(theme::TEXT_FAINT), | ||
| 252 | ); | ||
| 253 | }, | ||
| 254 | ); | ||
| 255 | }); | ||
| 256 | ui.add_space(theme::SPACE_4); | ||
| 257 | |||
| 258 | let available = ui.available_size(); | ||
| 259 | let size = vec2(available.x, (available.y - 70.0).max(260.0)); | ||
| 260 | let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); | ||
| 261 | let painter = ui.painter(); | ||
| 262 | |||
| 263 | painter.rect_filled(rect, theme::RADIUS_LG, theme::SURFACE); | ||
| 264 | painter.rect_stroke( | ||
| 265 | rect, | ||
| 266 | theme::RADIUS_LG, | ||
| 267 | Stroke::new(1.0, theme::LINE), | ||
| 268 | StrokeKind::Inside, | ||
| 269 | ); | ||
| 270 | |||
| 271 | let plot = rect.shrink2(vec2(56.0, 48.0)); | ||
| 272 | let max_seconds = fx | ||
| 273 | .points | ||
| 274 | .last() | ||
| 275 | .map(|sample| sample.seconds) | ||
| 276 | .unwrap_or(0.0) | ||
| 277 | .max(6.0) | ||
| 278 | * 1.08; | ||
| 279 | let max_multiplier = fx.peak.max(2.0) * 1.15; | ||
| 280 | |||
| 281 | painter.text( | ||
| 282 | plot.left_top() + vec2(0.0, 2.0), | ||
| 283 | Align2::LEFT_TOP, | ||
| 284 | format!("{:.2}x", view.multiplier), | ||
| 285 | theme::mono(46.0), | ||
| 286 | curve_color(view), | ||
| 287 | ); | ||
| 288 | painter.text( | ||
| 289 | plot.left_top() + vec2(2.0, 58.0), | ||
| 290 | Align2::LEFT_TOP, | ||
| 291 | status_label(view), | ||
| 292 | theme::mono(10.0), | ||
| 293 | theme::TEXT_FAINT, | ||
| 294 | ); | ||
| 295 | |||
| 296 | // Grid: whole multipliers and two-second columns. | ||
| 297 | let grid = Stroke::new(1.0, theme::LINE.gamma_multiply(0.55)); | ||
| 298 | let mut step = 1.0; | ||
| 299 | while max_multiplier / step > 6.0 { | ||
| 300 | step += 1.0; | ||
| 301 | } | ||
| 302 | let mut value = step; | ||
| 303 | while value <= max_multiplier { | ||
| 304 | let y = map_y(plot, value, max_multiplier); | ||
| 305 | painter.hline(plot.x_range(), y, grid); | ||
| 306 | painter.text( | ||
| 307 | pos2(plot.left() - 10.0, y), | ||
| 308 | Align2::RIGHT_CENTER, | ||
| 309 | format!("{value:.0}x"), | ||
| 310 | theme::mono(10.0), | ||
| 311 | theme::TEXT_FAINT, | ||
| 312 | ); | ||
| 313 | value += step; | ||
| 314 | } | ||
| 315 | let mut second = 2.0; | ||
| 316 | while f64::from(second) <= f64::from(max_seconds) { | ||
| 317 | let x = plot.left() + plot.width() * (second / max_seconds); | ||
| 318 | painter.vline(x, plot.y_range(), grid); | ||
| 319 | painter.text( | ||
| 320 | pos2(x, plot.bottom() + 8.0), | ||
| 321 | Align2::CENTER_TOP, | ||
| 322 | format!("{second:.0}s"), | ||
| 323 | theme::mono(10.0), | ||
| 324 | theme::TEXT_FAINT, | ||
| 325 | ); | ||
| 326 | second += 2.0; | ||
| 327 | } | ||
| 328 | |||
| 329 | if fx.points.is_empty() { | ||
| 330 | painter.text( | ||
| 331 | plot.center(), | ||
| 332 | Align2::CENTER_CENTER, | ||
| 333 | if view.status == "running" { | ||
| 334 | "…" | ||
| 335 | } else { | ||
| 336 | "Press START to play" | ||
| 337 | }, | ||
| 338 | theme::sans(14.0), | ||
| 339 | theme::TEXT_FAINT, | ||
| 340 | ); | ||
| 341 | } else { | ||
| 342 | let points: Vec<Pos2> = fx | ||
| 343 | .points | ||
| 344 | .iter() | ||
| 345 | .map(|sample| { | ||
| 346 | pos2( | ||
| 347 | plot.left() + plot.width() * (sample.seconds / max_seconds), | ||
| 348 | map_y(plot, sample.multiplier, max_multiplier), | ||
| 349 | ) | ||
| 350 | }) | ||
| 351 | .collect(); | ||
| 352 | |||
| 353 | // A soft fill under the curve. | ||
| 354 | if points.len() >= 2 { | ||
| 355 | let mut fill = points.clone(); | ||
| 356 | fill.push(pos2(points[points.len() - 1].x, plot.bottom())); | ||
| 357 | fill.push(pos2(points[0].x, plot.bottom())); | ||
| 358 | painter.add(Shape::convex_polygon( | ||
| 359 | fill, | ||
| 360 | curve_color(view).gamma_multiply(0.10), | ||
| 361 | Stroke::NONE, | ||
| 362 | )); | ||
| 363 | } | ||
| 364 | painter.add(Shape::line(points, Stroke::new(2.5, curve_color(view)))); | ||
| 365 | |||
| 366 | if view.status == "crashed" { | ||
| 367 | if let Some(point) = view.crash_point { | ||
| 368 | let y = map_y(plot, point, max_multiplier); | ||
| 369 | let x = plot.left() | ||
| 370 | + plot.width() * (fx.points.last().map_or(0.0, |s| s.seconds) / max_seconds); | ||
| 371 | painter.circle_filled(pos2(x, y), 5.0, theme::LOSS); | ||
| 372 | painter.text( | ||
| 373 | pos2(x + 10.0, y - 8.0), | ||
| 374 | Align2::LEFT_BOTTOM, | ||
| 375 | format!("{point:.2}x"), | ||
| 376 | theme::mono(12.0), | ||
| 377 | theme::LOSS, | ||
| 378 | ); | ||
| 379 | } | ||
| 380 | } | ||
| 381 | } | ||
| 382 | |||
| 383 | ui.add_space(theme::SPACE_4); | ||
| 384 | ui.label( | ||
| 385 | eframe::egui::RichText::new( | ||
| 386 | "The crash point is drawn from the server; the multiplier here is display only.", | ||
| 387 | ) | ||
| 388 | .font(theme::sans(11.0)) | ||
| 389 | .color(theme::TEXT_FAINT), | ||
| 390 | ); | ||
| 391 | } | ||
| 392 | |||
| 393 | fn map_y(plot: Rect, multiplier: f64, max_multiplier: f64) -> f32 { | ||
| 394 | let range = (max_multiplier - 1.0).max(0.1); | ||
| 395 | plot.bottom() - ((multiplier - 1.0) / range) as f32 * plot.height() | ||
| 396 | } | ||
| 397 | |||
| 398 | fn status_label(view: &CrashView) -> &'static str { | ||
| 399 | match view.status.as_str() { | ||
| 400 | "starting" => "STARTING", | ||
| 401 | "running" => "LIVE", | ||
| 402 | "cashed" => "CASHED OUT", | ||
| 403 | "crashed" => "CRASHED", | ||
| 404 | "forfeit" => "FORFEITED", | ||
| 405 | _ => "READY", | ||
| 406 | } | ||
| 407 | } | ||
| 408 | |||
| 409 | fn curve_color(view: &CrashView) -> Color32 { | ||
| 410 | match view.status.as_str() { | ||
| 411 | "running" => theme::WIN, | ||
| 412 | "cashed" => theme::GOLD, | ||
| 413 | "crashed" => theme::LOSS, | ||
| 414 | _ => theme::TEXT_DIM, | ||
| 415 | } | ||
| 416 | } | ||
