diff --git a/src/fighter/attack.rs b/src/fighter/attack.rs index cf1d274..f86e804 100644 --- a/src/fighter/attack.rs +++ b/src/fighter/attack.rs @@ -6,7 +6,8 @@ use serde::{Deserialize, Serialize}; use crate::{ fighter::{ - Fighter, FighterAttributes, FighterFacingDirection, FighterHitboxes, FighterTranslation, + Fighter, FighterAttributes, FighterFacingDirection, FighterHitboxes, + FighterPreviousFacingDirection, FighterPreviousTranslation, FighterTranslation, animation::{AnimKind, FighterAnimationFrame}, baked_animation::{FighterBoneMatrices, FixedMat4}, hurtbox::{FixedAffineCapsule, FixedCapsule}, @@ -16,6 +17,7 @@ use crate::{ int::{FGi32, FGi32Ext}, vec3::FGVec3, }, + player::Player, schedule::GameplaySet, scripting::FighterAttackScript, }; @@ -212,19 +214,32 @@ pub fn solve_hitboxes( mut query: Query<( &FighterBoneMatrices, &FighterTranslation, + &FighterPreviousTranslation, &FighterFacingDirection, + &FighterPreviousFacingDirection, &mut FighterHitboxes, &FighterAnimationFrame, )>, attack_scripts: Res>, ) { - 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(); if let Some(ref attack_script) = hitboxes.attack_script { let attack_script = attack_scripts .get(attack_script) .expect("Attack script should be valid"); 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(); for hitbox_idx in active_hitboxes { let hitbox = attack_script @@ -245,18 +260,24 @@ pub fn solve_hitboxes( if hitbox.start_frame == animation_frame.frame { // Sphere only - hitboxes.active_hitboxes_solved.push(FixedCapsule { - start: curr_hbox_pos, - end: curr_hbox_pos, - radius: hitbox.radius, + hitboxes.active_hitboxes_solved.push(FighterSolvedHitbox { + hitbox_idx, + capsule: FixedCapsule { + start: curr_hbox_pos, + end: curr_hbox_pos, + radius: hitbox.radius, + }, }) } 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); - hitboxes.active_hitboxes_solved.push(FixedCapsule { - start: prev_hbox_pos, - end: curr_hbox_pos, - radius: hitbox.radius, + hitboxes.active_hitboxes_solved.push(FighterSolvedHitbox { + hitbox_idx, + capsule: FixedCapsule { + start: prev_hbox_pos, + end: curr_hbox_pos, + radius: hitbox.radius, + }, }); } } @@ -312,10 +333,18 @@ pub fn solve_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>, ) { - /*for (entity_hurting, _, hitboxes) in query { + let mut attackers = query_attacker.iter_mut().collect::>(); + attackers + .sort_by(|(_, _, _, player_a), (_, _, _, player_b)| player_a.handle.cmp(&player_b.handle)); + + let mut receivers = query_attacked.iter().collect::>(); + 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() { continue; } @@ -329,18 +358,39 @@ pub fn intersect_attacks_with_hurtboxes( let attack_script = attack_script.unwrap(); - let capsules: Vec<(&AttackHitbox, FixedCapsule)> = hitboxes.active_hitboxes.iter().map(|idx| { - (attack_script.hitboxes.get(*idx).unwrap(), ) - }).collect(); - - for (entity_to_hurt, hurtboxes, _) in query { - // Self collisions not allowed - if entity_to_hurt == entity_hurting { + for (entity_receiving, hurtboxes, _) in receivers.iter() { + if entity_hurting == *entity_receiving { 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; @@ -349,7 +399,11 @@ impl Plugin for FighterAttackPlugin { fn build(&self, app: &mut App) { app.add_systems( GgrsSchedule, - (solve_hurtboxes, solve_hitboxes) + ( + solve_hurtboxes, + solve_hitboxes, + intersect_attacks_with_hurtboxes, + ) .chain() .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 1973b17..0bfe9fe 100644 --- a/src/fighter/baked_animation.rs +++ b/src/fighter/baked_animation.rs @@ -3,7 +3,6 @@ use std::{ collections::{HashMap, HashSet}, io, - path::Path, }; use bevy::{ @@ -18,11 +17,12 @@ use bevy::{ reflect::TypePath, world_serialization::{WorldInstanceReady, WorldInstanceSpawner}, }; -use bevy_ggrs::prelude::GgrsSchedule; +use bevy_ggrs::{GgrsFrameTiming, prelude::GgrsSchedule}; use serde::{Deserialize, Serialize}; use crate::{ fighter::{ + Fighter, animation::{AnimKind, FighterAnimationFrame}, manifest::FighterManifest, visual::FighterAnimations, @@ -194,7 +194,10 @@ impl BakedFighterAnimations { pub fn sample_pose(&self, frame: &FighterAnimationFrame) -> Option<&[FixedMat4]> { let frames = &self.clip(frame.kind)?.frames; 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 { frame.frame }; @@ -224,6 +227,7 @@ pub struct FighterBoneMatrices { matrices: Vec, previous_frame_matrices: Vec, local_matrices: Vec, + previous_local_matrices: Vec, } #[derive(Component)] @@ -263,7 +267,23 @@ impl FighterBoneMatrices { self.local_matrices.get(index).copied() } - fn set_pose(&mut self, bone_names: &[String], parents: &[Option], local: &[FixedMat4]) { + fn presentation_local(&self, index: usize, overstep_fraction: f32) -> Option { + 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], + local: &[FixedMat4], + model_scale: FGi32, + ) { assert_eq!( bone_names.len(), local.len(), @@ -282,6 +302,7 @@ impl FighterBoneMatrices { 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.local_matrices, &mut self.previous_local_matrices); self.local_matrices.clear(); self.local_matrices.extend_from_slice(local); let mut model = vec![None; local.len()]; @@ -289,9 +310,17 @@ impl FighterBoneMatrices { resolve_model_transform(index, parents, local, &mut model) .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 .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(); } } @@ -300,18 +329,32 @@ pub fn update_fighter_bone_matrices( mut fighters: Query<( &FighterAnimationFrame, &FighterAnimations, + &Fighter, &mut FighterBoneMatrices, )>, baked_assets: Res>, + manifests: Res>, ) { - for (frame, animations, mut matrices) in &mut fighters { + for (frame, animations, fighter, mut matrices) in &mut fighters { let baked = baked_assets .get(&animations.baked) .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 .sample_pose(frame) .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( mut nodes: Query<(&mut Transform, &BakedFighterBone)>, fighters: Query<&FighterBoneMatrices>, + timing: Res, ) { + let overstep_fraction = timing.overstep_fraction(); for (mut transform, bone) in &mut nodes { let Ok(matrices) = fighters.get(bone.fighter) else { continue; }; - let Some(local) = matrices.local(bone.index) else { + let Some(local) = matrices.presentation_local(bone.index, overstep_fraction) else { 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) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - // The processor's AssetServer reads processed assets. Reading this - // source dependency through it would wait for a processed GLB while - // this processor is still producing the first processed asset. - let model_bytes = std::fs::read(Path::new("assets").join(&manifest.model_path))?; + let model_bytes = load_context + .read_asset_bytes(&manifest.model_path) + .await + .map_err(|error| io::Error::other(error.to_string()))?; Ok(FighterBakeSource { model_bytes, animations: manifest.animations, @@ -616,10 +674,10 @@ fn bake_gltf( .iter() .filter_map(Channel::duration) .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); 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 .iter() .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( index: usize, parents: &[Option], @@ -829,18 +898,19 @@ mod tests { let manifest: FighterManifest = ron::from_str(include_str!("../../assets/fighters/test/test.fighter.ron")).unwrap(); let baked = bake_gltf( - include_bytes!("../../assets/fighters/test/test-fighter-normal.glb"), + include_bytes!("../../assets/fighters/test/test-fighter-2.glb"), &manifest.animations, ) .unwrap(); 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[&AnimKind::JumpSquat], - baked.clip_indices[&AnimKind::Landing] - ); assert_eq!( baked.clip(AnimKind::Wait).unwrap().frames[0].len(), baked.bone_names.len() @@ -853,12 +923,14 @@ mod tests { let first = FixedMat4::IDENTITY; let mut second = FixedMat4::IDENTITY; second.translate(FGVec3::lit("2", "0", "0")); + let mut terminal = FixedMat4::IDENTITY; + terminal.translate(FGVec3::lit("4", "0", "0")); let baked = BakedFighterAnimations { frame_rate: BAKED_ANIMATION_FPS, bone_names: vec!["root".into()], parents: vec![None], clips: vec![BakedAnimationClip { - frames: vec![vec![first], vec![second]], + frames: vec![vec![first], vec![second], vec![terminal]], }], clip_indices: HashMap::from([(AnimKind::Wait, 0)]), }; @@ -870,12 +942,48 @@ mod tests { }; let finished = FighterAnimationFrame { kind: AnimKind::Wait, - frame: 3, + frame: 4, repeat: false, }; 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] @@ -888,6 +996,7 @@ mod tests { &["root".into(), "hand".into()], &[None, Some(0)], &[first, second], + FGi32::ONE, ); assert_eq!(matrices.get_current("hand"), Some(second)); @@ -909,6 +1018,7 @@ mod tests { &["root".into(), "hand".into()], &[None, Some(0)], &[root, hand], + FGi32::ONE, ); 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] fn fixed_matrix_transforms_points() { let transform = FixedMat4 { diff --git a/src/fighter/debug.rs b/src/fighter/debug.rs index 5045f30..2b938da 100644 --- a/src/fighter/debug.rs +++ b/src/fighter/debug.rs @@ -341,14 +341,15 @@ pub fn attack_debug( for (hitboxes, matrices, translation, facing_direction, frame) in query { if let Some(script) = &hitboxes.attack_script { for solved_hitbox in hitboxes.active_hitboxes_solved.iter() { - let start = solved_hitbox.start.to_vec3(); - let end = solved_hitbox.end.to_vec3(); - let center = (solved_hitbox.start + solved_hitbox.end) * FGi32::lit("0.5"); + let start = solved_hitbox.capsule.start.to_vec3(); + let end = solved_hitbox.capsule.end.to_vec3(); + let center = + (solved_hitbox.capsule.start + solved_hitbox.capsule.end) * FGi32::lit("0.5"); let length = (start - end).length(); let attack_capsule = Capsule3d { 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); diff --git a/src/fighter/mod.rs b/src/fighter/mod.rs index 6ac4e19..ae03b41 100644 --- a/src/fighter/mod.rs +++ b/src/fighter/mod.rs @@ -26,13 +26,18 @@ pub mod visual; pub use attributes::FighterAttributes; pub use ecb::FighterECB; pub use manifest::FighterId; -pub use motion::{FighterPreviousTranslation, FighterTranslation, FighterVelocity, Grounded}; +pub use motion::{ + FighterPreviousFacingDirection, FighterPreviousTranslation, FighterTranslation, + FighterVelocity, Grounded, +}; use crate::{ fighter::{ - attack::{FighterAttackPlugin, FighterAttackScriptAssets, FighterSolvedHurtboxes}, + attack::{ + FighterAttackPlugin, FighterAttackScriptAssets, FighterSolvedHitboxes, + FighterSolvedHurtboxes, + }, baked_animation::FighterBoneMatrices, - hurtbox::FixedCapsule, manifest::FighterManifest, state::{FighterState, fall::FallState}, visual::FighterAnimations, @@ -113,7 +118,8 @@ impl Plugin for FighterPlugin { .add_systems( 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), state::state_interrupt_system, ) @@ -155,6 +161,8 @@ impl Plugin for FighterPlugin { .checksum_component_with_hash::() .rollback_component_with_copy::() .checksum_component_with_hash::() + .rollback_component_with_copy::() + .checksum_component_with_hash::() .rollback_component_with_copy::() .checksum_component_with_hash::() .rollback_component_with_copy::() @@ -198,13 +206,15 @@ impl Plugin for FighterPlugin { pub struct FighterHitboxes { pub attack_script: Option>, pub active_hitboxes: Vec, - pub active_hitboxes_solved: Vec, + pub active_hitboxes_solved: Vec, + pub hit_fighters: Vec, } impl FighterHitboxes { pub fn clear(&mut self) { self.attack_script = None; self.active_hitboxes.clear(); + self.hit_fighters.clear(); } } @@ -213,6 +223,7 @@ pub fn spawn_fighter( player_handle: usize, spawn_position: FGVec2, manifest: Handle, + model_scale: FGi32, animations: FighterAnimations, attack_scripts: FighterAttackScriptAssets, visual_root: WorldAssetRoot, @@ -224,6 +235,8 @@ pub fn spawn_fighter( }, Fighter { manifest: manifest }, FighterFacingDirection::Right, + FighterPreviousFacingDirection(FighterFacingDirection::Right), + Transform::from_scale(Vec3::splat(model_scale.to_num())), ( FighterECB { vertical_half: FGi32::lit("5.0"), @@ -233,6 +246,7 @@ pub fn spawn_fighter( attack_script: None, active_hitboxes: vec![], active_hitboxes_solved: vec![], + hit_fighters: vec![], }, ), ( @@ -248,6 +262,7 @@ pub fn spawn_fighter( ( baked_animation::FighterBoneMatrices::default(), FighterSolvedHurtboxes::default(), + FighterSolvedHitboxes::default(), FighterVisual, FighterInput::default(), crate::camera::FighterCameraExtents::default(), diff --git a/src/fighter/motion.rs b/src/fighter/motion.rs index e01889e..32454d4 100644 --- a/src/fighter/motion.rs +++ b/src/fighter/motion.rs @@ -10,6 +10,9 @@ use crate::{ math::{int::FGi32, vec::FGVec2, vec3::FGVec3}, }; +#[derive(Component, Clone, Copy, Hash)] +pub struct FighterPreviousFacingDirection(pub FighterFacingDirection); + #[derive(Component, Clone, Copy, Hash)] pub struct Grounded; @@ -100,6 +103,20 @@ pub fn copy_fighter_transform_to_visuals(fighters: Query<(&FighterTranslation, & 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)] mod tests { use super::*; @@ -127,4 +144,34 @@ mod tests { 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); + } } diff --git a/src/input/mod.rs b/src/input/mod.rs index b7cc7b7..7944b0f 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -43,7 +43,10 @@ impl LocalInputAssignments { } 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) } } diff --git a/src/scripting/move_compiler.rs b/src/scripting/move_compiler.rs index 7f441f6..7fba60b 100644 --- a/src/scripting/move_compiler.rs +++ b/src/scripting/move_compiler.rs @@ -87,11 +87,12 @@ impl MoveCompiler { fn into_attack_script(mut self) -> Result { self.remove_all_hitboxes().map_err(|err| err.to_string())?; - let hitboxes = self + let mut hitboxes: Vec<_> = self .hitboxes .into_iter() .map(|(_, hitbox)| hitbox) .collect(); + hitboxes.sort_unstable_by_key(|hitbox| hitbox.id); let iasa_frame = self.iasa_frame.unwrap_or(100000); let wont_autocancel_window = self.wont_autocancel_window.unwrap_or((0, 100000)); Ok(FighterAttackScript { @@ -242,3 +243,41 @@ pub fn compile_script(text: &str) -> Result { }) .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![3, 10, 20] + ); + assert_eq!( + script + .hitboxes + .iter() + .map(|hitbox| hitbox.start_frame) + .collect::>(), + vec![2, 3, 1] + ); + } +}