Skip to main content

subtr_actor/stats/calculators/
half_volley.rs

1use super::*;
2
3const DEFAULT_HALF_VOLLEY_MAX_BOUNCE_TO_TOUCH_SECONDS: f32 = 0.45;
4const DEFAULT_HALF_VOLLEY_MIN_BALL_SPEED: f32 = 1000.0;
5const HALF_VOLLEY_FLOOR_BOUNCE_MAX_BALL_Z: f32 = BALL_RADIUS_Z + 45.0;
6const HALF_VOLLEY_FLOOR_BOUNCE_MIN_APPROACH_SPEED_Z: f32 = 250.0;
7const HALF_VOLLEY_FLOOR_BOUNCE_MIN_REBOUND_SPEED_Z: f32 = 150.0;
8const HALF_VOLLEY_MAX_DODGE_TO_TOUCH_SECONDS: f32 = 0.35;
9const HALF_VOLLEY_MAX_GROUND_TO_DODGE_SECONDS: f32 = 0.45;
10const HALF_VOLLEY_GOAL_CENTER_Y: f32 = 5120.0;
11
12#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, ts_rs::TS)]
13#[ts(export)]
14pub struct HalfVolleyCalculatorConfig {
15    pub max_bounce_to_touch_seconds: f32,
16    pub min_ball_speed: f32,
17}
18
19impl Default for HalfVolleyCalculatorConfig {
20    fn default() -> Self {
21        Self {
22            max_bounce_to_touch_seconds: DEFAULT_HALF_VOLLEY_MAX_BOUNCE_TO_TOUCH_SECONDS,
23            min_ball_speed: DEFAULT_HALF_VOLLEY_MIN_BALL_SPEED,
24        }
25    }
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
29#[ts(export)]
30pub struct HalfVolleyEvent {
31    pub time: f32,
32    pub frame: usize,
33    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
34    pub player: PlayerId,
35    pub is_team_0: bool,
36    pub bounce_time: f32,
37    pub bounce_frame: usize,
38    pub bounce_to_touch_seconds: f32,
39    pub ball_speed: f32,
40    pub goal_alignment: f32,
41}
42
43#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
44#[ts(export)]
45pub struct HalfVolleyPlayerStats {
46    pub count: u32,
47    pub total_ball_speed: f32,
48    pub fastest_ball_speed: f32,
49    pub is_last_half_volley: bool,
50    pub last_half_volley_time: Option<f32>,
51    pub last_half_volley_frame: Option<usize>,
52    pub time_since_last_half_volley: Option<f32>,
53    pub frames_since_last_half_volley: Option<usize>,
54}
55
56impl HalfVolleyPlayerStats {
57    pub fn average_ball_speed(&self) -> f32 {
58        if self.count == 0 {
59            0.0
60        } else {
61            self.total_ball_speed / self.count as f32
62        }
63    }
64}
65
66#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
67#[ts(export)]
68pub struct HalfVolleyTeamStats {
69    pub count: u32,
70    pub total_ball_speed: f32,
71    pub fastest_ball_speed: f32,
72}
73
74impl HalfVolleyTeamStats {
75    pub fn average_ball_speed(&self) -> f32 {
76        if self.count == 0 {
77            0.0
78        } else {
79            self.total_ball_speed / self.count as f32
80        }
81    }
82}
83
84#[derive(Debug, Clone, PartialEq)]
85struct FloorBounce {
86    time: f32,
87    frame: usize,
88}
89
90#[derive(Debug, Clone, PartialEq)]
91struct GroundContact {
92    time: f32,
93}
94
95#[derive(Debug, Clone, PartialEq)]
96struct DodgeStart {
97    time: f32,
98    ground_contact: GroundContact,
99}
100
101#[derive(Debug, Clone, Default)]
102pub struct HalfVolleyCalculator {
103    config: HalfVolleyCalculatorConfig,
104    player_stats: HashMap<PlayerId, HalfVolleyPlayerStats>,
105    team_zero_stats: HalfVolleyTeamStats,
106    team_one_stats: HalfVolleyTeamStats,
107    events: Vec<HalfVolleyEvent>,
108    last_floor_bounce: Option<FloorBounce>,
109    last_ground_contacts: HashMap<PlayerId, GroundContact>,
110    recent_dodge_starts: HashMap<PlayerId, DodgeStart>,
111    previous_dodge_active: HashMap<PlayerId, bool>,
112    previous_ball_velocity: Option<glam::Vec3>,
113    current_last_half_volley_player: Option<PlayerId>,
114}
115
116impl HalfVolleyCalculator {
117    pub fn new() -> Self {
118        Self::with_config(HalfVolleyCalculatorConfig::default())
119    }
120
121    pub fn with_config(config: HalfVolleyCalculatorConfig) -> Self {
122        Self {
123            config,
124            ..Self::default()
125        }
126    }
127
128    pub fn config(&self) -> &HalfVolleyCalculatorConfig {
129        &self.config
130    }
131
132    pub fn player_stats(&self) -> &HashMap<PlayerId, HalfVolleyPlayerStats> {
133        &self.player_stats
134    }
135
136    pub fn team_zero_stats(&self) -> &HalfVolleyTeamStats {
137        &self.team_zero_stats
138    }
139
140    pub fn team_one_stats(&self) -> &HalfVolleyTeamStats {
141        &self.team_one_stats
142    }
143
144    pub fn events(&self) -> &[HalfVolleyEvent] {
145        &self.events
146    }
147
148    fn begin_sample(&mut self, frame: &FrameInfo) {
149        for stats in self.player_stats.values_mut() {
150            stats.is_last_half_volley = false;
151            stats.time_since_last_half_volley = stats
152                .last_half_volley_time
153                .map(|time| (frame.time - time).max(0.0));
154            stats.frames_since_last_half_volley = stats
155                .last_half_volley_frame
156                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
157        }
158    }
159
160    fn detect_floor_bounce(
161        frame: &FrameInfo,
162        ball: Option<&BallSample>,
163        previous_ball_velocity: Option<glam::Vec3>,
164        touch_events: &[TouchEvent],
165    ) -> Option<FloorBounce> {
166        if !touch_events.is_empty() {
167            return None;
168        }
169        let ball = ball?;
170        let previous_ball_velocity = previous_ball_velocity?;
171        let ball_position = ball.position();
172        let ball_velocity = ball.velocity();
173        if ball_position.z > HALF_VOLLEY_FLOOR_BOUNCE_MAX_BALL_Z {
174            return None;
175        }
176        if previous_ball_velocity.z > -HALF_VOLLEY_FLOOR_BOUNCE_MIN_APPROACH_SPEED_Z {
177            return None;
178        }
179        if ball_velocity.z < HALF_VOLLEY_FLOOR_BOUNCE_MIN_REBOUND_SPEED_Z {
180            return None;
181        }
182
183        Some(FloorBounce {
184            time: frame.time,
185            frame: frame.frame_number,
186        })
187    }
188
189    fn event_for_touch(
190        &self,
191        ball: &BallFrameState,
192        touch: &TouchEvent,
193    ) -> Option<HalfVolleyEvent> {
194        let player = touch.player.clone()?;
195        let bounce = self.last_floor_bounce.as_ref()?;
196        let bounce_to_touch_seconds = touch.time - bounce.time;
197        if !(0.0..=self.config.max_bounce_to_touch_seconds).contains(&bounce_to_touch_seconds) {
198            return None;
199        }
200        let dodge_start = self.recent_dodge_starts.get(&player)?;
201        let dodge_to_touch_seconds = touch.time - dodge_start.time;
202        if !(0.0..=HALF_VOLLEY_MAX_DODGE_TO_TOUCH_SECONDS).contains(&dodge_to_touch_seconds) {
203            return None;
204        }
205        let ground_to_dodge_seconds = dodge_start.time - dodge_start.ground_contact.time;
206        if !(0.0..=HALF_VOLLEY_MAX_GROUND_TO_DODGE_SECONDS).contains(&ground_to_dodge_seconds) {
207            return None;
208        }
209
210        let ball = ball.sample()?;
211        let ball_position = ball.position();
212        let ball_velocity = ball.velocity();
213        let ball_speed = ball_velocity.length();
214        if ball_speed < self.config.min_ball_speed {
215            return None;
216        }
217
218        let target_y = if touch.team_is_team_0 {
219            HALF_VOLLEY_GOAL_CENTER_Y
220        } else {
221            -HALF_VOLLEY_GOAL_CENTER_Y
222        };
223        let goal_direction = glam::Vec3::new(0.0, target_y, ball_position.z) - ball_position;
224        let goal_alignment = goal_direction
225            .normalize_or_zero()
226            .dot(ball_velocity.normalize_or_zero());
227
228        Some(HalfVolleyEvent {
229            time: touch.time,
230            frame: touch.frame,
231            player,
232            is_team_0: touch.team_is_team_0,
233            bounce_time: bounce.time,
234            bounce_frame: bounce.frame,
235            bounce_to_touch_seconds,
236            ball_speed,
237            goal_alignment,
238        })
239    }
240
241    fn record_half_volley(&mut self, frame: &FrameInfo, event: HalfVolleyEvent) {
242        let player_stats = self.player_stats.entry(event.player.clone()).or_default();
243        player_stats.count += 1;
244        player_stats.total_ball_speed += event.ball_speed;
245        player_stats.fastest_ball_speed = player_stats.fastest_ball_speed.max(event.ball_speed);
246        player_stats.last_half_volley_time = Some(event.time);
247        player_stats.last_half_volley_frame = Some(event.frame);
248        player_stats.time_since_last_half_volley = Some((frame.time - event.time).max(0.0));
249        player_stats.frames_since_last_half_volley =
250            Some(frame.frame_number.saturating_sub(event.frame));
251
252        let team_stats = if event.is_team_0 {
253            &mut self.team_zero_stats
254        } else {
255            &mut self.team_one_stats
256        };
257        team_stats.count += 1;
258        team_stats.total_ball_speed += event.ball_speed;
259        team_stats.fastest_ball_speed = team_stats.fastest_ball_speed.max(event.ball_speed);
260
261        self.current_last_half_volley_player = Some(event.player.clone());
262        self.events.push(event);
263    }
264
265    fn update_player_movement_state(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
266        for player in &players.players {
267            if player
268                .position()
269                .is_some_and(|position| position.z <= PLAYER_GROUND_Z_THRESHOLD)
270            {
271                self.last_ground_contacts
272                    .insert(player.player_id.clone(), GroundContact { time: frame.time });
273            }
274
275            let was_dodge_active = self
276                .previous_dodge_active
277                .insert(player.player_id.clone(), player.dodge_active)
278                .unwrap_or(false);
279            if !player.dodge_active || was_dodge_active {
280                continue;
281            }
282
283            if let Some(ground_contact) = self.last_ground_contacts.get(&player.player_id) {
284                self.recent_dodge_starts.insert(
285                    player.player_id.clone(),
286                    DodgeStart {
287                        time: frame.time,
288                        ground_contact: ground_contact.clone(),
289                    },
290                );
291            }
292        }
293
294        self.recent_dodge_starts.retain(|_, dodge_start| {
295            frame.time - dodge_start.time <= HALF_VOLLEY_MAX_DODGE_TO_TOUCH_SECONDS
296        });
297        self.last_ground_contacts.retain(|_, ground_contact| {
298            frame.time - ground_contact.time
299                <= HALF_VOLLEY_MAX_GROUND_TO_DODGE_SECONDS + HALF_VOLLEY_MAX_DODGE_TO_TOUCH_SECONDS
300        });
301    }
302
303    pub fn update(
304        &mut self,
305        frame: &FrameInfo,
306        ball: &BallFrameState,
307        players: &PlayerFrameState,
308        touch_state: &TouchState,
309        live_play: bool,
310    ) -> SubtrActorResult<()> {
311        self.begin_sample(frame);
312        if !live_play {
313            self.last_floor_bounce = None;
314            self.last_ground_contacts.clear();
315            self.recent_dodge_starts.clear();
316            self.previous_dodge_active.clear();
317            self.previous_ball_velocity = ball.velocity();
318            self.current_last_half_volley_player = None;
319            return Ok(());
320        }
321
322        self.update_player_movement_state(frame, players);
323
324        if let Some(bounce) = Self::detect_floor_bounce(
325            frame,
326            ball.sample(),
327            self.previous_ball_velocity,
328            &touch_state.touch_events,
329        ) {
330            self.last_floor_bounce = Some(bounce);
331        }
332
333        for touch in &touch_state.touch_events {
334            if let Some(event) = self.event_for_touch(ball, touch) {
335                self.record_half_volley(frame, event);
336            }
337        }
338
339        self.previous_ball_velocity = ball.velocity();
340        if let Some(player_id) = self.current_last_half_volley_player.as_ref() {
341            if let Some(stats) = self.player_stats.get_mut(player_id) {
342                stats.is_last_half_volley = true;
343            }
344        }
345
346        Ok(())
347    }
348}
349
350#[cfg(test)]
351#[path = "half_volley_tests.rs"]
352mod tests;