subtr_actor/stats/calculators/
ceiling_shot.rs1use super::*;
2
3const SOCCAR_CEILING_Z: f32 = 2044.0;
4const CEILING_CONTACT_MAX_GAP: f32 = 90.0;
5const CEILING_CONTACT_MIN_ROOF_ALIGNMENT: f32 = 0.72;
6const CEILING_SHOT_MAX_TOUCH_AFTER_CONTACT_SECONDS: f32 = 1.35;
7const CEILING_SHOT_MIN_TOUCH_SEPARATION: f32 = 120.0;
8const CEILING_SHOT_MIN_PLAYER_HEIGHT: f32 = 260.0;
9const CEILING_SHOT_MIN_BALL_HEIGHT: f32 = 220.0;
10const CEILING_SHOT_MIN_FORWARD_ALIGNMENT: f32 = 0.12;
11const CEILING_SHOT_MIN_FORWARD_APPROACH_SPEED: f32 = 90.0;
12const CEILING_SHOT_MIN_BALL_SPEED_CHANGE: f32 = 120.0;
13const CEILING_SHOT_MIN_CONFIDENCE: f32 = 0.54;
14
15#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
17#[ts(export)]
18pub struct CeilingShotEvent {
19 pub time: f32,
20 pub frame: usize,
21 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
22 pub player: PlayerId,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub player_position: Option<[f32; 3]>,
25 pub is_team_0: bool,
26 pub ceiling_contact_time: f32,
27 pub ceiling_contact_frame: usize,
28 pub time_since_ceiling_contact: f32,
29 pub ceiling_contact_position: [f32; 3],
30 pub touch_position: [f32; 3],
31 pub local_ball_position: [f32; 3],
32 pub separation_from_ceiling: f32,
33 pub roof_alignment: f32,
34 pub forward_alignment: f32,
35 pub forward_approach_speed: f32,
36 pub ball_speed_change: f32,
37 pub confidence: f32,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq)]
41struct RecentCeilingContact {
42 time: f32,
43 frame: usize,
44 position: [f32; 3],
45 roof_alignment: f32,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq)]
49struct CeilingContactObservation {
50 position: glam::Vec3,
51 roof_alignment: f32,
52}
53
54#[derive(Debug, Clone, Default, PartialEq)]
56pub struct CeilingShotCalculator {
57 events: EventStream<CeilingShotEvent>,
58 recent_ceiling_contacts: HashMap<PlayerId, RecentCeilingContact>,
59 previous_ball_velocity: Option<glam::Vec3>,
60}
61
62impl CeilingShotCalculator {
63 pub fn new() -> Self {
64 Self::default()
65 }
66
67 pub fn events(&self) -> &[CeilingShotEvent] {
68 self.events.all()
69 }
70
71 pub fn new_events(&self) -> &[CeilingShotEvent] {
72 self.events.new_events()
73 }
74
75 fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
76 if max_value <= min_value {
77 return 0.0;
78 }
79
80 ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
81 }
82
83 fn ball_speed_change(
84 frame: &FrameInfo,
85 ball: &BallFrameState,
86 previous_ball_velocity: Option<glam::Vec3>,
87 ) -> f32 {
88 const BALL_GRAVITY_Z: f32 = -650.0;
89
90 let Some(ball) = ball.sample() else {
91 return 0.0;
92 };
93 let Some(previous_ball_velocity) = previous_ball_velocity else {
94 return 0.0;
95 };
96
97 let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
98 let residual_linear_impulse =
99 ball.velocity() - previous_ball_velocity - expected_linear_delta;
100 residual_linear_impulse.length()
101 }
102
103 fn ceiling_contact_observation(player: &PlayerSample) -> Option<CeilingContactObservation> {
104 let rigid_body = player.rigid_body.as_ref()?;
105 let position = player.position()?;
106 let gap_to_ceiling = SOCCAR_CEILING_Z - position.z;
107 if !(0.0..=CEILING_CONTACT_MAX_GAP).contains(&gap_to_ceiling) {
108 return None;
109 }
110
111 let up = quat_to_glam(&rigid_body.rotation) * glam::Vec3::Z;
112 let roof_alignment = (-up).dot(glam::Vec3::Z);
113 if roof_alignment < CEILING_CONTACT_MIN_ROOF_ALIGNMENT {
114 return None;
115 }
116
117 Some(CeilingContactObservation {
118 position,
119 roof_alignment,
120 })
121 }
122
123 fn update_recent_ceiling_contacts(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
124 for player in &players.players {
125 let observation = Self::ceiling_contact_observation(player);
126 let Some(observation) = observation else {
127 continue;
128 };
129
130 self.recent_ceiling_contacts.insert(
131 player.player_id.clone(),
132 RecentCeilingContact {
133 time: frame.time,
134 frame: frame.frame_number,
135 position: observation.position.to_array(),
136 roof_alignment: observation.roof_alignment,
137 },
138 );
139 }
140 }
141
142 fn prune_recent_ceiling_contacts(&mut self, current_time: f32) {
143 self.recent_ceiling_contacts.retain(|_, contact| {
144 current_time - contact.time <= CEILING_SHOT_MAX_TOUCH_AFTER_CONTACT_SECONDS
145 });
146 }
147
148 fn candidate_event(
149 &self,
150 ball: &BallFrameState,
151 player: &PlayerSample,
152 touch_event: &TouchEvent,
153 recent_contact: RecentCeilingContact,
154 ball_speed_change: f32,
155 ) -> Option<CeilingShotEvent> {
156 let ball = ball.sample()?;
157 let player_position = player.position()?;
158 let player_rigid_body = player.rigid_body.as_ref()?;
159 let ball_position = ball.position();
160
161 if player_position.z < CEILING_SHOT_MIN_PLAYER_HEIGHT
162 || ball_position.z < CEILING_SHOT_MIN_BALL_HEIGHT
163 {
164 return None;
165 }
166
167 let time_since_ceiling_contact = touch_event.time - recent_contact.time;
168 if !(0.0..=CEILING_SHOT_MAX_TOUCH_AFTER_CONTACT_SECONDS)
169 .contains(&time_since_ceiling_contact)
170 {
171 return None;
172 }
173
174 let separation_from_ceiling = SOCCAR_CEILING_Z - player_position.z;
175 if separation_from_ceiling < CEILING_SHOT_MIN_TOUCH_SEPARATION {
176 return None;
177 }
178
179 let relative_ball_position = ball_position - player_position;
180 if relative_ball_position.length_squared() <= f32::EPSILON {
181 return None;
182 }
183
184 let player_rotation = quat_to_glam(&player_rigid_body.rotation);
185 let local_ball_position = player_rotation.inverse() * relative_ball_position;
186 if local_ball_position.x < -120.0
187 || local_ball_position.y.abs() > 260.0
188 || local_ball_position.z.abs() > 240.0
189 {
190 return None;
191 }
192
193 let to_ball = relative_ball_position.normalize_or_zero();
194 let forward = player_rotation * glam::Vec3::X;
195 let forward_alignment = forward.dot(to_ball);
196 if forward_alignment < CEILING_SHOT_MIN_FORWARD_ALIGNMENT {
197 return None;
198 }
199
200 let forward_approach_speed = player.velocity().unwrap_or(glam::Vec3::ZERO).dot(to_ball);
201 if forward_approach_speed < CEILING_SHOT_MIN_FORWARD_APPROACH_SPEED {
202 return None;
203 }
204 if ball_speed_change < CEILING_SHOT_MIN_BALL_SPEED_CHANGE {
205 return None;
206 }
207
208 let timing_score = 1.0
209 - Self::normalize_score(
210 time_since_ceiling_contact,
211 0.10,
212 CEILING_SHOT_MAX_TOUCH_AFTER_CONTACT_SECONDS,
213 );
214 let separation_score = Self::normalize_score(separation_from_ceiling, 140.0, 520.0);
215 let height_score = Self::normalize_score(
216 player_position.z.max(ball_position.z),
217 CEILING_SHOT_MIN_BALL_HEIGHT,
218 900.0,
219 );
220 let alignment_score =
221 Self::normalize_score(forward_alignment, CEILING_SHOT_MIN_FORWARD_ALIGNMENT, 0.92);
222 let approach_score = Self::normalize_score(
223 forward_approach_speed,
224 CEILING_SHOT_MIN_FORWARD_APPROACH_SPEED,
225 900.0,
226 );
227 let impulse_score =
228 Self::normalize_score(ball_speed_change, CEILING_SHOT_MIN_BALL_SPEED_CHANGE, 900.0);
229 let contact_score = Self::normalize_score(
230 recent_contact.roof_alignment,
231 CEILING_CONTACT_MIN_ROOF_ALIGNMENT,
232 0.98,
233 );
234
235 let confidence = 0.20 * timing_score
236 + 0.15 * separation_score
237 + 0.12 * height_score
238 + 0.17 * alignment_score
239 + 0.16 * approach_score
240 + 0.10 * impulse_score
241 + 0.10 * contact_score;
242 if confidence < CEILING_SHOT_MIN_CONFIDENCE {
243 return None;
244 }
245
246 Some(CeilingShotEvent {
247 time: touch_event.time,
248 frame: touch_event.frame,
249 player: player.player_id.clone(),
250 player_position: Some(player_position.to_array()),
251 is_team_0: player.is_team_0,
252 ceiling_contact_time: recent_contact.time,
253 ceiling_contact_frame: recent_contact.frame,
254 time_since_ceiling_contact,
255 ceiling_contact_position: recent_contact.position,
256 touch_position: ball_position.to_array(),
257 local_ball_position: local_ball_position.to_array(),
258 separation_from_ceiling,
259 roof_alignment: recent_contact.roof_alignment,
260 forward_alignment,
261 forward_approach_speed,
262 ball_speed_change,
263 confidence,
264 })
265 }
266
267 fn apply_touch_events(
268 &mut self,
269 frame: &FrameInfo,
270 ball: &BallFrameState,
271 players: &PlayerFrameState,
272 touch_events: &[TouchEvent],
273 ) {
274 let ball_speed_change = Self::ball_speed_change(frame, ball, self.previous_ball_velocity);
275
276 for touch_event in touch_events {
277 let Some(player_id) = touch_event.player.as_ref() else {
278 continue;
279 };
280 let Some(player) = players
281 .players
282 .iter()
283 .find(|player| &player.player_id == player_id)
284 else {
285 continue;
286 };
287 let Some(recent_contact) = self.recent_ceiling_contacts.get(player_id).copied() else {
288 continue;
289 };
290 let Some(event) =
291 self.candidate_event(ball, player, touch_event, recent_contact, ball_speed_change)
292 else {
293 continue;
294 };
295 self.events.push(event);
296 }
297 }
298
299 fn reset_live_play_state(&mut self, ball: &BallFrameState) {
300 self.recent_ceiling_contacts.clear();
301 self.previous_ball_velocity = ball.velocity();
302 }
303
304 pub fn update_parts(
305 &mut self,
306 frame: &FrameInfo,
307 ball: &BallFrameState,
308 players: &PlayerFrameState,
309 touch_events: &[TouchEvent],
310 live_play_state: &LivePlayState,
311 ) -> SubtrActorResult<()> {
312 self.events.begin_update();
313 if !live_play_state.is_live_play {
314 self.reset_live_play_state(ball);
315 return Ok(());
316 }
317 self.prune_recent_ceiling_contacts(frame.time);
318 self.apply_touch_events(frame, ball, players, touch_events);
319 self.update_recent_ceiling_contacts(frame, players);
320 self.previous_ball_velocity = ball.velocity();
321 Ok(())
322 }
323}