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