summaryrefslogtreecommitdiffstats
path: root/gui/src/slots.rs
diff options
context:
space:
mode:
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}