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}