Skip to main content

subtr_actor/stats/calculators/
ceiling_shot.rs

1use 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;
14const CEILING_SHOT_HIGH_CONFIDENCE: f32 = 0.78;
15
16#[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::ts_bindings::RemoteIdTs")]
22    pub player: PlayerId,
23    pub is_team_0: bool,
24    pub ceiling_contact_time: f32,
25    pub ceiling_contact_frame: usize,
26    pub time_since_ceiling_contact: f32,
27    pub ceiling_contact_position: [f32; 3],
28    pub touch_position: [f32; 3],
29    pub local_ball_position: [f32; 3],
30    pub separation_from_ceiling: f32,
31    pub roof_alignment: f32,
32    pub forward_alignment: f32,
33    pub forward_approach_speed: f32,
34    pub ball_speed_change: f32,
35    pub confidence: f32,
36}
37
38#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
39#[ts(export)]
40pub struct CeilingShotStats {
41    pub count: u32,
42    pub high_confidence_count: u32,
43    pub is_last_ceiling_shot: bool,
44    pub last_ceiling_shot_time: Option<f32>,
45    pub last_ceiling_shot_frame: Option<usize>,
46    pub time_since_last_ceiling_shot: Option<f32>,
47    pub frames_since_last_ceiling_shot: Option<usize>,
48    pub last_confidence: Option<f32>,
49    pub best_confidence: f32,
50    pub cumulative_confidence: f32,
51}
52
53impl CeilingShotStats {
54    pub fn average_confidence(&self) -> f32 {
55        if self.count == 0 {
56            0.0
57        } else {
58            self.cumulative_confidence / self.count as f32
59        }
60    }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq)]
64struct RecentCeilingContact {
65    time: f32,
66    frame: usize,
67    position: [f32; 3],
68    roof_alignment: f32,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq)]
72struct CeilingContactObservation {
73    position: glam::Vec3,
74    roof_alignment: f32,
75}
76
77#[derive(Debug, Clone, Default, PartialEq)]
78pub struct CeilingShotCalculator {
79    player_stats: HashMap<PlayerId, CeilingShotStats>,
80    events: Vec<CeilingShotEvent>,
81    recent_ceiling_contacts: HashMap<PlayerId, RecentCeilingContact>,
82    previous_ball_velocity: Option<glam::Vec3>,
83    current_last_ceiling_shot_player: Option<PlayerId>,
84}
85
86impl CeilingShotCalculator {
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    pub fn player_stats(&self) -> &HashMap<PlayerId, CeilingShotStats> {
92        &self.player_stats
93    }
94
95    pub fn events(&self) -> &[CeilingShotEvent] {
96        &self.events
97    }
98
99    fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
100        if max_value <= min_value {
101            return 0.0;
102        }
103
104        ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
105    }
106
107    fn ball_speed_change(
108        frame: &FrameInfo,
109        ball: &BallFrameState,
110        previous_ball_velocity: Option<glam::Vec3>,
111    ) -> f32 {
112        const BALL_GRAVITY_Z: f32 = -650.0;
113
114        let Some(ball) = ball.sample() else {
115            return 0.0;
116        };
117        let Some(previous_ball_velocity) = previous_ball_velocity else {
118            return 0.0;
119        };
120
121        let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
122        let residual_linear_impulse =
123            ball.velocity() - previous_ball_velocity - expected_linear_delta;
124        residual_linear_impulse.length()
125    }
126
127    fn begin_sample(&mut self, frame: &FrameInfo) {
128        for stats in self.player_stats.values_mut() {
129            stats.is_last_ceiling_shot = false;
130            stats.time_since_last_ceiling_shot = stats
131                .last_ceiling_shot_time
132                .map(|time| (frame.time - time).max(0.0));
133            stats.frames_since_last_ceiling_shot = stats
134                .last_ceiling_shot_frame
135                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
136        }
137
138        if let Some(player_id) = self.current_last_ceiling_shot_player.as_ref() {
139            if let Some(stats) = self.player_stats.get_mut(player_id) {
140                stats.is_last_ceiling_shot = true;
141            }
142        }
143    }
144
145    fn ceiling_contact_observation(player: &PlayerSample) -> Option<CeilingContactObservation> {
146        let rigid_body = player.rigid_body.as_ref()?;
147        let position = player.position()?;
148        let gap_to_ceiling = SOCCAR_CEILING_Z - position.z;
149        if !(0.0..=CEILING_CONTACT_MAX_GAP).contains(&gap_to_ceiling) {
150            return None;
151        }
152
153        let up = quat_to_glam(&rigid_body.rotation) * glam::Vec3::Z;
154        let roof_alignment = (-up).dot(glam::Vec3::Z);
155        if roof_alignment < CEILING_CONTACT_MIN_ROOF_ALIGNMENT {
156            return None;
157        }
158
159        Some(CeilingContactObservation {
160            position,
161            roof_alignment,
162        })
163    }
164
165    fn update_recent_ceiling_contacts(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
166        for player in &players.players {
167            let observation = Self::ceiling_contact_observation(player);
168            let Some(observation) = observation else {
169                continue;
170            };
171
172            self.recent_ceiling_contacts.insert(
173                player.player_id.clone(),
174                RecentCeilingContact {
175                    time: frame.time,
176                    frame: frame.frame_number,
177                    position: observation.position.to_array(),
178                    roof_alignment: observation.roof_alignment,
179                },
180            );
181        }
182    }
183
184    fn prune_recent_ceiling_contacts(&mut self, current_time: f32) {
185        self.recent_ceiling_contacts.retain(|_, contact| {
186            current_time - contact.time <= CEILING_SHOT_MAX_TOUCH_AFTER_CONTACT_SECONDS
187        });
188    }
189
190    fn candidate_event(
191        &self,
192        ball: &BallFrameState,
193        player: &PlayerSample,
194        touch_event: &TouchEvent,
195        recent_contact: RecentCeilingContact,
196        ball_speed_change: f32,
197    ) -> Option<CeilingShotEvent> {
198        let ball = ball.sample()?;
199        let player_position = player.position()?;
200        let player_rigid_body = player.rigid_body.as_ref()?;
201        let ball_position = ball.position();
202
203        if player_position.z < CEILING_SHOT_MIN_PLAYER_HEIGHT
204            || ball_position.z < CEILING_SHOT_MIN_BALL_HEIGHT
205        {
206            return None;
207        }
208
209        let time_since_ceiling_contact = touch_event.time - recent_contact.time;
210        if !(0.0..=CEILING_SHOT_MAX_TOUCH_AFTER_CONTACT_SECONDS)
211            .contains(&time_since_ceiling_contact)
212        {
213            return None;
214        }
215
216        let separation_from_ceiling = SOCCAR_CEILING_Z - player_position.z;
217        if separation_from_ceiling < CEILING_SHOT_MIN_TOUCH_SEPARATION {
218            return None;
219        }
220
221        let relative_ball_position = ball_position - player_position;
222        if relative_ball_position.length_squared() <= f32::EPSILON {
223            return None;
224        }
225
226        let player_rotation = quat_to_glam(&player_rigid_body.rotation);
227        let local_ball_position = player_rotation.inverse() * relative_ball_position;
228        if local_ball_position.x < -120.0
229            || local_ball_position.y.abs() > 260.0
230            || local_ball_position.z.abs() > 240.0
231        {
232            return None;
233        }
234
235        let to_ball = relative_ball_position.normalize_or_zero();
236        let forward = player_rotation * glam::Vec3::X;
237        let forward_alignment = forward.dot(to_ball);
238        if forward_alignment < CEILING_SHOT_MIN_FORWARD_ALIGNMENT {
239            return None;
240        }
241
242        let forward_approach_speed = player.velocity().unwrap_or(glam::Vec3::ZERO).dot(to_ball);
243        if forward_approach_speed < CEILING_SHOT_MIN_FORWARD_APPROACH_SPEED {
244            return None;
245        }
246        if ball_speed_change < CEILING_SHOT_MIN_BALL_SPEED_CHANGE {
247            return None;
248        }
249
250        let timing_score = 1.0
251            - Self::normalize_score(
252                time_since_ceiling_contact,
253                0.10,
254                CEILING_SHOT_MAX_TOUCH_AFTER_CONTACT_SECONDS,
255            );
256        let separation_score = Self::normalize_score(separation_from_ceiling, 140.0, 520.0);
257        let height_score = Self::normalize_score(
258            player_position.z.max(ball_position.z),
259            CEILING_SHOT_MIN_BALL_HEIGHT,
260            900.0,
261        );
262        let alignment_score =
263            Self::normalize_score(forward_alignment, CEILING_SHOT_MIN_FORWARD_ALIGNMENT, 0.92);
264        let approach_score = Self::normalize_score(
265            forward_approach_speed,
266            CEILING_SHOT_MIN_FORWARD_APPROACH_SPEED,
267            900.0,
268        );
269        let impulse_score =
270            Self::normalize_score(ball_speed_change, CEILING_SHOT_MIN_BALL_SPEED_CHANGE, 900.0);
271        let contact_score = Self::normalize_score(
272            recent_contact.roof_alignment,
273            CEILING_CONTACT_MIN_ROOF_ALIGNMENT,
274            0.98,
275        );
276
277        let confidence = 0.20 * timing_score
278            + 0.15 * separation_score
279            + 0.12 * height_score
280            + 0.17 * alignment_score
281            + 0.16 * approach_score
282            + 0.10 * impulse_score
283            + 0.10 * contact_score;
284        if confidence < CEILING_SHOT_MIN_CONFIDENCE {
285            return None;
286        }
287
288        Some(CeilingShotEvent {
289            time: touch_event.time,
290            frame: touch_event.frame,
291            player: player.player_id.clone(),
292            is_team_0: player.is_team_0,
293            ceiling_contact_time: recent_contact.time,
294            ceiling_contact_frame: recent_contact.frame,
295            time_since_ceiling_contact,
296            ceiling_contact_position: recent_contact.position,
297            touch_position: ball_position.to_array(),
298            local_ball_position: local_ball_position.to_array(),
299            separation_from_ceiling,
300            roof_alignment: recent_contact.roof_alignment,
301            forward_alignment,
302            forward_approach_speed,
303            ball_speed_change,
304            confidence,
305        })
306    }
307
308    fn apply_touch_events(
309        &mut self,
310        frame: &FrameInfo,
311        ball: &BallFrameState,
312        players: &PlayerFrameState,
313        touch_events: &[TouchEvent],
314    ) {
315        let ball_speed_change = Self::ball_speed_change(frame, ball, self.previous_ball_velocity);
316
317        for touch_event in touch_events {
318            let Some(player_id) = touch_event.player.as_ref() else {
319                continue;
320            };
321            let Some(player) = players
322                .players
323                .iter()
324                .find(|player| &player.player_id == player_id)
325            else {
326                continue;
327            };
328            let Some(recent_contact) = self.recent_ceiling_contacts.get(player_id).copied() else {
329                continue;
330            };
331            let Some(event) =
332                self.candidate_event(ball, player, touch_event, recent_contact, ball_speed_change)
333            else {
334                continue;
335            };
336
337            let stats = self.player_stats.entry(player_id.clone()).or_default();
338            stats.count += 1;
339            if event.confidence >= CEILING_SHOT_HIGH_CONFIDENCE {
340                stats.high_confidence_count += 1;
341            }
342            stats.is_last_ceiling_shot = true;
343            stats.last_ceiling_shot_time = Some(event.time);
344            stats.last_ceiling_shot_frame = Some(event.frame);
345            stats.time_since_last_ceiling_shot = Some((frame.time - event.time).max(0.0));
346            stats.frames_since_last_ceiling_shot =
347                Some(frame.frame_number.saturating_sub(event.frame));
348            stats.last_confidence = Some(event.confidence);
349            stats.best_confidence = stats.best_confidence.max(event.confidence);
350            stats.cumulative_confidence += event.confidence;
351
352            self.current_last_ceiling_shot_player = Some(player_id.clone());
353            self.events.push(event);
354        }
355
356        if let Some(player_id) = self.current_last_ceiling_shot_player.as_ref() {
357            if let Some(stats) = self.player_stats.get_mut(player_id) {
358                stats.is_last_ceiling_shot = true;
359            }
360        }
361    }
362
363    fn reset_live_play_state(&mut self, ball: &BallFrameState) {
364        self.current_last_ceiling_shot_player = None;
365        self.recent_ceiling_contacts.clear();
366        self.previous_ball_velocity = ball.velocity();
367    }
368
369    pub fn update_parts(
370        &mut self,
371        frame: &FrameInfo,
372        ball: &BallFrameState,
373        players: &PlayerFrameState,
374        touch_events: &[TouchEvent],
375        live_play: bool,
376    ) -> SubtrActorResult<()> {
377        if !live_play {
378            self.reset_live_play_state(ball);
379            return Ok(());
380        }
381
382        self.begin_sample(frame);
383        self.prune_recent_ceiling_contacts(frame.time);
384        self.apply_touch_events(frame, ball, players, touch_events);
385        self.update_recent_ceiling_contacts(frame, players);
386        self.previous_ball_velocity = ball.velocity();
387        Ok(())
388    }
389}