subtr_actor/stats/calculators/
touch_state.rs1use 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;