diff --git a/src/Ryujinx.Input.SDL2/SDL2Gamepad.cs b/src/Ryujinx.Input.SDL2/SDL2Gamepad.cs index 12bfab4bb..7e8bd4167 100644 --- a/src/Ryujinx.Input.SDL2/SDL2Gamepad.cs +++ b/src/Ryujinx.Input.SDL2/SDL2Gamepad.cs @@ -253,11 +253,23 @@ namespace Ryujinx.Input.SDL2 return IGamepad.GetStateSnapshot(this); } + private static bool hotButtonMinus = false; + private static bool hotExit = false; + + public bool SpecialExit() + { + if (hotButtonMinus) + { + hotButtonMinus = false; + return hotExit; + } + return hotExit = false; + } + public GamepadStateSnapshot GetMappedStateSnapshot() { GamepadStateSnapshot rawState = GetStateSnapshot(); GamepadStateSnapshot result = default; - lock (_userMappingLock) { if (_buttonsUserMapping.Count == 0) @@ -270,6 +282,28 @@ namespace Ryujinx.Input.SDL2 if (!entry.IsValid) continue; + if (GamepadButtonInputId.Minus == entry.To) + { + if (rawState.IsPressed(entry.From) && !hotButtonMinus) + { + hotButtonMinus = true; + } + else if (!result.IsPressed(entry.From) && hotButtonMinus) + { + hotButtonMinus = false; + } + } + + if (GamepadButtonInputId.Plus == entry.To) + { + if (rawState.IsPressed(entry.To) && hotButtonMinus) + { + + hotExit = true; + } + + } + // Do not touch state of button already pressed if (!result.IsPressed(entry.To)) { @@ -376,5 +410,7 @@ namespace Ryujinx.Input.SDL2 return SDL_GameControllerGetButton(_gamepadHandle, _buttonsDriverMapping[(int)inputId]) == 1; } + + } } diff --git a/src/Ryujinx.Input.SDL2/SDL2GamepadDriver.cs b/src/Ryujinx.Input.SDL2/SDL2GamepadDriver.cs index c580e4e7d..2c73d1604 100644 --- a/src/Ryujinx.Input.SDL2/SDL2GamepadDriver.cs +++ b/src/Ryujinx.Input.SDL2/SDL2GamepadDriver.cs @@ -1,3 +1,4 @@ +using Ryujinx.Common.Logging; using Ryujinx.SDL2.Common; using System; using System.Collections.Generic; @@ -36,6 +37,7 @@ namespace Ryujinx.Input.SDL2 SDL2Driver.Instance.Initialize(); SDL2Driver.Instance.OnJoyStickConnected += HandleJoyStickConnected; SDL2Driver.Instance.OnJoystickDisconnected += HandleJoyStickDisconnected; + SDL2Driver.Instance.OnJoyBatteryUpdated += HandleJoyBatteryUpdated; // Add already connected gamepads int numJoysticks = SDL_NumJoysticks(); @@ -83,19 +85,30 @@ namespace Ryujinx.Input.SDL2 private void HandleJoyStickDisconnected(int joystickInstanceId) { + bool joyConPairDisconnected = false; if (!_gamepadsInstanceIdsMapping.Remove(joystickInstanceId, out string id)) return; lock (_lock) { _gamepadsIds.Remove(id); + if (!SDL2JoyConPair.IsCombinable(_gamepadsIds)) + { + _gamepadsIds.Remove(SDL2JoyConPair.Id); + joyConPairDisconnected = true; + } } OnGamepadDisconnected?.Invoke(id); + if (joyConPairDisconnected) + { + OnGamepadDisconnected?.Invoke(SDL2JoyConPair.Id); + } } private void HandleJoyStickConnected(int joystickDeviceId, int joystickInstanceId) { + bool joyConPairConnected = false; if (SDL_IsGameController(joystickDeviceId) == SDL_bool.SDL_TRUE) { if (_gamepadsInstanceIdsMapping.ContainsKey(joystickInstanceId)) @@ -120,13 +133,29 @@ namespace Ryujinx.Input.SDL2 _gamepadsIds.Insert(joystickDeviceId, id); else _gamepadsIds.Add(id); + if (SDL2JoyConPair.IsCombinable(_gamepadsIds)) + { + _gamepadsIds.Remove(SDL2JoyConPair.Id); + _gamepadsIds.Add(SDL2JoyConPair.Id); + joyConPairConnected = true; + } } OnGamepadConnected?.Invoke(id); + if (joyConPairConnected) + { + OnGamepadConnected?.Invoke(SDL2JoyConPair.Id); + } } } } + private void HandleJoyBatteryUpdated(int joystickDeviceId, SDL_JoystickPowerLevel powerLevel) + { + Logger.Info?.Print(LogClass.Hid, + $"{SDL_GameControllerNameForIndex(joystickDeviceId)} power level: {powerLevel}"); + } + protected virtual void Dispose(bool disposing) { if (disposing) @@ -157,6 +186,14 @@ namespace Ryujinx.Input.SDL2 public IGamepad GetGamepad(string id) { + if (id == SDL2JoyConPair.Id) + { + lock (_lock) + { + return SDL2JoyConPair.GetGamepad(_gamepadsIds); + } + } + int joystickIndex = GetJoystickIndexByGamepadId(id); if (joystickIndex == -1) @@ -165,12 +202,16 @@ namespace Ryujinx.Input.SDL2 } nint gamepadHandle = SDL_GameControllerOpen(joystickIndex); - if (gamepadHandle == nint.Zero) { return null; } + if (SDL_GameControllerName(gamepadHandle).StartsWith(SDL2JoyCon.Prefix)) + { + return new SDL2JoyCon(gamepadHandle, id); + } + return new SDL2Gamepad(gamepadHandle, id); } } diff --git a/src/Ryujinx.Input.SDL2/SDL2JoyCon.cs b/src/Ryujinx.Input.SDL2/SDL2JoyCon.cs new file mode 100644 index 000000000..027785b96 --- /dev/null +++ b/src/Ryujinx.Input.SDL2/SDL2JoyCon.cs @@ -0,0 +1,409 @@ +using Ryujinx.Common.Configuration.Hid; +using Ryujinx.Common.Configuration.Hid.Controller; +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Threading; +using static SDL2.SDL; + +namespace Ryujinx.Input.SDL2 +{ + internal class SDL2JoyCon : IGamepad + { + private bool HasConfiguration => _configuration != null; + + private readonly record struct ButtonMappingEntry(GamepadButtonInputId To, GamepadButtonInputId From) + { + public bool IsValid => To is not GamepadButtonInputId.Unbound && From is not GamepadButtonInputId.Unbound; + } + + private StandardControllerInputConfig _configuration; + + private readonly Dictionary _leftButtonsDriverMapping = new() + { + { GamepadButtonInputId.LeftStick , SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_LEFTSTICK }, + {GamepadButtonInputId.DpadUp ,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_Y}, + {GamepadButtonInputId.DpadDown ,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_A}, + {GamepadButtonInputId.DpadLeft ,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_B}, + {GamepadButtonInputId.DpadRight ,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_X}, + {GamepadButtonInputId.Minus ,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_START}, + {GamepadButtonInputId.LeftShoulder,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_PADDLE2}, + {GamepadButtonInputId.LeftTrigger,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_PADDLE4}, + {GamepadButtonInputId.SingleRightTrigger0,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_RIGHTSHOULDER}, + {GamepadButtonInputId.SingleLeftTrigger0,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_LEFTSHOULDER}, + }; + private readonly Dictionary _rightButtonsDriverMapping = new() + { + {GamepadButtonInputId.RightStick,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_LEFTSTICK}, + {GamepadButtonInputId.A,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_B}, + {GamepadButtonInputId.B,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_Y}, + {GamepadButtonInputId.X,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_A}, + {GamepadButtonInputId.Y,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_X}, + {GamepadButtonInputId.Plus,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_START}, + {GamepadButtonInputId.RightShoulder,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_PADDLE1}, + {GamepadButtonInputId.RightTrigger,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_PADDLE3}, + {GamepadButtonInputId.SingleRightTrigger1,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_RIGHTSHOULDER}, + {GamepadButtonInputId.SingleLeftTrigger1,SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_LEFTSHOULDER} + }; + + private readonly Dictionary _buttonsDriverMapping; + private readonly Lock _userMappingLock = new(); + + private readonly List _buttonsUserMapping; + + private readonly StickInputId[] _stickUserMapping = new StickInputId[(int)StickInputId.Count] + { + StickInputId.Unbound, StickInputId.Left, StickInputId.Right, + }; + + public GamepadFeaturesFlag Features { get; } + + private nint _gamepadHandle; + + private enum JoyConType + { + Left, Right + } + + public const string Prefix = "Nintendo Switch Joy-Con"; + public const string LeftName = "Nintendo Switch Joy-Con (L)"; + public const string RightName = "Nintendo Switch Joy-Con (R)"; + + private readonly JoyConType _joyConType; + + public SDL2JoyCon(nint gamepadHandle, string driverId) + { + _gamepadHandle = gamepadHandle; + _buttonsUserMapping = new List(10); + + Name = SDL_GameControllerName(_gamepadHandle); + Id = driverId; + Features = GetFeaturesFlag(); + + // Enable motion tracking + if (Features.HasFlag(GamepadFeaturesFlag.Motion)) + { + if (SDL_GameControllerSetSensorEnabled(_gamepadHandle, SDL_SensorType.SDL_SENSOR_ACCEL, + SDL_bool.SDL_TRUE) != 0) + { + Logger.Error?.Print(LogClass.Hid, + $"Could not enable data reporting for SensorType {SDL_SensorType.SDL_SENSOR_ACCEL}."); + } + + if (SDL_GameControllerSetSensorEnabled(_gamepadHandle, SDL_SensorType.SDL_SENSOR_GYRO, + SDL_bool.SDL_TRUE) != 0) + { + Logger.Error?.Print(LogClass.Hid, + $"Could not enable data reporting for SensorType {SDL_SensorType.SDL_SENSOR_GYRO}."); + } + } + + switch (Name) + { + case LeftName: + { + _buttonsDriverMapping = _leftButtonsDriverMapping; + _joyConType = JoyConType.Left; + break; + } + case RightName: + { + _buttonsDriverMapping = _rightButtonsDriverMapping; + _joyConType = JoyConType.Right; + break; + } + } + } + + private GamepadFeaturesFlag GetFeaturesFlag() + { + GamepadFeaturesFlag result = GamepadFeaturesFlag.None; + + if (SDL_GameControllerHasSensor(_gamepadHandle, SDL_SensorType.SDL_SENSOR_ACCEL) == SDL_bool.SDL_TRUE && + SDL_GameControllerHasSensor(_gamepadHandle, SDL_SensorType.SDL_SENSOR_GYRO) == SDL_bool.SDL_TRUE) + { + result |= GamepadFeaturesFlag.Motion; + } + + int error = SDL_GameControllerRumble(_gamepadHandle, 0, 0, 100); + + if (error == 0) + { + result |= GamepadFeaturesFlag.Rumble; + } + + return result; + } + + public string Id { get; } + public string Name { get; } + public bool IsConnected => SDL_GameControllerGetAttached(_gamepadHandle) == SDL_bool.SDL_TRUE; + + protected virtual void Dispose(bool disposing) + { + if (disposing && _gamepadHandle != nint.Zero) + { + SDL_GameControllerClose(_gamepadHandle); + + _gamepadHandle = nint.Zero; + } + } + + public void Dispose() + { + Dispose(true); + } + + + public void SetTriggerThreshold(float triggerThreshold) + { + + } + + public void Rumble(float lowFrequency, float highFrequency, uint durationMs) + { + if (!Features.HasFlag(GamepadFeaturesFlag.Rumble)) + return; + + ushort lowFrequencyRaw = (ushort)(lowFrequency * ushort.MaxValue); + ushort highFrequencyRaw = (ushort)(highFrequency * ushort.MaxValue); + + if (durationMs == uint.MaxValue) + { + if (SDL_GameControllerRumble(_gamepadHandle, lowFrequencyRaw, highFrequencyRaw, SDL_HAPTIC_INFINITY) != + 0) + Logger.Error?.Print(LogClass.Hid, "Rumble is not supported on this game controller."); + } + else if (durationMs > SDL_HAPTIC_INFINITY) + { + Logger.Error?.Print(LogClass.Hid, $"Unsupported rumble duration {durationMs}"); + } + else + { + if (SDL_GameControllerRumble(_gamepadHandle, lowFrequencyRaw, highFrequencyRaw, durationMs) != 0) + Logger.Error?.Print(LogClass.Hid, "Rumble is not supported on this game controller."); + } + } + + public Vector3 GetMotionData(MotionInputId inputId) + { + SDL_SensorType sensorType = inputId switch + { + MotionInputId.Accelerometer => SDL_SensorType.SDL_SENSOR_ACCEL, + MotionInputId.Gyroscope => SDL_SensorType.SDL_SENSOR_GYRO, + _ => SDL_SensorType.SDL_SENSOR_INVALID + }; + + if (!Features.HasFlag(GamepadFeaturesFlag.Motion) || sensorType is SDL_SensorType.SDL_SENSOR_INVALID) + return Vector3.Zero; + + const int ElementCount = 3; + + unsafe + { + float* values = stackalloc float[ElementCount]; + + int result = SDL_GameControllerGetSensorData(_gamepadHandle, sensorType, (nint)values, ElementCount); + + if (result != 0) + return Vector3.Zero; + + Vector3 value = _joyConType switch + { + JoyConType.Left => new Vector3(-values[2], values[1], values[0]), + JoyConType.Right => new Vector3(values[2], values[1], -values[0]) + }; + + return inputId switch + { + MotionInputId.Gyroscope => RadToDegree(value), + MotionInputId.Accelerometer => GsToMs2(value), + _ => value + }; + } + } + + private static Vector3 RadToDegree(Vector3 rad) => rad * (180 / MathF.PI); + + private static Vector3 GsToMs2(Vector3 gs) => gs / SDL_STANDARD_GRAVITY; + + public void SetConfiguration(InputConfig configuration) + { + lock (_userMappingLock) + { + _configuration = (StandardControllerInputConfig)configuration; + + _buttonsUserMapping.Clear(); + + // First update sticks + _stickUserMapping[(int)StickInputId.Left] = (StickInputId)_configuration.LeftJoyconStick.Joystick; + _stickUserMapping[(int)StickInputId.Right] = (StickInputId)_configuration.RightJoyconStick.Joystick; + + + switch (_joyConType) + { + case JoyConType.Left: + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.LeftStick, (GamepadButtonInputId)_configuration.LeftJoyconStick.StickButton)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.DpadUp, (GamepadButtonInputId)_configuration.LeftJoycon.DpadUp)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.DpadDown, (GamepadButtonInputId)_configuration.LeftJoycon.DpadDown)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.DpadLeft, (GamepadButtonInputId)_configuration.LeftJoycon.DpadLeft)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.DpadRight, (GamepadButtonInputId)_configuration.LeftJoycon.DpadRight)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.Minus, (GamepadButtonInputId)_configuration.LeftJoycon.ButtonMinus)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.LeftShoulder, (GamepadButtonInputId)_configuration.LeftJoycon.ButtonL)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.LeftTrigger, (GamepadButtonInputId)_configuration.LeftJoycon.ButtonZl)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.SingleRightTrigger0, (GamepadButtonInputId)_configuration.LeftJoycon.ButtonSr)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.SingleLeftTrigger0, (GamepadButtonInputId)_configuration.LeftJoycon.ButtonSl)); + break; + case JoyConType.Right: + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.RightStick, (GamepadButtonInputId)_configuration.RightJoyconStick.StickButton)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.A, (GamepadButtonInputId)_configuration.RightJoycon.ButtonA)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.B, (GamepadButtonInputId)_configuration.RightJoycon.ButtonB)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.X, (GamepadButtonInputId)_configuration.RightJoycon.ButtonX)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.Y, (GamepadButtonInputId)_configuration.RightJoycon.ButtonY)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.Plus, (GamepadButtonInputId)_configuration.RightJoycon.ButtonPlus)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.RightShoulder, (GamepadButtonInputId)_configuration.RightJoycon.ButtonR)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.RightTrigger, (GamepadButtonInputId)_configuration.RightJoycon.ButtonZr)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.SingleRightTrigger1, (GamepadButtonInputId)_configuration.RightJoycon.ButtonSr)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.SingleLeftTrigger1, (GamepadButtonInputId)_configuration.RightJoycon.ButtonSl)); + break; + default: + throw new ArgumentOutOfRangeException(); + } + + SetTriggerThreshold(_configuration.TriggerThreshold); + } + } + + public GamepadStateSnapshot GetStateSnapshot() + { + return IGamepad.GetStateSnapshot(this); + } + + public GamepadStateSnapshot GetMappedStateSnapshot() + { + GamepadStateSnapshot rawState = GetStateSnapshot(); + GamepadStateSnapshot result = default; + + lock (_userMappingLock) + { + if (_buttonsUserMapping.Count == 0) + return rawState; + + + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator + foreach (ButtonMappingEntry entry in _buttonsUserMapping) + { + if (!entry.IsValid) + continue; + + // Do not touch state of button already pressed + if (!result.IsPressed(entry.To)) + { + result.SetPressed(entry.To, rawState.IsPressed(entry.From)); + } + } + + (float leftStickX, float leftStickY) = rawState.GetStick(_stickUserMapping[(int)StickInputId.Left]); + (float rightStickX, float rightStickY) = rawState.GetStick(_stickUserMapping[(int)StickInputId.Right]); + + result.SetStick(StickInputId.Left, leftStickX, leftStickY); + result.SetStick(StickInputId.Right, rightStickX, rightStickY); + } + + return result; + } + + + private static float ConvertRawStickValue(short value) + { + const float ConvertRate = 1.0f / (short.MaxValue + 0.5f); + + return value * ConvertRate; + } + + private JoyconConfigControllerStick + GetLogicalJoyStickConfig(StickInputId inputId) + { + switch (inputId) + { + case StickInputId.Left: + if (_configuration.RightJoyconStick.Joystick == + Common.Configuration.Hid.Controller.StickInputId.Left) + return _configuration.RightJoyconStick; + else + return _configuration.LeftJoyconStick; + case StickInputId.Right: + if (_configuration.LeftJoyconStick.Joystick == + Common.Configuration.Hid.Controller.StickInputId.Right) + return _configuration.LeftJoyconStick; + else + return _configuration.RightJoyconStick; + } + + return null; + } + + + public (float, float) GetStick(StickInputId inputId) + { + if (inputId == StickInputId.Unbound) + return (0.0f, 0.0f); + + if (inputId == StickInputId.Left && _joyConType == JoyConType.Right || inputId == StickInputId.Right && _joyConType == JoyConType.Left) + { + return (0.0f, 0.0f); + } + + (short stickX, short stickY) = GetStickXY(); + + float resultX = ConvertRawStickValue(stickX); + float resultY = -ConvertRawStickValue(stickY); + + if (HasConfiguration) + { + var joyconStickConfig = GetLogicalJoyStickConfig(inputId); + + if (joyconStickConfig != null) + { + if (joyconStickConfig.InvertStickX) + resultX = -resultX; + + if (joyconStickConfig.InvertStickY) + resultY = -resultY; + + if (joyconStickConfig.Rotate90CW) + { + float temp = resultX; + resultX = resultY; + resultY = -temp; + } + } + } + + return inputId switch + { + StickInputId.Left when _joyConType == JoyConType.Left => (resultY, -resultX), + StickInputId.Right when _joyConType == JoyConType.Right => (-resultY, resultX), + _ => (0.0f, 0.0f) + }; + } + + private (short, short) GetStickXY() + { + return ( + SDL_GameControllerGetAxis(_gamepadHandle, SDL_GameControllerAxis.SDL_CONTROLLER_AXIS_LEFTX), + SDL_GameControllerGetAxis(_gamepadHandle, SDL_GameControllerAxis.SDL_CONTROLLER_AXIS_LEFTY)); + } + + public bool IsPressed(GamepadButtonInputId inputId) + { + if (!_buttonsDriverMapping.TryGetValue(inputId, out var button)) + { + return false; + } + + return SDL_GameControllerGetButton(_gamepadHandle, button) == 1; + } + } +} diff --git a/src/Ryujinx.Input.SDL2/SDL2JoyConPair.cs b/src/Ryujinx.Input.SDL2/SDL2JoyConPair.cs new file mode 100644 index 000000000..4e0c01ef6 --- /dev/null +++ b/src/Ryujinx.Input.SDL2/SDL2JoyConPair.cs @@ -0,0 +1,142 @@ +using Ryujinx.Common.Configuration.Hid; +using Ryujinx.Common.Configuration.Hid.Controller; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using static SDL2.SDL; + +namespace Ryujinx.Input.SDL2 +{ + internal class SDL2JoyConPair(IGamepad left, IGamepad right) : IGamepad + { + private StandardControllerInputConfig _configuration; + + private readonly StickInputId[] _stickUserMapping = + [ + StickInputId.Unbound, + StickInputId.Left, + StickInputId.Right + ]; + + public GamepadFeaturesFlag Features => (left?.Features ?? GamepadFeaturesFlag.None) | + (right?.Features ?? GamepadFeaturesFlag.None); + + public const string Id = "JoyConPair"; + string IGamepad.Id => Id; + + public string Name => "* Nintendo Switch Joy-Con (L/R)"; + public bool IsConnected => left is { IsConnected: true } && right is { IsConnected: true }; + + public void Dispose() + { + left?.Dispose(); + right?.Dispose(); + } + + public GamepadStateSnapshot GetMappedStateSnapshot() + { + return GetStateSnapshot(); + } + + public Vector3 GetMotionData(MotionInputId inputId) + { + return inputId switch + { + MotionInputId.Accelerometer or + MotionInputId.Gyroscope => left.GetMotionData(inputId), + MotionInputId.SecondAccelerometer => right.GetMotionData(MotionInputId.Accelerometer), + MotionInputId.SecondGyroscope => right.GetMotionData(MotionInputId.Gyroscope), + _ => Vector3.Zero + }; + } + + public GamepadStateSnapshot GetStateSnapshot() + { + return IGamepad.GetStateSnapshot(this); + } + + public (float, float) GetStick(StickInputId inputId) + { + return inputId switch + { + StickInputId.Left => left.GetStick(StickInputId.Left), + StickInputId.Right => right.GetStick(StickInputId.Right), + _ => (0, 0) + }; + } + + public bool IsPressed(GamepadButtonInputId inputId) + { + return left.IsPressed(inputId) || right.IsPressed(inputId); + } + + public void Rumble(float lowFrequency, float highFrequency, uint durationMs) + { + if (lowFrequency != 0) + { + right.Rumble(lowFrequency, lowFrequency, durationMs); + } + + if (highFrequency != 0) + { + left.Rumble(highFrequency, highFrequency, durationMs); + } + + if (lowFrequency == 0 && highFrequency == 0) + { + left.Rumble(0, 0, durationMs); + right.Rumble(0, 0, durationMs); + } + } + + public void SetConfiguration(InputConfig configuration) + { + left.SetConfiguration(configuration); + right.SetConfiguration(configuration); + } + + public void SetTriggerThreshold(float triggerThreshold) + { + left.SetTriggerThreshold(triggerThreshold); + right.SetTriggerThreshold(triggerThreshold); + } + + public static bool IsCombinable(List gamepadsIds) + { + (int leftIndex, int rightIndex) = DetectJoyConPair(gamepadsIds); + return leftIndex >= 0 && rightIndex >= 0; + } + + private static (int leftIndex, int rightIndex) DetectJoyConPair(List gamepadsIds) + { + var gamepadNames = gamepadsIds.Where(gamepadId => gamepadId != Id) + .Select((_, index) => SDL_GameControllerNameForIndex(index)).ToList(); + int leftIndex = gamepadNames.IndexOf(SDL2JoyCon.LeftName); + int rightIndex = gamepadNames.IndexOf(SDL2JoyCon.RightName); + + return (leftIndex, rightIndex); + } + + public static IGamepad GetGamepad(List gamepadsIds) + { + (int leftIndex, int rightIndex) = DetectJoyConPair(gamepadsIds); + if (leftIndex == -1 || rightIndex == -1) + { + return null; + } + + nint leftGamepadHandle = SDL_GameControllerOpen(leftIndex); + nint rightGamepadHandle = SDL_GameControllerOpen(rightIndex); + + if (leftGamepadHandle == nint.Zero || rightGamepadHandle == nint.Zero) + { + return null; + } + + + return new SDL2JoyConPair(new SDL2JoyCon(leftGamepadHandle, gamepadsIds[leftIndex]), + new SDL2JoyCon(rightGamepadHandle, gamepadsIds[rightIndex])); + } + } +} diff --git a/src/Ryujinx.Input.SDL2/SDL2Keyboard.cs b/src/Ryujinx.Input.SDL2/SDL2Keyboard.cs index 8d6a30d11..ab01a9bbc 100644 --- a/src/Ryujinx.Input.SDL2/SDL2Keyboard.cs +++ b/src/Ryujinx.Input.SDL2/SDL2Keyboard.cs @@ -329,6 +329,11 @@ namespace Ryujinx.Input.SDL2 return result; } + public bool SpecialExit() + { + return false; + } + public GamepadStateSnapshot GetStateSnapshot() { throw new NotSupportedException(); diff --git a/src/Ryujinx.Input.SDL2/SDL2Mouse.cs b/src/Ryujinx.Input.SDL2/SDL2Mouse.cs index 37b356b76..da0622db3 100644 --- a/src/Ryujinx.Input.SDL2/SDL2Mouse.cs +++ b/src/Ryujinx.Input.SDL2/SDL2Mouse.cs @@ -25,6 +25,10 @@ namespace Ryujinx.Input.SDL2 { _driver = driver; } + public bool SpecialExit() + { + return false; + } public Vector2 GetPosition() { diff --git a/src/Ryujinx.Input/HLE/NpadController.cs b/src/Ryujinx.Input/HLE/NpadController.cs index 380745283..18601f6c7 100644 --- a/src/Ryujinx.Input/HLE/NpadController.cs +++ b/src/Ryujinx.Input/HLE/NpadController.cs @@ -3,6 +3,7 @@ using Ryujinx.Common.Configuration.Hid; using Ryujinx.Common.Configuration.Hid.Controller; using Ryujinx.Common.Configuration.Hid.Controller.Motion; using Ryujinx.Common.Logging; +using Ryujinx.Graphics.Gpu; using Ryujinx.HLE.HOS.Services.Hid; using System; using System.Collections.Concurrent; @@ -266,6 +267,7 @@ namespace Ryujinx.Input.HLE if (motionConfig.MotionBackend != MotionInputBackendType.CemuHook) { _leftMotionInput = new MotionInput(); + _rightMotionInput = new MotionInput(); } else { @@ -273,15 +275,21 @@ namespace Ryujinx.Input.HLE } } - public void Update() + public bool Update() { + // _gamepad may be altered by other threads var gamepad = _gamepad; - + if (gamepad != null && GamepadDriver != null) { State = gamepad.GetMappedStateSnapshot(); + if (gamepad.SpecialExit()) + { + return true; + } + if (_config is StandardControllerInputConfig controllerConfig && controllerConfig.Motion.EnableMotion) { if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.GamepadDriver) @@ -298,7 +306,20 @@ namespace Ryujinx.Input.HLE if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair) { - _rightMotionInput = _leftMotionInput; + if (gamepad.Id== "JoyConPair") + { + Vector3 rightAccelerometer = gamepad.GetMotionData(MotionInputId.SecondAccelerometer); + Vector3 rightGyroscope = gamepad.GetMotionData(MotionInputId.SecondGyroscope); + + rightAccelerometer = new Vector3(rightAccelerometer.X, -rightAccelerometer.Z, rightAccelerometer.Y); + rightGyroscope = new Vector3(rightGyroscope.X, -rightGyroscope.Z, rightGyroscope.Y); + + _rightMotionInput.Update(rightAccelerometer, rightGyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone); + } + else + { + _rightMotionInput = _leftMotionInput; + } } } } @@ -333,7 +354,9 @@ namespace Ryujinx.Input.HLE // Reset states State = default; _leftMotionInput = null; + _rightMotionInput = null; } + return false; } public GamepadInput GetHLEInputState() diff --git a/src/Ryujinx.Input/HLE/NpadManager.cs b/src/Ryujinx.Input/HLE/NpadManager.cs index 08f222a91..4ebb8401d 100644 --- a/src/Ryujinx.Input/HLE/NpadManager.cs +++ b/src/Ryujinx.Input/HLE/NpadManager.cs @@ -200,8 +200,10 @@ namespace Ryujinx.Input.HLE ReloadConfiguration(inputConfig, enableKeyboard, enableMouse); } - public void Update(float aspectRatio = 1) + public bool Update(float aspectRatio = 1) { + bool specialExit = false; + lock (_lock) { List hleInputStates = new(); @@ -225,9 +227,10 @@ namespace Ryujinx.Input.HLE DriverConfigurationUpdate(ref controller, inputConfig); controller.UpdateUserConfiguration(inputConfig); - controller.Update(); - controller.UpdateRumble(_device.Hid.Npads.GetRumbleQueue(playerIndex)); + specialExit = controller.Update(); //hotkey press check + controller.UpdateRumble(_device.Hid.Npads.GetRumbleQueue(playerIndex)); + inputState = controller.GetHLEInputState(); inputState.Buttons |= _device.Hid.UpdateStickButtons(inputState.LStick, inputState.RStick); @@ -315,6 +318,8 @@ namespace Ryujinx.Input.HLE _device.TamperMachine.UpdateInput(hleInputStates); } + + return specialExit; } internal InputConfig GetPlayerInputConfigByIndex(int index) diff --git a/src/Ryujinx.Input/IGamepad.cs b/src/Ryujinx.Input/IGamepad.cs index 3853f2819..f52703e19 100644 --- a/src/Ryujinx.Input/IGamepad.cs +++ b/src/Ryujinx.Input/IGamepad.cs @@ -79,6 +79,12 @@ namespace Ryujinx.Input /// A remapped snaphost of the state of the gamepad. GamepadStateSnapshot GetMappedStateSnapshot(); + /// + /// Gets the state if the minus and plus buttons were pressed on the gamepad. + /// + /// returns true if the buttons were pressed. + bool SpecialExit(); + /// /// Get a snaphost of the state of the gamepad. /// diff --git a/src/Ryujinx.Input/MotionInputId.cs b/src/Ryujinx.Input/MotionInputId.cs index 8aeb043a9..c3768da96 100644 --- a/src/Ryujinx.Input/MotionInputId.cs +++ b/src/Ryujinx.Input/MotionInputId.cs @@ -21,5 +21,17 @@ namespace Ryujinx.Input /// /// Values are in degrees Gyroscope, + + /// + /// Second accelerometer. + /// + /// Values are in m/s^2 + SecondAccelerometer, + + /// + /// Second gyroscope. + /// + /// Values are in degrees + SecondGyroscope } } diff --git a/src/Ryujinx.SDL2.Common/SDL2Driver.cs b/src/Ryujinx.SDL2.Common/SDL2Driver.cs index 851c07867..d41cbe945 100644 --- a/src/Ryujinx.SDL2.Common/SDL2Driver.cs +++ b/src/Ryujinx.SDL2.Common/SDL2Driver.cs @@ -25,14 +25,17 @@ namespace Ryujinx.SDL2.Common public static Action MainThreadDispatcher { get; set; } - private const uint SdlInitFlags = SDL_INIT_EVENTS | SDL_INIT_GAMECONTROLLER | SDL_INIT_JOYSTICK | SDL_INIT_AUDIO | SDL_INIT_VIDEO; + private const uint SdlInitFlags = SDL_INIT_EVENTS | SDL_INIT_GAMECONTROLLER | SDL_INIT_JOYSTICK | + SDL_INIT_AUDIO | SDL_INIT_VIDEO; private bool _isRunning; private uint _refereceCount; private Thread _worker; + private const uint SDL_JOYBATTERYUPDATED = 1543; public event Action OnJoyStickConnected; public event Action OnJoystickDisconnected; + public event Action OnJoyBatteryUpdated; private ConcurrentDictionary> _registeredWindowHandlers; @@ -78,12 +81,14 @@ namespace Ryujinx.SDL2.Common // First ensure that we only enable joystick events (for connected/disconnected). if (SDL_GameControllerEventState(SDL_IGNORE) != SDL_IGNORE) { - Logger.Error?.PrintMsg(LogClass.Application, "Couldn't change the state of game controller events."); + Logger.Error?.PrintMsg(LogClass.Application, + "Couldn't change the state of game controller events."); } if (SDL_JoystickEventState(SDL_ENABLE) < 0) { - Logger.Error?.PrintMsg(LogClass.Application, $"Failed to enable joystick event polling: {SDL_GetError()}"); + Logger.Error?.PrintMsg(LogClass.Application, + $"Failed to enable joystick event polling: {SDL_GetError()}"); } // Disable all joysticks information, we don't need them no need to flood the event queue for that. @@ -143,7 +148,12 @@ namespace Ryujinx.SDL2.Common OnJoystickDisconnected?.Invoke(evnt.cbutton.which); } - else if (evnt.type is SDL_EventType.SDL_WINDOWEVENT or SDL_EventType.SDL_MOUSEBUTTONDOWN or SDL_EventType.SDL_MOUSEBUTTONUP) + else if ((uint)evnt.type == SDL_JOYBATTERYUPDATED) + { + OnJoyBatteryUpdated?.Invoke(evnt.cbutton.which, (SDL_JoystickPowerLevel)evnt.user.code); + } + else if (evnt.type is SDL_EventType.SDL_WINDOWEVENT or SDL_EventType.SDL_MOUSEBUTTONDOWN + or SDL_EventType.SDL_MOUSEBUTTONUP) { if (_registeredWindowHandlers.TryGetValue(evnt.window.windowID, out Action handler)) { diff --git a/src/Ryujinx/AppHost.cs b/src/Ryujinx/AppHost.cs index a35a79e86..c94e869d5 100644 --- a/src/Ryujinx/AppHost.cs +++ b/src/Ryujinx/AppHost.cs @@ -18,6 +18,7 @@ using Ryujinx.Ava.UI.Helpers; using Ryujinx.Ava.UI.Models; using Ryujinx.Ava.UI.Renderer; using Ryujinx.Ava.UI.ViewModels; +using Ryujinx.Ava.UI.Views.Main; using Ryujinx.Ava.UI.Windows; using Ryujinx.Ava.Utilities; using Ryujinx.Ava.Utilities.AppLibrary; @@ -70,6 +71,7 @@ namespace Ryujinx.Ava private const float MaxResolutionScale = 4.0f; // Max resolution hotkeys can scale to before wrapping. private const int TargetFps = 60; private const float VolumeDelta = 0.05f; + static bool SpecialExit = false; private static readonly Cursor _invisibleCursor = new(StandardCursorType.None); private readonly nint _invisibleCursorWin; @@ -96,6 +98,7 @@ namespace Ryujinx.Ava private bool _isCursorInRenderer = true; private bool _ignoreCursorState = false; + private enum CursorStates { CursorIsHidden, @@ -503,10 +506,15 @@ namespace Ryujinx.Ava _viewModel.Volume = ConfigurationState.Instance.System.AudioVolume.Value; MainLoop(); - + Exit(); } + public bool IsSpecialExit() + { + return SpecialExit; + } + private void UpdateIgnoreMissingServicesState(object sender, ReactiveEventArgs args) { if (Device != null) @@ -589,6 +597,7 @@ namespace Ryujinx.Ava _isStopped = true; Stop(); + } public void DisposeContext() @@ -1135,6 +1144,7 @@ namespace Ryujinx.Ava string dockedMode = ConfigurationState.Instance.System.EnableDockedMode ? LocaleManager.Instance[LocaleKeys.Docked] : LocaleManager.Instance[LocaleKeys.Handheld]; string vSyncMode = Device.VSyncMode.ToString(); + UpdateShaderCount(); if (GraphicsConfig.ResScale != 1) @@ -1200,7 +1210,17 @@ namespace Ryujinx.Ava return false; } - NpadManager.Update(ConfigurationState.Instance.Graphics.AspectRatio.Value.ToFloat()); + if (NpadManager.Update(ConfigurationState.Instance.Graphics.AspectRatio.Value.ToFloat())) + { + if (ConfigurationState.Instance.Hid.SpecialExitEmulator.Value == 1) + { + SpecialExit = true; // close App + } + if (ConfigurationState.Instance.Hid.SpecialExitEmulator.Value > 0) + { + _isActive = false; //close game + } + } if (_viewModel.IsActive) { @@ -1335,6 +1355,8 @@ namespace Ryujinx.Ava Device.Hid.DebugPad.Update(); + + return true; } diff --git a/src/Ryujinx/Assets/locales.json b/src/Ryujinx/Assets/locales.json index 221c898ea..753752e0d 100644 --- a/src/Ryujinx/Assets/locales.json +++ b/src/Ryujinx/Assets/locales.json @@ -147,6 +147,31 @@ "zh_TW": "滑鼠直接存取" } }, + { + "ID": "SettingsExtraCloseApp", + "Translations": { + "ar_SA": "خروج سريع من التطبيق", + "de_DE": "Schneller Ausstieg aus der Anwendung", + "el_GR": "Γρήγορη έξοδος από την εφαρμογή", + "en_US": "Quick Exit from Application", + "es_ES": "Salida rápida de la aplicación", + "fr_FR": "Sortie rapide de l'application", + "he_IL": "יציאה מהירה מהאפליקציה", + "it_IT": "Uscita rapida dall'applicazione", + "ja_JP": "アプリケーションからの迅速な終了", + "ko_KR": "애플리케이션에서 빠른 종료", + "no_NO": "Rask avslutning av applikasjonen", + "pl_PL": "Szybkie wyjście z aplikacji", + "pt_BR": "Saída rápida do aplicativo", + "ru_RU": "Быстрый выход из приложения", + "sv_SE": "Snabb avslutning från applikationen", + "th_TH": "ออกจากแอปพลิเคชันอย่างรวดเร็ว", + "tr_TR": "Uygulamadan Hızlı Çıkış", + "uk_UA": "Швидкий вихід з програми", + "zh_CN": "快速退出应用程序", + "zh_TW": "快速退出應用程式" + } + }, { "ID": "SettingsTabSystemMemoryManagerMode", "Translations": { @@ -11047,6 +11072,81 @@ "zh_TW": "淺色" } }, + { + "ID": "SettingsTabInputDisableExitHotKey", + "Translations": { + "ar_SA": "الخروج السريع معطل", + "de_DE": "Schneller Ausstieg deaktiviert", + "el_GR": "Η γρήγορη έξοδος είναι απενεργοποιημένη", + "en_US": "Quick exit disabled", + "es_ES": "Salida rápida desactivada", + "fr_FR": "Sortie rapide désactivée", + "he_IL": "יציאה מהירה מושבתת", + "it_IT": "Uscita rapida disabilitata", + "ja_JP": "クイック終了が無効です", + "ko_KR": "빠른 종료 비활성화됨", + "no_NO": "Rask avslutning er deaktivert", + "pl_PL": "Szybkie wyjście wyłączone", + "pt_BR": "Saída rápida desativada", + "ru_RU": "Быстрый выход выключен", + "sv_SE": "Snabb avslutning inaktiverad", + "th_TH": "ปิดใช้งานออกอย่างรวดเร็ว", + "tr_TR": "Hızlı çıkış devre dışı bırakıldı", + "uk_UA": "Швидкий вихід вимкнено", + "zh_CN": "快速退出已禁用", + "zh_TW": "快速退出已停用" + } + }, + { + "ID": "SettingsTabInputHotkeyIsCloseApp", + "Translations": { + "ar_SA": "إغلاق التطبيق بالضغط على الزرين '+' و '-'.", + "de_DE": "App schließen mit den '+' und '-' Tasten.", + "el_GR": "Κλείσιμο της εφαρμογής με τα κουμπιά '+' και '-'.", + "en_US": "Close app by '+' and '-' buttons.", + "es_ES": "Cerrar la aplicación pulsando los botones '+' y '-'.", + "fr_FR": "Fermer l'application avec les boutons '+' et '-'.", + "he_IL": "סגור את האפליקציה בלחיצה על '+' ו-'-'.", + "it_IT": "Chiudi l'app premendo i pulsanti '+' e '-'.", + "ja_JP": "「+」と「-」ボタンを押してアプリを終了します。", + "ko_KR": "'+' 및 '-' 버튼을 눌러 앱을 종료합니다.", + "no_NO": "Lukk appen med '+' og '-' knappene.", + "pl_PL": "Zamknij aplikację przyciskiem '+' i '-'.", + "pt_BR": "Fechar o aplicativo pressionando os botões '+' e '-'.", + "ru_RU": "Закрыть приложение нажатием '+' и '-'.", + "sv_SE": "Stäng appen med '+' och '-' knapparna.", + "th_TH": "ปิดแอปโดยกดปุ่ม '+' และ '-'.", + "tr_TR": "'+' ve '-' düğmelerine basarak uygulamayı kapatın.", + "uk_UA": "Закрити додаток натисканням '+' та '-'.", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "SettingsTabInputHotkeyIsCloseGame", + "Translations": { + "ar_SA": "الخروج من اللعبة بالضغط على الزرين '+' و '-'.", + "de_DE": "Spiel beenden mit den '+' und '-' Tasten.", + "el_GR": "Έξοδος από το παιχνίδι με τα κουμπιά '+' και '-'.", + "en_US": "Exit game by '+' and '-' buttons.", + "es_ES": "Salir del juego pulsando los botones '+' y '-'.", + "fr_FR": "Quitter le jeu avec les boutons '+' et '-'.", + "he_IL": "יציאה מהמשחק בלחיצה על '+' ו-'-'.", + "it_IT": "Esci dal gioco premendo i pulsanti '+' e '-'.", + "ja_JP": "「+」と「-」ボタンを押してゲームを終了します。", + "ko_KR": "'+' 및 '-' 버튼을 눌러 게임을 종료합니다.", + "no_NO": "Avslutt spillet med '+' og '-' knappene.", + "pl_PL": "Wyjście z gry przyciskiem '+' i '-'.", + "pt_BR": "Sair do jogo pressionando os botões '+' e '-'.", + "ru_RU": "Выйти из игры нажатием '+' и '-'.", + "sv_SE": "Avsluta spelet med '+' och '-' knapparna.", + "th_TH": "ออกจากเกมโดยกดปุ่ม '+' และ '-'.", + "tr_TR": "'+' ve '-' düğmelerine basarak oyundan çıkın.", + "uk_UA": "Вийти з гри натисканням '+' та '-'.", + "zh_CN": "", + "zh_TW": "" + } + }, { "ID": "ControllerSettingsConfigureGeneral", "Translations": { @@ -15147,6 +15247,31 @@ "zh_TW": "支援滑鼠直接存取 (HID)。遊戲可將滑鼠作為指向裝置使用。\n\n僅適用於在 Switch 硬體上原生支援滑鼠控制的遊戲,這類遊戲很少。\n\n啟用後,觸控螢幕功能可能無法使用。\n\n如果不確定,請保持關閉狀態。" } }, + { + "ID": "SpecialExitTooltip", + "Translations": { + "ar_SA": "يقوم بتفعيل مفاتيح الاختصار 'زائد' و 'ناقص'.\nاضغط على زرّي زائد وناقص في نفس الوقت لتنفيذ إحدى العمليات:\n\n1) إغلاق التطبيق باستخدام مفاتيح الاختصار.\n2) الخروج من اللعبة دون إغلاق التطبيق.\n\nيعمل فقط مع أذرع التحكم.", + "de_DE": "Aktiviert die Hotkeys 'Plus' und 'Minus'.\nDrücken Sie gleichzeitig die Plus- und Minus-Tasten, um eine der folgenden Aktionen auszuführen:\n\n1) Schließt die Anwendung durch Drücken der Hotkeys.\n2) Beendet das Spiel, ohne die Anwendung zu schließen.\n\nFunktioniert nur mit Gamepads.", + "el_GR": "Ενεργοποιεί τα πλήκτρα πρόσβασης 'συν' και 'πλην'.\nΠατήστε ταυτόχρονα τα κουμπιά συν και πλην για μία από τις ενέργειες:\n\n1) Κλείνει την εφαρμογή πατώντας τα πλήκτρα πρόσβασης.\n2) Εξέρχεται από το παιχνίδι χωρίς να κλείσει η εφαρμογή.\n\nΛειτουργεί μόνο με gamepads.", + "en_US": "Activates the hot keys 'plus' and 'minus'.\nPress buttons plus and minus at the same time to get one of the actions:\n\n1) Closes the application by pressing the hot keys.\n2) Exits the game without closing the application.\n\nWorks only with gamepads.", + "es_ES": "Activa las teclas rápidas 'más' y 'menos'.\nPresiona los botones más y menos al mismo tiempo para realizar una de las siguientes acciones:\n\n1) Cierra la aplicación presionando las teclas rápidas.\n2) Salir del juego sin cerrar la aplicación.\n\nFunciona solo con mandos.", + "fr_FR": "Active les raccourcis 'plus' et 'moins'.\nAppuyez simultanément sur les boutons plus et moins pour effectuer l'une des actions suivantes :\n\n1) Ferme l'application en appuyant sur les raccourcis.\n2) Quitte le jeu sans fermer l'application.\n\nFonctionne uniquement avec les manettes.", + "he_IL": "מפעיל את המקשים הקצרים 'פלוס' ו-'מינוס'.\nלחץ על הכפתורים פלוס ומינוס בו זמנית כדי לבצע אחת מהפעולות:\n\n1) סוגר את היישום באמצעות המקשים הקצרים.\n2) יוצא מהמשחק מבלי לסגור את היישום.\n\nפועל רק עם בקרי משחק.", + "it_IT": "Attiva i tasti rapidi 'più' e 'meno'.\nPremere i pulsanti più e meno contemporaneamente per eseguire una delle seguenti azioni:\n\n1) Chiude l'applicazione premendo i tasti rapidi.\n2) Esce dal gioco senza chiudere l'applicazione.\n\nFunziona solo con i gamepad.", + "ja_JP": "ホットキー「プラス」と「マイナス」を有効化します。\nプラスとマイナスのボタンを同時に押して、次のいずれかの操作を実行します:\n\n1) ホットキーを押すことでアプリを閉じます。\n2) アプリを閉じずにゲームを終了します。\n\nゲームパッドでのみ動作します。", + "ko_KR": "'플러스' 및 '마이너스' 단축키를 활성화합니다.\n플러스 및 마이너스 버튼을 동시에 눌러 다음 작업 중 하나를 수행합니다:\n\n1) 단축키를 눌러 애플리케이션을 닫습니다.\n2) 애플리케이션을 닫지 않고 게임을 종료합니다.\n\n게임패드에서만 작동합니다.", + "no_NO": "Aktiverer hurtigtastene 'pluss' og 'minus'.\nTrykk på knappene pluss og minus samtidig for å utføre en av følgende handlinger:\n\n1) Lukker applikasjonen ved å trykke på hurtigtastene.\n2) Avslutter spillet uten å lukke applikasjonen.\n\nFungerer kun med spillkontroller.", + "pl_PL": "Aktywuje klawisze skrótu 'plus' i 'minus'.\nNaciśnij jednocześnie przyciski plus i minus, aby wykonać jedną z akcji:\n\n1) Zamknij aplikację, naciskając klawisze skrótu.\n2) Wyjdź z gry bez zamykania aplikacji.\n\nDziała tylko z gamepadami.", + "pt_BR": "Ativa as teclas de atalho 'mais' e 'menos'.\nPressione os botões mais e menos ao mesmo tempo para realizar uma das ações:\n\n1) Fecha o aplicativo pressionando as teclas de atalho.\n2) Sai do jogo sem fechar o aplicativo.\n\nFunciona apenas com gamepads.", + "ru_RU": "Активирует горячие клавиши 'плюс' и 'минус'.\nНажмите одновременно кнопки плюс и минус чтобы получить одно из действий:\n\n1) Закрывает приложение по нажатию горячих кнопок.\n2) Выходит из игры без закрытия приложения.\n\nРаботает только с геймпадами.", + "sv_SE": "Aktiverar snabbtangenterna 'plus' och 'minus'.\nTryck samtidigt på plus- och minusknapparna för att utföra en av följande åtgärder:\n\n1) Stänger applikationen med snabbtangenterna.\n2) Avslutar spelet utan att stänga applikationen.\n\nFungerar bara med spelkontroller.", + "th_TH": "เปิดใช้งานปุ่มลัด '+' และ '-'.\nกดปุ่ม '+' และ '-' พร้อมกันเพื่อทำการอย่างใดอย่างหนึ่ง:\n\n1) ปิดแอปพลิเคชันด้วยการกดปุ่มลัด\n2) ออกจากเกมโดยไม่ปิดแอปพลิเคชัน\n\nใช้งานได้เฉพาะกับจอยเกม", + "tr_TR": "'Artı' ve 'eksi' kısayol tuşlarını etkinleştirir.\nArtı ve eksi tuşlarına aynı anda basarak aşağıdaki işlemlerden birini gerçekleştirin:\n\n1) Kısayol tuşlarına basarak uygulamayı kapatır.\n2) Uygulamayı kapatmadan oyundan çıkar.\n\nYalnızca gamepad'lerle çalışır.", + "uk_UA": "Активує гарячі клавіші '+' та '-'.\nНатисніть одночасно кнопки плюс та мінус для виконання однієї з дій:\n\n1) Закриває додаток за допомогою гарячих клавіш.\n2) Виходить із гри без закриття додатка.\n\nПрацює лише з геймпадами.", + "zh_CN": "激活快捷键“加号”和“减号”。\n同时按下加号和减号按钮以执行以下操作之一:\n\n1) 按快捷键关闭应用程序。\n2) 退出游戏而不关闭应用程序。\n\n仅适用于游戏手柄。", + "zh_TW": "啟用快捷鍵「加號」和「減號」。\n同時按下加號和減號按鈕以執行以下其中一項操作:\n\n1) 按快捷鍵關閉應用程式。\n2) 離開遊戲而不關閉應用程式。\n\n僅適用於遊戲手柄。" + } + }, { "ID": "RegionTooltip", "Translations": { diff --git a/src/Ryujinx/Headless/HeadlessRyujinx.cs b/src/Ryujinx/Headless/HeadlessRyujinx.cs index 5730254f7..98636d4ff 100644 --- a/src/Ryujinx/Headless/HeadlessRyujinx.cs +++ b/src/Ryujinx/Headless/HeadlessRyujinx.cs @@ -350,11 +350,11 @@ namespace Ryujinx.Headless { return options.GraphicsBackend switch { - GraphicsBackend.Vulkan => new VulkanWindow(_inputManager, options.LoggingGraphicsDebugLevel, options.AspectRatio, options.EnableMouse, options.HideCursorMode, options.IgnoreControllerApplet), + GraphicsBackend.Vulkan => new VulkanWindow(_inputManager, options.LoggingGraphicsDebugLevel, options.AspectRatio, options.EnableMouse, options.HideCursorMode, options.IgnoreControllerApplet, options.SpecialExit), GraphicsBackend.Metal => OperatingSystem.IsMacOS() ? - new MetalWindow(_inputManager, options.LoggingGraphicsDebugLevel, options.AspectRatio, options.EnableKeyboard, options.HideCursorMode, options.IgnoreControllerApplet) : + new MetalWindow(_inputManager, options.LoggingGraphicsDebugLevel, options.AspectRatio, options.EnableKeyboard, options.HideCursorMode, options.IgnoreControllerApplet, options.SpecialExit) : throw new Exception("Attempted to use Metal renderer on non-macOS platform!"), - _ => new OpenGLWindow(_inputManager, options.LoggingGraphicsDebugLevel, options.AspectRatio, options.EnableMouse, options.HideCursorMode, options.IgnoreControllerApplet) + _ => new OpenGLWindow(_inputManager, options.LoggingGraphicsDebugLevel, options.AspectRatio, options.EnableMouse, options.HideCursorMode, options.IgnoreControllerApplet, options.SpecialExit) }; } diff --git a/src/Ryujinx/Headless/Metal/MetalWindow.cs b/src/Ryujinx/Headless/Metal/MetalWindow.cs index a2693c69d..1ae8f5ee4 100644 --- a/src/Ryujinx/Headless/Metal/MetalWindow.cs +++ b/src/Ryujinx/Headless/Metal/MetalWindow.cs @@ -23,8 +23,9 @@ namespace Ryujinx.Headless AspectRatio aspectRatio, bool enableMouse, HideCursorMode hideCursorMode, - bool ignoreControllerApplet) - : base(inputManager, glLogLevel, aspectRatio, enableMouse, hideCursorMode, ignoreControllerApplet) { } + bool ignoreControllerApplet, + int specialExitEmulator) + : base(inputManager, glLogLevel, aspectRatio, enableMouse, hideCursorMode, ignoreControllerApplet, specialExitEmulator) { } public override SDL_WindowFlags GetWindowFlags() => SDL_WindowFlags.SDL_WINDOW_METAL; diff --git a/src/Ryujinx/Headless/OpenGL/OpenGLWindow.cs b/src/Ryujinx/Headless/OpenGL/OpenGLWindow.cs index c00a0648f..ca4f48861 100644 --- a/src/Ryujinx/Headless/OpenGL/OpenGLWindow.cs +++ b/src/Ryujinx/Headless/OpenGL/OpenGLWindow.cs @@ -118,8 +118,9 @@ namespace Ryujinx.Headless AspectRatio aspectRatio, bool enableMouse, HideCursorMode hideCursorMode, - bool ignoreControllerApplet) - : base(inputManager, glLogLevel, aspectRatio, enableMouse, hideCursorMode, ignoreControllerApplet) + bool ignoreControllerApplet, + int specialExitEmulator) + : base(inputManager, glLogLevel, aspectRatio, enableMouse, hideCursorMode, ignoreControllerApplet, specialExitEmulator) { _glLogLevel = glLogLevel; } diff --git a/src/Ryujinx/Headless/Options.cs b/src/Ryujinx/Headless/Options.cs index 0d7e46285..c575416be 100644 --- a/src/Ryujinx/Headless/Options.cs +++ b/src/Ryujinx/Headless/Options.cs @@ -150,7 +150,10 @@ namespace Ryujinx.Headless if (NeedsOverride(nameof(IgnoreControllerApplet))) IgnoreControllerApplet = configurationState.IgnoreApplet; - + + if (NeedsOverride(nameof(SpecialExit))) + SpecialExit = configurationState.Hid.SpecialExitEmulator; + return; bool NeedsOverride(string argKey) => originalArgs.None(arg => arg.TrimStart('-').EqualsIgnoreCase(OptionName(argKey))); @@ -274,6 +277,9 @@ namespace Ryujinx.Headless [Option("enable-mouse", Required = false, Default = false, HelpText = "Enable or disable mouse support.")] public bool EnableMouse { get; set; } + [Option("enable-press-hotkeys-to-exit", Required = false, Default = 0, HelpText = "press the minus and plus buttons to: 0 -disable, 1 - exit app, 2 - exit game.")] + public int SpecialExit { get; set; } + [Option("hide-cursor", Required = false, Default = HideCursorMode.OnIdle, HelpText = "Change when the cursor gets hidden.")] public HideCursorMode HideCursorMode { get; set; } @@ -414,6 +420,7 @@ namespace Ryujinx.Headless [Option("ignore-controller-applet", Required = false, Default = false, HelpText = "Enable ignoring the controller applet when your game loses connection to your controller.")] public bool IgnoreControllerApplet { get; set; } + // Values [Value(0, MetaName = "input", HelpText = "Input to load.", Required = true)] diff --git a/src/Ryujinx/Headless/Vulkan/VulkanWindow.cs b/src/Ryujinx/Headless/Vulkan/VulkanWindow.cs index 92caad34e..9819bbc9b 100644 --- a/src/Ryujinx/Headless/Vulkan/VulkanWindow.cs +++ b/src/Ryujinx/Headless/Vulkan/VulkanWindow.cs @@ -18,8 +18,9 @@ namespace Ryujinx.Headless AspectRatio aspectRatio, bool enableMouse, HideCursorMode hideCursorMode, - bool ignoreControllerApplet) - : base(inputManager, glLogLevel, aspectRatio, enableMouse, hideCursorMode, ignoreControllerApplet) + bool ignoreControllerApplet, + int specialExitEmulator) + : base(inputManager, glLogLevel, aspectRatio, enableMouse, hideCursorMode, ignoreControllerApplet, specialExitEmulator) { _glLogLevel = glLogLevel; } diff --git a/src/Ryujinx/Headless/WindowBase.cs b/src/Ryujinx/Headless/WindowBase.cs index d89638cc1..4e4d5a4d4 100644 --- a/src/Ryujinx/Headless/WindowBase.cs +++ b/src/Ryujinx/Headless/WindowBase.cs @@ -88,6 +88,7 @@ namespace Ryujinx.Headless private readonly AspectRatio _aspectRatio; private readonly bool _enableMouse; + private readonly int _specialExitEmulator; private readonly bool _ignoreControllerApplet; public WindowBase( @@ -96,7 +97,8 @@ namespace Ryujinx.Headless AspectRatio aspectRatio, bool enableMouse, HideCursorMode hideCursorMode, - bool ignoreControllerApplet) + bool ignoreControllerApplet, + int specialExitEmulator) { MouseDriver = new SDL2MouseDriver(hideCursorMode); _inputManager = inputManager; @@ -112,6 +114,7 @@ namespace Ryujinx.Headless _gpuDoneEvent = new ManualResetEvent(false); _aspectRatio = aspectRatio; _enableMouse = enableMouse; + _specialExitEmulator = specialExitEmulator; _ignoreControllerApplet = ignoreControllerApplet; HostUITheme = new HeadlessHostUiTheme(); diff --git a/src/Ryujinx/Input/AvaloniaKeyboard.cs b/src/Ryujinx/Input/AvaloniaKeyboard.cs index 0b63af2d9..8936513ca 100644 --- a/src/Ryujinx/Input/AvaloniaKeyboard.cs +++ b/src/Ryujinx/Input/AvaloniaKeyboard.cs @@ -30,6 +30,11 @@ namespace Ryujinx.Ava.Input public readonly Key From = from; } + public bool SpecialExit() + { + return false; + } + public AvaloniaKeyboard(AvaloniaKeyboardDriver driver, string id, string name) { _buttonsUserMapping = []; diff --git a/src/Ryujinx/Input/AvaloniaMouse.cs b/src/Ryujinx/Input/AvaloniaMouse.cs index 1aa2d586a..cdcdc2106 100644 --- a/src/Ryujinx/Input/AvaloniaMouse.cs +++ b/src/Ryujinx/Input/AvaloniaMouse.cs @@ -13,6 +13,11 @@ namespace Ryujinx.Ava.Input public string Id => "0"; public string Name => "AvaloniaMouse"; + public bool SpecialExit() + { + return false; + } + public bool IsConnected => true; public GamepadFeaturesFlag Features => throw new NotImplementedException(); public bool[] Buttons => _driver.PressedButtons; diff --git a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs index 17b9ea98c..5ae9ef9ef 100644 --- a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs @@ -1049,6 +1049,7 @@ namespace Ryujinx.Ava.UI.ViewModels private void InitializeGame() { + RendererHostControl.WindowCreated += RendererHost_Created; AppHost.StatusUpdatedEvent += Update_StatusBar; @@ -1058,7 +1059,13 @@ namespace Ryujinx.Ava.UI.ViewModels AppHost?.Start(); + if (AppHost?.IsSpecialExit() == true) + { + Window.ForceExit(); + } + AppHost?.DisposeContext(); + } private async Task HandleRelaunch() diff --git a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs index 2678bbf98..ad82b4d62 100644 --- a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs @@ -128,6 +128,7 @@ namespace Ryujinx.Ava.UI.ViewModels public bool EnableDockedMode { get; set; } public bool EnableKeyboard { get; set; } public bool EnableMouse { get; set; } + public int EnableSpecialExit { get; set; } public VSyncMode VSyncMode { get => _vSyncMode; @@ -259,6 +260,8 @@ namespace Ryujinx.Ava.UI.ViewModels public int OpenglDebugLevel { get; set; } public int MemoryMode { get; set; } public int BaseStyleIndex { get; set; } + + public int GraphicsBackendIndex { get => _graphicsBackendIndex; @@ -511,6 +514,13 @@ namespace Ryujinx.Ava.UI.ViewModels EnableDockedMode = config.System.EnableDockedMode; EnableKeyboard = config.Hid.EnableKeyboard; EnableMouse = config.Hid.EnableMouse; + EnableSpecialExit = config.Hid.SpecialExitEmulator.Value switch + { + 0 => 0, // "Hotkey 'Exit' is Disabled" + 1 => 1, // "Close app. by hotkey" + 2 => 2, // "Close game by hotkey" + _ => 0 + }; // Keyboard Hotkeys KeyboardHotkey = new HotkeyConfig(config.Hid.Hotkeys.Value); @@ -618,6 +628,13 @@ namespace Ryujinx.Ava.UI.ViewModels config.System.EnableDockedMode.Value = EnableDockedMode; config.Hid.EnableKeyboard.Value = EnableKeyboard; config.Hid.EnableMouse.Value = EnableMouse; + config.Hid.SpecialExitEmulator.Value = EnableSpecialExit switch + { + 0 => 0, // "Hotkey 'Exit' is Disabled", + 1 => 1, // "Close app. by hotkey", + 2 => 2, // "Close game by hotkey", + _ => 0 + }; // Keyboard Hotkeys config.Hid.Hotkeys.Value = KeyboardHotkey.GetConfig(); diff --git a/src/Ryujinx/UI/Views/Settings/SettingsInputView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsInputView.axaml index b0edc51a5..033352548 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsInputView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsInputView.axaml @@ -1,4 +1,4 @@ - + + + + + + + + + + + diff --git a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs index 2aaac4098..3487da385 100644 --- a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs +++ b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs @@ -45,6 +45,7 @@ namespace Ryujinx.Ava.UI.Windows internal readonly AvaHostUIHandler UiHandler; private bool _isLoading; + private bool _isExitWithoutConfirm = false; private bool _applicationsLoadedOnce; private UserChannelPersistence _userChannelPersistence; @@ -571,11 +572,11 @@ namespace Ryujinx.Ava.UI.Windows protected override void OnClosing(WindowClosingEventArgs e) { - if (!ViewModel.IsClosing && ViewModel.AppHost != null && ConfigurationState.Instance.ShowConfirmExit) + if (!ViewModel.IsClosing && ViewModel.AppHost != null && ConfigurationState.Instance.ShowConfirmExit && !_isExitWithoutConfirm) { e.Cancel = true; - ConfirmExit(); + ConfirmExit(); return; } @@ -616,6 +617,12 @@ namespace Ryujinx.Ava.UI.Windows base.OnClosing(e); } + public void ForceExit() + { + _isExitWithoutConfirm = true; + Close(); + } + private void ConfirmExit() { Dispatcher.UIThread.InvokeAsync(async () => diff --git a/src/Ryujinx/Utilities/Configuration/ConfigurationFileFormat.cs b/src/Ryujinx/Utilities/Configuration/ConfigurationFileFormat.cs index 947dd5c8f..a0cfaec0c 100644 --- a/src/Ryujinx/Utilities/Configuration/ConfigurationFileFormat.cs +++ b/src/Ryujinx/Utilities/Configuration/ConfigurationFileFormat.cs @@ -17,7 +17,7 @@ namespace Ryujinx.Ava.Utilities.Configuration /// /// The current version of the file format /// - public const int CurrentVersion = 59; + public const int CurrentVersion = 60; /// /// Version of the configuration file format @@ -366,6 +366,12 @@ namespace Ryujinx.Ava.Utilities.Configuration /// public bool EnableMouse { get; set; } + /// + /// Allows you to choose one of several behaviors when pressing hotkeys: + /// 0 - Do nothing, 1 - Close the emulator application, 2 - Exit the game. + /// + public int SpecialExitEmulator { get; set; } + /// /// Hotkey Keyboard Bindings /// diff --git a/src/Ryujinx/Utilities/Configuration/ConfigurationState.Migration.cs b/src/Ryujinx/Utilities/Configuration/ConfigurationState.Migration.cs index ec66bcaac..f341c5f15 100644 --- a/src/Ryujinx/Utilities/Configuration/ConfigurationState.Migration.cs +++ b/src/Ryujinx/Utilities/Configuration/ConfigurationState.Migration.cs @@ -136,6 +136,7 @@ namespace Ryujinx.Ava.Utilities.Configuration Hid.EnableKeyboard.Value = cff.EnableKeyboard; Hid.EnableMouse.Value = cff.EnableMouse; + Hid.SpecialExitEmulator.Value = cff.SpecialExitEmulator; Hid.Hotkeys.Value = cff.Hotkeys; Hid.InputConfig.Value = cff.InputConfig ?? []; @@ -414,6 +415,10 @@ namespace Ryujinx.Ava.Utilities.Configuration // This was accidentally enabled by default when it was PRed. That is not what we want, // so as a compromise users who want to use it will simply need to re-enable it once after updating. cff.IgnoreApplet = false; + }), + (60, static cff => + { + cff.SpecialExitEmulator = 0; }) ); } diff --git a/src/Ryujinx/Utilities/Configuration/ConfigurationState.Model.cs b/src/Ryujinx/Utilities/Configuration/ConfigurationState.Model.cs index fe5f2c3ad..85bede2d5 100644 --- a/src/Ryujinx/Utilities/Configuration/ConfigurationState.Model.cs +++ b/src/Ryujinx/Utilities/Configuration/ConfigurationState.Model.cs @@ -420,6 +420,13 @@ namespace Ryujinx.Ava.Utilities.Configuration /// public ReactiveObject EnableMouse { get; private set; } + /// + /// Allows you to choose one of several behaviors when pressing hotkeys: + /// 0 - Do nothing, 1 - Close the emulator application, 2 - Exit the game. + /// + public ReactiveObject SpecialExitEmulator { get; private set; } + + /// /// Hotkey Keyboard Bindings /// @@ -436,6 +443,7 @@ namespace Ryujinx.Ava.Utilities.Configuration { EnableKeyboard = new ReactiveObject(); EnableMouse = new ReactiveObject(); + SpecialExitEmulator = new ReactiveObject(); Hotkeys = new ReactiveObject(); InputConfig = new ReactiveObject>(); } diff --git a/src/Ryujinx/Utilities/Configuration/ConfigurationState.cs b/src/Ryujinx/Utilities/Configuration/ConfigurationState.cs index 95ec62e83..b68f97826 100644 --- a/src/Ryujinx/Utilities/Configuration/ConfigurationState.cs +++ b/src/Ryujinx/Utilities/Configuration/ConfigurationState.cs @@ -128,6 +128,7 @@ namespace Ryujinx.Ava.Utilities.Configuration ShowConsole = UI.ShowConsole, EnableKeyboard = Hid.EnableKeyboard, EnableMouse = Hid.EnableMouse, + SpecialExitEmulator = Hid.SpecialExitEmulator, Hotkeys = Hid.Hotkeys, KeyboardConfig = [], ControllerConfig = [], @@ -241,6 +242,7 @@ namespace Ryujinx.Ava.Utilities.Configuration UI.WindowStartup.WindowMaximized.Value = false; Hid.EnableKeyboard.Value = false; Hid.EnableMouse.Value = false; + Hid.SpecialExitEmulator.Value = 0; Hid.Hotkeys.Value = new KeyboardHotkeys { ToggleVSyncMode = Key.F1,