Skip to main content

subtr_actor/stats/calculators/
backboard_bounce.rs

1use super::*;
2
3/// A ball rebound off the opponent backboard attributed to the player who sent it there.
4#[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/// Per-frame tracking state for backboard bounces.
21#[derive(Debug, Clone, Default, PartialEq)]
22pub struct BackboardBounceState {
23    pub bounce_events: Vec<BackboardBounceEvent>,
24    pub last_bounce_event: Option<BackboardBounceEvent>,
25}
26
27/// Detects backboard bounces and attributes them to the player who sent the ball.
28#[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        // Count the full opponent back wall, including wide corner reads. The
50        // y-position and y-velocity reversal checks below still keep pure
51        // side-wall touches from arming backboard plays.
52        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}