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