Manager component that builds visual and logical "damage wall" objects from configuration. It creates scene GameObjects, optional rendered meshes (extruded footprint or scaled cube), DamageWallVolume components for containment and optional looping SoundPointComponents, and tracks/destroys built walls on Rebuild.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Builds the config's damage walls.
///
/// ⚠️ THE SHAPE COMES FROM `DebrisMesh`, the same builder the debris blocks and the
/// invisible walls use — so a damage wall can be any footprint they can, and the three
/// can never disagree about what a drawn shape means.
///
/// ⛔ BUT NOTHING HERE CREATES A COLLIDER. That is the whole difference from
/// `InvisibleWallManager`: the mesh exists only to be looked at. Containment is
/// geometric, in `DamageWallVolume.Contains`.
///
/// ⛔ AND NOTHING HERE ASKS WHAT MODE THE GAME IS IN. Everything is built the same way
/// every time; `DamageWallVolume` turns the renderer off in a round. The first version
/// gated the visual at build time, which forced `NZGame.SetMode` to Rebuild — and that
/// fought the two hooks that already build these walls, so the wall stopped existing in
/// Survival at all. Same lifecycle as the invisible wall, no exceptions.
/// </summary>
public sealed class DamageWallManager : Component
{
public static DamageWallManager Instance { get; private set; }
protected override void OnAwake() => Instance = this;
protected override void OnDestroy() { if ( Instance == this ) Instance = null; }
public static DamageWallManager Ensure( Scene scene = null )
{
if ( Instance.IsValid() ) return Instance;
scene ??= Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Damage Wall Manager";
go.Flags |= GameObjectFlags.NotSaved;
return go.Components.Create<DamageWallManager>();
}
readonly List<GameObject> _built = new();
/// <summary>How many are standing right now.</summary>
public int Built => _built.Count( g => g.IsValid() );
/// <summary>Destroy what is standing and build the config again.</summary>
public void Rebuild()
{
foreach ( var g in _built ) g?.Destroy();
_built.Clear();
var list = ActiveConfig.Current?.DamageWalls;
if ( list is null || list.Count == 0 ) return;
for ( int i = 0; i < list.Count; i++ )
Build( i, list[i] );
Log.Info( $"[nz] {Built} of {list.Count} damage wall(s) built" );
}
void Build( int index, DamageWall w )
{
var go = Scene.CreateObject();
go.Name = $"Damage Wall {index}";
go.WorldPosition = w.Position;
go.WorldRotation = w.Rotation;
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
// ⛔ NO COLLIDER, NO NAV TAG, NO PassBullets TAG. The invisible wall needs all
// three because it is a solid body; this has no body at all, so there is nothing
// for a bullet to hit, nothing for the player to bump into, and nothing for the
// navmesh generator to bake in. Adding them "for consistency" would be adding
// three no-ops that imply a body exists.
ModelRenderer mr = null;
// ⚠️ BUILT WHENEVER THE MAPPER ASKED FOR IT, in every mode. Whether it DRAWS is
// `DamageWallVolume.TickVisibility`'s business, and keeping that a property toggle
// rather than a build-time branch is what stops the mode ever having to Rebuild.
// ⚠️ EITHER FLAG BUILDS IT. A wall that is hidden while authoring but shown in play is a
// strange thing to ask for and a perfectly legal one, and a build-time `&&` here would make
// it silently impossible.
if ( w.Visible || w.VisibleInGame )
{
var size = w.Size;
var shaped = w.HasFootprint
? DebrisMesh.Build( w.Footprint, size.z, MaterialFor( w ) )
: null;
if ( w.HasFootprint && shaped is null )
Log.Warning( $"[nz] damage wall #{index}: its {w.Footprint.Count}-point footprint "
+ "could not be extruded (do its corners cross over each other?) — "
+ "drawing as a plain box instead" );
var vis = Scene.CreateObject();
vis.Name = "visual";
vis.SetParent( go );
vis.LocalRotation = Rotation.Identity;
mr = vis.Components.Create<ModelRenderer>();
if ( shaped is not null )
{
// The prism runs from z=0 up and the wall's origin is its middle — same
// convention as a debris block, because it is the same builder.
vis.LocalPosition = Vector3.Down * size.z * 0.5f;
vis.LocalScale = Vector3.One;
mr.Model = shaped;
}
else
{
vis.LocalPosition = Vector3.Zero;
var cube = Model.Cube.Bounds.Size;
vis.LocalScale = new Vector3(
size.x / cube.x, size.y / cube.y, size.z / cube.z );
mr.Model = Model.Cube;
ApplyMaterial( mr, w );
}
// ⛔ THE KILLBOX RED IS AN AUTHORING COLOUR, NOT A SURFACE. It exists so a volume
// nobody can bump into still reads as "danger" while a map is being built — but a wall
// the PLAYER sees is a piece of the map, and red multiplied over lava is brown. So the
// wall wears its own tint exactly when someone other than the mapper will see it.
mr.Tint = w.VisibleInGame ? w.Tint : KillboxTint;
}
// ⚠️ THE VOLUME IS ALWAYS CREATED. `Visible` is a build aid, not a switch — a
// damage wall you cannot see still has to hurt, which is the entire point of the
// tool and the thing that broke last time.
var vol = go.Components.Create<DamageWallVolume>();
vol.Spot = w;
vol.Renderer = mr;
// ⚠️ ON ITS OWN CHILD, BECAUSE THE EMITTER MOVES AND THE VOLUME MUST NOT. `TickSound`
// walks it to whichever part of the lava is nearest the listener; putting it on `go` would
// drag the volume's own origin with it and the damage test would follow the player around.
if ( !string.IsNullOrWhiteSpace( w.Sound ) )
{
var sg = Scene.CreateObject();
sg.Name = "sound";
sg.SetParent( go );
sg.Flags |= GameObjectFlags.NotSaved;
sg.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
// ⛔ `SetParent` KEEPS THE WORLD TRANSFORM, so a fresh object parented to the wall stays
// at the world ORIGIN rather than moving to it. Measured: the emitter sat at 0,0,0 while
// its wall was at -289,-206,169. `TickSound` walks it to the listener every frame so a
// live game hides this completely — it only shows with no local player, which is exactly
// the case a mapper hits in a workbench scene, and it would read as "the sound is in the
// wrong place" with nothing to point at.
sg.LocalPosition = Vector3.Zero;
var sp = sg.Components.Create<SoundPointComponent>();
sp.SoundEvent = ResourceLibrary.Get<SoundEvent>( w.Sound );
if ( sp.SoundEvent is null )
{
Log.Warning( $"[nz] damage wall #{index}: sound '{w.Sound}' did not load — silent" );
}
else
{
// ⚠️ OVERRIDES ARE OPT-IN ON THIS COMPONENT. Setting Volume or Distance without
// their `...Override` flag writes a value the engine then ignores, which reads as
// "the volume setting does nothing".
sp.PlayOnStart = true;
// ⚠️ REPEAT IS HOW AN MP3 LOOPS. The file has no loop points, so without this it
// plays once and the map goes quiet — see DamageWall.SoundRepeat.
sp.Repeat = w.SoundRepeat;
// ⚠️ SHORTENED AND JITTERED, exactly as `SoundSpotManager` does and for the same
// reasons: a repeat at the clip's exact length leaves an audible seam, and a fixed
// interval keeps every emitter in phase so the seam becomes one collective dropout.
var len = MathF.Max( 0.1f, w.SoundRepeatTime );
sp.MinRepeatTime = len * 0.90f;
sp.MaxRepeatTime = len * 0.97f;
sp.SoundOverride = true;
sp.Volume = w.SoundVolume;
sp.DistanceAttenuationOverride = true;
sp.DistanceAttenuation = true;
sp.Distance = w.SoundDistance;
vol.Sound = sp;
}
}
_built.Add( go );
}
/// <summary>What an authoring-only damage wall is drawn in. See the assignment in Build —
/// this is the old `DamageWall.Tint` default, kept where it belongs now that the config field
/// means the surface a player sees.</summary>
public static readonly Color KillboxTint = new( 1f, 0.25f, 0.2f, 1f );
/// <summary>Never null — a runtime mesh has no material of its own to fall back on,
/// so an unresolvable path would make it invisible rather than untextured. Same
/// reasoning as `InvisibleWallManager.MaterialFor`.</summary>
static Material MaterialFor( DamageWall w )
{
if ( !string.IsNullOrWhiteSpace( w.Material ) )
{
var mat = Material.Load( w.Material );
if ( mat is not null ) return mat;
}
return Material.Load( "materials/dev/gray_50.vmat" );
}
static void ApplyMaterial( ModelRenderer r, DamageWall w )
{
var mat = MaterialFor( w );
if ( mat is not null ) r.MaterialOverride = mat;
}
}