Transport/TeleportPortal.cs

Static utility that spawns the visual effects for a teleporter portal. It exposes tunable static properties for colour, radius, arc count and height, and provides Departure and Arrival methods that fire ShockRing and LightningArc primitives in configured patterns. It also includes a console command to spawn a portal at the local player.

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

namespace NZombies;

/// <summary>
/// THE PORTAL — the world effect a teleporter throws at each end.
///
/// ⛔ COMPOSED FROM ShockRing AND LightningArc, NOT PORTED FROM THE .PCF. `driese_fx.pcf` really
/// does hold both phases — `driese_tp_departure_phase1` is 3 children and `phase2` is 4, needing 5
/// materials, all of which are on disk. It was still the wrong route, for two reasons this project
/// has already written down:
///
///   • ShockRing's own header: "the only layer of Thunderwall's original visual that ports cheaply,
///     and it is the one that carries the read … a ring is a LineRenderer with an animated radius:
///     no textures, no shader, nothing that has to be extracted first."
///   • CherryShockHud's header records THREE failed attempts at porting one PCF faithfully, all on
///     the same wall — Source's units and velocities do not convert.
///
/// And there are zero .vpcf in this project, so a port would also be the first of its kind.
///
/// ⚠️ THE DECOMPOSITION IS UPSTREAM'S, NOT INVENTED. Read out of the pcf:
///     phase1 = ground_xy + ground_xz   (two PERPENDICULAR flare rings)  + ground_amb (electric)
///     phase2 = beacon_xz + beacon_xy   (two more rings) + halo_sparks + halo
/// So both phases are rings plus electricity, which is exactly the two primitives we have.
///
/// ⚠️ STATIC METHODS WITH NO BOOKKEEPING. Both primitives own their GameObject and destroy
/// themselves, which is what lets a one-shot be fired and forgotten.
/// </summary>
public static class TeleportPortal
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
	// re-run. INSTRUCTIONS.md §1.

	static Color? _colour;
	/// <summary>Portal colour. Cyan-white, matching the pad's own glass and the overlay's arcs.</summary>
	public static Color Colour
	{
		get => _colour ?? new Color( 0.45f, 0.85f, 1f );
		set => _colour = value;
	}

	static float? _radius;
	/// <summary>How wide the rings grow. Sized to the real pad, which is 175 across.</summary>
	public static float Radius { get => _radius ?? 190f; set => _radius = value; }

	static int? _arcs;
	/// <summary>Bolts thrown per phase.</summary>
	public static int Arcs { get => _arcs ?? 5; set => _arcs = value; }

	static float? _height;
	/// <summary>How high the bolts reach above the pad.</summary>
	public static float Height { get => _height ?? 120f; set => _height = value; }

	/// <summary>
	/// DEPARTURE — the pad spinning up, one second before anyone leaves.
	///
	/// ⚠️ Upstream's phase1 is the GROUND set: two rings and an electric ambient, no halo. It is
	/// meant to read as the floor charging, so the rings are the loud part and the bolts are short
	/// and low.
	/// </summary>
	public static void Departure( Vector3 at )
	{
		ShockRing.Fire( at, Radius, Colour );
		ShockRing.Fire( at, Radius * 0.55f, Colour );

		Ring( at, Arcs, Height * 0.5f, spread: Radius * 0.45f );
	}

	/// <summary>
	/// ARRIVAL — the riders landing.
	///
	/// ⚠️ Upstream's phase2 adds the HALO and the sparks on top of the rings, so this is the bigger
	/// of the two: a third ring, taller bolts, and one straight up the middle for the column.
	/// </summary>
	public static void Arrival( Vector3 at )
	{
		ShockRing.Fire( at, Radius * 1.15f, Colour );
		ShockRing.Fire( at, Radius * 0.7f, Colour );
		ShockRing.Fire( at, Radius * 0.3f, Colour );

		Ring( at, Arcs + 3, Height, spread: Radius * 0.5f );

		// The column — a single bolt straight up out of the middle, which is what the halo child
		// reads as from the ground.
		LightningArc.Hang( at, at + Vector3.Up * Height * 1.4f, colour: Colour );
	}

	/// <summary>
	/// Throw <paramref name="count"/> bolts from the rim inward and up.
	///
	/// ⚠️ EVENLY SPACED, NOT RANDOMLY PLACED. LightningArc already jitters its own path every
	/// rebuild, so randomising the anchors too makes a clump on one side as often as a ring — and
	/// the thing being drawn is a ring.
	/// </summary>
	static void Ring( Vector3 at, int count, float height, float spread )
	{
		if ( count < 1 ) return;

		for ( int i = 0; i < count; i++ )
		{
			var ang = i / (float)count * MathF.PI * 2f;
			var rim = at + new Vector3( MathF.Cos( ang ) * spread, MathF.Sin( ang ) * spread, 2f );

			LightningArc.Hang( rim, at + Vector3.Up * height, colour: Colour );
		}
	}

	/// <summary>`nz_tp_portal [departure|arrival]` — fire one at your feet, with no teleporter.</summary>
	[ConCmd( "nz_tp_portal" )]
	public static void FireCmd( string phase = "arrival" )
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-tp] no player" ); return; }

		var at = p.WorldPosition;

		if ( phase.Equals( "departure", StringComparison.OrdinalIgnoreCase ) ) Departure( at );
		else Arrival( at );

		Log.Info( $"[nz-tp] {phase} portal at {at}" );
	}
}