diff --git a/buildSrc/src/main/java/deps.kt b/buildSrc/src/main/java/deps.kt index 5cecd9d3..18856e86 100644 --- a/buildSrc/src/main/java/deps.kt +++ b/buildSrc/src/main/java/deps.kt @@ -57,11 +57,13 @@ object deps { } } object support { + const val supportCompat = "com.android.support:support-compat:${versions.support}" const val appCompatV7 = "com.android.support:appcompat-v7:${versions.support}" const val leanbackV17 = "com.android.support:leanback-v17:${versions.support}" const val paletteV7 = "com.android.support:palette-v7:${versions.support}" const val prefLeanbackV17 = "com.android.support:preference-leanback-v17:${versions.support}" const val recyclerViewV7 = "com.android.support:recyclerview-v7:${versions.support}" + const val constraintLayout = "com.android.support.constraint:constraint-layout:1.1.3" } const val bugsnagAndroid = "com.bugsnag:bugsnag-android:4.9.2" const val bugsnagAndroidNdk = "com.bugsnag:bugsnag-android-ndk:4.9.2" diff --git a/retrograde-app-tv/build.gradle.kts b/retrograde-app-tv/build.gradle.kts index 9adcdb4d..85f01c01 100644 --- a/retrograde-app-tv/build.gradle.kts +++ b/retrograde-app-tv/build.gradle.kts @@ -43,6 +43,9 @@ dependencies { implementation(project(":retrograde-storage-webdav")) implementation(project(":retrograde-storage-archiveorg")) + // TODO... This dependency will be gone when the separate mobile application is created. + implementation(project(":retrograde-touchinput")) + implementation(deps.libs.arch.paging) implementation(deps.libs.arch.room.runtime) implementation(deps.libs.arch.work.runtime) diff --git a/retrograde-app-tv/src/main/java/com/codebutler/retrograde/app/feature/game/GameActivity.kt b/retrograde-app-tv/src/main/java/com/codebutler/retrograde/app/feature/game/GameActivity.kt index 3daba93c..9dca0112 100644 --- a/retrograde-app-tv/src/main/java/com/codebutler/retrograde/app/feature/game/GameActivity.kt +++ b/retrograde-app-tv/src/main/java/com/codebutler/retrograde/app/feature/game/GameActivity.kt @@ -24,6 +24,7 @@ import android.content.Intent import android.graphics.Color import android.os.Bundle import android.preference.PreferenceManager +import android.view.HapticFeedbackConstants import android.view.KeyEvent import android.view.MotionEvent import android.view.View @@ -48,6 +49,7 @@ import com.codebutler.retrograde.lib.util.subscribeBy import com.gojuno.koptional.None import com.gojuno.koptional.Some import com.gojuno.koptional.toOptional +import com.swordfish.touchinput.pads.GamePadFactory import com.uber.autodispose.android.lifecycle.scope import com.uber.autodispose.kotlin.autoDisposable import io.reactivex.android.schedulers.AndroidSchedulers @@ -73,12 +75,15 @@ class GameActivity : RetrogradeActivity() { private var game: Game? = null private var retroDroid: RetroDroid? = null + private var displayTouchInput: Boolean = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_game) val prefs = PreferenceManager.getDefaultSharedPreferences(this) val enableOpengl = prefs.getBoolean(getString(R.string.pref_key_flags_opengl), false) + displayTouchInput = prefs.getBoolean(getString(R.string.pref_key_flags_touchinput), false) gameDisplay = if (enableOpengl) { GlGameDisplay(this) @@ -116,6 +121,34 @@ class GameActivity : RetrogradeActivity() { } } + private fun setupTouchInput(game: Game) { + val frameLayout = findViewById(R.id.game_layout) + + val gameView = when (game.systemId) { + in listOf("snes", "gba") -> GamePadFactory.getGamePadView(this, GamePadFactory.Layout.SNES) + in listOf("nes", "gb", "gbc") -> GamePadFactory.getGamePadView(this, GamePadFactory.Layout.NES) + in listOf("md") -> GamePadFactory.getGamePadView(this, GamePadFactory.Layout.GENESIS) + else -> null + } + + if (gameView != null) { + frameLayout.addView(gameView) + + gameView.getEvents() + .doOnNext { + if (it.action == KeyEvent.ACTION_DOWN) { + performHapticFeedback(gameView) + } + }.autoDisposable(scope()) + .subscribe { gameInput.onKeyEvent(KeyEvent(it.action, it.keycode)) } + } + } + + private fun performHapticFeedback(view: View) { + val flags = HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING or HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP, flags) + } + override fun onDestroy() { super.onDestroy() // This activity runs in its own process which should not live beyond the activity lifecycle. @@ -161,6 +194,10 @@ class GameActivity : RetrogradeActivity() { val retroDroid = RetroDroid(gameDisplay, GameAudio(), gameInput, this, data.coreFile) lifecycle.addObserver(retroDroid) + if (displayTouchInput) { + setupTouchInput(data.game) + } + retroDroid.gameUnloaded .map { optionalSaveData -> if (optionalSaveData is Some) { diff --git a/retrograde-app-tv/src/main/res/values/keys.xml b/retrograde-app-tv/src/main/res/values/keys.xml index fc562600..0aad48da 100644 --- a/retrograde-app-tv/src/main/res/values/keys.xml +++ b/retrograde-app-tv/src/main/res/values/keys.xml @@ -5,6 +5,7 @@ flags flags.debug_logging flags.opengl_rendering + flags.enable_touchinputs sources licenses version diff --git a/retrograde-app-tv/src/main/res/values/strings.xml b/retrograde-app-tv/src/main/res/values/strings.xml index 2500b68c..ed6c7ded 100644 --- a/retrograde-app-tv/src/main/res/values/strings.xml +++ b/retrograde-app-tv/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ Retro gaming for Android TV Welcome to Retrograde OpenGL Rendering + Display Touch Inputs Play Recently Played Remove from Favorites diff --git a/retrograde-app-tv/src/main/res/xml/prefs.xml b/retrograde-app-tv/src/main/res/xml/prefs.xml index 10ff078e..6062dbc5 100644 --- a/retrograde-app-tv/src/main/res/xml/prefs.xml +++ b/retrograde-app-tv/src/main/res/xml/prefs.xml @@ -18,6 +18,9 @@ + diff --git a/retrograde-touchinput/src/main/java/com/swordfish/touchinput/data/ButtonEvent.kt b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/data/ButtonEvent.kt new file mode 100644 index 00000000..3a78712f --- /dev/null +++ b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/data/ButtonEvent.kt @@ -0,0 +1,3 @@ +package com.swordfish.touchinput.data + +data class ButtonEvent(val action: Int, val index: Int) \ No newline at end of file diff --git a/retrograde-touchinput/src/main/java/com/swordfish/touchinput/data/EventsTransformers.kt b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/data/EventsTransformers.kt new file mode 100644 index 00000000..5121fae6 --- /dev/null +++ b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/data/EventsTransformers.kt @@ -0,0 +1,44 @@ +package com.swordfish.touchinput.data + +import android.view.KeyEvent +import com.swordfish.touchinput.utils.observableOf +import com.swordfish.touchinput.views.DirectionPad +import io.reactivex.Observable +import io.reactivex.ObservableTransformer +import java.security.InvalidParameterException + +internal object EventsTransformers { + fun actionButtonsMap(vararg keycodes: Int): ObservableTransformer { + return ObservableTransformer { upstream -> + upstream.map { PadEvent(it.action, keycodes[it.index]) } + } + } + + fun singleButtonMap(keycode: Int): ObservableTransformer { + return ObservableTransformer { upstream -> + upstream.map { PadEvent(it.action, keycode) } + } + } + + fun directionPadMap(): ObservableTransformer { + return ObservableTransformer { upstream -> + upstream.flatMap { buttonEvent -> + mapDirectionToKey(buttonEvent.index).map { PadEvent(buttonEvent.action, it) } + } + } + } + + private fun mapDirectionToKey(index: Int): Observable { + return when (index) { + DirectionPad.BUTTON_LEFT -> observableOf(KeyEvent.KEYCODE_DPAD_LEFT) + DirectionPad.BUTTON_RIGHT -> observableOf(KeyEvent.KEYCODE_DPAD_RIGHT) + DirectionPad.BUTTON_UP -> observableOf(KeyEvent.KEYCODE_DPAD_UP) + DirectionPad.BUTTON_DOWN -> observableOf(KeyEvent.KEYCODE_DPAD_DOWN) + DirectionPad.BUTTON_UP_LEFT -> observableOf(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP) + DirectionPad.BUTTON_UP_RIGHT -> observableOf(KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_RIGHT) + DirectionPad.BUTTON_DOWN_LEFT -> observableOf(KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT) + DirectionPad.BUTTON_DOWN_RIGHT -> observableOf(KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT) + else -> throw InvalidParameterException("Invalid direction event with index: $index") + } + } +} diff --git a/retrograde-touchinput/src/main/java/com/swordfish/touchinput/data/PadEvent.kt b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/data/PadEvent.kt new file mode 100644 index 00000000..5904330d --- /dev/null +++ b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/data/PadEvent.kt @@ -0,0 +1,3 @@ +package com.swordfish.touchinput.data + +data class PadEvent(val action: Int, val keycode: Int) diff --git a/retrograde-touchinput/src/main/java/com/swordfish/touchinput/interfaces/ButtonEventsSource.kt b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/interfaces/ButtonEventsSource.kt new file mode 100644 index 00000000..c5bacf0e --- /dev/null +++ b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/interfaces/ButtonEventsSource.kt @@ -0,0 +1,8 @@ +package com.swordfish.touchinput.interfaces + +import com.swordfish.touchinput.data.ButtonEvent +import io.reactivex.Observable + +interface ButtonEventsSource { + fun getEvents(): Observable +} \ No newline at end of file diff --git a/retrograde-touchinput/src/main/java/com/swordfish/touchinput/pads/BaseGamePad.kt b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/pads/BaseGamePad.kt new file mode 100644 index 00000000..cff5fa52 --- /dev/null +++ b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/pads/BaseGamePad.kt @@ -0,0 +1,16 @@ +package com.swordfish.touchinput.pads + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import com.swordfish.touchinput.data.PadEvent +import io.reactivex.Observable + +abstract class BaseGamePad @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + abstract fun getEvents(): Observable +} diff --git a/retrograde-touchinput/src/main/java/com/swordfish/touchinput/pads/GameBoyAdvancePad.kt b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/pads/GameBoyAdvancePad.kt new file mode 100644 index 00000000..b0df437a --- /dev/null +++ b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/pads/GameBoyAdvancePad.kt @@ -0,0 +1,67 @@ +package com.swordfish.touchinput.pads + +import android.content.Context +import android.util.AttributeSet +import android.view.KeyEvent +import com.swordfish.touchinput.controller.R +import com.swordfish.touchinput.data.EventsTransformers +import com.swordfish.touchinput.data.PadEvent +import com.swordfish.touchinput.views.ActionButtons +import com.swordfish.touchinput.views.DirectionPad +import com.swordfish.touchinput.views.LargeSingleButton +import com.swordfish.touchinput.views.SmallSingleButton +import io.reactivex.Observable + +class GameBoyAdvancePad @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : BaseGamePad(context, attrs, defStyleAttr) { + + init { + inflate(context, R.layout.layout_gba, this) + } + + override fun getEvents(): Observable { + return Observable.merge(listOf( + getStartEvent(), + getSelectEvent(), + getDirectionEvents(), + getActionEvents(), + getR1Events(), + getL1Events())) + } + + private fun getStartEvent(): Observable { + return findViewById(R.id.gba_start) + .getEvents() + .compose(EventsTransformers.singleButtonMap(KeyEvent.KEYCODE_BUTTON_START)) + } + + private fun getSelectEvent(): Observable { + return findViewById(R.id.gba_select) + .getEvents() + .compose(EventsTransformers.singleButtonMap(KeyEvent.KEYCODE_BUTTON_SELECT)) + } + + private fun getActionEvents(): Observable { + return findViewById(R.id.gba_actions) + .getEvents() + .compose(EventsTransformers.actionButtonsMap(KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_A)) + } + + private fun getDirectionEvents(): Observable { + return findViewById(R.id.gba_direction).getEvents() + .compose(EventsTransformers.directionPadMap()) + } + + private fun getL1Events(): Observable { + return findViewById(R.id.gba_l1).getEvents() + .compose(EventsTransformers.singleButtonMap(KeyEvent.KEYCODE_BUTTON_L1)) + } + + private fun getR1Events(): Observable { + return findViewById(R.id.gba_r1).getEvents() + .compose(EventsTransformers.singleButtonMap(KeyEvent.KEYCODE_BUTTON_R1)) + } +} diff --git a/retrograde-touchinput/src/main/java/com/swordfish/touchinput/pads/GameBoyPad.kt b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/pads/GameBoyPad.kt new file mode 100644 index 00000000..78f81737 --- /dev/null +++ b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/pads/GameBoyPad.kt @@ -0,0 +1,55 @@ +package com.swordfish.touchinput.pads + +import android.content.Context +import android.util.AttributeSet +import android.view.KeyEvent +import com.swordfish.touchinput.controller.R +import com.swordfish.touchinput.data.EventsTransformers +import com.swordfish.touchinput.data.PadEvent +import com.swordfish.touchinput.views.ActionButtons +import com.swordfish.touchinput.views.DirectionPad +import com.swordfish.touchinput.views.base.BaseSingleButton +import io.reactivex.Observable + +class GameBoyPad @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : BaseGamePad(context, attrs, defStyleAttr) { + + init { + inflate(context, R.layout.layout_gb, this) + } + + override fun getEvents(): Observable { + return Observable.merge( + getStartEvent(), + getSelectEvent(), + getDirectionEvents(), + getActionEvents() + ) + } + + private fun getStartEvent(): Observable { + return findViewById(R.id.gb_start) + .getEvents() + .compose(EventsTransformers.singleButtonMap(KeyEvent.KEYCODE_BUTTON_START)) + } + + private fun getSelectEvent(): Observable { + return findViewById(R.id.gb_select) + .getEvents() + .compose(EventsTransformers.singleButtonMap(KeyEvent.KEYCODE_BUTTON_SELECT)) + } + + private fun getActionEvents(): Observable { + return findViewById(R.id.gb_actions) + .getEvents() + .compose(EventsTransformers.actionButtonsMap(KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_A)) + } + + private fun getDirectionEvents(): Observable { + return findViewById(R.id.gb_direction).getEvents() + .compose(EventsTransformers.directionPadMap()) + } +} diff --git a/retrograde-touchinput/src/main/java/com/swordfish/touchinput/pads/GamePadFactory.kt b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/pads/GamePadFactory.kt new file mode 100644 index 00000000..a3fc9653 --- /dev/null +++ b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/pads/GamePadFactory.kt @@ -0,0 +1,21 @@ +package com.swordfish.touchinput.pads + +import android.content.Context + +class GamePadFactory { + enum class Layout { + NES, + SNES, + GENESIS + } + + companion object { + fun getGamePadView(context: Context, layout: Layout): BaseGamePad { + return when (layout) { + Layout.NES -> GameBoyPad(context) + Layout.SNES -> GameBoyAdvancePad(context) + Layout.GENESIS -> GenesisPad(context) + } + } + } +} diff --git a/retrograde-touchinput/src/main/java/com/swordfish/touchinput/pads/GenesisPad.kt b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/pads/GenesisPad.kt new file mode 100644 index 00000000..187e51bd --- /dev/null +++ b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/pads/GenesisPad.kt @@ -0,0 +1,48 @@ +package com.swordfish.touchinput.pads + +import android.content.Context +import android.util.AttributeSet +import android.view.KeyEvent +import com.swordfish.touchinput.controller.R +import com.swordfish.touchinput.data.EventsTransformers +import com.swordfish.touchinput.data.PadEvent +import com.swordfish.touchinput.views.ActionButtons +import com.swordfish.touchinput.views.DirectionPad +import com.swordfish.touchinput.views.base.BaseSingleButton +import io.reactivex.Observable + +class GenesisPad @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : BaseGamePad(context, attrs, defStyleAttr) { + + init { + inflate(context, R.layout.layout_genesis, this) + } + + override fun getEvents(): Observable { + return Observable.merge( + getStartEvent(), + getDirectionEvents(), + getActionEvents() + ) + } + + private fun getStartEvent(): Observable { + return findViewById(R.id.genesis_start) + .getEvents() + .compose(EventsTransformers.singleButtonMap(KeyEvent.KEYCODE_BUTTON_START)) + } + + private fun getActionEvents(): Observable { + return findViewById(R.id.genesis_actions) + .getEvents() + .compose(EventsTransformers.actionButtonsMap(KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_C)) + } + + private fun getDirectionEvents(): Observable { + return findViewById(R.id.genesis_direction).getEvents() + .compose(EventsTransformers.directionPadMap()) + } +} diff --git a/retrograde-touchinput/src/main/java/com/swordfish/touchinput/utils/ControllerUtils.kt b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/utils/ControllerUtils.kt new file mode 100644 index 00000000..a97721ec --- /dev/null +++ b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/utils/ControllerUtils.kt @@ -0,0 +1,5 @@ +package com.swordfish.touchinput.utils + +import io.reactivex.Observable + +fun observableOf(vararg ts: T): Observable = Observable.fromArray(*ts) \ No newline at end of file diff --git a/retrograde-touchinput/src/main/java/com/swordfish/touchinput/views/ActionButtons.kt b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/views/ActionButtons.kt new file mode 100644 index 00000000..06b7619a --- /dev/null +++ b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/views/ActionButtons.kt @@ -0,0 +1,188 @@ +package com.swordfish.touchinput.views + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.TypedArray +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.drawable.Drawable +import android.support.v7.content.res.AppCompatResources +import android.util.AttributeSet +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import com.jakewharton.rxrelay2.PublishRelay +import com.swordfish.touchinput.controller.R +import com.swordfish.touchinput.data.ButtonEvent +import com.swordfish.touchinput.interfaces.ButtonEventsSource +import io.reactivex.Observable +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.sin + +class ActionButtons @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr), ButtonEventsSource { + + private val events: PublishRelay = PublishRelay.create() + + private var spacing: Float = 0.1f + private var rows: Int = 2 + private var cols: Int = 2 + private var rotateButtons: Float = 0.0f + private var supportsMultipleInputs: Boolean = false + + private var notRotatedWidth: Float = 0f + private var notRotatedHeight: Float = 0f + private var xPadding: Float = 0f + private var yPadding: Float = 0f + private var buttonSize: Float = 0f + private var totalButtonSize: Float = 0f + + private val buttonsPressed = mutableSetOf() + + private var pressedDrawable: Drawable? + private var normalDrawable: Drawable? + + private val touchRotationMatrix = Matrix() + + init { + pressedDrawable = retrieveDrawable(R.drawable.action_pressed) + normalDrawable = retrieveDrawable(R.drawable.action_normal) + + context.theme.obtainStyledAttributes(attrs, R.styleable.ActionButtons, defStyleAttr, 0)?.let { + initializeFromAttributes(it) + } + } + + override fun getEvents(): Observable = events + + private fun initializeFromAttributes(a: TypedArray) { + supportsMultipleInputs = a.getBoolean(R.styleable.ActionButtons_multipleInputs, false) + rows = a.getInt(R.styleable.ActionButtons_rows, 2) + cols = a.getInt(R.styleable.ActionButtons_cols, 2) + spacing = a.getFloat(R.styleable.ActionButtons_spacing, 0.1f) + rotateButtons = a.getFloat(R.styleable.ActionButtons_rotateButtons, 45.0f) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + buttonSize = resources.getDimension(R.dimen.size_action_button_item) + totalButtonSize = buttonSize + buttonSize * spacing + + notRotatedWidth = totalButtonSize * cols + notRotatedHeight = totalButtonSize * rows + + val radians = Math.toRadians(rotateButtons.toDouble()) + val rotatedWidth = (abs(notRotatedWidth * sin(radians)) + abs(notRotatedHeight * cos(radians))).toFloat() + val rotatedHeight = (abs(notRotatedWidth * cos(radians)) + abs(notRotatedHeight * sin(radians))).toFloat() + + val width = getSize(MeasureSpec.getMode(widthMeasureSpec), MeasureSpec.getSize(widthMeasureSpec), rotatedWidth.toInt()) + val height = getSize(MeasureSpec.getMode(heightMeasureSpec), MeasureSpec.getSize(heightMeasureSpec), rotatedHeight.toInt()) + + xPadding = abs(width - notRotatedWidth) / 2f + yPadding = abs(height - notRotatedHeight) / 2f + + setMeasuredDimension(width, height) + } + + private fun getSize(widthMode: Int, widthSize: Int, expectedSize: Int): Int { + return when (widthMode) { + MeasureSpec.EXACTLY -> widthSize + else -> minOf(expectedSize, widthSize) + } + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + touchRotationMatrix.reset() + touchRotationMatrix.setRotate(-rotateButtons, width / 2f, height / 2f) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + canvas.save() + canvas.rotate(rotateButtons, width / 2f, height / 2f) + + for (row in 0 until rows) { + for (col in 0 until cols) { + val index = toIndex(row, col) + + val drawable = if (index in buttonsPressed) { pressedDrawable } else { normalDrawable } + + drawable?.let { + val height = drawable.intrinsicHeight + val width = drawable.intrinsicWidth + val left = (xPadding + col * totalButtonSize).toInt() + val top = (yPadding + row * totalButtonSize).toInt() + + drawable.setBounds(left, top, left + width, top + height) + drawable.draw(canvas) + } + } + } + + canvas.restore() + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_MOVE -> { + handleTouchEvent(event.x, event.y) + return true + } + MotionEvent.ACTION_UP -> { + allKeysReleased() + invalidate() + return true + } + } + + return super.onTouchEvent(event) + } + + private fun handleTouchEvent(originalX: Float, originalY: Float) { + val point = floatArrayOf(originalX, originalY) + + touchRotationMatrix.mapPoints(point) + val x = (point[0] - xPadding) + val y = (point[1] - yPadding) + + val isXInRange = x in (0f..notRotatedWidth) + val isYInRange = y in (0f..notRotatedHeight) + + if (isXInRange && isYInRange) { + val col = floor(x / totalButtonSize).toInt() + val row = floor(y / totalButtonSize).toInt() + val index = toIndex(row, col) + + if (buttonsPressed.contains(index).not()) { + if (supportsMultipleInputs.not()) { + allKeysReleased() + } + onKeyPressed(index) + } + postInvalidate() + } + } + + private fun toIndex(row: Int, col: Int) = row * cols + col + + private fun onKeyPressed(index: Int) { + buttonsPressed.add(index) + events.accept(ButtonEvent(KeyEvent.ACTION_DOWN, index)) + } + + private fun allKeysReleased() { + buttonsPressed.map { events.accept(ButtonEvent(KeyEvent.ACTION_UP, it)) } + buttonsPressed.clear() + } + + private fun retrieveDrawable(drawableId: Int): Drawable? { + return AppCompatResources.getDrawable(context, drawableId) + } +} diff --git a/retrograde-touchinput/src/main/java/com/swordfish/touchinput/views/DirectionPad.kt b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/views/DirectionPad.kt new file mode 100644 index 00000000..0af7a46b --- /dev/null +++ b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/views/DirectionPad.kt @@ -0,0 +1,200 @@ +package com.swordfish.touchinput.views + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.TypedArray +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.drawable.Drawable +import android.support.v4.content.ContextCompat +import android.util.AttributeSet +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import com.jakewharton.rxrelay2.PublishRelay +import com.swordfish.touchinput.controller.R +import com.swordfish.touchinput.data.ButtonEvent +import com.swordfish.touchinput.interfaces.ButtonEventsSource +import io.reactivex.Observable +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.sin + +class DirectionPad @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr), ButtonEventsSource { + + companion object { + + private const val BUTTON_COUNT = 8 + private const val ROTATE_BUTTONS = 22.5f + private const val SINGLE_BUTTON_ANGLE = 360f / BUTTON_COUNT + + const val BUTTON_RIGHT = 0 + const val BUTTON_DOWN_RIGHT = 1 + const val BUTTON_DOWN = 2 + const val BUTTON_DOWN_LEFT = 3 + const val BUTTON_LEFT = 4 + const val BUTTON_UP_LEFT = 5 + const val BUTTON_UP = 6 + const val BUTTON_UP_RIGHT = 7 + } + + private var deadZone: Float = 0f + private var buttonCenterDistance: Float = 0f + + private val touchRotationMatrix = Matrix() + private var radius: Int = 0 + + private val normalDrawables: Map = initNormalDrawables() + private val pressedDrawables: Map = initPressedDrawables() + + private val events: PublishRelay = PublishRelay.create() + + private val buttonsPressed = mutableSetOf() + + init { + context.theme.obtainStyledAttributes(attrs, R.styleable.DirectionPad, defStyleAttr, R.style.default_directionpad).let { + initializeFromAttributes(it) + } + } + + override fun getEvents(): Observable = events + + private fun initializeFromAttributes(a: TypedArray) { + deadZone = a.getFloat(R.styleable.DirectionPad_deadZone, 0f) + buttonCenterDistance = a.getFloat(R.styleable.DirectionPad_buttonCenterDistance, 0f) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = getSize(MeasureSpec.getMode(widthMeasureSpec), MeasureSpec.getSize(widthMeasureSpec)) + val height = getSize(MeasureSpec.getMode(heightMeasureSpec), MeasureSpec.getSize(heightMeasureSpec)) + + val diameter = minOf(width, height) + setMeasuredDimension(diameter, diameter) + radius = diameter / 2 + } + + private fun getSize(widthMode: Int, widthSize: Int): Int { + return when (widthMode) { + MeasureSpec.EXACTLY -> widthSize + else -> minOf(resources.getDimension(R.dimen.size_dial).toInt(), widthSize) + } + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + + touchRotationMatrix.reset() + touchRotationMatrix.setRotate(ROTATE_BUTTONS) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val pressedButtons = convertDiagonals(buttonsPressed) + + for (i in 0..BUTTON_COUNT) { + val cAngle = SINGLE_BUTTON_ANGLE * i + + val isPressed = i in pressedButtons + + getStateDrawable(i, isPressed)?.let { + val height = it.intrinsicHeight + val width = it.intrinsicWidth + val angle = Math.toRadians((cAngle - ROTATE_BUTTONS + SINGLE_BUTTON_ANGLE / 2f).toDouble()) + val left = (radius * buttonCenterDistance * cos(angle) + radius).toInt() - width / 2 + val top = (radius * buttonCenterDistance * sin(angle) + radius).toInt() - height / 2 + + it.setBounds(left, top, left + width, top + height) + it.draw(canvas) + } + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_MOVE -> { + handleTouchEvent(event.x - radius, event.y - radius) + return true + } + MotionEvent.ACTION_UP -> { + allKeysReleased() + invalidate() + return true + } + } + + return super.onTouchEvent(event) + } + + private fun handleTouchEvent(originalX: Float, originalY: Float) { + val point = floatArrayOf(originalX, originalY) + + if (isOutsideDeadzone(point[0], point[1])) { + touchRotationMatrix.mapPoints(point) + val x = point[0] + val y = point[1] + + val angle = (atan2(y, x) * 180 / Math.PI + 360f) % 360f + val index = floor(angle / SINGLE_BUTTON_ANGLE).toInt() + + if (buttonsPressed.contains(index).not()) { + allKeysReleased() + onKeyPressed(index) + } + + postInvalidate() + } + } + + private fun onKeyPressed(index: Int) { + buttonsPressed.add(index) + events.accept(ButtonEvent(KeyEvent.ACTION_DOWN, index)) + } + + private fun allKeysReleased() { + buttonsPressed.map { events.accept(ButtonEvent(KeyEvent.ACTION_UP, it)) } + buttonsPressed.clear() + } + + private fun isOutsideDeadzone(x: Float, y: Float): Boolean { + return x * x + y * y > radius * deadZone * radius * deadZone + } + + private fun getStateDrawable(buttonIndex: Int, isPressed: Boolean): Drawable? { + val drawables = if (isPressed) { pressedDrawables } else { normalDrawables } + return drawables[buttonIndex] + } + + private fun initNormalDrawables(): Map { + return mapOf( + BUTTON_RIGHT to ContextCompat.getDrawable(context, R.drawable.direction_right_normal), + BUTTON_UP to ContextCompat.getDrawable(context, R.drawable.direction_up_normal), + BUTTON_LEFT to ContextCompat.getDrawable(context, R.drawable.direction_left_normal), + BUTTON_DOWN to ContextCompat.getDrawable(context, R.drawable.direction_down_normal) + ) + } + + private fun initPressedDrawables(): Map { + return mapOf( + BUTTON_RIGHT to ContextCompat.getDrawable(context, R.drawable.direction_right_pressed), + BUTTON_UP to ContextCompat.getDrawable(context, R.drawable.direction_up_pressed), + BUTTON_LEFT to ContextCompat.getDrawable(context, R.drawable.direction_left_pressed), + BUTTON_DOWN to ContextCompat.getDrawable(context, R.drawable.direction_down_pressed) + ) + } + + private fun convertDiagonals(buttonPressed: Set): Set { + return when { + BUTTON_DOWN_RIGHT in buttonPressed -> buttonPressed union setOf(BUTTON_DOWN, BUTTON_RIGHT) + BUTTON_DOWN_LEFT in buttonPressed -> buttonPressed union setOf(BUTTON_DOWN, BUTTON_LEFT) + BUTTON_UP_LEFT in buttonPressed -> buttonPressed union setOf(BUTTON_UP, BUTTON_LEFT) + BUTTON_UP_RIGHT in buttonPressed -> buttonPressed union setOf(BUTTON_UP, BUTTON_RIGHT) + else -> buttonPressed + } + } +} diff --git a/retrograde-touchinput/src/main/java/com/swordfish/touchinput/views/LargeSingleButton.kt b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/views/LargeSingleButton.kt new file mode 100644 index 00000000..79e485a2 --- /dev/null +++ b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/views/LargeSingleButton.kt @@ -0,0 +1,17 @@ +package com.swordfish.touchinput.views + +import android.content.Context +import android.util.AttributeSet +import com.swordfish.touchinput.controller.R +import com.swordfish.touchinput.views.base.BaseSingleButton + +class LargeSingleButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : BaseSingleButton(context, attrs, defStyleAttr) { + + init { + setBackgroundResource(R.drawable.large_button_selector) + } +} diff --git a/retrograde-touchinput/src/main/java/com/swordfish/touchinput/views/SmallSingleButton.kt b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/views/SmallSingleButton.kt new file mode 100644 index 00000000..5bce8e15 --- /dev/null +++ b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/views/SmallSingleButton.kt @@ -0,0 +1,17 @@ +package com.swordfish.touchinput.views + +import android.content.Context +import android.util.AttributeSet +import com.swordfish.touchinput.controller.R +import com.swordfish.touchinput.views.base.BaseSingleButton + +class SmallSingleButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : BaseSingleButton(context, attrs, defStyleAttr) { + + init { + setBackgroundResource(R.drawable.small_button_selector) + } +} diff --git a/retrograde-touchinput/src/main/java/com/swordfish/touchinput/views/base/BaseSingleButton.kt b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/views/base/BaseSingleButton.kt new file mode 100644 index 00000000..4d474d5f --- /dev/null +++ b/retrograde-touchinput/src/main/java/com/swordfish/touchinput/views/base/BaseSingleButton.kt @@ -0,0 +1,39 @@ +package com.swordfish.touchinput.views.base + +import android.content.Context +import android.support.v7.widget.AppCompatButton +import android.util.AttributeSet +import android.view.KeyEvent +import android.view.MotionEvent +import com.jakewharton.rxrelay2.PublishRelay +import com.swordfish.touchinput.data.ButtonEvent +import com.swordfish.touchinput.interfaces.ButtonEventsSource +import io.reactivex.Observable + +abstract class BaseSingleButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatButton(context, attrs, defStyleAttr), ButtonEventsSource { + + private val events: PublishRelay = PublishRelay.create() + + init { + setOnTouchListener { _, event -> handleTouchEvent(event); true } + } + + private fun handleTouchEvent(event: MotionEvent) { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + isPressed = true + events.accept(ButtonEvent(KeyEvent.ACTION_DOWN, 0)) + } + MotionEvent.ACTION_UP -> { + isPressed = false + events.accept(ButtonEvent(KeyEvent.ACTION_UP, 0)) + } + } + } + + override fun getEvents(): Observable = events +} diff --git a/retrograde-touchinput/src/main/res/drawable/action_normal.xml b/retrograde-touchinput/src/main/res/drawable/action_normal.xml new file mode 100644 index 00000000..d5dc0737 --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/action_normal.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/drawable/action_pressed.xml b/retrograde-touchinput/src/main/res/drawable/action_pressed.xml new file mode 100644 index 00000000..72373bab --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/action_pressed.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/drawable/direction_down_normal.xml b/retrograde-touchinput/src/main/res/drawable/direction_down_normal.xml new file mode 100644 index 00000000..8b5405fc --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/direction_down_normal.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/drawable/direction_down_pressed.xml b/retrograde-touchinput/src/main/res/drawable/direction_down_pressed.xml new file mode 100644 index 00000000..b5cc0ddc --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/direction_down_pressed.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/drawable/direction_left_normal.xml b/retrograde-touchinput/src/main/res/drawable/direction_left_normal.xml new file mode 100644 index 00000000..b9bc046a --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/direction_left_normal.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/drawable/direction_left_pressed.xml b/retrograde-touchinput/src/main/res/drawable/direction_left_pressed.xml new file mode 100644 index 00000000..e7b1963d --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/direction_left_pressed.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/drawable/direction_right_normal.xml b/retrograde-touchinput/src/main/res/drawable/direction_right_normal.xml new file mode 100644 index 00000000..9017644e --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/direction_right_normal.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/drawable/direction_right_pressed.xml b/retrograde-touchinput/src/main/res/drawable/direction_right_pressed.xml new file mode 100644 index 00000000..07cd6d37 --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/direction_right_pressed.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/drawable/direction_up_normal.xml b/retrograde-touchinput/src/main/res/drawable/direction_up_normal.xml new file mode 100644 index 00000000..87a6dc64 --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/direction_up_normal.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/drawable/direction_up_pressed.xml b/retrograde-touchinput/src/main/res/drawable/direction_up_pressed.xml new file mode 100644 index 00000000..29673bfa --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/direction_up_pressed.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/drawable/large_button_normal.xml b/retrograde-touchinput/src/main/res/drawable/large_button_normal.xml new file mode 100644 index 00000000..7fef53eb --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/large_button_normal.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/drawable/large_button_pressed.xml b/retrograde-touchinput/src/main/res/drawable/large_button_pressed.xml new file mode 100644 index 00000000..8e84185c --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/large_button_pressed.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/drawable/large_button_selector.xml b/retrograde-touchinput/src/main/res/drawable/large_button_selector.xml new file mode 100644 index 00000000..17fd6d87 --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/large_button_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/drawable/small_button_normal.xml b/retrograde-touchinput/src/main/res/drawable/small_button_normal.xml new file mode 100644 index 00000000..9b8c998c --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/small_button_normal.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/drawable/small_button_pressed.xml b/retrograde-touchinput/src/main/res/drawable/small_button_pressed.xml new file mode 100644 index 00000000..0526ba8a --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/small_button_pressed.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/drawable/small_button_selector.xml b/retrograde-touchinput/src/main/res/drawable/small_button_selector.xml new file mode 100644 index 00000000..767c21d8 --- /dev/null +++ b/retrograde-touchinput/src/main/res/drawable/small_button_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/retrograde-touchinput/src/main/res/layout/layout_gb.xml b/retrograde-touchinput/src/main/res/layout/layout_gb.xml new file mode 100644 index 00000000..932e7c65 --- /dev/null +++ b/retrograde-touchinput/src/main/res/layout/layout_gb.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + diff --git a/retrograde-touchinput/src/main/res/layout/layout_gba.xml b/retrograde-touchinput/src/main/res/layout/layout_gba.xml new file mode 100644 index 00000000..4825c274 --- /dev/null +++ b/retrograde-touchinput/src/main/res/layout/layout_gba.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + diff --git a/retrograde-touchinput/src/main/res/layout/layout_genesis.xml b/retrograde-touchinput/src/main/res/layout/layout_genesis.xml new file mode 100644 index 00000000..d3b5f540 --- /dev/null +++ b/retrograde-touchinput/src/main/res/layout/layout_genesis.xml @@ -0,0 +1,35 @@ + + + + + + + + + diff --git a/retrograde-touchinput/src/main/res/values/colors.xml b/retrograde-touchinput/src/main/res/values/colors.xml new file mode 100644 index 00000000..eb93eb17 --- /dev/null +++ b/retrograde-touchinput/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #7fff + diff --git a/retrograde-touchinput/src/main/res/values/default_styles.xml b/retrograde-touchinput/src/main/res/values/default_styles.xml new file mode 100644 index 00000000..15d4f627 --- /dev/null +++ b/retrograde-touchinput/src/main/res/values/default_styles.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/retrograde-touchinput/src/main/res/values/dimen.xml b/retrograde-touchinput/src/main/res/values/dimen.xml new file mode 100644 index 00000000..2640c0e2 --- /dev/null +++ b/retrograde-touchinput/src/main/res/values/dimen.xml @@ -0,0 +1,21 @@ + + + 160dp + 50dp + + 55dp + + 25dp + 50dp + + 40dp + 80dp + + 3dp + + 8dp + 16dp + + 2 + 30 + diff --git a/retrograde-touchinput/src/main/res/values/input_attrs.xml b/retrograde-touchinput/src/main/res/values/input_attrs.xml new file mode 100644 index 00000000..aa6147fb --- /dev/null +++ b/retrograde-touchinput/src/main/res/values/input_attrs.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/retrograde-touchinput/src/main/res/values/strings.xml b/retrograde-touchinput/src/main/res/values/strings.xml new file mode 100644 index 00000000..04330e08 --- /dev/null +++ b/retrograde-touchinput/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + M15,29 m-2,-2 l-8,-8 l0,-14 a17,17 0 0,1 20,0 l0,14 l-8,8 a3,3 0 0,1 -4,0z + M15,1 m2,2 l8,8 l0,14 a17,17 0 0,1 -20,0 l0,-14 l8,-8 a3,3 0 0,1 4,0z + M1,15 m2,-2 l8,-8 l14,0 a17,17 0 0,1 0,20 l-14,0 l-8,-8 a3,3 0 0,1 0,-4z + M29,15 m-2,-2 l-8,-8 l-14,0 a17,17 0 0,0 0,20 l14,0 l8,-8 a3,3 0 0,0 0,-4z + + diff --git a/settings.gradle.kts b/settings.gradle.kts index cf561244..c6c924d5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,5 +5,6 @@ include( ":retrograde-storage-webdav", ":retrograde-storage-archiveorg", ":retrograde-app-shared", - ":retrograde-app-tv" + ":retrograde-app-tv", + ":retrograde-touchinput" )