diff --git a/src/debug_tools.rs b/src/debug_tools.rs index cf1755c..d006402 100644 --- a/src/debug_tools.rs +++ b/src/debug_tools.rs @@ -111,6 +111,7 @@ enum DebugFlag { StageCollision, AnimationBones, AttackHitboxes, + Hurtboxes, Camera, DrawThroughGeometry, } @@ -156,6 +157,10 @@ const FIGHTER_MENU: &[DebugMenuEntry] = &[ label: "Attack hitboxes", flag: DebugFlag::AttackHitboxes, }, + DebugMenuEntry::Toggle { + label: "Hurtboxes", + flag: DebugFlag::Hurtboxes, + }, ]; const WORLD_MENU: &[DebugMenuEntry] = &[ @@ -227,6 +232,7 @@ pub struct DebugSettings { pub stage_collision: bool, pub animation_bones: bool, pub attack_hitboxes: bool, + pub hurtboxes: bool, pub camera: bool, pub draw_through_geometry: bool, pub motion_sampling: PresentationMotionMode, @@ -246,6 +252,7 @@ impl Default for DebugSettings { stage_collision: false, animation_bones: false, attack_hitboxes: false, + hurtboxes: false, camera: false, draw_through_geometry: false, motion_sampling: PresentationMotionMode::default(), @@ -309,6 +316,7 @@ impl DebugSettings { DebugFlag::StageCollision => self.stage_collision, DebugFlag::AnimationBones => self.animation_bones, DebugFlag::AttackHitboxes => self.attack_hitboxes, + DebugFlag::Hurtboxes => self.hurtboxes, DebugFlag::DrawThroughGeometry => self.draw_through_geometry, DebugFlag::Camera => self.camera, } @@ -321,6 +329,7 @@ impl DebugSettings { DebugFlag::StageCollision => self.stage_collision = !self.stage_collision, DebugFlag::AnimationBones => self.animation_bones = !self.animation_bones, DebugFlag::AttackHitboxes => self.attack_hitboxes = !self.attack_hitboxes, + DebugFlag::Hurtboxes => self.hurtboxes = !self.hurtboxes, DebugFlag::DrawThroughGeometry => { self.draw_through_geometry = !self.draw_through_geometry } @@ -623,6 +632,10 @@ pub fn attack_hitboxes_enabled(settings: Res) -> bool { settings.attack_hitboxes } +pub fn hurtboxes_enabled(settings: Res) -> bool { + settings.hurtboxes +} + pub fn camera_debug_enabled(settings: Res) -> bool { settings.camera } diff --git a/src/fighter/attack.rs b/src/fighter/attack.rs index 26d567c..4a5b70a 100644 --- a/src/fighter/attack.rs +++ b/src/fighter/attack.rs @@ -1,11 +1,22 @@ use std::collections::{HashMap, VecDeque}; -use bevy::{asset::Handle, ecs::component::Component, reflect::Reflect}; +use bevy::prelude::*; +use bevy_ggrs::prelude::GgrsSchedule; use serde::{Deserialize, Serialize}; use crate::{ - fighter::{FighterAttributes, animation::AnimKind}, - math::{int::FGi32, vec3::FGVec3}, + fighter::{ + Fighter, FighterAttributes, FighterFacingDirection, FighterTranslation, + animation::AnimKind, + baked_animation::{FighterBoneMatrices, FixedMat4}, + hurtbox::FixedAffineCapsule, + manifest::FighterManifest, + }, + math::{ + int::{FGi32, FGi32Ext}, + vec3::FGVec3, + }, + schedule::GameplaySet, scripting::FighterAttackScript, }; #[derive(Debug, Default, Clone, Serialize, Deserialize)] @@ -164,7 +175,69 @@ pub fn update_active_hitbox_list(script: &FighterAttackScript, list: &mut Vec>, } +#[derive(Component, Clone, Debug, Default)] +pub struct FighterSolvedHurtboxes(pub Vec); + +pub fn solve_hurtboxes( + mut query: Query<( + &FighterBoneMatrices, + &FighterTranslation, + &FighterFacingDirection, + &mut FighterSolvedHurtboxes, + &Fighter, + )>, + manifests: Res>, +) { + for (matrices, translation, facing_direction, mut solved_hurtboxes, fighter) in &mut query { + let manifest = manifests + .get(&fighter.manifest) + .expect("Manifest should be valid"); + let fighter_trf = translation.get_3d_transform(facing_direction); + solved_hurtboxes.0.clear(); + for (hurtbox_idx, hurtbox) in manifest.hurtboxes.iter().enumerate() { + let bone_matrix = matrices.get_current(&hurtbox.bone); + + if let Some(bone_matrix) = bone_matrix { + let to_global = fighter_trf.mul(bone_matrix); + let hurtbox_trf = FixedMat4::IDENTITY + .with_rotation(FGVec3::new( + hurtbox.rotation.x.to_radians(), + hurtbox.rotation.y.to_radians(), + hurtbox.rotation.z.to_radians(), + )) + .with_translation(hurtbox.offset); + solved_hurtboxes.0.push(FighterSolvedHurtbox { + hurtbox_idx, + capsule: FixedAffineCapsule::new( + to_global.mul(hurtbox_trf), + hurtbox.half_length, + hurtbox.radius, + ), + }); + } + } + } +} + +pub struct FighterAttackPlugin; + +impl Plugin for FighterAttackPlugin { + fn build(&self, app: &mut App) { + app.add_systems( + GgrsSchedule, + solve_hurtboxes + .after(crate::fighter::baked_animation::update_fighter_bone_matrices) + .in_set(GameplaySet::Animation), + ); + } +} diff --git a/src/fighter/baked_animation.rs b/src/fighter/baked_animation.rs index b447326..9e3d29b 100644 --- a/src/fighter/baked_animation.rs +++ b/src/fighter/baked_animation.rs @@ -28,7 +28,7 @@ use crate::{ visual::FighterAnimations, }, math::{ - int::{FGWide, FGi32}, + int::{FGWide, FGi32, FGi32Ext}, vec3::FGVec3, }, schedule::GameplaySet, @@ -108,6 +108,28 @@ impl FixedMat4 { self.cols[3][2] = translation.z; } + pub fn with_translation(mut self, translation: FGVec3) -> Self { + self.translate(translation); + self + } + + /// Replaces the rotation with XYZ Euler angles expressed in radians. + pub fn with_rotation(mut self, rotation: FGVec3) -> Self { + let (sin_x, cos_x) = rotation.x.sin_cos(); + let (sin_y, cos_y) = rotation.y.sin_cos(); + let (sin_z, cos_z) = rotation.z.sin_cos(); + self.cols[0][0] = cos_y * cos_z; + self.cols[0][1] = cos_x * sin_z + sin_x * sin_y * cos_z; + self.cols[0][2] = sin_x * sin_z - cos_x * sin_y * cos_z; + self.cols[1][0] = -cos_y * sin_z; + self.cols[1][1] = cos_x * cos_z - sin_x * sin_y * sin_z; + self.cols[1][2] = sin_x * cos_z + cos_x * sin_y * sin_z; + self.cols[2][0] = sin_y; + self.cols[2][1] = -sin_x * cos_y; + self.cols[2][2] = cos_x * cos_y; + self + } + pub fn get_translation(&self) -> FGVec3 { FGVec3::new(self.cols[3][0], self.cols[3][1], self.cols[3][2]) } @@ -949,4 +971,26 @@ mod tests { FGVec3::lit("10", "2", "-4") ); } + + #[test] + fn fixed_matrix_builders_set_euler_rotation_and_translation() { + let transform = FixedMat4::IDENTITY + .with_rotation(FGVec3::lit("0", "0", "1.5707963267948966")) + .with_translation(FGVec3::lit("10", "2", "-3")); + + assert_eq!( + transform.transform_point(FGVec3::lit("1", "0", "0")), + FGVec3::lit("10", "3", "-3") + ); + assert_eq!(transform.get_translation(), FGVec3::lit("10", "2", "-3")); + } + + #[test] + fn fixed_matrix_rotation_builder_preserves_translation() { + let transform = FixedMat4::IDENTITY + .with_translation(FGVec3::lit("4", "5", "6")) + .with_rotation(FGVec3::lit("0", "0", "1.5707963267948966")); + + assert_eq!(transform.get_translation(), FGVec3::lit("4", "5", "6")); + } } diff --git a/src/fighter/debug.rs b/src/fighter/debug.rs index 1e0e488..7c5abf7 100644 --- a/src/fighter/debug.rs +++ b/src/fighter/debug.rs @@ -1,21 +1,21 @@ //! Fighter debug views: gizmos and the egui inspector window. -use std::ops::Mul; +use std::sync::OnceLock; use bevy::color::palettes::css::ORANGE; -use bevy::gizmos::primitives::dim3::Capsule3dBuilder; use bevy::{color::palettes::css::YELLOW, prelude::*}; use bevy_egui::{EguiContexts, egui}; use crate::debug_tools::{DebugSettings, FighterDebugRowKind}; use crate::fighter::animation::FighterAnimationFrame; +use crate::fighter::attack::FighterSolvedHurtboxes; use crate::fighter::baked_animation::{BakedFighterAnimations, FighterBoneMatrices}; use crate::fighter::ecb::FighterPreviousECB; -use crate::fighter::manifest::FighterManifest; +use crate::fighter::hurtbox::FixedAffineCapsule; use crate::fighter::state::StateNameDebug; use crate::fighter::visual::FighterAnimations; use crate::fighter::{ - Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterPreviousTranslation, + FighterECB, FighterFacingDirection, FighterHitboxes, FighterPreviousTranslation, FighterTranslation, FighterVelocity, }; use crate::input::FighterInput; @@ -160,52 +160,157 @@ pub fn animation_debug(query: Query<(&GlobalTransform, &FighterBoneMatrices)>, m } } -pub fn hurtbox_debug( - query: Query<( - &FighterBoneMatrices, - &FighterTranslation, - &FighterFacingDirection, - &Fighter, - )>, - manifests: Res>, - mut gizmos: Gizmos, -) { - for (matrices, translation, facing_direction, fighter) in query { - let manifest = manifests - .get(&fighter.manifest) - .expect("Manifest should be valid"); - let fighter_trf = translation.get_3d_transform(facing_direction); - for hurtbox in manifest.hurtboxes.iter() { - let bone_matrix = matrices.get_current(&hurtbox.bone); - - if let Some(bone_matrix) = bone_matrix { - let to_global = fighter_trf.mul(bone_matrix); - let capsule = Capsule3d { - half_length: hurtbox.half_length.to_num(), - radius: hurtbox.radius.to_num(), - }; - - let hurtbox_trf = Transform::IDENTITY - .with_rotation(Quat::from_euler( - EulerRot::XYZ, - f32::to_radians(hurtbox.rotation.x.to_num()), - f32::to_radians(hurtbox.rotation.y.to_num()), - f32::to_radians(hurtbox.rotation.z.to_num()), - )) - .with_translation(hurtbox.offset.to_vec3()); - - let hurtbox_trf = Transform::from_matrix(to_global.to_mat4()).mul(hurtbox_trf); - - gizmos.primitive_3d( - &capsule, - hurtbox_trf.to_isometry(), - bevy::color::palettes::css::YELLOW, - ); - } +pub fn hurtbox_debug(query: Query<&FighterSolvedHurtboxes>, mut gizmos: Gizmos) { + for hurtboxes in &query { + for hurtbox in &hurtboxes.0 { + draw_affine_capsule(&mut gizmos, hurtbox.capsule, YELLOW); } } } +fn draw_affine_capsule( + gizmos: &mut Gizmos, + capsule: FixedAffineCapsule, + color: impl Into + Copy, +) { + let color = color.into(); + let wireframe = capsule_wireframe(); + let transform = capsule.transform.to_mat4(); + let radius = capsule.radius.to_num::(); + let half_length = capsule.half_length.to_num::(); + let vertices: Vec<_> = wireframe + .vertices + .iter() + .map(|vertex| { + transform.transform_point3( + vertex.direction * radius + Vec3::Y * vertex.center_sign * half_length, + ) + }) + .collect(); + for &(start, end) in &wireframe.lines { + gizmos.line(vertices[start], vertices[end], color); + } +} + +struct CapsuleWireVertex { + direction: Vec3, + center_sign: f32, +} + +struct CapsuleWireframe { + vertices: Vec, + lines: Vec<(usize, usize)>, +} + +fn capsule_wireframe() -> &'static CapsuleWireframe { + const RESOLUTION: usize = 16; + const HEMISPHERE_STEPS: usize = 4; + static WIREFRAME: OnceLock = OnceLock::new(); + + WIREFRAME.get_or_init(|| { + let mut vertices = Vec::new(); + let mut lines = Vec::new(); + let mut lower_ring = Vec::with_capacity(RESOLUTION); + let mut upper_ring = Vec::with_capacity(RESOLUTION); + + for index in 0..RESOLUTION { + let angle = index as f32 * std::f32::consts::TAU / RESOLUTION as f32; + let (sin, cos) = angle.sin_cos(); + lower_ring.push(vertices.len()); + vertices.push(CapsuleWireVertex { + direction: Vec3::new(cos, 0.0, sin), + center_sign: -1.0, + }); + upper_ring.push(vertices.len()); + vertices.push(CapsuleWireVertex { + direction: Vec3::new(cos, 0.0, sin), + center_sign: 1.0, + }); + } + + let lower_pole = vertices.len(); + vertices.push(CapsuleWireVertex { + direction: Vec3::NEG_Y, + center_sign: -1.0, + }); + let upper_pole = vertices.len(); + vertices.push(CapsuleWireVertex { + direction: Vec3::Y, + center_sign: 1.0, + }); + + for index in 0..RESOLUTION { + let next = (index + 1) % RESOLUTION; + lines.push((lower_ring[index], lower_ring[next])); + lines.push((upper_ring[index], upper_ring[next])); + lines.push((lower_ring[index], upper_ring[index])); + + let angle = index as f32 * std::f32::consts::TAU / RESOLUTION as f32; + let (sin_azimuth, cos_azimuth) = angle.sin_cos(); + let mut lower_previous = lower_ring[index]; + let mut upper_previous = upper_ring[index]; + for step in 1..HEMISPHERE_STEPS { + let latitude = step as f32 * std::f32::consts::FRAC_PI_2 + / HEMISPHERE_STEPS as f32; + let (sin_latitude, cos_latitude) = latitude.sin_cos(); + + let lower = vertices.len(); + vertices.push(CapsuleWireVertex { + direction: Vec3::new( + cos_azimuth * cos_latitude, + -sin_latitude, + sin_azimuth * cos_latitude, + ), + center_sign: -1.0, + }); + lines.push((lower_previous, lower)); + lower_previous = lower; + + let upper = vertices.len(); + vertices.push(CapsuleWireVertex { + direction: Vec3::new( + cos_azimuth * cos_latitude, + sin_latitude, + sin_azimuth * cos_latitude, + ), + center_sign: 1.0, + }); + lines.push((upper_previous, upper)); + upper_previous = upper; + } + lines.push((lower_previous, lower_pole)); + lines.push((upper_previous, upper_pole)); + } + + CapsuleWireframe { vertices, lines } + }) +} + +#[cfg(test)] +mod capsule_debug_tests { + use super::*; + + #[test] + fn capsule_wireframe_caps_extend_in_opposite_directions() { + let wireframe = capsule_wireframe(); + let lower: Vec<_> = wireframe + .vertices + .iter() + .filter(|vertex| vertex.center_sign < 0.0) + .collect(); + let upper: Vec<_> = wireframe + .vertices + .iter() + .filter(|vertex| vertex.center_sign > 0.0) + .collect(); + + assert!(lower.iter().all(|vertex| vertex.direction.y <= 0.0)); + assert!(upper.iter().all(|vertex| vertex.direction.y >= 0.0)); + assert!(lower.iter().any(|vertex| vertex.direction == Vec3::NEG_Y)); + assert!(upper.iter().any(|vertex| vertex.direction == Vec3::Y)); + } +} + pub fn attack_debug( query: Query<( &FighterHitboxes, diff --git a/src/fighter/hurtbox.rs b/src/fighter/hurtbox.rs new file mode 100644 index 0000000..9024787 --- /dev/null +++ b/src/fighter/hurtbox.rs @@ -0,0 +1,267 @@ +//! Fixed-point convex geometry used by fighter hurtboxes. + +use crate::{ + fighter::baked_animation::FixedMat4, + math::{ + int::{FGWide, FGi32}, + vec3::FGVec3, + }, +}; + +/// A capsule after an arbitrary affine transform. Non-uniform scale turns the +/// spherical caps and circular cylinder into ellipsoidal geometry. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FixedAffineCapsule { + pub transform: FixedMat4, + pub half_length: FGi32, + pub radius: FGi32, +} + +/// An ordinary world-space capsule, used for swept attack volumes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FixedCapsule { + pub start: FGVec3, + pub end: FGVec3, + pub radius: FGi32, +} + +impl FixedCapsule { + pub fn new(start: FGVec3, end: FGVec3, radius: FGi32) -> Self { + Self { start, end, radius } + } +} + +impl FixedAffineCapsule { + pub fn new(transform: FixedMat4, half_length: FGi32, radius: FGi32) -> Self { + Self { + transform, + half_length, + radius, + } + } + + pub fn transform_point(self, point: FGVec3) -> FGVec3 { + self.transform.transform_point(point) + } + + /// Tests the exact affine hurtbox against an ordinary world-space capsule. + pub fn intersects(self, rhs: FixedCapsule) -> bool { + let mut direction = rhs.start - self.transform.get_translation(); + if direction == FGVec3::ZERO { + direction = FGVec3::new(FGi32::ONE, FGi32::ZERO, FGi32::ZERO); + } + let mut simplex = [FGVec3::ZERO; 4]; + let mut simplex_len = 0; + for _ in 0..20 { + let point = self.support(direction) - rhs.support(-direction); + if point.dot(direction) < FGWide::ZERO { + return false; + } + for index in (1..=simplex_len.min(3)).rev() { + simplex[index] = simplex[index - 1]; + } + simplex[0] = point; + simplex_len += 1; + if update_simplex(&mut simplex, &mut simplex_len, &mut direction) { + return true; + } + if direction == FGVec3::ZERO { + return true; + } + } + // A capped GJK iteration means the origin remains enclosed by the + // current simplex to fixed-point precision, so conservatively collide. + true + } + +} + +trait SupportMap { + fn support(self, direction: FGVec3) -> FGVec3; +} + +impl SupportMap for FixedAffineCapsule { + fn support(self, direction: FGVec3) -> FGVec3 { + let local_direction = FGVec3::new( + dot_column(self.transform, 0, direction), + dot_column(self.transform, 1, direction), + dot_column(self.transform, 2, direction), + ); + let round = local_direction.normalized().unwrap_or(FGVec3::new( + FGi32::ONE, + FGi32::ZERO, + FGi32::ZERO, + )) * self.radius; + let endpoint = if local_direction.y >= FGi32::ZERO { + self.half_length + } else { + -self.half_length + }; + self.transform_point(round + FGVec3::new(FGi32::ZERO, endpoint, FGi32::ZERO)) + } +} + +impl SupportMap for FixedCapsule { + fn support(self, direction: FGVec3) -> FGVec3 { + let endpoint = if self.start.dot(direction) >= self.end.dot(direction) { + self.start + } else { + self.end + }; + endpoint + + direction + .normalized() + .unwrap_or(FGVec3::new(FGi32::ONE, FGi32::ZERO, FGi32::ZERO)) + * self.radius + } +} + +fn dot_column(matrix: FixedMat4, column: usize, vector: FGVec3) -> FGi32 { + FGi32::from_num( + FGWide::from_num(matrix.cols[column][0]) * FGWide::from_num(vector.x) + + FGWide::from_num(matrix.cols[column][1]) * FGWide::from_num(vector.y) + + FGWide::from_num(matrix.cols[column][2]) * FGWide::from_num(vector.z), + ) +} + +fn cross(lhs: FGVec3, rhs: FGVec3) -> FGVec3 { + FGVec3::new( + FGi32::from_num( + FGWide::from_num(lhs.y) * FGWide::from_num(rhs.z) + - FGWide::from_num(lhs.z) * FGWide::from_num(rhs.y), + ), + FGi32::from_num( + FGWide::from_num(lhs.z) * FGWide::from_num(rhs.x) + - FGWide::from_num(lhs.x) * FGWide::from_num(rhs.z), + ), + FGi32::from_num( + FGWide::from_num(lhs.x) * FGWide::from_num(rhs.y) + - FGWide::from_num(lhs.y) * FGWide::from_num(rhs.x), + ), + ) +} + +fn triple_cross(lhs: FGVec3, middle: FGVec3, rhs: FGVec3) -> FGVec3 { + cross(cross(lhs, middle), rhs) +} + +fn update_simplex( + simplex: &mut [FGVec3; 4], + simplex_len: &mut usize, + direction: &mut FGVec3, +) -> bool { + let a = simplex[0]; + let ao = -a; + match *simplex_len { + 2 => { + let ab = simplex[1] - a; + if ab.dot(ao) > FGWide::ZERO { + *direction = triple_cross(ab, ao, ab); + } else { + *simplex_len = 1; + *direction = ao; + } + false + } + 3 => { + let b = simplex[1]; + let c = simplex[2]; + let ab = b - a; + let ac = c - a; + let abc = cross(ab, ac); + if cross(abc, ac).dot(ao) > FGWide::ZERO { + if ac.dot(ao) > FGWide::ZERO { + simplex[1] = c; + *simplex_len = 2; + *direction = triple_cross(ac, ao, ac); + } else { + *simplex_len = 2; + *direction = triple_cross(ab, ao, ab); + } + } else if cross(ab, abc).dot(ao) > FGWide::ZERO { + *simplex_len = 2; + *direction = triple_cross(ab, ao, ab); + } else if abc.dot(ao) > FGWide::ZERO { + *direction = abc; + } else { + simplex.swap(1, 2); + *direction = -abc; + } + false + } + 4 => { + let b = simplex[1]; + let c = simplex[2]; + let d = simplex[3]; + for (first, second, opposite) in [(b, c, d), (c, d, b), (d, b, c)] { + let mut normal = cross(first - a, second - a); + if normal.dot(opposite - a) > FGWide::ZERO { + normal = -normal; + } + if normal.dot(ao) > FGWide::ZERO { + simplex[0] = a; + simplex[1] = first; + simplex[2] = second; + *simplex_len = 3; + *direction = normal; + return false; + } + } + true + } + _ => { + *direction = ao; + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn affine_capsule(x: &str, scale_y: &str) -> FixedAffineCapsule { + let mut transform = FixedMat4::IDENTITY; + transform.cols[1][1] = FGi32::lit(scale_y); + transform.translate(FGVec3::lit(x, "0", "0")); + FixedAffineCapsule::new(transform, FGi32::lit("1"), FGi32::lit("1")) + } + + fn capsule(x: &str) -> FixedCapsule { + FixedCapsule::new( + FGVec3::lit(x, "-1", "0"), + FGVec3::lit(x, "1", "0"), + FGi32::lit("1"), + ) + } + + #[test] + fn detects_touching_and_separated_capsules() { + assert!(affine_capsule("0", "1").intersects(capsule("2"))); + assert!(!affine_capsule("0", "1").intersects(capsule("2.01"))); + } + + #[test] + fn affine_scale_changes_capsule_intersection() { + let sphere = FixedAffineCapsule::new( + FixedMat4::IDENTITY, + FGi32::ZERO, + FGi32::lit("1"), + ); + let mut squashed_transform = FixedMat4::IDENTITY; + squashed_transform.cols[1][1] = FGi32::lit("0.5"); + let squashed = FixedAffineCapsule::new( + squashed_transform, + FGi32::ZERO, + FGi32::lit("1"), + ); + let target = FixedCapsule::new( + FGVec3::lit("0", "1.4", "0"), + FGVec3::lit("0", "1.4", "0"), + FGi32::lit("0.5"), + ); + + assert!(sphere.intersects(target)); + assert!(!squashed.intersects(target)); + } +} diff --git a/src/fighter/manifest.rs b/src/fighter/manifest.rs index 3068d52..9b368f6 100644 --- a/src/fighter/manifest.rs +++ b/src/fighter/manifest.rs @@ -7,7 +7,10 @@ use strum::IntoEnumIterator; use strum_macros::EnumIter; use crate::{ - AppState, fighter::{FighterAttributes, FighterCameraProfile, animation::AnimKind, attack::AttackKind}, game_settings::GameSettings, math::{int::FGi32, vec3::FGVec3}, + AppState, + fighter::{FighterAttributes, FighterCameraProfile, animation::AnimKind, attack::AttackKind}, + game_settings::GameSettings, + math::{int::FGi32, vec3::FGVec3}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Reflect, EnumIter)] @@ -23,7 +26,7 @@ pub struct FighterHurtbox { #[reflect(ignore)] pub half_length: FGi32, #[reflect(ignore)] - pub radius: FGi32 + pub radius: FGi32, } #[derive(Serialize, Deserialize, Clone, Asset, Reflect)] diff --git a/src/fighter/mod.rs b/src/fighter/mod.rs index 229c82b..06fef19 100644 --- a/src/fighter/mod.rs +++ b/src/fighter/mod.rs @@ -13,6 +13,7 @@ pub mod baked_animation; pub mod collision; pub mod debug; pub mod ecb; +pub mod hurtbox; pub mod manifest; pub mod motion; pub mod state; @@ -29,7 +30,7 @@ pub use motion::{FighterPreviousTranslation, FighterTranslation, FighterVelocity use crate::{ fighter::{ - attack::FighterAttackScriptAssets, + attack::{FighterAttackPlugin, FighterAttackScriptAssets, FighterSolvedHurtboxes}, manifest::FighterManifest, state::{FighterState, fall::FallState}, visual::FighterAnimations, @@ -105,7 +106,7 @@ pub struct Fighter { impl Plugin for FighterPlugin { fn build(&self, app: &mut App) { - app + app.add_plugins(FighterAttackPlugin) // Every state gets a chance to interrupt itself, in priority order. .add_systems( GgrsSchedule, @@ -171,10 +172,11 @@ impl Plugin for FighterPlugin { ) .add_systems( FixedPostUpdate, - debug::hurtbox_debug + debug::hurtbox_debug.run_if(crate::debug_tools::hurtboxes_enabled), ) .rollback_component_with_clone::() .rollback_component_with_clone::() + .rollback_component_with_clone::() .checksum_component_with_hash::() .checksum_component_with_hash::() .register_type::(); @@ -230,9 +232,12 @@ pub fn spawn_fighter( attack_scripts, FighterState::Fall(FallState), animation::FighterAnimationFrame::new(animation::AnimKind::Wait, true), - baked_animation::FighterBoneMatrices::default(), - FighterVisual, - FighterInput::default(), - crate::camera::FighterCameraExtents::default(), + ( + baked_animation::FighterBoneMatrices::default(), + FighterSolvedHurtboxes::default(), + FighterVisual, + FighterInput::default(), + crate::camera::FighterCameraExtents::default(), + ), )); } diff --git a/src/fighter/state/air_dodge.rs b/src/fighter/state/air_dodge.rs index 5263dc1..a6b2178 100644 --- a/src/fighter/state/air_dodge.rs +++ b/src/fighter/state/air_dodge.rs @@ -1,14 +1,10 @@ - - use crate::{ fighter::{ animation::AnimKind, collision, state::{ - FighterState, FighterStateContext, FighterStateImpl, air, - fall::FallState, - ground::GroundedStateCommon, - land::LandingState, + FighterState, FighterStateContext, FighterStateImpl, air, fall::FallState, + ground::GroundedStateCommon, land::LandingState, }, }, input, @@ -51,9 +47,7 @@ impl FighterStateImpl for AirDodgeState { let grounded_common = GroundedStateCommon { current_line_id: res.line_id, }; - Some(FighterState::Land(LandingState { - grounded_common - })) + Some(FighterState::Land(LandingState { grounded_common })) } else { None } diff --git a/src/fighter/state/dash.rs b/src/fighter/state/dash.rs index 93da978..872feab 100644 --- a/src/fighter/state/dash.rs +++ b/src/fighter/state/dash.rs @@ -1,4 +1,4 @@ -use std::hash::{Hasher}; +use std::hash::Hasher; use super::{FighterStateContext, FighterStateImpl, ground, run, wait, walk}; use crate::fighter::FighterFacingDirection; diff --git a/src/fighter/state/fall.rs b/src/fighter/state/fall.rs index 858d3ba..60d71b7 100644 --- a/src/fighter/state/fall.rs +++ b/src/fighter/state/fall.rs @@ -45,9 +45,7 @@ impl FighterStateImpl for FallState { let grounded_common = GroundedStateCommon { current_line_id: res.line_id, }; - Some(FighterState::Land(LandingState { - grounded_common - })) + Some(FighterState::Land(LandingState { grounded_common })) } else { None } diff --git a/src/fighter/state/ground.rs b/src/fighter/state/ground.rs index a210091..b38eadb 100644 --- a/src/fighter/state/ground.rs +++ b/src/fighter/state/ground.rs @@ -2,7 +2,9 @@ //! ECS types so it stays cheap to reason about and to unit-test — it has to be //! bit-for-bit deterministic for rollback. -use crate::fighter::state::{FighterState, FighterStateContext, dash, ground_attack, turn, wait, walk}; +use crate::fighter::state::{ + FighterState, FighterStateContext, dash, ground_attack, turn, wait, walk, +}; use crate::fighter::{ FighterAttributes, FighterPreviousTranslation, FighterTranslation, FighterVelocity, }; diff --git a/src/fighter/state/ground_attack.rs b/src/fighter/state/ground_attack.rs index fab9412..1e39241 100644 --- a/src/fighter/state/ground_attack.rs +++ b/src/fighter/state/ground_attack.rs @@ -1,6 +1,12 @@ use fixed::types::I16F16; -use crate::{fighter::{attack::{AttackKind, update_active_hitbox_list}, state::{FighterState, FighterStateImpl, ground::GroundedStateCommon, wait::WaitState}}, math::int::FGi32}; +use crate::{ + fighter::{ + attack::{AttackKind, update_active_hitbox_list}, + state::{FighterState, FighterStateImpl, ground::GroundedStateCommon, wait::WaitState}, + }, + math::int::FGi32, +}; #[derive(Clone, Debug, Hash)] pub struct GroundAttackState { @@ -11,10 +17,13 @@ pub struct GroundAttackState { impl FighterStateImpl for GroundAttackState { const NAME: &'static str = "GroundAttack"; - fn check_interrupt(&self, state_context: &mut super::FighterStateContext) -> Option { + fn check_interrupt( + &self, + state_context: &mut super::FighterStateContext, + ) -> Option { if state_context.is_current_animation_finished() { Some(FighterState::Wait(WaitState { - grounded_common: self.grounded_common.clone() + grounded_common: self.grounded_common.clone(), })) } else { None @@ -22,23 +31,30 @@ impl FighterStateImpl for GroundAttackState { } fn update(&mut self, state_context: &mut super::FighterStateContext) { - let script = state_context.attack_scripts.get(&state_context.attack_script_assets.scripts[&self.attack_kind]); - if let Some(script) = script { + let script = state_context + .attack_scripts + .get(&state_context.attack_script_assets.scripts[&self.attack_kind]); + if let Some(script) = script { let animation_frame = state_context.get_current_animation_frame(); - update_active_hitbox_list(script, &mut state_context.hitboxes.active_hitboxes, animation_frame); + update_active_hitbox_list( + script, + &mut state_context.hitboxes.active_hitboxes, + animation_frame, + ); } } - + fn on_enter(&mut self, state_context: &mut super::FighterStateContext) { state_context.play_animation(self.attack_kind.get_animation(), false); - state_context.hitboxes.attack_script = Some(state_context.attack_script_assets.scripts[&self.attack_kind].clone()); + 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, @@ -47,9 +63,14 @@ impl FighterStateImpl for GroundAttackState { } } -pub fn check_interrupt(state_context: &super::FighterStateContext, grounded_common: &GroundedStateCommon) -> Option { - if state_context.input.has_command(crate::input::FighterCommands::Attack) { - +pub fn check_interrupt( + state_context: &super::FighterStateContext, + grounded_common: &GroundedStateCommon, +) -> Option { + if state_context + .input + .has_command(crate::input::FighterCommands::Attack) + { let attack_kind = if state_context.input.get_last_frame().movement.y > FGi32::ZERO { AttackKind::UpTilt } else { @@ -58,9 +79,9 @@ pub fn check_interrupt(state_context: &super::FighterStateContext, grounded_comm Some(FighterState::GroundAttack(GroundAttackState { attack_kind, - grounded_common: grounded_common.clone() + grounded_common: grounded_common.clone(), })) } else { None } -} \ No newline at end of file +} diff --git a/src/fighter/state/jump.rs b/src/fighter/state/jump.rs index 5ccf82e..ead92a6 100644 --- a/src/fighter/state/jump.rs +++ b/src/fighter/state/jump.rs @@ -1,4 +1,3 @@ - use bevy::prelude::*; use crate::{ @@ -239,9 +238,7 @@ impl FighterStateImpl for JumpState { let grounded_common = GroundedStateCommon { current_line_id: res.line_id, }; - Some(FighterState::Land(LandingState { - grounded_common - })) + Some(FighterState::Land(LandingState { grounded_common })) } else { None } diff --git a/src/fighter/state/land.rs b/src/fighter/state/land.rs index f14ecc6..3ad74eb 100644 --- a/src/fighter/state/land.rs +++ b/src/fighter/state/land.rs @@ -13,14 +13,12 @@ use crate::fighter::{ #[derive(Debug, Clone, Hash)] pub struct LandingState { - pub grounded_common: GroundedStateCommon + pub grounded_common: GroundedStateCommon, } impl LandingState { pub fn create(grounded_common: GroundedStateCommon) -> Self { - Self { - grounded_common - } + Self { grounded_common } } } @@ -30,7 +28,9 @@ impl FighterStateImpl for LandingState { fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option { if state_context.is_current_animation_finished() { grounded_movement_standstill_common_interrupts(state_context, &self.grounded_common) - } else if state_context.get_current_animation_frame() >= state_context.fighter_manifest.attributes.landing_iasa { + } else if state_context.get_current_animation_frame() + >= state_context.fighter_manifest.attributes.landing_iasa + { dash::check_input(state_context, &self.grounded_common).or_else(|| { ground::grounded_movement_common_interrupts(state_context, &self.grounded_common) .or_else(|| walk::check_input(state_context, &self.grounded_common)) diff --git a/src/fighter/state/run.rs b/src/fighter/state/run.rs index 2ac75e7..e3fcedf 100644 --- a/src/fighter/state/run.rs +++ b/src/fighter/state/run.rs @@ -1,4 +1,3 @@ - use bevy::prelude::*; use super::{FighterStateContext, FighterStateImpl, ground, wait, walk}; diff --git a/src/fighter/state/turn.rs b/src/fighter/state/turn.rs index 1a3fe66..6a94b29 100644 --- a/src/fighter/state/turn.rs +++ b/src/fighter/state/turn.rs @@ -2,7 +2,7 @@ use super::{FighterStateContext, FighterStateImpl}; use crate::fighter::animation::AnimKind; use crate::fighter::state::fall::FallState; use crate::fighter::state::ground::{self, GroundedMotionResult, GroundedStateCommon}; -use crate::fighter::state::{dash, jump, wait, walk, FighterState}; +use crate::fighter::state::{FighterState, dash, jump, wait, walk}; #[derive(Debug, Clone, Hash)] pub struct TurnState { diff --git a/src/fighter/state/wait.rs b/src/fighter/state/wait.rs index 3b97de4..8bf11fd 100644 --- a/src/fighter/state/wait.rs +++ b/src/fighter/state/wait.rs @@ -1,12 +1,8 @@ - - use super::{FighterStateContext, FighterStateImpl, ground}; use crate::fighter::animation::AnimKind; -use crate::fighter::state::fall::FallState; -use crate::fighter::state::ground::{ - GroundedMotionResult, GroundedStateCommon, -}; use crate::fighter::state::FighterState; +use crate::fighter::state::fall::FallState; +use crate::fighter::state::ground::{GroundedMotionResult, GroundedStateCommon}; #[derive(Debug, Clone, Hash)] pub struct WaitState { @@ -16,12 +12,14 @@ pub struct WaitState { impl FighterStateImpl for WaitState { const NAME: &'static str = "Wait"; fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option { - if let Some(state) = ground::grounded_movement_common_interrupts(state_context, &self.grounded_common) { + if let Some(state) = + ground::grounded_movement_common_interrupts(state_context, &self.grounded_common) + { Some(state) } else if let Some(state) = ground::grounded_movement_standstill_common_interrupts( state_context, - &self.grounded_common) - { + &self.grounded_common, + ) { // Don't transition into yourself if let FighterState::Wait(_) = state { None diff --git a/src/fighter/state/walk.rs b/src/fighter/state/walk.rs index a55bca2..db6fdbd 100644 --- a/src/fighter/state/walk.rs +++ b/src/fighter/state/walk.rs @@ -1,10 +1,9 @@ - use super::{FighterStateContext, FighterStateImpl, dash, ground, wait}; use crate::fighter::animation::AnimKind; use crate::fighter::state::FighterState; use crate::fighter::state::fall::FallState; -use crate::fighter::state::turn; use crate::fighter::state::ground::{GroundedMotionResult, GroundedStateCommon}; +use crate::fighter::state::turn; #[derive(Debug, Clone, Hash)] pub struct WalkState { @@ -15,9 +14,8 @@ impl FighterStateImpl for WalkState { const NAME: &'static str = "Walk"; fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option { if let Some(state) = turn::check_input(state_context, &self.grounded_common) { - return Some(state) - } - else if let Some(state) = + return Some(state); + } else if let Some(state) = ground::grounded_movement_common_interrupts(state_context, &self.grounded_common) { return Some(state); diff --git a/src/math/int.rs b/src/math/int.rs index f763d63..f8a6460 100644 --- a/src/math/int.rs +++ b/src/math/int.rs @@ -1,2 +1,99 @@ pub type FGi32 = fixed::FixedI32; pub type FGWide = fixed::FixedI128; + +/// Angle conversions for the game's Q16.16 fixed-point values. +pub trait FGi32Ext { + fn to_radians(self) -> FGi32; + fn sin_cos(self) -> (FGi32, FGi32); +} + +impl FGi32Ext for FGi32 { + fn to_radians(self) -> FGi32 { + FGi32::from_num(FGWide::from_num(self) * FGWide::lit("0.01745329251994329576923690768489")) + } + + fn sin_cos(self) -> (FGi32, FGi32) { + const PI: FGi32 = FGi32::lit("3.14159265358979323846"); + const HALF_PI: FGi32 = FGi32::lit("1.57079632679489661923"); + const TWO_PI: FGi32 = FGi32::lit("6.28318530717958647692"); + const CORDIC_GAIN_INVERSE: FGi32 = FGi32::lit("0.60725293500888125617"); + const ANGLES: [FGi32; 16] = [ + FGi32::lit("0.78539816339744830962"), + FGi32::lit("0.46364760900080611621"), + FGi32::lit("0.24497866312686415417"), + FGi32::lit("0.12435499454676143503"), + FGi32::lit("0.06241880999595734847"), + FGi32::lit("0.03123983343026827625"), + FGi32::lit("0.01562372862047683080"), + FGi32::lit("0.00781234106010111130"), + FGi32::lit("0.00390623013196697183"), + FGi32::lit("0.00195312251647881869"), + FGi32::lit("0.00097656218955931943"), + FGi32::lit("0.00048828121119489829"), + FGi32::lit("0.00024414062014936177"), + FGi32::lit("0.00012207031189367021"), + FGi32::lit("0.00006103515617420878"), + FGi32::lit("0.00003051757811552610"), + ]; + + let mut angle = self % TWO_PI; + if angle > PI { + angle -= TWO_PI; + } else if angle < -PI { + angle += TWO_PI; + } + let mut sign = FGi32::ONE; + if angle > HALF_PI { + angle -= PI; + sign = FGi32::NEG_ONE; + } else if angle < -HALF_PI { + angle += PI; + sign = FGi32::NEG_ONE; + } + + let mut x = CORDIC_GAIN_INVERSE; + let mut y = FGi32::ZERO; + for (index, step) in ANGLES.into_iter().enumerate() { + let scale = FGi32::from_bits(1 << (16 - index)); + if angle >= FGi32::ZERO { + let next_x = x - y * scale; + y += x * scale; + x = next_x; + angle -= step; + } else { + let next_x = x + y * scale; + y -= x * scale; + x = next_x; + angle += step; + } + } + let snap_axis = |value: FGi32| { + const EPSILON: FGi32 = FGi32::from_bits(4); + if value.abs() <= EPSILON { + FGi32::ZERO + } else if (value.abs() - FGi32::ONE).abs() <= EPSILON { + if value < FGi32::ZERO { + FGi32::NEG_ONE + } else { + FGi32::ONE + } + } else { + value + } + }; + (snap_axis(y * sign), snap_axis(x * sign)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixed_degrees_convert_to_radians() { + assert_eq!( + FGi32::lit("180").to_radians(), + FGi32::lit("3.1415863037109375") + ); + } +} diff --git a/src/math/vec3.rs b/src/math/vec3.rs index df64bbb..66df438 100644 --- a/src/math/vec3.rs +++ b/src/math/vec3.rs @@ -1,7 +1,8 @@ use bevy::prelude::*; use serde::{Deserialize, Serialize}; +use std::ops::{Add, Mul, Neg, Sub}; -use crate::math::int::FGi32; +use crate::math::int::{FGWide, FGi32}; #[derive(Clone, Copy, PartialEq, Default, Debug, Reflect, Serialize, Deserialize, Hash)] #[reflect(opaque)] @@ -12,13 +13,14 @@ pub struct FGVec3 { } impl FGVec3 { + pub const ZERO: Self = Self { + x: FGi32::ZERO, + y: FGi32::ZERO, + z: FGi32::ZERO, + }; - pub fn new(x: FGi32, y: FGi32, z: FGi32) -> Self { - Self { - x, - y, - z - } + pub const fn new(x: FGi32, y: FGi32, z: FGi32) -> Self { + Self { x, y, z } } pub fn lit(x: &str, y: &str, z: &str) -> Self { @@ -32,10 +34,65 @@ impl FGVec3 { pub fn to_vec3(&self) -> Vec3 { Vec3::new(self.x.to_num(), self.y.to_num(), self.z.to_num()) } + + pub fn dot(self, rhs: Self) -> FGWide { + FGWide::from_num(self.x) * FGWide::from_num(rhs.x) + + FGWide::from_num(self.y) * FGWide::from_num(rhs.y) + + FGWide::from_num(self.z) * FGWide::from_num(rhs.z) + } + + pub fn length_squared(self) -> FGWide { + self.dot(self) + } + + pub fn normalized(self) -> Option { + let length_squared = self.length_squared(); + if length_squared == FGWide::ZERO { + return None; + } + let length = length_squared.sqrt(); + Some(Self::new( + FGi32::from_num(FGWide::from_num(self.x) / length), + FGi32::from_num(FGWide::from_num(self.y) / length), + FGi32::from_num(FGWide::from_num(self.z) / length), + )) + } +} + +impl Add for FGVec3 { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self::new(self.x + rhs.x, self.y + rhs.y, self.z + rhs.z) + } +} + +impl Sub for FGVec3 { + type Output = Self; + + fn sub(self, rhs: Self) -> Self::Output { + Self::new(self.x - rhs.x, self.y - rhs.y, self.z - rhs.z) + } +} + +impl Neg for FGVec3 { + type Output = Self; + + fn neg(self) -> Self::Output { + Self::new(-self.x, -self.y, -self.z) + } +} + +impl Mul for FGVec3 { + type Output = Self; + + fn mul(self, rhs: FGi32) -> Self::Output { + Self::new(self.x * rhs, self.y * rhs, self.z * rhs) + } } impl From<(&str, &str, &str)> for FGVec3 { fn from((x, y, z): (&str, &str, &str)) -> Self { Self::lit(x, y, z) } -} \ No newline at end of file +}