Skip to main content

subtr_actor/stats/calculators/
ball_carry.rs

1use super::*;
2
3#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
4#[ts(export)]
5pub struct BallCarryStats {
6    pub carry_count: u32,
7    pub total_carry_time: f32,
8    pub total_straight_line_distance: f32,
9    pub total_path_distance: f32,
10    pub longest_carry_time: f32,
11    pub furthest_carry_distance: f32,
12    pub fastest_carry_speed: f32,
13    pub carry_speed_sum: f32,
14    pub average_horizontal_gap_sum: f32,
15    pub average_vertical_gap_sum: f32,
16}
17
18impl BallCarryStats {
19    fn pct_count_average(&self, value: f32) -> f32 {
20        if self.carry_count == 0 {
21            0.0
22        } else {
23            value / self.carry_count as f32
24        }
25    }
26
27    pub fn average_carry_time(&self) -> f32 {
28        self.pct_count_average(self.total_carry_time)
29    }
30
31    pub fn average_straight_line_distance(&self) -> f32 {
32        self.pct_count_average(self.total_straight_line_distance)
33    }
34
35    pub fn average_path_distance(&self) -> f32 {
36        self.pct_count_average(self.total_path_distance)
37    }
38
39    pub fn average_carry_speed(&self) -> f32 {
40        self.pct_count_average(self.carry_speed_sum)
41    }
42
43    pub fn average_horizontal_gap(&self) -> f32 {
44        self.pct_count_average(self.average_horizontal_gap_sum)
45    }
46
47    pub fn average_vertical_gap(&self) -> f32 {
48        self.pct_count_average(self.average_vertical_gap_sum)
49    }
50}
51
52#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
53#[ts(export)]
54pub struct AirDribbleStats {
55    pub count: u32,
56    #[serde(default)]
57    pub ground_to_air_count: u32,
58    #[serde(default)]
59    pub wall_to_air_count: u32,
60    #[serde(default)]
61    pub total_touch_count: u32,
62    #[serde(default)]
63    pub max_touch_count: u32,
64    pub total_time: f32,
65    pub total_straight_line_distance: f32,
66    pub total_path_distance: f32,
67    pub longest_time: f32,
68    pub furthest_distance: f32,
69    pub fastest_speed: f32,
70    pub speed_sum: f32,
71    pub average_horizontal_gap_sum: f32,
72    pub average_vertical_gap_sum: f32,
73}
74
75impl AirDribbleStats {
76    fn count_average(&self, value: f32) -> f32 {
77        if self.count == 0 {
78            0.0
79        } else {
80            value / self.count as f32
81        }
82    }
83
84    pub fn average_time(&self) -> f32 {
85        self.count_average(self.total_time)
86    }
87
88    pub fn average_straight_line_distance(&self) -> f32 {
89        self.count_average(self.total_straight_line_distance)
90    }
91
92    pub fn average_path_distance(&self) -> f32 {
93        self.count_average(self.total_path_distance)
94    }
95
96    pub fn average_speed(&self) -> f32 {
97        self.count_average(self.speed_sum)
98    }
99
100    pub fn average_touch_count(&self) -> f32 {
101        self.count_average(self.total_touch_count as f32)
102    }
103
104    pub fn average_horizontal_gap(&self) -> f32 {
105        self.count_average(self.average_horizontal_gap_sum)
106    }
107
108    pub fn average_vertical_gap(&self) -> f32 {
109        self.count_average(self.average_vertical_gap_sum)
110    }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
114#[ts(export)]
115#[serde(rename_all = "snake_case")]
116pub enum AirDribbleOrigin {
117    GroundToAir,
118    WallToAir,
119}
120
121impl AirDribbleOrigin {
122    pub fn as_label_value(self) -> &'static str {
123        match self {
124            Self::GroundToAir => "ground_to_air",
125            Self::WallToAir => "wall_to_air",
126        }
127    }
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize)]
131pub struct BallCarryEvent {
132    pub player_id: PlayerId,
133    pub is_team_0: bool,
134    pub kind: BallCarryKind,
135    pub start_frame: usize,
136    pub end_frame: usize,
137    pub start_time: f32,
138    pub end_time: f32,
139    pub duration: f32,
140    pub straight_line_distance: f32,
141    pub path_distance: f32,
142    pub average_horizontal_gap: f32,
143    pub average_vertical_gap: f32,
144    pub average_speed: f32,
145    pub touch_count: u32,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub air_dribble_origin: Option<AirDribbleOrigin>,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
151#[ts(export)]
152#[serde(rename_all = "snake_case")]
153pub enum BallCarryKind {
154    Carry,
155    AirDribble,
156}
157
158#[derive(Debug, Clone, Default)]
159pub struct BallCarryCalculator {
160    player_stats: HashMap<PlayerId, BallCarryStats>,
161    player_air_dribble_stats: HashMap<PlayerId, AirDribbleStats>,
162    team_zero_stats: BallCarryStats,
163    team_one_stats: BallCarryStats,
164    team_zero_air_dribble_stats: AirDribbleStats,
165    team_one_air_dribble_stats: AirDribbleStats,
166    carry_events: Vec<BallCarryEvent>,
167    processed_control_sequence_count: usize,
168}
169
170impl BallCarryCalculator {
171    pub fn new() -> Self {
172        Self::default()
173    }
174
175    pub fn player_stats(&self) -> &HashMap<PlayerId, BallCarryStats> {
176        &self.player_stats
177    }
178
179    pub fn player_air_dribble_stats(&self) -> &HashMap<PlayerId, AirDribbleStats> {
180        &self.player_air_dribble_stats
181    }
182
183    pub fn team_zero_stats(&self) -> &BallCarryStats {
184        &self.team_zero_stats
185    }
186
187    pub fn team_one_stats(&self) -> &BallCarryStats {
188        &self.team_one_stats
189    }
190
191    pub fn team_zero_air_dribble_stats(&self) -> &AirDribbleStats {
192        &self.team_zero_air_dribble_stats
193    }
194
195    pub fn team_one_air_dribble_stats(&self) -> &AirDribbleStats {
196        &self.team_one_air_dribble_stats
197    }
198
199    pub fn carry_events(&self) -> &[BallCarryEvent] {
200        &self.carry_events
201    }
202
203    pub(crate) fn carry_frame_sample(
204        player: &PlayerSample,
205        ball: &BallSample,
206    ) -> Option<ContinuousBallControlSample<BallCarryKind>> {
207        let player_position = player.position()?;
208        let ball_position = ball.position();
209        let horizontal_gap = player_position
210            .truncate()
211            .distance(ball_position.truncate());
212        let vertical_gap = ball_position.z - player_position.z;
213
214        if Self::is_air_dribble_sample(player_position, ball_position, horizontal_gap, vertical_gap)
215        {
216            return Some(ContinuousBallControlSample {
217                player_position,
218                kind: BallCarryKind::AirDribble,
219                horizontal_gap,
220                vertical_gap,
221                speed: player.speed().unwrap_or(0.0),
222            });
223        }
224
225        if !(BALL_CARRY_MIN_BALL_Z..=BALL_CARRY_MAX_BALL_Z).contains(&ball_position.z) {
226            return None;
227        }
228
229        if horizontal_gap > BALL_CARRY_MAX_HORIZONTAL_GAP {
230            return None;
231        }
232
233        if !(0.0..=BALL_CARRY_MAX_VERTICAL_GAP).contains(&vertical_gap) {
234            return None;
235        }
236
237        Some(ContinuousBallControlSample {
238            player_position,
239            kind: BallCarryKind::Carry,
240            horizontal_gap,
241            vertical_gap,
242            speed: player.speed().unwrap_or(0.0),
243        })
244    }
245
246    fn is_air_dribble_sample(
247        player_position: glam::Vec3,
248        ball_position: glam::Vec3,
249        horizontal_gap: f32,
250        vertical_gap: f32,
251    ) -> bool {
252        ball_position.z >= AIR_DRIBBLE_MIN_BALL_Z
253            && player_position.z >= AIR_DRIBBLE_MIN_PLAYER_Z
254            && horizontal_gap <= AIR_DRIBBLE_MAX_HORIZONTAL_GAP
255            && (-AIR_DRIBBLE_MAX_BELOW_CAR_GAP..=AIR_DRIBBLE_MAX_ABOVE_CAR_GAP)
256                .contains(&vertical_gap)
257    }
258
259    pub(crate) fn min_duration_for_kind(kind: BallCarryKind) -> f32 {
260        match kind {
261            BallCarryKind::Carry => BALL_CARRY_MIN_DURATION,
262            BallCarryKind::AirDribble => AIR_DRIBBLE_MIN_DURATION,
263        }
264    }
265
266    pub(crate) fn control_candidate(
267        ball: &BallFrameState,
268        players: &PlayerFrameState,
269        live_play: bool,
270        touch_state: &TouchState,
271    ) -> Option<ContinuousBallControlCandidate<BallCarryKind>> {
272        if !live_play {
273            return None;
274        }
275        let ball = ball.sample()?;
276        let player_id = touch_state.last_touch_player.as_ref()?;
277        let touch_count = touch_state
278            .touch_events
279            .iter()
280            .filter(|event| event.player.as_ref() == Some(player_id))
281            .count() as u32;
282        players
283            .players
284            .iter()
285            .find(|player| &player.player_id == player_id)
286            .and_then(|player| {
287                Self::carry_frame_sample(player, ball).map(|sample| {
288                    ContinuousBallControlCandidate {
289                        player_id: player.player_id.clone(),
290                        is_team_0: player.is_team_0,
291                        touch_count,
292                        sample,
293                    }
294                })
295            })
296    }
297
298    fn air_dribble_origin(start_position: glam::Vec3) -> AirDribbleOrigin {
299        const WALL_TAKEOFF_MIN_Z: f32 = 120.0;
300        const SIDE_WALL_START_ABS_X: f32 = 3200.0;
301        const BACK_WALL_START_ABS_Y: f32 = 4600.0;
302
303        if start_position.z >= WALL_TAKEOFF_MIN_Z
304            && (start_position.x.abs() >= SIDE_WALL_START_ABS_X
305                || start_position.y.abs() >= BACK_WALL_START_ABS_Y)
306        {
307            AirDribbleOrigin::WallToAir
308        } else {
309            AirDribbleOrigin::GroundToAir
310        }
311    }
312
313    fn event_from_sequence(
314        sequence: CompletedBallControlSequence<BallCarryKind>,
315    ) -> BallCarryEvent {
316        let air_dribble_origin = (sequence.kind == BallCarryKind::AirDribble)
317            .then(|| Self::air_dribble_origin(sequence.start_position));
318        BallCarryEvent {
319            player_id: sequence.player_id,
320            is_team_0: sequence.is_team_0,
321            kind: sequence.kind,
322            start_frame: sequence.start_frame,
323            end_frame: sequence.end_frame,
324            start_time: sequence.start_time,
325            end_time: sequence.end_time,
326            duration: sequence.duration,
327            straight_line_distance: sequence.straight_line_distance,
328            path_distance: sequence.path_distance,
329            average_horizontal_gap: sequence.average_horizontal_gap,
330            average_vertical_gap: sequence.average_vertical_gap,
331            average_speed: sequence.average_speed,
332            touch_count: sequence.touch_count,
333            air_dribble_origin,
334        }
335    }
336
337    fn record_carry_event(&mut self, event: BallCarryEvent) {
338        match event.kind {
339            BallCarryKind::Carry => {
340                let player_stats = self
341                    .player_stats
342                    .entry(event.player_id.clone())
343                    .or_default();
344                Self::apply_carry_event(player_stats, &event);
345
346                let team_stats = if event.is_team_0 {
347                    &mut self.team_zero_stats
348                } else {
349                    &mut self.team_one_stats
350                };
351                Self::apply_carry_event(team_stats, &event);
352            }
353            BallCarryKind::AirDribble => {
354                let player_stats = self
355                    .player_air_dribble_stats
356                    .entry(event.player_id.clone())
357                    .or_default();
358                Self::apply_air_dribble_event(player_stats, &event);
359
360                let team_stats = if event.is_team_0 {
361                    &mut self.team_zero_air_dribble_stats
362                } else {
363                    &mut self.team_one_air_dribble_stats
364                };
365                Self::apply_air_dribble_event(team_stats, &event);
366            }
367        }
368        self.carry_events.push(event);
369    }
370
371    fn apply_carry_event(stats: &mut BallCarryStats, event: &BallCarryEvent) {
372        stats.carry_count += 1;
373        stats.total_carry_time += event.duration;
374        stats.total_straight_line_distance += event.straight_line_distance;
375        stats.total_path_distance += event.path_distance;
376        stats.longest_carry_time = stats.longest_carry_time.max(event.duration);
377        stats.furthest_carry_distance = stats
378            .furthest_carry_distance
379            .max(event.straight_line_distance);
380        stats.fastest_carry_speed = stats.fastest_carry_speed.max(event.average_speed);
381        stats.carry_speed_sum += event.average_speed;
382        stats.average_horizontal_gap_sum += event.average_horizontal_gap;
383        stats.average_vertical_gap_sum += event.average_vertical_gap;
384    }
385
386    fn apply_air_dribble_event(stats: &mut AirDribbleStats, event: &BallCarryEvent) {
387        stats.count += 1;
388        stats.total_time += event.duration;
389        stats.total_straight_line_distance += event.straight_line_distance;
390        stats.total_path_distance += event.path_distance;
391        stats.longest_time = stats.longest_time.max(event.duration);
392        stats.furthest_distance = stats.furthest_distance.max(event.straight_line_distance);
393        stats.fastest_speed = stats.fastest_speed.max(event.average_speed);
394        stats.speed_sum += event.average_speed;
395        stats.average_horizontal_gap_sum += event.average_horizontal_gap;
396        stats.average_vertical_gap_sum += event.average_vertical_gap;
397        stats.total_touch_count += event.touch_count;
398        stats.max_touch_count = stats.max_touch_count.max(event.touch_count);
399        match event.air_dribble_origin {
400            Some(AirDribbleOrigin::GroundToAir) => stats.ground_to_air_count += 1,
401            Some(AirDribbleOrigin::WallToAir) => stats.wall_to_air_count += 1,
402            None => {}
403        }
404    }
405
406    pub fn update(&mut self, control_state: &ContinuousBallControlState) -> SubtrActorResult<()> {
407        for sequence in control_state
408            .completed_sequences
409            .iter()
410            .skip(self.processed_control_sequence_count)
411            .cloned()
412        {
413            self.record_carry_event(Self::event_from_sequence(sequence));
414        }
415        self.processed_control_sequence_count = control_state.completed_sequences.len();
416        Ok(())
417    }
418}
419
420#[cfg(test)]
421#[path = "ball_carry_tests.rs"]
422mod tests;