using System.Collections.Generic; using Content.Client.ContextMenu.UI; using Content.Client.Gameplay; using Content.Client.Graphics; using Content.Client.Interactable.Components; using Content.Client.Viewport; using Content.Shared.CCVar; using Content.Shared.Interaction; 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.CustomControls; using Robust.Shared.Configuration; using Robust.Shared.Prototypes; namespace Content.Client.Outline; public sealed partial class InteractionOutlineSystem : EntitySystem { private static readonly ProtoId ShaderInRange = "SelectionOutlineInrange"; private static readonly ProtoId ShaderOutOfRange = "SelectionOutline"; private const float DefaultWidth = 1; private readonly Dictionary<(bool InRange, int RenderScale), ShaderInstance> _shaderCache = new(); [Dependency] private IConfigurationManager _configManager = default!; [Dependency] private IEyeManager _eyeManager = default!; [Dependency] private IInputManager _inputManager = default!; [Dependency] private IPlayerManager _playerManager = default!; [Dependency] private IStateManager _stateManager = default!; [Dependency] private IUserInterfaceManager _uiManager = default!; [Dependency] private SharedInteractionSystem _interactionSystem = default!; [Dependency] private IPrototypeManager _prototypeManager = default!; [Dependency] private SpriteSystem _sprite = default!; /// /// Whether to currently draw the outline. The outline may be temporarily disabled by other systems /// private bool _enabled = true; /// /// Whether to draw the outline at all. Overrides . /// private bool _cvarEnabled = true; private EntityUid? _lastHoveredEntity; public override void Shutdown() { foreach (var shader in _shaderCache.Values) { shader.Dispose(); } _shaderCache.Clear(); base.Shutdown(); } public override void Initialize() { base.Initialize(); Subs.CVar(_configManager, CCVars.OutlineEnabled, SetCvarEnabled); SubscribeLocalEvent(OnShutdown); UpdatesAfter.Add(typeof(SharedEyeSystem)); } private void OnShutdown(Entity ent, ref ComponentShutdown args) { RemoveOutline(ent); if (_lastHoveredEntity == ent.Owner) _lastHoveredEntity = null; } public void SetCvarEnabled(bool cvarEnabled) { _cvarEnabled = cvarEnabled; // clear last hover if required: if (_cvarEnabled) return; if (_lastHoveredEntity == null || Deleted(_lastHoveredEntity)) return; if (TryComp(_lastHoveredEntity, out InteractionOutlineComponent? outline)) RemoveOutline((_lastHoveredEntity.Value, outline)); } public void SetEnabled(bool enabled) { if (enabled == _enabled) return; _enabled = enabled; // clear last hover if required: if (enabled) return; if (_lastHoveredEntity == null || Deleted(_lastHoveredEntity)) return; if (TryComp(_lastHoveredEntity, out InteractionOutlineComponent? outline)) RemoveOutline((_lastHoveredEntity.Value, outline)); } public override void FrameUpdate(float frameTime) { base.FrameUpdate(frameTime); if (!_enabled || !_cvarEnabled) return; // If there is no local player, there is no session, and therefore nothing to do here. var localSession = _playerManager.LocalSession; if (localSession == null) return; // TODO InteractionOutlineComponent // BUG: The logic that gets the renderScale here assumes that the entity is only visible in a single // viewport. The entity will be highlighted in ALL viewport where it is visible, regardless of which // viewport is being used to hover over it. If these Viewports have very different render scales, this may // lead to extremely thick outlines in the other viewports. Fixing this probably requires changing how the // hover outline works, so that it only highlights the entity in a single viewport. // GameScreen is still in charge of what entities are visible under a specific cursor position. // Potentially change someday? who knows. var currentState = _stateManager.CurrentState; if (currentState is not GameplayStateBase screen) return; EntityUid? entityToClick = null; var renderScale = 1; if (_uiManager.CurrentlyHovered is IViewportControl vp && _inputManager.MouseScreenPosition.IsValid) { var mousePosWorld = vp.PixelToMap(_inputManager.MouseScreenPosition.Position); if (vp is ScalingViewport svp) { renderScale = svp.CurrentRenderScale; entityToClick = screen.GetClickedEntity(mousePosWorld, svp.Eye); } else { entityToClick = screen.GetClickedEntity(mousePosWorld); } } else if (_uiManager.CurrentlyHovered is EntityMenuElement element) { entityToClick = element.Entity; // TODO InteractionOutlineComponent // Currently we just take the renderscale from the main viewport. In the future, when the bug mentioned // above is fixed, the viewport should probably be the one that was clicked on to open the entity menu // in the first place. renderScale = _eyeManager.MainViewport.GetRenderScale(); } var inRange = false; if (localSession.AttachedEntity != null && !Deleted(entityToClick)) inRange = _interactionSystem.InRangeUnobstructed(localSession.AttachedEntity.Value, entityToClick.Value); InteractionOutlineComponent? outline; if (entityToClick == _lastHoveredEntity) { if (entityToClick != null && TryComp(entityToClick, out outline)) { UpdateOutline((entityToClick.Value, outline), inRange, renderScale); } return; } if (_lastHoveredEntity != null && !Deleted(_lastHoveredEntity) && TryComp(_lastHoveredEntity, out outline)) { RemoveOutline((_lastHoveredEntity.Value, outline)); } _lastHoveredEntity = entityToClick; if (_lastHoveredEntity != null && TryComp(_lastHoveredEntity, out outline)) { AddOutline((_lastHoveredEntity.Value, outline), inRange, renderScale); } } private void AddOutline(Entity ent, bool inInteractionRange, int renderScale) { ent.Comp.LastRenderScale = renderScale; ent.Comp.InRange = inInteractionRange; ent.Comp.Active = true; if (!TryComp(ent.Owner, out SpriteComponent? sprite)) return; SetOutlinePostShader((ent.Owner, sprite), inInteractionRange, renderScale); } private void RemoveOutline(Entity ent) { if (TryComp(ent.Owner, out SpriteComponent? sprite)) { _sprite.RemovePostShader((ent.Owner, sprite), ContentPostShaderIds.InteractionOutline); sprite.RenderOrder = 0; } ent.Comp.Active = false; } private void UpdateOutline(Entity ent, bool inInteractionRange, int renderScale) { if (!TryComp(ent.Owner, out SpriteComponent? sprite) || !_sprite.HasPostShader((ent.Owner, sprite), ContentPostShaderIds.InteractionOutline) || inInteractionRange == ent.Comp.InRange && ent.Comp.LastRenderScale == renderScale) { return; } ent.Comp.InRange = inInteractionRange; ent.Comp.LastRenderScale = renderScale; SetOutlinePostShader((ent.Owner, sprite), ent.Comp.InRange, ent.Comp.LastRenderScale); } private void SetOutlinePostShader(Entity sprite, bool inRange, int renderScale) { var shader = GetShader(inRange, renderScale); if (_sprite.TryGetPostShader(sprite, ContentPostShaderIds.InteractionOutline, out var entry) && entry.Shader == shader) { return; } _sprite.SetPostShader(sprite, new SpriteComponent.PostShaderArgs(ContentPostShaderIds.InteractionOutline, shader) { After = ContentPostShaderIds.AfterBaseEffects, }); } private ShaderInstance GetShader(bool inRange, int renderScale) { var key = (inRange, renderScale); if (_shaderCache.TryGetValue(key, out var shader)) return shader; shader = MakeNewShader(inRange, renderScale); _shaderCache.Add(key, shader); return shader; } private ShaderInstance MakeNewShader(bool inRange, int renderScale) { var shaderName = inRange ? ShaderInRange : ShaderOutOfRange; var instance = _prototypeManager.Index(shaderName).InstanceUnique(); instance.SetParameter("outline_width", DefaultWidth * renderScale); return instance; } }