diff --git a/Content.Client/White/Radials/RadialSystem.cs b/Content.Client/White/Radials/RadialSystem.cs
new file mode 100644
index 0000000000..ed93b355c9
--- /dev/null
+++ b/Content.Client/White/Radials/RadialSystem.cs
@@ -0,0 +1,240 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Content.Client.Examine;
+using Content.Client.Gameplay;
+using Content.Client.Popups;
+using Content.Shared.Examine;
+using Content.Shared.Tag;
+using Content.Shared.Verbs;
+using Content.Shared.White.Radials;
+using Content.Shared.White.Radials.Systems;
+using JetBrains.Annotations;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Client.Player;
+using Robust.Client.State;
+using Robust.Shared.Map;
+using Robust.Shared.Utility;
+
+namespace Content.Client.White.Radials;
+
+ [UsedImplicitly]
+ public sealed class RadialSystem : SharedRadialSystem
+ {
+ [Dependency] private readonly PopupSystem _popupSystem = default!;
+ [Dependency] private readonly ExamineSystem _examineSystem = default!;
+ [Dependency] private readonly TagSystem _tagSystem = default!;
+ [Dependency] private readonly IStateManager _stateManager = default!;
+ [Dependency] private readonly EntityLookupSystem _entityLookup = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
+
+ ///
+ /// When a user right clicks somewhere, how large is the box we use to get entities for the context menu?
+ ///
+ public const float EntityMenuLookupSize = 0.25f;
+
+ [Dependency] private readonly IEyeManager _eyeManager = default!;
+
+ ///
+ /// These flags determine what entities the user can see on the context menu.
+ ///
+ public MenuVisibility Visibility;
+
+ public Action? OnRadialsResponse;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeNetworkEvent(HandleRadialsResponse);
+ }
+
+ ///
+ /// Get all of the entities in an area for displaying on the context menu.
+ ///
+ public bool TryGetEntityMenuEntities(MapCoordinates targetPos, [NotNullWhen(true)] out List? result)
+ {
+ result = null;
+
+ if (_stateManager.CurrentState is not GameplayStateBase gameScreenBase)
+ return false;
+
+ var player = _playerManager.LocalPlayer?.ControlledEntity;
+ if (player == null)
+ return false;
+
+ // If FOV drawing is disabled, we will modify the visibility option to ignore visiblity checks.
+ var visibility = _eyeManager.CurrentEye.DrawFov
+ ? Visibility
+ : Visibility | MenuVisibility.NoFov;
+
+
+ // Get entities
+ List entities;
+
+ // Do we have to do FoV checks?
+ if ((visibility & MenuVisibility.NoFov) == 0)
+ {
+ var entitiesUnderMouse = gameScreenBase.GetClickableEntities(targetPos).ToHashSet();
+ bool Predicate(EntityUid e) => e == player || entitiesUnderMouse.Contains(e);
+
+ // first check the general location.
+ if (!_examineSystem.CanExamine(player.Value, targetPos, Predicate))
+ return false;
+
+ TryComp(player.Value, out ExaminerComponent? examiner);
+
+ // Then check every entity
+ entities = new();
+ foreach (var ent in _entityLookup.GetEntitiesInRange(targetPos, EntityMenuLookupSize))
+ {
+ if (_examineSystem.CanExamine(player.Value, targetPos, Predicate, ent, examiner))
+ entities.Add(ent);
+ }
+ }
+ else
+ {
+ entities = _entityLookup.GetEntitiesInRange(targetPos, EntityMenuLookupSize).ToList();
+ }
+
+ if (entities.Count == 0)
+ return false;
+
+ if (visibility == MenuVisibility.All)
+ {
+ result = entities;
+ return true;
+ }
+
+ // remove any entities in containers
+ if ((visibility & MenuVisibility.InContainer) == 0)
+ {
+ for (var i = entities.Count - 1; i >= 0; i--)
+ {
+ var entity = entities[i];
+
+ if (ContainerSystem.IsInSameOrTransparentContainer(player.Value, entity))
+ continue;
+
+ entities.RemoveSwap(i);
+ }
+ }
+
+ // remove any invisible entities
+ if ((visibility & MenuVisibility.Invisible) == 0)
+ {
+ var spriteQuery = GetEntityQuery();
+ var tagQuery = GetEntityQuery();
+
+ for (var i = entities.Count - 1; i >= 0; i--)
+ {
+ var entity = entities[i];
+
+ if (!spriteQuery.TryGetComponent(entity, out var spriteComponent) ||
+ !spriteComponent.Visible ||
+ _tagSystem.HasTag(entity, "HideContextMenu", tagQuery))
+ {
+ entities.RemoveSwap(i);
+ }
+ }
+ }
+
+ // Remove any entities that do not have LOS
+ if ((visibility & MenuVisibility.NoFov) == 0)
+ {
+ var xformQuery = GetEntityQuery();
+ var playerPos = xformQuery.GetComponent(player.Value).MapPosition;
+
+ for (var i = entities.Count - 1; i >= 0; i--)
+ {
+ var entity = entities[i];
+
+ if (!ExamineSystemShared.InRangeUnOccluded(
+ playerPos,
+ xformQuery.GetComponent(entity).MapPosition,
+ ExamineSystemShared.ExamineRange,
+ null))
+ {
+ entities.RemoveSwap(i);
+ }
+ }
+ }
+
+ if (entities.Count == 0)
+ return false;
+
+ result = entities;
+ return true;
+ }
+
+ ///
+ /// Asks the server to send back a list of server-side verbs, for the given verb type.
+ ///
+ public SortedSet GetRadials(EntityUid target, EntityUid user, Type type, bool force = false)
+ {
+ return GetRadials(target, user, new List() { type }, force);
+ }
+
+ ///
+ /// Ask the server to send back a list of server-side verbs, and for now return an incomplete list of verbs
+ /// (only those defined locally).
+ ///
+ public SortedSet GetRadials(EntityUid target, EntityUid user, List verbTypes,
+ bool force = false)
+ {
+ if (!target.IsClientSide())
+ {
+ RaiseNetworkEvent(new RequestServerRadialsEvent(target, verbTypes, adminRequest: force));
+ }
+
+ // Some admin menu interactions will try get verbs for entities that have not yet been sent to the player.
+ if (!Exists(target))
+ return new();
+
+ return GetLocalRadials(target, user, verbTypes, force);
+ }
+
+ ///
+ /// Execute actions associated with the given verb.
+ ///
+ ///
+ /// Unless this is a client-exclusive verb, this will also tell the server to run the same verb.
+ ///
+ public void ExecuteRadial(EntityUid target, Radial radial)
+ {
+ var user = _playerManager.LocalPlayer?.ControlledEntity;
+ if (user == null)
+ return;
+
+ // is this verb actually valid?
+ if (radial.Disabled)
+ {
+ // maybe send an informative pop-up message.
+ if (!string.IsNullOrWhiteSpace(radial.Message))
+ _popupSystem.PopupEntity(radial.Message, user.Value);
+
+ return;
+ }
+
+ if (radial.ClientExclusive || target.IsClientSide())
+ ExecuteRadial(radial, user.Value, target);
+ else
+ EntityManager.RaisePredictiveEvent(new ExecuteRadialEvent(target, radial));
+ }
+
+ private void HandleRadialsResponse(RadialsResponseEvent msg)
+ {
+ OnRadialsResponse?.Invoke(msg);
+ }
+ }
+
+ [Flags]
+ public enum MenuVisibility
+ {
+ // What entities can a user see on the entity menu?
+ Default = 0, // They can only see entities in FoV.
+ NoFov = 1 << 0, // They ignore FoV restrictions
+ InContainer = 1 << 1, // They can see through containers.
+ Invisible = 1 << 2, // They can see entities without sprites and the "HideContextMenu" tag is ignored.
+ All = NoFov | InContainer | Invisible
+ }
diff --git a/Content.Client/White/Radials/RadialUIController.cs b/Content.Client/White/Radials/RadialUIController.cs
new file mode 100644
index 0000000000..21d2845535
--- /dev/null
+++ b/Content.Client/White/Radials/RadialUIController.cs
@@ -0,0 +1,126 @@
+using System.Linq;
+using Content.Client.CombatMode;
+using Content.Client.ContextMenu.UI;
+using Content.Client.Gameplay;
+using Content.Client.Resources;
+using Content.Client.Verbs;
+using Content.Client.Verbs.UI;
+using Content.Client.White.UserInterface.Controls;
+using Content.Shared.Input;
+using Content.Shared.Verbs;
+using Content.Shared.White.Radials;
+using Robust.Client.Player;
+using Robust.Client.ResourceManagement;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controllers;
+using Robust.Client.Utility;
+using Robust.Shared.Input;
+
+namespace Content.Client.White.Radials;
+
+public sealed class RadialUIController : UIController, IOnStateEntered, IOnStateExited
+ {
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
+ [Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!;
+ //[Dependency] private readonly ContextMenuUIController _context = default!;
+
+ [UISystemDependency] private readonly CombatModeSystem _combatMode = default!;
+ [UISystemDependency] private readonly RadialSystem _radialSystem = default!;
+
+ public EntityUid CurrentTarget;
+ public SortedSet CurrentRadials = new();
+
+ ///
+ /// Separate from , since we can open a verb menu as a submenu
+ /// of an entity menu element. If that happens, we need to be aware and close it properly.
+ ///
+ public RadialContainer? OpenMenu = null;
+
+ public void OnStateEntered(GameplayState state)
+ {
+ //_context.OnContextClosed += Close;
+ _radialSystem.OnRadialsResponse += HandleVerbsResponse;
+ }
+
+ public void OnStateExited(GameplayState state)
+ {
+ //_context.OnContextClosed -= Close;
+ if (_radialSystem != null)
+ _radialSystem.OnRadialsResponse -= HandleVerbsResponse;
+ Close();
+ }
+
+ ///
+ /// Open a verb menu and fill it with verbs applicable to the given target entity.
+ ///
+ /// Entity to get verbs on.
+ /// Used to force showing all verbs (mostly for admins).
+ ///
+ /// If this is not null, verbs will be placed into the given popup instead.
+ ///
+ public void OpenRadialMenu(EntityUid target, bool force = false)
+ {
+ if (_playerManager.LocalPlayer?.ControlledEntity is not {Valid: true} user ||
+ _combatMode.IsInCombatMode(user))
+ return;
+
+ Close();
+
+ CurrentTarget = target;
+ CurrentRadials = _radialSystem.GetRadials(target, user, Radial.RadialTypes, force);
+ OpenMenu = new RadialContainer();
+ OpenMenu.NormalSize = 50;
+ OpenMenu.FocusSize = 64;
+
+ //Feat: Disable action text, while im not fixed it
+ OpenMenu.IsAction = false;
+ }
+
+ private void FillRadial()
+ {
+ OpenMenu ??= new RadialContainer();
+
+ OpenMenu.CloseButton.Controller.OnPressed += (_) => Close();
+
+ foreach (var radial in CurrentRadials)
+ {
+ var button = OpenMenu.AddButton(radial.Text, radial.Icon ?? null);
+ button.Controller.OnPressed += (_) => { ExecuteRadial(radial); };
+ }
+
+ OpenMenu.Open(_userInterfaceManager.MousePositionScaled.Position);
+ }
+
+ public void AddServerRadials(List radials)
+ {
+ CurrentRadials.UnionWith(radials);
+ FillRadial();
+ }
+
+ private void Close()
+ {
+ if (OpenMenu == null)
+ return;
+
+ OpenMenu.Close();
+ OpenMenu = null;
+ }
+
+ private void HandleVerbsResponse(RadialsResponseEvent msg)
+ {
+ if (OpenMenu == null || CurrentTarget != msg.Entity)
+ return;
+
+ if (msg.Radials == null)
+ return;
+ AddServerRadials(msg.Radials);
+ }
+
+ private void ExecuteRadial(Radial radial)
+ {
+ _radialSystem.ExecuteRadial(CurrentTarget, radial);
+
+ if (radial.CloseMenu ?? radial.CloseMenuDefault)
+ Close(); //_context.Close();
+ }
+ }
diff --git a/Content.Client/White/UserInterface/Controls/RadialButton.xaml b/Content.Client/White/UserInterface/Controls/RadialButton.xaml
new file mode 100644
index 0000000000..f0eb1d079d
--- /dev/null
+++ b/Content.Client/White/UserInterface/Controls/RadialButton.xaml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/White/UserInterface/Controls/RadialButton.xaml.cs b/Content.Client/White/UserInterface/Controls/RadialButton.xaml.cs
new file mode 100644
index 0000000000..d493c35988
--- /dev/null
+++ b/Content.Client/White/UserInterface/Controls/RadialButton.xaml.cs
@@ -0,0 +1,39 @@
+using JetBrains.Annotations;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Animations;
+using Robust.Shared.Timing;
+
+namespace Content.Client.White.UserInterface.Controls;
+
+[GenerateTypedNameReferences, Virtual, PublicAPI]
+public sealed partial class RadialButton : Control
+{
+ [Animatable] public Vector2 Offset { get; set; }
+ public string? Content { get; set; }
+
+ public string Texture
+ {
+ set => Controller.TexturePath = value;
+ }
+
+ [Animatable]
+ public Vector2 ButtonSize
+ {
+ get => this.Size;
+ set => this.SetSize = value;
+ }
+
+ public RadialButton()
+ {
+ RobustXamlLoader.Load(this);
+ Offset = Vector2.Zero;
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ base.FrameUpdate(args);
+ }
+}
diff --git a/Content.Client/White/UserInterface/Controls/RadialContainer.xaml b/Content.Client/White/UserInterface/Controls/RadialContainer.xaml
new file mode 100644
index 0000000000..717e186c3b
--- /dev/null
+++ b/Content.Client/White/UserInterface/Controls/RadialContainer.xaml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/White/UserInterface/Controls/RadialContainer.xaml.cs b/Content.Client/White/UserInterface/Controls/RadialContainer.xaml.cs
new file mode 100644
index 0000000000..46f16c8002
--- /dev/null
+++ b/Content.Client/White/UserInterface/Controls/RadialContainer.xaml.cs
@@ -0,0 +1,257 @@
+using System.Linq;
+using Content.Client.Gameplay;
+using Content.Client.Resources;
+using JetBrains.Annotations;
+using Robust.Client.Animations;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.ResourceManagement;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Client.Utility;
+using Robust.Shared.Animations;
+using Robust.Shared.Console;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Client.White.UserInterface.Controls;
+
+public sealed class RadialContainerCommandTest : LocalizedCommands
+{
+ public override string Command => "radialtest";
+
+ public override void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ var radial = new RadialContainer();
+ for (int i = 0; i < 24; i++)
+ {
+ var testButton = radial.AddButton("Action " + i, "/Textures/Interface/emotions.svg.192dpi.png");
+ testButton.Controller.OnPressed += (_) => { Logger.Debug("Press gay"); };
+ }
+
+ radial.CloseButton.Controller.OnPressed += (_) =>
+ {
+ radial.Close();
+ radial.Dispose();
+ };
+ //radial.OpenCentered();
+ var usrMngr = IoCManager.Resolve();
+ radial.Open(usrMngr.MousePositionScaled.Position);
+ }
+}
+
+[GenerateTypedNameReferences, Virtual]
+public partial class RadialContainer : Control
+{
+ private bool _isOpened = false;
+
+ private Vector2 _focusSize = new Vector2(64, 64);
+ private Vector2 _normalSize = new Vector2(50, 50);
+
+ private float _moveAniTime = 0.3f;
+ private float _focusAniTime = 0.25f;
+
+ private int _maxButtons = 8;
+
+ private string _backgroundTexture = "/Textures/Interface/Default/SlotBackground.png";
+
+ public const string MoveAnimationKey = "move";
+ public const string InSizeAnimationKey = "insize";
+ public const string OutSizeAnimationKey = "outsize";
+
+ public float FocusSize
+ {
+ get => _focusSize.Y;
+ set => _focusSize = new Vector2(value, value);
+ }
+ public float NormalSize
+ {
+ get => _normalSize.Y;
+ set => _normalSize = new Vector2(value, value);
+ }
+
+ public float MoveAnimationTime
+ {
+ get => _moveAniTime;
+ set => _moveAniTime = value;
+ }
+ public float FocusAnimationTime
+ {
+ get => _focusAniTime;
+ set => _focusAniTime = value;
+ }
+
+ public bool IsAction = true;
+
+ public RadialContainer() : base()
+ {
+ RobustXamlLoader.Load(this);
+ }
+
+ public void Open(Vector2 position)
+ {
+ AddToRoot();
+ LayoutContainer.SetPosition(this, position);
+ UpdateButtons();
+ }
+
+ public void OpenCentered()
+ {
+ AddToRoot();
+ if (Parent != null)
+ LayoutContainer.SetPosition(this, (Parent.Size/2) - (this.Size/2));
+ else
+ LayoutContainer.SetPosition(this, (UserInterfaceManager.MainViewport.Size/2) - (this.Size/2));
+ UpdateButtons();
+ }
+
+ public void Close()
+ {
+ Parent?.RemoveChild(this);
+ Visible = false;
+ _isOpened = false;
+ }
+
+ public RadialButton AddButton(string action, string? texture = null)
+ {
+ var button = new RadialButton();
+ button.Content = action;
+ button.Controller.TextureNormal = IoCManager.Resolve().GetTexture(_backgroundTexture);
+ if (texture != null)
+ button.BackgroundTexture.Texture = IoCManager.Resolve().GetTexture(texture);
+ Layout.AddChild(button);
+
+ return button;
+ }
+
+ private void AddToRoot()
+ {
+ if (_isOpened)
+ return;
+ UserInterfaceManager.WindowRoot.AddChild(this);
+ _isOpened = !_isOpened;
+ }
+
+ private void UpdateButtons()
+ {
+ Visible = true;
+
+ var angleDegrees = 360/Layout.ChildCount;
+ var stepAngle = -angleDegrees + -90;
+ var distance = FocusSize * 1.2;
+ if (Layout.Children.Count() > _maxButtons)
+ {
+ for (int i = 0; i < (Layout.Children.Count() - _maxButtons); i++)
+ {
+ distance += (NormalSize/3);
+ }
+ }
+ foreach (var child in Layout.Children)
+ {
+ var button = (RadialButton)child;
+ button.ButtonSize = _normalSize;
+ stepAngle += angleDegrees;
+ var pos = GetPointFromPolar(stepAngle, distance);
+ PlayRadialAnimation(button, pos, MoveAnimationKey);
+
+ button.Controller.OnMouseEntered += (_) =>
+ {
+ PlaySizeAnimation(button, _focusSize, OutSizeAnimationKey, InSizeAnimationKey);
+ ActionLabel.Text = button.Content ?? string.Empty;
+ ActionLabel.Visible = IsAction;
+ };
+ button.Controller.OnMouseExited += (_) =>
+ {
+ PlaySizeAnimation(button, _normalSize, InSizeAnimationKey, OutSizeAnimationKey);
+ ActionLabel.Visible = false;
+ };
+ }
+
+ CloseButton.ButtonSize = _normalSize;
+ CloseButton.Controller.OnMouseEntered += (_) =>
+ {
+ PlaySizeAnimation(CloseButton, _focusSize, OutSizeAnimationKey, InSizeAnimationKey);
+ ActionLabel.Text = CloseButton.Content ?? string.Empty;
+ ActionLabel.Visible = true;
+ };
+ CloseButton.Controller.OnMouseExited += (_) =>
+ {
+ PlaySizeAnimation(CloseButton, _normalSize, InSizeAnimationKey, OutSizeAnimationKey);
+ ActionLabel.Visible = false;
+ };
+ }
+
+ private void PlayRadialAnimation(Control button, Vector2 pos, string playKey)
+ {
+ var anim = new Animation
+ {
+ Length = TimeSpan.FromMilliseconds(_moveAniTime * 1000),
+ AnimationTracks =
+ {
+ new AnimationTrackControlProperty
+ {
+ Property = nameof(RadialButton.Offset),
+ InterpolationMode = AnimationInterpolationMode.Linear,
+ KeyFrames =
+ {
+ new AnimationTrackProperty.KeyFrame(new Vector2(0,0), 0f),
+ new AnimationTrackProperty.KeyFrame(pos, _moveAniTime)
+ }
+ }
+ }
+ };
+ if (!button.HasRunningAnimation(playKey))
+ button.PlayAnimation(anim, playKey);
+ }
+
+ private void PlaySizeAnimation(Control button, Vector2 size, string playKey, string? stopKey)
+ {
+ var anim = new Animation
+ {
+ Length = TimeSpan.FromMilliseconds(_focusAniTime * 1000),
+ AnimationTracks =
+ {
+ new AnimationTrackControlProperty
+ {
+ Property = nameof(RadialButton.ButtonSize),
+ InterpolationMode = AnimationInterpolationMode.Linear,
+ KeyFrames =
+ {
+ new AnimationTrackProperty.KeyFrame(button.Size, 0f),
+ new AnimationTrackProperty.KeyFrame(size, _focusAniTime)
+ }
+ }
+ }
+ };
+
+ if (stopKey != null && button.HasRunningAnimation(stopKey))
+ button.StopAnimation(stopKey);
+ if (!button.HasRunningAnimation(playKey))
+ button.PlayAnimation(anim, playKey);
+ }
+
+ protected override void Draw(DrawingHandleScreen handle)
+ {
+ base.Draw(handle);
+
+ foreach (var child in Layout.Children)
+ {
+ var button = (RadialButton)child;
+ LayoutContainer.SetPosition(child, button.Offset - (button.Size/2));
+ }
+ LayoutContainer.SetPosition(CloseButton, CloseButton.Offset - (CloseButton.Size/2));
+ LayoutContainer.SetPosition(ActionBox, new Vector2(0 - (ActionLabel.Size.X), FocusSize*1.5f));
+ }
+
+ private static Vector2 GetPointFromPolar(double angleDegrees, double distance)
+ {
+ var angleRadians = angleDegrees * (Math.PI / 180.0);
+
+ var x = distance * Math.Cos(angleRadians);
+ var y = distance * Math.Sin(angleRadians);
+
+ return new Vector2((int)Math.Round(x), (int)Math.Round(y));
+ }
+}
diff --git a/Content.Server/White/Radials/RadialSystem.cs b/Content.Server/White/Radials/RadialSystem.cs
new file mode 100644
index 0000000000..6c8bcf9814
--- /dev/null
+++ b/Content.Server/White/Radials/RadialSystem.cs
@@ -0,0 +1,120 @@
+using System.Linq;
+using Content.Server.Administration.Managers;
+using Content.Server.Popups;
+using Content.Shared.Administration;
+using Content.Shared.Administration.Logs;
+using Content.Shared.Database;
+using Content.Shared.Hands.Components;
+using Content.Shared.Verbs;
+using Content.Shared.White.Radials;
+using Content.Shared.White.Radials.Systems;
+using Robust.Server.Player;
+
+namespace Content.Server.White.Radials;
+
+public sealed class RadialSystem : SharedRadialSystem
+ {
+ [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
+ [Dependency] private readonly PopupSystem _popupSystem = default!;
+ [Dependency] private readonly IAdminManager _adminMgr = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeNetworkEvent(HandleRadialRequest);
+ }
+
+ private void HandleRadialRequest(RequestServerRadialsEvent args, EntitySessionEventArgs eventArgs)
+ {
+ var player = (IPlayerSession) eventArgs.SenderSession;
+
+ if (!EntityManager.EntityExists(args.EntityUid))
+ {
+ Logger.Warning($"{nameof(HandleRadialRequest)} called on a non-existent entity with id {args.EntityUid} by player {player}.");
+ return;
+ }
+
+ if (player.AttachedEntity is not {} attached)
+ {
+ Logger.Warning($"{nameof(HandleRadialRequest)} called by player {player} with no attached entity.");
+ return;
+ }
+
+ // We do not verify that the user has access to the requested entity. The individual verbs should check
+ // this, and some verbs (e.g. view variables) won't even care about whether an entity is accessible through
+ // the entity menu or not.
+
+ var force = args.AdminRequest && eventArgs.SenderSession is IPlayerSession playerSession &&
+ _adminMgr.HasAdminFlag(playerSession, AdminFlags.Admin);
+
+ List radialsTypes = new();
+ foreach (var key in args.RadialTypes)
+ {
+ var type = Radial.RadialTypes.FirstOrDefault(x => x.Name == key);
+
+ if (type != null)
+ radialsTypes.Add(type);
+ else
+ Logger.Error($"Unknown verb type received: {key}");
+ }
+
+ var response =
+ new RadialsResponseEvent(args.EntityUid, GetLocalRadials(args.EntityUid, attached, radialsTypes, force));
+ RaiseNetworkEvent(response, player.ConnectedClient);
+ }
+
+ ///
+ /// Execute the provided verb.
+ ///
+ ///
+ /// This will try to call the action delegates and raise the local events for the given verb.
+ ///
+ public override void ExecuteRadial(Radial radial, EntityUid user, EntityUid target, bool forced = false)
+ {
+ // is this verb actually valid?
+ if (radial.Disabled)
+ {
+ // Send an informative pop-up message
+ if (!string.IsNullOrWhiteSpace(radial.Message))
+ _popupSystem.PopupEntity(radial.Message, user, user);
+
+ return;
+ }
+
+ // first, lets log the verb. Just in case it ends up crashing the server or something.
+ LogRadial(radial, user, target, forced);
+
+ base.ExecuteRadial(radial, user, target, forced);
+ }
+
+ public void LogRadial(Radial radial, EntityUid user, EntityUid target, bool forced)
+ {
+ // first get the held item. again.
+ EntityUid? holding = null;
+ if (TryComp(user, out HandsComponent? hands) &&
+ hands.ActiveHandEntity is EntityUid heldEntity)
+ {
+ holding = heldEntity;
+ }
+
+ // if this is a virtual pull, get the held entity
+ if (holding != null && TryComp(holding, out HandVirtualItemComponent? pull))
+ holding = pull.BlockingEntity;
+
+ var verbText = $"{radial.Text}".Trim();
+
+ // lets not frame people, eh?
+ var executionText = forced ? "was forced to execute" : "executed";
+
+ if (holding == null)
+ {
+ _adminLogger.Add(LogType.Verb, radial.Impact,
+ $"{ToPrettyString(user):user} {executionText} the [{verbText:verb}] verb targeting {ToPrettyString(target):target}");
+ }
+ else
+ {
+ _adminLogger.Add(LogType.Verb, radial.Impact,
+ $"{ToPrettyString(user):user} {executionText} the [{verbText:verb}] verb targeting {ToPrettyString(target):target} while holding {ToPrettyString(holding.Value):held}");
+ }
+ }
+ }
diff --git a/Content.Shared/White/Radials/Radial.cs b/Content.Shared/White/Radials/Radial.cs
new file mode 100644
index 0000000000..9490dcb81c
--- /dev/null
+++ b/Content.Shared/White/Radials/Radial.cs
@@ -0,0 +1,122 @@
+using Content.Shared.Database;
+using Content.Shared.Interaction.Events;
+using Content.Shared.Verbs;
+using Robust.Shared.Serialization;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.White.Radials;
+
+ [Serializable, NetSerializable, Virtual]
+ public class Radial : IComparable
+ {
+
+ ///
+ /// Determines the priority of this type of verb when displaying in the verb-menu. See .
+ ///
+ public virtual int TypePriority => 0;
+
+ [NonSerialized]
+ public Action? Act;
+
+ [NonSerialized]
+ public object? ExecutionEventArgs;
+
+ [NonSerialized]
+ public EntityUid EventTarget = EntityUid.Invalid;
+
+ [NonSerialized]
+ public bool ClientExclusive;
+
+ public LogImpact Impact = LogImpact.Low;
+
+ public string Text = string.Empty;
+
+ public string? Icon;
+
+ public bool Disabled;
+
+ public string? Message;
+
+ public int Priority;
+
+ public EntityUid? IconEntity;
+
+ public bool? CloseMenu;
+
+ public virtual bool CloseMenuDefault => true;
+
+ public bool ConfirmationPopup = false;
+
+ public bool? DoContactInteraction;
+
+ public virtual bool DefaultDoContactInteraction => false;
+
+ public int CompareTo(object? obj)
+ {
+ if (obj is not Radial radial)
+ return -1;
+
+ // Sort first by type-priority
+ if (TypePriority != radial.TypePriority)
+ return radial.TypePriority - TypePriority;
+
+ // Then by verb-priority
+ if (Priority != radial.Priority)
+ return radial.Priority - Priority;
+
+ // Then try use alphabetical verb text.
+ if (Text != radial.Text)
+ {
+ return string.Compare(Text, radial.Text, StringComparison.CurrentCulture);
+ }
+
+ if (IconEntity != radial.IconEntity)
+ {
+ if (IconEntity == null)
+ return -1;
+
+ if (radial.IconEntity == null)
+ return 1;
+
+ return IconEntity.Value.CompareTo(radial.IconEntity.Value);
+ }
+
+ // Finally, compare icon texture paths. Note that this matters for verbs that don't have any text (e.g., the rotate-verbs)
+ return string.Compare(Icon?.ToString(), radial.Icon?.ToString(), StringComparison.CurrentCulture);
+ }
+
+ // I hate this. Please somebody allow generics to be networked.
+ ///
+ /// Collection of all verb types,
+ ///
+ ///
+ /// Useful when iterating over verb types, though maybe this should be obtained and stored via reflection or
+ /// something (list of all classes that inherit from Verb). Currently used for networking (apparently Type
+ /// is not serializable?), and resolving console commands.
+ ///
+ public static List RadialTypes = new()
+ {
+ typeof(Radial),
+ typeof(InteractionRadial),
+ };
+ }
+
+ ///
+ /// Primary interaction verbs. This includes both use-in-hand and interacting with external entities.
+ ///
+ ///
+ /// These verbs those that involve using the hands or the currently held item on some entity. These verbs usually
+ /// correspond to interactions that can be triggered by left-clicking or using 'Z', and often depend on the
+ /// currently held item. These verbs are collectively shown first in the context menu.
+ ///
+ [Serializable, NetSerializable]
+ public sealed class InteractionRadial : Radial
+ {
+ public override int TypePriority => 1;
+ public override bool DefaultDoContactInteraction => true;
+
+ public InteractionRadial() : base()
+ {
+ }
+ }
diff --git a/Content.Shared/White/Radials/RadialEvents.cs b/Content.Shared/White/Radials/RadialEvents.cs
new file mode 100644
index 0000000000..1501b4b220
--- /dev/null
+++ b/Content.Shared/White/Radials/RadialEvents.cs
@@ -0,0 +1,92 @@
+using Content.Shared.ActionBlocker;
+using Content.Shared.Hands.Components;
+using Content.Shared.Interaction;
+using Content.Shared.Verbs;
+using Robust.Shared.Containers;
+using Robust.Shared.Serialization;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.White.Radials;
+
+ [Serializable, NetSerializable]
+ public sealed class RequestServerRadialsEvent : EntityEventArgs
+ {
+ public readonly EntityUid EntityUid;
+
+ public readonly List RadialTypes = new();
+
+ public readonly EntityUid? SlotOwner;
+
+ public readonly bool AdminRequest;
+
+ public RequestServerRadialsEvent(EntityUid entityUid, IEnumerable radialTypes, EntityUid? slotOwner = null, bool adminRequest = false)
+ {
+ EntityUid = entityUid;
+ SlotOwner = slotOwner;
+ AdminRequest = adminRequest;
+
+ foreach (var type in radialTypes)
+ {
+ //DebugTools.Assert(typeof(Radial).IsAssignableFrom(type));
+ RadialTypes.Add(type.Name);
+ }
+ }
+ }
+
+ [Serializable, NetSerializable]
+ public sealed class RadialsResponseEvent : EntityEventArgs
+ {
+ public readonly List? Radials;
+ public readonly EntityUid Entity;
+
+ public RadialsResponseEvent(EntityUid entity, SortedSet? radials)
+ {
+ Entity = entity;
+
+ if (radials == null)
+ return;
+
+ Radials = new(radials);
+ }
+ }
+
+ [Serializable, NetSerializable]
+ public sealed class ExecuteRadialEvent : EntityEventArgs
+ {
+ public readonly EntityUid Target;
+ public readonly Radial RequestedRadial;
+
+ public ExecuteRadialEvent(EntityUid target, Radial requestedRadial)
+ {
+ Target = target;
+ RequestedRadial = requestedRadial;
+ }
+ }
+
+ public sealed class GetRadialsEvent : EntityEventArgs where TValue : Radial
+ {
+
+ public readonly SortedSet Radials = new();
+
+ public readonly bool CanAccess = false;
+
+ public readonly EntityUid Target;
+
+ public readonly EntityUid User;
+
+ public readonly bool CanInteract;
+
+ public readonly HandsComponent? Hands;
+
+ public readonly EntityUid? Using;
+
+ public GetRadialsEvent(EntityUid user, EntityUid target, EntityUid? @using, HandsComponent? hands, bool canInteract, bool canAccess)
+ {
+ User = user;
+ Target = target;
+ Using = @using;
+ Hands = hands;
+ CanAccess = canAccess;
+ CanInteract = canInteract;
+ }
+ }
diff --git a/Content.Shared/White/Radials/Systems/SharedRadialSystem.cs b/Content.Shared/White/Radials/Systems/SharedRadialSystem.cs
new file mode 100644
index 0000000000..3067ee79b8
--- /dev/null
+++ b/Content.Shared/White/Radials/Systems/SharedRadialSystem.cs
@@ -0,0 +1,128 @@
+using Content.Shared.ActionBlocker;
+using Content.Shared.Hands.Components;
+using Content.Shared.Interaction;
+using Content.Shared.Verbs;
+using Robust.Shared.Containers;
+
+namespace Content.Shared.White.Radials.Systems;
+
+public abstract class SharedRadialSystem : EntitySystem
+ {
+ [Dependency] private readonly SharedInteractionSystem _interactionSystem = default!;
+ [Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!;
+ [Dependency] protected readonly SharedContainerSystem ContainerSystem = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeAllEvent(HandleExecuteRadial);
+ }
+
+ private void HandleExecuteRadial(ExecuteRadialEvent args, EntitySessionEventArgs eventArgs)
+ {
+ var user = eventArgs.SenderSession.AttachedEntity;
+ if (user == null)
+ return;
+
+ if (Deleted(args.Target) || Deleted(user))
+ return;
+
+ var radials = GetLocalRadials(args.Target, user.Value, args.RequestedRadial.GetType());
+
+ if (radials.TryGetValue(args.RequestedRadial, out var radial))
+ ExecuteRadial(radial, user.Value, args.Target);
+ }
+
+ ///
+ /// Raises a number of events in order to get all verbs of the given type(s) defined in local systems. This
+ /// does not request verbs from the server.
+ ///
+ public SortedSet GetLocalRadials(EntityUid target, EntityUid user, Type type, bool force = false)
+ {
+ return GetLocalRadials(target, user, new List() { type }, force);
+ }
+
+ ///
+ /// Raises a number of events in order to get all verbs of the given type(s) defined in local systems. This
+ /// does not request verbs from the server.
+ ///
+ public SortedSet GetLocalRadials(EntityUid target, EntityUid user, List types, bool force = false)
+ {
+ SortedSet radials = new();
+
+ // accessibility checks
+ bool canAccess = false;
+ if (force || target == user)
+ canAccess = true;
+ else if (_interactionSystem.InRangeUnobstructed(user, target))
+ {
+ // Note that being in a container does not count as an obstruction for InRangeUnobstructed
+ // Therefore, we need extra checks to ensure the item is actually accessible:
+ if (ContainerSystem.IsInSameOrParentContainer(user, target))
+ canAccess = true;
+ else
+ // the item might be in a backpack that the user has open
+ canAccess = _interactionSystem.CanAccessViaStorage(user, target);
+ }
+
+ // A large number of verbs need to check action blockers. Instead of repeatedly having each system individually
+ // call ActionBlocker checks, just cache it for the verb request.
+ var canInteract = force || _actionBlockerSystem.CanInteract(user, target);
+
+ EntityUid? @using = null;
+ if (TryComp(user, out HandsComponent? hands) && (force || _actionBlockerSystem.CanUseHeldEntity(user)))
+ {
+ @using = hands.ActiveHandEntity;
+
+ // Check whether the "Held" entity is a virtual pull entity. If yes, set that as the entity being "Used".
+ // This allows you to do things like buckle a dragged person onto a surgery table, without click-dragging
+ // their sprite.
+
+ if (TryComp(@using, out HandVirtualItemComponent? pull))
+ {
+ @using = pull.BlockingEntity;
+ }
+ }
+
+ // TODO: fix this garbage and use proper generics or reflection or something else, not this.
+ if (types.Contains(typeof(InteractionRadial)))
+ {
+ var radialEvent = new GetRadialsEvent(user, target, @using, hands, canInteract, canAccess);
+ RaiseLocalEvent(target, radialEvent, true);
+ radials.UnionWith(radialEvent.Radials);
+ }
+
+ // generic verbs
+ if (types.Contains(typeof(Radial)))
+ {
+ var radialsEvent = new GetRadialsEvent(user, target, @using, hands, canInteract, canAccess);
+ RaiseLocalEvent(target, radialsEvent, true);
+ radials.UnionWith(radialsEvent.Radials);
+ }
+
+ return radials;
+ }
+
+ public virtual void ExecuteRadial(Radial radial, EntityUid user, EntityUid target, bool forced = false)
+ {
+ // invoke any relevant actions
+ radial.Act?.Invoke();
+
+ // Maybe raise a local event
+ if (radial.ExecutionEventArgs != null)
+ {
+ if (radial.EventTarget.IsValid())
+ RaiseLocalEvent(radial.EventTarget, radial.ExecutionEventArgs);
+ else
+ RaiseLocalEvent(radial.ExecutionEventArgs);
+ }
+
+ if (Deleted(user) || Deleted(target))
+ return;
+
+ // Perform any contact interactions
+ if (radial.DoContactInteraction ?? (radial.DefaultDoContactInteraction && _interactionSystem.InRangeUnobstructed(user, target)))
+ _interactionSystem.DoContactInteraction(user, target);
+ }
+ }