1use super::*;
2
3const WALL_AERIAL_MIN_WALL_CONTACT_DURATION: f32 = 0.30;
9const WALL_AERIAL_MAX_WALL_CONTACT_TO_TAKEOFF_SECONDS: f32 = 1.25;
10const WALL_AERIAL_MAX_TAKEOFF_TO_TOUCH_SECONDS: f32 = 2.75;
13const WALL_AERIAL_MIN_SECONDS_BETWEEN_ATTEMPTS: f32 = 3.0;
14pub(crate) const WALL_AERIAL_MIN_TOUCH_PLAYER_Z: f32 = AIR_DRIBBLE_MIN_PLAYER_Z;
15const WALL_AERIAL_MIN_CONTINUATION_PLAYER_Z: f32 = 300.0;
16pub(crate) const WALL_AERIAL_MIN_TOUCH_BALL_Z: f32 = 400.0;
17const WALL_AERIAL_REFERENCE_BALL_SPEED_CHANGE: f32 = 80.0;
18pub(crate) const WALL_AERIAL_HIGH_CONFIDENCE: f32 = 0.78;
19
20const SIDE_WALL_SURFACE_ABS_X: f32 = 4096.0;
23const BACK_WALL_SURFACE_ABS_Y: f32 = 5120.0;
24const WALL_CORNER_ARC_RADIUS: f32 = 1152.0;
25const WALL_CORNER_ARC_START_ABS_X: f32 = SIDE_WALL_SURFACE_ABS_X - WALL_CORNER_ARC_RADIUS;
30const WALL_CORNER_ARC_START_ABS_Y: f32 = BACK_WALL_SURFACE_ABS_Y - WALL_CORNER_ARC_RADIUS;
31const WALL_SURFACE_CONTACT_MAX_DISTANCE: f32 = 60.0;
37const WALL_SURFACE_CONTACT_MIN_UP_ALIGNMENT: f32 = 0.5;
44const WALL_AERIAL_CORNER_AXIS_RATIO: f32 = 0.4142136;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
55#[ts(export)]
56#[serde(rename_all = "snake_case")]
57pub enum WallAerialWall {
58 Left,
59 Right,
60 Front,
61 Back,
62 FrontLeft,
63 FrontRight,
64 BackLeft,
65 BackRight,
66}
67
68impl WallAerialWall {
69 pub fn as_label_value(self) -> &'static str {
70 match self {
71 Self::Left => "left",
72 Self::Right => "right",
73 Self::Front => "front",
74 Self::Back => "back",
75 Self::FrontLeft => "front_left",
76 Self::FrontRight => "front_right",
77 Self::BackLeft => "back_left",
78 Self::BackRight => "back_right",
79 }
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum WallSurface {
88 Side,
89 Back,
90}
91
92pub fn wall_outward_normal_and_distance(position: glam::Vec3) -> (glam::Vec3, f32) {
96 let arc_x = position.x.signum() * (position.x.abs() - WALL_CORNER_ARC_START_ABS_X).max(0.0);
97 let arc_y = position.y.signum() * (position.y.abs() - WALL_CORNER_ARC_START_ABS_Y).max(0.0);
98 if arc_x != 0.0 && arc_y != 0.0 {
99 let radial = glam::Vec3::new(arc_x, arc_y, 0.0);
100 let radial_length = radial.length();
101 (
102 radial / radial_length,
103 WALL_CORNER_ARC_RADIUS - radial_length,
104 )
105 } else {
106 let side_distance = SIDE_WALL_SURFACE_ABS_X - position.x.abs();
107 let back_distance = BACK_WALL_SURFACE_ABS_Y - position.y.abs();
108 if side_distance <= back_distance {
109 (
110 glam::Vec3::new(position.x.signum(), 0.0, 0.0),
111 side_distance,
112 )
113 } else {
114 (
115 glam::Vec3::new(0.0, position.y.signum(), 0.0),
116 back_distance,
117 )
118 }
119 }
120}
121
122pub fn wall_aerial_surface_contact(rigid_body: &boxcars::RigidBody) -> Option<WallSurface> {
128 let position = vec_to_glam(&rigid_body.location);
129 if position.z < WALL_CONTACT_MIN_PLAYER_Z {
130 return None;
131 }
132 let (outward, distance) = wall_outward_normal_and_distance(position);
133 if distance > WALL_SURFACE_CONTACT_MAX_DISTANCE {
134 return None;
135 }
136 let is_back_surface = outward.y.abs() > outward.x.abs();
137 if is_back_surface && position.x.abs() <= BACK_WALL_GOAL_MOUTH_HALF_WIDTH_X {
138 return None;
139 }
140 let up = quat_to_glam(&rigid_body.rotation) * glam::Vec3::Z;
141 if up.dot(-outward) < WALL_SURFACE_CONTACT_MIN_UP_ALIGNMENT {
142 return None;
143 }
144 Some(if is_back_surface {
145 WallSurface::Back
146 } else {
147 WallSurface::Side
148 })
149}
150
151pub(crate) fn wall_aerial_wall_classification(
157 is_team_0: bool,
158 position: glam::Vec3,
159) -> WallAerialWall {
160 let x = position.x.signum() * (position.x.abs() - WALL_CORNER_ARC_START_ABS_X).max(0.0);
161 let y = position.y.signum() * (position.y.abs() - WALL_CORNER_ARC_START_ABS_Y).max(0.0);
162 wall_aerial_wall_from_axes(is_team_0, x, y)
163}
164
165fn wall_aerial_wall_from_axes(is_team_0: bool, x: f32, y: f32) -> WallAerialWall {
171 let right = if is_team_0 { -x } else { x };
172 let front = if is_team_0 { y } else { -y };
173 let abs_right = right.abs();
174 let abs_front = front.abs();
175 let dominant = abs_right.max(abs_front);
176 if dominant <= f32::EPSILON {
177 return WallAerialWall::Back;
178 }
179 let toward_right = right >= 0.0;
180 let toward_front = front >= 0.0;
181 if abs_right.min(abs_front) / dominant >= WALL_AERIAL_CORNER_AXIS_RATIO {
182 match (toward_front, toward_right) {
183 (true, true) => WallAerialWall::FrontRight,
184 (true, false) => WallAerialWall::FrontLeft,
185 (false, true) => WallAerialWall::BackRight,
186 (false, false) => WallAerialWall::BackLeft,
187 }
188 } else if abs_right >= abs_front {
189 if toward_right {
190 WallAerialWall::Right
191 } else {
192 WallAerialWall::Left
193 }
194 } else if toward_front {
195 WallAerialWall::Front
196 } else {
197 WallAerialWall::Back
198 }
199}
200
201pub(crate) fn wall_aerial_normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
202 if max_value <= min_value {
203 return 0.0;
204 }
205 ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
206}
207
208pub(crate) fn wall_aerial_goal_alignment(
209 is_team_0: bool,
210 ball_position: glam::Vec3,
211 ball_velocity: glam::Vec3,
212) -> f32 {
213 const GOAL_CENTER_Y: f32 = 5120.0;
214
215 let target_y = if is_team_0 {
216 GOAL_CENTER_Y
217 } else {
218 -GOAL_CENTER_Y
219 };
220 let goal_direction =
221 (glam::Vec3::new(0.0, target_y, ball_position.z) - ball_position).normalize_or_zero();
222 goal_direction.dot(ball_velocity.normalize_or_zero())
223}
224
225#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
229#[ts(export)]
230pub struct WallAerialEvent {
231 pub time: f32,
232 pub frame: usize,
233 pub sample_time: f32,
234 pub sample_frame: usize,
235 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
236 pub player: PlayerId,
237 pub is_team_0: bool,
238 pub wall: WallAerialWall,
239 pub wall_contact_time: f32,
240 pub wall_contact_frame: usize,
241 pub takeoff_time: f32,
242 pub takeoff_frame: usize,
243 pub time_since_takeoff: f32,
244 pub wall_contact_position: [f32; 3],
245 pub takeoff_position: [f32; 3],
246 pub player_position: [f32; 3],
247 pub ball_position: [f32; 3],
248 pub setup_start_time: f32,
249 pub setup_start_frame: usize,
250 pub setup_duration: f32,
251 pub ball_speed: f32,
252 pub ball_speed_change: f32,
253 pub goal_alignment: f32,
254 pub confidence: f32,
255}
256
257#[derive(Debug, Clone, PartialEq)]
262struct WallContactSpan {
263 surface: WallSurface,
265 wall_direction: WallAerialWall,
268 start_time: f32,
269 start_frame: usize,
270 last_time: f32,
271 last_frame: usize,
272 last_position: glam::Vec3,
273}
274
275#[derive(Debug, Clone, PartialEq)]
276struct ArmedWallAerial {
277 player: PlayerId,
278 wall_direction: WallAerialWall,
279 wall_contact_time: f32,
280 wall_contact_frame: usize,
281 wall_contact_position: glam::Vec3,
282 takeoff_time: f32,
283 takeoff_frame: usize,
284 takeoff_position: glam::Vec3,
285 setup_start_time: f32,
286 setup_start_frame: usize,
287 setup_duration: f32,
288 recorded: bool,
289}
290
291#[derive(Debug, Clone, Default)]
293pub struct WallAerialCalculator {
294 events: EventStream<WallAerialEvent>,
295 wall_contacts: HashMap<PlayerId, WallContactSpan>,
296 armed_aerials: HashMap<PlayerId, ArmedWallAerial>,
297 recent_event_times: HashMap<PlayerId, f32>,
298 previous_ball_velocity: Option<glam::Vec3>,
299}
300
301impl WallAerialCalculator {
302 pub fn new() -> Self {
303 Self::default()
304 }
305
306 pub fn events(&self) -> &[WallAerialEvent] {
307 self.events.all()
308 }
309
310 pub fn new_events(&self) -> &[WallAerialEvent] {
311 self.events.new_events()
312 }
313
314 fn update_wall_contacts_and_takeoffs(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
323 for player in &players.players {
324 let Some(position) = player.position() else {
325 continue;
326 };
327
328 if let Some(surface) = player
330 .rigid_body
331 .as_ref()
332 .and_then(wall_aerial_surface_contact)
333 {
334 let wall_direction = wall_aerial_wall_classification(player.is_team_0, position);
335 match self.wall_contacts.get_mut(&player.player_id) {
336 Some(span) if span.surface == surface => {
337 span.last_time = frame.time;
338 span.last_frame = frame.frame_number;
339 span.last_position = position;
340 span.wall_direction = wall_direction;
341 }
342 _ => {
343 self.wall_contacts.insert(
344 player.player_id.clone(),
345 WallContactSpan {
346 surface,
347 wall_direction,
348 start_time: frame.time,
349 start_frame: frame.frame_number,
350 last_time: frame.time,
351 last_frame: frame.frame_number,
352 last_position: position,
353 },
354 );
355 }
356 }
357 continue;
358 }
359
360 if position.z < WALL_AERIAL_MIN_TOUCH_PLAYER_Z {
363 self.wall_contacts.remove(&player.player_id);
364 self.armed_aerials.remove(&player.player_id);
365 continue;
366 }
367
368 if self.armed_aerials.contains_key(&player.player_id) {
369 continue;
370 }
371
372 let Some(span) = self.wall_contacts.remove(&player.player_id) else {
375 continue;
376 };
377 if frame.time - span.last_time > WALL_AERIAL_MAX_WALL_CONTACT_TO_TAKEOFF_SECONDS {
378 continue;
379 }
380 let setup_duration = span.last_time - span.start_time;
381 if setup_duration < WALL_AERIAL_MIN_WALL_CONTACT_DURATION {
382 continue;
383 }
384 if self
385 .recent_event_times
386 .get(&player.player_id)
387 .is_some_and(|time| frame.time - time < WALL_AERIAL_MIN_SECONDS_BETWEEN_ATTEMPTS)
388 {
389 continue;
390 }
391 self.armed_aerials.insert(
392 player.player_id.clone(),
393 ArmedWallAerial {
394 player: player.player_id.clone(),
395 wall_direction: span.wall_direction,
396 wall_contact_time: span.last_time,
397 wall_contact_frame: span.last_frame,
398 wall_contact_position: span.last_position,
399 takeoff_time: frame.time,
400 takeoff_frame: frame.frame_number,
401 takeoff_position: position,
402 setup_start_time: span.start_time,
403 setup_start_frame: span.start_frame,
404 setup_duration,
405 recorded: false,
406 },
407 );
408 }
409 }
410
411 fn prune_armed_aerials(&mut self, current_time: f32) {
412 self.armed_aerials.retain(|_, armed| {
413 current_time - armed.takeoff_time <= WALL_AERIAL_MAX_TAKEOFF_TO_TOUCH_SECONDS
414 });
415 }
416
417 fn ball_speed_change(
418 frame: &FrameInfo,
419 ball: &BallFrameState,
420 previous_ball_velocity: Option<glam::Vec3>,
421 ) -> f32 {
422 const BALL_GRAVITY_Z: f32 = -650.0;
423
424 let Some(ball) = ball.sample() else {
425 return 0.0;
426 };
427 let Some(previous_ball_velocity) = previous_ball_velocity else {
428 return 0.0;
429 };
430
431 let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
432 let residual_linear_impulse =
433 ball.velocity() - previous_ball_velocity - expected_linear_delta;
434 residual_linear_impulse.length()
435 }
436
437 fn player_position(players: &PlayerFrameState, player_id: &PlayerId) -> Option<glam::Vec3> {
438 players
439 .players
440 .iter()
441 .find(|player| &player.player_id == player_id)
442 .and_then(PlayerSample::position)
443 }
444
445 fn controlled_play_event(
446 &self,
447 ball: &BallFrameState,
448 players: &PlayerFrameState,
449 touch: &TouchEvent,
450 ball_speed_change: f32,
451 ) -> Option<WallAerialEvent> {
452 let player_id = touch.player.as_ref()?;
453 let armed = self.armed_aerials.get(player_id)?;
454 if armed.recorded {
455 return None;
456 }
457 let player_position = Self::player_position(players, player_id)?;
458 if player_is_on_wall(player_position) || player_position.z < WALL_AERIAL_MIN_TOUCH_PLAYER_Z
459 {
460 return None;
461 }
462 let ball = ball.sample()?;
463 let ball_position = ball.position();
464 if ball_position.z < WALL_AERIAL_MIN_TOUCH_BALL_Z {
465 return None;
466 }
467 if player_position.z < WALL_AERIAL_MIN_CONTINUATION_PLAYER_Z {
468 return None;
469 }
470 let time_since_takeoff = touch.time - armed.takeoff_time;
471 if !(0.0..=WALL_AERIAL_MAX_TAKEOFF_TO_TOUCH_SECONDS).contains(&time_since_takeoff) {
472 return None;
473 }
474 let confidence = 0.30
475 + 0.20
476 * wall_aerial_normalize_score(
477 armed.setup_duration,
478 WALL_AERIAL_MIN_WALL_CONTACT_DURATION,
479 1.2,
480 )
481 + 0.18
482 * (1.0
483 - wall_aerial_normalize_score(
484 time_since_takeoff,
485 0.15,
486 WALL_AERIAL_MAX_TAKEOFF_TO_TOUCH_SECONDS,
487 ))
488 + 0.16
489 * wall_aerial_normalize_score(
490 player_position.z,
491 WALL_AERIAL_MIN_TOUCH_PLAYER_Z,
492 850.0,
493 )
494 + 0.16
495 * wall_aerial_normalize_score(
496 ball_speed_change,
497 WALL_AERIAL_REFERENCE_BALL_SPEED_CHANGE,
498 900.0,
499 );
500
501 Some(WallAerialEvent {
502 time: touch.time,
503 frame: touch.frame,
504 sample_time: touch.time,
505 sample_frame: touch.frame,
506 player: player_id.clone(),
507 is_team_0: touch.team_is_team_0,
508 wall: armed.wall_direction,
509 wall_contact_time: armed.wall_contact_time,
510 wall_contact_frame: armed.wall_contact_frame,
511 takeoff_time: armed.takeoff_time,
512 takeoff_frame: armed.takeoff_frame,
513 time_since_takeoff,
514 wall_contact_position: armed.wall_contact_position.to_array(),
515 takeoff_position: armed.takeoff_position.to_array(),
516 player_position: player_position.to_array(),
517 ball_position: ball_position.to_array(),
518 setup_start_time: armed.setup_start_time,
519 setup_start_frame: armed.setup_start_frame,
520 setup_duration: armed.setup_duration,
521 ball_speed: ball.velocity().length(),
522 ball_speed_change,
523 goal_alignment: wall_aerial_goal_alignment(
524 touch.team_is_team_0,
525 ball_position,
526 ball.velocity(),
527 ),
528 confidence: confidence.clamp(0.0, 1.0),
529 })
530 }
531
532 fn record_event(&mut self, frame: &FrameInfo, mut event: WallAerialEvent) {
533 event.sample_time = frame.time;
534 event.sample_frame = frame.frame_number;
535 self.recent_event_times
536 .insert(event.player.clone(), event.time);
537 self.events.push(event);
538 }
539
540 pub fn update(
541 &mut self,
542 frame: &FrameInfo,
543 ball: &BallFrameState,
544 players: &PlayerFrameState,
545 touch_state: &TouchState,
546 live_play_state: &LivePlayState,
547 ) -> SubtrActorResult<()> {
548 self.events.begin_update();
549 if !live_play_state.is_live_play {
550 self.wall_contacts.clear();
551 self.armed_aerials.clear();
552 self.recent_event_times.clear();
553 self.previous_ball_velocity = ball.velocity();
554 return Ok(());
555 }
556
557 self.update_wall_contacts_and_takeoffs(frame, players);
558 self.prune_armed_aerials(frame.time);
559
560 let ball_speed_change = Self::ball_speed_change(frame, ball, self.previous_ball_velocity);
561 for touch in chronological_touch_events(&touch_state.touch_events) {
562 if let Some(event) = self.controlled_play_event(ball, players, touch, ball_speed_change)
563 {
564 if let Some(armed) = self.armed_aerials.get_mut(&event.player) {
565 armed.recorded = true;
566 }
567 self.record_event(frame, event);
568 }
569 }
570
571 self.previous_ball_velocity = ball.velocity();
572
573 Ok(())
574 }
575}
576
577#[cfg(test)]
578#[path = "wall_aerial_tests.rs"]
579mod tests;