diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 6cec55182b..c102a02e84 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -670,6 +670,38 @@ object PrefManager { setPref(PORTRAIT_MODE, value) } + private val CONTROLS_GYRO_MODE = stringPreferencesKey("controls_gyro_mode") + var controlsGyroMode: String + get() = getPref(CONTROLS_GYRO_MODE, "disabled") + set(value) { + setPref(CONTROLS_GYRO_MODE, value) + } + + fun setGyroMode(value: String) { + controlsGyroMode = value + } + + private val CONTROLS_GYRO_LAST_TARGET = intPreferencesKey("controls_gyro_last_target") + var controlsGyroLastTarget: Int + get() = getPref(CONTROLS_GYRO_LAST_TARGET, 1).coerceIn(1, 3) + set(value) { + setPref(CONTROLS_GYRO_LAST_TARGET, value.coerceIn(1, 3)) + } + + private val CONTROLS_GYRO_INVERT_X = booleanPreferencesKey("controls_gyro_invert_x") + var controlsGyroInvertX: Boolean + get() = getPref(CONTROLS_GYRO_INVERT_X, false) + set(value) { + setPref(CONTROLS_GYRO_INVERT_X, value) + } + + private val CONTROLS_GYRO_INVERT_Y = booleanPreferencesKey("controls_gyro_invert_y") + var controlsGyroInvertY: Boolean + get() = getPref(CONTROLS_GYRO_INVERT_Y, false) + set(value) { + setPref(CONTROLS_GYRO_INVERT_Y, value) + } + private val BOX_86_VERSION = stringPreferencesKey("box86_version") var box86Version: String get() = getPref(BOX_86_VERSION, DefaultVersion.BOX86) 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 6cc91dad55..51781c1c2d 100644 --- a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt +++ b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -220,6 +221,16 @@ fun QuickMenu( performanceHudConfig: PerformanceHudConfig = PerformanceHudConfig(), onPerformanceHudConfigChanged: (PerformanceHudConfig) -> Unit = {}, hasPhysicalController: Boolean = false, + gyroEnabled: Boolean = false, + onGyroEnabledChanged: (Boolean) -> Unit = {}, + gyroMapping: Int = 1, + onGyroMappingChanged: (Int) -> Unit = {}, + gyroSensitivity: Float = 0.35f, + onGyroSensitivityChanged: (Float) -> Unit = {}, + gyroInvertX: Boolean = false, + gyroInvertY: Boolean = false, + onGyroInvertXChanged: (Boolean) -> Unit = {}, + onGyroInvertYChanged: (Boolean) -> Unit = {}, activeToggleIds: Set = emptySet(), modifier: Modifier = Modifier, ) { @@ -492,7 +503,7 @@ fun QuickMenu( } } - else -> { + QuickMenuTab.CONTROLLER -> { Column( modifier = Modifier .fillMaxSize() @@ -512,6 +523,18 @@ fun QuickMenu( focusRequester = if (index == 0) controllerItemFocusRequester else null, ) } + ControllerGyroSection( + gyroEnabled = gyroEnabled, + onGyroEnabledChanged = onGyroEnabledChanged, + gyroMapping = gyroMapping, + onGyroMappingChanged = onGyroMappingChanged, + gyroSensitivity = gyroSensitivity, + onGyroSensitivityChanged = onGyroSensitivityChanged, + gyroInvertX = gyroInvertX, + onGyroInvertXChanged = onGyroInvertXChanged, + gyroInvertY = gyroInvertY, + onGyroInvertYChanged = onGyroInvertYChanged, + ) } } } @@ -537,6 +560,91 @@ fun QuickMenu( } } +@Composable +private fun ControllerGyroSection( + gyroEnabled: Boolean, + onGyroEnabledChanged: (Boolean) -> Unit, + gyroMapping: Int, + onGyroMappingChanged: (Int) -> Unit, + gyroSensitivity: Float, + onGyroSensitivityChanged: (Float) -> Unit, + gyroInvertX: Boolean, + onGyroInvertXChanged: (Boolean) -> Unit, + gyroInvertY: Boolean, + onGyroInvertYChanged: (Boolean) -> Unit, +) { + val accentColor = PluviaTheme.colors.accentPurple + Spacer(modifier = Modifier.height(8.dp)) + QuickMenuToggleRow( + title = stringResource(R.string.quick_menu_tab_gyro), + subtitle = stringResource(R.string.controller_gyro_mode_subtitle), + enabled = gyroEnabled, + onToggle = { onGyroEnabledChanged(!gyroEnabled) }, + accentColor = accentColor, + ) + Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = stringResource(R.string.controller_gyro_mapping), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + QuickMenuChoiceChip( + text = stringResource(R.string.left_stick), + selected = gyroMapping == 1, + accentColor = accentColor, + onClick = { onGyroMappingChanged(1) }, + ) + QuickMenuChoiceChip( + text = stringResource(R.string.right_stick), + selected = gyroMapping == 2, + accentColor = accentColor, + onClick = { onGyroMappingChanged(2) }, + ) + QuickMenuChoiceChip( + text = stringResource(R.string.mouse), + selected = gyroMapping == 3, + accentColor = accentColor, + onClick = { onGyroMappingChanged(3) }, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + QuickMenuAdjustmentRow( + title = stringResource(R.string.controller_gyro_sensitivity), + valueText = stringResource( + R.string.controller_gyro_sensitivity_value, + (gyroSensitivity * 100f).roundToInt(), + ), + progress = normalizedProgress(gyroSensitivity, 0.1f, 2.0f), + onDecrease = { onGyroSensitivityChanged((gyroSensitivity - 0.05f).coerceIn(0.1f, 2.0f)) }, + onIncrease = { onGyroSensitivityChanged((gyroSensitivity + 0.05f).coerceIn(0.1f, 2.0f)) }, + accentColor = accentColor, + ) + Spacer(modifier = Modifier.height(8.dp)) + QuickMenuToggleRow( + title = stringResource(R.string.controller_gyro_invert_x), + enabled = gyroInvertX, + onToggle = { onGyroInvertXChanged(!gyroInvertX) }, + accentColor = accentColor, + ) + QuickMenuToggleRow( + title = stringResource(R.string.controller_gyro_invert_y), + enabled = gyroInvertY, + onToggle = { onGyroInvertYChanged(!gyroInvertY) }, + accentColor = accentColor, + ) + Spacer(modifier = Modifier.height(12.dp)) +} + @Composable private fun PerformanceHudQuickMenuTab( isPerformanceHudEnabled: Boolean, 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 e8aca665a2..ff9a3297f2 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 @@ -39,6 +39,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -194,6 +195,30 @@ private val isExiting = AtomicBoolean(false) private const val EXIT_PROCESS_TIMEOUT_MS = 30_000L private const val EXIT_PROCESS_POLL_INTERVAL_MS = 1_000L private const val EXIT_PROCESS_RESPONSE_TIMEOUT_MS = 2_000L +private const val PREF_CONTROLS_GYRO_MODE = "controls_gyro_mode" +private const val PREF_CONTROLS_GYRO_SENSITIVITY = "controls_gyro_sensitivity" +private const val GYRO_MODE_DISABLED = 0 +private const val GYRO_MODE_LEFT_STICK = 1 +private const val GYRO_MODE_RIGHT_STICK = 2 +private const val GYRO_MODE_MOUSE = 3 + +private fun parseGyroMode(value: String?): Int { + return when (value?.lowercase(Locale.getDefault())) { + "left_stick" -> GYRO_MODE_LEFT_STICK + "right_stick" -> GYRO_MODE_RIGHT_STICK + "mouse" -> GYRO_MODE_MOUSE + else -> GYRO_MODE_DISABLED + } +} + +private fun gyroModeToPrefValue(mode: Int): String { + return when (mode) { + GYRO_MODE_LEFT_STICK -> "left_stick" + GYRO_MODE_RIGHT_STICK -> "right_stick" + GYRO_MODE_MOUSE -> "mouse" + else -> "disabled" + } +} private data class XServerViewReleaseBinding( val xServerView: XServerView, @@ -378,6 +403,18 @@ fun XServerScreen( var hasPhysicalMouse by remember { mutableStateOf(false) } var hasInternalTouchpad by remember { mutableStateOf(false) } var hasUpdatedScreenGamepad by remember { mutableStateOf(false) } + var controlsGyroMode by remember { + mutableStateOf(parseGyroMode(PrefManager.getString(PREF_CONTROLS_GYRO_MODE, "disabled"))) + } + var controlsGyroLastTarget by remember { + val mode = parseGyroMode(PrefManager.getString(PREF_CONTROLS_GYRO_MODE, "disabled")) + mutableIntStateOf(if (mode != GYRO_MODE_DISABLED) mode else PrefManager.controlsGyroLastTarget.coerceIn(1, 3)) + } + var controlsGyroSensitivity by remember { + mutableStateOf(PrefManager.getFloat(PREF_CONTROLS_GYRO_SENSITIVITY, 0.35f).coerceIn(0.1f, 2.0f)) + } + var controlsGyroInvertX by remember { mutableStateOf(PrefManager.controlsGyroInvertX) } + var controlsGyroInvertY by remember { mutableStateOf(PrefManager.controlsGyroInvertY) } var isPerformanceHudEnabled by remember { mutableStateOf(PrefManager.showFps) } fun loadPerformanceHudConfig(): PerformanceHudConfig { @@ -1122,8 +1159,13 @@ fun XServerScreen( } else { var handled = false if (isGamepad && it.event != null) { + // Physical handler and InputControlsView share the same joystick pipeline; do not + // call both or axes/triggers are applied twice. Match onKeyEvent: physical first, + // overlay only if nothing consumed (e.g. no PhysicalControllerHandler yet). handled = physicalControllerHandler?.onGenericMotionEvent(it.event!!) == true - if (!handled) handled = PluviaApp.inputControlsView?.onGenericMotionEvent(it.event) == true + if (!handled) { + handled = PluviaApp.inputControlsView?.onGenericMotionEvent(it.event) == true + } // Final fallback to WinHandler passthrough if (!handled) handled = xServerView!!.getxServer().winHandler.onGenericMotionEvent(it.event) } @@ -1729,6 +1771,10 @@ fun XServerScreen( // Set container-level shooter mode setContainerShooterMode(container.isShooterMode) + setGyroMode(controlsGyroMode) + setGyroSensitivity(controlsGyroSensitivity) + setGyroInvertX(controlsGyroInvertX) + setGyroInvertY(controlsGyroInvertY) } PluviaApp.inputControlsView = icView @@ -2024,6 +2070,53 @@ fun XServerScreen( performanceHudConfig = performanceHudConfig, onPerformanceHudConfigChanged = ::applyPerformanceHudConfig, hasPhysicalController = hasPhysicalController, + gyroEnabled = controlsGyroMode != GYRO_MODE_DISABLED, + onGyroEnabledChanged = { enabled -> + if (enabled) { + val target = controlsGyroLastTarget.coerceIn(GYRO_MODE_LEFT_STICK, GYRO_MODE_MOUSE) + controlsGyroLastTarget = target + controlsGyroMode = target + PrefManager.controlsGyroLastTarget = target + PrefManager.setGyroMode(gyroModeToPrefValue(target)) + PluviaApp.inputControlsView?.setGyroMode(target) + } else { + val target = controlsGyroLastTarget.coerceIn(GYRO_MODE_LEFT_STICK, GYRO_MODE_MOUSE) + controlsGyroLastTarget = target + PrefManager.controlsGyroLastTarget = target + controlsGyroMode = GYRO_MODE_DISABLED + PrefManager.setGyroMode("disabled") + PluviaApp.inputControlsView?.setGyroMode(GYRO_MODE_DISABLED) + } + }, + gyroMapping = controlsGyroLastTarget.coerceIn(GYRO_MODE_LEFT_STICK, GYRO_MODE_MOUSE), + onGyroMappingChanged = { target -> + val t = target.coerceIn(GYRO_MODE_LEFT_STICK, GYRO_MODE_MOUSE) + controlsGyroLastTarget = t + PrefManager.controlsGyroLastTarget = t + if (controlsGyroMode != GYRO_MODE_DISABLED) { + controlsGyroMode = t + PrefManager.setGyroMode(gyroModeToPrefValue(t)) + PluviaApp.inputControlsView?.setGyroMode(t) + } + }, + gyroSensitivity = controlsGyroSensitivity, + onGyroSensitivityChanged = { sensitivity -> + controlsGyroSensitivity = sensitivity + PrefManager.setFloat(PREF_CONTROLS_GYRO_SENSITIVITY, sensitivity) + PluviaApp.inputControlsView?.setGyroSensitivity(sensitivity) + }, + gyroInvertX = controlsGyroInvertX, + gyroInvertY = controlsGyroInvertY, + onGyroInvertXChanged = { invert -> + controlsGyroInvertX = invert + PrefManager.controlsGyroInvertX = invert + PluviaApp.inputControlsView?.setGyroInvertX(invert) + }, + onGyroInvertYChanged = { invert -> + controlsGyroInvertY = invert + PrefManager.controlsGyroInvertY = invert + PluviaApp.inputControlsView?.setGyroInvertY(invert) + }, activeToggleIds = buildSet { if (areControlsVisible) add(QuickMenuAction.INPUT_CONTROLS) }, @@ -2356,7 +2449,6 @@ private fun showInputControls(profile: ControlsProfile, winHandler: WinHandler, private fun hideInputControls() { PluviaApp.inputControlsView?.setShowTouchscreenControls(false) PluviaApp.inputControlsView?.setVisibility(View.GONE) - PluviaApp.inputControlsView?.setProfile(null) PluviaApp.touchpadView?.setSensitivity(1.0f) PluviaApp.touchpadView?.setPointerButtonLeftEnabled(true) diff --git a/app/src/main/java/com/winlator/widget/GyroController.java b/app/src/main/java/com/winlator/widget/GyroController.java new file mode 100644 index 0000000000..924070e4a5 --- /dev/null +++ b/app/src/main/java/com/winlator/widget/GyroController.java @@ -0,0 +1,182 @@ +package com.winlator.widget; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.view.Surface; +import android.view.WindowManager; + +import androidx.annotation.NonNull; + +import com.winlator.math.Mathf; + +class GyroController implements SensorEventListener { + interface Listener { + void onGyroOutput(float x, float y, boolean rightStick, boolean isMouse); + } + + private final SensorManager sensorManager; + private final Sensor gyroscopeSensor; + private final Listener listener; + private final WindowManager windowManager; + + private int mode = InputControlsView.GYRO_MODE_DISABLED; + private float sensitivity = 0.35f; + private boolean invertX = false; + private boolean invertY = false; + private boolean editMode = false; + private boolean hasProfile = false; + private boolean isRegistered = false; + private boolean isAttached = false; + + GyroController(@NonNull Context context, @NonNull Listener listener) { + this.listener = listener; + sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); + gyroscopeSensor = sensorManager != null ? sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) : null; + windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + } + + int getMode() { + return mode; + } + + void setMode(int mode) { + int normalizedMode = (mode == InputControlsView.GYRO_MODE_LEFT_STICK + || mode == InputControlsView.GYRO_MODE_RIGHT_STICK + || mode == InputControlsView.GYRO_MODE_MOUSE) + ? mode : InputControlsView.GYRO_MODE_DISABLED; + if (this.mode == normalizedMode) { + updateRegistration(); + return; + } + clearCurrentStick(); + this.mode = normalizedMode; + updateRegistration(); + } + + void setEditMode(boolean editMode) { + if (!this.editMode && editMode) { + clearCurrentStick(); + } + this.editMode = editMode; + updateRegistration(); + } + + void setSensitivity(float sensitivity) { + this.sensitivity = Mathf.clamp(sensitivity, 0.1f, 2.0f); + } + + void setInvertX(boolean invertX) { + this.invertX = invertX; + } + + void setInvertY(boolean invertY) { + this.invertY = invertY; + } + + void setHasProfile(boolean hasProfile) { + if (this.hasProfile && !hasProfile && + (mode == InputControlsView.GYRO_MODE_LEFT_STICK || mode == InputControlsView.GYRO_MODE_RIGHT_STICK)) { + clearCurrentStick(); + } + this.hasProfile = hasProfile; + updateRegistration(); + } + + void onAttachedToWindow() { + isAttached = true; + updateRegistration(); + } + + void onDetachedFromWindow() { + isAttached = false; + unregister(); + clearCurrentStick(); + } + + @Override + public void onSensorChanged(SensorEvent event) { + if (event == null || event.sensor == null || event.sensor.getType() != Sensor.TYPE_GYROSCOPE) return; + if (mode == InputControlsView.GYRO_MODE_DISABLED || editMode || !hasProfile) return; + + float rawX = -event.values[1]; + float rawY = -event.values[0]; + int rotation = windowManager != null && windowManager.getDefaultDisplay() != null + ? windowManager.getDefaultDisplay().getRotation() + : Surface.ROTATION_0; + float[] mapped = mapToStick(rawX, rawY, rotation); + float x = mapped[0]; + float y = mapped[1]; + + boolean isMouse = mode == InputControlsView.GYRO_MODE_MOUSE; + listener.onGyroOutput(x, y, mode == InputControlsView.GYRO_MODE_RIGHT_STICK, isMouse); + } + + float[] mapToStick(float rawX, float rawY, int rotation) { + float mappedX; + float mappedY; + switch (rotation) { + case Surface.ROTATION_90: + mappedX = rawY; + mappedY = -rawX; + break; + case Surface.ROTATION_180: + mappedX = -rawX; + mappedY = -rawY; + break; + case Surface.ROTATION_270: + mappedX = -rawY; + mappedY = rawX; + break; + case Surface.ROTATION_0: + default: + mappedX = rawX; + mappedY = rawY; + break; + } + + if (invertX) mappedX = -mappedX; + if (invertY) mappedY = -mappedY; + + float x = Mathf.clamp(mappedX * sensitivity, -1f, 1f); + float y = Mathf.clamp(mappedY * sensitivity, -1f, 1f); + if (Math.abs(x) < 0.03f) x = 0f; + if (Math.abs(y) < 0.03f) y = 0f; + return new float[]{x, y}; + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) { + // no-op + } + + private void clearCurrentStick() { + if (mode == InputControlsView.GYRO_MODE_LEFT_STICK) { + listener.onGyroOutput(0f, 0f, false, false); + } else if (mode == InputControlsView.GYRO_MODE_RIGHT_STICK) { + listener.onGyroOutput(0f, 0f, true, false); + } + } + + private void updateRegistration() { + if (!isAttached || sensorManager == null || gyroscopeSensor == null) { + unregister(); + return; + } + if (mode == InputControlsView.GYRO_MODE_DISABLED || editMode || !hasProfile) { + unregister(); + return; + } + if (isRegistered) return; + sensorManager.registerListener(this, gyroscopeSensor, SensorManager.SENSOR_DELAY_GAME); + isRegistered = true; + } + + private void unregister() { + if (sensorManager == null || !isRegistered) return; + sensorManager.unregisterListener(this, gyroscopeSensor); + isRegistered = false; + } +} diff --git a/app/src/main/java/com/winlator/widget/InputControlsView.java b/app/src/main/java/com/winlator/widget/InputControlsView.java index 5466b31c20..2485928b05 100644 --- a/app/src/main/java/com/winlator/widget/InputControlsView.java +++ b/app/src/main/java/com/winlator/widget/InputControlsView.java @@ -46,6 +46,10 @@ public class InputControlsView extends View { public static final float DEFAULT_OVERLAY_OPACITY = 0.4f; + public static final int GYRO_MODE_DISABLED = 0; + public static final int GYRO_MODE_LEFT_STICK = 1; + public static final int GYRO_MODE_RIGHT_STICK = 2; + public static final int GYRO_MODE_MOUSE = 3; private boolean editMode = false; private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Path path = new Path(); @@ -87,10 +91,26 @@ public class InputControlsView extends View { // Container-level shooter mode (auto-replaces STICK elements) private boolean containerShooterMode = false; private boolean containerShooterModeRuntime = false; // runtime toggle state + private final GyroController gyroController; + private float baseThumbLX = 0f; + private float baseThumbLY = 0f; + private float baseThumbRX = 0f; + private float baseThumbRY = 0f; + private float gyroThumbLX = 0f; + private float gyroThumbLY = 0f; + private float gyroThumbRX = 0f; + private float gyroThumbRY = 0f; @SuppressLint("ResourceType") public InputControlsView(Context context) { super(context); + gyroController = new GyroController(context, (x, y, rightStick, isMouse) -> { + if (isMouse) { + updateGyroMouse(x, y); + } else { + updateGyroStick(rightStick, x, y); + } + }); setClickable(true); setFocusable(true); setFocusableInTouchMode(true); @@ -105,6 +125,7 @@ public boolean isEditMode() { public void setEditMode(boolean editMode) { this.editMode = editMode; + gyroController.setEditMode(editMode); invalidate(); // Trigger redraw to show/hide grid background immediately } @@ -271,7 +292,11 @@ public synchronized void setProfile(ControlsProfile profile) { this.profile = profile; deselectAllElements(); } - else this.profile = null; + else { + this.profile = null; + resetThumbContributions(); + } + gyroController.setHasProfile(this.profile != null); } public boolean isShowTouchscreenControls() { @@ -282,6 +307,26 @@ public void setShowTouchscreenControls(boolean showTouchscreenControls) { this.showTouchscreenControls = showTouchscreenControls; } + public int getGyroMode() { + return gyroController.getMode(); + } + + public void setGyroMode(int mode) { + gyroController.setMode(mode); + } + + public void setGyroSensitivity(float sensitivity) { + gyroController.setSensitivity(sensitivity); + } + + public void setGyroInvertX(boolean invertX) { + gyroController.setInvertX(invertX); + } + + public void setGyroInvertY(boolean invertY) { + gyroController.setInvertY(invertY); + } + public int getPrimaryColor() { return Color.argb((int)(overlayOpacity * 255), 255, 255, 255); } @@ -334,11 +379,18 @@ public int getMaxWidth() { @Override protected void onDetachedFromWindow() { + gyroController.onDetachedFromWindow(); if (mouseMoveTimer != null) mouseMoveTimer.cancel(); super.onDetachedFromWindow(); } + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + gyroController.onAttachedToWindow(); + } + public int getMaxHeight() { return (int)Mathf.roundTo(getHeight(), snappingSize); } @@ -942,6 +994,7 @@ public void handleInputEvent(Binding binding, boolean isActionDown) { public void handleInputEvent(Binding binding, boolean isActionDown, float offset) { if (binding.isGamepad()) { + if (profile == null) return; WinHandler winHandler = xServer != null ? xServer.getWinHandler() : null; GamepadState state = profile.getGamepadState(); @@ -957,16 +1010,20 @@ public void handleInputEvent(Binding binding, boolean isActionDown, float offset state.setPressed(buttonIdx, isActionDown); } else if (binding == Binding.GAMEPAD_LEFT_THUMB_UP || binding == Binding.GAMEPAD_LEFT_THUMB_DOWN) { - state.thumbLY = isActionDown ? offset : 0; + baseThumbLY = isActionDown ? offset : 0; + state.thumbLY = Mathf.clamp(baseThumbLY + gyroThumbLY, -1f, 1f); } else if (binding == Binding.GAMEPAD_LEFT_THUMB_LEFT || binding == Binding.GAMEPAD_LEFT_THUMB_RIGHT) { - state.thumbLX = isActionDown ? offset : 0; + baseThumbLX = isActionDown ? offset : 0; + state.thumbLX = Mathf.clamp(baseThumbLX + gyroThumbLX, -1f, 1f); } else if (binding == Binding.GAMEPAD_RIGHT_THUMB_UP || binding == Binding.GAMEPAD_RIGHT_THUMB_DOWN) { - state.thumbRY = isActionDown ? offset : 0; + baseThumbRY = isActionDown ? offset : 0; + state.thumbRY = Mathf.clamp(baseThumbRY + gyroThumbRY, -1f, 1f); } else if (binding == Binding.GAMEPAD_RIGHT_THUMB_LEFT || binding == Binding.GAMEPAD_RIGHT_THUMB_RIGHT) { - state.thumbRX = isActionDown ? offset : 0; + baseThumbRX = isActionDown ? offset : 0; + state.thumbRX = Mathf.clamp(baseThumbRX + gyroThumbRX, -1f, 1f); } else if (binding == Binding.GAMEPAD_DPAD_UP || binding == Binding.GAMEPAD_DPAD_RIGHT || binding == Binding.GAMEPAD_DPAD_DOWN || binding == Binding.GAMEPAD_DPAD_LEFT) { @@ -1007,6 +1064,55 @@ else if (binding == Binding.MOUSE_MOVE_DOWN || binding == Binding.MOUSE_MOVE_UP) } } + private void updateGyroMouse(float x, float y) { + if (profile == null || xServer == null) return; + if (Math.abs(x) < 0.001f && Math.abs(y) < 0.001f) return; + float cursorSpeed = profile.getCursorSpeed(); + int moveX = Mathf.roundPoint(x * 24f * cursorSpeed); + int moveY = Mathf.roundPoint(y * 24f * cursorSpeed); + if (moveX == 0 && moveY == 0) return; + if (xServer.isRelativeMouseMovement()) { + xServer.getWinHandler().mouseEvent(MouseEventFlags.MOVE, moveX, moveY, 0); + } else { + xServer.injectPointerMoveDelta(moveX, moveY); + } + } + + private void updateGyroStick(boolean rightStick, float x, float y) { + if (profile == null) return; + GamepadState state = profile.getGamepadState(); + if (rightStick) { + gyroThumbRX = x; + gyroThumbRY = y; + state.thumbRX = Mathf.clamp(baseThumbRX + gyroThumbRX, -1f, 1f); + state.thumbRY = Mathf.clamp(baseThumbRY + gyroThumbRY, -1f, 1f); + } else { + gyroThumbLX = x; + gyroThumbLY = y; + state.thumbLX = Mathf.clamp(baseThumbLX + gyroThumbLX, -1f, 1f); + state.thumbLY = Mathf.clamp(baseThumbLY + gyroThumbLY, -1f, 1f); + } + + WinHandler winHandler = xServer != null ? xServer.getWinHandler() : null; + if (winHandler != null) { + ExternalController controller = winHandler.getCurrentController(); + if (controller != null) controller.state.copy(state); + winHandler.sendGamepadState(); + winHandler.sendVirtualGamepadState(state); + } + } + + private void resetThumbContributions() { + baseThumbLX = 0f; + baseThumbLY = 0f; + baseThumbRX = 0f; + baseThumbRY = 0f; + gyroThumbLX = 0f; + gyroThumbLY = 0f; + gyroThumbRX = 0f; + gyroThumbRY = 0f; + } + public Bitmap getIcon(byte id) { if (icons[id] == null) { Context context = getContext(); diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 4f94dc76e7..4cd221422b 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -224,6 +224,13 @@ Luk Generelt Controller + Gyro + Tildel enhedens gyro til en gamepad-joystick eller mus + Tildel til + Følsomhed + %1$d%% + Inverter vandret + Inverter lodret Ydelses-HUD Vis eller skjul overlayet i spillet. Forudindstillinger diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0eae33a122..45e031bf1a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -286,6 +286,13 @@ Schließen Allgemein Controller + Gyro + Geräte-Gyroskop einem Gamepad-Stick oder der Maus zuordnen + Zuordnen zu + Empfindlichkeit + %1$d%% + Horizontalachse umkehren + Vertikalachse umkehren Leistungs-HUD Leistungsoverlay im Spiel ein- oder ausblenden. Voreinstellungen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8f8e9ce554..e64eff7bc5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -308,6 +308,13 @@ Cerrar General Controles + Giroscopio + Asigna el giroscopio del dispositivo a un joystick de mando o al ratón + Asignar a + Sensibilidad + %1$d%% + Invertir horizontal + Invertir vertical HUD de rendimiento Mostrar u ocultar la superposición en el juego. Preajustes diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 0cb58a144b..f15a49bf1c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -293,6 +293,13 @@ Fermer Général Contrôles + Gyroscope + Associer le gyroscope de l\'appareil à un joystick de manette ou à la souris + Affecter à + Sensibilité + %1$d%% + Inverser l\'axe horizontal + Inverser l\'axe vertical HUD de performance Afficher ou masquer la surcouche en jeu. Préréglages diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8f7b518fa4..30dfb88832 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -297,6 +297,13 @@ Chiudi Generale Controller + Giroscopio + Mappa il giroscopio del dispositivo su uno stick del controller o sul mouse + Mappa su + Sensibilità + %1$d%% + Inverti orizzontale + Inverti verticale HUD prestazioni Mostra o nascondi l’overlay di gioco. Predefiniti diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d6ad3fb7a4..3dfc6927e3 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -306,6 +306,13 @@ 닫기 일반 컨트롤러 + 자이로 + 기기 자이로를 게임패드 스틱이나 마우스에 매핑합니다 + 매핑 대상 + 감도 + %1$d%% + 가로 반전 + 세로 반전 성능 HUD 게임 내 오버레이를 표시하거나 숨깁니다. 프리셋 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index c6db415c9f..593c663815 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -305,6 +305,13 @@ Zamknij Ogólne Kontroler + Żyroskop + Mapuj żyroskop urządzenia na joystick pada lub mysz + Mapuj na + Czułość + %1$d%% + Odwróć poziomo + Odwróć pionowo HUD wydajności Pokaż lub ukryj nakładkę w grze. Presety diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c7d1088386..9326f30ee3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -224,6 +224,13 @@ Fechar Geral Controle + Giroscópio + Mapeie o giroscópio do dispositivo para um analógico do controle ou mouse + Mapear para + Sensibilidade + %1$d%% + Inverter horizontal + Inverter vertical HUD de desempenho Mostrar ou ocultar a sobreposição no jogo. Predefinições diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index e3077c203b..624b015483 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -295,6 +295,13 @@ Închide General Controller + Giroscop + Mapează giroscopul dispozitivului la un joystick de gamepad sau la mouse + Mapează la + Sensibilitate + %1$d%% + Inversează orizontal + Inversează vertical HUD de performanță Afișează sau ascunde suprapunerea din joc. Presetări diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 349b2efd26..c873a572dc 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -841,6 +841,13 @@ https://gamenative.app Закрыть Общие Контроллер + Гироскоп + Назначить гироскоп устройства на стик геймпада или мышь + Назначить на + Чувствительность + %1$d%% + Инвертировать по горизонтали + Инвертировать по вертикали HUD производительности Показывать или скрывать игровое наложение. Предустановки diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 5f752c6d53..3f6c730899 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -292,6 +292,13 @@ Закрити Загальні Контролер + Гіроскоп + Призначити гіроскоп пристрою на стік геймпада або мишу + Призначити на + Чутливість + %1$d%% + Інвертувати по горизонталі + Інвертувати по вертикалі HUD продуктивності Показувати або приховувати ігрове накладання. Набори diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 28c449fb44..f662a089a3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -291,6 +291,13 @@ 关闭 常规 控制器 + 陀螺仪 + 将设备陀螺仪映射到手柄摇杆或鼠标 + 映射到 + 灵敏度 + %1$d%% + 水平反转 + 垂直反转 性能 HUD 显示或隐藏游戏内叠加层。 预设 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 08a1152f40..0b429f77b5 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -291,6 +291,13 @@ 關閉 一般 控制器 + 陀螺儀 + 將裝置陀螺儀映射到手把搖桿或滑鼠 + 映射到 + 靈敏度 + %1$d%% + 水平反轉 + 垂直反轉 效能 HUD 顯示或隱藏遊戲內疊加層。 預設 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 41e8174521..678ab6dff8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -279,6 +279,13 @@ Close General Controller + Gyro + Map device gyro to a gamepad joystick or mouse + Map to + Sensitivity + %1$d%% + Invert Horizontal + Invert Vertical Performance HUD Show or hide the in-game overlay. Presets diff --git a/app/src/test/java/com/winlator/widget/GyroControllerTest.kt b/app/src/test/java/com/winlator/widget/GyroControllerTest.kt new file mode 100644 index 0000000000..2878f2d7b1 --- /dev/null +++ b/app/src/test/java/com/winlator/widget/GyroControllerTest.kt @@ -0,0 +1,243 @@ +package com.winlator.widget + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.view.Surface +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito +import org.mockito.Mockito.never +import org.mockito.Mockito.verify + +class GyroControllerTest { + private data class GyroEvent(val x: Float, val y: Float, val rightStick: Boolean, val isMouse: Boolean) + + private class RecordingListener : GyroController.Listener { + val events = mutableListOf() + + override fun onGyroOutput(x: Float, y: Float, rightStick: Boolean, isMouse: Boolean) { + events += GyroEvent(x, y, rightStick, isMouse) + } + } + + private fun createController(listener: RecordingListener): GyroController { + val context = Mockito.mock(Context::class.java) + return GyroController(context, listener) + } + + private fun createControllerWithGyroSensor(listener: RecordingListener): Pair { + val sensorManager = Mockito.mock(SensorManager::class.java) + val sensor = Mockito.mock(Sensor::class.java) + Mockito.`when`(sensor.type).thenReturn(Sensor.TYPE_GYROSCOPE) + Mockito.`when`(sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)).thenReturn(sensor) + val context = Mockito.mock(Context::class.java) + Mockito.`when`(context.getSystemService(Context.SENSOR_SERVICE)).thenReturn(sensorManager) + Mockito.`when`(context.getSystemService(Context.WINDOW_SERVICE)).thenReturn(null) + return Pair(GyroController(context, listener), sensorManager) + } + + @Test + fun mapToStick_appliesRotation() { + val listener = RecordingListener() + val controller = createController(listener) + controller.setSensitivity(1f) + + val mapped = controller.mapToStick(0.4f, 0.2f, Surface.ROTATION_90) + + assertEquals(0.2f, mapped[0], 0.0001f) + assertEquals(-0.4f, mapped[1], 0.0001f) + } + + @Test + fun mapToStick_appliesInvertToggles() { + val listener = RecordingListener() + val controller = createController(listener) + controller.setSensitivity(1f) + controller.setInvertX(true) + controller.setInvertY(true) + + val mapped = controller.mapToStick(0.3f, -0.6f, Surface.ROTATION_0) + + assertEquals(-0.3f, mapped[0], 0.0001f) + assertEquals(0.6f, mapped[1], 0.0001f) + } + + @Test + fun mapToStick_clampsAndDeadzonesWithSensitivity() { + val listener = RecordingListener() + val controller = createController(listener) + + controller.setSensitivity(2f) + val high = controller.mapToStick(0.8f, -0.8f, Surface.ROTATION_0) + assertEquals(1f, high[0], 0.0001f) + assertEquals(-1f, high[1], 0.0001f) + + controller.setSensitivity(0f) // clamp to minimum 0.1 + val low = controller.mapToStick(0.5f, 0.2f, Surface.ROTATION_0) + assertEquals(0.05f, low[0], 0.0001f) + assertEquals(0f, low[1], 0.0001f) // deadzone (< 0.03 after scaling) + } + + @Test + fun setMode_clearsPreviousStickContribution() { + val listener = RecordingListener() + val controller = createController(listener) + + controller.setMode(InputControlsView.GYRO_MODE_LEFT_STICK) + assertTrue(listener.events.isEmpty()) + + controller.setMode(InputControlsView.GYRO_MODE_RIGHT_STICK) + assertEquals(1, listener.events.size) + assertEquals(0f, listener.events[0].x, 0.0001f) + assertEquals(0f, listener.events[0].y, 0.0001f) + assertFalse(listener.events[0].rightStick) + assertFalse(listener.events[0].isMouse) + + controller.setMode(InputControlsView.GYRO_MODE_DISABLED) + assertEquals(2, listener.events.size) + assertTrue(listener.events[1].rightStick) + assertFalse(listener.events[1].isMouse) + } + + @Test + fun setEditMode_enteringEditMode_clearsLeftStick() { + val listener = RecordingListener() + val controller = createController(listener) + controller.setMode(InputControlsView.GYRO_MODE_LEFT_STICK) + assertTrue(listener.events.isEmpty()) + + controller.setEditMode(true) + + assertEquals(1, listener.events.size) + assertEquals(0f, listener.events[0].x, 0.0001f) + assertEquals(0f, listener.events[0].y, 0.0001f) + assertFalse(listener.events[0].rightStick) + assertFalse(listener.events[0].isMouse) + } + + @Test + fun setEditMode_enteringEditMode_clearsRightStick() { + val listener = RecordingListener() + val controller = createController(listener) + controller.setMode(InputControlsView.GYRO_MODE_RIGHT_STICK) + assertTrue(listener.events.isEmpty()) + + controller.setEditMode(true) + + assertEquals(1, listener.events.size) + assertEquals(0f, listener.events[0].x, 0.0001f) + assertEquals(0f, listener.events[0].y, 0.0001f) + assertTrue(listener.events[0].rightStick) + assertFalse(listener.events[0].isMouse) + } + + @Test + fun setEditMode_enteringEditMode_withGyroDisabled_doesNotNotifyListener() { + val listener = RecordingListener() + val controller = createController(listener) + controller.setMode(InputControlsView.GYRO_MODE_DISABLED) + + controller.setEditMode(true) + + assertTrue(listener.events.isEmpty()) + } + + @Test + fun setEditMode_exitingEditMode_doesNotClearStickAgain() { + val listener = RecordingListener() + val controller = createController(listener) + controller.setMode(InputControlsView.GYRO_MODE_LEFT_STICK) + controller.setEditMode(true) + assertEquals(1, listener.events.size) + + controller.setEditMode(false) + + assertEquals(1, listener.events.size) + } + + /** + * When profile goes away while gyro targets a stick, unregister alone would leave the + * last merged stick value latched in InputControlsView until something else overwrites it. + */ + @Test + fun setHasProfile_losingProfile_clearsLeftStick() { + val listener = RecordingListener() + val controller = createController(listener) + controller.setMode(InputControlsView.GYRO_MODE_LEFT_STICK) + controller.setHasProfile(true) + listener.events.clear() + + controller.setHasProfile(false) + + assertEquals(1, listener.events.size) + assertEquals(0f, listener.events[0].x, 0.0001f) + assertEquals(0f, listener.events[0].y, 0.0001f) + assertFalse(listener.events[0].rightStick) + assertFalse(listener.events[0].isMouse) + } + + @Test + fun setMode_mouseMode_doesNotEmitStickClearWhenSwitchingAway() { + val listener = RecordingListener() + val controller = createController(listener) + controller.setMode(InputControlsView.GYRO_MODE_MOUSE) + assertTrue(listener.events.isEmpty()) + + controller.setMode(InputControlsView.GYRO_MODE_LEFT_STICK) + assertTrue(listener.events.isEmpty()) + } + + @Test + fun updateRegistration_registersGyroListenerOnlyAfterViewAttached() { + val listener = RecordingListener() + val (controller, sensorManager) = createControllerWithGyroSensor(listener) + val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)!! + + controller.setMode(InputControlsView.GYRO_MODE_LEFT_STICK) + controller.setHasProfile(true) + + verify(sensorManager, never()).registerListener( + any(SensorEventListener::class.java), + eq(sensor), + eq(SensorManager.SENSOR_DELAY_GAME), + ) + + controller.onAttachedToWindow() + + verify(sensorManager).registerListener( + eq(controller), + eq(sensor), + eq(SensorManager.SENSOR_DELAY_GAME), + ) + } + + @Test + fun onDetachedFromWindow_unregistersGyroListener() { + val listener = RecordingListener() + val (controller, sensorManager) = createControllerWithGyroSensor(listener) + val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)!! + + controller.setMode(InputControlsView.GYRO_MODE_LEFT_STICK) + controller.setHasProfile(true) + controller.onAttachedToWindow() + + verify(sensorManager).registerListener( + eq(controller), + eq(sensor), + eq(SensorManager.SENSOR_DELAY_GAME), + ) + + controller.onDetachedFromWindow() + + verify(sensorManager).unregisterListener( + eq(controller), + eq(sensor), + ) + } +}