summaryrefslogtreecommitdiffstats
path: root/gui/src/app.rs
diff options
context:
space:
mode:
Diffstat (limited to 'gui/src/app.rs')
-rw-r--r--gui/src/app.rs587
1 files changed, 587 insertions, 0 deletions
diff --git a/gui/src/app.rs b/gui/src/app.rs
new file mode 100644
index 0000000..fde117a
--- /dev/null
+++ b/gui/src/app.rs
@@ -0,0 +1,587 @@
1//! The desktop shell: header, roulette table, and betting sidebar.
2//!
3//! Phase 3 is a style spike. The table is painted from a demo `RouletteView`
4//! built with the core's own types, but no command is sent yet: Phase 4 wires
5//! the transport and replaces the demo state with `ClientCore`.
6
7use std::f32::consts::TAU;
8
9use eframe::egui::{
10 self, Align, Button, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel,
11 RichText, Sense, Stroke, Ui, Vec2, vec2,
12};
13use zagan_bet::client::games::roulette::{Cursor, Direction};
14use zagan_bet::client::view::{RoulettePhase, RouletteView};
15use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color};
16use zagan_bet::version::VERSION;
17
18use crate::{roulette, theme};
19
20const HEADER_HEIGHT: f32 = 64.0;
21const SIDEBAR_WIDTH: f32 = 336.0;
22const SPIN_SECONDS: f64 = 2.4;
23/// Demo winners, cycled: a red, a zero, a red, a black, a black, a black.
24const DEMO_TARGETS: [u8; 6] = [17, 0, 32, 26, 8, 11];
25const CHIP_SIZES: [u64; 4] = [10, 25, 50, 100];
26
27pub struct ZaganApp {
28 demo: Demo,
29}
30
31impl ZaganApp {
32 pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
33 theme::install(&cc.egui_ctx);
34 Self { demo: Demo::new() }
35 }
36}
37
38impl eframe::App for ZaganApp {
39 fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
40 self.demo.tick(ui.ctx());
41 self.demo.keys(ui.ctx());
42
43 Panel::top("header")
44 .exact_size(HEADER_HEIGHT)
45 .frame(
46 Frame::new()
47 .fill(theme::SURFACE)
48 .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 0)),
49 )
50 .show(ui, |ui| header(ui, &self.demo));
51
52 Panel::right("sidebar")
53 .exact_size(SIDEBAR_WIDTH)
54 .frame(
55 Frame::new()
56 .fill(theme::SURFACE)
57 .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 20)),
58 )
59 .show(ui, |ui| {
60 egui::ScrollArea::vertical()
61 .auto_shrink([false, false])
62 .show(ui, |ui| sidebar(ui, &mut self.demo));
63 });
64
65 CentralPanel::default()
66 .frame(
67 Frame::new()
68 .fill(theme::BG)
69 .inner_margin(Margin::same(theme::SPACE_5 as i8)),
70 )
71 .show(ui, |ui| table(ui, &mut self.demo));
72 }
73}
74
75// ---------------------------------------------------------------------------
76// Header
77// ---------------------------------------------------------------------------
78
79fn header(ui: &mut Ui, demo: &Demo) {
80 ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
81 let (mark, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover());
82 ui.painter().rect_filled(mark, theme::RADIUS_SM, theme::WIN);
83 ui.add_space(theme::SPACE_2);
84 ui.label(
85 RichText::new("ZAGAN")
86 .font(theme::mono(18.0))
87 .color(theme::TEXT),
88 );
89 ui.label(
90 RichText::new("BET")
91 .font(theme::mono(18.0))
92 .color(theme::WIN),
93 );
94 ui.add_space(theme::SPACE_3);
95 pill(ui, &format!("v{VERSION}"), theme::TEXT_FAINT);
96 ui.add_space(theme::SPACE_1);
97 pill(ui, "PREVIEW", theme::GOLD);
98 ui.add_space(theme::SPACE_5);
99 connection_pill(ui, demo);
100
101 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
102 stat_block(
103 ui,
104 150.0,
105 "Balance",
106 &theme::money(demo.balance),
107 theme::mono(20.0),
108 theme::TEXT,
109 None,
110 );
111 ui.add_space(theme::SPACE_5);
112 stat_block(
113 ui,
114 190.0,
115 "Account",
116 demo.user,
117 theme::sans(theme::FONT_BODY),
118 theme::TEXT,
119 Some(demo.account),
120 );
121 });
122 });
123}
124
125fn pill(ui: &mut Ui, text: &str, color: Color32) {
126 Frame::new()
127 .fill(theme::SURFACE_RAISED)
128 .corner_radius(theme::RADIUS_SM)
129 .inner_margin(Margin::symmetric(theme::SPACE_2 as i8, 3))
130 .show(ui, |ui| {
131 ui.label(RichText::new(text).font(theme::mono(10.0)).color(color));
132 });
133}
134
135fn connection_pill(ui: &mut Ui, demo: &Demo) {
136 Frame::new()
137 .fill(theme::SURFACE_RAISED)
138 .corner_radius(theme::RADIUS_SM)
139 .inner_margin(Margin::symmetric(theme::SPACE_3 as i8, 5))
140 .show(ui, |ui| {
141 ui.horizontal(|ui| {
142 let (dot, _) = ui.allocate_exact_size(Vec2::splat(8.0), Sense::hover());
143 ui.painter().circle_filled(dot.center(), 3.0, theme::WIN);
144 ui.label(
145 RichText::new("ONLINE")
146 .font(theme::mono(10.0))
147 .color(theme::WIN),
148 );
149 ui.label(
150 RichText::new(demo.host)
151 .font(theme::mono(12.0))
152 .color(theme::TEXT_DIM),
153 );
154 ui.label(
155 RichText::new(format!("{} ms", demo.ping_ms))
156 .font(theme::mono(12.0))
157 .color(theme::TEXT_FAINT),
158 );
159 });
160 });
161}
162
163/// A label over a value, right-aligned in a fixed slot so the header's right
164/// edge stays put as the numbers change.
165fn stat_block(
166 ui: &mut Ui,
167 width: f32,
168 label: &str,
169 value: &str,
170 value_font: egui::FontId,
171 value_color: Color32,
172 detail: Option<&str>,
173) {
174 ui.allocate_ui_with_layout(vec2(width, 44.0), Layout::top_down(Align::Max), |ui| {
175 ui.spacing_mut().item_spacing.y = 0.0;
176 ui.label(theme::label(label));
177 ui.label(RichText::new(value).font(value_font).color(value_color));
178 if let Some(detail) = detail {
179 ui.label(
180 RichText::new(detail)
181 .font(theme::mono(10.0))
182 .color(theme::TEXT_FAINT),
183 );
184 }
185 });
186}
187
188// ---------------------------------------------------------------------------
189// Sidebar
190// ---------------------------------------------------------------------------
191
192fn sidebar(ui: &mut Ui, demo: &mut Demo) {
193 ui.label(
194 RichText::new("Roulette")
195 .font(theme::sans(22.0))
196 .color(theme::TEXT),
197 );
198 ui.label(
199 RichText::new("European · single zero")
200 .font(theme::sans(12.0))
201 .color(theme::TEXT_DIM),
202 );
203
204 ui.add_space(theme::SPACE_4);
205 let view = demo.view();
206 let wheel_size = ui.available_width().min(210.0);
207 ui.horizontal(|ui| {
208 let pad = ((ui.available_width() - wheel_size) * 0.5).max(0.0);
209 ui.add_space(pad);
210 roulette::wheel(ui, wheel_size, &view, demo.rotation);
211 });
212 ui.add_space(theme::SPACE_1);
213 result_line(ui, demo);
214
215 ui.add_space(theme::SPACE_3);
216 ui.separator();
217 ui.add_space(theme::SPACE_3);
218
219 ui.label(theme::label("Chip"));
220 ui.add_space(theme::SPACE_2);
221 ui.horizontal(|ui| {
222 ui.spacing_mut().item_spacing.x = theme::SPACE_2;
223 for amount in CHIP_SIZES {
224 if chip_button(ui, amount, demo.chip == amount).clicked() {
225 demo.chip = amount;
226 }
227 }
228 });
229
230 ui.add_space(theme::SPACE_3);
231 ui.label(theme::label("Bets"));
232 ui.add_space(theme::SPACE_1);
233 if demo.bets.chips.is_empty() {
234 ui.label(
235 RichText::new("No chips on the felt")
236 .font(theme::sans(12.0))
237 .color(theme::TEXT_FAINT),
238 );
239 } else {
240 for chip in &demo.bets.chips {
241 bet_row(ui, &roulette::target_name(&chip.target), chip.amount);
242 }
243 }
244
245 ui.add_space(theme::SPACE_3);
246 ui.separator();
247 ui.add_space(theme::SPACE_2);
248 bet_row(ui, "Total", demo.bets.total());
249
250 ui.add_space(theme::SPACE_3);
251 let spinning = demo.phase == RoulettePhase::Spinning;
252 let spin = Button::new(
253 RichText::new("SPIN")
254 .font(theme::mono(theme::FONT_MONO))
255 .color(theme::BG),
256 )
257 .fill(if spinning {
258 theme::WIN.gamma_multiply(0.45)
259 } else {
260 theme::WIN
261 })
262 .corner_radius(theme::RADIUS_MD)
263 .min_size(vec2(ui.available_width(), 44.0));
264 if ui.add_enabled(!spinning, spin).clicked() {
265 demo.start_spin(ui.ctx());
266 }
267
268 ui.add_space(theme::SPACE_2);
269 let clear = Button::new(
270 RichText::new("Clear bets")
271 .font(theme::sans(12.0))
272 .color(theme::TEXT_DIM),
273 )
274 .frame(false);
275 if ui
276 .add_enabled(!demo.bets.is_empty() && !spinning, clear)
277 .clicked()
278 {
279 demo.bets = Bets::default();
280 }
281}
282
283fn result_line(ui: &mut Ui, demo: &Demo) {
284 ui.horizontal(|ui| {
285 match (demo.phase, demo.number) {
286 (RoulettePhase::Result, Some(number)) => {
287 ui.label(theme::label("Result"));
288 ui.label(
289 RichText::new(number.to_string())
290 .font(theme::mono(15.0))
291 .color(color_of(number)),
292 );
293 ui.label(
294 RichText::new(wheel_color(number).to_uppercase())
295 .font(theme::mono(12.0))
296 .color(theme::TEXT_DIM),
297 );
298 }
299 (RoulettePhase::Spinning, _) => {
300 ui.label(
301 RichText::new("SPINNING…")
302 .font(theme::mono(12.0))
303 .color(theme::GOLD),
304 );
305 }
306 _ => {
307 ui.label(
308 RichText::new("Place your bets")
309 .font(theme::sans(12.0))
310 .color(theme::TEXT_DIM),
311 );
312 }
313 }
314 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
315 ui.label(
316 RichText::new(format!("{} chips", demo.bets.chips.len()))
317 .font(theme::mono(10.0))
318 .color(theme::TEXT_FAINT),
319 );
320 });
321 });
322}
323
324fn bet_row(ui: &mut Ui, name: &str, amount: u64) {
325 ui.horizontal(|ui| {
326 ui.label(
327 RichText::new(name)
328 .font(theme::sans(13.0))
329 .color(theme::TEXT_DIM),
330 );
331 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
332 ui.label(
333 RichText::new(theme::money(amount as i64))
334 .font(theme::mono(13.0))
335 .color(theme::GOLD),
336 );
337 });
338 });
339}
340
341fn chip_button(ui: &mut Ui, amount: u64, selected: bool) -> egui::Response {
342 let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), Sense::click());
343 let center = rect.center();
344 let radius = 20.0;
345 let painter = ui.painter();
346 if selected || response.hovered() {
347 painter.circle_stroke(
348 center,
349 radius + 3.0,
350 Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT),
351 );
352 }
353 roulette::paint_chip(painter, center, radius, amount);
354 response
355}
356
357fn color_of(number: u8) -> Color32 {
358 match wheel_color(number) {
359 "red" => theme::ROULETTE_RED,
360 "green" => theme::WIN,
361 _ => theme::TEXT,
362 }
363}
364
365// ---------------------------------------------------------------------------
366// Table
367// ---------------------------------------------------------------------------
368
369fn table(ui: &mut Ui, demo: &mut Demo) {
370 ui.horizontal(|ui| {
371 ui.label(
372 RichText::new("Table 01")
373 .font(theme::sans(15.0))
374 .color(theme::TEXT),
375 );
376 ui.label(
377 RichText::new("European · single zero")
378 .font(theme::sans(12.0))
379 .color(theme::TEXT_DIM),
380 );
381 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
382 ui.label(
383 RichText::new("Limits 10 – 500 Kr")
384 .font(theme::mono(12.0))
385 .color(theme::TEXT_FAINT),
386 );
387 });
388 });
389 ui.add_space(theme::SPACE_4);
390
391 let view = demo.view();
392 let strip_height = 104.0;
393 let available = ui.available_size();
394 let size = roulette::size_for(vec2(available.x, (available.y - strip_height).max(220.0)));
395 let side_pad = ((available.x - size.x) * 0.5).max(0.0);
396 let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0);
397 let chip = demo.chip;
398 ui.add_space(top_pad);
399 ui.horizontal(|ui| {
400 ui.add_space(side_pad);
401 if let Some(target) = roulette::felt(ui, size, &view) {
402 demo.bets.add(target, chip);
403 }
404 });
405
406 ui.add_space(theme::SPACE_4);
407 ui.horizontal(|ui| {
408 ui.label(theme::label("Last"));
409 if demo.last.is_empty() {
410 ui.label(
411 RichText::new("No spins yet")
412 .font(theme::sans(12.0))
413 .color(theme::TEXT_FAINT),
414 );
415 }
416 for number in &demo.last {
417 let (dot, _) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::hover());
418 roulette::paint_result_dot(ui.painter(), dot.center(), 11.0, *number);
419 ui.add_space(theme::SPACE_1);
420 }
421 });
422 ui.add_space(theme::SPACE_2);
423 ui.label(
424 RichText::new(
425 "Click a pocket to place a chip · arrow keys move the cursor · space spins",
426 )
427 .font(theme::sans(12.0))
428 .color(theme::TEXT_FAINT),
429 );
430}
431
432// ---------------------------------------------------------------------------
433// Demo state
434// ---------------------------------------------------------------------------
435
436struct Spin {
437 started: f64,
438 from: f32,
439 delta: f32,
440 target: u8,
441}
442
443struct Demo {
444 bets: Bets,
445 cursor: Cursor,
446 chip: u64,
447 phase: RoulettePhase,
448 number: Option<u8>,
449 rotation: f32,
450 spin: Option<Spin>,
451 spins: usize,
452 last: Vec<u8>,
453 balance: i64,
454 host: &'static str,
455 ping_ms: u32,
456 user: &'static str,
457 account: &'static str,
458}
459
460impl Demo {
461 fn new() -> Self {
462 let mut bets = Bets::default();
463 bets.add(BetTarget::Straight { number: 17 }, 50);
464 bets.add(
465 BetTarget::Corner {
466 numbers: [17, 18, 20, 21],
467 },
468 25,
469 );
470 bets.add(BetTarget::Outside { kind: Outside::Red }, 25);
471
472 Self {
473 bets,
474 // A split with no chip on it, so the fine lattice cursor shows.
475 cursor: Cursor::Lattice { x: 18, y: 3 },
476 chip: 25,
477 phase: RoulettePhase::Placing,
478 number: None,
479 rotation: 0.0,
480 spin: None,
481 spins: 0,
482 last: Vec::new(),
483 balance: 1000,
484 host: "127.0.0.1:11525",
485 ping_ms: 41,
486 user: "alice",
487 account: "#4161673306",
488 }
489 }
490
491 fn view(&self) -> RouletteView {
492 RouletteView {
493 bet_amount: 10,
494 balance: Some(self.balance),
495 chip: self.chip,
496 bets: self.bets.clone(),
497 cursor: self.cursor,
498 phase: self.phase,
499 number: self.number,
500 color: self.number.map(|number| wheel_color(number).to_string()),
501 winnings: None,
502 error: None,
503 }
504 }
505
506 fn tick(&mut self, ctx: &Context) {
507 let now = ctx.input(|input| input.time);
508 let mut finished = None;
509 if let Some(spin) = &self.spin {
510 let t = (((now - spin.started) / SPIN_SECONDS) as f32).clamp(0.0, 1.0);
511 let eased = 1.0 - (1.0 - t).powi(3);
512 self.rotation = spin.from + spin.delta * eased;
513 if t >= 1.0 {
514 finished = Some(spin.target);
515 }
516 ctx.request_repaint();
517 }
518 if let Some(number) = finished {
519 self.spin = None;
520 self.phase = RoulettePhase::Result;
521 self.number = Some(number);
522 self.last.insert(0, number);
523 self.last.truncate(12);
524 }
525 }
526
527 fn keys(&mut self, ctx: &Context) {
528 if self.phase == RoulettePhase::Spinning {
529 return;
530 }
531 let (left, right, up, down) = ctx.input(|input| {
532 (
533 input.key_pressed(Key::ArrowLeft),
534 input.key_pressed(Key::ArrowRight),
535 input.key_pressed(Key::ArrowUp),
536 input.key_pressed(Key::ArrowDown),
537 )
538 });
539 for (pressed, direction) in [
540 (left, Direction::Left),
541 (right, Direction::Right),
542 (up, Direction::Up),
543 (down, Direction::Down),
544 ] {
545 if pressed {
546 self.cursor = self.cursor.moved(direction);
547 }
548 }
549 if ctx.memory(|memory| memory.focused().is_none()) {
550 if ctx.input(|input| input.key_pressed(Key::Enter)) {
551 if let Some(target) = self.cursor.target() {
552 let chip = self.chip;
553 self.bets.add(target, chip);
554 }
555 }
556 if ctx.input(|input| input.key_pressed(Key::Space)) {
557 self.start_spin(ctx);
558 }
559 }
560 }
561
562 fn start_spin(&mut self, ctx: &Context) {
563 if self.phase == RoulettePhase::Spinning {
564 return;
565 }
566 let target = DEMO_TARGETS[self.spins % DEMO_TARGETS.len()];
567 self.spins += 1;
568
569 let segment = TAU / WHEEL.len() as f32;
570 let index = WHEEL
571 .iter()
572 .position(|number| *number == target)
573 .unwrap_or(0) as f32;
574 let desired = -index * segment;
575 let delta = (desired - self.rotation).rem_euclid(TAU) + 3.0 * TAU;
576
577 self.spin = Some(Spin {
578 started: ctx.input(|input| input.time),
579 from: self.rotation,
580 delta,
581 target,
582 });
583 self.phase = RoulettePhase::Spinning;
584 self.number = None;
585 ctx.request_repaint();
586 }
587}