Skip to main content

subtr_actor/stats/calculators/
touch.rs

1use super::*;
2
3const SOFT_TOUCH_BALL_SPEED_CHANGE_THRESHOLD: f32 = 320.0;
4const HARD_TOUCH_BALL_SPEED_CHANGE_THRESHOLD: f32 = 900.0;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7enum TouchKind {
8    Dribble,
9    Control,
10    MediumHit,
11    HardHit,
12}
13
14const ALL_TOUCH_KINDS: [TouchKind; 4] = [
15    TouchKind::Dribble,
16    TouchKind::Control,
17    TouchKind::MediumHit,
18    TouchKind::HardHit,
19];
20
21impl TouchKind {
22    fn as_label(self) -> StatLabel {
23        let value = match self {
24            Self::Dribble => "dribble",
25            Self::Control => "control",
26            Self::MediumHit => "medium_hit",
27            Self::HardHit => "hard_hit",
28        };
29        StatLabel::new("kind", value)
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34struct TouchClassification {
35    kind: TouchKind,
36    height_band: PlayerVerticalBand,
37}
38
39impl TouchClassification {
40    fn labels(self) -> [StatLabel; 2] {
41        [self.kind.as_label(), self.height_band.as_label()]
42    }
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
46#[ts(export)]
47pub struct TouchStats {
48    pub touch_count: u32,
49    pub dribble_touch_count: u32,
50    pub control_touch_count: u32,
51    pub medium_hit_count: u32,
52    pub hard_hit_count: u32,
53    pub aerial_touch_count: u32,
54    pub high_aerial_touch_count: u32,
55    pub is_last_touch: bool,
56    pub last_touch_time: Option<f32>,
57    pub last_touch_frame: Option<usize>,
58    pub time_since_last_touch: Option<f32>,
59    pub frames_since_last_touch: Option<usize>,
60    pub last_ball_speed_change: Option<f32>,
61    pub max_ball_speed_change: f32,
62    pub cumulative_ball_speed_change: f32,
63    #[serde(default)]
64    pub total_ball_travel_distance: f32,
65    #[serde(default)]
66    pub total_ball_advance_distance: f32,
67    #[serde(default)]
68    pub total_ball_retreat_distance: f32,
69    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
70    pub labeled_touch_counts: LabeledCounts,
71}
72
73impl TouchStats {
74    pub fn average_ball_speed_change(&self) -> f32 {
75        if self.touch_count == 0 {
76            0.0
77        } else {
78            self.cumulative_ball_speed_change / self.touch_count as f32
79        }
80    }
81
82    pub fn touch_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
83        self.labeled_touch_counts.count_matching(labels)
84    }
85
86    pub fn complete_labeled_touch_counts(&self) -> LabeledCounts {
87        let mut entries: Vec<_> = ALL_PLAYER_VERTICAL_BANDS
88            .into_iter()
89            .flat_map(|height_band| {
90                ALL_TOUCH_KINDS.into_iter().map(move |kind| {
91                    let mut labels = vec![kind.as_label(), height_band.as_label()];
92                    labels.sort();
93                    LabeledCountEntry {
94                        count: self.labeled_touch_counts.count_exact(&labels),
95                        labels,
96                    }
97                })
98            })
99            .collect();
100
101        entries.sort_by(|left, right| left.labels.cmp(&right.labels));
102
103        LabeledCounts { entries }
104    }
105
106    pub fn with_complete_labeled_touch_counts(mut self) -> Self {
107        self.labeled_touch_counts = self.complete_labeled_touch_counts();
108        self
109    }
110}
111
112#[derive(Debug, Clone, Default, PartialEq)]
113struct PendingFiftyFiftyMovement {
114    start_frame: usize,
115    travel_distance: f32,
116    y_delta: f32,
117}
118
119#[derive(Debug, Clone, Default, PartialEq)]
120pub struct TouchCalculator {
121    player_stats: HashMap<PlayerId, TouchStats>,
122    current_last_touch_player: Option<PlayerId>,
123    previous_ball_velocity: Option<glam::Vec3>,
124    previous_ball_position: Option<glam::Vec3>,
125    pending_fifty_fifty_movement: Option<PendingFiftyFiftyMovement>,
126}
127
128impl TouchCalculator {
129    pub fn new() -> Self {
130        Self::default()
131    }
132
133    pub fn player_stats(&self) -> &HashMap<PlayerId, TouchStats> {
134        &self.player_stats
135    }
136
137    fn ball_speed_change(
138        frame: &FrameInfo,
139        ball: &BallFrameState,
140        previous_ball_velocity: Option<glam::Vec3>,
141    ) -> f32 {
142        const BALL_GRAVITY_Z: f32 = -650.0;
143
144        let Some(ball) = ball.sample() else {
145            return 0.0;
146        };
147        let Some(previous_ball_velocity) = previous_ball_velocity else {
148            return 0.0;
149        };
150
151        let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
152        let residual_linear_impulse =
153            ball.velocity() - previous_ball_velocity - expected_linear_delta;
154        residual_linear_impulse.length()
155    }
156
157    fn classify_touch(
158        height_band: PlayerVerticalBand,
159        ball_speed_change: f32,
160    ) -> TouchClassification {
161        let kind = if ball_speed_change <= SOFT_TOUCH_BALL_SPEED_CHANGE_THRESHOLD {
162            if height_band.is_airborne() {
163                TouchKind::Control
164            } else {
165                TouchKind::Dribble
166            }
167        } else if ball_speed_change < HARD_TOUCH_BALL_SPEED_CHANGE_THRESHOLD {
168            TouchKind::MediumHit
169        } else {
170            TouchKind::HardHit
171        };
172
173        TouchClassification { kind, height_band }
174    }
175
176    fn apply_touch_classification(stats: &mut TouchStats, classification: TouchClassification) {
177        match classification.height_band {
178            PlayerVerticalBand::Ground => {}
179            PlayerVerticalBand::LowAir => stats.aerial_touch_count += 1,
180            PlayerVerticalBand::HighAir => {
181                stats.aerial_touch_count += 1;
182                stats.high_aerial_touch_count += 1;
183            }
184        }
185
186        match classification.kind {
187            TouchKind::Dribble => stats.dribble_touch_count += 1,
188            TouchKind::Control => stats.control_touch_count += 1,
189            TouchKind::MediumHit => stats.medium_hit_count += 1,
190            TouchKind::HardHit => stats.hard_hit_count += 1,
191        }
192
193        stats
194            .labeled_touch_counts
195            .increment(classification.labels());
196    }
197
198    fn begin_sample(&mut self, frame: &FrameInfo) {
199        for stats in self.player_stats.values_mut() {
200            stats.is_last_touch = false;
201            stats.time_since_last_touch = stats
202                .last_touch_time
203                .map(|time| (frame.time - time).max(0.0));
204            stats.frames_since_last_touch = stats
205                .last_touch_frame
206                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
207        }
208    }
209
210    fn apply_touch_events(
211        &mut self,
212        frame: &FrameInfo,
213        ball: &BallFrameState,
214        vertical_state: &PlayerVerticalState,
215        touch_events: &[TouchEvent],
216    ) {
217        let ball_speed_change = Self::ball_speed_change(frame, ball, self.previous_ball_velocity);
218
219        for touch_event in touch_events {
220            let Some(player_id) = touch_event.player.as_ref() else {
221                continue;
222            };
223            let height_band = vertical_state
224                .band_for_player(player_id)
225                .unwrap_or(PlayerVerticalBand::Ground);
226            let classification = Self::classify_touch(height_band, ball_speed_change);
227            let stats = self.player_stats.entry(player_id.clone()).or_default();
228            stats.touch_count += 1;
229            Self::apply_touch_classification(stats, classification);
230            stats.last_touch_time = Some(touch_event.time);
231            stats.last_touch_frame = Some(touch_event.frame);
232            stats.time_since_last_touch = Some((frame.time - touch_event.time).max(0.0));
233            stats.frames_since_last_touch =
234                Some(frame.frame_number.saturating_sub(touch_event.frame));
235            stats.last_ball_speed_change = Some(ball_speed_change);
236            stats.max_ball_speed_change = stats.max_ball_speed_change.max(ball_speed_change);
237            stats.cumulative_ball_speed_change += ball_speed_change;
238        }
239
240        if let Some(last_touch) = touch_events.last() {
241            self.current_last_touch_player = last_touch.player.clone();
242        }
243
244        if let Some(player_id) = self.current_last_touch_player.as_ref() {
245            if let Some(stats) = self.player_stats.get_mut(player_id) {
246                stats.is_last_touch = true;
247            }
248        }
249    }
250
251    fn apply_ball_movement_credit(
252        &mut self,
253        player_id: &PlayerId,
254        team_is_team_0: bool,
255        delta: glam::Vec3,
256        travel_distance: f32,
257    ) {
258        let team_forward_sign = if team_is_team_0 { 1.0 } else { -1.0 };
259        let advance_distance = delta.y * team_forward_sign;
260        let stats = self.player_stats.entry(player_id.clone()).or_default();
261        stats.total_ball_travel_distance += travel_distance;
262        if advance_distance >= 0.0 {
263            stats.total_ball_advance_distance += advance_distance;
264        } else {
265            stats.total_ball_retreat_distance += -advance_distance;
266        }
267    }
268
269    fn resolved_fifty_fifty_winner(event: &FiftyFiftyEvent) -> Option<(&PlayerId, bool)> {
270        let winning_team_is_team_0 = event.winning_team_is_team_0?;
271        let player = if winning_team_is_team_0 {
272            event.team_zero_player.as_ref()
273        } else {
274            event.team_one_player.as_ref()
275        }?;
276        Some((player, winning_team_is_team_0))
277    }
278
279    fn buffer_fifty_fifty_movement(
280        &mut self,
281        start_frame: usize,
282        delta: glam::Vec3,
283        travel_distance: f32,
284    ) {
285        let pending = self
286            .pending_fifty_fifty_movement
287            .get_or_insert(PendingFiftyFiftyMovement {
288                start_frame,
289                travel_distance: 0.0,
290                y_delta: 0.0,
291            });
292        if pending.start_frame != start_frame {
293            *pending = PendingFiftyFiftyMovement {
294                start_frame,
295                travel_distance: 0.0,
296                y_delta: 0.0,
297            };
298        }
299        pending.travel_distance += travel_distance;
300        pending.y_delta += delta.y;
301    }
302
303    fn flush_fifty_fifty_movement(&mut self, event: &FiftyFiftyEvent) {
304        let Some(pending) = self.pending_fifty_fifty_movement.take() else {
305            return;
306        };
307        if pending.start_frame != event.start_frame {
308            return;
309        }
310        let Some((player_id, team_is_team_0)) = Self::resolved_fifty_fifty_winner(event) else {
311            return;
312        };
313
314        let team_forward_sign = if team_is_team_0 { 1.0 } else { -1.0 };
315        let advance_distance = pending.y_delta * team_forward_sign;
316        let stats = self.player_stats.entry(player_id.clone()).or_default();
317        stats.total_ball_travel_distance += pending.travel_distance;
318        if advance_distance >= 0.0 {
319            stats.total_ball_advance_distance += advance_distance;
320        } else {
321            stats.total_ball_retreat_distance += -advance_distance;
322        }
323    }
324
325    fn credit_ball_movement(
326        &mut self,
327        ball: &BallFrameState,
328        possession_state: &PossessionState,
329        fifty_fifty_state: &FiftyFiftyState,
330        live_play: bool,
331    ) {
332        let current_ball_position = ball.position();
333        if !live_play {
334            self.previous_ball_position = current_ball_position;
335            self.pending_fifty_fifty_movement = None;
336            return;
337        }
338
339        let Some(current_ball_position) = current_ball_position else {
340            self.previous_ball_position = None;
341            self.pending_fifty_fifty_movement = None;
342            return;
343        };
344        let Some(previous_ball_position) = self.previous_ball_position else {
345            self.previous_ball_position = Some(current_ball_position);
346            return;
347        };
348        self.previous_ball_position = Some(current_ball_position);
349
350        let delta = current_ball_position - previous_ball_position;
351        let travel_distance = delta.length();
352        if travel_distance <= f32::EPSILON {
353            return;
354        }
355
356        if let Some(active_event) = fifty_fifty_state.active_event.as_ref() {
357            self.buffer_fifty_fifty_movement(active_event.start_frame, delta, travel_distance);
358            return;
359        }
360
361        if let Some(event) = fifty_fifty_state.resolved_events.last() {
362            self.buffer_fifty_fifty_movement(event.start_frame, delta, travel_distance);
363            self.flush_fifty_fifty_movement(event);
364            return;
365        }
366
367        self.pending_fifty_fifty_movement = None;
368
369        let (Some(player_id), Some(team_is_team_0)) = (
370            possession_state.active_player_before_sample.as_ref(),
371            possession_state.active_team_before_sample,
372        ) else {
373            return;
374        };
375
376        self.apply_ball_movement_credit(player_id, team_is_team_0, delta, travel_distance);
377    }
378
379    #[allow(clippy::too_many_arguments)]
380    pub fn update(
381        &mut self,
382        frame: &FrameInfo,
383        ball: &BallFrameState,
384        vertical_state: &PlayerVerticalState,
385        touch_state: &TouchState,
386        possession_state: &PossessionState,
387        fifty_fifty_state: &FiftyFiftyState,
388        live_play: bool,
389    ) -> SubtrActorResult<()> {
390        if !live_play {
391            self.current_last_touch_player = None;
392            self.previous_ball_velocity = ball.velocity();
393            self.previous_ball_position = ball.position();
394            self.pending_fifty_fifty_movement = None;
395            return Ok(());
396        }
397
398        self.begin_sample(frame);
399        self.apply_touch_events(frame, ball, vertical_state, &touch_state.touch_events);
400        self.credit_ball_movement(ball, possession_state, fifty_fifty_state, live_play);
401        self.previous_ball_velocity = ball.velocity();
402
403        if let Some(player_id) = touch_state.last_touch_player.as_ref() {
404            self.current_last_touch_player = Some(player_id.clone());
405        }
406
407        if let Some(player_id) = self.current_last_touch_player.as_ref() {
408            if let Some(stats) = self.player_stats.get_mut(player_id) {
409                stats.is_last_touch = true;
410            }
411        }
412
413        Ok(())
414    }
415}
416
417#[cfg(test)]
418#[path = "touch_tests.rs"]
419mod tests;