Skip to main content

subtr_actor/stats/calculators/
double_tap.rs

1use super::*;
2
3const DOUBLE_TAP_TOUCH_WINDOW_SECONDS: f32 = 2.5;
4
5#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
6#[ts(export)]
7pub struct DoubleTapEvent {
8    pub time: f32,
9    pub frame: usize,
10    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
11    pub player: PlayerId,
12    pub is_team_0: bool,
13    pub backboard_time: f32,
14    pub backboard_frame: usize,
15}
16
17#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
18#[ts(export)]
19pub struct DoubleTapPlayerStats {
20    pub count: u32,
21    pub is_last_double_tap: bool,
22    pub last_double_tap_time: Option<f32>,
23    pub last_double_tap_frame: Option<usize>,
24    pub time_since_last_double_tap: Option<f32>,
25    pub frames_since_last_double_tap: Option<usize>,
26}
27
28#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
29#[ts(export)]
30pub struct DoubleTapTeamStats {
31    pub count: u32,
32}
33
34#[derive(Debug, Clone)]
35struct PendingBackboardBounce {
36    player_id: PlayerId,
37    is_team_0: bool,
38    time: f32,
39    frame: usize,
40}
41
42#[derive(Debug, Clone, Default)]
43pub struct DoubleTapCalculator {
44    player_stats: HashMap<PlayerId, DoubleTapPlayerStats>,
45    team_zero_stats: DoubleTapTeamStats,
46    team_one_stats: DoubleTapTeamStats,
47    events: Vec<DoubleTapEvent>,
48    pending_backboard_bounces: Vec<PendingBackboardBounce>,
49    current_last_double_tap_player: Option<PlayerId>,
50}
51
52impl DoubleTapCalculator {
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    pub fn player_stats(&self) -> &HashMap<PlayerId, DoubleTapPlayerStats> {
58        &self.player_stats
59    }
60
61    pub fn team_zero_stats(&self) -> &DoubleTapTeamStats {
62        &self.team_zero_stats
63    }
64
65    pub fn team_one_stats(&self) -> &DoubleTapTeamStats {
66        &self.team_one_stats
67    }
68
69    pub fn events(&self) -> &[DoubleTapEvent] {
70        &self.events
71    }
72
73    fn begin_sample(&mut self, frame: &FrameInfo) {
74        for stats in self.player_stats.values_mut() {
75            stats.is_last_double_tap = false;
76            stats.time_since_last_double_tap = stats
77                .last_double_tap_time
78                .map(|time| (frame.time - time).max(0.0));
79            stats.frames_since_last_double_tap = stats
80                .last_double_tap_frame
81                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
82        }
83    }
84
85    fn prune_pending_backboard_bounces(&mut self, current_time: f32) {
86        self.pending_backboard_bounces
87            .retain(|entry| current_time - entry.time <= DOUBLE_TAP_TOUCH_WINDOW_SECONDS);
88    }
89
90    fn record_backboard_bounces(&mut self, state: &BackboardBounceState) {
91        for event in &state.bounce_events {
92            if let Some(existing) = self
93                .pending_backboard_bounces
94                .iter_mut()
95                .find(|pending| pending.player_id == event.player)
96            {
97                *existing = PendingBackboardBounce {
98                    player_id: event.player.clone(),
99                    is_team_0: event.is_team_0,
100                    time: event.time,
101                    frame: event.frame,
102                };
103            } else {
104                self.pending_backboard_bounces.push(PendingBackboardBounce {
105                    player_id: event.player.clone(),
106                    is_team_0: event.is_team_0,
107                    time: event.time,
108                    frame: event.frame,
109                });
110            }
111        }
112    }
113
114    fn resolve_double_tap_touches(
115        &mut self,
116        frame: &FrameInfo,
117        ball: &BallFrameState,
118        touch_events: &[TouchEvent],
119    ) {
120        if touch_events.is_empty() || self.pending_backboard_bounces.is_empty() {
121            return;
122        }
123
124        let mut completed_events = Vec::new();
125        self.pending_backboard_bounces.retain(|pending| {
126            if frame.time <= pending.time {
127                return true;
128            }
129
130            let matching_touch = touch_events.iter().any(|touch| {
131                touch.team_is_team_0 == pending.is_team_0
132                    && touch.player.as_ref() == Some(&pending.player_id)
133            });
134            let conflicting_touch = touch_events
135                .iter()
136                .any(|touch| touch.player.as_ref() != Some(&pending.player_id));
137
138            if matching_touch
139                && !conflicting_touch
140                && Self::followup_touch_is_goal_directed(ball, pending.is_team_0)
141            {
142                completed_events.push(DoubleTapEvent {
143                    time: frame.time,
144                    frame: frame.frame_number,
145                    player: pending.player_id.clone(),
146                    is_team_0: pending.is_team_0,
147                    backboard_time: pending.time,
148                    backboard_frame: pending.frame,
149                });
150            }
151            false
152        });
153
154        for event in completed_events {
155            self.record_double_tap(frame, event);
156        }
157    }
158
159    fn record_double_tap(&mut self, frame: &FrameInfo, event: DoubleTapEvent) {
160        let stats = self.player_stats.entry(event.player.clone()).or_default();
161        stats.count += 1;
162        stats.last_double_tap_time = Some(event.time);
163        stats.last_double_tap_frame = Some(event.frame);
164        stats.time_since_last_double_tap = Some((frame.time - event.time).max(0.0));
165        stats.frames_since_last_double_tap = Some(frame.frame_number.saturating_sub(event.frame));
166
167        let team_stats = if event.is_team_0 {
168            &mut self.team_zero_stats
169        } else {
170            &mut self.team_one_stats
171        };
172        team_stats.count += 1;
173        self.current_last_double_tap_player = Some(event.player.clone());
174        self.events.push(event);
175    }
176
177    fn followup_touch_is_goal_directed(ball: &BallFrameState, is_team_0: bool) -> bool {
178        const GOAL_CENTER_Y: f32 = 5120.0;
179        const MIN_GOAL_ALIGNMENT_COSINE: f32 = 0.6;
180
181        let Some(ball) = ball.sample() else {
182            return false;
183        };
184
185        let target_y = if is_team_0 {
186            GOAL_CENTER_Y
187        } else {
188            -GOAL_CENTER_Y
189        };
190        let ball_velocity = ball.velocity();
191        if ball_velocity.length_squared() <= f32::EPSILON {
192            return false;
193        }
194
195        let goal_direction = glam::Vec3::new(0.0, target_y, ball.position().z) - ball.position();
196        goal_direction
197            .normalize_or_zero()
198            .dot(ball_velocity.normalize_or_zero())
199            >= MIN_GOAL_ALIGNMENT_COSINE
200    }
201
202    pub fn update(
203        &mut self,
204        frame: &FrameInfo,
205        ball: &BallFrameState,
206        events: &FrameEventsState,
207        backboard_bounce_state: &BackboardBounceState,
208        live_play: bool,
209    ) -> SubtrActorResult<()> {
210        self.begin_sample(frame);
211        if !live_play {
212            self.pending_backboard_bounces.clear();
213        }
214
215        self.prune_pending_backboard_bounces(frame.time);
216        self.record_backboard_bounces(backboard_bounce_state);
217        self.resolve_double_tap_touches(frame, ball, &events.touch_events);
218
219        if let Some(player_id) = self.current_last_double_tap_player.as_ref() {
220            if let Some(stats) = self.player_stats.get_mut(player_id) {
221                stats.is_last_double_tap = true;
222            }
223        }
224        Ok(())
225    }
226}