Skip to main content

subtr_actor/stats/calculators/
double_tap.rs

1use super::*;
2
3/// A follow-up touch after an attributed backboard bounce forming a shot-like trajectory.
4#[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        // Speculative: a backboard bounce only becomes a double tap if the same
29        // player makes a goal-bound follow-up touch; otherwise it is discarded.
30        Recognition::speculative(self.time, self.frame)
31    }
32
33    fn on_boundary(&mut self, _boundary: Boundary) -> Disposition {
34        // A pending bounce that survives to a boundary never resolved.
35        Disposition::Discard
36    }
37}
38
39/// Detects double taps from a backboard-bounce sequence.
40///
41/// Current heuristic:
42///
43/// 1. A [`BackboardBounceEvent`] arms a pending double tap for the player who
44///    last touched the ball before the bounce. The exact backboard geometry and
45///    attribution thresholds live in [`BackboardBounceCalculator`].
46/// 2. The touch that armed the backboard bounce must be airborne.
47/// 3. The same player must remain off ground, wall, and ceiling surfaces.
48/// 4. The same player must make the next attributed ball touch while the replay
49///    is in live play.
50/// 5. The ball's post-touch constant-velocity trajectory must project into or
51///    close to the opponent goal mouth.
52///
53/// The detector intentionally does not aim at the center of the goal. Near-post
54/// shots and cross-goal trajectories can be valid double taps even when their
55/// velocity is poorly aligned with the goal center.
56#[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            // Arming with the player key replaces any existing pending bounce
82            // for that player.
83            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    /// Returns true when the ball's current trajectory crosses the opponent
164    /// goal line within the goal mouth, with a small ball-radius based margin.
165    ///
166    /// This is a straight-line projection from the sampled post-touch ball
167    /// velocity. It deliberately ignores gravity, wall bounces, and later
168    /// touches; goal tagging handles the separate question of whether a nearby
169    /// goal should receive the double-tap label.
170    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;