From d72de032fa74a3b2984e3a65428404d8633e380d Mon Sep 17 00:00:00 2001 From: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com> Date: Fri, 26 Apr 2024 18:12:55 +1000 Subject: [PATCH] Predicted BUIs (#5059) * Add TryGetOpenBUI Avoids having to get the component and openinterfaces separately. * Couple more helpers * entityquery * reviews * Shared BUIs * zawehdo * More boilerplate * Bunch more work * Building * Stuff * More state handling * API cleanup * Slight tweak * Tweaks * gabriel * Disposies * Active UI support * Lots of fixes - Fix states not applying properly, fix predicted messages, remove redundant message type, add RaiseUiMessage for an easy way to do it from shared, add the old BUI state change events back. * Fix test failures * weh * Remove unncessary closes. * release note --- RELEASE-NOTES.md | 6 +- .../EntitySystems/UserInterfaceSystem.cs | 82 +- .../EntitySystems/UserInterfaceSystem.cs | 415 +------ .../UserInterface/BoundUserInterface.cs | 22 +- .../UserInterface/IgnoreUIRangeComponent.cs | 6 +- .../UserInterface/PlayerBoundUserInterface.cs | 53 - .../ServerBoundUserInterfaceMessage.cs | 37 +- .../UserInterface/UserInterfaceComponent.cs | 117 +- .../UserInterfaceUserComponent.cs | 25 + .../Systems/SharedUserInterfaceSystem.cs | 1038 ++++++++++++++--- Robust.Shared/Map/EntityCoordinates.cs | 3 + .../Player/SharedPlayerManager.Sessions.cs | 5 +- .../Server/Maps/MapLoaderTest.cs | 1 + 13 files changed, 992 insertions(+), 818 deletions(-) rename {Robust.Server => Robust.Shared}/GameObjects/Components/UserInterface/IgnoreUIRangeComponent.cs (67%) delete mode 100644 Robust.Shared/GameObjects/Components/UserInterface/PlayerBoundUserInterface.cs create mode 100644 Robust.Shared/GameObjects/Components/UserInterface/UserInterfaceUserComponent.cs diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 0fbc0eba8..b9a6b33b9 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -35,7 +35,11 @@ END TEMPLATE--> ### Breaking changes -*None yet* +* Refactor UserInterfaceSystem. + - The API has been significantly cleaned up and standardised, most noticeably callers don't need to worry about TryGetUi and can rely on either HasUi, SetUiState, CloseUi, or OpenUi to handle their code as appropriate. + - Interface data is now stored via key rather than as a flat list which is a breaking change for YAML. + - BoundUserInterfaces can now be completely handled via Shared code. Existing Server-side callers will behave similarly to before. + - BoundUserInterfaces now properly close in many more situations, additionally they are now attached to the entity so reconnecting can re-open them and they can be serialized properly. ### New features diff --git a/Robust.Client/GameObjects/EntitySystems/UserInterfaceSystem.cs b/Robust.Client/GameObjects/EntitySystems/UserInterfaceSystem.cs index f50db2892..20a0225a6 100644 --- a/Robust.Client/GameObjects/EntitySystems/UserInterfaceSystem.cs +++ b/Robust.Client/GameObjects/EntitySystems/UserInterfaceSystem.cs @@ -1,84 +1,8 @@ -using Robust.Client.Player; using Robust.Shared.GameObjects; -using Robust.Shared.IoC; -using Robust.Shared.Reflection; -using System; -using UserInterfaceComponent = Robust.Shared.GameObjects.UserInterfaceComponent; -namespace Robust.Client.GameObjects +namespace Robust.Client.GameObjects; + +public sealed class UserInterfaceSystem : SharedUserInterfaceSystem { - public sealed class UserInterfaceSystem : SharedUserInterfaceSystem - { - [Dependency] private readonly IDynamicTypeFactory _dynamicTypeFactory = default!; - [Dependency] private readonly IPlayerManager _playerManager = default!; - [Dependency] private readonly IReflectionManager _reflectionManager = default!; - public override void Initialize() - { - base.Initialize(); - - SubscribeNetworkEvent(MessageReceived); - } - - private void MessageReceived(BoundUIWrapMessage ev) - { - var uid = GetEntity(ev.Entity); - - if (!TryComp(uid, out var cmp)) - return; - - var uiKey = ev.UiKey; - var message = ev.Message; - message.Session = _playerManager.LocalSession!; - message.Entity = GetNetEntity(uid); - message.UiKey = uiKey; - - // Raise as object so the correct type is used. - RaiseLocalEvent(uid, (object)message, true); - - switch (message) - { - case OpenBoundInterfaceMessage _: - TryOpenUi(uid, uiKey, cmp); - break; - - case CloseBoundInterfaceMessage _: - TryCloseUi(message.Session, uid, uiKey, remoteCall: true, uiComp: cmp); - break; - - default: - if (cmp.OpenInterfaces.TryGetValue(uiKey, out var bui)) - bui.InternalReceiveMessage(message); - - break; - } - } - - private bool TryOpenUi(EntityUid uid, Enum uiKey, UserInterfaceComponent? uiComp = null) - { - if (!Resolve(uid, ref uiComp)) - return false; - - if (uiComp.OpenInterfaces.ContainsKey(uiKey)) - return false; - - var data = uiComp.MappedInterfaceData[uiKey]; - - // TODO: This type should be cached, but I'm too lazy. - var type = _reflectionManager.LooseGetType(data.ClientType); - var boundInterface = - (BoundUserInterface) _dynamicTypeFactory.CreateInstance(type, new object[] {uid, uiKey}); - - boundInterface.Open(); - uiComp.OpenInterfaces[uiKey] = boundInterface; - - if (_playerManager.LocalSession is { } playerSession) - { - uiComp.Interfaces[uiKey]._subscribedSessions.Add(playerSession); - RaiseLocalEvent(uid, new BoundUIOpenedEvent(uiKey, uid, playerSession), true); - } - - return true; - } - } } diff --git a/Robust.Server/GameObjects/EntitySystems/UserInterfaceSystem.cs b/Robust.Server/GameObjects/EntitySystems/UserInterfaceSystem.cs index 3f87ff3fe..07fffe099 100644 --- a/Robust.Server/GameObjects/EntitySystems/UserInterfaceSystem.cs +++ b/Robust.Server/GameObjects/EntitySystems/UserInterfaceSystem.cs @@ -1,416 +1,9 @@ using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using Robust.Server.Player; -using Robust.Shared.Enums; +using System.Collections; using Robust.Shared.GameObjects; -using Robust.Shared.IoC; -using Robust.Shared.Player; -using Robust.Shared.Utility; -namespace Robust.Server.GameObjects +namespace Robust.Server.GameObjects; + +public sealed class UserInterfaceSystem : SharedUserInterfaceSystem { - public sealed class UserInterfaceSystem : SharedUserInterfaceSystem - { - [Dependency] private readonly IPlayerManager _playerMan = default!; - [Dependency] private readonly TransformSystem _xformSys = default!; - - private EntityQuery _ignoreUIRangeQuery; - - private readonly List _sessionCache = new(); - - /// - public override void Initialize() - { - base.Initialize(); - - SubscribeNetworkEvent(OnMessageReceived); - _playerMan.PlayerStatusChanged += OnPlayerStatusChanged; - - _ignoreUIRangeQuery = GetEntityQuery(); - } - - public override void Shutdown() - { - base.Shutdown(); - - _playerMan.PlayerStatusChanged -= OnPlayerStatusChanged; - } - - private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs args) - { - if (args.NewStatus != SessionStatus.Disconnected) - return; - - if (!OpenInterfaces.TryGetValue(args.Session, out var buis)) - return; - - foreach (var bui in buis.ToArray()) - { - CloseShared(bui, args.Session); - } - } - - /// - public override void Update(float frameTime) - { - var xformQuery = GetEntityQuery(); - var query = AllEntityQuery(); - - while (query.MoveNext(out var uid, out var activeUis, out var xform)) - { - foreach (var ui in activeUis.Interfaces) - { - CheckRange(uid, activeUis, ui, xform, xformQuery); - - if (!ui.StateDirty) - continue; - - ui.StateDirty = false; - - foreach (var (player, state) in ui.PlayerStateOverrides) - { - RaiseNetworkEvent(state, player.Channel); - } - - if (ui.LastStateMsg == null) - continue; - - foreach (var session in ui.SubscribedSessions) - { - if (!ui.PlayerStateOverrides.ContainsKey(session)) - RaiseNetworkEvent(ui.LastStateMsg, session.Channel); - } - } - } - } - - /// - /// Verify that the subscribed clients are still in range of the interface. - /// - private void CheckRange(EntityUid uid, ActiveUserInterfaceComponent activeUis, PlayerBoundUserInterface ui, TransformComponent transform, EntityQuery query) - { - if (ui.InteractionRange <= 0) - return; - - // We have to cache the set of sessions because Unsubscribe modifies the original. - _sessionCache.Clear(); - _sessionCache.AddRange(ui.SubscribedSessions); - - var uiPos = _xformSys.GetWorldPosition(transform, query); - var uiMap = transform.MapID; - - foreach (var session in _sessionCache) - { - // The component manages the set of sessions, so this invalid session should be removed soon. - if (!query.TryGetComponent(session.AttachedEntity, out var xform)) - continue; - - if (_ignoreUIRangeQuery.HasComponent(session.AttachedEntity)) - continue; - - // Handle pluggable BoundUserInterfaceCheckRangeEvent - var checkRangeEvent = new BoundUserInterfaceCheckRangeEvent(uid, ui, session); - RaiseLocalEvent(uid, ref checkRangeEvent, broadcast: true); - if (checkRangeEvent.Result == BoundUserInterfaceRangeResult.Pass) - continue; - - if (checkRangeEvent.Result == BoundUserInterfaceRangeResult.Fail) - { - CloseUi(ui, session, activeUis); - continue; - } - - DebugTools.Assert(checkRangeEvent.Result == BoundUserInterfaceRangeResult.Default); - - if (uiMap != xform.MapID) - { - CloseUi(ui, session, activeUis); - continue; - } - - var distanceSquared = (uiPos - _xformSys.GetWorldPosition(xform, query)).LengthSquared(); - if (distanceSquared > ui.InteractionRangeSqrd) - CloseUi(ui, session, activeUis); - } - } - - #region Get BUI - - public bool HasUi(EntityUid uid, Enum uiKey, UserInterfaceComponent? ui = null) - { - if (!Resolve(uid, ref ui)) - return false; - - return ui.Interfaces.ContainsKey(uiKey); - } - - public PlayerBoundUserInterface GetUi(EntityUid uid, Enum uiKey, UserInterfaceComponent? ui = null) - { - if (!Resolve(uid, ref ui)) - throw new InvalidOperationException($"Cannot get {typeof(PlayerBoundUserInterface)} from an entity without {typeof(UserInterfaceComponent)}!"); - - return ui.Interfaces[uiKey]; - } - - public PlayerBoundUserInterface? GetUiOrNull(EntityUid uid, Enum uiKey, UserInterfaceComponent? ui = null) - { - return TryGetUi(uid, uiKey, out var bui, ui) - ? bui - : null; - } - - /// - /// Return UIs a session has open. - /// Null if empty. - /// - public List? GetAllUIsForSession(ICommonSession session) - { - OpenInterfaces.TryGetValue(session, out var value); - return value; - } - #endregion - - public bool IsUiOpen(EntityUid uid, Enum uiKey, UserInterfaceComponent? ui = null) - { - if (!TryGetUi(uid, uiKey, out var bui, ui)) - return false; - - return bui.SubscribedSessions.Count > 0; - } - - public bool SessionHasOpenUi(EntityUid uid, Enum uiKey, ICommonSession session, UserInterfaceComponent? ui = null) - { - if (!TryGetUi(uid, uiKey, out var bui, ui)) - return false; - - return bui.SubscribedSessions.Contains(session); - } - - /// - /// Sets a state. This can be used for stateful UI updating. - /// This state is sent to all clients, and automatically sent to all new clients when they open the UI. - /// Pretty much how NanoUI did it back in ye olde BYOND. - /// - /// - /// The state object that will be sent to all current and future client. - /// This can be null. - /// - /// - /// The player session to send this new state to. - /// Set to null for sending it to every subscribed player session. - /// - public bool TrySetUiState(EntityUid uid, - Enum uiKey, - BoundUserInterfaceState state, - ICommonSession? session = null, - UserInterfaceComponent? ui = null, - bool clearOverrides = true) - { - if (!TryGetUi(uid, uiKey, out var bui, ui)) - return false; - - SetUiState(bui, state, session, clearOverrides); - return true; - } - - /// - /// Sets a state. This can be used for stateful UI updating. - /// This state is sent to all clients, and automatically sent to all new clients when they open the UI. - /// Pretty much how NanoUI did it back in ye olde BYOND. - /// - /// - /// The state object that will be sent to all current and future client. - /// This can be null. - /// - /// - /// The player session to send this new state to. - /// Set to null for sending it to every subscribed player session. - /// - public void SetUiState(PlayerBoundUserInterface bui, BoundUserInterfaceState state, ICommonSession? session = null, bool clearOverrides = true) - { - var msg = new BoundUIWrapMessage(GetNetEntity(bui.Owner), new UpdateBoundStateMessage(state), bui.UiKey); - if (session == null) - { - bui.LastStateMsg = msg; - if (clearOverrides) - bui.PlayerStateOverrides.Clear(); - } - else - { - bui.PlayerStateOverrides[session] = msg; - } - - bui.StateDirty = true; - } - - #region Close - protected override void CloseShared(PlayerBoundUserInterface bui, ICommonSession session, ActiveUserInterfaceComponent? activeUis = null) - { - var owner = bui.Owner; - bui._subscribedSessions.Remove(session); - bui.PlayerStateOverrides.Remove(session); - - if (OpenInterfaces.TryGetValue(session, out var buis)) - buis.Remove(bui); - - RaiseLocalEvent(owner, new BoundUIClosedEvent(bui.UiKey, owner, session)); - - if (bui._subscribedSessions.Count == 0) - DeactivateInterface(bui.Owner, bui, activeUis); - } - - /// - /// Closes this all interface for any clients that have any open. - /// - public bool TryCloseAll(EntityUid uid, Shared.GameObjects.ActiveUserInterfaceComponent? aui = null) - { - if (!Resolve(uid, ref aui, false)) - return false; - - foreach (var ui in aui.Interfaces) - { - CloseAll(ui); - } - - return true; - } - - /// - /// Closes this specific interface for any clients that have it open. - /// - public bool TryCloseAll(EntityUid uid, Enum uiKey, UserInterfaceComponent? ui = null) - { - if (!TryGetUi(uid, uiKey, out var bui, ui)) - return false; - - CloseAll(bui); - return true; - } - - /// - /// Closes this interface for any clients that have it open. - /// - public void CloseAll(PlayerBoundUserInterface bui) - { - foreach (var session in bui.SubscribedSessions.ToArray()) - { - CloseUi(bui, session); - } - } - - #endregion - - #region SendMessage - - /// - /// Send a BUI message to all connected player sessions. - /// - public bool TrySendUiMessage(EntityUid uid, Enum uiKey, BoundUserInterfaceMessage message, UserInterfaceComponent? ui = null) - { - if (!TryGetUi(uid, uiKey, out var bui, ui)) - return false; - - SendUiMessage(bui, message); - return true; - } - - /// - /// Send a BUI message to all connected player sessions. - /// - public void SendUiMessage(PlayerBoundUserInterface bui, BoundUserInterfaceMessage message) - { - var msg = new BoundUIWrapMessage(GetNetEntity(bui.Owner), message, bui.UiKey); - foreach (var session in bui.SubscribedSessions) - { - RaiseNetworkEvent(msg, session.Channel); - } - } - - /// - /// Send a BUI message to a specific player session. - /// - public bool TrySendUiMessage(EntityUid uid, Enum uiKey, BoundUserInterfaceMessage message, ICommonSession session, UserInterfaceComponent? ui = null) - { - if (!TryGetUi(uid, uiKey, out var bui, ui)) - return false; - - return TrySendUiMessage(bui, message, session); - } - - /// - /// Send a BUI message to a specific player session. - /// - public bool TrySendUiMessage(PlayerBoundUserInterface bui, BoundUserInterfaceMessage message, ICommonSession session) - { - if (!bui.SubscribedSessions.Contains(session)) - return false; - - RaiseNetworkEvent(new BoundUIWrapMessage(GetNetEntity(bui.Owner), message, bui.UiKey), session.Channel); - return true; - } - - #endregion - } - - /// - /// Raised by to check whether an interface is still accessible by its user. - /// - [ByRefEvent] - [PublicAPI] - public struct BoundUserInterfaceCheckRangeEvent - { - /// - /// The entity owning the UI being checked for. - /// - public readonly EntityUid Target; - - /// - /// The UI itself. - /// - /// - public readonly PlayerBoundUserInterface UserInterface; - - /// - /// The player for which the UI is being checked. - /// - public readonly ICommonSession Player; - - /// - /// The result of the range check. - /// - public BoundUserInterfaceRangeResult Result; - - public BoundUserInterfaceCheckRangeEvent( - EntityUid target, - PlayerBoundUserInterface userInterface, - ICommonSession player) - { - Target = target; - UserInterface = userInterface; - Player = player; - } - } - - /// - /// Possible results for a . - /// - public enum BoundUserInterfaceRangeResult : byte - { - /// - /// Run built-in range check. - /// - Default, - - /// - /// Range check passed, UI is accessible. - /// - Pass, - - /// - /// Range check failed, UI is inaccessible. - /// - Fail - } } diff --git a/Robust.Shared/GameObjects/Components/UserInterface/BoundUserInterface.cs b/Robust.Shared/GameObjects/Components/UserInterface/BoundUserInterface.cs index b6c6fb6bd..00aa9f33a 100644 --- a/Robust.Shared/GameObjects/Components/UserInterface/BoundUserInterface.cs +++ b/Robust.Shared/GameObjects/Components/UserInterface/BoundUserInterface.cs @@ -41,14 +41,14 @@ namespace Robust.Shared.GameObjects /// /// Invoked when the server uses SetState. /// - protected virtual void UpdateState(BoundUserInterfaceState state) + protected internal virtual void UpdateState(BoundUserInterfaceState state) { } /// /// Invoked when the server sends an arbitrary message. /// - protected virtual void ReceiveMessage(BoundUserInterfaceMessage message) + protected internal virtual void ReceiveMessage(BoundUserInterfaceMessage message) { } @@ -57,7 +57,7 @@ namespace Robust.Shared.GameObjects /// public void Close() { - UiSystem.TryCloseUi(_playerManager.LocalSession, Owner, UiKey); + UiSystem.CloseUi(Owner, UiKey, _playerManager.LocalEntity, predicted: true); } /// @@ -65,7 +65,7 @@ namespace Robust.Shared.GameObjects /// public void SendMessage(BoundUserInterfaceMessage message) { - UiSystem.SendUiMessage(this, message); + UiSystem.ClientSendUiMessage(Owner, UiKey, message); } public void SendPredictedMessage(BoundUserInterfaceMessage message) @@ -73,20 +73,6 @@ namespace Robust.Shared.GameObjects UiSystem.SendPredictedUiMessage(this, message); } - internal void InternalReceiveMessage(BoundUserInterfaceMessage message) - { - switch (message) - { - case UpdateBoundStateMessage updateBoundStateMessage: - State = updateBoundStateMessage.State; - UpdateState(State); - break; - default: - ReceiveMessage(message); - break; - } - } - ~BoundUserInterface() { Dispose(false); diff --git a/Robust.Server/GameObjects/Components/UserInterface/IgnoreUIRangeComponent.cs b/Robust.Shared/GameObjects/Components/UserInterface/IgnoreUIRangeComponent.cs similarity index 67% rename from Robust.Server/GameObjects/Components/UserInterface/IgnoreUIRangeComponent.cs rename to Robust.Shared/GameObjects/Components/UserInterface/IgnoreUIRangeComponent.cs index db00a0c06..b6fa9aaf9 100644 --- a/Robust.Server/GameObjects/Components/UserInterface/IgnoreUIRangeComponent.cs +++ b/Robust.Shared/GameObjects/Components/UserInterface/IgnoreUIRangeComponent.cs @@ -1,12 +1,12 @@ -using Robust.Shared.GameObjects; +using Robust.Shared.GameStates; -namespace Robust.Server.GameObjects; +namespace Robust.Shared.GameObjects; /// /// Lets any entities with this component ignore user interface range checks that would normally /// close the UI automatically. /// -[RegisterComponent] +[RegisterComponent, NetworkedComponent] public sealed partial class IgnoreUIRangeComponent : Component { } diff --git a/Robust.Shared/GameObjects/Components/UserInterface/PlayerBoundUserInterface.cs b/Robust.Shared/GameObjects/Components/UserInterface/PlayerBoundUserInterface.cs deleted file mode 100644 index f8b0790b3..000000000 --- a/Robust.Shared/GameObjects/Components/UserInterface/PlayerBoundUserInterface.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using Robust.Shared.Player; -using Robust.Shared.ViewVariables; - -namespace Robust.Shared.GameObjects; - -/// -/// Represents an entity-bound interface that can be opened by multiple players at once. -/// -[PublicAPI] -public sealed class PlayerBoundUserInterface -{ - [ViewVariables] - public float InteractionRange; - - [ViewVariables] - public float InteractionRangeSqrd => InteractionRange * InteractionRange; - - [ViewVariables] - public Enum UiKey { get; } - [ViewVariables] - public EntityUid Owner { get; } - - internal readonly HashSet _subscribedSessions = new(); - [ViewVariables] - internal BoundUIWrapMessage? LastStateMsg; - [ViewVariables(VVAccess.ReadWrite)] - public bool RequireInputValidation; - - [ViewVariables] - internal bool StateDirty; - - [ViewVariables] - internal readonly Dictionary PlayerStateOverrides = - new(); - - /// - /// All of the sessions currently subscribed to this UserInterface. - /// - [ViewVariables] - public IReadOnlySet SubscribedSessions => _subscribedSessions; - - public PlayerBoundUserInterface(PrototypeData data, EntityUid owner) - { - RequireInputValidation = data.RequireInputValidation; - UiKey = data.UiKey; - Owner = owner; - - InteractionRange = data.InteractionRange; - } -} diff --git a/Robust.Shared/GameObjects/Components/UserInterface/ServerBoundUserInterfaceMessage.cs b/Robust.Shared/GameObjects/Components/UserInterface/ServerBoundUserInterfaceMessage.cs index f89fcd13e..27afadc44 100644 --- a/Robust.Shared/GameObjects/Components/UserInterface/ServerBoundUserInterfaceMessage.cs +++ b/Robust.Shared/GameObjects/Components/UserInterface/ServerBoundUserInterfaceMessage.cs @@ -1,29 +1,26 @@ -using System.Collections.Generic; using JetBrains.Annotations; +using Robust.Shared.GameStates; using Robust.Shared.Player; using Robust.Shared.ViewVariables; -namespace Robust.Shared.GameObjects +namespace Robust.Shared.GameObjects; + +[RegisterComponent, NetworkedComponent] +public sealed partial class ActiveUserInterfaceComponent : Component { - [RegisterComponent] - public sealed partial class ActiveUserInterfaceComponent : Component - { - [ViewVariables] - public HashSet Interfaces = new(); - } +} - [PublicAPI] - public sealed class ServerBoundUserInterfaceMessage - { - [ViewVariables] - public BoundUserInterfaceMessage Message { get; } - [ViewVariables] - public ICommonSession Session { get; } +[PublicAPI] +public sealed class ServerBoundUserInterfaceMessage +{ + [ViewVariables] + public BoundUserInterfaceMessage Message { get; } + [ViewVariables] + public ICommonSession Session { get; } - public ServerBoundUserInterfaceMessage(BoundUserInterfaceMessage message, ICommonSession session) - { - Message = message; - Session = session; - } + public ServerBoundUserInterfaceMessage(BoundUserInterfaceMessage message, ICommonSession session) + { + Message = message; + Session = session; } } diff --git a/Robust.Shared/GameObjects/Components/UserInterface/UserInterfaceComponent.cs b/Robust.Shared/GameObjects/Components/UserInterface/UserInterfaceComponent.cs index 2e8808e8b..42cc789c2 100644 --- a/Robust.Shared/GameObjects/Components/UserInterface/UserInterfaceComponent.cs +++ b/Robust.Shared/GameObjects/Components/UserInterface/UserInterfaceComponent.cs @@ -8,36 +8,51 @@ using Robust.Shared.ViewVariables; namespace Robust.Shared.GameObjects { - [RegisterComponent, NetworkedComponent] + [RegisterComponent, NetworkedComponent, Access(typeof(SharedUserInterfaceSystem))] public sealed partial class UserInterfaceComponent : Component { - // TODO: Obviously clean this shit up, I just moved it into shared. + /// + /// The currently open interfaces. Used clientside to store the UI. + /// + [ViewVariables, Access(Friend = AccessPermissions.ReadWriteExecute, Other = AccessPermissions.ReadWriteExecute)] + public readonly Dictionary ClientOpenInterfaces = new(); - [ViewVariables] public readonly Dictionary OpenInterfaces = new(); - - [ViewVariables] public readonly Dictionary Interfaces = new(); - - public Dictionary MappedInterfaceData = new(); + [DataField] + internal Dictionary Interfaces = new(); /// - /// Loaded on Init from serialized data. + /// Actors that currently have interfaces open. /// - [DataField("interfaces")] internal List InterfaceData = new(); + [DataField] + public Dictionary> Actors = new(); + + /// + /// Legacy data, new BUIs should be using comp states. + /// + public Dictionary States = new(); + + [Serializable, NetSerializable] + internal sealed class UserInterfaceComponentState( + Dictionary> actors, + Dictionary states) + : IComponentState + { + public Dictionary> Actors = actors; + + public Dictionary States = states; + } } [DataDefinition] - public sealed partial class PrototypeData + public sealed partial class InterfaceData { - [DataField("key", required: true)] - public Enum UiKey { get; private set; } = default!; - [DataField("type", required: true)] public string ClientType { get; private set; } = default!; /// /// Maximum range before a BUI auto-closes. A non-positive number means there is no limit. /// - [DataField("range")] + [DataField] public float InteractionRange = 2f; // TODO BUI move to content? @@ -48,7 +63,7 @@ namespace Robust.Shared.GameObjects /// /// Avoids requiring each system to individually validate client inputs. However, perhaps some BUIs are supposed to be bypass accessibility checks /// - [DataField("requireInputValidation")] + [DataField] public bool RequireInputValidation = true; } @@ -56,18 +71,12 @@ namespace Robust.Shared.GameObjects /// Raised whenever the server receives a BUI message from a client relating to a UI that requires input /// validation. /// - public sealed class BoundUserInterfaceMessageAttempt : CancellableEntityEventArgs + public sealed class BoundUserInterfaceMessageAttempt(EntityUid actor, EntityUid target, Enum uiKey) + : CancellableEntityEventArgs { - public readonly ICommonSession Sender; - public readonly EntityUid Target; - public readonly Enum UiKey; - - public BoundUserInterfaceMessageAttempt(ICommonSession sender, EntityUid target, Enum uiKey) - { - Sender = sender; - Target = target; - UiKey = uiKey; - } + public readonly EntityUid Actor = actor; + public readonly EntityUid Target = target; + public readonly Enum UiKey = uiKey; } [NetSerializable, Serializable] @@ -104,7 +113,7 @@ namespace Robust.Shared.GameObjects /// Only set when the message is raised as a directed event. /// [NonSerialized] - public ICommonSession Session = default!; + public EntityUid Actor = default!; } /// @@ -120,17 +129,6 @@ namespace Robust.Shared.GameObjects public NetEntity Entity { get; set; } = NetEntity.Invalid; } - [NetSerializable, Serializable] - internal sealed class UpdateBoundStateMessage : BoundUserInterfaceMessage - { - public readonly BoundUserInterfaceState State; - - public UpdateBoundStateMessage(BoundUserInterfaceState state) - { - State = state; - } - } - [NetSerializable, Serializable] internal sealed class OpenBoundInterfaceMessage : BoundUserInterfaceMessage { @@ -142,59 +140,38 @@ namespace Robust.Shared.GameObjects } [Serializable, NetSerializable] - internal abstract class BaseBoundUIWrapMessage : EntityEventArgs + internal abstract class BaseBoundUIWrapMessage(NetEntity entity, BoundUserInterfaceMessage message, Enum uiKey) + : EntityEventArgs { - public readonly NetEntity Entity; - public readonly BoundUserInterfaceMessage Message; - public readonly Enum UiKey; - - public BaseBoundUIWrapMessage(NetEntity entity, BoundUserInterfaceMessage message, Enum uiKey) - { - Message = message; - UiKey = uiKey; - Entity = entity; - } + public readonly NetEntity Entity = entity; + public readonly BoundUserInterfaceMessage Message = message; + public readonly Enum UiKey = uiKey; } /// /// Helper message raised from client to server. /// [Serializable, NetSerializable] - internal sealed class BoundUIWrapMessage : BaseBoundUIWrapMessage - { - public BoundUIWrapMessage(NetEntity entity, BoundUserInterfaceMessage message, Enum uiKey) : base(entity, message, uiKey) - { - } - } - - /// - /// Helper message raised from client to server. - /// - [Serializable, NetSerializable] - internal sealed class PredictedBoundUIWrapMessage : BaseBoundUIWrapMessage - { - public PredictedBoundUIWrapMessage(NetEntity entity, BoundUserInterfaceMessage message, Enum uiKey) : base(entity, message, uiKey) - { - } - } + internal sealed class BoundUIWrapMessage(NetEntity entity, BoundUserInterfaceMessage message, Enum uiKey) + : BaseBoundUIWrapMessage(entity, message, uiKey); public sealed class BoundUIOpenedEvent : BaseLocalBoundUserInterfaceEvent { - public BoundUIOpenedEvent(Enum uiKey, EntityUid uid, ICommonSession session) + public BoundUIOpenedEvent(Enum uiKey, EntityUid uid, EntityUid actor) { UiKey = uiKey; Entity = uid; - Session = session; + Actor = actor; } } public sealed class BoundUIClosedEvent : BaseLocalBoundUserInterfaceEvent { - public BoundUIClosedEvent(Enum uiKey, EntityUid uid, ICommonSession session) + public BoundUIClosedEvent(Enum uiKey, EntityUid uid, EntityUid actor) { UiKey = uiKey; Entity = uid; - Session = session; + Actor = actor; } } } diff --git a/Robust.Shared/GameObjects/Components/UserInterface/UserInterfaceUserComponent.cs b/Robust.Shared/GameObjects/Components/UserInterface/UserInterfaceUserComponent.cs new file mode 100644 index 000000000..f2e09def5 --- /dev/null +++ b/Robust.Shared/GameObjects/Components/UserInterface/UserInterfaceUserComponent.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using Robust.Shared.GameStates; +using Robust.Shared.Serialization; +using Robust.Shared.Serialization.Manager.Attributes; + +namespace Robust.Shared.GameObjects; + +/// +/// Stores data about this entity and what BUIs they have open. +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class UserInterfaceUserComponent : Component +{ + public override bool SessionSpecific => true; + + [DataField] + public Dictionary> OpenInterfaces = new(); +} + +[Serializable, NetSerializable] +internal sealed class UserInterfaceUserComponentState : IComponentState +{ + public Dictionary> OpenInterfaces = new(); +} diff --git a/Robust.Shared/GameObjects/Systems/SharedUserInterfaceSystem.cs b/Robust.Shared/GameObjects/Systems/SharedUserInterfaceSystem.cs index 5ad50f9d9..4bbd6da6a 100644 --- a/Robust.Shared/GameObjects/Systems/SharedUserInterfaceSystem.cs +++ b/Robust.Shared/GameObjects/Systems/SharedUserInterfaceSystem.cs @@ -1,54 +1,79 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using Robust.Shared.Enums; +using System.Linq; +using System.Runtime.InteropServices; +using JetBrains.Annotations; +using Robust.Shared.Collections; +using Robust.Shared.GameStates; +using Robust.Shared.IoC; +using Robust.Shared.Map; +using Robust.Shared.Network; using Robust.Shared.Player; +using Robust.Shared.Reflection; +using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Robust.Shared.GameObjects; public abstract class SharedUserInterfaceSystem : EntitySystem { - protected readonly Dictionary> OpenInterfaces = new(); + [Dependency] private readonly IDynamicTypeFactory _factory = default!; + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly INetManager _netManager = default!; + [Dependency] private readonly IReflectionManager _reflection = default!; + [Dependency] private readonly ISharedPlayerManager _player = default!; + [Dependency] private readonly SharedTransformSystem _transforms = default!; + + private EntityQuery _ignoreUIRangeQuery; + private EntityQuery _xformQuery; + private EntityQuery _uiQuery; + private EntityQuery _userQuery; public override void Initialize() { base.Initialize(); - SubscribeAllEvent(OnMessageReceived); - SubscribeLocalEvent(OnUserInterfaceInit); + + _ignoreUIRangeQuery = GetEntityQuery(); + _xformQuery = GetEntityQuery(); + _uiQuery = GetEntityQuery(); + _userQuery = GetEntityQuery(); + + SubscribeAllEvent((msg, args) => + { + if (args.SenderSession.AttachedEntity is not { } player) + return; + + OnMessageReceived(msg, player); + }); + + SubscribeLocalEvent(OnUserInterfaceOpen); + SubscribeLocalEvent(OnUserInterfaceClosed); SubscribeLocalEvent(OnUserInterfaceShutdown); - } + SubscribeLocalEvent(OnUserInterfaceGetState); + SubscribeLocalEvent(OnUserInterfaceHandleState); - private void OnUserInterfaceInit(EntityUid uid, UserInterfaceComponent component, ComponentInit args) - { - component.Interfaces.Clear(); + SubscribeLocalEvent(OnPlayerAttached); + SubscribeLocalEvent(OnPlayerDetached); - foreach (var prototypeData in component.InterfaceData) - { - AddUi((uid, component), prototypeData); - } - } - - private void OnUserInterfaceShutdown(EntityUid uid, UserInterfaceComponent component, ComponentShutdown args) - { - if (!TryComp(uid, out ActiveUserInterfaceComponent? activeUis)) - return; - - foreach (var bui in activeUis.Interfaces) - { - DeactivateInterface(uid, bui, activeUis); - } + SubscribeLocalEvent(OnGetStateAttempt); + SubscribeLocalEvent(OnActorGetState); + SubscribeLocalEvent(OnActorHandleState); } /// - /// Validates the received message, and then pass it onto systems/components + /// Validates the received message, and then pass it onto systems/components /// - internal void OnMessageReceived(BaseBoundUIWrapMessage msg, EntitySessionEventArgs args) + private void OnMessageReceived(BoundUIWrapMessage msg, EntityUid sender) { + // This is more or less the main BUI method that handles all messages. + var uid = GetEntity(msg.Entity); - if (!TryComp(uid, out UserInterfaceComponent? uiComp) || args.SenderSession is not { } session) + if (!_uiQuery.TryComp(uid, out var uiComp)) + { return; + } if (!uiComp.Interfaces.TryGetValue(msg.UiKey, out var ui)) { @@ -56,23 +81,20 @@ public abstract class SharedUserInterfaceSystem : EntitySystem return; } - if (!ui.SubscribedSessions.Contains(session)) + // If it's not an open message check we're even a subscriber. + if (msg.Message is not OpenBoundInterfaceMessage && + (!uiComp.Actors.TryGetValue(msg.UiKey, out var actors) || + !actors.Contains(sender))) { - Log.Debug($"UI {msg.UiKey} got BoundInterfaceMessageWrapMessage from a client who was not subscribed: {session}"); - return; - } - - // if they want to close the UI, we can go home early. - if (msg.Message is CloseBoundInterfaceMessage) - { - CloseShared(ui, session); + Log.Debug($"UI {msg.UiKey} got BoundInterfaceMessageWrapMessage from a client who was not subscribed: {ToPrettyString(sender)}"); return; } // verify that the user is allowed to press buttons on this UI: - if (ui.RequireInputValidation) + // If it's a close message something else might try to cancel it but we want to force it. + if (msg.Message is not CloseBoundInterfaceMessage && ui.RequireInputValidation) { - var attempt = new BoundUserInterfaceMessageAttempt(args.SenderSession, uid, msg.UiKey); + var attempt = new BoundUserInterfaceMessageAttempt(sender, uid, msg.UiKey); RaiseLocalEvent(attempt); if (attempt.Cancelled) return; @@ -80,172 +102,866 @@ public abstract class SharedUserInterfaceSystem : EntitySystem // get the wrapped message and populate it with the sender & UI key information. var message = msg.Message; - message.Session = args.SenderSession; + message.Actor = sender; message.Entity = msg.Entity; message.UiKey = msg.UiKey; + if (uiComp.ClientOpenInterfaces.TryGetValue(msg.UiKey, out var cBui)) + { + cBui.ReceiveMessage(message); + } + // Raise as object so the correct type is used. RaiseLocalEvent(uid, (object)message, true); } - protected void DeactivateInterface(EntityUid entityUid, PlayerBoundUserInterface ui, - ActiveUserInterfaceComponent? activeUis = null) - { - if (!Resolve(entityUid, ref activeUis, false)) - return; + #region User - activeUis.Interfaces.Remove(ui); - if (activeUis.Interfaces.Count == 0) - RemCompDeferred(entityUid, activeUis); + private void OnGetStateAttempt(Entity ent, ref ComponentGetStateAttemptEvent args) + { + if (args.Cancelled || args.Player?.AttachedEntity != ent.Owner) + args.Cancelled = true; } - protected virtual void CloseShared(PlayerBoundUserInterface bui, ICommonSession session, - ActiveUserInterfaceComponent? activeUis = null) + private void OnActorGetState(Entity ent, ref ComponentGetState args) { - } + var interfaces = new Dictionary>(); - /// - /// Add a UI after an entity has been created. - /// It cannot be added already. - /// - public void AddUi(Entity ent, PrototypeData data) - { - if (!Resolve(ent, ref ent.Comp)) - return; - - ent.Comp.Interfaces[data.UiKey] = new PlayerBoundUserInterface(data, ent); - ent.Comp.MappedInterfaceData[data.UiKey] = data; - } - - public bool TryGetUi(EntityUid uid, Enum uiKey, [NotNullWhen(true)] out PlayerBoundUserInterface? bui, UserInterfaceComponent? ui = null) - { - bui = null; - - return Resolve(uid, ref ui, false) && ui.Interfaces.TryGetValue(uiKey, out bui); - } - - /// - /// Switches between closed and open for a specific client. - /// - public virtual bool TryToggleUi(EntityUid uid, Enum uiKey, ICommonSession session, UserInterfaceComponent? ui = null) - { - if (!TryGetUi(uid, uiKey, out var bui, ui)) - return false; - - ToggleUi(bui, session); - return true; - } - - /// - /// Switches between closed and open for a specific client. - /// - public void ToggleUi(PlayerBoundUserInterface bui, ICommonSession session) - { - if (bui._subscribedSessions.Contains(session)) - CloseUi(bui, session); - else - OpenUi(bui, session); - } - - public bool TryOpen(EntityUid uid, Enum uiKey, ICommonSession session, UserInterfaceComponent? ui = null) - { - if (!TryGetUi(uid, uiKey, out var bui, ui)) - return false; - - return OpenUi(bui, session); - } - - /// - /// Opens this interface for a specific client. - /// - public bool OpenUi(PlayerBoundUserInterface bui, ICommonSession session) - { - if (session.Status == SessionStatus.Connecting || session.Status == SessionStatus.Disconnected) - return false; - - if (!bui._subscribedSessions.Add(session)) - return false; - - OpenInterfaces.GetOrNew(session).Add(bui); - RaiseLocalEvent(bui.Owner, new BoundUIOpenedEvent(bui.UiKey, bui.Owner, session)); - if (!bui._subscribedSessions.Contains(session)) + foreach (var (buid, data) in ent.Comp.OpenInterfaces) + { + interfaces[GetNetEntity(buid)] = data; + } + + args.State = new UserInterfaceUserComponentState() + { + OpenInterfaces = interfaces, + }; + } + + private void OnActorHandleState(Entity ent, ref ComponentHandleState args) + { + if (args.Current is not UserInterfaceUserComponentState state) + return; + + // TODO: Allocate less. + ent.Comp.OpenInterfaces.Clear(); + + foreach (var (nent, data) in state.OpenInterfaces) + { + var openEnt = EnsureEntity(nent, ent.Owner); + ent.Comp.OpenInterfaces[openEnt] = data; + } + } + + #endregion + + private void OnPlayerAttached(PlayerAttachedEvent ev) + { + if (!_userQuery.TryGetComponent(ev.Entity, out var actor)) + return; + + // Open BUIs upon attachment + foreach (var (uid, keys) in actor.OpenInterfaces) + { + if (!_uiQuery.TryGetComponent(uid, out var uiComp)) + continue; + + foreach (var key in keys) + { + if (!uiComp.Interfaces.TryGetValue(key, out var data)) + continue; + + EnsureClientBui((uid, uiComp), key, data); + } + } + } + + private void OnPlayerDetached(PlayerDetachedEvent ev) + { + if (!_userQuery.TryGetComponent(ev.Entity, out var actor)) + return; + + // Close BUIs open detachment. + foreach (var (uid, keys) in actor.OpenInterfaces) + { + if (!_uiQuery.TryGetComponent(uid, out var uiComp)) + continue; + + foreach (var key in keys) + { + if (!uiComp.ClientOpenInterfaces.TryGetValue(key, out var cBui)) + continue; + + cBui.Dispose(); + uiComp.ClientOpenInterfaces.Remove(key); + } + } + } + + private void OnUserInterfaceClosed(Entity ent, ref CloseBoundInterfaceMessage args) + { + // This handles all of the actually closing BUI. + // This is because CloseUi just relays the event so client sending message to server vs just server + // go through the same path. + + var actor = args.Actor; + + var actors = ent.Comp.Actors[args.UiKey]; + actors.Remove(actor); + + if (actors.Count == 0) + ent.Comp.Actors.Remove(args.UiKey); + + Dirty(ent); + + // If the actor is also deleting then don't worry about updating what they have open. + if (!TerminatingOrDeleted(actor)) + { + var actorComp = EnsureComp(actor); + + if (actorComp.OpenInterfaces.TryGetValue(ent.Owner, out var keys)) + { + keys.Remove(args.UiKey); + + if (keys.Count == 0) + actorComp.OpenInterfaces.Remove(ent.Owner); + + Dirty(actor, actorComp); + } + } + + // If we're client we want this handled immediately. + if (ent.Comp.ClientOpenInterfaces.Remove(args.UiKey, out var cBui)) + { + cBui.Dispose(); + } + + if (ent.Comp.Actors.Count == 0) + RemCompDeferred(ent.Owner); + + var ev = new BoundUIClosedEvent(args.UiKey, ent.Owner, args.Actor); + RaiseLocalEvent(ent.Owner, ev); + } + + private void OnUserInterfaceOpen(Entity ent, ref OpenBoundInterfaceMessage args) + { + // Similar to the close method this handles actually opening a UI, it just gets relayed here + EnsureComp(ent.Owner); + + var actor = args.Actor; + var actorComp = EnsureComp(actor); + + // Let state handling open the UI clientside. + actorComp.OpenInterfaces.GetOrNew(ent.Owner).Add(args.UiKey); + ent.Comp.Actors.GetOrNew(args.UiKey).Add(actor); + Dirty(ent); + Dirty(actor, actorComp); + + var ev = new BoundUIOpenedEvent(args.UiKey, ent.Owner, args.Actor); + RaiseLocalEvent(ent.Owner, ev); + + // If we're client we want this handled immediately. + EnsureClientBui(ent, args.UiKey, ent.Comp.Interfaces[args.UiKey]); + } + + private void OnUserInterfaceShutdown(EntityUid uid, UserInterfaceComponent component, ComponentShutdown args) + { + foreach (var bui in component.ClientOpenInterfaces.Values) + { + bui.Dispose(); + } + + component.ClientOpenInterfaces.Clear(); + } + + private void OnUserInterfaceGetState(Entity ent, ref ComponentGetState args) + { + var actors = new Dictionary>(); + var states = new Dictionary(); + + foreach (var (key, acts) in ent.Comp.Actors) + { + actors[key] = GetNetEntityList(acts); + } + + foreach (var (key, state) in ent.Comp.States) + { + states[key] = state; + } + + args.State = new UserInterfaceComponent.UserInterfaceComponentState(actors, states); + } + + private void OnUserInterfaceHandleState(Entity ent, ref ComponentHandleState args) + { + if (args.Current is not UserInterfaceComponent.UserInterfaceComponentState state) + return; + + var toRemove = new ValueList(); + + foreach (var (key, actors) in state.Actors) + { + ref var existing = ref CollectionsMarshal.GetValueRefOrAddDefault(ent.Comp.Actors, key, out _); + + existing ??= new List(); + + existing.Clear(); + existing.AddRange(EnsureEntityList(actors, ent.Owner)); + } + + foreach (var key in ent.Comp.Actors.Keys) + { + if (state.Actors.ContainsKey(key)) + continue; + + toRemove.Add(key); + } + + foreach (var key in toRemove) + { + ent.Comp.Actors.Remove(key); + } + + toRemove.Clear(); + + // State handling + foreach (var key in ent.Comp.States.Keys) + { + if (state.States.ContainsKey(key)) + continue; + + toRemove.Add(key); + } + + foreach (var key in toRemove) + { + ent.Comp.States.Remove(key); + } + + toRemove.Clear(); + + // Check if the UI is still open, otherwise call close. + foreach (var (key, bui) in ent.Comp.ClientOpenInterfaces) + { + if (ent.Comp.Actors.ContainsKey(key)) + continue; + + bui.Dispose(); + toRemove.Add(key); + } + + foreach (var key in toRemove) + { + ent.Comp.ClientOpenInterfaces.Remove(key); + } + + // update any states we have open + foreach (var (key, buiState) in state.States) + { + if (ent.Comp.States.TryGetValue(key, out var existing) && + existing.Equals(buiState)) + { + continue; + } + + ent.Comp.States[key] = buiState; + + if (!ent.Comp.ClientOpenInterfaces.TryGetValue(key, out var cBui)) + continue; + + cBui.UpdateState(buiState); + } + + // If UI not open then open it + var attachedEnt = _player.LocalEntity; + + if (attachedEnt != null) + { + foreach (var (key, value) in ent.Comp.Interfaces) + { + EnsureClientBui(ent, key, value); + } + } + } + + /// + /// Opens a client's BUI if not already open and applies the state to it. + /// + private void EnsureClientBui(Entity entity, Enum key, InterfaceData data) + { + // If it's out BUI open it up and apply the state, otherwise do nothing. + var player = _player.LocalEntity; + + if (player == null || + !entity.Comp.Actors.TryGetValue(key, out var actors) || + !actors.Contains(player.Value)) + { + return; + } + + DebugTools.Assert(_netManager.IsClient); + + if (entity.Comp.ClientOpenInterfaces.ContainsKey(key)) + { + return; + } + + var type = _reflection.LooseGetType(data.ClientType); + var boundUserInterface = (BoundUserInterface) _factory.CreateInstance(type, [entity.Owner, key]); + + entity.Comp.ClientOpenInterfaces[key] = boundUserInterface; + boundUserInterface.Open(); + + if (entity.Comp.States.TryGetValue(key, out var buiState)) + { + boundUserInterface.UpdateState(buiState); + } + } + + /// + /// Yields all the entities + keys currently open by this entity. + /// + public IEnumerable<(EntityUid Entity, Enum Key)> GetActorUis(Entity entity) + { + if (!_userQuery.Resolve(entity.Owner, ref entity.Comp, false)) + yield break; + + foreach (var berry in entity.Comp.OpenInterfaces) + { + foreach (var key in berry.Value) + { + yield return (berry.Key, key); + } + } + } + + /// + /// Gets the actors that have the specified key attached to this entity open. + /// + public IEnumerable GetActors(Entity entity, Enum key) + { + if (!_uiQuery.Resolve(entity.Owner, ref entity.Comp, false) || !entity.Comp.Actors.TryGetValue(key, out var actors)) + yield break; + + foreach (var actorUid in actors) + { + yield return actorUid; + } + } + + /// + /// Closes the attached UI for all entities. + /// + public void CloseUi(Entity entity, Enum key) + { + if (!_uiQuery.Resolve(entity.Owner, ref entity.Comp, false)) + return; + + if (!entity.Comp.Actors.TryGetValue(key, out var actors)) + return; + + for (var i = actors.Count - 1; i >= 0; i--) + { + var actor = actors[i]; + CloseUi(entity, key, actor); + } + + DebugTools.Assert(actors.Count == 0); + } + + /// + /// Closes the attached UI only for the specified actor. + /// + public void CloseUi(Entity entity, Enum key, ICommonSession? actor, bool predicted = false) + { + var actorEnt = actor?.AttachedEntity; + + if (actorEnt == null) + return; + + CloseUi(entity, key, actorEnt.Value, predicted); + } + + /// + /// Closes the attached Ui only for the specified actor. + /// + public void CloseUi(Entity entity, Enum key, EntityUid? actor, bool predicted = false) + { + if (actor == null) + return; + + if (!_uiQuery.Resolve(entity.Owner, ref entity.Comp, false)) + return; + + // Short-circuit if no UI. + if (!entity.Comp.Interfaces.ContainsKey(key)) + return; + + if (!entity.Comp.Actors.TryGetValue(key, out var actors) || !actors.Contains(actor.Value)) + return; + + // Rely upon the client telling us. + if (predicted) + { + if (_timing.IsFirstTimePredicted) + { + // Not guaranteed to open so rely upon the event handling it. + // Also lets client request it to be opened remotely too. + EntityManager.RaisePredictiveEvent(new BoundUIWrapMessage(GetNetEntity(entity.Owner), new CloseBoundInterfaceMessage(), key)); + } + } + else + { + OnMessageReceived(new BoundUIWrapMessage(GetNetEntity(entity.Owner), new CloseBoundInterfaceMessage(), key), actor.Value); + } + } + + /// + /// Tries to call OpenUi and return false if it isn't open. + /// + public bool TryOpenUi(Entity entity, Enum key, EntityUid actor, bool predicted = false) + { + if (!_uiQuery.Resolve(entity.Owner, ref entity.Comp, false)) + return false; + + OpenUi(entity, key, actor, predicted); + + // Due to the event actually handling the UI open / closed we can't + if (!entity.Comp.Actors.TryGetValue(key, out var actors) || + !actors.Contains(actor)) { - // This can happen if Content closed a BUI from inside the event handler. - // This will already have caused a redundant close event to be sent to the client, but whatever. - // Just avoid doing the rest to avoid any state corruption shit. return false; } - RaiseNetworkEvent(new BoundUIWrapMessage(GetNetEntity(bui.Owner), new OpenBoundInterfaceMessage(), bui.UiKey), session.Channel); - - // Fun fact, clients needs to have BUIs open before they can receive the state..... - if (bui.LastStateMsg != null) - RaiseNetworkEvent(bui.LastStateMsg, session.Channel); - - ActivateInterface(bui); return true; } - private void ActivateInterface(PlayerBoundUserInterface ui) + public void OpenUi(Entity entity, Enum key, EntityUid? actor, bool predicted = false) { - EnsureComp(ui.Owner).Interfaces.Add(ui); + if (actor == null || !_uiQuery.Resolve(entity.Owner, ref entity.Comp, false)) + return; + + // No implementation for that UI key on this ent so short-circuit. + if (!entity.Comp.Interfaces.ContainsKey(key)) + return; + + if (entity.Comp.Actors.TryGetValue(key, out var actors) && actors.Contains(actor.Value)) + return; + + if (predicted) + { + if (_timing.IsFirstTimePredicted) + { + // Not guaranteed to open so rely upon the event handling it. + // Also lets client request it to be opened remotely too. + EntityManager.RaisePredictiveEvent(new BoundUIWrapMessage(GetNetEntity(entity.Owner), new OpenBoundInterfaceMessage(), key)); + } + } + else + { + OnMessageReceived(new BoundUIWrapMessage(GetNetEntity(entity.Owner), new OpenBoundInterfaceMessage(), key), actor.Value); + } } - internal bool TryCloseUi(ICommonSession? session, EntityUid uid, Enum uiKey, bool remoteCall = false, UserInterfaceComponent? uiComp = null) + public void OpenUi(Entity entity, Enum key, ICommonSession actor, bool predicted = false) { - if (!Resolve(uid, ref uiComp)) - return false; + var actorEnt = actor.AttachedEntity; - if (!uiComp.OpenInterfaces.TryGetValue(uiKey, out var boundUserInterface)) - return false; + if (actorEnt == null) + return; - if (!remoteCall) - SendUiMessage(boundUserInterface, new CloseBoundInterfaceMessage()); - - uiComp.OpenInterfaces.Remove(uiKey); - boundUserInterface.Dispose(); - - if (session != null) - RaiseLocalEvent(uid, new BoundUIClosedEvent(uiKey, uid, session), true); - - return true; - } - - public bool TryClose(EntityUid uid, Enum uiKey, ICommonSession session, UserInterfaceComponent? ui = null) - { - if (!TryGetUi(uid, uiKey, out var bui, ui)) - return false; - - return CloseUi(bui, session); + OpenUi(entity, key, actorEnt.Value, predicted); } /// - /// Close this interface for a specific client. + /// Sets a BUI state and networks it to all clients. /// - public bool CloseUi(PlayerBoundUserInterface bui, ICommonSession session, ActiveUserInterfaceComponent? activeUis = null) + public void SetUiState(Entity entity, Enum key, BoundUserInterfaceState? state) { - if (!bui._subscribedSessions.Remove(session)) - return false; + if (!_uiQuery.Resolve(entity.Owner, ref entity.Comp, false)) + return; - RaiseNetworkEvent(new BoundUIWrapMessage(GetNetEntity(bui.Owner), new CloseBoundInterfaceMessage(), bui.UiKey), session.Channel); - CloseShared(bui, session, activeUis); - return true; + if (!entity.Comp.Interfaces.ContainsKey(key)) + return; + + // Null state + if (state == null) + { + if (!entity.Comp.States.Remove(key)) + return; + + Dirty(entity); + } + // Non-null state, check if it matches existing. + else + { + ref var stateRef = ref CollectionsMarshal.GetValueRefOrAddDefault(entity.Comp.States, key, out var exists); + + if (exists && stateRef?.Equals(state) == true) + return; + + stateRef = state; + } + + Dirty(entity); } /// - /// Raised by client-side UIs to send to server. + /// Returns true if this entity has the specified Ui key available, even if not currently open. /// - internal void SendUiMessage(BoundUserInterface bui, BoundUserInterfaceMessage msg) + public bool HasUi(EntityUid uid, Enum uiKey, UserInterfaceComponent? ui = null) { - RaiseNetworkEvent(new BoundUIWrapMessage(GetNetEntity(bui.Owner), msg, bui.UiKey)); + if (!Resolve(uid, ref ui, false)) + return false; + + return ui.Interfaces.ContainsKey(uiKey); } + /// + /// Returns true if the specified UI key is open for this entity by anyone. + /// + public bool IsUiOpen(Entity entity, Enum uiKey) + { + if (!_uiQuery.Resolve(entity.Owner, ref entity.Comp, false)) + return false; + + if (!entity.Comp.Actors.TryGetValue(uiKey, out var actors)) + return false; + + DebugTools.Assert(actors.Count > 0); + return actors.Count > 0; + } + + public bool IsUiOpen(Entity entity, Enum uiKey, EntityUid actor) + { + if (!_uiQuery.Resolve(entity.Owner, ref entity.Comp, false)) + return false; + + if (!entity.Comp.Actors.TryGetValue(uiKey, out var actors)) + return false; + + return actors.Contains(actor); + } + + /// + /// Raises a BUI message locally (on client or server) without networking it. + /// + [PublicAPI] + public void RaiseUiMessage(Entity entity, Enum key, BoundUserInterfaceMessage message) + { + if (!_uiQuery.Resolve(entity.Owner, ref entity.Comp, false)) + return; + + if (!entity.Comp.Actors.TryGetValue(key, out var actors)) + return; + + OnMessageReceived(new BoundUIWrapMessage(GetNetEntity(entity.Owner), message, key), message.Actor); + } + + #region Server messages + + /// + /// Sends a BUI message to any actors who have the specified Ui key open. + /// + public void ServerSendUiMessage(Entity entity, Enum key, BoundUserInterfaceMessage message) + { + if (!_uiQuery.Resolve(entity.Owner, ref entity.Comp, false)) + return; + + if (!entity.Comp.Actors.TryGetValue(key, out var actors)) + return; + + var filter = Filter.Entities(actors.ToArray()); + RaiseNetworkEvent(new BoundUIWrapMessage(GetNetEntity(entity.Owner), message, key), filter); + } + + /// + /// Sends a Bui message to the specified actor only. + /// + public void ServerSendUiMessage(Entity entity, Enum key, BoundUserInterfaceMessage message, EntityUid actor) + { + if (!_uiQuery.Resolve(entity.Owner, ref entity.Comp, false)) + return; + + if (!entity.Comp.Actors.TryGetValue(key, out var actors) || !actors.Contains(actor)) + return; + + RaiseNetworkEvent(new BoundUIWrapMessage(GetNetEntity(entity.Owner), message, key), actor); + } + + /// + /// Sends a Bui message to the specified actor only. + /// + public void ServerSendUiMessage(Entity entity, Enum key, BoundUserInterfaceMessage message, ICommonSession actor) + { + if (!_netManager.IsClient) + return; + + if (!_uiQuery.Resolve(entity.Owner, ref entity.Comp, false) || actor.AttachedEntity is not { } attachedEntity) + return; + + if (!entity.Comp.Actors.TryGetValue(key, out var actors) || !actors.Contains(attachedEntity)) + return; + + RaiseNetworkEvent(new BoundUIWrapMessage(GetNetEntity(entity.Owner), message, key), actor); + } + + #endregion + + /// + /// Raises a BUI message from the client to the server. + /// + public void ClientSendUiMessage(Entity entity, Enum key, BoundUserInterfaceMessage message) + { + var player = _player.LocalEntity; + + // Don't send it if we're not a valid actor for it just in case. + if (player == null || + !_uiQuery.Resolve(entity.Owner, ref entity.Comp, false) || + !entity.Comp.Actors.TryGetValue(key, out var actors) || + !actors.Contains(player.Value)) + { + return; + } + + RaiseNetworkEvent(new BoundUIWrapMessage(GetNetEntity(entity.Owner), message, key)); + } + + /// + /// Closes all Uis for the entity. + /// + public void CloseUis(Entity entity) + { + if (!_uiQuery.Resolve(entity.Owner, ref entity.Comp, false)) + return; + + entity.Comp.Actors.Clear(); + entity.Comp.States.Clear(); + Dirty(entity); + } + + /// + /// Closes all Uis for the entity that the specified actor has open. + /// + public void CloseUis(Entity entity, EntityUid actor) + { + if (!_uiQuery.Resolve(entity.Owner, ref entity.Comp, false)) + return; + + foreach (var key in entity.Comp.Interfaces.Keys) + { + CloseUi(entity, key, actor); + } + } + + /// + /// Closes all Uis for the entity that the specified actor has open. + /// + public void CloseUis(Entity entity, ICommonSession actor) + { + if (actor.AttachedEntity is not { } attachedEnt || !_uiQuery.Resolve(entity.Owner, ref entity.Comp, false)) + return; + + CloseUis(entity, attachedEnt); + } + + /// + /// Tries to get the BUI if it is currently open. + /// + public bool TryGetOpenUi(Entity entity, Enum uiKey, [NotNullWhen(true)] out BoundUserInterface? bui) + { + bui = null; + + return _uiQuery.Resolve(entity.Owner, ref entity.Comp, false) && entity.Comp.ClientOpenInterfaces.TryGetValue(uiKey, out bui); + } + + /// + /// Tries to get the BUI if it is currently open. + /// + public bool TryGetOpenUi(Entity entity, Enum uiKey, [NotNullWhen(true)] out T? bui) where T : BoundUserInterface + { + if (!_uiQuery.Resolve(entity.Owner, ref entity.Comp, false) || !entity.Comp.ClientOpenInterfaces.TryGetValue(uiKey, out var cBui)) + { + bui = null; + return false; + } + + bui = (T)cBui; + return true; + } + + public bool TryToggleUi(Entity entity, Enum uiKey, ICommonSession actor) + { + if (actor.AttachedEntity is not { } attachedEntity) + return false; + + return TryToggleUi(entity, uiKey, attachedEntity); + } + + /// + /// Switches between closed and open for a specific client. + /// + public bool TryToggleUi(Entity entity, Enum uiKey, EntityUid actor) + { + if (!_uiQuery.Resolve(entity.Owner, ref entity.Comp, false) || + !entity.Comp.Interfaces.ContainsKey(uiKey)) + { + return false; + } + + if (entity.Comp.Actors.TryGetValue(uiKey, out var actors) && actors.Contains(actor)) + { + CloseUi(entity, uiKey, actor); + } + else + { + OpenUi(entity, uiKey, actor); + } + + return true; + } /// /// Raised by client-side UIs to send predicted messages to server. /// - internal void SendPredictedUiMessage(BoundUserInterface bui, BoundUserInterfaceMessage msg) + public void SendPredictedUiMessage(BoundUserInterface bui, BoundUserInterfaceMessage msg) { - RaisePredictiveEvent(new PredictedBoundUIWrapMessage(GetNetEntity(bui.Owner), msg, bui.UiKey)); + RaisePredictiveEvent(new BoundUIWrapMessage(GetNetEntity(bui.Owner), msg, bui.UiKey)); + } + + /// + public override void Update(float frameTime) + { + var query = AllEntityQuery(); + + // Handles closing the BUI if actors move out of range of them. + while (query.MoveNext(out var uid, out _, out var uiComp)) + { + foreach (var (key, actors) in uiComp.Actors) + { + DebugTools.Assert(actors.Count > 0); + var data = uiComp.Interfaces[key]; + + // Short-circuit + if (data.InteractionRange <= 0f || actors.Count == 0) + continue; + + // Okay so somehow UISystem is high up on the server profile + // If that's actually still a problem turn this into an IParallelRobustJob and slam all the UIs in parallel. + var xform = _xformQuery.GetComponent(uid); + var coordinates = xform.Coordinates; + var mapId = xform.MapID; + + for (var i = actors.Count - 1; i >= 0; i--) + { + var actor = actors[i]; + + if (CheckRange(uid, key, data, actor, coordinates, mapId)) + continue; + + // Using the non-predicted one here seems fine? + CloseUi((uid, uiComp), key, actor); + } + } + } + } + + /// + /// Verify that the subscribed clients are still in range of the interface. + /// + private bool CheckRange( + EntityUid uid, + Enum key, + InterfaceData data, + EntityUid actor, + EntityCoordinates uiCoordinates, + MapId uiMap) + { + if (_ignoreUIRangeQuery.HasComponent(actor)) + return true; + + if (!_xformQuery.TryGetComponent(actor, out var actorXform)) + return false; + + // Handle pluggable BoundUserInterfaceCheckRangeEvent + var checkRangeEvent = new BoundUserInterfaceCheckRangeEvent(uid, key, data, actor); + RaiseLocalEvent(uid, ref checkRangeEvent, broadcast: true); + + if (checkRangeEvent.Result == BoundUserInterfaceRangeResult.Pass) + return true; + + if (checkRangeEvent.Result == BoundUserInterfaceRangeResult.Fail) + return false; + + DebugTools.Assert(checkRangeEvent.Result == BoundUserInterfaceRangeResult.Default); + + if (uiMap != actorXform.MapID) + return false; + + return uiCoordinates.InRange(EntityManager, _transforms, actorXform.Coordinates, data.InteractionRange); } } + +/// +/// Raised by to check whether an interface is still accessible by its user. +/// +[ByRefEvent] +[PublicAPI] +public struct BoundUserInterfaceCheckRangeEvent +{ + /// + /// The entity owning the UI being checked for. + /// + public readonly EntityUid Target; + + /// + /// The UI itself. + /// + /// + public readonly Enum UiKey; + + public readonly InterfaceData Data; + + /// + /// The player for which the UI is being checked. + /// + public readonly EntityUid Actor; + + /// + /// The result of the range check. + /// + public BoundUserInterfaceRangeResult Result; + + public BoundUserInterfaceCheckRangeEvent( + EntityUid target, + Enum uiKey, + InterfaceData data, + EntityUid actor) + { + Target = target; + UiKey = uiKey; + Data = data; + Actor = actor; + } +} + +/// +/// Possible results for a . +/// +public enum BoundUserInterfaceRangeResult : byte +{ + /// + /// Run built-in range check. + /// + Default, + + /// + /// Range check passed, UI is accessible. + /// + Pass, + + /// + /// Range check failed, UI is inaccessible. + /// + Fail +} diff --git a/Robust.Shared/Map/EntityCoordinates.cs b/Robust.Shared/Map/EntityCoordinates.cs index 41c60c39a..9aaf80392 100644 --- a/Robust.Shared/Map/EntityCoordinates.cs +++ b/Robust.Shared/Map/EntityCoordinates.cs @@ -344,6 +344,9 @@ namespace Robust.Shared.Map var mapCoordinates = ToMap(entityManager, transformSystem); var otherMapCoordinates = otherCoordinates.ToMap(entityManager, transformSystem); + if (mapCoordinates.MapId != otherMapCoordinates.MapId) + return false; + return mapCoordinates.InRange(otherMapCoordinates, range); } diff --git a/Robust.Shared/Player/SharedPlayerManager.Sessions.cs b/Robust.Shared/Player/SharedPlayerManager.Sessions.cs index b61551481..d0d958e57 100644 --- a/Robust.Shared/Player/SharedPlayerManager.Sessions.cs +++ b/Robust.Shared/Player/SharedPlayerManager.Sessions.cs @@ -200,12 +200,13 @@ internal abstract partial class SharedPlayerManager if (EntManager.EnsureComponent(uid, out var actor)) { // component already existed. - DebugTools.AssertNotNull(actor.PlayerSession); if (!force) return false; kicked = actor.PlayerSession; - Detach(kicked); + + if (kicked != null) + Detach(kicked); } if (_netMan.IsServer) diff --git a/Robust.UnitTesting/Server/Maps/MapLoaderTest.cs b/Robust.UnitTesting/Server/Maps/MapLoaderTest.cs index 68ad2b220..9f6130ef0 100644 --- a/Robust.UnitTesting/Server/Maps/MapLoaderTest.cs +++ b/Robust.UnitTesting/Server/Maps/MapLoaderTest.cs @@ -12,6 +12,7 @@ using Robust.Shared.Prototypes; using Robust.Shared.Serialization.Manager; using Robust.Shared.Serialization.Manager.Attributes; using Robust.Shared.Utility; +using IgnoreUIRangeComponent = Robust.Shared.GameObjects.IgnoreUIRangeComponent; namespace Robust.UnitTesting.Server.Maps {