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(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}