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 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;