Add affine transformations for capsules.

This commit is contained in:
2026-09-03 16:40:41 +02:00
parent 06d221936b
commit 31cc9cfa15
20 changed files with 792 additions and 121 deletions
+13
View File
@@ -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<DebugSettings>) -> bool {
settings.attack_hitboxes
}
pub fn hurtboxes_enabled(settings: Res<DebugSettings>) -> bool {
settings.hurtboxes
}
pub fn camera_debug_enabled(settings: Res<DebugSettings>) -> bool {
settings.camera
}
+76 -3
View File
@@ -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<us
.collect()
}
#[derive(Clone, Debug, PartialEq)]
pub struct FighterSolvedHurtbox {
pub hurtbox_idx: usize,
pub capsule: FixedAffineCapsule,
}
#[derive(Component)]
pub struct FighterAttackScriptAssets {
pub scripts: HashMap<AttackKind, Handle<FighterAttackScript>>,
}
#[derive(Component, Clone, Debug, Default)]
pub struct FighterSolvedHurtboxes(pub Vec<FighterSolvedHurtbox>);
pub fn solve_hurtboxes(
mut query: Query<(
&FighterBoneMatrices,
&FighterTranslation,
&FighterFacingDirection,
&mut FighterSolvedHurtboxes,
&Fighter,
)>,
manifests: Res<Assets<FighterManifest>>,
) {
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),
);
}
}
+45 -1
View File
@@ -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"));
}
}
+149 -44
View File
@@ -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,49 +160,154 @@ pub fn animation_debug(query: Query<(&GlobalTransform, &FighterBoneMatrices)>, m
}
}
pub fn hurtbox_debug(
query: Query<(
&FighterBoneMatrices,
&FighterTranslation,
&FighterFacingDirection,
&Fighter,
)>,
manifests: Res<Assets<FighterManifest>>,
mut gizmos: Gizmos,
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<Color> + Copy,
) {
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,
);
let color = color.into();
let wireframe = capsule_wireframe();
let transform = capsule.transform.to_mat4();
let radius = capsule.radius.to_num::<f32>();
let half_length = capsule.half_length.to_num::<f32>();
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<CapsuleWireVertex>,
lines: Vec<(usize, usize)>,
}
fn capsule_wireframe() -> &'static CapsuleWireframe {
const RESOLUTION: usize = 16;
const HEMISPHERE_STEPS: usize = 4;
static WIREFRAME: OnceLock<CapsuleWireframe> = 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));
}
}
+267
View File
@@ -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));
}
}
+5 -2
View File
@@ -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)]
+8 -3
View File
@@ -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::<FighterState>()
.rollback_component_with_clone::<FighterHitboxes>()
.rollback_component_with_clone::<FighterSolvedHurtboxes>()
.checksum_component_with_hash::<FighterState>()
.checksum_component_with_hash::<FighterFacingDirection>()
.register_type::<FighterFacingDirection>();
@@ -230,9 +232,12 @@ pub fn spawn_fighter(
attack_scripts,
FighterState::Fall(FallState),
animation::FighterAnimationFrame::new(animation::AnimKind::Wait, true),
(
baked_animation::FighterBoneMatrices::default(),
FighterSolvedHurtboxes::default(),
FighterVisual,
FighterInput::default(),
crate::camera::FighterCameraExtents::default(),
),
));
}
+3 -9
View File
@@ -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
}
+1 -1
View File
@@ -1,4 +1,4 @@
use std::hash::{Hasher};
use std::hash::Hasher;
use super::{FighterStateContext, FighterStateImpl, ground, run, wait, walk};
use crate::fighter::FighterFacingDirection;
+1 -3
View File
@@ -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
}
+3 -1
View File
@@ -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,
};
+31 -10
View File
@@ -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<FighterState> {
fn check_interrupt(
&self,
state_context: &mut super::FighterStateContext,
) -> Option<FighterState> {
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,16 +31,23 @@ 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]);
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();
}
@@ -47,9 +63,14 @@ impl FighterStateImpl for GroundAttackState {
}
}
pub fn check_interrupt(state_context: &super::FighterStateContext, grounded_common: &GroundedStateCommon) -> Option<FighterState> {
if state_context.input.has_command(crate::input::FighterCommands::Attack) {
pub fn check_interrupt(
state_context: &super::FighterStateContext,
grounded_common: &GroundedStateCommon,
) -> Option<FighterState> {
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,7 +79,7 @@ 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
+1 -4
View File
@@ -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
}
+5 -5
View File
@@ -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<FighterState> {
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))
-1
View File
@@ -1,4 +1,3 @@
use bevy::prelude::*;
use super::{FighterStateContext, FighterStateImpl, ground, wait, walk};
+1 -1
View File
@@ -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 {
+7 -9
View File
@@ -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<FighterState> {
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
+3 -5
View File
@@ -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<FighterState> {
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);
+97
View File
@@ -1,2 +1,99 @@
pub type FGi32 = fixed::FixedI32<fixed::types::extra::U16>;
pub type FGWide = fixed::FixedI128<fixed::types::extra::U32>;
/// 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")
);
}
}
+64 -7
View File
@@ -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,6 +34,61 @@ 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<Self> {
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<FGi32> 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 {