Skip to main content

subtr_actor/stats/calculators/
movement.rs

1use super::*;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4enum MovementSpeedBand {
5    Slow,
6    Boost,
7    Supersonic,
8}
9
10const ALL_MOVEMENT_SPEED_BANDS: [MovementSpeedBand; 3] = [
11    MovementSpeedBand::Slow,
12    MovementSpeedBand::Boost,
13    MovementSpeedBand::Supersonic,
14];
15
16impl MovementSpeedBand {
17    fn as_label_value(self) -> &'static str {
18        match self {
19            Self::Slow => "slow",
20            Self::Boost => "boost",
21            Self::Supersonic => "supersonic",
22        }
23    }
24
25    fn as_label(self) -> StatLabel {
26        StatLabel::new("speed_band", self.as_label_value())
27    }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31struct MovementClassification {
32    speed_band: MovementSpeedBand,
33    height_band: PlayerVerticalBand,
34}
35
36impl MovementClassification {
37    fn labels(self) -> [StatLabel; 2] {
38        [self.speed_band.as_label(), self.height_band.as_label()]
39    }
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
43#[ts(export)]
44pub struct MovementStats {
45    pub tracked_time: f32,
46    pub total_distance: f32,
47    pub speed_integral: f32,
48    pub time_slow_speed: f32,
49    pub time_boost_speed: f32,
50    pub time_supersonic_speed: f32,
51    pub time_on_ground: f32,
52    pub time_low_air: f32,
53    pub time_high_air: f32,
54    #[serde(default, skip_serializing_if = "LabeledFloatSums::is_empty")]
55    pub labeled_tracked_time: LabeledFloatSums,
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
59#[ts(export)]
60pub struct MovementEvent {
61    pub time: f32,
62    pub frame: usize,
63    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
64    pub player: PlayerId,
65    pub is_team_0: bool,
66    pub dt: f32,
67    pub speed: f32,
68    pub distance: f32,
69    pub speed_band: String,
70    pub height_band: String,
71}
72
73impl MovementStats {
74    pub fn average_speed(&self) -> f32 {
75        if self.tracked_time == 0.0 {
76            0.0
77        } else {
78            self.speed_integral / self.tracked_time
79        }
80    }
81
82    pub fn average_speed_pct(&self) -> f32 {
83        self.average_speed() * 100.0 / CAR_MAX_SPEED
84    }
85
86    pub fn slow_speed_pct(&self) -> f32 {
87        if self.tracked_time == 0.0 {
88            0.0
89        } else {
90            self.time_slow_speed * 100.0 / self.tracked_time
91        }
92    }
93
94    pub fn boost_speed_pct(&self) -> f32 {
95        if self.tracked_time == 0.0 {
96            0.0
97        } else {
98            self.time_boost_speed * 100.0 / self.tracked_time
99        }
100    }
101
102    pub fn supersonic_speed_pct(&self) -> f32 {
103        if self.tracked_time == 0.0 {
104            0.0
105        } else {
106            self.time_supersonic_speed * 100.0 / self.tracked_time
107        }
108    }
109
110    pub fn on_ground_pct(&self) -> f32 {
111        if self.tracked_time == 0.0 {
112            0.0
113        } else {
114            self.time_on_ground * 100.0 / self.tracked_time
115        }
116    }
117
118    pub fn low_air_pct(&self) -> f32 {
119        if self.tracked_time == 0.0 {
120            0.0
121        } else {
122            self.time_low_air * 100.0 / self.tracked_time
123        }
124    }
125
126    pub fn high_air_pct(&self) -> f32 {
127        if self.tracked_time == 0.0 {
128            0.0
129        } else {
130            self.time_high_air * 100.0 / self.tracked_time
131        }
132    }
133
134    pub fn tracked_time_with_labels(&self, labels: &[StatLabel]) -> f32 {
135        self.labeled_tracked_time.sum_matching(labels)
136    }
137
138    pub fn complete_labeled_tracked_time(&self) -> LabeledFloatSums {
139        let mut entries: Vec<_> = ALL_PLAYER_VERTICAL_BANDS
140            .into_iter()
141            .flat_map(|height_band| {
142                ALL_MOVEMENT_SPEED_BANDS.into_iter().map(move |speed_band| {
143                    let mut labels = vec![speed_band.as_label(), height_band.as_label()];
144                    labels.sort();
145                    LabeledFloatSumEntry {
146                        value: self.labeled_tracked_time.sum_exact(&labels),
147                        labels,
148                    }
149                })
150            })
151            .collect();
152
153        entries.sort_by(|left, right| left.labels.cmp(&right.labels));
154
155        LabeledFloatSums { entries }
156    }
157
158    pub fn with_complete_labeled_tracked_time(mut self) -> Self {
159        self.labeled_tracked_time = self.complete_labeled_tracked_time();
160        self
161    }
162}
163
164#[derive(Debug, Clone, Default)]
165pub struct MovementCalculator {
166    player_stats: HashMap<PlayerId, MovementStats>,
167    player_teams: HashMap<PlayerId, bool>,
168    previous_positions: HashMap<PlayerId, glam::Vec3>,
169    team_zero_stats: MovementStats,
170    team_one_stats: MovementStats,
171    events: Vec<MovementEvent>,
172}
173
174impl MovementCalculator {
175    pub fn new() -> Self {
176        Self::default()
177    }
178
179    pub fn player_stats(&self) -> &HashMap<PlayerId, MovementStats> {
180        &self.player_stats
181    }
182
183    pub fn team_zero_stats(&self) -> &MovementStats {
184        &self.team_zero_stats
185    }
186
187    pub fn team_one_stats(&self) -> &MovementStats {
188        &self.team_one_stats
189    }
190
191    pub fn events(&self) -> &[MovementEvent] {
192        &self.events
193    }
194
195    fn classify_movement(speed: f32, height_band: PlayerVerticalBand) -> MovementClassification {
196        let speed_band = if speed >= SUPERSONIC_SPEED_THRESHOLD {
197            MovementSpeedBand::Supersonic
198        } else if speed >= BOOST_SPEED_THRESHOLD {
199            MovementSpeedBand::Boost
200        } else {
201            MovementSpeedBand::Slow
202        };
203
204        MovementClassification {
205            speed_band,
206            height_band,
207        }
208    }
209
210    fn apply_classification(
211        stats: &mut MovementStats,
212        classification: MovementClassification,
213        dt: f32,
214    ) {
215        match classification.speed_band {
216            MovementSpeedBand::Slow => stats.time_slow_speed += dt,
217            MovementSpeedBand::Boost => stats.time_boost_speed += dt,
218            MovementSpeedBand::Supersonic => stats.time_supersonic_speed += dt,
219        }
220
221        match classification.height_band {
222            PlayerVerticalBand::Ground => stats.time_on_ground += dt,
223            PlayerVerticalBand::LowAir => stats.time_low_air += dt,
224            PlayerVerticalBand::HighAir => stats.time_high_air += dt,
225        }
226
227        stats.labeled_tracked_time.add(classification.labels(), dt);
228    }
229
230    pub fn update(
231        &mut self,
232        frame: &FrameInfo,
233        players: &PlayerFrameState,
234        vertical_state: &PlayerVerticalState,
235        live_play: bool,
236    ) -> SubtrActorResult<()> {
237        if frame.dt == 0.0 {
238            for player in &players.players {
239                if let Some(position) = player.position() {
240                    self.previous_positions
241                        .insert(player.player_id.clone(), position);
242                }
243            }
244            return Ok(());
245        }
246
247        for player in &players.players {
248            self.player_teams
249                .insert(player.player_id.clone(), player.is_team_0);
250            let Some(position) = player.position() else {
251                continue;
252            };
253            let speed = player.speed().unwrap_or(0.0);
254            let stats = self
255                .player_stats
256                .entry(player.player_id.clone())
257                .or_default();
258            let team_stats = if player.is_team_0 {
259                &mut self.team_zero_stats
260            } else {
261                &mut self.team_one_stats
262            };
263
264            if live_play {
265                stats.tracked_time += frame.dt;
266                stats.speed_integral += speed * frame.dt;
267                team_stats.tracked_time += frame.dt;
268                team_stats.speed_integral += speed * frame.dt;
269
270                let distance = if let Some(previous_position) =
271                    self.previous_positions.get(&player.player_id)
272                {
273                    position.distance(*previous_position)
274                } else {
275                    0.0
276                };
277                if distance > 0.0 {
278                    stats.total_distance += distance;
279                    team_stats.total_distance += distance;
280                }
281
282                let height_band = vertical_state
283                    .band_for_player(&player.player_id)
284                    .unwrap_or_else(|| PlayerVerticalBand::from_height(position.z));
285                let classification = Self::classify_movement(speed, height_band);
286                Self::apply_classification(stats, classification, frame.dt);
287                Self::apply_classification(team_stats, classification, frame.dt);
288                self.events.push(MovementEvent {
289                    time: frame.time,
290                    frame: frame.frame_number,
291                    player: player.player_id.clone(),
292                    is_team_0: player.is_team_0,
293                    dt: frame.dt,
294                    speed,
295                    distance,
296                    speed_band: classification.speed_band.as_label_value().to_owned(),
297                    height_band: classification.height_band.as_label().value.to_owned(),
298                });
299            }
300
301            self.previous_positions
302                .insert(player.player_id.clone(), position);
303        }
304
305        Ok(())
306    }
307}