From 96ae4a31d8f1b15bc4c7798352bf72ae5a959e2a Mon Sep 17 00:00:00 2001 From: DocNight Date: Sun, 25 Jun 2023 18:19:34 +0300 Subject: [PATCH] [Feat] RadialContainer (#177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added radial button 🥳 * Added animations 🔔 * Added network radial classes (dont forget remove InternalsSystem.cs, i mean my test hook, And StorageBoundUserInterface.cs * Functionality meow 🏛️ * Фиксы * More tests * Support now more 8 buttons * Update StorageBoundUserInterface.cs --- Content.Client/White/Radials/RadialSystem.cs | 240 ++++++++++++++++ .../White/Radials/RadialUIController.cs | 126 +++++++++ .../UserInterface/Controls/RadialButton.xaml | 18 ++ .../Controls/RadialButton.xaml.cs | 39 +++ .../Controls/RadialContainer.xaml | 19 ++ .../Controls/RadialContainer.xaml.cs | 257 ++++++++++++++++++ Content.Server/White/Radials/RadialSystem.cs | 120 ++++++++ Content.Shared/White/Radials/Radial.cs | 122 +++++++++ Content.Shared/White/Radials/RadialEvents.cs | 92 +++++++ .../Radials/Systems/SharedRadialSystem.cs | 128 +++++++++ 10 files changed, 1161 insertions(+) create mode 100644 Content.Client/White/Radials/RadialSystem.cs create mode 100644 Content.Client/White/Radials/RadialUIController.cs create mode 100644 Content.Client/White/UserInterface/Controls/RadialButton.xaml create mode 100644 Content.Client/White/UserInterface/Controls/RadialButton.xaml.cs create mode 100644 Content.Client/White/UserInterface/Controls/RadialContainer.xaml create mode 100644 Content.Client/White/UserInterface/Controls/RadialContainer.xaml.cs create mode 100644 Content.Server/White/Radials/RadialSystem.cs create mode 100644 Content.Shared/White/Radials/Radial.cs create mode 100644 Content.Shared/White/Radials/RadialEvents.cs create mode 100644 Content.Shared/White/Radials/Systems/SharedRadialSystem.cs 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); + } + }