subtr-actor 0.5.0

Rocket League replay transformer
Documentation
use super::*;

const DOUBLE_TAP_TOUCH_WINDOW_SECONDS: f32 = 2.5;

#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct DoubleTapEvent {
    pub time: f32,
    pub frame: usize,
    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
    pub player: PlayerId,
    pub is_team_0: bool,
    pub backboard_time: f32,
    pub backboard_frame: usize,
}

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct DoubleTapPlayerStats {
    pub count: u32,
    pub is_last_double_tap: bool,
    pub last_double_tap_time: Option<f32>,
    pub last_double_tap_frame: Option<usize>,
    pub time_since_last_double_tap: Option<f32>,
    pub frames_since_last_double_tap: Option<usize>,
}

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct DoubleTapTeamStats {
    pub count: u32,
}

#[derive(Debug, Clone)]
struct PendingBackboardBounce {
    player_id: PlayerId,
    is_team_0: bool,
    time: f32,
    frame: usize,
}

#[derive(Debug, Clone, Default)]
pub struct DoubleTapCalculator {
    player_stats: HashMap<PlayerId, DoubleTapPlayerStats>,
    team_zero_stats: DoubleTapTeamStats,
    team_one_stats: DoubleTapTeamStats,
    events: Vec<DoubleTapEvent>,
    pending_backboard_bounces: Vec<PendingBackboardBounce>,
    current_last_double_tap_player: Option<PlayerId>,
}

impl DoubleTapCalculator {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn player_stats(&self) -> &HashMap<PlayerId, DoubleTapPlayerStats> {
        &self.player_stats
    }

    pub fn team_zero_stats(&self) -> &DoubleTapTeamStats {
        &self.team_zero_stats
    }

    pub fn team_one_stats(&self) -> &DoubleTapTeamStats {
        &self.team_one_stats
    }

    pub fn events(&self) -> &[DoubleTapEvent] {
        &self.events
    }

    fn begin_sample(&mut self, frame: &FrameInfo) {
        for stats in self.player_stats.values_mut() {
            stats.is_last_double_tap = false;
            stats.time_since_last_double_tap = stats
                .last_double_tap_time
                .map(|time| (frame.time - time).max(0.0));
            stats.frames_since_last_double_tap = stats
                .last_double_tap_frame
                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
        }
    }

    fn prune_pending_backboard_bounces(&mut self, current_time: f32) {
        self.pending_backboard_bounces
            .retain(|entry| current_time - entry.time <= DOUBLE_TAP_TOUCH_WINDOW_SECONDS);
    }

    fn record_backboard_bounces(&mut self, state: &BackboardBounceState) {
        for event in &state.bounce_events {
            if let Some(existing) = self
                .pending_backboard_bounces
                .iter_mut()
                .find(|pending| pending.player_id == event.player)
            {
                *existing = PendingBackboardBounce {
                    player_id: event.player.clone(),
                    is_team_0: event.is_team_0,
                    time: event.time,
                    frame: event.frame,
                };
            } else {
                self.pending_backboard_bounces.push(PendingBackboardBounce {
                    player_id: event.player.clone(),
                    is_team_0: event.is_team_0,
                    time: event.time,
                    frame: event.frame,
                });
            }
        }
    }

    fn resolve_double_tap_touches(
        &mut self,
        frame: &FrameInfo,
        ball: &BallFrameState,
        touch_events: &[TouchEvent],
    ) {
        if touch_events.is_empty() || self.pending_backboard_bounces.is_empty() {
            return;
        }

        let mut completed_events = Vec::new();
        self.pending_backboard_bounces.retain(|pending| {
            if frame.time <= pending.time {
                return true;
            }

            let matching_touch = touch_events.iter().any(|touch| {
                touch.team_is_team_0 == pending.is_team_0
                    && touch.player.as_ref() == Some(&pending.player_id)
            });
            let conflicting_touch = touch_events
                .iter()
                .any(|touch| touch.player.as_ref() != Some(&pending.player_id));

            if matching_touch
                && !conflicting_touch
                && Self::followup_touch_is_goal_directed(ball, pending.is_team_0)
            {
                completed_events.push(DoubleTapEvent {
                    time: frame.time,
                    frame: frame.frame_number,
                    player: pending.player_id.clone(),
                    is_team_0: pending.is_team_0,
                    backboard_time: pending.time,
                    backboard_frame: pending.frame,
                });
            }
            false
        });

        for event in completed_events {
            self.record_double_tap(frame, event);
        }
    }

    fn record_double_tap(&mut self, frame: &FrameInfo, event: DoubleTapEvent) {
        let stats = self.player_stats.entry(event.player.clone()).or_default();
        stats.count += 1;
        stats.last_double_tap_time = Some(event.time);
        stats.last_double_tap_frame = Some(event.frame);
        stats.time_since_last_double_tap = Some((frame.time - event.time).max(0.0));
        stats.frames_since_last_double_tap = Some(frame.frame_number.saturating_sub(event.frame));

        let team_stats = if event.is_team_0 {
            &mut self.team_zero_stats
        } else {
            &mut self.team_one_stats
        };
        team_stats.count += 1;
        self.current_last_double_tap_player = Some(event.player.clone());
        self.events.push(event);
    }

    fn followup_touch_is_goal_directed(ball: &BallFrameState, is_team_0: bool) -> bool {
        const GOAL_CENTER_Y: f32 = 5120.0;
        const MIN_GOAL_ALIGNMENT_COSINE: f32 = 0.6;

        let Some(ball) = ball.sample() else {
            return false;
        };

        let target_y = if is_team_0 {
            GOAL_CENTER_Y
        } else {
            -GOAL_CENTER_Y
        };
        let ball_velocity = ball.velocity();
        if ball_velocity.length_squared() <= f32::EPSILON {
            return false;
        }

        let goal_direction = glam::Vec3::new(0.0, target_y, ball.position().z) - ball.position();
        goal_direction
            .normalize_or_zero()
            .dot(ball_velocity.normalize_or_zero())
            >= MIN_GOAL_ALIGNMENT_COSINE
    }

    pub fn update(
        &mut self,
        frame: &FrameInfo,
        ball: &BallFrameState,
        events: &FrameEventsState,
        backboard_bounce_state: &BackboardBounceState,
        live_play: bool,
    ) -> SubtrActorResult<()> {
        self.begin_sample(frame);
        if !live_play {
            self.pending_backboard_bounces.clear();
        }

        self.prune_pending_backboard_bounces(frame.time);
        self.record_backboard_bounces(backboard_bounce_state);
        self.resolve_double_tap_touches(frame, ball, &events.touch_events);

        if let Some(player_id) = self.current_last_double_tap_player.as_ref() {
            if let Some(stats) = self.player_stats.get_mut(player_id) {
                stats.is_last_double_tap = true;
            }
        }
        Ok(())
    }
}