use boxcars;
use serde::Serialize;
use crate::*;
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub enum BallFrame {
Empty,
Data {
#[ts(as = "crate::interop::ts_bindings::RigidBodyTs")]
rigid_body: boxcars::RigidBody,
},
}
impl BallFrame {
fn new_from_processor(processor: &dyn ProcessorView, current_time: f32) -> Self {
if processor.get_ignore_ball_syncing().unwrap_or(false) {
Self::Empty
} else {
match processor.get_interpolated_ball_rigid_body(current_time, 0.0) {
Ok(rigid_body) => Self::new_from_rigid_body(rigid_body),
_ => Self::Empty,
}
}
}
fn new_from_rigid_body(rigid_body: boxcars::RigidBody) -> Self {
Self::Data { rigid_body }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct PlayerCameraFrame {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pitch: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub yaw: Option<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct PlayerInputFrame {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub throttle: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub steer: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dodge_impulse: Option<(f32, f32, f32)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dodge_torque: Option<(f32, f32, f32)>,
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub enum PlayerFrame {
Empty,
Data {
#[ts(as = "crate::interop::ts_bindings::RigidBodyTs")]
rigid_body: boxcars::RigidBody,
boost_amount: f32,
boost_active: bool,
powerslide_active: bool,
jump_active: bool,
double_jump_active: bool,
dodge_active: bool,
player_name: Option<String>,
team: Option<i32>,
is_team_0: Option<bool>,
camera: PlayerCameraFrame,
input: PlayerInputFrame,
},
}
impl PlayerFrame {
fn new_from_processor(
processor: &dyn ProcessorView,
player_id: &PlayerId,
current_time: f32,
) -> SubtrActorResult<Self> {
let rigid_body =
processor.get_interpolated_player_rigid_body(player_id, current_time, 0.0)?;
let boost_amount = processor.get_player_boost_level(player_id).unwrap_or(0.0);
let boost_active = processor.get_boost_active(player_id).unwrap_or(0) % 2 == 1;
let powerslide_active = processor.get_powerslide_active(player_id).unwrap_or(false);
let jump_active = processor.get_jump_active(player_id).unwrap_or(0) % 2 == 1;
let double_jump_active = processor.get_double_jump_active(player_id).unwrap_or(0) % 2 == 1;
let dodge_active = processor.get_dodge_active(player_id).unwrap_or(0) % 2 == 1;
let camera = PlayerCameraFrame {
pitch: processor.get_camera_pitch(player_id).ok(),
yaw: processor.get_camera_yaw(player_id).ok(),
};
let input = PlayerInputFrame {
throttle: processor.get_throttle(player_id).ok(),
steer: processor.get_steer(player_id).ok(),
dodge_impulse: processor.get_dodge_impulse(player_id).ok(),
dodge_torque: processor.get_dodge_torque(player_id).ok(),
};
let player_name = processor.get_player_name(player_id).ok();
let team = processor
.get_player_team_key(player_id)
.ok()
.and_then(|team_key| team_key.parse::<i32>().ok());
let is_team_0 = processor.get_player_is_team_0(player_id).ok();
Ok(Self::from_data(
rigid_body,
boost_amount,
boost_active,
powerslide_active,
jump_active,
double_jump_active,
dodge_active,
player_name,
team,
is_team_0,
camera,
input,
))
}
#[allow(clippy::too_many_arguments)]
fn from_data(
rigid_body: boxcars::RigidBody,
boost_amount: f32,
boost_active: bool,
powerslide_active: bool,
jump_active: bool,
double_jump_active: bool,
dodge_active: bool,
player_name: Option<String>,
team: Option<i32>,
is_team_0: Option<bool>,
camera: PlayerCameraFrame,
input: PlayerInputFrame,
) -> Self {
Self::Data {
rigid_body,
boost_amount,
boost_active,
powerslide_active,
jump_active,
double_jump_active,
dodge_active,
player_name,
team,
is_team_0,
camera,
input,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct PlayerData {
frames: Vec<PlayerFrame>,
}
impl PlayerData {
fn new() -> Self {
Self { frames: Vec::new() }
}
fn add_frame(&mut self, frame_index: usize, frame: PlayerFrame) {
let empty_frames_to_add = frame_index - self.frames.len();
if empty_frames_to_add > 0 {
for _ in 0..empty_frames_to_add {
self.frames.push(PlayerFrame::Empty)
}
}
self.frames.push(frame)
}
pub fn frames(&self) -> &Vec<PlayerFrame> {
&self.frames
}
pub fn frame_count(&self) -> usize {
self.frames.len()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct BallData {
frames: Vec<BallFrame>,
}
impl BallData {
fn new() -> Self {
Self { frames: Vec::new() }
}
fn add_frame(&mut self, frame_index: usize, frame: BallFrame) {
let empty_frames_to_add = frame_index - self.frames.len();
if empty_frames_to_add > 0 {
for _ in 0..empty_frames_to_add {
self.frames.push(BallFrame::Empty)
}
}
self.frames.push(frame)
}
pub fn frames(&self) -> &Vec<BallFrame> {
&self.frames
}
pub fn frame_count(&self) -> usize {
self.frames.len()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct MetadataFrame {
pub time: f32,
pub seconds_remaining: i32,
pub replicated_game_state_name: i32,
pub replicated_game_state_time_remaining: i32,
}
impl MetadataFrame {
fn new_from_processor(processor: &dyn ProcessorView, time: f32) -> SubtrActorResult<Self> {
Ok(Self::new(
time,
metadata_i32_or_default(processor.get_seconds_remaining()),
metadata_i32_or_default(processor.get_replicated_state_name()),
metadata_i32_or_default(processor.get_replicated_game_state_time_remaining()),
))
}
fn new(
time: f32,
seconds_remaining: i32,
replicated_game_state_name: i32,
replicated_game_state_time_remaining: i32,
) -> Self {
MetadataFrame {
time,
seconds_remaining,
replicated_game_state_name,
replicated_game_state_time_remaining,
}
}
}
fn metadata_i32_or_default(value: SubtrActorResult<i32>) -> i32 {
value.unwrap_or(0)
}
#[cfg(test)]
#[path = "replay_data_tests.rs"]
mod replay_data_tests;
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct FrameData {
pub ball_data: BallData,
#[ts(as = "Vec<(crate::interop::ts_bindings::RemoteIdTs, PlayerData)>")]
pub players: Vec<(PlayerId, PlayerData)>,
pub metadata_frames: Vec<MetadataFrame>,
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct ReplayData {
pub frame_data: FrameData,
pub meta: ReplayMeta,
pub demolish_infos: Vec<DemolishInfo>,
pub boost_pad_events: Vec<BoostPadEvent>,
pub boost_pads: Vec<ResolvedBoostPad>,
pub touch_events: Vec<TouchEvent>,
pub dodge_refreshed_events: Vec<DodgeRefreshedEvent>,
#[ts(as = "Vec<(crate::interop::ts_bindings::RemoteIdTs, Vec<PlayerCameraStateChange>)>")]
pub player_camera_events: Vec<(PlayerId, Vec<PlayerCameraStateChange>)>,
pub player_stat_events: Vec<PlayerStatEvent>,
pub goal_events: Vec<GoalEvent>,
pub replay_tick_marks: Vec<ReplayTickMark>,
}
impl ReplayData {
pub fn as_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn as_pretty_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
fn replay_tick_marks(
replay: &boxcars::Replay,
metadata_frames: &[MetadataFrame],
) -> Vec<ReplayTickMark> {
replay
.tick_marks
.iter()
.map(|tick_mark| ReplayTickMark {
description: tick_mark.description.clone(),
frame: tick_mark.frame,
time: usize::try_from(tick_mark.frame)
.ok()
.and_then(|frame| metadata_frames.get(frame))
.map(|frame| frame.time),
})
.collect()
}
pub(crate) fn group_player_camera_events(
events: &[(PlayerId, PlayerCameraStateChange)],
) -> Vec<(PlayerId, Vec<PlayerCameraStateChange>)> {
let mut grouped: Vec<(PlayerId, Vec<PlayerCameraStateChange>)> = Vec::new();
for (player_id, change) in events {
if let Some((_, changes)) = grouped.iter_mut().find(|(id, _)| id == player_id) {
changes.push(change.clone());
} else {
grouped.push((player_id.clone(), vec![change.clone()]));
}
}
grouped
}
#[cfg(test)]
pub(crate) fn player_stat_events_with_shot_saves(
player_stat_events: &[PlayerStatEvent],
) -> Vec<PlayerStatEvent> {
player_stat_events_with_shot_saves_and_frame_data(player_stat_events, None, None)
}
fn player_stat_events_with_shot_saves_and_frame_data(
player_stat_events: &[PlayerStatEvent],
frame_data: Option<&FrameData>,
touch_events: Option<&[TouchEvent]>,
) -> Vec<PlayerStatEvent> {
const MAX_SHOT_SAVE_LINK_SECONDS: f32 = 3.0;
let mut annotated_events = player_stat_events.to_vec();
let mut pending_shot_indices: Vec<usize> = Vec::new();
for index in 0..annotated_events.len() {
let current_time = annotated_events[index].time;
pending_shot_indices.retain(|shot_index| {
current_time - annotated_events[*shot_index].time <= MAX_SHOT_SAVE_LINK_SECONDS
});
match annotated_events[index].kind {
PlayerStatEventKind::Shot => {
if annotated_events[index].shot.is_some() {
pending_shot_indices.push(index);
}
}
PlayerStatEventKind::Save => {
let save = ShotSaveMetadata {
time: annotated_events[index].time,
frame: annotated_events[index].frame,
player: annotated_events[index].player.clone(),
player_position: annotated_events[index].player_position,
is_team_0: annotated_events[index].is_team_0,
};
let Some(pending_position) = pending_shot_indices.iter().rposition(|shot_index| {
let shot_event = &annotated_events[*shot_index];
if shot_event.is_team_0 == annotated_events[index].is_team_0 {
return false;
}
let save_time_after_shot = annotated_events[index].time - shot_event.time;
if save_time_after_shot <= 0.0
|| save_time_after_shot > MAX_SHOT_SAVE_LINK_SECONDS
{
return false;
}
shot_event
.shot
.as_ref()
.and_then(|shot| shot.projected_goal_line_crossing.as_ref())
.is_none_or(|crossing| {
shot_goal_line_crossing_is_after_save_reference(
shot_event,
&save,
crossing,
touch_events,
)
})
}) else {
continue;
};
let shot_index = pending_shot_indices.remove(pending_position);
let should_estimate_crossing = annotated_events[shot_index]
.shot
.as_ref()
.is_some_and(|shot| {
shot.projected_goal_line_crossing
.as_ref()
.is_none_or(|crossing| !crossing.inside_goal_mouth)
});
let estimated_crossing = should_estimate_crossing.then(|| {
frame_data.and_then(|frame_data| {
estimate_saved_shot_goal_line_crossing(
&annotated_events[shot_index],
&save,
frame_data,
touch_events,
)
})
});
let unavailable_reason = estimated_crossing
.as_ref()
.is_none_or(Option::is_none)
.then(|| {
frame_data.and_then(|frame_data| {
saved_shot_goal_line_crossing_unavailable_reason(
&annotated_events[shot_index],
&save,
frame_data,
touch_events,
)
})
});
if let Some(shot) = annotated_events[shot_index].shot.as_mut() {
if let Some(Some(estimated_crossing)) = estimated_crossing {
shot.projected_goal_target_hit = Some(
ShotGoalTargetHit::from_goal_line_crossing(&estimated_crossing),
);
shot.projected_goal_line_crossing = Some(estimated_crossing);
shot.projected_goal_line_crossing_unavailable_reason = None;
} else if shot
.projected_goal_line_crossing
.as_ref()
.is_some_and(saved_shot_crossing_is_unphysical_free_flight)
{
shot.projected_goal_line_crossing = None;
}
if shot.projected_goal_line_crossing.is_none() {
if let Some(Some(unavailable_reason)) = unavailable_reason {
shot.projected_goal_line_crossing_unavailable_reason =
Some(unavailable_reason);
}
} else {
shot.projected_goal_line_crossing_unavailable_reason = None;
}
shot.resulting_save = Some(save);
}
}
PlayerStatEventKind::Assist => {}
}
}
annotated_events
}
fn estimate_saved_shot_goal_line_crossing(
shot_event: &PlayerStatEvent,
save: &ShotSaveMetadata,
frame_data: &FrameData,
touch_events: Option<&[TouchEvent]>,
) -> Option<ShotGoalLineCrossing> {
const MAX_SAVE_TOUCH_STAT_LAG_SECONDS: f32 = 0.25;
let prediction_window = saved_shot_prediction_window(shot_event, save, touch_events);
estimate_saved_shot_goal_line_crossing_in_window(shot_event, frame_data, prediction_window)
.or_else(|| {
let lagged_prediction_window = saved_shot_prediction_window_with_save_touch_lag(
shot_event,
save,
touch_events,
MAX_SAVE_TOUCH_STAT_LAG_SECONDS,
);
(lagged_prediction_window.has_save_touch
&& !prediction_window.has_save_touch
&& lagged_prediction_window.estimation_time < prediction_window.shot_time)
.then(|| {
estimate_saved_shot_goal_line_crossing_in_window(
shot_event,
frame_data,
lagged_prediction_window,
)
})
.flatten()
})
}
fn estimate_saved_shot_goal_line_crossing_in_window(
shot_event: &PlayerStatEvent,
frame_data: &FrameData,
prediction_window: SavedShotPredictionWindow,
) -> Option<ShotGoalLineCrossing> {
const MAX_PRE_SAVE_LOOKBACK_SECONDS: f32 = 3.0;
const MAX_NO_TOUCH_SHOT_STAT_LAG_SECONDS: f32 = 0.1;
const FLOAT_EPSILON: f32 = 0.0001;
shot_event.shot.as_ref()?;
let target_direction = if shot_event.is_team_0 { 1.0 } else { -1.0 };
let estimation_frame = prediction_window
.estimation_frame
.min(frame_data.ball_data.frames.len().saturating_sub(1));
let mut fallback_crossing = None;
for frame_index in (0..=estimation_frame).rev() {
let Some(metadata) = frame_data.metadata_frames.get(frame_index) else {
continue;
};
if metadata.time > prediction_window.estimation_time + FLOAT_EPSILON {
continue;
}
if prediction_window.estimation_time - metadata.time > MAX_PRE_SAVE_LOOKBACK_SECONDS {
break;
}
if prediction_window.has_inferred_shot_touch
&& metadata.time + FLOAT_EPSILON < prediction_window.shot_time
{
break;
}
let Some(BallFrame::Data { rigid_body }) = frame_data.ball_data.frames.get(frame_index)
else {
continue;
};
let Some(velocity) = rigid_body.linear_velocity else {
continue;
};
if target_direction * velocity.y <= 0.0 {
continue;
}
let Some(mut crossing) = ShotGoalLineCrossing::predict_saved_shot_from_rigid_body(
shot_event.is_team_0,
rigid_body,
) else {
continue;
};
let crossing_time = metadata.time + crossing.time_after_shot;
let mut prediction_start_time = prediction_window.shot_time;
let mut prediction_start_frame = prediction_window.shot_frame;
if crossing_time <= prediction_window.shot_time + FLOAT_EPSILON {
if prediction_window.has_inferred_shot_touch
|| prediction_window.has_save_touch
|| prediction_window.shot_time - crossing_time > MAX_NO_TOUCH_SHOT_STAT_LAG_SECONDS
|| crossing_time <= metadata.time + FLOAT_EPSILON
{
continue;
}
prediction_start_time = metadata.time;
prediction_start_frame = frame_index;
}
if prediction_window.has_save_touch
&& crossing_time <= prediction_window.estimation_time + FLOAT_EPSILON
{
continue;
}
crossing.time_after_shot = crossing_time - prediction_start_time;
crossing.prediction_start_time = Some(prediction_start_time);
crossing.prediction_start_frame = Some(prediction_start_frame);
if crossing.inside_goal_mouth {
return Some(crossing);
}
fallback_crossing.get_or_insert(crossing);
}
fallback_crossing
}
fn saved_shot_goal_line_crossing_unavailable_reason(
shot_event: &PlayerStatEvent,
save: &ShotSaveMetadata,
frame_data: &FrameData,
touch_events: Option<&[TouchEvent]>,
) -> Option<ShotGoalLineCrossingUnavailableReason> {
let prediction_window = saved_shot_prediction_window(shot_event, save, touch_events);
Some(saved_shot_goal_line_crossing_unavailable_reason_in_window(
shot_event,
save,
frame_data,
prediction_window,
))
}
fn saved_shot_goal_line_crossing_unavailable_reason_in_window(
shot_event: &PlayerStatEvent,
save: &ShotSaveMetadata,
frame_data: &FrameData,
prediction_window: SavedShotPredictionWindow,
) -> ShotGoalLineCrossingUnavailableReason {
const MAX_PRE_SAVE_LOOKBACK_SECONDS: f32 = 3.0;
const FLOAT_EPSILON: f32 = 0.0001;
let target_direction = if shot_event.is_team_0 { 1.0 } else { -1.0 };
let estimation_frame = prediction_window
.estimation_frame
.min(frame_data.ball_data.frames.len().saturating_sub(1));
let mut saw_velocity = false;
let mut inbound_frame_count = 0;
let mut projected_inbound_frame_count = 0;
let mut unphysical_free_flight_count = 0;
let mut crossing_before_or_at_prediction_start_count = 0;
let mut crossing_before_or_at_save_touch_count = 0;
let mut crossing_before_or_at_save_count = 0;
for frame_index in (0..=estimation_frame).rev() {
let Some(metadata) = frame_data.metadata_frames.get(frame_index) else {
continue;
};
if metadata.time > prediction_window.estimation_time + FLOAT_EPSILON {
continue;
}
if prediction_window.estimation_time - metadata.time > MAX_PRE_SAVE_LOOKBACK_SECONDS {
break;
}
if prediction_window.has_inferred_shot_touch
&& metadata.time + FLOAT_EPSILON < prediction_window.shot_time
{
break;
}
let Some(BallFrame::Data { rigid_body }) = frame_data.ball_data.frames.get(frame_index)
else {
continue;
};
let Some(velocity) = rigid_body.linear_velocity else {
continue;
};
saw_velocity = true;
if target_direction * velocity.y <= 0.0 {
continue;
}
inbound_frame_count += 1;
let Some((crossing_time, unphysical_free_flight)) =
saved_shot_diagnostic_crossing_time(shot_event.is_team_0, rigid_body)
else {
continue;
};
projected_inbound_frame_count += 1;
if unphysical_free_flight {
unphysical_free_flight_count += 1;
continue;
}
let absolute_crossing_time = metadata.time + crossing_time;
if absolute_crossing_time <= prediction_window.shot_time + FLOAT_EPSILON {
crossing_before_or_at_prediction_start_count += 1;
continue;
}
if prediction_window.has_save_touch
&& absolute_crossing_time <= prediction_window.estimation_time + FLOAT_EPSILON
{
crossing_before_or_at_save_touch_count += 1;
continue;
}
if absolute_crossing_time <= save.time + FLOAT_EPSILON {
crossing_before_or_at_save_count += 1;
continue;
}
return ShotGoalLineCrossingUnavailableReason::NoUsableProjection;
}
if !saw_velocity {
return ShotGoalLineCrossingUnavailableReason::NoBallVelocity;
}
if inbound_frame_count == 0 {
return ShotGoalLineCrossingUnavailableReason::NoGoalwardBallBeforeSaveReference;
}
if projected_inbound_frame_count == 0 {
return ShotGoalLineCrossingUnavailableReason::NoGoalLineCrossingBeforeSaveReference;
}
if unphysical_free_flight_count == projected_inbound_frame_count {
return ShotGoalLineCrossingUnavailableReason::OnlyUnphysicalFreeFlightCrossings;
}
if crossing_before_or_at_prediction_start_count == projected_inbound_frame_count {
return ShotGoalLineCrossingUnavailableReason::CrossingsBeforePredictionStart;
}
if crossing_before_or_at_save_touch_count == projected_inbound_frame_count {
return ShotGoalLineCrossingUnavailableReason::CrossingsBeforeSaveTouch;
}
if crossing_before_or_at_save_count == projected_inbound_frame_count {
return ShotGoalLineCrossingUnavailableReason::CrossingsBeforeSaveStat;
}
ShotGoalLineCrossingUnavailableReason::NoUsableProjection
}
fn saved_shot_diagnostic_crossing_time(
is_team_0: bool,
rigid_body: &boxcars::RigidBody,
) -> Option<(f32, bool)> {
let crossing_config = BallGoalLineCrossingConfig::attacking_goal(is_team_0);
let surfaces = standard_soccar_goal_line_prediction_field_surfaces();
predict_ball_with_surface_bounces_goal_line_crossing(
rigid_body,
crossing_config,
BallTrajectoryConfig::STANDARD_SOCCAR,
BallBounceConfig::STANDARD_SOCCAR,
&surfaces,
)
.map(|crossing| (crossing.time, false))
.or_else(|| {
predict_free_flight_goal_line_crossing(
rigid_body,
crossing_config,
BallTrajectoryConfig::STANDARD_SOCCAR,
)
.map(|crossing| {
(
crossing.time,
crossing.position.z < STANDARD_BALL_RADIUS - STANDARD_GOAL_MOUTH_TRAJECTORY_MARGIN,
)
})
})
}
fn saved_shot_crossing_is_unphysical_free_flight(crossing: &ShotGoalLineCrossing) -> bool {
matches!(
crossing.prediction_kind,
ShotGoalLineCrossingPredictionKind::FreeFlight
| ShotGoalLineCrossingPredictionKind::SavedShotPreSaveFreeFlight
) && crossing.position.z < STANDARD_BALL_RADIUS - STANDARD_GOAL_MOUTH_TRAJECTORY_MARGIN
}
fn shot_goal_line_crossing_is_after_save_reference(
shot_event: &PlayerStatEvent,
save: &ShotSaveMetadata,
crossing: &ShotGoalLineCrossing,
touch_events: Option<&[TouchEvent]>,
) -> bool {
const FLOAT_EPSILON: f32 = 0.0001;
let crossing_time =
crossing.prediction_start_time.unwrap_or(shot_event.time) + crossing.time_after_shot;
let save_reference_time =
saved_shot_prediction_window(shot_event, save, touch_events).save_reference_time();
crossing_time > save_reference_time + FLOAT_EPSILON
}
#[derive(Debug, Clone, Copy)]
struct SavedShotPredictionWindow {
shot_frame: usize,
shot_time: f32,
has_inferred_shot_touch: bool,
has_save_touch: bool,
estimation_frame: usize,
estimation_time: f32,
}
impl SavedShotPredictionWindow {
fn save_reference_time(self) -> f32 {
if self.has_save_touch {
self.estimation_time
} else {
self.estimation_time.max(self.shot_time)
}
}
}
fn saved_shot_prediction_window(
shot_event: &PlayerStatEvent,
save: &ShotSaveMetadata,
touch_events: Option<&[TouchEvent]>,
) -> SavedShotPredictionWindow {
saved_shot_prediction_window_with_save_touch_lag(shot_event, save, touch_events, 0.0)
}
fn saved_shot_prediction_window_with_save_touch_lag(
shot_event: &PlayerStatEvent,
save: &ShotSaveMetadata,
touch_events: Option<&[TouchEvent]>,
max_save_touch_stat_lag_seconds: f32,
) -> SavedShotPredictionWindow {
const FLOAT_EPSILON: f32 = 0.0001;
const MAX_SHOT_TOUCH_LOOKBACK_SECONDS: f32 = 3.0;
let save_touch = touch_events.and_then(|touch_events| {
let player_touch = touch_events.iter().rev().find(|touch| {
touch.team_is_team_0 == save.is_team_0
&& touch.player.as_ref() == Some(&save.player)
&& touch.time >= shot_event.time - max_save_touch_stat_lag_seconds - FLOAT_EPSILON
&& touch.time <= save.time + FLOAT_EPSILON
});
let team_touch = || {
touch_events.iter().rev().find(|touch| {
touch.team_is_team_0 == save.is_team_0
&& touch.time
>= shot_event.time - max_save_touch_stat_lag_seconds - FLOAT_EPSILON
&& touch.time <= save.time + FLOAT_EPSILON
})
};
player_touch.or_else(team_touch)
});
let shot_touch = touch_events.and_then(|touch_events| {
let player_touch = touch_events.iter().rev().find(|touch| {
touch.team_is_team_0 == shot_event.is_team_0
&& touch.player.as_ref() == Some(&shot_event.player)
&& touch.time >= shot_event.time - MAX_SHOT_TOUCH_LOOKBACK_SECONDS - FLOAT_EPSILON
&& touch.time <= shot_event.time + FLOAT_EPSILON
});
let team_touch = || {
touch_events.iter().rev().find(|touch| {
touch.team_is_team_0 == shot_event.is_team_0
&& touch.time
>= shot_event.time - MAX_SHOT_TOUCH_LOOKBACK_SECONDS - FLOAT_EPSILON
&& touch.time <= shot_event.time + FLOAT_EPSILON
})
};
player_touch.or_else(team_touch)
});
let (estimation_frame, estimation_time) = save_touch
.map(|touch| {
let frame = if touch.frame > 0 {
touch.frame - 1
} else {
touch.frame
};
(frame, touch.time)
})
.unwrap_or((save.frame, save.time));
let has_save_touch = save_touch.is_some();
let inferred_shot_touch =
shot_touch.filter(|touch| touch.time <= estimation_time + FLOAT_EPSILON);
let has_inferred_shot_touch = inferred_shot_touch.is_some();
let (shot_frame, shot_time) = inferred_shot_touch
.map(|touch| (touch.frame, touch.time))
.unwrap_or((shot_event.frame, shot_event.time));
if shot_frame <= estimation_frame {
SavedShotPredictionWindow {
shot_frame,
shot_time,
has_inferred_shot_touch,
has_save_touch,
estimation_frame,
estimation_time,
}
} else {
SavedShotPredictionWindow {
shot_frame: shot_event.frame,
shot_time: shot_event.time,
has_inferred_shot_touch: false,
has_save_touch,
estimation_frame,
estimation_time,
}
}
}
impl FrameData {
fn new() -> Self {
FrameData {
ball_data: BallData::new(),
players: Vec::new(),
metadata_frames: Vec::new(),
}
}
pub fn frame_count(&self) -> usize {
self.metadata_frames.len()
}
pub fn duration(&self) -> f32 {
self.metadata_frames.last().map(|f| f.time).unwrap_or(0.0)
}
fn add_frame(
&mut self,
frame_metadata: MetadataFrame,
ball_frame: BallFrame,
player_frames: Vec<(PlayerId, PlayerFrame)>,
) -> SubtrActorResult<()> {
let frame_index = self.metadata_frames.len();
self.metadata_frames.push(frame_metadata);
self.ball_data.add_frame(frame_index, ball_frame);
for (player_id, frame) in player_frames {
self.players
.get_entry(player_id)
.or_insert_with(PlayerData::new)
.add_frame(frame_index, frame)
}
Ok(())
}
}
pub struct ReplayDataCollector {
frame_data: FrameData,
}
impl Default for ReplayDataCollector {
fn default() -> Self {
Self::new()
}
}
impl ReplayDataCollector {
pub fn new() -> Self {
ReplayDataCollector {
frame_data: FrameData::new(),
}
}
pub fn get_frame_data(self) -> FrameData {
self.frame_data
}
pub fn into_replay_data(self, processor: ReplayProcessor<'_>) -> SubtrActorResult<ReplayData> {
let meta = processor.get_replay_meta()?;
let frame_data = self.get_frame_data();
Ok(ReplayData {
meta,
demolish_infos: processor.demolishes().to_vec(),
boost_pad_events: processor.boost_pad_events().to_vec(),
boost_pads: processor.resolved_boost_pads(),
touch_events: processor.touch_events().to_vec(),
dodge_refreshed_events: processor.dodge_refreshed_events().to_vec(),
player_camera_events: group_player_camera_events(processor.player_camera_events()),
player_stat_events: player_stat_events_with_shot_saves_and_frame_data(
processor.player_stat_events(),
Some(&frame_data),
Some(processor.touch_events()),
),
goal_events: processor.goal_events().to_vec(),
replay_tick_marks: replay_tick_marks(processor.replay, &frame_data.metadata_frames),
frame_data,
})
}
pub fn get_replay_data(mut self, replay: &boxcars::Replay) -> SubtrActorResult<ReplayData> {
let mut processor = ReplayProcessor::new(replay)?;
processor.process_all(&mut [&mut self])?;
self.into_replay_data(processor)
}
fn get_player_frames(
&self,
processor: &dyn ProcessorView,
current_time: f32,
) -> SubtrActorResult<Vec<(PlayerId, PlayerFrame)>> {
Ok(processor
.iter_player_ids_in_order()
.map(|player_id| {
(
player_id.clone(),
PlayerFrame::new_from_processor(processor, player_id, current_time)
.unwrap_or(PlayerFrame::Empty),
)
})
.collect())
}
}
impl Collector for ReplayDataCollector {
fn process_frame(
&mut self,
processor: &dyn ProcessorView,
_frame: &boxcars::Frame,
_frame_number: usize,
current_time: f32,
) -> SubtrActorResult<TimeAdvance> {
let metadata_frame = MetadataFrame::new_from_processor(processor, current_time)?;
let ball_frame = BallFrame::new_from_processor(processor, current_time);
let player_frames = self.get_player_frames(processor, current_time)?;
self.frame_data
.add_frame(metadata_frame, ball_frame, player_frames)?;
Ok(TimeAdvance::NextFrame)
}
}