497 lines
17 KiB
Rust
497 lines
17 KiB
Rust
//! Everything fighter-shaped: the components that describe a fighter, the
|
|
//! state machine that drives it, and the plugin that schedules it all.
|
|
|
|
use bevy::prelude::*;
|
|
use bevy_egui::EguiPrimaryContextPass;
|
|
use bevy_ggrs::prelude::*;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
pub mod animation;
|
|
pub mod attack;
|
|
pub mod attributes;
|
|
pub mod baked_animation;
|
|
pub mod blast_zones;
|
|
pub mod collision;
|
|
pub mod debug;
|
|
pub mod ecb;
|
|
pub mod errors;
|
|
pub mod hurtbox;
|
|
pub mod manifest;
|
|
pub mod motion;
|
|
pub mod push;
|
|
pub mod state;
|
|
pub mod surface;
|
|
pub mod visual;
|
|
|
|
// The fighter components are re-exported so the rest of the crate can say
|
|
// `fighter::FighterVelocity` without caring which file it lives in. Named
|
|
// explicitly rather than globbed, so moving a type between submodules can't
|
|
// silently change what this module exports.
|
|
pub use attributes::FighterAttributes;
|
|
pub use ecb::FighterECB;
|
|
pub use manifest::FighterId;
|
|
pub use motion::{
|
|
FighterPresentationSnap, FighterPreviousFacingDirection, FighterPreviousTranslation,
|
|
FighterTranslation, FighterVelocity,
|
|
};
|
|
pub use surface::FighterSurfaceState;
|
|
|
|
use crate::{
|
|
AppState,
|
|
fighter::{
|
|
attack::{
|
|
AttackKind, FighterAttackPlugin, FighterAttackScriptAssets, FighterDamage,
|
|
FighterKnockbackVelocity, FighterPendingHitReaction, FighterSolvedHurtboxes,
|
|
FighterStaleMoveQueue,
|
|
},
|
|
baked_animation::FighterBoneIndexMap,
|
|
ecb::{FighterDesiredECB, FighterECBBoneIndices},
|
|
manifest::FighterManifest,
|
|
state::{FighterState, air::AirborneStateCommon, fall::FallState},
|
|
visual::{FighterAnimations, FighterInvulnerabilityVisualState},
|
|
},
|
|
game_settings::GameSettings,
|
|
input::FighterInput,
|
|
math::{int::FGi32, vec::FGVec2},
|
|
player::Player,
|
|
schedule::GameplaySet,
|
|
scripting::FighterAttackScript,
|
|
};
|
|
use state::state_interrupt_system;
|
|
|
|
/// Owns fighter simulation, the rollback registrations for fighter
|
|
/// components, and the fighter debug overlays.
|
|
#[derive(Default)]
|
|
pub struct FighterPlugin;
|
|
|
|
#[derive(Component, Copy, Clone, Hash, Debug, PartialEq, Reflect)]
|
|
pub enum FighterFacingDirection {
|
|
Left,
|
|
Right,
|
|
}
|
|
|
|
#[derive(Component, Copy, Clone, Hash, Debug, PartialEq, Reflect, Default)]
|
|
pub enum FighterHitstopShakeKind {
|
|
#[default]
|
|
Horizontal,
|
|
Vertical,
|
|
}
|
|
|
|
#[derive(Component, Copy, Clone, Hash, Debug, PartialEq, Reflect, Default)]
|
|
pub enum FighterHitstopShakeRole {
|
|
#[default]
|
|
Attacker,
|
|
Receiver,
|
|
}
|
|
|
|
#[derive(Component, Default, Copy, Clone, Hash)]
|
|
pub struct FighterHitstopShake {
|
|
pub remaining_frames: u32,
|
|
pub total_frames: u32,
|
|
pub kind: FighterHitstopShakeKind,
|
|
pub role: FighterHitstopShakeRole,
|
|
}
|
|
|
|
#[derive(Component, Default, Copy, Clone, Hash)]
|
|
#[require(FighterHitstopShake)]
|
|
pub struct FighterHitstop {
|
|
pub remaining_frames: u32,
|
|
}
|
|
|
|
#[derive(Component, Default, Copy, Clone, Hash, Debug, PartialEq, Eq)]
|
|
pub struct FighterLedgeInformation {
|
|
pub regrab_lockout_remaining_frames: u32,
|
|
pub grab_count: u32,
|
|
}
|
|
|
|
impl FighterLedgeInformation {
|
|
pub fn start(&mut self, duration_frames: u32) {
|
|
self.regrab_lockout_remaining_frames = duration_frames;
|
|
}
|
|
|
|
pub fn is_regrab_lockout_active(&self) -> bool {
|
|
self.regrab_lockout_remaining_frames > 0
|
|
}
|
|
|
|
fn tick(&mut self) {
|
|
self.regrab_lockout_remaining_frames =
|
|
self.regrab_lockout_remaining_frames.saturating_sub(1);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod ledge_grab_lockout_tests {
|
|
use super::FighterLedgeInformation;
|
|
|
|
#[test]
|
|
fn lockout_expires_after_the_requested_number_of_ticks() {
|
|
let mut lockout = FighterLedgeInformation::default();
|
|
lockout.start(2);
|
|
|
|
assert!(lockout.is_regrab_lockout_active());
|
|
lockout.tick();
|
|
assert!(lockout.is_regrab_lockout_active());
|
|
lockout.tick();
|
|
assert!(!lockout.is_regrab_lockout_active());
|
|
}
|
|
|
|
#[test]
|
|
fn zero_duration_does_not_enable_lockout() {
|
|
let mut lockout = FighterLedgeInformation::default();
|
|
lockout.start(0);
|
|
|
|
assert!(!lockout.is_regrab_lockout_active());
|
|
}
|
|
}
|
|
|
|
#[derive(Component, Default, Copy, Clone, Hash)]
|
|
pub enum FighterInvulnerabilityState {
|
|
#[default]
|
|
Vulnerable,
|
|
Invulnerable {
|
|
frames_remaining: Option<u32>,
|
|
},
|
|
Intangible {
|
|
frames_remaining: Option<u32>,
|
|
},
|
|
}
|
|
|
|
impl FighterFacingDirection {
|
|
fn reverse(&self) -> Self {
|
|
match self {
|
|
FighterFacingDirection::Left => FighterFacingDirection::Right,
|
|
FighterFacingDirection::Right => FighterFacingDirection::Left,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(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 {
|
|
pub fn to_sign(&self) -> FGi32 {
|
|
match self {
|
|
FighterFacingDirection::Left => FGi32::NEG_ONE,
|
|
FighterFacingDirection::Right => FGi32::ONE,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Component)]
|
|
#[require(Transform)]
|
|
pub struct FighterVisual;
|
|
|
|
#[derive(Component, DerefMut, Deref, Debug, Default, Hash, Clone, Copy)]
|
|
pub struct FighterPendingRespawn(bool);
|
|
|
|
#[derive(Component)]
|
|
#[require(
|
|
FighterStaleMoveQueue,
|
|
FighterDamage,
|
|
FighterPendingHitReaction,
|
|
FighterPendingRespawn,
|
|
FighterHitstop,
|
|
FighterLedgeInformation,
|
|
FighterKnockbackVelocity,
|
|
FighterTranslation,
|
|
FighterInvulnerabilityState,
|
|
FighterInvulnerabilityVisualState
|
|
)]
|
|
pub struct Fighter {
|
|
pub manifest: Handle<FighterManifest>,
|
|
}
|
|
|
|
#[derive(Resource, Clone, Debug)]
|
|
pub struct FighterRoster(pub Vec<Entity>);
|
|
|
|
impl Plugin for FighterPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.add_plugins(FighterAttackPlugin)
|
|
// Every state gets a chance to interrupt itself, in priority order.
|
|
.add_systems(
|
|
GgrsSchedule,
|
|
motion::clear_fighter_presentation_snaps.in_set(GameplaySet::Input),
|
|
)
|
|
.add_systems(
|
|
GgrsSchedule,
|
|
(
|
|
(ecb::snapshot_fighter_ecb, motion::snapshot_fighter_motion)
|
|
.before(state_interrupt_system),
|
|
animation::advance_fighter_animation_frames.before(state_interrupt_system),
|
|
state::state_interrupt_system,
|
|
)
|
|
.in_set(GameplaySet::Interrupt),
|
|
)
|
|
.add_systems(
|
|
GgrsSchedule,
|
|
(
|
|
state::state_update_system,
|
|
decay_invulnerability,
|
|
)
|
|
.chain()
|
|
.in_set(GameplaySet::StateUpdate),
|
|
)
|
|
/*.add_systems(
|
|
GgrsSchedule,
|
|
(motion::integrate_gravity, motion::apply_grounded_motion)
|
|
.chain()
|
|
.in_set(GameplaySet::Physics),
|
|
)*/
|
|
.add_systems(
|
|
GgrsSchedule,
|
|
(
|
|
(
|
|
blast_zones::apply_blast_zones,
|
|
ecb::decrement_ecb_lockout,
|
|
ecb::update_ecb,
|
|
state::state_collision_interrupt_system,
|
|
// Every gameplay phase must observe the same hitstop value.
|
|
decay_hitstop,
|
|
decay_ledge_grab_lockout,
|
|
)
|
|
.chain(),
|
|
baked_animation::resample_fighter_bone_matrices,
|
|
)
|
|
.chain()
|
|
.in_set(GameplaySet::Collision),
|
|
)
|
|
.add_systems(
|
|
GgrsSchedule,
|
|
push::apply_grounded_pushes
|
|
.before(state::state_update_system)
|
|
.in_set(GameplaySet::StateUpdate),
|
|
)
|
|
.add_systems(
|
|
PostUpdate,
|
|
(
|
|
motion::apply_fighter_translation_to_visuals,
|
|
animation::apply_animation,
|
|
)
|
|
.chain()
|
|
.before(bevy::transform::TransformSystems::Propagate)
|
|
.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
|
|
// is a desync waiting to happen.
|
|
.rollback_component_with_copy::<FighterECB>()
|
|
.checksum_component_with_hash::<FighterECB>()
|
|
.rollback_component_with_copy::<FighterDesiredECB>()
|
|
.checksum_component_with_hash::<FighterDesiredECB>()
|
|
.rollback_component_with_clone::<FighterStaleMoveQueue>()
|
|
.checksum_component_with_hash::<FighterStaleMoveQueue>()
|
|
.rollback_component_with_copy::<ecb::FighterPreviousECB>()
|
|
.checksum_component_with_hash::<ecb::FighterPreviousECB>()
|
|
.rollback_component_with_copy::<FighterVelocity>()
|
|
.checksum_component_with_hash::<FighterVelocity>()
|
|
.rollback_component_with_copy::<FighterTranslation>()
|
|
.checksum_component_with_hash::<FighterTranslation>()
|
|
.rollback_component_with_copy::<FighterPreviousTranslation>()
|
|
.checksum_component_with_hash::<FighterPreviousTranslation>()
|
|
.rollback_component_with_copy::<FighterPresentationSnap>()
|
|
.checksum_component_with_hash::<FighterPresentationSnap>()
|
|
.rollback_component_with_copy::<FighterPreviousFacingDirection>()
|
|
.checksum_component_with_hash::<FighterPreviousFacingDirection>()
|
|
.rollback_component_with_copy::<animation::FighterAnimationFrame>()
|
|
.checksum_component_with_hash::<animation::FighterAnimationFrame>()
|
|
.rollback_component_with_clone::<FighterSurfaceState>()
|
|
.checksum_component_with_hash::<FighterSurfaceState>()
|
|
.rollback_component_with_copy::<FighterFacingDirection>()
|
|
.rollback_component_with_copy::<FighterDamage>()
|
|
.checksum_component_with_hash::<FighterDamage>()
|
|
.rollback_component_with_clone::<FighterPendingHitReaction>()
|
|
.checksum_component_with_hash::<FighterPendingHitReaction>()
|
|
.rollback_component_with_copy::<FighterPendingRespawn>()
|
|
.checksum_component_with_hash::<FighterPendingRespawn>()
|
|
.rollback_component_with_copy::<FighterHitstop>()
|
|
.checksum_component_with_hash::<FighterHitstop>()
|
|
.rollback_component_with_copy::<FighterLedgeInformation>()
|
|
.checksum_component_with_hash::<FighterLedgeInformation>()
|
|
.rollback_component_with_copy::<FighterInvulnerabilityState>()
|
|
.checksum_component_with_hash::<FighterInvulnerabilityState>()
|
|
.rollback_component_with_copy::<FighterKnockbackVelocity>()
|
|
.checksum_component_with_hash::<FighterKnockbackVelocity>()
|
|
.rollback_component_with_clone::<baked_animation::FighterBoneMatrices>()
|
|
.checksum_component_with_hash::<baked_animation::FighterBoneMatrices>()
|
|
// Debug views.
|
|
.add_systems(
|
|
FixedPostUpdate,
|
|
debug::debug_draw_ecb.run_if(crate::debug_tools::ecb_enabled),
|
|
)
|
|
.add_systems(
|
|
FixedPostUpdate,
|
|
debug::ledge_grab_debug.run_if(crate::debug_tools::ledge_grab_enabled),
|
|
)
|
|
.add_systems(
|
|
EguiPrimaryContextPass,
|
|
debug::fighter_debug.run_if(crate::debug_tools::fighter_info_enabled),
|
|
)
|
|
.add_systems(
|
|
FixedPostUpdate,
|
|
debug::animation_debug.run_if(crate::debug_tools::animation_bones_enabled),
|
|
)
|
|
.add_systems(
|
|
FixedPostUpdate,
|
|
(
|
|
debug::attack_debug_gather,
|
|
(debug::attack_debug, debug::attack_launch_debug_draw)
|
|
.after(debug::attack_debug_gather),
|
|
)
|
|
.chain()
|
|
.run_if(crate::debug_tools::attack_hitboxes_enabled)
|
|
.run_if(in_state(AppState::InMatch)),
|
|
)
|
|
.add_systems(
|
|
FixedPostUpdate,
|
|
visual::update_status_colors.run_if(resource_exists::<GameSettings>),
|
|
)
|
|
.add_systems(
|
|
FixedPostUpdate,
|
|
debug::hurtbox_debug.run_if(crate::debug_tools::hurtboxes_enabled),
|
|
)
|
|
.rollback_component_with_clone::<FighterState>()
|
|
.rollback_component_with_clone::<FighterHitboxes>()
|
|
.rollback_component_with_clone::<FighterHitstopShake>()
|
|
.checksum_component_with_hash::<FighterHitboxes>()
|
|
.checksum_component_with_hash::<FighterState>()
|
|
.checksum_component_with_hash::<FighterFacingDirection>()
|
|
.register_type::<FighterFacingDirection>();
|
|
}
|
|
}
|
|
#[derive(Clone, Debug, Hash)]
|
|
pub struct FighterActiveAttack {
|
|
pub kind: AttackKind,
|
|
pub script: Handle<FighterAttackScript>,
|
|
pub active_hitboxes: Vec<usize>,
|
|
pub solved_hitboxes: Vec<attack::FighterSolvedHitbox>,
|
|
pub hit_groups: Vec<FighterHitGroupState>,
|
|
pub stalled: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Hash)]
|
|
pub struct FighterHitGroupState {
|
|
pub group: u32,
|
|
pub hit_fighters: Vec<Entity>,
|
|
}
|
|
|
|
#[derive(Component, Clone, Debug, Default, Hash)]
|
|
pub struct FighterHitboxes(pub Option<FighterActiveAttack>);
|
|
|
|
pub fn spawn_fighter(
|
|
commands: &mut Commands,
|
|
player_handle: usize,
|
|
controller_port: usize,
|
|
spawn_position: FGVec2,
|
|
manifest: Handle<FighterManifest>,
|
|
model_scale: FGi32,
|
|
animations: FighterAnimations,
|
|
attack_scripts: FighterAttackScriptAssets,
|
|
visual_root: WorldAssetRoot,
|
|
ecb_bone_indices: Vec<usize>,
|
|
) -> Entity {
|
|
commands
|
|
.spawn((
|
|
Name(format!("Fighter {player_handle}").into()),
|
|
Player {
|
|
handle: player_handle,
|
|
controller_port,
|
|
},
|
|
Fighter { manifest: manifest },
|
|
FighterFacingDirection::Right,
|
|
FighterPreviousFacingDirection(FighterFacingDirection::Right),
|
|
FighterSurfaceState::default(),
|
|
Transform::from_scale(Vec3::splat(model_scale.to_num())),
|
|
(
|
|
FighterECB::default(),
|
|
FighterDesiredECB::default(),
|
|
FighterHitboxes::default(),
|
|
FighterECBBoneIndices {
|
|
indices: ecb_bone_indices,
|
|
},
|
|
),
|
|
(
|
|
FighterVelocity::default(),
|
|
FighterTranslation(spawn_position),
|
|
FighterPreviousTranslation(spawn_position),
|
|
FighterPresentationSnap::default(),
|
|
),
|
|
visual_root,
|
|
animations,
|
|
attack_scripts,
|
|
FighterState::Fall(FallState::new(AirborneStateCommon::default())),
|
|
animation::FighterAnimationFrame::new(animation::AnimKind::Wait, true),
|
|
(
|
|
baked_animation::FighterBoneMatrices::default(),
|
|
FighterBoneIndexMap::default(),
|
|
FighterSolvedHurtboxes::default(),
|
|
FighterVisual,
|
|
FighterInput::default(),
|
|
crate::camera::FighterCameraExtents::default(),
|
|
),
|
|
))
|
|
.id()
|
|
}
|
|
|
|
pub fn decay_hitstop(query: Query<(&mut FighterHitstop, &mut FighterHitstopShake)>) {
|
|
for (mut hitstop, mut hitstop_shake) in query {
|
|
hitstop.remaining_frames = hitstop.remaining_frames.saturating_sub(1);
|
|
hitstop_shake.remaining_frames = hitstop_shake.remaining_frames.saturating_sub(1);
|
|
}
|
|
}
|
|
|
|
pub fn decay_ledge_grab_lockout(query: Query<&mut FighterLedgeInformation>) {
|
|
for mut lockout in query {
|
|
lockout.tick();
|
|
}
|
|
}
|
|
|
|
pub fn decay_invulnerability(query: Query<&mut FighterInvulnerabilityState>) {
|
|
for invuln_state in query {
|
|
let invuln_state = invuln_state.into_inner();
|
|
let frames_remaining = match invuln_state {
|
|
FighterInvulnerabilityState::Vulnerable => None,
|
|
FighterInvulnerabilityState::Invulnerable { frames_remaining } => {
|
|
if let Some(frames_remaining) = frames_remaining {
|
|
*frames_remaining = frames_remaining.saturating_sub(1);
|
|
Some(*frames_remaining)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
FighterInvulnerabilityState::Intangible { frames_remaining } => {
|
|
if let Some(frames_remaining) = frames_remaining {
|
|
*frames_remaining = frames_remaining.saturating_sub(1);
|
|
Some(*frames_remaining)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
};
|
|
|
|
if let Some(frames_remaining) = frames_remaining {
|
|
if frames_remaining == 0 {
|
|
*invuln_state = FighterInvulnerabilityState::Vulnerable;
|
|
}
|
|
}
|
|
}
|
|
}
|