Skip to main content

subtr_actor/stats/calculators/
whiff.rs

1use super::*;
2
3const WHIFF_ENTER_DISTANCE: f32 = 150.0;
4const WHIFF_EXIT_DISTANCE: f32 = 285.0;
5const WHIFF_MAX_CANDIDATE_SECONDS: f32 = 0.65;
6const WHIFF_MIN_APPROACH_SPEED: f32 = 700.0;
7const WHIFF_MIN_CLOSING_SPEED: f32 = 450.0;
8const WHIFF_MIN_FORWARD_ALIGNMENT: f32 = 0.55;
9const WHIFF_MIN_VELOCITY_ALIGNMENT: f32 = 0.7;
10const WHIFF_MIN_DODGE_APPROACH_SPEED: f32 = 450.0;
11const WHIFF_MIN_DODGE_CLOSING_SPEED: f32 = 300.0;
12const WHIFF_MIN_DODGE_FORWARD_ALIGNMENT: f32 = 0.25;
13const WHIFF_MAX_LATERAL_OFFSET: f32 = 120.0;
14const WHIFF_MAX_DODGE_LATERAL_OFFSET: f32 = 150.0;
15const WHIFF_MIN_LOCAL_FORWARD_OFFSET: f32 = 0.0;
16const WHIFF_MIN_DODGE_LOCAL_FORWARD_OFFSET: f32 = -20.0;
17
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
19#[serde(rename_all = "snake_case")]
20#[ts(export, rename_all = "snake_case")]
21pub enum WhiffEventKind {
22    #[default]
23    Whiff,
24    BeatenToBall,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
28#[ts(export)]
29pub struct WhiffEvent {
30    #[serde(default)]
31    pub kind: WhiffEventKind,
32    pub time: f32,
33    pub frame: usize,
34    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
35    pub player: PlayerId,
36    pub is_team_0: bool,
37    pub closest_approach_distance: f32,
38    pub forward_alignment: f32,
39    pub approach_speed: f32,
40    pub dodge_active: bool,
41    pub aerial: bool,
42}
43
44#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
45#[ts(export)]
46pub struct WhiffStats {
47    pub whiff_count: u32,
48    pub beaten_to_ball_count: u32,
49    pub grounded_whiff_count: u32,
50    pub aerial_whiff_count: u32,
51    pub dodge_whiff_count: u32,
52    pub is_last_whiff: bool,
53    pub last_whiff_time: Option<f32>,
54    pub last_whiff_frame: Option<usize>,
55    pub time_since_last_whiff: Option<f32>,
56    pub frames_since_last_whiff: Option<usize>,
57    pub last_closest_approach_distance: Option<f32>,
58    pub best_closest_approach_distance: Option<f32>,
59    pub cumulative_closest_approach_distance: f32,
60}
61
62impl WhiffStats {
63    pub fn average_closest_approach_distance(&self) -> f32 {
64        if self.whiff_count == 0 {
65            0.0
66        } else {
67            self.cumulative_closest_approach_distance / self.whiff_count as f32
68        }
69    }
70}
71
72#[derive(Debug, Clone, PartialEq)]
73struct ActiveWhiffCandidate {
74    player: PlayerId,
75    is_team_0: bool,
76    start_time: f32,
77    closest_time: f32,
78    closest_frame: usize,
79    closest_approach_distance: f32,
80    forward_alignment: f32,
81    approach_speed: f32,
82    dodge_active: bool,
83    aerial: bool,
84}
85
86#[derive(Debug, Clone, Default, PartialEq)]
87pub struct WhiffCalculator {
88    player_stats: HashMap<PlayerId, WhiffStats>,
89    active_candidates: HashMap<PlayerId, ActiveWhiffCandidate>,
90    events: Vec<WhiffEvent>,
91    current_last_whiff_player: Option<PlayerId>,
92}
93
94impl WhiffCalculator {
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    pub fn player_stats(&self) -> &HashMap<PlayerId, WhiffStats> {
100        &self.player_stats
101    }
102
103    pub fn events(&self) -> &[WhiffEvent] {
104        &self.events
105    }
106
107    fn hitbox_distance(ball_position: glam::Vec3, player: &PlayerSample) -> Option<f32> {
108        const OCTANE_HITBOX_LENGTH: f32 = 118.01;
109        const OCTANE_HITBOX_WIDTH: f32 = 84.2;
110        const OCTANE_HITBOX_HEIGHT: f32 = 36.16;
111        const OCTANE_HITBOX_OFFSET: f32 = 13.88;
112        const OCTANE_HITBOX_ELEVATION: f32 = 17.05;
113
114        let rigid_body = player.rigid_body.as_ref()?;
115        let player_position = player.position()?;
116        let local_ball_position =
117            quat_to_glam(&rigid_body.rotation).inverse() * (ball_position - player_position);
118
119        let x_min = -OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET;
120        let x_max = OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET;
121        let y_min = -OCTANE_HITBOX_WIDTH / 2.0;
122        let y_max = OCTANE_HITBOX_WIDTH / 2.0;
123        let z_min = -OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION;
124        let z_max = OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION;
125
126        let x_distance = if local_ball_position.x < x_min {
127            x_min - local_ball_position.x
128        } else if local_ball_position.x > x_max {
129            local_ball_position.x - x_max
130        } else {
131            0.0
132        };
133        let y_distance = if local_ball_position.y < y_min {
134            y_min - local_ball_position.y
135        } else if local_ball_position.y > y_max {
136            local_ball_position.y - y_max
137        } else {
138            0.0
139        };
140        let z_distance = if local_ball_position.z < z_min {
141            z_min - local_ball_position.z
142        } else if local_ball_position.z > z_max {
143            local_ball_position.z - z_max
144        } else {
145            0.0
146        };
147
148        Some(glam::Vec3::new(x_distance, y_distance, z_distance).length())
149    }
150
151    fn local_ball_position(ball_position: glam::Vec3, player: &PlayerSample) -> Option<glam::Vec3> {
152        let rigid_body = player.rigid_body.as_ref()?;
153        let player_position = player.position()?;
154        Some(quat_to_glam(&rigid_body.rotation).inverse() * (ball_position - player_position))
155    }
156
157    fn whiff_candidate(
158        frame: &FrameInfo,
159        ball_position: glam::Vec3,
160        ball_velocity: glam::Vec3,
161        player: &PlayerSample,
162    ) -> Option<ActiveWhiffCandidate> {
163        let distance = Self::hitbox_distance(ball_position, player)?;
164        if distance > WHIFF_ENTER_DISTANCE {
165            return None;
166        }
167
168        let rigid_body = player.rigid_body.as_ref()?;
169        let player_position = player.position()?;
170        let local_ball_position = Self::local_ball_position(ball_position, player)?;
171        let to_ball = (ball_position - player_position).normalize_or_zero();
172        if to_ball.length_squared() <= f32::EPSILON {
173            return None;
174        }
175
176        let rotation = quat_to_glam(&rigid_body.rotation);
177        let forward_alignment = (rotation * glam::Vec3::X).dot(to_ball);
178        let player_velocity = player.velocity().unwrap_or(glam::Vec3::ZERO);
179        let player_speed = player_velocity.length();
180        let velocity_alignment = if player_speed <= f32::EPSILON {
181            0.0
182        } else {
183            player_velocity.normalize_or_zero().dot(to_ball)
184        };
185        let approach_speed = player_velocity.dot(to_ball);
186        let closing_speed = (player_velocity - ball_velocity).dot(to_ball);
187        let ball_in_front = local_ball_position.x >= WHIFF_MIN_LOCAL_FORWARD_OFFSET
188            && local_ball_position.y.abs() <= WHIFF_MAX_LATERAL_OFFSET;
189        let dodge_ball_in_front = local_ball_position.x >= WHIFF_MIN_DODGE_LOCAL_FORWARD_OFFSET
190            && local_ball_position.y.abs() <= WHIFF_MAX_DODGE_LATERAL_OFFSET;
191        let committed_approach = approach_speed >= WHIFF_MIN_APPROACH_SPEED
192            && closing_speed >= WHIFF_MIN_CLOSING_SPEED
193            && forward_alignment >= WHIFF_MIN_FORWARD_ALIGNMENT;
194        let directed_motion = velocity_alignment >= WHIFF_MIN_VELOCITY_ALIGNMENT;
195        let committed_dodge = player.dodge_active
196            && approach_speed >= WHIFF_MIN_DODGE_APPROACH_SPEED
197            && closing_speed >= WHIFF_MIN_DODGE_CLOSING_SPEED
198            && forward_alignment >= WHIFF_MIN_DODGE_FORWARD_ALIGNMENT
199            && dodge_ball_in_front;
200        if !(committed_dodge || committed_approach && directed_motion && ball_in_front) {
201            return None;
202        }
203
204        Some(ActiveWhiffCandidate {
205            player: player.player_id.clone(),
206            is_team_0: player.is_team_0,
207            start_time: frame.time,
208            closest_time: frame.time,
209            closest_frame: frame.frame_number,
210            closest_approach_distance: distance,
211            forward_alignment,
212            approach_speed,
213            dodge_active: player.dodge_active,
214            aerial: player_position.z > POWERSLIDE_MAX_Z_THRESHOLD,
215        })
216    }
217
218    fn begin_sample(&mut self, frame: &FrameInfo) {
219        for stats in self.player_stats.values_mut() {
220            stats.is_last_whiff = false;
221            stats.time_since_last_whiff = stats
222                .last_whiff_time
223                .map(|time| (frame.time - time).max(0.0));
224            stats.frames_since_last_whiff = stats
225                .last_whiff_frame
226                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
227        }
228    }
229
230    fn finish_touched_candidates(&mut self, frame: &FrameInfo, touch_state: &TouchState) {
231        let touched_players = touch_state
232            .touch_events
233            .iter()
234            .filter_map(|touch| touch.player.as_ref())
235            .collect::<HashSet<_>>();
236        let touched_teams = touch_state
237            .touch_events
238            .iter()
239            .map(|touch| touch.team_is_team_0)
240            .collect::<HashSet<_>>();
241        if touched_players.is_empty() && touched_teams.is_empty() {
242            return;
243        }
244
245        let candidate_players = self.active_candidates.keys().cloned().collect::<Vec<_>>();
246        for player_id in candidate_players {
247            let Some(candidate) = self.active_candidates.remove(&player_id) else {
248                continue;
249            };
250            if touched_players.contains(&candidate.player) {
251                continue;
252            }
253            if touched_teams.contains(&!candidate.is_team_0) {
254                self.emit_candidate(candidate, frame, WhiffEventKind::BeatenToBall);
255            }
256        }
257    }
258
259    fn emit_candidate(
260        &mut self,
261        candidate: ActiveWhiffCandidate,
262        frame: &FrameInfo,
263        kind: WhiffEventKind,
264    ) {
265        let (time, frame_number) = match kind {
266            WhiffEventKind::Whiff => (candidate.closest_time, candidate.closest_frame),
267            WhiffEventKind::BeatenToBall => (frame.time, frame.frame_number),
268        };
269        let event = WhiffEvent {
270            kind,
271            time,
272            frame: frame_number,
273            player: candidate.player.clone(),
274            is_team_0: candidate.is_team_0,
275            closest_approach_distance: candidate.closest_approach_distance,
276            forward_alignment: candidate.forward_alignment,
277            approach_speed: candidate.approach_speed,
278            dodge_active: candidate.dodge_active,
279            aerial: candidate.aerial,
280        };
281
282        let stats = self
283            .player_stats
284            .entry(candidate.player.clone())
285            .or_default();
286        match event.kind {
287            WhiffEventKind::Whiff => {
288                stats.whiff_count += 1;
289                if event.aerial {
290                    stats.aerial_whiff_count += 1;
291                } else {
292                    stats.grounded_whiff_count += 1;
293                }
294                if event.dodge_active {
295                    stats.dodge_whiff_count += 1;
296                }
297                stats.is_last_whiff = true;
298                stats.last_whiff_time = Some(event.time);
299                stats.last_whiff_frame = Some(event.frame);
300                stats.time_since_last_whiff = Some((frame.time - event.time).max(0.0));
301                stats.frames_since_last_whiff =
302                    Some(frame.frame_number.saturating_sub(event.frame));
303                stats.last_closest_approach_distance = Some(event.closest_approach_distance);
304                stats.best_closest_approach_distance = Some(
305                    stats
306                        .best_closest_approach_distance
307                        .map(|distance| distance.min(event.closest_approach_distance))
308                        .unwrap_or(event.closest_approach_distance),
309                );
310                stats.cumulative_closest_approach_distance += event.closest_approach_distance;
311                self.current_last_whiff_player = Some(candidate.player);
312            }
313            WhiffEventKind::BeatenToBall => {
314                stats.beaten_to_ball_count += 1;
315            }
316        }
317        self.events.push(event);
318    }
319
320    fn update_active_candidates(
321        &mut self,
322        frame: &FrameInfo,
323        ball_position: glam::Vec3,
324        ball_velocity: glam::Vec3,
325        players: &PlayerFrameState,
326    ) {
327        let mut visible_players = HashSet::new();
328
329        for player in &players.players {
330            let player_id = player.player_id.clone();
331            visible_players.insert(player_id.clone());
332            let distance = Self::hitbox_distance(ball_position, player);
333
334            if let (Some(candidate), Some(distance)) =
335                (self.active_candidates.get_mut(&player_id), distance)
336            {
337                if distance < candidate.closest_approach_distance {
338                    candidate.closest_approach_distance = distance;
339                    candidate.closest_time = frame.time;
340                    candidate.closest_frame = frame.frame_number;
341                    if let Some(updated) =
342                        Self::whiff_candidate(frame, ball_position, ball_velocity, player)
343                    {
344                        candidate.forward_alignment = updated.forward_alignment;
345                        candidate.approach_speed = updated.approach_speed;
346                        candidate.dodge_active |= updated.dodge_active;
347                        candidate.aerial |= updated.aerial;
348                    }
349                }
350
351                if distance > WHIFF_EXIT_DISTANCE
352                    || frame.time - candidate.start_time > WHIFF_MAX_CANDIDATE_SECONDS
353                {
354                    if let Some(candidate) = self.active_candidates.remove(&player_id) {
355                        self.emit_candidate(candidate, frame, WhiffEventKind::Whiff);
356                    }
357                }
358                continue;
359            }
360
361            if let Some(candidate) =
362                Self::whiff_candidate(frame, ball_position, ball_velocity, player)
363            {
364                self.active_candidates.insert(player_id, candidate);
365            }
366        }
367
368        let missing_players = self
369            .active_candidates
370            .keys()
371            .filter(|player_id| !visible_players.contains(*player_id))
372            .cloned()
373            .collect::<Vec<_>>();
374        for player_id in missing_players {
375            self.active_candidates.remove(&player_id);
376        }
377    }
378
379    pub fn update(
380        &mut self,
381        frame: &FrameInfo,
382        ball: &BallFrameState,
383        players: &PlayerFrameState,
384        touch_state: &TouchState,
385        live_play: bool,
386    ) -> SubtrActorResult<()> {
387        if !live_play {
388            self.active_candidates.clear();
389            self.current_last_whiff_player = None;
390            return Ok(());
391        }
392
393        self.begin_sample(frame);
394        self.finish_touched_candidates(frame, touch_state);
395        if touch_state.touch_events.is_empty() {
396            if let Some(ball_position) = ball.position() {
397                self.update_active_candidates(
398                    frame,
399                    ball_position,
400                    ball.velocity().unwrap_or(glam::Vec3::ZERO),
401                    players,
402                );
403            }
404        }
405
406        if let Some(player_id) = self.current_last_whiff_player.as_ref() {
407            if let Some(stats) = self.player_stats.get_mut(player_id) {
408                stats.is_last_whiff = true;
409            }
410        }
411
412        Ok(())
413    }
414}
415
416#[cfg(test)]
417#[path = "whiff_tests.rs"]
418mod tests;