summaryrefslogtreecommitdiffstats
path: root/gui/src/slots.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/slots.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 '')
-rw-r--r--gui/src/slots.rs447
1 files changed, 447 insertions, 0 deletions
diff --git a/gui/src/slots.rs b/gui/src/slots.rs
new file mode 100644
index 0000000..6574626
--- /dev/null
+++ b/gui/src/slots.rs
@@ -0,0 +1,447 @@
1//! Slots screen: a 5x3 board painted from `SlotsView`.
2
3use eframe::egui::{
4 Align2, Color32, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, vec2,
5};
6use zagan_bet::client::view::{Command, SlotsPhase, SlotsView};
7use zagan_bet::slots::{LINE_NAMES, PAYLINES, REELS, ROWS, SYMBOLS};
8
9use crate::{theme, widgets};
10
11/// Reel animation. The server answers at once, so the UI spins the reels for
12/// this long before revealing the result it already has.
13const SPIN_SECONDS: f64 = 0.9;
14
15#[derive(Default)]
16pub struct Fx {
17 started: Option<f64>,
18}
19
20impl Fx {
21 pub fn spin_requested(&mut self, ctx: &eframe::egui::Context) {
22 self.started = Some(ctx.input(|input| input.time));
23 }
24
25 fn spinning(&self, view: &SlotsView, now: f64) -> bool {
26 // A rejected spin (no balance, server error) never reaches the reels.
27 if view.error.is_some() {
28 return false;
29 }
30 match self.started {
31 Some(started) => now - started < SPIN_SECONDS || view.phase == SlotsPhase::Spinning,
32 None => view.phase == SlotsPhase::Spinning,
33 }
34 }
35
36 /// True once the reels have stopped on the server's answer.
37 pub fn revealed(&self, ctx: &eframe::egui::Context, view: &SlotsView) -> bool {
38 let now = ctx.input(|input| input.time);
39 !self.spinning(view, now)
40 }
41
42 pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &SlotsView) {
43 if view.error.is_some() {
44 self.started = None;
45 return;
46 }
47 let now = ctx.input(|input| input.time);
48 if self.spinning(view, now) {
49 ctx.request_repaint();
50 } else {
51 self.started = None;
52 }
53 }
54}
55
56pub fn show(ui: &mut Ui, view: &SlotsView, fx: &mut Fx) -> Option<Command> {
57 let mut command = None;
58 eframe::egui::CentralPanel::default()
59 .frame(eframe::egui::Frame::new().fill(theme::BG))
60 .show(ui, |ui| {
61 let full = ui.max_rect();
62 let sidebar_rect = eframe::egui::Rect::from_min_max(
63 eframe::egui::pos2(full.right() - 336.0, full.top()),
64 full.right_bottom(),
65 );
66 let board_rect = eframe::egui::Rect::from_min_max(
67 full.min,
68 eframe::egui::pos2(sidebar_rect.left(), full.bottom()),
69 );
70 ui.painter().rect_filled(
71 sidebar_rect,
72 eframe::egui::CornerRadius::ZERO,
73 theme::SURFACE,
74 );
75 ui.scope_builder(
76 eframe::egui::UiBuilder::new()
77 .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0)))
78 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
79 |ui| board(ui, view, fx),
80 );
81 ui.scope_builder(
82 eframe::egui::UiBuilder::new()
83 .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0)))
84 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
85 |ui| {
86 command = sidebar(ui, view, fx);
87 },
88 );
89 });
90 command
91}
92
93fn sidebar(ui: &mut Ui, view: &SlotsView, fx: &mut Fx) -> Option<Command> {
94 let now = ui.input(|input| input.time);
95 let spinning = fx.spinning(view, now);
96 let mut command = None;
97 let (content, actions) = widgets::sidebar_layout(ui, 150.0);
98
99 ui.scope_builder(
100 eframe::egui::UiBuilder::new()
101 .max_rect(content)
102 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
103 |ui| {
104 eframe::egui::ScrollArea::vertical()
105 .auto_shrink([false, false])
106 .show(ui, |ui| {
107 ui.label(
108 eframe::egui::RichText::new("Slots")
109 .font(theme::sans(22.0))
110 .color(theme::TEXT),
111 );
112 ui.label(
113 eframe::egui::RichText::new("5 reels · 5 paylines")
114 .font(theme::sans(12.0))
115 .color(theme::TEXT_DIM),
116 );
117
118 ui.add_space(theme::SPACE_5);
119 ui.label(theme::label("Bet"));
120 ui.add_space(theme::SPACE_2);
121 if let Some(delta) = widgets::bet_control(ui, view.bet, !spinning) {
122 command = Some(Command::AdjustBet(delta));
123 }
124
125 ui.add_space(theme::SPACE_4);
126 status(ui, view, spinning);
127 if let Some(error) = &view.error {
128 ui.add_space(theme::SPACE_3);
129 widgets::error(ui, error);
130 }
131
132 ui.add_space(theme::SPACE_4);
133 ui.separator();
134 ui.add_space(theme::SPACE_3);
135 ui.label(theme::label("Paytable"));
136 ui.add_space(theme::SPACE_2);
137 ui.label(
138 eframe::egui::RichText::new("Pays bet × symbol × shape")
139 .font(theme::sans(11.0))
140 .color(theme::TEXT_FAINT),
141 );
142 ui.horizontal(|ui| {
143 ui.spacing_mut().item_spacing.x = theme::SPACE_2;
144 for (symbol, multiplier) in zagan_bet::slots::SYMBOL_MULTIPLIERS {
145 ui.label(
146 eframe::egui::RichText::new(format!("{symbol}{multiplier}"))
147 .font(theme::mono(12.0))
148 .color(symbol_color(symbol)),
149 );
150 }
151 });
152 ui.label(
153 eframe::egui::RichText::new("3×1 4×3 5×14")
154 .font(theme::mono(12.0))
155 .color(theme::TEXT_DIM),
156 );
157 });
158 },
159 );
160
161 ui.scope_builder(
162 eframe::egui::UiBuilder::new()
163 .max_rect(actions)
164 .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)),
165 |ui| {
166 if widgets::ghost(ui, "Leave machine") {
167 command = Some(Command::Leave);
168 }
169 ui.add_space(theme::SPACE_2);
170 if widgets::primary(ui, "SPIN", !spinning, ui.available_width(), 46.0) {
171 fx.spin_requested(ui.ctx());
172 command = Some(Command::Spin);
173 }
174 },
175 );
176
177 command
178}
179
180fn status(ui: &mut Ui, view: &SlotsView, spinning: bool) {
181 if spinning {
182 ui.label(
183 eframe::egui::RichText::new("SPINNING…")
184 .font(theme::mono(12.0))
185 .color(theme::GOLD),
186 );
187 return;
188 }
189
190 match view.phase {
191 SlotsPhase::Result => {
192 let winnings = view.winnings.unwrap_or(0);
193 if winnings == 0 {
194 ui.label(
195 eframe::egui::RichText::new("NO WIN")
196 .font(theme::mono(13.0))
197 .color(theme::TEXT_FAINT),
198 );
199 } else {
200 let label = match view.win_type.as_str() {
201 "mega" => "MEGA WIN",
202 "big" => "BIG WIN",
203 _ => "WON",
204 };
205 ui.horizontal(|ui| {
206 ui.label(theme::label(label));
207 ui.label(
208 eframe::egui::RichText::new(theme::money(winnings as i64))
209 .font(theme::mono(18.0))
210 .color(theme::WIN),
211 );
212 });
213 if !view.win_lines.is_empty() {
214 let names: Vec<String> = view
215 .win_lines
216 .iter()
217 .map(|line| {
218 line.checked_sub(1)
219 .and_then(|index| LINE_NAMES.get(index).copied())
220 .map(|name| format!("{line} ({name})"))
221 .unwrap_or_else(|| line.to_string())
222 })
223 .collect();
224 ui.label(
225 eframe::egui::RichText::new(names.join(" · "))
226 .font(theme::sans(11.0))
227 .color(theme::TEXT_DIM),
228 );
229 }
230 }
231 }
232 _ => {
233 ui.label(
234 eframe::egui::RichText::new("Press SPIN to play")
235 .font(theme::sans(12.0))
236 .color(theme::TEXT_DIM),
237 );
238 }
239 }
240}
241
242fn board(ui: &mut Ui, view: &SlotsView, fx: &Fx) {
243 ui.horizontal(|ui| {
244 ui.label(
245 eframe::egui::RichText::new("Machine 01")
246 .font(theme::sans(15.0))
247 .color(theme::TEXT),
248 );
249 ui.label(
250 eframe::egui::RichText::new("5 reels · 5 paylines")
251 .font(theme::sans(12.0))
252 .color(theme::TEXT_DIM),
253 );
254 ui.with_layout(
255 eframe::egui::Layout::right_to_left(eframe::egui::Align::Center),
256 |ui| {
257 ui.label(
258 eframe::egui::RichText::new("Bet 10 – 500 Kr")
259 .font(theme::mono(12.0))
260 .color(theme::TEXT_FAINT),
261 );
262 },
263 );
264 });
265 ui.add_space(theme::SPACE_5);
266
267 let now = ui.input(|input| input.time);
268 let spinning = fx.spinning(view, now);
269 let available = ui.available_size();
270 let gap = theme::SPACE_2;
271 let tile_width = ((available.x - gap * (REELS as f32 - 1.0)) / REELS as f32)
272 .min((available.y - 150.0) / 3.0)
273 .min(190.0);
274 let tile = vec2(tile_width, tile_width * 0.92);
275 let board_width = tile.x * REELS as f32 + gap * (REELS as f32 - 1.0);
276 let board_height = tile.y * 3.0 + gap * 2.0;
277 let left = ((available.x - board_width) * 0.5).max(0.0);
278 let top = ((available.y - board_height) * 0.35).max(0.0);
279
280 ui.add_space(top);
281 let origin = ui
282 .horizontal(|ui| {
283 ui.add_space(left);
284 ui.allocate_exact_size(vec2(board_width, board_height), Sense::hover())
285 .0
286 .min
287 })
288 .inner;
289
290 let painter = ui.painter();
291 let cycling = (now * 18.0) as usize;
292 for col in 0..REELS {
293 for row in 0..ROWS {
294 let rect = Rect::from_min_size(
295 origin + vec2(col as f32 * (tile.x + gap), row as f32 * (tile.y + gap)),
296 tile,
297 );
298 let index = col * ROWS + row;
299 let stocked =
300 view.reels.len() == REELS * ROWS || (view.reels.len() == REELS && row == 0);
301 let symbol = if spinning {
302 SYMBOLS[(cycling + index * 3) % SYMBOLS.len()]
303 } else if stocked {
304 if view.reels.len() == REELS * ROWS {
305 view.reels[index]
306 } else {
307 view.reels[col]
308 }
309 } else {
310 SYMBOLS[(index * 5 + col) % SYMBOLS.len()]
311 };
312 let winning = !spinning && view.win_cells.contains(&index);
313 paint_tile(painter, rect, symbol, spinning, winning, !stocked);
314 }
315 }
316
317 if !spinning && view.phase == SlotsPhase::Result {
318 paint_win_lines(painter, origin, tile, gap, view);
319 }
320
321 ui.add_space(theme::SPACE_5);
322 ui.horizontal(|ui| {
323 ui.label(theme::label("Result"));
324 if spinning {
325 ui.label(
326 eframe::egui::RichText::new("Spinning…")
327 .font(theme::mono(12.0))
328 .color(theme::GOLD),
329 );
330 } else if view.phase == SlotsPhase::Result {
331 let winnings = view.winnings.unwrap_or(0);
332 if winnings == 0 {
333 ui.label(
334 eframe::egui::RichText::new("No win")
335 .font(theme::mono(12.0))
336 .color(theme::TEXT_DIM),
337 );
338 } else {
339 let label = match view.win_type.as_str() {
340 "mega" => "MEGA WIN",
341 "big" => "BIG WIN",
342 _ => "WIN",
343 };
344 ui.label(
345 eframe::egui::RichText::new(format!(
346 "{label} +{}",
347 theme::money(winnings as i64)
348 ))
349 .font(theme::mono(12.0))
350 .color(theme::WIN),
351 );
352 }
353 } else {
354 ui.label(
355 eframe::egui::RichText::new("Ready")
356 .font(theme::mono(12.0))
357 .color(theme::TEXT_DIM),
358 );
359 }
360 });
361}
362
363fn paint_tile(
364 painter: &Painter,
365 rect: Rect,
366 symbol: char,
367 spinning: bool,
368 winning: bool,
369 placeholder: bool,
370) {
371 let fill = if winning {
372 theme::GOLD.gamma_multiply(0.14)
373 } else {
374 theme::SURFACE
375 };
376 painter.rect_filled(rect, theme::RADIUS_MD, fill);
377 let stroke = if winning {
378 Stroke::new(2.0, theme::GOLD)
379 } else {
380 Stroke::new(1.0, theme::LINE)
381 };
382 painter.rect_stroke(rect, theme::RADIUS_MD, stroke, StrokeKind::Inside);
383
384 let color = if spinning || placeholder {
385 symbol_color(symbol).gamma_multiply(0.35)
386 } else {
387 symbol_color(symbol)
388 };
389 painter.text(
390 rect.center(),
391 Align2::CENTER_CENTER,
392 symbol,
393 theme::mono(rect.width() * 0.42),
394 color,
395 );
396}
397
398fn paint_win_lines(painter: &Painter, origin: Pos2, tile: Vec2, gap: f32, view: &SlotsView) {
399 for line in &view.win_lines {
400 let columns = winning_columns(view, *line);
401 if columns.len() < 2 {
402 continue;
403 }
404 let points: Vec<Pos2> = columns
405 .iter()
406 .map(|col| {
407 let row = PAYLINES[*line - 1][*col];
408 Rect::from_min_size(
409 origin + vec2(*col as f32 * (tile.x + gap), row as f32 * (tile.y + gap)),
410 tile,
411 )
412 .center()
413 })
414 .collect();
415 painter.add(eframe::egui::Shape::line(
416 points,
417 Stroke::new(2.0, theme::GOLD.gamma_multiply(0.8)),
418 ));
419 }
420}
421
422/// The reels that actually matched on a payline.
423///
424/// A win is a prefix of matching symbols from reel 1, so the line must stop
425/// at the last winning cell instead of crossing all five reels.
426pub fn winning_columns(view: &SlotsView, line: usize) -> Vec<usize> {
427 let Some(rows) = line.checked_sub(1).and_then(|index| PAYLINES.get(index)) else {
428 return Vec::new();
429 };
430 rows.iter()
431 .enumerate()
432 .filter(|(col, row)| view.win_cells.contains(&(col * ROWS + *row)))
433 .map(|(col, _)| col)
434 .collect()
435}
436
437pub fn symbol_color(symbol: char) -> Color32 {
438 match symbol {
439 'C' => Color32::from_rgb(0xe0, 0x5a, 0x5a),
440 'L' => Color32::from_rgb(0xe0, 0xb6, 0x4a),
441 'O' => Color32::from_rgb(0xe0, 0x8a, 0x3c),
442 'G' => Color32::from_rgb(0xb5, 0x7e, 0xdc),
443 'D' => Color32::from_rgb(0x4f, 0xb8, 0xc9),
444 'S' => Color32::from_rgb(0x4e, 0xc2, 0x7b),
445 _ => theme::TEXT,
446 }
447}