//! 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; 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; 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); 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, !spinning).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_4); let actions = ui.available_width(); let clear_width = 108.0; ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = theme::SPACE_2; 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(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(format!("{} chips", demo.bets.chips.len())) .font(theme::mono(10.0)) .color(theme::TEXT_FAINT), ); }); } _ => { 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), ); }); } }); } /// 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( 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, 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(); if selected || response.hovered() { painter.circle_stroke( center, radius + 3.0, Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT), ); } if enabled { roulette::paint_chip(painter, center, radius, amount); } else { roulette::paint_chip_preview(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; let placing = demo.phase != RoulettePhase::Spinning; ui.add_space(top_pad); ui.horizontal(|ui| { ui.add_space(side_pad); 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); } _ => {} } }); 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 to place · right or middle click takes back · C clears · 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, chip: u64, phase: RoulettePhase, number: Option, rotation: f32, spin: Option, spins: usize, last: Vec, winnings: Option, spent: u64, 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, chip: 25, phase: RoulettePhase::Placing, number: None, rotation: 0.0, spin: None, spins: 0, last: Vec::new(), winnings: None, spent: 0, 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: Cursor::default(), phase: self.phase, number: self.number, color: self.number.map(|number| wheel_color(number).to_string()), 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; 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.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; } if ctx.memory(|memory| memory.focused().is_none()) { 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 || 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 .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(); } }