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