subtr-actor 0.5.0

Rocket League replay transformer
Documentation
use super::*;

#[derive(Debug, Clone, Default)]
pub struct TouchState {
    pub touch_events: Vec<TouchEvent>,
    pub last_touch: Option<TouchEvent>,
    pub last_touch_player: Option<PlayerId>,
    pub last_touch_team_is_team_0: Option<bool>,
}

#[derive(Default)]
pub struct TouchStateCalculator {
    previous_ball_linear_velocity: Option<glam::Vec3>,
    previous_ball_angular_velocity: Option<glam::Vec3>,
    current_last_touch: Option<TouchEvent>,
    recent_touch_candidates: HashMap<PlayerId, TouchEvent>,
}

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

    fn should_emit_candidate(&self, candidate: &TouchEvent) -> bool {
        const SAME_PLAYER_TOUCH_COOLDOWN_FRAMES: usize = 7;

        let Some(previous_touch) = self.current_last_touch.as_ref() else {
            return true;
        };

        let same_player =
            previous_touch.player.is_some() && previous_touch.player == candidate.player;
        if !same_player {
            return true;
        }

        candidate.frame.saturating_sub(previous_touch.frame) >= SAME_PLAYER_TOUCH_COOLDOWN_FRAMES
    }

    fn prune_recent_touch_candidates(&mut self, current_frame: usize) {
        const TOUCH_CANDIDATE_WINDOW_FRAMES: usize = 4;

        self.recent_touch_candidates.retain(|_, candidate| {
            current_frame.saturating_sub(candidate.frame) <= TOUCH_CANDIDATE_WINDOW_FRAMES
        });
    }

    fn current_ball_angular_velocity(ball: &BallFrameState) -> Option<glam::Vec3> {
        ball.sample()
            .map(|ball| {
                ball.rigid_body
                    .angular_velocity
                    .unwrap_or(boxcars::Vector3f {
                        x: 0.0,
                        y: 0.0,
                        z: 0.0,
                    })
            })
            .map(|velocity| vec_to_glam(&velocity))
    }

    fn current_ball_linear_velocity(ball: &BallFrameState) -> Option<glam::Vec3> {
        ball.velocity()
    }

    fn is_touch_candidate(&self, frame: &FrameInfo, ball: &BallFrameState) -> bool {
        const BALL_GRAVITY_Z: f32 = -650.0;
        const TOUCH_LINEAR_IMPULSE_THRESHOLD: f32 = 120.0;
        const TOUCH_ANGULAR_VELOCITY_DELTA_THRESHOLD: f32 = 0.5;

        let Some(current_linear_velocity) = Self::current_ball_linear_velocity(ball) else {
            return false;
        };
        let Some(previous_linear_velocity) = self.previous_ball_linear_velocity else {
            return false;
        };
        let Some(current_angular_velocity) = Self::current_ball_angular_velocity(ball) else {
            return false;
        };
        let Some(previous_angular_velocity) = self.previous_ball_angular_velocity else {
            return false;
        };

        let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
        let residual_linear_impulse =
            current_linear_velocity - previous_linear_velocity - expected_linear_delta;
        let angular_velocity_delta = current_angular_velocity - previous_angular_velocity;

        residual_linear_impulse.length() > TOUCH_LINEAR_IMPULSE_THRESHOLD
            || angular_velocity_delta.length() > TOUCH_ANGULAR_VELOCITY_DELTA_THRESHOLD
    }

    fn proximity_touch_candidates(
        &self,
        frame: &FrameInfo,
        ball: &BallFrameState,
        players: &PlayerFrameState,
        max_collision_distance: f32,
    ) -> Vec<TouchEvent> {
        const OCTANE_HITBOX_LENGTH: f32 = 118.01;
        const OCTANE_HITBOX_WIDTH: f32 = 84.2;
        const OCTANE_HITBOX_HEIGHT: f32 = 36.16;
        const OCTANE_HITBOX_OFFSET: f32 = 13.88;
        const OCTANE_HITBOX_ELEVATION: f32 = 17.05;

        let Some(ball) = ball.sample() else {
            return Vec::new();
        };
        let ball_position = vec_to_glam(&ball.rigid_body.location);

        let mut candidates = players
            .players
            .iter()
            .filter_map(|player| {
                let rigid_body = player.rigid_body.as_ref()?;
                let player_position = vec_to_glam(&rigid_body.location);
                let local_ball_position = quat_to_glam(&rigid_body.rotation).inverse()
                    * (ball_position - player_position);

                let x_distance = if local_ball_position.x
                    < -OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET
                {
                    (-OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET) - local_ball_position.x
                } else if local_ball_position.x > OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET
                {
                    local_ball_position.x - (OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET)
                } else {
                    0.0
                };
                let y_distance = if local_ball_position.y < -OCTANE_HITBOX_WIDTH / 2.0 {
                    (-OCTANE_HITBOX_WIDTH / 2.0) - local_ball_position.y
                } else if local_ball_position.y > OCTANE_HITBOX_WIDTH / 2.0 {
                    local_ball_position.y - OCTANE_HITBOX_WIDTH / 2.0
                } else {
                    0.0
                };
                let z_distance = if local_ball_position.z
                    < -OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION
                {
                    (-OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION) - local_ball_position.z
                } else if local_ball_position.z
                    > OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION
                {
                    local_ball_position.z - (OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION)
                } else {
                    0.0
                };

                let collision_distance =
                    glam::Vec3::new(x_distance, y_distance, z_distance).length();
                if collision_distance > max_collision_distance {
                    return None;
                }

                Some(TouchEvent {
                    time: frame.time,
                    frame: frame.frame_number,
                    team_is_team_0: player.is_team_0,
                    player: Some(player.player_id.clone()),
                    closest_approach_distance: Some(collision_distance),
                })
            })
            .collect::<Vec<_>>();

        candidates.sort_by(|left, right| {
            let left_distance = left.closest_approach_distance.unwrap_or(f32::INFINITY);
            let right_distance = right.closest_approach_distance.unwrap_or(f32::INFINITY);
            left_distance.total_cmp(&right_distance)
        });
        candidates
    }

    fn candidate_touch_event(
        &self,
        frame: &FrameInfo,
        ball: &BallFrameState,
        players: &PlayerFrameState,
    ) -> Option<TouchEvent> {
        const TOUCH_COLLISION_DISTANCE_THRESHOLD: f32 = 300.0;

        self.proximity_touch_candidates(frame, ball, players, TOUCH_COLLISION_DISTANCE_THRESHOLD)
            .into_iter()
            .next()
    }

    fn update_recent_touch_candidates(
        &mut self,
        frame: &FrameInfo,
        ball: &BallFrameState,
        players: &PlayerFrameState,
    ) {
        const PROXIMITY_CANDIDATE_DISTANCE_THRESHOLD: f32 = 220.0;

        for candidate in self.proximity_touch_candidates(
            frame,
            ball,
            players,
            PROXIMITY_CANDIDATE_DISTANCE_THRESHOLD,
        ) {
            let Some(player_id) = candidate.player.clone() else {
                continue;
            };

            self.recent_touch_candidates.insert(player_id, candidate);
        }
    }

    fn candidate_for_player(&self, player_id: &PlayerId) -> Option<TouchEvent> {
        self.recent_touch_candidates.get(player_id).cloned()
    }

    fn contested_touch_candidates(&self, primary: &TouchEvent) -> Vec<TouchEvent> {
        const CONTESTED_TOUCH_DISTANCE_MARGIN: f32 = 80.0;

        let primary_distance = primary.closest_approach_distance.unwrap_or(f32::INFINITY);

        let best_opposing_candidate = self
            .recent_touch_candidates
            .values()
            .filter(|candidate| candidate.team_is_team_0 != primary.team_is_team_0)
            .filter(|candidate| {
                candidate.closest_approach_distance.unwrap_or(f32::INFINITY)
                    <= primary_distance + CONTESTED_TOUCH_DISTANCE_MARGIN
            })
            .min_by(|left, right| {
                let left_distance = left.closest_approach_distance.unwrap_or(f32::INFINITY);
                let right_distance = right.closest_approach_distance.unwrap_or(f32::INFINITY);
                left_distance.total_cmp(&right_distance)
            })
            .cloned();

        best_opposing_candidate.into_iter().collect()
    }

    fn confirmed_touch_events(
        &self,
        frame: &FrameInfo,
        ball: &BallFrameState,
        players: &PlayerFrameState,
        events: &FrameEventsState,
    ) -> Vec<TouchEvent> {
        let mut touch_events = Vec::new();
        let mut confirmed_players = HashSet::new();

        if self.is_touch_candidate(frame, ball) {
            if let Some(candidate) = self.candidate_touch_event(frame, ball, players) {
                for contested_candidate in self.contested_touch_candidates(&candidate) {
                    if let Some(player_id) = contested_candidate.player.clone() {
                        confirmed_players.insert(player_id);
                    }
                    touch_events.push(contested_candidate);
                }
                if let Some(player_id) = candidate.player.clone() {
                    confirmed_players.insert(player_id);
                }
                touch_events.push(candidate);
            }
        }

        for dodge_refresh in &events.dodge_refreshed_events {
            if !confirmed_players.insert(dodge_refresh.player.clone()) {
                continue;
            }
            let Some(candidate) = self.candidate_for_player(&dodge_refresh.player) else {
                continue;
            };
            touch_events.push(candidate);
        }

        touch_events
    }

    pub fn update(
        &mut self,
        frame: &FrameInfo,
        ball: &BallFrameState,
        players: &PlayerFrameState,
        events: &FrameEventsState,
        live_play_state: &LivePlayState,
    ) -> TouchState {
        let touch_events = if live_play_state.is_live_play {
            self.prune_recent_touch_candidates(frame.frame_number);
            self.update_recent_touch_candidates(frame, ball, players);
            self.confirmed_touch_events(frame, ball, players, events)
                .into_iter()
                .filter(|candidate| self.should_emit_candidate(candidate))
                .collect()
        } else {
            self.current_last_touch = None;
            self.recent_touch_candidates.clear();
            Vec::new()
        };

        if let Some(last_touch) = touch_events.last() {
            self.current_last_touch = Some(last_touch.clone());
        }
        self.previous_ball_linear_velocity = Self::current_ball_linear_velocity(ball);
        self.previous_ball_angular_velocity = Self::current_ball_angular_velocity(ball);

        TouchState {
            touch_events,
            last_touch: self.current_last_touch.clone(),
            last_touch_player: self
                .current_last_touch
                .as_ref()
                .and_then(|touch| touch.player.clone()),
            last_touch_team_is_team_0: self
                .current_last_touch
                .as_ref()
                .map(|touch| touch.team_is_team_0),
        }
    }
}