//! Presentation-only state and tuning for the match camera. use bevy::prelude::*; use crate::{fighter::manifest::FighterManifest, 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]; /// 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, } /// 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(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 }; let depth_fraction = ((current_depth - profile.min_depth) / (profile.max_depth - profile.min_depth).max(f32::EPSILON)) .clamp(0.0, 1.0); frame.bottom = (frame.bottom - profile.max_downward_expansion * depth_fraction).max(profile.bottom); frame } /// 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 - profile.origin.y) * profile.vertical_pan_coefficient).clamp( -profile.max_downward_pan_degrees.to_radians(), profile.max_upward_pan_degrees.to_radians(), ) + profile.vertical_tilt_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_half_fov = (vertical_half_fov.tan() * aspect_ratio).atan(); let horizontal_center = (frame.left + frame.right) * 0.5; let horizontal_angle = (-(horizontal_center - profile.origin.x) * profile.horizontal_pan_coefficient).clamp( -profile.max_horizontal_pan_degrees.to_radians(), profile.max_horizontal_pan_degrees.to_radians(), ); let right_angle = horizontal_half_fov - horizontal_angle; let left_angle = horizontal_half_fov + horizontal_angle; let horizontal_depth = frame.width() / (right_angle.tan() + left_angle.tan()).max(f32::EPSILON); let horizontal_eye_offset = horizontal_depth * horizontal_angle.tan(); let interest_x = frame.right - horizontal_depth * right_angle.tan() - 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).clamp( Vec2::new(profile.left, profile.bottom), Vec2::new(profile.right, profile.top), ); CameraPose { eye: interest + Vec2::new(horizontal_eye_offset, -vertical_eye_offset), interest, depth, fov: profile.vertical_fov_degrees.to_radians(), } } fn update_match_camera( time: Res