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)]
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;