Improve hitbox determinisim and performance.
This commit is contained in:
+70
-16
@@ -6,7 +6,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
fighter::{
|
fighter::{
|
||||||
Fighter, FighterAttributes, FighterFacingDirection, FighterHitboxes, FighterTranslation,
|
Fighter, FighterAttributes, FighterFacingDirection, FighterHitboxes,
|
||||||
|
FighterPreviousFacingDirection, FighterPreviousTranslation, FighterTranslation,
|
||||||
animation::{AnimKind, FighterAnimationFrame},
|
animation::{AnimKind, FighterAnimationFrame},
|
||||||
baked_animation::{FighterBoneMatrices, FixedMat4},
|
baked_animation::{FighterBoneMatrices, FixedMat4},
|
||||||
hurtbox::{FixedAffineCapsule, FixedCapsule},
|
hurtbox::{FixedAffineCapsule, FixedCapsule},
|
||||||
@@ -16,6 +17,7 @@ use crate::{
|
|||||||
int::{FGi32, FGi32Ext},
|
int::{FGi32, FGi32Ext},
|
||||||
vec3::FGVec3,
|
vec3::FGVec3,
|
||||||
},
|
},
|
||||||
|
player::Player,
|
||||||
schedule::GameplaySet,
|
schedule::GameplaySet,
|
||||||
scripting::FighterAttackScript,
|
scripting::FighterAttackScript,
|
||||||
};
|
};
|
||||||
@@ -212,19 +214,32 @@ pub fn solve_hitboxes(
|
|||||||
mut query: Query<(
|
mut query: Query<(
|
||||||
&FighterBoneMatrices,
|
&FighterBoneMatrices,
|
||||||
&FighterTranslation,
|
&FighterTranslation,
|
||||||
|
&FighterPreviousTranslation,
|
||||||
&FighterFacingDirection,
|
&FighterFacingDirection,
|
||||||
|
&FighterPreviousFacingDirection,
|
||||||
&mut FighterHitboxes,
|
&mut FighterHitboxes,
|
||||||
&FighterAnimationFrame,
|
&FighterAnimationFrame,
|
||||||
)>,
|
)>,
|
||||||
attack_scripts: Res<Assets<FighterAttackScript>>,
|
attack_scripts: Res<Assets<FighterAttackScript>>,
|
||||||
) {
|
) {
|
||||||
for (matrices, translation, facing_direction, mut hitboxes, animation_frame) in &mut query {
|
for (
|
||||||
|
matrices,
|
||||||
|
translation,
|
||||||
|
previous_translation,
|
||||||
|
facing_direction,
|
||||||
|
previous_facing_direction,
|
||||||
|
mut hitboxes,
|
||||||
|
animation_frame,
|
||||||
|
) in &mut query
|
||||||
|
{
|
||||||
hitboxes.active_hitboxes_solved.clear();
|
hitboxes.active_hitboxes_solved.clear();
|
||||||
if let Some(ref attack_script) = hitboxes.attack_script {
|
if let Some(ref attack_script) = hitboxes.attack_script {
|
||||||
let attack_script = attack_scripts
|
let attack_script = attack_scripts
|
||||||
.get(attack_script)
|
.get(attack_script)
|
||||||
.expect("Attack script should be valid");
|
.expect("Attack script should be valid");
|
||||||
let fighter_trf = translation.get_3d_transform(facing_direction);
|
let fighter_trf = translation.get_3d_transform(facing_direction);
|
||||||
|
let previous_fighter_trf = FighterTranslation(previous_translation.0)
|
||||||
|
.get_3d_transform(&previous_facing_direction.0);
|
||||||
let active_hitboxes = hitboxes.active_hitboxes.clone();
|
let active_hitboxes = hitboxes.active_hitboxes.clone();
|
||||||
for hitbox_idx in active_hitboxes {
|
for hitbox_idx in active_hitboxes {
|
||||||
let hitbox = attack_script
|
let hitbox = attack_script
|
||||||
@@ -245,18 +260,24 @@ pub fn solve_hitboxes(
|
|||||||
|
|
||||||
if hitbox.start_frame == animation_frame.frame {
|
if hitbox.start_frame == animation_frame.frame {
|
||||||
// Sphere only
|
// Sphere only
|
||||||
hitboxes.active_hitboxes_solved.push(FixedCapsule {
|
hitboxes.active_hitboxes_solved.push(FighterSolvedHitbox {
|
||||||
|
hitbox_idx,
|
||||||
|
capsule: FixedCapsule {
|
||||||
start: curr_hbox_pos,
|
start: curr_hbox_pos,
|
||||||
end: curr_hbox_pos,
|
end: curr_hbox_pos,
|
||||||
radius: hitbox.radius,
|
radius: hitbox.radius,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
let prev_hbox_matrix = fighter_trf.mul(prev_bone_matrix);
|
let prev_hbox_matrix = previous_fighter_trf.mul(prev_bone_matrix);
|
||||||
let prev_hbox_pos = prev_hbox_matrix.transform_point(hitbox.offset);
|
let prev_hbox_pos = prev_hbox_matrix.transform_point(hitbox.offset);
|
||||||
hitboxes.active_hitboxes_solved.push(FixedCapsule {
|
hitboxes.active_hitboxes_solved.push(FighterSolvedHitbox {
|
||||||
|
hitbox_idx,
|
||||||
|
capsule: FixedCapsule {
|
||||||
start: prev_hbox_pos,
|
start: prev_hbox_pos,
|
||||||
end: curr_hbox_pos,
|
end: curr_hbox_pos,
|
||||||
radius: hitbox.radius,
|
radius: hitbox.radius,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,10 +333,18 @@ pub fn solve_hurtboxes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn intersect_attacks_with_hurtboxes(
|
pub fn intersect_attacks_with_hurtboxes(
|
||||||
query: Query<(Entity, &FighterSolvedHurtboxes, &FighterHitboxes)>,
|
mut query_attacker: Query<(Entity, &mut FighterHitboxes, &Fighter, &Player)>,
|
||||||
|
query_attacked: Query<(Entity, &FighterSolvedHurtboxes, &Player)>,
|
||||||
attack_scripts: Res<Assets<FighterAttackScript>>,
|
attack_scripts: Res<Assets<FighterAttackScript>>,
|
||||||
) {
|
) {
|
||||||
/*for (entity_hurting, _, hitboxes) in query {
|
let mut attackers = query_attacker.iter_mut().collect::<Vec<_>>();
|
||||||
|
attackers
|
||||||
|
.sort_by(|(_, _, _, player_a), (_, _, _, player_b)| player_a.handle.cmp(&player_b.handle));
|
||||||
|
|
||||||
|
let mut receivers = query_attacked.iter().collect::<Vec<_>>();
|
||||||
|
receivers.sort_by(|(_, _, player_a), (_, _, player_b)| player_a.handle.cmp(&player_b.handle));
|
||||||
|
|
||||||
|
for (entity_hurting, mut hitboxes, _, _) in attackers {
|
||||||
if hitboxes.attack_script.is_none() {
|
if hitboxes.attack_script.is_none() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -329,18 +358,39 @@ pub fn intersect_attacks_with_hurtboxes(
|
|||||||
|
|
||||||
let attack_script = attack_script.unwrap();
|
let attack_script = attack_script.unwrap();
|
||||||
|
|
||||||
let capsules: Vec<(&AttackHitbox, FixedCapsule)> = hitboxes.active_hitboxes.iter().map(|idx| {
|
for (entity_receiving, hurtboxes, _) in receivers.iter() {
|
||||||
(attack_script.hitboxes.get(*idx).unwrap(), )
|
if entity_hurting == *entity_receiving {
|
||||||
}).collect();
|
|
||||||
|
|
||||||
for (entity_to_hurt, hurtboxes, _) in query {
|
|
||||||
// Self collisions not allowed
|
|
||||||
if entity_to_hurt == entity_hurting {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if hitboxes.hit_fighters.contains(entity_receiving) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut hitting_hitboxes = vec![];
|
||||||
|
for hurtbox in hurtboxes.0.iter() {
|
||||||
|
for solved_hitbox in hitboxes.active_hitboxes_solved.iter() {
|
||||||
|
if hurtbox.capsule.intersects(solved_hitbox.capsule) {
|
||||||
|
hitting_hitboxes.push(solved_hitbox.hitbox_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hitting_hitboxes.sort_by(|a, b| {
|
||||||
|
let hitbox_id_a = attack_script.hitboxes[*a].id;
|
||||||
|
let hitbox_id_b = attack_script.hitboxes[*b].id;
|
||||||
|
|
||||||
|
hitbox_id_a.cmp(&hitbox_id_b)
|
||||||
|
});
|
||||||
|
|
||||||
|
if hitting_hitboxes.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hitting_hitbox_idx = hitting_hitboxes[0];
|
||||||
|
hitboxes.hit_fighters.push(*entity_receiving);
|
||||||
|
info!("Hit by hitbox {}!!!!", hitting_hitbox_idx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct FighterAttackPlugin;
|
pub struct FighterAttackPlugin;
|
||||||
@@ -349,7 +399,11 @@ impl Plugin for FighterAttackPlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
GgrsSchedule,
|
GgrsSchedule,
|
||||||
(solve_hurtboxes, solve_hitboxes)
|
(
|
||||||
|
solve_hurtboxes,
|
||||||
|
solve_hitboxes,
|
||||||
|
intersect_attacks_with_hurtboxes,
|
||||||
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.after(crate::fighter::baked_animation::update_fighter_bone_matrices)
|
.after(crate::fighter::baked_animation::update_fighter_bone_matrices)
|
||||||
.in_set(GameplaySet::Animation),
|
.in_set(GameplaySet::Animation),
|
||||||
|
|||||||
+156
-24
@@ -3,7 +3,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
io,
|
io,
|
||||||
path::Path,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use bevy::{
|
use bevy::{
|
||||||
@@ -18,11 +17,12 @@ use bevy::{
|
|||||||
reflect::TypePath,
|
reflect::TypePath,
|
||||||
world_serialization::{WorldInstanceReady, WorldInstanceSpawner},
|
world_serialization::{WorldInstanceReady, WorldInstanceSpawner},
|
||||||
};
|
};
|
||||||
use bevy_ggrs::prelude::GgrsSchedule;
|
use bevy_ggrs::{GgrsFrameTiming, prelude::GgrsSchedule};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
fighter::{
|
fighter::{
|
||||||
|
Fighter,
|
||||||
animation::{AnimKind, FighterAnimationFrame},
|
animation::{AnimKind, FighterAnimationFrame},
|
||||||
manifest::FighterManifest,
|
manifest::FighterManifest,
|
||||||
visual::FighterAnimations,
|
visual::FighterAnimations,
|
||||||
@@ -194,7 +194,10 @@ impl BakedFighterAnimations {
|
|||||||
pub fn sample_pose(&self, frame: &FighterAnimationFrame) -> Option<&[FixedMat4]> {
|
pub fn sample_pose(&self, frame: &FighterAnimationFrame) -> Option<&[FixedMat4]> {
|
||||||
let frames = &self.clip(frame.kind)?.frames;
|
let frames = &self.clip(frame.kind)?.frames;
|
||||||
let frame_index = if frame.repeat {
|
let frame_index = if frame.repeat {
|
||||||
frame.frame % frames.len() as u32
|
// The terminal sample is retained for one-shot animations, but is
|
||||||
|
// the same point in time as the next loop's first sample.
|
||||||
|
let loop_frame_count = frames.len().saturating_sub(1).max(1) as u32;
|
||||||
|
frame.frame % loop_frame_count
|
||||||
} else {
|
} else {
|
||||||
frame.frame
|
frame.frame
|
||||||
};
|
};
|
||||||
@@ -224,6 +227,7 @@ pub struct FighterBoneMatrices {
|
|||||||
matrices: Vec<FixedMat4>,
|
matrices: Vec<FixedMat4>,
|
||||||
previous_frame_matrices: Vec<FixedMat4>,
|
previous_frame_matrices: Vec<FixedMat4>,
|
||||||
local_matrices: Vec<FixedMat4>,
|
local_matrices: Vec<FixedMat4>,
|
||||||
|
previous_local_matrices: Vec<FixedMat4>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
@@ -263,7 +267,23 @@ impl FighterBoneMatrices {
|
|||||||
self.local_matrices.get(index).copied()
|
self.local_matrices.get(index).copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_pose(&mut self, bone_names: &[String], parents: &[Option<usize>], local: &[FixedMat4]) {
|
fn presentation_local(&self, index: usize, overstep_fraction: f32) -> Option<Transform> {
|
||||||
|
let current = self.local_matrices.get(index).copied()?;
|
||||||
|
let previous = self
|
||||||
|
.previous_local_matrices
|
||||||
|
.get(index)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(current);
|
||||||
|
Some(interpolate_transform(previous, current, overstep_fraction))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_pose(
|
||||||
|
&mut self,
|
||||||
|
bone_names: &[String],
|
||||||
|
parents: &[Option<usize>],
|
||||||
|
local: &[FixedMat4],
|
||||||
|
model_scale: FGi32,
|
||||||
|
) {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bone_names.len(),
|
bone_names.len(),
|
||||||
local.len(),
|
local.len(),
|
||||||
@@ -282,6 +302,7 @@ impl FighterBoneMatrices {
|
|||||||
assert_eq!(self.parents, parents, "baked bone parents changed");
|
assert_eq!(self.parents, parents, "baked bone parents changed");
|
||||||
}
|
}
|
||||||
std::mem::swap(&mut self.matrices, &mut self.previous_frame_matrices);
|
std::mem::swap(&mut self.matrices, &mut self.previous_frame_matrices);
|
||||||
|
std::mem::swap(&mut self.local_matrices, &mut self.previous_local_matrices);
|
||||||
self.local_matrices.clear();
|
self.local_matrices.clear();
|
||||||
self.local_matrices.extend_from_slice(local);
|
self.local_matrices.extend_from_slice(local);
|
||||||
let mut model = vec![None; local.len()];
|
let mut model = vec![None; local.len()];
|
||||||
@@ -289,9 +310,17 @@ impl FighterBoneMatrices {
|
|||||||
resolve_model_transform(index, parents, local, &mut model)
|
resolve_model_transform(index, parents, local, &mut model)
|
||||||
.expect("baked bone hierarchy should be valid");
|
.expect("baked bone hierarchy should be valid");
|
||||||
}
|
}
|
||||||
|
let model_scale = FixedMat4 {
|
||||||
|
cols: [
|
||||||
|
[model_scale, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO],
|
||||||
|
[FGi32::ZERO, model_scale, FGi32::ZERO, FGi32::ZERO],
|
||||||
|
[FGi32::ZERO, FGi32::ZERO, model_scale, FGi32::ZERO],
|
||||||
|
[FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE],
|
||||||
|
],
|
||||||
|
};
|
||||||
self.matrices = model
|
self.matrices = model
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|matrix| matrix.expect("baked bone hierarchy should be valid"))
|
.map(|matrix| model_scale.mul(matrix.expect("baked bone hierarchy should be valid")))
|
||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,18 +329,32 @@ pub fn update_fighter_bone_matrices(
|
|||||||
mut fighters: Query<(
|
mut fighters: Query<(
|
||||||
&FighterAnimationFrame,
|
&FighterAnimationFrame,
|
||||||
&FighterAnimations,
|
&FighterAnimations,
|
||||||
|
&Fighter,
|
||||||
&mut FighterBoneMatrices,
|
&mut FighterBoneMatrices,
|
||||||
)>,
|
)>,
|
||||||
baked_assets: Res<Assets<BakedFighterAnimations>>,
|
baked_assets: Res<Assets<BakedFighterAnimations>>,
|
||||||
|
manifests: Res<Assets<FighterManifest>>,
|
||||||
) {
|
) {
|
||||||
for (frame, animations, mut matrices) in &mut fighters {
|
for (frame, animations, fighter, mut matrices) in &mut fighters {
|
||||||
let baked = baked_assets
|
let baked = baked_assets
|
||||||
.get(&animations.baked)
|
.get(&animations.baked)
|
||||||
.expect("fighter baked animation asset should be loaded before the match starts");
|
.expect("fighter baked animation asset should be loaded before the match starts");
|
||||||
|
assert_eq!(
|
||||||
|
baked.frame_rate, BAKED_ANIMATION_FPS,
|
||||||
|
"fighter baked animation rate does not match the simulation rate"
|
||||||
|
);
|
||||||
let pose = baked
|
let pose = baked
|
||||||
.sample_pose(frame)
|
.sample_pose(frame)
|
||||||
.expect("fighter animation frame should select a baked pose");
|
.expect("fighter animation frame should select a baked pose");
|
||||||
matrices.set_pose(&baked.bone_names, &baked.parents, pose);
|
let manifest = manifests
|
||||||
|
.get(&fighter.manifest)
|
||||||
|
.expect("fighter manifest should be loaded before the match starts");
|
||||||
|
matrices.set_pose(
|
||||||
|
&baked.bone_names,
|
||||||
|
&baked.parents,
|
||||||
|
pose,
|
||||||
|
manifest.model_scale,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,15 +446,30 @@ fn bind_fighter_visual_bones(
|
|||||||
fn apply_baked_fighter_visual_pose(
|
fn apply_baked_fighter_visual_pose(
|
||||||
mut nodes: Query<(&mut Transform, &BakedFighterBone)>,
|
mut nodes: Query<(&mut Transform, &BakedFighterBone)>,
|
||||||
fighters: Query<&FighterBoneMatrices>,
|
fighters: Query<&FighterBoneMatrices>,
|
||||||
|
timing: Res<GgrsFrameTiming>,
|
||||||
) {
|
) {
|
||||||
|
let overstep_fraction = timing.overstep_fraction();
|
||||||
for (mut transform, bone) in &mut nodes {
|
for (mut transform, bone) in &mut nodes {
|
||||||
let Ok(matrices) = fighters.get(bone.fighter) else {
|
let Ok(matrices) = fighters.get(bone.fighter) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(local) = matrices.local(bone.index) else {
|
let Some(local) = matrices.presentation_local(bone.index, overstep_fraction) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
*transform = Transform::from_matrix(local.to_mat4());
|
*transform = local;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interpolate_transform(previous: FixedMat4, current: FixedMat4, factor: f32) -> Transform {
|
||||||
|
let (previous_scale, previous_rotation, previous_translation) =
|
||||||
|
previous.to_mat4().to_scale_rotation_translation();
|
||||||
|
let (current_scale, current_rotation, current_translation) =
|
||||||
|
current.to_mat4().to_scale_rotation_translation();
|
||||||
|
let factor = factor.clamp(0.0, 1.0);
|
||||||
|
Transform {
|
||||||
|
translation: previous_translation.lerp(current_translation, factor),
|
||||||
|
rotation: previous_rotation.slerp(current_rotation, factor),
|
||||||
|
scale: previous_scale.lerp(current_scale, factor),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,10 +510,10 @@ impl AssetLoader for FighterBakeSourceLoader {
|
|||||||
let manifest: FighterManifest = ron::de::from_bytes(&manifest_bytes)
|
let manifest: FighterManifest = ron::de::from_bytes(&manifest_bytes)
|
||||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||||
|
|
||||||
// The processor's AssetServer reads processed assets. Reading this
|
let model_bytes = load_context
|
||||||
// source dependency through it would wait for a processed GLB while
|
.read_asset_bytes(&manifest.model_path)
|
||||||
// this processor is still producing the first processed asset.
|
.await
|
||||||
let model_bytes = std::fs::read(Path::new("assets").join(&manifest.model_path))?;
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||||
Ok(FighterBakeSource {
|
Ok(FighterBakeSource {
|
||||||
model_bytes,
|
model_bytes,
|
||||||
animations: manifest.animations,
|
animations: manifest.animations,
|
||||||
@@ -616,10 +674,10 @@ fn bake_gltf(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(Channel::duration)
|
.filter_map(Channel::duration)
|
||||||
.fold(0.0_f32, f32::max);
|
.fold(0.0_f32, f32::max);
|
||||||
let frame_count = (duration * BAKED_ANIMATION_FPS as f32).ceil() as usize + 1;
|
let frame_count = baked_frame_count(duration, BAKED_ANIMATION_FPS);
|
||||||
let mut frames = Vec::with_capacity(frame_count);
|
let mut frames = Vec::with_capacity(frame_count);
|
||||||
for frame in 0..frame_count {
|
for frame in 0..frame_count {
|
||||||
let time = frame as f32 / BAKED_ANIMATION_FPS as f32;
|
let time = (frame as f32 / BAKED_ANIMATION_FPS as f32).min(duration);
|
||||||
let local = nodes
|
let local = nodes
|
||||||
.iter()
|
.iter()
|
||||||
.map(|node| local_transform(node.clone(), &channels, time))
|
.map(|node| local_transform(node.clone(), &channels, time))
|
||||||
@@ -658,6 +716,17 @@ fn bake_gltf(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn baked_frame_count(duration: f32, frame_rate: u32) -> usize {
|
||||||
|
let frame = duration as f64 * frame_rate as f64;
|
||||||
|
let nearest_frame = frame.round();
|
||||||
|
let last_frame = if (frame - nearest_frame).abs() < 0.0001 {
|
||||||
|
nearest_frame
|
||||||
|
} else {
|
||||||
|
frame.ceil()
|
||||||
|
};
|
||||||
|
last_frame as usize + 1
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_model_transform(
|
fn resolve_model_transform(
|
||||||
index: usize,
|
index: usize,
|
||||||
parents: &[Option<usize>],
|
parents: &[Option<usize>],
|
||||||
@@ -829,18 +898,19 @@ mod tests {
|
|||||||
let manifest: FighterManifest =
|
let manifest: FighterManifest =
|
||||||
ron::from_str(include_str!("../../assets/fighters/test/test.fighter.ron")).unwrap();
|
ron::from_str(include_str!("../../assets/fighters/test/test.fighter.ron")).unwrap();
|
||||||
let baked = bake_gltf(
|
let baked = bake_gltf(
|
||||||
include_bytes!("../../assets/fighters/test/test-fighter-normal.glb"),
|
include_bytes!("../../assets/fighters/test/test-fighter-2.glb"),
|
||||||
&manifest.animations,
|
&manifest.animations,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(baked.frame_rate, BAKED_ANIMATION_FPS);
|
assert_eq!(baked.frame_rate, BAKED_ANIMATION_FPS);
|
||||||
assert!(baked.frame_count(AnimKind::Wait).unwrap() > 1);
|
assert_eq!(baked.frame_count(AnimKind::Wait), Some(121));
|
||||||
|
assert_eq!(baked.frame_count(AnimKind::Walk), Some(33));
|
||||||
|
assert_eq!(baked.frame_count(AnimKind::Run), Some(33));
|
||||||
|
assert_eq!(baked.frame_count(AnimKind::Dash), Some(21));
|
||||||
|
assert_eq!(baked.frame_count(AnimKind::Landing), Some(9));
|
||||||
|
assert_eq!(baked.frame_count(AnimKind::JumpBack), Some(57));
|
||||||
assert_eq!(baked.clip_indices.len(), manifest.animations.len());
|
assert_eq!(baked.clip_indices.len(), manifest.animations.len());
|
||||||
assert_eq!(
|
|
||||||
baked.clip_indices[&AnimKind::JumpSquat],
|
|
||||||
baked.clip_indices[&AnimKind::Landing]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
baked.clip(AnimKind::Wait).unwrap().frames[0].len(),
|
baked.clip(AnimKind::Wait).unwrap().frames[0].len(),
|
||||||
baked.bone_names.len()
|
baked.bone_names.len()
|
||||||
@@ -853,12 +923,14 @@ mod tests {
|
|||||||
let first = FixedMat4::IDENTITY;
|
let first = FixedMat4::IDENTITY;
|
||||||
let mut second = FixedMat4::IDENTITY;
|
let mut second = FixedMat4::IDENTITY;
|
||||||
second.translate(FGVec3::lit("2", "0", "0"));
|
second.translate(FGVec3::lit("2", "0", "0"));
|
||||||
|
let mut terminal = FixedMat4::IDENTITY;
|
||||||
|
terminal.translate(FGVec3::lit("4", "0", "0"));
|
||||||
let baked = BakedFighterAnimations {
|
let baked = BakedFighterAnimations {
|
||||||
frame_rate: BAKED_ANIMATION_FPS,
|
frame_rate: BAKED_ANIMATION_FPS,
|
||||||
bone_names: vec!["root".into()],
|
bone_names: vec!["root".into()],
|
||||||
parents: vec![None],
|
parents: vec![None],
|
||||||
clips: vec![BakedAnimationClip {
|
clips: vec![BakedAnimationClip {
|
||||||
frames: vec![vec![first], vec![second]],
|
frames: vec![vec![first], vec![second], vec![terminal]],
|
||||||
}],
|
}],
|
||||||
clip_indices: HashMap::from([(AnimKind::Wait, 0)]),
|
clip_indices: HashMap::from([(AnimKind::Wait, 0)]),
|
||||||
};
|
};
|
||||||
@@ -870,12 +942,48 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let finished = FighterAnimationFrame {
|
let finished = FighterAnimationFrame {
|
||||||
kind: AnimKind::Wait,
|
kind: AnimKind::Wait,
|
||||||
frame: 3,
|
frame: 4,
|
||||||
repeat: false,
|
repeat: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(baked.sample_pose(&repeating), Some(&[second][..]));
|
assert_eq!(baked.sample_pose(&repeating), Some(&[second][..]));
|
||||||
assert_eq!(baked.sample_pose(&finished), Some(&[second][..]));
|
assert_eq!(baked.sample_pose(&finished), Some(&[terminal][..]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_count_snaps_source_frame_rounding_error_to_the_bake_grid() {
|
||||||
|
assert_eq!(baked_frame_count(0.533333361, 60), 33);
|
||||||
|
assert_eq!(baked_frame_count(0.333333343, 60), 21);
|
||||||
|
assert_eq!(baked_frame_count(0.133333340, 60), 9);
|
||||||
|
assert_eq!(baked_frame_count(2.0, 60), 121);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linear_source_channel_is_resampled_between_thirty_hz_keys() {
|
||||||
|
let channel = Channel {
|
||||||
|
node: 0,
|
||||||
|
property: gltf::animation::Property::Translation,
|
||||||
|
interpolation: gltf::animation::Interpolation::Linear,
|
||||||
|
times: vec![0.0, 1.0 / 30.0],
|
||||||
|
values: ChannelValues::Vec3(vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]),
|
||||||
|
};
|
||||||
|
let ChannelValues::Vec3(values) = &channel.values else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(sample_vec3(&channel, values, 1.0 / 60.0), [1.0, 0.0, 0.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presentation_pose_interpolates_previous_and_current_local_transforms() {
|
||||||
|
let mut previous = FixedMat4::IDENTITY;
|
||||||
|
previous.translate(FGVec3::lit("2", "0", "0"));
|
||||||
|
let mut current = FixedMat4::IDENTITY;
|
||||||
|
current.translate(FGVec3::lit("6", "0", "0"));
|
||||||
|
|
||||||
|
let transform = interpolate_transform(previous, current, 0.25);
|
||||||
|
|
||||||
|
assert_eq!(transform.translation, Vec3::new(3.0, 0.0, 0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -888,6 +996,7 @@ mod tests {
|
|||||||
&["root".into(), "hand".into()],
|
&["root".into(), "hand".into()],
|
||||||
&[None, Some(0)],
|
&[None, Some(0)],
|
||||||
&[first, second],
|
&[first, second],
|
||||||
|
FGi32::ONE,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(matrices.get_current("hand"), Some(second));
|
assert_eq!(matrices.get_current("hand"), Some(second));
|
||||||
@@ -909,6 +1018,7 @@ mod tests {
|
|||||||
&["root".into(), "hand".into()],
|
&["root".into(), "hand".into()],
|
||||||
&[None, Some(0)],
|
&[None, Some(0)],
|
||||||
&[root, hand],
|
&[root, hand],
|
||||||
|
FGi32::ONE,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(matrices.local(1), Some(hand));
|
assert_eq!(matrices.local(1), Some(hand));
|
||||||
@@ -926,6 +1036,28 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_scale_affects_gameplay_bones_without_changing_visual_locals() {
|
||||||
|
let mut root = FixedMat4::IDENTITY;
|
||||||
|
root.translate(FGVec3::lit("1", "0", "0"));
|
||||||
|
let mut hand = FixedMat4::IDENTITY;
|
||||||
|
hand.translate(FGVec3::lit("0", "2", "0"));
|
||||||
|
let mut matrices = FighterBoneMatrices::default();
|
||||||
|
|
||||||
|
matrices.set_pose(
|
||||||
|
&["root".into(), "hand".into()],
|
||||||
|
&[None, Some(0)],
|
||||||
|
&[root, hand],
|
||||||
|
FGi32::lit("10"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(matrices.local(1), Some(hand));
|
||||||
|
assert_eq!(
|
||||||
|
matrices.get_current("hand").unwrap().get_translation(),
|
||||||
|
FGVec3::lit("10", "20", "0")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fixed_matrix_transforms_points() {
|
fn fixed_matrix_transforms_points() {
|
||||||
let transform = FixedMat4 {
|
let transform = FixedMat4 {
|
||||||
|
|||||||
@@ -341,14 +341,15 @@ pub fn attack_debug(
|
|||||||
for (hitboxes, matrices, translation, facing_direction, frame) in query {
|
for (hitboxes, matrices, translation, facing_direction, frame) in query {
|
||||||
if let Some(script) = &hitboxes.attack_script {
|
if let Some(script) = &hitboxes.attack_script {
|
||||||
for solved_hitbox in hitboxes.active_hitboxes_solved.iter() {
|
for solved_hitbox in hitboxes.active_hitboxes_solved.iter() {
|
||||||
let start = solved_hitbox.start.to_vec3();
|
let start = solved_hitbox.capsule.start.to_vec3();
|
||||||
let end = solved_hitbox.end.to_vec3();
|
let end = solved_hitbox.capsule.end.to_vec3();
|
||||||
let center = (solved_hitbox.start + solved_hitbox.end) * FGi32::lit("0.5");
|
let center =
|
||||||
|
(solved_hitbox.capsule.start + solved_hitbox.capsule.end) * FGi32::lit("0.5");
|
||||||
let length = (start - end).length();
|
let length = (start - end).length();
|
||||||
|
|
||||||
let attack_capsule = Capsule3d {
|
let attack_capsule = Capsule3d {
|
||||||
half_length: length * 0.5,
|
half_length: length * 0.5,
|
||||||
radius: solved_hitbox.radius.to_num(),
|
radius: solved_hitbox.capsule.radius.to_num(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let aim_dir = (end - start).normalize_or(Vec3::Y);
|
let aim_dir = (end - start).normalize_or(Vec3::Y);
|
||||||
|
|||||||
+20
-5
@@ -26,13 +26,18 @@ pub mod visual;
|
|||||||
pub use attributes::FighterAttributes;
|
pub use attributes::FighterAttributes;
|
||||||
pub use ecb::FighterECB;
|
pub use ecb::FighterECB;
|
||||||
pub use manifest::FighterId;
|
pub use manifest::FighterId;
|
||||||
pub use motion::{FighterPreviousTranslation, FighterTranslation, FighterVelocity, Grounded};
|
pub use motion::{
|
||||||
|
FighterPreviousFacingDirection, FighterPreviousTranslation, FighterTranslation,
|
||||||
|
FighterVelocity, Grounded,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
fighter::{
|
fighter::{
|
||||||
attack::{FighterAttackPlugin, FighterAttackScriptAssets, FighterSolvedHurtboxes},
|
attack::{
|
||||||
|
FighterAttackPlugin, FighterAttackScriptAssets, FighterSolvedHitboxes,
|
||||||
|
FighterSolvedHurtboxes,
|
||||||
|
},
|
||||||
baked_animation::FighterBoneMatrices,
|
baked_animation::FighterBoneMatrices,
|
||||||
hurtbox::FixedCapsule,
|
|
||||||
manifest::FighterManifest,
|
manifest::FighterManifest,
|
||||||
state::{FighterState, fall::FallState},
|
state::{FighterState, fall::FallState},
|
||||||
visual::FighterAnimations,
|
visual::FighterAnimations,
|
||||||
@@ -113,7 +118,8 @@ impl Plugin for FighterPlugin {
|
|||||||
.add_systems(
|
.add_systems(
|
||||||
GgrsSchedule,
|
GgrsSchedule,
|
||||||
(
|
(
|
||||||
ecb::snapshot_fighter_ecb.before(state_interrupt_system),
|
(ecb::snapshot_fighter_ecb, motion::snapshot_fighter_motion)
|
||||||
|
.before(state_interrupt_system),
|
||||||
animation::advance_fighter_animation_frames.before(state_interrupt_system),
|
animation::advance_fighter_animation_frames.before(state_interrupt_system),
|
||||||
state::state_interrupt_system,
|
state::state_interrupt_system,
|
||||||
)
|
)
|
||||||
@@ -155,6 +161,8 @@ impl Plugin for FighterPlugin {
|
|||||||
.checksum_component_with_hash::<FighterTranslation>()
|
.checksum_component_with_hash::<FighterTranslation>()
|
||||||
.rollback_component_with_copy::<FighterPreviousTranslation>()
|
.rollback_component_with_copy::<FighterPreviousTranslation>()
|
||||||
.checksum_component_with_hash::<FighterPreviousTranslation>()
|
.checksum_component_with_hash::<FighterPreviousTranslation>()
|
||||||
|
.rollback_component_with_copy::<FighterPreviousFacingDirection>()
|
||||||
|
.checksum_component_with_hash::<FighterPreviousFacingDirection>()
|
||||||
.rollback_component_with_copy::<animation::FighterAnimationFrame>()
|
.rollback_component_with_copy::<animation::FighterAnimationFrame>()
|
||||||
.checksum_component_with_hash::<animation::FighterAnimationFrame>()
|
.checksum_component_with_hash::<animation::FighterAnimationFrame>()
|
||||||
.rollback_component_with_copy::<Grounded>()
|
.rollback_component_with_copy::<Grounded>()
|
||||||
@@ -198,13 +206,15 @@ impl Plugin for FighterPlugin {
|
|||||||
pub struct FighterHitboxes {
|
pub struct FighterHitboxes {
|
||||||
pub attack_script: Option<Handle<FighterAttackScript>>,
|
pub attack_script: Option<Handle<FighterAttackScript>>,
|
||||||
pub active_hitboxes: Vec<usize>,
|
pub active_hitboxes: Vec<usize>,
|
||||||
pub active_hitboxes_solved: Vec<FixedCapsule>,
|
pub active_hitboxes_solved: Vec<attack::FighterSolvedHitbox>,
|
||||||
|
pub hit_fighters: Vec<Entity>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FighterHitboxes {
|
impl FighterHitboxes {
|
||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
self.attack_script = None;
|
self.attack_script = None;
|
||||||
self.active_hitboxes.clear();
|
self.active_hitboxes.clear();
|
||||||
|
self.hit_fighters.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +223,7 @@ pub fn spawn_fighter(
|
|||||||
player_handle: usize,
|
player_handle: usize,
|
||||||
spawn_position: FGVec2,
|
spawn_position: FGVec2,
|
||||||
manifest: Handle<FighterManifest>,
|
manifest: Handle<FighterManifest>,
|
||||||
|
model_scale: FGi32,
|
||||||
animations: FighterAnimations,
|
animations: FighterAnimations,
|
||||||
attack_scripts: FighterAttackScriptAssets,
|
attack_scripts: FighterAttackScriptAssets,
|
||||||
visual_root: WorldAssetRoot,
|
visual_root: WorldAssetRoot,
|
||||||
@@ -224,6 +235,8 @@ pub fn spawn_fighter(
|
|||||||
},
|
},
|
||||||
Fighter { manifest: manifest },
|
Fighter { manifest: manifest },
|
||||||
FighterFacingDirection::Right,
|
FighterFacingDirection::Right,
|
||||||
|
FighterPreviousFacingDirection(FighterFacingDirection::Right),
|
||||||
|
Transform::from_scale(Vec3::splat(model_scale.to_num())),
|
||||||
(
|
(
|
||||||
FighterECB {
|
FighterECB {
|
||||||
vertical_half: FGi32::lit("5.0"),
|
vertical_half: FGi32::lit("5.0"),
|
||||||
@@ -233,6 +246,7 @@ pub fn spawn_fighter(
|
|||||||
attack_script: None,
|
attack_script: None,
|
||||||
active_hitboxes: vec![],
|
active_hitboxes: vec![],
|
||||||
active_hitboxes_solved: vec![],
|
active_hitboxes_solved: vec![],
|
||||||
|
hit_fighters: vec![],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -248,6 +262,7 @@ pub fn spawn_fighter(
|
|||||||
(
|
(
|
||||||
baked_animation::FighterBoneMatrices::default(),
|
baked_animation::FighterBoneMatrices::default(),
|
||||||
FighterSolvedHurtboxes::default(),
|
FighterSolvedHurtboxes::default(),
|
||||||
|
FighterSolvedHitboxes::default(),
|
||||||
FighterVisual,
|
FighterVisual,
|
||||||
FighterInput::default(),
|
FighterInput::default(),
|
||||||
crate::camera::FighterCameraExtents::default(),
|
crate::camera::FighterCameraExtents::default(),
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ use crate::{
|
|||||||
math::{int::FGi32, vec::FGVec2, vec3::FGVec3},
|
math::{int::FGi32, vec::FGVec2, vec3::FGVec3},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Component, Clone, Copy, Hash)]
|
||||||
|
pub struct FighterPreviousFacingDirection(pub FighterFacingDirection);
|
||||||
|
|
||||||
#[derive(Component, Clone, Copy, Hash)]
|
#[derive(Component, Clone, Copy, Hash)]
|
||||||
pub struct Grounded;
|
pub struct Grounded;
|
||||||
|
|
||||||
@@ -100,6 +103,20 @@ pub fn copy_fighter_transform_to_visuals(fighters: Query<(&FighterTranslation, &
|
|||||||
|
|
||||||
pub fn fighter_movement() {}
|
pub fn fighter_movement() {}
|
||||||
|
|
||||||
|
pub fn snapshot_fighter_motion(
|
||||||
|
query: Query<(
|
||||||
|
&FighterTranslation,
|
||||||
|
&FighterFacingDirection,
|
||||||
|
&mut FighterPreviousTranslation,
|
||||||
|
&mut FighterPreviousFacingDirection,
|
||||||
|
)>,
|
||||||
|
) {
|
||||||
|
for (translation, facing, mut previous_translation, mut previous_facing) in query {
|
||||||
|
previous_translation.0 = translation.0;
|
||||||
|
previous_facing.0 = *facing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -127,4 +144,34 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(position, Vec2::new(7.0, 14.0));
|
assert_eq!(position, Vec2::new(7.0, 14.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_fighter_motion_copies_current_world_state() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.spawn((
|
||||||
|
FighterTranslation(FGVec2::new(FGi32::lit("3"), FGi32::lit("4"))),
|
||||||
|
FighterFacingDirection::Left,
|
||||||
|
FighterPreviousTranslation::default(),
|
||||||
|
FighterPreviousFacingDirection(FighterFacingDirection::Right),
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut schedule = Schedule::default();
|
||||||
|
schedule.add_systems(snapshot_fighter_motion);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let (_, _, previous_translation, previous_facing) = world
|
||||||
|
.query::<(
|
||||||
|
&FighterTranslation,
|
||||||
|
&FighterFacingDirection,
|
||||||
|
&FighterPreviousTranslation,
|
||||||
|
&FighterPreviousFacingDirection,
|
||||||
|
)>()
|
||||||
|
.single(&world)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
previous_translation.0,
|
||||||
|
FGVec2::new(FGi32::lit("3"), FGi32::lit("4"))
|
||||||
|
);
|
||||||
|
assert_eq!(previous_facing.0, FighterFacingDirection::Left);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -43,7 +43,10 @@ impl LocalInputAssignments {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_from_slot(&self, slot: usize) -> Option<&LocalInputSource> {
|
pub fn get_from_slot(&self, slot: usize) -> Option<&LocalInputSource> {
|
||||||
self.0.iter().find(|(c_slot, _)| slot == *c_slot).map(|(_, source)| source)
|
self.0
|
||||||
|
.iter()
|
||||||
|
.find(|(c_slot, _)| slot == *c_slot)
|
||||||
|
.map(|(_, source)| source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,11 +87,12 @@ impl MoveCompiler {
|
|||||||
|
|
||||||
fn into_attack_script(mut self) -> Result<FighterAttackScript, String> {
|
fn into_attack_script(mut self) -> Result<FighterAttackScript, String> {
|
||||||
self.remove_all_hitboxes().map_err(|err| err.to_string())?;
|
self.remove_all_hitboxes().map_err(|err| err.to_string())?;
|
||||||
let hitboxes = self
|
let mut hitboxes: Vec<_> = self
|
||||||
.hitboxes
|
.hitboxes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(_, hitbox)| hitbox)
|
.map(|(_, hitbox)| hitbox)
|
||||||
.collect();
|
.collect();
|
||||||
|
hitboxes.sort_unstable_by_key(|hitbox| hitbox.id);
|
||||||
let iasa_frame = self.iasa_frame.unwrap_or(100000);
|
let iasa_frame = self.iasa_frame.unwrap_or(100000);
|
||||||
let wont_autocancel_window = self.wont_autocancel_window.unwrap_or((0, 100000));
|
let wont_autocancel_window = self.wont_autocancel_window.unwrap_or((0, 100000));
|
||||||
Ok(FighterAttackScript {
|
Ok(FighterAttackScript {
|
||||||
@@ -242,3 +243,41 @@ pub fn compile_script(text: &str) -> Result<FighterAttackScript, String> {
|
|||||||
})
|
})
|
||||||
.map_err(|err| err.to_string())?
|
.map_err(|err| err.to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compiled_hitboxes_are_sorted_by_authored_id() {
|
||||||
|
let script = compile_script(
|
||||||
|
r#"
|
||||||
|
frame(1);
|
||||||
|
hitbox(20, "hand_l", damage("1"), vec3("0", "0", "0"), fg32("1"), angle(fg32("0")), knockback_normal(), knockback("1"), fg32("1"));
|
||||||
|
frame(2);
|
||||||
|
hitbox(3, "hand_l", damage("2"), vec3("0", "0", "0"), fg32("1"), angle(fg32("0")), knockback_normal(), knockback("1"), fg32("1"));
|
||||||
|
frame(3);
|
||||||
|
hitbox(10, "hand_l", damage("3"), vec3("0", "0", "0"), fg32("1"), angle(fg32("0")), knockback_normal(), knockback("1"), fg32("1"));
|
||||||
|
frame(4);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
script
|
||||||
|
.hitboxes
|
||||||
|
.iter()
|
||||||
|
.map(|hitbox| hitbox.id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![3, 10, 20]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
script
|
||||||
|
.hitboxes
|
||||||
|
.iter()
|
||||||
|
.map(|hitbox| hitbox.start_frame)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![2, 3, 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user