Skip to main content

subtr_actor/stats/calculators/
whiff.rs

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