Joystick area node

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;
    }
}