using System.Linq; using Content.Client.CombatMode; using Content.Client.Examine; using Content.Client.Gameplay; using Content.Client.Verbs; using Content.Client.Verbs.UI; using Content.Shared.CCVar; using Content.Shared.CombatMode; using Content.Shared.Examine; using Content.Shared.Input; using Robust.Client.GameObjects; using Robust.Client.Graphics; using Robust.Client.Input; using Robust.Client.Player; using Robust.Client.State; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controllers; using Robust.Shared.Configuration; using Robust.Shared.Input; using Robust.Shared.Input.Binding; using Robust.Shared.Map; using Robust.Shared.Timing; namespace Content.Client.ContextMenu.UI { /// /// This class handles the displaying of the entity context menu. /// /// /// This also provides functions to get /// a list of entities near the mouse position, add them to the context menu grouped by prototypes, and remove /// them from the menu as they move out of sight. /// public sealed partial class EntityMenuUIController : UIController, IOnStateEntered, IOnStateExited { [Dependency] private readonly IEntitySystemManager _systemManager = default!; [Dependency] private readonly IEntityManager _entityManager = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IStateManager _stateManager = default!; [Dependency] private readonly IInputManager _inputManager = default!; [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private readonly IGameTiming _gameTiming = default!; [Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!; [Dependency] private readonly IEyeManager _eyeManager = default!; [Dependency] private readonly ContextMenuUIController _context = default!; [Dependency] private readonly VerbMenuUIController _verb = default!; [UISystemDependency] private readonly VerbSystem _verbSystem = default!; [UISystemDependency] private readonly ExamineSystem _examineSystem = default!; [UISystemDependency] private readonly TransformSystem _xform = default!; [UISystemDependency] private readonly CombatModeSystem _combatMode = default!; private bool _updating; /// /// This maps the currently displayed entities to the actual GUI elements. /// /// /// This is used remove GUI elements when the entities are deleted. or leave the LOS. /// public Dictionary Elements = new(); public void OnStateEntered(GameplayState state) { _updating = true; _cfg.OnValueChanged(CCVars.EntityMenuGroupingType, OnGroupingChanged, true); _context.OnContextKeyEvent += OnKeyBindDown; CommandBinds.Builder .Bind(EngineKeyFunctions.UseSecondary, new PointerInputCmdHandler(HandleOpenEntityMenu, outsidePrediction: true)) .Register(); } public void OnStateExited(GameplayState state) { _updating = false; Elements.Clear(); _cfg.UnsubValueChanged(CCVars.EntityMenuGroupingType, OnGroupingChanged); _context.OnContextKeyEvent -= OnKeyBindDown; CommandBinds.Unregister(); } /// /// Given a list of entities, sort them into groups and them to a new entity menu. /// public void OpenRootMenu(List entities) { // close any old menus first. if (_context.RootMenu.Visible) _context.Close(); var entitySpriteStates = GroupEntities(entities); var orderedStates = entitySpriteStates.ToList(); orderedStates.Sort((x, y) => string.CompareOrdinal(_entityManager.GetComponent(x.First()).EntityPrototype?.Name, _entityManager.GetComponent(y.First()).EntityPrototype?.Name)); Elements.Clear(); AddToUI(orderedStates); var box = UIBox2.FromDimensions(_userInterfaceManager.MousePositionScaled.Position, (1, 1)); _context.RootMenu.Open(box); } public void OnKeyBindDown(ContextMenuElement element, GUIBoundKeyEventArgs args) { if (element is not EntityMenuElement entityElement) return; // get an entity associated with this element var entity = entityElement.Entity; entity ??= GetFirstEntityOrNull(element.SubMenu); // Deleted() automatically checks for null & existence. if (_entityManager.Deleted(entity)) return; // do examination? if (args.Function == ContentKeyFunctions.ExamineEntity) { _systemManager.GetEntitySystem().DoExamine(entity.Value); args.Handle(); return; } // do some other server-side interaction? if (args.Function == EngineKeyFunctions.Use || args.Function == ContentKeyFunctions.ActivateItemInWorld || args.Function == ContentKeyFunctions.AltActivateItemInWorld || args.Function == ContentKeyFunctions.Point || args.Function == ContentKeyFunctions.TryPullObject || args.Function == ContentKeyFunctions.MovePulledObject) { var inputSys = _systemManager.GetEntitySystem(); var func = args.Function; var funcId = _inputManager.NetworkBindMap.KeyFunctionID(func); var message = new FullInputCmdMessage(_gameTiming.CurTick, _gameTiming.TickFraction, funcId, BoundKeyState.Down, _entityManager.GetComponent(entity.Value).Coordinates, args.PointerLocation, entity.Value); var session = _playerManager.LocalPlayer?.Session; if (session != null) { inputSys.HandleInputCommand(session, func, message); } _context.Close(); args.Handle(); } } private bool HandleOpenEntityMenu(in PointerInputCmdHandler.PointerInputCmdArgs args) { if (args.State != BoundKeyState.Down) return false; if (_stateManager.CurrentState is not GameplayStateBase) return false; if (_combatMode.IsInCombatMode(args.Session?.AttachedEntity)) return false; var coords = args.Coordinates.ToMap(_entityManager); if (_verbSystem.TryGetEntityMenuEntities(coords, out var entities)) OpenRootMenu(entities); return true; } /// /// Check that entities in the context menu are still visible. If not, remove them from the context menu. /// public override void FrameUpdate(FrameEventArgs args) { if (!_updating || _context.RootMenu == null) return; if (!_context.RootMenu.Visible) return; if (_playerManager.LocalPlayer?.ControlledEntity is not { } player || !player.IsValid()) return; // Do we need to do in-range unOccluded checks? var ignoreFov = !_eyeManager.CurrentEye.DrawFov || (_verbSystem.Visibility & MenuVisibility.NoFov) == MenuVisibility.NoFov; _entityManager.TryGetComponent(player, out ExaminerComponent? examiner); var xformQuery = _entityManager.GetEntityQuery(); foreach (var entity in Elements.Keys.ToList()) { if (!xformQuery.TryGetComponent(entity, out var xform)) { // entity was deleted RemoveEntity(entity); continue; } if (ignoreFov) continue; var pos = new MapCoordinates(_xform.GetWorldPosition(xform, xformQuery), xform.MapID); if (!_examineSystem.CanExamine(player, pos, e => e == player || e == entity, entity, examiner)) RemoveEntity(entity); } } /// /// Add menu elements for a list of grouped entities; /// /// A list of entity groups. Entities are grouped together based on prototype. private void AddToUI(List> entityGroups) { // If there is only a single group. We will just directly list individual entities if (entityGroups.Count == 1) { foreach (var entity in entityGroups[0]) { var element = new EntityMenuElement(entity); element.SubMenu = new ContextMenuPopup(_context, element); element.SubMenu.OnPopupOpen += () => _verb.OpenVerbMenu(entity, popup: element.SubMenu); element.SubMenu.OnPopupHide += element.SubMenu.MenuBody.DisposeAllChildren; _context.AddElement(_context.RootMenu, element); Elements.TryAdd(entity, element); } return; } foreach (var group in entityGroups) { if (group.Count > 1) { AddGroupToUI(group); continue; } // this group only has a single entity, add a simple menu element var element = new EntityMenuElement(group[0]); element.SubMenu = new ContextMenuPopup(_context, element); element.SubMenu.OnPopupOpen += () => _verb.OpenVerbMenu(group[0], popup: element.SubMenu); element.SubMenu.OnPopupHide += element.SubMenu.MenuBody.DisposeAllChildren; _context.AddElement(_context.RootMenu, element); Elements.TryAdd(group[0], element); } } /// /// Given a group of entities, add a menu element that has a pop-up sub-menu listing group members /// private void AddGroupToUI(List group) { EntityMenuElement element = new(); ContextMenuPopup subMenu = new(_context, element); foreach (var entity in group) { var subElement = new EntityMenuElement(entity); subElement.SubMenu = new ContextMenuPopup(_context, subElement); subElement.SubMenu.OnPopupOpen += () => _verb.OpenVerbMenu(entity, popup: subElement.SubMenu); subElement.SubMenu.OnPopupHide += subElement.SubMenu.MenuBody.DisposeAllChildren; _context.AddElement(subMenu, subElement); Elements.TryAdd(entity, subElement); } UpdateElement(element); _context.AddElement(_context.RootMenu, element); } /// /// Remove an entity from the entity context menu. /// private void RemoveEntity(EntityUid entity) { // find the element associated with this entity if (!Elements.TryGetValue(entity, out var element)) { Logger.Error($"Attempted to remove unknown entity from the entity menu: {_entityManager.GetComponent(entity).EntityName} ({entity})"); return; } // remove the element var parent = element.ParentMenu?.ParentElement; element.Dispose(); Elements.Remove(entity); // update any parent elements if (parent is EntityMenuElement e) UpdateElement(e); // If this was the last entity, close the entity menu if (_context.RootMenu.MenuBody.ChildCount == 0) _context.Close(); } /// /// Update the information displayed by a menu element. /// /// /// This is called when initializing elements or after an element was removed from a sub-menu. /// private void UpdateElement(EntityMenuElement element) { if (element.SubMenu == null) return; // Get the first entity in the sub-menus var entity = GetFirstEntityOrNull(element.SubMenu); if (entity == null) { // This whole element has no associated entities. We should remove it element.Dispose(); return; } element.UpdateEntity(entity); // Update the entity count & count label element.Count = 0; foreach (var subElement in element.SubMenu.MenuBody.Children) { if (subElement is EntityMenuElement entityElement) element.Count += entityElement.Count; } element.CountLabel.Text = element.Count.ToString(); if (element.Count == 1) { // There was only one entity in the sub-menu. So we will just remove the sub-menu and point directly to // that entity. element.Entity = entity; element.SubMenu.Dispose(); element.SubMenu = null; element.CountLabel.Visible = false; Elements[entity.Value] = element; } // update the parent element, so that it's count and entity icon gets updated. var parent = element.ParentMenu?.ParentElement; if (parent is EntityMenuElement e) UpdateElement(e); } /// /// Recursively look through a sub-menu and return the first entity. /// private EntityUid? GetFirstEntityOrNull(ContextMenuPopup? menu) { if (menu == null) return null; foreach (var element in menu.MenuBody.Children) { if (element is not EntityMenuElement entityElement) continue; if (entityElement.Entity != null) { if (!_entityManager.Deleted(entityElement.Entity)) return entityElement.Entity; continue; } // if the element has no entity, its a group of entities with another attached sub-menu. var entity = GetFirstEntityOrNull(entityElement.SubMenu); if (entity != null) return entity; } return null; } } }