summaryrefslogtreecommitdiffstats
path: root/gui/src/crash.rs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--gui/src/crash.rs416
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
3use eframe::egui::{Align2, Color32, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
4use zagan_bet::client::view::{Command, CrashView};
5
6use crate::{theme, widgets};
7
8/// One sample of the running curve.
9struct Sample {
10 seconds: f32,
11 multiplier: f64,
12}
13
14#[derive(Default)]
15pub struct Fx {
16 points: Vec<Sample>,
17 started: Option<f64>,
18 status: String,
19 peak: f64,
20 last_crash: Option<f64>,
21}
22
23impl 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
66pub 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
103fn 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
197fn 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
233fn 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
393fn 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
398fn 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
409fn 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}