diff --git a/Cargo.toml b/Cargo.toml index b787a012f21ae..24eec1a452693 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1016,6 +1016,17 @@ category = "2D Rendering" # Loading asset folders is not supported in Wasm, but required to create the atlas. wasm = false +[[example]] +name = "tilemap_entities" +path = "examples/2d/tilemap_entities.rs" +doc-scrape-examples = true + +[package.metadata.example.tilemap_entities] +name = "Tilemap Entities" +description = "Renders a tilemap where each tile is an entity" +category = "2D Rendering" +wasm = true + [[example]] name = "tilemap_chunk" path = "examples/2d/tilemap_chunk.rs" @@ -1028,6 +1039,17 @@ description = "Renders a tilemap chunk" category = "2D Rendering" wasm = true +[[example]] +name = "tilemap" +path = "examples/2d/tilemap.rs" +doc-scrape-examples = true + +[package.metadata.example.tilemap] +name = "Tilemap" +description = "Renders a tilemap" +category = "2D Rendering" +wasm = true + [[example]] name = "transparency_2d" path = "examples/2d/transparency_2d.rs" diff --git a/crates/bevy_sprite/Cargo.toml b/crates/bevy_sprite/Cargo.toml index 823718762c495..2312f05e7d7fa 100644 --- a/crates/bevy_sprite/Cargo.toml +++ b/crates/bevy_sprite/Cargo.toml @@ -23,12 +23,15 @@ bevy_camera = { path = "../bevy_camera", version = "0.18.0-dev" } bevy_mesh = { path = "../bevy_mesh", version = "0.18.0-dev" } bevy_math = { path = "../bevy_math", version = "0.18.0-dev" } bevy_picking = { path = "../bevy_picking", version = "0.18.0-dev", optional = true } +bevy_platform = { path = "../bevy_platform", version = "0.18.0-dev" } bevy_reflect = { path = "../bevy_reflect", version = "0.18.0-dev" } bevy_transform = { path = "../bevy_transform", version = "0.18.0-dev" } bevy_window = { path = "../bevy_window", version = "0.18.0-dev", optional = true } bevy_derive = { path = "../bevy_derive", version = "0.18.0-dev" } bevy_text = { path = "../bevy_text", version = "0.18.0-dev", optional = true } +variadics_please = { version = "1.1", default-features = false } + # other radsort = "0.1" tracing = { version = "0.1", default-features = false, features = ["std"] } diff --git a/crates/bevy_sprite/src/lib.rs b/crates/bevy_sprite/src/lib.rs index 5c6e9fa755021..985222f81606b 100644 --- a/crates/bevy_sprite/src/lib.rs +++ b/crates/bevy_sprite/src/lib.rs @@ -1,6 +1,5 @@ #![expect(missing_docs, reason = "Not all docs are written yet, see #3492.")] #![cfg_attr(docsrs, feature(doc_cfg))] -#![forbid(unsafe_code)] #![doc( html_logo_url = "https://bevy.org/assets/icon.png", html_favicon_url = "https://bevy.org/assets/icon.png" @@ -16,6 +15,7 @@ mod sprite; #[cfg(feature = "bevy_text")] mod text2d; mod texture_slice; +mod tilemap; /// The sprite prelude. /// @@ -50,6 +50,7 @@ pub use sprite::*; #[cfg(feature = "bevy_text")] pub use text2d::*; pub use texture_slice::*; +pub use tilemap::*; use bevy_app::prelude::*; use bevy_asset::prelude::AssetChanged; @@ -94,6 +95,8 @@ impl Plugin for SpritePlugin { .after(bevy_app::AnimationSystems), ); + app.add_plugins(TilemapPlugin); + #[cfg(feature = "bevy_picking")] app.add_plugins(SpritePickingPlugin); } diff --git a/crates/bevy_sprite/src/tilemap/commands.rs b/crates/bevy_sprite/src/tilemap/commands.rs new file mode 100644 index 0000000000000..80f1903f61018 --- /dev/null +++ b/crates/bevy_sprite/src/tilemap/commands.rs @@ -0,0 +1,180 @@ +use crate::{ + tilemap::{TileStorage, Tilemap}, + TileStorages, +}; +use bevy_ecs::{ + entity::Entity, + hierarchy::ChildOf, + system::{Command, Commands}, + world::World, +}; +use bevy_math::{IVec2, Vec2, Vec3}; +use bevy_transform::components::Transform; + +pub trait CommandsTilemapExt { + fn set_tile( + &mut self, + tilemap_id: Entity, + tile_position: IVec2, + maybe_tile: Option, + ); + + fn remove_tile(&mut self, tilemap_id: Entity, tile_position: IVec2); +} + +impl CommandsTilemapExt for Commands<'_, '_> { + fn set_tile( + &mut self, + tilemap_id: Entity, + tile_position: IVec2, + maybe_tile: Option, + ) { + self.queue(move |world: &mut World| { + SetTile { + tilemap_id, + tile_position, + maybe_tile, + } + .apply(world); + }); + } + + fn remove_tile(&mut self, tilemap_id: Entity, tile_position: IVec2) { + self.queue(move |world: &mut World| { + RemoveTile { + tilemap_id, + tile_position, + } + .apply(world); + }); + } +} + +pub struct SetTile { + pub tilemap_id: Entity, + pub tile_position: IVec2, + pub maybe_tile: Option, +} + +pub struct SetTileResult { + pub replaced_tile: Option, + pub chunk_id: Option, +} + +impl Default for SetTileResult { + fn default() -> Self { + Self { + replaced_tile: Default::default(), + chunk_id: Default::default(), + } + } +} + +impl Command> for SetTile { + fn apply(self, world: &mut World) -> SetTileResult { + let Ok(mut tilemap_entity) = world.get_entity_mut(self.tilemap_id) else { + tracing::warn!("Could not find Tilemap Entity {:?}", self.tilemap_id); + return Default::default(); + }; + + let Some(tilemap) = tilemap_entity.get::() else { + tracing::warn!("Could not find Tilemap on Entity {:?}", self.tilemap_id); + return Default::default(); + }; + + let chunk_position = tilemap.tile_chunk_position(self.tile_position); + let tile_relative_position = tilemap.tile_relative_position(self.tile_position); + + let chunk_size = tilemap.chunk_size; + let tile_size = tilemap.tile_display_size; + + if let Some(tile_storage_id) = tilemap.chunks.get(&chunk_position).cloned() { + let replaced_tile = tilemap_entity.world_scope(move |w| { + let Ok(mut tilestorage_entity) = w.get_entity_mut(tile_storage_id) else { + tracing::warn!("Could not find TileStorage Entity {:?}", tile_storage_id); + return None; + }; + + let Some(mut tile_storage) = tilestorage_entity.get_mut::>() else { + let mut tile_storage = TileStorage::::new(chunk_size); + tile_storage.set(tile_relative_position, self.maybe_tile); + tilestorage_entity.insert(tile_storage); + return None; + }; + + tile_storage.set(tile_relative_position, self.maybe_tile) + }); + SetTileResult { + chunk_id: Some(tile_storage_id), + replaced_tile, + } + } else { + let tile_storage_id = tilemap_entity.world_scope(move |w| { + let mut tile_storage = TileStorage::::new(chunk_size); + tile_storage.set(tile_relative_position, self.maybe_tile); + let translation = Vec2::new(chunk_size.x as f32, chunk_size.y as f32) + * Vec2::new(tile_size.x as f32, tile_size.y as f32) + * Vec2::new(chunk_position.x as f32, chunk_position.y as f32); + let translation = Vec3::new(translation.x, translation.y, 0.0); + let transform = Transform::from_translation(translation); + w.spawn((ChildOf(self.tilemap_id), tile_storage, transform)) + .id() + }); + let Some(mut tilemap) = tilemap_entity.get_mut::() else { + tracing::warn!("Could not find Tilemap on Entity {:?}", self.tilemap_id); + return Default::default(); + }; + tilemap.chunks.insert(chunk_position, tile_storage_id); + SetTileResult { + chunk_id: Some(tile_storage_id), + replaced_tile: None, + } + } + } +} + +pub struct RemoveTile { + pub tilemap_id: Entity, + pub tile_position: IVec2, +} + +impl Command for RemoveTile { + fn apply(self, world: &mut World) { + let Ok(mut tilemap_entity) = world.get_entity_mut(self.tilemap_id) else { + tracing::warn!("Could not find Tilemap Entity {:?}", self.tilemap_id); + return; + }; + + let Some(tilemap) = tilemap_entity.get::() else { + tracing::warn!("Could not find Tilemap on Entity {:?}", self.tilemap_id); + return; + }; + + let chunk_position = tilemap.tile_chunk_position(self.tile_position); + let tile_relative_position = tilemap.tile_relative_position(self.tile_position); + + if let Some(tile_storage_id) = tilemap.chunks.get(&chunk_position).cloned() { + tilemap_entity.world_scope(move |w| { + let Ok(mut tile_storage_entity) = w.get_entity_mut(tile_storage_id) else { + tracing::warn!("Could not find TileStorage Entity {:?}", tile_storage_id); + return; + }; + + let Some(tile_storages) = tile_storage_entity.get::().cloned() else { + tracing::warn!( + "Could not find TileStorages on Entity {:?}", + tile_storage_id + ); + return; + }; + + for (tile_storage, tile_removal) in tile_storages.removals { + let Ok(storage) = tile_storage_entity.get_mut_by_id(tile_storage) else { + continue; + }; + tile_removal(storage, tile_relative_position); + } + }); + } + } +} diff --git a/crates/bevy_sprite/src/tilemap/entity_tiles.rs b/crates/bevy_sprite/src/tilemap/entity_tiles.rs new file mode 100644 index 0000000000000..a7a3870375c5a --- /dev/null +++ b/crates/bevy_sprite/src/tilemap/entity_tiles.rs @@ -0,0 +1,132 @@ +use bevy_app::{App, Plugin}; +use bevy_derive::Deref; +use bevy_ecs::{ + component::Component, + entity::Entity, + hierarchy::ChildOf, + lifecycle::HookContext, + system::Command, + world::{DeferredWorld, World}, +}; +use bevy_math::IVec2; +use tracing::warn; + +use crate::{RemoveTile, SetTile, SetTileResult}; + +/// Plugin that handles the initialization and updating of tilemap chunks. +/// Adds systems for processing newly added tilemap chunks. +pub struct EntityTilePlugin; + +impl Plugin for EntityTilePlugin { + fn build(&self, app: &mut App) { + app.world_mut() + .register_component_hooks::() + .on_insert(on_insert_entity_tile) + .on_remove(on_remove_entity_tile); + app.world_mut() + .register_component_hooks::() + .on_remove(on_remove_entity_tile); + } +} + +/// An Entity in the tilemap +#[derive(Component, Clone, Debug, Deref)] +pub struct EntityTile(pub Entity); + +#[derive(Component, Clone, Debug, Deref)] +#[component(immutable)] +pub struct InMap(pub Entity); + +#[derive(Component, Clone, Debug, Deref)] +#[component(immutable)] +pub struct TileCoord(pub IVec2); + +impl TileCoord { + /// Iterate through the non-diagonal adjacent tiles to this coord + pub fn adjacent(&self) -> impl Iterator + use<> { + [ + TileCoord(IVec2::new(self.x + 1, self.y)), + TileCoord(IVec2::new(self.x, self.y + 1)), + TileCoord(IVec2::new(self.x, self.y - 1)), + TileCoord(IVec2::new(self.x - 1, self.y)), + ] + .into_iter() + } +} + +#[derive(Component, Clone, Debug)] +pub struct DespawnOnRemove; + +fn on_insert_entity_tile(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) { + let Ok(tile) = world.get_entity(entity) else { + warn!("Tile {} not found", entity); + return; + }; + let Some(in_map) = tile.get::().cloned() else { + warn!("Tile {} is not in a TileMap", entity); + return; + }; + let Some(tile_position) = tile.get::().cloned() else { + warn!("Tile {} has no tile coord.", entity); + return; + }; + + world.commands().queue(move |world: &mut World| { + let SetTileResult { + chunk_id: Some(chunk_id), + replaced_tile, + } = SetTile { + tilemap_id: in_map.0, + tile_position: tile_position.0, + maybe_tile: Some(EntityTile(entity)), + } + .apply(world) + else { + warn!("Could not create chunk to place Tile {} entity.", entity); + return; + }; + + world.entity_mut(entity).insert(ChildOf(chunk_id)); + + if let Some(replaced_tile) = replaced_tile { + let mut replaced_tile = world.entity_mut(replaced_tile.0); + if replaced_tile.contains::() { + replaced_tile.despawn(); + } else { + replaced_tile.remove::<(InMap, TileCoord)>(); + } + } + }); +} + +fn on_remove_entity_tile(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) { + let Ok(tile) = world.get_entity(entity) else { + warn!("Tile {} not found", entity); + return; + }; + let Some(in_map) = tile.get::().cloned() else { + warn!("Tile {} is not in a TileMap", entity); + return; + }; + let Some(tile_position) = tile.get::().cloned() else { + warn!("Tile {} has no tile coord.", entity); + return; + }; + + world.commands().queue(move |world: &mut World| { + RemoveTile { + tilemap_id: in_map.0, + tile_position: tile_position.0, + } + .apply(world); + + let Ok(mut removed) = world.get_entity_mut(entity) else { + return; + }; + if removed.contains::() { + removed.despawn(); + } else { + removed.remove::(); + } + }); +} diff --git a/crates/bevy_sprite/src/tilemap/mod.rs b/crates/bevy_sprite/src/tilemap/mod.rs new file mode 100644 index 0000000000000..37ce8e38e259b --- /dev/null +++ b/crates/bevy_sprite/src/tilemap/mod.rs @@ -0,0 +1,194 @@ +use bevy_app::{App, Plugin}; +use bevy_ecs::{ + component::Component, entity::Entity, name::Name, query::QueryData, reflect::ReflectComponent, + world::Mut, +}; +use bevy_math::{IVec2, UVec2, Vec2}; +use bevy_platform::collections::HashMap; +use bevy_reflect::Reflect; +use bevy_transform::components::Transform; + +mod commands; +mod entity_tiles; +mod query; +mod storage; + +pub use commands::*; +pub use entity_tiles::*; +pub use query::*; +pub use storage::*; +use variadics_please::all_tuples_enumerated; + +/// Plugin that handles the initialization and updating of tilemap chunks. +/// Adds systems for processing newly added tilemap chunks. +pub struct TilemapPlugin; + +impl Plugin for TilemapPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(EntityTilePlugin); + } +} + +#[derive(Component, Clone, Debug, Reflect)] +#[reflect(Component, Clone, Debug)] +#[require(Name::new("Tilemap"), Transform)] +pub struct Tilemap { + pub chunks: HashMap, + pub chunk_size: UVec2, + pub tile_display_size: UVec2, +} + +impl Tilemap { + pub fn new(chunk_size: UVec2, tile_display_size: UVec2) -> Self { + Self { + chunks: HashMap::new(), + chunk_size, + tile_display_size, + } + } + + /// Get the coordinates of the chunk a given tile is in. + // TODO: NAME THIS BETTER + pub fn tile_chunk_position(&self, tile_position: IVec2) -> IVec2 { + tile_position.div_euclid( + self.chunk_size + .try_into() + .expect("Could not convert chunk size into IVec2"), + ) + } + + /// Get the coordinates with in a chunk from a tiles global coordinates. + pub fn tile_relative_position(&self, tile_position: IVec2) -> UVec2 { + let chunk_size = self + .chunk_size + .try_into() + .expect("Could not convert chunk size into IVec2"); + let mut res = tile_position.rem_euclid(chunk_size); + if res.x < 0 { + res.x = chunk_size.x - res.x.abs() - 1; + } + if res.y < 0 { + res.y = chunk_size.y - res.y.abs() - 1; + } + res.try_into() + .expect("Could not convert chunk local position into UVec2") + } + + pub fn index(&self, tile_coord: IVec2) -> usize { + let tile_coord = self.tile_relative_position(tile_coord); + (tile_coord.y * self.chunk_size.x + tile_coord.x) as usize + } + + //TODO: CORRECT FOR POSITIVE/NEGATIVE REGIONS + pub fn calculate_tile_transform(&self, tile_position: IVec2) -> Transform { + Transform::from_xyz( + // tile position + tile_position.x as f32 + // times display size for a tile + * self.tile_display_size.x as f32 + // plus 1/2 the tile_display_size to correct the center + + self.tile_display_size.x as f32 / 2. + // minus 1/2 the tilechunk size, in terms of the tile_display_size, + // to place the 0 at left of tilemapchunk + - self.tile_display_size.x as f32 * self.chunk_size.x as f32 / 2., + // tile position + tile_position.y as f32 + // times display size for a tile + * self.tile_display_size.y as f32 + // minus 1/2 the tile_display_size to correct the center + + self.tile_display_size.y as f32 / 2. + // plus 1/2 the tilechunk size, in terms of the tile_display_size, + // to place the 0 at top of tilemapchunk + - self.tile_display_size.y as f32 * self.chunk_size.y as f32 / 2., + 0., + ) + } + + //TODO: CORRECT FOR POSITIVE/NEGATIVE REGIONS + pub fn get_tile_coord(&self, tile_position: Vec2) -> IVec2 { + IVec2::new( + // tile position + ((tile_position.x + // plus 1/2 the tile_display_size to correct the center + //- self.tile_display_size.x as f32 / 2. + // minus 1/2 the tilechunk size, in terms of the tile_display_size, + // to place the 0 at left of tilemapchunk + + self.tile_display_size.x as f32 * self.chunk_size.x as f32 / 2.) + // times display size for a tile + .div_euclid(self.tile_display_size.x as f32)) as i32, + // tile position + ((tile_position.y + // minus 1/2 the tile_display_size to correct the center + //- self.tile_display_size.y as f32 / 2. + // plus 1/2 the tilechunk size, in terms of the tile_display_size, + // to place the 0 at top of tilemapchunk + + self.tile_display_size.y as f32 * self.chunk_size.y as f32 / 2.) + // times display size for a tile + .div_euclid(self.tile_display_size.y as f32)) as i32, + ) + } +} + +pub trait TileQueryData { + type Data<'w>; + type Storage: QueryData; + type ReadOnly: TileQueryData< + Storage = <::Storage as QueryData>::ReadOnly, + >; + + fn get_at<'world, 'state>( + storage: ::Item<'world, 'state>, + index: usize, + ) -> Option>; +} + +impl TileQueryData for &T { + type Data<'w> = &'w T; + type Storage = &'static TileStorage; + type ReadOnly = Self; + + fn get_at<'world, 'state>( + storage: ::Item<'world, 'state>, + index: usize, + ) -> Option> { + storage.tiles.get(index).and_then(Option::as_ref) + } +} + +impl TileQueryData for &mut T { + type Data<'w> = &'w mut T; + type Storage = &'static mut TileStorage; + type ReadOnly = &'static T; + + fn get_at<'world: 'world, 'state>( + storage: Mut<'world, TileStorage>, + index: usize, + ) -> Option> { + storage + .into_inner() + .tiles + .get_mut(index) + .and_then(Option::as_mut) + } +} + +macro_rules! impl_tile_query_data { + ($(($n:tt, $P:ident, $p:ident)),*) => { + impl<$($P: TileQueryData),*> TileQueryData for ($($P,)*) { + type Data<'w> = ($($P::Data<'w>),*); + type Storage = ($($P::Storage),*); + type ReadOnly = ($($P::ReadOnly),*); + + fn get_at<'world, 'state>( + storage: ::Item<'world, 'state>, + index: usize, + ) -> Option> { + Some(( + $($P::get_at(storage.$n, index)?),* + )) + } + } + } +} + +all_tuples_enumerated!(impl_tile_query_data, 2, 15, P, p); diff --git a/crates/bevy_sprite/src/tilemap/query.rs b/crates/bevy_sprite/src/tilemap/query.rs new file mode 100644 index 0000000000000..56182d361c54f --- /dev/null +++ b/crates/bevy_sprite/src/tilemap/query.rs @@ -0,0 +1,232 @@ +use bevy_ecs::{ + entity::Entity, + query::{QueryData, QueryFilter, With}, + system::{Query, SystemParam}, +}; +use bevy_math::IVec2; + +use crate::{EntityTile, TileCoord, TileQueryData, TileStorage, TileStorages, Tilemap}; + +/// Query for looking up tilemaps. +/// Contains a nested query for [`Tilemap`] entities and Chunk entities. +#[derive(SystemParam)] +pub struct TilemapQuery<'w, 's, D, F = ()> +where + D: TileQueryData + 'static, + F: QueryFilter + 'static, +{ + chunks: Query<'w, 's, ::Storage, (F, With)>, + maps: Query<'w, 's, &'static Tilemap>, +} + +/// Query for looking up tiles in a tilemap. +/// Contains a nested query for a [`Tilemap`] entity and Chunk entities. +pub struct TileQuery<'world, 'state, D, F = ()> +where + D: TileQueryData + 'static, + F: QueryFilter + 'static, +{ + chunks: Query<'world, 'state, ::Storage, (F, With)>, + pub map: &'world Tilemap, +} + +impl<'world, 'state, D, F> TilemapQuery<'world, 'state, D, F> +where + D: TileQueryData + 'static, + F: QueryFilter + 'static, +{ + /// Gets the query for a given map. + pub fn get_map<'a>(&'a self, map_id: Entity) -> Option> { + let map = self.maps.get(map_id).ok()?; + + Some(TileQuery { + chunks: self.chunks.as_readonly(), + map, + }) + } + + /// Gets the query for a given map. + pub fn get_map_mut<'a>(&'a mut self, map_id: Entity) -> Option> { + let map = self.maps.get(map_id).ok()?; + + Some(TileQuery { + chunks: self.chunks.reborrow(), + map, + }) + } +} + +impl<'world, 'state, D, F> TileQuery<'world, 'state, D, F> +where + D: TileQueryData + 'static, + F: QueryFilter + 'static, +{ + /// Get the readonly variant of this query. + pub fn as_readonly(&self) -> TileQuery<'_, 'state, D::ReadOnly, F> { + TileQuery { + chunks: self.chunks.as_readonly(), + map: self.map, + } + } + + /// Get the readonly variant of this query. + pub fn reborrow(&mut self) -> TileQuery<'_, 'state, D, F> { + TileQuery { + chunks: self.chunks.reborrow(), + map: self.map, + } + } + + /// Get's the readonly query item for the given tile. + #[inline] + pub fn get_at( + &self, + coord: IVec2, + ) -> Option<<::ReadOnly as TileQueryData>::Data<'_>> { + let chunk_coord = self.map.tile_chunk_position(coord); + let chunk_entity = self.map.chunks.get(&chunk_coord).cloned()?; + + let Ok(storages) = self.chunks.get(chunk_entity) else { + return None; + }; + + let index = self.map.index(coord); + + <::ReadOnly as TileQueryData>::get_at(storages, index) + } + + /// Get's the mutable query item for the given tile. + #[inline] + pub fn get_at_mut(&mut self, coord: IVec2) -> Option<::Data<'_>> { + let chunk_coord = self.map.tile_chunk_position(coord); + let chunk_entity = self.map.chunks.get(&chunk_coord).cloned()?; + + let Ok(storages) = self.chunks.get_mut(chunk_entity) else { + return None; + }; + + let index = self.map.index(coord); + + ::get_at(storages, index) + } +} + +/// Query for looking up tilemaps. +/// Contains a nested query for [`Tilemap`] entities and Chunk entities. +#[derive(SystemParam)] +pub struct TilemapEntityQuery<'w, 's, D, F = ()> +where + D: QueryData + 'static, + F: QueryFilter + 'static, +{ + tiles: Query<'w, 's, D, (F, With)>, + chunks: Query<'w, 's, &'static TileStorage, With>, + maps: Query<'w, 's, &'static Tilemap>, +} + +/// Query for looking up tiles in a tilemap. +/// Contains a nested query for a [`Tilemap`] entity and Chunk entities. +pub struct TileEntityQuery<'w, 's, D, F = ()> +where + D: QueryData + 'static, + F: QueryFilter + 'static, +{ + tiles: Query<'w, 's, D, (F, With)>, + chunks: Query<'w, 's, &'static TileStorage, With>, + pub map: &'w Tilemap, +} + +impl<'world, 'state, D, F> TilemapEntityQuery<'world, 'state, D, F> +where + D: QueryData + 'static, + F: QueryFilter + 'static, +{ + /// Gets the query for a given map. + pub fn get_map<'a>( + &'a self, + map_id: Entity, + ) -> Option> { + let map = self.maps.get(map_id).ok()?; + + Some(TileEntityQuery { + tiles: self.tiles.as_readonly(), + chunks: self.chunks.as_readonly(), + map, + }) + } + + /// Gets the query for a given map. + pub fn get_map_mut<'a>( + &'a mut self, + map_id: Entity, + ) -> Option> { + let map = self.maps.get(map_id).ok()?; + + Some(TileEntityQuery { + tiles: self.tiles.reborrow(), + chunks: self.chunks.reborrow(), + map, + }) + } +} + +impl<'world, 'state, D, F> TileEntityQuery<'world, 'state, D, F> +where + D: QueryData + 'static, + F: QueryFilter + 'static, +{ + /// Get the readonly variant of this query. + pub fn as_readonly(&self) -> TileEntityQuery<'_, 'state, D::ReadOnly, F> { + TileEntityQuery { + tiles: self.tiles.as_readonly(), + chunks: self.chunks.as_readonly(), + map: self.map, + } + } + + /// Get the readonly variant of this query. + pub fn reborrow(&mut self) -> TileEntityQuery<'_, 'state, D, F> { + TileEntityQuery { + tiles: self.tiles.reborrow(), + chunks: self.chunks.reborrow(), + map: self.map, + } + } + + /// Get's the readonly query item for the given tile. + #[inline] + pub fn get_at( + &self, + coord: IVec2, + ) -> Option<<::ReadOnly as QueryData>::Item<'_, 'state>> { + let chunk_coord = self.map.tile_chunk_position(coord); + let chunk_entity = self.map.chunks.get(&chunk_coord).cloned()?; + + let storages = self.chunks.get(chunk_entity).ok()?; + + let index = self.map.index(coord); + + let entity = + <<&EntityTile as TileQueryData>::ReadOnly as TileQueryData>::get_at(storages, index)?; + + self.tiles.get(**entity).ok() + } + + /// Get's the mutable query item for the given tile. + #[inline] + pub fn get_at_mut(&mut self, coord: IVec2) -> Option<::Item<'_, 'state>> { + let chunk_coord = self.map.tile_chunk_position(coord); + let chunk_entity = self.map.chunks.get(&chunk_coord).cloned()?; + + let Ok(storages) = self.chunks.get_mut(chunk_entity) else { + return None; + }; + + let index = self.map.index(coord); + + let entity = + <<&EntityTile as TileQueryData>::ReadOnly as TileQueryData>::get_at(storages, index)?; + + self.tiles.get_mut(**entity).ok() + } +} diff --git a/crates/bevy_sprite/src/tilemap/storage.rs b/crates/bevy_sprite/src/tilemap/storage.rs new file mode 100644 index 0000000000000..43d8600a48ab7 --- /dev/null +++ b/crates/bevy_sprite/src/tilemap/storage.rs @@ -0,0 +1,148 @@ +use bevy_ecs::{ + change_detection::{DetectChangesMut, MutUntyped}, + component::{Component, ComponentId}, + lifecycle::HookContext, + name::Name, + reflect::ReflectComponent, + world::{DeferredWorld, World}, +}; +use bevy_math::{URect, UVec2}; +use bevy_platform::collections::HashMap; +use bevy_reflect::Reflect; +use bevy_transform::components::Transform; +use tracing::{error, info}; + +#[derive(Component, Clone, Debug, Default)] +#[require(Name::new("TileStorage"), Transform)] +pub struct TileStorages { + // Stores removal operations + pub(crate) removals: HashMap, UVec2)>, +} + +#[derive(Component, Clone, Debug, Default, Reflect)] +#[reflect(Component)] +#[require(Name::new("TileStorage"), TileStorages, Transform)] +#[component(on_add = on_add_tile_storage::, on_remove = on_remove_tile_storage::)] +pub struct TileStorage { + pub tiles: Vec>, + size: UVec2, +} + +impl TileStorage { + pub fn new(size: UVec2) -> Self { + let mut tiles = Vec::new(); + tiles.resize_with(size.element_product() as usize, Default::default); + Self { tiles, size } + } + + pub fn index(&self, tile_coord: UVec2) -> usize { + (tile_coord.y * self.size.x + tile_coord.x) as usize + } + + pub fn get_at(&self, tile_coord: UVec2) -> Option<&T> { + let index = self.index(tile_coord); + self.tiles.get(index).and_then(Option::as_ref) + } + + pub fn get_at_mut(&mut self, tile_coord: UVec2) -> Option<&mut T> { + let index = self.index(tile_coord); + self.tiles.get_mut(index).and_then(Option::as_mut) + } + + pub fn set(&mut self, tile_position: UVec2, maybe_tile: Option) -> Option { + let index = self.index(tile_position); + let tile = self.tiles.get_mut(index)?; + core::mem::replace(tile, maybe_tile) + } + + pub fn remove(&mut self, tile_position: UVec2) -> Option { + self.set(tile_position, None) + } + + pub fn iter(&self) -> impl Iterator> { + self.tiles.iter().map(|item| item.as_ref()) + } + + pub fn iter_sub_rect(&self, rect: URect) -> impl Iterator> { + let URect { min, max } = rect; + + (min.y..max.y).flat_map(move |y| { + (min.x..max.x).map(move |x| { + if x >= self.size.x || y >= self.size.y { + return None; + } + + self.get_at(UVec2 { x, y }) + }) + }) + } + + pub fn size(&self) -> UVec2 { + self.size + } +} + +fn on_add_tile_storage( + mut world: DeferredWorld<'_>, + HookContext { + component_id, + entity, + .. + }: HookContext, +) { + world.commands().queue(move |world: &mut World| { + let Ok(mut tile_storage_entity) = world.get_entity_mut(entity) else { + info!("Could not fine Tile Storage {}", entity); + return; + }; + + if let Some(mut storages) = tile_storage_entity.get_mut::() { + storages.removals.insert(component_id, remove_tile::); + } else { + let mut tile_storages = TileStorages { + removals: HashMap::with_capacity(1), + }; + tile_storages + .removals + .insert(component_id, remove_tile::); + tile_storage_entity.insert(tile_storages); + } + }); +} + +fn remove_tile(mut raw: MutUntyped<'_>, tile_coord: UVec2) { + let storage = raw.bypass_change_detection().reborrow(); + // SAFETY: We only call this from entities that have a TileStorage + // TODO: Maybe change this function to accept the enttity and do the component id look up here? + #[expect(unsafe_code, reason = "testing")] + let storage = unsafe { storage.deref_mut::>() }; + if storage.remove(tile_coord).is_some() { + raw.set_changed(); + } +} + +fn on_remove_tile_storage( + mut world: DeferredWorld<'_>, + HookContext { + component_id, + entity, + .. + }: HookContext, +) { + world.commands().queue(move |world: &mut World| { + let Ok(mut tile_storage_entity) = world.get_entity_mut(entity) else { + error!("Could not fine Tile Storage {}", entity); + return; + }; + + if let Some(mut storages) = tile_storage_entity.get_mut::() { + storages.removals.insert(component_id, remove_tile::); + } else { + let mut tile_storages = TileStorages { + removals: HashMap::with_capacity(1), + }; + tile_storages.removals.remove(&component_id); + tile_storage_entity.insert(tile_storages); + } + }); +} diff --git a/crates/bevy_sprite_render/src/tilemap_chunk/mod.rs b/crates/bevy_sprite_render/src/tilemap_chunk/mod.rs index 6832b6b5f9bb5..c72ddc42d45a1 100644 --- a/crates/bevy_sprite_render/src/tilemap_chunk/mod.rs +++ b/crates/bevy_sprite_render/src/tilemap_chunk/mod.rs @@ -1,26 +1,29 @@ use crate::{AlphaMode2d, MeshMaterial2d}; use bevy_app::{App, Plugin, Update}; use bevy_asset::{Assets, Handle}; +use bevy_camera::visibility::Visibility; use bevy_color::Color; use bevy_derive::{Deref, DerefMut}; use bevy_ecs::{ component::Component, entity::Entity, + hierarchy::ChildOf, lifecycle::HookContext, query::Changed, reflect::{ReflectComponent, ReflectResource}, resource::Resource, - system::{Query, ResMut}, - world::DeferredWorld, + system::{Command, Query, ResMut}, + world::{DeferredWorld, World}, }; use bevy_image::Image; use bevy_math::{primitives::Rectangle, UVec2}; use bevy_mesh::{Mesh, Mesh2d}; use bevy_platform::collections::HashMap; use bevy_reflect::{prelude::*, Reflect}; +use bevy_sprite::{InMap, SetTile, TileCoord, TileStorage, Tilemap}; use bevy_transform::components::Transform; use bevy_utils::default; -use tracing::warn; +use tracing::{trace, warn}; mod tilemap_chunk_material; @@ -34,6 +37,13 @@ impl Plugin for TilemapChunkPlugin { fn build(&self, app: &mut App) { app.init_resource::() .add_systems(Update, update_tilemap_chunk_indices); + app.world_mut() + .register_component_hooks::>() + .on_insert(on_insert_chunk_tile_render_data); + app.world_mut() + .register_component_hooks::() + .on_insert(on_insert_tile_render_data) + .on_remove(on_remove_tile_render_data); } } @@ -47,7 +57,7 @@ pub struct TilemapChunkMeshCache(HashMap>); #[derive(Component, Clone, Debug, Default, Reflect)] #[reflect(Component, Clone, Debug, Default)] #[component(immutable, on_insert = on_insert_tilemap_chunk)] -pub struct TilemapChunk { +pub struct TilemapChunkRenderData { /// The size of the chunk in tiles. pub chunk_size: UVec2, /// The size to use for each tile, not to be confused with the size of a tile in the tileset image. @@ -59,7 +69,20 @@ pub struct TilemapChunk { pub alpha_mode: AlphaMode2d, } -impl TilemapChunk { +/// A component representing a chunk of a tilemap. +/// Each chunk is a rectangular section of tiles that is rendered as a single mesh. +#[derive(Component, Clone, Debug, Default, Reflect)] +#[reflect(Component, Clone, Debug, Default)] +#[require(Transform, Visibility)] +#[component(immutable)] +pub struct TilemapRenderData { + /// Handle to the tileset image containing all tile textures. + pub tileset: Handle, + /// The alpha mode to use for the tilemap chunk. + pub alpha_mode: AlphaMode2d, +} + +impl TilemapChunkRenderData { pub fn calculate_tile_transform(&self, position: UVec2) -> Transform { Transform::from_xyz( // tile position @@ -85,10 +108,62 @@ impl TilemapChunk { } } +fn on_insert_chunk_tile_render_data( + mut world: DeferredWorld, + HookContext { entity, .. }: HookContext, +) { + let Ok(chunk) = world.get_entity(entity) else { + warn!("Chunk {} not found", entity); + return; + }; + if chunk.contains::() { + trace!("Chunk {} already contains TilemapChunkRenderData", entity); + return; + } + let Some(child_of) = chunk.get::() else { + warn!("Chunk {} is not a child of an entity", entity); + return; + }; + let Ok(tilemap) = world.get_entity(child_of.parent()) else { + warn!( + "Could not find chunk {}'s parent {}", + entity, + child_of.parent() + ); + return; + }; + let Some(tilemap_render_data) = tilemap.get::() else { + warn!( + "Could not find TilemapRenderData on chunk {}'s parent {}", + entity, + child_of.parent() + ); + return; + }; + let Some(tilemap) = tilemap.get::() else { + warn!( + "Could not find Tilemap on chunk {}'s parent {}", + entity, + child_of.parent() + ); + return; + }; + + let data = TilemapChunkRenderData { + chunk_size: tilemap.chunk_size, + tile_display_size: tilemap.tile_display_size, + tileset: tilemap_render_data.tileset.clone(), + alpha_mode: tilemap_render_data.alpha_mode, + }; + + world.commands().entity(entity).insert(data); +} + /// Data for a single tile in the tilemap chunk. -#[derive(Clone, Copy, Debug, Reflect)] +#[derive(Component, Clone, Copy, Debug, Reflect)] #[reflect(Clone, Debug, Default)] -pub struct TileData { +#[component(immutable)] +pub struct TileRenderData { /// The index of the tile in the corresponding tileset array texture. pub tileset_index: u16, /// The color tint of the tile. White leaves the sampled texture color unchanged. @@ -97,7 +172,7 @@ pub struct TileData { pub visible: bool, } -impl TileData { +impl TileRenderData { /// Creates a new `TileData` with the given tileset index and default values. pub fn from_tileset_index(tileset_index: u16) -> Self { Self { @@ -107,7 +182,7 @@ impl TileData { } } -impl Default for TileData { +impl Default for TileRenderData { fn default() -> Self { Self { tileset_index: 0, @@ -117,15 +192,12 @@ impl Default for TileData { } } -/// Component storing the data of tiles within a chunk. -/// Each index corresponds to a specific tile in the tileset. `None` indicates an empty tile. -#[derive(Component, Clone, Debug, Deref, DerefMut, Reflect)] -#[reflect(Component, Clone, Debug)] -pub struct TilemapChunkTileData(pub Vec>); - fn on_insert_tilemap_chunk(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) { - let Some(tilemap_chunk) = world.get::(entity) else { - warn!("TilemapChunk not found for tilemap chunk {}", entity); + let Some(tilemap_chunk) = world.get::(entity) else { + warn!( + "TilemapChunkRenderData not found for tilemap chunk {}", + entity + ); return; }; @@ -133,25 +205,28 @@ fn on_insert_tilemap_chunk(mut world: DeferredWorld, HookContext { entity, .. }: let alpha_mode = tilemap_chunk.alpha_mode; let tileset = tilemap_chunk.tileset.clone(); - let Some(tile_data) = world.get::(entity) else { - warn!("TilemapChunkIndices not found for tilemap chunk {}", entity); + let Some(tile_data) = world.get::>(entity) else { + warn!( + "TileStorage not found for tilemap chunk {}", + entity + ); return; }; let expected_tile_data_length = chunk_size.element_product() as usize; - if tile_data.len() != expected_tile_data_length { + if tile_data.tiles.len() != expected_tile_data_length { warn!( "Invalid tile data length for tilemap chunk {} of size {}. Expected {}, got {}", entity, chunk_size, expected_tile_data_length, - tile_data.len(), + tile_data.tiles.len(), ); return; } let packed_tile_data: Vec = - tile_data.0.iter().map(|&tile| tile.into()).collect(); + tile_data.tiles.iter().map(|&tile| tile.into()).collect(); let tile_data_image = make_chunk_tile_data_image(&chunk_size, &packed_tile_data); @@ -186,30 +261,30 @@ pub fn update_tilemap_chunk_indices( query: Query< ( Entity, - &TilemapChunk, - &TilemapChunkTileData, + &TilemapChunkRenderData, + &TileStorage, &MeshMaterial2d, ), - Changed, + Changed>, >, mut materials: ResMut>, mut images: ResMut>, ) { - for (chunk_entity, TilemapChunk { chunk_size, .. }, tile_data, material) in query { + for (chunk_entity, TilemapChunkRenderData { chunk_size, .. }, tile_data, material) in query { let expected_tile_data_length = chunk_size.element_product() as usize; - if tile_data.len() != expected_tile_data_length { + if tile_data.tiles.len() != expected_tile_data_length { warn!( "Invalid TilemapChunkTileData length for tilemap chunk {} of size {}. Expected {}, got {}", chunk_entity, chunk_size, - tile_data.len(), + tile_data.tiles.len(), expected_tile_data_length ); continue; } let packed_tile_data: Vec = - tile_data.0.iter().map(|&tile| tile.into()).collect(); + tile_data.tiles.iter().map(|&tile| tile.into()).collect(); // Getting the material mutably to trigger change detection let Some(material) = materials.get_mut(material.id()) else { @@ -238,14 +313,54 @@ pub fn update_tilemap_chunk_indices( } } -impl TilemapChunkTileData { - pub fn tile_data_from_tile_pos( - &self, - tilemap_size: UVec2, - position: UVec2, - ) -> Option<&TileData> { - self.0 - .get(tilemap_size.x as usize * position.y as usize + position.x as usize) - .and_then(|opt| opt.as_ref()) - } +fn on_insert_tile_render_data(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) { + let Ok(tile) = world.get_entity(entity) else { + warn!("Tile {} not found", entity); + return; + }; + let Some(in_map) = tile.get::().cloned() else { + warn!("Tile {} is not in a TileMap", entity); + return; + }; + let Some(tile_position) = tile.get::().cloned() else { + warn!("Tile {} has no tile coord.", entity); + return; + }; + let Some(tile_render_data) = tile.get::().cloned() else { + warn!("Tile {} does not have TileRenderData", entity); + return; + }; + + world.commands().queue(move |world: &mut World| { + SetTile { + tilemap_id: in_map.0, + tile_position: tile_position.0, + maybe_tile: Some(tile_render_data), + } + .apply(world); + }); +} + +fn on_remove_tile_render_data(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) { + let Ok(tile) = world.get_entity(entity) else { + warn!("Tile {} not found", entity); + return; + }; + let Some(in_map) = tile.get::().cloned() else { + warn!("Tile {} is not in a TileMap", entity); + return; + }; + let Some(tile_position) = tile.get::().cloned() else { + warn!("Tile {} has no tile coord.", entity); + return; + }; + + world.commands().queue(move |world: &mut World| { + SetTile:: { + tilemap_id: in_map.0, + tile_position: tile_position.0, + maybe_tile: None, + } + .apply(world); + }); } diff --git a/crates/bevy_sprite_render/src/tilemap_chunk/tilemap_chunk_material.rs b/crates/bevy_sprite_render/src/tilemap_chunk/tilemap_chunk_material.rs index 53039c3458de3..5372445cc35c3 100644 --- a/crates/bevy_sprite_render/src/tilemap_chunk/tilemap_chunk_material.rs +++ b/crates/bevy_sprite_render/src/tilemap_chunk/tilemap_chunk_material.rs @@ -1,4 +1,4 @@ -use crate::{AlphaMode2d, Material2d, Material2dPlugin, TileData}; +use crate::{AlphaMode2d, Material2d, Material2dPlugin, TileRenderData}; use bevy_app::{App, Plugin}; use bevy_asset::{embedded_asset, embedded_path, Asset, AssetPath, Handle, RenderAssetUsages}; use bevy_color::ColorToPacked; @@ -68,13 +68,13 @@ impl PackedTileData { } } -impl From for PackedTileData { +impl From for PackedTileData { fn from( - TileData { + TileRenderData { tileset_index, color, visible, - }: TileData, + }: TileRenderData, ) -> Self { Self { tileset_index, @@ -84,8 +84,8 @@ impl From for PackedTileData { } } -impl From> for PackedTileData { - fn from(maybe_tile_data: Option) -> Self { +impl From> for PackedTileData { + fn from(maybe_tile_data: Option) -> Self { maybe_tile_data .map(Into::into) .unwrap_or(PackedTileData::empty()) diff --git a/examples/2d/tilemap.rs b/examples/2d/tilemap.rs new file mode 100644 index 0000000000000..320e2b925315d --- /dev/null +++ b/examples/2d/tilemap.rs @@ -0,0 +1,85 @@ +//! Shows a tilemap chunk rendered with a single draw call. + +use bevy::{ + image::{ImageArrayLayout, ImageLoaderSettings}, + prelude::*, + sprite::{CommandsTilemapExt, Tilemap, TilemapQuery}, + sprite_render::{TileRenderData, TilemapRenderData}, +}; +use rand::SeedableRng; +use rand_chacha::ChaCha8Rng; + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest())) + .add_systems(Startup, setup) + .add_systems(Update, set_tile) + .run(); +} + +#[derive(Resource, Deref, DerefMut)] +struct SeededRng(ChaCha8Rng); + +fn setup(mut commands: Commands, assets: Res) { + // We're seeding the PRNG here to make this example deterministic for testing purposes. + // This isn't strictly required in practical use unless you need your app to be deterministic. + let rng = ChaCha8Rng::seed_from_u64(42); + + let chunk_size = UVec2::splat(16); + let tile_display_size = UVec2::splat(32); + + commands.spawn(( + Transform::default(), + Visibility::default(), + Tilemap::new(chunk_size, tile_display_size), + TilemapRenderData { + tileset: assets.load_with_settings( + "textures/array_texture.png", + |settings: &mut ImageLoaderSettings| { + // The tileset texture is expected to be an array of tile textures, so we tell the + // `ImageLoader` that our texture is composed of 4 stacked tile images. + settings.array_layout = Some(ImageArrayLayout::RowCount { rows: 4 }); + }, + ), + ..default() + }, + )); + + commands.spawn(Camera2d); + + commands.insert_resource(SeededRng(rng)); +} + +fn set_tile( + mut commands: Commands, + mut clicks: MessageReader>, + camera_query: Single<(&Camera, &GlobalTransform)>, + map: Single>, + mut tiles: TilemapQuery<&mut TileRenderData>, +) { + let (camera, camera_transform) = *camera_query; + let map = *map; + + let mut tiles = tiles.get_map_mut(map).unwrap(); + + for click in clicks.read() { + if let Ok(tile_coord) = + camera.viewport_to_world_2d(camera_transform, click.pointer_location.position) + { + let tile_coord = tiles.map.get_tile_coord(tile_coord); + info!("{}", tile_coord); + if let Some(tile) = tiles.get_at_mut(tile_coord) { + tile.tileset_index = (tile.tileset_index + 1) % 4; + } else { + commands.set_tile( + map, + tile_coord, + Some(TileRenderData { + tileset_index: 0, + ..Default::default() + }), + ); + } + } + } +} diff --git a/examples/2d/tilemap_chunk.rs b/examples/2d/tilemap_chunk.rs index 4bc1855841c04..7346aeb5f93fd 100644 --- a/examples/2d/tilemap_chunk.rs +++ b/examples/2d/tilemap_chunk.rs @@ -4,7 +4,8 @@ use bevy::{ color::palettes::tailwind::RED_400, image::{ImageArrayLayout, ImageLoaderSettings}, prelude::*, - sprite_render::{TileData, TilemapChunk, TilemapChunkTileData}, + sprite::TileStorage, + sprite_render::{TileRenderData, TilemapChunkRenderData}, }; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha8Rng; @@ -30,19 +31,21 @@ fn setup(mut commands: Commands, assets: Res) { let chunk_size = UVec2::splat(64); let tile_display_size = UVec2::splat(8); - let tile_data: Vec> = (0..chunk_size.element_product()) + let tile_data: Vec> = (0..chunk_size.element_product()) .map(|_| rng.random_range(0..5)) .map(|i| { if i == 0 { None } else { - Some(TileData::from_tileset_index(i - 1)) + Some(TileRenderData::from_tileset_index(i - 1)) } }) .collect(); + let mut storage = TileStorage::::new(chunk_size); + storage.tiles = tile_data; commands.spawn(( - TilemapChunk { + TilemapChunkRenderData { chunk_size, tile_display_size, tileset: assets.load_with_settings( @@ -55,7 +58,7 @@ fn setup(mut commands: Commands, assets: Res) { ), ..default() }, - TilemapChunkTileData(tile_data), + storage, UpdateTimer(Timer::from_seconds(0.1, TimerMode::Repeating)), )); @@ -71,7 +74,7 @@ fn spawn_fake_player( mut commands: Commands, mut meshes: ResMut>, mut materials: ResMut>, - chunk: Single<&TilemapChunk>, + chunk: Single<&TilemapChunkRenderData>, ) { let mut transform = chunk.calculate_tile_transform(UVec2::new(0, 0)); transform.translation.z = 1.; @@ -97,7 +100,7 @@ fn spawn_fake_player( fn move_player( mut player: Single<&mut Transform, With>, time: Res