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