diff --git a/app/src/main/java/app/gamenative/inputcontrols/ControlProfileService.kt b/app/src/main/java/app/gamenative/inputcontrols/ControlProfileService.kt index 6597e33c53..1c445b14eb 100644 --- a/app/src/main/java/app/gamenative/inputcontrols/ControlProfileService.kt +++ b/app/src/main/java/app/gamenative/inputcontrols/ControlProfileService.kt @@ -60,6 +60,16 @@ object ControlProfileService { private const val KEY_LIBRARY_PROFILE_ID = "libraryProfileId" private const val KEY_GAME_OWNER_ID = "gameOwnerId" private const val KEY_SECTION_SOURCES = "sectionSources" + private val STICK_TUNING_KEYS = arrayOf( + ControlsProfile.KEY_LEFT_STICK_DEADZONE, + ControlsProfile.KEY_RIGHT_STICK_DEADZONE, + ControlsProfile.KEY_LEFT_STICK_SENSITIVITY, + ControlsProfile.KEY_RIGHT_STICK_SENSITIVITY, + ControlsProfile.KEY_LEFT_STICK_DEADZONE_MODE, + ControlsProfile.KEY_RIGHT_STICK_DEADZONE_MODE, + ControlsProfile.KEY_LEFT_STICK_DIGITAL_MODE, + ControlsProfile.KEY_RIGHT_STICK_DIGITAL_MODE, + ) fun preview(context: Context, profile: ControlsProfile): ControlProfilePreview = preview(readProfileJson(context, profile)) @@ -100,7 +110,7 @@ object ControlProfileService { // arrays are included only when present. return buildSet { add(ControlProfileSection.ON_SCREEN) - if (json.has("controllers")) add(ControlProfileSection.PHYSICAL_CONTROLLER) + if (json.has("controllers") || hasStickTuning(json)) add(ControlProfileSection.PHYSICAL_CONTROLLER) if (json.has("radialMenus")) add(ControlProfileSection.RADIAL_MENU) if (json.has(KEY_GYRO_SETTINGS)) add(ControlProfileSection.GYRO) if (json.has(KEY_TOUCHSCREEN_SETTINGS)) add(ControlProfileSection.TOUCHSCREEN) @@ -605,6 +615,10 @@ object ControlProfileService { } if (ControlProfileSection.PHYSICAL_CONTROLLER in sections) { destination.put("controllers", deepCopyArray(source.optJSONArray("controllers"))) + STICK_TUNING_KEYS.forEach { key -> + if (source.has(key)) destination.put(key, source.opt(key)) + else destination.remove(key) + } } if (ControlProfileSection.RADIAL_MENU in sections) { destination.put("radialMenus", deepCopyArray(source.optJSONArray("radialMenus"))) @@ -622,7 +636,7 @@ object ControlProfileService { private fun allStoredSections(json: JSONObject): Set = buildSet { if (json.has("elements")) add(ControlProfileSection.ON_SCREEN) - if (json.has("controllers")) add(ControlProfileSection.PHYSICAL_CONTROLLER) + if (json.has("controllers") || hasStickTuning(json)) add(ControlProfileSection.PHYSICAL_CONTROLLER) if (json.has("radialMenus")) add(ControlProfileSection.RADIAL_MENU) if (json.has(KEY_GYRO_SETTINGS)) add(ControlProfileSection.GYRO) if (json.has(KEY_TOUCHSCREEN_SETTINGS)) add(ControlProfileSection.TOUCHSCREEN) @@ -696,14 +710,17 @@ object ControlProfileService { val sections = sectionsOf(json) if (sections.isEmpty()) throw IllegalArgumentException("Profile contains no settings") requireArrayPayload(json, sections, ControlProfileSection.ON_SCREEN, "elements") - requireArrayPayload(json, sections, ControlProfileSection.PHYSICAL_CONTROLLER, "controllers") requireArrayPayload(json, sections, ControlProfileSection.RADIAL_MENU, "radialMenus") requireObjectPayload(json, sections, ControlProfileSection.GYRO, KEY_GYRO_SETTINGS) requireObjectPayload(json, sections, ControlProfileSection.TOUCHSCREEN, KEY_TOUCHSCREEN_SETTINGS) requireObjectPayload(json, sections, ControlProfileSection.SHOOTER, KEY_SHOOTER_SETTINGS) if (ControlProfileSection.ON_SCREEN in sections) validateElements(json) - if (ControlProfileSection.PHYSICAL_CONTROLLER in sections) validateControllers(json) + if (ControlProfileSection.PHYSICAL_CONTROLLER in sections) { + if (json.has("controllers")) validateControllers(json) + else if (!hasStickTuning(json)) throw IllegalArgumentException("Profile has invalid physical controller settings") + validateStickTuning(json) + } if (ControlProfileSection.RADIAL_MENU in sections) validateRadialMenus(json) if (ControlProfileSection.GYRO in sections) { validatePrimitiveSettings(requireNotNull(json.optJSONObject(KEY_GYRO_SETTINGS)), "gyro") @@ -788,6 +805,48 @@ object ControlProfileService { } } + private fun validateStickTuning(json: JSONObject) { + optionalFiniteNumber( + json, + ControlsProfile.KEY_LEFT_STICK_DEADZONE, + ControlsProfile.MIN_STICK_DEADZONE.toDouble(), + ControlsProfile.MAX_STICK_DEADZONE.toDouble(), + ) + optionalFiniteNumber( + json, + ControlsProfile.KEY_RIGHT_STICK_DEADZONE, + ControlsProfile.MIN_STICK_DEADZONE.toDouble(), + ControlsProfile.MAX_STICK_DEADZONE.toDouble(), + ) + optionalFiniteNumber( + json, + ControlsProfile.KEY_LEFT_STICK_SENSITIVITY, + ControlsProfile.MIN_STICK_SENSITIVITY.toDouble(), + ControlsProfile.MAX_STICK_SENSITIVITY.toDouble(), + ) + optionalFiniteNumber( + json, + ControlsProfile.KEY_RIGHT_STICK_SENSITIVITY, + ControlsProfile.MIN_STICK_SENSITIVITY.toDouble(), + ControlsProfile.MAX_STICK_SENSITIVITY.toDouble(), + ) + + val deadzoneModes = ControlsProfile.StickDeadzoneMode.values().mapTo(mutableSetOf()) { it.jsonName } + val digitalModes = ControlsProfile.StickDigitalMode.values().mapTo(mutableSetOf()) { it.jsonName } + validateOptionalEnum(json, ControlsProfile.KEY_LEFT_STICK_DEADZONE_MODE, deadzoneModes) + validateOptionalEnum(json, ControlsProfile.KEY_RIGHT_STICK_DEADZONE_MODE, deadzoneModes) + validateOptionalEnum(json, ControlsProfile.KEY_LEFT_STICK_DIGITAL_MODE, digitalModes) + validateOptionalEnum(json, ControlsProfile.KEY_RIGHT_STICK_DIGITAL_MODE, digitalModes) + } + + private fun validateOptionalEnum(json: JSONObject, key: String, allowed: Set) { + json.optStringOrNull(key)?.let { value -> + if (value !in allowed) throw IllegalArgumentException("Profile has an unsupported $key value: $value") + } + } + + private fun hasStickTuning(json: JSONObject): Boolean = STICK_TUNING_KEYS.any(json::has) + private fun validateRadialMenus(json: JSONObject) { val menus = requireNotNull(json.optJSONArray("radialMenus")) if (menus.length() > 16) throw IllegalArgumentException("Profile contains too many radial menus") diff --git a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt index bb9db3f976..b7094fe281 100644 --- a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt +++ b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt @@ -131,6 +131,7 @@ object QuickMenuAction { const val GYRO = 11 const val CONTROL_PROFILES = 12 const val CONTROL_PROFILE_APPLIED = 13 + const val PHYSICAL_CONTROLLER_SETTINGS = 14 } private object QuickMenuTab { @@ -1121,21 +1122,27 @@ fun QuickMenu( }, focusRequester = if (index == 0) controllerItemFocusRequester else null, onFocused = { lastControllerFocusRequester = it }, - secondaryIcon = if (item.id == QuickMenuAction.TOUCHSCREEN_MODE && touchscreenEnabled) + secondaryIcon = if (item.id == QuickMenuAction.EDIT_PHYSICAL_CONTROLLER) + Icons.Default.Settings + else if (item.id == QuickMenuAction.TOUCHSCREEN_MODE && touchscreenEnabled) Icons.Default.Settings else if (item.id == QuickMenuAction.SHOOTER_MODE && shooterEnabled) Icons.Default.Settings else if (item.id == QuickMenuAction.GYRO && gyroEnabled) Icons.Default.Settings else null, - secondaryContentDescriptionResId = if (item.id == QuickMenuAction.TOUCHSCREEN_MODE && touchscreenEnabled) + secondaryContentDescriptionResId = if (item.id == QuickMenuAction.EDIT_PHYSICAL_CONTROLLER) + R.string.physical_controller_settings_title + else if (item.id == QuickMenuAction.TOUCHSCREEN_MODE && touchscreenEnabled) R.string.gesture_settings_title else if (item.id == QuickMenuAction.SHOOTER_MODE && shooterEnabled) R.string.shooter_mode_settings_title else if (item.id == QuickMenuAction.GYRO && gyroEnabled) R.string.gyro_settings_title else null, - onSecondaryClick = if (item.id == QuickMenuAction.TOUCHSCREEN_MODE && touchscreenEnabled) + onSecondaryClick = if (item.id == QuickMenuAction.EDIT_PHYSICAL_CONTROLLER) + ({ onItemSelected(QuickMenuAction.PHYSICAL_CONTROLLER_SETTINGS) }) + else if (item.id == QuickMenuAction.TOUCHSCREEN_MODE && touchscreenEnabled) onTouchGestureSettingsClick else if (item.id == QuickMenuAction.SHOOTER_MODE && shooterEnabled) onShooterModeSettingsClick diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/PhysicalControllerConfigSection.kt b/app/src/main/java/app/gamenative/ui/component/dialog/PhysicalControllerConfigSection.kt index d4e633a812..206bacd107 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/PhysicalControllerConfigSection.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/PhysicalControllerConfigSection.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Save @@ -22,10 +23,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import app.gamenative.R +import app.gamenative.ui.theme.PluviaBackground import com.winlator.inputcontrols.Binding import com.winlator.inputcontrols.BindingCombo import com.winlator.inputcontrols.ControlsProfile import com.winlator.inputcontrols.ExternalControllerBinding +import java.util.Locale +import kotlinx.coroutines.launch /** * Data classes for controller configuration @@ -117,7 +121,8 @@ internal fun PhysicalControllerConfigSection( } } - var selectedCategory by remember { mutableStateOf(0) } // 0 = Face, 1 = Shoulder, 2 = Menu, 3 = Thumbstick, 4 = Left Stick, 5 = Right Stick, 6 = D-Pad + // 0 = Face, 1 = Shoulder, 2 = Menu, 3 = Thumbstick, 4 = Left Stick, 5 = Right Stick, 6 = D-Pad + var selectedCategory by remember { mutableStateOf(0) } var showBindingDialog by remember { mutableStateOf?>(null) } var refreshKey by remember { mutableIntStateOf(0) } @@ -259,9 +264,10 @@ internal fun PhysicalControllerConfigSection( } // Save button - IconButton(onClick = { + IconButton(onClick = saveBindings@{ Log.d("gncontrol", "=== Save: Applying ${workingBindings.size} bindings ===") - controller?.let { ctrl -> + + val saved = controller?.let { ctrl -> val existingBindings = ctrl.getControllerBindings().toList() for (binding in existingBindings) { ctrl.removeControllerBinding(binding) @@ -283,8 +289,12 @@ internal fun PhysicalControllerConfigSection( } profile.save() - Log.d("gncontrol", "Saved profile ${profile.name}") + } ?: false + if (!saved) { + Log.e("gncontrol", "Failed to save profile ${profile.name}") + return@saveBindings } + Log.d("gncontrol", "Saved profile ${profile.name}") onSave() }) { Icon(Icons.Default.Save, null) @@ -369,6 +379,7 @@ internal fun PhysicalControllerConfigSection( isSelected = selectedCategory == 6, onClick = { selectedCategory = 6 } ) + } } @@ -611,6 +622,308 @@ private fun ControllerBindingItem( } } +private data class StickTuningState( + val deadzone: Float, + val sensitivity: Float, + val deadzoneMode: ControlsProfile.StickDeadzoneMode, + val directionMode: ControlsProfile.StickDigitalMode, +) + +private data class PhysicalStickTuningState( + val left: StickTuningState, + val right: StickTuningState, +) { + fun applyTo(profile: ControlsProfile) { + profile.leftStickDeadzone = left.deadzone + profile.leftStickSensitivity = left.sensitivity + profile.leftStickDeadzoneMode = left.deadzoneMode + profile.leftStickDigitalMode = left.directionMode + profile.rightStickDeadzone = right.deadzone + profile.rightStickSensitivity = right.sensitivity + profile.rightStickDeadzoneMode = right.deadzoneMode + profile.rightStickDigitalMode = right.directionMode + } + + companion object { + fun from(profile: ControlsProfile) = PhysicalStickTuningState( + left = StickTuningState( + deadzone = profile.leftStickDeadzone, + sensitivity = profile.leftStickSensitivity, + deadzoneMode = profile.leftStickDeadzoneMode, + directionMode = profile.leftStickDigitalMode, + ), + right = StickTuningState( + deadzone = profile.rightStickDeadzone, + sensitivity = profile.rightStickSensitivity, + deadzoneMode = profile.rightStickDeadzoneMode, + directionMode = profile.rightStickDigitalMode, + ), + ) + + fun defaults(): PhysicalStickTuningState { + val defaults = StickTuningState( + deadzone = ControlsProfile.DEFAULT_STICK_DEADZONE, + sensitivity = ControlsProfile.DEFAULT_STICK_SENSITIVITY, + deadzoneMode = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE, + directionMode = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE, + ) + return PhysicalStickTuningState(left = defaults, right = defaults) + } + } +} + +internal fun applyAndSavePhysicalControllerTuning( + profile: ControlsProfile, + applyTuning: () -> Unit, + onSave: () -> Boolean, +): Boolean { + val previousTuning = PhysicalStickTuningState.from(profile) + val wasConfigured = profile.isStickTuningConfigured + applyTuning() + if (onSave()) return true + + previousTuning.applyTo(profile) + profile.restoreStickTuningConfigured(wasConfigured) + return false +} + +/** Full-screen stick tuning page opened from the Physical Controller quick-menu gear. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PhysicalControllerSettingsDialog( + profile: ControlsProfile, + onDismiss: () -> Unit, + onSave: () -> Boolean, +) { + var tuning by remember(profile) { mutableStateOf(PhysicalStickTuningState.from(profile)) } + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val saveFailedMessage = stringResource(R.string.physical_controller_settings_save_failed) + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false, + ), + ) { + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = PluviaBackground, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(R.string.physical_controller_settings_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close)) + } + }, + actions = { + IconButton(onClick = { tuning = PhysicalStickTuningState.defaults() }) { + Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.reset)) + } + IconButton(onClick = { + val saved = applyAndSavePhysicalControllerTuning( + profile = profile, + applyTuning = { tuning.applyTo(profile) }, + onSave = onSave, + ) + if (!saved) { + tuning = PhysicalStickTuningState.from(profile) + scope.launch { snackbarHostState.showSnackbar(saveFailedMessage) } + } + }) { + Icon(Icons.Default.Check, contentDescription = stringResource(R.string.save)) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 16.dp), + ) { + StickTuningSection( + tuning = tuning, + onTuningChange = { tuning = it }, + ) + } + } + } +} + +/** + * Deadzone and sensitivity tuning for the physical controller's analog sticks. + * + * These values are applied to the raw stick axes before they reach the virtual gamepad state, so + * they carry through to the guest game (including while shooter mode is active). + */ +@Composable +private fun StickTuningSection( + tuning: PhysicalStickTuningState, + onTuningChange: (PhysicalStickTuningState) -> Unit, +) { + val deadzoneDescription = stringResource(R.string.stick_deadzone_description) + val sensitivityDescription = stringResource(R.string.stick_sensitivity_description) + val directionDescription = stringResource(R.string.stick_direction_mode_description) + val deadzoneOptions = listOf( + ControlsProfile.StickDeadzoneMode.AXIAL to stringResource(R.string.stick_deadzone_axial), + ControlsProfile.StickDeadzoneMode.CIRCULAR to stringResource(R.string.stick_deadzone_circular), + ControlsProfile.StickDeadzoneMode.HYBRID to stringResource(R.string.stick_deadzone_hybrid), + ) + val directionOptions = listOf( + ControlsProfile.StickDigitalMode.UNRESTRICTED to stringResource(R.string.stick_direction_unrestricted), + ControlsProfile.StickDigitalMode.FOUR_WAY to stringResource(R.string.stick_direction_four_way), + ControlsProfile.StickDigitalMode.EIGHT_WAY to stringResource(R.string.stick_direction_eight_way), + ) + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + StickTuningControls( + title = stringResource(R.string.left_stick), + deadzoneLabel = stringResource(R.string.left_stick_deadzone), + deadzoneShapeLabel = stringResource(R.string.left_stick_deadzone_shape), + sensitivityLabel = stringResource(R.string.left_stick_sensitivity), + directionLabel = stringResource(R.string.left_stick_direction_mode), + tuning = tuning.left, + deadzoneDescription = deadzoneDescription, + sensitivityDescription = sensitivityDescription, + directionDescription = directionDescription, + deadzoneOptions = deadzoneOptions, + directionOptions = directionOptions, + onTuningChange = { onTuningChange(tuning.copy(left = it)) }, + ) + StickTuningControls( + title = stringResource(R.string.right_stick), + deadzoneLabel = stringResource(R.string.right_stick_deadzone), + deadzoneShapeLabel = stringResource(R.string.right_stick_deadzone_shape), + sensitivityLabel = stringResource(R.string.right_stick_sensitivity), + directionLabel = stringResource(R.string.right_stick_direction_mode), + tuning = tuning.right, + deadzoneDescription = deadzoneDescription, + sensitivityDescription = sensitivityDescription, + directionDescription = directionDescription, + deadzoneOptions = deadzoneOptions, + directionOptions = directionOptions, + onTuningChange = { onTuningChange(tuning.copy(right = it)) }, + ) + } +} + +@Composable +private fun StickTuningControls( + title: String, + deadzoneLabel: String, + deadzoneShapeLabel: String, + sensitivityLabel: String, + directionLabel: String, + tuning: StickTuningState, + deadzoneDescription: String, + sensitivityDescription: String, + directionDescription: String, + deadzoneOptions: List>, + directionOptions: List>, + onTuningChange: (StickTuningState) -> Unit, +) { + SettingsDialogSectionHeader(title) + StickAdjustmentSlider( + label = deadzoneLabel, + description = deadzoneDescription, + value = tuning.deadzone, + displayValue = String.format(Locale.getDefault(), "%.0f%%", tuning.deadzone * 100), + valueRange = ControlsProfile.MIN_STICK_DEADZONE..ControlsProfile.MAX_STICK_DEADZONE, + steps = DEADZONE_SLIDER_STEPS, + onValueChange = { onTuningChange(tuning.copy(deadzone = it)) }, + ) + StickModeSelector( + label = deadzoneShapeLabel, + description = stickDeadzoneModeDescription(tuning.deadzoneMode), + selected = tuning.deadzoneMode, + options = deadzoneOptions, + onSelected = { onTuningChange(tuning.copy(deadzoneMode = it)) }, + ) + StickAdjustmentSlider( + label = sensitivityLabel, + description = sensitivityDescription, + value = tuning.sensitivity, + displayValue = String.format(Locale.getDefault(), "%.2f×", tuning.sensitivity), + valueRange = ControlsProfile.MIN_STICK_SENSITIVITY..ControlsProfile.MAX_STICK_SENSITIVITY, + steps = SENSITIVITY_SLIDER_STEPS, + onValueChange = { onTuningChange(tuning.copy(sensitivity = it)) }, + ) + StickModeSelector( + label = directionLabel, + description = directionDescription, + selected = tuning.directionMode, + options = directionOptions, + onSelected = { onTuningChange(tuning.copy(directionMode = it)) }, + ) +} + +@Composable +private fun stickDeadzoneModeDescription(mode: ControlsProfile.StickDeadzoneMode): String { + val description = when (mode) { + ControlsProfile.StickDeadzoneMode.AXIAL -> R.string.stick_deadzone_axial_description + ControlsProfile.StickDeadzoneMode.CIRCULAR -> R.string.stick_deadzone_circular_description + ControlsProfile.StickDeadzoneMode.HYBRID -> R.string.stick_deadzone_hybrid_description + } + return stringResource(description) +} + +@Composable +private fun StickModeSelector( + label: String, + description: String, + selected: T, + options: List>, + onSelected: (T) -> Unit, +) { + SettingsDropdownBlock( + title = label, + subtitle = description, + value = selected, + values = options.map { it.first }, + labels = options.map { it.second }, + onValueChange = onSelected, + ) +} + +@Composable +private fun StickAdjustmentSlider( + label: String, + description: String, + value: Float, + displayValue: String, + valueRange: ClosedFloatingPointRange, + steps: Int, + onValueChange: (Float) -> Unit +) { + SettingsSliderBlock( + title = label, + subtitle = description, + value = value, + valueRange = valueRange, + valueText = displayValue, + onValueChange = onValueChange, + steps = steps, + ) +} + +// 0% .. 100% in 1% increments leaves 99 stops between the two endpoints +private const val DEADZONE_SLIDER_STEPS = 99 +// 0.10 .. 3.0 in 0.10 increments leaves 28 stops between the two endpoints +private const val SENSITIVITY_SLIDER_STEPS = 28 + /** * Quick preset buttons for physical controller stick/dpad bindings */ diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt index 40845ae8a1..3da6c341cc 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt @@ -56,7 +56,9 @@ class PhysicalControllerHandler( private val TAG = "gncontrol" private val mouseMoveOffset = PointF(0f, 0f) + private val mouseMoveRemainder = PointF(0f, 0f) private val mouseMoveContributions = mutableMapOf() + private val mouseMoveLock = Any() private val sequenceHandler = Handler(Looper.getMainLooper()) private var mouseMoveTimer: Timer? = null private var scrollRepeatTimer: Timer? = null @@ -124,6 +126,10 @@ class PhysicalControllerHandler( } } + init { + ExternalController.setStickTuning(profile, this) + } + fun setProfile(profile: ControlsProfile?) { releaseActiveBindings(activeButtonBindings) releaseActiveBindings(activeTriggerBindings, fromMotion = true) @@ -136,6 +142,7 @@ class PhysicalControllerHandler( activeSequenceTriggerBindings.clear() sendGamepadState() this.profile = profile + ExternalController.setStickTuning(profile, this) Log.d(TAG, "PhysicalControllerHandler: Profile set to ${profile?.name}") } @@ -154,6 +161,7 @@ class PhysicalControllerHandler( showKeyboardPressed = false closeRadialMenuIfOpen(commit = false) sendGamepadState() + ExternalController.clearStickTuning(this) } fun onInputDeviceRemoved(deviceId: Int) { @@ -349,15 +357,24 @@ class PhysicalControllerHandler( mouseMoveTimer = Timer() mouseMoveTimer?.schedule(object : TimerTask() { override fun run() { - // Skip injection if movement is below 8% deadzone to save CPU cycles - val magnitude = Math.sqrt((mouseMoveOffset.x * mouseMoveOffset.x + mouseMoveOffset.y * mouseMoveOffset.y).toDouble()) - if (magnitude < 0.08) return - - // Look up cursor speed dynamically so it updates when profile changes - val cursorSpeed = profile?.cursorSpeed ?: 1f - val deltaX = (mouseMoveOffset.x * 10 * cursorSpeed).toInt() - val deltaY = (mouseMoveOffset.y * 10 * cursorSpeed).toInt() - xServer?.injectPointerMoveDelta(deltaX, deltaY) + synchronized(mouseMoveLock) { + // Tuning already applies the user's chosen deadzone. Preserve sub-pixel input + // across ticks instead of silently discarding low-sensitivity movement. + if (mouseMoveOffset.x == 0f) mouseMoveRemainder.x = 0f + if (mouseMoveOffset.y == 0f) mouseMoveRemainder.y = 0f + if (mouseMoveOffset.x == 0f && mouseMoveOffset.y == 0f) return@synchronized + + // Look up cursor speed dynamically so it updates when profile changes + val cursorSpeed = profile?.cursorSpeed ?: 1f + val scaledX = mouseMoveOffset.x * 10 * cursorSpeed + mouseMoveRemainder.x + val scaledY = mouseMoveOffset.y * 10 * cursorSpeed + mouseMoveRemainder.y + val deltaX = scaledX.toInt() + val deltaY = scaledY.toInt() + mouseMoveRemainder.set(scaledX - deltaX, scaledY - deltaY) + if (deltaX != 0 || deltaY != 0) { + xServer?.injectPointerMoveDelta(deltaX, deltaY) + } + } } }, 0, 1000 / 60) } @@ -391,25 +408,31 @@ class PhysicalControllerHandler( } private fun recalculateMouseMoveOffset() { - mouseMoveOffset.set(0f, 0f) - mouseMoveContributions.forEach { (source, contribution) -> - if (source.binding == Binding.MOUSE_MOVE_LEFT || source.binding == Binding.MOUSE_MOVE_RIGHT) { - mouseMoveOffset.x += contribution - } else { - mouseMoveOffset.y += contribution + synchronized(mouseMoveLock) { + mouseMoveOffset.set(0f, 0f) + mouseMoveContributions.forEach { (source, contribution) -> + if (source.binding == Binding.MOUSE_MOVE_LEFT || source.binding == Binding.MOUSE_MOVE_RIGHT) { + mouseMoveOffset.x += contribution + } else { + mouseMoveOffset.y += contribution + } + } + if (mouseMoveContributions.isEmpty()) { + mouseMoveRemainder.set(0f, 0f) + mouseMoveTimer?.cancel() + mouseMoveTimer = null } - } - if (mouseMoveContributions.isEmpty()) { - mouseMoveTimer?.cancel() - mouseMoveTimer = null } } private fun clearMouseMoveContributions() { - mouseMoveContributions.clear() - mouseMoveOffset.set(0f, 0f) - mouseMoveTimer?.cancel() - mouseMoveTimer = null + synchronized(mouseMoveLock) { + mouseMoveContributions.clear() + mouseMoveOffset.set(0f, 0f) + mouseMoveRemainder.set(0f, 0f) + mouseMoveTimer?.cancel() + mouseMoveTimer = null + } } private fun handleScrollBinding(binding: Binding, isActionDown: Boolean): Boolean { @@ -488,14 +511,15 @@ class PhysicalControllerHandler( controller.state.dPadX.toFloat(), controller.state.dPadY.toFloat() ) - for (i in axes.indices) { val posKeyCode = ExternalControllerBinding.getKeyCodeForAxis(axes[i], 1.toByte()) val negKeyCode = ExternalControllerBinding.getKeyCodeForAxis(axes[i], (-1).toByte()) val positiveSource = PhysicalInputSource(deviceId, posKeyCode) val negativeSource = PhysicalInputSource(deviceId, negKeyCode) - if (Math.abs(values[i]) > ControlElement.STICK_DEAD_ZONE) { + val isActive = isPhysicalAxisActive(values[i], axes[i]) + + if (isActive) { val activeKey = ExternalControllerBinding.getKeyCodeForAxis(axes[i], Mathf.sign(values[i])) val oppositeKey = if (activeKey == posKeyCode) negKeyCode else posKeyCode val activeSource = if (activeKey == posKeyCode) positiveSource else negativeSource @@ -1069,9 +1093,7 @@ class PhysicalControllerHandler( ): Pair? { val filteredX = radialSelectionComponent(x, xAxis) val filteredY = radialSelectionComponent(y, yAxis) - return if (Math.abs(filteredX) <= ControlElement.STICK_DEAD_ZONE && - Math.abs(filteredY) <= ControlElement.STICK_DEAD_ZONE - ) { + return if (filteredX == 0f && filteredY == 0f) { null } else { filteredX to filteredY @@ -1079,11 +1101,21 @@ class PhysicalControllerHandler( } private fun radialSelectionComponent(value: Float, axis: Int): Float { - if (Math.abs(value) <= ControlElement.STICK_DEAD_ZONE) return 0f + if (!isPhysicalAxisActive(value, axis)) return 0f val keyCode = ExternalControllerBinding.getKeyCodeForAxis(axis, Mathf.sign(value)) return if (keyCode == radialMenuOpenerKeyCode) 0f else value } + private fun isPhysicalAxisActive(value: Float, axis: Int): Boolean { + val isStick = axis == MotionEvent.AXIS_X || axis == MotionEvent.AXIS_Y || + axis == MotionEvent.AXIS_Z || axis == MotionEvent.AXIS_RZ + return if (isStick && profile?.isStickTuningConfigured == true) { + value != 0f + } else { + Math.abs(value) > ControlElement.STICK_DEAD_ZONE + } + } + private fun isRadialMenuMotionOpenerPressed(controller: ExternalController): Boolean { val axes = intArrayOf( MotionEvent.AXIS_X, @@ -1103,7 +1135,7 @@ class PhysicalControllerHandler( ) for (i in axes.indices) { - if (Math.abs(values[i]) <= ControlElement.STICK_DEAD_ZONE) continue + if (!isPhysicalAxisActive(values[i], axes[i])) continue val keyCode = ExternalControllerBinding.getKeyCodeForAxis(axes[i], Mathf.sign(values[i])) if (keyCode == radialMenuOpenerKeyCode) { return true diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 2092b35ef0..24fd5ac6b0 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -275,6 +275,9 @@ private const val DEFAULT_FPS_LIMITER_MAX_HZ = 60 private const val DEFAULT_FPS_LIMITER_TARGET_HZ = 60 private const val FPS_LIMITER_ENABLED_EXTRA = "fpsLimiterEnabled" private const val FPS_LIMITER_TARGET_EXTRA = "fpsLimiterTarget" +private const val PHYSICAL_CONTROLLER_DIALOG_NONE = 0 +private const val PHYSICAL_CONTROLLER_DIALOG_BINDINGS = 1 +private const val PHYSICAL_CONTROLLER_DIALOG_SETTINGS = 2 private fun initialFpsLimiterEnabled(container: Container): Boolean = parseBooleanExtra(container.getExtra(FPS_LIMITER_ENABLED_EXTRA)) ?: true @@ -623,7 +626,7 @@ fun XServerScreen( var elementPositionsSnapshot by remember { mutableStateOf>>(emptyMap()) } var showElementEditor by remember { mutableStateOf(false) } var elementToEdit by remember { mutableStateOf(null) } - var showPhysicalControllerDialog by remember { mutableStateOf(false) } + var physicalControllerDialogMode by remember { mutableIntStateOf(PHYSICAL_CONTROLLER_DIALOG_NONE) } var showPlayingBlockedDialog by rememberSaveable { mutableStateOf(false) } var playingBlockedRemoteName by rememberSaveable { mutableStateOf(null) } var showTouchGestureDialog by remember { mutableStateOf(false) } @@ -667,7 +670,8 @@ fun XServerScreen( var fpsLimiterTarget by rememberSaveable(container.id) { mutableIntStateOf(initialFpsLimiterTarget(container)) } val gyroOverlaySuppressed = showQuickMenu || keepPausedForEditor || showElementEditor || - showPhysicalControllerDialog || showTouchGestureDialog || showShooterModeDialog || + physicalControllerDialogMode != PHYSICAL_CONTROLLER_DIALOG_NONE || + showTouchGestureDialog || showShooterModeDialog || showPlayingBlockedDialog || isEditMode SyncGyroOverlaySuppression( suppressed = gyroOverlaySuppressed, @@ -1467,10 +1471,15 @@ fun XServerScreen( QuickMenuAction.EDIT_PHYSICAL_CONTROLLER -> { if (PrefManager.usageAnalyticsEnabled) PostHog.capture(event = "edit_physical_controller_from_menu") keepPausedForEditor = true - showPhysicalControllerDialog = true + physicalControllerDialogMode = PHYSICAL_CONTROLLER_DIALOG_BINDINGS true } + QuickMenuAction.PHYSICAL_CONTROLLER_SETTINGS -> { + physicalControllerDialogMode = PHYSICAL_CONTROLLER_DIALOG_SETTINGS + false + } + QuickMenuAction.RADIAL_MENU -> { if (PrefManager.usageAnalyticsEnabled) PostHog.capture(event = "edit_radial_menu_from_menu") PluviaApp.radialMenuCoordinator?.showSettingsDialog() == true @@ -3109,8 +3118,8 @@ fun XServerScreen( ) } - // Physical Controller Config Dialog - if (showPhysicalControllerDialog) { + // Physical Controller bindings editor or stick settings page + if (physicalControllerDialogMode != PHYSICAL_CONTROLLER_DIALOG_NONE) { // Get profile from container settings, not from InputControlsView // (InputControlsView.profile is null when on-screen controls are hidden) val manager = PluviaApp.inputControlsManager ?: InputControlsManager(context) @@ -3122,46 +3131,67 @@ fun XServerScreen( } if (profile != null) { - androidx.compose.ui.window.Dialog( - onDismissRequest = { - showPhysicalControllerDialog = false - keepPausedForEditor = false - resumeIfAllowedAfterOverlay() + when (physicalControllerDialogMode) { + PHYSICAL_CONTROLLER_DIALOG_BINDINGS -> { + androidx.compose.ui.window.Dialog( + onDismissRequest = { + physicalControllerDialogMode = PHYSICAL_CONTROLLER_DIALOG_NONE + keepPausedForEditor = false + resumeIfAllowedAfterOverlay() + } + ) { + androidx.compose.foundation.layout.Box( + modifier = Modifier + .fillMaxSize() + .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.95f)) + ) { + app.gamenative.ui.component.dialog.PhysicalControllerConfigSection( + profile = profile, + onDismiss = { + physicalControllerDialogMode = PHYSICAL_CONTROLLER_DIALOG_NONE + keepPausedForEditor = false + resumeIfAllowedAfterOverlay() + }, + onSave = { + // Ensure controllersLoaded is true before saving + // (addController sets the flag even if controller already exists) + profile.addController("*") + + // Save profileId to container so it persists across launches + container.putExtra("profileId", profile.id.toString()) + container.saveData() + + profile.loadControllers() + + // Keep gyro and binding inspection on the reloaded profile without + // unintentionally showing controls that were hidden for a controller. + PluviaApp.inputControlsView?.setProfilePreservingOverlayVisibility(profile) + PluviaApp.radialMenuCoordinator?.setProfile(profile) + physicalControllerDialogMode = PHYSICAL_CONTROLLER_DIALOG_NONE + keepPausedForEditor = false + resumeIfAllowedAfterOverlay() + } + ) + } + } } - ) { - androidx.compose.foundation.layout.Box( - modifier = Modifier - .fillMaxSize() - .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.95f)) - ) { - app.gamenative.ui.component.dialog.PhysicalControllerConfigSection( + + PHYSICAL_CONTROLLER_DIALOG_SETTINGS -> { + app.gamenative.ui.component.dialog.PhysicalControllerSettingsDialog( profile = profile, onDismiss = { - showPhysicalControllerDialog = false - keepPausedForEditor = false - resumeIfAllowedAfterOverlay() + physicalControllerDialogMode = PHYSICAL_CONTROLLER_DIALOG_NONE }, - onSave = { - // Ensure controllersLoaded is true before saving - // (addController sets the flag even if controller already exists) - profile.addController("*") - - // Save profileId to container so it persists across launches - container.putExtra("profileId", profile.id.toString()) - container.saveData() - - // Save profile (will now write controllers since controllersLoaded = true) - profile.save() - profile.loadControllers() - - // Keep gyro and binding inspection on the reloaded profile without - // unintentionally showing controls that were hidden for a controller. + onSave = saveSettings@{ + if (!profile.save()) { + Timber.e("Failed to save physical controller stick settings for ${profile.name}") + return@saveSettings false + } PluviaApp.inputControlsView?.setProfilePreservingOverlayVisibility(profile) PluviaApp.radialMenuCoordinator?.setProfile(profile) - showPhysicalControllerDialog = false - keepPausedForEditor = false - resumeIfAllowedAfterOverlay() - } + physicalControllerDialogMode = PHYSICAL_CONTROLLER_DIALOG_NONE + true + }, ) } } diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java index 0a034eefd9..70042215a0 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java @@ -6,6 +6,7 @@ import androidx.annotation.NonNull; import com.winlator.core.FileUtils; +import com.winlator.math.Mathf; import com.winlator.widget.InputControlsView; import org.json.JSONArray; @@ -22,10 +23,83 @@ public class ControlsProfile implements Comparable { public static final float DEFAULT_CURSOR_SPEED = 1.0f; public static final String KEY_AUTO_FIT_LAYOUT = "autoFitLayout"; + public static final String KEY_LEFT_STICK_DEADZONE = "leftStickDeadzone"; + public static final String KEY_RIGHT_STICK_DEADZONE = "rightStickDeadzone"; + public static final String KEY_LEFT_STICK_SENSITIVITY = "leftStickSensitivity"; + public static final String KEY_RIGHT_STICK_SENSITIVITY = "rightStickSensitivity"; + public static final String KEY_LEFT_STICK_DEADZONE_MODE = "leftStickDeadzoneMode"; + public static final String KEY_RIGHT_STICK_DEADZONE_MODE = "rightStickDeadzoneMode"; + public static final String KEY_LEFT_STICK_DIGITAL_MODE = "leftStickDigitalMode"; + public static final String KEY_RIGHT_STICK_DIGITAL_MODE = "rightStickDigitalMode"; + + public enum StickDeadzoneMode { + AXIAL("axial"), + CIRCULAR("circular"), + HYBRID("hybrid"); + + private final String jsonName; + + StickDeadzoneMode(String jsonName) { + this.jsonName = jsonName; + } + + public String getJsonName() { + return jsonName; + } + + public static StickDeadzoneMode fromJsonName(String value) { + for (StickDeadzoneMode mode : values()) { + if (mode.jsonName.equals(value)) return mode; + } + return DEFAULT_STICK_DEADZONE_MODE; + } + } + + public enum StickDigitalMode { + UNRESTRICTED("unrestricted"), + FOUR_WAY("four_way"), + EIGHT_WAY("eight_way"); + + private final String jsonName; + + StickDigitalMode(String jsonName) { + this.jsonName = jsonName; + } + + public String getJsonName() { + return jsonName; + } + + public static StickDigitalMode fromJsonName(String value) { + for (StickDigitalMode mode : values()) { + if (mode.jsonName.equals(value)) return mode; + } + return DEFAULT_STICK_DIGITAL_MODE; + } + } + + /** Bounds for the user-configurable physical stick deadzone and sensitivity. */ + public static final float MIN_STICK_DEADZONE = 0.0f; + public static final float MAX_STICK_DEADZONE = 1.0f; + public static final float MIN_STICK_SENSITIVITY = 0.1f; + public static final float MAX_STICK_SENSITIVITY = 3.0f; + public static final float DEFAULT_STICK_DEADZONE = ControlElement.STICK_DEAD_ZONE; + public static final float DEFAULT_STICK_SENSITIVITY = 1.0f; + public static final StickDeadzoneMode DEFAULT_STICK_DEADZONE_MODE = StickDeadzoneMode.AXIAL; + public static final StickDigitalMode DEFAULT_STICK_DIGITAL_MODE = StickDigitalMode.UNRESTRICTED; public final int id; private String name; private float cursorSpeed = DEFAULT_CURSOR_SPEED; + private float leftStickDeadzone = DEFAULT_STICK_DEADZONE; + private float rightStickDeadzone = DEFAULT_STICK_DEADZONE; + private float leftStickSensitivity = DEFAULT_STICK_SENSITIVITY; + private float rightStickSensitivity = DEFAULT_STICK_SENSITIVITY; + private StickDeadzoneMode leftStickDeadzoneMode = DEFAULT_STICK_DEADZONE_MODE; + private StickDeadzoneMode rightStickDeadzoneMode = DEFAULT_STICK_DEADZONE_MODE; + private StickDigitalMode leftStickDigitalMode = DEFAULT_STICK_DIGITAL_MODE; + private StickDigitalMode rightStickDigitalMode = DEFAULT_STICK_DIGITAL_MODE; + private boolean stickTuningConfigured = false; private final ArrayList elements = new ArrayList<>(); private final ArrayList controllers = new ArrayList<>(); private final ArrayList radialMenus = new ArrayList<>(); @@ -90,6 +164,110 @@ public void setCursorSpeed(float cursorSpeed) { this.cursorSpeed = cursorSpeed; } + private static float clampDeadzone(float value) { + if (Float.isNaN(value)) return DEFAULT_STICK_DEADZONE; + return Mathf.clamp(value, MIN_STICK_DEADZONE, MAX_STICK_DEADZONE); + } + + private static float clampSensitivity(float value) { + if (Float.isNaN(value)) return DEFAULT_STICK_SENSITIVITY; + return Mathf.clamp(value, MIN_STICK_SENSITIVITY, MAX_STICK_SENSITIVITY); + } + + public float getLeftStickDeadzone() { + return leftStickDeadzone; + } + + public void setLeftStickDeadzone(float deadzone) { + this.leftStickDeadzone = clampDeadzone(deadzone); + stickTuningConfigured = true; + } + + public float getRightStickDeadzone() { + return rightStickDeadzone; + } + + public void setRightStickDeadzone(float deadzone) { + this.rightStickDeadzone = clampDeadzone(deadzone); + stickTuningConfigured = true; + } + + public float getLeftStickSensitivity() { + return leftStickSensitivity; + } + + public void setLeftStickSensitivity(float sensitivity) { + this.leftStickSensitivity = clampSensitivity(sensitivity); + stickTuningConfigured = true; + } + + public float getRightStickSensitivity() { + return rightStickSensitivity; + } + + public void setRightStickSensitivity(float sensitivity) { + this.rightStickSensitivity = clampSensitivity(sensitivity); + stickTuningConfigured = true; + } + + public StickDeadzoneMode getLeftStickDeadzoneMode() { + return leftStickDeadzoneMode; + } + + public void setLeftStickDeadzoneMode(StickDeadzoneMode mode) { + leftStickDeadzoneMode = mode != null ? mode : DEFAULT_STICK_DEADZONE_MODE; + stickTuningConfigured = true; + } + + public StickDeadzoneMode getRightStickDeadzoneMode() { + return rightStickDeadzoneMode; + } + + public void setRightStickDeadzoneMode(StickDeadzoneMode mode) { + rightStickDeadzoneMode = mode != null ? mode : DEFAULT_STICK_DEADZONE_MODE; + stickTuningConfigured = true; + } + + public StickDigitalMode getLeftStickDigitalMode() { + return leftStickDigitalMode; + } + + public void setLeftStickDigitalMode(StickDigitalMode mode) { + leftStickDigitalMode = mode != null ? mode : DEFAULT_STICK_DIGITAL_MODE; + stickTuningConfigured = true; + } + + public StickDigitalMode getRightStickDigitalMode() { + return rightStickDigitalMode; + } + + public void setRightStickDigitalMode(StickDigitalMode mode) { + rightStickDigitalMode = mode != null ? mode : DEFAULT_STICK_DIGITAL_MODE; + stickTuningConfigured = true; + } + + /** Whether this profile explicitly opted into physical-stick tuning. */ + public boolean isStickTuningConfigured() { + return stickTuningConfigured; + } + + /** Restores whether stick tuning was explicitly configured after a failed transactional edit. */ + public void restoreStickTuningConfigured(boolean configured) { + stickTuningConfigured = configured; + } + + /** + * Rescales an axis value across the remaining travel so the first output past the deadzone is + * near zero instead of jumping straight to the deadzone magnitude. + */ + public static float applyStickDeadzone(float value, float deadzone) { + if (!Float.isFinite(value)) return 0; + deadzone = clampDeadzone(deadzone); + float magnitude = Math.abs(value); + if (magnitude <= deadzone || deadzone >= 1.0f) return 0; + return ((magnitude - deadzone) / (1.0f - deadzone)) * Mathf.sign(value); + } + public boolean isVirtualGamepad() { return virtualGamepad; } @@ -215,6 +393,20 @@ public boolean saveElementsForLayoutSize(int layoutWidth, int layoutHeight) { if (!gameOwnerId.isEmpty()) data.put("gameOwnerId", gameOwnerId); else data.remove("gameOwnerId"); + // Profiles created before stick tuning existed retain their original input behavior. + // Opening this feature and saving any values opts the profile in from then on. + if (stickTuningConfigured) { + data.put(KEY_LEFT_STICK_DEADZONE, (double) leftStickDeadzone); + data.put(KEY_RIGHT_STICK_DEADZONE, (double) rightStickDeadzone); + data.put(KEY_LEFT_STICK_SENSITIVITY, (double) leftStickSensitivity); + data.put(KEY_RIGHT_STICK_SENSITIVITY, (double) rightStickSensitivity); + data.put(KEY_LEFT_STICK_DEADZONE_MODE, leftStickDeadzoneMode.getJsonName()); + data.put(KEY_RIGHT_STICK_DEADZONE_MODE, rightStickDeadzoneMode.getJsonName()); + data.put(KEY_LEFT_STICK_DIGITAL_MODE, leftStickDigitalMode.getJsonName()); + data.put(KEY_RIGHT_STICK_DIGITAL_MODE, rightStickDigitalMode.getJsonName()); + includeSectionIfDeclared(data, "physicalController"); + } + JSONArray elementsJSONArray = new JSONArray(); if (!elementsLoaded && file.isFile()) { JSONArray storedElements = data.optJSONArray("elements"); @@ -295,6 +487,11 @@ private static boolean includesSection(JSONObject data, String section) { return false; } + private static void includeSectionIfDeclared(JSONObject data, String section) { + JSONArray included = data.optJSONArray("includedSections"); + if (included != null && !includesSection(data, section)) included.put(section); + } + public static File getProfileFile(Context context, int id) { return new File(InputControlsManager.getProfilesDir(context), "controls-"+id+".icp"); } diff --git a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java index c82f30393a..9e5566b1a3 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java +++ b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java @@ -13,6 +13,7 @@ import androidx.annotation.Nullable; import com.winlator.PrefManager; +import com.winlator.math.Mathf; import com.winlator.winhandler.WinHandler; import org.json.JSONArray; @@ -39,6 +40,52 @@ public class ExternalController { public static final byte TRIGGER_IS_AXIS = 1; public static final byte TRIGGER_IS_BOTH = 2; + // Controllers for players 2-4 are created on demand by WinHandler and do not belong to a + // ControlsProfile, so the active profile's tuning is shared. Publishing one immutable object + // prevents an input event from observing a mixture of old and new values while settings save. + private static final class StickTuningConfig { + final float deadzoneLeft; + final float deadzoneRight; + final float sensitivityLeft; + final float sensitivityRight; + final ControlsProfile.StickDeadzoneMode deadzoneModeLeft; + final ControlsProfile.StickDeadzoneMode deadzoneModeRight; + final ControlsProfile.StickDigitalMode directionModeLeft; + final ControlsProfile.StickDigitalMode directionModeRight; + final Object owner; + + StickTuningConfig(@Nullable ControlsProfile profile, @Nullable Object owner) { + boolean tuningEnabled = profile != null && profile.isStickTuningConfigured(); + deadzoneLeft = tuningEnabled + ? profile.getLeftStickDeadzone() + : ControlsProfile.MIN_STICK_DEADZONE; + deadzoneRight = tuningEnabled + ? profile.getRightStickDeadzone() + : ControlsProfile.MIN_STICK_DEADZONE; + sensitivityLeft = tuningEnabled + ? profile.getLeftStickSensitivity() + : ControlsProfile.DEFAULT_STICK_SENSITIVITY; + sensitivityRight = tuningEnabled + ? profile.getRightStickSensitivity() + : ControlsProfile.DEFAULT_STICK_SENSITIVITY; + deadzoneModeLeft = tuningEnabled + ? profile.getLeftStickDeadzoneMode() + : ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; + deadzoneModeRight = tuningEnabled + ? profile.getRightStickDeadzoneMode() + : ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; + directionModeLeft = tuningEnabled + ? profile.getLeftStickDigitalMode() + : ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; + directionModeRight = tuningEnabled + ? profile.getRightStickDigitalMode() + : ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; + this.owner = owner; + } + } + + private static volatile StickTuningConfig stickTuning = new StickTuningConfig(null, null); + private String id; private String name; private int deviceId = -1; @@ -46,6 +93,22 @@ public class ExternalController { private final ArrayList controllerBindings = new ArrayList<>(); public final GamepadState state = new GamepadState(); private boolean processTriggerButtonOnMotionEvent = true; + private float rawThumbLX; + private float rawThumbLY; + private float rawThumbRX; + private float rawThumbRY; + private int snappedLeftDirection = StickVectorProcessor.DIRECTION_NONE; + private int snappedRightDirection = StickVectorProcessor.DIRECTION_NONE; + + /** Applies {@code profile}'s stick tuning until its owner releases the shared configuration. */ + public static synchronized void setStickTuning(ControlsProfile profile, @NonNull Object owner) { + stickTuning = new StickTuningConfig(profile, owner); + } + + /** Clears tuning only when {@code owner} still owns the active configuration. */ + public static synchronized void clearStickTuning(@NonNull Object owner) { + if (stickTuning.owner == owner) stickTuning = new StickTuningConfig(null, null); + } public String getName() { return this.name; @@ -178,12 +241,29 @@ public String toString() { return getDeviceId() + " | " + getName(); } + static float resolveRawAxis(boolean reported, float retained, float value) { + return JoyConSupport.axisValue(reported, retained, value); + } + private void processJoystickInput(MotionEvent event, int historyPos) { boolean z = false; - this.state.thumbLX = updateAxis(event, MotionEvent.AXIS_X, historyPos, this.state.thumbLX); - this.state.thumbLY = updateAxis(event, MotionEvent.AXIS_Y, historyPos, this.state.thumbLY); - this.state.thumbRX = updateAxis(event, MotionEvent.AXIS_Z, historyPos, this.state.thumbRX); - this.state.thumbRY = updateAxis(event, MotionEvent.AXIS_RZ, historyPos, this.state.thumbRY); + rawThumbLX = updateRawAxis(event, MotionEvent.AXIS_X, historyPos, rawThumbLX); + rawThumbLY = updateRawAxis(event, MotionEvent.AXIS_Y, historyPos, rawThumbLY); + rawThumbRX = updateRawAxis(event, MotionEvent.AXIS_Z, historyPos, rawThumbRX); + rawThumbRY = updateRawAxis(event, MotionEvent.AXIS_RZ, historyPos, rawThumbRY); + StickTuningConfig tuning = stickTuning; + StickVectorProcessor.Vector left = StickVectorProcessor.tune( + rawThumbLX, rawThumbLY, + tuning.deadzoneLeft, tuning.sensitivityLeft, tuning.deadzoneModeLeft); + StickVectorProcessor.Vector right = StickVectorProcessor.tune( + rawThumbRX, rawThumbRY, + tuning.deadzoneRight, tuning.sensitivityRight, tuning.deadzoneModeRight); + left = applyDirectionSnapping(left, tuning.directionModeLeft, false); + right = applyDirectionSnapping(right, tuning.directionModeRight, true); + this.state.thumbLX = left.x; + this.state.thumbLY = left.y; + this.state.thumbRX = right.x; + this.state.thumbRY = right.y; if (historyPos == -1 && !JoyConSupport.isJoyCon(event.getDevice())) { float axisX = getCenteredAxis(event, MotionEvent.AXIS_HAT_X, historyPos); float axisY = getCenteredAxis(event, MotionEvent.AXIS_HAT_Y, historyPos); @@ -202,10 +282,34 @@ private void processJoystickInput(MotionEvent event, int historyPos) { } } - private static float updateAxis(MotionEvent event, int axis, int historyPos, float retained) { + StickVectorProcessor.Vector applyDirectionSnapping( + StickVectorProcessor.Vector vector, + ControlsProfile.StickDigitalMode mode, + boolean rightStick) { + ControlsProfile.StickDigitalMode resolvedMode = mode != null + ? mode + : ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; + if (resolvedMode == ControlsProfile.StickDigitalMode.UNRESTRICTED) { + if (rightStick) snappedRightDirection = StickVectorProcessor.DIRECTION_NONE; + else snappedLeftDirection = StickVectorProcessor.DIRECTION_NONE; + return vector; + } + int previousDirection = rightStick ? snappedRightDirection : snappedLeftDirection; + int direction = StickVectorProcessor.snapDirection( + vector.x, vector.y, resolvedMode, previousDirection); + if (rightStick) snappedRightDirection = direction; + else snappedLeftDirection = direction; + return StickVectorProcessor.snapToDirection(vector.x, vector.y, direction); + } + + private static float updateRawAxis(MotionEvent event, int axis, int historyPos, float retained) { InputDevice device = event.getDevice(); boolean reported = device != null && device.getMotionRange(axis, event.getSource()) != null; - return JoyConSupport.axisValue(reported, retained, getCenteredAxis(event, axis, historyPos)); + return resolveRawAxis( + reported, + retained, + getCenteredAxis(event, axis, historyPos) + ); } private void processTriggerButton(MotionEvent event) { diff --git a/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java b/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java index 209af5e3a7..07ce0fdb82 100644 --- a/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java +++ b/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java @@ -350,6 +350,14 @@ public static ControlsProfile loadProfile(Context context, InputStream inStream) boolean hasProfileId = false; String profileName = null; float cursorSpeed = ControlsProfile.DEFAULT_CURSOR_SPEED; + float leftStickDeadzone = Float.NaN; + float rightStickDeadzone = Float.NaN; + float leftStickSensitivity = Float.NaN; + float rightStickSensitivity = Float.NaN; + ControlsProfile.StickDeadzoneMode leftStickDeadzoneMode = null; + ControlsProfile.StickDeadzoneMode rightStickDeadzoneMode = null; + ControlsProfile.StickDigitalMode leftStickDigitalMode = null; + ControlsProfile.StickDigitalMode rightStickDigitalMode = null; boolean listed = true; int libraryProfileId = -1; int maxReferencedProfileId = -1; @@ -369,6 +377,30 @@ else if (name.equals("name")) { else if (name.equals("cursorSpeed")) { cursorSpeed = (float) reader.nextDouble(); } + else if (name.equals(ControlsProfile.KEY_LEFT_STICK_DEADZONE)) { + leftStickDeadzone = (float) reader.nextDouble(); + } + else if (name.equals(ControlsProfile.KEY_RIGHT_STICK_DEADZONE)) { + rightStickDeadzone = (float) reader.nextDouble(); + } + else if (name.equals(ControlsProfile.KEY_LEFT_STICK_SENSITIVITY)) { + leftStickSensitivity = (float) reader.nextDouble(); + } + else if (name.equals(ControlsProfile.KEY_RIGHT_STICK_SENSITIVITY)) { + rightStickSensitivity = (float) reader.nextDouble(); + } + else if (name.equals(ControlsProfile.KEY_LEFT_STICK_DEADZONE_MODE)) { + leftStickDeadzoneMode = ControlsProfile.StickDeadzoneMode.fromJsonName(reader.nextString()); + } + else if (name.equals(ControlsProfile.KEY_RIGHT_STICK_DEADZONE_MODE)) { + rightStickDeadzoneMode = ControlsProfile.StickDeadzoneMode.fromJsonName(reader.nextString()); + } + else if (name.equals(ControlsProfile.KEY_LEFT_STICK_DIGITAL_MODE)) { + leftStickDigitalMode = ControlsProfile.StickDigitalMode.fromJsonName(reader.nextString()); + } + else if (name.equals(ControlsProfile.KEY_RIGHT_STICK_DIGITAL_MODE)) { + rightStickDigitalMode = ControlsProfile.StickDigitalMode.fromJsonName(reader.nextString()); + } else if (name.equals("listed")) { listed = reader.nextBoolean(); } @@ -405,6 +437,14 @@ else if (name.equals("gameOwnerId")) { profile.setLibraryProfileId(libraryProfileId); profile.setMaxReferencedProfileId(maxReferencedProfileId); profile.setGameOwnerId(gameOwnerId); + if (!Float.isNaN(leftStickDeadzone)) profile.setLeftStickDeadzone(leftStickDeadzone); + if (!Float.isNaN(rightStickDeadzone)) profile.setRightStickDeadzone(rightStickDeadzone); + if (!Float.isNaN(leftStickSensitivity)) profile.setLeftStickSensitivity(leftStickSensitivity); + if (!Float.isNaN(rightStickSensitivity)) profile.setRightStickSensitivity(rightStickSensitivity); + if (leftStickDeadzoneMode != null) profile.setLeftStickDeadzoneMode(leftStickDeadzoneMode); + if (rightStickDeadzoneMode != null) profile.setRightStickDeadzoneMode(rightStickDeadzoneMode); + if (leftStickDigitalMode != null) profile.setLeftStickDigitalMode(leftStickDigitalMode); + if (rightStickDigitalMode != null) profile.setRightStickDigitalMode(rightStickDigitalMode); return profile; } catch (Exception e) { diff --git a/app/src/main/java/com/winlator/inputcontrols/StickVectorProcessor.java b/app/src/main/java/com/winlator/inputcontrols/StickVectorProcessor.java new file mode 100644 index 0000000000..6ea66f5212 --- /dev/null +++ b/app/src/main/java/com/winlator/inputcontrols/StickVectorProcessor.java @@ -0,0 +1,172 @@ +package com.winlator.inputcontrols; + +import com.winlator.math.Mathf; + +/** Pure two-dimensional transforms shared by physical-stick passthrough and digital bindings. */ +public final class StickVectorProcessor { + public static final int DIRECTION_NONE = -1; + private static final double MAGNITUDE_EPSILON = 1.0e-6; + private static final double HYSTERESIS_RADIANS = Math.toRadians(5.0); + private static final double TWO_PI = Math.PI * 2.0; + private static final float INVERSE_SQRT_TWO = (float)(1.0 / Math.sqrt(2.0)); + + private StickVectorProcessor() {} + + public static final class Vector { + public static final Vector ZERO = new Vector(0, 0); + public final float x; + public final float y; + + public Vector(float x, float y) { + this.x = x; + this.y = y; + } + } + + public static Vector tune( + float x, + float y, + float deadzone, + float sensitivity, + ControlsProfile.StickDeadzoneMode mode) { + if (!Float.isFinite(x) || !Float.isFinite(y)) return Vector.ZERO; + ControlsProfile.StickDeadzoneMode resolvedMode = mode != null + ? mode + : ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; + Vector adjusted; + switch (resolvedMode) { + case CIRCULAR: + adjusted = applyCircularDeadzone(x, y, deadzone); + break; + case HYBRID: + adjusted = applyHybridDeadzone(x, y, deadzone); + break; + case AXIAL: + default: + adjusted = new Vector( + ControlsProfile.applyStickDeadzone(x, deadzone), + ControlsProfile.applyStickDeadzone(y, deadzone)); + break; + } + + float resolvedSensitivity = Float.isFinite(sensitivity) + ? Mathf.clamp( + sensitivity, + ControlsProfile.MIN_STICK_SENSITIVITY, + ControlsProfile.MAX_STICK_SENSITIVITY) + : ControlsProfile.DEFAULT_STICK_SENSITIVITY; + float scaledX = adjusted.x * resolvedSensitivity; + float scaledY = adjusted.y * resolvedSensitivity; + if (resolvedMode == ControlsProfile.StickDeadzoneMode.AXIAL) { + return new Vector(Mathf.clamp(scaledX, -1, 1), Mathf.clamp(scaledY, -1, 1)); + } + return clampToUnitCircle(scaledX, scaledY); + } + + static Vector applyCircularDeadzone(float x, float y, float deadzone) { + float resolvedDeadzone = resolveDeadzone(deadzone); + double magnitude = Math.hypot(x, y); + if (magnitude <= resolvedDeadzone + MAGNITUDE_EPSILON || resolvedDeadzone >= 1.0f) { + return Vector.ZERO; + } + float outputMagnitude = Mathf.clamp( + (float)((magnitude - resolvedDeadzone) / (1.0f - resolvedDeadzone)), + 0, + 1); + float scale = outputMagnitude / (float)magnitude; + return new Vector(x * scale, y * scale); + } + + static Vector applyHybridDeadzone(float x, float y, float deadzone) { + float resolvedDeadzone = resolveDeadzone(deadzone); + Vector circular = applyCircularDeadzone(x, y, resolvedDeadzone); + if (circular.x == 0 && circular.y == 0) return circular; + + // Add narrow axial corridors to a circular center without changing diagonal magnitude. + float outputX = Math.abs(circular.x) <= resolvedDeadzone * Math.abs(circular.y) + ? 0 + : circular.x; + float outputY = Math.abs(circular.y) <= resolvedDeadzone * Math.abs(circular.x) + ? 0 + : circular.y; + return new Vector(outputX, outputY); + } + + private static Vector clampToUnitCircle(float x, float y) { + double magnitude = Math.hypot(x, y); + if (magnitude <= 1.0) return new Vector(x, y); + float scale = 1.0f / (float)magnitude; + return new Vector(x * scale, y * scale); + } + + private static float resolveDeadzone(float deadzone) { + if (!Float.isFinite(deadzone)) return ControlsProfile.DEFAULT_STICK_DEADZONE; + return Mathf.clamp( + deadzone, + ControlsProfile.MIN_STICK_DEADZONE, + ControlsProfile.MAX_STICK_DEADZONE); + } + + /** + * Resolves a stable 4-way or 8-way sector. Direction indices are clockwise eighth-turns: + * right=0, down=2, left=4, up=6. The previous sector gets a five-degree margin. + */ + public static int snapDirection( + float x, + float y, + ControlsProfile.StickDigitalMode mode, + int previousDirection) { + if (x == 0 && y == 0) return DIRECTION_NONE; + if (!Float.isFinite(x) || !Float.isFinite(y)) return DIRECTION_NONE; + ControlsProfile.StickDigitalMode resolvedMode = mode != null + ? mode + : ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; + if (resolvedMode == ControlsProfile.StickDigitalMode.UNRESTRICTED) return DIRECTION_NONE; + + int sectorCount = resolvedMode == ControlsProfile.StickDigitalMode.FOUR_WAY ? 4 : 8; + double sectorSize = TWO_PI / sectorCount; + double angle = normalizeAngle(Math.atan2(y, x)); + if (isDirectionValid(previousDirection, sectorCount)) { + double previousCenter = previousDirection * Math.PI / 4.0; + if (angularDistance(angle, previousCenter) <= sectorSize / 2.0 + HYSTERESIS_RADIANS) { + return previousDirection; + } + } + + int sector = (int)Math.floor((angle + sectorSize / 2.0) / sectorSize) % sectorCount; + return sectorCount == 4 ? sector * 2 : sector; + } + + static Vector snapToDirection(float x, float y, int direction) { + if (!Float.isFinite(x) || !Float.isFinite(y) || direction < 0 || direction > 7) { + return Vector.ZERO; + } + float strength = Mathf.clamp((float)Math.hypot(x, y), 0, 1); + float diagonalComponent = strength * INVERSE_SQRT_TWO; + switch (direction) { + case 0: return new Vector(strength, 0); + case 1: return new Vector(diagonalComponent, diagonalComponent); + case 2: return new Vector(0, strength); + case 3: return new Vector(-diagonalComponent, diagonalComponent); + case 4: return new Vector(-strength, 0); + case 5: return new Vector(-diagonalComponent, -diagonalComponent); + case 6: return new Vector(0, -strength); + case 7: return new Vector(diagonalComponent, -diagonalComponent); + default: return Vector.ZERO; + } + } + + private static boolean isDirectionValid(int direction, int sectorCount) { + if (direction < 0 || direction > 7) return false; + return sectorCount == 8 || direction % 2 == 0; + } + + private static double normalizeAngle(double angle) { + return angle < 0 ? angle + TWO_PI : angle; + } + + private static double angularDistance(double first, double second) { + double difference = Math.abs(normalizeAngle(first) - normalizeAngle(second)); + return Math.min(difference, TWO_PI - difference); + } +} diff --git a/app/src/main/java/com/winlator/widget/InputControlsView.java b/app/src/main/java/com/winlator/widget/InputControlsView.java index d37fe0dab2..44c919e868 100644 --- a/app/src/main/java/com/winlator/widget/InputControlsView.java +++ b/app/src/main/java/com/winlator/widget/InputControlsView.java @@ -58,6 +58,8 @@ public class InputControlsView extends View { private static final long SHOOTER_SPRINT_TAP_DURATION_MS = 120; public static final float DEFAULT_OVERLAY_OPACITY = 0.4f; private static final int SEQUENCE_PRESS_MS = 80; + // LX, LY, RX, RY - the leading entries of the axis array processed in processJoystickInput + private static final int PHYSICAL_STICK_AXIS_COUNT = 4; private boolean editMode = false; private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Path path = new Path(); @@ -82,6 +84,7 @@ public class InputControlsView extends View { private final Bitmap[] icons = new Bitmap[40]; private Timer mouseMoveTimer; private final PointF mouseMoveOffset = new PointF(); + private final PointF mouseMoveRemainder = new PointF(); private boolean showTouchscreenControls = true; // Shooter mode state @@ -594,7 +597,18 @@ private void createMouseMoveTimer() { mouseMoveTimer.schedule(new TimerTask() { @Override public void run() { - xServer.injectPointerMoveDelta((int)(mouseMoveOffset.x * 10 * cursorSpeed), (int)(mouseMoveOffset.y * 10 * cursorSpeed)); + if (mouseMoveOffset.x == 0f) mouseMoveRemainder.x = 0f; + if (mouseMoveOffset.y == 0f) mouseMoveRemainder.y = 0f; + if (mouseMoveOffset.x == 0f && mouseMoveOffset.y == 0f) return; + + float scaledX = mouseMoveOffset.x * 10 * cursorSpeed + mouseMoveRemainder.x; + float scaledY = mouseMoveOffset.y * 10 * cursorSpeed + mouseMoveRemainder.y; + int deltaX = (int)scaledX; + int deltaY = (int)scaledY; + mouseMoveRemainder.set(scaledX - deltaX, scaledY - deltaY); + if (deltaX != 0 || deltaY != 0) { + xServer.injectPointerMoveDelta(deltaX, deltaY); + } } }, 0, 1000 / 60); } @@ -606,7 +620,15 @@ private void processJoystickInput(ExternalController controller) { final float[] values = {controller.state.thumbLX, controller.state.thumbLY, controller.state.thumbRX, controller.state.thumbRY, controller.state.getDPadX(), controller.state.getDPadY()}; for (byte i = 0; i < axes.length; i++) { - if (Math.abs(values[i]) > ControlElement.STICK_DEAD_ZONE) { + // Tuned analog-stick values already include the user's chosen deadzone, so anything + // non-zero is live input. Untuned legacy profiles and digital hat axes retain the + // original fixed activation threshold. + boolean isStick = i < PHYSICAL_STICK_AXIS_COUNT; + boolean isActive = isStick && profile.isStickTuningConfigured() + ? values[i] != 0 + : Math.abs(values[i]) > ControlElement.STICK_DEAD_ZONE; + + if (isActive) { controllerBinding = controller.getControllerBinding(ExternalControllerBinding.getKeyCodeForAxis(axes[i], Mathf.sign(values[i]))); if (controllerBinding != null) handleInputEvent(controllerBinding.getBindingCombo(), true, values[i]); } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 9d7cc40835..c8a6597ee9 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -215,6 +215,28 @@ Venstre stick Højre stick D-Pad + Indstillinger for fysisk controller + Controllerindstillingerne kunne ikke gemmes. Prøv igen. + Venstre sticks dødzone + Højre sticks dødzone + Venstre sticks følsomhed + Højre sticks følsomhed + Hvor langt styrepinden skal bevæges, før input registreres. Øg værdien for at stoppe drift. + Skalerer delvist stickudslag. Over 1,0 kan ikke overstige gamepaddens maksimale output; fuldt output nås med mindre fysisk udslag. + Venstre sticks dødzoneform + Højre sticks dødzoneform + Måler hver akse separat. Holder vandret og lodret bevægelse lige, men diagonaler kan føles mindre jævne. + Måler afstanden fra midten som én cirkel. Bevarer stickens retning for jævn bevægelse i alle retninger. + Bruger en cirkulær dødzone og låser derefter bevægelse nær vandret og lodret. Giver både jævne diagonaler og mere lige hovedretninger. + Aksial + Cirkulær + Hybrid + Venstre sticks retningsfastlåsning + Højre sticks retningsfastlåsning + Fastlåser hele stickets output til faste retninger. 4-vejs fjerner diagonaler; 8-vejs tillader kun hovedretninger og diagonaler. + Ubegrænset + 4-vejs + 8-vejs D-Pad op D-Pad ned D-Pad venstre diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 46fe5b255d..c56ac9e7fc 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -279,6 +279,28 @@ Linker Stick Rechter Stick D-Pad + Einstellungen für physischen Controller + Die Controller-Einstellungen konnten nicht gespeichert werden. Versuche es erneut. + Totzone des linken Sticks + Totzone des rechten Sticks + Empfindlichkeit des linken Sticks + Empfindlichkeit des rechten Sticks + Legt fest, wie weit der Stick bewegt werden muss, bevor eine Eingabe erkannt wird. Erhöhen, um Driften zu verhindern. + Skaliert teilweise Stickbewegungen. Werte über 1,0 können die maximale Gamepad-Ausgabe nicht überschreiten; der volle Ausschlag wird mit weniger physischem Weg erreicht. + Totzonenform des linken Sticks + Totzonenform des rechten Sticks + Misst jede Achse getrennt. Hält horizontale und vertikale Bewegungen gerade, Diagonalen können sich jedoch weniger gleichmäßig anfühlen. + Misst den Abstand von der Mitte als Kreis. Bewahrt die Stickrichtung für gleichmäßige Bewegungen in alle Richtungen. + Verwendet eine kreisförmige Totzone und richtet Bewegungen nahe der Horizontalen und Vertikalen exakt aus. Verbindet gleichmäßige Diagonalen mit geraderen Hauptrichtungen. + Axial + Kreisförmig + Hybrid + Richtungseinrastung des linken Sticks + Richtungseinrastung des rechten Sticks + Rastet die gesamte Stick-Ausgabe auf feste Richtungen ein. 4-Wege entfernt Diagonalen; 8-Wege erlaubt nur Haupt- und Diagonalrichtungen. + Uneingeschränkt + 4-Wege + 8-Wege D-Pad hoch D-Pad runter D-Pad links diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6e4f3ae3e5..af53e1a323 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -297,6 +297,28 @@ Stick izquierdo Stick derecho Cruceta + Ajustes del mando físico + No se pudieron guardar los ajustes del mando. Inténtalo de nuevo. + Zona muerta del stick izquierdo + Zona muerta del stick derecho + Sensibilidad del stick izquierdo + Sensibilidad del stick derecho + Distancia que debe recorrer el stick antes de registrar una entrada. Auméntala para evitar el drift. + Amplifica el recorrido parcial del stick. Por encima de 1,0 no se puede superar la salida máxima del mando; se alcanza con menos recorrido físico. + Forma de zona muerta del stick izquierdo + Forma de zona muerta del stick derecho + Mide cada eje por separado. Facilita movimientos horizontales y verticales rectos, pero las diagonales pueden sentirse menos uniformes. + Mide la distancia desde el centro como un círculo. Conserva la dirección del stick para un movimiento suave y uniforme. + Usa una zona muerta circular y después fija el movimiento cerca de los ejes horizontal y vertical. Equilibra diagonales suaves con direcciones cardinales más rectas. + Axial + Circular + Híbrida + Ajuste direccional del stick izquierdo + Ajuste direccional del stick derecho + Ajusta toda la salida del stick a direcciones fijas. 4 direcciones elimina las diagonales; 8 direcciones solo permite ángulos cardinales y diagonales. + Sin restricción + 4 direcciones + 8 direcciones Cruceta arriba Cruceta abajo Cruceta izquierda diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9263dc799f..5548a08a98 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -287,6 +287,28 @@ Joystick gauche Joystick droit Croix directionnelle + Paramètres du contrôleur physique + Impossible d’enregistrer les paramètres de la manette. Réessayez. + Zone morte du joystick gauche + Zone morte du joystick droit + Sensibilité du joystick gauche + Sensibilité du joystick droit + Distance que le joystick doit parcourir avant qu’une entrée soit détectée. Augmentez-la pour supprimer la dérive. + Amplifie les déplacements partiels du stick. Au-dessus de 1,0, la sortie maximale de la manette ne peut pas être dépassée ; elle est atteinte avec moins de course physique. + Forme de la zone morte du joystick gauche + Forme de la zone morte du joystick droit + Mesure chaque axe séparément. Facilite les mouvements horizontaux et verticaux rectilignes, mais les diagonales peuvent sembler moins régulières. + Mesure la distance depuis le centre comme un cercle. Préserve la direction du stick pour des mouvements fluides et réguliers. + Utilise une zone morte circulaire, puis verrouille les mouvements proches de l’horizontale et de la verticale. Combine des diagonales fluides et des directions cardinales plus droites. + Axiale + Circulaire + Hybride + Verrouillage directionnel du joystick gauche + Verrouillage directionnel du joystick droit + Aligne toute la sortie du stick sur des directions fixes. Le mode à 4 directions supprime les diagonales ; le mode à 8 directions autorise uniquement les angles cardinaux et diagonaux. + Libre + 4 directions + 8 directions D-Pad Haut D-Pad Bas D-Pad Gauche diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 50c4ed06f9..25a3bbc019 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -286,6 +286,28 @@ Levetta Sinistra Levetta Destra D-Pad + Impostazioni controller fisico + Impossibile salvare le impostazioni del controller. Riprova. + Zona morta levetta sinistra + Zona morta levetta destra + Sensibilità levetta sinistra + Sensibilità levetta destra + Distanza che la levetta deve percorrere prima che l’input venga rilevato. Aumentala per eliminare il drift. + Amplifica gli spostamenti parziali dello stick. Oltre 1,0 non può superare l’uscita massima del gamepad; la raggiunge con una corsa fisica minore. + Forma zona morta levetta sinistra + Forma zona morta levetta destra + Misura ogni asse separatamente. Favorisce movimenti orizzontali e verticali diritti, ma le diagonali possono sembrare meno uniformi. + Misura la distanza dal centro come un unico cerchio. Mantiene la direzione dello stick per movimenti fluidi e uniformi. + Usa una zona morta circolare, poi blocca il movimento vicino agli assi orizzontale e verticale. Unisce diagonali fluide e direzioni cardinali più dritte. + Assiale + Circolare + Ibrida + Aggancio direzionale levetta sinistra + Aggancio direzionale levetta destra + Allinea l’intero output dello stick a direzioni fisse. 4 direzioni eliminano le diagonali; 8 direzioni consentono solo angoli cardinali e diagonali. + Senza limiti + 4 direzioni + 8 direzioni D-Pad Su D-Pad Giù D-Pad Sinistra diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 93884aaded..bdd5ba9227 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -240,6 +240,28 @@ 左スティック 右スティック 十字キー + 物理コントローラー設定 + コントローラー設定を保存できませんでした。もう一度お試しください。 + 左スティックのデッドゾーン + 右スティックのデッドゾーン + 左スティックの感度 + 右スティックの感度 + 入力が反応し始めるまでに必要なスティックの移動量です。ドリフトを抑えるには値を上げます。 + スティックを途中まで倒したときの入力を増幅します。1.0を超えてもゲームパッドの最大出力は超えず、より少ない物理的な倒し量で最大に達します。 + 左スティックのデッドゾーン形状 + 右スティックのデッドゾーン形状 + 各軸を別々に測定します。水平・垂直移動を直線に保ちやすい一方、斜め移動はやや不均一に感じる場合があります。 + 中心からの距離を1つの円として測定します。スティックの方向を保ち、全方向で滑らかで均一な動きになります。 + 円形デッドゾーンを使い、水平・垂直に近い動きを軸に固定します。滑らかな斜め移動と直線的な上下左右移動を両立します。 + 軸別 + 円形 + ハイブリッド + 左スティックの方向スナップ + 右スティックの方向スナップ + スティック出力全体を固定方向に揃えます。4方向では斜めを除外し、8方向では上下左右と斜めの角度だけを使用します。 + 制限なし + 4方向 + 8方向 十字キー上 十字キー下 十字キー左 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 047ae1c0e1..5e0186168e 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -294,6 +294,28 @@ 왼쪽 스틱 오른쪽 스틱 십자 버튼 + 물리 컨트롤러 설정 + 컨트롤러 설정을 저장하지 못했습니다. 다시 시도하세요. + 왼쪽 스틱 데드존 + 오른쪽 스틱 데드존 + 왼쪽 스틱 감도 + 오른쪽 스틱 감도 + 입력이 감지되기 전에 스틱을 움직여야 하는 거리입니다. 드리프트를 멈추려면 값을 높이세요. + 스틱을 일부만 기울였을 때의 입력을 확대합니다. 1.0을 넘어도 게임패드 최대 출력을 초과하지 않으며, 더 적게 기울여도 최대 출력에 도달합니다. + 왼쪽 스틱 데드존 형태 + 오른쪽 스틱 데드존 형태 + 각 축을 따로 측정합니다. 수평·수직 이동을 곧게 유지하기 쉽지만 대각선 이동은 덜 균일하게 느껴질 수 있습니다. + 중심에서의 거리를 하나의 원으로 측정합니다. 스틱 방향을 유지해 모든 방향에서 부드럽고 균일하게 움직입니다. + 원형 데드존을 사용한 뒤 수평·수직에 가까운 움직임을 축에 고정합니다. 부드러운 대각선과 곧은 상하좌우 이동을 함께 제공합니다. + 축별 + 원형 + 하이브리드 + 왼쪽 스틱 방향 스냅 + 오른쪽 스틱 방향 스냅 + 전체 스틱 출력을 고정된 방향으로 맞춥니다. 4방향은 대각선을 제거하고, 8방향은 상하좌우와 대각선 각도만 허용합니다. + 제한 없음 + 4방향 + 8방향 십자 버튼 위 십자 버튼 아래 십자 버튼 왼쪽 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index aa9fe92bd9..a809160990 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -294,6 +294,28 @@ Lewy drążek Prawy drążek D-Pad + Ustawienia kontrolera fizycznego + Nie udało się zapisać ustawień kontrolera. Spróbuj ponownie. + Martwa strefa lewego drążka + Martwa strefa prawego drążka + Czułość lewego drążka + Czułość prawego drążka + Określa, jak daleko trzeba wychylić drążek, zanim zostanie wykryty ruch. Zwiększ wartość, aby usunąć dryf. + Skaluje częściowe wychylenie drążka. Wartości powyżej 1,0 nie przekroczą maksymalnego sygnału kontrolera; pełny sygnał jest osiągany przy mniejszym wychyleniu fizycznym. + Kształt martwej strefy lewego drążka + Kształt martwej strefy prawego drążka + Mierzy każdą oś osobno. Ułatwia proste ruchy poziome i pionowe, ale ruch po przekątnej może być mniej równomierny. + Mierzy odległość od środka jako jeden okrąg. Zachowuje kierunek drążka, zapewniając płynny i równomierny ruch. + Używa okrągłej martwej strefy, a następnie blokuje ruch w pobliżu osi poziomej i pionowej. Łączy płynne przekątne z prostszymi kierunkami głównymi. + Osiowa + Kołowa + Hybrydowa + Przyciąganie kierunkowe lewego drążka + Przyciąganie kierunkowe prawego drążka + Przyciąga cały sygnał drążka do stałych kierunków. Tryb 4-kierunkowy usuwa przekątne; 8-kierunkowy pozwala tylko na kierunki główne i przekątne. + Bez ograniczeń + 4 kierunki + 8 kierunków D-Pad Góra D-Pad Dół D-Pad Lewo diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 2a6422e030..e870a54941 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -215,6 +215,28 @@ Analógico Esquerdo Analógico Direito D-Pad + Configurações do controle físico + Não foi possível salvar as configurações do controle. Tente novamente. + Zona morta do analógico esquerdo + Zona morta do analógico direito + Sensibilidade do analógico esquerdo + Sensibilidade do analógico direito + Define o quanto o analógico deve se mover antes de registrar uma entrada. Aumente para eliminar o drift. + Amplifica o movimento parcial do analógico. Acima de 1,0 não ultrapassa a saída máxima do controle; ela é atingida com menos movimento físico. + Formato da zona morta do analógico esquerdo + Formato da zona morta do analógico direito + Mede cada eixo separadamente. Facilita movimentos horizontais e verticais retos, mas as diagonais podem parecer menos uniformes. + Mede a distância do centro como um círculo. Preserva a direção do analógico para movimentos suaves e uniformes. + Usa uma zona morta circular e depois trava o movimento perto dos eixos horizontal e vertical. Equilibra diagonais suaves com direções cardeais mais retas. + Axial + Circular + Híbrido + Encaixe direcional do analógico esquerdo + Encaixe direcional do analógico direito + Ajusta toda a saída do analógico para direções fixas. 4 direções removem diagonais; 8 direções permitem apenas ângulos cardeais e diagonais. + Livre + 4 direções + 8 direções D-Pad Cima D-Pad Baixo D-Pad Esquerda diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 57ed8085ed..98134f8faa 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -284,6 +284,28 @@ Stick stânga Stick dreapta D‑Pad + Setări controller fizic + Setările controlerului nu au putut fi salvate. Încercați din nou. + Zona moartă a stickului stâng + Zona moartă a stickului drept + Sensibilitatea stickului stâng + Sensibilitatea stickului drept + Cât de mult trebuie mișcat stickul înainte ca intrarea să fie înregistrată. Mărește valoarea pentru a opri devierea. + Amplifică mișcarea parțială a stickului. Peste 1,0 nu poate depăși ieșirea maximă a controlerului; aceasta este atinsă cu o cursă fizică mai mică. + Forma zonei moarte a stickului stâng + Forma zonei moarte a stickului drept + Măsoară fiecare axă separat. Ajută mișcarea orizontală și verticală să rămână dreaptă, dar diagonalele pot părea mai puțin uniforme. + Măsoară distanța față de centru ca un singur cerc. Păstrează direcția stickului pentru o mișcare lină și uniformă. + Folosește o zonă moartă circulară, apoi blochează mișcarea aproape de orizontală și verticală. Echilibrează diagonalele line cu direcții cardinale mai drepte. + Axială + Circulară + Hibridă + Fixare direcțională stick stâng + Fixare direcțională stick drept + Fixează întreaga ieșire a stickului pe direcții prestabilite. Modul cu 4 direcții elimină diagonalele; cel cu 8 permite doar direcții cardinale și diagonale. + Nerestricționat + 4 direcții + 8 direcții D‑Pad Sus D‑Pad Jos D‑Pad Stânga diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index fd9fdf6950..21d2c952ce 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -399,6 +399,28 @@ Установить приложение Загрузка... D-Pad + Настройки физического контроллера + Не удалось сохранить настройки контроллера. Повторите попытку. + Мёртвая зона левого джойстика + Мёртвая зона правого джойстика + Чувствительность левого джойстика + Чувствительность правого джойстика + Определяет, насколько нужно отклонить стик, прежде чем ввод будет зарегистрирован. Увеличьте значение, чтобы устранить дрейф. + Усиливает частичное отклонение стика. Значение выше 1,0 не превышает максимум геймпада, а достигает его при меньшем физическом отклонении. + Форма мёртвой зоны левого джойстика + Форма мёртвой зоны правого джойстика + Измеряет каждую ось отдельно. Упрощает прямое движение по горизонтали и вертикали, но диагонали могут ощущаться менее равномерными. + Измеряет расстояние от центра как единый круг. Сохраняет направление стика для плавного и равномерного движения. + Использует круглую мёртвую зону, затем фиксирует движение рядом с горизонталью и вертикалью. Сочетает плавные диагонали с более прямыми основными направлениями. + Осевая + Круговая + Гибридная + Фиксация направлений левого джойстика + Фиксация направлений правого джойстика + Привязывает весь сигнал стика к фиксированным направлениям. 4 направления исключают диагонали; 8 направлений допускают только основные и диагональные углы. + Без ограничений + 4 направления + 8 направлений D-Pad вниз D-Pad влево D-Pad вправо diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index fd88490174..10e485a193 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -281,6 +281,28 @@ Лівий стік Правий стік D-Pad + Налаштування фізичного контролера + Не вдалося зберегти налаштування контролера. Спробуйте ще раз. + Мертва зона лівого стіка + Мертва зона правого стіка + Чутливість лівого стіка + Чутливість правого стіка + Визначає, наскільки потрібно відхилити стік, перш ніж введення буде зареєстровано. Збільште значення, щоб усунути дрейф. + Підсилює часткове відхилення стіка. Значення понад 1,0 не перевищує максимум геймпада, а досягає його за меншого фізичного відхилення. + Форма мертвої зони лівого стіка + Форма мертвої зони правого стіка + Вимірює кожну вісь окремо. Полегшує прямий горизонтальний і вертикальний рух, але діагоналі можуть відчуватися менш рівномірними. + Вимірює відстань від центра як єдине коло. Зберігає напрямок стіка для плавного й рівномірного руху. + Використовує кругову мертву зону, а потім фіксує рух поблизу горизонталі й вертикалі. Поєднує плавні діагоналі з прямішими основними напрямками. + Осьова + Кругова + Гібридна + Фіксація напрямків лівого стіка + Фіксація напрямків правого стіка + Прив’язує весь сигнал стіка до фіксованих напрямків. 4 напрямки усувають діагоналі; 8 напрямків дозволяють лише основні та діагональні кути. + Без обмежень + 4 напрямки + 8 напрямків D-Pad вгору D-Pad вниз D-Pad вліво diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 445c7897c9..17c85ad467 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -280,6 +280,28 @@ 左摇杆 右摇杆 方向键 + 实体控制器设置 + 无法保存控制器设置。请重试。 + 左摇杆死区 + 右摇杆死区 + 左摇杆灵敏度 + 右摇杆灵敏度 + 摇杆移动多远后才会检测到输入。调高可消除漂移。 + 放大摇杆的部分行程。高于 1.0 也不会超过手柄最大输出,而是用更小的物理行程达到最大输出。 + 左摇杆死区形状 + 右摇杆死区形状 + 分别测量每个轴。更容易保持水平和垂直直线移动,但斜向移动可能不够均匀。 + 将距中心的距离作为一个圆来测量。保留摇杆方向,使各方向移动平滑均匀。 + 先使用圆形死区,再将接近水平或垂直的移动锁定到轴向。兼顾平滑斜向移动和更直的上下左右移动。 + 轴向 + 圆形 + 混合 + 左摇杆方向吸附 + 右摇杆方向吸附 + 将整个摇杆输出吸附到固定方向。4 向会移除斜向;8 向仅允许上下左右和斜向角度。 + 不限制 + 4向 + 8向 方向键 上 方向键 下 方向键 左 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 230f3c491f..61c7294f55 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -280,6 +280,28 @@ 左搖桿 右搖桿 方向鍵 + 實體控制器設定 + 無法儲存控制器設定。請再試一次。 + 左搖桿死區 + 右搖桿死區 + 左搖桿靈敏度 + 右搖桿靈敏度 + 搖桿移動多遠後才會偵測到輸入。調高可消除飄移。 + 放大搖桿的部分行程。高於 1.0 也不會超過控制器最大輸出,而是用較小的實際行程達到最大輸出。 + 左搖桿死區形狀 + 右搖桿死區形狀 + 分別測量每個軸。更容易維持水平和垂直直線移動,但斜向移動可能較不均勻。 + 將距中心的距離視為一個圓來測量。保留搖桿方向,使各方向移動平順均勻。 + 先使用圓形死區,再將接近水平或垂直的移動鎖定到軸向。兼顧平順斜向移動和更直的上下左右移動。 + 軸向 + 圓形 + 混合 + 左搖桿方向吸附 + 右搖桿方向吸附 + 將整個搖桿輸出吸附到固定方向。4 向會移除斜向;8 向僅允許上下左右和斜向角度。 + 不限制 + 4向 + 8向 方向鍵 上 方向鍵 下 方向鍵 左 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4081c0fdd8..dfe2b09c44 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -274,6 +274,28 @@ Left Stick Right Stick D-Pad + Physical Controller Settings + Couldn’t save controller settings. Try again. + Left Stick Deadzone + Right Stick Deadzone + Left Stick Sensitivity + Right Stick Sensitivity + How far the stick must move before input registers. Raise it to stop drift. + Scales partial stick movement. Above 1.0 cannot exceed full gamepad output; it reaches full output with less physical travel. + Left Stick Deadzone Shape + Right Stick Deadzone Shape + Measures each axis separately. Helps horizontal and vertical movement stay straight, but diagonals may feel less even. + Measures distance from the center as one circle. Preserves the stick’s direction for smooth, even movement. + Uses a circular deadzone, then locks movement near horizontal and vertical. Balances smooth diagonals with straighter cardinal movement. + Axial + Circular + Hybrid + Left Stick Directional Snapping + Right Stick Directional Snapping + Snaps the complete stick output to fixed directions. 4-way removes diagonals; 8-way allows only cardinal and diagonal angles. + Unrestricted + 4-way + 8-way D-Pad Up D-Pad Down D-Pad Left diff --git a/app/src/test/java/app/gamenative/inputcontrols/ControlProfileServiceTest.kt b/app/src/test/java/app/gamenative/inputcontrols/ControlProfileServiceTest.kt index 1d62bbad52..1e2fc83121 100644 --- a/app/src/test/java/app/gamenative/inputcontrols/ControlProfileServiceTest.kt +++ b/app/src/test/java/app/gamenative/inputcontrols/ControlProfileServiceTest.kt @@ -108,6 +108,109 @@ class ControlProfileServiceTest { } } + @Test + fun physicalStickTuning_roundTripsThroughLibraryAndLegacyApplyClearsIt() { + val context = ApplicationProvider.getApplicationContext() + val manager = InputControlsManager(context) + val root = Files.createTempDirectory("profile-stick-tuning").toFile() + val container = Container("stick-tuning-game").apply { rootDir = root } + val ids = mutableSetOf() + + try { + var library = ControlProfileService.createBlank(context, manager, "Tuned controller").also { ids += it.id } + var working = ControlProfileService.applyProfile(context, container, manager, library).also { ids += it.id } + working.leftStickDeadzone = 0.2f + working.rightStickDeadzone = 0.3f + working.leftStickSensitivity = 1.5f + working.rightStickSensitivity = 2.25f + working.leftStickDeadzoneMode = ControlsProfile.StickDeadzoneMode.CIRCULAR + working.rightStickDeadzoneMode = ControlsProfile.StickDeadzoneMode.HYBRID + working.leftStickDigitalMode = ControlsProfile.StickDigitalMode.FOUR_WAY + working.rightStickDigitalMode = ControlsProfile.StickDigitalMode.EIGHT_WAY + assertTrue(working.save()) + + library = ControlProfileService.updateFromCurrent( + context, + container, + manager, + library, + setOf(ControlProfileSection.PHYSICAL_CONTROLLER), + ) + assertEquals( + library.id, + ControlProfileService.appliedSectionSources(context, container, manager)[ControlProfileSection.PHYSICAL_CONTROLLER], + ) + + working = manager.getProfile(working.id)!! + working.rightStickSensitivity = 2.5f + assertTrue(working.save()) + assertFalse( + ControlProfileService.appliedSectionSources(context, container, manager) + .containsKey(ControlProfileSection.PHYSICAL_CONTROLLER), + ) + library = ControlProfileService.updateFromCurrent( + context, + container, + manager, + library, + setOf(ControlProfileSection.PHYSICAL_CONTROLLER), + ) + + val exportUri = Uri.fromFile(root.resolve("tuned-controller.icp")) + ControlProfileService.exportProfile( + context, + library, + setOf(ControlProfileSection.PHYSICAL_CONTROLLER), + exportUri, + ) + val importedPreview = ControlProfileService.importProfile(context, exportUri) + assertEquals(0.2, importedPreview.json.getDouble(ControlsProfile.KEY_LEFT_STICK_DEADZONE), 0.0001) + assertEquals(2.5, importedPreview.json.getDouble(ControlsProfile.KEY_RIGHT_STICK_SENSITIVITY), 0.0001) + assertEquals("four_way", importedPreview.json.getString(ControlsProfile.KEY_LEFT_STICK_DIGITAL_MODE)) + + val imported = ControlProfileService.installImported(manager, importedPreview).also { ids += it.id } + assertEquals(0.2f, imported.leftStickDeadzone, 0.0001f) + assertEquals(2.5f, imported.rightStickSensitivity, 0.0001f) + assertEquals(ControlsProfile.StickDeadzoneMode.HYBRID, imported.rightStickDeadzoneMode) + assertEquals(ControlsProfile.StickDigitalMode.EIGHT_WAY, imported.rightStickDigitalMode) + + val duplicate = manager.duplicateProfile(library).also { ids += it.id } + assertEquals(0.2f, duplicate.leftStickDeadzone, 0.0001f) + assertEquals(2.5f, duplicate.rightStickSensitivity, 0.0001f) + + val legacyId = manager.nextProfileId() + ids += legacyId + assertTrue( + FileUtils.writeString( + ControlsProfile.getProfileFile(context, legacyId), + JSONObject().apply { + put("id", legacyId) + put("name", "Untuned controller") + put("schemaVersion", ControlProfileService.SCHEMA_VERSION) + put("includedSections", JSONArray().put("physicalController")) + put("controllers", JSONArray()) + put("listed", true) + }.toString(), + ), + ) + manager.reloadProfiles() + val cleared = ControlProfileService.applyProfile( + context, + container, + manager, + manager.getProfile(legacyId)!!, + setOf(ControlProfileSection.PHYSICAL_CONTROLLER), + ) + val clearedJson = ControlProfileService.readProfileJson(context, cleared) + assertFalse(cleared.isStickTuningConfigured) + assertFalse(clearedJson.has(ControlsProfile.KEY_LEFT_STICK_DEADZONE)) + assertFalse(clearedJson.has(ControlsProfile.KEY_RIGHT_STICK_SENSITIVITY)) + } finally { + ids.forEach { ControlsProfile.getProfileFile(context, it).delete() } + root.deleteRecursively() + } + } + @Test fun runtimeSaveKeepsExplicitEmptyCategoriesValid() { val context = ApplicationProvider.getApplicationContext() @@ -302,10 +405,13 @@ class ControlProfileServiceTest { val json = """{ "id":42, "name":"Working copy", + "leftStickSensitivity":1.5, + "elements":[], "listed":false, "libraryProfileId":7, + "sectionSources":{"physicalController":9}, "gameOwnerId":"game-123", - "elements":[] + "rightStickDigitalMode":"eight_way" }""".trimIndent() val profile = InputControlsManager.loadProfile( @@ -318,6 +424,9 @@ class ControlProfileServiceTest { assertFalse(profile.isListed) assertEquals(7, profile.libraryProfileId) assertEquals("game-123", profile.gameOwnerId) + assertEquals(1.5f, profile.leftStickSensitivity, 0.0001f) + assertEquals(ControlsProfile.StickDigitalMode.EIGHT_WAY, profile.rightStickDigitalMode) + assertTrue(profile.isStickTuningConfigured) } @Test diff --git a/app/src/test/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandlerTest.kt b/app/src/test/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandlerTest.kt index 765e309a2a..5ed6dfd560 100644 --- a/app/src/test/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandlerTest.kt +++ b/app/src/test/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandlerTest.kt @@ -29,6 +29,87 @@ import java.util.concurrent.TimeUnit.MILLISECONDS @RunWith(RobolectricTestRunner::class) class PhysicalControllerHandlerTest { + @Test + fun `cardinal stick output activates only the selected digital binding`() { + val deviceId = 42 + val rightSource = ExternalControllerBinding.getKeyCodeForAxis(MotionEvent.AXIS_X, 1) + val downSource = ExternalControllerBinding.getKeyCodeForAxis(MotionEvent.AXIS_Y, 1) + val controller = motionController(rightSource, Binding.GAMEPAD_DPAD_RIGHT).apply { + addControllerBinding( + ExternalControllerBinding().apply { + setKeyCode(downSource) + setBinding(Binding.GAMEPAD_DPAD_DOWN) + }, + ) + } + val gamepadState = GamepadState() + val profile = mock() + whenever(profile.getController(deviceId)).thenReturn(controller) + whenever(profile.gamepadState).thenReturn(gamepadState) + val handler = PhysicalControllerHandler(profile, mock(), gamepadStateSender = {}) + val event = motionEvent(deviceId) + + try { + controller.state.thumbLX = 0.8f + controller.state.thumbLY = 0f + assertTrue(handler.onGenericMotionEvent(event)) + assertTrue(gamepadState.dpad[1]) + assertFalse(gamepadState.dpad[2]) + + controller.state.thumbLX = 0f + controller.state.thumbLY = 0.8f + assertTrue(handler.onGenericMotionEvent(event)) + assertFalse(gamepadState.dpad[1]) + assertTrue(gamepadState.dpad[2]) + } finally { + handler.cleanup() + } + } + + @Test + fun `sub-pixel stick mouse movement accumulates into pointer output`() { + val deviceId = 42 + val rightSource = ExternalControllerBinding.getKeyCodeForAxis(MotionEvent.AXIS_X, 1) + val controller = motionController(rightSource, Binding.MOUSE_MOVE_RIGHT).apply { + state.thumbLX = 0.05f + } + val profile = mock() + whenever(profile.getController(deviceId)).thenReturn(controller) + whenever(profile.isStickTuningConfigured).thenReturn(true) + whenever(profile.cursorSpeed).thenReturn(1f) + val xServer = mock() + val handler = PhysicalControllerHandler(profile, xServer, gamepadStateSender = {}) + + try { + assertTrue(handler.onGenericMotionEvent(motionEvent(deviceId))) + verify(xServer, org.mockito.Mockito.timeout(500).atLeastOnce()) + .injectPointerMoveDelta(1, 0) + } finally { + handler.cleanup() + } + } + + @Test + fun `untuned legacy profile retains the original stick binding threshold`() { + val deviceId = 42 + val rightSource = ExternalControllerBinding.getKeyCodeForAxis(MotionEvent.AXIS_X, 1) + val controller = motionController(rightSource, Binding.GAMEPAD_DPAD_RIGHT).apply { + state.thumbLX = 0.05f + } + val gamepadState = GamepadState() + val profile = mock() + whenever(profile.getController(deviceId)).thenReturn(controller) + whenever(profile.gamepadState).thenReturn(gamepadState) + val handler = PhysicalControllerHandler(profile, mock(), gamepadStateSender = {}) + + try { + assertTrue(handler.onGenericMotionEvent(motionEvent(deviceId))) + assertFalse(gamepadState.dpad[1]) + } finally { + handler.cleanup() + } + } + @Test fun `vertical physical stick release clears gyro mixed gamepad state`() { val deviceId = 42 @@ -147,6 +228,38 @@ class PhysicalControllerHandlerTest { } } + @Test + fun `radial menu accepts tuned stick movement below the legacy deadzone`() { + val deviceId = 42 + val controller = motionController(KeyEvent.KEYCODE_BUTTON_A, Binding.OPEN_RADIAL_MENU).apply { + state.thumbRX = 0.05f + } + val profile = mock() + whenever(profile.getController(deviceId)).thenReturn(controller) + whenever(profile.isStickTuningConfigured).thenReturn(true) + val vectors = mutableListOf>() + val handler = PhysicalControllerHandler( + profile = profile, + xServer = mock(), + onRadialMenuVectorChanged = { x, y -> vectors.add(x to y) }, + ) + val openEvent = mock() + whenever(openEvent.repeatCount).thenReturn(0) + whenever(openEvent.deviceId).thenReturn(deviceId) + whenever(openEvent.keyCode).thenReturn(KeyEvent.KEYCODE_BUTTON_A) + whenever(openEvent.action).thenReturn(KeyEvent.ACTION_DOWN) + + try { + assertTrue(handler.onKeyEvent(openEvent)) + assertTrue(handler.onGenericMotionEvent(motionEvent(deviceId))) + + assertEquals(0.05f, vectors.last().first, 0f) + assertEquals(0f, vectors.last().second, 0f) + } finally { + handler.cleanup() + } + } + @Test fun `removing a controller releases its held digital bindings`() { val deviceId = 42 diff --git a/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt b/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt new file mode 100644 index 0000000000..a275a21e8b --- /dev/null +++ b/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt @@ -0,0 +1,378 @@ +package com.winlator.inputcontrols + +import android.content.Context +import app.gamenative.ui.component.dialog.applyAndSavePhysicalControllerTuning +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import java.io.ByteArrayInputStream + +@RunWith(RobolectricTestRunner::class) +class PhysicalControllerStickTuningTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private lateinit var context: Context + + @Before + fun setUp() { + context = mock() + whenever(context.filesDir).thenReturn(temporaryFolder.root) + } + + @Test + fun `deadzone supports the full zero to one range and clamps profile values`() { + val profile = ControlsProfile(context, 1) + + profile.leftStickDeadzone = -0.5f + profile.rightStickDeadzone = 1.5f + + assertEquals(0f, profile.leftStickDeadzone, 0f) + assertEquals(1f, profile.rightStickDeadzone, 0f) + assertEquals(0.42f, ControlsProfile.applyStickDeadzone(0.42f, 0f), 0.0001f) + assertEquals(0f, ControlsProfile.applyStickDeadzone(1f, 1f), 0f) + } + + @Test + fun `axial deadzone retains existing behavior and sensitivity saturates output`() { + assertEquals(0f, ControlsProfile.applyStickDeadzone(0.2f, 0.2f), 0f) + assertEquals(0.5f, ControlsProfile.applyStickDeadzone(0.6f, 0.2f), 0.0001f) + assertEquals(-0.5f, ControlsProfile.applyStickDeadzone(-0.6f, 0.2f), 0.0001f) + val tuned = StickVectorProcessor.tune( + 0.6f, + -0.6f, + 0.2f, + 1.5f, + ControlsProfile.StickDeadzoneMode.AXIAL, + ) + assertEquals(0.75f, tuned.x, 0.0001f) + assertEquals(-0.75f, tuned.y, 0.0001f) + assertEquals( + 1f, + StickVectorProcessor.tune( + 0.6f, + 0f, + 0.2f, + 3f, + ControlsProfile.StickDeadzoneMode.AXIAL, + ).x, + 0f, + ) + assertEquals( + 0f, + StickVectorProcessor.tune( + Float.NaN, + 0f, + 0.2f, + 1f, + ControlsProfile.StickDeadzoneMode.AXIAL, + ).x, + 0f, + ) + } + + @Test + fun `circular deadzone rescales magnitude and preserves direction`() { + val tuned = StickVectorProcessor.tune( + 0.3f, + 0.4f, + 0.2f, + 1f, + ControlsProfile.StickDeadzoneMode.CIRCULAR, + ) + assertEquals(0.225f, tuned.x, 0.0001f) + assertEquals(0.3f, tuned.y, 0.0001f) + + val atBoundary = StickVectorProcessor.tune( + 0.3f, + 0.4f, + 0.5f, + 1f, + ControlsProfile.StickDeadzoneMode.CIRCULAR, + ) + assertEquals(0f, atBoundary.x, 0f) + assertEquals(0f, atBoundary.y, 0f) + } + + @Test + fun `circular sensitivity clamps to the unit circle`() { + val tuned = StickVectorProcessor.tune( + 0.6f, + 0.8f, + 0f, + 2f, + ControlsProfile.StickDeadzoneMode.CIRCULAR, + ) + assertEquals(0.6f, tuned.x, 0.0001f) + assertEquals(0.8f, tuned.y, 0.0001f) + assertEquals(1.0, Math.hypot(tuned.x.toDouble(), tuned.y.toDouble()), 0.0001) + } + + @Test + fun `sensitivity above one amplifies partial travel in serialized gamepad output`() { + val normal = StickVectorProcessor.tune( + 0.4f, + 0f, + 0f, + 1f, + ControlsProfile.StickDeadzoneMode.CIRCULAR, + ) + val amplified = StickVectorProcessor.tune( + 0.4f, + 0f, + 0f, + 2f, + ControlsProfile.StickDeadzoneMode.CIRCULAR, + ) + + assertEquals(0.4f, normal.x, 0.0001f) + assertEquals(0.8f, amplified.x, 0.0001f) + assertTrue(GamepadState.encodeThumbAxis(amplified.x) > GamepadState.encodeThumbAxis(normal.x)) + } + + @Test + fun `hybrid deadzone creates axial corridors while retaining diagonals`() { + val nearHorizontal = StickVectorProcessor.tune( + 0.8f, + 0.05f, + 0.2f, + 1f, + ControlsProfile.StickDeadzoneMode.HYBRID, + ) + assertTrue(nearHorizontal.x > 0f) + assertEquals(0f, nearHorizontal.y, 0f) + + val diagonal = StickVectorProcessor.tune( + 0.6f, + 0.6f, + 0.2f, + 1f, + ControlsProfile.StickDeadzoneMode.HYBRID, + ) + assertTrue(diagonal.x > 0f) + assertTrue(diagonal.y > 0f) + assertEquals(diagonal.x, diagonal.y, 0.0001f) + } + + @Test + fun `four way snapping uses cardinal sectors with angular hysteresis`() { + val mode = ControlsProfile.StickDigitalMode.FOUR_WAY + val right = StickVectorProcessor.snapDirection( + vectorX(44.0), + vectorY(44.0), + mode, + StickVectorProcessor.DIRECTION_NONE, + ) + assertEquals(0, right) + val snapped = StickVectorProcessor.snapToDirection(0.48f, 0.36f, right) + assertEquals(0.6f, snapped.x, 0.0001f) + assertEquals(0f, snapped.y, 0f) + assertEquals( + right, + StickVectorProcessor.snapDirection(vectorX(48.0), vectorY(48.0), mode, right), + ) + assertEquals( + 2, + StickVectorProcessor.snapDirection(vectorX(52.0), vectorY(52.0), mode, right), + ) + } + + @Test + fun `eight way snapping emits diagonals and unrestricted mode does not snap`() { + val diagonal = StickVectorProcessor.snapDirection( + vectorX(30.0), + vectorY(30.0), + ControlsProfile.StickDigitalMode.EIGHT_WAY, + StickVectorProcessor.DIRECTION_NONE, + ) + assertEquals(1, diagonal) + val snapped = StickVectorProcessor.snapToDirection(0.48f, 0.36f, diagonal) + assertEquals(0.4243f, snapped.x, 0.0001f) + assertEquals(0.4243f, snapped.y, 0.0001f) + assertEquals( + StickVectorProcessor.DIRECTION_NONE, + StickVectorProcessor.snapDirection( + 1f, + 1f, + ControlsProfile.StickDigitalMode.UNRESTRICTED, + diagonal, + ), + ) + } + + @Test + fun `external controller snaps complete stick output and preserves strength`() { + val controller = ExternalController() + val input = StickVectorProcessor.Vector(0.48f, 0.36f) + + val fourWay = controller.applyDirectionSnapping( + input, + ControlsProfile.StickDigitalMode.FOUR_WAY, + false, + ) + assertEquals(0.6f, fourWay.x, 0.0001f) + assertEquals(0f, fourWay.y, 0f) + + val eightWay = controller.applyDirectionSnapping( + input, + ControlsProfile.StickDigitalMode.EIGHT_WAY, + false, + ) + assertEquals(0.4243f, eightWay.x, 0.0001f) + assertEquals(0.4243f, eightWay.y, 0.0001f) + + val unrestricted = controller.applyDirectionSnapping( + input, + ControlsProfile.StickDigitalMode.UNRESTRICTED, + false, + ) + assertEquals(input.x, unrestricted.x, 0f) + assertEquals(input.y, unrestricted.y, 0f) + } + + @Test + fun `an unreported Joy-Con axis retains its raw value`() { + assertEquals(0.65f, ExternalController.resolveRawAxis(false, 0.65f, -0.6f), 0f) + assertEquals(-0.6f, ExternalController.resolveRawAxis(true, 0.65f, -0.6f), 0f) + } + + @Test + fun `legacy profiles keep defaults and fields are read independent of JSON order`() { + val legacy = load("""{"id":7,"name":"Legacy","elements":[]}""") + assertEquals(1f, legacy.cursorSpeed, 0f) + assertEquals(ControlsProfile.DEFAULT_STICK_DEADZONE, legacy.leftStickDeadzone, 0f) + assertEquals(ControlsProfile.DEFAULT_STICK_DEADZONE, legacy.rightStickDeadzone, 0f) + assertEquals(ControlsProfile.DEFAULT_STICK_SENSITIVITY, legacy.leftStickSensitivity, 0f) + assertEquals(ControlsProfile.DEFAULT_STICK_SENSITIVITY, legacy.rightStickSensitivity, 0f) + assertEquals(ControlsProfile.DEFAULT_STICK_DEADZONE_MODE, legacy.leftStickDeadzoneMode) + assertEquals(ControlsProfile.DEFAULT_STICK_DEADZONE_MODE, legacy.rightStickDeadzoneMode) + assertEquals(ControlsProfile.DEFAULT_STICK_DIGITAL_MODE, legacy.leftStickDigitalMode) + assertEquals(ControlsProfile.DEFAULT_STICK_DIGITAL_MODE, legacy.rightStickDigitalMode) + assertFalse(legacy.isStickTuningConfigured) + + legacy.save() + assertFalse( + ControlsProfile.getProfileFile(context, legacy.id).readText().contains("leftStickDeadzone"), + ) + + val reordered = load( + """{ + "elements":[], + "controllers":[], + "id":8, + "name":"Reordered", + "rightStickSensitivity":1.8, + "leftStickDeadzone":0.05, + "rightStickDeadzone":0.3, + "leftStickSensitivity":1.2, + "rightStickDeadzoneMode":"hybrid", + "leftStickDeadzoneMode":"circular", + "rightStickDigitalMode":"eight_way", + "leftStickDigitalMode":"four_way", + "cursorSpeed":1.4 + }""".trimIndent(), + ) + assertEquals(1.4f, reordered.cursorSpeed, 0.0001f) + assertEquals(0.05f, reordered.leftStickDeadzone, 0.0001f) + assertEquals(0.3f, reordered.rightStickDeadzone, 0.0001f) + assertEquals(1.2f, reordered.leftStickSensitivity, 0.0001f) + assertEquals(1.8f, reordered.rightStickSensitivity, 0.0001f) + assertEquals(ControlsProfile.StickDeadzoneMode.CIRCULAR, reordered.leftStickDeadzoneMode) + assertEquals(ControlsProfile.StickDeadzoneMode.HYBRID, reordered.rightStickDeadzoneMode) + assertEquals(ControlsProfile.StickDigitalMode.FOUR_WAY, reordered.leftStickDigitalMode) + assertEquals(ControlsProfile.StickDigitalMode.EIGHT_WAY, reordered.rightStickDigitalMode) + assertTrue(reordered.isStickTuningConfigured) + } + + @Test + fun `stick tuning persists through profile save and load`() { + val profile = ControlsProfile(context, 9).apply { + name = "Tuned" + leftStickDeadzone = 0f + rightStickDeadzone = 0.35f + leftStickSensitivity = 0.7f + rightStickSensitivity = 2.4f + leftStickDeadzoneMode = ControlsProfile.StickDeadzoneMode.CIRCULAR + rightStickDeadzoneMode = ControlsProfile.StickDeadzoneMode.HYBRID + leftStickDigitalMode = ControlsProfile.StickDigitalMode.FOUR_WAY + rightStickDigitalMode = ControlsProfile.StickDigitalMode.EIGHT_WAY + save() + } + + val loaded = checkNotNull( + InputControlsManager.loadProfile(context, ControlsProfile.getProfileFile(context, profile.id)), + ) + assertEquals(0f, loaded.leftStickDeadzone, 0f) + assertEquals(0.35f, loaded.rightStickDeadzone, 0.0001f) + assertEquals(0.7f, loaded.leftStickSensitivity, 0.0001f) + assertEquals(2.4f, loaded.rightStickSensitivity, 0.0001f) + assertEquals(ControlsProfile.StickDeadzoneMode.CIRCULAR, loaded.leftStickDeadzoneMode) + assertEquals(ControlsProfile.StickDeadzoneMode.HYBRID, loaded.rightStickDeadzoneMode) + assertEquals(ControlsProfile.StickDigitalMode.FOUR_WAY, loaded.leftStickDigitalMode) + assertEquals(ControlsProfile.StickDigitalMode.EIGHT_WAY, loaded.rightStickDigitalMode) + assertTrue(loaded.isStickTuningConfigured) + } + + @Test + fun `settings save restores tuning and opt-in state only on failure`() { + val configured = ControlsProfile(context, 10).apply { + leftStickDeadzone = 0.2f + rightStickDigitalMode = ControlsProfile.StickDigitalMode.EIGHT_WAY + } + assertFalse( + applyAndSavePhysicalControllerTuning( + profile = configured, + applyTuning = { + configured.leftStickDeadzone = 0.7f + configured.rightStickDigitalMode = ControlsProfile.StickDigitalMode.FOUR_WAY + }, + onSave = { false }, + ), + ) + assertEquals(0.2f, configured.leftStickDeadzone, 0f) + assertEquals(ControlsProfile.StickDigitalMode.EIGHT_WAY, configured.rightStickDigitalMode) + assertTrue(configured.isStickTuningConfigured) + + val legacy = ControlsProfile(context, 11) + assertFalse( + applyAndSavePhysicalControllerTuning( + profile = legacy, + applyTuning = { legacy.leftStickSensitivity = 2f }, + onSave = { false }, + ), + ) + assertEquals(ControlsProfile.DEFAULT_STICK_SENSITIVITY, legacy.leftStickSensitivity, 0f) + assertFalse(legacy.isStickTuningConfigured) + + assertTrue( + applyAndSavePhysicalControllerTuning( + profile = legacy, + applyTuning = { legacy.leftStickSensitivity = 2f }, + onSave = { true }, + ), + ) + assertEquals(2f, legacy.leftStickSensitivity, 0f) + assertTrue(legacy.isStickTuningConfigured) + } + + private fun load(json: String): ControlsProfile { + return checkNotNull( + InputControlsManager.loadProfile( + context, + ByteArrayInputStream(json.toByteArray(Charsets.UTF_8)), + ), + ) + } + + private fun vectorX(degrees: Double): Float = Math.cos(Math.toRadians(degrees)).toFloat() + + private fun vectorY(degrees: Double): Float = Math.sin(Math.toRadians(degrees)).toFloat() +}