From bf4cf78ab26896faf457273e37d30fc96381c4a4 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 21:09:56 +0100 Subject: v4.2.0: wire the GUI to the core, transport and all four games Phase 4. The egui frontend now shares ClientCore and the client database with the terminal: one background worker owns TLS, trust answers go through a channel, and a 10 s request deadline mirrors the terminal socket timeout. First contact, a changed key and a version mismatch are explicit screens; login can remember the credential and auto-login signs in before the login form appears. Adds the lobby with an update notice, a full-width scrollable history table, and the four games painted from View: slots with reel animation and win lines that stop at the last matching reel, roulette with the hover lattice and wheel spin, blackjack with vector-drawn suits, and a smooth crash curve. Sidebars pin their action block; leaving a live hand or round asks before forfeiting. The header balance holds the staked amount while the reels or wheel turn and only shows the payout when they stop. Refused spins cancel the animation. Live crash multipliers keep full precision for the chart; the terminal still displays two decimals. Verified live against a 4.2.0 server: register, login, auto-login, trust, all four games settling, history (50 rows), logout, disconnect, forfeit confirmation, and the version-mismatch screen. Release: 8.3 MB Linux, 6.8 MB Windows, 61 ms to window, 131 MB idle RSS. --- gui/src/roulette.rs | 431 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 431 insertions(+) (limited to 'gui/src/roulette.rs') diff --git a/gui/src/roulette.rs b/gui/src/roulette.rs index f674251..c791693 100644 --- a/gui/src/roulette.rs +++ b/gui/src/roulette.rs @@ -553,3 +553,434 @@ pub fn paint_result_dot(painter: &Painter, center: Pos2, radius: f32, number: u8 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, + } +} -- cgit v1.2.3