Weapons/PrismaReloadGlow.cs

A GameObjectSystem that animates the Prisma weapon accents during reload by tinting shared materials and swapping pre-baked textures for painted accents. It polls the local viewmodel reload flag each tick, lerps tint colors for two accent materials, and steps through texture variants for two painted parts; provides console commands to inspect and control the effect.

File AccessNative Interop
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using SWB.Base;

namespace NZombies;

/// <summary>
/// The Prisma's accents run red while it reloads, and fade back to blue when it is done.
///
/// ⚠️ A `GameObjectSystem`, NOT A COMPONENT, so no prefab or scene edit is needed — the same
/// reason `NZPostProcess` and `NZStartup` are. The engine makes one per scene and ticks it.
///
/// ⛔ IT POLLS `IsReloading` RATHER THAN HOOKING THE RELOAD. `Weapon.Reload.cs` sets that flag
/// false in **five** separate places — finished, cancelled, interrupted, emptied, and on drop.
/// Hooking would mean catching all five and catching every one added later, and the failure mode
/// of missing one is a gun that stays red forever. Reading a bool once a frame cannot miss an
/// exit that does not exist yet.
///
/// ⛔ TWO KINDS OF ACCENT, DRIVEN TWO DIFFERENT WAYS, BECAUSE THEY STORE THEIR COLOUR
/// DIFFERENTLY.
///
///   `hoverball.vmat` and `prisma_sight_glow.vmat` draw a WHITE texture through `g_vColorTint`,
///     so the tint IS the colour. They lerp, continuously.
///   `spectra_bone.vmat` and `spectra_body.vmat` — the lit dots on the fin and along the top
///     piece — keep their blue PAINTED INTO their base textures, which they share with the gun's
///     white body. A tint there reddens the whole part. They step through pre-baked textures
///     instead, one set each.
///
/// ⚠️ THE STEPPING IS NOT A COMPROMISE AT THIS DURATION. Four steps across 0.1s is one every
/// 25ms, which is a frame and a half at 60fps — there is no perceptible difference between that
/// and a continuous fade, and a continuous one would need a texture per frame.
///
/// ⚠️ THE STEP TEXTURES WERE RECOLOURED THROUGH THE GLOW MASK, WHICH IS THE ONLY THING THAT
/// KNOWS WHICH TEXELS ARE ACCENT. Measured on the result: of 277 texels that changed by more than
/// 3, ALL 277 fall in a 36x37 patch, 226 of them where the mask is solid and 51 on its soft edge.
/// Not one texel with a zero mask moved meaningfully, so the white body cannot be affected.
///
/// ⚠️ THE MUTATION IS GLOBAL TO THE MATERIAL, WHICH IS A REAL MULTIPLAYER LIMITATION AND NOT AN
/// OVERSIGHT. `Material.Load` hands back the shared material, so this tints every Prisma on the
/// machine, including another player's world model, for as long as YOURS is reloading. Both
/// materials are Prisma-exclusive so nothing else is ever affected, and two Prismas at once needs
/// two box hits. The per-object alternative is `SceneObject.Attributes`, which cannot be aimed at
/// one material — it would redden the gun's body along with its accents, which is worse and
/// wrong for everybody rather than rare and wrong for one person.
/// </summary>
public sealed class PrismaReloadGlow : GameObjectSystem<PrismaReloadGlow>
{
	public PrismaReloadGlow( Scene scene ) : base( scene )
	{
		Listen( Stage.StartUpdate, 0, Tick, "nz.prisma.reloadglow" );
	}

	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — INSTRUCTIONS.md §1.

	static bool? _on;
	/// <summary>Does the reload run red. On.</summary>
	public static bool Enabled { get => _on ?? true; set => _on = value; }

	static float? _seconds;
	/// <summary>
	/// How long each colour change takes. 0.1s.
	/// </summary>
	///
	/// ⚠️ IT IS THE SAME FIGURE IN BOTH DIRECTIONS and deliberately so: an asymmetric fade reads
	/// as the gun reacting slowly to one of the two events rather than as one effect.
	public static float Seconds { get => _seconds ?? 0.1f; set => _seconds = value; }

	static Color? _red;
	/// <summary>
	/// The reloading colour. A hot red.
	/// </summary>
	///
	/// ⚠️ RED STAYS ON THE BRIGHT SIDE because these are EMISSIVE surfaces. Emission is albedo
	/// times mask with no intensity dial on this shader — see `hoverball.vmat` — so the only way a
	/// colour reads as energy rather than paint is to keep its dominant channel at the top.
	public static Color Red
	{
		get => _red ?? new Color( 1.00f, 0.14f, 0.09f );
		set => _red = value;
	}

	/// <summary>
	/// Each accent material and the blue it is authored with.
	/// </summary>
	///
	/// ⛔ THESE MUST AGREE WITH THE `.vmat` FILES AND NOTHING ENFORCES IT. The rest colour cannot
	/// be read back off a loaded `Material`, so it is mirrored here — which means editing a
	/// material's `g_vColorTint` without editing this table leaves the gun the WRONG blue after
	/// its first reload, and correct until then. `nz_prisma_reload_glow` prints both figures so
	/// the mismatch is at least visible.
	//
	// ⚠️ THE ELEMENT IS `Blue`, NOT `Rest`, AND THAT IS NOT A STYLE CHOICE: `Rest` is a
	// reserved element name on `ValueTuple` and the compiler rejects it outright (CS8126).
	static readonly (string Path, Color Blue)[] Accents =
	{
		("materials/models/weapons/prisma/hoverball.vmat", new Color( 0.310f, 0.680f, 1.000f )),
		("materials/models/weapons/prisma/prisma_sight_glow.vmat",
			new Color( 0.690f, 0.763f, 0.883f )),
	};

	/// <summary>
	/// The painted accents: a material, and its pre-baked textures from blue through to red.
	/// </summary>
	///
	/// ⛔ THERE ARE TWO OF THESE AND ONLY ONE WAS FOUND AT FIRST. `spectra_bone` carries the three
	/// dots on the fin; `spectra_body` carries its own set along the top piece — a SEPARATE glow
	/// mask, 358 lit texels against the bone's 226, averaging rgb(185, 212, 246) against
	/// rgb(176, 194, 223). Two parts, two textures, two masks. Recolouring one left the other
	/// blue, which is exactly what it looked like.
	///
	/// ⚠️ SO THE LESSON IS TO ENUMERATE THE MASKS, NOT THE PARTS YOU HAPPEN TO HAVE NOTICED. The
	/// glow masks are the definitive list of what on this gun is an accent; anything with one
	/// belongs in this table.
	///
	/// ⛔ EVERY STEP IS NAMED BY A `spectra_*_r*.vmat` THAT NOTHING RENDERS, AND THAT IS LOAD
	/// BEARING. s&box compiles a texture only when some MATERIAL references it; a PNG nothing
	/// points at never gets a `.vtex_c` and `LoadFromFileSystem` finds nothing. Delete those eight
	/// materials and this stops working silently.
	static readonly (string Mat, string[] Steps)[] Painted =
	{
		("materials/models/weapons/prisma/spectra_bone.vmat", new[]
		{
			"materials/models/weapons/spectra/spectra_bone_baset.png",
			"materials/models/weapons/spectra/spectra_bone_baset_r25.png",
			"materials/models/weapons/spectra/spectra_bone_baset_r50.png",
			"materials/models/weapons/spectra/spectra_bone_baset_r75.png",
			"materials/models/weapons/spectra/spectra_bone_baset_r100.png",
		}),
		("materials/models/weapons/prisma/spectra_body.vmat", new[]
		{
			"materials/models/weapons/spectra/spectra_body_baset.png",
			"materials/models/weapons/spectra/spectra_body_baset_r25.png",
			"materials/models/weapons/spectra/spectra_body_baset_r50.png",
			"materials/models/weapons/spectra/spectra_body_baset_r75.png",
			"materials/models/weapons/spectra/spectra_body_baset_r100.png",
		}),
	};

	static Texture[][] _paintTex;
	static int[] _paintStep;

	// ══ the fade ═════════════════════════════════════════════════════════════

	// ⚠️ STATIC, BECAUSE WHAT IT DRIVES IS STATIC. The materials are global to the client, so
	// there is exactly one fade in flight no matter how many systems exist, and the console
	// commands — which are static — have to be able to report it. `GameObjectSystem<T>` exposes
	// no static `Instance` to reach an instance field through.
	//
	// ⚠️ NO INITIALISER NEEDED AND THAT IS THE POINT (INSTRUCTIONS.md §1): a static's VALUE
	// survives a hotload while its initialiser does not re-run. A stale fade position is
	// self-correcting — the next tick walks it back to where it belongs within `Seconds`.
	static float _t;
	static bool _painted;

	/// <summary>
	/// `nz_prisma_reload_hold` pins the fade, overriding the reload.
	/// </summary>
	///
	/// ⚠️ IT EXISTS BECAUSE THE WHOLE EFFECT LASTS 0.1s AND THEN THE RELOAD ENDS. Judging a
	/// colour, or proving one of the three accents is not moving, means catching a tenth of a
	/// second while also holding a gun and being out of ammo. Pinned, you can just look at it.
	static float? _hold;

	void Tick()
	{
		if ( !Enabled )
		{
			// ⚠️ TURNING IT OFF MID-RELOAD HAS TO HAND THE COLOUR BACK. A flag that only stopped
			// the fade advancing would leave the accents wherever they happened to be — the same
			// trap `NZPostProcess.Enabled` documents for the camera.
			if ( _painted ) { _t = 0f; Paint(); _painted = false; }
			return;
		}

		var want = _hold ?? (Reloading() ? 1f : 0f);
		var step = Time.Delta / MathF.Max( 0.01f, Seconds );

		_t = want > _t ? MathF.Min( want, _t + step ) : MathF.Max( want, _t - step );

		// ⚠️ AT REST IT STOPS WRITING ENTIRELY, rather than writing the same blue every frame
		// forever. `_painted` is what makes the LAST write at zero still happen, so the accents
		// land exactly back on their authored colour instead of a hair off it.
		if ( _t <= 0f && !_painted ) return;

		Paint();
		_painted = _t > 0f;
	}

	static void Paint()
	{
		foreach ( var (path, blue) in Accents )
		{
			if ( Material.Load( path ) is not Material m ) continue;

			var c = Color.Lerp( blue, Red, _t );

			// ⚠️ `Vector4`, NOT `Color`, AND ALPHA 1. `MaterialTint.cs` already writes this
			// parameter that way; the accent materials are authored with alpha 1, and the sight
			// glow needed it there before its colour did anything at all.
			m.Set( "g_vColorTint", new Vector4( c.r, c.g, c.b, 1f ) );
		}

		PaintPainted();
	}

	/// <summary>Step each painted accent's texture to whichever stage the fade is nearest.</summary>
	///
	/// ⛔ ONLY ON A CHANGE OF STEP. Handing a material the texture it already has, every frame,
	/// is not free the way writing a colour is — and there are only five distinct answers per
	/// material, so there is no reason to ask more than five times.
	///
	/// ⚠️ A FAILED LOAD DOES NOT LATCH. The step moves only after the texture is in hand, so a
	/// missing `.vtex_c` retries on the next tick instead of recording a step it never applied
	/// and going quiet forever.
	static void PaintPainted()
	{
		_paintTex ??= new Texture[Painted.Length][];
		_paintStep ??= Enumerable.Repeat( -1, Painted.Length ).ToArray();

		for ( var p = 0; p < Painted.Length; p++ )
			PaintOne( p );
	}

	static void PaintOne( int p )
	{
		var (matPath, steps) = Painted[p];

		var last = steps.Length - 1;
		var want = Math.Clamp( (int)MathF.Round( _t * last ), 0, last );

		if ( want == _paintStep[p] ) return;

		_paintTex[p] ??= new Texture[steps.Length];
		_paintTex[p][want] ??= LoadStep( steps[want] );

		if ( _paintTex[p][want] is null ) return;
		if ( Material.Load( matPath ) is not Material mat ) return;

		// ⛔ `g_tColor`, NOT `TextureColor`, AND THAT DISTINCTION IS WHY THE DOTS DID NOT MOVE.
		// `TextureColor` is a COMPILE-TIME key in the `.vmat`; the material compiler maps it to the
		// shader's runtime parameter `g_tColor`. `Material.Set` writes runtime parameters, so
		// setting `TextureColor` writes an attribute no shader ever reads — it succeeds, changes
		// nothing, and reports success.
		//
		// ⚠️ THE TINT WORKED ALL ALONG FOR EXACTLY THIS REASON: `g_vColorTint` already IS the
		// runtime name, which is what the `g_` prefix means. A vmat key without that prefix is a
		// compiler input and cannot be set at runtime under the same spelling.
		//
		// ⚠️ BOTH ARE WRITTEN because the second costs nothing and an unread attribute is inert.
		// If a future shader takes the friendly spelling, this keeps working rather than becoming
		// a second silent no-op to find.
		mat.Set( "g_tColor", _paintTex[p][want] );
		mat.Set( "TextureColor", _paintTex[p][want] );

		_paintStep[p] = want;
	}

	static readonly HashSet<string> _moaned = new();

	/// <summary>
	/// Load one accent step, trying both spellings, and COMPLAIN if neither works.
	/// </summary>
	///
	/// ⛔ A SILENT NULL HERE COST A ROUND TRIP ALREADY. `PaintBone` deliberately does not latch a
	/// step it failed to apply, which is right — but with nothing logged, the only evidence was
	/// `nz_prisma_reload_glow` reporting step 0 while the ball was plainly red, and that had to be
	/// reasoned out rather than read. It now says which path it could not find.
	///
	/// ⚠️ TWO SPELLINGS BECAUSE THE MOUNTED FILESYSTEM SERVES COMPILED ASSETS. A `.png` in
	/// Assets is compiled to a hashed `*.generated.vtex_c`, and which name resolves depends on
	/// whether the editor has rescanned since the file appeared — these four were generated in a
	/// running session. `.vtex` is the compiled spelling; the `.png` is the source one.
	///
	/// ⚠️ IT MOANS ONCE PER PATH, not once per frame. This runs off a step change, but a step
	/// that never loads never latches, so it is retried on every tick that wants it — which is
	/// sixty complaints a second without the set.
	static Texture LoadStep( string png )
	{
		if ( Texture.LoadFromFileSystem( png, FileSystem.Mounted ) is Texture a ) return a;

		var vtex = png.Replace( ".png", ".vtex", StringComparison.OrdinalIgnoreCase );

		if ( Texture.LoadFromFileSystem( vtex, FileSystem.Mounted ) is Texture b )
		{
			if ( _moaned.Add( png ) )
				Log.Info( $"[nz-prisma] {png} resolved as .vtex, not .png" );

			return b;
		}

		if ( _moaned.Add( png ) )
			Log.Warning( $"[nz-prisma] could not load '{png}' or '{vtex}' — that accent step will"
				+ " not apply. The texture compiles only because a spectra_bone_r*.vmat names it;"
				+ " if those were just created, the editor has to rescan before the mounted"
				+ " filesystem can serve them." );

		return null;
	}

	/// <summary>Is the local player reloading a Prisma right now.</summary>
	///
	/// ⚠️ FOUND BY VIEWMODEL, NOT BY PLAYER, the same way `SckPartsRig` does it. A viewmodel only
	/// ever exists on the machine that owns it, so there is no "which player" question here and
	/// no `FirstOrDefault` over players.
	bool Reloading()
	{
		var w = Scene?.GetAllComponents<ViewModelHandler>()
			.FirstOrDefault( h => h.IsValid() && h.Weapon.IsValid() )?.Weapon;

		return w.IsValid() && PrismaFx.IsFor( w ) && w.IsReloading;
	}

	// ══ diagnostics ══════════════════════════════════════════════════════════

	/// <summary>
	/// `nz_prisma_reload_glow [on] [seconds]` — the reload colour. Bare, it reports.
	/// </summary>
	[ConCmd( "nz_prisma_reload_glow" )]
	public static void Cmd( int on = -1, float seconds = -1f )
	{
		if ( on >= 0 ) Enabled = on > 0;
		if ( seconds > 0f ) Seconds = seconds;

		Log.Info( $"[nz-prisma] reload glow {(Enabled ? "on" : "off")}"
			+ $" · {Seconds:0.###}s each way"
			+ $" · red ({Red.r:0.00}, {Red.g:0.00}, {Red.b:0.00})"
			+ $" · now {_t:0.00} of the way there"
			+ (_hold is null ? "" : $"  ⚠ HELD at {_hold:0.00}") );

		foreach ( var (path, blue) in Accents )
			Log.Info( $"[nz-prisma]   {path.Split( '/' ).Last()}"
				+ $" rest ({blue.r:0.00}, {blue.g:0.00}, {blue.b:0.00})" );

		// ⚠️ THE REPORT TRIES EVERY STEP, because "which ones can actually load" is the single
		// question worth asking when the dots are not changing, and asking it by reloading five
		// times and watching is not asking it.
		_paintTex ??= new Texture[Painted.Length][];
		_paintStep ??= Enumerable.Repeat( -1, Painted.Length ).ToArray();

		for ( var p = 0; p < Painted.Length; p++ )
		{
			var (matPath, steps) = Painted[p];
			var at = _paintStep[p];

			Log.Info( $"[nz-prisma]   {matPath.Split( '/' ).Last()} steps its texture"
				+ $" — {steps.Length} stages, on"
				+ $" {(at < 0 ? "none yet" : steps[at].Split( '/' ).Last())}" );

			_paintTex[p] ??= new Texture[steps.Length];

			for ( var i = 0; i < steps.Length; i++ )
			{
				_paintTex[p][i] ??= LoadStep( steps[i] );

				Log.Info( $"[nz-prisma]     step {i} {steps[i].Split( '/' ).Last()}"
					+ $"  {(_paintTex[p][i] is null ? "NOT LOADED" : "ok")}" );
			}
		}
	}

	/// <summary>
	/// `nz_prisma_reload_hold [0..1]` — pin the fade to look at it; no argument releases it.
	/// </summary>
	[ConCmd( "nz_prisma_reload_hold" )]
	public static void HoldCmd( float t = -1f )
	{
		_hold = t < 0f ? null : MathX.Clamp( t, 0f, 1f );

		// ⚠️ RELEASING HAS TO LEAVE IT SOMEWHERE VALID. `_painted` is already true whenever the
		// fade is off zero, so the next tick walks it home on its own — nothing to reset here.
		Log.Info( _hold is null
			? "[nz-prisma] reload glow released — back to following the reload"
			: $"[nz-prisma] reload glow held at {_hold:0.00}" );

		Cmd();
	}

	/// <summary>
	/// `nz_prisma_reload_red &lt;r&gt; &lt;g&gt; &lt;b&gt;` — 0-1 or 0-255, it works out which.
	/// </summary>
	[ConCmd( "nz_prisma_reload_red" )]
	public static void RedCmd( float r = -1f, float g = -1f, float b = -1f )
	{
		if ( r < 0f || g < 0f || b < 0f ) { Cmd(); return; }

		if ( r > 1f || g > 1f || b > 1f ) { r /= 255f; g /= 255f; b /= 255f; }

		Red = new Color( r.Clamp( 0f, 1f ), g.Clamp( 0f, 1f ), b.Clamp( 0f, 1f ) );
		Cmd();
	}
}