Implement camera system
This commit is contained in:
@@ -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<Item = CameraSubjectBounds>,
|
||||||
|
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<Time>,
|
||||||
|
fighters: Query<
|
||||||
|
(
|
||||||
|
&crate::fighter::motion::FighterTranslation,
|
||||||
|
&crate::fighter::FighterCameraProfile,
|
||||||
|
&crate::fighter::FighterFacingDirection,
|
||||||
|
),
|
||||||
|
With<crate::player::Player>,
|
||||||
|
>,
|
||||||
|
mut cameras: Query<(
|
||||||
|
&mut Transform,
|
||||||
|
&mut Projection,
|
||||||
|
&mut MatchCamera,
|
||||||
|
&StageCameraProfile,
|
||||||
|
)>,
|
||||||
|
) {
|
||||||
|
let subjects = fighters.iter().map(|(translation, profile, facing)| {
|
||||||
|
fighter_subject_bounds(
|
||||||
|
Vec2::new(translation.x.to_num(), translation.y.to_num()),
|
||||||
|
*profile,
|
||||||
|
*facing,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
for (mut transform, mut projection, mut camera, profile) in &mut cameras {
|
||||||
|
let Projection::Perspective(projection) = projection.as_mut() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
camera.frame = frame_subjects(subjects.clone(), profile, camera.current.depth);
|
||||||
|
camera.target = solve_pose(camera.frame, profile, projection.aspect_ratio);
|
||||||
|
|
||||||
|
if camera.needs_snap {
|
||||||
|
camera.current = camera.target;
|
||||||
|
camera.needs_snap = false;
|
||||||
|
} else {
|
||||||
|
let spread = camera.frame.width().max(camera.frame.height());
|
||||||
|
let interest_rate =
|
||||||
|
profile.tracking_smoothness * (1.0 + ((spread - 120.0) / 780.0).clamp(0.0, 1.0));
|
||||||
|
let interest_alpha = 1.0 - (-interest_rate * time.delta_secs()).exp();
|
||||||
|
let eye_alpha = 1.0 - (-(profile.tracking_smoothness * 3.0) * time.delta_secs()).exp();
|
||||||
|
camera.current.interest = camera
|
||||||
|
.current
|
||||||
|
.interest
|
||||||
|
.lerp(camera.target.interest, interest_alpha);
|
||||||
|
camera.current.eye = camera.current.eye.lerp(camera.target.eye, eye_alpha);
|
||||||
|
camera.current.depth += (camera.target.depth - camera.current.depth) * eye_alpha;
|
||||||
|
camera.current.fov += (camera.target.fov - camera.current.fov) * interest_alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
projection.fov = camera.current.fov;
|
||||||
|
transform.translation = Vec3::new(
|
||||||
|
camera.current.eye.x + camera.shake_offset.x,
|
||||||
|
camera.current.eye.y + camera.shake_offset.y,
|
||||||
|
camera.current.depth,
|
||||||
|
);
|
||||||
|
transform.look_at(
|
||||||
|
Vec3::new(camera.current.interest.x, camera.current.interest.y, 0.0),
|
||||||
|
Vec3::Y,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fighter_subject_bounds(
|
||||||
|
translation: Vec2,
|
||||||
|
profile: crate::fighter::FighterCameraProfile,
|
||||||
|
facing: crate::fighter::FighterFacingDirection,
|
||||||
|
) -> CameraSubjectBounds {
|
||||||
|
let (left_extent, right_extent) = match facing {
|
||||||
|
crate::fighter::FighterFacingDirection::Left => (
|
||||||
|
profile.forward_extent.to_num(),
|
||||||
|
profile.backward_extent.to_num(),
|
||||||
|
),
|
||||||
|
crate::fighter::FighterFacingDirection::Right => (
|
||||||
|
profile.backward_extent.to_num(),
|
||||||
|
profile.forward_extent.to_num(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
CameraSubjectBounds {
|
||||||
|
center: translation + Vec2::Y * profile.vertical_origin_offset.to_num::<f32>(),
|
||||||
|
left_extent,
|
||||||
|
right_extent,
|
||||||
|
bottom_extent: profile.downward_extent.to_num(),
|
||||||
|
top_extent: profile.upward_extent.to_num(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn debug_draw_camera(
|
||||||
|
mut gizmos: Gizmos,
|
||||||
|
fighters: Query<
|
||||||
|
(
|
||||||
|
&crate::fighter::motion::FighterTranslation,
|
||||||
|
&crate::fighter::FighterCameraProfile,
|
||||||
|
&crate::fighter::FighterFacingDirection,
|
||||||
|
),
|
||||||
|
With<crate::player::Player>,
|
||||||
|
>,
|
||||||
|
cameras: Query<(&MatchCamera, &StageCameraProfile)>,
|
||||||
|
) {
|
||||||
|
for (camera, profile) in &cameras {
|
||||||
|
draw_rectangle(
|
||||||
|
&mut gizmos,
|
||||||
|
CameraFrameBounds {
|
||||||
|
left: profile.left,
|
||||||
|
right: profile.right,
|
||||||
|
bottom: profile.bottom,
|
||||||
|
top: profile.top,
|
||||||
|
},
|
||||||
|
Color::srgb(1.0, 0.5, 0.0),
|
||||||
|
);
|
||||||
|
draw_rectangle(&mut gizmos, camera.frame, Color::srgb(1.0, 1.0, 0.0));
|
||||||
|
for (translation, fighter_profile, facing) in &fighters {
|
||||||
|
let subject = fighter_subject_bounds(
|
||||||
|
Vec2::new(translation.x.to_num(), translation.y.to_num()),
|
||||||
|
*fighter_profile,
|
||||||
|
*facing,
|
||||||
|
);
|
||||||
|
draw_rectangle(
|
||||||
|
&mut gizmos,
|
||||||
|
CameraFrameBounds {
|
||||||
|
left: subject.center.x - subject.left_extent,
|
||||||
|
right: subject.center.x + subject.right_extent,
|
||||||
|
bottom: subject.center.y - subject.bottom_extent,
|
||||||
|
top: subject.center.y + subject.top_extent,
|
||||||
|
},
|
||||||
|
Color::srgb(0.0, 1.0, 0.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
gizmos.circle(
|
||||||
|
Vec3::new(camera.current.interest.x, camera.current.interest.y, 0.0),
|
||||||
|
1.0,
|
||||||
|
Color::srgb(0.0, 1.0, 1.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_rectangle(gizmos: &mut Gizmos, frame: CameraFrameBounds, color: Color) {
|
||||||
|
let bottom_left = Vec3::new(frame.left, frame.bottom, 0.0);
|
||||||
|
let bottom_right = Vec3::new(frame.right, frame.bottom, 0.0);
|
||||||
|
let top_left = Vec3::new(frame.left, frame.top, 0.0);
|
||||||
|
let top_right = Vec3::new(frame.right, frame.top, 0.0);
|
||||||
|
gizmos.line(bottom_left, bottom_right, color);
|
||||||
|
gizmos.line(bottom_right, top_right, color);
|
||||||
|
gizmos.line(top_right, top_left, color);
|
||||||
|
gizmos.line(top_left, bottom_left, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn profile() -> StageCameraProfile {
|
||||||
|
StageCameraProfile {
|
||||||
|
left: -100.0,
|
||||||
|
right: 100.0,
|
||||||
|
bottom: -50.0,
|
||||||
|
top: 100.0,
|
||||||
|
origin: Vec2::new(0.0, 20.0),
|
||||||
|
vertical_fov_degrees: 30.0,
|
||||||
|
min_depth: 20.0,
|
||||||
|
max_depth: 200.0,
|
||||||
|
subject_scale: 1.0,
|
||||||
|
tracking_smoothness: 3.0,
|
||||||
|
vertical_tilt_degrees: 0.0,
|
||||||
|
horizontal_pan_coefficient: 0.0,
|
||||||
|
vertical_pan_coefficient: 0.0,
|
||||||
|
max_horizontal_pan_degrees: 17.5,
|
||||||
|
max_upward_pan_degrees: 5.0,
|
||||||
|
max_downward_pan_degrees: 7.0,
|
||||||
|
max_downward_expansion: 40.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fighter_profile() -> crate::fighter::FighterCameraProfile {
|
||||||
|
crate::fighter::FighterCameraProfile {
|
||||||
|
vertical_origin_offset: crate::math::int::FGi32::lit("1.5"),
|
||||||
|
forward_extent: crate::math::int::FGi32::lit("4.0"),
|
||||||
|
backward_extent: crate::math::int::FGi32::lit("2.0"),
|
||||||
|
upward_extent: crate::math::int::FGi32::lit("3.0"),
|
||||||
|
downward_extent: crate::math::int::FGi32::lit("1.0"),
|
||||||
|
visibility_radius: crate::math::int::FGi32::lit("3.0"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fighter_camera_bounds_mirror_with_facing_direction() {
|
||||||
|
let profile = fighter_profile();
|
||||||
|
let translation = Vec2::new(10.0, 20.0);
|
||||||
|
let right = fighter_subject_bounds(
|
||||||
|
translation,
|
||||||
|
profile,
|
||||||
|
crate::fighter::FighterFacingDirection::Right,
|
||||||
|
);
|
||||||
|
let left = fighter_subject_bounds(
|
||||||
|
translation,
|
||||||
|
profile,
|
||||||
|
crate::fighter::FighterFacingDirection::Left,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(right.center, Vec2::new(10.0, 21.5));
|
||||||
|
assert_eq!(right.left_extent, 2.0);
|
||||||
|
assert_eq!(right.right_extent, 4.0);
|
||||||
|
assert_eq!(left.left_extent, 4.0);
|
||||||
|
assert_eq!(left.right_extent, 2.0);
|
||||||
|
assert_eq!(right.bottom_extent, 1.0);
|
||||||
|
assert_eq!(right.top_extent, 3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_frame_is_centered_on_the_stage_origin() {
|
||||||
|
let profile = profile();
|
||||||
|
let frame = frame_subjects([], &profile, profile.min_depth);
|
||||||
|
|
||||||
|
assert_eq!(frame.left, -40.0);
|
||||||
|
assert_eq!(frame.right, 40.0);
|
||||||
|
assert_eq!(frame.bottom, -20.0);
|
||||||
|
assert_eq!(frame.top, 60.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subject_bounds_are_clamped_to_camera_limits() {
|
||||||
|
let profile = profile();
|
||||||
|
let frame = frame_subjects(
|
||||||
|
[CameraSubjectBounds {
|
||||||
|
center: Vec2::new(300.0, 300.0),
|
||||||
|
left_extent: 20.0,
|
||||||
|
right_extent: 20.0,
|
||||||
|
bottom_extent: 20.0,
|
||||||
|
top_extent: 20.0,
|
||||||
|
}],
|
||||||
|
&profile,
|
||||||
|
profile.min_depth,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(frame.right, profile.right);
|
||||||
|
assert_eq!(frame.top, profile.top);
|
||||||
|
assert!(frame.left >= profile.left);
|
||||||
|
assert!(frame.bottom >= profile.bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn four_subjects_use_the_base_scale() {
|
||||||
|
let profile = profile();
|
||||||
|
let subject = CameraSubjectBounds {
|
||||||
|
center: Vec2::ZERO,
|
||||||
|
left_extent: 10.0,
|
||||||
|
right_extent: 10.0,
|
||||||
|
bottom_extent: 10.0,
|
||||||
|
top_extent: 10.0,
|
||||||
|
};
|
||||||
|
let one = frame_subjects([subject], &profile, profile.min_depth);
|
||||||
|
let four = frame_subjects([subject; 4], &profile, profile.min_depth);
|
||||||
|
|
||||||
|
assert_eq!(one.width(), 30.0);
|
||||||
|
assert_eq!(four.width(), 20.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn depth_grows_with_subject_separation_and_obeys_limits() {
|
||||||
|
let profile = profile();
|
||||||
|
let near = solve_pose(
|
||||||
|
CameraFrameBounds {
|
||||||
|
left: -1.0,
|
||||||
|
right: 1.0,
|
||||||
|
bottom: 19.0,
|
||||||
|
top: 21.0,
|
||||||
|
},
|
||||||
|
&profile,
|
||||||
|
16.0 / 9.0,
|
||||||
|
);
|
||||||
|
let far = solve_pose(
|
||||||
|
CameraFrameBounds {
|
||||||
|
left: -100.0,
|
||||||
|
right: 100.0,
|
||||||
|
bottom: -50.0,
|
||||||
|
top: 100.0,
|
||||||
|
},
|
||||||
|
&profile,
|
||||||
|
16.0 / 9.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(near.depth, profile.min_depth);
|
||||||
|
assert!(far.depth > near.depth);
|
||||||
|
assert_eq!(far.depth, profile.max_depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn narrow_aspect_ratio_requires_more_depth_for_horizontal_spread() {
|
||||||
|
let profile = profile();
|
||||||
|
let frame = CameraFrameBounds {
|
||||||
|
left: -50.0,
|
||||||
|
right: 50.0,
|
||||||
|
bottom: 19.0,
|
||||||
|
top: 21.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let narrow = solve_pose(frame, &profile, 4.0 / 3.0);
|
||||||
|
let wide = solve_pose(frame, &profile, 16.0 / 9.0);
|
||||||
|
|
||||||
|
assert!(narrow.depth > wide.depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downward_expansion_increases_with_depth_without_crossing_stage_bottom() {
|
||||||
|
let profile = profile();
|
||||||
|
let subject = CameraSubjectBounds {
|
||||||
|
center: Vec2::new(0.0, 0.0),
|
||||||
|
left_extent: 0.0,
|
||||||
|
right_extent: 0.0,
|
||||||
|
bottom_extent: 0.0,
|
||||||
|
top_extent: 0.0,
|
||||||
|
};
|
||||||
|
let near = frame_subjects([subject], &profile, profile.min_depth);
|
||||||
|
let far = frame_subjects([subject], &profile, profile.max_depth);
|
||||||
|
|
||||||
|
assert!(far.bottom < near.bottom);
|
||||||
|
assert!(far.bottom >= profile.bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ use strum_macros::EnumIter;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
fighter::{FighterAttributes, animation::AnimKind},
|
fighter::{FighterAttributes, FighterCameraProfile, animation::AnimKind},
|
||||||
game_settings::GameSettings,
|
game_settings::GameSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ pub struct FighterManifest {
|
|||||||
pub id: FighterId,
|
pub id: FighterId,
|
||||||
pub model_path: String,
|
pub model_path: String,
|
||||||
pub attributes: FighterAttributes,
|
pub attributes: FighterAttributes,
|
||||||
|
pub camera: FighterCameraProfile,
|
||||||
pub animations: HashMap<AnimKind, String>,
|
pub animations: HashMap<AnimKind, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +94,13 @@ pub fn prepare_fighter_manifests(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if !manifest.camera.is_valid() {
|
||||||
|
fail(
|
||||||
|
&mut next_state,
|
||||||
|
&format!("{:?} has an invalid camera profile", manifest.id),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if registry.insert(manifest.id, handle.clone()).is_some() {
|
if registry.insert(manifest.id, handle.clone()).is_some() {
|
||||||
fail(
|
fail(
|
||||||
&mut next_state,
|
&mut next_state,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy_egui::EguiPrimaryContextPass;
|
use bevy_egui::EguiPrimaryContextPass;
|
||||||
use bevy_ggrs::prelude::*;
|
use bevy_ggrs::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub mod animation;
|
pub mod animation;
|
||||||
pub mod attributes;
|
pub mod attributes;
|
||||||
@@ -48,6 +49,27 @@ pub enum FighterFacingDirection {
|
|||||||
Left,
|
Left,
|
||||||
Right,
|
Right,
|
||||||
}
|
}
|
||||||
|
#[derive(Component, Copy, Clone, Debug, Reflect, Serialize, Deserialize)]
|
||||||
|
#[reflect(opaque)]
|
||||||
|
pub struct FighterCameraProfile {
|
||||||
|
pub vertical_origin_offset: FGi32,
|
||||||
|
pub forward_extent: FGi32,
|
||||||
|
pub backward_extent: FGi32,
|
||||||
|
pub upward_extent: FGi32,
|
||||||
|
pub downward_extent: FGi32,
|
||||||
|
/// Reserved for future offscreen indicators and close-up camera modes.
|
||||||
|
pub visibility_radius: FGi32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FighterCameraProfile {
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.forward_extent >= FGi32::ZERO
|
||||||
|
&& self.backward_extent >= FGi32::ZERO
|
||||||
|
&& self.upward_extent >= FGi32::ZERO
|
||||||
|
&& self.downward_extent >= FGi32::ZERO
|
||||||
|
&& self.visibility_radius >= FGi32::ZERO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FighterFacingDirection {
|
impl FighterFacingDirection {
|
||||||
pub fn to_sign(&self) -> FGi32 {
|
pub fn to_sign(&self) -> FGi32 {
|
||||||
@@ -126,6 +148,7 @@ pub fn spawn_fighter(
|
|||||||
player_handle: usize,
|
player_handle: usize,
|
||||||
spawn_position: FGVec2,
|
spawn_position: FGVec2,
|
||||||
attributes: FighterAttributes,
|
attributes: FighterAttributes,
|
||||||
|
camera_profile: FighterCameraProfile,
|
||||||
animations: FighterAnimations,
|
animations: FighterAnimations,
|
||||||
visual_root: WorldAssetRoot,
|
visual_root: WorldAssetRoot,
|
||||||
) {
|
) {
|
||||||
@@ -142,6 +165,7 @@ pub fn spawn_fighter(
|
|||||||
FighterVelocity::default(),
|
FighterVelocity::default(),
|
||||||
FighterTranslation(spawn_position),
|
FighterTranslation(spawn_position),
|
||||||
attributes,
|
attributes,
|
||||||
|
camera_profile,
|
||||||
visual_root,
|
visual_root,
|
||||||
animations,
|
animations,
|
||||||
FighterState::Fall(FallState),
|
FighterState::Fall(FallState),
|
||||||
|
|||||||
+3
-1
@@ -28,10 +28,11 @@ use clap::Parser;
|
|||||||
use jackdaw_runtime::EditorCategory;
|
use jackdaw_runtime::EditorCategory;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
fighter::manifest::FighterManifest, game_settings::GameSettings, schedule::GameplaySchedulePlugin, stage::manifest::{StageManifest, StageManifestAssets},
|
camera::MatchCameraPlugin, fighter::manifest::FighterManifest, game_settings::GameSettings, schedule::GameplaySchedulePlugin, stage::manifest::{StageManifest, StageManifestAssets},
|
||||||
};
|
};
|
||||||
|
|
||||||
mod args;
|
mod args;
|
||||||
|
pub mod camera;
|
||||||
pub mod fighter;
|
pub mod fighter;
|
||||||
pub mod game_settings;
|
pub mod game_settings;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
@@ -78,6 +79,7 @@ impl Plugin for GamePlugin {
|
|||||||
input::FighterInputPlugin,
|
input::FighterInputPlugin,
|
||||||
fighter::FighterPlugin,
|
fighter::FighterPlugin,
|
||||||
stage::StagePlugin,
|
stage::StagePlugin,
|
||||||
|
MatchCameraPlugin,
|
||||||
bevy_common_assets::ron::RonAssetPlugin::<FighterManifest>::new(&["fighter.ron"]),
|
bevy_common_assets::ron::RonAssetPlugin::<FighterManifest>::new(&["fighter.ron"]),
|
||||||
bevy_common_assets::ron::RonAssetPlugin::<StageManifest>::new(&["stage.ron"]),
|
bevy_common_assets::ron::RonAssetPlugin::<StageManifest>::new(&["stage.ron"]),
|
||||||
bevy_common_assets::ron::RonAssetPlugin::<GameSettings>::new(&["ron"]),
|
bevy_common_assets::ron::RonAssetPlugin::<GameSettings>::new(&["ron"]),
|
||||||
|
|||||||
+16
-4
@@ -186,7 +186,12 @@ pub fn prepare_match(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Some(animations) = shared_animations.get(&character.fighter) {
|
if let Some(animations) = shared_animations.get(&character.fighter) {
|
||||||
prepared.push((scene, animations.clone(), manifest.attributes));
|
prepared.push((
|
||||||
|
scene,
|
||||||
|
animations.clone(),
|
||||||
|
manifest.attributes,
|
||||||
|
manifest.camera,
|
||||||
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +222,7 @@ pub fn prepare_match(
|
|||||||
clips,
|
clips,
|
||||||
};
|
};
|
||||||
shared_animations.insert(character.fighter, animations.clone());
|
shared_animations.insert(character.fighter, animations.clone());
|
||||||
prepared.push((scene, animations, manifest.attributes));
|
prepared.push((scene, animations, manifest.attributes, manifest.camera));
|
||||||
}
|
}
|
||||||
|
|
||||||
let session = match create_session(&args, request.players.len()) {
|
let session = match create_session(&args, request.players.len()) {
|
||||||
@@ -235,9 +240,15 @@ pub fn prepare_match(
|
|||||||
|
|
||||||
let stage_handle = stage_registry.get(request.stage).expect("Initialization check should ensure all stages exist");
|
let stage_handle = stage_registry.get(request.stage).expect("Initialization check should ensure all stages exist");
|
||||||
let stage_manifest = stage_manifests.get(&stage_handle).expect("Initialization check should ensure stage manifests are kept alive");
|
let stage_manifest = stage_manifests.get(&stage_handle).expect("Initialization check should ensure stage manifests are kept alive");
|
||||||
stage::spawn_stage(&mut commands, root, assets.stage.clone(), stage_manifest.to_collision());
|
stage::spawn_stage(
|
||||||
|
&mut commands,
|
||||||
|
root,
|
||||||
|
assets.stage.clone(),
|
||||||
|
stage_manifest.to_collision(),
|
||||||
|
stage_manifest.camera.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
for (index, (player, (scene, animations, attributes))) in
|
for (index, (player, (scene, animations, attributes, camera_profile))) in
|
||||||
request.players.iter().zip(prepared).enumerate()
|
request.players.iter().zip(prepared).enumerate()
|
||||||
{
|
{
|
||||||
let spawn_x = (index as i32 * 2 + 1 - request.players.len() as i32) * 5;
|
let spawn_x = (index as i32 * 2 + 1 - request.players.len() as i32) * 5;
|
||||||
@@ -246,6 +257,7 @@ pub fn prepare_match(
|
|||||||
player.handle,
|
player.handle,
|
||||||
FGVec2::new(FGi32::from_num(spawn_x), FGi32::lit("12.5")),
|
FGVec2::new(FGi32::from_num(spawn_x), FGi32::lit("12.5")),
|
||||||
attributes,
|
attributes,
|
||||||
|
camera_profile,
|
||||||
animations,
|
animations,
|
||||||
WorldAssetRoot(scene),
|
WorldAssetRoot(scene),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,31 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
/// Per-stage presentation tuning for the standard match camera.
|
||||||
|
///
|
||||||
|
/// Values use gameplay world units on the XY plane
|
||||||
|
#[derive(Component, Serialize, Deserialize, Clone, Reflect)]
|
||||||
|
#[reflect(opaque)]
|
||||||
|
pub struct StageCameraProfile {
|
||||||
|
pub left: f32,
|
||||||
|
pub right: f32,
|
||||||
|
pub bottom: f32,
|
||||||
|
pub top: f32,
|
||||||
|
pub origin: Vec2,
|
||||||
|
pub vertical_fov_degrees: f32,
|
||||||
|
pub min_depth: f32,
|
||||||
|
pub max_depth: f32,
|
||||||
|
pub subject_scale: f32,
|
||||||
|
pub tracking_smoothness: f32,
|
||||||
|
pub vertical_tilt_degrees: f32,
|
||||||
|
pub horizontal_pan_coefficient: f32,
|
||||||
|
pub vertical_pan_coefficient: f32,
|
||||||
|
pub max_horizontal_pan_degrees: f32,
|
||||||
|
pub max_upward_pan_degrees: f32,
|
||||||
|
pub max_downward_pan_degrees: f32,
|
||||||
|
pub max_downward_expansion: f32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Reflect, Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Hash, Debug, EnumIter)]
|
#[derive(Reflect, Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Hash, Debug, EnumIter)]
|
||||||
pub enum StageId {
|
pub enum StageId {
|
||||||
TestStage,
|
TestStage,
|
||||||
@@ -26,6 +51,7 @@ pub struct StagePolyDefinition {
|
|||||||
pub struct StageManifest {
|
pub struct StageManifest {
|
||||||
pub id: StageId,
|
pub id: StageId,
|
||||||
pub model_path: String,
|
pub model_path: String,
|
||||||
|
pub camera: StageCameraProfile,
|
||||||
pub polygons: Vec<StagePolyDefinition>,
|
pub polygons: Vec<StagePolyDefinition>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +122,13 @@ pub fn prepare_stage_manifests(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if !manifest.camera.is_valid() {
|
||||||
|
fail(
|
||||||
|
&mut next_state,
|
||||||
|
&format!("{:?} has an invalid camera profile", manifest.id),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if registry.insert(manifest.id, handle.clone()).is_some() {
|
if registry.insert(manifest.id, handle.clone()).is_some() {
|
||||||
fail(
|
fail(
|
||||||
&mut next_state,
|
&mut next_state,
|
||||||
@@ -130,3 +163,36 @@ impl StageManifest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl StageCameraProfile {
|
||||||
|
fn is_valid(&self) -> bool {
|
||||||
|
self.left.is_finite()
|
||||||
|
&& self.right.is_finite()
|
||||||
|
&& self.bottom.is_finite()
|
||||||
|
&& self.top.is_finite()
|
||||||
|
&& self.origin.is_finite()
|
||||||
|
&& self.vertical_fov_degrees.is_finite()
|
||||||
|
&& self.min_depth.is_finite()
|
||||||
|
&& self.max_depth.is_finite()
|
||||||
|
&& self.subject_scale.is_finite()
|
||||||
|
&& self.tracking_smoothness.is_finite()
|
||||||
|
&& self.vertical_tilt_degrees.is_finite()
|
||||||
|
&& self.horizontal_pan_coefficient.is_finite()
|
||||||
|
&& self.vertical_pan_coefficient.is_finite()
|
||||||
|
&& self.max_horizontal_pan_degrees.is_finite()
|
||||||
|
&& self.max_upward_pan_degrees.is_finite()
|
||||||
|
&& self.max_downward_pan_degrees.is_finite()
|
||||||
|
&& self.max_downward_expansion.is_finite()
|
||||||
|
&& self.left < self.right
|
||||||
|
&& self.bottom < self.top
|
||||||
|
&& (0.0..180.0).contains(&self.vertical_fov_degrees)
|
||||||
|
&& self.min_depth > 0.0
|
||||||
|
&& self.min_depth <= self.max_depth
|
||||||
|
&& self.subject_scale > 0.0
|
||||||
|
&& self.tracking_smoothness >= 0.0
|
||||||
|
&& self.max_horizontal_pan_degrees >= 0.0
|
||||||
|
&& self.max_upward_pan_degrees >= 0.0
|
||||||
|
&& self.max_downward_pan_degrees >= 0.0
|
||||||
|
&& self.max_downward_expansion >= 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+9
-3
@@ -10,7 +10,7 @@ pub mod manifest;
|
|||||||
|
|
||||||
pub use line::{StagePlaneIntersectResult, StagePoly};
|
pub use line::{StagePlaneIntersectResult, StagePoly};
|
||||||
|
|
||||||
use crate::stage::line::StageCollision;
|
use crate::{stage::{line::StageCollision, manifest::StageCameraProfile}};
|
||||||
|
|
||||||
/// Owns the stage geometry: which scene gets spawned and how it is drawn.
|
/// Owns the stage geometry: which scene gets spawned and how it is drawn.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -25,7 +25,13 @@ impl Plugin for StagePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn_stage(commands: &mut Commands, match_root: Entity, visual: Handle<WorldAsset>, collision: StageCollision) {
|
pub fn spawn_stage(
|
||||||
|
commands: &mut Commands,
|
||||||
|
match_root: Entity,
|
||||||
|
visual: Handle<WorldAsset>,
|
||||||
|
collision: StageCollision,
|
||||||
|
camera_profile: StageCameraProfile,
|
||||||
|
) {
|
||||||
commands.entity(match_root).insert(WorldAssetRoot(visual));
|
commands.entity(match_root).insert(WorldAssetRoot(visual));
|
||||||
scene::spawn_stage_support(commands, match_root, collision);
|
scene::spawn_stage_support(commands, match_root, collision, camera_profile);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -6,13 +6,19 @@ use bevy::prelude::*;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
camera::MatchCamera,
|
||||||
math::{int::FGi32, vec::FGVec2}, stage::{
|
math::{int::FGi32, vec::FGVec2}, stage::{
|
||||||
StagePoly,
|
StagePoly,
|
||||||
line::{StageCollision, StagePolyLineSegmentType, StagePolyType},
|
line::{StageCollision, StagePolyLineSegmentType, StagePolyType},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn spawn_stage_support(commands: &mut Commands, match_root: Entity, collision: StageCollision) {
|
pub fn spawn_stage_support(
|
||||||
|
commands: &mut Commands,
|
||||||
|
match_root: Entity,
|
||||||
|
collision: StageCollision,
|
||||||
|
camera_profile: crate::stage::manifest::StageCameraProfile,
|
||||||
|
) {
|
||||||
commands.insert_resource(collision);
|
commands.insert_resource(collision);
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
DirectionalLight {
|
DirectionalLight {
|
||||||
@@ -28,6 +34,12 @@ pub fn spawn_stage_support(commands: &mut Commands, match_root: Entity, collisio
|
|||||||
));
|
));
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
Camera3d::default(),
|
Camera3d::default(),
|
||||||
|
Projection::Perspective(PerspectiveProjection {
|
||||||
|
fov: camera_profile.vertical_fov_degrees.to_radians(),
|
||||||
|
..default()
|
||||||
|
}),
|
||||||
|
MatchCamera::default(),
|
||||||
|
camera_profile,
|
||||||
Transform::from_xyz(0.0, 10.5, 150.0),
|
Transform::from_xyz(0.0, 10.5, 150.0),
|
||||||
/*FreeCamera {
|
/*FreeCamera {
|
||||||
sensitivity: 0.2,
|
sensitivity: 0.2,
|
||||||
|
|||||||
Reference in New Issue
Block a user