Static utility for the Vulture Aid gas cloud (the "stink") effect. It loads a prefab, spawns non-networked cloud GameObjects, exposes tuning console commands to inspect and retune live clouds, and provides helpers to test whether a player is inside a cloud.
using Sandbox;
using System.Linq;
namespace NZombies;
/// <summary>
/// Vulture Aid's gas cloud — the "stink" a drop leaves on the ground.
///
/// ⛔ THE CLOUD IS STATIONARY AND THE PLAYER WALKS INTO IT. The original attaches
/// `nz_perks_vulture_stink` to the DROP, not to the player: a `nodraw` prop with a
/// 12-second timer. The player-side effect (`status_effect_vultures_stink`) has no visual
/// at all — it only sets `SetNoTarget(true)` and plays a looping sound. Getting that
/// backwards would give the player a green aura they carry around, which is a different
/// perk.
///
/// ⚠️ VALUES ARE THE APPROVED SET from `Docs/vfx/vulture_stink.html`, which is itself a
/// `pcf_decode.py` dump of `particles/perks_vulture.pcf`. Three differ from the file:
/// radius 32→35, rise +60→+70, oscillation ×5→×6. The mock's "Authored PCF" button is the
/// reference to compare against.
///
/// ⚠️ EVERY UNDERIVABLE NUMBER HAS A COMMAND FROM DAY ONE. `ParticleEffect.Scale`'s unit is
/// not the PCF's radius unit and cannot be worked out without looking — that class of guess
/// has cost this project three separate debugging sessions (INSTRUCTIONS: unverifiable unit
/// conversions), so `nz_stink_set` exists before the first screenshot rather than after it.
///
/// ⛔ AND THAT PAID OFF IMMEDIATELY — THE FACTOR IS NOW MEASURED, NOT GUESSED:
///
/// `ParticleEffect.Scale` ≈ 2.5 × a Source PCF radius
///
/// The prefab shipped 24→70, which is the PCF's authored 24–35 grown ×2, and in engine it
/// read far too small. ×2.5 was judged correct by eye, so Scale is 60→175. **That factor
/// applies to every future PCF port**, including the napalm flame, whose 9→32 was dialled in
/// by trial for the same reason — nobody had a number to convert with. Write the conversion
/// down once and no later effect has to rediscover it.
///
/// ⚠️ THE APPROVED MOCK IS STILL CORRECT AND WAS NOT CHANGED. `Docs/vfx/vulture_stink.html`
/// works in Source units (radius 35), and 35 × 2.5 = 87.5 — the midpoint of 60–175. What
/// changed is the conversion, not the design.
/// </summary>
public static class VultureStink
{
/// <summary>The generated prefab.</summary>
public const string Prefab = "prefabs/particles/nz/vulture_stink.prefab";
/// <summary>
/// How long a cloud lasts. The original's drop carries a 12-second timer.
/// </summary>
/// <summary>
/// How long a cloud lives, in seconds.
///
/// ⚠️ THE BASE VALUE ONLY. Vulture Aid's M2 Gas Cloak overrides it per spawn through
/// `Spawn( pos, seconds )` — see `VultureAugments.GasLifetime`. Reading this static
/// directly will miss the augment.
/// </summary>
public static float Lifetime { get; set; } = 12f;
/// <summary>
/// Radius within which a player counts as standing in the gas.
///
/// ⚠️ NOT THE VISUAL RADIUS, and the two are allowed to differ. The puffs grow to ~70
/// units and drift upward, so a hitbox matching the visual would cover air nobody can
/// stand in. This is the footprint on the floor.
/// </summary>
public static float Radius { get; set; } = 90f;
/// <summary>
/// How far above the floor the cloud's origin sits.
///
/// ⚠️ THE PCF SPAWNS AT THE ORIGIN AND RISES FROM THERE, so a cloud placed exactly on the
/// floor buries its first half-second of puffs in the geometry — they are 65 units across
/// at birth and their centres are at ground level. Lifting the origin puts the whole
/// sphere above the floor without changing the rise.
///
/// ⚠️ SMALL ON PURPOSE. Much more and it stops reading as a ground effect and starts
/// looking like something hovering, which is a different thing entirely.
/// </summary>
public static float GroundOffset { get; set; } = 20f;
static PrefabFile _prefab;
static bool _looked;
/// <summary>
/// The prefab, loaded once.
///
/// ⚠️ CACHES THE FAILURE TOO, like `BulletDecals.Prefab` — a missing prefab looked up
/// per spawn would spam `ERROR_FILEOPEN` at kill rate.
/// </summary>
static PrefabFile Asset
{
get
{
if ( _looked ) return _prefab;
_looked = true;
_prefab = ResourceLibrary.Get<PrefabFile>( Prefab );
if ( _prefab is null )
Log.Warning( $"[nz-stink] prefab '{Prefab}' not found — no gas" );
return _prefab;
}
}
/// <summary>
/// Put a cloud on the ground at a position.
///
/// ⚠️ NOT PARENTED TO ANYTHING. A cloud parented to a drop would move with it and
/// vanish when it was collected; the gas outlives the pickup that made it.
/// </summary>
public static GameObject Spawn( Vector3 position, float? seconds = null, bool announce = true )
{
// ⚠️ EVERY MACHINE BUILDS ITS OWN. The cloud is `NetworkMode.Never`, and it is queried as
// well as seen — Vulture m3 Gas Feed asks whether its owner stands in one — so a machine
// without the object loses an augment, not just a picture.
if ( announce && Networking.IsActive && Connection.Local is not null )
NZNet.WorldFx( Connection.Local.Id.ToString(), "",
(int)NZNet.FxKind.VultureGas, position, System.Guid.Empty );
var prefab = Asset;
if ( prefab is null ) return null;
var scene = SceneUtility.GetPrefabScene( prefab );
if ( scene is null ) return null;
// ⚠️ THE OFFSET IS APPLIED HERE, IN Spawn, not at the call sites. Both callers — the
// console command and the kill roll — pass a floor position, and an offset added by
// each of them separately is the §3 shape where one gets the fix and the other keeps
// spawning clouds in the ground.
var go = scene.Clone( new CloneConfig
{
Name = "vulture_stink",
StartEnabled = true,
Transform = new() { Position = position + Vector3.Up * GroundOffset },
} );
go.NetworkMode = NetworkMode.Never;
SWB.Shared.GameObjectExtensions.DestroyAsync( go, seconds ?? Lifetime );
return go;
}
static float _cloudStamp = -1f;
static Vector3[] _cloudCache = System.Array.Empty<Vector3>();
/// <summary>
/// Where the live clouds are, resolved once per frame.
///
/// ⛔ CACHED BECAUSE `ZombieAI.GetTargetables` ASKS PER ZOMBIE. That method runs on each
/// zombie's own retarget, so on a heavy round the gas test is reached up to 35 times in a
/// frame — and the expensive half is the `Directory.FindByName` sweep, not the distance
/// check. Stamping `Time.Now` collapses 35 sweeps into one.
///
/// ⚠️ POSITIONS, NOT GameObjects. The list is only ever used for distance tests, and
/// holding positions means a cloud destroyed mid-frame cannot produce an invalid-object
/// check in the middle of the AI tick.
///
/// ⚠️ The stamp seeds to -1 so the first frame of a session cannot match it and return a
/// stale empty list (INSTRUCTIONS §1 — a static that starts wrong).
/// </summary>
static Vector3[] Clouds()
{
if ( _cloudStamp == Time.Now ) return _cloudCache;
_cloudStamp = Time.Now;
var scene = Game.ActiveScene;
_cloudCache = scene.IsValid()
? scene.Directory.FindByName( "vulture_stink" )
.Where( g => g.IsValid() )
.Select( g => g.WorldPosition )
.ToArray()
: System.Array.Empty<Vector3>();
return _cloudCache;
}
/// <summary>
/// Is this player standing in any gas cloud.
///
/// ⚠️ FLAT DISTANCE, ignoring Z. The cloud rises off the floor and its origin is lifted
/// 20 units, so a spherical test would stop covering a player who was plainly still
/// standing in it.
/// </summary>
public static bool IsInGas( NZPlayer player )
{
if ( !player.IsValid() ) return false;
var clouds = Clouds();
if ( clouds.Length == 0 ) return false;
var feet = player.WorldPosition.WithZ( 0f );
foreach ( var at in clouds )
if ( at.WithZ( 0f ).Distance( feet ) <= Radius ) return true;
return false;
}
// ── commands ─────────────────────────────────────────────────────────────
static NZPlayer Me()
=> NZPlayer.Local;
/// <summary>
/// `nz_stink [distance]` — drop a cloud in front of the player and turn them to face it.
///
/// ⛔ IT AIMS THE CAMERA AS WELL AS SPAWNING, and that is not a convenience. A billboarded
/// particle cannot be photographed from a second camera — `LookAtCamera` orients to
/// whichever camera is rendering, so a scene screenshot of a sprite effect comes back
/// empty or edge-on. The napalm flame cost real time to that before `nz_flame_dump` was
/// written. Turning the PLAYER's view is the only way to get the effect into a frame.
///
/// ⚠️ PLACED ON THE FLOOR BY A TRACE, not at eye height minus a guess. The gas is a
/// ground effect and a cloud floating at chest height would misrepresent both its shape
/// and how much of it a standing player is inside.
/// </summary>
[ConCmd( "nz_stink" )]
public static void SpawnCmd( float distance = 140f )
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-stink] no player" ); return; }
var c = p.Components.Get<PlayerController>();
var eye = c?.EyePosition ?? p.WorldPosition + Vector3.Up * 64f;
var fwd = (c?.EyeAngles.ToRotation() ?? p.WorldRotation).Forward.WithZ( 0f ).Normal;
var ahead = eye + fwd * distance;
// Drop it to the floor.
var tr = Game.ActiveScene.Trace
.Ray( ahead + Vector3.Up * 64f, ahead + Vector3.Down * 512f )
.IgnoreGameObjectHierarchy( p.GameObject )
.Run();
var pos = tr.Hit ? tr.HitPosition : ahead.WithZ( p.WorldPosition.z );
var go = Spawn( pos );
// ⚠️ AIMED SLIGHTLY ABOVE THE ORIGIN, because the cloud rises. Looking at the spawn
// point puts most of the gas above the crosshair.
if ( c.IsValid() )
c.EyeAngles = Rotation.LookAt(
(pos + Vector3.Up * (GroundOffset + 40f)) - eye ).Angles();
Log.Info( go.IsValid()
? $"[nz-stink] cloud at {pos} — {distance:0}u ahead, floor {(tr.Hit ? "found" : "MISSED, used player z")}"
+ $", {Lifetime:0.#}s"
: "[nz-stink] no cloud — prefab missing" );
// ⛔ THE COUNT IS REPORTED A SECOND LATER, AND THAT IS THE WHOLE POINT OF THIS BLOCK.
// At 10 particles a second the first one arrives 0.1s after the spawn, so a dump run
// in the same frame reads `0/20` on a perfectly healthy cloud — which is exactly the
// ambiguous reading that wasted a round trip here. "Nothing happened" has two
// completely different causes and this is the line that separates them:
//
// 0 particles → the EMITTER is not producing. Look at Rate, Duration, Loop.
// >0 but invisible → the emitter is fine. Look at Scale, Alpha, the sprite.
//
// The napalm flame needed `nz_flame_dump` for the same reason: a LookAtCamera
// billboard cannot be photographed from a second camera, so the console is the only
// witness a particle effect has.
if ( go.IsValid() ) ReportAfter( go );
}
/// <summary>
/// Wait a beat, then say how many particles the cloud actually has.
///
/// ⚠️ `async void` WITH AN EXPLICIT VALIDITY RE-CHECK. The object can be destroyed while
/// this is awaiting — a short lifetime, a stopped play session — and touching a dead
/// GameObject after the await is the shape that throws inside a task nobody is observing.
/// </summary>
static async void ReportAfter( GameObject go )
{
// ⛔ `GameTask.Delay`, WHICH IS WHAT THE REST OF THIS PROJECT USES — see
// `SoundCommands`, four call sites. Two wrong guesses preceded it: `Task.DelaySeconds`
// (does not exist), then the same with `using System.Threading.Tasks;` added, which
// shadowed the engine type and made it worse. Copy the idiom the codebase already
// has rather than reaching for the one another engine would have.
await GameTask.Delay( 1000 );
if ( !go.IsValid() ) return;
var fx = go.Components.Get<ParticleEffect>( FindMode.EverythingInSelfAndDescendants );
if ( !fx.IsValid() ) { Log.Warning( "[nz-stink] no ParticleEffect after 1s" ); return; }
var n = fx.Particles?.Count ?? 0;
Log.Info( $"[nz-stink] after 1s: {n}/{fx.MaxParticles} particles"
+ $" · scale {fx.Scale.ConstantA:0.#}-{fx.Scale.ConstantB:0.#}" );
if ( n == 0 )
Log.Warning( "[nz-stink] ⛔ ZERO PARTICLES — the EMITTER is the problem, not the"
+ " look. Check Rate / Duration / Loop with nz_stink_dump." );
else
Log.Info( "[nz-stink] ✅ emitting — if you cannot see it the problem is the LOOK:"
+ " try nz_stink_set 80 220 to rule out Scale, whose unit is unverified." );
}
/// <summary>
/// `nz_stink_set [scaleMin] [scaleMax] [rate] [max] [rise] [damping]` — retune the live
/// clouds and every one spawned after.
///
/// ⛔ THIS EXISTS BECAUSE `ParticleEffect.Scale` IS NOT IN PCF RADIUS UNITS. The prefab
/// ships 24→70 because that is what the source says, and there is no way to know from
/// here whether that reads as a waist-high cloud or a wall of green. Rather than guess
/// and ship, the knob comes first — see the class note.
///
/// ⚠️ WRITES TO EVERY LIVE CLOUD, not just the next one. A tuning command you have to
/// respawn to see is one that makes comparing two values impossible.
/// </summary>
[ConCmd( "nz_stink_set" )]
public static void SetCmd( float scaleMin = -1f, float scaleMax = -1f, float rate = -1f,
int max = -1, float rise = -1f, float damping = -1f, float ground = -1f,
float footprint = -1f )
{
// ⚠️ THE OFFSET CANNOT BE APPLIED TO A LIVE CLOUD — it is a spawn position, not a
// running property — so it is set for the NEXT one and said so, rather than silently
// appearing to do nothing on the cloud in front of you.
if ( ground >= 0f )
{
GroundOffset = ground;
Log.Info( $"[nz-stink] ground offset {GroundOffset:0.#}u — applies to the NEXT cloud" );
}
// ⚠️ THE FOOTPRINT IS NOW GAMEPLAY, not decoration — it decides whether zombies can
// see you — so it gets a knob beside the visual ones. It is deliberately NOT tied to
// the visual radius: the puffs grow to 189 and drift upward, and a hitbox matching
// that would hide a player standing well outside the cloud.
if ( footprint >= 0f )
{
Radius = footprint;
Log.Info( $"[nz-stink] footprint {Radius:0.#}u — the radius zombies cannot see into" );
}
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-stink] no scene" ); return; }
var n = 0;
foreach ( var go in scene.Directory.FindByName( "vulture_stink" ) )
{
if ( !go.IsValid() ) continue;
var fx = go.Components.Get<ParticleEffect>( FindMode.EverythingInSelfAndDescendants );
var em = go.Components.Get<ParticleSphereEmitter>( FindMode.EverythingInSelfAndDescendants );
if ( fx.IsValid() )
{
if ( scaleMin >= 0f || scaleMax >= 0f )
{
var lo = scaleMin >= 0f ? scaleMin : fx.Scale.ConstantA;
var hi = scaleMax >= 0f ? scaleMax : fx.Scale.ConstantB;
fx.Scale = new ParticleFloat
{
Type = ParticleFloat.ValueType.Range,
Evaluation = ParticleFloat.EvaluationType.Life,
ConstantA = lo,
ConstantB = hi,
};
}
if ( max >= 0 ) fx.MaxParticles = max;
if ( rise >= 0f ) fx.ForceScale = rise;
if ( damping >= 0f ) fx.Damping = damping;
}
if ( em.IsValid() && rate >= 0f )
em.Rate = rate;
n++;
}
Log.Info( $"[nz-stink] retuned {n} live cloud(s)"
+ (n == 0 ? " — nz_stink to make one" : "") );
Report();
}
/// <summary>
/// `nz_stink_dump` — what the live clouds actually are.
///
/// ⛔ REPORTS PER-COMPONENT, because "the gas is not visible" has at least five
/// identical-looking causes: no cloud object, no ParticleEffect, an emitter whose
/// Duration expired, zero live particles, or a Scale so small the puffs are subpixel.
/// The napalm flame taught this the hard way — a billboard cannot be screenshotted from
/// a side camera, so the console is the only witness.
/// </summary>
[ConCmd( "nz_stink_dump" )]
public static void Report()
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-stink] no scene" ); return; }
var clouds = scene.Directory.FindByName( "vulture_stink" )
.Where( g => g.IsValid() ).ToArray();
Log.Info( $"[nz-stink] {clouds.Length} cloud(s)"
+ $" · lifetime {Lifetime:0.#}s · footprint {Radius:0}u"
+ $" · player in gas: {IsInGas( Me() )}" );
foreach ( var go in clouds )
{
var fx = go.Components.Get<ParticleEffect>( FindMode.EverythingInSelfAndDescendants );
var em = go.Components.Get<ParticleSphereEmitter>( FindMode.EverythingInSelfAndDescendants );
var rd = go.Components.Get<ParticleSpriteRenderer>( FindMode.EverythingInSelfAndDescendants );
Log.Info( $"[nz-stink] at {go.WorldPosition}"
+ $" · fx {(fx.IsValid() ? (fx.Enabled ? "on" : "OFF") : "MISSING")}"
+ $" · emitter {(em.IsValid() ? (em.Enabled ? "on" : "OFF") : "MISSING")}"
+ $" · renderer {(rd.IsValid() ? (rd.Enabled ? "on" : "OFF") : "MISSING")}" );
if ( fx.IsValid() )
Log.Info( $"[nz-stink] particles {fx.Particles?.Count ?? 0}/{fx.MaxParticles}"
+ $" · scale {fx.Scale.ConstantA:0.#}-{fx.Scale.ConstantB:0.#}"
+ $" · force {fx.ForceScale:0.#} {fx.ForceDirection}"
+ $" · damping {fx.Damping:0.##}"
+ $" · rotation {fx.ApplyRotation}" );
if ( em.IsValid() )
Log.Info( $"[nz-stink] rate {em.Rate.ConstantA:0.#}/s"
+ $" · radius {em.Radius:0.#}"
+ $" · duration {em.Duration.ConstantA:0}"
+ $" · loop {em.Loop}" );
if ( rd.IsValid() )
Log.Info( $"[nz-stink] additive {rd.Additive}"
+ $" · sort {rd.SortMode}"
+ $" · sprite {(rd.Sprite is null ? "NULL" : "set")}" );
}
}
}