Path: blob/main/crates/bevy_input_focus/src/directional_navigation.rs
6595 views
//! A navigation framework for moving between focusable elements based on directional input.1//!2//! While virtual cursors are a common way to navigate UIs with a gamepad (or arrow keys!),3//! they are generally both slow and frustrating to use.4//! Instead, directional inputs should provide a direct way to snap between focusable elements.5//!6//! Like the rest of this crate, the [`InputFocus`] resource is manipulated to track7//! the current focus.8//!9//! Navigating between focusable entities (commonly UI nodes) is done by10//! passing a [`CompassOctant`] into the [`navigate`](DirectionalNavigation::navigate) method11//! from the [`DirectionalNavigation`] system parameter.12//!13//! Under the hood, the [`DirectionalNavigationMap`] stores a directed graph of focusable entities.14//! Each entity can have up to 8 neighbors, one for each [`CompassOctant`], balancing flexibility and required precision.15//! For now, this graph must be built manually, but in the future, it could be generated automatically.1617use bevy_app::prelude::*;18use bevy_ecs::{19entity::{EntityHashMap, EntityHashSet},20prelude::*,21system::SystemParam,22};23use bevy_math::CompassOctant;24use thiserror::Error;2526use crate::InputFocus;2728#[cfg(feature = "bevy_reflect")]29use bevy_reflect::{prelude::*, Reflect};3031/// A plugin that sets up the directional navigation systems and resources.32#[derive(Default)]33pub struct DirectionalNavigationPlugin;3435impl Plugin for DirectionalNavigationPlugin {36fn build(&self, app: &mut App) {37app.init_resource::<DirectionalNavigationMap>();38}39}4041/// The up-to-eight neighbors of a focusable entity, one for each [`CompassOctant`].42#[derive(Default, Debug, Clone, PartialEq)]43#[cfg_attr(44feature = "bevy_reflect",45derive(Reflect),46reflect(Default, Debug, PartialEq, Clone)47)]48pub struct NavNeighbors {49/// The array of neighbors, one for each [`CompassOctant`].50/// The mapping between array elements and directions is determined by [`CompassOctant::to_index`].51///52/// If no neighbor exists in a given direction, the value will be [`None`].53/// In most cases, using [`NavNeighbors::set`] and [`NavNeighbors::get`]54/// will be more ergonomic than directly accessing this array.55pub neighbors: [Option<Entity>; 8],56}5758impl NavNeighbors {59/// An empty set of neighbors.60pub const EMPTY: NavNeighbors = NavNeighbors {61neighbors: [None; 8],62};6364/// Get the neighbor for a given [`CompassOctant`].65pub const fn get(&self, octant: CompassOctant) -> Option<Entity> {66self.neighbors[octant.to_index()]67}6869/// Set the neighbor for a given [`CompassOctant`].70pub const fn set(&mut self, octant: CompassOctant, entity: Entity) {71self.neighbors[octant.to_index()] = Some(entity);72}73}7475/// A resource that stores the traversable graph of focusable entities.76///77/// Each entity can have up to 8 neighbors, one for each [`CompassOctant`].78///79/// To ensure that your graph is intuitive to navigate and generally works correctly, it should be:80///81/// - **Connected**: Every focusable entity should be reachable from every other focusable entity.82/// - **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.83/// - **Physical**: The direction of navigation should match the layout of the entities when possible,84/// although looping around the edges of the screen is also acceptable.85/// - **Not self-connected**: An entity should not be a neighbor of itself; use [`None`] instead.86///87/// For now, this graph must be built manually, and the developer is responsible for ensuring that it meets the above criteria.88#[derive(Resource, Debug, Default, Clone, PartialEq)]89#[cfg_attr(90feature = "bevy_reflect",91derive(Reflect),92reflect(Resource, Debug, Default, PartialEq, Clone)93)]94pub struct DirectionalNavigationMap {95/// A directed graph of focusable entities.96///97/// Pass in the current focus as a key, and get back a collection of up to 8 neighbors,98/// each keyed by a [`CompassOctant`].99pub neighbors: EntityHashMap<NavNeighbors>,100}101102impl DirectionalNavigationMap {103/// Adds a new entity to the navigation map, overwriting any existing neighbors for that entity.104///105/// Removes an entity from the navigation map, including all connections to and from it.106///107/// Note that this is an O(n) operation, where n is the number of entities in the map,108/// as we must iterate over each entity to check for connections to the removed entity.109///110/// If you are removing multiple entities, consider using [`remove_multiple`](Self::remove_multiple) instead.111pub fn remove(&mut self, entity: Entity) {112self.neighbors.remove(&entity);113114for node in self.neighbors.values_mut() {115for neighbor in node.neighbors.iter_mut() {116if *neighbor == Some(entity) {117*neighbor = None;118}119}120}121}122123/// Removes a collection of entities from the navigation map.124///125/// While this is still an O(n) operation, where n is the number of entities in the map,126/// it is more efficient than calling [`remove`](Self::remove) multiple times,127/// as we can check for connections to all removed entities in a single pass.128///129/// An [`EntityHashSet`] must be provided as it is noticeably faster than the standard hasher or a [`Vec`](`alloc::vec::Vec`).130pub fn remove_multiple(&mut self, entities: EntityHashSet) {131for entity in &entities {132self.neighbors.remove(entity);133}134135for node in self.neighbors.values_mut() {136for neighbor in node.neighbors.iter_mut() {137if let Some(entity) = *neighbor {138if entities.contains(&entity) {139*neighbor = None;140}141}142}143}144}145146/// Completely clears the navigation map, removing all entities and connections.147pub fn clear(&mut self) {148self.neighbors.clear();149}150151/// Adds an edge between two entities in the navigation map.152/// Any existing edge from A in the provided direction will be overwritten.153///154/// The reverse edge will not be added, so navigation will only be possible in one direction.155/// If you want to add a symmetrical edge, use [`add_symmetrical_edge`](Self::add_symmetrical_edge) instead.156pub fn add_edge(&mut self, a: Entity, b: Entity, direction: CompassOctant) {157self.neighbors158.entry(a)159.or_insert(NavNeighbors::EMPTY)160.set(direction, b);161}162163/// Adds a symmetrical edge between two entities in the navigation map.164/// The A -> B path will use the provided direction, while B -> A will use the [`CompassOctant::opposite`] variant.165///166/// Any existing connections between the two entities will be overwritten.167pub fn add_symmetrical_edge(&mut self, a: Entity, b: Entity, direction: CompassOctant) {168self.add_edge(a, b, direction);169self.add_edge(b, a, direction.opposite());170}171172/// Add symmetrical edges between each consecutive pair of entities in the provided slice.173///174/// Unlike [`add_looping_edges`](Self::add_looping_edges), this method does not loop back to the first entity.175pub fn add_edges(&mut self, entities: &[Entity], direction: CompassOctant) {176for pair in entities.windows(2) {177self.add_symmetrical_edge(pair[0], pair[1], direction);178}179}180181/// Add symmetrical edges between each consecutive pair of entities in the provided slice, looping back to the first entity at the end.182///183/// This is useful for creating a circular navigation path between a set of entities, such as a menu.184pub fn add_looping_edges(&mut self, entities: &[Entity], direction: CompassOctant) {185self.add_edges(entities, direction);186if let Some((first_entity, rest)) = entities.split_first() {187if let Some(last_entity) = rest.last() {188self.add_symmetrical_edge(*last_entity, *first_entity, direction);189}190}191}192193/// Gets the entity in a given direction from the current focus, if any.194pub fn get_neighbor(&self, focus: Entity, octant: CompassOctant) -> Option<Entity> {195self.neighbors196.get(&focus)197.and_then(|neighbors| neighbors.get(octant))198}199200/// Looks up the neighbors of a given entity.201///202/// If the entity is not in the map, [`None`] will be returned.203/// Note that the set of neighbors is not guaranteed to be non-empty though!204pub fn get_neighbors(&self, entity: Entity) -> Option<&NavNeighbors> {205self.neighbors.get(&entity)206}207}208209/// A system parameter for navigating between focusable entities in a directional way.210#[derive(SystemParam, Debug)]211pub struct DirectionalNavigation<'w> {212/// The currently focused entity.213pub focus: ResMut<'w, InputFocus>,214/// The navigation map containing the connections between entities.215pub map: Res<'w, DirectionalNavigationMap>,216}217218impl DirectionalNavigation<'_> {219/// Navigates to the neighbor in a given direction from the current focus, if any.220///221/// Returns the new focus if successful.222/// Returns an error if there is no focus set or if there is no neighbor in the requested direction.223///224/// If the result was `Ok`, the [`InputFocus`] resource is updated to the new focus as part of this method call.225pub fn navigate(226&mut self,227direction: CompassOctant,228) -> Result<Entity, DirectionalNavigationError> {229if let Some(current_focus) = self.focus.0 {230if let Some(new_focus) = self.map.get_neighbor(current_focus, direction) {231self.focus.set(new_focus);232Ok(new_focus)233} else {234Err(DirectionalNavigationError::NoNeighborInDirection {235current_focus,236direction,237})238}239} else {240Err(DirectionalNavigationError::NoFocus)241}242}243}244245/// An error that can occur when navigating between focusable entities using [directional navigation](crate::directional_navigation).246#[derive(Debug, PartialEq, Clone, Error)]247pub enum DirectionalNavigationError {248/// No focusable entity is currently set.249#[error("No focusable entity is currently set.")]250NoFocus,251/// No neighbor in the requested direction.252#[error("No neighbor from {current_focus} in the {direction:?} direction.")]253NoNeighborInDirection {254/// The entity that was the focus when the error occurred.255current_focus: Entity,256/// The direction in which the navigation was attempted.257direction: CompassOctant,258},259}260261#[cfg(test)]262mod tests {263use bevy_ecs::system::RunSystemOnce;264265use super::*;266267#[test]268fn setting_and_getting_nav_neighbors() {269let mut neighbors = NavNeighbors::EMPTY;270assert_eq!(neighbors.get(CompassOctant::SouthEast), None);271272neighbors.set(CompassOctant::SouthEast, Entity::PLACEHOLDER);273274for i in 0..8 {275if i == CompassOctant::SouthEast.to_index() {276assert_eq!(277neighbors.get(CompassOctant::SouthEast),278Some(Entity::PLACEHOLDER)279);280} else {281assert_eq!(neighbors.get(CompassOctant::from_index(i).unwrap()), None);282}283}284}285286#[test]287fn simple_set_and_get_navmap() {288let mut world = World::new();289let a = world.spawn_empty().id();290let b = world.spawn_empty().id();291292let mut map = DirectionalNavigationMap::default();293map.add_edge(a, b, CompassOctant::SouthEast);294295assert_eq!(map.get_neighbor(a, CompassOctant::SouthEast), Some(b));296assert_eq!(297map.get_neighbor(b, CompassOctant::SouthEast.opposite()),298None299);300}301302#[test]303fn symmetrical_edges() {304let mut world = World::new();305let a = world.spawn_empty().id();306let b = world.spawn_empty().id();307308let mut map = DirectionalNavigationMap::default();309map.add_symmetrical_edge(a, b, CompassOctant::North);310311assert_eq!(map.get_neighbor(a, CompassOctant::North), Some(b));312assert_eq!(map.get_neighbor(b, CompassOctant::South), Some(a));313}314315#[test]316fn remove_nodes() {317let mut world = World::new();318let a = world.spawn_empty().id();319let b = world.spawn_empty().id();320321let mut map = DirectionalNavigationMap::default();322map.add_edge(a, b, CompassOctant::North);323map.add_edge(b, a, CompassOctant::South);324325assert_eq!(map.get_neighbor(a, CompassOctant::North), Some(b));326assert_eq!(map.get_neighbor(b, CompassOctant::South), Some(a));327328map.remove(b);329330assert_eq!(map.get_neighbor(a, CompassOctant::North), None);331assert_eq!(map.get_neighbor(b, CompassOctant::South), None);332}333334#[test]335fn remove_multiple_nodes() {336let mut world = World::new();337let a = world.spawn_empty().id();338let b = world.spawn_empty().id();339let c = world.spawn_empty().id();340341let mut map = DirectionalNavigationMap::default();342map.add_edge(a, b, CompassOctant::North);343map.add_edge(b, a, CompassOctant::South);344map.add_edge(b, c, CompassOctant::East);345map.add_edge(c, b, CompassOctant::West);346347let mut to_remove = EntityHashSet::default();348to_remove.insert(b);349to_remove.insert(c);350351map.remove_multiple(to_remove);352353assert_eq!(map.get_neighbor(a, CompassOctant::North), None);354assert_eq!(map.get_neighbor(b, CompassOctant::South), None);355assert_eq!(map.get_neighbor(b, CompassOctant::East), None);356assert_eq!(map.get_neighbor(c, CompassOctant::West), None);357}358359#[test]360fn edges() {361let mut world = World::new();362let a = world.spawn_empty().id();363let b = world.spawn_empty().id();364let c = world.spawn_empty().id();365366let mut map = DirectionalNavigationMap::default();367map.add_edges(&[a, b, c], CompassOctant::East);368369assert_eq!(map.get_neighbor(a, CompassOctant::East), Some(b));370assert_eq!(map.get_neighbor(b, CompassOctant::East), Some(c));371assert_eq!(map.get_neighbor(c, CompassOctant::East), None);372373assert_eq!(map.get_neighbor(a, CompassOctant::West), None);374assert_eq!(map.get_neighbor(b, CompassOctant::West), Some(a));375assert_eq!(map.get_neighbor(c, CompassOctant::West), Some(b));376}377378#[test]379fn looping_edges() {380let mut world = World::new();381let a = world.spawn_empty().id();382let b = world.spawn_empty().id();383let c = world.spawn_empty().id();384385let mut map = DirectionalNavigationMap::default();386map.add_looping_edges(&[a, b, c], CompassOctant::East);387388assert_eq!(map.get_neighbor(a, CompassOctant::East), Some(b));389assert_eq!(map.get_neighbor(b, CompassOctant::East), Some(c));390assert_eq!(map.get_neighbor(c, CompassOctant::East), Some(a));391392assert_eq!(map.get_neighbor(a, CompassOctant::West), Some(c));393assert_eq!(map.get_neighbor(b, CompassOctant::West), Some(a));394assert_eq!(map.get_neighbor(c, CompassOctant::West), Some(b));395}396397#[test]398fn nav_with_system_param() {399let mut world = World::new();400let a = world.spawn_empty().id();401let b = world.spawn_empty().id();402let c = world.spawn_empty().id();403404let mut map = DirectionalNavigationMap::default();405map.add_looping_edges(&[a, b, c], CompassOctant::East);406407world.insert_resource(map);408409let mut focus = InputFocus::default();410focus.set(a);411world.insert_resource(focus);412413assert_eq!(world.resource::<InputFocus>().get(), Some(a));414415fn navigate_east(mut nav: DirectionalNavigation) {416nav.navigate(CompassOctant::East).unwrap();417}418419world.run_system_once(navigate_east).unwrap();420assert_eq!(world.resource::<InputFocus>().get(), Some(b));421422world.run_system_once(navigate_east).unwrap();423assert_eq!(world.resource::<InputFocus>().get(), Some(c));424425world.run_system_once(navigate_east).unwrap();426assert_eq!(world.resource::<InputFocus>().get(), Some(a));427}428}429430431