From 79ad89a3f041bf2a1fba62b87ec8fe52a03478e6 Mon Sep 17 00:00:00 2001 From: Tristan Guichaoua Date: Fri, 11 Aug 2023 19:47:03 +0200 Subject: [PATCH] First commit --- .github/workflows/ci.yml | 53 +++++ .gitignore | 3 + Cargo.toml | 55 +++++ README.md | 49 ++++ assets/isometric-sheet.png | Bin 0 -> 2318 bytes examples/basic.rs | 17 ++ examples/multiple_windows.rs | 353 +++++++++++++++++++++++++++++ examples/tilemap.rs | 193 ++++++++++++++++ examples/utils/change_detection.rs | 78 +++++++ examples/utils/mod.rs | 1 + src/lib.rs | 227 +++++++++++++++++++ 11 files changed, 1029 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 assets/isometric-sheet.png create mode 100644 examples/basic.rs create mode 100644 examples/multiple_windows.rs create mode 100644 examples/tilemap.rs create mode 100644 examples/utils/change_detection.rs create mode 100644 examples/utils/mod.rs create mode 100644 src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1a407f2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +env: + RUSTFLAGS: -Dwarnings + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@stable + - name: No features + run: cargo check --no-default-features + - name: All features + run: cargo check --features 2d,3d + - name: Examples + run: cargo check --examples + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --all-features + + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@stable + - run: cargo fmt --all -- --check + + clippy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@stable + - run: cargo clippy --all-features -- -D warnings + + doc: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@stable + - run: cargo doc --all-features --no-deps --document-private-items diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d6c90f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/Cargo.lock +.vscode \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..207d764 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "bevy_cursor" +version = "0.1.0" +edition = "2021" +authors = ["Tristan Guichaoua "] +description = "A bevy plugin to track informations about the cursor" +repository = "github.com/tguichaoua/bevy_cursor" +license = "MIT OR Apache-2.0" +keywords = ["bevy", "cursor", "window", "camera"] +categories = ["game-engines"] + + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +default = ["2d"] +2d = [] +3d = [] + + +[dependencies] +bevy = { version = "0.11.0", default-features = false, features = [ + "bevy_render", +] } +smallvec = { version = "1.11.0", features = ["union"] } + +[dev-dependencies] +bevy = { version = "0.11.0", default-features = false, features = [ + "bevy_ui", + "bevy_winit", + "default_font", + "png", +] } +# bevy_ecs_tilemap = "0.11.0" +bevy_ecs_tilemap = { git = "https://github.com/StarArawn/bevy_ecs_tilemap.git", rev = "fbda80c735bf7faaa2cc4d79524cfbf016044a0f" } +bevy_pancam = "0.9.0" + +[package.metadata.docs.rs] +# To build locally: +# RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --all-features --no-deps --open +all-features = true +# enable unstable features in the documentation +rustdoc-args = ["--cfg", "docsrs"] + +[[example]] +name = "basic" +required-features = ["2d"] + +[[example]] +name = "multiple_windows" +required-features = ["2d"] + +[[example]] +name = "tilemap" +required-features = ["2d"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7cb2a15 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Bevy Cursor + +[![Latest Version]][crates.io] [![Bevy Tracking]][bevy tracking doc] [![Doc Status]][docs] [![Build Status]][actions] + +[Latest Version]: https://img.shields.io/crates/v/bevy_cursor.svg +[crates.io]: https://crates.io/crates/bevy_cursor +[Bevy Tracking]: https://img.shields.io/badge/Bevy%20tracking-released%20version-lightblue?labelColor=555555&logo=bevy +[bevy tracking doc]: https://github.com/bevyengine/bevy/blob/main/docs/plugins_guidelines.md#main-branch-tracking +[Doc Status]: https://docs.rs/bevy_cursor/badge.svg +[docs]: https://docs.rs/bevy_cursor +[Build Status]: https://github.com/tguichaoua/bevy-cursor/actions/workflows/ci.yml/badge.svg?branch=main +[actions]: https://github.com/tguichaoua/bevy-cursor/actions/workflows/ci.yml + +**Bevy Cursor is a [`bevy`](https://github.com/bevyengine/bevy) plugin to track informations about the cursor.** + +--- + +## Example + +```rust +use bevy::prelude::*; +use bevy_cursor::prelude::*; + + +fn main() { + App::new() + // + .add_plugins(DefaultPlugins) + .add_plugins(CursorInfoPlugin) // Add the plugin + // + .add_systems(Update, print_cursor_info) + .run(); +} + +fn print_cursor_info(cursor: Res) { + if let Some(position) = cursor.position() { + info!("Cursor position: {position:?}"); + } else { + info!("The cursor is not in any window"); + } +} + +``` + +## Bevy compatible version + +| bevy | bevy_cursor | +| ---- | ----------- | +| 0.11 | 0.1 | diff --git a/assets/isometric-sheet.png b/assets/isometric-sheet.png new file mode 100644 index 0000000000000000000000000000000000000000..2ddcf9edb6f29cd48cddef76cc4b9d7ea65ccfdc GIT binary patch literal 2318 zcma);c~leE9>)imBO~jCRV^i%1bh`iY!Rg(4q5sV6afR+KvYuPK&-eg<(Nn`Bmrs& z;0g$MKwm5%s~)hj)TG9~Cj$Dwtq1{?h%7-G28iU1E$JU^PtW`3cYo(|@16U*=ls6k zJJQH-rmfW?D*ym&Ls<+q0GOS?^AKnr9vwNw&G=w;fX$=+isHDlb{_1a02Z2 z3{hGx7>s>p!e)>Q1@7wr~Ex!g&nzh^I7+o zGhBPKOyf^oT@lxR&0c*wgmvb8B7kas-hC3|exEuelDriauQRB-C#}gfu0mT)%07Lc zAK{5L^&BS8t@Zl=m+2@k+8#*NTcDMaW>sIur-YZ*)bV^9XEebRq^o3{`K^&U%0}Aq zXoKVo{5u}7qSmY;F6A?lp&v7d9gBq38fINOuN)&(APY=5^kd;*%A$1!$yr$GfL`_l ztCh0E`BZNmC5{H0aE=$)(c^svBPbA-C!etkVoQngMZ#zevoxJ#!XAgA#m%93t!!F0 zzA+CTa6rkPV1rV|nol)hRymwCt$8&%_Sywydx2$2S)LVjiH=f6i(G49Dd0SZIUHt7 zZFfC0y+;5w&`4hxSd}n~faWX(*-F`fRhpBI@+($9hHa*|-P17HqL6EtJQ~UN;(NqE zr5VIfXLE%Kx5K*)9_uyC1W`yPCg&jSWWL8ns7^wZI-C2La0!BGGcT%SS+;2#b(F32 z;ur(|^+ad%vz1_+2_HuiH`jUaw{}1sx?b}1Cv+6 z9A~uC+59^VvysM&F|fXb$}@;stF#y$Wq~ku{dmd`2sT-CoLu9}KSkd={i2AmE#3f$ zdna#-=d%9WsdL%bXtPA!gc0moJui=r3ns_^9hY&oFF3#T);CAO<6ro8vFmI`pV;*1 z3zHA(``*n>KKr@nN^Wg!y_3(K)RiZXY){T?DlEEQmbkFT@#I}vdD^SWp?3gB;%1f{ z0Ul4ShCes&x&%$@6OjF2c`#vOSVbFvIH}~pXPF{3p-RuFO7fi@DN-f=k_1c!tqyED zO3JE-PmO%#1D2$6tf_@78UzJrtx~MA8>LrNOD;Un7#>t2bcfr6?|x(5CPQk#}}$968@W><6B~pyG9o zyYSJ0PDfl_RF3%eX zON8n&iSupK4(li-Nc%Fr#|rSNN_Hweq!yD`BkC!E)jf-r2~i7PNfXR*L_1y0lQqm~ z8gIRU6%Tn!@%9utG{@xm05<8sb*?4-M0dqWq-Ch974AJW#Dm5fw>Q!v$Gby)w>S4^ zTp3H23v&N5W(#ujS5-c||53MgZ1B9D>~uHrTrmf5ySuNT=IDpPOJ&g;a5`#53EPWC7I1H#Mxzb*TvgPJ~X|Muq#?RP5DUbP(! zSZBN9$d7kocNI7Ixwo+`?iWsloW0mv=-bTSznr_7>+l3E5UGq?N0|)9K|y`l?Jit{FEZoRiFnsA!q>=oFFu0{UBPEugt)Vr@B%OXl~q92 z1gvyH$-!W?O2)FM?$%K{=`hzIG2s%;&Uuz6ut6ouv!m|89eNIjIir4mCz{{1RIPjvW(VX3f}KiPsa@I% z9YsMW;hp&gW;voca~QAFufx3M4@&u?fU?&b!X|*_zl-FrwNvVX=Kh^(ZejBF4=+;g zY9K5gDwh(q)@lAa%5kJ>F~8Io>{7~1TEg!g_11o-@8Rw3@3wxY8P&`ZugA@BodvG9 zWW+lCO|RKymxkZ|mIQ!VF}frBQb#u?8=q@-&RV{_a6gvaQWJ##-~gdtgfl8>JmLQU DN;oZv literal 0 HcmV?d00001 diff --git a/examples/basic.rs b/examples/basic.rs new file mode 100644 index 0000000..a6b4546 --- /dev/null +++ b/examples/basic.rs @@ -0,0 +1,17 @@ +use bevy::prelude::*; +use bevy_cursor::prelude::*; + +fn main() { + App::new() + .add_plugins((DefaultPlugins, CursorInfoPlugin)) + .add_systems(Update, print_cursor_position) + .run(); +} + +fn print_cursor_position(cursor: Res) { + if let Some(position) = cursor.position() { + info!("Cursor position: {position:?}"); + } else { + info!("The cursor is not in any window"); + } +} diff --git a/examples/multiple_windows.rs b/examples/multiple_windows.rs new file mode 100644 index 0000000..143e35a --- /dev/null +++ b/examples/multiple_windows.rs @@ -0,0 +1,353 @@ +use bevy::core_pipeline::clear_color::ClearColorConfig; +use bevy::prelude::*; +use bevy::render::camera::{RenderTarget, Viewport}; +use bevy::window::{ExitCondition, WindowRef, WindowResized}; +use bevy_cursor::prelude::*; + +const WINDOW_SIZE: Vec2 = Vec2::new(600.0, 400.0); + +fn main() { + App::new() + // + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + title: String::from("Primary"), + resolution: WINDOW_SIZE.into(), + ..default() + }), + exit_condition: ExitCondition::OnPrimaryClosed, + ..default() + })) + .add_plugins(CursorInfoPlugin) + // + .add_systems(Startup, setup) + .add_systems(Update, set_camera_viewports) + .add_systems( + Update, + print_cursor_data.run_if(resource_changed::()), + ) + .run(); +} + +// ============================================================================= + +/// A component to give a name to our cameras. +#[derive(Component)] +struct Name(String); + +/// A marker for the left camera. +#[derive(Component)] +struct LeftCamera; + +/// A marker for the right camera. +#[derive(Component)] +struct RightCamera; + +/// A marker for the secondary window. +#[derive(Component)] +struct SecondaryWindow; + +// A bunch of marker components to identify each text. + +#[derive(Component)] +struct TextWindow; + +#[derive(Component)] +struct TextCamera; + +#[derive(Component)] +struct TextWindowPosition; + +#[derive(Component)] +struct TextWorldPosition; + +// ============================================================================= + +fn setup(mut commands: Commands) { + // Spawn a camera to render to the primary window. + commands.spawn(( + Camera2dBundle::default(), + Name(String::from("The default one")), + )); + + // Spawn a second window and two other cameras to render into. + + let secondary_window_ref = commands + .spawn(( + Window { + title: String::from("Secondary"), + resolution: WINDOW_SIZE.into(), + ..default() + }, + SecondaryWindow, + )) + .id(); + + // The left camera + { + commands.spawn(( + Camera2dBundle { + transform: Transform::from_xyz(1000.0, 0.0, 0.0), + camera: Camera { + target: RenderTarget::Window(WindowRef::Entity(secondary_window_ref)), + ..default() + }, + ..default() + }, + Name(String::from("The left one")), + LeftCamera, + )); + + commands.spawn(Text2dBundle { + transform: Transform::from_xyz(1000.0, 0.0, 0.0), + text: Text::from_section( + "Left", + TextStyle { + font_size: 40.0, + color: Color::WHITE, + ..default() + }, + ), + ..default() + }); + + commands.spawn(SpriteBundle { + transform: Transform::from_xyz(1000.0, 0.0, -1.0), + sprite: Sprite { + color: Color::VIOLET, + custom_size: Some(Vec2::splat(1000.0)), + ..default() + }, + ..default() + }); + } + + // The right camera + { + commands.spawn(( + Camera2dBundle { + transform: Transform::from_xyz(2000.0, 0.0, 0.0), + camera: Camera { + target: RenderTarget::Window(WindowRef::Entity(secondary_window_ref)), + order: 1, + ..default() + }, + camera_2d: Camera2d { + // don't clear on the second camera because the first camera already cleared the window + clear_color: ClearColorConfig::None, + }, + ..default() + }, + Name(String::from("The right one")), + RightCamera, + )); + + commands.spawn(Text2dBundle { + transform: Transform::from_xyz(2000.0, 0.0, 0.0), + text: Text::from_section( + "Right", + TextStyle { + font_size: 40.0, + color: Color::WHITE, + ..default() + }, + ), + ..default() + }); + + commands.spawn(SpriteBundle { + transform: Transform::from_xyz(2000.0, 0.0, -1.0), + sprite: Sprite { + color: Color::GREEN, + custom_size: Some(Vec2::splat(1000.0)), + ..default() + }, + ..default() + }); + } + + // Spawn ui texts + + const FONT_SIZE: f32 = 30.0; + + commands + .spawn(NodeBundle { + style: Style { + flex_direction: FlexDirection::Column, + ..default() + }, + ..default() + }) + .with_children(|parent| { + parent.spawn(( + TextBundle::from_sections([ + TextSection::new( + "Window: ", + TextStyle { + font_size: FONT_SIZE, + color: Color::WHITE, + ..default() + }, + ), + TextSection::from_style(TextStyle { + font_size: FONT_SIZE, + color: Color::GOLD, + ..default() + }), + ]), + TextWindow, + )); + + parent.spawn(( + TextBundle::from_sections([ + TextSection::new( + "Camera: ", + TextStyle { + font_size: FONT_SIZE, + color: Color::WHITE, + ..default() + }, + ), + TextSection::from_style(TextStyle { + font_size: FONT_SIZE, + color: Color::GOLD, + ..default() + }), + ]), + TextCamera, + )); + + parent.spawn(( + TextBundle::from_sections([ + TextSection::new( + "Window position: ", + TextStyle { + font_size: FONT_SIZE, + color: Color::WHITE, + ..default() + }, + ), + TextSection::from_style(TextStyle { + font_size: FONT_SIZE, + color: Color::GOLD, + ..default() + }), + ]), + TextWindowPosition, + )); + + parent.spawn(( + TextBundle::from_sections([ + TextSection::new( + "World Position: ", + TextStyle { + font_size: FONT_SIZE, + color: Color::WHITE, + ..default() + }, + ), + TextSection::from_style(TextStyle { + font_size: FONT_SIZE, + color: Color::GOLD, + ..default() + }), + ]), + TextWorldPosition, + )); + }); +} + +// ============================================================================= + +/// Update the viewport of the camera on the secondary window when this one is resized. +fn set_camera_viewports( + secondary_window_q: Query<&Window, With>, + mut resize_events: EventReader, + mut left_camera_q: Query<&mut Camera, (With, Without)>, + mut right_camera_q: Query<&mut Camera, With>, +) { + for resize_event in resize_events.iter() { + let Ok(window) = secondary_window_q.get(resize_event.window) else { continue; }; + + let mut left_camera = left_camera_q.single_mut(); + left_camera.viewport = Some(Viewport { + physical_position: UVec2::new(0, 0), + physical_size: UVec2::new( + window.resolution.physical_width() / 2, + window.resolution.physical_height(), + ), + ..default() + }); + + let mut right_camera = right_camera_q.single_mut(); + right_camera.viewport = Some(Viewport { + physical_position: UVec2::new(window.resolution.physical_width() / 2, 0), + physical_size: UVec2::new( + window.resolution.physical_width() / 2, + window.resolution.physical_height(), + ), + ..default() + }); + } +} + +// ============================================================================= + +/// Update the textes with the cursor informations. +#[allow(clippy::type_complexity)] +fn print_cursor_data( + cursor: Res, + + mut set: ParamSet<( + Query<&mut Text, With>, + Query<&mut Text, With>, + Query<&mut Text, With>, + Query<&mut Text, With>, + )>, + + window_q: Query<&Window>, + name_q: Query<&Name>, +) { + // A closure that update the `Text`s' value. + let mut set_texts = |a, b, c, d| { + let mut window_text_q = set.p0(); + let mut window_text = window_text_q.single_mut(); + window_text.sections[1].value = a; + + let mut camera_text_q = set.p1(); + let mut camera_text = camera_text_q.single_mut(); + camera_text.sections[1].value = b; + + let mut viewport_position_text_q = set.p2(); + let mut viewport_position_text = viewport_position_text_q.single_mut(); + viewport_position_text.sections[1].value = c; + + let mut world_position_text_q = set.p3(); + let mut world_position_text = world_position_text_q.single_mut(); + world_position_text.sections[1].value = d; + }; + + if let Some(cursor) = cursor.get() { + set_texts( + format!( + "{:?} {:?}", + cursor.window, + window_q.get(cursor.window).unwrap().title + ), + format!( + "{:?} {:?}", + cursor.camera, + name_q.get(cursor.camera).unwrap().0 + ), + cursor.window_position.to_string(), + cursor.position.to_string(), + ); + } else { + set_texts( + String::default(), + String::default(), + String::default(), + String::default(), + ); + } +} diff --git a/examples/tilemap.rs b/examples/tilemap.rs new file mode 100644 index 0000000..6d51e7b --- /dev/null +++ b/examples/tilemap.rs @@ -0,0 +1,193 @@ +use bevy::math::Vec4Swizzles; +use bevy::prelude::*; +use bevy_cursor::prelude::*; +use bevy_ecs_tilemap::prelude::*; +use bevy_pancam::{PanCam, PanCamPlugin}; + +mod utils; +use utils::change_detection::DetectChangesMutExt; + +const MAP_SIZE: TilemapSize = TilemapSize { x: 20, y: 20 }; + +fn main() { + App::new() + // + .add_event::() + // + .insert_resource(TilemapRenderSettings { + y_sort: true, + render_chunk_size: UVec2::new(MAP_SIZE.x, 1), + }) + .init_resource::() + // + .add_plugins(( + DefaultPlugins, + CursorInfoPlugin, + TilemapPlugin, + PanCamPlugin, + )) + // + .add_systems(Startup, setup) + .add_systems( + First, + update_hovered_tile + .after(UpdateCursorInfo) + .run_if(resource_changed::()), + ) + .add_systems(Update, colorize_tile_on_hover) + .run(); +} + +// ============================================================================= + +/// The currently hovered tile entity, if any. +#[derive(Resource, Default)] +pub struct HoveredTile(pub Option); + +/// Event emitted when the cursor enter or leave a tile. +#[derive(Event)] +pub enum TileHoverEvent { + Leave(Entity), + Enter(Entity), +} + +/// The original [`TileTextureIndex`] value of the tile. +#[derive(Component)] +pub struct BaseTileTextureIndex(TileTextureIndex); + +// ============================================================================= + +fn setup( + mut commands: Commands, + asset_server: Res, + array_texture_loader: Res, +) { + // Spawn a camera + commands + .spawn(Camera2dBundle::default()) + .insert(PanCam::default()); + + // Spawn a tilemap + let texture_handle: Handle = asset_server.load("isometric-sheet.png"); + let texture = TilemapTexture::Single(texture_handle); + + let map_size = MAP_SIZE; + let grid_size = TilemapGridSize { x: 64.0, y: 32.0 }; + let map_type = TilemapType::Isometric(IsoCoordSystem::Diamond); + let tile_size = TilemapTileSize { x: 64.0, y: 64.0 }; + + let tilemap_entity = commands.spawn_empty().id(); + let mut tile_storage = TileStorage::empty(map_size); + + for x in 0..map_size.x { + for y in 0..map_size.y { + let position = TilePos { x, y }; + + let texture_index = 5; + + let tile_entity = commands + .spawn(( + TileBundle { + position, + tilemap_id: TilemapId(tilemap_entity), + texture_index: TileTextureIndex(texture_index), + ..default() + }, + BaseTileTextureIndex(TileTextureIndex(texture_index)), + )) + .id(); + + tile_storage.set(&position, tile_entity); + } + } + + commands.entity(tilemap_entity).insert(TilemapBundle { + grid_size, + size: map_size, + map_type, + storage: tile_storage, + texture: texture.clone(), + tile_size, + transform: get_tilemap_center_transform(&map_size, &grid_size, &map_type, 0.0), + ..default() + }); + + array_texture_loader.add(TilemapArrayTexture { + texture, + tile_size, + ..default() + }); +} + +fn update_hovered_tile( + cursor: Res, + hovered_tile: ResMut, + mut hover_tile_event_writer: EventWriter, + + tilemap_q: Query<( + &TilemapSize, + &TilemapGridSize, + &TilemapType, + &TileStorage, + &Transform, + )>, +) { + let mut hovered_tile = hovered_tile.map_unchanged(|x| &mut x.0); + + if let Some(cursor_position) = cursor.position() { + for (map_size, grid_size, map_type, tile_storage, map_transform) in tilemap_q.iter() { + // We need to make sure that the cursor's world position is correct relative to the map + // due to any map transformation. + let cursor_in_map_pos: Vec2 = { + // Extend the cursor_pos vec3 by 0.0 and 1.0 + let cursor_pos = Vec4::from((cursor_position, 0.0, 1.0)); + let cursor_in_map_pos = map_transform.compute_matrix().inverse() * cursor_pos; + cursor_in_map_pos.xy() + }; + + // Fix the gap due the dimond grid. + let cursor_in_map_pos = cursor_in_map_pos - Vec2::new(0.0, grid_size.y / 2.0); + + // Once we have a world position we can transform it into a possible tile position. + if let Some(tile_pos) = + TilePos::from_world_pos(&cursor_in_map_pos, map_size, grid_size, map_type) + { + if let Some(tile_entity) = tile_storage.get(&tile_pos) { + if let Some(previous_tile) = hovered_tile.replace_if_neq(Some(tile_entity)) { + if let Some(previous_tile) = previous_tile { + hover_tile_event_writer.send(TileHoverEvent::Leave(previous_tile)); + } + hover_tile_event_writer.send(TileHoverEvent::Enter(tile_entity)); + } + + return; + } + } + } + } + + // If the cursor is not in any window or didn't hover a tile, set the value to None. + if let Some(Some(previous_tile)) = hovered_tile.replace_if_neq(None) { + hover_tile_event_writer.send(TileHoverEvent::Leave(previous_tile)); + } +} + +pub fn colorize_tile_on_hover( + mut query: Query<(&mut TileTextureIndex, &BaseTileTextureIndex)>, + mut tile_hovered_event: EventReader, +) { + for event in tile_hovered_event.iter() { + match event { + TileHoverEvent::Leave(tile) => match query.get_mut(*tile) { + Ok((mut index, base_index)) => { + *index = base_index.0; + } + Err(error) => error!("{error}"), + }, + TileHoverEvent::Enter(tile) => match query.get_mut(*tile) { + Ok((mut index, _)) => index.0 = 3, + Err(error) => error!("{error}"), + }, + } + } +} diff --git a/examples/utils/change_detection.rs b/examples/utils/change_detection.rs new file mode 100644 index 0000000..a5c0077 --- /dev/null +++ b/examples/utils/change_detection.rs @@ -0,0 +1,78 @@ +use std::mem; + +use bevy::ecs::change_detection::DetectChangesMut; + +pub trait DetectChangesMutExt: DetectChangesMut { + /// Overwrites this smart pointer with the given value, if and only if `*self != value` + /// returning the previous value if this occurs. + /// + /// This is useful to ensure change detection is only triggered when the underlying value + /// changes, instead of every time it is mutably accessed. + /// + /// If you don't need to handle the previous value, use [`set_if_neq`](DetectChangesMut::set_if_neq). + /// + /// # Examples + /// + /// ```ignore + /// # use bevy_ecs::{prelude::*, schedule::common_conditions::{resource_changed, on_event}}; + /// #[derive(Resource, PartialEq, Eq)] + /// pub struct Score(u32); + /// + /// #[derive(Event, PartialEq, Eq)] + /// pub struct ScoreChanged { + /// current: u32, + /// previous: u32, + /// } + /// + /// fn reset_score(mut score: ResMut, mut score_changed: EventWriter) { + /// // Set the score to zero, unless it is already zero. + /// let new_score = 0; + /// if let Some(Score(previous_score)) = score.replace_if_neq(Score(new_score)) { + /// // If `score` change, emit a `ScoreChanged` event. + /// score_changed.send(ScoreChanged { + /// current: new_score, + /// previous: previous_score, + /// }); + /// } + /// } + /// # let mut world = World::new(); + /// # world.insert_resource(Events::::default()); + /// # world.insert_resource(Score(1)); + /// # let mut score_changed = IntoSystem::into_system(resource_changed::()); + /// # score_changed.initialize(&mut world); + /// # score_changed.run((), &mut world); + /// # + /// # let mut score_changed_event = IntoSystem::into_system(on_event::()); + /// # score_changed_event.initialize(&mut world); + /// # score_changed_event.run((), &mut world); + /// # + /// # let mut schedule = Schedule::new(); + /// # schedule.add_systems(reset_score); + /// # + /// # // first time `reset_score` runs, the score is changed. + /// # schedule.run(&mut world); + /// # assert!(score_changed.run((), &mut world)); + /// # assert!(score_changed_event.run((), &mut world)); + /// # // second time `reset_score` runs, the score is not changed. + /// # schedule.run(&mut world); + /// # assert!(!score_changed.run((), &mut world)); + /// # assert!(!score_changed_event.run((), &mut world)); + /// ``` + #[inline] + #[must_use = "If you don't need to handle the previous value, use `set_if_neq` instead."] + fn replace_if_neq(&mut self, value: Self::Inner) -> Option + where + Self::Inner: Sized + PartialEq, + { + let old = self.bypass_change_detection(); + if *old != value { + let previous = mem::replace(old, value); + self.set_changed(); + Some(previous) + } else { + None + } + } +} + +impl DetectChangesMutExt for T {} diff --git a/examples/utils/mod.rs b/examples/utils/mod.rs new file mode 100644 index 0000000..b409914 --- /dev/null +++ b/examples/utils/mod.rs @@ -0,0 +1 @@ +pub mod change_detection; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c5dc4a2 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,227 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![deny(missing_docs)] + +//! +#![doc = include_str!("../README.md")] + +use bevy::ecs::query::Has; +use bevy::prelude::*; +use bevy::render::camera::RenderTarget; +use bevy::window::{PrimaryWindow, WindowRef}; +use smallvec::SmallVec; + +// ============================================================================= + +/// Export common types. +pub mod prelude { + pub use crate::{CursorInfo, CursorInfoPlugin, UpdateCursorInfo}; +} + +// ============================================================================= + +/// This plugin adds support to get informations about the cursor. +pub struct CursorInfoPlugin; + +impl Plugin for CursorInfoPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(First, update_cursor_info.in_set(UpdateCursorInfo)); + } +} + +// ============================================================================= + +/// A [`SystemSet`] in which [`CursorInfo`] is updated. +/// +/// # Example +/// +/// ``` +/// # use bevy::prelude::*; +/// # use bevy_cursor::prelude::*; +/// struct MyPlugin; +/// +/// impl Plugin for MyPlugin { +/// fn build(&self, app: &mut App) { +/// app.add_systems(First, foo.after(UpdateCursorInfo)); +/// } +/// } +/// +/// // Runs just after `CursorInfo` has been updated. +/// fn foo(cursor: Res) { +/// /* ... */ +/// } +/// ``` +#[derive(SystemSet, Hash, Debug, PartialEq, Eq, Clone, Copy)] +pub struct UpdateCursorInfo; + +// ============================================================================= + +/// A resource that provide informations about the cursor. +/// +/// # Example +/// +/// ``` +/// # use bevy::prelude::*; +/// # use bevy_cursor::prelude::*; +/// fn foo(cursor: Res) { +/// if let Some(position) = cursor.position() { +/// info!("Cursor position: {position:?}"); +/// } else { +/// info!("The cursor is not in any window"); +/// } +/// } +/// +/// # let _ = IntoSystem::into_system(foo); +/// ``` +#[derive(Resource, Default)] +pub struct CursorInfo(Option); + +/// Informations about the cursor, provided by [`CursorInfo`]. +#[derive(Debug, Clone, PartialEq)] +pub struct CursorData { + /// The position of the cursor in the world. + #[cfg(feature = "2d")] + pub position: Vec2, + /// The [`Ray`] emitted by the cursor from the camera. + #[cfg(feature = "3d")] + pub ray: Ray, + /// The cursor position in the window in logical pixels. + /// + /// See [`cursor_position`](Window::cursor_position()). + pub window_position: Vec2, + /// The [`Entity`] of the window that contains the cursor. + pub window: Entity, + /// The [`Entity`] of the camera used to compute the world position of the cursor. + pub camera: Entity, +} + +impl CursorInfo { + /// The informations about the cursor. + /// + /// The value is `None` if the cursor is not in any window. + #[inline] + pub fn get(&self) -> Option<&CursorData> { + self.0.as_ref() + } + + /// The position of the cursor in the world. + /// + /// See [`Camera::viewport_to_world_2d`](Camera::viewport_to_world_2d). + /// + /// The value is `None` if the cursor is not in any window. + #[cfg(feature = "2d")] + #[inline] + pub fn position(&self) -> Option { + self.get().map(|data| data.position) + } + + /// The [`Ray`] emitted by the cursor from the camera. + /// + /// See [`Camera::viewport_to_world`](Camera::viewport_to_world). + /// + /// The value is `None` if the cursor is not in any window. + #[cfg(feature = "3d")] + #[inline] + pub fn ray(&self) -> Option { + self.get().map(|data| data.ray) + } + + /// The cursor position in the window in logical pixels. + /// + /// See [`cursor_position`](Window::cursor_position()). + /// + /// The value is `None` if the cursor is not in any window. + #[inline] + pub fn window_position(&self) -> Option { + self.get().map(|data| data.window_position) + } + + /// The [`Entity`] of the window that contains the cursor. + /// + /// The value is `None` if the cursor is not in any window. + #[inline] + pub fn window(&self) -> Option { + self.get().map(|data| data.window) + } + + /// The [`Entity`] of the camera used to compute the world position of the cursor. + /// + /// The value is `None` if the cursor is not in any window. + #[inline] + pub fn camera(&self) -> Option { + self.get().map(|data| data.camera) + } +} + +// ============================================================================= + +fn update_cursor_info( + window_q: Query<(Entity, &Window, Has)>, + camera_q: Query<(Entity, &GlobalTransform, &Camera)>, + cursor: ResMut, +) { + let mut cursor = cursor.map_unchanged(|cursor| &mut cursor.0); + + for (win_ref, window, is_primary) in &window_q { + // Get the window that contains the cursor. + let Some(cursor_position) = window.cursor_position() else { continue; }; + let physical_cursor_position = window.physical_cursor_position().unwrap(); + + // Get the cameras that render into the current window. + let mut cameras = camera_q + .iter() + .filter(|(_, _, camera)| match camera.target { + RenderTarget::Window(WindowRef::Primary) => is_primary, + RenderTarget::Window(WindowRef::Entity(target_ref)) => target_ref == win_ref, + RenderTarget::Image(_) | RenderTarget::TextureView(_) => false, + }) + // PERF: this is unlikely to have more than 4 cameras on the same window. + .collect::>(); + + // Cameras with a higher order are rendered later, and thus on top of lower order cameras. + // We want to handle them first. + cameras.sort_unstable_by_key(|(_, _, camera)| camera.order); + let cameras = cameras.into_iter().rev(); + + for (camera_ref, cam_t, camera) in cameras { + // Does the camera viewport contain the cursor ? + let contain_cursor = match &camera.viewport { + Some(viewport) => { + let Vec2 { x, y } = physical_cursor_position; + let Vec2 { x: vx, y: vy } = viewport.physical_position.as_vec2(); + let Vec2 { x: vw, y: vh } = viewport.physical_size.as_vec2(); + x >= vx && x <= (vx + vw) && y >= vy && y <= (vy + vh) + } + None => true, + }; + + if !contain_cursor { + continue; + } + + #[cfg(feature = "2d")] + let Some(position) = camera.viewport_to_world_2d(cam_t, cursor_position) else { continue; }; + + #[cfg(feature = "3d")] + let Some(ray) = camera.viewport_to_world(cam_t, cursor_position) else { continue; }; + + cursor.set_if_neq(Some(CursorData { + #[cfg(feature = "2d")] + position, + + #[cfg(feature = "3d")] + ray, + + window_position: cursor_position, + window: win_ref, + camera: camera_ref, + })); + + // We found the correct window and camera, we can stop here. + return; + } + } + + // The cursor is outside of every windows. + cursor.set_if_neq(None); +}