Skip to content

Commit

Permalink
add ability to load animated cursors from text-based asset files (#17)
Browse files Browse the repository at this point in the history
  • Loading branch information
mgi388 authored Jan 18, 2025
1 parent c1a25a4 commit 0f72115
Show file tree
Hide file tree
Showing 4 changed files with 313 additions and 13 deletions.
40 changes: 36 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ Load the classic Microsoft Windows `.CUR` and `.ANI` cursor file formats.
- `.CUR` files can be used for static cursor icons like a grabbing hand.
- `.ANI` files can be used for animated cursor icons like an hourglass.

### `.cur.json` ,`.cur.ron`, `.cur.toml` text formats
### `.cur.json` ,`.cur.ron`, `.cur.toml`, `.ani.json` ,`.ani.ron`, `.ani.toml` text formats

Text-based versions of the classic `.CUR` static cursor file format.
Text-based versions of the classic `.CUR` static cursor and `.ANI` animated cursor file formats.

Write your static cursors in JSON, RON, or TOML and `bevy_cursor_kit` can load them for you.
Write your cursors in JSON, RON, or TOML and `bevy_cursor_kit` can load them for you.

#### Static cursor

```ron
(
Expand All @@ -41,11 +43,41 @@ Write your static cursors in JSON, RON, or TOML and `bevy_cursor_kit` can load t
},
),
)
```

Check out the [cur_ron_asset.rs example](example/cur_ron_asset.rs) for more details.

#### Animated cursor

```ron
(
image: (
path: "path/to/sprite-sheet.png",
),
texture_atlas_layout: (
tile_size: (32, 32),
columns: 2,
rows: 2,
),
hotspots: (
default: (0, 0),
),
animation: (
repeat: Loop,
clips: [
(
atlas_indices: [3, 0, 1, 2],
duration: PerFrame(75),
),
(
atlas_indices: [2],
duration: PerFrame(5000),
),
],
)
)
```

## Quick start

Add the asset plugin for asset loader support:
Expand Down
43 changes: 34 additions & 9 deletions src/ani/asset.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
use std::time::Duration;

use bevy_app::prelude::*;
use bevy_asset::{io::Reader, prelude::*, AssetLoader, LoadContext, RenderAssetUsages};
use bevy_image::{Image, TextureAtlasBuilder, TextureAtlasBuilderError, TextureAtlasLayout};
Expand All @@ -17,11 +15,41 @@ use crate::{
};

use super::animation::*;
#[cfg(feature = "serde_json_asset")]
use super::serde_asset::JsonDeserializer;
#[cfg(feature = "serde_ron_asset")]
use super::serde_asset::RonDeserializer;
#[cfg(feature = "serde_asset")]
use super::serde_asset::SerdeAnimatedCursorAssetPlugin;
#[cfg(feature = "serde_toml_asset")]
use super::serde_asset::TomlDeserializer;

pub struct AnimatedCursorAssetPlugin;

impl Plugin for AnimatedCursorAssetPlugin {
fn build(&self, app: &mut App) {
#[cfg(feature = "serde_asset")]
{
#[cfg(feature = "serde_json_asset")]
if !app.is_plugin_added::<SerdeAnimatedCursorAssetPlugin<JsonDeserializer>>() {
app.add_plugins(SerdeAnimatedCursorAssetPlugin::<JsonDeserializer>::new(
["ANI.json", "ani.json"].to_vec(),
));
}
#[cfg(feature = "serde_ron_asset")]
if !app.is_plugin_added::<SerdeAnimatedCursorAssetPlugin<RonDeserializer>>() {
app.add_plugins(SerdeAnimatedCursorAssetPlugin::<RonDeserializer>::new(
["ANI.ron", "ani.ron"].to_vec(),
));
}
#[cfg(feature = "serde_toml_asset")]
if !app.is_plugin_added::<SerdeAnimatedCursorAssetPlugin<TomlDeserializer>>() {
app.add_plugins(SerdeAnimatedCursorAssetPlugin::<TomlDeserializer>::new(
["ANI.toml", "ani.toml"].to_vec(),
));
}
}

app.init_asset::<AnimatedCursor>()
.init_asset_loader::<AnimatedCursorLoader>()
.register_asset_reflect::<AnimatedCursor>();
Expand All @@ -31,7 +59,9 @@ impl Plugin for AnimatedCursorAssetPlugin {
#[derive(Asset, Clone, Debug, Reflect)]
#[reflect(Debug)]
pub struct AnimatedCursor {
metadata: AnimatedCursorMetadata,
/// The metadata for the animated cursor. This is optional and only set for
/// .ANI files.
pub(super) metadata: Option<AnimatedCursorMetadata>,
/// A handle to the image asset.
pub image: Handle<Image>,
/// A handle to the texture atlas layout asset.
Expand All @@ -49,11 +79,6 @@ impl AnimatedCursor {
pub fn hotspot_or_default(&self, index: usize) -> (u16, u16) {
self.hotspots.get_or_default(index)
}

#[inline(always)]
pub fn duration_per_frame(&self) -> Duration {
self.metadata.duration_per_frame()
}
}

/// A loader for animated cursor assets from .ANI files.
Expand Down Expand Up @@ -203,7 +228,7 @@ impl AssetLoader for AnimatedCursorLoader {
};

Ok(AnimatedCursor {
metadata: c.metadata.clone(),
metadata: Some(c.metadata.clone()),
image,
texture_atlas_layout,
hotspots,
Expand Down
2 changes: 2 additions & 0 deletions src/ani/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
pub mod animation;
pub mod asset;
pub mod decoder;
#[cfg(feature = "serde_asset")]
mod serde_asset;

use std::time::Duration;

Expand Down
241 changes: 241 additions & 0 deletions src/ani/serde_asset.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
#[cfg(feature = "serde_toml_asset")]
use std::str::from_utf8;
use std::{fmt::Debug, marker::PhantomData};

use bevy_app::prelude::*;
use bevy_asset::{io::Reader, prelude::*, AssetLoader, LoadContext};
use bevy_math::UVec2;
use bevy_reflect::prelude::*;
use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::{
asset_image::{load_image, LoadImageError},
hotspot::CursorHotspots,
};

use super::{animation::Animation, asset::AnimatedCursor};

/// A plugin for loading animated cursor assets using Serde.
pub struct SerdeAnimatedCursorAssetPlugin<D: Deserializer> {
_phantom: PhantomData<D>,

extensions: Vec<&'static str>,
}

impl<D: Deserializer> SerdeAnimatedCursorAssetPlugin<D> {
/// Creates a new [`SerdeAnimatedCursorAssetPlugin`].
pub fn new(extensions: Vec<&'static str>) -> Self {
Self {
_phantom: PhantomData,
extensions,
}
}
}

impl<D: Deserializer> Plugin for SerdeAnimatedCursorAssetPlugin<D> {
fn build(&self, app: &mut App) {
app.register_asset_loader(SerdeAnimatedCursorLoader::<D>::new(
D::default(),
self.extensions.clone(),
));
}
}

#[derive(Asset, Debug, Clone, Deserialize, Reflect, Serialize)]
#[reflect(Debug, Deserialize, Serialize)]
pub struct SerdeAnimatedCursor {
/// The image to use.
pub image: SerdeImage,
/// The layout of the texture atlas.
pub texture_atlas_layout: SerdeTextureAtlasLayout,
/// The hotspot data.
#[serde(default)]
pub hotspots: CursorHotspots,
/// The animation to play.
pub animation: Animation,
}

#[derive(Clone, Debug, Default, Deserialize, Reflect, Serialize)]
#[reflect(Debug, Default, Deserialize, Serialize)]
pub struct SerdeImage {
/// The path to the image asset relative to the assets root directory.
pub path: String,
/// An optional color key. Pixels in the image with this color are converted
/// to transparent.
#[serde(default)]
pub color_key: Option<(u8, u8, u8)>,
/// Whether to flip the image horizontally. Flips the entire image.
#[serde(default)]
pub flip_x: bool,
/// Whether to flip the image vertically. Flips the entire image.
#[serde(default)]
pub flip_y: bool,
}

#[derive(Clone, Debug, Default, Deserialize, Reflect, Serialize)]
#[reflect(Debug, Default, Deserialize, Serialize)]
pub struct SerdeTextureAtlasLayout {
/// The size of each tile, in pixels.
pub tile_size: UVec2,
/// The number columns on the sprite sheet.
pub columns: u32,
/// The number of rows on the sprite sheet.
pub rows: u32,
/// The padding between each tile, in pixels.
pub padding: Option<UVec2>,
/// The global offset of the grid, in pixels.
pub offset: Option<UVec2>,
}

/// Possible errors that can be produced by deserialization.
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum DeserializeError {
/// A [serde_json::error::Error] error.
#[cfg(feature = "serde_json_asset")]
#[error("Could not parse the JSON: {0}")]
Json(#[from] serde_json::error::Error),
/// A [ron::error::SpannedError] error.
#[cfg(feature = "serde_ron_asset")]
#[error("could not parse RON: {0}")]
Ron(#[from] ron::error::SpannedError),
/// A [std::str::Utf8Error] error.
#[cfg(feature = "serde_toml_asset")]
#[error("Could not interpret as UTF-8: {0}")]
FormatError(#[from] std::str::Utf8Error),
/// A [toml::de::Error] error.
#[cfg(feature = "serde_toml_asset")]
#[error("Could not parse TOML: {0}")]
Toml(#[from] toml::de::Error),
}

/// A trait for deserializing bytes into a [`SerdeAnimatedCursor`].
pub trait Deserializer: Debug + Default + Send + Sync + 'static {
fn deserialize(&self, bytes: &[u8]) -> Result<SerdeAnimatedCursor, DeserializeError>;
}

/// Implements deserialization for JSON format.
#[cfg(feature = "serde_json_asset")]
#[derive(Clone, Debug, Default)]
pub struct JsonDeserializer;

#[cfg(feature = "serde_json_asset")]
impl Deserializer for JsonDeserializer {
fn deserialize(&self, bytes: &[u8]) -> Result<SerdeAnimatedCursor, DeserializeError> {
Ok(serde_json::from_slice(bytes)?)
}
}

/// Implements deserialization for RON format.
#[cfg(feature = "serde_ron_asset")]
#[derive(Clone, Debug, Default)]
pub struct RonDeserializer;

#[cfg(feature = "serde_ron_asset")]
impl Deserializer for RonDeserializer {
fn deserialize(&self, bytes: &[u8]) -> Result<SerdeAnimatedCursor, DeserializeError> {
Ok(ron::de::from_bytes::<SerdeAnimatedCursor>(bytes)?)
}
}

/// Implements deserialization for TOML format.
#[cfg(feature = "serde_toml_asset")]
#[derive(Clone, Debug, Default)]
pub struct TomlDeserializer;

#[cfg(feature = "serde_toml_asset")]
impl Deserializer for TomlDeserializer {
fn deserialize(&self, bytes: &[u8]) -> Result<SerdeAnimatedCursor, DeserializeError> {
Ok(toml::from_str::<SerdeAnimatedCursor>(from_utf8(bytes)?)?)
}
}

/// A loader for animated cursor assets using Serde.
pub struct SerdeAnimatedCursorLoader<D: Deserializer> {
_phantom: PhantomData<D>,
extensions: Vec<&'static str>,
deserializer: D,
}

/// Possible errors that can be produced by [`SerdeAnimatedCursorLoader`].
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum SerdeAnimatedCursorLoaderError {
/// An [IO](std::io) error.
#[error("could not load asset: {0}")]
Io(#[from] std::io::Error),
/// A [DeserializeError] error.
#[error("could not deserialize animated cursor: {0}")]
DeserializeError(#[from] DeserializeError),
/// A [LoadImageError] error.
#[error("could not load image: {0}")]
LoadImageError(#[from] LoadImageError),
}

impl<D: Deserializer> AssetLoader for SerdeAnimatedCursorLoader<D> {
type Asset = AnimatedCursor;
type Settings = ();
type Error = SerdeAnimatedCursorLoaderError;
async fn load(
&self,
reader: &mut dyn Reader,
_settings: &Self::Settings,
load_context: &mut LoadContext<'_>,
) -> Result<Self::Asset, Self::Error> {
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes).await?;

let c = self.deserializer.deserialize(&bytes)?;

// Load the image asset. If the image has a color key or needs to be
// flipped, load it as a dynamic image so we can process it now.
// Otherwise, load it as a regular asset.
let image = if c.image.color_key.is_some() || c.image.flip_x || c.image.flip_y {
let image = load_image(
load_context,
&c.image.path,
c.image.color_key,
c.image.flip_x,
c.image.flip_y,
)
.await?;
load_context.add_labeled_asset("image".to_string(), image)
} else {
load_context.load(&c.image.path)
};

let texture_atlas_layout = bevy_image::TextureAtlasLayout::from_grid(
c.texture_atlas_layout.tile_size,
c.texture_atlas_layout.columns,
c.texture_atlas_layout.rows,
c.texture_atlas_layout.padding,
c.texture_atlas_layout.offset,
);

let texture_atlas_layout = load_context
.labeled_asset_scope("texture_atlas_layout".to_string(), |_| texture_atlas_layout);

Ok(AnimatedCursor {
metadata: None,
image,
texture_atlas_layout,
hotspots: c.hotspots,
animation: c.animation,
})
}

fn extensions(&self) -> &[&str] {
&self.extensions
}
}

impl<D: Deserializer> SerdeAnimatedCursorLoader<D> {
pub fn new(deserializer: D, extensions: Vec<&'static str>) -> Self {
Self {
_phantom: PhantomData,
deserializer,
extensions,
}
}
}

0 comments on commit 0f72115

Please sign in to comment.