feat: 摇杆手型 HandStyle 支持(标准/单摇杆左/单摇杆右/坦克式),设置界面持久化
- AppSettings.kt: 添加 handStyle 字段,默认 STANDARD - SettingsRepository.kt: 添加 KEY_HAND_STYLE 持久化键,setHandStyle() 方法,setAll() 包含手型 - JoystickController.kt: 添加 HandStyle 枚举,dualStickToAxisCommand() 按手型映射轴 - MainViewModel.kt: 轴指令循环传入当前 handStyle - SettingsScreen.kt: 添加 HandStyleDropdown 下拉选择组件 - AGENTS.md: 同步更新手型文档 Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package com.example.m20_gamepad.data
|
||||
|
||||
import com.example.m20_gamepad.service.HandStyle
|
||||
import com.example.m20_gamepad.video.VideoCodec
|
||||
|
||||
/**
|
||||
@@ -25,7 +26,8 @@ data class AppSettings(
|
||||
val robotPort: Int = DEFAULT_ROBOT_PORT,
|
||||
val rtspUrl: String = DEFAULT_RTSP_URL,
|
||||
val videoCodec: VideoCodec = VideoCodec.AUTO,
|
||||
val videoResizeMode: VideoResizeMode = VideoResizeMode.ZOOM
|
||||
val videoResizeMode: VideoResizeMode = VideoResizeMode.ZOOM,
|
||||
val handStyle: HandStyle = HandStyle.STANDARD
|
||||
) {
|
||||
companion object {
|
||||
const val DEFAULT_ROBOT_HOST = "10.21.41.1"
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.example.m20_gamepad.service.HandStyle
|
||||
import com.example.m20_gamepad.video.VideoCodec
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -35,7 +36,8 @@ class SettingsRepository(context: Context) {
|
||||
robotPort = prefs[KEY_PORT] ?: AppSettings.DEFAULT_ROBOT_PORT,
|
||||
rtspUrl = prefs[KEY_RTSP_URL] ?: AppSettings.DEFAULT_RTSP_URL,
|
||||
videoCodec = VideoCodec.entries.getOrElse(prefs[KEY_CODEC] ?: 0) { VideoCodec.AUTO },
|
||||
videoResizeMode = VideoResizeMode.entries.getOrElse(prefs[KEY_RESIZE_MODE] ?: 1) { VideoResizeMode.ZOOM }
|
||||
videoResizeMode = VideoResizeMode.entries.getOrElse(prefs[KEY_RESIZE_MODE] ?: 1) { VideoResizeMode.ZOOM },
|
||||
handStyle = HandStyle.entries.getOrElse(prefs[KEY_HAND_STYLE] ?: 0) { HandStyle.STANDARD }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +57,10 @@ class SettingsRepository(context: Context) {
|
||||
dataStore.edit { it[KEY_CODEC] = codec.ordinal }
|
||||
}
|
||||
|
||||
suspend fun setHandStyle(handStyle: HandStyle) {
|
||||
dataStore.edit { it[KEY_HAND_STYLE] = handStyle.ordinal }
|
||||
}
|
||||
|
||||
/** 一次性写入全部设置(用于设置界面保存按钮) */
|
||||
suspend fun setAll(settings: AppSettings) {
|
||||
dataStore.edit { prefs ->
|
||||
@@ -63,6 +69,7 @@ class SettingsRepository(context: Context) {
|
||||
prefs[KEY_RTSP_URL] = settings.rtspUrl.trim()
|
||||
prefs[KEY_CODEC] = settings.videoCodec.ordinal
|
||||
prefs[KEY_RESIZE_MODE] = settings.videoResizeMode.ordinal
|
||||
prefs[KEY_HAND_STYLE] = settings.handStyle.ordinal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,5 +79,6 @@ class SettingsRepository(context: Context) {
|
||||
private val KEY_RTSP_URL = stringPreferencesKey("rtsp_url")
|
||||
private val KEY_CODEC = intPreferencesKey("video_codec")
|
||||
private val KEY_RESIZE_MODE = intPreferencesKey("video_resize_mode")
|
||||
private val KEY_HAND_STYLE = intPreferencesKey("hand_style")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,20 @@ import com.example.m20_gamepad.network.models.ControlCommands
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* 手型(摇杆-轴映射模式)。
|
||||
*/
|
||||
enum class HandStyle {
|
||||
/** 标准:左摇杆 X/Y, 右摇杆 Yaw */
|
||||
STANDARD,
|
||||
/** 单摇杆左:左摇杆 X/Yaw(类车), 右摇杆 Y */
|
||||
SINGLE_LEFT,
|
||||
/** 单摇杆右:左摇杆 Y, 右摇杆 X/Yaw(类车) */
|
||||
SINGLE_RIGHT,
|
||||
/** 坦克式:两摇杆 up/down 差速 X/Yaw, 无 Y, cos 忽略 */
|
||||
TANK
|
||||
}
|
||||
|
||||
/**
|
||||
* 摇杆输入 -> 协议轴指令映射。
|
||||
*
|
||||
@@ -15,39 +29,59 @@ import kotlin.math.sin
|
||||
*
|
||||
* 协议轴定义:
|
||||
* - X 正 = 前进,Y 正 = 左移,Yaw 正 = 逆时针(左转)
|
||||
*
|
||||
* 映射公式:
|
||||
* - 左摇杆 X(前后) = sin(angle) * power/100
|
||||
* - 左摇杆 Y(左右) = cos(angle) * power/100
|
||||
* - 右摇杆 Yaw = cos(angle) * power/100
|
||||
*/
|
||||
object JoystickController {
|
||||
|
||||
/** 左摇杆角度/强度 -> 轴指令 JSON(X=前后, Y=左右) */
|
||||
fun leftStickToAxisCommand(angle: Double, power: Double): String {
|
||||
val scale = power / 100.0
|
||||
val x = sin(angle) * scale
|
||||
val y = cos(angle) * scale
|
||||
return ControlCommands.axisCommand(x = x, y = y)
|
||||
}
|
||||
|
||||
/** 右摇杆角度/强度 -> 轴指令 JSON(Yaw=偏航) */
|
||||
fun rightStickToAxisCommand(angle: Double, power: Double): String {
|
||||
val yaw = cos(angle) * power / 100.0
|
||||
return ControlCommands.axisCommand(yaw = yaw)
|
||||
}
|
||||
|
||||
/** 双摇杆合并 -> 轴指令 JSON(左摇杆控制 X/Y,右摇杆控制 Yaw) */
|
||||
/**
|
||||
* 根据手型将双摇杆输入映射为轴指令 JSON。
|
||||
*
|
||||
* @param handStyle 当前手型
|
||||
* @param leftAngle 左摇杆角度(弧度)
|
||||
* @param leftPower 左摇杆强度(0-100)
|
||||
* @param rightAngle 右摇杆角度(弧度)
|
||||
* @param rightPower 右摇杆强度(0-100)
|
||||
*/
|
||||
fun dualStickToAxisCommand(
|
||||
handStyle: HandStyle,
|
||||
leftAngle: Double, leftPower: Double,
|
||||
rightAngle: Double, rightPower: Double
|
||||
): String {
|
||||
val leftScale = leftPower / 100.0
|
||||
val rightScale = rightPower / 100.0
|
||||
val x = sin(leftAngle) * leftScale
|
||||
val y = cos(leftAngle) * leftScale
|
||||
val yaw = cos(rightAngle) * rightScale
|
||||
return ControlCommands.axisCommand(x = x, y = y, yaw = yaw)
|
||||
|
||||
return when (handStyle) {
|
||||
HandStyle.STANDARD -> {
|
||||
val x = sin(leftAngle) * leftScale
|
||||
val y = cos(leftAngle) * leftScale
|
||||
val yaw = cos(rightAngle) * rightScale
|
||||
ControlCommands.axisCommand(x = x, y = y, yaw = yaw)
|
||||
}
|
||||
HandStyle.SINGLE_LEFT -> {
|
||||
// 左摇杆:X(前后) + Yaw(转向),类车操控
|
||||
val x = sin(leftAngle) * leftScale
|
||||
val yaw = cos(leftAngle) * leftScale
|
||||
// 右摇杆:Y(横移),仅左右有效
|
||||
val y = cos(rightAngle) * rightScale
|
||||
ControlCommands.axisCommand(x = x, y = y, yaw = yaw)
|
||||
}
|
||||
HandStyle.SINGLE_RIGHT -> {
|
||||
// 左摇杆:Y(横移),仅左右有效
|
||||
val y = cos(leftAngle) * leftScale
|
||||
// 右摇杆:X(前后) + Yaw(转向),类车操控
|
||||
val x = sin(rightAngle) * rightScale
|
||||
val yaw = cos(rightAngle) * rightScale
|
||||
ControlCommands.axisCommand(x = x, y = y, yaw = yaw)
|
||||
}
|
||||
HandStyle.TANK -> {
|
||||
// 左摇杆 up/down → 左履带,右摇杆 up/down → 右履带
|
||||
// cos 方向忽略 → 仅 sin 参与
|
||||
val leftTrack = sin(leftAngle) * leftScale
|
||||
val rightTrack = sin(rightAngle) * rightScale
|
||||
val x = (leftTrack + rightTrack) / 2.0
|
||||
val yaw = (leftTrack - rightTrack) / 2.0
|
||||
ControlCommands.axisCommand(x = x, yaw = yaw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 零速度指令(松手时发送,保持连接活跃) */
|
||||
|
||||
@@ -348,6 +348,7 @@ class MainViewModel(
|
||||
if (c != null && c.isConnected) {
|
||||
val cmd = if (robot.canSendAxisCommand) {
|
||||
JoystickController.dualStickToAxisCommand(
|
||||
settings.value.handStyle,
|
||||
leftAngle, leftPower,
|
||||
rightAngle, rightPower
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.example.m20_gamepad.data.AppSettings
|
||||
import com.example.m20_gamepad.data.SettingsRepository
|
||||
import com.example.m20_gamepad.data.VideoResizeMode
|
||||
import com.example.m20_gamepad.service.HandStyle
|
||||
import com.example.m20_gamepad.video.VideoCodec
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -67,6 +68,7 @@ fun SettingsScreen(
|
||||
var rtspUrl by remember { mutableStateOf(AppSettings.DEFAULT_RTSP_URL) }
|
||||
var codec by remember { mutableStateOf(VideoCodec.AUTO) }
|
||||
var resizeMode by remember { mutableStateOf(VideoResizeMode.ZOOM) }
|
||||
var handStyle by remember { mutableStateOf(HandStyle.STANDARD) }
|
||||
var hostError by remember { mutableStateOf<String?>(null) }
|
||||
var portError by remember { mutableStateOf<String?>(null) }
|
||||
var initialized by remember { mutableStateOf(false) }
|
||||
@@ -79,6 +81,7 @@ fun SettingsScreen(
|
||||
rtspUrl = s.rtspUrl
|
||||
codec = s.videoCodec
|
||||
resizeMode = s.videoResizeMode
|
||||
handStyle = s.handStyle
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
@@ -170,6 +173,12 @@ fun SettingsScreen(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
HandStyleDropdown(
|
||||
selected = handStyle,
|
||||
onSelect = { handStyle = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.padding(top = 8.dp))
|
||||
|
||||
// ---- 操作按钮 ----
|
||||
@@ -193,7 +202,8 @@ fun SettingsScreen(
|
||||
robotPort = port,
|
||||
rtspUrl = rtspUrl,
|
||||
videoCodec = codec,
|
||||
videoResizeMode = resizeMode
|
||||
videoResizeMode = resizeMode,
|
||||
handStyle = handStyle
|
||||
)
|
||||
)
|
||||
onSaved()
|
||||
@@ -330,3 +340,60 @@ private fun ResizeModeDropdown(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun HandStyleDropdown(
|
||||
selected: HandStyle,
|
||||
onSelect: (HandStyle) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val options = HandStyle.entries
|
||||
val labels = mapOf(
|
||||
HandStyle.STANDARD to "标准(左摇杆 X/Y,右摇杆 Yaw)",
|
||||
HandStyle.SINGLE_LEFT to "单摇杆左(左摇杆 X/Yaw,右摇杆 Y)",
|
||||
HandStyle.SINGLE_RIGHT to "单摇杆右(左摇杆 Y,右摇杆 X/Yaw)",
|
||||
HandStyle.TANK to "坦克式(两摇杆差速,无横移)"
|
||||
)
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = "摇杆手型",
|
||||
style = androidx.compose.material3.MaterialTheme.typography.titleSmall,
|
||||
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(Modifier.padding(top = 4.dp))
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = labels[selected] ?: selected.name,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
options.forEach { h ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(labels[h] ?: h.name) },
|
||||
onClick = {
|
||||
onSelect(h)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user