From 19f3497b35538e792cf884a2a6b9a95e20520cbc Mon Sep 17 00:00:00 2001 From: Fildrance Date: Mon, 31 Mar 2025 12:57:47 +0300 Subject: [PATCH] refactor: simple radial menu for easier creation (#34639) * it works! kinda * so it works now * minor cleanup * central button now is useful too * more cleanup * minor cleanup * more cleanup * refactor: migrated code from toolbox (as it was rejected as too specific) * feat: moved border drawing for radial menu into RadialMenuTextureButton. Radial menu position setting into was moved to OverrideArrange to not being called on every frame * refactor: major reworks! * renamed DrawBagleSector to DrawAnnulusSector * Remove strange indexing * Regularize math * refactor: re-orienting segment elements to be Y-mirrored * refactor: extracted radial menu radius multiplier property, changed color pallet for radial menu button * refactor: removed icon backgrounds on textures used in current radial menu buttons with sectors, RadialContainer Radius renamed and now actually changed control radius. * refactor: in RadialMenuTextureButtonWithSector all sector colors are converted to and from sRGB in property getter-setters * refactor: renamed srgb to include Srgb suffix so devs gonna see that its srgb clearly * fix: enabled any functional keys pressed when pushing radial menu buttons * fix: radial menu sector now scales with UIScale * fix: accept only one event when clicking on radial menu ContextualButton * fix: now radial menu buttons accepts only click/alt-click, now clicks outside menu closes menu always * feat: simple radial menu prototype for easier creation * refactor: cleanup, restored emote filtering, button models now have class hierarchy * refactor: remove usage of closure from 'outside code' * refactor: remove non existing type from UiControlTest * refactor: remove unused using * refactor: revert ability to declare radial menu layers in xaml, scale 32px sprites using scale in radial menu * refactor: whitespaces * refactor: subscribe for dispose on existing radial menus * feat: now simple radial menu button models can have custom color for each sector background (and hover background color). Also added OpenOverMouseScreenPosition inside SimpleRadialMenu * fix: AI door menu now can be closed by verb if it gets unpowered * refactor: simplify hiding border, extended xml-doc for simple radial menu settings * refactor: remove linq * fix: fix AI radial action serialization using invalid type * refactor: fix duplicate ShowDeviceNotRespondingPopup for AI by properly checking if it can interact * refactor: whitespaces, changed list to array in simple radial button preparing methods --------- Co-authored-by: pa.pecherskij Co-authored-by: Eoin Mcloughlin --- Content.Client/Chat/UI/EmotesMenu.xaml | 31 -- Content.Client/Chat/UI/EmotesMenu.xaml.cs | 111 ------- Content.Client/RCD/RCDMenu.xaml | 47 --- Content.Client/RCD/RCDMenu.xaml.cs | 172 ----------- .../RCD/RCDMenuBoundUserInterface.cs | 126 +++++++- .../StationAi/StationAiBoundUserInterface.cs | 44 ++- .../Silicons/StationAi/StationAiMenu.xaml | 13 - .../Silicons/StationAi/StationAiMenu.xaml.cs | 126 -------- .../UserInterface/Controls/RadialMenu.cs | 58 ++-- .../Controls/SimpleRadialMenu.xaml | 8 + .../Controls/SimpleRadialMenu.xaml.cs | 279 ++++++++++++++++++ .../Systems/Emotes/EmotesUIController.cs | 96 +++++- .../Tests/UserInterface/UiControlTest.cs | 2 - .../StationAi/SharedStationAiSystem.Held.cs | 19 +- 14 files changed, 559 insertions(+), 573 deletions(-) delete mode 100644 Content.Client/Chat/UI/EmotesMenu.xaml delete mode 100644 Content.Client/Chat/UI/EmotesMenu.xaml.cs delete mode 100644 Content.Client/RCD/RCDMenu.xaml delete mode 100644 Content.Client/RCD/RCDMenu.xaml.cs delete mode 100644 Content.Client/Silicons/StationAi/StationAiMenu.xaml delete mode 100644 Content.Client/Silicons/StationAi/StationAiMenu.xaml.cs create mode 100644 Content.Client/UserInterface/Controls/SimpleRadialMenu.xaml create mode 100644 Content.Client/UserInterface/Controls/SimpleRadialMenu.xaml.cs diff --git a/Content.Client/Chat/UI/EmotesMenu.xaml b/Content.Client/Chat/UI/EmotesMenu.xaml deleted file mode 100644 index 845b631617..0000000000 --- a/Content.Client/Chat/UI/EmotesMenu.xaml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Content.Client/Chat/UI/EmotesMenu.xaml.cs b/Content.Client/Chat/UI/EmotesMenu.xaml.cs deleted file mode 100644 index 80daa405a6..0000000000 --- a/Content.Client/Chat/UI/EmotesMenu.xaml.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System.Numerics; -using Content.Client.UserInterface.Controls; -using Content.Shared.Chat.Prototypes; -using Content.Shared.Speech; -using Content.Shared.Whitelist; -using Robust.Client.AutoGenerated; -using Robust.Client.GameObjects; -using Robust.Client.UserInterface.Controls; -using Robust.Client.UserInterface.XAML; -using Robust.Shared.Player; -using Robust.Shared.Prototypes; - -namespace Content.Client.Chat.UI; - -[GenerateTypedNameReferences] -public sealed partial class EmotesMenu : RadialMenu -{ - [Dependency] private readonly EntityManager _entManager = default!; - [Dependency] private readonly IPrototypeManager _prototypeManager = default!; - [Dependency] private readonly ISharedPlayerManager _playerManager = default!; - - public event Action>? OnPlayEmote; - - public EmotesMenu() - { - IoCManager.InjectDependencies(this); - RobustXamlLoader.Load(this); - - var spriteSystem = _entManager.System(); - var whitelistSystem = _entManager.System(); - - var main = FindControl("Main"); - - var emotes = _prototypeManager.EnumeratePrototypes(); - foreach (var emote in emotes) - { - var player = _playerManager.LocalSession?.AttachedEntity; - if (emote.Category == EmoteCategory.Invalid || - emote.ChatTriggers.Count == 0 || - !(player.HasValue && whitelistSystem.IsWhitelistPassOrNull(emote.Whitelist, player.Value)) || - whitelistSystem.IsBlacklistPass(emote.Blacklist, player.Value)) - continue; - - if (!emote.Available && - _entManager.TryGetComponent(player.Value, out var speech) && - !speech.AllowedEmotes.Contains(emote.ID)) - continue; - - var parent = FindControl(emote.Category.ToString()); - - var button = new EmoteMenuButton - { - SetSize = new Vector2(64f, 64f), - ToolTip = Loc.GetString(emote.Name), - ProtoId = emote.ID, - }; - - var tex = new TextureRect - { - VerticalAlignment = VAlignment.Center, - HorizontalAlignment = HAlignment.Center, - Texture = spriteSystem.Frame0(emote.Icon), - TextureScale = new Vector2(2f, 2f), - }; - - button.AddChild(tex); - parent.AddChild(button); - foreach (var child in main.Children) - { - if (child is not RadialMenuTextureButton castChild) - continue; - - if (castChild.TargetLayer == emote.Category.ToString()) - { - castChild.Visible = true; - break; - } - } - } - - - // Set up menu actions - foreach (var child in Children) - { - if (child is not RadialContainer container) - continue; - AddEmoteClickAction(container); - } - } - - private void AddEmoteClickAction(RadialContainer container) - { - foreach (var child in container.Children) - { - if (child is not EmoteMenuButton castChild) - continue; - - castChild.OnButtonUp += _ => - { - OnPlayEmote?.Invoke(castChild.ProtoId); - Close(); - }; - } - } -} - - -public sealed class EmoteMenuButton : RadialMenuTextureButtonWithSector -{ - public ProtoId ProtoId { get; set; } -} diff --git a/Content.Client/RCD/RCDMenu.xaml b/Content.Client/RCD/RCDMenu.xaml deleted file mode 100644 index d8ab0ac8f4..0000000000 --- a/Content.Client/RCD/RCDMenu.xaml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Content.Client/RCD/RCDMenu.xaml.cs b/Content.Client/RCD/RCDMenu.xaml.cs deleted file mode 100644 index 7ea9894e41..0000000000 --- a/Content.Client/RCD/RCDMenu.xaml.cs +++ /dev/null @@ -1,172 +0,0 @@ -using Content.Client.UserInterface.Controls; -using Content.Shared.Popups; -using Content.Shared.RCD; -using Content.Shared.RCD.Components; -using Robust.Client.AutoGenerated; -using Robust.Client.GameObjects; -using Robust.Client.Player; -using Robust.Client.UserInterface; -using Robust.Client.UserInterface.Controls; -using Robust.Client.UserInterface.XAML; -using Robust.Shared.Prototypes; -using System.Numerics; - -namespace Content.Client.RCD; - -[GenerateTypedNameReferences] -public sealed partial class RCDMenu : RadialMenu -{ - [Dependency] private readonly EntityManager _entManager = default!; - [Dependency] private readonly IPrototypeManager _protoManager = default!; - [Dependency] private readonly IPlayerManager _playerManager = default!; - - private SharedPopupSystem _popup; - private SpriteSystem _sprites; - - public event Action>? SendRCDSystemMessageAction; - - private EntityUid _owner; - - public RCDMenu() - { - IoCManager.InjectDependencies(this); - RobustXamlLoader.Load(this); - - _popup = _entManager.System(); - _sprites = _entManager.System(); - - OnChildAdded += AddRCDMenuButtonOnClickActions; - } - - public void SetEntity(EntityUid uid) - { - _owner = uid; - Refresh(); - } - - public void Refresh() - { - // Find the main radial container - var main = FindControl("Main"); - - // Populate secondary radial containers - if (!_entManager.TryGetComponent(_owner, out var rcd)) - return; - - foreach (var protoId in rcd.AvailablePrototypes) - { - if (!_protoManager.TryIndex(protoId, out var proto)) - continue; - - if (proto.Mode == RcdMode.Invalid) - continue; - - var parent = FindControl(proto.Category); - var tooltip = Loc.GetString(proto.SetName); - - if ((proto.Mode == RcdMode.ConstructTile || proto.Mode == RcdMode.ConstructObject) && - proto.Prototype != null && _protoManager.TryIndex(proto.Prototype, out var entProto, logError: false)) - { - tooltip = Loc.GetString(entProto.Name); - } - - tooltip = OopsConcat(char.ToUpper(tooltip[0]).ToString(), tooltip.Remove(0, 1)); - - var button = new RCDMenuButton() - { - SetSize = new Vector2(64f, 64f), - ToolTip = tooltip, - ProtoId = protoId, - }; - - if (proto.Sprite != null) - { - var tex = new TextureRect() - { - VerticalAlignment = VAlignment.Center, - HorizontalAlignment = HAlignment.Center, - Texture = _sprites.Frame0(proto.Sprite), - TextureScale = new Vector2(2f, 2f), - }; - - button.AddChild(tex); - } - - parent.AddChild(button); - - // Ensure that the button that transitions the menu to the associated category layer - // is visible in the main radial container (as these all start with Visible = false) - foreach (var child in main.Children) - { - if (child is not RadialMenuTextureButton castChild) - continue; - - if (castChild.TargetLayer == proto.Category) - { - castChild.Visible = true; - break; - } - } - } - - // Set up menu actions - foreach (var child in Children) - { - AddRCDMenuButtonOnClickActions(child); - } - } - - private static string OopsConcat(string a, string b) - { - // This exists to prevent Roslyn being clever and compiling something that fails sandbox checks. - return a + b; - } - - private void AddRCDMenuButtonOnClickActions(Control control) - { - var radialContainer = control as RadialContainer; - - if (radialContainer == null) - return; - - foreach (var child in radialContainer.Children) - { - var castChild = child as RCDMenuButton; - - if (castChild == null) - continue; - - castChild.OnButtonUp += _ => - { - SendRCDSystemMessageAction?.Invoke(castChild.ProtoId); - - if (_playerManager.LocalSession?.AttachedEntity != null && - _protoManager.TryIndex(castChild.ProtoId, out var proto)) - { - var msg = Loc.GetString("rcd-component-change-mode", ("mode", Loc.GetString(proto.SetName))); - - if (proto.Mode == RcdMode.ConstructTile || proto.Mode == RcdMode.ConstructObject) - { - var name = Loc.GetString(proto.SetName); - - if (proto.Prototype != null && - _protoManager.TryIndex(proto.Prototype, out var entProto, logError: false)) - name = entProto.Name; - - msg = Loc.GetString("rcd-component-change-build-mode", ("name", name)); - } - - // Popup message - _popup.PopupClient(msg, _owner, _playerManager.LocalSession.AttachedEntity); - } - - Close(); - }; - } - } -} - -public sealed class RCDMenuButton : RadialMenuTextureButtonWithSector -{ - public ProtoId ProtoId { get; set; } -} diff --git a/Content.Client/RCD/RCDMenuBoundUserInterface.cs b/Content.Client/RCD/RCDMenuBoundUserInterface.cs index 1dd03626ae..d599c324e1 100644 --- a/Content.Client/RCD/RCDMenuBoundUserInterface.cs +++ b/Content.Client/RCD/RCDMenuBoundUserInterface.cs @@ -1,20 +1,32 @@ +using Content.Client.Popups; +using Content.Client.UserInterface.Controls; using Content.Shared.RCD; using Content.Shared.RCD.Components; using JetBrains.Annotations; -using Robust.Client.Graphics; -using Robust.Client.Input; using Robust.Client.UserInterface; +using Robust.Shared.Player; using Robust.Shared.Prototypes; +using Robust.Shared.Utility; namespace Content.Client.RCD; [UsedImplicitly] public sealed class RCDMenuBoundUserInterface : BoundUserInterface { - [Dependency] private readonly IClyde _displayManager = default!; - [Dependency] private readonly IInputManager _inputManager = default!; + private static readonly Dictionary PrototypesGroupingInfo + = new Dictionary + { + ["WallsAndFlooring"] = ("rcd-component-walls-and-flooring", new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/Radial/RCD/walls_and_flooring.png"))), + ["WindowsAndGrilles"] = ("rcd-component-windows-and-grilles", new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/Radial/RCD/windows_and_grilles.png"))), + ["Airlocks"] = ("rcd-component-airlocks", new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/Radial/RCD/airlocks.png"))), + ["Electrical"] = ("rcd-component-electrical", new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/Radial/RCD/multicoil.png"))), + ["Lighting"] = ("rcd-component-lighting", new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/Radial/RCD/lighting.png"))), + }; - private RCDMenu? _menu; + [Dependency] private readonly IPrototypeManager _prototypeManager = default!; + [Dependency] private readonly ISharedPlayerManager _playerManager = default!; + + private SimpleRadialMenu? _menu; public RCDMenuBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) { @@ -25,19 +37,107 @@ public sealed class RCDMenuBoundUserInterface : BoundUserInterface { base.Open(); - _menu = this.CreateWindow(); - _menu.SetEntity(Owner); - _menu.SendRCDSystemMessageAction += SendRCDSystemMessage; + if (!EntMan.TryGetComponent(Owner, out var rcd)) + return; - // Open the menu, centered on the mouse - var vpSize = _displayManager.ScreenSize; - _menu.OpenCenteredAt(_inputManager.MouseScreenPosition.Position / vpSize); + _menu = this.CreateWindow(); + _menu.Track(Owner); + var models = ConvertToButtons(rcd.AvailablePrototypes); + _menu.SetButtons(models); + + _menu.OpenOverMouseScreenPosition(); } - public void SendRCDSystemMessage(ProtoId protoId) + private IEnumerable ConvertToButtons(HashSet> prototypes) + { + Dictionary> buttonsByCategory = new(); + foreach (var protoId in prototypes) + { + var prototype = _prototypeManager.Index(protoId); + if (!PrototypesGroupingInfo.TryGetValue(prototype.Category, out var groupInfo)) + continue; + + if (!buttonsByCategory.TryGetValue(prototype.Category, out var list)) + { + list = new List(); + buttonsByCategory.Add(prototype.Category, list); + } + + var actionOption = new RadialMenuActionOption(HandleMenuOptionClick, prototype) + { + Sprite = prototype.Sprite, + ToolTip = GetTooltip(prototype) + }; + list.Add(actionOption); + } + + var models = new RadialMenuNestedLayerOption[buttonsByCategory.Count]; + var i = 0; + foreach (var (key, list) in buttonsByCategory) + { + var groupInfo = PrototypesGroupingInfo[key]; + models[i] = new RadialMenuNestedLayerOption(list) + { + Sprite = groupInfo.Sprite, + ToolTip = Loc.GetString(groupInfo.Tooltip) + }; + i++; + } + + return models; + } + + private void HandleMenuOptionClick(RCDPrototype proto) { // A predicted message cannot be used here as the RCD UI is closed immediately // after this message is sent, which will stop the server from receiving it - SendMessage(new RCDSystemMessage(protoId)); + SendMessage(new RCDSystemMessage(proto.ID)); + + + if (_playerManager.LocalSession?.AttachedEntity == null) + return; + + var msg = Loc.GetString("rcd-component-change-mode", ("mode", Loc.GetString(proto.SetName))); + + if (proto.Mode is RcdMode.ConstructTile or RcdMode.ConstructObject) + { + var name = Loc.GetString(proto.SetName); + + if (proto.Prototype != null && + _prototypeManager.TryIndex(proto.Prototype, out var entProto, logError: false)) + name = entProto.Name; + + msg = Loc.GetString("rcd-component-change-build-mode", ("name", name)); + } + + // Popup message + var popup = EntMan.System(); + popup.PopupClient(msg, Owner, _playerManager.LocalSession.AttachedEntity); + } + + private string GetTooltip(RCDPrototype proto) + { + string tooltip; + + if (proto.Mode is RcdMode.ConstructTile or RcdMode.ConstructObject + && proto.Prototype != null + && _prototypeManager.TryIndex(proto.Prototype, out var entProto, logError: false)) + { + tooltip = Loc.GetString(entProto.Name); + } + else + { + tooltip = Loc.GetString(proto.SetName); + } + + tooltip = OopsConcat(char.ToUpper(tooltip[0]).ToString(), tooltip.Remove(0, 1)); + + return tooltip; + } + + private static string OopsConcat(string a, string b) + { + // This exists to prevent Roslyn being clever and compiling something that fails sandbox checks. + return a + b; } } diff --git a/Content.Client/Silicons/StationAi/StationAiBoundUserInterface.cs b/Content.Client/Silicons/StationAi/StationAiBoundUserInterface.cs index 68318305a0..77ac13c972 100644 --- a/Content.Client/Silicons/StationAi/StationAiBoundUserInterface.cs +++ b/Content.Client/Silicons/StationAi/StationAiBoundUserInterface.cs @@ -1,28 +1,46 @@ +using Content.Client.UserInterface.Controls; using Content.Shared.Silicons.StationAi; using Robust.Client.UserInterface; namespace Content.Client.Silicons.StationAi; -public sealed class StationAiBoundUserInterface : BoundUserInterface +public sealed class StationAiBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey) { - private StationAiMenu? _menu; - - public StationAiBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) - { - } + private SimpleRadialMenu? _menu; protected override void Open() { base.Open(); - _menu = this.CreateWindow(); - _menu.Track(Owner); - _menu.OnAiRadial += args => + var ev = new GetStationAiRadialEvent(); + EntMan.EventBus.RaiseLocalEvent(Owner, ref ev); + + _menu = this.CreateWindow(); + _menu.Track(Owner); + var buttonModels = ConvertToButtons(ev.Actions); + _menu.SetButtons(buttonModels); + + _menu.Open(); + } + + private IEnumerable ConvertToButtons(IReadOnlyList actions) + { + var models = new RadialMenuActionOption[actions.Count]; + for (int i = 0; i < actions.Count; i++) { - SendPredictedMessage(new StationAiRadialMessage() + var action = actions[i]; + models[i] = new RadialMenuActionOption(HandleRadialMenuClick, action.Event) { - Event = args, - }); - }; + Sprite = action.Sprite, + ToolTip = action.Tooltip + }; + } + + return models; + } + + private void HandleRadialMenuClick(BaseStationAiAction p) + { + SendPredictedMessage(new StationAiRadialMessage { Event = p }); } } diff --git a/Content.Client/Silicons/StationAi/StationAiMenu.xaml b/Content.Client/Silicons/StationAi/StationAiMenu.xaml deleted file mode 100644 index cfa0b93234..0000000000 --- a/Content.Client/Silicons/StationAi/StationAiMenu.xaml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/Content.Client/Silicons/StationAi/StationAiMenu.xaml.cs b/Content.Client/Silicons/StationAi/StationAiMenu.xaml.cs deleted file mode 100644 index a536d911f3..0000000000 --- a/Content.Client/Silicons/StationAi/StationAiMenu.xaml.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Numerics; -using Content.Client.UserInterface.Controls; -using Content.Shared.Silicons.StationAi; -using Robust.Client.AutoGenerated; -using Robust.Client.GameObjects; -using Robust.Client.Graphics; -using Robust.Client.UserInterface.Controls; -using Robust.Client.UserInterface.XAML; -using Robust.Shared.Timing; - -namespace Content.Client.Silicons.StationAi; - -[GenerateTypedNameReferences] -public sealed partial class StationAiMenu : RadialMenu -{ - [Dependency] private readonly IClyde _clyde = default!; - [Dependency] private readonly IEntityManager _entManager = default!; - - public event Action? OnAiRadial; - - private EntityUid _tracked; - - public StationAiMenu() - { - IoCManager.InjectDependencies(this); - RobustXamlLoader.Load(this); - } - - public void Track(EntityUid owner) - { - _tracked = owner; - - if (!_entManager.EntityExists(_tracked)) - { - Close(); - return; - } - - BuildButtons(); - UpdatePosition(); - } - - private void BuildButtons() - { - var ev = new GetStationAiRadialEvent(); - _entManager.EventBus.RaiseLocalEvent(_tracked, ref ev); - - var main = FindControl("Main"); - main.DisposeAllChildren(); - var sprites = _entManager.System(); - - foreach (var action in ev.Actions) - { - // TODO: This radial boilerplate is quite annoying - var button = new StationAiMenuButton(action.Event) - { - SetSize = new Vector2(64f, 64f), - ToolTip = action.Tooltip != null ? Loc.GetString(action.Tooltip) : null, - }; - - if (action.Sprite != null) - { - var texture = sprites.Frame0(action.Sprite); - var scale = Vector2.One; - - if (texture.Width <= 32) - { - scale *= 2; - } - - var tex = new TextureRect - { - VerticalAlignment = VAlignment.Center, - HorizontalAlignment = HAlignment.Center, - Texture = texture, - TextureScale = scale, - }; - - button.AddChild(tex); - } - - button.OnPressed += args => - { - OnAiRadial?.Invoke(action.Event); - Close(); - }; - main.AddChild(button); - } - } - - protected override void FrameUpdate(FrameEventArgs args) - { - base.FrameUpdate(args); - UpdatePosition(); - } - - private void UpdatePosition() - { - if (!_entManager.TryGetComponent(_tracked, out TransformComponent? xform)) - { - Close(); - return; - } - - if (!xform.Coordinates.IsValid(_entManager)) - { - Close(); - return; - } - - var coords = _entManager.System().GetSpriteScreenCoordinates((_tracked, null, xform)); - - if (!coords.IsValid) - { - Close(); - return; - } - - OpenScreenAt(coords.Position, _clyde); - } -} - -public sealed class StationAiMenuButton(BaseStationAiAction action) : RadialMenuTextureButtonWithSector -{ - public BaseStationAiAction Action = action; -} diff --git a/Content.Client/UserInterface/Controls/RadialMenu.cs b/Content.Client/UserInterface/Controls/RadialMenu.cs index 1b7f07aa2c..9734cf2960 100644 --- a/Content.Client/UserInterface/Controls/RadialMenu.cs +++ b/Content.Client/UserInterface/Controls/RadialMenu.cs @@ -1,10 +1,10 @@ -using Robust.Client.UserInterface; -using Robust.Client.UserInterface.Controls; -using Robust.Client.UserInterface.CustomControls; using System.Linq; using System.Numerics; using Content.Shared.Input; using Robust.Client.Graphics; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.CustomControls; using Robust.Shared.Input; namespace Content.Client.UserInterface.Controls; @@ -143,11 +143,8 @@ public class RadialMenu : BaseWindow return children.First(x => x.Visible); } - public bool TryToMoveToNewLayer(string newLayer) + public bool TryToMoveToNewLayer(Control newLayer) { - if (newLayer == string.Empty) - return false; - var currentLayer = GetCurrentActiveLayer(); if (currentLayer == null) @@ -161,7 +158,7 @@ public class RadialMenu : BaseWindow continue; // Hide layers which are not of interest - if (result == true || child.Name != newLayer) + if (result == true || child != newLayer) { child.Visible = false; } @@ -186,6 +183,19 @@ public class RadialMenu : BaseWindow return result; } + public bool TryToMoveToNewLayer(string targetLayerControlName) + { + foreach (var child in Children) + { + if (child.Name == targetLayerControlName && child is RadialContainer) + { + return TryToMoveToNewLayer(child); + } + } + + return false; + } + public void ReturnToPreviousLayer() { // Close the menu if the traversal path is empty @@ -296,9 +306,15 @@ public sealed class RadialMenuOuterAreaButton : RadialMenuTextureButtonBase public class RadialMenuTextureButton : RadialMenuTextureButtonBase { /// - /// Upon clicking this button the radial menu will be moved to the named layer + /// Upon clicking this button the radial menu will be moved to the layer of this control. /// - public string TargetLayer { get; set; } = string.Empty; + public Control? TargetLayer { get; set; } + + /// + /// Other way to set navigation to other container, as , + /// but using property of target . + /// + public string? TargetLayerControlName { get; set; } /// /// A simple texture button that can move the user to a different layer within a radial menu @@ -311,7 +327,7 @@ public class RadialMenuTextureButton : RadialMenuTextureButtonBase private void OnClicked(ButtonEventArgs args) { - if (TargetLayer == string.Empty) + if (TargetLayer == null && TargetLayerControlName == null) return; var parent = FindParentMultiLayerContainer(this); @@ -319,7 +335,14 @@ public class RadialMenuTextureButton : RadialMenuTextureButtonBase if (parent == null) return; - parent.TryToMoveToNewLayer(TargetLayer); + if (TargetLayer != null) + { + parent.TryToMoveToNewLayer(TargetLayer); + } + else + { + parent.TryToMoveToNewLayer(TargetLayerControlName!); + } } private RadialMenu? FindParentMultiLayerContainer(Control control) @@ -387,7 +410,7 @@ public class RadialMenuTextureButtonWithSector : RadialMenuTextureButton, IRadia private Color _hoverBorderColorSrgb = Color.ToSrgb(new Color(87, 91, 127, 128)); /// - /// Marker, that control should render border of segment. Is false by default. + /// Marker, that controls if border of segment should be rendered. Is false by default. /// /// /// By default color of border is same as color of background. Use @@ -400,13 +423,6 @@ public class RadialMenuTextureButtonWithSector : RadialMenuTextureButton, IRadia /// public bool DrawBackground { get; set; } = true; - /// - /// Marker, that control should render separator lines. - /// Separator lines are used to visually separate sector of radial menu items. - /// Is true by default - /// - public bool DrawSeparators { get; set; } = true; - /// /// Color of background in non-hovered state. Accepts RGB color, works with sRGB for DrawPrimitive internally. /// @@ -520,7 +536,7 @@ public class RadialMenuTextureButtonWithSector : RadialMenuTextureButton, IRadia DrawAnnulusSector(handle, containerCenter, _innerRadius * UIScale, _outerRadius * UIScale, angleFrom, angleTo, borderColor, false); } - if (!_isWholeCircle && DrawSeparators) + if (!_isWholeCircle && DrawBorder) { DrawSeparatorLines(handle, containerCenter, _innerRadius * UIScale, _outerRadius * UIScale, angleFrom, angleTo, SeparatorColor); } diff --git a/Content.Client/UserInterface/Controls/SimpleRadialMenu.xaml b/Content.Client/UserInterface/Controls/SimpleRadialMenu.xaml new file mode 100644 index 0000000000..307064334d --- /dev/null +++ b/Content.Client/UserInterface/Controls/SimpleRadialMenu.xaml @@ -0,0 +1,8 @@ + + diff --git a/Content.Client/UserInterface/Controls/SimpleRadialMenu.xaml.cs b/Content.Client/UserInterface/Controls/SimpleRadialMenu.xaml.cs new file mode 100644 index 0000000000..15c8065a44 --- /dev/null +++ b/Content.Client/UserInterface/Controls/SimpleRadialMenu.xaml.cs @@ -0,0 +1,279 @@ +using Robust.Client.UserInterface; +using System.Numerics; +using Robust.Client.AutoGenerated; +using Robust.Client.Graphics; +using Robust.Shared.Utility; +using Robust.Client.GameObjects; +using Robust.Shared.Timing; +using Robust.Client.UserInterface.XAML; +using Robust.Client.Input; + +namespace Content.Client.UserInterface.Controls; + +[GenerateTypedNameReferences] +public partial class SimpleRadialMenu : RadialMenu +{ + private EntityUid? _attachMenuToEntity; + + [Dependency] private readonly IClyde _clyde = default!; + [Dependency] private readonly IEntityManager _entManager = default!; + [Dependency] private readonly IInputManager _inputManager = default!; + + public SimpleRadialMenu() + { + IoCManager.InjectDependencies(this); + RobustXamlLoader.Load(this); + } + + public void Track(EntityUid owner) + { + _attachMenuToEntity = owner; + } + + public void SetButtons(IEnumerable models, SimpleRadialMenuSettings? settings = null) + { + ClearExistingChildrenRadialButtons(); + + var sprites = _entManager.System(); + Fill(models, sprites, Children, settings ?? new SimpleRadialMenuSettings()); + } + + public void OpenOverMouseScreenPosition() + { + var vpSize = _clyde.ScreenSize; + OpenCenteredAt(_inputManager.MouseScreenPosition.Position / vpSize); + } + + private void Fill( + IEnumerable models, + SpriteSystem sprites, + ICollection rootControlChildren, + SimpleRadialMenuSettings settings + ) + { + var rootContainer = new RadialContainer + { + HorizontalExpand = true, + VerticalExpand = true, + InitialRadius = settings.DefaultContainerRadius, + ReserveSpaceForHiddenChildren = false, + Visible = true + }; + rootControlChildren.Add(rootContainer); + + foreach (var model in models) + { + if (model is RadialMenuNestedLayerOption nestedMenuModel) + { + var linkButton = RecursiveContainerExtraction(sprites, rootControlChildren, nestedMenuModel, settings); + linkButton.Visible = true; + rootContainer.AddChild(linkButton); + } + else + { + var rootButtons = ConvertToButton(model, sprites, settings, false); + rootContainer.AddChild(rootButtons); + } + } + } + + private RadialMenuTextureButton RecursiveContainerExtraction( + SpriteSystem sprites, + ICollection rootControlChildren, + RadialMenuNestedLayerOption model, + SimpleRadialMenuSettings settings + ) + { + var container = new RadialContainer + { + HorizontalExpand = true, + VerticalExpand = true, + InitialRadius = model.ContainerRadius!.Value, + ReserveSpaceForHiddenChildren = false, + Visible = false + }; + foreach (var nested in model.Nested) + { + if (nested is RadialMenuNestedLayerOption nestedMenuModel) + { + var linkButton = RecursiveContainerExtraction(sprites, rootControlChildren, nestedMenuModel, settings); + container.AddChild(linkButton); + } + else + { + var button = ConvertToButton(nested, sprites, settings, false); + container.AddChild(button); + } + } + rootControlChildren.Add(container); + + var thisLayerLinkButton = ConvertToButton(model, sprites, settings, true); + thisLayerLinkButton.TargetLayer = container; + return thisLayerLinkButton; + } + + private RadialMenuTextureButton ConvertToButton( + RadialMenuOption model, + SpriteSystem sprites, + SimpleRadialMenuSettings settings, + bool haveNested + ) + { + var button = settings.UseSectors + ? ConvertToButtonWithSector(model, settings) + : new RadialMenuTextureButton(); + button.SetSize = new Vector2(64f, 64f); + button.ToolTip = model.ToolTip; + if (model.Sprite != null) + { + var scale = Vector2.One; + + var texture = sprites.Frame0(model.Sprite); + if (texture.Width <= 32) + { + scale *= 2; + } + + button.TextureNormal = texture; + button.Scale = scale; + } + + if (model is RadialMenuActionOption actionOption) + { + button.OnPressed += _ => + { + actionOption.OnPressed?.Invoke(); + if(!haveNested) + Close(); + }; + } + + return button; + } + + private static RadialMenuTextureButtonWithSector ConvertToButtonWithSector(RadialMenuOption model, SimpleRadialMenuSettings settings) + { + var button = new RadialMenuTextureButtonWithSector + { + DrawBorder = settings.DisplayBorders, + DrawBackground = !settings.NoBackground + }; + if (model.BackgroundColor.HasValue) + { + button.BackgroundColor = model.BackgroundColor.Value; + } + + if (model.HoverBackgroundColor.HasValue) + { + button.HoverBackgroundColor = model.HoverBackgroundColor.Value; + } + + return button; + } + + private void ClearExistingChildrenRadialButtons() + { + var toRemove = new List(ChildCount); + foreach (var child in Children) + { + if (child != ContextualButton && child != MenuOuterAreaButton) + { + toRemove.Add(child); + } + } + + foreach (var control in toRemove) + { + Children.Remove(control); + } + } + + #region target entity tracking + + protected override void FrameUpdate(FrameEventArgs args) + { + base.FrameUpdate(args); + if (_attachMenuToEntity != null) + { + UpdatePosition(); + } + } + + private void UpdatePosition() + { + if (!_entManager.TryGetComponent(_attachMenuToEntity, out TransformComponent? xform)) + { + Close(); + return; + } + + if (!xform.Coordinates.IsValid(_entManager)) + { + Close(); + return; + } + + var coords = _entManager.System().GetSpriteScreenCoordinates((_attachMenuToEntity.Value, null, xform)); + + if (!coords.IsValid) + { + Close(); + return; + } + + OpenScreenAt(coords.Position, _clyde); + } + + #endregion + +} + + +public abstract class RadialMenuOption +{ + public string? ToolTip { get; init; } + + public SpriteSpecifier? Sprite { get; init; } + public Color? BackgroundColor { get; set; } + public Color? HoverBackgroundColor { get; set; } +} + +public class RadialMenuActionOption(Action onPressed) : RadialMenuOption +{ + public Action OnPressed { get; } = onPressed; +} + +public class RadialMenuActionOption(Action onPressed, T data) + : RadialMenuActionOption(onPressed: () => onPressed(data)); + +public class RadialMenuNestedLayerOption(IReadOnlyCollection nested, float containerRadius = 100) + : RadialMenuOption +{ + public float? ContainerRadius { get; } = containerRadius; + + public IReadOnlyCollection Nested { get; } = nested; +} + +public class SimpleRadialMenuSettings +{ + /// + /// Default container draw radius. Is going to be further affected by per sector increment. + /// + public int DefaultContainerRadius = 100; + + /// + /// Marker, if sector-buttons should be used. + /// + public bool UseSectors = true; + + /// + /// Marker, if border of buttons should be rendered. Can only be used when = true. + /// + public bool DisplayBorders = true; + + /// + /// Marker, if sector background should not be rendered. Can only be used when = true. + /// + public bool NoBackground = false; +} + diff --git a/Content.Client/UserInterface/Systems/Emotes/EmotesUIController.cs b/Content.Client/UserInterface/Systems/Emotes/EmotesUIController.cs index 7b86859a1a..7652e39bfd 100644 --- a/Content.Client/UserInterface/Systems/Emotes/EmotesUIController.cs +++ b/Content.Client/UserInterface/Systems/Emotes/EmotesUIController.cs @@ -1,16 +1,17 @@ -using Content.Client.Chat.UI; using Content.Client.Gameplay; using Content.Client.UserInterface.Controls; using Content.Shared.Chat; using Content.Shared.Chat.Prototypes; using Content.Shared.Input; +using Content.Shared.Speech; +using Content.Shared.Whitelist; using JetBrains.Annotations; -using Robust.Client.Graphics; -using Robust.Client.Input; +using Robust.Client.Player; using Robust.Client.UserInterface.Controllers; using Robust.Client.UserInterface.Controls; using Robust.Shared.Input.Binding; using Robust.Shared.Prototypes; +using Robust.Shared.Utility; namespace Content.Client.UserInterface.Systems.Emotes; @@ -18,11 +19,19 @@ namespace Content.Client.UserInterface.Systems.Emotes; public sealed class EmotesUIController : UIController, IOnStateChanged { [Dependency] private readonly IEntityManager _entityManager = default!; - [Dependency] private readonly IClyde _displayManager = default!; - [Dependency] private readonly IInputManager _inputManager = default!; - + [Dependency] private readonly IPrototypeManager _prototypeManager = default!; + [Dependency] private readonly IPlayerManager _playerManager = default!; + private MenuButton? EmotesButton => UIManager.GetActiveUIWidgetOrNull()?.EmotesButton; - private EmotesMenu? _menu; + private SimpleRadialMenu? _menu; + + private static readonly Dictionary EmoteGroupingInfo + = new Dictionary + { + [EmoteCategory.General] = ("emote-menu-category-general", new SpriteSpecifier.Texture(new ResPath("/Textures/Clothing/Head/Soft/mimesoft.rsi/icon.png"))), + [EmoteCategory.Hands] = ("emote-menu-category-hands", new SpriteSpecifier.Texture(new ResPath("/Textures/Clothing/Hands/Gloves/latex.rsi/icon.png"))), + [EmoteCategory.Vocal] = ("emote-menu-category-vocal", new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/Emotes/vocal.png"))), + }; public void OnStateEntered(GameplayState state) { @@ -42,10 +51,16 @@ public sealed class EmotesUIController : UIController, IOnStateChanged(); + var prototypes = _prototypeManager.EnumeratePrototypes(); + var models = ConvertToButtons(prototypes); + + _menu = new SimpleRadialMenu(); + _menu.SetButtons(models); + + _menu.Open(); + _menu.OnClose += OnWindowClosed; _menu.OnOpen += OnWindowOpen; - _menu.OnPlayEmote += OnPlayEmote; if (EmotesButton != null) EmotesButton.SetClickPressed(true); @@ -56,16 +71,13 @@ public sealed class EmotesUIController : UIController, IOnStateChanged protoId) + private IEnumerable ConvertToButtons(IEnumerable emotePrototypes) { - _entityManager.RaisePredictiveEvent(new PlayEmoteMessage(protoId)); + var whitelistSystem = EntitySystemManager.GetEntitySystem(); + var player = _playerManager.LocalSession?.AttachedEntity; + + Dictionary> emotesByCategory = new(); + foreach (var emote in emotePrototypes) + { + if(emote.Category == EmoteCategory.Invalid) + continue; + + // only valid emotes that have ways to be triggered by chat and player have access / no restriction on + if (emote.Category == EmoteCategory.Invalid + || emote.ChatTriggers.Count == 0 + || !(player.HasValue && whitelistSystem.IsWhitelistPassOrNull(emote.Whitelist, player.Value)) + || whitelistSystem.IsBlacklistPass(emote.Blacklist, player.Value)) + continue; + + if (!emote.Available + && EntityManager.TryGetComponent(player.Value, out var speech) + && !speech.AllowedEmotes.Contains(emote.ID)) + continue; + + if (!emotesByCategory.TryGetValue(emote.Category, out var list)) + { + list = new List(); + emotesByCategory.Add(emote.Category, list); + } + + var actionOption = new RadialMenuActionOption(HandleRadialButtonClick, emote) + { + Sprite = emote.Icon, + ToolTip = Loc.GetString(emote.Name) + }; + list.Add(actionOption); + } + + var models = new RadialMenuOption[emotesByCategory.Count]; + var i = 0; + foreach (var (key, list) in emotesByCategory) + { + var tuple = EmoteGroupingInfo[key]; + + models[i] = new RadialMenuNestedLayerOption(list) + { + Sprite = tuple.Sprite, + ToolTip = Loc.GetString(tuple.Tooltip) + }; + i++; + } + + return models; + } + + private void HandleRadialButtonClick(EmotePrototype prototype) + { + _entityManager.RaisePredictiveEvent(new PlayEmoteMessage(prototype.ID)); } } diff --git a/Content.IntegrationTests/Tests/UserInterface/UiControlTest.cs b/Content.IntegrationTests/Tests/UserInterface/UiControlTest.cs index c8378bb661..5efa009ca7 100644 --- a/Content.IntegrationTests/Tests/UserInterface/UiControlTest.cs +++ b/Content.IntegrationTests/Tests/UserInterface/UiControlTest.cs @@ -1,5 +1,4 @@ using System.Linq; -using Content.Client.Chat.UI; using Content.Client.LateJoin; using Robust.Client.UserInterface.CustomControls; using Robust.Shared.ContentPack; @@ -14,7 +13,6 @@ public sealed class UiControlTest // You should not be adding to this. private Type[] _ignored = new Type[] { - typeof(EmotesMenu), typeof(LateJoinGui), }; diff --git a/Content.Shared/Silicons/StationAi/SharedStationAiSystem.Held.cs b/Content.Shared/Silicons/StationAi/SharedStationAiSystem.Held.cs index 8acfb56376..afdf9c2b6d 100644 --- a/Content.Shared/Silicons/StationAi/SharedStationAiSystem.Held.cs +++ b/Content.Shared/Silicons/StationAi/SharedStationAiSystem.Held.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using Content.Shared.Actions.Events; using Content.Shared.IdentityManagement; using Content.Shared.Interaction.Events; @@ -122,6 +121,14 @@ public abstract partial class SharedStationAiSystem if (ev.Actor == ev.Target) return; + // no need to show menu if device is not powered. + if (!PowerReceiver.IsPowered(ev.Target)) + { + ShowDeviceNotRespondingPopup(ev.Actor); + ev.Cancel(); + return; + } + if (TryComp(ev.Actor, out StationAiHeldComponent? aiComp) && (!TryComp(ev.Target, out StationAiWhitelistComponent? whitelistComponent) || !ValidateAi((ev.Actor, aiComp)))) @@ -150,7 +157,8 @@ public abstract partial class SharedStationAiSystem private void OnTargetVerbs(Entity ent, ref GetVerbsEvent args) { if (!args.CanComplexInteract - || !HasComp(args.User)) + || !HasComp(args.User) + || !args.CanInteract) { return; } @@ -166,13 +174,6 @@ public abstract partial class SharedStationAiSystem Text = isOpen ? Loc.GetString("ai-close") : Loc.GetString("ai-open"), Act = () => { - // no need to show menu if device is not powered. - if (!PowerReceiver.IsPowered(ent.Owner)) - { - ShowDeviceNotRespondingPopup(user); - return; - } - if (isOpen) { _uiSystem.CloseUi(ent.Owner, AiUi.Key, user);