diff --git a/src/camera/mod.rs b/src/camera/mod.rs index 26c9dbf..40bdc2b 100644 --- a/src/camera/mod.rs +++ b/src/camera/mod.rs @@ -1,8 +1,13 @@ //! Presentation-only state and tuning for the match camera. use bevy::prelude::*; +use bevy_ggrs::GgrsFrameTiming; -use crate::{fighter::manifest::FighterManifest, stage::manifest::StageCameraProfile}; +use crate::{ + debug_tools::DebugSettings, + fighter::{manifest::FighterManifest, motion::sample_presentation_translation}, + 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]; @@ -110,7 +115,10 @@ impl Plugin for MatchCameraPlugin { fn build(&self, app: &mut App) { app.add_systems( Update, - (update_match_camera, debug_draw_camera) + ( + update_match_camera, + debug_draw_camera.run_if(crate::debug_tools::camera_debug_enabled), + ) .chain() .run_if(in_state(crate::AppState::InMatch)), ); @@ -302,6 +310,7 @@ fn update_match_camera( mut fighters: Query< ( &crate::fighter::motion::FighterTranslation, + &crate::fighter::motion::FighterPreviousTranslation, &crate::fighter::Fighter, &crate::fighter::FighterFacingDirection, &mut FighterCameraExtents, @@ -311,25 +320,34 @@ fn update_match_camera( camera_profiles: Query<&StageCameraProfile, With>, mut cameras: Query<(&mut Transform, &mut Projection, &mut MatchCamera), With>, manifests: Res>, + timing: Res, + debug_settings: Res, ) { let Ok(profile) = camera_profiles.single() else { return; }; let subjects = fighters .iter_mut() - .map(|(translation, fighter, facing, mut extents)| { - let manifest = manifests - .get(&fighter.manifest) - .expect("Fighter manifest should be valid"); - fighter_subject_bounds( - Vec2::new(translation.x.to_num(), translation.y.to_num()), - manifest.camera, - *facing, - profile, - &mut extents, - time.delta_secs(), - ) - }) + .map( + |(translation, previous_translation, fighter, facing, mut extents)| { + let manifest = manifests + .get(&fighter.manifest) + .expect("Fighter manifest should be valid"); + fighter_subject_bounds( + sample_presentation_translation( + *previous_translation, + *translation, + timing.overstep_fraction(), + debug_settings.motion_sampling, + ), + manifest.camera, + *facing, + profile, + &mut extents, + time.delta_secs(), + ) + }, + ) .collect::>(); for (mut transform, mut projection, mut camera) in &mut cameras { @@ -444,6 +462,7 @@ fn debug_draw_camera( fighters: Query< ( &crate::fighter::motion::FighterTranslation, + &crate::fighter::motion::FighterPreviousTranslation, &crate::fighter::Fighter, &crate::fighter::FighterFacingDirection, &FighterCameraExtents, @@ -452,6 +471,8 @@ fn debug_draw_camera( >, cameras: Query<(&MatchCamera, &StageCameraProfile)>, manifests: Res>, + timing: Res, + debug_settings: Res, ) { for (camera, profile) in &cameras { draw_rectangle( @@ -465,12 +486,16 @@ fn debug_draw_camera( 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, _facing, extents) in &fighters { + for (translation, previous_translation, fighter, _facing, extents) in &fighters { let manifest = manifests .get(&fighter.manifest) .expect("Fighter manifest should be valid"); - let center = Vec2::new(translation.x.to_num(), translation.y.to_num()) - + Vec2::Y * manifest.camera.vertical_origin_offset.to_num::(); + let center = sample_presentation_translation( + *previous_translation, + *translation, + timing.overstep_fraction(), + debug_settings.motion_sampling, + ) + Vec2::Y * manifest.camera.vertical_origin_offset.to_num::(); draw_rectangle( &mut gizmos, CameraFrameBounds { diff --git a/src/debug_tools.rs b/src/debug_tools.rs index b42d8a6..4faf6eb 100644 --- a/src/debug_tools.rs +++ b/src/debug_tools.rs @@ -19,6 +19,29 @@ pub enum FighterDebugRowKind { Facing, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PresentationMotionMode { + #[default] + Interpolation, + Extrapolation, +} + +impl PresentationMotionMode { + pub const fn label(self) -> &'static str { + match self { + Self::Interpolation => "Interpolation", + Self::Extrapolation => "Extrapolation", + } + } + + fn cycle(&mut self) { + *self = match self { + Self::Interpolation => Self::Extrapolation, + Self::Extrapolation => Self::Interpolation, + }; + } +} + impl FighterDebugRowKind { const ALL: [Self; 6] = [ Self::None, @@ -57,6 +80,7 @@ enum DebugFlag { StageCollision, AnimationBones, AttackHitboxes, + Camera, DrawThroughGeometry, } @@ -73,6 +97,9 @@ enum DebugMenuEntry { PlayerSlots { label: &'static str, }, + MotionSampling { + label: &'static str, + }, } const FIGHTER_MENU: &[DebugMenuEntry] = &[ @@ -87,6 +114,10 @@ const FIGHTER_MENU: &[DebugMenuEntry] = &[ label: "Animation bones", flag: DebugFlag::AnimationBones, }, + DebugMenuEntry::Toggle { + label: "Camera", + flag: DebugFlag::Camera, + }, DebugMenuEntry::Toggle { label: "Attack hitboxes", flag: DebugFlag::AttackHitboxes, @@ -103,10 +134,15 @@ const DIAGNOSTICS_MENU: &[DebugMenuEntry] = &[DebugMenuEntry::Toggle { flag: DebugFlag::Network, }]; -const RENDERING_MENU: &[DebugMenuEntry] = &[DebugMenuEntry::Toggle { - label: "Draw through geometry", - flag: DebugFlag::DrawThroughGeometry, -}]; +const RENDERING_MENU: &[DebugMenuEntry] = &[ + DebugMenuEntry::MotionSampling { + label: "Motion sampling", + }, + DebugMenuEntry::Toggle { + label: "Draw through geometry", + flag: DebugFlag::DrawThroughGeometry, + }, +]; const ROOT_MENU: &[DebugMenuEntry] = &[ DebugMenuEntry::Submenu { @@ -146,7 +182,9 @@ pub struct DebugSettings { pub stage_collision: bool, pub animation_bones: bool, pub attack_hitboxes: bool, + pub camera: bool, pub draw_through_geometry: bool, + pub motion_sampling: PresentationMotionMode, } impl Default for DebugSettings { @@ -162,7 +200,9 @@ impl Default for DebugSettings { stage_collision: false, animation_bones: false, attack_hitboxes: false, + camera: false, draw_through_geometry: false, + motion_sampling: PresentationMotionMode::default(), } } } @@ -223,6 +263,7 @@ impl DebugSettings { DebugFlag::AnimationBones => self.animation_bones, DebugFlag::AttackHitboxes => self.attack_hitboxes, DebugFlag::DrawThroughGeometry => self.draw_through_geometry, + DebugFlag::Camera => self.camera, } } @@ -236,9 +277,14 @@ impl DebugSettings { DebugFlag::DrawThroughGeometry => { self.draw_through_geometry = !self.draw_through_geometry } + DebugFlag::Camera => self.camera = !self.camera, } } + fn cycle_motion_sampling(&mut self) { + self.motion_sampling.cycle(); + } + fn rows_mut(&mut self, handle: usize) -> &mut [FighterDebugRowKind; PLAYER_DEBUG_ROW_COUNT] { self.player_rows .entry(handle) @@ -342,6 +388,16 @@ fn handle_palette_input( rows[selected] = rows[selected].cycle(direction); } } + if let DebugMenuPage::Static(entries) = page + && matches!( + entries.get(settings.selected()), + Some(DebugMenuEntry::MotionSampling { .. }) + ) + && (gamepad.just_pressed(GamepadButton::DPadLeft) + || gamepad.just_pressed(GamepadButton::DPadRight)) + { + settings.cycle_motion_sampling(); + } if gamepad.just_pressed(GamepadButton::South) { match page { DebugMenuPage::Static(entries) if page_len != 0 => match entries[settings.selected()] { @@ -350,6 +406,7 @@ fn handle_palette_input( settings.push_page(DebugMenuPage::Static(entries)); } DebugMenuEntry::PlayerSlots { .. } => settings.push_page(DebugMenuPage::PlayerList), + DebugMenuEntry::MotionSampling { .. } => settings.cycle_motion_sampling(), }, DebugMenuPage::PlayerList if page_len != 0 => { let handle = player_handles[settings.selected()]; @@ -409,6 +466,12 @@ fn draw_palette( | DebugMenuEntry::PlayerSlots { label } => { ui.label(format!("{marker} {label} >")); } + DebugMenuEntry::MotionSampling { label } => { + ui.label(format!( + "{marker} {label}: {}", + settings.motion_sampling.label() + )); + } } } } @@ -437,7 +500,7 @@ fn draw_palette( let controls = if matches!(page, DebugMenuPage::PlayerRows(_)) { "D-pad: select/change B: back" } else { - "D-pad: select A: enter/toggle B: back" + "D-pad: select A: enter/toggle Left/right: change B: back" }; ui.label(controls); }); @@ -471,6 +534,10 @@ pub fn attack_hitboxes_enabled(settings: Res) -> bool { settings.attack_hitboxes } +pub fn camera_debug_enabled(settings: Res) -> bool { + settings.camera +} + #[cfg(test)] mod tests { use super::*; @@ -509,4 +576,14 @@ mod tests { ); assert!(settings.player_rows(1).is_none()); } + + #[test] + fn presentation_motion_mode_cycles_between_choices() { + let mut mode = PresentationMotionMode::Interpolation; + + mode.cycle(); + assert_eq!(mode, PresentationMotionMode::Extrapolation); + mode.cycle(); + assert_eq!(mode, PresentationMotionMode::Interpolation); + } } diff --git a/src/fighter/animation.rs b/src/fighter/animation.rs index 5309f47..e950a3c 100644 --- a/src/fighter/animation.rs +++ b/src/fighter/animation.rs @@ -4,7 +4,7 @@ use bevy::prelude::*; use crate::{ fighter::{ - FighterECB, FighterFacingDirection, FighterTranslation, FighterVisual, + FighterFacingDirection, FighterVisual, visual::FighterAnimations, }, player::Player, @@ -54,15 +54,6 @@ pub fn advance_fighter_animation_frames(mut fighters: Query<&mut FighterAnimatio } } -pub fn apply_fighter_translation_to_visuals( - query: Query<(&FighterTranslation, &FighterECB, &mut Transform)>, -) { - for (translation, ecb, mut transform) in query { - let translation = translation.0 + ecb.get_bottom_point(); - transform.translation = Vec3::new(translation.x.to_num(), translation.y.to_num(), 0.0); - } -} - pub fn setup_fighter_animation_player( mut commands: Commands, animated: Query>, @@ -153,4 +144,5 @@ pub enum AnimKind { // Attacks AttackJab1, + AttackUpTilt, } diff --git a/src/fighter/attack.rs b/src/fighter/attack.rs index cccae17..3acc165 100644 --- a/src/fighter/attack.rs +++ b/src/fighter/attack.rs @@ -64,7 +64,8 @@ impl AttackAngle { #[derive(Serialize, Deserialize, Reflect, PartialEq, Eq, Hash, Clone, Debug)] pub enum AttackKind { NAir, - Jab + Jab, + UpTilt, } impl AttackKind { @@ -72,6 +73,7 @@ impl AttackKind { match self { AttackKind::NAir => AnimKind::AttackJab1, AttackKind::Jab => AnimKind::AttackJab1, + AttackKind::UpTilt => AnimKind::AttackUpTilt, } } } diff --git a/src/fighter/mod.rs b/src/fighter/mod.rs index 74929cb..1f67767 100644 --- a/src/fighter/mod.rs +++ b/src/fighter/mod.rs @@ -125,13 +125,10 @@ impl Plugin for FighterPlugin { state::state_collision_interrupt_system.in_set(GameplaySet::Collision), ) .add_systems( - GgrsSchedule, - ( - animation::apply_fighter_translation_to_visuals, - animation::apply_animation, - ) + Update, + (motion::apply_fighter_translation_to_visuals, animation::apply_animation) .chain() - .in_set(GameplaySet::Animation), + .run_if(in_state(crate::AppState::InMatch)), ) // Rollback registration lives next to the systems that write these // components; a component simulated here but missing from this list @@ -177,6 +174,13 @@ pub struct FighterHitboxes { pub active_hitboxes: Vec } +impl FighterHitboxes { + pub fn clear(&mut self) { + self.attack_script = None; + self.active_hitboxes.clear(); + } +} + pub fn spawn_fighter( commands: &mut Commands, player_handle: usize, @@ -203,8 +207,11 @@ pub fn spawn_fighter( attack_script: None, active_hitboxes: vec![] }), - FighterVelocity::default(), - FighterTranslation(spawn_position), + ( + FighterVelocity::default(), + FighterTranslation(spawn_position), + FighterPreviousTranslation(spawn_position), + ), visual_root, animations, attack_scripts, diff --git a/src/fighter/motion.rs b/src/fighter/motion.rs index cc48047..10b32f2 100644 --- a/src/fighter/motion.rs +++ b/src/fighter/motion.rs @@ -2,8 +2,13 @@ //! this module is what turns that into a position. use bevy::prelude::*; +use bevy_ggrs::GgrsFrameTiming; -use crate::{fighter::{FighterFacingDirection, baked_animation::FixedMat4}, math::{int::FGi32, vec::FGVec2, vec3::FGVec3}}; +use crate::debug_tools::PresentationMotionMode; +use crate::{ + fighter::{FighterECB, FighterFacingDirection, baked_animation::FixedMat4}, + math::{int::FGi32, vec::FGVec2, vec3::FGVec3}, +}; #[derive(Component, Clone, Copy)] pub struct Grounded; @@ -29,6 +34,49 @@ impl FighterTranslation { } } +/// Samples deterministic fighter positions for presentation without changing +/// gameplay state or rollback timing. +pub fn sample_presentation_translation( + previous: FighterPreviousTranslation, + current: FighterTranslation, + overstep_fraction: f32, + mode: PresentationMotionMode, +) -> Vec2 { + let previous = Vec2::new(previous.x.to_num(), previous.y.to_num()); + let current = Vec2::new(current.x.to_num(), current.y.to_num()); + match mode { + PresentationMotionMode::Interpolation => previous.lerp(current, overstep_fraction), + PresentationMotionMode::Extrapolation => current + (current - previous) * overstep_fraction, + } +} + +pub fn apply_fighter_translation_to_visuals( + query: Query<( + &FighterTranslation, + &FighterPreviousTranslation, + &FighterECB, + &mut Transform, + )>, + timing: Res, + debug_settings: Res, +) { + let overstep_fraction = timing.overstep_fraction(); + for (translation, previous_translation, ecb, mut transform) in query { + let translation = sample_presentation_translation( + *previous_translation, + *translation, + overstep_fraction, + debug_settings.motion_sampling, + ); + let bottom = ecb.get_bottom_point(); + transform.translation = Vec3::new( + translation.x + bottom.x.to_num::(), + translation.y + bottom.y.to_num::(), + 0.0, + ); + } +} + pub fn apply_air_motion( fighters: Query<( &FighterVelocity, @@ -51,3 +99,32 @@ pub fn copy_fighter_transform_to_visuals(fighters: Query<(&FighterTranslation, & } pub fn fighter_movement() {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn presentation_translation_interpolates_between_fixed_samples() { + let position = sample_presentation_translation( + FighterPreviousTranslation(FGVec2::new(FGi32::lit("2"), FGi32::lit("4"))), + FighterTranslation(FGVec2::new(FGi32::lit("6"), FGi32::lit("12"))), + 0.25, + PresentationMotionMode::Interpolation, + ); + + assert_eq!(position, Vec2::new(3.0, 6.0)); + } + + #[test] + fn presentation_translation_extrapolates_from_current_sample() { + let position = sample_presentation_translation( + FighterPreviousTranslation(FGVec2::new(FGi32::lit("2"), FGi32::lit("4"))), + FighterTranslation(FGVec2::new(FGi32::lit("6"), FGi32::lit("12"))), + 0.25, + PresentationMotionMode::Extrapolation, + ); + + assert_eq!(position, Vec2::new(7.0, 14.0)); + } +} diff --git a/src/fighter/state/ground_attack.rs b/src/fighter/state/ground_attack.rs index e250420..51a703e 100644 --- a/src/fighter/state/ground_attack.rs +++ b/src/fighter/state/ground_attack.rs @@ -32,6 +32,17 @@ impl FighterStateImpl for GroundAttackState { state_context.hitboxes.attack_script = Some(state_context.attack_script_assets.scripts[&self.attack_kind].clone()); state_context.hitboxes.active_hitboxes.clear(); } + + fn on_exit(&mut self, state_context: &mut super::FighterStateContext) { + state_context.hitboxes.clear(); + } + + fn check_collision_interrupt( + &mut self, + _state_context: &mut super::FighterStateContext, + ) -> Option { + None + } } pub fn check_interrupt(state_context: &super::FighterStateContext, grounded_common: &GroundedStateCommon) -> Option { diff --git a/src/fighter/state/mod.rs b/src/fighter/state/mod.rs index 31a9bef..9edd0f0 100644 --- a/src/fighter/state/mod.rs +++ b/src/fighter/state/mod.rs @@ -52,6 +52,10 @@ macro_rules! fighter_states { match self { $(Self::$variant(s) => s.on_enter(ctx)),* } } + pub fn on_exit(&mut self, ctx: &mut FighterStateContext) { + match self { $(Self::$variant(s) => s.on_exit(ctx)),* } + } + pub fn check_collision_interrupt(&mut self, ctx: &mut FighterStateContext) -> Option { match self { $(Self::$variant(s) => s.check_collision_interrupt(ctx)),* } } @@ -68,6 +72,7 @@ pub trait FighterStateImpl: Sized { fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option; fn on_enter(&mut self, _state_context: &mut FighterStateContext) {} + fn on_exit(&mut self, _state_context: &mut FighterStateContext) {} fn update(&mut self, _state_context: &mut FighterStateContext); fn check_collision_interrupt( @@ -272,7 +277,7 @@ pub fn state_interrupt_system( for fighter in &mut query { let StatefulFighterQueryItem { entity, - state, + mut state, mut frame, animations, animation_player_link, @@ -301,6 +306,7 @@ pub fn state_interrupt_system( let mut new_state = new_state; let old_state_name = state.name(); let new_state_name = new_state.name(); + state.on_exit(&mut state_context); new_state.on_enter(&mut state_context); commands .entity(entity) @@ -352,6 +358,7 @@ pub fn state_collision_interrupt_system( let mut new_state = new_state; let old_state_name = state.name(); let new_state_name = new_state.name(); + state.on_exit(&mut state_context); new_state.on_enter(&mut state_context); commands .entity(entity) diff --git a/src/netcode/mod.rs b/src/netcode/mod.rs index 3a84a94..75c12cd 100644 --- a/src/netcode/mod.rs +++ b/src/netcode/mod.rs @@ -21,7 +21,6 @@ impl Plugin for FighterNetcodePlugin { app.add_plugins(plugin) // Engine-level components that every rolled-back entity may carry. // Gameplay components are registered by the plugin that owns them. - .rollback_component_with_clone::() .rollback_component_with_clone::() .add_systems( EguiPrimaryContextPass,