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.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}