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_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/// Configuration thresholds for half-volley detection.
13#[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/// A fast touch shortly after the ball bounces off the floor, paired with a recent dodge.
30#[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/// Detects half-volleys from ball/player state and touches.
67#[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;