This commit is contained in:
codokie 2024-11-12 07:48:23 +00:00 committed by GitHub
commit 7c2e93f56c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 492 additions and 40 deletions

View File

@ -29,6 +29,7 @@ import com.google.android.material.slider.Slider
import org.dolphinemu.dolphinemu.NativeLibrary import org.dolphinemu.dolphinemu.NativeLibrary
import org.dolphinemu.dolphinemu.R import org.dolphinemu.dolphinemu.R
import org.dolphinemu.dolphinemu.databinding.ActivityEmulationBinding import org.dolphinemu.dolphinemu.databinding.ActivityEmulationBinding
import org.dolphinemu.dolphinemu.databinding.DialogHapticsAdjustBinding
import org.dolphinemu.dolphinemu.databinding.DialogInputAdjustBinding import org.dolphinemu.dolphinemu.databinding.DialogInputAdjustBinding
import org.dolphinemu.dolphinemu.databinding.DialogNfcFiguresManagerBinding import org.dolphinemu.dolphinemu.databinding.DialogNfcFiguresManagerBinding
import org.dolphinemu.dolphinemu.features.infinitybase.InfinityConfig 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.infinitybase.ui.FigureSlotAdapter
import org.dolphinemu.dolphinemu.features.input.model.ControllerInterface import org.dolphinemu.dolphinemu.features.input.model.ControllerInterface
import org.dolphinemu.dolphinemu.features.input.model.DolphinSensorEventListener 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.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
import org.dolphinemu.dolphinemu.features.settings.model.Settings import org.dolphinemu.dolphinemu.features.settings.model.Settings
import org.dolphinemu.dolphinemu.features.settings.model.StringSetting 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.AfterDirectoryInitializationRunner
import org.dolphinemu.dolphinemu.utils.DirectoryInitialization import org.dolphinemu.dolphinemu.utils.DirectoryInitialization
import org.dolphinemu.dolphinemu.utils.FileBrowserHelper 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 org.dolphinemu.dolphinemu.utils.ThemeHelper
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -412,6 +417,12 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider {
menu.findItem(R.id.menu_emulation_ir_recenter).isChecked = menu.findItem(R.id.menu_emulation_ir_recenter).isChecked =
BooleanSetting.MAIN_IR_ALWAYS_RECENTER.boolean 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.setOnMenuItemClickListener { item: MenuItem -> onOptionsItemSelected(item) }
popup.show() popup.show()
} }
@ -492,6 +503,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider {
MENU_ACTION_SKYLANDERS -> showSkylanderPortalSettings() MENU_ACTION_SKYLANDERS -> showSkylanderPortalSettings()
MENU_ACTION_INFINITY_BASE -> showInfinityBaseSettings() MENU_ACTION_INFINITY_BASE -> showInfinityBaseSettings()
MENU_ACTION_EXIT -> emulationFragment!!.stopEmulation() MENU_ACTION_EXIT -> emulationFragment!!.stopEmulation()
MENU_ACTION_ADJUST_HAPTICS -> adjustHaptics()
} }
} }
@ -667,6 +679,62 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider {
.show() .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() { private fun chooseDoubleTapButton() {
val currentValue = IntSetting.MAIN_DOUBLE_TAP_BUTTON.int 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_SKYLANDERS = 36
const val MENU_ACTION_INFINITY_BASE = 37 const val MENU_ACTION_INFINITY_BASE = 37
const val MENU_ACTION_LATCHING_CONTROLS = 38 const val MENU_ACTION_LATCHING_CONTROLS = 38
const val MENU_ACTION_ADJUST_HAPTICS = 39
init { init {
buttonsActionsMap.apply { 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_ir_recenter, MENU_SET_IR_RECENTER)
append(R.id.menu_emulation_set_ir_mode, MENU_SET_IR_MODE) 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_choose_doubletap, MENU_ACTION_CHOOSE_DOUBLETAP)
append(R.id.menu_emulation_haptics, MENU_ACTION_ADJUST_HAPTICS)
} }
} }

View File

@ -4,16 +4,15 @@ package org.dolphinemu.dolphinemu.features.input.model
import android.content.Context import android.content.Context
import android.hardware.input.InputManager import android.hardware.input.InputManager
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.VibrationEffect
import android.os.Vibrator import android.os.Vibrator
import android.os.VibratorManager
import android.view.InputDevice import android.view.InputDevice
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import androidx.annotation.Keep import androidx.annotation.Keep
import org.dolphinemu.dolphinemu.DolphinApplication import org.dolphinemu.dolphinemu.DolphinApplication
import org.dolphinemu.dolphinemu.utils.HapticEffect
import org.dolphinemu.dolphinemu.utils.HapticsProvider
import org.dolphinemu.dolphinemu.utils.LooperThread import org.dolphinemu.dolphinemu.utils.LooperThread
/** /**
@ -105,36 +104,19 @@ object ControllerInterface {
@Keep @Keep
@JvmStatic @JvmStatic
private fun getVibratorManager(device: InputDevice): DolphinVibratorManager { private fun getDeviceVibratorManager(device: InputDevice): DolphinVibratorManager =
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { DolphinVibratorManagerFactory.getDeviceVibratorManager(device)
DolphinVibratorManagerPassthrough(device.vibratorManager)
} else {
DolphinVibratorManagerCompat(device.vibrator)
}
}
@Keep @Keep
@JvmStatic @JvmStatic
private fun getSystemVibratorManager(): DolphinVibratorManager { private fun getSystemVibratorManager(): DolphinVibratorManager =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { DolphinVibratorManagerFactory.getSystemVibratorManager()
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)
}
@Keep @Keep
@JvmStatic @JvmStatic
private fun vibrate(vibrator: Vibrator) { private fun vibrate(vibrator: Vibrator) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // TODO: Add a slider to the Rumble options that allows adjusting the vibration intensity.
vibrator.vibrate(VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE)) HapticsProvider(vibrator).provideFeedback(HapticEffect.SPIN, 0.5f)
} else {
vibrator.vibrate(100)
}
} }
private class InputDeviceListener : InputManager.InputDeviceListener { private class InputDeviceListener : InputManager.InputDeviceListener {

View File

@ -13,4 +13,6 @@ interface DolphinVibratorManager {
fun getVibrator(vibratorId: Int): Vibrator fun getVibrator(vibratorId: Int): Vibrator
fun getVibratorIds(): IntArray fun getVibratorIds(): IntArray
fun getDefaultVibrator(): Vibrator
} }

View File

@ -21,4 +21,6 @@ class DolphinVibratorManagerCompat(vibrator: Vibrator) : DolphinVibratorManager
} }
override fun getVibratorIds(): IntArray = vibratorIds override fun getVibratorIds(): IntArray = vibratorIds
override fun getDefaultVibrator(): Vibrator = vibrator
} }

View File

@ -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)
}
}

View File

@ -13,4 +13,6 @@ class DolphinVibratorManagerPassthrough(private val vibratorManager: VibratorMan
override fun getVibrator(vibratorId: Int): Vibrator = vibratorManager.getVibrator(vibratorId) override fun getVibrator(vibratorId: Int): Vibrator = vibratorManager.getVibrator(vibratorId)
override fun getVibratorIds(): IntArray = vibratorManager.vibratorIds override fun getVibratorIds(): IntArray = vibratorManager.vibratorIds
override fun getDefaultVibrator(): Vibrator = vibratorManager.defaultVibrator
} }

View File

@ -646,6 +646,24 @@ enum class BooleanSetting(
"ButtonLatchingNunchukZ", "ButtonLatchingNunchukZ",
false 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_SCREENSAVER(Settings.FILE_SYSCONF, "IPL", "SSV", false),
SYSCONF_WIDESCREEN(Settings.FILE_SYSCONF, "IPL", "AR", true), SYSCONF_WIDESCREEN(Settings.FILE_SYSCONF, "IPL", "AR", true),
SYSCONF_PROGRESSIVE_SCAN(Settings.FILE_SYSCONF, "IPL", "PGS", true), SYSCONF_PROGRESSIVE_SCAN(Settings.FILE_SYSCONF, "IPL", "PGS", true),

View File

@ -8,6 +8,12 @@ enum class FloatSetting(
private val key: String, private val key: String,
private val defaultValue: Float private val defaultValue: Float
) : AbstractFloatSetting { ) : 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. // 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_EMULATION_SPEED(Settings.FILE_DOLPHIN, Settings.SECTION_INI_CORE, "EmulationSpeed", 1.0f),
MAIN_OVERCLOCK(Settings.FILE_DOLPHIN, Settings.SECTION_INI_CORE, "Overclock", 1.0f), MAIN_OVERCLOCK(Settings.FILE_DOLPHIN, Settings.SECTION_INI_CORE, "Overclock", 1.0f),

View File

@ -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.InputOverrider.ControlId
import org.dolphinemu.dolphinemu.features.input.model.controlleremu.EmulatedController import org.dolphinemu.dolphinemu.features.input.model.controlleremu.EmulatedController
import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting 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
import org.dolphinemu.dolphinemu.features.settings.model.IntSetting.Companion.getSettingForSIDevice import org.dolphinemu.dolphinemu.features.settings.model.IntSetting.Companion.getSettingForSIDevice
import org.dolphinemu.dolphinemu.features.settings.model.IntSetting.Companion.getSettingForWiimoteSource 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 import java.util.Arrays
/** /**
@ -41,6 +44,7 @@ import java.util.Arrays
*/ */
class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(context, attrs), class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(context, attrs),
OnTouchListener { OnTouchListener {
private val hapticsProvider: HapticsProvider = HapticsProvider()
private val overlayButtons: MutableSet<InputOverlayDrawableButton> = HashSet() private val overlayButtons: MutableSet<InputOverlayDrawableButton> = HashSet()
private val overlayDpads: MutableSet<InputOverlayDrawableDpad> = HashSet() private val overlayDpads: MutableSet<InputOverlayDrawableDpad> = HashSet()
private val overlayJoysticks: MutableSet<InputOverlayDrawableJoystick> = HashSet() private val overlayJoysticks: MutableSet<InputOverlayDrawableJoystick> = HashSet()
@ -51,6 +55,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex
private var isFirstRun = true private var isFirstRun = true
private val gcPadRegistered = BooleanArray(4) private val gcPadRegistered = BooleanArray(4)
private val wiimoteRegistered = BooleanArray(4) private val wiimoteRegistered = BooleanArray(4)
private val dpadPreviouslyPressed = BooleanArray(4)
var editMode = false var editMode = false
private var controllerType = -1 private var controllerType = -1
private var controllerIndex = 0 private var controllerIndex = 0
@ -140,6 +145,9 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex
val firstPointer = action != MotionEvent.ACTION_POINTER_DOWN && val firstPointer = action != MotionEvent.ACTION_POINTER_DOWN &&
action != MotionEvent.ACTION_POINTER_UP action != MotionEvent.ACTION_POINTER_UP
val pointerIndex = if (firstPointer) 0 else event.actionIndex 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 // Tracks if any button/joystick is pressed down
var pressed = false var pressed = false
@ -154,7 +162,17 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex
event.getY(pointerIndex).toInt() 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) button.trackId = event.getPointerId(pointerIndex)
pressed = true pressed = true
InputOverrider.setControlState(controllerIndex, button.control, if (button.getPressedState()) 1.0 else 0.0) 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 -> { MotionEvent.ACTION_POINTER_UP -> {
// If a pointer ends, release the button it was pressing. // If a pointer ends, release the button it was pressing.
if (button.trackId == event.getPointerId(pointerIndex)) { if (button.trackId == event.getPointerId(pointerIndex)) {
if (!button.latching) if (!button.latching) {
button.setPressedState(false) button.setPressedState(false)
if (releaseFeedback) hapticsProvider.provideFeedback(
HapticEffect.QUICK_RISE, hapticsScale
)
}
InputOverrider.setControlState(controllerIndex, button.control, if (button.getPressedState()) 1.0 else 0.0) InputOverrider.setControlState(controllerIndex, button.control, if (button.getPressedState()) 1.0 else 0.0)
val analogControl = getAnalogControlForTrigger(button.control) val analogControl = getAnalogControlForTrigger(button.control)
@ -205,6 +227,9 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex
) { ) {
dpad.trackId = event.getPointerId(pointerIndex) dpad.trackId = event.getPointerId(pointerIndex)
pressed = true 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 // Release the buttons first, then press
for (i in dpadPressed.indices) { for (i in dpadPressed.indices) {
if (!dpadPressed[i]) { if (!dpadPressed[i]) {
if (dpadPreviouslyPressed[i] && releaseFeedback) {
hapticsProvider.provideFeedback(
HapticEffect.QUICK_RISE, hapticsScale
)
}
InputOverrider.setControlState( InputOverrider.setControlState(
controllerIndex, controllerIndex,
dpad.getControl(i), dpad.getControl(i),
0.0 0.0
) )
} else { } else {
if (!dpadPreviouslyPressed[i] && pressFeedback) {
hapticsProvider.provideFeedback(
HapticEffect.QUICK_FALL, hapticsScale
)
}
InputOverrider.setControlState( InputOverrider.setControlState(
controllerIndex, controllerIndex,
dpad.getControl(i), dpad.getControl(i),
1.0 1.0
) )
} }
dpadPreviouslyPressed[i] = dpadPressed[i]
} }
setDpadState( setDpadState(
dpad, dpad,
@ -256,6 +292,12 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex
if (dpad.trackId == event.getPointerId(pointerIndex)) { if (dpad.trackId == event.getPointerId(pointerIndex)) {
for (i in 0 until 4) { for (i in 0 until 4) {
dpad.setState(InputOverlayDrawableDpad.STATE_DEFAULT) dpad.setState(InputOverlayDrawableDpad.STATE_DEFAULT)
if (dpadPreviouslyPressed[i]) {
dpadPreviouslyPressed[i] = false
if (releaseFeedback) hapticsProvider.provideFeedback(
HapticEffect.QUICK_RISE, hapticsScale
)
}
InputOverrider.setControlState( InputOverrider.setControlState(
controllerIndex, controllerIndex,
dpad.getControl(i), dpad.getControl(i),
@ -1349,7 +1391,8 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex
legacyId, legacyId,
xControl, xControl,
yControl, yControl,
controllerIndex controllerIndex,
hapticsProvider
) )
// Need to set the image's position // Need to set the image's position

View File

@ -10,6 +10,9 @@ import android.graphics.drawable.BitmapDrawable
import android.view.MotionEvent import android.view.MotionEvent
import org.dolphinemu.dolphinemu.features.input.model.InputOverrider import org.dolphinemu.dolphinemu.features.input.model.InputOverrider
import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting 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.atan2
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.hypot import kotlin.math.hypot
@ -28,6 +31,7 @@ import kotlin.math.sin
* @param legacyId Legacy identifier (ButtonType) for which joystick this is. * @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 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 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( class InputOverlayDrawableJoystick(
res: Resources, res: Resources,
@ -39,7 +43,8 @@ class InputOverlayDrawableJoystick(
val legacyId: Int, val legacyId: Int,
val xControl: Int, val xControl: Int,
val yControl: Int, val yControl: Int,
private val controllerIndex: Int private val controllerIndex: Int,
private val hapticsProvider: HapticsProvider
) { ) {
var x = 0.0f var x = 0.0f
private set private set
@ -47,6 +52,11 @@ class InputOverlayDrawableJoystick(
private set private set
var trackId = -1 var trackId = -1
private set 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 controlPositionX = 0
private var controlPositionY = 0 private var controlPositionY = 0
private var previousTouchX = 0 private var previousTouchX = 0
@ -100,6 +110,7 @@ class InputOverlayDrawableJoystick(
val firstPointer = action != MotionEvent.ACTION_POINTER_DOWN && val firstPointer = action != MotionEvent.ACTION_POINTER_DOWN &&
action != MotionEvent.ACTION_POINTER_UP action != MotionEvent.ACTION_POINTER_UP
val pointerIndex = if (firstPointer) 0 else event.actionIndex val pointerIndex = if (firstPointer) 0 else event.actionIndex
val hapticsScale = FloatSetting.MAIN_OVERLAY_HAPTICS_SCALE.float
var pressed = false var pressed = false
when (action) { when (action) {
@ -112,6 +123,9 @@ class InputOverlayDrawableJoystick(
) { ) {
pressed = true pressed = true
pressedState = true pressedState = true
if (BooleanSetting.MAIN_OVERLAY_HAPTICS_PRESS.boolean) {
hapticsProvider.provideFeedback(HapticEffect.QUICK_FALL, hapticsScale)
}
outerBitmap.alpha = 0 outerBitmap.alpha = 0
boundsBoxBitmap.alpha = opacity boundsBoxBitmap.alpha = opacity
if (reCenter) { if (reCenter) {
@ -130,6 +144,9 @@ class InputOverlayDrawableJoystick(
if (trackId == event.getPointerId(pointerIndex)) { if (trackId == event.getPointerId(pointerIndex)) {
pressed = true pressed = true
pressedState = false pressedState = false
if (BooleanSetting.MAIN_OVERLAY_HAPTICS_RELEASE.boolean) {
hapticsProvider.provideFeedback(HapticEffect.QUICK_RISE, hapticsScale)
}
y = 0f y = 0f
x = y x = y
outerBitmap.alpha = opacity outerBitmap.alpha = opacity
@ -139,6 +156,8 @@ class InputOverlayDrawableJoystick(
bounds = bounds =
Rect(origBounds.left, origBounds.top, origBounds.right, origBounds.bottom) Rect(origBounds.left, origBounds.top, origBounds.right, origBounds.bottom)
setInnerBounds() setInnerBounds()
previousRadius = 0.0
previousAngle = 0.0
trackId = -1 trackId = -1
} }
} }
@ -161,6 +180,20 @@ class InputOverlayDrawableJoystick(
y = touchY / maxY y = touchY / maxY
setInnerBounds() 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 return pressed
@ -209,12 +242,13 @@ class InputOverlayDrawableJoystick(
var x = x.toDouble() var x = x.toDouble()
var y = y.toDouble() var y = y.toDouble()
val angle = atan2(y, x) + Math.PI + Math.PI angle = atan2(y, x) + Math.PI + Math.PI
val radius = hypot(y, x) radius = hypot(y, x)
val maxRadius = InputOverrider.getGateRadiusAtAngle(controllerIndex, xControl, angle) gateRadius = InputOverrider.getGateRadiusAtAngle(controllerIndex, xControl, angle)
if (radius > maxRadius) { if (radius > gateRadius) {
x = maxRadius * cos(angle) radius = gateRadius
y = maxRadius * sin(angle) x = gateRadius * cos(angle)
y = gateRadius * sin(angle)
this.x = x.toFloat() this.x = x.toFloat()
this.y = y.toFloat() this.y = y.toFloat()
} }
@ -255,4 +289,9 @@ class InputOverlayDrawableJoystick(
boundsBoxBitmap.alpha = value 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
}
} }

View File

@ -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
}

View File

@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/haptics_triggers"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="@dimen/spacing_large">
<TextView
android:id="@+id/haptics_triggers_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/spacing_medlarge"
android:text="@string/haptics_triggers"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.AppCompat.Title"
app:layout_constraintBottom_toTopOf="@id/haptics_release_checkbox"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/haptics_press_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="48dp"
android:minHeight="48dp"
android:text="@string/haptics_press"
android:textAppearance="?android:textAppearanceListItem"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/haptics_release_checkbox"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/haptics_release_checkbox" />
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/haptics_release_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="48dp"
android:minHeight="48dp"
android:text="@string/haptics_release"
android:textAppearance="?android:textAppearanceListItem"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/haptics_joystick_checkbox"
app:layout_constraintStart_toEndOf="@+id/haptics_press_checkbox"
app:layout_constraintTop_toBottomOf="@+id/haptics_triggers_text" />
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/haptics_joystick_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="48dp"
android:minHeight="48dp"
android:text="@string/haptics_joystick"
android:textAppearance="?android:textAppearanceListItem"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/haptics_release_checkbox"
app:layout_constraintTop_toTopOf="@id/haptics_release_checkbox" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/haptics_intensity"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="@dimen/spacing_small"
android:paddingHorizontal="24dp">
<TextView
android:id="@+id/haptics_intensity_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/haptics_intensity"
android:textAlignment="viewStart"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/haptics_intensity_slider"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.slider.Slider
android:id="@+id/haptics_intensity_slider"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/spacing_medlarge"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/haptics_intensity_value"
app:layout_constraintStart_toEndOf="@id/haptics_intensity_name"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/haptics_intensity_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/haptics_intensity_slider"
app:layout_constraintTop_toTopOf="parent"
tools:text="50%" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.appcompat.widget.LinearLayoutCompat>

View File

@ -27,6 +27,10 @@
android:id="@+id/menu_emulation_choose_controller" android:id="@+id/menu_emulation_choose_controller"
android:title="@string/emulation_choose_controller"/> android:title="@string/emulation_choose_controller"/>
<item
android:id="@+id/menu_emulation_haptics"
android:title="@string/emulation_haptics"/>
<item <item
android:id="@+id/menu_emulation_reset_overlay" android:id="@+id/menu_emulation_reset_overlay"
android:title="@string/emulation_touch_overlay_reset"/> android:title="@string/emulation_touch_overlay_reset"/>

View File

@ -29,6 +29,10 @@
android:id="@+id/menu_emulation_choose_controller" android:id="@+id/menu_emulation_choose_controller"
android:title="@string/emulation_choose_controller"/> android:title="@string/emulation_choose_controller"/>
<item
android:id="@+id/menu_emulation_haptics"
android:title="@string/emulation_haptics"/>
<item <item
android:id="@+id/menu_emulation_ir_group" android:id="@+id/menu_emulation_ir_group"
android:title="@string/emulation_ir_group"> android:title="@string/emulation_ir_group">

View File

@ -616,6 +616,7 @@ It can efficiently compress both junk data and encrypted Wii data.
<string name="emulation_ir_mode">IR Mode</string> <string name="emulation_ir_mode">IR Mode</string>
<string name="emulation_ir_sensitivity">IR Sensitivity</string> <string name="emulation_ir_sensitivity">IR Sensitivity</string>
<string name="emulation_choose_doubletap">Double tap button</string> <string name="emulation_choose_doubletap">Double tap button</string>
<string name="emulation_haptics">Touch Haptics</string>
<!-- GC Adapter Menu--> <!-- GC Adapter Menu-->
<string name="gc_adapter_rumble">Enable Vibration</string> <string name="gc_adapter_rumble">Enable Vibration</string>
@ -818,6 +819,13 @@ It can efficiently compress both junk data and encrypted Wii data.
<string name="ir_follow">Follow</string> <string name="ir_follow">Follow</string>
<string name="ir_drag">Drag</string> <string name="ir_drag">Drag</string>
<!-- Haptics -->
<string name="haptics_triggers">Feedback Triggers</string>
<string name="haptics_press">Press</string>
<string name="haptics_release">Release</string>
<string name="haptics_joystick">Joystick</string>
<string name="haptics_intensity">Intensity</string>
<!-- Double Tap Buttons --> <!-- Double Tap Buttons -->
<string name="double_tap_a">Button A</string> <string name="double_tap_a">Button A</string>
<string name="double_tap_b">Button B</string> <string name="double_tap_b">Button B</string>

View File

@ -63,7 +63,7 @@ jmethodID s_motion_event_get_source;
jclass s_controller_interface_class; jclass s_controller_interface_class;
jmethodID s_controller_interface_register_input_device_listener; jmethodID s_controller_interface_register_input_device_listener;
jmethodID s_controller_interface_unregister_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_get_system_vibrator_manager;
jmethodID s_controller_interface_vibrate; jmethodID s_controller_interface_vibrate;
@ -78,6 +78,7 @@ jmethodID s_sensor_event_listener_get_negative_axes;
jclass s_dolphin_vibrator_manager_class; jclass s_dolphin_vibrator_manager_class;
jmethodID s_dolphin_vibrator_manager_get_vibrator; jmethodID s_dolphin_vibrator_manager_get_vibrator;
jmethodID s_dolphin_vibrator_manager_get_vibrator_ids; jmethodID s_dolphin_vibrator_manager_get_vibrator_ids;
jmethodID s_dolphin_vibrator_manager_get_default_vibrator;
jintArray s_keycodes_array; jintArray s_keycodes_array;
@ -746,7 +747,8 @@ private:
void AddMotors(JNIEnv* env, jobject input_device) void AddMotors(JNIEnv* env, jobject input_device)
{ {
jobject vibrator_manager = env->CallStaticObjectMethod( 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); AddMotorsFromManager(env, vibrator_manager);
env->DeleteLocalRef(vibrator_manager); env->DeleteLocalRef(vibrator_manager);
} }
@ -857,8 +859,8 @@ InputBackend::InputBackend(ControllerInterface* controller_interface)
env->GetStaticMethodID(s_controller_interface_class, "registerInputDeviceListener", "()V"); env->GetStaticMethodID(s_controller_interface_class, "registerInputDeviceListener", "()V");
s_controller_interface_unregister_input_device_listener = s_controller_interface_unregister_input_device_listener =
env->GetStaticMethodID(s_controller_interface_class, "unregisterInputDeviceListener", "()V"); env->GetStaticMethodID(s_controller_interface_class, "unregisterInputDeviceListener", "()V");
s_controller_interface_get_vibrator_manager = s_controller_interface_get_device_vibrator_manager =
env->GetStaticMethodID(s_controller_interface_class, "getVibratorManager", env->GetStaticMethodID(s_controller_interface_class, "getDeviceVibratorManager",
"(Landroid/view/InputDevice;)Lorg/dolphinemu/dolphinemu/features/" "(Landroid/view/InputDevice;)Lorg/dolphinemu/dolphinemu/features/"
"input/model/DolphinVibratorManager;"); "input/model/DolphinVibratorManager;");
s_controller_interface_get_system_vibrator_manager = env->GetStaticMethodID( 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;"); env->GetMethodID(s_dolphin_vibrator_manager_class, "getVibrator", "(I)Landroid/os/Vibrator;");
s_dolphin_vibrator_manager_get_vibrator_ids = s_dolphin_vibrator_manager_get_vibrator_ids =
env->GetMethodID(s_dolphin_vibrator_manager_class, "getVibratorIds", "()[I"); 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); env->DeleteLocalRef(dolphin_vibrator_manager_class);
jintArray keycodes_array = CreateKeyCodesArray(env); jintArray keycodes_array = CreateKeyCodesArray(env);