Skip to main content

subtr_actor/stats/calculators/
wall_aerial_shot.rs

1use super::wall_aerial::{
2    WALL_AERIAL_MIN_TOUCH_BALL_Z, WALL_AERIAL_MIN_TOUCH_PLAYER_Z, wall_aerial_normalize_score,
3    wall_aerial_surface_contact, wall_aerial_wall_classification,
4};
5use super::*;
6
7const WALL_AERIAL_SHOT_MAX_WALL_CONTACT_TO_TAKEOFF_SECONDS: f32 = 2.25;
8/// The takeoff is the frame the car genuinely leaves the wall surface, so the
9/// whole flight to the shot counts against this window.
10const WALL_AERIAL_SHOT_MAX_TAKEOFF_TO_SHOT_SECONDS: f32 = 2.75;
11const WALL_AERIAL_SHOT_GROUND_CONTACT_MAX_PLAYER_Z: f32 = 80.0;
12
13/// A shot credited to a player shortly after taking off from a wall.
14#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
15#[ts(export)]
16pub struct WallAerialShotEvent {
17    pub time: f32,
18    pub frame: usize,
19    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
20    pub player: PlayerId,
21    pub is_team_0: bool,
22    pub wall: WallAerialWall,
23    pub wall_contact_time: f32,
24    pub wall_contact_frame: usize,
25    pub takeoff_time: f32,
26    pub takeoff_frame: usize,
27    pub time_since_takeoff: f32,
28    pub wall_contact_position: [f32; 3],
29    pub takeoff_position: [f32; 3],
30    pub player_position: [f32; 3],
31    pub ball_position: [f32; 3],
32    pub ball_speed: Option<f32>,
33    pub goal_alignment: Option<f32>,
34    pub confidence: f32,
35}
36
37#[derive(Debug, Clone, PartialEq)]
38struct RecentWallContact {
39    player: PlayerId,
40    is_team_0: bool,
41    wall_direction: WallAerialWall,
42    time: f32,
43    frame: usize,
44    position: glam::Vec3,
45}
46
47#[derive(Debug, Clone, PartialEq)]
48struct ArmedWallAerialShot {
49    player: PlayerId,
50    is_team_0: bool,
51    wall_direction: WallAerialWall,
52    wall_contact_time: f32,
53    wall_contact_frame: usize,
54    wall_contact_position: glam::Vec3,
55    takeoff_time: f32,
56    takeoff_frame: usize,
57    takeoff_position: glam::Vec3,
58}
59
60/// Detects wall-aerial shots during live play.
61#[derive(Debug, Clone, Default)]
62pub struct WallAerialShotCalculator {
63    events: EventStream<WallAerialShotEvent>,
64    recent_wall_contacts: HashMap<PlayerId, RecentWallContact>,
65    armed_shots: HashMap<PlayerId, ArmedWallAerialShot>,
66}
67
68impl WallAerialShotCalculator {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    pub fn events(&self) -> &[WallAerialShotEvent] {
74        self.events.all()
75    }
76
77    pub fn new_events(&self) -> &[WallAerialShotEvent] {
78        self.events.new_events()
79    }
80
81    fn update_wall_contacts_and_takeoffs(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
82        for player in &players.players {
83            let Some(position) = player.position() else {
84                continue;
85            };
86            if position.z <= WALL_AERIAL_SHOT_GROUND_CONTACT_MAX_PLAYER_Z {
87                self.recent_wall_contacts.remove(&player.player_id);
88                self.armed_shots.remove(&player.player_id);
89                continue;
90            }
91
92            if player
93                .rigid_body
94                .as_ref()
95                .and_then(wall_aerial_surface_contact)
96                .is_some()
97            {
98                let wall_direction = wall_aerial_wall_classification(player.is_team_0, position);
99                self.recent_wall_contacts.insert(
100                    player.player_id.clone(),
101                    RecentWallContact {
102                        player: player.player_id.clone(),
103                        is_team_0: player.is_team_0,
104                        wall_direction,
105                        time: frame.time,
106                        frame: frame.frame_number,
107                        position,
108                    },
109                );
110                continue;
111            }
112
113            if position.z < WALL_AERIAL_MIN_TOUCH_PLAYER_Z {
114                self.armed_shots.remove(&player.player_id);
115                continue;
116            }
117
118            if self.armed_shots.contains_key(&player.player_id) {
119                continue;
120            }
121
122            let Some(contact) = self.recent_wall_contacts.remove(&player.player_id) else {
123                continue;
124            };
125            if frame.time - contact.time > WALL_AERIAL_SHOT_MAX_WALL_CONTACT_TO_TAKEOFF_SECONDS {
126                continue;
127            }
128            self.armed_shots.insert(
129                player.player_id.clone(),
130                ArmedWallAerialShot {
131                    player: contact.player,
132                    is_team_0: contact.is_team_0,
133                    wall_direction: contact.wall_direction,
134                    wall_contact_time: contact.time,
135                    wall_contact_frame: contact.frame,
136                    wall_contact_position: contact.position,
137                    takeoff_time: frame.time,
138                    takeoff_frame: frame.frame_number,
139                    takeoff_position: position,
140                },
141            );
142        }
143    }
144
145    fn prune_armed_shots(&mut self, current_time: f32) {
146        self.armed_shots.retain(|_, armed| {
147            current_time - armed.takeoff_time <= WALL_AERIAL_SHOT_MAX_TAKEOFF_TO_SHOT_SECONDS
148        });
149    }
150
151    fn player_position(players: &PlayerFrameState, player_id: &PlayerId) -> Option<glam::Vec3> {
152        players
153            .players
154            .iter()
155            .find(|player| &player.player_id == player_id)
156            .and_then(PlayerSample::position)
157    }
158
159    fn shot_event(
160        &self,
161        players: &PlayerFrameState,
162        event: &PlayerStatEvent,
163    ) -> Option<WallAerialShotEvent> {
164        if event.kind != PlayerStatEventKind::Shot {
165            return None;
166        }
167        let armed = self.armed_shots.get(&event.player)?;
168        let time_since_takeoff = event.time - armed.takeoff_time;
169        if !(0.0..=WALL_AERIAL_SHOT_MAX_TAKEOFF_TO_SHOT_SECONDS).contains(&time_since_takeoff) {
170            return None;
171        }
172
173        let player_position = event
174            .shot
175            .as_ref()
176            .and_then(|shot| shot.player_position.as_ref().map(vec_to_glam))
177            .or_else(|| Self::player_position(players, &event.player))?;
178        if player_is_on_wall(player_position) || player_position.z < WALL_AERIAL_MIN_TOUCH_PLAYER_Z
179        {
180            return None;
181        }
182
183        let shot = event.shot.as_ref()?;
184        let ball_position = vec_to_glam(&shot.ball_position);
185        if ball_position.z < WALL_AERIAL_MIN_TOUCH_BALL_Z {
186            return None;
187        }
188
189        let ball_speed = shot.ball_speed;
190        let goal_alignment = shot.ball_goal_alignment;
191        let confidence = 0.42
192            + 0.20
193                * (1.0
194                    - wall_aerial_normalize_score(
195                        time_since_takeoff,
196                        0.15,
197                        WALL_AERIAL_SHOT_MAX_TAKEOFF_TO_SHOT_SECONDS,
198                    ))
199            + 0.16
200                * wall_aerial_normalize_score(
201                    player_position.z,
202                    WALL_AERIAL_MIN_TOUCH_PLAYER_Z,
203                    850.0,
204                )
205            + 0.12 * goal_alignment.unwrap_or(0.0).clamp(0.0, 1.0)
206            + 0.10 * wall_aerial_normalize_score(ball_speed.unwrap_or(0.0), 600.0, 1800.0);
207
208        Some(WallAerialShotEvent {
209            time: event.time,
210            frame: event.frame,
211            player: event.player.clone(),
212            is_team_0: event.is_team_0,
213            wall: armed.wall_direction,
214            wall_contact_time: armed.wall_contact_time,
215            wall_contact_frame: armed.wall_contact_frame,
216            takeoff_time: armed.takeoff_time,
217            takeoff_frame: armed.takeoff_frame,
218            time_since_takeoff,
219            wall_contact_position: armed.wall_contact_position.to_array(),
220            takeoff_position: armed.takeoff_position.to_array(),
221            player_position: player_position.to_array(),
222            ball_position: ball_position.to_array(),
223            ball_speed,
224            goal_alignment,
225            confidence: confidence.clamp(0.0, 1.0),
226        })
227    }
228
229    fn record_event(&mut self, _frame: &FrameInfo, event: WallAerialShotEvent) {
230        self.recent_wall_contacts.remove(&event.player);
231        self.armed_shots.remove(&event.player);
232        self.events.push(event);
233    }
234
235    pub fn update(
236        &mut self,
237        frame: &FrameInfo,
238        players: &PlayerFrameState,
239        frame_events: &FrameEventsState,
240        live_play_state: &LivePlayState,
241    ) -> SubtrActorResult<()> {
242        self.events.begin_update();
243        if !live_play_state.is_live_play {
244            self.recent_wall_contacts.clear();
245            self.armed_shots.clear();
246            return Ok(());
247        }
248
249        self.update_wall_contacts_and_takeoffs(frame, players);
250        self.prune_armed_shots(frame.time);
251
252        for stat_event in &frame_events.player_stat_events {
253            if let Some(event) = self.shot_event(players, stat_event) {
254                self.record_event(frame, event);
255            }
256        }
257        Ok(())
258    }
259}
260
261#[cfg(test)]
262#[path = "wall_aerial_shot_tests.rs"]
263mod tests;