
# 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;
|
|
}
|
|
}
|
|
}
|