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(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}