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, 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)]
107pub struct TouchCalculator {
108    player_stats: HashMap<PlayerId, TouchStats>,
109    current_last_touch_player: Option<PlayerId>,
110    previous_ball_velocity: Option<glam::Vec3>,
111}
112
113impl TouchCalculator {
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    pub fn player_stats(&self) -> &HashMap<PlayerId, TouchStats> {
119        &self.player_stats
120    }
121
122    fn ball_speed_change(
123        frame: &FrameInfo,
124        ball: &BallFrameState,
125        previous_ball_velocity: Option<glam::Vec3>,
126    ) -> f32 {
127        const BALL_GRAVITY_Z: f32 = -650.0;
128
129        let Some(ball) = ball.sample() else {
130            return 0.0;
131        };
132        let Some(previous_ball_velocity) = previous_ball_velocity else {
133            return 0.0;
134        };
135
136        let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
137        let residual_linear_impulse =
138            ball.velocity() - previous_ball_velocity - expected_linear_delta;
139        residual_linear_impulse.length()
140    }
141
142    fn classify_touch(
143        height_band: PlayerVerticalBand,
144        ball_speed_change: f32,
145    ) -> TouchClassification {
146        let kind = if ball_speed_change <= SOFT_TOUCH_BALL_SPEED_CHANGE_THRESHOLD {
147            if height_band.is_airborne() {
148                TouchKind::Control
149            } else {
150                TouchKind::Dribble
151            }
152        } else if ball_speed_change < HARD_TOUCH_BALL_SPEED_CHANGE_THRESHOLD {
153            TouchKind::MediumHit
154        } else {
155            TouchKind::HardHit
156        };
157
158        TouchClassification { kind, height_band }
159    }
160
161    fn apply_touch_classification(stats: &mut TouchStats, classification: TouchClassification) {
162        match classification.height_band {
163            PlayerVerticalBand::Ground => {}
164            PlayerVerticalBand::LowAir => stats.aerial_touch_count += 1,
165            PlayerVerticalBand::HighAir => {
166                stats.aerial_touch_count += 1;
167                stats.high_aerial_touch_count += 1;
168            }
169        }
170
171        match classification.kind {
172            TouchKind::Dribble => stats.dribble_touch_count += 1,
173            TouchKind::Control => stats.control_touch_count += 1,
174            TouchKind::MediumHit => stats.medium_hit_count += 1,
175            TouchKind::HardHit => stats.hard_hit_count += 1,
176        }
177
178        stats
179            .labeled_touch_counts
180            .increment(classification.labels());
181    }
182
183    fn begin_sample(&mut self, frame: &FrameInfo) {
184        for stats in self.player_stats.values_mut() {
185            stats.is_last_touch = false;
186            stats.time_since_last_touch = stats
187                .last_touch_time
188                .map(|time| (frame.time - time).max(0.0));
189            stats.frames_since_last_touch = stats
190                .last_touch_frame
191                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
192        }
193    }
194
195    fn apply_touch_events(
196        &mut self,
197        frame: &FrameInfo,
198        ball: &BallFrameState,
199        vertical_state: &PlayerVerticalState,
200        touch_events: &[TouchEvent],
201    ) {
202        let ball_speed_change = Self::ball_speed_change(frame, ball, self.previous_ball_velocity);
203
204        for touch_event in touch_events {
205            let Some(player_id) = touch_event.player.as_ref() else {
206                continue;
207            };
208            let height_band = vertical_state
209                .band_for_player(player_id)
210                .unwrap_or(PlayerVerticalBand::Ground);
211            let classification = Self::classify_touch(height_band, ball_speed_change);
212            let stats = self.player_stats.entry(player_id.clone()).or_default();
213            stats.touch_count += 1;
214            Self::apply_touch_classification(stats, classification);
215            stats.last_touch_time = Some(touch_event.time);
216            stats.last_touch_frame = Some(touch_event.frame);
217            stats.time_since_last_touch = Some((frame.time - touch_event.time).max(0.0));
218            stats.frames_since_last_touch =
219                Some(frame.frame_number.saturating_sub(touch_event.frame));
220            stats.last_ball_speed_change = Some(ball_speed_change);
221            stats.max_ball_speed_change = stats.max_ball_speed_change.max(ball_speed_change);
222            stats.cumulative_ball_speed_change += ball_speed_change;
223        }
224
225        if let Some(last_touch) = touch_events.last() {
226            self.current_last_touch_player = last_touch.player.clone();
227        }
228
229        if let Some(player_id) = self.current_last_touch_player.as_ref() {
230            if let Some(stats) = self.player_stats.get_mut(player_id) {
231                stats.is_last_touch = true;
232            }
233        }
234    }
235
236    pub fn update(
237        &mut self,
238        frame: &FrameInfo,
239        ball: &BallFrameState,
240        vertical_state: &PlayerVerticalState,
241        touch_state: &TouchState,
242        live_play: bool,
243    ) -> SubtrActorResult<()> {
244        if !live_play {
245            self.current_last_touch_player = None;
246            self.previous_ball_velocity = ball.velocity();
247            return Ok(());
248        }
249
250        self.begin_sample(frame);
251        self.apply_touch_events(frame, ball, vertical_state, &touch_state.touch_events);
252        self.previous_ball_velocity = ball.velocity();
253
254        if let Some(player_id) = touch_state.last_touch_player.as_ref() {
255            self.current_last_touch_player = Some(player_id.clone());
256        }
257
258        if let Some(player_id) = self.current_last_touch_player.as_ref() {
259            if let Some(stats) = self.player_stats.get_mut(player_id) {
260                stats.is_last_touch = true;
261            }
262        }
263
264        Ok(())
265    }
266}