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.
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 <r> <g> <b>` — 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();
}
}