Buyables/WallBuyBurnIn.cs

Component that animates a wall-buy weapon "burning in" from a chalk drawing. It swaps each weapon material for a burn-shader copy while an animated front travels across the model, exposes control for showing, flaring on purchase, replaying and a console command to set the behaviour.

Native InteropFile Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// A WALL WEAPON BURNING INTO ITS CHALK — *"instead of the weapon model appearing instantly in the chalk, it 'burns in' from one side
/// to the other"* (2026-09-28). It sits on a wall buy's hidden "weapon" (`WallBuyManager.SpawnWeaponModel`), and
/// `WallBuyManager.SetRevealed` hands it every show and hide. Over `Gameplay.ChalkBurnIn` seconds (basalt's 0.9; 0, the default, is at
/// once, as it always was) a ragged front of embers in the chalk's own colour crosses the drawing from the player's left and leaves the
/// gun behind it. Looking away runs it back, twice as fast, and a purchase sends one more front across the gun (`Ignite`), a flare to
/// go with the flame.
///
/// ⛔ ITS OWN SHADER, AND ONLY WHILE IT MOVES. No engine shader can hide part of a model, so while the front travels each of the gun's
/// materials is swapped for a copy of `materials/nz/burnin.vmat` (`shaders/nz_burnin.shader`) carrying that material's colour map. The
/// moment it is through, the gun's own materials go back, so at rest a wall gun is drawn exactly as before.
/// ⚠️ WHERE THE FRONT IS TRAVELS ON THE RENDERER, as the fog sheets' heights do (`FogAreaManager`): the gun's span along the wall and how
/// far the fire has got. So one copy of each gun material serves every wall that sells it.
/// ⚠️ LOCAL, LIKE THE REVEAL IT ANIMATES: nothing is sent.
/// ⚠️ THE TUNABLES ARE NULLABLE-BACKED (INSTRUCTIONS §1).
/// </summary>
public sealed class WallBuyBurnIn : Component
{
	public const string BurnMaterial = "materials/nz/burnin.vmat";

	/// <summary>Seconds a burn takes: the map's `Gameplay.ChalkBurnIn`. 0 is off, and the gun appears at once.</summary>
	public static float Seconds => Math.Clamp( ActiveConfig.Current?.Gameplay?.ChalkBurnIn ?? 0f, 0f, 10f );

	/// <summary>
	/// Which side the fire starts from, as the player faces the wall: the left by default (the muzzle, the way the chalk hangs), the
	/// right when this is set. `nz_wallbuy_burn right` (this session).
	/// </summary>
	public static bool FromRight
	{
		get => _fromRight ?? false;
		set => _fromRight = value;
	}

	static bool? _fromRight;

	/// <summary>0 hidden in the chalk, 1 the whole gun.</summary>
	public float Progress { get; private set; }

	float _want;

	/// <summary>Where the purchase's flare is across the gun, 0 to 1, or below 0 while there is none.</summary>
	float _flare = -1f;

	TimeUntil _dropAt;
	bool _dropArmed;

	ModelRenderer _gun;
	bool _burning;
	Vector3 _axis;
	float _start;
	float _length = 1f;

	ModelRenderer Gun => _gun.IsValid() ? _gun : _gun = Components.Get<ModelRenderer>( FindMode.EverythingInSelf );

	/// <summary>A wall buy's burn, or null when it has no weapon model (a marker box).</summary>
	public static WallBuyBurnIn Of( WallBuy buy )
	{
		if ( !buy.IsValid() ) return null;

		var go = buy.GameObject.Children.FirstOrDefault( c => c.Name == "weapon" );
		return go.IsValid() ? go.Components.Get<WallBuyBurnIn>( FindMode.EverythingInSelf ) : null;
	}

	/// <summary>The purchase's flare on a wall buy (`WallBuy.TryBuy`).</summary>
	public static void Ignite( WallBuy buy ) => Of( buy )?.Flare();

	/// <summary>Show the gun or put it back into the chalk: burning where the map burns them, else — or when told — at once.</summary>
	public void Show( bool on, bool instant = false )
	{
		_want = on ? 1f : 0f;
		_dropArmed = false;

		if ( instant || Seconds <= 0f || !Ready )
		{
			Progress = _want;
			_flare = -1f;
			Settle();
			GameObject.Enabled = on;
			return;
		}

		if ( !on && Progress <= 0f )
		{
			Settle();
			GameObject.Enabled = false;
			return;
		}

		// ⛔ ENABLED FIRST AND THE BURN MATERIALS STRAIGHT AFTER, IN THE SAME FRAME. A hidden object does not update, so enabling it is
		// what starts the burn, and a frame between the two would flash the whole gun in its own materials.
		GameObject.Enabled = true;
		if ( Progress != _want ) Burning();
	}

	/// <summary>
	/// The purchase's flare: one more front across the gun, lighting it and taking nothing away. Nothing while the gun is still
	/// burning in, since that front is already the fire.
	/// </summary>
	public void Flare()
	{
		if ( Seconds <= 0f || !Ready || !GameObject.Enabled || Progress < 1f ) return;

		_flare = 0f;
		Burning();
	}

	/// <summary>
	/// Burn in from nothing, to watch it (`nz_wallbuy_burn`). With <paramref name="drop"/> it burns back out a moment later, for a
	/// wall nobody is looking at, which the aim would otherwise never hide.
	/// </summary>
	public void Replay( bool drop )
	{
		Progress = 0f;
		_flare = -1f;
		Show( true );

		_dropArmed = drop;
		_dropAt = Seconds + 1.2f;
	}

	protected override void OnUpdate()
	{
		if ( _dropArmed && _dropAt )
		{
			_dropArmed = false;
			Show( false );
		}

		if ( !_burning ) return;

		var secs = Seconds;
		var step = secs <= 0f ? 1f : Time.Delta / secs;

		// ⚠️ OUT TWICE AS FAST AS IN: looking away should clear the wall, not hold the eye
		Progress = _want > Progress ? MathF.Min( _want, Progress + step ) : MathF.Max( _want, Progress - step * 2f );

		if ( _flare >= 0f )
		{
			_flare += step;
			if ( _flare > 1f ) _flare = -1f;
		}

		Push();

		if ( Progress == _want && _flare < 0f )
		{
			Settle();
			if ( _want <= 0f ) GameObject.Enabled = false;
		}
	}

	/// <summary>
	/// ⚠️ HIDDEN IS NOTHING SHOWN, whoever hid it: `nz_wallbuy_reveal 0` and a rebuilt wall switch the object off directly, and the next
	/// reveal must burn in from nothing rather than pop in at the progress it had.
	/// </summary>
	protected override void OnDisabled()
	{
		Settle();
		Progress = 0f;
		_want = 0f;
		_flare = -1f;
		_dropArmed = false;
	}

	/// <summary>Into the burn copies of its materials, with the span measured and the front placed, until it arrives.</summary>
	void Burning()
	{
		var gun = Gun;
		if ( !gun.IsValid() || gun.Model is null ) return;

		if ( !_burning )
		{
			for ( var i = 0; i < gun.Materials.Count; i++ )
			{
				var copy = CopyFor( gun.Materials.GetOriginal( i ) );
				if ( copy is not null ) gun.Materials.SetOverride( i, copy );
			}

			_burning = true;
		}

		Measure();
		Push();
	}

	/// <summary>The gun's own materials back: at rest it is exactly what it always was.</summary>
	void Settle()
	{
		if ( !_burning ) return;
		_burning = false;

		var gun = Gun;
		if ( gun.IsValid() ) gun.ClearMaterialOverrides();
	}

	/// <summary>
	/// The gun's span along the wall, the way the fire runs: from the player's left as they face it (<see cref="FromRight"/> turns it
	/// round). ⚠️ FROM THE WALL BUY'S OWN FRAME: its forward points out of the wall at the player, so its left is the player's right.
	/// </summary>
	void Measure()
	{
		var gun = Gun;
		var wall = GameObject.Parent.IsValid() ? GameObject.Parent.WorldRotation : WorldRotation;
		_axis = wall.Left * (FromRight ? -1f : 1f);

		// ⚠️ THE MODEL'S BOX THROUGH THIS OBJECT'S WHOLE TRANSFORM, flattening and all (`WallBuyManager.Orient`), so the span is the
		// drawing's, not the unflattened gun's
		var b = gun.Model.Bounds;
		var world = WorldTransform;
		float lo = float.MaxValue, hi = float.MinValue;

		for ( var c = 0; c < 8; c++ )
		{
			var p = new Vector3( (c & 1) == 0 ? b.Mins.x : b.Maxs.x, (c & 2) == 0 ? b.Mins.y : b.Maxs.y, (c & 4) == 0 ? b.Mins.z : b.Maxs.z );
			var d = Vector3.Dot( world.PointToWorld( p ), _axis );
			lo = MathF.Min( lo, d );
			hi = MathF.Max( hi, d );
		}

		_start = lo;
		_length = MathF.Max( 1f, hi - lo );
	}

	/// <summary>
	/// Where the front is, to the renderer. ⚠️ `Renderer.Attributes`, NOT THE SCENE OBJECT'S: it holds them for a renderer not yet
	/// drawn, so the first frame after the reveal already has them.
	/// </summary>
	void Push()
	{
		var gun = Gun;
		if ( !gun.IsValid() ) return;

		var a = gun.Attributes;
		a.Set( "g_vBurnAxis", _axis );
		a.Set( "g_flBurnStart", _start );
		a.Set( "g_flBurnLength", _length );
		a.Set( "g_flBurnProgress", Progress );
		a.Set( "g_flBurnFlare", _flare );

		var c = Colour;
		a.Set( "g_vBurnColour", new Vector3( c.r, c.g, c.b ) );
	}

	/// <summary>The fire's colour is the chalk's own: the map's for the common tier (basalt's ember), a rarity's for its.</summary>
	Color Colour
	{
		get
		{
			var buy = GameObject.Parent.IsValid() ? GameObject.Parent.Components.Get<WallBuy>() : null;
			return buy.IsValid() && buy.Rarity > 0 ? Rarity.ColorFor( buy.Rarity ) : WallBuyManager.ChalkBase;
		}
	}

	static Material _base;
	static readonly Dictionary<string, Material> _copies = new();

	/// <summary>Is the burn material there? Without it a wall gun simply appears, as it used to.</summary>
	public static bool Ready => (_base ??= Material.Load( BurnMaterial )) is not null;

	/// <summary>
	/// A gun material's burn copy, made once and kept: the burn material with that material's colour map.
	///
	/// ⚠️ SET BY THE SHADER VARIABLE'S NAME, `g_tColor`, the convention the hex panels' copies already use for their tint
	/// (`HexPlatforms`: `g_vColorTint`). The weapons' `complex.shader` names its colour map the same.
	/// ⚠️ THE COLOUR MAP ONLY. `complex.shader` packs its normal and roughness in a layout the burn shader would misread, so for the
	/// second the front is moving the gun is lit flat, and then it is itself again.
	/// </summary>
	static Material CopyFor( Material original )
	{
		if ( original is null || !Ready ) return null;

		var key = original.Name ?? "";
		if ( _copies.TryGetValue( key, out var hit ) && hit is not null ) return hit;

		var copy = _base.CreateCopy( $"nz_burnin_{_copies.Count}" );
		var colour = original.GetTexture( "g_tColor" ) ?? original.FirstTexture;
		if ( colour is not null ) copy.Set( "g_tColor", colour );

		return _copies[key] = copy;
	}

	/// <summary>
	/// `nz_wallbuy_burn [seconds] [left|right]` — the burn-in. A number sets this map's `Gameplay.ChalkBurnIn` (the config in memory,
	/// which `nz_save` keeps; 0 is off). `left` or `right` is the side the fire starts from as you face the wall (this session; say
	/// which you want and it becomes the default). Then the wall buy you are looking at, or else the nearest, burns in again to watch.
	/// </summary>
	[ConCmd( "nz_wallbuy_burn" )]
	public static void Cmd( string seconds = "", string side = "" )
	{
		var g = ActiveConfig.Current?.Gameplay;
		if ( g is not null && float.TryParse( seconds, System.Globalization.NumberStyles.Float,
			System.Globalization.CultureInfo.InvariantCulture, out var s ) )
			g.ChalkBurnIn = Math.Clamp( s, 0f, 10f );

		var dir = (side.Length > 0 ? side : seconds).Trim().ToLowerInvariant();
		if ( dir == "left" ) FromRight = false;
		else if ( dir == "right" ) FromRight = true;

		Log.Info( "[wallbuy] burn-in "
			+ (Seconds > 0f ? $"{Seconds:0.##} s, from the {(FromRight ? "right" : "left")}" : "OFF — the gun appears at once")
			+ (Ready ? "" : $" · ⚠ {BurnMaterial} did not load, so every gun appears at once") + " · nz_save keeps the seconds" );

		var mgr = WallBuyManager.Instance;
		if ( !mgr.IsValid() || Seconds <= 0f ) return;

		var me = NZPlayer.Local;
		var aimed = me.IsValid() ? mgr.Aimed( me ) : null;
		var buy = aimed ?? mgr.All
			.Where( b => Of( b ) is not null )
			.OrderBy( b => me.IsValid() ? b.WorldPosition.DistanceSquared( me.WorldPosition ) : 0f )
			.FirstOrDefault();

		var burn = Of( buy );
		if ( burn is null ) { Log.Info( "[wallbuy] no wall buy with a weapon model to burn" ); return; }

		burn.Replay( drop: aimed is null && !buy.Bought );
		Log.Info( $"[wallbuy] burning in {buy.WeaponName}{(aimed is null ? " (the nearest)" : "")}" );
	}
}