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/app.rs | 587 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 587 insertions(+) create mode 100644 gui/src/app.rs (limited to 'gui/src/app.rs') diff --git a/gui/src/app.rs b/gui/src/app.rs new file mode 100644 index 0000000..fde117a --- /dev/null +++ b/gui/src/app.rs @@ -0,0 +1,587 @@ +//! The desktop shell: header, roulette table, and betting sidebar. +//! +//! Phase 3 is a style spike. The table is painted from a demo `RouletteView` +//! built with the core's own types, but no command is sent yet: Phase 4 wires +//! the transport and replaces the demo state with `ClientCore`. + +use std::f32::consts::TAU; + +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::view::{RoulettePhase, RouletteView}; +use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color}; +use zagan_bet::version::VERSION; + +use crate::{roulette, theme}; + +const HEADER_HEIGHT: f32 = 64.0; +const SIDEBAR_WIDTH: f32 = 336.0; +const SPIN_SECONDS: f64 = 2.4; +/// Demo winners, cycled: a red, a zero, a red, a black, a black, a black. +const DEMO_TARGETS: [u8; 6] = [17, 0, 32, 26, 8, 11]; +const CHIP_SIZES: [u64; 4] = [10, 25, 50, 100]; + +pub struct ZaganApp { + demo: Demo, +} + +impl ZaganApp { + pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + theme::install(&cc.egui_ctx); + Self { demo: Demo::new() } + } +} + +impl eframe::App for ZaganApp { + fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { + self.demo.tick(ui.ctx()); + self.demo.keys(ui.ctx()); + + Panel::top("header") + .exact_size(HEADER_HEIGHT) + .frame( + Frame::new() + .fill(theme::SURFACE) + .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 0)), + ) + .show(ui, |ui| header(ui, &self.demo)); + + Panel::right("sidebar") + .exact_size(SIDEBAR_WIDTH) + .frame( + Frame::new() + .fill(theme::SURFACE) + .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 20)), + ) + .show(ui, |ui| { + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| sidebar(ui, &mut self.demo)); + }); + + CentralPanel::default() + .frame( + Frame::new() + .fill(theme::BG) + .inner_margin(Margin::same(theme::SPACE_5 as i8)), + ) + .show(ui, |ui| table(ui, &mut self.demo)); + } +} + +// --------------------------------------------------------------------------- +// Header +// --------------------------------------------------------------------------- + +fn header(ui: &mut Ui, demo: &Demo) { + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + let (mark, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); + ui.painter().rect_filled(mark, theme::RADIUS_SM, theme::WIN); + ui.add_space(theme::SPACE_2); + ui.label( + RichText::new("ZAGAN") + .font(theme::mono(18.0)) + .color(theme::TEXT), + ); + ui.label( + RichText::new("BET") + .font(theme::mono(18.0)) + .color(theme::WIN), + ); + ui.add_space(theme::SPACE_3); + pill(ui, &format!("v{VERSION}"), theme::TEXT_FAINT); + ui.add_space(theme::SPACE_1); + pill(ui, "PREVIEW", theme::GOLD); + ui.add_space(theme::SPACE_5); + connection_pill(ui, demo); + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + stat_block( + ui, + 150.0, + "Balance", + &theme::money(demo.balance), + theme::mono(20.0), + theme::TEXT, + None, + ); + ui.add_space(theme::SPACE_5); + stat_block( + ui, + 190.0, + "Account", + demo.user, + theme::sans(theme::FONT_BODY), + theme::TEXT, + Some(demo.account), + ); + }); + }); +} + +fn pill(ui: &mut Ui, text: &str, color: Color32) { + Frame::new() + .fill(theme::SURFACE_RAISED) + .corner_radius(theme::RADIUS_SM) + .inner_margin(Margin::symmetric(theme::SPACE_2 as i8, 3)) + .show(ui, |ui| { + ui.label(RichText::new(text).font(theme::mono(10.0)).color(color)); + }); +} + +fn connection_pill(ui: &mut Ui, demo: &Demo) { + Frame::new() + .fill(theme::SURFACE_RAISED) + .corner_radius(theme::RADIUS_SM) + .inner_margin(Margin::symmetric(theme::SPACE_3 as i8, 5)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let (dot, _) = ui.allocate_exact_size(Vec2::splat(8.0), Sense::hover()); + ui.painter().circle_filled(dot.center(), 3.0, theme::WIN); + ui.label( + RichText::new("ONLINE") + .font(theme::mono(10.0)) + .color(theme::WIN), + ); + ui.label( + RichText::new(demo.host) + .font(theme::mono(12.0)) + .color(theme::TEXT_DIM), + ); + ui.label( + RichText::new(format!("{} ms", demo.ping_ms)) + .font(theme::mono(12.0)) + .color(theme::TEXT_FAINT), + ); + }); + }); +} + +/// A label over a value, right-aligned in a fixed slot so the header's right +/// edge stays put as the numbers change. +fn stat_block( + ui: &mut Ui, + width: f32, + label: &str, + value: &str, + value_font: egui::FontId, + value_color: Color32, + detail: Option<&str>, +) { + ui.allocate_ui_with_layout(vec2(width, 44.0), Layout::top_down(Align::Max), |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.label(theme::label(label)); + ui.label(RichText::new(value).font(value_font).color(value_color)); + if let Some(detail) = detail { + ui.label( + RichText::new(detail) + .font(theme::mono(10.0)) + .color(theme::TEXT_FAINT), + ); + } + }); +} + +// --------------------------------------------------------------------------- +// Sidebar +// --------------------------------------------------------------------------- + +fn sidebar(ui: &mut Ui, demo: &mut Demo) { + ui.label( + RichText::new("Roulette") + .font(theme::sans(22.0)) + .color(theme::TEXT), + ); + ui.label( + RichText::new("European · single zero") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + + ui.add_space(theme::SPACE_4); + let view = demo.view(); + 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); + roulette::wheel(ui, wheel_size, &view, demo.rotation); + }); + ui.add_space(theme::SPACE_1); + result_line(ui, demo); + + 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); + 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() { + demo.chip = amount; + } + } + }); + + ui.add_space(theme::SPACE_3); + ui.label(theme::label("Bets")); + ui.add_space(theme::SPACE_1); + if demo.bets.chips.is_empty() { + ui.label( + RichText::new("No chips on the felt") + .font(theme::sans(12.0)) + .color(theme::TEXT_FAINT), + ); + } else { + for chip in &demo.bets.chips { + bet_row(ui, &roulette::target_name(&chip.target), chip.amount); + } + } + + ui.add_space(theme::SPACE_3); + ui.separator(); + 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_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(); + } +} + +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.label( + 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), + ); + }); + }); +} + +fn bet_row(ui: &mut Ui, name: &str, amount: u64) { + ui.horizontal(|ui| { + ui.label( + RichText::new(name) + .font(theme::sans(13.0)) + .color(theme::TEXT_DIM), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(theme::money(amount as i64)) + .font(theme::mono(13.0)) + .color(theme::GOLD), + ); + }); + }); +} + +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()); + let center = rect.center(); + let radius = 20.0; + let painter = ui.painter(); + if selected || response.hovered() { + painter.circle_stroke( + center, + radius + 3.0, + Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT), + ); + } + roulette::paint_chip(painter, center, radius, amount); + response +} + +fn color_of(number: u8) -> Color32 { + match wheel_color(number) { + "red" => theme::ROULETTE_RED, + "green" => theme::WIN, + _ => theme::TEXT, + } +} + +// --------------------------------------------------------------------------- +// Table +// --------------------------------------------------------------------------- + +fn table(ui: &mut Ui, demo: &mut Demo) { + ui.horizontal(|ui| { + ui.label( + RichText::new("Table 01") + .font(theme::sans(15.0)) + .color(theme::TEXT), + ); + ui.label( + RichText::new("European · single zero") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new("Limits 10 – 500 Kr") + .font(theme::mono(12.0)) + .color(theme::TEXT_FAINT), + ); + }); + }); + ui.add_space(theme::SPACE_4); + + let view = demo.view(); + let strip_height = 104.0; + let available = ui.available_size(); + let size = roulette::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); + let chip = demo.chip; + 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); + } + }); + + ui.add_space(theme::SPACE_4); + ui.horizontal(|ui| { + ui.label(theme::label("Last")); + if demo.last.is_empty() { + ui.label( + RichText::new("No spins yet") + .font(theme::sans(12.0)) + .color(theme::TEXT_FAINT), + ); + } + for number in &demo.last { + let (dot, _) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::hover()); + roulette::paint_result_dot(ui.painter(), dot.center(), 11.0, *number); + ui.add_space(theme::SPACE_1); + } + }); + ui.add_space(theme::SPACE_2); + ui.label( + RichText::new( + "Click a pocket to place a chip · arrow keys move the cursor · space spins", + ) + .font(theme::sans(12.0)) + .color(theme::TEXT_FAINT), + ); +} + +// --------------------------------------------------------------------------- +// Demo state +// --------------------------------------------------------------------------- + +struct Spin { + started: f64, + from: f32, + delta: f32, + target: u8, +} + +struct Demo { + bets: Bets, + cursor: Cursor, + chip: u64, + phase: RoulettePhase, + number: Option, + rotation: f32, + spin: Option, + spins: usize, + last: Vec, + balance: i64, + host: &'static str, + ping_ms: u32, + user: &'static str, + account: &'static str, +} + +impl Demo { + fn new() -> Self { + let mut bets = Bets::default(); + bets.add(BetTarget::Straight { number: 17 }, 50); + bets.add( + BetTarget::Corner { + numbers: [17, 18, 20, 21], + }, + 25, + ); + bets.add(BetTarget::Outside { kind: Outside::Red }, 25); + + 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, + rotation: 0.0, + spin: None, + spins: 0, + last: Vec::new(), + balance: 1000, + host: "127.0.0.1:11525", + ping_ms: 41, + user: "alice", + account: "#4161673306", + } + } + + fn view(&self) -> RouletteView { + RouletteView { + bet_amount: 10, + balance: Some(self.balance), + chip: self.chip, + bets: self.bets.clone(), + cursor: self.cursor, + phase: self.phase, + number: self.number, + color: self.number.map(|number| wheel_color(number).to_string()), + winnings: None, + error: None, + } + } + + fn tick(&mut self, ctx: &Context) { + let now = ctx.input(|input| input.time); + let mut finished = None; + if let Some(spin) = &self.spin { + let t = (((now - spin.started) / SPIN_SECONDS) 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 { + finished = Some(spin.target); + } + ctx.request_repaint(); + } + if let Some(number) = finished { + self.spin = None; + self.phase = RoulettePhase::Result; + self.number = Some(number); + self.last.insert(0, number); + self.last.truncate(12); + } + } + + 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); + } + } + } + + fn start_spin(&mut self, ctx: &Context) { + if self.phase == RoulettePhase::Spinning { + return; + } + let target = DEMO_TARGETS[self.spins % DEMO_TARGETS.len()]; + self.spins += 1; + + 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(Spin { + started: ctx.input(|input| input.time), + from: self.rotation, + delta, + target, + }); + self.phase = RoulettePhase::Spinning; + self.number = None; + ctx.request_repaint(); + } +} -- cgit v1.2.3