From 5ddbe2f19f980eb2cd8d012f5dd4c7a86155a913 Mon Sep 17 00:00:00 2001 From: Jonathan Cornaz Date: Sun, 5 Jun 2022 19:59:57 +0200 Subject: [PATCH] feat(unstable): parse animation from yaml (#60) --- .github/workflows/build.yml | 1 + .gitpod.yml | 9 +- Cargo.toml | 6 ++ README.md | 8 ++ src/animation.rs | 47 ++++++++- src/animation/parse.rs | 188 ++++++++++++++++++++++++++++++++++++ 6 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 src/animation/parse.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f77216d..9402d80 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,6 +30,7 @@ jobs: - run: cargo check --all-targets - run: cargo test --no-default-features - run: cargo test + - run: cargo test --features unstable-load-from-file - run: cargo test --all-features - run: cargo test --all-features -- --ignored diff --git a/.gitpod.yml b/.gitpod.yml index 10bc53e..ddce416 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -7,10 +7,17 @@ tasks: cp .cargo/fast_compiles_config .cargo/config.toml cargo update cargo test --all-features + cargo test gp sync-done test cargo clippy --all-features --all-targets cargo doc --all-features --no-deps - command: cargo watch -x 'test --all-features' -x 'clippy --all-features --all-targets' -x 'doc --all-features --no-deps' + command: | + cargo watch \ + -x 'test --tests' \ + -x 'test --all-features --tests' \ + -x 'test --all-features' \ + -x 'clippy --all-features --all-targets' \ + -x 'doc --all-features --no-deps' - name: example init: | diff --git a/Cargo.toml b/Cargo.toml index b838e7e..71c29d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,10 @@ repository = "https://github.com/jcornaz/benimator" keywords = ["game", "gamedev", "anmiation", "bevy"] categories = ["game-development"] +[features] +default = [] +unstable-load-from-file = ["serde", "serde_yaml"] + [dependencies] bevy_core = { version = "0.7.0", default-features = false } bevy_ecs = { version = "0.7.0", default-features = false } @@ -17,6 +21,8 @@ bevy_app = { version = "0.7.0", default-features = false } bevy_reflect = { version = "0.7.0", default-features = false } bevy_sprite = { version = "0.7.0", default-features = false } bevy_asset = { version = "0.7.0", default-features = false } +serde = { version = "1.0", default-features = false, features = ["derive"], optional = true } +serde_yaml = { version = "0.8.24", default-features = false, optional = true } [dev-dependencies] bevy = { version = "0.7.0", default-features = false, features = ["render", "x11", "png"] } diff --git a/README.md b/README.md index 6277acf..8400ead 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,14 @@ Add to `Cargo.toml`: benimator = "3" ``` +## Cargo features + +### Unstable features + +**Any API behind one of theses feature flags is unstable, should not be considered complete nor part of the public API. Breaking changes to that API may happen in minor releases** + +* `unstable-load-from-file` API to create animation from yaml files. + ## MSRV The minimum supported rust version is currently: `1.59` diff --git a/src/animation.rs b/src/animation.rs index 92fa01e..0c321e2 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -1,17 +1,27 @@ -use std::ops::RangeInclusive; -use std::time::Duration; +#[cfg(feature = "unstable-load-from-file")] +mod parse; + +use std::{ops::RangeInclusive, time::Duration}; use bevy_reflect::TypeUuid; +#[cfg(feature = "unstable-load-from-file")] +pub use parse::AnimationParseError; + +#[cfg(feature = "unstable-load-from-file")] +use serde::Deserialize; + /// Asset that define an animation of `TextureAtlasSprite` /// /// See crate level documentation for usage #[derive(Debug, Clone, Default, TypeUuid)] +#[cfg_attr(feature = "unstable-load-from-file", derive(Deserialize))] #[uuid = "6378e9c2-ecd1-4029-9cd5-801caf68517c"] pub struct SpriteSheetAnimation { /// Frames pub(crate) frames: Vec, /// Animation mode + #[cfg_attr(feature = "unstable-load-from-file", serde(default))] pub(crate) mode: Mode, } @@ -38,11 +48,16 @@ pub enum AnimationMode { } /// A single animation frame -#[derive(Debug, Copy, Clone, Default)] +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq)] +#[cfg_attr(feature = "unstable-load-from-file", derive(Deserialize))] pub struct Frame { /// Index in the sprite atlas pub(crate) index: usize, /// How long should the frame be displayed + #[cfg_attr( + feature = "unstable-load-from-file", + serde(deserialize_with = "parse::deserialize_duration") + )] pub(crate) duration: Duration, } @@ -123,6 +138,32 @@ impl SpriteSheetAnimation { pub(crate) fn has_frames(&self) -> bool { !self.frames.is_empty() } + + /// Parse content of a yaml file representing the animation + /// + /// # Yaml schema + /// + /// ```yaml + /// # The mode can be one of: 'once', 'repeat', 'ping-pong' + /// # or 'repeatFrom(n)' (where 'n' is the frame-index to repeat from) + /// # The default is 'repeat' + /// mode: ping-pong + /// frames: + /// - index: 0 # index in the sprite sheet for that frame + /// duration: 100 # duration of the frame in milliseconds + /// - index: 1 + /// duration: 100 + /// - index: 2 + /// duration: 120 + /// ``` + /// + /// # Errors + /// + /// Returns an error if the content is not a valid yaml representation of an animation + #[cfg(feature = "unstable-load-from-file")] + pub fn from_yaml(yaml: &str) -> Result { + serde_yaml::from_str(yaml).map_err(AnimationParseError) + } } #[derive(Debug, Copy, Clone, Eq, PartialEq)] diff --git a/src/animation/parse.rs b/src/animation/parse.rs new file mode 100644 index 0000000..82f1cd2 --- /dev/null +++ b/src/animation/parse.rs @@ -0,0 +1,188 @@ +use std::{ + error::Error, + fmt::{self, Display, Formatter}, + time::Duration, +}; + +use serde::{de, Deserialize, Deserializer}; + +use super::Mode; + +#[derive(Debug)] +#[non_exhaustive] +pub struct AnimationParseError(pub(super) serde_yaml::Error); + +impl Display for AnimationParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "Animation format is invalid: {}", self.0) + } +} + +impl Error for AnimationParseError {} + +pub(super) fn deserialize_duration<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + deserializer.deserialize_u64(DurationVisitor) +} + +struct DurationVisitor; + +impl<'de> de::Visitor<'de> for DurationVisitor { + type Value = Duration; + + fn visit_u64(self, v: u64) -> Result + where + E: de::Error, + { + Ok(Duration::from_millis(v)) + } + + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + write!(formatter, "a positive integer") + } +} + +impl<'de> Deserialize<'de> for Mode { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(ModeVisitor) + } +} + +struct ModeVisitor; + +impl<'de> de::Visitor<'de> for ModeVisitor { + type Value = Mode; + + fn visit_str(self, s: &str) -> Result + where + E: de::Error, + { + match s { + "ping-pong" => Ok(Mode::PingPong), + "repeat" => Ok(Mode::RepeatFrom(0)), + "once" => Ok(Mode::Once), + _ => { + match s + .strip_prefix("repeat-from(") + .and_then(|s| s.strip_suffix(')')) + .and_then(|s| s.parse::().ok()) + { + Some(index) => Ok(Mode::RepeatFrom(index)), + None => Err(de::Error::invalid_value(de::Unexpected::Str(s), &self)), + } + } + } + } + + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + write!( + formatter, + "one of: 'repeat', 'once', 'ping-pong', 'repeat-from(n)'" + ) + } +} + +#[cfg(test)] +mod tests { + use super::super::{Frame, SpriteSheetAnimation}; + + use super::*; + + #[test] + fn load_from_yaml() { + // given + let content = " + mode: ping-pong + frames: + - index: 0 # index in the sprite sheet for that frame + duration: 100 # duration of the frame in milliseconds + - index: 1 + duration: 100 + - index: 2 + duration: 120"; + + // when + let animation = SpriteSheetAnimation::from_yaml(content).unwrap(); + + // then + assert_eq!(animation.mode, Mode::PingPong); + assert_eq!( + animation.frames, + vec![ + Frame::new(0, Duration::from_millis(100)), + Frame::new(1, Duration::from_millis(100)), + Frame::new(2, Duration::from_millis(120)), + ] + ); + } + + #[test] + fn load_from_yaml_default_mode() { + // given + let content = " + frames: + - index: 0 + duration: 100"; + + // when + let animation = SpriteSheetAnimation::from_yaml(content).unwrap(); + + // then + assert_eq!(animation.mode, Mode::RepeatFrom(0)); + } + + #[test] + fn load_from_yaml_repeat() { + // given + let content = " + mode: repeat + frames: + - index: 0 + duration: 100"; + + // when + let animation = SpriteSheetAnimation::from_yaml(content).unwrap(); + + // then + assert_eq!(animation.mode, Mode::RepeatFrom(0)); + } + + #[test] + fn load_from_yaml_once() { + // given + let content = " + mode: once + frames: + - index: 0 + duration: 100"; + + // when + let animation = SpriteSheetAnimation::from_yaml(content).unwrap(); + + // then + assert_eq!(animation.mode, Mode::Once); + } + + #[test] + fn load_from_yaml_repeat_from() { + // given + let content = " + mode: repeat-from(1) + frames: + - index: 0 + duration: 100 + - index: 1 + duration: 100"; + + // when + let animation = SpriteSheetAnimation::from_yaml(content).unwrap(); + + // then + assert_eq!(animation.mode, Mode::RepeatFrom(1)); + } +}