summaryrefslogtreecommitdiffstats
path: root/gui
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--gui/Cargo.toml2
-rw-r--r--gui/src/app.rs253
-rw-r--r--gui/src/roulette.rs161
-rw-r--r--gui/src/theme.rs6
-rw-r--r--gui/tests/layout.rs76
5 files changed, 332 insertions, 166 deletions
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]
2name = "zagan-bet-gui" 2name = "zagan-bet-gui"
3version = "4.1.0" 3version = "4.1.1"
4edition = "2024" 4edition = "2024"
5license = "GPL-3.0-or-later" 5license = "GPL-3.0-or-later"
6description = "Desktop frontend for zagan-bet" 6description = "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};
13use zagan_bet::client::games::roulette::{Cursor, Direction}; 13use zagan_bet::client::games::roulette::Cursor;
14use zagan_bet::client::view::{RoulettePhase, RouletteView}; 14use zagan_bet::client::view::{RoulettePhase, RouletteView};
15use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color}; 15use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color};
16use zagan_bet::version::VERSION; 16use zagan_bet::version::VERSION;
17 17
18use crate::roulette::FeltAction;
18use crate::{roulette, theme}; 19use crate::{roulette, theme};
19 20
20const HEADER_HEIGHT: f32 = 64.0; 21const 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
283fn result_line(ui: &mut Ui, demo: &Demo) { 294fn 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.
339fn 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
324fn bet_row(ui: &mut Ui, name: &str, amount: u64) { 364fn 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
341fn chip_button(ui: &mut Ui, amount: u64, selected: bool) -> egui::Response { 381fn 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
443struct Demo { 501struct 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 @@
7use std::f32::consts::{FRAC_PI_2, TAU}; 7use std::f32::consts::{FRAC_PI_2, TAU};
8 8
9use eframe::egui::{ 9use 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};
13use zagan_bet::client::games::roulette::Cursor; 13use zagan_bet::client::games::roulette::target_at as lattice_target;
14use zagan_bet::client::view::{RoulettePhase, RouletteView}; 14use zagan_bet::client::view::{RoulettePhase, RouletteView};
15use zagan_bet::roulette::{BetTarget, Outside, WHEEL, color as wheel_color, is_red}; 15use 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.
46pub fn felt(ui: &mut Ui, size: Vec2, view: &RouletteView) -> Option<BetTarget> { 46#[derive(Debug, Clone, PartialEq, Eq)]
47pub 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.
55pub 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
103struct 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.
137pub struct Felt {
104 cell: f32, 138 cell: f32,
105 origin: Pos2, 139 origin: Pos2,
106} 140}
107 141
108impl Felt { 142impl 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
337fn 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.
347pub fn paint_chip(painter: &Painter, center: Pos2, radius: f32, amount: u64) { 364pub 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.
387pub 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.
370pub fn chip_color(amount: u64) -> Color32 { 404pub 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"`.
124pub 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
5use std::f32::consts::TAU; 5use std::f32::consts::TAU;
6 6
7use eframe::egui::{Pos2, Rect, pos2, vec2};
7use zagan_bet::roulette::{BetTarget, Outside}; 8use zagan_bet::roulette::{BetTarget, Outside};
8use zagan_bet_gui::roulette::{number_under, size_for, target_name}; 9use zagan_bet_gui::roulette::{Felt, number_under, size_for, target_name};
9use zagan_bet_gui::theme::{group_digits, money}; 10use 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
53fn 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]
61fn 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]
98fn 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]
53fn targets_read_as_bet_slips() { 127fn targets_read_as_bet_slips() {
54 assert_eq!( 128 assert_eq!(