From 8a36537994abe211f159d27e1aa2e1678136b628 Mon Sep 17 00:00:00 2001 From: CaoWangrenbo Date: Mon, 27 Jul 2026 21:22:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=91=87=E6=9D=86=E6=89=8B=E5=9E=8B=20?= =?UTF-8?q?HandStyle=20=E6=94=AF=E6=8C=81=EF=BC=88=E6=A0=87=E5=87=86/?= =?UTF-8?q?=E5=8D=95=E6=91=87=E6=9D=86=E5=B7=A6/=E5=8D=95=E6=91=87?= =?UTF-8?q?=E6=9D=86=E5=8F=B3/=E5=9D=A6=E5=85=8B=E5=BC=8F=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E8=AE=BE=E7=BD=AE=E7=95=8C=E9=9D=A2=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 23 ++++-- .../example/m20_gamepad/data/AppSettings.kt | 4 +- .../m20_gamepad/data/SettingsRepository.kt | 10 ++- .../m20_gamepad/service/JoystickController.kt | 82 +++++++++++++------ .../example/m20_gamepad/ui/MainViewModel.kt | 1 + .../example/m20_gamepad/ui/SettingsScreen.kt | 69 +++++++++++++++- 6 files changed, 157 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a576044..645d240 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ com.example.m20_gamepad/ │ └── StatusReports.kt # 下行状态结构(基础状态、运控状态、设备状态等) ├── service/ │ ├── RobotConnection.kt # 连接状态机管理(连接/订阅/心跳/断线判定) -│ └── JoystickController.kt # 摇杆输入 → 协议指令映射 +│ └── JoystickController.kt # 摇杆输入 → 协议指令映射,含 HandStyle 多手型支持 ├── video/ │ └── RtspVideoPlayer.kt # RTSP 视频流播放(ExoPlayer + Composable 包装) ├── data/ @@ -91,12 +91,23 @@ Joystick( - 角度 270° = 正下 → X 负方向(后退) - 强度百分比 → [-1, 1] 比例值 -**映射公式(从左摇杆角度/强度到 X/Y)**: +**标准手型(STANDARD)**: ``` -val x = power / 100.0 * cos(angle) // 垂直分量 -val y = power / 100.0 * sin(angle) // 水平分量 +左摇杆: X = sin(angle) * power/100, Y = cos(angle) * power/100 +右摇杆: Yaw = cos(angle) * power/100 +``` + +**单摇杆左(SINGLE_LEFT)**:左摇杆控制 X(前后) + Yaw(转向,类车操控),右摇杆仅控制 Y(横移) + +**单摇杆右(SINGLE_RIGHT)**:左摇杆仅控制 Y(横移),右摇杆控制 X(前后) + Yaw(转向,类车操控) + +**坦克式(TANK)**:两摇杆 up/down 差速计算 X/Yaw,无横移 Y +``` +左履带 = sin(leftAngle) * leftScale +右履带 = sin(rightAngle) * rightScale +X = (左履带 + 右履带) / 2 +Yaw = (左履带 - 右履带) / 2 ``` -右摇杆控制 Yaw(偏航角速度)。 --- @@ -117,6 +128,7 @@ val y = power / 100.0 * sin(angle) // 水平分量 | `rtsp_url` | String | `rtsp://10.21.31.103:554/stream` | RTSP 视频流地址 | | `video_codec` | Enum | `AUTO` | `AUTO` / `FORCE_HW` / `FORCE_SW_H264` / `FORCE_SW_H265` | | `video_resize_mode` | Enum | `ZOOM` | `FIT` / `ZOOM` / `FILL` | +| `hand_style` | Enum | `STANDARD` | `STANDARD` / `SINGLE_LEFT` / `SINGLE_RIGHT` / `TANK` | ### 持久化 @@ -219,6 +231,7 @@ gradlew installDebug - [x] 休眠按钮状态约束:仅非站立状态(空闲/趴下/阻尼等)可切换休眠,站立/RL 控制时禁用 - [x] RTSP 自动重连:`RtspVideoPlayer` 播放失败后自动重试,指数退避(初始 1s,最大 30s,倍率 2),URL/解码器变化时重置退避状态;覆盖层显示重试次数和倒计时 - [x] 订阅请求清理:删除 `ProtocolClient.sendSubscriptionRequests()` 方法及 `connect()` 中的注释调用;同步删除 `ControlCommands.subscribeStatus()` 和 `SUB_*` 常量,彻底清除死代码 +- [x] 摇杆手型支持:`HandStyle` 枚举(标准/单摇杆左/单摇杆右/坦克式),映射到 `JoystickController.dualStickToAxisCommand()`,设置界面持久化存储,实时生效 ### 待办 diff --git a/app/src/main/java/com/example/m20_gamepad/data/AppSettings.kt b/app/src/main/java/com/example/m20_gamepad/data/AppSettings.kt index 7db742c..1d9a546 100644 --- a/app/src/main/java/com/example/m20_gamepad/data/AppSettings.kt +++ b/app/src/main/java/com/example/m20_gamepad/data/AppSettings.kt @@ -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" diff --git a/app/src/main/java/com/example/m20_gamepad/data/SettingsRepository.kt b/app/src/main/java/com/example/m20_gamepad/data/SettingsRepository.kt index da4577b..692542b 100644 --- a/app/src/main/java/com/example/m20_gamepad/data/SettingsRepository.kt +++ b/app/src/main/java/com/example/m20_gamepad/data/SettingsRepository.kt @@ -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") } } diff --git a/app/src/main/java/com/example/m20_gamepad/service/JoystickController.kt b/app/src/main/java/com/example/m20_gamepad/service/JoystickController.kt index b57dac1..f8844cc 100644 --- a/app/src/main/java/com/example/m20_gamepad/service/JoystickController.kt +++ b/app/src/main/java/com/example/m20_gamepad/service/JoystickController.kt @@ -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) + } + } } /** 零速度指令(松手时发送,保持连接活跃) */ diff --git a/app/src/main/java/com/example/m20_gamepad/ui/MainViewModel.kt b/app/src/main/java/com/example/m20_gamepad/ui/MainViewModel.kt index 0ed9205..18b2798 100644 --- a/app/src/main/java/com/example/m20_gamepad/ui/MainViewModel.kt +++ b/app/src/main/java/com/example/m20_gamepad/ui/MainViewModel.kt @@ -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 ) diff --git a/app/src/main/java/com/example/m20_gamepad/ui/SettingsScreen.kt b/app/src/main/java/com/example/m20_gamepad/ui/SettingsScreen.kt index 03fc4f9..4c45d98 100644 --- a/app/src/main/java/com/example/m20_gamepad/ui/SettingsScreen.kt +++ b/app/src/main/java/com/example/m20_gamepad/ui/SettingsScreen.kt @@ -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(null) } var portError by remember { mutableStateOf(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 + } + ) + } + } + } + } +}