Skip to content

Commit

Permalink
feat(unstable): parse animation from yaml (#60)
Browse files Browse the repository at this point in the history
  • Loading branch information
jcornaz committed Jun 5, 2022
1 parent d13efc0 commit 5ddbe2f
Show file tree
Hide file tree
Showing 6 changed files with 255 additions and 4 deletions.
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion .gitpod.yml
Expand Up @@ -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: |
Expand Down
6 changes: 6 additions & 0 deletions Cargo.toml
Expand Up @@ -10,13 +10,19 @@ 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 }
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"] }
Expand Down
8 changes: 8 additions & 0 deletions README.md
Expand Up @@ -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`
Expand Down
47 changes: 44 additions & 3 deletions 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<Frame>,
/// Animation mode
#[cfg_attr(feature = "unstable-load-from-file", serde(default))]
pub(crate) mode: Mode,
}

Expand All @@ -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,
}

Expand Down Expand Up @@ -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<Self, AnimationParseError> {
serde_yaml::from_str(yaml).map_err(AnimationParseError)
}
}

#[derive(Debug, Copy, Clone, Eq, PartialEq)]
Expand Down
188 changes: 188 additions & 0 deletions 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<Duration, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_u64(DurationVisitor)
}

struct DurationVisitor;

impl<'de> de::Visitor<'de> for DurationVisitor {
type Value = Duration;

fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
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<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(ModeVisitor)
}
}

struct ModeVisitor;

impl<'de> de::Visitor<'de> for ModeVisitor {
type Value = Mode;

fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
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::<usize>().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));
}
}

0 comments on commit 5ddbe2f

Please sign in to comment.