//! Slots screen: a 5x3 board painted from `SlotsView`. use eframe::egui::{ Align2, Color32, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, vec2, }; use zagan_bet::client::view::{Command, SlotsPhase, SlotsView}; use zagan_bet::slots::{LINE_NAMES, PAYLINES, REELS, ROWS, SYMBOLS}; use crate::{theme, widgets}; /// Reel animation. The server answers at once, so the UI spins the reels for /// this long before revealing the result it already has. const SPIN_SECONDS: f64 = 0.9; #[derive(Default)] pub struct Fx { started: Option, } impl Fx { pub fn spin_requested(&mut self, ctx: &eframe::egui::Context) { self.started = Some(ctx.input(|input| input.time)); } fn spinning(&self, view: &SlotsView, now: f64) -> bool { // A rejected spin (no balance, server error) never reaches the reels. if view.error.is_some() { return false; } match self.started { Some(started) => now - started < SPIN_SECONDS || view.phase == SlotsPhase::Spinning, None => view.phase == SlotsPhase::Spinning, } } /// True once the reels have stopped on the server's answer. pub fn revealed(&self, ctx: &eframe::egui::Context, view: &SlotsView) -> bool { let now = ctx.input(|input| input.time); !self.spinning(view, now) } pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &SlotsView) { if view.error.is_some() { self.started = None; return; } let now = ctx.input(|input| input.time); if self.spinning(view, now) { ctx.request_repaint(); } else { self.started = None; } } } pub fn show(ui: &mut Ui, view: &SlotsView, fx: &mut Fx) -> Option { let mut command = None; eframe::egui::CentralPanel::default() .frame(eframe::egui::Frame::new().fill(theme::BG)) .show(ui, |ui| { let full = ui.max_rect(); let sidebar_rect = eframe::egui::Rect::from_min_max( eframe::egui::pos2(full.right() - 336.0, full.top()), full.right_bottom(), ); let board_rect = eframe::egui::Rect::from_min_max( full.min, eframe::egui::pos2(sidebar_rect.left(), full.bottom()), ); ui.painter().rect_filled( sidebar_rect, eframe::egui::CornerRadius::ZERO, theme::SURFACE, ); ui.scope_builder( eframe::egui::UiBuilder::new() .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0))) .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), |ui| board(ui, view, fx), ); ui.scope_builder( eframe::egui::UiBuilder::new() .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0))) .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), |ui| { command = sidebar(ui, view, fx); }, ); }); command } fn sidebar(ui: &mut Ui, view: &SlotsView, fx: &mut Fx) -> Option { let now = ui.input(|input| input.time); let spinning = fx.spinning(view, now); let mut command = None; let (content, actions) = widgets::sidebar_layout(ui, 150.0); ui.scope_builder( eframe::egui::UiBuilder::new() .max_rect(content) .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)), |ui| { eframe::egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { ui.label( eframe::egui::RichText::new("Slots") .font(theme::sans(22.0)) .color(theme::TEXT), ); widgets::note(ui, "Match three or more symbols from the left."); ui.add_space(theme::SPACE_5); ui.label(theme::label("Bet")); ui.add_space(theme::SPACE_2); if let Some(delta) = widgets::bet_control(ui, view.bet, !spinning) { command = Some(Command::AdjustBet(delta)); } ui.add_space(theme::SPACE_4); status(ui, view, spinning); if let Some(error) = &view.error { ui.add_space(theme::SPACE_3); widgets::error(ui, error); } }); }, ); ui.scope_builder( eframe::egui::UiBuilder::new() .max_rect(actions) .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)), |ui| { if widgets::ghost(ui, "Leave machine") { command = Some(Command::Leave); } ui.add_space(theme::SPACE_2); if widgets::primary(ui, "SPIN", !spinning, ui.available_width(), 46.0) { fx.spin_requested(ui.ctx()); command = Some(Command::Spin); } }, ); command } fn status(ui: &mut Ui, view: &SlotsView, spinning: bool) { if spinning { ui.label( eframe::egui::RichText::new("SPINNING…") .font(theme::mono(12.0)) .color(theme::GOLD), ); return; } match view.phase { SlotsPhase::Result => { let winnings = view.winnings.unwrap_or(0); if winnings == 0 { ui.label( eframe::egui::RichText::new("NO WIN") .font(theme::mono(13.0)) .color(theme::TEXT_FAINT), ); } else { let label = match view.win_type.as_str() { "mega" => "MEGA WIN", "big" => "BIG WIN", _ => "WON", }; ui.horizontal(|ui| { ui.label(theme::label(label)); ui.label( eframe::egui::RichText::new(theme::money(winnings as i64)) .font(theme::mono(18.0)) .color(theme::WIN), ); }); if !view.win_lines.is_empty() { let names: Vec = view .win_lines .iter() .map(|line| { line.checked_sub(1) .and_then(|index| LINE_NAMES.get(index).copied()) .map(|name| format!("{line} ({name})")) .unwrap_or_else(|| line.to_string()) }) .collect(); ui.label( eframe::egui::RichText::new(names.join(" · ")) .font(theme::sans(11.0)) .color(theme::TEXT_DIM), ); } } } _ => { ui.label( eframe::egui::RichText::new("Press SPIN to play") .font(theme::sans(12.0)) .color(theme::TEXT_DIM), ); } } } fn board(ui: &mut Ui, view: &SlotsView, fx: &Fx) { ui.add_space(theme::SPACE_2); let now = ui.input(|input| input.time); let spinning = fx.spinning(view, now); let available = ui.available_size(); let gap = theme::SPACE_2; let tile_width = ((available.x - gap * (REELS as f32 - 1.0)) / REELS as f32) .min((available.y - 150.0) / 3.0) .min(190.0); let tile = vec2(tile_width, tile_width * 0.92); let board_width = tile.x * REELS as f32 + gap * (REELS as f32 - 1.0); let board_height = tile.y * 3.0 + gap * 2.0; let left = ((available.x - board_width) * 0.5).max(0.0); let top = ((available.y - board_height) * 0.35).max(0.0); ui.add_space(top); let origin = ui .horizontal(|ui| { ui.add_space(left); ui.allocate_exact_size(vec2(board_width, board_height), Sense::hover()) .0 .min }) .inner; let painter = ui.painter(); let cycling = (now * 18.0) as usize; for col in 0..REELS { for row in 0..ROWS { let rect = Rect::from_min_size( origin + vec2(col as f32 * (tile.x + gap), row as f32 * (tile.y + gap)), tile, ); let index = col * ROWS + row; let stocked = view.reels.len() == REELS * ROWS || (view.reels.len() == REELS && row == 0); let symbol = if spinning { SYMBOLS[(cycling + index * 3) % SYMBOLS.len()] } else if stocked { if view.reels.len() == REELS * ROWS { view.reels[index] } else { view.reels[col] } } else { SYMBOLS[(index * 5 + col) % SYMBOLS.len()] }; let winning = !spinning && view.win_cells.contains(&index); paint_tile(painter, rect, symbol, spinning, winning, !stocked); } } if !spinning && view.phase == SlotsPhase::Result { paint_win_lines(painter, origin, tile, gap, view); } ui.add_space(theme::SPACE_5); ui.horizontal(|ui| { ui.label(theme::label("Result")); if spinning { ui.label( eframe::egui::RichText::new("Spinning…") .font(theme::mono(12.0)) .color(theme::GOLD), ); } else if view.phase == SlotsPhase::Result { let winnings = view.winnings.unwrap_or(0); if winnings == 0 { ui.label( eframe::egui::RichText::new("No win") .font(theme::mono(12.0)) .color(theme::TEXT_DIM), ); } else { let label = match view.win_type.as_str() { "mega" => "MEGA WIN", "big" => "BIG WIN", _ => "WIN", }; ui.label( eframe::egui::RichText::new(format!( "{label} +{}", theme::money(winnings as i64) )) .font(theme::mono(12.0)) .color(theme::WIN), ); } } else { ui.label( eframe::egui::RichText::new("Ready") .font(theme::mono(12.0)) .color(theme::TEXT_DIM), ); } }); } fn paint_tile( painter: &Painter, rect: Rect, symbol: char, spinning: bool, winning: bool, placeholder: bool, ) { let fill = if winning { theme::GOLD.gamma_multiply(0.14) } else { theme::SURFACE }; painter.rect_filled(rect, theme::RADIUS_MD, fill); let stroke = if winning { Stroke::new(2.0, theme::GOLD) } else { Stroke::new(1.0, theme::LINE) }; painter.rect_stroke(rect, theme::RADIUS_MD, stroke, StrokeKind::Inside); let color = if spinning || placeholder { symbol_color(symbol).gamma_multiply(0.35) } else { symbol_color(symbol) }; painter.text( rect.center(), Align2::CENTER_CENTER, symbol, theme::mono(rect.width() * 0.42), color, ); } fn paint_win_lines(painter: &Painter, origin: Pos2, tile: Vec2, gap: f32, view: &SlotsView) { for line in &view.win_lines { let columns = winning_columns(view, *line); if columns.len() < 2 { continue; } let points: Vec = columns .iter() .map(|col| { let row = PAYLINES[*line - 1][*col]; Rect::from_min_size( origin + vec2(*col as f32 * (tile.x + gap), row as f32 * (tile.y + gap)), tile, ) .center() }) .collect(); painter.add(eframe::egui::Shape::line( points, Stroke::new(2.0, theme::GOLD.gamma_multiply(0.8)), )); } } /// The reels that actually matched on a payline. /// /// A win is a prefix of matching symbols from reel 1, so the line must stop /// at the last winning cell instead of crossing all five reels. pub fn winning_columns(view: &SlotsView, line: usize) -> Vec { let Some(rows) = line.checked_sub(1).and_then(|index| PAYLINES.get(index)) else { return Vec::new(); }; rows.iter() .enumerate() .filter(|(col, row)| view.win_cells.contains(&(col * ROWS + *row))) .map(|(col, _)| col) .collect() } pub fn symbol_color(symbol: char) -> Color32 { match symbol { 'C' => Color32::from_rgb(0xe0, 0x5a, 0x5a), 'L' => Color32::from_rgb(0xe0, 0xb6, 0x4a), 'O' => Color32::from_rgb(0xe0, 0x8a, 0x3c), 'G' => Color32::from_rgb(0xb5, 0x7e, 0xdc), 'D' => Color32::from_rgb(0x4f, 0xb8, 0xc9), 'S' => Color32::from_rgb(0x4e, 0xc2, 0x7b), _ => theme::TEXT, } }