subtr_actor/stats/calculators/
continuous_ball_control.rs1use super::*;
2
3#[derive(Debug, Clone, Default, PartialEq)]
5pub struct ContinuousBallControlState {
6 pub completed_sequences: Vec<CompletedBallControlSequence<BallCarryKind>>,
7}
8
9#[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#[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#[derive(Debug, Clone)]
31pub struct ContinuousBallControlPlayerStatus {
32 pub player_id: PlayerId,
33 pub is_airborne: bool,
34}
35
36#[derive(Debug, Clone)]
38pub struct ContinuousBallControlTouch {
39 pub player_id: PlayerId,
40 pub is_airborne: bool,
41}
42
43#[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#[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;