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