A UI PanelComponent Razor file that renders an ash/soot overlay with three layered divs and updates their opacity each frame based on the FogAreaManager blended fog weight. It exposes console commands to toggle the overlay, set ceiling strength, and print status, and it uses a smoothing Lerp to follow fog changes.
@using Sandbox;
@using Sandbox.UI;
@using System;
@using NZombies;
@inherits PanelComponent
@*
ASH OVERLAY — drifting soot on the screen while you are inside a fog area.
Built on `DamageOverlay`'s shape: a separate PanelComponent, a constant
BuildHash, and opacity written to Style every frame from code.
⛔ THE DRIFT IS A KEYFRAME, THE OPACITY IS CODE, AND THE SPLIT IS DELIBERATE.
DamageOverlay drives BOTH from code because its pulse has to stay in phase
with its health-driven base — two independent CSS animations would drift
apart. Here drifting apart is the entire point: three layers at three speeds
is what makes it read as depth rather than as a moving picture. So the drift
is left to CSS and only the strength comes from the game.
⚠️ EACH LAYER TRANSLATES BY EXACTLY ONE OF ITS OWN TILES. `background-size`
and the keyframe distance are the same number in each case — 900, 420, 700.
Any other distance puts a seam across the screen once per loop, which reads
as a rendering fault and not as ash.
# MAPPORT: ash overlay
*@
<root class="ash">
<div @ref="Soot" class="layer soot"></div>
<div @ref="Far" class="layer motes-far"></div>
<div @ref="Near" class="layer motes-near"></div>
</root>
@code
{
/// <summary>⛔ CONSTANT, like DamageOverlay's — opacity is written to Style every frame, and a
/// hash that tracked the fog weight would rebuild the tree constantly and restart every drift
/// animation from zero as you walked.</summary>
protected override int BuildHash() => 0;
Panel Soot;
Panel Far;
Panel Near;
/// <summary>
/// Master switch.
///
/// ⚠️ NOT called `Enabled`. `PanelComponent` inherits `Component.Enabled`, and a static of that
/// name SHADOWS it — the two then become separate switches with one name, with `nz_ash 0`
/// setting one while the component system reads the other. This project has hit that collision
/// at least four times; `DamageOverlay.ShowOverlay` carries the same note.
/// </summary>
public static bool ShowOverlay { get; set; } = true;
/// <summary>
/// Ceiling on how strong it gets at full fog weight.
///
/// ⚠️ WELL UNDER 1, AND THAT IS THE POINT. At full opacity the specks stop being ash in the air
/// and become dirt on the lens — something ON the screen rather than something you are standing
/// in. It was asked for light.
/// </summary>
public static float Strength
{
get => _strength ??= 0.55f;
set => _strength = value;
}
static float? _strength;
/// <summary>Last applied strength, for the status command.</summary>
public static float Applied { get; private set; }
/// <summary>
/// How quickly it follows the fog.
///
/// ⛔ ITS OWN EASE, ON TOP OF THE FOG'S. `FogAreaManager` already cross-fades, but it fades the
/// fog COLOUR and distance — and specks appearing at the same rate a colour shifts looks like
/// they were switched on. Ash should gather a moment later than the haze does.
/// </summary>
public static float Follow { get; set; } = 1.4f;
float _shown;
protected override void OnUpdate()
{
if ( Soot is null || Far is null || Near is null ) return;
var target = Target();
// Time-based, so the ease does not change with framerate.
_shown = _shown.LerpTo( target, ( Time.Delta / MathF.Max( 0.05f, Follow ) ).Clamp( 0f, 1f ) );
Applied = _shown;
// ⛔ THE SOOT BED CARRIES THIS NOW; THE SCREEN MOTES ARE ALMOST GONE. `AshParticles` puts
// REAL motes in the air, and a painted speck sitting next to one that genuinely parallaxes
// is worse than no painted speck at all — the moment you turn, the fake ones slide with the
// screen and give themselves away. So the overlay keeps the job a screen layer is actually
// good at, which is the far haze and the grime, and hands the near field to the particles.
//
// ⚠️ NOT ZERO. A trace of them still reads as depth beyond the particle box, which is only
// `nz_ash_box` units across — past that edge the soot alone would look like a wall.
Soot.Style.Opacity = _shown;
Far.Style.Opacity = _shown * 0.30f;
Near.Style.Opacity = _shown * 0.12f;
}
/// <summary>0 = clear air, 1 = as thick as this overlay goes.</summary>
static float Target()
{
if ( !ShowOverlay ) return 0f;
var fog = FogAreaManager.Instance;
if ( !fog.IsValid() ) return 0f;
// ⛔ READS THE MANAGER'S BLENDED WEIGHT, NOT THE AREAS. Recomputing which area the player is
// in would be a second implementation of the same question, free to disagree with the one
// that decides the actual fog — and the two disagreeing is precisely the bug that would be
// impossible to see, because both look like "the overlay is a bit off".
return fog.Weight.Clamp( 0f, 1f ) * Strength;
}
// ── console ─────────────────────────────────────────────────────────────
/// <summary>`nz_ash [0|1]` — toggle it.</summary>
[ConCmd( "nz_ash" )]
public static void CmdEnable( int state = -1 )
{
ShowOverlay = state < 0 ? !ShowOverlay : state > 0;
Log.Info( $"[nz-ash] overlay {( ShowOverlay ? "on" : "off" )}" );
}
/// <summary>`nz_ash_strength 0.55` — ceiling at full fog.</summary>
[ConCmd( "nz_ash_strength" )]
public static void CmdStrength( float value )
{
Strength = value.Clamp( 0f, 1f );
Log.Info( $"[nz-ash] strength {Strength:0.##}" );
}
/// <summary>
/// `nz_ash_status` — what it is drawing and why.
///
/// ⛔ IT NAMES THE FOG WEIGHT AS WELL AS ITS OWN. An ash overlay showing nothing has two
/// completely different causes — the overlay is off or broken, or you are simply not standing
/// in a fog area — and from inside the game they look identical. This separates them in one
/// line instead of a hunt through two systems.
/// </summary>
[ConCmd( "nz_ash_status" )]
public static void CmdStatus()
{
var fog = FogAreaManager.Instance;
var w = fog.IsValid() ? fog.Weight : 0f;
Log.Info( $"[nz-ash] enabled={ShowOverlay} strength={Strength:0.##}"
+ $" fog weight={w:0.###} -> drawing {Applied:0.###}" );
if ( !fog.IsValid() )
Log.Warning( "[nz-ash] no FogAreaManager — load a map config (nz_load)" );
else if ( w <= 0.001f )
Log.Info( "[nz-ash] fog weight is zero — you are not inside a fog area (nz_fog_report)" );
}
}