//! The fighter state machine: the [`FighterState`] trait every state //! implements, the context they inspect, and the generic system that runs the //! interrupt check. One state per submodule. use bevy::ecs::query::QueryData; use bevy::prelude::*; use crate::fighter::animation::{AnimKind, FighterAnimationFrame}; use crate::fighter::attack::{ FighterAttackScriptAssets, FighterKnockbackVelocity, FighterPendingHitReaction, FighterStaleMoveQueue, HitStop, }; use crate::fighter::attributes::FighterAttributes; use crate::fighter::baked_animation::{BakedFighterAnimations, FighterBoneMatrices}; use crate::fighter::ecb::FighterPreviousECB; use crate::fighter::manifest::FighterManifest; use crate::fighter::state::hitstun::HitstunState; use crate::fighter::surface::EcbLockPolicy; use crate::fighter::visual::FighterAnimations; use crate::fighter::{ Fighter, FighterDamage, FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop, FighterHitstopShake, FighterHitstopShakeKind, FighterHitstopShakeRole, FighterInvulnerabilityState, FighterLedgeInformation, FighterPendingRespawn, FighterPresentationSnap, FighterPreviousTranslation, FighterSurfaceState, FighterTranslation, FighterVelocity, }; use crate::game_settings::GameSettings; use crate::input::FighterInput; use crate::match_loading::MatchConfig; use crate::match_runtime::MatchState; use crate::math::int::FGi32; use crate::math::rect::FGRect; use crate::math::vec::FGVec2; use crate::math::vec3::FGVec3; use crate::player::Player; use crate::scripting::FighterAttackScript; use crate::stage::line::StageCollision; use crate::stage::manifest::{StageManifest, StageManifestRegistry}; pub mod air; pub mod air_attack; pub mod air_dodge; pub mod angel; pub mod crouch; pub mod dash; pub mod fall; pub mod ground; pub mod ground_attack; pub mod hitstun; pub mod jump; pub mod land; pub mod platform_pass; pub mod run; pub mod turn; pub mod wait; pub mod walk; pub mod ledge_wait; pub mod ledge_action; pub mod ledge_grab; #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum SurfacePolicy { Grounded, Airborne(EcbLockPolicy), } macro_rules! fighter_states { ($($variant:ident => $ty:ty),* $(,)?) => { #[derive(Component, Clone, Debug, Hash)] pub enum FighterState { $($variant($ty)),* } impl FighterState { pub fn name(&self) -> &'static str { match self { $(Self::$variant(_) => <$ty>::NAME),* } } pub fn check_interrupt(&self, ctx: &mut InterruptContext) -> Option { match self { $(Self::$variant(s) => s.check_interrupt(ctx)),* } } pub fn update(&mut self, ctx: &mut UpdateContext) { match self { $(Self::$variant(s) => s.update(ctx)),* } } pub fn on_enter(&mut self, ctx: &mut EnterContext) { match self { $(Self::$variant(s) => s.on_enter(ctx)),* } } pub fn on_exit(&mut self, ctx: &mut ExitContext) { match self { $(Self::$variant(s) => s.on_exit(ctx)),* } } pub fn check_collision_interrupt(&mut self, ctx: &mut CollisionContext) -> Option { match self { $(Self::$variant(s) => s.check_collision_interrupt(ctx)),* } } pub fn is_visually_grounded(&self) -> bool { match self { $(Self::$variant(s) => s.is_visually_grounded()),* } } pub fn surface_policy(&self) -> SurfacePolicy { match self { $(Self::$variant(s) => s.surface_policy()),* } } } $(impl From<$ty> for FighterState { fn from(s: $ty) -> Self { Self::$variant(s) } })* }; } pub trait FighterStateImpl: Sized { const NAME: &'static str; fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option; fn on_enter(&mut self, _state_context: &mut EnterContext) {} fn on_exit(&mut self, _state_context: &mut ExitContext) {} fn name(&self) -> String { Self::NAME.to_owned() } fn update(&mut self, _state_context: &mut UpdateContext); fn check_collision_interrupt( &mut self, _state_context: &mut CollisionContext, ) -> Option { None } fn is_visually_grounded<'a>(&'a self) -> bool; fn surface_policy(&self) -> SurfacePolicy { SurfacePolicy::Grounded } } pub struct AnimationStatus { pub frame: u32, pub finished: bool, } pub struct InterruptContext<'a> { pub input: &'a FighterInput, pub facing_direction: &'a mut FighterFacingDirection, pub ledge_grab_lockout: &'a mut FighterLedgeInformation, pub game_settings: &'a GameSettings, pub fighter_attributes: &'a FighterAttributes, pub surface: &'a FighterSurfaceState, pub animation: AnimationStatus, pub stage_collision: &'a StageCollision, pub attack_scripts: &'a Assets, pub attack_script_assets: &'a FighterAttackScriptAssets, } impl InterruptContext<'_> { pub fn get_current_animation_frame(&self) -> u32 { self.animation.frame } pub fn is_current_animation_finished(&self) -> bool { self.animation.finished } } pub struct EnterContext<'a> { pub input: &'a mut FighterInput, pub hitboxes: &'a mut FighterHitboxes, pub velocity: &'a mut FighterVelocity, pub translation: &'a mut FighterTranslation, pub knockback_velocity: &'a mut FighterKnockbackVelocity, pub stale_move_queue: &'a mut FighterStaleMoveQueue, pub facing_direction: &'a mut FighterFacingDirection, pub animation_frame: &'a mut FighterAnimationFrame, pub presentation_snap: &'a mut FighterPresentationSnap, pub surface: &'a mut FighterSurfaceState, pub vulnerability: &'a mut FighterInvulnerabilityState, pub ledge_info: &'a mut FighterLedgeInformation, pub fighter_attributes: &'a FighterAttributes, pub attack_script_assets: &'a FighterAttackScriptAssets, pub game_settings: &'a GameSettings, pub stage_collision: &'a StageCollision, baked_animations: &'a BakedFighterAnimations, model_scale: FGi32, } impl EnterContext<'_> { pub fn play_animation(&mut self, kind: AnimKind, repeat: bool) { self.animation_frame.reset(kind, repeat); } /// World-space root position authored for frame zero of the animation most /// recently selected with [`Self::play_animation`]. pub fn initial_animation_root_position(&self) -> Option { let position = self .baked_animations .initial_root_position(self.animation_frame.kind, self.model_scale)?; Some( self.translation.0 + animation_position_to_gameplay(position, self.facing_direction), ) } pub fn get_animation_duration(&self, kind: AnimKind) -> Option { self.baked_animations.get_animation_duration(kind) } } pub struct ExitContext<'a> { pub input: &'a mut FighterInput, pub hitboxes: &'a mut FighterHitboxes, pub surface: &'a mut FighterSurfaceState, pub vulnerability: &'a mut FighterInvulnerabilityState, } pub struct UpdateContext<'a> { pub translation: &'a mut FighterTranslation, pub prev_translation: &'a mut FighterPreviousTranslation, pub input: &'a mut FighterInput, pub hitboxes: &'a mut FighterHitboxes, pub velocity: &'a mut FighterVelocity, pub surface: &'a mut FighterSurfaceState, pub knockback_velocity: &'a mut FighterKnockbackVelocity, pub vulnerability: &'a mut FighterInvulnerabilityState, pub ledge_info: &'a mut FighterLedgeInformation, pub game_settings: &'a GameSettings, /// Root motion in the fighter's gameplay plane: X is world-right and Y is /// world-up. Animation +Z is forward and is mirrored by facing direction. pub animation_root_motion_delta: FGVec2, /// Current world-space root position implied by this animation frame. /// States opt into root motion by assigning this to [`FighterTranslation`]. pub animation_root_position: Option, pub animation_frame: u32, pub fighter_attributes: &'a FighterAttributes, pub attack_scripts: &'a Assets, pub attack_script_assets: &'a FighterAttackScriptAssets, pub facing_direction: &'a FighterFacingDirection, pub stage_collision: &'a StageCollision, } fn animation_position_to_gameplay( position: FGVec3, facing_direction: &FighterFacingDirection, ) -> FGVec2 { FGVec2::new(position.z * facing_direction.to_sign(), position.y) } impl UpdateContext<'_> { pub fn get_current_animation_frame(&self) -> u32 { self.animation_frame } } pub struct CollisionContext<'a> { pub translation: &'a mut FighterTranslation, pub prev_translation: &'a FighterPreviousTranslation, pub input: &'a FighterInput, pub velocity: &'a mut FighterVelocity, pub surface: &'a mut FighterSurfaceState, pub ecb: &'a mut FighterECB, pub prev_ecb: &'a FighterPreviousECB, pub stage_collision: &'a StageCollision, pub leave_ground_ecb_lock_duration: u32, pub presentation_snap: &'a mut FighterPresentationSnap, pub ledge_info: &'a mut FighterLedgeInformation, pub fighter_ledge_collision_box: FGRect, pub downward_stick_threshold: FGi32, } impl CollisionContext<'_> { pub fn air_collide_with_stage( &self, can_ignore_soft_platforms: bool, ) -> Option { let ignored_polys = self.surface.ignored_polygon_ids(); let ignore_soft_platforms = can_ignore_soft_platforms && self.input.get_last_frame().movement.y <= -self.downward_stick_threshold; crate::fighter::collision::air_collide_with_stage( self.prev_translation, self.translation, self.prev_ecb, self.ecb, self.stage_collision, &ignored_polys, ignore_soft_platforms, ) } pub fn air_collide_with_ledges( &self, ) -> Option { if self.ledge_info.is_regrab_lockout_active() { return None; } crate::fighter::collision::air_collide_with_ledges( &self.fighter_ledge_collision_box, self.stage_collision, ) } } fighter_states! { Wait => wait::WaitState, Walk => walk::WalkState, PlatformPass => platform_pass::PlatformPassState, Crouch => crouch::CrouchState, Dash => dash::DashState, Run => run::RunState, Fall => fall::FallState, JumpSquat => jump::JumpSquatState, Jump => jump::JumpState, Land => land::LandingState, LedgeWait => ledge_wait::LedgeWaitState, LedgeAction => ledge_action::LedgeActionState, LedgeGrab => ledge_grab::LedgeGrabState, AirDodge => air_dodge::AirDodgeState, Turn => turn::TurnState, GroundAttack => ground_attack::GroundAttackState, Hitstun => hitstun::HitstunState, Angel => angel::AngelState, AirAttack => air_attack::AirAttackState, } #[derive(QueryData)] #[query_data(mutable)] pub struct FighterFrameQuery { pub translation: &'static mut FighterTranslation, pub prev_translation: &'static mut FighterPreviousTranslation, pub presentation_snap: &'static mut FighterPresentationSnap, pub input: &'static mut FighterInput, pub hitboxes: &'static mut FighterHitboxes, pub velocity: &'static mut FighterVelocity, pub surface: &'static mut FighterSurfaceState, pub knockback_velocity: &'static mut FighterKnockbackVelocity, pub stale_move_queue: &'static mut FighterStaleMoveQueue, pub ecb: &'static mut FighterECB, pub prev_ecb: &'static FighterPreviousECB, pub facing_direction: &'static mut FighterFacingDirection, pub animation_frame: &'static mut FighterAnimationFrame, pub vulnerability: &'static mut FighterInvulnerabilityState, pub ledge_information: &'static mut FighterLedgeInformation, pub fighter: &'static Fighter, pub bone_matrices: &'static FighterBoneMatrices, } impl<'w, 's> FighterFrameQueryItem<'w, 's> { fn animation_status( &self, animations: &FighterAnimations, baked_animations: &Assets, ) -> AnimationStatus { let baked = baked_animations .get(&animations.baked) .expect("Animation should exist"); let frame = self.animation_frame.frame; let finished = baked .frame_count(self.animation_frame.kind) .expect("Current animation should have baked frames") <= frame; AnimationStatus { frame, finished } } fn interrupt_context<'a>( &'a mut self, game_settings: &'a GameSettings, animations: &'a FighterAnimations, baked_animations: &'a Assets, attributes: &'a FighterAttributes, stage_collision: &'a StageCollision, attack_scripts: &'a Assets, attack_script_assets: &'a FighterAttackScriptAssets, ) -> InterruptContext<'a> { let animation = self.animation_status(animations, baked_animations); InterruptContext { input: &self.input, facing_direction: &mut self.facing_direction, ledge_grab_lockout: &mut self.ledge_information, game_settings, fighter_attributes: attributes, animation, surface: &self.surface, stage_collision, attack_script_assets: attack_script_assets, attack_scripts, } } fn enter_context<'a>( &'a mut self, attributes: &'a FighterAttributes, attack_script_assets: &'a FighterAttackScriptAssets, game_settings: &'a GameSettings, stage_collision: &'a StageCollision, baked_animations: &'a BakedFighterAnimations, model_scale: FGi32, ) -> EnterContext<'a> { EnterContext { input: &mut self.input, hitboxes: &mut self.hitboxes, velocity: &mut self.velocity, knockback_velocity: &mut self.knockback_velocity, translation: &mut self.translation, stale_move_queue: &mut self.stale_move_queue, facing_direction: &mut self.facing_direction, animation_frame: &mut self.animation_frame, surface: &mut self.surface, vulnerability: &mut self.vulnerability, presentation_snap: &mut self.presentation_snap, ledge_info: &mut self.ledge_information, fighter_attributes: attributes, attack_script_assets, game_settings, stage_collision, baked_animations, model_scale, } } fn exit_context(&mut self) -> ExitContext<'_> { ExitContext { input: &mut self.input, hitboxes: &mut self.hitboxes, surface: &mut self.surface, vulnerability: &mut self.vulnerability, } } fn update_context<'a>( &'a mut self, game_settings: &'a GameSettings, attributes: &'a FighterAttributes, attack_scripts: &'a Assets, attack_script_assets: &'a FighterAttackScriptAssets, stage_collision: &'a StageCollision, ) -> UpdateContext<'a> { let animation_root_motion_delta = animation_position_to_gameplay( self.bone_matrices.root_motion_delta(), &self.facing_direction, ); let animation_root_position = self .bone_matrices .root_motion_position() .map(|_| self.translation.0 + animation_root_motion_delta); UpdateContext { translation: &mut self.translation, prev_translation: &mut self.prev_translation, input: &mut self.input, hitboxes: &mut self.hitboxes, velocity: &mut self.velocity, surface: &mut self.surface, knockback_velocity: &mut self.knockback_velocity, game_settings, animation_root_motion_delta, animation_root_position, animation_frame: self.animation_frame.frame, vulnerability: &mut self.vulnerability, ledge_info: &mut self.ledge_information, fighter_attributes: attributes, attack_scripts, attack_script_assets, facing_direction: &self.facing_direction, stage_collision: &stage_collision, } } fn collision_context<'a>( &'a mut self, stage_collision: &'a StageCollision, fighter_manifest: &'a FighterManifest, game_settings: &'a GameSettings, ) -> CollisionContext<'a> { let fighter_ledge_collision_box = FGRect { origin: self.translation.0 + fighter_manifest.ledge_grab_box_start, end: self.translation.0 + fighter_manifest.ledge_grab_box_end, }; CollisionContext { translation: &mut self.translation, prev_translation: &self.prev_translation, presentation_snap: &mut self.presentation_snap, input: &self.input, velocity: &mut self.velocity, surface: &mut self.surface, ecb: &mut self.ecb, ledge_info: &mut self.ledge_information, prev_ecb: &self.prev_ecb, stage_collision, leave_ground_ecb_lock_duration: game_settings .fighter_common .leave_ground_ecb_frame_lock_duration, downward_stick_threshold: game_settings.input_common.run_stick_threshold, fighter_ledge_collision_box, } } } #[derive(QueryData)] #[query_data(mutable)] pub struct StatefulFighterQuery { pub state: &'static mut FighterState, pub frame: FighterFrameQuery, pub animations: &'static FighterAnimations, pub fighter: &'static Fighter, pub attack_script_assets: &'static FighterAttackScriptAssets, pub player: &'static Player, pub pending_hit_reaction: &'static mut FighterPendingHitReaction, pub hitstop: &'static mut FighterHitstop, pub hitstop_shake: &'static mut FighterHitstopShake, pub pending_respawn: &'static mut FighterPendingRespawn, pub damage: &'static mut FighterDamage, } pub fn state_update_system( mut query: Query, game_settings: Res, stage_collision: Res, attack_scripts: Res>, manifests: Res>, ) { for fighter in &mut query { let StatefulFighterQueryItem { mut state, mut frame, hitstop, .. } = fighter; if hitstop.remaining_frames > 0 { continue; } let manifest = manifests .get(&fighter.fighter.manifest) .expect("Fighter manifest should be valid"); let mut update_ctx = frame.update_context( &game_settings, &manifest.attributes, &attack_scripts, &fighter.attack_script_assets, &stage_collision, ); state.update(&mut update_ctx); } } fn transition_state( current: &mut FighterState, mut next: FighterState, frame: &mut FighterFrameQueryItem, attributes: &FighterAttributes, attack_script_assets: &FighterAttackScriptAssets, stage_collision: &StageCollision, game_settings: &GameSettings, baked_animations: &BakedFighterAnimations, model_scale: FGi32, ) { let old_name = current.name(); let new_name = next.name(); current.on_exit(&mut frame.exit_context()); if let SurfacePolicy::Airborne(lock_policy) = next.surface_policy() { frame.surface.apply_airborne_policy( lock_policy, game_settings .fighter_common .leave_ground_ecb_frame_lock_duration, ); } next.on_enter(&mut frame.enter_context( attributes, attack_script_assets, game_settings, stage_collision, baked_animations, model_scale, )); *current = next; info!("{} -> {}", old_name, new_name); } pub fn state_interrupt_system( mut query: Query, game_settings: Res, baked_animations: Res>, fighter_manifests: Res>, attack_scripts: Res>, stage_collision: Res, ) { for fighter in &mut query { let StatefulFighterQueryItem { mut state, mut frame, animations, fighter, attack_script_assets, mut pending_hit_reaction, mut hitstop, mut hitstop_shake, .. } = fighter; let manifest = fighter_manifests .get(&fighter.manifest) .expect("Fighter manifest should be valid"); let baked = baked_animations .get(&animations.baked) .expect("Animation should exist"); let new_state = if let Some(reaction) = pending_hit_reaction.take() { hitstop.remaining_frames = *reaction.victim_hitstop_frames; let shake_frames = reaction .victim_hitstop_frames .calculate_shake_frames(&game_settings); hitstop_shake.role = FighterHitstopShakeRole::Receiver; hitstop_shake.total_frames = shake_frames; hitstop_shake.remaining_frames = shake_frames; hitstop_shake.kind = if state.is_visually_grounded() { FighterHitstopShakeKind::Horizontal } else { FighterHitstopShakeKind::Vertical }; let mut hitstop_kind = if frame.surface.is_grounded() { hitstun::HitstopStateKind::Grounded } else { hitstun::HitstopStateKind::Airborne }; if let hitstun::HitstopStateKind::Grounded = hitstop_kind { let straight_up_angle = FGi32::lit("90.0"); if reaction.victim_hitstop_frames >= HitStop::new(game_settings.fighter_common.tumble_bounce_hitstop_threshold) { hitstop_kind = hitstun::HitstopStateKind::Airborne; // TODO: Use floor normal here } else if reaction.launch_angle.abs_diff(straight_up_angle) < game_settings .fighter_common .attack_ground_to_airborne_transition_vertical_angle_threshold { hitstop_kind = hitstun::HitstopStateKind::Airborne; } else { // straight down is 270 let straight_down_angle = FGi32::lit("270.0"); let meteor_threshold = game_settings.fighter_common.meteor_angle_threshold; if (reaction.launch_angle - straight_down_angle).abs() <= meteor_threshold { // TODO: Floor bounce hitstop_kind = hitstun::HitstopStateKind::Airborne; } } } info!("Entering hitstop kind: {:?}", hitstop_kind); Some(FighterState::Hitstun(HitstunState::new( reaction, hitstop_kind, ))) } else if hitstop.remaining_frames > 0 { None } else { state.check_interrupt(&mut frame.interrupt_context( &game_settings, animations, &baked_animations, &manifest.attributes, &stage_collision, &attack_scripts, attack_script_assets, )) }; if let Some(new_state) = new_state { transition_state( &mut state, new_state, &mut frame, &manifest.attributes, attack_script_assets, &stage_collision, &game_settings, baked, manifest.model_scale, ); }; } } pub fn state_collision_interrupt_system( mut query: Query, stage_collision: Res, fighter_manifests: Res>, game_settings: Res, match_config: Res, mut match_state: ResMut, stage_registry: Res, stage_manifests: Res>, baked_animations: Res>, ) { let Some(stage_manifest_handle) = stage_registry.get(match_config.stage) else { return; }; let Some(stage_manifest) = stage_manifests.get(&stage_manifest_handle) else { return; }; for fighter in &mut query { let StatefulFighterQueryItem { mut state, mut frame, fighter, animations, attack_script_assets, mut hitstop, mut pending_respawn, player, mut damage, .. } = fighter; let manifest = fighter_manifests .get(&fighter.manifest) .expect("Fighter manifest should be valid"); let baked = baked_animations .get(&animations.baked) .expect("Animation should exist"); if pending_respawn.0 { **pending_respawn = false; let respawn_state = FighterState::Angel(angel::AngelState::new( game_settings .fighter_common .angel_platform_invulnerability_duration, )); // Find the angel platform position let respawn_point = stage_manifest .respawn_points .get(player.controller_port % stage_manifest.respawn_points.len()); if let None = respawn_point { error!("Couldn't get a respawn point, falling back to the world origin"); } let respawn_point = respawn_point.copied().unwrap_or(FGVec2::ZERO); frame.translation.0 = respawn_point; frame.presentation_snap.0 = true; // Clear hitstop hitstop.remaining_frames = 0; // subtract one stock let player_state = match_state.players.get_mut(player.handle).expect(&format!( "Failed to get fighter for GGRS slot {} (controller port {})", player.handle, player.controller_port )); if let Some(stocks_remaining) = &mut player_state.stocks_remaining { *stocks_remaining = stocks_remaining.saturating_sub(1); } // reset percentage *damage = FighterDamage::default(); transition_state( &mut state, respawn_state, &mut frame, &manifest.attributes, attack_script_assets, &stage_collision, &game_settings, baked, manifest.model_scale, ); continue; } if hitstop.remaining_frames > 0 { continue; } let new_state = state.check_collision_interrupt( &mut frame.collision_context(&stage_collision, manifest, &game_settings), ); if let Some(new_state) = new_state { transition_state( &mut state, new_state, &mut frame, &manifest.attributes, &attack_script_assets, &stage_collision, &game_settings, baked, manifest.model_scale, ); } } } #[cfg(test)] mod tests { use super::*; use crate::fighter::ecb::FighterPreviousECB; use crate::stage::line::{StageLedge, StageLedgeSide, StageVertexID}; #[test] fn animation_positions_map_forward_to_facing_and_preserve_height() { let position = FGVec3::lit("99", "3", "5"); assert_eq!( animation_position_to_gameplay(position, &FighterFacingDirection::Right), FGVec2::lit("5", "3") ); assert_eq!( animation_position_to_gameplay(position, &FighterFacingDirection::Left), FGVec2::lit("-5", "3") ); } #[test] fn ledge_lockout_suppresses_ledge_collision() { let ledge_id = StageVertexID { polygon: 0, vertex: 0, }; let stage_collision = StageCollision { ledges: vec![StageLedge { id: ledge_id, position: FGVec2::ZERO, hang_side: StageLedgeSide::Left, }], ..default() }; let previous_translation = FighterPreviousTranslation::default(); let input = FighterInput::default(); let mut translation = FighterTranslation::default(); let mut velocity = FighterVelocity::default(); let mut surface = FighterSurfaceState::default(); let mut ecb = FighterECB::default(); let mut ledge_info = FighterLedgeInformation::default(); let previous_ecb = FighterPreviousECB::from(ecb); let mut presentation_snap = FighterPresentationSnap::default(); let context = CollisionContext { translation: &mut translation, prev_translation: &previous_translation, input: &input, velocity: &mut velocity, surface: &mut surface, ecb: &mut ecb, ledge_info: &mut ledge_info, prev_ecb: &previous_ecb, stage_collision: &stage_collision, leave_ground_ecb_lock_duration: 0, presentation_snap: &mut presentation_snap, fighter_ledge_collision_box: FGRect { origin: FGVec2::lit("-1", "1"), end: FGVec2::lit("1", "-1"), }, downward_stick_threshold: FGi32::ONE, }; assert!(context.air_collide_with_ledges().is_none()); assert_eq!( context.air_collide_with_ledges().map(|result| result.ledge), Some(ledge_id) ); } }