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