//! The roulette table and wheel, painted from `RouletteView`. //! //! Geometry is the GUI's business: the core only says which number or region a //! chip or the cursor is on. Everything here is derived from that, so the two //! frontends can lay the same table out differently. use std::f32::consts::{FRAC_PI_2, TAU}; use eframe::egui::{ Align2, Color32, CornerRadius, Painter, PointerButton, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, pos2, vec2, }; use zagan_bet::client::games::roulette::target_at as lattice_target; use zagan_bet::client::view::{RoulettePhase, RouletteView}; use zagan_bet::roulette::{BetTarget, Outside, WHEEL, color as wheel_color, is_red}; use crate::theme; const COLUMNS: usize = 12; const ROWS: usize = 3; const OUTSIDE_ORDER: [Outside; 6] = [ Outside::Low, Outside::Even, Outside::Red, Outside::Black, Outside::Odd, Outside::High, ]; const DOZEN_ROW: f32 = 0.72; const COLUMN_ROW: f32 = 0.55; const OUTSIDE_ROW: f32 = 0.62; const HEIGHT_UNITS: f32 = ROWS as f32 + DOZEN_ROW + COLUMN_ROW + OUTSIDE_ROW; const BOARD_PAD: f32 = 6.0; /// Largest table that fits in `available`, including its felt border. pub fn size_for(available: Vec2) -> Vec2 { let inner = (available - Vec2::splat(BOARD_PAD * 2.0)).max(Vec2::ZERO); let cell = (inner.x / (COLUMNS as f32 + 1.0)) .min(inner.y / HEIGHT_UNITS) .max(1.0); vec2((COLUMNS as f32 + 1.0) * cell, HEIGHT_UNITS * cell) + Vec2::splat(BOARD_PAD * 2.0) } /// What the pointer did to the felt this frame. #[derive(Debug, Clone, PartialEq, Eq)] pub enum FeltAction { /// Left click: add the selected chip. Place(BetTarget), /// Right or middle click: take one chip back. Reduce(BetTarget), } /// The table painted at `size`; returns the bet the pointer changed. pub fn felt(ui: &mut Ui, size: Vec2, view: &RouletteView) -> Option { let (rect, response) = ui.allocate_exact_size(size, Sense::click()); let felt = Felt::new(rect); let hovered = if view.phase == RoulettePhase::Spinning { None } else { response.hover_pos().and_then(|pos| felt.target_at(pos)) }; let painter = ui.painter(); painter.rect_filled(rect, theme::RADIUS_LG, theme::FELT_DARK); // Every cell the hovered bet covers tints, so a split or corner reads // before the chip lands. let covered = hovered.as_ref().map(BetTarget::numbers).unwrap_or_default(); for number in 0..=36u8 { felt.paint_number( painter, number, covered.contains(&number), hovered == Some(BetTarget::Straight { number }), ); } for index in 0..3 { felt.paint_dozen( painter, index, hovered == Some(BetTarget::Dozen { index: index as u8 }), ); } for index in 0..3 { felt.paint_column( painter, index, hovered == Some(BetTarget::Column { index: index as u8 }), ); } for (index, kind) in OUTSIDE_ORDER.iter().enumerate() { felt.paint_outside( painter, index, *kind, hovered == Some(BetTarget::Outside { kind: *kind }), ); } if view.phase == RoulettePhase::Result { if let Some(number) = view.number { painter.rect_stroke( felt.number_rect(number).shrink(2.0), theme::RADIUS_SM, Stroke::new(2.0, theme::GOLD), StrokeKind::Inside, ); } } for chip in &view.bets.chips { if let Some(center) = felt.anchor(&chip.target) { paint_chip(painter, center, felt.cell_size() * 0.26, chip.amount); } } if let Some(target) = &hovered { if let Some(center) = felt.anchor(target) { paint_chip_preview(painter, center, felt.cell_size() * 0.26, view.chip); } } if response.clicked_by(PointerButton::Secondary) || response.clicked_by(PointerButton::Middle) { hovered.map(FeltAction::Reduce) } else if response.clicked() { hovered.map(FeltAction::Place) } else { None } } /// Geometry of one table: the felt is 13 cells wide (zero plus twelve /// columns) and 3 + 0.72 + 0.55 + 0.62 cells tall. /// /// Public so the pointer snapping can be tested without a window. pub struct Felt { cell: f32, origin: Pos2, } impl Felt { pub fn new(board: Rect) -> Self { let inner = board.shrink(BOARD_PAD); Self { cell: inner.width() / (COLUMNS as f32 + 1.0), origin: inner.min, } } pub fn cell_size(&self) -> f32 { self.cell } pub fn number_rect(&self, number: u8) -> Rect { debug_assert!(number <= 36); if number == 0 { return Rect::from_min_size(self.origin, vec2(self.cell, self.cell * ROWS as f32)); } let column = ((number - 1) / 3) as f32; let row = (2 - (number - 1) % 3) as f32; Rect::from_min_size( self.origin + vec2(self.cell * (1.0 + column), self.cell * row), Vec2::splat(self.cell), ) } fn dozen_rect(&self, index: usize) -> Rect { let y = self.origin.y + self.cell * ROWS as f32; Rect::from_min_size( pos2(self.origin.x + self.cell * (1.0 + 4.0 * index as f32), y), vec2(self.cell * 4.0, self.cell * DOZEN_ROW), ) } fn column_rect(&self, index: usize) -> Rect { let y = self.origin.y + self.cell * (ROWS as f32 + DOZEN_ROW); Rect::from_min_size( pos2(self.origin.x + self.cell * (1.0 + 4.0 * index as f32), y), vec2(self.cell * 4.0, self.cell * COLUMN_ROW), ) } fn outside_rect(&self, index: usize) -> Rect { let y = self.origin.y + self.cell * (ROWS as f32 + DOZEN_ROW + COLUMN_ROW); Rect::from_min_size( pos2(self.origin.x + self.cell * (1.0 + 2.0 * index as f32), y), vec2(self.cell * 2.0, self.cell * OUTSIDE_ROW), ) } /// Where a chip sits for this target. pub fn anchor(&self, target: &BetTarget) -> Option { Some(match target { BetTarget::Straight { number } => self.number_rect(*number).center(), BetTarget::Split { a, b } => { let a = self.number_rect(*a).center(); let b = self.number_rect(*b).center(); pos2((a.x + b.x) * 0.5, (a.y + b.y) * 0.5) } BetTarget::Corner { numbers } => { let mut sum = Vec2::ZERO; for number in numbers { sum += self.number_rect(*number).center().to_vec2(); } (sum / numbers.len() as f32).to_pos2() } BetTarget::Dozen { index } => self.dozen_rect(*index as usize).center(), BetTarget::Column { index } => self.column_rect(*index as usize).center(), BetTarget::Outside { kind } => { let index = OUTSIDE_ORDER .iter() .position(|candidate| candidate == kind)?; self.outside_rect(index).center() } }) } fn grid_origin(&self) -> Pos2 { self.origin + vec2(self.cell, 0.0) } /// The bet under the pointer, snapped to the fine lattice: a cell centre /// is a straight, a line is a split, a crossing is a corner. pub fn target_at(&self, pos: Pos2) -> Option { if self.number_rect(0).contains(pos) { return Some(BetTarget::Straight { number: 0 }); } let grid = self.grid_origin(); let fx = (pos.x - grid.x) / self.cell; let fy = (pos.y - grid.y) / self.cell; if (0.0..COLUMNS as f32).contains(&fx) && (0.0..ROWS as f32).contains(&fy) { // Thresholds sit a quarter cell from a centre, so the inner half // of a cell is a straight and its edges are splits or corners. let x = (fx * 2.0).round().clamp(1.0, 23.0) as usize; let y = (fy * 2.0).round().clamp(1.0, 5.0) as usize; return lattice_target(x, y); } for index in 0..3usize { if self.dozen_rect(index).contains(pos) { return Some(BetTarget::Dozen { index: index as u8 }); } if self.column_rect(index).contains(pos) { return Some(BetTarget::Column { index: index as u8 }); } } for (index, kind) in OUTSIDE_ORDER.iter().enumerate() { if self.outside_rect(index).contains(pos) { return Some(BetTarget::Outside { kind: *kind }); } } None } fn paint_number(&self, painter: &Painter, number: u8, covered: bool, hovered: bool) { let rect = self.number_rect(number); let fill = if number == 0 { theme::ROULETTE_ZERO } else if is_red(number) { theme::ROULETTE_RED } else { theme::ROULETTE_BLACK }; painter.rect_filled(rect, CornerRadius::ZERO, fill); painter.rect_stroke( rect, CornerRadius::ZERO, Stroke::new(1.0, theme::FELT_LINE), StrokeKind::Inside, ); if covered { painter.rect_filled(rect, CornerRadius::ZERO, theme::ACCENT.gamma_multiply(0.16)); } else if hovered { painter.rect_filled( rect, CornerRadius::ZERO, Color32::WHITE.gamma_multiply(0.10), ); } painter.text( rect.center(), Align2::CENTER_CENTER, number, theme::mono(self.number_font()), theme::FELT_TEXT, ); } fn paint_dozen(&self, painter: &Painter, index: usize, hovered: bool) { let rect = self.dozen_rect(index); self.paint_region(painter, rect, hovered); let label = match index { 0 => "1st 12", 1 => "2nd 12", _ => "3rd 12", }; painter.text( rect.center(), Align2::CENTER_CENTER, label, theme::sans(self.label_font()), theme::FELT_TEXT, ); } fn paint_column(&self, painter: &Painter, index: usize, hovered: bool) { let rect = self.column_rect(index); self.paint_region(painter, rect, hovered); painter.text( rect.center(), Align2::CENTER_CENTER, "2:1", theme::sans(self.label_font()), theme::FELT_TEXT, ); } fn paint_outside(&self, painter: &Painter, index: usize, kind: Outside, hovered: bool) { let rect = self.outside_rect(index); self.paint_region(painter, rect, hovered); let color = match kind { Outside::Red => theme::ROULETTE_RED, Outside::Black => theme::TEXT_DIM, _ => theme::FELT_TEXT, }; painter.text( rect.center(), Align2::CENTER_CENTER, kind.label(), theme::sans(self.label_font()), color, ); } fn paint_region(&self, painter: &Painter, rect: Rect, hovered: bool) { painter.rect_filled(rect, CornerRadius::ZERO, theme::FELT); painter.rect_stroke( rect, CornerRadius::ZERO, Stroke::new(1.0, theme::FELT_LINE), StrokeKind::Inside, ); if hovered { painter.rect_filled( rect, CornerRadius::ZERO, Color32::WHITE.gamma_multiply(0.10), ); } } fn number_font(&self) -> f32 { (self.cell * 0.40).clamp(12.0, 22.0) } fn label_font(&self) -> f32 { (self.cell * 0.24).clamp(10.0, 15.0) } } /// A single chip, also used by the sidebar's chip tray. pub fn paint_chip(painter: &Painter, center: Pos2, radius: f32, amount: u64) { let fill = chip_color(amount); painter.circle_filled(center, radius, fill); painter.circle_stroke( center, radius - radius * 0.18, Stroke::new(radius * 0.10, Color32::WHITE.gamma_multiply(0.55)), ); let text_color = if amount <= 10 { theme::BG } else { Color32::WHITE }; painter.text( center, Align2::CENTER_CENTER, amount, theme::mono(radius * 0.85), text_color, ); } /// The chip under the pointer, showing what a click would add. pub fn paint_chip_preview(painter: &Painter, center: Pos2, radius: f32, amount: u64) { painter.circle_filled(center, radius, chip_color(amount).gamma_multiply(0.45)); painter.circle_stroke( center, radius, Stroke::new(2.0, Color32::WHITE.gamma_multiply(0.85)), ); painter.text( center, Align2::CENTER_CENTER, amount, theme::mono(radius * 0.85), Color32::WHITE, ); } /// Colour per denomination, as a chip tray would be stocked. pub fn chip_color(amount: u64) -> Color32 { match amount { 0..=10 => Color32::from_rgb(0xd9, 0xd3, 0xc7), 11..=25 => Color32::from_rgb(0x3f, 0x7f, 0xd1), 26..=50 => Color32::from_rgb(0xc9, 0x4b, 0x4b), _ => Color32::from_rgb(0x3a, 0x9d, 0x5d), } } fn segment_color(number: u8) -> Color32 { match wheel_color(number) { "red" => theme::ROULETTE_RED, "green" => theme::ROULETTE_ZERO, _ => Color32::from_rgb(0x2b, 0x35, 0x3f), } } /// Number currently under the pointer for this wheel rotation. pub fn number_under(rotation: f32) -> u8 { let segment = TAU / WHEEL.len() as f32; let index = (-rotation / segment).round() as i64; WHEEL[index.rem_euclid(WHEEL.len() as i64) as usize] } /// The wheel dial: 37 coloured pockets, a fixed pointer, and the number under /// it. `rotation` is in radians. pub fn wheel(ui: &mut Ui, diameter: f32, view: &RouletteView, rotation: f32) { let (rect, _) = ui.allocate_exact_size(Vec2::splat(diameter), Sense::hover()); let painter = ui.painter(); let center = rect.center(); let radius = diameter * 0.5; painter.circle_filled(center, radius, theme::SURFACE_RAISED); painter.circle_stroke(center, radius - 1.0, Stroke::new(1.0, theme::LINE)); let segment = TAU / WHEEL.len() as f32; let ring = radius * 0.80; let dot = (radius * 0.072).max(2.0); painter.circle_stroke( center, ring, Stroke::new(dot * 2.0, Color32::from_rgb(0x1f, 0x28, 0x31)), ); for (index, number) in WHEEL.iter().enumerate() { let angle = -FRAC_PI_2 + rotation + index as f32 * segment; let pocket = center + vec2(angle.cos(), angle.sin()) * ring; let winning = view.phase == RoulettePhase::Result && view.number == Some(*number); let stroke = if winning { Stroke::new(2.0, theme::GOLD) } else { Stroke::new(1.0, Color32::from_rgb(0x3a, 0x46, 0x52)) }; painter.circle( pocket, if winning { dot * 1.25 } else { dot }, segment_color(*number), stroke, ); } // A fixed pointer at the top, marking the winning pocket. let tip = center + vec2(0.0, -radius + dot * 0.30); painter.add(Shape::convex_polygon( vec![ tip, tip + vec2(-dot * 0.9, -dot * 1.4), tip + vec2(dot * 0.9, -dot * 1.4), ], theme::TEXT, Stroke::NONE, )); let inner = radius * 0.52; painter.circle_filled(center, inner, theme::SURFACE); painter.circle_stroke(center, inner, Stroke::new(1.0, theme::LINE)); let displayed = match view.phase { RoulettePhase::Spinning => Some(number_under(rotation)), _ => view.number, }; match displayed { Some(number) => { let color = match wheel_color(number) { "red" => theme::ROULETTE_RED, "green" => theme::WIN, _ => theme::TEXT, }; painter.text( center, Align2::CENTER_CENTER, number, theme::mono(inner * 0.85), color, ); } None => { painter.text( center, Align2::CENTER_CENTER, "—", theme::mono(inner * 0.55), theme::TEXT_FAINT, ); } } } /// "Straight 17", "Corner 17/18/20/21", "Red". pub fn target_name(target: &BetTarget) -> String { match target { BetTarget::Straight { number } => format!("Straight {number}"), BetTarget::Split { a, b } => format!("Split {a}/{b}"), BetTarget::Corner { numbers } => format!( "Corner {}/{}/{}/{}", numbers[0], numbers[1], numbers[2], numbers[3] ), BetTarget::Dozen { index } => match index { 0 => "1st 12".to_string(), 1 => "2nd 12".to_string(), _ => "3rd 12".to_string(), }, BetTarget::Column { index } => format!("Column {}", index + 1), BetTarget::Outside { kind } => title_case(kind.label()), } } fn title_case(text: &str) -> String { let mut characters = text.chars(); match characters.next() { Some(first) => { first.to_uppercase().collect::() + &characters.as_str().to_lowercase() } None => String::new(), } } /// Small coloured dot for a result strip. pub fn paint_result_dot(painter: &Painter, center: Pos2, radius: f32, number: u8) { let color = match wheel_color(number) { "red" => theme::ROULETTE_RED, "green" => theme::ROULETTE_ZERO, _ => theme::ROULETTE_BLACK, }; painter.circle_filled(center, radius, color); painter.text( center, Align2::CENTER_CENTER, number, theme::mono(radius * 0.95), theme::FELT_TEXT, ); }