//! 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(); } }