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
10impl MovementSpeedBand {
11    fn as_label_value(self) -> &'static str {
12        match self {
13            Self::Slow => "slow",
14            Self::Boost => "boost",
15            Self::Supersonic => "supersonic",
16        }
17    }
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21struct MovementClassification {
22    speed_band: MovementSpeedBand,
23    height_band: PlayerVerticalBand,
24}
25
26/// A change in a player's movement classification.
27#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
28#[ts(export)]
29pub struct MovementEvent {
30    pub time: f32,
31    pub frame: usize,
32    pub end_time: f32,
33    pub end_frame: usize,
34    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
35    pub player: PlayerId,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub player_position: Option<[f32; 3]>,
38    pub is_team_0: bool,
39    pub dt: f32,
40    pub speed: f32,
41    pub distance: f32,
42    pub speed_band: String,
43    pub height_band: String,
44}
45
46impl MovementEvent {
47    fn absorb_sample(&mut self, sample: Self) {
48        self.end_time = sample.time;
49        self.end_frame = sample.frame;
50        self.player_position = sample.player_position;
51        let combined_dt = self.dt + sample.dt;
52        if combined_dt > 0.0 {
53            self.speed = (self.speed * self.dt + sample.speed * sample.dt) / combined_dt;
54        }
55        self.dt = combined_dt;
56        self.distance += sample.distance;
57    }
58}
59
60/// Tracks per-player movement classification and stats.
61#[derive(Debug, Clone, Default)]
62pub struct MovementCalculator {
63    player_teams: HashMap<PlayerId, bool>,
64    previous_positions: HashMap<PlayerId, glam::Vec3>,
65    events: EventStream<MovementEvent>,
66    pending_events: HashMap<PlayerId, PendingMovementEvent>,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70struct MovementEventState {
71    is_team_0: bool,
72    classification: MovementClassification,
73}
74
75#[derive(Debug, Clone, PartialEq)]
76struct PendingMovementEvent {
77    state: MovementEventState,
78    event: MovementEvent,
79}
80
81impl MovementCalculator {
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    pub fn events(&self) -> &[MovementEvent] {
87        self.events.all()
88    }
89
90    pub fn new_events(&self) -> &[MovementEvent] {
91        self.events.new_events()
92    }
93
94    /// The in-progress (not yet committed) coalescing events, one per active
95    /// player, in deterministic order. These keep mutating frame-to-frame until
96    /// the player's classification changes, so consumers that accumulate
97    /// incrementally must overlay these on top of the committed [`events`] each
98    /// frame rather than folding them in permanently.
99    ///
100    /// [`events`]: Self::events
101    pub fn pending_events(&self) -> Vec<MovementEvent> {
102        let mut pending: Vec<_> = self
103            .pending_events
104            .values()
105            .map(|pending| pending.event.clone())
106            .collect();
107        pending.sort_by(|left, right| {
108            left.frame
109                .cmp(&right.frame)
110                .then_with(|| format!("{:?}", left.player).cmp(&format!("{:?}", right.player)))
111        });
112        pending
113    }
114
115    pub fn projected_events(&self) -> Vec<MovementEvent> {
116        let mut events = self.events.all().to_vec();
117        events.extend(self.pending_events());
118        events
119    }
120
121    pub fn flush_pending_events(&mut self) {
122        let mut pending: Vec<_> = self
123            .pending_events
124            .drain()
125            .map(|(_, pending)| pending)
126            .collect();
127        pending.sort_by(|left, right| {
128            left.event.frame.cmp(&right.event.frame).then_with(|| {
129                format!("{:?}", left.event.player).cmp(&format!("{:?}", right.event.player))
130            })
131        });
132        self.events
133            .extend(pending.into_iter().map(|pending| pending.event));
134    }
135
136    fn classify_movement(speed: f32, height_band: PlayerVerticalBand) -> MovementClassification {
137        let speed_band = if speed >= SUPERSONIC_SPEED_THRESHOLD {
138            MovementSpeedBand::Supersonic
139        } else if speed >= BOOST_SPEED_THRESHOLD {
140            MovementSpeedBand::Boost
141        } else {
142            MovementSpeedBand::Slow
143        };
144
145        MovementClassification {
146            speed_band,
147            height_band,
148        }
149    }
150
151    pub fn update(
152        &mut self,
153        frame: &FrameInfo,
154        players: &PlayerFrameState,
155        vertical_state: &PlayerVerticalState,
156        live_play_state: &LivePlayState,
157    ) -> SubtrActorResult<()> {
158        self.events.begin_update();
159        if !live_play_state.counts_toward_player_motion() {
160            self.flush_pending_events();
161            for player in &players.players {
162                if let Some(position) = player.position() {
163                    self.previous_positions
164                        .insert(player.player_id.clone(), position);
165                }
166            }
167            return Ok(());
168        }
169
170        let active_players: HashSet<_> = players
171            .players
172            .iter()
173            .map(|player| player.player_id.clone())
174            .collect();
175        self.flush_pending_events_for_missing_players(&active_players);
176
177        if frame.dt == 0.0 {
178            for player in &players.players {
179                if let Some(position) = player.position() {
180                    self.previous_positions
181                        .insert(player.player_id.clone(), position);
182                }
183            }
184            return Ok(());
185        }
186
187        for player in &players.players {
188            self.player_teams
189                .insert(player.player_id.clone(), player.is_team_0);
190            let Some(position) = player.position() else {
191                self.flush_pending_event_for_player(&player.player_id);
192                continue;
193            };
194            let speed = player.speed().unwrap_or(0.0);
195            let distance =
196                if let Some(previous_position) = self.previous_positions.get(&player.player_id) {
197                    position.distance(*previous_position)
198                } else {
199                    0.0
200                };
201
202            let height_band = vertical_state
203                .band_for_player(&player.player_id)
204                .unwrap_or_else(|| PlayerVerticalBand::from_height(position.z));
205            let classification = Self::classify_movement(speed, height_band);
206            let event = MovementEvent {
207                time: frame.time,
208                frame: frame.frame_number,
209                end_time: frame.time,
210                end_frame: frame.frame_number,
211                player: player.player_id.clone(),
212                player_position: Some(position.to_array()),
213                is_team_0: player.is_team_0,
214                dt: frame.dt,
215                speed,
216                distance,
217                speed_band: classification.speed_band.as_label_value().to_owned(),
218                height_band: classification.height_band.as_label().value.to_owned(),
219            };
220            self.record_event(
221                MovementEventState {
222                    is_team_0: player.is_team_0,
223                    classification,
224                },
225                event,
226            );
227
228            self.previous_positions
229                .insert(player.player_id.clone(), position);
230        }
231
232        Ok(())
233    }
234
235    fn record_event(&mut self, state: MovementEventState, event: MovementEvent) {
236        let player = event.player.clone();
237        let Some(pending) = self.pending_events.get_mut(&player) else {
238            self.pending_events
239                .insert(player, PendingMovementEvent { state, event });
240            return;
241        };
242
243        if pending.state == state {
244            pending.event.absorb_sample(event);
245        } else {
246            let previous = self
247                .pending_events
248                .insert(player, PendingMovementEvent { state, event });
249            let Some(previous) = previous else {
250                return;
251            };
252            self.events.push(previous.event);
253        }
254    }
255
256    fn flush_pending_event_for_player(&mut self, player_id: &PlayerId) {
257        let Some(pending) = self.pending_events.remove(player_id) else {
258            return;
259        };
260        self.events.push(pending.event);
261    }
262
263    fn flush_pending_events_for_missing_players(&mut self, active_players: &HashSet<PlayerId>) {
264        let missing_players: Vec<_> = self
265            .pending_events
266            .keys()
267            .filter(|player_id| !active_players.contains(*player_id))
268            .cloned()
269            .collect();
270        for player_id in missing_players {
271            self.flush_pending_event_for_player(&player_id);
272        }
273    }
274}