-
-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add basic directional (gamepad) navigation for UI (and non-UI) (#17102)
# Objective While directional navigation is helpful for UI in general for accessibility reasons, it is *especially* valuable for a game engine, where menus may be navigated primarily or exclusively through the use of a game controller. Thumb-stick powered cursor-based navigation can work as a fallback, but is generally a pretty poor user experience. We can do better! ## Prior art Within Bevy, https://github.com/nicopap/ui-navigation and https://github.com/rparrett/bevy-alt-ui-navigation-lite exist to solve this same problem. This isn't yet a complete replacement for that ecosystem, but hopefully we'll be there for 0.16. ## Solution UI navigation is complicated, and the right tradeoffs will vary based on the project and even the individual scene. We're starting with something simple and flexible, hooking into the existing `InputFocus` resource, and storing a manually constructed graph of entities to explore in a `DirectionalNavigationMap` resource. The developer experience won't be great (so much wiring to do!), but the tools are all there for a great user experience. We could have chosen to represent these linkages via component-flavored not-quite-relations. This would be useful for inspectors, and would give us automatic cleanup when the entities were despawned, but seriously complicates the developer experience when building and checking this API. For now, we're doing a dumb "entity graph in a resource" thing and `remove` helpers. Once relations are added, we can re-evaluate. I've decided to use a `CompassOctant` as our key for the possible paths. This should give users a reasonable amount of precise control without being fiddly, and playing reasonably nicely with arrow-key navigation. This design lets us store the set of entities that we're connected to as a 8-byte array (yay Entity-niching). In theory, this is maybe nicer than the double indirection of two hashmaps. but if this ends up being slow we should create benchmarks. To make this work more pleasant, I've added a few utilities to the `CompassOctant` type: converting to and from usize, and adding a helper to find the 180 degrees opposite direction. These have been mirrored onto `CompassQuadrant` for consistency: they should be generally useful for game logic. ## Future work This is a relatively complex initiative! In the hopes of easing review and avoiding merge conflicts, I've opted to split this work into bite-sized chunks. Before 0.16, I'd like to have: - An example demonstrating gamepad and tab-based navigation in a realistic game menu - Helpers to convert axis-based inputs into compass quadrants / octants - Tools to check the listed graph desiderata - A helper to build a graph from a grid of entities - A tool to automatically build a graph given a supplied UI layout One day, it would be sweet if: - We had an example demonstrating how to use focus navigation in a non-UI scene to cycle between game objects - Standard actions for tab-style and directional navigation with a first-party bevy_actions integration - We had a visual debugging tool to display these navigation graphs for QC purposes - There was a built-in way to go "up a level" by cancelling the current action - The navigation graph is built completely out of relations ## Testing - tests for the new `CompassQuadrant` / `CompassOctant` methods - tests for the new directional navigation module --------- Co-authored-by: Rob Parrett <robparrett@gmail.com>
- Loading branch information
1 parent
b4b47d6
commit aa09b66
Showing
4 changed files
with
536 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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::<DirectionalNavigationMap>(); | ||
} | ||
} | ||
|
||
/// 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<Entity>; 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<Entity> { | ||
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<NavNeighbors>, | ||
} | ||
|
||
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<Entity> { | ||
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<Entity, DirectionalNavigationError> { | ||
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::<InputFocus>().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::<InputFocus>().get(), Some(b)); | ||
|
||
world.run_system_once(navigate_east).unwrap(); | ||
assert_eq!(world.resource::<InputFocus>().get(), Some(c)); | ||
|
||
world.run_system_once(navigate_east).unwrap(); | ||
assert_eq!(world.resource::<InputFocus>().get(), Some(a)); | ||
} | ||
} |
Oops, something went wrong.