1use super::*;
2
3const BUMP_MAX_SAMPLE_DT: f32 = 0.18;
4const BUMP_MAX_CONTACT_GAP: f32 = 35.0;
5const BUMP_CONTACT_GAP_SAMPLES: usize = 8;
6const BUMP_MIN_CLOSING_SPEED: f32 = 420.0;
7const BUMP_MIN_VICTIM_IMPULSE: f32 = 180.0;
8const BUMP_MIN_DIRECTIONAL_SCORE: f32 = 650.0;
9const BUMP_MIN_SCORE_MARGIN: f32 = 175.0;
10const BUMP_REPEAT_FRAME_WINDOW: usize = 10;
11const BUMP_FIFTY_FIFTY_SUPPRESSION_WINDOW_SECONDS: f32 = 0.35;
12
13#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
15#[ts(export)]
16pub struct BumpEvent {
17 pub time: f32,
18 pub frame: usize,
19 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
20 pub initiator: PlayerId,
21 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
22 pub victim: PlayerId,
23 pub initiator_is_team_0: bool,
24 pub victim_is_team_0: bool,
25 pub is_team_bump: bool,
26 pub strength: f32,
27 pub confidence: f32,
28 pub contact_distance: f32,
29 pub closing_speed: f32,
30 pub victim_impulse: f32,
31 pub initiator_position: [f32; 3],
32 pub victim_position: [f32; 3],
33}
34
35#[derive(Debug, Clone)]
36struct PreviousPlayerSample {
37 rigid_body: boxcars::RigidBody,
38}
39
40#[derive(Debug, Clone, Copy)]
41struct DirectionalBumpCandidate {
42 score: f32,
43 closing_speed: f32,
44 victim_impulse: f32,
45}
46
47#[derive(Debug, Clone, Default)]
49pub struct BumpCalculator {
50 events: EventStream<BumpEvent>,
51 previous_players: HashMap<PlayerId, PreviousPlayerSample>,
52 last_seen_pair_frame: HashMap<(PlayerId, PlayerId), usize>,
53}
54
55impl BumpCalculator {
56 pub fn new() -> Self {
57 Self::default()
58 }
59
60 pub fn events(&self) -> &[BumpEvent] {
61 self.events.all()
62 }
63
64 pub fn new_events(&self) -> &[BumpEvent] {
65 self.events.new_events()
66 }
67
68 pub fn update(
69 &mut self,
70 frame: &FrameInfo,
71 players: &PlayerFrameState,
72 events: &FrameEventsState,
73 live_play_state: &LivePlayState,
74 ) -> SubtrActorResult<()> {
75 self.update_with_fifty_fifty_state(
76 frame,
77 players,
78 events,
79 &FiftyFiftyState::default(),
80 live_play_state,
81 )
82 }
83
84 pub fn update_with_fifty_fifty_state(
85 &mut self,
86 frame: &FrameInfo,
87 players: &PlayerFrameState,
88 events: &FrameEventsState,
89 fifty_fifty_state: &FiftyFiftyState,
90 live_play_state: &LivePlayState,
91 ) -> SubtrActorResult<()> {
92 self.events.begin_update();
93
94 if !live_play_state.is_live_play {
95 self.previous_players.clear();
96 return Ok(());
97 }
98
99 if frame.dt > 0.0 && frame.dt <= BUMP_MAX_SAMPLE_DT {
100 self.detect_bumps(frame, players, events, fifty_fifty_state);
101 }
102
103 self.previous_players = players
104 .players
105 .iter()
106 .filter_map(|player| {
107 Some((
108 player.player_id.clone(),
109 PreviousPlayerSample {
110 rigid_body: player.rigid_body?,
111 },
112 ))
113 })
114 .collect();
115
116 Ok(())
117 }
118
119 fn detect_bumps(
120 &mut self,
121 frame: &FrameInfo,
122 players: &PlayerFrameState,
123 frame_events: &FrameEventsState,
124 fifty_fifty_state: &FiftyFiftyState,
125 ) {
126 let current_players: Vec<_> = players
127 .players
128 .iter()
129 .filter_map(|player| {
130 Some((
131 player,
132 player.rigid_body.as_ref()?,
133 self.previous_players.get(&player.player_id)?.rigid_body,
134 ))
135 })
136 .collect();
137
138 for left_index in 0..current_players.len() {
139 for right_index in (left_index + 1)..current_players.len() {
140 let (left, left_body, previous_left_body) = current_players[left_index];
141 let (right, right_body, previous_right_body) = current_players[right_index];
142
143 if self.is_recent_demo_pair(frame_events, &left.player_id, &right.player_id) {
144 continue;
145 }
146
147 if Self::is_recent_fifty_fifty_pair(
148 frame,
149 fifty_fifty_state,
150 &left.player_id,
151 &right.player_id,
152 ) {
153 continue;
154 }
155
156 let Some(event) = Self::evaluate_pair(
157 frame,
158 left,
159 left_body,
160 &previous_left_body,
161 right,
162 right_body,
163 &previous_right_body,
164 ) else {
165 continue;
166 };
167
168 if self.should_count_bump(&event.initiator, &event.victim, frame.frame_number) {
169 self.record_bump(event);
170 }
171 }
172 }
173 }
174
175 fn evaluate_pair(
176 frame: &FrameInfo,
177 left: &PlayerSample,
178 left_body: &boxcars::RigidBody,
179 previous_left_body: &boxcars::RigidBody,
180 right: &PlayerSample,
181 right_body: &boxcars::RigidBody,
182 previous_right_body: &boxcars::RigidBody,
183 ) -> Option<BumpEvent> {
184 let left_previous_position = vec_to_glam(&previous_left_body.location);
185 let right_previous_position = vec_to_glam(&previous_right_body.location);
186 let left_position = vec_to_glam(&left_body.location);
187 let right_position = vec_to_glam(&right_body.location);
188
189 let contact_distance = swept_car_hitbox_contact_gap(
190 previous_left_body,
191 left_body,
192 left.hitbox,
193 previous_right_body,
194 right_body,
195 right.hitbox,
196 )?;
197 if contact_distance > BUMP_MAX_CONTACT_GAP {
198 return None;
199 }
200
201 let normal_left_to_right = contact_normal(
202 left_previous_position,
203 left_position,
204 right_previous_position,
205 right_position,
206 )?;
207 let left_to_right = directional_candidate(
208 previous_left_body,
209 left_body,
210 previous_right_body,
211 right_body,
212 normal_left_to_right,
213 )?;
214 let right_to_left = directional_candidate(
215 previous_right_body,
216 right_body,
217 previous_left_body,
218 left_body,
219 -normal_left_to_right,
220 )?;
221
222 let (initiator, victim, initiator_body, victim_body, candidate, reverse_score) =
223 if left_to_right.score >= right_to_left.score {
224 (
225 left,
226 right,
227 left_body,
228 right_body,
229 left_to_right,
230 right_to_left.score,
231 )
232 } else {
233 (
234 right,
235 left,
236 right_body,
237 left_body,
238 right_to_left,
239 left_to_right.score,
240 )
241 };
242
243 if candidate.score < BUMP_MIN_DIRECTIONAL_SCORE
244 || candidate.score - reverse_score < BUMP_MIN_SCORE_MARGIN
245 || candidate.closing_speed < BUMP_MIN_CLOSING_SPEED
246 || candidate.victim_impulse < BUMP_MIN_VICTIM_IMPULSE
247 {
248 return None;
249 }
250
251 let distance_factor = (1.0 - (contact_distance / BUMP_MAX_CONTACT_GAP)).clamp(0.0, 1.0);
252 let score_factor = ((candidate.score - BUMP_MIN_DIRECTIONAL_SCORE) / 900.0).clamp(0.0, 1.0);
253 let margin_factor =
254 ((candidate.score - reverse_score - BUMP_MIN_SCORE_MARGIN) / 500.0).clamp(0.0, 1.0);
255 let confidence = (0.35 + 0.3 * distance_factor + 0.25 * score_factor + 0.1 * margin_factor)
256 .clamp(0.0, 1.0);
257
258 Some(BumpEvent {
259 time: frame.time,
260 frame: frame.frame_number,
261 initiator: initiator.player_id.clone(),
262 victim: victim.player_id.clone(),
263 initiator_is_team_0: initiator.is_team_0,
264 victim_is_team_0: victim.is_team_0,
265 is_team_bump: initiator.is_team_0 == victim.is_team_0,
266 strength: candidate.score,
267 confidence,
268 contact_distance,
269 closing_speed: candidate.closing_speed,
270 victim_impulse: candidate.victim_impulse,
271 initiator_position: vec3_to_array(vec_to_glam(&initiator_body.location)),
272 victim_position: vec3_to_array(vec_to_glam(&victim_body.location)),
273 })
274 }
275
276 fn is_recent_demo_pair(
277 &self,
278 frame_events: &FrameEventsState,
279 left: &PlayerId,
280 right: &PlayerId,
281 ) -> bool {
282 frame_events.demo_events.iter().any(|demo| {
283 (&demo.attacker == left && &demo.victim == right)
284 || (&demo.attacker == right && &demo.victim == left)
285 }) || frame_events.active_demos.iter().any(|demo| {
286 (&demo.attacker == left && &demo.victim == right)
287 || (&demo.attacker == right && &demo.victim == left)
288 })
289 }
290
291 fn is_recent_fifty_fifty_pair(
292 frame: &FrameInfo,
293 fifty_fifty_state: &FiftyFiftyState,
294 left: &PlayerId,
295 right: &PlayerId,
296 ) -> bool {
297 if fifty_fifty_state
298 .active_event
299 .as_ref()
300 .is_some_and(|event| Self::active_fifty_fifty_matches_pair(event, left, right))
301 {
302 return true;
303 }
304
305 fifty_fifty_state
306 .resolved_events
307 .iter()
308 .any(|event| Self::resolved_fifty_fifty_matches_pair(event, left, right))
309 || fifty_fifty_state
310 .last_resolved_event
311 .as_ref()
312 .is_some_and(|event| {
313 frame.time - event.resolve_time <= BUMP_FIFTY_FIFTY_SUPPRESSION_WINDOW_SECONDS
314 && Self::resolved_fifty_fifty_matches_pair(event, left, right)
315 })
316 }
317
318 fn active_fifty_fifty_matches_pair(
319 event: &ActiveFiftyFifty,
320 left: &PlayerId,
321 right: &PlayerId,
322 ) -> bool {
323 Self::optional_player_pair_matches(
324 event.team_zero_player.as_ref(),
325 event.team_one_player.as_ref(),
326 left,
327 right,
328 )
329 }
330
331 fn resolved_fifty_fifty_matches_pair(
332 event: &FiftyFiftyEvent,
333 left: &PlayerId,
334 right: &PlayerId,
335 ) -> bool {
336 Self::optional_player_pair_matches(
337 event.team_zero_player.as_ref(),
338 event.team_one_player.as_ref(),
339 left,
340 right,
341 )
342 }
343
344 fn optional_player_pair_matches(
345 team_zero_player: Option<&PlayerId>,
346 team_one_player: Option<&PlayerId>,
347 left: &PlayerId,
348 right: &PlayerId,
349 ) -> bool {
350 matches!(
351 (team_zero_player, team_one_player),
352 (Some(team_zero_player), Some(team_one_player))
353 if (team_zero_player == left && team_one_player == right)
354 || (team_zero_player == right && team_one_player == left)
355 )
356 }
357
358 fn should_count_bump(
359 &mut self,
360 initiator: &PlayerId,
361 victim: &PlayerId,
362 frame_number: usize,
363 ) -> bool {
364 let key = (initiator.clone(), victim.clone());
365 let already_counted = self
366 .last_seen_pair_frame
367 .get(&key)
368 .map(|previous_frame| {
369 frame_number.saturating_sub(*previous_frame) <= BUMP_REPEAT_FRAME_WINDOW
370 })
371 .unwrap_or(false);
372 self.last_seen_pair_frame.insert(key, frame_number);
373 !already_counted
374 }
375
376 fn record_bump(&mut self, event: BumpEvent) {
377 self.events.push(event);
378 }
379}
380
381fn vec3_to_array(v: glam::Vec3) -> [f32; 3] {
382 [v.x, v.y, v.z]
383}
384
385fn swept_car_hitbox_contact_gap(
386 left_previous: &boxcars::RigidBody,
387 left_current: &boxcars::RigidBody,
388 left_hitbox: CarHitbox,
389 right_previous: &boxcars::RigidBody,
390 right_current: &boxcars::RigidBody,
391 right_hitbox: CarHitbox,
392) -> Option<f32> {
393 let mut closest_gap =
394 car_hitbox_pair_contact_gap(left_current, left_hitbox, right_current, right_hitbox)?;
395
396 for sample_index in 0..=BUMP_CONTACT_GAP_SAMPLES {
397 let sample_fraction = sample_index as f32 / BUMP_CONTACT_GAP_SAMPLES as f32;
398 let left_sample = interpolate_rigid_body(left_previous, left_current, sample_fraction);
399 let right_sample = interpolate_rigid_body(right_previous, right_current, sample_fraction);
400 let sample_gap =
401 car_hitbox_pair_contact_gap(&left_sample, left_hitbox, &right_sample, right_hitbox)?;
402 closest_gap = closest_gap.min(sample_gap);
403 }
404
405 Some(closest_gap)
406}
407
408fn interpolate_rigid_body(
409 previous: &boxcars::RigidBody,
410 current: &boxcars::RigidBody,
411 fraction: f32,
412) -> boxcars::RigidBody {
413 let fraction = fraction.clamp(0.0, 1.0);
414 let previous_position = vec_to_glam(&previous.location);
415 let current_position = vec_to_glam(¤t.location);
416 let previous_rotation = quat_to_glam(&previous.rotation);
417 let current_rotation = quat_to_glam(¤t.rotation);
418
419 let mut interpolated = *current;
420 interpolated.location = glam_to_vec(&previous_position.lerp(current_position, fraction));
421 interpolated.rotation = glam_to_quat(&previous_rotation.slerp(current_rotation, fraction));
422 interpolated
423}
424
425fn contact_normal(
426 left_previous: glam::Vec3,
427 left_current: glam::Vec3,
428 right_previous: glam::Vec3,
429 right_current: glam::Vec3,
430) -> Option<glam::Vec3> {
431 let relative_current = right_current - left_current;
432 let current_horizontal = glam::Vec3::new(relative_current.x, relative_current.y, 0.0);
433 if current_horizontal.length_squared() > 1.0 {
434 return Some(current_horizontal.normalize());
435 }
436
437 let relative_previous = right_previous - left_previous;
438 let previous_horizontal = glam::Vec3::new(relative_previous.x, relative_previous.y, 0.0);
439 (previous_horizontal.length_squared() > 1.0).then(|| previous_horizontal.normalize())
440}
441
442fn directional_candidate(
443 initiator_previous: &boxcars::RigidBody,
444 initiator_current: &boxcars::RigidBody,
445 victim_previous: &boxcars::RigidBody,
446 victim_current: &boxcars::RigidBody,
447 normal: glam::Vec3,
448) -> Option<DirectionalBumpCandidate> {
449 let initiator_previous_velocity = rigid_body_velocity(initiator_previous);
450 let initiator_current_velocity = rigid_body_velocity(initiator_current);
451 let victim_previous_velocity = rigid_body_velocity(victim_previous);
452 let victim_current_velocity = rigid_body_velocity(victim_current);
453
454 let closing_speed = (initiator_previous_velocity - victim_previous_velocity).dot(normal);
455 let victim_impulse = (victim_current_velocity - victim_previous_velocity).dot(normal);
456 let initiator_slowdown = (initiator_previous_velocity - initiator_current_velocity).dot(normal);
457 let speed_advantage =
458 initiator_previous_velocity.dot(normal) - victim_previous_velocity.dot(normal);
459 let forward_alignment = (quat_to_glam(&initiator_previous.rotation) * glam::Vec3::X)
460 .dot(normal)
461 .max(0.0);
462
463 if !closing_speed.is_finite() || !victim_impulse.is_finite() {
464 return None;
465 }
466
467 let score = closing_speed
468 + 1.35 * victim_impulse.max(0.0)
469 + 0.35 * initiator_slowdown.max(0.0)
470 + 220.0 * forward_alignment
471 + 0.15 * speed_advantage.max(0.0);
472
473 Some(DirectionalBumpCandidate {
474 score,
475 closing_speed,
476 victim_impulse,
477 })
478}
479
480fn rigid_body_velocity(rigid_body: &boxcars::RigidBody) -> glam::Vec3 {
481 rigid_body
482 .linear_velocity
483 .as_ref()
484 .map(vec_to_glam)
485 .unwrap_or(glam::Vec3::ZERO)
486}
487
488#[cfg(test)]
489#[path = "bump_tests.rs"]
490mod tests;