bevy/examples/window/custom_cursor_image.rs
mgi388 0756a19f28
Support texture atlases in CustomCursor::Image (#17121)
# 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
2025-01-14 22:27:24 +00:00

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