/* * SPDX-License-Identifier: MPL-2.0 * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) */ package emu.skyline.input.onscreen import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ValueAnimator import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.PointF import android.os.VibrationEffect import android.os.Vibrator import android.util.AttributeSet import android.util.TypedValue import android.view.MotionEvent import android.view.View import android.view.View.OnTouchListener import androidx.annotation.IntRange import emu.skyline.R import emu.skyline.input.ButtonId import emu.skyline.input.ButtonState import emu.skyline.input.ControllerType import emu.skyline.input.StickId import emu.skyline.utils.add import emu.skyline.utils.multiply import emu.skyline.utils.normalize import kotlin.math.roundToLong typealias OnButtonStateChangedListener = (buttonId : ButtonId, state : ButtonState) -> Unit typealias OnStickStateChangedListener = (stickId : StickId, position : PointF) -> Unit /** * Renders On-Screen Controls as a single view, handles touch inputs and button toggling */ class OnScreenControllerView @JvmOverloads constructor(context : Context, attrs : AttributeSet? = null, defStyleAttr : Int = 0, defStyleRes : Int = 0) : View(context, attrs, defStyleAttr, defStyleRes) { companion object { private val controllerTypeMappings = mapOf(*ControllerType.values().map { it to (setOf(*it.buttons) + setOf(*it.optionalButtons) to setOf(*it.sticks)) }.toTypedArray()) } private var onButtonStateChangedListener : OnButtonStateChangedListener? = null fun setOnButtonStateChangedListener(listener : OnButtonStateChangedListener) { onButtonStateChangedListener = listener } private var onStickStateChangedListener : OnStickStateChangedListener? = null fun setOnStickStateChangedListener(listener : OnStickStateChangedListener) { onStickStateChangedListener = listener } private val joystickAnimators = mutableMapOf() var controllerType : ControllerType? = null set(value) { field = value invalidate() } var recenterSticks = false set(value) { field = value controls.joysticks.forEach { it.recenterSticks = recenterSticks } } var hapticFeedback = false set(value) { field = value (controls.circularButtons + controls.rectangularButtons + controls.triggerButtons).forEach { it.hapticFeedback = hapticFeedback } } internal val editInfo = OnScreenEditInfo() fun setOnEditButtonChangedListener(listener : OnEditButtonChangedListener?) { editInfo.onEditButtonChangedListener = listener } private val selectionPaint = Paint().apply { color = context.obtainStyledAttributes(intArrayOf(R.attr.colorPrimary)).use { it.getColor(0, Color.RED) } style = Paint.Style.STROKE strokeWidth = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, context.resources.displayMetrics) } // Populated externally by the activity, as retrieving the vibrator service inside the view crashes the layout editor lateinit var vibrator : Vibrator private val effectClick = VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK) // Ensure controls init happens after editInfo is initialized so that the buttons have a valid reference to it private val controls = Controls(this) override fun onDraw(canvas : Canvas) { super.onDraw(canvas) val allowedIds = controllerTypeMappings[controllerType] controls.allButtons.forEach { button -> if (allowedIds?.let { (buttonIds, stickIds) -> if (button is JoystickButton) stickIds.contains(button.stickId) else buttonIds.contains(button.buttonId) } != false ) { button.width = width button.height = height button.render(canvas) } } // Draw the selection box around the edit button if (editInfo.isEditing && editInfo.editButton is OnScreenButton) canvas.drawRect((editInfo.editButton as OnScreenButton).currentBounds, selectionPaint) } private val playingTouchHandler = OnTouchListener { _, event -> var handled = false val actionIndex = event.actionIndex val pointerId = event.getPointerId(actionIndex) val x by lazy { event.getX(actionIndex) } val y by lazy { event.getY(actionIndex) } (controls.circularButtons + controls.rectangularButtons + controls.triggerButtons).forEach { button -> when (event.action and event.actionMasked) { MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> { if (pointerId == button.touchPointerId) { button.touchPointerId = -1 button.onFingerUp(x, y) onButtonStateChangedListener?.invoke(button.buttonId, ButtonState.Released) handled = true } else if (pointerId == button.partnerPointerId) { button.partnerPointerId = -1 button.onFingerUp(x, y) onButtonStateChangedListener?.invoke(button.buttonId, ButtonState.Released) handled = true } } MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { if (button.config.enabled && button.isTouched(x, y)) { button.touchPointerId = pointerId button.onFingerDown(x, y) if (hapticFeedback) vibrator.vibrate(effectClick) performClick() onButtonStateChangedListener?.invoke(button.buttonId, ButtonState.Pressed) handled = true } } MotionEvent.ACTION_MOVE -> { for (fingerId in 0 until event.pointerCount) { if (fingerId == button.touchPointerId) { for (buttonPair in controls.buttonPairs) { if (buttonPair.contains(button)) { for (otherButton in buttonPair) { if (otherButton != button && otherButton.config.enabled && otherButton.isTouched(event.getX(fingerId), event.getY(fingerId))) { otherButton.partnerPointerId = fingerId otherButton.onFingerDown(x, y) performClick() onButtonStateChangedListener?.invoke(otherButton.buttonId, ButtonState.Pressed) handled = true } } } } } } } } } for (joystick in controls.joysticks) { when (event.actionMasked) { MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL -> { if (pointerId == joystick.touchPointerId) { joystick.touchPointerId = -1 val position = PointF(joystick.currentX, joystick.currentY) val radius = joystick.radius val outerToInner = joystick.outerToInner() val outerToInnerLength = outerToInner.length() val direction = outerToInner.normalize() val duration = (50f * outerToInnerLength / radius).roundToLong() joystickAnimators[joystick] = ValueAnimator.ofFloat(outerToInnerLength, 0f).apply { addUpdateListener { animation -> val value = animation.animatedValue as Float val vector = direction.multiply(value) val newPosition = position.add(vector) joystick.onFingerMoved(newPosition.x, newPosition.y, false) onStickStateChangedListener?.invoke(joystick.stickId, vector.multiply(1f / radius)) invalidate() } addListener(object : AnimatorListenerAdapter() { override fun onAnimationCancel(animation : Animator) { super.onAnimationCancel(animation) onAnimationEnd(animation) onStickStateChangedListener?.invoke(joystick.stickId, PointF(0f, 0f)) } override fun onAnimationEnd(animation : Animator) { super.onAnimationEnd(animation) if (joystick.shortDoubleTapped) onButtonStateChangedListener?.invoke(joystick.buttonId, ButtonState.Released) joystick.onFingerUp(event.x, event.y) invalidate() } }) setDuration(duration) start() } handled = true } } MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { if (joystick.config.enabled && joystick.isTouched(x, y)) { joystickAnimators[joystick]?.cancel() joystickAnimators[joystick] = null joystick.touchPointerId = pointerId joystick.onFingerDown(x, y) if (joystick.shortDoubleTapped) onButtonStateChangedListener?.invoke(joystick.buttonId, ButtonState.Pressed) if (recenterSticks) onStickStateChangedListener?.invoke(joystick.stickId, joystick.outerToInnerRelative()) performClick() handled = true } } MotionEvent.ACTION_MOVE -> { for (i in 0 until event.pointerCount) { if (event.getPointerId(i) == joystick.touchPointerId) { val centerToPoint = joystick.onFingerMoved(event.getX(i), event.getY(i)) onStickStateChangedListener?.invoke(joystick.stickId, centerToPoint) handled = true } } } } } handled.also { if (it) invalidate() } } /** * Tracks whether the last pointer down event changed the active edit button * Avoids moving the button when the user just wants to select it */ private var activeEditButtonChanged = false private val editingTouchHandler = OnTouchListener { _, event -> run { when (event.actionMasked) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { val touchedButton = controls.allButtons.firstOrNull { it.isTouched(event.x, event.y) } ?: return@OnTouchListener false // Update the selection if the user touched a button other than the selected one if (touchedButton != editInfo.editButton) { activeEditButtonChanged = true editInfo.editButton = touchedButton performClick() invalidate() return@run } editInfo.editButton.startMove(event.x, event.y) } MotionEvent.ACTION_MOVE -> { // If the user just selected another button, don't move it yet if (activeEditButtonChanged) return@run editInfo.editButton.move(event.x, event.y) invalidate() } MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL -> { if (activeEditButtonChanged) { activeEditButtonChanged = false return@run } editInfo.editButton.endMove() } } } true } init { setOnTouchListener(playingTouchHandler) } fun updateEditButtonInfo() { editInfo.onEditButtonChangedListener?.invoke(editInfo.editButton) } fun setEditMode(isEdit : Boolean) { // Select all buttons when entering edit if we weren't already editing if (!editInfo.isEditing) selectAllButtons() editInfo.isEditing = isEdit setOnTouchListener(if (isEdit) editingTouchHandler else playingTouchHandler) invalidate() } fun selectAllButtons() { editInfo.editButton = allButtonsProxy invalidate() } fun setButtonEnabled(enabled : Boolean) { editInfo.editButton.config.enabled = enabled invalidate() } fun setButtonScale(@IntRange(from = 0, to = 100) scale : Int) { fun toScaleRange(value : Int) : Float = (value / 100f) * (OnScreenConfiguration.MaxScale - OnScreenConfiguration.MinScale) + OnScreenConfiguration.MinScale editInfo.editButton.config.scale = toScaleRange(scale) invalidate() } fun setButtonOpacity(@IntRange(from = 0, to = 100) opacity : Int) { fun toAlphaRange(value : Int) : Int = ((value / 100f) * (OnScreenConfiguration.MaxAlpha - OnScreenConfiguration.MinAlpha)).toInt() + OnScreenConfiguration.MinAlpha editInfo.editButton.config.alpha = toAlphaRange(opacity) invalidate() } fun moveButtonUp() { editInfo.editButton.moveUp() invalidate() } fun moveButtonDown() { editInfo.editButton.moveDown() invalidate() } fun moveButtonLeft() { editInfo.editButton.moveLeft() invalidate() } fun moveButtonRight() { editInfo.editButton.moveRight() invalidate() } // Used to retrieve the current color to use in the color picker dialog fun getButtonTextColor() = editInfo.editButton.config.textColor fun getButtonBackgroundColor() = editInfo.editButton.config.backgroundColor fun setButtonTextColor(color : Int) { editInfo.editButton.config.textColor = color invalidate() } fun setButtonBackgroundColor(color : Int) { editInfo.editButton.config.backgroundColor = color invalidate() } fun setSnapToGrid(snap : Boolean) { editInfo.snapToGrid = snap } fun resetButton() { editInfo.editButton.resetConfig() editInfo.onEditButtonChangedListener?.invoke(editInfo.editButton) invalidate() } /** * A proxy button that is used to apply changes to all buttons */ private val allButtonsProxy = object : ConfigurableButton { override val buttonId : ButtonId = ButtonId.All override val config = object : OnScreenConfiguration { override var enabled : Boolean get() = controls.allButtons.all { it.config.enabled } set(value) { controls.allButtons.forEach { it.config.enabled = value } } override val groupEnabled : Int get() { if (controls.allButtons.all { it.config.enabled }) return OnScreenConfiguration.GroupEnabled if (controls.allButtons.all { !it.config.enabled }) return OnScreenConfiguration.GroupDisabled return OnScreenConfiguration.GroupIndeterminate } override var alpha : Int get() = controls.allButtons.sumOf { it.config.alpha } / controls.allButtons.size set(value) { controls.allButtons.forEach { it.config.alpha = value } } override var textColor : Int get() = controls.allButtons.first().config.textColor set(value) { controls.allButtons.forEach { it.config.textColor = value } } override var backgroundColor : Int get() = controls.allButtons.first().config.backgroundColor set(value) { controls.allButtons.forEach { it.config.backgroundColor = value } } override var scale : Float get() = (controls.allButtons.sumOf { it.config.scale.toDouble() } / controls.allButtons.size).toFloat() set(value) { controls.allButtons.forEach { it.config.scale = value } } override var relativeX = 0f override var relativeY = 0f } override fun startMove(x : Float, y : Float) {} override fun move(x : Float, y : Float) {} override fun endMove() {} override fun moveUp() { controls.allButtons.forEach { it.moveUp() } } override fun moveDown() { controls.allButtons.forEach { it.moveDown() } } override fun moveLeft() { controls.allButtons.forEach { it.moveLeft() } } override fun moveRight() { controls.allButtons.forEach { it.moveRight() } } override fun resetConfig() { controls.allButtons.forEach { it.resetConfig() } } } }