From 177c6343e30ae9457a039d8331407c2dac33c4af Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Fri, 2 Aug 2024 02:51:30 +0300 Subject: [PATCH] Haptic feedback support for overlay controls & controller rumble --- .../activities/EmulationActivity.kt | 70 ++++++++++ .../input/model/ControllerInterface.kt | 34 ++--- .../input/model/DolphinVibratorManager.kt | 2 + .../model/DolphinVibratorManagerCompat.kt | 2 + .../model/DolphinVibratorManagerFactory.kt | 33 +++++ .../DolphinVibratorManagerPassthrough.kt | 2 + .../features/settings/model/BooleanSetting.kt | 18 +++ .../features/settings/model/FloatSetting.kt | 6 + .../dolphinemu/overlay/InputOverlay.kt | 49 ++++++- .../overlay/InputOverlayDrawableJoystick.kt | 53 +++++++- .../dolphinemu/utils/HapticsProvider.kt | 126 ++++++++++++++++++ .../main/res/layout/dialog_haptics_adjust.xml | 109 +++++++++++++++ .../res/menu/menu_overlay_controls_gc.xml | 4 + .../res/menu/menu_overlay_controls_wii.xml | 4 + .../app/src/main/res/values/strings.xml | 8 ++ .../ControllerInterface/Android/Android.cpp | 12 +- 16 files changed, 492 insertions(+), 40 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerFactory.kt create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/HapticsProvider.kt create mode 100644 Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt index cb2b0f89c9..afd0dce245 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt @@ -29,6 +29,7 @@ import com.google.android.material.slider.Slider import org.dolphinemu.dolphinemu.NativeLibrary import org.dolphinemu.dolphinemu.R import org.dolphinemu.dolphinemu.databinding.ActivityEmulationBinding +import org.dolphinemu.dolphinemu.databinding.DialogHapticsAdjustBinding import org.dolphinemu.dolphinemu.databinding.DialogInputAdjustBinding import org.dolphinemu.dolphinemu.databinding.DialogNfcFiguresManagerBinding import org.dolphinemu.dolphinemu.features.infinitybase.InfinityConfig @@ -37,7 +38,9 @@ import org.dolphinemu.dolphinemu.features.infinitybase.ui.FigureSlot import org.dolphinemu.dolphinemu.features.infinitybase.ui.FigureSlotAdapter import org.dolphinemu.dolphinemu.features.input.model.ControllerInterface import org.dolphinemu.dolphinemu.features.input.model.DolphinSensorEventListener +import org.dolphinemu.dolphinemu.features.input.model.DolphinVibratorManagerFactory import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting +import org.dolphinemu.dolphinemu.features.settings.model.FloatSetting import org.dolphinemu.dolphinemu.features.settings.model.IntSetting import org.dolphinemu.dolphinemu.features.settings.model.Settings import org.dolphinemu.dolphinemu.features.settings.model.StringSetting @@ -58,6 +61,8 @@ import org.dolphinemu.dolphinemu.ui.main.ThemeProvider import org.dolphinemu.dolphinemu.utils.AfterDirectoryInitializationRunner import org.dolphinemu.dolphinemu.utils.DirectoryInitialization import org.dolphinemu.dolphinemu.utils.FileBrowserHelper +import org.dolphinemu.dolphinemu.utils.HapticEffect +import org.dolphinemu.dolphinemu.utils.HapticsProvider import org.dolphinemu.dolphinemu.utils.ThemeHelper import kotlin.math.roundToInt @@ -412,6 +417,12 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { menu.findItem(R.id.menu_emulation_ir_recenter).isChecked = BooleanSetting.MAIN_IR_ALWAYS_RECENTER.boolean } + // Hide the haptic feedback menu item if the device has no vibrator + if (!DolphinVibratorManagerFactory.getSystemVibratorManager().getDefaultVibrator() + .hasVibrator() + ) { + menu.findItem(R.id.menu_emulation_haptics).setVisible(false) + } popup.setOnMenuItemClickListener { item: MenuItem -> onOptionsItemSelected(item) } popup.show() } @@ -492,6 +503,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { MENU_ACTION_SKYLANDERS -> showSkylanderPortalSettings() MENU_ACTION_INFINITY_BASE -> showInfinityBaseSettings() MENU_ACTION_EXIT -> emulationFragment!!.stopEmulation() + MENU_ACTION_ADJUST_HAPTICS -> adjustHaptics() } } @@ -667,6 +679,62 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { .show() } + private fun adjustHaptics() { + val dialogBinding = DialogHapticsAdjustBinding.inflate(layoutInflater) + val hapticsProvider = HapticsProvider() + dialogBinding.apply { + val toggleIntensity = { isChecked: Boolean -> + hapticsIntensityName.isEnabled = isChecked + hapticsIntensitySlider.isEnabled = isChecked + hapticsIntensityValue.isEnabled = isChecked + } + val checkboxes = + listOf(hapticsPressCheckbox, hapticsReleaseCheckbox, hapticsJoystickCheckbox) + hapticsPressCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_PRESS.boolean + hapticsReleaseCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_RELEASE.boolean + hapticsJoystickCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.boolean + if (checkboxes.none { it.isChecked }) { + toggleIntensity(false) + } + checkboxes.forEach { checkbox -> + checkbox.setOnCheckedChangeListener { _, _ -> + toggleIntensity(checkboxes.any { it.isChecked }) + } + } + hapticsIntensitySlider.apply { + val setValueText = { value: Float -> + hapticsIntensityValue.text = + getString(R.string.slider_setting_value, value * 100f, '%') + } + stepSize = 0.1f + valueFrom = 0.1f + valueTo = 1.0f + value = FloatSetting.MAIN_OVERLAY_HAPTICS_SCALE.float.also { setValueText(it) } + addOnChangeListener { _: Slider, value: Float, _: Boolean -> + setValueText(value) + hapticsProvider.provideFeedback(HapticEffect.LOW_TICK, value) + } + } + } + MaterialAlertDialogBuilder(this) + .setView(dialogBinding.root) + .setPositiveButton(R.string.ok) { _: DialogInterface?, _: Int -> + BooleanSetting.MAIN_OVERLAY_HAPTICS_PRESS.setBoolean( + settings, dialogBinding.hapticsPressCheckbox.isChecked + ) + BooleanSetting.MAIN_OVERLAY_HAPTICS_RELEASE.setBoolean( + settings, dialogBinding.hapticsReleaseCheckbox.isChecked + ) + BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.setBoolean( + settings, dialogBinding.hapticsJoystickCheckbox.isChecked + ) + FloatSetting.MAIN_OVERLAY_HAPTICS_SCALE.setFloat( + settings, dialogBinding.hapticsIntensitySlider.value + ) + } + .show() + } + private fun chooseDoubleTapButton() { val currentValue = IntSetting.MAIN_DOUBLE_TAP_BUTTON.int @@ -1059,6 +1127,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { const val MENU_ACTION_SKYLANDERS = 36 const val MENU_ACTION_INFINITY_BASE = 37 const val MENU_ACTION_LATCHING_CONTROLS = 38 + const val MENU_ACTION_ADJUST_HAPTICS = 39 init { buttonsActionsMap.apply { @@ -1072,6 +1141,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { append(R.id.menu_emulation_ir_recenter, MENU_SET_IR_RECENTER) append(R.id.menu_emulation_set_ir_mode, MENU_SET_IR_MODE) append(R.id.menu_emulation_choose_doubletap, MENU_ACTION_CHOOSE_DOUBLETAP) + append(R.id.menu_emulation_haptics, MENU_ACTION_ADJUST_HAPTICS) } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt index 3bca59f7b7..30131282de 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt @@ -4,16 +4,15 @@ package org.dolphinemu.dolphinemu.features.input.model import android.content.Context import android.hardware.input.InputManager -import android.os.Build import android.os.Handler -import android.os.VibrationEffect import android.os.Vibrator -import android.os.VibratorManager import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent import androidx.annotation.Keep import org.dolphinemu.dolphinemu.DolphinApplication +import org.dolphinemu.dolphinemu.utils.HapticEffect +import org.dolphinemu.dolphinemu.utils.HapticsProvider import org.dolphinemu.dolphinemu.utils.LooperThread /** @@ -105,36 +104,19 @@ object ControllerInterface { @Keep @JvmStatic - private fun getVibratorManager(device: InputDevice): DolphinVibratorManager { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - DolphinVibratorManagerPassthrough(device.vibratorManager) - } else { - DolphinVibratorManagerCompat(device.vibrator) - } - } + private fun getDeviceVibratorManager(device: InputDevice): DolphinVibratorManager = + DolphinVibratorManagerFactory.getDeviceVibratorManager(device) @Keep @JvmStatic - private fun getSystemVibratorManager(): DolphinVibratorManager { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val vibratorManager = DolphinApplication.getAppContext() - .getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager? - if (vibratorManager != null) - return DolphinVibratorManagerPassthrough(vibratorManager) - } - val vibrator = DolphinApplication.getAppContext() - .getSystemService(Context.VIBRATOR_SERVICE) as Vibrator - return DolphinVibratorManagerCompat(vibrator) - } + private fun getSystemVibratorManager(): DolphinVibratorManager = + DolphinVibratorManagerFactory.getSystemVibratorManager() @Keep @JvmStatic private fun vibrate(vibrator: Vibrator) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - vibrator.vibrate(VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE)) - } else { - vibrator.vibrate(100) - } + // TODO: Add a slider to the Rumble options that allows adjusting the vibration intensity. + HapticsProvider(vibrator).provideFeedback(HapticEffect.SPIN, 0.5f) } private class InputDeviceListener : InputManager.InputDeviceListener { diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManager.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManager.kt index c3c6ba28da..a0e042eaf4 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManager.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManager.kt @@ -13,4 +13,6 @@ interface DolphinVibratorManager { fun getVibrator(vibratorId: Int): Vibrator fun getVibratorIds(): IntArray + + fun getDefaultVibrator(): Vibrator } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerCompat.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerCompat.kt index 039f0ecb6b..4c2c6019c7 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerCompat.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerCompat.kt @@ -21,4 +21,6 @@ class DolphinVibratorManagerCompat(vibrator: Vibrator) : DolphinVibratorManager } override fun getVibratorIds(): IntArray = vibratorIds + + override fun getDefaultVibrator(): Vibrator = vibrator } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerFactory.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerFactory.kt new file mode 100644 index 0000000000..a6d17a3292 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerFactory.kt @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.input.model + +import android.content.Context +import android.os.Build +import android.os.Vibrator +import android.os.VibratorManager +import android.view.InputDevice +import org.dolphinemu.dolphinemu.DolphinApplication + +object DolphinVibratorManagerFactory { + fun getDeviceVibratorManager(device: InputDevice): DolphinVibratorManager { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + DolphinVibratorManagerPassthrough(device.vibratorManager) + } else { + DolphinVibratorManagerCompat(device.vibrator) + } + } + + fun getSystemVibratorManager(): DolphinVibratorManager { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = DolphinApplication.getAppContext() + .getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager? + if (vibratorManager != null) { + return DolphinVibratorManagerPassthrough(vibratorManager) + } + } + val vibrator = DolphinApplication.getAppContext() + .getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + return DolphinVibratorManagerCompat(vibrator) + } +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerPassthrough.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerPassthrough.kt index 0895484314..9a73320119 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerPassthrough.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerPassthrough.kt @@ -13,4 +13,6 @@ class DolphinVibratorManagerPassthrough(private val vibratorManager: VibratorMan override fun getVibrator(vibratorId: Int): Vibrator = vibratorManager.getVibrator(vibratorId) override fun getVibratorIds(): IntArray = vibratorManager.vibratorIds + + override fun getDefaultVibrator(): Vibrator = vibratorManager.defaultVibrator } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt index 331ad7fa09..9fab04539d 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt @@ -646,6 +646,24 @@ enum class BooleanSetting( "ButtonLatchingNunchukZ", false ), + MAIN_OVERLAY_HAPTICS_PRESS( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_ANDROID, + "OverlayHapticsPress", + false + ), + MAIN_OVERLAY_HAPTICS_RELEASE( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_ANDROID, + "OverlayHapticsRelease", + false + ), + MAIN_OVERLAY_HAPTICS_JOYSTICK( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_ANDROID, + "OverlayHapticsJoystick", + false + ), SYSCONF_SCREENSAVER(Settings.FILE_SYSCONF, "IPL", "SSV", false), SYSCONF_WIDESCREEN(Settings.FILE_SYSCONF, "IPL", "AR", true), SYSCONF_PROGRESSIVE_SCAN(Settings.FILE_SYSCONF, "IPL", "PGS", true), diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/FloatSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/FloatSetting.kt index ec5bda11cc..96067380b7 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/FloatSetting.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/FloatSetting.kt @@ -8,6 +8,12 @@ enum class FloatSetting( private val key: String, private val defaultValue: Float ) : AbstractFloatSetting { + MAIN_OVERLAY_HAPTICS_SCALE( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_ANDROID, + "OverlayHapticsScale", + 0.5f + ), // These entries have the same names and order as in C++, just for consistency. MAIN_EMULATION_SPEED(Settings.FILE_DOLPHIN, Settings.SECTION_INI_CORE, "EmulationSpeed", 1.0f), MAIN_OVERCLOCK(Settings.FILE_DOLPHIN, Settings.SECTION_INI_CORE, "Overclock", 1.0f), diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlay.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlay.kt index dfe557e712..be7d27cd7e 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlay.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlay.kt @@ -27,9 +27,12 @@ import org.dolphinemu.dolphinemu.features.input.model.InputOverrider import org.dolphinemu.dolphinemu.features.input.model.InputOverrider.ControlId import org.dolphinemu.dolphinemu.features.input.model.controlleremu.EmulatedController import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting +import org.dolphinemu.dolphinemu.features.settings.model.FloatSetting import org.dolphinemu.dolphinemu.features.settings.model.IntSetting import org.dolphinemu.dolphinemu.features.settings.model.IntSetting.Companion.getSettingForSIDevice import org.dolphinemu.dolphinemu.features.settings.model.IntSetting.Companion.getSettingForWiimoteSource +import org.dolphinemu.dolphinemu.utils.HapticEffect +import org.dolphinemu.dolphinemu.utils.HapticsProvider import java.util.Arrays /** @@ -41,6 +44,7 @@ import java.util.Arrays */ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(context, attrs), OnTouchListener { + private val hapticsProvider: HapticsProvider = HapticsProvider() private val overlayButtons: MutableSet = HashSet() private val overlayDpads: MutableSet = HashSet() private val overlayJoysticks: MutableSet = HashSet() @@ -51,6 +55,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex private var isFirstRun = true private val gcPadRegistered = BooleanArray(4) private val wiimoteRegistered = BooleanArray(4) + private val dpadPreviouslyPressed = BooleanArray(4) var editMode = false private var controllerType = -1 private var controllerIndex = 0 @@ -140,6 +145,9 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex val firstPointer = action != MotionEvent.ACTION_POINTER_DOWN && action != MotionEvent.ACTION_POINTER_UP val pointerIndex = if (firstPointer) 0 else event.actionIndex + val hapticsScale = FloatSetting.MAIN_OVERLAY_HAPTICS_SCALE.float + val pressFeedback = BooleanSetting.MAIN_OVERLAY_HAPTICS_PRESS.boolean + val releaseFeedback = BooleanSetting.MAIN_OVERLAY_HAPTICS_RELEASE.boolean // Tracks if any button/joystick is pressed down var pressed = false @@ -154,7 +162,17 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex event.getY(pointerIndex).toInt() ) ) { - button.setPressedState(if (button.latching) !button.getPressedState() else true) + if (button.latching && button.getPressedState()) { + button.setPressedState(false) + if (releaseFeedback) hapticsProvider.provideFeedback( + HapticEffect.QUICK_RISE, hapticsScale + ) + } else { + button.setPressedState(true) + if (pressFeedback) hapticsProvider.provideFeedback( + HapticEffect.QUICK_FALL, hapticsScale + ) + } button.trackId = event.getPointerId(pointerIndex) pressed = true InputOverrider.setControlState(controllerIndex, button.control, if (button.getPressedState()) 1.0 else 0.0) @@ -173,8 +191,12 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex MotionEvent.ACTION_POINTER_UP -> { // If a pointer ends, release the button it was pressing. if (button.trackId == event.getPointerId(pointerIndex)) { - if (!button.latching) + if (!button.latching) { button.setPressedState(false) + if (releaseFeedback) hapticsProvider.provideFeedback( + HapticEffect.QUICK_RISE, hapticsScale + ) + } InputOverrider.setControlState(controllerIndex, button.control, if (button.getPressedState()) 1.0 else 0.0) val analogControl = getAnalogControlForTrigger(button.control) @@ -205,6 +227,9 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex ) { dpad.trackId = event.getPointerId(pointerIndex) pressed = true + if (pressFeedback) hapticsProvider.provideFeedback( + HapticEffect.QUICK_FALL, hapticsScale + ) } } } @@ -227,18 +252,29 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex // Release the buttons first, then press for (i in dpadPressed.indices) { if (!dpadPressed[i]) { + if (dpadPreviouslyPressed[i] && releaseFeedback) { + hapticsProvider.provideFeedback( + HapticEffect.QUICK_RISE, hapticsScale + ) + } InputOverrider.setControlState( controllerIndex, dpad.getControl(i), 0.0 ) } else { + if (!dpadPreviouslyPressed[i] && pressFeedback) { + hapticsProvider.provideFeedback( + HapticEffect.QUICK_FALL, hapticsScale + ) + } InputOverrider.setControlState( controllerIndex, dpad.getControl(i), 1.0 ) } + dpadPreviouslyPressed[i] = dpadPressed[i] } setDpadState( dpad, @@ -256,6 +292,12 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex if (dpad.trackId == event.getPointerId(pointerIndex)) { for (i in 0 until 4) { dpad.setState(InputOverlayDrawableDpad.STATE_DEFAULT) + if (dpadPreviouslyPressed[i]) { + dpadPreviouslyPressed[i] = false + if (releaseFeedback) hapticsProvider.provideFeedback( + HapticEffect.QUICK_RISE, hapticsScale + ) + } InputOverrider.setControlState( controllerIndex, dpad.getControl(i), @@ -1349,7 +1391,8 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex legacyId, xControl, yControl, - controllerIndex + controllerIndex, + hapticsProvider ) // Need to set the image's position diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlayDrawableJoystick.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlayDrawableJoystick.kt index 7dff339dcf..65dcba2c78 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlayDrawableJoystick.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlayDrawableJoystick.kt @@ -10,6 +10,9 @@ import android.graphics.drawable.BitmapDrawable import android.view.MotionEvent import org.dolphinemu.dolphinemu.features.input.model.InputOverrider import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting +import org.dolphinemu.dolphinemu.features.settings.model.FloatSetting +import org.dolphinemu.dolphinemu.utils.HapticEffect +import org.dolphinemu.dolphinemu.utils.HapticsProvider import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.hypot @@ -28,6 +31,7 @@ import kotlin.math.sin * @param legacyId Legacy identifier (ButtonType) for which joystick this is. * @param xControl The control which the x value of the joystick will be written to. * @param yControl The control which the y value of the joystick will be written to. + * @param hapticsProvider An instance of [HapticsProvider] for providing haptic feedback. */ class InputOverlayDrawableJoystick( res: Resources, @@ -39,7 +43,8 @@ class InputOverlayDrawableJoystick( val legacyId: Int, val xControl: Int, val yControl: Int, - private val controllerIndex: Int + private val controllerIndex: Int, + private val hapticsProvider: HapticsProvider ) { var x = 0.0f private set @@ -47,6 +52,11 @@ class InputOverlayDrawableJoystick( private set var trackId = -1 private set + private var angle = 0.0 + private var radius = 0.0 + private var gateRadius = 0.0 + private var previousRadius = 0.0 + private var previousAngle = 0.0 private var controlPositionX = 0 private var controlPositionY = 0 private var previousTouchX = 0 @@ -100,6 +110,7 @@ class InputOverlayDrawableJoystick( val firstPointer = action != MotionEvent.ACTION_POINTER_DOWN && action != MotionEvent.ACTION_POINTER_UP val pointerIndex = if (firstPointer) 0 else event.actionIndex + val hapticsScale = FloatSetting.MAIN_OVERLAY_HAPTICS_SCALE.float var pressed = false when (action) { @@ -112,6 +123,9 @@ class InputOverlayDrawableJoystick( ) { pressed = true pressedState = true + if (BooleanSetting.MAIN_OVERLAY_HAPTICS_PRESS.boolean) { + hapticsProvider.provideFeedback(HapticEffect.QUICK_FALL, hapticsScale) + } outerBitmap.alpha = 0 boundsBoxBitmap.alpha = opacity if (reCenter) { @@ -130,6 +144,9 @@ class InputOverlayDrawableJoystick( if (trackId == event.getPointerId(pointerIndex)) { pressed = true pressedState = false + if (BooleanSetting.MAIN_OVERLAY_HAPTICS_RELEASE.boolean) { + hapticsProvider.provideFeedback(HapticEffect.QUICK_RISE, hapticsScale) + } y = 0f x = y outerBitmap.alpha = opacity @@ -139,6 +156,8 @@ class InputOverlayDrawableJoystick( bounds = Rect(origBounds.left, origBounds.top, origBounds.right, origBounds.bottom) setInnerBounds() + previousRadius = 0.0 + previousAngle = 0.0 trackId = -1 } } @@ -161,6 +180,20 @@ class InputOverlayDrawableJoystick( y = touchY / maxY setInnerBounds() + if (BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.boolean) { + val radiusThreshold = gateRadius * 0.33 + val angularDistance = kotlin.math.abs(previousAngle - angle) + .let { kotlin.math.min(it, Math.PI + Math.PI - it) } + if (kotlin.math.abs(previousRadius - radius) > radiusThreshold + || (radius > radiusThreshold && + (angularDistance >= HAPTICS_MAX_ANGLE || (radius == gateRadius && + angularDistance * hapticsScale >= HAPTICS_MIN_ANGLE))) + ) { + hapticsProvider.provideFeedback(HapticEffect.LOW_TICK, hapticsScale) + previousRadius = radius + previousAngle = angle + } + } } } return pressed @@ -209,12 +242,13 @@ class InputOverlayDrawableJoystick( var x = x.toDouble() var y = y.toDouble() - val angle = atan2(y, x) + Math.PI + Math.PI - val radius = hypot(y, x) - val maxRadius = InputOverrider.getGateRadiusAtAngle(controllerIndex, xControl, angle) - if (radius > maxRadius) { - x = maxRadius * cos(angle) - y = maxRadius * sin(angle) + angle = atan2(y, x) + Math.PI + Math.PI + radius = hypot(y, x) + gateRadius = InputOverrider.getGateRadiusAtAngle(controllerIndex, xControl, angle) + if (radius > gateRadius) { + radius = gateRadius + x = gateRadius * cos(angle) + y = gateRadius * sin(angle) this.x = x.toFloat() this.y = y.toFloat() } @@ -255,4 +289,9 @@ class InputOverlayDrawableJoystick( boundsBoxBitmap.alpha = value } } + + companion object { + private const val HAPTICS_MIN_ANGLE = Math.PI / 20.0 + private const val HAPTICS_MAX_ANGLE = Math.PI / 4.0 + } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/HapticsProvider.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/HapticsProvider.kt new file mode 100644 index 0000000000..3a2ed93d74 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/HapticsProvider.kt @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.utils + +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import androidx.annotation.FloatRange +import androidx.annotation.RequiresApi +import org.dolphinemu.dolphinemu.features.input.model.DolphinVibratorManagerFactory + +/** + * This class provides methods that facilitate performing haptic feedback. + * + * @property vibrator The [Vibrator] instance to be used for vibration. + * Defaults to the system default vibrator. + */ +class HapticsProvider( + private val vibrator: Vibrator = + DolphinVibratorManagerFactory.getSystemVibratorManager().getDefaultVibrator() +) { + private val primitiveSupport: Boolean = areAllPrimitivesSupported() + + /** + * Perform haptic feedback by composing primitives (if supported), + * with a fallback to a waveform or a legacy vibration. + * + * @param effect The [HapticEffect] of the feedback. + * @param scale The intensity scale of the feedback. + */ + fun provideFeedback(effect: HapticEffect, @FloatRange(from = 0.0, to = 1.0) scale: Float) { + if (primitiveSupport) { + vibrator.vibrate( + VibrationEffect + .startComposition() + .addPrimitive(getPrimitive(effect), scale) + .compose() + ) + } else { + val timings = getTimings(effect, scale) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (vibrator.hasAmplitudeControl()) { + vibrator.vibrate( + VibrationEffect.createWaveform( + timings, getAmplitudes(effect, scale), -1 + ) + ) + } else { + vibrator.vibrate(VibrationEffect.createWaveform(timings, -1)) + } + } else { + vibrator.vibrate(timings.sum()) + } + } + } + + /** + * Get the timings for a waveform vibration based on the [effect], scaled by [scale]. + * + * @param effect The [HapticEffect] of the vibration. + * @param scale The intensity scale of the vibration. + * @return The LongArray of scaled timings for the specified [effect]. + */ + private fun getTimings( + effect: HapticEffect, @FloatRange(from = 0.0, to = 1.0) scale: Float + ): LongArray { + // Note: It is recommended that these values differ by a ratio of 1.4 or more, + // so the difference in the duration of the vibration can be easily perceived. + // Lower-end vibrators can't vibrate at all if the duration is too short. + return when (effect) { + HapticEffect.QUICK_FALL -> longArrayOf(0L, (100f * scale).toLong()) + HapticEffect.QUICK_RISE -> longArrayOf(0L, (70f * scale).toLong()) + HapticEffect.LOW_TICK -> longArrayOf(0L, (50f * scale).toLong()) + HapticEffect.SPIN -> LongArray(SPIN_TIMINGS.size) { (SPIN_TIMINGS[it] * scale).toLong() } + } + } + + /** + * Get the amplitudes for a waveform vibration based on the [effect], scaled by [scale]. + * + * @param effect The [HapticEffect] of the vibration. + * @param scale The intensity scale of the vibration. + * @return The IntArray of scaled amplitudes for the specified [effect]. + */ + @RequiresApi(Build.VERSION_CODES.O) + private fun getAmplitudes( + effect: HapticEffect, @FloatRange(from = 0.0, to = 1.0) scale: Float + ): IntArray { + // Note: It is recommended that these values differ by a ratio of 1.4 or more, + // so the difference in the amplitude of the vibration can be easily perceived. + return when (effect) { + HapticEffect.QUICK_FALL -> intArrayOf(0, (180 * scale).toInt()) + HapticEffect.QUICK_RISE -> intArrayOf(0, (128 * scale).toInt()) + HapticEffect.LOW_TICK -> intArrayOf(0, (90 * scale).toInt()) + HapticEffect.SPIN -> IntArray(SPIN_AMPLITUDES.size) { (SPIN_AMPLITUDES[it] * scale).toInt() } + } + } + + @RequiresApi(Build.VERSION_CODES.S) + private fun getPrimitive(effect: HapticEffect): Int { + return when (effect) { + HapticEffect.QUICK_FALL -> VibrationEffect.Composition.PRIMITIVE_QUICK_FALL + HapticEffect.QUICK_RISE -> VibrationEffect.Composition.PRIMITIVE_QUICK_RISE + HapticEffect.LOW_TICK -> VibrationEffect.Composition.PRIMITIVE_LOW_TICK + HapticEffect.SPIN -> VibrationEffect.Composition.PRIMITIVE_SPIN + } + } + + private fun areAllPrimitivesSupported(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && vibrator.areAllPrimitivesSupported( + *HapticEffect.values().map { getPrimitive(it) }.toIntArray() + ) + } + + companion object { + private val SPIN_TIMINGS = longArrayOf(15L, 30L, 20L, 30L, 20L, 30L, 20L, 10L) + private val SPIN_AMPLITUDES = intArrayOf(0, 128, 255, 100, 200, 32, 64, 0) + } +} + +enum class HapticEffect { + QUICK_FALL, + QUICK_RISE, + LOW_TICK, + SPIN +} diff --git a/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml b/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml new file mode 100644 index 0000000000..7b9591fb8c --- /dev/null +++ b/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/Android/app/src/main/res/menu/menu_overlay_controls_gc.xml b/Source/Android/app/src/main/res/menu/menu_overlay_controls_gc.xml index e4ef459487..a946ebb4f3 100644 --- a/Source/Android/app/src/main/res/menu/menu_overlay_controls_gc.xml +++ b/Source/Android/app/src/main/res/menu/menu_overlay_controls_gc.xml @@ -27,6 +27,10 @@ android:id="@+id/menu_emulation_choose_controller" android:title="@string/emulation_choose_controller"/> + + diff --git a/Source/Android/app/src/main/res/menu/menu_overlay_controls_wii.xml b/Source/Android/app/src/main/res/menu/menu_overlay_controls_wii.xml index 066889493f..383555bd81 100644 --- a/Source/Android/app/src/main/res/menu/menu_overlay_controls_wii.xml +++ b/Source/Android/app/src/main/res/menu/menu_overlay_controls_wii.xml @@ -29,6 +29,10 @@ android:id="@+id/menu_emulation_choose_controller" android:title="@string/emulation_choose_controller"/> + + diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index a195ef08ea..ac999e8343 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -610,6 +610,7 @@ It can efficiently compress both junk data and encrypted Wii data. IR Mode IR Sensitivity Double tap button + Touch Haptics Enable Vibration @@ -812,6 +813,13 @@ It can efficiently compress both junk data and encrypted Wii data. Follow Drag + + Feedback Triggers + Press + Release + Joystick + Intensity + Button A Button B diff --git a/Source/Core/InputCommon/ControllerInterface/Android/Android.cpp b/Source/Core/InputCommon/ControllerInterface/Android/Android.cpp index c60679daf0..4f809c0ebd 100644 --- a/Source/Core/InputCommon/ControllerInterface/Android/Android.cpp +++ b/Source/Core/InputCommon/ControllerInterface/Android/Android.cpp @@ -63,7 +63,7 @@ jmethodID s_motion_event_get_source; jclass s_controller_interface_class; jmethodID s_controller_interface_register_input_device_listener; jmethodID s_controller_interface_unregister_input_device_listener; -jmethodID s_controller_interface_get_vibrator_manager; +jmethodID s_controller_interface_get_device_vibrator_manager; jmethodID s_controller_interface_get_system_vibrator_manager; jmethodID s_controller_interface_vibrate; @@ -78,6 +78,7 @@ jmethodID s_sensor_event_listener_get_negative_axes; jclass s_dolphin_vibrator_manager_class; jmethodID s_dolphin_vibrator_manager_get_vibrator; jmethodID s_dolphin_vibrator_manager_get_vibrator_ids; +jmethodID s_dolphin_vibrator_manager_get_default_vibrator; jintArray s_keycodes_array; @@ -746,7 +747,8 @@ private: void AddMotors(JNIEnv* env, jobject input_device) { jobject vibrator_manager = env->CallStaticObjectMethod( - s_controller_interface_class, s_controller_interface_get_vibrator_manager, input_device); + s_controller_interface_class, s_controller_interface_get_device_vibrator_manager, + input_device); AddMotorsFromManager(env, vibrator_manager); env->DeleteLocalRef(vibrator_manager); } @@ -857,8 +859,8 @@ InputBackend::InputBackend(ControllerInterface* controller_interface) env->GetStaticMethodID(s_controller_interface_class, "registerInputDeviceListener", "()V"); s_controller_interface_unregister_input_device_listener = env->GetStaticMethodID(s_controller_interface_class, "unregisterInputDeviceListener", "()V"); - s_controller_interface_get_vibrator_manager = - env->GetStaticMethodID(s_controller_interface_class, "getVibratorManager", + s_controller_interface_get_device_vibrator_manager = + env->GetStaticMethodID(s_controller_interface_class, "getDeviceVibratorManager", "(Landroid/view/InputDevice;)Lorg/dolphinemu/dolphinemu/features/" "input/model/DolphinVibratorManager;"); s_controller_interface_get_system_vibrator_manager = env->GetStaticMethodID( @@ -894,6 +896,8 @@ InputBackend::InputBackend(ControllerInterface* controller_interface) env->GetMethodID(s_dolphin_vibrator_manager_class, "getVibrator", "(I)Landroid/os/Vibrator;"); s_dolphin_vibrator_manager_get_vibrator_ids = env->GetMethodID(s_dolphin_vibrator_manager_class, "getVibratorIds", "()[I"); + s_dolphin_vibrator_manager_get_default_vibrator = env->GetMethodID( + s_dolphin_vibrator_manager_class, "getDefaultVibrator", "()Landroid/os/Vibrator;"); env->DeleteLocalRef(dolphin_vibrator_manager_class); jintArray keycodes_array = CreateKeyCodesArray(env);