Doors/InvisibleWallManager.cs

Component that spawns and manages invisible map walls from the active configuration. It creates scene objects and colliders or extruded meshes, tags bodies so the NavMesh generator ignores them when desired, controls visibility and whether walls block zombies, and exposes lookup helpers and shape access.

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

namespace NZombies;

/// <summary>
/// WALLS — spawns the config's invisible walls and keeps them out of the navmesh.
///
/// Deliberately a much smaller thing than <see cref="DebrisManager"/>: there is
/// nothing to buy, nothing to gate, nothing to reset between rounds, and no nav
/// blocker. A wall is built once and stands for the whole session.
///
/// ⚠️ Lives beside DebrisManager rather than in a folder of its own because it
/// shares <see cref="DebrisMesh"/> — the same footprint, the same extrusion, the
/// same per-triangle hulls. Keeping them together is what stops the two drifting
/// into two different ideas of what a built block is.
/// </summary>
public sealed class InvisibleWallManager : Component
{
	public static InvisibleWallManager Instance { get; private set; }

	/// <summary>
	/// The tag every wall body carries, and the one the navmesh is told to skip.
	///
	/// ⛔ THIS IS WHAT MAKES "DOES NOT BLOCK THE NAVMESH" TRUE RATHER THAN
	/// MERELY LIKELY. Simply not creating a NavMeshArea is not enough: the mesh
	/// is generated FROM COLLISION, and these walls are solid. They are ignored
	/// today only because the mesh was baked before they existed — but
	/// DebrisManager calls RequestTilesGeneration every time a barrier is built
	/// or bought, so any wall sharing tiles with a doorway would be quietly
	/// baked in the moment someone opened that door, and the horde would stop
	/// pathing through a gap that had worked all game.
	///
	/// `NavMesh.ExcludedBodies` is the engine's own answer — "don't include
	/// these bodies in the generation" — so the exclusion holds no matter who
	/// asks for a rebuild or when.
	/// </summary>
	public const string NavIgnoreTag = "nz_nav_ignore";

	/// <summary>
	/// The manager, creating it if the scene has none yet. Same reasoning as
	/// DebrisManager.Ensure — whoever needs walls first has to be able to bring
	/// it into existence, or a map plays with none and nothing says why.
	/// </summary>
	public static InvisibleWallManager Ensure( Scene scene )
	{
		if ( Instance.IsValid() ) return Instance;
		if ( !scene.IsValid() ) return null;

		var go = scene.CreateObject();
		go.Name = "Invisible Wall Manager";
		go.Flags |= GameObjectFlags.NotSaved;
		return go.Components.Create<InvisibleWallManager>();
	}

	readonly Dictionary<int, GameObject> _props = new();
	readonly Dictionary<int, Model> _shapes = new();

	protected override void OnAwake() => Instance = this;

	protected override void OnDestroy()
	{
		Clear();
		if ( Instance == this ) Instance = null;
	}

	protected override void OnStart() => Rebuild();

	/// <summary>Drop every wall into the world.</summary>
	public void Rebuild()
	{
		Clear();
		ExcludeFromNavMesh();

		var list = ActiveConfig.Current.InvisibleWalls;

		for ( int i = 0; i < list.Count; i++ )
			Spawn( i, list[i] );

		if ( list.Count > 0 )
			Log.Info( $"[nz] invisible walls: {_props.Count} standing"
				+ $" ({list.Count( w => w.Visible )} visible)" );
	}

	/// <summary>
	/// Tell the navmesh generator to skip our bodies.
	///
	/// ⚠️ RE-APPLIED ON EVERY REBUILD, not once at startup. The NavMesh's tag
	/// sets are scene data, so a scene reload, a play restart or a different map
	/// comes back without ours — and the failure is silent and delayed, showing
	/// up only as zombies refusing a route much later.
	/// </summary>
	void ExcludeFromNavMesh()
	{
		var nav = Scene?.NavMesh;
		if ( nav is null ) return;

		if ( nav.ExcludedBodies.Has( NavIgnoreTag ) ) return;

		nav.ExcludedBodies.Add( NavIgnoreTag );
		Log.Info( $"[nz] navmesh now ignores bodies tagged '{NavIgnoreTag}'" );
	}

	void Spawn( int index, InvisibleWall w )
	{
		var go = Scene.CreateObject();
		go.Name = $"Invisible 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)

		// ⚠️ ON THE ROOT, and the collider goes on this same object below. The
		// tag is what excludes it from navmesh generation, so it has to be on
		// whatever object owns the physics body — putting it on a child that
		// holds only the renderer would look right and do nothing.
		//
		// ⛔ AND ONLY WHEN THE WALL IS PLAYER-ONLY. `BlocksZombies` decides whether this
		// body is hidden from the generator; withholding the tag is the ENTIRE mechanism,
		// because the mesh is built from collision and the collider is solid either way.
		// There is no second switch to find.
		if ( !w.BlocksZombies )
			go.Tags.Add( NavIgnoreTag );

		// ⛔ BULLETS AND THE KNIFE PASS THROUGH. `TagsHelper.PassBullets` is already in
		// Weapon.BulletTraceIgnoreTags, so this needs no change to any trace list.
		//
		// ⚠️ WHY IT SHOULD PASS: an invisible wall is a MAP BOUNDARY. A round stopping dead in mid
		// air — with an impact sound and, until this session, a bullet hole hanging in space — reads
		// as the game being broken rather than as a boundary being respected. There is nothing
		// beyond one worth hitting, so letting the round through costs no gameplay.
		//
		// ⚠️ TAGGED HERE AND ON `vis` BOTH, for the same reason NavIgnoreTag is: the collider sits on
		// the ROOT for a box wall and on the `visual` CHILD for a shaped one, and a trace filters on
		// the tags of the body it actually hit.
		go.Tags.Add( SWB.Shared.TagsHelper.PassBullets );

		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] invisible wall #{index}: its {w.Footprint.Count}-point "
				+ "footprint could not be extruded (do its corners cross over each "
				+ "other?) — standing as a plain box instead" );

		if ( shaped is not null )
		{
			_shapes[index] = shaped;

			// 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.
			var vis = Scene.CreateObject();
			vis.Name = "visual";
			vis.SetParent( go );
			vis.LocalPosition = Vector3.Down * size.z * 0.5f;
			vis.LocalRotation = Rotation.Identity;
			vis.LocalScale = Vector3.One;

			// ⛔ THE COLLIDER IS NOT CONDITIONAL. Only the RENDERER answers to
			// Visible — an invisible wall you can walk through is not a wall, and
			// hiding one would be a very confusing way to delete it.
			var hull = vis.Components.Create<ModelCollider>();
			hull.Model = shaped;

			// ⚠️ Tagged too. The collider lives on this child, so the child is
			// the body the navmesh generator would see — and the body a bullet trace hits.
			//
			// ⛔ THIS IS THE ONE THAT MATTERS FOR A SHAPED WALL. Its ModelCollider is HERE,
			// not on the root, so a BlocksZombies check applied only to the root would leave
			// every footprint wall excluded no matter what the flag said — and the setting
			// would appear to work on box walls and silently do nothing on shaped ones.
			if ( !w.BlocksZombies )
				vis.Tags.Add( NavIgnoreTag );
			vis.Tags.Add( SWB.Shared.TagsHelper.PassBullets );

			if ( w.Visible )
			{
				var mr = vis.Components.Create<ModelRenderer>();
				mr.Model = shaped;
				mr.Tint = w.Tint;
				ApplyMaterial( mr, w );
			}
		}
		else
		{
			var solid = go.Components.Create<BoxCollider>();
			solid.Scale = size;

			if ( w.Visible )
			{
				var vis = Scene.CreateObject();
				vis.Name = "visual";
				vis.SetParent( go );
				vis.LocalPosition = Vector3.Zero;
				vis.LocalRotation = Rotation.Identity;
				if ( !w.BlocksZombies )
					vis.Tags.Add( NavIgnoreTag );
				vis.Tags.Add( SWB.Shared.TagsHelper.PassBullets );

				var cube = Model.Cube.Bounds.Size;
				vis.LocalScale = new Vector3(
					size.x / cube.x, size.y / cube.y, size.z / cube.z );

				var vr = vis.Components.Create<ModelRenderer>();
				vr.Model = Model.Cube;
				vr.Tint = w.Tint;
				ApplyMaterial( vr, w );
			}
		}

		_props[index] = go;
	}

	/// <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. See DebrisManager.MaterialFor.</summary>
	static Material MaterialFor( InvisibleWall 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, InvisibleWall w )
	{
		if ( string.IsNullOrWhiteSpace( w.Material ) ) return;

		var mat = Material.Load( w.Material );
		if ( mat is null )
		{
			Log.Warning( $"[nz] material not found: {w.Material} — left untextured" );
			return;
		}

		r.MaterialOverride = mat;
	}

	/// <summary>The built mesh of a shaped wall, for the authoring overlay.</summary>
	public Model ShapeOf( int index )
		=> _shapes.TryGetValue( index, out var m ) ? m : null;

	/// <summary>Is this wall in the world right now?</summary>
	public bool IsStanding( int index )
		=> _props.TryGetValue( index, out var go ) && go.IsValid();

	/// <summary>Which wall a world object belongs to, or -1. Walks the ancestor
	/// chain — the collider is on a child, so comparing against the root alone
	/// silently never matches. (That exact bug cost the debris tool its use
	/// prompt; see DebrisManager.Aimed.)</summary>
	public int IndexOfObject( GameObject go )
	{
		if ( !go.IsValid() ) return -1;

		foreach ( var (i, obj) in _props )
		{
			if ( !obj.IsValid() ) continue;

			for ( var n = go; n is not null; n = n.Parent )
				if ( n == obj ) return i;
		}

		return -1;
	}

	public void Clear()
	{
		foreach ( var (_, go) in _props )
			go?.Destroy();

		_props.Clear();
		_shapes.Clear();
	}

	/// <summary>
	/// Show or hide one wall, rebuilding just it.
	///
	/// ⚠️ Writes the CONFIG then respawns, rather than toggling the renderer.
	/// The visibility is authored data that has to survive a save, and a runtime
	/// toggle that does not reach the config is the kind of change that looks
	/// applied and is gone the next time the map loads.
	/// </summary>
	public bool SetVisible( int index, bool visible )
	{
		var list = ActiveConfig.Current.InvisibleWalls;
		if ( index < 0 || index >= list.Count ) return false;

		list[index].Visible = visible;
		Rebuild();
		return true;
	}

	/// <summary>
	/// Whether the horde is stopped by one wall, rebuilding just it.
	///
	/// ⛔ THE NAVMESH DOES NOT NOTICE A TAG CHANGING. `Rebuild` respawns the body with
	/// or without NavIgnoreTag, but the mesh around it was generated already and keeps
	/// whatever it decided — so the wall LOOKS switched and the horde carries on as
	/// before. `RequestTilesGeneration` over the wall's own bounds is what makes the
	/// change real, and it is the same call DebrisManager makes when a barrier is built.
	///
	/// ⚠️ A GENEROUS BOX, not the wall's exact size. Tile generation works on whole
	/// tiles; a bounds that only just contains the wall can leave the tile straddling its
	/// edge unregenerated, which is a half-applied wall — passable at one end.
	/// </summary>
	public bool SetBlocksZombies( int index, bool blocks )
	{
		var list = ActiveConfig.Current.InvisibleWalls;
		if ( index < 0 || index >= list.Count ) return false;

		var w = list[index];
		w.BlocksZombies = blocks;
		Rebuild();

		var nav = Scene?.NavMesh;
		if ( nav is not null )
		{
			var pad = w.Size * 1.5f + new Vector3( 128f, 128f, 128f );
			nav.RequestTilesGeneration( new BBox( w.Position - pad, w.Position + pad ) );
			Log.Info( $"[nz] navmesh tiles regenerating around wall #{index}" );
		}

		return true;
	}
}