1use super::*;
2
3const FLICK_MAX_DODGE_TO_TOUCH_SECONDS: f32 = 0.32;
4const FLICK_MAX_SETUP_STALE_SECONDS: f32 = 0.35;
5const FLICK_MIN_SETUP_SECONDS: f32 = 0.18;
6const FLICK_MIN_SETUP_TOUCHES: u32 = 2;
7const FLICK_MIN_BALL_SPEED_CHANGE: f32 = 450.0;
8const FLICK_HIGH_CONFIDENCE: f32 = 0.80;
9const FLICK_MIN_CONFIDENCE: f32 = 0.55;
10const FLICK_MAX_CONTROL_BALL_Z: f32 = 700.0;
11const FLICK_MAX_CONTROL_HORIZONTAL_GAP: f32 = BALL_RADIUS_Z * 1.7;
12const FLICK_MIN_CONTROL_VERTICAL_GAP: f32 = 35.0;
13const FLICK_MAX_CONTROL_VERTICAL_GAP: f32 = 280.0;
14const FLICK_MIN_LOCAL_Z: f32 = 20.0;
15const FLICK_MAX_LOCAL_X_BEHIND: f32 = 95.0;
16const FLICK_MAX_LOCAL_X_FRONT: f32 = 210.0;
17const FLICK_MAX_LOCAL_Y: f32 = 170.0;
18const FLICK_MIN_IMPULSE_AWAY_ALIGNMENT: f32 = 0.15;
19
20#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
21#[ts(export)]
22pub struct FlickEvent {
23 pub time: f32,
24 pub frame: usize,
25 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
26 pub player: PlayerId,
27 pub is_team_0: bool,
28 pub dodge_time: f32,
29 pub dodge_frame: usize,
30 pub time_since_dodge: f32,
31 pub setup_start_time: f32,
32 pub setup_start_frame: usize,
33 pub setup_duration: f32,
34 pub setup_touch_count: u32,
35 pub average_horizontal_gap: f32,
36 pub average_vertical_gap: f32,
37 pub ball_speed_change: f32,
38 pub ball_impulse: [f32; 3],
39 pub impulse_away_alignment: f32,
40 pub vertical_impulse: f32,
41 pub confidence: f32,
42}
43
44#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
45#[ts(export)]
46pub struct FlickStats {
47 pub count: u32,
48 pub high_confidence_count: u32,
49 pub is_last_flick: bool,
50 pub last_flick_time: Option<f32>,
51 pub last_flick_frame: Option<usize>,
52 pub time_since_last_flick: Option<f32>,
53 pub frames_since_last_flick: Option<usize>,
54 pub last_confidence: Option<f32>,
55 pub best_confidence: f32,
56 pub cumulative_confidence: f32,
57 pub cumulative_setup_duration: f32,
58 pub cumulative_ball_speed_change: f32,
59}
60
61impl FlickStats {
62 pub fn average_confidence(&self) -> f32 {
63 if self.count == 0 {
64 0.0
65 } else {
66 self.cumulative_confidence / self.count as f32
67 }
68 }
69
70 pub fn average_setup_duration(&self) -> f32 {
71 if self.count == 0 {
72 0.0
73 } else {
74 self.cumulative_setup_duration / self.count as f32
75 }
76 }
77
78 pub fn average_ball_speed_change(&self) -> f32 {
79 if self.count == 0 {
80 0.0
81 } else {
82 self.cumulative_ball_speed_change / self.count as f32
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq)]
88struct FlickControlObservation {
89 horizontal_gap: f32,
90 vertical_gap: f32,
91}
92
93#[derive(Debug, Clone, PartialEq)]
94struct ActiveFlickSetup {
95 is_team_0: bool,
96 start_time: f32,
97 start_frame: usize,
98 last_time: f32,
99 last_frame: usize,
100 duration: f32,
101 horizontal_gap_integral: f32,
102 vertical_gap_integral: f32,
103 touch_count: u32,
104}
105
106#[derive(Debug, Clone, PartialEq)]
107struct FlickSetupSummary {
108 is_team_0: bool,
109 start_time: f32,
110 start_frame: usize,
111 last_time: f32,
112 last_frame: usize,
113 duration: f32,
114 average_horizontal_gap: f32,
115 average_vertical_gap: f32,
116 touch_count: u32,
117}
118
119#[derive(Debug, Clone, PartialEq)]
120struct RecentDodgeStart {
121 time: f32,
122 frame: usize,
123 setup: FlickSetupSummary,
124}
125
126#[derive(Debug, Clone, Default, PartialEq)]
127pub struct FlickCalculator {
128 player_stats: HashMap<PlayerId, FlickStats>,
129 events: Vec<FlickEvent>,
130 active_setups: HashMap<PlayerId, ActiveFlickSetup>,
131 recent_setups: HashMap<PlayerId, FlickSetupSummary>,
132 recent_dodge_starts: HashMap<PlayerId, RecentDodgeStart>,
133 previous_dodge_active: HashMap<PlayerId, bool>,
134 previous_ball_velocity: Option<glam::Vec3>,
135 current_last_flick_player: Option<PlayerId>,
136}
137
138impl FlickCalculator {
139 pub fn new() -> Self {
140 Self::default()
141 }
142
143 pub fn player_stats(&self) -> &HashMap<PlayerId, FlickStats> {
144 &self.player_stats
145 }
146
147 pub fn events(&self) -> &[FlickEvent] {
148 &self.events
149 }
150
151 fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
152 if max_value <= min_value {
153 return 0.0;
154 }
155
156 ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
157 }
158
159 fn begin_sample(&mut self, frame: &FrameInfo) {
160 for stats in self.player_stats.values_mut() {
161 stats.is_last_flick = false;
162 stats.time_since_last_flick = stats
163 .last_flick_time
164 .map(|time| (frame.time - time).max(0.0));
165 stats.frames_since_last_flick = stats
166 .last_flick_frame
167 .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
168 }
169
170 if let Some(player_id) = self.current_last_flick_player.as_ref() {
171 if let Some(stats) = self.player_stats.get_mut(player_id) {
172 stats.is_last_flick = true;
173 }
174 }
175 }
176
177 fn ball_impulse(
178 frame: &FrameInfo,
179 ball: &BallFrameState,
180 previous_ball_velocity: Option<glam::Vec3>,
181 ) -> glam::Vec3 {
182 const BALL_GRAVITY_Z: f32 = -650.0;
183
184 let Some(ball) = ball.sample() else {
185 return glam::Vec3::ZERO;
186 };
187 let Some(previous_ball_velocity) = previous_ball_velocity else {
188 return glam::Vec3::ZERO;
189 };
190
191 let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
192 ball.velocity() - previous_ball_velocity - expected_linear_delta
193 }
194
195 fn control_observation(
196 ball: &BallSample,
197 player: &PlayerSample,
198 controlling_player: Option<&PlayerId>,
199 ) -> Option<FlickControlObservation> {
200 if controlling_player != Some(&player.player_id) {
201 return None;
202 }
203
204 let player_rigid_body = player.rigid_body.as_ref()?;
205 let player_position = player.position()?;
206 let ball_position = ball.position();
207 if !(BALL_CARRY_MIN_BALL_Z..=FLICK_MAX_CONTROL_BALL_Z).contains(&ball_position.z) {
208 return None;
209 }
210
211 let horizontal_gap = player_position
212 .truncate()
213 .distance(ball_position.truncate());
214 if horizontal_gap > FLICK_MAX_CONTROL_HORIZONTAL_GAP {
215 return None;
216 }
217
218 let vertical_gap = ball_position.z - player_position.z;
219 if !(FLICK_MIN_CONTROL_VERTICAL_GAP..=FLICK_MAX_CONTROL_VERTICAL_GAP)
220 .contains(&vertical_gap)
221 {
222 return None;
223 }
224
225 let local_ball_position =
226 quat_to_glam(&player_rigid_body.rotation).inverse() * (ball_position - player_position);
227 if local_ball_position.x < -FLICK_MAX_LOCAL_X_BEHIND
228 || local_ball_position.x > FLICK_MAX_LOCAL_X_FRONT
229 || local_ball_position.y.abs() > FLICK_MAX_LOCAL_Y
230 || local_ball_position.z < FLICK_MIN_LOCAL_Z
231 {
232 return None;
233 }
234
235 Some(FlickControlObservation {
236 horizontal_gap,
237 vertical_gap,
238 })
239 }
240
241 fn setup_summary(setup: &ActiveFlickSetup) -> FlickSetupSummary {
242 FlickSetupSummary {
243 is_team_0: setup.is_team_0,
244 start_time: setup.start_time,
245 start_frame: setup.start_frame,
246 last_time: setup.last_time,
247 last_frame: setup.last_frame,
248 duration: setup.duration,
249 average_horizontal_gap: setup.horizontal_gap_integral
250 / setup.duration.max(f32::EPSILON),
251 average_vertical_gap: setup.vertical_gap_integral / setup.duration.max(f32::EPSILON),
252 touch_count: setup.touch_count,
253 }
254 }
255
256 fn setup_qualifies(setup: &FlickSetupSummary) -> bool {
257 setup.duration >= FLICK_MIN_SETUP_SECONDS || setup.touch_count >= FLICK_MIN_SETUP_TOUCHES
258 }
259
260 fn store_recent_setup(&mut self, player_id: PlayerId, setup: FlickSetupSummary) {
261 if Self::setup_qualifies(&setup) {
262 self.recent_setups.insert(player_id, setup);
263 }
264 }
265
266 fn finish_setup(&mut self, player_id: &PlayerId) {
267 let Some(setup) = self.active_setups.remove(player_id) else {
268 return;
269 };
270 self.store_recent_setup(player_id.clone(), Self::setup_summary(&setup));
271 }
272
273 fn recent_setup_for_player(
274 &self,
275 player_id: &PlayerId,
276 current_time: f32,
277 ) -> Option<FlickSetupSummary> {
278 if let Some(active) = self.active_setups.get(player_id) {
279 return Some(Self::setup_summary(active));
280 }
281
282 self.recent_setups
283 .get(player_id)
284 .filter(|setup| current_time - setup.last_time <= FLICK_MAX_SETUP_STALE_SECONDS)
285 .cloned()
286 }
287
288 fn update_control_setups(
289 &mut self,
290 frame: &FrameInfo,
291 ball: &BallFrameState,
292 players: &PlayerFrameState,
293 touch_events: &[TouchEvent],
294 controlling_player: Option<&PlayerId>,
295 ) {
296 let Some(ball) = ball.sample() else {
297 let player_ids: Vec<_> = self.active_setups.keys().cloned().collect();
298 for player_id in player_ids {
299 self.finish_setup(&player_id);
300 }
301 return;
302 };
303
304 let mut observed_players = HashSet::new();
305 for player in &players.players {
306 let Some(observation) = Self::control_observation(ball, player, controlling_player)
307 else {
308 continue;
309 };
310 observed_players.insert(player.player_id.clone());
311 let setup = self
312 .active_setups
313 .entry(player.player_id.clone())
314 .or_insert_with(|| ActiveFlickSetup {
315 is_team_0: player.is_team_0,
316 start_time: (frame.time - frame.dt).max(0.0),
317 start_frame: frame.frame_number.saturating_sub(1),
318 last_time: frame.time,
319 last_frame: frame.frame_number,
320 duration: frame.dt.max(0.0),
321 horizontal_gap_integral: observation.horizontal_gap * frame.dt.max(0.0),
322 vertical_gap_integral: observation.vertical_gap * frame.dt.max(0.0),
323 touch_count: 0,
324 });
325
326 if setup.last_frame != frame.frame_number {
327 setup.last_time = frame.time;
328 setup.last_frame = frame.frame_number;
329 setup.duration += frame.dt.max(0.0);
330 setup.horizontal_gap_integral += observation.horizontal_gap * frame.dt.max(0.0);
331 setup.vertical_gap_integral += observation.vertical_gap * frame.dt.max(0.0);
332 }
333 }
334
335 for touch_event in touch_events {
336 let Some(player_id) = touch_event.player.as_ref() else {
337 continue;
338 };
339 if let Some(setup) = self.active_setups.get_mut(player_id) {
340 setup.touch_count += 1;
341 }
342 }
343
344 let active_ids: Vec<_> = self.active_setups.keys().cloned().collect();
345 for player_id in active_ids {
346 if !observed_players.contains(&player_id) {
347 self.finish_setup(&player_id);
348 }
349 }
350 }
351
352 fn track_dodge_starts(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
353 for player in &players.players {
354 let was_dodge_active = self
355 .previous_dodge_active
356 .insert(player.player_id.clone(), player.dodge_active)
357 .unwrap_or(false);
358 if !player.dodge_active || was_dodge_active {
359 continue;
360 }
361
362 let Some(setup) = self.recent_setup_for_player(&player.player_id, frame.time) else {
363 continue;
364 };
365 if !Self::setup_qualifies(&setup) {
366 continue;
367 }
368
369 self.recent_dodge_starts.insert(
370 player.player_id.clone(),
371 RecentDodgeStart {
372 time: frame.time,
373 frame: frame.frame_number,
374 setup,
375 },
376 );
377 }
378 }
379
380 fn prune_recent_state(&mut self, current_time: f32) {
381 self.recent_setups
382 .retain(|_, setup| current_time - setup.last_time <= FLICK_MAX_SETUP_STALE_SECONDS);
383 self.recent_dodge_starts
384 .retain(|_, dodge| current_time - dodge.time <= FLICK_MAX_DODGE_TO_TOUCH_SECONDS);
385 }
386
387 fn candidate_event(
388 &self,
389 ball: &BallFrameState,
390 player: &PlayerSample,
391 touch_event: &TouchEvent,
392 dodge_start: &RecentDodgeStart,
393 ball_impulse: glam::Vec3,
394 ) -> Option<FlickEvent> {
395 let ball = ball.sample()?;
396 let player_position = player.position()?;
397 let time_since_dodge = touch_event.time - dodge_start.time;
398 if !(0.0..=FLICK_MAX_DODGE_TO_TOUCH_SECONDS).contains(&time_since_dodge) {
399 return None;
400 }
401
402 let ball_speed_change = ball_impulse.length();
403 if ball_speed_change < FLICK_MIN_BALL_SPEED_CHANGE {
404 return None;
405 }
406
407 let to_ball = (ball.position() - player_position).normalize_or_zero();
408 let impulse_direction = ball_impulse.normalize_or_zero();
409 if to_ball.length_squared() <= f32::EPSILON
410 || impulse_direction.length_squared() <= f32::EPSILON
411 {
412 return None;
413 }
414
415 let impulse_away_alignment = impulse_direction.dot(to_ball);
416 if impulse_away_alignment < FLICK_MIN_IMPULSE_AWAY_ALIGNMENT {
417 return None;
418 }
419
420 let vertical_impulse = ball_impulse.z.max(0.0);
421 let setup = &dodge_start.setup;
422 let timing_score =
423 1.0 - (time_since_dodge / FLICK_MAX_DODGE_TO_TOUCH_SECONDS).clamp(0.0, 1.0);
424 let setup_duration_score =
425 Self::normalize_score(setup.duration, FLICK_MIN_SETUP_SECONDS, 0.75);
426 let touch_score =
427 (setup.touch_count as f32 / FLICK_MIN_SETUP_TOUCHES as f32).clamp(0.0, 1.0);
428 let horizontal_control_score =
429 1.0 - (setup.average_horizontal_gap / FLICK_MAX_CONTROL_HORIZONTAL_GAP).clamp(0.0, 1.0);
430 let vertical_control_score = 1.0
431 - ((setup.average_vertical_gap - 110.0).abs() / FLICK_MAX_CONTROL_VERTICAL_GAP)
432 .clamp(0.0, 1.0);
433 let impulse_score =
434 Self::normalize_score(ball_speed_change, FLICK_MIN_BALL_SPEED_CHANGE, 1450.0);
435 let away_score = Self::normalize_score(
436 impulse_away_alignment,
437 FLICK_MIN_IMPULSE_AWAY_ALIGNMENT,
438 0.85,
439 );
440 let vertical_score = Self::normalize_score(vertical_impulse, 100.0, 750.0);
441
442 let confidence = 0.16 * timing_score
443 + 0.19 * setup_duration_score.max(touch_score)
444 + 0.12 * horizontal_control_score
445 + 0.10 * vertical_control_score
446 + 0.22 * impulse_score
447 + 0.15 * away_score
448 + 0.06 * vertical_score;
449 if confidence < FLICK_MIN_CONFIDENCE {
450 return None;
451 }
452
453 Some(FlickEvent {
454 time: touch_event.time,
455 frame: touch_event.frame,
456 player: player.player_id.clone(),
457 is_team_0: player.is_team_0,
458 dodge_time: dodge_start.time,
459 dodge_frame: dodge_start.frame,
460 time_since_dodge,
461 setup_start_time: setup.start_time,
462 setup_start_frame: setup.start_frame,
463 setup_duration: setup.duration,
464 setup_touch_count: setup.touch_count,
465 average_horizontal_gap: setup.average_horizontal_gap,
466 average_vertical_gap: setup.average_vertical_gap,
467 ball_speed_change,
468 ball_impulse: ball_impulse.to_array(),
469 impulse_away_alignment,
470 vertical_impulse,
471 confidence,
472 })
473 }
474
475 fn apply_event(&mut self, frame: &FrameInfo, event: FlickEvent) {
476 let stats = self.player_stats.entry(event.player.clone()).or_default();
477 stats.count += 1;
478 if event.confidence >= FLICK_HIGH_CONFIDENCE {
479 stats.high_confidence_count += 1;
480 }
481 stats.is_last_flick = true;
482 stats.last_flick_time = Some(event.time);
483 stats.last_flick_frame = Some(event.frame);
484 stats.time_since_last_flick = Some((frame.time - event.time).max(0.0));
485 stats.frames_since_last_flick = Some(frame.frame_number.saturating_sub(event.frame));
486 stats.last_confidence = Some(event.confidence);
487 stats.best_confidence = stats.best_confidence.max(event.confidence);
488 stats.cumulative_confidence += event.confidence;
489 stats.cumulative_setup_duration += event.setup_duration;
490 stats.cumulative_ball_speed_change += event.ball_speed_change;
491
492 self.current_last_flick_player = Some(event.player.clone());
493 self.events.push(event);
494 }
495
496 fn apply_touch_events(
497 &mut self,
498 frame: &FrameInfo,
499 ball: &BallFrameState,
500 players: &PlayerFrameState,
501 touch_events: &[TouchEvent],
502 ) {
503 let ball_impulse = Self::ball_impulse(frame, ball, self.previous_ball_velocity);
504
505 for touch_event in touch_events {
506 let Some(player_id) = touch_event.player.as_ref() else {
507 continue;
508 };
509 let Some(player) = players
510 .players
511 .iter()
512 .find(|player| &player.player_id == player_id)
513 else {
514 continue;
515 };
516 let Some(dodge_start) = self.recent_dodge_starts.get(player_id) else {
517 continue;
518 };
519 let Some(event) =
520 self.candidate_event(ball, player, touch_event, dodge_start, ball_impulse)
521 else {
522 continue;
523 };
524
525 self.apply_event(frame, event);
526 }
527
528 if let Some(player_id) = self.current_last_flick_player.as_ref() {
529 if let Some(stats) = self.player_stats.get_mut(player_id) {
530 stats.is_last_flick = true;
531 }
532 }
533 }
534
535 fn reset_live_play_state(&mut self, ball: &BallFrameState) {
536 self.current_last_flick_player = None;
537 self.active_setups.clear();
538 self.recent_setups.clear();
539 self.recent_dodge_starts.clear();
540 self.previous_dodge_active.clear();
541 self.previous_ball_velocity = ball.velocity();
542 }
543
544 pub fn update(
545 &mut self,
546 frame: &FrameInfo,
547 ball: &BallFrameState,
548 players: &PlayerFrameState,
549 touch_state: &TouchState,
550 live_play_state: &LivePlayState,
551 ) -> SubtrActorResult<()> {
552 if !live_play_state.is_live_play {
553 self.reset_live_play_state(ball);
554 return Ok(());
555 }
556
557 self.begin_sample(frame);
558 self.prune_recent_state(frame.time);
559 self.update_control_setups(
560 frame,
561 ball,
562 players,
563 &touch_state.touch_events,
564 touch_state.last_touch_player.as_ref(),
565 );
566 self.track_dodge_starts(frame, players);
567 self.apply_touch_events(frame, ball, players, &touch_state.touch_events);
568 self.previous_ball_velocity = ball.velocity();
569 Ok(())
570 }
571}
572
573#[cfg(test)]
574#[path = "flick_tests.rs"]
575mod tests;