Effects/ZombieEyes.cs

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.

File AccessNative Interop
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();
	}
}