Skip to main content

subtr_actor/stats/calculators/
bump.rs

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/// A player-on-player bump with attacker/victim and impact context.
14#[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/// Detects player-on-player bumps from player frame state and events.
48#[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(&current.location);
416    let previous_rotation = quat_to_glam(&previous.rotation);
417    let current_rotation = quat_to_glam(&current.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;