summaryrefslogtreecommitdiffstats
path: root/gui/src/roulette.rs
diff options
context:
space:
mode:
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}