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;
14
15/// A shot taken shortly after a player contacts the ceiling and drops back to the ball.
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::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/// Detects ceiling shots from ball/player positions and touches.
55#[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}