From 88dfa38f34de5b42f3f278c23ce7a6fffac88811 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sat, 15 Aug 2026 23:49:40 +0100 Subject: [PATCH 01/13] feat(): WIP (cherry picked from commit 7f4dd8defde908d9bd83929178f9b35ac2c4a653) --- .../dialog/PhysicalControllerConfigSection.kt | 147 +++++++++++++++++- .../xserver/PhysicalControllerHandler.kt | 26 +++- .../inputcontrols/ControlsProfile.java | 69 ++++++++ .../inputcontrols/ExternalController.java | 42 ++++- .../inputcontrols/InputControlsManager.java | 30 +++- .../winlator/widget/InputControlsView.java | 10 +- app/src/main/res/values/strings.xml | 7 + 7 files changed, 315 insertions(+), 16 deletions(-) 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..d4032e9360 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 @@ -26,6 +26,7 @@ import com.winlator.inputcontrols.Binding import com.winlator.inputcontrols.BindingCombo import com.winlator.inputcontrols.ControlsProfile import com.winlator.inputcontrols.ExternalControllerBinding +import java.util.Locale /** * Data classes for controller configuration @@ -117,7 +118,14 @@ 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 + // Working copies of the stick tuning values (memory only until Save is clicked) + var leftStickDeadzone by remember { mutableFloatStateOf(profile.leftStickDeadzone) } + var rightStickDeadzone by remember { mutableFloatStateOf(profile.rightStickDeadzone) } + var leftStickSensitivity by remember { mutableFloatStateOf(profile.leftStickSensitivity) } + var rightStickSensitivity by remember { mutableFloatStateOf(profile.rightStickSensitivity) } + + // 0 = Face, 1 = Shoulder, 2 = Menu, 3 = Thumbstick, 4 = Left Stick, 5 = Right Stick, 6 = D-Pad, 7 = Stick Tuning + var selectedCategory by remember { mutableStateOf(0) } var showBindingDialog by remember { mutableStateOf?>(null) } var refreshKey by remember { mutableIntStateOf(0) } @@ -253,6 +261,11 @@ internal fun PhysicalControllerConfigSection( workingBindings[KeyEvent.KEYCODE_BUTTON_MODE] = BindingCombo.of(com.winlator.inputcontrols.Binding.OPEN_NAVIGATION_MENU) Log.d("gncontrol", "Set Home button (KEYCODE_BUTTON_MODE) to OPEN_NAVIGATION_MENU") + leftStickDeadzone = ControlsProfile.DEFAULT_STICK_DEADZONE + rightStickDeadzone = ControlsProfile.DEFAULT_STICK_DEADZONE + leftStickSensitivity = ControlsProfile.DEFAULT_STICK_SENSITIVITY + rightStickSensitivity = ControlsProfile.DEFAULT_STICK_SENSITIVITY + refreshKey++ }) { Icon(Icons.Default.Refresh, null) @@ -261,6 +274,12 @@ internal fun PhysicalControllerConfigSection( // Save button IconButton(onClick = { Log.d("gncontrol", "=== Save: Applying ${workingBindings.size} bindings ===") + + profile.leftStickDeadzone = leftStickDeadzone + profile.rightStickDeadzone = rightStickDeadzone + profile.leftStickSensitivity = leftStickSensitivity + profile.rightStickSensitivity = rightStickSensitivity + controller?.let { ctrl -> val existingBindings = ctrl.getControllerBindings().toList() for (binding in existingBindings) { @@ -369,6 +388,13 @@ internal fun PhysicalControllerConfigSection( isSelected = selectedCategory == 6, onClick = { selectedCategory = 6 } ) + + // Stick deadzone / sensitivity category + CategoryButton( + label = stringResource(R.string.stick_tuning_category), + isSelected = selectedCategory == 7, + onClick = { selectedCategory = 7 } + ) } } @@ -512,6 +538,18 @@ internal fun PhysicalControllerConfigSection( ) } } + 7 -> { + StickTuningSection( + leftStickDeadzone = leftStickDeadzone, + leftStickSensitivity = leftStickSensitivity, + rightStickDeadzone = rightStickDeadzone, + rightStickSensitivity = rightStickSensitivity, + onLeftStickDeadzoneChange = { leftStickDeadzone = it }, + onLeftStickSensitivityChange = { leftStickSensitivity = it }, + onRightStickDeadzoneChange = { rightStickDeadzone = it }, + onRightStickSensitivityChange = { rightStickSensitivity = it } + ) + } } } } @@ -611,6 +649,113 @@ private fun ControllerBindingItem( } } +/** + * 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( + leftStickDeadzone: Float, + leftStickSensitivity: Float, + rightStickDeadzone: Float, + rightStickSensitivity: Float, + onLeftStickDeadzoneChange: (Float) -> Unit, + onLeftStickSensitivityChange: (Float) -> Unit, + onRightStickDeadzoneChange: (Float) -> Unit, + onRightStickSensitivityChange: (Float) -> Unit +) { + val deadzoneDescription = stringResource(R.string.stick_deadzone_description) + val sensitivityDescription = stringResource(R.string.stick_sensitivity_description) + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + StickAdjustmentSlider( + label = stringResource(R.string.left_stick_deadzone), + description = deadzoneDescription, + value = leftStickDeadzone, + valueRange = ControlsProfile.MIN_STICK_DEADZONE..ControlsProfile.MAX_STICK_DEADZONE, + steps = DEADZONE_SLIDER_STEPS, + onValueChange = onLeftStickDeadzoneChange + ) + StickAdjustmentSlider( + label = stringResource(R.string.left_stick_sensitivity), + description = sensitivityDescription, + value = leftStickSensitivity, + valueRange = ControlsProfile.MIN_STICK_SENSITIVITY..ControlsProfile.MAX_STICK_SENSITIVITY, + steps = SENSITIVITY_SLIDER_STEPS, + onValueChange = onLeftStickSensitivityChange + ) + StickAdjustmentSlider( + label = stringResource(R.string.right_stick_deadzone), + description = deadzoneDescription, + value = rightStickDeadzone, + valueRange = ControlsProfile.MIN_STICK_DEADZONE..ControlsProfile.MAX_STICK_DEADZONE, + steps = DEADZONE_SLIDER_STEPS, + onValueChange = onRightStickDeadzoneChange + ) + StickAdjustmentSlider( + label = stringResource(R.string.right_stick_sensitivity), + description = sensitivityDescription, + value = rightStickSensitivity, + valueRange = ControlsProfile.MIN_STICK_SENSITIVITY..ControlsProfile.MAX_STICK_SENSITIVITY, + steps = SENSITIVITY_SLIDER_STEPS, + onValueChange = onRightStickSensitivityChange + ) + } +} + +@Composable +private fun StickAdjustmentSlider( + label: String, + description: String, + value: Float, + valueRange: ClosedFloatingPointRange, + steps: Int, + onValueChange: (Float) -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = String.format(Locale.US, "%.2f", value), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + } + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = steps + ) + } + } +} + +// 0.10 .. 1.0 in 0.05 increments leaves 17 stops between the two endpoints +private const val DEADZONE_SLIDER_STEPS = 17 +// 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..c1c5af695a 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 @@ -52,6 +52,11 @@ class PhysicalControllerHandler( private const val SCROLL_REPEAT_INTERVAL_MS = 90L private const val UNKNOWN_DEVICE_ID = -1 private const val SEQUENCE_PRESS_MS = 80L + // LX, LY, RX, RY - the leading entries of the axis array processed in processJoystickInput + private const val STICK_AXIS_COUNT = 4 + // Stick values reaching the mouse-move timer are already deadzone-gated, so this only skips + // injection while the sticks are resting. + private const val MOUSE_MOVE_IDLE_THRESHOLD = 0.01 } private val TAG = "gncontrol" @@ -124,6 +129,10 @@ class PhysicalControllerHandler( } } + init { + ExternalController.setStickTuning(profile) + } + fun setProfile(profile: ControlsProfile?) { releaseActiveBindings(activeButtonBindings) releaseActiveBindings(activeTriggerBindings, fromMotion = true) @@ -136,6 +145,7 @@ class PhysicalControllerHandler( activeSequenceTriggerBindings.clear() sendGamepadState() this.profile = profile + ExternalController.setStickTuning(profile) Log.d(TAG, "PhysicalControllerHandler: Profile set to ${profile?.name}") } @@ -349,9 +359,9 @@ class PhysicalControllerHandler( mouseMoveTimer = Timer() mouseMoveTimer?.schedule(object : TimerTask() { override fun run() { - // Skip injection if movement is below 8% deadzone to save CPU cycles + // Skip injection while resting to save CPU cycles val magnitude = Math.sqrt((mouseMoveOffset.x * mouseMoveOffset.x + mouseMoveOffset.y * mouseMoveOffset.y).toDouble()) - if (magnitude < 0.08) return + if (magnitude < MOUSE_MOVE_IDLE_THRESHOLD) return // Look up cursor speed dynamically so it updates when profile changes val cursorSpeed = profile?.cursorSpeed ?: 1f @@ -495,7 +505,17 @@ class PhysicalControllerHandler( val positiveSource = PhysicalInputSource(deviceId, posKeyCode) val negativeSource = PhysicalInputSource(deviceId, negKeyCode) - if (Math.abs(values[i]) > ControlElement.STICK_DEAD_ZONE) { + // Indices 0..3 are the analog sticks, whose values already had the profile's deadzone + // and sensitivity applied by ExternalController, so anything non-zero is live input. + // 4..5 are the digital hat axes, which keep the fixed threshold. + val isStick = i < STICK_AXIS_COUNT + val isActive = if (isStick) { + values[i] != 0f + } else { + Math.abs(values[i]) > ControlElement.STICK_DEAD_ZONE + } + + 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 diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java index 37dcb9a1c1..c670347bcd 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; @@ -19,9 +20,21 @@ import java.util.Locale; public class ControlsProfile implements Comparable { + /** Bounds for the user-configurable physical stick deadzone and sensitivity. */ + public static final float MIN_STICK_DEADZONE = 0.1f; + 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 final int id; private String name; private float cursorSpeed = 1.0f; + 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 final ArrayList elements = new ArrayList<>(); private final ArrayList controllers = new ArrayList<>(); private final ArrayList radialMenus = new ArrayList<>(); @@ -54,6 +67,58 @@ 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); + } + + public float getRightStickDeadzone() { + return rightStickDeadzone; + } + + public void setRightStickDeadzone(float deadzone) { + this.rightStickDeadzone = clampDeadzone(deadzone); + } + + public float getLeftStickSensitivity() { + return leftStickSensitivity; + } + + public void setLeftStickSensitivity(float sensitivity) { + this.leftStickSensitivity = clampSensitivity(sensitivity); + } + + public float getRightStickSensitivity() { + return rightStickSensitivity; + } + + public void setRightStickSensitivity(float sensitivity) { + this.rightStickSensitivity = clampSensitivity(sensitivity); + } + + /** + * 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) { + 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; } @@ -135,6 +200,10 @@ public void save() { data.put("id", id); data.put("name", name); data.put("cursorSpeed", Float.valueOf(cursorSpeed)); + data.put("leftStickDeadzone", (double) leftStickDeadzone); + data.put("rightStickDeadzone", (double) rightStickDeadzone); + data.put("leftStickSensitivity", (double) leftStickSensitivity); + data.put("rightStickSensitivity", (double) rightStickSensitivity); JSONArray elementsJSONArray = new JSONArray(); if (!elementsLoaded && file.isFile()) { diff --git a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java index c82f30393a..450b902ac8 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,14 @@ public class ExternalController { public static final byte TRIGGER_IS_AXIS = 1; public static final byte TRIGGER_IS_BOTH = 2; + // Stick tuning is intentionally global: controllers for players 2-4 are created on demand by + // WinHandler and never belong to a ControlsProfile, but they must honour the same tuning as + // player 1. setStickTuning() is called whenever the active profile changes. + private static volatile float stickDeadzoneLeft = ControlsProfile.DEFAULT_STICK_DEADZONE; + private static volatile float stickDeadzoneRight = ControlsProfile.DEFAULT_STICK_DEADZONE; + private static volatile float stickSensitivityLeft = ControlsProfile.DEFAULT_STICK_SENSITIVITY; + private static volatile float stickSensitivityRight = ControlsProfile.DEFAULT_STICK_SENSITIVITY; + private String id; private String name; private int deviceId = -1; @@ -47,6 +56,21 @@ public class ExternalController { public final GamepadState state = new GamepadState(); private boolean processTriggerButtonOnMotionEvent = true; + /** Applies {@code profile}'s stick deadzone/sensitivity to every physical controller. */ + public static void setStickTuning(ControlsProfile profile) { + if (profile == null) { + stickDeadzoneLeft = ControlsProfile.DEFAULT_STICK_DEADZONE; + stickDeadzoneRight = ControlsProfile.DEFAULT_STICK_DEADZONE; + stickSensitivityLeft = ControlsProfile.DEFAULT_STICK_SENSITIVITY; + stickSensitivityRight = ControlsProfile.DEFAULT_STICK_SENSITIVITY; + return; + } + stickDeadzoneLeft = profile.getLeftStickDeadzone(); + stickDeadzoneRight = profile.getRightStickDeadzone(); + stickSensitivityLeft = profile.getLeftStickSensitivity(); + stickSensitivityRight = profile.getRightStickSensitivity(); + } + public String getName() { return this.name; } @@ -178,12 +202,17 @@ public String toString() { return getDeviceId() + " | " + getName(); } + private static float tuneStickAxis(float value, float deadzone, float sensitivity) { + float adjusted = ControlsProfile.applyStickDeadzone(value, deadzone); + return Mathf.clamp(adjusted * sensitivity, -1, 1); + } + 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); + this.state.thumbLX = updateAxis(event, MotionEvent.AXIS_X, historyPos, this.state.thumbLX, stickDeadzoneLeft, stickSensitivityLeft); + this.state.thumbLY = updateAxis(event, MotionEvent.AXIS_Y, historyPos, this.state.thumbLY, stickDeadzoneLeft, stickSensitivityLeft); + this.state.thumbRX = updateAxis(event, MotionEvent.AXIS_Z, historyPos, this.state.thumbRX, stickDeadzoneRight, stickSensitivityRight); + this.state.thumbRY = updateAxis(event, MotionEvent.AXIS_RZ, historyPos, this.state.thumbRY, stickDeadzoneRight, stickSensitivityRight); 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 +231,11 @@ private void processJoystickInput(MotionEvent event, int historyPos) { } } - private static float updateAxis(MotionEvent event, int axis, int historyPos, float retained) { + private static float updateAxis(MotionEvent event, int axis, int historyPos, float retained, float deadzone, float sensitivity) { InputDevice device = event.getDevice(); boolean reported = device != null && device.getMotionRange(axis, event.getSource()) != null; - return JoyConSupport.axisValue(reported, retained, getCenteredAxis(event, axis, historyPos)); + float tuned = tuneStickAxis(getCenteredAxis(event, axis, historyPos), deadzone, sensitivity); + return JoyConSupport.axisValue(reported, retained, tuned); } 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 f6b6cf26ae..bc262a743c 100644 --- a/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java +++ b/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java @@ -249,7 +249,10 @@ public static ControlsProfile loadProfile(Context context, InputStream inStream) int profileId = 0; String profileName = null; float cursorSpeed = Float.NaN; - int fieldsRead = 0; + float leftStickDeadzone = Float.NaN; + float rightStickDeadzone = Float.NaN; + float leftStickSensitivity = Float.NaN; + float rightStickSensitivity = Float.NaN; reader.beginObject(); while (reader.hasNext()) { @@ -257,18 +260,31 @@ public static ControlsProfile loadProfile(Context context, InputStream inStream) if (name.equals("id")) { profileId = reader.nextInt(); - fieldsRead++; } else if (name.equals("name")) { profileName = reader.nextString(); - fieldsRead++; } else if (name.equals("cursorSpeed")) { cursorSpeed = (float) reader.nextDouble(); - fieldsRead++; + } + else if (name.equals("leftStickDeadzone")) { + leftStickDeadzone = (float) reader.nextDouble(); + } + else if (name.equals("rightStickDeadzone")) { + rightStickDeadzone = (float) reader.nextDouble(); + } + else if (name.equals("leftStickSensitivity")) { + leftStickSensitivity = (float) reader.nextDouble(); + } + else if (name.equals("rightStickSensitivity")) { + rightStickSensitivity = (float) reader.nextDouble(); + } + else if (name.equals("elements") || name.equals("controllers")) { + // Every scalar field is written before these two arrays, so there is nothing + // left worth parsing - bail out rather than walking the whole element list. + break; } else { - if (fieldsRead == 3) break; reader.skipValue(); } } @@ -276,6 +292,10 @@ else if (name.equals("cursorSpeed")) { ControlsProfile profile = new ControlsProfile(context, profileId); profile.setName(profileName); profile.setCursorSpeed(cursorSpeed); + profile.setLeftStickDeadzone(leftStickDeadzone); + profile.setRightStickDeadzone(rightStickDeadzone); + profile.setLeftStickSensitivity(leftStickSensitivity); + profile.setRightStickSensitivity(rightStickSensitivity); return profile; } catch (IOException e) { diff --git a/app/src/main/java/com/winlator/widget/InputControlsView.java b/app/src/main/java/com/winlator/widget/InputControlsView.java index 48fca94008..fda31c3b12 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(); @@ -589,7 +591,13 @@ 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) { + // Indices 0..3 are the analog sticks, whose values already had the profile's deadzone + // and sensitivity applied by ExternalController, so anything non-zero is live input. + // 4..5 are the digital hat axes, which keep the fixed threshold. + boolean isStick = i < PHYSICAL_STICK_AXIS_COUNT; + boolean isActive = isStick ? 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/strings.xml b/app/src/main/res/values/strings.xml index 0f10b158a5..52733c7e8e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -263,6 +263,13 @@ Left Stick Right Stick D-Pad + Deadzone & Sensitivity + 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 how much stick movement the game sees. Above 1.0 reaches full travel sooner. D-Pad Up D-Pad Down D-Pad Left From 31fae12a84dbf85e91d95f2b757c15dddaef0034 Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Mon, 7 Sep 2026 00:53:14 -0500 Subject: [PATCH 02/13] feat(input): finish physical controller stick tuning --- .../dialog/PhysicalControllerConfigSection.kt | 11 +- .../xserver/PhysicalControllerHandler.kt | 27 ++-- .../inputcontrols/ControlsProfile.java | 4 +- .../inputcontrols/ExternalController.java | 15 ++- .../inputcontrols/InputControlsManager.java | 8 +- app/src/main/res/values-da/strings.xml | 7 + app/src/main/res/values-de/strings.xml | 7 + app/src/main/res/values-es/strings.xml | 7 + app/src/main/res/values-fr/strings.xml | 7 + app/src/main/res/values-it/strings.xml | 7 + app/src/main/res/values-ja/strings.xml | 7 + app/src/main/res/values-ko/strings.xml | 7 + app/src/main/res/values-pl/strings.xml | 7 + app/src/main/res/values-pt-rBR/strings.xml | 7 + app/src/main/res/values-ro/strings.xml | 7 + app/src/main/res/values-ru/strings.xml | 7 + app/src/main/res/values-uk/strings.xml | 7 + app/src/main/res/values-zh-rCN/strings.xml | 7 + app/src/main/res/values-zh-rTW/strings.xml | 7 + .../xserver/PhysicalControllerHandlerTest.kt | 31 +++++ .../PhysicalControllerStickTuningTest.kt | 122 ++++++++++++++++++ 21 files changed, 287 insertions(+), 29 deletions(-) create mode 100644 app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt 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 d4032e9360..58184450d7 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 @@ -674,6 +674,7 @@ private fun StickTuningSection( label = stringResource(R.string.left_stick_deadzone), description = deadzoneDescription, value = leftStickDeadzone, + displayValue = String.format(Locale.getDefault(), "%.0f%%", leftStickDeadzone * 100), valueRange = ControlsProfile.MIN_STICK_DEADZONE..ControlsProfile.MAX_STICK_DEADZONE, steps = DEADZONE_SLIDER_STEPS, onValueChange = onLeftStickDeadzoneChange @@ -682,6 +683,7 @@ private fun StickTuningSection( label = stringResource(R.string.left_stick_sensitivity), description = sensitivityDescription, value = leftStickSensitivity, + displayValue = String.format(Locale.getDefault(), "%.2f×", leftStickSensitivity), valueRange = ControlsProfile.MIN_STICK_SENSITIVITY..ControlsProfile.MAX_STICK_SENSITIVITY, steps = SENSITIVITY_SLIDER_STEPS, onValueChange = onLeftStickSensitivityChange @@ -690,6 +692,7 @@ private fun StickTuningSection( label = stringResource(R.string.right_stick_deadzone), description = deadzoneDescription, value = rightStickDeadzone, + displayValue = String.format(Locale.getDefault(), "%.0f%%", rightStickDeadzone * 100), valueRange = ControlsProfile.MIN_STICK_DEADZONE..ControlsProfile.MAX_STICK_DEADZONE, steps = DEADZONE_SLIDER_STEPS, onValueChange = onRightStickDeadzoneChange @@ -698,6 +701,7 @@ private fun StickTuningSection( label = stringResource(R.string.right_stick_sensitivity), description = sensitivityDescription, value = rightStickSensitivity, + displayValue = String.format(Locale.getDefault(), "%.2f×", rightStickSensitivity), valueRange = ControlsProfile.MIN_STICK_SENSITIVITY..ControlsProfile.MAX_STICK_SENSITIVITY, steps = SENSITIVITY_SLIDER_STEPS, onValueChange = onRightStickSensitivityChange @@ -710,6 +714,7 @@ private fun StickAdjustmentSlider( label: String, description: String, value: Float, + displayValue: String, valueRange: ClosedFloatingPointRange, steps: Int, onValueChange: (Float) -> Unit @@ -731,7 +736,7 @@ private fun StickAdjustmentSlider( fontWeight = FontWeight.Medium ) Text( - text = String.format(Locale.US, "%.2f", value), + text = displayValue, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold ) @@ -751,8 +756,8 @@ private fun StickAdjustmentSlider( } } -// 0.10 .. 1.0 in 0.05 increments leaves 17 stops between the two endpoints -private const val DEADZONE_SLIDER_STEPS = 17 +// 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 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 c1c5af695a..d988b41524 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 @@ -52,8 +52,6 @@ class PhysicalControllerHandler( private const val SCROLL_REPEAT_INTERVAL_MS = 90L private const val UNKNOWN_DEVICE_ID = -1 private const val SEQUENCE_PRESS_MS = 80L - // LX, LY, RX, RY - the leading entries of the axis array processed in processJoystickInput - private const val STICK_AXIS_COUNT = 4 // Stick values reaching the mouse-move timer are already deadzone-gated, so this only skips // injection while the sticks are resting. private const val MOUSE_MOVE_IDLE_THRESHOLD = 0.01 @@ -164,6 +162,7 @@ class PhysicalControllerHandler( showKeyboardPressed = false closeRadialMenuIfOpen(commit = false) sendGamepadState() + ExternalController.setStickTuning(null) } fun onInputDeviceRemoved(deviceId: Int) { @@ -505,15 +504,7 @@ class PhysicalControllerHandler( val positiveSource = PhysicalInputSource(deviceId, posKeyCode) val negativeSource = PhysicalInputSource(deviceId, negKeyCode) - // Indices 0..3 are the analog sticks, whose values already had the profile's deadzone - // and sensitivity applied by ExternalController, so anything non-zero is live input. - // 4..5 are the digital hat axes, which keep the fixed threshold. - val isStick = i < STICK_AXIS_COUNT - val isActive = if (isStick) { - values[i] != 0f - } else { - 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])) @@ -1089,9 +1080,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 @@ -1099,11 +1088,17 @@ 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) value != 0f else Math.abs(value) > ControlElement.STICK_DEAD_ZONE + } + private fun isRadialMenuMotionOpenerPressed(controller: ExternalController): Boolean { val axes = intArrayOf( MotionEvent.AXIS_X, @@ -1123,7 +1118,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/com/winlator/inputcontrols/ControlsProfile.java b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java index c670347bcd..94c61ff9b4 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java @@ -21,7 +21,7 @@ public class ControlsProfile implements Comparable { /** Bounds for the user-configurable physical stick deadzone and sensitivity. */ - public static final float MIN_STICK_DEADZONE = 0.1f; + 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; @@ -114,6 +114,8 @@ public void setRightStickSensitivity(float sensitivity) { * 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); diff --git a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java index 450b902ac8..3a93e9da9e 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java +++ b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java @@ -202,11 +202,15 @@ public String toString() { return getDeviceId() + " | " + getName(); } - private static float tuneStickAxis(float value, float deadzone, float sensitivity) { + static float tuneStickAxis(float value, float deadzone, float sensitivity) { float adjusted = ControlsProfile.applyStickDeadzone(value, deadzone); return Mathf.clamp(adjusted * sensitivity, -1, 1); } + static float resolveStickAxis(boolean reported, float retained, float value, float deadzone, float sensitivity) { + return JoyConSupport.axisValue(reported, retained, tuneStickAxis(value, deadzone, sensitivity)); + } + private void processJoystickInput(MotionEvent event, int historyPos) { boolean z = false; this.state.thumbLX = updateAxis(event, MotionEvent.AXIS_X, historyPos, this.state.thumbLX, stickDeadzoneLeft, stickSensitivityLeft); @@ -234,8 +238,13 @@ private void processJoystickInput(MotionEvent event, int historyPos) { private static float updateAxis(MotionEvent event, int axis, int historyPos, float retained, float deadzone, float sensitivity) { InputDevice device = event.getDevice(); boolean reported = device != null && device.getMotionRange(axis, event.getSource()) != null; - float tuned = tuneStickAxis(getCenteredAxis(event, axis, historyPos), deadzone, sensitivity); - return JoyConSupport.axisValue(reported, retained, tuned); + return resolveStickAxis( + reported, + retained, + getCenteredAxis(event, axis, historyPos), + deadzone, + sensitivity + ); } 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 bc262a743c..c27606253e 100644 --- a/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java +++ b/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java @@ -279,19 +279,15 @@ else if (name.equals("leftStickSensitivity")) { else if (name.equals("rightStickSensitivity")) { rightStickSensitivity = (float) reader.nextDouble(); } - else if (name.equals("elements") || name.equals("controllers")) { - // Every scalar field is written before these two arrays, so there is nothing - // left worth parsing - bail out rather than walking the whole element list. - break; - } else { reader.skipValue(); } } + reader.endObject(); ControlsProfile profile = new ControlsProfile(context, profileId); profile.setName(profileName); - profile.setCursorSpeed(cursorSpeed); + if (!Float.isNaN(cursorSpeed)) profile.setCursorSpeed(cursorSpeed); profile.setLeftStickDeadzone(leftStickDeadzone); profile.setRightStickDeadzone(rightStickDeadzone); profile.setLeftStickSensitivity(leftStickSensitivity); diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 5c40e75651..f4d1ac7f20 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -204,6 +204,13 @@ Venstre stick Højre stick D-Pad + Dødzone og følsomhed + 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, hvor meget bevægelse spillet registrerer. Over 1,0 nås fuldt udslag tidligere. 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 025ce0eae3..fdee058697 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -268,6 +268,13 @@ Linker Stick Rechter Stick D-Pad + Totzone und Empfindlichkeit + 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 die Stickbewegung, die das Spiel erkennt. Über 1,0 wird der volle Ausschlag früher erreicht. 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 54a03691a2..ea0e8a6c56 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -286,6 +286,13 @@ Stick izquierdo Stick derecho Cruceta + Zona muerta y sensibilidad + 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. + Ajusta cuánto movimiento del stick recibe el juego. Por encima de 1,0 se alcanza antes el recorrido máximo. 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 40f9d2b181..e48b268953 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -276,6 +276,13 @@ Joystick gauche Joystick droit Croix directionnelle + Zone morte et sensibilité + 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. + Ajuste l’amplitude du mouvement transmis au jeu. Au-dessus de 1,0, la course maximale est atteinte plus tôt. 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 e141f6ab3d..76ee884dde 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -275,6 +275,13 @@ Levetta Sinistra Levetta Destra D-Pad + Zona morta e sensibilità + 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. + Regola l’ampiezza del movimento trasmesso al gioco. Oltre 1,0, la corsa massima viene raggiunta prima. 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 cb3885b328..3355f1f1d8 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -229,6 +229,13 @@ 左スティック 右スティック 十字キー + デッドゾーンと感度 + 左スティックのデッドゾーン + 右スティックのデッドゾーン + 左スティックの感度 + 右スティックの感度 + 入力が反応し始めるまでに必要なスティックの移動量です。ドリフトを抑えるには値を上げます。 + ゲームに伝わるスティック移動量を調整します。1.0を超えると、より早く最大入力に達します。 十字キー上 十字キー下 十字キー左 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index a6fc4dc520..027cb65b86 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -283,6 +283,13 @@ 왼쪽 스틱 오른쪽 스틱 십자 버튼 + 데드존 및 감도 + 왼쪽 스틱 데드존 + 오른쪽 스틱 데드존 + 왼쪽 스틱 감도 + 오른쪽 스틱 감도 + 입력이 감지되기 전에 스틱을 움직여야 하는 거리입니다. 드리프트를 멈추려면 값을 높이세요. + 게임에 전달되는 스틱 움직임을 조절합니다. 1.0보다 높으면 최대 입력에 더 빨리 도달합니다. 십자 버튼 위 십자 버튼 아래 십자 버튼 왼쪽 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 4311c238e9..08434b7f73 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -283,6 +283,13 @@ Lewy drążek Prawy drążek D-Pad + Martwa strefa i czułość + 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 ruch drążka odbierany przez grę. Wartość powyżej 1,0 pozwala szybciej osiągnąć pełne wychylenie. 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 5641758424..d82dcbbec8 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -204,6 +204,13 @@ Analógico Esquerdo Analógico Direito D-Pad + Zona morta e sensibilidade + 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. + Ajusta quanto movimento do analógico o jogo recebe. Acima de 1,0, o curso máximo é atingido mais cedo. 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 5ceadd96f6..2f5209fa11 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -273,6 +273,13 @@ Stick stânga Stick dreapta D‑Pad + Zonă moartă și sensibilitate + 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. + Scalează mișcarea stickului transmisă jocului. Peste 1,0, cursa maximă este atinsă mai repede. 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 8b0b666100..d0c5cc560b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -390,6 +390,13 @@ Установить приложение Загрузка... D-Pad + Мёртвая зона и чувствительность + Мёртвая зона левого джойстика + Мёртвая зона правого джойстика + Чувствительность левого джойстика + Чувствительность правого джойстика + Определяет, насколько нужно отклонить стик, прежде чем ввод будет зарегистрирован. Увеличьте значение, чтобы устранить дрейф. + Масштабирует отклонение стика, передаваемое игре. При значении выше 1,0 максимальное отклонение достигается раньше. 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 ef55e6aed7..0e7e919a02 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -270,6 +270,13 @@ Лівий стік Правий стік D-Pad + Мертва зона та чутливість + Мертва зона лівого стіка + Мертва зона правого стіка + Чутливість лівого стіка + Чутливість правого стіка + Визначає, наскільки потрібно відхилити стік, перш ніж введення буде зареєстровано. Збільште значення, щоб усунути дрейф. + Масштабує відхилення стіка, передане грі. За значення понад 1,0 максимальне відхилення досягається раніше. 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 1308575c44..6d825e44fc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -268,6 +268,13 @@ 左摇杆 右摇杆 方向键 + 死区和灵敏度 + 左摇杆死区 + 右摇杆死区 + 左摇杆灵敏度 + 右摇杆灵敏度 + 摇杆移动多远后才会检测到输入。调高可消除漂移。 + 调整游戏接收到的摇杆移动幅度。高于 1.0 时会更快达到最大输入。 方向键 上 方向键 下 方向键 左 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index fbdccd0056..9d414646d3 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -268,6 +268,13 @@ 左搖桿 右搖桿 方向鍵 + 死區與靈敏度 + 左搖桿死區 + 右搖桿死區 + 左搖桿靈敏度 + 右搖桿靈敏度 + 搖桿移動多遠後才會偵測到輸入。調高可消除飄移。 + 調整遊戲接收到的搖桿移動幅度。高於 1.0 時會更快達到最大輸入。 方向鍵 上 方向鍵 下 方向鍵 左 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..4091b86955 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 @@ -147,6 +147,37 @@ 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) + 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..931af18047 --- /dev/null +++ b/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt @@ -0,0 +1,122 @@ +package com.winlator.inputcontrols + +import android.content.Context +import org.junit.Assert.assertEquals +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 `deadzone rescales both directions 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) + assertEquals(0.75f, ExternalController.tuneStickAxis(0.6f, 0.2f, 1.5f), 0.0001f) + assertEquals(1f, ExternalController.tuneStickAxis(0.6f, 0.2f, 3f), 0f) + assertEquals(0f, ExternalController.tuneStickAxis(Float.NaN, 0.2f, 1f), 0f) + } + + @Test + fun `an unreported Joy-Con axis retains its already tuned value`() { + assertEquals( + 0.65f, + ExternalController.resolveStickAxis(false, 0.65f, -0.6f, 0.2f, 1f), + 0f, + ) + assertEquals( + -0.5f, + ExternalController.resolveStickAxis(true, 0.65f, -0.6f, 0.2f, 1f), + 0.0001f, + ) + } + + @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) + + val reordered = load( + """{ + "elements":[], + "controllers":[], + "id":8, + "name":"Reordered", + "rightStickSensitivity":1.8, + "leftStickDeadzone":0.05, + "rightStickDeadzone":0.3, + "leftStickSensitivity":1.2, + "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) + } + + @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 + 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) + } + + private fun load(json: String): ControlsProfile { + return checkNotNull( + InputControlsManager.loadProfile( + context, + ByteArrayInputStream(json.toByteArray(Charsets.UTF_8)), + ), + ) + } +} From 3857794deec3cb3fb3dc12469f8d2bdcae8e5e65 Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Mon, 7 Sep 2026 01:33:48 -0500 Subject: [PATCH 03/13] feat(input): add stick deadzone shapes and directional snapping --- .../dialog/PhysicalControllerConfigSection.kt | 119 +++++++++++- .../xserver/PhysicalControllerHandler.kt | 62 ++++++ .../com/winlator/inputcontrols/Binding.java | 5 + .../winlator/inputcontrols/BindingCombo.java | 6 + .../inputcontrols/ControlsProfile.java | 88 +++++++++ .../inputcontrols/ExternalController.java | 43 +++-- .../inputcontrols/InputControlsManager.java | 20 ++ .../inputcontrols/StickVectorProcessor.java | 179 +++++++++++++++++ .../winlator/widget/InputControlsView.java | 29 +++ app/src/main/res/values-da/strings.xml | 12 ++ app/src/main/res/values-de/strings.xml | 12 ++ app/src/main/res/values-es/strings.xml | 12 ++ app/src/main/res/values-fr/strings.xml | 12 ++ app/src/main/res/values-it/strings.xml | 12 ++ app/src/main/res/values-ja/strings.xml | 12 ++ app/src/main/res/values-ko/strings.xml | 12 ++ app/src/main/res/values-pl/strings.xml | 12 ++ app/src/main/res/values-pt-rBR/strings.xml | 12 ++ app/src/main/res/values-ro/strings.xml | 12 ++ app/src/main/res/values-ru/strings.xml | 12 ++ app/src/main/res/values-uk/strings.xml | 12 ++ app/src/main/res/values-zh-rCN/strings.xml | 12 ++ app/src/main/res/values-zh-rTW/strings.xml | 12 ++ app/src/main/res/values/strings.xml | 12 ++ .../xserver/PhysicalControllerHandlerTest.kt | 39 ++++ .../PhysicalControllerStickTuningTest.kt | 181 ++++++++++++++++-- 26 files changed, 922 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/winlator/inputcontrols/StickVectorProcessor.java 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 58184450d7..5bf87ce7d0 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 @@ -123,6 +123,10 @@ internal fun PhysicalControllerConfigSection( var rightStickDeadzone by remember { mutableFloatStateOf(profile.rightStickDeadzone) } var leftStickSensitivity by remember { mutableFloatStateOf(profile.leftStickSensitivity) } var rightStickSensitivity by remember { mutableFloatStateOf(profile.rightStickSensitivity) } + var leftStickDeadzoneMode by remember { mutableStateOf(profile.leftStickDeadzoneMode) } + var rightStickDeadzoneMode by remember { mutableStateOf(profile.rightStickDeadzoneMode) } + var leftStickDigitalMode by remember { mutableStateOf(profile.leftStickDigitalMode) } + var rightStickDigitalMode by remember { mutableStateOf(profile.rightStickDigitalMode) } // 0 = Face, 1 = Shoulder, 2 = Menu, 3 = Thumbstick, 4 = Left Stick, 5 = Right Stick, 6 = D-Pad, 7 = Stick Tuning var selectedCategory by remember { mutableStateOf(0) } @@ -265,6 +269,10 @@ internal fun PhysicalControllerConfigSection( rightStickDeadzone = ControlsProfile.DEFAULT_STICK_DEADZONE leftStickSensitivity = ControlsProfile.DEFAULT_STICK_SENSITIVITY rightStickSensitivity = ControlsProfile.DEFAULT_STICK_SENSITIVITY + leftStickDeadzoneMode = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE + rightStickDeadzoneMode = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE + leftStickDigitalMode = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE + rightStickDigitalMode = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE refreshKey++ }) { @@ -279,6 +287,10 @@ internal fun PhysicalControllerConfigSection( profile.rightStickDeadzone = rightStickDeadzone profile.leftStickSensitivity = leftStickSensitivity profile.rightStickSensitivity = rightStickSensitivity + profile.leftStickDeadzoneMode = leftStickDeadzoneMode + profile.rightStickDeadzoneMode = rightStickDeadzoneMode + profile.leftStickDigitalMode = leftStickDigitalMode + profile.rightStickDigitalMode = rightStickDigitalMode controller?.let { ctrl -> val existingBindings = ctrl.getControllerBindings().toList() @@ -544,10 +556,18 @@ internal fun PhysicalControllerConfigSection( leftStickSensitivity = leftStickSensitivity, rightStickDeadzone = rightStickDeadzone, rightStickSensitivity = rightStickSensitivity, + leftStickDeadzoneMode = leftStickDeadzoneMode, + rightStickDeadzoneMode = rightStickDeadzoneMode, + leftStickDigitalMode = leftStickDigitalMode, + rightStickDigitalMode = rightStickDigitalMode, onLeftStickDeadzoneChange = { leftStickDeadzone = it }, onLeftStickSensitivityChange = { leftStickSensitivity = it }, onRightStickDeadzoneChange = { rightStickDeadzone = it }, - onRightStickSensitivityChange = { rightStickSensitivity = it } + onRightStickSensitivityChange = { rightStickSensitivity = it }, + onLeftStickDeadzoneModeChange = { leftStickDeadzoneMode = it }, + onRightStickDeadzoneModeChange = { rightStickDeadzoneMode = it }, + onLeftStickDigitalModeChange = { leftStickDigitalMode = it }, + onRightStickDigitalModeChange = { rightStickDigitalMode = it }, ) } } @@ -655,16 +675,25 @@ private fun ControllerBindingItem( * 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). */ +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun StickTuningSection( leftStickDeadzone: Float, leftStickSensitivity: Float, rightStickDeadzone: Float, rightStickSensitivity: Float, + leftStickDeadzoneMode: ControlsProfile.StickDeadzoneMode, + rightStickDeadzoneMode: ControlsProfile.StickDeadzoneMode, + leftStickDigitalMode: ControlsProfile.StickDigitalMode, + rightStickDigitalMode: ControlsProfile.StickDigitalMode, onLeftStickDeadzoneChange: (Float) -> Unit, onLeftStickSensitivityChange: (Float) -> Unit, onRightStickDeadzoneChange: (Float) -> Unit, - onRightStickSensitivityChange: (Float) -> Unit + onRightStickSensitivityChange: (Float) -> Unit, + onLeftStickDeadzoneModeChange: (ControlsProfile.StickDeadzoneMode) -> Unit, + onRightStickDeadzoneModeChange: (ControlsProfile.StickDeadzoneMode) -> Unit, + onLeftStickDigitalModeChange: (ControlsProfile.StickDigitalMode) -> Unit, + onRightStickDigitalModeChange: (ControlsProfile.StickDigitalMode) -> Unit, ) { val deadzoneDescription = stringResource(R.string.stick_deadzone_description) val sensitivityDescription = stringResource(R.string.stick_sensitivity_description) @@ -679,6 +708,17 @@ private fun StickTuningSection( steps = DEADZONE_SLIDER_STEPS, onValueChange = onLeftStickDeadzoneChange ) + StickModeSelector( + label = stringResource(R.string.left_stick_deadzone_shape), + description = stringResource(R.string.stick_deadzone_shape_description), + selected = leftStickDeadzoneMode, + options = 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), + ), + onSelected = onLeftStickDeadzoneModeChange, + ) StickAdjustmentSlider( label = stringResource(R.string.left_stick_sensitivity), description = sensitivityDescription, @@ -688,6 +728,17 @@ private fun StickTuningSection( steps = SENSITIVITY_SLIDER_STEPS, onValueChange = onLeftStickSensitivityChange ) + StickModeSelector( + label = stringResource(R.string.left_stick_direction_mode), + description = stringResource(R.string.stick_direction_mode_description), + selected = leftStickDigitalMode, + options = 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), + ), + onSelected = onLeftStickDigitalModeChange, + ) StickAdjustmentSlider( label = stringResource(R.string.right_stick_deadzone), description = deadzoneDescription, @@ -697,6 +748,17 @@ private fun StickTuningSection( steps = DEADZONE_SLIDER_STEPS, onValueChange = onRightStickDeadzoneChange ) + StickModeSelector( + label = stringResource(R.string.right_stick_deadzone_shape), + description = stringResource(R.string.stick_deadzone_shape_description), + selected = rightStickDeadzoneMode, + options = 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), + ), + onSelected = onRightStickDeadzoneModeChange, + ) StickAdjustmentSlider( label = stringResource(R.string.right_stick_sensitivity), description = sensitivityDescription, @@ -706,6 +768,59 @@ private fun StickTuningSection( steps = SENSITIVITY_SLIDER_STEPS, onValueChange = onRightStickSensitivityChange ) + StickModeSelector( + label = stringResource(R.string.right_stick_direction_mode), + description = stringResource(R.string.stick_direction_mode_description), + selected = rightStickDigitalMode, + options = 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), + ), + onSelected = onRightStickDigitalModeChange, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun StickModeSelector( + label: String, + description: String, + selected: T, + options: List>, + onSelected: (T) -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + options.forEachIndexed { index, option -> + SegmentedButton( + selected = selected == option.first, + onClick = { onSelected(option.first) }, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + label = { Text(option.second, maxLines = 1) }, + ) + } + } + } } } 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 d988b41524..ec342af267 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 @@ -15,6 +15,7 @@ import com.winlator.inputcontrols.ExternalController import com.winlator.inputcontrols.ExternalControllerBinding import com.winlator.inputcontrols.GamepadState import com.winlator.inputcontrols.JoyConSupport +import com.winlator.inputcontrols.StickVectorProcessor import com.winlator.math.Mathf import com.winlator.xserver.XServer import java.util.Timer @@ -42,6 +43,8 @@ class PhysicalControllerHandler( ) { private data class PhysicalInputSource(val deviceId: Int, val keyCode: Int) + private data class PhysicalStickSource(val deviceId: Int, val rightStick: Boolean) + private data class MouseMoveSource( val deviceId: Int, val keyCode: Int, @@ -68,6 +71,7 @@ class PhysicalControllerHandler( // track which axis keycodes are currently "pressed" so we only release on actual transitions. // accessed only from main thread (MotionEvent dispatch + Compose lifecycle), no sync needed. private val activeAxisBindings = mutableSetOf() + private val snappedStickDirections = mutableMapOf() private val activeButtonBindings = mutableMapOf() private val activeTriggerBindings = mutableMapOf() private val activeSequenceTriggerBindings = mutableSetOf() @@ -141,6 +145,7 @@ class PhysicalControllerHandler( clearScrollRepeats() closeRadialMenuIfOpen(commit = false) activeSequenceTriggerBindings.clear() + snappedStickDirections.clear() sendGamepadState() this.profile = profile ExternalController.setStickTuning(profile) @@ -159,6 +164,7 @@ class PhysicalControllerHandler( clearMouseMoveContributions() clearScrollRepeats() activeSequenceTriggerBindings.clear() + snappedStickDirections.clear() showKeyboardPressed = false closeRadialMenuIfOpen(commit = false) sendGamepadState() @@ -171,6 +177,7 @@ class PhysicalControllerHandler( releaseActiveBindings(activeTriggerBindings, deviceId, fromMotion = true) releaseActiveAxes(deviceId = deviceId) releaseGyroModifierSources(deviceId) + snappedStickDirections.keys.removeAll { it.deviceId == deviceId } mouseMoveContributions.keys.removeAll { it.deviceId == deviceId } recalculateMouseMoveOffset() sendGamepadState() @@ -497,6 +504,35 @@ class PhysicalControllerHandler( controller.state.dPadX.toFloat(), controller.state.dPadY.toFloat() ) + val leftDigitalMode = profile?.leftStickDigitalMode + ?: ControlsProfile.DEFAULT_STICK_DIGITAL_MODE + val rightDigitalMode = profile?.rightStickDigitalMode + ?: ControlsProfile.DEFAULT_STICK_DIGITAL_MODE + val leftDirectionMask = resolveStickDirectionMask( + deviceId, + rightStick = false, + values[0], + values[1], + leftDigitalMode, + ) + val rightDirectionMask = resolveStickDirectionMask( + deviceId, + rightStick = true, + values[2], + values[3], + rightDigitalMode, + ) + + for (i in 0..3) { + val mode = if (i < 2) leftDigitalMode else rightDigitalMode + if (mode == ControlsProfile.StickDigitalMode.UNRESTRICTED || values[i] == 0f) continue + val keyCode = ExternalControllerBinding.getKeyCodeForAxis(axes[i], Mathf.sign(values[i])) + val binding = controller.getControllerBinding(keyCode) ?: continue + if (binding.bindingCombo.usesAnalogOffset()) continue + val directionMask = if (i < 2) leftDirectionMask else rightDirectionMask + val horizontal = i == 0 || i == 2 + if (!StickVectorProcessor.allowsAxis(directionMask, horizontal, values[i])) values[i] = 0f + } for (i in axes.indices) { val posKeyCode = ExternalControllerBinding.getKeyCodeForAxis(axes[i], 1.toByte()) @@ -573,6 +609,32 @@ class PhysicalControllerHandler( } } + private fun resolveStickDirectionMask( + deviceId: Int, + rightStick: Boolean, + x: Float, + y: Float, + mode: ControlsProfile.StickDigitalMode, + ): Int { + val source = PhysicalStickSource(deviceId, rightStick) + if (mode == ControlsProfile.StickDigitalMode.UNRESTRICTED) { + snappedStickDirections.remove(source) + return StickVectorProcessor.MASK_ALL + } + val direction = StickVectorProcessor.snapDirection( + x, + y, + mode, + snappedStickDirections[source] ?: StickVectorProcessor.DIRECTION_NONE, + ) + if (direction == StickVectorProcessor.DIRECTION_NONE) { + snappedStickDirections.remove(source) + } else { + snappedStickDirections[source] = direction + } + return StickVectorProcessor.directionMask(direction) + } + private fun handleTriggerBinding( keyCode: Int, legacyBinding: Binding, diff --git a/app/src/main/java/com/winlator/inputcontrols/Binding.java b/app/src/main/java/com/winlator/inputcontrols/Binding.java index cb103b99ed..e758e711b1 100644 --- a/app/src/main/java/com/winlator/inputcontrols/Binding.java +++ b/app/src/main/java/com/winlator/inputcontrols/Binding.java @@ -209,6 +209,11 @@ public boolean isGamepadAxis() { ordinal() <= GAMEPAD_RIGHT_THUMB_LEFT.ordinal(); } + public boolean usesAnalogOffset() { + return isGamepadAxis() || this == MOUSE_MOVE_LEFT || this == MOUSE_MOVE_RIGHT || + this == MOUSE_MOVE_UP || this == MOUSE_MOVE_DOWN; + } + public boolean isExtra() { return this == OPEN_NAVIGATION_MENU || this == SHOW_KEYBOARD || this == ALT_ENTER || this == OPEN_RADIAL_MENU || this == GYRO_MODIFIER; diff --git a/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java b/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java index 7d9686b798..546729f749 100644 --- a/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java +++ b/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java @@ -197,6 +197,12 @@ public boolean isGamepadOnly() { return true; } + /** True when any binding consumes the analog magnitude rather than just press/release state. */ + public boolean usesAnalogOffset() { + for (Binding binding : bindings) if (binding.usesAnalogOffset()) return true; + return false; + } + public void writeToJsonObject(JSONObject object) throws JSONException { object.put("bindings", toJsonArray()); if (mode == Mode.SEQUENCE) { diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java index 94c61ff9b4..7e436fef6b 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java @@ -20,6 +20,52 @@ import java.util.Locale; public class ControlsProfile implements Comparable { + 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; @@ -27,6 +73,8 @@ public class ControlsProfile implements Comparable { 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; @@ -35,6 +83,10 @@ public class ControlsProfile implements Comparable { 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 final ArrayList elements = new ArrayList<>(); private final ArrayList controllers = new ArrayList<>(); private final ArrayList radialMenus = new ArrayList<>(); @@ -109,6 +161,38 @@ public void setRightStickSensitivity(float sensitivity) { this.rightStickSensitivity = clampSensitivity(sensitivity); } + public StickDeadzoneMode getLeftStickDeadzoneMode() { + return leftStickDeadzoneMode; + } + + public void setLeftStickDeadzoneMode(StickDeadzoneMode mode) { + leftStickDeadzoneMode = mode != null ? mode : DEFAULT_STICK_DEADZONE_MODE; + } + + public StickDeadzoneMode getRightStickDeadzoneMode() { + return rightStickDeadzoneMode; + } + + public void setRightStickDeadzoneMode(StickDeadzoneMode mode) { + rightStickDeadzoneMode = mode != null ? mode : DEFAULT_STICK_DEADZONE_MODE; + } + + public StickDigitalMode getLeftStickDigitalMode() { + return leftStickDigitalMode; + } + + public void setLeftStickDigitalMode(StickDigitalMode mode) { + leftStickDigitalMode = mode != null ? mode : DEFAULT_STICK_DIGITAL_MODE; + } + + public StickDigitalMode getRightStickDigitalMode() { + return rightStickDigitalMode; + } + + public void setRightStickDigitalMode(StickDigitalMode mode) { + rightStickDigitalMode = mode != null ? mode : DEFAULT_STICK_DIGITAL_MODE; + } + /** * 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. @@ -206,6 +290,10 @@ public void save() { data.put("rightStickDeadzone", (double) rightStickDeadzone); data.put("leftStickSensitivity", (double) leftStickSensitivity); data.put("rightStickSensitivity", (double) rightStickSensitivity); + data.put("leftStickDeadzoneMode", leftStickDeadzoneMode.getJsonName()); + data.put("rightStickDeadzoneMode", rightStickDeadzoneMode.getJsonName()); + data.put("leftStickDigitalMode", leftStickDigitalMode.getJsonName()); + data.put("rightStickDigitalMode", rightStickDigitalMode.getJsonName()); JSONArray elementsJSONArray = new JSONArray(); if (!elementsLoaded && file.isFile()) { diff --git a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java index 3a93e9da9e..cc2ed3a989 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java +++ b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java @@ -47,6 +47,8 @@ public class ExternalController { private static volatile float stickDeadzoneRight = ControlsProfile.DEFAULT_STICK_DEADZONE; private static volatile float stickSensitivityLeft = ControlsProfile.DEFAULT_STICK_SENSITIVITY; private static volatile float stickSensitivityRight = ControlsProfile.DEFAULT_STICK_SENSITIVITY; + private static volatile ControlsProfile.StickDeadzoneMode stickDeadzoneModeLeft = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; + private static volatile ControlsProfile.StickDeadzoneMode stickDeadzoneModeRight = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; private String id; private String name; @@ -55,6 +57,10 @@ 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; /** Applies {@code profile}'s stick deadzone/sensitivity to every physical controller. */ public static void setStickTuning(ControlsProfile profile) { @@ -63,12 +69,16 @@ public static void setStickTuning(ControlsProfile profile) { stickDeadzoneRight = ControlsProfile.DEFAULT_STICK_DEADZONE; stickSensitivityLeft = ControlsProfile.DEFAULT_STICK_SENSITIVITY; stickSensitivityRight = ControlsProfile.DEFAULT_STICK_SENSITIVITY; + stickDeadzoneModeLeft = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; + stickDeadzoneModeRight = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; return; } stickDeadzoneLeft = profile.getLeftStickDeadzone(); stickDeadzoneRight = profile.getRightStickDeadzone(); stickSensitivityLeft = profile.getLeftStickSensitivity(); stickSensitivityRight = profile.getRightStickSensitivity(); + stickDeadzoneModeLeft = profile.getLeftStickDeadzoneMode(); + stickDeadzoneModeRight = profile.getRightStickDeadzoneMode(); } public String getName() { @@ -202,21 +212,24 @@ public String toString() { return getDeviceId() + " | " + getName(); } - static float tuneStickAxis(float value, float deadzone, float sensitivity) { - float adjusted = ControlsProfile.applyStickDeadzone(value, deadzone); - return Mathf.clamp(adjusted * sensitivity, -1, 1); - } - - static float resolveStickAxis(boolean reported, float retained, float value, float deadzone, float sensitivity) { - return JoyConSupport.axisValue(reported, retained, tuneStickAxis(value, deadzone, sensitivity)); + 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, stickDeadzoneLeft, stickSensitivityLeft); - this.state.thumbLY = updateAxis(event, MotionEvent.AXIS_Y, historyPos, this.state.thumbLY, stickDeadzoneLeft, stickSensitivityLeft); - this.state.thumbRX = updateAxis(event, MotionEvent.AXIS_Z, historyPos, this.state.thumbRX, stickDeadzoneRight, stickSensitivityRight); - this.state.thumbRY = updateAxis(event, MotionEvent.AXIS_RZ, historyPos, this.state.thumbRY, stickDeadzoneRight, stickSensitivityRight); + 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); + StickVectorProcessor.Vector left = StickVectorProcessor.tune( + rawThumbLX, rawThumbLY, stickDeadzoneLeft, stickSensitivityLeft, stickDeadzoneModeLeft); + StickVectorProcessor.Vector right = StickVectorProcessor.tune( + rawThumbRX, rawThumbRY, stickDeadzoneRight, stickSensitivityRight, stickDeadzoneModeRight); + 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); @@ -235,15 +248,13 @@ private void processJoystickInput(MotionEvent event, int historyPos) { } } - private static float updateAxis(MotionEvent event, int axis, int historyPos, float retained, float deadzone, float sensitivity) { + 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 resolveStickAxis( + return resolveRawAxis( reported, retained, - getCenteredAxis(event, axis, historyPos), - deadzone, - sensitivity + getCenteredAxis(event, axis, historyPos) ); } diff --git a/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java b/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java index c27606253e..ea80935680 100644 --- a/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java +++ b/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java @@ -253,6 +253,10 @@ public static ControlsProfile loadProfile(Context context, InputStream inStream) float rightStickDeadzone = Float.NaN; float leftStickSensitivity = Float.NaN; float rightStickSensitivity = Float.NaN; + ControlsProfile.StickDeadzoneMode leftStickDeadzoneMode = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; + ControlsProfile.StickDeadzoneMode rightStickDeadzoneMode = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; + ControlsProfile.StickDigitalMode leftStickDigitalMode = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; + ControlsProfile.StickDigitalMode rightStickDigitalMode = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; reader.beginObject(); while (reader.hasNext()) { @@ -279,6 +283,18 @@ else if (name.equals("leftStickSensitivity")) { else if (name.equals("rightStickSensitivity")) { rightStickSensitivity = (float) reader.nextDouble(); } + else if (name.equals("leftStickDeadzoneMode")) { + leftStickDeadzoneMode = ControlsProfile.StickDeadzoneMode.fromJsonName(reader.nextString()); + } + else if (name.equals("rightStickDeadzoneMode")) { + rightStickDeadzoneMode = ControlsProfile.StickDeadzoneMode.fromJsonName(reader.nextString()); + } + else if (name.equals("leftStickDigitalMode")) { + leftStickDigitalMode = ControlsProfile.StickDigitalMode.fromJsonName(reader.nextString()); + } + else if (name.equals("rightStickDigitalMode")) { + rightStickDigitalMode = ControlsProfile.StickDigitalMode.fromJsonName(reader.nextString()); + } else { reader.skipValue(); } @@ -292,6 +308,10 @@ else if (name.equals("rightStickSensitivity")) { profile.setRightStickDeadzone(rightStickDeadzone); profile.setLeftStickSensitivity(leftStickSensitivity); profile.setRightStickSensitivity(rightStickSensitivity); + profile.setLeftStickDeadzoneMode(leftStickDeadzoneMode); + profile.setRightStickDeadzoneMode(rightStickDeadzoneMode); + profile.setLeftStickDigitalMode(leftStickDigitalMode); + profile.setRightStickDigitalMode(rightStickDigitalMode); return profile; } catch (IOException 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..dddb22fe89 --- /dev/null +++ b/app/src/main/java/com/winlator/inputcontrols/StickVectorProcessor.java @@ -0,0 +1,179 @@ +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; + public static final int MASK_RIGHT = 1; + public static final int MASK_DOWN = 1 << 1; + public static final int MASK_LEFT = 1 << 2; + public static final int MASK_UP = 1 << 3; + public static final int MASK_ALL = MASK_RIGHT | MASK_DOWN | MASK_LEFT | MASK_UP; + 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 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; + } + + public static int directionMask(int direction) { + switch (direction) { + case 0: return MASK_RIGHT; + case 1: return MASK_RIGHT | MASK_DOWN; + case 2: return MASK_DOWN; + case 3: return MASK_DOWN | MASK_LEFT; + case 4: return MASK_LEFT; + case 5: return MASK_LEFT | MASK_UP; + case 6: return MASK_UP; + case 7: return MASK_UP | MASK_RIGHT; + default: return 0; + } + } + + public static boolean allowsAxis(int directionMask, boolean horizontal, float value) { + if (value == 0) return false; + int requiredMask = horizontal + ? (value > 0 ? MASK_RIGHT : MASK_LEFT) + : (value > 0 ? MASK_DOWN : MASK_UP); + return (directionMask & requiredMask) != 0; + } + + 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 fda31c3b12..e8ded85fc7 100644 --- a/app/src/main/java/com/winlator/widget/InputControlsView.java +++ b/app/src/main/java/com/winlator/widget/InputControlsView.java @@ -37,6 +37,7 @@ import com.winlator.inputcontrols.ExternalController; import com.winlator.inputcontrols.ExternalControllerBinding; import com.winlator.inputcontrols.GamepadState; +import com.winlator.inputcontrols.StickVectorProcessor; import com.winlator.math.Mathf; import com.winlator.winhandler.MouseEventFlags; import com.winlator.winhandler.WinHandler; @@ -70,6 +71,7 @@ public class InputControlsView extends View { private final Set activeSequenceCombos = Collections.newSetFromMap(new IdentityHashMap<>()); private final Map activeSequenceBindings = new EnumMap<>(Binding.class); private final Map activeSequenceSources = new IdentityHashMap<>(); + private final Map snappedStickDirections = new IdentityHashMap<>(); private int sequenceGeneration = 0; private int snappingSize; private float offsetX; @@ -391,6 +393,7 @@ public void setProfilePreservingOverlayVisibility(ControlsProfile profile) { private void setProfileInternal(ControlsProfile profile, boolean preserveOverlayVisibility) { cancelTouchRouting(); + snappedStickDirections.clear(); final boolean profileChanged; final boolean overlayProfileVisible; @@ -430,6 +433,7 @@ private void setProfileInternal(ControlsProfile profile, boolean preserveOverlay /** Hides the controls profile from drawing without disabling per-game gyro input. */ public void hideProfileForOverlay() { cancelTouchRouting(); + snappedStickDirections.clear(); synchronized (this) { this.profile = null; } @@ -590,6 +594,31 @@ private void processJoystickInput(ExternalController controller) { final int[] axes = {MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ, MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y}; final float[] values = {controller.state.thumbLX, controller.state.thumbLY, controller.state.thumbRX, controller.state.thumbRY, controller.state.getDPadX(), controller.state.getDPadY()}; + ControlsProfile.StickDigitalMode leftMode = profile.getLeftStickDigitalMode(); + ControlsProfile.StickDigitalMode rightMode = profile.getRightStickDigitalMode(); + int[] previousDirections = snappedStickDirections.computeIfAbsent( + controller, + ignored -> new int[]{StickVectorProcessor.DIRECTION_NONE, StickVectorProcessor.DIRECTION_NONE}); + int leftDirection = StickVectorProcessor.snapDirection( + values[0], values[1], leftMode, previousDirections[0]); + int rightDirection = StickVectorProcessor.snapDirection( + values[2], values[3], rightMode, previousDirections[1]); + previousDirections[0] = leftDirection; + previousDirections[1] = rightDirection; + int leftDirectionMask = StickVectorProcessor.directionMask(leftDirection); + int rightDirectionMask = StickVectorProcessor.directionMask(rightDirection); + + for (byte i = 0; i < PHYSICAL_STICK_AXIS_COUNT; i++) { + ControlsProfile.StickDigitalMode mode = i < 2 ? leftMode : rightMode; + if (mode == ControlsProfile.StickDigitalMode.UNRESTRICTED || values[i] == 0) continue; + int keyCode = ExternalControllerBinding.getKeyCodeForAxis(axes[i], Mathf.sign(values[i])); + ExternalControllerBinding binding = controller.getControllerBinding(keyCode); + if (binding == null || binding.getBindingCombo().usesAnalogOffset()) continue; + int directionMask = i < 2 ? leftDirectionMask : rightDirectionMask; + boolean horizontal = i == 0 || i == 2; + if (!StickVectorProcessor.allowsAxis(directionMask, horizontal, values[i])) values[i] = 0; + } + for (byte i = 0; i < axes.length; i++) { // Indices 0..3 are the analog sticks, whose values already had the profile's deadzone // and sensitivity applied by ExternalController, so anything non-zero is live input. diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index f4d1ac7f20..ff2c1dff98 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -211,6 +211,18 @@ Højre sticks følsomhed Hvor langt styrepinden skal bevæges, før input registreres. Øg værdien for at stoppe drift. Skalerer, hvor meget bevægelse spillet registrerer. Over 1,0 nås fuldt udslag tidligere. + Venstre sticks dødzoneform + Højre sticks dødzoneform + Vælg, hvordan dødzonen måles. Hybrid giver stærkere justering nær hovedretningerne. + Aksial + Cirkulær + Hybrid + Venstre sticks retningsfastlåsning + Højre sticks retningsfastlåsning + Begrænser kun tilknyttede digitale retninger; analoge stick- og musebevægelsesbindinger forbliver kontinuerlige. + 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 fdee058697..c2fef2fd91 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -275,6 +275,18 @@ 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 die Stickbewegung, die das Spiel erkennt. Über 1,0 wird der volle Ausschlag früher erreicht. + Totzonenform des linken Sticks + Totzonenform des rechten Sticks + Wählt, wie die Totzone gemessen wird. Hybrid richtet Bewegungen nahe den Hauptrichtungen stärker aus. + Axial + Kreisförmig + Hybrid + Richtungseinrastung des linken Sticks + Richtungseinrastung des rechten Sticks + Beschränkt nur zugewiesene digitale Richtungen; analoge Stick- und Mausbewegungsbelegungen bleiben stufenlos. + 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 ea0e8a6c56..4766d05f6e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -293,6 +293,18 @@ Sensibilidad del stick derecho Distancia que debe recorrer el stick antes de registrar una entrada. Auméntala para evitar el drift. Ajusta cuánto movimiento del stick recibe el juego. Por encima de 1,0 se alcanza antes el recorrido máximo. + Forma de zona muerta del stick izquierdo + Forma de zona muerta del stick derecho + Elige cómo se mide la zona muerta. Híbrida refuerza la alineación cerca de las direcciones cardinales. + Axial + Circular + Híbrida + Ajuste direccional del stick izquierdo + Ajuste direccional del stick derecho + Solo restringe las direcciones digitales asignadas; las asignaciones analógicas y de movimiento del ratón continúan siendo graduales. + 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 e48b268953..2c551158a9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -283,6 +283,18 @@ 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. Ajuste l’amplitude du mouvement transmis au jeu. Au-dessus de 1,0, la course maximale est atteinte plus tôt. + Forme de la zone morte du joystick gauche + Forme de la zone morte du joystick droit + Choisissez la méthode de mesure de la zone morte. Hybride renforce l’alignement près des axes cardinaux. + Axiale + Circulaire + Hybride + Verrouillage directionnel du joystick gauche + Verrouillage directionnel du joystick droit + Limite uniquement les directions numériques assignées ; les commandes analogiques et de déplacement de la souris restent progressives. + 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 76ee884dde..2f0cff8bf0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -282,6 +282,18 @@ Sensibilità levetta destra Distanza che la levetta deve percorrere prima che l’input venga rilevato. Aumentala per eliminare il drift. Regola l’ampiezza del movimento trasmesso al gioco. Oltre 1,0, la corsa massima viene raggiunta prima. + Forma zona morta levetta sinistra + Forma zona morta levetta destra + Scegli come viene misurata la zona morta. Ibrida rafforza l’allineamento vicino agli assi cardinali. + Assiale + Circolare + Ibrida + Aggancio direzionale levetta sinistra + Aggancio direzionale levetta destra + Limita solo le direzioni digitali assegnate; i comandi analogici e di movimento del mouse restano continui. + 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 3355f1f1d8..e06859d41e 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -236,6 +236,18 @@ 右スティックの感度 入力が反応し始めるまでに必要なスティックの移動量です。ドリフトを抑えるには値を上げます。 ゲームに伝わるスティック移動量を調整します。1.0を超えると、より早く最大入力に達します。 + 左スティックのデッドゾーン形状 + 右スティックのデッドゾーン形状 + デッドゾーンの測定方法を選択します。ハイブリッドは上下左右の軸付近で方向をより強く揃えます。 + 軸別 + 円形 + ハイブリッド + 左スティックの方向スナップ + 右スティックの方向スナップ + 割り当てられたデジタル方向のみを制限します。アナログスティックとマウス移動の割り当ては連続入力のままです。 + 制限なし + 4方向 + 8方向 十字キー上 十字キー下 十字キー左 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 027cb65b86..3970fd2d38 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -290,6 +290,18 @@ 오른쪽 스틱 감도 입력이 감지되기 전에 스틱을 움직여야 하는 거리입니다. 드리프트를 멈추려면 값을 높이세요. 게임에 전달되는 스틱 움직임을 조절합니다. 1.0보다 높으면 최대 입력에 더 빨리 도달합니다. + 왼쪽 스틱 데드존 형태 + 오른쪽 스틱 데드존 형태 + 데드존 측정 방식을 선택합니다. 하이브리드는 상하좌우 축 근처에서 방향을 더 강하게 맞춥니다. + 축별 + 원형 + 하이브리드 + 왼쪽 스틱 방향 스냅 + 오른쪽 스틱 방향 스냅 + 매핑된 디지털 방향만 제한하며 아날로그 스틱과 마우스 이동 매핑은 연속 입력을 유지합니다. + 제한 없음 + 4방향 + 8방향 십자 버튼 위 십자 버튼 아래 십자 버튼 왼쪽 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 08434b7f73..2ea69844ce 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -290,6 +290,18 @@ Czułość prawego drążka Określa, jak daleko trzeba wychylić drążek, zanim zostanie wykryty ruch. Zwiększ wartość, aby usunąć dryf. Skaluje ruch drążka odbierany przez grę. Wartość powyżej 1,0 pozwala szybciej osiągnąć pełne wychylenie. + Kształt martwej strefy lewego drążka + Kształt martwej strefy prawego drążka + Wybierz sposób pomiaru martwej strefy. Hybrydowa silniej wyrównuje ruch w pobliżu osi głównych. + Osiowa + Kołowa + Hybrydowa + Przyciąganie kierunkowe lewego drążka + Przyciąganie kierunkowe prawego drążka + Ogranicza tylko przypisane kierunki cyfrowe; przypisania analogowego drążka i ruchu myszy pozostają płynne. + 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 d82dcbbec8..d4650c25d7 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -211,6 +211,18 @@ Sensibilidade do analógico direito Define o quanto o analógico deve se mover antes de registrar uma entrada. Aumente para eliminar o drift. Ajusta quanto movimento do analógico o jogo recebe. Acima de 1,0, o curso máximo é atingido mais cedo. + Formato da zona morta do analógico esquerdo + Formato da zona morta do analógico direito + Escolha como a zona morta é medida. Híbrido reforça o alinhamento próximo aos eixos cardeais. + Axial + Circular + Híbrido + Encaixe direcional do analógico esquerdo + Encaixe direcional do analógico direito + Restringe apenas direções digitais mapeadas; mapeamentos de analógico e movimento do mouse continuam graduais. + 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 2f5209fa11..9ca432e489 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -280,6 +280,18 @@ 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. Scalează mișcarea stickului transmisă jocului. Peste 1,0, cursa maximă este atinsă mai repede. + Forma zonei moarte a stickului stâng + Forma zonei moarte a stickului drept + Alegeți cum este măsurată zona moartă. Hibrid aliniază mai puternic mișcarea lângă axele cardinale. + Axială + Circulară + Hibridă + Fixare direcțională stick stâng + Fixare direcțională stick drept + Limitează doar direcțiile digitale asociate; comenzile analogice și de mișcare a mouse-ului rămân continue. + 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 d0c5cc560b..079e954b34 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -397,6 +397,18 @@ Чувствительность правого джойстика Определяет, насколько нужно отклонить стик, прежде чем ввод будет зарегистрирован. Увеличьте значение, чтобы устранить дрейф. Масштабирует отклонение стика, передаваемое игре. При значении выше 1,0 максимальное отклонение достигается раньше. + Форма мёртвой зоны левого джойстика + Форма мёртвой зоны правого джойстика + Выберите способ измерения мёртвой зоны. Гибридный режим сильнее выравнивает движение возле основных осей. + Осевая + Круговая + Гибридная + Фиксация направлений левого джойстика + Фиксация направлений правого джойстика + Ограничивает только назначенные цифровые направления; аналоговые привязки стика и движения мыши остаются плавными. + Без ограничений + 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 0e7e919a02..eebd43527a 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -277,6 +277,18 @@ Чутливість правого стіка Визначає, наскільки потрібно відхилити стік, перш ніж введення буде зареєстровано. Збільште значення, щоб усунути дрейф. Масштабує відхилення стіка, передане грі. За значення понад 1,0 максимальне відхилення досягається раніше. + Форма мертвої зони лівого стіка + Форма мертвої зони правого стіка + Виберіть спосіб вимірювання мертвої зони. Гібридний режим сильніше вирівнює рух біля основних осей. + Осьова + Кругова + Гібридна + Фіксація напрямків лівого стіка + Фіксація напрямків правого стіка + Обмежує лише призначені цифрові напрямки; аналогові прив’язки стіка й руху миші залишаються плавними. + Без обмежень + 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 6d825e44fc..ecc02b8bbb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -275,6 +275,18 @@ 右摇杆灵敏度 摇杆移动多远后才会检测到输入。调高可消除漂移。 调整游戏接收到的摇杆移动幅度。高于 1.0 时会更快达到最大输入。 + 左摇杆死区形状 + 右摇杆死区形状 + 选择死区的测量方式。混合模式会在上下左右轴附近增强方向对齐。 + 轴向 + 圆形 + 混合 + 左摇杆方向吸附 + 右摇杆方向吸附 + 仅限制已映射的数字方向;模拟摇杆和鼠标移动映射仍保持连续输入。 + 不限制 + 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 9d414646d3..a4471976bd 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -275,6 +275,18 @@ 右搖桿靈敏度 搖桿移動多遠後才會偵測到輸入。調高可消除飄移。 調整遊戲接收到的搖桿移動幅度。高於 1.0 時會更快達到最大輸入。 + 左搖桿死區形狀 + 右搖桿死區形狀 + 選擇死區的測量方式。混合模式會在上下左右軸附近增強方向對齊。 + 軸向 + 圓形 + 混合 + 左搖桿方向吸附 + 右搖桿方向吸附 + 僅限制已對應的數位方向;類比搖桿和滑鼠移動對應仍保持連續輸入。 + 不限制 + 4向 + 8向 方向鍵 上 方向鍵 下 方向鍵 左 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 52733c7e8e..1f2e1866fb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -270,6 +270,18 @@ Right Stick Sensitivity How far the stick must move before input registers. Raise it to stop drift. Scales how much stick movement the game sees. Above 1.0 reaches full travel sooner. + Left Stick Deadzone Shape + Right Stick Deadzone Shape + Choose how the deadzone is measured. Hybrid adds stronger alignment near the cardinal axes. + Axial + Circular + Hybrid + Left Stick Directional Snapping + Right Stick Directional Snapping + Restricts mapped digital directions only; analog stick and mouse movement bindings remain continuous. + Unrestricted + 4-way + 8-way D-Pad Up D-Pad Down D-Pad Left 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 4091b86955..286f2d742f 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,45 @@ import java.util.concurrent.TimeUnit.MILLISECONDS @RunWith(RobolectricTestRunner::class) class PhysicalControllerHandlerTest { + @Test + fun `four way snapping activates only the selected cardinal 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) + whenever(profile.leftStickDigitalMode).thenReturn(ControlsProfile.StickDigitalMode.FOUR_WAY) + whenever(profile.rightStickDigitalMode).thenReturn(ControlsProfile.StickDigitalMode.UNRESTRICTED) + val handler = PhysicalControllerHandler(profile, mock(), gamepadStateSender = {}) + val event = motionEvent(deviceId) + + try { + controller.state.thumbLX = 0.8f + controller.state.thumbLY = 0.2f + assertTrue(handler.onGenericMotionEvent(event)) + assertTrue(gamepadState.dpad[1]) + assertFalse(gamepadState.dpad[2]) + + controller.state.thumbLX = 0.2f + controller.state.thumbLY = 0.8f + assertTrue(handler.onGenericMotionEvent(event)) + assertFalse(gamepadState.dpad[1]) + assertTrue(gamepadState.dpad[2]) + } finally { + handler.cleanup() + } + } + @Test fun `vertical physical stick release clears gyro mixed gamepad state`() { 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 index 931af18047..54ec66fda2 100644 --- a/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt @@ -2,6 +2,8 @@ package com.winlator.inputcontrols import android.content.Context 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 @@ -39,27 +41,160 @@ class PhysicalControllerStickTuningTest { } @Test - fun `deadzone rescales both directions and sensitivity saturates output`() { + 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) - assertEquals(0.75f, ExternalController.tuneStickAxis(0.6f, 0.2f, 1.5f), 0.0001f) - assertEquals(1f, ExternalController.tuneStickAxis(0.6f, 0.2f, 3f), 0f) - assertEquals(0f, ExternalController.tuneStickAxis(Float.NaN, 0.2f, 1f), 0f) + 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 `an unreported Joy-Con axis retains its already tuned value`() { - assertEquals( - 0.65f, - ExternalController.resolveStickAxis(false, 0.65f, -0.6f, 0.2f, 1f), + 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 `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) + 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) assertEquals( - -0.5f, - ExternalController.resolveStickAxis(true, 0.65f, -0.6f, 0.2f, 1f), - 0.0001f, + StickVectorProcessor.MASK_RIGHT or StickVectorProcessor.MASK_DOWN, + StickVectorProcessor.directionMask(diagonal), ) + assertEquals( + StickVectorProcessor.DIRECTION_NONE, + StickVectorProcessor.snapDirection( + 1f, + 1f, + ControlsProfile.StickDigitalMode.UNRESTRICTED, + diagonal, + ), + ) + } + + @Test + fun `digital snapping leaves analog magnitude consumers unrestricted`() { + assertTrue(BindingCombo.of(Binding.GAMEPAD_LEFT_THUMB_RIGHT).usesAnalogOffset()) + assertTrue(BindingCombo.of(Binding.MOUSE_MOVE_RIGHT).usesAnalogOffset()) + assertFalse(BindingCombo.of(Binding.GAMEPAD_DPAD_RIGHT).usesAnalogOffset()) + assertFalse(BindingCombo.of(Binding.KEY_D).usesAnalogOffset()) + } + + @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 @@ -70,6 +205,10 @@ class PhysicalControllerStickTuningTest { 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) val reordered = load( """{ @@ -81,6 +220,10 @@ class PhysicalControllerStickTuningTest { "leftStickDeadzone":0.05, "rightStickDeadzone":0.3, "leftStickSensitivity":1.2, + "rightStickDeadzoneMode":"hybrid", + "leftStickDeadzoneMode":"circular", + "rightStickDigitalMode":"eight_way", + "leftStickDigitalMode":"four_way", "cursorSpeed":1.4 }""".trimIndent(), ) @@ -89,6 +232,10 @@ class PhysicalControllerStickTuningTest { 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) } @Test @@ -99,6 +246,10 @@ class PhysicalControllerStickTuningTest { 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() } @@ -109,6 +260,10 @@ class PhysicalControllerStickTuningTest { 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) } private fun load(json: String): ControlsProfile { @@ -119,4 +274,8 @@ class PhysicalControllerStickTuningTest { ), ) } + + private fun vectorX(degrees: Double): Float = Math.cos(Math.toRadians(degrees)).toFloat() + + private fun vectorY(degrees: Double): Float = Math.sin(Math.toRadians(degrees)).toFloat() } From 327e1173594e658d9c709b3ad891a090b7147c89 Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Mon, 7 Sep 2026 16:24:46 -0500 Subject: [PATCH 04/13] fix(input): improve physical stick tuning controls --- .../dialog/PhysicalControllerConfigSection.kt | 58 +++++++------------ app/src/main/res/values-da/strings.xml | 6 +- app/src/main/res/values-de/strings.xml | 6 +- app/src/main/res/values-es/strings.xml | 6 +- app/src/main/res/values-fr/strings.xml | 6 +- app/src/main/res/values-it/strings.xml | 6 +- app/src/main/res/values-ja/strings.xml | 6 +- app/src/main/res/values-ko/strings.xml | 6 +- app/src/main/res/values-pl/strings.xml | 6 +- app/src/main/res/values-pt-rBR/strings.xml | 6 +- app/src/main/res/values-ro/strings.xml | 6 +- app/src/main/res/values-ru/strings.xml | 6 +- app/src/main/res/values-uk/strings.xml | 6 +- app/src/main/res/values-zh-rCN/strings.xml | 6 +- app/src/main/res/values-zh-rTW/strings.xml | 6 +- app/src/main/res/values/strings.xml | 6 +- .../PhysicalControllerStickTuningTest.kt | 22 +++++++ 17 files changed, 103 insertions(+), 67 deletions(-) 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 5bf87ce7d0..7ae97fd2f1 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 @@ -710,7 +710,7 @@ private fun StickTuningSection( ) StickModeSelector( label = stringResource(R.string.left_stick_deadzone_shape), - description = stringResource(R.string.stick_deadzone_shape_description), + description = stickDeadzoneModeDescription(leftStickDeadzoneMode), selected = leftStickDeadzoneMode, options = listOf( ControlsProfile.StickDeadzoneMode.AXIAL to stringResource(R.string.stick_deadzone_axial), @@ -750,7 +750,7 @@ private fun StickTuningSection( ) StickModeSelector( label = stringResource(R.string.right_stick_deadzone_shape), - description = stringResource(R.string.stick_deadzone_shape_description), + description = stickDeadzoneModeDescription(rightStickDeadzoneMode), selected = rightStickDeadzoneMode, options = listOf( ControlsProfile.StickDeadzoneMode.AXIAL to stringResource(R.string.stick_deadzone_axial), @@ -782,6 +782,16 @@ private fun StickTuningSection( } } +@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) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun StickModeSelector( @@ -834,41 +844,15 @@ private fun StickAdjustmentSlider( steps: Int, onValueChange: (Float) -> Unit ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.small - ) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - Text( - text = displayValue, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold - ) - } - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Slider( - value = value, - onValueChange = onValueChange, - valueRange = valueRange, - steps = steps - ) - } - } + 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 diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index ff2c1dff98..b6d2a4d708 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -210,10 +210,12 @@ 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, hvor meget bevægelse spillet registrerer. Over 1,0 nås fuldt udslag tidligere. + 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 - Vælg, hvordan dødzonen måles. Hybrid giver stærkere justering nær hovedretningerne. + 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 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c2fef2fd91..83550121d8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -274,10 +274,12 @@ 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 die Stickbewegung, die das Spiel erkennt. Über 1,0 wird der volle Ausschlag früher erreicht. + 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 - Wählt, wie die Totzone gemessen wird. Hybrid richtet Bewegungen nahe den Hauptrichtungen stärker aus. + 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 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4766d05f6e..a4169b1121 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -292,10 +292,12 @@ 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. - Ajusta cuánto movimiento del stick recibe el juego. Por encima de 1,0 se alcanza antes el recorrido máximo. + 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 - Elige cómo se mide la zona muerta. Híbrida refuerza la alineación cerca de las direcciones cardinales. + 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 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 2c551158a9..6b0efd9552 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -282,10 +282,12 @@ 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. - Ajuste l’amplitude du mouvement transmis au jeu. Au-dessus de 1,0, la course maximale est atteinte plus tôt. + 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 - Choisissez la méthode de mesure de la zone morte. Hybride renforce l’alignement près des axes cardinaux. + 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2f0cff8bf0..912712416f 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -281,10 +281,12 @@ Sensibilità levetta sinistra Sensibilità levetta destra Distanza che la levetta deve percorrere prima che l’input venga rilevato. Aumentala per eliminare il drift. - Regola l’ampiezza del movimento trasmesso al gioco. Oltre 1,0, la corsa massima viene raggiunta prima. + 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 - Scegli come viene misurata la zona morta. Ibrida rafforza l’allineamento vicino agli assi cardinali. + 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 diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e06859d41e..ce41d96672 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -235,10 +235,12 @@ 左スティックの感度 右スティックの感度 入力が反応し始めるまでに必要なスティックの移動量です。ドリフトを抑えるには値を上げます。 - ゲームに伝わるスティック移動量を調整します。1.0を超えると、より早く最大入力に達します。 + スティックの途中までの倒し量を拡大します。1.0を超えてもゲームパッドの最大出力は超えず、より少ない物理的な倒し量で最大に達します。 左スティックのデッドゾーン形状 右スティックのデッドゾーン形状 - デッドゾーンの測定方法を選択します。ハイブリッドは上下左右の軸付近で方向をより強く揃えます。 + 各軸を別々に測定します。水平・垂直移動を直線に保ちやすい一方、斜め移動はやや不均一に感じる場合があります。 + 中心からの距離を1つの円として測定します。スティックの方向を保ち、全方向で滑らかで均一な動きになります。 + 円形デッドゾーンを使い、水平・垂直に近い動きを軸に固定します。滑らかな斜め移動と直線的な上下左右移動を両立します。 軸別 円形 ハイブリッド diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 3970fd2d38..8dea7273af 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -289,10 +289,12 @@ 왼쪽 스틱 감도 오른쪽 스틱 감도 입력이 감지되기 전에 스틱을 움직여야 하는 거리입니다. 드리프트를 멈추려면 값을 높이세요. - 게임에 전달되는 스틱 움직임을 조절합니다. 1.0보다 높으면 최대 입력에 더 빨리 도달합니다. + 스틱을 일부만 기울였을 때의 입력을 확대합니다. 1.0을 넘어도 게임패드 최대 출력을 초과하지 않으며, 더 적게 기울여도 최대 출력에 도달합니다. 왼쪽 스틱 데드존 형태 오른쪽 스틱 데드존 형태 - 데드존 측정 방식을 선택합니다. 하이브리드는 상하좌우 축 근처에서 방향을 더 강하게 맞춥니다. + 각 축을 따로 측정합니다. 수평·수직 이동을 곧게 유지하기 쉽지만 대각선 이동은 덜 균일하게 느껴질 수 있습니다. + 중심에서의 거리를 하나의 원으로 측정합니다. 스틱 방향을 유지해 모든 방향에서 부드럽고 균일하게 움직입니다. + 원형 데드존을 사용한 뒤 수평·수직에 가까운 움직임을 축에 고정합니다. 부드러운 대각선과 곧은 상하좌우 이동을 함께 제공합니다. 축별 원형 하이브리드 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 2ea69844ce..2f92fe607a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -289,10 +289,12 @@ 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 ruch drążka odbierany przez grę. Wartość powyżej 1,0 pozwala szybciej osiągnąć pełne wychylenie. + 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 - Wybierz sposób pomiaru martwej strefy. Hybrydowa silniej wyrównuje ruch w pobliżu osi głównych. + 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index d4650c25d7..0fb4051028 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -210,10 +210,12 @@ 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. - Ajusta quanto movimento do analógico o jogo recebe. Acima de 1,0, o curso máximo é atingido mais cedo. + 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 - Escolha como a zona morta é medida. Híbrido reforça o alinhamento próximo aos eixos cardeais. + 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 diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 9ca432e489..ea5ac0d547 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -279,10 +279,12 @@ 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. - Scalează mișcarea stickului transmisă jocului. Peste 1,0, cursa maximă este atinsă mai repede. + 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 - Alegeți cum este măsurată zona moartă. Hibrid aliniază mai puternic mișcarea lângă axele cardinale. + 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ă diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 079e954b34..76c099c6ed 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -396,10 +396,12 @@ Чувствительность левого джойстика Чувствительность правого джойстика Определяет, насколько нужно отклонить стик, прежде чем ввод будет зарегистрирован. Увеличьте значение, чтобы устранить дрейф. - Масштабирует отклонение стика, передаваемое игре. При значении выше 1,0 максимальное отклонение достигается раньше. + Усиливает частичное отклонение стика. Значение выше 1,0 не превышает максимум геймпада, а достигает его при меньшем физическом отклонении. Форма мёртвой зоны левого джойстика Форма мёртвой зоны правого джойстика - Выберите способ измерения мёртвой зоны. Гибридный режим сильнее выравнивает движение возле основных осей. + Измеряет каждую ось отдельно. Упрощает прямое движение по горизонтали и вертикали, но диагонали могут ощущаться менее равномерными. + Измеряет расстояние от центра как единый круг. Сохраняет направление стика для плавного и равномерного движения. + Использует круглую мёртвую зону, затем фиксирует движение рядом с горизонталью и вертикалью. Сочетает плавные диагонали с более прямыми основными направлениями. Осевая Круговая Гибридная diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index eebd43527a..6be79d8307 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -276,10 +276,12 @@ Чутливість лівого стіка Чутливість правого стіка Визначає, наскільки потрібно відхилити стік, перш ніж введення буде зареєстровано. Збільште значення, щоб усунути дрейф. - Масштабує відхилення стіка, передане грі. За значення понад 1,0 максимальне відхилення досягається раніше. + Підсилює часткове відхилення стіка. Значення понад 1,0 не перевищує максимум геймпада, а досягає його за меншого фізичного відхилення. Форма мертвої зони лівого стіка Форма мертвої зони правого стіка - Виберіть спосіб вимірювання мертвої зони. Гібридний режим сильніше вирівнює рух біля основних осей. + Вимірює кожну вісь окремо. Полегшує прямий горизонтальний і вертикальний рух, але діагоналі можуть відчуватися менш рівномірними. + Вимірює відстань від центра як єдине коло. Зберігає напрямок стіка для плавного й рівномірного руху. + Використовує кругову мертву зону, а потім фіксує рух поблизу горизонталі й вертикалі. Поєднує плавні діагоналі з прямішими основними напрямками. Осьова Кругова Гібридна diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ecc02b8bbb..92b5d5a29f 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -274,10 +274,12 @@ 左摇杆灵敏度 右摇杆灵敏度 摇杆移动多远后才会检测到输入。调高可消除漂移。 - 调整游戏接收到的摇杆移动幅度。高于 1.0 时会更快达到最大输入。 + 放大摇杆的部分行程。高于 1.0 也不会超过手柄最大输出,而是用更小的物理行程达到最大输出。 左摇杆死区形状 右摇杆死区形状 - 选择死区的测量方式。混合模式会在上下左右轴附近增强方向对齐。 + 分别测量每个轴。更容易保持水平和垂直直线移动,但斜向移动可能不够均匀。 + 将距中心的距离作为一个圆来测量。保留摇杆方向,使各方向移动平滑均匀。 + 先使用圆形死区,再将接近水平或垂直的移动锁定到轴向。兼顾平滑斜向移动和更直的上下左右移动。 轴向 圆形 混合 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a4471976bd..40dee5e795 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -274,10 +274,12 @@ 左搖桿靈敏度 右搖桿靈敏度 搖桿移動多遠後才會偵測到輸入。調高可消除飄移。 - 調整遊戲接收到的搖桿移動幅度。高於 1.0 時會更快達到最大輸入。 + 放大搖桿的部分行程。高於 1.0 也不會超過控制器最大輸出,而是用較小的實際行程達到最大輸出。 左搖桿死區形狀 右搖桿死區形狀 - 選擇死區的測量方式。混合模式會在上下左右軸附近增強方向對齊。 + 分別測量每個軸。更容易維持水平和垂直直線移動,但斜向移動可能較不均勻。 + 將距中心的距離視為一個圓來測量。保留搖桿方向,使各方向移動平順均勻。 + 先使用圓形死區,再將接近水平或垂直的移動鎖定到軸向。兼顧平順斜向移動和更直的上下左右移動。 軸向 圓形 混合 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f2e1866fb..c7de541d38 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -269,10 +269,12 @@ Left Stick Sensitivity Right Stick Sensitivity How far the stick must move before input registers. Raise it to stop drift. - Scales how much stick movement the game sees. Above 1.0 reaches full travel sooner. + 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 - Choose how the deadzone is measured. Hybrid adds stronger alignment near the cardinal axes. + 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 diff --git a/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt b/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt index 54ec66fda2..1db0d65507 100644 --- a/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt @@ -115,6 +115,28 @@ class PhysicalControllerStickTuningTest { 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( From e2a408ff5567f750ae568359b5374654a3e1e6cf Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Mon, 7 Sep 2026 22:20:42 -0500 Subject: [PATCH 05/13] fix(input): apply direction snapping to analog sticks --- .../xserver/PhysicalControllerHandler.kt | 63 ------------------- .../com/winlator/inputcontrols/Binding.java | 5 -- .../winlator/inputcontrols/BindingCombo.java | 6 -- .../inputcontrols/ExternalController.java | 32 +++++++++- .../inputcontrols/StickVectorProcessor.java | 39 +++++------- .../winlator/widget/InputControlsView.java | 33 +--------- app/src/main/res/values-da/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-ro/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../xserver/PhysicalControllerHandlerTest.kt | 6 +- .../PhysicalControllerStickTuningTest.kt | 42 ++++++++++--- 23 files changed, 100 insertions(+), 156 deletions(-) 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 ec342af267..e2d9c54870 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 @@ -15,7 +15,6 @@ import com.winlator.inputcontrols.ExternalController import com.winlator.inputcontrols.ExternalControllerBinding import com.winlator.inputcontrols.GamepadState import com.winlator.inputcontrols.JoyConSupport -import com.winlator.inputcontrols.StickVectorProcessor import com.winlator.math.Mathf import com.winlator.xserver.XServer import java.util.Timer @@ -43,8 +42,6 @@ class PhysicalControllerHandler( ) { private data class PhysicalInputSource(val deviceId: Int, val keyCode: Int) - private data class PhysicalStickSource(val deviceId: Int, val rightStick: Boolean) - private data class MouseMoveSource( val deviceId: Int, val keyCode: Int, @@ -71,7 +68,6 @@ class PhysicalControllerHandler( // track which axis keycodes are currently "pressed" so we only release on actual transitions. // accessed only from main thread (MotionEvent dispatch + Compose lifecycle), no sync needed. private val activeAxisBindings = mutableSetOf() - private val snappedStickDirections = mutableMapOf() private val activeButtonBindings = mutableMapOf() private val activeTriggerBindings = mutableMapOf() private val activeSequenceTriggerBindings = mutableSetOf() @@ -145,7 +141,6 @@ class PhysicalControllerHandler( clearScrollRepeats() closeRadialMenuIfOpen(commit = false) activeSequenceTriggerBindings.clear() - snappedStickDirections.clear() sendGamepadState() this.profile = profile ExternalController.setStickTuning(profile) @@ -164,7 +159,6 @@ class PhysicalControllerHandler( clearMouseMoveContributions() clearScrollRepeats() activeSequenceTriggerBindings.clear() - snappedStickDirections.clear() showKeyboardPressed = false closeRadialMenuIfOpen(commit = false) sendGamepadState() @@ -177,7 +171,6 @@ class PhysicalControllerHandler( releaseActiveBindings(activeTriggerBindings, deviceId, fromMotion = true) releaseActiveAxes(deviceId = deviceId) releaseGyroModifierSources(deviceId) - snappedStickDirections.keys.removeAll { it.deviceId == deviceId } mouseMoveContributions.keys.removeAll { it.deviceId == deviceId } recalculateMouseMoveOffset() sendGamepadState() @@ -504,36 +497,6 @@ class PhysicalControllerHandler( controller.state.dPadX.toFloat(), controller.state.dPadY.toFloat() ) - val leftDigitalMode = profile?.leftStickDigitalMode - ?: ControlsProfile.DEFAULT_STICK_DIGITAL_MODE - val rightDigitalMode = profile?.rightStickDigitalMode - ?: ControlsProfile.DEFAULT_STICK_DIGITAL_MODE - val leftDirectionMask = resolveStickDirectionMask( - deviceId, - rightStick = false, - values[0], - values[1], - leftDigitalMode, - ) - val rightDirectionMask = resolveStickDirectionMask( - deviceId, - rightStick = true, - values[2], - values[3], - rightDigitalMode, - ) - - for (i in 0..3) { - val mode = if (i < 2) leftDigitalMode else rightDigitalMode - if (mode == ControlsProfile.StickDigitalMode.UNRESTRICTED || values[i] == 0f) continue - val keyCode = ExternalControllerBinding.getKeyCodeForAxis(axes[i], Mathf.sign(values[i])) - val binding = controller.getControllerBinding(keyCode) ?: continue - if (binding.bindingCombo.usesAnalogOffset()) continue - val directionMask = if (i < 2) leftDirectionMask else rightDirectionMask - val horizontal = i == 0 || i == 2 - if (!StickVectorProcessor.allowsAxis(directionMask, horizontal, values[i])) values[i] = 0f - } - for (i in axes.indices) { val posKeyCode = ExternalControllerBinding.getKeyCodeForAxis(axes[i], 1.toByte()) val negKeyCode = ExternalControllerBinding.getKeyCodeForAxis(axes[i], (-1).toByte()) @@ -609,32 +572,6 @@ class PhysicalControllerHandler( } } - private fun resolveStickDirectionMask( - deviceId: Int, - rightStick: Boolean, - x: Float, - y: Float, - mode: ControlsProfile.StickDigitalMode, - ): Int { - val source = PhysicalStickSource(deviceId, rightStick) - if (mode == ControlsProfile.StickDigitalMode.UNRESTRICTED) { - snappedStickDirections.remove(source) - return StickVectorProcessor.MASK_ALL - } - val direction = StickVectorProcessor.snapDirection( - x, - y, - mode, - snappedStickDirections[source] ?: StickVectorProcessor.DIRECTION_NONE, - ) - if (direction == StickVectorProcessor.DIRECTION_NONE) { - snappedStickDirections.remove(source) - } else { - snappedStickDirections[source] = direction - } - return StickVectorProcessor.directionMask(direction) - } - private fun handleTriggerBinding( keyCode: Int, legacyBinding: Binding, diff --git a/app/src/main/java/com/winlator/inputcontrols/Binding.java b/app/src/main/java/com/winlator/inputcontrols/Binding.java index e758e711b1..cb103b99ed 100644 --- a/app/src/main/java/com/winlator/inputcontrols/Binding.java +++ b/app/src/main/java/com/winlator/inputcontrols/Binding.java @@ -209,11 +209,6 @@ public boolean isGamepadAxis() { ordinal() <= GAMEPAD_RIGHT_THUMB_LEFT.ordinal(); } - public boolean usesAnalogOffset() { - return isGamepadAxis() || this == MOUSE_MOVE_LEFT || this == MOUSE_MOVE_RIGHT || - this == MOUSE_MOVE_UP || this == MOUSE_MOVE_DOWN; - } - public boolean isExtra() { return this == OPEN_NAVIGATION_MENU || this == SHOW_KEYBOARD || this == ALT_ENTER || this == OPEN_RADIAL_MENU || this == GYRO_MODIFIER; diff --git a/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java b/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java index 546729f749..7d9686b798 100644 --- a/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java +++ b/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java @@ -197,12 +197,6 @@ public boolean isGamepadOnly() { return true; } - /** True when any binding consumes the analog magnitude rather than just press/release state. */ - public boolean usesAnalogOffset() { - for (Binding binding : bindings) if (binding.usesAnalogOffset()) return true; - return false; - } - public void writeToJsonObject(JSONObject object) throws JSONException { object.put("bindings", toJsonArray()); if (mode == Mode.SEQUENCE) { diff --git a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java index cc2ed3a989..b893737a5c 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java +++ b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java @@ -49,6 +49,8 @@ public class ExternalController { private static volatile float stickSensitivityRight = ControlsProfile.DEFAULT_STICK_SENSITIVITY; private static volatile ControlsProfile.StickDeadzoneMode stickDeadzoneModeLeft = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; private static volatile ControlsProfile.StickDeadzoneMode stickDeadzoneModeRight = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; + private static volatile ControlsProfile.StickDigitalMode stickDirectionModeLeft = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; + private static volatile ControlsProfile.StickDigitalMode stickDirectionModeRight = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; private String id; private String name; @@ -61,8 +63,10 @@ public class ExternalController { 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 deadzone/sensitivity to every physical controller. */ + /** Applies {@code profile}'s stick tuning to every physical controller. */ public static void setStickTuning(ControlsProfile profile) { if (profile == null) { stickDeadzoneLeft = ControlsProfile.DEFAULT_STICK_DEADZONE; @@ -71,6 +75,8 @@ public static void setStickTuning(ControlsProfile profile) { stickSensitivityRight = ControlsProfile.DEFAULT_STICK_SENSITIVITY; stickDeadzoneModeLeft = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; stickDeadzoneModeRight = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; + stickDirectionModeLeft = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; + stickDirectionModeRight = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; return; } stickDeadzoneLeft = profile.getLeftStickDeadzone(); @@ -79,6 +85,8 @@ public static void setStickTuning(ControlsProfile profile) { stickSensitivityRight = profile.getRightStickSensitivity(); stickDeadzoneModeLeft = profile.getLeftStickDeadzoneMode(); stickDeadzoneModeRight = profile.getRightStickDeadzoneMode(); + stickDirectionModeLeft = profile.getLeftStickDigitalMode(); + stickDirectionModeRight = profile.getRightStickDigitalMode(); } public String getName() { @@ -226,6 +234,8 @@ private void processJoystickInput(MotionEvent event, int historyPos) { rawThumbLX, rawThumbLY, stickDeadzoneLeft, stickSensitivityLeft, stickDeadzoneModeLeft); StickVectorProcessor.Vector right = StickVectorProcessor.tune( rawThumbRX, rawThumbRY, stickDeadzoneRight, stickSensitivityRight, stickDeadzoneModeRight); + left = applyDirectionSnapping(left, stickDirectionModeLeft, false); + right = applyDirectionSnapping(right, stickDirectionModeRight, true); this.state.thumbLX = left.x; this.state.thumbLY = left.y; this.state.thumbRX = right.x; @@ -248,6 +258,26 @@ private void processJoystickInput(MotionEvent event, int historyPos) { } } + 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; diff --git a/app/src/main/java/com/winlator/inputcontrols/StickVectorProcessor.java b/app/src/main/java/com/winlator/inputcontrols/StickVectorProcessor.java index dddb22fe89..6ea66f5212 100644 --- a/app/src/main/java/com/winlator/inputcontrols/StickVectorProcessor.java +++ b/app/src/main/java/com/winlator/inputcontrols/StickVectorProcessor.java @@ -5,14 +5,10 @@ /** Pure two-dimensional transforms shared by physical-stick passthrough and digital bindings. */ public final class StickVectorProcessor { public static final int DIRECTION_NONE = -1; - public static final int MASK_RIGHT = 1; - public static final int MASK_DOWN = 1 << 1; - public static final int MASK_LEFT = 1 << 2; - public static final int MASK_UP = 1 << 3; - public static final int MASK_ALL = MASK_RIGHT | MASK_DOWN | MASK_LEFT | MASK_UP; 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() {} @@ -141,28 +137,25 @@ public static int snapDirection( return sectorCount == 4 ? sector * 2 : sector; } - public static int directionMask(int direction) { + 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 MASK_RIGHT; - case 1: return MASK_RIGHT | MASK_DOWN; - case 2: return MASK_DOWN; - case 3: return MASK_DOWN | MASK_LEFT; - case 4: return MASK_LEFT; - case 5: return MASK_LEFT | MASK_UP; - case 6: return MASK_UP; - case 7: return MASK_UP | MASK_RIGHT; - default: return 0; + 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; } } - public static boolean allowsAxis(int directionMask, boolean horizontal, float value) { - if (value == 0) return false; - int requiredMask = horizontal - ? (value > 0 ? MASK_RIGHT : MASK_LEFT) - : (value > 0 ? MASK_DOWN : MASK_UP); - return (directionMask & requiredMask) != 0; - } - private static boolean isDirectionValid(int direction, int sectorCount) { if (direction < 0 || direction > 7) return false; return sectorCount == 8 || direction % 2 == 0; diff --git a/app/src/main/java/com/winlator/widget/InputControlsView.java b/app/src/main/java/com/winlator/widget/InputControlsView.java index e8ded85fc7..14b1fc3350 100644 --- a/app/src/main/java/com/winlator/widget/InputControlsView.java +++ b/app/src/main/java/com/winlator/widget/InputControlsView.java @@ -37,7 +37,6 @@ import com.winlator.inputcontrols.ExternalController; import com.winlator.inputcontrols.ExternalControllerBinding; import com.winlator.inputcontrols.GamepadState; -import com.winlator.inputcontrols.StickVectorProcessor; import com.winlator.math.Mathf; import com.winlator.winhandler.MouseEventFlags; import com.winlator.winhandler.WinHandler; @@ -71,7 +70,6 @@ public class InputControlsView extends View { private final Set activeSequenceCombos = Collections.newSetFromMap(new IdentityHashMap<>()); private final Map activeSequenceBindings = new EnumMap<>(Binding.class); private final Map activeSequenceSources = new IdentityHashMap<>(); - private final Map snappedStickDirections = new IdentityHashMap<>(); private int sequenceGeneration = 0; private int snappingSize; private float offsetX; @@ -393,7 +391,6 @@ public void setProfilePreservingOverlayVisibility(ControlsProfile profile) { private void setProfileInternal(ControlsProfile profile, boolean preserveOverlayVisibility) { cancelTouchRouting(); - snappedStickDirections.clear(); final boolean profileChanged; final boolean overlayProfileVisible; @@ -433,7 +430,6 @@ private void setProfileInternal(ControlsProfile profile, boolean preserveOverlay /** Hides the controls profile from drawing without disabling per-game gyro input. */ public void hideProfileForOverlay() { cancelTouchRouting(); - snappedStickDirections.clear(); synchronized (this) { this.profile = null; } @@ -594,34 +590,9 @@ private void processJoystickInput(ExternalController controller) { final int[] axes = {MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ, MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y}; final float[] values = {controller.state.thumbLX, controller.state.thumbLY, controller.state.thumbRX, controller.state.thumbRY, controller.state.getDPadX(), controller.state.getDPadY()}; - ControlsProfile.StickDigitalMode leftMode = profile.getLeftStickDigitalMode(); - ControlsProfile.StickDigitalMode rightMode = profile.getRightStickDigitalMode(); - int[] previousDirections = snappedStickDirections.computeIfAbsent( - controller, - ignored -> new int[]{StickVectorProcessor.DIRECTION_NONE, StickVectorProcessor.DIRECTION_NONE}); - int leftDirection = StickVectorProcessor.snapDirection( - values[0], values[1], leftMode, previousDirections[0]); - int rightDirection = StickVectorProcessor.snapDirection( - values[2], values[3], rightMode, previousDirections[1]); - previousDirections[0] = leftDirection; - previousDirections[1] = rightDirection; - int leftDirectionMask = StickVectorProcessor.directionMask(leftDirection); - int rightDirectionMask = StickVectorProcessor.directionMask(rightDirection); - - for (byte i = 0; i < PHYSICAL_STICK_AXIS_COUNT; i++) { - ControlsProfile.StickDigitalMode mode = i < 2 ? leftMode : rightMode; - if (mode == ControlsProfile.StickDigitalMode.UNRESTRICTED || values[i] == 0) continue; - int keyCode = ExternalControllerBinding.getKeyCodeForAxis(axes[i], Mathf.sign(values[i])); - ExternalControllerBinding binding = controller.getControllerBinding(keyCode); - if (binding == null || binding.getBindingCombo().usesAnalogOffset()) continue; - int directionMask = i < 2 ? leftDirectionMask : rightDirectionMask; - boolean horizontal = i == 0 || i == 2; - if (!StickVectorProcessor.allowsAxis(directionMask, horizontal, values[i])) values[i] = 0; - } - for (byte i = 0; i < axes.length; i++) { - // Indices 0..3 are the analog sticks, whose values already had the profile's deadzone - // and sensitivity applied by ExternalController, so anything non-zero is live input. + // Indices 0..3 are the analog sticks, whose values already had the profile's complete + // stick tuning applied by ExternalController, so anything non-zero is live input. // 4..5 are the digital hat axes, which keep the fixed threshold. boolean isStick = i < PHYSICAL_STICK_AXIS_COUNT; boolean isActive = isStick ? values[i] != 0 : Math.abs(values[i]) > ControlElement.STICK_DEAD_ZONE; diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index b6d2a4d708..99f4514ebe 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -221,7 +221,7 @@ Hybrid Venstre sticks retningsfastlåsning Højre sticks retningsfastlåsning - Begrænser kun tilknyttede digitale retninger; analoge stick- og musebevægelsesbindinger forbliver kontinuerlige. + 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 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 83550121d8..788cae3136 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -285,7 +285,7 @@ Hybrid Richtungseinrastung des linken Sticks Richtungseinrastung des rechten Sticks - Beschränkt nur zugewiesene digitale Richtungen; analoge Stick- und Mausbewegungsbelegungen bleiben stufenlos. + 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 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a4169b1121..3fcbd1e0f5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -303,7 +303,7 @@ Híbrida Ajuste direccional del stick izquierdo Ajuste direccional del stick derecho - Solo restringe las direcciones digitales asignadas; las asignaciones analógicas y de movimiento del ratón continúan siendo graduales. + 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 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6b0efd9552..9c246cf925 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -293,7 +293,7 @@ Hybride Verrouillage directionnel du joystick gauche Verrouillage directionnel du joystick droit - Limite uniquement les directions numériques assignées ; les commandes analogiques et de déplacement de la souris restent progressives. + Aligne toute la sortie du stick sur des directions fixes. 4 directions supprime les diagonales ; 8 directions autorise uniquement les angles cardinaux et diagonaux. Libre 4 directions 8 directions diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 912712416f..1996c762a5 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -292,7 +292,7 @@ Ibrida Aggancio direzionale levetta sinistra Aggancio direzionale levetta destra - Limita solo le direzioni digitali assegnate; i comandi analogici e di movimento del mouse restano continui. + Allinea l’intero output dello stick a direzioni fisse. 4 direzioni elimina le diagonali; 8 direzioni consente solo angoli cardinali e diagonali. Senza limiti 4 direzioni 8 direzioni diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index ce41d96672..d8bfc3a406 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -246,7 +246,7 @@ ハイブリッド 左スティックの方向スナップ 右スティックの方向スナップ - 割り当てられたデジタル方向のみを制限します。アナログスティックとマウス移動の割り当ては連続入力のままです。 + スティック出力全体を固定方向に揃えます。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 8dea7273af..00b07232e2 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -300,7 +300,7 @@ 하이브리드 왼쪽 스틱 방향 스냅 오른쪽 스틱 방향 스냅 - 매핑된 디지털 방향만 제한하며 아날로그 스틱과 마우스 이동 매핑은 연속 입력을 유지합니다. + 전체 스틱 출력을 고정된 방향으로 맞춥니다. 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 2f92fe607a..48610b56c6 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -300,7 +300,7 @@ Hybrydowa Przyciąganie kierunkowe lewego drążka Przyciąganie kierunkowe prawego drążka - Ogranicza tylko przypisane kierunki cyfrowe; przypisania analogowego drążka i ruchu myszy pozostają płynne. + 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 0fb4051028..dc0177ae18 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -221,7 +221,7 @@ Híbrido Encaixe direcional do analógico esquerdo Encaixe direcional do analógico direito - Restringe apenas direções digitais mapeadas; mapeamentos de analógico e movimento do mouse continuam graduais. + Ajusta toda a saída do analógico para direções fixas. 4 direções remove diagonais; 8 direções permite apenas ângulos cardeais e diagonais. Livre 4 direções 8 direções diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index ea5ac0d547..921b5a14ea 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -290,7 +290,7 @@ Hibridă Fixare direcțională stick stâng Fixare direcțională stick drept - Limitează doar direcțiile digitale asociate; comenzile analogice și de mișcare a mouse-ului rămân continue. + 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 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 76c099c6ed..3337cd154f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -407,7 +407,7 @@ Гибридная Фиксация направлений левого джойстика Фиксация направлений правого джойстика - Ограничивает только назначенные цифровые направления; аналоговые привязки стика и движения мыши остаются плавными. + Привязывает весь сигнал стика к фиксированным направлениям. 4 направления исключают диагонали; 8 направлений допускают только основные и диагональные углы. Без ограничений 4 направления 8 направлений diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 6be79d8307..06a7301f26 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -287,7 +287,7 @@ Гібридна Фіксація напрямків лівого стіка Фіксація напрямків правого стіка - Обмежує лише призначені цифрові напрямки; аналогові прив’язки стіка й руху миші залишаються плавними. + Прив’язує весь сигнал стіка до фіксованих напрямків. 4 напрямки усувають діагоналі; 8 напрямків дозволяють лише основні та діагональні кути. Без обмежень 4 напрямки 8 напрямків diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 92b5d5a29f..8878d27bf7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -285,7 +285,7 @@ 混合 左摇杆方向吸附 右摇杆方向吸附 - 仅限制已映射的数字方向;模拟摇杆和鼠标移动映射仍保持连续输入。 + 将整个摇杆输出吸附到固定方向。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 40dee5e795..d76ae77f4b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -285,7 +285,7 @@ 混合 左搖桿方向吸附 右搖桿方向吸附 - 僅限制已對應的數位方向;類比搖桿和滑鼠移動對應仍保持連續輸入。 + 將整個搖桿輸出吸附到固定方向。4 向會移除斜向;8 向僅允許上下左右和斜向角度。 不限制 4向 8向 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7de541d38..f30728ba8a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -280,7 +280,7 @@ Hybrid Left Stick Directional Snapping Right Stick Directional Snapping - Restricts mapped digital directions only; analog stick and mouse movement bindings remain continuous. + 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 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 286f2d742f..1497c2e58d 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 @@ -30,7 +30,7 @@ import java.util.concurrent.TimeUnit.MILLISECONDS @RunWith(RobolectricTestRunner::class) class PhysicalControllerHandlerTest { @Test - fun `four way snapping activates only the selected cardinal binding`() { + fun `snapped cardinal 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) @@ -53,12 +53,12 @@ class PhysicalControllerHandlerTest { try { controller.state.thumbLX = 0.8f - controller.state.thumbLY = 0.2f + controller.state.thumbLY = 0f assertTrue(handler.onGenericMotionEvent(event)) assertTrue(gamepadState.dpad[1]) assertFalse(gamepadState.dpad[2]) - controller.state.thumbLX = 0.2f + controller.state.thumbLX = 0f controller.state.thumbLY = 0.8f assertTrue(handler.onGenericMotionEvent(event)) assertFalse(gamepadState.dpad[1]) diff --git a/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt b/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt index 1db0d65507..93329a7cb9 100644 --- a/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt @@ -171,6 +171,9 @@ class PhysicalControllerStickTuningTest { 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), @@ -190,10 +193,9 @@ class PhysicalControllerStickTuningTest { StickVectorProcessor.DIRECTION_NONE, ) assertEquals(1, diagonal) - assertEquals( - StickVectorProcessor.MASK_RIGHT or StickVectorProcessor.MASK_DOWN, - StickVectorProcessor.directionMask(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( @@ -206,11 +208,33 @@ class PhysicalControllerStickTuningTest { } @Test - fun `digital snapping leaves analog magnitude consumers unrestricted`() { - assertTrue(BindingCombo.of(Binding.GAMEPAD_LEFT_THUMB_RIGHT).usesAnalogOffset()) - assertTrue(BindingCombo.of(Binding.MOUSE_MOVE_RIGHT).usesAnalogOffset()) - assertFalse(BindingCombo.of(Binding.GAMEPAD_DPAD_RIGHT).usesAnalogOffset()) - assertFalse(BindingCombo.of(Binding.KEY_D).usesAnalogOffset()) + 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 From 4c2475574ad62911d865ec23331283a46d1b7578 Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Mon, 7 Sep 2026 23:47:32 -0500 Subject: [PATCH 06/13] refactor(input): move physical stick tuning to settings --- .../app/gamenative/ui/component/QuickMenu.kt | 13 +- .../dialog/PhysicalControllerConfigSection.kt | 201 ++++++++++-------- .../ui/screen/xserver/XServerScreen.kt | 103 +++++---- app/src/main/res/values-da/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-ro/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 18 files changed, 206 insertions(+), 126 deletions(-) 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 b44f1a2fea..c964c243be 100644 --- a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt +++ b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt @@ -128,6 +128,7 @@ object QuickMenuAction { const val SHOOTER_MODE = 9 const val RADIAL_MENU = 10 const val GYRO = 11 + const val PHYSICAL_CONTROLLER_SETTINGS = 12 } private object QuickMenuTab { @@ -1082,21 +1083,27 @@ fun QuickMenu( } }, focusRequester = if (index == 0) controllerItemFocusRequester else null, - secondaryIcon = if (item.id == QuickMenuAction.TOUCHSCREEN_MODE && isTouchscreenModeActive) + secondaryIcon = if (item.id == QuickMenuAction.EDIT_PHYSICAL_CONTROLLER) + Icons.Default.Settings + else if (item.id == QuickMenuAction.TOUCHSCREEN_MODE && isTouchscreenModeActive) Icons.Default.Settings else if (item.id == QuickMenuAction.SHOOTER_MODE && isShooterModeActive) Icons.Default.Settings else if (item.id == QuickMenuAction.GYRO && gyroEnabled) Icons.Default.Settings else null, - secondaryContentDescriptionResId = if (item.id == QuickMenuAction.TOUCHSCREEN_MODE && isTouchscreenModeActive) + secondaryContentDescriptionResId = if (item.id == QuickMenuAction.EDIT_PHYSICAL_CONTROLLER) + R.string.physical_controller_settings_title + else if (item.id == QuickMenuAction.TOUCHSCREEN_MODE && isTouchscreenModeActive) R.string.gesture_settings_title else if (item.id == QuickMenuAction.SHOOTER_MODE && isShooterModeActive) 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 && isTouchscreenModeActive) + onSecondaryClick = if (item.id == QuickMenuAction.EDIT_PHYSICAL_CONTROLLER) + ({ onItemSelected(QuickMenuAction.PHYSICAL_CONTROLLER_SETTINGS) }) + else if (item.id == QuickMenuAction.TOUCHSCREEN_MODE && isTouchscreenModeActive) onTouchGestureSettingsClick else if (item.id == QuickMenuAction.SHOOTER_MODE && isShooterModeActive) 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 7ae97fd2f1..d6a56d1a09 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,6 +23,7 @@ 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 @@ -118,17 +120,7 @@ internal fun PhysicalControllerConfigSection( } } - // Working copies of the stick tuning values (memory only until Save is clicked) - var leftStickDeadzone by remember { mutableFloatStateOf(profile.leftStickDeadzone) } - var rightStickDeadzone by remember { mutableFloatStateOf(profile.rightStickDeadzone) } - var leftStickSensitivity by remember { mutableFloatStateOf(profile.leftStickSensitivity) } - var rightStickSensitivity by remember { mutableFloatStateOf(profile.rightStickSensitivity) } - var leftStickDeadzoneMode by remember { mutableStateOf(profile.leftStickDeadzoneMode) } - var rightStickDeadzoneMode by remember { mutableStateOf(profile.rightStickDeadzoneMode) } - var leftStickDigitalMode by remember { mutableStateOf(profile.leftStickDigitalMode) } - var rightStickDigitalMode by remember { mutableStateOf(profile.rightStickDigitalMode) } - - // 0 = Face, 1 = Shoulder, 2 = Menu, 3 = Thumbstick, 4 = Left Stick, 5 = Right Stick, 6 = D-Pad, 7 = Stick Tuning + // 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) } @@ -265,15 +257,6 @@ internal fun PhysicalControllerConfigSection( workingBindings[KeyEvent.KEYCODE_BUTTON_MODE] = BindingCombo.of(com.winlator.inputcontrols.Binding.OPEN_NAVIGATION_MENU) Log.d("gncontrol", "Set Home button (KEYCODE_BUTTON_MODE) to OPEN_NAVIGATION_MENU") - leftStickDeadzone = ControlsProfile.DEFAULT_STICK_DEADZONE - rightStickDeadzone = ControlsProfile.DEFAULT_STICK_DEADZONE - leftStickSensitivity = ControlsProfile.DEFAULT_STICK_SENSITIVITY - rightStickSensitivity = ControlsProfile.DEFAULT_STICK_SENSITIVITY - leftStickDeadzoneMode = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE - rightStickDeadzoneMode = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE - leftStickDigitalMode = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE - rightStickDigitalMode = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE - refreshKey++ }) { Icon(Icons.Default.Refresh, null) @@ -283,15 +266,6 @@ internal fun PhysicalControllerConfigSection( IconButton(onClick = { Log.d("gncontrol", "=== Save: Applying ${workingBindings.size} bindings ===") - profile.leftStickDeadzone = leftStickDeadzone - profile.rightStickDeadzone = rightStickDeadzone - profile.leftStickSensitivity = leftStickSensitivity - profile.rightStickSensitivity = rightStickSensitivity - profile.leftStickDeadzoneMode = leftStickDeadzoneMode - profile.rightStickDeadzoneMode = rightStickDeadzoneMode - profile.leftStickDigitalMode = leftStickDigitalMode - profile.rightStickDigitalMode = rightStickDigitalMode - controller?.let { ctrl -> val existingBindings = ctrl.getControllerBindings().toList() for (binding in existingBindings) { @@ -401,12 +375,6 @@ internal fun PhysicalControllerConfigSection( onClick = { selectedCategory = 6 } ) - // Stick deadzone / sensitivity category - CategoryButton( - label = stringResource(R.string.stick_tuning_category), - isSelected = selectedCategory == 7, - onClick = { selectedCategory = 7 } - ) } } @@ -550,26 +518,6 @@ internal fun PhysicalControllerConfigSection( ) } } - 7 -> { - StickTuningSection( - leftStickDeadzone = leftStickDeadzone, - leftStickSensitivity = leftStickSensitivity, - rightStickDeadzone = rightStickDeadzone, - rightStickSensitivity = rightStickSensitivity, - leftStickDeadzoneMode = leftStickDeadzoneMode, - rightStickDeadzoneMode = rightStickDeadzoneMode, - leftStickDigitalMode = leftStickDigitalMode, - rightStickDigitalMode = rightStickDigitalMode, - onLeftStickDeadzoneChange = { leftStickDeadzone = it }, - onLeftStickSensitivityChange = { leftStickSensitivity = it }, - onRightStickDeadzoneChange = { rightStickDeadzone = it }, - onRightStickSensitivityChange = { rightStickSensitivity = it }, - onLeftStickDeadzoneModeChange = { leftStickDeadzoneMode = it }, - onRightStickDeadzoneModeChange = { rightStickDeadzoneMode = it }, - onLeftStickDigitalModeChange = { leftStickDigitalMode = it }, - onRightStickDigitalModeChange = { rightStickDigitalMode = it }, - ) - } } } } @@ -669,6 +617,108 @@ private fun ControllerBindingItem( } } +/** 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: () -> Unit, +) { + var leftStickDeadzone by remember(profile) { mutableFloatStateOf(profile.leftStickDeadzone) } + var rightStickDeadzone by remember(profile) { mutableFloatStateOf(profile.rightStickDeadzone) } + var leftStickSensitivity by remember(profile) { mutableFloatStateOf(profile.leftStickSensitivity) } + var rightStickSensitivity by remember(profile) { mutableFloatStateOf(profile.rightStickSensitivity) } + var leftStickDeadzoneMode by remember(profile) { mutableStateOf(profile.leftStickDeadzoneMode) } + var rightStickDeadzoneMode by remember(profile) { mutableStateOf(profile.rightStickDeadzoneMode) } + var leftStickDigitalMode by remember(profile) { mutableStateOf(profile.leftStickDigitalMode) } + var rightStickDigitalMode by remember(profile) { mutableStateOf(profile.rightStickDigitalMode) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false, + ), + ) { + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = PluviaBackground, + 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 = { + leftStickDeadzone = ControlsProfile.DEFAULT_STICK_DEADZONE + rightStickDeadzone = ControlsProfile.DEFAULT_STICK_DEADZONE + leftStickSensitivity = ControlsProfile.DEFAULT_STICK_SENSITIVITY + rightStickSensitivity = ControlsProfile.DEFAULT_STICK_SENSITIVITY + leftStickDeadzoneMode = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE + rightStickDeadzoneMode = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE + leftStickDigitalMode = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE + rightStickDigitalMode = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE + }) { + Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.reset)) + } + IconButton(onClick = { + profile.leftStickDeadzone = leftStickDeadzone + profile.rightStickDeadzone = rightStickDeadzone + profile.leftStickSensitivity = leftStickSensitivity + profile.rightStickSensitivity = rightStickSensitivity + profile.leftStickDeadzoneMode = leftStickDeadzoneMode + profile.rightStickDeadzoneMode = rightStickDeadzoneMode + profile.leftStickDigitalMode = leftStickDigitalMode + profile.rightStickDigitalMode = rightStickDigitalMode + onSave() + }) { + Icon(Icons.Default.Check, contentDescription = stringResource(R.string.save)) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 16.dp), + ) { + StickTuningSection( + leftStickDeadzone = leftStickDeadzone, + leftStickSensitivity = leftStickSensitivity, + rightStickDeadzone = rightStickDeadzone, + rightStickSensitivity = rightStickSensitivity, + leftStickDeadzoneMode = leftStickDeadzoneMode, + rightStickDeadzoneMode = rightStickDeadzoneMode, + leftStickDigitalMode = leftStickDigitalMode, + rightStickDigitalMode = rightStickDigitalMode, + onLeftStickDeadzoneChange = { leftStickDeadzone = it }, + onLeftStickSensitivityChange = { leftStickSensitivity = it }, + onRightStickDeadzoneChange = { rightStickDeadzone = it }, + onRightStickSensitivityChange = { rightStickSensitivity = it }, + onLeftStickDeadzoneModeChange = { leftStickDeadzoneMode = it }, + onRightStickDeadzoneModeChange = { rightStickDeadzoneMode = it }, + onLeftStickDigitalModeChange = { leftStickDigitalMode = it }, + onRightStickDigitalModeChange = { rightStickDigitalMode = it }, + ) + } + } + } +} + /** * Deadzone and sensitivity tuning for the physical controller's analog sticks. * @@ -699,6 +749,7 @@ private fun StickTuningSection( val sensitivityDescription = stringResource(R.string.stick_sensitivity_description) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + SettingsDialogSectionHeader(stringResource(R.string.left_stick)) StickAdjustmentSlider( label = stringResource(R.string.left_stick_deadzone), description = deadzoneDescription, @@ -739,6 +790,7 @@ private fun StickTuningSection( ), onSelected = onLeftStickDigitalModeChange, ) + SettingsDialogSectionHeader(stringResource(R.string.right_stick)) StickAdjustmentSlider( label = stringResource(R.string.right_stick_deadzone), description = deadzoneDescription, @@ -801,37 +853,14 @@ private fun StickModeSelector( options: List>, onSelected: (T) -> Unit, ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.small, - ) { - Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - ) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - options.forEachIndexed { index, option -> - SegmentedButton( - selected = selected == option.first, - onClick = { onSelected(option.first) }, - shape = SegmentedButtonDefaults.itemShape(index, options.size), - label = { Text(option.second, maxLines = 1) }, - ) - } - } - } - } + SettingsDropdownBlock( + title = label, + subtitle = description, + value = selected, + values = options.map { it.first }, + labels = options.map { it.second }, + onValueChange = onSelected, + ) } @Composable 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 da27863c23..e5f6ac462f 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 @@ -251,6 +251,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 @@ -567,7 +570,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) } @@ -611,7 +614,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, @@ -1386,10 +1390,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 @@ -3023,8 +3032,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) @@ -3066,47 +3075,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() + + // 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. + PluviaApp.inputControlsView?.setProfilePreservingOverlayVisibility(profile) + physicalControllerHandler?.setProfile(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. PluviaApp.inputControlsView?.setProfilePreservingOverlayVisibility(profile) physicalControllerHandler?.setProfile(profile) PluviaApp.radialMenuCoordinator?.setProfile(profile) - showPhysicalControllerDialog = false - keepPausedForEditor = false - resumeIfAllowedAfterOverlay() - } + physicalControllerDialogMode = PHYSICAL_CONTROLLER_DIALOG_NONE + }, ) } } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 99f4514ebe..f96772d59f 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -205,6 +205,7 @@ Højre stick D-Pad Dødzone og følsomhed + Indstillinger for fysisk controller Venstre sticks dødzone Højre sticks dødzone Venstre sticks følsomhed diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 788cae3136..76f05d7cd3 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -269,6 +269,7 @@ Rechter Stick D-Pad Totzone und Empfindlichkeit + Einstellungen für physischen Controller Totzone des linken Sticks Totzone des rechten Sticks Empfindlichkeit des linken Sticks diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3fcbd1e0f5..f13dd90bd9 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -287,6 +287,7 @@ Stick derecho Cruceta Zona muerta y sensibilidad + Ajustes del mando físico Zona muerta del stick izquierdo Zona muerta del stick derecho Sensibilidad del stick izquierdo diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9c246cf925..7ed497ac0e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -277,6 +277,7 @@ Joystick droit Croix directionnelle Zone morte et sensibilité + Paramètres du contrôleur physique Zone morte du joystick gauche Zone morte du joystick droit Sensibilité du joystick gauche diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1996c762a5..d04642b9a4 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -276,6 +276,7 @@ Levetta Destra D-Pad Zona morta e sensibilità + Impostazioni controller fisico Zona morta levetta sinistra Zona morta levetta destra Sensibilità levetta sinistra diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d8bfc3a406..cac7ba9ca4 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -230,6 +230,7 @@ 右スティック 十字キー デッドゾーンと感度 + 物理コントローラー設定 左スティックのデッドゾーン 右スティックのデッドゾーン 左スティックの感度 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 00b07232e2..955281fe11 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -284,6 +284,7 @@ 오른쪽 스틱 십자 버튼 데드존 및 감도 + 물리 컨트롤러 설정 왼쪽 스틱 데드존 오른쪽 스틱 데드존 왼쪽 스틱 감도 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 48610b56c6..c57281c3c6 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -284,6 +284,7 @@ Prawy drążek D-Pad Martwa strefa i czułość + Ustawienia kontrolera fizycznego Martwa strefa lewego drążka Martwa strefa prawego drążka Czułość lewego drążka diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index dc0177ae18..b3317d8b92 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -205,6 +205,7 @@ Analógico Direito D-Pad Zona morta e sensibilidade + Configurações do controle físico Zona morta do analógico esquerdo Zona morta do analógico direito Sensibilidade do analógico esquerdo diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 921b5a14ea..8b4e5b140f 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -274,6 +274,7 @@ Stick dreapta D‑Pad Zonă moartă și sensibilitate + Setări controller fizic Zona moartă a stickului stâng Zona moartă a stickului drept Sensibilitatea stickului stâng diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3337cd154f..8efaf3b14e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -391,6 +391,7 @@ Загрузка... D-Pad Мёртвая зона и чувствительность + Настройки физического контроллера Мёртвая зона левого джойстика Мёртвая зона правого джойстика Чувствительность левого джойстика diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 06a7301f26..1c75da8adc 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -271,6 +271,7 @@ Правий стік 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 8878d27bf7..9327bd3d2c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -269,6 +269,7 @@ 右摇杆 方向键 死区和灵敏度 + 实体控制器设置 左摇杆死区 右摇杆死区 左摇杆灵敏度 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index d76ae77f4b..5615d3a68b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -269,6 +269,7 @@ 右搖桿 方向鍵 死區與靈敏度 + 實體控制器設定 左搖桿死區 右搖桿死區 左搖桿靈敏度 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f30728ba8a..0b0c6af8ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -264,6 +264,7 @@ Right Stick D-Pad Deadzone & Sensitivity + Physical Controller Settings Left Stick Deadzone Right Stick Deadzone Left Stick Sensitivity From 13f70cb7847b6509c36bce2f39776c55c7065b76 Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Tue, 8 Sep 2026 00:36:03 -0500 Subject: [PATCH 07/13] fix(input): harden physical stick tuning lifecycle --- .../dialog/PhysicalControllerConfigSection.kt | 161 +++++++++--------- .../xserver/PhysicalControllerHandler.kt | 10 +- .../inputcontrols/ExternalController.java | 84 +++++---- app/src/main/res/values-da/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 1 - app/src/main/res/values-ja/strings.xml | 1 - app/src/main/res/values-ko/strings.xml | 1 - app/src/main/res/values-pl/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values-ro/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-uk/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values-zh-rTW/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - 18 files changed, 132 insertions(+), 138 deletions(-) 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 d6a56d1a09..5b68dd5e6c 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 @@ -617,6 +617,56 @@ 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) + } + } +} + /** Full-screen stick tuning page opened from the Physical Controller quick-menu gear. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -625,14 +675,7 @@ internal fun PhysicalControllerSettingsDialog( onDismiss: () -> Unit, onSave: () -> Unit, ) { - var leftStickDeadzone by remember(profile) { mutableFloatStateOf(profile.leftStickDeadzone) } - var rightStickDeadzone by remember(profile) { mutableFloatStateOf(profile.rightStickDeadzone) } - var leftStickSensitivity by remember(profile) { mutableFloatStateOf(profile.leftStickSensitivity) } - var rightStickSensitivity by remember(profile) { mutableFloatStateOf(profile.rightStickSensitivity) } - var leftStickDeadzoneMode by remember(profile) { mutableStateOf(profile.leftStickDeadzoneMode) } - var rightStickDeadzoneMode by remember(profile) { mutableStateOf(profile.rightStickDeadzoneMode) } - var leftStickDigitalMode by remember(profile) { mutableStateOf(profile.leftStickDigitalMode) } - var rightStickDigitalMode by remember(profile) { mutableStateOf(profile.rightStickDigitalMode) } + var tuning by remember(profile) { mutableStateOf(PhysicalStickTuningState.from(profile)) } Dialog( onDismissRequest = onDismiss, @@ -660,27 +703,11 @@ internal fun PhysicalControllerSettingsDialog( } }, actions = { - IconButton(onClick = { - leftStickDeadzone = ControlsProfile.DEFAULT_STICK_DEADZONE - rightStickDeadzone = ControlsProfile.DEFAULT_STICK_DEADZONE - leftStickSensitivity = ControlsProfile.DEFAULT_STICK_SENSITIVITY - rightStickSensitivity = ControlsProfile.DEFAULT_STICK_SENSITIVITY - leftStickDeadzoneMode = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE - rightStickDeadzoneMode = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE - leftStickDigitalMode = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE - rightStickDigitalMode = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE - }) { + IconButton(onClick = { tuning = PhysicalStickTuningState.defaults() }) { Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.reset)) } IconButton(onClick = { - profile.leftStickDeadzone = leftStickDeadzone - profile.rightStickDeadzone = rightStickDeadzone - profile.leftStickSensitivity = leftStickSensitivity - profile.rightStickSensitivity = rightStickSensitivity - profile.leftStickDeadzoneMode = leftStickDeadzoneMode - profile.rightStickDeadzoneMode = rightStickDeadzoneMode - profile.leftStickDigitalMode = leftStickDigitalMode - profile.rightStickDigitalMode = rightStickDigitalMode + tuning.applyTo(profile) onSave() }) { Icon(Icons.Default.Check, contentDescription = stringResource(R.string.save)) @@ -697,22 +724,8 @@ internal fun PhysicalControllerSettingsDialog( .padding(bottom = 16.dp), ) { StickTuningSection( - leftStickDeadzone = leftStickDeadzone, - leftStickSensitivity = leftStickSensitivity, - rightStickDeadzone = rightStickDeadzone, - rightStickSensitivity = rightStickSensitivity, - leftStickDeadzoneMode = leftStickDeadzoneMode, - rightStickDeadzoneMode = rightStickDeadzoneMode, - leftStickDigitalMode = leftStickDigitalMode, - rightStickDigitalMode = rightStickDigitalMode, - onLeftStickDeadzoneChange = { leftStickDeadzone = it }, - onLeftStickSensitivityChange = { leftStickSensitivity = it }, - onRightStickDeadzoneChange = { rightStickDeadzone = it }, - onRightStickSensitivityChange = { rightStickSensitivity = it }, - onLeftStickDeadzoneModeChange = { leftStickDeadzoneMode = it }, - onRightStickDeadzoneModeChange = { rightStickDeadzoneMode = it }, - onLeftStickDigitalModeChange = { leftStickDigitalMode = it }, - onRightStickDigitalModeChange = { rightStickDigitalMode = it }, + tuning = tuning, + onTuningChange = { tuning = it }, ) } } @@ -725,25 +738,10 @@ internal fun PhysicalControllerSettingsDialog( * 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). */ -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun StickTuningSection( - leftStickDeadzone: Float, - leftStickSensitivity: Float, - rightStickDeadzone: Float, - rightStickSensitivity: Float, - leftStickDeadzoneMode: ControlsProfile.StickDeadzoneMode, - rightStickDeadzoneMode: ControlsProfile.StickDeadzoneMode, - leftStickDigitalMode: ControlsProfile.StickDigitalMode, - rightStickDigitalMode: ControlsProfile.StickDigitalMode, - onLeftStickDeadzoneChange: (Float) -> Unit, - onLeftStickSensitivityChange: (Float) -> Unit, - onRightStickDeadzoneChange: (Float) -> Unit, - onRightStickSensitivityChange: (Float) -> Unit, - onLeftStickDeadzoneModeChange: (ControlsProfile.StickDeadzoneMode) -> Unit, - onRightStickDeadzoneModeChange: (ControlsProfile.StickDeadzoneMode) -> Unit, - onLeftStickDigitalModeChange: (ControlsProfile.StickDigitalMode) -> Unit, - onRightStickDigitalModeChange: (ControlsProfile.StickDigitalMode) -> Unit, + tuning: PhysicalStickTuningState, + onTuningChange: (PhysicalStickTuningState) -> Unit, ) { val deadzoneDescription = stringResource(R.string.stick_deadzone_description) val sensitivityDescription = stringResource(R.string.stick_sensitivity_description) @@ -753,83 +751,83 @@ private fun StickTuningSection( StickAdjustmentSlider( label = stringResource(R.string.left_stick_deadzone), description = deadzoneDescription, - value = leftStickDeadzone, - displayValue = String.format(Locale.getDefault(), "%.0f%%", leftStickDeadzone * 100), + value = tuning.left.deadzone, + displayValue = String.format(Locale.getDefault(), "%.0f%%", tuning.left.deadzone * 100), valueRange = ControlsProfile.MIN_STICK_DEADZONE..ControlsProfile.MAX_STICK_DEADZONE, steps = DEADZONE_SLIDER_STEPS, - onValueChange = onLeftStickDeadzoneChange + onValueChange = { onTuningChange(tuning.copy(left = tuning.left.copy(deadzone = it))) } ) StickModeSelector( label = stringResource(R.string.left_stick_deadzone_shape), - description = stickDeadzoneModeDescription(leftStickDeadzoneMode), - selected = leftStickDeadzoneMode, + description = stickDeadzoneModeDescription(tuning.left.deadzoneMode), + selected = tuning.left.deadzoneMode, options = 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), ), - onSelected = onLeftStickDeadzoneModeChange, + onSelected = { onTuningChange(tuning.copy(left = tuning.left.copy(deadzoneMode = it))) }, ) StickAdjustmentSlider( label = stringResource(R.string.left_stick_sensitivity), description = sensitivityDescription, - value = leftStickSensitivity, - displayValue = String.format(Locale.getDefault(), "%.2f×", leftStickSensitivity), + value = tuning.left.sensitivity, + displayValue = String.format(Locale.getDefault(), "%.2f×", tuning.left.sensitivity), valueRange = ControlsProfile.MIN_STICK_SENSITIVITY..ControlsProfile.MAX_STICK_SENSITIVITY, steps = SENSITIVITY_SLIDER_STEPS, - onValueChange = onLeftStickSensitivityChange + onValueChange = { onTuningChange(tuning.copy(left = tuning.left.copy(sensitivity = it))) } ) StickModeSelector( label = stringResource(R.string.left_stick_direction_mode), description = stringResource(R.string.stick_direction_mode_description), - selected = leftStickDigitalMode, + selected = tuning.left.directionMode, options = 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), ), - onSelected = onLeftStickDigitalModeChange, + onSelected = { onTuningChange(tuning.copy(left = tuning.left.copy(directionMode = it))) }, ) SettingsDialogSectionHeader(stringResource(R.string.right_stick)) StickAdjustmentSlider( label = stringResource(R.string.right_stick_deadzone), description = deadzoneDescription, - value = rightStickDeadzone, - displayValue = String.format(Locale.getDefault(), "%.0f%%", rightStickDeadzone * 100), + value = tuning.right.deadzone, + displayValue = String.format(Locale.getDefault(), "%.0f%%", tuning.right.deadzone * 100), valueRange = ControlsProfile.MIN_STICK_DEADZONE..ControlsProfile.MAX_STICK_DEADZONE, steps = DEADZONE_SLIDER_STEPS, - onValueChange = onRightStickDeadzoneChange + onValueChange = { onTuningChange(tuning.copy(right = tuning.right.copy(deadzone = it))) } ) StickModeSelector( label = stringResource(R.string.right_stick_deadzone_shape), - description = stickDeadzoneModeDescription(rightStickDeadzoneMode), - selected = rightStickDeadzoneMode, + description = stickDeadzoneModeDescription(tuning.right.deadzoneMode), + selected = tuning.right.deadzoneMode, options = 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), ), - onSelected = onRightStickDeadzoneModeChange, + onSelected = { onTuningChange(tuning.copy(right = tuning.right.copy(deadzoneMode = it))) }, ) StickAdjustmentSlider( label = stringResource(R.string.right_stick_sensitivity), description = sensitivityDescription, - value = rightStickSensitivity, - displayValue = String.format(Locale.getDefault(), "%.2f×", rightStickSensitivity), + value = tuning.right.sensitivity, + displayValue = String.format(Locale.getDefault(), "%.2f×", tuning.right.sensitivity), valueRange = ControlsProfile.MIN_STICK_SENSITIVITY..ControlsProfile.MAX_STICK_SENSITIVITY, steps = SENSITIVITY_SLIDER_STEPS, - onValueChange = onRightStickSensitivityChange + onValueChange = { onTuningChange(tuning.copy(right = tuning.right.copy(sensitivity = it))) } ) StickModeSelector( label = stringResource(R.string.right_stick_direction_mode), description = stringResource(R.string.stick_direction_mode_description), - selected = rightStickDigitalMode, + selected = tuning.right.directionMode, options = 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), ), - onSelected = onRightStickDigitalModeChange, + onSelected = { onTuningChange(tuning.copy(right = tuning.right.copy(directionMode = it))) }, ) } } @@ -844,7 +842,6 @@ private fun stickDeadzoneModeDescription(mode: ControlsProfile.StickDeadzoneMode return stringResource(description) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun StickModeSelector( label: String, 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 e2d9c54870..acbb385212 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 @@ -52,9 +52,6 @@ class PhysicalControllerHandler( private const val SCROLL_REPEAT_INTERVAL_MS = 90L private const val UNKNOWN_DEVICE_ID = -1 private const val SEQUENCE_PRESS_MS = 80L - // Stick values reaching the mouse-move timer are already deadzone-gated, so this only skips - // injection while the sticks are resting. - private const val MOUSE_MOVE_IDLE_THRESHOLD = 0.01 } private val TAG = "gncontrol" @@ -162,7 +159,6 @@ class PhysicalControllerHandler( showKeyboardPressed = false closeRadialMenuIfOpen(commit = false) sendGamepadState() - ExternalController.setStickTuning(null) } fun onInputDeviceRemoved(deviceId: Int) { @@ -358,9 +354,9 @@ class PhysicalControllerHandler( mouseMoveTimer = Timer() mouseMoveTimer?.schedule(object : TimerTask() { override fun run() { - // Skip injection while resting to save CPU cycles - val magnitude = Math.sqrt((mouseMoveOffset.x * mouseMoveOffset.x + mouseMoveOffset.y * mouseMoveOffset.y).toDouble()) - if (magnitude < MOUSE_MOVE_IDLE_THRESHOLD) return + // Tuning already applies the user's chosen deadzone. Only exact rest is idle; + // another threshold here would silently discard low-sensitivity movement. + if (mouseMoveOffset.x == 0f && mouseMoveOffset.y == 0f) return // Look up cursor speed dynamically so it updates when profile changes val cursorSpeed = profile?.cursorSpeed ?: 1f diff --git a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java index b893737a5c..dfc8d2d69b 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java +++ b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java @@ -40,17 +40,48 @@ public class ExternalController { public static final byte TRIGGER_IS_AXIS = 1; public static final byte TRIGGER_IS_BOTH = 2; - // Stick tuning is intentionally global: controllers for players 2-4 are created on demand by - // WinHandler and never belong to a ControlsProfile, but they must honour the same tuning as - // player 1. setStickTuning() is called whenever the active profile changes. - private static volatile float stickDeadzoneLeft = ControlsProfile.DEFAULT_STICK_DEADZONE; - private static volatile float stickDeadzoneRight = ControlsProfile.DEFAULT_STICK_DEADZONE; - private static volatile float stickSensitivityLeft = ControlsProfile.DEFAULT_STICK_SENSITIVITY; - private static volatile float stickSensitivityRight = ControlsProfile.DEFAULT_STICK_SENSITIVITY; - private static volatile ControlsProfile.StickDeadzoneMode stickDeadzoneModeLeft = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; - private static volatile ControlsProfile.StickDeadzoneMode stickDeadzoneModeRight = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; - private static volatile ControlsProfile.StickDigitalMode stickDirectionModeLeft = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; - private static volatile ControlsProfile.StickDigitalMode stickDirectionModeRight = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; + // 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; + + StickTuningConfig(@Nullable ControlsProfile profile) { + deadzoneLeft = profile != null + ? profile.getLeftStickDeadzone() + : ControlsProfile.DEFAULT_STICK_DEADZONE; + deadzoneRight = profile != null + ? profile.getRightStickDeadzone() + : ControlsProfile.DEFAULT_STICK_DEADZONE; + sensitivityLeft = profile != null + ? profile.getLeftStickSensitivity() + : ControlsProfile.DEFAULT_STICK_SENSITIVITY; + sensitivityRight = profile != null + ? profile.getRightStickSensitivity() + : ControlsProfile.DEFAULT_STICK_SENSITIVITY; + deadzoneModeLeft = profile != null + ? profile.getLeftStickDeadzoneMode() + : ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; + deadzoneModeRight = profile != null + ? profile.getRightStickDeadzoneMode() + : ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; + directionModeLeft = profile != null + ? profile.getLeftStickDigitalMode() + : ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; + directionModeRight = profile != null + ? profile.getRightStickDigitalMode() + : ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; + } + } + + private static volatile StickTuningConfig stickTuning = new StickTuningConfig(null); private String id; private String name; @@ -68,25 +99,7 @@ public class ExternalController { /** Applies {@code profile}'s stick tuning to every physical controller. */ public static void setStickTuning(ControlsProfile profile) { - if (profile == null) { - stickDeadzoneLeft = ControlsProfile.DEFAULT_STICK_DEADZONE; - stickDeadzoneRight = ControlsProfile.DEFAULT_STICK_DEADZONE; - stickSensitivityLeft = ControlsProfile.DEFAULT_STICK_SENSITIVITY; - stickSensitivityRight = ControlsProfile.DEFAULT_STICK_SENSITIVITY; - stickDeadzoneModeLeft = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; - stickDeadzoneModeRight = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; - stickDirectionModeLeft = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; - stickDirectionModeRight = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; - return; - } - stickDeadzoneLeft = profile.getLeftStickDeadzone(); - stickDeadzoneRight = profile.getRightStickDeadzone(); - stickSensitivityLeft = profile.getLeftStickSensitivity(); - stickSensitivityRight = profile.getRightStickSensitivity(); - stickDeadzoneModeLeft = profile.getLeftStickDeadzoneMode(); - stickDeadzoneModeRight = profile.getRightStickDeadzoneMode(); - stickDirectionModeLeft = profile.getLeftStickDigitalMode(); - stickDirectionModeRight = profile.getRightStickDigitalMode(); + stickTuning = new StickTuningConfig(profile); } public String getName() { @@ -230,12 +243,15 @@ private void processJoystickInput(MotionEvent event, int historyPos) { 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, stickDeadzoneLeft, stickSensitivityLeft, stickDeadzoneModeLeft); + rawThumbLX, rawThumbLY, + tuning.deadzoneLeft, tuning.sensitivityLeft, tuning.deadzoneModeLeft); StickVectorProcessor.Vector right = StickVectorProcessor.tune( - rawThumbRX, rawThumbRY, stickDeadzoneRight, stickSensitivityRight, stickDeadzoneModeRight); - left = applyDirectionSnapping(left, stickDirectionModeLeft, false); - right = applyDirectionSnapping(right, stickDirectionModeRight, true); + 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; diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index f96772d59f..e416474a40 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -204,7 +204,6 @@ Venstre stick Højre stick D-Pad - Dødzone og følsomhed Indstillinger for fysisk controller Venstre sticks dødzone Højre sticks dødzone diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 76f05d7cd3..36820d83a2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -268,7 +268,6 @@ Linker Stick Rechter Stick D-Pad - Totzone und Empfindlichkeit Einstellungen für physischen Controller Totzone des linken Sticks Totzone des rechten Sticks diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f13dd90bd9..2cf82f1c44 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -286,7 +286,6 @@ Stick izquierdo Stick derecho Cruceta - Zona muerta y sensibilidad Ajustes del mando físico Zona muerta del stick izquierdo Zona muerta del stick derecho diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7ed497ac0e..49af311241 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -276,7 +276,6 @@ Joystick gauche Joystick droit Croix directionnelle - Zone morte et sensibilité Paramètres du contrôleur physique Zone morte du joystick gauche Zone morte du joystick droit diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d04642b9a4..bc09c1f445 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -275,7 +275,6 @@ Levetta Sinistra Levetta Destra D-Pad - Zona morta e sensibilità Impostazioni controller fisico Zona morta levetta sinistra Zona morta levetta destra diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index cac7ba9ca4..8b63eaa289 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -229,7 +229,6 @@ 左スティック 右スティック 十字キー - デッドゾーンと感度 物理コントローラー設定 左スティックのデッドゾーン 右スティックのデッドゾーン diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 955281fe11..eb1415ef77 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -283,7 +283,6 @@ 왼쪽 스틱 오른쪽 스틱 십자 버튼 - 데드존 및 감도 물리 컨트롤러 설정 왼쪽 스틱 데드존 오른쪽 스틱 데드존 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index c57281c3c6..104b3c998f 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -283,7 +283,6 @@ Lewy drążek Prawy drążek D-Pad - Martwa strefa i czułość Ustawienia kontrolera fizycznego Martwa strefa lewego drążka Martwa strefa prawego drążka diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index b3317d8b92..4591a419a5 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -204,7 +204,6 @@ Analógico Esquerdo Analógico Direito D-Pad - Zona morta e sensibilidade Configurações do controle físico Zona morta do analógico esquerdo Zona morta do analógico direito diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 8b4e5b140f..ccbce06df7 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -273,7 +273,6 @@ Stick stânga Stick dreapta D‑Pad - Zonă moartă și sensibilitate Setări controller fizic Zona moartă a stickului stâng Zona moartă a stickului drept diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8efaf3b14e..83e2b13ed5 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -390,7 +390,6 @@ Установить приложение Загрузка... D-Pad - Мёртвая зона и чувствительность Настройки физического контроллера Мёртвая зона левого джойстика Мёртвая зона правого джойстика diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 1c75da8adc..8eb5293f96 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -270,7 +270,6 @@ Лівий стік Правий стік 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 9327bd3d2c..36ef30dd81 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -268,7 +268,6 @@ 左摇杆 右摇杆 方向键 - 死区和灵敏度 实体控制器设置 左摇杆死区 右摇杆死区 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 5615d3a68b..ee80df4f82 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -268,7 +268,6 @@ 左搖桿 右搖桿 方向鍵 - 死區與靈敏度 實體控制器設定 左搖桿死區 右搖桿死區 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0b0c6af8ae..08833cbb98 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -263,7 +263,6 @@ Left Stick Right Stick D-Pad - Deadzone & Sensitivity Physical Controller Settings Left Stick Deadzone Right Stick Deadzone From 83d209dd9f5d7603f9f6957de067a1b7490d8526 Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Tue, 8 Sep 2026 00:43:49 -0500 Subject: [PATCH 08/13] fix(i18n): refine controller tuning descriptions --- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 49af311241..1e3d450a77 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -293,7 +293,7 @@ Hybride Verrouillage directionnel du joystick gauche Verrouillage directionnel du joystick droit - Aligne toute la sortie du stick sur des directions fixes. 4 directions supprime les diagonales ; 8 directions autorise uniquement les angles cardinaux et diagonaux. + 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index bc09c1f445..b36ef39f3c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -292,7 +292,7 @@ Ibrida Aggancio direzionale levetta sinistra Aggancio direzionale levetta destra - Allinea l’intero output dello stick a direzioni fisse. 4 direzioni elimina le diagonali; 8 direzioni consente solo angoli cardinali e diagonali. + 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 diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 8b63eaa289..d5b352e5b9 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -235,7 +235,7 @@ 左スティックの感度 右スティックの感度 入力が反応し始めるまでに必要なスティックの移動量です。ドリフトを抑えるには値を上げます。 - スティックの途中までの倒し量を拡大します。1.0を超えてもゲームパッドの最大出力は超えず、より少ない物理的な倒し量で最大に達します。 + スティックを途中まで倒したときの入力を増幅します。1.0を超えてもゲームパッドの最大出力は超えず、より少ない物理的な倒し量で最大に達します。 左スティックのデッドゾーン形状 右スティックのデッドゾーン形状 各軸を別々に測定します。水平・垂直移動を直線に保ちやすい一方、斜め移動はやや不均一に感じる場合があります。 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 4591a419a5..85c5d493aa 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -221,7 +221,7 @@ 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 remove diagonais; 8 direções permite apenas ângulos cardeais e diagonais. + 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 From 245bcd4647f618b10e1764a9f8d0835a033b69a7 Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Tue, 8 Sep 2026 22:38:48 -0500 Subject: [PATCH 09/13] fix(input): address controller tuning review findings --- .../dialog/PhysicalControllerConfigSection.kt | 167 +++++++++--------- .../xserver/PhysicalControllerHandler.kt | 34 +++- .../inputcontrols/ControlsProfile.java | 34 +++- .../inputcontrols/ExternalController.java | 38 ++-- .../inputcontrols/InputControlsManager.java | 47 +++-- .../winlator/widget/InputControlsView.java | 10 +- .../xserver/PhysicalControllerHandlerTest.kt | 49 ++++- .../PhysicalControllerStickTuningTest.kt | 8 + 8 files changed, 255 insertions(+), 132 deletions(-) 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 5b68dd5e6c..9af4d49dd5 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 @@ -745,93 +745,100 @@ private fun StickTuningSection( ) { 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)) { - SettingsDialogSectionHeader(stringResource(R.string.left_stick)) - StickAdjustmentSlider( - label = stringResource(R.string.left_stick_deadzone), - description = deadzoneDescription, - value = tuning.left.deadzone, - displayValue = String.format(Locale.getDefault(), "%.0f%%", tuning.left.deadzone * 100), - valueRange = ControlsProfile.MIN_STICK_DEADZONE..ControlsProfile.MAX_STICK_DEADZONE, - steps = DEADZONE_SLIDER_STEPS, - onValueChange = { onTuningChange(tuning.copy(left = tuning.left.copy(deadzone = it))) } - ) - StickModeSelector( - label = stringResource(R.string.left_stick_deadzone_shape), - description = stickDeadzoneModeDescription(tuning.left.deadzoneMode), - selected = tuning.left.deadzoneMode, - options = 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), - ), - onSelected = { onTuningChange(tuning.copy(left = tuning.left.copy(deadzoneMode = it))) }, - ) - StickAdjustmentSlider( - label = stringResource(R.string.left_stick_sensitivity), - description = sensitivityDescription, - value = tuning.left.sensitivity, - displayValue = String.format(Locale.getDefault(), "%.2f×", tuning.left.sensitivity), - valueRange = ControlsProfile.MIN_STICK_SENSITIVITY..ControlsProfile.MAX_STICK_SENSITIVITY, - steps = SENSITIVITY_SLIDER_STEPS, - onValueChange = { onTuningChange(tuning.copy(left = tuning.left.copy(sensitivity = it))) } - ) - StickModeSelector( - label = stringResource(R.string.left_stick_direction_mode), - description = stringResource(R.string.stick_direction_mode_description), - selected = tuning.left.directionMode, - options = 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), - ), - onSelected = { onTuningChange(tuning.copy(left = tuning.left.copy(directionMode = it))) }, - ) - SettingsDialogSectionHeader(stringResource(R.string.right_stick)) - StickAdjustmentSlider( - label = stringResource(R.string.right_stick_deadzone), - description = deadzoneDescription, - value = tuning.right.deadzone, - displayValue = String.format(Locale.getDefault(), "%.0f%%", tuning.right.deadzone * 100), - valueRange = ControlsProfile.MIN_STICK_DEADZONE..ControlsProfile.MAX_STICK_DEADZONE, - steps = DEADZONE_SLIDER_STEPS, - onValueChange = { onTuningChange(tuning.copy(right = tuning.right.copy(deadzone = it))) } - ) - StickModeSelector( - label = stringResource(R.string.right_stick_deadzone_shape), - description = stickDeadzoneModeDescription(tuning.right.deadzoneMode), - selected = tuning.right.deadzoneMode, - options = 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), - ), - onSelected = { onTuningChange(tuning.copy(right = tuning.right.copy(deadzoneMode = it))) }, + 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)) }, ) - StickAdjustmentSlider( - label = stringResource(R.string.right_stick_sensitivity), - description = sensitivityDescription, - value = tuning.right.sensitivity, - displayValue = String.format(Locale.getDefault(), "%.2f×", tuning.right.sensitivity), - valueRange = ControlsProfile.MIN_STICK_SENSITIVITY..ControlsProfile.MAX_STICK_SENSITIVITY, - steps = SENSITIVITY_SLIDER_STEPS, - onValueChange = { onTuningChange(tuning.copy(right = tuning.right.copy(sensitivity = it))) } - ) - StickModeSelector( - label = stringResource(R.string.right_stick_direction_mode), - description = stringResource(R.string.stick_direction_mode_description), - selected = tuning.right.directionMode, - options = 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), - ), - onSelected = { onTuningChange(tuning.copy(right = tuning.right.copy(directionMode = 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) { 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 acbb385212..a7c2a57afe 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,6 +56,7 @@ class PhysicalControllerHandler( private val TAG = "gncontrol" private val mouseMoveOffset = PointF(0f, 0f) + private val mouseMoveRemainder = PointF(0f, 0f) private val mouseMoveContributions = mutableMapOf() private val sequenceHandler = Handler(Looper.getMainLooper()) private var mouseMoveTimer: Timer? = null @@ -125,7 +126,7 @@ class PhysicalControllerHandler( } init { - ExternalController.setStickTuning(profile) + ExternalController.setStickTuning(profile, this) } fun setProfile(profile: ControlsProfile?) { @@ -140,7 +141,7 @@ class PhysicalControllerHandler( activeSequenceTriggerBindings.clear() sendGamepadState() this.profile = profile - ExternalController.setStickTuning(profile) + ExternalController.setStickTuning(profile, this) Log.d(TAG, "PhysicalControllerHandler: Profile set to ${profile?.name}") } @@ -159,6 +160,7 @@ class PhysicalControllerHandler( showKeyboardPressed = false closeRadialMenuIfOpen(commit = false) sendGamepadState() + ExternalController.clearStickTuning(this) } fun onInputDeviceRemoved(deviceId: Int) { @@ -354,15 +356,23 @@ class PhysicalControllerHandler( mouseMoveTimer = Timer() mouseMoveTimer?.schedule(object : TimerTask() { override fun run() { - // Tuning already applies the user's chosen deadzone. Only exact rest is idle; - // another threshold here would silently discard low-sensitivity movement. - if (mouseMoveOffset.x == 0f && mouseMoveOffset.y == 0f) return + // 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 && mouseMoveOffset.y == 0f) { + mouseMoveRemainder.set(0f, 0f) + 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) + 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) } @@ -405,6 +415,7 @@ class PhysicalControllerHandler( } } if (mouseMoveContributions.isEmpty()) { + mouseMoveRemainder.set(0f, 0f) mouseMoveTimer?.cancel() mouseMoveTimer = null } @@ -413,6 +424,7 @@ class PhysicalControllerHandler( private fun clearMouseMoveContributions() { mouseMoveContributions.clear() mouseMoveOffset.set(0f, 0f) + mouseMoveRemainder.set(0f, 0f) mouseMoveTimer?.cancel() mouseMoveTimer = null } @@ -1091,7 +1103,11 @@ class PhysicalControllerHandler( 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) value != 0f else Math.abs(value) > ControlElement.STICK_DEAD_ZONE + return if (isStick && profile?.isStickTuningConfigured == true) { + value != 0f + } else { + Math.abs(value) > ControlElement.STICK_DEAD_ZONE + } } private fun isRadialMenuMotionOpenerPressed(controller: ExternalController): Boolean { diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java index 7e436fef6b..d1b4daddd4 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java @@ -87,6 +87,7 @@ public static StickDigitalMode fromJsonName(String value) { 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<>(); @@ -135,6 +136,7 @@ public float getLeftStickDeadzone() { public void setLeftStickDeadzone(float deadzone) { this.leftStickDeadzone = clampDeadzone(deadzone); + stickTuningConfigured = true; } public float getRightStickDeadzone() { @@ -143,6 +145,7 @@ public float getRightStickDeadzone() { public void setRightStickDeadzone(float deadzone) { this.rightStickDeadzone = clampDeadzone(deadzone); + stickTuningConfigured = true; } public float getLeftStickSensitivity() { @@ -151,6 +154,7 @@ public float getLeftStickSensitivity() { public void setLeftStickSensitivity(float sensitivity) { this.leftStickSensitivity = clampSensitivity(sensitivity); + stickTuningConfigured = true; } public float getRightStickSensitivity() { @@ -159,6 +163,7 @@ public float getRightStickSensitivity() { public void setRightStickSensitivity(float sensitivity) { this.rightStickSensitivity = clampSensitivity(sensitivity); + stickTuningConfigured = true; } public StickDeadzoneMode getLeftStickDeadzoneMode() { @@ -167,6 +172,7 @@ public StickDeadzoneMode getLeftStickDeadzoneMode() { public void setLeftStickDeadzoneMode(StickDeadzoneMode mode) { leftStickDeadzoneMode = mode != null ? mode : DEFAULT_STICK_DEADZONE_MODE; + stickTuningConfigured = true; } public StickDeadzoneMode getRightStickDeadzoneMode() { @@ -175,6 +181,7 @@ public StickDeadzoneMode getRightStickDeadzoneMode() { public void setRightStickDeadzoneMode(StickDeadzoneMode mode) { rightStickDeadzoneMode = mode != null ? mode : DEFAULT_STICK_DEADZONE_MODE; + stickTuningConfigured = true; } public StickDigitalMode getLeftStickDigitalMode() { @@ -183,6 +190,7 @@ public StickDigitalMode getLeftStickDigitalMode() { public void setLeftStickDigitalMode(StickDigitalMode mode) { leftStickDigitalMode = mode != null ? mode : DEFAULT_STICK_DIGITAL_MODE; + stickTuningConfigured = true; } public StickDigitalMode getRightStickDigitalMode() { @@ -191,6 +199,12 @@ public StickDigitalMode getRightStickDigitalMode() { 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; } /** @@ -286,14 +300,18 @@ public void save() { data.put("id", id); data.put("name", name); data.put("cursorSpeed", Float.valueOf(cursorSpeed)); - data.put("leftStickDeadzone", (double) leftStickDeadzone); - data.put("rightStickDeadzone", (double) rightStickDeadzone); - data.put("leftStickSensitivity", (double) leftStickSensitivity); - data.put("rightStickSensitivity", (double) rightStickSensitivity); - data.put("leftStickDeadzoneMode", leftStickDeadzoneMode.getJsonName()); - data.put("rightStickDeadzoneMode", rightStickDeadzoneMode.getJsonName()); - data.put("leftStickDigitalMode", leftStickDigitalMode.getJsonName()); - data.put("rightStickDigitalMode", rightStickDigitalMode.getJsonName()); + // 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("leftStickDeadzone", (double) leftStickDeadzone); + data.put("rightStickDeadzone", (double) rightStickDeadzone); + data.put("leftStickSensitivity", (double) leftStickSensitivity); + data.put("rightStickSensitivity", (double) rightStickSensitivity); + data.put("leftStickDeadzoneMode", leftStickDeadzoneMode.getJsonName()); + data.put("rightStickDeadzoneMode", rightStickDeadzoneMode.getJsonName()); + data.put("leftStickDigitalMode", leftStickDigitalMode.getJsonName()); + data.put("rightStickDigitalMode", rightStickDigitalMode.getJsonName()); + } JSONArray elementsJSONArray = new JSONArray(); if (!elementsLoaded && file.isFile()) { diff --git a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java index dfc8d2d69b..9e5566b1a3 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java +++ b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java @@ -52,36 +52,39 @@ private static final class StickTuningConfig { final ControlsProfile.StickDeadzoneMode deadzoneModeRight; final ControlsProfile.StickDigitalMode directionModeLeft; final ControlsProfile.StickDigitalMode directionModeRight; + final Object owner; - StickTuningConfig(@Nullable ControlsProfile profile) { - deadzoneLeft = profile != null + StickTuningConfig(@Nullable ControlsProfile profile, @Nullable Object owner) { + boolean tuningEnabled = profile != null && profile.isStickTuningConfigured(); + deadzoneLeft = tuningEnabled ? profile.getLeftStickDeadzone() - : ControlsProfile.DEFAULT_STICK_DEADZONE; - deadzoneRight = profile != null + : ControlsProfile.MIN_STICK_DEADZONE; + deadzoneRight = tuningEnabled ? profile.getRightStickDeadzone() - : ControlsProfile.DEFAULT_STICK_DEADZONE; - sensitivityLeft = profile != null + : ControlsProfile.MIN_STICK_DEADZONE; + sensitivityLeft = tuningEnabled ? profile.getLeftStickSensitivity() : ControlsProfile.DEFAULT_STICK_SENSITIVITY; - sensitivityRight = profile != null + sensitivityRight = tuningEnabled ? profile.getRightStickSensitivity() : ControlsProfile.DEFAULT_STICK_SENSITIVITY; - deadzoneModeLeft = profile != null + deadzoneModeLeft = tuningEnabled ? profile.getLeftStickDeadzoneMode() : ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; - deadzoneModeRight = profile != null + deadzoneModeRight = tuningEnabled ? profile.getRightStickDeadzoneMode() : ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; - directionModeLeft = profile != null + directionModeLeft = tuningEnabled ? profile.getLeftStickDigitalMode() : ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; - directionModeRight = profile != null + directionModeRight = tuningEnabled ? profile.getRightStickDigitalMode() : ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; + this.owner = owner; } } - private static volatile StickTuningConfig stickTuning = new StickTuningConfig(null); + private static volatile StickTuningConfig stickTuning = new StickTuningConfig(null, null); private String id; private String name; @@ -97,9 +100,14 @@ private static final class StickTuningConfig { private int snappedLeftDirection = StickVectorProcessor.DIRECTION_NONE; private int snappedRightDirection = StickVectorProcessor.DIRECTION_NONE; - /** Applies {@code profile}'s stick tuning to every physical controller. */ - public static void setStickTuning(ControlsProfile profile) { - stickTuning = new StickTuningConfig(profile); + /** 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() { diff --git a/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java b/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java index ea80935680..1809f83b26 100644 --- a/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java +++ b/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java @@ -253,10 +253,13 @@ public static ControlsProfile loadProfile(Context context, InputStream inStream) float rightStickDeadzone = Float.NaN; float leftStickSensitivity = Float.NaN; float rightStickSensitivity = Float.NaN; - ControlsProfile.StickDeadzoneMode leftStickDeadzoneMode = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; - ControlsProfile.StickDeadzoneMode rightStickDeadzoneMode = ControlsProfile.DEFAULT_STICK_DEADZONE_MODE; - ControlsProfile.StickDigitalMode leftStickDigitalMode = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; - ControlsProfile.StickDigitalMode rightStickDigitalMode = ControlsProfile.DEFAULT_STICK_DIGITAL_MODE; + ControlsProfile.StickDeadzoneMode leftStickDeadzoneMode = null; + ControlsProfile.StickDeadzoneMode rightStickDeadzoneMode = null; + ControlsProfile.StickDigitalMode leftStickDigitalMode = null; + ControlsProfile.StickDigitalMode rightStickDigitalMode = null; + int fieldsRead = 0; + final int allProfileFields = (1 << 11) - 1; + boolean stoppedEarly = false; reader.beginObject(); while (reader.hasNext()) { @@ -264,54 +267,72 @@ public static ControlsProfile loadProfile(Context context, InputStream inStream) if (name.equals("id")) { profileId = reader.nextInt(); + fieldsRead |= 1 << 0; } else if (name.equals("name")) { profileName = reader.nextString(); + fieldsRead |= 1 << 1; } else if (name.equals("cursorSpeed")) { cursorSpeed = (float) reader.nextDouble(); + fieldsRead |= 1 << 2; } else if (name.equals("leftStickDeadzone")) { leftStickDeadzone = (float) reader.nextDouble(); + fieldsRead |= 1 << 3; } else if (name.equals("rightStickDeadzone")) { rightStickDeadzone = (float) reader.nextDouble(); + fieldsRead |= 1 << 4; } else if (name.equals("leftStickSensitivity")) { leftStickSensitivity = (float) reader.nextDouble(); + fieldsRead |= 1 << 5; } else if (name.equals("rightStickSensitivity")) { rightStickSensitivity = (float) reader.nextDouble(); + fieldsRead |= 1 << 6; } else if (name.equals("leftStickDeadzoneMode")) { leftStickDeadzoneMode = ControlsProfile.StickDeadzoneMode.fromJsonName(reader.nextString()); + fieldsRead |= 1 << 7; } else if (name.equals("rightStickDeadzoneMode")) { rightStickDeadzoneMode = ControlsProfile.StickDeadzoneMode.fromJsonName(reader.nextString()); + fieldsRead |= 1 << 8; } else if (name.equals("leftStickDigitalMode")) { leftStickDigitalMode = ControlsProfile.StickDigitalMode.fromJsonName(reader.nextString()); + fieldsRead |= 1 << 9; } else if (name.equals("rightStickDigitalMode")) { rightStickDigitalMode = ControlsProfile.StickDigitalMode.fromJsonName(reader.nextString()); + fieldsRead |= 1 << 10; } else { reader.skipValue(); } + + // Current profiles write all summary fields before their potentially large arrays. + // Keep accepting arbitrary field order, but avoid scanning those arrays when possible. + if (fieldsRead == allProfileFields) { + stoppedEarly = true; + break; + } } - reader.endObject(); + if (!stoppedEarly) reader.endObject(); ControlsProfile profile = new ControlsProfile(context, profileId); profile.setName(profileName); if (!Float.isNaN(cursorSpeed)) profile.setCursorSpeed(cursorSpeed); - profile.setLeftStickDeadzone(leftStickDeadzone); - profile.setRightStickDeadzone(rightStickDeadzone); - profile.setLeftStickSensitivity(leftStickSensitivity); - profile.setRightStickSensitivity(rightStickSensitivity); - profile.setLeftStickDeadzoneMode(leftStickDeadzoneMode); - profile.setRightStickDeadzoneMode(rightStickDeadzoneMode); - profile.setLeftStickDigitalMode(leftStickDigitalMode); - profile.setRightStickDigitalMode(rightStickDigitalMode); + 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 (IOException e) { diff --git a/app/src/main/java/com/winlator/widget/InputControlsView.java b/app/src/main/java/com/winlator/widget/InputControlsView.java index 14b1fc3350..1c52263fe5 100644 --- a/app/src/main/java/com/winlator/widget/InputControlsView.java +++ b/app/src/main/java/com/winlator/widget/InputControlsView.java @@ -591,11 +591,13 @@ 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++) { - // Indices 0..3 are the analog sticks, whose values already had the profile's complete - // stick tuning applied by ExternalController, so anything non-zero is live input. - // 4..5 are the digital hat axes, which keep the fixed threshold. + // 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 ? values[i] != 0 : Math.abs(values[i]) > ControlElement.STICK_DEAD_ZONE; + 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]))); 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 1497c2e58d..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 @@ -30,7 +30,7 @@ import java.util.concurrent.TimeUnit.MILLISECONDS @RunWith(RobolectricTestRunner::class) class PhysicalControllerHandlerTest { @Test - fun `snapped cardinal output activates only the selected digital binding`() { + 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) @@ -46,8 +46,6 @@ class PhysicalControllerHandlerTest { val profile = mock() whenever(profile.getController(deviceId)).thenReturn(controller) whenever(profile.gamepadState).thenReturn(gamepadState) - whenever(profile.leftStickDigitalMode).thenReturn(ControlsProfile.StickDigitalMode.FOUR_WAY) - whenever(profile.rightStickDigitalMode).thenReturn(ControlsProfile.StickDigitalMode.UNRESTRICTED) val handler = PhysicalControllerHandler(profile, mock(), gamepadStateSender = {}) val event = motionEvent(deviceId) @@ -68,6 +66,50 @@ class PhysicalControllerHandlerTest { } } + @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 @@ -194,6 +236,7 @@ class PhysicalControllerHandlerTest { } val profile = mock() whenever(profile.getController(deviceId)).thenReturn(controller) + whenever(profile.isStickTuningConfigured).thenReturn(true) val vectors = mutableListOf>() val handler = PhysicalControllerHandler( profile = profile, diff --git a/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt b/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt index 93329a7cb9..c21ee9ab4e 100644 --- a/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt @@ -255,6 +255,12 @@ class PhysicalControllerStickTuningTest { 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( """{ @@ -282,6 +288,7 @@ class PhysicalControllerStickTuningTest { assertEquals(ControlsProfile.StickDeadzoneMode.HYBRID, reordered.rightStickDeadzoneMode) assertEquals(ControlsProfile.StickDigitalMode.FOUR_WAY, reordered.leftStickDigitalMode) assertEquals(ControlsProfile.StickDigitalMode.EIGHT_WAY, reordered.rightStickDigitalMode) + assertTrue(reordered.isStickTuningConfigured) } @Test @@ -310,6 +317,7 @@ class PhysicalControllerStickTuningTest { assertEquals(ControlsProfile.StickDeadzoneMode.HYBRID, loaded.rightStickDeadzoneMode) assertEquals(ControlsProfile.StickDigitalMode.FOUR_WAY, loaded.leftStickDigitalMode) assertEquals(ControlsProfile.StickDigitalMode.EIGHT_WAY, loaded.rightStickDigitalMode) + assertTrue(loaded.isStickTuningConfigured) } private fun load(json: String): ControlsProfile { From b5a5fcccf22dc5f6d6a7d3f2cf888b7c241657f3 Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Tue, 8 Sep 2026 22:44:09 -0500 Subject: [PATCH 10/13] fix(input): preserve subpixel controller mouse movement --- .../ui/screen/xserver/PhysicalControllerHandler.kt | 7 +++---- .../com/winlator/widget/InputControlsView.java | 14 +++++++++++++- 2 files changed, 16 insertions(+), 5 deletions(-) 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 a7c2a57afe..096942f8d1 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 @@ -358,10 +358,9 @@ class PhysicalControllerHandler( override fun run() { // 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 && mouseMoveOffset.y == 0f) { - mouseMoveRemainder.set(0f, 0f) - return - } + if (mouseMoveOffset.x == 0f) mouseMoveRemainder.x = 0f + if (mouseMoveOffset.y == 0f) mouseMoveRemainder.y = 0f + if (mouseMoveOffset.x == 0f && mouseMoveOffset.y == 0f) return // Look up cursor speed dynamically so it updates when profile changes val cursorSpeed = profile?.cursorSpeed ?: 1f diff --git a/app/src/main/java/com/winlator/widget/InputControlsView.java b/app/src/main/java/com/winlator/widget/InputControlsView.java index 1c52263fe5..f0547ff35e 100644 --- a/app/src/main/java/com/winlator/widget/InputControlsView.java +++ b/app/src/main/java/com/winlator/widget/InputControlsView.java @@ -84,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 @@ -579,7 +580,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); } From 26fa48a8749f92edaa89c1cb56e24f46619d75f2 Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Tue, 8 Sep 2026 22:50:04 -0500 Subject: [PATCH 11/13] fix(input): synchronize controller mouse timer reset --- .../xserver/PhysicalControllerHandler.kt | 67 ++++++++++--------- 1 file changed, 37 insertions(+), 30 deletions(-) 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 096942f8d1..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 @@ -58,6 +58,7 @@ class PhysicalControllerHandler( 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 @@ -356,21 +357,23 @@ class PhysicalControllerHandler( mouseMoveTimer = Timer() mouseMoveTimer?.schedule(object : TimerTask() { override fun run() { - // 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 - - // 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) + 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) @@ -405,29 +408,33 @@ 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()) { + } + + private fun clearMouseMoveContributions() { + synchronized(mouseMoveLock) { + mouseMoveContributions.clear() + mouseMoveOffset.set(0f, 0f) mouseMoveRemainder.set(0f, 0f) mouseMoveTimer?.cancel() mouseMoveTimer = null } } - private fun clearMouseMoveContributions() { - mouseMoveContributions.clear() - mouseMoveOffset.set(0f, 0f) - mouseMoveRemainder.set(0f, 0f) - mouseMoveTimer?.cancel() - mouseMoveTimer = null - } - private fun handleScrollBinding(binding: Binding, isActionDown: Boolean): Boolean { if (binding != Binding.MOUSE_SCROLL_UP && binding != Binding.MOUSE_SCROLL_DOWN) { return false From a4c3fddb23c4419e7fa17263abc4f71843717283 Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Wed, 30 Sep 2026 23:32:48 -0500 Subject: [PATCH 12/13] Fix failed controller tuning save rollback --- .../dialog/PhysicalControllerConfigSection.kt | 24 +++++++++-- .../ui/screen/xserver/XServerScreen.kt | 3 +- .../inputcontrols/ControlsProfile.java | 5 +++ .../PhysicalControllerStickTuningTest.kt | 43 +++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) 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 f9f85e8527..5eefab1e8c 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 @@ -671,13 +671,28 @@ private data class PhysicalStickTuningState( } } +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: () -> Unit, + onSave: () -> Boolean, ) { var tuning by remember(profile) { mutableStateOf(PhysicalStickTuningState.from(profile)) } @@ -711,8 +726,11 @@ internal fun PhysicalControllerSettingsDialog( Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.reset)) } IconButton(onClick = { - tuning.applyTo(profile) - onSave() + applyAndSavePhysicalControllerTuning( + profile = profile, + applyTuning = { tuning.applyTo(profile) }, + onSave = onSave, + ) }) { Icon(Icons.Default.Check, contentDescription = stringResource(R.string.save)) } 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 3e19bd0a0b..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 @@ -3185,11 +3185,12 @@ fun XServerScreen( onSave = saveSettings@{ if (!profile.save()) { Timber.e("Failed to save physical controller stick settings for ${profile.name}") - return@saveSettings + return@saveSettings false } PluviaApp.inputControlsView?.setProfilePreservingOverlayVisibility(profile) PluviaApp.radialMenuCoordinator?.setProfile(profile) 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 2775c535fa..70042215a0 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java @@ -251,6 +251,11 @@ 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. diff --git a/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt b/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt index c21ee9ab4e..a275a21e8b 100644 --- a/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/PhysicalControllerStickTuningTest.kt @@ -1,6 +1,7 @@ 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 @@ -320,6 +321,48 @@ class PhysicalControllerStickTuningTest { 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( From 0a3713cfca2b00fc86e1dc8d6aae727bc4f45b6d Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Wed, 30 Sep 2026 23:46:17 -0500 Subject: [PATCH 13/13] Show controller tuning save failures --- .../dialog/PhysicalControllerConfigSection.kt | 11 ++++++++++- app/src/main/res/values-da/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-ro/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 16 files changed, 25 insertions(+), 1 deletion(-) 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 5eefab1e8c..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 @@ -29,6 +29,7 @@ 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 @@ -695,6 +696,9 @@ internal fun PhysicalControllerSettingsDialog( 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, @@ -707,6 +711,7 @@ internal fun PhysicalControllerSettingsDialog( Scaffold( modifier = Modifier.fillMaxSize(), containerColor = PluviaBackground, + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { CenterAlignedTopAppBar( title = { @@ -726,11 +731,15 @@ internal fun PhysicalControllerSettingsDialog( Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.reset)) } IconButton(onClick = { - applyAndSavePhysicalControllerTuning( + 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)) } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index f93d7a027d..c8a6597ee9 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -216,6 +216,7 @@ 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 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 31006884d0..c56ac9e7fc 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -280,6 +280,7 @@ 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 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b27443b701..af53e1a323 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -298,6 +298,7 @@ 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 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 174d773fb6..5548a08a98 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -288,6 +288,7 @@ 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8a900c2ffd..25a3bbc019 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -287,6 +287,7 @@ 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 diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index bb54567ae4..bdd5ba9227 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -241,6 +241,7 @@ 右スティック 十字キー 物理コントローラー設定 + コントローラー設定を保存できませんでした。もう一度お試しください。 左スティックのデッドゾーン 右スティックのデッドゾーン 左スティックの感度 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e3415d0110..5e0186168e 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -295,6 +295,7 @@ 오른쪽 스틱 십자 버튼 물리 컨트롤러 설정 + 컨트롤러 설정을 저장하지 못했습니다. 다시 시도하세요. 왼쪽 스틱 데드존 오른쪽 스틱 데드존 왼쪽 스틱 감도 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 636413bd01..a809160990 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -295,6 +295,7 @@ 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 00575863b2..e870a54941 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -216,6 +216,7 @@ 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 diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 0b9be951b5..98134f8faa 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -285,6 +285,7 @@ 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 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ddc93a5dba..21d2c952ce 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -400,6 +400,7 @@ Загрузка... D-Pad Настройки физического контроллера + Не удалось сохранить настройки контроллера. Повторите попытку. Мёртвая зона левого джойстика Мёртвая зона правого джойстика Чувствительность левого джойстика diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 8a6dc6e00b..10e485a193 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -282,6 +282,7 @@ Правий стік 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 026bf85931..17c85ad467 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -281,6 +281,7 @@ 右摇杆 方向键 实体控制器设置 + 无法保存控制器设置。请重试。 左摇杆死区 右摇杆死区 左摇杆灵敏度 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 40e8ae1e13..61c7294f55 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -281,6 +281,7 @@ 右搖桿 方向鍵 實體控制器設定 + 無法儲存控制器設定。請再試一次。 左搖桿死區 右搖桿死區 左搖桿靈敏度 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 32c073beb8..dfe2b09c44 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -275,6 +275,7 @@ Right Stick D-Pad Physical Controller Settings + Couldn’t save controller settings. Try again. Left Stick Deadzone Right Stick Deadzone Left Stick Sensitivity