summaryrefslogtreecommitdiffstats
path: root/gui/src/crash.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 21:09:56 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 21:09:56 +0100
commitbf4cf78ab26896faf457273e37d30fc96381c4a4 (patch)
tree71d67c300b6be191016a988ed5338e15c8b1bdd3 /gui/src/crash.rs
parentv4.1.1: pointer-first betting and visible payouts in the GUI (diff)
downloadzagan-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.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}