From e920b20fafe375eec15b24eb8cde4b1455dd01af Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 20:11:41 +0100 Subject: 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. --- AGENTS.md | 4 + Cargo.toml | 2 +- docs/GUI-PLAN.md | 12 +- docs/TESTING.md | 34 ++-- docs/screenshots/gui-roulette-1080p.png | Bin 126927 -> 128158 bytes docs/screenshots/gui-roulette-result-1080p.png | Bin 127523 -> 127283 bytes gui/Cargo.toml | 2 +- gui/src/app.rs | 253 +++++++++++++++---------- gui/src/roulette.rs | 161 +++++++++------- gui/src/theme.rs | 6 + gui/tests/layout.rs | 76 +++++++- src/client/games/roulette.rs | 8 +- src/roulette.rs | 10 + tests/roulette.rs | 20 ++ 14 files changed, 402 insertions(+), 186 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c3b555d..6207d7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,10 @@ look; keep a style module and a reference screen. The palette is derived from the terminal's ANSI colours, and felt green is only ever the roulette table. +- The GUI felt is pointer-first: the hover snaps to the shared fine lattice + (cell centre, edge, crossing), left click adds the chip and right or middle + click takes one back. The terminal keeps its arrow-key cursor; the GUI does + not paint it. ## Client TUI - `render_lines()` and `render()` must agree. If `render()` uses `\r\n`, diff --git a/Cargo.toml b/Cargo.toml index ed9d4f3..53acc77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["gui"] [package] name = "zagan-bet" -version = "4.1.0" +version = "4.1.1" edition = "2024" license = "GPL-3.0-or-later" diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md index 8c3db0a..bb63530 100644 --- a/docs/GUI-PLAN.md +++ b/docs/GUI-PLAN.md @@ -79,11 +79,17 @@ crash board from its local clock. green kept for the table, a 4/8/12/16/24 spacing scale, radii, and the type scale. Two embedded fonts: Open Sans (OFL) for text, Hack (MIT) for numbers, chips and labels. Licences sit next to the files in `gui/assets/fonts/`. -- `gui/src/roulette.rs` draws the felt, chips, the fine lattice cursor and the - wheel from a `RouletteView`; it owns the geometry, not the rules. +- `gui/src/roulette.rs` draws the felt, chips and wheel from a `RouletteView`; + it owns the geometry, not the rules. The felt is pointer-first: the hover + snaps to the fine lattice, so the inner half of a cell is a straight, an edge + is a split and a crossing is a corner. Left click adds the selected chip, + right or middle click takes one back, and a translucent chip previews the + target under the pointer. - `gui/src/app.rs` composes the persistent header (connection, account, balance) with the table and the betting sidebar. The demo state animates a - 2.4 s wheel spin and highlights the winning pocket and cell. + 2.4 s wheel spin, highlights the winning pocket and cell, and reports the + amount paid and the net for the round. SPIN and CLEAR sit side by side so + clearing is never below the fold. - `gui/tests/layout.rs` covers the pure maths (money grouping, wheel angle, table aspect, bet names). UI wiring tests arrive with `egui_kittest` in Phase 4. diff --git a/docs/TESTING.md b/docs/TESTING.md index f42b84a..32fc628 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -125,21 +125,27 @@ Windows 10 before approving the look. 1. `cargo run -p zagan-bet-gui` at 1440x900, then at 1920x1080. The header keeps its right margin at both sizes and shows ONLINE, host, ping, account and balance; the table is centred and the sidebar is full height. -2. The felt shows 0, the 3x12 numbers, dozens, columns and the six outside - cells. Chips sit on their targets: a straight in the cell, a corner on the - lattice crossing, an outside bet in its box. -3. Click a pocket: a chip of the selected denomination appears and the slip - gains a row. Chips are 10/25/50/100; the selected one has a cyan ring. -4. Arrow keys move the cursor. Over the numbers the cyan marker sits on the - cell or lattice point and the covered cells tint; leaving the table walks - dozens, columns and outside. +2. The felt shows 0, the 3x12 numbers, dozens, columns (each a quarter of the + row) and the six outside cells. Chips sit on their targets: a straight in + the cell, a corner on the lattice crossing, an outside bet in its box. +3. Hover a number: a translucent chip of the selected denomination appears. + In the inner half of a cell it is a straight (one cell tints); near an edge + it snaps to a split (two cells tint) and near a corner to a corner (four + cells tint). Hovering the dozens, columns and outside boxes moves the + preview there. +4. Left click adds the chip where the preview is; right or middle click takes + one chip of that value back, dropping the row when it reaches zero. The + slip and total follow every change. C or CLEAR empties the slip. 5. Space or SPIN animates the wheel for about 2.4 s and stops with the winning - pocket under the pointer. The winning cell gets a gold outline, RESULT names - the number and colour, and LAST gains a dot. SPIN is disabled during the - spin. -6. Clear bets empties the slip and the total. Resize to 1100x720: the sidebar - scrolls, nothing overlaps the header, and the table keeps its aspect ratio. -7. Compare the 1080p window against the approved reference screenshot. + pocket under the pointer. The winning cell gets a gold outline and LAST + gains a dot. RESULT names the number and colour, WON shows the amount paid + and the net for the round, and a losing spin says NO WIN. SPIN, CLEAR and + the chips are disabled during the spin. +6. Placing a chip after a result starts the next round: the payout row goes + back to PLACE YOUR BETS while the wheel stays where it stopped. Resize to + 1100x720: the sidebar scrolls, nothing overlaps the header, and the table + keeps its aspect ratio. +7. Compare the 1080p window against the approved reference screenshots. ## Manual roulette checklist diff --git a/docs/screenshots/gui-roulette-1080p.png b/docs/screenshots/gui-roulette-1080p.png index b6c9b17..2f30aa7 100644 Binary files a/docs/screenshots/gui-roulette-1080p.png and b/docs/screenshots/gui-roulette-1080p.png differ diff --git a/docs/screenshots/gui-roulette-result-1080p.png b/docs/screenshots/gui-roulette-result-1080p.png index 0f37133..a7d425c 100644 Binary files a/docs/screenshots/gui-roulette-result-1080p.png and b/docs/screenshots/gui-roulette-result-1080p.png differ diff --git a/gui/Cargo.toml b/gui/Cargo.toml index a2f4154..a0dfb86 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet-gui" -version = "4.1.0" +version = "4.1.1" edition = "2024" license = "GPL-3.0-or-later" description = "Desktop frontend for zagan-bet" diff --git a/gui/src/app.rs b/gui/src/app.rs index fde117a..b5a50b3 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -10,11 +10,12 @@ use eframe::egui::{ self, Align, Button, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel, RichText, Sense, Stroke, Ui, Vec2, vec2, }; -use zagan_bet::client::games::roulette::{Cursor, Direction}; +use zagan_bet::client::games::roulette::Cursor; use zagan_bet::client::view::{RoulettePhase, RouletteView}; use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color}; use zagan_bet::version::VERSION; +use crate::roulette::FeltAction; use crate::{roulette, theme}; const HEADER_HEIGHT: f32 = 64.0; @@ -218,10 +219,11 @@ fn sidebar(ui: &mut Ui, demo: &mut Demo) { ui.label(theme::label("Chip")); ui.add_space(theme::SPACE_2); + let spinning = demo.phase == RoulettePhase::Spinning; ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = theme::SPACE_2; for amount in CHIP_SIZES { - if chip_button(ui, amount, demo.chip == amount).clicked() { + if chip_button(ui, amount, demo.chip == amount, !spinning).clicked() { demo.chip = amount; } } @@ -247,80 +249,118 @@ fn sidebar(ui: &mut Ui, demo: &mut Demo) { ui.add_space(theme::SPACE_2); bet_row(ui, "Total", demo.bets.total()); - ui.add_space(theme::SPACE_3); - let spinning = demo.phase == RoulettePhase::Spinning; - let spin = Button::new( - RichText::new("SPIN") - .font(theme::mono(theme::FONT_MONO)) - .color(theme::BG), - ) - .fill(if spinning { - theme::WIN.gamma_multiply(0.45) - } else { - theme::WIN - }) - .corner_radius(theme::RADIUS_MD) - .min_size(vec2(ui.available_width(), 44.0)); - if ui.add_enabled(!spinning, spin).clicked() { - demo.start_spin(ui.ctx()); - } + ui.add_space(theme::SPACE_4); + let actions = ui.available_width(); + let clear_width = 108.0; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = theme::SPACE_2; - ui.add_space(theme::SPACE_2); - let clear = Button::new( - RichText::new("Clear bets") - .font(theme::sans(12.0)) - .color(theme::TEXT_DIM), - ) - .frame(false); - if ui - .add_enabled(!demo.bets.is_empty() && !spinning, clear) - .clicked() - { - demo.bets = Bets::default(); - } + let spin = Button::new( + RichText::new("SPIN") + .font(theme::mono(theme::FONT_MONO)) + .color(theme::BG), + ) + .fill(if spinning { + theme::WIN.gamma_multiply(0.45) + } else { + theme::WIN + }) + .corner_radius(theme::RADIUS_MD) + .min_size(vec2(actions - clear_width - theme::SPACE_2, 44.0)); + let can_spin = !spinning && !demo.bets.is_empty(); + if ui.add_enabled(can_spin, spin).clicked() { + demo.start_spin(ui.ctx()); + } + + let clear = Button::new( + RichText::new("CLEAR") + .font(theme::mono(12.0)) + .color(theme::TEXT_DIM), + ) + .fill(theme::SURFACE_RAISED) + .stroke(Stroke::new(1.0, theme::LINE)) + .corner_radius(theme::RADIUS_MD) + .min_size(vec2(clear_width, 44.0)); + if ui + .add_enabled(!demo.bets.is_empty() && !spinning, clear) + .clicked() + { + demo.begin_placing(); + demo.bets = Bets::default(); + } + }); } fn result_line(ui: &mut Ui, demo: &Demo) { - ui.horizontal(|ui| { - match (demo.phase, demo.number) { - (RoulettePhase::Result, Some(number)) => { - ui.label(theme::label("Result")); - ui.label( - RichText::new(number.to_string()) - .font(theme::mono(15.0)) - .color(color_of(number)), - ); - ui.label( - RichText::new(wheel_color(number).to_uppercase()) - .font(theme::mono(12.0)) - .color(theme::TEXT_DIM), - ); - } - (RoulettePhase::Spinning, _) => { - ui.label( - RichText::new("SPINNING…") - .font(theme::mono(12.0)) - .color(theme::GOLD), - ); - } - _ => { + ui.horizontal(|ui| match (demo.phase, demo.number) { + (RoulettePhase::Result, Some(number)) => { + ui.label(theme::label("Result")); + ui.label( + RichText::new(format!("{number} {}", wheel_color(number).to_uppercase())) + .font(theme::mono(14.0)) + .color(color_of(number)), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + payout(ui, demo); + }); + } + (RoulettePhase::Spinning, _) => { + ui.label( + RichText::new("SPINNING…") + .font(theme::mono(12.0)) + .color(theme::GOLD), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.label( - RichText::new("Place your bets") - .font(theme::sans(12.0)) - .color(theme::TEXT_DIM), + RichText::new(format!("{} chips", demo.bets.chips.len())) + .font(theme::mono(10.0)) + .color(theme::TEXT_FAINT), ); - } + }); } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + _ => { ui.label( - RichText::new(format!("{} chips", demo.bets.chips.len())) - .font(theme::mono(10.0)) - .color(theme::TEXT_FAINT), + RichText::new("Place your bets") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), ); - }); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(format!("{} chips", demo.bets.chips.len())) + .font(theme::mono(10.0)) + .color(theme::TEXT_FAINT), + ); + }); + } }); } +/// What the round paid, and the net for the spin. +fn payout(ui: &mut Ui, demo: &Demo) { + let winnings = demo.winnings.unwrap_or(0); + if winnings == 0 { + ui.label( + RichText::new("NO WIN") + .font(theme::mono(13.0)) + .color(theme::TEXT_FAINT), + ); + return; + } + + let profit = winnings as i64 - demo.spent as i64; + ui.label( + RichText::new(format!("({})", theme::signed_money(profit))) + .font(theme::sans(11.0)) + .color(if profit >= 0 { theme::WIN } else { theme::LOSS }), + ); + ui.label( + RichText::new(theme::money(winnings as i64)) + .font(theme::mono(15.0)) + .color(theme::WIN), + ); + ui.label(theme::label("Won")); +} + fn bet_row(ui: &mut Ui, name: &str, amount: u64) { ui.horizontal(|ui| { ui.label( @@ -338,8 +378,13 @@ fn bet_row(ui: &mut Ui, name: &str, amount: u64) { }); } -fn chip_button(ui: &mut Ui, amount: u64, selected: bool) -> egui::Response { - let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), Sense::click()); +fn chip_button(ui: &mut Ui, amount: u64, selected: bool, enabled: bool) -> egui::Response { + let sense = if enabled { + Sense::click() + } else { + Sense::hover() + }; + let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), sense); let center = rect.center(); let radius = 20.0; let painter = ui.painter(); @@ -350,7 +395,11 @@ fn chip_button(ui: &mut Ui, amount: u64, selected: bool) -> egui::Response { Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT), ); } - roulette::paint_chip(painter, center, radius, amount); + if enabled { + roulette::paint_chip(painter, center, radius, amount); + } else { + roulette::paint_chip_preview(painter, center, radius, amount); + } response } @@ -395,11 +444,20 @@ fn table(ui: &mut Ui, demo: &mut Demo) { 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); let chip = demo.chip; + let placing = demo.phase != RoulettePhase::Spinning; ui.add_space(top_pad); ui.horizontal(|ui| { ui.add_space(side_pad); - if let Some(target) = roulette::felt(ui, size, &view) { - demo.bets.add(target, chip); + match roulette::felt(ui, size, &view) { + Some(FeltAction::Place(target)) if placing => { + demo.begin_placing(); + demo.bets.add(target, chip); + } + Some(FeltAction::Reduce(target)) if placing => { + demo.begin_placing(); + demo.bets.reduce(&target, chip); + } + _ => {} } }); @@ -422,7 +480,7 @@ fn table(ui: &mut Ui, demo: &mut Demo) { ui.add_space(theme::SPACE_2); ui.label( RichText::new( - "Click a pocket to place a chip · arrow keys move the cursor · space spins", + "Click to place · right or middle click takes back · C clears · space spins", ) .font(theme::sans(12.0)) .color(theme::TEXT_FAINT), @@ -442,7 +500,6 @@ struct Spin { struct Demo { bets: Bets, - cursor: Cursor, chip: u64, phase: RoulettePhase, number: Option, @@ -450,6 +507,8 @@ struct Demo { spin: Option, spins: usize, last: Vec, + winnings: Option, + spent: u64, balance: i64, host: &'static str, ping_ms: u32, @@ -471,8 +530,6 @@ impl Demo { Self { bets, - // A split with no chip on it, so the fine lattice cursor shows. - cursor: Cursor::Lattice { x: 18, y: 3 }, chip: 25, phase: RoulettePhase::Placing, number: None, @@ -480,6 +537,8 @@ impl Demo { spin: None, spins: 0, last: Vec::new(), + winnings: None, + spent: 0, balance: 1000, host: "127.0.0.1:11525", ping_ms: 41, @@ -494,15 +553,24 @@ impl Demo { balance: Some(self.balance), chip: self.chip, bets: self.bets.clone(), - cursor: self.cursor, + cursor: Cursor::default(), phase: self.phase, number: self.number, color: self.number.map(|number| wheel_color(number).to_string()), - winnings: None, + winnings: self.winnings, error: None, } } + /// A bet change after a result starts the next round: the wheel stays + /// where it stopped, but the payout and the winning highlight do not. + fn begin_placing(&mut self) { + if self.phase == RoulettePhase::Result { + self.phase = RoulettePhase::Placing; + self.winnings = None; + } + } + fn tick(&mut self, ctx: &Context) { let now = ctx.input(|input| input.time); let mut finished = None; @@ -519,52 +587,37 @@ impl Demo { self.spin = None; self.phase = RoulettePhase::Result; self.number = Some(number); + self.winnings = Some(self.bets.winnings(number)); self.last.insert(0, number); self.last.truncate(12); } } + /// Pointer-first: the felt reads the mouse. The keyboard keeps the two + /// verbs that have no pointer equivalent in profile (spin and clear). fn keys(&mut self, ctx: &Context) { if self.phase == RoulettePhase::Spinning { return; } - let (left, right, up, down) = ctx.input(|input| { - ( - input.key_pressed(Key::ArrowLeft), - input.key_pressed(Key::ArrowRight), - input.key_pressed(Key::ArrowUp), - input.key_pressed(Key::ArrowDown), - ) - }); - for (pressed, direction) in [ - (left, Direction::Left), - (right, Direction::Right), - (up, Direction::Up), - (down, Direction::Down), - ] { - if pressed { - self.cursor = self.cursor.moved(direction); - } - } if ctx.memory(|memory| memory.focused().is_none()) { - if ctx.input(|input| input.key_pressed(Key::Enter)) { - if let Some(target) = self.cursor.target() { - let chip = self.chip; - self.bets.add(target, chip); - } - } if ctx.input(|input| input.key_pressed(Key::Space)) { self.start_spin(ctx); } + if ctx.input(|input| input.key_pressed(Key::C)) { + self.begin_placing(); + self.bets = Bets::default(); + } } } fn start_spin(&mut self, ctx: &Context) { - if self.phase == RoulettePhase::Spinning { + if self.phase == RoulettePhase::Spinning || self.bets.is_empty() { return; } let target = DEMO_TARGETS[self.spins % DEMO_TARGETS.len()]; self.spins += 1; + self.spent = self.bets.total(); + self.winnings = None; let segment = TAU / WHEEL.len() as f32; let index = WHEEL 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 @@ 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, + Align2, Color32, CornerRadius, Painter, PointerButton, Pos2, Rect, Sense, Shape, Stroke, + StrokeKind, Ui, Vec2, pos2, vec2, }; -use zagan_bet::client::games::roulette::Cursor; +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}; @@ -42,18 +42,38 @@ pub fn size_for(available: Vec2) -> Vec2 { 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 { +/// 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 = response.hover_pos().and_then(|pos| felt.hit(pos)); + 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 { - let highlighted = hovered == Some(BetTarget::Straight { number }); - felt.paint_number(painter, number, highlighted); + felt.paint_number( + painter, + number, + covered.contains(&number), + hovered == Some(BetTarget::Straight { number }), + ); } for index in 0..3 { felt.paint_dozen( @@ -89,24 +109,38 @@ pub fn felt(ui: &mut Ui, size: Vec2, view: &RouletteView) -> Option { } } - 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); + 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() { hovered } else { None } + 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 + } } -struct Felt { +/// 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 { - fn new(board: Rect) -> Self { + pub fn new(board: Rect) -> Self { let inner = board.shrink(BOARD_PAD); Self { cell: inner.width() / (COLUMNS as f32 + 1.0), @@ -114,7 +148,11 @@ impl Felt { } } - fn number_rect(&self, number: u8) -> Rect { + 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)); @@ -138,8 +176,8 @@ impl Felt { 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), + pos2(self.origin.x + self.cell * (1.0 + 4.0 * index as f32), y), + vec2(self.cell * 4.0, self.cell * COLUMN_ROW), ) } @@ -152,7 +190,7 @@ impl Felt { } /// Where a chip sits for this target. - fn anchor(&self, target: &BetTarget) -> Option { + pub fn anchor(&self, target: &BetTarget) -> Option { Some(match target { BetTarget::Straight { number } => self.number_rect(*number).center(), BetTarget::Split { a, b } => { @@ -178,20 +216,28 @@ impl Felt { }) } - /// 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 grid_origin(&self) -> Pos2 { + self.origin + vec2(self.cell, 0.0) } - fn hit(&self, pos: Pos2) -> Option { - for number in 0..=36u8 { - if self.number_rect(number).contains(pos) { - return Some(BetTarget::Straight { number }); - } + /// 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 }); @@ -208,7 +254,7 @@ impl Felt { None } - fn paint_number(&self, painter: &Painter, number: u8, hovered: bool) { + 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 @@ -224,7 +270,9 @@ impl Felt { Stroke::new(1.0, theme::FELT_LINE), StrokeKind::Inside, ); - if hovered { + if covered { + painter.rect_filled(rect, CornerRadius::ZERO, theme::ACCENT.gamma_multiply(0.16)); + } else if hovered { painter.rect_filled( rect, CornerRadius::ZERO, @@ -303,28 +351,6 @@ impl Felt { } } - 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) } @@ -334,15 +360,6 @@ impl Felt { } } -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); @@ -366,6 +383,23 @@ pub fn paint_chip(painter: &Painter, center: Pos2, radius: f32, amount: u64) { ); } +/// 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 { @@ -444,9 +478,8 @@ pub fn wheel(ui: &mut Ui, diameter: f32, view: &RouletteView, rotation: f32) { 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, + _ => view.number, }; match displayed { Some(number) => { diff --git a/gui/src/theme.rs b/gui/src/theme.rs index 9270aa6..0e5a475 100644 --- a/gui/src/theme.rs +++ b/gui/src/theme.rs @@ -120,6 +120,12 @@ pub fn money(amount: i64) -> String { format!("{} Kr", group_digits(amount)) } +/// Net amount with an explicit sign: `"+800 Kr"`, `"-90 Kr"`. +pub fn signed_money(amount: i64) -> String { + let sign = if amount < 0 { "-" } else { "+" }; + format!("{sign}{} Kr", group_digits(amount.abs())) +} + // --------------------------------------------------------------------------- // Installation // --------------------------------------------------------------------------- diff --git a/gui/tests/layout.rs b/gui/tests/layout.rs index e0c0475..ab5a62e 100644 --- a/gui/tests/layout.rs +++ b/gui/tests/layout.rs @@ -4,8 +4,9 @@ use std::f32::consts::TAU; +use eframe::egui::{Pos2, Rect, pos2, vec2}; use zagan_bet::roulette::{BetTarget, Outside}; -use zagan_bet_gui::roulette::{number_under, size_for, target_name}; +use zagan_bet_gui::roulette::{Felt, number_under, size_for, target_name}; use zagan_bet_gui::theme::{group_digits, money}; #[test] @@ -49,6 +50,79 @@ fn table_size_keeps_the_felt_aspect_and_fits() { assert!((cell - (size.y - 12.0) / 4.89).abs() < 0.01); } +fn felt() -> Felt { + Felt::new(Rect::from_min_size( + Pos2::ZERO, + size_for(vec2(1300.0, 900.0)), + )) +} + +#[test] +fn the_pointer_snaps_to_straights_splits_and_corners() { + let felt = felt(); + let cell = felt.cell_size(); + let center = felt.number_rect(17).center(); + + // The inner half of a cell is a straight, even slightly off centre. + assert_eq!( + felt.target_at(center + vec2(cell * 0.2, 0.0)), + Some(BetTarget::Straight { number: 17 }) + ); + + // A quarter cell from the centre is the line: right of 17 is 20, above is 18. + assert_eq!( + felt.target_at(center + vec2(cell * 0.3, 0.0)), + Some(BetTarget::Split { a: 17, b: 20 }) + ); + assert_eq!( + felt.target_at(center + vec2(0.0, -cell * 0.3)), + Some(BetTarget::Split { a: 17, b: 18 }) + ); + + // Near both lines at once is the corner of the four cells. + assert_eq!( + felt.target_at(center + vec2(cell * 0.3, -cell * 0.3)), + Some(BetTarget::Corner { + numbers: [17, 18, 20, 21] + }) + ); + + // Zero is a straight and nothing else. + assert_eq!( + felt.target_at(felt.number_rect(0).center()), + Some(BetTarget::Straight { number: 0 }) + ); +} + +#[test] +fn the_pointer_finds_the_wide_bets_below_the_grid() { + let felt = felt(); + let cell = felt.cell_size(); + let x = felt.number_rect(17).center().x; + let grid_bottom = felt.number_rect(16).bottom(); + + // 17 sits in the second dozen (13-24). + assert_eq!( + felt.target_at(pos2(x, grid_bottom + cell * 0.3)), + Some(BetTarget::Dozen { index: 1 }) + ); + + // Below the dozens is the column row; 17 is in column 2 (2,5,...,17). + assert_eq!( + felt.target_at(pos2(x, grid_bottom + cell * (0.72 + 0.3))), + Some(BetTarget::Column { index: 1 }) + ); + + // And below that, the outside row: 17's column lands on RED. + assert_eq!( + felt.target_at(pos2(x, grid_bottom + cell * (0.72 + 0.55 + 0.3))), + Some(BetTarget::Outside { kind: Outside::Red }) + ); + + // Outside the board is not a bet. + assert_eq!(felt.target_at(pos2(-1.0, -1.0)), None); +} + #[test] fn targets_read_as_bet_slips() { assert_eq!( diff --git a/src/client/games/roulette.rs b/src/client/games/roulette.rs index ec9573c..f0ed816 100644 --- a/src/client/games/roulette.rs +++ b/src/client/games/roulette.rs @@ -95,7 +95,7 @@ impl Cursor { pub fn target(&self) -> Option { match self { Cursor::Zero => Some(BetTarget::Straight { number: 0 }), - Cursor::Lattice { x, y } => felt_target(*x, *y), + Cursor::Lattice { x, y } => target_at(*x, *y), Cursor::Dozen(index) => Some(BetTarget::Dozen { index: *index as u8, }), @@ -171,7 +171,11 @@ fn cell_number(column: usize, row: usize) -> Option { } /// Bet under a point of the fine grid. -fn felt_target(x: usize, y: usize) -> Option { +/// +/// Both frontends use this: the terminal moves a cursor over the lattice, and +/// the GUI snaps a mouse position to it. Odd coordinates are cell centres, +/// even coordinates are the lines where a split or corner chip sits. +pub fn target_at(x: usize, y: usize) -> Option { if !(1..=FELT_X_MAX).contains(&x) || !(1..=FELT_Y_MAX).contains(&y) { return None; } diff --git a/src/roulette.rs b/src/roulette.rs index 6b73c2a..b697e31 100644 --- a/src/roulette.rs +++ b/src/roulette.rs @@ -226,6 +226,16 @@ impl Bets { } } + /// Take `amount` back from one target, dropping the chip when it empties. + /// + /// This is the felt undo: a right or middle click removes one chip. + pub fn reduce(&mut self, target: &BetTarget, amount: u64) { + if let Some(chip) = self.chips.iter_mut().find(|chip| &chip.target == target) { + chip.amount = chip.amount.saturating_sub(amount); + } + self.chips.retain(|chip| chip.amount > 0); + } + /// Total returned for a winning number, including returned stakes. pub fn winnings(&self, number: u8) -> u64 { self.chips.iter().fold(0u64, |total, chip| { diff --git a/tests/roulette.rs b/tests/roulette.rs index 0c5e195..970ab6a 100644 --- a/tests/roulette.rs +++ b/tests/roulette.rs @@ -396,3 +396,23 @@ fn rejects_bad_roulette_results() { assert!(parse_roulette_response("ROULETTE_RESULT s 1 1 1 17 black nope").is_err()); assert!(parse_roulette_response("").is_err()); } + +#[test] +fn chips_can_be_taken_back_one_at_a_time() { + let mut bets = Bets::default(); + bets.add(BetTarget::Straight { number: 17 }, 50); + bets.add(BetTarget::Outside { kind: Outside::Red }, 10); + + bets.reduce(&BetTarget::Straight { number: 17 }, 25); + assert_eq!(bets.amount_on(&BetTarget::Straight { number: 17 }), 25); + assert_eq!(bets.total(), 35); + + // Reducing past zero drops the chip instead of going negative. + bets.reduce(&BetTarget::Straight { number: 17 }, 100); + assert_eq!(bets.amount_on(&BetTarget::Straight { number: 17 }), 0); + assert_eq!(bets.total(), 10); + + // A target with no chip is left alone. + bets.reduce(&BetTarget::Dozen { index: 0 }, 25); + assert_eq!(bets.total(), 10); +} -- cgit v1.2.3