diff --git a/crates/bevy_input_focus/Cargo.toml b/crates/bevy_input_focus/Cargo.toml index b14795c4a2d1e..9942f1344b437 100644 --- a/crates/bevy_input_focus/Cargo.toml +++ b/crates/bevy_input_focus/Cargo.toml @@ -15,6 +15,7 @@ bevy_app = { path = "../bevy_app", version = "0.16.0-dev", default-features = fa bevy_ecs = { path = "../bevy_ecs", version = "0.16.0-dev", default-features = false } bevy_input = { path = "../bevy_input", version = "0.16.0-dev", default-features = false } bevy_hierarchy = { path = "../bevy_hierarchy", version = "0.16.0-dev", default-features = false } +bevy_math = { path = "../bevy_math", version = "0.16.0-dev", default-features = false } bevy_window = { path = "../bevy_window", version = "0.16.0-dev", default-features = false } # other diff --git a/crates/bevy_input_focus/src/directional_navigation.rs b/crates/bevy_input_focus/src/directional_navigation.rs new file mode 100644 index 0000000000000..458b7c5fb6d25 --- /dev/null +++ b/crates/bevy_input_focus/src/directional_navigation.rs @@ -0,0 +1,379 @@ +//! A navigation framework for moving between focusable elements based on directional input. +//! +//! While virtual cursors are a common way to navigate UIs with a gamepad (or arrow keys!), +//! they are generally both slow and frustrating to use. +//! Instead, directional inputs should provide a direct way to snap between focusable elements. +//! +//! Like the rest of this crate, the [`InputFocus`] resource is manipulated to track +//! the current focus. +//! +//! Navigating between focusable entities (commonly UI nodes) is done by +//! passing a [`CompassOctant`] into the [`navigate`](DirectionalNavigation::navigate) method +//! from the [`DirectionalNavigation`] system parameter. +//! +//! Under the hood, the [`DirectionalNavigationMap`] stores a directed graph of focusable entities. +//! Each entity can have up to 8 neighbors, one for each [`CompassOctant`], balancing flexibility and required precision. +//! For now, this graph must be built manually, but in the future, it could be generated automatically. + +use bevy_app::prelude::*; +use bevy_ecs::{ + entity::{EntityHashMap, EntityHashSet}, + prelude::*, + system::SystemParam, +}; +use bevy_math::CompassOctant; +use thiserror::Error; + +use crate::InputFocus; + +/// A plugin that sets up the directional navigation systems and resources. +#[derive(Default)] +pub struct DirectionalNavigationPlugin; + +impl Plugin for DirectionalNavigationPlugin { + fn build(&self, app: &mut App) { + app.init_resource::(); + } +} + +/// The up-to-eight neighbors of a focusable entity, one for each [`CompassOctant`]. +#[derive(Default, Debug, Clone, PartialEq)] +pub struct NavNeighbors { + /// The array of neighbors, one for each [`CompassOctant`]. + /// The mapping between array elements and directions is determined by [`CompassOctant::to_index`]. + /// + /// If no neighbor exists in a given direction, the value will be [`None`]. + /// In most cases, using [`NavNeighbors::set`] and [`NavNeighbors::get`] + /// will be more ergonomic than directly accessing this array. + pub neighbors: [Option; 8], +} + +impl NavNeighbors { + /// An empty set of neighbors. + pub const EMPTY: NavNeighbors = NavNeighbors { + neighbors: [None; 8], + }; + + /// Get the neighbor for a given [`CompassOctant`]. + pub const fn get(&self, octant: CompassOctant) -> Option { + self.neighbors[octant.to_index()] + } + + /// Set the neighbor for a given [`CompassOctant`]. + pub const fn set(&mut self, octant: CompassOctant, entity: Entity) { + self.neighbors[octant.to_index()] = Some(entity); + } +} + +/// A resource that stores the traversable graph of focusable entities. +/// +/// Each entity can have up to 8 neighbors, one for each [`CompassOctant`]. +/// +/// To ensure that your graph is intuitive to navigate and generally works correctly, it should be: +/// +/// - **Connected**: Every focusable entity should be reachable from every other focusable entity. +/// - **Symmetric**: If entity A is a neighbor of entity B, then entity B should be a neighbor of entity A, ideally in the reverse direction. +/// - **Physical**: The direction of navigation should match the layout of the entities when possible, +/// although looping around the edges of the screen is also acceptable. +/// - **Not self-connected**: An entity should not be a neighbor of itself; use [`None`] instead. +/// +/// For now, this graph must be built manually, and the developer is responsible for ensuring that it meets the above criteria. +#[derive(Resource, Debug, Default, Clone, PartialEq)] +pub struct DirectionalNavigationMap { + /// A directed graph of focusable entities. + /// + /// Pass in the current focus as a key, and get back a collection of up to 8 neighbors, + /// each keyed by a [`CompassOctant`]. + pub neighbors: EntityHashMap, +} + +impl DirectionalNavigationMap { + /// Adds a new entity to the navigation map, overwriting any existing neighbors for that entity. + /// + /// Removes an entity from the navigation map, including all connections to and from it. + /// + /// Note that this is an O(n) operation, where n is the number of entities in the map, + /// as we must iterate over each entity to check for connections to the removed entity. + /// + /// If you are removing multiple entities, consider using [`remove_multiple`](Self::remove_multiple) instead. + pub fn remove(&mut self, entity: Entity) { + self.neighbors.remove(&entity); + + for node in self.neighbors.values_mut() { + for neighbor in node.neighbors.iter_mut() { + if *neighbor == Some(entity) { + *neighbor = None; + } + } + } + } + + /// Removes a collection of entities from the navigation map. + /// + /// While this is still an O(n) operation, where n is the number of entities in the map, + /// it is more efficient than calling [`remove`](Self::remove) multiple times, + /// as we can check for connections to all removed entities in a single pass. + /// + /// An [`EntityHashSet`] must be provided as it is noticeably faster than the standard hasher or a [`Vec`]. + pub fn remove_multiple(&mut self, entities: EntityHashSet) { + for entity in &entities { + self.neighbors.remove(entity); + } + + for node in self.neighbors.values_mut() { + for neighbor in node.neighbors.iter_mut() { + if let Some(entity) = *neighbor { + if entities.contains(&entity) { + *neighbor = None; + } + } + } + } + } + + /// Completely clears the navigation map, removing all entities and connections. + pub fn clear(&mut self) { + self.neighbors.clear(); + } + + /// Adds an edge between two entities in the navigation map. + /// Any existing edge from A in the provided direction will be overwritten. + /// + /// The reverse edge will not be added, so navigation will only be possible in one direction. + /// If you want to add a symmetrical edge, use [`add_symmetrical_edge`](Self::add_symmetrical_edge) instead. + pub fn add_edge(&mut self, a: Entity, b: Entity, direction: CompassOctant) { + self.neighbors + .entry(a) + .or_insert(NavNeighbors::EMPTY) + .set(direction, b); + } + + /// Adds a symmetrical edge between two entities in the navigation map. + /// The A -> B path will use the provided direction, while B -> A will use the [`CompassOctant::opposite`] variant. + /// + /// Any existing connections between the two entities will be overwritten. + pub fn add_symmetrical_edge(&mut self, a: Entity, b: Entity, direction: CompassOctant) { + self.add_edge(a, b, direction); + self.add_edge(b, a, direction.opposite()); + } + + /// Add symmetrical edges between all entities in the provided slice, looping back to the first entity at the end. + /// + /// This is useful for creating a circular navigation path between a set of entities, such as a menu. + pub fn add_looping_edges(&mut self, entities: &[Entity], direction: CompassOctant) { + for i in 0..entities.len() { + let a = entities[i]; + let b = entities[(i + 1) % entities.len()]; + self.add_symmetrical_edge(a, b, direction); + } + } + + /// Gets the entity in a given direction from the current focus, if any. + pub fn get_neighbor(&self, focus: Entity, octant: CompassOctant) -> Option { + self.neighbors + .get(&focus) + .and_then(|neighbors| neighbors.get(octant)) + } + + /// Looks up the neighbors of a given entity. + /// + /// If the entity is not in the map, [`None`] will be returned. + /// Note that the set of neighbors is not guaranteed to be non-empty though! + pub fn get_neighbors(&self, entity: Entity) -> Option<&NavNeighbors> { + self.neighbors.get(&entity) + } +} + +/// A system parameter for navigating between focusable entities in a directional way. +#[derive(SystemParam, Debug)] +pub struct DirectionalNavigation<'w> { + /// The currently focused entity. + pub focus: ResMut<'w, InputFocus>, + /// The navigation map containing the connections between entities. + pub map: Res<'w, DirectionalNavigationMap>, +} + +impl DirectionalNavigation<'_> { + /// Navigates to the neighbor in a given direction from the current focus, if any. + /// + /// Returns the new focus if successful. + /// Returns an error if there is no focus set or if there is no neighbor in the requested direction. + /// + /// If the result was `Ok`, the [`InputFocus`] resource is updated to the new focus as part of this method call. + pub fn navigate( + &mut self, + octant: CompassOctant, + ) -> Result { + if let Some(current_focus) = self.focus.0 { + if let Some(new_focus) = self.map.get_neighbor(current_focus, octant) { + self.focus.set(new_focus); + Ok(new_focus) + } else { + Err(DirectionalNavigationError::NoNeighborInDirection) + } + } else { + Err(DirectionalNavigationError::NoFocus) + } + } +} + +/// An error that can occur when navigating between focusable entities using [directional navigation](crate::directional_navigation). +#[derive(Debug, PartialEq, Clone, Error)] +pub enum DirectionalNavigationError { + /// No focusable entity is currently set. + #[error("No focusable entity is currently set.")] + NoFocus, + /// No neighbor in the requested direction. + #[error("No neighbor in the requested direction.")] + NoNeighborInDirection, +} + +#[cfg(test)] +mod tests { + use bevy_ecs::system::RunSystemOnce; + + use super::*; + + #[test] + fn setting_and_getting_nav_neighbors() { + let mut neighbors = NavNeighbors::EMPTY; + assert_eq!(neighbors.get(CompassOctant::SouthEast), None); + + neighbors.set(CompassOctant::SouthEast, Entity::PLACEHOLDER); + + for i in 0..8 { + if i == CompassOctant::SouthEast.to_index() { + assert_eq!( + neighbors.get(CompassOctant::SouthEast), + Some(Entity::PLACEHOLDER) + ); + } else { + assert_eq!(neighbors.get(CompassOctant::from_index(i).unwrap()), None); + } + } + } + + #[test] + fn simple_set_and_get_navmap() { + let mut world = World::new(); + let a = world.spawn_empty().id(); + let b = world.spawn_empty().id(); + + let mut map = DirectionalNavigationMap::default(); + map.add_edge(a, b, CompassOctant::SouthEast); + + assert_eq!(map.get_neighbor(a, CompassOctant::SouthEast), Some(b)); + assert_eq!( + map.get_neighbor(b, CompassOctant::SouthEast.opposite()), + None + ); + } + + #[test] + fn symmetrical_edges() { + let mut world = World::new(); + let a = world.spawn_empty().id(); + let b = world.spawn_empty().id(); + + let mut map = DirectionalNavigationMap::default(); + map.add_symmetrical_edge(a, b, CompassOctant::North); + + assert_eq!(map.get_neighbor(a, CompassOctant::North), Some(b)); + assert_eq!(map.get_neighbor(b, CompassOctant::South), Some(a)); + } + + #[test] + fn remove_nodes() { + let mut world = World::new(); + let a = world.spawn_empty().id(); + let b = world.spawn_empty().id(); + + let mut map = DirectionalNavigationMap::default(); + map.add_edge(a, b, CompassOctant::North); + map.add_edge(b, a, CompassOctant::South); + + assert_eq!(map.get_neighbor(a, CompassOctant::North), Some(b)); + assert_eq!(map.get_neighbor(b, CompassOctant::South), Some(a)); + + map.remove(b); + + assert_eq!(map.get_neighbor(a, CompassOctant::North), None); + assert_eq!(map.get_neighbor(b, CompassOctant::South), None); + } + + #[test] + fn remove_multiple_nodes() { + let mut world = World::new(); + let a = world.spawn_empty().id(); + let b = world.spawn_empty().id(); + let c = world.spawn_empty().id(); + + let mut map = DirectionalNavigationMap::default(); + map.add_edge(a, b, CompassOctant::North); + map.add_edge(b, a, CompassOctant::South); + map.add_edge(b, c, CompassOctant::East); + map.add_edge(c, b, CompassOctant::West); + + let mut to_remove = EntityHashSet::default(); + to_remove.insert(b); + to_remove.insert(c); + + map.remove_multiple(to_remove); + + assert_eq!(map.get_neighbor(a, CompassOctant::North), None); + assert_eq!(map.get_neighbor(b, CompassOctant::South), None); + assert_eq!(map.get_neighbor(b, CompassOctant::East), None); + assert_eq!(map.get_neighbor(c, CompassOctant::West), None); + } + + #[test] + fn looping_edges() { + let mut world = World::new(); + let a = world.spawn_empty().id(); + let b = world.spawn_empty().id(); + let c = world.spawn_empty().id(); + + let mut map = DirectionalNavigationMap::default(); + map.add_looping_edges(&[a, b, c], CompassOctant::East); + + assert_eq!(map.get_neighbor(a, CompassOctant::East), Some(b)); + assert_eq!(map.get_neighbor(b, CompassOctant::East), Some(c)); + assert_eq!(map.get_neighbor(c, CompassOctant::East), Some(a)); + + assert_eq!(map.get_neighbor(a, CompassOctant::West), Some(c)); + assert_eq!(map.get_neighbor(b, CompassOctant::West), Some(a)); + assert_eq!(map.get_neighbor(c, CompassOctant::West), Some(b)); + } + + #[test] + fn nav_with_system_param() { + let mut world = World::new(); + let a = world.spawn_empty().id(); + let b = world.spawn_empty().id(); + let c = world.spawn_empty().id(); + + let mut map = DirectionalNavigationMap::default(); + map.add_looping_edges(&[a, b, c], CompassOctant::East); + + world.insert_resource(map); + + let mut focus = InputFocus::default(); + focus.set(a); + world.insert_resource(focus); + + assert_eq!(world.resource::().get(), Some(a)); + + fn navigate_east(mut nav: DirectionalNavigation) { + nav.navigate(CompassOctant::East).unwrap(); + } + + world.run_system_once(navigate_east).unwrap(); + assert_eq!(world.resource::().get(), Some(b)); + + world.run_system_once(navigate_east).unwrap(); + assert_eq!(world.resource::().get(), Some(c)); + + world.run_system_once(navigate_east).unwrap(); + assert_eq!(world.resource::().get(), Some(a)); + } +} diff --git a/crates/bevy_input_focus/src/lib.rs b/crates/bevy_input_focus/src/lib.rs index 4dff21644f3f3..279dc355d31f2 100644 --- a/crates/bevy_input_focus/src/lib.rs +++ b/crates/bevy_input_focus/src/lib.rs @@ -11,11 +11,12 @@ //! * [`InputFocus`], a resource for tracking which entity has input focus. //! * Methods for getting and setting input focus via [`InputFocus`] and [`IsFocusedHelper`]. //! * A generic [`FocusedInput`] event for input events which bubble up from the focused entity. -//! * Various navigation frameworks for moving input focus between entities based on user input, such as [`tab_navigation`]. +//! * Various navigation frameworks for moving input focus between entities based on user input, such as [`tab_navigation`] and [`directional_navigation`]. //! //! This crate does *not* provide any integration with UI widgets: this is the responsibility of the widget crate, //! which should depend on [`bevy_input_focus`](crate). +pub mod directional_navigation; pub mod tab_navigation; // This module is too small / specific to be exported by the crate, diff --git a/crates/bevy_math/src/compass.rs b/crates/bevy_math/src/compass.rs index 5ee224df4b118..72dd817146905 100644 --- a/crates/bevy_math/src/compass.rs +++ b/crates/bevy_math/src/compass.rs @@ -1,3 +1,5 @@ +use core::ops::Neg; + use crate::Dir2; #[cfg(feature = "bevy_reflect")] use bevy_reflect::Reflect; @@ -34,6 +36,45 @@ pub enum CompassQuadrant { West, } +impl CompassQuadrant { + /// Converts a standard index to a [`CompassQuadrant`]. + /// + /// Starts at 0 for [`CompassQuadrant::North`] and increments clockwise. + pub const fn from_index(index: usize) -> Option { + match index { + 0 => Some(Self::North), + 1 => Some(Self::East), + 2 => Some(Self::South), + 3 => Some(Self::West), + _ => None, + } + } + + /// Converts a [`CompassQuadrant`] to a standard index. + /// + /// Starts at 0 for [`CompassQuadrant::North`] and increments clockwise. + pub const fn to_index(self) -> usize { + match self { + Self::North => 0, + Self::East => 1, + Self::South => 2, + Self::West => 3, + } + } + + /// Returns the opposite [`CompassQuadrant`], located 180 degrees from `self`. + /// + /// This can also be accessed via the `-` operator, using the [`Neg`] trait. + pub const fn opposite(&self) -> CompassQuadrant { + match self { + Self::North => Self::South, + Self::East => Self::West, + Self::South => Self::North, + Self::West => Self::East, + } + } +} + /// A compass enum with 8 directions. /// ```text /// N (North) @@ -72,6 +113,57 @@ pub enum CompassOctant { NorthWest, } +impl CompassOctant { + /// Converts a standard index to a [`CompassOctant`]. + /// + /// Starts at 0 for [`CompassOctant::North`] and increments clockwise. + pub const fn from_index(index: usize) -> Option { + match index { + 0 => Some(Self::North), + 1 => Some(Self::NorthEast), + 2 => Some(Self::East), + 3 => Some(Self::SouthEast), + 4 => Some(Self::South), + 5 => Some(Self::SouthWest), + 6 => Some(Self::West), + 7 => Some(Self::NorthWest), + _ => None, + } + } + + /// Converts a [`CompassOctant`] to a standard index. + /// + /// Starts at 0 for [`CompassOctant::North`] and increments clockwise. + pub const fn to_index(self) -> usize { + match self { + Self::North => 0, + Self::NorthEast => 1, + Self::East => 2, + Self::SouthEast => 3, + Self::South => 4, + Self::SouthWest => 5, + Self::West => 6, + Self::NorthWest => 7, + } + } + + /// Returns the opposite [`CompassOctant`], located 180 degrees from `self`. + /// + /// This can also be accessed via the `-` operator, using the [`Neg`] trait. + pub const fn opposite(&self) -> CompassOctant { + match self { + Self::North => Self::South, + Self::NorthEast => Self::SouthWest, + Self::East => Self::West, + Self::SouthEast => Self::NorthWest, + Self::South => Self::North, + Self::SouthWest => Self::NorthEast, + Self::West => Self::East, + Self::NorthWest => Self::SouthEast, + } + } +} + impl From for Dir2 { fn from(q: CompassQuadrant) -> Self { match q { @@ -134,6 +226,22 @@ impl From for CompassOctant { } } +impl Neg for CompassQuadrant { + type Output = CompassQuadrant; + + fn neg(self) -> Self::Output { + self.opposite() + } +} + +impl Neg for CompassOctant { + type Output = CompassOctant; + + fn neg(self) -> Self::Output { + self.opposite() + } +} + #[cfg(test)] mod test_compass_quadrant { use crate::{CompassQuadrant, Dir2, Vec2}; @@ -235,6 +343,29 @@ mod test_compass_quadrant { assert_eq!(CompassQuadrant::from(dir), expected); } } + + #[test] + fn out_of_bounds_indexes_return_none() { + assert_eq!(CompassQuadrant::from_index(4), None); + assert_eq!(CompassQuadrant::from_index(5), None); + assert_eq!(CompassQuadrant::from_index(usize::MAX), None); + } + + #[test] + fn compass_indexes_are_reversible() { + for i in 0..4 { + let quadrant = CompassQuadrant::from_index(i).unwrap(); + assert_eq!(quadrant.to_index(), i); + } + } + + #[test] + fn opposite_directions_reverse_themselves() { + for i in 0..4 { + let quadrant = CompassQuadrant::from_index(i).unwrap(); + assert_eq!(-(-quadrant), quadrant); + } + } } #[cfg(test)] @@ -420,4 +551,27 @@ mod test_compass_octant { assert_eq!(CompassOctant::from(dir), expected); } } + + #[test] + fn out_of_bounds_indexes_return_none() { + assert_eq!(CompassOctant::from_index(8), None); + assert_eq!(CompassOctant::from_index(9), None); + assert_eq!(CompassOctant::from_index(usize::MAX), None); + } + + #[test] + fn compass_indexes_are_reversible() { + for i in 0..8 { + let octant = CompassOctant::from_index(i).unwrap(); + assert_eq!(octant.to_index(), i); + } + } + + #[test] + fn opposite_directions_reverse_themselves() { + for i in 0..8 { + let octant = CompassOctant::from_index(i).unwrap(); + assert_eq!(-(-octant), octant); + } + } }