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