diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 20:11:41 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 20:11:41 +0100 |
| commit | e920b20fafe375eec15b24eb8cde4b1455dd01af (patch) | |
| tree | 308d2f7999177a753528779f6ec7926f3fa50818 /gui/src/roulette.rs | |
| parent | v4.1.0: add the egui workspace member and the roulette style spike (diff) | |
| download | zagan-bet-e920b20fafe375eec15b24eb8cde4b1455dd01af.tar.gz zagan-bet-e920b20fafe375eec15b24eb8cde4b1455dd01af.zip | |
v4.1.1: pointer-first betting and visible payouts in the GUI
The felt snaps the hover to the shared fine lattice: inner half of a cell is a straight, an edge a split, a crossing a corner. A translucent chip previews the target and the covered cells tint. Left click adds the selected chip, right or middle click takes one back through the new Bets::reduce; C or CLEAR empties the slip, and SPIN stays disabled with no chips.
The result now pays out: the wheel leaves the winning pocket under the pointer, the winning cell and pocket get a gold outline, and the sidebar reports the amount returned and the net for the round (NO WIN on a loss). Placing a chip after a result starts the next round without losing the wheel position.
The column row now spans the full width like a real table instead of hugging the left edge. Adds tests for lattice snapping, target naming and taking chips back.
Diffstat (limited to '')
| -rw-r--r-- | gui/src/roulette.rs | 161 |
1 files changed, 97 insertions, 64 deletions
diff --git a/gui/src/roulette.rs b/gui/src/roulette.rs index 22c46d6..f674251 100644 --- a/gui/src/roulette.rs +++ b/gui/src/roulette.rs | |||
| @@ -7,10 +7,10 @@ | |||
| 7 | use std::f32::consts::{FRAC_PI_2, TAU}; | 7 | use std::f32::consts::{FRAC_PI_2, TAU}; |
| 8 | 8 | ||
| 9 | use eframe::egui::{ | 9 | use eframe::egui::{ |
| 10 | Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, | 10 | Align2, Color32, CornerRadius, Painter, PointerButton, Pos2, Rect, Sense, Shape, Stroke, |
| 11 | pos2, vec2, | 11 | StrokeKind, Ui, Vec2, pos2, vec2, |
| 12 | }; | 12 | }; |
| 13 | use zagan_bet::client::games::roulette::Cursor; | 13 | use zagan_bet::client::games::roulette::target_at as lattice_target; |
| 14 | use zagan_bet::client::view::{RoulettePhase, RouletteView}; | 14 | use zagan_bet::client::view::{RoulettePhase, RouletteView}; |
| 15 | use zagan_bet::roulette::{BetTarget, Outside, WHEEL, color as wheel_color, is_red}; | 15 | use zagan_bet::roulette::{BetTarget, Outside, WHEEL, color as wheel_color, is_red}; |
| 16 | 16 | ||
| @@ -42,18 +42,38 @@ pub fn size_for(available: Vec2) -> Vec2 { | |||
| 42 | vec2((COLUMNS as f32 + 1.0) * cell, HEIGHT_UNITS * cell) + Vec2::splat(BOARD_PAD * 2.0) | 42 | vec2((COLUMNS as f32 + 1.0) * cell, HEIGHT_UNITS * cell) + Vec2::splat(BOARD_PAD * 2.0) |
| 43 | } | 43 | } |
| 44 | 44 | ||
| 45 | /// The table painted at `size`; returns the target clicked this frame. | 45 | /// What the pointer did to the felt this frame. |
| 46 | pub fn felt(ui: &mut Ui, size: Vec2, view: &RouletteView) -> Option<BetTarget> { | 46 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 47 | pub enum FeltAction { | ||
| 48 | /// Left click: add the selected chip. | ||
| 49 | Place(BetTarget), | ||
| 50 | /// Right or middle click: take one chip back. | ||
| 51 | Reduce(BetTarget), | ||
| 52 | } | ||
| 53 | |||
| 54 | /// The table painted at `size`; returns the bet the pointer changed. | ||
| 55 | pub fn felt(ui: &mut Ui, size: Vec2, view: &RouletteView) -> Option<FeltAction> { | ||
| 47 | let (rect, response) = ui.allocate_exact_size(size, Sense::click()); | 56 | let (rect, response) = ui.allocate_exact_size(size, Sense::click()); |
| 48 | let felt = Felt::new(rect); | 57 | let felt = Felt::new(rect); |
| 49 | let hovered = response.hover_pos().and_then(|pos| felt.hit(pos)); | 58 | let hovered = if view.phase == RoulettePhase::Spinning { |
| 59 | None | ||
| 60 | } else { | ||
| 61 | response.hover_pos().and_then(|pos| felt.target_at(pos)) | ||
| 62 | }; | ||
| 50 | let painter = ui.painter(); | 63 | let painter = ui.painter(); |
| 51 | 64 | ||
| 52 | painter.rect_filled(rect, theme::RADIUS_LG, theme::FELT_DARK); | 65 | painter.rect_filled(rect, theme::RADIUS_LG, theme::FELT_DARK); |
| 53 | 66 | ||
| 67 | // Every cell the hovered bet covers tints, so a split or corner reads | ||
| 68 | // before the chip lands. | ||
| 69 | let covered = hovered.as_ref().map(BetTarget::numbers).unwrap_or_default(); | ||
| 54 | for number in 0..=36u8 { | 70 | for number in 0..=36u8 { |
| 55 | let highlighted = hovered == Some(BetTarget::Straight { number }); | 71 | felt.paint_number( |
| 56 | felt.paint_number(painter, number, highlighted); | 72 | painter, |
| 73 | number, | ||
| 74 | covered.contains(&number), | ||
| 75 | hovered == Some(BetTarget::Straight { number }), | ||
| 76 | ); | ||
| 57 | } | 77 | } |
| 58 | for index in 0..3 { | 78 | for index in 0..3 { |
| 59 | felt.paint_dozen( | 79 | felt.paint_dozen( |
| @@ -89,24 +109,38 @@ pub fn felt(ui: &mut Ui, size: Vec2, view: &RouletteView) -> Option<BetTarget> { | |||
| 89 | } | 109 | } |
| 90 | } | 110 | } |
| 91 | 111 | ||
| 92 | felt.paint_cursor(painter, &view.cursor); | ||
| 93 | |||
| 94 | for chip in &view.bets.chips { | 112 | for chip in &view.bets.chips { |
| 95 | if let Some(center) = felt.anchor(&chip.target) { | 113 | if let Some(center) = felt.anchor(&chip.target) { |
| 96 | paint_chip(painter, center, felt.cell * 0.26, chip.amount); | 114 | paint_chip(painter, center, felt.cell_size() * 0.26, chip.amount); |
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | if let Some(target) = &hovered { | ||
| 119 | if let Some(center) = felt.anchor(target) { | ||
| 120 | paint_chip_preview(painter, center, felt.cell_size() * 0.26, view.chip); | ||
| 97 | } | 121 | } |
| 98 | } | 122 | } |
| 99 | 123 | ||
| 100 | if response.clicked() { hovered } else { None } | 124 | if response.clicked_by(PointerButton::Secondary) || response.clicked_by(PointerButton::Middle) { |
| 125 | hovered.map(FeltAction::Reduce) | ||
| 126 | } else if response.clicked() { | ||
| 127 | hovered.map(FeltAction::Place) | ||
| 128 | } else { | ||
| 129 | None | ||
| 130 | } | ||
| 101 | } | 131 | } |
| 102 | 132 | ||
| 103 | struct Felt { | 133 | /// Geometry of one table: the felt is 13 cells wide (zero plus twelve |
| 134 | /// columns) and 3 + 0.72 + 0.55 + 0.62 cells tall. | ||
| 135 | /// | ||
| 136 | /// Public so the pointer snapping can be tested without a window. | ||
| 137 | pub struct Felt { | ||
| 104 | cell: f32, | 138 | cell: f32, |
| 105 | origin: Pos2, | 139 | origin: Pos2, |
| 106 | } | 140 | } |
| 107 | 141 | ||
| 108 | impl Felt { | 142 | impl Felt { |
| 109 | fn new(board: Rect) -> Self { | 143 | pub fn new(board: Rect) -> Self { |
| 110 | let inner = board.shrink(BOARD_PAD); | 144 | let inner = board.shrink(BOARD_PAD); |
| 111 | Self { | 145 | Self { |
| 112 | cell: inner.width() / (COLUMNS as f32 + 1.0), | 146 | cell: inner.width() / (COLUMNS as f32 + 1.0), |
| @@ -114,7 +148,11 @@ impl Felt { | |||
| 114 | } | 148 | } |
| 115 | } | 149 | } |
| 116 | 150 | ||
| 117 | fn number_rect(&self, number: u8) -> Rect { | 151 | pub fn cell_size(&self) -> f32 { |
| 152 | self.cell | ||
| 153 | } | ||
| 154 | |||
| 155 | pub fn number_rect(&self, number: u8) -> Rect { | ||
| 118 | debug_assert!(number <= 36); | 156 | debug_assert!(number <= 36); |
| 119 | if number == 0 { | 157 | if number == 0 { |
| 120 | return Rect::from_min_size(self.origin, vec2(self.cell, self.cell * ROWS as f32)); | 158 | return Rect::from_min_size(self.origin, vec2(self.cell, self.cell * ROWS as f32)); |
| @@ -138,8 +176,8 @@ impl Felt { | |||
| 138 | fn column_rect(&self, index: usize) -> Rect { | 176 | fn column_rect(&self, index: usize) -> Rect { |
| 139 | let y = self.origin.y + self.cell * (ROWS as f32 + DOZEN_ROW); | 177 | let y = self.origin.y + self.cell * (ROWS as f32 + DOZEN_ROW); |
| 140 | Rect::from_min_size( | 178 | Rect::from_min_size( |
| 141 | pos2(self.origin.x + self.cell * (1.0 + index as f32), y), | 179 | pos2(self.origin.x + self.cell * (1.0 + 4.0 * index as f32), y), |
| 142 | vec2(self.cell, self.cell * COLUMN_ROW), | 180 | vec2(self.cell * 4.0, self.cell * COLUMN_ROW), |
| 143 | ) | 181 | ) |
| 144 | } | 182 | } |
| 145 | 183 | ||
| @@ -152,7 +190,7 @@ impl Felt { | |||
| 152 | } | 190 | } |
| 153 | 191 | ||
| 154 | /// Where a chip sits for this target. | 192 | /// Where a chip sits for this target. |
| 155 | fn anchor(&self, target: &BetTarget) -> Option<Pos2> { | 193 | pub fn anchor(&self, target: &BetTarget) -> Option<Pos2> { |
| 156 | Some(match target { | 194 | Some(match target { |
| 157 | BetTarget::Straight { number } => self.number_rect(*number).center(), | 195 | BetTarget::Straight { number } => self.number_rect(*number).center(), |
| 158 | BetTarget::Split { a, b } => { | 196 | BetTarget::Split { a, b } => { |
| @@ -178,20 +216,28 @@ impl Felt { | |||
| 178 | }) | 216 | }) |
| 179 | } | 217 | } |
| 180 | 218 | ||
| 181 | /// The fine-grid point the cursor occupies over the numbers. | 219 | fn grid_origin(&self) -> Pos2 { |
| 182 | fn lattice(&self, x: usize, y: usize) -> Pos2 { | 220 | self.origin + vec2(self.cell, 0.0) |
| 183 | pos2( | ||
| 184 | self.origin.x + self.cell * (1.0 + x as f32 * 0.5), | ||
| 185 | self.origin.y + self.cell * (y as f32 * 0.5), | ||
| 186 | ) | ||
| 187 | } | 221 | } |
| 188 | 222 | ||
| 189 | fn hit(&self, pos: Pos2) -> Option<BetTarget> { | 223 | /// The bet under the pointer, snapped to the fine lattice: a cell centre |
| 190 | for number in 0..=36u8 { | 224 | /// is a straight, a line is a split, a crossing is a corner. |
| 191 | if self.number_rect(number).contains(pos) { | 225 | pub fn target_at(&self, pos: Pos2) -> Option<BetTarget> { |
| 192 | return Some(BetTarget::Straight { number }); | 226 | if self.number_rect(0).contains(pos) { |
| 193 | } | 227 | return Some(BetTarget::Straight { number: 0 }); |
| 228 | } | ||
| 229 | |||
| 230 | let grid = self.grid_origin(); | ||
| 231 | let fx = (pos.x - grid.x) / self.cell; | ||
| 232 | let fy = (pos.y - grid.y) / self.cell; | ||
| 233 | if (0.0..COLUMNS as f32).contains(&fx) && (0.0..ROWS as f32).contains(&fy) { | ||
| 234 | // Thresholds sit a quarter cell from a centre, so the inner half | ||
| 235 | // of a cell is a straight and its edges are splits or corners. | ||
| 236 | let x = (fx * 2.0).round().clamp(1.0, 23.0) as usize; | ||
| 237 | let y = (fy * 2.0).round().clamp(1.0, 5.0) as usize; | ||
| 238 | return lattice_target(x, y); | ||
| 194 | } | 239 | } |
| 240 | |||
| 195 | for index in 0..3usize { | 241 | for index in 0..3usize { |
| 196 | if self.dozen_rect(index).contains(pos) { | 242 | if self.dozen_rect(index).contains(pos) { |
| 197 | return Some(BetTarget::Dozen { index: index as u8 }); | 243 | return Some(BetTarget::Dozen { index: index as u8 }); |
| @@ -208,7 +254,7 @@ impl Felt { | |||
| 208 | None | 254 | None |
| 209 | } | 255 | } |
| 210 | 256 | ||
| 211 | fn paint_number(&self, painter: &Painter, number: u8, hovered: bool) { | 257 | fn paint_number(&self, painter: &Painter, number: u8, covered: bool, hovered: bool) { |
| 212 | let rect = self.number_rect(number); | 258 | let rect = self.number_rect(number); |
| 213 | let fill = if number == 0 { | 259 | let fill = if number == 0 { |
| 214 | theme::ROULETTE_ZERO | 260 | theme::ROULETTE_ZERO |
| @@ -224,7 +270,9 @@ impl Felt { | |||
| 224 | Stroke::new(1.0, theme::FELT_LINE), | 270 | Stroke::new(1.0, theme::FELT_LINE), |
| 225 | StrokeKind::Inside, | 271 | StrokeKind::Inside, |
| 226 | ); | 272 | ); |
| 227 | if hovered { | 273 | if covered { |
| 274 | painter.rect_filled(rect, CornerRadius::ZERO, theme::ACCENT.gamma_multiply(0.16)); | ||
| 275 | } else if hovered { | ||
| 228 | painter.rect_filled( | 276 | painter.rect_filled( |
| 229 | rect, | 277 | rect, |
| 230 | CornerRadius::ZERO, | 278 | CornerRadius::ZERO, |
| @@ -303,28 +351,6 @@ impl Felt { | |||
| 303 | } | 351 | } |
| 304 | } | 352 | } |
| 305 | 353 | ||
| 306 | fn paint_cursor(&self, painter: &Painter, cursor: &Cursor) { | ||
| 307 | let stroke = Stroke::new(2.0, theme::ACCENT); | ||
| 308 | match cursor { | ||
| 309 | Cursor::Zero => outline(painter, self.number_rect(0), stroke), | ||
| 310 | Cursor::Lattice { x, y } => { | ||
| 311 | for number in cursor.numbers() { | ||
| 312 | painter.rect_filled( | ||
| 313 | self.number_rect(number), | ||
| 314 | CornerRadius::ZERO, | ||
| 315 | theme::ACCENT.gamma_multiply(0.14), | ||
| 316 | ); | ||
| 317 | } | ||
| 318 | let point = self.lattice(*x, *y); | ||
| 319 | painter.circle_filled(point, self.cell * 0.05, theme::ACCENT); | ||
| 320 | painter.circle_stroke(point, self.cell * 0.14, stroke); | ||
| 321 | } | ||
| 322 | Cursor::Dozen(index) => outline(painter, self.dozen_rect(*index), stroke), | ||
| 323 | Cursor::Column(index) => outline(painter, self.column_rect(*index), stroke), | ||
| 324 | Cursor::Outside(index) => outline(painter, self.outside_rect(*index), stroke), | ||
| 325 | } | ||
| 326 | } | ||
| 327 | |||
| 328 | fn number_font(&self) -> f32 { | 354 | fn number_font(&self) -> f32 { |
| 329 | (self.cell * 0.40).clamp(12.0, 22.0) | 355 | (self.cell * 0.40).clamp(12.0, 22.0) |
| 330 | } | 356 | } |
| @@ -334,15 +360,6 @@ impl Felt { | |||
| 334 | } | 360 | } |
| 335 | } | 361 | } |
| 336 | 362 | ||
| 337 | fn outline(painter: &Painter, rect: Rect, stroke: Stroke) { | ||
| 338 | painter.rect_stroke( | ||
| 339 | rect.shrink(1.0), | ||
| 340 | theme::RADIUS_SM, | ||
| 341 | stroke, | ||
| 342 | StrokeKind::Inside, | ||
| 343 | ); | ||
| 344 | } | ||
| 345 | |||
| 346 | /// A single chip, also used by the sidebar's chip tray. | 363 | /// A single chip, also used by the sidebar's chip tray. |
| 347 | pub fn paint_chip(painter: &Painter, center: Pos2, radius: f32, amount: u64) { | 364 | pub fn paint_chip(painter: &Painter, center: Pos2, radius: f32, amount: u64) { |
| 348 | let fill = chip_color(amount); | 365 | let fill = chip_color(amount); |
| @@ -366,6 +383,23 @@ pub fn paint_chip(painter: &Painter, center: Pos2, radius: f32, amount: u64) { | |||
| 366 | ); | 383 | ); |
| 367 | } | 384 | } |
| 368 | 385 | ||
| 386 | /// The chip under the pointer, showing what a click would add. | ||
| 387 | pub fn paint_chip_preview(painter: &Painter, center: Pos2, radius: f32, amount: u64) { | ||
| 388 | painter.circle_filled(center, radius, chip_color(amount).gamma_multiply(0.45)); | ||
| 389 | painter.circle_stroke( | ||
| 390 | center, | ||
| 391 | radius, | ||
| 392 | Stroke::new(2.0, Color32::WHITE.gamma_multiply(0.85)), | ||
| 393 | ); | ||
| 394 | painter.text( | ||
| 395 | center, | ||
| 396 | Align2::CENTER_CENTER, | ||
| 397 | amount, | ||
| 398 | theme::mono(radius * 0.85), | ||
| 399 | Color32::WHITE, | ||
| 400 | ); | ||
| 401 | } | ||
| 402 | |||
| 369 | /// Colour per denomination, as a chip tray would be stocked. | 403 | /// Colour per denomination, as a chip tray would be stocked. |
| 370 | pub fn chip_color(amount: u64) -> Color32 { | 404 | pub fn chip_color(amount: u64) -> Color32 { |
| 371 | match amount { | 405 | match amount { |
| @@ -444,9 +478,8 @@ pub fn wheel(ui: &mut Ui, diameter: f32, view: &RouletteView, rotation: f32) { | |||
| 444 | painter.circle_stroke(center, inner, Stroke::new(1.0, theme::LINE)); | 478 | painter.circle_stroke(center, inner, Stroke::new(1.0, theme::LINE)); |
| 445 | 479 | ||
| 446 | let displayed = match view.phase { | 480 | let displayed = match view.phase { |
| 447 | RoulettePhase::Result => view.number, | ||
| 448 | RoulettePhase::Spinning => Some(number_under(rotation)), | 481 | RoulettePhase::Spinning => Some(number_under(rotation)), |
| 449 | RoulettePhase::Placing => None, | 482 | _ => view.number, |
| 450 | }; | 483 | }; |
| 451 | match displayed { | 484 | match displayed { |
| 452 | Some(number) => { | 485 | Some(number) => { |
