Haptic feedback support for overlay controls & controller rumble

This commit is contained in:
codokie 2024-08-02 02:51:30 +03:00
parent 8a50676b83
commit 177c6343e3
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.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)
}
}

View File

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

View File

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

View File

@ -21,4 +21,6 @@ class DolphinVibratorManagerCompat(vibrator: Vibrator) : DolphinVibratorManager
}
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 getVibratorIds(): IntArray = vibratorManager.vibratorIds
override fun getDefaultVibrator(): Vibrator = vibratorManager.defaultVibrator
}

View File

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

View File

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

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.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<InputOverlayDrawableButton> = HashSet()
private val overlayDpads: MutableSet<InputOverlayDrawableDpad> = HashSet()
private val overlayJoysticks: MutableSet<InputOverlayDrawableJoystick> = 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

View File

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

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:title="@string/emulation_choose_controller"/>
<item
android:id="@+id/menu_emulation_haptics"
android:title="@string/emulation_haptics"/>
<item
android:id="@+id/menu_emulation_reset_overlay"
android:title="@string/emulation_touch_overlay_reset"/>

View File

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

View File

@ -610,6 +610,7 @@ It can efficiently compress both junk data and encrypted Wii data.
<string name="emulation_ir_mode">IR Mode</string>
<string name="emulation_ir_sensitivity">IR Sensitivity</string>
<string name="emulation_choose_doubletap">Double tap button</string>
<string name="emulation_haptics">Touch Haptics</string>
<!-- GC Adapter Menu-->
<string name="gc_adapter_rumble">Enable Vibration</string>
@ -812,6 +813,13 @@ It can efficiently compress both junk data and encrypted Wii data.
<string name="ir_follow">Follow</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 -->
<string name="double_tap_a">Button A</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;
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);