Zombies/BossShotLook.cs

A visual-only component that attaches to a projectile GameObject and draws a glowing head sprite, halo, fading trail and optional pulseable ring following the projectile. It runs locally (NetworkMode.Never) and updates sprite sizes, colors and line renderers each frame based on movement, time and configured parameters.

File Access
using Sandbox;
using System;
using System.Collections.Generic;

namespace NZombies;

/// <summary>
/// A BOSS PROJECTILE YOU CAN SEE (2026-10-07). The user, of Avogadro's bolt and the Krasny's fire bomb: *"i cannot see the
/// projectile"*; of the Mangler's shot: *"there should be a pulsing yellow ring shot from the cannon so the player can follow
/// it"*. Those shots were a point light and hair-thin sparks: at 1250 u/s there was nothing to look at but a lit wall.
///
/// A glowing head (the tracers' `sprites/nz/powerup_glow.sprite`, or another sprite such as a flame), a softer halo round it, a
/// fading trail of where it has been, and, when asked, a ring round its path that pulses — all additive and unlit, brighter than
/// white so `NZPostProcess`'s bloom catches them.
///
/// ⚠️ EVERY MACHINE'S OWN: a projectile calls <see cref="On"/> in its `OnStart`, which runs on each machine for a
/// network-spawned object. The look is a child object out of the network (`NetworkMode.Never`: a joiner's snapshot would
/// freeze it, `NZNetListener`), and goes when the projectile does. It only follows its parent: the trail is sampled from
/// wherever it is each frame, so a straight bolt and a lobbed bomb draw the same way.
/// ⛔ NOTHING HERE LIGHTS ANYTHING (the tracers' rule, *"the rounds should not iluminate the surroundings as that is laggy"*):
/// a projectile's own light, if it has one, stays its own.
/// </summary>
public sealed class BossShotLook : Component
{
	const string GlowFile = "sprites/nz/powerup_glow.sprite";

	/// <summary>The head's colour, how wide it is across (units) and how far past white it burns.</summary>
	public Color Tint { get; set; } = Color.White;
	public float Size { get; set; } = 18f;
	public float Brightness { get; set; } = 3f;

	/// <summary>The trail: seconds of path kept, and its width at the head (0: no trail). It thins and fades to its tail.</summary>
	public float TrailSeconds { get; set; } = 0.2f;
	public float TrailWidth { get; set; }

	/// <summary>A ring round its path, square to where it is going (0: none), its line's width, and pulses a second (0: steady).
	/// The pulse swells the ring, the head and their brightness together.</summary>
	public float RingRadius { get; set; }
	public float RingWidth { get; set; } = 2.5f;
	public float PulseHz { get; set; }

	/// <summary>The head's sprite, when it is not the round glow (a flame for a fire bomb).</summary>
	public string HeadSprite { get; set; }

	SpriteRenderer _head, _halo;
	LineRenderer _trail, _ring;
	readonly List<Vector3> _pts = new(), _ringPts = new();
	readonly List<float> _times = new();
	Vector3 _last, _dir = Vector3.Forward;
	float _born, _seed;

	static Sprite _glow;
	static bool _glowLooked;

	/// <summary>The glow, loaded once — the failure cached too (`TravelTracer.GlowSprite`'s reason: no log spam per shot).</summary>
	static Sprite Glow
	{
		get
		{
			if ( _glowLooked ) return _glow;
			_glowLooked = true;
			_glow = ResourceLibrary.Get<Sprite>( GlowFile );
			if ( _glow is null ) Log.Warning( $"[nz-fx] '{GlowFile}' not found — boss shots draw without a head" );
			return _glow;
		}
	}

	/// <summary>Give <paramref name="shot"/> a look. ON EVERY MACHINE, from the projectile's own `OnStart`.</summary>
	public static BossShotLook On( GameObject shot, Color tint, float size, float trailWidth = 0f, float trailSeconds = 0.2f,
		float ringRadius = 0f, float pulseHz = 0f, float brightness = 3f, string headSprite = null )
	{
		if ( !shot.IsValid() ) return null;

		var go = new GameObject { Parent = shot, Name = "nz_shot_look" };
		go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		go.NetworkMode = NetworkMode.Never;
		go.LocalPosition = Vector3.Zero;

		var look = go.Components.Create<BossShotLook>();
		look.Tint = tint;
		look.Size = size;
		look.TrailWidth = trailWidth;
		look.TrailSeconds = trailSeconds;
		look.RingRadius = ringRadius;
		look.PulseHz = pulseHz;
		look.Brightness = brightness;
		look.HeadSprite = headSprite;
		return look;
	}

	protected override void OnStart()
	{
		_born = Time.Now;
		_seed = Game.Random.Float( 0f, 100f );
		_last = WorldPosition;

		var head = string.IsNullOrWhiteSpace( HeadSprite ) ? Glow : ResourceLibrary.Get<Sprite>( HeadSprite ) ?? Glow;
		if ( head is not null ) _head = MakeSprite( head, 2f );
		if ( Glow is not null ) _halo = MakeSprite( Glow, 6f );
		if ( TrailWidth > 0f ) _trail = MakeLine();
		if ( RingRadius > 0f ) _ring = MakeLine();
	}

	SpriteRenderer MakeSprite( Sprite sprite, float feather )
	{
		var s = Components.Create<SpriteRenderer>();
		s.Sprite = sprite;
		s.Additive = true;
		s.Lighting = false;
		s.Shadows = false;
		s.DepthFeather = feather;
		return s;
	}

	LineRenderer MakeLine()
	{
		var line = Components.Create<LineRenderer>();
		line.UseVectorPoints = true;
		line.Additive = true;
		line.Opaque = false;
		line.Lighting = false;
		line.CastShadows = false;
		return line;
	}

	protected override void OnUpdate()
	{
		var now = Time.Now;
		var at = WorldPosition;
		var moved = at - _last;
		if ( moved.Length > 0.25f ) _dir = moved.Normal;
		_last = at;

		// 0..1 with the pulse (steady at 1 without one), and a little flicker
		var pulse = PulseHz > 0f ? 0.5f + 0.5f * MathF.Sin( (now - _born) * PulseHz * MathF.PI * 2f ) : 1f;
		var flicker = 0.92f + 0.08f * MathF.Sin( (now + _seed) * 41f );

		if ( _head.IsValid() )
		{
			var s = Size * (PulseHz > 0f ? 0.8f + 0.4f * pulse : 1f) * flicker;
			_head.Size = new Vector2( s, s );
			_head.Color = Burn( Tint, Brightness * (PulseHz > 0f ? 0.7f + 0.5f * pulse : 1f) );
		}

		if ( _halo.IsValid() )
		{
			var s = Size * 2.8f * (PulseHz > 0f ? 0.85f + 0.3f * pulse : 1f);
			_halo.Size = new Vector2( s, s );
			_halo.Color = Burn( Tint, Brightness * 0.3f * flicker );
		}

		DrawTrail( at, now );
		DrawRing( at, pulse );
	}

	/// <summary>Where it has been, the last <see cref="TrailSeconds"/>: full width and bright at the head, nothing at the tail.</summary>
	void DrawTrail( Vector3 at, float now )
	{
		if ( !_trail.IsValid() ) return;

		_pts.Add( at );
		_times.Add( now );
		while ( _times.Count > 2 && now - _times[0] > TrailSeconds )
		{
			_times.RemoveAt( 0 );
			_pts.RemoveAt( 0 );
		}

		if ( _pts.Count < 2 ) { _trail.Enabled = false; return; }
		_trail.Enabled = true;
		_trail.VectorPoints = _pts;

		// ⚠️ FRAME 0 IS THE OLDEST POINT, the list's order
		var hot = Burn( Tint, Brightness * 0.6f );
		_trail.Color = new Gradient(
			new Gradient.ColorFrame( 0f, hot.WithAlpha( 0f ) ),
			new Gradient.ColorFrame( 1f, hot.WithAlpha( 1f ) ) );
		_trail.Width = new Curve( new[]
		{
			new Curve.Frame { Time = 0f, Value = TrailWidth * 0.15f, Mode = Curve.HandleMode.Linear },
			new Curve.Frame { Time = 1f, Value = TrailWidth, Mode = Curve.HandleMode.Linear },
		} );
	}

	/// <summary>The ring, square to its path, swelling and brightening with the pulse.</summary>
	void DrawRing( Vector3 at, float pulse )
	{
		if ( !_ring.IsValid() ) return;

		// ⚠️ TWO PERPENDICULARS, NOT FROM `Up` ALONE (`LightningArc.Rebuild`'s reason): a shot going straight up would collapse them
		var u = Vector3.Cross( _dir, Vector3.Up );
		if ( u.Length < 0.1f ) u = Vector3.Cross( _dir, Vector3.Forward );
		u = u.Normal;
		var v = Vector3.Cross( _dir, u ).Normal;

		var r = RingRadius * (0.7f + 0.6f * pulse);
		const int n = 24;
		_ringPts.Clear();
		for ( int i = 0; i <= n; i++ )
		{
			var a = i / (float)n * MathF.PI * 2f;
			_ringPts.Add( at + (u * MathF.Cos( a ) + v * MathF.Sin( a )) * r );
		}

		_ring.VectorPoints = _ringPts;
		_ring.Color = new Gradient( new Gradient.ColorFrame( 0f, Burn( Tint, Brightness * (0.5f + 0.7f * pulse) ) ) );
		_ring.Width = RingWidth * (0.8f + 0.4f * pulse);
	}

	static Color Burn( Color c, float k ) => new( c.r * k, c.g * k, c.b * k, 1f );
}