diff --git a/.github/workflows/j3o-scan.yml b/.github/workflows/j3o-scan.yml index 33ccbc1314..bcb1b57a71 100644 --- a/.github/workflows/j3o-scan.yml +++ b/.github/workflows/j3o-scan.yml @@ -34,7 +34,7 @@ jobs: uses: actions/setup-java@v5 with: distribution: temurin - java-version: '17' + java-version: '25' - name: Scan J3O assets run: ./gradlew :jme3-desktop:scanJ3O --console=plain diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d93461de56..0da6c76710 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -69,7 +69,7 @@ jobs: uses: actions/setup-java@v5 with: distribution: 'temurin' - java-version: '21' + java-version: '25' - name: Validate the Gradle wrapper uses: gradle/actions/wrapper-validation@v6.1.0 - name: Run Checkstyle @@ -96,7 +96,7 @@ jobs: uses: actions/setup-java@v5 with: distribution: 'temurin' - java-version: '21' + java-version: '25' - name: Validate the Gradle wrapper uses: gradle/actions/wrapper-validation@v6.1.0 - name: Run SpotBugs @@ -120,6 +120,11 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + - name: Setup the java environment + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '25' - name: Start xvfb run: | Xvfb :99 -ac -screen 0 1024x768x16 & @@ -150,45 +155,6 @@ jobs: **/build/changed-images/** **/build/test-results/** - # Build iOS natives - BuildIosNatives: - name: Build natives for iOS - runs-on: macOS-14 - - steps: - - name: Check default JAVAs - run: echo $JAVA_HOME --- $JAVA_HOME_8_X64 --- $JAVA_HOME_11_X64 --- $JAVA_HOME_17_X64 --- $JAVA_HOME_21_X64 --- - - - name: Setup the java environment - uses: actions/setup-java@v5 - with: - distribution: 'temurin' - java-version: '17' - - - name: Setup the XCode version to 15.1.0 - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 - with: - xcode-version: '15.1.0' - - - name: Clone the repo - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - - name: Validate the Gradle wrapper - uses: gradle/actions/wrapper-validation@v6.1.0 - - - name: Build - run: | - ./gradlew -PuseCommitHashAsVersionName=true --no-daemon -PbuildNativeProjects=true \ - :jme3-ios-native:build - - - name: Upload natives - uses: actions/upload-artifact@v7.0.1 - with: - name: ios-natives - path: jme3-ios-native/template/META-INF/robovm/ios/libs/jme3-ios-native.xcframework - # Build the natives on android BuildAndroidNatives: name: Build natives for android @@ -202,11 +168,11 @@ jobs: with: fetch-depth: 1 - - name: Setup Java 17 + - name: Setup Java 25 uses: actions/setup-java@v5 with: distribution: temurin - java-version: '17' + java-version: '25' - name: Check java version run: java -version @@ -234,7 +200,7 @@ jobs: # Build the engine, we only deploy from ubuntu-latest jdk25 BuildJMonkey: - needs: [BuildAndroidNatives, BuildIosNatives] + needs: [BuildAndroidNatives] name: Build on ${{ matrix.osName }} jdk${{ matrix.jdk }} runs-on: ${{ matrix.os }} strategy: @@ -259,18 +225,18 @@ jobs: with: fetch-depth: 1 + - name: Setup the java environment + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: ${{ matrix.jdk }} + - name: Download natives for android uses: actions/download-artifact@v8.0.1 with: name: android-natives path: build/native - - name: Download natives for iOS - uses: actions/download-artifact@v8.0.1 - with: - name: ios-natives - path: jme3-ios-native/template/META-INF/robovm/ios/libs/jme3-ios-native.xcframework - - name: Validate the Gradle wrapper uses: gradle/actions/wrapper-validation@v6.1.0 - name: Build Engine @@ -453,12 +419,12 @@ jobs: with: fetch-depth: 1 - # Setup jdk 21 used for building Maven-style artifacts + # Setup jdk 25 used for building Maven-style artifacts - name: Setup the java environment uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: distribution: 'temurin' - java-version: '21' + java-version: '25' - name: Download natives for android uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -466,12 +432,6 @@ jobs: name: android-natives path: build/native - - name: Download natives for iOS - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ios-natives - path: jme3-ios-native/template/META-INF/robovm/ios/libs/jme3-ios-native.xcframework - - name: Rebuild the maven artifacts and upload them to Sonatype's maven-snapshots repo run: | if [ "${{ secrets.CENTRAL_PASSWORD }}" = "" ]; @@ -502,12 +462,12 @@ jobs: with: fetch-depth: 1 - # Setup jdk 21 used for building Sonatype artifacts + # Setup jdk 25 used for building Sonatype artifacts - name: Setup the java environment uses: actions/setup-java@v5 with: distribution: 'temurin' - java-version: '21' + java-version: '25' # Download all the stuff... - name: Download maven artifacts @@ -528,12 +488,6 @@ jobs: name: android-natives path: build/native - - name: Download natives for iOS - uses: actions/download-artifact@v8.0.1 - with: - name: ios-natives - path: jme3-ios-native/template/META-INF/robovm/ios/libs/jme3-ios-native.xcframework - - name: Rebuild the maven artifacts and upload them to Sonatype's Central Publisher Portal run: | if [ "${{ secrets.CENTRAL_PASSWORD }}" = "" ]; diff --git a/build.gradle b/build.gradle index 3a1395207c..ebe2df96eb 100644 --- a/build.gradle +++ b/build.gradle @@ -26,7 +26,15 @@ apply from: file('version.gradle') allprojects { repositories { + mavenLocal() mavenCentral() + maven { + name = 'CentralSnapshots' + url = uri('https://central.sonatype.com/repository/maven-snapshots/') + mavenContent { + snapshotsOnly() + } + } google() } tasks.withType(Jar) { 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 fc05da90d6..70c96cbe26 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 @@ -111,6 +111,7 @@ protected void addListeners(GLSurfaceView view) { public void loadSettings(AppSettings settings) { touchInput.loadSettings(settings); + joyInput.loadSettings(settings); } public TouchInput getTouchInput() { 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 fbbdc8c831..728dcfdc5d 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 @@ -31,9 +31,7 @@ */ package com.jme3.input.android; -import android.content.Context; import android.opengl.GLSurfaceView; -import android.os.Vibrator; import com.jme3.input.InputManager; import com.jme3.input.JoyInput; import com.jme3.input.Joystick; @@ -42,6 +40,7 @@ import com.jme3.input.event.JoyAxisEvent; import com.jme3.input.event.JoyButtonEvent; import com.jme3.system.AppSettings; +import com.jme3.system.JmeSystem; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ConcurrentLinkedQueue; @@ -55,9 +54,9 @@ * This class manages all the joysticks and feeds the inputs from each back * to jME's InputManager. * - * This handler also supports the joystick.rumble(rumbleAmount) method. In this - * case, when joystick.rumble(rumbleAmount) is called, the Android device will vibrate - * if the device has a built-in vibrate motor. + * This handler also supports redirecting joystick.rumble(rumbleAmount) to the + * Android device vibrator if AppSettings#setOnDeviceJoystickRumble(boolean) is + * enabled and the device has a built-in vibrate motor. * * Because Android does not allow for the user to define the intensity of the * vibration, the rumble amount (ie strength) is converted into vibration pulses @@ -92,9 +91,7 @@ public class AndroidJoyInput implements JoyInput { private RawInputListener listener = null; private ConcurrentLinkedQueue eventQueue = new ConcurrentLinkedQueue<>(); private AndroidSensorJoyInput sensorJoyInput; - private Vibrator vibrator = null; - private boolean vibratorActive = false; - private long maxRumbleTime = 250; // 250ms + private boolean onDeviceJoystickRumble = false; public AndroidJoyInput(AndroidInputHandler inputHandler) { this.inputHandler = inputHandler; @@ -102,23 +99,17 @@ public AndroidJoyInput(AndroidInputHandler inputHandler) { } public void setView(GLSurfaceView view) { - if (view == null) { - vibrator = null; - } else { - // Get instance of Vibrator from current Context - vibrator = (Vibrator) view.getContext().getSystemService(Context.VIBRATOR_SERVICE); - if (vibrator == null) { - logger.log(Level.FINE, "Vibrator Service not found."); - } - } - if (sensorJoyInput != null) { sensorJoyInput.setView(view); } } public void loadSettings(AppSettings settings) { + onDeviceJoystickRumble = settings.isOnDeviceJoystickRumble(); + } + protected boolean isOnDeviceJoystickRumble() { + return onDeviceJoystickRumble; } public void addEvent(InputEvent event) { @@ -133,8 +124,8 @@ public void pauseJoysticks() { if (sensorJoyInput != null) { sensorJoyInput.pauseSensors(); } - if (vibrator != null && vibratorActive) { - vibrator.cancel(); + if (onDeviceJoystickRumble) { + JmeSystem.rumble(0f); } } @@ -182,28 +173,16 @@ public long getInputTimeNanos() { } @Override - public void setJoyRumble(int joyId, float amount) { - // convert amount to pulses since Android doesn't allow intensity - if (vibrator != null) { - final long rumbleOnDur = (long)(amount * maxRumbleTime); // ms to pulse vibration on - final long rumbleOffDur = maxRumbleTime - rumbleOnDur; // ms to delay between pulses - final long[] rumblePattern = { - 0, // start immediately - rumbleOnDur, // time to leave vibration on - rumbleOffDur // time to delay between vibrations - }; - final int rumbleRepeatFrom = 0; // index into rumble pattern to repeat from - -// logger.log(Level.FINE, "Rumble amount: {0}, rumbleOnDur: {1}, rumbleOffDur: {2}", -// new Object[]{amount, rumbleOnDur, rumbleOffDur}); - - if (rumbleOnDur > 0) { - vibrator.vibrate(rumblePattern, rumbleRepeatFrom); - vibratorActive = true; - } else { - vibrator.cancel(); - vibratorActive = false; - } + public void setJoyRumble(int joyId, float amountHigh, float amountLow, float duration) { + if (onDeviceJoystickRumble && JmeSystem.isDeviceRumbleSupported()) { + JmeSystem.rumble(amountHigh, amountLow, duration); + } + } + + @Override + public void stopJoyRumble(int joyId) { + if (onDeviceJoystickRumble && JmeSystem.isDeviceRumbleSupported()) { + JmeSystem.stopRumble(); } } 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 050a00e292..ffdcaa590e 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 @@ -35,6 +35,7 @@ import android.view.MotionEvent; import com.jme3.input.InputManager; import com.jme3.input.Joystick; +import com.jme3.system.JmeSystem; import java.util.logging.Logger; /** @@ -97,6 +98,24 @@ public Joystick[] loadJoysticks(InputManager inputManager) { return joystickList.toArray( new Joystick[joystickList.size()] ); } + @Override + public void setJoyRumble(int joyId, float amountHigh, float amountLow, float duration) { + if (isOnDeviceJoystickRumble() && JmeSystem.isDeviceRumbleSupported()) { + super.setJoyRumble(joyId, amountHigh, amountLow, duration); + } else { + joystickJoyInput.setJoyRumble(joyId, amountHigh, amountLow, duration); + } + } + + @Override + public void stopJoyRumble(int joyId) { + if (isOnDeviceJoystickRumble() && JmeSystem.isDeviceRumbleSupported()) { + super.stopJoyRumble(joyId); + } else { + joystickJoyInput.stopJoyRumble(joyId); + } + } + public boolean onGenericMotion(MotionEvent event) { return joystickJoyInput.onGenericMotion(event); } diff --git a/jme3-android/src/main/java/com/jme3/input/android/AndroidJoystickJoyInput14.java b/jme3-android/src/main/java/com/jme3/input/android/AndroidJoystickJoyInput14.java index 6011e9d0a7..fb6061008a 100644 --- a/jme3-android/src/main/java/com/jme3/input/android/AndroidJoystickJoyInput14.java +++ b/jme3-android/src/main/java/com/jme3/input/android/AndroidJoystickJoyInput14.java @@ -47,6 +47,7 @@ import com.jme3.input.JoystickCompatibilityMappings; import com.jme3.input.event.JoyAxisEvent; import com.jme3.input.event.JoyButtonEvent; +import com.jme3.system.android.AndroidHapticFeedback; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -96,7 +97,7 @@ public AndroidJoystickJoyInput14(AndroidJoyInput joyInput) { public void pauseJoysticks() { - + stopAllRumble(); } public void resumeJoysticks() { @@ -104,7 +105,7 @@ public void resumeJoysticks() { } public void destroy() { - + stopAllRumble(); } public List loadJoysticks(int joyId, InputManager inputManager) { @@ -245,6 +246,48 @@ public boolean onKey(KeyEvent event) { return consumed; } + public boolean setJoyRumble(int joyId, float amount) { + AndroidJoystick joystick = getJoystick(joyId); + if (joystick == null || !joystick.isHapticFeedbackSupported()) { + return false; + } + joystick.rumble(amount); + return true; + } + + public boolean setJoyRumble(int joyId, float amountHigh, float amountLow, float duration) { + AndroidJoystick joystick = getJoystick(joyId); + if (joystick == null || !joystick.isHapticFeedbackSupported()) { + return false; + } + joystick.rumble(amountHigh, amountLow, duration); + return true; + } + + public boolean stopJoyRumble(int joyId) { + AndroidJoystick joystick = getJoystick(joyId); + if (joystick == null || !joystick.isHapticFeedbackSupported()) { + return false; + } + joystick.stopRumble(); + return true; + } + + private AndroidJoystick getJoystick(int joyId) { + for (AndroidJoystick joystick : joystickIndex.values()) { + if (joystick.getJoyId() == joyId) { + return joystick; + } + } + return null; + } + + private void stopAllRumble() { + for (AndroidJoystick joystick : joystickIndex.values()) { + joystick.stopRumble(); + } + } + protected class AndroidJoystick extends AbstractJoystick { private JoystickAxis nullAxis; @@ -278,6 +321,25 @@ protected Set getAndroidAxes() { return axisIndex.keySet(); } + @SuppressWarnings("deprecation") + private android.os.Vibrator getVibrator() { + return device.getVibrator(); + } + + protected boolean isHapticFeedbackSupported() { + return AndroidHapticFeedback.isSupported(getVibrator()); + } + + @Override + public void rumble(float amountHigh, float amountLow, float duration) { + AndroidHapticFeedback.rumble(getVibrator(), amountHigh, amountLow, duration); + } + + @Override + public void stopRumble() { + AndroidHapticFeedback.stop(getVibrator()); + } + protected JoystickButton getButton(int keyCode) { return buttonIndex.get(keyCode); } diff --git a/jme3-android/src/main/java/com/jme3/system/android/AndroidHapticFeedback.java b/jme3-android/src/main/java/com/jme3/system/android/AndroidHapticFeedback.java new file mode 100644 index 0000000000..c73382f0dd --- /dev/null +++ b/jme3-android/src/main/java/com/jme3/system/android/AndroidHapticFeedback.java @@ -0,0 +1,103 @@ +/* + * 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; 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.system.android; + +import com.jme3.math.FastMath; + +import android.os.Build; +import android.os.VibrationEffect; +import android.os.Vibrator; + +/** + * Android haptic helpers shared by device and input-device vibrators. + */ +public final class AndroidHapticFeedback { + + + private AndroidHapticFeedback() { + } + + public static boolean isSupported(Vibrator vibrator) { + return vibrator != null && vibrator.hasVibrator(); + } + + @SuppressWarnings("deprecation") + public static void rumble(Vibrator vibrator, float amountHigh, float amountLow, float duration) { + if (!isSupported(vibrator)) { + return; + } + float amount = Math.max(FastMath.clamp(amountHigh, 0f, 1f), FastMath.clamp(amountLow, 0f, 1f)); + if (amount <= 0f || duration <= 0f) { + stop(vibrator); + return; + } + + try { + if (duration == Float.POSITIVE_INFINITY) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && vibrator.hasAmplitudeControl()) { + int amplitude = Math.max(1, Math.round(amount * 255f)); + vibrator.vibrate(VibrationEffect.createWaveform( + new long[]{0, 1000}, + new int[]{0, amplitude}, + 0)); + } else { + long rumbleOnDur = Math.max(1L, Math.round(amount * 1000)); + long rumbleOffDur = Math.max(0L, 1000 - rumbleOnDur); + vibrator.vibrate(new long[]{0, rumbleOnDur, rumbleOffDur}, 0); + } + } else { + long durationMs = duration <= 0f ? 0L : Math.max(1L, Math.round(duration * 1000f)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && vibrator.hasAmplitudeControl()) { + int amplitude = Math.max(1, Math.round(amount * 255f)); + vibrator.vibrate(VibrationEffect.createOneShot(durationMs, amplitude)); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE)); + } else { + vibrator.vibrate(durationMs); + } + } + } catch (SecurityException ignored) { + // Applications without VIBRATE permission should degrade to no-op. + } + } + + public static void stop(Vibrator vibrator) { + if (!isSupported(vibrator)) { + return; + } + try { + vibrator.cancel(); + } catch (SecurityException ignored) { + // Applications without VIBRATE permission should degrade to no-op. + } + } +} diff --git a/jme3-android/src/main/java/com/jme3/system/android/JmeAndroidSystem.java b/jme3-android/src/main/java/com/jme3/system/android/JmeAndroidSystem.java index e828ce9b13..98b3f7a247 100644 --- a/jme3-android/src/main/java/com/jme3/system/android/JmeAndroidSystem.java +++ b/jme3-android/src/main/java/com/jme3/system/android/JmeAndroidSystem.java @@ -5,6 +5,7 @@ import android.graphics.Bitmap; import android.os.Build; import android.os.Environment; +import android.os.Vibrator; import android.view.View; import android.view.inputmethod.InputMethodManager; import com.jme3.audio.AudioRenderer; @@ -32,7 +33,6 @@ public class JmeAndroidSystem extends JmeSystemDelegate { private static View view; - static { try { System.loadLibrary("bulletjme"); @@ -208,6 +208,29 @@ public static View getView() { return view; } + @Override + public boolean isDeviceRumbleSupported() { + return AndroidHapticFeedback.isSupported(getVibrator()); + } + + @Override + public void rumble(float amountHigh, float amountLow, float duration) { + AndroidHapticFeedback.rumble(getVibrator(), amountHigh, amountLow, duration); + } + + @Override + public void stopRumble() { + AndroidHapticFeedback.stop(getVibrator()); + } + + private static Vibrator getVibrator() { + View currentView = view; + if (currentView == null || currentView.getContext() == null) { + return null; + } + return (Vibrator) currentView.getContext().getSystemService(Context.VIBRATOR_SERVICE); + } + @Override public void showSoftKeyboard(final boolean show) { view.getHandler().post(new Runnable() { diff --git a/jme3-core/src/main/java/com/jme3/environment/baker/IBLGLEnvBakerLight.java b/jme3-core/src/main/java/com/jme3/environment/baker/IBLGLEnvBakerLight.java index 35f032a32f..a5e2c6a4d0 100644 --- a/jme3-core/src/main/java/com/jme3/environment/baker/IBLGLEnvBakerLight.java +++ b/jme3-core/src/main/java/com/jme3/environment/baker/IBLGLEnvBakerLight.java @@ -260,7 +260,6 @@ public void bakeSphericalHarmonicsCoefficients() { else if (weightAccum != c.a) { LOG.warning("SH weight is not uniform, this may cause issues."); } - } if (remapMaxValue > 0) weightAccum /= remapMaxValue; @@ -272,6 +271,7 @@ else if (weightAccum != c.a) { } EnvMapUtils.prepareShCoefs(shCoef); img.dispose(); + shbaker.dispose(); } } diff --git a/jme3-core/src/main/java/com/jme3/input/AbstractJoystick.java b/jme3-core/src/main/java/com/jme3/input/AbstractJoystick.java index 7eab91ee12..b26eef13c3 100644 --- a/jme3-core/src/main/java/com/jme3/input/AbstractJoystick.java +++ b/jme3-core/src/main/java/com/jme3/input/AbstractJoystick.java @@ -82,14 +82,14 @@ protected void addButton(JoystickButton button) { buttons.add(button); } - /** - * Rumbles the joystick for the given amount/magnitude. - * - * @param amount The amount to rumble. Should be between 0 and 1. - */ @Override - public void rumble(float amount) { - joyInput.setJoyRumble(joyId, amount); + public void rumble(float amountHigh, float amountLow, float duration) { + joyInput.setJoyRumble(joyId, amountHigh, amountLow, duration); + } + + @Override + public void stopRumble() { + joyInput.stopJoyRumble(joyId); } /** diff --git a/jme3-ios/src/main/java/com/jme3/system/ios/IosHarness.java b/jme3-core/src/main/java/com/jme3/input/HapticDevice.java similarity index 60% rename from jme3-ios/src/main/java/com/jme3/system/ios/IosHarness.java rename to jme3-core/src/main/java/com/jme3/input/HapticDevice.java index 7a47d8ff98..dc94ae0258 100644 --- a/jme3-ios/src/main/java/com/jme3/system/ios/IosHarness.java +++ b/jme3-core/src/main/java/com/jme3/input/HapticDevice.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2009-2012 jMonkeyEngine + * Copyright (c) 2009-2026 jMonkeyEngine * All rights reserved. * * Redistribution and use in source and binary forms, with or without @@ -29,33 +29,39 @@ * 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.system.ios; - -import com.jme3.app.LegacyApplication; -import com.jme3.system.JmeSystem; +package com.jme3.input; /** - * @author normenhansen + * A device that can produce haptic feedback. */ -public abstract class IosHarness extends ObjcNativeObject { - - protected LegacyApplication app; +public interface HapticDevice { - public IosHarness(long appDelegate) { - super(appDelegate); - JmeSystem.setSystemDelegate(new JmeIosSystem()); + /** + * Rumbles the device with the given amount. + *

+ * This starts a continuous rumble that keeps playing until + * {@code rumble(0)} or {@link #stopRumble()} is called. + * + * @param amount the amount to rumble, between 0 and 1 + */ + default void rumble(float amount) { + rumble(amount, amount, Float.POSITIVE_INFINITY); } - public abstract void appPaused(); - - public abstract void appReactivated(); - - public abstract void appClosed(); + /** + * Rumbles the device with separate high and low frequency amounts for the + * given duration. + * + * @param amountHigh the high frequency amount, between 0 and 1 + * @param amountLow the low frequency amount, between 0 and 1 + * @param duration the duration in seconds + */ + void rumble(float amountHigh, float amountLow, float duration); - public abstract void appUpdate(); - - public abstract void appDraw(); - - public abstract void appReshape(int width, int height); - -} \ No newline at end of file + /** + * Stops any rumble currently playing on the device. + */ + default void stopRumble() { + rumble(0f); + } +} diff --git a/jme3-core/src/main/java/com/jme3/input/JoyInput.java b/jme3-core/src/main/java/com/jme3/input/JoyInput.java index 0041b86942..8fc0ba7d02 100644 --- a/jme3-core/src/main/java/com/jme3/input/JoyInput.java +++ b/jme3-core/src/main/java/com/jme3/input/JoyInput.java @@ -49,11 +49,36 @@ public interface JoyInput extends Input { /** * Causes the joystick at joyId index to rumble with * the given amount. + *

+ * The rumble continues until this method is called with 0 or + * {@link #stopJoyRumble(int)} is called. * * @param joyId The joystick index * @param amount Rumble amount. Should be between 0 and 1. */ - public void setJoyRumble(int joyId, float amount); + public default void setJoyRumble(int joyId, float amount) { + setJoyRumble(joyId, amount, amount, Float.POSITIVE_INFINITY); + } + + /** + * Causes the joystick at joyId index to rumble with + * separate high and low frequency amounts for the given duration. + * + * @param joyId The joystick index + * @param amountHigh High frequency rumble amount. Should be between 0 and 1. + * @param amountLow Low frequency rumble amount. Should be between 0 and 1. + * @param duration Rumble duration in seconds. + */ + public void setJoyRumble(int joyId, float amountHigh, float amountLow, float duration); + + /** + * Stops any rumble currently playing on the joystick at joyId. + * + * @param joyId the joystick index + */ + public default void stopJoyRumble(int joyId) { + setJoyRumble(joyId, 0f); + } /** * Loads a list of joysticks from the system. diff --git a/jme3-core/src/main/java/com/jme3/input/Joystick.java b/jme3-core/src/main/java/com/jme3/input/Joystick.java index 967422fc6f..b93ce0e65c 100644 --- a/jme3-core/src/main/java/com/jme3/input/Joystick.java +++ b/jme3-core/src/main/java/com/jme3/input/Joystick.java @@ -38,14 +38,8 @@ * * @author Paul Speed, Kirill Vainer */ -public interface Joystick { +public interface Joystick extends HapticDevice { - /** - * Rumbles the joystick for the given amount/magnitude. - * - * @param amount The amount to rumble. Should be between 0 and 1. - */ - public void rumble(float amount); /** * Assign the mapping name to receive events from the given button index diff --git a/jme3-core/src/main/java/com/jme3/renderer/Caps.java b/jme3-core/src/main/java/com/jme3/renderer/Caps.java index dfe8a10669..1183002a65 100644 --- a/jme3-core/src/main/java/com/jme3/renderer/Caps.java +++ b/jme3-core/src/main/java/com/jme3/renderer/Caps.java @@ -427,6 +427,11 @@ public enum Caps { */ DepthTexture, + /** + * Supports hardware depth texture comparison for shadow maps. + */ + TextureShadowCompare, + /** * Supports 32-bit index buffers. */ diff --git a/jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java b/jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java index e8410ce429..91ad5596e4 100644 --- a/jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java +++ b/jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java @@ -453,6 +453,10 @@ private void loadCapabilitiesCommon() { caps.add(Caps.DepthTexture); } + if (gl2 != null || caps.contains(Caps.OpenGLES30)) { + caps.add(Caps.TextureShadowCompare); + } + if (caps.contains(Caps.OpenGL20) || caps.contains(Caps.OpenGLES30) || caps.contains(Caps.WebGL) || hasExtension("GL_OES_depth24")) { caps.add(Caps.Depth24); @@ -2674,7 +2678,7 @@ && isMipmapGenerationSupported(image.getFormat(), } ShadowCompareMode texCompareMode = tex.getShadowCompareMode(); - if ( (gl2 != null || caps.contains(Caps.OpenGLES30)) && curState.shadowCompareMode != texCompareMode) { + if (caps.contains(Caps.TextureShadowCompare) && curState.shadowCompareMode != texCompareMode) { bindTextureAndUnit(target, image, unit); if (texCompareMode != ShadowCompareMode.Off) { gl.glTexParameteri(target, GL2.GL_TEXTURE_COMPARE_MODE, GL2.GL_COMPARE_REF_TO_TEXTURE); 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 46a18ca333..b39b115f2a 100644 --- a/jme3-core/src/main/java/com/jme3/system/AppSettings.java +++ b/jme3-core/src/main/java/com/jme3/system/AppSettings.java @@ -346,6 +346,7 @@ public final class AppSettings extends HashMap { defaults.put("JoysticksTriggerToButtonThreshold", 0.5f); defaults.put("JoysticksAxisJitterThreshold", 0.0001f); defaults.put("SDLGameControllerDBResourcePath", ""); + defaults.put("OnDeviceJoystickRumble", false); // defaults.put("Icons", null); } @@ -798,6 +799,15 @@ public void setUseJoysticks(boolean use) { putBoolean("DisableJoysticks", !use); } + /** + * @param enabled If true, joystick rumble requests may be redirected to + * the device rumble motor on supported platforms. + * (Default: false) + */ + public void setOnDeviceJoystickRumble(boolean enabled) { + putBoolean("OnDeviceJoystickRumble", enabled); + } + /** * Set the graphics renderer to use, one of:
*

    @@ -1268,6 +1278,16 @@ public boolean useJoysticks() { return !getBoolean("DisableJoysticks"); } + /** + * Get whether joystick rumble may be redirected to device rumble. + * + * @return true to redirect joystick rumble to device rumble when supported + * @see #setOnDeviceJoystickRumble(boolean) + */ + public boolean isOnDeviceJoystickRumble() { + return getBoolean("OnDeviceJoystickRumble"); + } + /** * Get the audio renderer * diff --git a/jme3-core/src/main/java/com/jme3/system/JmeSystem.java b/jme3-core/src/main/java/com/jme3/system/JmeSystem.java index 9def80e73a..675dab19dc 100644 --- a/jme3-core/src/main/java/com/jme3/system/JmeSystem.java +++ b/jme3-core/src/main/java/com/jme3/system/JmeSystem.java @@ -31,9 +31,9 @@ */ package com.jme3.system; -import com.jme3.asset.AssetManager; -import com.jme3.audio.AudioRenderer; -import com.jme3.input.SoftTextDialogInput; +import com.jme3.asset.AssetManager; +import com.jme3.audio.AudioRenderer; +import com.jme3.input.SoftTextDialogInput; import java.io.File; import java.io.IOException; @@ -48,11 +48,11 @@ import java.util.logging.Logger; /** - * Utility class to access platform-dependant features. - */ -public class JmeSystem { - - private static final Logger logger = Logger.getLogger(JmeSystem.class.getName()); + * Utility class to access platform-dependant features. + */ +public class JmeSystem { + + private static final Logger logger = Logger.getLogger(JmeSystem.class.getName()); public enum StorageFolderType { Internal, @@ -67,10 +67,10 @@ public enum StorageFolderType { private JmeSystem() { } - public static void setSystemDelegate(JmeSystemDelegate systemDelegate) { - JmeSystem.systemDelegate = systemDelegate; - } - + public static void setSystemDelegate(JmeSystemDelegate systemDelegate) { + JmeSystem.systemDelegate = systemDelegate; + } + public static synchronized File getStorageFolder() { return getStorageFolder(StorageFolderType.External); } @@ -120,14 +120,34 @@ public static void setSoftTextDialogInput(SoftTextDialogInput input) { * * @param show If true, the keyboard is displayed, if false, the screen is hidden. */ - public static void showSoftKeyboard(boolean show) { - checkDelegate(); - systemDelegate.showSoftKeyboard(show); - } - - public static SoftTextDialogInput getSoftTextDialogInput() { - checkDelegate(); - return systemDelegate.getSoftTextDialogInput(); + public static void showSoftKeyboard(boolean show) { + checkDelegate(); + systemDelegate.showSoftKeyboard(show); + } + + public static boolean isDeviceRumbleSupported() { + checkDelegate(); + return systemDelegate.isDeviceRumbleSupported(); + } + + public static void rumble(float amount) { + checkDelegate(); + systemDelegate.rumble(amount); + } + + public static void rumble(float amountHigh, float amountLow, float duration) { + checkDelegate(); + systemDelegate.rumble(amountHigh, amountLow, duration); + } + + public static void stopRumble() { + checkDelegate(); + systemDelegate.stopRumble(); + } + + public static SoftTextDialogInput getSoftTextDialogInput() { + checkDelegate(); + return systemDelegate.getSoftTextDialogInput(); } /** diff --git a/jme3-core/src/main/java/com/jme3/system/JmeSystemDelegate.java b/jme3-core/src/main/java/com/jme3/system/JmeSystemDelegate.java index f95745011a..0a5dc59fb9 100644 --- a/jme3-core/src/main/java/com/jme3/system/JmeSystemDelegate.java +++ b/jme3-core/src/main/java/com/jme3/system/JmeSystemDelegate.java @@ -34,6 +34,7 @@ import com.jme3.asset.AssetManager; import com.jme3.asset.DesktopAssetManager; import com.jme3.audio.AudioRenderer; +import com.jme3.input.HapticDevice; import com.jme3.input.SoftTextDialogInput; import com.jme3.util.res.Resources; @@ -55,7 +56,7 @@ * * @author Kirill Vainer, normenhansen */ -public abstract class JmeSystemDelegate { +public abstract class JmeSystemDelegate implements HapticDevice { protected final Logger logger = Logger.getLogger(JmeSystem.class.getName()); protected boolean initialized = false; @@ -68,7 +69,7 @@ public abstract class JmeSystemDelegate { try { dialogFactory = (JmeDialogsFactory)Class.forName("com.jme3.system.JmeDialogsFactoryImpl").getConstructor().newInstance(); } catch(ClassNotFoundException e){ - logger.warning("JmeDialogsFactory implementation not found."); + logger.warning("JmeDialogsFactory implementation not found."); } catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException e) { e.printStackTrace(); } @@ -81,7 +82,7 @@ public abstract class JmeSystemDelegate { try { dialogFactory = (JmeDialogsFactory)Class.forName("com.jme3.system.JmeDialogsFactoryImpl").getConstructor().newInstance(); } catch(ClassNotFoundException e){ - logger.warning("JmeDialogsFactory implementation not found."); + logger.warning("JmeDialogsFactory implementation not found."); } catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException e) { e.printStackTrace(); } @@ -149,11 +150,19 @@ public boolean isLowPermissions() { public void setSoftTextDialogInput(SoftTextDialogInput input) { softTextDialogInput = input; } - + public SoftTextDialogInput getSoftTextDialogInput() { return softTextDialogInput; } + public boolean isDeviceRumbleSupported() { + return false; + } + + @Override + public void rumble(float amountHigh, float amountLow, float duration) { + } + public final AssetManager newAssetManager(URL configFile) { return new DesktopAssetManager(configFile); } @@ -161,11 +170,11 @@ public final AssetManager newAssetManager(URL configFile) { public final AssetManager newAssetManager() { return new DesktopAssetManager(null); } - + public abstract void writeImageFile(OutputStream outStream, String format, ByteBuffer imageData, int width, int height) throws IOException; /** - * Set function to handle errors. + * Set function to handle errors. * The default implementation show a dialog if available. * @param handler Consumer to which the error is passed as String */ @@ -181,10 +190,10 @@ public void handleErrorMessage(String message){ } /** - * Set a function to handler app settings. + * Set a function to handler app settings. * The default implementation shows a settings dialog if available. - * @param handler handler function that accepts as argument an instance of AppSettings - * to transform and a boolean with the value of true if the settings are expected to be loaded from + * @param handler handler function that accepts as argument an instance of AppSettings + * to transform and a boolean with the value of true if the settings are expected to be loaded from * the user registry. The handler function returns false if the configuration is interrupted (eg.the the dialog was closed) * or true otherwise. */ @@ -215,27 +224,27 @@ public boolean showSettingsDialog(AppSettings settings, boolean loadFromRegistry } - private boolean is64Bit(String arch) { - switch (arch) { - case "amd64": - case "x86_64": - case "aarch64": - case "arm64": - case "ppc64": - case "universal": - return true; - case "x86": - case "i386": - case "i686": - case "aarch32": - case "arm": - case "armv7": - case "armv7l": - return false; - default: - throw new UnsupportedOperationException("Unsupported architecture: " + arch); - } - } + private boolean is64Bit(String arch) { + switch (arch) { + case "amd64": + case "x86_64": + case "aarch64": + case "arm64": + case "ppc64": + case "universal": + return true; + case "x86": + case "i386": + case "i686": + case "aarch32": + case "arm": + case "armv7": + case "armv7l": + return false; + default: + throw new UnsupportedOperationException("Unsupported architecture: " + arch); + } + } private boolean isArmArchitecture(String arch) { return arch.startsWith("arm") || arch.startsWith("aarch"); @@ -290,7 +299,7 @@ public Platform getPlatform() { boolean is64 = is64Bit(arch); if (os.contains("windows")) { return getWindowsPlatform(arch, is64); - } else if (os.contains("linux") || os.contains("freebsd") + } else if (os.contains("linux") || os.contains("freebsd") || os.contains("sunos") || os.contains("unix")) { return getLinuxPlatform(arch, is64); } else if (os.contains("mac os x") || os.contains("darwin")) { @@ -308,9 +317,9 @@ public String getBuildInfo() { sb.append(" * Build Date: ").append(JmeVersion.BUILD_DATE); return sb.toString(); } - + public abstract URL getPlatformAssetConfigURL(); - + public abstract JmeContext newContext(AppSettings settings, JmeContext.Type contextType); public abstract AudioRenderer newAudioRenderer(AppSettings settings); diff --git a/jme3-core/src/main/java/com/jme3/util/MaterialDebugAppState.java b/jme3-core/src/main/java/com/jme3/util/MaterialDebugAppState.java index 62d4c6c5de..7f0a81d94a 100644 --- a/jme3-core/src/main/java/com/jme3/util/MaterialDebugAppState.java +++ b/jme3-core/src/main/java/com/jme3/util/MaterialDebugAppState.java @@ -377,16 +377,23 @@ public void init() { file = new File(url.getFile()); fileLastM = file.lastModified(); - } catch (NoSuchFieldException - | SecurityException + } catch (NoSuchFieldException ex) { + Logger.getLogger(MaterialDebugAppState.class.getName()).log(Level.FINE, + "Material hot reload disabled for {0}; asset URL is not reflectively available.", + fileName); + } catch (SecurityException | IllegalArgumentException | IllegalAccessException ex) { - Logger.getLogger(MaterialDebugAppState.class.getName()).log(Level.SEVERE, null, ex); + Logger.getLogger(MaterialDebugAppState.class.getName()).log(Level.FINE, + "Material hot reload disabled for " + fileName, ex); } } } public boolean shouldFire() { + if (file == null || fileLastM == null) { + return false; + } if (file.lastModified() != fileLastM) { fileLastM = file.lastModified(); return true; diff --git a/jme3-core/src/main/resources/Common/IBLSphH/IBLSphH.frag b/jme3-core/src/main/resources/Common/IBLSphH/IBLSphH.frag index aa896e0536..8431ed6388 100644 --- a/jme3-core/src/main/resources/Common/IBLSphH/IBLSphH.frag +++ b/jme3-core/src/main/resources/Common/IBLSphH/IBLSphH.frag @@ -175,11 +175,8 @@ void sphHammersleyKernel(int coefficientIndex, out vec3 shCoef, out float weight #endif void sphKernel() { - int width = int(m_Resolution.x); - int height = int(m_Resolution.y); vec3 texelVect=vec3(0); float shDir=0.; - float weight=0.; vec4 color=vec4(0); int i=int(gl_FragCoord.x); @@ -216,4 +213,4 @@ void sphKernel() { void main() { sphKernel(); -} \ No newline at end of file +} diff --git a/jme3-core/src/main/resources/Common/IBLSphH/IBLSphH.j3md b/jme3-core/src/main/resources/Common/IBLSphH/IBLSphH.j3md index a350cf01d8..04203b0dc1 100644 --- a/jme3-core/src/main/resources/Common/IBLSphH/IBLSphH.j3md +++ b/jme3-core/src/main/resources/Common/IBLSphH/IBLSphH.j3md @@ -31,4 +31,4 @@ MaterialDef IBLSphH { } } -} \ No newline at end of file +} diff --git a/jme3-examples/src/main/java/jme3test/input/TestDeviceRumble.java b/jme3-examples/src/main/java/jme3test/input/TestDeviceRumble.java new file mode 100644 index 0000000000..131890a0a9 --- /dev/null +++ b/jme3-examples/src/main/java/jme3test/input/TestDeviceRumble.java @@ -0,0 +1,120 @@ +package jme3test.input; + +import com.jme3.app.SimpleApplication; +import com.jme3.collision.CollisionResults; +import com.jme3.font.BitmapText; +import com.jme3.input.MouseInput; +import com.jme3.input.RawInputListenerAdapter; +import com.jme3.input.controls.ActionListener; +import com.jme3.input.controls.MouseButtonTrigger; +import com.jme3.input.event.TouchEvent; +import com.jme3.light.DirectionalLight; +import com.jme3.material.Material; +import com.jme3.math.ColorRGBA; +import com.jme3.math.Ray; +import com.jme3.math.Vector2f; +import com.jme3.math.Vector3f; +import com.jme3.scene.Geometry; +import com.jme3.scene.shape.Box; +import com.jme3.system.AppSettings; +import com.jme3.system.JmeSystem; + +public class TestDeviceRumble extends SimpleApplication { + + private static final String CLICK_MAPPING = "ClickCube"; + + private Geometry cube; + private Material cubeMaterial; + private BitmapText statusText; + private boolean rumbling; + + public static void main(String[] args) { + TestDeviceRumble app = new TestDeviceRumble(); + AppSettings settings = new AppSettings(true); + settings.setUseJoysticks(false); + app.setSettings(settings); + app.start(); + } + + @Override + public void simpleInitApp() { + flyCam.setEnabled(false); + inputManager.setCursorVisible(true); + + cubeMaterial = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md"); + cubeMaterial.setColor("Color", ColorRGBA.Blue); + + cube = new Geometry("Device rumble cube", new Box(1.25f, 1.25f, 1.25f)); + cube.setMaterial(cubeMaterial); + rootNode.attachChild(cube); + + DirectionalLight light = new DirectionalLight(); + light.setDirection(new Vector3f(-1f, -1f, -1f).normalizeLocal()); + rootNode.addLight(light); + + cam.setLocation(new Vector3f(0f, 0f, 6f)); + cam.lookAt(Vector3f.ZERO, Vector3f.UNIT_Y); + + statusText = new BitmapText(guiFont); + statusText.setLocalTranslation(12f, cam.getHeight() - 12f, 0f); + guiNode.attachChild(statusText); + updateStatusText(); + + inputManager.addMapping(CLICK_MAPPING, new MouseButtonTrigger(MouseInput.BUTTON_LEFT)); + inputManager.addListener((ActionListener) (name, isPressed, tpf) -> { + if (isPressed) { + startRumble(inputManager.getCursorPosition()); + } else { + stopRumble(); + } + }, CLICK_MAPPING); + + inputManager.addRawInputListener(new RawInputListenerAdapter() { + @Override + public void onTouchEvent(TouchEvent evt) { + if (evt.getType() == TouchEvent.Type.DOWN) { + startRumble(new Vector2f(evt.getX(), evt.getY())); + } else if (evt.getType() == TouchEvent.Type.UP) { + stopRumble(); + } + } + }); + } + + private void startRumble(Vector2f screenPosition) { + if (!isCubeHit(screenPosition)) { + return; + } + + rumbling = true; + cubeMaterial.setColor("Color", ColorRGBA.Red); + if (JmeSystem.isDeviceRumbleSupported()) { + JmeSystem.rumble(1f, 1f, Float.POSITIVE_INFINITY); + } + updateStatusText(); + } + + private void stopRumble() { + if (!rumbling) { + return; + } + rumbling = false; + JmeSystem.stopRumble(); + cubeMaterial.setColor("Color", ColorRGBA.Blue); + updateStatusText(); + } + + private boolean isCubeHit(Vector2f screenPosition) { + Vector3f origin = cam.getWorldCoordinates(screenPosition, 0f); + Vector3f direction = cam.getWorldCoordinates(screenPosition, 1f).subtractLocal(origin).normalizeLocal(); + CollisionResults results = new CollisionResults(); + cube.collideWith(new Ray(origin, direction), results); + return results.size() > 0; + } + + private void updateStatusText() { + String state = rumbling ? "rumbling" : "idle"; + String supported = JmeSystem.isDeviceRumbleSupported() ? "supported" : "not supported"; + statusText.setText("Device rumble: " + supported + "\nHold the cube to test\nState: " + state); + } +} diff --git a/jme3-examples/src/main/java/jme3test/input/TestJoystick.java b/jme3-examples/src/main/java/jme3test/input/TestJoystick.java index 6ef8435d98..d00b08aa28 100644 --- a/jme3-examples/src/main/java/jme3test/input/TestJoystick.java +++ b/jme3-examples/src/main/java/jme3test/input/TestJoystick.java @@ -29,6 +29,8 @@ import com.jme3.scene.Node; import com.jme3.scene.shape.Quad; import com.jme3.system.AppSettings; +import com.jme3.system.JmeSystem; +import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.io.PrintWriter; @@ -42,6 +44,7 @@ public class TestJoystick extends SimpleApplication { private Node joystickInfo; private float yInfo = 0; private JoystickButton lastButton; + private static final float SHORT_RUMBLE_TIME = 0.4f; public static void main(String[] args){ TestJoystick app = new TestJoystick(); @@ -62,11 +65,16 @@ public void simpleInitApp() { throw new IllegalStateException("Cannot find any joysticks!"); try { - PrintWriter out = new PrintWriter( new FileWriter( "joysticks-" + System.currentTimeMillis() + ".txt" ) ); - dumpJoysticks( joysticks, out ); - out.close(); + File storageFolder = JmeSystem.getStorageFolder(); + if (storageFolder != null) { + storageFolder.mkdirs(); + } + File dumpFile = new File(storageFolder, "joysticks-" + System.currentTimeMillis() + ".txt"); + try (PrintWriter out = new PrintWriter(new FileWriter(dumpFile))) { + dumpJoysticks(joysticks, out); + } } catch( IOException e ) { - throw new RuntimeException( "Error writing joystick dump", e ); + System.err.println( "Error writing joystick dump: " + e ); } @@ -137,6 +145,7 @@ protected void setViewedJoystick( Joystick stick ) { yInfo = 0; addInfo( "Joystick:\"" + stick.getName() + "\" id:" + stick.getJoyId(), 0 ); + addInfo( "Rumble: buttons=joystick, dpad=device", 0 ); yInfo -= 5; @@ -158,6 +167,118 @@ protected void setViewedJoystick( Joystick stick ) { } } + protected void handleRumbleButton(JoystickButton button, boolean pressed) { + String logicalId = button.getLogicalId(); + if (isDpadButton(logicalId)) { + handleDeviceRumbleButton(logicalId, pressed); + return; + } + + Joystick joystick = button.getJoystick(); + if (JoystickButton.BUTTON_XBOX_B.equals(logicalId)) { + if (pressed) { + joystick.rumble(1f, 0.35f, SHORT_RUMBLE_TIME); + } + return; + } + + if (!pressed) { + joystick.stopRumble(); + return; + } + + float[] pattern = getJoystickRumblePattern(button); + joystick.rumble(pattern[0], pattern[1], Float.POSITIVE_INFINITY); + } + + protected void handleTriggerRumble(JoystickAxis axis, float value) { + String logicalId = axis.getLogicalId(); + if (!JoystickAxis.AXIS_XBOX_LEFT_TRIGGER.equals(logicalId) + && !JoystickAxis.AXIS_XBOX_RIGHT_TRIGGER.equals(logicalId)) { + return; + } + + Joystick joystick = axis.getJoystick(); + if (value <= 0f) { + joystick.stopRumble(); + return; + } + + float amount = FastMath.clamp(value, 0f, 1f); + if (JoystickAxis.AXIS_XBOX_LEFT_TRIGGER.equals(logicalId)) { + joystick.rumble(0.25f + amount * 0.35f, amount, Float.POSITIVE_INFINITY); + } else { + joystick.rumble(amount, 0.25f + amount * 0.35f, Float.POSITIVE_INFINITY); + } + } + + protected boolean isDpadButton(String logicalId) { + return JoystickButton.BUTTON_XBOX_DPAD_UP.equals(logicalId) + || JoystickButton.BUTTON_XBOX_DPAD_RIGHT.equals(logicalId) + || JoystickButton.BUTTON_XBOX_DPAD_DOWN.equals(logicalId) + || JoystickButton.BUTTON_XBOX_DPAD_LEFT.equals(logicalId); + } + + protected void handleDeviceRumbleButton(String logicalId, boolean pressed) { + if (!JmeSystem.isDeviceRumbleSupported()) { + return; + } + if (!pressed) { + JmeSystem.stopRumble(); + return; + } + + float high = 0.5f; + float low = 0.5f; + if (JoystickButton.BUTTON_XBOX_DPAD_UP.equals(logicalId)) { + high = 1f; + low = 0.2f; + } else if (JoystickButton.BUTTON_XBOX_DPAD_RIGHT.equals(logicalId)) { + high = 0.2f; + low = 1f; + } else if (JoystickButton.BUTTON_XBOX_DPAD_DOWN.equals(logicalId)) { + high = 0.35f; + low = 0.35f; + } else if (JoystickButton.BUTTON_XBOX_DPAD_LEFT.equals(logicalId)) { + high = 0.75f; + low = 0.75f; + } + + JmeSystem.rumble(high, low, Float.POSITIVE_INFINITY); + } + + protected float[] getJoystickRumblePattern(JoystickButton button) { + String logicalId = button.getLogicalId(); + if (JoystickButton.BUTTON_XBOX_A.equals(logicalId)) { + return new float[]{1f, 1f}; + } else if (JoystickButton.BUTTON_XBOX_X.equals(logicalId)) { + return new float[]{0.25f, 1f}; + } else if (JoystickButton.BUTTON_XBOX_Y.equals(logicalId)) { + return new float[]{1f, 0.25f}; + } else if (JoystickButton.BUTTON_XBOX_LB.equals(logicalId)) { + return new float[]{0.15f, 0.7f}; + } else if (JoystickButton.BUTTON_XBOX_RB.equals(logicalId)) { + return new float[]{0.7f, 0.15f}; + } else if (JoystickButton.BUTTON_XBOX_LT.equals(logicalId)) { + return new float[]{0.45f, 0.9f}; + } else if (JoystickButton.BUTTON_XBOX_RT.equals(logicalId)) { + return new float[]{0.9f, 0.45f}; + } else if (JoystickButton.BUTTON_XBOX_BACK.equals(logicalId)) { + return new float[]{0.3f, 0.3f}; + } else if (JoystickButton.BUTTON_XBOX_START.equals(logicalId)) { + return new float[]{0.6f, 0.6f}; + } else if (JoystickButton.BUTTON_XBOX_L3.equals(logicalId)) { + return new float[]{0.2f, 0.5f}; + } else if (JoystickButton.BUTTON_XBOX_R3.equals(logicalId)) { + return new float[]{0.5f, 0.2f}; + } + + int index = Math.abs(button.getButtonId()); + float high = 0.2f + (index % 5) * 0.16f; + float low = 1f - (index % 4) * 0.18f; + return new float[]{high, low}; + } + /** * Easier to watch for all button and axis events with a raw input listener. */ @@ -184,9 +305,9 @@ public void onJoyAxisEvent(JoyAxisEvent evt) { } setViewedJoystick( evt.getAxis().getJoystick() ); gamepad.setAxisValue( evt.getAxis(), value ); + handleTriggerRumble(evt.getAxis(), value); if( value != 0 ) { lastValues.put(evt.getAxis(), value); - evt.getAxis().getJoystick().rumble(0.5f); } } @@ -194,7 +315,7 @@ public void onJoyAxisEvent(JoyAxisEvent evt) { public void onJoyButtonEvent(JoyButtonEvent evt) { setViewedJoystick( evt.getButton().getJoystick() ); gamepad.setButtonValue( evt.getButton(), evt.isPressed() ); - evt.getButton().getJoystick().rumble(1f); + handleRumbleButton(evt.getButton(), evt.isPressed()); } @Override diff --git a/jme3-examples/src/main/java/jme3test/light/pbr/TestPBRSimple.java b/jme3-examples/src/main/java/jme3test/light/pbr/TestPBRSimple.java index 2447800759..0e1e2af20a 100644 --- a/jme3-examples/src/main/java/jme3test/light/pbr/TestPBRSimple.java +++ b/jme3-examples/src/main/java/jme3test/light/pbr/TestPBRSimple.java @@ -105,7 +105,6 @@ public void simpleInitApp() { updateMaterial(); - } @@ -153,4 +152,5 @@ private void updateMaterial() { "Tank material -> metallic: " + metallic + ", roughness: " + roughness + " (N/P, F toggles SH fast path)"); } + } diff --git a/jme3-ios-examples/build.gradle b/jme3-ios-examples/build.gradle new file mode 100644 index 0000000000..9977512407 --- /dev/null +++ b/jme3-ios-examples/build.gradle @@ -0,0 +1,391 @@ +buildscript { + repositories { + def libJGLIOSPluginRepositories = [] + if ((findProperty('libJGLIOSLocal') ?: 'false').toString().toBoolean()) { + libJGLIOSPluginRepositories << mavenLocal() + } + libJGLIOSPluginRepositories << maven { + name = 'CentralSnapshots' + url = uri('https://central.sonatype.com/repository/maven-snapshots/') + mavenContent { + snapshotsOnly() + } + } + exclusiveContent { + forRepositories(*libJGLIOSPluginRepositories) + filter { + includeModule('org.ngengine', 'libjglios-gradle-plugin') + } + } + gradlePluginPortal() + mavenCentral() + } + dependencies { + classpath 'org.ngengine:libjglios-gradle-plugin:0.1.0-SNAPSHOT' + } +} + +import groovy.json.JsonOutput +import java.io.DataInputStream +import java.util.zip.ZipFile + +apply plugin: 'org.ngengine.libjglios' + +description = 'iOS libJGLIOS launcher for jme3-examples.' + +repositories { + def libJGLIOSRepositories = [] + if ((findProperty('libJGLIOSLocal') ?: 'false').toString().toBoolean()) { + libJGLIOSRepositories << mavenLocal() + } + libJGLIOSRepositories << maven { + name = 'CentralSnapshots' + url = uri('https://central.sonatype.com/repository/maven-snapshots/') + mavenContent { + snapshotsOnly() + } + } + exclusiveContent { + forRepositories(*libJGLIOSRepositories) + filter { + includeModule('org.ngengine', 'libjglios-core-ios') + includeModule('org.ngengine', 'libjglios-gles-ios') + includeModule('org.ngengine', 'libjglios-sdl3-ios') + includeModule('org.ngengine', 'libjglios-openal-ios') + includeModule('org.ngengine', 'libjglios-angle-ios') + } + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +def examplesJar = project(':jme3-examples').tasks.named('jar') +def generateExamplesTestChooserClassList = project(':jme3-examples').tasks.named('generateTestChooserClassList') +def generatedExamplesTestChooserResourcesDir = project(':jme3-examples').layout.buildDirectory.dir('generated/testchooser/resources') +def examplesTestClassListFile = generatedExamplesTestChooserResourcesDir.map { it.file('jme3test/test-classes.txt') } +def iosChooserLauncherClass = 'jme3test.ios.IosTestChooserLauncher' +def requestedExampleClass = findProperty('example')?.toString()?.trim() +def exampleClass = requestedExampleClass ?: iosChooserLauncherClass +def iosExampleClassesDir = layout.buildDirectory.dir('ios-example-classes') +def iosInitialExampleSourceDir = layout.buildDirectory.dir('generated/ios-initial-example/sources') +def iosNativeImageMetadataDir = layout.buildDirectory.dir('generated/ios-native-image-metadata/resources') +def iosChooserExcludedPrefixes = [ + 'jme3test.awt.', + 'jme3test.bullet.', + 'jme3test.niftygui.', + 'jme3test.opencl.', + 'jme3test.terrain.' +] +def iosChooserExcludedNames = [ + 'jme3test.app.TestChangeAppIcon', + 'jme3test.app.TestContextRestart', + 'jme3test.app.TestMonitorApp', + 'jme3test.app.TestResizableApp', + 'jme3test.asset.TestOnlineJar', + 'jme3test.audio.TestAudioDeviceDisconnect' +] +def isIosChooserClass = { String className -> + !iosChooserExcludedPrefixes.any { className.startsWith(it) } + && !iosChooserExcludedNames.contains(className) + && !className.contains('Jogl') + && !className.contains('Lwjgl') +} +def readExamplesTestClassList = { + def file = examplesTestClassListFile.get().asFile + if (!file.exists()) { + return [] + } + file.readLines('UTF-8') + .collect { it.trim() } + .findAll { it } + .findAll { isIosChooserClass(it) } +} +def selectedIosExampleClasses = { + requestedExampleClass ? [requestedExampleClass] : readExamplesTestClassList() +} +def javaStringLiteral = { String value -> + if (value == null) { + return 'null' + } + '"' + value + .replace('\\', '\\\\') + .replace('"', '\\"') + .replace('\n', '\\n') + .replace('\r', '\\r') + '"' +} +def readClassReferences = { ZipFile zip, String classPath -> + def entry = zip.getEntry("${classPath}.class") + if (entry == null) { + return [] as Set + } + zip.getInputStream(entry).withCloseable { input -> + def data = new DataInputStream(input) + if (data.readInt() != (int) 0xCAFEBABE) { + return [] as Set + } + data.readUnsignedShort() + data.readUnsignedShort() + def constantPool = new Object[data.readUnsignedShort()] + for (int index = 1; index < constantPool.length; index++) { + int tag = data.readUnsignedByte() + switch (tag) { + case 1: + constantPool[index] = data.readUTF() + break + case 3: + case 4: + data.skipBytes(4) + break + case 5: + case 6: + data.skipBytes(8) + index++ + break + case 7: + case 8: + case 16: + case 19: + case 20: + constantPool[index] = [tag: tag, nameIndex: data.readUnsignedShort()] + break + case 9: + case 10: + case 11: + case 12: + case 18: + data.skipBytes(4) + break + case 15: + data.skipBytes(3) + break + case 17: + data.skipBytes(4) + break + default: + throw new GradleException("Unsupported constant-pool tag ${tag} in ${classPath}.class") + } + } + constantPool.findAll { it instanceof Map && it.tag == 7 } + .collect { constantPool[it.nameIndex] } + .findAll { it instanceof String && it.startsWith('jme3test/') && !it.startsWith('jme3test/ios/') } + .collect { it.replace('/', '.') } + .findAll { !it.contains('[') } as Set + } +} +def expandExamplesClassNames = { Collection rootClasses -> + def expanded = new LinkedHashSet(rootClasses) + def jarFile = examplesJar.flatMap { it.archiveFile }.get().asFile + new ZipFile(jarFile).withCloseable { zip -> + def availablePaths = new LinkedHashSet() + zip.entries().each { entry -> + if (entry.directory || !entry.name.endsWith('.class')) { + return + } + def classPath = entry.name.substring(0, entry.name.length() - '.class'.length()) + if (classPath.startsWith('jme3test/')) { + availablePaths.add(classPath) + } + } + + def pending = new ArrayDeque(expanded) + while (!pending.isEmpty()) { + def className = pending.removeFirst() + def rootPath = className.replace('.', '/') + availablePaths.findAll { it.startsWith("${rootPath}\$") }.each { classPath -> + def nestedClass = classPath.replace('/', '.') + if (expanded.add(nestedClass)) { + pending.add(nestedClass) + } + } + readClassReferences(zip, rootPath).each { referencedClass -> + def referencedPath = referencedClass.replace('.', '/') + if (availablePaths.contains(referencedPath) && expanded.add(referencedClass)) { + pending.add(referencedClass) + } + } + } + } + expanded as List +} +def collectRuntimeClassNamesByPrefix = { Collection prefixes -> + def classes = new LinkedHashSet() + sourceSets.main.compileClasspath.files.findAll { it.exists() }.each { file -> + if (file.isDirectory()) { + file.eachFileRecurse { classFile -> + if (!classFile.name.endsWith('.class')) { + return + } + def relativePath = file.toPath().relativize(classFile.toPath()).toString().replace(File.separatorChar, (char) '/') + def className = relativePath.substring(0, relativePath.length() - '.class'.length()).replace('/', '.') + if (prefixes.any { className.startsWith(it) } && !className.endsWith('module-info') && !className.endsWith('package-info')) { + classes.add(className) + } + } + } else if (file.name.endsWith('.jar')) { + new ZipFile(file).withCloseable { zip -> + zip.entries().each { entry -> + if (entry.directory || !entry.name.endsWith('.class')) { + return + } + def className = entry.name.substring(0, entry.name.length() - '.class'.length()).replace('/', '.') + if (prefixes.any { className.startsWith(it) } && !className.endsWith('module-info') && !className.endsWith('package-info')) { + classes.add(className) + } + } + } + } + } + classes as List +} + +def prepareIosExampleClasses = tasks.register('prepareIosExampleClasses', Sync) { + dependsOn examplesJar, generateExamplesTestChooserClassList + inputs.property('exampleClass', exampleClass) + inputs.property('requestedExampleClass', requestedExampleClass ?: '') + inputs.property('iosExampleClassExpansionVersion', 'bytecode-reference-v1') + inputs.property('iosExampleIncludes', findProperty('iosExampleIncludes')?.toString() ?: '') + inputs.file(examplesTestClassListFile) + inputs.file(examplesJar.flatMap { it.archiveFile }) + from(zipTree(examplesJar.flatMap { it.archiveFile })) { + def expandedClasses + include { details -> + expandedClasses = expandedClasses ?: expandExamplesClassNames(selectedIosExampleClasses()) + expandedClasses.any { className -> details.path == "${className.replace('.', '/')}.class" } + } + def extraIncludes = findProperty('iosExampleIncludes')?.toString() + if (extraIncludes) { + extraIncludes.split(',').collect { it.trim() }.findAll { it }.each { include it } + } + } + into iosExampleClassesDir +} + +def generateIosInitialExampleSource = tasks.register('generateIosInitialExampleSource') { + def outputFile = iosInitialExampleSourceDir.map { it.file('jme3test/ios/IosInitialExample.java') } + outputs.file(outputFile) + inputs.property('requestedExampleClass', requestedExampleClass ?: '') + doLast { + def file = outputFile.get().asFile + file.parentFile.mkdirs() + file.text = """package jme3test.ios; + +final class IosInitialExample { + private static final String CLASS_NAME = ${javaStringLiteral(requestedExampleClass)}; + + private IosInitialExample() { + } + + static String className() { + return CLASS_NAME; + } +} +""" + } +} + +def generateIosNativeImageMetadata = tasks.register('generateIosNativeImageMetadata') { + dependsOn examplesJar, generateExamplesTestChooserClassList, tasks.named('prepareGraalHostNik') + def reflectConfig = iosNativeImageMetadataDir.map { + it.file('META-INF/native-image/org.jmonkeyengine/jme3-ios-testchooser/reflect-config.json') + } + outputs.file(reflectConfig) + inputs.property('exampleClass', exampleClass) + inputs.property('requestedExampleClass', requestedExampleClass ?: '') + inputs.property('iosExampleClassExpansionVersion', 'bytecode-reference-v1') + inputs.file(examplesTestClassListFile) + inputs.file(examplesJar.flatMap { it.archiveFile }) + inputs.files({ sourceSets.main.compileClasspath }) + doLast { + def exampleClasses = selectedIosExampleClasses() + def classes = (expandExamplesClassNames(exampleClasses) + + collectRuntimeClassNamesByPrefix(['com.bulletphysics.']) + + [iosChooserLauncherClass, 'jme3test.ios.IosTestChooser', 'jme3test.ios.IosInitialExample']).unique() + def metadata = classes.collect { className -> + [ + name: className, + allDeclaredConstructors: true, + allPublicConstructors: true, + allPublicMethods: true + ] + } + def outputFile = reflectConfig.get().asFile + outputFile.parentFile.mkdirs() + outputFile.text = JsonOutput.prettyPrint(JsonOutput.toJson(metadata)) + System.lineSeparator() + } +} + +libJGLIOS { + mainClass = iosChooserLauncherClass + bundleId = 'org.jmonkeyengine.jme3iosexamples' + appName = 'JmeIosExamples' + simulatorDevice = (findProperty('iosSimulatorDevice') ?: 'iPhone 16').toString() +} + +sourceSets { + main { + java.srcDir iosInitialExampleSourceDir + output.dir(iosExampleClassesDir, builtBy: prepareIosExampleClasses) + resources { + srcDir iosNativeImageMetadataDir + srcDir generatedExamplesTestChooserResourcesDir + srcDir '../jme3-testdata/src/main/resources' + srcDir '../jme3-examples/src/main/resources' + } + } +} + +dependencies { + implementation project(':jme3-core') + implementation project(':jme3-ios') + implementation 'org.ngengine:libjglios-core-ios:0.1.0-SNAPSHOT' + implementation 'org.ngengine:libjglios-gles-ios:0.1.0-SNAPSHOT' + implementation 'org.ngengine:libjglios-sdl3-ios:0.1.0-SNAPSHOT' + implementation 'org.ngengine:libjglios-openal-ios:0.1.0-SNAPSHOT' + implementation 'org.ngengine:libjglios-angle-ios:0.1.0-SNAPSHOT' + implementation project(':jme3-effects') + implementation project(':jme3-jbullet') + implementation project(':jme3-jogg') + implementation project(':jme3-networking') + implementation project(':jme3-plugins') + implementation project(':jme3-plugins-json') + implementation project(':jme3-plugins-json-gson') + + if ((findProperty('iosIncludeAwtUnsafeModules') ?: 'false').toString().toBoolean()) { + implementation project(':jme3-niftygui') + implementation project(':jme3-terrain') + } +} + +tasks.named('compileJava') { + dependsOn prepareIosExampleClasses, generateIosInitialExampleSource + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + options.release = 11 +} + +tasks.named('processResources') { + dependsOn generateIosNativeImageMetadata, generateExamplesTestChooserClassList +} + +tasks.register('runIosExamples') { + group = 'verification' + description = 'Builds, installs, and launches the jme3-examples iOS app.' + dependsOn tasks.named('runIosApp') +} + +tasks.register('printIosExamplesRunHelp') { + group = 'help' + description = 'Prints how to run jme3-examples on iOS through libJGLIOS.' + doLast { + println "Run :jme3-ios-examples:runIosExamples for the iOS test chooser." + println "Run :jme3-ios-examples:runIosExamples -Pexample=jme3test.helloworld.HelloJME3 to open one example automatically through the chooser launcher." + println "runIosExamples uses an available connected device automatically, otherwise it opens the simulator." + println "Use -PiosAppTarget=simulator to force simulator, or -PiosDevice='' to force a device." + println "For device signing, add -PiosSigningIdentity, -PiosProvisioningProfile, and optionally -PiosCodesignEntitlements." + println "For examples with helper classes, add -PiosExampleIncludes='jme3test/path/**'." + println "Terrain and niftygui are excluded by default because they pull java.awt into native-image; opt in with -PiosIncludeAwtUnsafeModules=true." + } +} diff --git a/jme3-ios-examples/src/main/java/jme3test/ios/IosTestChooser.java b/jme3-ios-examples/src/main/java/jme3test/ios/IosTestChooser.java new file mode 100644 index 0000000000..2e6acd5ad5 --- /dev/null +++ b/jme3-ios-examples/src/main/java/jme3test/ios/IosTestChooser.java @@ -0,0 +1,428 @@ +package jme3test.ios; + +import com.jme3.app.SimpleApplication; +import com.jme3.font.BitmapText; +import com.jme3.input.KeyInput; +import com.jme3.input.MouseInput; +import com.jme3.input.RawInputListenerAdapter; +import com.jme3.input.controls.ActionListener; +import com.jme3.input.controls.MouseButtonTrigger; +import com.jme3.input.event.KeyInputEvent; +import com.jme3.input.event.TouchEvent; +import com.jme3.material.Material; +import com.jme3.math.ColorRGBA; +import com.jme3.math.Vector2f; +import com.jme3.renderer.queue.RenderQueue; +import com.jme3.scene.Geometry; +import com.jme3.scene.shape.Quad; +import com.jme3.system.JmeSystem; +import java.util.ArrayList; +import java.util.List; + +public final class IosTestChooser extends SimpleApplication implements ActionListener { + private static final String SELECT_MAPPING = "IosTestChooserSelect"; + private static final float MIN_TEXT_SIZE = 10f; + private static final long TAP_DEBOUNCE_NANOS = 180_000_000L; + private static final ColorRGBA BACKGROUND_COLOR = new ColorRGBA(0.045f, 0.060f, 0.050f, 1f); + private static final ColorRGBA EXAMPLE_BUTTON_COLOR = new ColorRGBA(0.135f, 0.305f, 0.145f, 1f); + private static final ColorRGBA ACTION_BUTTON_COLOR = new ColorRGBA(0.760f, 0.360f, 0.060f, 1f); + private static final ColorRGBA DISABLED_BUTTON_COLOR = new ColorRGBA(0.090f, 0.105f, 0.095f, 1f); + private static final ColorRGBA SEARCH_BUTTON_COLOR = new ColorRGBA(0.070f, 0.085f, 0.075f, 1f); + private static final ColorRGBA PRIMARY_TEXT_COLOR = new ColorRGBA(0.930f, 0.960f, 0.900f, 1f); + private static final ColorRGBA MUTED_TEXT_COLOR = new ColorRGBA(0.500f, 0.560f, 0.480f, 1f); + private static final ColorRGBA SEARCH_TEXT_COLOR = new ColorRGBA(0.765f, 0.910f, 0.555f, 1f); + + private final List