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_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;