diff --git a/Content.Client/Access/UI/AgentIDCardWindow.xaml b/Content.Client/Access/UI/AgentIDCardWindow.xaml
index 4947cd7f10..89de793714 100644
--- a/Content.Client/Access/UI/AgentIDCardWindow.xaml
+++ b/Content.Client/Access/UI/AgentIDCardWindow.xaml
@@ -6,7 +6,7 @@
-
+
diff --git a/Content.Client/Overlays/EquipmentHudSystem.cs b/Content.Client/Overlays/EquipmentHudSystem.cs
new file mode 100644
index 0000000000..1d5ec03291
--- /dev/null
+++ b/Content.Client/Overlays/EquipmentHudSystem.cs
@@ -0,0 +1,117 @@
+using Content.Shared.GameTicking;
+using Content.Shared.Inventory;
+using Content.Shared.Inventory.Events;
+using Robust.Client.GameObjects;
+using Robust.Client.Player;
+
+namespace Content.Client.Overlays;
+
+///
+/// This is a base system to make it easier to enable or disabling UI elements based on whether or not the player has
+/// some component, either on their controlled entity on some worn piece of equipment.
+///
+public abstract class EquipmentHudSystem : EntitySystem where T : IComponent
+{
+ [Dependency] private readonly IPlayerManager _player = default!;
+
+ protected bool IsActive;
+ protected virtual SlotFlags TargetSlots => ~SlotFlags.POCKET;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnStartup);
+ SubscribeLocalEvent(OnRemove);
+
+ SubscribeLocalEvent(OnPlayerAttached);
+ SubscribeLocalEvent(OnPlayerDetached);
+
+ SubscribeLocalEvent(OnCompEquip);
+ SubscribeLocalEvent(OnCompUnequip);
+
+ SubscribeLocalEvent>(OnRefreshComponentHud);
+ SubscribeLocalEvent>>(OnRefreshEquipmentHud);
+
+ SubscribeLocalEvent(OnRoundRestart);
+ }
+
+ private void Update(RefreshEquipmentHudEvent ev)
+ {
+ IsActive = true;
+ UpdateInternal(ev);
+ }
+
+ public void Deactivate()
+ {
+ if (!IsActive)
+ return;
+
+ IsActive = false;
+ DeactivateInternal();
+ }
+
+ protected virtual void UpdateInternal(RefreshEquipmentHudEvent args) { }
+
+ protected virtual void DeactivateInternal() { }
+
+ private void OnStartup(EntityUid uid, T component, ComponentStartup args)
+ {
+ RefreshOverlay(uid);
+ }
+
+ private void OnRemove(EntityUid uid, T component, ComponentRemove args)
+ {
+ RefreshOverlay(uid);
+ }
+
+ private void OnPlayerAttached(PlayerAttachedEvent args)
+ {
+ RefreshOverlay(args.Entity);
+ }
+
+ private void OnPlayerDetached(PlayerDetachedEvent args)
+ {
+ if (_player.LocalPlayer?.ControlledEntity == null)
+ Deactivate();
+ }
+
+ private void OnCompEquip(EntityUid uid, T component, GotEquippedEvent args)
+ {
+ RefreshOverlay(args.Equipee);
+ }
+
+ private void OnCompUnequip(EntityUid uid, T component, GotUnequippedEvent args)
+ {
+ RefreshOverlay(args.Equipee);
+ }
+
+ private void OnRoundRestart(RoundRestartCleanupEvent args)
+ {
+ Deactivate();
+ }
+
+ protected virtual void OnRefreshEquipmentHud(EntityUid uid, T component, InventoryRelayedEvent> args)
+ {
+ args.Args.Active = true;
+ }
+
+ protected virtual void OnRefreshComponentHud(EntityUid uid, T component, RefreshEquipmentHudEvent args)
+ {
+ args.Active = true;
+ }
+
+ private void RefreshOverlay(EntityUid uid)
+ {
+ if (uid != _player.LocalPlayer?.ControlledEntity)
+ return;
+
+ var ev = new RefreshEquipmentHudEvent(TargetSlots);
+ RaiseLocalEvent(uid, ev);
+
+ if (ev.Active)
+ Update(ev);
+ else
+ Deactivate();
+ }
+}
diff --git a/Content.Client/Overlays/ShowSecurityIconsSystem.cs b/Content.Client/Overlays/ShowSecurityIconsSystem.cs
new file mode 100644
index 0000000000..cbf8044a60
--- /dev/null
+++ b/Content.Client/Overlays/ShowSecurityIconsSystem.cs
@@ -0,0 +1,73 @@
+using Content.Shared.Access.Components;
+using Content.Shared.Access.Systems;
+using Content.Shared.Overlays;
+using Content.Shared.PDA;
+using Content.Shared.StatusIcon;
+using Content.Shared.StatusIcon.Components;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Overlays;
+public sealed class ShowSecurityIconsSystem : EquipmentHudSystem
+{
+ [Dependency] private readonly IPrototypeManager _prototypeMan = default!;
+ [Dependency] private readonly AccessReaderSystem _accessReader = default!;
+
+ [ValidatePrototypeId]
+ private const string JobIconForNoId = "JobIconNoId";
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnGetStatusIconsEvent);
+ }
+
+ private void OnGetStatusIconsEvent(EntityUid uid, StatusIconComponent _, ref GetStatusIconsEvent @event)
+ {
+ if (!IsActive || @event.InContainer)
+ {
+ return;
+ }
+
+ var healthIcons = DecideSecurityIcon(uid);
+
+ @event.StatusIcons.AddRange(healthIcons);
+ }
+
+ private IReadOnlyList DecideSecurityIcon(EntityUid uid)
+ {
+ var result = new List();
+
+ var jobIconToGet = JobIconForNoId;
+ if (_accessReader.FindAccessItemsInventory(uid, out var items))
+ {
+ foreach (var item in items)
+ {
+ // ID Card
+ if (TryComp(item, out IdCardComponent? id))
+ {
+ jobIconToGet = id.JobIcon;
+ break;
+ }
+
+ // PDA
+ if (TryComp(item, out PdaComponent? pda)
+ && pda.ContainedId != null
+ && TryComp(pda.ContainedId, out id))
+ {
+ jobIconToGet = id.JobIcon;
+ break;
+ }
+ }
+ }
+
+ if (_prototypeMan.TryIndex(jobIconToGet, out var jobIcon))
+ result.Add(jobIcon);
+ else
+ Log.Error($"Invalid job icon prototype: {jobIcon}");
+
+ // Add arrest icons here, WYCI.
+
+ return result;
+ }
+}
diff --git a/Content.Client/StatusIcon/StatusIconOverlay.cs b/Content.Client/StatusIcon/StatusIconOverlay.cs
index c5add7f9bd..eee0ebf6b4 100644
--- a/Content.Client/StatusIcon/StatusIconOverlay.cs
+++ b/Content.Client/StatusIcon/StatusIconOverlay.cs
@@ -1,8 +1,9 @@
-using System.Numerics;
+using Content.Shared.StatusIcon;
using Content.Shared.StatusIcon.Components;
using Robust.Client.GameObjects;
using Robust.Client.Graphics;
using Robust.Shared.Enums;
+using System.Numerics;
namespace Content.Client.StatusIcon;
@@ -41,10 +42,6 @@ public sealed class StatusIconOverlay : Overlay
if (xform.MapID != args.MapId)
continue;
- var icons = _statusIcon.GetStatusIcons(uid, meta);
- if (icons.Count == 0)
- continue;
-
var bounds = comp.Bounds ?? sprite.Bounds;
var worldPos = _transform.GetWorldPosition(xform, xformQuery);
@@ -52,12 +49,17 @@ public sealed class StatusIconOverlay : Overlay
if (!bounds.Translated(worldPos).Intersects(args.WorldAABB))
continue;
+ var icons = _statusIcon.GetStatusIcons(uid, meta);
+ if (icons.Count == 0)
+ continue;
+
var worldMatrix = Matrix3.CreateTranslation(worldPos);
Matrix3.Multiply(scaleMatrix, worldMatrix, out var scaledWorld);
Matrix3.Multiply(rotationMatrix, scaledWorld, out var matty);
handle.SetTransform(matty);
- var count = 0;
+ var countL = 0;
+ var countR = 0;
var accOffsetL = 0;
var accOffsetR = 0;
icons.Sort();
@@ -71,13 +73,16 @@ public sealed class StatusIconOverlay : Overlay
// the icons are ordered left to right, top to bottom.
// extra icons that don't fit are just cut off.
- if (count % 2 == 0)
+ if (proto.LocationPreference == StatusIconLocationPreference.Left ||
+ proto.LocationPreference == StatusIconLocationPreference.None && countL <= countR)
{
if (accOffsetL + texture.Height > sprite.Bounds.Height * EyeManager.PixelsPerMeter)
break;
accOffsetL += texture.Height;
yOffset = (bounds.Height + sprite.Offset.Y) / 2f - (float) accOffsetL / EyeManager.PixelsPerMeter;
xOffset = -(bounds.Width + sprite.Offset.X) / 2f;
+
+ countL++;
}
else
{
@@ -86,8 +91,9 @@ public sealed class StatusIconOverlay : Overlay
accOffsetR += texture.Height;
yOffset = (bounds.Height + sprite.Offset.Y) / 2f - (float) accOffsetR / EyeManager.PixelsPerMeter;
xOffset = (bounds.Width + sprite.Offset.X) / 2f - (float) texture.Width / EyeManager.PixelsPerMeter;
+
+ countR++;
}
- count++;
var position = new Vector2(xOffset, yOffset);
handle.DrawTexture(texture, position);
diff --git a/Content.Client/StatusIcon/StatusIconSystem.cs b/Content.Client/StatusIcon/StatusIconSystem.cs
index a6caf4a2db..bd708b63d0 100644
--- a/Content.Client/StatusIcon/StatusIconSystem.cs
+++ b/Content.Client/StatusIcon/StatusIconSystem.cs
@@ -1,4 +1,4 @@
-using Content.Shared.CCVar;
+using Content.Shared.CCVar;
using Content.Shared.StatusIcon;
using Content.Shared.StatusIcon.Components;
using Robust.Client.Graphics;
diff --git a/Content.Shared/Inventory/Events/RefreshEquipmentHudEvent.cs b/Content.Shared/Inventory/Events/RefreshEquipmentHudEvent.cs
new file mode 100644
index 0000000000..2f2744331d
--- /dev/null
+++ b/Content.Shared/Inventory/Events/RefreshEquipmentHudEvent.cs
@@ -0,0 +1,13 @@
+namespace Content.Shared.Inventory.Events;
+
+public sealed class RefreshEquipmentHudEvent : EntityEventArgs, IInventoryRelayEvent where T : IComponent
+{
+ public SlotFlags TargetSlots { get; init; }
+ public bool Active = false;
+ public object? ExtraData;
+
+ public RefreshEquipmentHudEvent(SlotFlags targetSlots)
+ {
+ TargetSlots = targetSlots;
+ }
+}
diff --git a/Content.Shared/Inventory/InventorySystem.Relay.cs b/Content.Shared/Inventory/InventorySystem.Relay.cs
index 179d234ce5..bada7935ba 100644
--- a/Content.Shared/Inventory/InventorySystem.Relay.cs
+++ b/Content.Shared/Inventory/InventorySystem.Relay.cs
@@ -4,7 +4,9 @@ using Content.Shared.Electrocution;
using Content.Shared.Explosion;
using Content.Shared.Eye.Blinding.Systems;
using Content.Shared.IdentityManagement.Components;
+using Content.Shared.Inventory.Events;
using Content.Shared.Movement.Systems;
+using Content.Shared.Overlays;
using Content.Shared.Radio;
using Content.Shared.Slippery;
using Content.Shared.Strip.Components;
@@ -34,6 +36,9 @@ public partial class InventorySystem
SubscribeLocalEvent(RelayInventoryEvent);
SubscribeLocalEvent(RelayInventoryEvent);
+ // ComponentActivatedClientSystems
+ SubscribeLocalEvent>(RelayInventoryEvent);
+
SubscribeLocalEvent>(OnGetStrippingVerbs);
}
@@ -47,7 +52,7 @@ public partial class InventorySystem
while (containerEnumerator.MoveNext(out var container))
{
if (!container.ContainedEntity.HasValue) continue;
- RaiseLocalEvent(container.ContainedEntity.Value, ev, false);
+ RaiseLocalEvent(container.ContainedEntity.Value, ev, broadcast: false);
}
}
diff --git a/Content.Shared/Overlays/ShowSecurityIconsComponent.cs b/Content.Shared/Overlays/ShowSecurityIconsComponent.cs
new file mode 100644
index 0000000000..bade256d6f
--- /dev/null
+++ b/Content.Shared/Overlays/ShowSecurityIconsComponent.cs
@@ -0,0 +1,10 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Overlays
+{
+ ///
+ /// This component allows you to see job icons above mobs.
+ ///
+ [RegisterComponent, NetworkedComponent]
+ public sealed class ShowSecurityIconsComponent : Component { }
+}
diff --git a/Content.Shared/StatusIcon/StatusIconPrototype.cs b/Content.Shared/StatusIcon/StatusIconPrototype.cs
index 2b675f3d60..afd9dc0ff2 100644
--- a/Content.Shared/StatusIcon/StatusIconPrototype.cs
+++ b/Content.Shared/StatusIcon/StatusIconPrototype.cs
@@ -1,5 +1,5 @@
-using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Array;
using Robust.Shared.Utility;
@@ -24,6 +24,12 @@ public class StatusIconData : IComparable
[DataField("priority")]
public int Priority = 10;
+ ///
+ /// A preference for where the icon will be displayed. None | Left | Right
+ ///
+ [DataField("locationPreference")]
+ public StatusIconLocationPreference LocationPreference = StatusIconLocationPreference.None;
+
public int CompareTo(StatusIconData? other)
{
return Priority.CompareTo(other?.Priority ?? int.MaxValue);
@@ -49,3 +55,11 @@ public sealed class StatusIconPrototype : StatusIconData, IPrototype, IInheritin
[IdDataField]
public string ID { get; } = default!;
}
+
+[Serializable, NetSerializable]
+public enum StatusIconLocationPreference : byte
+{
+ None,
+ Left,
+ Right,
+}
diff --git a/Resources/Prototypes/Entities/Clothing/Eyes/hud.yml b/Resources/Prototypes/Entities/Clothing/Eyes/hud.yml
index 9405e1f83c..9b505654c9 100644
--- a/Resources/Prototypes/Entities/Clothing/Eyes/hud.yml
+++ b/Resources/Prototypes/Entities/Clothing/Eyes/hud.yml
@@ -30,6 +30,7 @@
sprite: Clothing/Eyes/Hud/sec.rsi
- type: Clothing
sprite: Clothing/Eyes/Hud/sec.rsi
+ - type: ShowSecurityIcons
- type: entity
parent: ClothingEyesBase
@@ -96,6 +97,7 @@
sprite: Clothing/Eyes/Hud/medsec.rsi
- type: Clothing
sprite: Clothing/Eyes/Hud/medsec.rsi
+ - type: ShowSecurityIcons
- type: entity
parent: ClothingEyesBase
@@ -107,6 +109,7 @@
sprite: Clothing/Eyes/Hud/medsecengi.rsi
- type: Clothing
sprite: Clothing/Eyes/Hud/medsecengi.rsi
+ - type: ShowSecurityIcons
- type: entity
parent: ClothingEyesBase
@@ -118,3 +121,4 @@
sprite: Clothing/Eyes/Hud/omni.rsi
- type: Clothing
sprite: Clothing/Eyes/Hud/omni.rsi
+ - type: ShowSecurityIcons
diff --git a/Resources/Prototypes/StatusEffects/job.yml b/Resources/Prototypes/StatusEffects/job.yml
index e8418a6716..fc2580924b 100644
--- a/Resources/Prototypes/StatusEffects/job.yml
+++ b/Resources/Prototypes/StatusEffects/job.yml
@@ -2,6 +2,7 @@
id: JobIcon
abstract: true
priority: 1
+ locationPreference: Left
- type: statusIcon
parent: JobIcon
@@ -337,7 +338,7 @@
id: JobIconSeniorPhysician
icon:
sprite: Interface/Misc/job_icons.rsi
- state: SeniorPhysician
+ state: SeniorPhysician
- type: statusIcon
parent: JobIcon