-
-
Notifications
You must be signed in to change notification settings - Fork 4k
Add basic directional (gamepad) navigation for UI (and non-UI) #17102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f0465c3
67358c6
e9524b6
bf6c849
7ee06f3
5336173
b03d8ee
e87d8ab
56edaf4
d4c8b6a
ffa4557
0843574
ea5cedc
a9c47bf
70e562e
203f091
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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>, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just a thought, could it make sense for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I chewed on this a fair bit. IMO until we have relations we shouldn't try to encode these graphs via components. |
||
} | ||
|
||
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; | ||
} | ||
} | ||
} | ||
Comment on lines
+100
to
+108
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we forced the graph to be symmetric we could make this significantly more efficient (and constant-time bounded). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, sadly I don't think that's viable. As discussed on Discord, the "three small elements beside one big one" layout is not symmetric. |
||
} | ||
|
||
/// 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider using let-else syntax for less nesting! |
||
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()); | ||
} | ||
Comment on lines
+139
to
+158
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As mentioned above, I think there might be good perf reasons to only have the symmetric case. It's hard for me to imagine a good UI that is not symmetric and the non-symmetric edges do have a real cost. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @IQuick143 has pointed out to me that requiring symmetry may not be a good idea, and I will also admit that perf of graph modification isn't really a major concern. I'll weaken this to an extremely surface-level nit: Perhaps we should make the symmetric edge fn (which I think is probably what most people will want most of the time) the "default" by making it shorter: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that symmetric edges present too much of a footgun to make the default :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm ok with that. What do you think about allowing multiple vertices of the focus graph to correspond to the same ui element entity, instead of making them 1-to-1? Later in the same discussion we ran into some situations where this seemed desireable. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that's reasonable, but best left to follow-up. This is the sort of situation that was pushing me towards having a history, but I think there's a bunch of design to chew on. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Totally fair. This has good bones, I think we should run with it largely as is. |
||
|
||
/// 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. | ||
alice-i-cecile marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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>, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For another PR: we might want to consider making There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We've talked a bit about multi-focus before :) It's relatively niche, and we're worried that it will complicate the happy path for handling UI. Open to having my mind changed by a design in the future though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed; there are always going to be minority use cases that don't fit within the model. Fortunately, it's not that hard to roll your own implementation, which doesn't require any changes to Bevy. This is an area where third-party crates can fill the gaps. |
||
/// 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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is for another PR, but I wonder if we should also consider "adjacent octants". It may be confusing to users if they accidentally pressed up-and-left on a UI with just a left and their action had no effect. I recognize this is ambiguous (what if you have both a left and an up but no top-left), but I think picking any direction is better than doing nothing. |
||
&mut self, | ||
octant: CompassOctant, | ||
) -> Result<Entity, DirectionalNavigationError> { | ||
if let Some(current_focus) = self.focus.0 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let-else here and the next line perhaps |
||
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)); | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.