using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using JetBrains.Annotations; using Robust.Server.Player; using Robust.Shared.Enums; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Player; using Robust.Shared.Utility; namespace Robust.Server.GameObjects { 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(); private readonly Dictionary> _openInterfaces = 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.ConnectedClient); } if (ui.LastStateMsg == null) continue; foreach (var session in ui.SubscribedSessions) { if (!ui.PlayerStateOverrides.ContainsKey(session)) RaiseNetworkEvent(ui.LastStateMsg, session.ConnectedClient); } } } } /// /// Verify that the subscribed clients are still in range of the interface. /// private void CheckRange(EntityUid uid, Shared.GameObjects.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); } } private void ActivateInterface(PlayerBoundUserInterface ui) { EnsureComp(ui.Owner).Interfaces.Add(ui); } #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; } 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); } /// /// 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; } /// /// Switches between closed and open for a specific client. /// public 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); } #region Open 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)); RaiseNetworkEvent(new BoundUIWrapMessage(GetNetEntity(bui.Owner), new OpenBoundInterfaceMessage(), bui.UiKey), session.ConnectedClient); // Fun fact, clients needs to have BUIs open before they can receive the state..... if (bui.LastStateMsg != null) RaiseNetworkEvent(bui.LastStateMsg, session.ConnectedClient); ActivateInterface(bui); return true; } #endregion #region Close 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); } /// /// Close this interface for a specific client. /// public bool CloseUi(PlayerBoundUserInterface bui, ICommonSession session, ActiveUserInterfaceComponent? activeUis = null) { if (!bui._subscribedSessions.Remove(session)) return false; RaiseNetworkEvent(new BoundUIWrapMessage(GetNetEntity(bui.Owner), new CloseBoundInterfaceMessage(), bui.UiKey), session.ConnectedClient); CloseShared(bui, session, activeUis); return true; } 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.ConnectedClient); } } /// /// 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.ConnectedClient); 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 } }