subtr_actor/stats/calculators/
whiff.rs1use 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)]
322#[path = "whiff_tests.rs"]
323mod tests;