diff options
Diffstat (limited to 'gui/src/slots.rs')
| -rw-r--r-- | gui/src/slots.rs | 447 |
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 | |||
| 3 | use eframe::egui::{ | ||
| 4 | Align2, Color32, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, vec2, | ||
| 5 | }; | ||
| 6 | use zagan_bet::client::view::{Command, SlotsPhase, SlotsView}; | ||
| 7 | use zagan_bet::slots::{LINE_NAMES, PAYLINES, REELS, ROWS, SYMBOLS}; | ||
| 8 | |||
| 9 | use 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. | ||
| 13 | const SPIN_SECONDS: f64 = 0.9; | ||
| 14 | |||
| 15 | #[derive(Default)] | ||
| 16 | pub struct Fx { | ||
| 17 | started: Option<f64>, | ||
| 18 | } | ||
| 19 | |||
| 20 | impl 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 | |||
| 56 | pub 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 | |||
| 93 | fn 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 | |||
| 180 | fn 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 | |||
| 242 | fn 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 | |||
| 363 | fn 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 | |||
| 398 | fn 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. | ||
| 426 | pub 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 | |||
| 437 | pub 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 | } | ||
