diff options
Diffstat (limited to '')
| -rw-r--r-- | gui/src/roulette.rs | 431 |
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. | ||
| 563 | pub struct Fx { | ||
| 564 | rotation: f32, | ||
| 565 | spin: Option<WheelSpin>, | ||
| 566 | awaiting_result: bool, | ||
| 567 | settled: Option<u8>, | ||
| 568 | last: Vec<u8>, | ||
| 569 | } | ||
| 570 | |||
| 571 | struct WheelSpin { | ||
| 572 | started: f64, | ||
| 573 | from: f32, | ||
| 574 | delta: f32, | ||
| 575 | target: u8, | ||
| 576 | } | ||
| 577 | |||
| 578 | impl 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 | |||
| 590 | impl 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 | |||
| 656 | pub 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 | |||
| 710 | fn 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 | |||
| 906 | fn 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 | |||
| 980 | fn number_color(number: u8) -> Color32 { | ||
| 981 | match wheel_color(number) { | ||
| 982 | "red" => theme::ROULETTE_RED, | ||
| 983 | "green" => theme::WIN, | ||
| 984 | _ => theme::TEXT, | ||
| 985 | } | ||
| 986 | } | ||
