diff --git a/jme3-android-examples/src/main/java/jme3test/android/TestAndroidSensors.java b/jme3-android-examples/src/main/java/jme3test/android/TestAndroidSensors.java
index b38bba76c5..c6e3bcc8c4 100644
--- a/jme3-android-examples/src/main/java/jme3test/android/TestAndroidSensors.java
+++ b/jme3-android-examples/src/main/java/jme3test/android/TestAndroidSensors.java
@@ -17,6 +17,7 @@
import com.jme3.scene.Mesh;
import com.jme3.scene.shape.Box;
import com.jme3.scene.shape.Line;
+import com.jme3.system.AppSettings;
import com.jme3.texture.Texture;
import com.jme3.util.IntMap;
@@ -79,6 +80,12 @@ public class TestAndroidSensors extends SimpleApplication implements ActionListe
// Make sure to set joystickEventsEnabled = true in MainActivity for Android
+ public static void configureSettings(AppSettings settings) {
+ settings.setUseJoysticks(true);
+ settings.setUseAndroidSensorJoystick(true);
+ settings.setVirtualJoystick(AppSettings.VIRTUAL_JOYSTICK_DISABLED);
+ }
+
private float toDegrees(float rad) {
return rad * FastMath.RAD_TO_DEG;
}
@@ -311,4 +318,4 @@ public void onAnalog(String string, float value, float tpf) {
}
}
-}
\ No newline at end of file
+}
diff --git a/jme3-android/src/main/java/com/jme3/app/AndroidHarnessFragment.java b/jme3-android/src/main/java/com/jme3/app/AndroidHarnessFragment.java
index 9a7a53cd7a..371819034a 100644
--- a/jme3-android/src/main/java/com/jme3/app/AndroidHarnessFragment.java
+++ b/jme3-android/src/main/java/com/jme3/app/AndroidHarnessFragment.java
@@ -42,7 +42,7 @@
import androidx.fragment.app.Fragment;
import com.jme3.audio.AudioRenderer;
import com.jme3.input.JoyInput;
-import com.jme3.input.android.AndroidSensorJoyInput;
+import com.jme3.input.android.AndroidJoyInput;
import com.jme3.system.AppSettings;
import com.jme3.system.SystemListener;
import com.jme3.system.android.JmeAndroidSystem;
@@ -269,8 +269,8 @@ public void gainFocus() {
}
JoyInput joyInput = app.getContext() != null ? app.getContext().getJoyInput() : null;
- if (joyInput instanceof AndroidSensorJoyInput) {
- ((AndroidSensorJoyInput) joyInput).resumeSensors();
+ if (joyInput instanceof AndroidJoyInput) {
+ ((AndroidJoyInput) joyInput).resumeJoysticks();
}
app.gainFocus();
@@ -295,8 +295,8 @@ public void loseFocus() {
}
JoyInput joyInput = app.getContext() != null ? app.getContext().getJoyInput() : null;
- if (joyInput instanceof AndroidSensorJoyInput) {
- ((AndroidSensorJoyInput) joyInput).pauseSensors();
+ if (joyInput instanceof AndroidJoyInput) {
+ ((AndroidJoyInput) joyInput).pauseJoysticks();
}
}
}
diff --git a/jme3-android/src/main/java/com/jme3/input/android/AndroidInputHandler.java b/jme3-android/src/main/java/com/jme3/input/android/AndroidInputHandler.java
index 70c96cbe26..6db3c95c6b 100644
--- a/jme3-android/src/main/java/com/jme3/input/android/AndroidInputHandler.java
+++ b/jme3-android/src/main/java/com/jme3/input/android/AndroidInputHandler.java
@@ -208,6 +208,10 @@ public boolean onTouch(View view, MotionEvent event) {
// logger.log(Level.INFO, "onTouch source: {0}, isTouch: {1}",
// new Object[]{source, isTouch});
+ if (isTouch && joyInput != null && joyInput.onTouch(event)) {
+ return true;
+ }
+
if (isTouch && touchInput != null) {
// send the event to the touch processor
consumed = touchInput.onTouch(event);
@@ -234,6 +238,10 @@ public boolean onKey(View view, int keyCode, KeyEvent event) {
// logger.log(Level.INFO, "onKey source: {0}, isTouch: {1}",
// new Object[]{source, isTouch});
+ if ((source & InputDevice.SOURCE_KEYBOARD) == InputDevice.SOURCE_KEYBOARD && joyInput != null) {
+ joyInput.onKeyboardInput();
+ }
+
if (touchInput != null) {
consumed = touchInput.onKey(event);
}
diff --git a/jme3-android/src/main/java/com/jme3/input/android/AndroidInputHandler14.java b/jme3-android/src/main/java/com/jme3/input/android/AndroidInputHandler14.java
index bfa07941bb..355d3c55fa 100644
--- a/jme3-android/src/main/java/com/jme3/input/android/AndroidInputHandler14.java
+++ b/jme3-android/src/main/java/com/jme3/input/android/AndroidInputHandler14.java
@@ -179,6 +179,10 @@ public boolean onKey(View view, int keyCode, KeyEvent event) {
boolean isUnknown =
(source & android.view.InputDevice.SOURCE_UNKNOWN) == android.view.InputDevice.SOURCE_UNKNOWN;
+ if ((source & InputDevice.SOURCE_KEYBOARD) == InputDevice.SOURCE_KEYBOARD && joyInput != null) {
+ joyInput.onKeyboardInput();
+ }
+
if (touchInput != null && (isTouch || (isUnknown && this.touchInput.isSimulateKeyboard()))) {
// logger.log(Level.INFO, "onKey source: {0}, isTouch: {1}",
// new Object[]{source, isTouch});
diff --git a/jme3-android/src/main/java/com/jme3/input/android/AndroidJoyInput.java b/jme3-android/src/main/java/com/jme3/input/android/AndroidJoyInput.java
index 633499eb77..ad6c1388ba 100644
--- a/jme3-android/src/main/java/com/jme3/input/android/AndroidJoyInput.java
+++ b/jme3-android/src/main/java/com/jme3/input/android/AndroidJoyInput.java
@@ -32,6 +32,7 @@
package com.jme3.input.android;
import android.opengl.GLSurfaceView;
+import android.view.MotionEvent;
import com.jme3.input.InputManager;
import com.jme3.input.JoyInput;
import com.jme3.input.Joystick;
@@ -39,6 +40,7 @@
import com.jme3.input.event.InputEvent;
import com.jme3.input.event.JoyAxisEvent;
import com.jme3.input.event.JoyButtonEvent;
+import com.jme3.input.virtual.VirtualJoystick;
import com.jme3.system.AppSettings;
import com.jme3.system.JmeSystem;
import java.util.ArrayList;
@@ -48,9 +50,8 @@
import java.util.logging.Logger;
/**
- * Main class that manages various joystick devices. Joysticks can be many forms
- * including a simulated joystick to communicate the device orientation as well
- * as physical gamepads.
+ * Main class that manages joystick devices. Joysticks can be physical gamepads,
+ * the on-screen virtual joystick, or an explicitly-enabled sensor joystick.
* This class manages all the joysticks and feeds the inputs from each back
* to jME's InputManager.
*
@@ -67,7 +68,7 @@
*
* MainActivity needs the following line to enable Joysticks on Android platforms
* joystickEventsEnabled = true;
- * This is done to allow for battery conservation when sensor data or gamepads
+ * This is done to allow for battery conservation when sensor data or joysticks
* are not required by the application.
*
* {@code
@@ -79,7 +80,6 @@
*/
public class AndroidJoyInput implements JoyInput {
private static final Logger logger = Logger.getLogger(AndroidJoyInput.class.getName());
- public static boolean disableSensors = false;
protected AndroidInputHandler inputHandler;
protected List joystickList = new ArrayList<>();
@@ -92,13 +92,21 @@ public class AndroidJoyInput implements JoyInput {
private ConcurrentLinkedQueue eventQueue = new ConcurrentLinkedQueue<>();
private AndroidSensorJoyInput sensorJoyInput;
private boolean onDeviceJoystickRumble = false;
+ private String virtualJoystickMode = AppSettings.VIRTUAL_JOYSTICK_AUTO;
+ private String virtualJoystickDefaultLayout = AppSettings.VIRTUAL_JOYSTICK_LAYOUT_DYNAMIC;
+ private boolean useJoysticks = true;
+ private boolean useAndroidSensorJoystick = false;
+ private boolean physicalJoystickAvailable = false;
+ private boolean keyboardSuppressedAutoJoystick = false;
+ private volatile VirtualJoystick virtualJoystick;
+ private GLSurfaceView view;
public AndroidJoyInput(AndroidInputHandler inputHandler) {
this.inputHandler = inputHandler;
- sensorJoyInput = new AndroidSensorJoyInput(this);
}
public void setView(GLSurfaceView view) {
+ this.view = view;
if (sensorJoyInput != null) {
sensorJoyInput.setView(view);
}
@@ -106,6 +114,10 @@ public void setView(GLSurfaceView view) {
public void loadSettings(AppSettings settings) {
onDeviceJoystickRumble = settings.isOnDeviceJoystickRumble();
+ virtualJoystickMode = settings.getVirtualJoystickMode();
+ virtualJoystickDefaultLayout = settings.getVirtualJoystickDefaultLayout();
+ useJoysticks = settings.useJoysticks();
+ useAndroidSensorJoystick = settings.useAndroidSensorJoystick();
}
boolean isOnDeviceJoystickRumble() {
@@ -127,6 +139,9 @@ public void pauseJoysticks() {
if (onDeviceJoystickRumble) {
JmeSystem.stopRumble();
}
+ if (virtualJoystick != null) {
+ virtualJoystick.onPointerCancel(0L);
+ }
}
@@ -138,7 +153,6 @@ public void resumeJoysticks() {
if (sensorJoyInput != null) {
sensorJoyInput.resumeSensors();
}
-
}
@Override
@@ -154,12 +168,11 @@ public boolean isInitialized() {
@Override
public void destroy() {
initialized = false;
-
if (sensorJoyInput != null) {
sensorJoyInput.destroy();
+ sensorJoyInput = null;
}
-
- setView(null);
+ view = null;
}
@Override
@@ -191,12 +204,72 @@ public Joystick[] loadJoysticks(InputManager inputManager) {
if (logger.isLoggable(Level.INFO)) {
logger.log(Level.INFO, "loading joysticks for {0}", this.getClass().getName());
}
- if (!disableSensors) {
+ joystickList.clear();
+ if (useJoysticks && useAndroidSensorJoystick) {
+ if (sensorJoyInput == null) {
+ sensorJoyInput = new AndroidSensorJoyInput(this);
+ sensorJoyInput.setView(view);
+ }
joystickList.add(sensorJoyInput.loadJoystick(joystickList.size(), inputManager));
}
+ physicalJoystickAvailable = false;
+ if (shouldCreateVirtualJoystick()) {
+ virtualJoystick = new VirtualJoystick(inputManager, this, joystickList.size());
+ virtualJoystick.setLayout(VirtualJoystick.createLayout(virtualJoystickDefaultLayout));
+ virtualJoystick.setEnabled(false);
+ updateVirtualJoystickAutoVisibility();
+ joystickList.add(virtualJoystick);
+ } else {
+ virtualJoystick = null;
+ }
return joystickList.toArray( new Joystick[joystickList.size()] );
}
+ public boolean onTouch(MotionEvent event) {
+ VirtualJoystick joystick = virtualJoystick;
+ if (joystick == null || inputHandler.getView() == null) {
+ return false;
+ }
+
+ boolean consumed = false;
+ int action = event.getAction() & MotionEvent.ACTION_MASK;
+ int pointerIndex = (event.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK)
+ >> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
+ long time = event.getEventTime();
+
+ switch (action) {
+ case MotionEvent.ACTION_POINTER_DOWN:
+ case MotionEvent.ACTION_DOWN:
+ consumed = joystick.onPointerDown(event.getPointerId(pointerIndex),
+ toJmeX(event.getX(pointerIndex)), toJmeY(event.getY(pointerIndex)), time);
+ break;
+ case MotionEvent.ACTION_POINTER_UP:
+ case MotionEvent.ACTION_UP:
+ consumed = joystick.onPointerUp(event.getPointerId(pointerIndex),
+ toJmeX(event.getX(pointerIndex)), toJmeY(event.getY(pointerIndex)), time);
+ break;
+ case MotionEvent.ACTION_CANCEL:
+ consumed = joystick.onPointerCancel(time);
+ break;
+ case MotionEvent.ACTION_MOVE:
+ for (int i = 0; i < event.getPointerCount(); i++) {
+ consumed = joystick.onPointerMove(event.getPointerId(i),
+ toJmeX(event.getX(i)), toJmeY(event.getY(i)), time) || consumed;
+ }
+ break;
+ default:
+ break;
+ }
+ return consumed;
+ }
+
+ public void onKeyboardInput() {
+ if (AppSettings.VIRTUAL_JOYSTICK_AUTO.equals(virtualJoystickMode)) {
+ keyboardSuppressedAutoJoystick = true;
+ updateVirtualJoystickAutoVisibility();
+ }
+ }
+
@Override
public void update() {
if (sensorJoyInput != null) {
@@ -214,7 +287,43 @@ public void update() {
}
}
}
+ if (virtualJoystick != null) {
+ updateVirtualJoystickAutoVisibility();
+ virtualJoystick.dispatchEvents(listener);
+ }
+
+ }
+
+ private float toJmeX(float x) {
+ return inputHandler.touchInput.getJmeX(x);
+ }
+
+ private float toJmeY(float y) {
+ return inputHandler.touchInput.invertY(inputHandler.touchInput.getJmeY(y));
+ }
+
+ protected void setPhysicalJoystickAvailable(boolean available) {
+ physicalJoystickAvailable = available;
+ updateVirtualJoystickAutoVisibility();
+ }
+ private boolean shouldCreateVirtualJoystick() {
+ return useJoysticks && !AppSettings.VIRTUAL_JOYSTICK_DISABLED.equals(virtualJoystickMode);
+ }
+
+ private void updateVirtualJoystickAutoVisibility() {
+ if (virtualJoystick == null) {
+ return;
+ }
+ boolean wasEnabled = virtualJoystick.isEnabled();
+ boolean active = AppSettings.VIRTUAL_JOYSTICK_ENABLED.equals(virtualJoystickMode)
+ || (AppSettings.VIRTUAL_JOYSTICK_AUTO.equals(virtualJoystickMode)
+ && !physicalJoystickAvailable
+ && !keyboardSuppressedAutoJoystick
+ && virtualJoystick.hasInputBindings());
+ if (wasEnabled != active) {
+ virtualJoystick.setEnabled(active);
+ }
}
}
diff --git a/jme3-android/src/main/java/com/jme3/input/android/AndroidJoyInput14.java b/jme3-android/src/main/java/com/jme3/input/android/AndroidJoyInput14.java
index ffdcaa590e..f4a4625ed1 100644
--- a/jme3-android/src/main/java/com/jme3/input/android/AndroidJoyInput14.java
+++ b/jme3-android/src/main/java/com/jme3/input/android/AndroidJoyInput14.java
@@ -90,10 +90,12 @@ public void destroy() {
@Override
public Joystick[] loadJoysticks(InputManager inputManager) {
- // load the simulated joystick for device orientation
+ // load virtual joystick if enabled
super.loadJoysticks(inputManager);
// load physical gamepads/joysticks
+ int beforePhysicalJoysticks = joystickList.size();
joystickList.addAll(joystickJoyInput.loadJoysticks(joystickList.size(), inputManager));
+ setPhysicalJoystickAvailable(joystickList.size() > beforePhysicalJoysticks);
// return the list of joysticks back to InputManager
return joystickList.toArray( new Joystick[joystickList.size()] );
}
diff --git a/jme3-core/src/main/java/com/jme3/app/SimpleApplication.java b/jme3-core/src/main/java/com/jme3/app/SimpleApplication.java
index 7850b4b218..ced517be5f 100644
--- a/jme3-core/src/main/java/com/jme3/app/SimpleApplication.java
+++ b/jme3-core/src/main/java/com/jme3/app/SimpleApplication.java
@@ -37,10 +37,13 @@
import com.jme3.font.BitmapFont;
import com.jme3.font.BitmapText;
import com.jme3.input.FlyByCamera;
+import com.jme3.input.Joystick;
import com.jme3.input.KeyInput;
import com.jme3.input.controls.ActionListener;
import com.jme3.input.controls.KeyTrigger;
+import com.jme3.input.virtual.VirtualJoystick;
import com.jme3.profile.AppStep;
+import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.queue.RenderQueue.Bucket;
import com.jme3.scene.Node;
@@ -336,6 +339,7 @@ public void update() {
if (prof != null) {
prof.appStep(AppStep.SpatialUpdate);
}
+ updateVirtualJoystickVisuals(tpf);
rootNode.updateLogicalState(tpf);
guiNode.updateLogicalState(tpf);
@@ -410,4 +414,22 @@ public void simpleUpdate(float tpf) {
public void simpleRender(RenderManager rm) {
// Default empty implementation; subclasses can override
}
+
+ private void updateVirtualJoystickVisuals(float tpf) {
+ if (inputManager == null || assetManager == null || guiViewPort == null) {
+ return;
+ }
+ Joystick[] joysticks = inputManager.getJoysticks();
+ if (joysticks == null) {
+ return;
+ }
+ Camera guiCamera = guiViewPort.getCamera();
+ int width = guiCamera != null ? guiCamera.getWidth() : settings.getWidth();
+ int height = guiCamera != null ? guiCamera.getHeight() : settings.getHeight();
+ for (Joystick joystick : joysticks) {
+ if (joystick instanceof VirtualJoystick) {
+ ((VirtualJoystick) joystick).updateVisuals(guiNode, assetManager, width, height, tpf);
+ }
+ }
+ }
}
diff --git a/jme3-core/src/main/java/com/jme3/input/ChaseCamera.java b/jme3-core/src/main/java/com/jme3/input/ChaseCamera.java
index fe494801b3..3ff89e8166 100644
--- a/jme3-core/src/main/java/com/jme3/input/ChaseCamera.java
+++ b/jme3-core/src/main/java/com/jme3/input/ChaseCamera.java
@@ -50,7 +50,7 @@
* A camera that follows a spatial and can turn around it by dragging the mouse
* @author nehon
*/
-public class ChaseCamera implements ActionListener, AnalogListener, Control, JmeCloneable {
+public class ChaseCamera implements ActionListener, AnalogListener, Control, JmeCloneable, JoystickConnectionListener {
protected Spatial target = null;
protected float minVerticalRotation = 0.00f;
@@ -134,8 +134,15 @@ public class ChaseCamera implements ActionListener, AnalogListener, Control, Jme
/**
* @deprecated use {@link CameraInput#CHASECAM_TOGGLEROTATE}
*/
- @Deprecated
- public static final String ChaseCamToggleRotate = "ChaseCamToggleRotate";
+ @Deprecated
+ public static final String ChaseCamToggleRotate = "ChaseCamToggleRotate";
+
+ private static final String CHASECAM_JOYDOWN = "ChaseCamJoyDown";
+ private static final String CHASECAM_JOYUP = "ChaseCamJoyUp";
+ private static final String CHASECAM_JOYMOVELEFT = "ChaseCamJoyMoveLeft";
+ private static final String CHASECAM_JOYMOVERIGHT = "ChaseCamJoyMoveRight";
+ private static final String CHASECAM_JOYZOOMIN = "ChaseCamJoyZoomIn";
+ private static final String CHASECAM_JOYZOOMOUT = "ChaseCamJoyZoomOut";
protected boolean zoomin;
protected boolean hideCursorOnRotate = true;
@@ -219,14 +226,34 @@ public void onAnalog(String name, float value, float tpf) {
distanceLerpFactor = 0;
}
zoomin = true;
- } else if (name.equals(CameraInput.CHASECAM_ZOOMOUT)) {
- zoomCamera(+value);
- if (zoomin == true) {
- distanceLerpFactor = 0;
- }
- zoomin = false;
- }
- }
+ } else if (name.equals(CameraInput.CHASECAM_ZOOMOUT)) {
+ zoomCamera(+value);
+ if (zoomin == true) {
+ distanceLerpFactor = 0;
+ }
+ zoomin = false;
+ } else if (name.equals(CHASECAM_JOYMOVELEFT)) {
+ rotateCamera(-value, true);
+ } else if (name.equals(CHASECAM_JOYMOVERIGHT)) {
+ rotateCamera(value, true);
+ } else if (name.equals(CHASECAM_JOYUP)) {
+ vRotateCamera(value, true);
+ } else if (name.equals(CHASECAM_JOYDOWN)) {
+ vRotateCamera(-value, true);
+ } else if (name.equals(CHASECAM_JOYZOOMIN)) {
+ zoomCamera(-value);
+ if (zoomin == false) {
+ distanceLerpFactor = 0;
+ }
+ zoomin = true;
+ } else if (name.equals(CHASECAM_JOYZOOMOUT)) {
+ zoomCamera(+value);
+ if (zoomin == true) {
+ distanceLerpFactor = 0;
+ }
+ zoomin = false;
+ }
+ }
/**
* Registers inputs with the input manager
@@ -237,10 +264,16 @@ public final void registerWithInput(InputManager inputManager) {
String[] inputs = {CameraInput.CHASECAM_TOGGLEROTATE,
CameraInput.CHASECAM_DOWN,
CameraInput.CHASECAM_UP,
- CameraInput.CHASECAM_MOVELEFT,
- CameraInput.CHASECAM_MOVERIGHT,
- CameraInput.CHASECAM_ZOOMIN,
- CameraInput.CHASECAM_ZOOMOUT};
+ CameraInput.CHASECAM_MOVELEFT,
+ CameraInput.CHASECAM_MOVERIGHT,
+ CameraInput.CHASECAM_ZOOMIN,
+ CameraInput.CHASECAM_ZOOMOUT,
+ CHASECAM_JOYDOWN,
+ CHASECAM_JOYUP,
+ CHASECAM_JOYMOVELEFT,
+ CHASECAM_JOYMOVERIGHT,
+ CHASECAM_JOYZOOMIN,
+ CHASECAM_JOYZOOMOUT};
this.inputManager = inputManager;
if (!invertYaxis) {
@@ -271,11 +304,66 @@ public final void registerWithInput(InputManager inputManager) {
}
inputManager.addMapping(CameraInput.CHASECAM_TOGGLEROTATE,
new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
- inputManager.addMapping(CameraInput.CHASECAM_TOGGLEROTATE,
- new MouseButtonTrigger(MouseInput.BUTTON_RIGHT));
-
- inputManager.addListener(this, inputs);
- }
+ inputManager.addMapping(CameraInput.CHASECAM_TOGGLEROTATE,
+ new MouseButtonTrigger(MouseInput.BUTTON_RIGHT));
+
+ Joystick[] joysticks = inputManager.getJoysticks();
+ if (joysticks != null) {
+ for (Joystick joystick : joysticks) {
+ mapJoystick(joystick);
+ }
+ }
+
+ inputManager.addListener(this, inputs);
+ inputManager.addJoystickConnectionListener(this);
+ }
+
+ /**
+ * Configures joystick input mappings for the chase camera.
+ *
+ * @param joystick joystick to map
+ */
+ protected void mapJoystick(Joystick joystick) {
+ JoystickAxis xAxis = joystick.getAxis(JoystickAxis.AXIS_XBOX_RIGHT_THUMB_STICK_X);
+ JoystickAxis yAxis = joystick.getAxis(JoystickAxis.AXIS_XBOX_RIGHT_THUMB_STICK_Y);
+
+ if (xAxis == null) {
+ xAxis = joystick.getXAxis();
+ }
+ if (yAxis == null) {
+ yAxis = joystick.getYAxis();
+ }
+
+ if (xAxis != null) {
+ xAxis.assignAxis(CHASECAM_JOYMOVERIGHT, CHASECAM_JOYMOVELEFT);
+ }
+ if (yAxis != null) {
+ yAxis.assignAxis(CHASECAM_JOYDOWN, CHASECAM_JOYUP);
+ }
+
+ JoystickAxis leftTrigger = joystick.getAxis(JoystickAxis.AXIS_XBOX_LEFT_TRIGGER);
+ if (leftTrigger != null) {
+ inputManager.addMapping(CHASECAM_JOYZOOMOUT,
+ new JoyAxisTrigger(joystick.getJoyId(), leftTrigger.getAxisId(), false));
+ }
+
+ JoystickAxis rightTrigger = joystick.getAxis(JoystickAxis.AXIS_XBOX_RIGHT_TRIGGER);
+ if (rightTrigger != null) {
+ inputManager.addMapping(CHASECAM_JOYZOOMIN,
+ new JoyAxisTrigger(joystick.getJoyId(), rightTrigger.getAxisId(), false));
+ }
+ }
+
+ @Override
+ public void onConnected(Joystick joystick) {
+ if (inputManager != null) {
+ mapJoystick(joystick);
+ }
+ }
+
+ @Override
+ public void onDisconnected(Joystick joystick) {
+ }
/**
* Cleans up the input mappings from the input manager.
@@ -287,11 +375,18 @@ public void cleanupWithInput(InputManager mgr) {
mgr.deleteMapping(CameraInput.CHASECAM_DOWN);
mgr.deleteMapping(CameraInput.CHASECAM_UP);
mgr.deleteMapping(CameraInput.CHASECAM_MOVELEFT);
- mgr.deleteMapping(CameraInput.CHASECAM_MOVERIGHT);
- mgr.deleteMapping(CameraInput.CHASECAM_ZOOMIN);
- mgr.deleteMapping(CameraInput.CHASECAM_ZOOMOUT);
- mgr.removeListener(this);
- }
+ mgr.deleteMapping(CameraInput.CHASECAM_MOVERIGHT);
+ mgr.deleteMapping(CameraInput.CHASECAM_ZOOMIN);
+ mgr.deleteMapping(CameraInput.CHASECAM_ZOOMOUT);
+ mgr.deleteMapping(CHASECAM_JOYDOWN);
+ mgr.deleteMapping(CHASECAM_JOYUP);
+ mgr.deleteMapping(CHASECAM_JOYMOVELEFT);
+ mgr.deleteMapping(CHASECAM_JOYMOVERIGHT);
+ mgr.deleteMapping(CHASECAM_JOYZOOMIN);
+ mgr.deleteMapping(CHASECAM_JOYZOOMOUT);
+ mgr.removeListener(this);
+ mgr.removeJoystickConnectionListener(this);
+ }
/**
* Sets custom triggers for toggling the rotation of the cam
@@ -340,12 +435,16 @@ protected void computePosition() {
pos.addLocal(target.getWorldTranslation());
}
- //rotate the camera around the target on the horizontal plane
- protected void rotateCamera(float value) {
- if (!canRotate || !enabled) {
- return;
- }
- rotating = true;
+ //rotate the camera around the target on the horizontal plane
+ protected void rotateCamera(float value) {
+ rotateCamera(value, false);
+ }
+
+ protected void rotateCamera(float value, boolean forceRotate) {
+ if ((!forceRotate && !canRotate) || !enabled) {
+ return;
+ }
+ rotating = true;
targetRotation += value * rotationSpeed;
@@ -372,12 +471,16 @@ protected void zoomCamera(float value) {
}
}
- //rotate the camera around the target on the vertical plane
- protected void vRotateCamera(float value) {
- if (!canRotate || !enabled) {
- return;
- }
- vRotating = true;
+ //rotate the camera around the target on the vertical plane
+ protected void vRotateCamera(float value) {
+ vRotateCamera(value, false);
+ }
+
+ protected void vRotateCamera(float value, boolean forceRotate) {
+ if ((!forceRotate && !canRotate) || !enabled) {
+ return;
+ }
+ vRotating = true;
float lastGoodRot = targetVRotation;
targetVRotation += value * rotationSpeed;
if (targetVRotation > maxVerticalRotation) {
diff --git a/jme3-core/src/main/java/com/jme3/input/FlyByCamera.java b/jme3-core/src/main/java/com/jme3/input/FlyByCamera.java
index 1a39ee9820..f5d1076664 100644
--- a/jme3-core/src/main/java/com/jme3/input/FlyByCamera.java
+++ b/jme3-core/src/main/java/com/jme3/input/FlyByCamera.java
@@ -54,7 +54,12 @@
* - WASD keys for moving forward/backward and strafing
* - QZ keys raise or lower the camera
*/
-public class FlyByCamera implements AnalogListener, ActionListener {
+public class FlyByCamera implements AnalogListener, ActionListener, JoystickConnectionListener {
+
+ private static final String FLYCAM_JOYSTICK_LEFT = "FLYCAM_JoystickLeft";
+ private static final String FLYCAM_JOYSTICK_RIGHT = "FLYCAM_JoystickRight";
+ private static final String FLYCAM_JOYSTICK_UP = "FLYCAM_JoystickUp";
+ private static final String FLYCAM_JOYSTICK_DOWN = "FLYCAM_JoystickDown";
private static final String[] mappings = new String[]{
CameraInput.FLYCAM_LEFT,
@@ -74,7 +79,12 @@ public class FlyByCamera implements AnalogListener, ActionListener {
CameraInput.FLYCAM_RISE,
CameraInput.FLYCAM_LOWER,
- CameraInput.FLYCAM_INVERTY
+ CameraInput.FLYCAM_INVERTY,
+
+ FLYCAM_JOYSTICK_LEFT,
+ FLYCAM_JOYSTICK_RIGHT,
+ FLYCAM_JOYSTICK_UP,
+ FLYCAM_JOYSTICK_DOWN
};
/**
* camera controlled by this controller (not null)
@@ -105,9 +115,10 @@ public class FlyByCamera implements AnalogListener, ActionListener {
* drag-to-rotate mode flag
*/
protected boolean dragToRotate = false;
- protected boolean canRotate = false;
- protected boolean invertY = false;
- protected InputManager inputManager;
+ protected boolean canRotate = false;
+ protected boolean invertY = false;
+ protected InputManager inputManager;
+ private boolean inputMappingsRegistered;
// Reusable temporary objects to reduce allocations during updates
private final Matrix3f tempMat = new Matrix3f();
@@ -203,14 +214,21 @@ public float getZoomSpeed() {
*
* @param enable true to enable, false to disable
*/
- public void setEnabled(boolean enable) {
- if (enabled && !enable) {
- if (inputManager != null && (!dragToRotate || (dragToRotate && canRotate))) {
- inputManager.setCursorVisible(true);
- }
- }
- enabled = enable;
- }
+ public void setEnabled(boolean enable) {
+ if (enabled == enable) {
+ return;
+ }
+ if (enabled && !enable) {
+ if (inputManager != null && (!dragToRotate || (dragToRotate && canRotate))) {
+ inputManager.setCursorVisible(true);
+ }
+ unregisterInputMappings();
+ }
+ enabled = enable;
+ if (enabled) {
+ registerInputMappings();
+ }
+ }
/**
* Checks whether this camera controller is currently enabled.
@@ -243,12 +261,12 @@ public boolean isDragToRotate() {
*
* @param dragToRotate true to enable, false to disable
*/
- public void setDragToRotate(boolean dragToRotate) {
- this.dragToRotate = dragToRotate;
- if (inputManager != null) {
- inputManager.setCursorVisible(dragToRotate);
- }
- }
+ public void setDragToRotate(boolean dragToRotate) {
+ this.dragToRotate = dragToRotate;
+ if (inputManager != null) {
+ inputManager.setCursorVisible(dragToRotate || !isEnabled());
+ }
+ }
/**
* Registers this controller to receive input events from the specified
@@ -257,12 +275,23 @@ public void setDragToRotate(boolean dragToRotate) {
*
* @param inputManager The InputManager instance to register with (must not be null).
*/
- public void registerWithInput(InputManager inputManager) {
- this.inputManager = inputManager;
-
- // Mouse and Keyboard Mappings for Rotation
- inputManager.addMapping(CameraInput.FLYCAM_LEFT, new MouseAxisTrigger(MouseInput.AXIS_X, true),
- new KeyTrigger(KeyInput.KEY_LEFT));
+ public void registerWithInput(InputManager inputManager) {
+ this.inputManager = inputManager;
+ if (enabled) {
+ registerInputMappings();
+ } else {
+ inputManager.setCursorVisible(true);
+ }
+ inputManager.addJoystickConnectionListener(this);
+ }
+
+ private void registerInputMappings() {
+ if (inputManager == null || inputMappingsRegistered) {
+ return;
+ }
+ // Mouse and Keyboard Mappings for Rotation
+ inputManager.addMapping(CameraInput.FLYCAM_LEFT, new MouseAxisTrigger(MouseInput.AXIS_X, true),
+ new KeyTrigger(KeyInput.KEY_LEFT));
inputManager.addMapping(CameraInput.FLYCAM_RIGHT, new MouseAxisTrigger(MouseInput.AXIS_X, false),
new KeyTrigger(KeyInput.KEY_RIGHT));
@@ -291,11 +320,12 @@ public void registerWithInput(InputManager inputManager) {
Joystick[] joysticks = inputManager.getJoysticks();
if (joysticks != null && joysticks.length > 0) {
- for (Joystick j : joysticks) {
- mapJoystick(j);
- }
- }
- }
+ for (Joystick j : joysticks) {
+ mapJoystick(j);
+ }
+ }
+ inputMappingsRegistered = true;
+ }
/**
* Configures joystick input mappings for the camera controller. This method
@@ -304,58 +334,63 @@ public void registerWithInput(InputManager inputManager) {
* @param joystick The {@link Joystick} to map (not null).
*/
protected void mapJoystick(Joystick joystick) {
- // Map it differently if there are Z axis
- if (joystick.getAxis(JoystickAxis.Z_ROTATION) != null
- && joystick.getAxis(JoystickAxis.Z_AXIS) != null) {
-
- // Make the left stick move
- joystick.getXAxis().assignAxis(CameraInput.FLYCAM_STRAFERIGHT, CameraInput.FLYCAM_STRAFELEFT);
- joystick.getYAxis().assignAxis(CameraInput.FLYCAM_BACKWARD, CameraInput.FLYCAM_FORWARD);
-
- // And the right stick control the camera
- joystick.getAxis(JoystickAxis.Z_ROTATION)
- .assignAxis(CameraInput.FLYCAM_DOWN, CameraInput.FLYCAM_UP);
- joystick.getAxis(JoystickAxis.Z_AXIS)
- .assignAxis(CameraInput.FLYCAM_RIGHT, CameraInput.FLYCAM_LEFT);
-
- // And let the dpad be up and down
- joystick.getPovYAxis().assignAxis(CameraInput.FLYCAM_RISE, CameraInput.FLYCAM_LOWER);
-
- if (joystick.getButton("Button 8") != null) {
- // Let the standard select button be the y invert toggle
- joystick.getButton("Button 8").assignButton(CameraInput.FLYCAM_INVERTY);
- }
+ JoystickAxis xAxis = joystick.getXAxis();
+ JoystickAxis yAxis = joystick.getYAxis();
+ JoystickAxis rightXAxis = joystick.getAxis(JoystickAxis.AXIS_XBOX_RIGHT_THUMB_STICK_X);
+ JoystickAxis rightYAxis = joystick.getAxis(JoystickAxis.AXIS_XBOX_RIGHT_THUMB_STICK_Y);
- } else {
- joystick.getPovXAxis().assignAxis(CameraInput.FLYCAM_STRAFERIGHT, CameraInput.FLYCAM_STRAFELEFT);
- joystick.getPovYAxis().assignAxis(CameraInput.FLYCAM_FORWARD, CameraInput.FLYCAM_BACKWARD);
- joystick.getXAxis().assignAxis(CameraInput.FLYCAM_RIGHT, CameraInput.FLYCAM_LEFT);
- joystick.getYAxis().assignAxis(CameraInput.FLYCAM_DOWN, CameraInput.FLYCAM_UP);
+ if (xAxis != null) {
+ xAxis.assignAxis(CameraInput.FLYCAM_STRAFERIGHT, CameraInput.FLYCAM_STRAFELEFT);
}
- }
-
- /**
- * Unregisters this controller from its currently associated {@link InputManager}.
- */
- public void unregisterInput() {
- if (inputManager == null) {
- return;
+ if (yAxis != null) {
+ yAxis.assignAxis(CameraInput.FLYCAM_BACKWARD, CameraInput.FLYCAM_FORWARD);
}
-
- for (String s : mappings) {
- if (inputManager.hasMapping(s)) {
- inputManager.deleteMapping(s);
- }
+ if (rightXAxis != null && rightYAxis != null && rightXAxis != xAxis && rightYAxis != yAxis) {
+ rightYAxis.assignAxis(FLYCAM_JOYSTICK_DOWN, FLYCAM_JOYSTICK_UP);
+ rightXAxis.assignAxis(FLYCAM_JOYSTICK_RIGHT, FLYCAM_JOYSTICK_LEFT);
}
- inputManager.removeListener(this);
- inputManager.setCursorVisible(!dragToRotate);
+ }
+
+ @Override
+ public void onConnected(Joystick joystick) {
+ if (inputManager != null && inputMappingsRegistered) {
+ mapJoystick(joystick);
+ }
+ }
- // Joysticks cannot be "unassigned" in the same way, but mappings are removed with listener.
- // Joystick-specific mapping might persist but won't trigger this listener.
- inputManager = null; // Clear reference
+ @Override
+ public void onDisconnected(Joystick joystick) {
}
+ /**
+ * Unregisters this controller from its currently associated {@link InputManager}.
+ */
+ public void unregisterInput() {
+ if (inputManager == null) {
+ return;
+ }
+
+ unregisterInputMappings();
+ inputManager.removeJoystickConnectionListener(this);
+ inputManager.setCursorVisible(!dragToRotate);
+ inputManager = null; // Clear reference
+ }
+
+ private void unregisterInputMappings() {
+ if (inputManager == null || !inputMappingsRegistered) {
+ return;
+ }
+ for (String s : mappings) {
+ if (inputManager.hasMapping(s)) {
+ inputManager.deleteMapping(s);
+ }
+ }
+
+ inputManager.removeListener(this);
+ inputMappingsRegistered = false;
+ }
+
/**
* Rotates the camera by the specified amount around the given axis.
*
@@ -363,7 +398,11 @@ public void unregisterInput() {
* @param axis The axis around which to rotate (a unit vector, unaffected).
*/
protected void rotateCamera(float value, Vector3f axis) {
- if (dragToRotate && !canRotate) {
+ rotateCamera(value, axis, false);
+ }
+
+ private void rotateCamera(float value, Vector3f axis, boolean forceRotate) {
+ if (!forceRotate && dragToRotate && !canRotate) {
return; // In drag-to-rotate mode, only rotate if canRotate is true.
}
@@ -479,6 +518,14 @@ public void onAnalog(String name, float value, float tpf) {
rotateCamera(-value * (invertY ? -1 : 1), cam.getLeft(tempLeft));
} else if (name.equals(CameraInput.FLYCAM_DOWN)) {
rotateCamera(value * (invertY ? -1 : 1), cam.getLeft(tempLeft));
+ } else if (name.equals(FLYCAM_JOYSTICK_LEFT)) {
+ rotateCamera(value, initialUpVec, true);
+ } else if (name.equals(FLYCAM_JOYSTICK_RIGHT)) {
+ rotateCamera(-value, initialUpVec, true);
+ } else if (name.equals(FLYCAM_JOYSTICK_UP)) {
+ rotateCamera(-value * (invertY ? -1 : 1), cam.getLeft(tempLeft), true);
+ } else if (name.equals(FLYCAM_JOYSTICK_DOWN)) {
+ rotateCamera(value * (invertY ? -1 : 1), cam.getLeft(tempLeft), true);
} else if (name.equals(CameraInput.FLYCAM_FORWARD)) {
moveCamera(value, false);
} else if (name.equals(CameraInput.FLYCAM_BACKWARD)) {
diff --git a/jme3-core/src/main/java/com/jme3/input/InputManager.java b/jme3-core/src/main/java/com/jme3/input/InputManager.java
index b45433d1d9..7928f33ffc 100644
--- a/jme3-core/src/main/java/com/jme3/input/InputManager.java
+++ b/jme3-core/src/main/java/com/jme3/input/InputManager.java
@@ -606,6 +606,20 @@ public boolean hasMapping(String mappingName) {
return mappings.containsKey(mappingName);
}
+
+
+ /**
+ * Returns true if at least one mapping is registered for the specified
+ * trigger hash.
+ *
+ * @param triggerHash hash returned by {@link Trigger#triggerHashCode()}
+ * @return true if the trigger hash is registered to at least one mapping
+ */
+ public boolean hasTriggerMapping(int triggerHash) {
+ ArrayList maps = bindings.get(triggerHash);
+ return maps != null && !maps.isEmpty();
+ }
+
/**
* Deletes a mapping from receiving trigger events.
*
diff --git a/jme3-core/src/main/java/com/jme3/input/virtual/VirtualJoystick.java b/jme3-core/src/main/java/com/jme3/input/virtual/VirtualJoystick.java
new file mode 100644
index 0000000000..e364ef9bd7
--- /dev/null
+++ b/jme3-core/src/main/java/com/jme3/input/virtual/VirtualJoystick.java
@@ -0,0 +1,686 @@
+/*
+ * Copyright (c) 2009-2026 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.jme3.input.virtual;
+
+import com.jme3.asset.AssetManager;
+import com.jme3.font.BitmapFont;
+import com.jme3.font.BitmapText;
+import com.jme3.input.AbstractJoystick;
+import com.jme3.input.DefaultJoystickAxis;
+import com.jme3.input.DefaultJoystickButton;
+import com.jme3.input.InputManager;
+import com.jme3.input.JoyInput;
+import com.jme3.input.JoystickAxis;
+import com.jme3.input.JoystickButton;
+import com.jme3.input.MouseInput;
+import com.jme3.input.RawInputListener;
+import com.jme3.input.TouchInput;
+import com.jme3.input.controls.JoyAxisTrigger;
+import com.jme3.input.controls.JoyButtonTrigger;
+import com.jme3.input.controls.MouseAxisTrigger;
+import com.jme3.input.controls.TouchTrigger;
+import com.jme3.input.event.InputEvent;
+import com.jme3.input.event.JoyAxisEvent;
+import com.jme3.input.event.JoyButtonEvent;
+import com.jme3.input.virtual.VirtualJoystickLayout.Element;
+import com.jme3.input.virtual.VirtualJoystickTheme.TextureKey;
+import com.jme3.math.FastMath;
+import com.jme3.scene.Node;
+import com.jme3.system.AppSettings;
+import com.jme3.system.JmeSystem;
+import com.jme3.ui.Picture;
+import java.util.ArrayDeque;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A joystick implementation driven by on-screen controls.
+ */
+public class VirtualJoystick extends AbstractJoystick {
+
+ private static final int AXIS_LEFT_X = 0;
+ private static final int AXIS_LEFT_Y = 1;
+ private static final int AXIS_RIGHT_X = 2;
+ private static final int AXIS_RIGHT_Y = 3;
+ private static final int AXIS_LEFT_TRIGGER = 4;
+ private static final int AXIS_RIGHT_TRIGGER = 5;
+ private static final int AXIS_POV_X_ID = 6;
+
+ private static final String ROOT_NAME = "Virtual Joystick";
+
+ private final Map axesByLogicalId = new HashMap<>();
+ private final Map buttonsByLogicalId = new HashMap<>();
+ private final Map captures = new HashMap<>();
+ private ArrayDeque events = new ArrayDeque<>();
+ private ArrayDeque readyEvents = new ArrayDeque<>();
+ private final float[] axisValues = new float[7];
+ private final boolean[] buttonValues = new boolean[16];
+ private final Object inputLock = new Object();
+ private final Element.BoundsSnapshot inputBounds = new Element.BoundsSnapshot();
+
+ private JoystickAxis xAxis;
+ private JoystickAxis yAxis;
+ private JoystickAxis povXAxis;
+ private volatile boolean enabled = true;
+ private volatile int buttonStateMask;
+ private volatile boolean hasEvents;
+ private volatile VirtualJoystickTheme theme = new VirtualJoystickTheme();
+ private volatile VirtualJoystickLayout layout = new VirtualJoystickDynamicLayout(true);
+ private volatile int visualWidth;
+ private volatile int visualHeight;
+ private Node visualRoot;
+ private Node visualParent;
+ private BitmapFont font;
+
+ public VirtualJoystick(InputManager inputManager, JoyInput joyInput, int joyId) {
+ super(inputManager, joyInput, joyId, "Virtual Joystick");
+ addAxes();
+ addButtons(inputManager);
+ releaseAllLocked(0L);
+ }
+
+ /**
+ * Returns true if this joystick accepts pointer input.
+ *
+ * @return true if enabled
+ */
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ /**
+ * Enables or disables pointer processing for this joystick.
+ *
+ * @param enabled true to accept pointer input
+ */
+ public void setEnabled(boolean enabled) {
+ synchronized (inputLock) {
+ this.enabled = enabled;
+ if (!enabled) {
+ releaseAllLocked(0L);
+ }
+ }
+ }
+
+ public VirtualJoystickTheme getTheme() {
+ return theme;
+ }
+
+ public void setTheme(VirtualJoystickTheme theme) {
+ this.theme = theme == null ? new VirtualJoystickTheme() : theme;
+ this.theme.markUpdateNeeded();
+ }
+
+ public VirtualJoystickLayout getLayout() {
+ return layout;
+ }
+
+ public void setLayout(VirtualJoystickLayout layout) {
+ synchronized (inputLock) {
+ releaseAllLocked(0L);
+ this.layout = layout == null ? new VirtualJoystickDynamicLayout(true) : layout;
+ this.layout.markUpdateNeeded();
+ }
+ }
+
+ public static VirtualJoystickLayout createLayout(String layout) {
+ if (AppSettings.VIRTUAL_JOYSTICK_LAYOUT_XBOX.equalsIgnoreCase(layout)) {
+ return new VirtualJoystickXboxLayout();
+ }
+ return new VirtualJoystickDynamicLayout(true);
+ }
+
+ @Override
+ public void rumble(float amountHigh, float amountLow, float duration) {
+ if (JmeSystem.isDeviceRumbleSupported()) {
+ JmeSystem.rumble(amountHigh, amountLow, duration);
+ }
+ }
+
+ @Override
+ public void stopRumble() {
+ JmeSystem.stopRumble();
+ }
+
+ /**
+ * Processes a pointer-down event.
+ *
+ * @return true if the pointer was captured by a virtual control
+ */
+ public boolean onPointerDown(int pointerId, float x, float y, long time) {
+ synchronized (inputLock) {
+ Capture existingCapture = captures.get(pointerId);
+ if (!enabled || existingCapture != null) {
+ return existingCapture != null;
+ }
+
+ for (Element element : layout.getAxisElements()) {
+ if (element.visible && element.contains(x, y)) {
+ captures.put(pointerId, new Capture(element, true));
+ updateAxisCapture(element, x, y, time);
+ return true;
+ }
+ }
+
+ for (Element element : layout.getButtons()) {
+ if (element.visible && element.contains(x, y)) {
+ captures.put(pointerId, new Capture(element, false));
+ if (isToggleButton(element.id)) {
+ pressButton(element.id, !isButtonPressed(element.id), time);
+ } else {
+ pressButton(element.id, true, time);
+ }
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+
+ /**
+ * Processes a pointer-move event.
+ *
+ * @return true if the pointer is captured by a virtual control
+ */
+ public boolean onPointerMove(int pointerId, float x, float y, long time) {
+ synchronized (inputLock) {
+ Capture capture = captures.get(pointerId);
+ if (capture == null) {
+ return false;
+ }
+ if (capture.axis) {
+ updateAxisCapture(capture.element, x, y, time);
+ }
+ return true;
+ }
+ }
+
+ /**
+ * Processes a pointer-up event.
+ *
+ * @return true if the pointer was captured by a virtual control
+ */
+ public boolean onPointerUp(int pointerId, float x, float y, long time) {
+ synchronized (inputLock) {
+ Capture capture = captures.remove(pointerId);
+ if (capture == null) {
+ return false;
+ }
+ if (capture.axis) {
+ centerAxisCapture(capture.element, time);
+ } else if (!isToggleButton(capture.element.id)) {
+ pressButton(capture.element.id, false, time);
+ }
+ return true;
+ }
+ }
+
+ /**
+ * Releases all active pointer captures.
+ *
+ * @return true if at least one pointer was captured
+ */
+ public boolean onPointerCancel(long time) {
+ synchronized (inputLock) {
+ boolean captured = !captures.isEmpty();
+ releaseAllLocked(time);
+ return captured;
+ }
+ }
+
+ /**
+ * Dispatches pending joystick events to the backend listener.
+ *
+ * @param listener listener that receives joystick events
+ */
+ public void dispatchEvents(RawInputListener listener) {
+ if (!hasEvents) {
+ return;
+ }
+
+ synchronized (inputLock) {
+ if (!hasEvents) {
+ return;
+ }
+ if (listener == null) {
+ events.clear();
+ hasEvents = false;
+ return;
+ }
+ ArrayDeque pendingEvents = events;
+ events = readyEvents;
+ readyEvents = pendingEvents;
+ hasEvents = false;
+ }
+
+ InputEvent event;
+ while ((event = readyEvents.poll()) != null) {
+ if (event instanceof JoyAxisEvent) {
+ listener.onJoyAxisEvent((JoyAxisEvent) event);
+ } else if (event instanceof JoyButtonEvent) {
+ listener.onJoyButtonEvent((JoyButtonEvent) event);
+ }
+ }
+ }
+
+ /**
+ * Synchronizes GUI spatials with the current joystick state.
+ *
+ * @param parent GUI node that should contain the controls
+ * @param assetManager asset manager used to load default textures
+ * @param width GUI width in pixels
+ * @param height GUI height in pixels
+ * @param tpf time per frame
+ */
+ public void updateVisuals(Node parent, AssetManager assetManager, int width, int height, float tpf) {
+ if (parent == null || assetManager == null || width <= 0 || height <= 0) {
+ return;
+ }
+ if (visualRoot == null) {
+ visualRoot = new Node(ROOT_NAME);
+ }
+ attachVisualRootOnTop(parent);
+ if (width != visualWidth || height != visualHeight) {
+ synchronized (inputLock) {
+ if (width != visualWidth || height != visualHeight) {
+ visualWidth = width;
+ visualHeight = height;
+ releaseAllLocked(0L);
+ }
+ }
+ }
+
+ if (!enabled) {
+ if (visualRoot.getQuantity() > 0) {
+ visualRoot.detachAllChildren();
+ }
+ return;
+ }
+
+ VirtualJoystickTheme currentTheme = theme;
+ VirtualJoystickLayout currentLayout = layout;
+ currentLayout.update(this);
+ boolean themeUpdateNeeded = currentTheme.isUpdateNeeded();
+ if (themeUpdateNeeded || currentLayout.isUpdateNeeded()) {
+ clearVisuals(currentLayout);
+ if (themeUpdateNeeded) {
+ font = null;
+ }
+ currentTheme.clearUpdateNeeded();
+ currentLayout.clearUpdateNeeded();
+ }
+ String fontPath = currentTheme.getFontPath();
+ if (font == null && fontPath != null) {
+ font = assetManager.loadFont(fontPath);
+ }
+
+ float scale = currentLayout.getScale();
+
+ for (Element element : currentLayout.getButtons()) {
+ syncElement(element, assetManager, width, height, scale);
+ }
+ for (Element element : currentLayout.getAxisElements()) {
+ syncElement(element, assetManager, width, height, scale);
+ }
+ }
+
+ private void attachVisualRootOnTop(Node parent) {
+ if (visualParent != parent || visualRoot.getParent() != parent) {
+ visualRoot.removeFromParent();
+ parent.attachChild(visualRoot);
+ visualParent = parent;
+ return;
+ }
+
+ int childIndex = parent.getChildIndex(visualRoot);
+ int topIndex = parent.getQuantity() - 1;
+ if (childIndex >= 0 && childIndex < topIndex) {
+ visualRoot.removeFromParent();
+ parent.attachChild(visualRoot);
+ }
+ }
+
+ @Override
+ public JoystickAxis getXAxis() {
+ return xAxis;
+ }
+
+ @Override
+ public JoystickAxis getYAxis() {
+ return yAxis;
+ }
+
+ @Override
+ public JoystickAxis getPovXAxis() {
+ return povXAxis;
+ }
+
+ @Override
+ public JoystickAxis getPovYAxis() {
+ return null;
+ }
+
+ public boolean hasInputBindings() {
+ for (JoystickAxis axis : getAxes()) {
+ if (isAxisBound(axis.getLogicalId())) {
+ return true;
+ }
+ }
+ for (JoystickButton button : getButtons()) {
+ if (isButtonBound(button.getLogicalId())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public boolean isAxisBound(String logicalId) {
+ JoystickAxis axis = axesByLogicalId.get(logicalId);
+ if (axis == null) {
+ return false;
+ }
+ InputManager manager = getInputManager();
+ return manager != null
+ && (manager.hasTriggerMapping(JoyAxisTrigger.joyAxisHash(getJoyId(), axis.getAxisId(), false))
+ || manager.hasTriggerMapping(JoyAxisTrigger.joyAxisHash(getJoyId(), axis.getAxisId(), true)));
+ }
+
+ public boolean isButtonBound(String logicalId) {
+ JoystickButton button = buttonsByLogicalId.get(logicalId);
+ if (button == null) {
+ return false;
+ }
+ InputManager manager = getInputManager();
+ return manager != null
+ && manager.hasTriggerMapping(JoyButtonTrigger.joyButtonHash(getJoyId(), button.getButtonId()));
+ }
+
+ public boolean hasPointerLookBindings() {
+ InputManager manager = getInputManager();
+ if (manager == null) {
+ return false;
+ }
+ boolean mouseLook = hasMouseAxisBinding(manager, MouseInput.AXIS_X)
+ && hasMouseAxisBinding(manager, MouseInput.AXIS_Y);
+ boolean touchLook = manager.hasTriggerMapping(TouchTrigger.touchHash(TouchInput.ALL));
+ return touchLook || (mouseLook && manager.isSimulateMouse());
+ }
+
+ private boolean hasMouseAxisBinding(InputManager manager, int axis) {
+ return manager.hasTriggerMapping(MouseAxisTrigger.mouseAxisHash(axis, false))
+ || manager.hasTriggerMapping(MouseAxisTrigger.mouseAxisHash(axis, true));
+ }
+
+ private void addAxes() {
+ xAxis = addAxis(AXIS_LEFT_X, "LEFT THUMB STICK (X)", JoystickAxis.AXIS_XBOX_LEFT_THUMB_STICK_X);
+ yAxis = addAxis(AXIS_LEFT_Y, "LEFT THUMB STICK (Y)", JoystickAxis.AXIS_XBOX_LEFT_THUMB_STICK_Y);
+ addAxis(AXIS_RIGHT_X, "RIGHT THUMB STICK (X)", JoystickAxis.AXIS_XBOX_RIGHT_THUMB_STICK_X);
+ addAxis(AXIS_RIGHT_Y, "RIGHT THUMB STICK (Y)", JoystickAxis.AXIS_XBOX_RIGHT_THUMB_STICK_Y);
+ addAxis(AXIS_LEFT_TRIGGER, "LEFT TRIGGER", JoystickAxis.AXIS_XBOX_LEFT_TRIGGER);
+ addAxis(AXIS_RIGHT_TRIGGER, "RIGHT TRIGGER", JoystickAxis.AXIS_XBOX_RIGHT_TRIGGER);
+ povXAxis = addAxis(AXIS_POV_X_ID, JoystickAxis.POV_X, JoystickAxis.POV_X);
+ }
+
+ private JoystickAxis addAxis(int id, String name, String logicalId) {
+ JoystickAxis axis = new DefaultJoystickAxis(getInputManager(), this, id, name, logicalId, true, false, 0f);
+ axesByLogicalId.put(logicalId, axis);
+ super.addAxis(axis);
+ return axis;
+ }
+
+ private void addButtons(InputManager inputManager) {
+ addButton(inputManager, 0, "A", JoystickButton.BUTTON_XBOX_A);
+ addButton(inputManager, 1, "B", JoystickButton.BUTTON_XBOX_B);
+ addButton(inputManager, 2, "X", JoystickButton.BUTTON_XBOX_X);
+ addButton(inputManager, 3, "Y", JoystickButton.BUTTON_XBOX_Y);
+ addButton(inputManager, 4, "LB", JoystickButton.BUTTON_XBOX_LB);
+ addButton(inputManager, 5, "RB", JoystickButton.BUTTON_XBOX_RB);
+ addButton(inputManager, 6, "LT", JoystickButton.BUTTON_XBOX_LT);
+ addButton(inputManager, 7, "RT", JoystickButton.BUTTON_XBOX_RT);
+ addButton(inputManager, 8, "BACK", JoystickButton.BUTTON_XBOX_BACK);
+ addButton(inputManager, 9, "START", JoystickButton.BUTTON_XBOX_START);
+ addButton(inputManager, 10, "L3", JoystickButton.BUTTON_XBOX_L3);
+ addButton(inputManager, 11, "R3", JoystickButton.BUTTON_XBOX_R3);
+ addButton(inputManager, 12, "D-PAD UP", JoystickButton.BUTTON_XBOX_DPAD_UP);
+ addButton(inputManager, 13, "D-PAD DOWN", JoystickButton.BUTTON_XBOX_DPAD_DOWN);
+ addButton(inputManager, 14, "D-PAD LEFT", JoystickButton.BUTTON_XBOX_DPAD_LEFT);
+ addButton(inputManager, 15, "D-PAD RIGHT", JoystickButton.BUTTON_XBOX_DPAD_RIGHT);
+ }
+
+ private void addButton(InputManager inputManager, int id, String name, String logicalId) {
+ JoystickButton button = new DefaultJoystickButton(inputManager, this, id, name, logicalId);
+ buttonsByLogicalId.put(logicalId, button);
+ super.addButton(button);
+ }
+
+ private void updateAxisCapture(Element element, float x, float y, long time) {
+ element.copyBoundsTo(inputBounds);
+ float radius = inputBounds.size * 0.5f;
+ if (radius <= 0f) {
+ return;
+ }
+ float dx = x - inputBounds.x;
+ float dy = y - inputBounds.y;
+ float length = FastMath.sqrt(dx * dx + dy * dy);
+ if (length > radius && length > 0f) {
+ dx *= radius / length;
+ dy *= radius / length;
+ }
+
+ float valueX = FastMath.clamp(dx / radius, -1f, 1f);
+ float valueY = FastMath.clamp(dy / radius, -1f, 1f);
+ element.nubX = valueX;
+ element.nubY = valueY;
+
+ setAxisValue(element.id, valueX, time);
+ setAxisValue(element.yAxisLogicalId, -valueY, time);
+ }
+
+ private void centerAxisCapture(Element element, long time) {
+ element.nubX = 0f;
+ element.nubY = 0f;
+ setAxisValue(element.id, 0f, time);
+ setAxisValue(element.yAxisLogicalId, 0f, time);
+ }
+
+ private void pressButton(String logicalId, boolean pressed, long time) {
+ JoystickButton button = buttonsByLogicalId.get(logicalId);
+ if (button == null) {
+ return;
+ }
+ int buttonId = button.getButtonId();
+ if (buttonValues[buttonId] == pressed) {
+ return;
+ }
+ buttonValues[buttonId] = pressed;
+ if (pressed) {
+ buttonStateMask |= 1 << buttonId;
+ } else {
+ buttonStateMask &= ~(1 << buttonId);
+ }
+ JoyButtonEvent event = new JoyButtonEvent(button, pressed);
+ event.setTime(time);
+ enqueueEvent(event);
+
+ if (JoystickButton.BUTTON_XBOX_LT.equals(logicalId)) {
+ setAxisValue(JoystickAxis.AXIS_XBOX_LEFT_TRIGGER, pressed ? 1f : 0f, time);
+ } else if (JoystickButton.BUTTON_XBOX_RT.equals(logicalId)) {
+ setAxisValue(JoystickAxis.AXIS_XBOX_RIGHT_TRIGGER, pressed ? 1f : 0f, time);
+ } else if (isDpad(logicalId)) {
+ updatePovXAxis(time);
+ }
+ }
+
+ private boolean isButtonPressed(String logicalId) {
+ JoystickButton button = buttonsByLogicalId.get(logicalId);
+ return button != null && buttonValues[button.getButtonId()];
+ }
+
+ private boolean isToggleButton(String logicalId) {
+ return JoystickButton.BUTTON_XBOX_L3.equals(logicalId)
+ || JoystickButton.BUTTON_XBOX_R3.equals(logicalId);
+ }
+
+ private boolean isDpad(String logicalId) {
+ return JoystickButton.BUTTON_XBOX_DPAD_UP.equals(logicalId)
+ || JoystickButton.BUTTON_XBOX_DPAD_DOWN.equals(logicalId)
+ || JoystickButton.BUTTON_XBOX_DPAD_LEFT.equals(logicalId)
+ || JoystickButton.BUTTON_XBOX_DPAD_RIGHT.equals(logicalId);
+ }
+
+ private void updatePovXAxis(long time) {
+ float x = 0f;
+ if (buttonValues[buttonsByLogicalId.get(JoystickButton.BUTTON_XBOX_DPAD_LEFT).getButtonId()]) {
+ x -= 1f;
+ }
+ if (buttonValues[buttonsByLogicalId.get(JoystickButton.BUTTON_XBOX_DPAD_RIGHT).getButtonId()]) {
+ x += 1f;
+ }
+ setAxisValue(JoystickAxis.POV_X, x, time);
+ }
+
+ private void setAxisValue(String logicalId, float value, long time) {
+ JoystickAxis axis = axesByLogicalId.get(logicalId);
+ if (axis == null) {
+ return;
+ }
+ value = FastMath.clamp(value, -1f, 1f);
+ int axisId = axis.getAxisId();
+ if (axisValues[axisId] == value) {
+ return;
+ }
+ axisValues[axisId] = value;
+ JoyAxisEvent event = new JoyAxisEvent(axis, value, value);
+ event.setTime(time);
+ enqueueEvent(event);
+ }
+
+ private void enqueueEvent(InputEvent event) {
+ events.add(event);
+ hasEvents = true;
+ }
+
+ private void releaseAllLocked(long time) {
+ captures.clear();
+ for (Element element : layout.getAxisElements()) {
+ centerAxisCapture(element, time);
+ }
+ for (String logicalId : buttonsByLogicalId.keySet()) {
+ pressButton(logicalId, false, time);
+ }
+ }
+
+ private void syncElement(Element element, AssetManager assetManager, int width, int height, float scale) {
+ if (element == null) {
+ return;
+ }
+ if (!element.visible) {
+ if (element.node != null && element.node.getParent() != null) {
+ element.node.removeFromParent();
+ }
+ return;
+ }
+ if (element.node == null) {
+ createVisual(element, assetManager);
+ }
+ if (element.node.getParent() != visualRoot) {
+ visualRoot.attachChild(element.node);
+ }
+ JoystickButton button = buttonsByLogicalId.get(element.id);
+ boolean pressed = button != null && (buttonStateMask & (1 << button.getButtonId())) != 0;
+ element.sync(width, height, scale, pressed);
+ }
+
+ private void createVisual(Element element, AssetManager assetManager) {
+ element.node = new Node("Virtual Joystick " + element.id);
+ element.base = new Picture(element.id);
+ TextureKey baseTextureKey;
+ TextureKey nubTextureKey;
+ TextureKey iconTextureKey;
+ String label;
+ baseTextureKey = element.textureKey;
+ nubTextureKey = element.nubTextureKey;
+ iconTextureKey = element.iconTextureKey;
+ label = element.label;
+ element.base.setImage(assetManager, texture(baseTextureKey), true);
+ element.node.attachChild(element.base);
+
+ if (nubTextureKey != null) {
+ element.nub = new Picture(element.id + " Nub");
+ element.nub.setImage(assetManager, texture(nubTextureKey), true);
+ element.node.attachChild(element.nub);
+ }
+
+ if (iconTextureKey != null) {
+ element.icon = new Picture(element.id + " Icon");
+ element.icon.setImage(assetManager, texture(iconTextureKey), true);
+ element.node.attachChild(element.icon);
+ }
+
+ if (font != null && !label.isEmpty()) {
+ element.text = new BitmapText(font, false);
+ element.text.setText(label);
+ element.node.attachChild(element.text);
+ }
+ }
+
+ private String texture(TextureKey textureKey) {
+ String texturePath = theme.getTexture(textureKey);
+ if (texturePath == null) {
+ throw new IllegalStateException("No virtual joystick texture bound for key: " + textureKey);
+ }
+ return texturePath;
+ }
+
+ private void clearVisuals(VirtualJoystickLayout layout) {
+ for (Element element : layout.getButtons()) {
+ element.clearVisuals();
+ }
+ for (Element element : layout.getAxisElements()) {
+ element.clearVisuals();
+ }
+ if (visualRoot != null) {
+ visualRoot.detachAllChildren();
+ }
+ }
+
+ private static final class Capture {
+ final Element element;
+ final boolean axis;
+
+ Capture(Element element, boolean axis) {
+ this.element = element;
+ this.axis = axis;
+ }
+ }
+}
diff --git a/jme3-core/src/main/java/com/jme3/input/virtual/VirtualJoystickDynamicLayout.java b/jme3-core/src/main/java/com/jme3/input/virtual/VirtualJoystickDynamicLayout.java
new file mode 100644
index 0000000000..a8f972d0b5
--- /dev/null
+++ b/jme3-core/src/main/java/com/jme3/input/virtual/VirtualJoystickDynamicLayout.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2009-2026 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF DATA, OR PROFITS;
+ * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+ * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
+ * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+ * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.jme3.input.virtual;
+
+import com.jme3.input.JoystickAxis;
+import com.jme3.input.JoystickButton;
+
+/**
+ * Xbox-like layout that only shows controls currently bound in the input manager.
+ */
+public class VirtualJoystickDynamicLayout extends VirtualJoystickXboxLayout {
+
+ private final boolean useTouchForRightAnalog;
+ private long visibilityMask = -1L;
+
+ public VirtualJoystickDynamicLayout(boolean useTouchForRightAnalog) {
+ this.useTouchForRightAnalog = useTouchForRightAnalog;
+ }
+
+ @Override
+ public void update(VirtualJoystick joystick) {
+ if (joystick == null) {
+ return;
+ }
+
+ boolean leftTrigger = joystick.isButtonBound(JoystickButton.BUTTON_XBOX_LT)
+ || joystick.isAxisBound(JoystickAxis.AXIS_XBOX_LEFT_TRIGGER);
+ boolean rightTrigger = joystick.isButtonBound(JoystickButton.BUTTON_XBOX_RT)
+ || joystick.isAxisBound(JoystickAxis.AXIS_XBOX_RIGHT_TRIGGER);
+
+ boolean leftStick = joystick.isAxisBound(JoystickAxis.AXIS_XBOX_LEFT_THUMB_STICK_X)
+ || joystick.isAxisBound(JoystickAxis.AXIS_XBOX_LEFT_THUMB_STICK_Y);
+ boolean rightStick = joystick.isAxisBound(JoystickAxis.AXIS_XBOX_RIGHT_THUMB_STICK_X)
+ || joystick.isAxisBound(JoystickAxis.AXIS_XBOX_RIGHT_THUMB_STICK_Y);
+ if (useTouchForRightAnalog && joystick.hasPointerLookBindings()) {
+ rightStick = false;
+ }
+
+ long nextVisibilityMask = 0L;
+ nextVisibilityMask |= joystick.isButtonBound(JoystickButton.BUTTON_XBOX_A) ? 1L : 0L;
+ nextVisibilityMask |= joystick.isButtonBound(JoystickButton.BUTTON_XBOX_B) ? 1L << 1 : 0L;
+ nextVisibilityMask |= joystick.isButtonBound(JoystickButton.BUTTON_XBOX_X) ? 1L << 2 : 0L;
+ nextVisibilityMask |= joystick.isButtonBound(JoystickButton.BUTTON_XBOX_Y) ? 1L << 3 : 0L;
+ nextVisibilityMask |= leftTrigger ? 1L << 4 : 0L;
+ nextVisibilityMask |= rightTrigger ? 1L << 5 : 0L;
+ nextVisibilityMask |= joystick.isButtonBound(JoystickButton.BUTTON_XBOX_LB) ? 1L << 6 : 0L;
+ nextVisibilityMask |= joystick.isButtonBound(JoystickButton.BUTTON_XBOX_RB) ? 1L << 7 : 0L;
+ nextVisibilityMask |= joystick.isButtonBound(JoystickButton.BUTTON_XBOX_BACK) ? 1L << 8 : 0L;
+ nextVisibilityMask |= joystick.isButtonBound(JoystickButton.BUTTON_XBOX_START) ? 1L << 9 : 0L;
+ nextVisibilityMask |= joystick.isButtonBound(JoystickButton.BUTTON_XBOX_L3) ? 1L << 10 : 0L;
+ nextVisibilityMask |= joystick.isButtonBound(JoystickButton.BUTTON_XBOX_R3) ? 1L << 11 : 0L;
+ nextVisibilityMask |= joystick.isButtonBound(JoystickButton.BUTTON_XBOX_DPAD_UP) ? 1L << 12 : 0L;
+ nextVisibilityMask |= joystick.isButtonBound(JoystickButton.BUTTON_XBOX_DPAD_DOWN) ? 1L << 13 : 0L;
+ nextVisibilityMask |= joystick.isButtonBound(JoystickButton.BUTTON_XBOX_DPAD_LEFT)
+ || joystick.isAxisBound(JoystickAxis.POV_X) ? 1L << 14 : 0L;
+ nextVisibilityMask |= joystick.isButtonBound(JoystickButton.BUTTON_XBOX_DPAD_RIGHT)
+ || joystick.isAxisBound(JoystickAxis.POV_X) ? 1L << 15 : 0L;
+ nextVisibilityMask |= leftStick ? 1L << 16 : 0L;
+ nextVisibilityMask |= rightStick ? 1L << 17 : 0L;
+
+ if (visibilityMask == nextVisibilityMask) {
+ return;
+ }
+ visibilityMask = nextVisibilityMask;
+
+ setButtonVisible(JoystickButton.BUTTON_XBOX_A, (nextVisibilityMask & 1L) != 0L);
+ setButtonVisible(JoystickButton.BUTTON_XBOX_B, (nextVisibilityMask & (1L << 1)) != 0L);
+ setButtonVisible(JoystickButton.BUTTON_XBOX_X, (nextVisibilityMask & (1L << 2)) != 0L);
+ setButtonVisible(JoystickButton.BUTTON_XBOX_Y, (nextVisibilityMask & (1L << 3)) != 0L);
+ setButtonVisible(JoystickButton.BUTTON_XBOX_LT, (nextVisibilityMask & (1L << 4)) != 0L);
+ setButtonVisible(JoystickButton.BUTTON_XBOX_RT, (nextVisibilityMask & (1L << 5)) != 0L);
+ setButtonVisible(JoystickButton.BUTTON_XBOX_LB, (nextVisibilityMask & (1L << 6)) != 0L);
+ setButtonVisible(JoystickButton.BUTTON_XBOX_RB, (nextVisibilityMask & (1L << 7)) != 0L);
+ setButtonVisible(JoystickButton.BUTTON_XBOX_BACK, (nextVisibilityMask & (1L << 8)) != 0L);
+ setButtonVisible(JoystickButton.BUTTON_XBOX_START, (nextVisibilityMask & (1L << 9)) != 0L);
+ setButtonVisible(JoystickButton.BUTTON_XBOX_L3, (nextVisibilityMask & (1L << 10)) != 0L);
+ setButtonVisible(JoystickButton.BUTTON_XBOX_R3, (nextVisibilityMask & (1L << 11)) != 0L);
+ setButtonVisible(JoystickButton.BUTTON_XBOX_DPAD_UP, (nextVisibilityMask & (1L << 12)) != 0L);
+ setButtonVisible(JoystickButton.BUTTON_XBOX_DPAD_DOWN, (nextVisibilityMask & (1L << 13)) != 0L);
+ setButtonVisible(JoystickButton.BUTTON_XBOX_DPAD_LEFT, (nextVisibilityMask & (1L << 14)) != 0L);
+ setButtonVisible(JoystickButton.BUTTON_XBOX_DPAD_RIGHT, (nextVisibilityMask & (1L << 15)) != 0L);
+ setAxisVisible(JoystickAxis.AXIS_XBOX_LEFT_THUMB_STICK_X, leftStick);
+ setAxisVisible(JoystickAxis.AXIS_XBOX_RIGHT_THUMB_STICK_X, rightStick);
+ }
+}
diff --git a/jme3-core/src/main/java/com/jme3/input/virtual/VirtualJoystickLayout.java b/jme3-core/src/main/java/com/jme3/input/virtual/VirtualJoystickLayout.java
new file mode 100644
index 0000000000..9b84f62133
--- /dev/null
+++ b/jme3-core/src/main/java/com/jme3/input/virtual/VirtualJoystickLayout.java
@@ -0,0 +1,603 @@
+/*
+ * Copyright (c) 2009-2026 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.jme3.input.virtual;
+
+import com.jme3.export.InputCapsule;
+import com.jme3.export.JmeExporter;
+import com.jme3.export.JmeImporter;
+import com.jme3.export.OutputCapsule;
+import com.jme3.export.Savable;
+import com.jme3.font.BitmapText;
+import com.jme3.input.virtual.VirtualJoystickTheme.TextureKey;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.math.Vector2f;
+import com.jme3.scene.Node;
+import com.jme3.ui.Picture;
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Virtual joystick controls layout.
+ */
+public abstract class VirtualJoystickLayout implements Savable {
+
+ private final Map buttons = new LinkedHashMap<>();
+ private final Map axisElements = new LinkedHashMap<>();
+ private volatile float scale = 1.15f;
+ private transient volatile boolean updateNeeded = true;
+ private transient volatile Element[] buttonSnapshot = new Element[0];
+ private transient volatile Element[] axisSnapshot = new Element[0];
+ protected VirtualJoystickLayout() {
+ }
+
+ protected final synchronized void addButtonElement(String logicalId, String label, float x, float y, float size,
+ TextureKey textureKey) {
+ buttons.put(logicalId, new Element(logicalId, label, x, y, size, textureKey));
+ layoutChanged();
+ }
+
+ protected final synchronized void addButtonElement(String logicalId, String label, float x, float y, float shortOffsetX,
+ float shortOffsetY, float size, TextureKey textureKey) {
+ buttons.put(logicalId, new Element(logicalId, label, x, y, size, textureKey)
+ .setShortOffset(shortOffsetX, shortOffsetY));
+ layoutChanged();
+ }
+
+ protected final synchronized void addButtonElement(String logicalId, String label, float x, float y, float shortOffsetX,
+ float shortOffsetY, float size, TextureKey textureKey, TextureKey iconTextureKey) {
+ buttons.put(logicalId, new Element(logicalId, label, x, y, size, textureKey)
+ .setShortOffset(shortOffsetX, shortOffsetY)
+ .setIconTextureKey(iconTextureKey));
+ layoutChanged();
+ }
+
+ protected final synchronized void addButtonElement(String logicalId, String label, float x, float y, float size, float aspect,
+ TextureKey textureKey) {
+ buttons.put(logicalId, new Element(logicalId, label, x, y, size, textureKey).setAspect(aspect));
+ layoutChanged();
+ }
+
+ protected final synchronized void addButtonElement(String logicalId, String label, float x, float y, float size, float aspect,
+ TextureKey textureKey, TextureKey iconTextureKey) {
+ buttons.put(logicalId, new Element(logicalId, label, x, y, size, textureKey)
+ .setAspect(aspect)
+ .setIconTextureKey(iconTextureKey));
+ layoutChanged();
+ }
+
+ protected final synchronized void addAxisElement(String xAxisLogicalId, String yAxisLogicalId, String label, float x, float y,
+ float size, TextureKey textureKey, TextureKey nubTextureKey) {
+ axisElements.put(xAxisLogicalId, new Element(xAxisLogicalId, label, x, y, size, textureKey)
+ .setYAxisLogicalId(yAxisLogicalId)
+ .setNubTextureKey(nubTextureKey));
+ layoutChanged();
+ }
+
+ protected final synchronized void setButtonPosition(String logicalId, float x, float y) {
+ element(buttons, logicalId).setPosition(x, y);
+ markUpdateNeeded();
+ }
+
+ public synchronized Vector2f getButtonPosition(String logicalId) {
+ Element element = element(buttons, logicalId);
+ return new Vector2f(element.positionX, element.positionY);
+ }
+
+ protected final synchronized void setButtonVisible(String logicalId, boolean visible) {
+ Element element = element(buttons, logicalId);
+ if (element.visible == visible) {
+ return;
+ }
+ element.visible = visible;
+ markUpdateNeeded();
+ }
+
+ public synchronized boolean isButtonVisible(String logicalId) {
+ return element(buttons, logicalId).visible;
+ }
+
+ protected final synchronized void setAxisPosition(String logicalId, float x, float y) {
+ axisElement(logicalId).setPosition(x, y);
+ markUpdateNeeded();
+ }
+
+ public synchronized Vector2f getAxisPosition(String logicalId) {
+ Element element = axisElement(logicalId);
+ return new Vector2f(element.positionX, element.positionY);
+ }
+
+ protected final synchronized void setAxisVisible(String logicalId, boolean visible) {
+ Element element = axisElement(logicalId);
+ if (element.visible == visible) {
+ return;
+ }
+ element.visible = visible;
+ markUpdateNeeded();
+ }
+
+ public synchronized boolean isAxisVisible(String logicalId) {
+ return axisElement(logicalId).visible;
+ }
+
+ protected final synchronized void setButtonSize(String logicalId, float size) {
+ Element element = element(buttons, logicalId);
+ element.size = FastMath.clamp(size, 0.01f, 1f);
+ markUpdateNeeded();
+ }
+
+ protected final synchronized void setButtonTextureKey(String logicalId, TextureKey textureKey) {
+ Element element = element(buttons, logicalId);
+ element.textureKey = textureKey;
+ markUpdateNeeded();
+ }
+
+ protected final synchronized void setButtonIconTextureKey(String logicalId, TextureKey iconTextureKey) {
+ Element element = element(buttons, logicalId);
+ element.iconTextureKey = iconTextureKey;
+ markUpdateNeeded();
+ }
+
+ protected final synchronized void setAxisSize(String logicalId, float size) {
+ Element element = axisElement(logicalId);
+ element.size = FastMath.clamp(size, 0.01f, 1f);
+ markUpdateNeeded();
+ }
+
+ protected final synchronized void setAxisTextureKey(String logicalId, TextureKey textureKey) {
+ Element element = axisElement(logicalId);
+ element.textureKey = textureKey;
+ markUpdateNeeded();
+ }
+
+ protected final synchronized void setAxisNubTextureKey(String logicalId, TextureKey nubTextureKey) {
+ Element element = axisElement(logicalId);
+ element.nubTextureKey = nubTextureKey;
+ markUpdateNeeded();
+ }
+
+ protected final synchronized void setScale(float scale) {
+ this.scale = FastMath.clamp(scale, 0.25f, 3f);
+ markUpdateNeeded();
+ }
+
+ public float getScale() {
+ return scale;
+ }
+
+ boolean isUpdateNeeded() {
+ return updateNeeded;
+ }
+
+ void markUpdateNeeded() {
+ updateNeeded = true;
+ }
+
+ void clearUpdateNeeded() {
+ updateNeeded = false;
+ }
+
+ Element[] getButtons() {
+ return buttonSnapshot;
+ }
+
+ Element[] getAxisElements() {
+ return axisSnapshot;
+ }
+
+ public void update(VirtualJoystick joystick) {
+ }
+
+ public synchronized Element getButtonElement(String logicalId) {
+ return buttons.get(logicalId);
+ }
+
+ public synchronized Element getAxisElement(String logicalId) {
+ return findAxisElement(logicalId);
+ }
+
+ private Element axisElement(String logicalId) {
+ Element element = findAxisElement(logicalId);
+ if (element == null) {
+ throw new IllegalArgumentException("Unknown virtual joystick axis element: " + logicalId);
+ }
+ return element;
+ }
+
+ private Element findAxisElement(String logicalId) {
+ Element element = axisElements.get(logicalId);
+ if (element != null) {
+ return element;
+ }
+ for (Element axisElement : axisElements.values()) {
+ if (logicalId.equals(axisElement.yAxisLogicalId)) {
+ return axisElement;
+ }
+ }
+ return null;
+ }
+
+ private Element element(Map elements, String logicalId) {
+ Element element = elements.get(logicalId);
+ if (element == null) {
+ throw new IllegalArgumentException("Unknown virtual joystick element: " + logicalId);
+ }
+ return element;
+ }
+
+ @Override
+ public synchronized void write(JmeExporter ex) throws IOException {
+ OutputCapsule capsule = ex.getCapsule(this);
+ capsule.write(scale, "scale", 1.15f);
+ capsule.write(buttons.values().toArray(new Element[0]), "buttons", null);
+ capsule.write(axisElements.values().toArray(new Element[0]), "axes", null);
+ }
+
+ @Override
+ public synchronized void read(JmeImporter im) throws IOException {
+ InputCapsule capsule = im.getCapsule(this);
+ scale = capsule.readFloat("scale", 1.15f);
+ buttons.clear();
+ axisElements.clear();
+ Savable[] readButtons = capsule.readSavableArray("buttons", null);
+ if (readButtons != null) {
+ for (Savable savable : readButtons) {
+ Element element = (Element) savable;
+ buttons.put(element.id, element);
+ }
+ }
+ Savable[] readAxes = capsule.readSavableArray("axes", null);
+ if (readAxes != null) {
+ for (Savable savable : readAxes) {
+ Element element = (Element) savable;
+ axisElements.put(element.id, element);
+ }
+ }
+ rebuildSnapshots();
+ markUpdateNeeded();
+ }
+
+ private void layoutChanged() {
+ rebuildSnapshots();
+ markUpdateNeeded();
+ }
+
+ private void rebuildSnapshots() {
+ buttonSnapshot = buttons.values().toArray(new Element[0]);
+ axisSnapshot = axisElements.values().toArray(new Element[0]);
+ }
+
+ public static class Element implements Savable {
+ private static final ColorRGBA DEFAULT_COLOR = new ColorRGBA(1f, 1f, 1f, 0.72f);
+
+ volatile String id;
+ volatile String label;
+ volatile String yAxisLogicalId;
+ volatile float positionX;
+ volatile float positionY;
+ volatile TextureKey textureKey;
+ volatile TextureKey nubTextureKey;
+ volatile TextureKey iconTextureKey;
+ volatile float size;
+ volatile float aspect = 1f;
+ volatile float shortOffsetX;
+ volatile float shortOffsetY;
+ volatile boolean visible = true;
+ private transient volatile Bounds bounds = Bounds.EMPTY;
+ transient volatile float nubX;
+ transient volatile float nubY;
+ transient Node node;
+ transient Picture base;
+ transient Picture nub;
+ transient Picture icon;
+ transient BitmapText text;
+ private transient boolean baseGeometrySynced;
+ private transient boolean baseColorSynced;
+ private transient boolean nubGeometrySynced;
+ private transient boolean iconGeometrySynced;
+ private transient boolean iconColorSynced;
+ private transient boolean textGeometrySynced;
+ private transient boolean textColorSynced;
+ private transient boolean nodePositionSynced;
+ private transient boolean lastPressed;
+ private transient float lastBaseWidth;
+ private transient float lastBaseHeight;
+ private transient float lastBaseX;
+ private transient float lastBaseY;
+ private transient float lastNubSize;
+ private transient float lastNubX;
+ private transient float lastNubY;
+ private transient float lastIconSize;
+ private transient float lastTextSize;
+ private transient float lastNodeX;
+ private transient float lastNodeY;
+
+ public Element() {
+ }
+
+ public Element(String id, String label, float x, float y, float size, TextureKey textureKey) {
+ this.id = id;
+ this.label = label == null ? "" : label;
+ this.size = size;
+ this.textureKey = textureKey;
+ setPosition(x, y);
+ }
+
+ synchronized Element setPosition(float x, float y) {
+ positionX = FastMath.clamp(x, 0f, 1f);
+ positionY = FastMath.clamp(y, 0f, 1f);
+ shortOffsetX = 0f;
+ shortOffsetY = 0f;
+ return this;
+ }
+
+ synchronized Element setShortOffset(float x, float y) {
+ shortOffsetX = x;
+ shortOffsetY = y;
+ return this;
+ }
+
+ synchronized Element setAspect(float aspect) {
+ this.aspect = aspect;
+ return this;
+ }
+
+ synchronized Element setYAxisLogicalId(String yAxisLogicalId) {
+ this.yAxisLogicalId = yAxisLogicalId;
+ return this;
+ }
+
+ synchronized Element setNubTextureKey(TextureKey nubTextureKey) {
+ this.nubTextureKey = nubTextureKey;
+ return this;
+ }
+
+ synchronized Element setIconTextureKey(TextureKey iconTextureKey) {
+ this.iconTextureKey = iconTextureKey;
+ return this;
+ }
+
+ boolean contains(float x, float y) {
+ Bounds current = bounds;
+ return Math.abs(x - current.x) <= current.width * 0.5f
+ && Math.abs(y - current.y) <= current.height * 0.5f;
+ }
+
+ void copyBoundsTo(BoundsSnapshot target) {
+ Bounds current = bounds;
+ target.x = current.x;
+ target.y = current.y;
+ target.size = current.size;
+ target.width = current.width;
+ target.height = current.height;
+ }
+
+ void sync(int width, int height, float scale, boolean pressed) {
+ float shortSide = Math.min(width, height);
+ float scaledShortSide = shortSide * scale;
+ float pixelSize = Math.max(scaledShortSide * size, 1f);
+ float pixelWidth = pixelSize * aspect;
+ float pixelHeight = pixelSize;
+ float pixelX = positionX * width + shortOffsetX * scaledShortSide;
+ float pixelY = positionY * height + shortOffsetY * scaledShortSide;
+ if (pixelWidth < width) {
+ pixelX = FastMath.clamp(pixelX, pixelWidth * 0.5f, width - pixelWidth * 0.5f);
+ } else {
+ pixelX = width * 0.5f;
+ }
+ if (pixelHeight < height) {
+ pixelY = FastMath.clamp(pixelY, pixelHeight * 0.5f, height - pixelHeight * 0.5f);
+ } else {
+ pixelY = height * 0.5f;
+ }
+ float baseX = -pixelWidth * 0.5f;
+ float baseY = -pixelHeight * 0.5f;
+ if (!baseGeometrySynced || lastBaseWidth != pixelWidth) {
+ base.setWidth(pixelWidth);
+ lastBaseWidth = pixelWidth;
+ }
+ if (!baseGeometrySynced || lastBaseHeight != pixelHeight) {
+ base.setHeight(pixelHeight);
+ lastBaseHeight = pixelHeight;
+ }
+ if (!baseGeometrySynced || lastBaseX != baseX || lastBaseY != baseY) {
+ base.setPosition(baseX, baseY);
+ lastBaseX = baseX;
+ lastBaseY = baseY;
+ }
+ baseGeometrySynced = true;
+ if (!baseColorSynced || lastPressed != pressed) {
+ setColor(base, pressed ? ColorRGBA.White : DEFAULT_COLOR);
+ lastPressed = pressed;
+ baseColorSynced = true;
+ }
+
+ if (nub != null) {
+ float nubSize = pixelHeight * 0.42f;
+ float nubPixelX = (nubX * pixelHeight * 0.32f) - nubSize * 0.5f;
+ float nubPixelY = (nubY * pixelHeight * 0.32f) - nubSize * 0.5f;
+ if (!nubGeometrySynced || lastNubSize != nubSize) {
+ nub.setWidth(nubSize);
+ nub.setHeight(nubSize);
+ lastNubSize = nubSize;
+ }
+ if (!nubGeometrySynced || lastNubX != nubPixelX || lastNubY != nubPixelY) {
+ nub.setPosition(nubPixelX, nubPixelY);
+ lastNubX = nubPixelX;
+ lastNubY = nubPixelY;
+ }
+ nubGeometrySynced = true;
+ }
+
+ if (icon != null) {
+ float iconSize = pixelHeight * (aspect > 1f ? 0.38f : 0.46f);
+ if (!iconGeometrySynced || lastIconSize != iconSize) {
+ icon.setWidth(iconSize);
+ icon.setHeight(iconSize);
+ icon.setPosition(-iconSize * 0.5f, -iconSize * 0.5f);
+ lastIconSize = iconSize;
+ iconGeometrySynced = true;
+ }
+ if (!iconColorSynced) {
+ setColor(icon, ColorRGBA.White);
+ iconColorSynced = true;
+ }
+ }
+
+ if (text != null) {
+ float textSize = pixelHeight * (label.length() > 2 ? 0.2f : 0.32f);
+ if (!textGeometrySynced || lastTextSize != textSize) {
+ text.setSize(textSize);
+ lastTextSize = textSize;
+ text.setLocalTranslation(-text.getLineWidth() * 0.5f,
+ text.getLineHeight() * 0.48f, 1f);
+ textGeometrySynced = true;
+ }
+ if (!textColorSynced) {
+ text.setColor(ColorRGBA.White);
+ textColorSynced = true;
+ }
+ }
+
+ if (!nodePositionSynced || lastNodeX != pixelX || lastNodeY != pixelY) {
+ node.setLocalTranslation(pixelX, pixelY, 0f);
+ lastNodeX = pixelX;
+ lastNodeY = pixelY;
+ nodePositionSynced = true;
+ }
+ Bounds current = bounds;
+ if (current.x != pixelX || current.y != pixelY || current.size != pixelSize
+ || current.width != pixelWidth || current.height != pixelHeight) {
+ publishBounds(pixelX, pixelY, pixelSize, pixelWidth, pixelHeight);
+ }
+ }
+
+ synchronized void clearVisuals() {
+ if (node != null) {
+ node.removeFromParent();
+ }
+ node = null;
+ base = null;
+ nub = null;
+ icon = null;
+ text = null;
+ publishBounds(0f, 0f, 0f, 0f, 0f);
+ clearSyncState();
+ }
+
+ private void setColor(Picture picture, ColorRGBA color) {
+ if (picture.getMaterial() != null) {
+ picture.getMaterial().setColor("Color", color);
+ }
+ }
+
+ private void clearSyncState() {
+ baseGeometrySynced = false;
+ baseColorSynced = false;
+ nubGeometrySynced = false;
+ iconGeometrySynced = false;
+ iconColorSynced = false;
+ textGeometrySynced = false;
+ textColorSynced = false;
+ nodePositionSynced = false;
+ }
+
+ private void publishBounds(float x, float y, float size, float width, float height) {
+ bounds = x == 0f && y == 0f && size == 0f && width == 0f && height == 0f
+ ? Bounds.EMPTY
+ : new Bounds(x, y, size, width, height);
+ }
+
+ private static final class Bounds {
+ static final Bounds EMPTY = new Bounds(0f, 0f, 0f, 0f, 0f);
+
+ final float x;
+ final float y;
+ final float size;
+ final float width;
+ final float height;
+
+ Bounds(float x, float y, float size, float width, float height) {
+ this.x = x;
+ this.y = y;
+ this.size = size;
+ this.width = width;
+ this.height = height;
+ }
+ }
+
+ static final class BoundsSnapshot {
+ float x;
+ float y;
+ float size;
+ float width;
+ float height;
+ }
+
+ @Override
+ public synchronized void write(JmeExporter ex) throws IOException {
+ OutputCapsule capsule = ex.getCapsule(this);
+ capsule.write(id, "id", null);
+ capsule.write(label, "label", "");
+ capsule.write(yAxisLogicalId, "yAxisLogicalId", null);
+ capsule.write(new Vector2f(positionX, positionY), "position", null);
+ capsule.write(textureKey, "textureKey", null);
+ capsule.write(nubTextureKey, "nubTextureKey", null);
+ capsule.write(iconTextureKey, "iconTextureKey", null);
+ capsule.write(size, "size", 0f);
+ capsule.write(aspect, "aspect", 1f);
+ capsule.write(shortOffsetX, "shortOffsetX", 0f);
+ capsule.write(shortOffsetY, "shortOffsetY", 0f);
+ capsule.write(visible, "visible", true);
+ }
+
+ @Override
+ public synchronized void read(JmeImporter im) throws IOException {
+ InputCapsule capsule = im.getCapsule(this);
+ id = capsule.readString("id", null);
+ label = capsule.readString("label", "");
+ yAxisLogicalId = capsule.readString("yAxisLogicalId", null);
+ Vector2f position = (Vector2f) capsule.readSavable("position", new Vector2f());
+ positionX = position.x;
+ positionY = position.y;
+ textureKey = capsule.readEnum("textureKey", TextureKey.class, null);
+ nubTextureKey = capsule.readEnum("nubTextureKey", TextureKey.class, null);
+ iconTextureKey = capsule.readEnum("iconTextureKey", TextureKey.class, null);
+ size = capsule.readFloat("size", 0f);
+ aspect = capsule.readFloat("aspect", 1f);
+ shortOffsetX = capsule.readFloat("shortOffsetX", 0f);
+ shortOffsetY = capsule.readFloat("shortOffsetY", 0f);
+ visible = capsule.readBoolean("visible", true);
+ }
+ }
+}
diff --git a/jme3-core/src/main/java/com/jme3/input/virtual/VirtualJoystickTheme.java b/jme3-core/src/main/java/com/jme3/input/virtual/VirtualJoystickTheme.java
new file mode 100644
index 0000000000..4c16af3009
--- /dev/null
+++ b/jme3-core/src/main/java/com/jme3/input/virtual/VirtualJoystickTheme.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright (c) 2009-2026 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.jme3.input.virtual;
+
+import com.jme3.export.InputCapsule;
+import com.jme3.export.JmeExporter;
+import com.jme3.export.JmeImporter;
+import com.jme3.export.OutputCapsule;
+import com.jme3.export.Savable;
+import java.io.IOException;
+import java.util.EnumMap;
+import java.util.Map;
+
+/**
+ * Virtual joystick look and feel
+ */
+public class VirtualJoystickTheme implements Savable {
+
+ private static final String DEFAULT_FONT = "Interface/Fonts/Default.fnt";
+
+ public enum TextureKey {
+ BUTTON,
+ BUTTON_WIDE,
+ BUTTON_A_ICON,
+ BUTTON_B_ICON,
+ BUTTON_X_ICON,
+ BUTTON_Y_ICON,
+ BUTTON_BACK_ICON,
+ BUTTON_START_ICON,
+ STICK_PAD,
+ STICK_NUB,
+ DPAD_UP,
+ DPAD_DOWN,
+ DPAD_LEFT,
+ DPAD_RIGHT
+ }
+
+ private final Map textures = new EnumMap<>(TextureKey.class);
+ private volatile String fontPath = DEFAULT_FONT;
+ private transient volatile boolean updateNeeded = true;
+
+ public VirtualJoystickTheme() {
+ resetToDefault();
+ }
+
+ public synchronized final void resetToDefault() {
+ textures.clear();
+ fontPath = DEFAULT_FONT;
+ textures.put(TextureKey.BUTTON, "Common/VirtualJoystick/button_circle.png");
+ textures.put(TextureKey.BUTTON_WIDE, "Common/VirtualJoystick/button_circle_wide.png");
+ textures.put(TextureKey.BUTTON_A_ICON, "Common/VirtualJoystick/icon_button_a.png");
+ textures.put(TextureKey.BUTTON_B_ICON, "Common/VirtualJoystick/icon_button_b.png");
+ textures.put(TextureKey.BUTTON_X_ICON, "Common/VirtualJoystick/icon_button_x.png");
+ textures.put(TextureKey.BUTTON_Y_ICON, "Common/VirtualJoystick/icon_button_y.png");
+ textures.put(TextureKey.BUTTON_BACK_ICON, "Common/VirtualJoystick/icon_menu.png");
+ textures.put(TextureKey.BUTTON_START_ICON, "Common/VirtualJoystick/icon_star.png");
+ textures.put(TextureKey.STICK_PAD, "Common/VirtualJoystick/joystick_circle_pad_a.png");
+ textures.put(TextureKey.STICK_NUB, "Common/VirtualJoystick/joystick_circle_nub_a.png");
+ textures.put(TextureKey.DPAD_UP, "Common/VirtualJoystick/dpad_element_north.png");
+ textures.put(TextureKey.DPAD_DOWN, "Common/VirtualJoystick/dpad_element_south.png");
+ textures.put(TextureKey.DPAD_LEFT, "Common/VirtualJoystick/dpad_element_west.png");
+ textures.put(TextureKey.DPAD_RIGHT, "Common/VirtualJoystick/dpad_element_east.png");
+ markUpdateNeeded();
+ }
+
+ public String getFontPath() {
+ return fontPath;
+ }
+
+ public void setFontPath(String fontPath) {
+ this.fontPath = fontPath;
+ markUpdateNeeded();
+ }
+
+ public synchronized String getTexture(TextureKey key) {
+ return textures.get(key);
+ }
+
+ public synchronized void setTexture(TextureKey key, String texturePath) {
+ if (key == null) {
+ throw new IllegalArgumentException("Texture key cannot be null.");
+ }
+ if (texturePath == null) {
+ textures.remove(key);
+ } else {
+ textures.put(key, texturePath);
+ }
+ markUpdateNeeded();
+ }
+
+ boolean isUpdateNeeded() {
+ return updateNeeded;
+ }
+
+ void markUpdateNeeded() {
+ updateNeeded = true;
+ }
+
+ void clearUpdateNeeded() {
+ updateNeeded = false;
+ }
+
+ @Override
+ public synchronized void write(JmeExporter ex) throws IOException {
+ OutputCapsule capsule = ex.getCapsule(this);
+ capsule.write(fontPath, "fontPath", DEFAULT_FONT);
+ String[] keys = new String[textures.size()];
+ int index = 0;
+ for (TextureKey key : textures.keySet()) {
+ keys[index++] = key.name();
+ }
+ capsule.write(keys, "keys", null);
+ capsule.write(textures.values().toArray(new String[0]), "paths", null);
+ }
+
+ @Override
+ public synchronized void read(JmeImporter im) throws IOException {
+ InputCapsule capsule = im.getCapsule(this);
+ fontPath = capsule.readString("fontPath", DEFAULT_FONT);
+ String[] keys = capsule.readStringArray("keys", null);
+ String[] paths = capsule.readStringArray("paths", null);
+ textures.clear();
+ if (keys != null && paths != null) {
+ int count = Math.min(keys.length, paths.length);
+ for (int i = 0; i < count; i++) {
+ textures.put(TextureKey.valueOf(keys[i]), paths[i]);
+ }
+ }
+ markUpdateNeeded();
+ }
+}
diff --git a/jme3-core/src/main/java/com/jme3/input/virtual/VirtualJoystickXboxLayout.java b/jme3-core/src/main/java/com/jme3/input/virtual/VirtualJoystickXboxLayout.java
new file mode 100644
index 0000000000..4b90d40ab4
--- /dev/null
+++ b/jme3-core/src/main/java/com/jme3/input/virtual/VirtualJoystickXboxLayout.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2009-2026 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
+ * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+ * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
+ * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+ * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.jme3.input.virtual;
+
+import com.jme3.input.JoystickAxis;
+import com.jme3.input.JoystickButton;
+import com.jme3.input.virtual.VirtualJoystickTheme.TextureKey;
+
+/**
+ * Default Xbox-like virtual joystick layout.
+ */
+public class VirtualJoystickXboxLayout extends VirtualJoystickLayout {
+
+ public VirtualJoystickXboxLayout() {
+ float leftStickColumn = 0.145f;
+ float dpadColumn = 0.255f;
+ float rightStickColumn = 0.745f;
+ float faceColumn = 0.855f;
+ float leftStickRow = 0.565f;
+ float faceRow = 0.535f;
+ float lowerRow = 0.285f;
+ float faceButtonSize = 0.086f;
+ float faceButtonOffset = 0.09f;
+ float shoulderSize = 0.09f;
+
+ addButtonElement(JoystickButton.BUTTON_XBOX_A, "", faceColumn, faceRow, 0f, -faceButtonOffset,
+ faceButtonSize, TextureKey.BUTTON, TextureKey.BUTTON_A_ICON);
+ addButtonElement(JoystickButton.BUTTON_XBOX_B, "", faceColumn, faceRow, faceButtonOffset, 0f,
+ faceButtonSize, TextureKey.BUTTON, TextureKey.BUTTON_B_ICON);
+ addButtonElement(JoystickButton.BUTTON_XBOX_X, "", faceColumn, faceRow, -faceButtonOffset, 0f,
+ faceButtonSize, TextureKey.BUTTON, TextureKey.BUTTON_X_ICON);
+ addButtonElement(JoystickButton.BUTTON_XBOX_Y, "", faceColumn, faceRow, 0f, faceButtonOffset,
+ faceButtonSize, TextureKey.BUTTON, TextureKey.BUTTON_Y_ICON);
+
+ addButtonElement(JoystickButton.BUTTON_XBOX_LT, "LT", leftStickColumn, 0.94f, shoulderSize, 2f,
+ TextureKey.BUTTON_WIDE);
+ addButtonElement(JoystickButton.BUTTON_XBOX_RT, "RT", faceColumn, 0.94f, shoulderSize, 2f,
+ TextureKey.BUTTON_WIDE);
+ addButtonElement(JoystickButton.BUTTON_XBOX_LB, "LB", leftStickColumn, 0.79f, shoulderSize, 2f,
+ TextureKey.BUTTON_WIDE);
+ addButtonElement(JoystickButton.BUTTON_XBOX_RB, "RB", faceColumn, 0.79f, shoulderSize, 2f,
+ TextureKey.BUTTON_WIDE);
+
+ addButtonElement(JoystickButton.BUTTON_XBOX_BACK, "", 0.44f, 0.06f, 0.078f, 2f,
+ TextureKey.BUTTON_WIDE, TextureKey.BUTTON_BACK_ICON);
+ addButtonElement(JoystickButton.BUTTON_XBOX_START, "", 0.56f, 0.06f, 0.078f, 2f,
+ TextureKey.BUTTON_WIDE, TextureKey.BUTTON_START_ICON);
+ addButtonElement(JoystickButton.BUTTON_XBOX_L3, "L3", leftStickColumn, leftStickRow, -0.145f, -0.07f, 0.065f,
+ TextureKey.BUTTON);
+ addButtonElement(JoystickButton.BUTTON_XBOX_R3, "R3", rightStickColumn, lowerRow, 0.145f, -0.07f, 0.065f,
+ TextureKey.BUTTON);
+
+ addButtonElement(JoystickButton.BUTTON_XBOX_DPAD_UP, "", dpadColumn, lowerRow, 0f, 0.064f, 0.085f,
+ TextureKey.DPAD_UP);
+ addButtonElement(JoystickButton.BUTTON_XBOX_DPAD_DOWN, "", dpadColumn, lowerRow, 0f, -0.064f, 0.085f,
+ TextureKey.DPAD_DOWN);
+ addButtonElement(JoystickButton.BUTTON_XBOX_DPAD_LEFT, "", dpadColumn, lowerRow, -0.064f, 0f, 0.085f,
+ TextureKey.DPAD_LEFT);
+ addButtonElement(JoystickButton.BUTTON_XBOX_DPAD_RIGHT, "", dpadColumn, lowerRow, 0.064f, 0f, 0.085f,
+ TextureKey.DPAD_RIGHT);
+
+ addAxisElement(JoystickAxis.AXIS_XBOX_LEFT_THUMB_STICK_X, JoystickAxis.AXIS_XBOX_LEFT_THUMB_STICK_Y,
+ "", leftStickColumn, leftStickRow, 0.235f, TextureKey.STICK_PAD, TextureKey.STICK_NUB);
+ addAxisElement(JoystickAxis.AXIS_XBOX_RIGHT_THUMB_STICK_X, JoystickAxis.AXIS_XBOX_RIGHT_THUMB_STICK_Y,
+ "", rightStickColumn, lowerRow, 0.235f, TextureKey.STICK_PAD, TextureKey.STICK_NUB);
+ }
+}
diff --git a/jme3-core/src/main/java/com/jme3/system/AppSettings.java b/jme3-core/src/main/java/com/jme3/system/AppSettings.java
index 053f6b022d..2625be0321 100644
--- a/jme3-core/src/main/java/com/jme3/system/AppSettings.java
+++ b/jme3-core/src/main/java/com/jme3/system/AppSettings.java
@@ -249,6 +249,43 @@ public final class AppSettings extends HashMap {
*/
public static final String OPENAL = "OPENAL";
+ /**
+ * Disable the on-screen virtual gamepad entirely.
+ *
+ * @see #setVirtualJoystick(String)
+ */
+ public static final String VIRTUAL_JOYSTICK_DISABLED = "VirtualJoystickDisabled";
+
+ /**
+ * Always display the full virtual gamepad, even on desktop and even when a
+ * hardware gamepad is detected.
+ *
+ * @see #setVirtualJoystick(String)
+ */
+ public static final String VIRTUAL_JOYSTICK_ENABLED = "VirtualJoystickEnabled";
+
+ /**
+ * Display the full virtual gamepad automatically on mobile when no
+ * hardware gamepad is detected.
+ *
+ * @see #setVirtualJoystick(String)
+ */
+ public static final String VIRTUAL_JOYSTICK_AUTO = "VirtualJoystickAuto";
+
+ /**
+ * Use the fixed Xbox-like virtual joystick layout.
+ *
+ * @see #setVirtualJoystickDefaultLayout(String)
+ */
+ public static final String VIRTUAL_JOYSTICK_LAYOUT_XBOX = "Xbox";
+
+ /**
+ * Use a virtual joystick layout that only displays bound controls.
+ *
+ * @see #setVirtualJoystickDefaultLayout(String)
+ */
+ public static final String VIRTUAL_JOYSTICK_LAYOUT_DYNAMIC = "Dynamic";
+
/**
* Use the Android MediaPlayer / SoundPool based renderer for Android audio capabilities.
@@ -306,27 +343,27 @@ public final class AppSettings extends HashMap {
public static final String JOAL = "JOAL";
/**
- * Map gamepads to Xbox-like layout.
+ * Map joysticks to Xbox-like layout.
*/
public static final String JOYSTICKS_XBOX_MAPPER = "JOYSTICKS_XBOX_MAPPER";
/**
- * Map gamepads to an Xbox-like layout, with fallback to raw if the gamepad is not recognized.
+ * Map joysticks to an Xbox-like layout, with fallback to raw if the joystick is not recognized.
*/
public static final String JOYSTICKS_XBOX_WITH_FALLBACK_MAPPER = "JOYSTICKS_XBOX_WITH_FALLBACK_MAPPER";
/**
- * Map gamepads to an Xbox-like layout using the legacy jME input
+ * Map joysticks to an Xbox-like layout using the legacy jME input
*/
public static final String JOYSTICKS_XBOX_LEGACY_MAPPER = "JOYSTICKS_XBOX_LEGACY_MAPPER";
/**
- * Map gamepads using the legacy jME mapper and input.
+ * Map joysticks using the legacy jME mapper and input.
*/
public static final String JOYSTICKS_LEGACY_MAPPER = "JOYSTICKS_LEGACY_MAPPER";
/**
- * Don't map gamepads, use raw events instead (ie. bring your own mapper)
+ * Don't map joysticks, use raw events instead (ie. bring your own mapper)
*/
public static final String JOYSTICKS_RAW_MAPPER = "JOYSTICKS_RAW_MAPPER";
@@ -346,7 +383,7 @@ public final class AppSettings extends HashMap {
defaults.put("Title", JmeVersion.FULL_NAME);
defaults.put("Renderer", ANGLE_GLES3);
defaults.put("AudioRenderer", OPENAL);
- defaults.put("DisableJoysticks", true);
+ defaults.put("DisableJoysticks", false);
defaults.put("UseInput", true);
defaults.put("VSync", true);
defaults.put("FrameRate", -1);
@@ -365,6 +402,9 @@ public final class AppSettings extends HashMap {
defaults.put("JoysticksAxisJitterThreshold", 0.0001f);
defaults.put("SDLGameControllerDBResourcePath", "");
defaults.put("OnDeviceJoystickRumble", false);
+ defaults.put("UseAndroidSensorJoystick", false);
+ defaults.put("VirtualJoystick", VIRTUAL_JOYSTICK_AUTO);
+ defaults.put("VirtualJoystickDefaultLayout", VIRTUAL_JOYSTICK_LAYOUT_DYNAMIC);
// defaults.put("Icons", null);
}
@@ -810,8 +850,9 @@ public void setUseInput(boolean use) {
/**
* @param use If true, the application will initialize and use joystick
- * input. Set to false if no joystick input is desired.
- * (Default: false)
+ * input, including hardware gamepads when available. Set to false if no
+ * joystick input is desired.
+ * (Default: true)
*/
public void setUseJoysticks(boolean use) {
putBoolean("DisableJoysticks", !use);
@@ -826,6 +867,61 @@ public void setOnDeviceJoystickRumble(boolean enabled) {
putBoolean("OnDeviceJoystickRumble", enabled);
}
+ /**
+ * @param use If true, Android exposes device orientation sensors as a
+ * joystick. This is disabled by default because the sensor joystick reports
+ * movement from device rotation and can conflict with gamepad or virtual
+ * joystick mappings.
+ * (Default: false)
+ */
+ public void setUseAndroidSensorJoystick(boolean use) {
+ putBoolean("UseAndroidSensorJoystick", use);
+ }
+
+ /**
+ * Sets the on-screen virtual joystick mode.
+ *
+ * The default mode is {@link #VIRTUAL_JOYSTICK_AUTO}, which displays the
+ * virtual joystick on mobile only when joystick mappings exist and no
+ * hardware gamepad is connected.
+ *
+ *
{@link #VIRTUAL_JOYSTICK_DISABLED}: disable the virtual gamepad
+ * entirely.
+ *
{@link #VIRTUAL_JOYSTICK_ENABLED}: always display the virtual
+ * joystick, even on desktop and even when a hardware gamepad is detected.
+ *
{@link #VIRTUAL_JOYSTICK_AUTO}: display the full virtual gamepad
+ * automatically on mobile when joystick mappings exist and no hardware
+ * gamepad is detected.
+ *
+ *
+ * @param mode one of {@link #VIRTUAL_JOYSTICK_DISABLED},
+ * {@link #VIRTUAL_JOYSTICK_ENABLED}, or {@link #VIRTUAL_JOYSTICK_AUTO}
+ */
+ public void setVirtualJoystick(String mode) {
+ if (!VIRTUAL_JOYSTICK_DISABLED.equals(mode)
+ && !VIRTUAL_JOYSTICK_ENABLED.equals(mode)
+ && !VIRTUAL_JOYSTICK_AUTO.equals(mode)) {
+ throw new IllegalArgumentException("Unsupported virtual joystick mode: " + mode);
+ }
+ putString("VirtualJoystick", mode);
+ }
+
+ /**
+ * Sets the default virtual joystick layout used by supporting backends.
+ *
+ * @param layout one of {@link #VIRTUAL_JOYSTICK_LAYOUT_XBOX} or
+ * {@link #VIRTUAL_JOYSTICK_LAYOUT_DYNAMIC}
+ */
+ public void setVirtualJoystickDefaultLayout(String layout) {
+ if (VIRTUAL_JOYSTICK_LAYOUT_XBOX.equalsIgnoreCase(layout)) {
+ putString("VirtualJoystickDefaultLayout", VIRTUAL_JOYSTICK_LAYOUT_XBOX);
+ } else if (VIRTUAL_JOYSTICK_LAYOUT_DYNAMIC.equalsIgnoreCase(layout)) {
+ putString("VirtualJoystickDefaultLayout", VIRTUAL_JOYSTICK_LAYOUT_DYNAMIC);
+ } else {
+ throw new IllegalArgumentException("Unsupported virtual joystick layout: " + layout);
+ }
+ }
+
/**
* Set the graphics renderer to use, one of:
*
@@ -1306,6 +1402,79 @@ public boolean isOnDeviceJoystickRumble() {
return getBoolean("OnDeviceJoystickRumble");
}
+ /**
+ * Get the Android sensor joystick state.
+ *
+ * @return true to expose Android device orientation sensors as a joystick
+ * @see #setUseAndroidSensorJoystick(boolean)
+ */
+ public boolean useAndroidSensorJoystick() {
+ return getBoolean("UseAndroidSensorJoystick");
+ }
+
+ /**
+ * Get whether supporting backends should expose an on-screen virtual joystick.
+ *
+ * @return true to expose the on-screen virtual joystick
+ * @see #setVirtualJoystick(String)
+ */
+ public boolean isVirtualJoystick() {
+ return !VIRTUAL_JOYSTICK_DISABLED.equals(getVirtualJoystickMode());
+ }
+
+ /**
+ * Get whether supporting backends should expose an on-screen virtual joystick.
+ *
+ * @return true to expose the on-screen virtual joystick
+ * @see #setVirtualJoystick(String)
+ */
+ public boolean isVirtualJoystickEnabled() {
+ return isVirtualJoystick();
+ }
+
+ /**
+ * Gets the on-screen virtual joystick mode.
+ *
+ * @return one of {@link #VIRTUAL_JOYSTICK_DISABLED},
+ * {@link #VIRTUAL_JOYSTICK_ENABLED}, or {@link #VIRTUAL_JOYSTICK_AUTO}
+ */
+ public String getVirtualJoystickMode() {
+ Object value = get("VirtualJoystick");
+ if (value instanceof Boolean) {
+ return (Boolean) value ? VIRTUAL_JOYSTICK_ENABLED : VIRTUAL_JOYSTICK_DISABLED;
+ }
+ if (value instanceof String) {
+ String mode = (String) value;
+ if (VIRTUAL_JOYSTICK_DISABLED.equalsIgnoreCase(mode)) {
+ return VIRTUAL_JOYSTICK_DISABLED;
+ } else if (VIRTUAL_JOYSTICK_ENABLED.equalsIgnoreCase(mode)) {
+ return VIRTUAL_JOYSTICK_ENABLED;
+ } else if (VIRTUAL_JOYSTICK_AUTO.equalsIgnoreCase(mode)) {
+ return VIRTUAL_JOYSTICK_AUTO;
+ }
+ }
+ return VIRTUAL_JOYSTICK_AUTO;
+ }
+
+ /**
+ * Gets the default virtual joystick layout.
+ *
+ * @return one of {@link #VIRTUAL_JOYSTICK_LAYOUT_XBOX} or
+ * {@link #VIRTUAL_JOYSTICK_LAYOUT_DYNAMIC}
+ */
+ public String getVirtualJoystickDefaultLayout() {
+ Object value = get("VirtualJoystickDefaultLayout");
+ if (value instanceof String) {
+ String layout = (String) value;
+ if (VIRTUAL_JOYSTICK_LAYOUT_XBOX.equalsIgnoreCase(layout)) {
+ return VIRTUAL_JOYSTICK_LAYOUT_XBOX;
+ } else if (VIRTUAL_JOYSTICK_LAYOUT_DYNAMIC.equalsIgnoreCase(layout)) {
+ return VIRTUAL_JOYSTICK_LAYOUT_DYNAMIC;
+ }
+ }
+ return VIRTUAL_JOYSTICK_LAYOUT_DYNAMIC;
+ }
+
/**
* Get the audio renderer
*
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/button_circle.png b/jme3-core/src/main/resources/Common/VirtualJoystick/button_circle.png
new file mode 100644
index 0000000000..75d8fefcce
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/button_circle.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/button_circle_wide.png b/jme3-core/src/main/resources/Common/VirtualJoystick/button_circle_wide.png
new file mode 100644
index 0000000000..92dedaa905
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/button_circle_wide.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/credits.txt b/jme3-core/src/main/resources/Common/VirtualJoystick/credits.txt
new file mode 100644
index 0000000000..f55ed57810
--- /dev/null
+++ b/jme3-core/src/main/resources/Common/VirtualJoystick/credits.txt
@@ -0,0 +1 @@
+https://kenney.nl/assets/mobile-controls
\ No newline at end of file
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/dpad_element_east.png b/jme3-core/src/main/resources/Common/VirtualJoystick/dpad_element_east.png
new file mode 100644
index 0000000000..d0519ecc69
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/dpad_element_east.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/dpad_element_north.png b/jme3-core/src/main/resources/Common/VirtualJoystick/dpad_element_north.png
new file mode 100644
index 0000000000..885aa2aa55
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/dpad_element_north.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/dpad_element_south.png b/jme3-core/src/main/resources/Common/VirtualJoystick/dpad_element_south.png
new file mode 100644
index 0000000000..5ff17b32e0
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/dpad_element_south.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/dpad_element_west.png b/jme3-core/src/main/resources/Common/VirtualJoystick/dpad_element_west.png
new file mode 100644
index 0000000000..920a0ddb33
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/dpad_element_west.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/icon_back.png b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_back.png
new file mode 100644
index 0000000000..706eb8000f
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_back.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/icon_button_a.png b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_button_a.png
new file mode 100644
index 0000000000..901a44a4f3
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_button_a.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/icon_button_b.png b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_button_b.png
new file mode 100644
index 0000000000..e134dac4cf
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_button_b.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/icon_button_x.png b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_button_x.png
new file mode 100644
index 0000000000..ee5c55193b
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_button_x.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/icon_button_y.png b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_button_y.png
new file mode 100644
index 0000000000..a6d3ab4609
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_button_y.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/icon_dpad.png b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_dpad.png
new file mode 100644
index 0000000000..a95cdb7eec
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_dpad.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/icon_joystick.png b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_joystick.png
new file mode 100644
index 0000000000..404345fb89
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_joystick.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/icon_menu.png b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_menu.png
new file mode 100644
index 0000000000..56c4193c11
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_menu.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/icon_star.png b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_star.png
new file mode 100644
index 0000000000..ab2f6ad86a
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/icon_star.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/joystick_circle_nub_a.png b/jme3-core/src/main/resources/Common/VirtualJoystick/joystick_circle_nub_a.png
new file mode 100644
index 0000000000..e2cfc1b874
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/joystick_circle_nub_a.png differ
diff --git a/jme3-core/src/main/resources/Common/VirtualJoystick/joystick_circle_pad_a.png b/jme3-core/src/main/resources/Common/VirtualJoystick/joystick_circle_pad_a.png
new file mode 100644
index 0000000000..74e2c93ca8
Binary files /dev/null and b/jme3-core/src/main/resources/Common/VirtualJoystick/joystick_circle_pad_a.png differ
diff --git a/jme3-examples/build.gradle b/jme3-examples/build.gradle
index ec06ef429d..2017ffb303 100644
--- a/jme3-examples/build.gradle
+++ b/jme3-examples/build.gradle
@@ -25,6 +25,7 @@ tasks.register('generateTestChooserClassList') {
group = 'build'
description = 'Generates a resource list of jme3test classes for TestChooserCli fallback discovery.'
dependsOn 'compileJava'
+ inputs.files(sourceSets.main.output.classesDirs)
outputs.files(testChooserClassListFile, testChooserReachabilityMetadataFile, testChooserReflectionConfigFile)
doLast {
@@ -109,7 +110,7 @@ task run(dependsOn: 'build', type:JavaExec) {
}
-task runExamples(dependsOn: 'build', type: JavaExec) {
+task runExamples(dependsOn: 'classes', type: JavaExec) {
classpath = sourceSets.main.runtimeClasspath
if (System.getProperty('os.name').toLowerCase().contains('mac')) {
jvmArgs '-XstartOnFirstThread'
diff --git a/jme3-examples/src/main/java/jme3test/input/TestJoystick.java b/jme3-examples/src/main/java/jme3test/input/TestJoystick.java
index 4a517ea240..811387fd99 100644
--- a/jme3-examples/src/main/java/jme3test/input/TestJoystick.java
+++ b/jme3-examples/src/main/java/jme3test/input/TestJoystick.java
@@ -30,6 +30,7 @@
import com.jme3.scene.shape.Quad;
import com.jme3.system.AppSettings;
import com.jme3.system.JmeSystem;
+import com.jme3.system.Platform;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
@@ -49,11 +50,25 @@ public class TestJoystick extends SimpleApplication {
public static void main(String[] args){
TestJoystick app = new TestJoystick();
AppSettings settings = new AppSettings(true);
+ configureSettings(settings);
+ app.setSettings(settings);
+ app.start();
+ }
+
+ public static void configureSettings(AppSettings settings) {
settings.setJoysticksMapper(AppSettings.JOYSTICKS_XBOX_MAPPER);
settings.setUseJoysticks(true);
+ settings.setVirtualJoystick(defaultVirtualJoystickMode());
+ settings.setVirtualJoystickDefaultLayout(AppSettings.VIRTUAL_JOYSTICK_LAYOUT_XBOX);
settings.setX11PlatformPreferred(true);
- app.setSettings(settings);
- app.start();
+ }
+
+ private static String defaultVirtualJoystickMode() {
+ Platform.Os os = JmeSystem.getPlatform().getOs();
+ if (os == Platform.Os.Android || os == Platform.Os.iOS) {
+ return AppSettings.VIRTUAL_JOYSTICK_ENABLED;
+ }
+ return AppSettings.VIRTUAL_JOYSTICK_AUTO;
}
@Override
diff --git a/jme3-examples/src/main/java/jme3test/input/TestVirtualJoystick.java b/jme3-examples/src/main/java/jme3test/input/TestVirtualJoystick.java
new file mode 100644
index 0000000000..b8a00613c2
--- /dev/null
+++ b/jme3-examples/src/main/java/jme3test/input/TestVirtualJoystick.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright (c) 2009-2026 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
+ * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+ * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
+ * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+ * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3test.input;
+
+import com.jme3.app.SimpleApplication;
+import com.jme3.input.Joystick;
+import com.jme3.input.JoystickButton;
+import com.jme3.input.KeyInput;
+import com.jme3.input.controls.ActionListener;
+import com.jme3.input.controls.KeyTrigger;
+import com.jme3.input.virtual.VirtualJoystick;
+import com.jme3.input.virtual.VirtualJoystickDynamicLayout;
+import com.jme3.input.virtual.VirtualJoystickXboxLayout;
+import com.jme3.light.AmbientLight;
+import com.jme3.light.DirectionalLight;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Vector3f;
+import com.jme3.scene.Spatial;
+import com.jme3.system.AppSettings;
+import com.jme3.system.JmeSystem;
+import com.jme3.system.Platform;
+import com.jme3.util.SkyFactory;
+
+/**
+ * Manual test for the on-screen virtual joystick.
+ */
+public class TestVirtualJoystick extends SimpleApplication {
+
+ private static final String TOGGLE_LAYOUT = "ToggleVirtualJoystickLayout";
+
+ private boolean dynamicLayout;
+
+ public static void main(String[] args) {
+ TestVirtualJoystick app = new TestVirtualJoystick();
+ AppSettings settings = new AppSettings(true);
+ configureSettings(settings);
+ app.setSettings(settings);
+ app.start();
+ }
+
+ public static void configureSettings(AppSettings settings) {
+ settings.setUseJoysticks(true);
+ settings.setJoysticksMapper(AppSettings.JOYSTICKS_XBOX_MAPPER);
+ settings.setVirtualJoystick(defaultVirtualJoystickMode());
+ }
+
+ private static String defaultVirtualJoystickMode() {
+ Platform.Os os = JmeSystem.getPlatform().getOs();
+ if (os == Platform.Os.Android || os == Platform.Os.iOS) {
+ return AppSettings.VIRTUAL_JOYSTICK_AUTO;
+ }
+ return AppSettings.VIRTUAL_JOYSTICK_ENABLED;
+ }
+
+ @Override
+ public void simpleInitApp() {
+ setDisplayStatView(false);
+ setDisplayFps(false);
+
+ cam.setLocation(new Vector3f(42f, 18f, 18f));
+ cam.lookAt(new Vector3f(-18f, 4f, -10f), Vector3f.UNIT_Y);
+ cam.setFrustumPerspective(60f, cam.getAspect(), 0.1f, 500f);
+
+ flyCam.setMoveSpeed(18f);
+ flyCam.setRotationSpeed(2f);
+ flyCam.setDragToRotate(true);
+ inputManager.setCursorVisible(true);
+ setupLayoutToggle();
+
+ AmbientLight ambient = new AmbientLight();
+ ambient.setColor(new ColorRGBA(0.35f, 0.35f, 0.35f, 1f));
+ rootNode.addLight(ambient);
+
+ DirectionalLight sun = new DirectionalLight();
+ sun.setDirection(new Vector3f(-0.4f, -0.75f, -0.3f).normalizeLocal());
+ sun.setColor(ColorRGBA.White.mult(1.1f));
+ rootNode.addLight(sun);
+
+ buildScene();
+ }
+
+ private void setupLayoutToggle() {
+ inputManager.addMapping(TOGGLE_LAYOUT, new KeyTrigger(KeyInput.KEY_L));
+ Joystick[] joysticks = inputManager.getJoysticks();
+ if (joysticks != null) {
+ for (Joystick joystick : joysticks) {
+ JoystickButton start = joystick.getButton(JoystickButton.BUTTON_XBOX_START);
+ if (start != null) {
+ start.assignButton(TOGGLE_LAYOUT);
+ }
+ }
+ }
+ inputManager.addListener(layoutListener, TOGGLE_LAYOUT);
+ }
+
+ private final ActionListener layoutListener = (name, isPressed, tpf) -> {
+ if (!TOGGLE_LAYOUT.equals(name) || !isPressed) {
+ return;
+ }
+ dynamicLayout = !dynamicLayout;
+ Joystick[] joysticks = inputManager.getJoysticks();
+ if (joysticks == null) {
+ return;
+ }
+ for (Joystick joystick : joysticks) {
+ if (joystick instanceof VirtualJoystick) {
+ ((VirtualJoystick) joystick).setLayout(dynamicLayout
+ ? new VirtualJoystickDynamicLayout(true)
+ : new VirtualJoystickXboxLayout());
+ }
+ }
+ };
+
+ private void buildScene() {
+ Spatial scene = assetManager.loadModel("BlenderParity/scene.glb");
+ rootNode.attachChild(scene);
+
+ Spatial sky = SkyFactory.createSky(assetManager, "Textures/Sky/Path.hdr", SkyFactory.EnvMapType.EquirectMap);
+ rootNode.attachChild(sky);
+ }
+}
diff --git a/jme3-ios-examples/src/main/java/jme3test/ios/IosTestChooser.java b/jme3-ios-examples/src/main/java/jme3test/ios/IosTestChooser.java
index 2e6acd5ad5..8d41ea518b 100644
--- a/jme3-ios-examples/src/main/java/jme3test/ios/IosTestChooser.java
+++ b/jme3-ios-examples/src/main/java/jme3test/ios/IosTestChooser.java
@@ -1,6 +1,10 @@
package jme3test.ios;
+import com.jme3.app.DebugKeysAppState;
import com.jme3.app.SimpleApplication;
+import com.jme3.app.StatsAppState;
+import com.jme3.app.state.ConstantVerifierState;
+import com.jme3.audio.AudioListenerState;
import com.jme3.font.BitmapText;
import com.jme3.input.KeyInput;
import com.jme3.input.MouseInput;
@@ -45,12 +49,19 @@ public final class IosTestChooser extends SimpleApplication implements ActionLis
private RawInputListenerAdapter keyboardListener;
private boolean searchInputActive;
+ public IosTestChooser() {
+ super(new StatsAppState(),
+ new AudioListenerState(),
+ new DebugKeysAppState(),
+ new ConstantVerifierState());
+ }
+
@Override
public void simpleInitApp() {
setDisplayStatView(false);
setDisplayFps(false);
if (flyCam != null) {
- flyCam.setEnabled(false);
+ flyCam.unregisterInput();
}
viewPort.setBackgroundColor(BACKGROUND_COLOR);
buttonMaterial = material(EXAMPLE_BUTTON_COLOR);
diff --git a/jme3-ios-examples/src/main/java/jme3test/ios/IosTestChooserLauncher.java b/jme3-ios-examples/src/main/java/jme3test/ios/IosTestChooserLauncher.java
index e85aa77450..568d52dcac 100644
--- a/jme3-ios-examples/src/main/java/jme3test/ios/IosTestChooserLauncher.java
+++ b/jme3-ios-examples/src/main/java/jme3test/ios/IosTestChooserLauncher.java
@@ -218,6 +218,16 @@ private static void configureIosSettings(Application application) {
AppSettings settings = new AppSettings(true);
settings.setUseJoysticks(true);
settings.setOnDeviceJoystickRumble(true);
+ try {
+ java.lang.reflect.Method method = application.getClass().getMethod("configureSettings", AppSettings.class);
+ Object target = java.lang.reflect.Modifier.isStatic(method.getModifiers()) ? null : application;
+ method.invoke(target, settings);
+ } catch (NoSuchMethodException ignored) {
+ // Most examples rely on default settings.
+ } catch (IllegalAccessException | InvocationTargetException exception) {
+ throw new IllegalStateException("Could not configure iOS settings for "
+ + application.getClass().getName(), exception);
+ }
application.setSettings(settings);
}
diff --git a/jme3-ios/src/main/java/com/jme3/input/ios/IosInputHandler.java b/jme3-ios/src/main/java/com/jme3/input/ios/IosInputHandler.java
index 74d31906a6..a5d07ec7a9 100644
--- a/jme3-ios/src/main/java/com/jme3/input/ios/IosInputHandler.java
+++ b/jme3-ios/src/main/java/com/jme3/input/ios/IosInputHandler.java
@@ -241,13 +241,19 @@ void dispatchBridgeEvent(int[] intData, float[] floatData, long time) {
int type = intData[0];
switch (type) {
case LibJGLIOSInputBridge.EVENT_TOUCH_DOWN:
- injectTouchDown(intData[1], time, nativeX(floatData[0]), nativeY(floatData[1]));
+ if (!IosJoyInput.dispatchPointerDown(intData[1], nativeX(floatData[0]), touchY(floatData[1]), time)) {
+ injectTouchDown(intData[1], time, nativeX(floatData[0]), nativeY(floatData[1]));
+ }
break;
case LibJGLIOSInputBridge.EVENT_TOUCH_UP:
- injectTouchUp(intData[1], time, nativeX(floatData[0]), nativeY(floatData[1]));
+ if (!IosJoyInput.dispatchPointerUp(intData[1], nativeX(floatData[0]), touchY(floatData[1]), time)) {
+ injectTouchUp(intData[1], time, nativeX(floatData[0]), nativeY(floatData[1]));
+ }
break;
case LibJGLIOSInputBridge.EVENT_TOUCH_MOVE:
- injectTouchMove(intData[1], time, nativeX(floatData[0]), nativeY(floatData[1]));
+ if (!IosJoyInput.dispatchPointerMove(intData[1], nativeX(floatData[0]), touchY(floatData[1]), time)) {
+ injectTouchMove(intData[1], time, nativeX(floatData[0]), nativeY(floatData[1]));
+ }
break;
case LibJGLIOSInputBridge.EVENT_MOUSE_BUTTON:
MouseButtonEvent button = new MouseButtonEvent(
@@ -270,6 +276,7 @@ void dispatchBridgeEvent(int[] intData, float[] floatData, long time) {
addEvent(motion);
break;
case LibJGLIOSInputBridge.EVENT_KEY:
+ IosJoyInput.dispatchKeyboardInput();
KeyInputEvent key = new KeyInputEvent(
IosSdlKeyMap.toJmeKeyCode(intData[1]),
'\0',
@@ -282,6 +289,7 @@ void dispatchBridgeEvent(int[] intData, float[] floatData, long time) {
if (!isSingleCharTextInput(intData[1])) {
break;
}
+ IosJoyInput.dispatchKeyboardInput();
KeyInputEvent text = new KeyInputEvent(
KeyInput.KEY_UNKNOWN,
(char) intData[1],
@@ -327,6 +335,10 @@ private float mouseY(float value) {
return mouseEventsInvertY ? invertY(y) : y;
}
+ private float touchY(float value) {
+ return invertY(nativeY(value));
+ }
+
private float mouseDeltaX(float value) {
float deltaX = nativeDeltaX(value);
return mouseEventsInvertX ? -deltaX : deltaX;
diff --git a/jme3-ios/src/main/java/com/jme3/input/ios/IosJoyInput.java b/jme3-ios/src/main/java/com/jme3/input/ios/IosJoyInput.java
index 227ddc0fd1..0f5677d7d2 100644
--- a/jme3-ios/src/main/java/com/jme3/input/ios/IosJoyInput.java
+++ b/jme3-ios/src/main/java/com/jme3/input/ios/IosJoyInput.java
@@ -11,10 +11,13 @@
import com.jme3.input.RawInputListener;
import com.jme3.input.event.JoyAxisEvent;
import com.jme3.input.event.JoyButtonEvent;
+import com.jme3.input.virtual.VirtualJoystick;
import com.jme3.math.FastMath;
import com.jme3.system.AppSettings;
import com.jme3.system.JmeSystem;
+import java.util.ArrayList;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import org.ngengine.libjglios.core.LibJGLIOSInputBridge;
import static org.ngengine.libjglios.sdl3.SDL3.SDL_GAMEPAD_AXIS_COUNT;
@@ -65,14 +68,18 @@
import static org.ngengine.libjglios.sdl3.SDL3.SDL_SetJoystickEventsEnabled;
public final class IosJoyInput implements JoyInput {
- private static IosJoyInput active;
+ private static volatile IosJoyInput active;
private static final int POV_X_AXIS_ID = 0x4000;
- private static final int POV_Y_AXIS_ID = 0x4001;
private final Map joysticks = new HashMap<>();
private RawInputListener listener;
private InputManager inputManager;
private boolean initialized;
private boolean onDeviceJoystickRumble;
+ private String virtualJoystickMode = AppSettings.VIRTUAL_JOYSTICK_AUTO;
+ private String virtualJoystickDefaultLayout = AppSettings.VIRTUAL_JOYSTICK_LAYOUT_DYNAMIC;
+ private boolean useJoysticks = true;
+ private boolean keyboardSuppressedAutoJoystick;
+ private volatile VirtualJoystick virtualJoystick;
public static void dispatchNativeEvent(int[] intData, float[] floatData) {
IosJoyInput joyInput = active;
@@ -81,6 +88,28 @@ public static void dispatchNativeEvent(int[] intData, float[] floatData) {
}
}
+ public static boolean dispatchPointerDown(int pointerId, float x, float y, long time) {
+ IosJoyInput joyInput = active;
+ return joyInput != null && joyInput.onPointerDown(pointerId, x, y, time);
+ }
+
+ public static boolean dispatchPointerMove(int pointerId, float x, float y, long time) {
+ IosJoyInput joyInput = active;
+ return joyInput != null && joyInput.onPointerMove(pointerId, x, y, time);
+ }
+
+ public static boolean dispatchPointerUp(int pointerId, float x, float y, long time) {
+ IosJoyInput joyInput = active;
+ return joyInput != null && joyInput.onPointerUp(pointerId, x, y, time);
+ }
+
+ public static void dispatchKeyboardInput() {
+ IosJoyInput joyInput = active;
+ if (joyInput != null) {
+ joyInput.onKeyboardInput();
+ }
+ }
+
@Override
public void initialize() {
initialized = true;
@@ -91,6 +120,11 @@ public void initialize() {
@Override
public void update() {
+ VirtualJoystick joystick = virtualJoystick;
+ if (joystick != null) {
+ updateVirtualJoystickAutoVisibility();
+ joystick.dispatchEvents(listener);
+ }
}
@Override
@@ -145,16 +179,49 @@ public void stopJoyRumble(int joyId) {
public void loadSettings(AppSettings settings) {
onDeviceJoystickRumble = settings.isOnDeviceJoystickRumble();
+ virtualJoystickMode = settings.getVirtualJoystickMode();
+ virtualJoystickDefaultLayout = settings.getVirtualJoystickDefaultLayout();
+ useJoysticks = settings.useJoysticks();
}
@Override
public Joystick[] loadJoysticks(InputManager inputManager) {
this.inputManager = inputManager;
refreshJoysticks(false);
+ if (shouldCreateVirtualJoystick()) {
+ virtualJoystick = new VirtualJoystick(inputManager, this, nextVirtualJoyId());
+ virtualJoystick.setLayout(VirtualJoystick.createLayout(virtualJoystickDefaultLayout));
+ virtualJoystick.setEnabled(false);
+ updateVirtualJoystickAutoVisibility();
+ } else {
+ virtualJoystick = null;
+ }
drainPendingEvents();
return currentJoysticks();
}
+ private boolean onPointerDown(int pointerId, float x, float y, long time) {
+ VirtualJoystick joystick = virtualJoystick;
+ return joystick != null && joystick.onPointerDown(pointerId, x, y, time);
+ }
+
+ private boolean onPointerMove(int pointerId, float x, float y, long time) {
+ VirtualJoystick joystick = virtualJoystick;
+ return joystick != null && joystick.onPointerMove(pointerId, x, y, time);
+ }
+
+ private boolean onPointerUp(int pointerId, float x, float y, long time) {
+ VirtualJoystick joystick = virtualJoystick;
+ return joystick != null && joystick.onPointerUp(pointerId, x, y, time);
+ }
+
+ private void onKeyboardInput() {
+ if (AppSettings.VIRTUAL_JOYSTICK_AUTO.equals(virtualJoystickMode)) {
+ keyboardSuppressedAutoJoystick = true;
+ updateVirtualJoystickAutoVisibility();
+ }
+ }
+
private void drainPendingEvents() {
int[] intData = new int[4];
float[] floatData = new float[4];
@@ -197,9 +264,11 @@ private void handleNativeEvent(int[] intData, float[] floatData) {
return;
}
JoystickButton button = joystick.getButtonById(intData[2]);
+ boolean pressed = intData[3] != 0;
if (button != null && listener != null) {
- listener.onJoyButtonEvent(new JoyButtonEvent(button, intData[3] != 0));
+ listener.onJoyButtonEvent(new JoyButtonEvent(button, pressed));
}
+ joystick.updateDpadPov(intData[2], pressed, listener);
}
}
@@ -265,6 +334,7 @@ private IosJoystick connect(IosJoystick joystick, boolean fireConnectionEvent) {
joysticks.put(joystick.getJoyId(), joystick);
try {
if (inputManager != null) {
+ updateVirtualJoystickAutoVisibility();
inputManager.setJoysticks(currentJoysticks());
if (fireConnectionEvent) {
inputManager.fireJoystickConnectedEvent(joystick);
@@ -282,14 +352,48 @@ private void disconnect(int id) {
if (joystick != null) {
joystick.close();
if (inputManager != null) {
+ updateVirtualJoystickAutoVisibility();
inputManager.setJoysticks(currentJoysticks());
inputManager.fireJoystickDisconnectedEvent(joystick);
}
}
}
+ private boolean shouldCreateVirtualJoystick() {
+ return useJoysticks
+ && !AppSettings.VIRTUAL_JOYSTICK_DISABLED.equals(virtualJoystickMode);
+ }
+
+ private void updateVirtualJoystickAutoVisibility() {
+ if (virtualJoystick == null) {
+ return;
+ }
+ boolean wasEnabled = virtualJoystick.isEnabled();
+ boolean active = AppSettings.VIRTUAL_JOYSTICK_ENABLED.equals(virtualJoystickMode)
+ || (AppSettings.VIRTUAL_JOYSTICK_AUTO.equals(virtualJoystickMode)
+ && joysticks.isEmpty()
+ && !keyboardSuppressedAutoJoystick
+ && virtualJoystick.hasInputBindings());
+ if (wasEnabled != active) {
+ virtualJoystick.setEnabled(active);
+ }
+ }
+
private Joystick[] currentJoysticks() {
- return joysticks.values().toArray(new Joystick[0]);
+ List current = new ArrayList<>(joysticks.values());
+ VirtualJoystick joystick = virtualJoystick;
+ if (joystick != null) {
+ current.add(joystick);
+ }
+ return current.toArray(new Joystick[0]);
+ }
+
+ private int nextVirtualJoyId() {
+ int id = 0;
+ for (Integer joystickId : joysticks.keySet()) {
+ id = Math.max(id, joystickId + 1);
+ }
+ return id;
}
private static String displayName(String sdlName, String fallback, int id) {
@@ -321,7 +425,11 @@ private static final class IosJoystick extends AbstractJoystick {
private JoystickAxis xAxis;
private JoystickAxis yAxis;
private JoystickAxis povXAxis;
- private JoystickAxis povYAxis;
+ private boolean dpadUp;
+ private boolean dpadDown;
+ private boolean dpadLeft;
+ private boolean dpadRight;
+ private float povXValue;
IosJoystick(InputManager inputManager, JoyInput joyInput, int id, boolean gamepad, long gamepadHandle,
long joystickHandle, String name, int axisCount, int buttonCount) {
@@ -405,10 +513,6 @@ private void addPovAxes(InputManager inputManager) {
JoystickAxis povX = new DefaultJoystickAxis(inputManager, this, POV_X_AXIS_ID, JoystickAxis.POV_X,
JoystickAxis.POV_X, true, false, 0f);
addAxis(POV_X_AXIS_ID, povX);
-
- JoystickAxis povY = new DefaultJoystickAxis(inputManager, this, POV_Y_AXIS_ID, JoystickAxis.POV_Y,
- JoystickAxis.POV_Y, true, false, 0f);
- addAxis(POV_Y_AXIS_ID, povY);
}
JoystickAxis getAxisById(int id) {
@@ -436,7 +540,7 @@ public JoystickAxis getPovXAxis() {
@Override
public JoystickAxis getPovYAxis() {
- return povYAxis;
+ return null;
}
private void addAxis(int index, JoystickAxis axis) {
@@ -452,14 +556,39 @@ private void addAxis(int index, JoystickAxis axis) {
case POV_X_AXIS_ID:
povXAxis = axis;
break;
- case POV_Y_AXIS_ID:
- povYAxis = axis;
- break;
default:
break;
}
}
+ private void updateDpadPov(int buttonId, boolean pressed, RawInputListener listener) {
+ if (!gamepad) {
+ return;
+ }
+ switch (buttonId) {
+ case SDL_GAMEPAD_BUTTON_DPAD_UP:
+ dpadUp = pressed;
+ break;
+ case SDL_GAMEPAD_BUTTON_DPAD_DOWN:
+ dpadDown = pressed;
+ break;
+ case SDL_GAMEPAD_BUTTON_DPAD_LEFT:
+ dpadLeft = pressed;
+ break;
+ case SDL_GAMEPAD_BUTTON_DPAD_RIGHT:
+ dpadRight = pressed;
+ break;
+ default:
+ return;
+ }
+
+ float nextPovX = (dpadRight ? 1f : 0f) + (dpadLeft ? -1f : 0f);
+ if (listener != null && povXAxis != null && povXValue != nextPovX) {
+ listener.onJoyAxisEvent(new JoyAxisEvent(povXAxis, nextPovX, nextPovX));
+ }
+ povXValue = nextPovX;
+ }
+
protected void addButton(JoystickButton button) {
buttons.put(button.getButtonId(), button);
super.addButton(button);
diff --git a/jme3-lwjgl3/src/main/java/com/jme3/input/lwjgl/SdlJoystickInput.java b/jme3-lwjgl3/src/main/java/com/jme3/input/lwjgl/SdlJoystickInput.java
index f09acc452a..dac51d3159 100644
--- a/jme3-lwjgl3/src/main/java/com/jme3/input/lwjgl/SdlJoystickInput.java
+++ b/jme3-lwjgl3/src/main/java/com/jme3/input/lwjgl/SdlJoystickInput.java
@@ -3,11 +3,14 @@
import com.jme3.input.*;
import com.jme3.input.event.JoyAxisEvent;
import com.jme3.input.event.JoyButtonEvent;
+import com.jme3.input.virtual.VirtualJoystick;
import com.jme3.math.FastMath;
import com.jme3.system.AppSettings;
import java.nio.ByteBuffer;
import java.nio.IntBuffer;
+import java.util.ArrayList;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -28,9 +31,6 @@
public class SdlJoystickInput implements JoyInput {
private static final Logger LOGGER = Logger.getLogger(SdlJoystickInput.class.getName());
- private static final int POV_X_AXIS_ID = 7;
- private static final int POV_Y_AXIS_ID = 8;
-
private final AppSettings settings;
private final Map joysticks = new HashMap<>();
private final Map joyButtonPressed = new HashMap<>();
@@ -42,6 +42,7 @@ public class SdlJoystickInput implements JoyInput {
private float globalJitterThreshold;
private boolean loadGamepads;
private boolean loadRaw;
+ private volatile VirtualJoystick virtualJoystick;
private RawInputListener listener;
@@ -170,13 +171,11 @@ private void onDeviceConnected(InputManager inputManager, int deviceIndex, boole
}
// Virtual POV axes for D-pad.
- JoystickAxis povX = new DefaultJoystickAxis(inputManager, joystick, POV_X_AXIS_ID, JoystickAxis.POV_X,
+ int povXAxisId = joystick.getAxisCount();
+ JoystickAxis povX = new DefaultJoystickAxis(inputManager, joystick, povXAxisId, JoystickAxis.POV_X,
JoystickAxis.POV_X, true, false, 0.0f);
- joystick.addAxis(POV_X_AXIS_ID, povX);
-
- JoystickAxis povY = new DefaultJoystickAxis(inputManager, joystick, POV_Y_AXIS_ID, JoystickAxis.POV_Y,
- JoystickAxis.POV_Y, true, false, 0.0f);
- joystick.addAxis(POV_Y_AXIS_ID, povY);
+ joystick.addAxis(povXAxisId, povX);
+ joystick.povAxisX = povX;
inputManager.fireJoystickConnectedEvent(joystick);
@@ -241,7 +240,7 @@ public Joystick[] loadJoysticks(InputManager inputManager) {
}
if (loadRaw) {
- // load raw gamepads
+ // load raw joysticks
IntBuffer joys = SDL_GetJoysticks();
if (joys != null) {
try {
@@ -256,12 +255,39 @@ public Joystick[] loadJoysticks(InputManager inputManager) {
}
}
- return joysticks.values().toArray(new Joystick[0]);
+ if (settings.useJoysticks()
+ && AppSettings.VIRTUAL_JOYSTICK_ENABLED.equals(settings.getVirtualJoystickMode())) {
+ virtualJoystick = new VirtualJoystick(inputManager, this, nextVirtualJoyId());
+ virtualJoystick.setLayout(VirtualJoystick.createLayout(settings.getVirtualJoystickDefaultLayout()));
+ } else {
+ virtualJoystick = null;
+ }
+
+ return currentJoysticks();
}
@Override
public void update() {
handleInputEvents();
+ VirtualJoystick joystick = virtualJoystick;
+ if (joystick != null) {
+ joystick.dispatchEvents(listener);
+ }
+ }
+
+ public boolean onPointerDown(int pointerId, float x, float y, long time) {
+ VirtualJoystick joystick = virtualJoystick;
+ return joystick != null && joystick.onPointerDown(pointerId, x, y, time);
+ }
+
+ public boolean onPointerMove(int pointerId, float x, float y, long time) {
+ VirtualJoystick joystick = virtualJoystick;
+ return joystick != null && joystick.onPointerMove(pointerId, x, y, time);
+ }
+
+ public boolean onPointerUp(int pointerId, float x, float y, long time) {
+ VirtualJoystick joystick = virtualJoystick;
+ return joystick != null && joystick.onPointerUp(pointerId, x, y, time);
}
public void onSDLEvent(SDL_Event evt) {
@@ -301,11 +327,11 @@ private void handleInputEvents() {
long gp = js.gamepad;
for (JoystickAxis axis : js.getAxes()) {
- int axisIndex = axis.getAxisId();
- if (isVirtualPovAxis(axisIndex)) {
+ if (axis == js.getPovXAxis() || axis == js.getPovYAxis()) {
continue;
}
+ int axisIndex = axis.getAxisId();
String jmeAxisId = axis.getLogicalId();
short v = SDL_GetGamepadAxis(gp, axisIndex);
@@ -327,8 +353,6 @@ private void handleInputEvents() {
}
float povXValue = 0f;
- float povYValue = 0f;
-
for (JoystickButton button : js.getButtons()) {
int buttonId = button.getButtonId();
String jmeButtonId = button.getLogicalId();
@@ -336,11 +360,7 @@ private void handleInputEvents() {
boolean pressed = SDL_GetGamepadButton(gp, buttonId);
updateButton(button, pressed);
- if (JoystickButton.BUTTON_XBOX_DPAD_UP.equals(jmeButtonId)) {
- povYValue += pressed ? 1f : 0f;
- } else if (JoystickButton.BUTTON_XBOX_DPAD_DOWN.equals(jmeButtonId)) {
- povYValue += pressed ? -1f : 0f;
- } else if (JoystickButton.BUTTON_XBOX_DPAD_LEFT.equals(jmeButtonId)) {
+ if (JoystickButton.BUTTON_XBOX_DPAD_LEFT.equals(jmeButtonId)) {
povXValue += pressed ? -1f : 0f;
} else if (JoystickButton.BUTTON_XBOX_DPAD_RIGHT.equals(jmeButtonId)) {
povXValue += pressed ? 1f : 0f;
@@ -351,16 +371,11 @@ private void handleInputEvents() {
if (povXAxis != null) {
updateAxis(povXAxis, povXValue, povXValue);
}
-
- JoystickAxis povYAxis = js.getPovYAxis();
- if (povYAxis != null) {
- updateAxis(povYAxis, povYValue, povYValue);
- }
} else {
long joy = js.joystick;
for (JoystickAxis axis : js.getAxes()) {
- if (isVirtualPovAxis(axis.getAxisId())) {
+ if (axis == js.getPovXAxis() || axis == js.getPovYAxis()) {
continue;
}
@@ -378,8 +393,21 @@ private void handleInputEvents() {
}
}
- private boolean isVirtualPovAxis(int axisId) {
- return axisId == POV_X_AXIS_ID || axisId == POV_Y_AXIS_ID;
+ private Joystick[] currentJoysticks() {
+ List current = new ArrayList<>(joysticks.values());
+ VirtualJoystick joystick = virtualJoystick;
+ if (joystick != null) {
+ current.add(joystick);
+ }
+ return current.toArray(new Joystick[0]);
+ }
+
+ private int nextVirtualJoyId() {
+ int id = 0;
+ for (Integer joystickId : joysticks.keySet()) {
+ id = Math.max(id, joystickId + 1);
+ }
+ return id;
}
@Override
@@ -471,7 +499,7 @@ private String getButtonLabel(SdlJoystick gamepad, int sdlButtonIndex) {
}
}
- private String getAxisLabel(SdlJoystick gamepad, int sdlAxisIndex) {
+ private String getAxisLabel(SdlJoystick joystick, int sdlAxisIndex) {
switch (sdlAxisIndex) {
case SDL_GAMEPAD_AXIS_LEFTX:
return "LEFT THUMB STICK (X)";
@@ -619,10 +647,9 @@ private static class SdlJoystick extends AbstractJoystick {
private JoystickAxis xAxis;
private JoystickAxis yAxis;
private JoystickAxis povAxisX;
- private JoystickAxis povAxisY;
- long gamepad;
long joystick;
+ long gamepad;
SdlJoystick(InputManager inputManager, JoyInput joyInput, int joyId, String name, long gamepad,
long joystick) {
@@ -647,14 +674,6 @@ void addAxis(int index, JoystickAxis axis) {
yAxis = axis;
break;
}
- case POV_X_AXIS_ID: {
- povAxisX = axis;
- break;
- }
- case POV_Y_AXIS_ID: {
- povAxisY = axis;
- break;
- }
}
}
@@ -676,7 +695,7 @@ public JoystickAxis getPovXAxis() {
@Override
public JoystickAxis getPovYAxis() {
- return povAxisY;
+ return null;
}
@Override
diff --git a/jme3-lwjgl3/src/main/java/com/jme3/input/lwjgl/SdlMouseInput.java b/jme3-lwjgl3/src/main/java/com/jme3/input/lwjgl/SdlMouseInput.java
index 2370974e1d..545a4c495b 100644
--- a/jme3-lwjgl3/src/main/java/com/jme3/input/lwjgl/SdlMouseInput.java
+++ b/jme3-lwjgl3/src/main/java/com/jme3/input/lwjgl/SdlMouseInput.java
@@ -32,6 +32,7 @@
package com.jme3.input.lwjgl;
import com.jme3.cursors.plugins.JmeCursor;
+import com.jme3.input.JoyInput;
import com.jme3.input.MouseInput;
import com.jme3.input.RawInputListener;
import com.jme3.input.event.MouseButtonEvent;
@@ -174,6 +175,9 @@ public void onSDLEvent(SDL_Event event) {
}
if (xDelta != 0 || yDelta != 0) {
+ if (onPointerMove(0, x, y, event.motion().timestamp())) {
+ return;
+ }
MouseMotionEvent mouseMotionEvent =
new MouseMotionEvent(x, y, xDelta, yDelta, mouseWheel, 0);
mouseMotionEvent.setTime(event.motion().timestamp());
@@ -205,6 +209,9 @@ public void onSDLEvent(SDL_Event event) {
refreshWindowMetrics();
mouseX = toInputX(event.button().x());
mouseY = toInputY(event.button().y());
+ if (onPointerButton(0, event.button().down(), mouseX, mouseY, event.button().timestamp())) {
+ return;
+ }
int button = Byte.toUnsignedInt(event.button().button());
MouseButtonEvent mouseButtonEvent =
@@ -214,6 +221,25 @@ public void onSDLEvent(SDL_Event event) {
}
}
+ private boolean onPointerButton(int pointerId, boolean pressed, float x, float y, long time) {
+ JoyInput joyInput = context.getJoyInput();
+ if (joyInput instanceof SdlJoystickInput) {
+ if (pressed) {
+ return ((SdlJoystickInput) joyInput).onPointerDown(pointerId, x, y, time);
+ }
+ return ((SdlJoystickInput) joyInput).onPointerUp(pointerId, x, y, time);
+ }
+ return false;
+ }
+
+ private boolean onPointerMove(int pointerId, float x, float y, long time) {
+ JoyInput joyInput = context.getJoyInput();
+ if (joyInput instanceof SdlJoystickInput) {
+ return ((SdlJoystickInput) joyInput).onPointerMove(pointerId, x, y, time);
+ }
+ return false;
+ }
+
private void refreshWindowMetrics() {
AppSettings settings = context.getSettings();
currentWidth = Math.max(settings.getWidth(), 1);