
# 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>
578 lines
16 KiB
Rust
578 lines
16 KiB
Rust
use core::ops::Neg;
|
||
|
||
use crate::Dir2;
|
||
#[cfg(feature = "bevy_reflect")]
|
||
use bevy_reflect::Reflect;
|
||
#[cfg(all(feature = "serialize", feature = "bevy_reflect"))]
|
||
use bevy_reflect::{ReflectDeserialize, ReflectSerialize};
|
||
|
||
/// A compass enum with 4 directions.
|
||
/// ```text
|
||
/// N (North)
|
||
/// ▲
|
||
/// │
|
||
/// │
|
||
/// W (West) ┼─────► E (East)
|
||
/// │
|
||
/// │
|
||
/// ▼
|
||
/// S (South)
|
||
/// ```
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
|
||
#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Debug, PartialEq))]
|
||
#[cfg_attr(
|
||
all(feature = "serialize", feature = "bevy_reflect"),
|
||
reflect(Deserialize, Serialize)
|
||
)]
|
||
pub enum CompassQuadrant {
|
||
/// Corresponds to [`Dir2::Y`] and [`Dir2::NORTH`]
|
||
North,
|
||
/// Corresponds to [`Dir2::X`] and [`Dir2::EAST`]
|
||
East,
|
||
/// Corresponds to [`Dir2::NEG_X`] and [`Dir2::SOUTH`]
|
||
South,
|
||
/// Corresponds to [`Dir2::NEG_Y`] and [`Dir2::WEST`]
|
||
West,
|
||
}
|
||
|
||
impl CompassQuadrant {
|
||
/// Converts a standard index to a [`CompassQuadrant`].
|
||
///
|
||
/// Starts at 0 for [`CompassQuadrant::North`] and increments clockwise.
|
||
pub const fn from_index(index: usize) -> Option<Self> {
|
||
match index {
|
||
0 => Some(Self::North),
|
||
1 => Some(Self::East),
|
||
2 => Some(Self::South),
|
||
3 => Some(Self::West),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Converts a [`CompassQuadrant`] to a standard index.
|
||
///
|
||
/// Starts at 0 for [`CompassQuadrant::North`] and increments clockwise.
|
||
pub const fn to_index(self) -> usize {
|
||
match self {
|
||
Self::North => 0,
|
||
Self::East => 1,
|
||
Self::South => 2,
|
||
Self::West => 3,
|
||
}
|
||
}
|
||
|
||
/// Returns the opposite [`CompassQuadrant`], located 180 degrees from `self`.
|
||
///
|
||
/// This can also be accessed via the `-` operator, using the [`Neg`] trait.
|
||
pub const fn opposite(&self) -> CompassQuadrant {
|
||
match self {
|
||
Self::North => Self::South,
|
||
Self::East => Self::West,
|
||
Self::South => Self::North,
|
||
Self::West => Self::East,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A compass enum with 8 directions.
|
||
/// ```text
|
||
/// N (North)
|
||
/// ▲
|
||
/// NW │ NE
|
||
/// ╲ │ ╱
|
||
/// W (West) ┼─────► E (East)
|
||
/// ╱ │ ╲
|
||
/// SW │ SE
|
||
/// ▼
|
||
/// S (South)
|
||
/// ```
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
|
||
#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Debug, PartialEq))]
|
||
#[cfg_attr(
|
||
all(feature = "serialize", feature = "bevy_reflect"),
|
||
reflect(Deserialize, Serialize)
|
||
)]
|
||
pub enum CompassOctant {
|
||
/// Corresponds to [`Dir2::Y`] and [`Dir2::NORTH`]
|
||
North,
|
||
/// Corresponds to [`Dir2::NORTH_EAST`]
|
||
NorthEast,
|
||
/// Corresponds to [`Dir2::X`] and [`Dir2::EAST`]
|
||
East,
|
||
/// Corresponds to [`Dir2::SOUTH_EAST`]
|
||
SouthEast,
|
||
/// Corresponds to [`Dir2::NEG_X`] and [`Dir2::SOUTH`]
|
||
South,
|
||
/// Corresponds to [`Dir2::SOUTH_WEST`]
|
||
SouthWest,
|
||
/// Corresponds to [`Dir2::NEG_Y`] and [`Dir2::WEST`]
|
||
West,
|
||
/// Corresponds to [`Dir2::NORTH_WEST`]
|
||
NorthWest,
|
||
}
|
||
|
||
impl CompassOctant {
|
||
/// Converts a standard index to a [`CompassOctant`].
|
||
///
|
||
/// Starts at 0 for [`CompassOctant::North`] and increments clockwise.
|
||
pub const fn from_index(index: usize) -> Option<Self> {
|
||
match index {
|
||
0 => Some(Self::North),
|
||
1 => Some(Self::NorthEast),
|
||
2 => Some(Self::East),
|
||
3 => Some(Self::SouthEast),
|
||
4 => Some(Self::South),
|
||
5 => Some(Self::SouthWest),
|
||
6 => Some(Self::West),
|
||
7 => Some(Self::NorthWest),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Converts a [`CompassOctant`] to a standard index.
|
||
///
|
||
/// Starts at 0 for [`CompassOctant::North`] and increments clockwise.
|
||
pub const fn to_index(self) -> usize {
|
||
match self {
|
||
Self::North => 0,
|
||
Self::NorthEast => 1,
|
||
Self::East => 2,
|
||
Self::SouthEast => 3,
|
||
Self::South => 4,
|
||
Self::SouthWest => 5,
|
||
Self::West => 6,
|
||
Self::NorthWest => 7,
|
||
}
|
||
}
|
||
|
||
/// Returns the opposite [`CompassOctant`], located 180 degrees from `self`.
|
||
///
|
||
/// This can also be accessed via the `-` operator, using the [`Neg`] trait.
|
||
pub const fn opposite(&self) -> CompassOctant {
|
||
match self {
|
||
Self::North => Self::South,
|
||
Self::NorthEast => Self::SouthWest,
|
||
Self::East => Self::West,
|
||
Self::SouthEast => Self::NorthWest,
|
||
Self::South => Self::North,
|
||
Self::SouthWest => Self::NorthEast,
|
||
Self::West => Self::East,
|
||
Self::NorthWest => Self::SouthEast,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<CompassQuadrant> for Dir2 {
|
||
fn from(q: CompassQuadrant) -> Self {
|
||
match q {
|
||
CompassQuadrant::North => Dir2::NORTH,
|
||
CompassQuadrant::East => Dir2::EAST,
|
||
CompassQuadrant::South => Dir2::SOUTH,
|
||
CompassQuadrant::West => Dir2::WEST,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<Dir2> for CompassQuadrant {
|
||
/// Converts a [`Dir2`] to a [`CompassQuadrant`] in a lossy manner.
|
||
/// Converting back to a [`Dir2`] is not guaranteed to yield the same value.
|
||
fn from(dir: Dir2) -> Self {
|
||
let angle = dir.to_angle().to_degrees();
|
||
|
||
match angle {
|
||
-135.0..=-45.0 => Self::South,
|
||
-45.0..=45.0 => Self::East,
|
||
45.0..=135.0 => Self::North,
|
||
135.0..=180.0 | -180.0..=-135.0 => Self::West,
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<CompassOctant> for Dir2 {
|
||
fn from(o: CompassOctant) -> Self {
|
||
match o {
|
||
CompassOctant::North => Dir2::NORTH,
|
||
CompassOctant::NorthEast => Dir2::NORTH_EAST,
|
||
CompassOctant::East => Dir2::EAST,
|
||
CompassOctant::SouthEast => Dir2::SOUTH_EAST,
|
||
CompassOctant::South => Dir2::SOUTH,
|
||
CompassOctant::SouthWest => Dir2::SOUTH_WEST,
|
||
CompassOctant::West => Dir2::WEST,
|
||
CompassOctant::NorthWest => Dir2::NORTH_WEST,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<Dir2> for CompassOctant {
|
||
/// Converts a [`Dir2`] to a [`CompassOctant`] in a lossy manner.
|
||
/// Converting back to a [`Dir2`] is not guaranteed to yield the same value.
|
||
fn from(dir: Dir2) -> Self {
|
||
let angle = dir.to_angle().to_degrees();
|
||
|
||
match angle {
|
||
-112.5..=-67.5 => Self::South,
|
||
-67.5..=-22.5 => Self::SouthEast,
|
||
-22.5..=22.5 => Self::East,
|
||
22.5..=67.5 => Self::NorthEast,
|
||
67.5..=112.5 => Self::North,
|
||
112.5..=157.5 => Self::NorthWest,
|
||
157.5..=180.0 | -180.0..=-157.5 => Self::West,
|
||
-157.5..=-112.5 => Self::SouthWest,
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Neg for CompassQuadrant {
|
||
type Output = CompassQuadrant;
|
||
|
||
fn neg(self) -> Self::Output {
|
||
self.opposite()
|
||
}
|
||
}
|
||
|
||
impl Neg for CompassOctant {
|
||
type Output = CompassOctant;
|
||
|
||
fn neg(self) -> Self::Output {
|
||
self.opposite()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod test_compass_quadrant {
|
||
use crate::{CompassQuadrant, Dir2, Vec2};
|
||
|
||
#[test]
|
||
fn test_cardinal_directions() {
|
||
let tests = [
|
||
(
|
||
Dir2::new(Vec2::new(1.0, 0.0)).unwrap(),
|
||
CompassQuadrant::East,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(0.0, 1.0)).unwrap(),
|
||
CompassQuadrant::North,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(-1.0, 0.0)).unwrap(),
|
||
CompassQuadrant::West,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(0.0, -1.0)).unwrap(),
|
||
CompassQuadrant::South,
|
||
),
|
||
];
|
||
|
||
for (dir, expected) in tests {
|
||
assert_eq!(CompassQuadrant::from(dir), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_north_pie_slice() {
|
||
let tests = [
|
||
(
|
||
Dir2::new(Vec2::new(-0.1, 0.9)).unwrap(),
|
||
CompassQuadrant::North,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(0.1, 0.9)).unwrap(),
|
||
CompassQuadrant::North,
|
||
),
|
||
];
|
||
|
||
for (dir, expected) in tests {
|
||
assert_eq!(CompassQuadrant::from(dir), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_east_pie_slice() {
|
||
let tests = [
|
||
(
|
||
Dir2::new(Vec2::new(0.9, 0.1)).unwrap(),
|
||
CompassQuadrant::East,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(0.9, -0.1)).unwrap(),
|
||
CompassQuadrant::East,
|
||
),
|
||
];
|
||
|
||
for (dir, expected) in tests {
|
||
assert_eq!(CompassQuadrant::from(dir), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_south_pie_slice() {
|
||
let tests = [
|
||
(
|
||
Dir2::new(Vec2::new(-0.1, -0.9)).unwrap(),
|
||
CompassQuadrant::South,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(0.1, -0.9)).unwrap(),
|
||
CompassQuadrant::South,
|
||
),
|
||
];
|
||
|
||
for (dir, expected) in tests {
|
||
assert_eq!(CompassQuadrant::from(dir), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_west_pie_slice() {
|
||
let tests = [
|
||
(
|
||
Dir2::new(Vec2::new(-0.9, -0.1)).unwrap(),
|
||
CompassQuadrant::West,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(-0.9, 0.1)).unwrap(),
|
||
CompassQuadrant::West,
|
||
),
|
||
];
|
||
|
||
for (dir, expected) in tests {
|
||
assert_eq!(CompassQuadrant::from(dir), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn out_of_bounds_indexes_return_none() {
|
||
assert_eq!(CompassQuadrant::from_index(4), None);
|
||
assert_eq!(CompassQuadrant::from_index(5), None);
|
||
assert_eq!(CompassQuadrant::from_index(usize::MAX), None);
|
||
}
|
||
|
||
#[test]
|
||
fn compass_indexes_are_reversible() {
|
||
for i in 0..4 {
|
||
let quadrant = CompassQuadrant::from_index(i).unwrap();
|
||
assert_eq!(quadrant.to_index(), i);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn opposite_directions_reverse_themselves() {
|
||
for i in 0..4 {
|
||
let quadrant = CompassQuadrant::from_index(i).unwrap();
|
||
assert_eq!(-(-quadrant), quadrant);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod test_compass_octant {
|
||
use crate::{CompassOctant, Dir2, Vec2};
|
||
|
||
#[test]
|
||
fn test_cardinal_directions() {
|
||
let tests = [
|
||
(
|
||
Dir2::new(Vec2::new(-0.5, 0.5)).unwrap(),
|
||
CompassOctant::NorthWest,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(0.0, 1.0)).unwrap(),
|
||
CompassOctant::North,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(0.5, 0.5)).unwrap(),
|
||
CompassOctant::NorthEast,
|
||
),
|
||
(Dir2::new(Vec2::new(1.0, 0.0)).unwrap(), CompassOctant::East),
|
||
(
|
||
Dir2::new(Vec2::new(0.5, -0.5)).unwrap(),
|
||
CompassOctant::SouthEast,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(0.0, -1.0)).unwrap(),
|
||
CompassOctant::South,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(-0.5, -0.5)).unwrap(),
|
||
CompassOctant::SouthWest,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(-1.0, 0.0)).unwrap(),
|
||
CompassOctant::West,
|
||
),
|
||
];
|
||
|
||
for (dir, expected) in tests {
|
||
assert_eq!(CompassOctant::from(dir), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_north_pie_slice() {
|
||
let tests = [
|
||
(
|
||
Dir2::new(Vec2::new(-0.1, 0.9)).unwrap(),
|
||
CompassOctant::North,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(0.1, 0.9)).unwrap(),
|
||
CompassOctant::North,
|
||
),
|
||
];
|
||
|
||
for (dir, expected) in tests {
|
||
assert_eq!(CompassOctant::from(dir), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_north_east_pie_slice() {
|
||
let tests = [
|
||
(
|
||
Dir2::new(Vec2::new(0.4, 0.6)).unwrap(),
|
||
CompassOctant::NorthEast,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(0.6, 0.4)).unwrap(),
|
||
CompassOctant::NorthEast,
|
||
),
|
||
];
|
||
|
||
for (dir, expected) in tests {
|
||
assert_eq!(CompassOctant::from(dir), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_east_pie_slice() {
|
||
let tests = [
|
||
(Dir2::new(Vec2::new(0.9, 0.1)).unwrap(), CompassOctant::East),
|
||
(
|
||
Dir2::new(Vec2::new(0.9, -0.1)).unwrap(),
|
||
CompassOctant::East,
|
||
),
|
||
];
|
||
|
||
for (dir, expected) in tests {
|
||
assert_eq!(CompassOctant::from(dir), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_south_east_pie_slice() {
|
||
let tests = [
|
||
(
|
||
Dir2::new(Vec2::new(0.4, -0.6)).unwrap(),
|
||
CompassOctant::SouthEast,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(0.6, -0.4)).unwrap(),
|
||
CompassOctant::SouthEast,
|
||
),
|
||
];
|
||
|
||
for (dir, expected) in tests {
|
||
assert_eq!(CompassOctant::from(dir), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_south_pie_slice() {
|
||
let tests = [
|
||
(
|
||
Dir2::new(Vec2::new(-0.1, -0.9)).unwrap(),
|
||
CompassOctant::South,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(0.1, -0.9)).unwrap(),
|
||
CompassOctant::South,
|
||
),
|
||
];
|
||
|
||
for (dir, expected) in tests {
|
||
assert_eq!(CompassOctant::from(dir), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_south_west_pie_slice() {
|
||
let tests = [
|
||
(
|
||
Dir2::new(Vec2::new(-0.4, -0.6)).unwrap(),
|
||
CompassOctant::SouthWest,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(-0.6, -0.4)).unwrap(),
|
||
CompassOctant::SouthWest,
|
||
),
|
||
];
|
||
|
||
for (dir, expected) in tests {
|
||
assert_eq!(CompassOctant::from(dir), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_west_pie_slice() {
|
||
let tests = [
|
||
(
|
||
Dir2::new(Vec2::new(-0.9, -0.1)).unwrap(),
|
||
CompassOctant::West,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(-0.9, 0.1)).unwrap(),
|
||
CompassOctant::West,
|
||
),
|
||
];
|
||
|
||
for (dir, expected) in tests {
|
||
assert_eq!(CompassOctant::from(dir), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_north_west_pie_slice() {
|
||
let tests = [
|
||
(
|
||
Dir2::new(Vec2::new(-0.4, 0.6)).unwrap(),
|
||
CompassOctant::NorthWest,
|
||
),
|
||
(
|
||
Dir2::new(Vec2::new(-0.6, 0.4)).unwrap(),
|
||
CompassOctant::NorthWest,
|
||
),
|
||
];
|
||
|
||
for (dir, expected) in tests {
|
||
assert_eq!(CompassOctant::from(dir), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn out_of_bounds_indexes_return_none() {
|
||
assert_eq!(CompassOctant::from_index(8), None);
|
||
assert_eq!(CompassOctant::from_index(9), None);
|
||
assert_eq!(CompassOctant::from_index(usize::MAX), None);
|
||
}
|
||
|
||
#[test]
|
||
fn compass_indexes_are_reversible() {
|
||
for i in 0..8 {
|
||
let octant = CompassOctant::from_index(i).unwrap();
|
||
assert_eq!(octant.to_index(), i);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn opposite_directions_reverse_themselves() {
|
||
for i in 0..8 {
|
||
let octant = CompassOctant::from_index(i).unwrap();
|
||
assert_eq!(-(-octant), octant);
|
||
}
|
||
}
|
||
}
|