A scene Component that spawns falling ceiling dust particles over the local player when triggered. It locates a ceiling by tracing upward from the player, places a thin box emitter under it, configures a ParticleEffect/ParticleBoxEmitter/ParticleSpriteRenderer, and controls emission rate over a timed "shed" period; includes a console command to trigger shedding.
using Sandbox;
using System;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// Dust shaken down from the ceiling over the local player — the spawn-in tremor's (`SpawnTremor`): *"also add like dust
/// falling from the cieling"* (2026-09-27).
///
/// ⛔ `AshParticles`' RECIPE, AND ITS WARNINGS WITH IT: one ParticleEffect, a box emitter created DISABLED so its default burst
/// of a hundred cannot fire before it is zeroed, world space so the dust stays where it fell, unlit and unfogged so a dark room
/// cannot turn it black, and the ash mote's sprite — tinted as dust, larger, falling rather than drifting.
///
/// ⚠️ THE EMITTER IS A THIN SLAB JUST UNDER THE CEILING ABOVE THE PLAYER, found by a trace up and followed as they move. With no
/// ceiling within <see cref="Reach"/> — open sky — nothing falls, because nothing is up there to shake loose.
///
/// ⚠️ EACH MACHINE, ITS OWN PLAYER. It is a picture, and the tremor that starts it fires on every machine from its own fade.
/// </summary>
public sealed class CeilingDust : Component
{
public static CeilingDust Instance { get; private set; }
protected override void OnAwake() => Instance = this;
public static CeilingDust Ensure( Scene scene = null )
{
if ( Instance.IsValid() ) return Instance;
scene ??= Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Ceiling Dust";
go.Flags |= GameObjectFlags.NotSaved;
return go.Components.Create<CeilingDust>();
}
/// <summary>The ash mote — a soft round speck — tinted as dust.</summary>
public const string SpritePath = AshParticles.SpritePath;
/// <summary>Specks a second while the ceiling sheds, at the tremor's height.</summary>
public static float Rate
{
get => _rate ?? 120f;
set => _rate = value;
}
static float? _rate;
/// <summary>How wide a patch of ceiling sheds, centred over the player.</summary>
public static float Box
{
get => _box ?? 560f;
set => _box = value;
}
static float? _box;
/// <summary>How fast the dust falls, in units a second — slow enough to hang, fast enough to read as falling.</summary>
public static float Fall
{
get => _fall ?? 95f;
set => _fall = value;
}
static float? _fall;
/// <summary>How big a speck is (`ParticleEffect.Scale` units — not a fraction, see `AshParticles.Size`).</summary>
public static float Size
{
get => _size ?? 3.6f;
set => _size = value;
}
static float? _size;
/// <summary>How far up a ceiling is looked for, from the player's head.</summary>
public static float Reach
{
get => _reach ?? 800f;
set => _reach = value;
}
static float? _reach;
/// <summary>
/// The dust's colour. ⚠️ DIMMER SINCE 2026-09-28 (it was 0.82, 0.76, 0.66): unlit, it glowed in basalt's dark rooms like snow.
/// </summary>
static readonly Color DustTint = new( 0.64f, 0.58f, 0.5f, 1f );
/// <summary>Where the ceiling is looked for from: straight above the player's head, and four points round it.</summary>
static readonly Vector3[] Offsets =
{
Vector3.Zero, new( 180f, 0f, 0f ), new( -180f, 0f, 0f ), new( 0f, 180f, 0f ), new( 0f, -180f, 0f ),
};
ParticleEffect _fx;
ParticleBoxEmitter _emitter;
ParticleSpriteRenderer _renderer;
float _until, _length, _scale = 1f;
bool _ceiling;
TimeSince _placed;
/// <summary>How many specks are in the air now, or -1 with no effect.</summary>
public int Live => _fx.IsValid() ? _fx.Particles.Count : -1;
/// <summary>
/// Shake dust down over the local player for <paramref name="seconds"/>: full for its first half, tapering out over the rest,
/// as the tremor does.
/// </summary>
public static void Shed( float seconds, float scale = 1f )
{
var m = Ensure();
if ( !m.IsValid() || seconds <= 0f ) return;
// ⚠️ AT A SCALE OF THE FULL RATE — the map's own tremors shed half the spawn-in's (`AmbientTremor.DustScale`)
m._scale = MathF.Max( 0f, scale );
m._length = seconds;
m._until = Time.Now + seconds;
m.Place();
if ( !m._ceiling ) Log.Info( "[nz-dust] no ceiling over the player — nothing to shake down" );
}
bool Build()
{
if ( _fx.IsValid() ) return true;
_fx = GameObject.Components.GetOrCreate<ParticleEffect>();
_emitter = GameObject.Components.Create<ParticleBoxEmitter>( false );
_renderer = GameObject.Components.GetOrCreate<ParticleSpriteRenderer>();
_fx.MaxParticles = 600;
_fx.LocalSpace = 0f;
_fx.Collision = false;
// ⚠️ FALLING, NOT DRIFTING: a steady drop with a little spread, and no drag to slow it into ash — and a sway of its own for
// each speck (`EmberLook.Wander`), so it sifts down rather than raining in straight lines
_fx.ConstantMovement = new Vector3( 0f, 0f, -Fall );
_fx.StartVelocity = 12f;
_fx.Damping = 0.2f;
_fx.OnStep = EmberLook.Wander( 14f );
_fx.ApplyShape = true;
_fx.ApplyAlpha = true;
_fx.ApplyColor = true;
_fx.Tint = DustTint;
// ⚠️ IT SPREADS AS IT FALLS AND THINS OUT: a speck at the ceiling, a haze by the floor
_fx.Scale = new ParticleFloat
{
Type = ParticleFloat.ValueType.Range,
Evaluation = ParticleFloat.EvaluationType.Life,
ConstantA = Size * 0.7f,
ConstantB = Size * 1.6f,
};
_fx.Alpha = new ParticleFloat
{
Type = ParticleFloat.ValueType.Range,
Evaluation = ParticleFloat.EvaluationType.Life,
ConstantA = 0.85f,
ConstantB = 0f,
};
_emitter.Burst = 0;
_emitter.Rate = 0f;
_emitter.Loop = true;
_emitter.Duration = 1f;
_emitter.Size = new Vector3( Box, Box, 12f );
_emitter.Enabled = true;
_renderer.Lighting = false;
_renderer.FogStrength = 0f;
_renderer.Additive = false;
_renderer.Alignment = ParticleSpriteRenderer.BillboardAlignment.LookAtCamera;
_renderer.SortMode = ParticleSpriteRenderer.ParticleSortMode.ByDistance;
_renderer.CameraFadeNear = 30f;
var sprite = ResourceLibrary.Get<Sprite>( SpritePath );
if ( sprite is null ) Log.Warning( $"[nz-dust] '{SpritePath}' did not load — the dust will fall invisibly" );
else _renderer.Sprite = sprite;
return true;
}
/// <summary>Find the ceiling over the local player and put the slab under it; the specks live long enough to reach the floor.</summary>
void Place()
{
_placed = 0f;
_ceiling = false;
var me = NZPlayer.Local;
if ( !me.IsValid() ) return;
// ⚠️ FIVE RAYS, NOT ONE (2026-09-28): straight up, and up from four points round the player. One ray under a beam put the
// whole slab at the beam; one into a skylight put it nowhere. The slab sits at the middle of what they find, and three
// misses of five is open sky.
var head = me.WorldPosition + Vector3.Up * 64f;
var hits = new List<float>( 5 );
foreach ( var off in Offsets )
{
var from = head + off;
var tr = Scene.Trace.Ray( from, from + Vector3.Up * Reach )
.WithoutTags( "player", "zombie", "trigger", "corpse", "ragdoll" )
.Run();
if ( tr.Hit ) hits.Add( tr.HitPosition.z );
}
if ( hits.Count < 3 ) return;
hits.Sort();
_ceiling = true;
WorldPosition = new Vector3( head.x, head.y, hits[hits.Count / 2] - 10f );
if ( Build() )
{
var drop = MathF.Max( 64f, WorldPosition.z - me.WorldPosition.z );
_fx.Lifetime = (drop / MathF.Max( 10f, Fall )).Clamp( 1f, 8f );
}
}
protected override void OnUpdate()
{
if ( !Build() ) return;
var left = _until - Time.Now;
var shedding = left > 0f && _ceiling;
// ⚠️ FOLLOWS THE PLAYER, a few times a second — the dust already falling stays where it was shaken loose
if ( left > 0f && _placed > 0.3f ) Place();
// full for the first half, then tapering out with the tremor
var rate = shedding ? Rate * _scale * (left / MathF.Max( 0.01f, _length * 0.5f )).Clamp( 0f, 1f ) : 0f;
_emitter.Rate = rate;
}
/// <summary>`nz_dust [seconds]` — shake the ceiling's dust down over you now, without the tremor. How many are in the air.</summary>
[ConCmd( "nz_dust" )]
public static void Cmd( float seconds = 4f )
{
Shed( seconds );
var m = Instance;
Log.Info( $"[nz-dust] shedding for {seconds:0.#}s at {Rate:0}/s · "
+ (m.IsValid() && m._ceiling ? $"ceiling {m.WorldPosition.z - (NZPlayer.Local?.WorldPosition.z ?? 0f):0}u up" : "no ceiling")
+ $" · {(m.IsValid() ? m.Live : -1)} in the air" );
}
}