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.
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)" : "")}" );
}
}