subtr_actor/stats/calculators/
ball_carry.rs1use super::*;
2
3#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
4#[ts(export)]
5pub struct BallCarryStats {
6 pub carry_count: u32,
7 pub total_carry_time: f32,
8 pub total_straight_line_distance: f32,
9 pub total_path_distance: f32,
10 pub longest_carry_time: f32,
11 pub furthest_carry_distance: f32,
12 pub fastest_carry_speed: f32,
13 pub carry_speed_sum: f32,
14 pub average_horizontal_gap_sum: f32,
15 pub average_vertical_gap_sum: f32,
16}
17
18impl BallCarryStats {
19 fn pct_count_average(&self, value: f32) -> f32 {
20 if self.carry_count == 0 {
21 0.0
22 } else {
23 value / self.carry_count as f32
24 }
25 }
26
27 pub fn average_carry_time(&self) -> f32 {
28 self.pct_count_average(self.total_carry_time)
29 }
30
31 pub fn average_straight_line_distance(&self) -> f32 {
32 self.pct_count_average(self.total_straight_line_distance)
33 }
34
35 pub fn average_path_distance(&self) -> f32 {
36 self.pct_count_average(self.total_path_distance)
37 }
38
39 pub fn average_carry_speed(&self) -> f32 {
40 self.pct_count_average(self.carry_speed_sum)
41 }
42
43 pub fn average_horizontal_gap(&self) -> f32 {
44 self.pct_count_average(self.average_horizontal_gap_sum)
45 }
46
47 pub fn average_vertical_gap(&self) -> f32 {
48 self.pct_count_average(self.average_vertical_gap_sum)
49 }
50}
51
52#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
53#[ts(export)]
54pub struct AirDribbleStats {
55 pub count: u32,
56 #[serde(default)]
57 pub ground_to_air_count: u32,
58 #[serde(default)]
59 pub wall_to_air_count: u32,
60 #[serde(default)]
61 pub total_touch_count: u32,
62 #[serde(default)]
63 pub max_touch_count: u32,
64 pub total_time: f32,
65 pub total_straight_line_distance: f32,
66 pub total_path_distance: f32,
67 pub longest_time: f32,
68 pub furthest_distance: f32,
69 pub fastest_speed: f32,
70 pub speed_sum: f32,
71 pub average_horizontal_gap_sum: f32,
72 pub average_vertical_gap_sum: f32,
73}
74
75impl AirDribbleStats {
76 fn count_average(&self, value: f32) -> f32 {
77 if self.count == 0 {
78 0.0
79 } else {
80 value / self.count as f32
81 }
82 }
83
84 pub fn average_time(&self) -> f32 {
85 self.count_average(self.total_time)
86 }
87
88 pub fn average_straight_line_distance(&self) -> f32 {
89 self.count_average(self.total_straight_line_distance)
90 }
91
92 pub fn average_path_distance(&self) -> f32 {
93 self.count_average(self.total_path_distance)
94 }
95
96 pub fn average_speed(&self) -> f32 {
97 self.count_average(self.speed_sum)
98 }
99
100 pub fn average_touch_count(&self) -> f32 {
101 self.count_average(self.total_touch_count as f32)
102 }
103
104 pub fn average_horizontal_gap(&self) -> f32 {
105 self.count_average(self.average_horizontal_gap_sum)
106 }
107
108 pub fn average_vertical_gap(&self) -> f32 {
109 self.count_average(self.average_vertical_gap_sum)
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
114#[ts(export)]
115#[serde(rename_all = "snake_case")]
116pub enum AirDribbleOrigin {
117 GroundToAir,
118 WallToAir,
119}
120
121impl AirDribbleOrigin {
122 pub fn as_label_value(self) -> &'static str {
123 match self {
124 Self::GroundToAir => "ground_to_air",
125 Self::WallToAir => "wall_to_air",
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize)]
131pub struct BallCarryEvent {
132 pub player_id: PlayerId,
133 pub is_team_0: bool,
134 pub kind: BallCarryKind,
135 pub start_frame: usize,
136 pub end_frame: usize,
137 pub start_time: f32,
138 pub end_time: f32,
139 pub duration: f32,
140 pub straight_line_distance: f32,
141 pub path_distance: f32,
142 pub average_horizontal_gap: f32,
143 pub average_vertical_gap: f32,
144 pub average_speed: f32,
145 pub touch_count: u32,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub air_dribble_origin: Option<AirDribbleOrigin>,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
151#[ts(export)]
152#[serde(rename_all = "snake_case")]
153pub enum BallCarryKind {
154 Carry,
155 AirDribble,
156}
157
158#[derive(Debug, Clone, Default)]
159pub struct BallCarryCalculator {
160 player_stats: HashMap<PlayerId, BallCarryStats>,
161 player_air_dribble_stats: HashMap<PlayerId, AirDribbleStats>,
162 team_zero_stats: BallCarryStats,
163 team_one_stats: BallCarryStats,
164 team_zero_air_dribble_stats: AirDribbleStats,
165 team_one_air_dribble_stats: AirDribbleStats,
166 carry_events: Vec<BallCarryEvent>,
167 processed_control_sequence_count: usize,
168}
169
170impl BallCarryCalculator {
171 pub fn new() -> Self {
172 Self::default()
173 }
174
175 pub fn player_stats(&self) -> &HashMap<PlayerId, BallCarryStats> {
176 &self.player_stats
177 }
178
179 pub fn player_air_dribble_stats(&self) -> &HashMap<PlayerId, AirDribbleStats> {
180 &self.player_air_dribble_stats
181 }
182
183 pub fn team_zero_stats(&self) -> &BallCarryStats {
184 &self.team_zero_stats
185 }
186
187 pub fn team_one_stats(&self) -> &BallCarryStats {
188 &self.team_one_stats
189 }
190
191 pub fn team_zero_air_dribble_stats(&self) -> &AirDribbleStats {
192 &self.team_zero_air_dribble_stats
193 }
194
195 pub fn team_one_air_dribble_stats(&self) -> &AirDribbleStats {
196 &self.team_one_air_dribble_stats
197 }
198
199 pub fn carry_events(&self) -> &[BallCarryEvent] {
200 &self.carry_events
201 }
202
203 pub(crate) fn carry_frame_sample(
204 player: &PlayerSample,
205 ball: &BallSample,
206 ) -> Option<ContinuousBallControlSample<BallCarryKind>> {
207 let player_position = player.position()?;
208 let ball_position = ball.position();
209 let horizontal_gap = player_position
210 .truncate()
211 .distance(ball_position.truncate());
212 let vertical_gap = ball_position.z - player_position.z;
213
214 if Self::is_air_dribble_sample(player_position, ball_position, horizontal_gap, vertical_gap)
215 {
216 return Some(ContinuousBallControlSample {
217 player_position,
218 kind: BallCarryKind::AirDribble,
219 horizontal_gap,
220 vertical_gap,
221 speed: player.speed().unwrap_or(0.0),
222 });
223 }
224
225 if !(BALL_CARRY_MIN_BALL_Z..=BALL_CARRY_MAX_BALL_Z).contains(&ball_position.z) {
226 return None;
227 }
228
229 if horizontal_gap > BALL_CARRY_MAX_HORIZONTAL_GAP {
230 return None;
231 }
232
233 if !(0.0..=BALL_CARRY_MAX_VERTICAL_GAP).contains(&vertical_gap) {
234 return None;
235 }
236
237 Some(ContinuousBallControlSample {
238 player_position,
239 kind: BallCarryKind::Carry,
240 horizontal_gap,
241 vertical_gap,
242 speed: player.speed().unwrap_or(0.0),
243 })
244 }
245
246 fn is_air_dribble_sample(
247 player_position: glam::Vec3,
248 ball_position: glam::Vec3,
249 horizontal_gap: f32,
250 vertical_gap: f32,
251 ) -> bool {
252 ball_position.z >= AIR_DRIBBLE_MIN_BALL_Z
253 && player_position.z >= AIR_DRIBBLE_MIN_PLAYER_Z
254 && horizontal_gap <= AIR_DRIBBLE_MAX_HORIZONTAL_GAP
255 && (-AIR_DRIBBLE_MAX_BELOW_CAR_GAP..=AIR_DRIBBLE_MAX_ABOVE_CAR_GAP)
256 .contains(&vertical_gap)
257 }
258
259 pub(crate) fn min_duration_for_kind(kind: BallCarryKind) -> f32 {
260 match kind {
261 BallCarryKind::Carry => BALL_CARRY_MIN_DURATION,
262 BallCarryKind::AirDribble => AIR_DRIBBLE_MIN_DURATION,
263 }
264 }
265
266 pub(crate) fn control_candidate(
267 ball: &BallFrameState,
268 players: &PlayerFrameState,
269 live_play: bool,
270 touch_state: &TouchState,
271 ) -> Option<ContinuousBallControlCandidate<BallCarryKind>> {
272 if !live_play {
273 return None;
274 }
275 let ball = ball.sample()?;
276 let player_id = touch_state.last_touch_player.as_ref()?;
277 let touch_count = touch_state
278 .touch_events
279 .iter()
280 .filter(|event| event.player.as_ref() == Some(player_id))
281 .count() as u32;
282 players
283 .players
284 .iter()
285 .find(|player| &player.player_id == player_id)
286 .and_then(|player| {
287 Self::carry_frame_sample(player, ball).map(|sample| {
288 ContinuousBallControlCandidate {
289 player_id: player.player_id.clone(),
290 is_team_0: player.is_team_0,
291 touch_count,
292 sample,
293 }
294 })
295 })
296 }
297
298 fn air_dribble_origin(start_position: glam::Vec3) -> AirDribbleOrigin {
299 const WALL_TAKEOFF_MIN_Z: f32 = 120.0;
300 const SIDE_WALL_START_ABS_X: f32 = 3200.0;
301 const BACK_WALL_START_ABS_Y: f32 = 4600.0;
302
303 if start_position.z >= WALL_TAKEOFF_MIN_Z
304 && (start_position.x.abs() >= SIDE_WALL_START_ABS_X
305 || start_position.y.abs() >= BACK_WALL_START_ABS_Y)
306 {
307 AirDribbleOrigin::WallToAir
308 } else {
309 AirDribbleOrigin::GroundToAir
310 }
311 }
312
313 fn event_from_sequence(
314 sequence: CompletedBallControlSequence<BallCarryKind>,
315 ) -> BallCarryEvent {
316 let air_dribble_origin = (sequence.kind == BallCarryKind::AirDribble)
317 .then(|| Self::air_dribble_origin(sequence.start_position));
318 BallCarryEvent {
319 player_id: sequence.player_id,
320 is_team_0: sequence.is_team_0,
321 kind: sequence.kind,
322 start_frame: sequence.start_frame,
323 end_frame: sequence.end_frame,
324 start_time: sequence.start_time,
325 end_time: sequence.end_time,
326 duration: sequence.duration,
327 straight_line_distance: sequence.straight_line_distance,
328 path_distance: sequence.path_distance,
329 average_horizontal_gap: sequence.average_horizontal_gap,
330 average_vertical_gap: sequence.average_vertical_gap,
331 average_speed: sequence.average_speed,
332 touch_count: sequence.touch_count,
333 air_dribble_origin,
334 }
335 }
336
337 fn record_carry_event(&mut self, event: BallCarryEvent) {
338 match event.kind {
339 BallCarryKind::Carry => {
340 let player_stats = self
341 .player_stats
342 .entry(event.player_id.clone())
343 .or_default();
344 Self::apply_carry_event(player_stats, &event);
345
346 let team_stats = if event.is_team_0 {
347 &mut self.team_zero_stats
348 } else {
349 &mut self.team_one_stats
350 };
351 Self::apply_carry_event(team_stats, &event);
352 }
353 BallCarryKind::AirDribble => {
354 let player_stats = self
355 .player_air_dribble_stats
356 .entry(event.player_id.clone())
357 .or_default();
358 Self::apply_air_dribble_event(player_stats, &event);
359
360 let team_stats = if event.is_team_0 {
361 &mut self.team_zero_air_dribble_stats
362 } else {
363 &mut self.team_one_air_dribble_stats
364 };
365 Self::apply_air_dribble_event(team_stats, &event);
366 }
367 }
368 self.carry_events.push(event);
369 }
370
371 fn apply_carry_event(stats: &mut BallCarryStats, event: &BallCarryEvent) {
372 stats.carry_count += 1;
373 stats.total_carry_time += event.duration;
374 stats.total_straight_line_distance += event.straight_line_distance;
375 stats.total_path_distance += event.path_distance;
376 stats.longest_carry_time = stats.longest_carry_time.max(event.duration);
377 stats.furthest_carry_distance = stats
378 .furthest_carry_distance
379 .max(event.straight_line_distance);
380 stats.fastest_carry_speed = stats.fastest_carry_speed.max(event.average_speed);
381 stats.carry_speed_sum += event.average_speed;
382 stats.average_horizontal_gap_sum += event.average_horizontal_gap;
383 stats.average_vertical_gap_sum += event.average_vertical_gap;
384 }
385
386 fn apply_air_dribble_event(stats: &mut AirDribbleStats, event: &BallCarryEvent) {
387 stats.count += 1;
388 stats.total_time += event.duration;
389 stats.total_straight_line_distance += event.straight_line_distance;
390 stats.total_path_distance += event.path_distance;
391 stats.longest_time = stats.longest_time.max(event.duration);
392 stats.furthest_distance = stats.furthest_distance.max(event.straight_line_distance);
393 stats.fastest_speed = stats.fastest_speed.max(event.average_speed);
394 stats.speed_sum += event.average_speed;
395 stats.average_horizontal_gap_sum += event.average_horizontal_gap;
396 stats.average_vertical_gap_sum += event.average_vertical_gap;
397 stats.total_touch_count += event.touch_count;
398 stats.max_touch_count = stats.max_touch_count.max(event.touch_count);
399 match event.air_dribble_origin {
400 Some(AirDribbleOrigin::GroundToAir) => stats.ground_to_air_count += 1,
401 Some(AirDribbleOrigin::WallToAir) => stats.wall_to_air_count += 1,
402 None => {}
403 }
404 }
405
406 pub fn update(&mut self, control_state: &ContinuousBallControlState) -> SubtrActorResult<()> {
407 for sequence in control_state
408 .completed_sequences
409 .iter()
410 .skip(self.processed_control_sequence_count)
411 .cloned()
412 {
413 self.record_carry_event(Self::event_from_sequence(sequence));
414 }
415 self.processed_control_sequence_count = control_state.completed_sequences.len();
416 Ok(())
417 }
418}
419
420#[cfg(test)]
421#[path = "ball_carry_tests.rs"]
422mod tests;