subtr_actor/stats/calculators/
whiff.rs1use super::*;
2
3const WHIFF_ENTER_DISTANCE: f32 = 220.0;
14const WHIFF_EXIT_DISTANCE: f32 = 360.0;
15const WHIFF_MAX_CANDIDATE_SECONDS: f32 = 1.0;
16const WHIFF_MIN_APPROACH_SPEED: f32 = 350.0;
17const WHIFF_MIN_CLOSING_SPEED: f32 = 250.0;
18const WHIFF_MIN_FORWARD_ALIGNMENT: f32 = 0.3;
19const WHIFF_MIN_VELOCITY_ALIGNMENT: f32 = 0.45;
20const WHIFF_MIN_DODGE_APPROACH_SPEED: f32 = 450.0;
21const WHIFF_MIN_DODGE_CLOSING_SPEED: f32 = 300.0;
22const WHIFF_MIN_DODGE_FORWARD_ALIGNMENT: f32 = 0.25;
23const WHIFF_MAX_LATERAL_OFFSET: f32 = 200.0;
24const WHIFF_MAX_DODGE_LATERAL_OFFSET: f32 = 150.0;
25const WHIFF_MIN_LOCAL_FORWARD_OFFSET: f32 = 0.0;
26const WHIFF_MIN_DODGE_LOCAL_FORWARD_OFFSET: f32 = -20.0;
27
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
30#[serde(rename_all = "snake_case")]
31#[ts(export, rename_all = "snake_case")]
32pub enum WhiffEventKind {
33 #[default]
34 Whiff,
35 BeatenToBall,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
40#[ts(export)]
41pub struct WhiffEvent {
42 #[serde(default)]
43 pub kind: WhiffEventKind,
44 pub time: f32,
45 pub frame: usize,
46 pub resolved_time: f32,
47 pub resolved_frame: usize,
48 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
49 pub player: PlayerId,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub player_position: Option<[f32; 3]>,
52 pub is_team_0: bool,
53 pub closest_approach_distance: f32,
54 pub forward_alignment: f32,
55 pub approach_speed: f32,
56 pub dodge_active: bool,
57 pub aerial: bool,
58}
59
60pub(crate) const WHIFF_DODGE_STATE_LABELS: [StatLabel; 2] = [
61 StatLabel::new("dodge_state", "no_dodge"),
62 StatLabel::new("dodge_state", "dodge"),
63];
64
65impl WhiffEvent {
66 pub(crate) fn labels(&self) -> [StatLabel; 2] {
67 [
68 vertical_state_label(self.aerial),
69 whiff_dodge_state_label(self.dodge_active),
70 ]
71 }
72}
73
74pub(crate) fn whiff_dodge_state_label(dodge_active: bool) -> StatLabel {
75 if dodge_active {
76 StatLabel::new("dodge_state", "dodge")
77 } else {
78 StatLabel::new("dodge_state", "no_dodge")
79 }
80}
81
82#[derive(Debug, Clone, PartialEq)]
83struct ActiveWhiffCandidate {
84 player: PlayerId,
85 is_team_0: bool,
86 start_time: f32,
87 closest_time: f32,
88 closest_frame: usize,
89 closest_position: [f32; 3],
90 closest_approach_distance: f32,
91 forward_alignment: f32,
92 approach_speed: f32,
93 dodge_active: bool,
94 aerial: bool,
95}
96
97impl InFlightItem for ActiveWhiffCandidate {
98 fn recognition(&self) -> Recognition {
99 Recognition::speculative(self.start_time, self.closest_frame)
103 }
104
105 fn on_boundary(&mut self, _boundary: Boundary) -> Disposition {
106 Disposition::Discard
108 }
109}
110
111#[derive(Debug, Clone, Default, PartialEq)]
113pub struct WhiffCalculator {
114 active_candidates: KeyedInFlightLedger<PlayerId, ActiveWhiffCandidate>,
115 events: EventStream<WhiffEvent>,
116}
117
118impl WhiffCalculator {
119 pub fn new() -> Self {
120 Self::default()
121 }
122
123 pub fn events(&self) -> &[WhiffEvent] {
124 self.events.all()
125 }
126
127 pub fn new_events(&self) -> &[WhiffEvent] {
128 self.events.new_events()
129 }
130
131 fn hitbox_distance(ball_position: glam::Vec3, player: &PlayerSample) -> Option<f32> {
132 let rigid_body = player.rigid_body.as_ref()?;
133 car_hitbox_distance(ball_position, rigid_body, player.hitbox)
134 }
135
136 fn local_ball_position(ball_position: glam::Vec3, player: &PlayerSample) -> Option<glam::Vec3> {
137 let rigid_body = player.rigid_body.as_ref()?;
138 let player_position = player.position()?;
139 Some(quat_to_glam(&rigid_body.rotation).inverse() * (ball_position - player_position))
140 }
141
142 fn whiff_candidate(
143 frame: &FrameInfo,
144 ball_position: glam::Vec3,
145 ball_velocity: glam::Vec3,
146 player: &PlayerSample,
147 ) -> Option<ActiveWhiffCandidate> {
148 let distance = Self::hitbox_distance(ball_position, player)?;
149 if distance > WHIFF_ENTER_DISTANCE {
150 return None;
151 }
152
153 let rigid_body = player.rigid_body.as_ref()?;
154 let player_position = player.position()?;
155 let local_ball_position = Self::local_ball_position(ball_position, player)?;
156 let to_ball = (ball_position - player_position).normalize_or_zero();
157 if to_ball.length_squared() <= f32::EPSILON {
158 return None;
159 }
160
161 let rotation = quat_to_glam(&rigid_body.rotation);
162 let forward_alignment = (rotation * glam::Vec3::X).dot(to_ball);
163 let player_velocity = player.velocity().unwrap_or(glam::Vec3::ZERO);
164 let player_speed = player_velocity.length();
165 let velocity_alignment = if player_speed <= f32::EPSILON {
166 0.0
167 } else {
168 player_velocity.normalize_or_zero().dot(to_ball)
169 };
170 let approach_speed = player_velocity.dot(to_ball);
171 let closing_speed = (player_velocity - ball_velocity).dot(to_ball);
172 let ball_in_front = local_ball_position.x >= WHIFF_MIN_LOCAL_FORWARD_OFFSET
173 && local_ball_position.y.abs() <= WHIFF_MAX_LATERAL_OFFSET;
174 let dodge_ball_in_front = local_ball_position.x >= WHIFF_MIN_DODGE_LOCAL_FORWARD_OFFSET
175 && local_ball_position.y.abs() <= WHIFF_MAX_DODGE_LATERAL_OFFSET;
176 let committed_approach = approach_speed >= WHIFF_MIN_APPROACH_SPEED
177 && closing_speed >= WHIFF_MIN_CLOSING_SPEED
178 && forward_alignment >= WHIFF_MIN_FORWARD_ALIGNMENT;
179 let directed_motion = velocity_alignment >= WHIFF_MIN_VELOCITY_ALIGNMENT;
180 let committed_dodge = player.dodge_active
181 && approach_speed >= WHIFF_MIN_DODGE_APPROACH_SPEED
182 && closing_speed >= WHIFF_MIN_DODGE_CLOSING_SPEED
183 && forward_alignment >= WHIFF_MIN_DODGE_FORWARD_ALIGNMENT
184 && dodge_ball_in_front;
185 if !(committed_dodge || committed_approach && directed_motion && ball_in_front) {
186 return None;
187 }
188
189 Some(ActiveWhiffCandidate {
190 player: player.player_id.clone(),
191 is_team_0: player.is_team_0,
192 start_time: frame.time,
193 closest_time: frame.time,
194 closest_frame: frame.frame_number,
195 closest_position: player_position.to_array(),
196 closest_approach_distance: distance,
197 forward_alignment,
198 approach_speed,
199 dodge_active: player.dodge_active,
200 aerial: player_position.z > POWERSLIDE_MAX_Z_THRESHOLD,
201 })
202 }
203
204 fn finish_touched_candidates(&mut self, frame: &FrameInfo, touch_state: &TouchState) {
205 let touched_players = touch_state
206 .touch_events
207 .iter()
208 .filter_map(|touch| touch.player.as_ref())
209 .collect::<HashSet<_>>();
210 let touched_teams = touch_state
211 .touch_events
212 .iter()
213 .map(|touch| touch.team_is_team_0)
214 .collect::<HashSet<_>>();
215 if touched_players.is_empty() && touched_teams.is_empty() {
216 return;
217 }
218
219 let candidate_players = self.active_candidates.keys().cloned().collect::<Vec<_>>();
220 for player_id in candidate_players {
221 let Some((candidate_player, candidate_team)) = self
222 .active_candidates
223 .get(&player_id)
224 .map(|candidate| (candidate.player.clone(), candidate.is_team_0))
225 else {
226 continue;
227 };
228 if touched_players.contains(&candidate_player) {
229 self.active_candidates.discard(&player_id);
231 } else if touched_teams.contains(&!candidate_team) {
232 if let Some(candidate) = self
233 .active_candidates
234 .finalize(&player_id, FinalizeReason::Completed)
235 {
236 self.emit_candidate(candidate, frame, WhiffEventKind::BeatenToBall);
237 }
238 } else {
239 self.active_candidates.discard(&player_id);
240 }
241 }
242 }
243
244 fn emit_candidate(
245 &mut self,
246 candidate: ActiveWhiffCandidate,
247 frame: &FrameInfo,
248 kind: WhiffEventKind,
249 ) {
250 let (time, frame_number) = match kind {
251 WhiffEventKind::Whiff => (candidate.closest_time, candidate.closest_frame),
252 WhiffEventKind::BeatenToBall => (frame.time, frame.frame_number),
253 };
254 let event = WhiffEvent {
255 kind,
256 time,
257 frame: frame_number,
258 resolved_time: frame.time,
259 resolved_frame: frame.frame_number,
260 player: candidate.player.clone(),
261 player_position: Some(candidate.closest_position),
262 is_team_0: candidate.is_team_0,
263 closest_approach_distance: candidate.closest_approach_distance,
264 forward_alignment: candidate.forward_alignment,
265 approach_speed: candidate.approach_speed,
266 dodge_active: candidate.dodge_active,
267 aerial: candidate.aerial,
268 };
269 self.events.push(event);
270 }
271
272 fn update_active_candidates(
273 &mut self,
274 frame: &FrameInfo,
275 ball_position: glam::Vec3,
276 ball_velocity: glam::Vec3,
277 players: &PlayerFrameState,
278 ) {
279 let mut visible_players = HashSet::new();
280
281 for player in &players.players {
282 let player_id = player.player_id.clone();
283 visible_players.insert(player_id.clone());
284 let distance = Self::hitbox_distance(ball_position, player);
285
286 if let (Some(candidate), Some(distance)) =
287 (self.active_candidates.get_mut(&player_id), distance)
288 {
289 if distance < candidate.closest_approach_distance {
290 candidate.closest_approach_distance = distance;
291 candidate.closest_time = frame.time;
292 candidate.closest_frame = frame.frame_number;
293 if let Some(position) = player.position() {
294 candidate.closest_position = position.to_array();
295 }
296 if let Some(updated) =
297 Self::whiff_candidate(frame, ball_position, ball_velocity, player)
298 {
299 candidate.forward_alignment = updated.forward_alignment;
300 candidate.approach_speed = updated.approach_speed;
301 candidate.dodge_active |= updated.dodge_active;
302 candidate.aerial |= updated.aerial;
303 }
304 }
305
306 if distance > WHIFF_EXIT_DISTANCE
307 || frame.time - candidate.start_time > WHIFF_MAX_CANDIDATE_SECONDS
308 {
309 if let Some(candidate) = self
310 .active_candidates
311 .finalize(&player_id, FinalizeReason::Completed)
312 {
313 self.emit_candidate(candidate, frame, WhiffEventKind::Whiff);
314 }
315 }
316 continue;
317 }
318
319 if let Some(candidate) =
320 Self::whiff_candidate(frame, ball_position, ball_velocity, player)
321 {
322 self.active_candidates.arm(player_id, candidate);
323 }
324 }
325
326 let missing_players = self
327 .active_candidates
328 .keys()
329 .filter(|player_id| !visible_players.contains(*player_id))
330 .cloned()
331 .collect::<Vec<_>>();
332 for player_id in missing_players {
333 self.active_candidates.discard(&player_id);
334 }
335 }
336
337 pub fn update(
338 &mut self,
339 frame: &FrameInfo,
340 ball: &BallFrameState,
341 players: &PlayerFrameState,
342 touch_state: &TouchState,
343 live_play_state: &LivePlayState,
344 ) -> SubtrActorResult<()> {
345 self.events.begin_update();
346 if !live_play_state.is_live_play {
347 self.active_candidates
348 .apply_boundary(Boundary::LivePlayEnded);
349 return Ok(());
350 }
351 self.finish_touched_candidates(frame, touch_state);
352 if touch_state.touch_events.is_empty() {
353 if let Some(ball_position) = ball.position() {
354 self.update_active_candidates(
355 frame,
356 ball_position,
357 ball.velocity().unwrap_or(glam::Vec3::ZERO),
358 players,
359 );
360 }
361 }
362 Ok(())
363 }
364
365 pub fn finish(&mut self) {
369 self.active_candidates.finish();
370 }
371}
372
373#[cfg(test)]
374#[path = "whiff_tests.rs"]
375mod tests;