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.
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&box answer: its `VectorPoints` is a
/// `List<Vector3>` 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;
}
}