diff --git a/.gitignore b/.gitignore index bf0b126..45250cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /.jackdaw +imported_assets \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 1e1a10a..96abaf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,6 +491,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -1207,7 +1213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84e21ad83b7dc8d456491ea3d50057685d8c4cca8bc52a1b4a7161c86c369842" dependencies = [ "async-lock", - "base64", + "base64 0.22.1", "bevy_animation", "bevy_app", "bevy_asset", @@ -3516,11 +3522,14 @@ dependencies = [ "bevy_egui", "bevy_ggrs", "bevy_wind_waker_shader", + "bincode", "bitfields", "clap", "fixed", "gc-adapter-neo", + "gltf", "jackdaw_runtime", + "ron 0.12.2", "serde", "strum", "strum_macros", @@ -3733,10 +3742,13 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7" dependencies = [ + "base64 0.13.1", "byteorder", "gltf-json", + "image", "lazy_static", "serde_json", + "urlencoding", ] [[package]] @@ -4164,6 +4176,8 @@ dependencies = [ "num-traits", "png", "tiff", + "zune-core", + "zune-jpeg", ] [[package]] @@ -6205,7 +6219,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db09040cc89e461f1a265139777a2bde7f8d8c67c4936f700c63ce3e2904d468" dependencies = [ - "base64", + "base64 0.22.1", "bitflags 2.13.1", "serde", "serde_derive", @@ -7170,6 +7184,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index e4b1e12..617e55f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ authors = [""] [workspace] [dependencies] -bevy = { version = "0.19", features = ["dynamic_linking", "free_camera"] } +bevy = { version = "0.19", features = ["asset_processor", "dynamic_linking", "free_camera"] } # Loads the scenes you author in the editor (assets/*.bsn) and builds # physics colliders from them. An ordinary dependency; remove it if you # stop using authored scenes. @@ -26,6 +26,9 @@ bevy-inspector-egui = "0.37.0" bevy_egui = "0.40.0" bevy_ggrs = { git = "https://github.com/gschup/bevy_ggrs" } serde = "1.0.229" +bincode = "1.3.3" +gltf = "1.4.1" +ron = "0.12.0" bitfields = "3.0.0" clap = { version = "4.6.6", features = ["derive"] } bevy_wind_waker_shader = { git = "https://github.com/lavaeater/bevy_wind_waker_shader.git" } diff --git a/assets/fighters/test/test.fighter.ron b/assets/fighters/test/test.fighter.ron index 9590256..b007ead 100644 --- a/assets/fighters/test/test.fighter.ron +++ b/assets/fighters/test/test.fighter.ron @@ -1,11 +1,12 @@ ( id: TestFighter, model_path: "fighters/test/test-fighter-normal.glb", + baked_animation_path: "fighters/test/test.fighterbake", attributes: ( base_walk_accel: "0.1", stick_walk_accel: "0.16", max_walk_vel: "0.7", - ground_friction: "0.02", + ground_friction: "0.09", dash_acceleration_duration: 13, dash_duration: 24, base_dash_accel: "0.02", @@ -32,6 +33,14 @@ air_dodge_velocity: "2.790", air_dodge_decay: "0.9" ), + camera: ( + vertical_origin_offset: "10.0", + forward_extent: "25.5", + backward_extent: "2.5", + upward_extent: "15.0", + downward_extent: "5.0", + visibility_radius: "5.0", + ), animations: { Wait: "PlyPurin5K_Share_ACTION_Wait", Walk: "PlyPurin5K_Share_ACTION_WalkFast", @@ -43,6 +52,7 @@ Landing: "PlyPurin5K_Share_ACTION_Landing", AirDodge: "PlyPurin5K_Share_ACTION_EscapeAir", DoubleJump: "PlyPurin5K_Share_ACTION_JumpAerialF1", - Fall: "PlyPurin5K_Share_ACTION_Fall" + Fall: "PlyPurin5K_Share_ACTION_Fall", + Turn: "PlyPurin5K_Share_ACTION_Turn", } ) diff --git a/assets/fighters/test/test.fighterbake b/assets/fighters/test/test.fighterbake new file mode 100644 index 0000000..706a119 --- /dev/null +++ b/assets/fighters/test/test.fighterbake @@ -0,0 +1,3 @@ +( + manifest_path: "fighters/test/test.fighter.ron", +) diff --git a/assets/stages/test.stage.ron b/assets/stages/test.stage.ron index 7cab389..3904493 100644 --- a/assets/stages/test.stage.ron +++ b/assets/stages/test.stage.ron @@ -1,6 +1,25 @@ ( id: TestStage, model_path: "stages/battlefield.glb#Scene0", + camera: ( + left: -75.0, + right: 75.0, + bottom: -30.0, + top: 70.0, + origin: (0.0, 15.0), + vertical_fov_degrees: 30.0, + min_depth: 150.0, + max_depth: 400.0, + subject_scale: 1.0, + tracking_smoothness: 3.0, + vertical_tilt_degrees: 0.0, + horizontal_pan_coefficient: 0.0, + vertical_pan_coefficient: 0.0, + max_horizontal_pan_degrees: 17.5, + max_upward_pan_degrees: 5.0, + max_downward_pan_degrees: 7.0, + max_downward_expansion: 40.0, + ), polygons: [ ( poly_type: Platform, @@ -62,4 +81,4 @@ ] ) ] -) \ No newline at end of file +) diff --git a/src/camera/mod.rs b/src/camera/mod.rs index c2560fd..57c24ba 100644 --- a/src/camera/mod.rs +++ b/src/camera/mod.rs @@ -2,7 +2,7 @@ use bevy::prelude::*; -use crate::stage::manifest::StageCameraProfile; +use crate::{fighter::manifest::FighterManifest, stage::manifest::StageCameraProfile}; const EMPTY_FRAME_HALF_SIZE: f32 = 40.0; const SUBJECT_COUNT_SCALES: [f32; 4] = [1.5, 1.32, 1.16, 1.0]; @@ -208,7 +208,7 @@ fn update_match_camera( fighters: Query< ( &crate::fighter::motion::FighterTranslation, - &crate::fighter::FighterCameraProfile, + &crate::fighter::Fighter, &crate::fighter::FighterFacingDirection, ), With, @@ -219,11 +219,13 @@ fn update_match_camera( &mut MatchCamera, &StageCameraProfile, )>, + manifests: Res> ) { - let subjects = fighters.iter().map(|(translation, profile, facing)| { + let subjects = fighters.iter().map(|(translation, fighter, facing)| { + let manifest = manifests.get(&fighter.manifest).expect("Fighter manifest should be valid"); fighter_subject_bounds( Vec2::new(translation.x.to_num(), translation.y.to_num()), - *profile, + manifest.camera, *facing, ) }); @@ -296,12 +298,13 @@ fn debug_draw_camera( fighters: Query< ( &crate::fighter::motion::FighterTranslation, - &crate::fighter::FighterCameraProfile, + &crate::fighter::Fighter, &crate::fighter::FighterFacingDirection, ), With, >, cameras: Query<(&MatchCamera, &StageCameraProfile)>, + manifests: Res>, ) { for (camera, profile) in &cameras { draw_rectangle( @@ -315,10 +318,11 @@ fn debug_draw_camera( Color::srgb(1.0, 0.5, 0.0), ); draw_rectangle(&mut gizmos, camera.frame, Color::srgb(1.0, 1.0, 0.0)); - for (translation, fighter_profile, facing) in &fighters { + for (translation, fighter, facing) in &fighters { + let manifest = manifests.get(&fighter.manifest).expect("Fighter manifest should be valid"); let subject = fighter_subject_bounds( Vec2::new(translation.x.to_num(), translation.y.to_num()), - *fighter_profile, + manifest.camera, *facing, ); draw_rectangle( diff --git a/src/fighter/animation.rs b/src/fighter/animation.rs index 8d2794f..6596531 100644 --- a/src/fighter/animation.rs +++ b/src/fighter/animation.rs @@ -15,6 +15,37 @@ impl FighterAnimationPlayerLink { } } +/// The simulation-owned animation clock. Rendering may interpolate freely, but +/// gameplay samples baked data only with this rollback-tracked value. +#[derive(Component, Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub struct FighterAnimationFrame { + pub kind: AnimKind, + pub frame: u32, + pub repeat: bool, +} + +impl FighterAnimationFrame { + pub const fn new(kind: AnimKind, repeat: bool) -> Self { + Self { kind, frame: 0, repeat } + } + + pub fn reset(&mut self, kind: AnimKind, repeat: bool) { + self.kind = kind; + self.frame = 0; + self.repeat = repeat; + } + + pub fn advance(&mut self) { + self.frame = self.frame.saturating_add(1); + } +} + +pub fn advance_fighter_animation_frames(mut fighters: Query<&mut FighterAnimationFrame>) { + for mut animation in &mut fighters { + animation.advance(); + } +} + pub fn apply_fighter_translation_to_visuals( query: Query<(&FighterTranslation, &FighterECB, &mut Transform)>, ) { @@ -47,11 +78,16 @@ pub fn setup_fighter_animation_player( } } -pub fn apply_animation(children: Query<(&mut Transform, &FighterFacingDirection), With>) { - for (mut trf, facing_direction) in children { +pub fn apply_animation(children: Query<(&mut Transform, &FighterFacingDirection, &FighterAnimationFrame), With>) { + for (mut trf, facing_direction, frame) in children { // Melee model scale is in decimeters trf.rotation = Quat::IDENTITY; - trf.rotate_local_y(std::f32::consts::PI * 0.5 * facing_direction.to_sign().to_num::()); + if let AnimKind::Turn = frame.kind { + trf.rotate_local_y(std::f32::consts::PI * -0.5 * facing_direction.to_sign().to_num::()); + } else { + trf.rotate_local_y(std::f32::consts::PI * 0.5 * facing_direction.to_sign().to_num::()); + } + } } @@ -92,4 +128,5 @@ pub enum AnimKind { Landing, AirDodge, Fall, + Turn, } diff --git a/src/fighter/attributes.rs b/src/fighter/attributes.rs index 4467706..3cf6f64 100644 --- a/src/fighter/attributes.rs +++ b/src/fighter/attributes.rs @@ -6,7 +6,7 @@ use crate::{input::FighterInputFrame, math::int::FGi32}; /// Per-fighter tuning values. These are character data, not global rules — /// anything that applies to every fighter belongs in /// [`crate::game_settings::FighterSettingsCommon`] instead. -#[derive(Asset, Component, Clone, Copy, Reflect, Serialize, Deserialize)] +#[derive(Asset, Clone, Copy, Reflect, Serialize, Deserialize)] #[reflect(opaque)] pub struct FighterAttributes { pub base_walk_accel: FGi32, diff --git a/src/fighter/baked_animation.rs b/src/fighter/baked_animation.rs new file mode 100644 index 0000000..7664bb4 --- /dev/null +++ b/src/fighter/baked_animation.rs @@ -0,0 +1,528 @@ +//! Deterministic, fighter-local skeletal animation samples for gameplay. +//! +//! Rendering continues to use Bevy's floating-point animation player. Gameplay +//! reads this asset instead, so rollback never depends on render-time sampling. + +use std::{collections::HashMap, io, path::Path}; + +use bevy::{ + asset::{ + AssetLoader, AssetPath, AsyncWriteExt, LoadContext, + io::{Reader, Writer}, + processor::LoadTransformAndSave, + saver::{AssetSaver, SavedAsset}, + transformer::{AssetTransformer, TransformedAsset}, + }, + prelude::*, + reflect::TypePath, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + fighter::{animation::AnimKind, manifest::FighterManifest}, + math::int::{FGWide, FGi32}, +}; + +pub const BAKED_ANIMATION_FPS: u32 = 60; + +/// Parent transforms are composed after +/// quantization, which makes every runtime sample platform-independent. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FixedMat4 { + pub cols: [[FGi32; 4]; 4], +} + +impl FixedMat4 { + fn from_mat4(matrix: Mat4) -> io::Result { + let values = matrix.to_cols_array(); + let mut cols = [[FGi32::ZERO; 4]; 4]; + for (index, value) in values.into_iter().enumerate() { + cols[index / 4][index % 4] = FGi32::checked_from_num(value).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "animation transform overflows Q16.16", + ) + })?; + } + Ok(Self { cols }) + } + + fn mul(self, rhs: Self) -> Self { + let mut cols = [[FGi32::ZERO; 4]; 4]; + for column in 0..4 { + for row in 0..4 { + let mut value = FGWide::ZERO; + for k in 0..4 { + value += + FGWide::from_num(self.cols[k][row]) * FGWide::from_num(rhs.cols[column][k]); + } + cols[column][row] = FGi32::from_num(value); + } + } + Self { cols } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BakedAnimationClip { + /// One model-space matrix per stored node, for each 60 Hz simulation frame. + pub frames: Vec>, +} + +#[derive(Asset, TypePath, Clone, Debug, Serialize, Deserialize)] +pub struct BakedFighterAnimations { + pub frame_rate: u32, + /// Indexed exactly like every frame in each clip. + pub bone_names: Vec, + clips: Vec, + clip_indices: HashMap, +} + +impl BakedFighterAnimations { + pub fn sample(&self, kind: AnimKind, frame: u32, bone: &str) -> Option { + let bone_index = self.bone_names.iter().position(|name| name == bone)?; + let frames = &self.clip(kind)?.frames; + let pose = frames.get(frame as usize).or_else(|| frames.last())?; + pose.get(bone_index).copied() + } + + pub fn frame_count(&self, kind: AnimKind) -> Option { + self.clip(kind).map(|clip| clip.frames.len() as u32) + } + + fn clip(&self, kind: AnimKind) -> Option<&BakedAnimationClip> { + self.clip_indices + .get(&kind) + .and_then(|index| self.clips.get(*index)) + } +} + +#[derive(Deserialize)] +struct FighterBakeDeclaration { + manifest_path: String, +} + +#[derive(Asset, TypePath)] +struct FighterBakeSource { + model_bytes: Vec, + animations: HashMap, +} + +#[derive(Default, TypePath)] +struct FighterBakeSourceLoader; + +impl AssetLoader for FighterBakeSourceLoader { + type Asset = FighterBakeSource; + type Settings = (); + type Error = io::Error; + + async fn load( + &self, + reader: &mut dyn Reader, + _: &Self::Settings, + load_context: &mut LoadContext<'_>, + ) -> Result { + let mut declaration = Vec::new(); + reader.read_to_end(&mut declaration).await?; + let declaration: FighterBakeDeclaration = ron::de::from_bytes(&declaration) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + + let manifest_bytes = load_context + .read_asset_bytes(&declaration.manifest_path) + .await + .map_err(|error| io::Error::other(error.to_string()))?; + 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))?; + Ok(FighterBakeSource { + model_bytes, + animations: manifest.animations, + }) + } + + fn extensions(&self) -> &[&str] { + &["fighterbake"] + } +} + +#[derive(Default, TypePath)] +struct BakedFighterAnimationsLoader; + +impl AssetLoader for BakedFighterAnimationsLoader { + type Asset = BakedFighterAnimations; + type Settings = (); + type Error = io::Error; + + async fn load( + &self, + reader: &mut dyn Reader, + _: &Self::Settings, + _: &mut LoadContext<'_>, + ) -> Result { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await?; + bincode::deserialize(&bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) + } + + fn extensions(&self) -> &[&str] { + &["fighterbake"] + } +} + +#[derive(Default, TypePath)] +struct FighterBakeTransformer; + +impl AssetTransformer for FighterBakeTransformer { + type AssetInput = FighterBakeSource; + type AssetOutput = BakedFighterAnimations; + type Settings = (); + type Error = io::Error; + + async fn transform<'a>( + &'a self, + source: TransformedAsset, + _: &'a Self::Settings, + ) -> Result, Self::Error> { + let baked = bake_gltf(&source.model_bytes, &source.animations)?; + Ok(source.replace_asset(baked)) + } +} + +#[derive(Default, TypePath)] +struct BakedFighterAnimationsSaver; + +impl AssetSaver for BakedFighterAnimationsSaver { + type Asset = BakedFighterAnimations; + type Settings = (); + type OutputLoader = BakedFighterAnimationsLoader; + type Error = io::Error; + + async fn save( + &self, + writer: &mut Writer, + asset: SavedAsset<'_, '_, Self::Asset>, + _: &Self::Settings, + _: AssetPath<'_>, + ) -> Result<(), Self::Error> { + let bytes = bincode::serialize(&*asset) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + writer.write_all(&bytes).await + } +} + +type FighterBakeProcessor = LoadTransformAndSave< + FighterBakeSourceLoader, + FighterBakeTransformer, + BakedFighterAnimationsSaver, +>; + +pub struct BakedAnimationPlugin; + +impl Plugin for BakedAnimationPlugin { + fn build(&self, app: &mut App) { + app.init_asset::() + .register_asset_loader(FighterBakeSourceLoader) + .register_asset_loader(BakedFighterAnimationsLoader) + .register_asset_processor(FighterBakeProcessor::new( + FighterBakeTransformer, + BakedFighterAnimationsSaver, + )) + .set_default_asset_processor::("fighterbake"); + } +} + +fn bake_gltf( + bytes: &[u8], + animations: &HashMap, +) -> io::Result { + let (document, buffers, _) = gltf::import_slice(bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let nodes: Vec<_> = document.nodes().collect(); + if nodes.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "fighter model has no nodes", + )); + } + let mut parents = vec![None; nodes.len()]; + for node in &nodes { + for child in node.children() { + parents[child.index()] = Some(node.index()); + } + } + let names = nodes + .iter() + .map(|node| { + node.name().map(str::to_owned).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("node {} has no name", node.index()), + ) + }) + }) + .collect::>>()?; + + let mut pending = HashMap::<&str, Vec>::new(); + for (kind, name) in animations { + pending.entry(name).or_default().push(*kind); + } + + let mut clips = Vec::with_capacity(pending.len()); + let mut clip_indices = HashMap::with_capacity(animations.len()); + for animation in document.animations() { + let name = animation.name().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("animation {} has no name", animation.index()), + ) + })?; + let Some(kinds) = pending.remove(name) else { + continue; + }; + let channels = animation + .channels() + .map(|channel| read_channel(channel, &buffers)) + .collect::>>()?; + let duration = channels + .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 mut frames = Vec::with_capacity(frame_count); + for frame in 0..frame_count { + let time = frame as f32 / BAKED_ANIMATION_FPS as f32; + let local = nodes + .iter() + .map(|node| local_transform(node.clone(), &channels, time)) + .collect::>>()?; + let mut model = vec![None; nodes.len()]; + for index in 0..nodes.len() { + resolve_model_transform(index, &parents, &local, &mut model)?; + } + frames.push(model.into_iter().map(Option::unwrap).collect()); + } + let index = clips.len(); + clips.push(BakedAnimationClip { frames }); + for kind in kinds { + clip_indices.insert(kind, index); + } + } + if clips.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "fighter model has no animations", + )); + } + if !pending.is_empty() { + let mut missing = pending.keys().copied().collect::>(); + missing.sort_unstable(); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "fighter model has no animations named {}", + missing.join(", ") + ), + )); + } + Ok(BakedFighterAnimations { + frame_rate: BAKED_ANIMATION_FPS, + bone_names: names, + clips, + clip_indices, + }) +} + +fn resolve_model_transform( + index: usize, + parents: &[Option], + local: &[FixedMat4], + model: &mut [Option], +) -> io::Result { + if let Some(value) = model[index] { + return Ok(value); + } + let value = match parents[index] { + Some(parent) => resolve_model_transform(parent, parents, local, model)?.mul(local[index]), + None => local[index], + }; + model[index] = Some(value); + Ok(value) +} + +#[derive(Clone)] +struct Channel { + node: usize, + property: gltf::animation::Property, + interpolation: gltf::animation::Interpolation, + times: Vec, + values: ChannelValues, +} + +impl Channel { + fn duration(&self) -> Option { + self.times.last().copied() + } +} + +#[derive(Clone)] +enum ChannelValues { + Vec3(Vec<[f32; 3]>), + Rotation(Vec<[f32; 4]>), +} + +fn read_channel( + channel: gltf::animation::Channel<'_>, + buffers: &[gltf::buffer::Data], +) -> io::Result { + let reader = channel.reader(|buffer| Some(&buffers[buffer.index()].0)); + let times = reader + .read_inputs() + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "animation channel has no input") + })? + .collect(); + let values = match reader.read_outputs().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "animation channel has no output", + ) + })? { + gltf::animation::util::ReadOutputs::Translations(values) + | gltf::animation::util::ReadOutputs::Scales(values) => { + ChannelValues::Vec3(values.collect()) + } + gltf::animation::util::ReadOutputs::Rotations(values) => { + ChannelValues::Rotation(values.into_f32().collect()) + } + gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "morph target animation is not supported for bone baking", + )); + } + }; + if channel.sampler().interpolation() == gltf::animation::Interpolation::CubicSpline { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "cubic spline animation is not supported for bone baking", + )); + } + Ok(Channel { + node: channel.target().node().index(), + property: channel.target().property(), + interpolation: channel.sampler().interpolation(), + times, + values, + }) +} + +fn local_transform(node: gltf::Node<'_>, channels: &[Channel], time: f32) -> io::Result { + let (mut translation, mut rotation, mut scale) = match node.transform() { + gltf::scene::Transform::Decomposed { + translation, + rotation, + scale, + } => ( + Vec3::from(translation), + Quat::from_array(rotation), + Vec3::from(scale), + ), + gltf::scene::Transform::Matrix { matrix } => { + return FixedMat4::from_mat4(Mat4::from_cols_array_2d(&matrix)); + } + }; + for channel in channels + .iter() + .filter(|channel| channel.node == node.index()) + { + match (&channel.values, channel.property) { + (ChannelValues::Vec3(values), gltf::animation::Property::Translation) => { + translation = Vec3::from(sample_vec3(channel, values, time)) + } + (ChannelValues::Vec3(values), gltf::animation::Property::Scale) => { + scale = Vec3::from(sample_vec3(channel, values, time)) + } + (ChannelValues::Rotation(values), gltf::animation::Property::Rotation) => { + rotation = Quat::from_array(sample_rotation(channel, values, time)) + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "animation property does not match its values", + )); + } + } + } + FixedMat4::from_mat4(Mat4::from_scale_rotation_translation( + scale, + rotation, + translation, + )) +} + +fn sample_index(channel: &Channel, time: f32) -> (usize, usize, f32) { + let next = channel.times.partition_point(|key| *key <= time); + if next == 0 { + return (0, 0, 0.0); + } + if next == channel.times.len() { + let last = next - 1; + return (last, last, 0.0); + } + let previous = next - 1; + let factor = (time - channel.times[previous]) / (channel.times[next] - channel.times[previous]); + (previous, next, factor) +} + +fn sample_vec3(channel: &Channel, values: &[[f32; 3]], time: f32) -> [f32; 3] { + let (from, to, factor) = sample_index(channel, time); + if channel.interpolation == gltf::animation::Interpolation::Step { + return values[from]; + } + Vec3::from(values[from]) + .lerp(Vec3::from(values[to]), factor) + .to_array() +} + +fn sample_rotation(channel: &Channel, values: &[[f32; 4]], time: f32) -> [f32; 4] { + let (from, to, factor) = sample_index(channel, time); + if channel.interpolation == gltf::animation::Interpolation::Step { + return values[from]; + } + Quat::from_array(values[from]) + .slerp(Quat::from_array(values[to]), factor) + .to_array() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn configured_fighter_clips_bake_to_fixed_point_frames() { + 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"), + &manifest.animations, + ) + .unwrap(); + + assert_eq!(baked.frame_rate, BAKED_ANIMATION_FPS); + assert!(baked.frame_count(AnimKind::Wait).unwrap() > 1); + 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() + ); + } +} diff --git a/src/fighter/debug.rs b/src/fighter/debug.rs index 5c1c520..ad0d7b0 100644 --- a/src/fighter/debug.rs +++ b/src/fighter/debug.rs @@ -4,10 +4,15 @@ use bevy::color::palettes::css::ORANGE; use bevy::{color::palettes::css::YELLOW, prelude::*}; use bevy_egui::{EguiContexts, egui}; +use crate::fighter::animation::FighterAnimationFrame; +use crate::fighter::baked_animation::BakedFighterAnimations; use crate::fighter::ecb::FighterPreviousECB; +use crate::fighter::manifest::{FighterManifest, FighterManifestRegistry}; use crate::fighter::state::StateNameDebug; -use crate::fighter::{FighterECB, FighterPreviousTranslation, FighterTranslation, FighterVelocity}; +use crate::fighter::visual::FighterAnimations; +use crate::fighter::{Fighter, FighterECB, FighterFacingDirection, FighterPreviousTranslation, FighterTranslation, FighterVelocity}; use crate::input::FighterInput; +use crate::match_loading::MatchPlayer; pub fn debug_draw_ecb( ecbs: Query<( @@ -30,22 +35,42 @@ pub fn debug_draw_ecb( gizmos.lineloop(prev_transformed, ORANGE); } } - pub fn fighter_debug( mut contexts: EguiContexts, query: Query<(&FighterVelocity, &FighterInput, &StateNameDebug)>, ) -> Result { - for (vel, input, name) in query { - egui::Window::new("Hello").show(contexts.ctx_mut()?, |ui| { + for (i, (vel, input, name)) in query.iter().enumerate() { + egui::Window::new(format!("Player {}", i)).show(contexts.ctx_mut()?, |ui| { ui.label(format!("State: {}", name.0)); ui.label(format!("{:?}", vel)); ui.label(format!("{:?}", input.get_last_frame())); }); - } + } Ok(()) } +pub fn animation_debug( + query: Query<( + &GlobalTransform, + &FighterAnimationFrame, + &FighterAnimations, + )>, + baked_anims: Res>, + mut gizmos: Gizmos, +) { + for (global_transform, frame, anims) in query { + let baked: &BakedFighterAnimations = baked_anims.get(&anims.baked).expect("Baked anims should be in"); + for bone in &baked.bone_names { + let out = anims.sample_bone(&baked_anims, *frame, bone); + if let Some(out) = out { + let pos = global_transform.transform_point(Vec3::new(out.cols[3][0].to_num(), out.cols[3][1].to_num(), out.cols[3][2].to_num())); + gizmos.cross(pos, 0.5, bevy::color::palettes::css::RED); + } + } + } +} + pub fn update_config( mut config_store: ResMut, keyboard: Res>, diff --git a/src/fighter/ecb.rs b/src/fighter/ecb.rs index 219140d..edba154 100644 --- a/src/fighter/ecb.rs +++ b/src/fighter/ecb.rs @@ -36,29 +36,29 @@ pub fn snapshot_fighter_ecb(fighters: Query<(&FighterECB, &mut FighterPreviousEC impl FighterECB { pub fn to_3d_lineloop(&self, trf: Transform) -> [Vec3; 4] { - let rel_up = trf.transform_point(Vec3::new(0.0, self.vertical_half.to_num(), 0.0)); - let rel_down = trf.transform_point(Vec3::new(0.0, (-self.vertical_half).to_num(), 0.0)); - let rel_left = trf.transform_point(Vec3::new((-self.horizontal_half).to_num(), 0.0, 0.0)); - let rel_right = trf.transform_point(Vec3::new(self.horizontal_half.to_num(), 0.0, 0.0)); + let rel_up = trf.transform_point(Vec3::new(0.0, (self.vertical_half + self.vertical_half).to_num(), 0.0)); + let rel_down = trf.transform_point(Vec3::new(0.0, 0.0, 0.0)); + let rel_left = trf.transform_point(Vec3::new((-self.horizontal_half).to_num(), self.vertical_half.to_num(), 0.0)); + let rel_right = trf.transform_point(Vec3::new(self.horizontal_half.to_num(), self.vertical_half.to_num(), 0.0)); [rel_up, rel_right, rel_down, rel_left] } pub fn get_bottom_point(&self) -> FGVec2 { - FGVec2::new(FGi32::ZERO, -self.vertical_half) + FGVec2::new(FGi32::ZERO, FGi32::ZERO) } } impl FighterPreviousECB { pub fn to_3d_lineloop(&self, trf: Transform) -> [Vec3; 4] { - let rel_up = trf.transform_point(Vec3::new(0.0, self.vertical_half.to_num(), 0.0)); - let rel_down = trf.transform_point(Vec3::new(0.0, (-self.vertical_half).to_num(), 0.0)); - let rel_left = trf.transform_point(Vec3::new((-self.horizontal_half).to_num(), 0.0, 0.0)); - let rel_right = trf.transform_point(Vec3::new(self.horizontal_half.to_num(), 0.0, 0.0)); + let rel_up = trf.transform_point(Vec3::new(0.0, (self.vertical_half + self.vertical_half).to_num(), 0.0)); + let rel_down = trf.transform_point(Vec3::new(0.0, 0.0, 0.0)); + let rel_left = trf.transform_point(Vec3::new((-self.horizontal_half).to_num(), self.vertical_half.to_num(), 0.0)); + let rel_right = trf.transform_point(Vec3::new(self.horizontal_half.to_num(), self.vertical_half.to_num(), 0.0)); [rel_up, rel_right, rel_down, rel_left] } pub fn get_bottom_point(&self) -> FGVec2 { - FGVec2::new(FGi32::ZERO, -self.vertical_half) + FGVec2::new(FGi32::ZERO, FGi32::ZERO) } } diff --git a/src/fighter/manifest.rs b/src/fighter/manifest.rs index 10c2f95..9f6b3fd 100644 --- a/src/fighter/manifest.rs +++ b/src/fighter/manifest.rs @@ -21,6 +21,7 @@ pub enum FighterId { pub struct FighterManifest { pub id: FighterId, pub model_path: String, + pub baked_animation_path: String, pub attributes: FighterAttributes, pub camera: FighterCameraProfile, pub animations: HashMap, @@ -87,6 +88,13 @@ pub fn prepare_fighter_manifests( ); return; } + if manifest.baked_animation_path.is_empty() { + fail( + &mut next_state, + &format!("{:?} has an empty baked animation path", manifest.id), + ); + return; + } if !manifest.animations.contains_key(&AnimKind::Wait) { fail( &mut next_state, diff --git a/src/fighter/mod.rs b/src/fighter/mod.rs index 14efa88..38a2ed9 100644 --- a/src/fighter/mod.rs +++ b/src/fighter/mod.rs @@ -7,6 +7,7 @@ use bevy_ggrs::prelude::*; use serde::{Deserialize, Serialize}; pub mod animation; +pub mod baked_animation; pub mod attributes; pub mod collision; pub mod debug; @@ -27,15 +28,8 @@ pub use motion::{FighterPreviousTranslation, FighterTranslation, FighterVelocity use crate::{ fighter::{ - animation::animation_init, - state::{FighterState, fall::FallState}, - visual::FighterAnimations, - }, - input::FighterInput, - math::{int::FGi32, vec::FGVec2}, - player::Player, - schedule::GameplaySet, - stage::line::StageCollision, + animation::animation_init, manifest::FighterManifest, state::{FighterState, fall::FallState}, visual::FighterAnimations, + }, input::FighterInput, math::{int::FGi32, vec::FGVec2}, player::Player, schedule::GameplaySet, stage::line::StageCollision, }; use state::state_interrupt_system; @@ -49,7 +43,17 @@ pub enum FighterFacingDirection { Left, Right, } -#[derive(Component, Copy, Clone, Debug, Reflect, Serialize, Deserialize)] + +impl FighterFacingDirection { + fn reverse(&self) -> Self { + match self { + FighterFacingDirection::Left => FighterFacingDirection::Right, + FighterFacingDirection::Right => FighterFacingDirection::Left, + } + } +} + +#[derive(Copy, Clone, Debug, Reflect, Serialize, Deserialize)] #[reflect(opaque)] pub struct FighterCameraProfile { pub vertical_origin_offset: FGi32, @@ -84,6 +88,12 @@ impl FighterFacingDirection { #[require(Transform)] pub struct FighterVisual; +#[derive(Component)] +pub struct Fighter { + id: FighterId, + pub manifest: Handle +} + impl Plugin for FighterPlugin { fn build(&self, app: &mut App) { app.add_systems( @@ -95,6 +105,7 @@ impl Plugin for FighterPlugin { GgrsSchedule, ( ecb::snapshot_fighter_ecb.before(state_interrupt_system), + animation::advance_fighter_animation_frames.before(state_interrupt_system), state::state_interrupt_system, ) .in_set(GameplaySet::Interrupt), @@ -130,15 +141,19 @@ impl Plugin for FighterPlugin { .rollback_component_with_copy::() .rollback_component_with_copy::() .rollback_component_with_copy::() - .rollback_component_with_copy::() + .rollback_component_with_copy::() + .checksum_component_with_hash::() .rollback_component_with_copy::() + .rollback_component_with_copy::() .rollback_resource_with_reflect::() // Debug views. .add_systems(FixedPostUpdate, debug::debug_draw_ecb) .add_systems(EguiPrimaryContextPass, debug::fighter_debug) + .add_systems(FixedPostUpdate, debug::animation_debug) .add_systems(EguiPrimaryContextPass, debug::update_config) .rollback_component_with_clone::() .checksum_component_with_hash::() + .checksum_component_with_hash::() .register_type::(); } } @@ -147,8 +162,8 @@ pub fn spawn_fighter( commands: &mut Commands, player_handle: usize, spawn_position: FGVec2, + manifest: (Handle, &FighterManifest), attributes: FighterAttributes, - camera_profile: FighterCameraProfile, animations: FighterAnimations, visual_root: WorldAssetRoot, ) { @@ -157,6 +172,10 @@ pub fn spawn_fighter( Player { handle: player_handle, }, + Fighter { + id: manifest.1.id, + manifest: manifest.0 + }, FighterFacingDirection::Right, FighterECB { vertical_half: FGi32::lit("5.0"), @@ -164,11 +183,10 @@ pub fn spawn_fighter( }, FighterVelocity::default(), FighterTranslation(spawn_position), - attributes, - camera_profile, visual_root, animations, FighterState::Fall(FallState), + animation::FighterAnimationFrame::new(animation::AnimKind::Wait, true), FighterVisual, FighterInput::default(), )); diff --git a/src/fighter/state/air.rs b/src/fighter/state/air.rs index ca8682c..e4fd906 100644 --- a/src/fighter/state/air.rs +++ b/src/fighter/state/air.rs @@ -7,7 +7,11 @@ pub fn integrate_gravity(velocity: &mut FighterVelocity, attributes: &FighterAtt velocity.y = velocity.y.max(-attributes.terminal_velocity); } -pub fn apply_air_drift(velocity: &mut FighterVelocity, attribs: &FighterAttributes, input: &FighterInput) { +pub fn apply_air_drift( + velocity: &mut FighterVelocity, + attribs: &FighterAttributes, + input: &FighterInput, +) { if input.get_last_frame().movement.x.is_zero() { if velocity.x.abs() < attribs.air_friction { velocity.x = FGi32::ZERO; @@ -16,9 +20,12 @@ pub fn apply_air_drift(velocity: &mut FighterVelocity, attribs: &FighterAttribut velocity.x += (-velocity_sign) * attribs.air_friction; } } else { - let target_velocity = input.get_last_frame().movement.x.signum() * attribs.max_air_horizontal_velocity; - let acceleration = input.get_last_frame().movement.x.signum() * attribs.air_acceleration_base; - let mut acceleration = acceleration + input.get_last_frame().movement.x * attribs.air_acceleration_stick; + let target_velocity = + input.get_last_frame().movement.x.signum() * attribs.max_air_horizontal_velocity; + let acceleration = + input.get_last_frame().movement.x.signum() * attribs.air_acceleration_base; + let mut acceleration = + acceleration + input.get_last_frame().movement.x * attribs.air_acceleration_stick; let velocity_x = velocity.x; if !(velocity_x * acceleration < FGi32::ZERO) { if acceleration > FGi32::ZERO { @@ -46,13 +53,11 @@ pub fn apply_air_drift(velocity: &mut FighterVelocity, attribs: &FighterAttribut } } - pub fn apply_air_motion( velocity: &FighterVelocity, translation: &mut FighterTranslation, prev_translation: &mut FighterPreviousTranslation, ) { - // Vertical velocity should be 0 on the ground prev_translation.0 = translation.0; translation.0 += velocity.0; diff --git a/src/fighter/state/air_dodge.rs b/src/fighter/state/air_dodge.rs index e9e8c3c..d4f5073 100644 --- a/src/fighter/state/air_dodge.rs +++ b/src/fighter/state/air_dodge.rs @@ -14,7 +14,9 @@ use crate::{ wait::WaitState, walk, }, - }, input, math::vec::FGVec2, + }, + input, + math::vec::FGVec2, }; #[derive(Debug, Clone, Hash)] @@ -26,8 +28,8 @@ pub struct AirDodgeState { impl FighterStateImpl for AirDodgeState { const NAME: &'static str = "AirDodge"; - fn check_interrupt(&self, state_context: &FighterStateContext) -> Option { - if self.duration_counter >= state_context.fighter_attribs.air_dodge_duration { + fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option { + if self.duration_counter >= state_context.fighter_manifest.attributes.air_dodge_duration { Some(FighterState::Fall(FallState)) } else { None @@ -37,7 +39,7 @@ impl FighterStateImpl for AirDodgeState { fn update(&mut self, state_context: &mut FighterStateContext) { self.duration_counter += 1; - state_context.velocity.0 *= state_context.fighter_attribs.air_dodge_decay; + state_context.velocity.0 *= state_context.fighter_manifest.attributes.air_dodge_decay; air::apply_air_motion( state_context.velocity, @@ -66,13 +68,10 @@ impl FighterStateImpl for AirDodgeState { } fn on_enter(&mut self, state_context: &mut FighterStateContext) { - state_context.animation_transitions.play( - state_context.animation_player, - state_context.animations.clips[&AnimKind::AirDodge], - Duration::ZERO, - ); + state_context.play_animation(AnimKind::AirDodge, false); - state_context.velocity.0 = self.direction * state_context.fighter_attribs.air_dodge_velocity; + state_context.velocity.0 = + self.direction * state_context.fighter_manifest.attributes.air_dodge_velocity; } } diff --git a/src/fighter/state/dash.rs b/src/fighter/state/dash.rs index d72e03a..c95f89f 100644 --- a/src/fighter/state/dash.rs +++ b/src/fighter/state/dash.rs @@ -4,11 +4,11 @@ use std::time::Duration; use bevy::prelude::*; use super::{FighterStateContext, FighterStateImpl, ground, run, wait, walk}; +use crate::fighter::FighterFacingDirection; use crate::fighter::animation::AnimKind; use crate::fighter::state::FighterState; use crate::fighter::state::fall::FallState; use crate::fighter::state::ground::{GroundedMotionResult, GroundedStateCommon}; -use crate::fighter::FighterFacingDirection; use crate::input::FighterCommands::{SmashMoveLeft, SmashMoveRight}; #[derive(Debug, Clone)] @@ -37,9 +37,9 @@ impl DashState { impl FighterStateImpl for DashState { const NAME: &'static str = "Dash"; - fn check_interrupt(&self, ctx: &FighterStateContext) -> Option { + fn check_interrupt(&self, ctx: &mut FighterStateContext) -> Option { let ground = &self.ground_common; - let attributes = ctx.fighter_attribs; + let attributes = ctx.fighter_manifest.attributes; // A reverse dash can interrupt before IASA if let Some(direction) = @@ -70,42 +70,48 @@ impl FighterStateImpl for DashState { fn on_enter(&mut self, state_context: &mut FighterStateContext) { *state_context.facing_direction = self.direction; - state_context.velocity.x = - state_context.fighter_attribs.dash_initial_velocity * self.direction.to_sign(); + state_context.velocity.x = state_context + .fighter_manifest + .attributes + .dash_initial_velocity + * self.direction.to_sign(); - state_context.animation_transitions.play( - &mut state_context.animation_player, - state_context.animations.clips[&AnimKind::Dash], - Duration::ZERO, - ); + state_context.play_animation(AnimKind::Dash, false); match state_context.facing_direction { FighterFacingDirection::Left => { - state_context.input.clear_command(crate::input::FighterCommands::SmashMoveLeft); - }, + state_context + .input + .clear_command(crate::input::FighterCommands::SmashMoveLeft); + } FighterFacingDirection::Right => { - state_context.input.clear_command(crate::input::FighterCommands::SmashMoveRight); - }, + state_context + .input + .clear_command(crate::input::FighterCommands::SmashMoveRight); + } } - } fn update(&mut self, state_context: &mut FighterStateContext) { self.frames_in_dash += 1; - if self.frames_in_dash < state_context.fighter_attribs.dash_acceleration_duration { - - } + if self.frames_in_dash + < state_context + .fighter_manifest + .attributes + .dash_acceleration_duration + {} let last_frame = state_context.input.get_last_frame(); let (accel, target_vel) = state_context - .fighter_attribs + .fighter_manifest + .attributes .get_accel_and_target_dashrun(&last_frame, self.direction.to_sign()); let accel = ground::compute_ground_accel( accel, target_vel, state_context.velocity.x, - state_context.fighter_attribs, + &state_context.fighter_manifest.attributes, state_context.game_settings, ); state_context.velocity.x += accel; @@ -131,7 +137,9 @@ impl FighterStateImpl for DashState { } } -fn check_smash_input_with_dir(state_context: &FighterStateContext) -> Option { +fn check_smash_input_with_dir( + state_context: &FighterStateContext, +) -> Option { if state_context.input.has_command(SmashMoveLeft) { return Some(FighterFacingDirection::Left); } else if state_context.input.has_command(SmashMoveRight) { diff --git a/src/fighter/state/fall.rs b/src/fighter/state/fall.rs index 65f4535..4dccf98 100644 --- a/src/fighter/state/fall.rs +++ b/src/fighter/state/fall.rs @@ -10,23 +10,22 @@ pub struct FallState; impl FighterStateImpl for FallState { const NAME: &'static str = "Fall"; - fn check_interrupt(&self, state_context: &FighterStateContext) -> Option { + fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option { jump::check_input_double_jump(state_context) } fn on_enter(&mut self, state_context: &mut super::FighterStateContext) { - state_context.animation_transitions.play( - &mut state_context.animation_player, - state_context.animations.clips[&AnimKind::Fall], - std::time::Duration::ZERO, - ).repeat(); + state_context.play_animation(AnimKind::Fall, true); } fn update(&mut self, state_context: &mut super::FighterStateContext) { - air::integrate_gravity(state_context.velocity, state_context.fighter_attribs); + air::integrate_gravity( + state_context.velocity, + &state_context.fighter_manifest.attributes, + ); air::apply_air_drift( state_context.velocity, - state_context.fighter_attribs, + &state_context.fighter_manifest.attributes, state_context.input, ); air::apply_air_motion( diff --git a/src/fighter/state/ground.rs b/src/fighter/state/ground.rs index 083f37c..f708d9f 100644 --- a/src/fighter/state/ground.rs +++ b/src/fighter/state/ground.rs @@ -2,16 +2,14 @@ //! 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}; +use crate::fighter::state::{FighterState, FighterStateContext, dash, turn, wait, walk}; use crate::fighter::{ FighterAttributes, FighterPreviousTranslation, FighterTranslation, FighterVelocity, }; use crate::game_settings::GameSettings; use crate::math::int::FGi32; use crate::math::vec::FGVec2; -use crate::stage::line::{ - StageLineID, StagePolyLineSegmentType, -}; +use crate::stage::line::{StageLineID, StagePolyLineSegmentType}; use bevy::prelude::*; use fixed::traits::Fixed; @@ -149,3 +147,20 @@ pub fn grounded_movement_common_interrupts( ) -> Option { super::jump::check_input(state_context, ground_common) } + +pub fn grounded_movement_standstill_common_interrupts( + state_context: &FighterStateContext, + ground_common: &GroundedStateCommon, +) -> Option { + if let Some(state) = dash::check_input(state_context, &ground_common) { + Some(state) + } else if let Some(state) = turn::check_input(state_context, &ground_common) { + Some(state) + } else if let Some(state) = walk::check_input(state_context, &ground_common) { + Some(state) + } else if let Some(state) = wait::check_input(state_context, &ground_common) { + Some(state) + } else { + None + } +} diff --git a/src/fighter/state/jump.rs b/src/fighter/state/jump.rs index 871c911..49fa736 100644 --- a/src/fighter/state/jump.rs +++ b/src/fighter/state/jump.rs @@ -47,10 +47,11 @@ impl JumpSquatState { impl FighterStateImpl for JumpSquatState { const NAME: &'static str = "JumpSquat"; - fn check_interrupt(&self, state_context: &FighterStateContext) -> Option { + fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option { // Special handling for wavedash from jump squat if state_context.input.has_command(FighterCommands::Shield) { // If aiming sideways and in the deadzone, do a perfect wavedash + // TODO: Improve this so we don't have to go through the airborne state first let mut wavedash_dir = state_context .input .get_last_frame() @@ -71,7 +72,9 @@ impl FighterStateImpl for JumpSquatState { duration_counter: 0, direction: wavedash_dir.normalize_or_zero(), })) - } else if self.duration_counter >= state_context.fighter_attribs.jumpsquat_duration { + } else if self.duration_counter + >= state_context.fighter_manifest.attributes.jumpsquat_duration + { info!("Jump {}", state_context.input.get_last_frame().jump); let jump_type = if state_context.input.get_last_frame().jump { JumpType::LongJump @@ -87,16 +90,17 @@ impl FighterStateImpl for JumpSquatState { fn update(&mut self, state_context: &mut FighterStateContext) { self.duration_counter += 1; - let friction = - if state_context.velocity.x.abs() > state_context.fighter_attribs.max_walk_vel { - state_context.fighter_attribs.ground_friction - * state_context - .game_settings - .fighter_common - .ground_friction_over_walk_speed_multiplier - } else { - state_context.fighter_attribs.ground_friction - }; + let friction = if state_context.velocity.x.abs() + > state_context.fighter_manifest.attributes.max_walk_vel + { + state_context.fighter_manifest.attributes.ground_friction + * state_context + .game_settings + .fighter_common + .ground_friction_over_walk_speed_multiplier + } else { + state_context.fighter_manifest.attributes.ground_friction + }; let ground_velocity = state_context.velocity.x; @@ -123,18 +127,14 @@ impl FighterStateImpl for JumpSquatState { } fn on_enter(&mut self, state_context: &mut FighterStateContext) { - state_context.animation_transitions.play( - state_context.animation_player, - state_context.animations.clips[&AnimKind::JumpSquat], - Duration::ZERO, - ); + state_context.play_animation(AnimKind::JumpSquat, false); state_context.input.clear_command(FighterCommands::Jump); } } impl FighterStateImpl for JumpState { const NAME: &'static str = "Jump"; - fn check_interrupt(&self, state_context: &FighterStateContext) -> Option { + fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option { if state_context.input.has_command(FighterCommands::Jump) { Some(FighterState::Jump(JumpState { jump_type: JumpType::DoubleJump, @@ -145,10 +145,13 @@ impl FighterStateImpl for JumpState { } fn update(&mut self, state_context: &mut FighterStateContext) { - air::integrate_gravity(state_context.velocity, state_context.fighter_attribs); + air::integrate_gravity( + state_context.velocity, + &state_context.fighter_manifest.attributes, + ); air::apply_air_drift( state_context.velocity, - state_context.fighter_attribs, + &state_context.fighter_manifest.attributes, state_context.input, ); air::apply_air_motion( @@ -168,51 +171,64 @@ impl FighterStateImpl for JumpState { if movement_stick_x.is_zero() || movement_stick_x.signum() == state_context.facing_direction.to_sign() { - state_context.animation_transitions.play( - state_context.animation_player, - state_context.animations.clips[&AnimKind::JumpForward], - Duration::ZERO, - ); + state_context.play_animation(AnimKind::JumpForward, false); } else { - state_context.animation_transitions.play( - state_context.animation_player, - state_context.animations.clips[&AnimKind::JumpBack], - Duration::ZERO, - ); + state_context.play_animation(AnimKind::JumpBack, false); } } JumpType::DoubleJump => { - state_context.animation_transitions.play( - state_context.animation_player, - state_context.animations.clips[&AnimKind::DoubleJump], - Duration::ZERO, - ); + state_context.play_animation(AnimKind::DoubleJump, false); } } let vertical_vel = match self.jump_type { - JumpType::ShortHop => state_context.fighter_attribs.short_hop_vertical_velocity, - JumpType::LongJump => state_context.fighter_attribs.full_jump_vertical_velocity, + JumpType::ShortHop => { + state_context + .fighter_manifest + .attributes + .short_hop_vertical_velocity + } + JumpType::LongJump => { + state_context + .fighter_manifest + .attributes + .full_jump_vertical_velocity + } JumpType::DoubleJump => { - state_context.fighter_attribs.full_jump_vertical_velocity - * state_context.fighter_attribs.air_jump_multiplier + state_context + .fighter_manifest + .attributes + .full_jump_vertical_velocity + * state_context + .fighter_manifest + .attributes + .air_jump_multiplier } }; match self.jump_type { JumpType::ShortHop | JumpType::LongJump => { - state_context.velocity.x += - state_context.fighter_attribs.jump_horizontal_velocity * movement_stick_x; + state_context.velocity.x += state_context + .fighter_manifest + .attributes + .jump_horizontal_velocity + * movement_stick_x; } JumpType::DoubleJump => { - state_context.velocity.x = - state_context.fighter_attribs.air_jump_horizontal_velocity * movement_stick_x; + state_context.velocity.x = state_context + .fighter_manifest + .attributes + .air_jump_horizontal_velocity + * movement_stick_x; } }; state_context.velocity.y = vertical_vel; - state_context.velocity.x += - state_context.fighter_attribs.jump_horizontal_velocity * movement_stick_x; + state_context.velocity.x += state_context + .fighter_manifest + .attributes + .jump_horizontal_velocity + * movement_stick_x; } fn check_collision_interrupt( @@ -245,13 +261,13 @@ pub fn check_input( .then(|| FighterState::JumpSquat(JumpSquatState::create(ground_common.clone()))) } -pub fn check_input_double_jump( - state_context: &FighterStateContext -) -> Option { +pub fn check_input_double_jump(state_context: &FighterStateContext) -> Option { state_context .input .has_command(FighterCommands::Jump) - .then(|| FighterState::Jump(JumpState{ - jump_type: JumpType::DoubleJump - })) + .then(|| { + FighterState::Jump(JumpState { + jump_type: JumpType::DoubleJump, + }) + }) } diff --git a/src/fighter/state/land.rs b/src/fighter/state/land.rs index 43d25f1..dbc98df 100644 --- a/src/fighter/state/land.rs +++ b/src/fighter/state/land.rs @@ -3,15 +3,18 @@ use std::time::Duration; use bevy::prelude::*; use crate::fighter::{ - animation::AnimKind, - state::{ - FighterState, FighterStateContext, FighterStateImpl, dash, - fall::FallState, - ground::{self, GroundedMotionResult, GroundedStateCommon}, - wait::WaitState, - walk, + animation::AnimKind, + state::{ + FighterState, FighterStateContext, FighterStateImpl, dash, + fall::FallState, + ground::{ + self, GroundedMotionResult, GroundedStateCommon, + grounded_movement_standstill_common_interrupts, }, - }; + wait::WaitState, + walk, + }, +}; #[derive(Debug, Clone, Hash)] pub struct LandingState { @@ -31,12 +34,10 @@ impl LandingState { impl FighterStateImpl for LandingState { const NAME: &'static str = "Landing"; - fn check_interrupt(&self, state_context: &FighterStateContext) -> Option { - if self.duration_counter >= state_context.fighter_attribs.landing_duration { - Some(FighterState::Wait(WaitState { - grounded_common: self.grounded_common.clone(), - })) - } else if self.duration_counter >= state_context.fighter_attribs.landing_iasa { + fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option { + if state_context.is_current_animation_finished() { + grounded_movement_standstill_common_interrupts(state_context, &self.grounded_common) + } else if self.duration_counter >= 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)) @@ -49,16 +50,17 @@ impl FighterStateImpl for LandingState { fn update(&mut self, state_context: &mut FighterStateContext) { self.duration_counter += 1; - let friction = - if state_context.velocity.x.abs() > state_context.fighter_attribs.max_walk_vel { - state_context.fighter_attribs.ground_friction - * state_context - .game_settings - .fighter_common - .ground_friction_over_walk_speed_multiplier - } else { - state_context.fighter_attribs.ground_friction - }; + let friction = if state_context.velocity.x.abs() + > state_context.fighter_manifest.attributes.max_walk_vel + { + state_context.fighter_manifest.attributes.ground_friction + * state_context + .game_settings + .fighter_common + .ground_friction_over_walk_speed_multiplier + } else { + state_context.fighter_manifest.attributes.ground_friction + }; let ground_velocity = state_context.velocity.x; @@ -85,10 +87,6 @@ impl FighterStateImpl for LandingState { } fn on_enter(&mut self, state_context: &mut FighterStateContext) { - state_context.animation_transitions.play( - state_context.animation_player, - state_context.animations.clips[&AnimKind::Landing], - Duration::ZERO, - ); + state_context.play_animation(AnimKind::Landing, false); } } diff --git a/src/fighter/state/mod.rs b/src/fighter/state/mod.rs index 9ee1cff..32ff01d 100644 --- a/src/fighter/state/mod.rs +++ b/src/fighter/state/mod.rs @@ -5,26 +5,30 @@ use bevy::ecs::query::QueryData; use bevy::prelude::*; -use crate::fighter::animation::FighterAnimationPlayerLink; +use crate::fighter::animation::{AnimKind, FighterAnimationFrame, FighterAnimationPlayerLink}; +use crate::fighter::baked_animation::{BakedFighterAnimations, FixedMat4}; use crate::fighter::ecb::FighterPreviousECB; +use crate::fighter::manifest::FighterManifest; use crate::fighter::visual::FighterAnimations; use crate::fighter::{ - FighterAttributes, FighterECB, FighterFacingDirection, FighterPreviousTranslation, FighterTranslation, FighterVelocity, + Fighter, FighterAttributes, FighterECB, FighterFacingDirection, FighterPreviousTranslation, + FighterTranslation, FighterVelocity, }; use crate::game_settings::GameSettings; use crate::input::FighterInput; use crate::stage::line::StageCollision; pub mod air; +pub mod air_dodge; pub mod dash; pub mod fall; pub mod ground; pub mod jump; +pub mod land; pub mod run; +pub mod turn; pub mod wait; pub mod walk; -pub mod land; -pub mod air_dodge; macro_rules! fighter_states { ($($variant:ident => $ty:ty),* $(,)?) => { @@ -35,7 +39,7 @@ macro_rules! fighter_states { pub fn name(&self) -> &'static str { match self { $(Self::$variant(_) => <$ty>::NAME),* } } - pub fn check_interrupt(&self, ctx: &FighterStateContext) -> Option { + pub fn check_interrupt(&self, ctx: &mut FighterStateContext) -> Option { match self { $(Self::$variant(s) => s.check_interrupt(ctx)),* } } @@ -59,7 +63,7 @@ macro_rules! fighter_states { pub trait FighterStateImpl: Sized { const NAME: &'static str; - fn check_interrupt(&self, state_context: &FighterStateContext) -> Option; + fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option; fn on_enter(&mut self, _state_context: &mut FighterStateContext) {} fn update(&mut self, _state_context: &mut FighterStateContext); @@ -79,13 +83,49 @@ pub struct FighterStateContext<'a> { pub velocity: &'a mut FighterVelocity, pub ecb: &'a mut FighterECB, pub prev_ecb: &'a mut FighterPreviousECB, - pub fighter_attribs: &'a FighterAttributes, pub facing_direction: &'a mut FighterFacingDirection, pub game_settings: &'a GameSettings, pub stage_collision: &'a StageCollision, pub animations: &'a FighterAnimations, pub animation_player: &'a mut AnimationPlayer, pub animation_transitions: &'a mut AnimationTransitions, + pub animation_frame: &'a mut FighterAnimationFrame, + pub baked_animations: &'a Assets, + pub fighter_manifest: &'a FighterManifest, +} + +impl FighterStateContext<'_> { + pub fn play_animation(&mut self, kind: AnimKind, repeat: bool) { + let active = self.animation_transitions.play( + self.animation_player, + self.animations.clips[&kind], + std::time::Duration::ZERO, + ); + if repeat { + active.repeat(); + } + self.animation_frame.reset(kind, repeat); + } + + pub fn get_current_animation_frame(&self) -> u32 { + self.animation_frame.frame + } + + pub fn is_current_animation_finished(&self) -> bool { + let anims_data = self + .baked_animations + .get(&self.animations.baked) + .expect("Animation should exist"); + anims_data + .frame_count(self.animation_frame.kind) + .expect("Current animation should have baked frames") + < self.animation_frame.frame + } + + pub fn sample_bone(&self, bone: &str) -> Option { + self.animations + .sample_bone(self.baked_animations, *self.animation_frame, bone) + } } fighter_states! { @@ -97,7 +137,8 @@ fighter_states! { JumpSquat => jump::JumpSquatState, Jump => jump::JumpState, Land => land::LandingState, - AirDodge => air_dodge::AirDodgeState + AirDodge => air_dodge::AirDodgeState, + Turn => turn::TurnState } #[derive(QueryData)] @@ -110,7 +151,8 @@ pub struct FighterFrameQuery { pub ecb: &'static mut FighterECB, pub prev_ecb: &'static mut FighterPreviousECB, pub facing_direction: &'static mut FighterFacingDirection, - pub attributes: &'static FighterAttributes, + pub animation_frame: &'static mut FighterAnimationFrame, + pub fighter: &'static Fighter, } impl<'w, 's> FighterFrameQueryItem<'w, 's> { @@ -121,6 +163,8 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { animations: &'a FighterAnimations, animation_player: &'a mut AnimationPlayer, animation_transitions: &'a mut AnimationTransitions, + baked_animations: &'a Assets, + manifest: &'a FighterManifest, ) -> FighterStateContext<'a> { FighterStateContext { translation: &mut self.translation, @@ -130,12 +174,14 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { ecb: &mut self.ecb, prev_ecb: &mut self.prev_ecb, facing_direction: &mut self.facing_direction, - fighter_attribs: self.attributes, game_settings, stage_collision, animations, animation_player, animation_transitions, + animation_frame: &mut self.animation_frame, + baked_animations, + fighter_manifest: manifest, } } } @@ -148,6 +194,7 @@ pub struct StatefulFighterQuery { pub frame: FighterFrameQuery, pub animations: &'static FighterAnimations, pub animation_player_link: &'static FighterAnimationPlayerLink, + pub fighter: &'static Fighter, } #[derive(Component)] @@ -164,6 +211,8 @@ pub fn state_update_system( mut animation_players: Query<(&mut AnimationPlayer, &mut AnimationTransitions)>, stage_collision: Res, game_settings: Res, + baked_animations: Res>, + manifests: Res>, ) { for fighter in &mut query { let StatefulFighterQueryItem { @@ -178,12 +227,17 @@ pub fn state_update_system( else { continue; }; + let mut update_ctx = frame.context( &game_settings, &stage_collision, animations, &mut animation_player, &mut animation_transitions, + &baked_animations, + &manifests + .get(&(fighter.fighter.manifest)) + .expect("Fighter manifest should be valid"), ); state.update(&mut update_ctx); } @@ -194,6 +248,8 @@ pub fn state_interrupt_system( mut animation_players: Query<(&mut AnimationPlayer, &mut AnimationTransitions)>, stage_collision: Res, game_settings: Res, + baked_animations: Res>, + fighter_manifests: Res>, mut commands: Commands, ) { for fighter in &mut query { @@ -203,6 +259,7 @@ pub fn state_interrupt_system( mut frame, animations, animation_player_link, + fighter, } = fighter; let Ok((mut animation_player, mut animation_transitions)) = animation_players.get_mut(animation_player_link.player()) @@ -215,8 +272,12 @@ pub fn state_interrupt_system( animations, &mut animation_player, &mut animation_transitions, + &baked_animations, + fighter_manifests + .get(&fighter.manifest) + .expect("Fighter manifest should be valid"), ); - if let Some(new_state) = state.check_interrupt(&state_context) { + if let Some(new_state) = state.check_interrupt(&mut state_context) { let mut new_state = new_state; let old_state_name = state.name(); let new_state_name = new_state.name(); @@ -234,6 +295,8 @@ pub fn state_collision_interrupt_system( mut animation_players: Query<(&mut AnimationPlayer, &mut AnimationTransitions)>, stage_collision: Res, game_settings: Res, + baked_animations: Res>, + fighter_manifests: Res>, mut commands: Commands, ) { for fighter in &mut query { @@ -243,6 +306,7 @@ pub fn state_collision_interrupt_system( mut frame, animations, animation_player_link, + fighter, } = fighter; let Ok((mut animation_player, mut animation_transitions)) = animation_players.get_mut(animation_player_link.player()) @@ -255,6 +319,10 @@ pub fn state_collision_interrupt_system( animations, &mut animation_player, &mut animation_transitions, + &baked_animations, + fighter_manifests + .get(&fighter.manifest) + .expect("Fighter manifest should be valid"), ); if let Some(new_state) = state.check_collision_interrupt(&mut state_context) { let mut new_state = new_state; diff --git a/src/fighter/state/run.rs b/src/fighter/state/run.rs index 82b1434..629b5a5 100644 --- a/src/fighter/state/run.rs +++ b/src/fighter/state/run.rs @@ -16,8 +16,10 @@ pub struct RunState { impl FighterStateImpl for RunState { const NAME: &'static str = "Run"; - fn check_interrupt(&self, state_context: &FighterStateContext) -> Option { - if let Some(state) = ground::grounded_movement_common_interrupts(state_context, &self.grounded_common) { + fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option { + if let Some(state) = + ground::grounded_movement_common_interrupts(state_context, &self.grounded_common) + { return Some(state); } @@ -33,26 +35,20 @@ impl FighterStateImpl for RunState { } fn on_enter(&mut self, state_context: &mut super::FighterStateContext) { - state_context - .animation_transitions - .play( - &mut state_context.animation_player, - state_context.animations.clips[&AnimKind::Run], - Duration::ZERO, - ) - .repeat(); + state_context.play_animation(AnimKind::Run, true); } fn update(&mut self, state_context: &mut super::FighterStateContext) { let last_frame = state_context.input.get_last_frame(); let (accel, target_vel) = state_context - .fighter_attribs + .fighter_manifest + .attributes .get_accel_and_target_dashrun(&last_frame, FGi32::ONE); let accel = ground::compute_ground_accel( accel, target_vel, state_context.velocity.x, - state_context.fighter_attribs, + &state_context.fighter_manifest.attributes, state_context.game_settings, ); state_context.velocity.x += accel; diff --git a/src/fighter/state/turn.rs b/src/fighter/state/turn.rs new file mode 100644 index 0000000..492f9a5 --- /dev/null +++ b/src/fighter/state/turn.rs @@ -0,0 +1,92 @@ +use super::{FighterStateContext, FighterStateImpl}; +use crate::fighter::animation::AnimKind; +use crate::fighter::collision; +use crate::fighter::state::fall::FallState; +use crate::fighter::state::ground::{self, GroundedMotionResult, GroundedStateCommon}; +use crate::fighter::state::land::LandingState; +use crate::fighter::state::{FighterState, air, dash, jump, wait, walk}; + +#[derive(Debug, Clone, Hash)] +pub struct TurnState { + grounded_common: GroundedStateCommon, +} + +impl FighterStateImpl for TurnState { + const NAME: &'static str = "Turn"; + fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option { + // State chain that results in the turn completing + if let Some(state) = jump::check_input(state_context, &self.grounded_common) { + Some(state) + } else if state_context.is_current_animation_finished() { + if let Some(state) = dash::check_input(state_context, &self.grounded_common) { + Some(state) + } else if let Some(state) = walk::check_input(state_context, &self.grounded_common) { + Some(state) + } else if let Some(state) = wait::check_input(state_context, &self.grounded_common) { + Some(state) + } else { + None + } + } else { + None + } + } + + fn on_enter(&mut self, state_context: &mut super::FighterStateContext) { + state_context.play_animation(AnimKind::Turn, false); + *state_context.facing_direction = state_context.facing_direction.reverse(); + } + + fn update(&mut self, state_context: &mut super::FighterStateContext) { + let friction = if state_context.velocity.x.abs() + > state_context.fighter_manifest.attributes.max_walk_vel + { + state_context.fighter_manifest.attributes.ground_friction + * state_context + .game_settings + .fighter_common + .ground_friction_over_walk_speed_multiplier + } else { + state_context.fighter_manifest.attributes.ground_friction + }; + + let ground_velocity = state_context.velocity.x; + + state_context.velocity.x += ground::apply_grounded_friction(friction, ground_velocity); + + ground::apply_grounded_motion( + state_context.velocity, + state_context.translation, + state_context.prev_translation, + ); + } + + fn check_collision_interrupt( + &mut self, + state_context: &mut FighterStateContext, + ) -> Option { + if let GroundedMotionResult::InAir = + ground::collide_with_stage_grounded(state_context, &mut self.grounded_common, false) + { + Some(FighterState::Fall(FallState)) + } else { + None + } + } +} + +pub fn check_input( + state_context: &FighterStateContext, + grounded_common: &GroundedStateCommon, +) -> Option { + let input_frame = state_context.input.get_last_frame(); + + if !input_frame.movement.x.is_zero() + && input_frame.movement.x.signum() != state_context.facing_direction.to_sign() + { + return Some(FighterState::Turn(TurnState { + grounded_common: grounded_common.clone(), + })); + } + None +} diff --git a/src/fighter/state/wait.rs b/src/fighter/state/wait.rs index d1b61db..09387fb 100644 --- a/src/fighter/state/wait.rs +++ b/src/fighter/state/wait.rs @@ -8,7 +8,7 @@ use crate::fighter::state::fall::FallState; use crate::fighter::state::ground::{ GroundedMotionResult, GroundedStateCommon, grounded_movement_common_interrupts, }; -use crate::fighter::state::{FighterState, dash}; +use crate::fighter::state::{FighterState, dash, turn}; #[derive(Debug, Clone, Hash)] pub struct WaitState { @@ -17,35 +17,42 @@ pub struct WaitState { impl FighterStateImpl for WaitState { const NAME: &'static str = "Wait"; - fn check_interrupt(&self, state_context: &FighterStateContext) -> Option { - dash::check_input(state_context, &self.grounded_common).or_else(|| { - grounded_movement_common_interrupts(state_context, &self.grounded_common) - .or_else(|| walk::check_input(state_context, &self.grounded_common)) - }) + fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option { + if let Some(state) = ground::grounded_movement_standstill_common_interrupts( + state_context, + &self.grounded_common, + ) { + // Prevent transitioning into myself + if let FighterState::Wait(_) = state { + None + } else { + Some(state) + } + } else if let Some(state) = + ground::grounded_movement_common_interrupts(state_context, &self.grounded_common) + { + Some(state) + } else { + None + } } fn on_enter(&mut self, state_context: &mut super::FighterStateContext) { - state_context - .animation_transitions - .play( - &mut state_context.animation_player, - state_context.animations.clips[&AnimKind::Wait], - Duration::ZERO, - ) - .repeat(); + state_context.play_animation(AnimKind::Wait, true); } fn update(&mut self, state_context: &mut super::FighterStateContext) { - let friction = - if state_context.velocity.x.abs() > state_context.fighter_attribs.max_walk_vel { - state_context.fighter_attribs.ground_friction - * state_context - .game_settings - .fighter_common - .ground_friction_over_walk_speed_multiplier - } else { - state_context.fighter_attribs.ground_friction - }; + let friction = if state_context.velocity.x.abs() + > state_context.fighter_manifest.attributes.max_walk_vel + { + state_context.fighter_manifest.attributes.ground_friction + * state_context + .game_settings + .fighter_common + .ground_friction_over_walk_speed_multiplier + } else { + state_context.fighter_manifest.attributes.ground_friction + }; let ground_velocity = state_context.velocity.x; diff --git a/src/fighter/state/walk.rs b/src/fighter/state/walk.rs index f61334b..e8fe20a 100644 --- a/src/fighter/state/walk.rs +++ b/src/fighter/state/walk.rs @@ -4,9 +4,9 @@ use bevy::prelude::*; 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::ground::{GroundedMotionResult, GroundedStateCommon}; -use crate::fighter::state::FighterState; #[derive(Debug, Clone, Hash)] pub struct WalkState { @@ -15,8 +15,10 @@ pub struct WalkState { impl FighterStateImpl for WalkState { const NAME: &'static str = "Walk"; - fn check_interrupt(&self, state_context: &FighterStateContext) -> Option { - if let Some(state) = ground::grounded_movement_common_interrupts(state_context, &self.grounded_common) { + fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option { + if let Some(state) = + ground::grounded_movement_common_interrupts(state_context, &self.grounded_common) + { return Some(state); } wait::check_input(state_context, &self.grounded_common) @@ -24,27 +26,21 @@ impl FighterStateImpl for WalkState { } fn on_enter(&mut self, state_context: &mut FighterStateContext) { - state_context - .animation_transitions - .play( - &mut state_context.animation_player, - state_context.animations.clips[&AnimKind::Walk], - Duration::ZERO, - ) - .repeat(); + state_context.play_animation(AnimKind::Walk, true); } fn update(&mut self, state_context: &mut FighterStateContext) { let last_frame = state_context.input.get_last_frame(); let (accel, target_vel) = state_context - .fighter_attribs + .fighter_manifest + .attributes .get_accel_and_target_walk(&last_frame); let accel = ground::compute_ground_accel( accel, target_vel, state_context.velocity.x, - state_context.fighter_attribs, + &state_context.fighter_manifest.attributes, &state_context.game_settings, ); @@ -77,7 +73,7 @@ pub fn check_input( ) -> Option { let input_frame = state_context.input.get_last_frame(); - if input_frame.movement.x.abs() >= state_context.game_settings.input_common.stick_deadzone { + if !input_frame.movement.x.is_zero() { return Some(FighterState::Walk(WalkState { grounded_common: ground_state_common.clone(), })); diff --git a/src/fighter/visual.rs b/src/fighter/visual.rs index 1e80b86..7ea08a0 100644 --- a/src/fighter/visual.rs +++ b/src/fighter/visual.rs @@ -1,10 +1,34 @@ use bevy::prelude::*; use std::collections::HashMap; -use crate::fighter::animation::AnimKind; +use crate::fighter::animation::{AnimKind, FighterAnimationFrame}; +use crate::fighter::baked_animation::BakedFighterAnimations; #[derive(Component, Clone)] pub struct FighterAnimations { pub graph: Handle, pub clips: HashMap, + /// Gameplay samples this fixed-point asset; the graph remains visual-only. + pub baked: Handle, +} + +impl FighterAnimations { + pub fn sample_bone( + &self, + baked_assets: &Assets, + frame: FighterAnimationFrame, + bone: &str, + ) -> Option { + let baked = baked_assets.get(&self.baked)?; + let frame_count = baked.frame_count(frame.kind)?; + if frame_count == 0 { + return None; + } + let frame_index = if frame.repeat { + frame.frame % frame_count + } else { + frame.frame + }; + baked.sample(frame.kind, frame_index, bone) + } } diff --git a/src/lib.rs b/src/lib.rs index 16dd635..4e165d8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,7 +21,7 @@ //! save, click Rebuild in jackdaw (or run `jd build`) and it //! appears in `Add Component`. No registration code is needed. -use bevy::prelude::*; +use bevy::{asset::processor::{AssetProcessor, ProcessorState}, prelude::*, tasks::block_on}; use bevy_asset_loader::prelude::*; use bevy_wind_waker_shader::prelude::*; use clap::Parser; @@ -46,6 +46,7 @@ pub mod stage; #[derive(States, Debug, Clone, Copy, Eq, PartialEq, Hash, Default)] pub enum AppState { #[default] + WaitForAssetProcessing, LoadCommonAssets, LoadFighterManifests, PrepareFighterManifests, @@ -78,6 +79,7 @@ impl Plugin for GamePlugin { GameplaySchedulePlugin, input::FighterInputPlugin, fighter::FighterPlugin, + fighter::baked_animation::BakedAnimationPlugin, stage::StagePlugin, MatchCameraPlugin, bevy_common_assets::ron::RonAssetPlugin::::new(&["fighter.ron"]), @@ -109,6 +111,10 @@ impl Plugin for GamePlugin { .on_failure_continue_to_state(AppState::CommonAssetLoadFailed) .load_collection::(), ) + .add_systems( + Update, + wait_for_asset_processor.run_if(in_state(AppState::WaitForAssetProcessing)), + ) .add_systems( OnEnter(AppState::PrepareFighterManifests), fighter::manifest::prepare_fighter_manifests, @@ -131,6 +137,15 @@ impl Plugin for GamePlugin { } } +fn wait_for_asset_processor( + processor: Option>, + mut next_state: ResMut>, +) { + if processor.is_none_or(|processor| block_on(processor.get_state()) == ProcessorState::Finished) { + next_state.set(AppState::LoadCommonAssets); + } +} + /// Spin-rate in radians per second. Attach it in the inspector; the /// editor sees this component with no registration code because Bevy's /// `reflect_auto_register` picks up the `Reflect` derive. `Default` + diff --git a/src/main.rs b/src/main.rs index b964ca8..27a6996 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ //! without the editor. The editor's Play button runs the same //! [`game_test::GamePlugin`] through its own game runner instead. +use bevy::asset::AssetMode::Processed; use bevy::camera_controller::free_camera::FreeCameraPlugin; use bevy::image::{ImageAddressMode, ImagePlugin, ImageSamplerDescriptor}; use bevy::prelude::*; @@ -21,6 +22,9 @@ fn main() -> AppExit { address_mode_w: ImageAddressMode::Repeat, ..ImageSamplerDescriptor::linear() }, + }).set(AssetPlugin { + mode: Processed, + ..default() }); App::new() diff --git a/src/match_loading.rs b/src/match_loading.rs index 909b1c3..49ca5c4 100644 --- a/src/match_loading.rs +++ b/src/match_loading.rs @@ -12,6 +12,7 @@ use crate::{ fighter::{ FighterId, animation::AnimKind, + baked_animation::BakedFighterAnimations, manifest::{FighterManifest, FighterManifestRegistry}, spawn_fighter, visual::FighterAnimations, @@ -39,6 +40,7 @@ pub struct PendingMatch { pub struct SelectedFighterAssets { pub fighter: FighterId, pub model: Handle, + pub baked_animations: Handle, } #[derive(Resource)] @@ -79,6 +81,7 @@ impl MatchAssets { SelectedFighterAssets { fighter: player.fighter, model: asset_server.load(manifest.model_path.clone()), + baked_animations: asset_server.load(manifest.baked_animation_path.clone()), } }) .collect(), @@ -97,6 +100,7 @@ impl AssetCollection for MatchAssets { handles.push(assets.stage.clone().untyped()); for character in assets.fighters { handles.push(character.model.untyped()); + handles.push(character.baked_animations.untyped()); } handles } @@ -141,6 +145,7 @@ pub fn prepare_match( request: Res, assets: Res, gltfs: Res>, + baked_animations: Res>, registry: Res, stage_registry: Res, manifests: Res>, @@ -180,6 +185,13 @@ pub fn prepare_match( fail_match(&mut next_state, "a loaded character GLTF is unavailable"); return; }; + let Some(baked) = baked_animations.get(&character.baked_animations) else { + fail_match( + &mut next_state, + "a fighter baked animation asset is unavailable", + ); + return; + }; let Some(scene) = gltf.scenes.first().cloned() else { fail_match(&mut next_state, "a character GLTF contains no scene"); return; @@ -191,6 +203,8 @@ pub fn prepare_match( animations.clone(), manifest.attributes, manifest.camera, + manifest, + manifest_handle, )); continue; } @@ -210,6 +224,16 @@ pub fn prepare_match( return; }; clips.insert(*kind, graph.add_clip(clip.clone(), 1.0, graph.root)); + if baked.frame_count(*kind).is_none() { + fail_match( + &mut next_state, + &format!( + "{:?} baked data has no {kind:?} animation", + character.fighter + ), + ); + return; + } } if !clips.contains_key(&AnimKind::Wait) { @@ -220,9 +244,17 @@ pub fn prepare_match( let animations = FighterAnimations { graph: graphs.add(graph), clips, + baked: character.baked_animations.clone(), }; shared_animations.insert(character.fighter, animations.clone()); - prepared.push((scene, animations, manifest.attributes, manifest.camera)); + prepared.push(( + scene, + animations, + manifest.attributes, + manifest.camera, + manifest, + manifest_handle, + )); } let session = match create_session(&args, request.players.len()) { @@ -238,8 +270,12 @@ pub fn prepare_match( let root = commands.spawn((Name::new("Match"), MatchRoot)).id(); - let stage_handle = stage_registry.get(request.stage).expect("Initialization check should ensure all stages exist"); - let stage_manifest = stage_manifests.get(&stage_handle).expect("Initialization check should ensure stage manifests are kept alive"); + let stage_handle = stage_registry + .get(request.stage) + .expect("Initialization check should ensure all stages exist"); + let stage_manifest = stage_manifests + .get(&stage_handle) + .expect("Initialization check should ensure stage manifests are kept alive"); stage::spawn_stage( &mut commands, root, @@ -248,16 +284,18 @@ pub fn prepare_match( stage_manifest.camera.clone(), ); - for (index, (player, (scene, animations, attributes, camera_profile))) in - request.players.iter().zip(prepared).enumerate() + for ( + index, + (player, (scene, animations, attributes, camera_profile, manifest, manifest_handle)), + ) in request.players.iter().zip(prepared).enumerate() { let spawn_x = (index as i32 * 2 + 1 - request.players.len() as i32) * 5; spawn_fighter( &mut commands, player.handle, FGVec2::new(FGi32::from_num(spawn_x), FGi32::lit("12.5")), + (manifest_handle.clone(), manifest), attributes, - camera_profile, animations, WorldAssetRoot(scene), );