|
| 1 | +//! A navigation framework for moving between focusable elements based on directional input. |
| 2 | +//! |
| 3 | +//! While virtual cursors are a common way to navigate UIs with a gamepad (or arrow keys!), |
| 4 | +//! they are generally both slow and frustrating to use. |
| 5 | +//! Instead, directional inputs should provide a direct way to snap between focusable elements. |
| 6 | +//! |
| 7 | +//! Like the rest of this crate, the [`InputFocus`] resource is manipulated to track |
| 8 | +//! the current focus. |
| 9 | +//! |
| 10 | +//! Navigating between focusable entities (commonly UI nodes) is done by |
| 11 | +//! passing a [`CompassOctant`] into the [`navigate`](DirectionalNavigation::navigate) method |
| 12 | +//! from the [`DirectionalNavigation`] system parameter. |
| 13 | +//! |
| 14 | +//! Under the hood, the [`DirectionalNavigationMap`] stores a directed graph of focusable entities. |
| 15 | +//! Each entity can have up to 8 neighbors, one for each [`CompassOctant`], balancing flexibility and required precision. |
| 16 | +//! For now, this graph must be built manually, but in the future, it could be generated automatically. |
| 17 | +
|
| 18 | +use bevy_app::prelude::*; |
| 19 | +use bevy_ecs::{ |
| 20 | + entity::{EntityHashMap, EntityHashSet}, |
| 21 | + prelude::*, |
| 22 | + system::SystemParam, |
| 23 | +}; |
| 24 | +use bevy_math::CompassOctant; |
| 25 | +use thiserror::Error; |
| 26 | + |
| 27 | +use crate::InputFocus; |
| 28 | + |
| 29 | +/// A plugin that sets up the directional navigation systems and resources. |
| 30 | +#[derive(Default)] |
| 31 | +pub struct DirectionalNavigationPlugin; |
| 32 | + |
| 33 | +impl Plugin for DirectionalNavigationPlugin { |
| 34 | + fn build(&self, app: &mut App) { |
| 35 | + app.init_resource::<DirectionalNavigationMap>(); |
| 36 | + } |
| 37 | +} |
| 38 | + |
| 39 | +/// The up-to-eight neighbors of a focusable entity, one for each [`CompassOctant`]. |
| 40 | +#[derive(Default, Debug, Clone, PartialEq)] |
| 41 | +pub struct NavNeighbors { |
| 42 | + /// The array of neighbors, one for each [`CompassOctant`]. |
| 43 | + /// The mapping between array elements and directions is determined by [`CompassOctant::to_index`]. |
| 44 | + /// |
| 45 | + /// If no neighbor exists in a given direction, the value will be [`None`]. |
| 46 | + /// In most cases, using [`NavNeighbors::set`] and [`NavNeighbors::get`] |
| 47 | + /// will be more ergonomic than directly accessing this array. |
| 48 | + pub neighbors: [Option<Entity>; 8], |
| 49 | +} |
| 50 | + |
| 51 | +impl NavNeighbors { |
| 52 | + /// An empty set of neighbors. |
| 53 | + pub const EMPTY: NavNeighbors = NavNeighbors { |
| 54 | + neighbors: [None; 8], |
| 55 | + }; |
| 56 | + |
| 57 | + /// Get the neighbor for a given [`CompassOctant`]. |
| 58 | + pub const fn get(&self, octant: CompassOctant) -> Option<Entity> { |
| 59 | + self.neighbors[octant.to_index()] |
| 60 | + } |
| 61 | + |
| 62 | + /// Set the neighbor for a given [`CompassOctant`]. |
| 63 | + pub const fn set(&mut self, octant: CompassOctant, entity: Entity) { |
| 64 | + self.neighbors[octant.to_index()] = Some(entity); |
| 65 | + } |
| 66 | +} |
| 67 | + |
| 68 | +/// A resource that stores the traversable graph of focusable entities. |
| 69 | +/// |
| 70 | +/// Each entity can have up to 8 neighbors, one for each [`CompassOctant`]. |
| 71 | +/// |
| 72 | +/// To ensure that your graph is intuitive to navigate and generally works correctly, it should be: |
| 73 | +/// |
| 74 | +/// - **Connected**: Every focusable entity should be reachable from every other focusable entity. |
| 75 | +/// - **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. |
| 76 | +/// - **Physical**: The direction of navigation should match the layout of the entities when possible, |
| 77 | +/// although looping around the edges of the screen is also acceptable. |
| 78 | +/// - **Not self-connected**: An entity should not be a neighbor of itself; use [`None`] instead. |
| 79 | +/// |
| 80 | +/// For now, this graph must be built manually, and the developer is responsible for ensuring that it meets the above criteria. |
| 81 | +#[derive(Resource, Debug, Default, Clone, PartialEq)] |
| 82 | +pub struct DirectionalNavigationMap { |
| 83 | + /// A directed graph of focusable entities. |
| 84 | + /// |
| 85 | + /// Pass in the current focus as a key, and get back a collection of up to 8 neighbors, |
| 86 | + /// each keyed by a [`CompassOctant`]. |
| 87 | + pub neighbors: EntityHashMap<NavNeighbors>, |
| 88 | +} |
| 89 | + |
| 90 | +impl DirectionalNavigationMap { |
| 91 | + /// Adds a new entity to the navigation map, overwriting any existing neighbors for that entity. |
| 92 | + /// |
| 93 | + /// Removes an entity from the navigation map, including all connections to and from it. |
| 94 | + /// |
| 95 | + /// Note that this is an O(n) operation, where n is the number of entities in the map, |
| 96 | + /// as we must iterate over each entity to check for connections to the removed entity. |
| 97 | + /// |
| 98 | + /// If you are removing multiple entities, consider using [`remove_multiple`](Self::remove_multiple) instead. |
| 99 | + pub fn remove(&mut self, entity: Entity) { |
| 100 | + self.neighbors.remove(&entity); |
| 101 | + |
| 102 | + for node in self.neighbors.values_mut() { |
| 103 | + for neighbor in node.neighbors.iter_mut() { |
| 104 | + if *neighbor == Some(entity) { |
| 105 | + *neighbor = None; |
| 106 | + } |
| 107 | + } |
| 108 | + } |
| 109 | + } |
| 110 | + |
| 111 | + /// Removes a collection of entities from the navigation map. |
| 112 | + /// |
| 113 | + /// While this is still an O(n) operation, where n is the number of entities in the map, |
| 114 | + /// it is more efficient than calling [`remove`](Self::remove) multiple times, |
| 115 | + /// as we can check for connections to all removed entities in a single pass. |
| 116 | + /// |
| 117 | + /// An [`EntityHashSet`] must be provided as it is noticeably faster than the standard hasher or a [`Vec`]. |
| 118 | + pub fn remove_multiple(&mut self, entities: EntityHashSet) { |
| 119 | + for entity in &entities { |
| 120 | + self.neighbors.remove(entity); |
| 121 | + } |
| 122 | + |
| 123 | + for node in self.neighbors.values_mut() { |
| 124 | + for neighbor in node.neighbors.iter_mut() { |
| 125 | + if let Some(entity) = *neighbor { |
| 126 | + if entities.contains(&entity) { |
| 127 | + *neighbor = None; |
| 128 | + } |
| 129 | + } |
| 130 | + } |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + /// Completely clears the navigation map, removing all entities and connections. |
| 135 | + pub fn clear(&mut self) { |
| 136 | + self.neighbors.clear(); |
| 137 | + } |
| 138 | + |
| 139 | + /// Adds an edge between two entities in the navigation map. |
| 140 | + /// Any existing edge from A in the provided direction will be overwritten. |
| 141 | + /// |
| 142 | + /// The reverse edge will not be added, so navigation will only be possible in one direction. |
| 143 | + /// If you want to add a symmetrical edge, use [`add_symmetrical_edge`](Self::add_symmetrical_edge) instead. |
| 144 | + pub fn add_edge(&mut self, a: Entity, b: Entity, direction: CompassOctant) { |
| 145 | + self.neighbors |
| 146 | + .entry(a) |
| 147 | + .or_insert(NavNeighbors::EMPTY) |
| 148 | + .set(direction, b); |
| 149 | + } |
| 150 | + |
| 151 | + /// Adds a symmetrical edge between two entities in the navigation map. |
| 152 | + /// The A -> B path will use the provided direction, while B -> A will use the [`CompassOctant::opposite`] variant. |
| 153 | + /// |
| 154 | + /// Any existing connections between the two entities will be overwritten. |
| 155 | + pub fn add_symmetrical_edge(&mut self, a: Entity, b: Entity, direction: CompassOctant) { |
| 156 | + self.add_edge(a, b, direction); |
| 157 | + self.add_edge(b, a, direction.opposite()); |
| 158 | + } |
| 159 | + |
| 160 | + /// Add symmetrical edges between all entities in the provided slice, looping back to the first entity at the end. |
| 161 | + /// |
| 162 | + /// This is useful for creating a circular navigation path between a set of entities, such as a menu. |
| 163 | + pub fn add_looping_edges(&mut self, entities: &[Entity], direction: CompassOctant) { |
| 164 | + for i in 0..entities.len() { |
| 165 | + let a = entities[i]; |
| 166 | + let b = entities[(i + 1) % entities.len()]; |
| 167 | + self.add_symmetrical_edge(a, b, direction); |
| 168 | + } |
| 169 | + } |
| 170 | + |
| 171 | + /// Gets the entity in a given direction from the current focus, if any. |
| 172 | + pub fn get_neighbor(&self, focus: Entity, octant: CompassOctant) -> Option<Entity> { |
| 173 | + self.neighbors |
| 174 | + .get(&focus) |
| 175 | + .and_then(|neighbors| neighbors.get(octant)) |
| 176 | + } |
| 177 | + |
| 178 | + /// Looks up the neighbors of a given entity. |
| 179 | + /// |
| 180 | + /// If the entity is not in the map, [`None`] will be returned. |
| 181 | + /// Note that the set of neighbors is not guaranteed to be non-empty though! |
| 182 | + pub fn get_neighbors(&self, entity: Entity) -> Option<&NavNeighbors> { |
| 183 | + self.neighbors.get(&entity) |
| 184 | + } |
| 185 | +} |
| 186 | + |
| 187 | +/// A system parameter for navigating between focusable entities in a directional way. |
| 188 | +#[derive(SystemParam, Debug)] |
| 189 | +pub struct DirectionalNavigation<'w> { |
| 190 | + /// The currently focused entity. |
| 191 | + pub focus: ResMut<'w, InputFocus>, |
| 192 | + /// The navigation map containing the connections between entities. |
| 193 | + pub map: Res<'w, DirectionalNavigationMap>, |
| 194 | +} |
| 195 | + |
| 196 | +impl DirectionalNavigation<'_> { |
| 197 | + /// Navigates to the neighbor in a given direction from the current focus, if any. |
| 198 | + /// |
| 199 | + /// Returns the new focus if successful. |
| 200 | + /// Returns an error if there is no focus set or if there is no neighbor in the requested direction. |
| 201 | + /// |
| 202 | + /// If the result was `Ok`, the [`InputFocus`] resource is updated to the new focus as part of this method call. |
| 203 | + pub fn navigate( |
| 204 | + &mut self, |
| 205 | + octant: CompassOctant, |
| 206 | + ) -> Result<Entity, DirectionalNavigationError> { |
| 207 | + if let Some(current_focus) = self.focus.0 { |
| 208 | + if let Some(new_focus) = self.map.get_neighbor(current_focus, octant) { |
| 209 | + self.focus.set(new_focus); |
| 210 | + Ok(new_focus) |
| 211 | + } else { |
| 212 | + Err(DirectionalNavigationError::NoNeighborInDirection) |
| 213 | + } |
| 214 | + } else { |
| 215 | + Err(DirectionalNavigationError::NoFocus) |
| 216 | + } |
| 217 | + } |
| 218 | +} |
| 219 | + |
| 220 | +/// An error that can occur when navigating between focusable entities using [directional navigation](crate::directional_navigation). |
| 221 | +#[derive(Debug, PartialEq, Clone, Error)] |
| 222 | +pub enum DirectionalNavigationError { |
| 223 | + /// No focusable entity is currently set. |
| 224 | + #[error("No focusable entity is currently set.")] |
| 225 | + NoFocus, |
| 226 | + /// No neighbor in the requested direction. |
| 227 | + #[error("No neighbor in the requested direction.")] |
| 228 | + NoNeighborInDirection, |
| 229 | +} |
| 230 | + |
| 231 | +#[cfg(test)] |
| 232 | +mod tests { |
| 233 | + use bevy_ecs::system::RunSystemOnce; |
| 234 | + |
| 235 | + use super::*; |
| 236 | + |
| 237 | + #[test] |
| 238 | + fn setting_and_getting_nav_neighbors() { |
| 239 | + let mut neighbors = NavNeighbors::EMPTY; |
| 240 | + assert_eq!(neighbors.get(CompassOctant::SouthEast), None); |
| 241 | + |
| 242 | + neighbors.set(CompassOctant::SouthEast, Entity::PLACEHOLDER); |
| 243 | + |
| 244 | + for i in 0..8 { |
| 245 | + if i == CompassOctant::SouthEast.to_index() { |
| 246 | + assert_eq!( |
| 247 | + neighbors.get(CompassOctant::SouthEast), |
| 248 | + Some(Entity::PLACEHOLDER) |
| 249 | + ); |
| 250 | + } else { |
| 251 | + assert_eq!(neighbors.get(CompassOctant::from_index(i).unwrap()), None); |
| 252 | + } |
| 253 | + } |
| 254 | + } |
| 255 | + |
| 256 | + #[test] |
| 257 | + fn simple_set_and_get_navmap() { |
| 258 | + let mut world = World::new(); |
| 259 | + let a = world.spawn_empty().id(); |
| 260 | + let b = world.spawn_empty().id(); |
| 261 | + |
| 262 | + let mut map = DirectionalNavigationMap::default(); |
| 263 | + map.add_edge(a, b, CompassOctant::SouthEast); |
| 264 | + |
| 265 | + assert_eq!(map.get_neighbor(a, CompassOctant::SouthEast), Some(b)); |
| 266 | + assert_eq!( |
| 267 | + map.get_neighbor(b, CompassOctant::SouthEast.opposite()), |
| 268 | + None |
| 269 | + ); |
| 270 | + } |
| 271 | + |
| 272 | + #[test] |
| 273 | + fn symmetrical_edges() { |
| 274 | + let mut world = World::new(); |
| 275 | + let a = world.spawn_empty().id(); |
| 276 | + let b = world.spawn_empty().id(); |
| 277 | + |
| 278 | + let mut map = DirectionalNavigationMap::default(); |
| 279 | + map.add_symmetrical_edge(a, b, CompassOctant::North); |
| 280 | + |
| 281 | + assert_eq!(map.get_neighbor(a, CompassOctant::North), Some(b)); |
| 282 | + assert_eq!(map.get_neighbor(b, CompassOctant::South), Some(a)); |
| 283 | + } |
| 284 | + |
| 285 | + #[test] |
| 286 | + fn remove_nodes() { |
| 287 | + let mut world = World::new(); |
| 288 | + let a = world.spawn_empty().id(); |
| 289 | + let b = world.spawn_empty().id(); |
| 290 | + |
| 291 | + let mut map = DirectionalNavigationMap::default(); |
| 292 | + map.add_edge(a, b, CompassOctant::North); |
| 293 | + map.add_edge(b, a, CompassOctant::South); |
| 294 | + |
| 295 | + assert_eq!(map.get_neighbor(a, CompassOctant::North), Some(b)); |
| 296 | + assert_eq!(map.get_neighbor(b, CompassOctant::South), Some(a)); |
| 297 | + |
| 298 | + map.remove(b); |
| 299 | + |
| 300 | + assert_eq!(map.get_neighbor(a, CompassOctant::North), None); |
| 301 | + assert_eq!(map.get_neighbor(b, CompassOctant::South), None); |
| 302 | + } |
| 303 | + |
| 304 | + #[test] |
| 305 | + fn remove_multiple_nodes() { |
| 306 | + let mut world = World::new(); |
| 307 | + let a = world.spawn_empty().id(); |
| 308 | + let b = world.spawn_empty().id(); |
| 309 | + let c = world.spawn_empty().id(); |
| 310 | + |
| 311 | + let mut map = DirectionalNavigationMap::default(); |
| 312 | + map.add_edge(a, b, CompassOctant::North); |
| 313 | + map.add_edge(b, a, CompassOctant::South); |
| 314 | + map.add_edge(b, c, CompassOctant::East); |
| 315 | + map.add_edge(c, b, CompassOctant::West); |
| 316 | + |
| 317 | + let mut to_remove = EntityHashSet::default(); |
| 318 | + to_remove.insert(b); |
| 319 | + to_remove.insert(c); |
| 320 | + |
| 321 | + map.remove_multiple(to_remove); |
| 322 | + |
| 323 | + assert_eq!(map.get_neighbor(a, CompassOctant::North), None); |
| 324 | + assert_eq!(map.get_neighbor(b, CompassOctant::South), None); |
| 325 | + assert_eq!(map.get_neighbor(b, CompassOctant::East), None); |
| 326 | + assert_eq!(map.get_neighbor(c, CompassOctant::West), None); |
| 327 | + } |
| 328 | + |
| 329 | + #[test] |
| 330 | + fn looping_edges() { |
| 331 | + let mut world = World::new(); |
| 332 | + let a = world.spawn_empty().id(); |
| 333 | + let b = world.spawn_empty().id(); |
| 334 | + let c = world.spawn_empty().id(); |
| 335 | + |
| 336 | + let mut map = DirectionalNavigationMap::default(); |
| 337 | + map.add_looping_edges(&[a, b, c], CompassOctant::East); |
| 338 | + |
| 339 | + assert_eq!(map.get_neighbor(a, CompassOctant::East), Some(b)); |
| 340 | + assert_eq!(map.get_neighbor(b, CompassOctant::East), Some(c)); |
| 341 | + assert_eq!(map.get_neighbor(c, CompassOctant::East), Some(a)); |
| 342 | + |
| 343 | + assert_eq!(map.get_neighbor(a, CompassOctant::West), Some(c)); |
| 344 | + assert_eq!(map.get_neighbor(b, CompassOctant::West), Some(a)); |
| 345 | + assert_eq!(map.get_neighbor(c, CompassOctant::West), Some(b)); |
| 346 | + } |
| 347 | + |
| 348 | + #[test] |
| 349 | + fn nav_with_system_param() { |
| 350 | + let mut world = World::new(); |
| 351 | + let a = world.spawn_empty().id(); |
| 352 | + let b = world.spawn_empty().id(); |
| 353 | + let c = world.spawn_empty().id(); |
| 354 | + |
| 355 | + let mut map = DirectionalNavigationMap::default(); |
| 356 | + map.add_looping_edges(&[a, b, c], CompassOctant::East); |
| 357 | + |
| 358 | + world.insert_resource(map); |
| 359 | + |
| 360 | + let mut focus = InputFocus::default(); |
| 361 | + focus.set(a); |
| 362 | + world.insert_resource(focus); |
| 363 | + |
| 364 | + assert_eq!(world.resource::<InputFocus>().get(), Some(a)); |
| 365 | + |
| 366 | + fn navigate_east(mut nav: DirectionalNavigation) { |
| 367 | + nav.navigate(CompassOctant::East).unwrap(); |
| 368 | + } |
| 369 | + |
| 370 | + world.run_system_once(navigate_east).unwrap(); |
| 371 | + assert_eq!(world.resource::<InputFocus>().get(), Some(b)); |
| 372 | + |
| 373 | + world.run_system_once(navigate_east).unwrap(); |
| 374 | + assert_eq!(world.resource::<InputFocus>().get(), Some(c)); |
| 375 | + |
| 376 | + world.run_system_once(navigate_east).unwrap(); |
| 377 | + assert_eq!(world.resource::<InputFocus>().get(), Some(a)); |
| 378 | + } |
| 379 | +} |
0 commit comments