subtr_actor/stats/calculators/
half_volley.rs1use 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;