Zombies/WebStrands.cs

Component that draws Widow's Wine web strands around a webbed zombie. Spawns pooled LineRenderer chains of points that orbit an ellipse around the zombie, then sag and fade; exposes tuning properties and console commands to inspect and retune live webs.

File AccessNetworking
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// Widow's Wine webbing — chains of points orbiting a snared zombie, drawn as lines.
///
/// ⛔ NOT A PARTICLE SYSTEM, AND THAT IS WHY THIS IS A COMPONENT AND NOT A PREFAB. GMod's
/// `web_aura2` stores chains of points and calls `render.DrawBeam` between consecutive ones
/// every frame — immediate-mode geometry, the one VFX category that has no particle
/// equivalent. `LineRenderer` is the s&amp;box answer: its `VectorPoints` is a
/// `List&lt;Vector3&gt;` we own and rewrite each frame, which is exactly what `WebPoints` was.
///
/// ── THE ALGORITHM, from effects/web_aura2/init.lua ───────────────────────────
/// Every <see cref="SpawnInterval"/> seconds, push a chain of 2–3 points. Each point takes
/// ONE random value used for BOTH its angular offset and its lifetime offset — which is the
/// detail that makes a chain peel off in order rather than all at once, because later
/// points both trail behind and outlive the earlier ones. Each frame a live point sits on
/// an ellipse at `cos(rot)*up + sin(rot)*right` with `rot = (age - offset) * StringSpeed`.
/// Past its hold time it stops orbiting, sags at <see cref="SagSpeed"/> and fades over
/// 1.8–2.5s.
///
/// ⚠️ ONE LINERENDERER PER CHAIN, POOLED. A LineRenderer draws a single continuous
/// polyline, so separate chains cannot share one without stray lines joining them. At the
/// tuned values about ten chains are alive at once, and they turn over twice a second — so
/// the child objects are created once and reused, not spawned and destroyed. Thirty webbed
/// zombies would otherwise mean sixty GameObject churns a second for nothing.
///
/// ⚠️ ORBIT AXES COME FROM THE ZOMBIE, NOT A BONE. The original reads the hitbox bone's
/// angles, so its ellipse tilts with a ragdoll. This uses world up and the body's own
/// yaw, which is simpler, has no bone-name dependency, and differs only while a zombie is
/// falling over. Revisit if that ever reads wrong.
///
/// Values are the ones approved from the browser mock-up (Docs/vfx/web_strands.html);
/// where they differ from the original the comment says so.
/// </summary>
[Group( "nZombies" )]
public sealed class WebStrands : Component
{
	// ── tuning ───────────────────────────────────────────────────────────────
	/// <summary>Seconds between chains. Original 0.5, kept.</summary>
	[Property] public float SpawnInterval { get; set; } = 0.5f;

	/// <summary>Radians/sec the chains orbit. Original 2, tuned to 2.1.</summary>
	[Property] public float StringSpeed { get; set; } = 2.1f;

	/// <summary>
	/// Strand thickness.
	///
	/// ⛔ NOT WORLD UNITS, WHICH IS WHAT THE FIRST TWO VALUES ASSUMED. `LineRenderer.Width`
	/// is a `Curve`, and curve widths in this engine are small normalised numbers — the
	/// `ParticleTrailRenderer` already in this project uses a `rangey` of "0,0.2". Feeding
	/// it GMod's 3 (and before that the mock-up's 4.5) drew slabs roughly twenty units
	/// across: the screenshot looked like white shards, not webbing.
	///
	/// ⚠️ So the original's 3 is NOT transferable here, and neither is anything the
	/// browser mock-up produced — those were world units by construction. This number has
	/// to be found in game, which is what `nz_web_set` is for.
	/// </summary>
	/// ⚠️ 0.21 = the 0.15 above plus 40%, settled by eye in game. There is no derivation
	/// for it and there cannot be one — see the unit note.
	[Property] public float Width { get; set; } = 0.21f;

	/// <summary>Units/sec a point sinks once it stops orbiting. Original 10, tuned to 35.</summary>
	[Property] public float SagSpeed { get; set; } = 35f;

	/// <summary>Points per chain. Original 2–4, tuned to 2–3 (so 1–2 segments).</summary>
	[Property] public int PointsMin { get; set; } = 2;
	[Property] public int PointsMax { get; set; } = 3;

	/// <summary>
	/// Mean radians between points in a chain. Original picks 0.3–1.0 per point; this is
	/// the midpoint and the range is taken around it, so 0.8 spans roughly 0.37–1.23.
	/// </summary>
	[Property] public float PointSpacing { get; set; } = 0.8f;

	/// <summary>Base seconds a point orbits before sagging. Original 1, kept.</summary>
	[Property] public float ChainHold { get; set; } = 1f;

	/// <summary>Ellipse half-extents. The original derives these from the hitbox bounds
	/// plus 5; these are the representative torso the mock-up was tuned against.</summary>
	[Property] public float OrbitUp { get; set; } = 27f;
	[Property] public float OrbitRight { get; set; } = 19f;

	/// <summary>Where the ellipse sits, as a fraction of <c>ZombieAI.BodyHeight</c> — NOT
	/// units, so a shorter variant scales with it rather than wearing a web at its ankles.</summary>
	[Property] public float TorsoFraction { get; set; } = 0.55f;

	/// <summary>Pool size. Ten chains live at the tuned values; this leaves headroom.</summary>
	[Property] public int MaxChains { get; set; } = 14;

	[Property] public Color Tint { get; set; } = Color.White;

	/// <summary>Additive, matching the mock-up the look was approved from.</summary>
	[Property] public bool Additive { get; set; } = true;

	// ── state ────────────────────────────────────────────────────────────────
	sealed class Point
	{
		public float Offset;
		public float FallTime;
		public float DeadAt = -1f;      // -1 = still orbiting
		public float DeadDur;
		public float Fade = 1f;
		public Vector3 Pos;
		public bool Gone;
	}

	sealed class Chain
	{
		public float Born;
		public float Depth;
		public readonly List<Point> Points = new();
		public GameObject Go;
		public LineRenderer Line;
		public bool Live;
	}

	readonly List<Chain> _pool = new();
	float _next;

	protected override void OnEnabled() => _next = 0f;

	protected override void OnDisabled()
	{
		// ⚠️ Hide rather than destroy: the status can lapse and land again on the same
		// zombie, and rebuilding fourteen child objects for that is pointless.
		foreach ( var c in _pool )
		{
			c.Live = false;
			c.Points.Clear();
			if ( c.Go.IsValid() ) c.Go.Enabled = false;
		}
	}

	protected override void OnDestroy()
	{
		foreach ( var c in _pool )
			if ( c.Go.IsValid() ) c.Go.Destroy();
		_pool.Clear();
	}

	Chain Free()
	{
		foreach ( var c in _pool )
			if ( !c.Live ) return c;

		if ( _pool.Count >= MaxChains ) return null;

		// ⛔ NOT PARENTED TO THE ZOMBIE, AND AT AN IDENTITY TRANSFORM ON PURPOSE. Whether
		// `LineRenderer.VectorPoints` is world space or local to its own transform is not
		// documented and there is no precedent in this project to copy — the first version
		// guessed "local", parented to the zombie, and drew nothing. Pinning the object to
		// the origin with no rotation makes local and world IDENTICAL, so the points below
		// are correct under either reading. Guessing twice is how an evening disappears.
		//
		// ⚠️ They still track the zombie, because every point is recomputed each frame
		// from its live transform — the parent was never what moved them.
		var go = new GameObject( true, "web_strand" );
		go.WorldPosition = Vector3.Zero;
		go.WorldRotation = Rotation.Identity;

		var line = go.Components.Create<LineRenderer>();
		line.UseVectorPoints = true;
		line.VectorPoints = new List<Vector3>();
		line.Additive = Additive;
		line.Lighting = false;
		line.CastShadows = false;

		var chain = new Chain { Go = go, Line = line };
		_pool.Add( chain );
		return chain;
	}

	/// <summary>
	/// `nz_web_dump` — what every live web thinks it is drawing.
	///
	/// ⛔ THIS EXISTS BECAUSE "the zombie does not get webbed" HAS AT LEAST FIVE CAUSES
	/// and they look identical from outside: no status applied, no component, no chains
	/// spawned, points computed but not handed to the renderer, or handed over in the wrong
	/// space. Printing the count at each stage separates them in one line.
	/// </summary>
	/// <summary>
	/// `nz_web_set [width] [speed] [sag]` — retune every live web without a recompile.
	///
	/// ⛔ THIS EXISTS BECAUSE WIDTH CANNOT BE DERIVED. It is a Curve in units nothing
	/// documents, the mock-up's numbers were world units and do not carry over, and each
	/// code edit hotloads and ends the play session — so finding it by editing the default
	/// costs a full restart per guess. Omit an argument to leave it alone.
	/// </summary>
	[ConCmd( "nz_web_set" )]
	public static void SetCmd( float width = -1f, float speed = -1f, float sag = -1f )
	{
		var all = Game.ActiveScene?.GetAllComponents<WebStrands>().ToList();
		if ( all is null || all.Count == 0 )
		{
			Log.Info( "[nz-web] no live webs — nz_status web 600 on a zombie first" );
			return;
		}

		foreach ( var w in all )
		{
			if ( width > 0f ) w.Width = width;
			if ( speed > 0f ) w.StringSpeed = speed;
			if ( sag >= 0f ) w.SagSpeed = sag;
		}

		var f = all[0];
		Log.Info( $"[nz-web] {all.Count} web(s): width={f.Width} speed={f.StringSpeed} sag={f.SagSpeed}" );
	}

	[ConCmd( "nz_web_dump" )]
	public static void DumpCmd()
	{
		var all = Game.ActiveScene?.GetAllComponents<WebStrands>().ToList();
		if ( all is null || all.Count == 0 )
		{
			Log.Info( "[nz-web] no WebStrands anywhere — is any zombie carrying the 'web' status?" );
			return;
		}

		foreach ( var w in all )
		{
			var live = 0; var pts = 0; var handed = 0;
			foreach ( var c in w._pool )
			{
				if ( !c.Live ) continue;
				live++;
				pts += c.Points.Count;
				if ( c.Line.IsValid() && c.Line.VectorPoints is not null )
					handed += c.Line.VectorPoints.Count;
			}
			Log.Info( $"[nz-web] {w.GameObject.Name}: enabled={w.Enabled}"
				+ $"  pool={w._pool.Count}  liveChains={live}  points={pts}"
				+ $"  handedToRenderer={handed}  width={w.Width}  additive={w.Additive}" );

			foreach ( var c in w._pool )
			{
				if ( !c.Live || c.Points.Count == 0 ) continue;
				Log.Info( $"[nz-web]   chain p0={c.Points[0].Pos} fade={c.Points[0].Fade:0.##}"
					+ $"  goPos={(c.Go.IsValid() ? c.Go.WorldPosition.ToString() : "<none>")}" );
				break;
			}
		}
	}

	protected override void OnUpdate()
	{
		var now = Time.Now;

		if ( now >= _next )
		{
			Spawn( now );
			_next = now + MathF.Max( 0.05f, SpawnInterval );
		}

		// The ellipse's centre and axes, recomputed each frame so the web rides the body
		var ai = Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
		var height = ai.IsValid() ? ai.BodyHeight : 72f;
		var centre = WorldPosition + Vector3.Up * height * TorsoFraction;
		var right = WorldRotation.Right * OrbitRight;
		var up = Vector3.Up * OrbitUp;
		var forward = WorldRotation.Forward;

		foreach ( var chain in _pool )
		{
			if ( !chain.Live ) continue;
			Step( chain, now, centre, right, up, forward );
		}
	}

	void Spawn( float now )
	{
		var chain = Free();
		if ( chain is null ) return;

		chain.Points.Clear();
		chain.Born = now;
		chain.Depth = Game.Random.Float( -10f, 10f );
		chain.Live = true;
		if ( chain.Go.IsValid() ) chain.Go.Enabled = true;

		var count = Game.Random.Int( PointsMin, MathF.Max( PointsMin, PointsMax ).FloorToInt() );
		var offset = Game.Random.Float( -MathF.PI, MathF.PI );
		var timeOffset = 0f;

		for ( var i = 0; i < count; i++ )
		{
			// ⚠️ ONE ROLL, USED TWICE — see the class note. Splitting this into two rolls
			// looks tidier and loses the peel-off, because the point that trails furthest
			// behind is no longer the one that lives longest.
			var step = Game.Random.Float( PointSpacing * 0.46f, PointSpacing * 1.54f );
			offset += step;
			timeOffset += step;

			chain.Points.Add( new Point
			{
				Offset = offset,
				FallTime = ChainHold + Game.Random.Float( 0f, 0.2f ) + timeOffset,
			} );
		}
	}

	void Step( Chain chain, float now, Vector3 centre, Vector3 right, Vector3 up, Vector3 forward )
	{
		var age = now - chain.Born;
		var dt = Time.Delta;

		foreach ( var p in chain.Points )
		{
			var pd = age - p.Offset;

			if ( age <= p.FallTime )
			{
				var rot = pd * StringSpeed;
				p.Pos = centre + MathF.Cos( rot ) * up + MathF.Sin( rot ) * right
					+ chain.Depth * forward;
				p.Fade = 1f;
				continue;
			}

			if ( p.DeadAt < 0f )
			{
				p.DeadDur = Game.Random.Float( 1.8f, 2.5f );
				p.DeadAt = pd + p.DeadDur;
			}

			if ( pd >= p.DeadAt ) { p.Gone = true; continue; }

			p.Pos -= Vector3.Up * SagSpeed * dt;
			p.Fade = ((p.DeadAt - pd) / p.DeadDur).Clamp( 0f, 1f );
		}

		chain.Points.RemoveAll( p => p.Gone );

		// ⚠️ A chain with one point has no segment left, so it retires. The original does
		// the same thing by removing the group once `pcount <= 1`.
		if ( chain.Points.Count < 2 )
		{
			chain.Live = false;
			chain.Points.Clear();
			if ( chain.Go.IsValid() ) chain.Go.Enabled = false;
			return;
		}

		if ( !chain.Line.IsValid() ) return;

		// ⚠️ MUTATED IN PLACE **AND ASSIGNED BACK**. In place to avoid a fresh List per
		// chain per frame; assigned back because a property returning a copy would make the
		// in-place edit invisible, and that cannot be ruled out from outside the engine.
		// The assignment costs nothing and removes the second unverifiable assumption.
		var pts = chain.Line.VectorPoints ?? new List<Vector3>();
		pts.Clear();
		foreach ( var p in chain.Points ) pts.Add( p.Pos );
		chain.Line.VectorPoints = pts;

		// The strand fades with its OLDEST surviving point, which is the one nearest the
		// end of its sag — the alternative is a per-vertex gradient and this reads the same.
		var fade = chain.Points[0].Fade;
		chain.Line.Color = new Gradient( new Gradient.ColorFrame( 0f, Tint.WithAlpha( fade ) ) );
		chain.Line.Width = Width;
		chain.Line.Additive = Additive;
	}
}