Joystick area node

Bevy version: 0.16

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, picking::pointer::PointerId, prelude::*};

/// 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>()
            .register_type::<JoystickNodeInputStart>()
            .add_observer(add_joystick_observers);
    }
}

/// 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()) {
            return axis.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,
    Pickable,
    Node
)]
#[reflect(Component)]
pub struct JoystickNodeArea {
    /// The current direction of the cursor, in normalized coordinates.
    current_dir: Option<Vec2>,
}

/// Tracks the pointer start position and ID
#[derive(Default, Debug, Reflect, Component)]
#[reflect(Component)]
#[require(JoystickNodeArea)]
pub struct JoystickNodeInputStart {
    /// The position where the cursor initially pressed down, in normalized coordinates.
    pub pos: Vec2,
    /// The ID of the pointer that is currently pressing the joystick.
    pub id: PointerId,
}

impl From<&Pointer<Pressed>> for JoystickNodeInputStart {
    fn from(pointer: &Pointer<Pressed>) -> Self {
        JoystickNodeInputStart {
            pos: pointer.pointer_location.position,
            id: pointer.pointer_id,
        }
    }
}

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.
    pub fn update_dir(&mut self, start_data: &JoystickNodeInputStart, new_pos: Vec2) {
        self.current_dir =
            Some((new_pos - start_data.pos) * Vec2::new(1.0, -1.0).normalize_or_zero());
    }

    /// Returns the current direction of the cursor
    pub fn get_movement(&self) -> Option<Vec2> {
        self.current_dir
    }

    pub fn clear(&mut self) {
        self.current_dir = None;
    }

    /// Returns if Joystick area is currently pressed
    pub fn is_pressed(&self) -> bool {
        self.current_dir.is_some()
    }
}

fn add_joystick_observers(trigger: Trigger<OnAdd, JoystickNodeArea>, mut commands: Commands) {
    commands
        .entity(trigger.target())
        .with_child(Observer::new(on_remove_start_info).with_entity(trigger.target()))
        .with_child(Observer::new(joystick_release).with_entity(trigger.target()))
        .with_child(Observer::new(joystick_update).with_entity(trigger.target()))
        .with_child(Observer::new(joystick_start).with_entity(trigger.target()));
}

fn on_remove_start_info(
    trigger: Trigger<OnRemove, JoystickNodeInputStart>,
    mut q: Query<&mut JoystickNodeArea>,
) {
    if let Ok(mut area) = q.get_mut(trigger.target()) {
        area.clear();
    }
}

fn joystick_start(
    trigger: Trigger<Pointer<Pressed>>,
    mut commands: Commands,
    q: Query<(), (With<JoystickNodeArea>, Without<JoystickNodeInputStart>)>,
) {
    if q.get(trigger.target).is_err() {
        return;
    }
    let input_start: JoystickNodeInputStart = trigger.event().into();
    commands.entity(trigger.target).try_insert(input_start);
}

fn joystick_release(
    trigger: Trigger<Pointer<Released>>,
    mut commands: Commands,
    mut q: Query<&JoystickNodeInputStart>,
) {
    let Ok(start_data) = q.get_mut(trigger.target) else {
        return;
    };
    if start_data.id == trigger.event().pointer_id {
        commands
            .entity(trigger.target)
            .try_remove::<JoystickNodeInputStart>();
    }
}

fn joystick_update(
    trigger: Trigger<Pointer<Move>>,
    mut q: Query<(&mut JoystickNodeArea, &JoystickNodeInputStart)>,
) {
    let Ok((mut joystick, start_data)) = q.get_mut(trigger.target()) else {
        return;
    };
    if start_data.id == trigger.pointer_id {
        joystick.update_dir(&*start_data, trigger.pointer_location.position);
    }
}