diff options
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 | } | ||
