subtr_actor/stats/calculators/
movement.rs1use 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}