]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
refactor: simple radial menu for easier creation (#34639)
authorFildrance <fildrance@gmail.com>
Mon, 31 Mar 2025 09:57:47 +0000 (12:57 +0300)
committerGitHub <noreply@github.com>
Mon, 31 Mar 2025 09:57:47 +0000 (12:57 +0300)
* 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 <pa.pecherskij@interfax.ru>
Co-authored-by: Eoin Mcloughlin <helloworld@eoinrul.es>
14 files changed:
Content.Client/Chat/UI/EmotesMenu.xaml [deleted file]
Content.Client/Chat/UI/EmotesMenu.xaml.cs [deleted file]
Content.Client/RCD/RCDMenu.xaml [deleted file]
Content.Client/RCD/RCDMenu.xaml.cs [deleted file]
Content.Client/RCD/RCDMenuBoundUserInterface.cs
Content.Client/Silicons/StationAi/StationAiBoundUserInterface.cs
Content.Client/Silicons/StationAi/StationAiMenu.xaml [deleted file]
Content.Client/Silicons/StationAi/StationAiMenu.xaml.cs [deleted file]
Content.Client/UserInterface/Controls/RadialMenu.cs
Content.Client/UserInterface/Controls/SimpleRadialMenu.xaml [new file with mode: 0644]
Content.Client/UserInterface/Controls/SimpleRadialMenu.xaml.cs [new file with mode: 0644]
Content.Client/UserInterface/Systems/Emotes/EmotesUIController.cs
Content.IntegrationTests/Tests/UserInterface/UiControlTest.cs
Content.Shared/Silicons/StationAi/SharedStationAiSystem.Held.cs

diff --git a/Content.Client/Chat/UI/EmotesMenu.xaml b/Content.Client/Chat/UI/EmotesMenu.xaml
deleted file mode 100644 (file)
index 845b631..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-<ui:RadialMenu xmlns="https://spacestation14.io"
-                xmlns:ui="clr-namespace:Content.Client.UserInterface.Controls"
-                BackButtonStyleClass="RadialMenuBackButton"
-                CloseButtonStyleClass="RadialMenuCloseButton"
-                VerticalExpand="True"
-                HorizontalExpand="True"
-                MinSize="450 450">
-
-    <!-- Main -->
-    <ui:RadialContainer Name="Main" VerticalExpand="True" HorizontalExpand="True" InitialRadius="100" ReserveSpaceForHiddenChildren="False">
-        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'emote-menu-category-general'}" TargetLayer="General" Visible="False">
-            <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Clothing/Head/Soft/mimesoft.rsi/icon.png"/>
-        </ui:RadialMenuTextureButtonWithSector>
-        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'emote-menu-category-vocal'}" TargetLayer="Vocal" Visible="False">
-            <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Interface/Emotes/vocal.png"/>
-        </ui:RadialMenuTextureButtonWithSector>
-        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'emote-menu-category-hands'}" TargetLayer="Hands" Visible="False">
-            <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Clothing/Hands/Gloves/latex.rsi/icon.png"/>
-        </ui:RadialMenuTextureButtonWithSector>
-    </ui:RadialContainer>
-
-    <!-- General -->
-    <ui:RadialContainer Name="General"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
-
-    <!-- Vocal -->
-    <ui:RadialContainer Name="Vocal"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
-
-    <!-- Hands -->
-    <ui:RadialContainer Name="Hands"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
-
-</ui:RadialMenu>
diff --git a/Content.Client/Chat/UI/EmotesMenu.xaml.cs b/Content.Client/Chat/UI/EmotesMenu.xaml.cs
deleted file mode 100644 (file)
index 80daa40..0000000
+++ /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<ProtoId<EmotePrototype>>? OnPlayEmote;
-
-    public EmotesMenu()
-    {
-        IoCManager.InjectDependencies(this);
-        RobustXamlLoader.Load(this);
-
-        var spriteSystem = _entManager.System<SpriteSystem>();
-        var whitelistSystem = _entManager.System<EntityWhitelistSystem>();
-
-        var main = FindControl<RadialContainer>("Main");
-
-        var emotes = _prototypeManager.EnumeratePrototypes<EmotePrototype>();
-        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<SpeechComponent>(player.Value, out var speech) &&
-                !speech.AllowedEmotes.Contains(emote.ID))
-                continue;
-
-            var parent = FindControl<RadialContainer>(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<EmotePrototype> ProtoId { get; set; }
-}
diff --git a/Content.Client/RCD/RCDMenu.xaml b/Content.Client/RCD/RCDMenu.xaml
deleted file mode 100644 (file)
index d8ab0ac..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-<ui:RadialMenu xmlns="https://spacestation14.io"
-                xmlns:ui="clr-namespace:Content.Client.UserInterface.Controls"
-                xmlns:rcd="clr-namespace:Content.Client.RCD"
-                BackButtonStyleClass="RadialMenuBackButton"
-                CloseButtonStyleClass="RadialMenuCloseButton"
-                VerticalExpand="True"
-                HorizontalExpand="True"
-                MinSize="450 450">
-    
-    <!-- Note: The min size of the window just determine how close to the edge of the screen the center of the radial menu can be placed -->
-    <!-- The radial menu will try to open so that its center is located where the player's cursor is currently -->
-
-    <!-- Entry layer (shows main categories) -->
-    <ui:RadialContainer Name="Main" VerticalExpand="True" HorizontalExpand="True" InitialRadius="100" ReserveSpaceForHiddenChildren="False">
-        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'rcd-component-walls-and-flooring'}" TargetLayer="WallsAndFlooring" Visible="False">
-            <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Interface/Radial/RCD/walls_and_flooring.png"/>
-        </ui:RadialMenuTextureButtonWithSector>
-        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'rcd-component-windows-and-grilles'}" TargetLayer="WindowsAndGrilles" Visible="False">
-            <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Interface/Radial/RCD/windows_and_grilles.png"/>
-        </ui:RadialMenuTextureButtonWithSector>
-        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'rcd-component-airlocks'}" TargetLayer="Airlocks" Visible="False">
-            <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Interface/Radial/RCD/airlocks.png"/>
-        </ui:RadialMenuTextureButtonWithSector>
-        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'rcd-component-electrical'}" TargetLayer="Electrical" Visible="False">
-            <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Interface/Radial/RCD/multicoil.png"/>
-        </ui:RadialMenuTextureButtonWithSector>
-        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'rcd-component-lighting'}" TargetLayer="Lighting" Visible="False">
-            <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Interface/Radial/RCD/lighting.png"/>
-        </ui:RadialMenuTextureButtonWithSector>
-    </ui:RadialContainer>
-
-    <!-- Walls and flooring -->
-    <ui:RadialContainer Name="WallsAndFlooring"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
-
-    <!-- Windows and grilles -->
-    <ui:RadialContainer Name="WindowsAndGrilles"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
-
-    <!-- Airlocks -->
-    <ui:RadialContainer Name="Airlocks"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
-
-    <!-- Computer and machine frames -->
-    <ui:RadialContainer Name="Electrical"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
-       
-    <!-- Lighting -->
-    <ui:RadialContainer Name="Lighting"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
-
-</ui:RadialMenu>
diff --git a/Content.Client/RCD/RCDMenu.xaml.cs b/Content.Client/RCD/RCDMenu.xaml.cs
deleted file mode 100644 (file)
index 7ea9894..0000000
+++ /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<ProtoId<RCDPrototype>>? SendRCDSystemMessageAction;
-
-    private EntityUid _owner;
-
-    public RCDMenu()
-    {
-        IoCManager.InjectDependencies(this);
-        RobustXamlLoader.Load(this);
-
-        _popup = _entManager.System<SharedPopupSystem>();
-        _sprites = _entManager.System<SpriteSystem>();
-
-        OnChildAdded += AddRCDMenuButtonOnClickActions;
-    }
-
-    public void SetEntity(EntityUid uid)
-    {
-        _owner = uid;
-        Refresh();
-    }
-
-    public void Refresh()
-    {
-        // Find the main radial container
-        var main = FindControl<RadialContainer>("Main");
-
-        // Populate secondary radial containers
-        if (!_entManager.TryGetComponent<RCDComponent>(_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<RadialContainer>(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<RCDPrototype> ProtoId { get; set; }
-}
index 1dd03626ae62d674d28f1f382fd7a8dd1aaf5860..d599c324e17f177c270a54d5df9552be81dc8e17 100644 (file)
@@ -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<string, (string Tooltip, SpriteSpecifier Sprite)> PrototypesGroupingInfo
+        = new Dictionary<string, (string Tooltip, SpriteSpecifier Sprite)>
+        {
+            ["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<RCDMenu>();
-        _menu.SetEntity(Owner);
-        _menu.SendRCDSystemMessageAction += SendRCDSystemMessage;
+        if (!EntMan.TryGetComponent<RCDComponent>(Owner, out var rcd))
+            return;
+
+        _menu = this.CreateWindow<SimpleRadialMenu>();
+        _menu.Track(Owner);
+        var models = ConvertToButtons(rcd.AvailablePrototypes);
+        _menu.SetButtons(models);
 
-        // Open the menu, centered on the mouse
-        var vpSize = _displayManager.ScreenSize;
-        _menu.OpenCenteredAt(_inputManager.MouseScreenPosition.Position / vpSize);
+        _menu.OpenOverMouseScreenPosition();
     }
 
-    public void SendRCDSystemMessage(ProtoId<RCDPrototype> protoId)
+    private IEnumerable<RadialMenuNestedLayerOption> ConvertToButtons(HashSet<ProtoId<RCDPrototype>> prototypes)
+    {
+        Dictionary<string, List<RadialMenuActionOption>> 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<RadialMenuActionOption>();
+                buttonsByCategory.Add(prototype.Category, list);
+            }
+
+            var actionOption = new RadialMenuActionOption<RCDPrototype>(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<PopupSystem>();
+        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;
     }
 }
index 68318305a0c9e47913f1cf422479c93adfe98819..77ac13c972fd568be15595067fe7e5ec7e82953c 100644 (file)
@@ -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<StationAiMenu>();
+
+        var ev = new GetStationAiRadialEvent();
+        EntMan.EventBus.RaiseLocalEvent(Owner, ref ev);
+
+        _menu = this.CreateWindow<SimpleRadialMenu>();
         _menu.Track(Owner);
+        var buttonModels = ConvertToButtons(ev.Actions);
+        _menu.SetButtons(buttonModels);
+        
+        _menu.Open();
+    }
 
-        _menu.OnAiRadial += args =>
+    private IEnumerable<RadialMenuActionOption> ConvertToButtons(IReadOnlyList<StationAiRadial> 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<BaseStationAiAction>(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 (file)
index cfa0b93..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-<ui:RadialMenu xmlns="https://spacestation14.io"
-                xmlns:ui="clr-namespace:Content.Client.UserInterface.Controls"
-                BackButtonStyleClass="RadialMenuBackButton"
-                CloseButtonStyleClass="RadialMenuCloseButton"
-                VerticalExpand="True"
-                HorizontalExpand="True"
-                MinSize="450 450">
-
-    <!-- Main -->
-    <ui:RadialContainer Name="Main" VerticalExpand="True" HorizontalExpand="True" InitialRadius="100" ReserveSpaceForHiddenChildren="False">
-    </ui:RadialContainer>
-
-</ui:RadialMenu>
diff --git a/Content.Client/Silicons/StationAi/StationAiMenu.xaml.cs b/Content.Client/Silicons/StationAi/StationAiMenu.xaml.cs
deleted file mode 100644 (file)
index a536d91..0000000
+++ /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<BaseStationAiAction>? 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<RadialContainer>("Main");
-        main.DisposeAllChildren();
-        var sprites = _entManager.System<SpriteSystem>();
-
-        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<SpriteSystem>().GetSpriteScreenCoordinates((_tracked, null, xform));
-
-        if (!coords.IsValid)
-        {
-            Close();
-            return;
-        }
-
-        OpenScreenAt(coords.Position, _clyde);
-    }
-}
-
-public sealed class StationAiMenuButton(BaseStationAiAction action) : RadialMenuTextureButtonWithSector
-{
-    public BaseStationAiAction Action = action;
-}
index 1b7f07aa2cc75cc7415d127fd27a27377beebf47..9734cf2960119a1e03aa7560173e570d7d947efb 100644 (file)
@@ -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
 {
     /// <summary>
-    /// 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.
     /// </summary>
-    public string TargetLayer { get; set; } = string.Empty;
+    public Control? TargetLayer { get; set; }
+
+    /// <summary>
+    /// Other way to set navigation to other container, as <see cref="TargetLayer"/>,
+    /// but using <see cref="Control.Name"/> property of target <see cref="RadialContainer"/>.
+    /// </summary>
+    public string? TargetLayerControlName { get; set; }
 
     /// <summary>
     /// 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));
 
     /// <summary>
-    /// 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.
     /// </summary>
     /// <remarks>
     /// By default color of border is same as color of background. Use <see cref="BorderColor"/>
@@ -400,13 +423,6 @@ public class RadialMenuTextureButtonWithSector : RadialMenuTextureButton, IRadia
     /// </summary>
     public bool DrawBackground { get; set; } = true;
 
-    /// <summary>
-    /// Marker, that control should render separator lines.
-    /// Separator lines are used to visually separate sector of radial menu items.
-    /// Is true by default
-    /// </summary>
-    public bool DrawSeparators { get; set; } = true;
-
     /// <summary>
     /// Color of background in non-hovered state. Accepts RGB color, works with sRGB for DrawPrimitive internally.
     /// </summary>
@@ -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 (file)
index 0000000..3070643
--- /dev/null
@@ -0,0 +1,8 @@
+<ui:SimpleRadialMenu xmlns="https://spacestation14.io"
+                     xmlns:ui="clr-namespace:Content.Client.UserInterface.Controls"
+                     BackButtonStyleClass="RadialMenuBackButton"
+                     CloseButtonStyleClass="RadialMenuCloseButton"
+                     VerticalExpand="True"
+                     HorizontalExpand="True"
+                     MinSize="450 450">
+</ui:SimpleRadialMenu>
diff --git a/Content.Client/UserInterface/Controls/SimpleRadialMenu.xaml.cs b/Content.Client/UserInterface/Controls/SimpleRadialMenu.xaml.cs
new file mode 100644 (file)
index 0000000..15c8065
--- /dev/null
@@ -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<RadialMenuOption> models, SimpleRadialMenuSettings? settings = null)
+    {
+        ClearExistingChildrenRadialButtons();
+
+        var sprites = _entManager.System<SpriteSystem>();
+        Fill(models, sprites, Children, settings ?? new SimpleRadialMenuSettings());
+    }
+
+    public void OpenOverMouseScreenPosition()
+    {
+        var vpSize = _clyde.ScreenSize;
+        OpenCenteredAt(_inputManager.MouseScreenPosition.Position / vpSize);
+    }
+
+    private void Fill(
+        IEnumerable<RadialMenuOption> models,
+        SpriteSystem sprites,
+        ICollection<Control> 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<Control> 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<Control>(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<SpriteSystem>().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<T>(Action<T> onPressed, T data)
+    : RadialMenuActionOption(onPressed: () => onPressed(data));
+
+public class RadialMenuNestedLayerOption(IReadOnlyCollection<RadialMenuOption> nested, float containerRadius = 100)
+    : RadialMenuOption
+{
+    public float? ContainerRadius { get; } = containerRadius;
+
+    public IReadOnlyCollection<RadialMenuOption> Nested { get; } = nested;
+}
+
+public class SimpleRadialMenuSettings
+{
+    /// <summary>
+    /// Default container draw radius. Is going to be further affected by per sector increment.
+    /// </summary>
+    public int DefaultContainerRadius = 100;
+
+    /// <summary>
+    /// Marker, if sector-buttons should be used.
+    /// </summary>
+    public bool UseSectors = true;
+
+    /// <summary>
+    /// Marker, if border of buttons should be rendered. Can only be used when <see cref="UseSectors"/> = true.
+    /// </summary>
+    public bool DisplayBorders = true;
+
+    /// <summary>
+    /// Marker, if sector background should not be rendered. Can only be used when <see cref="UseSectors"/> = true.
+    /// </summary>
+    public bool NoBackground = false;
+}
+
index 7b86859a1a2ca27f8748e1de615f353903028abf..7652e39bfd6a11e43f899fb93b9435bc4ac7b508 100644 (file)
@@ -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<GameplayState>
 {
     [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<MenuBar.Widgets.GameTopMenuBar>()?.EmotesButton;
-    private EmotesMenu? _menu;
+    private SimpleRadialMenu? _menu;
+
+    private static readonly Dictionary<EmoteCategory, (string Tooltip, SpriteSpecifier Sprite)> EmoteGroupingInfo
+        = new Dictionary<EmoteCategory, (string Tooltip, SpriteSpecifier Sprite)>
+    {
+        [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<GameplayS
         if (_menu == null)
         {
             // setup window
-            _menu = UIManager.CreateWindow<EmotesMenu>();
+            var prototypes = _prototypeManager.EnumeratePrototypes<EmotePrototype>();
+            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<GameplayS
             }
             else
             {
-                // Open the menu, centered on the mouse
-                var vpSize = _displayManager.ScreenSize;
-                _menu.OpenCenteredAt(_inputManager.MouseScreenPosition.Position / vpSize);
+                _menu.OpenOverMouseScreenPosition();
             }
         }
         else
         {
             _menu.OnClose -= OnWindowClosed;
             _menu.OnOpen -= OnWindowOpen;
-            _menu.OnPlayEmote -= OnPlayEmote;
 
             if (EmotesButton != null)
                 EmotesButton.SetClickPressed(false);
@@ -118,8 +130,62 @@ public sealed class EmotesUIController : UIController, IOnStateChanged<GameplayS
         _menu = null;
     }
 
-    private void OnPlayEmote(ProtoId<EmotePrototype> protoId)
+    private IEnumerable<RadialMenuOption> ConvertToButtons(IEnumerable<EmotePrototype> emotePrototypes)
+    {
+        var whitelistSystem = EntitySystemManager.GetEntitySystem<EntityWhitelistSystem>();
+        var player = _playerManager.LocalSession?.AttachedEntity;
+
+        Dictionary<EmoteCategory, List<RadialMenuOption>> 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<SpeechComponent>(player.Value, out var speech)
+                && !speech.AllowedEmotes.Contains(emote.ID))
+                continue;
+
+            if (!emotesByCategory.TryGetValue(emote.Category, out var list))
+            {
+                list = new List<RadialMenuOption>();
+                emotesByCategory.Add(emote.Category, list);
+            }
+
+            var actionOption = new RadialMenuActionOption<EmotePrototype>(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(protoId));
+        _entityManager.RaisePredictiveEvent(new PlayEmoteMessage(prototype.ID));
     }
 }
index c8378bb6612a0c7e5c073f671acc274935f22ca1..5efa009ca7e77bc65087465c232db0baa230d0e5 100644 (file)
@@ -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),
     };
 
index 8acfb56376956bce01880a0a97ddf9e9eb47ce02..afdf9c2b6d0538ac28bcd9976e05de9873be6fad 100644 (file)
@@ -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<StationAiWhitelistComponent> ent, ref GetVerbsEvent<AlternativeVerb> args)
     {
         if (!args.CanComplexInteract
-            || !HasComp<StationAiHeldComponent>(args.User))
+            || !HasComp<StationAiHeldComponent>(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);