diff --git a/Content.Client/Wall/Components/WallMountTreeComponent.cs b/Content.Client/Wall/Components/WallMountTreeComponent.cs new file mode 100644 index 0000000000..01fce349c8 --- /dev/null +++ b/Content.Client/Wall/Components/WallMountTreeComponent.cs @@ -0,0 +1,11 @@ +using Content.Shared.Wall; +using Robust.Shared.ComponentTrees; +using Robust.Shared.Physics; + +namespace Content.Client.Wall.Components; + +[RegisterComponent] +public sealed partial class WallMountTreeComponent : Component, IComponentTreeComponent +{ + public DynamicTree> Tree { get; set; } = default!; +} diff --git a/Content.Client/Wall/Systems/WallMountTreeSystem.cs b/Content.Client/Wall/Systems/WallMountTreeSystem.cs new file mode 100644 index 0000000000..93398750d7 --- /dev/null +++ b/Content.Client/Wall/Systems/WallMountTreeSystem.cs @@ -0,0 +1,22 @@ +using System.Numerics; +using Content.Client.Wall.Components; +using Content.Shared.Wall; +using Robust.Client.GameObjects; +using Robust.Shared.ComponentTrees; +using Robust.Shared.Physics; + +namespace Content.Client.Wall.Systems; + +public sealed partial class WallMountTreeSystem : ComponentTreeSystem +{ + [Dependency] private SpriteSystem _sprite = default!; + + protected override bool DoFrameUpdate => true; + protected override bool DoTickUpdate => false; + protected override bool Recursive => false; + + protected override Box2 ExtractAabb(in ComponentTreeEntry entry, Vector2 pos, Angle rot) + { + return _sprite.CalculateBounds((entry.Uid, Comp(entry.Uid)), pos, rot, default).CalcBoundingBox(); + } +} diff --git a/Content.Client/Wall/Systems/WallMountVisibilitySystem.cs b/Content.Client/Wall/Systems/WallMountVisibilitySystem.cs new file mode 100644 index 0000000000..39f1c8be78 --- /dev/null +++ b/Content.Client/Wall/Systems/WallMountVisibilitySystem.cs @@ -0,0 +1,187 @@ +using System.Linq; +using Content.Shared.CCVar; +using Content.Shared.GameTicking; +using Content.Shared.Tag; +using Content.Shared.Wall; +using Robust.Client.GameObjects; +using Robust.Client.Graphics; +using Robust.Shared.Configuration; +using Robust.Shared.Map.Components; +using Robust.Shared.Prototypes; +using Robust.Shared.Timing; + +namespace Content.Client.Wall.Systems; + +/// +/// Manages the directional visibility overlay for wall-mounted entities. +/// +public sealed partial class WallMountVisibilitySystem : EntitySystem +{ + [Dependency] private IConfigurationManager _cfg = default!; + [Dependency] private IGameTiming _timing = default!; + [Dependency] private IOverlayManager _overlay = default!; + [Dependency] private SharedMapSystem _map = default!; + [Dependency] private SpriteSystem _sprite = default!; + [Dependency] private TagSystem _tag = default!; + [Dependency] private TransformSystem _xform = default!; + [Dependency] private WallMountTreeSystem _tree = default!; + + [Dependency] private EntityQuery _gridQuery = default!; + [Dependency] private EntityQuery _spriteQuery = default!; + + /// + /// Tags that block visibility when present on the same tile. + /// + private static readonly ProtoId[] BlockingTags = ["Wall"]; + + /// + /// Caches for whether a tile has any blocking entity. + /// + private readonly Dictionary<(EntityUid Grid, Vector2i Tile), bool> _tileCache = []; + + private WallMountVisibilityOverlay _overlayInstance = default!; + + /// + /// Whether directional visibility is currently enabled. + /// + internal bool DirectionalVisibilityEnabled = true; + + public override void Initialize() + { + base.Initialize(); + + _overlayInstance = new WallMountVisibilityOverlay(_timing, _map, _sprite, _xform, _tree, this, _gridQuery, _spriteQuery); + Subs.CVar(_cfg, CCVars.WallMountDirectionalVisibility, OnDirectionalVisibilityChanged, true); + } + + public override void Shutdown() + { + base.Shutdown(); + + _overlay.RemoveOverlay(_overlayInstance); + } + + private void OnDirectionalVisibilityChanged(bool enabled) + { + DirectionalVisibilityEnabled = enabled; + + if (enabled) + _overlay.AddOverlay(_overlayInstance); + else + { + _overlay.RemoveOverlay(_overlayInstance); + SetAllVisible(true); + } + } + + /// + /// Invalidates tile cache when anchor state changes for a blocking entity. + /// + [SubscribeLocalEvent] + private void OnTagAnchorChanged(Entity ent, ref AnchorStateChangedEvent args) + { + if (!_tag.HasAnyTag(ent.Comp, BlockingTags)) + return; + + var xform = args.Transform; + if (xform.GridUid is not { } gridUid) + return; + + if (!_gridQuery.TryGetComponent(gridUid, out var grid)) + return; + + var tile = _map.TileIndicesFor(gridUid, grid, xform.Coordinates); + _tileCache.Remove((gridUid, tile)); + } + + /// + /// Makes the entity visible again on component shutdown. + /// + [SubscribeLocalEvent] + private void OnWallMountShutdown(Entity ent, ref ComponentShutdown args) + { + if (TerminatingOrDeleted(ent)) + return; + + if (!TryComp(ent, out var sprite)) + return; + + _sprite.SetVisible((ent, sprite), true); + } + + /// + /// Makes the entity visible again if directional visibility is disabled for this mount. + /// + [SubscribeLocalEvent] + private void OnWallMountAfterHandleState(Entity ent, ref AfterAutoHandleStateEvent args) + { + if (ent.Comp.DirectionalVisibility) + return; + + if (!TryComp(ent, out var sprite)) + return; + + _sprite.SetVisible((ent, sprite), true); + } + + /// + /// Removes all cached entries for a grid that is being removed. + /// + [SubscribeLocalEvent] + private void OnGridRemoval(GridRemovalEvent ev) + { + foreach (var key in _tileCache.Keys.Where(k => k.Grid == ev.EntityUid).ToList()) + { + _tileCache.Remove(key); + } + } + + /// + /// Clears tile cache and resets all wall-mount visibility on round restart. + /// + [SubscribeNetworkEvent] + private void OnRoundRestart(RoundRestartCleanupEvent ev) + { + _tileCache.Clear(); + SetAllVisible(true); + } + + /// + /// Forces all wall-mount entities to become visible or hidden. + /// + internal void SetAllVisible(bool visible) + { + var query = AllEntityQuery(); + while (query.MoveNext(out var uid, out _, out var sprite)) + { + _sprite.SetVisible((uid, sprite), visible); + } + } + + /// + /// Checks whether the tile contains any anchored blocking entity. + /// + internal bool IsTileBlocked(EntityUid gridUid, Vector2i tile, EntityUid? ignoreUid = null) + { + if (!_gridQuery.TryGetComponent(gridUid, out var grid)) + return false; + + var key = (gridUid, tile); + if (_tileCache.TryGetValue(key, out var cached)) + return cached; + + var enumerator = _map.GetAnchoredEntitiesEnumerator(gridUid, grid, tile); + while (enumerator.MoveNext(out var anchored)) + { + if (anchored == ignoreUid) + continue; + + if (!_tag.HasAnyTag(anchored.Value, BlockingTags)) + continue; + + return _tileCache[key] = true; + } + + return _tileCache[key] = false; + } +} diff --git a/Content.Client/Wall/WallMountFadeState.cs b/Content.Client/Wall/WallMountFadeState.cs new file mode 100644 index 0000000000..f8c7b7478c --- /dev/null +++ b/Content.Client/Wall/WallMountFadeState.cs @@ -0,0 +1,88 @@ +using Robust.Client.GameObjects; + +namespace Content.Client.Wall; + +/// +/// Tracks fade progress for a wall-mounted entity. +/// +internal struct WallMountFadeState +{ + public float OriginalAlpha; + public float CurrentAlpha; + public float TargetAlpha; + + /// + /// Sprite alpha after applying the fade multiplier. + /// + public readonly float EffectiveAlpha => OriginalAlpha * CurrentAlpha; + + /// + /// Whether the entity should be rendered as visible at the current fade level. + /// + public readonly bool IsVisible => CurrentAlpha > 0f; + + /// + /// Creates a new fade state snapped immediately to the given target alpha. + /// + public static WallMountFadeState Snapped(float originalAlpha, float targetAlpha) + { + return new() + { + OriginalAlpha = originalAlpha, + CurrentAlpha = targetAlpha, + TargetAlpha = targetAlpha, + }; + } + + /// + /// Moves one step towards . + /// + public void StepTowards(float fadeStep) + { + if (MathHelper.CloseTo(CurrentAlpha, TargetAlpha)) + return; + + var delta = TargetAlpha - CurrentAlpha; + var step = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), fadeStep); + CurrentAlpha = Math.Clamp(CurrentAlpha + step, 0f, 1f); + } +} + +/// +/// Per-viewport fade state for wall-mounted entities. +/// +internal sealed class ViewportFadeState(SpriteSystem sprite, EntityQuery spriteQuery) : IDisposable +{ + private readonly SpriteSystem _sprite = sprite; + private readonly EntityQuery _spriteQuery = spriteQuery; + + /// + /// Fade states for entities tracked in this viewport. + /// + public readonly Dictionary FadeStates = []; + + /// + /// Entities visible this frame. + /// + public readonly HashSet SeenThisFrame = []; + + /// + /// Whether FOV was enabled during the previous frame. + /// + public bool WasFovEnabled = true; + + public void Dispose() + { + foreach (var (uid, state) in FadeStates) + { + if (!_spriteQuery.TryGetComponent(uid, out var sprite)) + continue; + + _sprite.SetColor((uid, sprite), sprite.Color.WithAlpha(state.OriginalAlpha)); + _sprite.SetVisible((uid, sprite), true); + } + + FadeStates.Clear(); + SeenThisFrame.Clear(); + } +} diff --git a/Content.Client/Wall/WallMountVisibilityOverlay.cs b/Content.Client/Wall/WallMountVisibilityOverlay.cs new file mode 100644 index 0000000000..c2c3e1dc7d --- /dev/null +++ b/Content.Client/Wall/WallMountVisibilityOverlay.cs @@ -0,0 +1,242 @@ +using System.Linq; +using System.Numerics; +using Content.Client.Graphics; +using Content.Client.Wall.Systems; +using Content.Shared.Wall; +using Robust.Client.GameObjects; +using Robust.Client.Graphics; +using Robust.Shared.Enums; +using Robust.Shared.Graphics; +using Robust.Shared.Map.Components; +using Robust.Shared.Timing; + +namespace Content.Client.Wall; + +/// +/// Renders wall-mounted entities conditionally based on their facing arc relative to the viewport's eye. +/// +public sealed partial class WallMountVisibilityOverlay( + IGameTiming timing, + SharedMapSystem map, + SpriteSystem sprite, + TransformSystem xform, + WallMountTreeSystem tree, + WallMountVisibilitySystem visibility, + EntityQuery gridQuery, + EntityQuery spriteQuery) : Overlay +{ + private readonly IGameTiming _timing = timing; + private readonly SharedMapSystem _map = map; + private readonly SpriteSystem _sprite = sprite; + private readonly TransformSystem _xform = xform; + private readonly WallMountTreeSystem _tree = tree; + private readonly WallMountVisibilitySystem _visibility = visibility; + + private readonly EntityQuery _gridQuery = gridQuery; + private readonly EntityQuery _spriteQuery = spriteQuery; + + /// + /// Caches instances per viewport. + /// + private readonly OverlayResourceCache _fadeCache = new(); + + /// + /// Original sprite alphas, shared across viewports to avoid capturing values modified by another viewport. + /// + private readonly Dictionary _originalAlphas = []; + + /// + /// Alpha change per second during fade. + /// + private const float FadeSpeed = 9f; + + public override OverlaySpace Space => OverlaySpace.WorldSpaceBelowEntities; + + protected override void Draw(in OverlayDrawArgs args) + { + if (!_visibility.DirectionalVisibilityEnabled) + return; + + if (args.Viewport.Eye is not { } eye) + return; + + var viewportState = _fadeCache.GetForViewport(args.Viewport, _ => new ViewportFadeState(_sprite, _spriteQuery)); + + if (!eye.DrawFov) + { + HandleFovDisabled(args, viewportState); + return; + } + + viewportState.WasFovEnabled = true; + + var fadeStep = FadeSpeed * (float)_timing.FrameTime.TotalSeconds; + var matrix = args.Viewport.GetWorldToLocalMatrix(); + + viewportState.SeenThisFrame.Clear(); + ProcessVisibleEntities(args, eye, matrix, fadeStep, viewportState); + + // Remove entities that left the viewport this frame. + foreach (var uid in viewportState.FadeStates.Keys.Except(viewportState.SeenThisFrame).ToList()) + { + RemoveTrackedEntity(uid, viewportState); + } + + ApplyFadeToVisibleEntities(viewportState); + } + + /// + /// When FOV gets disabled, clears fade state and restores sprites. + /// On subsequent frames, keeps wall-mounts visible and restores alpha modified by other viewports. + /// + private void HandleFovDisabled(in OverlayDrawArgs args, ViewportFadeState viewportState) + { + if (viewportState.WasFovEnabled) + { + ClearViewportFadeState(viewportState); + viewportState.WasFovEnabled = false; + } + + // Restore alpha modified by other viewports. + foreach (var entity in _tree.QueryAabb(args.MapId, args.WorldBounds)) + { + var uid = entity.Uid; + if (!_spriteQuery.TryGetComponent(uid, out var sprite)) + continue; + + if (_originalAlphas.Remove(uid, out var origAlpha)) + _sprite.SetColor((uid, sprite), sprite.Color.WithAlpha(origAlpha)); + + _sprite.SetVisible((uid, sprite), true); + } + } + + /// + /// Updates fade state for all wall-mounted entities in the viewport. + /// + private void ProcessVisibleEntities(in OverlayDrawArgs args, IEye eye, Matrix3x2 matrix, float fadeStep, ViewportFadeState viewportState) + { + foreach (var entity in _tree.QueryAabb(args.MapId, args.WorldBounds)) + { + var (wallmount, xform) = entity; + var uid = entity.Uid; + + if (!_spriteQuery.TryGetComponent(uid, out var sprite)) + continue; + + // Capture original alpha before any viewport modifies it. + if (!_originalAlphas.TryGetValue(uid, out var originalAlpha)) + _originalAlphas[uid] = originalAlpha = sprite.Color.A; + + viewportState.SeenThisFrame.Add(uid); + + var targetAlpha = ComputeTargetAlpha(uid, wallmount, xform, eye, matrix); + UpdateFadeState(uid, originalAlpha, targetAlpha, fadeStep, viewportState); + } + } + + /// + /// Returns 1 if the entity is within its facing arc relative to the eye, 0 otherwise. + /// + private float ComputeTargetAlpha(EntityUid uid, WallMountComponent wallmount, TransformComponent xform, IEye eye, Matrix3x2 matrix) + { + if (!wallmount.DirectionalVisibility || wallmount.Arc >= Math.Tau) + return 1f; + + if (xform.GridUid is not { } gridUid || !_gridQuery.TryGetComponent(gridUid, out var grid)) + return 1f; + + var tile = _map.TileIndicesFor(gridUid, grid, xform.Coordinates); + if (!_visibility.IsTileBlocked(gridUid, tile, uid)) + return 1f; + + var (pos, rot) = _xform.GetWorldPositionRotation(xform); + var facingAngle = rot + eye.Rotation + wallmount.Direction; + + var entityScreenPos = Vector2.Transform(pos, matrix); + var eyeScreenPos = Vector2.Transform(eye.Position.Position, matrix); + var toEntity = entityScreenPos - eyeScreenPos; + var eyeToEntityAngle = (toEntity with { X = -toEntity.X }).ToWorldAngle(); + + var angleDiff = Angle.ShortestDistance(eyeToEntityAngle, facingAngle); + return Math.Abs(angleDiff) < wallmount.Arc / 2 ? 1f : 0f; + } + + /// + /// Applies the current fade state to all entities seen this frame. + /// + private void ApplyFadeToVisibleEntities(ViewportFadeState viewportState) + { + foreach (var uid in viewportState.SeenThisFrame) + { + if (!viewportState.FadeStates.TryGetValue(uid, out var state)) + continue; + + if (!_spriteQuery.TryGetComponent(uid, out var sprite)) + { + RemoveTrackedEntity(uid, viewportState); + continue; + } + + _sprite.SetColor((uid, sprite), sprite.Color.WithAlpha(state.EffectiveAlpha)); + _sprite.SetVisible((uid, sprite), state.IsVisible); + } + } + + /// + /// Updates fade target for a tracked entity. Newly seen entities snap immediately without fading. + /// + private static void UpdateFadeState(EntityUid uid, float originalAlpha, float targetAlpha, float fadeStep, ViewportFadeState viewportState) + { + if (viewportState.FadeStates.TryGetValue(uid, out var state)) + { + state.TargetAlpha = targetAlpha; + state.StepTowards(fadeStep); + viewportState.FadeStates[uid] = state; + return; + } + + viewportState.FadeStates[uid] = WallMountFadeState.Snapped(originalAlpha, targetAlpha); + } + + private void RestoreSprite(EntityUid uid, WallMountFadeState state) + { + if (!_spriteQuery.TryGetComponent(uid, out var sprite)) + return; + + _sprite.SetColor((uid, sprite), sprite.Color.WithAlpha(state.OriginalAlpha)); + _sprite.SetVisible((uid, sprite), true); + } + + /// + /// Removes an entity from fade tracking and restores its sprite. + /// + private void RemoveTrackedEntity(EntityUid uid, ViewportFadeState viewportState) + { + if (viewportState.FadeStates.Remove(uid, out var state)) + RestoreSprite(uid, state); + + _originalAlphas.Remove(uid); + } + + /// + /// Clears all fade tracking for a viewport and restores sprites. + /// + private void ClearViewportFadeState(ViewportFadeState viewportState) + { + foreach (var (uid, state) in viewportState.FadeStates) + { + RestoreSprite(uid, state); + _originalAlphas.Remove(uid); + } + + viewportState.FadeStates.Clear(); + viewportState.SeenThisFrame.Clear(); + } + + protected override void DisposeBehavior() + { + _fadeCache.Dispose(); + base.DisposeBehavior(); + } +} diff --git a/Content.Shared/CCVar/CCVars.Game.cs b/Content.Shared/CCVar/CCVars.Game.cs index 16fcac7385..342cf783ce 100644 --- a/Content.Shared/CCVar/CCVars.Game.cs +++ b/Content.Shared/CCVar/CCVars.Game.cs @@ -435,4 +435,10 @@ public sealed partial class CCVars /// public static readonly CVarDef NewCharacterJobs = CVarDef.Create("game.new_character_jobs", "Passenger", CVar.REPLICATED); + + /// + /// Determines whether wall-mounted entities are hidden when viewed from outside their facing arc. + /// + public static readonly CVarDef WallMountDirectionalVisibility = + CVarDef.Create("game.wallmount_directional_visibility", true, CVar.SERVER | CVar.REPLICATED); } diff --git a/Content.Shared/Wall/WallMountComponent.cs b/Content.Shared/Wall/WallMountComponent.cs index 765dd5aff2..d7d1b04391 100644 --- a/Content.Shared/Wall/WallMountComponent.cs +++ b/Content.Shared/Wall/WallMountComponent.cs @@ -1,28 +1,45 @@ +using Robust.Shared.ComponentTrees; using Robust.Shared.GameStates; +using Robust.Shared.Physics; namespace Content.Shared.Wall; /// -/// This component enables an entity to ignore some obstructions for interaction checks. +/// Marks an entity as wall-mounted. +/// Allows interaction through other anchored entities on the same tile within the . +/// Hides the sprite when viewed from outside that arc. /// -/// -/// This will only exempt anchored entities that intersect the wall-mount. Additionally, this exemption will apply -/// in a limited arc, providing basic functionality for directional wall mounts. -/// -[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] -public sealed partial class WallMountComponent : Component +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(raiseAfterAutoHandleState: true)] +public sealed partial class WallMountComponent : Component, IComponentTreeEntry { /// - /// Range of angles for which the exemption applies. Bigger is more permissive. + /// Range of angles where interaction through other anchored entities on the same tile is allowed. + /// Bigger is more permissive. /// - [ViewVariables(VVAccess.ReadWrite)] - [DataField("arc"), AutoNetworkedField] + [DataField, AutoNetworkedField] public Angle Arc = new(MathF.PI); /// - /// The direction in which the exemption arc is facing, relative to the entity's rotation. Defaults to south. + /// The direction the allowed angle range faces, relative to the entity's rotation. + /// Defaults to south. /// - [ViewVariables(VVAccess.ReadWrite)] - [DataField("direction"), AutoNetworkedField] + [DataField, AutoNetworkedField] public Angle Direction = Angle.Zero; + + /// + /// If true, the sprite is only visible from within the facing . + /// + [DataField, AutoNetworkedField] + public bool DirectionalVisibility = true; + + [ViewVariables] + public EntityUid? TreeUid { get; set; } + + [ViewVariables] + public DynamicTree>? Tree { get; set; } + + [ViewVariables] + public bool AddToTree => Arc < Math.Tau && DirectionalVisibility; + + public bool TreeUpdateQueued { get; set; } }