summaryrefslogtreecommitdiffstats
path: root/gui/src/roulette.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 21:09:56 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 21:09:56 +0100
commitbf4cf78ab26896faf457273e37d30fc96381c4a4 (patch)
tree71d67c300b6be191016a988ed5338e15c8b1bdd3 /gui/src/roulette.rs
parentv4.1.1: pointer-first betting and visible payouts in the GUI (diff)
downloadzagan-bet-bf4cf78ab26896faf457273e37d30fc96381c4a4.tar.gz
zagan-bet-bf4cf78ab26896faf457273e37d30fc96381c4a4.zip
v4.2.0: wire the GUI to the core, transport and all four games
Phase 4. The egui frontend now shares ClientCore and the client database with the terminal: one background worker owns TLS, trust answers go through a channel, and a 10 s request deadline mirrors the terminal socket timeout. First contact, a changed key and a version mismatch are explicit screens; login can remember the credential and auto-login signs in before the login form appears. Adds the lobby with an update notice, a full-width scrollable history table, and the four games painted from View: slots with reel animation and win lines that stop at the last matching reel, roulette with the hover lattice and wheel spin, blackjack with vector-drawn suits, and a smooth crash curve. Sidebars pin their action block; leaving a live hand or round asks before forfeiting. The header balance holds the staked amount while the reels or wheel turn and only shows the payout when they stop. Refused spins cancel the animation. Live crash multipliers keep full precision for the chart; the terminal still displays two decimals. Verified live against a 4.2.0 server: register, login, auto-login, trust, all four games settling, history (50 rows), logout, disconnect, forfeit confirmation, and the version-mismatch screen. Release: 8.3 MB Linux, 6.8 MB Windows, 61 ms to window, 131 MB idle RSS.
Diffstat (limited to '')
-rw-r--r--gui/src/roulette.rs431
1 files changed, 431 insertions, 0 deletions
diff --git a/gui/src/roulette.rs b/gui/src/roulette.rs
index f674251..c791693 100644
--- a/gui/src/roulette.rs
+++ b/gui/src/roulette.rs
@@ -553,3 +553,434 @@ pub fn paint_result_dot(painter: &Painter, center: Pos2, radius: f32, number: u8
553 theme::FELT_TEXT, 553 theme::FELT_TEXT,
554 ); 554 );
555} 555}
556
557// ---------------------------------------------------------------------------
558// The screen
559// ---------------------------------------------------------------------------
560
561/// Wheel animation and result history. Kept across frames; reset when the
562/// player leaves the table.
563pub struct Fx {
564 rotation: f32,
565 spin: Option<WheelSpin>,
566 awaiting_result: bool,
567 settled: Option<u8>,
568 last: Vec<u8>,
569}
570
571struct WheelSpin {
572 started: f64,
573 from: f32,
574 delta: f32,
575 target: u8,
576}
577
578impl Default for Fx {
579 fn default() -> Self {
580 Self {
581 rotation: 0.0,
582 spin: None,
583 awaiting_result: false,
584 settled: None,
585 last: Vec::new(),
586 }
587 }
588}
589
590impl Fx {
591 /// Called when the player asks for a spin, before the reply can arrive.
592 pub fn spin_requested(&mut self) {
593 self.awaiting_result = true;
594 }
595
596 pub fn ticking(&self) -> bool {
597 self.spin.is_some()
598 }
599
600 /// Advance the wheel; starts a spin when a new result lands.
601 pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &RouletteView) {
602 let now = ctx.input(|input| input.time);
603 if view.error.is_some() {
604 // The request was refused, so no result is coming.
605 self.awaiting_result = false;
606 }
607 if self.awaiting_result && view.phase == RoulettePhase::Result {
608 if let Some(target) = view.number {
609 self.awaiting_result = false;
610 self.start(now, target);
611 }
612 }
613
614 let Some(spin) = &self.spin else {
615 return;
616 };
617 let t = (((now - spin.started) / 1.7) as f32).clamp(0.0, 1.0);
618 let eased = 1.0 - (1.0 - t).powi(3);
619 self.rotation = spin.from + spin.delta * eased;
620 if t >= 1.0 {
621 let target = spin.target;
622 self.spin = None;
623 self.settled = Some(target);
624 self.last.insert(0, target);
625 self.last.truncate(12);
626 }
627 ctx.request_repaint();
628 }
629
630 fn start(&mut self, now: f64, target: u8) {
631 let segment = TAU / WHEEL.len() as f32;
632 let index = WHEEL
633 .iter()
634 .position(|number| *number == target)
635 .unwrap_or(0) as f32;
636 let desired = -index * segment;
637 let delta = (desired - self.rotation).rem_euclid(TAU) + 3.0 * TAU;
638 self.spin = Some(WheelSpin {
639 started: now,
640 from: self.rotation,
641 delta,
642 target,
643 });
644 }
645
646 fn spinning(&self) -> bool {
647 self.spin.is_some()
648 }
649
650 /// The payout is held back until the wheel has stopped.
651 pub fn revealed(&self, view: &RouletteView) -> bool {
652 view.phase == RoulettePhase::Result && !self.spinning()
653 }
654}
655
656pub fn show(
657 ui: &mut Ui,
658 view: &RouletteView,
659 fx: &mut Fx,
660) -> Option<zagan_bet::client::view::Command> {
661 let mut command = None;
662 // While the wheel turns, keep the result hidden: no winning cell and no
663 // payout until it stops under the pointer.
664 let mut shown = view.clone();
665 if fx.spinning() && view.phase == RoulettePhase::Result {
666 shown.phase = RoulettePhase::Spinning;
667 }
668 eframe::egui::CentralPanel::default()
669 .frame(eframe::egui::Frame::new().fill(theme::BG))
670 .show(ui, |ui| {
671 let full = ui.max_rect();
672 let sidebar_rect = eframe::egui::Rect::from_min_max(
673 eframe::egui::pos2(full.right() - 336.0, full.top()),
674 full.right_bottom(),
675 );
676 let board_rect = eframe::egui::Rect::from_min_max(
677 full.min,
678 eframe::egui::pos2(sidebar_rect.left(), full.bottom()),
679 );
680 ui.painter().rect_filled(
681 sidebar_rect,
682 eframe::egui::CornerRadius::ZERO,
683 theme::SURFACE,
684 );
685
686 ui.scope_builder(
687 eframe::egui::UiBuilder::new()
688 .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0)))
689 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
690 |ui| {
691 if let Some(clicked) = board(ui, &shown, fx) {
692 command = Some(clicked);
693 }
694 },
695 );
696 ui.scope_builder(
697 eframe::egui::UiBuilder::new()
698 .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0)))
699 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
700 |ui| {
701 if let Some(clicked) = sidebar(ui, &shown, fx) {
702 command = Some(clicked);
703 }
704 },
705 );
706 });
707 command
708}
709
710fn sidebar(
711 ui: &mut Ui,
712 view: &RouletteView,
713 fx: &mut Fx,
714) -> Option<zagan_bet::client::view::Command> {
715 use zagan_bet::client::view::Command;
716
717 let mut command = None;
718 let (content, actions) = super::widgets::sidebar_layout(ui, 150.0);
719
720 ui.scope_builder(
721 eframe::egui::UiBuilder::new()
722 .max_rect(content)
723 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
724 |ui| {
725 eframe::egui::ScrollArea::vertical()
726 .auto_shrink([false, false])
727 .show(ui, |ui| {
728 ui.label(
729 eframe::egui::RichText::new("Roulette")
730 .font(theme::sans(22.0))
731 .color(theme::TEXT),
732 );
733 ui.label(
734 eframe::egui::RichText::new("European · single zero")
735 .font(theme::sans(12.0))
736 .color(theme::TEXT_DIM),
737 );
738
739 ui.add_space(theme::SPACE_4);
740 let wheel_size = ui.available_width().min(210.0);
741 ui.horizontal(|ui| {
742 let pad = ((ui.available_width() - wheel_size) * 0.5).max(0.0);
743 ui.add_space(pad);
744 wheel(ui, wheel_size, view, fx.rotation);
745 });
746 ui.add_space(theme::SPACE_1);
747
748 let spinning = view.phase == RoulettePhase::Spinning || fx.spinning();
749 if spinning {
750 ui.label(
751 eframe::egui::RichText::new("SPINNING…")
752 .font(theme::mono(12.0))
753 .color(theme::GOLD),
754 );
755 } else if fx.revealed(view) {
756 ui.horizontal(|ui| {
757 let number = view.number.unwrap_or(0);
758 ui.label(theme::label("Result"));
759 ui.label(
760 eframe::egui::RichText::new(format!(
761 "{number} {}",
762 wheel_color(number).to_uppercase()
763 ))
764 .font(theme::mono(14.0))
765 .color(number_color(number)),
766 );
767 ui.with_layout(
768 eframe::egui::Layout::right_to_left(eframe::egui::Align::Center),
769 |ui| {
770 let winnings = view.winnings.unwrap_or(0);
771 if winnings == 0 {
772 ui.label(
773 eframe::egui::RichText::new("NO WIN")
774 .font(theme::mono(13.0))
775 .color(theme::TEXT_FAINT),
776 );
777 } else {
778 let profit = winnings as i64 - view.bets.total() as i64;
779 ui.label(
780 eframe::egui::RichText::new(format!(
781 "({})",
782 theme::signed_money(profit)
783 ))
784 .font(theme::sans(11.0))
785 .color(
786 if profit >= 0 { theme::WIN } else { theme::LOSS },
787 ),
788 );
789 ui.label(
790 eframe::egui::RichText::new(theme::money(
791 winnings as i64,
792 ))
793 .font(theme::mono(15.0))
794 .color(theme::WIN),
795 );
796 ui.label(theme::label("Won"));
797 }
798 },
799 );
800 });
801 } else {
802 ui.horizontal(|ui| {
803 ui.label(
804 eframe::egui::RichText::new("Place your bets")
805 .font(theme::sans(12.0))
806 .color(theme::TEXT_DIM),
807 );
808 ui.with_layout(
809 eframe::egui::Layout::right_to_left(eframe::egui::Align::Center),
810 |ui| {
811 ui.label(
812 eframe::egui::RichText::new(match view.bets.chips.len() {
813 1 => "1 chip".to_string(),
814 count => format!("{count} chips"),
815 })
816 .font(theme::mono(10.0))
817 .color(theme::TEXT_FAINT),
818 );
819 },
820 );
821 });
822 }
823
824 ui.add_space(theme::SPACE_3);
825 ui.separator();
826 ui.add_space(theme::SPACE_3);
827
828 ui.label(theme::label("Chip"));
829 ui.add_space(theme::SPACE_2);
830 if let Some(chip) =
831 super::widgets::chip_row(ui, [10, 25, 50, 100], view.chip, !spinning)
832 {
833 command = Some(Command::SetChip(chip));
834 }
835
836 ui.add_space(theme::SPACE_3);
837 ui.label(theme::label("Bets"));
838 ui.add_space(theme::SPACE_1);
839 if view.bets.chips.is_empty() {
840 ui.label(
841 eframe::egui::RichText::new("No chips on the felt")
842 .font(theme::sans(12.0))
843 .color(theme::TEXT_FAINT),
844 );
845 } else {
846 for chip in &view.bets.chips {
847 super::widgets::row(
848 ui,
849 &target_name(&chip.target),
850 &theme::money(chip.amount as i64),
851 theme::GOLD,
852 );
853 }
854 }
855
856 ui.add_space(theme::SPACE_3);
857 ui.separator();
858 ui.add_space(theme::SPACE_2);
859 super::widgets::row(
860 ui,
861 "Total",
862 &theme::money(view.bets.total() as i64),
863 theme::GOLD,
864 );
865 });
866 },
867 );
868
869 let spinning = view.phase == RoulettePhase::Spinning || fx.spinning();
870 ui.scope_builder(
871 eframe::egui::UiBuilder::new()
872 .max_rect(actions)
873 .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)),
874 |ui| {
875 if super::widgets::ghost(ui, "Leave table") {
876 command = Some(Command::Leave);
877 }
878 ui.add_space(theme::SPACE_2);
879 if super::widgets::secondary(
880 ui,
881 "CLEAR",
882 !spinning && !view.bets.is_empty(),
883 ui.available_width(),
884 36.0,
885 ) {
886 command = Some(Command::ClearBets);
887 }
888 ui.add_space(theme::SPACE_2);
889 let clicked = super::widgets::primary(
890 ui,
891 "SPIN",
892 !spinning && !view.bets.is_empty(),
893 ui.available_width(),
894 44.0,
895 );
896 if clicked {
897 fx.spin_requested();
898 command = Some(Command::Spin);
899 }
900 },
901 );
902
903 command
904}
905
906fn board(ui: &mut Ui, view: &RouletteView, fx: &Fx) -> Option<zagan_bet::client::view::Command> {
907 use zagan_bet::client::view::Command;
908
909 ui.horizontal(|ui| {
910 ui.label(
911 eframe::egui::RichText::new("Table 01")
912 .font(theme::sans(15.0))
913 .color(theme::TEXT),
914 );
915 ui.label(
916 eframe::egui::RichText::new("European · single zero")
917 .font(theme::sans(12.0))
918 .color(theme::TEXT_DIM),
919 );
920 ui.with_layout(
921 eframe::egui::Layout::right_to_left(eframe::egui::Align::Center),
922 |ui| {
923 ui.label(
924 eframe::egui::RichText::new("Limits 10 – 500 Kr")
925 .font(theme::mono(12.0))
926 .color(theme::TEXT_FAINT),
927 );
928 },
929 );
930 });
931 ui.add_space(theme::SPACE_4);
932
933 let strip_height = 104.0;
934 let available = ui.available_size();
935 let size = size_for(vec2(available.x, (available.y - strip_height).max(220.0)));
936 let side_pad = ((available.x - size.x) * 0.5).max(0.0);
937 let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0);
938 ui.add_space(top_pad);
939
940 let enabled = view.phase != RoulettePhase::Spinning && !fx.spinning();
941 let action = ui
942 .horizontal(|ui| {
943 ui.add_space(side_pad);
944 felt(ui, size, view)
945 })
946 .inner;
947 let command = match action {
948 Some(FeltAction::Place(target)) if enabled => Some(Command::PlaceBetAt(target)),
949 Some(FeltAction::Reduce(target)) if enabled => Some(Command::RemoveBetAt(target)),
950 _ => None,
951 };
952
953 ui.add_space(theme::SPACE_4);
954 ui.horizontal(|ui| {
955 ui.label(theme::label("Last"));
956 if fx.last.is_empty() {
957 ui.label(
958 eframe::egui::RichText::new("No spins yet")
959 .font(theme::sans(12.0))
960 .color(theme::TEXT_FAINT),
961 );
962 }
963 for number in &fx.last {
964 let (dot, _) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::hover());
965 paint_result_dot(ui.painter(), dot.center(), 11.0, *number);
966 ui.add_space(theme::SPACE_1);
967 }
968 });
969 ui.add_space(theme::SPACE_2);
970 ui.label(
971 eframe::egui::RichText::new(
972 "Click to place · right or middle click takes back · C clears · space spins",
973 )
974 .font(theme::sans(12.0))
975 .color(theme::TEXT_FAINT),
976 );
977 command
978}
979
980fn number_color(number: u8) -> Color32 {
981 match wheel_color(number) {
982 "red" => theme::ROULETTE_RED,
983 "green" => theme::WIN,
984 _ => theme::TEXT,
985 }
986}