mirror of
https://git.ryujinx.app/ryubing/ryujinx.git
synced 2026-06-27 06:39:06 +00:00
Feat : Input : Dynamic Input Swap, Device Profile Linking and Player Device Assignement (#125)
Heyy everyone! Continuing my work on the input system overhaul, this PR is mainly focused around three new features : - Dynamic Input Swap - Device Profile Linking - Player Device Assignement What are these new features and why are they bundled in the same PR you might ask? - **Dynamic Input Swap** Is a new feature that, when enabled, allows the user to switch between multiple input devices, without having to return to the settings menu. This feature can be enabled at the bottom of the Input Settings menu, next to the "global input" checkbox. A nifty new tooltip has been added for clarity. - **Device Profile linking** : While working on Dynamic Input Swap, one issue arose, which was that Ryujinx would use that device’s Default profile, without the user being able to specify a specific profile to use on that specific device. This feature fixes that, by allowing the user to link a profile as the device’s Default. This means that this profile will be auto loaded when selecting that same device, both when connecting it to the emulator, and when using Dynamic Input Swap. This feature can be found next to the Device Profiles dropdown, and includes a nifty new tooltip. By default, the "default" profile is linked to any device. There also is a small icon to the right of the profile name’s Right to visualise which profile is linked. - **Player Device Assignement** : Another issue that arose while working on Dynamic Input Swap was that enabling the feature would render multi player configurations unusable. This new feature addresses this issue by adding a new menu, which allows the user to assign different Input Devices to different players. Inside this new menu, you also get the device’s linked profile and the list of which players are already assigned to which input device, below and to the right of the input device name, respectively. You also get an option to allow mapping the same input device to different players. ### Nerd Zone This PR adds a new player-level input routing layer on top of the existing `InputConfig` system. Previously, each player effectively had one active input config, tied to one device. Given Dynamic Input Swap needs more state than that, this PR introduces a persisted `PlayerInputAssignment` model, which stores the player index, whether Dynamic Input Swap is enabled or not, the list of assigned input devices and an optional profile name bound to each assigned device. The new assignment data lives coexists with the existing input configs instead of replacing them, which should keep the old single-device behavior intact when Dynamic Input Swap is disabled, while allowing dynamic players to own multiple devices. New configl types include: - `AssignedInputDevice` - `AssignedInputDeviceType` - `PlayerInputAssignment` - `PlayerInputAssignmentHelper` - `PlayerInputDeviceAssignmentItem` `PlayerInputAssignmentHelper` normalizes assignments, deduplicates device entries, preserves a primary device, and compares assignments for dirty-checking. On the runtime side, `NpadManager` now passes both the normal `InputConfig` and the player assignment to `NpadController`. When Dynamic Input Swap is disabled, Ryujinx switches to the old behavior : one player config opens one device. However, when it's enabled, `NpadController` opens every assigned keyboard/controller device it can resolve, tracks their state snapshots, and promotes the active source based on recent input. Dynamic swap currently follows a “last meaningful input wins” model (annotated in the code) : So if the keyboard produces new input, it becomes active; if an assigned controller produces new input, that controller becomes active; if the active device stops producing input and another assigned device is held/active, the active source can fall back; and if no device has produced input yet, the initial active source is chosen from the selected config and available assigned devices. Per-device profile binding is handled by storing the profile name on the assigned device entry. When a device is selected or resolved through Dynamic Input Swap, Ryujinx tries to load that bound profile for the matching device type. If the profile is missing, invalid, or explicitly `Default`, it falls back to the generated default config for that device type. The new restore-to-defaults behavior deliberately bypasses linked profiles. This means pressing the reset button loads the real generated Default profile for the currently selected device, not the profile linked to that device. The input settings UI now dirty-checks both the currently edited input config and the player input assignment state, meaning that assigning/unassigning devices, changing Dynamic Input Swap, or changing profile bindings correctly marks the page as modified -> in continuation of my previous efforts in #13 to clean up its behaviour. The Assigned Devices dialog is backed by the currently available keyboard/controller device list. It also checks other players’ persisted assignments so the UI can show which players already use a device. If duplicate assignment is disabled, devices already assigned to another dynamic-swap player are disabled for the current player. If no explicit player assignments exist yet, Ryujinx synthesizes a default assignment from the existing input config. Dynamic swap is disabled by default until the user enables it. ⚠️⚠️⚠️⚠️⚠️Caution : this PR is still a WIP; and while all features described above have been fully implemented, various issues still remain, notably inside the Player assignement menu, that are getting worked on. Additionally, little bug testing has been done across the emulator, so it is not guaranteed that this build will be bug free. Signed by : 🦫 With the very generous help and support from @neo 🤗 Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/125
This commit is contained in:
@@ -6200,6 +6200,206 @@
|
||||
"zh_TW": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "ControllerSettingsDynamicInputSwap",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Dynamic Input Swap",
|
||||
"es_ES": "Cambio Dinámico de Entrada",
|
||||
"fr_FR": "Bascule Dynamique des Périphériques",
|
||||
"he_IL": "",
|
||||
"it_IT": "",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "",
|
||||
"no_NO": "",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "",
|
||||
"ru_RU": "",
|
||||
"sv_SE": "",
|
||||
"th_TH": "",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "",
|
||||
"zh_CN": "",
|
||||
"zh_TW": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "ControllerSettingsDynamicInputSwapTooltip",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Allows real-time switching between connected input devices. When enabled, you can assign specific devices to this player slot to prevent input interference between players.",
|
||||
"es_ES": "Permite cambiar en tiempo real entre dispositivos de entrada conectados. Cuando está activado, puedes asignar dispositivos específicos a este espacio de jugador para evitar interferencias de entrada entre jugadores.",
|
||||
"fr_FR": "Permet de basculer en temps réel entre les périphériques connectés. Lorsqu'il est activé, vous pouvez assigner des périphériques spécifiques à cet emplacement de joueur pour éviter les interférences entre les joueurs.",
|
||||
"he_IL": "",
|
||||
"it_IT": "",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "",
|
||||
"no_NO": "",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "",
|
||||
"ru_RU": "",
|
||||
"sv_SE": "",
|
||||
"th_TH": "",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "",
|
||||
"zh_CN": "",
|
||||
"zh_TW": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "DialogDynamicInputSwapDeviceAssignmentsHint",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "You can assign devices to specific players by clicking the gear icon next to each input device.",
|
||||
"es_ES": "Puedes asignar dispositivos a jugadores específicos haciendo clic en el icono de engranaje junto a cada dispositivo de entrada.",
|
||||
"fr_FR": "Vous pouvez assigner des périphériques à des joueurs spécifiques en cliquant sur l'icône d'engrenage à côté de chaque périphérique d'entrée.",
|
||||
"he_IL": "",
|
||||
"it_IT": "",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "",
|
||||
"no_NO": "",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "",
|
||||
"ru_RU": "",
|
||||
"sv_SE": "",
|
||||
"th_TH": "",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "",
|
||||
"zh_CN": "",
|
||||
"zh_TW": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "DialogDontShowAgain",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Don't show this message again",
|
||||
"es_ES": "No mostrar este mensaje de nuevo",
|
||||
"fr_FR": "Ne plus afficher ce message",
|
||||
"he_IL": "",
|
||||
"it_IT": "",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "",
|
||||
"no_NO": "",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "",
|
||||
"ru_RU": "",
|
||||
"sv_SE": "",
|
||||
"th_TH": "",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "",
|
||||
"zh_CN": "",
|
||||
"zh_TW": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "ControllerSettingsAssignedInputDevices",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Assigned Devices",
|
||||
"es_ES": "Dispositivos asignados",
|
||||
"fr_FR": "Périphériques assignés",
|
||||
"he_IL": "",
|
||||
"it_IT": "",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "",
|
||||
"no_NO": "",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "",
|
||||
"ru_RU": "",
|
||||
"sv_SE": "",
|
||||
"th_TH": "",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "",
|
||||
"zh_CN": "",
|
||||
"zh_TW": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "ControllerSettingsAssignedInputDevicesTooltip",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Open the Assigned Devices settings to configure Dynamic Input Swap and per-device profile bindings for this player.",
|
||||
"es_ES": "Abre la configuración de dispositivos asignados para configurar el Cambio Dinámico de Entrada y las vinculaciones de perfil por dispositivo para este jugador.",
|
||||
"fr_FR": "Ouvre les paramètres des périphériques assignés pour configurer la Bascule Dynamique des Périphériques et les liaisons de profil par périphérique pour ce joueur.",
|
||||
"he_IL": "",
|
||||
"it_IT": "",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "",
|
||||
"no_NO": "",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "",
|
||||
"ru_RU": "",
|
||||
"sv_SE": "",
|
||||
"th_TH": "",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "",
|
||||
"zh_CN": "",
|
||||
"zh_TW": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "ControllerSettingsAllowDuplicateDeviceAssignment",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Allow the same device for multiple players",
|
||||
"es_ES": "Permitir el mismo dispositivo para varios jugadores",
|
||||
"fr_FR": "Autoriser le même périphérique pour plusieurs joueurs",
|
||||
"he_IL": "",
|
||||
"it_IT": "",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "",
|
||||
"no_NO": "",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "",
|
||||
"ru_RU": "",
|
||||
"sv_SE": "",
|
||||
"th_TH": "",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "",
|
||||
"zh_CN": "",
|
||||
"zh_TW": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "ControllerSettingsBindProfileToolTip",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Link this profile as the default for the currently connected input device. The linked profile will load automatically when this device is connected or swapped via Dynamic Input Swap.",
|
||||
"es_ES": "Vincular este perfil como predeterminado para el dispositivo de entrada actualmente conectado. El perfil vinculado se cargará automáticamente cuando se conecte este dispositivo o se intercambie mediante Intercambio Dinámico de Entrada.",
|
||||
"fr_FR": "Lier ce profil comme profil par défaut pour le périphérique d'entrée actuellement connecté. Le profil lié sera chargé automatiquement lorsque ce périphérique sera connecté ou échangé via l'Échange Dynamique d'Entrée.",
|
||||
"he_IL": "",
|
||||
"it_IT": "",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "",
|
||||
"no_NO": "",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "",
|
||||
"ru_RU": "",
|
||||
"sv_SE": "",
|
||||
"th_TH": "",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "",
|
||||
"zh_CN": "",
|
||||
"zh_TW": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "ControllerSettingsDeviceDisabled",
|
||||
"Translations": {
|
||||
|
||||
11
src/Ryujinx.Common/Configuration/Hid/AssignedInputDevice.cs
Normal file
11
src/Ryujinx.Common/Configuration/Hid/AssignedInputDevice.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Ryujinx.Common.Configuration.Hid
|
||||
{
|
||||
public class AssignedInputDevice
|
||||
{
|
||||
public AssignedInputDeviceType Type { get; set; }
|
||||
|
||||
public string Id { get; set; }
|
||||
|
||||
public string ProfileName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ryujinx.Common.Configuration.Hid
|
||||
{
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<AssignedInputDeviceType>))]
|
||||
public enum AssignedInputDeviceType
|
||||
{
|
||||
Keyboard,
|
||||
Controller,
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,12 @@ namespace Ryujinx.Common.Configuration.Hid
|
||||
/// </summary>
|
||||
public PlayerIndex PlayerIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow a keyboard configuration to temporarily promote to a connected gamepad,
|
||||
/// while preserving the existing keyboard fallback path when that gamepad disappears.
|
||||
/// </summary>
|
||||
public bool EnableDynamicGamepadSwap { get; set; }
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Ryujinx.Common.Configuration.Hid
|
||||
{
|
||||
public class PlayerInputAssignment
|
||||
{
|
||||
public PlayerIndex PlayerIndex { get; set; }
|
||||
|
||||
public bool EnableDynamicInputSwap { get; set; }
|
||||
|
||||
public List<AssignedInputDevice> Devices { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using Ryujinx.Common.Configuration.Hid.Keyboard;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Ryujinx.Common.Configuration.Hid
|
||||
{
|
||||
public static class PlayerInputAssignmentHelper
|
||||
{
|
||||
public static AssignedInputDevice CreatePrimaryDevice(InputConfig inputConfig)
|
||||
{
|
||||
if (inputConfig == null || string.IsNullOrWhiteSpace(inputConfig.Id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AssignedInputDevice
|
||||
{
|
||||
Type = inputConfig is StandardKeyboardInputConfig
|
||||
? AssignedInputDeviceType.Keyboard
|
||||
: AssignedInputDeviceType.Controller,
|
||||
Id = inputConfig.Id,
|
||||
};
|
||||
}
|
||||
|
||||
public static PlayerInputAssignment Normalize(PlayerInputAssignment assignment, AssignedInputDevice preferredDevice = null)
|
||||
{
|
||||
if (assignment == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
PlayerInputAssignment normalized = new()
|
||||
{
|
||||
PlayerIndex = assignment.PlayerIndex,
|
||||
EnableDynamicInputSwap = assignment.EnableDynamicInputSwap,
|
||||
};
|
||||
|
||||
List<AssignedInputDevice> distinctDevices = Deduplicate(assignment.Devices);
|
||||
|
||||
if (assignment.EnableDynamicInputSwap)
|
||||
{
|
||||
normalized.Devices.AddRange(distinctDevices.Select(Clone));
|
||||
return normalized;
|
||||
}
|
||||
|
||||
AssignedInputDevice primaryDevice = SelectPrimaryDevice(distinctDevices, preferredDevice) ?? Clone(preferredDevice);
|
||||
|
||||
if (primaryDevice != null)
|
||||
{
|
||||
normalized.Devices.Add(Clone(primaryDevice));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static bool AreEquivalent(
|
||||
PlayerInputAssignment left,
|
||||
PlayerInputAssignment right,
|
||||
AssignedInputDevice leftPreferredDevice = null,
|
||||
AssignedInputDevice rightPreferredDevice = null)
|
||||
{
|
||||
if (left == null || right == null)
|
||||
{
|
||||
return left == right;
|
||||
}
|
||||
|
||||
PlayerInputAssignment normalizedLeft = Normalize(left, leftPreferredDevice);
|
||||
PlayerInputAssignment normalizedRight = Normalize(right, rightPreferredDevice);
|
||||
|
||||
if (normalizedLeft.EnableDynamicInputSwap != normalizedRight.EnableDynamicInputSwap)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
List<(AssignedInputDeviceType Type, string Id, string ProfileName)> leftDevices = normalizedLeft.Devices
|
||||
.Select(device => (Type: device.Type, Id: device.Id, ProfileName: device.ProfileName ?? string.Empty))
|
||||
.OrderBy(device => device.Type)
|
||||
.ThenBy(device => device.Id, StringComparer.Ordinal)
|
||||
.ThenBy(device => device.ProfileName, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
List<(AssignedInputDeviceType Type, string Id, string ProfileName)> rightDevices = normalizedRight.Devices
|
||||
.Select(device => (Type: device.Type, Id: device.Id, ProfileName: device.ProfileName ?? string.Empty))
|
||||
.OrderBy(device => device.Type)
|
||||
.ThenBy(device => device.Id, StringComparer.Ordinal)
|
||||
.ThenBy(device => device.ProfileName, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return leftDevices.SequenceEqual(rightDevices);
|
||||
}
|
||||
|
||||
private static List<AssignedInputDevice> Deduplicate(IEnumerable<AssignedInputDevice> devices)
|
||||
{
|
||||
List<AssignedInputDevice> result = [];
|
||||
|
||||
if (devices == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (AssignedInputDevice device in devices)
|
||||
{
|
||||
if (device == null || string.IsNullOrWhiteSpace(device.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int existingIndex = result.FindIndex(existing =>
|
||||
existing.Type == device.Type &&
|
||||
string.Equals(existing.Id, device.Id, StringComparison.Ordinal));
|
||||
|
||||
if (existingIndex == -1)
|
||||
{
|
||||
result.Add(Clone(device));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(device.ProfileName) ||
|
||||
string.IsNullOrWhiteSpace(result[existingIndex].ProfileName))
|
||||
{
|
||||
result[existingIndex].ProfileName = device.ProfileName;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static AssignedInputDevice SelectPrimaryDevice(List<AssignedInputDevice> devices, AssignedInputDevice preferredDevice)
|
||||
{
|
||||
if (devices == null || devices.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preferredDevice != null)
|
||||
{
|
||||
AssignedInputDevice matchedDevice = devices.FirstOrDefault(device =>
|
||||
device.Type == preferredDevice.Type &&
|
||||
string.Equals(device.Id, preferredDevice.Id, StringComparison.Ordinal));
|
||||
|
||||
if (matchedDevice != null)
|
||||
{
|
||||
return matchedDevice;
|
||||
}
|
||||
}
|
||||
|
||||
return devices[0];
|
||||
}
|
||||
|
||||
private static AssignedInputDevice Clone(AssignedInputDevice device)
|
||||
{
|
||||
if (device == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AssignedInputDevice
|
||||
{
|
||||
Type = device.Type,
|
||||
Id = device.Id,
|
||||
ProfileName = device.ProfileName,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,7 +275,7 @@ namespace Ryujinx.Input.SDL3
|
||||
{
|
||||
_configuration = (StandardControllerInputConfig)configuration;
|
||||
|
||||
if ((Features & GamepadFeaturesFlag.Led) != 0 && _configuration.Led.EnableLed)
|
||||
if ((Features & GamepadFeaturesFlag.Led) != 0 && _configuration.Led?.EnableLed == true)
|
||||
{
|
||||
if (_configuration.Led.TurnOffLed)
|
||||
(this as IGamepad).ClearLed();
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
using Ryujinx.Common;
|
||||
using Ryujinx.Common.Configuration;
|
||||
using Ryujinx.Common.Configuration.Hid;
|
||||
using Ryujinx.Common.Configuration.Hid.Controller;
|
||||
using Ryujinx.Common.Configuration.Hid.Controller.Motion;
|
||||
using Ryujinx.Common.Configuration.Hid.Keyboard;
|
||||
using Ryujinx.Common.Logging;
|
||||
using Ryujinx.Common.Utilities;
|
||||
using Ryujinx.HLE.HOS.Services.Hid;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using CemuHookClient = Ryujinx.Input.Motion.CemuHook.Client;
|
||||
using ConfigControllerType = Ryujinx.Common.Configuration.Hid.ControllerType;
|
||||
|
||||
@@ -16,6 +22,9 @@ namespace Ryujinx.Input.HLE
|
||||
{
|
||||
public class NpadController : IDisposable
|
||||
{
|
||||
private const string KeyboardString = "keyboard";
|
||||
private const string ControllerString = "controller";
|
||||
|
||||
private class HLEButtonMappingEntry
|
||||
{
|
||||
public readonly GamepadButtonInputId DriverInputId;
|
||||
@@ -211,14 +220,40 @@ namespace Ryujinx.Input.HLE
|
||||
private MotionInput _rightMotionInput;
|
||||
|
||||
private IGamepad _gamepad;
|
||||
private IGamepad _keyboardGamepad;
|
||||
private IGamepad _controllerGamepad;
|
||||
private readonly List<IGamepad> _assignedControllerGamepads = [];
|
||||
private readonly List<StandardControllerInputConfig> _assignedControllerConfigs = [];
|
||||
private InputConfig _config;
|
||||
private InputConfig _activeConfig;
|
||||
private StandardKeyboardInputConfig _keyboardConfig;
|
||||
private StandardControllerInputConfig _controllerConfig;
|
||||
private GamepadStateSnapshot _previousKeyboardState;
|
||||
private readonly List<GamepadStateSnapshot> _previousControllerStates = [];
|
||||
private DynamicInputSource _activeInputSource;
|
||||
private PlayerInputAssignment _playerInputAssignment;
|
||||
private bool _singleUsesKeyboardDriver;
|
||||
private IGamepadDriver _keyboardDriver;
|
||||
private IGamepadDriver _controllerDriver;
|
||||
private int _activeControllerIndex = -1;
|
||||
|
||||
public IGamepadDriver GamepadDriver { get; private set; }
|
||||
public GamepadStateSnapshot State { get; private set; }
|
||||
public InputConfig ActiveConfig => _activeConfig;
|
||||
|
||||
public string Id { get; private set; }
|
||||
|
||||
public bool IsAvailable => _gamepad != null || _keyboardGamepad != null || _assignedControllerGamepads.Count > 0;
|
||||
|
||||
private readonly CemuHookClient _cemuHookClient;
|
||||
private static readonly InputConfigJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions());
|
||||
|
||||
private enum DynamicInputSource
|
||||
{
|
||||
None,
|
||||
Keyboard,
|
||||
Controller,
|
||||
}
|
||||
|
||||
public NpadController(CemuHookClient cemuHookClient)
|
||||
{
|
||||
@@ -227,31 +262,114 @@ namespace Ryujinx.Input.HLE
|
||||
_cemuHookClient = cemuHookClient;
|
||||
}
|
||||
|
||||
public bool UpdateDriverConfiguration(IGamepadDriver gamepadDriver, InputConfig config)
|
||||
public bool MatchesDriverConfiguration(InputConfig config, PlayerInputAssignment playerInputAssignment)
|
||||
{
|
||||
GamepadDriver = gamepadDriver;
|
||||
if (_config?.EnableDynamicGamepadSwap != config.EnableDynamicGamepadSwap)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_gamepad?.Dispose();
|
||||
if (playerInputAssignment?.EnableDynamicInputSwap == true)
|
||||
{
|
||||
if (_playerInputAssignment == null || _playerInputAssignment.EnableDynamicInputSwap != playerInputAssignment.EnableDynamicInputSwap)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Id = config.Id;
|
||||
_gamepad = config is StandardKeyboardInputConfig && GamepadDriver is IKeyboardModeDriver keyboardModeDriver
|
||||
? keyboardModeDriver.GetKeyboard(Id, KeyboardInputMode.Physical)
|
||||
: GamepadDriver.GetGamepad(Id);
|
||||
return PlayerInputAssignmentHelper.AreEquivalent(_playerInputAssignment, playerInputAssignment);
|
||||
}
|
||||
|
||||
return _singleUsesKeyboardDriver == (config is StandardKeyboardInputConfig) &&
|
||||
Id == config.Id;
|
||||
}
|
||||
|
||||
public bool UpdateDriverConfiguration(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, InputConfig config, PlayerInputAssignment playerInputAssignment)
|
||||
{
|
||||
_keyboardDriver = keyboardDriver;
|
||||
_controllerDriver = gamepadDriver;
|
||||
_playerInputAssignment = playerInputAssignment;
|
||||
|
||||
DisposeOpenedGamepads();
|
||||
|
||||
_gamepad = null;
|
||||
_keyboardGamepad = null;
|
||||
_controllerGamepad = null;
|
||||
_assignedControllerGamepads.Clear();
|
||||
_assignedControllerConfigs.Clear();
|
||||
_previousKeyboardState = default;
|
||||
_previousControllerStates.Clear();
|
||||
_activeInputSource = DynamicInputSource.None;
|
||||
_activeControllerIndex = -1;
|
||||
|
||||
if (playerInputAssignment?.EnableDynamicInputSwap == true)
|
||||
{
|
||||
ConfigureDynamicGamepads(keyboardDriver, gamepadDriver, config);
|
||||
}
|
||||
else
|
||||
{
|
||||
_singleUsesKeyboardDriver = config is StandardKeyboardInputConfig;
|
||||
GamepadDriver = _singleUsesKeyboardDriver ? keyboardDriver : gamepadDriver;
|
||||
Id = config.Id;
|
||||
_gamepad = OpenSingleGamepad(GamepadDriver, config.Id, _singleUsesKeyboardDriver);
|
||||
}
|
||||
|
||||
UpdateUserConfiguration(config);
|
||||
|
||||
return _gamepad != null;
|
||||
return IsAvailable;
|
||||
}
|
||||
|
||||
public void UpdateUserConfiguration(InputConfig config)
|
||||
{
|
||||
InputConfig oldConfig = _config;
|
||||
|
||||
if (_playerInputAssignment?.EnableDynamicInputSwap == true)
|
||||
{
|
||||
StandardControllerInputConfig oldControllerConfig = _controllerConfig;
|
||||
|
||||
_config = config;
|
||||
UpdateDynamicConfigurations(config);
|
||||
|
||||
if (_controllerConfig?.Motion == null)
|
||||
{
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
}
|
||||
else if (NeedsMotionInputUpdate(oldControllerConfig, _controllerConfig))
|
||||
{
|
||||
UpdateMotionInput(_controllerConfig.Motion);
|
||||
}
|
||||
|
||||
if (_keyboardConfig != null)
|
||||
{
|
||||
_keyboardGamepad?.SetConfiguration(_keyboardConfig);
|
||||
}
|
||||
|
||||
for (int i = 0; i < _assignedControllerGamepads.Count; i++)
|
||||
{
|
||||
StandardControllerInputConfig assignedControllerConfig = i < _assignedControllerConfigs.Count
|
||||
? _assignedControllerConfigs[i]
|
||||
: _controllerConfig;
|
||||
|
||||
if (assignedControllerConfig != null)
|
||||
{
|
||||
_assignedControllerGamepads[i].SetConfiguration(assignedControllerConfig);
|
||||
}
|
||||
}
|
||||
|
||||
UpdateActiveGamepad();
|
||||
return;
|
||||
}
|
||||
|
||||
_config = config;
|
||||
|
||||
if (config is StandardControllerInputConfig controllerConfig)
|
||||
{
|
||||
bool needsMotionInputUpdate = _config is not StandardControllerInputConfig oldControllerConfig ||
|
||||
((oldControllerConfig.Motion.EnableMotion != controllerConfig.Motion.EnableMotion) &&
|
||||
(oldControllerConfig.Motion.MotionBackend != controllerConfig.Motion.MotionBackend));
|
||||
|
||||
if (needsMotionInputUpdate)
|
||||
if (controllerConfig.Motion == null)
|
||||
{
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
}
|
||||
else if (NeedsMotionInputUpdate(oldConfig as StandardControllerInputConfig, controllerConfig))
|
||||
{
|
||||
UpdateMotionInput(controllerConfig.Motion);
|
||||
}
|
||||
@@ -260,15 +378,23 @@ namespace Ryujinx.Input.HLE
|
||||
{
|
||||
// Non-controller doesn't have motions.
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
}
|
||||
|
||||
_config = config;
|
||||
_activeConfig = config;
|
||||
|
||||
_gamepad?.SetConfiguration(config);
|
||||
}
|
||||
|
||||
private void UpdateMotionInput(MotionConfigController motionConfig)
|
||||
{
|
||||
if (motionConfig == null)
|
||||
{
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (motionConfig.MotionBackend != MotionInputBackendType.CemuHook)
|
||||
{
|
||||
_leftMotionInput = new MotionInput();
|
||||
@@ -281,72 +407,50 @@ namespace Ryujinx.Input.HLE
|
||||
}
|
||||
}
|
||||
|
||||
private bool NeedsMotionInputUpdate(StandardControllerInputConfig oldConfig, StandardControllerInputConfig newConfig)
|
||||
{
|
||||
if (newConfig?.Motion == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool motionWasDisabled = oldConfig?.Motion == null;
|
||||
bool leftMotionMissing = _leftMotionInput == null;
|
||||
bool isJoyconPairNeedingRightMotion = newConfig.ControllerType == ConfigControllerType.JoyconPair && _rightMotionInput == null;
|
||||
bool motionEnabledChanged = oldConfig.Motion.EnableMotion != newConfig.Motion.EnableMotion;
|
||||
bool motionBackendChanged = oldConfig.Motion.MotionBackend != newConfig.Motion.MotionBackend;
|
||||
|
||||
return motionWasDisabled ||
|
||||
leftMotionMissing ||
|
||||
isJoyconPairNeedingRightMotion ||
|
||||
motionEnabledChanged ||
|
||||
motionBackendChanged;
|
||||
}
|
||||
|
||||
public void Update()
|
||||
{
|
||||
if (_playerInputAssignment?.EnableDynamicInputSwap == true)
|
||||
{
|
||||
UpdateDynamic();
|
||||
return;
|
||||
}
|
||||
|
||||
// _gamepad may be altered by other threads
|
||||
IGamepad gamepad = _gamepad;
|
||||
|
||||
if (gamepad != null && GamepadDriver != null)
|
||||
{
|
||||
State = gamepad.GetMappedStateSnapshot();
|
||||
_activeConfig = _config;
|
||||
|
||||
if (_config is StandardControllerInputConfig controllerConfig && controllerConfig.Motion.EnableMotion)
|
||||
if (_activeConfig is StandardControllerInputConfig controllerConfig && controllerConfig.Motion?.EnableMotion == true)
|
||||
{
|
||||
if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.GamepadDriver)
|
||||
{
|
||||
if ((gamepad.Features & GamepadFeaturesFlag.Motion) != 0)
|
||||
{
|
||||
Vector3 accelerometer = gamepad.GetMotionData(MotionInputId.Accelerometer);
|
||||
Vector3 gyroscope = gamepad.GetMotionData(MotionInputId.Gyroscope);
|
||||
|
||||
accelerometer = new Vector3(accelerometer.X, -accelerometer.Z, accelerometer.Y);
|
||||
gyroscope = new Vector3(gyroscope.X, -gyroscope.Z, gyroscope.Y);
|
||||
|
||||
_leftMotionInput.Update(accelerometer, gyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone);
|
||||
|
||||
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.CemuHook && controllerConfig.Motion is CemuHookMotionConfigController cemuControllerConfig)
|
||||
{
|
||||
int clientId = (int)controllerConfig.PlayerIndex;
|
||||
|
||||
// First of all ensure we are registered
|
||||
_cemuHookClient.RegisterClient(clientId, cemuControllerConfig.DsuServerHost, cemuControllerConfig.DsuServerPort);
|
||||
|
||||
// Then request and retrieve the data
|
||||
_cemuHookClient.RequestData(clientId, cemuControllerConfig.Slot);
|
||||
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.Slot, out _leftMotionInput);
|
||||
|
||||
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
|
||||
{
|
||||
if (!cemuControllerConfig.MirrorInput)
|
||||
{
|
||||
_cemuHookClient.RequestData(clientId, cemuControllerConfig.AltSlot);
|
||||
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.AltSlot, out _rightMotionInput);
|
||||
}
|
||||
else
|
||||
{
|
||||
_rightMotionInput = _leftMotionInput;
|
||||
}
|
||||
}
|
||||
}
|
||||
UpdateControllerMotion(gamepad, controllerConfig);
|
||||
}
|
||||
else
|
||||
{
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -371,7 +475,7 @@ namespace Ryujinx.Input.HLE
|
||||
}
|
||||
}
|
||||
|
||||
if (_gamepad is IKeyboard)
|
||||
if (_activeConfig is StandardKeyboardInputConfig)
|
||||
{
|
||||
(float leftAxisX, float leftAxisY) = State.GetStick(StickInputId.Left);
|
||||
(float rightAxisX, float rightAxisY) = State.GetStick(StickInputId.Right);
|
||||
@@ -388,7 +492,7 @@ namespace Ryujinx.Input.HLE
|
||||
Dy = ClampAxis(rightAxisY),
|
||||
};
|
||||
}
|
||||
else if (_config is StandardControllerInputConfig controllerConfig)
|
||||
else if (_activeConfig is StandardControllerInputConfig controllerConfig)
|
||||
{
|
||||
(float leftAxisX, float leftAxisY) = State.GetStick(StickInputId.Left);
|
||||
(float rightAxisX, float rightAxisY) = State.GetStick(StickInputId.Right);
|
||||
@@ -509,9 +613,12 @@ namespace Ryujinx.Input.HLE
|
||||
return value;
|
||||
}
|
||||
|
||||
public static KeyboardInput GetHLEKeyboardInput(IGamepadDriver KeyboardDriver)
|
||||
public static KeyboardInput GetHLEKeyboardInput(IGamepadDriver keyboardDriver)
|
||||
{
|
||||
IKeyboard keyboard = KeyboardDriver.GetGamepad("0") as IKeyboard;
|
||||
if (keyboardDriver.GetGamepad("0") is not IKeyboard keyboard)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
KeyboardStateSnapshot keyboardState = keyboard.GetKeyboardStateSnapshot();
|
||||
|
||||
@@ -543,7 +650,7 @@ namespace Ryujinx.Input.HLE
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_gamepad?.Dispose();
|
||||
DisposeOpenedGamepads();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,38 +664,565 @@ namespace Ryujinx.Input.HLE
|
||||
{
|
||||
if (queue.TryDequeue(out (VibrationValue, VibrationValue) dualVibrationValue))
|
||||
{
|
||||
if (_config is not StandardControllerInputConfig controllerConfig ||
|
||||
!controllerConfig.Rumble.EnableRumble)
|
||||
if (_controllerConfig is StandardControllerInputConfig dynamicControllerConfig &&
|
||||
_playerInputAssignment?.EnableDynamicInputSwap == true &&
|
||||
dynamicControllerConfig.Rumble?.EnableRumble == true)
|
||||
{
|
||||
return;
|
||||
ApplyRumble(_controllerGamepad ?? _assignedControllerGamepads.FirstOrDefault(), dynamicControllerConfig, dualVibrationValue);
|
||||
}
|
||||
else if (_config is StandardControllerInputConfig controllerConfig && controllerConfig.Rumble?.EnableRumble == true)
|
||||
{
|
||||
ApplyRumble(_gamepad, controllerConfig, dualVibrationValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasAssignedControllerId(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_playerInputAssignment?.EnableDynamicInputSwap == true)
|
||||
{
|
||||
return _assignedControllerGamepads.Any(gamepad => gamepad?.Id == id);
|
||||
}
|
||||
|
||||
return Id == id;
|
||||
}
|
||||
|
||||
private void ApplyRumble(IGamepad gamepad, StandardControllerInputConfig controllerConfig, (VibrationValue, VibrationValue) dualVibrationValue)
|
||||
{
|
||||
if (gamepad == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
VibrationValue leftVibrationValue = dualVibrationValue.Item1;
|
||||
VibrationValue rightVibrationValue = dualVibrationValue.Item2;
|
||||
|
||||
leftVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
|
||||
leftVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
|
||||
rightVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
|
||||
rightVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
|
||||
|
||||
if (!controllerConfig.Rumble.UseHDRumble || gamepad.HDRumble(leftVibrationValue, rightVibrationValue) == false)
|
||||
{
|
||||
float low = Math.Min(1f, (float)(rightVibrationValue.AmplitudeLow * 0.85 + rightVibrationValue.AmplitudeHigh * 0.15));
|
||||
float high = Math.Min(1f, (float)(leftVibrationValue.AmplitudeLow * 0.15 + leftVibrationValue.AmplitudeHigh * 0.85));
|
||||
gamepad.Rumble(low, high, 0xFFFFFFFF);
|
||||
}
|
||||
|
||||
Logger.Debug?.Print(LogClass.Hid, $"Effect for {controllerConfig.PlayerIndex} " +
|
||||
// Value=value/multiplier * multiplier (result)
|
||||
$"L.low.amp={leftVibrationValue.AmplitudeLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeLow}), " +
|
||||
$"L.high.amp={leftVibrationValue.AmplitudeHigh / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeHigh}), " +
|
||||
$"L.low.freq={leftVibrationValue.FrequencyLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyLow}), " +
|
||||
$"L.high.freq={leftVibrationValue.FrequencyHigh / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyHigh}), " +
|
||||
$"R.low.amp={rightVibrationValue.AmplitudeLow / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeLow}), " +
|
||||
$"R.high.amp={rightVibrationValue.AmplitudeHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeHigh}), " +
|
||||
$"R.low.freq={rightVibrationValue.FrequencyLow / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyLow}), " +
|
||||
$"R.high.freq={rightVibrationValue.FrequencyHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyHigh})");
|
||||
}
|
||||
|
||||
private void ConfigureDynamicGamepads(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, InputConfig config)
|
||||
{
|
||||
AssignedInputDevice assignedKeyboard = _playerInputAssignment?.Devices.FirstOrDefault(device => device.Type == AssignedInputDeviceType.Keyboard);
|
||||
|
||||
if (!string.IsNullOrEmpty(assignedKeyboard?.Id))
|
||||
{
|
||||
_keyboardGamepad = OpenSingleGamepad(keyboardDriver, assignedKeyboard.Id, true);
|
||||
}
|
||||
|
||||
foreach (AssignedInputDevice assignedController in ResolveDynamicControllerAssignments(gamepadDriver, config))
|
||||
{
|
||||
IGamepad controllerGamepad = OpenSingleGamepad(gamepadDriver, assignedController.Id, false);
|
||||
|
||||
if (controllerGamepad != null)
|
||||
{
|
||||
_assignedControllerGamepads.Add(controllerGamepad);
|
||||
_assignedControllerConfigs.Add(null);
|
||||
_previousControllerStates.Add(default);
|
||||
}
|
||||
}
|
||||
|
||||
_controllerGamepad = _assignedControllerGamepads.FirstOrDefault();
|
||||
GamepadDriver = null;
|
||||
Id = _assignedControllerGamepads.FirstOrDefault()?.Id ?? config.Id;
|
||||
}
|
||||
|
||||
private IEnumerable<AssignedInputDevice> ResolveDynamicControllerAssignments(IGamepadDriver gamepadDriver, InputConfig config)
|
||||
{
|
||||
if (gamepadDriver == null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
List<AssignedInputDevice> assignedControllers = _playerInputAssignment?.Devices
|
||||
.Where(device => device.Type == AssignedInputDeviceType.Controller)
|
||||
.ToList() ?? [];
|
||||
|
||||
if (_playerInputAssignment?.EnableDynamicInputSwap == true)
|
||||
{
|
||||
foreach (AssignedInputDevice assignedController in assignedControllers)
|
||||
{
|
||||
foreach (string gamepadId in gamepadDriver.GamepadsIds)
|
||||
{
|
||||
if (gamepadId == assignedController.Id)
|
||||
{
|
||||
yield return assignedController;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VibrationValue leftVibrationValue = dualVibrationValue.Item1;
|
||||
VibrationValue rightVibrationValue = dualVibrationValue.Item2;
|
||||
yield break;
|
||||
}
|
||||
|
||||
leftVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
|
||||
leftVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
|
||||
rightVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
|
||||
rightVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
|
||||
|
||||
if (!controllerConfig.Rumble.UseHDRumble || _gamepad?.HDRumble(leftVibrationValue, rightVibrationValue) == false)
|
||||
if (config is StandardControllerInputConfig)
|
||||
{
|
||||
foreach (string gamepadId in gamepadDriver.GamepadsIds)
|
||||
{
|
||||
float low = Math.Min(1f, (float)((rightVibrationValue.AmplitudeLow * 0.85 + rightVibrationValue.AmplitudeHigh * 0.15)));
|
||||
float high = Math.Min(1f, (float)((leftVibrationValue.AmplitudeLow * 0.15 + leftVibrationValue.AmplitudeHigh * 0.85)));
|
||||
_gamepad?.Rumble(low, high, 0xFFFFFFFF);
|
||||
if (gamepadId == config.Id)
|
||||
{
|
||||
yield return new AssignedInputDevice
|
||||
{
|
||||
Type = AssignedInputDeviceType.Controller,
|
||||
Id = gamepadId,
|
||||
};
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!gamepadDriver.GamepadsIds.IsEmpty)
|
||||
{
|
||||
yield return new AssignedInputDevice
|
||||
{
|
||||
Type = AssignedInputDeviceType.Controller,
|
||||
Id = gamepadDriver.GamepadsIds[0],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static IGamepad OpenSingleGamepad(IGamepadDriver driver, string id, bool keyboard)
|
||||
{
|
||||
if (driver == null || string.IsNullOrEmpty(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (keyboard && driver is IKeyboardModeDriver keyboardModeDriver)
|
||||
{
|
||||
return keyboardModeDriver.GetKeyboard(id, KeyboardInputMode.Physical);
|
||||
}
|
||||
|
||||
return driver.GetGamepad(id);
|
||||
}
|
||||
|
||||
private void UpdateDynamicConfigurations(InputConfig config)
|
||||
{
|
||||
if (config is StandardKeyboardInputConfig keyboardConfig)
|
||||
{
|
||||
AssignedInputDevice assignedKeyboard = _playerInputAssignment?.Devices.FirstOrDefault(device => device.Type == AssignedInputDeviceType.Keyboard);
|
||||
|
||||
_keyboardConfig = ResolveKeyboardConfiguration(assignedKeyboard, keyboardConfig, _keyboardGamepad);
|
||||
|
||||
_assignedControllerConfigs.Clear();
|
||||
|
||||
foreach (IGamepad controllerGamepad in _assignedControllerGamepads)
|
||||
{
|
||||
AssignedInputDevice assignedController = _playerInputAssignment?.Devices.FirstOrDefault(device =>
|
||||
device.Type == AssignedInputDeviceType.Controller &&
|
||||
device.Id == controllerGamepad.Id);
|
||||
|
||||
_assignedControllerConfigs.Add(ResolveControllerConfiguration(assignedController, keyboardConfig, controllerGamepad));
|
||||
}
|
||||
|
||||
Logger.Debug?.Print(LogClass.Hid, $"Effect for {controllerConfig.PlayerIndex} " +
|
||||
// Value=value/multiplier * multiplier (result)
|
||||
$"L.low.amp={leftVibrationValue.AmplitudeLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeLow}), " +
|
||||
$"L.high.amp={leftVibrationValue.AmplitudeHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({leftVibrationValue.AmplitudeHigh}), " +
|
||||
$"L.low.freq={leftVibrationValue.FrequencyLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyLow}), " +
|
||||
$"L.high.freq={leftVibrationValue.FrequencyHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({leftVibrationValue.FrequencyHigh}), " +
|
||||
$"R.low.amp={rightVibrationValue.AmplitudeLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({rightVibrationValue.AmplitudeLow}), " +
|
||||
$"R.high.amp={rightVibrationValue.AmplitudeHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeHigh}), " +
|
||||
$"R.low.freq={rightVibrationValue.FrequencyLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({rightVibrationValue.FrequencyLow}), " +
|
||||
$"R.high.freq={rightVibrationValue.FrequencyHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyHigh})");
|
||||
_controllerConfig = _assignedControllerConfigs.FirstOrDefault();
|
||||
}
|
||||
else if (config is StandardControllerInputConfig controllerConfig)
|
||||
{
|
||||
_assignedControllerConfigs.Clear();
|
||||
|
||||
foreach (IGamepad controllerGamepad in _assignedControllerGamepads)
|
||||
{
|
||||
AssignedInputDevice assignedController = _playerInputAssignment?.Devices.FirstOrDefault(device =>
|
||||
device.Type == AssignedInputDeviceType.Controller &&
|
||||
device.Id == controllerGamepad.Id);
|
||||
|
||||
_assignedControllerConfigs.Add(ResolveControllerConfiguration(assignedController, controllerConfig, controllerGamepad));
|
||||
}
|
||||
|
||||
_controllerConfig = _assignedControllerConfigs.FirstOrDefault() ?? controllerConfig;
|
||||
|
||||
if (_keyboardGamepad != null)
|
||||
{
|
||||
AssignedInputDevice assignedKeyboard = _playerInputAssignment?.Devices.FirstOrDefault(device => device.Type == AssignedInputDeviceType.Keyboard);
|
||||
|
||||
_keyboardConfig = ResolveKeyboardConfiguration(assignedKeyboard, controllerConfig, _keyboardGamepad);
|
||||
}
|
||||
else
|
||||
{
|
||||
_keyboardConfig = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private StandardKeyboardInputConfig ResolveKeyboardConfiguration(AssignedInputDevice assignedKeyboard, InputConfig baseConfig, IGamepad keyboardGamepad)
|
||||
{
|
||||
if (keyboardGamepad == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (TryLoadAssignedProfile<StandardKeyboardInputConfig>(assignedKeyboard, KeyboardString, keyboardGamepad, baseConfig, out StandardKeyboardInputConfig profileConfig))
|
||||
{
|
||||
return profileConfig;
|
||||
}
|
||||
|
||||
if (baseConfig is StandardKeyboardInputConfig keyboardBaseConfig)
|
||||
{
|
||||
StandardKeyboardInputConfig clonedConfig = CloneConfig(keyboardBaseConfig);
|
||||
|
||||
if (clonedConfig != null)
|
||||
{
|
||||
clonedConfig.Id = keyboardGamepad.Id;
|
||||
clonedConfig.Name = keyboardGamepad.Name;
|
||||
clonedConfig.PlayerIndex = baseConfig.PlayerIndex;
|
||||
clonedConfig.EnableDynamicGamepadSwap = true;
|
||||
return clonedConfig;
|
||||
}
|
||||
}
|
||||
|
||||
StandardKeyboardInputConfig defaultConfig = InputConfigDefaults.CreateDefaultKeyboardConfiguration(
|
||||
keyboardGamepad.Id,
|
||||
keyboardGamepad.Name,
|
||||
baseConfig.ControllerType,
|
||||
baseConfig.PlayerIndex);
|
||||
defaultConfig.EnableDynamicGamepadSwap = true;
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
private StandardControllerInputConfig ResolveControllerConfiguration(AssignedInputDevice assignedController, InputConfig baseConfig, IGamepad controllerGamepad)
|
||||
{
|
||||
if (controllerGamepad == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (TryLoadAssignedProfile<StandardControllerInputConfig>(assignedController, ControllerString, controllerGamepad, baseConfig, out StandardControllerInputConfig profileConfig))
|
||||
{
|
||||
return profileConfig;
|
||||
}
|
||||
|
||||
if (baseConfig is StandardControllerInputConfig controllerBaseConfig)
|
||||
{
|
||||
StandardControllerInputConfig clonedConfig = CloneConfig(controllerBaseConfig);
|
||||
|
||||
if (clonedConfig != null)
|
||||
{
|
||||
clonedConfig.Id = controllerGamepad.Id;
|
||||
clonedConfig.Name = controllerGamepad.Name;
|
||||
clonedConfig.PlayerIndex = baseConfig.PlayerIndex;
|
||||
clonedConfig.EnableDynamicGamepadSwap = true;
|
||||
return clonedConfig;
|
||||
}
|
||||
}
|
||||
|
||||
StandardControllerInputConfig defaultConfig = InputConfigDefaults.CreateDefaultControllerConfiguration(
|
||||
controllerGamepad.Id,
|
||||
controllerGamepad.Name,
|
||||
baseConfig.ControllerType,
|
||||
baseConfig.PlayerIndex,
|
||||
controllerGamepad.Name?.Contains("Nintendo") == true);
|
||||
defaultConfig.EnableDynamicGamepadSwap = true;
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
private static T CloneConfig<T>(T config) where T : InputConfig
|
||||
{
|
||||
return JsonHelper.Deserialize(
|
||||
JsonHelper.Serialize(config, _serializerContext.InputConfig),
|
||||
_serializerContext.InputConfig) as T;
|
||||
}
|
||||
|
||||
private static bool TryLoadAssignedProfile<T>(AssignedInputDevice assignedDevice, string profileDirectory, IGamepad gamepad, InputConfig baseConfig, out T config)
|
||||
where T : InputConfig
|
||||
{
|
||||
config = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(assignedDevice?.ProfileName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string path = Path.Combine(AppDataManager.ProfilesDirPath, profileDirectory, assignedDevice.ProfileName + ".json");
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
config = JsonHelper.DeserializeFromFile(path, _serializerContext.InputConfig) as T;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
config.Id = gamepad.Id;
|
||||
config.Name = gamepad.Name;
|
||||
config.PlayerIndex = baseConfig.PlayerIndex;
|
||||
config.EnableDynamicGamepadSwap = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void UpdateDynamic()
|
||||
{
|
||||
GamepadStateSnapshot keyboardState = _keyboardGamepad?.GetMappedStateSnapshot() ?? default;
|
||||
bool keyboardHasInput = _keyboardGamepad != null && HasInput(keyboardState);
|
||||
bool keyboardNewInput = _keyboardGamepad != null && HasNewInput(keyboardState, _previousKeyboardState);
|
||||
int controllerWithNewInput = -1;
|
||||
int controllerWithHeldInput = -1;
|
||||
|
||||
// Note: dynamic swap is "last input wins", so we scan every assigned controller
|
||||
// and promote whichever one most recently produced a meaningful state change.
|
||||
for (int i = 0; i < _assignedControllerGamepads.Count; i++)
|
||||
{
|
||||
IGamepad controllerGamepad = _assignedControllerGamepads[i];
|
||||
GamepadStateSnapshot controllerState = controllerGamepad?.GetMappedStateSnapshot() ?? default;
|
||||
|
||||
if (HasNewInput(controllerState, _previousControllerStates[i]))
|
||||
{
|
||||
controllerWithNewInput = i;
|
||||
}
|
||||
|
||||
if (controllerWithHeldInput == -1 && HasInput(controllerState))
|
||||
{
|
||||
controllerWithHeldInput = i;
|
||||
}
|
||||
|
||||
_previousControllerStates[i] = controllerState;
|
||||
}
|
||||
|
||||
if (keyboardNewInput && controllerWithNewInput == -1)
|
||||
{
|
||||
_activeInputSource = DynamicInputSource.Keyboard;
|
||||
}
|
||||
else if (controllerWithNewInput != -1 && !keyboardNewInput)
|
||||
{
|
||||
_activeInputSource = DynamicInputSource.Controller;
|
||||
_activeControllerIndex = controllerWithNewInput;
|
||||
}
|
||||
else if (_activeInputSource == DynamicInputSource.Keyboard && !keyboardHasInput && controllerWithHeldInput != -1)
|
||||
{
|
||||
_activeInputSource = DynamicInputSource.Controller;
|
||||
_activeControllerIndex = controllerWithHeldInput;
|
||||
}
|
||||
else if (_activeInputSource == DynamicInputSource.Controller && controllerWithHeldInput == -1 && keyboardHasInput)
|
||||
{
|
||||
_activeInputSource = DynamicInputSource.Keyboard;
|
||||
}
|
||||
else if (_activeInputSource == DynamicInputSource.None)
|
||||
{
|
||||
_activeInputSource = _config switch
|
||||
{
|
||||
StandardKeyboardInputConfig when _keyboardGamepad != null => DynamicInputSource.Keyboard,
|
||||
StandardControllerInputConfig when _assignedControllerGamepads.Count > 0 => DynamicInputSource.Controller,
|
||||
_ when keyboardHasInput => DynamicInputSource.Keyboard,
|
||||
_ when controllerWithHeldInput != -1 => DynamicInputSource.Controller,
|
||||
_ when _keyboardGamepad != null => DynamicInputSource.Keyboard,
|
||||
_ when _assignedControllerGamepads.Count > 0 => DynamicInputSource.Controller,
|
||||
_ => DynamicInputSource.None,
|
||||
};
|
||||
|
||||
if (_activeInputSource == DynamicInputSource.Controller)
|
||||
{
|
||||
_activeControllerIndex = controllerWithHeldInput != -1 ? controllerWithHeldInput : 0;
|
||||
}
|
||||
}
|
||||
|
||||
UpdateActiveGamepad();
|
||||
|
||||
State = _activeInputSource switch
|
||||
{
|
||||
DynamicInputSource.Keyboard => keyboardState,
|
||||
DynamicInputSource.Controller when _activeControllerIndex >= 0 && _activeControllerIndex < _previousControllerStates.Count => _previousControllerStates[_activeControllerIndex],
|
||||
_ => default,
|
||||
};
|
||||
|
||||
if (_activeConfig is StandardControllerInputConfig controllerConfig && _controllerGamepad != null && _activeInputSource == DynamicInputSource.Controller)
|
||||
{
|
||||
UpdateControllerMotion(_controllerGamepad, controllerConfig);
|
||||
}
|
||||
else
|
||||
{
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
}
|
||||
|
||||
_previousKeyboardState = keyboardState;
|
||||
}
|
||||
|
||||
private void UpdateActiveGamepad()
|
||||
{
|
||||
(_gamepad, _activeConfig, GamepadDriver) = _activeInputSource switch
|
||||
{
|
||||
DynamicInputSource.Keyboard => (_keyboardGamepad, _keyboardConfig, _keyboardDriver),
|
||||
DynamicInputSource.Controller =>
|
||||
(
|
||||
_activeControllerIndex >= 0 && _activeControllerIndex < _assignedControllerGamepads.Count
|
||||
? _assignedControllerGamepads[_activeControllerIndex]
|
||||
: _assignedControllerGamepads.FirstOrDefault(),
|
||||
_activeControllerIndex >= 0 && _activeControllerIndex < _assignedControllerConfigs.Count
|
||||
? _assignedControllerConfigs[_activeControllerIndex]
|
||||
: _assignedControllerConfigs.FirstOrDefault(),
|
||||
_controllerDriver
|
||||
),
|
||||
_ => ((IGamepad?)null, (InputConfig?)null, (IGamepadDriver?)null)
|
||||
};
|
||||
|
||||
_controllerGamepad = _gamepad;
|
||||
}
|
||||
|
||||
private void UpdateControllerMotion(IGamepad gamepad, StandardControllerInputConfig controllerConfig)
|
||||
{
|
||||
if (gamepad == null || controllerConfig?.Motion == null || !controllerConfig.Motion.EnableMotion)
|
||||
{
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.GamepadDriver)
|
||||
{
|
||||
if ((gamepad.Features & GamepadFeaturesFlag.Motion) != 0)
|
||||
{
|
||||
_leftMotionInput ??= new MotionInput();
|
||||
_rightMotionInput ??= new MotionInput();
|
||||
|
||||
Vector3 accelerometer = gamepad.GetMotionData(MotionInputId.Accelerometer);
|
||||
Vector3 gyroscope = gamepad.GetMotionData(MotionInputId.Gyroscope);
|
||||
|
||||
accelerometer = new Vector3(accelerometer.X, -accelerometer.Z, accelerometer.Y);
|
||||
gyroscope = new Vector3(gyroscope.X, -gyroscope.Z, gyroscope.Y);
|
||||
|
||||
_leftMotionInput.Update(accelerometer, gyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone);
|
||||
|
||||
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
}
|
||||
}
|
||||
else if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.CemuHook && controllerConfig.Motion is CemuHookMotionConfigController cemuControllerConfig)
|
||||
{
|
||||
int clientId = (int)controllerConfig.PlayerIndex;
|
||||
|
||||
_cemuHookClient.RegisterClient(clientId, cemuControllerConfig.DsuServerHost, cemuControllerConfig.DsuServerPort);
|
||||
_cemuHookClient.RequestData(clientId, cemuControllerConfig.Slot);
|
||||
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.Slot, out _leftMotionInput);
|
||||
|
||||
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
|
||||
{
|
||||
if (!cemuControllerConfig.MirrorInput)
|
||||
{
|
||||
_cemuHookClient.RequestData(clientId, cemuControllerConfig.AltSlot);
|
||||
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.AltSlot, out _rightMotionInput);
|
||||
}
|
||||
else
|
||||
{
|
||||
_rightMotionInput = _leftMotionInput;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasInput(GamepadStateSnapshot state)
|
||||
{
|
||||
for (GamepadButtonInputId inputId = GamepadButtonInputId.A; inputId < GamepadButtonInputId.Count; inputId++)
|
||||
{
|
||||
if (state.IsPressed(inputId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return StickIsActive(state.GetStick(StickInputId.Left)) || StickIsActive(state.GetStick(StickInputId.Right));
|
||||
}
|
||||
|
||||
private static bool HasNewInput(GamepadStateSnapshot current, GamepadStateSnapshot previous)
|
||||
{
|
||||
for (GamepadButtonInputId inputId = GamepadButtonInputId.A; inputId < GamepadButtonInputId.Count; inputId++)
|
||||
{
|
||||
if (current.IsPressed(inputId) && !previous.IsPressed(inputId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return StickBecameActive(current.GetStick(StickInputId.Left), previous.GetStick(StickInputId.Left)) ||
|
||||
StickBecameActive(current.GetStick(StickInputId.Right), previous.GetStick(StickInputId.Right));
|
||||
}
|
||||
|
||||
private static bool StickIsActive((float X, float Y) stick)
|
||||
{
|
||||
const float Threshold = 0.2f;
|
||||
|
||||
return MathF.Abs(stick.X) > Threshold || MathF.Abs(stick.Y) > Threshold;
|
||||
}
|
||||
|
||||
private static bool StickBecameActive((float X, float Y) current, (float X, float Y) previous)
|
||||
{
|
||||
bool currentActive = StickIsActive(current);
|
||||
bool previousActive = StickIsActive(previous);
|
||||
|
||||
return currentActive && (!previousActive || MathF.Abs(current.X - previous.X) > 0.1f || MathF.Abs(current.Y - previous.Y) > 0.1f);
|
||||
}
|
||||
|
||||
private void DisposeOpenedGamepads()
|
||||
{
|
||||
if (!ReferenceEquals(_gamepad, _keyboardGamepad) && !_assignedControllerGamepads.Contains(_gamepad))
|
||||
{
|
||||
_gamepad?.Dispose();
|
||||
}
|
||||
|
||||
_keyboardGamepad?.Dispose();
|
||||
|
||||
foreach (IGamepad controllerGamepad in _assignedControllerGamepads.Distinct())
|
||||
{
|
||||
controllerGamepad?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ namespace Ryujinx.Input.HLE
|
||||
|
||||
private List<InputConfig> _inputConfig;
|
||||
private List<InputConfig> _requestedInputConfig;
|
||||
private List<PlayerInputAssignment> _playerInputAssignments;
|
||||
private bool _enableKeyboard;
|
||||
private bool _enableMouse;
|
||||
private Switch _device;
|
||||
@@ -54,6 +55,7 @@ namespace Ryujinx.Input.HLE
|
||||
_mouseDriver = mouseDriver;
|
||||
_inputConfig = [];
|
||||
_requestedInputConfig = [];
|
||||
_playerInputAssignments = [];
|
||||
|
||||
_gamepadDriver.OnGamepadConnected += HandleOnGamepadConnected;
|
||||
_gamepadDriver.OnGamepadDisconnected += HandleOnGamepadDisconnected;
|
||||
@@ -78,52 +80,98 @@ namespace Ryujinx.Input.HLE
|
||||
|
||||
private void HandleOnGamepadDisconnected(string obj)
|
||||
{
|
||||
// Force input reload
|
||||
List<InputConfig> requestedInputConfig;
|
||||
List<PlayerInputAssignment> playerInputAssignments;
|
||||
bool enableKeyboard;
|
||||
bool enableMouse;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
// Forcibly disconnect any controllers with this ID.
|
||||
for (int i = 0; i < _controllers.Length; i++)
|
||||
{
|
||||
if (_controllers[i]?.Id == obj)
|
||||
if (_controllers[i]?.HasAssignedControllerId(obj) == true)
|
||||
{
|
||||
_controllers[i]?.Dispose();
|
||||
_controllers[i] = null;
|
||||
}
|
||||
}
|
||||
|
||||
ReloadConfiguration(_requestedInputConfig, _enableKeyboard, _enableMouse);
|
||||
requestedInputConfig = _requestedInputConfig;
|
||||
playerInputAssignments = _playerInputAssignments;
|
||||
enableKeyboard = _enableKeyboard;
|
||||
enableMouse = _enableMouse;
|
||||
}
|
||||
|
||||
// Force input reload.
|
||||
ReloadConfiguration(requestedInputConfig, playerInputAssignments, enableKeyboard, enableMouse);
|
||||
}
|
||||
|
||||
private void HandleOnGamepadConnected(string _)
|
||||
private void HandleOnGamepadConnected(string id)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
for (int i = 0; i < _controllers.Length; i++)
|
||||
{
|
||||
if (_controllers[i] != null && PlayerHasAssignedControllerId((PlayerIndex)i, id))
|
||||
{
|
||||
_controllers[i]?.Dispose();
|
||||
_controllers[i] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force input reload
|
||||
ReloadConfiguration(_requestedInputConfig, _enableKeyboard, _enableMouse);
|
||||
ReloadConfiguration(_requestedInputConfig, _playerInputAssignments, _enableKeyboard, _enableMouse);
|
||||
}
|
||||
|
||||
private bool PlayerHasAssignedControllerId(PlayerIndex playerIndex, string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
InputConfig inputConfig = _requestedInputConfig.FirstOrDefault(config => (int)config.PlayerIndex == (int)playerIndex);
|
||||
|
||||
if (inputConfig == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PlayerInputAssignment playerInputAssignment = GetPlayerInputAssignment(inputConfig);
|
||||
|
||||
return playerInputAssignment.EnableDynamicInputSwap &&
|
||||
playerInputAssignment.Devices.Any(device =>
|
||||
device.Type == AssignedInputDeviceType.Controller &&
|
||||
string.Equals(device.Id, id, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private bool DriverConfigurationUpdate(ref NpadController controller, InputConfig config)
|
||||
private bool DriverConfigurationUpdate(ref NpadController controller, InputConfig config, PlayerInputAssignment playerInputAssignment)
|
||||
{
|
||||
IGamepadDriver targetDriver =
|
||||
config is StandardKeyboardInputConfig
|
||||
? _keyboardDriver
|
||||
: _gamepadDriver;
|
||||
Debug.Assert(_keyboardDriver != null, "Keyboard driver is not initialized!");
|
||||
Debug.Assert(_gamepadDriver != null, "Gamepad driver is not initialized!");
|
||||
|
||||
Debug.Assert(targetDriver != null, "Unknown input configuration!");
|
||||
|
||||
if (controller.GamepadDriver != targetDriver || controller.Id != config.Id)
|
||||
if (!controller.MatchesDriverConfiguration(config, playerInputAssignment))
|
||||
{
|
||||
return controller.UpdateDriverConfiguration(targetDriver, config);
|
||||
return controller.UpdateDriverConfiguration(_keyboardDriver, _gamepadDriver, config, playerInputAssignment);
|
||||
}
|
||||
|
||||
return controller.GamepadDriver != null;
|
||||
return controller.IsAvailable;
|
||||
}
|
||||
|
||||
public void ReloadConfiguration(List<InputConfig> inputConfig, bool enableKeyboard, bool enableMouse)
|
||||
{
|
||||
ReloadConfiguration(inputConfig, [], enableKeyboard, enableMouse);
|
||||
}
|
||||
|
||||
public void ReloadConfiguration(List<InputConfig> inputConfig, List<PlayerInputAssignment> playerInputAssignments, bool enableKeyboard, bool enableMouse)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_requestedInputConfig = inputConfig?.ToList() ?? [];
|
||||
_playerInputAssignments = playerInputAssignments?.ToList() ?? [];
|
||||
|
||||
NpadController[] oldControllers = _controllers.ToArray();
|
||||
|
||||
@@ -146,14 +194,17 @@ namespace Ryujinx.Input.HLE
|
||||
}
|
||||
|
||||
InputConfig activeConfig = inputConfigEntry;
|
||||
bool isValid = DriverConfigurationUpdate(ref controller, activeConfig);
|
||||
PlayerInputAssignment playerInputAssignment = GetPlayerInputAssignment(inputConfigEntry);
|
||||
|
||||
bool isValid = DriverConfigurationUpdate(ref controller, activeConfig, playerInputAssignment);
|
||||
|
||||
if (!isValid &&
|
||||
!playerInputAssignment.EnableDynamicInputSwap &&
|
||||
inputConfigEntry is StandardControllerInputConfig &&
|
||||
TryGetKeyboardFallback(inputConfigEntry, out StandardKeyboardInputConfig fallbackConfig))
|
||||
{
|
||||
activeConfig = fallbackConfig;
|
||||
isValid = DriverConfigurationUpdate(ref controller, activeConfig);
|
||||
isValid = DriverConfigurationUpdate(ref controller, activeConfig, playerInputAssignment);
|
||||
}
|
||||
|
||||
if (!isValid)
|
||||
@@ -184,6 +235,54 @@ namespace Ryujinx.Input.HLE
|
||||
}
|
||||
}
|
||||
|
||||
private PlayerInputAssignment GetPlayerInputAssignment(InputConfig inputConfig)
|
||||
{
|
||||
PlayerInputAssignment playerInputAssignment = _playerInputAssignments.FirstOrDefault(assignment => assignment.PlayerIndex == inputConfig.PlayerIndex);
|
||||
|
||||
if (playerInputAssignment != null)
|
||||
{
|
||||
PlayerInputAssignment normalizedAssignment = PlayerInputAssignmentHelper.Normalize(
|
||||
playerInputAssignment,
|
||||
PlayerInputAssignmentHelper.CreatePrimaryDevice(inputConfig));
|
||||
|
||||
if (normalizedAssignment.EnableDynamicInputSwap || normalizedAssignment.Devices.Count > 0)
|
||||
{
|
||||
return normalizedAssignment;
|
||||
}
|
||||
}
|
||||
|
||||
// Note: older configs only know about a single saved device per player,
|
||||
// so we synthesize a routing entry here until the user saves explicit assignments.
|
||||
playerInputAssignment = new PlayerInputAssignment
|
||||
{
|
||||
PlayerIndex = inputConfig.PlayerIndex,
|
||||
EnableDynamicInputSwap = inputConfig.EnableDynamicGamepadSwap,
|
||||
};
|
||||
|
||||
AssignedInputDevice primaryDevice = PlayerInputAssignmentHelper.CreatePrimaryDevice(inputConfig);
|
||||
|
||||
if (primaryDevice != null)
|
||||
{
|
||||
playerInputAssignment.Devices.Add(primaryDevice);
|
||||
}
|
||||
|
||||
if (playerInputAssignment.EnableDynamicInputSwap && inputConfig is StandardControllerInputConfig)
|
||||
{
|
||||
string keyboardId = _keyboardDriver.GamepadsIds.IsEmpty ? null : _keyboardDriver.GamepadsIds[0];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyboardId))
|
||||
{
|
||||
playerInputAssignment.Devices.Add(new AssignedInputDevice
|
||||
{
|
||||
Type = AssignedInputDeviceType.Keyboard,
|
||||
Id = keyboardId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return playerInputAssignment;
|
||||
}
|
||||
|
||||
private bool TryGetKeyboardFallback(InputConfig inputConfig, out StandardKeyboardInputConfig fallbackConfig)
|
||||
{
|
||||
fallbackConfig = null;
|
||||
@@ -210,6 +309,8 @@ namespace Ryujinx.Input.HLE
|
||||
inputConfig.ControllerType,
|
||||
inputConfig.PlayerIndex);
|
||||
|
||||
fallbackConfig.EnableDynamicGamepadSwap = inputConfig.EnableDynamicGamepadSwap;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -257,11 +358,16 @@ namespace Ryujinx.Input.HLE
|
||||
}
|
||||
|
||||
public void Initialize(Switch device, List<InputConfig> inputConfig, bool enableKeyboard, bool enableMouse)
|
||||
{
|
||||
Initialize(device, inputConfig, [], enableKeyboard, enableMouse);
|
||||
}
|
||||
|
||||
public void Initialize(Switch device, List<InputConfig> inputConfig, List<PlayerInputAssignment> playerInputAssignments, bool enableKeyboard, bool enableMouse)
|
||||
{
|
||||
_device = device;
|
||||
_device.Configuration.RefreshInputConfig = RefreshInputConfigForHLE;
|
||||
|
||||
ReloadConfiguration(inputConfig, enableKeyboard, enableMouse);
|
||||
ReloadConfiguration(inputConfig, playerInputAssignments, enableKeyboard, enableMouse);
|
||||
}
|
||||
|
||||
public void Update(float aspectRatio = 1)
|
||||
@@ -286,7 +392,7 @@ namespace Ryujinx.Input.HLE
|
||||
// Do we allow input updates and is a controller connected?
|
||||
if (_inputUpdateBlockCount == 0 && controller != null)
|
||||
{
|
||||
DriverConfigurationUpdate(ref controller, inputConfig);
|
||||
DriverConfigurationUpdate(ref controller, inputConfig, GetPlayerInputAssignment(inputConfig));
|
||||
|
||||
controller.UpdateUserConfiguration(inputConfig);
|
||||
controller.Update();
|
||||
@@ -387,7 +493,9 @@ namespace Ryujinx.Input.HLE
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _inputConfig.FirstOrDefault(x => x.PlayerIndex == (Common.Configuration.Hid.PlayerIndex)index);
|
||||
NpadController controller = _controllers[index];
|
||||
|
||||
return controller?.ActiveConfig ?? _inputConfig.FirstOrDefault(x => x.PlayerIndex == (Common.Configuration.Hid.PlayerIndex)index);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -468,11 +468,11 @@ namespace Ryujinx.Ava.Systems
|
||||
|
||||
if (ConfigurationState.Instance.System.UseInputGlobalConfig.Value && Program.UseExtraConfig)
|
||||
{
|
||||
NpadManager.Initialize(Device, ConfigurationState.InstanceExtra.Hid.InputConfig, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse);
|
||||
NpadManager.Initialize(Device, ConfigurationState.InstanceExtra.Hid.InputConfig, ConfigurationState.InstanceExtra.Hid.PlayerInputAssignments, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse);
|
||||
}
|
||||
else
|
||||
{
|
||||
NpadManager.Initialize(Device, ConfigurationState.Instance.Hid.InputConfig, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse);
|
||||
NpadManager.Initialize(Device, ConfigurationState.Instance.Hid.InputConfig, ConfigurationState.Instance.Hid.PlayerInputAssignments, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse);
|
||||
}
|
||||
|
||||
TouchScreenManager.Initialize(Device);
|
||||
|
||||
@@ -426,6 +426,16 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
/// </summary>
|
||||
public List<InputConfig> InputConfig { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Player-level input routing assignments
|
||||
/// </summary>
|
||||
public List<PlayerInputAssignment> PlayerInputAssignments { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow mapping the same input device to multiple players.
|
||||
/// </summary>
|
||||
public bool AllowDuplicateDeviceAssignment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The speed of spectrum cycling for the Rainbow LED feature.
|
||||
/// </summary>
|
||||
|
||||
@@ -157,6 +157,8 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
Hid.DisableInputWhenOutOfFocus.Value = shouldLoadFromFile ? cff.DisableInputWhenOutOfFocus : Hid.DisableInputWhenOutOfFocus.Value; // Get from global config only
|
||||
Hid.Hotkeys.Value = shouldLoadFromFile ? cff.Hotkeys : Hid.Hotkeys.Value; // Get from global config only
|
||||
Hid.InputConfig.Value = cff.InputConfig ?? [] ;
|
||||
Hid.PlayerInputAssignments.Value = cff.PlayerInputAssignments ?? [];
|
||||
Hid.AllowDuplicateDeviceAssignment.Value = cff.AllowDuplicateDeviceAssignment;
|
||||
Hid.RainbowSpeed.Value = cff.RainbowSpeed;
|
||||
|
||||
Multiplayer.LanInterfaceId.Value = cff.MultiplayerLanInterfaceId;
|
||||
|
||||
@@ -191,6 +191,11 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
/// </summary>
|
||||
public ReactiveObject<bool> IsAscendingOrder { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Show Dynamic Input Swap first-use warning
|
||||
/// </summary>
|
||||
public ReactiveObject<bool> ShowDynamicInputSwapWarning { get; private set; }
|
||||
|
||||
public UISection()
|
||||
{
|
||||
GuiColumns = new Columns();
|
||||
@@ -210,6 +215,8 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
LanguageCode = new ReactiveObject<string>();
|
||||
ShowConsole = new ReactiveObject<bool>();
|
||||
ShowConsole.Event += static (_, e) => ConsoleHelper.SetConsoleWindowState(e.NewValue);
|
||||
ShowDynamicInputSwapWarning = new ReactiveObject<bool>();
|
||||
ShowDynamicInputSwapWarning.Value = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +520,19 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
/// </summary>
|
||||
public ReactiveObject<List<InputConfig>> InputConfig { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Player-level input routing assignments.
|
||||
/// NOTE: This keeps dynamic input swap and multi-device ownership attached to the player,
|
||||
/// not to the currently edited keyboard/controller profile.
|
||||
/// </summary>
|
||||
public ReactiveObject<List<PlayerInputAssignment>> PlayerInputAssignments { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow mapping the same input device to multiple players.
|
||||
/// This is a global setting shared across all players.
|
||||
/// </summary>
|
||||
public ReactiveObject<bool> AllowDuplicateDeviceAssignment { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The speed of spectrum cycling for the Rainbow LED feature.
|
||||
/// </summary>
|
||||
@@ -525,6 +545,8 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
DisableInputWhenOutOfFocus = new ReactiveObject<bool>();
|
||||
Hotkeys = new ReactiveObject<KeyboardHotkeys>();
|
||||
InputConfig = new ReactiveObject<List<InputConfig>>();
|
||||
PlayerInputAssignments = new ReactiveObject<List<PlayerInputAssignment>>();
|
||||
AllowDuplicateDeviceAssignment = new ReactiveObject<bool>();
|
||||
RainbowSpeed = new ReactiveObject<float>();
|
||||
RainbowSpeed.Event += (_, args) => Rainbow.Speed = args.NewValue;
|
||||
}
|
||||
|
||||
@@ -143,6 +143,8 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
DisableInputWhenOutOfFocus = Hid.DisableInputWhenOutOfFocus,
|
||||
Hotkeys = Hid.Hotkeys,
|
||||
InputConfig = Hid.InputConfig,
|
||||
PlayerInputAssignments = Hid.PlayerInputAssignments,
|
||||
AllowDuplicateDeviceAssignment = Hid.AllowDuplicateDeviceAssignment,
|
||||
RainbowSpeed = Hid.RainbowSpeed,
|
||||
GraphicsBackend = Graphics.GraphicsBackend,
|
||||
PreferredGpu = Graphics.PreferredGpu,
|
||||
@@ -332,6 +334,22 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
},
|
||||
}
|
||||
];
|
||||
Hid.PlayerInputAssignments.Value =
|
||||
[
|
||||
new PlayerInputAssignment
|
||||
{
|
||||
PlayerIndex = PlayerIndex.Player1,
|
||||
EnableDynamicInputSwap = false,
|
||||
Devices =
|
||||
[
|
||||
new AssignedInputDevice
|
||||
{
|
||||
Type = AssignedInputDeviceType.Keyboard,
|
||||
Id = "0",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
Debug.EnableGdbStub.Value = false;
|
||||
Debug.GdbStubPort.Value = 55555;
|
||||
Debug.DebuggerSuspendOnStart.Value = false;
|
||||
|
||||
@@ -18,6 +18,11 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace Ryujinx.Ava.UI.Helpers
|
||||
{
|
||||
public class CheckBoxDialogResult
|
||||
{
|
||||
public bool IsChecked { get; set; }
|
||||
}
|
||||
|
||||
public static class ContentDialogHelper
|
||||
{
|
||||
private static bool _isChoiceDialogOpen;
|
||||
@@ -431,6 +436,67 @@ namespace Ryujinx.Ava.UI.Helpers
|
||||
return response == UserResult.Yes;
|
||||
}
|
||||
|
||||
internal static async Task<CheckBoxDialogResult> CreateCheckBoxDialog(string title, string primaryText, string checkBoxText, bool isCheckedDefault)
|
||||
{
|
||||
CheckBoxDialogResult result = new CheckBoxDialogResult { IsChecked = isCheckedDefault };
|
||||
|
||||
Grid content = new()
|
||||
{
|
||||
RowDefinitions = [new(), new(), new()],
|
||||
ColumnDefinitions = [new(GridLength.Auto), new()],
|
||||
MinHeight = 80,
|
||||
};
|
||||
|
||||
content.Children.Add(new SymbolIcon
|
||||
{
|
||||
Symbol = (Symbol)Symbol.Important,
|
||||
Margin = new Thickness(10),
|
||||
FontSize = 40,
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
GridColumn = 0,
|
||||
GridRow = 0,
|
||||
GridRowSpan = 2
|
||||
});
|
||||
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = primaryText,
|
||||
Margin = new Thickness(5),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
MaxWidth = 450,
|
||||
GridColumn = 1,
|
||||
GridRow = 0
|
||||
});
|
||||
|
||||
CheckBox checkBox = new()
|
||||
{
|
||||
Content = checkBoxText,
|
||||
IsChecked = isCheckedDefault,
|
||||
Margin = new Thickness(5),
|
||||
GridColumn = 1,
|
||||
GridRow = 1
|
||||
};
|
||||
|
||||
checkBox.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
result.IsChecked = checkBox.IsChecked == true;
|
||||
};
|
||||
|
||||
content.Children.Add(checkBox);
|
||||
|
||||
ContentDialog contentDialog = new()
|
||||
{
|
||||
Title = title,
|
||||
PrimaryButtonText = LocaleManager.Instance[LocaleKeys.InputDialogOk],
|
||||
Content = content,
|
||||
};
|
||||
|
||||
await ShowAsync(contentDialog);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static async Task<UserResult> CreateUpdaterChoiceDialog(string title, string primary, string secondaryText, string changelogUrl)
|
||||
{
|
||||
if (_isChoiceDialogOpen)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Avalonia.Data.Converters;
|
||||
using Ryujinx.Ava.UI.ViewModels.Input;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Ryujinx.Ava.UI.Helpers
|
||||
{
|
||||
public class ProfileNameLinkedConverter : IValueConverter
|
||||
{
|
||||
public static readonly ProfileNameLinkedConverter Instance = new();
|
||||
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string profileName || string.IsNullOrWhiteSpace(profileName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parameter is InputViewModel viewModel)
|
||||
{
|
||||
return viewModel.IsProfileNameLinked(profileName);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Ryujinx.Ava.UI.ViewModels;
|
||||
using Ryujinx.Common.Configuration.Hid;
|
||||
using System;
|
||||
|
||||
namespace Ryujinx.Ava.UI.Models.Input
|
||||
{
|
||||
public class PlayerInputDeviceAssignmentItem : BaseModel
|
||||
{
|
||||
public DeviceType DeviceType { get; init; }
|
||||
|
||||
public string Id { get; init; }
|
||||
|
||||
public string Name { get; init; }
|
||||
|
||||
public AssignedInputDeviceType AssignedType =>
|
||||
DeviceType == DeviceType.Keyboard ? AssignedInputDeviceType.Keyboard : AssignedInputDeviceType.Controller;
|
||||
|
||||
public bool IsAssigned
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasBoundProfileName => !string.IsNullOrWhiteSpace(BoundProfileName);
|
||||
|
||||
public string BoundProfileName
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasBoundProfileName));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasAssignedToPlayers => !string.IsNullOrWhiteSpace(AssignedToPlayers);
|
||||
|
||||
/// <summary>
|
||||
/// Comma-separated list of player names (e.g. "Player 1, Player 3")
|
||||
/// that have this device assigned. Empty if no other player uses it.
|
||||
/// </summary>
|
||||
public string AssignedToPlayers
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasAssignedToPlayers));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when this device is assigned to another player and
|
||||
/// AllowDuplicateDeviceAssignment is disabled, making it unclickable.
|
||||
/// </summary>
|
||||
public bool IsDisabledByOtherPlayer
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
89
src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml
Normal file
89
src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml
Normal file
@@ -0,0 +1,89 @@
|
||||
<UserControl
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
|
||||
xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:models="clr-namespace:Ryujinx.Ava.UI.Models.Input"
|
||||
xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels.Input"
|
||||
x:Class="Ryujinx.Ava.UI.Views.Input.AssignedDevicesInputView"
|
||||
x:DataType="viewModels:InputViewModel"
|
||||
x:CompileBindings="True"
|
||||
mc:Ignorable="d">
|
||||
<Grid Margin="10" RowDefinitions="Auto,Auto">
|
||||
<Border
|
||||
Grid.Row="0"
|
||||
Padding="10,8"
|
||||
BorderBrush="{DynamicResource ThemeControlBorderColor}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="5"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ItemsControl
|
||||
ItemsSource="{Binding PlayerInputDevices}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="models:PlayerInputDeviceAssignmentItem">
|
||||
<Border
|
||||
Margin="0,3"
|
||||
Padding="10,8"
|
||||
CornerRadius="4"
|
||||
Background="{DynamicResource ControlFillColorTertiary}"
|
||||
BorderBrush="{DynamicResource ThemeControlBorderColor}"
|
||||
BorderThickness="1"
|
||||
PointerPressed="DeviceRow_OnPointerPressed"
|
||||
Cursor="Hand"
|
||||
IsEnabled="{Binding !IsDisabledByOtherPlayer}">
|
||||
<Grid ColumnDefinitions="Auto,*,200">
|
||||
<CheckBox
|
||||
Grid.Column="0"
|
||||
IsChecked="{Binding IsAssigned}"
|
||||
Checked="AssignedDeviceCheckBox_OnCheckedChanged"
|
||||
Unchecked="AssignedDeviceCheckBox_OnCheckedChanged"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0"
|
||||
Padding="0" />
|
||||
<StackPanel
|
||||
Grid.Column="1"
|
||||
Margin="8,0,0,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" />
|
||||
<TextBlock
|
||||
Opacity="0.6"
|
||||
FontSize="12"
|
||||
MaxWidth="160"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap"
|
||||
MaxLines="1"
|
||||
IsVisible="{Binding HasBoundProfileName}">
|
||||
<Run Text="{ext:Locale ControllerSettingsProfile}" />
|
||||
<Run Text=":" />
|
||||
<Run Text="{Binding BoundProfileName}" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<TextBlock
|
||||
Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="10,0,0,0"
|
||||
Opacity="0.6"
|
||||
FontSize="12"
|
||||
Text="{Binding AssignedToPlayers}"
|
||||
TextWrapping="Wrap"
|
||||
MaxWidth="200"
|
||||
IsVisible="{Binding HasAssignedToPlayers}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
<CheckBox
|
||||
Grid.Row="1"
|
||||
Margin="0,8,0,0"
|
||||
IsChecked="{Binding AllowDuplicateDeviceAssignment}">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Text="{ext:Locale ControllerSettingsAllowDuplicateDeviceAssignment}" />
|
||||
</CheckBox>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
97
src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml.cs
Normal file
97
src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.VisualTree;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using Ryujinx.Ava.Common.Locale;
|
||||
using Ryujinx.Ava.UI.Models.Input;
|
||||
using Ryujinx.Ava.UI.ViewModels.Input;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Ryujinx.Ava.UI.Views.Input
|
||||
{
|
||||
public partial class AssignedDevicesInputView : UserControl
|
||||
{
|
||||
public AssignedDevicesInputView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public AssignedDevicesInputView(InputViewModel viewModel)
|
||||
{
|
||||
DataContext = viewModel;
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public static async Task Show(InputViewModel viewModel)
|
||||
{
|
||||
// Store original state to allow discarding changes
|
||||
var originalAssignments = viewModel.PlayerInputDevices
|
||||
.Select(item => new { item.Id, item.DeviceType, item.IsAssigned })
|
||||
.ToList();
|
||||
var originalAllowDuplicate = viewModel.AllowDuplicateDeviceAssignment;
|
||||
|
||||
AssignedDevicesInputView content = new(viewModel);
|
||||
|
||||
ContentDialog contentDialog = new()
|
||||
{
|
||||
Title = LocaleManager.Instance[LocaleKeys.ControllerSettingsAssignedInputDevices],
|
||||
PrimaryButtonText = LocaleManager.Instance[LocaleKeys.ControllerSettingsSave],
|
||||
SecondaryButtonText = string.Empty,
|
||||
CloseButtonText = LocaleManager.Instance[LocaleKeys.ControllerSettingsClose],
|
||||
Content = content,
|
||||
};
|
||||
|
||||
ContentDialogResult result = await contentDialog.ShowAsync();
|
||||
|
||||
if (result == ContentDialogResult.Primary)
|
||||
{
|
||||
viewModel.Save();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Discard changes by reverting to original state
|
||||
foreach (var original in originalAssignments)
|
||||
{
|
||||
var item = viewModel.PlayerInputDevices.FirstOrDefault(d =>
|
||||
d.Id == original.Id && d.DeviceType == original.DeviceType);
|
||||
if (item != null && item.IsAssigned != original.IsAssigned)
|
||||
{
|
||||
// Use Toggle to revert, which will properly refresh state
|
||||
viewModel.ToggleAssignedPlayerInputDevice(item, original.IsAssigned);
|
||||
}
|
||||
}
|
||||
// Revert AllowDuplicateDeviceAssignment to original state
|
||||
if (viewModel.AllowDuplicateDeviceAssignment != originalAllowDuplicate)
|
||||
{
|
||||
viewModel.AllowDuplicateDeviceAssignment = originalAllowDuplicate;
|
||||
}
|
||||
viewModel.RefreshModifiedState();
|
||||
}
|
||||
}
|
||||
|
||||
private void AssignedDeviceCheckBox_OnCheckedChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is CheckBox { DataContext: PlayerInputDeviceAssignmentItem item } checkBox)
|
||||
{
|
||||
_viewModel?.ToggleAssignedPlayerInputDevice(item, checkBox.IsChecked == true);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeviceRow_OnPointerPressed(object sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.Source is Control control && control.FindAncestorOfType<CheckBox>() != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (sender is Border { DataContext: PlayerInputDeviceAssignmentItem item })
|
||||
{
|
||||
_viewModel?.ToggleAssignedPlayerInputDevice(item, !item.IsAssigned);
|
||||
}
|
||||
}
|
||||
|
||||
private InputViewModel _viewModel => DataContext as InputViewModel;
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@
|
||||
Grid.Column="2"
|
||||
Margin="2"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center" ColumnDefinitions="Auto,*,Auto,Auto,Auto">
|
||||
VerticalAlignment="Center" ColumnDefinitions="Auto,*,Auto,Auto,Auto,Auto">
|
||||
<TextBlock
|
||||
Margin="5,0,10,0"
|
||||
Width="90"
|
||||
@@ -101,19 +101,46 @@
|
||||
Name="ProfileBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
MaxHeight="32"
|
||||
Padding="8,0,44,0"
|
||||
IsEnabled="{Binding ShowSettings}"
|
||||
SelectedItem="{Binding ChosenProfile, Mode=TwoWay}"
|
||||
SelectionChanged="ComboBox_SelectionChanged"
|
||||
ItemsSource="{Binding ProfilesList}"
|
||||
Text="{Binding ProfileName, Mode=TwoWay}" />
|
||||
Text="{Binding ProfileName, Mode=TwoWay}">
|
||||
<ui:FAComboBox.Styles>
|
||||
<Style Selector="TextBlock">
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
<Setter Property="TextWrapping" Value="NoWrap" />
|
||||
<Setter Property="MaxLines" Value="1" />
|
||||
<Setter Property="MaxWidth" Value="170" />
|
||||
</Style>
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="TextWrapping" Value="NoWrap" />
|
||||
<Setter Property="MaxLines" Value="1" />
|
||||
<Setter Property="Padding" Value="0,0,36,0" />
|
||||
</Style>
|
||||
</ui:FAComboBox.Styles>
|
||||
</ui:FAComboBox>
|
||||
<ui:SymbolIcon
|
||||
Grid.Column="1"
|
||||
Symbol="Link"
|
||||
FontSize="12"
|
||||
Opacity="0.6"
|
||||
IsVisible="{Binding IsProfileLinked}"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,37,0" />
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
MinWidth="0"
|
||||
Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsLoadProfileToolTip}"
|
||||
Command="{Binding LoadProfileButton}">
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsBindProfileToolTip}"
|
||||
IsEnabled="{Binding CanBindSelectedProfile}"
|
||||
Click="LinkProfileButton_OnClick">
|
||||
<ui:SymbolIcon
|
||||
Symbol="View"
|
||||
Symbol="Link"
|
||||
FontSize="15"
|
||||
Height="20" />
|
||||
</Button>
|
||||
@@ -122,19 +149,34 @@
|
||||
MinWidth="0"
|
||||
Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsSaveProfileToolTip}"
|
||||
Command="{Binding SaveProfile}">
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsLoadProfileToolTip}"
|
||||
IsEnabled="{Binding ShowSettings}"
|
||||
Click="LoadProfileButton_OnClick">
|
||||
<ui:SymbolIcon
|
||||
Symbol="Save"
|
||||
Symbol="OpenFolder"
|
||||
FontSize="15"
|
||||
Height="20" />
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="4"
|
||||
MinWidth="0"
|
||||
Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsSaveProfileToolTip}"
|
||||
IsEnabled="{Binding CanDeleteOrSaveProfile}"
|
||||
Command="{Binding SaveProfile}">
|
||||
<ui:SymbolIcon
|
||||
Symbol="Save"
|
||||
FontSize="15"
|
||||
Height="20" />
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="5"
|
||||
MinWidth="0"
|
||||
Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsRemoveProfileToolTip}"
|
||||
IsEnabled="{Binding CanDeleteOrSaveProfile}"
|
||||
Command="{Binding RemoveProfile}">
|
||||
<ui:SymbolIcon
|
||||
Symbol="Delete"
|
||||
@@ -149,7 +191,7 @@
|
||||
<Grid
|
||||
Grid.Column="0"
|
||||
Margin="2"
|
||||
HorizontalAlignment="Stretch" ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
HorizontalAlignment="Stretch" ColumnDefinitions="Auto,*,Auto,Auto,Auto">
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
Margin="5,0,10,0"
|
||||
@@ -187,7 +229,21 @@
|
||||
MinWidth="0"
|
||||
Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsAssignedInputDevicesTooltip}"
|
||||
IsEnabled="{Binding CanOpenAssignedDevices}"
|
||||
Click="AssignedDevicesButton_OnClick">
|
||||
<ui:SymbolIcon
|
||||
Symbol="Settings"
|
||||
FontSize="15"
|
||||
Height="20"/>
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="4"
|
||||
MinWidth="0"
|
||||
Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsResetKeybindsToDefault}"
|
||||
IsEnabled="{Binding ShowSettings}"
|
||||
Click="ResetCurrentDeviceToDefaultsButton_OnClick">
|
||||
<ui:SymbolIcon
|
||||
Symbol="Undo"
|
||||
@@ -210,6 +266,7 @@
|
||||
<ComboBox
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
IsEnabled="{Binding ShowSettings}"
|
||||
ItemsSource="{Binding Controllers}"
|
||||
SelectedIndex="{Binding Controller}">
|
||||
<ComboBox.ItemTemplate>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia;
|
||||
using Avalonia.Layout;
|
||||
@@ -45,12 +46,69 @@ namespace Ryujinx.Ava.UI.Views.Input
|
||||
ViewModel = new InputViewModel(this, useGlobalConfig); // Create new Input Page with the selected input config scope.
|
||||
InitializeComponent();
|
||||
|
||||
SetupProfileBoxItemTemplate();
|
||||
|
||||
if (VisualRoot is not null)
|
||||
{
|
||||
ViewModel.RetargetKeyboardDriver(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupProfileBoxItemTemplate()
|
||||
{
|
||||
var dataTemplate = new FuncDataTemplate<string>((profileName, scope) =>
|
||||
{
|
||||
var grid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto")
|
||||
};
|
||||
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = profileName,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = TextWrapping.NoWrap,
|
||||
MaxWidth = 170
|
||||
};
|
||||
Grid.SetColumn(textBlock, 0);
|
||||
|
||||
var linkIcon = new FluentAvalonia.UI.Controls.SymbolIcon
|
||||
{
|
||||
Symbol = FluentAvalonia.UI.Controls.Symbol.Link,
|
||||
FontSize = 12,
|
||||
Opacity = 0.6,
|
||||
Margin = new Thickness(10, 0, 0, 0),
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
};
|
||||
Grid.SetColumn(linkIcon, 1);
|
||||
|
||||
// Bind visibility to whether the profile is linked
|
||||
linkIcon.Bind(
|
||||
FluentAvalonia.UI.Controls.SymbolIcon.IsVisibleProperty,
|
||||
new Avalonia.Data.Binding(".")
|
||||
{
|
||||
Converter = ProfileNameLinkedConverter.Instance,
|
||||
ConverterParameter = ViewModel
|
||||
});
|
||||
|
||||
grid.Children.Add(textBlock);
|
||||
grid.Children.Add(linkIcon);
|
||||
|
||||
return grid;
|
||||
});
|
||||
|
||||
ProfileBox.ItemTemplate = dataTemplate;
|
||||
}
|
||||
|
||||
public void RefreshProfileBoxItemTemplate()
|
||||
{
|
||||
// Force the ComboBox to re-render its items
|
||||
var itemsSource = ProfileBox.ItemsSource;
|
||||
ProfileBox.ItemsSource = null;
|
||||
ProfileBox.ItemsSource = itemsSource;
|
||||
}
|
||||
|
||||
private async void PlayerIndexBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (PlayerIndexBox != null)
|
||||
@@ -102,8 +160,15 @@ namespace Ryujinx.Ava.UI.Views.Input
|
||||
if (sender is FAComboBox faComboBox)
|
||||
{
|
||||
faComboBox.IsDropDownOpen = false;
|
||||
ViewModel.RefreshModifiedState();
|
||||
}
|
||||
|
||||
ViewModel.RefreshModifiedState();
|
||||
}
|
||||
|
||||
private async void AssignedDevicesButton_OnClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
await AssignedDevicesInputView.Show(ViewModel);
|
||||
ViewModel.RefreshModifiedState();
|
||||
}
|
||||
|
||||
private async void ResetCurrentDeviceToDefaultsButton_OnClick(object sender, RoutedEventArgs e)
|
||||
@@ -155,6 +220,17 @@ namespace Ryujinx.Ava.UI.Views.Input
|
||||
}
|
||||
}
|
||||
|
||||
private void LinkProfileButton_OnClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ViewModel?.LinkCurrentProfileToCurrentDevice();
|
||||
RefreshProfileBoxItemTemplate();
|
||||
}
|
||||
|
||||
private void LoadProfileButton_OnClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ViewModel?.LoadProfile();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ViewModel.Dispose();
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
<StackPanel
|
||||
Orientation="Horizontal"
|
||||
Spacing="10">
|
||||
<CheckBox
|
||||
ToolTip.Tip="{ext:Locale DockModeToggleTooltip}"
|
||||
MinWidth="0"
|
||||
IsChecked="{Binding EnableDockedMode}">
|
||||
<TextBlock
|
||||
Text="{ext:Locale SettingsTabInputEnableDockedMode}" />
|
||||
</CheckBox>
|
||||
<CheckBox
|
||||
ToolTip.Tip="{ext:Locale UseGlobalInputTooltip}"
|
||||
MinWidth="0"
|
||||
@@ -49,11 +56,11 @@
|
||||
Text="{ext:Locale SettingsTabInputUseGlobalInput}" />
|
||||
</CheckBox>
|
||||
<CheckBox
|
||||
ToolTip.Tip="{ext:Locale DockModeToggleTooltip}"
|
||||
MinWidth="0"
|
||||
IsChecked="{Binding EnableDockedMode}">
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsDynamicInputSwapTooltip}"
|
||||
IsChecked="{Binding ElementName=InputView, Path=ViewModel.EnableDynamicGamepadSwap, Mode=TwoWay}"
|
||||
IsEnabled="{Binding ElementName=InputView, Path=ViewModel.ShowSettings}">
|
||||
<TextBlock
|
||||
Text="{ext:Locale SettingsTabInputEnableDockedMode}" />
|
||||
Text="{ext:Locale ControllerSettingsDynamicInputSwap}" />
|
||||
</CheckBox>
|
||||
<CheckBox
|
||||
ToolTip.Tip="{ext:Locale DirectKeyboardTooltip}"
|
||||
|
||||
Reference in New Issue
Block a user