Transport/TeleporterManager.cs

Manager component that builds and manages teleporter pad GameObjects from configuration. It creates non-networked, not-saved objects with a model or generated placeholder mesh, sets renderer tint, and instantiates a Teleporter component per spot; Rebuild destroys old pads and rebuilds from ActiveConfig.Current.Teleporters.

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

namespace NZombies;

/// <summary>
/// TELEPORTERS — a pad you stand on, and the place it sends you.
///
/// ⚠️ Deliberately the same shape as WunderfizzManager / PerkMachineManager / DebrisManager:
/// Ensure creates on demand, NotSaved keeps it out of the map, Rebuild is the single entry point.
///
/// ⚠️ THE BOX IS A PLACEHOLDER AND SAYS SO. It is extruded through DebrisMesh from the spot's own
/// square footprint rather than being a scaled Model.Cube, so the day a real pad model arrives the
/// only thing that changes is where the Model comes from — the collider, the tint and the
/// stand-on test all keep working.
/// </summary>
public sealed class TeleporterManager : Component
{
	public static TeleporterManager Instance { get; private set; }

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

	public static TeleporterManager Ensure( Scene scene )
	{
		if ( Instance.IsValid() ) return Instance;
		if ( !scene.IsValid() ) return null;

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

	readonly List<GameObject> _built = new();

	/// <summary>How many pads 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?.Teleporters;
		if ( list is null || list.Count == 0 ) return;

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

		Log.Info( $"[nz] {Built} of {list.Count} teleporter(s) built" );
	}

	void Build( TeleporterSpot spot, int index )
	{
		var authored = false;
		Model pad = null;

		if ( !string.IsNullOrWhiteSpace( spot.Model ) )
		{
			var m = Model.Load( spot.Model );

			// ⚠️ Model.Load returns null on a bad path but an ERROR MODEL on a compiled-but-broken
			// one, and the error model renders happily as a checkerboard. Both fall through to the
			// placeholder box, SAID OUT LOUD — a silent checkerboard pad reads as a broken import
			// rather than a missing file.
			if ( m is not null && !m.IsError )
			{
				pad = m;
				authored = true;
			}
			else
			{
				Log.Warning( $"[nz] teleporter #{index}: model {spot.Model} "
					+ $"({(m is null ? "not found" : "error model")}) — standing as a plain box" );
			}
		}

		pad ??= DebrisMesh.Build( spot.Footprint(), MathF.Max( 1f, spot.PadHeight ),
			MaterialFor( spot ) );

		// ⚠️ SAID OUT LOUD rather than falling back to a cube. A pad that quietly became a
		// different shape than the one configured is indistinguishable from the feature not
		// working, which is the trap DebrisManager already records.
		if ( pad is null )
		{
			Log.Warning( $"[nz] teleporter #{index}: its pad mesh could not be built — not standing" );
			return;
		}

		var go = Scene.CreateObject();
		go.Name = $"Teleporter #{index}";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		go.WorldPosition = spot.A;
		go.WorldRotation = Rotation.FromYaw( spot.Yaw );

		var r = go.Components.Create<ModelRenderer>();
		r.Model = pad;

		// ⛔ THE TINT IS FOR THE PLACEHOLDER ONLY. It exists to make an untextured box readable;
		// multiplying it into a real pad's own textures would just make Der Riese's teleporter
		// cyan, which is the "flat-tinted block reads as a dev placeholder" note in reverse.
		if ( !authored ) r.Tint = spot.Tint;

		// ⛔️ NO COLLIDER, DELIBERATELY. The pad used to be solid -- a ModelCollider carrying the
		// pad's own hull -- and it is not any more: it was getting in the player's way, which is
		// the one thing a thing you are supposed to walk onto must never do.
		//
		// ⚠️ NOTHING ABOUT THE TELEPORT NEEDS IT. Teleporter.IsOn is a box test against the
		// player's POSITION in the pad's local space, not a physics query, and Riders() is built
		// from that same test. So the pad detects exactly as well with no physics on it at all --
		// which is why this could be dropped rather than worked around.
		//
		// ⚠️ THE TRADE, SAID OUT LOUD: shots no longer stop on the pad, and a player stands at
		// the floor rather than on the pad's surface, so a thick pad swallows their feet. Both are
		// cosmetic. If a map ever wants a pad that is genuinely a raised platform, that wants a
		// per-spot flag rather than this line coming back for every teleporter.

		var tp = go.Components.Create<Teleporter>();
		tp.Spot = spot;
		tp.Index = index;

		_built.Add( go );
	}

	/// <summary>The pad's surface material, falling back to the dev grey the barriers use.</summary>
	static Material MaterialFor( TeleporterSpot spot )
	{
		if ( !string.IsNullOrWhiteSpace( spot.Material ) )
		{
			var mat = Material.Load( spot.Material );
			if ( mat is not null ) return mat;
		}

		return Material.Load( "materials/dev/gray_50.vmat" );
	}
}