From 730ffb777e7d5db487b0b3b8e47af8ecf0b8eb9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Rom=C3=A0n?= Date: Fri, 28 Aug 2026 19:22:57 +0200 Subject: [PATCH] Implement camera system --- src/camera/mod.rs | 525 ++++++++++++++++++++++++++++++++++++++++ src/fighter/manifest.rs | 10 +- src/fighter/mod.rs | 24 ++ src/lib.rs | 4 +- src/match_loading.rs | 20 +- src/stage/manifest.rs | 68 +++++- src/stage/mod.rs | 12 +- src/stage/scene.rs | 14 +- 8 files changed, 666 insertions(+), 11 deletions(-) create mode 100644 src/camera/mod.rs diff --git a/src/camera/mod.rs b/src/camera/mod.rs new file mode 100644 index 0000000..c2560fd --- /dev/null +++ b/src/camera/mod.rs @@ -0,0 +1,525 @@ +//! Presentation-only state and tuning for the match camera. + +use bevy::prelude::*; + +use crate::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