 0756a19f28
			
		
	
	
		0756a19f28
		
			
		
	
	
	
	
		
			
			# Objective - Bevy 0.15 added support for custom cursor images in https://github.com/bevyengine/bevy/pull/14284. - However, to do animated cursors using the initial support shipped in 0.15 means you'd have to animate the `Handle<Image>`: You can't use a `TextureAtlas` like you can with sprites and UI images. - For my use case, my cursors are spritesheets. To animate them, I'd have to break them down into multiple `Image` assets, but that seems less than ideal. ## Solution - Allow users to specify a `TextureAtlas` field when creating a custom cursor image. - To create parity with Bevy's `TextureAtlas` support on `Sprite`s and `ImageNode`s, this also allows users to specify `rect`, `flip_x` and `flip_y`. In fact, for my own use case, I need to `flip_y`. ## Testing - I added unit tests for `calculate_effective_rect` and `extract_and_transform_rgba_pixels`. - I added a brand new example for custom cursor images. It has controls to toggle fields on and off. I opted to add a new example because the existing cursor example (`window_settings`) would be far too messy for showcasing these custom cursor features (I did start down that path but decided to stop and make a brand new example). - The new example uses a [Kenny cursor icon] sprite sheet. I included the licence even though it's not required (and it's CC0). - I decided to make the example just loop through all cursor icons for its animation even though it's not a _realistic_ in-game animation sequence. - I ran the PNG through https://tinypng.com. Looks like it's about 35KB. - I'm open to adjusting the example spritesheet if required, but if it's fine as is, great. [Kenny cursor icon]: https://kenney-assets.itch.io/crosshair-pack --- ## Showcase https://github.com/user-attachments/assets/8f6be8d7-d1d4-42f9-b769-ef8532367749 ## Migration Guide The `CustomCursor::Image` enum variant has some new fields. Update your code to set them. Before: ```rust CustomCursor::Image { handle: asset_server.load("branding/icon.png"), hotspot: (128, 128), } ``` After: ```rust CustomCursor::Image { handle: asset_server.load("branding/icon.png"), texture_atlas: None, flip_x: false, flip_y: false, rect: None, hotspot: (128, 128), } ``` ## References - Feature request [originally raised in Discord]. [originally raised in Discord]: https://discord.com/channels/691052431525675048/692572690833473578/1319836362219847681
		
			
				
	
	
		
			229 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
			
		
		
	
	
			229 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
| //! Illustrates how to use a custom cursor image with a texture atlas and
 | |
| //! animation.
 | |
| 
 | |
| use std::time::Duration;
 | |
| 
 | |
| use bevy::winit::cursor::CustomCursor;
 | |
| use bevy::{prelude::*, winit::cursor::CursorIcon};
 | |
| 
 | |
| fn main() {
 | |
|     App::new()
 | |
|         .add_plugins(DefaultPlugins)
 | |
|         .add_systems(
 | |
|             Startup,
 | |
|             (setup_cursor_icon, setup_camera, setup_instructions),
 | |
|         )
 | |
|         .add_systems(
 | |
|             Update,
 | |
|             (
 | |
|                 execute_animation,
 | |
|                 toggle_texture_atlas,
 | |
|                 toggle_flip_x,
 | |
|                 toggle_flip_y,
 | |
|                 cycle_rect,
 | |
|             ),
 | |
|         )
 | |
|         .run();
 | |
| }
 | |
| 
 | |
| fn setup_cursor_icon(
 | |
|     mut commands: Commands,
 | |
|     asset_server: Res<AssetServer>,
 | |
|     mut texture_atlas_layouts: ResMut<Assets<TextureAtlasLayout>>,
 | |
|     window: Single<Entity, With<Window>>,
 | |
| ) {
 | |
|     let layout =
 | |
|         TextureAtlasLayout::from_grid(UVec2::splat(64), 20, 10, Some(UVec2::splat(5)), None);
 | |
|     let texture_atlas_layout = texture_atlas_layouts.add(layout);
 | |
| 
 | |
|     let animation_config = AnimationConfig::new(0, 199, 1, 4);
 | |
| 
 | |
|     commands.entity(*window).insert((
 | |
|         CursorIcon::Custom(CustomCursor::Image {
 | |
|             // Image to use as the cursor.
 | |
|             handle: asset_server
 | |
|                 .load("cursors/kenney_crosshairPack/Tilesheet/crosshairs_tilesheet_white.png"),
 | |
|             // Optional texture atlas allows you to pick a section of the image
 | |
|             // and animate it.
 | |
|             texture_atlas: Some(TextureAtlas {
 | |
|                 layout: texture_atlas_layout.clone(),
 | |
|                 index: animation_config.first_sprite_index,
 | |
|             }),
 | |
|             flip_x: false,
 | |
|             flip_y: false,
 | |
|             // Optional section of the image to use as the cursor.
 | |
|             rect: None,
 | |
|             // The hotspot is the point in the cursor image that will be
 | |
|             // positioned at the mouse cursor's position.
 | |
|             hotspot: (0, 0),
 | |
|         }),
 | |
|         animation_config,
 | |
|     ));
 | |
| }
 | |
| 
 | |
| fn setup_camera(mut commands: Commands) {
 | |
|     commands.spawn(Camera3d::default());
 | |
| }
 | |
| 
 | |
| fn setup_instructions(mut commands: Commands) {
 | |
|     commands.spawn((
 | |
|         Text::new(
 | |
|             "Press T to toggle the cursor's `texture_atlas`.\n
 | |
| Press X to toggle the cursor's `flip_x` setting.\n
 | |
| Press Y to toggle the cursor's `flip_y` setting.\n
 | |
| Press C to cycle through the sections of the cursor's image using `rect`.",
 | |
|         ),
 | |
|         Node {
 | |
|             position_type: PositionType::Absolute,
 | |
|             bottom: Val::Px(12.0),
 | |
|             left: Val::Px(12.0),
 | |
|             ..default()
 | |
|         },
 | |
|     ));
 | |
| }
 | |
| 
 | |
| #[derive(Component)]
 | |
| struct AnimationConfig {
 | |
|     first_sprite_index: usize,
 | |
|     last_sprite_index: usize,
 | |
|     increment: usize,
 | |
|     fps: u8,
 | |
|     frame_timer: Timer,
 | |
| }
 | |
| 
 | |
| impl AnimationConfig {
 | |
|     fn new(first: usize, last: usize, increment: usize, fps: u8) -> Self {
 | |
|         Self {
 | |
|             first_sprite_index: first,
 | |
|             last_sprite_index: last,
 | |
|             increment,
 | |
|             fps,
 | |
|             frame_timer: Self::timer_from_fps(fps),
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     fn timer_from_fps(fps: u8) -> Timer {
 | |
|         Timer::new(Duration::from_secs_f32(1.0 / (fps as f32)), TimerMode::Once)
 | |
|     }
 | |
| }
 | |
| 
 | |
| /// This system loops through all the sprites in the [`CursorIcon`]'s
 | |
| /// [`TextureAtlas`], from [`AnimationConfig`]'s `first_sprite_index` to
 | |
| /// `last_sprite_index`.
 | |
| fn execute_animation(time: Res<Time>, mut query: Query<(&mut AnimationConfig, &mut CursorIcon)>) {
 | |
|     for (mut config, mut cursor_icon) in &mut query {
 | |
|         if let CursorIcon::Custom(CustomCursor::Image {
 | |
|             ref mut texture_atlas,
 | |
|             ..
 | |
|         }) = *cursor_icon
 | |
|         {
 | |
|             config.frame_timer.tick(time.delta());
 | |
| 
 | |
|             if config.frame_timer.finished() {
 | |
|                 if let Some(atlas) = texture_atlas {
 | |
|                     atlas.index += config.increment;
 | |
| 
 | |
|                     if atlas.index > config.last_sprite_index {
 | |
|                         atlas.index = config.first_sprite_index;
 | |
|                     }
 | |
| 
 | |
|                     config.frame_timer = AnimationConfig::timer_from_fps(config.fps);
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| fn toggle_texture_atlas(
 | |
|     input: Res<ButtonInput<KeyCode>>,
 | |
|     mut query: Query<&mut CursorIcon, With<Window>>,
 | |
|     mut cached_atlas: Local<Option<TextureAtlas>>, // this lets us restore the previous value
 | |
| ) {
 | |
|     if input.just_pressed(KeyCode::KeyT) {
 | |
|         for mut cursor_icon in &mut query {
 | |
|             if let CursorIcon::Custom(CustomCursor::Image {
 | |
|                 ref mut texture_atlas,
 | |
|                 ..
 | |
|             }) = *cursor_icon
 | |
|             {
 | |
|                 *texture_atlas = match texture_atlas.take() {
 | |
|                     Some(a) => {
 | |
|                         *cached_atlas = Some(a.clone());
 | |
|                         None
 | |
|                     }
 | |
|                     None => cached_atlas.take(),
 | |
|                 };
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| fn toggle_flip_x(
 | |
|     input: Res<ButtonInput<KeyCode>>,
 | |
|     mut query: Query<&mut CursorIcon, With<Window>>,
 | |
| ) {
 | |
|     if input.just_pressed(KeyCode::KeyX) {
 | |
|         for mut cursor_icon in &mut query {
 | |
|             if let CursorIcon::Custom(CustomCursor::Image { ref mut flip_x, .. }) = *cursor_icon {
 | |
|                 *flip_x = !*flip_x;
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| fn toggle_flip_y(
 | |
|     input: Res<ButtonInput<KeyCode>>,
 | |
|     mut query: Query<&mut CursorIcon, With<Window>>,
 | |
| ) {
 | |
|     if input.just_pressed(KeyCode::KeyY) {
 | |
|         for mut cursor_icon in &mut query {
 | |
|             if let CursorIcon::Custom(CustomCursor::Image { ref mut flip_y, .. }) = *cursor_icon {
 | |
|                 *flip_y = !*flip_y;
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| /// This system alternates the [`CursorIcon`]'s `rect` field between `None` and
 | |
| /// 4 sections/rectangles of the cursor's image.
 | |
| fn cycle_rect(input: Res<ButtonInput<KeyCode>>, mut query: Query<&mut CursorIcon, With<Window>>) {
 | |
|     if !input.just_pressed(KeyCode::KeyC) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const RECT_SIZE: u32 = 32; // half the size of a tile in the texture atlas
 | |
| 
 | |
|     const SECTIONS: [Option<URect>; 5] = [
 | |
|         Some(URect {
 | |
|             min: UVec2::ZERO,
 | |
|             max: UVec2::splat(RECT_SIZE),
 | |
|         }),
 | |
|         Some(URect {
 | |
|             min: UVec2::new(RECT_SIZE, 0),
 | |
|             max: UVec2::new(2 * RECT_SIZE, RECT_SIZE),
 | |
|         }),
 | |
|         Some(URect {
 | |
|             min: UVec2::new(0, RECT_SIZE),
 | |
|             max: UVec2::new(RECT_SIZE, 2 * RECT_SIZE),
 | |
|         }),
 | |
|         Some(URect {
 | |
|             min: UVec2::new(RECT_SIZE, RECT_SIZE),
 | |
|             max: UVec2::splat(2 * RECT_SIZE),
 | |
|         }),
 | |
|         None, // reset to None
 | |
|     ];
 | |
| 
 | |
|     for mut cursor_icon in &mut query {
 | |
|         if let CursorIcon::Custom(CustomCursor::Image { ref mut rect, .. }) = *cursor_icon {
 | |
|             let next_rect = SECTIONS
 | |
|                 .iter()
 | |
|                 .cycle()
 | |
|                 .skip_while(|&&corner| corner != *rect)
 | |
|                 .nth(1) // move to the next element
 | |
|                 .unwrap_or(&None);
 | |
| 
 | |
|             *rect = *next_rect;
 | |
|         }
 | |
|     }
 | |
| }
 |