1use super::*;
2
3#[derive(Debug, Clone, Default)]
5pub struct TouchState {
6 pub touch_events: Vec<TouchEvent>,
7 pub last_touch: Option<TouchEvent>,
8 pub last_touch_player: Option<PlayerId>,
9 pub last_touch_team_is_team_0: Option<bool>,
10}
11
12impl TouchState {
13 pub fn primary_touch_event(&self) -> Option<&TouchEvent> {
14 primary_touch_event(&self.touch_events)
15 }
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19enum TouchCooldownKey {
20 Player(PlayerId),
21 Team(bool),
22}
23
24#[derive(Debug, Clone, Copy)]
28struct LastCooldownTouch {
29 time: f32,
30 dodge_contact: bool,
31}
32
33const TOUCH_SCORING: TouchCandidateScoring = TouchCandidateScoring::DEFAULT;
34const TOUCH_CANDIDATE_WINDOW_FRAMES: usize = 4;
35const CONTESTED_TOUCH_WINDOW_FRAMES: usize = 1;
36const BALL_GRAVITY_Z: f32 = -650.0;
37
38const MARKER_CONTACT_ATTRIBUTION_MAX_GAP: f32 = TOUCH_SCORING.relaxed_contact_gap_threshold;
49
50const GEOMETRIC_CONTEST_MAX_GAP: f32 = 5.0;
55
56const GEOMETRIC_CONTEST_WINDOW_FRAMES: usize = 4;
59const GEOMETRIC_CONTEST_WINDOW_SECONDS: f32 = 0.2;
60
61fn accepted_contact_gap(closest_contact_gap: f32, ball_deviation: BallTrajectoryDeviation) -> bool {
62 TOUCH_SCORING.accepts_contact_gap(
63 closest_contact_gap,
64 ball_deviation.position_deviation,
65 ball_deviation.velocity_deviation,
66 )
67}
68
69fn touch_candidate_score(closest_contact_gap: f32, dodge_contact: bool) -> f32 {
70 TOUCH_SCORING.score_contact_gap(closest_contact_gap, dodge_contact)
71}
72
73fn touch_event_score(event: &TouchEvent) -> f32 {
74 touch_candidate_score(
75 event.closest_approach_distance.unwrap_or(f32::INFINITY),
76 event.dodge_contact,
77 )
78}
79
80fn player_sort_key(player: &PlayerId) -> (u8, u64, String) {
81 match player {
82 boxcars::RemoteId::PlayStation(id) => (0, id.online_id, id.name.clone()),
83 boxcars::RemoteId::PsyNet(id) => (1, id.online_id, format!("{:?}", id.unknown1)),
84 boxcars::RemoteId::SplitScreen(id) => (2, u64::from(*id), String::new()),
85 boxcars::RemoteId::Steam(id) => (3, *id, String::new()),
86 boxcars::RemoteId::Switch(id) => (4, id.online_id, format!("{:?}", id.unknown1)),
87 boxcars::RemoteId::Xbox(id) => (5, *id, String::new()),
88 boxcars::RemoteId::QQ(id) => (6, *id, String::new()),
89 boxcars::RemoteId::Epic(id) => (7, 0, id.clone()),
90 }
91}
92
93fn touch_event_player_sort_key(event: &TouchEvent) -> Option<(u8, u64, String)> {
94 event.player.as_ref().map(player_sort_key)
95}
96
97pub(crate) fn touch_event_ordering(left: &TouchEvent, right: &TouchEvent) -> std::cmp::Ordering {
98 touch_event_score(left)
99 .total_cmp(&touch_event_score(right))
100 .then_with(|| {
101 left.closest_approach_distance
102 .unwrap_or(f32::INFINITY)
103 .total_cmp(&right.closest_approach_distance.unwrap_or(f32::INFINITY))
104 })
105 .then_with(|| right.dodge_contact.cmp(&left.dodge_contact))
106 .then_with(|| right.frame.cmp(&left.frame))
107 .then_with(|| right.time.total_cmp(&left.time))
108 .then_with(|| right.player.is_some().cmp(&left.player.is_some()))
109 .then_with(|| left.team_is_team_0.cmp(&right.team_is_team_0))
110 .then_with(|| touch_event_player_sort_key(left).cmp(&touch_event_player_sort_key(right)))
111}
112
113fn touch_event_chronological_ordering(left: &TouchEvent, right: &TouchEvent) -> std::cmp::Ordering {
114 TouchEvent::timestamp_ordering(left, right).then_with(|| touch_event_ordering(left, right))
115}
116
117fn primary_touch_event(touch_events: &[TouchEvent]) -> Option<&TouchEvent> {
118 let latest_touch = touch_events
119 .iter()
120 .max_by(|left, right| TouchEvent::timestamp_ordering(left, right))?;
121 touch_events
122 .iter()
123 .filter(|event| TouchEvent::timestamp_ordering(event, latest_touch).is_eq())
124 .min_by(|left, right| touch_event_ordering(left, right))
125}
126
127#[derive(Debug, Clone, Copy, Default)]
128struct TouchEventContactFields {
129 local_ball_position: Option<[f32; 3]>,
130 local_hitbox_point: Option<[f32; 3]>,
131 world_hitbox_point: Option<[f32; 3]>,
132}
133
134fn touch_event_contact_fields(
135 ball_position: glam::Vec3,
136 player_body: &boxcars::RigidBody,
137 hitbox: CarHitbox,
138) -> TouchEventContactFields {
139 let Some(estimate) = car_hitbox_contact_estimate(ball_position, player_body, hitbox) else {
140 return TouchEventContactFields::default();
141 };
142
143 let car_position = vec_to_glam(&player_body.location);
144 let car_rotation = quat_to_glam(&player_body.rotation);
145 let hitbox_center = glam::Vec3::new(hitbox.offset, 0.0, hitbox.elevation);
146 let hitbox_rotation = glam::Quat::from_rotation_y(hitbox.angle.to_radians());
147 let world_hitbox_point = car_position
148 + car_rotation * (hitbox_center + hitbox_rotation * estimate.local_contact_point);
149
150 TouchEventContactFields {
151 local_ball_position: Some(estimate.local_ball_position.to_array()),
152 local_hitbox_point: Some(estimate.local_contact_point.to_array()),
153 world_hitbox_point: Some(world_hitbox_point.to_array()),
154 }
155}
156
157#[derive(Debug, Clone)]
158struct RecentTeamTouch {
159 frame: usize,
160 time: f32,
161 team_is_team_0: bool,
162}
163
164#[derive(Debug, Clone, Default)]
166pub struct TouchStateCalculator {
167 previous_ball_rigid_body: Option<(boxcars::RigidBody, f32)>,
168 current_last_touch: Option<TouchEvent>,
169 recent_touch_candidates: HashMap<PlayerId, TouchEvent>,
170 last_touch_times: HashMap<TouchCooldownKey, LastCooldownTouch>,
171 recent_team_touches: Vec<RecentTeamTouch>,
172 next_touch_id: u64,
173}
174
175impl TouchStateCalculator {
176 pub fn new() -> Self {
177 Self::default()
178 }
179
180 fn prune_recent_touch_candidates(&mut self, current_frame: usize) {
181 self.recent_touch_candidates.retain(|_, candidate| {
182 current_frame.saturating_sub(candidate.frame) <= TOUCH_CANDIDATE_WINDOW_FRAMES
183 });
184 }
185
186 fn ball_trajectory_deviation(
187 &self,
188 frame: &FrameInfo,
189 ball: &BallFrameState,
190 ) -> Option<BallTrajectoryDeviation> {
191 let current_ball = ball.sample()?;
192 let (previous_ball, previous_time) = &self.previous_ball_rigid_body?;
193 ball_trajectory_deviation_with_gravity(
194 previous_ball,
195 *previous_time,
196 ¤t_ball.rigid_body,
197 frame.time,
198 BALL_GRAVITY_Z,
199 )
200 }
201
202 fn proximity_touch_candidates(
203 &self,
204 frame: &FrameInfo,
205 ball: &BallFrameState,
206 players: &PlayerFrameState,
207 ball_deviation: BallTrajectoryDeviation,
208 ) -> Vec<TouchEvent> {
209 let Some(ball) = ball.sample() else {
210 return Vec::new();
211 };
212
213 let mut candidates = players
214 .players
215 .iter()
216 .filter_map(|player| {
217 let rigid_body = player.rigid_body.as_ref()?;
218 let (closest_contact_gap, _current_contact_gap) =
219 touch_candidate_contact_gap_rank_with_hitbox(
220 &ball.rigid_body,
221 rigid_body,
222 player.hitbox,
223 )?;
224 if !accepted_contact_gap(closest_contact_gap, ball_deviation) {
225 return None;
226 }
227 let contact_fields =
228 touch_event_contact_fields(ball.position(), rigid_body, player.hitbox);
229
230 Some(TouchEvent {
231 touch_id: None,
232 time: frame.time,
233 frame: frame.frame_number,
234 team_is_team_0: player.is_team_0,
235 player: Some(player.player_id.clone()),
236 player_position: Some(rigid_body.location),
237 closest_approach_distance: Some(closest_contact_gap),
238 contact_local_ball_position: contact_fields.local_ball_position,
239 contact_local_hitbox_point: contact_fields.local_hitbox_point,
240 contact_world_hitbox_point: contact_fields.world_hitbox_point,
241 dodge_contact: player.dodge_active,
242 })
243 })
244 .collect::<Vec<_>>();
245
246 candidates.sort_by(touch_event_ordering);
247 candidates
248 }
249
250 fn candidate_touch_events(
251 &self,
252 frame: &FrameInfo,
253 ball: &BallFrameState,
254 players: &PlayerFrameState,
255 ball_deviation: BallTrajectoryDeviation,
256 ) -> Vec<TouchEvent> {
257 let candidates = self.proximity_touch_candidates(frame, ball, players, ball_deviation);
258 let Some(primary) = candidates.first() else {
259 return Vec::new();
260 };
261 let primary_score = touch_candidate_score(
262 primary.closest_approach_distance.unwrap_or(f32::INFINITY),
263 primary.dodge_contact,
264 );
265 candidates
266 .into_iter()
267 .filter(|candidate| {
268 let score = touch_candidate_score(
269 candidate.closest_approach_distance.unwrap_or(f32::INFINITY),
270 candidate.dodge_contact,
271 );
272 score <= primary_score + TOUCH_SCORING.simultaneous_touch_score_margin
273 })
274 .collect()
275 }
276
277 fn update_recent_touch_candidates(
278 &mut self,
279 frame: &FrameInfo,
280 ball: &BallFrameState,
281 players: &PlayerFrameState,
282 ) {
283 let Some(ball_deviation) = self.ball_trajectory_deviation(frame, ball) else {
284 return;
285 };
286
287 for candidate in self.proximity_touch_candidates(frame, ball, players, ball_deviation) {
288 let Some(player_id) = candidate.player.clone() else {
289 continue;
290 };
291
292 if self
293 .recent_touch_candidates
294 .get(&player_id)
295 .is_none_or(|previous| touch_event_ordering(&candidate, previous).is_lt())
296 {
297 self.recent_touch_candidates.insert(player_id, candidate);
298 }
299 }
300 }
301
302 fn candidate_for_player(&self, player_id: &PlayerId) -> Option<TouchEvent> {
303 self.recent_touch_candidates.get(player_id).cloned()
304 }
305
306 fn best_candidate_for_team(&self, team_is_team_0: bool) -> Option<TouchEvent> {
307 self.recent_touch_candidates
308 .values()
309 .filter(|candidate| candidate.team_is_team_0 == team_is_team_0)
310 .min_by(|left, right| touch_event_ordering(left, right))
311 .cloned()
312 }
313
314 fn current_frame_touch_candidate_for_team(
317 &self,
318 event: &TouchEvent,
319 ball: &BallFrameState,
320 players: &PlayerFrameState,
321 ) -> Option<TouchEvent> {
322 let ball = ball.sample()?;
323
324 players
325 .players
326 .iter()
327 .filter(|player| player.is_team_0 == event.team_is_team_0)
328 .filter_map(|player| {
329 let rigid_body = player.rigid_body.as_ref()?;
330 let (closest_contact_gap, _current_contact_gap) =
331 touch_candidate_contact_gap_rank_with_hitbox(
332 &ball.rigid_body,
333 rigid_body,
334 player.hitbox,
335 )?;
336 Some((closest_contact_gap, player, rigid_body))
337 })
338 .min_by(|left, right| left.0.total_cmp(&right.0))
339 .filter(|(closest_contact_gap, _, _)| {
340 *closest_contact_gap <= MARKER_CONTACT_ATTRIBUTION_MAX_GAP
341 })
342 .map(|(closest_contact_gap, player, rigid_body)| {
343 let contact_fields =
344 touch_event_contact_fields(ball.position(), rigid_body, player.hitbox);
345 TouchEvent {
346 touch_id: None,
347 time: event.time,
348 frame: event.frame,
349 team_is_team_0: event.team_is_team_0,
350 player: Some(player.player_id.clone()),
351 player_position: Some(rigid_body.location),
352 closest_approach_distance: Some(closest_contact_gap),
353 contact_local_ball_position: contact_fields.local_ball_position,
354 contact_local_hitbox_point: contact_fields.local_hitbox_point,
355 contact_world_hitbox_point: contact_fields.world_hitbox_point,
356 dodge_contact: player.dodge_active,
357 }
358 })
359 }
360
361 fn current_frame_touch_candidate_for_dodge_refresh(
366 &self,
367 dodge_refresh: &DodgeRefreshedEvent,
368 ball: &BallFrameState,
369 players: &PlayerFrameState,
370 ) -> Option<TouchEvent> {
371 self.previous_ball_rigid_body?;
372 let ball = ball.sample()?;
373 let player = players
374 .players
375 .iter()
376 .find(|player| player.player_id == dodge_refresh.player)?;
377 let rigid_body = player.rigid_body.as_ref()?;
378 let (closest_contact_gap, _current_contact_gap) =
379 touch_candidate_contact_gap_rank_with_hitbox(
380 &ball.rigid_body,
381 rigid_body,
382 player.hitbox,
383 )?;
384 if closest_contact_gap > MARKER_CONTACT_ATTRIBUTION_MAX_GAP {
385 return None;
386 }
387 let contact_fields = touch_event_contact_fields(ball.position(), rigid_body, player.hitbox);
388 Some(TouchEvent {
389 touch_id: None,
390 time: dodge_refresh.time,
391 frame: dodge_refresh.frame,
392 team_is_team_0: dodge_refresh.is_team_0,
393 player: Some(dodge_refresh.player.clone()),
394 player_position: Some(rigid_body.location),
395 closest_approach_distance: Some(closest_contact_gap),
396 contact_local_ball_position: contact_fields.local_ball_position,
397 contact_local_hitbox_point: contact_fields.local_hitbox_point,
398 contact_world_hitbox_point: contact_fields.world_hitbox_point,
399 dodge_contact: player.dodge_active,
400 })
401 }
402
403 fn enrich_team_touch_event_from_recent_cache(
412 &self,
413 event: &TouchEvent,
414 ball: &BallFrameState,
415 players: &PlayerFrameState,
416 ) -> Option<TouchEvent> {
417 let gap_of =
418 |candidate: &TouchEvent| candidate.closest_approach_distance.unwrap_or(f32::INFINITY);
419
420 let current_frame_candidate = if self.previous_ball_rigid_body.is_some() {
425 self.current_frame_touch_candidate_for_team(event, ball, players)
426 } else {
427 None
428 };
429
430 let candidate = match (
431 self.best_candidate_for_team(event.team_is_team_0),
432 current_frame_candidate,
433 ) {
434 (Some(cache_candidate), Some(current_candidate)) => {
435 if gap_of(¤t_candidate) < gap_of(&cache_candidate) {
436 current_candidate
437 } else {
438 cache_candidate
439 }
440 }
441 (Some(cache_candidate), None) => cache_candidate,
442 (None, Some(current_candidate)) => current_candidate,
443 (None, None) => return None,
444 };
445
446 Some(TouchEvent {
447 touch_id: None,
448 time: event.time,
449 frame: event.frame,
450 team_is_team_0: event.team_is_team_0,
451 player: event.player.clone().or(candidate.player),
452 player_position: event.player_position.or(candidate.player_position),
453 closest_approach_distance: event
454 .closest_approach_distance
455 .or(candidate.closest_approach_distance),
456 contact_local_ball_position: event
457 .contact_local_ball_position
458 .or(candidate.contact_local_ball_position),
459 contact_local_hitbox_point: event
460 .contact_local_hitbox_point
461 .or(candidate.contact_local_hitbox_point),
462 contact_world_hitbox_point: event
463 .contact_world_hitbox_point
464 .or(candidate.contact_world_hitbox_point),
465 dodge_contact: event.dodge_contact || candidate.dodge_contact,
466 })
467 }
468
469 fn enrich_explicit_touch_event_from_current_frame(
470 &self,
471 event: &TouchEvent,
472 ball: &BallFrameState,
473 players: &PlayerFrameState,
474 ) -> TouchEvent {
475 let Some(player_id) = event.player.as_ref() else {
476 return event.clone();
477 };
478 let Some(player) = players
479 .players
480 .iter()
481 .find(|sample| &sample.player_id == player_id)
482 else {
483 return event.clone();
484 };
485 let rigid_body = player.rigid_body.as_ref();
486 let closest_contact_gap = ball
487 .sample()
488 .zip(rigid_body)
489 .and_then(|(ball, rigid_body)| {
490 touch_candidate_contact_gap_rank_with_hitbox(
491 &ball.rigid_body,
492 rigid_body,
493 player.hitbox,
494 )
495 })
496 .map(|(closest_contact_gap, _current_contact_gap)| closest_contact_gap);
497 let contact_fields = ball
498 .position()
499 .zip(rigid_body)
500 .map(|(ball_position, rigid_body)| {
501 touch_event_contact_fields(ball_position, rigid_body, player.hitbox)
502 })
503 .unwrap_or_default();
504
505 TouchEvent {
506 team_is_team_0: player.is_team_0,
507 player_position: event
508 .player_position
509 .or_else(|| rigid_body.map(|rigid_body| rigid_body.location)),
510 closest_approach_distance: event.closest_approach_distance.or(closest_contact_gap),
511 contact_local_ball_position: event
512 .contact_local_ball_position
513 .or(contact_fields.local_ball_position),
514 contact_local_hitbox_point: event
515 .contact_local_hitbox_point
516 .or(contact_fields.local_hitbox_point),
517 contact_world_hitbox_point: event
518 .contact_world_hitbox_point
519 .or(contact_fields.world_hitbox_point),
520 dodge_contact: event.dodge_contact || player.dodge_active,
521 ..event.clone()
522 }
523 }
524
525 fn touch_event_from_dodge_refresh(
526 dodge_refresh: &DodgeRefreshedEvent,
527 candidate: TouchEvent,
528 ) -> TouchEvent {
529 TouchEvent {
530 touch_id: None,
531 time: dodge_refresh.time,
532 frame: dodge_refresh.frame,
533 team_is_team_0: dodge_refresh.is_team_0,
534 player: Some(dodge_refresh.player.clone()),
535 player_position: dodge_refresh
536 .player_position
537 .map(|position| glam_to_vec(&glam::Vec3::from_array(position)))
538 .or(candidate.player_position),
539 closest_approach_distance: candidate.closest_approach_distance,
540 contact_local_ball_position: candidate.contact_local_ball_position,
541 contact_local_hitbox_point: candidate.contact_local_hitbox_point,
542 contact_world_hitbox_point: candidate.contact_world_hitbox_point,
543 dodge_contact: candidate.dodge_contact,
544 }
545 }
546
547 fn explicit_touch_event(
548 &self,
549 event: &TouchEvent,
550 ball: &BallFrameState,
551 players: &PlayerFrameState,
552 allow_team_only_cache_attribution: bool,
553 ) -> Option<TouchEvent> {
554 if event.player.is_some() {
555 Some(self.enrich_explicit_touch_event_from_current_frame(event, ball, players))
556 } else if allow_team_only_cache_attribution {
557 self.enrich_team_touch_event_from_recent_cache(event, ball, players)
558 } else {
559 None
560 }
561 }
562
563 fn contested_touch_candidates(&self, primary: &TouchEvent) -> Vec<TouchEvent> {
564 let primary_score = touch_candidate_score(
565 primary.closest_approach_distance.unwrap_or(f32::INFINITY),
566 primary.dodge_contact,
567 );
568
569 let mut opposing_candidates = self
570 .recent_touch_candidates
571 .values()
572 .filter(|candidate| candidate.team_is_team_0 != primary.team_is_team_0)
573 .filter(|candidate| {
574 candidate.frame.abs_diff(primary.frame) <= CONTESTED_TOUCH_WINDOW_FRAMES
575 })
576 .filter(|candidate| {
577 touch_candidate_score(
578 candidate.closest_approach_distance.unwrap_or(f32::INFINITY),
579 candidate.dodge_contact,
580 ) <= primary_score + TOUCH_SCORING.contested_touch_score_margin
581 })
582 .cloned()
583 .collect::<Vec<_>>();
584 opposing_candidates.sort_by(touch_event_ordering);
585
586 opposing_candidates
587 }
588
589 fn geometric_contested_touches(
595 &self,
596 frame: &FrameInfo,
597 ball: &BallFrameState,
598 players: &PlayerFrameState,
599 confirmed_players: &HashSet<PlayerId>,
600 ) -> Vec<TouchEvent> {
601 if self.previous_ball_rigid_body.is_none() {
602 return Vec::new();
603 }
604 let Some(ball) = ball.sample() else {
605 return Vec::new();
606 };
607
608 players
609 .players
610 .iter()
611 .filter(|player| !confirmed_players.contains(&player.player_id))
612 .filter(|player| self.has_recent_opposing_team_touch(frame, player.is_team_0))
613 .filter_map(|player| {
614 let rigid_body = player.rigid_body.as_ref()?;
615 let (closest_contact_gap, _current_contact_gap) =
616 touch_candidate_contact_gap_rank_with_hitbox(
617 &ball.rigid_body,
618 rigid_body,
619 player.hitbox,
620 )?;
621 if closest_contact_gap > GEOMETRIC_CONTEST_MAX_GAP {
622 return None;
623 }
624 let contact_fields =
625 touch_event_contact_fields(ball.position(), rigid_body, player.hitbox);
626 Some(TouchEvent {
627 touch_id: None,
628 time: frame.time,
629 frame: frame.frame_number,
630 team_is_team_0: player.is_team_0,
631 player: Some(player.player_id.clone()),
632 player_position: Some(rigid_body.location),
633 closest_approach_distance: Some(closest_contact_gap),
634 contact_local_ball_position: contact_fields.local_ball_position,
635 contact_local_hitbox_point: contact_fields.local_hitbox_point,
636 contact_world_hitbox_point: contact_fields.world_hitbox_point,
637 dodge_contact: player.dodge_active,
638 })
639 })
640 .collect()
641 }
642
643 fn has_recent_opposing_team_touch(&self, frame: &FrameInfo, team_is_team_0: bool) -> bool {
644 self.recent_team_touches.iter().any(|touch| {
645 touch.team_is_team_0 != team_is_team_0
646 && frame.frame_number.saturating_sub(touch.frame) <= GEOMETRIC_CONTEST_WINDOW_FRAMES
647 && frame.time - touch.time <= GEOMETRIC_CONTEST_WINDOW_SECONDS
648 })
649 }
650
651 fn confirmed_touch_events(
652 &self,
653 frame: &FrameInfo,
654 ball: &BallFrameState,
655 players: &PlayerFrameState,
656 events: &FrameEventsState,
657 ) -> Vec<TouchEvent> {
658 let mut touch_events = Vec::new();
659 let mut confirmed_players = HashSet::new();
660
661 if let Some(ball_deviation) = self.ball_trajectory_deviation(frame, ball) {
662 let candidate_events =
663 self.candidate_touch_events(frame, ball, players, ball_deviation);
664 if let Some(candidate) = candidate_events.first() {
665 for contested_candidate in self.contested_touch_candidates(candidate) {
666 if let Some(player_id) = contested_candidate.player.as_ref() {
667 if confirmed_players.contains(player_id) {
668 continue;
669 }
670 confirmed_players.insert(player_id.clone());
671 }
672 touch_events.push(contested_candidate);
673 }
674 for candidate in candidate_events {
675 if let Some(player_id) = candidate.player.clone() {
676 if confirmed_players.contains(&player_id) {
677 continue;
678 }
679 confirmed_players.insert(player_id);
680 }
681 touch_events.push(candidate);
682 }
683 }
684 }
685
686 if touch_events.is_empty() {
687 for event in &events.touch_events {
688 let Some(event) = self.explicit_touch_event(event, ball, players, true) else {
689 continue;
690 };
691 if let Some(player_id) = event.player.clone() {
692 confirmed_players.insert(player_id);
693 }
694 touch_events.push(event);
695 }
696 } else {
697 for event in &events.touch_events {
698 if event.player.is_none() {
699 continue;
700 }
701 let Some(event) = self.explicit_touch_event(event, ball, players, false) else {
702 continue;
703 };
704 let Some(player_id) = event.player.clone() else {
705 continue;
706 };
707 if !confirmed_players.insert(player_id) {
708 continue;
709 }
710 touch_events.push(event);
711 }
712 }
713
714 for dodge_refresh in &events.dodge_refreshed_events {
715 if !confirmed_players.insert(dodge_refresh.player.clone()) {
716 continue;
717 }
718 let Some(candidate) = self
719 .candidate_for_player(&dodge_refresh.player)
720 .or_else(|| {
721 self.current_frame_touch_candidate_for_dodge_refresh(
722 dodge_refresh,
723 ball,
724 players,
725 )
726 })
727 else {
728 continue;
729 };
730 touch_events.push(Self::touch_event_from_dodge_refresh(
731 dodge_refresh,
732 candidate,
733 ));
734 }
735
736 for contested in self.geometric_contested_touches(frame, ball, players, &confirmed_players)
737 {
738 if let Some(player_id) = contested.player.clone() {
739 if !confirmed_players.insert(player_id) {
740 continue;
741 }
742 }
743 touch_events.push(contested);
744 }
745
746 touch_events
747 }
748
749 fn record_recent_team_touches(&mut self, frame: usize, touch_events: &[TouchEvent]) {
750 self.recent_team_touches
751 .retain(|touch| frame.saturating_sub(touch.frame) <= GEOMETRIC_CONTEST_WINDOW_FRAMES);
752 for event in touch_events {
753 self.recent_team_touches.push(RecentTeamTouch {
754 frame: event.frame,
755 time: event.time,
756 team_is_team_0: event.team_is_team_0,
757 });
758 }
759 }
760
761 fn touch_cooldown_key(event: &TouchEvent) -> TouchCooldownKey {
762 event
763 .player
764 .clone()
765 .map(TouchCooldownKey::Player)
766 .unwrap_or(TouchCooldownKey::Team(event.team_is_team_0))
767 }
768
769 fn touch_cooldown_allows(&mut self, event: &TouchEvent) -> bool {
770 const FLOAT_EPSILON: f32 = 0.0001;
771
772 let key = Self::touch_cooldown_key(event);
773 let allowed = self.last_touch_times.get(&key).is_none_or(|last| {
774 (event.dodge_contact && !last.dodge_contact)
781 || event.time - last.time + FLOAT_EPSILON >= TOUCH_RATE_LIMIT_SECONDS
782 });
783 if allowed {
784 self.last_touch_times.insert(
785 key,
786 LastCooldownTouch {
787 time: event.time,
788 dodge_contact: event.dodge_contact,
789 },
790 );
791 }
792 allowed
793 }
794
795 fn apply_touch_cooldown(&mut self, mut touch_events: Vec<TouchEvent>) -> Vec<TouchEvent> {
796 touch_events.sort_by(touch_event_chronological_ordering);
797 let mut accepted = touch_events
798 .into_iter()
799 .filter(|event| self.touch_cooldown_allows(event))
800 .collect::<Vec<_>>();
801 accepted.sort_by(touch_event_chronological_ordering);
802 accepted
803 }
804
805 pub fn update(
806 &mut self,
807 frame: &FrameInfo,
808 ball: &BallFrameState,
809 players: &PlayerFrameState,
810 events: &FrameEventsState,
811 live_play_state: &LivePlayState,
812 ) -> TouchState {
813 let touch_events = if live_play_state.counts_toward_player_motion() {
814 self.prune_recent_touch_candidates(frame.frame_number);
815 self.update_recent_touch_candidates(frame, ball, players);
816 let touch_events = self.confirmed_touch_events(frame, ball, players, events);
817 let mut touch_events = self.apply_touch_cooldown(touch_events);
818 for event in &mut touch_events {
819 event.touch_id = Some(self.next_touch_id);
820 self.next_touch_id += 1;
821 }
822 self.record_recent_team_touches(frame.frame_number, &touch_events);
823 touch_events
824 } else {
825 self.current_last_touch = None;
826 self.recent_touch_candidates.clear();
827 self.last_touch_times.clear();
828 self.recent_team_touches.clear();
829 Vec::new()
830 };
831
832 if let Some(last_touch) = primary_touch_event(&touch_events) {
833 self.current_last_touch = Some(last_touch.clone());
834 }
835 self.previous_ball_rigid_body = ball.sample().map(|sample| (sample.rigid_body, frame.time));
836
837 TouchState {
838 touch_events,
839 last_touch: self.current_last_touch.clone(),
840 last_touch_player: self
841 .current_last_touch
842 .as_ref()
843 .and_then(|touch| touch.player.clone()),
844 last_touch_team_is_team_0: self
845 .current_last_touch
846 .as_ref()
847 .map(|touch| touch.team_is_team_0),
848 }
849 }
850}
851
852#[cfg(test)]
853#[path = "touch_state_tests.rs"]
854mod tests;