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