diff --git a/Content.Client/_DV/Overlays/DarkVisionOverlay.cs b/Content.Client/_DV/Overlays/DarkVisionOverlay.cs
new file mode 100644
index 00000000000..1a29215ffbf
--- /dev/null
+++ b/Content.Client/_DV/Overlays/DarkVisionOverlay.cs
@@ -0,0 +1,104 @@
+using System.Numerics;
+using Content.Client.Graphics;
+using Robust.Client.Graphics;
+using Robust.Shared.Enums;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client._DV.Overlays;
+
+///
+/// Makes darkness visible, and bright lights painfully visible
+/// Tweakable. Algo is max((light*gain)^exp, lightFloor)
+///
+public sealed class DarkVisionOverlay : Overlay
+{
+ [Dependency] private readonly IClyde _clyde = default!;
+ [Dependency] private readonly IPrototypeManager _prototype = default!;
+
+ public override OverlaySpace Space => OverlaySpace.WorldSpaceBelowWorld;
+
+ private readonly ProtoId _shaderProto = "DarkVision";
+
+ public float LightFloor = 0.5f;
+ public float LightGain = 2f;
+ public float LightExp = 1f;
+
+ private readonly ShaderInstance _copyShader;
+ private readonly ShaderInstance _remapShader;
+ private readonly OverlayResourceCache _resources = new();
+
+ public DarkVisionOverlay()
+ {
+ IoCManager.InjectDependencies(this);
+
+ var proto = _prototype.Index(_shaderProto);
+ _remapShader = proto.InstanceUnique();
+ // With floor 0, gain 1, exp 1 the shader is an exact blend-mode-none copy.
+ _copyShader = proto.InstanceUnique();
+ _copyShader.SetParameter("lightFloor", 0f);
+ _copyShader.SetParameter("lightGain", 1f);
+ _copyShader.SetParameter("lightExp", 1f);
+ }
+
+ protected override void Draw(in OverlayDrawArgs args)
+ {
+ var viewport = args.Viewport;
+ var worldHandle = args.WorldHandle;
+
+ if (viewport.Eye == null)
+ return;
+
+ var lightTarget = viewport.LightRenderTarget;
+ var res = _resources.GetForViewport(viewport, static _ => new CachedResources());
+
+ if (res.ScratchTarget?.Size != lightTarget.Size)
+ {
+ res.ScratchTarget?.Dispose();
+ res.ScratchTarget = _clyde.CreateLightRenderTarget(lightTarget.Size, "darkvision-scratch", depthStencil: false);
+ }
+
+ var bounds = args.WorldBounds;
+ var lightScale = lightTarget.Size / (Vector2) viewport.Size;
+ var scale = viewport.RenderScale / (Vector2.One / lightScale);
+ var localMatrix = lightTarget.GetWorldToLocalMatrix(viewport.Eye, scale);
+
+ // Copy the light buffer aside first: a texture can't be sampled while it is also the
+ // render target being drawn into.
+ worldHandle.RenderInRenderTarget(res.ScratchTarget, () =>
+ {
+ worldHandle.UseShader(_copyShader);
+ worldHandle.SetTransform(localMatrix);
+ worldHandle.DrawTextureRect(lightTarget.Texture, bounds);
+ worldHandle.UseShader(null);
+ }, Color.Black);
+
+ // Then write it back through the remap.
+ _remapShader.SetParameter("lightFloor", LightFloor);
+ _remapShader.SetParameter("lightGain", LightGain);
+ _remapShader.SetParameter("lightExp", LightExp);
+ worldHandle.RenderInRenderTarget(lightTarget, () =>
+ {
+ worldHandle.UseShader(_remapShader);
+ worldHandle.SetTransform(localMatrix);
+ worldHandle.DrawTextureRect(res.ScratchTarget.Texture, bounds);
+ worldHandle.UseShader(null);
+ }, null);
+ }
+
+ protected override void DisposeBehavior()
+ {
+ _resources.Dispose();
+
+ base.DisposeBehavior();
+ }
+
+ private sealed class CachedResources : IDisposable
+ {
+ public IRenderTexture? ScratchTarget;
+
+ public void Dispose()
+ {
+ ScratchTarget?.Dispose();
+ }
+ }
+}
diff --git a/Content.Client/_DV/Overlays/DarkVisionSystem.cs b/Content.Client/_DV/Overlays/DarkVisionSystem.cs
new file mode 100644
index 00000000000..835156e8595
--- /dev/null
+++ b/Content.Client/_DV/Overlays/DarkVisionSystem.cs
@@ -0,0 +1,56 @@
+using Content.Shared._DV.Overlays.Components;
+using Robust.Client.Graphics;
+using Robust.Shared.Player;
+
+namespace Content.Client._DV.Overlays;
+
+public sealed class DarkVisionSystem : EntitySystem
+{
+ [Dependency] private readonly IOverlayManager _overlayMan = default!;
+ [Dependency] private readonly ISharedPlayerManager _playerMan = default!;
+
+ private DarkVisionOverlay _overlay = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnInit);
+ SubscribeLocalEvent(OnShutdown);
+ SubscribeLocalEvent(OnPlayerAttached);
+ SubscribeLocalEvent(OnPlayerDetached);
+
+ _overlay = new();
+ }
+
+ private void OnInit(Entity ent, ref ComponentInit args)
+ {
+ if (ent.Owner == _playerMan.LocalEntity)
+ EnableOverlay(ent.Comp);
+ }
+
+ private void OnShutdown(Entity ent, ref ComponentShutdown args)
+ {
+ if (ent.Owner == _playerMan.LocalEntity)
+ _overlayMan.RemoveOverlay(_overlay);
+ }
+
+ private void OnPlayerAttached(Entity ent, ref LocalPlayerAttachedEvent args)
+ {
+ EnableOverlay(ent.Comp);
+ }
+
+ private void OnPlayerDetached(Entity ent, ref LocalPlayerDetachedEvent args)
+ {
+ _overlayMan.RemoveOverlay(_overlay);
+ }
+
+ private void EnableOverlay(DarkVisionComponent comp)
+ {
+ _overlay.LightFloor = comp.LightFloor;
+ _overlay.LightGain = comp.LightGain;
+ _overlay.LightExp = comp.LightExp;
+ if (!_overlayMan.HasOverlay())
+ _overlayMan.AddOverlay(_overlay);
+ }
+}
diff --git a/Content.Shared/_DV/Light/SharedLightReactiveSystem.cs b/Content.Shared/_DV/Light/SharedLightReactiveSystem.cs
index 389a21fe4fa..2cddb4b20e3 100644
--- a/Content.Shared/_DV/Light/SharedLightReactiveSystem.cs
+++ b/Content.Shared/_DV/Light/SharedLightReactiveSystem.cs
@@ -70,13 +70,23 @@ public abstract class SharedLightReactiveSystem : EntitySystem
/// Avoid calling this too often, as it can be expensive.
///
public float GetLightLevelForPoint(EntityUid uid, TransformComponent? xform = null)
+ {
+ return GetLightLevelAtPosition(uid, _transform.GetWorldPosition(uid), xform);
+ }
+
+ ///
+ /// Gets the light level at an arbitrary world position, using for the
+ /// light lookup and map resolution. Lets callers sample somewhere other than the entity's
+ /// centre — e.g. a point just outside a wall, so the wall's own body can occlude the ray.
+ /// Avoid calling this too often, as it can be expensive.
+ ///
+ public float GetLightLevelAtPosition(EntityUid uid, Vector2 pos, TransformComponent? xform = null)
{
float val = 0.0f;
// Get the current map entity so we can get a MapLightComponent from it if it has one
var map = _transform.GetMap((uid, xform));
if (TryComp(map, out MapLightComponent? mapLight))
val += (mapLight.AmbientLightColor.R + mapLight.AmbientLightColor.G + mapLight.AmbientLightColor.B) / 3f;
- var pos = _transform.GetWorldPosition(uid);
foreach (var (lightUid, lightComp) in GetLights(uid))
{
diff --git a/Content.Shared/_DV/Overlays/Components/DarkVisionComponent.cs b/Content.Shared/_DV/Overlays/Components/DarkVisionComponent.cs
new file mode 100644
index 00000000000..3ddc961d1ff
--- /dev/null
+++ b/Content.Shared/_DV/Overlays/Components/DarkVisionComponent.cs
@@ -0,0 +1,30 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._DV.Overlays.Components;
+
+///
+/// Gives the owner darkvision: lighting still renders, but total darkness is raised to
+/// brightness instead of pitch black. Unlike night vision this keeps
+/// the whole lighting gradient visible, so creatures like the Skia can judge what is and isn't dark enough for them.
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class DarkVisionComponent : Component
+{
+ ///
+ /// Brightness that full darkness renders at, 0-1. Rendered light is clamped to a minimum of this value.
+ ///
+ [DataField, AutoNetworkedField]
+ public float LightFloor = 0.2f;
+
+ ///
+ /// Multiplier applied to actual light on top of the floor. Values above 1 overbrighten lit areas so they are unmistakable next to the grey darkness floor.
+ ///
+ [DataField, AutoNetworkedField]
+ public float LightGain = 8f;
+
+ ///
+ /// Exponent applied to lights, to make brighter areas look notably brighter
+ ///
+ [DataField, AutoNetworkedField]
+ public float LightExp = 2f;
+}
diff --git a/Content.Shared/_DV/ShadowWalk/ShadowWalkerComponent.cs b/Content.Shared/_DV/ShadowWalk/ShadowWalkerComponent.cs
new file mode 100644
index 00000000000..689ad0aa555
--- /dev/null
+++ b/Content.Shared/_DV/ShadowWalk/ShadowWalkerComponent.cs
@@ -0,0 +1,36 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Timing;
+
+namespace Content.Shared._DV.ShadowWalk;
+
+///
+/// Lets this entity walk straight through solid static objects (walls, doors, windows...)
+/// while the entity itself is bathed in darkness (the same light level it heals in.)
+/// Mobs and projectiles always stay solid.
+///
+/// On collision, checks our light level. Objects we're stuck in are tagged in
+///
+///
+[RegisterComponent, NetworkedComponent]
+public sealed partial class ShadowWalkerComponent : Component
+{
+ ///
+ /// Light level below which an object counts as bathed in darkness.
+ /// If the entity has a LightLevelHealthComponent its DarkThreshold is used
+ /// instead, so objects are passable exactly where the entity would heal.
+ ///
+ [DataField]
+ public float DarkThreshold = 0.3f;
+
+ ///
+ /// Objects we're currently in. Objects in this list are never solid until we fully leave.
+ ///
+ public HashSet PassableEntities = new();
+
+ ///
+ /// Light level for this tick, to avoid re-calculating for more than one collision a tick.
+ ///
+ public GameTick LastLightCheckTick = GameTick.Zero;
+
+ public float LastLightLevel;
+}
diff --git a/Content.Shared/_DV/ShadowWalk/SharedShadowWalkSystem.cs b/Content.Shared/_DV/ShadowWalk/SharedShadowWalkSystem.cs
new file mode 100644
index 00000000000..5119175396d
--- /dev/null
+++ b/Content.Shared/_DV/ShadowWalk/SharedShadowWalkSystem.cs
@@ -0,0 +1,139 @@
+using Content.Shared._DV.Body;
+using Content.Shared._DV.Light;
+using Content.Shared.Projectiles;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Physics;
+using Robust.Shared.Physics.Components;
+using Robust.Shared.Physics.Events;
+using Robust.Shared.Timing;
+
+namespace Content.Shared._DV.ShadowWalk;
+
+///
+/// Can walk through darkness freely.
+///
+public sealed partial class SharedShadowWalkSystem : EntitySystem
+{
+ [Dependency] private readonly EntityLookupSystem _lookup = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly SharedLightReactiveSystem _lightReactive = default!;
+ [Dependency] private readonly SharedTransformSystem _transform = default!;
+
+ ///
+ /// How far outside a tagged object's AABB the walker's centre must be before the object is untagged (and so becomes solid again on the next collision).
+ /// At least the walker's collision radius, so an object is never made solid while it still overlaps the walker.
+ ///
+ private const float UnstickMargin = 0.45f;
+
+ ///
+ /// Gamefeel. Non-walls get a bigger unstick margin so they stay unstick even if you clip into a wall. Prevents getting stuck in walls.
+ ///
+ private const float MovableUnstickMargin = 1f;
+
+ private EntityQuery _lightHealthQuery;
+ private EntityQuery _physicsQuery;
+ private EntityQuery _projectileQuery;
+
+ private readonly List _toRemove = [];
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _lightHealthQuery = GetEntityQuery();
+ _physicsQuery = GetEntityQuery();
+ _projectileQuery = GetEntityQuery();
+
+ SubscribeLocalEvent(OnPreventCollide);
+ }
+
+ public override void Update(float frameTime)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var comp))
+ {
+ if (comp.PassableEntities.Count == 0)
+ continue;
+
+ var worldPos = _transform.GetWorldPosition(uid);
+
+ _toRemove.Clear();
+ foreach (var other in comp.PassableEntities)
+ {
+ // Untag anything we've deleted or fully walked clear of; the next collision with it will re-check the light level from scratch.
+ if (Deleted(other))
+ {
+ _toRemove.Add(other);
+ continue;
+ }
+
+ var margin = UnstickMargin;
+ // Non-statics get a bigger margin :)
+ if (_physicsQuery.TryComp(other, out var body) && body.BodyType != BodyType.Static)
+ margin += MovableUnstickMargin;
+
+ if (!_lookup.GetWorldAABB(other).Enlarged(margin).Contains(worldPos))
+ _toRemove.Add(other);
+ }
+
+ foreach (var other in _toRemove)
+ comp.PassableEntities.Remove(other);
+ }
+ }
+
+ private void OnPreventCollide(Entity ent, ref PreventCollideEvent args)
+ {
+ if (args.Cancelled)
+ return;
+
+ // Only phase through hard blockers; sensor fixtures must keep triggering.
+ if (!args.OurFixture.Hard || !args.OtherFixture.Hard)
+ return;
+
+ // Already phasing through this one: keep it passable until we've left it (pruned in
+ // Update), so a light change mid-overlap can never trap us inside it.
+ if (ent.Comp.PassableEntities.Contains(args.OtherEntity))
+ {
+ args.Cancelled = true;
+ return;
+ }
+
+ if (!CanPhaseThrough(args.OtherEntity, args.OtherBody))
+ return;
+
+ // A fresh collision: only phase if the walker itself is currently in darkness.
+ if (!InDarkness(ent))
+ return;
+
+ args.Cancelled = true;
+ ent.Comp.PassableEntities.Add(args.OtherEntity);
+ }
+
+ private bool CanPhaseThrough(EntityUid other, PhysicsComponent otherBody)
+ {
+ if (otherBody.BodyType == BodyType.KinematicController)
+ return false;
+ // Bullets never pass or hit based on collision timing.
+ if (_projectileQuery.HasComp(other))
+ return false;
+
+ return true;
+ }
+
+ private bool InDarkness(Entity ent)
+ {
+ // Darkness is whatever the walker heals in, if it heals in darkness at all.
+ var threshold = _lightHealthQuery.TryComp(ent, out var lightHealth)
+ ? lightHealth.DarkThreshold
+ : ent.Comp.DarkThreshold;
+
+ var curTick = _timing.CurTick;
+ if (ent.Comp.LastLightCheckTick != curTick)
+ {
+ ent.Comp.LastLightLevel = _lightReactive.GetLightLevelForPoint(ent.Owner);
+ ent.Comp.LastLightCheckTick = curTick;
+ }
+
+ return ent.Comp.LastLightLevel < threshold;
+ }
+}
diff --git a/Resources/Prototypes/_DV/Entities/Mobs/NPCs/skia.yml b/Resources/Prototypes/_DV/Entities/Mobs/NPCs/skia.yml
index ff3c408acbb..1b7f77591e3 100644
--- a/Resources/Prototypes/_DV/Entities/Mobs/NPCs/skia.yml
+++ b/Resources/Prototypes/_DV/Entities/Mobs/NPCs/skia.yml
@@ -1,5 +1,5 @@
- type: entity
- parent: [ SimpleSpaceMobBase, MobCombat, DVNodeCrawler ]
+ parent: [ SimpleSpaceMobBase, MobCombat ]
id: MobSkia
name: skia
description: A shadow given form, lashing out at anything that comes too close.
@@ -46,6 +46,7 @@
- type: MovementSpeedModifier
baseWalkSpeed: 2.25
baseSprintSpeed: 3.75
+ - type: ShadowWalker # Phases through walls and other static objects that are bathed in darkness.
- type: NoSlip
- type: MovedByPressure
enabled: false
@@ -128,8 +129,7 @@
speedModifier: 2.5 # needs to be fast because they'll get ganked otherwise
useSound:
path: /Audio/Items/crowbar.ogg
- - type: NightVision
- drawOverlay: false
+ - type: DarkVision # Not NightVision: keeps the lighting gradient visible so they can judge what's dark enough to heal/shadow-walk in.
- type: EmbedImmune
immuneTo:
components:
diff --git a/Resources/Prototypes/_DV/Shaders/darkvision.yml b/Resources/Prototypes/_DV/Shaders/darkvision.yml
new file mode 100644
index 00000000000..5dd6037fcc8
--- /dev/null
+++ b/Resources/Prototypes/_DV/Shaders/darkvision.yml
@@ -0,0 +1,4 @@
+- type: shader
+ id: DarkVision
+ kind: source
+ path: "/Textures/_DV/Shaders/darkvision.swsl"
diff --git a/Resources/Textures/_DV/Shaders/darkvision.swsl b/Resources/Textures/_DV/Shaders/darkvision.swsl
new file mode 100644
index 00000000000..f45940e2f8a
--- /dev/null
+++ b/Resources/Textures/_DV/Shaders/darkvision.swsl
@@ -0,0 +1,23 @@
+// Remaps the lighting buffer for darkvision: output = lightFloor + light * lightGain.
+// blend_mode none because this rewrites the buffer wholesale; with lightFloor 0 and
+// lightGain 1 it acts as an exact copy.
+light_mode unshaded;
+blend_mode none;
+
+uniform highp float lightFloor;
+uniform highp float lightGain;
+uniform highp float lightExp;
+
+highp vec3 lowerBound(highp vec3 col, highp float bound) {
+ return vec3(
+ max(col.r, bound),
+ max(col.g, bound),
+ max(col.b, bound)
+ );
+}
+
+void fragment() {
+ highp vec4 light = zTexture(UV);
+
+ COLOR = vec4(lowerBound(pow((light.rgb * lightGain), vec3(lightExp)), lightFloor), 1.0);
+}