subtr_actor/stats/calculators/
half_flip.rs1use 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;