subtr_actor/stats/calculators/
backboard_bounce.rs1use super::*;
2
3#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
5#[ts(export)]
6pub struct BackboardBounceEvent {
7 pub time: f32,
8 pub frame: usize,
9 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
10 pub player: PlayerId,
11 #[serde(default, skip_serializing_if = "Option::is_none")]
12 pub player_position: Option<[f32; 3]>,
13 pub is_team_0: bool,
14}
15
16#[cfg(test)]
17#[path = "backboard_bounce_tests.rs"]
18mod tests;
19
20#[derive(Debug, Clone, Default, PartialEq)]
22pub struct BackboardBounceState {
23 pub bounce_events: Vec<BackboardBounceEvent>,
24 pub last_bounce_event: Option<BackboardBounceEvent>,
25}
26
27#[derive(Default)]
29pub struct BackboardBounceCalculator {
30 previous_ball_velocity: Option<glam::Vec3>,
31 last_touch: Option<TouchEvent>,
32 last_bounce_event: Option<BackboardBounceEvent>,
33}
34
35impl BackboardBounceCalculator {
36 pub fn new() -> Self {
37 Self::default()
38 }
39
40 fn detect_bounce(
41 &self,
42 frame: &FrameInfo,
43 ball: Option<&BallSample>,
44 touch_state: &TouchState,
45 ) -> Option<BackboardBounceEvent> {
46 const BACKBOARD_MIN_BALL_Z: f32 = 500.0;
47 const BACKBOARD_MIN_NORMALIZED_Y: f32 = 4700.0;
48 const BACKBOARD_SIMULTANEOUS_TOUCH_MIN_NORMALIZED_Y: f32 = 5000.0;
49 const BACKBOARD_MAX_ABS_X: f32 = 4096.0;
53 const BACKBOARD_MIN_APPROACH_SPEED_Y: f32 = 350.0;
54 const BACKBOARD_MIN_REBOUND_SPEED_Y: f32 = 250.0;
55 const BACKBOARD_TOUCH_ATTRIBUTION_MAX_SECONDS: f32 = 2.5;
56
57 let last_touch = self.last_touch.as_ref()?;
58 let player = last_touch.player.clone()?;
59 let current_ball = ball?;
60 let previous_ball_velocity = self.previous_ball_velocity?;
61
62 if (frame.time - last_touch.time).max(0.0) > BACKBOARD_TOUCH_ATTRIBUTION_MAX_SECONDS {
63 return None;
64 }
65
66 let ball_position = current_ball.position();
67 if ball_position.x.abs() > BACKBOARD_MAX_ABS_X || ball_position.z < BACKBOARD_MIN_BALL_Z {
68 return None;
69 }
70
71 let normalized_position_y = normalized_y(last_touch.team_is_team_0, ball_position);
72 if normalized_position_y < BACKBOARD_MIN_NORMALIZED_Y {
73 return None;
74 }
75
76 let previous_normalized_velocity_y = if last_touch.team_is_team_0 {
77 previous_ball_velocity.y
78 } else {
79 -previous_ball_velocity.y
80 };
81 let current_normalized_velocity_y = if last_touch.team_is_team_0 {
82 current_ball.velocity().y
83 } else {
84 -current_ball.velocity().y
85 };
86
87 if previous_normalized_velocity_y < BACKBOARD_MIN_APPROACH_SPEED_Y {
88 return None;
89 }
90
91 let has_rebound_velocity = current_normalized_velocity_y <= -BACKBOARD_MIN_REBOUND_SPEED_Y;
92 let has_simultaneous_same_player_touch =
93 touch_state.primary_touch_event().is_some_and(|touch| {
94 touch.team_is_team_0 == last_touch.team_is_team_0
95 && touch.player.as_ref() == Some(&player)
96 && normalized_position_y >= BACKBOARD_SIMULTANEOUS_TOUCH_MIN_NORMALIZED_Y
97 && (touch.frame > last_touch.frame
98 || (touch.frame == last_touch.frame && touch.time > last_touch.time))
99 });
100 if !has_rebound_velocity && !has_simultaneous_same_player_touch {
101 return None;
102 }
103
104 Some(BackboardBounceEvent {
105 time: frame.time,
106 frame: frame.frame_number,
107 player,
108 player_position: last_touch
109 .player_position
110 .map(|position| vec_to_glam(&position).to_array()),
111 is_team_0: last_touch.team_is_team_0,
112 })
113 }
114
115 pub fn update(
116 &mut self,
117 frame: &FrameInfo,
118 ball: &BallFrameState,
119 players: &PlayerFrameState,
120 touch_state: &TouchState,
121 live_play_state: &LivePlayState,
122 ) -> BackboardBounceState {
123 if !live_play_state.is_live_play {
124 self.previous_ball_velocity = ball.velocity();
125 self.last_touch = None;
126 self.last_bounce_event = None;
127 return BackboardBounceState::default();
128 }
129
130 if self
131 .last_touch
132 .as_ref()
133 .and_then(|touch| touch.player.as_ref())
134 .and_then(|player_id| players.player(player_id))
135 .is_some_and(player_sample_is_touching_surface)
136 {
137 self.last_touch = None;
138 }
139
140 let bounce_events: Vec<_> = self
141 .detect_bounce(frame, ball.sample(), touch_state)
142 .into_iter()
143 .collect();
144 if let Some(last_bounce_event) = bounce_events.last() {
145 self.last_bounce_event = Some(last_bounce_event.clone());
146 }
147
148 if !touch_state.touch_events.is_empty() {
149 self.last_touch = touch_state.primary_touch_event().cloned();
150 }
151 self.previous_ball_velocity = ball.velocity();
152
153 BackboardBounceState {
154 bounce_events,
155 last_bounce_event: self.last_bounce_event.clone(),
156 }
157 }
158}