[Feat] RadialContainer (#177)
* 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
This commit is contained in:
240
Content.Client/White/Radials/RadialSystem.cs
Normal file
240
Content.Client/White/Radials/RadialSystem.cs
Normal file
@@ -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!;
|
||||
|
||||
/// <summary>
|
||||
/// When a user right clicks somewhere, how large is the box we use to get entities for the context menu?
|
||||
/// </summary>
|
||||
public const float EntityMenuLookupSize = 0.25f;
|
||||
|
||||
[Dependency] private readonly IEyeManager _eyeManager = default!;
|
||||
|
||||
/// <summary>
|
||||
/// These flags determine what entities the user can see on the context menu.
|
||||
/// </summary>
|
||||
public MenuVisibility Visibility;
|
||||
|
||||
public Action<RadialsResponseEvent>? OnRadialsResponse;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
SubscribeNetworkEvent<RadialsResponseEvent>(HandleRadialsResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all of the entities in an area for displaying on the context menu.
|
||||
/// </summary>
|
||||
public bool TryGetEntityMenuEntities(MapCoordinates targetPos, [NotNullWhen(true)] out List<EntityUid>? 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<EntityUid> 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<SpriteComponent>();
|
||||
var tagQuery = GetEntityQuery<TagComponent>();
|
||||
|
||||
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<TransformComponent>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks the server to send back a list of server-side verbs, for the given verb type.
|
||||
/// </summary>
|
||||
public SortedSet<Radial> GetRadials(EntityUid target, EntityUid user, Type type, bool force = false)
|
||||
{
|
||||
return GetRadials(target, user, new List<Type>() { type }, force);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public SortedSet<Radial> GetRadials(EntityUid target, EntityUid user, List<Type> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute actions associated with the given verb.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unless this is a client-exclusive verb, this will also tell the server to run the same verb.
|
||||
/// </remarks>
|
||||
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
|
||||
}
|
||||
126
Content.Client/White/Radials/RadialUIController.cs
Normal file
126
Content.Client/White/Radials/RadialUIController.cs
Normal file
@@ -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<GameplayState>, IOnStateExited<GameplayState>
|
||||
{
|
||||
[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<Radial> CurrentRadials = new();
|
||||
|
||||
/// <summary>
|
||||
/// Separate from <see cref="ContextMenuUIController.RootMenu"/>, 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Open a verb menu and fill it with verbs applicable to the given target entity.
|
||||
/// </summary>
|
||||
/// <param name="target">Entity to get verbs on.</param>
|
||||
/// <param name="force">Used to force showing all verbs (mostly for admins).</param>
|
||||
/// <param name="popup">
|
||||
/// If this is not null, verbs will be placed into the given popup instead.
|
||||
/// </param>
|
||||
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<Radial> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Control
|
||||
xmlns="https://spacestation14.io"
|
||||
xmlns:controls="clr-namespace:Content.Client.White.UserInterface.Controls">
|
||||
|
||||
<BoxContainer>
|
||||
<TextureButton
|
||||
Name="Controller"
|
||||
Access="Public"
|
||||
VerticalExpand="True"
|
||||
HorizontalExpand="True">
|
||||
<TextureRect Access="Public" Name="BackgroundTexture"
|
||||
VerticalExpand="True"
|
||||
HorizontalExpand="True"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Center" Stretch="KeepAspect">
|
||||
</TextureRect>
|
||||
</TextureButton>
|
||||
</BoxContainer>
|
||||
</Control>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<controls:RadialContainer
|
||||
xmlns="https://spacestation14.io"
|
||||
xmlns:controls="clr-namespace:Content.Client.White.UserInterface.Controls"
|
||||
Visible="False"
|
||||
MaxSize="0 0">
|
||||
<BoxContainer>
|
||||
<controls:RadialButton
|
||||
Name="CloseButton"
|
||||
Access="Public"
|
||||
VerticalExpand="True"
|
||||
HorizontalExpand="True"
|
||||
Content="Close"
|
||||
Texture="/Textures/Interface/Default/blocked.png"/>
|
||||
<LayoutContainer Access="Public" Name="Layout" HorizontalExpand="True" VerticalExpand="True"></LayoutContainer>
|
||||
<BoxContainer Access="Public" Name="ActionBox" HorizontalExpand="True" VerticalExpand="True">
|
||||
<Label Access="Public" Name="ActionLabel" HorizontalExpand="True" Visible="False"/>
|
||||
</BoxContainer>
|
||||
</BoxContainer>
|
||||
</controls:RadialContainer>
|
||||
@@ -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<IUserInterfaceManager>();
|
||||
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<IResourceCache>().GetTexture(_backgroundTexture);
|
||||
if (texture != null)
|
||||
button.BackgroundTexture.Texture = IoCManager.Resolve<IResourceCache>().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));
|
||||
}
|
||||
}
|
||||
120
Content.Server/White/Radials/RadialSystem.cs
Normal file
120
Content.Server/White/Radials/RadialSystem.cs
Normal file
@@ -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<RequestServerRadialsEvent>(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<Type> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute the provided verb.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This will try to call the action delegates and raise the local events for the given verb.
|
||||
/// </remarks>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
122
Content.Shared/White/Radials/Radial.cs
Normal file
122
Content.Shared/White/Radials/Radial.cs
Normal file
@@ -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
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Determines the priority of this type of verb when displaying in the verb-menu. See <see
|
||||
/// cref="CompareTo"/>.
|
||||
/// </summary>
|
||||
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.
|
||||
/// <summary>
|
||||
/// Collection of all verb types,
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static List<Type> RadialTypes = new()
|
||||
{
|
||||
typeof(Radial),
|
||||
typeof(InteractionRadial),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Primary interaction verbs. This includes both use-in-hand and interacting with external entities.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[Serializable, NetSerializable]
|
||||
public sealed class InteractionRadial : Radial
|
||||
{
|
||||
public override int TypePriority => 1;
|
||||
public override bool DefaultDoContactInteraction => true;
|
||||
|
||||
public InteractionRadial() : base()
|
||||
{
|
||||
}
|
||||
}
|
||||
92
Content.Shared/White/Radials/RadialEvents.cs
Normal file
92
Content.Shared/White/Radials/RadialEvents.cs
Normal file
@@ -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<string> RadialTypes = new();
|
||||
|
||||
public readonly EntityUid? SlotOwner;
|
||||
|
||||
public readonly bool AdminRequest;
|
||||
|
||||
public RequestServerRadialsEvent(EntityUid entityUid, IEnumerable<Type> 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<Radial>? Radials;
|
||||
public readonly EntityUid Entity;
|
||||
|
||||
public RadialsResponseEvent(EntityUid entity, SortedSet<Radial>? 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<TValue> : EntityEventArgs where TValue : Radial
|
||||
{
|
||||
|
||||
public readonly SortedSet<TValue> 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;
|
||||
}
|
||||
}
|
||||
128
Content.Shared/White/Radials/Systems/SharedRadialSystem.cs
Normal file
128
Content.Shared/White/Radials/Systems/SharedRadialSystem.cs
Normal file
@@ -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<ExecuteRadialEvent>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public SortedSet<Radial> GetLocalRadials(EntityUid target, EntityUid user, Type type, bool force = false)
|
||||
{
|
||||
return GetLocalRadials(target, user, new List<Type>() { type }, force);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public SortedSet<Radial> GetLocalRadials(EntityUid target, EntityUid user, List<Type> types, bool force = false)
|
||||
{
|
||||
SortedSet<Radial> 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<InteractionRadial>(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<Radial>(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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user