diff --git a/Content.Server/_DV/AiDetector/AiDetectorComponent.cs b/Content.Server/_DV/AiDetector/AiDetectorComponent.cs
new file mode 100644
index 0000000000..0d376a2c62
--- /dev/null
+++ b/Content.Server/_DV/AiDetector/AiDetectorComponent.cs
@@ -0,0 +1,44 @@
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Server._DV.AiDetector;
+
+///
+/// Changes an appearance data string depending on distance to any AiDetectable entities.
+///
+[RegisterComponent, Access(typeof(AiDetectorSystem))]
+public sealed partial class AiDetectorComponent : Component
+{
+ ///
+ /// The string to use for appearance data when there is no AI nearby.
+ ///
+ [DataField]
+ public string Default = "none";
+
+ ///
+ /// Each range and state to use.
+ /// The first one found is used, so have the shortest range first.
+ ///
+ [DataField(required: true)]
+ public List Ranges = new();
+
+ ///
+ /// The state currently shown.
+ ///
+ [DataField]
+ public string State = string.Empty;
+
+ ///
+ /// How long to wait between updates.
+ ///
+ [DataField]
+ public TimeSpan UpdateDelay = TimeSpan.FromSeconds(0.5);
+
+ ///
+ /// When to next update state.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
+ public TimeSpan NextUpdate = TimeSpan.Zero;
+}
+
+[DataRecord]
+public partial record struct AiDetectorRange(string State = "", float Range = 0f);
diff --git a/Content.Server/_DV/AiDetector/AiDetectorSystem.cs b/Content.Server/_DV/AiDetector/AiDetectorSystem.cs
new file mode 100644
index 0000000000..2454d68fb8
--- /dev/null
+++ b/Content.Server/_DV/AiDetector/AiDetectorSystem.cs
@@ -0,0 +1,54 @@
+using Content.Shared._DV.AiDetector;
+using Content.Shared._DV.Whitelist;
+using Robust.Shared.Timing;
+
+namespace Content.Server._DV.AiDetector;
+
+public sealed class AiDetectorSystem : EntitySystem
+{
+ [Dependency] private readonly EntityLookupSystem _lookup = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+
+ private HashSet> _entities = new();
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var now = _timing.CurTime;
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var comp))
+ {
+ if (comp.NextUpdate > now)
+ return;
+
+ comp.NextUpdate = now + comp.UpdateDelay;
+
+ var ent = (uid, comp);
+ UpdateState(ent);
+ }
+ }
+
+ private void UpdateState(Entity ent)
+ {
+ var coords = Transform(ent).Coordinates;
+ var state = ent.Comp.Default;
+ foreach (var range in ent.Comp.Ranges)
+ {
+ _entities.Clear();
+ _lookup.GetEntitiesInRange(coords, range.Range, _entities);
+ if (_entities.Count == 0)
+ continue;
+
+ state = range.State;
+ break;
+ }
+
+ if (ent.Comp.State == state)
+ return;
+
+ ent.Comp.State = state;
+ _appearance.SetData(ent.Owner, AiDetectorVisuals.Light, state);
+ }
+}
diff --git a/Content.Shared/_DV/AiDetector/AiDetectorVisuals.cs b/Content.Shared/_DV/AiDetector/AiDetectorVisuals.cs
new file mode 100644
index 0000000000..b4497b4c07
--- /dev/null
+++ b/Content.Shared/_DV/AiDetector/AiDetectorVisuals.cs
@@ -0,0 +1,10 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._DV.AiDetector;
+
+[Serializable, NetSerializable]
+public enum AiDetectorVisuals : byte
+{
+ Layer,
+ Light
+}
diff --git a/Content.Shared/_DV/Physics/CollidingVisualsComponent.cs b/Content.Shared/_DV/Physics/CollidingVisualsComponent.cs
deleted file mode 100644
index dfa1800652..0000000000
--- a/Content.Shared/_DV/Physics/CollidingVisualsComponent.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-using Content.Shared.Whitelist;
-using Robust.Shared.Serialization;
-
-namespace Content.Shared._DV.Physics;
-
-///
-/// Changes an appearance data string depending on active collisions with fixtures.
-///
-[RegisterComponent, Access(typeof(CollidingVisualsSystem))]
-public sealed partial class CollidingVisualsComponent : Component
-{
- ///
- /// A whitelist entities must match to be counted for collisions.
- ///
- [DataField]
- public EntityWhitelist? Whitelist;
-
- ///
- /// The string to use for appearance data when no fixtures are being collided with.
- ///
- [DataField]
- public string Default = "none";
-
- ///
- /// The list of fixtures to check for collisions, first one colliding is used so is most important.
- ///
- [DataField(required: true)]
- public List Fixtures = new();
-
- ///
- /// Actively colliding fixtures.
- ///
- [DataField]
- public HashSet Active = new();
-}
-
-[Serializable, NetSerializable]
-public enum CollidingVisuals : byte
-{
- Layer,
- Fixture
-}
diff --git a/Content.Shared/_DV/Physics/CollidingVisualsSystem.cs b/Content.Shared/_DV/Physics/CollidingVisualsSystem.cs
deleted file mode 100644
index a6b41c8476..0000000000
--- a/Content.Shared/_DV/Physics/CollidingVisualsSystem.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using Content.Shared.Whitelist;
-using Robust.Shared.Physics.Events;
-
-namespace Content.Shared._DV.Physics;
-
-public sealed class CollidingVisualsSystem : EntitySystem
-{
- [Dependency] private readonly EntityWhitelistSystem _whitelist = default!;
- [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
-
- public override void Initialize()
- {
- base.Initialize();
-
- SubscribeLocalEvent(OnStartCollide);
- SubscribeLocalEvent(OnEndCollide);
- }
-
- private void OnStartCollide(Entity ent, ref StartCollideEvent args)
- {
- if (_whitelist.IsWhitelistFail(ent.Comp.Whitelist, args.OtherEntity))
- return;
-
- // update active fixtures and state
- var state = ent.Comp.Default;
- foreach (var id in ent.Comp.Fixtures)
- {
- if (args.OurFixtureId == id)
- {
- ent.Comp.Active.Add(id);
- state = id;
- break;
- }
- }
-
- SetState(ent, state);
- }
-
- private void OnEndCollide(Entity ent, ref EndCollideEvent args)
- {
- if (_whitelist.IsWhitelistFail(ent.Comp.Whitelist, args.OtherEntity))
- return;
-
- ent.Comp.Active.Remove(args.OurFixtureId);
-
- // find the first state that is still active
- var state = ent.Comp.Default;
- foreach (var id in ent.Comp.Fixtures)
- {
- if (ent.Comp.Active.Contains(id))
- {
- state = id;
- break;
- }
- }
-
- SetState(ent, state);
- }
-
- public void SetState(EntityUid uid, string state)
- {
- _appearance.SetData(uid, CollidingVisuals.Fixture, state);
- }
-}
diff --git a/Resources/Prototypes/_DV/Entities/Objects/Tools/ai_detector.yml b/Resources/Prototypes/_DV/Entities/Objects/Tools/ai_detector.yml
index 6c3f2a04b3..b8a2c747aa 100644
--- a/Resources/Prototypes/_DV/Entities/Objects/Tools/ai_detector.yml
+++ b/Resources/Prototypes/_DV/Entities/Objects/Tools/ai_detector.yml
@@ -8,7 +8,7 @@
- state: icon
- state: green-unlit
shader: unshaded
- map: [ enum.CollidingVisuals.Layer ]
+ map: [ enum.AiDetectorVisuals.Layer ]
- type: Physics
canCollide: true
- type: Fixtures
@@ -35,23 +35,20 @@
hard: false
mask:
- GhostImpassable
- - type: CollisionWake # don't stop checking for AI just because this isn't moving
- enabled: false
- type: Appearance
- type: GenericVisualizer
visuals:
- enum.CollidingVisuals.Fixture:
- enum.CollidingVisuals.Layer:
+ enum.AiDetectorVisuals.Light:
+ enum.AiDetectorVisuals.Layer:
none: { state: "green-unlit" }
yellow: { state: "yellow-unlit" }
red: { state: "red-unlit" }
- - type: CollidingVisuals
- whitelist:
- components:
- - AiDetectable
- fixtures:
- - red
- - yellow
+ - type: AiDetector
+ ranges:
+ - state: red
+ range: 5
+ - state: yellow
+ range: 12
- type: MappingCategories # don't map valid multitool by mistake
categories:
- Syndicate