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:
2026-07-27 21:22:35 +08:00
parent b4712a782c
commit 8a36537994
6 changed files with 157 additions and 32 deletions
@@ -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 {
/** 左摇杆角度/强度 -> 轴指令 JSONX=前后, 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)
}
/** 右摇杆角度/强度 -> 轴指令 JSONYaw=偏航) */
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
}
)
}
}
}
}
}