subtr_actor/stats/calculators/
touch.rs1use 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}