diff --git a/Cargo.toml b/Cargo.toml index 06b3902e951ff..2c5c15052bf0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -663,6 +663,17 @@ description = "Animates a sprite in response to an event" category = "2D Rendering" wasm = true +[[example]] +name = "sprite_scale" +path = "examples/2d/sprite_scale.rs" +doc-scrape-examples = true + +[package.metadata.example.sprite_scale] +name = "Sprite Scale" +description = "Shows how a sprite can be scaled into a rectangle while keeping the aspect ratio" +category = "2D Rendering" +wasm = true + [[example]] name = "sprite_flipping" path = "examples/2d/sprite_flipping.rs" diff --git a/crates/bevy_sprite/src/lib.rs b/crates/bevy_sprite/src/lib.rs index 2ab05ea14679d..2bd354ac8b263 100644 --- a/crates/bevy_sprite/src/lib.rs +++ b/crates/bevy_sprite/src/lib.rs @@ -25,7 +25,7 @@ pub mod prelude { pub use crate::{ sprite::{Sprite, SpriteImageMode}, texture_slice::{BorderRect, SliceScaleMode, TextureSlice, TextureSlicer}, - ColorMaterial, MeshMaterial2d, + ColorMaterial, MeshMaterial2d, ScalingMode, }; } diff --git a/crates/bevy_sprite/src/render/mod.rs b/crates/bevy_sprite/src/render/mod.rs index afda36fa4d81c..eaedced58c84e 100644 --- a/crates/bevy_sprite/src/render/mod.rs +++ b/crates/bevy_sprite/src/render/mod.rs @@ -1,6 +1,6 @@ use core::ops::Range; -use crate::{ComputedTextureSlices, Sprite, SPRITE_SHADER_HANDLE}; +use crate::{ComputedTextureSlices, ScalingMode, Sprite, SPRITE_SHADER_HANDLE}; use bevy_asset::{AssetEvent, AssetId, Assets}; use bevy_color::{ColorToComponents, LinearRgba}; use bevy_core_pipeline::{ @@ -339,6 +339,7 @@ pub struct ExtractedSprite { /// For cases where additional [`ExtractedSprites`] are created during extraction, this stores the /// entity that caused that creation for use in determining visibility. pub original_entity: Option, + pub scaling_mode: Option, } #[derive(Resource, Default)] @@ -430,6 +431,7 @@ pub fn extract_sprites( image_handle_id: sprite.image.id(), anchor: sprite.anchor.as_vec(), original_entity: Some(original_entity), + scaling_mode: sprite.image_mode.scale(), }, ); } @@ -700,21 +702,43 @@ pub fn prepare_sprite_image_bind_groups( // By default, the size of the quad is the size of the texture let mut quad_size = batch_image_size; - // Calculate vertex data for this item - let mut uv_offset_scale: Vec4; + // Texture size is the size of the image + let mut texture_size = batch_image_size; // If a rect is specified, adjust UVs and the size of the quad - if let Some(rect) = extracted_sprite.rect { + let mut uv_offset_scale = if let Some(rect) = extracted_sprite.rect { let rect_size = rect.size(); - uv_offset_scale = Vec4::new( + quad_size = rect_size; + // Update texture size to the rect size + // It will help scale properly only portion of the image + texture_size = rect_size; + Vec4::new( rect.min.x / batch_image_size.x, rect.max.y / batch_image_size.y, rect_size.x / batch_image_size.x, -rect_size.y / batch_image_size.y, - ); - quad_size = rect_size; + ) } else { - uv_offset_scale = Vec4::new(0.0, 1.0, 1.0, -1.0); + Vec4::new(0.0, 1.0, 1.0, -1.0) + }; + + // Override the size if a custom one is specified + if let Some(custom_size) = extracted_sprite.custom_size { + quad_size = custom_size; + } + + // Used for translation of the quad if `TextureScale::Fit...` is specified. + let mut quad_translation = Vec2::ZERO; + + // Scales the texture based on the `texture_scale` field. + if let Some(scaling_mode) = extracted_sprite.scaling_mode { + apply_scaling( + scaling_mode, + texture_size, + &mut quad_size, + &mut quad_translation, + &mut uv_offset_scale, + ); } if extracted_sprite.flip_x { @@ -726,15 +750,13 @@ pub fn prepare_sprite_image_bind_groups( uv_offset_scale.w *= -1.0; } - // Override the size if a custom one is specified - if let Some(custom_size) = extracted_sprite.custom_size { - quad_size = custom_size; - } let transform = extracted_sprite.transform.affine() * Affine3A::from_scale_rotation_translation( quad_size.extend(1.0), Quat::IDENTITY, - (quad_size * (-extracted_sprite.anchor - Vec2::splat(0.5))).extend(0.0), + ((quad_size + quad_translation) + * (-extracted_sprite.anchor - Vec2::splat(0.5))) + .extend(0.0), ); // Store the vertex data and add the item to the render phase @@ -875,3 +897,89 @@ impl RenderCommand

for DrawSpriteBatch { RenderCommandResult::Success } } + +/// Scales a texture to fit within a given quad size with keeping the aspect ratio. +fn apply_scaling( + scaling_mode: ScalingMode, + texture_size: Vec2, + quad_size: &mut Vec2, + quad_translation: &mut Vec2, + uv_offset_scale: &mut Vec4, +) { + let quad_ratio = quad_size.x / quad_size.y; + let texture_ratio = texture_size.x / texture_size.y; + let tex_quad_scale = texture_ratio / quad_ratio; + let quad_tex_scale = quad_ratio / texture_ratio; + + match scaling_mode { + ScalingMode::FillCenter => { + if quad_ratio > texture_ratio { + // offset texture to center by y coordinate + uv_offset_scale.y += (uv_offset_scale.w - uv_offset_scale.w * tex_quad_scale) * 0.5; + // sum up scales + uv_offset_scale.w *= tex_quad_scale; + } else { + // offset texture to center by x coordinate + uv_offset_scale.x += (uv_offset_scale.z - uv_offset_scale.z * quad_tex_scale) * 0.5; + uv_offset_scale.z *= quad_tex_scale; + }; + } + ScalingMode::FillStart => { + if quad_ratio > texture_ratio { + uv_offset_scale.y += uv_offset_scale.w - uv_offset_scale.w * tex_quad_scale; + uv_offset_scale.w *= tex_quad_scale; + } else { + uv_offset_scale.z *= quad_tex_scale; + } + } + ScalingMode::FillEnd => { + if quad_ratio > texture_ratio { + uv_offset_scale.w *= tex_quad_scale; + } else { + uv_offset_scale.x += uv_offset_scale.z - uv_offset_scale.z * quad_tex_scale; + uv_offset_scale.z *= quad_tex_scale; + } + } + ScalingMode::FitCenter => { + if texture_ratio > quad_ratio { + // Scale based on width + quad_size.y *= quad_tex_scale; + } else { + // Scale based on height + quad_size.x *= tex_quad_scale; + } + } + ScalingMode::FitStart => { + if texture_ratio > quad_ratio { + // The quad is scaled to match the image ratio, and the quad translation is adjusted + // to start of the quad within the original quad size. + let scale = Vec2::new(1.0, quad_tex_scale); + let new_quad = *quad_size * scale; + let offset = *quad_size - new_quad; + *quad_translation = Vec2::new(0.0, -offset.y); + *quad_size = new_quad; + } else { + let scale = Vec2::new(tex_quad_scale, 1.0); + let new_quad = *quad_size * scale; + let offset = *quad_size - new_quad; + *quad_translation = Vec2::new(offset.x, 0.0); + *quad_size = new_quad; + } + } + ScalingMode::FitEnd => { + if texture_ratio > quad_ratio { + let scale = Vec2::new(1.0, quad_tex_scale); + let new_quad = *quad_size * scale; + let offset = *quad_size - new_quad; + *quad_translation = Vec2::new(0.0, offset.y); + *quad_size = new_quad; + } else { + let scale = Vec2::new(tex_quad_scale, 1.0); + let new_quad = *quad_size * scale; + let offset = *quad_size - new_quad; + *quad_translation = Vec2::new(-offset.x, 0.0); + *quad_size = new_quad; + } + } + } +} diff --git a/crates/bevy_sprite/src/sprite.rs b/crates/bevy_sprite/src/sprite.rs index 82101a8b38024..59c60071a0293 100644 --- a/crates/bevy_sprite/src/sprite.rs +++ b/crates/bevy_sprite/src/sprite.rs @@ -162,6 +162,9 @@ pub enum SpriteImageMode { /// The sprite will take on the size of the image by default, and will be stretched or shrunk if [`Sprite::custom_size`] is set. #[default] Auto, + /// The texture will be scaled to fit the rect bounds defined in [`Sprite::custom_size`]. + /// Otherwise no scaling will be applied. + Scale(ScalingMode), /// The texture will be cut in 9 slices, keeping the texture in proportions on resize Sliced(TextureSlicer), /// The texture will be repeated if stretched beyond `stretched_value` @@ -185,6 +188,59 @@ impl SpriteImageMode { SpriteImageMode::Sliced(..) | SpriteImageMode::Tiled { .. } ) } + + /// Returns [`ScalingMode`] if scale is presented or [`Option::None`] otherwise. + #[inline] + #[must_use] + pub const fn scale(&self) -> Option { + if let SpriteImageMode::Scale(scale) = self { + Some(*scale) + } else { + None + } + } +} + +/// Represents various modes for proportional scaling of a texture. +/// +/// Can be used in [`SpriteImageMode::Scale`]. +#[derive(Debug, Clone, Copy, PartialEq, Default, Reflect)] +#[reflect(Debug)] +pub enum ScalingMode { + /// Scale the texture uniformly (maintain the texture's aspect ratio) + /// so that both dimensions (width and height) of the texture will be equal + /// to or larger than the corresponding dimension of the target rectangle. + /// Fill sprite with a centered texture. + #[default] + FillCenter, + /// Scales the texture to fill the target rectangle while maintaining its aspect ratio. + /// One dimension of the texture will match the rectangle's size, + /// while the other dimension may exceed it. + /// The exceeding portion is aligned to the start: + /// * Horizontal overflow is left-aligned if the width exceeds the rectangle. + /// * Vertical overflow is top-aligned if the height exceeds the rectangle. + FillStart, + /// Scales the texture to fill the target rectangle while maintaining its aspect ratio. + /// One dimension of the texture will match the rectangle's size, + /// while the other dimension may exceed it. + /// The exceeding portion is aligned to the end: + /// * Horizontal overflow is right-aligned if the width exceeds the rectangle. + /// * Vertical overflow is bottom-aligned if the height exceeds the rectangle. + FillEnd, + /// Scaling the texture will maintain the original aspect ratio + /// and ensure that the original texture fits entirely inside the rect. + /// At least one axis (x or y) will fit exactly. The result is centered inside the rect. + FitCenter, + /// Scaling the texture will maintain the original aspect ratio + /// and ensure that the original texture fits entirely inside rect. + /// At least one axis (x or y) will fit exactly. + /// Aligns the result to the left and top edges of rect. + FitStart, + /// Scaling the texture will maintain the original aspect ratio + /// and ensure that the original texture fits entirely inside rect. + /// At least one axis (x or y) will fit exactly. + /// Aligns the result to the right and bottom edges of rect. + FitEnd, } /// How a sprite is positioned relative to its [`Transform`]. diff --git a/crates/bevy_sprite/src/texture_slice/computed_slices.rs b/crates/bevy_sprite/src/texture_slice/computed_slices.rs index 6905bf1697d10..c258e5f652af0 100644 --- a/crates/bevy_sprite/src/texture_slice/computed_slices.rs +++ b/crates/bevy_sprite/src/texture_slice/computed_slices.rs @@ -53,6 +53,7 @@ impl ComputedTextureSlices { flip_y, image_handle_id: sprite.image.id(), anchor: Self::redepend_anchor_from_sprite_to_slice(sprite, slice), + scaling_mode: sprite.image_mode.scale(), } }) } @@ -123,6 +124,9 @@ fn compute_sprite_slices( SpriteImageMode::Auto => { unreachable!("Slices should not be computed for SpriteImageMode::Stretch") } + SpriteImageMode::Scale(_) => { + unreachable!("Slices should not be computed for SpriteImageMode::Scale") + } }; Some(ComputedTextureSlices(slices)) } diff --git a/crates/bevy_text/src/text2d.rs b/crates/bevy_text/src/text2d.rs index 31c1ad76e302b..051ed5c6f30b2 100644 --- a/crates/bevy_text/src/text2d.rs +++ b/crates/bevy_text/src/text2d.rs @@ -213,6 +213,7 @@ pub fn extract_text2d_sprite( flip_y: false, anchor: Anchor::Center.as_vec(), original_entity: Some(original_entity), + scaling_mode: None, }, ); } diff --git a/crates/bevy_ui/src/render/ui_texture_slice_pipeline.rs b/crates/bevy_ui/src/render/ui_texture_slice_pipeline.rs index 2cb70ed51c460..7727fa4d3a753 100644 --- a/crates/bevy_ui/src/render/ui_texture_slice_pipeline.rs +++ b/crates/bevy_ui/src/render/ui_texture_slice_pipeline.rs @@ -814,7 +814,10 @@ fn compute_texture_slices( [[0., 0., 1., 1.], [0., 0., 1., 1.], [1., 1., rx, ry]] } SpriteImageMode::Auto => { - unreachable!("Slices should not be computed for ImageScaleMode::Stretch") + unreachable!("Slices can not be computed for SpriteImageMode::Stretch") + } + SpriteImageMode::Scale(_) => { + unreachable!("Slices can not be computed for SpriteImageMode::Scale") } } } diff --git a/examples/2d/sprite_scale.rs b/examples/2d/sprite_scale.rs new file mode 100644 index 0000000000000..036ff841b504e --- /dev/null +++ b/examples/2d/sprite_scale.rs @@ -0,0 +1,332 @@ +//! Shows how to use sprite scaling to fill and fit textures into the sprite. + +use bevy::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems( + Startup, + (setup_sprites, setup_texture_atlas).after(setup_camera), + ) + .add_systems(Update, animate_sprite) + .run(); +} + +fn setup_camera(mut commands: Commands) { + commands.spawn(Camera2d); +} + +fn setup_sprites(mut commands: Commands, asset_server: Res) { + let square = asset_server.load("textures/slice_square_2.png"); + let banner = asset_server.load("branding/banner.png"); + + let rects = [ + Rect { + size: Vec2::new(100., 225.), + text: "Stretched".to_string(), + transform: Transform::from_translation(Vec3::new(-570., 230., 0.)), + texture: square.clone(), + image_mode: SpriteImageMode::Auto, + }, + Rect { + size: Vec2::new(100., 225.), + text: "Fill Center".to_string(), + transform: Transform::from_translation(Vec3::new(-450., 230., 0.)), + texture: square.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FillCenter), + }, + Rect { + size: Vec2::new(100., 225.), + text: "Fill Start".to_string(), + transform: Transform::from_translation(Vec3::new(-330., 230., 0.)), + texture: square.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FillStart), + }, + Rect { + size: Vec2::new(100., 225.), + text: "Fill End".to_string(), + transform: Transform::from_translation(Vec3::new(-210., 230., 0.)), + texture: square.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FillEnd), + }, + Rect { + size: Vec2::new(300., 100.), + text: "Fill Start Horizontal".to_string(), + transform: Transform::from_translation(Vec3::new(10., 290., 0.)), + texture: square.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FillStart), + }, + Rect { + size: Vec2::new(300., 100.), + text: "Fill End Horizontal".to_string(), + transform: Transform::from_translation(Vec3::new(10., 155., 0.)), + texture: square.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FillEnd), + }, + Rect { + size: Vec2::new(200., 200.), + text: "Fill Center".to_string(), + transform: Transform::from_translation(Vec3::new(280., 230., 0.)), + texture: banner.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FillCenter), + }, + Rect { + size: Vec2::new(200., 100.), + text: "Fill Center".to_string(), + transform: Transform::from_translation(Vec3::new(500., 230., 0.)), + texture: square.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FillCenter), + }, + Rect { + size: Vec2::new(100., 100.), + text: "Stretched".to_string(), + transform: Transform::from_translation(Vec3::new(-570., -40., 0.)), + texture: banner.clone(), + image_mode: SpriteImageMode::Auto, + }, + Rect { + size: Vec2::new(200., 200.), + text: "Fit Center".to_string(), + transform: Transform::from_translation(Vec3::new(-400., -40., 0.)), + texture: banner.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FitCenter), + }, + Rect { + size: Vec2::new(200., 200.), + text: "Fit Start".to_string(), + transform: Transform::from_translation(Vec3::new(-180., -40., 0.)), + texture: banner.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FitStart), + }, + Rect { + size: Vec2::new(200., 200.), + text: "Fit End".to_string(), + transform: Transform::from_translation(Vec3::new(40., -40., 0.)), + texture: banner.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FitEnd), + }, + Rect { + size: Vec2::new(100., 200.), + text: "Fit Center".to_string(), + transform: Transform::from_translation(Vec3::new(210., -40., 0.)), + texture: banner.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FitCenter), + }, + ]; + + for rect in rects { + let mut cmd = commands.spawn(( + Sprite { + image: rect.texture, + custom_size: Some(rect.size), + image_mode: rect.image_mode, + ..default() + }, + rect.transform, + )); + + cmd.with_children(|builder| { + builder.spawn(( + Text2d::new(rect.text), + TextLayout::new_with_justify(JustifyText::Center), + TextFont::from_font_size(15.), + Transform::from_xyz(0., -0.5 * rect.size.y - 10., 0.), + bevy::sprite::Anchor::TopCenter, + )); + }); + } +} + +fn setup_texture_atlas( + mut commands: Commands, + asset_server: Res, + mut texture_atlas_layouts: ResMut>, +) { + commands.spawn(Camera2d); + let gabe = asset_server.load("textures/rpg/chars/gabe/gabe-idle-run.png"); + let animation_indices_gabe = AnimationIndices { first: 0, last: 6 }; + let gabe_atlas = TextureAtlas { + layout: texture_atlas_layouts.add(TextureAtlasLayout::from_grid( + UVec2::splat(24), + 7, + 1, + None, + None, + )), + index: animation_indices_gabe.first, + }; + + let sprite_sheets = [ + SpriteSheet { + size: Vec2::new(120., 50.), + text: "Stretched".to_string(), + transform: Transform::from_translation(Vec3::new(-570., -200., 0.)), + texture: gabe.clone(), + image_mode: SpriteImageMode::Auto, + atlas: gabe_atlas.clone(), + indices: animation_indices_gabe.clone(), + timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)), + }, + SpriteSheet { + size: Vec2::new(120., 50.), + text: "Fill Center".to_string(), + transform: Transform::from_translation(Vec3::new(-570., -300., 0.)), + texture: gabe.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FillCenter), + atlas: gabe_atlas.clone(), + indices: animation_indices_gabe.clone(), + timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)), + }, + SpriteSheet { + size: Vec2::new(120., 50.), + text: "Fill Start".to_string(), + transform: Transform::from_translation(Vec3::new(-430., -200., 0.)), + texture: gabe.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FillStart), + atlas: gabe_atlas.clone(), + indices: animation_indices_gabe.clone(), + timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)), + }, + SpriteSheet { + size: Vec2::new(120., 50.), + text: "Fill End".to_string(), + transform: Transform::from_translation(Vec3::new(-430., -300., 0.)), + texture: gabe.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FillEnd), + atlas: gabe_atlas.clone(), + indices: animation_indices_gabe.clone(), + timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)), + }, + SpriteSheet { + size: Vec2::new(50., 120.), + text: "Fill Center".to_string(), + transform: Transform::from_translation(Vec3::new(-300., -250., 0.)), + texture: gabe.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FillCenter), + atlas: gabe_atlas.clone(), + indices: animation_indices_gabe.clone(), + timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)), + }, + SpriteSheet { + size: Vec2::new(50., 120.), + text: "Fill Start".to_string(), + transform: Transform::from_translation(Vec3::new(-190., -250., 0.)), + texture: gabe.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FillStart), + atlas: gabe_atlas.clone(), + indices: animation_indices_gabe.clone(), + timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)), + }, + SpriteSheet { + size: Vec2::new(50., 120.), + text: "Fill End".to_string(), + transform: Transform::from_translation(Vec3::new(-90., -250., 0.)), + texture: gabe.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FillEnd), + atlas: gabe_atlas.clone(), + indices: animation_indices_gabe.clone(), + timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)), + }, + SpriteSheet { + size: Vec2::new(120., 50.), + text: "Fit Center".to_string(), + transform: Transform::from_translation(Vec3::new(20., -200., 0.)), + texture: gabe.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FitCenter), + atlas: gabe_atlas.clone(), + indices: animation_indices_gabe.clone(), + timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)), + }, + SpriteSheet { + size: Vec2::new(120., 50.), + text: "Fit Start".to_string(), + transform: Transform::from_translation(Vec3::new(20., -300., 0.)), + texture: gabe.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FitStart), + atlas: gabe_atlas.clone(), + indices: animation_indices_gabe.clone(), + timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)), + }, + SpriteSheet { + size: Vec2::new(120., 50.), + text: "Fit End".to_string(), + transform: Transform::from_translation(Vec3::new(160., -200., 0.)), + texture: gabe.clone(), + image_mode: SpriteImageMode::Scale(ScalingMode::FitEnd), + atlas: gabe_atlas.clone(), + indices: animation_indices_gabe.clone(), + timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)), + }, + ]; + + for sprite_sheet in sprite_sheets { + let mut cmd = commands.spawn(( + Sprite { + image_mode: sprite_sheet.image_mode, + custom_size: Some(sprite_sheet.size), + ..Sprite::from_atlas_image(sprite_sheet.texture.clone(), sprite_sheet.atlas.clone()) + }, + sprite_sheet.indices, + sprite_sheet.timer, + sprite_sheet.transform, + )); + + cmd.with_children(|builder| { + builder.spawn(( + Text2d::new(sprite_sheet.text), + TextLayout::new_with_justify(JustifyText::Center), + TextFont::from_font_size(15.), + Transform::from_xyz(0., -0.5 * sprite_sheet.size.y - 10., 0.), + bevy::sprite::Anchor::TopCenter, + )); + }); + } +} + +struct Rect { + size: Vec2, + text: String, + transform: Transform, + texture: Handle, + image_mode: SpriteImageMode, +} + +struct SpriteSheet { + size: Vec2, + text: String, + transform: Transform, + texture: Handle, + image_mode: SpriteImageMode, + atlas: TextureAtlas, + indices: AnimationIndices, + timer: AnimationTimer, +} + +#[derive(Component, Clone)] +struct AnimationIndices { + first: usize, + last: usize, +} + +#[derive(Component, Deref, DerefMut)] +struct AnimationTimer(Timer); + +fn animate_sprite( + time: Res