Doors/DamageWallVolume.cs

A component that represents a non-physical damage volume (killbox) in the world. It tracks all instances, tests point containment against an optional footprint polygon and height, applies periodic damage to local players inside, updates an optional renderer and sound emitter, and exposes utility functions like ClosestPoint and At.

NetworkingFile Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// One damage wall standing in the world: hurts any player inside it, on a timer.
///
/// ⛔ NO COLLIDER AND NO TRIGGER — CONTAINMENT IS GEOMETRIC. A trigger volume is the
/// obvious answer and was rejected: nothing else in this project uses `IsTrigger` or
/// `OnTriggerEnter`, so it would be an unproven engine path introduced for one feature,
/// and a trigger that silently failed to fire would look exactly like a wall that does
/// no damage. The shape is already known exactly — a footprint polygon and a height —
/// so testing a point against it is a dozen lines that cannot half-work.
///
/// ⚠️ IT ALSO SIDESTEPS THE NAVMESH PROBLEM ENTIRELY. `InvisibleWallManager` needs the
/// `nz_nav_ignore` tag and `NavMesh.ExcludedBodies` because its walls are solid bodies
/// the mesh generator would otherwise bake in. A damage wall has no body at all, so
/// there is nothing for the generator to see.
/// </summary>
public sealed class DamageWallVolume : Component
{
	/// <summary>Every live damage wall, for the diagnostics.</summary>
	public static readonly List<DamageWallVolume> All = new();

	protected override void OnEnabled() { if ( !All.Contains( this ) ) All.Add( this ); }
	protected override void OnDisabled() => All.Remove( this );

	/// <summary>The config row this was built from.</summary>
	[Property] public DamageWall Spot { get; set; }

	/// <summary>
	/// The visual, when the mapper asked for one. Null when they did not.
	///
	/// ⛔ THE RENDERER IS TOGGLED AT RUNTIME, NOT GATED AT BUILD TIME — and the first
	/// attempt at this got it wrong badly enough to be reverted. Building the visual only
	/// under `ShowAuthoringVisuals` meant the mode had to trigger a Rebuild to make the box
	/// appear and disappear, so `NZGame.SetMode` gained a `Rebuild()` call — and that
	/// fought the two hooks that already build these walls (`ShowConfig` for creative,
	/// `RoundManager.StartGame` for survival). The wall stopped existing in Survival
	/// entirely.
	///
	/// ⚠️ SO THE LIFECYCLE IS NOW IDENTICAL TO THE INVISIBLE WALL'S — built by exactly the
	/// same two hooks, never rebuilt for a mode change, and `NZGame` is not touched at all.
	/// Only this one property flips.
	/// </summary>
	public ModelRenderer Renderer { get; set; }

	/// <summary>
	/// The looping sound, when the mapper asked for one. Null when they did not.
	///
	/// ⛔ ONE EMITTER THAT MOVES, NOT ONE PER CORNER. Basalt's lava is a SINGLE wall 7515x5152
	/// units across, so an emitter pinned to its centre is over a thousand units away from lava you
	/// are standing in — the volume would be wrong everywhere except the middle. Spreading copies
	/// around the footprint instead means several copies of the same loop playing at once, which
	/// comb-filters and stacks in volume rather than sounding like one big pool.
	///
	/// ⚠️ SO IT CHASES THE LISTENER. Each frame the emitter is moved to the nearest point of the
	/// volume, which is what an area sound source actually sounds like: loud at the edge you are
	/// standing on, quiet across the room, and never audible through the floor when the lava is a
	/// storey below.
	/// </summary>
	public SoundPointComponent Sound { get; set; }

	/// <summary>Master switch, so a mapper can walk through their own killbox.
	/// `nz_dmgwall_enable 0`.
	///
	/// ⛔ `Armed`, NOT `Enabled` — `Component.Enabled` ALREADY EXISTS and a static of that
	/// name hides it (CS0108). The compiler warns rather than errors, so it would have
	/// shipped: `wall.Enabled = false` would then have set the master switch for EVERY wall
	/// instead of turning off that one component. INSTRUCTIONS.md records this exact
	/// collision from `DamageOverlay.Enabled` and `Component.Active`.</summary>
	public static bool Armed { get; set; } = true;

	/// <summary>How many ticks this wall has dealt, for `nz_dmgwall_list`.</summary>
	public int Ticks { get; private set; }

	TimeSince _sinceTick;

	protected override void OnStart() => _sinceTick = 0f;

	protected override void OnUpdate()
	{
		TickVisibility();
		TickSound();

		if ( !Armed || Spot is null ) return;

		// ⚠️ CLAMPED HERE, NOT IN THE CONFIG. A saved map can carry 0 — hand-edited, or
		// written by an older tool — and 0 would fire every frame, which at 60fps is the
		// difference between "hurts" and "kills the instant you touch it".
		var interval = MathF.Max( 0.05f, Spot.Interval );
		if ( _sinceTick < interval ) return;

		_sinceTick = 0f;

		var hurt = 0;

		foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
		{
			if ( !p.IsValid() || !Contains( p.WorldPosition ) ) continue;

			// ⛔ THIS MACHINE'S OWN PLAYER ONLY. Every machine runs every wall, and `Health.Apply` forwards a hit on somebody
			// else's body to its owner — so a wall that hurt every body in it hurt each player once PER MACHINE, on top of their
			// own machine's tick: twice in a duo, four times with four, 150 health gone in 0.75s in the boss arena's gap (the
			// co-op audit, 2026-09-27). The player's own machine alone hurts them, as the arena's flood already did — *"it
			// should only hurt the player in the lava"*.
			if ( Networking.IsActive && !PlayerPresence.Mine( p.GameObject ) ) continue;

			// ⚠️ A DOWNED PLAYER IS LEFT ALONE. They are already on a bleedout clock, and
			// stacking wall damage on top would make a body that fell into the volume
			// unrevivable for reasons nobody watching could see.
			if ( p.IsDown ) continue;

			var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
			if ( !hp.IsValid() ) continue;

			// ⚠️ `Apply`, NOT `OnDamage`. OnDamage is the bullet/explosion entry point and
			// runs the PhD Flopper block; a damage wall is environmental and standing in one
			// is a choice, so a perk that exists to survive your own grenades should not make
			// you immune to it. This is the same path a zombie's swipe takes.
			//
			// ⛔ AS A TICK (`Health.Apply`'s `tick`): it lands whatever the post-hit window says and opens none. That window is the
			// crowd's, and a wall at 0.2s — the boss arena's lava, *"10 damage every 0.2s"* (2026-09-27) — would land one tick in
			// three, and shield its victim from the zombies meanwhile.
			hp.Apply( MathF.Max( 0f, Spot.Damage ), tick: true );
			hurt++;
		}

		if ( hurt > 0 ) Ticks++;
	}

	/// <summary>
	/// The box shows while authoring and never in a round.
	///
	/// ⛔ THE DAMAGE DOES NOT CARE. This method touches the RENDERER and nothing else —
	/// which is the whole point. In Survival the wall is exactly what an invisible wall is:
	/// present, doing its job, and not drawn.
	///
	/// ⚠️ `ShowAuthoringVisuals` rather than `IsCreative`, so PREVIEW MODE hides it too.
	/// Preview exists to judge a map as a player sees it, and a preview that still showed
	/// the killboxes would defeat the one thing it is for.
	///
	/// ⚠️ ASSIGNED ONLY ON A CHANGE. `Enabled` is a real property with a setter behind it;
	/// writing the same value every frame for every wall on the map is work for nothing.
	/// </summary>
	void TickVisibility()
	{
		if ( !Renderer.IsValid() ) return;

		// ⚠️ OR THE MAPPER ASKED FOR IT. `VisibleInGame` is the one way a damage volume stays drawn
		// once the round starts — a lava pool you are meant to avoid has to be visible, and the
		// authoring-only rule above made that impossible to express.
		var show = NZGame.ShowAuthoringVisuals || Spot?.VisibleInGame == true;
		if ( Renderer.Enabled != show ) Renderer.Enabled = show;
	}

	/// <summary>
	/// Keep the emitter on the part of the volume nearest the listener.
	///
	/// ⚠️ THE LOCAL PLAYER, AND THAT IS NOT A MULTIPLAYER SLIP. Sound is a client-side thing —
	/// each machine positions its own emitter for its own listener, and there is nothing here to
	/// network. `NZPlayer.Local` is the right accessor for exactly this and nothing else.
	/// </summary>
	void TickSound()
	{
		if ( !Sound.IsValid() || Spot is null ) return;

		var p = NZPlayer.Local;
		if ( !p.IsValid() ) return;

		Sound.WorldPosition = ClosestPoint( p.WorldPosition );
	}

	/// <summary>
	/// The point of this volume closest to a world position.
	///
	/// ⚠️ IT REUSES THE CONTAINMENT TEST'S OWN FRAME, so a yawed wall needs no special case and a
	/// point already inside the volume returns itself — the emitter sits on the listener and the
	/// sound is at full volume, which is correct when you are standing in the lava.
	/// </summary>
	public Vector3 ClosestPoint( Vector3 world )
	{
		if ( Spot is null ) return world;

		var local = Spot.Rotation.Inverse * (world - Spot.Position);

		var half = Spot.Size.z * 0.5f;
		local.z = MathX.Clamp( local.z, -half, half );

		if ( Spot.HasFootprint )
		{
			var xy = new Vector2( local.x, local.y );

			// ⚠️ ONLY WALK THE EDGES WHEN THE POINT IS OUTSIDE. Inside a concave footprint the
			// nearest EDGE is not the nearest point — the nearest point is where you already are.
			if ( !InPolygon( xy, Spot.Footprint ) )
				xy = ClosestOnPolygon( xy, Spot.Footprint );

			local.x = xy.x;
			local.y = xy.y;
		}
		else
		{
			local.x = MathX.Clamp( local.x, -Spot.Size.x * 0.5f, Spot.Size.x * 0.5f );
			local.y = MathX.Clamp( local.y, -Spot.Size.y * 0.5f, Spot.Size.y * 0.5f );
		}

		return Spot.Position + Spot.Rotation * local;
	}

	/// <summary>Nearest point on a polygon's outline. Standard point-to-segment, per edge.</summary>
	static Vector2 ClosestOnPolygon( Vector2 pt, IReadOnlyList<Vector2> poly )
	{
		if ( poly is null || poly.Count < 2 ) return pt;

		var best = poly[0];
		var bestDist = float.MaxValue;

		for ( int i = 0, j = poly.Count - 1; i < poly.Count; j = i++ )
		{
			Vector2 a = poly[j], b = poly[i];
			var ab = b - a;

			var lenSq = ab.LengthSquared;

			// ⚠️ A ZERO-LENGTH EDGE IS A DIVIDE BY ZERO, and duplicated corners are common in
			// hand-clicked footprints.
			var t = lenSq <= 1e-6f ? 0f : MathX.Clamp( Vector2.Dot( pt - a, ab ) / lenSq, 0f, 1f );

			var on = a + ab * t;
			var d = (pt - on).LengthSquared;

			if ( d < bestDist ) { bestDist = d; best = on; }
		}

		return best;
	}

	/// <summary>
	/// Is this world point inside the volume?
	///
	/// ⚠️ TESTED IN THE WALL'S OWN SPACE, so a yawed wall needs no special case — the
	/// point is rotated in rather than the polygon rotated out.
	///
	/// ⚠️ HEIGHT IS THE BOX EITHER WAY. `Size.z` is the extrusion the shape builder used,
	/// and the origin sits at the middle of it — the convention `InvisibleWallManager`
	/// documents when it offsets the visual by `size.z * 0.5`.
	/// </summary>
	public bool Contains( Vector3 world )
	{
		if ( Spot is null ) return false;

		var local = Spot.Rotation.Inverse * (world - Spot.Position);

		var half = Spot.Size.z * 0.5f;
		if ( local.z < -half || local.z > half ) return false;

		if ( Spot.HasFootprint )
			return InPolygon( new Vector2( local.x, local.y ), Spot.Footprint );

		return MathF.Abs( local.x ) <= Spot.Size.x * 0.5f
			&& MathF.Abs( local.y ) <= Spot.Size.y * 0.5f;
	}

	/// <summary>
	/// Standard crossing-number point-in-polygon.
	///
	/// ⚠️ WORKS FOR CONCAVE SHAPES, which matters: the wall tool explicitly allows a
	/// non-convex footprint (`MapEditor` only enforces convexity for debris), so a
	/// convex-only test would quietly mis-handle exactly the shapes this tool is for.
	///
	/// ⚠️ The `(a.y > y) != (b.y > y)` form counts each edge once at a shared vertex, so
	/// a point level with a corner is not double-counted and reported outside.
	/// </summary>
	static bool InPolygon( Vector2 pt, IReadOnlyList<Vector2> poly )
	{
		if ( poly is null || poly.Count < 3 ) return false;

		var inside = false;

		for ( int i = 0, j = poly.Count - 1; i < poly.Count; j = i++ )
		{
			Vector2 a = poly[i], b = poly[j];

			if ( (a.y > pt.y) != (b.y > pt.y)
				&& pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x )
			{
				inside = !inside;
			}
		}

		return inside;
	}

	/// <summary>The wall containing a point, or null. For the diagnostics.</summary>
	public static DamageWallVolume At( Vector3 world )
		=> All.FirstOrDefault( w => w.IsValid() && w.Contains( world ) );
}