diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 20:11:41 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 20:11:41 +0100 |
| commit | e920b20fafe375eec15b24eb8cde4b1455dd01af (patch) | |
| tree | 308d2f7999177a753528779f6ec7926f3fa50818 | |
| parent | v4.1.0: add the egui workspace member and the roulette style spike (diff) | |
| download | zagan-bet-e920b20fafe375eec15b24eb8cde4b1455dd01af.tar.gz zagan-bet-e920b20fafe375eec15b24eb8cde4b1455dd01af.zip | |
v4.1.1: pointer-first betting and visible payouts in the GUI
The felt snaps the hover to the shared fine lattice: inner half of a cell is a straight, an edge a split, a crossing a corner. A translucent chip previews the target and the covered cells tint. Left click adds the selected chip, right or middle click takes one back through the new Bets::reduce; C or CLEAR empties the slip, and SPIN stays disabled with no chips.
The result now pays out: the wheel leaves the winning pocket under the pointer, the winning cell and pocket get a gold outline, and the sidebar reports the amount returned and the net for the round (NO WIN on a loss). Placing a chip after a result starts the next round without losing the wheel position.
The column row now spans the full width like a real table instead of hugging the left edge. Adds tests for lattice snapping, target naming and taking chips back.
| -rw-r--r-- | AGENTS.md | 4 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/GUI-PLAN.md | 12 | ||||
| -rw-r--r-- | docs/TESTING.md | 34 | ||||
| -rw-r--r-- | docs/screenshots/gui-roulette-1080p.png | bin | 126927 -> 128158 bytes | |||
| -rw-r--r-- | docs/screenshots/gui-roulette-result-1080p.png | bin | 127523 -> 127283 bytes | |||
| -rw-r--r-- | gui/Cargo.toml | 2 | ||||
| -rw-r--r-- | gui/src/app.rs | 253 | ||||
| -rw-r--r-- | gui/src/roulette.rs | 161 | ||||
| -rw-r--r-- | gui/src/theme.rs | 6 | ||||
| -rw-r--r-- | gui/tests/layout.rs | 76 | ||||
| -rw-r--r-- | src/client/games/roulette.rs | 8 | ||||
| -rw-r--r-- | src/roulette.rs | 10 | ||||
| -rw-r--r-- | tests/roulette.rs | 20 |
14 files changed, 402 insertions, 186 deletions
| @@ -73,6 +73,10 @@ | |||
| 73 | look; keep a style module and a reference screen. The palette is derived | 73 | look; keep a style module and a reference screen. The palette is derived |
| 74 | from the terminal's ANSI colours, and felt green is only ever the roulette | 74 | from the terminal's ANSI colours, and felt green is only ever the roulette |
| 75 | table. | 75 | table. |
| 76 | - The GUI felt is pointer-first: the hover snaps to the shared fine lattice | ||
| 77 | (cell centre, edge, crossing), left click adds the chip and right or middle | ||
| 78 | click takes one back. The terminal keeps its arrow-key cursor; the GUI does | ||
| 79 | not paint it. | ||
| 76 | 80 | ||
| 77 | ## Client TUI | 81 | ## Client TUI |
| 78 | - `render_lines()` and `render()` must agree. If `render()` uses `\r\n`, | 82 | - `render_lines()` and `render()` must agree. If `render()` uses `\r\n`, |
| @@ -3,7 +3,7 @@ members = ["gui"] | |||
| 3 | 3 | ||
| 4 | [package] | 4 | [package] |
| 5 | name = "zagan-bet" | 5 | name = "zagan-bet" |
| 6 | version = "4.1.0" | 6 | version = "4.1.1" |
| 7 | edition = "2024" | 7 | edition = "2024" |
| 8 | license = "GPL-3.0-or-later" | 8 | license = "GPL-3.0-or-later" |
| 9 | 9 | ||
diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md index 8c3db0a..bb63530 100644 --- a/docs/GUI-PLAN.md +++ b/docs/GUI-PLAN.md | |||
| @@ -79,11 +79,17 @@ crash board from its local clock. | |||
| 79 | green kept for the table, a 4/8/12/16/24 spacing scale, radii, and the type | 79 | green kept for the table, a 4/8/12/16/24 spacing scale, radii, and the type |
| 80 | scale. Two embedded fonts: Open Sans (OFL) for text, Hack (MIT) for numbers, | 80 | scale. Two embedded fonts: Open Sans (OFL) for text, Hack (MIT) for numbers, |
| 81 | chips and labels. Licences sit next to the files in `gui/assets/fonts/`. | 81 | chips and labels. Licences sit next to the files in `gui/assets/fonts/`. |
| 82 | - `gui/src/roulette.rs` draws the felt, chips, the fine lattice cursor and the | 82 | - `gui/src/roulette.rs` draws the felt, chips and wheel from a `RouletteView`; |
| 83 | wheel from a `RouletteView`; it owns the geometry, not the rules. | 83 | it owns the geometry, not the rules. The felt is pointer-first: the hover |
| 84 | snaps to the fine lattice, so the inner half of a cell is a straight, an edge | ||
| 85 | is a split and a crossing is a corner. Left click adds the selected chip, | ||
| 86 | right or middle click takes one back, and a translucent chip previews the | ||
| 87 | target under the pointer. | ||
| 84 | - `gui/src/app.rs` composes the persistent header (connection, account, | 88 | - `gui/src/app.rs` composes the persistent header (connection, account, |
| 85 | balance) with the table and the betting sidebar. The demo state animates a | 89 | balance) with the table and the betting sidebar. The demo state animates a |
| 86 | 2.4 s wheel spin and highlights the winning pocket and cell. | 90 | 2.4 s wheel spin, highlights the winning pocket and cell, and reports the |
| 91 | amount paid and the net for the round. SPIN and CLEAR sit side by side so | ||
| 92 | clearing is never below the fold. | ||
| 87 | - `gui/tests/layout.rs` covers the pure maths (money grouping, wheel angle, | 93 | - `gui/tests/layout.rs` covers the pure maths (money grouping, wheel angle, |
| 88 | table aspect, bet names). UI wiring tests arrive with `egui_kittest` in | 94 | table aspect, bet names). UI wiring tests arrive with `egui_kittest` in |
| 89 | Phase 4. | 95 | Phase 4. |
diff --git a/docs/TESTING.md b/docs/TESTING.md index f42b84a..32fc628 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md | |||
| @@ -125,21 +125,27 @@ Windows 10 before approving the look. | |||
| 125 | 1. `cargo run -p zagan-bet-gui` at 1440x900, then at 1920x1080. The header | 125 | 1. `cargo run -p zagan-bet-gui` at 1440x900, then at 1920x1080. The header |
| 126 | keeps its right margin at both sizes and shows ONLINE, host, ping, account | 126 | keeps its right margin at both sizes and shows ONLINE, host, ping, account |
| 127 | and balance; the table is centred and the sidebar is full height. | 127 | and balance; the table is centred and the sidebar is full height. |
| 128 | 2. The felt shows 0, the 3x12 numbers, dozens, columns and the six outside | 128 | 2. The felt shows 0, the 3x12 numbers, dozens, columns (each a quarter of the |
| 129 | cells. Chips sit on their targets: a straight in the cell, a corner on the | 129 | row) and the six outside cells. Chips sit on their targets: a straight in |
| 130 | lattice crossing, an outside bet in its box. | 130 | the cell, a corner on the lattice crossing, an outside bet in its box. |
| 131 | 3. Click a pocket: a chip of the selected denomination appears and the slip | 131 | 3. Hover a number: a translucent chip of the selected denomination appears. |
| 132 | gains a row. Chips are 10/25/50/100; the selected one has a cyan ring. | 132 | In the inner half of a cell it is a straight (one cell tints); near an edge |
| 133 | 4. Arrow keys move the cursor. Over the numbers the cyan marker sits on the | 133 | it snaps to a split (two cells tint) and near a corner to a corner (four |
| 134 | cell or lattice point and the covered cells tint; leaving the table walks | 134 | cells tint). Hovering the dozens, columns and outside boxes moves the |
| 135 | dozens, columns and outside. | 135 | preview there. |
| 136 | 4. Left click adds the chip where the preview is; right or middle click takes | ||
| 137 | one chip of that value back, dropping the row when it reaches zero. The | ||
| 138 | slip and total follow every change. C or CLEAR empties the slip. | ||
| 136 | 5. Space or SPIN animates the wheel for about 2.4 s and stops with the winning | 139 | 5. Space or SPIN animates the wheel for about 2.4 s and stops with the winning |
| 137 | pocket under the pointer. The winning cell gets a gold outline, RESULT names | 140 | pocket under the pointer. The winning cell gets a gold outline and LAST |
| 138 | the number and colour, and LAST gains a dot. SPIN is disabled during the | 141 | gains a dot. RESULT names the number and colour, WON shows the amount paid |
| 139 | spin. | 142 | and the net for the round, and a losing spin says NO WIN. SPIN, CLEAR and |
| 140 | 6. Clear bets empties the slip and the total. Resize to 1100x720: the sidebar | 143 | the chips are disabled during the spin. |
| 141 | scrolls, nothing overlaps the header, and the table keeps its aspect ratio. | 144 | 6. Placing a chip after a result starts the next round: the payout row goes |
| 142 | 7. Compare the 1080p window against the approved reference screenshot. | 145 | back to PLACE YOUR BETS while the wheel stays where it stopped. Resize to |
| 146 | 1100x720: the sidebar scrolls, nothing overlaps the header, and the table | ||
| 147 | keeps its aspect ratio. | ||
| 148 | 7. Compare the 1080p window against the approved reference screenshots. | ||
| 143 | 149 | ||
| 144 | ## Manual roulette checklist | 150 | ## Manual roulette checklist |
| 145 | 151 | ||
diff --git a/docs/screenshots/gui-roulette-1080p.png b/docs/screenshots/gui-roulette-1080p.png index b6c9b17..2f30aa7 100644 --- a/docs/screenshots/gui-roulette-1080p.png +++ b/docs/screenshots/gui-roulette-1080p.png | |||
| Binary files differ | |||
diff --git a/docs/screenshots/gui-roulette-result-1080p.png b/docs/screenshots/gui-roulette-result-1080p.png index 0f37133..a7d425c 100644 --- a/docs/screenshots/gui-roulette-result-1080p.png +++ b/docs/screenshots/gui-roulette-result-1080p.png | |||
| Binary files differ | |||
diff --git a/gui/Cargo.toml b/gui/Cargo.toml index a2f4154..a0dfb86 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml | |||
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet-gui" | 2 | name = "zagan-bet-gui" |
| 3 | version = "4.1.0" | 3 | version = "4.1.1" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | license = "GPL-3.0-or-later" | 5 | license = "GPL-3.0-or-later" |
| 6 | description = "Desktop frontend for zagan-bet" | 6 | description = "Desktop frontend for zagan-bet" |
diff --git a/gui/src/app.rs b/gui/src/app.rs index fde117a..b5a50b3 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs | |||
| @@ -10,11 +10,12 @@ use eframe::egui::{ | |||
| 10 | self, Align, Button, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel, | 10 | self, Align, Button, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel, |
| 11 | RichText, Sense, Stroke, Ui, Vec2, vec2, | 11 | RichText, Sense, Stroke, Ui, Vec2, vec2, |
| 12 | }; | 12 | }; |
| 13 | use zagan_bet::client::games::roulette::{Cursor, Direction}; | 13 | use zagan_bet::client::games::roulette::Cursor; |
| 14 | use zagan_bet::client::view::{RoulettePhase, RouletteView}; | 14 | use zagan_bet::client::view::{RoulettePhase, RouletteView}; |
| 15 | use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color}; | 15 | use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color}; |
| 16 | use zagan_bet::version::VERSION; | 16 | use zagan_bet::version::VERSION; |
| 17 | 17 | ||
| 18 | use crate::roulette::FeltAction; | ||
| 18 | use crate::{roulette, theme}; | 19 | use crate::{roulette, theme}; |
| 19 | 20 | ||
| 20 | const HEADER_HEIGHT: f32 = 64.0; | 21 | const HEADER_HEIGHT: f32 = 64.0; |
| @@ -218,10 +219,11 @@ fn sidebar(ui: &mut Ui, demo: &mut Demo) { | |||
| 218 | 219 | ||
| 219 | ui.label(theme::label("Chip")); | 220 | ui.label(theme::label("Chip")); |
| 220 | ui.add_space(theme::SPACE_2); | 221 | ui.add_space(theme::SPACE_2); |
| 222 | let spinning = demo.phase == RoulettePhase::Spinning; | ||
| 221 | ui.horizontal(|ui| { | 223 | ui.horizontal(|ui| { |
| 222 | ui.spacing_mut().item_spacing.x = theme::SPACE_2; | 224 | ui.spacing_mut().item_spacing.x = theme::SPACE_2; |
| 223 | for amount in CHIP_SIZES { | 225 | for amount in CHIP_SIZES { |
| 224 | if chip_button(ui, amount, demo.chip == amount).clicked() { | 226 | if chip_button(ui, amount, demo.chip == amount, !spinning).clicked() { |
| 225 | demo.chip = amount; | 227 | demo.chip = amount; |
| 226 | } | 228 | } |
| 227 | } | 229 | } |
| @@ -247,80 +249,118 @@ fn sidebar(ui: &mut Ui, demo: &mut Demo) { | |||
| 247 | ui.add_space(theme::SPACE_2); | 249 | ui.add_space(theme::SPACE_2); |
| 248 | bet_row(ui, "Total", demo.bets.total()); | 250 | bet_row(ui, "Total", demo.bets.total()); |
| 249 | 251 | ||
| 250 | ui.add_space(theme::SPACE_3); | 252 | ui.add_space(theme::SPACE_4); |
| 251 | let spinning = demo.phase == RoulettePhase::Spinning; | 253 | let actions = ui.available_width(); |
| 252 | let spin = Button::new( | 254 | let clear_width = 108.0; |
| 253 | RichText::new("SPIN") | 255 | ui.horizontal(|ui| { |
| 254 | .font(theme::mono(theme::FONT_MONO)) | 256 | ui.spacing_mut().item_spacing.x = theme::SPACE_2; |
| 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 | 257 | ||
| 268 | ui.add_space(theme::SPACE_2); | 258 | let spin = Button::new( |
| 269 | let clear = Button::new( | 259 | RichText::new("SPIN") |
| 270 | RichText::new("Clear bets") | 260 | .font(theme::mono(theme::FONT_MONO)) |
| 271 | .font(theme::sans(12.0)) | 261 | .color(theme::BG), |
| 272 | .color(theme::TEXT_DIM), | 262 | ) |
| 273 | ) | 263 | .fill(if spinning { |
| 274 | .frame(false); | 264 | theme::WIN.gamma_multiply(0.45) |
| 275 | if ui | 265 | } else { |
| 276 | .add_enabled(!demo.bets.is_empty() && !spinning, clear) | 266 | theme::WIN |
| 277 | .clicked() | 267 | }) |
| 278 | { | 268 | .corner_radius(theme::RADIUS_MD) |
| 279 | demo.bets = Bets::default(); | 269 | .min_size(vec2(actions - clear_width - theme::SPACE_2, 44.0)); |
| 280 | } | 270 | let can_spin = !spinning && !demo.bets.is_empty(); |
| 271 | if ui.add_enabled(can_spin, spin).clicked() { | ||
| 272 | demo.start_spin(ui.ctx()); | ||
| 273 | } | ||
| 274 | |||
| 275 | let clear = Button::new( | ||
| 276 | RichText::new("CLEAR") | ||
| 277 | .font(theme::mono(12.0)) | ||
| 278 | .color(theme::TEXT_DIM), | ||
| 279 | ) | ||
| 280 | .fill(theme::SURFACE_RAISED) | ||
| 281 | .stroke(Stroke::new(1.0, theme::LINE)) | ||
| 282 | .corner_radius(theme::RADIUS_MD) | ||
| 283 | .min_size(vec2(clear_width, 44.0)); | ||
| 284 | if ui | ||
| 285 | .add_enabled(!demo.bets.is_empty() && !spinning, clear) | ||
| 286 | .clicked() | ||
| 287 | { | ||
| 288 | demo.begin_placing(); | ||
| 289 | demo.bets = Bets::default(); | ||
| 290 | } | ||
| 291 | }); | ||
| 281 | } | 292 | } |
| 282 | 293 | ||
| 283 | fn result_line(ui: &mut Ui, demo: &Demo) { | 294 | fn result_line(ui: &mut Ui, demo: &Demo) { |
| 284 | ui.horizontal(|ui| { | 295 | ui.horizontal(|ui| match (demo.phase, demo.number) { |
| 285 | match (demo.phase, demo.number) { | 296 | (RoulettePhase::Result, Some(number)) => { |
| 286 | (RoulettePhase::Result, Some(number)) => { | 297 | ui.label(theme::label("Result")); |
| 287 | ui.label(theme::label("Result")); | 298 | ui.label( |
| 288 | ui.label( | 299 | RichText::new(format!("{number} {}", wheel_color(number).to_uppercase())) |
| 289 | RichText::new(number.to_string()) | 300 | .font(theme::mono(14.0)) |
| 290 | .font(theme::mono(15.0)) | 301 | .color(color_of(number)), |
| 291 | .color(color_of(number)), | 302 | ); |
| 292 | ); | 303 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { |
| 293 | ui.label( | 304 | payout(ui, demo); |
| 294 | RichText::new(wheel_color(number).to_uppercase()) | 305 | }); |
| 295 | .font(theme::mono(12.0)) | 306 | } |
| 296 | .color(theme::TEXT_DIM), | 307 | (RoulettePhase::Spinning, _) => { |
| 297 | ); | 308 | ui.label( |
| 298 | } | 309 | RichText::new("SPINNING…") |
| 299 | (RoulettePhase::Spinning, _) => { | 310 | .font(theme::mono(12.0)) |
| 300 | ui.label( | 311 | .color(theme::GOLD), |
| 301 | RichText::new("SPINNING…") | 312 | ); |
| 302 | .font(theme::mono(12.0)) | 313 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { |
| 303 | .color(theme::GOLD), | ||
| 304 | ); | ||
| 305 | } | ||
| 306 | _ => { | ||
| 307 | ui.label( | 314 | ui.label( |
| 308 | RichText::new("Place your bets") | 315 | RichText::new(format!("{} chips", demo.bets.chips.len())) |
| 309 | .font(theme::sans(12.0)) | 316 | .font(theme::mono(10.0)) |
| 310 | .color(theme::TEXT_DIM), | 317 | .color(theme::TEXT_FAINT), |
| 311 | ); | 318 | ); |
| 312 | } | 319 | }); |
| 313 | } | 320 | } |
| 314 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | 321 | _ => { |
| 315 | ui.label( | 322 | ui.label( |
| 316 | RichText::new(format!("{} chips", demo.bets.chips.len())) | 323 | RichText::new("Place your bets") |
| 317 | .font(theme::mono(10.0)) | 324 | .font(theme::sans(12.0)) |
| 318 | .color(theme::TEXT_FAINT), | 325 | .color(theme::TEXT_DIM), |
| 319 | ); | 326 | ); |
| 320 | }); | 327 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { |
| 328 | ui.label( | ||
| 329 | RichText::new(format!("{} chips", demo.bets.chips.len())) | ||
| 330 | .font(theme::mono(10.0)) | ||
| 331 | .color(theme::TEXT_FAINT), | ||
| 332 | ); | ||
| 333 | }); | ||
| 334 | } | ||
| 321 | }); | 335 | }); |
| 322 | } | 336 | } |
| 323 | 337 | ||
| 338 | /// What the round paid, and the net for the spin. | ||
| 339 | fn payout(ui: &mut Ui, demo: &Demo) { | ||
| 340 | let winnings = demo.winnings.unwrap_or(0); | ||
| 341 | if winnings == 0 { | ||
| 342 | ui.label( | ||
| 343 | RichText::new("NO WIN") | ||
| 344 | .font(theme::mono(13.0)) | ||
| 345 | .color(theme::TEXT_FAINT), | ||
| 346 | ); | ||
| 347 | return; | ||
| 348 | } | ||
| 349 | |||
| 350 | let profit = winnings as i64 - demo.spent as i64; | ||
| 351 | ui.label( | ||
| 352 | RichText::new(format!("({})", theme::signed_money(profit))) | ||
| 353 | .font(theme::sans(11.0)) | ||
| 354 | .color(if profit >= 0 { theme::WIN } else { theme::LOSS }), | ||
| 355 | ); | ||
| 356 | ui.label( | ||
| 357 | RichText::new(theme::money(winnings as i64)) | ||
| 358 | .font(theme::mono(15.0)) | ||
| 359 | .color(theme::WIN), | ||
| 360 | ); | ||
| 361 | ui.label(theme::label("Won")); | ||
| 362 | } | ||
| 363 | |||
| 324 | fn bet_row(ui: &mut Ui, name: &str, amount: u64) { | 364 | fn bet_row(ui: &mut Ui, name: &str, amount: u64) { |
| 325 | ui.horizontal(|ui| { | 365 | ui.horizontal(|ui| { |
| 326 | ui.label( | 366 | ui.label( |
| @@ -338,8 +378,13 @@ fn bet_row(ui: &mut Ui, name: &str, amount: u64) { | |||
| 338 | }); | 378 | }); |
| 339 | } | 379 | } |
| 340 | 380 | ||
| 341 | fn chip_button(ui: &mut Ui, amount: u64, selected: bool) -> egui::Response { | 381 | fn chip_button(ui: &mut Ui, amount: u64, selected: bool, enabled: bool) -> egui::Response { |
| 342 | let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), Sense::click()); | 382 | let sense = if enabled { |
| 383 | Sense::click() | ||
| 384 | } else { | ||
| 385 | Sense::hover() | ||
| 386 | }; | ||
| 387 | let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), sense); | ||
| 343 | let center = rect.center(); | 388 | let center = rect.center(); |
| 344 | let radius = 20.0; | 389 | let radius = 20.0; |
| 345 | let painter = ui.painter(); | 390 | let painter = ui.painter(); |
| @@ -350,7 +395,11 @@ fn chip_button(ui: &mut Ui, amount: u64, selected: bool) -> egui::Response { | |||
| 350 | Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT), | 395 | Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT), |
| 351 | ); | 396 | ); |
| 352 | } | 397 | } |
| 353 | roulette::paint_chip(painter, center, radius, amount); | 398 | if enabled { |
| 399 | roulette::paint_chip(painter, center, radius, amount); | ||
| 400 | } else { | ||
| 401 | roulette::paint_chip_preview(painter, center, radius, amount); | ||
| 402 | } | ||
| 354 | response | 403 | response |
| 355 | } | 404 | } |
| 356 | 405 | ||
| @@ -395,11 +444,20 @@ fn table(ui: &mut Ui, demo: &mut Demo) { | |||
| 395 | let side_pad = ((available.x - size.x) * 0.5).max(0.0); | 444 | 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); | 445 | let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0); |
| 397 | let chip = demo.chip; | 446 | let chip = demo.chip; |
| 447 | let placing = demo.phase != RoulettePhase::Spinning; | ||
| 398 | ui.add_space(top_pad); | 448 | ui.add_space(top_pad); |
| 399 | ui.horizontal(|ui| { | 449 | ui.horizontal(|ui| { |
| 400 | ui.add_space(side_pad); | 450 | ui.add_space(side_pad); |
| 401 | if let Some(target) = roulette::felt(ui, size, &view) { | 451 | match roulette::felt(ui, size, &view) { |
| 402 | demo.bets.add(target, chip); | 452 | Some(FeltAction::Place(target)) if placing => { |
| 453 | demo.begin_placing(); | ||
| 454 | demo.bets.add(target, chip); | ||
| 455 | } | ||
| 456 | Some(FeltAction::Reduce(target)) if placing => { | ||
| 457 | demo.begin_placing(); | ||
| 458 | demo.bets.reduce(&target, chip); | ||
| 459 | } | ||
| 460 | _ => {} | ||
| 403 | } | 461 | } |
| 404 | }); | 462 | }); |
| 405 | 463 | ||
| @@ -422,7 +480,7 @@ fn table(ui: &mut Ui, demo: &mut Demo) { | |||
| 422 | ui.add_space(theme::SPACE_2); | 480 | ui.add_space(theme::SPACE_2); |
| 423 | ui.label( | 481 | ui.label( |
| 424 | RichText::new( | 482 | RichText::new( |
| 425 | "Click a pocket to place a chip · arrow keys move the cursor · space spins", | 483 | "Click to place · right or middle click takes back · C clears · space spins", |
| 426 | ) | 484 | ) |
| 427 | .font(theme::sans(12.0)) | 485 | .font(theme::sans(12.0)) |
| 428 | .color(theme::TEXT_FAINT), | 486 | .color(theme::TEXT_FAINT), |
| @@ -442,7 +500,6 @@ struct Spin { | |||
| 442 | 500 | ||
| 443 | struct Demo { | 501 | struct Demo { |
| 444 | bets: Bets, | 502 | bets: Bets, |
| 445 | cursor: Cursor, | ||
| 446 | chip: u64, | 503 | chip: u64, |
| 447 | phase: RoulettePhase, | 504 | phase: RoulettePhase, |
| 448 | number: Option<u8>, | 505 | number: Option<u8>, |
| @@ -450,6 +507,8 @@ struct Demo { | |||
| 450 | spin: Option<Spin>, | 507 | spin: Option<Spin>, |
| 451 | spins: usize, | 508 | spins: usize, |
| 452 | last: Vec<u8>, | 509 | last: Vec<u8>, |
| 510 | winnings: Option<u64>, | ||
| 511 | spent: u64, | ||
| 453 | balance: i64, | 512 | balance: i64, |
| 454 | host: &'static str, | 513 | host: &'static str, |
| 455 | ping_ms: u32, | 514 | ping_ms: u32, |
| @@ -471,8 +530,6 @@ impl Demo { | |||
| 471 | 530 | ||
| 472 | Self { | 531 | Self { |
| 473 | bets, | 532 | 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, | 533 | chip: 25, |
| 477 | phase: RoulettePhase::Placing, | 534 | phase: RoulettePhase::Placing, |
| 478 | number: None, | 535 | number: None, |
| @@ -480,6 +537,8 @@ impl Demo { | |||
| 480 | spin: None, | 537 | spin: None, |
| 481 | spins: 0, | 538 | spins: 0, |
| 482 | last: Vec::new(), | 539 | last: Vec::new(), |
| 540 | winnings: None, | ||
| 541 | spent: 0, | ||
| 483 | balance: 1000, | 542 | balance: 1000, |
| 484 | host: "127.0.0.1:11525", | 543 | host: "127.0.0.1:11525", |
| 485 | ping_ms: 41, | 544 | ping_ms: 41, |
| @@ -494,15 +553,24 @@ impl Demo { | |||
| 494 | balance: Some(self.balance), | 553 | balance: Some(self.balance), |
| 495 | chip: self.chip, | 554 | chip: self.chip, |
| 496 | bets: self.bets.clone(), | 555 | bets: self.bets.clone(), |
| 497 | cursor: self.cursor, | 556 | cursor: Cursor::default(), |
| 498 | phase: self.phase, | 557 | phase: self.phase, |
| 499 | number: self.number, | 558 | number: self.number, |
| 500 | color: self.number.map(|number| wheel_color(number).to_string()), | 559 | color: self.number.map(|number| wheel_color(number).to_string()), |
| 501 | winnings: None, | 560 | winnings: self.winnings, |
| 502 | error: None, | 561 | error: None, |
| 503 | } | 562 | } |
| 504 | } | 563 | } |
| 505 | 564 | ||
| 565 | /// A bet change after a result starts the next round: the wheel stays | ||
| 566 | /// where it stopped, but the payout and the winning highlight do not. | ||
| 567 | fn begin_placing(&mut self) { | ||
| 568 | if self.phase == RoulettePhase::Result { | ||
| 569 | self.phase = RoulettePhase::Placing; | ||
| 570 | self.winnings = None; | ||
| 571 | } | ||
| 572 | } | ||
| 573 | |||
| 506 | fn tick(&mut self, ctx: &Context) { | 574 | fn tick(&mut self, ctx: &Context) { |
| 507 | let now = ctx.input(|input| input.time); | 575 | let now = ctx.input(|input| input.time); |
| 508 | let mut finished = None; | 576 | let mut finished = None; |
| @@ -519,52 +587,37 @@ impl Demo { | |||
| 519 | self.spin = None; | 587 | self.spin = None; |
| 520 | self.phase = RoulettePhase::Result; | 588 | self.phase = RoulettePhase::Result; |
| 521 | self.number = Some(number); | 589 | self.number = Some(number); |
| 590 | self.winnings = Some(self.bets.winnings(number)); | ||
| 522 | self.last.insert(0, number); | 591 | self.last.insert(0, number); |
| 523 | self.last.truncate(12); | 592 | self.last.truncate(12); |
| 524 | } | 593 | } |
| 525 | } | 594 | } |
| 526 | 595 | ||
| 596 | /// Pointer-first: the felt reads the mouse. The keyboard keeps the two | ||
| 597 | /// verbs that have no pointer equivalent in profile (spin and clear). | ||
| 527 | fn keys(&mut self, ctx: &Context) { | 598 | fn keys(&mut self, ctx: &Context) { |
| 528 | if self.phase == RoulettePhase::Spinning { | 599 | if self.phase == RoulettePhase::Spinning { |
| 529 | return; | 600 | return; |
| 530 | } | 601 | } |
| 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()) { | 602 | 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)) { | 603 | if ctx.input(|input| input.key_pressed(Key::Space)) { |
| 557 | self.start_spin(ctx); | 604 | self.start_spin(ctx); |
| 558 | } | 605 | } |
| 606 | if ctx.input(|input| input.key_pressed(Key::C)) { | ||
| 607 | self.begin_placing(); | ||
| 608 | self.bets = Bets::default(); | ||
| 609 | } | ||
| 559 | } | 610 | } |
| 560 | } | 611 | } |
| 561 | 612 | ||
| 562 | fn start_spin(&mut self, ctx: &Context) { | 613 | fn start_spin(&mut self, ctx: &Context) { |
| 563 | if self.phase == RoulettePhase::Spinning { | 614 | if self.phase == RoulettePhase::Spinning || self.bets.is_empty() { |
| 564 | return; | 615 | return; |
| 565 | } | 616 | } |
| 566 | let target = DEMO_TARGETS[self.spins % DEMO_TARGETS.len()]; | 617 | let target = DEMO_TARGETS[self.spins % DEMO_TARGETS.len()]; |
| 567 | self.spins += 1; | 618 | self.spins += 1; |
| 619 | self.spent = self.bets.total(); | ||
| 620 | self.winnings = None; | ||
| 568 | 621 | ||
| 569 | let segment = TAU / WHEEL.len() as f32; | 622 | let segment = TAU / WHEEL.len() as f32; |
| 570 | let index = WHEEL | 623 | let index = WHEEL |
diff --git a/gui/src/roulette.rs b/gui/src/roulette.rs index 22c46d6..f674251 100644 --- a/gui/src/roulette.rs +++ b/gui/src/roulette.rs | |||
| @@ -7,10 +7,10 @@ | |||
| 7 | use std::f32::consts::{FRAC_PI_2, TAU}; | 7 | use std::f32::consts::{FRAC_PI_2, TAU}; |
| 8 | 8 | ||
| 9 | use eframe::egui::{ | 9 | use eframe::egui::{ |
| 10 | Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, | 10 | Align2, Color32, CornerRadius, Painter, PointerButton, Pos2, Rect, Sense, Shape, Stroke, |
| 11 | pos2, vec2, | 11 | StrokeKind, Ui, Vec2, pos2, vec2, |
| 12 | }; | 12 | }; |
| 13 | use zagan_bet::client::games::roulette::Cursor; | 13 | use zagan_bet::client::games::roulette::target_at as lattice_target; |
| 14 | use zagan_bet::client::view::{RoulettePhase, RouletteView}; | 14 | use zagan_bet::client::view::{RoulettePhase, RouletteView}; |
| 15 | use zagan_bet::roulette::{BetTarget, Outside, WHEEL, color as wheel_color, is_red}; | 15 | use zagan_bet::roulette::{BetTarget, Outside, WHEEL, color as wheel_color, is_red}; |
| 16 | 16 | ||
| @@ -42,18 +42,38 @@ pub fn size_for(available: Vec2) -> Vec2 { | |||
| 42 | vec2((COLUMNS as f32 + 1.0) * cell, HEIGHT_UNITS * cell) + Vec2::splat(BOARD_PAD * 2.0) | 42 | vec2((COLUMNS as f32 + 1.0) * cell, HEIGHT_UNITS * cell) + Vec2::splat(BOARD_PAD * 2.0) |
| 43 | } | 43 | } |
| 44 | 44 | ||
| 45 | /// The table painted at `size`; returns the target clicked this frame. | 45 | /// What the pointer did to the felt this frame. |
| 46 | pub fn felt(ui: &mut Ui, size: Vec2, view: &RouletteView) -> Option<BetTarget> { | 46 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 47 | pub enum FeltAction { | ||
| 48 | /// Left click: add the selected chip. | ||
| 49 | Place(BetTarget), | ||
| 50 | /// Right or middle click: take one chip back. | ||
| 51 | Reduce(BetTarget), | ||
| 52 | } | ||
| 53 | |||
| 54 | /// The table painted at `size`; returns the bet the pointer changed. | ||
| 55 | pub fn felt(ui: &mut Ui, size: Vec2, view: &RouletteView) -> Option<FeltAction> { | ||
| 47 | let (rect, response) = ui.allocate_exact_size(size, Sense::click()); | 56 | let (rect, response) = ui.allocate_exact_size(size, Sense::click()); |
| 48 | let felt = Felt::new(rect); | 57 | let felt = Felt::new(rect); |
| 49 | let hovered = response.hover_pos().and_then(|pos| felt.hit(pos)); | 58 | let hovered = if view.phase == RoulettePhase::Spinning { |
| 59 | None | ||
| 60 | } else { | ||
| 61 | response.hover_pos().and_then(|pos| felt.target_at(pos)) | ||
| 62 | }; | ||
| 50 | let painter = ui.painter(); | 63 | let painter = ui.painter(); |
| 51 | 64 | ||
| 52 | painter.rect_filled(rect, theme::RADIUS_LG, theme::FELT_DARK); | 65 | painter.rect_filled(rect, theme::RADIUS_LG, theme::FELT_DARK); |
| 53 | 66 | ||
| 67 | // Every cell the hovered bet covers tints, so a split or corner reads | ||
| 68 | // before the chip lands. | ||
| 69 | let covered = hovered.as_ref().map(BetTarget::numbers).unwrap_or_default(); | ||
| 54 | for number in 0..=36u8 { | 70 | for number in 0..=36u8 { |
| 55 | let highlighted = hovered == Some(BetTarget::Straight { number }); | 71 | felt.paint_number( |
| 56 | felt.paint_number(painter, number, highlighted); | 72 | painter, |
| 73 | number, | ||
| 74 | covered.contains(&number), | ||
| 75 | hovered == Some(BetTarget::Straight { number }), | ||
| 76 | ); | ||
| 57 | } | 77 | } |
| 58 | for index in 0..3 { | 78 | for index in 0..3 { |
| 59 | felt.paint_dozen( | 79 | felt.paint_dozen( |
| @@ -89,24 +109,38 @@ pub fn felt(ui: &mut Ui, size: Vec2, view: &RouletteView) -> Option<BetTarget> { | |||
| 89 | } | 109 | } |
| 90 | } | 110 | } |
| 91 | 111 | ||
| 92 | felt.paint_cursor(painter, &view.cursor); | ||
| 93 | |||
| 94 | for chip in &view.bets.chips { | 112 | for chip in &view.bets.chips { |
| 95 | if let Some(center) = felt.anchor(&chip.target) { | 113 | if let Some(center) = felt.anchor(&chip.target) { |
| 96 | paint_chip(painter, center, felt.cell * 0.26, chip.amount); | 114 | paint_chip(painter, center, felt.cell_size() * 0.26, chip.amount); |
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | if let Some(target) = &hovered { | ||
| 119 | if let Some(center) = felt.anchor(target) { | ||
| 120 | paint_chip_preview(painter, center, felt.cell_size() * 0.26, view.chip); | ||
| 97 | } | 121 | } |
| 98 | } | 122 | } |
| 99 | 123 | ||
| 100 | if response.clicked() { hovered } else { None } | 124 | if response.clicked_by(PointerButton::Secondary) || response.clicked_by(PointerButton::Middle) { |
| 125 | hovered.map(FeltAction::Reduce) | ||
| 126 | } else if response.clicked() { | ||
| 127 | hovered.map(FeltAction::Place) | ||
| 128 | } else { | ||
| 129 | None | ||
| 130 | } | ||
| 101 | } | 131 | } |
| 102 | 132 | ||
| 103 | struct Felt { | 133 | /// Geometry of one table: the felt is 13 cells wide (zero plus twelve |
| 134 | /// columns) and 3 + 0.72 + 0.55 + 0.62 cells tall. | ||
| 135 | /// | ||
| 136 | /// Public so the pointer snapping can be tested without a window. | ||
| 137 | pub struct Felt { | ||
| 104 | cell: f32, | 138 | cell: f32, |
| 105 | origin: Pos2, | 139 | origin: Pos2, |
| 106 | } | 140 | } |
| 107 | 141 | ||
| 108 | impl Felt { | 142 | impl Felt { |
| 109 | fn new(board: Rect) -> Self { | 143 | pub fn new(board: Rect) -> Self { |
| 110 | let inner = board.shrink(BOARD_PAD); | 144 | let inner = board.shrink(BOARD_PAD); |
| 111 | Self { | 145 | Self { |
| 112 | cell: inner.width() / (COLUMNS as f32 + 1.0), | 146 | cell: inner.width() / (COLUMNS as f32 + 1.0), |
| @@ -114,7 +148,11 @@ impl Felt { | |||
| 114 | } | 148 | } |
| 115 | } | 149 | } |
| 116 | 150 | ||
| 117 | fn number_rect(&self, number: u8) -> Rect { | 151 | pub fn cell_size(&self) -> f32 { |
| 152 | self.cell | ||
| 153 | } | ||
| 154 | |||
| 155 | pub fn number_rect(&self, number: u8) -> Rect { | ||
| 118 | debug_assert!(number <= 36); | 156 | debug_assert!(number <= 36); |
| 119 | if number == 0 { | 157 | if number == 0 { |
| 120 | return Rect::from_min_size(self.origin, vec2(self.cell, self.cell * ROWS as f32)); | 158 | return Rect::from_min_size(self.origin, vec2(self.cell, self.cell * ROWS as f32)); |
| @@ -138,8 +176,8 @@ impl Felt { | |||
| 138 | fn column_rect(&self, index: usize) -> Rect { | 176 | fn column_rect(&self, index: usize) -> Rect { |
| 139 | let y = self.origin.y + self.cell * (ROWS as f32 + DOZEN_ROW); | 177 | let y = self.origin.y + self.cell * (ROWS as f32 + DOZEN_ROW); |
| 140 | Rect::from_min_size( | 178 | Rect::from_min_size( |
| 141 | pos2(self.origin.x + self.cell * (1.0 + index as f32), y), | 179 | pos2(self.origin.x + self.cell * (1.0 + 4.0 * index as f32), y), |
| 142 | vec2(self.cell, self.cell * COLUMN_ROW), | 180 | vec2(self.cell * 4.0, self.cell * COLUMN_ROW), |
| 143 | ) | 181 | ) |
| 144 | } | 182 | } |
| 145 | 183 | ||
| @@ -152,7 +190,7 @@ impl Felt { | |||
| 152 | } | 190 | } |
| 153 | 191 | ||
| 154 | /// Where a chip sits for this target. | 192 | /// Where a chip sits for this target. |
| 155 | fn anchor(&self, target: &BetTarget) -> Option<Pos2> { | 193 | pub fn anchor(&self, target: &BetTarget) -> Option<Pos2> { |
| 156 | Some(match target { | 194 | Some(match target { |
| 157 | BetTarget::Straight { number } => self.number_rect(*number).center(), | 195 | BetTarget::Straight { number } => self.number_rect(*number).center(), |
| 158 | BetTarget::Split { a, b } => { | 196 | BetTarget::Split { a, b } => { |
| @@ -178,20 +216,28 @@ impl Felt { | |||
| 178 | }) | 216 | }) |
| 179 | } | 217 | } |
| 180 | 218 | ||
| 181 | /// The fine-grid point the cursor occupies over the numbers. | 219 | fn grid_origin(&self) -> Pos2 { |
| 182 | fn lattice(&self, x: usize, y: usize) -> Pos2 { | 220 | self.origin + vec2(self.cell, 0.0) |
| 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 | } | 221 | } |
| 188 | 222 | ||
| 189 | fn hit(&self, pos: Pos2) -> Option<BetTarget> { | 223 | /// The bet under the pointer, snapped to the fine lattice: a cell centre |
| 190 | for number in 0..=36u8 { | 224 | /// is a straight, a line is a split, a crossing is a corner. |
| 191 | if self.number_rect(number).contains(pos) { | 225 | pub fn target_at(&self, pos: Pos2) -> Option<BetTarget> { |
| 192 | return Some(BetTarget::Straight { number }); | 226 | if self.number_rect(0).contains(pos) { |
| 193 | } | 227 | return Some(BetTarget::Straight { number: 0 }); |
| 228 | } | ||
| 229 | |||
| 230 | let grid = self.grid_origin(); | ||
| 231 | let fx = (pos.x - grid.x) / self.cell; | ||
| 232 | let fy = (pos.y - grid.y) / self.cell; | ||
| 233 | if (0.0..COLUMNS as f32).contains(&fx) && (0.0..ROWS as f32).contains(&fy) { | ||
| 234 | // Thresholds sit a quarter cell from a centre, so the inner half | ||
| 235 | // of a cell is a straight and its edges are splits or corners. | ||
| 236 | let x = (fx * 2.0).round().clamp(1.0, 23.0) as usize; | ||
| 237 | let y = (fy * 2.0).round().clamp(1.0, 5.0) as usize; | ||
| 238 | return lattice_target(x, y); | ||
| 194 | } | 239 | } |
| 240 | |||
| 195 | for index in 0..3usize { | 241 | for index in 0..3usize { |
| 196 | if self.dozen_rect(index).contains(pos) { | 242 | if self.dozen_rect(index).contains(pos) { |
| 197 | return Some(BetTarget::Dozen { index: index as u8 }); | 243 | return Some(BetTarget::Dozen { index: index as u8 }); |
| @@ -208,7 +254,7 @@ impl Felt { | |||
| 208 | None | 254 | None |
| 209 | } | 255 | } |
| 210 | 256 | ||
| 211 | fn paint_number(&self, painter: &Painter, number: u8, hovered: bool) { | 257 | fn paint_number(&self, painter: &Painter, number: u8, covered: bool, hovered: bool) { |
| 212 | let rect = self.number_rect(number); | 258 | let rect = self.number_rect(number); |
| 213 | let fill = if number == 0 { | 259 | let fill = if number == 0 { |
| 214 | theme::ROULETTE_ZERO | 260 | theme::ROULETTE_ZERO |
| @@ -224,7 +270,9 @@ impl Felt { | |||
| 224 | Stroke::new(1.0, theme::FELT_LINE), | 270 | Stroke::new(1.0, theme::FELT_LINE), |
| 225 | StrokeKind::Inside, | 271 | StrokeKind::Inside, |
| 226 | ); | 272 | ); |
| 227 | if hovered { | 273 | if covered { |
| 274 | painter.rect_filled(rect, CornerRadius::ZERO, theme::ACCENT.gamma_multiply(0.16)); | ||
| 275 | } else if hovered { | ||
| 228 | painter.rect_filled( | 276 | painter.rect_filled( |
| 229 | rect, | 277 | rect, |
| 230 | CornerRadius::ZERO, | 278 | CornerRadius::ZERO, |
| @@ -303,28 +351,6 @@ impl Felt { | |||
| 303 | } | 351 | } |
| 304 | } | 352 | } |
| 305 | 353 | ||
| 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 { | 354 | fn number_font(&self) -> f32 { |
| 329 | (self.cell * 0.40).clamp(12.0, 22.0) | 355 | (self.cell * 0.40).clamp(12.0, 22.0) |
| 330 | } | 356 | } |
| @@ -334,15 +360,6 @@ impl Felt { | |||
| 334 | } | 360 | } |
| 335 | } | 361 | } |
| 336 | 362 | ||
| 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. | 363 | /// A single chip, also used by the sidebar's chip tray. |
| 347 | pub fn paint_chip(painter: &Painter, center: Pos2, radius: f32, amount: u64) { | 364 | pub fn paint_chip(painter: &Painter, center: Pos2, radius: f32, amount: u64) { |
| 348 | let fill = chip_color(amount); | 365 | let fill = chip_color(amount); |
| @@ -366,6 +383,23 @@ pub fn paint_chip(painter: &Painter, center: Pos2, radius: f32, amount: u64) { | |||
| 366 | ); | 383 | ); |
| 367 | } | 384 | } |
| 368 | 385 | ||
| 386 | /// The chip under the pointer, showing what a click would add. | ||
| 387 | pub fn paint_chip_preview(painter: &Painter, center: Pos2, radius: f32, amount: u64) { | ||
| 388 | painter.circle_filled(center, radius, chip_color(amount).gamma_multiply(0.45)); | ||
| 389 | painter.circle_stroke( | ||
| 390 | center, | ||
| 391 | radius, | ||
| 392 | Stroke::new(2.0, Color32::WHITE.gamma_multiply(0.85)), | ||
| 393 | ); | ||
| 394 | painter.text( | ||
| 395 | center, | ||
| 396 | Align2::CENTER_CENTER, | ||
| 397 | amount, | ||
| 398 | theme::mono(radius * 0.85), | ||
| 399 | Color32::WHITE, | ||
| 400 | ); | ||
| 401 | } | ||
| 402 | |||
| 369 | /// Colour per denomination, as a chip tray would be stocked. | 403 | /// Colour per denomination, as a chip tray would be stocked. |
| 370 | pub fn chip_color(amount: u64) -> Color32 { | 404 | pub fn chip_color(amount: u64) -> Color32 { |
| 371 | match amount { | 405 | match amount { |
| @@ -444,9 +478,8 @@ pub fn wheel(ui: &mut Ui, diameter: f32, view: &RouletteView, rotation: f32) { | |||
| 444 | painter.circle_stroke(center, inner, Stroke::new(1.0, theme::LINE)); | 478 | painter.circle_stroke(center, inner, Stroke::new(1.0, theme::LINE)); |
| 445 | 479 | ||
| 446 | let displayed = match view.phase { | 480 | let displayed = match view.phase { |
| 447 | RoulettePhase::Result => view.number, | ||
| 448 | RoulettePhase::Spinning => Some(number_under(rotation)), | 481 | RoulettePhase::Spinning => Some(number_under(rotation)), |
| 449 | RoulettePhase::Placing => None, | 482 | _ => view.number, |
| 450 | }; | 483 | }; |
| 451 | match displayed { | 484 | match displayed { |
| 452 | Some(number) => { | 485 | Some(number) => { |
diff --git a/gui/src/theme.rs b/gui/src/theme.rs index 9270aa6..0e5a475 100644 --- a/gui/src/theme.rs +++ b/gui/src/theme.rs | |||
| @@ -120,6 +120,12 @@ pub fn money(amount: i64) -> String { | |||
| 120 | format!("{} Kr", group_digits(amount)) | 120 | format!("{} Kr", group_digits(amount)) |
| 121 | } | 121 | } |
| 122 | 122 | ||
| 123 | /// Net amount with an explicit sign: `"+800 Kr"`, `"-90 Kr"`. | ||
| 124 | pub fn signed_money(amount: i64) -> String { | ||
| 125 | let sign = if amount < 0 { "-" } else { "+" }; | ||
| 126 | format!("{sign}{} Kr", group_digits(amount.abs())) | ||
| 127 | } | ||
| 128 | |||
| 123 | // --------------------------------------------------------------------------- | 129 | // --------------------------------------------------------------------------- |
| 124 | // Installation | 130 | // Installation |
| 125 | // --------------------------------------------------------------------------- | 131 | // --------------------------------------------------------------------------- |
diff --git a/gui/tests/layout.rs b/gui/tests/layout.rs index e0c0475..ab5a62e 100644 --- a/gui/tests/layout.rs +++ b/gui/tests/layout.rs | |||
| @@ -4,8 +4,9 @@ | |||
| 4 | 4 | ||
| 5 | use std::f32::consts::TAU; | 5 | use std::f32::consts::TAU; |
| 6 | 6 | ||
| 7 | use eframe::egui::{Pos2, Rect, pos2, vec2}; | ||
| 7 | use zagan_bet::roulette::{BetTarget, Outside}; | 8 | use zagan_bet::roulette::{BetTarget, Outside}; |
| 8 | use zagan_bet_gui::roulette::{number_under, size_for, target_name}; | 9 | use zagan_bet_gui::roulette::{Felt, number_under, size_for, target_name}; |
| 9 | use zagan_bet_gui::theme::{group_digits, money}; | 10 | use zagan_bet_gui::theme::{group_digits, money}; |
| 10 | 11 | ||
| 11 | #[test] | 12 | #[test] |
| @@ -49,6 +50,79 @@ fn table_size_keeps_the_felt_aspect_and_fits() { | |||
| 49 | assert!((cell - (size.y - 12.0) / 4.89).abs() < 0.01); | 50 | assert!((cell - (size.y - 12.0) / 4.89).abs() < 0.01); |
| 50 | } | 51 | } |
| 51 | 52 | ||
| 53 | fn felt() -> Felt { | ||
| 54 | Felt::new(Rect::from_min_size( | ||
| 55 | Pos2::ZERO, | ||
| 56 | size_for(vec2(1300.0, 900.0)), | ||
| 57 | )) | ||
| 58 | } | ||
| 59 | |||
| 60 | #[test] | ||
| 61 | fn the_pointer_snaps_to_straights_splits_and_corners() { | ||
| 62 | let felt = felt(); | ||
| 63 | let cell = felt.cell_size(); | ||
| 64 | let center = felt.number_rect(17).center(); | ||
| 65 | |||
| 66 | // The inner half of a cell is a straight, even slightly off centre. | ||
| 67 | assert_eq!( | ||
| 68 | felt.target_at(center + vec2(cell * 0.2, 0.0)), | ||
| 69 | Some(BetTarget::Straight { number: 17 }) | ||
| 70 | ); | ||
| 71 | |||
| 72 | // A quarter cell from the centre is the line: right of 17 is 20, above is 18. | ||
| 73 | assert_eq!( | ||
| 74 | felt.target_at(center + vec2(cell * 0.3, 0.0)), | ||
| 75 | Some(BetTarget::Split { a: 17, b: 20 }) | ||
| 76 | ); | ||
| 77 | assert_eq!( | ||
| 78 | felt.target_at(center + vec2(0.0, -cell * 0.3)), | ||
| 79 | Some(BetTarget::Split { a: 17, b: 18 }) | ||
| 80 | ); | ||
| 81 | |||
| 82 | // Near both lines at once is the corner of the four cells. | ||
| 83 | assert_eq!( | ||
| 84 | felt.target_at(center + vec2(cell * 0.3, -cell * 0.3)), | ||
| 85 | Some(BetTarget::Corner { | ||
| 86 | numbers: [17, 18, 20, 21] | ||
| 87 | }) | ||
| 88 | ); | ||
| 89 | |||
| 90 | // Zero is a straight and nothing else. | ||
| 91 | assert_eq!( | ||
| 92 | felt.target_at(felt.number_rect(0).center()), | ||
| 93 | Some(BetTarget::Straight { number: 0 }) | ||
| 94 | ); | ||
| 95 | } | ||
| 96 | |||
| 97 | #[test] | ||
| 98 | fn the_pointer_finds_the_wide_bets_below_the_grid() { | ||
| 99 | let felt = felt(); | ||
| 100 | let cell = felt.cell_size(); | ||
| 101 | let x = felt.number_rect(17).center().x; | ||
| 102 | let grid_bottom = felt.number_rect(16).bottom(); | ||
| 103 | |||
| 104 | // 17 sits in the second dozen (13-24). | ||
| 105 | assert_eq!( | ||
| 106 | felt.target_at(pos2(x, grid_bottom + cell * 0.3)), | ||
| 107 | Some(BetTarget::Dozen { index: 1 }) | ||
| 108 | ); | ||
| 109 | |||
| 110 | // Below the dozens is the column row; 17 is in column 2 (2,5,...,17). | ||
| 111 | assert_eq!( | ||
| 112 | felt.target_at(pos2(x, grid_bottom + cell * (0.72 + 0.3))), | ||
| 113 | Some(BetTarget::Column { index: 1 }) | ||
| 114 | ); | ||
| 115 | |||
| 116 | // And below that, the outside row: 17's column lands on RED. | ||
| 117 | assert_eq!( | ||
| 118 | felt.target_at(pos2(x, grid_bottom + cell * (0.72 + 0.55 + 0.3))), | ||
| 119 | Some(BetTarget::Outside { kind: Outside::Red }) | ||
| 120 | ); | ||
| 121 | |||
| 122 | // Outside the board is not a bet. | ||
| 123 | assert_eq!(felt.target_at(pos2(-1.0, -1.0)), None); | ||
| 124 | } | ||
| 125 | |||
| 52 | #[test] | 126 | #[test] |
| 53 | fn targets_read_as_bet_slips() { | 127 | fn targets_read_as_bet_slips() { |
| 54 | assert_eq!( | 128 | assert_eq!( |
diff --git a/src/client/games/roulette.rs b/src/client/games/roulette.rs index ec9573c..f0ed816 100644 --- a/src/client/games/roulette.rs +++ b/src/client/games/roulette.rs | |||
| @@ -95,7 +95,7 @@ impl Cursor { | |||
| 95 | pub fn target(&self) -> Option<BetTarget> { | 95 | pub fn target(&self) -> Option<BetTarget> { |
| 96 | match self { | 96 | match self { |
| 97 | Cursor::Zero => Some(BetTarget::Straight { number: 0 }), | 97 | Cursor::Zero => Some(BetTarget::Straight { number: 0 }), |
| 98 | Cursor::Lattice { x, y } => felt_target(*x, *y), | 98 | Cursor::Lattice { x, y } => target_at(*x, *y), |
| 99 | Cursor::Dozen(index) => Some(BetTarget::Dozen { | 99 | Cursor::Dozen(index) => Some(BetTarget::Dozen { |
| 100 | index: *index as u8, | 100 | index: *index as u8, |
| 101 | }), | 101 | }), |
| @@ -171,7 +171,11 @@ fn cell_number(column: usize, row: usize) -> Option<u8> { | |||
| 171 | } | 171 | } |
| 172 | 172 | ||
| 173 | /// Bet under a point of the fine grid. | 173 | /// Bet under a point of the fine grid. |
| 174 | fn felt_target(x: usize, y: usize) -> Option<BetTarget> { | 174 | /// |
| 175 | /// Both frontends use this: the terminal moves a cursor over the lattice, and | ||
| 176 | /// the GUI snaps a mouse position to it. Odd coordinates are cell centres, | ||
| 177 | /// even coordinates are the lines where a split or corner chip sits. | ||
| 178 | pub fn target_at(x: usize, y: usize) -> Option<BetTarget> { | ||
| 175 | if !(1..=FELT_X_MAX).contains(&x) || !(1..=FELT_Y_MAX).contains(&y) { | 179 | if !(1..=FELT_X_MAX).contains(&x) || !(1..=FELT_Y_MAX).contains(&y) { |
| 176 | return None; | 180 | return None; |
| 177 | } | 181 | } |
diff --git a/src/roulette.rs b/src/roulette.rs index 6b73c2a..b697e31 100644 --- a/src/roulette.rs +++ b/src/roulette.rs | |||
| @@ -226,6 +226,16 @@ impl Bets { | |||
| 226 | } | 226 | } |
| 227 | } | 227 | } |
| 228 | 228 | ||
| 229 | /// Take `amount` back from one target, dropping the chip when it empties. | ||
| 230 | /// | ||
| 231 | /// This is the felt undo: a right or middle click removes one chip. | ||
| 232 | pub fn reduce(&mut self, target: &BetTarget, amount: u64) { | ||
| 233 | if let Some(chip) = self.chips.iter_mut().find(|chip| &chip.target == target) { | ||
| 234 | chip.amount = chip.amount.saturating_sub(amount); | ||
| 235 | } | ||
| 236 | self.chips.retain(|chip| chip.amount > 0); | ||
| 237 | } | ||
| 238 | |||
| 229 | /// Total returned for a winning number, including returned stakes. | 239 | /// Total returned for a winning number, including returned stakes. |
| 230 | pub fn winnings(&self, number: u8) -> u64 { | 240 | pub fn winnings(&self, number: u8) -> u64 { |
| 231 | self.chips.iter().fold(0u64, |total, chip| { | 241 | self.chips.iter().fold(0u64, |total, chip| { |
diff --git a/tests/roulette.rs b/tests/roulette.rs index 0c5e195..970ab6a 100644 --- a/tests/roulette.rs +++ b/tests/roulette.rs | |||
| @@ -396,3 +396,23 @@ fn rejects_bad_roulette_results() { | |||
| 396 | assert!(parse_roulette_response("ROULETTE_RESULT s 1 1 1 17 black nope").is_err()); | 396 | assert!(parse_roulette_response("ROULETTE_RESULT s 1 1 1 17 black nope").is_err()); |
| 397 | assert!(parse_roulette_response("").is_err()); | 397 | assert!(parse_roulette_response("").is_err()); |
| 398 | } | 398 | } |
| 399 | |||
| 400 | #[test] | ||
| 401 | fn chips_can_be_taken_back_one_at_a_time() { | ||
| 402 | let mut bets = Bets::default(); | ||
| 403 | bets.add(BetTarget::Straight { number: 17 }, 50); | ||
| 404 | bets.add(BetTarget::Outside { kind: Outside::Red }, 10); | ||
| 405 | |||
| 406 | bets.reduce(&BetTarget::Straight { number: 17 }, 25); | ||
| 407 | assert_eq!(bets.amount_on(&BetTarget::Straight { number: 17 }), 25); | ||
| 408 | assert_eq!(bets.total(), 35); | ||
| 409 | |||
| 410 | // Reducing past zero drops the chip instead of going negative. | ||
| 411 | bets.reduce(&BetTarget::Straight { number: 17 }, 100); | ||
| 412 | assert_eq!(bets.amount_on(&BetTarget::Straight { number: 17 }), 0); | ||
| 413 | assert_eq!(bets.total(), 10); | ||
| 414 | |||
| 415 | // A target with no chip is left alone. | ||
| 416 | bets.reduce(&BetTarget::Dozen { index: 0 }, 25); | ||
| 417 | assert_eq!(bets.total(), 10); | ||
| 418 | } | ||
