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