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.
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" );
}
}