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