subtr_actor/stats/calculators/
whiff.rs1use 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;