Skip to main content

subtr_actor/stats/calculators/
flick.rs

1use super::*;
2
3const FLICK_MAX_DODGE_TO_TOUCH_SECONDS: f32 = 0.32;
4const FLICK_MAX_SETUP_STALE_SECONDS: f32 = 0.35;
5const FLICK_MIN_SETUP_SECONDS: f32 = 0.18;
6const FLICK_MIN_SETUP_TOUCHES: u32 = 2;
7const FLICK_MIN_BALL_SPEED_CHANGE: f32 = 450.0;
8const FLICK_HIGH_CONFIDENCE: f32 = 0.80;
9const FLICK_MIN_CONFIDENCE: f32 = 0.55;
10const FLICK_MAX_CONTROL_BALL_Z: f32 = 700.0;
11const FLICK_MAX_CONTROL_HORIZONTAL_GAP: f32 = BALL_RADIUS_Z * 1.7;
12const FLICK_MIN_CONTROL_VERTICAL_GAP: f32 = 35.0;
13const FLICK_MAX_CONTROL_VERTICAL_GAP: f32 = 280.0;
14const FLICK_MIN_LOCAL_Z: f32 = 20.0;
15const FLICK_MAX_LOCAL_X_BEHIND: f32 = 95.0;
16const FLICK_MAX_LOCAL_X_FRONT: f32 = 210.0;
17const FLICK_MAX_LOCAL_Y: f32 = 170.0;
18const FLICK_MIN_IMPULSE_AWAY_ALIGNMENT: f32 = 0.15;
19
20#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
21#[ts(export)]
22pub struct FlickEvent {
23    pub time: f32,
24    pub frame: usize,
25    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
26    pub player: PlayerId,
27    pub is_team_0: bool,
28    pub dodge_time: f32,
29    pub dodge_frame: usize,
30    pub time_since_dodge: f32,
31    pub setup_start_time: f32,
32    pub setup_start_frame: usize,
33    pub setup_duration: f32,
34    pub setup_touch_count: u32,
35    pub average_horizontal_gap: f32,
36    pub average_vertical_gap: f32,
37    pub ball_speed_change: f32,
38    pub ball_impulse: [f32; 3],
39    pub impulse_away_alignment: f32,
40    pub vertical_impulse: f32,
41    pub confidence: f32,
42}
43
44#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
45#[ts(export)]
46pub struct FlickStats {
47    pub count: u32,
48    pub high_confidence_count: u32,
49    pub is_last_flick: bool,
50    pub last_flick_time: Option<f32>,
51    pub last_flick_frame: Option<usize>,
52    pub time_since_last_flick: Option<f32>,
53    pub frames_since_last_flick: Option<usize>,
54    pub last_confidence: Option<f32>,
55    pub best_confidence: f32,
56    pub cumulative_confidence: f32,
57    pub cumulative_setup_duration: f32,
58    pub cumulative_ball_speed_change: f32,
59}
60
61impl FlickStats {
62    pub fn average_confidence(&self) -> f32 {
63        if self.count == 0 {
64            0.0
65        } else {
66            self.cumulative_confidence / self.count as f32
67        }
68    }
69
70    pub fn average_setup_duration(&self) -> f32 {
71        if self.count == 0 {
72            0.0
73        } else {
74            self.cumulative_setup_duration / self.count as f32
75        }
76    }
77
78    pub fn average_ball_speed_change(&self) -> f32 {
79        if self.count == 0 {
80            0.0
81        } else {
82            self.cumulative_ball_speed_change / self.count as f32
83        }
84    }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq)]
88struct FlickControlObservation {
89    horizontal_gap: f32,
90    vertical_gap: f32,
91}
92
93#[derive(Debug, Clone, PartialEq)]
94struct ActiveFlickSetup {
95    is_team_0: bool,
96    start_time: f32,
97    start_frame: usize,
98    last_time: f32,
99    last_frame: usize,
100    duration: f32,
101    horizontal_gap_integral: f32,
102    vertical_gap_integral: f32,
103    touch_count: u32,
104}
105
106#[derive(Debug, Clone, PartialEq)]
107struct FlickSetupSummary {
108    is_team_0: bool,
109    start_time: f32,
110    start_frame: usize,
111    last_time: f32,
112    last_frame: usize,
113    duration: f32,
114    average_horizontal_gap: f32,
115    average_vertical_gap: f32,
116    touch_count: u32,
117}
118
119#[derive(Debug, Clone, PartialEq)]
120struct RecentDodgeStart {
121    time: f32,
122    frame: usize,
123    setup: FlickSetupSummary,
124}
125
126#[derive(Debug, Clone, Default, PartialEq)]
127pub struct FlickCalculator {
128    player_stats: HashMap<PlayerId, FlickStats>,
129    events: Vec<FlickEvent>,
130    active_setups: HashMap<PlayerId, ActiveFlickSetup>,
131    recent_setups: HashMap<PlayerId, FlickSetupSummary>,
132    recent_dodge_starts: HashMap<PlayerId, RecentDodgeStart>,
133    previous_dodge_active: HashMap<PlayerId, bool>,
134    previous_ball_velocity: Option<glam::Vec3>,
135    current_last_flick_player: Option<PlayerId>,
136}
137
138impl FlickCalculator {
139    pub fn new() -> Self {
140        Self::default()
141    }
142
143    pub fn player_stats(&self) -> &HashMap<PlayerId, FlickStats> {
144        &self.player_stats
145    }
146
147    pub fn events(&self) -> &[FlickEvent] {
148        &self.events
149    }
150
151    fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
152        if max_value <= min_value {
153            return 0.0;
154        }
155
156        ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
157    }
158
159    fn begin_sample(&mut self, frame: &FrameInfo) {
160        for stats in self.player_stats.values_mut() {
161            stats.is_last_flick = false;
162            stats.time_since_last_flick = stats
163                .last_flick_time
164                .map(|time| (frame.time - time).max(0.0));
165            stats.frames_since_last_flick = stats
166                .last_flick_frame
167                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
168        }
169
170        if let Some(player_id) = self.current_last_flick_player.as_ref() {
171            if let Some(stats) = self.player_stats.get_mut(player_id) {
172                stats.is_last_flick = true;
173            }
174        }
175    }
176
177    fn ball_impulse(
178        frame: &FrameInfo,
179        ball: &BallFrameState,
180        previous_ball_velocity: Option<glam::Vec3>,
181    ) -> glam::Vec3 {
182        const BALL_GRAVITY_Z: f32 = -650.0;
183
184        let Some(ball) = ball.sample() else {
185            return glam::Vec3::ZERO;
186        };
187        let Some(previous_ball_velocity) = previous_ball_velocity else {
188            return glam::Vec3::ZERO;
189        };
190
191        let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
192        ball.velocity() - previous_ball_velocity - expected_linear_delta
193    }
194
195    fn control_observation(
196        ball: &BallSample,
197        player: &PlayerSample,
198        controlling_player: Option<&PlayerId>,
199    ) -> Option<FlickControlObservation> {
200        if controlling_player != Some(&player.player_id) {
201            return None;
202        }
203
204        let player_rigid_body = player.rigid_body.as_ref()?;
205        let player_position = player.position()?;
206        let ball_position = ball.position();
207        if !(BALL_CARRY_MIN_BALL_Z..=FLICK_MAX_CONTROL_BALL_Z).contains(&ball_position.z) {
208            return None;
209        }
210
211        let horizontal_gap = player_position
212            .truncate()
213            .distance(ball_position.truncate());
214        if horizontal_gap > FLICK_MAX_CONTROL_HORIZONTAL_GAP {
215            return None;
216        }
217
218        let vertical_gap = ball_position.z - player_position.z;
219        if !(FLICK_MIN_CONTROL_VERTICAL_GAP..=FLICK_MAX_CONTROL_VERTICAL_GAP)
220            .contains(&vertical_gap)
221        {
222            return None;
223        }
224
225        let local_ball_position =
226            quat_to_glam(&player_rigid_body.rotation).inverse() * (ball_position - player_position);
227        if local_ball_position.x < -FLICK_MAX_LOCAL_X_BEHIND
228            || local_ball_position.x > FLICK_MAX_LOCAL_X_FRONT
229            || local_ball_position.y.abs() > FLICK_MAX_LOCAL_Y
230            || local_ball_position.z < FLICK_MIN_LOCAL_Z
231        {
232            return None;
233        }
234
235        Some(FlickControlObservation {
236            horizontal_gap,
237            vertical_gap,
238        })
239    }
240
241    fn setup_summary(setup: &ActiveFlickSetup) -> FlickSetupSummary {
242        FlickSetupSummary {
243            is_team_0: setup.is_team_0,
244            start_time: setup.start_time,
245            start_frame: setup.start_frame,
246            last_time: setup.last_time,
247            last_frame: setup.last_frame,
248            duration: setup.duration,
249            average_horizontal_gap: setup.horizontal_gap_integral
250                / setup.duration.max(f32::EPSILON),
251            average_vertical_gap: setup.vertical_gap_integral / setup.duration.max(f32::EPSILON),
252            touch_count: setup.touch_count,
253        }
254    }
255
256    fn setup_qualifies(setup: &FlickSetupSummary) -> bool {
257        setup.duration >= FLICK_MIN_SETUP_SECONDS || setup.touch_count >= FLICK_MIN_SETUP_TOUCHES
258    }
259
260    fn store_recent_setup(&mut self, player_id: PlayerId, setup: FlickSetupSummary) {
261        if Self::setup_qualifies(&setup) {
262            self.recent_setups.insert(player_id, setup);
263        }
264    }
265
266    fn finish_setup(&mut self, player_id: &PlayerId) {
267        let Some(setup) = self.active_setups.remove(player_id) else {
268            return;
269        };
270        self.store_recent_setup(player_id.clone(), Self::setup_summary(&setup));
271    }
272
273    fn recent_setup_for_player(
274        &self,
275        player_id: &PlayerId,
276        current_time: f32,
277    ) -> Option<FlickSetupSummary> {
278        if let Some(active) = self.active_setups.get(player_id) {
279            return Some(Self::setup_summary(active));
280        }
281
282        self.recent_setups
283            .get(player_id)
284            .filter(|setup| current_time - setup.last_time <= FLICK_MAX_SETUP_STALE_SECONDS)
285            .cloned()
286    }
287
288    fn update_control_setups(
289        &mut self,
290        frame: &FrameInfo,
291        ball: &BallFrameState,
292        players: &PlayerFrameState,
293        touch_events: &[TouchEvent],
294        controlling_player: Option<&PlayerId>,
295    ) {
296        let Some(ball) = ball.sample() else {
297            let player_ids: Vec<_> = self.active_setups.keys().cloned().collect();
298            for player_id in player_ids {
299                self.finish_setup(&player_id);
300            }
301            return;
302        };
303
304        let mut observed_players = HashSet::new();
305        for player in &players.players {
306            let Some(observation) = Self::control_observation(ball, player, controlling_player)
307            else {
308                continue;
309            };
310            observed_players.insert(player.player_id.clone());
311            let setup = self
312                .active_setups
313                .entry(player.player_id.clone())
314                .or_insert_with(|| ActiveFlickSetup {
315                    is_team_0: player.is_team_0,
316                    start_time: (frame.time - frame.dt).max(0.0),
317                    start_frame: frame.frame_number.saturating_sub(1),
318                    last_time: frame.time,
319                    last_frame: frame.frame_number,
320                    duration: frame.dt.max(0.0),
321                    horizontal_gap_integral: observation.horizontal_gap * frame.dt.max(0.0),
322                    vertical_gap_integral: observation.vertical_gap * frame.dt.max(0.0),
323                    touch_count: 0,
324                });
325
326            if setup.last_frame != frame.frame_number {
327                setup.last_time = frame.time;
328                setup.last_frame = frame.frame_number;
329                setup.duration += frame.dt.max(0.0);
330                setup.horizontal_gap_integral += observation.horizontal_gap * frame.dt.max(0.0);
331                setup.vertical_gap_integral += observation.vertical_gap * frame.dt.max(0.0);
332            }
333        }
334
335        for touch_event in touch_events {
336            let Some(player_id) = touch_event.player.as_ref() else {
337                continue;
338            };
339            if let Some(setup) = self.active_setups.get_mut(player_id) {
340                setup.touch_count += 1;
341            }
342        }
343
344        let active_ids: Vec<_> = self.active_setups.keys().cloned().collect();
345        for player_id in active_ids {
346            if !observed_players.contains(&player_id) {
347                self.finish_setup(&player_id);
348            }
349        }
350    }
351
352    fn track_dodge_starts(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
353        for player in &players.players {
354            let was_dodge_active = self
355                .previous_dodge_active
356                .insert(player.player_id.clone(), player.dodge_active)
357                .unwrap_or(false);
358            if !player.dodge_active || was_dodge_active {
359                continue;
360            }
361
362            let Some(setup) = self.recent_setup_for_player(&player.player_id, frame.time) else {
363                continue;
364            };
365            if !Self::setup_qualifies(&setup) {
366                continue;
367            }
368
369            self.recent_dodge_starts.insert(
370                player.player_id.clone(),
371                RecentDodgeStart {
372                    time: frame.time,
373                    frame: frame.frame_number,
374                    setup,
375                },
376            );
377        }
378    }
379
380    fn prune_recent_state(&mut self, current_time: f32) {
381        self.recent_setups
382            .retain(|_, setup| current_time - setup.last_time <= FLICK_MAX_SETUP_STALE_SECONDS);
383        self.recent_dodge_starts
384            .retain(|_, dodge| current_time - dodge.time <= FLICK_MAX_DODGE_TO_TOUCH_SECONDS);
385    }
386
387    fn candidate_event(
388        &self,
389        ball: &BallFrameState,
390        player: &PlayerSample,
391        touch_event: &TouchEvent,
392        dodge_start: &RecentDodgeStart,
393        ball_impulse: glam::Vec3,
394    ) -> Option<FlickEvent> {
395        let ball = ball.sample()?;
396        let player_position = player.position()?;
397        let time_since_dodge = touch_event.time - dodge_start.time;
398        if !(0.0..=FLICK_MAX_DODGE_TO_TOUCH_SECONDS).contains(&time_since_dodge) {
399            return None;
400        }
401
402        let ball_speed_change = ball_impulse.length();
403        if ball_speed_change < FLICK_MIN_BALL_SPEED_CHANGE {
404            return None;
405        }
406
407        let to_ball = (ball.position() - player_position).normalize_or_zero();
408        let impulse_direction = ball_impulse.normalize_or_zero();
409        if to_ball.length_squared() <= f32::EPSILON
410            || impulse_direction.length_squared() <= f32::EPSILON
411        {
412            return None;
413        }
414
415        let impulse_away_alignment = impulse_direction.dot(to_ball);
416        if impulse_away_alignment < FLICK_MIN_IMPULSE_AWAY_ALIGNMENT {
417            return None;
418        }
419
420        let vertical_impulse = ball_impulse.z.max(0.0);
421        let setup = &dodge_start.setup;
422        let timing_score =
423            1.0 - (time_since_dodge / FLICK_MAX_DODGE_TO_TOUCH_SECONDS).clamp(0.0, 1.0);
424        let setup_duration_score =
425            Self::normalize_score(setup.duration, FLICK_MIN_SETUP_SECONDS, 0.75);
426        let touch_score =
427            (setup.touch_count as f32 / FLICK_MIN_SETUP_TOUCHES as f32).clamp(0.0, 1.0);
428        let horizontal_control_score =
429            1.0 - (setup.average_horizontal_gap / FLICK_MAX_CONTROL_HORIZONTAL_GAP).clamp(0.0, 1.0);
430        let vertical_control_score = 1.0
431            - ((setup.average_vertical_gap - 110.0).abs() / FLICK_MAX_CONTROL_VERTICAL_GAP)
432                .clamp(0.0, 1.0);
433        let impulse_score =
434            Self::normalize_score(ball_speed_change, FLICK_MIN_BALL_SPEED_CHANGE, 1450.0);
435        let away_score = Self::normalize_score(
436            impulse_away_alignment,
437            FLICK_MIN_IMPULSE_AWAY_ALIGNMENT,
438            0.85,
439        );
440        let vertical_score = Self::normalize_score(vertical_impulse, 100.0, 750.0);
441
442        let confidence = 0.16 * timing_score
443            + 0.19 * setup_duration_score.max(touch_score)
444            + 0.12 * horizontal_control_score
445            + 0.10 * vertical_control_score
446            + 0.22 * impulse_score
447            + 0.15 * away_score
448            + 0.06 * vertical_score;
449        if confidence < FLICK_MIN_CONFIDENCE {
450            return None;
451        }
452
453        Some(FlickEvent {
454            time: touch_event.time,
455            frame: touch_event.frame,
456            player: player.player_id.clone(),
457            is_team_0: player.is_team_0,
458            dodge_time: dodge_start.time,
459            dodge_frame: dodge_start.frame,
460            time_since_dodge,
461            setup_start_time: setup.start_time,
462            setup_start_frame: setup.start_frame,
463            setup_duration: setup.duration,
464            setup_touch_count: setup.touch_count,
465            average_horizontal_gap: setup.average_horizontal_gap,
466            average_vertical_gap: setup.average_vertical_gap,
467            ball_speed_change,
468            ball_impulse: ball_impulse.to_array(),
469            impulse_away_alignment,
470            vertical_impulse,
471            confidence,
472        })
473    }
474
475    fn apply_event(&mut self, frame: &FrameInfo, event: FlickEvent) {
476        let stats = self.player_stats.entry(event.player.clone()).or_default();
477        stats.count += 1;
478        if event.confidence >= FLICK_HIGH_CONFIDENCE {
479            stats.high_confidence_count += 1;
480        }
481        stats.is_last_flick = true;
482        stats.last_flick_time = Some(event.time);
483        stats.last_flick_frame = Some(event.frame);
484        stats.time_since_last_flick = Some((frame.time - event.time).max(0.0));
485        stats.frames_since_last_flick = Some(frame.frame_number.saturating_sub(event.frame));
486        stats.last_confidence = Some(event.confidence);
487        stats.best_confidence = stats.best_confidence.max(event.confidence);
488        stats.cumulative_confidence += event.confidence;
489        stats.cumulative_setup_duration += event.setup_duration;
490        stats.cumulative_ball_speed_change += event.ball_speed_change;
491
492        self.current_last_flick_player = Some(event.player.clone());
493        self.events.push(event);
494    }
495
496    fn apply_touch_events(
497        &mut self,
498        frame: &FrameInfo,
499        ball: &BallFrameState,
500        players: &PlayerFrameState,
501        touch_events: &[TouchEvent],
502    ) {
503        let ball_impulse = Self::ball_impulse(frame, ball, self.previous_ball_velocity);
504
505        for touch_event in touch_events {
506            let Some(player_id) = touch_event.player.as_ref() else {
507                continue;
508            };
509            let Some(player) = players
510                .players
511                .iter()
512                .find(|player| &player.player_id == player_id)
513            else {
514                continue;
515            };
516            let Some(dodge_start) = self.recent_dodge_starts.get(player_id) else {
517                continue;
518            };
519            let Some(event) =
520                self.candidate_event(ball, player, touch_event, dodge_start, ball_impulse)
521            else {
522                continue;
523            };
524
525            self.apply_event(frame, event);
526        }
527
528        if let Some(player_id) = self.current_last_flick_player.as_ref() {
529            if let Some(stats) = self.player_stats.get_mut(player_id) {
530                stats.is_last_flick = true;
531            }
532        }
533    }
534
535    fn reset_live_play_state(&mut self, ball: &BallFrameState) {
536        self.current_last_flick_player = None;
537        self.active_setups.clear();
538        self.recent_setups.clear();
539        self.recent_dodge_starts.clear();
540        self.previous_dodge_active.clear();
541        self.previous_ball_velocity = ball.velocity();
542    }
543
544    pub fn update(
545        &mut self,
546        frame: &FrameInfo,
547        ball: &BallFrameState,
548        players: &PlayerFrameState,
549        touch_state: &TouchState,
550        live_play_state: &LivePlayState,
551    ) -> SubtrActorResult<()> {
552        if !live_play_state.is_live_play {
553            self.reset_live_play_state(ball);
554            return Ok(());
555        }
556
557        self.begin_sample(frame);
558        self.prune_recent_state(frame.time);
559        self.update_control_setups(
560            frame,
561            ball,
562            players,
563            &touch_state.touch_events,
564            touch_state.last_touch_player.as_ref(),
565        );
566        self.track_dodge_starts(frame, players);
567        self.apply_touch_events(frame, ball, players, &touch_state.touch_events);
568        self.previous_ball_velocity = ball.velocity();
569        Ok(())
570    }
571}
572
573#[cfg(test)]
574#[path = "flick_tests.rs"]
575mod tests;