1use super::*;
2
3const FLIP_IMPULSE_EVALUATION_SECONDS: f32 = 0.18;
4const FLIP_ROTATION_WINDOW_SECONDS: f32 = 0.45;
10const FLIP_IMPULSE_MAX_CANDIDATE_SECONDS: f32 = 0.6;
11const FLIP_IMPULSE_MIN_DELTA: f32 = 10.0;
12const FLIP_IMPULSE_STRONG_DELTA: f32 = 280.0;
13const BOOST_ACCELERATION_UU_PER_SECOND_SQUARED: f32 = 991.6667;
14
15#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
17#[ts(export)]
18pub struct DodgeImpulse {
19 pub start_position: [f32; 3],
20 pub end_position: [f32; 3],
21 pub start_speed: f32,
22 pub end_speed: f32,
23 pub raw_velocity_delta: [f32; 3],
24 pub estimated_impulse_delta: [f32; 3],
25 pub estimated_direction: [f32; 3],
26 pub estimated_horizontal_direction: [f32; 2],
27 pub estimated_impulse_magnitude: f32,
28 pub estimated_horizontal_impulse_magnitude: f32,
29 pub local_forward_component: f32,
30 pub local_right_component: f32,
31 pub local_up_component: f32,
32 pub direction_label: String,
33 pub boost_sample_count: u32,
34 pub sample_count: u32,
35 pub boost_compensation_magnitude: f32,
36 pub confidence: f32,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
54#[ts(export)]
55pub struct DodgeRotation {
56 pub onset_pitch_rate: f32,
60 pub onset_roll_rate: f32,
61 pub onset_yaw_rate: f32,
62 pub min_forward_z: f32,
65 pub max_forward_deviation_degrees: f32,
67 pub max_up_deviation_degrees: f32,
70 pub min_up_z: f32,
73 pub sample_count: u32,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
79#[ts(export)]
80pub struct DodgeEvent {
81 pub time: f32,
82 pub frame: usize,
83 pub resolved_time: f32,
84 pub resolved_frame: usize,
85 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
86 pub player: PlayerId,
87 pub is_team_0: bool,
88 pub dodge_impulse: Option<DodgeImpulse>,
89 #[serde(default)]
90 pub dodge_rotation: Option<DodgeRotation>,
91 #[serde(default)]
98 pub dodge_torque: Option<[f32; 3]>,
99}
100
101#[derive(Debug, Clone, PartialEq)]
102struct ActiveFlipImpulseCandidate {
103 is_team_0: bool,
104 start_time: f32,
105 start_frame: usize,
106 latest_time: f32,
107 latest_frame: usize,
108 start_position: glam::Vec3,
109 end_position: glam::Vec3,
110 start_velocity: glam::Vec3,
111 end_velocity: glam::Vec3,
112 local_forward: glam::Vec3,
113 local_right: glam::Vec3,
114 local_up: glam::Vec3,
115 boost_compensation: glam::Vec3,
116 sample_count: u32,
117 boost_sample_count: u32,
118 onset_local_angular_velocity: glam::Vec3,
119 min_forward_z: f32,
120 max_forward_deviation_degrees: f32,
121 max_up_deviation_degrees: f32,
122 min_up_z: f32,
123 rotation_sample_count: u32,
124 dodge_torque: Option<glam::Vec3>,
126}
127
128impl InFlightItem for ActiveFlipImpulseCandidate {
129 fn recognition(&self) -> Recognition {
130 Recognition::speculative(self.start_time, self.start_frame)
133 }
134
135 fn on_boundary(&mut self, _boundary: Boundary) -> Disposition {
136 Disposition::Discard
139 }
140}
141
142#[derive(Debug, Clone, Default, PartialEq)]
144pub struct FlipImpulseCalculator {
145 events: EventStream<DodgeEvent>,
146 active_candidates: KeyedInFlightLedger<PlayerId, ActiveFlipImpulseCandidate>,
147 previous_dodge_active: HashMap<PlayerId, bool>,
148}
149
150impl FlipImpulseCalculator {
151 pub fn new() -> Self {
152 Self::default()
153 }
154
155 pub fn events(&self) -> &[DodgeEvent] {
156 self.events.all()
157 }
158
159 pub fn new_events(&self) -> &[DodgeEvent] {
160 self.events.new_events()
161 }
162
163 fn player_by_id<'a>(
164 players: &'a PlayerFrameState,
165 player_id: &PlayerId,
166 ) -> Option<&'a PlayerSample> {
167 players
168 .players
169 .iter()
170 .find(|player| &player.player_id == player_id)
171 }
172
173 fn direction_label(local_forward: f32, local_right: f32, local_up: f32) -> String {
174 let mut parts = Vec::new();
175 if local_forward.abs() >= 0.28 {
176 parts.push(if local_forward >= 0.0 {
177 "forward"
178 } else {
179 "backward"
180 });
181 }
182 if local_right.abs() >= 0.28 {
183 parts.push(if local_right >= 0.0 { "right" } else { "left" });
184 }
185 if parts.is_empty() {
186 parts.push("neutral");
187 }
188 if local_up.abs() >= 0.45 {
189 parts.push(if local_up >= 0.0 { "up" } else { "down" });
190 }
191 parts.join("_")
192 }
193
194 fn score_confidence(
195 impulse_magnitude: f32,
196 boost_compensation_magnitude: f32,
197 sample_count: u32,
198 ) -> f32 {
199 let strength_score = ((impulse_magnitude - FLIP_IMPULSE_MIN_DELTA)
200 / FLIP_IMPULSE_STRONG_DELTA)
201 .clamp(0.0, 1.0);
202 let boost_ratio = boost_compensation_magnitude
203 / (impulse_magnitude + boost_compensation_magnitude).max(1.0);
204 let boost_penalty = (1.0 - boost_ratio * 0.75).clamp(0.25, 1.0);
205 let sample_score = (sample_count as f32 / 3.0).clamp(0.35, 1.0);
206 (0.20 + 0.80 * strength_score) * boost_penalty * sample_score
207 }
208
209 fn maybe_start_candidate(&mut self, frame: &FrameInfo, player: &PlayerSample) {
210 let was_dodge_active = self
211 .previous_dodge_active
212 .insert(player.player_id.clone(), player.dodge_active)
213 .unwrap_or(false);
214 if !player.dodge_active || was_dodge_active {
215 return;
216 }
217
218 let Some(rigid_body) = player.rigid_body.as_ref() else {
219 return;
220 };
221 let Some(position) = player.position() else {
222 return;
223 };
224 let Some(velocity) = player.velocity() else {
225 return;
226 };
227
228 let rotation = quat_to_glam(&rigid_body.rotation);
229 let local_forward = rotation * glam::Vec3::X;
230 let local_up = rotation * glam::Vec3::Z;
231 let onset_local_angular_velocity = rigid_body
232 .angular_velocity
233 .as_ref()
234 .map(vec_to_glam)
235 .map(|angular_velocity| rotation.inverse() * angular_velocity)
236 .unwrap_or(glam::Vec3::ZERO);
237 self.active_candidates.arm(
238 player.player_id.clone(),
239 ActiveFlipImpulseCandidate {
240 is_team_0: player.is_team_0,
241 start_time: frame.time,
242 start_frame: frame.frame_number,
243 latest_time: frame.time,
244 latest_frame: frame.frame_number,
245 start_position: position,
246 end_position: position,
247 start_velocity: velocity,
248 end_velocity: velocity,
249 local_forward,
250 local_right: rotation * glam::Vec3::Y,
251 local_up,
252 boost_compensation: glam::Vec3::ZERO,
253 sample_count: 0,
254 boost_sample_count: 0,
255 onset_local_angular_velocity,
256 min_forward_z: local_forward.z,
257 max_forward_deviation_degrees: 0.0,
258 max_up_deviation_degrees: 0.0,
259 min_up_z: local_up.z,
260 rotation_sample_count: 0,
261 dodge_torque: player.dodge_torque,
262 },
263 );
264 }
265
266 fn update_candidate(
267 candidate: &mut ActiveFlipImpulseCandidate,
268 frame: &FrameInfo,
269 player: &PlayerSample,
270 ) {
271 let elapsed = frame.time - candidate.start_time;
272 if elapsed <= 0.0 {
273 return;
274 }
275
276 if elapsed <= FLIP_IMPULSE_EVALUATION_SECONDS {
279 if let Some(position) = player.position() {
280 candidate.end_position = position;
281 }
282 if let Some(velocity) = player.velocity() {
283 candidate.end_velocity = velocity;
284 candidate.sample_count += 1;
285 }
286
287 if player.boost_active {
288 candidate.boost_sample_count += 1;
289 candidate.boost_compensation +=
290 candidate.local_forward * BOOST_ACCELERATION_UU_PER_SECOND_SQUARED * frame.dt;
291 }
292
293 candidate.latest_time = frame.time;
294 candidate.latest_frame = frame.frame_number;
295 }
296
297 if elapsed <= FLIP_ROTATION_WINDOW_SECONDS {
300 if let Some(rigid_body) = player.rigid_body.as_ref() {
301 let rotation = quat_to_glam(&rigid_body.rotation);
302 let forward = rotation * glam::Vec3::X;
303 let up = rotation * glam::Vec3::Z;
304 candidate.min_forward_z = candidate.min_forward_z.min(forward.z);
305 candidate.max_forward_deviation_degrees = candidate
306 .max_forward_deviation_degrees
307 .max(candidate.local_forward.angle_between(forward).to_degrees());
308 candidate.max_up_deviation_degrees = candidate
309 .max_up_deviation_degrees
310 .max(candidate.local_up.angle_between(up).to_degrees());
311 candidate.min_up_z = candidate.min_up_z.min(up.z);
312 candidate.rotation_sample_count += 1;
313 }
314 }
315 }
316
317 fn candidate_event(player_id: &PlayerId, candidate: ActiveFlipImpulseCandidate) -> DodgeEvent {
318 let raw_delta = candidate.end_velocity - candidate.start_velocity;
319 let estimated_delta = raw_delta - candidate.boost_compensation;
320 let estimated_magnitude = estimated_delta.length();
321 let dodge_impulse = (candidate.sample_count > 0
322 && estimated_magnitude >= FLIP_IMPULSE_MIN_DELTA)
323 .then(|| {
324 let direction = estimated_delta / estimated_magnitude;
325 let horizontal_delta = estimated_delta.truncate();
326 let horizontal_magnitude = horizontal_delta.length();
327 let horizontal_direction = if horizontal_magnitude > f32::EPSILON {
328 horizontal_delta / horizontal_magnitude
329 } else {
330 glam::Vec2::ZERO
331 };
332 let local_forward_component = direction.dot(candidate.local_forward);
333 let local_right_component = direction.dot(candidate.local_right);
334 let local_up_component = direction.dot(candidate.local_up);
335 let boost_compensation_magnitude = candidate.boost_compensation.length();
336 let confidence = Self::score_confidence(
337 estimated_magnitude,
338 boost_compensation_magnitude,
339 candidate.sample_count,
340 );
341
342 DodgeImpulse {
343 start_position: candidate.start_position.to_array(),
344 end_position: candidate.end_position.to_array(),
345 start_speed: candidate.start_velocity.length(),
346 end_speed: candidate.end_velocity.length(),
347 raw_velocity_delta: raw_delta.to_array(),
348 estimated_impulse_delta: estimated_delta.to_array(),
349 estimated_direction: direction.to_array(),
350 estimated_horizontal_direction: horizontal_direction.to_array(),
351 estimated_impulse_magnitude: estimated_magnitude,
352 estimated_horizontal_impulse_magnitude: horizontal_magnitude,
353 local_forward_component,
354 local_right_component,
355 local_up_component,
356 direction_label: Self::direction_label(
357 local_forward_component,
358 local_right_component,
359 local_up_component,
360 ),
361 boost_sample_count: candidate.boost_sample_count,
362 sample_count: candidate.sample_count,
363 boost_compensation_magnitude,
364 confidence,
365 }
366 });
367
368 let dodge_rotation = (candidate.rotation_sample_count > 0).then_some(DodgeRotation {
369 onset_pitch_rate: candidate.onset_local_angular_velocity.y,
370 onset_roll_rate: candidate.onset_local_angular_velocity.x,
371 onset_yaw_rate: candidate.onset_local_angular_velocity.z,
372 min_forward_z: candidate.min_forward_z,
373 max_forward_deviation_degrees: candidate.max_forward_deviation_degrees,
374 max_up_deviation_degrees: candidate.max_up_deviation_degrees,
375 min_up_z: candidate.min_up_z,
376 sample_count: candidate.rotation_sample_count,
377 });
378
379 DodgeEvent {
380 time: candidate.start_time,
381 frame: candidate.start_frame,
382 resolved_time: candidate.latest_time,
383 resolved_frame: candidate.latest_frame,
384 player: player_id.clone(),
385 is_team_0: candidate.is_team_0,
386 dodge_impulse,
387 dodge_rotation,
388 dodge_torque: candidate.dodge_torque.map(|torque| torque.to_array()),
389 }
390 }
391
392 fn finalize_candidates(&mut self, frame: &FrameInfo, force_all: bool) {
393 let mut finished_candidates = Vec::new();
394
395 for (player_id, candidate) in self.active_candidates.iter() {
396 let duration = frame.time - candidate.start_time;
397 if force_all || duration >= FLIP_ROTATION_WINDOW_SECONDS {
398 finished_candidates.push((
399 candidate.start_time,
400 candidate.start_frame,
401 format!("{player_id:?}"),
402 player_id.clone(),
403 ));
404 }
405 }
406
407 finished_candidates.sort_by(|left, right| {
408 left.0
409 .total_cmp(&right.0)
410 .then_with(|| left.1.cmp(&right.1))
411 .then_with(|| left.2.cmp(&right.2))
412 });
413
414 for (_, _, _, player_id) in finished_candidates {
415 let Some(candidate) = self
416 .active_candidates
417 .finalize(&player_id, FinalizeReason::Completed)
418 else {
419 continue;
420 };
421 let event = Self::candidate_event(&player_id, candidate);
422 self.events.push(event);
423 }
424 }
425
426 pub fn update_parts(
427 &mut self,
428 frame: &FrameInfo,
429 players: &PlayerFrameState,
430 live_play_state: &LivePlayState,
431 ) -> SubtrActorResult<()> {
432 self.events.begin_update();
433
434 if !live_play_state.counts_toward_player_motion() {
435 self.active_candidates
436 .apply_boundary(Boundary::LivePlayEnded);
437 return Ok(());
438 }
439
440 for player in &players.players {
441 self.maybe_start_candidate(frame, player);
442 }
443
444 for (player_id, candidate) in self.active_candidates.iter_mut() {
445 let Some(player) = Self::player_by_id(players, player_id) else {
446 continue;
447 };
448 Self::update_candidate(candidate, frame, player);
449 }
450
451 self.finalize_candidates(frame, false);
452 self.active_candidates.retain(|_, candidate| {
453 frame.time - candidate.start_time <= FLIP_IMPULSE_MAX_CANDIDATE_SECONDS
454 });
455 Ok(())
456 }
457
458 pub fn finalize_parts(&mut self, frame: &FrameInfo) {
459 self.finalize_candidates(frame, true);
460 }
461}
462
463#[cfg(test)]
464#[path = "flip_impulse_tests.rs"]
465mod tests;