Skip to main content

subtr_actor/stats/calculators/
air_dribble.rs

1use super::*;
2
3const AIR_DRIBBLE_MIN_BALL_Z: f32 = 300.0;
4pub(crate) const AIR_DRIBBLE_MIN_PLAYER_Z: f32 = 100.0;
5pub(crate) const AIR_DRIBBLE_MIN_DURATION: f32 = 0.65;
6const AIR_DRIBBLE_MIN_TOUCHES: u32 = 3;
7const AIR_DRIBBLE_MIN_AIR_TOUCHES: u32 = 2;
8const AIR_DRIBBLE_TOUCH_MAX_GAP_SECONDS: f32 = 3.0;
9/// A resting ball's center sits at ~93uu (ball radius ≈ 92.75uu), so a ball
10/// whose center descends to this height between touches has effectively
11/// returned to the ground and any active dribble must not bridge across it;
12/// the small margin above resting height absorbs physics/sampling jitter.
13/// The gate deliberately keys on genuine ground-level height rather than wall
14/// proximity: a ball hugging a wall at ~100uu is sitting in the ground corner,
15/// where breaking the dribble is correct, while a true wall dribble keeps the
16/// ball well above resting height.
17const AIR_DRIBBLE_BALL_GROUND_RETURN_MAX_Z: f32 = 110.0;
18const WALL_TAKEOFF_MIN_Z: f32 = 120.0;
19const SIDE_WALL_START_ABS_X: f32 = 3200.0;
20const BACK_WALL_START_ABS_Y: f32 = 4600.0;
21
22/// Where an air dribble originated (ground vs wall).
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
24#[ts(export)]
25#[serde(rename_all = "snake_case")]
26pub enum AirDribbleOrigin {
27    GroundToAir,
28    WallToAir,
29}
30
31impl AirDribbleOrigin {
32    pub fn as_label_value(self) -> &'static str {
33        match self {
34            Self::GroundToAir => "ground_to_air",
35            Self::WallToAir => "wall_to_air",
36        }
37    }
38}
39
40pub(crate) struct AirDribblePolicy;
41
42impl AirDribblePolicy {
43    pub(crate) fn is_air_touch_position(player_position: glam::Vec3) -> bool {
44        player_position.z > PLAYER_GROUND_Z_THRESHOLD && !player_is_on_wall(player_position)
45    }
46
47    pub(crate) fn origin(start_position: glam::Vec3) -> AirDribbleOrigin {
48        if start_position.z >= WALL_TAKEOFF_MIN_Z
49            && (start_position.x.abs() >= SIDE_WALL_START_ABS_X
50                || start_position.y.abs() >= BACK_WALL_START_ABS_Y)
51        {
52            AirDribbleOrigin::WallToAir
53        } else {
54            AirDribbleOrigin::GroundToAir
55        }
56    }
57}
58
59#[derive(Debug, Clone)]
60struct ActiveAirDribble {
61    player_id: PlayerId,
62    is_team_0: bool,
63    start_frame: usize,
64    end_frame: usize,
65    start_time: f32,
66    end_time: f32,
67    start_position: glam::Vec3,
68    end_position: glam::Vec3,
69    path_distance: f32,
70    horizontal_gap_sum: f32,
71    vertical_gap_sum: f32,
72    touch_count: u32,
73    air_touch_count: u32,
74    /// Lowest ball height observed on frames since this dribble's most recent
75    /// touch. Touch events sample with lag (a touch can register with the
76    /// ball already back above ground level), so the between-touch per-frame
77    /// minimum — not the touch's own ball height — is what detects the ball
78    /// returning to the ground between touches.
79    min_ball_z_since_last_touch: f32,
80}
81
82impl ActiveAirDribble {
83    fn from_touch(touch: &TouchClassificationEvent) -> Option<Self> {
84        let player_position = touch.player_position.map(glam::Vec3::from_array)?;
85        let ball_position = touch.ball_position.map(glam::Vec3::from_array)?;
86        let (horizontal_gap, vertical_gap) = touch_gaps(player_position, ball_position);
87        Some(Self {
88            player_id: touch.player.clone(),
89            is_team_0: touch.is_team_0,
90            start_frame: touch.frame,
91            end_frame: touch.frame,
92            start_time: touch.time,
93            end_time: touch.time,
94            start_position: player_position,
95            end_position: player_position,
96            path_distance: 0.0,
97            horizontal_gap_sum: horizontal_gap,
98            vertical_gap_sum: vertical_gap,
99            touch_count: 1,
100            air_touch_count: u32::from(is_qualifying_air_dribble_touch(touch)),
101            min_ball_z_since_last_touch: f32::INFINITY,
102        })
103    }
104
105    fn extend(&mut self, touch: &TouchClassificationEvent) -> Option<()> {
106        let player_position = touch.player_position.map(glam::Vec3::from_array)?;
107        let ball_position = touch.ball_position.map(glam::Vec3::from_array)?;
108        let (horizontal_gap, vertical_gap) = touch_gaps(player_position, ball_position);
109        self.path_distance += player_position.distance(self.end_position);
110        self.end_position = player_position;
111        self.end_time = touch.time;
112        self.end_frame = touch.frame;
113        self.horizontal_gap_sum += horizontal_gap;
114        self.vertical_gap_sum += vertical_gap;
115        self.touch_count += 1;
116        self.air_touch_count += u32::from(is_qualifying_air_dribble_touch(touch));
117        self.min_ball_z_since_last_touch = f32::INFINITY;
118        Some(())
119    }
120
121    fn observe_ball_height(&mut self, ball_z: f32) {
122        self.min_ball_z_since_last_touch = self.min_ball_z_since_last_touch.min(ball_z);
123    }
124
125    fn ball_returned_to_ground_since_last_touch(&self) -> bool {
126        self.min_ball_z_since_last_touch <= AIR_DRIBBLE_BALL_GROUND_RETURN_MAX_Z
127    }
128
129    fn event(self) -> Option<BallCarryEvent> {
130        if self.touch_count < AIR_DRIBBLE_MIN_TOUCHES {
131            return None;
132        }
133        if self.air_touch_count < AIR_DRIBBLE_MIN_AIR_TOUCHES {
134            return None;
135        }
136        let duration = self.end_time - self.start_time;
137        if duration < AIR_DRIBBLE_MIN_DURATION {
138            return None;
139        }
140        let average_speed = if duration > 0.0 {
141            self.path_distance / duration
142        } else {
143            0.0
144        };
145        Some(BallCarryEvent {
146            player_id: self.player_id,
147            is_team_0: self.is_team_0,
148            kind: BallCarryKind::AirDribble,
149            start_position: self.start_position.to_array(),
150            end_position: self.end_position.to_array(),
151            start_frame: self.start_frame,
152            end_frame: self.end_frame,
153            start_time: self.start_time,
154            end_time: self.end_time,
155            duration,
156            straight_line_distance: self
157                .start_position
158                .truncate()
159                .distance(self.end_position.truncate()),
160            path_distance: self.path_distance,
161            average_horizontal_gap: self.horizontal_gap_sum / self.touch_count as f32,
162            average_vertical_gap: self.vertical_gap_sum / self.touch_count as f32,
163            average_speed,
164            touch_count: self.touch_count,
165            air_touch_count: self.air_touch_count,
166            air_dribble_origin: Some(AirDribblePolicy::origin(self.start_position)),
167        })
168    }
169
170    fn has_qualifying_air_touch(&self) -> bool {
171        self.air_touch_count > 0
172    }
173}
174
175fn touch_gaps(player_position: glam::Vec3, ball_position: glam::Vec3) -> (f32, f32) {
176    (
177        player_position
178            .truncate()
179            .distance(ball_position.truncate()),
180        ball_position.z - player_position.z,
181    )
182}
183
184fn is_qualifying_air_dribble_touch(touch: &TouchClassificationEvent) -> bool {
185    matches!(touch.tag("surface"), Some("air"))
186        && touch
187            .ball_position
188            .is_some_and(|position| position[2] >= AIR_DRIBBLE_MIN_BALL_Z)
189}
190
191/// Detects air dribbles from continuous same-player touches.
192#[derive(Debug, Clone, Default)]
193pub struct AirDribbleCalculator {
194    events: EventStream<BallCarryEvent>,
195    active: Option<ActiveAirDribble>,
196    processed_touch_count: usize,
197}
198
199impl AirDribbleCalculator {
200    pub fn new() -> Self {
201        Self::default()
202    }
203
204    pub fn events(&self) -> &[BallCarryEvent] {
205        self.events.all()
206    }
207
208    pub fn new_events(&self) -> &[BallCarryEvent] {
209        self.events.new_events()
210    }
211
212    fn finish_active(&mut self) {
213        if let Some(event) = self.active.take().and_then(ActiveAirDribble::event) {
214            self.events.push(event);
215        }
216    }
217
218    fn observe_touch(&mut self, touch: &TouchClassificationEvent) {
219        let same_sequence = self.active.as_ref().is_some_and(|active| {
220            active.player_id == touch.player
221                && active.is_team_0 == touch.is_team_0
222                && touch.time - active.end_time <= AIR_DRIBBLE_TOUCH_MAX_GAP_SECONDS
223                && !active.ball_returned_to_ground_since_last_touch()
224        });
225        if same_sequence {
226            if self
227                .active
228                .as_mut()
229                .and_then(|active| active.extend(touch))
230                .is_none()
231            {
232                self.finish_active();
233            }
234            return;
235        }
236
237        self.finish_active();
238        self.active = ActiveAirDribble::from_touch(touch);
239    }
240
241    pub fn update(
242        &mut self,
243        frame: &FrameInfo,
244        ball: &BallFrameState,
245        players: &PlayerFrameState,
246        live_play: &LivePlayState,
247        touch: &TouchCalculator,
248    ) -> SubtrActorResult<()> {
249        self.update_with_touch_classification_events(
250            frame,
251            ball,
252            players,
253            live_play,
254            touch.events(),
255        )
256    }
257
258    pub(crate) fn update_with_touch_classification_events(
259        &mut self,
260        frame: &FrameInfo,
261        ball: &BallFrameState,
262        _players: &PlayerFrameState,
263        live_play: &LivePlayState,
264        touch_events: &[TouchClassificationEvent],
265    ) -> SubtrActorResult<()> {
266        self.events.begin_update();
267        if !live_play.is_live_play {
268            self.finish_active();
269            self.processed_touch_count = touch_events.len();
270            return Ok(());
271        }
272        if let (Some(active), Some(position)) = (self.active.as_mut(), ball.position()) {
273            active.observe_ball_height(position.z);
274        }
275        if self
276            .active
277            .as_ref()
278            .is_some_and(ActiveAirDribble::has_qualifying_air_touch)
279            && ball
280                .position()
281                .is_some_and(|position| position.z < AIR_DRIBBLE_MIN_BALL_Z)
282        {
283            self.finish_active();
284        }
285        for touch in &touch_events[self.processed_touch_count..] {
286            if touch.frame > frame.frame_number {
287                break;
288            }
289            self.observe_touch(touch);
290            self.processed_touch_count += 1;
291        }
292        Ok(())
293    }
294
295    pub fn finish(&mut self) -> SubtrActorResult<()> {
296        self.events.begin_update();
297        self.finish_active();
298        Ok(())
299    }
300}
301
302#[cfg(test)]
303#[path = "air_dribble_tests.rs"]
304mod tests;