use super::*;
const HALF_FLIP_EVALUATION_SECONDS: f32 = 0.65;
const HALF_FLIP_MAX_CANDIDATE_SECONDS: f32 = 1.0;
const HALF_FLIP_MAX_START_Z: f32 = PLAYER_GROUND_Z_THRESHOLD + 45.0;
const HALF_FLIP_MIN_START_SPEED: f32 = 250.0;
const HALF_FLIP_MIN_START_BACKWARD_ALIGNMENT: f32 = 0.55;
const HALF_FLIP_MIN_REORIENTATION_ALIGNMENT: f32 = 0.60;
const HALF_FLIP_MIN_FORWARD_REVERSAL: f32 = 0.55;
const HALF_FLIP_MIN_FORWARD_VERTICAL: f32 = 0.22;
const HALF_FLIP_MIN_CONFIDENCE: f32 = 0.55;
const HALF_FLIP_HIGH_CONFIDENCE: f32 = 0.78;
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct HalfFlipEvent {
pub time: f32,
pub frame: usize,
#[ts(as = "crate::ts_bindings::RemoteIdTs")]
pub player: PlayerId,
pub is_team_0: bool,
pub start_position: [f32; 3],
pub end_position: [f32; 3],
pub start_speed: f32,
pub end_speed: f32,
pub start_backward_alignment: f32,
pub best_reorientation_alignment: f32,
pub best_forward_reversal: f32,
pub max_forward_vertical: f32,
pub confidence: f32,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct HalfFlipStats {
pub count: u32,
pub high_confidence_count: u32,
pub is_last_half_flip: bool,
pub last_half_flip_time: Option<f32>,
pub last_half_flip_frame: Option<usize>,
pub time_since_last_half_flip: Option<f32>,
pub frames_since_last_half_flip: Option<usize>,
pub last_quality: Option<f32>,
pub best_quality: f32,
pub cumulative_quality: f32,
#[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
pub labeled_event_counts: LabeledCounts,
}
impl HalfFlipStats {
pub fn average_quality(&self) -> f32 {
if self.count == 0 {
0.0
} else {
self.cumulative_quality / self.count as f32
}
}
fn record_event(&mut self, event: &HalfFlipEvent) {
self.labeled_event_counts.increment([confidence_band_label(
event.confidence >= HALF_FLIP_HIGH_CONFIDENCE,
)]);
self.sync_legacy_counts();
self.last_half_flip_time = Some(event.time);
self.last_half_flip_frame = Some(event.frame);
self.last_quality = Some(event.confidence);
self.best_quality = self.best_quality.max(event.confidence);
self.cumulative_quality += event.confidence;
}
pub fn event_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
self.labeled_event_counts.count_matching(labels)
}
pub fn complete_labeled_event_counts(&self) -> LabeledCounts {
LabeledCounts::complete_from_label_sets(
&[&CONFIDENCE_BAND_LABELS],
&self.labeled_event_counts,
)
}
fn sync_legacy_counts(&mut self) {
self.count = self.labeled_event_counts.total();
self.high_confidence_count = self.event_count_with_labels(&[confidence_band_label(true)]);
}
}
#[derive(Debug, Clone, PartialEq)]
struct ActiveHalfFlipCandidate {
is_team_0: bool,
start_time: f32,
start_frame: usize,
latest_time: f32,
latest_frame: usize,
start_position: [f32; 3],
end_position: [f32; 3],
start_speed: f32,
end_speed: f32,
start_forward_xy: glam::Vec2,
start_backward_alignment: f32,
best_reorientation_alignment: f32,
best_forward_reversal: f32,
max_forward_vertical: f32,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct HalfFlipCalculator {
player_stats: HashMap<PlayerId, HalfFlipStats>,
events: Vec<HalfFlipEvent>,
active_candidates: HashMap<PlayerId, ActiveHalfFlipCandidate>,
previous_dodge_active: HashMap<PlayerId, bool>,
current_last_half_flip_player: Option<PlayerId>,
}
impl HalfFlipCalculator {
pub fn new() -> Self {
Self::default()
}
pub fn player_stats(&self) -> &HashMap<PlayerId, HalfFlipStats> {
&self.player_stats
}
pub fn events(&self) -> &[HalfFlipEvent] {
&self.events
}
fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
if max_value <= min_value {
return 0.0;
}
((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
}
fn horizontal_velocity(player: &PlayerSample) -> Option<glam::Vec2> {
let velocity = player.velocity()?.truncate();
if velocity.length_squared() <= f32::EPSILON {
return None;
}
Some(velocity)
}
fn forward_vector(player: &PlayerSample) -> Option<glam::Vec3> {
let rigid_body = player.rigid_body.as_ref()?;
Some(quat_to_glam(&rigid_body.rotation) * glam::Vec3::X)
}
fn forward_xy(player: &PlayerSample) -> Option<glam::Vec2> {
let forward_xy = Self::forward_vector(player)?.truncate().normalize_or_zero();
if forward_xy.length_squared() <= f32::EPSILON {
return None;
}
Some(forward_xy)
}
fn maybe_start_candidate(&mut self, frame: &FrameInfo, player: &PlayerSample) {
let was_dodge_active = self
.previous_dodge_active
.insert(player.player_id.clone(), player.dodge_active)
.unwrap_or(false);
if !player.dodge_active || was_dodge_active {
return;
}
let Some(position) = player.position() else {
return;
};
if position.z > HALF_FLIP_MAX_START_Z {
return;
}
let velocity_xy = Self::horizontal_velocity(player).unwrap_or(glam::Vec2::ZERO);
let start_speed = velocity_xy.length();
if start_speed < HALF_FLIP_MIN_START_SPEED {
return;
}
let Some(start_forward_xy) = Self::forward_xy(player) else {
return;
};
let velocity_direction = velocity_xy.normalize_or_zero();
let start_backward_alignment = -start_forward_xy.dot(velocity_direction);
if start_backward_alignment < HALF_FLIP_MIN_START_BACKWARD_ALIGNMENT {
return;
}
let max_forward_vertical =
Self::forward_vector(player).map_or(0.0, |forward| forward.z.abs());
self.active_candidates.insert(
player.player_id.clone(),
ActiveHalfFlipCandidate {
is_team_0: player.is_team_0,
start_time: frame.time,
start_frame: frame.frame_number,
latest_time: frame.time,
latest_frame: frame.frame_number,
start_position: position.to_array(),
end_position: position.to_array(),
start_speed,
end_speed: start_speed,
start_forward_xy,
start_backward_alignment,
best_reorientation_alignment: 0.0,
best_forward_reversal: 0.0,
max_forward_vertical,
},
);
}
fn update_candidate(
candidate: &mut ActiveHalfFlipCandidate,
frame: &FrameInfo,
player: &PlayerSample,
) {
if let Some(position) = player.position() {
candidate.end_position = position.to_array();
}
let velocity_xy = Self::horizontal_velocity(player).unwrap_or(glam::Vec2::ZERO);
candidate.end_speed = velocity_xy.length();
let velocity_direction = velocity_xy.normalize_or_zero();
if let Some(forward) = Self::forward_vector(player) {
candidate.max_forward_vertical = candidate.max_forward_vertical.max(forward.z.abs());
let forward_xy = forward.truncate().normalize_or_zero();
if forward_xy.length_squared() > f32::EPSILON {
candidate.best_forward_reversal = candidate
.best_forward_reversal
.max((-candidate.start_forward_xy.dot(forward_xy)).clamp(-1.0, 1.0));
if velocity_direction.length_squared() > f32::EPSILON {
candidate.best_reorientation_alignment = candidate
.best_reorientation_alignment
.max(forward_xy.dot(velocity_direction));
}
}
}
candidate.latest_time = frame.time;
candidate.latest_frame = frame.frame_number;
}
fn candidate_event(
player_id: &PlayerId,
candidate: ActiveHalfFlipCandidate,
) -> Option<HalfFlipEvent> {
if candidate.best_reorientation_alignment < HALF_FLIP_MIN_REORIENTATION_ALIGNMENT
|| candidate.best_forward_reversal < HALF_FLIP_MIN_FORWARD_REVERSAL
|| candidate.max_forward_vertical < HALF_FLIP_MIN_FORWARD_VERTICAL
{
return None;
}
let backward_score = Self::normalize_score(
candidate.start_backward_alignment,
HALF_FLIP_MIN_START_BACKWARD_ALIGNMENT,
0.95,
);
let reorientation_score = Self::normalize_score(
candidate.best_reorientation_alignment,
HALF_FLIP_MIN_REORIENTATION_ALIGNMENT,
0.98,
);
let reversal_score = Self::normalize_score(
candidate.best_forward_reversal,
HALF_FLIP_MIN_FORWARD_REVERSAL,
0.98,
);
let flip_score = Self::normalize_score(
candidate.max_forward_vertical,
HALF_FLIP_MIN_FORWARD_VERTICAL,
0.85,
);
let speed_score = Self::normalize_score(candidate.end_speed, 900.0, 1800.0).max(
Self::normalize_score(candidate.end_speed - candidate.start_speed, 100.0, 700.0) * 0.7,
);
let confidence = 0.25 * backward_score
+ 0.30 * reorientation_score
+ 0.25 * reversal_score
+ 0.10 * flip_score
+ 0.10 * speed_score;
if confidence < HALF_FLIP_MIN_CONFIDENCE {
return None;
}
Some(HalfFlipEvent {
time: candidate.latest_time,
frame: candidate.latest_frame,
player: player_id.clone(),
is_team_0: candidate.is_team_0,
start_position: candidate.start_position,
end_position: candidate.end_position,
start_speed: candidate.start_speed,
end_speed: candidate.end_speed,
start_backward_alignment: candidate.start_backward_alignment,
best_reorientation_alignment: candidate.best_reorientation_alignment,
best_forward_reversal: candidate.best_forward_reversal,
max_forward_vertical: candidate.max_forward_vertical,
confidence,
})
}
fn apply_event(&mut self, event: HalfFlipEvent) {
for stats in self.player_stats.values_mut() {
stats.is_last_half_flip = false;
}
let stats = self.player_stats.entry(event.player.clone()).or_default();
stats.record_event(&event);
stats.is_last_half_flip = true;
stats.time_since_last_half_flip = Some(0.0);
stats.frames_since_last_half_flip = Some(0);
self.current_last_half_flip_player = Some(event.player.clone());
self.events.push(event);
}
fn begin_sample(&mut self, frame: &FrameInfo) {
for stats in self.player_stats.values_mut() {
stats.is_last_half_flip = false;
stats.time_since_last_half_flip = stats
.last_half_flip_time
.map(|time| (frame.time - time).max(0.0));
stats.frames_since_last_half_flip = stats
.last_half_flip_frame
.map(|last_frame| frame.frame_number.saturating_sub(last_frame));
}
if let Some(player_id) = self.current_last_half_flip_player.as_ref() {
if let Some(stats) = self.player_stats.get_mut(player_id) {
stats.is_last_half_flip = true;
}
}
}
fn finalize_candidates(&mut self, frame: &FrameInfo, force_all: bool) {
let mut finished_candidates = Vec::new();
for (player_id, candidate) in &self.active_candidates {
let duration = frame.time - candidate.start_time;
if force_all || duration >= HALF_FLIP_EVALUATION_SECONDS {
finished_candidates.push((
candidate.start_time,
candidate.start_frame,
format!("{player_id:?}"),
player_id.clone(),
));
}
}
finished_candidates.sort_by(|left, right| {
left.0
.total_cmp(&right.0)
.then_with(|| left.1.cmp(&right.1))
.then_with(|| left.2.cmp(&right.2))
});
for (_, _, _, player_id) in finished_candidates {
let Some(candidate) = self.active_candidates.remove(&player_id) else {
continue;
};
if let Some(event) = Self::candidate_event(&player_id, candidate) {
self.apply_event(event);
}
}
}
pub fn update(
&mut self,
frame: &FrameInfo,
players: &PlayerFrameState,
live_play: bool,
) -> SubtrActorResult<()> {
if !live_play {
self.active_candidates.clear();
self.current_last_half_flip_player = None;
return Ok(());
}
self.begin_sample(frame);
for player in &players.players {
self.maybe_start_candidate(frame, player);
}
let mut visible_players = HashSet::new();
for player in &players.players {
visible_players.insert(player.player_id.clone());
if let Some(candidate) = self.active_candidates.get_mut(&player.player_id) {
Self::update_candidate(candidate, frame, player);
}
}
self.finalize_candidates(frame, false);
self.active_candidates.retain(|player_id, candidate| {
visible_players.contains(player_id)
&& frame.time - candidate.start_time <= HALF_FLIP_MAX_CANDIDATE_SECONDS
});
Ok(())
}
pub fn finalize(&mut self, frame: &FrameInfo) {
self.finalize_candidates(frame, true);
}
}
#[cfg(test)]
#[path = "half_flip_tests.rs"]
mod tests;