Skip to main content

subtr_actor/stats/calculators/
continuous_ball_control.rs

1use super::*;
2
3/// Per-frame state of an active continuous ball-control sequence.
4#[derive(Debug, Clone, Default, PartialEq)]
5pub struct ContinuousBallControlState {
6    pub completed_sequences: Vec<CompletedBallControlSequence<BallCarryKind>>,
7}
8
9/// A single sampled frame of a continuous ball-control sequence.
10#[derive(Debug, Clone, Copy)]
11pub struct ContinuousBallControlSample<K> {
12    pub kind: K,
13    pub player_position: glam::Vec3,
14    pub horizontal_gap: f32,
15    pub vertical_gap: f32,
16    pub speed: f32,
17}
18
19/// A candidate ball-control sequence being evaluated.
20#[derive(Debug, Clone)]
21pub struct ContinuousBallControlCandidate<K> {
22    pub player_id: PlayerId,
23    pub is_team_0: bool,
24    pub touch_count: u32,
25    pub air_touch_count: u32,
26    pub sample: ContinuousBallControlSample<K>,
27}
28
29/// Per-player status within continuous ball-control tracking.
30#[derive(Debug, Clone)]
31pub struct ContinuousBallControlPlayerStatus {
32    pub player_id: PlayerId,
33    pub is_airborne: bool,
34}
35
36/// A touch recorded during a continuous ball-control sequence.
37#[derive(Debug, Clone)]
38pub struct ContinuousBallControlTouch {
39    pub player_id: PlayerId,
40    pub is_airborne: bool,
41}
42
43/// A finished continuous ball-control sequence with its samples and touches.
44#[derive(Debug, Clone, PartialEq)]
45pub struct CompletedBallControlSequence<K> {
46    pub player_id: PlayerId,
47    pub is_team_0: bool,
48    pub kind: K,
49    pub start_frame: usize,
50    pub end_frame: usize,
51    pub start_time: f32,
52    pub end_time: f32,
53    pub duration: f32,
54    pub straight_line_distance: f32,
55    pub path_distance: f32,
56    pub average_horizontal_gap: f32,
57    pub average_vertical_gap: f32,
58    pub average_speed: f32,
59    pub start_position: glam::Vec3,
60    pub end_position: glam::Vec3,
61    pub touch_count: u32,
62    pub air_touch_count: u32,
63}
64
65#[derive(Debug, Clone)]
66struct ActiveBallControlSequence<K> {
67    player_id: PlayerId,
68    is_team_0: bool,
69    kind: K,
70    start_frame: usize,
71    last_frame: usize,
72    start_time: f32,
73    last_time: f32,
74    start_position: glam::Vec3,
75    last_position: glam::Vec3,
76    duration: f32,
77    path_distance: f32,
78    horizontal_gap_integral: f32,
79    vertical_gap_integral: f32,
80    speed_integral: f32,
81    touch_count: u32,
82    air_touch_count: u32,
83}
84
85/// Generic tracker that groups per-frame samples into continuous ball-control sequences.
86#[derive(Debug, Clone)]
87pub struct ContinuousBallControlTracker<K> {
88    active_sequence: Option<ActiveBallControlSequence<K>>,
89    pending_takeoff_touches: HashMap<PlayerId, u32>,
90}
91
92impl<K> Default for ContinuousBallControlTracker<K> {
93    fn default() -> Self {
94        Self {
95            active_sequence: None,
96            pending_takeoff_touches: HashMap::new(),
97        }
98    }
99}
100
101impl<K> ContinuousBallControlTracker<K>
102where
103    K: Copy + PartialEq,
104{
105    fn begin_sequence(
106        frame: &FrameInfo,
107        candidate: ContinuousBallControlCandidate<K>,
108        takeoff_touch_count: u32,
109    ) -> ActiveBallControlSequence<K> {
110        let sample = candidate.sample;
111        ActiveBallControlSequence {
112            player_id: candidate.player_id,
113            is_team_0: candidate.is_team_0,
114            kind: sample.kind,
115            start_frame: frame.frame_number.saturating_sub(1),
116            last_frame: frame.frame_number,
117            start_time: (frame.time - frame.dt).max(0.0),
118            last_time: frame.time,
119            start_position: sample.player_position,
120            last_position: sample.player_position,
121            duration: frame.dt,
122            path_distance: 0.0,
123            horizontal_gap_integral: sample.horizontal_gap * frame.dt,
124            vertical_gap_integral: sample.vertical_gap * frame.dt,
125            speed_integral: sample.speed * frame.dt,
126            touch_count: candidate.touch_count + takeoff_touch_count,
127            air_touch_count: candidate.air_touch_count,
128        }
129    }
130
131    fn extend_sequence(
132        active_sequence: &mut ActiveBallControlSequence<K>,
133        frame: &FrameInfo,
134        sample: ContinuousBallControlSample<K>,
135        touch_count: u32,
136        air_touch_count: u32,
137    ) {
138        active_sequence.duration += frame.dt;
139        active_sequence.path_distance += sample
140            .player_position
141            .distance(active_sequence.last_position);
142        active_sequence.last_position = sample.player_position;
143        active_sequence.last_time = frame.time;
144        active_sequence.last_frame = frame.frame_number;
145        active_sequence.horizontal_gap_integral += sample.horizontal_gap * frame.dt;
146        active_sequence.vertical_gap_integral += sample.vertical_gap * frame.dt;
147        active_sequence.speed_integral += sample.speed * frame.dt;
148        active_sequence.touch_count += touch_count;
149        active_sequence.air_touch_count += air_touch_count;
150    }
151
152    fn complete_sequence(
153        active_sequence: ActiveBallControlSequence<K>,
154    ) -> CompletedBallControlSequence<K> {
155        CompletedBallControlSequence {
156            player_id: active_sequence.player_id,
157            is_team_0: active_sequence.is_team_0,
158            kind: active_sequence.kind,
159            start_frame: active_sequence.start_frame,
160            end_frame: active_sequence.last_frame,
161            start_time: active_sequence.start_time,
162            end_time: active_sequence.last_time,
163            duration: active_sequence.duration,
164            straight_line_distance: active_sequence
165                .start_position
166                .truncate()
167                .distance(active_sequence.last_position.truncate()),
168            path_distance: active_sequence.path_distance,
169            average_horizontal_gap: active_sequence.horizontal_gap_integral
170                / active_sequence.duration,
171            average_vertical_gap: active_sequence.vertical_gap_integral / active_sequence.duration,
172            average_speed: active_sequence.speed_integral / active_sequence.duration,
173            start_position: active_sequence.start_position,
174            end_position: active_sequence.last_position,
175            touch_count: active_sequence.touch_count,
176            air_touch_count: active_sequence.air_touch_count,
177        }
178    }
179
180    fn track_touch_contacts(&mut self, touches: &[ContinuousBallControlTouch]) {
181        if touches.is_empty() {
182            return;
183        }
184
185        let touched_players = touches
186            .iter()
187            .map(|touch| touch.player_id.clone())
188            .collect::<HashSet<_>>();
189        self.pending_takeoff_touches
190            .retain(|player_id, _| touched_players.contains(player_id));
191
192        for touch in touches {
193            if !touch.is_airborne {
194                *self
195                    .pending_takeoff_touches
196                    .entry(touch.player_id.clone())
197                    .or_default() += 1;
198            }
199        }
200    }
201
202    fn active_player_is_non_airborne<G>(
203        &self,
204        player_statuses: &[ContinuousBallControlPlayerStatus],
205        requires_airborne_for_kind: G,
206    ) -> bool
207    where
208        G: Fn(K) -> bool,
209    {
210        self.active_sequence
211            .as_ref()
212            .is_some_and(|active_sequence| {
213                requires_airborne_for_kind(active_sequence.kind)
214                    && player_statuses
215                        .iter()
216                        .find(|status| status.player_id == active_sequence.player_id)
217                        .is_some_and(|status| !status.is_airborne)
218            })
219    }
220
221    fn finish_active_sequence<F>(
222        &mut self,
223        min_duration_for_kind: F,
224    ) -> Option<CompletedBallControlSequence<K>>
225    where
226        F: Fn(K) -> f32,
227    {
228        let active_sequence = self.active_sequence.take()?;
229        if active_sequence.duration < min_duration_for_kind(active_sequence.kind) {
230            return None;
231        }
232        Some(Self::complete_sequence(active_sequence))
233    }
234
235    pub fn update<F, G>(
236        &mut self,
237        frame: &FrameInfo,
238        candidate: Option<ContinuousBallControlCandidate<K>>,
239        player_statuses: &[ContinuousBallControlPlayerStatus],
240        touches: &[ContinuousBallControlTouch],
241        min_duration_for_kind: F,
242        requires_airborne_for_kind: G,
243    ) -> Vec<CompletedBallControlSequence<K>>
244    where
245        F: Fn(K) -> f32 + Copy,
246        G: Fn(K) -> bool + Copy,
247    {
248        let mut completed = Vec::new();
249        self.track_touch_contacts(touches);
250
251        if self.active_player_is_non_airborne(player_statuses, requires_airborne_for_kind) {
252            if let Some(sequence) = self.finish_active_sequence(min_duration_for_kind) {
253                completed.push(sequence);
254            }
255        }
256
257        let Some(candidate) = candidate else {
258            if let Some(sequence) = self.finish_active_sequence(min_duration_for_kind) {
259                completed.push(sequence);
260            }
261            return completed;
262        };
263
264        let same_sequence = self
265            .active_sequence
266            .as_ref()
267            .is_some_and(|active_sequence| {
268                active_sequence.player_id == candidate.player_id
269                    && active_sequence.kind == candidate.sample.kind
270            });
271
272        if same_sequence {
273            if let Some(active_sequence) = self.active_sequence.as_mut() {
274                Self::extend_sequence(
275                    active_sequence,
276                    frame,
277                    candidate.sample,
278                    candidate.touch_count,
279                    candidate.air_touch_count,
280                );
281            }
282        } else {
283            if let Some(sequence) = self.finish_active_sequence(min_duration_for_kind) {
284                completed.push(sequence);
285            }
286            let takeoff_touch_count = if requires_airborne_for_kind(candidate.sample.kind) {
287                self.pending_takeoff_touches
288                    .remove(&candidate.player_id)
289                    .unwrap_or(0)
290            } else {
291                0
292            };
293            self.active_sequence =
294                Some(Self::begin_sequence(frame, candidate, takeoff_touch_count));
295        }
296
297        completed
298    }
299
300    pub fn finish<F>(&mut self, min_duration_for_kind: F) -> Option<CompletedBallControlSequence<K>>
301    where
302        F: Fn(K) -> f32,
303    {
304        self.finish_active_sequence(min_duration_for_kind)
305    }
306}
307
308#[cfg(test)]
309#[path = "continuous_ball_control_tests.rs"]
310mod tests;