diff --git a/src/fighter/animation.rs b/src/fighter/animation.rs index f87cce0..667391f 100644 --- a/src/fighter/animation.rs +++ b/src/fighter/animation.rs @@ -98,4 +98,5 @@ pub enum AnimKind { AttackUpTilt, AttackForwardTilt, AttackDownTilt, + AttackNeutralAir, } diff --git a/src/fighter/attack/mod.rs b/src/fighter/attack/mod.rs index 134943e..d1581e3 100644 --- a/src/fighter/attack/mod.rs +++ b/src/fighter/attack/mod.rs @@ -179,7 +179,7 @@ pub enum AttackKind { impl AttackKind { pub fn get_animation(&self) -> AnimKind { match self { - AttackKind::NeutralAir => AnimKind::AttackJab1, + AttackKind::NeutralAir => AnimKind::AttackNeutralAir, AttackKind::Jab => AnimKind::AttackJab1, AttackKind::UpTilt => AnimKind::AttackUpTilt, AttackKind::ForwardAir => AnimKind::AttackUpTilt, diff --git a/src/fighter/state/air.rs b/src/fighter/state/air.rs index 17a7f3b..697a736 100644 --- a/src/fighter/state/air.rs +++ b/src/fighter/state/air.rs @@ -1,5 +1,6 @@ +use crate::fighter::state::jump::{JumpState, JumpType}; use crate::fighter::state::land::{LandingState, LandingType}; -use crate::fighter::state::{CollisionContext, FighterState}; +use crate::fighter::state::{CollisionContext, FighterState, InterruptContext, air_attack, air_dodge}; use crate::fighter::*; use crate::input::FighterCommands; @@ -112,6 +113,17 @@ pub fn update_standard_motion( ); } +pub fn air_interrupts_common(ctx: &mut InterruptContext, airborne_state_common: &AirborneStateCommon) -> Option { + if ctx.input.has_command(FighterCommands::Jump) { + Some(FighterState::Jump(JumpState { + jump_type: JumpType::DoubleJump, + airborne_state_common: AirborneStateCommon::default(), + })) + } else { + air_dodge::check_input(ctx).or_else(|| air_attack::check_interrupt(ctx, airborne_state_common)) + } +} + pub fn collision_interrupt( ctx: &mut CollisionContext, landing_type: LandingType, diff --git a/src/fighter/state/air_attack.rs b/src/fighter/state/air_attack.rs new file mode 100644 index 0000000..e523c0e --- /dev/null +++ b/src/fighter/state/air_attack.rs @@ -0,0 +1,124 @@ +use bevy::log::error; + +use crate::fighter::{ + attack::{AttackKind, FighterAttackScriptAssets, update_active_hitbox_list}, state::{ + CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl, InterruptContext, UpdateContext, air::{self, AirborneStateCommon}, fall::FallState, + }, +}; + +#[derive(Clone, Debug, Hash)] +pub struct AirAttackState { + pub attack_kind: AttackKind, + pub airborne_state_common: AirborneStateCommon +} + +impl FighterStateImpl for AirAttackState { + const NAME: &'static str = "AirAttack"; + + fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option { + if state_context.is_current_animation_finished() { + Some(FighterState::Fall(FallState::new(self.airborne_state_common))) + } else { + None + } + } + + fn update(&mut self, state_context: &mut UpdateContext) { + let animation_frame = state_context.get_current_animation_frame(); + let script = state_context + .attack_scripts + .get(&state_context.attack_script_assets.scripts[&self.attack_kind]); + if let Some(script) = script { + update_active_hitbox_list( + script, + &mut state_context + .hitboxes + .0 + .as_mut() + .expect("ground attack must have active hitboxes") + .active_hitboxes, + animation_frame, + ); + } + + air::update_standard_motion(state_context, &mut self.airborne_state_common); + } + + fn on_enter(&mut self, state_context: &mut EnterContext) { + state_context.play_animation(self.attack_kind.get_animation(), false); + let script = state_context.attack_script_assets.scripts[&self.attack_kind].clone(); + state_context.hitboxes.0 = Some(crate::fighter::FighterActiveAttack { + kind: self.attack_kind, + script, + active_hitboxes: Vec::new(), + solved_hitboxes: Vec::new(), + hit_fighters: Vec::new(), + stalled: false, + }); + } + + fn on_exit(&mut self, state_context: &mut ExitContext) { + state_context.hitboxes.0 = None; + } + + fn check_collision_interrupt( + &mut self, + state_context: &mut CollisionContext, + ) -> Option { + air::collision_interrupt( + state_context, + crate::fighter::state::land::LandingType::Normal, + false, + ) + } + + fn is_visually_grounded<'a>(&'a self) -> bool { + false + } +} + +#[derive(thiserror::Error, Debug)] +pub enum AirAttackStateCreateError { + #[error("Attack script: {attack:?} not found")] + AttackScriptNotFound{attack: AttackKind}, +} + +impl AirAttackState { + pub fn create(attack_kind: AttackKind, attack_script_assets: &FighterAttackScriptAssets, airborne_state_common: &AirborneStateCommon) -> Result { + if attack_script_assets.scripts.contains_key(&attack_kind) { + Ok(AirAttackState { + airborne_state_common: airborne_state_common.clone(), + attack_kind + }) + } else { + Err(AirAttackStateCreateError::AttackScriptNotFound { attack: attack_kind }) + } + } + +} + + +pub fn check_interrupt(state_context: &InterruptContext, airborne_state_common: &AirborneStateCommon) -> Option { + if state_context + .input + .has_command(crate::input::FighterCommands::Attack) + { + let attack_kind = state_context + .input + .get_attack_command_type() + .to_attack_kind_airborne(state_context.facing_direction); + let state = AirAttackState::create(attack_kind, state_context.attack_script_assets, airborne_state_common); + + match state { + Ok(state) => { + Some(FighterState::AirAttack(state)) + }, + Err(create_error) => { + error!("{}", create_error); + None + }, + } + } else { + None + } +} diff --git a/src/fighter/state/fall.rs b/src/fighter/state/fall.rs index e945184..e40f9a8 100644 --- a/src/fighter/state/fall.rs +++ b/src/fighter/state/fall.rs @@ -1,7 +1,7 @@ use super::{CollisionContext, EnterContext, FighterStateImpl, InterruptContext, UpdateContext}; use crate::fighter::animation::AnimKind; use crate::fighter::state::air::AirborneStateCommon; -use crate::fighter::state::{FighterState, air, jump}; +use crate::fighter::state::{FighterState, air, air_attack, jump}; #[derive(Debug, Clone, Copy, Hash, Default)] pub struct FallState { @@ -20,7 +20,7 @@ impl FighterStateImpl for FallState { const NAME: &'static str = "Fall"; fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option { - jump::check_input_double_jump(state_context) + jump::check_input_double_jump(state_context).or_else(|| air_attack::check_interrupt(state_context, &self.airborne_state_common)) } fn on_enter(&mut self, state_context: &mut EnterContext) { diff --git a/src/fighter/state/jump.rs b/src/fighter/state/jump.rs index 77efb44..7de00a2 100644 --- a/src/fighter/state/jump.rs +++ b/src/fighter/state/jump.rs @@ -7,7 +7,7 @@ use crate::{ CollisionContext, EnterContext, FighterState, FighterStateImpl, InterruptContext, UpdateContext, air::{self, AirborneStateCommon}, - air_dodge::{self, AirDodgeState}, + air_dodge::AirDodgeState, ground, }, }, @@ -23,8 +23,8 @@ pub enum JumpType { #[derive(Debug, Clone, Hash)] pub struct JumpState { - jump_type: JumpType, - airborne_state_common: AirborneStateCommon, + pub jump_type: JumpType, + pub airborne_state_common: AirborneStateCommon, } #[derive(Debug, Clone, Hash)] @@ -108,14 +108,7 @@ impl FighterStateImpl for JumpSquatState { impl FighterStateImpl for JumpState { const NAME: &'static str = "Jump"; fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option { - if state_context.input.has_command(FighterCommands::Jump) { - Some(FighterState::Jump(JumpState { - jump_type: JumpType::DoubleJump, - airborne_state_common: AirborneStateCommon::default(), - })) - } else { - air_dodge::check_input(state_context) - } + air::air_interrupts_common(state_context, &self.airborne_state_common) } fn update(&mut self, state_context: &mut UpdateContext) { diff --git a/src/fighter/state/mod.rs b/src/fighter/state/mod.rs index 7491998..6738c0f 100644 --- a/src/fighter/state/mod.rs +++ b/src/fighter/state/mod.rs @@ -47,6 +47,7 @@ pub mod run; pub mod turn; pub mod wait; pub mod walk; +pub mod air_attack; #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum SurfacePolicy { @@ -135,6 +136,7 @@ pub struct InterruptContext<'a> { pub surface: &'a FighterSurfaceState, pub animation: AnimationStatus, pub stage_collision: &'a StageCollision, + pub attack_script_assets: &'a FighterAttackScriptAssets, } impl InterruptContext<'_> { @@ -246,6 +248,7 @@ fighter_states! { GroundAttack => ground_attack::GroundAttackState, Hitstun => hitstun::HitstunState, Angel => angel::AngelState, + AirAttack => air_attack::AirAttackState, } #[derive(QueryData)] @@ -292,6 +295,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { baked_animations: &'a Assets, attributes: &'a FighterAttributes, stage_collision: &'a StageCollision, + attack_script_assets: &'a FighterAttackScriptAssets, ) -> InterruptContext<'a> { let animation = self.animation_status(animations, baked_animations); InterruptContext { @@ -302,6 +306,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { animation, surface: &self.surface, stage_collision, + attack_script_assets: attack_script_assets } } @@ -535,6 +540,7 @@ pub fn state_interrupt_system( &baked_animations, &manifest.attributes, &stage_collision, + attack_script_assets )) }; diff --git a/src/input/buffer.rs b/src/input/buffer.rs index 5426425..33f111a 100644 --- a/src/input/buffer.rs +++ b/src/input/buffer.rs @@ -9,6 +9,7 @@ use bevy_ggrs::{PlayerInputs, Rollback}; use strum::IntoEnumIterator; use strum_macros::EnumIter; +use crate::fighter::FighterFacingDirection; use crate::fighter::attack::AttackKind; use crate::game_settings::GameSettings; use crate::input::FighterInputFrame; @@ -54,6 +55,26 @@ impl FighterAttackCommandType { FighterAttackCommandType::TiltAttackRight => AttackKind::ForwardTilt, } } + pub fn to_attack_kind_airborne(&self, facing_direction: &FighterFacingDirection) -> AttackKind { + match self { + FighterAttackCommandType::Neutral => AttackKind::NeutralAir, + FighterAttackCommandType::SmashAttackUp | FighterAttackCommandType::TiltAttackUp => AttackKind::UpAir, + FighterAttackCommandType::SmashAttackDown | FighterAttackCommandType::TiltAttackDown => AttackKind::DownAir, + + FighterAttackCommandType::SmashAttackLeft | FighterAttackCommandType::TiltAttackLeft => { + match facing_direction { + FighterFacingDirection::Left => AttackKind::ForwardAir, + FighterFacingDirection::Right => AttackKind::BackAir, + } + }, + FighterAttackCommandType::SmashAttackRight | FighterAttackCommandType::TiltAttackRight => { + match facing_direction { + FighterFacingDirection::Left => AttackKind::BackAir, + FighterFacingDirection::Right => AttackKind::ForwardAir, + } + }, + } + } } /// Frames of life remaining for each buffered command. Indexed by diff --git a/src/match_runtime.rs b/src/match_runtime.rs index b66f7bb..a4577a8 100644 --- a/src/match_runtime.rs +++ b/src/match_runtime.rs @@ -2,7 +2,7 @@ use bevy::input::InputSystems; use bevy::prelude::*; -use bevy_ggrs::{RunGgrsSystems, prelude::*}; +use bevy_ggrs::{ConfirmedFrameCount, RollbackFrameCount, RunGgrsSystems, prelude::*}; use crate::{ AppState, @@ -15,6 +15,8 @@ pub struct MatchState { pub elapsed_frames: u32, /// Indexed by the player's session handle. pub players: Vec, + /// The rollback frame on which the match first finished. + pub finished_at_frame: Option, } #[derive(Clone, Debug, Hash, PartialEq, Eq)] @@ -47,6 +49,7 @@ impl MatchState { deaths: 0, }) .collect(), + finished_at_frame: None, } } @@ -65,10 +68,11 @@ impl MatchState { } } - if let MatchRules::Stock { - starting_stocks: _, - time_limit_frames: _, - } = config.rules + if config.players.len() > 1 + && let MatchRules::Stock { + starting_stocks: _, + time_limit_frames: _, + } = config.rules { let alive_fighter_count = self .players @@ -88,6 +92,23 @@ impl MatchState { false } + + fn advance_frame(&mut self, config: &MatchConfig, frame: i32) { + if self.finished_at_frame.is_some() { + return; + } + + self.elapsed_frames = self.elapsed_frames.saturating_add(1); + + if self.is_match_finished(config) { + self.finished_at_frame = Some(frame); + } + } + + fn finish_is_confirmed(&self, confirmed_frame: i32) -> bool { + self.finished_at_frame + .is_some_and(|finished_at| confirmed_frame >= finished_at) + } } pub struct MatchRuntimePlugin; @@ -100,13 +121,20 @@ impl Plugin for MatchRuntimePlugin { .configure_sets(PreUpdate, RunGgrsSystems.run_if(ggrs_should_run)) .add_systems(OnEnter(AppState::InMatch), reset_pause) .add_systems(OnExit(AppState::InMatch), reset_pause) + .add_systems( + GgrsSchedule, + update_match_rules.in_set(crate::schedule::GameplaySet::MatchRules), + ) .add_systems( PreUpdate, - (toggle_pause, check_match_finished) - .chain() + toggle_pause .after(InputSystems) .before(RunGgrsSystems) .run_if(in_state(AppState::InMatch)), + ) + .add_systems( + Update, + transition_after_confirmed_finish.run_if(in_state(AppState::InMatch)), ); } } @@ -115,6 +143,10 @@ pub fn ggrs_should_run(pause: Option>) -> bool { !pause.is_some_and(|pause| pause.paused) } +pub(crate) fn match_is_ongoing(match_state: Option>) -> bool { + match_state.is_none_or(|match_state| match_state.finished_at_frame.is_none()) +} + fn toggle_pause( key: Res>, gamepads: Query<&Gamepad>, @@ -143,12 +175,20 @@ fn reset_pause(mut pause: ResMut, mut virtual_time: ResMut, +fn update_match_rules( + mut match_state: ResMut, match_config: Res, + frame: Res, +) { + match_state.advance_frame(&match_config, frame.0); +} + +fn transition_after_confirmed_finish( + match_state: Res, + confirmed_frame: Res, mut next_state: ResMut>, ) { - if match_state.is_match_finished(&match_config) { + if match_state.finish_is_confirmed(confirmed_frame.0) { next_state.set(AppState::MatchResults); } } @@ -185,6 +225,7 @@ mod tests { })); assert_eq!(state.elapsed_frames, 0); + assert_eq!(state.finished_at_frame, None); assert_eq!(state.players.len(), 2); assert!(state.players.iter().all(|player| { player.stocks_remaining == Some(4) && player.score == 0 && player.deaths == 0 @@ -204,4 +245,74 @@ mod tests { .all(|player| player.stocks_remaining.is_none()) ); } + + #[test] + fn timed_match_finishes_on_its_rollback_frame() { + let config = config(MatchRules::Timed { + time_limit_frames: 2, + }); + let mut state = MatchState::from_config(&config); + + state.advance_frame(&config, 10); + assert_eq!(state.elapsed_frames, 1); + assert_eq!(state.finished_at_frame, None); + + state.advance_frame(&config, 11); + assert_eq!(state.elapsed_frames, 2); + assert_eq!(state.finished_at_frame, Some(11)); + assert!(!state.finish_is_confirmed(10)); + assert!(state.finish_is_confirmed(11)); + + state.advance_frame(&config, 12); + assert_eq!(state.finished_at_frame, Some(11)); + assert_eq!(state.elapsed_frames, 2); + } + + #[test] + fn corrected_rollback_state_does_not_keep_speculative_finish() { + let config = config(MatchRules::Stock { + starting_stocks: 1, + time_limit_frames: None, + }); + let state_before_prediction = MatchState::from_config(&config); + let mut predicted_state = state_before_prediction.clone(); + predicted_state.players[1].stocks_remaining = Some(0); + predicted_state.advance_frame(&config, 20); + assert_eq!(predicted_state.finished_at_frame, Some(20)); + + let mut corrected_state = state_before_prediction; + corrected_state.advance_frame(&config, 20); + assert_eq!(corrected_state.finished_at_frame, None); + assert!(!corrected_state.finish_is_confirmed(20)); + } + + #[test] + fn single_player_stock_match_does_not_finish_by_elimination() { + let mut config = config(MatchRules::Stock { + starting_stocks: 1, + time_limit_frames: None, + }); + config.players.truncate(1); + let mut state = MatchState::from_config(&config); + + state.advance_frame(&config, 1); + assert_eq!(state.finished_at_frame, None); + + state.players[0].stocks_remaining = Some(0); + state.advance_frame(&config, 2); + assert_eq!(state.finished_at_frame, None); + } + + #[test] + fn single_player_stock_match_can_finish_by_time_limit() { + let mut config = config(MatchRules::Stock { + starting_stocks: 1, + time_limit_frames: Some(1), + }); + config.players.truncate(1); + let mut state = MatchState::from_config(&config); + + state.advance_frame(&config, 7); + assert_eq!(state.finished_at_frame, Some(7)); + } } diff --git a/src/schedule.rs b/src/schedule.rs index 7c35a16..7966b81 100644 --- a/src/schedule.rs +++ b/src/schedule.rs @@ -9,6 +9,8 @@ use bevy::prelude::*; use bevy_ggrs::prelude::*; +use crate::match_runtime::match_is_ongoing; + #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub enum GameplaySet { /// Turn this frame's netcode inputs into per-fighter input state. @@ -25,6 +27,8 @@ pub enum GameplaySet { Animation, /// Resolve attacks Attacks, + /// Advance match rules and detect the end of the match. + MatchRules, } /// Declares the order of [`GameplaySet`] inside [`GgrsSchedule`]. Added by @@ -43,8 +47,10 @@ impl Plugin for GameplaySchedulePlugin { GameplaySet::Animation, GameplaySet::Collision, GameplaySet::Attacks, + GameplaySet::MatchRules, ) - .chain(), + .chain() + .run_if(match_is_ongoing), ); } }