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