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