//! Crash screen: a live multiplier painted from `CrashView`. use eframe::egui::{Align2, Color32, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use zagan_bet::client::view::{Command, CrashView}; use zagan_bet::crash::{crash_point_from_seed, seed_from_hex, verify_seed}; use crate::{theme, widgets}; fn short_hash(hash: &str) -> String { hash.char_indices() .nth(12) .map(|(index, _)| &hash[..index]) .unwrap_or(hash) .to_string() } /// One sample of the running curve. struct Sample { seconds: f32, multiplier: f64, } #[derive(Default)] pub struct Fx { points: Vec, started: Option, status: String, peak: f64, last_crash: Option, } impl Fx { pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &CrashView) { let now = ctx.input(|input| input.time); if view.status == "starting" && self.status != "starting" { self.points.clear(); self.started = None; self.peak = 1.0; } if view.status == "running" { let started = *self.started.get_or_insert(now); let seconds = (now - started) as f32; let last = self.points.last().map(|sample| sample.seconds); if last.is_none_or(|last| seconds - last >= 0.016) { self.points.push(Sample { seconds, multiplier: view.multiplier, }); self.peak = self.peak.max(view.multiplier); } ctx.request_repaint_after(std::time::Duration::from_millis(16)); } else if view.status == "starting" { ctx.request_repaint_after(std::time::Duration::from_millis(50)); } if view.status == "crashed" { if let Some(point) = view.crash_point { self.last_crash = Some(point); } } if view.status != self.status { self.status = view.status.clone(); } } fn reset(&mut self) { self.points.clear(); self.started = None; self.peak = 1.0; } } pub fn show(ui: &mut Ui, view: &CrashView, fx: &mut Fx) -> Option { let mut command = None; eframe::egui::CentralPanel::default() .frame(eframe::egui::Frame::new().fill(theme::BG)) .show(ui, |ui| { let full = ui.max_rect(); let sidebar_rect = eframe::egui::Rect::from_min_max( eframe::egui::pos2(full.right() - 336.0, full.top()), full.right_bottom(), ); let board_rect = eframe::egui::Rect::from_min_max( full.min, eframe::egui::pos2(sidebar_rect.left(), full.bottom()), ); ui.painter().rect_filled( sidebar_rect, eframe::egui::CornerRadius::ZERO, theme::SURFACE, ); ui.scope_builder( eframe::egui::UiBuilder::new() .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0))) .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), |ui| chart(ui, view, fx), ); ui.scope_builder( eframe::egui::UiBuilder::new() .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0))) .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), |ui| { command = sidebar(ui, view, fx); }, ); }); command } fn sidebar(ui: &mut Ui, view: &CrashView, fx: &mut Fx) -> Option { let mut command = None; let running = view.status == "running"; let starting = view.status == "starting"; let (content, actions) = widgets::sidebar_layout(ui, 130.0); ui.scope_builder( eframe::egui::UiBuilder::new() .max_rect(content) .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), |ui| { eframe::egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { ui.label( eframe::egui::RichText::new("Crash") .font(theme::sans(22.0)) .color(theme::TEXT), ); widgets::note(ui, "Cash out before the multiplier crashes."); ui.add_space(theme::SPACE_5); ui.label(theme::label("Bet")); ui.add_space(theme::SPACE_2); if let Some(delta) = widgets::bet_control(ui, view.bet, !running && !starting) { command = Some(Command::AdjustBet(delta)); } ui.add_space(theme::SPACE_4); widgets::row( ui, "Potential", &theme::money(view.potential as i64), theme::GOLD, ); if let Some(commit) = &view.commit { widgets::row(ui, "Commit", &short_hash(commit), theme::TEXT_FAINT); } if let Some(seed) = &view.seed { let commit_ok = view .commit .as_deref() .map(|commit| verify_seed(seed, commit)) .unwrap_or(false); let point_ok = seed_from_hex(seed) .map(|bytes| { view.crash_point .map(|point| { (crash_point_from_seed(&bytes) - point).abs() < 1e-9 }) .unwrap_or(false) }) .unwrap_or(false); if commit_ok && point_ok { widgets::row(ui, "Seed ✓", &short_hash(seed), theme::WIN); } else { widgets::row(ui, "Seed ✗", &short_hash(seed), theme::LOSS); } } ui.add_space(theme::SPACE_3); status(ui, view); if let Some(point) = fx.last_crash { ui.add_space(theme::SPACE_2); widgets::row(ui, "Last crash", &format!("{point:.2}x"), theme::TEXT_DIM); } if let Some(error) = &view.error { ui.add_space(theme::SPACE_3); widgets::error(ui, error); } }); }, ); ui.scope_builder( eframe::egui::UiBuilder::new() .max_rect(actions) .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)), |ui| { if widgets::ghost( ui, if running { "Forfeit round" } else { "Leave table" }, ) { command = Some(Command::Leave); } ui.add_space(theme::SPACE_2); if running { if widgets::primary(ui, "CASH OUT", true, ui.available_width(), 46.0) { command = Some(Command::CashOut); } } else if !starting && widgets::primary(ui, "START", true, ui.available_width(), 46.0) { fx.reset(); command = Some(Command::Start); } }, ); command } fn status(ui: &mut Ui, view: &CrashView) { match view.status.as_str() { "starting" => widgets::note(ui, "Round starting…"), "running" => widgets::note(ui, "Rising. Cash out before it crashes."), "cashed" => { ui.label( eframe::egui::RichText::new(format!( "CASHED OUT {:.2}x +{}", view.multiplier, theme::money(view.payout as i64) )) .font(theme::mono(14.0)) .color(theme::WIN), ); } "crashed" => { ui.label( eframe::egui::RichText::new(format!( "CRASHED at {:.2}x", view.crash_point.unwrap_or(view.multiplier) )) .font(theme::mono(14.0)) .color(theme::LOSS), ); } "forfeit" => { ui.label( eframe::egui::RichText::new("FORFEITED") .font(theme::mono(14.0)) .color(theme::TEXT_DIM), ); } _ => widgets::note(ui, "Press START to play."), } } fn chart(ui: &mut Ui, view: &CrashView, fx: &Fx) { ui.add_space(theme::SPACE_2); let available = ui.available_size(); let size = vec2(available.x, (available.y - 70.0).max(260.0)); let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); let painter = ui.painter(); painter.rect_filled(rect, theme::RADIUS_LG, theme::SURFACE); painter.rect_stroke( rect, theme::RADIUS_LG, Stroke::new(1.0, theme::LINE), StrokeKind::Inside, ); let plot = rect.shrink2(vec2(56.0, 48.0)); let max_seconds = fx .points .last() .map(|sample| sample.seconds) .unwrap_or(0.0) .max(6.0) * 1.08; let max_multiplier = fx.peak.max(2.0) * 1.15; painter.text( plot.left_top() + vec2(0.0, 2.0), Align2::LEFT_TOP, format!("{:.2}x", view.multiplier), theme::mono(46.0), curve_color(view), ); painter.text( plot.left_top() + vec2(2.0, 58.0), Align2::LEFT_TOP, status_label(view), theme::mono(10.0), theme::TEXT_FAINT, ); // Grid: whole multipliers and two-second columns. let grid = Stroke::new(1.0, theme::LINE.gamma_multiply(0.55)); let mut step = 1.0; while max_multiplier / step > 6.0 { step += 1.0; } let mut value = step; while value <= max_multiplier { let y = map_y(plot, value, max_multiplier); painter.hline(plot.x_range(), y, grid); painter.text( pos2(plot.left() - 10.0, y), Align2::RIGHT_CENTER, format!("{value:.0}x"), theme::mono(10.0), theme::TEXT_FAINT, ); value += step; } let mut second = 2.0; while f64::from(second) <= f64::from(max_seconds) { let x = plot.left() + plot.width() * (second / max_seconds); painter.vline(x, plot.y_range(), grid); painter.text( pos2(x, plot.bottom() + 8.0), Align2::CENTER_TOP, format!("{second:.0}s"), theme::mono(10.0), theme::TEXT_FAINT, ); second += 2.0; } if fx.points.is_empty() { painter.text( plot.center(), Align2::CENTER_CENTER, if view.status == "running" { "…" } else { "Press START to play" }, theme::sans(14.0), theme::TEXT_FAINT, ); } else { let points: Vec = fx .points .iter() .map(|sample| { pos2( plot.left() + plot.width() * (sample.seconds / max_seconds), map_y(plot, sample.multiplier, max_multiplier), ) }) .collect(); // A soft fill under the curve. if points.len() >= 2 { let mut fill = points.clone(); fill.push(pos2(points[points.len() - 1].x, plot.bottom())); fill.push(pos2(points[0].x, plot.bottom())); painter.add(Shape::convex_polygon( fill, curve_color(view).gamma_multiply(0.10), Stroke::NONE, )); } painter.add(Shape::line(points, Stroke::new(2.5, curve_color(view)))); if view.status == "crashed" { if let Some(point) = view.crash_point { let y = map_y(plot, point, max_multiplier); let x = plot.left() + plot.width() * (fx.points.last().map_or(0.0, |s| s.seconds) / max_seconds); painter.circle_filled(pos2(x, y), 5.0, theme::LOSS); painter.text( pos2(x + 10.0, y - 8.0), Align2::LEFT_BOTTOM, format!("{point:.2}x"), theme::mono(12.0), theme::LOSS, ); } } } } fn map_y(plot: Rect, multiplier: f64, max_multiplier: f64) -> f32 { let range = (max_multiplier - 1.0).max(0.1); plot.bottom() - ((multiplier - 1.0) / range) as f32 * plot.height() } fn status_label(view: &CrashView) -> &'static str { match view.status.as_str() { "starting" => "STARTING", "running" => "LIVE", "cashed" => "CASHED OUT", "crashed" => "CRASHED", "forfeit" => "FORFEITED", _ => "READY", } } fn curve_color(view: &CrashView) -> Color32 { match view.status.as_str() { "running" => theme::WIN, "cashed" => theme::GOLD, "crashed" => theme::LOSS, _ => theme::TEXT_DIM, } }