subtr_actor/stats/calculators/
double_tap.rs1use super::*;
2
3#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
5#[ts(export)]
6pub struct DoubleTapEvent {
7 pub time: f32,
8 pub frame: usize,
9 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
10 pub player: PlayerId,
11 #[serde(default, skip_serializing_if = "Option::is_none")]
12 pub player_position: Option<[f32; 3]>,
13 pub is_team_0: bool,
14 pub backboard_time: f32,
15 pub backboard_frame: usize,
16}
17
18#[derive(Debug, Clone)]
19struct PendingBackboardBounce {
20 player_id: PlayerId,
21 is_team_0: bool,
22 time: f32,
23 frame: usize,
24}
25
26impl InFlightItem for PendingBackboardBounce {
27 fn recognition(&self) -> Recognition {
28 Recognition::speculative(self.time, self.frame)
31 }
32
33 fn on_boundary(&mut self, _boundary: Boundary) -> Disposition {
34 Disposition::Discard
36 }
37}
38
39#[derive(Debug, Clone, Default)]
57pub struct DoubleTapCalculator {
58 events: EventStream<DoubleTapEvent>,
59 pending_backboard_bounces: KeyedInFlightLedger<PlayerId, PendingBackboardBounce>,
60}
61
62impl DoubleTapCalculator {
63 pub fn new() -> Self {
64 Self::default()
65 }
66
67 pub fn events(&self) -> &[DoubleTapEvent] {
68 self.events.all()
69 }
70
71 pub fn new_events(&self) -> &[DoubleTapEvent] {
72 self.events.new_events()
73 }
74
75 fn record_backboard_bounces(&mut self, state: &BackboardBounceState) {
76 for event in &state.bounce_events {
77 if Self::backboard_touch_was_grounded(event) {
78 continue;
79 }
80
81 self.pending_backboard_bounces.arm(
84 event.player.clone(),
85 PendingBackboardBounce {
86 player_id: event.player.clone(),
87 is_team_0: event.is_team_0,
88 time: event.time,
89 frame: event.frame,
90 },
91 );
92 }
93 }
94
95 fn backboard_touch_was_grounded(event: &BackboardBounceEvent) -> bool {
96 event
97 .player_position
98 .is_some_and(|position| PlayerVerticalBand::from_height(position[2]).is_grounded())
99 }
100
101 fn prune_surface_contacts(&mut self, players: &PlayerFrameState) {
102 self.pending_backboard_bounces.retain(|_, pending| {
103 !players
104 .player(&pending.player_id)
105 .is_some_and(player_sample_is_touching_surface)
106 });
107 }
108
109 fn resolve_double_tap_touches(
110 &mut self,
111 frame: &FrameInfo,
112 ball: &BallFrameState,
113 touch_state: &TouchState,
114 ) {
115 if self.pending_backboard_bounces.is_empty() {
116 return;
117 }
118
119 let Some(touch) = touch_state.primary_touch_event() else {
120 return;
121 };
122
123 let resolved = self
124 .pending_backboard_bounces
125 .advance(frame.time, |_player, pending| {
126 if touch.frame < pending.frame
127 || (touch.frame == pending.frame && touch.time < pending.time)
128 {
129 return Disposition::Keep;
130 }
131 let is_matching_followup = touch.team_is_team_0 == pending.is_team_0
132 && touch.player.as_ref() == Some(&pending.player_id);
133 if !is_matching_followup {
134 return Disposition::Discard;
135 }
136 if Self::followup_touch_projects_on_goal_mouth(ball, pending.is_team_0) {
137 Disposition::Finalize(FinalizeReason::Completed)
138 } else {
139 Disposition::Discard
140 }
141 });
142
143 for (_player, pending, _reason) in resolved {
144 let event = DoubleTapEvent {
145 time: touch.time,
146 frame: touch.frame,
147 player: pending.player_id.clone(),
148 player_position: touch
149 .player_position
150 .map(|position| vec_to_glam(&position).to_array()),
151 is_team_0: pending.is_team_0,
152 backboard_time: pending.time,
153 backboard_frame: pending.frame,
154 };
155 self.record_double_tap(frame, event);
156 }
157 }
158
159 fn record_double_tap(&mut self, _frame: &FrameInfo, event: DoubleTapEvent) {
160 self.events.push(event);
161 }
162
163 fn followup_touch_projects_on_goal_mouth(ball: &BallFrameState, is_team_0: bool) -> bool {
171 const GOAL_LINE_Y: f32 = 5120.0;
172 const GOAL_MOUTH_HEIGHT_Z: f32 = 642.775;
173 const GOAL_MOUTH_TRAJECTORY_MARGIN: f32 = BALL_RADIUS_Z * 1.5;
174
175 let Some(ball) = ball.sample() else {
176 return false;
177 };
178
179 let target_y = if is_team_0 { GOAL_LINE_Y } else { -GOAL_LINE_Y };
180 let ball_velocity = ball.velocity();
181 if ball_velocity.length_squared() <= f32::EPSILON {
182 return false;
183 }
184
185 let time_to_goal_line = (target_y - ball.position().y) / ball_velocity.y;
186 if !time_to_goal_line.is_finite() || time_to_goal_line < 0.0 {
187 return false;
188 }
189
190 let projected = ball.position() + ball_velocity * time_to_goal_line;
191 projected.x.abs() <= BACK_WALL_GOAL_MOUTH_HALF_WIDTH_X + GOAL_MOUTH_TRAJECTORY_MARGIN
192 && projected.z >= BALL_RADIUS_Z - GOAL_MOUTH_TRAJECTORY_MARGIN
193 && projected.z <= GOAL_MOUTH_HEIGHT_Z + GOAL_MOUTH_TRAJECTORY_MARGIN
194 }
195
196 pub fn update(
197 &mut self,
198 frame: &FrameInfo,
199 ball: &BallFrameState,
200 players: &PlayerFrameState,
201 touch_state: &TouchState,
202 backboard_bounce_state: &BackboardBounceState,
203 live_play_state: &LivePlayState,
204 ) -> SubtrActorResult<()> {
205 self.events.begin_update();
206 if !live_play_state.is_live_play {
207 self.pending_backboard_bounces
208 .apply_boundary(Boundary::LivePlayEnded);
209 }
210
211 self.record_backboard_bounces(backboard_bounce_state);
212 self.prune_surface_contacts(players);
213 self.resolve_double_tap_touches(frame, ball, touch_state);
214 Ok(())
215 }
216}
217
218#[cfg(test)]
219#[path = "double_tap_tests.rs"]
220mod tests;