//! 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; #[cfg(feature = "bevy_reflect")] use bevy_reflect::{prelude::*, Reflect}; 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::(); #[cfg(feature = "bevy_reflect")] app.register_type::() .register_type::(); } } /// The up-to-eight neighbors of a focusable entity, one for each [`CompassOctant`]. #[derive(Default, Debug, Clone, PartialEq)] #[cfg_attr( feature = "bevy_reflect", derive(Reflect), reflect(Default, Debug, 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)] #[cfg_attr( feature = "bevy_reflect", derive(Reflect), reflect(Resource, Debug, Default, 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)); } }