subtr_actor/stats/calculators/
air_dribble.rs1use 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;
9const 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#[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 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#[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;