diff --git a/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/setting/AllSettings.kt b/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/setting/AllSettings.kt index 3f4279a9b..617696358 100644 --- a/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/setting/AllSettings.kt +++ b/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/setting/AllSettings.kt @@ -31,6 +31,7 @@ import com.movtery.zalithlauncher.setting.enums.MirrorSourceType import com.movtery.zalithlauncher.setting.enums.MouseControlMode import com.movtery.zalithlauncher.ui.control.HotbarRule import com.movtery.zalithlauncher.ui.control.gamepad.JoystickMode +import com.movtery.zalithlauncher.ui.control.joystick.HalfScreenJoystickMode import com.movtery.zalithlauncher.ui.control.mouse.CENTER_HOTSPOT import com.movtery.zalithlauncher.ui.control.mouse.CursorHotspot import com.movtery.zalithlauncher.ui.control.mouse.LEFT_TOP_HOTSPOT @@ -510,6 +511,14 @@ object AllSettings : SettingsRegistry() { */ val joystickControlLockSpring = boolSetting("joystickControlLockSpring", true) + /** + * 半屏摇杆模式 + * 禁用:使用固定位置摇杆 + * 左半屏:手指在左半屏非控制按键位置按下时,以该位置为中心出现摇杆 + * 右半屏:手指在右半屏非控制按键位置按下时,以该位置为中心出现摇杆 + */ + val joystickHalfScreenMode = enumSetting("joystickHalfScreenMode", HalfScreenJoystickMode.Disabled) + /** * 上次检查更新的时间戳 */ diff --git a/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/control/joystick/HalfScreenJoystick.kt b/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/control/joystick/HalfScreenJoystick.kt new file mode 100644 index 000000000..c7093cfc4 --- /dev/null +++ b/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/control/joystick/HalfScreenJoystick.kt @@ -0,0 +1,257 @@ +/* + * Zalith Launcher 2 + * Copyright (C) 2025 MovTery and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.movtery.zalithlauncher.ui.control.joystick + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.changedToDown +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import com.movtery.layer_controller.observable.ObservableJoystickStyle +import kotlinx.coroutines.coroutineScope +import kotlin.math.roundToInt + +class HalfScreenJoystickState { + var isVisible by mutableStateOf(false) + var center by mutableStateOf(Offset.Zero) + var joystickOffset by mutableStateOf(Offset.Zero) + var isLocked by mutableStateOf(false) + var internalCanLock by mutableStateOf(false) +} + +@Composable +fun rememberHalfScreenJoystickState() = remember { HalfScreenJoystickState() } + +@Composable +fun HalfScreenJoystickInput( + state: HalfScreenJoystickState, + modifier: Modifier = Modifier, + screenSize: IntSize, + size: Dp, + deadZoneRatio: Float, + canLock: Boolean, + isLeftHalf: Boolean, + offsetX: Int = 0, + isOccupiedPointer: (PointerId) -> Boolean, + onOccupiedPointer: (PointerId) -> Unit, + onReleasePointer: (PointerId) -> Unit, + onDirectionChanged: (JoystickDirection) -> Unit, + onLock: (Boolean) -> Unit +) { + val density = LocalDensity.current + val joystickSizePx = with(density) { size.toPx() } + + val currentOnDirectionChanged by rememberUpdatedState(onDirectionChanged) + val currentOnLock by rememberUpdatedState(onLock) + + var lastReportedDirection by remember { mutableStateOf(JoystickDirection.None) } + + Box( + modifier = modifier + .fillMaxSize() + .pointerInput(screenSize, joystickSizePx, deadZoneRatio, canLock, isLeftHalf, offsetX) { + handleJoystickTouch( + screenSize = screenSize, + joystickSizePx = joystickSizePx, + deadZoneRatio = deadZoneRatio, + canLock = canLock, + isLeftHalf = isLeftHalf, + offsetX = offsetX, + isOccupiedPointer = isOccupiedPointer, + onOccupiedPointer = onOccupiedPointer, + onReleasePointer = onReleasePointer, + onUpdateState = { visible, center, offset, locked, canLockState, direction -> + state.isVisible = visible + state.center = center + state.joystickOffset = offset + + if (state.isLocked != locked) { + state.isLocked = locked + currentOnLock(locked) + } + + state.internalCanLock = canLockState + + if (direction != lastReportedDirection) { + lastReportedDirection = direction + currentOnDirectionChanged(direction) + } + } + ) + } + ) +} + +@Composable +fun HalfScreenJoystickVisual( + state: HalfScreenJoystickState, + modifier: Modifier = Modifier, + style: ObservableJoystickStyle, + size: Dp +) { + val density = LocalDensity.current + val joystickSizePx = with(density) { size.toPx() } + + if (state.isVisible) { + StatelessStyleableJoystick( + modifier = modifier.absoluteOffset { + IntOffset( + x = (state.center.x - joystickSizePx / 2).roundToInt(), + y = (state.center.y - joystickSizePx / 2).roundToInt() + ) + }, + style = style, + size = size, + joystickOffset = { state.joystickOffset }, + isLocked = state.isLocked, + internalCanLock = state.internalCanLock + ) + } +} + +private suspend fun PointerInputScope.handleJoystickTouch( + screenSize: IntSize, + joystickSizePx: Float, + deadZoneRatio: Float, + canLock: Boolean, + isLeftHalf: Boolean, + offsetX: Int, + isOccupiedPointer: (PointerId) -> Boolean, + onOccupiedPointer: (PointerId) -> Unit, + onReleasePointer: (PointerId) -> Unit, + onUpdateState: (Boolean, Offset, Offset, Boolean, Boolean, JoystickDirection) -> Unit +) { + val centerPoint = Offset(joystickSizePx / 2, joystickSizePx / 2) + val deadZoneRadius = joystickSizePx * deadZoneRatio / 2 + val lockThresholdPx = joystickSizePx * 0.3f + val lockPositionOffset = Offset(0f, -joystickSizePx / 2) + + coroutineScope { + awaitPointerEventScope { + var activePointerId: PointerId? = null + var center = Offset.Zero + var currentLocked = false + var canLockTriggered = false + + while (true) { + val event = awaitPointerEvent() + + if (activePointerId == null) { + val downChange = event.changes.find { + it.changedToDown() && + it.type == PointerType.Touch && + !it.isConsumed + } + + if (downChange != null) { + val localPos = downChange.position + val actualX = localPos.x + offsetX + + if (actualX >= 0 && actualX < screenSize.width && !isOccupiedPointer(downChange.id)) { + activePointerId = downChange.id + + val halfSize = joystickSizePx / 2 + center = Offset( + actualX.coerceIn(halfSize, screenSize.width - halfSize), + localPos.y.coerceIn(halfSize, screenSize.height - halfSize) + ) + + currentLocked = false + canLockTriggered = false + onOccupiedPointer(activePointerId!!) + + onUpdateState(true, center, Offset.Zero, false, false, JoystickDirection.None) + downChange.consume() + } + } + } else { + val change = event.changes.find { it.id == activePointerId } + + if (change != null) { + if (change.changedToUpIgnoreConsumed()) { + if (canLockTriggered) { + currentLocked = true + val direction = JoystickDirection.North + onUpdateState(true, center, lockPositionOffset, true, false, direction) + } else { + currentLocked = false + onUpdateState(false, center, Offset.Zero, false, false, JoystickDirection.None) + } + + onReleasePointer(activePointerId!!) + activePointerId = null + canLockTriggered = false + change.consume() + } else if (change.pressed && change.positionChanged()) { + val localPos = change.position + val actualX = localPos.x + offsetX + val actualPos = Offset(actualX, localPos.y) + val relativeOffset = actualPos - center + + if (currentLocked) currentLocked = false + + val clampedPos = updateJoystickPosition( + newPosition = relativeOffset + centerPoint, + minX = 0f, + maxX = joystickSizePx, + minY = 0f, + maxY = joystickSizePx + ) + + val finalOffset = clampedPos - centerPoint + val direction = calculateDirection(clampedPos, centerPoint, deadZoneRadius) + + canLockTriggered = canLock && + direction == JoystickDirection.North && + relativeOffset.y < -lockThresholdPx - (joystickSizePx / 2) + + onUpdateState(true, center, finalOffset, currentLocked, canLockTriggered, direction) + change.consume() + } + } + + if (!event.changes.any { it.pressed }) { + if (activePointerId != null) { + onUpdateState(false, center, Offset.Zero, false, false, JoystickDirection.None) + onReleasePointer(activePointerId!!) + activePointerId = null + } + } + } + } + } + } +} diff --git a/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/control/joystick/HalfScreenJoystickMode.kt b/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/control/joystick/HalfScreenJoystickMode.kt new file mode 100644 index 000000000..91b695887 --- /dev/null +++ b/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/control/joystick/HalfScreenJoystickMode.kt @@ -0,0 +1,36 @@ +/* + * Zalith Launcher 2 + * Copyright (C) 2025 MovTery and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.movtery.zalithlauncher.ui.control.joystick + +import com.movtery.zalithlauncher.R + +enum class HalfScreenJoystickMode(val titleRes: Int, val summaryRes: Int) { + Disabled( + titleRes = R.string.settings_control_joystick_half_screen_disabled, + summaryRes = R.string.settings_control_joystick_half_screen_disabled_summary + ), + LeftHalf( + titleRes = R.string.settings_control_joystick_left_half_screen_title, + summaryRes = R.string.settings_control_joystick_left_half_screen_summary + ), + RightHalf( + titleRes = R.string.settings_control_joystick_right_half_screen_title, + summaryRes = R.string.settings_control_joystick_right_half_screen_summary + ) +} diff --git a/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/control/joystick/JoystickControl.kt b/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/control/joystick/JoystickControl.kt index 1e1d50566..5081ab6fa 100644 --- a/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/control/joystick/JoystickControl.kt +++ b/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/control/joystick/JoystickControl.kt @@ -174,6 +174,139 @@ fun StyleableJoystick( ) } +/** + * 无状态的摇杆样式组件,由外部提供摇杆偏移位置 + */ +@Composable +fun StatelessStyleableJoystick( + modifier: Modifier = Modifier, + isDarkTheme: Boolean = isLauncherInDarkTheme(), + style: ObservableJoystickStyle, + size: Dp = 120.dp, + joystickOffset: () -> Offset, // 摇杆相对于中心的偏移 + isLocked: Boolean = false, + internalCanLock: Boolean = false +) { + val theme = if (isDarkTheme) style.darkStyle else style.lightStyle + + val backgroundShape = remember(theme.backgroundShape) { + RoundedCornerShape(percent = theme.backgroundShape) + } + + val joystickShape = remember(theme.joystickShape) { + RoundedCornerShape(percent = theme.joystickShape) + } + + val borderWidthRatio = remember(theme.borderWidthRatio) { + (theme.borderWidthRatio.toFloat() / 100f).coerceIn(0.0f, 0.5f) + } + + StatelessJoystick( + modifier = modifier, + alpha = theme.alpha, + backgroundColor = theme.backgroundColor, + joystickColor = theme.joystickColor, + joystickCanLockColor = theme.joystickCanLockColor, + joystickLockedColor = theme.joystickLockedColor, + lockMarkColor = theme.lockMarkColor, + borderColor = theme.borderColor, + borderWidthRatio = borderWidthRatio, + backgroundShape = backgroundShape, + joystickShape = joystickShape, + size = size, + joystickSize = theme.joystickSize, + joystickOffset = joystickOffset, + isLocked = isLocked, + internalCanLock = internalCanLock + ) +} + +/** + * 无状态移动摇杆控件 + */ +@Composable +fun StatelessJoystick( + modifier: Modifier = Modifier, + @FloatRange(from = 0.0, to = 1.0) + alpha: Float = 1.0f, + backgroundColor: Color = Color.Black.copy(alpha = 0.5f), + joystickColor: Color = Color.White.copy(alpha = 0.5f), + joystickCanLockColor: Color = Color.Yellow.copy(alpha = 0.5f), + joystickLockedColor: Color = Color.Green.copy(alpha = 0.5f), + lockMarkColor: Color = Color.White, + borderColor: Color = Color.White, + @FloatRange(from = 0.0, to = 0.5) + borderWidthRatio: Float = 0f, + backgroundShape: Shape = CircleShape, + joystickShape: Shape = CircleShape, + size: Dp = 120.dp, + @FloatRange(from = 0.0, to = 1.0) + joystickSize: Float = 0.5f, + joystickOffset: () -> Offset, + isLocked: Boolean = false, + internalCanLock: Boolean = false +) { + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + + // 颜色处理 + val currentBackgroundColor = remember(backgroundColor, alpha) { backgroundColor.applyAlpha(alpha) } + val currentJoystickColor = remember(joystickColor, alpha) { joystickColor.applyAlpha(alpha) } + val currentJoystickCanLockColor = remember(joystickCanLockColor, alpha) { joystickCanLockColor.applyAlpha(alpha) } + val currentJoystickLockedColor = remember(joystickLockedColor, alpha) { joystickLockedColor.applyAlpha(alpha) } + val currentLockMarkColor = remember(lockMarkColor, alpha) { lockMarkColor.applyAlpha(alpha) } + val currentBorderColor = remember(borderColor, alpha) { borderColor.applyAlpha(alpha) } + + // 计算尺寸 + val backgroundSizePx = with(density) { size.toPx() } + val joystickSizePx = backgroundSizePx * joystickSize.coerceIn(0.0f, 1.0f) + val centerPoint = Offset(backgroundSizePx / 2, backgroundSizePx / 2) + val lockPosition = Offset(centerPoint.x, 0f) + + // 计算摇杆当前的绝对位置供 Canvas 绘制 + // 延迟到 Draw 阶段计算,避免重组 + // val joystickPosition = centerPoint + joystickOffset + + Canvas( + modifier = modifier + .size(size) + ) { + val minSide = minOf(this@Canvas.size.width, this@Canvas.size.height) + + // 背景层 + drawBackgroundLayer( + layoutDirection = layoutDirection, + size = this@Canvas.size, + shape = backgroundShape, + backgroundColor = currentBackgroundColor, + borderColor = currentBorderColor, + borderWidthPx = (minSide * borderWidthRatio).coerceAtLeast(0f) + ) + + // 摇杆层 + drawJoystick( + layoutDirection = layoutDirection, + color = when { + isLocked -> currentJoystickLockedColor + internalCanLock -> currentJoystickCanLockColor + else -> currentJoystickColor + }, + center = centerPoint + joystickOffset(), + size = joystickSizePx, + shape = joystickShape + ) + + // 锁定标记 + if (isLocked) { + drawCircle( + color = currentLockMarkColor, + center = lockPosition, + radius = 4f + ) + } + } +} + /** * 移动摇杆控件 * @param alpha 整体不透明度 @@ -534,7 +667,7 @@ private fun DrawScope.drawJoystick( } } -private fun updateJoystickPosition( +fun updateJoystickPosition( newPosition: Offset, minX: Float, maxX: Float, @@ -551,7 +684,7 @@ private fun updateJoystickPosition( /** * 计算摇杆方向 */ -private fun calculateDirection( +fun calculateDirection( joystickPosition: Offset, backgroundCenter: Offset, deadZoneRadius: Float diff --git a/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/screens/game/GameScreen.kt b/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/screens/game/GameScreen.kt index 51e0d6b8a..b62b30f19 100644 --- a/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/screens/game/GameScreen.kt +++ b/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/screens/game/GameScreen.kt @@ -28,12 +28,15 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -46,10 +49,12 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -102,9 +107,14 @@ import com.movtery.zalithlauncher.ui.control.gyroscope.GyroscopeReader import com.movtery.zalithlauncher.ui.control.gyroscope.isGyroscopeAvailable import com.movtery.zalithlauncher.ui.control.hotbarPercentage import com.movtery.zalithlauncher.ui.control.input.TextInputMode +import com.movtery.zalithlauncher.ui.control.joystick.HalfScreenJoystickInput +import com.movtery.zalithlauncher.ui.control.joystick.HalfScreenJoystickMode +import com.movtery.zalithlauncher.ui.control.joystick.HalfScreenJoystickState +import com.movtery.zalithlauncher.ui.control.joystick.HalfScreenJoystickVisual import com.movtery.zalithlauncher.ui.control.joystick.JoystickDirectionListener import com.movtery.zalithlauncher.ui.control.joystick.StyleableJoystick import com.movtery.zalithlauncher.ui.control.joystick.loadJoystickStyle +import com.movtery.zalithlauncher.ui.control.joystick.rememberHalfScreenJoystickState import com.movtery.zalithlauncher.ui.control.joystick.saveJoystickStyle import com.movtery.zalithlauncher.ui.control.mouse.SwitchableMouseLayout import com.movtery.zalithlauncher.ui.screens.game.elements.DraggableGameBall @@ -494,6 +504,7 @@ fun GameScreen( val viewModel = rememberGameViewModel(version) { mode -> eventViewModel.sendEvent(EventViewModel.Event.Game.SwitchIme(mode)) } + val joystickState = rememberHalfScreenJoystickState() val editorViewModel = rememberEditorViewModel("ControlEditor_Times=${viewModel.editorRefresh}") val isGrabbing = remember(ZLBridgeStates.cursorMode) { ZLBridgeStates.cursorMode == CURSOR_DISABLED @@ -616,6 +627,31 @@ fun GameScreen( onTouch = { viewModel.switchControlLayer(HideLayerWhen.None) }, gamepadViewModel = gamepadViewModel.takeIf { AllSettings.gamepadControl.state } ) + + //摇杆控制层 - 输入层 (位于按键之下) + //仅在左半屏移动开启时启用 + viewModel.observableLayout?.let { layout -> + val special by layout.special.collectAsStateWithLifecycle() + JoystickControlLayout( + layerType = JoystickLayerType.Input, + joystickState = joystickState, + screenSize = screenSize, + isGrabbing = isGrabbing, + special = special, + defaultStyle = viewModel.launcherJoystickStyle, + hideLayerWhen = viewModel.controlLayerHideState, + viewModel = joystickMovementViewModel, + checkOccupiedPointers = { viewModel.occupiedPointers.contains(it) }, + onOccupiedPointer = { viewModel.occupiedPointers.add(it) }, + onReleasePointer = { + viewModel.occupiedPointers.remove(it) + viewModel.moveOnlyPointers.remove(it) + }, + onKeyEvent = { event, pressed -> + viewModel.onKeyEvent(event, pressed) + } + ) + } } //物品栏触发层 @@ -633,20 +669,27 @@ fun GameScreen( onReleasePointer = { viewModel.occupiedPointers.remove(it) } ) - //摇杆控制层 + + //摇杆控制层 - 视觉层 (位于按键之上) viewModel.observableLayout?.let { layout -> val special by layout.special.collectAsStateWithLifecycle() - JoystickControlLayout( - screenSize = screenSize, - isGrabbing = isGrabbing, - special = special, - defaultStyle = viewModel.launcherJoystickStyle, - hideLayerWhen = viewModel.controlLayerHideState, - viewModel = joystickMovementViewModel, - onKeyEvent = { event, pressed -> - viewModel.onKeyEvent(event, pressed) + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Box(Modifier.fillMaxSize()) { + JoystickControlLayout( + layerType = JoystickLayerType.Visual, + joystickState = joystickState, + screenSize = screenSize, + isGrabbing = isGrabbing, + special = special, + defaultStyle = viewModel.launcherJoystickStyle, + hideLayerWhen = viewModel.controlLayerHideState, + viewModel = joystickMovementViewModel, + onKeyEvent = { event, pressed -> + viewModel.onKeyEvent(event, pressed) + } + ) } - ) + } } if (AllSettings.gamepadControl.state) { @@ -961,14 +1004,27 @@ private fun Float.sumPosition(): Float { * @param viewModel 摇杆移动监听 ViewModel * @param onKeyEvent 由 */ +private enum class JoystickLayerType { + Input, Visual +} + +/** + * 摇杆控制层 + * 支持 Input/Visual 分离渲染以解决图层优先级问题 + */ @Composable private fun JoystickControlLayout( + layerType: JoystickLayerType, + joystickState: HalfScreenJoystickState, isGrabbing: Boolean, screenSize: IntSize, special: ObservableSpecial, defaultStyle: ObservableJoystickStyle, hideLayerWhen: HideLayerWhen, viewModel: JoystickMovementViewModel, + checkOccupiedPointers: (PointerId) -> Boolean = { false }, + onOccupiedPointer: (PointerId) -> Unit = {}, + onReleasePointer: (PointerId) -> Unit = {}, onKeyEvent: (ClickEvent, pressed: Boolean) -> Unit ) { val joystickStyle by special.joystickStyle.collectAsStateWithLifecycle() @@ -986,60 +1042,125 @@ private fun JoystickControlLayout( ((isGrabbing && !hideState) || viewModel.operation == JoystickManageOperation.Manage) && AllSettings.enableJoystickControl.state ) { - val size = AllSettings.joystickControlSize.state.dp - val x = AllSettings.joystickControlX.state - val y = AllSettings.joystickControlY.state - - val position = remember(screenSize, size, x, y) { - val widgetSize = with(density) { - val pixelSize = size.roundToPx() - IntSize( - width = pixelSize, - height = pixelSize + val halfScreenMode = AllSettings.joystickHalfScreenMode.state + val useHalfScreenMode = halfScreenMode != HalfScreenJoystickMode.Disabled && + viewModel.operation != JoystickManageOperation.Manage + + if (useHalfScreenMode) { + val isLeftHalf = halfScreenMode == HalfScreenJoystickMode.LeftHalf + val size = AllSettings.joystickControlSize.state.dp + val offsetX = if (isLeftHalf) 0 else screenSize.width / 2 + + if (layerType == JoystickLayerType.Input) { + val halfScreenWidth = if (isLeftHalf) { + Modifier.fillMaxHeight().fillMaxWidth(0.5f) + } else { + Modifier.fillMaxHeight().fillMaxWidth(0.5f) + .absoluteOffset { IntOffset(x = screenSize.width / 2, y = 0) } + } + + HalfScreenJoystickInput( + state = joystickState, + modifier = halfScreenWidth, + screenSize = screenSize, + size = size, + deadZoneRatio = AllSettings.joystickDeadZoneRatio.state / 100f, + canLock = AllSettings.joystickControlCanLock.state, + isLeftHalf = isLeftHalf, + offsetX = offsetX, + isOccupiedPointer = checkOccupiedPointers, + onOccupiedPointer = onOccupiedPointer, + onReleasePointer = onReleasePointer, + onDirectionChanged = { direction -> + viewModel.onListen(direction) + }, + onLock = { lock -> + if (AllSettings.joystickControlLockSpring.state) { + mapToControlEvent(SPRING, SPRING_VALUE)?.let { key -> + val event = ClickEvent( + type = ClickEvent.Type.Key, + key = key + ) + if (lock) { + onKeyEvent(event, true) + } else { + onKeyEvent(event, false) + } + } + } + } ) - } - - widgetPosition( - xPercentage = x / 10000f, - yPercentage = y / 10000f, - widgetSize = widgetSize, - screenSize = screenSize - ) - } - - StyleableJoystick( - modifier = Modifier - .absoluteOffset { - IntOffset(x = position.x.toInt(), y = position.y.toInt()) - }, - style = if (AllSettings.joystickUseStyleByLayout.state) { - //启用后,优先使用控制布局提供的样式 - joystickStyle ?: defaultStyle } else { - defaultStyle - }, - size = size, - onDirectionChanged = { direction -> - viewModel.onListen(direction) - }, - deadZoneRatio = AllSettings.joystickDeadZoneRatio.state / 100f, - canLock = AllSettings.joystickControlCanLock.state, - onLock = { lock -> - if (AllSettings.joystickControlLockSpring.state) { - mapToControlEvent(SPRING, SPRING_VALUE)?.let { key -> - val event = ClickEvent( - type = ClickEvent.Type.Key, - key = key + HalfScreenJoystickVisual( + state = joystickState, + style = if (AllSettings.joystickUseStyleByLayout.state) { + joystickStyle ?: defaultStyle + } else { + defaultStyle + }, + size = size + ) + } + } else { + // 固定位置摇杆模式(原有逻辑) + // 固定摇杆仅在Visual层渲染 + if (layerType == JoystickLayerType.Visual) { + val size = AllSettings.joystickControlSize.state.dp + val x = AllSettings.joystickControlX.state + val y = AllSettings.joystickControlY.state + + val position = remember(screenSize, size, x, y) { + val widgetSize = with(density) { + val pixelSize = size.roundToPx() + IntSize( + width = pixelSize, + height = pixelSize ) - if (lock) { - onKeyEvent(event, true) - } else { - onKeyEvent(event, false) - } } + + widgetPosition( + xPercentage = x / 10000f, + yPercentage = y / 10000f, + widgetSize = widgetSize, + screenSize = screenSize + ) } + + StyleableJoystick( + modifier = Modifier + .absoluteOffset { + IntOffset(x = position.x.toInt(), y = position.y.toInt()) + }, + style = if (AllSettings.joystickUseStyleByLayout.state) { + //启用后,优先使用控制布局提供的样式 + joystickStyle ?: defaultStyle + } else { + defaultStyle + }, + size = size, + onDirectionChanged = { direction -> + viewModel.onListen(direction) + }, + deadZoneRatio = AllSettings.joystickDeadZoneRatio.state / 100f, + canLock = AllSettings.joystickControlCanLock.state, + onLock = { lock -> + if (AllSettings.joystickControlLockSpring.state) { + mapToControlEvent(SPRING, SPRING_VALUE)?.let { key -> + val event = ClickEvent( + type = ClickEvent.Type.Key, + key = key + ) + if (lock) { + onKeyEvent(event, true) + } else { + onKeyEvent(event, false) + } + } + } + } + ) } - ) + } } JoystickDirectionListener( diff --git a/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/screens/game/elements/JoystickManageOperationUI.kt b/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/screens/game/elements/JoystickManageOperationUI.kt index 062ced52b..0f8f8a507 100644 --- a/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/screens/game/elements/JoystickManageOperationUI.kt +++ b/ZalithLauncher/src/main/java/com/movtery/zalithlauncher/ui/screens/game/elements/JoystickManageOperationUI.kt @@ -54,7 +54,9 @@ import com.movtery.zalithlauncher.setting.AllSettings import com.movtery.zalithlauncher.setting.unit.floatRange import com.movtery.zalithlauncher.ui.components.MarqueeText import com.movtery.zalithlauncher.ui.components.fadeEdge +import com.movtery.zalithlauncher.ui.control.joystick.HalfScreenJoystickMode import com.movtery.zalithlauncher.ui.screens.content.elements.DisabledAlpha +import com.movtery.zalithlauncher.ui.screens.main.control_editor.InfoLayoutListItem import com.movtery.zalithlauncher.ui.screens.main.control_editor.InfoLayoutSliderItem import com.movtery.zalithlauncher.ui.screens.main.control_editor.InfoLayoutSwitchItem import com.movtery.zalithlauncher.ui.screens.main.control_editor.InfoLayoutTextItem @@ -184,6 +186,16 @@ private fun JoystickManageDialog( onValueChange = { AllSettings.enableJoystickControl.save(it) } ) + //半屏摇杆模式 + InfoLayoutListItem( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.settings_control_joystick_half_screen_mode), + items = HalfScreenJoystickMode.entries, + selectedItem = AllSettings.joystickHalfScreenMode.state, + onItemSelected = { AllSettings.joystickHalfScreenMode.save(it) }, + getItemText = { stringResource(it.titleRes) } + ) + //x InfoLayoutSliderItem( modifier = Modifier.fillMaxWidth(), diff --git a/ZalithLauncher/src/main/res/values-zh-rCN/strings.xml b/ZalithLauncher/src/main/res/values-zh-rCN/strings.xml index bd9aa31fd..53eccf142 100644 --- a/ZalithLauncher/src/main/res/values-zh-rCN/strings.xml +++ b/ZalithLauncher/src/main/res/values-zh-rCN/strings.xml @@ -835,6 +835,13 @@ 启用后,左右转动设备会反向控制视角 反转 Y 轴 启用后,上下转动设备会反向控制视角 + 半屏摇杆模式 + 禁用 + 使用固定位置摇杆 + 左半屏 + 手指在左半屏非控制按键位置按下时,以该位置为中心出现摇杆 + 右半屏 + 手指在右半屏非控制按键位置按下时,以该位置为中心出现摇杆 手柄控制 启用或关闭手柄控制功能。若您的其他外设被误识别为手柄,请关闭此选项 手柄死区缩放 diff --git a/ZalithLauncher/src/main/res/values-zh-rTW/strings.xml b/ZalithLauncher/src/main/res/values-zh-rTW/strings.xml index 61a7f9ab8..eadeb0692 100644 --- a/ZalithLauncher/src/main/res/values-zh-rTW/strings.xml +++ b/ZalithLauncher/src/main/res/values-zh-rTW/strings.xml @@ -771,6 +771,8 @@ 啟用後,左右轉動裝置會反向控制視角 反轉 Y 軸 啟用後,上下轉動裝置會反向控制視角 + 啟用左半屏移動 + 手指在左半屏非控制按鍵位置按下時,以該位置為中心出現搖桿 手把控制 啟用或關閉手把控制功能。若您的其他外設被誤識別為手把,請關閉此選項。 手把死區縮放 diff --git a/ZalithLauncher/src/main/res/values/strings.xml b/ZalithLauncher/src/main/res/values/strings.xml index 6e5fcd463..dd8dac673 100644 --- a/ZalithLauncher/src/main/res/values/strings.xml +++ b/ZalithLauncher/src/main/res/values/strings.xml @@ -1075,6 +1075,14 @@ Failed to check for updates. Please try again later. You are already on the latest version Cloud Drive + + Half-Screen Joystick Mode + Disabled + Use fixed position joystick + Left Half-Screen + Joystick appears at touch position when pressing on the left half of the screen (excluding control buttons) + Right Half-Screen + Joystick appears at touch position when pressing on the right half of the screen (excluding control buttons) https://minecraft.wiki/w/Java_Edition_%s https://minecraft.wiki/w/Java_Edition_%s