Skip to main content

subtr_actor/stats/calculators/
half_flip.rs

1use super::*;
2
3const HALF_FLIP_EVALUATION_SECONDS: f32 = 0.65;
4const HALF_FLIP_MAX_CANDIDATE_SECONDS: f32 = 1.0;
5const HALF_FLIP_MAX_START_Z: f32 = PLAYER_GROUND_Z_THRESHOLD + 45.0;
6const HALF_FLIP_MIN_START_SPEED: f32 = 250.0;
7const HALF_FLIP_MIN_START_BACKWARD_ALIGNMENT: f32 = 0.55;
8const HALF_FLIP_MIN_REORIENTATION_ALIGNMENT: f32 = 0.60;
9const HALF_FLIP_MIN_FORWARD_REVERSAL: f32 = 0.55;
10const HALF_FLIP_MIN_FORWARD_VERTICAL: f32 = 0.22;
11const HALF_FLIP_MIN_CONFIDENCE: f32 = 0.55;
12const HALF_FLIP_HIGH_CONFIDENCE: f32 = 0.78;
13
14#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
15#[ts(export)]
16pub struct HalfFlipEvent {
17    pub time: f32,
18    pub frame: usize,
19    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
20    pub player: PlayerId,
21    pub is_team_0: bool,
22    pub start_position: [f32; 3],
23    pub end_position: [f32; 3],
24    pub start_speed: f32,
25    pub end_speed: f32,
26    pub start_backward_alignment: f32,
27    pub best_reorientation_alignment: f32,
28    pub best_forward_reversal: f32,
29    pub max_forward_vertical: f32,
30    pub confidence: f32,
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
34#[ts(export)]
35pub struct HalfFlipStats {
36    pub count: u32,
37    pub high_confidence_count: u32,
38    pub is_last_half_flip: bool,
39    pub last_half_flip_time: Option<f32>,
40    pub last_half_flip_frame: Option<usize>,
41    pub time_since_last_half_flip: Option<f32>,
42    pub frames_since_last_half_flip: Option<usize>,
43    pub last_quality: Option<f32>,
44    pub best_quality: f32,
45    pub cumulative_quality: f32,
46}
47
48impl HalfFlipStats {
49    pub fn average_quality(&self) -> f32 {
50        if self.count == 0 {
51            0.0
52        } else {
53            self.cumulative_quality / self.count as f32
54        }
55    }
56}
57
58#[derive(Debug, Clone, PartialEq)]
59struct ActiveHalfFlipCandidate {
60    is_team_0: bool,
61    start_time: f32,
62    start_frame: usize,
63    latest_time: f32,
64    latest_frame: usize,
65    start_position: [f32; 3],
66    end_position: [f32; 3],
67    start_speed: f32,
68    end_speed: f32,
69    start_forward_xy: glam::Vec2,
70    start_backward_alignment: f32,
71    best_reorientation_alignment: f32,
72    best_forward_reversal: f32,
73    max_forward_vertical: f32,
74}
75
76#[derive(Debug, Clone, Default, PartialEq)]
77pub struct HalfFlipCalculator {
78    player_stats: HashMap<PlayerId, HalfFlipStats>,
79    events: Vec<HalfFlipEvent>,
80    active_candidates: HashMap<PlayerId, ActiveHalfFlipCandidate>,
81    previous_dodge_active: HashMap<PlayerId, bool>,
82    current_last_half_flip_player: Option<PlayerId>,
83}
84
85impl HalfFlipCalculator {
86    pub fn new() -> Self {
87        Self::default()
88    }
89
90    pub fn player_stats(&self) -> &HashMap<PlayerId, HalfFlipStats> {
91        &self.player_stats
92    }
93
94    pub fn events(&self) -> &[HalfFlipEvent] {
95        &self.events
96    }
97
98    fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
99        if max_value <= min_value {
100            return 0.0;
101        }
102
103        ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
104    }
105
106    fn horizontal_velocity(player: &PlayerSample) -> Option<glam::Vec2> {
107        let velocity = player.velocity()?.truncate();
108        if velocity.length_squared() <= f32::EPSILON {
109            return None;
110        }
111        Some(velocity)
112    }
113
114    fn forward_vector(player: &PlayerSample) -> Option<glam::Vec3> {
115        let rigid_body = player.rigid_body.as_ref()?;
116        Some(quat_to_glam(&rigid_body.rotation) * glam::Vec3::X)
117    }
118
119    fn forward_xy(player: &PlayerSample) -> Option<glam::Vec2> {
120        let forward_xy = Self::forward_vector(player)?.truncate().normalize_or_zero();
121        if forward_xy.length_squared() <= f32::EPSILON {
122            return None;
123        }
124        Some(forward_xy)
125    }
126
127    fn maybe_start_candidate(&mut self, frame: &FrameInfo, player: &PlayerSample) {
128        let was_dodge_active = self
129            .previous_dodge_active
130            .insert(player.player_id.clone(), player.dodge_active)
131            .unwrap_or(false);
132        if !player.dodge_active || was_dodge_active {
133            return;
134        }
135
136        let Some(position) = player.position() else {
137            return;
138        };
139        if position.z > HALF_FLIP_MAX_START_Z {
140            return;
141        }
142
143        let velocity_xy = Self::horizontal_velocity(player).unwrap_or(glam::Vec2::ZERO);
144        let start_speed = velocity_xy.length();
145        if start_speed < HALF_FLIP_MIN_START_SPEED {
146            return;
147        }
148
149        let Some(start_forward_xy) = Self::forward_xy(player) else {
150            return;
151        };
152        let velocity_direction = velocity_xy.normalize_or_zero();
153        let start_backward_alignment = -start_forward_xy.dot(velocity_direction);
154        if start_backward_alignment < HALF_FLIP_MIN_START_BACKWARD_ALIGNMENT {
155            return;
156        }
157
158        let max_forward_vertical =
159            Self::forward_vector(player).map_or(0.0, |forward| forward.z.abs());
160
161        self.active_candidates.insert(
162            player.player_id.clone(),
163            ActiveHalfFlipCandidate {
164                is_team_0: player.is_team_0,
165                start_time: frame.time,
166                start_frame: frame.frame_number,
167                latest_time: frame.time,
168                latest_frame: frame.frame_number,
169                start_position: position.to_array(),
170                end_position: position.to_array(),
171                start_speed,
172                end_speed: start_speed,
173                start_forward_xy,
174                start_backward_alignment,
175                best_reorientation_alignment: 0.0,
176                best_forward_reversal: 0.0,
177                max_forward_vertical,
178            },
179        );
180    }
181
182    fn update_candidate(
183        candidate: &mut ActiveHalfFlipCandidate,
184        frame: &FrameInfo,
185        player: &PlayerSample,
186    ) {
187        if let Some(position) = player.position() {
188            candidate.end_position = position.to_array();
189        }
190
191        let velocity_xy = Self::horizontal_velocity(player).unwrap_or(glam::Vec2::ZERO);
192        candidate.end_speed = velocity_xy.length();
193        let velocity_direction = velocity_xy.normalize_or_zero();
194
195        if let Some(forward) = Self::forward_vector(player) {
196            candidate.max_forward_vertical = candidate.max_forward_vertical.max(forward.z.abs());
197            let forward_xy = forward.truncate().normalize_or_zero();
198            if forward_xy.length_squared() > f32::EPSILON {
199                candidate.best_forward_reversal = candidate
200                    .best_forward_reversal
201                    .max((-candidate.start_forward_xy.dot(forward_xy)).clamp(-1.0, 1.0));
202                if velocity_direction.length_squared() > f32::EPSILON {
203                    candidate.best_reorientation_alignment = candidate
204                        .best_reorientation_alignment
205                        .max(forward_xy.dot(velocity_direction));
206                }
207            }
208        }
209
210        candidate.latest_time = frame.time;
211        candidate.latest_frame = frame.frame_number;
212    }
213
214    fn candidate_event(
215        player_id: &PlayerId,
216        candidate: ActiveHalfFlipCandidate,
217    ) -> Option<HalfFlipEvent> {
218        if candidate.best_reorientation_alignment < HALF_FLIP_MIN_REORIENTATION_ALIGNMENT
219            || candidate.best_forward_reversal < HALF_FLIP_MIN_FORWARD_REVERSAL
220            || candidate.max_forward_vertical < HALF_FLIP_MIN_FORWARD_VERTICAL
221        {
222            return None;
223        }
224
225        let backward_score = Self::normalize_score(
226            candidate.start_backward_alignment,
227            HALF_FLIP_MIN_START_BACKWARD_ALIGNMENT,
228            0.95,
229        );
230        let reorientation_score = Self::normalize_score(
231            candidate.best_reorientation_alignment,
232            HALF_FLIP_MIN_REORIENTATION_ALIGNMENT,
233            0.98,
234        );
235        let reversal_score = Self::normalize_score(
236            candidate.best_forward_reversal,
237            HALF_FLIP_MIN_FORWARD_REVERSAL,
238            0.98,
239        );
240        let flip_score = Self::normalize_score(
241            candidate.max_forward_vertical,
242            HALF_FLIP_MIN_FORWARD_VERTICAL,
243            0.85,
244        );
245        let speed_score = Self::normalize_score(candidate.end_speed, 900.0, 1800.0).max(
246            Self::normalize_score(candidate.end_speed - candidate.start_speed, 100.0, 700.0) * 0.7,
247        );
248        let confidence = 0.25 * backward_score
249            + 0.30 * reorientation_score
250            + 0.25 * reversal_score
251            + 0.10 * flip_score
252            + 0.10 * speed_score;
253
254        if confidence < HALF_FLIP_MIN_CONFIDENCE {
255            return None;
256        }
257
258        Some(HalfFlipEvent {
259            time: candidate.latest_time,
260            frame: candidate.latest_frame,
261            player: player_id.clone(),
262            is_team_0: candidate.is_team_0,
263            start_position: candidate.start_position,
264            end_position: candidate.end_position,
265            start_speed: candidate.start_speed,
266            end_speed: candidate.end_speed,
267            start_backward_alignment: candidate.start_backward_alignment,
268            best_reorientation_alignment: candidate.best_reorientation_alignment,
269            best_forward_reversal: candidate.best_forward_reversal,
270            max_forward_vertical: candidate.max_forward_vertical,
271            confidence,
272        })
273    }
274
275    fn apply_event(&mut self, event: HalfFlipEvent) {
276        for stats in self.player_stats.values_mut() {
277            stats.is_last_half_flip = false;
278        }
279
280        let stats = self.player_stats.entry(event.player.clone()).or_default();
281        stats.count += 1;
282        if event.confidence >= HALF_FLIP_HIGH_CONFIDENCE {
283            stats.high_confidence_count += 1;
284        }
285        stats.is_last_half_flip = true;
286        stats.last_half_flip_time = Some(event.time);
287        stats.last_half_flip_frame = Some(event.frame);
288        stats.time_since_last_half_flip = Some(0.0);
289        stats.frames_since_last_half_flip = Some(0);
290        stats.last_quality = Some(event.confidence);
291        stats.best_quality = stats.best_quality.max(event.confidence);
292        stats.cumulative_quality += event.confidence;
293
294        self.current_last_half_flip_player = Some(event.player.clone());
295        self.events.push(event);
296    }
297
298    fn begin_sample(&mut self, frame: &FrameInfo) {
299        for stats in self.player_stats.values_mut() {
300            stats.is_last_half_flip = false;
301            stats.time_since_last_half_flip = stats
302                .last_half_flip_time
303                .map(|time| (frame.time - time).max(0.0));
304            stats.frames_since_last_half_flip = stats
305                .last_half_flip_frame
306                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
307        }
308
309        if let Some(player_id) = self.current_last_half_flip_player.as_ref() {
310            if let Some(stats) = self.player_stats.get_mut(player_id) {
311                stats.is_last_half_flip = true;
312            }
313        }
314    }
315
316    fn finalize_candidates(&mut self, frame: &FrameInfo, force_all: bool) {
317        let mut finished_candidates = Vec::new();
318
319        for (player_id, candidate) in &self.active_candidates {
320            let duration = frame.time - candidate.start_time;
321            if force_all || duration >= HALF_FLIP_EVALUATION_SECONDS {
322                finished_candidates.push((
323                    candidate.start_time,
324                    candidate.start_frame,
325                    format!("{player_id:?}"),
326                    player_id.clone(),
327                ));
328            }
329        }
330
331        finished_candidates.sort_by(|left, right| {
332            left.0
333                .total_cmp(&right.0)
334                .then_with(|| left.1.cmp(&right.1))
335                .then_with(|| left.2.cmp(&right.2))
336        });
337
338        for (_, _, _, player_id) in finished_candidates {
339            let Some(candidate) = self.active_candidates.remove(&player_id) else {
340                continue;
341            };
342            if let Some(event) = Self::candidate_event(&player_id, candidate) {
343                self.apply_event(event);
344            }
345        }
346    }
347
348    pub fn update(
349        &mut self,
350        frame: &FrameInfo,
351        players: &PlayerFrameState,
352        live_play: bool,
353    ) -> SubtrActorResult<()> {
354        if !live_play {
355            self.active_candidates.clear();
356            self.current_last_half_flip_player = None;
357            return Ok(());
358        }
359
360        self.begin_sample(frame);
361
362        for player in &players.players {
363            self.maybe_start_candidate(frame, player);
364        }
365
366        let mut visible_players = HashSet::new();
367        for player in &players.players {
368            visible_players.insert(player.player_id.clone());
369            if let Some(candidate) = self.active_candidates.get_mut(&player.player_id) {
370                Self::update_candidate(candidate, frame, player);
371            }
372        }
373
374        self.finalize_candidates(frame, false);
375        self.active_candidates.retain(|player_id, candidate| {
376            visible_players.contains(player_id)
377                && frame.time - candidate.start_time <= HALF_FLIP_MAX_CANDIDATE_SECONDS
378        });
379
380        Ok(())
381    }
382
383    pub fn finalize(&mut self, frame: &FrameInfo) {
384        self.finalize_candidates(frame, true);
385    }
386}
387
388#[cfg(test)]
389#[path = "half_flip_tests.rs"]
390mod tests;