Player/VultureStink.cs

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.

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