Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -510,6 +511,14 @@ object AllSettings : SettingsRegistry() {
*/
val joystickControlLockSpring = boolSetting("joystickControlLockSpring", true)

/**
* 半屏摇杆模式
* 禁用:使用固定位置摇杆
* 左半屏:手指在左半屏非控制按键位置按下时,以该位置为中心出现摇杆
* 右半屏:手指在右半屏非控制按键位置按下时,以该位置为中心出现摇杆
*/
val joystickHalfScreenMode = enumSetting("joystickHalfScreenMode", HalfScreenJoystickMode.Disabled)

/**
* 上次检查更新的时间戳
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
/*
* Zalith Launcher 2
* Copyright (C) 2025 MovTery <[email protected]> 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 <https://www.gnu.org/licenses/gpl-3.0.txt>.
*/

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
}
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Zalith Launcher 2
* Copyright (C) 2025 MovTery <[email protected]> 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 <https://www.gnu.org/licenses/gpl-3.0.txt>.
*/

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
)
}
Loading