subtr_actor/stats/calculators/
air_dribble.rs1use super::*;
2
3const AIR_DRIBBLE_MIN_BALL_Z: f32 = 300.0;
4pub(crate) const AIR_DRIBBLE_MIN_PLAYER_Z: f32 = 100.0;
5const AIR_DRIBBLE_MAX_HORIZONTAL_GAP: f32 = BALL_RADIUS_Z * 3.0;
6const AIR_DRIBBLE_MAX_ABOVE_CAR_GAP: f32 = 360.0;
7const AIR_DRIBBLE_MAX_BELOW_CAR_GAP: f32 = 100.0;
8pub(crate) const AIR_DRIBBLE_MIN_DURATION: f32 = 0.65;
9const AIR_DRIBBLE_MIN_TOUCHES: u32 = 3;
10const AIR_DRIBBLE_MIN_AIR_TOUCHES: u32 = 2;
11const WALL_TAKEOFF_MIN_Z: f32 = 120.0;
12const SIDE_WALL_START_ABS_X: f32 = 3200.0;
13const BACK_WALL_START_ABS_Y: f32 = 4600.0;
14
15#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
16#[ts(export)]
17pub struct AirDribbleStats {
18 pub count: u32,
19 #[serde(default)]
20 pub ground_to_air_count: u32,
21 #[serde(default)]
22 pub wall_to_air_count: u32,
23 #[serde(default)]
24 pub total_touch_count: u32,
25 #[serde(default)]
26 pub max_touch_count: u32,
27 pub total_time: f32,
28 pub total_straight_line_distance: f32,
29 pub total_path_distance: f32,
30 pub longest_time: f32,
31 pub furthest_distance: f32,
32 pub fastest_speed: f32,
33 pub speed_sum: f32,
34 pub average_horizontal_gap_sum: f32,
35 pub average_vertical_gap_sum: f32,
36 #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
37 pub labeled_event_counts: LabeledCounts,
38}
39
40impl AirDribbleStats {
41 fn count_average(&self, value: f32) -> f32 {
42 if self.count == 0 {
43 0.0
44 } else {
45 value / self.count as f32
46 }
47 }
48
49 pub fn average_time(&self) -> f32 {
50 self.count_average(self.total_time)
51 }
52
53 pub fn average_straight_line_distance(&self) -> f32 {
54 self.count_average(self.total_straight_line_distance)
55 }
56
57 pub fn average_path_distance(&self) -> f32 {
58 self.count_average(self.total_path_distance)
59 }
60
61 pub fn average_speed(&self) -> f32 {
62 self.count_average(self.speed_sum)
63 }
64
65 pub fn average_touch_count(&self) -> f32 {
66 self.count_average(self.total_touch_count as f32)
67 }
68
69 pub fn average_horizontal_gap(&self) -> f32 {
70 self.count_average(self.average_horizontal_gap_sum)
71 }
72
73 pub fn average_vertical_gap(&self) -> f32 {
74 self.count_average(self.average_vertical_gap_sum)
75 }
76
77 fn record_event(&mut self, event: &BallCarryEvent) {
78 if let Some(origin) = event.air_dribble_origin {
79 self.labeled_event_counts
80 .increment([air_dribble_origin_label(origin)]);
81 }
82 self.sync_legacy_counts();
83 }
84
85 pub fn event_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
86 self.labeled_event_counts.count_matching(labels)
87 }
88
89 pub fn complete_labeled_event_counts(&self) -> LabeledCounts {
90 LabeledCounts::complete_from_label_sets(
91 &[&AIR_DRIBBLE_ORIGIN_LABELS],
92 &self.labeled_event_counts,
93 )
94 }
95
96 fn sync_legacy_counts(&mut self) {
97 self.count = self.labeled_event_counts.total();
98 self.ground_to_air_count = self
99 .event_count_with_labels(&[air_dribble_origin_label(AirDribbleOrigin::GroundToAir)]);
100 self.wall_to_air_count =
101 self.event_count_with_labels(&[air_dribble_origin_label(AirDribbleOrigin::WallToAir)]);
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
106#[ts(export)]
107#[serde(rename_all = "snake_case")]
108pub enum AirDribbleOrigin {
109 GroundToAir,
110 WallToAir,
111}
112
113const AIR_DRIBBLE_ORIGIN_LABELS: [StatLabel; 2] = [
114 StatLabel::new("origin", "ground_to_air"),
115 StatLabel::new("origin", "wall_to_air"),
116];
117
118fn air_dribble_origin_label(origin: AirDribbleOrigin) -> StatLabel {
119 StatLabel::new("origin", origin.as_label_value())
120}
121
122impl AirDribbleOrigin {
123 pub fn as_label_value(self) -> &'static str {
124 match self {
125 Self::GroundToAir => "ground_to_air",
126 Self::WallToAir => "wall_to_air",
127 }
128 }
129}
130
131pub(crate) struct AirDribblePolicy;
132
133impl AirDribblePolicy {
134 pub(crate) fn is_sample(
135 player_position: glam::Vec3,
136 ball_position: glam::Vec3,
137 horizontal_gap: f32,
138 vertical_gap: f32,
139 ) -> bool {
140 ball_position.z >= AIR_DRIBBLE_MIN_BALL_Z
141 && player_position.z >= AIR_DRIBBLE_MIN_PLAYER_Z
142 && !player_is_on_wall(player_position)
143 && horizontal_gap <= AIR_DRIBBLE_MAX_HORIZONTAL_GAP
144 && (-AIR_DRIBBLE_MAX_BELOW_CAR_GAP..=AIR_DRIBBLE_MAX_ABOVE_CAR_GAP)
145 .contains(&vertical_gap)
146 }
147
148 pub(crate) fn is_air_touch_position(player_position: glam::Vec3) -> bool {
149 player_position.z > PLAYER_GROUND_Z_THRESHOLD && !player_is_on_wall(player_position)
150 }
151
152 pub(crate) fn kind_requires_airborne(kind: BallCarryKind) -> bool {
153 kind == BallCarryKind::AirDribble
154 }
155
156 pub(crate) fn is_valid_sequence(
157 sequence: &CompletedBallControlSequence<BallCarryKind>,
158 ) -> bool {
159 sequence.kind != BallCarryKind::AirDribble
160 || (sequence.touch_count >= AIR_DRIBBLE_MIN_TOUCHES
161 && sequence.air_touch_count >= AIR_DRIBBLE_MIN_AIR_TOUCHES)
162 }
163
164 pub(crate) fn origin(start_position: glam::Vec3) -> AirDribbleOrigin {
165 if start_position.z >= WALL_TAKEOFF_MIN_Z
166 && (start_position.x.abs() >= SIDE_WALL_START_ABS_X
167 || start_position.y.abs() >= BACK_WALL_START_ABS_Y)
168 {
169 AirDribbleOrigin::WallToAir
170 } else {
171 AirDribbleOrigin::GroundToAir
172 }
173 }
174
175 pub(crate) fn apply_event(stats: &mut AirDribbleStats, event: &BallCarryEvent) {
176 stats.record_event(event);
177 stats.total_time += event.duration;
178 stats.total_straight_line_distance += event.straight_line_distance;
179 stats.total_path_distance += event.path_distance;
180 stats.longest_time = stats.longest_time.max(event.duration);
181 stats.furthest_distance = stats.furthest_distance.max(event.straight_line_distance);
182 stats.fastest_speed = stats.fastest_speed.max(event.average_speed);
183 stats.speed_sum += event.average_speed;
184 stats.average_horizontal_gap_sum += event.average_horizontal_gap;
185 stats.average_vertical_gap_sum += event.average_vertical_gap;
186 stats.total_touch_count += event.touch_count;
187 stats.max_touch_count = stats.max_touch_count.max(event.touch_count);
188 }
189}
190
191#[cfg(test)]
192#[path = "air_dribble_tests.rs"]
193mod tests;