Skip to main content

subtr_actor/stats/calculators/
touch_state.rs

1use super::*;
2
3#[derive(Debug, Clone, Default)]
4pub struct TouchState {
5    pub touch_events: Vec<TouchEvent>,
6    pub last_touch: Option<TouchEvent>,
7    pub last_touch_player: Option<PlayerId>,
8    pub last_touch_team_is_team_0: Option<bool>,
9}
10
11#[derive(Clone, Default)]
12pub struct TouchStateCalculator {
13    previous_ball_linear_velocity: Option<glam::Vec3>,
14    previous_ball_angular_velocity: Option<glam::Vec3>,
15    current_last_touch: Option<TouchEvent>,
16    recent_touch_candidates: HashMap<PlayerId, TouchEvent>,
17}
18
19impl TouchStateCalculator {
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    fn prune_recent_touch_candidates(&mut self, current_frame: usize) {
25        const TOUCH_CANDIDATE_WINDOW_FRAMES: usize = 4;
26
27        self.recent_touch_candidates.retain(|_, candidate| {
28            current_frame.saturating_sub(candidate.frame) <= TOUCH_CANDIDATE_WINDOW_FRAMES
29        });
30    }
31
32    fn current_ball_angular_velocity(ball: &BallFrameState) -> Option<glam::Vec3> {
33        ball.sample()
34            .map(|ball| {
35                ball.rigid_body
36                    .angular_velocity
37                    .unwrap_or(boxcars::Vector3f {
38                        x: 0.0,
39                        y: 0.0,
40                        z: 0.0,
41                    })
42            })
43            .map(|velocity| vec_to_glam(&velocity))
44    }
45
46    fn current_ball_linear_velocity(ball: &BallFrameState) -> Option<glam::Vec3> {
47        ball.velocity()
48    }
49
50    fn is_touch_candidate(&self, frame: &FrameInfo, ball: &BallFrameState) -> bool {
51        const BALL_GRAVITY_Z: f32 = -650.0;
52        const TOUCH_LINEAR_IMPULSE_THRESHOLD: f32 = 120.0;
53        const TOUCH_ANGULAR_VELOCITY_DELTA_THRESHOLD: f32 = 0.5;
54
55        let Some(current_linear_velocity) = Self::current_ball_linear_velocity(ball) else {
56            return false;
57        };
58        let Some(previous_linear_velocity) = self.previous_ball_linear_velocity else {
59            return false;
60        };
61        let Some(current_angular_velocity) = Self::current_ball_angular_velocity(ball) else {
62            return false;
63        };
64        let Some(previous_angular_velocity) = self.previous_ball_angular_velocity else {
65            return false;
66        };
67
68        let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
69        let residual_linear_impulse =
70            current_linear_velocity - previous_linear_velocity - expected_linear_delta;
71        let angular_velocity_delta = current_angular_velocity - previous_angular_velocity;
72
73        residual_linear_impulse.length() > TOUCH_LINEAR_IMPULSE_THRESHOLD
74            || angular_velocity_delta.length() > TOUCH_ANGULAR_VELOCITY_DELTA_THRESHOLD
75    }
76
77    fn proximity_touch_candidates(
78        &self,
79        frame: &FrameInfo,
80        ball: &BallFrameState,
81        players: &PlayerFrameState,
82        max_collision_distance: f32,
83    ) -> Vec<TouchEvent> {
84        const OCTANE_HITBOX_LENGTH: f32 = 118.01;
85        const OCTANE_HITBOX_WIDTH: f32 = 84.2;
86        const OCTANE_HITBOX_HEIGHT: f32 = 36.16;
87        const OCTANE_HITBOX_OFFSET: f32 = 13.88;
88        const OCTANE_HITBOX_ELEVATION: f32 = 17.05;
89
90        let Some(ball) = ball.sample() else {
91            return Vec::new();
92        };
93        let ball_position = vec_to_glam(&ball.rigid_body.location);
94
95        let mut candidates = players
96            .players
97            .iter()
98            .filter_map(|player| {
99                let rigid_body = player.rigid_body.as_ref()?;
100                let player_position = vec_to_glam(&rigid_body.location);
101                let local_ball_position = quat_to_glam(&rigid_body.rotation).inverse()
102                    * (ball_position - player_position);
103
104                let x_distance = if local_ball_position.x
105                    < -OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET
106                {
107                    (-OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET) - local_ball_position.x
108                } else if local_ball_position.x > OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET
109                {
110                    local_ball_position.x - (OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET)
111                } else {
112                    0.0
113                };
114                let y_distance = if local_ball_position.y < -OCTANE_HITBOX_WIDTH / 2.0 {
115                    (-OCTANE_HITBOX_WIDTH / 2.0) - local_ball_position.y
116                } else if local_ball_position.y > OCTANE_HITBOX_WIDTH / 2.0 {
117                    local_ball_position.y - OCTANE_HITBOX_WIDTH / 2.0
118                } else {
119                    0.0
120                };
121                let z_distance = if local_ball_position.z
122                    < -OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION
123                {
124                    (-OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION) - local_ball_position.z
125                } else if local_ball_position.z
126                    > OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION
127                {
128                    local_ball_position.z - (OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION)
129                } else {
130                    0.0
131                };
132
133                let collision_distance =
134                    glam::Vec3::new(x_distance, y_distance, z_distance).length();
135                if collision_distance > max_collision_distance {
136                    return None;
137                }
138
139                Some(TouchEvent {
140                    time: frame.time,
141                    frame: frame.frame_number,
142                    team_is_team_0: player.is_team_0,
143                    player: Some(player.player_id.clone()),
144                    closest_approach_distance: Some(collision_distance),
145                    dodge_contact: player.dodge_active,
146                })
147            })
148            .collect::<Vec<_>>();
149
150        candidates.sort_by(|left, right| {
151            let left_distance = left.closest_approach_distance.unwrap_or(f32::INFINITY);
152            let right_distance = right.closest_approach_distance.unwrap_or(f32::INFINITY);
153            left_distance.total_cmp(&right_distance)
154        });
155        candidates
156    }
157
158    fn candidate_touch_event(
159        &self,
160        frame: &FrameInfo,
161        ball: &BallFrameState,
162        players: &PlayerFrameState,
163    ) -> Option<TouchEvent> {
164        const TOUCH_COLLISION_DISTANCE_THRESHOLD: f32 = 300.0;
165
166        self.proximity_touch_candidates(frame, ball, players, TOUCH_COLLISION_DISTANCE_THRESHOLD)
167            .into_iter()
168            .next()
169    }
170
171    fn update_recent_touch_candidates(
172        &mut self,
173        frame: &FrameInfo,
174        ball: &BallFrameState,
175        players: &PlayerFrameState,
176    ) {
177        const PROXIMITY_CANDIDATE_DISTANCE_THRESHOLD: f32 = 220.0;
178
179        for candidate in self.proximity_touch_candidates(
180            frame,
181            ball,
182            players,
183            PROXIMITY_CANDIDATE_DISTANCE_THRESHOLD,
184        ) {
185            let Some(player_id) = candidate.player.clone() else {
186                continue;
187            };
188
189            self.recent_touch_candidates.insert(player_id, candidate);
190        }
191    }
192
193    fn candidate_for_player(&self, player_id: &PlayerId) -> Option<TouchEvent> {
194        self.recent_touch_candidates.get(player_id).cloned()
195    }
196
197    fn best_candidate_for_team(&self, team_is_team_0: bool) -> Option<TouchEvent> {
198        self.recent_touch_candidates
199            .values()
200            .filter(|candidate| candidate.team_is_team_0 == team_is_team_0)
201            .min_by(|left, right| {
202                let left_distance = left.closest_approach_distance.unwrap_or(f32::INFINITY);
203                let right_distance = right.closest_approach_distance.unwrap_or(f32::INFINITY);
204                left_distance.total_cmp(&right_distance)
205            })
206            .cloned()
207    }
208
209    fn enrich_explicit_touch_event(&self, event: &TouchEvent) -> TouchEvent {
210        let candidate = if let Some(player_id) = event.player.as_ref() {
211            self.candidate_for_player(player_id)
212        } else {
213            self.best_candidate_for_team(event.team_is_team_0)
214        };
215        let Some(candidate) = candidate else {
216            return event.clone();
217        };
218
219        TouchEvent {
220            player: event.player.clone().or(candidate.player),
221            closest_approach_distance: event
222                .closest_approach_distance
223                .or(candidate.closest_approach_distance),
224            dodge_contact: event.dodge_contact || candidate.dodge_contact,
225            ..event.clone()
226        }
227    }
228
229    fn contested_touch_candidates(&self, primary: &TouchEvent) -> Vec<TouchEvent> {
230        const CONTESTED_TOUCH_DISTANCE_MARGIN: f32 = 80.0;
231
232        let primary_distance = primary.closest_approach_distance.unwrap_or(f32::INFINITY);
233
234        let best_opposing_candidate = self
235            .recent_touch_candidates
236            .values()
237            .filter(|candidate| candidate.team_is_team_0 != primary.team_is_team_0)
238            .filter(|candidate| {
239                candidate.closest_approach_distance.unwrap_or(f32::INFINITY)
240                    <= primary_distance + CONTESTED_TOUCH_DISTANCE_MARGIN
241            })
242            .min_by(|left, right| {
243                let left_distance = left.closest_approach_distance.unwrap_or(f32::INFINITY);
244                let right_distance = right.closest_approach_distance.unwrap_or(f32::INFINITY);
245                left_distance.total_cmp(&right_distance)
246            })
247            .cloned();
248
249        best_opposing_candidate.into_iter().collect()
250    }
251
252    fn confirmed_touch_events(
253        &self,
254        frame: &FrameInfo,
255        ball: &BallFrameState,
256        players: &PlayerFrameState,
257        events: &FrameEventsState,
258    ) -> Vec<TouchEvent> {
259        let mut touch_events = Vec::new();
260        let mut confirmed_players = HashSet::new();
261
262        for event in &events.touch_events {
263            let event = self.enrich_explicit_touch_event(event);
264            if let Some(player_id) = event.player.clone() {
265                confirmed_players.insert(player_id);
266            }
267            touch_events.push(event);
268        }
269
270        if touch_events.is_empty() && self.is_touch_candidate(frame, ball) {
271            if let Some(candidate) = self.candidate_touch_event(frame, ball, players) {
272                for contested_candidate in self.contested_touch_candidates(&candidate) {
273                    if let Some(player_id) = contested_candidate.player.clone() {
274                        confirmed_players.insert(player_id);
275                    }
276                    touch_events.push(contested_candidate);
277                }
278                if let Some(player_id) = candidate.player.clone() {
279                    confirmed_players.insert(player_id);
280                }
281                touch_events.push(candidate);
282            }
283        }
284
285        for dodge_refresh in &events.dodge_refreshed_events {
286            if !confirmed_players.insert(dodge_refresh.player.clone()) {
287                continue;
288            }
289            let Some(candidate) = self.candidate_for_player(&dodge_refresh.player) else {
290                continue;
291            };
292            touch_events.push(candidate);
293        }
294
295        touch_events
296    }
297
298    pub fn update(
299        &mut self,
300        frame: &FrameInfo,
301        ball: &BallFrameState,
302        players: &PlayerFrameState,
303        events: &FrameEventsState,
304        live_play_state: &LivePlayState,
305    ) -> TouchState {
306        let touch_events = if live_play_state.is_live_play {
307            self.prune_recent_touch_candidates(frame.frame_number);
308            self.update_recent_touch_candidates(frame, ball, players);
309            self.confirmed_touch_events(frame, ball, players, events)
310        } else {
311            self.current_last_touch = None;
312            self.recent_touch_candidates.clear();
313            Vec::new()
314        };
315
316        if let Some(last_touch) = touch_events.last() {
317            self.current_last_touch = Some(last_touch.clone());
318        }
319        self.previous_ball_linear_velocity = Self::current_ball_linear_velocity(ball);
320        self.previous_ball_angular_velocity = Self::current_ball_angular_velocity(ball);
321
322        TouchState {
323            touch_events,
324            last_touch: self.current_last_touch.clone(),
325            last_touch_player: self
326                .current_last_touch
327                .as_ref()
328                .and_then(|touch| touch.player.clone()),
329            last_touch_team_is_team_0: self
330                .current_last_touch
331                .as_ref()
332                .map(|touch| touch.team_is_team_0),
333        }
334    }
335}
336
337#[cfg(test)]
338#[path = "touch_state_tests.rs"]
339mod tests;