Effects/CeilingDust.cs

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.

File Access
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" );
	}
}