Version caution
Note created for Bevy in version 0.16 in mind.
I cannot guarantee that it will work in all versions.
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);
}
}