Skip to main content

subtr_actor/stats/calculators/
center.rs

1use super::*;
2
3const CENTER_MAX_DURATION_SECONDS: f32 = 3.0;
4const CENTER_MIN_BALL_TRAVEL_DISTANCE: f32 = 500.0;
5const CENTER_MIN_LATERAL_DISTANCE: f32 = 500.0;
6const CENTER_MIN_START_ABS_X: f32 = 1600.0;
7const CENTER_MAX_END_ABS_X: f32 = 1400.0;
8const CENTER_MIN_START_ATTACKING_Y: f32 = BOOST_PAD_MIDFIELD_TOLERANCE_Y;
9const CENTER_MIN_END_ATTACKING_Y: f32 = FIELD_ZONE_BOUNDARY_Y;
10
11#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
12#[ts(export)]
13pub struct CenterEvent {
14    pub time: f32,
15    pub frame: usize,
16    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
17    pub player: PlayerId,
18    pub is_team_0: bool,
19    pub start_time: f32,
20    pub start_frame: usize,
21    pub duration: f32,
22    pub start_ball_position: [f32; 3],
23    pub end_ball_position: [f32; 3],
24    pub ball_travel_distance: f32,
25    pub ball_advance_distance: f32,
26    pub lateral_centering_distance: f32,
27}
28
29#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
30#[ts(export)]
31pub struct CenterPlayerStats {
32    pub count: u32,
33    pub total_ball_travel_distance: f32,
34    pub total_ball_advance_distance: f32,
35    pub total_lateral_centering_distance: f32,
36    pub longest_center_distance: f32,
37    pub is_last_center: bool,
38    pub last_center_time: Option<f32>,
39    pub last_center_frame: Option<usize>,
40    pub time_since_last_center: Option<f32>,
41    pub frames_since_last_center: Option<usize>,
42}
43
44impl CenterPlayerStats {
45    pub fn average_ball_travel_distance(&self) -> f32 {
46        if self.count == 0 {
47            0.0
48        } else {
49            self.total_ball_travel_distance / self.count as f32
50        }
51    }
52
53    pub fn average_ball_advance_distance(&self) -> f32 {
54        if self.count == 0 {
55            0.0
56        } else {
57            self.total_ball_advance_distance / self.count as f32
58        }
59    }
60
61    pub fn average_lateral_centering_distance(&self) -> f32 {
62        if self.count == 0 {
63            0.0
64        } else {
65            self.total_lateral_centering_distance / self.count as f32
66        }
67    }
68}
69
70#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
71#[ts(export)]
72pub struct CenterTeamStats {
73    pub count: u32,
74    pub total_ball_travel_distance: f32,
75    pub total_ball_advance_distance: f32,
76    pub total_lateral_centering_distance: f32,
77    pub longest_center_distance: f32,
78}
79
80impl CenterTeamStats {
81    pub fn average_ball_travel_distance(&self) -> f32 {
82        if self.count == 0 {
83            0.0
84        } else {
85            self.total_ball_travel_distance / self.count as f32
86        }
87    }
88
89    pub fn average_ball_advance_distance(&self) -> f32 {
90        if self.count == 0 {
91            0.0
92        } else {
93            self.total_ball_advance_distance / self.count as f32
94        }
95    }
96
97    pub fn average_lateral_centering_distance(&self) -> f32 {
98        if self.count == 0 {
99            0.0
100        } else {
101            self.total_lateral_centering_distance / self.count as f32
102        }
103    }
104}
105
106#[derive(Debug, Clone)]
107struct PendingCenterTouch {
108    player: PlayerId,
109    is_team_0: bool,
110    time: f32,
111    frame: usize,
112    ball_position: glam::Vec3,
113}
114
115#[derive(Debug, Clone, Default)]
116pub struct CenterCalculator {
117    player_stats: HashMap<PlayerId, CenterPlayerStats>,
118    team_zero_stats: CenterTeamStats,
119    team_one_stats: CenterTeamStats,
120    events: Vec<CenterEvent>,
121    pending_touch: Option<PendingCenterTouch>,
122    current_last_center_player: Option<PlayerId>,
123}
124
125impl CenterCalculator {
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    pub fn player_stats(&self) -> &HashMap<PlayerId, CenterPlayerStats> {
131        &self.player_stats
132    }
133
134    pub fn team_zero_stats(&self) -> &CenterTeamStats {
135        &self.team_zero_stats
136    }
137
138    pub fn team_one_stats(&self) -> &CenterTeamStats {
139        &self.team_one_stats
140    }
141
142    pub fn events(&self) -> &[CenterEvent] {
143        &self.events
144    }
145
146    fn begin_sample(&mut self, frame: &FrameInfo) {
147        for stats in self.player_stats.values_mut() {
148            stats.is_last_center = false;
149            stats.time_since_last_center = stats
150                .last_center_time
151                .map(|time| (frame.time - time).max(0.0));
152            stats.frames_since_last_center = stats
153                .last_center_frame
154                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
155        }
156    }
157
158    fn center_event_for_position(
159        pending: &PendingCenterTouch,
160        frame: &FrameInfo,
161        ball_position: glam::Vec3,
162    ) -> Option<CenterEvent> {
163        let duration = frame.time - pending.time;
164        if !(0.0..=CENTER_MAX_DURATION_SECONDS).contains(&duration) {
165            return None;
166        }
167
168        let start_normalized_y = normalized_y(pending.is_team_0, pending.ball_position);
169        let end_normalized_y = normalized_y(pending.is_team_0, ball_position);
170        if start_normalized_y < CENTER_MIN_START_ATTACKING_Y
171            || end_normalized_y < CENTER_MIN_END_ATTACKING_Y
172        {
173            return None;
174        }
175
176        let start_abs_x = pending.ball_position.x.abs();
177        let end_abs_x = ball_position.x.abs();
178        let lateral_centering_distance = start_abs_x - end_abs_x;
179        if start_abs_x < CENTER_MIN_START_ABS_X
180            || end_abs_x > CENTER_MAX_END_ABS_X
181            || lateral_centering_distance < CENTER_MIN_LATERAL_DISTANCE
182        {
183            return None;
184        }
185
186        let ball_delta = ball_position - pending.ball_position;
187        let ball_travel_distance = ball_delta.length();
188        if ball_travel_distance < CENTER_MIN_BALL_TRAVEL_DISTANCE {
189            return None;
190        }
191
192        let team_forward_sign = if pending.is_team_0 { 1.0 } else { -1.0 };
193        Some(CenterEvent {
194            time: frame.time,
195            frame: frame.frame_number,
196            player: pending.player.clone(),
197            is_team_0: pending.is_team_0,
198            start_time: pending.time,
199            start_frame: pending.frame,
200            duration,
201            start_ball_position: pending.ball_position.to_array(),
202            end_ball_position: ball_position.to_array(),
203            ball_travel_distance,
204            ball_advance_distance: ball_delta.y * team_forward_sign,
205            lateral_centering_distance,
206        })
207    }
208
209    fn record_center(&mut self, frame: &FrameInfo, event: CenterEvent) {
210        let player_stats = self.player_stats.entry(event.player.clone()).or_default();
211        player_stats.count += 1;
212        player_stats.total_ball_travel_distance += event.ball_travel_distance;
213        player_stats.total_ball_advance_distance += event.ball_advance_distance;
214        player_stats.total_lateral_centering_distance += event.lateral_centering_distance;
215        player_stats.longest_center_distance = player_stats
216            .longest_center_distance
217            .max(event.ball_travel_distance);
218        player_stats.last_center_time = Some(event.time);
219        player_stats.last_center_frame = Some(event.frame);
220        player_stats.time_since_last_center = Some((frame.time - event.time).max(0.0));
221        player_stats.frames_since_last_center =
222            Some(frame.frame_number.saturating_sub(event.frame));
223
224        let team_stats = if event.is_team_0 {
225            &mut self.team_zero_stats
226        } else {
227            &mut self.team_one_stats
228        };
229        team_stats.count += 1;
230        team_stats.total_ball_travel_distance += event.ball_travel_distance;
231        team_stats.total_ball_advance_distance += event.ball_advance_distance;
232        team_stats.total_lateral_centering_distance += event.lateral_centering_distance;
233        team_stats.longest_center_distance = team_stats
234            .longest_center_distance
235            .max(event.ball_travel_distance);
236
237        self.current_last_center_player = Some(event.player.clone());
238        self.events.push(event);
239        self.pending_touch = None;
240    }
241
242    fn update_pending_center(&mut self, frame: &FrameInfo, ball_position: glam::Vec3) {
243        let Some(pending) = self.pending_touch.as_ref() else {
244            return;
245        };
246        let duration = frame.time - pending.time;
247        if duration > CENTER_MAX_DURATION_SECONDS {
248            self.pending_touch = None;
249            return;
250        }
251
252        if let Some(event) = Self::center_event_for_position(pending, frame, ball_position) {
253            self.record_center(frame, event);
254        }
255    }
256
257    pub fn update(
258        &mut self,
259        frame: &FrameInfo,
260        ball: &BallFrameState,
261        touch_state: &TouchState,
262        live_play: bool,
263    ) -> SubtrActorResult<()> {
264        self.begin_sample(frame);
265        if !live_play {
266            self.pending_touch = None;
267            self.current_last_center_player = None;
268            return Ok(());
269        }
270
271        let Some(ball_position) = ball.position() else {
272            return Ok(());
273        };
274
275        self.update_pending_center(frame, ball_position);
276
277        for touch in &touch_state.touch_events {
278            let Some(player) = touch.player.clone() else {
279                self.pending_touch = None;
280                continue;
281            };
282
283            self.pending_touch = Some(PendingCenterTouch {
284                player,
285                is_team_0: touch.team_is_team_0,
286                time: touch.time,
287                frame: touch.frame,
288                ball_position,
289            });
290        }
291
292        if let Some(player_id) = self.current_last_center_player.as_ref() {
293            if let Some(stats) = self.player_stats.get_mut(player_id) {
294                stats.is_last_center = true;
295            }
296        }
297
298        Ok(())
299    }
300}
301
302#[cfg(test)]
303#[path = "center_tests.rs"]
304mod tests;