summaryrefslogtreecommitdiffstats
path: root/gui/src/roulette.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 19:58:59 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 19:58:59 +0100
commit10ff558853f549d3f3c7c8432488bfad9da6973a (patch)
tree271b05fead384e2d91e5c8332fea9d00b540f0cc /gui/src/roulette.rs
parentv4.0.7: finish the Phase 1 boundary with a command shell (diff)
downloadzagan-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 'gui/src/roulette.rs')
-rw-r--r--gui/src/roulette.rs522
1 files changed, 522 insertions, 0 deletions
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
7use std::f32::consts::{FRAC_PI_2, TAU};
8
9use eframe::egui::{
10 Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2,
11 pos2, vec2,
12};
13use zagan_bet::client::games::roulette::Cursor;
14use zagan_bet::client::view::{RoulettePhase, RouletteView};
15use zagan_bet::roulette::{BetTarget, Outside, WHEEL, color as wheel_color, is_red};
16
17use crate::theme;
18
19const COLUMNS: usize = 12;
20const ROWS: usize = 3;
21const OUTSIDE_ORDER: [Outside; 6] = [
22 Outside::Low,
23 Outside::Even,
24 Outside::Red,
25 Outside::Black,
26 Outside::Odd,
27 Outside::High,
28];
29
30const DOZEN_ROW: f32 = 0.72;
31const COLUMN_ROW: f32 = 0.55;
32const OUTSIDE_ROW: f32 = 0.62;
33const HEIGHT_UNITS: f32 = ROWS as f32 + DOZEN_ROW + COLUMN_ROW + OUTSIDE_ROW;
34const BOARD_PAD: f32 = 6.0;
35
36/// Largest table that fits in `available`, including its felt border.
37pub 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.
46pub 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
103struct Felt {
104 cell: f32,
105 origin: Pos2,
106}
107
108impl 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
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.
347pub 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.
370pub 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
379fn 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.
388pub 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.
396pub 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".
479pub 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
497fn 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.
508pub 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}