Initial commit: M20 Gamepad control app
山猫 M20 四足机器人手柄控制 Android App,使用 UDP + JSON 协议控制机器人运动。 - 协议层:16 字节小端 Header + JSON ASDU 编解码 - 网络层:UDP 客户端,心跳 1Hz,断线检测 3s - 连接状态机:空闲/连接中/已连接/断线 + 运动状态机:空闲/站立/RL 控制 - 虚拟摇杆:Compose 原生摇杆库,angle/power -> X/Y/Yaw 映射 - 视频播放:Media3 ExoPlayer RTSP,支持 AUTO/FORCE_HW/FORCE_SW_H264/FORCE_SW_H265 - 设置界面:机器人 IP/端口、RTSP 地址、编码器选择,DataStore 持久化 - 状态面板:异常列表、运控状态、设备温度、电池信息 - 控制按钮:模式/步态/照明/充电/休眠 Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true">
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.erz.joysticklibrary
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@Deprecated(
|
||||
message = "Use the Compose-based Joystick composable instead.",
|
||||
replaceWith = ReplaceWith("Joystick", "com.erz.joysticklibrary.Joystick")
|
||||
)
|
||||
class JoyStick @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs), GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener {
|
||||
|
||||
companion object {
|
||||
const val DIRECTION_CENTER = -1
|
||||
const val DIRECTION_LEFT = 0
|
||||
const val DIRECTION_LEFT_UP = 1
|
||||
const val DIRECTION_UP = 2
|
||||
const val DIRECTION_UP_RIGHT = 3
|
||||
const val DIRECTION_RIGHT = 4
|
||||
const val DIRECTION_RIGHT_DOWN = 5
|
||||
const val DIRECTION_DOWN = 6
|
||||
const val DIRECTION_DOWN_LEFT = 7
|
||||
|
||||
const val TYPE_8_AXIS = 11
|
||||
const val TYPE_4_AXIS = 22
|
||||
const val TYPE_2_AXIS_LEFT_RIGHT = 33
|
||||
const val TYPE_2_AXIS_UP_DOWN = 44
|
||||
|
||||
private fun calculateDirection(degrees: Double): Int {
|
||||
return when {
|
||||
(degrees >= 0 && degrees < 22.5) || (degrees < 0 && degrees > -22.5) -> DIRECTION_LEFT
|
||||
degrees >= 22.5 && degrees < 67.5 -> DIRECTION_LEFT_UP
|
||||
degrees >= 67.5 && degrees < 112.5 -> DIRECTION_UP
|
||||
degrees >= 112.5 && degrees < 157.5 -> DIRECTION_UP_RIGHT
|
||||
(degrees >= 157.5 && degrees <= 180) || (degrees >= -180 && degrees < -157.5) -> DIRECTION_RIGHT
|
||||
degrees >= -157.5 && degrees < -112.5 -> DIRECTION_RIGHT_DOWN
|
||||
degrees >= -112.5 && degrees < -67.5 -> DIRECTION_DOWN
|
||||
degrees >= -67.5 && degrees < -22.5 -> DIRECTION_DOWN_LEFT
|
||||
else -> DIRECTION_CENTER
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface JoyStickListener {
|
||||
fun onMove(joyStick: JoyStick?, angle: Double, power: Double, direction: Int)
|
||||
fun onTap()
|
||||
fun onDoubleTap()
|
||||
}
|
||||
|
||||
private var listener: JoyStickListener? = null
|
||||
private val paint: Paint = Paint().apply {
|
||||
style = Paint.Style.FILL
|
||||
isAntiAlias = true
|
||||
isFilterBitmap = true
|
||||
}
|
||||
private val temp: RectF = RectF()
|
||||
private val gestureDetector: GestureDetector
|
||||
private var direction = DIRECTION_CENTER
|
||||
private var type = TYPE_8_AXIS
|
||||
private var centerX = 0f
|
||||
private var centerY = 0f
|
||||
private var posX = 0f
|
||||
private var posY = 0f
|
||||
private var radius = 0f
|
||||
private var buttonRadius = 0f
|
||||
private var power = 0.0
|
||||
private var angle = 0.0
|
||||
|
||||
// Background Color
|
||||
private var padColor: Int = Color.WHITE
|
||||
|
||||
// Stick Color
|
||||
private var buttonColor: Int = Color.RED
|
||||
|
||||
// Keeps joystick in last position
|
||||
private var stayPut = false
|
||||
|
||||
// Button Size percentage of the minimum(width, height)
|
||||
private var percentage = 25
|
||||
|
||||
// Background Bitmap
|
||||
private var padBGBitmap: Bitmap? = null
|
||||
|
||||
// Button Bitmap
|
||||
private var buttonBitmap: Bitmap? = null
|
||||
|
||||
init {
|
||||
gestureDetector = GestureDetector(context, this).apply {
|
||||
setIsLongpressEnabled(false)
|
||||
setOnDoubleTapListener(this@JoyStick)
|
||||
}
|
||||
|
||||
if (attrs != null) {
|
||||
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.JoyStick)
|
||||
padColor = typedArray.getColor(R.styleable.JoyStick_padColor, Color.WHITE)
|
||||
buttonColor = typedArray.getColor(R.styleable.JoyStick_buttonColor, Color.RED)
|
||||
stayPut = typedArray.getBoolean(R.styleable.JoyStick_stayPut, false)
|
||||
percentage = typedArray.getInt(R.styleable.JoyStick_percentage, 25)
|
||||
if (percentage > 50) percentage = 50
|
||||
if (percentage < 25) percentage = 25
|
||||
|
||||
val padResId = typedArray.getResourceId(R.styleable.JoyStick_backgroundDrawable, -1)
|
||||
val buttonResId = typedArray.getResourceId(R.styleable.JoyStick_buttonDrawable, -1)
|
||||
|
||||
if (padResId > 0) {
|
||||
padBGBitmap = BitmapFactory.decodeResource(resources, padResId)
|
||||
}
|
||||
if (buttonResId > 0) {
|
||||
buttonBitmap = BitmapFactory.decodeResource(resources, buttonResId)
|
||||
}
|
||||
typedArray.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
val width = MeasureSpec.getSize(widthMeasureSpec).toFloat()
|
||||
val height = MeasureSpec.getSize(heightMeasureSpec).toFloat()
|
||||
centerX = width / 2
|
||||
centerY = height / 2
|
||||
val minDim = minOf(width, height)
|
||||
posX = centerX
|
||||
posY = centerY
|
||||
buttonRadius = minDim / 2f * (percentage / 100f)
|
||||
radius = minDim / 2f * ((100f - percentage) / 100f)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val pad = padBGBitmap
|
||||
if (pad == null) {
|
||||
paint.color = padColor
|
||||
canvas.drawCircle(centerX, centerY, radius, paint)
|
||||
} else {
|
||||
temp.set(centerX - radius, centerY - radius, centerX + radius, centerY + radius)
|
||||
canvas.drawBitmap(pad, null, temp, paint)
|
||||
}
|
||||
|
||||
val button = buttonBitmap
|
||||
if (button == null) {
|
||||
paint.color = buttonColor
|
||||
canvas.drawCircle(posX, posY, buttonRadius, paint)
|
||||
} else {
|
||||
temp.set(posX - buttonRadius, posY - buttonRadius, posX + buttonRadius, posY + buttonRadius)
|
||||
canvas.drawBitmap(button, null, temp, paint)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
gestureDetector.onTouchEvent(event)
|
||||
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
|
||||
posX = event.x
|
||||
posY = event.y
|
||||
|
||||
when (type) {
|
||||
TYPE_2_AXIS_LEFT_RIGHT -> {
|
||||
posY = centerY
|
||||
}
|
||||
TYPE_2_AXIS_UP_DOWN -> {
|
||||
posX = centerX
|
||||
}
|
||||
TYPE_4_AXIS -> {
|
||||
if (abs(posX - centerX) > abs(posY - centerY)) {
|
||||
posY = centerY
|
||||
} else {
|
||||
posX = centerX
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val absVal = sqrt(((posX - centerX) * (posX - centerX) + (posY - centerY) * (posY - centerY)).toDouble()).toFloat()
|
||||
if (absVal > radius) {
|
||||
posX = (posX - centerX) * radius / absVal + centerX
|
||||
posY = (posY - centerY) * radius / absVal + centerY
|
||||
}
|
||||
|
||||
angle = atan2((centerY - posY).toDouble(), (centerX - posX).toDouble())
|
||||
power = 100 * sqrt(((posX - centerX) * (posX - centerX) + (posY - centerY) * (posY - centerY)).toDouble()) / radius
|
||||
direction = calculateDirection(Math.toDegrees(angle))
|
||||
invalidate()
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
if (!stayPut) {
|
||||
posX = centerX
|
||||
posY = centerY
|
||||
direction = DIRECTION_CENTER
|
||||
angle = 0.0
|
||||
power = 0.0
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listener?.onMove(this, angle, power, direction)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDown(motionEvent: MotionEvent): Boolean = true
|
||||
override fun onShowPress(motionEvent: MotionEvent) {}
|
||||
override fun onSingleTapUp(motionEvent: MotionEvent): Boolean = false
|
||||
override fun onScroll(motionEvent: MotionEvent?, motionEvent1: MotionEvent, v: Float, v1: Float): Boolean = false
|
||||
override fun onLongPress(motionEvent: MotionEvent) {}
|
||||
override fun onFling(motionEvent: MotionEvent?, motionEvent1: MotionEvent, v: Float, v1: Float): Boolean = false
|
||||
|
||||
override fun onSingleTapConfirmed(motionEvent: MotionEvent): Boolean {
|
||||
listener?.onTap()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onDoubleTap(motionEvent: MotionEvent): Boolean {
|
||||
listener?.onDoubleTap()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onDoubleTapEvent(motionEvent: MotionEvent): Boolean = false
|
||||
|
||||
fun setListener(listener: JoyStickListener?) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
fun getPower(): Double = power
|
||||
fun getAngle(): Double = angle
|
||||
fun getAngleDegrees(): Double = Math.toDegrees(angle)
|
||||
fun getDirection(): Int = direction
|
||||
fun getType(): Int = type
|
||||
|
||||
fun setPadColor(padColor: Int) {
|
||||
this.padColor = padColor
|
||||
}
|
||||
|
||||
fun setButtonColor(buttonColor: Int) {
|
||||
this.buttonColor = buttonColor
|
||||
}
|
||||
|
||||
fun setButtonRadiusScale(scale: Int) {
|
||||
percentage = scale
|
||||
if (percentage > 50) percentage = 50
|
||||
if (percentage < 25) percentage = 25
|
||||
}
|
||||
|
||||
fun enableStayPut(enable: Boolean) {
|
||||
this.stayPut = enable
|
||||
}
|
||||
|
||||
fun setPadBackground(resId: Int) {
|
||||
this.padBGBitmap = BitmapFactory.decodeResource(resources, resId)
|
||||
}
|
||||
|
||||
fun setPadBackground(bitmap: Bitmap?) {
|
||||
this.padBGBitmap = bitmap
|
||||
}
|
||||
|
||||
fun setButtonDrawable(resId: Int) {
|
||||
this.buttonBitmap = BitmapFactory.decodeResource(resources, resId)
|
||||
}
|
||||
|
||||
fun setButtonDrawable(bitmap: Bitmap?) {
|
||||
this.buttonBitmap = bitmap
|
||||
}
|
||||
|
||||
fun setType(type: Int) {
|
||||
this.type = type
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package com.erz.joysticklibrary
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
import androidx.compose.ui.util.fastAll
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
|
||||
enum class JoystickDirection(val value: Int) {
|
||||
CENTER(-1),
|
||||
LEFT(0),
|
||||
LEFT_UP(1),
|
||||
UP(2),
|
||||
UP_RIGHT(3),
|
||||
RIGHT(4),
|
||||
RIGHT_DOWN(5),
|
||||
DOWN(6),
|
||||
DOWN_LEFT(7);
|
||||
|
||||
companion object {
|
||||
private val valuesByValue = entries.associateBy { it.value }
|
||||
fun fromValue(value: Int): JoystickDirection = valuesByValue[value] ?: CENTER
|
||||
}
|
||||
}
|
||||
|
||||
enum class JoystickType(val value: Int) {
|
||||
EIGHT_AXIS(11),
|
||||
FOUR_AXIS(22),
|
||||
TWO_AXIS_HORIZONTAL(33),
|
||||
TWO_AXIS_VERTICAL(44)
|
||||
}
|
||||
|
||||
/**
|
||||
* A highly customizable, performant, and responsive Joystick composable for Jetpack Compose.
|
||||
*
|
||||
* It supports various axis restrictions, customizable shapes/colors, custom drawables/painters,
|
||||
* and built-in gesture callbacks (move, tap, and double tap).
|
||||
*
|
||||
* ### How to Use
|
||||
*
|
||||
* ```kotlin
|
||||
* Joystick(
|
||||
* modifier = Modifier
|
||||
* .size(150.dp)
|
||||
* .background(Color.Gray)
|
||||
* .clip(CircleShape),
|
||||
* type = JoystickType.EIGHT_AXIS,
|
||||
* stayPut = false,
|
||||
* onMove = { angle, power, direction ->
|
||||
* // Handle movement
|
||||
* // angle (radians), power (0% - 100%), direction (enum)
|
||||
* },
|
||||
* onTap = {
|
||||
* // Handle tap
|
||||
* },
|
||||
* onDoubleTap = {
|
||||
* // Handle double tap
|
||||
* }
|
||||
* )
|
||||
* ```
|
||||
*
|
||||
* @param modifier The modifier to be applied to the layout. Crucial for setting the size (e.g., [Modifier.size]) of the joystick.
|
||||
* @param type The movement restriction constraints ([JoystickType]). Defaults to [JoystickType.EIGHT_AXIS].
|
||||
* @param stayPut If true, the thumb will stay in its last position when released. If false, it snaps back to the center.
|
||||
* @param radiusScale Scale ratio of the button (thumb) radius relative to the total joystick radius. Range: `[0.25f, 0.50f]`.
|
||||
* @param padColor Fallback background color of the pad if no [padPainter] is provided.
|
||||
* @param buttonColor Fallback background color of the thumb button if no [buttonPainter] is provided.
|
||||
* @param padPainter Custom [Painter] for drawing the background pad of the joystick.
|
||||
* @param buttonPainter Custom [Painter] for drawing the joystick thumb button.
|
||||
* @param onMove Callback triggered when the joystick is dragged. Receives:
|
||||
* - `angle` (Double): Direction angle in radians (ranging from `-PI` to `PI`).
|
||||
* - `power` (Double): Travel displacement percentage of the button from the center, ranging from `0.0` to `100.0`.
|
||||
* - `direction` (JoystickDirection): Decoded directional zone (e.g. `UP`, `DOWN_LEFT`, `CENTER`).
|
||||
* @param onTap Optional callback triggered when the joystick is clicked (pointer down & up without dragging).
|
||||
* @param onDoubleTap Optional callback triggered when the joystick is double-clicked.
|
||||
*/
|
||||
@Composable
|
||||
fun Joystick(
|
||||
modifier: Modifier = Modifier,
|
||||
type: JoystickType = JoystickType.EIGHT_AXIS,
|
||||
stayPut: Boolean = false,
|
||||
radiusScale: Float = 0.25f,
|
||||
padColor: Color = Color.White,
|
||||
buttonColor: Color = Color.Red,
|
||||
padPainter: Painter? = null,
|
||||
buttonPainter: Painter? = null,
|
||||
onMove: (angle: Double, power: Double, direction: JoystickDirection) -> Unit = { _, _, _ -> },
|
||||
onTap: (() -> Unit)? = null,
|
||||
onDoubleTap: (() -> Unit)? = null
|
||||
) {
|
||||
val currentOnMove by rememberUpdatedState(onMove)
|
||||
val currentOnTap by rememberUpdatedState(onTap)
|
||||
val currentOnDoubleTap by rememberUpdatedState(onDoubleTap)
|
||||
|
||||
val scale = remember(radiusScale) { radiusScale.coerceIn(0.25f, 0.50f) }
|
||||
|
||||
var thumbOffsetX by remember { mutableFloatStateOf(0f) }
|
||||
var thumbOffsetY by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(stayPut, type, scale) {
|
||||
val touchSlop = viewConfiguration.touchSlop
|
||||
var lastTapTime = 0L
|
||||
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val now = System.currentTimeMillis()
|
||||
val isDoubleTap = now - lastTapTime < 300L
|
||||
|
||||
var dragTriggered = false
|
||||
val pointerId = down.id
|
||||
|
||||
fun updatePosition(rawX: Float, rawY: Float) {
|
||||
val currentWidth = size.width
|
||||
val currentHeight = size.height
|
||||
val centerX = currentWidth / 2f
|
||||
val centerY = currentHeight / 2f
|
||||
val minDim = min(currentWidth, currentHeight).toFloat()
|
||||
val maxTravelDistance = (minDim / 2f) * (1f - scale)
|
||||
|
||||
if (maxTravelDistance <= 0f) return
|
||||
|
||||
val deltaX = rawX - centerX
|
||||
val deltaY = rawY - centerY
|
||||
|
||||
var targetDx = deltaX
|
||||
var targetDy = deltaY
|
||||
|
||||
when (type) {
|
||||
JoystickType.TWO_AXIS_HORIZONTAL -> targetDy = 0f
|
||||
JoystickType.TWO_AXIS_VERTICAL -> targetDx = 0f
|
||||
JoystickType.FOUR_AXIS -> {
|
||||
if (abs(deltaX) > abs(deltaY)) targetDy = 0f else targetDx = 0f
|
||||
}
|
||||
JoystickType.EIGHT_AXIS -> { /* No restriction */ }
|
||||
}
|
||||
|
||||
val distance = sqrt(targetDx * targetDx + targetDy * targetDy)
|
||||
|
||||
val newOffsetX: Float
|
||||
val newOffsetY: Float
|
||||
if (distance > maxTravelDistance) {
|
||||
newOffsetX = targetDx * maxTravelDistance / distance
|
||||
newOffsetY = targetDy * maxTravelDistance / distance
|
||||
} else {
|
||||
newOffsetX = targetDx
|
||||
newOffsetY = targetDy
|
||||
}
|
||||
|
||||
// Skip callback if thumb position hasn't meaningfully changed (prevent micro-jitter)
|
||||
if (abs(newOffsetX - thumbOffsetX) < 0.01f && abs(newOffsetY - thumbOffsetY) < 0.01f) return
|
||||
|
||||
thumbOffsetX = newOffsetX
|
||||
thumbOffsetY = newOffsetY
|
||||
|
||||
val power = (100f * sqrt(thumbOffsetX * thumbOffsetX + thumbOffsetY * thumbOffsetY) / maxTravelDistance).toDouble()
|
||||
val angle = atan2(-thumbOffsetY, -thumbOffsetX).toDouble()
|
||||
val direction = calculateDirection(Math.toDegrees(angle))
|
||||
|
||||
currentOnMove(angle, power, direction)
|
||||
}
|
||||
|
||||
updatePosition(down.position.x, down.position.y)
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
|
||||
val change = event.changes.fastFirstOrNull { it.id == pointerId }
|
||||
|
||||
if (change != null && change.positionChanged()) {
|
||||
// Only count as drag if movement exceeds touch slop
|
||||
if (!dragTriggered) {
|
||||
val totalDragX = change.position.x - down.position.x
|
||||
val totalDragY = change.position.y - down.position.y
|
||||
val totalDrag = sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
|
||||
if (totalDrag > touchSlop) {
|
||||
dragTriggered = true
|
||||
}
|
||||
}
|
||||
updatePosition(change.position.x, change.position.y)
|
||||
change.consume()
|
||||
}
|
||||
|
||||
if (event.changes.fastAll { !it.pressed }) {
|
||||
if (!dragTriggered) {
|
||||
if (isDoubleTap) {
|
||||
currentOnDoubleTap?.invoke()
|
||||
lastTapTime = 0L // Reset to prevent triple-tap as double
|
||||
} else {
|
||||
currentOnTap?.invoke()
|
||||
lastTapTime = now // Only record tap time for actual taps
|
||||
}
|
||||
}
|
||||
if (!stayPut) {
|
||||
thumbOffsetX = 0f
|
||||
thumbOffsetY = 0f
|
||||
currentOnMove(0.0, 0.0, JoystickDirection.CENTER)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val centerX = size.width / 2f
|
||||
val centerY = size.height / 2f
|
||||
val minDim = min(size.width, size.height)
|
||||
val maxTravelDistance = (minDim / 2f) * (1f - scale)
|
||||
val buttonRadius = (minDim / 2f) * scale
|
||||
|
||||
if (centerX == 0f || centerY == 0f) return@Canvas
|
||||
|
||||
// Draw Background Pad
|
||||
if (padPainter != null) {
|
||||
translate(left = centerX - maxTravelDistance, top = centerY - maxTravelDistance) {
|
||||
with(padPainter) {
|
||||
draw(size = Size(maxTravelDistance * 2, maxTravelDistance * 2))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
drawCircle(
|
||||
color = padColor,
|
||||
radius = maxTravelDistance,
|
||||
center = Offset(centerX, centerY)
|
||||
)
|
||||
}
|
||||
|
||||
// Draw Button tracking states cleanly
|
||||
val posX = centerX + thumbOffsetX
|
||||
val posY = centerY + thumbOffsetY
|
||||
|
||||
if (buttonPainter != null) {
|
||||
translate(left = posX - buttonRadius, top = posY - buttonRadius) {
|
||||
with(buttonPainter) {
|
||||
draw(size = Size(buttonRadius * 2, buttonRadius * 2))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
drawCircle(
|
||||
color = buttonColor,
|
||||
radius = buttonRadius,
|
||||
center = Offset(posX, posY)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean, O(1) mathematical lookup mapping degrees flawlessly to standard directions
|
||||
private fun calculateDirection(degrees: Double): JoystickDirection {
|
||||
// Normalize degrees from [-180, 180] to [0, 360)
|
||||
val normalized = if (degrees < 0) degrees + 360.0 else degrees
|
||||
|
||||
// Shift by 22.5 degrees so that the "LEFT" sector spans across the 0/360 boundary cleanly
|
||||
val shifted = (normalized + 22.5) % 360.0
|
||||
|
||||
// Map 45-degree chunks to their respective indices
|
||||
return when ((shifted / 45.0).toInt()) {
|
||||
0 -> JoystickDirection.LEFT
|
||||
1 -> JoystickDirection.LEFT_UP
|
||||
2 -> JoystickDirection.UP
|
||||
3 -> JoystickDirection.UP_RIGHT
|
||||
4 -> JoystickDirection.RIGHT
|
||||
5 -> JoystickDirection.RIGHT_DOWN
|
||||
6 -> JoystickDirection.DOWN
|
||||
7 -> JoystickDirection.DOWN_LEFT
|
||||
else -> JoystickDirection.CENTER
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<declare-styleable name="JoyStick">
|
||||
<attr name="padColor" format="color" />
|
||||
<attr name="buttonColor" format="color" />
|
||||
<attr name="stayPut" format="boolean" />
|
||||
<attr name="percentage" format="integer" />
|
||||
<attr name="backgroundDrawable" format="reference" />
|
||||
<attr name="buttonDrawable" format="reference" />
|
||||
</declare-styleable>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">JoyStick</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user