subtr_actor/stats/calculators/
double_tap.rs1use 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}