Runtime tuning and corpse-darkening for zombie eye materials. Defines groups of eye materials (walkers, hounds/Brutus, napalm), lets console commands change tint and glow, applies tint to loaded materials, creates dark copies for corpses and overrides renderer material slots to make dead eyes dim.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace NZombies;
/// <summary>
/// Live tuning for the glowing eyes — walkers blue, hounds and Brutus red.
///
/// ⚠️ THE MATERIALS ARE THE SOURCE OF TRUTH; THIS IS FOR FINDING THE NUMBER. Nothing here persists —
/// a recompile of a .vmat puts the authored values back. That is the same deal `NZSound`'s per-cue
/// volume multipliers make, and for the same reason: a colour is chosen by looking at it, and
/// editing a file and waiting for an asset compile between each look is not looking at it. Settle on
/// a value, then paste the line these commands print into the .vmat.
///
/// ⛔ IT SETS MATERIALS, SO IT IS GLOBAL AND IT IS MEANT TO BE. Every walker shares one eye
/// material; there is no per-zombie variant and there should not be, because a hundred material
/// instances is a hundred draw calls that used to be one.
///
/// ⛔ AND A CORPSE'S EYES GO OUT (2026-09-28, `Darken`) — the one thing here that is per body: a slot override on the dead one's own
/// renderer, pointing at ONE shared dark copy per eye material, so it is still a handful of materials, never one per zombie.
///
/// # MAPPORT: zombie eye glow
/// </summary>
public static class ZombieEyes
{
/// <summary>
/// One set of eyes that share a colour.
///
/// ⚠️ BRUTUS SITS WITH THE HOUNDS, NOT ON HIS OWN, because they were asked for as a pair —
/// "same as the hellhounds". One command moves both, which is the only way they stay matched.
/// </summary>
sealed class Group
{
public string Name;
public string[] Materials;
public string AuthoredHex;
public float AuthoredGlow;
// ⛔ NULL MEANS "WHATEVER THE MATERIAL WAS COMPILED WITH", and that is why nothing here
// caches the authored values into a static. A static's initialiser does not re-run on
// hotload, so a cached default would go stale the moment the .vmat changed underneath it —
// the trap that had `CharacterVoice.GlobalGap` reporting 12 while its source said 15.
// Storing only the OVERRIDE makes the stale case impossible: there is nothing to go stale.
public Color? Hue;
public float? Glow;
public Vector3 Tint
{
get
{
var c = Hue ?? Color.Parse( AuthoredHex ) ?? Color.White;
var g = Glow ?? AuthoredGlow;
return new Vector3( c.r * g, c.g * g, c.b * g );
}
}
}
static readonly Group Walkers = new()
{
Name = "walkers",
AuthoredHex = "#1257FF",
AuthoredGlow = 3.5f,
Materials = new[]
{
"materials/models/moo/codz/t7_zombies/prototype/mtl_c_zom_dlchd_zombie_eyes.vmat",
// ⚠️ THE ORIGINS KNIGHTS BELONG IN **THIS** GROUP, NOT A THIRD ONE. They are a walker
// SKIN — `WalkerSkins.OriginsTemplar` — so they are the same creature wearing different
// armour, and a separate eye colour would read as a different enemy. `nz_zombie_eyes`
// retunes every walker body at once, which is the behaviour a single tuning command
// implies and the reason these are listed rather than left to drift.
//
// ⚠️ TWO ENTRIES BECAUSE THE KNIGHT AND THE TEMPLAR HAVE SEPARATE HEADS — `head_1_u`
// and `head_z_u`. One list entry would have retuned half the horde.
"materials/models/moo/codz/t6_zombies/tomb/mtl_c_zom_tomb_crusader_head_1_u.vmat",
"materials/models/moo/codz/t6_zombies/tomb/mtl_c_zom_tomb_crusader_head_z_u.vmat",
// ⚠️ THE PER-MAP SKINS' EYES (2026-10-05), every one written by `Tools/walker_skin_port.py` to the walker's recipe
// (AUTHORED, the same icy blue) and listed for the knights' reason above: a skin is the same creature. One per
// head material that carried the glow, which is why some sets have several.
"materials/models/moo/codz/_common/mtl_c_zom_dlchd_zombie_eyes.vmat", // ascension, moon
"materials/models/moo/codz/_common/mtl_c_zom_dlchd_zombie_eyes_bloat.vmat", // moon
"materials/models/moo/codz/iw7_zombies/park/zmb_male_eyes_02.vmat", // park
"materials/models/moo/codz/iw7_zombies/park/zmb_shared_eyes_a.vmat", // park
"materials/models/moo/codz/s1_zombies/zombies/mtl_zom_eye_a_l.vmat", // exo_brg
"materials/models/moo/codz/s1_zombies/zombies/mtl_zom_eye_a_r.vmat", // exo_brg
"materials/models/moo/codz/t10_zombies/zm/c_t10_zmb_zombie_base_body_01_eyes.vmat",// quartz_lab_hazmat
"materials/models/moo/codz/t10_zombies/zm/xmaterial_7bc047c19822a5.vmat", // quartz_lab_hazmat
"materials/models/moo/codz/t5_zombies/_common/mtl_c_ger_zombie_eyes.vmat", // five_classic
"materials/models/moo/codz/t5_zombies/rus_cosmo/mtl_gen_eye_iris_blue.vmat", // ascension_classic
"materials/models/moo/codz/t6_zombies/buried/mtl_c_zom_buried_male_head1_u.vmat", // buried
"materials/models/moo/codz/t6_zombies/buried/mtl_c_zom_buried_male_head3_u.vmat", // buried
"materials/models/moo/codz/t6_zombies/buried/mtl_c_zom_buried_saloongirl_head1_u.vmat", // buried
"materials/models/moo/codz/t6_zombies/highrise/mtl_c_zom_chinese_zombie_head1_u.vmat", // dierise
"materials/models/moo/codz/t6_zombies/highrise/mtl_c_zom_chinese_zombie_head3_u.vmat", // dierise
"materials/models/moo/codz/t6_zombies/highrise/mtl_c_zom_chinese_zombie_head4_u.vmat", // dierise
"materials/models/moo/codz/t6_zombies/nuketown/mtl_c_zom_dlc0_zombie_hazmat_head_1_u.vmat", // nuketown
"materials/models/moo/codz/t6_zombies/nuketown/mtl_c_zom_dlc0_zombie_hazmat_head_2_u.vmat", // nuketown
"materials/models/moo/codz/t6_zombies/nuketown/mtl_c_zom_dlc0_zombie_hazmat_head_4_u.vmat", // nuketown
"materials/models/moo/codz/t6_zombies/nuketown/mtl_c_zom_dlc0_zombie_hazmat_head_mask_u.vmat", // nuketown
"materials/models/moo/codz/t6_zombies/transit/mtl_c_zom_zombie_head_a_unlit.vmat", // greenrun
"materials/models/moo/codz/t6_zombies/transit/mtl_c_zom_zombie_head_d_unlit.vmat", // greenrun
"materials/models/moo/codz/t6_zombies/transit/mtl_c_zom_zombie_head_f_unlit.vmat", // greenrun
"materials/models/moo/codz/t6_zombies/transit/mtl_c_zom_zombie_head_k_unlit.vmat", // greenrun
"materials/models/moo/codz/t6_zombies/transit/mtl_c_zom_zombie_head_l_unlit.vmat", // greenrun
"materials/models/moo/codz/t7_zombies/stalingrad/mtl_char_rus_zombie_eyes.vmat", // gorodkrovi
"materials/models/moo/codz/t8_zombies/common/mtl_c_t8_zmb_eyes.vmat", // ix, titanic, mansion
},
};
static readonly Group Hounds = new()
{
Name = "hounds + Brutus",
AuthoredHex = "#FF0000",
AuthoredGlow = 2.2f,
Materials = new[]
{
"materials/models/moo/codz/t5_hellhound/mtl_nazi_hellhound_eyes.vmat",
// ⚠️ BRUTUS'S EYES, DESPITE THE NAME. "hellcatraz_head_unlit" reads like a head variant,
// which is why it was passed over once while looking for something called `_eyes`.
"materials/models/moo/codz/t6_zombies/hellcatraz/mtl_c_zom_zombie_hellcatraz_head_unlit.vmat",
},
};
/// <summary>
/// The Napalm Zombie, alone, because its eyes are ORANGE and the source says so.
///
/// ⛔ NOT IN `Walkers` DESPITE BEING KEYED THE SAME WAY. Every other eye material this project
/// has hand-authored carried `$emissiveBlendTint [1 1 1]` — white, i.e. no opinion — which is
/// why they were all free to be the project's icy blue. This one is authored `[1 0.15 0]` by
/// Treyarch. Folding it into `Walkers` would mean `nz_zombie_eyes` painted a burning corpse
/// the same colour as everything else the first time anyone used the command.
///
/// ⚠️ DRIVEN AT 2.2, NOT 3.5 — see the material. Clipped red tonemaps toward yellow, so a red
/// needs LESS drive than a blue to read as the colour it is.
///
/// ⛔ IT PAINTS THE SHRIEKER TOO, AND IT CANNOT NOT. Both come out of
/// `moo_codz_t5_viet_special_zombie.mdl` and both reference the SAME eye material file, so
/// there is one asset and it can hold one colour — `nz_napalm_eyes` moves both or neither.
/// That is fine rather than lucky: the Shrieker's entity sets `RedEyes = true`, so the two
/// want the same end of the spectrum anyway. Giving them separate colours would mean a second
/// copy of the .vmat and a second `material_search_path`, which is a real cost for a
/// distinction nobody asked for.
/// </summary>
static readonly Group Napalm = new()
{
Name = "napalm",
AuthoredHex = "#FF2600",
AuthoredGlow = 2.2f,
Materials = new[]
{
"materials/models/moo/codz/t5_zombies/temple/mtl_c_ger_zombie_eyes.vmat",
},
};
// ── basalt's altar defense ──
//
// ⛔ WHILE IT RUNS, EVERY WALKER'S EYES BURN PURPLE INSTEAD OF BLUE — *"during this step all zombies get purple eyes
// instead of blue eyes"* (`HexPlatforms.Defense.cs`). The blue ones only: the hounds', Brutus's and the Napalm's and
// Shrieker's are red and orange, not blue. On every machine, from the mirrored defense, and over any `nz_zombie_eyes`
// retune, which comes back when it ends.
static bool _defense;
static Color? _defenseHue;
static float? _defenseGlow;
/// <summary>The defense's purple: a saturated one, as the note on `nz_zombie_eyes` says a colour must be to read as itself.</summary>
static Vector3 DefenseTint
{
get
{
var c = _defenseHue ?? Color.Parse( "#8000FF" ) ?? Color.White;
var g = _defenseGlow ?? 3f;
return new Vector3( c.r * g, c.g * g, c.b * g );
}
}
/// <summary>Are the walkers' eyes purple now, for the altar's defense? For `nz_hex_selftest`.</summary>
public static bool InDefense => _defense;
/// <summary>The walkers' eyes purple, or back to their own. LOCAL — `HexPlatforms.ApplyDefense`.</summary>
public static void SetDefense( bool on )
{
var was = _defense;
_defense = on;
if ( on || was ) Apply( Walkers );
}
/// <summary>`nz_hex_defense_eyes [hex] [glow]` — the walkers' purple while basalt's altar is defended. Until a restart.</summary>
[ConCmd( "nz_hex_defense_eyes" )]
public static void DefenseEyesCmd( string hex = "", float glow = -1f )
{
if ( !string.IsNullOrWhiteSpace( hex ) )
{
if ( Color.Parse( hex ) is not Color c ) { Log.Warning( $"[nz-eyes] '{hex}' is not a colour — try #8000FF" ); return; }
_defenseHue = c;
}
if ( glow >= 0f ) _defenseGlow = glow;
if ( _defense ) Apply( Walkers );
var t = DefenseTint;
Log.Info( $"[nz-eyes] the walkers' eyes while basalt's altar is defended: tint [{t.x:0.###} {t.y:0.###} {t.z:0.###}]"
+ ( _defense ? " — on now" : " — not running now" ) );
}
// ── the dead ──
//
// ⛔ A CORPSE'S EYES GO OUT (2026-09-28): *"when a zombie dies their eyes should stop glowing"*. The living share each eye
// material, so the dead body's own renderer has its eye SLOTS pointed at a dark copy (`MaterialAccessor.SetOverride`: that
// renderer only) — one copy per eye material, made on the first death that needs it and shared by every corpse after. On every
// machine: the host's `ZombieAI.Die`, each client's `DieAsPuppet`. Every group's eyes: the walkers', the knights', the hounds',
// Brutus's, the Napalm's and the Shrieker's — each is its own eyes-only submesh (the .vmat notes), so nothing else goes dark.
/// <summary>`nz_dead_eyes 0` leaves a corpse's eyes glowing, as before (this session).</summary>
public static bool DarkenDead
{
get => _darkenDead ?? true;
set => _darkenDead = value;
}
static bool? _darkenDead;
/// <summary>How bright a dead eye is drawn: the eye surface is white times this, unlit — 0 is black; the living are driven at 2.2-3.5.</summary>
public static float DeadGlow
{
get => _deadGlow ?? 0.02f;
set => _deadGlow = value;
}
static float? _deadGlow;
static Vector3 DeadTint => new( DeadGlow, DeadGlow, DeadGlow );
/// <summary>The dark copy of each eye material, by the path its group lists.</summary>
static readonly Dictionary<string, Material> _dead = new();
/// <summary>A material path as the groups list it: forward slashes, lower case, no `_c`.</summary>
static string PathKey( string p )
{
var s = (p ?? "").Replace( '\\', '/' ).Trim().TrimStart( '/' ).ToLowerInvariant();
return s.EndsWith( ".vmat_c" ) ? s.Substring( 0, s.Length - 2 ) : s;
}
/// <summary>The listed path of the eye material this is, or null when it is not one.</summary>
static string EyePathOf( Material m )
{
if ( m is null ) return null;
var have = PathKey( m.ResourcePath );
var name = PathKey( m.Name );
foreach ( var g in new[] { Walkers, Hounds, Napalm } )
foreach ( var path in g.Materials )
{
var key = PathKey( path );
if ( have == key || name == key ) return path;
}
return null;
}
/// <summary>
/// The eyes on this body go dark: every renderer on it and under it has each slot that holds a glowing eye material pointed at that
/// material's dark copy. How many slots it darkened — 0 for a body with no glowing eyes, or with `nz_dead_eyes 0` unless forced.
/// </summary>
public static int Darken( GameObject body, bool force = false )
{
if ( !body.IsValid() || !(DarkenDead || force) ) return 0;
var n = 0;
foreach ( var r in body.Components.GetAll<ModelRenderer>( FindMode.EverythingInSelfAndDescendants ) )
{
var slots = r.Materials;
if ( slots is null ) continue;
for ( var i = 0; i < slots.Count; i++ )
{
var original = slots.GetOriginal( i );
var path = EyePathOf( original );
if ( path is null || DarkCopy( path, original ) is not Material dark ) continue;
slots.SetOverride( i, dark );
n++;
}
}
return n;
}
/// <summary>The dark copy of one eye material, made once: the same material, its tint turned down to <see cref="DeadGlow"/>.</summary>
static Material DarkCopy( string path, Material from )
{
if ( _dead.TryGetValue( path, out var have ) && have is not null ) return have;
var dark = from.CreateCopy( $"nz_dead_eyes_{_dead.Count}" );
if ( dark is null ) return null;
dark.Set( "g_vColorTint", DeadTint );
_dead[path] = dark;
return dark;
}
/// <summary>
/// `nz_dead_eyes` — whether a corpse's eyes go dark, how dark, and how many eye materials have a dark copy. `nz_dead_eyes 0` leaves
/// them glowing (this session), `1` puts it back; `nz_dead_eyes glow 0.05` sets how bright a dead eye is drawn (0 is black);
/// `nz_dead_eyes test` darkens the living zombie in front of you, to see which of its slots are eyes. This machine only.
/// </summary>
[ConCmd( "nz_dead_eyes" )]
public static void DeadEyesCmd( string what = "", string value = "" )
{
switch ( what.ToLowerInvariant() )
{
case "0" or "off": DarkenDead = false; break;
case "1" or "on": DarkenDead = true; break;
case "glow" when float.TryParse( value, NumberStyles.Float, CultureInfo.InvariantCulture, out var g ):
DeadGlow = Math.Clamp( g, 0f, 1f );
foreach ( var m in _dead.Values ) m?.Set( "g_vColorTint", DeadTint );
break;
case "test":
{
var z = ZombieAI.GoreTarget();
if ( !z.IsValid() ) { Log.Warning( "[nz-eyes] no living zombie — nz_spawn 1 first" ); return; }
var n = Darken( z.GameObject, force: true );
Log.Info( $"[nz-eyes] '{z.GameObject.Name}': {n} eye slot(s) darkened"
+ ( n == 0 ? " — none of its materials is one of the eye materials" : " — kill it to see a corpse's, or wait for one" ) );
break;
}
}
// ⚠️ A CHECK THAT NEEDS NO CORPSE: each listed eye material, loaded, must be recognised as itself — the test a dead body's
// slots are put to. Fewer than all means `Darken` would miss those eyes.
var listed = new[] { Walkers, Hounds, Napalm }.SelectMany( x => x.Materials ).ToList();
var known = listed.Count( p => EyePathOf( Material.Load( p ) ) == p );
Log.Info( $"[nz-eyes] a corpse's eyes {( DarkenDead ? "go dark" : "KEEP GLOWING (nz_dead_eyes 1)" )} · drawn at {DeadGlow:0.###}"
+ $" · {_dead.Count} eye material(s) with a dark copy · {known} of {listed.Count} eye materials recognised" );
}
static void Apply( Group g )
{
var tint = g == Walkers && _defense ? DefenseTint : g.Tint;
foreach ( var path in g.Materials )
{
var mat = Material.Load( path );
if ( mat is null )
{
Log.Warning( $"[nz-eyes] '{path}' did not load" );
continue;
}
mat.Set( "g_vColorTint", tint );
}
}
static void Report( Group g )
{
var t = g.Tint;
var hue = g.Hue?.Hex ?? g.AuthoredHex;
var glow = g.Glow ?? g.AuthoredGlow;
var over = g.Hue.HasValue || g.Glow.HasValue;
Log.Info( $"[nz-eyes] {g.Name}: {hue} x{glow:0.##}"
+ $" -> tint [{t.x:0.###} {t.y:0.###} {t.z:0.###}]"
+ ( over ? " (overridden)" : " (as authored)" ) );
if ( !over ) return;
// ⚠️ SAYS IT IS TEMPORARY EVERY TIME IT IS CHANGED. This is the setting most likely to be
// tuned, walked away from, and assumed saved — and the next asset compile reverts it.
Log.Info( "[nz-eyes] runtime only — paste into the .vmat to keep it:" );
Log.Info( $"[nz-eyes] g_vColorTint \"[{t.x:0.000000} {t.y:0.000000} {t.z:0.000000} 0.000000]\"" );
foreach ( var p in g.Materials ) Log.Info( $"[nz-eyes] {p}" );
}
static void Tune( Group g, string hex, float glow )
{
if ( !string.IsNullOrWhiteSpace( hex ) )
{
// ⚠️ REFUSES A BAD STRING RATHER THAN GOING BLACK. `Color.Parse` returns null on junk,
// and defaulting it would turn the eyes off while reporting success — a setting that
// looks applied and is not.
if ( Color.Parse( hex ) is not Color c )
{
Log.Warning( $"[nz-eyes] '{hex}' is not a colour — try {g.AuthoredHex}" );
return;
}
g.Hue = c;
}
if ( glow >= 0f ) g.Glow = glow;
Apply( g );
Report( g );
}
/// <summary>
/// `nz_zombie_eyes [hex] [glow]` — the walkers' blue.
///
/// ⚠️ PICK A SATURATED COLOUR TO GET A PALE ONE, which is the opposite of the instinct. The tint
/// is driven far above 1.0 and the tonemapper then compresses it, so the channels converge as
/// they brighten: an already-pale `#88CCFF` arrives as plain white, while a hard `#1257FF` is
/// what reads as light blue in game. The same curve shifts bright reds toward orange, which is
/// why the hound red is pure `#FF0000` at a LOWER drive rather than a brighter warm red.
/// </summary>
[ConCmd( "nz_zombie_eyes" )]
public static void ZombieCmd( string hex = "", float glow = -1f ) => Tune( Walkers, hex, glow );
/// <summary>`nz_hound_eyes [hex] [glow]` — the hellhound and Brutus red, together.</summary>
[ConCmd( "nz_hound_eyes" )]
public static void HoundCmd( string hex = "", float glow = -1f ) => Tune( Hounds, hex, glow );
/// <summary>`nz_napalm_eyes [hex] [glow]` — the Napalm Zombie's orange, on its own.</summary>
[ConCmd( "nz_napalm_eyes" )]
public static void NapalmCmd( string hex = "", float glow = -1f ) => Tune( Napalm, hex, glow );
/// <summary>`nz_eyes_report` — every group, and whether it is authored or overridden.</summary>
[ConCmd( "nz_eyes_report" )]
public static void ReportCmd()
{
Report( Walkers );
Report( Hounds );
Report( Napalm );
}
/// <summary>
/// `nz_eyes_reset` — drop every override.
///
/// ⚠️ IT DOES NOT RESTORE THE MATERIAL, IT STOPS OVERRIDING IT — and those differ until the
/// asset is recompiled, because the last `Set` is still on the live material. Re-applying the
/// authored values is what actually puts them back.
/// </summary>
[ConCmd( "nz_eyes_reset" )]
public static void ResetCmd()
{
foreach ( var g in new[] { Walkers, Hounds, Napalm } )
{
g.Hue = null;
g.Glow = null;
Apply( g );
}
Log.Info( "[nz-eyes] back to the authored values" );
ReportCmd();
}
}