1use super::*;
2
3const BALL_GRAVITY_Z: f32 = -650.0;
6
7const FLICK_IMPULSE_WINDOW_SECONDS: f32 = 0.15;
14
15const FLICK_MAX_DODGE_TO_TOUCH_SECONDS: f32 = 0.32;
16const FLICK_DODGE_LEAD_TOLERANCE_SECONDS: f32 = DODGE_ACTIVE_BYTE_LAG_TOLERANCE_SECONDS;
24const FLICK_PENDING_RETENTION_SECONDS: f32 = FLICK_DODGE_LEAD_TOLERANCE_SECONDS;
30const _: () = assert!(FLICK_PENDING_RETENTION_SECONDS >= FLICK_IMPULSE_WINDOW_SECONDS);
31const FLICK_MAX_CONTROL_TO_DODGE_SECONDS: f32 = 0.08;
32const FLICK_MAX_SETUP_STALE_SECONDS: f32 = 0.35;
33const FLICK_SETUP_GAP_GRACE_SECONDS: f32 = 0.12;
40const FLICK_MIN_PENDING_DODGE_SETUP_SECONDS: f32 = 0.10;
41const FLICK_MIN_SETUP_SECONDS: f32 = 0.20;
42const FLICK_MIN_BALL_SPEED_CHANGE: f32 = 325.0;
43const FLICK_MIN_CONFIDENCE: f32 = 0.55;
44const FLICK_MAX_CONTROL_BALL_Z: f32 = 700.0;
45const FLICK_MAX_CONTROL_HORIZONTAL_GAP: f32 = BALL_RADIUS_Z * 1.7;
46const FLICK_MIN_CONTROL_VERTICAL_GAP: f32 = 35.0;
47const FLICK_MAX_CONTROL_VERTICAL_GAP: f32 = 280.0;
48const FLICK_MAX_CARRY_REL_HORIZONTAL_SPEED: f32 = 300.0;
58const FLICK_MIN_LOCAL_Z: f32 = 20.0;
59const FLICK_MAX_LOCAL_X_BEHIND: f32 = 95.0;
60const FLICK_MAX_LOCAL_X_FRONT: f32 = 210.0;
61const FLICK_MAX_LOCAL_Y: f32 = 170.0;
62const FLICK_MIN_IMPULSE_AWAY_ALIGNMENT: f32 = 0.15;
63
64const REVERSE_FLICK_MIN_BACKWARD: f32 = 0.25;
87const REVERSE_FLICK_MIN_UNDERSIDE_ROTATION: f32 = 0.2;
95const REVERSE_FLICK_MIN_LAUNCH_FORWARD: f32 = 0.4;
102const REVERSE_FLICK_MAX_LAUNCH_VERTICAL_FRACTION: f32 = 0.6;
113const SIDE_FLICK_MIN_SIDE: f32 = 0.6;
115const FORWARD_FLICK_MIN_FORWARD: f32 = 0.35;
117const FLICK_DIRECTION_MIN_SIDE: f32 = 0.25;
120const FLICK_DODGE_SIDE_RIGHT_SIGN: f32 = 1.0;
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum FlickKind {
131 Other,
134 Forward,
136 Reverse,
138 Side,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum FlickDirection {
146 Center,
147 Left,
148 Right,
149}
150
151pub(crate) const FLICK_KIND_LABELS: [StatLabel; 4] = [
152 StatLabel::new("kind", "other"),
153 StatLabel::new("kind", "forward"),
154 StatLabel::new("kind", "reverse"),
155 StatLabel::new("kind", "side"),
156];
157
158pub(crate) const FLICK_DIRECTION_LABELS: [StatLabel; 3] = [
159 StatLabel::new("direction", "center"),
160 StatLabel::new("direction", "left"),
161 StatLabel::new("direction", "right"),
162];
163
164impl FlickKind {
165 pub fn as_label_value(self) -> &'static str {
166 match self {
167 Self::Other => "other",
168 Self::Forward => "forward",
169 Self::Reverse => "reverse",
170 Self::Side => "side",
171 }
172 }
173
174 pub fn as_label(self) -> StatLabel {
175 flick_kind_label(self.as_label_value())
176 }
177}
178
179impl FlickDirection {
180 pub fn as_label_value(self) -> &'static str {
181 match self {
182 Self::Center => "center",
183 Self::Left => "left",
184 Self::Right => "right",
185 }
186 }
187}
188
189pub(crate) fn flick_kind_label(value: &str) -> StatLabel {
190 match value {
191 "forward" => StatLabel::new("kind", "forward"),
192 "reverse" => StatLabel::new("kind", "reverse"),
193 "side" => StatLabel::new("kind", "side"),
194 _ => StatLabel::new("kind", "other"),
195 }
196}
197
198pub(crate) fn flick_direction_label(value: &str) -> StatLabel {
199 match value {
200 "left" => StatLabel::new("direction", "left"),
201 "right" => StatLabel::new("direction", "right"),
202 _ => StatLabel::new("direction", "center"),
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
208#[ts(export)]
209pub struct FlickEvent {
210 pub time: f32,
211 pub frame: usize,
212 pub sample_time: f32,
213 pub sample_frame: usize,
214 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
215 pub player: PlayerId,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub player_position: Option<[f32; 3]>,
218 pub is_team_0: bool,
219 pub dodge_time: f32,
220 pub dodge_frame: usize,
221 pub time_since_dodge: f32,
222 pub setup_start_time: f32,
223 pub setup_start_frame: usize,
224 pub setup_duration: f32,
225 pub setup_touch_count: u32,
226 pub average_horizontal_gap: f32,
227 pub average_vertical_gap: f32,
228 pub ball_speed_change: f32,
229 pub ball_impulse: [f32; 3],
230 pub impulse_away_alignment: f32,
231 pub vertical_impulse: f32,
232 pub kind: String,
233 pub direction: String,
234 pub local_ball_position: [f32; 3],
235 pub local_ball_impulse: [f32; 3],
236 pub dodge_forward_back: f32,
238 pub dodge_side: f32,
241 pub dodge_torque: Option<[f32; 3]>,
247 pub travel_offset_radians: f32,
253 pub launch_forward_alignment: f32,
260 pub launch_vertical_fraction: f32,
267 pub underside_rotation: f32,
274 pub confidence: f32,
275}
276
277#[derive(Debug, Clone, Copy, PartialEq)]
278struct FlickControlObservation {
279 horizontal_gap: f32,
280 vertical_gap: f32,
281 relative_horizontal_speed: Option<f32>,
284}
285
286#[derive(Debug, Clone, PartialEq)]
287struct ActiveFlickSetup {
288 is_team_0: bool,
289 start_time: f32,
290 start_frame: usize,
291 last_time: f32,
292 last_frame: usize,
293 duration: f32,
294 horizontal_gap_integral: f32,
295 vertical_gap_integral: f32,
296 touch_count: u32,
297 min_relative_horizontal_speed: f32,
301 observed_velocity: bool,
305}
306
307#[derive(Debug, Clone, PartialEq)]
308struct FlickSetupSummary {
309 is_team_0: bool,
310 start_time: f32,
311 start_frame: usize,
312 last_time: f32,
313 last_frame: usize,
314 duration: f32,
315 average_horizontal_gap: f32,
316 average_vertical_gap: f32,
317 touch_count: u32,
318 min_relative_horizontal_speed: f32,
319 observed_velocity: bool,
320}
321
322#[derive(Debug, Clone, PartialEq)]
323struct RecentDodgeStart {
324 time: f32,
325 frame: usize,
326 setup: FlickSetupSummary,
327 rotation_at_dodge: Option<glam::Quat>,
328 dodge_torque: Option<glam::Vec3>,
332}
333
334#[derive(Debug, Clone)]
341struct PendingFlick {
342 touch_event: TouchEvent,
343 ball: BallFrameState,
344 player: PlayerSample,
345 real_dodge_start: Option<RecentDodgeStart>,
347 classified_dodge: bool,
349 measure_start_time: f32,
355 pre_velocity: glam::Vec3,
357 peak_impulse: glam::Vec3,
358 peak_magnitude: f32,
359}
360
361impl PartialEq for PendingFlick {
362 fn eq(&self, other: &Self) -> bool {
363 self.touch_event.touch_id == other.touch_event.touch_id
364 && self.touch_event.player == other.touch_event.player
365 && self.touch_event.frame == other.touch_event.frame
366 }
367}
368
369#[derive(Debug, Clone, Default, PartialEq)]
371pub struct FlickCalculator {
372 events: EventStream<FlickEvent>,
373 active_setups: HashMap<PlayerId, ActiveFlickSetup>,
374 recent_setups: HashMap<PlayerId, FlickSetupSummary>,
375 recent_dodge_starts: HashMap<PlayerId, RecentDodgeStart>,
376 pending_flicks: Vec<PendingFlick>,
377 previous_dodge_active: HashMap<PlayerId, bool>,
378 previous_ball_velocity: Option<glam::Vec3>,
379 last_emitted_dodge_frame: HashMap<PlayerId, usize>,
383}
384
385impl FlickCalculator {
386 pub fn new() -> Self {
387 Self::default()
388 }
389
390 pub fn events(&self) -> &[FlickEvent] {
391 self.events.all()
392 }
393
394 pub fn new_events(&self) -> &[FlickEvent] {
395 self.events.new_events()
396 }
397
398 fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
399 if max_value <= min_value {
400 return 0.0;
401 }
402
403 ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
404 }
405
406 fn gravity_compensated_impulse(
412 current_velocity: glam::Vec3,
413 reference_velocity: glam::Vec3,
414 elapsed: f32,
415 ) -> glam::Vec3 {
416 let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * elapsed.max(0.0));
417 current_velocity - reference_velocity - expected_linear_delta
418 }
419
420 fn control_observation(
421 ball: &BallSample,
422 player: &PlayerSample,
423 controlling_player: Option<&PlayerId>,
424 ) -> Option<FlickControlObservation> {
425 if controlling_player != Some(&player.player_id) {
426 return None;
427 }
428
429 let player_rigid_body = player.rigid_body.as_ref()?;
430 let player_position = player.position()?;
431 let ball_position = ball.position();
432 if !(BALL_CARRY_MIN_BALL_Z..=FLICK_MAX_CONTROL_BALL_Z).contains(&ball_position.z) {
433 return None;
434 }
435
436 let horizontal_gap = player_position
437 .truncate()
438 .distance(ball_position.truncate());
439 if horizontal_gap > FLICK_MAX_CONTROL_HORIZONTAL_GAP {
440 return None;
441 }
442
443 let vertical_gap = ball_position.z - player_position.z;
444 if !(FLICK_MIN_CONTROL_VERTICAL_GAP..=FLICK_MAX_CONTROL_VERTICAL_GAP)
445 .contains(&vertical_gap)
446 {
447 return None;
448 }
449
450 let relative_horizontal_speed = match (
456 ball.rigid_body.linear_velocity.as_ref().map(vec_to_glam),
457 player.velocity(),
458 ) {
459 (Some(ball_velocity), Some(player_velocity)) => {
460 Some((ball_velocity.truncate() - player_velocity.truncate()).length())
461 }
462 _ => None,
463 };
464
465 let local_ball_position =
466 quat_to_glam(&player_rigid_body.rotation).inverse() * (ball_position - player_position);
467 if local_ball_position.x < -FLICK_MAX_LOCAL_X_BEHIND
468 || local_ball_position.x > FLICK_MAX_LOCAL_X_FRONT
469 || local_ball_position.y.abs() > FLICK_MAX_LOCAL_Y
470 || local_ball_position.z < FLICK_MIN_LOCAL_Z
471 {
472 return None;
473 }
474
475 Some(FlickControlObservation {
476 horizontal_gap,
477 vertical_gap,
478 relative_horizontal_speed,
479 })
480 }
481
482 fn setup_summary(setup: &ActiveFlickSetup) -> FlickSetupSummary {
483 FlickSetupSummary {
484 is_team_0: setup.is_team_0,
485 start_time: setup.start_time,
486 start_frame: setup.start_frame,
487 last_time: setup.last_time,
488 last_frame: setup.last_frame,
489 duration: setup.duration,
490 average_horizontal_gap: setup.horizontal_gap_integral
491 / setup.duration.max(f32::EPSILON),
492 average_vertical_gap: setup.vertical_gap_integral / setup.duration.max(f32::EPSILON),
493 touch_count: setup.touch_count,
494 min_relative_horizontal_speed: setup.min_relative_horizontal_speed,
495 observed_velocity: setup.observed_velocity,
496 }
497 }
498
499 fn setup_shows_carry(setup: &FlickSetupSummary) -> bool {
507 !setup.observed_velocity
508 || setup.min_relative_horizontal_speed <= FLICK_MAX_CARRY_REL_HORIZONTAL_SPEED
509 }
510
511 fn setup_qualifies(setup: &FlickSetupSummary) -> bool {
512 setup.duration >= FLICK_MIN_SETUP_SECONDS
513 }
514
515 fn local_ball_geometry(
518 player_rotation: glam::Quat,
519 rotation_at_dodge: Option<glam::Quat>,
520 relative_ball_position: glam::Vec3,
521 ball_impulse: glam::Vec3,
522 ) -> (glam::Vec3, glam::Vec3) {
523 let local_ball_position = player_rotation.inverse() * relative_ball_position;
524 let impulse_reference_rotation = rotation_at_dodge.unwrap_or(player_rotation);
525 let local_ball_impulse = impulse_reference_rotation.inverse() * ball_impulse;
526 (local_ball_position, local_ball_impulse)
527 }
528
529 fn classify_dodge(
554 dodge_torque: Option<glam::Vec3>,
555 launch_forward: f32,
556 launch_vertical_fraction: f32,
557 underside_rotation: f32,
558 ) -> (FlickKind, FlickDirection, f32, f32) {
559 let Some(torque) = dodge_torque else {
560 return (FlickKind::Other, FlickDirection::Center, 0.0, 0.0);
561 };
562 let torque_horizontal = torque.truncate();
563 if torque_horizontal.length_squared() <= f32::EPSILON {
564 return (FlickKind::Other, FlickDirection::Center, 0.0, 0.0);
565 }
566
567 let t = torque_horizontal.normalize();
568 let dodge_forward_back = t.y;
573 let dodge_side = -t.x;
574 let handed_side = dodge_side * FLICK_DODGE_SIDE_RIGHT_SIGN;
575
576 let direction = if handed_side >= FLICK_DIRECTION_MIN_SIDE {
577 FlickDirection::Right
578 } else if handed_side <= -FLICK_DIRECTION_MIN_SIDE {
579 FlickDirection::Left
580 } else {
581 FlickDirection::Center
582 };
583
584 let kind = if dodge_forward_back <= -REVERSE_FLICK_MIN_BACKWARD
585 && launch_forward >= REVERSE_FLICK_MIN_LAUNCH_FORWARD
586 && launch_vertical_fraction <= REVERSE_FLICK_MAX_LAUNCH_VERTICAL_FRACTION
587 && underside_rotation.abs() >= REVERSE_FLICK_MIN_UNDERSIDE_ROTATION
588 {
589 FlickKind::Reverse
590 } else if dodge_side.abs() >= SIDE_FLICK_MIN_SIDE {
591 FlickKind::Side
592 } else if dodge_forward_back >= FORWARD_FLICK_MIN_FORWARD {
593 FlickKind::Forward
594 } else {
595 FlickKind::Other
596 };
597
598 (kind, direction, dodge_forward_back, dodge_side)
599 }
600
601 fn store_recent_setup(&mut self, player_id: PlayerId, setup: FlickSetupSummary) {
602 if Self::setup_qualifies(&setup) {
603 self.recent_setups.insert(player_id, setup);
604 }
605 }
606
607 fn finish_setup(&mut self, player_id: &PlayerId) {
608 let Some(setup) = self.active_setups.remove(player_id) else {
609 return;
610 };
611 self.store_recent_setup(player_id.clone(), Self::setup_summary(&setup));
612 }
613
614 fn recent_setup_for_player(
615 &self,
616 player_id: &PlayerId,
617 current_time: f32,
618 ) -> Option<FlickSetupSummary> {
619 if let Some(active) = self.active_setups.get(player_id) {
620 return Some(Self::setup_summary(active));
621 }
622
623 self.recent_setups
624 .get(player_id)
625 .filter(|setup| current_time - setup.last_time <= FLICK_MAX_SETUP_STALE_SECONDS)
626 .cloned()
627 }
628
629 fn update_control_setups(
630 &mut self,
631 frame: &FrameInfo,
632 ball: &BallFrameState,
633 players: &PlayerFrameState,
634 touch_events: &[TouchEvent],
635 controlling_player: Option<&PlayerId>,
636 ) {
637 let Some(ball) = ball.sample() else {
638 let player_ids: Vec<_> = self.active_setups.keys().cloned().collect();
639 for player_id in player_ids {
640 self.finish_setup(&player_id);
641 }
642 return;
643 };
644
645 let mut observed_players = HashSet::new();
646 for player in &players.players {
647 let Some(observation) = Self::control_observation(ball, player, controlling_player)
648 else {
649 continue;
650 };
651 observed_players.insert(player.player_id.clone());
652 let setup = self
653 .active_setups
654 .entry(player.player_id.clone())
655 .or_insert_with(|| ActiveFlickSetup {
656 is_team_0: player.is_team_0,
657 start_time: (frame.time - frame.dt).max(0.0),
658 start_frame: frame.frame_number.saturating_sub(1),
659 last_time: frame.time,
660 last_frame: frame.frame_number,
661 duration: frame.dt.max(0.0),
662 horizontal_gap_integral: observation.horizontal_gap * frame.dt.max(0.0),
663 vertical_gap_integral: observation.vertical_gap * frame.dt.max(0.0),
664 touch_count: 0,
665 min_relative_horizontal_speed: f32::INFINITY,
666 observed_velocity: false,
667 });
668
669 if let Some(relative_horizontal_speed) = observation.relative_horizontal_speed {
674 setup.observed_velocity = true;
675 if !player.dodge_active {
683 setup.min_relative_horizontal_speed = setup
684 .min_relative_horizontal_speed
685 .min(relative_horizontal_speed);
686 }
687 }
688
689 if setup.last_frame != frame.frame_number {
690 setup.last_time = frame.time;
691 setup.last_frame = frame.frame_number;
692 setup.duration += frame.dt.max(0.0);
693 setup.horizontal_gap_integral += observation.horizontal_gap * frame.dt.max(0.0);
694 setup.vertical_gap_integral += observation.vertical_gap * frame.dt.max(0.0);
695 }
696 }
697
698 for touch_event in touch_events {
699 let Some(player_id) = touch_event.player.as_ref() else {
700 continue;
701 };
702 if let Some(setup) = self.active_setups.get_mut(player_id) {
703 setup.touch_count += 1;
704 }
705 }
706
707 let active_ids: Vec<_> = self.active_setups.keys().cloned().collect();
708 for player_id in active_ids {
709 if observed_players.contains(&player_id) {
710 continue;
711 }
712 let gap_elapsed = self
716 .active_setups
717 .get(&player_id)
718 .map(|setup| frame.time - setup.last_time > FLICK_SETUP_GAP_GRACE_SECONDS)
719 .unwrap_or(true);
720 if gap_elapsed {
721 self.finish_setup(&player_id);
722 }
723 }
724 }
725
726 fn track_dodge_starts(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
727 for player in &players.players {
728 let was_dodge_active = self
729 .previous_dodge_active
730 .insert(player.player_id.clone(), player.dodge_active)
731 .unwrap_or(false);
732 if !player.dodge_active || was_dodge_active {
733 continue;
734 }
735
736 let Some(setup) = self.recent_setup_for_player(&player.player_id, frame.time) else {
737 continue;
738 };
739 if !Self::setup_qualifies(&setup) {
740 continue;
741 }
742 if !Self::setup_shows_carry(&setup) {
743 continue;
744 }
745 if frame.time - setup.last_time > FLICK_MAX_CONTROL_TO_DODGE_SECONDS {
746 continue;
747 }
748
749 self.recent_dodge_starts.insert(
750 player.player_id.clone(),
751 Self::dodge_start(frame.time, frame.frame_number, setup, player),
752 );
753 }
754 }
755
756 fn dodge_start(
760 time: f32,
761 frame: usize,
762 setup: FlickSetupSummary,
763 player: &PlayerSample,
764 ) -> RecentDodgeStart {
765 RecentDodgeStart {
766 time,
767 frame,
768 setup,
769 rotation_at_dodge: player
770 .rigid_body
771 .as_ref()
772 .map(|rigid_body| quat_to_glam(&rigid_body.rotation)),
773 dodge_torque: player.dodge_torque,
774 }
775 }
776
777 fn prune_recent_state(&mut self, current_time: f32) {
778 self.recent_setups
779 .retain(|_, setup| current_time - setup.last_time <= FLICK_MAX_SETUP_STALE_SECONDS);
780 self.recent_dodge_starts
781 .retain(|_, dodge| current_time - dodge.time <= FLICK_MAX_DODGE_TO_TOUCH_SECONDS);
782 }
783
784 fn candidate_event(
785 &self,
786 ball: &BallFrameState,
787 player: &PlayerSample,
788 touch_event: &TouchEvent,
789 dodge_start: &RecentDodgeStart,
790 ball_impulse: glam::Vec3,
791 ) -> Option<FlickEvent> {
792 let ball = ball.sample()?;
793 let player_rigid_body = player.rigid_body.as_ref()?;
794 let player_position = player.position()?;
795 let time_since_dodge = touch_event.time - dodge_start.time;
796 if !(-FLICK_DODGE_LEAD_TOLERANCE_SECONDS..=FLICK_MAX_DODGE_TO_TOUCH_SECONDS)
797 .contains(&time_since_dodge)
798 {
799 return None;
800 }
801
802 let ball_speed_change = ball_impulse.length();
803 if ball_speed_change < FLICK_MIN_BALL_SPEED_CHANGE {
804 return None;
805 }
806
807 let to_ball = (ball.position() - player_position).normalize_or_zero();
808 let impulse_direction = ball_impulse.normalize_or_zero();
809 if to_ball.length_squared() <= f32::EPSILON
810 || impulse_direction.length_squared() <= f32::EPSILON
811 {
812 return None;
813 }
814
815 let impulse_away_alignment = impulse_direction.dot(to_ball);
816 if impulse_away_alignment < FLICK_MIN_IMPULSE_AWAY_ALIGNMENT {
817 return None;
818 }
819
820 let vertical_impulse = ball_impulse.z.max(0.0);
821 let player_rotation = quat_to_glam(&player_rigid_body.rotation);
822 let (local_ball_position, local_ball_impulse) = Self::local_ball_geometry(
823 player_rotation,
824 dodge_start.rotation_at_dodge,
825 ball.position() - player_position,
826 ball_impulse,
827 );
828 let travel_offset_radians = player
832 .velocity()
833 .map(|velocity| {
834 let forward = (player_rotation * glam::Vec3::X).truncate();
835 let heading = velocity.truncate();
836 if forward.length() > 0.3 && heading.length() > 50.0 {
837 let forward = forward.normalize();
838 let heading = heading.normalize();
839 (forward.x * heading.y - forward.y * heading.x).atan2(forward.dot(heading))
840 } else {
841 0.0
842 }
843 })
844 .unwrap_or(0.0);
845 let launch_forward_alignment = player
849 .velocity()
850 .map(|velocity| {
851 let launch = ball_impulse.truncate();
852 let heading = velocity.truncate();
853 if launch.length() > f32::EPSILON && heading.length() > 50.0 {
854 launch.normalize().dot(heading.normalize())
855 } else {
856 0.0
857 }
858 })
859 .unwrap_or(0.0);
860 let launch_vertical_fraction = {
864 let mag = ball_impulse.length();
865 if mag > f32::EPSILON {
866 ball_impulse.z / mag
867 } else {
868 0.0
869 }
870 };
871 let underside_rotation = -(player_rotation * glam::Vec3::Y).z;
875 let (kind, direction, dodge_forward_back, dodge_side) = Self::classify_dodge(
876 dodge_start.dodge_torque,
877 launch_forward_alignment,
878 launch_vertical_fraction,
879 underside_rotation,
880 );
881 let setup = &dodge_start.setup;
882 let timing_score =
883 1.0 - (time_since_dodge / FLICK_MAX_DODGE_TO_TOUCH_SECONDS).clamp(0.0, 1.0);
884 let setup_duration_score =
885 Self::normalize_score(setup.duration, FLICK_MIN_SETUP_SECONDS, 0.75);
886 let horizontal_control_score =
887 1.0 - (setup.average_horizontal_gap / FLICK_MAX_CONTROL_HORIZONTAL_GAP).clamp(0.0, 1.0);
888 let vertical_control_score = 1.0
889 - ((setup.average_vertical_gap - 110.0).abs() / FLICK_MAX_CONTROL_VERTICAL_GAP)
890 .clamp(0.0, 1.0);
891 let impulse_score =
892 Self::normalize_score(ball_speed_change, FLICK_MIN_BALL_SPEED_CHANGE, 1450.0);
893 let away_score = Self::normalize_score(
894 impulse_away_alignment,
895 FLICK_MIN_IMPULSE_AWAY_ALIGNMENT,
896 0.85,
897 );
898 let vertical_score = Self::normalize_score(vertical_impulse, 100.0, 750.0);
899
900 let confidence = 0.16 * timing_score
901 + 0.19 * setup_duration_score
902 + 0.12 * horizontal_control_score
903 + 0.10 * vertical_control_score
904 + 0.22 * impulse_score
905 + 0.15 * away_score
906 + 0.06 * vertical_score;
907 if confidence < FLICK_MIN_CONFIDENCE {
908 return None;
909 }
910
911 Some(FlickEvent {
912 time: touch_event.time,
913 frame: touch_event.frame,
914 sample_time: touch_event.time,
915 sample_frame: touch_event.frame,
916 player: player.player_id.clone(),
917 player_position: Some(player_position.to_array()),
918 is_team_0: player.is_team_0,
919 dodge_time: dodge_start.time,
920 dodge_frame: dodge_start.frame,
921 time_since_dodge,
922 setup_start_time: setup.start_time,
923 setup_start_frame: setup.start_frame,
924 setup_duration: setup.duration,
925 setup_touch_count: setup.touch_count,
926 average_horizontal_gap: setup.average_horizontal_gap,
927 average_vertical_gap: setup.average_vertical_gap,
928 ball_speed_change,
929 ball_impulse: ball_impulse.to_array(),
930 impulse_away_alignment,
931 vertical_impulse,
932 kind: kind.as_label_value().to_owned(),
933 direction: direction.as_label_value().to_owned(),
934 local_ball_position: local_ball_position.to_array(),
935 local_ball_impulse: local_ball_impulse.to_array(),
936 dodge_forward_back,
937 dodge_side,
938 dodge_torque: dodge_start.dodge_torque.map(|torque| torque.to_array()),
939 travel_offset_radians,
940 launch_forward_alignment,
941 launch_vertical_fraction,
942 underside_rotation,
943 confidence,
944 })
945 }
946
947 fn apply_event(&mut self, frame: &FrameInfo, mut event: FlickEvent) {
948 event.sample_time = frame.time;
949 event.sample_frame = frame.frame_number;
950 self.events.push(event);
951 }
952
953 fn dodge_start_for_touch(&self, player: &PlayerSample) -> Option<RecentDodgeStart> {
954 if let Some(dodge_start) = self.recent_dodge_starts.get(&player.player_id) {
955 return Some(dodge_start.clone());
956 }
957 None
958 }
959
960 fn classified_as_dodge_touch(
961 touch_event: &TouchEvent,
962 touch_classification_events: &[TouchClassificationEvent],
963 ) -> bool {
964 let Some(touch_player) = touch_event.player.as_ref() else {
965 return false;
966 };
967 touch_classification_events.iter().any(|event| {
968 let same_touch = match (event.touch_id, touch_event.touch_id) {
969 (Some(event_id), Some(touch_id)) => event_id == touch_id,
970 _ => event.player == *touch_player && event.frame == touch_event.frame,
971 };
972 same_touch && event.has_tag("dodge_state", "dodge")
973 })
974 }
975
976 fn pending_dodge_start_for_touch(
977 &self,
978 player: &PlayerSample,
979 touch_event: &TouchEvent,
980 ) -> Option<RecentDodgeStart> {
981 let setup = self.recent_setup_for_player(&player.player_id, touch_event.time)?;
982 if setup.duration < FLICK_MIN_PENDING_DODGE_SETUP_SECONDS {
983 return None;
984 }
985 if !Self::setup_shows_carry(&setup) {
986 return None;
987 }
988 Some(Self::dodge_start(
989 touch_event.time,
990 touch_event.frame,
991 setup,
992 player,
993 ))
994 }
995
996 fn store_pending_flick(
1002 &mut self,
1003 ball: &BallFrameState,
1004 player: &PlayerSample,
1005 touch_event: &TouchEvent,
1006 pre_velocity: glam::Vec3,
1007 ) {
1008 let already_tracked = self.pending_flicks.iter().any(|pending| {
1009 pending.touch_event.touch_id == touch_event.touch_id
1010 && pending.touch_event.player == touch_event.player
1011 && pending.touch_event.frame == touch_event.frame
1012 });
1013 if already_tracked {
1014 return;
1017 }
1018 let has_setup = self
1021 .recent_setup_for_player(&player.player_id, touch_event.time)
1022 .is_some_and(|setup| setup.duration >= FLICK_MIN_PENDING_DODGE_SETUP_SECONDS);
1023 if !has_setup {
1024 return;
1025 }
1026 let inherited_measurement = self
1037 .pending_flicks
1038 .iter()
1039 .find(|pending| {
1040 pending.player.player_id == player.player_id
1041 && touch_event.time >= pending.touch_event.time
1042 })
1043 .map(|pending| {
1044 (
1045 pending.measure_start_time,
1046 pending.pre_velocity,
1047 pending.peak_impulse,
1048 pending.peak_magnitude,
1049 )
1050 });
1051 self.pending_flicks
1052 .retain(|pending| pending.player.player_id != player.player_id);
1053 let (measure_start_time, pre_velocity, peak_impulse, peak_magnitude) =
1054 inherited_measurement.unwrap_or((
1055 touch_event.time,
1056 pre_velocity,
1057 glam::Vec3::ZERO,
1058 0.0,
1059 ));
1060 self.pending_flicks.push(PendingFlick {
1061 touch_event: touch_event.clone(),
1062 ball: ball.clone(),
1063 player: player.clone(),
1064 real_dodge_start: self.dodge_start_for_touch(player),
1065 classified_dodge: false,
1066 measure_start_time,
1067 pre_velocity,
1068 peak_impulse,
1069 peak_magnitude,
1070 });
1071 }
1072
1073 fn update_and_resolve_pending_flicks(
1077 &mut self,
1078 frame: &FrameInfo,
1079 ball: &BallFrameState,
1080 touch_classification_events: &[TouchClassificationEvent],
1081 ) {
1082 let current_velocity = ball.velocity();
1083 let mut pending = std::mem::take(&mut self.pending_flicks);
1084 let mut emitted = Vec::new();
1085 pending.retain_mut(|flick| {
1086 let elapsed = (frame.time - flick.touch_event.time).max(0.0);
1087 if elapsed > FLICK_PENDING_RETENTION_SECONDS {
1088 return false;
1089 }
1090
1091 if elapsed <= FLICK_IMPULSE_WINDOW_SECONDS {
1098 if let Some(velocity) = current_velocity {
1099 let measure_elapsed = (frame.time - flick.measure_start_time).max(0.0);
1100 let impulse = Self::gravity_compensated_impulse(
1101 velocity,
1102 flick.pre_velocity,
1103 measure_elapsed,
1104 );
1105 let magnitude = impulse.length();
1106 if magnitude > flick.peak_magnitude {
1107 flick.peak_magnitude = magnitude;
1108 flick.peak_impulse = impulse;
1109 }
1110 }
1111 }
1112
1113 if flick.real_dodge_start.is_none() {
1114 flick.real_dodge_start = self.dodge_start_for_touch(&flick.player);
1115 }
1116 if !flick.classified_dodge {
1117 flick.classified_dodge = Self::classified_as_dodge_touch(
1118 &flick.touch_event,
1119 touch_classification_events,
1120 );
1121 }
1122
1123 let dodge_start = flick.real_dodge_start.clone().or_else(|| {
1127 if flick.classified_dodge {
1128 self.pending_dodge_start_for_touch(&flick.player, &flick.touch_event)
1129 } else {
1130 None
1131 }
1132 });
1133 let Some(dodge_start) = dodge_start else {
1134 return true;
1135 };
1136
1137 let already_emitted = self.last_emitted_dodge_frame.get(&flick.player.player_id)
1145 == Some(&dodge_start.frame)
1146 || emitted.iter().any(|event: &FlickEvent| {
1147 event.player == flick.player.player_id && event.dodge_frame == dodge_start.frame
1148 });
1149 if already_emitted {
1150 return false;
1151 }
1152
1153 if let Some(event) = self.candidate_event(
1154 &flick.ball,
1155 &flick.player,
1156 &flick.touch_event,
1157 &dodge_start,
1158 flick.peak_impulse,
1159 ) {
1160 emitted.push(event);
1161 return false;
1162 }
1163 true
1164 });
1165 self.pending_flicks = pending;
1166 for event in emitted {
1167 self.last_emitted_dodge_frame
1168 .insert(event.player.clone(), event.dodge_frame);
1169 self.apply_event(frame, event);
1170 }
1171 }
1172
1173 fn apply_touch_events(
1174 &mut self,
1175 _frame: &FrameInfo,
1176 ball: &BallFrameState,
1177 players: &PlayerFrameState,
1178 touch_events: &[TouchEvent],
1179 ) {
1180 let pre_velocity = self
1181 .previous_ball_velocity
1182 .or_else(|| ball.velocity())
1183 .unwrap_or(glam::Vec3::ZERO);
1184
1185 for touch_event in touch_events {
1186 let Some(player_id) = touch_event.player.as_ref() else {
1187 continue;
1188 };
1189 let Some(player) = players
1190 .players
1191 .iter()
1192 .find(|player| &player.player_id == player_id)
1193 else {
1194 continue;
1195 };
1196 self.store_pending_flick(ball, player, touch_event, pre_velocity);
1200 }
1201 }
1202
1203 fn reset_live_play_state(&mut self, ball: &BallFrameState) {
1204 self.active_setups.clear();
1205 self.recent_setups.clear();
1206 self.recent_dodge_starts.clear();
1207 self.pending_flicks.clear();
1208 self.previous_dodge_active.clear();
1209 self.last_emitted_dodge_frame.clear();
1210 self.previous_ball_velocity = ball.velocity();
1211 }
1212
1213 fn update_with_touch_classification_events(
1214 &mut self,
1215 frame: &FrameInfo,
1216 ball: &BallFrameState,
1217 players: &PlayerFrameState,
1218 touch_state: &TouchState,
1219 touch_classification_events: &[TouchClassificationEvent],
1220 live_play_state: &LivePlayState,
1221 ) -> SubtrActorResult<()> {
1222 self.events.begin_update();
1223 if !live_play_state.is_live_play {
1224 self.reset_live_play_state(ball);
1225 return Ok(());
1226 }
1227 self.prune_recent_state(frame.time);
1228 self.update_control_setups(
1229 frame,
1230 ball,
1231 players,
1232 &touch_state.touch_events,
1233 touch_state.last_touch_player.as_ref(),
1234 );
1235 self.track_dodge_starts(frame, players);
1236 self.apply_touch_events(frame, ball, players, &touch_state.touch_events);
1237 self.update_and_resolve_pending_flicks(frame, ball, touch_classification_events);
1238 self.previous_ball_velocity = ball.velocity();
1239 Ok(())
1240 }
1241
1242 pub fn update(
1243 &mut self,
1244 frame: &FrameInfo,
1245 ball: &BallFrameState,
1246 players: &PlayerFrameState,
1247 touch_state: &TouchState,
1248 touch: &TouchCalculator,
1249 live_play_state: &LivePlayState,
1250 ) -> SubtrActorResult<()> {
1251 self.update_with_touch_classification_events(
1252 frame,
1253 ball,
1254 players,
1255 touch_state,
1256 touch.events(),
1257 live_play_state,
1258 )
1259 }
1260}
1261
1262#[cfg(test)]
1263#[path = "flick_tests.rs"]
1264mod tests;