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