diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8165e66..3aaf41e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -12,8 +12,8 @@ android { minSdk = 26 targetSdk = 36 - versionCode = 6 - versionName = "1.0.5" + versionCode = 7 + versionName = "1.0.6" ndkVersion = "29.0.14206865" diff --git a/android/app/src/main/cpp/android_input_system.cpp b/android/app/src/main/cpp/android_input_system.cpp index 93d833a..e3cebb1 100644 --- a/android/app/src/main/cpp/android_input_system.cpp +++ b/android/app/src/main/cpp/android_input_system.cpp @@ -56,7 +56,7 @@ void AndroidInputSystem::SetVirtualWheelEnabled(bool enabled) m_virtualWheelEnabled = enabled; m_wheelFingerActive = false; m_wheelFinger = 0; - m_virtualJoyX = 0; + m_virtualJoyX.store(0, std::memory_order_relaxed); m_virtualJoyY = 0; if (enabled && !m_virtualAnalogGunEnabled) { @@ -86,7 +86,7 @@ void AndroidInputSystem::SetVirtualAnalogGunEnabled(bool enabled) else std::strncpy(m_virtualJoyDetails.name, "Touch Controls", MAX_NAME_LENGTH); m_virtualJoyDetails.name[MAX_NAME_LENGTH] = '\0'; - m_virtualJoyX = 0; + m_virtualJoyX.store(0, std::memory_order_relaxed); m_virtualJoyY = 0; } @@ -103,24 +103,37 @@ bool AndroidInputSystem::UseVirtualJoystick() const void AndroidInputSystem::SetVirtualSteerFromEncoded(float encodedX) { // encodedX comes from Java as [(steer+1)/2], where steer is in [-1,1]. - const float steer = std::clamp((encodedX - 0.5f) * 2.0f, -1.0f, 1.0f); + SetVirtualSteerFromSteer((encodedX - 0.5f) * 2.0f); +} + +void AndroidInputSystem::SetVirtualSteerFromSteer(float steer) +{ + steer = std::clamp(steer, -1.0f, 1.0f); constexpr float deadzone = 0.08f; if (std::abs(steer) < deadzone) { - m_virtualJoyX = 0; + m_virtualJoyX.store(0, std::memory_order_relaxed); return; } const float scaled = (std::abs(steer) - deadzone) / (1.0f - deadzone); const float signedScaled = (steer < 0.0f) ? -scaled : scaled; - m_virtualJoyX = (int)std::lround(std::clamp(signedScaled, -1.0f, 1.0f) * 32767.0f); + m_virtualJoyX.store( + (int)std::lround(std::clamp(signedScaled, -1.0f, 1.0f) * 32767.0f), + std::memory_order_relaxed + ); +} + +void AndroidInputSystem::SetGyroSteer(float steer) +{ + SetVirtualSteerFromSteer(steer); } void AndroidInputSystem::SetVirtualJoyFromNormalized(float x, float y) { const float sx = std::clamp((x - 0.5f) * 2.0f, -1.0f, 1.0f); const float sy = std::clamp((y - 0.5f) * 2.0f, -1.0f, 1.0f); - m_virtualJoyX = (int)std::lround(sx * 32767.0f); + m_virtualJoyX.store((int)std::lround(sx * 32767.0f), std::memory_order_relaxed); m_virtualJoyY = (int)std::lround(sy * 32767.0f); } @@ -217,7 +230,7 @@ bool AndroidInputSystem::InitializeSystem() m_gunFinger = 0; m_wheelFingerActive = false; m_wheelFinger = 0; - m_virtualJoyX = 0; + m_virtualJoyX.store(0, std::memory_order_relaxed); m_virtualJoyY = 0; m_lastVirtualGear = -1; @@ -989,7 +1002,7 @@ int AndroidInputSystem::GetJoyAxisValue(int joyNum, int axisNum) if (UseVirtualJoystick()) { if (axisNum == AXIS_X) - return m_virtualJoyX; + return m_virtualJoyX.load(std::memory_order_relaxed); if (axisNum == AXIS_Y && m_virtualJoyDetails.hasAxis[AXIS_Y]) return m_virtualJoyY; return 0; diff --git a/android/app/src/main/cpp/android_input_system.h b/android/app/src/main/cpp/android_input_system.h index 090a5bb..429a95e 100644 --- a/android/app/src/main/cpp/android_input_system.h +++ b/android/app/src/main/cpp/android_input_system.h @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -24,6 +25,7 @@ public: void SetVirtualWheelEnabled(bool enabled); void SetVirtualShifterMode(bool shift4, bool shiftUpDown); void SetVirtualAnalogGunEnabled(bool enabled); + void SetGyroSteer(float steer); // [-1..1] // Called from the SDL event loop (main thread). void HandleEvent(const SDL_Event& ev); @@ -84,6 +86,7 @@ private: bool UseVirtualWheel() const; bool UseVirtualJoystick() const; void SetVirtualSteerFromEncoded(float encodedX); + void SetVirtualSteerFromSteer(float steer); void SetVirtualJoyFromNormalized(float x, float y); void SetKey(SDL_Scancode sc, bool down); @@ -172,7 +175,7 @@ private: bool m_virtualWheelEnabled = false; SDL_FingerID m_wheelFinger = 0; bool m_wheelFingerActive = false; - int m_virtualJoyX = 0; + std::atomic m_virtualJoyX{0}; int m_virtualJoyY = 0; JoyDetails m_virtualJoyDetails{}; diff --git a/android/app/src/main/cpp/native-lib.cpp b/android/app/src/main/cpp/native-lib.cpp index e641d1d..8e2fa0e 100644 --- a/android/app/src/main/cpp/native-lib.cpp +++ b/android/app/src/main/cpp/native-lib.cpp @@ -986,3 +986,13 @@ Java_com_izzy2lost_super3_Super3Activity_nativeGetLoadedGameName(JNIEnv* env, jo return nullptr; return env->NewStringUTF(g_host->game.name.c_str()); } + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_izzy2lost_super3_Super3Activity_nativeSetGyroSteer(JNIEnv*, jobject, jfloat steer) +{ + if (!g_host) + return JNI_FALSE; + const float clamped = std::clamp((float)steer, -1.0f, 1.0f); + g_host->inputSystem.SetGyroSteer(clamped); + return JNI_TRUE; +} diff --git a/android/app/src/main/java/com/izzy2lost/super3/MainActivity.kt b/android/app/src/main/java/com/izzy2lost/super3/MainActivity.kt index d11b044..d27e664 100644 --- a/android/app/src/main/java/com/izzy2lost/super3/MainActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/super3/MainActivity.kt @@ -169,6 +169,8 @@ class MainActivity : AppCompatActivity() { btnEnhancedReal3d = headerView.findViewById(R.id.btn_enhanced_real3d) val btnShowTouchControls: MaterialButton = headerView.findViewById(R.id.btn_show_touch_controls) val btnShowShifterOverlay: MaterialButton = headerView.findViewById(R.id.btn_show_shifter_overlay) + val btnGyroSteering: MaterialButton = headerView.findViewById(R.id.btn_gyro_steering) + val btnGyroSensitivity: MaterialButton = headerView.findViewById(R.id.btn_gyro_sensitivity) gamesAdapter = GamesAdapter { item -> if (!item.launchable) { @@ -217,6 +219,38 @@ class MainActivity : AppCompatActivity() { ).show() } + btnGyroSteering.isChecked = prefs.getBoolean("gyro_steering_enabled", false) + btnGyroSteering.setOnClickListener { + val enabled = btnGyroSteering.isChecked + prefs.edit().putBoolean("gyro_steering_enabled", enabled).apply() + Toast.makeText( + this, + if (enabled) "Gyro steering enabled (restart game to apply)" else "Gyro steering disabled", + Toast.LENGTH_SHORT, + ).show() + } + + data class GyroPreset(val label: String, val scale: Float) + val gyroPresets = listOf( + GyroPreset("Less", 0.65f), + GyroPreset("Normal", 1.0f), + GyroPreset("More", 1.35f), + ) + fun updateGyroSensitivityLabel() { + val cur = prefs.getFloat("gyro_steering_sensitivity", 1.0f) + val match = gyroPresets.minByOrNull { kotlin.math.abs(it.scale - cur) } ?: gyroPresets[1] + btnGyroSensitivity.text = "Gyro sensitivity: ${match.label}" + } + updateGyroSensitivityLabel() + btnGyroSensitivity.setOnClickListener { + val cur = prefs.getFloat("gyro_steering_sensitivity", 1.0f) + val idx = gyroPresets.indexOfFirst { kotlin.math.abs(it.scale - cur) < 0.01f }.let { if (it < 0) 1 else it } + val next = gyroPresets[(idx + 1) % gyroPresets.size] + prefs.edit().putFloat("gyro_steering_sensitivity", next.scale).apply() + updateGyroSensitivityLabel() + Toast.makeText(this, "Gyro sensitivity: ${next.label} (restart game to apply)", Toast.LENGTH_SHORT).show() + } + runCatching { searchView.setupWithSearchBar(searchBar) } styleSearchBarText() searchBar.setOnClickListener { diff --git a/android/app/src/main/java/com/izzy2lost/super3/Super3Activity.kt b/android/app/src/main/java/com/izzy2lost/super3/Super3Activity.kt index ed229e2..cc1f215 100644 --- a/android/app/src/main/java/com/izzy2lost/super3/Super3Activity.kt +++ b/android/app/src/main/java/com/izzy2lost/super3/Super3Activity.kt @@ -7,10 +7,15 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.Build +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager import android.view.PixelCopy import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent +import android.view.Surface import android.view.View import android.view.ViewGroup import android.widget.FrameLayout @@ -40,6 +45,7 @@ class Super3Activity : SDLActivity() { private val mainHandler = Handler(Looper.getMainLooper()) private var overlayView: View? = null private var overlayControlsEnabled: Boolean = true + private var gyroSteeringEnabled: Boolean = false private var menuPaused = false private var saveDialogOpen = false private var exitDialogOpen = false @@ -49,6 +55,12 @@ class Super3Activity : SDLActivity() { private var gameName: String = "" private var userDataRoot: File? = null + private var gyroSensorManager: SensorManager? = null + private var gyroSensor: Sensor? = null + private var gyroListener: SensorEventListener? = null + private var gyroZeroRollRad: Float? = null + private var gyroFilteredSteer: Float = 0f + private data class SaveSlot( val slotIndex: Int, val title: String, @@ -80,6 +92,7 @@ class Super3Activity : SDLActivity() { super.onCreate(savedInstanceState) overlayControlsEnabled = prefs.getBoolean("overlay_controls_enabled", true) + gyroSteeringEnabled = prefs.getBoolean("gyro_steering_enabled", false) saveStateSlot = prefs.getInt("save_state_slot", 0).coerceIn(0, 9) gameName = intent.getStringExtra("gameName").orEmpty() val userDataRootPath = intent.getStringExtra("userDataRoot").orEmpty() @@ -502,6 +515,116 @@ class Super3Activity : SDLActivity() { } } + private fun angleDiffRad(a: Float, b: Float): Float { + var d = a - b + val twoPi = (Math.PI * 2.0).toFloat() + val pi = Math.PI.toFloat() + while (d > pi) d -= twoPi + while (d < -pi) d += twoPi + return d + } + + private fun startGyroSteering() { + if (!gyroSteeringEnabled) return + val game = gameName + val gamesXml = intent.getStringExtra("gamesXmlPath").orEmpty() + val isRacing = + game.isNotBlank() && + gamesXml.isNotBlank() && + GameInputsIndex.hasAnyInputType(gamesXml, game, setOf("vehicle", "harley")) + if (!isRacing) return + + val sensitivity = prefs.getFloat("gyro_steering_sensitivity", 1.0f).coerceIn(0.25f, 2.0f) + + if (gyroSensorManager == null) { + gyroSensorManager = getSystemService(SENSOR_SERVICE) as? SensorManager + } + val mgr = gyroSensorManager ?: return + + val sensor = + mgr.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR) + ?: mgr.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) + ?: return + gyroSensor = sensor + + gyroZeroRollRad = null + gyroFilteredSteer = 0f + + val listener = + object : SensorEventListener { + private val rotationMatrix = FloatArray(9) + private val adjusted = FloatArray(9) + private val orientation = FloatArray(3) + private val maxRollRad = Math.toRadians(28.0).toFloat() + + override fun onSensorChanged(event: SensorEvent) { + if (!gyroSteeringEnabled) return + if (menuPaused || userPaused) return + + SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) + + val rotation = display?.rotation ?: Surface.ROTATION_0 + when (rotation) { + Surface.ROTATION_0 -> rotationMatrix.copyInto(adjusted) + Surface.ROTATION_90 -> + SensorManager.remapCoordinateSystem( + rotationMatrix, + SensorManager.AXIS_Y, + SensorManager.AXIS_MINUS_X, + adjusted, + ) + Surface.ROTATION_180 -> + SensorManager.remapCoordinateSystem( + rotationMatrix, + SensorManager.AXIS_MINUS_X, + SensorManager.AXIS_MINUS_Y, + adjusted, + ) + Surface.ROTATION_270 -> + SensorManager.remapCoordinateSystem( + rotationMatrix, + SensorManager.AXIS_MINUS_Y, + SensorManager.AXIS_X, + adjusted, + ) + } + + SensorManager.getOrientation(adjusted, orientation) + val roll = orientation[2] + + val zero = gyroZeroRollRad + if (zero == null) { + gyroZeroRollRad = roll + nativeSetGyroSteer(0f) + return + } + + val delta = angleDiffRad(roll, zero) + val raw = ((delta / maxRollRad) * sensitivity).coerceIn(-1f, 1f) + gyroFilteredSteer = (gyroFilteredSteer * 0.85f) + (raw * 0.15f) + nativeSetGyroSteer(gyroFilteredSteer) + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + } + + gyroListener = listener + mgr.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_GAME) + } + + private fun stopGyroSteering() { + val mgr = gyroSensorManager + val listener = gyroListener + if (mgr != null && listener != null) { + mgr.unregisterListener(listener) + } + gyroListener = null + gyroSensor = null + gyroZeroRollRad = null + gyroFilteredSteer = 0f + nativeSetGyroSteer(0f) + } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { if (event.keyCode == KeyEvent.KEYCODE_BACK) { if (event.action == KeyEvent.ACTION_UP) { @@ -517,6 +640,7 @@ class Super3Activity : SDLActivity() { } override fun onDestroy() { + stopGyroSteering() overlayView?.let { v -> (v.parent as? ViewGroup)?.removeView(v) } @@ -527,6 +651,13 @@ class Super3Activity : SDLActivity() { override fun onResume() { super.onResume() applyImmersiveMode() + gyroSteeringEnabled = prefs.getBoolean("gyro_steering_enabled", false) + startGyroSteering() + } + + override fun onPause() { + stopGyroSteering() + super.onPause() } override fun onWindowFocusChanged(hasFocus: Boolean) { @@ -758,6 +889,7 @@ class Super3Activity : SDLActivity() { private external fun nativeRequestSaveState(slot: Int): Boolean private external fun nativeRequestLoadState(slot: Int): Boolean private external fun nativeGetLoadedGameName(): String? + private external fun nativeSetGyroSteer(steer: Float): Boolean private class SaveSlotAdapter( private val slots: List, diff --git a/android/app/src/main/res/layout/nav_header.xml b/android/app/src/main/res/layout/nav_header.xml index b58e0ea..87157f7 100644 --- a/android/app/src/main/res/layout/nav_header.xml +++ b/android/app/src/main/res/layout/nav_header.xml @@ -137,6 +137,23 @@ android:text="Show shifter overlay" style="?attr/materialButtonElevatedStyle" /> + + + +