Bevy version: 0.15
Concept
In mobile games it is often the case that you can start input on screen and have virtual joystick to control the character in game. This is a implmentation of that.
Using in Bevy
Code with plugin is in the next chapter, here is the example guide.
Add plugin first:
...
app.add_plugins(JoystickNodePlugin);
...
Spawn node with JoystickNode
component:
...
commands
.spawn((
Node {
width: Val::Percent(100.),
height: Val::Percent(100.),
position_type: PositionType::Absolute,
..default()
},
JoystickNodeArea::default(),
))
.insert(Name::new("JoystickRoot"));
...
Then it should be possible to access input from Update
system:
fn act_on_input(joystick: SingleJoystick){
if let Some(input) = joystick.get_movement() {
info!("Input: {}", input);
}
}
Code
In this example there are no external dependencies, just Bevy.
//! A Bevy plugin providing a UI node that behaves like a joystick area,
//! allowing relative cursor movement tracking for game or UI input purposes.
use bevy::{ecs::system::SystemParam, prelude::*, ui::RelativeCursorPosition};
/// A Bevy plugin that registers the `JoystickNodeArea` component and sets up
/// the `update_joystick` system for handling joystick interactions.
pub struct JoystickNodePlugin;
impl Plugin for JoystickNodePlugin {
fn build(&self, app: &mut App) {
app.register_type::<JoystickNodeArea>()
.add_systems(PreUpdate, update_joystick.in_set(bevy::input::InputSystem));
}
}
/// SystemParam helper for accessing `JoystickNodeArea`
#[derive(SystemParam, DerefMut, Deref)]
pub struct SingleJoystick<'w>(pub Option<Single<'w, &'static JoystickNodeArea>>);
impl SingleJoystick<'_> {
/// Calculates the current movement vector from the start position to the current cursor position.
///
/// The Y-axis is inverted to match screen coordinates.
pub fn get_movement(&self) -> Option<Vec2> {
if let Some(Some(axis)) = self.as_ref().map(|j| j.get_movement()) {
if axis.length_squared() > 0.002 {
return axis.normalize_or_zero().into();
}
}
None
}
/// Returns if Joystick area exists and is currently pressed
pub fn is_pressed(&self) -> Option<bool> {
self.as_ref().map(|j| j.is_pressed())
}
}
/// A UI component representing a joystick interaction zone.
/// Tracks the cursor's start and current positions when pressed,
/// enabling directional movement calculation.
#[derive(Default, Debug, Reflect, Component)]
#[require(bevy::ui::FocusPolicy(|| bevy::ui::FocusPolicy::Block), Interaction, RelativeCursorPosition, Node)]
pub struct JoystickNodeArea {
/// The position where the cursor initially pressed down, in normalized coordinates.
pub start_pos: Option<Vec2>,
/// The current position of the cursor, in normalized coordinates.
pub current_pos: Option<Vec2>,
}
impl JoystickNodeArea {
/// Calculates the current movement vector from the start position to the current cursor position.
///
/// The Y-axis is inverted to match screen coordinates.
/// Returns `None` if either position is not yet set.
pub fn get_movement(&self) -> Option<Vec2> {
let start = self.start_pos?;
let current = self.current_pos?;
Some((current - start) * Vec2::new(1.0, -1.0))
}
/// Returns if Joystick area is currently pressed
pub fn is_pressed(&self) -> bool {
self.start_pos.is_some()
}
}
/// System that updates the `JoystickNodeArea` based on user interaction.
///
/// - When the user presses inside the area, records the starting cursor position.
/// - While held, continuously updates the current position.
/// - Resets when the interaction ends.
pub fn update_joystick(
mut q: Query<(&Interaction, &RelativeCursorPosition, &mut JoystickNodeArea)>,
) {
for (interaction, relative_pos, mut joystick) in q.iter_mut() {
if interaction != &Interaction::Pressed {
joystick.current_pos = None;
joystick.start_pos = None;
continue;
}
if joystick.start_pos.is_none() {
joystick.start_pos = relative_pos.normalized;
continue;
}
joystick.current_pos = relative_pos.normalized;
}
}