diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 19:58:59 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 19:58:59 +0100 |
| commit | 10ff558853f549d3f3c7c8432488bfad9da6973a (patch) | |
| tree | 271b05fead384e2d91e5c8332fea9d00b540f0cc /gui/src | |
| parent | v4.0.7: finish the Phase 1 boundary with a command shell (diff) | |
| download | zagan-bet-10ff558853f549d3f3c7c8432488bfad9da6973a.tar.gz zagan-bet-10ff558853f549d3f3c7c8432488bfad9da6973a.zip | |
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.
Diffstat (limited to '')
| -rw-r--r-- | gui/src/app.rs | 587 | ||||
| -rw-r--r-- | gui/src/lib.rs | 9 | ||||
| -rw-r--r-- | gui/src/main.rs | 25 | ||||
| -rw-r--r-- | gui/src/roulette.rs | 522 | ||||
| -rw-r--r-- | gui/src/theme.rs | 211 |
5 files changed, 1354 insertions, 0 deletions
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 @@ | |||
| 1 | //! The desktop shell: header, roulette table, and betting sidebar. | ||
| 2 | //! | ||
| 3 | //! Phase 3 is a style spike. The table is painted from a demo `RouletteView` | ||
| 4 | //! built with the core's own types, but no command is sent yet: Phase 4 wires | ||
| 5 | //! the transport and replaces the demo state with `ClientCore`. | ||
| 6 | |||
| 7 | use std::f32::consts::TAU; | ||
| 8 | |||
| 9 | use eframe::egui::{ | ||
| 10 | self, Align, Button, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel, | ||
| 11 | RichText, Sense, Stroke, Ui, Vec2, vec2, | ||
| 12 | }; | ||
| 13 | use zagan_bet::client::games::roulette::{Cursor, Direction}; | ||
| 14 | use zagan_bet::client::view::{RoulettePhase, RouletteView}; | ||
| 15 | use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color}; | ||
| 16 | use zagan_bet::version::VERSION; | ||
| 17 | |||
| 18 | use crate::{roulette, theme}; | ||
| 19 | |||
| 20 | const HEADER_HEIGHT: f32 = 64.0; | ||
| 21 | const SIDEBAR_WIDTH: f32 = 336.0; | ||
| 22 | const SPIN_SECONDS: f64 = 2.4; | ||
| 23 | /// Demo winners, cycled: a red, a zero, a red, a black, a black, a black. | ||
| 24 | const DEMO_TARGETS: [u8; 6] = [17, 0, 32, 26, 8, 11]; | ||
| 25 | const CHIP_SIZES: [u64; 4] = [10, 25, 50, 100]; | ||
| 26 | |||
| 27 | pub struct ZaganApp { | ||
| 28 | demo: Demo, | ||
| 29 | } | ||
| 30 | |||
| 31 | impl ZaganApp { | ||
| 32 | pub fn new(cc: &eframe::CreationContext<'_>) -> Self { | ||
| 33 | theme::install(&cc.egui_ctx); | ||
| 34 | Self { demo: Demo::new() } | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | impl eframe::App for ZaganApp { | ||
| 39 | fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { | ||
| 40 | self.demo.tick(ui.ctx()); | ||
| 41 | self.demo.keys(ui.ctx()); | ||
| 42 | |||
| 43 | Panel::top("header") | ||
| 44 | .exact_size(HEADER_HEIGHT) | ||
| 45 | .frame( | ||
| 46 | Frame::new() | ||
| 47 | .fill(theme::SURFACE) | ||
| 48 | .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 0)), | ||
| 49 | ) | ||
| 50 | .show(ui, |ui| header(ui, &self.demo)); | ||
| 51 | |||
| 52 | Panel::right("sidebar") | ||
| 53 | .exact_size(SIDEBAR_WIDTH) | ||
| 54 | .frame( | ||
| 55 | Frame::new() | ||
| 56 | .fill(theme::SURFACE) | ||
| 57 | .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 20)), | ||
| 58 | ) | ||
| 59 | .show(ui, |ui| { | ||
| 60 | egui::ScrollArea::vertical() | ||
| 61 | .auto_shrink([false, false]) | ||
| 62 | .show(ui, |ui| sidebar(ui, &mut self.demo)); | ||
| 63 | }); | ||
| 64 | |||
| 65 | CentralPanel::default() | ||
| 66 | .frame( | ||
| 67 | Frame::new() | ||
| 68 | .fill(theme::BG) | ||
| 69 | .inner_margin(Margin::same(theme::SPACE_5 as i8)), | ||
| 70 | ) | ||
| 71 | .show(ui, |ui| table(ui, &mut self.demo)); | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | // --------------------------------------------------------------------------- | ||
| 76 | // Header | ||
| 77 | // --------------------------------------------------------------------------- | ||
| 78 | |||
| 79 | fn header(ui: &mut Ui, demo: &Demo) { | ||
| 80 | ui.with_layout(Layout::left_to_right(Align::Center), |ui| { | ||
| 81 | let (mark, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); | ||
| 82 | ui.painter().rect_filled(mark, theme::RADIUS_SM, theme::WIN); | ||
| 83 | ui.add_space(theme::SPACE_2); | ||
| 84 | ui.label( | ||
| 85 | RichText::new("ZAGAN") | ||
| 86 | .font(theme::mono(18.0)) | ||
| 87 | .color(theme::TEXT), | ||
| 88 | ); | ||
| 89 | ui.label( | ||
| 90 | RichText::new("BET") | ||
| 91 | .font(theme::mono(18.0)) | ||
| 92 | .color(theme::WIN), | ||
| 93 | ); | ||
| 94 | ui.add_space(theme::SPACE_3); | ||
| 95 | pill(ui, &format!("v{VERSION}"), theme::TEXT_FAINT); | ||
| 96 | ui.add_space(theme::SPACE_1); | ||
| 97 | pill(ui, "PREVIEW", theme::GOLD); | ||
| 98 | ui.add_space(theme::SPACE_5); | ||
| 99 | connection_pill(ui, demo); | ||
| 100 | |||
| 101 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 102 | stat_block( | ||
| 103 | ui, | ||
| 104 | 150.0, | ||
| 105 | "Balance", | ||
| 106 | &theme::money(demo.balance), | ||
| 107 | theme::mono(20.0), | ||
| 108 | theme::TEXT, | ||
| 109 | None, | ||
| 110 | ); | ||
| 111 | ui.add_space(theme::SPACE_5); | ||
| 112 | stat_block( | ||
| 113 | ui, | ||
| 114 | 190.0, | ||
| 115 | "Account", | ||
| 116 | demo.user, | ||
| 117 | theme::sans(theme::FONT_BODY), | ||
| 118 | theme::TEXT, | ||
| 119 | Some(demo.account), | ||
| 120 | ); | ||
| 121 | }); | ||
| 122 | }); | ||
| 123 | } | ||
| 124 | |||
| 125 | fn pill(ui: &mut Ui, text: &str, color: Color32) { | ||
| 126 | Frame::new() | ||
| 127 | .fill(theme::SURFACE_RAISED) | ||
| 128 | .corner_radius(theme::RADIUS_SM) | ||
| 129 | .inner_margin(Margin::symmetric(theme::SPACE_2 as i8, 3)) | ||
| 130 | .show(ui, |ui| { | ||
| 131 | ui.label(RichText::new(text).font(theme::mono(10.0)).color(color)); | ||
| 132 | }); | ||
| 133 | } | ||
| 134 | |||
| 135 | fn connection_pill(ui: &mut Ui, demo: &Demo) { | ||
| 136 | Frame::new() | ||
| 137 | .fill(theme::SURFACE_RAISED) | ||
| 138 | .corner_radius(theme::RADIUS_SM) | ||
| 139 | .inner_margin(Margin::symmetric(theme::SPACE_3 as i8, 5)) | ||
| 140 | .show(ui, |ui| { | ||
| 141 | ui.horizontal(|ui| { | ||
| 142 | let (dot, _) = ui.allocate_exact_size(Vec2::splat(8.0), Sense::hover()); | ||
| 143 | ui.painter().circle_filled(dot.center(), 3.0, theme::WIN); | ||
| 144 | ui.label( | ||
| 145 | RichText::new("ONLINE") | ||
| 146 | .font(theme::mono(10.0)) | ||
| 147 | .color(theme::WIN), | ||
| 148 | ); | ||
| 149 | ui.label( | ||
| 150 | RichText::new(demo.host) | ||
| 151 | .font(theme::mono(12.0)) | ||
| 152 | .color(theme::TEXT_DIM), | ||
| 153 | ); | ||
| 154 | ui.label( | ||
| 155 | RichText::new(format!("{} ms", demo.ping_ms)) | ||
| 156 | .font(theme::mono(12.0)) | ||
| 157 | .color(theme::TEXT_FAINT), | ||
| 158 | ); | ||
| 159 | }); | ||
| 160 | }); | ||
| 161 | } | ||
| 162 | |||
| 163 | /// A label over a value, right-aligned in a fixed slot so the header's right | ||
| 164 | /// edge stays put as the numbers change. | ||
| 165 | fn stat_block( | ||
| 166 | ui: &mut Ui, | ||
| 167 | width: f32, | ||
| 168 | label: &str, | ||
| 169 | value: &str, | ||
| 170 | value_font: egui::FontId, | ||
| 171 | value_color: Color32, | ||
| 172 | detail: Option<&str>, | ||
| 173 | ) { | ||
| 174 | ui.allocate_ui_with_layout(vec2(width, 44.0), Layout::top_down(Align::Max), |ui| { | ||
| 175 | ui.spacing_mut().item_spacing.y = 0.0; | ||
| 176 | ui.label(theme::label(label)); | ||
| 177 | ui.label(RichText::new(value).font(value_font).color(value_color)); | ||
| 178 | if let Some(detail) = detail { | ||
| 179 | ui.label( | ||
| 180 | RichText::new(detail) | ||
| 181 | .font(theme::mono(10.0)) | ||
| 182 | .color(theme::TEXT_FAINT), | ||
| 183 | ); | ||
| 184 | } | ||
| 185 | }); | ||
| 186 | } | ||
| 187 | |||
| 188 | // --------------------------------------------------------------------------- | ||
| 189 | // Sidebar | ||
| 190 | // --------------------------------------------------------------------------- | ||
| 191 | |||
| 192 | fn sidebar(ui: &mut Ui, demo: &mut Demo) { | ||
| 193 | ui.label( | ||
| 194 | RichText::new("Roulette") | ||
| 195 | .font(theme::sans(22.0)) | ||
| 196 | .color(theme::TEXT), | ||
| 197 | ); | ||
| 198 | ui.label( | ||
| 199 | RichText::new("European · single zero") | ||
| 200 | .font(theme::sans(12.0)) | ||
| 201 | .color(theme::TEXT_DIM), | ||
| 202 | ); | ||
| 203 | |||
| 204 | ui.add_space(theme::SPACE_4); | ||
| 205 | let view = demo.view(); | ||
| 206 | let wheel_size = ui.available_width().min(210.0); | ||
| 207 | ui.horizontal(|ui| { | ||
| 208 | let pad = ((ui.available_width() - wheel_size) * 0.5).max(0.0); | ||
| 209 | ui.add_space(pad); | ||
| 210 | roulette::wheel(ui, wheel_size, &view, demo.rotation); | ||
| 211 | }); | ||
| 212 | ui.add_space(theme::SPACE_1); | ||
| 213 | result_line(ui, demo); | ||
| 214 | |||
| 215 | ui.add_space(theme::SPACE_3); | ||
| 216 | ui.separator(); | ||
| 217 | ui.add_space(theme::SPACE_3); | ||
| 218 | |||
| 219 | ui.label(theme::label("Chip")); | ||
| 220 | ui.add_space(theme::SPACE_2); | ||
| 221 | ui.horizontal(|ui| { | ||
| 222 | ui.spacing_mut().item_spacing.x = theme::SPACE_2; | ||
| 223 | for amount in CHIP_SIZES { | ||
| 224 | if chip_button(ui, amount, demo.chip == amount).clicked() { | ||
| 225 | demo.chip = amount; | ||
| 226 | } | ||
| 227 | } | ||
| 228 | }); | ||
| 229 | |||
| 230 | ui.add_space(theme::SPACE_3); | ||
| 231 | ui.label(theme::label("Bets")); | ||
| 232 | ui.add_space(theme::SPACE_1); | ||
| 233 | if demo.bets.chips.is_empty() { | ||
| 234 | ui.label( | ||
| 235 | RichText::new("No chips on the felt") | ||
| 236 | .font(theme::sans(12.0)) | ||
| 237 | .color(theme::TEXT_FAINT), | ||
| 238 | ); | ||
| 239 | } else { | ||
| 240 | for chip in &demo.bets.chips { | ||
| 241 | bet_row(ui, &roulette::target_name(&chip.target), chip.amount); | ||
| 242 | } | ||
| 243 | } | ||
| 244 | |||
| 245 | ui.add_space(theme::SPACE_3); | ||
| 246 | ui.separator(); | ||
| 247 | ui.add_space(theme::SPACE_2); | ||
| 248 | bet_row(ui, "Total", demo.bets.total()); | ||
| 249 | |||
| 250 | ui.add_space(theme::SPACE_3); | ||
| 251 | let spinning = demo.phase == RoulettePhase::Spinning; | ||
| 252 | let spin = Button::new( | ||
| 253 | RichText::new("SPIN") | ||
| 254 | .font(theme::mono(theme::FONT_MONO)) | ||
| 255 | .color(theme::BG), | ||
| 256 | ) | ||
| 257 | .fill(if spinning { | ||
| 258 | theme::WIN.gamma_multiply(0.45) | ||
| 259 | } else { | ||
| 260 | theme::WIN | ||
| 261 | }) | ||
| 262 | .corner_radius(theme::RADIUS_MD) | ||
| 263 | .min_size(vec2(ui.available_width(), 44.0)); | ||
| 264 | if ui.add_enabled(!spinning, spin).clicked() { | ||
| 265 | demo.start_spin(ui.ctx()); | ||
| 266 | } | ||
| 267 | |||
| 268 | ui.add_space(theme::SPACE_2); | ||
| 269 | let clear = Button::new( | ||
| 270 | RichText::new("Clear bets") | ||
| 271 | .font(theme::sans(12.0)) | ||
| 272 | .color(theme::TEXT_DIM), | ||
| 273 | ) | ||
| 274 | .frame(false); | ||
| 275 | if ui | ||
| 276 | .add_enabled(!demo.bets.is_empty() && !spinning, clear) | ||
| 277 | .clicked() | ||
| 278 | { | ||
| 279 | demo.bets = Bets::default(); | ||
| 280 | } | ||
| 281 | } | ||
| 282 | |||
| 283 | fn result_line(ui: &mut Ui, demo: &Demo) { | ||
| 284 | ui.horizontal(|ui| { | ||
| 285 | match (demo.phase, demo.number) { | ||
| 286 | (RoulettePhase::Result, Some(number)) => { | ||
| 287 | ui.label(theme::label("Result")); | ||
| 288 | ui.label( | ||
| 289 | RichText::new(number.to_string()) | ||
| 290 | .font(theme::mono(15.0)) | ||
| 291 | .color(color_of(number)), | ||
| 292 | ); | ||
| 293 | ui.label( | ||
| 294 | RichText::new(wheel_color(number).to_uppercase()) | ||
| 295 | .font(theme::mono(12.0)) | ||
| 296 | .color(theme::TEXT_DIM), | ||
| 297 | ); | ||
| 298 | } | ||
| 299 | (RoulettePhase::Spinning, _) => { | ||
| 300 | ui.label( | ||
| 301 | RichText::new("SPINNING…") | ||
| 302 | .font(theme::mono(12.0)) | ||
| 303 | .color(theme::GOLD), | ||
| 304 | ); | ||
| 305 | } | ||
| 306 | _ => { | ||
| 307 | ui.label( | ||
| 308 | RichText::new("Place your bets") | ||
| 309 | .font(theme::sans(12.0)) | ||
| 310 | .color(theme::TEXT_DIM), | ||
| 311 | ); | ||
| 312 | } | ||
| 313 | } | ||
| 314 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 315 | ui.label( | ||
| 316 | RichText::new(format!("{} chips", demo.bets.chips.len())) | ||
| 317 | .font(theme::mono(10.0)) | ||
| 318 | .color(theme::TEXT_FAINT), | ||
| 319 | ); | ||
| 320 | }); | ||
| 321 | }); | ||
| 322 | } | ||
| 323 | |||
| 324 | fn bet_row(ui: &mut Ui, name: &str, amount: u64) { | ||
| 325 | ui.horizontal(|ui| { | ||
| 326 | ui.label( | ||
| 327 | RichText::new(name) | ||
| 328 | .font(theme::sans(13.0)) | ||
| 329 | .color(theme::TEXT_DIM), | ||
| 330 | ); | ||
| 331 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 332 | ui.label( | ||
| 333 | RichText::new(theme::money(amount as i64)) | ||
| 334 | .font(theme::mono(13.0)) | ||
| 335 | .color(theme::GOLD), | ||
| 336 | ); | ||
| 337 | }); | ||
| 338 | }); | ||
| 339 | } | ||
| 340 | |||
| 341 | fn chip_button(ui: &mut Ui, amount: u64, selected: bool) -> egui::Response { | ||
| 342 | let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), Sense::click()); | ||
| 343 | let center = rect.center(); | ||
| 344 | let radius = 20.0; | ||
| 345 | let painter = ui.painter(); | ||
| 346 | if selected || response.hovered() { | ||
| 347 | painter.circle_stroke( | ||
| 348 | center, | ||
| 349 | radius + 3.0, | ||
| 350 | Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT), | ||
| 351 | ); | ||
| 352 | } | ||
| 353 | roulette::paint_chip(painter, center, radius, amount); | ||
| 354 | response | ||
| 355 | } | ||
| 356 | |||
| 357 | fn color_of(number: u8) -> Color32 { | ||
| 358 | match wheel_color(number) { | ||
| 359 | "red" => theme::ROULETTE_RED, | ||
| 360 | "green" => theme::WIN, | ||
| 361 | _ => theme::TEXT, | ||
| 362 | } | ||
| 363 | } | ||
| 364 | |||
| 365 | // --------------------------------------------------------------------------- | ||
| 366 | // Table | ||
| 367 | // --------------------------------------------------------------------------- | ||
| 368 | |||
| 369 | fn table(ui: &mut Ui, demo: &mut Demo) { | ||
| 370 | ui.horizontal(|ui| { | ||
| 371 | ui.label( | ||
| 372 | RichText::new("Table 01") | ||
| 373 | .font(theme::sans(15.0)) | ||
| 374 | .color(theme::TEXT), | ||
| 375 | ); | ||
| 376 | ui.label( | ||
| 377 | RichText::new("European · single zero") | ||
| 378 | .font(theme::sans(12.0)) | ||
| 379 | .color(theme::TEXT_DIM), | ||
| 380 | ); | ||
| 381 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 382 | ui.label( | ||
| 383 | RichText::new("Limits 10 – 500 Kr") | ||
| 384 | .font(theme::mono(12.0)) | ||
| 385 | .color(theme::TEXT_FAINT), | ||
| 386 | ); | ||
| 387 | }); | ||
| 388 | }); | ||
| 389 | ui.add_space(theme::SPACE_4); | ||
| 390 | |||
| 391 | let view = demo.view(); | ||
| 392 | let strip_height = 104.0; | ||
| 393 | let available = ui.available_size(); | ||
| 394 | let size = roulette::size_for(vec2(available.x, (available.y - strip_height).max(220.0))); | ||
| 395 | let side_pad = ((available.x - size.x) * 0.5).max(0.0); | ||
| 396 | let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0); | ||
| 397 | let chip = demo.chip; | ||
| 398 | ui.add_space(top_pad); | ||
| 399 | ui.horizontal(|ui| { | ||
| 400 | ui.add_space(side_pad); | ||
| 401 | if let Some(target) = roulette::felt(ui, size, &view) { | ||
| 402 | demo.bets.add(target, chip); | ||
| 403 | } | ||
| 404 | }); | ||
| 405 | |||
| 406 | ui.add_space(theme::SPACE_4); | ||
| 407 | ui.horizontal(|ui| { | ||
| 408 | ui.label(theme::label("Last")); | ||
| 409 | if demo.last.is_empty() { | ||
| 410 | ui.label( | ||
| 411 | RichText::new("No spins yet") | ||
| 412 | .font(theme::sans(12.0)) | ||
| 413 | .color(theme::TEXT_FAINT), | ||
| 414 | ); | ||
| 415 | } | ||
| 416 | for number in &demo.last { | ||
| 417 | let (dot, _) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::hover()); | ||
| 418 | roulette::paint_result_dot(ui.painter(), dot.center(), 11.0, *number); | ||
| 419 | ui.add_space(theme::SPACE_1); | ||
| 420 | } | ||
| 421 | }); | ||
| 422 | ui.add_space(theme::SPACE_2); | ||
| 423 | ui.label( | ||
| 424 | RichText::new( | ||
| 425 | "Click a pocket to place a chip · arrow keys move the cursor · space spins", | ||
| 426 | ) | ||
| 427 | .font(theme::sans(12.0)) | ||
| 428 | .color(theme::TEXT_FAINT), | ||
| 429 | ); | ||
| 430 | } | ||
| 431 | |||
| 432 | // --------------------------------------------------------------------------- | ||
| 433 | // Demo state | ||
| 434 | // --------------------------------------------------------------------------- | ||
| 435 | |||
| 436 | struct Spin { | ||
| 437 | started: f64, | ||
| 438 | from: f32, | ||
| 439 | delta: f32, | ||
| 440 | target: u8, | ||
| 441 | } | ||
| 442 | |||
| 443 | struct Demo { | ||
| 444 | bets: Bets, | ||
| 445 | cursor: Cursor, | ||
| 446 | chip: u64, | ||
| 447 | phase: RoulettePhase, | ||
| 448 | number: Option<u8>, | ||
| 449 | rotation: f32, | ||
| 450 | spin: Option<Spin>, | ||
| 451 | spins: usize, | ||
| 452 | last: Vec<u8>, | ||
| 453 | balance: i64, | ||
| 454 | host: &'static str, | ||
| 455 | ping_ms: u32, | ||
| 456 | user: &'static str, | ||
| 457 | account: &'static str, | ||
| 458 | } | ||
| 459 | |||
| 460 | impl Demo { | ||
| 461 | fn new() -> Self { | ||
| 462 | let mut bets = Bets::default(); | ||
| 463 | bets.add(BetTarget::Straight { number: 17 }, 50); | ||
| 464 | bets.add( | ||
| 465 | BetTarget::Corner { | ||
| 466 | numbers: [17, 18, 20, 21], | ||
| 467 | }, | ||
| 468 | 25, | ||
| 469 | ); | ||
| 470 | bets.add(BetTarget::Outside { kind: Outside::Red }, 25); | ||
| 471 | |||
| 472 | Self { | ||
| 473 | bets, | ||
| 474 | // A split with no chip on it, so the fine lattice cursor shows. | ||
| 475 | cursor: Cursor::Lattice { x: 18, y: 3 }, | ||
| 476 | chip: 25, | ||
| 477 | phase: RoulettePhase::Placing, | ||
| 478 | number: None, | ||
| 479 | rotation: 0.0, | ||
| 480 | spin: None, | ||
| 481 | spins: 0, | ||
| 482 | last: Vec::new(), | ||
| 483 | balance: 1000, | ||
| 484 | host: "127.0.0.1:11525", | ||
| 485 | ping_ms: 41, | ||
| 486 | user: "alice", | ||
| 487 | account: "#4161673306", | ||
| 488 | } | ||
| 489 | } | ||
| 490 | |||
| 491 | fn view(&self) -> RouletteView { | ||
| 492 | RouletteView { | ||
| 493 | bet_amount: 10, | ||
| 494 | balance: Some(self.balance), | ||
| 495 | chip: self.chip, | ||
| 496 | bets: self.bets.clone(), | ||
| 497 | cursor: self.cursor, | ||
| 498 | phase: self.phase, | ||
| 499 | number: self.number, | ||
| 500 | color: self.number.map(|number| wheel_color(number).to_string()), | ||
| 501 | winnings: None, | ||
| 502 | error: None, | ||
| 503 | } | ||
| 504 | } | ||
| 505 | |||
| 506 | fn tick(&mut self, ctx: &Context) { | ||
| 507 | let now = ctx.input(|input| input.time); | ||
| 508 | let mut finished = None; | ||
| 509 | if let Some(spin) = &self.spin { | ||
| 510 | let t = (((now - spin.started) / SPIN_SECONDS) as f32).clamp(0.0, 1.0); | ||
| 511 | let eased = 1.0 - (1.0 - t).powi(3); | ||
| 512 | self.rotation = spin.from + spin.delta * eased; | ||
| 513 | if t >= 1.0 { | ||
| 514 | finished = Some(spin.target); | ||
| 515 | } | ||
| 516 | ctx.request_repaint(); | ||
| 517 | } | ||
| 518 | if let Some(number) = finished { | ||
| 519 | self.spin = None; | ||
| 520 | self.phase = RoulettePhase::Result; | ||
| 521 | self.number = Some(number); | ||
| 522 | self.last.insert(0, number); | ||
| 523 | self.last.truncate(12); | ||
| 524 | } | ||
| 525 | } | ||
| 526 | |||
| 527 | fn keys(&mut self, ctx: &Context) { | ||
| 528 | if self.phase == RoulettePhase::Spinning { | ||
| 529 | return; | ||
| 530 | } | ||
| 531 | let (left, right, up, down) = ctx.input(|input| { | ||
| 532 | ( | ||
| 533 | input.key_pressed(Key::ArrowLeft), | ||
| 534 | input.key_pressed(Key::ArrowRight), | ||
| 535 | input.key_pressed(Key::ArrowUp), | ||
| 536 | input.key_pressed(Key::ArrowDown), | ||
| 537 | ) | ||
| 538 | }); | ||
| 539 | for (pressed, direction) in [ | ||
| 540 | (left, Direction::Left), | ||
| 541 | (right, Direction::Right), | ||
| 542 | (up, Direction::Up), | ||
| 543 | (down, Direction::Down), | ||
| 544 | ] { | ||
| 545 | if pressed { | ||
| 546 | self.cursor = self.cursor.moved(direction); | ||
| 547 | } | ||
| 548 | } | ||
| 549 | if ctx.memory(|memory| memory.focused().is_none()) { | ||
| 550 | if ctx.input(|input| input.key_pressed(Key::Enter)) { | ||
| 551 | if let Some(target) = self.cursor.target() { | ||
| 552 | let chip = self.chip; | ||
| 553 | self.bets.add(target, chip); | ||
| 554 | } | ||
| 555 | } | ||
| 556 | if ctx.input(|input| input.key_pressed(Key::Space)) { | ||
| 557 | self.start_spin(ctx); | ||
| 558 | } | ||
| 559 | } | ||
| 560 | } | ||
| 561 | |||
| 562 | fn start_spin(&mut self, ctx: &Context) { | ||
| 563 | if self.phase == RoulettePhase::Spinning { | ||
| 564 | return; | ||
| 565 | } | ||
| 566 | let target = DEMO_TARGETS[self.spins % DEMO_TARGETS.len()]; | ||
| 567 | self.spins += 1; | ||
| 568 | |||
| 569 | let segment = TAU / WHEEL.len() as f32; | ||
| 570 | let index = WHEEL | ||
| 571 | .iter() | ||
| 572 | .position(|number| *number == target) | ||
| 573 | .unwrap_or(0) as f32; | ||
| 574 | let desired = -index * segment; | ||
| 575 | let delta = (desired - self.rotation).rem_euclid(TAU) + 3.0 * TAU; | ||
| 576 | |||
| 577 | self.spin = Some(Spin { | ||
| 578 | started: ctx.input(|input| input.time), | ||
| 579 | from: self.rotation, | ||
| 580 | delta, | ||
| 581 | target, | ||
| 582 | }); | ||
| 583 | self.phase = RoulettePhase::Spinning; | ||
| 584 | self.number = None; | ||
| 585 | ctx.request_repaint(); | ||
| 586 | } | ||
| 587 | } | ||
diff --git a/gui/src/lib.rs b/gui/src/lib.rs new file mode 100644 index 0000000..e0f5bc9 --- /dev/null +++ b/gui/src/lib.rs | |||
| @@ -0,0 +1,9 @@ | |||
| 1 | //! Desktop frontend for zagan-bet. | ||
| 2 | //! | ||
| 3 | //! The window is a thin shell over the shared `ClientCore` (Phase 4). This | ||
| 4 | //! crate holds only presentation: the theme, the roulette table, and the app | ||
| 5 | //! layout. It never enters the TUI or server build graph. | ||
| 6 | |||
| 7 | pub mod app; | ||
| 8 | pub mod roulette; | ||
| 9 | pub mod theme; | ||
diff --git a/gui/src/main.rs b/gui/src/main.rs new file mode 100644 index 0000000..cf1c67d --- /dev/null +++ b/gui/src/main.rs | |||
| @@ -0,0 +1,25 @@ | |||
| 1 | //! Desktop frontend for zagan-bet. | ||
| 2 | //! | ||
| 3 | //! Phase 3 of `docs/GUI-PLAN.md`: the style spike. It owns the window and the | ||
| 4 | //! roulette reference screen; the shared core and transport are wired in | ||
| 5 | //! Phase 4. | ||
| 6 | |||
| 7 | use zagan_bet_gui::app::ZaganApp; | ||
| 8 | |||
| 9 | fn main() -> eframe::Result { | ||
| 10 | let options = eframe::NativeOptions { | ||
| 11 | viewport: eframe::egui::ViewportBuilder::default() | ||
| 12 | .with_title("Zagan-Bet") | ||
| 13 | .with_app_id("zagan-bet") | ||
| 14 | .with_inner_size([1440.0, 900.0]) | ||
| 15 | .with_min_inner_size([1100.0, 720.0]), | ||
| 16 | renderer: eframe::Renderer::Glow, | ||
| 17 | ..Default::default() | ||
| 18 | }; | ||
| 19 | |||
| 20 | eframe::run_native( | ||
| 21 | "Zagan-Bet", | ||
| 22 | options, | ||
| 23 | Box::new(|cc| Ok(Box::new(ZaganApp::new(cc)))), | ||
| 24 | ) | ||
| 25 | } | ||
diff --git a/gui/src/roulette.rs b/gui/src/roulette.rs new file mode 100644 index 0000000..22c46d6 --- /dev/null +++ b/gui/src/roulette.rs | |||
| @@ -0,0 +1,522 @@ | |||
| 1 | //! The roulette table and wheel, painted from `RouletteView`. | ||
| 2 | //! | ||
| 3 | //! Geometry is the GUI's business: the core only says which number or region a | ||
| 4 | //! chip or the cursor is on. Everything here is derived from that, so the two | ||
| 5 | //! frontends can lay the same table out differently. | ||
| 6 | |||
| 7 | use std::f32::consts::{FRAC_PI_2, TAU}; | ||
| 8 | |||
| 9 | use eframe::egui::{ | ||
| 10 | Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, | ||
| 11 | pos2, vec2, | ||
| 12 | }; | ||
| 13 | use zagan_bet::client::games::roulette::Cursor; | ||
| 14 | use zagan_bet::client::view::{RoulettePhase, RouletteView}; | ||
| 15 | use zagan_bet::roulette::{BetTarget, Outside, WHEEL, color as wheel_color, is_red}; | ||
| 16 | |||
| 17 | use crate::theme; | ||
| 18 | |||
| 19 | const COLUMNS: usize = 12; | ||
| 20 | const ROWS: usize = 3; | ||
| 21 | const OUTSIDE_ORDER: [Outside; 6] = [ | ||
| 22 | Outside::Low, | ||
| 23 | Outside::Even, | ||
| 24 | Outside::Red, | ||
| 25 | Outside::Black, | ||
| 26 | Outside::Odd, | ||
| 27 | Outside::High, | ||
| 28 | ]; | ||
| 29 | |||
| 30 | const DOZEN_ROW: f32 = 0.72; | ||
| 31 | const COLUMN_ROW: f32 = 0.55; | ||
| 32 | const OUTSIDE_ROW: f32 = 0.62; | ||
| 33 | const HEIGHT_UNITS: f32 = ROWS as f32 + DOZEN_ROW + COLUMN_ROW + OUTSIDE_ROW; | ||
| 34 | const BOARD_PAD: f32 = 6.0; | ||
| 35 | |||
| 36 | /// Largest table that fits in `available`, including its felt border. | ||
| 37 | pub fn size_for(available: Vec2) -> Vec2 { | ||
| 38 | let inner = (available - Vec2::splat(BOARD_PAD * 2.0)).max(Vec2::ZERO); | ||
| 39 | let cell = (inner.x / (COLUMNS as f32 + 1.0)) | ||
| 40 | .min(inner.y / HEIGHT_UNITS) | ||
| 41 | .max(1.0); | ||
| 42 | vec2((COLUMNS as f32 + 1.0) * cell, HEIGHT_UNITS * cell) + Vec2::splat(BOARD_PAD * 2.0) | ||
| 43 | } | ||
| 44 | |||
| 45 | /// The table painted at `size`; returns the target clicked this frame. | ||
| 46 | pub fn felt(ui: &mut Ui, size: Vec2, view: &RouletteView) -> Option<BetTarget> { | ||
| 47 | let (rect, response) = ui.allocate_exact_size(size, Sense::click()); | ||
| 48 | let felt = Felt::new(rect); | ||
| 49 | let hovered = response.hover_pos().and_then(|pos| felt.hit(pos)); | ||
| 50 | let painter = ui.painter(); | ||
| 51 | |||
| 52 | painter.rect_filled(rect, theme::RADIUS_LG, theme::FELT_DARK); | ||
| 53 | |||
| 54 | for number in 0..=36u8 { | ||
| 55 | let highlighted = hovered == Some(BetTarget::Straight { number }); | ||
| 56 | felt.paint_number(painter, number, highlighted); | ||
| 57 | } | ||
| 58 | for index in 0..3 { | ||
| 59 | felt.paint_dozen( | ||
| 60 | painter, | ||
| 61 | index, | ||
| 62 | hovered == Some(BetTarget::Dozen { index: index as u8 }), | ||
| 63 | ); | ||
| 64 | } | ||
| 65 | for index in 0..3 { | ||
| 66 | felt.paint_column( | ||
| 67 | painter, | ||
| 68 | index, | ||
| 69 | hovered == Some(BetTarget::Column { index: index as u8 }), | ||
| 70 | ); | ||
| 71 | } | ||
| 72 | for (index, kind) in OUTSIDE_ORDER.iter().enumerate() { | ||
| 73 | felt.paint_outside( | ||
| 74 | painter, | ||
| 75 | index, | ||
| 76 | *kind, | ||
| 77 | hovered == Some(BetTarget::Outside { kind: *kind }), | ||
| 78 | ); | ||
| 79 | } | ||
| 80 | |||
| 81 | if view.phase == RoulettePhase::Result { | ||
| 82 | if let Some(number) = view.number { | ||
| 83 | painter.rect_stroke( | ||
| 84 | felt.number_rect(number).shrink(2.0), | ||
| 85 | theme::RADIUS_SM, | ||
| 86 | Stroke::new(2.0, theme::GOLD), | ||
| 87 | StrokeKind::Inside, | ||
| 88 | ); | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | felt.paint_cursor(painter, &view.cursor); | ||
| 93 | |||
| 94 | for chip in &view.bets.chips { | ||
| 95 | if let Some(center) = felt.anchor(&chip.target) { | ||
| 96 | paint_chip(painter, center, felt.cell * 0.26, chip.amount); | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | if response.clicked() { hovered } else { None } | ||
| 101 | } | ||
| 102 | |||
| 103 | struct Felt { | ||
| 104 | cell: f32, | ||
| 105 | origin: Pos2, | ||
| 106 | } | ||
| 107 | |||
| 108 | impl Felt { | ||
| 109 | fn new(board: Rect) -> Self { | ||
| 110 | let inner = board.shrink(BOARD_PAD); | ||
| 111 | Self { | ||
| 112 | cell: inner.width() / (COLUMNS as f32 + 1.0), | ||
| 113 | origin: inner.min, | ||
| 114 | } | ||
| 115 | } | ||
| 116 | |||
| 117 | fn number_rect(&self, number: u8) -> Rect { | ||
| 118 | debug_assert!(number <= 36); | ||
| 119 | if number == 0 { | ||
| 120 | return Rect::from_min_size(self.origin, vec2(self.cell, self.cell * ROWS as f32)); | ||
| 121 | } | ||
| 122 | let column = ((number - 1) / 3) as f32; | ||
| 123 | let row = (2 - (number - 1) % 3) as f32; | ||
| 124 | Rect::from_min_size( | ||
| 125 | self.origin + vec2(self.cell * (1.0 + column), self.cell * row), | ||
| 126 | Vec2::splat(self.cell), | ||
| 127 | ) | ||
| 128 | } | ||
| 129 | |||
| 130 | fn dozen_rect(&self, index: usize) -> Rect { | ||
| 131 | let y = self.origin.y + self.cell * ROWS as f32; | ||
| 132 | Rect::from_min_size( | ||
| 133 | pos2(self.origin.x + self.cell * (1.0 + 4.0 * index as f32), y), | ||
| 134 | vec2(self.cell * 4.0, self.cell * DOZEN_ROW), | ||
| 135 | ) | ||
| 136 | } | ||
| 137 | |||
| 138 | fn column_rect(&self, index: usize) -> Rect { | ||
| 139 | let y = self.origin.y + self.cell * (ROWS as f32 + DOZEN_ROW); | ||
| 140 | Rect::from_min_size( | ||
| 141 | pos2(self.origin.x + self.cell * (1.0 + index as f32), y), | ||
| 142 | vec2(self.cell, self.cell * COLUMN_ROW), | ||
| 143 | ) | ||
| 144 | } | ||
| 145 | |||
| 146 | fn outside_rect(&self, index: usize) -> Rect { | ||
| 147 | let y = self.origin.y + self.cell * (ROWS as f32 + DOZEN_ROW + COLUMN_ROW); | ||
| 148 | Rect::from_min_size( | ||
| 149 | pos2(self.origin.x + self.cell * (1.0 + 2.0 * index as f32), y), | ||
| 150 | vec2(self.cell * 2.0, self.cell * OUTSIDE_ROW), | ||
| 151 | ) | ||
| 152 | } | ||
| 153 | |||
| 154 | /// Where a chip sits for this target. | ||
| 155 | fn anchor(&self, target: &BetTarget) -> Option<Pos2> { | ||
| 156 | Some(match target { | ||
| 157 | BetTarget::Straight { number } => self.number_rect(*number).center(), | ||
| 158 | BetTarget::Split { a, b } => { | ||
| 159 | let a = self.number_rect(*a).center(); | ||
| 160 | let b = self.number_rect(*b).center(); | ||
| 161 | pos2((a.x + b.x) * 0.5, (a.y + b.y) * 0.5) | ||
| 162 | } | ||
| 163 | BetTarget::Corner { numbers } => { | ||
| 164 | let mut sum = Vec2::ZERO; | ||
| 165 | for number in numbers { | ||
| 166 | sum += self.number_rect(*number).center().to_vec2(); | ||
| 167 | } | ||
| 168 | (sum / numbers.len() as f32).to_pos2() | ||
| 169 | } | ||
| 170 | BetTarget::Dozen { index } => self.dozen_rect(*index as usize).center(), | ||
| 171 | BetTarget::Column { index } => self.column_rect(*index as usize).center(), | ||
| 172 | BetTarget::Outside { kind } => { | ||
| 173 | let index = OUTSIDE_ORDER | ||
| 174 | .iter() | ||
| 175 | .position(|candidate| candidate == kind)?; | ||
| 176 | self.outside_rect(index).center() | ||
| 177 | } | ||
| 178 | }) | ||
| 179 | } | ||
| 180 | |||
| 181 | /// The fine-grid point the cursor occupies over the numbers. | ||
| 182 | fn lattice(&self, x: usize, y: usize) -> Pos2 { | ||
| 183 | pos2( | ||
| 184 | self.origin.x + self.cell * (1.0 + x as f32 * 0.5), | ||
| 185 | self.origin.y + self.cell * (y as f32 * 0.5), | ||
| 186 | ) | ||
| 187 | } | ||
| 188 | |||
| 189 | fn hit(&self, pos: Pos2) -> Option<BetTarget> { | ||
| 190 | for number in 0..=36u8 { | ||
| 191 | if self.number_rect(number).contains(pos) { | ||
| 192 | return Some(BetTarget::Straight { number }); | ||
| 193 | } | ||
| 194 | } | ||
| 195 | for index in 0..3usize { | ||
| 196 | if self.dozen_rect(index).contains(pos) { | ||
| 197 | return Some(BetTarget::Dozen { index: index as u8 }); | ||
| 198 | } | ||
| 199 | if self.column_rect(index).contains(pos) { | ||
| 200 | return Some(BetTarget::Column { index: index as u8 }); | ||
| 201 | } | ||
| 202 | } | ||
| 203 | for (index, kind) in OUTSIDE_ORDER.iter().enumerate() { | ||
| 204 | if self.outside_rect(index).contains(pos) { | ||
| 205 | return Some(BetTarget::Outside { kind: *kind }); | ||
| 206 | } | ||
| 207 | } | ||
| 208 | None | ||
| 209 | } | ||
| 210 | |||
| 211 | fn paint_number(&self, painter: &Painter, number: u8, hovered: bool) { | ||
| 212 | let rect = self.number_rect(number); | ||
| 213 | let fill = if number == 0 { | ||
| 214 | theme::ROULETTE_ZERO | ||
| 215 | } else if is_red(number) { | ||
| 216 | theme::ROULETTE_RED | ||
| 217 | } else { | ||
| 218 | theme::ROULETTE_BLACK | ||
| 219 | }; | ||
| 220 | painter.rect_filled(rect, CornerRadius::ZERO, fill); | ||
| 221 | painter.rect_stroke( | ||
| 222 | rect, | ||
| 223 | CornerRadius::ZERO, | ||
| 224 | Stroke::new(1.0, theme::FELT_LINE), | ||
| 225 | StrokeKind::Inside, | ||
| 226 | ); | ||
| 227 | if hovered { | ||
| 228 | painter.rect_filled( | ||
| 229 | rect, | ||
| 230 | CornerRadius::ZERO, | ||
| 231 | Color32::WHITE.gamma_multiply(0.10), | ||
| 232 | ); | ||
| 233 | } | ||
| 234 | painter.text( | ||
| 235 | rect.center(), | ||
| 236 | Align2::CENTER_CENTER, | ||
| 237 | number, | ||
| 238 | theme::mono(self.number_font()), | ||
| 239 | theme::FELT_TEXT, | ||
| 240 | ); | ||
| 241 | } | ||
| 242 | |||
| 243 | fn paint_dozen(&self, painter: &Painter, index: usize, hovered: bool) { | ||
| 244 | let rect = self.dozen_rect(index); | ||
| 245 | self.paint_region(painter, rect, hovered); | ||
| 246 | let label = match index { | ||
| 247 | 0 => "1st 12", | ||
| 248 | 1 => "2nd 12", | ||
| 249 | _ => "3rd 12", | ||
| 250 | }; | ||
| 251 | painter.text( | ||
| 252 | rect.center(), | ||
| 253 | Align2::CENTER_CENTER, | ||
| 254 | label, | ||
| 255 | theme::sans(self.label_font()), | ||
| 256 | theme::FELT_TEXT, | ||
| 257 | ); | ||
| 258 | } | ||
| 259 | |||
| 260 | fn paint_column(&self, painter: &Painter, index: usize, hovered: bool) { | ||
| 261 | let rect = self.column_rect(index); | ||
| 262 | self.paint_region(painter, rect, hovered); | ||
| 263 | painter.text( | ||
| 264 | rect.center(), | ||
| 265 | Align2::CENTER_CENTER, | ||
| 266 | "2:1", | ||
| 267 | theme::sans(self.label_font()), | ||
| 268 | theme::FELT_TEXT, | ||
| 269 | ); | ||
| 270 | } | ||
| 271 | |||
| 272 | fn paint_outside(&self, painter: &Painter, index: usize, kind: Outside, hovered: bool) { | ||
| 273 | let rect = self.outside_rect(index); | ||
| 274 | self.paint_region(painter, rect, hovered); | ||
| 275 | let color = match kind { | ||
| 276 | Outside::Red => theme::ROULETTE_RED, | ||
| 277 | Outside::Black => theme::TEXT_DIM, | ||
| 278 | _ => theme::FELT_TEXT, | ||
| 279 | }; | ||
| 280 | painter.text( | ||
| 281 | rect.center(), | ||
| 282 | Align2::CENTER_CENTER, | ||
| 283 | kind.label(), | ||
| 284 | theme::sans(self.label_font()), | ||
| 285 | color, | ||
| 286 | ); | ||
| 287 | } | ||
| 288 | |||
| 289 | fn paint_region(&self, painter: &Painter, rect: Rect, hovered: bool) { | ||
| 290 | painter.rect_filled(rect, CornerRadius::ZERO, theme::FELT); | ||
| 291 | painter.rect_stroke( | ||
| 292 | rect, | ||
| 293 | CornerRadius::ZERO, | ||
| 294 | Stroke::new(1.0, theme::FELT_LINE), | ||
| 295 | StrokeKind::Inside, | ||
| 296 | ); | ||
| 297 | if hovered { | ||
| 298 | painter.rect_filled( | ||
| 299 | rect, | ||
| 300 | CornerRadius::ZERO, | ||
| 301 | Color32::WHITE.gamma_multiply(0.10), | ||
| 302 | ); | ||
| 303 | } | ||
| 304 | } | ||
| 305 | |||
| 306 | fn paint_cursor(&self, painter: &Painter, cursor: &Cursor) { | ||
| 307 | let stroke = Stroke::new(2.0, theme::ACCENT); | ||
| 308 | match cursor { | ||
| 309 | Cursor::Zero => outline(painter, self.number_rect(0), stroke), | ||
| 310 | Cursor::Lattice { x, y } => { | ||
| 311 | for number in cursor.numbers() { | ||
| 312 | painter.rect_filled( | ||
| 313 | self.number_rect(number), | ||
| 314 | CornerRadius::ZERO, | ||
| 315 | theme::ACCENT.gamma_multiply(0.14), | ||
| 316 | ); | ||
| 317 | } | ||
| 318 | let point = self.lattice(*x, *y); | ||
| 319 | painter.circle_filled(point, self.cell * 0.05, theme::ACCENT); | ||
| 320 | painter.circle_stroke(point, self.cell * 0.14, stroke); | ||
| 321 | } | ||
| 322 | Cursor::Dozen(index) => outline(painter, self.dozen_rect(*index), stroke), | ||
| 323 | Cursor::Column(index) => outline(painter, self.column_rect(*index), stroke), | ||
| 324 | Cursor::Outside(index) => outline(painter, self.outside_rect(*index), stroke), | ||
| 325 | } | ||
| 326 | } | ||
| 327 | |||
| 328 | fn number_font(&self) -> f32 { | ||
| 329 | (self.cell * 0.40).clamp(12.0, 22.0) | ||
| 330 | } | ||
| 331 | |||
| 332 | fn label_font(&self) -> f32 { | ||
| 333 | (self.cell * 0.24).clamp(10.0, 15.0) | ||
| 334 | } | ||
| 335 | } | ||
| 336 | |||
| 337 | fn outline(painter: &Painter, rect: Rect, stroke: Stroke) { | ||
| 338 | painter.rect_stroke( | ||
| 339 | rect.shrink(1.0), | ||
| 340 | theme::RADIUS_SM, | ||
| 341 | stroke, | ||
| 342 | StrokeKind::Inside, | ||
| 343 | ); | ||
| 344 | } | ||
| 345 | |||
| 346 | /// A single chip, also used by the sidebar's chip tray. | ||
| 347 | pub fn paint_chip(painter: &Painter, center: Pos2, radius: f32, amount: u64) { | ||
| 348 | let fill = chip_color(amount); | ||
| 349 | painter.circle_filled(center, radius, fill); | ||
| 350 | painter.circle_stroke( | ||
| 351 | center, | ||
| 352 | radius - radius * 0.18, | ||
| 353 | Stroke::new(radius * 0.10, Color32::WHITE.gamma_multiply(0.55)), | ||
| 354 | ); | ||
| 355 | let text_color = if amount <= 10 { | ||
| 356 | theme::BG | ||
| 357 | } else { | ||
| 358 | Color32::WHITE | ||
| 359 | }; | ||
| 360 | painter.text( | ||
| 361 | center, | ||
| 362 | Align2::CENTER_CENTER, | ||
| 363 | amount, | ||
| 364 | theme::mono(radius * 0.85), | ||
| 365 | text_color, | ||
| 366 | ); | ||
| 367 | } | ||
| 368 | |||
| 369 | /// Colour per denomination, as a chip tray would be stocked. | ||
| 370 | pub fn chip_color(amount: u64) -> Color32 { | ||
| 371 | match amount { | ||
| 372 | 0..=10 => Color32::from_rgb(0xd9, 0xd3, 0xc7), | ||
| 373 | 11..=25 => Color32::from_rgb(0x3f, 0x7f, 0xd1), | ||
| 374 | 26..=50 => Color32::from_rgb(0xc9, 0x4b, 0x4b), | ||
| 375 | _ => Color32::from_rgb(0x3a, 0x9d, 0x5d), | ||
| 376 | } | ||
| 377 | } | ||
| 378 | |||
| 379 | fn segment_color(number: u8) -> Color32 { | ||
| 380 | match wheel_color(number) { | ||
| 381 | "red" => theme::ROULETTE_RED, | ||
| 382 | "green" => theme::ROULETTE_ZERO, | ||
| 383 | _ => Color32::from_rgb(0x2b, 0x35, 0x3f), | ||
| 384 | } | ||
| 385 | } | ||
| 386 | |||
| 387 | /// Number currently under the pointer for this wheel rotation. | ||
| 388 | pub fn number_under(rotation: f32) -> u8 { | ||
| 389 | let segment = TAU / WHEEL.len() as f32; | ||
| 390 | let index = (-rotation / segment).round() as i64; | ||
| 391 | WHEEL[index.rem_euclid(WHEEL.len() as i64) as usize] | ||
| 392 | } | ||
| 393 | |||
| 394 | /// The wheel dial: 37 coloured pockets, a fixed pointer, and the number under | ||
| 395 | /// it. `rotation` is in radians. | ||
| 396 | pub fn wheel(ui: &mut Ui, diameter: f32, view: &RouletteView, rotation: f32) { | ||
| 397 | let (rect, _) = ui.allocate_exact_size(Vec2::splat(diameter), Sense::hover()); | ||
| 398 | let painter = ui.painter(); | ||
| 399 | let center = rect.center(); | ||
| 400 | let radius = diameter * 0.5; | ||
| 401 | |||
| 402 | painter.circle_filled(center, radius, theme::SURFACE_RAISED); | ||
| 403 | painter.circle_stroke(center, radius - 1.0, Stroke::new(1.0, theme::LINE)); | ||
| 404 | |||
| 405 | let segment = TAU / WHEEL.len() as f32; | ||
| 406 | let ring = radius * 0.80; | ||
| 407 | let dot = (radius * 0.072).max(2.0); | ||
| 408 | painter.circle_stroke( | ||
| 409 | center, | ||
| 410 | ring, | ||
| 411 | Stroke::new(dot * 2.0, Color32::from_rgb(0x1f, 0x28, 0x31)), | ||
| 412 | ); | ||
| 413 | for (index, number) in WHEEL.iter().enumerate() { | ||
| 414 | let angle = -FRAC_PI_2 + rotation + index as f32 * segment; | ||
| 415 | let pocket = center + vec2(angle.cos(), angle.sin()) * ring; | ||
| 416 | let winning = view.phase == RoulettePhase::Result && view.number == Some(*number); | ||
| 417 | let stroke = if winning { | ||
| 418 | Stroke::new(2.0, theme::GOLD) | ||
| 419 | } else { | ||
| 420 | Stroke::new(1.0, Color32::from_rgb(0x3a, 0x46, 0x52)) | ||
| 421 | }; | ||
| 422 | painter.circle( | ||
| 423 | pocket, | ||
| 424 | if winning { dot * 1.25 } else { dot }, | ||
| 425 | segment_color(*number), | ||
| 426 | stroke, | ||
| 427 | ); | ||
| 428 | } | ||
| 429 | |||
| 430 | // A fixed pointer at the top, marking the winning pocket. | ||
| 431 | let tip = center + vec2(0.0, -radius + dot * 0.30); | ||
| 432 | painter.add(Shape::convex_polygon( | ||
| 433 | vec![ | ||
| 434 | tip, | ||
| 435 | tip + vec2(-dot * 0.9, -dot * 1.4), | ||
| 436 | tip + vec2(dot * 0.9, -dot * 1.4), | ||
| 437 | ], | ||
| 438 | theme::TEXT, | ||
| 439 | Stroke::NONE, | ||
| 440 | )); | ||
| 441 | |||
| 442 | let inner = radius * 0.52; | ||
| 443 | painter.circle_filled(center, inner, theme::SURFACE); | ||
| 444 | painter.circle_stroke(center, inner, Stroke::new(1.0, theme::LINE)); | ||
| 445 | |||
| 446 | let displayed = match view.phase { | ||
| 447 | RoulettePhase::Result => view.number, | ||
| 448 | RoulettePhase::Spinning => Some(number_under(rotation)), | ||
| 449 | RoulettePhase::Placing => None, | ||
| 450 | }; | ||
| 451 | match displayed { | ||
| 452 | Some(number) => { | ||
| 453 | let color = match wheel_color(number) { | ||
| 454 | "red" => theme::ROULETTE_RED, | ||
| 455 | "green" => theme::WIN, | ||
| 456 | _ => theme::TEXT, | ||
| 457 | }; | ||
| 458 | painter.text( | ||
| 459 | center, | ||
| 460 | Align2::CENTER_CENTER, | ||
| 461 | number, | ||
| 462 | theme::mono(inner * 0.85), | ||
| 463 | color, | ||
| 464 | ); | ||
| 465 | } | ||
| 466 | None => { | ||
| 467 | painter.text( | ||
| 468 | center, | ||
| 469 | Align2::CENTER_CENTER, | ||
| 470 | "—", | ||
| 471 | theme::mono(inner * 0.55), | ||
| 472 | theme::TEXT_FAINT, | ||
| 473 | ); | ||
| 474 | } | ||
| 475 | } | ||
| 476 | } | ||
| 477 | |||
| 478 | /// "Straight 17", "Corner 17/18/20/21", "Red". | ||
| 479 | pub fn target_name(target: &BetTarget) -> String { | ||
| 480 | match target { | ||
| 481 | BetTarget::Straight { number } => format!("Straight {number}"), | ||
| 482 | BetTarget::Split { a, b } => format!("Split {a}/{b}"), | ||
| 483 | BetTarget::Corner { numbers } => format!( | ||
| 484 | "Corner {}/{}/{}/{}", | ||
| 485 | numbers[0], numbers[1], numbers[2], numbers[3] | ||
| 486 | ), | ||
| 487 | BetTarget::Dozen { index } => match index { | ||
| 488 | 0 => "1st 12".to_string(), | ||
| 489 | 1 => "2nd 12".to_string(), | ||
| 490 | _ => "3rd 12".to_string(), | ||
| 491 | }, | ||
| 492 | BetTarget::Column { index } => format!("Column {}", index + 1), | ||
| 493 | BetTarget::Outside { kind } => title_case(kind.label()), | ||
| 494 | } | ||
| 495 | } | ||
| 496 | |||
| 497 | fn title_case(text: &str) -> String { | ||
| 498 | let mut characters = text.chars(); | ||
| 499 | match characters.next() { | ||
| 500 | Some(first) => { | ||
| 501 | first.to_uppercase().collect::<String>() + &characters.as_str().to_lowercase() | ||
| 502 | } | ||
| 503 | None => String::new(), | ||
| 504 | } | ||
| 505 | } | ||
| 506 | |||
| 507 | /// Small coloured dot for a result strip. | ||
| 508 | pub fn paint_result_dot(painter: &Painter, center: Pos2, radius: f32, number: u8) { | ||
| 509 | let color = match wheel_color(number) { | ||
| 510 | "red" => theme::ROULETTE_RED, | ||
| 511 | "green" => theme::ROULETTE_ZERO, | ||
| 512 | _ => theme::ROULETTE_BLACK, | ||
| 513 | }; | ||
| 514 | painter.circle_filled(center, radius, color); | ||
| 515 | painter.text( | ||
| 516 | center, | ||
| 517 | Align2::CENTER_CENTER, | ||
| 518 | number, | ||
| 519 | theme::mono(radius * 0.95), | ||
| 520 | theme::FELT_TEXT, | ||
| 521 | ); | ||
| 522 | } | ||
diff --git a/gui/src/theme.rs b/gui/src/theme.rs new file mode 100644 index 0000000..9270aa6 --- /dev/null +++ b/gui/src/theme.rs | |||
| @@ -0,0 +1,211 @@ | |||
| 1 | //! Look and feel for the desktop frontend. | ||
| 2 | //! | ||
| 3 | //! The palette is derived from the terminal client's ANSI scheme so the two | ||
| 4 | //! frontends read as the same product: near-black ground, low-contrast panels, | ||
| 5 | //! cyan for focus, and the felt green reserved for the roulette table. | ||
| 6 | //! | ||
| 7 | //! egui's defaults are a debug tool's palette. Every colour, size and corner | ||
| 8 | //! used by the app comes from this module; widgets never pick their own. | ||
| 9 | |||
| 10 | use std::sync::Arc; | ||
| 11 | |||
| 12 | use eframe::egui::style::WidgetVisuals; | ||
| 13 | use eframe::egui::{ | ||
| 14 | self, Color32, Context, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Margin, | ||
| 15 | RichText, Stroke, TextStyle, Theme, Visuals, | ||
| 16 | }; | ||
| 17 | |||
| 18 | // --------------------------------------------------------------------------- | ||
| 19 | // Colour tokens | ||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | |||
| 22 | /// Window ground. Everything else sits above this. | ||
| 23 | pub const BG: Color32 = Color32::from_rgb(0x0a, 0x0e, 0x12); | ||
| 24 | /// Cards, header, sidebar. | ||
| 25 | pub const SURFACE: Color32 = Color32::from_rgb(0x11, 0x17, 0x1d); | ||
| 26 | /// Inputs, pills, raised rows. | ||
| 27 | pub const SURFACE_RAISED: Color32 = Color32::from_rgb(0x17, 0x1f, 0x27); | ||
| 28 | /// Hover state. | ||
| 29 | pub const SURFACE_HOVER: Color32 = Color32::from_rgb(0x1d, 0x27, 0x31); | ||
| 30 | /// Hairlines and table borders. | ||
| 31 | pub const LINE: Color32 = Color32::from_rgb(0x24, 0x2e, 0x38); | ||
| 32 | /// Primary text. | ||
| 33 | pub const TEXT: Color32 = Color32::from_rgb(0xd9, 0xe2, 0xea); | ||
| 34 | /// Labels and secondary text. | ||
| 35 | pub const TEXT_DIM: Color32 = Color32::from_rgb(0x8a, 0x98, 0xa6); | ||
| 36 | /// Disabled text, captions. | ||
| 37 | pub const TEXT_FAINT: Color32 = Color32::from_rgb(0x5b, 0x67, 0x73); | ||
| 38 | /// Focus, cursor, selection. | ||
| 39 | pub const ACCENT: Color32 = Color32::from_rgb(0x4f, 0xb8, 0xc9); | ||
| 40 | /// Wins, "online", the spin button. | ||
| 41 | pub const WIN: Color32 = Color32::from_rgb(0x4e, 0xc2, 0x7b); | ||
| 42 | /// Losses and errors. | ||
| 43 | pub const LOSS: Color32 = Color32::from_rgb(0xe0, 0x5a, 0x5a); | ||
| 44 | /// Chips and pending bets. | ||
| 45 | pub const GOLD: Color32 = Color32::from_rgb(0xe0, 0xb6, 0x4a); | ||
| 46 | |||
| 47 | /// Roulette felt. Never used outside the table. | ||
| 48 | pub const FELT: Color32 = Color32::from_rgb(0x0d, 0x5c, 0x39); | ||
| 49 | pub const FELT_DARK: Color32 = Color32::from_rgb(0x09, 0x47, 0x2c); | ||
| 50 | pub const FELT_LINE: Color32 = Color32::from_rgb(0x1f, 0x7d, 0x52); | ||
| 51 | pub const FELT_TEXT: Color32 = Color32::from_rgb(0xec, 0xf7, 0xf0); | ||
| 52 | pub const ROULETTE_RED: Color32 = Color32::from_rgb(0xb0, 0x31, 0x3a); | ||
| 53 | pub const ROULETTE_BLACK: Color32 = Color32::from_rgb(0x15, 0x1b, 0x21); | ||
| 54 | pub const ROULETTE_ZERO: Color32 = Color32::from_rgb(0x10, 0x6b, 0x45); | ||
| 55 | |||
| 56 | // --------------------------------------------------------------------------- | ||
| 57 | // Spacing, radius, type scale | ||
| 58 | // --------------------------------------------------------------------------- | ||
| 59 | |||
| 60 | pub const SPACE_1: f32 = 4.0; | ||
| 61 | pub const SPACE_2: f32 = 8.0; | ||
| 62 | pub const SPACE_3: f32 = 12.0; | ||
| 63 | pub const SPACE_4: f32 = 16.0; | ||
| 64 | pub const SPACE_5: f32 = 24.0; | ||
| 65 | |||
| 66 | pub const RADIUS_SM: CornerRadius = CornerRadius::same(4); | ||
| 67 | pub const RADIUS_MD: CornerRadius = CornerRadius::same(8); | ||
| 68 | pub const RADIUS_LG: CornerRadius = CornerRadius::same(12); | ||
| 69 | |||
| 70 | pub const FONT_BODY: f32 = 14.0; | ||
| 71 | pub const FONT_SMALL: f32 = 12.0; | ||
| 72 | pub const FONT_LABEL: f32 = 11.0; | ||
| 73 | pub const FONT_TITLE: f32 = 24.0; | ||
| 74 | pub const FONT_MONO: f32 = 16.0; | ||
| 75 | |||
| 76 | // --------------------------------------------------------------------------- | ||
| 77 | // Fonts | ||
| 78 | // --------------------------------------------------------------------------- | ||
| 79 | |||
| 80 | /// Open Sans, SIL OFL 1.1. See `assets/fonts/OFL-OpenSans.txt`. | ||
| 81 | pub const FONT_SANS_BYTES: &[u8] = include_bytes!("../assets/fonts/OpenSans-Regular.ttf"); | ||
| 82 | /// Hack, MIT. See `assets/fonts/LICENSE-Hack.md`. | ||
| 83 | pub const FONT_MONO_BYTES: &[u8] = include_bytes!("../assets/fonts/Hack-Regular.ttf"); | ||
| 84 | |||
| 85 | pub fn sans(size: f32) -> FontId { | ||
| 86 | FontId::new(size, FontFamily::Proportional) | ||
| 87 | } | ||
| 88 | |||
| 89 | pub fn mono(size: f32) -> FontId { | ||
| 90 | FontId::new(size, FontFamily::Monospace) | ||
| 91 | } | ||
| 92 | |||
| 93 | /// A small uppercase label, the terminal's dim section headers. | ||
| 94 | pub fn label(text: &str) -> RichText { | ||
| 95 | RichText::new(text.to_uppercase()) | ||
| 96 | .font(mono(FONT_LABEL)) | ||
| 97 | .color(TEXT_DIM) | ||
| 98 | } | ||
| 99 | |||
| 100 | /// Group an amount in threes: 1234567 -> "1 234 567". | ||
| 101 | pub fn group_digits(amount: i64) -> String { | ||
| 102 | let negative = amount < 0; | ||
| 103 | let digits = amount.unsigned_abs().to_string(); | ||
| 104 | let mut grouped = String::with_capacity(digits.len() + digits.len() / 3); | ||
| 105 | for (index, character) in digits.chars().enumerate() { | ||
| 106 | if index > 0 && (digits.len() - index) % 3 == 0 { | ||
| 107 | grouped.push(' '); | ||
| 108 | } | ||
| 109 | grouped.push(character); | ||
| 110 | } | ||
| 111 | if negative { | ||
| 112 | format!("-{grouped}") | ||
| 113 | } else { | ||
| 114 | grouped | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | /// `1000` -> `"1 000 Kr"`. | ||
| 119 | pub fn money(amount: i64) -> String { | ||
| 120 | format!("{} Kr", group_digits(amount)) | ||
| 121 | } | ||
| 122 | |||
| 123 | // --------------------------------------------------------------------------- | ||
| 124 | // Installation | ||
| 125 | // --------------------------------------------------------------------------- | ||
| 126 | |||
| 127 | /// Install the embedded fonts and the dark style. Call once, from the app | ||
| 128 | /// constructor, before the first frame. | ||
| 129 | pub fn install(ctx: &Context) { | ||
| 130 | let mut fonts = FontDefinitions::default(); | ||
| 131 | fonts.font_data.insert( | ||
| 132 | "zagan-sans".to_owned(), | ||
| 133 | Arc::new(FontData::from_static(FONT_SANS_BYTES)), | ||
| 134 | ); | ||
| 135 | fonts.font_data.insert( | ||
| 136 | "zagan-mono".to_owned(), | ||
| 137 | Arc::new(FontData::from_static(FONT_MONO_BYTES)), | ||
| 138 | ); | ||
| 139 | fonts | ||
| 140 | .families | ||
| 141 | .get_mut(&FontFamily::Proportional) | ||
| 142 | .expect("proportional family exists") | ||
| 143 | .insert(0, "zagan-sans".to_owned()); | ||
| 144 | fonts | ||
| 145 | .families | ||
| 146 | .get_mut(&FontFamily::Monospace) | ||
| 147 | .expect("monospace family exists") | ||
| 148 | .insert(0, "zagan-mono".to_owned()); | ||
| 149 | ctx.set_fonts(fonts); | ||
| 150 | |||
| 151 | ctx.set_theme(Theme::Dark); | ||
| 152 | let mut style = (*ctx.global_style()).clone(); | ||
| 153 | style.text_styles = [ | ||
| 154 | (TextStyle::Heading, sans(FONT_TITLE)), | ||
| 155 | (TextStyle::Body, sans(FONT_BODY)), | ||
| 156 | (TextStyle::Button, sans(FONT_BODY)), | ||
| 157 | (TextStyle::Small, sans(FONT_SMALL)), | ||
| 158 | (TextStyle::Monospace, mono(FONT_MONO)), | ||
| 159 | ] | ||
| 160 | .into(); | ||
| 161 | style.spacing.item_spacing = egui::vec2(SPACE_2, SPACE_2); | ||
| 162 | style.spacing.button_padding = egui::vec2(SPACE_3, SPACE_2); | ||
| 163 | style.spacing.interact_size = egui::vec2(36.0, 32.0); | ||
| 164 | style.spacing.window_margin = Margin::same(SPACE_3 as i8); | ||
| 165 | style.visuals = visuals(); | ||
| 166 | ctx.set_global_style(style); | ||
| 167 | } | ||
| 168 | |||
| 169 | fn widget( | ||
| 170 | bg_fill: Color32, | ||
| 171 | bg_stroke: Color32, | ||
| 172 | fg: Color32, | ||
| 173 | corner_radius: CornerRadius, | ||
| 174 | ) -> WidgetVisuals { | ||
| 175 | WidgetVisuals { | ||
| 176 | bg_fill, | ||
| 177 | weak_bg_fill: bg_fill, | ||
| 178 | bg_stroke: Stroke::new(1.0, bg_stroke), | ||
| 179 | corner_radius, | ||
| 180 | fg_stroke: Stroke::new(1.0, fg), | ||
| 181 | expansion: 0.0, | ||
| 182 | } | ||
| 183 | } | ||
| 184 | |||
| 185 | fn visuals() -> Visuals { | ||
| 186 | let mut visuals = Visuals::dark(); | ||
| 187 | visuals.override_text_color = Some(TEXT); | ||
| 188 | visuals.panel_fill = BG; | ||
| 189 | visuals.window_fill = SURFACE; | ||
| 190 | visuals.window_stroke = Stroke::new(1.0, LINE); | ||
| 191 | visuals.window_corner_radius = RADIUS_LG; | ||
| 192 | visuals.extreme_bg_color = BG; | ||
| 193 | visuals.faint_bg_color = SURFACE_RAISED; | ||
| 194 | visuals.code_bg_color = SURFACE_RAISED; | ||
| 195 | visuals.hyperlink_color = ACCENT; | ||
| 196 | visuals.warn_fg_color = GOLD; | ||
| 197 | visuals.error_fg_color = LOSS; | ||
| 198 | visuals.selection.bg_fill = ACCENT.gamma_multiply(0.30); | ||
| 199 | visuals.selection.stroke = Stroke::new(1.0, ACCENT); | ||
| 200 | visuals.widgets.noninteractive = widget(SURFACE, LINE, TEXT, RADIUS_MD); | ||
| 201 | visuals.widgets.inactive = widget(SURFACE_RAISED, LINE, TEXT, RADIUS_MD); | ||
| 202 | visuals.widgets.hovered = widget(SURFACE_HOVER, ACCENT.gamma_multiply(0.75), TEXT, RADIUS_MD); | ||
| 203 | visuals.widgets.active = widget( | ||
| 204 | ACCENT.gamma_multiply(0.25), | ||
| 205 | ACCENT, | ||
| 206 | Color32::WHITE, | ||
| 207 | RADIUS_MD, | ||
| 208 | ); | ||
| 209 | visuals.widgets.open = visuals.widgets.active; | ||
| 210 | visuals | ||
| 211 | } | ||
