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_GOAL_CENTER_Y: f32 = 5120.0;
9
10#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, ts_rs::TS)]
11#[ts(export)]
12pub struct HalfVolleyCalculatorConfig {
13    pub max_bounce_to_touch_seconds: f32,
14    pub min_ball_speed: f32,
15}
16
17impl Default for HalfVolleyCalculatorConfig {
18    fn default() -> Self {
19        Self {
20            max_bounce_to_touch_seconds: DEFAULT_HALF_VOLLEY_MAX_BOUNCE_TO_TOUCH_SECONDS,
21            min_ball_speed: DEFAULT_HALF_VOLLEY_MIN_BALL_SPEED,
22        }
23    }
24}
25
26#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
27#[ts(export)]
28pub struct HalfVolleyEvent {
29    pub time: f32,
30    pub frame: usize,
31    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
32    pub player: PlayerId,
33    pub is_team_0: bool,
34    pub bounce_time: f32,
35    pub bounce_frame: usize,
36    pub bounce_to_touch_seconds: f32,
37    pub ball_speed: f32,
38    pub goal_alignment: f32,
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
42#[ts(export)]
43pub struct HalfVolleyPlayerStats {
44    pub count: u32,
45    pub total_ball_speed: f32,
46    pub fastest_ball_speed: f32,
47    pub is_last_half_volley: bool,
48    pub last_half_volley_time: Option<f32>,
49    pub last_half_volley_frame: Option<usize>,
50    pub time_since_last_half_volley: Option<f32>,
51    pub frames_since_last_half_volley: Option<usize>,
52}
53
54impl HalfVolleyPlayerStats {
55    pub fn average_ball_speed(&self) -> f32 {
56        if self.count == 0 {
57            0.0
58        } else {
59            self.total_ball_speed / self.count as f32
60        }
61    }
62}
63
64#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
65#[ts(export)]
66pub struct HalfVolleyTeamStats {
67    pub count: u32,
68    pub total_ball_speed: f32,
69    pub fastest_ball_speed: f32,
70}
71
72impl HalfVolleyTeamStats {
73    pub fn average_ball_speed(&self) -> f32 {
74        if self.count == 0 {
75            0.0
76        } else {
77            self.total_ball_speed / self.count as f32
78        }
79    }
80}
81
82#[derive(Debug, Clone, PartialEq)]
83struct FloorBounce {
84    time: f32,
85    frame: usize,
86}
87
88#[derive(Debug, Clone, Default)]
89pub struct HalfVolleyCalculator {
90    config: HalfVolleyCalculatorConfig,
91    player_stats: HashMap<PlayerId, HalfVolleyPlayerStats>,
92    team_zero_stats: HalfVolleyTeamStats,
93    team_one_stats: HalfVolleyTeamStats,
94    events: Vec<HalfVolleyEvent>,
95    last_floor_bounce: Option<FloorBounce>,
96    previous_ball_velocity: Option<glam::Vec3>,
97    current_last_half_volley_player: Option<PlayerId>,
98}
99
100impl HalfVolleyCalculator {
101    pub fn new() -> Self {
102        Self::with_config(HalfVolleyCalculatorConfig::default())
103    }
104
105    pub fn with_config(config: HalfVolleyCalculatorConfig) -> Self {
106        Self {
107            config,
108            ..Self::default()
109        }
110    }
111
112    pub fn config(&self) -> &HalfVolleyCalculatorConfig {
113        &self.config
114    }
115
116    pub fn player_stats(&self) -> &HashMap<PlayerId, HalfVolleyPlayerStats> {
117        &self.player_stats
118    }
119
120    pub fn team_zero_stats(&self) -> &HalfVolleyTeamStats {
121        &self.team_zero_stats
122    }
123
124    pub fn team_one_stats(&self) -> &HalfVolleyTeamStats {
125        &self.team_one_stats
126    }
127
128    pub fn events(&self) -> &[HalfVolleyEvent] {
129        &self.events
130    }
131
132    fn begin_sample(&mut self, frame: &FrameInfo) {
133        for stats in self.player_stats.values_mut() {
134            stats.is_last_half_volley = false;
135            stats.time_since_last_half_volley = stats
136                .last_half_volley_time
137                .map(|time| (frame.time - time).max(0.0));
138            stats.frames_since_last_half_volley = stats
139                .last_half_volley_frame
140                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
141        }
142    }
143
144    fn detect_floor_bounce(
145        frame: &FrameInfo,
146        ball: Option<&BallSample>,
147        previous_ball_velocity: Option<glam::Vec3>,
148        touch_events: &[TouchEvent],
149    ) -> Option<FloorBounce> {
150        if !touch_events.is_empty() {
151            return None;
152        }
153        let ball = ball?;
154        let previous_ball_velocity = previous_ball_velocity?;
155        let ball_position = ball.position();
156        let ball_velocity = ball.velocity();
157        if ball_position.z > HALF_VOLLEY_FLOOR_BOUNCE_MAX_BALL_Z {
158            return None;
159        }
160        if previous_ball_velocity.z > -HALF_VOLLEY_FLOOR_BOUNCE_MIN_APPROACH_SPEED_Z {
161            return None;
162        }
163        if ball_velocity.z < HALF_VOLLEY_FLOOR_BOUNCE_MIN_REBOUND_SPEED_Z {
164            return None;
165        }
166
167        Some(FloorBounce {
168            time: frame.time,
169            frame: frame.frame_number,
170        })
171    }
172
173    fn event_for_touch(
174        &self,
175        ball: &BallFrameState,
176        touch: &TouchEvent,
177    ) -> Option<HalfVolleyEvent> {
178        let player = touch.player.clone()?;
179        let bounce = self.last_floor_bounce.as_ref()?;
180        let bounce_to_touch_seconds = touch.time - bounce.time;
181        if !(0.0..=self.config.max_bounce_to_touch_seconds).contains(&bounce_to_touch_seconds) {
182            return None;
183        }
184
185        let ball = ball.sample()?;
186        let ball_position = ball.position();
187        let ball_velocity = ball.velocity();
188        let ball_speed = ball_velocity.length();
189        if ball_speed < self.config.min_ball_speed {
190            return None;
191        }
192
193        let target_y = if touch.team_is_team_0 {
194            HALF_VOLLEY_GOAL_CENTER_Y
195        } else {
196            -HALF_VOLLEY_GOAL_CENTER_Y
197        };
198        let goal_direction = glam::Vec3::new(0.0, target_y, ball_position.z) - ball_position;
199        let goal_alignment = goal_direction
200            .normalize_or_zero()
201            .dot(ball_velocity.normalize_or_zero());
202
203        Some(HalfVolleyEvent {
204            time: touch.time,
205            frame: touch.frame,
206            player,
207            is_team_0: touch.team_is_team_0,
208            bounce_time: bounce.time,
209            bounce_frame: bounce.frame,
210            bounce_to_touch_seconds,
211            ball_speed,
212            goal_alignment,
213        })
214    }
215
216    fn record_half_volley(&mut self, frame: &FrameInfo, event: HalfVolleyEvent) {
217        let player_stats = self.player_stats.entry(event.player.clone()).or_default();
218        player_stats.count += 1;
219        player_stats.total_ball_speed += event.ball_speed;
220        player_stats.fastest_ball_speed = player_stats.fastest_ball_speed.max(event.ball_speed);
221        player_stats.last_half_volley_time = Some(event.time);
222        player_stats.last_half_volley_frame = Some(event.frame);
223        player_stats.time_since_last_half_volley = Some((frame.time - event.time).max(0.0));
224        player_stats.frames_since_last_half_volley =
225            Some(frame.frame_number.saturating_sub(event.frame));
226
227        let team_stats = if event.is_team_0 {
228            &mut self.team_zero_stats
229        } else {
230            &mut self.team_one_stats
231        };
232        team_stats.count += 1;
233        team_stats.total_ball_speed += event.ball_speed;
234        team_stats.fastest_ball_speed = team_stats.fastest_ball_speed.max(event.ball_speed);
235
236        self.current_last_half_volley_player = Some(event.player.clone());
237        self.events.push(event);
238    }
239
240    pub fn update(
241        &mut self,
242        frame: &FrameInfo,
243        ball: &BallFrameState,
244        touch_state: &TouchState,
245        live_play: bool,
246    ) -> SubtrActorResult<()> {
247        self.begin_sample(frame);
248        if !live_play {
249            self.last_floor_bounce = None;
250            self.previous_ball_velocity = ball.velocity();
251            self.current_last_half_volley_player = None;
252            return Ok(());
253        }
254
255        if let Some(bounce) = Self::detect_floor_bounce(
256            frame,
257            ball.sample(),
258            self.previous_ball_velocity,
259            &touch_state.touch_events,
260        ) {
261            self.last_floor_bounce = Some(bounce);
262        }
263
264        for touch in &touch_state.touch_events {
265            if let Some(event) = self.event_for_touch(ball, touch) {
266                self.record_half_volley(frame, event);
267            }
268        }
269
270        self.previous_ball_velocity = ball.velocity();
271        if let Some(player_id) = self.current_last_half_volley_player.as_ref() {
272            if let Some(stats) = self.player_stats.get_mut(player_id) {
273                stats.is_last_half_volley = true;
274            }
275        }
276
277        Ok(())
278    }
279}
280
281#[cfg(test)]
282#[path = "half_volley_tests.rs"]
283mod tests;