From 10ff558853f549d3f3c7c8432488bfad9da6973a Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 19:58:59 +0100 Subject: v4.1.0: add the egui workspace member and the roulette style spike Phase 3 of the GUI plan. gui/ is a separate package that depends on the core with default features off, so egui never enters the TUI or server build graph. It carries the theme tokens and two embedded fonts (Open Sans OFL, Hack MIT), paints the felt, chips, cursor and wheel from RouletteView, and composes the persistent header with the betting sidebar. The reference screen runs on demo state: arrow keys move the shared Cursor, clicks place chips, and SPIN animates a 2.4 s wheel that settles with the winning pocket under the pointer. No network until Phase 4. Verified x86_64-pc-windows-gnu cross-compile. Measured on Linux at 1080p with llvmpipe: 6.0 MB binary (4.7 MB on Windows), 52 ms to window, 137 MB idle RSS, 0% idle CPU. --- gui/src/roulette.rs | 522 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 522 insertions(+) create mode 100644 gui/src/roulette.rs (limited to 'gui/src/roulette.rs') diff --git a/gui/src/roulette.rs b/gui/src/roulette.rs new file mode 100644 index 0000000..22c46d6 --- /dev/null +++ b/gui/src/roulette.rs @@ -0,0 +1,522 @@ +//! 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, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, + pos2, vec2, +}; +use zagan_bet::client::games::roulette::Cursor; +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) +} + +/// The table painted at `size`; returns the target clicked this frame. +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 = response.hover_pos().and_then(|pos| felt.hit(pos)); + let painter = ui.painter(); + + painter.rect_filled(rect, theme::RADIUS_LG, theme::FELT_DARK); + + for number in 0..=36u8 { + let highlighted = hovered == Some(BetTarget::Straight { number }); + felt.paint_number(painter, number, highlighted); + } + 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, + ); + } + } + + felt.paint_cursor(painter, &view.cursor); + + for chip in &view.bets.chips { + if let Some(center) = felt.anchor(&chip.target) { + paint_chip(painter, center, felt.cell * 0.26, chip.amount); + } + } + + if response.clicked() { hovered } else { None } +} + +struct Felt { + cell: f32, + origin: Pos2, +} + +impl Felt { + fn new(board: Rect) -> Self { + let inner = board.shrink(BOARD_PAD); + Self { + cell: inner.width() / (COLUMNS as f32 + 1.0), + origin: inner.min, + } + } + + 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 + index as f32), y), + vec2(self.cell, 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. + 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() + } + }) + } + + /// The fine-grid point the cursor occupies over the numbers. + fn lattice(&self, x: usize, y: usize) -> Pos2 { + pos2( + self.origin.x + self.cell * (1.0 + x as f32 * 0.5), + self.origin.y + self.cell * (y as f32 * 0.5), + ) + } + + fn hit(&self, pos: Pos2) -> Option { + for number in 0..=36u8 { + if self.number_rect(number).contains(pos) { + return Some(BetTarget::Straight { number }); + } + } + 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, 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 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 paint_cursor(&self, painter: &Painter, cursor: &Cursor) { + let stroke = Stroke::new(2.0, theme::ACCENT); + match cursor { + Cursor::Zero => outline(painter, self.number_rect(0), stroke), + Cursor::Lattice { x, y } => { + for number in cursor.numbers() { + painter.rect_filled( + self.number_rect(number), + CornerRadius::ZERO, + theme::ACCENT.gamma_multiply(0.14), + ); + } + let point = self.lattice(*x, *y); + painter.circle_filled(point, self.cell * 0.05, theme::ACCENT); + painter.circle_stroke(point, self.cell * 0.14, stroke); + } + Cursor::Dozen(index) => outline(painter, self.dozen_rect(*index), stroke), + Cursor::Column(index) => outline(painter, self.column_rect(*index), stroke), + Cursor::Outside(index) => outline(painter, self.outside_rect(*index), stroke), + } + } + + 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) + } +} + +fn outline(painter: &Painter, rect: Rect, stroke: Stroke) { + painter.rect_stroke( + rect.shrink(1.0), + theme::RADIUS_SM, + stroke, + StrokeKind::Inside, + ); +} + +/// 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, + ); +} + +/// 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::Result => view.number, + RoulettePhase::Spinning => Some(number_under(rotation)), + RoulettePhase::Placing => None, + }; + 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, + ); +} -- cgit v1.2.3