//! Presentation-only state and tuning for the match camera. use bevy::prelude::*; use bevy_ggrs::GgrsFrameTiming; use crate::{ debug_tools::DebugSettings, fighter::{manifest::FighterManifest, motion::{sample_presentation_translation_with_snap, FighterPresentationSnap}}, stage::manifest::StageCameraProfile, }; const EMPTY_FRAME_HALF_SIZE: f32 = 40.0; const SUBJECT_COUNT_SCALES: [f32; 4] = [1.5, 1.32, 1.16, 1.0]; const VERTICAL_PAN_REFERENCE_OFFSET: f32 = -30.0; const EXTENT_APPROACH_PER_SECOND: f32 = 30.0; const MELEE_DOWNWARD_EXPANSION_START_DEPTH: f32 = 80.0; const MELEE_DOWNWARD_EXPANSION_END_DEPTH: f32 = 5000.0; const MELEE_DOWNWARD_EXPANSION_MIN: f32 = 10.0; const MELEE_DOWNWARD_EXPANSION_MAX: f32 = 400.0; const MELEE_INTEREST_FOLLOW_MIN: f32 = 0.05; const MELEE_INTEREST_FOLLOW_MAX: f32 = 0.1; const MELEE_INTEREST_FOLLOW_START_SPREAD: f32 = 120.0; const MELEE_INTEREST_FOLLOW_END_SPREAD: f32 = 900.0; const MELEE_EYE_FOLLOW: f32 = 0.15; const MELEE_FOV_FOLLOW: f32 = 0.1; /// The rectangle contributed by one tracked subject on the gameplay plane. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct CameraSubjectBounds { pub center: Vec2, pub left_extent: f32, pub right_extent: f32, pub bottom_extent: f32, pub top_extent: f32, } /// Presentation-only fighter camera extents that ease toward the manifest /// values. This prevents a facing change from instantly expanding the frame. #[derive(Component, Clone, Copy, Debug, Default)] pub struct FighterCameraExtents { left: f32, right: f32, bottom: f32, top: f32, initialized: bool, } /// The aggregate rectangle the camera must keep in view on the gameplay plane. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct CameraFrameBounds { pub left: f32, pub right: f32, pub bottom: f32, pub top: f32, } impl CameraFrameBounds { fn around(center: Vec2, half_size: f32) -> Self { Self { left: center.x - half_size, right: center.x + half_size, bottom: center.y - half_size, top: center.y + half_size, } } fn width(self) -> f32 { self.right - self.left } fn height(self) -> f32 { self.top - self.bottom } } /// A solved camera pose before it is smoothed and applied to the render camera. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct CameraPose { pub interest: Vec2, pub eye: Vec2, pub depth: f32, pub fov: f32, } /// Runtime presentation state for the match camera. /// /// This intentionally remains outside rollback: it follows already-simulated /// fighter positions and must not affect gameplay. #[derive(Component, Clone, Copy, Debug)] pub struct MatchCamera { pub current: CameraPose, pub target: CameraPose, pub frame: CameraFrameBounds, pub shake_offset: Vec2, pub needs_snap: bool, } impl Default for MatchCamera { fn default() -> Self { Self { current: CameraPose::default(), target: CameraPose::default(), frame: CameraFrameBounds::default(), shake_offset: Vec2::ZERO, needs_snap: true, } } } /// Owns the presentation-only match-camera systems. #[derive(Default)] pub struct MatchCameraPlugin; impl Plugin for MatchCameraPlugin { fn build(&self, app: &mut App) { app.add_systems( Update, ( update_match_camera, debug_draw_camera.run_if(crate::debug_tools::camera_debug_enabled), ) .chain() .run_if(in_state(crate::AppState::InMatch)), ); } } /// Produces the aggregate rectangle to frame, constraining target contributions /// to the explicit stage camera rectangle. pub fn frame_subjects( subjects: impl IntoIterator, profile: &StageCameraProfile, current_depth: f32, ) -> CameraFrameBounds { let subjects: Vec<_> = subjects.into_iter().collect(); let scale = profile.subject_scale * SUBJECT_COUNT_SCALES[subjects .len() .saturating_sub(1) .min(SUBJECT_COUNT_SCALES.len() - 1)]; let mut frame = if subjects.is_empty() { CameraFrameBounds::around(profile.origin, EMPTY_FRAME_HALF_SIZE) } else { let mut frame = CameraFrameBounds { left: f32::INFINITY, right: f32::NEG_INFINITY, bottom: f32::INFINITY, top: f32::NEG_INFINITY, }; for subject in subjects { let center = subject.center.clamp( Vec2::new(profile.left, profile.bottom), Vec2::new(profile.right, profile.top), ); let left_extent = subject.left_extent * scale; let right_extent = subject.right_extent * scale; let bottom_extent = subject.bottom_extent * scale; let top_extent = subject.top_extent * scale; frame.left = frame .left .min((center.x - left_extent).clamp(profile.left, profile.right)); frame.right = frame .right .max((center.x + right_extent).clamp(profile.left, profile.right)); frame.bottom = frame .bottom .min((center.y - bottom_extent).clamp(profile.bottom, profile.top)); frame.top = frame .top .max((center.y + top_extent).clamp(profile.bottom, profile.top)); } frame }; frame.bottom -= melee_downward_expansion(current_depth); frame } fn melee_downward_expansion(depth: f32) -> f32 { let fraction = ((depth.abs() - MELEE_DOWNWARD_EXPANSION_START_DEPTH) / (MELEE_DOWNWARD_EXPANSION_END_DEPTH - MELEE_DOWNWARD_EXPANSION_START_DEPTH)) .clamp(0.0, 1.0); MELEE_DOWNWARD_EXPANSION_MIN + (MELEE_DOWNWARD_EXPANSION_MAX - MELEE_DOWNWARD_EXPANSION_MIN) * fraction } /// Solves a perspective camera pose that contains `frame` at the supplied /// aspect ratio. The profile supplies Melee-style stage bias and pan limits. pub fn solve_pose( frame: CameraFrameBounds, profile: &StageCameraProfile, aspect_ratio: f32, ) -> CameraPose { let aspect_ratio = aspect_ratio.max(f32::EPSILON); let vertical_half_fov = profile.vertical_fov_degrees.to_radians() * 0.5; let spread = frame.width().max(frame.height()); let vertical_bias = ((spread - 60.0) / 60.0).clamp(0.0, 1.0) * 0.0682; let vertical_center = profile.origin.y + ((frame.bottom - profile.origin.y) + (frame.top - profile.origin.y)) * (0.5 - vertical_bias); let vertical_angle = (-(vertical_center + VERTICAL_PAN_REFERENCE_OFFSET) * profile.vertical_pan_degrees_per_unit) .to_radians() .clamp( -profile.max_downward_pan_degrees.to_radians(), profile.max_upward_pan_degrees.to_radians(), ) + profile.vertical_pan_degrees.to_radians(); let up_angle = vertical_half_fov + vertical_angle; let down_angle = vertical_half_fov - vertical_angle; let vertical_depth = frame.height() / (up_angle.tan() + down_angle.tan()).max(f32::EPSILON); let vertical_eye_offset = vertical_depth * vertical_angle.tan(); let interest_y = vertical_eye_offset + frame.top - vertical_depth * up_angle.tan(); let horizontal_center = (frame.left + frame.right) * 0.5; let horizontal_angle = (-(horizontal_center - profile.origin.x) * profile.horizontal_pan_degrees_per_unit) .to_radians() .clamp( -profile.max_horizontal_pan_degrees.to_radians(), profile.max_horizontal_pan_degrees.to_radians(), ); let right_tangent = aspect_ratio * (vertical_half_fov - horizontal_angle).tan(); let left_tangent = aspect_ratio * (vertical_half_fov + horizontal_angle).tan(); let horizontal_depth = frame.width() / (right_tangent + left_tangent).max(f32::EPSILON); let horizontal_eye_offset = aspect_ratio * horizontal_depth * horizontal_angle.tan(); let interest_x = frame.right - horizontal_depth * right_tangent - horizontal_eye_offset; let depth = horizontal_depth .max(vertical_depth) .clamp(profile.min_depth, profile.max_depth); let interest = Vec2::new(interest_x, interest_y); correct_pose_to_camera_bounds( CameraPose { eye: interest + Vec2::new(horizontal_eye_offset, -vertical_eye_offset), interest, depth, fov: profile.vertical_fov_degrees.to_radians(), }, profile, aspect_ratio, ) } fn correct_pose_to_camera_bounds( mut pose: CameraPose, profile: &StageCameraProfile, aspect_ratio: f32, ) -> CameraPose { let Some(corners) = view_corners_on_plane(pose, aspect_ratio) else { return pose; }; let mut left = f32::INFINITY; let mut right = f32::NEG_INFINITY; let mut bottom = f32::INFINITY; let mut top = f32::NEG_INFINITY; for corner in corners { left = left.min(corner.x); right = right.max(corner.x); bottom = bottom.min(corner.y); top = top.max(corner.y); } let correction = Vec2::new( axis_correction(left, right, profile.left, profile.right), axis_correction(bottom, top, profile.bottom, profile.top), ); pose.eye += correction; pose.interest += correction; pose } fn axis_correction(minimum: f32, maximum: f32, lower_bound: f32, upper_bound: f32) -> f32 { let lower_overlap = (lower_bound - minimum).max(0.0); let upper_overlap = (upper_bound - maximum).min(0.0); match (lower_overlap > 0.0, upper_overlap < 0.0) { (true, true) => (lower_overlap + upper_overlap) * 0.5, (true, false) => lower_overlap, (false, true) => upper_overlap, (false, false) => 0.0, } } fn view_corners_on_plane(pose: CameraPose, aspect_ratio: f32) -> Option<[Vec2; 4]> { let eye = Vec3::new(pose.eye.x, pose.eye.y, pose.depth); let interest = Vec3::new(pose.interest.x, pose.interest.y, 0.0); let forward = (interest - eye).try_normalize()?; let right = forward.cross(Vec3::Y).try_normalize()?; let up = right.cross(forward).try_normalize()?; let vertical_tangent = (pose.fov * 0.5).tan(); let horizontal_tangent = vertical_tangent * aspect_ratio; [(1.0, 1.0), (-1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)] .map(|(horizontal, vertical)| { let ray = (forward + right * horizontal_tangent * horizontal + up * vertical_tangent * vertical) .try_normalize()?; let distance = -eye.z / ray.z; (distance >= 0.0).then(|| (eye + ray * distance).truncate()) }) .into_iter() .collect::>>() .and_then(|corners| corners.try_into().ok()) } fn update_match_camera( time: Res