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. --- gui/src/app.rs | 253 ++++++++++++++++++++++++++++++++++----------------------- 1 file changed, 153 insertions(+), 100 deletions(-) (limited to 'gui/src/app.rs') 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 -- cgit v1.2.3