diff --git a/assets/locales.json b/assets/locales.json index 2f52ee71b..533bd5cc3 100644 --- a/assets/locales.json +++ b/assets/locales.json @@ -24372,6 +24372,156 @@ "zh_TW": "只在按下時" } }, + { + "ID": "SettingsTabHotkeysCycleInputDevicePlayerX", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Cycle Input Device {0}:", + "es_ES": "", + "fr_FR": "", + "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": "ControllerOverlayTitle", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Controller Bindings", + "es_ES": "", + "fr_FR": "", + "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": "ControllerOverlayNoController", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "No controller assigned", + "es_ES": "", + "fr_FR": "", + "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": "ControllerOverlayKeyboard", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Keyboard", + "es_ES": "", + "fr_FR": "", + "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": "ControllerOverlayController", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Controller", + "es_ES": "", + "fr_FR": "", + "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": "ControllerOverlayUnknown", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Unknown", + "es_ES": "", + "fr_FR": "", + "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": "CompatibilityListLastUpdated", "Translations": { @@ -25021,6 +25171,131 @@ "zh_CN": "动态 Rich Presence", "zh_TW": "動態 Rich Presence" } + }, + { + "ID": "SettingsTabUIControllerOverlay", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Controller Overlay", + "es_ES": "", + "fr_FR": "", + "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": "SettingsTabUIControllerOverlayGameStartDuration", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Show on game start (seconds):", + "es_ES": "", + "fr_FR": "", + "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": "SettingsTabUIControllerOverlayGameStartDurationTooltip", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Duration to show controller overlay when game starts (0 = disabled)", + "es_ES": "", + "fr_FR": "", + "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": "SettingsTabUIControllerOverlayInputCycleDuration", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Show on input cycle (seconds):", + "es_ES": "", + "fr_FR": "", + "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": "SettingsTabUIControllerOverlayInputCycleDurationTooltip", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Duration to show controller overlay when cycling inputs (0 = disabled)", + "es_ES": "", + "fr_FR": "", + "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": "" + } } ] } diff --git a/src/Ryujinx.Common/Configuration/Hid/DefaultInputConfigurationProvider.cs b/src/Ryujinx.Common/Configuration/Hid/DefaultInputConfigurationProvider.cs new file mode 100644 index 000000000..be145029f --- /dev/null +++ b/src/Ryujinx.Common/Configuration/Hid/DefaultInputConfigurationProvider.cs @@ -0,0 +1,200 @@ +using System; +using Ryujinx.Common.Configuration.Hid.Controller; +using Ryujinx.Common.Configuration.Hid.Controller.Motion; +using Ryujinx.Common.Configuration.Hid.Keyboard; +using ConfigGamepadInputId = Ryujinx.Common.Configuration.Hid.Controller.GamepadInputId; +using ConfigStickInputId = Ryujinx.Common.Configuration.Hid.Controller.StickInputId; + +namespace Ryujinx.Common.Configuration.Hid +{ + /// + /// Provides default input configurations for keyboard and controller devices + /// + public static class DefaultInputConfigurationProvider + { + /// + /// Creates a default keyboard input configuration + /// + /// Device ID + /// Device name + /// Player index + /// Controller type (defaults to ProController) + /// Default keyboard input configuration + public static StandardKeyboardInputConfig CreateDefaultKeyboardConfig(string id, string name, PlayerIndex playerIndex, ControllerType controllerType = ControllerType.ProController) + { + return new StandardKeyboardInputConfig + { + Version = InputConfig.CurrentVersion, + Backend = InputBackendType.WindowKeyboard, + Id = id, + Name = name, + ControllerType = ControllerType.ProController, + PlayerIndex = playerIndex, + LeftJoycon = new LeftJoyconCommonConfig + { + DpadUp = Key.Up, + DpadDown = Key.Down, + DpadLeft = Key.Left, + DpadRight = Key.Right, + ButtonMinus = Key.Minus, + ButtonL = Key.E, + ButtonZl = Key.Q, + ButtonSl = Key.Unbound, + ButtonSr = Key.Unbound, + }, + LeftJoyconStick = new JoyconConfigKeyboardStick + { + StickUp = Key.W, + StickDown = Key.S, + StickLeft = Key.A, + StickRight = Key.D, + StickButton = Key.F, + }, + RightJoycon = new RightJoyconCommonConfig + { + ButtonA = Key.Z, + ButtonB = Key.X, + ButtonX = Key.C, + ButtonY = Key.V, + ButtonPlus = Key.Plus, + ButtonR = Key.U, + ButtonZr = Key.O, + ButtonSl = Key.Unbound, + ButtonSr = Key.Unbound, + }, + RightJoyconStick = new JoyconConfigKeyboardStick + { + StickUp = Key.I, + StickDown = Key.K, + StickLeft = Key.J, + StickRight = Key.L, + StickButton = Key.H, + }, + }; + } + + /// + /// Creates a default controller input configuration + /// + /// Device ID + /// Device name + /// Player index + /// Whether to use Nintendo-style button mapping + /// Default controller input configuration + public static StandardControllerInputConfig CreateDefaultControllerConfig(string id, string name, PlayerIndex playerIndex, bool isNintendoStyle = false) + { + // Split the ID for controller configs + string cleanId = id.Split(" ")[0]; + + return new StandardControllerInputConfig + { + Version = InputConfig.CurrentVersion, + Backend = InputBackendType.GamepadSDL2, + Id = cleanId, + Name = name, + ControllerType = ControllerType.ProController, + PlayerIndex = playerIndex, + DeadzoneLeft = 0.1f, + DeadzoneRight = 0.1f, + RangeLeft = 1.0f, + RangeRight = 1.0f, + TriggerThreshold = 0.5f, + LeftJoycon = new LeftJoyconCommonConfig + { + DpadUp = ConfigGamepadInputId.DpadUp, + DpadDown = ConfigGamepadInputId.DpadDown, + DpadLeft = ConfigGamepadInputId.DpadLeft, + DpadRight = ConfigGamepadInputId.DpadRight, + ButtonMinus = ConfigGamepadInputId.Minus, + ButtonL = ConfigGamepadInputId.LeftShoulder, + ButtonZl = ConfigGamepadInputId.LeftTrigger, + ButtonSl = ConfigGamepadInputId.Unbound, + ButtonSr = ConfigGamepadInputId.Unbound, + }, + LeftJoyconStick = new JoyconConfigControllerStick + { + Joystick = ConfigStickInputId.Left, + StickButton = ConfigGamepadInputId.LeftStick, + InvertStickX = false, + InvertStickY = false, + }, + RightJoycon = new RightJoyconCommonConfig + { + ButtonA = isNintendoStyle ? ConfigGamepadInputId.A : ConfigGamepadInputId.B, + ButtonB = isNintendoStyle ? ConfigGamepadInputId.B : ConfigGamepadInputId.A, + ButtonX = isNintendoStyle ? ConfigGamepadInputId.X : ConfigGamepadInputId.Y, + ButtonY = isNintendoStyle ? ConfigGamepadInputId.Y : ConfigGamepadInputId.X, + ButtonPlus = ConfigGamepadInputId.Plus, + ButtonR = ConfigGamepadInputId.RightShoulder, + ButtonZr = ConfigGamepadInputId.RightTrigger, + ButtonSl = ConfigGamepadInputId.Unbound, + ButtonSr = ConfigGamepadInputId.Unbound, + }, + RightJoyconStick = new JoyconConfigControllerStick + { + Joystick = ConfigStickInputId.Right, + StickButton = ConfigGamepadInputId.RightStick, + InvertStickX = false, + InvertStickY = false, + }, + Motion = new StandardMotionConfigController + { + EnableMotion = true, + MotionBackend = MotionInputBackendType.GamepadDriver, + GyroDeadzone = 1, + Sensitivity = 100, + }, + Rumble = new RumbleConfigController + { + EnableRumble = false, + WeakRumble = 1f, + StrongRumble = 1f, + }, + Led = new LedConfigController + { + EnableLed = false, + TurnOffLed = false, + UseRainbow = false, + LedColor = 0xFFFFFFFF, + } + }; + } + + /// + /// Gets the short name of a gamepad by removing SDL prefix and truncating if too long + /// + /// Full gamepad name + /// Maximum length before truncation (default: 50) + /// Short gamepad name + public static string GetShortGamepadName(string name, int maxLength = 50) + { + const string SdlGamepadNamePrefix = "SDL2 Gamepad "; + const string Ellipsis = "..."; + + // First remove SDL prefix if present + string shortName = name; + if (name.StartsWith(SdlGamepadNamePrefix)) + { + shortName = name[SdlGamepadNamePrefix.Length..]; + } + + // Then truncate if too long + if (shortName.Length > maxLength) + { + return $"{shortName.AsSpan(0, maxLength - Ellipsis.Length)}{Ellipsis}"; + } + + return shortName; + } + + /// + /// Determines if a controller uses Nintendo-style button mapping + /// + /// Controller name + /// True if Nintendo-style mapping should be used + public static bool IsNintendoStyleController(string name) + { + return name.Contains("Nintendo"); + } + } +} diff --git a/src/Ryujinx.Common/Configuration/Hid/KeyboardHotkeys.cs b/src/Ryujinx.Common/Configuration/Hid/KeyboardHotkeys.cs index efdb422e7..cc6ec55d5 100644 --- a/src/Ryujinx.Common/Configuration/Hid/KeyboardHotkeys.cs +++ b/src/Ryujinx.Common/Configuration/Hid/KeyboardHotkeys.cs @@ -15,5 +15,14 @@ namespace Ryujinx.Common.Configuration.Hid public Key CustomVSyncIntervalDecrement { get; set; } public Key TurboMode { get; set; } public bool TurboModeWhileHeld { get; set; } + public Key CycleInputDevicePlayer1 { get; set; } + public Key CycleInputDevicePlayer2 { get; set; } + public Key CycleInputDevicePlayer3 { get; set; } + public Key CycleInputDevicePlayer4 { get; set; } + public Key CycleInputDevicePlayer5 { get; set; } + public Key CycleInputDevicePlayer6 { get; set; } + public Key CycleInputDevicePlayer7 { get; set; } + public Key CycleInputDevicePlayer8 { get; set; } + public Key CycleInputDeviceHandheld { get; set; } } } diff --git a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs index 7a4836982..340a47321 100644 --- a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs +++ b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs @@ -41,5 +41,10 @@ namespace Ryujinx.Graphics.GAL.Multithreading public void SetScalingFilterLevel(float level) { } public void SetColorSpacePassthrough(bool colorSpacePassthroughEnabled) { } + + /// + /// Gets the underlying implementation window for direct access + /// + public IWindow BaseWindow => _impl.Window; } } diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/ControllerOverlay.cs b/src/Ryujinx.Graphics.Gpu/Overlay/ControllerOverlay.cs new file mode 100644 index 000000000..d22389ed5 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Overlay/ControllerOverlay.cs @@ -0,0 +1,271 @@ +using SkiaSharp; +using System; +using System.Collections.Generic; +using System.Linq; +using OriginalInputConfig = Ryujinx.Common.Configuration.Hid.InputConfig; +using OriginalPlayerIndex = Ryujinx.Common.Configuration.Hid.PlayerIndex; +using OriginalInputBackendType = Ryujinx.Common.Configuration.Hid.InputBackendType; + +namespace Ryujinx.Graphics.Gpu.Overlay +{ + /// + /// Localization strings for the controller overlay + /// + public class ControllerOverlayLocalization + { + public string TitleText { get; set; } = "Controller Bindings"; + public string NoControllerText { get; set; } = "No controller assigned"; + public string KeyboardText { get; set; } = "Keyboard"; + public string ControllerText { get; set; } = "Controller"; + public string UnknownText { get; set; } = "Unknown"; + } + + /// + /// Controller overlay that shows controller bindings matching the original AXAML design + /// + public class ControllerOverlay : Overlay + { + private const float OverlayWidth = 400; + private const float Padding = 24; + private const float Spacing = 16; + private const float PlayerSpacing = 12; + private const float PlayerRowHeight = 32; + + private const float TitleTextSize = 25; + private const float PlayerTextSize = 22; + + private float _lifespan = 0f; + private ControllerOverlayLocalization _localization; + + public ControllerOverlay(ControllerOverlayLocalization localization) : base("ControllerOverlay") + { + _localization = localization; + CreateBaseElements(); + } + + private void CreateBaseElements() + { + // Main background container + var background = new RectangleElement(0, 0, OverlayWidth, 200, // Dynamic height will be set later + new SKColor(0, 0, 0, 224)) // #E0000000 + { + Name = "Background", + CornerRadius = 12, + BorderColor = new SKColor(255, 255, 255, 64), // #40FFFFFF + BorderWidth = 1 + }; + AddElement(background); + + // Title text (will be updated with localized text) + var titleText = new TextElement(Padding + 30, Padding, _localization.TitleText, TitleTextSize, SKColors.White) + { + Name = "TitleText", + FontStyle = SKFontStyle.Bold + }; + AddElement(titleText); + } + + /// + /// Show controller bindings with localized strings + /// + public void ShowControllerBindings(List inputConfigs, int durationSeconds = 3) + { + // Update title text + var titleElement = FindElement("TitleText"); + if (titleElement != null) + { + titleElement.Text = _localization.TitleText; + } + + // Reset lifespan and opacity + _lifespan = durationSeconds; + + // Clear existing player bindings + ClearPlayerBindings(); + + // Group controllers by player index (support all players + handheld) + var playerBindings = new Dictionary>(); + + foreach (var config in inputConfigs.Where(c => c.PlayerIndex <= OriginalPlayerIndex.Handheld)) + { + if (!playerBindings.ContainsKey(config.PlayerIndex)) + { + playerBindings[config.PlayerIndex] = new List(); + } + playerBindings[config.PlayerIndex].Add(config); + } + + float currentY = Padding + 40; // After title + + // Add player bindings to UI (support 8 players + handheld) + var playerIndices = new[] + { + OriginalPlayerIndex.Player1, OriginalPlayerIndex.Player2, OriginalPlayerIndex.Player3, OriginalPlayerIndex.Player4, + OriginalPlayerIndex.Player5, OriginalPlayerIndex.Player6, OriginalPlayerIndex.Player7, OriginalPlayerIndex.Player8, + OriginalPlayerIndex.Handheld + }; + + for (int i = 0; i < playerIndices.Length; i++) + { + var playerIndex = playerIndices[i]; + float rowY = currentY + (i * (PlayerRowHeight + PlayerSpacing)); + + // Player number with colored background (circular badge) + var playerColor = GetPlayerColor(i); + var playerBadge = new RectangleElement(Padding, rowY, 24, 20, playerColor) + { + Name = $"PlayerBadge_{i}", + CornerRadius = 12 + }; + AddElement(playerBadge); + + // Player number text + string playerLabel = playerIndex == OriginalPlayerIndex.Handheld ? "H" : $"P{(int)playerIndex + 1}"; + var playerLabelElement = new TextElement(Padding + 12, rowY + 2, playerLabel, PlayerTextSize, SKColors.White) + { + Name = $"PlayerLabel_{i}", + FontStyle = SKFontStyle.Bold, + TextAlign = SKTextAlign.Center + }; + AddElement(playerLabelElement); + + // Controller info + if (playerBindings.ContainsKey(playerIndex)) + { + var controllers = playerBindings[playerIndex]; + var controllerNames = controllers.Select(c => GetControllerDisplayName(c)).ToList(); + + var controllerTextElement = new TextElement(Padding + 56, rowY + 2, string.Join(", ", controllerNames), PlayerTextSize, new SKColor(144, 238, 144)) // LightGreen + { + Name = $"ControllerText_{i}", + FontStyle = SKFontStyle.Bold + }; + AddElement(controllerTextElement); + } + else + { + var noControllerTextElement = new TextElement(Padding + 56, rowY + 2, _localization.NoControllerText, PlayerTextSize, new SKColor(128, 128, 128)) // Gray + { + Name = $"NoControllerText_{i}", + FontStyle = SKFontStyle.Italic + }; + AddElement(noControllerTextElement); + } + } + + // Calculate total height and update background + float totalHeight = Padding + 40 + (playerIndices.Length * (PlayerRowHeight + PlayerSpacing)) + Padding + 20; + var background = FindElement("Background"); + if (background != null) + { + background.Height = totalHeight; + } + + // Show the overlay (position will be set by Window class with actual dimensions) + IsVisible = true; + } + + private static SKColor GetPlayerColor(int playerIndex) + { + return playerIndex switch + { + 0 => new SKColor(255, 92, 92), // Red for Player 1 + 1 => new SKColor(54, 162, 235), // Blue for Player 2 + 2 => new SKColor(255, 206, 84), // Yellow for Player 3 + 3 => new SKColor(75, 192, 192), // Green for Player 4 + 4 => new SKColor(153, 102, 255), // Purple for Player 5 + 5 => new SKColor(255, 159, 64), // Orange for Player 6 + 6 => new SKColor(199, 199, 199), // Light Gray for Player 7 + 7 => new SKColor(83, 102, 255), // Indigo for Player 8 + 8 => new SKColor(255, 99, 132), // Pink for Handheld + _ => new SKColor(128, 128, 128) // Gray fallback + }; + } + + private string GetControllerDisplayName(OriginalInputConfig config) + { + if (string.IsNullOrEmpty(config.Name)) + { + return config.Backend switch + { + OriginalInputBackendType.WindowKeyboard => _localization.KeyboardText, + OriginalInputBackendType.GamepadSDL2 => _localization.ControllerText, + _ => _localization.UnknownText + }; + } + + // Truncate long controller names + string name = config.Name; + if (name.Length > 25) + { + name = name.Substring(0, 22) + "..."; + } + + return name; + } + + /// + /// Clear all player bindings + /// + private void ClearPlayerBindings() + { + var elementsToRemove = new List(); + + foreach (var element in GetElements()) + { + if (element.Name.StartsWith("PlayerBadge_") || + element.Name.StartsWith("PlayerLabel_") || + element.Name.StartsWith("ControllerText_") || + element.Name.StartsWith("NoControllerText_")) + { + elementsToRemove.Add(element); + } + } + + foreach (var element in elementsToRemove) + { + RemoveElement(element); + element.Dispose(); + } + } + + /// + /// Update overlay + /// + public override void Update(float deltaTime, SKSize screenSize) + { + _lifespan -= deltaTime; + + if (_lifespan <= 0) + { + IsVisible = false; + return; + } + + if (_lifespan <= 0.5f) + { + // Fade out during the last 0.5 seconds + Opacity = _lifespan / 0.5f; + } + else + { + Opacity = 1; + } + + // Update position if screen size is provided + if (screenSize.Width > 0 && screenSize.Height > 0) + { + SetPositionToTopRight(screenSize.Width, screenSize.Height); + } + } + + /// + /// Position overlay to top-right matching original AXAML positioning + /// + public void SetPositionToTopRight(float screenWidth, float screenHeight) + { + X = screenWidth - OverlayWidth - 20; // 20px margin from right + Y = 50; // 50px margin from top + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/ImageElement.cs b/src/Ryujinx.Graphics.Gpu/Overlay/ImageElement.cs new file mode 100644 index 000000000..2f2fb0811 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Overlay/ImageElement.cs @@ -0,0 +1,146 @@ +using Ryujinx.Common.Logging; +using SkiaSharp; +using System; + +namespace Ryujinx.Graphics.Gpu.Overlay +{ + /// + /// Image overlay element + /// + public class ImageElement : OverlayElement + { + private SKBitmap _bitmap; + private byte[] _imageData; + private string _imagePath; + + public SKFilterQuality FilterQuality { get; set; } = SKFilterQuality.Medium; + public bool MaintainAspectRatio { get; set; } = true; + + public ImageElement() + { + } + + public ImageElement(float x, float y, float width, float height, byte[] imageData) + { + X = x; + Y = y; + Width = width; + Height = height; + SetImageData(imageData); + } + + public ImageElement(float x, float y, float width, float height, string imagePath) + { + X = x; + Y = y; + Width = width; + Height = height; + SetImagePath(imagePath); + } + + /// + /// Set image from byte array + /// + public void SetImageData(byte[] imageData) + { + _imageData = imageData; + _imagePath = null; + LoadBitmap(); + } + + /// + /// Set image from file path + /// + public void SetImagePath(string imagePath) + { + _imagePath = imagePath; + _imageData = null; + LoadBitmap(); + } + + /// + /// Set image from existing SKBitmap + /// + public void SetBitmap(SKBitmap bitmap) + { + _bitmap?.Dispose(); + _bitmap = bitmap; + _imageData = null; + _imagePath = null; + } + + private void LoadBitmap() + { + try + { + _bitmap?.Dispose(); + _bitmap = null; + + if (_imageData != null) + { + _bitmap = SKBitmap.Decode(_imageData); + } + else if (!string.IsNullOrEmpty(_imagePath)) + { + _bitmap = SKBitmap.Decode(_imagePath); + } + } + catch (Exception ex) + { + Logger.Error?.Print(LogClass.Gpu, $"Failed to load image: {ex.Message}"); + _bitmap = null; + } + } + + public override void Render(SKCanvas canvas, float globalOpacity = 1.0f) + { + if (!IsVisible || _bitmap == null || Width <= 0 || Height <= 0) + return; + + float effectiveOpacity = Opacity * globalOpacity; + + using var paint = new SKPaint + { + FilterQuality = FilterQuality, + Color = SKColors.White.WithAlpha((byte)(255 * effectiveOpacity)) + }; + + var sourceRect = new SKRect(0, 0, _bitmap.Width, _bitmap.Height); + var destRect = new SKRect(X, Y, X + Width, Y + Height); + + if (MaintainAspectRatio) + { + // Calculate aspect ratio preserving destination rectangle + float sourceAspect = (float)_bitmap.Width / _bitmap.Height; + float destAspect = Width / Height; + + if (sourceAspect > destAspect) + { + // Source is wider, fit to width + float newHeight = Width / sourceAspect; + float yOffset = (Height - newHeight) / 2; + destRect = new SKRect(X, Y + yOffset, X + Width, Y + yOffset + newHeight); + } + else + { + // Source is taller, fit to height + float newWidth = Height * sourceAspect; + float xOffset = (Width - newWidth) / 2; + destRect = new SKRect(X + xOffset, Y, X + xOffset + newWidth, Y + Height); + } + } + + canvas.DrawBitmap(_bitmap, sourceRect, destRect, paint); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _bitmap?.Dispose(); + _bitmap = null; + } + base.Dispose(disposing); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/Overlay.cs b/src/Ryujinx.Graphics.Gpu/Overlay/Overlay.cs new file mode 100644 index 000000000..a414888e1 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Overlay/Overlay.cs @@ -0,0 +1,115 @@ +using SkiaSharp; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Ryujinx.Graphics.Gpu.Overlay +{ + /// + /// Base overlay class containing multiple elements + /// + public abstract class Overlay : IDisposable + { + private readonly List _elements = new(); + + public string Name { get; set; } = string.Empty; + public bool IsVisible { get; set; } = true; + public float Opacity { get; set; } = 1.0f; + public float X { get; set; } + public float Y { get; set; } + public int ZIndex { get; set; } = 0; + + public Overlay() + { + } + + public Overlay(string name) + { + Name = name; + } + + /// + /// Add an element to this overlay + /// + public void AddElement(OverlayElement element) + { + _elements.Add(element); + } + + /// + /// Remove an element from this overlay + /// + public void RemoveElement(OverlayElement element) + { + _elements.Remove(element); + } + + /// + /// Get all elements + /// + public IReadOnlyList GetElements() + { + return _elements.AsReadOnly(); + } + + /// + /// Find element by name + /// + public T FindElement(string name) where T : OverlayElement + { + return _elements.OfType().FirstOrDefault(e => e.Name == name); + } + + /// + /// Clear all elements + /// + public void Clear() + { + foreach (var element in _elements) + { + element.Dispose(); + } + _elements.Clear(); + } + + /// + /// Update overlay + /// + public abstract void Update(float deltaTime, SKSize screenSize = default); + + /// + /// Render this overlay + /// + public void Render(SKCanvas canvas) + { + if (!IsVisible || Opacity <= 0.0f) + return; + + // Save canvas state + canvas.Save(); + + // Apply overlay position offset + if (X != 0 || Y != 0) + { + canvas.Translate(X, Y); + } + + // Render all elements + foreach (var element in _elements) + { + if (element.IsVisible) + { + element.Render(canvas, Opacity); + } + } + + // Restore canvas state + canvas.Restore(); + } + + public void Dispose() + { + Clear(); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/OverlayElement.cs b/src/Ryujinx.Graphics.Gpu/Overlay/OverlayElement.cs new file mode 100644 index 000000000..c4c528d65 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Overlay/OverlayElement.cs @@ -0,0 +1,66 @@ +using SkiaSharp; +using System; + +namespace Ryujinx.Graphics.Gpu.Overlay +{ + /// + /// Base class for all overlay elements + /// + public abstract class OverlayElement : IDisposable + { + public float X { get; set; } + public float Y { get; set; } + public float Width { get; set; } + public float Height { get; set; } + public bool IsVisible { get; set; } = true; + public float Opacity { get; set; } = 1.0f; + public string Name { get; set; } = string.Empty; + + /// + /// Render this element to the canvas + /// + /// The canvas to draw on + /// Global opacity multiplier + public abstract void Render(SKCanvas canvas, float globalOpacity = 1.0f); + + /// + /// Check if a point is within this element's bounds + /// + public virtual bool Contains(float x, float y) + { + return x >= X && x <= X + Width && y >= Y && y <= Y + Height; + } + + /// + /// Get the bounds of this element + /// + public SKRect GetBounds() + { + return new SKRect(X, Y, X + Width, Y + Height); + } + + /// + /// Apply opacity to a color + /// + protected SKColor ApplyOpacity(SKColor color, float opacity) + { + return color.WithAlpha((byte)(color.Alpha * opacity)); + } + + /// + /// Dispose of resources + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose of resources + /// + protected virtual void Dispose(bool disposing) + { + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/OverlayManager.cs b/src/Ryujinx.Graphics.Gpu/Overlay/OverlayManager.cs new file mode 100644 index 000000000..fb0a4906f --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Overlay/OverlayManager.cs @@ -0,0 +1,160 @@ +using SkiaSharp; +using System; +using System.Collections.Generic; +using System.Linq; +using Ryujinx.Graphics.Gpu.Image; + +namespace Ryujinx.Graphics.Gpu.Overlay +{ + /// + /// Manages multiple overlays and handles rendering + /// + public class OverlayManager : IDisposable + { + private readonly List _overlays = new(); + private readonly object _lock = new(); + + /// + /// Add an overlay to the manager + /// + public void AddOverlay(Overlay overlay) + { + lock (_lock) + { + _overlays.Add(overlay); + SortOverlays(); + } + } + + /// + /// Remove an overlay from the manager + /// + public void RemoveOverlay(Overlay overlay) + { + lock (_lock) + { + _overlays.Remove(overlay); + } + } + + /// + /// Remove overlay by name + /// + public void RemoveOverlay(string name) + { + lock (_lock) + { + var overlay = _overlays.FirstOrDefault(o => o.Name == name); + if (overlay != null) + { + _overlays.Remove(overlay); + overlay.Dispose(); + } + } + } + + /// + /// Find overlay by name + /// + public Overlay FindOverlay(string name) + { + lock (_lock) + { + return _overlays.FirstOrDefault(o => o.Name == name); + } + } + + /// + /// Get all overlays + /// + public IReadOnlyList GetOverlays() + { + lock (_lock) + { + return _overlays.AsReadOnly(); + } + } + + /// + /// Clear all overlays + /// + public void Clear() + { + lock (_lock) + { + foreach (var overlay in _overlays) + { + overlay.Dispose(); + } + _overlays.Clear(); + } + } + + /// + /// Update all overlays (for animations) + /// + public void Update(float deltaTime, SKSize screenSize = default) + { + lock (_lock) + { + foreach (var overlay in _overlays.Where(o => o.IsVisible)) + { + overlay.Update(deltaTime, screenSize); + } + } + } + + /// + /// Render all visible overlays + /// + public void Render(SKCanvas canvas) + { + lock (_lock) + { + foreach (var overlay in _overlays.Where(o => o.IsVisible && o.Opacity > 0.0f)) + { + overlay.Render(canvas); + } + } + } + + /// + /// Sort overlays by Z-index + /// + private void SortOverlays() + { + _overlays.Sort((a, b) => a.ZIndex.CompareTo(b.ZIndex)); + } + + /// + /// Show overlay + /// + public void ShowOverlay(string name) + { + var overlay = FindOverlay(name); + if (overlay != null) + { + overlay.IsVisible = true; + overlay.Opacity = 1.0f; + } + } + + /// + /// Hide overlay + /// + public void HideOverlay(string name) + { + var overlay = FindOverlay(name); + if (overlay != null) + { + overlay.IsVisible = false; + overlay.Opacity = 0.0f; + } + } + + public void Dispose() + { + Clear(); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/RectangleElement.cs b/src/Ryujinx.Graphics.Gpu/Overlay/RectangleElement.cs new file mode 100644 index 000000000..b310ef2fc --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Overlay/RectangleElement.cs @@ -0,0 +1,78 @@ +using SkiaSharp; + +namespace Ryujinx.Graphics.Gpu.Overlay +{ + /// + /// Rectangle overlay element + /// + public class RectangleElement : OverlayElement + { + public SKColor BackgroundColor { get; set; } = SKColors.Transparent; + public SKColor BorderColor { get; set; } = SKColors.Transparent; + public float BorderWidth { get; set; } = 0; + public float CornerRadius { get; set; } = 0; + + public RectangleElement() + { + } + + public RectangleElement(float x, float y, float width, float height, SKColor backgroundColor) + { + X = x; + Y = y; + Width = width; + Height = height; + BackgroundColor = backgroundColor; + } + + public override void Render(SKCanvas canvas, float globalOpacity = 1.0f) + { + if (!IsVisible || Width <= 0 || Height <= 0) + return; + + float effectiveOpacity = Opacity * globalOpacity; + var bounds = new SKRect(X, Y, X + Width, Y + Height); + + // Draw background + if (BackgroundColor.Alpha > 0) + { + using var backgroundPaint = new SKPaint + { + Color = ApplyOpacity(BackgroundColor, effectiveOpacity), + Style = SKPaintStyle.Fill, + IsAntialias = true + }; + + if (CornerRadius > 0) + { + canvas.DrawRoundRect(bounds, CornerRadius, CornerRadius, backgroundPaint); + } + else + { + canvas.DrawRect(bounds, backgroundPaint); + } + } + + // Draw border + if (BorderWidth > 0 && BorderColor.Alpha > 0) + { + using var borderPaint = new SKPaint + { + Color = ApplyOpacity(BorderColor, effectiveOpacity), + Style = SKPaintStyle.Stroke, + StrokeWidth = BorderWidth, + IsAntialias = true + }; + + if (CornerRadius > 0) + { + canvas.DrawRoundRect(bounds, CornerRadius, CornerRadius, borderPaint); + } + else + { + canvas.DrawRect(bounds, borderPaint); + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/TextElement.cs b/src/Ryujinx.Graphics.Gpu/Overlay/TextElement.cs new file mode 100644 index 000000000..a1788fde6 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Overlay/TextElement.cs @@ -0,0 +1,121 @@ +using SkiaSharp; + +namespace Ryujinx.Graphics.Gpu.Overlay +{ + /// + /// Text overlay element + /// + public class TextElement : OverlayElement + { + public string Text { get; set; } = string.Empty; + public SKColor TextColor { get; set; } = SKColors.White; + public float FontSize { get; set; } = 16; + public string FontFamily { get; set; } = "Arial"; + public SKFontStyle FontStyle { get; set; } = SKFontStyle.Normal; + public SKTextAlign TextAlign { get; set; } = SKTextAlign.Left; + public bool IsAntialias { get; set; } = true; + + // Shadow properties + public bool HasShadow { get; set; } = false; + public SKColor ShadowColor { get; set; } = SKColors.Black; + public float ShadowOffsetX { get; set; } = 1; + public float ShadowOffsetY { get; set; } = 1; + public float ShadowBlur { get; set; } = 0; + + public TextElement() + { + } + + public TextElement(float x, float y, string text, float fontSize = 16, SKColor? color = null) + { + X = x; + Y = y; + Text = text; + FontSize = fontSize; + TextColor = color ?? SKColors.White; + + // Auto-calculate width and height based on text + UpdateDimensions(); + } + + public override void Render(SKCanvas canvas, float globalOpacity = 1.0f) + { + if (!IsVisible || string.IsNullOrEmpty(Text)) + return; + + float effectiveOpacity = Opacity * globalOpacity; + + using var typeface = SKTypeface.FromFamilyName(FontFamily, FontStyle); + using var paint = new SKPaint + { + Color = ApplyOpacity(TextColor, effectiveOpacity), + TextSize = FontSize, + Typeface = typeface, + TextAlign = TextAlign, + IsAntialias = IsAntialias + }; + + float textX = X; + float textY = Y + FontSize; // Baseline adjustment + + // Adjust X position based on alignment + if (TextAlign == SKTextAlign.Center) + { + textX += Width / 2; + } + else if (TextAlign == SKTextAlign.Right) + { + textX += Width; + } + + // Draw shadow if enabled + if (HasShadow) + { + using var shadowPaint = new SKPaint + { + Color = ApplyOpacity(ShadowColor, effectiveOpacity), + TextSize = FontSize, + Typeface = typeface, + TextAlign = TextAlign, + IsAntialias = IsAntialias + }; + + if (ShadowBlur > 0) + { + shadowPaint.MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, ShadowBlur); + } + + canvas.DrawText(Text, textX + ShadowOffsetX, textY + ShadowOffsetY, shadowPaint); + } + + // Draw main text + canvas.DrawText(Text, textX, textY, paint); + } + + /// + /// Update width and height based on current text and font settings + /// + public void UpdateDimensions() + { + if (string.IsNullOrEmpty(Text)) + { + Width = 0; + Height = 0; + return; + } + + using var typeface = SKTypeface.FromFamilyName(FontFamily, FontStyle); + using var paint = new SKPaint + { + TextSize = FontSize, + Typeface = typeface + }; + + var bounds = new SKRect(); + paint.MeasureText(Text, ref bounds); + + Width = bounds.Width; + Height = FontSize; // Use font size as height for consistency + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj b/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj index 4cd9c1d5c..9228fceba 100644 --- a/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj +++ b/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/src/Ryujinx.Graphics.Gpu/Window.cs b/src/Ryujinx.Graphics.Gpu/Window.cs index 5c3463f2a..ad6deb2ee 100644 --- a/src/Ryujinx.Graphics.Gpu/Window.cs +++ b/src/Ryujinx.Graphics.Gpu/Window.cs @@ -1,10 +1,15 @@ +using Ryujinx.Common.Logging; +using Ryujinx.Common.Memory; using Ryujinx.Graphics.GAL; using Ryujinx.Graphics.Gpu.Image; using Ryujinx.Graphics.Gpu.Memory; +using Ryujinx.Graphics.Gpu.Overlay; using Ryujinx.Graphics.Texture; using Ryujinx.Memory.Range; +using SkiaSharp; using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Threading; namespace Ryujinx.Graphics.Gpu @@ -15,6 +20,8 @@ namespace Ryujinx.Graphics.Gpu public class Window { private readonly GpuContext _context; + private readonly OverlayManager _overlayManager; + private DateTime _lastUpdateTime = DateTime.UtcNow; /// /// Texture presented on the window. @@ -98,6 +105,7 @@ namespace Ryujinx.Graphics.Gpu public Window(GpuContext context) { _context = context; + _overlayManager = new OverlayManager(); _frameQueue = new ConcurrentQueue(); } @@ -207,6 +215,9 @@ namespace Ryujinx.Graphics.Gpu texture.SynchronizeMemory(); + // Add overlays by modifying texture data directly + AddOverlaysToTexture(texture); + ImageCrop crop = new( (int)(pt.Crop.Left * texture.ScaleFactor), (int)MathF.Ceiling(pt.Crop.Right * texture.ScaleFactor), @@ -244,6 +255,87 @@ namespace Ryujinx.Graphics.Gpu } } + /// + /// Add overlay to the overlay manager + /// + public void AddOverlay(Overlay.Overlay overlay) + { + _overlayManager.AddOverlay(overlay); + } + + /// + /// Add overlays to the texture using SkiaSharp + /// + /// The texture to modify + private void AddOverlaysToTexture(Image.Texture texture) + { + try + { + // Calculate delta time for lifespan updates + DateTime currentTime = DateTime.UtcNow; + float deltaTime = (float)(currentTime - _lastUpdateTime).TotalSeconds; + _lastUpdateTime = currentTime; + + // Update overlay animations + _overlayManager.Update(deltaTime, new SKSize(texture.Info.Width, texture.Info.Height)); + + // Get texture data from host texture + using var pinnedData = texture.HostTexture.GetData(); + var data = pinnedData.Get().ToArray(); + if (data == null || data.Length == 0) + return; + + int width = texture.Info.Width; + int height = texture.Info.Height; + int bytesPerPixel = texture.Info.FormatInfo.BytesPerPixel; + + // Determine the SKColorType based on bytes per pixel + SKColorType colorType = bytesPerPixel switch + { + 4 => SKColorType.Rgba8888, + 3 => SKColorType.Rgb888x, + 2 => SKColorType.Rgb565, + _ => SKColorType.Rgba8888 + }; + + // Create SKBitmap from texture data + var imageInfo = new SKImageInfo(width, height, colorType, SKAlphaType.Premul); + using var bitmap = new SKBitmap(imageInfo); + + // Copy texture data to bitmap + unsafe + { + fixed (byte* dataPtr = data) + { + bitmap.SetPixels((IntPtr)dataPtr); + } + } + + // Create canvas for drawing overlays + using var canvas = new SKCanvas(bitmap); + + // Render all overlays + _overlayManager.Render(canvas); + + // Copy modified bitmap data back to texture data array + var pixels = bitmap.Bytes; + if (pixels.Length <= data.Length) + { + Array.Copy(pixels, data, pixels.Length); + } + + // Upload modified data back to texture + var memoryOwner = MemoryOwner.Rent(data.Length); + data.CopyTo(memoryOwner.Span); + texture.HostTexture.SetData(memoryOwner); // SetData will dispose the MemoryOwner + } + catch (Exception ex) + { + // Silently fail if overlay rendering doesn't work + Logger.Error?.Print(LogClass.Gpu, $"Overlay rendering failed: {ex.Message}"); + } + } + /// /// Indicate that a frame on the queue is ready to be acquired. /// @@ -267,5 +359,13 @@ namespace Ryujinx.Graphics.Gpu return false; } + + /// + /// Dispose resources + /// + public void Dispose() + { + _overlayManager?.Dispose(); + } } } diff --git a/src/Ryujinx/Common/KeyboardHotkeyState.cs b/src/Ryujinx/Common/KeyboardHotkeyState.cs index b6fb02f04..2df1d5970 100644 --- a/src/Ryujinx/Common/KeyboardHotkeyState.cs +++ b/src/Ryujinx/Common/KeyboardHotkeyState.cs @@ -15,5 +15,14 @@ namespace Ryujinx.Ava.Common CustomVSyncIntervalIncrement, CustomVSyncIntervalDecrement, TurboMode, + CycleInputDevicePlayer1, + CycleInputDevicePlayer2, + CycleInputDevicePlayer3, + CycleInputDevicePlayer4, + CycleInputDevicePlayer5, + CycleInputDevicePlayer6, + CycleInputDevicePlayer7, + CycleInputDevicePlayer8, + CycleInputDeviceHandheld, } } diff --git a/src/Ryujinx/Common/Markup/MarkupExtensions.cs b/src/Ryujinx/Common/Markup/MarkupExtensions.cs index b2ed01517..07d7b3613 100644 --- a/src/Ryujinx/Common/Markup/MarkupExtensions.cs +++ b/src/Ryujinx/Common/Markup/MarkupExtensions.cs @@ -1,3 +1,4 @@ +using Avalonia.Data.Converters; using Avalonia.Markup.Xaml.MarkupExtensions; using Projektanker.Icons.Avalonia; using Ryujinx.Ava.Common.Locale; @@ -18,11 +19,19 @@ namespace Ryujinx.Ava.Common.Markup internal class LocaleExtension(LocaleKeys key) : BasicMarkupExtension { + public IValueConverter Converter { get; set; } + public override string Name => "Translation"; protected override string Value => LocaleManager.Instance[key]; protected override void ConfigureBindingExtension(CompiledBindingExtension bindingExtension) - => bindingExtension.Source = LocaleManager.Instance; + { + bindingExtension.Source = LocaleManager.Instance; + if (Converter != null) + { + bindingExtension.Converter = Converter; + } + } } internal class WindowTitleExtension(LocaleKeys key, bool includeVersion) : BasicMarkupExtension diff --git a/src/Ryujinx/Systems/AppHost.cs b/src/Ryujinx/Systems/AppHost.cs index 1c5f64309..756771de5 100644 --- a/src/Ryujinx/Systems/AppHost.cs +++ b/src/Ryujinx/Systems/AppHost.cs @@ -33,6 +33,7 @@ using Ryujinx.Common.Utilities; using Ryujinx.Graphics.GAL; using Ryujinx.Graphics.GAL.Multithreading; using Ryujinx.Graphics.Gpu; +using Ryujinx.Graphics.Gpu.Overlay; using Ryujinx.Graphics.OpenGL; using Ryujinx.Graphics.Vulkan; using Ryujinx.HLE.FileSystem; @@ -40,6 +41,11 @@ using Ryujinx.HLE.HOS; using Ryujinx.HLE.HOS.Services.Account.Acc; using Ryujinx.Input; using Ryujinx.Input.HLE; +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 System.Linq; using SkiaSharp; using SPB.Graphics.Vulkan; using System; @@ -124,6 +130,7 @@ namespace Ryujinx.Ava.Systems private readonly bool _isFirmwareTitle; private readonly Lock _lockObject = new(); + private ControllerOverlay _controllerOverlay; public event EventHandler AppExit; public event EventHandler StatusUpdatedEvent; @@ -927,6 +934,18 @@ namespace Ryujinx.Ava.Systems _viewModel.UiHandler ) ); + + // Initialize controller overlay with localization + var localization = new ControllerOverlayLocalization + { + TitleText = LocaleManager.Instance[LocaleKeys.ControllerOverlayTitle], + NoControllerText = LocaleManager.Instance[LocaleKeys.ControllerOverlayNoController], + KeyboardText = LocaleManager.Instance[LocaleKeys.ControllerOverlayKeyboard], + ControllerText = LocaleManager.Instance[LocaleKeys.ControllerOverlayController], + UnknownText = LocaleManager.Instance[LocaleKeys.ControllerOverlayUnknown] + }; + _controllerOverlay = new ControllerOverlay(localization); + Device.Gpu.Window.AddOverlay(_controllerOverlay); } private static IHardwareDeviceDriver InitializeAudio() @@ -1333,6 +1352,33 @@ namespace Ryujinx.Ava.Systems _viewModel.Volume = Device.GetVolume(); break; + case KeyboardHotkeyState.CycleInputDevicePlayer1: + CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player1); + break; + case KeyboardHotkeyState.CycleInputDevicePlayer2: + CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player2); + break; + case KeyboardHotkeyState.CycleInputDevicePlayer3: + CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player3); + break; + case KeyboardHotkeyState.CycleInputDevicePlayer4: + CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player4); + break; + case KeyboardHotkeyState.CycleInputDevicePlayer5: + CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player5); + break; + case KeyboardHotkeyState.CycleInputDevicePlayer6: + CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player6); + break; + case KeyboardHotkeyState.CycleInputDevicePlayer7: + CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player7); + break; + case KeyboardHotkeyState.CycleInputDevicePlayer8: + CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player8); + break; + case KeyboardHotkeyState.CycleInputDeviceHandheld: + CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Handheld); + break; case KeyboardHotkeyState.None: (_keyboardInterface as AvaloniaKeyboard).Clear(); break; @@ -1366,6 +1412,118 @@ namespace Ryujinx.Ava.Systems return true; } + private void CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex playerIndex) + { + // Get current input configuration + List currentConfig = new(ConfigurationState.Instance.Hid.InputConfig.Value); + + // Find current configuration for this player + InputConfig playerConfig = currentConfig.FirstOrDefault(x => x.PlayerIndex == (PlayerIndex)playerIndex); + + // Get available devices from InputManager + List<(DeviceType Type, string Id, string Name)> availableDevices = []; + + // Add disabled option + availableDevices.Add((DeviceType.None, "disabled", "Disabled")); + + // Add keyboard devices + foreach (string id in _inputManager.KeyboardDriver.GamepadsIds) + { + using var gamepad = _inputManager.KeyboardDriver.GetGamepad(id); + if (gamepad != null) + { + availableDevices.Add((DeviceType.Keyboard, id, gamepad.Name)); + } + } + + // Add controller devices + int controllerNumber = 0; + foreach (string id in _inputManager.GamepadDriver.GamepadsIds) + { + using var gamepad = _inputManager.GamepadDriver.GetGamepad(id); + if (gamepad != null) + { + string name = $"{DefaultInputConfigurationProvider.GetShortGamepadName(gamepad.Name)} ({controllerNumber++})"; + availableDevices.Add((DeviceType.Controller, id, name)); + } + } + + // Find current device index + int currentIndex = 0; + if (playerConfig != null) + { + DeviceType currentType = DeviceType.None; + if (playerConfig is StandardKeyboardInputConfig) + currentType = DeviceType.Keyboard; + else if (playerConfig is StandardControllerInputConfig) + currentType = DeviceType.Controller; + + currentIndex = availableDevices.FindIndex(x => x.Type == currentType && x.Id == playerConfig.Id); + if (currentIndex == -1) currentIndex = 0; + } + + // Cycle to next device + int nextIndex = (currentIndex + 1) % availableDevices.Count; + var nextDevice = availableDevices[nextIndex]; + + // Remove existing configuration for this player + currentConfig.RemoveAll(x => x.PlayerIndex == (PlayerIndex)playerIndex); + + // Add new configuration if not disabled + if (nextDevice.Type != DeviceType.None) + { + InputConfig newConfig = CreateDefaultInputConfig(nextDevice, (PlayerIndex)playerIndex); + if (newConfig != null) + { + currentConfig.Add(newConfig); + } + } + + // Apply the new configuration + ConfigurationState.Instance.Hid.InputConfig.Value = currentConfig; + ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath); + + // Reload the input system + NpadManager.ReloadConfiguration(currentConfig, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse); + + // Show controller overlay + ShowControllerOverlay(currentConfig, ConfigurationState.Instance.ControllerOverlayInputCycleDuration.Value); + } + + private InputConfig CreateDefaultInputConfig((DeviceType Type, string Id, string Name) device, PlayerIndex playerIndex) + { + if (device.Type == DeviceType.Keyboard) + { + return DefaultInputConfigurationProvider.CreateDefaultKeyboardConfig(device.Id, device.Name, playerIndex); + } + else if (device.Type == DeviceType.Controller) + { + bool isNintendoStyle = DefaultInputConfigurationProvider.IsNintendoStyleController(device.Name); + return DefaultInputConfigurationProvider.CreateDefaultControllerConfig(device.Id, device.Name, playerIndex, isNintendoStyle); + } + + return null; + } + + public void ShowControllerOverlay(List inputConfigs, int duration) + { + try + { + if (_controllerOverlay != null) + { + _controllerOverlay.ShowControllerBindings(inputConfigs, duration); + } + else + { + Logger.Warning?.Print(LogClass.Application, "AppHost: Cannot show overlay - ControllerOverlay is null"); + } + } + catch (Exception ex) + { + Logger.Error?.Print(LogClass.Application, $"Failed to show controller overlay: {ex.Message}"); + } + } + private KeyboardHotkeyState GetHotkeyState() { KeyboardHotkeyState state = KeyboardHotkeyState.None; @@ -1418,6 +1576,42 @@ namespace Ryujinx.Ava.Systems { state = KeyboardHotkeyState.TurboMode; } + else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer1)) + { + state = KeyboardHotkeyState.CycleInputDevicePlayer1; + } + else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer2)) + { + state = KeyboardHotkeyState.CycleInputDevicePlayer2; + } + else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer3)) + { + state = KeyboardHotkeyState.CycleInputDevicePlayer3; + } + else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer4)) + { + state = KeyboardHotkeyState.CycleInputDevicePlayer4; + } + else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer5)) + { + state = KeyboardHotkeyState.CycleInputDevicePlayer5; + } + else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer6)) + { + state = KeyboardHotkeyState.CycleInputDevicePlayer6; + } + else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer7)) + { + state = KeyboardHotkeyState.CycleInputDevicePlayer7; + } + else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer8)) + { + state = KeyboardHotkeyState.CycleInputDevicePlayer8; + } + else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDeviceHandheld)) + { + state = KeyboardHotkeyState.CycleInputDeviceHandheld; + } return state; } diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs b/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs index c21383349..eaa92c92a 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs @@ -15,7 +15,7 @@ namespace Ryujinx.Ava.Systems.Configuration /// /// The current version of the file format /// - public const int CurrentVersion = 70; + public const int CurrentVersion = 72; /// /// Version of the configuration file format @@ -217,6 +217,16 @@ namespace Ryujinx.Ava.Systems.Configuration /// public HideCursorMode HideCursor { get; set; } + /// + /// Duration to show controller overlay when game starts (seconds, 0 = disabled) + /// + public int ControllerOverlayGameStartDuration { get; set; } + + /// + /// Duration to show controller overlay when input is cycled (seconds, 0 = disabled) + /// + public int ControllerOverlayInputCycleDuration { get; set; } + /// /// Enables or disables Vertical Sync /// diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs index afabdb4e3..3cf2e04d7 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs @@ -53,6 +53,8 @@ namespace Ryujinx.Ava.Systems.Configuration ShowOldUI.Value = shouldLoadFromFile ? cff.ShowTitleBar : ShowOldUI.Value; // Get from global config only EnableHardwareAcceleration.Value = shouldLoadFromFile ? cff.EnableHardwareAcceleration : EnableHardwareAcceleration.Value; // Get from global config only HideCursor.Value = cff.HideCursor; + ControllerOverlayGameStartDuration.Value = cff.ControllerOverlayGameStartDuration; + ControllerOverlayInputCycleDuration.Value = cff.ControllerOverlayInputCycleDuration; Logger.EnableFileLog.Value = cff.EnableFileLog; Logger.EnableDebug.Value = cff.LoggingEnableDebug; @@ -479,7 +481,40 @@ namespace Ryujinx.Ava.Systems.Configuration }; } ), - (69, static cff => cff.SkipUserProfiles = false) + (69, static cff => cff.SkipUserProfiles = false), + (71, static cff => + { + cff.ControllerOverlayGameStartDuration = 3; + cff.ControllerOverlayInputCycleDuration = 2; + }), + (72, static cff => + { + cff.Hotkeys = new KeyboardHotkeys + { + ToggleVSyncMode = cff.Hotkeys.ToggleVSyncMode, + Screenshot = cff.Hotkeys.Screenshot, + ShowUI = cff.Hotkeys.ShowUI, + Pause = cff.Hotkeys.Pause, + ToggleMute = cff.Hotkeys.ToggleMute, + ResScaleUp = cff.Hotkeys.ResScaleUp, + ResScaleDown = cff.Hotkeys.ResScaleDown, + VolumeUp = cff.Hotkeys.VolumeUp, + VolumeDown = cff.Hotkeys.VolumeDown, + CustomVSyncIntervalIncrement = cff.Hotkeys.CustomVSyncIntervalIncrement, + CustomVSyncIntervalDecrement = cff.Hotkeys.CustomVSyncIntervalDecrement, + TurboMode = cff.Hotkeys.TurboMode, + TurboModeWhileHeld = cff.Hotkeys.TurboModeWhileHeld, + CycleInputDevicePlayer1 = Key.Unbound, + CycleInputDevicePlayer2 = Key.Unbound, + CycleInputDevicePlayer3 = Key.Unbound, + CycleInputDevicePlayer4 = Key.Unbound, + CycleInputDevicePlayer5 = Key.Unbound, + CycleInputDevicePlayer6 = Key.Unbound, + CycleInputDevicePlayer7 = Key.Unbound, + CycleInputDevicePlayer8 = Key.Unbound, + CycleInputDeviceHandheld = Key.Unbound + }; + }) ); } } diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs index 29a390b26..2f9a0787c 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs @@ -846,6 +846,16 @@ namespace Ryujinx.Ava.Systems.Configuration /// public ReactiveObject HideCursor { get; private set; } + /// + /// Duration to show controller overlay when game starts (seconds, 0 = disabled) + /// + public ReactiveObject ControllerOverlayGameStartDuration { get; private set; } + + /// + /// Duration to show controller overlay when input is cycled (seconds, 0 = disabled) + /// + public ReactiveObject ControllerOverlayInputCycleDuration { get; private set; } + private ConfigurationState() { UI = new UISection(); @@ -863,6 +873,8 @@ namespace Ryujinx.Ava.Systems.Configuration RememberWindowState = new ReactiveObject(); ShowOldUI = new ReactiveObject(); EnableHardwareAcceleration = new ReactiveObject(); + ControllerOverlayGameStartDuration = new ReactiveObject(); + ControllerOverlayInputCycleDuration = new ReactiveObject(); } public HleConfiguration CreateHleConfiguration() => diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.cs index 4a565d5d3..0d4211e1b 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.cs @@ -65,6 +65,8 @@ namespace Ryujinx.Ava.Systems.Configuration ShowTitleBar = ShowOldUI, EnableHardwareAcceleration = EnableHardwareAcceleration, HideCursor = HideCursor, + ControllerOverlayGameStartDuration = ControllerOverlayGameStartDuration, + ControllerOverlayInputCycleDuration = ControllerOverlayInputCycleDuration, VSyncMode = Graphics.VSyncMode, EnableCustomVSyncInterval = Graphics.EnableCustomVSyncInterval, CustomVSyncInterval = Graphics.CustomVSyncInterval, @@ -190,6 +192,8 @@ namespace Ryujinx.Ava.Systems.Configuration ShowOldUI.Value = !OperatingSystem.IsWindows(); EnableHardwareAcceleration.Value = true; HideCursor.Value = HideCursorMode.OnIdle; + ControllerOverlayGameStartDuration.Value = 3; + ControllerOverlayInputCycleDuration.Value = 2; Graphics.VSyncMode.Value = VSyncMode.Switch; Graphics.CustomVSyncInterval.Value = 120; Graphics.EnableCustomVSyncInterval.Value = false; @@ -269,7 +273,16 @@ namespace Ryujinx.Ava.Systems.Configuration CustomVSyncIntervalIncrement = Key.Unbound, CustomVSyncIntervalDecrement = Key.Unbound, TurboMode = Key.Unbound, - TurboModeWhileHeld = false + TurboModeWhileHeld = false, + CycleInputDevicePlayer1 = Key.Unbound, + CycleInputDevicePlayer2 = Key.Unbound, + CycleInputDevicePlayer3 = Key.Unbound, + CycleInputDevicePlayer4 = Key.Unbound, + CycleInputDevicePlayer5 = Key.Unbound, + CycleInputDevicePlayer6 = Key.Unbound, + CycleInputDevicePlayer7 = Key.Unbound, + CycleInputDevicePlayer8 = Key.Unbound, + CycleInputDeviceHandheld = Key.Unbound }; Hid.RainbowSpeed.Value = 1f; Hid.InputConfig.Value = diff --git a/src/Ryujinx/UI/Helpers/Converters/PlayerHotkeyLabelConverter.cs b/src/Ryujinx/UI/Helpers/Converters/PlayerHotkeyLabelConverter.cs new file mode 100644 index 000000000..9ac7392a6 --- /dev/null +++ b/src/Ryujinx/UI/Helpers/Converters/PlayerHotkeyLabelConverter.cs @@ -0,0 +1,28 @@ +using Avalonia.Data.Converters; +using Ryujinx.Ava.Common.Locale; +using System; +using System.Globalization; + +namespace Ryujinx.Ava.UI.Helpers +{ + internal class PlayerHotkeyLabelConverter : IValueConverter + { + public static readonly PlayerHotkeyLabelConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string playerName && !string.IsNullOrEmpty(playerName)) + { + string baseText = LocaleManager.Instance[LocaleKeys.SettingsTabHotkeysCycleInputDevicePlayerX]; + return string.Format(baseText, playerName); + } + + return string.Empty; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/Ryujinx/UI/Models/Input/HotkeyConfig.cs b/src/Ryujinx/UI/Models/Input/HotkeyConfig.cs index 9e557d7b1..b6de26aa0 100644 --- a/src/Ryujinx/UI/Models/Input/HotkeyConfig.cs +++ b/src/Ryujinx/UI/Models/Input/HotkeyConfig.cs @@ -32,6 +32,24 @@ namespace Ryujinx.Ava.UI.Models.Input [ObservableProperty] private bool _turboModeWhileHeld; + [ObservableProperty] private Key _cycleInputDevicePlayer1; + + [ObservableProperty] private Key _cycleInputDevicePlayer2; + + [ObservableProperty] private Key _cycleInputDevicePlayer3; + + [ObservableProperty] private Key _cycleInputDevicePlayer4; + + [ObservableProperty] private Key _cycleInputDevicePlayer5; + + [ObservableProperty] private Key _cycleInputDevicePlayer6; + + [ObservableProperty] private Key _cycleInputDevicePlayer7; + + [ObservableProperty] private Key _cycleInputDevicePlayer8; + + [ObservableProperty] private Key _cycleInputDeviceHandheld; + public HotkeyConfig(KeyboardHotkeys config) { if (config == null) @@ -50,6 +68,15 @@ namespace Ryujinx.Ava.UI.Models.Input CustomVSyncIntervalDecrement = config.CustomVSyncIntervalDecrement; TurboMode = config.TurboMode; TurboModeWhileHeld = config.TurboModeWhileHeld; + CycleInputDevicePlayer1 = config.CycleInputDevicePlayer1; + CycleInputDevicePlayer2 = config.CycleInputDevicePlayer2; + CycleInputDevicePlayer3 = config.CycleInputDevicePlayer3; + CycleInputDevicePlayer4 = config.CycleInputDevicePlayer4; + CycleInputDevicePlayer5 = config.CycleInputDevicePlayer5; + CycleInputDevicePlayer6 = config.CycleInputDevicePlayer6; + CycleInputDevicePlayer7 = config.CycleInputDevicePlayer7; + CycleInputDevicePlayer8 = config.CycleInputDevicePlayer8; + CycleInputDeviceHandheld = config.CycleInputDeviceHandheld; } public KeyboardHotkeys GetConfig() => @@ -67,7 +94,16 @@ namespace Ryujinx.Ava.UI.Models.Input CustomVSyncIntervalIncrement = CustomVSyncIntervalIncrement, CustomVSyncIntervalDecrement = CustomVSyncIntervalDecrement, TurboMode = TurboMode, - TurboModeWhileHeld = TurboModeWhileHeld + TurboModeWhileHeld = TurboModeWhileHeld, + CycleInputDevicePlayer1 = CycleInputDevicePlayer1, + CycleInputDevicePlayer2 = CycleInputDevicePlayer2, + CycleInputDevicePlayer3 = CycleInputDevicePlayer3, + CycleInputDevicePlayer4 = CycleInputDevicePlayer4, + CycleInputDevicePlayer5 = CycleInputDevicePlayer5, + CycleInputDevicePlayer6 = CycleInputDevicePlayer6, + CycleInputDevicePlayer7 = CycleInputDevicePlayer7, + CycleInputDevicePlayer8 = CycleInputDevicePlayer8, + CycleInputDeviceHandheld = CycleInputDeviceHandheld }; } } diff --git a/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs b/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs index 81aae6b74..7a775aa7c 100644 --- a/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs @@ -526,18 +526,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input } } - private static string GetShortGamepadName(string str) - { - const string Ellipsis = "..."; - const int MaxSize = 50; - if (str.Length > MaxSize) - { - return $"{str.AsSpan(0, MaxSize - Ellipsis.Length)}{Ellipsis}"; - } - - return str; - } private static string GetShortGamepadId(string str) { @@ -551,7 +540,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input { string GetGamepadName(IGamepad gamepad, int controllerNumber) { - return $"{GetShortGamepadName(gamepad.Name)} ({controllerNumber})"; + return $"{DefaultInputConfigurationProvider.GetShortGamepadName(gamepad.Name)} ({controllerNumber})"; } string GetUniqueGamepadName(IGamepad gamepad, ref int controllerNumber) @@ -579,7 +568,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input if (gamepad != null) { - Devices.Add((DeviceType.Keyboard, id, $"{GetShortGamepadName(gamepad.Name)}")); + Devices.Add((DeviceType.Keyboard, id, $"{DefaultInputConfigurationProvider.GetShortGamepadName(gamepad.Name)}")); } } @@ -652,138 +641,21 @@ namespace Ryujinx.Ava.UI.ViewModels.Input InputConfig config; if (activeDevice.Type == DeviceType.Keyboard) { - string id = activeDevice.Id; - string name = activeDevice.Name; - - config = new StandardKeyboardInputConfig - { - Version = InputConfig.CurrentVersion, - Backend = InputBackendType.WindowKeyboard, - Id = id, - Name = name, - ControllerType = ControllerType.ProController, - LeftJoycon = new LeftJoyconCommonConfig - { - DpadUp = Key.Up, - DpadDown = Key.Down, - DpadLeft = Key.Left, - DpadRight = Key.Right, - ButtonMinus = Key.Minus, - ButtonL = Key.E, - ButtonZl = Key.Q, - ButtonSl = Key.Unbound, - ButtonSr = Key.Unbound, - }, - LeftJoyconStick = - new JoyconConfigKeyboardStick - { - StickUp = Key.W, - StickDown = Key.S, - StickLeft = Key.A, - StickRight = Key.D, - StickButton = Key.F, - }, - RightJoycon = new RightJoyconCommonConfig - { - ButtonA = Key.Z, - ButtonB = Key.X, - ButtonX = Key.C, - ButtonY = Key.V, - ButtonPlus = Key.Plus, - ButtonR = Key.U, - ButtonZr = Key.O, - ButtonSl = Key.Unbound, - ButtonSr = Key.Unbound, - }, - RightJoyconStick = new JoyconConfigKeyboardStick - { - StickUp = Key.I, - StickDown = Key.K, - StickLeft = Key.J, - StickRight = Key.L, - StickButton = Key.H, - }, - }; + config = DefaultInputConfigurationProvider.CreateDefaultKeyboardConfig(activeDevice.Id, activeDevice.Name, _playerId); } else if (activeDevice.Type == DeviceType.Controller) { - bool isNintendoStyle = Devices.ToList().FirstOrDefault(x => x.Id == activeDevice.Id).Name.Contains("Nintendo"); - - string id = activeDevice.Id.Split(" ")[0]; - string name = activeDevice.Name; - - config = new StandardControllerInputConfig - { - Version = InputConfig.CurrentVersion, - Backend = InputBackendType.GamepadSDL2, - Id = id, - Name = name, - ControllerType = ControllerType.ProController, - DeadzoneLeft = 0.1f, - DeadzoneRight = 0.1f, - RangeLeft = 1.0f, - RangeRight = 1.0f, - TriggerThreshold = 0.5f, - LeftJoycon = new LeftJoyconCommonConfig - { - DpadUp = ConfigGamepadInputId.DpadUp, - DpadDown = ConfigGamepadInputId.DpadDown, - DpadLeft = ConfigGamepadInputId.DpadLeft, - DpadRight = ConfigGamepadInputId.DpadRight, - ButtonMinus = ConfigGamepadInputId.Minus, - ButtonL = ConfigGamepadInputId.LeftShoulder, - ButtonZl = ConfigGamepadInputId.LeftTrigger, - ButtonSl = ConfigGamepadInputId.Unbound, - ButtonSr = ConfigGamepadInputId.Unbound, - }, - LeftJoyconStick = new JoyconConfigControllerStick - { - Joystick = ConfigStickInputId.Left, - StickButton = ConfigGamepadInputId.LeftStick, - InvertStickX = false, - InvertStickY = false, - }, - RightJoycon = new RightJoyconCommonConfig - { - ButtonA = isNintendoStyle ? ConfigGamepadInputId.A : ConfigGamepadInputId.B, - ButtonB = isNintendoStyle ? ConfigGamepadInputId.B : ConfigGamepadInputId.A, - ButtonX = isNintendoStyle ? ConfigGamepadInputId.X : ConfigGamepadInputId.Y, - ButtonY = isNintendoStyle ? ConfigGamepadInputId.Y : ConfigGamepadInputId.X, - ButtonPlus = ConfigGamepadInputId.Plus, - ButtonR = ConfigGamepadInputId.RightShoulder, - ButtonZr = ConfigGamepadInputId.RightTrigger, - ButtonSl = ConfigGamepadInputId.Unbound, - ButtonSr = ConfigGamepadInputId.Unbound, - }, - RightJoyconStick = new JoyconConfigControllerStick - { - Joystick = ConfigStickInputId.Right, - StickButton = ConfigGamepadInputId.RightStick, - InvertStickX = false, - InvertStickY = false, - }, - Motion = new StandardMotionConfigController - { - MotionBackend = MotionInputBackendType.GamepadDriver, - EnableMotion = true, - Sensitivity = 100, - GyroDeadzone = 1, - }, - Rumble = new RumbleConfigController - { - StrongRumble = 1f, - WeakRumble = 1f, - EnableRumble = false, - }, - }; + bool isNintendoStyle = DefaultInputConfigurationProvider.IsNintendoStyleController(activeDevice.Name); + config = DefaultInputConfigurationProvider.CreateDefaultControllerConfig(activeDevice.Id, activeDevice.Name, _playerId, isNintendoStyle); } else { - config = new InputConfig(); + config = new InputConfig + { + PlayerIndex = _playerId + }; } - config.PlayerIndex = _playerId; - return config; } diff --git a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs index 4ca21e788..4c73f0aa4 100644 --- a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs @@ -32,6 +32,7 @@ using Ryujinx.Ava.UI.Windows; using Ryujinx.Ava.Utilities; using Ryujinx.Common; using Ryujinx.Common.Configuration; +using Ryujinx.Common.Configuration.Hid; using Ryujinx.Common.Helper; using Ryujinx.Common.Logging; using Ryujinx.Common.UI; @@ -1599,9 +1600,9 @@ namespace Ryujinx.Ava.UI.ViewModels // Code where conditions will be executed after loading user configuration if (ConfigurationState.Instance.Graphics.BackendThreading.Value.ToString() != backendThreadingInit) { - Rebooter.RebootAppWithGame(application.Path, + Rebooter.RebootAppWithGame(application.Path, [ - "--bt", + "--bt", ConfigurationState.Instance.Graphics.BackendThreading.Value.ToString() ]); @@ -1690,11 +1691,34 @@ namespace Ryujinx.Ava.UI.ViewModels SetMainContent(RendererHostControl); RendererHostControl.Focus(); + + // Show controller overlay + ShowControllerOverlay(); }); public static void UpdateGameMetadata(string titleId, TimeSpan playTime) => ApplicationLibrary.LoadAndSaveMetaData(titleId, appMetadata => appMetadata.UpdatePostGame(playTime)); + private void ShowControllerOverlay() + { + try + { + var inputConfigs = ConfigurationState.Instance.System.UseInputGlobalConfig.Value && Program.UseExtraConfig + ? ConfigurationState.InstanceExtra.Hid.InputConfig.Value + : ConfigurationState.Instance.Hid.InputConfig.Value; + + // Always show overlay - if no configs, it will show test data + int duration = ConfigurationState.Instance.ControllerOverlayGameStartDuration.Value; + // Show overlay through the GPU context window directly + AppHost.ShowControllerOverlay(inputConfigs, duration); + } + catch (Exception ex) + { + // Log the error but don't let it crash the game launch + Logger.Error?.Print(LogClass.UI, $"Failed to show controller overlay: {ex.Message}"); + } + } + public void RefreshFirmwareStatus() { SystemVersion version = null; diff --git a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs index 654eb0c43..b24b74dcb 100644 --- a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs @@ -146,6 +146,8 @@ namespace Ryujinx.Ava.UI.ViewModels public bool EnableMouse { get; set; } public bool DisableInputWhenOutOfFocus { get; set; } public int FocusLostActionType { get; set; } + public int ControllerOverlayGameStartDuration { get; set; } + public int ControllerOverlayInputCycleDuration { get; set; } public bool UseGlobalInputConfig { @@ -587,6 +589,8 @@ namespace Ryujinx.Ava.UI.ViewModels HideCursor = (int)config.HideCursor.Value; UpdateCheckerType = (int)config.UpdateCheckerType.Value; FocusLostActionType = (int)config.FocusLostActionType.Value; + ControllerOverlayGameStartDuration = config.ControllerOverlayGameStartDuration.Value; + ControllerOverlayInputCycleDuration = config.ControllerOverlayInputCycleDuration.Value; GameDirectories.Clear(); GameDirectories.AddRange(config.UI.GameDirs.Value); @@ -698,6 +702,8 @@ namespace Ryujinx.Ava.UI.ViewModels config.HideCursor.Value = (HideCursorMode)HideCursor; config.UpdateCheckerType.Value = (UpdaterType)UpdateCheckerType; config.FocusLostActionType.Value = (FocusLostType)FocusLostActionType; + config.ControllerOverlayGameStartDuration.Value = ControllerOverlayGameStartDuration; + config.ControllerOverlayInputCycleDuration.Value = ControllerOverlayInputCycleDuration; config.UI.GameDirs.Value = [.. GameDirectories]; config.UI.AutoloadDirs.Value = [.. AutoloadDirectories]; diff --git a/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml index 917177fb5..e120a4ef1 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml @@ -120,6 +120,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml.cs b/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml.cs index b9a5462b2..e547f2e70 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml.cs +++ b/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml.cs @@ -82,7 +82,16 @@ namespace Ryujinx.Ava.UI.Views.Settings { "VolumeDown", () => viewModel.KeyboardHotkey.VolumeDown = Key.Unbound }, { "CustomVSyncIntervalIncrement", () => viewModel.KeyboardHotkey.CustomVSyncIntervalIncrement = Key.Unbound }, { "CustomVSyncIntervalDecrement", () => viewModel.KeyboardHotkey.CustomVSyncIntervalDecrement = Key.Unbound }, - { "TurboMode", () => viewModel.KeyboardHotkey.TurboMode = Key.Unbound } + { "TurboMode", () => viewModel.KeyboardHotkey.TurboMode = Key.Unbound }, + { "CycleInputDevicePlayer1", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer1 = Key.Unbound }, + { "CycleInputDevicePlayer2", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer2 = Key.Unbound }, + { "CycleInputDevicePlayer3", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer3 = Key.Unbound }, + { "CycleInputDevicePlayer4", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer4 = Key.Unbound }, + { "CycleInputDevicePlayer5", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer5 = Key.Unbound }, + { "CycleInputDevicePlayer6", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer6 = Key.Unbound }, + { "CycleInputDevicePlayer7", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer7 = Key.Unbound }, + { "CycleInputDevicePlayer8", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer8 = Key.Unbound }, + { "CycleInputDeviceHandheld", () => viewModel.KeyboardHotkey.CycleInputDeviceHandheld = Key.Unbound } }; if (buttonActions.TryGetValue(_currentAssigner.ToggledButton.Name, out Action action)) @@ -162,6 +171,33 @@ namespace Ryujinx.Ava.UI.Views.Settings case "TurboMode": ViewModel.KeyboardHotkey.TurboMode = buttonValue.AsHidType(); break; + case "CycleInputDevicePlayer1": + ViewModel.KeyboardHotkey.CycleInputDevicePlayer1 = buttonValue.AsHidType(); + break; + case "CycleInputDevicePlayer2": + ViewModel.KeyboardHotkey.CycleInputDevicePlayer2 = buttonValue.AsHidType(); + break; + case "CycleInputDevicePlayer3": + ViewModel.KeyboardHotkey.CycleInputDevicePlayer3 = buttonValue.AsHidType(); + break; + case "CycleInputDevicePlayer4": + ViewModel.KeyboardHotkey.CycleInputDevicePlayer4 = buttonValue.AsHidType(); + break; + case "CycleInputDevicePlayer5": + ViewModel.KeyboardHotkey.CycleInputDevicePlayer5 = buttonValue.AsHidType(); + break; + case "CycleInputDevicePlayer6": + ViewModel.KeyboardHotkey.CycleInputDevicePlayer6 = buttonValue.AsHidType(); + break; + case "CycleInputDevicePlayer7": + ViewModel.KeyboardHotkey.CycleInputDevicePlayer7 = buttonValue.AsHidType(); + break; + case "CycleInputDevicePlayer8": + ViewModel.KeyboardHotkey.CycleInputDevicePlayer8 = buttonValue.AsHidType(); + break; + case "CycleInputDeviceHandheld": + ViewModel.KeyboardHotkey.CycleInputDeviceHandheld = buttonValue.AsHidType(); + break; } }); } diff --git a/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml index ad05efd06..0b9aa1514 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml @@ -157,6 +157,32 @@ + + + + + + + + + + + + + +