use super::*;
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct DoubleTapEvent {
pub time: f32,
pub frame: usize,
#[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
pub player: PlayerId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_position: Option<[f32; 3]>,
pub is_team_0: bool,
pub backboard_time: f32,
pub backboard_frame: usize,
}
#[derive(Debug, Clone)]
struct PendingBackboardBounce {
player_id: PlayerId,
is_team_0: bool,
time: f32,
frame: usize,
}
impl InFlightItem for PendingBackboardBounce {
fn recognition(&self) -> Recognition {
Recognition::speculative(self.time, self.frame)
}
fn on_boundary(&mut self, _boundary: Boundary) -> Disposition {
Disposition::Discard
}
}
#[derive(Debug, Clone, Default)]
pub struct DoubleTapCalculator {
events: EventStream<DoubleTapEvent>,
pending_backboard_bounces: KeyedInFlightLedger<PlayerId, PendingBackboardBounce>,
}
impl DoubleTapCalculator {
pub fn new() -> Self {
Self::default()
}
pub fn events(&self) -> &[DoubleTapEvent] {
self.events.all()
}
pub fn new_events(&self) -> &[DoubleTapEvent] {
self.events.new_events()
}
fn record_backboard_bounces(&mut self, state: &BackboardBounceState) {
for event in &state.bounce_events {
if Self::backboard_touch_was_grounded(event) {
continue;
}
self.pending_backboard_bounces.arm(
event.player.clone(),
PendingBackboardBounce {
player_id: event.player.clone(),
is_team_0: event.is_team_0,
time: event.time,
frame: event.frame,
},
);
}
}
fn backboard_touch_was_grounded(event: &BackboardBounceEvent) -> bool {
event
.player_position
.is_some_and(|position| PlayerVerticalBand::from_height(position[2]).is_grounded())
}
fn prune_surface_contacts(&mut self, players: &PlayerFrameState) {
self.pending_backboard_bounces.retain(|_, pending| {
!players
.player(&pending.player_id)
.is_some_and(player_sample_is_touching_surface)
});
}
fn resolve_double_tap_touches(
&mut self,
frame: &FrameInfo,
ball: &BallFrameState,
touch_state: &TouchState,
) {
if self.pending_backboard_bounces.is_empty() {
return;
}
let Some(touch) = touch_state.primary_touch_event() else {
return;
};
let resolved = self
.pending_backboard_bounces
.advance(frame.time, |_player, pending| {
if touch.time <= pending.time {
return Disposition::Keep;
}
let is_matching_followup = touch.team_is_team_0 == pending.is_team_0
&& touch.player.as_ref() == Some(&pending.player_id);
if !is_matching_followup {
return Disposition::Discard;
}
if Self::followup_touch_projects_on_goal_mouth(ball, pending.is_team_0) {
Disposition::Finalize(FinalizeReason::Completed)
} else {
Disposition::Discard
}
});
for (_player, pending, _reason) in resolved {
let event = DoubleTapEvent {
time: touch.time,
frame: touch.frame,
player: pending.player_id.clone(),
player_position: touch
.player_position
.map(|position| vec_to_glam(&position).to_array()),
is_team_0: pending.is_team_0,
backboard_time: pending.time,
backboard_frame: pending.frame,
};
self.record_double_tap(frame, event);
}
}
fn record_double_tap(&mut self, _frame: &FrameInfo, event: DoubleTapEvent) {
self.events.push(event);
}
fn followup_touch_projects_on_goal_mouth(ball: &BallFrameState, is_team_0: bool) -> bool {
const GOAL_LINE_Y: f32 = 5120.0;
const GOAL_MOUTH_HEIGHT_Z: f32 = 642.775;
const GOAL_MOUTH_TRAJECTORY_MARGIN: f32 = BALL_RADIUS_Z * 1.5;
let Some(ball) = ball.sample() else {
return false;
};
let target_y = if is_team_0 { GOAL_LINE_Y } else { -GOAL_LINE_Y };
let ball_velocity = ball.velocity();
if ball_velocity.length_squared() <= f32::EPSILON {
return false;
}
let time_to_goal_line = (target_y - ball.position().y) / ball_velocity.y;
if !time_to_goal_line.is_finite() || time_to_goal_line < 0.0 {
return false;
}
let projected = ball.position() + ball_velocity * time_to_goal_line;
projected.x.abs() <= BACK_WALL_GOAL_MOUTH_HALF_WIDTH_X + GOAL_MOUTH_TRAJECTORY_MARGIN
&& projected.z >= BALL_RADIUS_Z - GOAL_MOUTH_TRAJECTORY_MARGIN
&& projected.z <= GOAL_MOUTH_HEIGHT_Z + GOAL_MOUTH_TRAJECTORY_MARGIN
}
pub fn update(
&mut self,
frame: &FrameInfo,
ball: &BallFrameState,
players: &PlayerFrameState,
touch_state: &TouchState,
backboard_bounce_state: &BackboardBounceState,
live_play_state: &LivePlayState,
) -> SubtrActorResult<()> {
self.events.begin_update();
if !live_play_state.is_live_play {
self.pending_backboard_bounces
.apply_boundary(Boundary::LivePlayEnded);
}
self.record_backboard_bounces(backboard_bounce_state);
self.prune_surface_contacts(players);
self.resolve_double_tap_touches(frame, ball, touch_state);
Ok(())
}
}
#[cfg(test)]
#[path = "double_tap_tests.rs"]
mod tests;