//! 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, ); } // --------------------------------------------------------------------------- // The screen // --------------------------------------------------------------------------- /// Wheel animation and result history. Kept across frames; reset when the /// player leaves the table. pub struct Fx { rotation: f32, spin: Option, awaiting_result: bool, settled: Option, last: Vec, } struct WheelSpin { started: f64, from: f32, delta: f32, target: u8, } impl Default for Fx { fn default() -> Self { Self { rotation: 0.0, spin: None, awaiting_result: false, settled: None, last: Vec::new(), } } } impl Fx { /// Called when the player asks for a spin, before the reply can arrive. pub fn spin_requested(&mut self) { self.awaiting_result = true; } pub fn ticking(&self) -> bool { self.spin.is_some() } /// Advance the wheel; starts a spin when a new result lands. pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &RouletteView) { let now = ctx.input(|input| input.time); if view.error.is_some() { // The request was refused, so no result is coming. self.awaiting_result = false; } if self.awaiting_result && view.phase == RoulettePhase::Result { if let Some(target) = view.number { self.awaiting_result = false; self.start(now, target); } } let Some(spin) = &self.spin else { return; }; let t = (((now - spin.started) / 1.7) as f32).clamp(0.0, 1.0); let eased = 1.0 - (1.0 - t).powi(3); self.rotation = spin.from + spin.delta * eased; if t >= 1.0 { let target = spin.target; self.spin = None; self.settled = Some(target); self.last.insert(0, target); self.last.truncate(12); } ctx.request_repaint(); } fn start(&mut self, now: f64, target: u8) { let segment = TAU / WHEEL.len() as f32; let index = WHEEL .iter() .position(|number| *number == target) .unwrap_or(0) as f32; let desired = -index * segment; let delta = (desired - self.rotation).rem_euclid(TAU) + 3.0 * TAU; self.spin = Some(WheelSpin { started: now, from: self.rotation, delta, target, }); } fn spinning(&self) -> bool { self.spin.is_some() } /// The payout is held back until the wheel has stopped. pub fn revealed(&self, view: &RouletteView) -> bool { view.phase == RoulettePhase::Result && !self.spinning() } } pub fn show( ui: &mut Ui, view: &RouletteView, fx: &mut Fx, ) -> Option { let mut command = None; // While the wheel turns, keep the result hidden: no winning cell and no // payout until it stops under the pointer. let mut shown = view.clone(); if fx.spinning() && view.phase == RoulettePhase::Result { shown.phase = RoulettePhase::Spinning; } 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| { if let Some(clicked) = board(ui, &shown, fx) { command = Some(clicked); } }, ); 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| { if let Some(clicked) = sidebar(ui, &shown, fx) { command = Some(clicked); } }, ); }); command } fn sidebar( ui: &mut Ui, view: &RouletteView, fx: &mut Fx, ) -> Option { use zagan_bet::client::view::Command; let mut command = None; let (content, actions) = super::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("Roulette") .font(theme::sans(22.0)) .color(theme::TEXT), ); ui.label( eframe::egui::RichText::new("European · single zero") .font(theme::sans(12.0)) .color(theme::TEXT_DIM), ); ui.add_space(theme::SPACE_4); let wheel_size = ui.available_width().min(210.0); ui.horizontal(|ui| { let pad = ((ui.available_width() - wheel_size) * 0.5).max(0.0); ui.add_space(pad); wheel(ui, wheel_size, view, fx.rotation); }); ui.add_space(theme::SPACE_1); let spinning = view.phase == RoulettePhase::Spinning || fx.spinning(); if spinning { ui.label( eframe::egui::RichText::new("SPINNING…") .font(theme::mono(12.0)) .color(theme::GOLD), ); } else if fx.revealed(view) { ui.horizontal(|ui| { let number = view.number.unwrap_or(0); ui.label(theme::label("Result")); ui.label( eframe::egui::RichText::new(format!( "{number} {}", wheel_color(number).to_uppercase() )) .font(theme::mono(14.0)) .color(number_color(number)), ); ui.with_layout( eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), |ui| { 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 profit = winnings as i64 - view.bets.total() as i64; ui.label( eframe::egui::RichText::new(format!( "({})", theme::signed_money(profit) )) .font(theme::sans(11.0)) .color( if profit >= 0 { theme::WIN } else { theme::LOSS }, ), ); ui.label( eframe::egui::RichText::new(theme::money( winnings as i64, )) .font(theme::mono(15.0)) .color(theme::WIN), ); ui.label(theme::label("Won")); } }, ); }); } else { ui.horizontal(|ui| { ui.label( eframe::egui::RichText::new("Place your bets") .font(theme::sans(12.0)) .color(theme::TEXT_DIM), ); ui.with_layout( eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), |ui| { ui.label( eframe::egui::RichText::new(match view.bets.chips.len() { 1 => "1 chip".to_string(), count => format!("{count} chips"), }) .font(theme::mono(10.0)) .color(theme::TEXT_FAINT), ); }, ); }); } ui.add_space(theme::SPACE_3); ui.separator(); ui.add_space(theme::SPACE_3); ui.label(theme::label("Chip")); ui.add_space(theme::SPACE_2); if let Some(chip) = super::widgets::chip_row(ui, [10, 25, 50, 100], view.chip, !spinning) { command = Some(Command::SetChip(chip)); } ui.add_space(theme::SPACE_3); ui.label(theme::label("Bets")); ui.add_space(theme::SPACE_1); if view.bets.chips.is_empty() { ui.label( eframe::egui::RichText::new("No chips on the felt") .font(theme::sans(12.0)) .color(theme::TEXT_FAINT), ); } else { for chip in &view.bets.chips { super::widgets::row( ui, &target_name(&chip.target), &theme::money(chip.amount as i64), theme::GOLD, ); } } ui.add_space(theme::SPACE_3); ui.separator(); ui.add_space(theme::SPACE_2); super::widgets::row( ui, "Total", &theme::money(view.bets.total() as i64), theme::GOLD, ); }); }, ); let spinning = view.phase == RoulettePhase::Spinning || fx.spinning(); ui.scope_builder( eframe::egui::UiBuilder::new() .max_rect(actions) .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)), |ui| { if super::widgets::ghost(ui, "Leave table") { command = Some(Command::Leave); } ui.add_space(theme::SPACE_2); if super::widgets::secondary( ui, "CLEAR", !spinning && !view.bets.is_empty(), ui.available_width(), 36.0, ) { command = Some(Command::ClearBets); } ui.add_space(theme::SPACE_2); let clicked = super::widgets::primary( ui, "SPIN", !spinning && !view.bets.is_empty(), ui.available_width(), 44.0, ); if clicked { fx.spin_requested(); command = Some(Command::Spin); } }, ); command } fn board(ui: &mut Ui, view: &RouletteView, fx: &Fx) -> Option { use zagan_bet::client::view::Command; ui.horizontal(|ui| { ui.label( eframe::egui::RichText::new("Table 01") .font(theme::sans(15.0)) .color(theme::TEXT), ); ui.label( eframe::egui::RichText::new("European · single zero") .font(theme::sans(12.0)) .color(theme::TEXT_DIM), ); ui.with_layout( eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), |ui| { ui.label( eframe::egui::RichText::new("Limits 10 – 500 Kr") .font(theme::mono(12.0)) .color(theme::TEXT_FAINT), ); }, ); }); ui.add_space(theme::SPACE_4); let strip_height = 104.0; let available = ui.available_size(); let size = size_for(vec2(available.x, (available.y - strip_height).max(220.0))); let side_pad = ((available.x - size.x) * 0.5).max(0.0); let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0); ui.add_space(top_pad); let enabled = view.phase != RoulettePhase::Spinning && !fx.spinning(); let action = ui .horizontal(|ui| { ui.add_space(side_pad); felt(ui, size, view) }) .inner; let command = match action { Some(FeltAction::Place(target)) if enabled => Some(Command::PlaceBetAt(target)), Some(FeltAction::Reduce(target)) if enabled => Some(Command::RemoveBetAt(target)), _ => None, }; ui.add_space(theme::SPACE_4); ui.horizontal(|ui| { ui.label(theme::label("Last")); if fx.last.is_empty() { ui.label( eframe::egui::RichText::new("No spins yet") .font(theme::sans(12.0)) .color(theme::TEXT_FAINT), ); } for number in &fx.last { let (dot, _) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::hover()); paint_result_dot(ui.painter(), dot.center(), 11.0, *number); ui.add_space(theme::SPACE_1); } }); ui.add_space(theme::SPACE_2); ui.label( eframe::egui::RichText::new( "Click to place · right or middle click takes back · C clears · space spins", ) .font(theme::sans(12.0)) .color(theme::TEXT_FAINT), ); command } fn number_color(number: u8) -> Color32 { match wheel_color(number) { "red" => theme::ROULETTE_RED, "green" => theme::WIN, _ => theme::TEXT, } }