Barricades/Barricade.SpawnSide.cs

Part of the Barricade class that determines which window a zombie spawner feeds and exactly where a zombie from that spawner should stand. It computes nearest window within reach, which side of the run the spawner is on, determines standing points on floor or navmesh, checks line-of-sight and traces against scene geometry, and exposes a console command listing spawn-window mappings.

File AccessNetworking
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// A SPAWNER AT A WINDOW: which window it feeds, and where on that window's own side a zombie from it stands (the user,
/// 2026-10-01: *"zombies not getting behind the barricade when spawning ... make it so they are moved to the barricade after
/// spawning, on the side nearest to their spawn point"*). Used by `ZombieCommands.SpawnAt( atWindow: true )`.
///
/// ⛔ WHY THEY ENDED UP IN FRONT OF THE BOARDS. A spawn closet behind a window is barely bigger than a zombie (Defocus: about
/// 68×68 and 79 high), and in half of Defocus's the navmesh does not reach at all (`nz_barr_cross`: 16 of 32 windows have no
/// crossing). The spawn snap (`ZombieAI.NavGround`) then found the nearest mesh, which was the room on the other side of the
/// boards, and the agent held the zombie there.
///
/// ⛔ THE SIDE IS THE SPAWNER'S, NOT THE NEAREST PLAYER'S. `FarSideOf` guesses which side is outside from where the players
/// are. A spawner placed behind a window says it outright.
/// </summary>
public sealed partial class Barricade
{
	static float? _spawnWindowReach;

	/// <summary>
	/// How far from a window's run a spawner may stand and still feed that window, flat, in units. 96. The vertical band is
	/// the barricade's own (`InReach`, 64), so a spawner on the floor above or below feeds nothing here.
	///
	/// ⚠️ Defocus's spawners stand 25 to 41 units from their windows. 96 takes those with room to spare, and stays short of
	/// a window across a corridor.
	/// </summary>
	public static float SpawnWindowReach { get => _spawnWindowReach ?? 96f; set => _spawnWindowReach = value; }

	/// <summary>The window a spawner feeds: the nearest run within <see cref="SpawnWindowReach"/> on its own level, or null.</summary>
	public static Barricade WindowFor( Vector3 spawner )
	{
		Barricade best = null;
		var bestDist = float.MaxValue;

		foreach ( var b in All )
		{
			if ( !b.IsValid() || !b.InReach( spawner, SpawnWindowReach ) ) continue;

			var d = b.DistanceToRun( spawner );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = b;
		}

		return best;
	}

	/// <summary>Which side of the run a spawner is on, +1 or -1; on the line counts as +1.</summary>
	int SpawnSide( Vector3 spawner ) => PlaneDistance( spawner ) < 0f ? -1 : 1;

	/// <summary>
	/// Where a zombie from a spawner at this window stands: on the spawner's side of the run, <see cref="CrossOffset"/> out
	/// from its middle, inside the reach a zombie tears from. With a crossing, that is the crossing's own landing point on that
	/// side (on the navmesh). Without one it is that point on the floor, and <paramref name="onMesh"/> says whether the
	/// navmesh reaches it there. Null when there is no floor for it on the spawner's level, or a wall stands between: the
	/// caller then spawns as before.
	///
	/// ⚠️ OFF THE MESH IS NOT A FAILURE. A zombie there is held where it stands (`ZombieAI.ParkedAt`) until the boards are
	/// down, then climbs through to <see cref="RoomSideFor"/>.
	/// </summary>
	public Vector3? SpawnSideFor( Vector3 spawner, out bool onMesh )
	{
		onMesh = false;
		var side = SpawnSide( spawner );

		if ( CrossValid )
		{
			onMesh = true;
			return PlaneDistance( CrossA ) * side > 0f ? CrossA : CrossB;
		}

		var stand = FloorAt( MidRun() + RunNormal.WithZ( 0f ).Normal * side * CrossOffset, spawner.z );
		if ( stand is not Vector3 at ) return null;

		// ⚠️ ONLY A STAND THE BOARDS CAN BE TORN FROM (`InReach`, its vertical band too): a zombie held anywhere else would
		// stand there for good
		if ( !InReach( at, CrossOffset + 1f ) ) return null;

		// ⚠️ NOT THROUGH A WALL: the stand and the spawner share the closet, or this window is not the one it feeds
		var scene = Scene ?? Game.ActiveScene;
		var wall = scene.Trace.Ray( spawner + Vector3.Up * 36f, at + Vector3.Up * 36f ).WithoutTags( StandIgnores ).Run();
		if ( wall.Hit && !wall.StartedSolid && wall.Distance < spawner.Distance( at ) - 4f ) return null;

		// ⚠️ THE MESH, ONLY ON THIS SIDE. A box around the stand reaches the room's mesh past the boards, and a point there
		// would put the zombie back in front of them, which is the bug.
		var near = scene.NavMesh?.GetClosestPoint( new BBox( at - new Vector3( 16f, 16f, 24f ), at + new Vector3( 16f, 16f, 24f ) ) );
		if ( near is Vector3 mesh && PlaneDistance( mesh ) * side > 4f )
		{
			onMesh = true;
			return mesh;
		}

		return at;
	}

	/// <summary>
	/// Where a zombie held on a spawner's side climbs to: the run's other side, <see cref="CrossOffset"/> in, on the navmesh of
	/// its own level. With a crossing, the crossing's landing point there.
	/// </summary>
	public Vector3 RoomSideFor( Vector3 spawner )
	{
		var side = SpawnSide( spawner );
		if ( CrossValid ) return PlaneDistance( CrossA ) * side > 0f ? CrossB : CrossA;

		var at = MidRun() - RunNormal.WithZ( 0f ).Normal * side * CrossOffset;
		var floor = FloorAt( at, spawner.z ) ?? at.WithZ( spawner.z );

		// ⚠️ KEPT ONLY IF IT STAYED IN THE ROOM. `NavGround` looks 64 to the side, and the closet is closer than that
		var mesh = ZombieAI.NavGround( Scene ?? Game.ActiveScene, floor );
		return PlaneDistance( mesh ) * side < 0f ? mesh : floor;
	}

	Vector3 MidRun() => (RunA + RunB) * 0.5f;

	/// <summary>
	/// What the stand's two traces see through: bodies, so only the map decides (`FloorAt`, and the wall test in `SpawnSideFor`).
	///
	/// ⛔ A ZOMBIE ALREADY ON THE STAND MADE THE WINDOW REFUSE THE NEXT ONE (2026-10-05). Every zombie from a window is held on
	/// the same stand, and both traces hit bodies: the floor trace starts 40 up, inside the first zombie's capsule
	/// (`StartedSolid`), and the wall trace hits its side. So while one waited at the boards, the next from that spawner got no
	/// stand, fell back to the bare spawner, and in a closet the navmesh doesn't reach the agent could take it to the closet's
	/// roof. That fits the busiest closets on Defocus filling their roofs (the user: *"small changes can decide where it
	/// spawns"*).
	/// </summary>
	internal static readonly string[] StandIgnores = { "player", "zombie", "corpse", "ragdoll", "trigger" };

	/// <summary>The floor under a point, from just above the sill as `Snap` reads it, if it is within 32 of the given level.</summary>
	Vector3? FloorAt( Vector3 at, float level )
	{
		var scene = Scene ?? Game.ActiveScene;
		if ( !scene.IsValid() ) return null;

		var top = at.WithZ( level + 40f );
		var tr = scene.Trace.Ray( top, top + Vector3.Down * 120f ).WithoutTags( StandIgnores ).Run();
		if ( !tr.Hit || tr.StartedSolid || MathF.Abs( tr.HitPosition.z - level ) > 32f ) return null;

		return tr.HitPosition;
	}

	/// <summary>
	/// `nz_spawn_windows` — every zombie spawner: the window it feeds, and whether its zombies stand there on the navmesh or
	/// are held there off it. Read only.
	/// </summary>
	[ConCmd( "nz_spawn_windows" )]
	public static void SpawnWindowsCmd()
	{
		var spawns = ActiveConfig.Current?.ZombieSpawns;
		if ( spawns is null || spawns.Count == 0 ) { Log.Info( "[nz-window] no zombie spawns placed" ); return; }

		int at = 0, held = 0, none = 0;
		for ( var i = 0; i < spawns.Count; i++ )
		{
			var p = spawns[i].Position;
			var w = WindowFor( p );
			if ( w is null )
			{
				none++;
				Log.Info( $"[nz-window] spawn #{i} {p:0}: no window within {SpawnWindowReach:0} on its level — spawns where it stands" );
				continue;
			}

			var stand = w.SpawnSideFor( p, out var onMesh );
			if ( stand is not Vector3 s )
			{
				none++;
				Log.Info( $"[nz-window] spawn #{i} {p:0}: window #{w.Index} {w.DistanceToRun( p ):0}u away, but no floor for a stand"
					+ " on its side — spawns where it stands" );
				continue;
			}

			at++;
			if ( !onMesh ) held++;
			Log.Info( $"[nz-window] spawn #{i} {p:0}: window #{w.Index} {w.DistanceToRun( p ):0}u away"
				+ ( w.CrossValid ? ", crossing built" : ", NO crossing" )
				+ $" — stands at {s:0}" + ( onMesh ? " on the navmesh" : ", HELD there off the navmesh until the boards are down" ) );
		}

		Log.Info( $"[nz-window] {at} of {spawns.Count} spawners are at a window ({held} of them held off the navmesh),"
			+ $" {none} spawn where they stand" );
	}
}