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