subtr_actor/stats/calculators/
wall_aerial_shot.rs1use 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;
8const 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#[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#[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;