Transport/Springboard.cs

A game system and console commands that implement invisible springboard pads which launch the local player straight up when they land on a pad. It reads springboard definitions from ActiveConfig.Current.Springboards each tick, detects whether the local player is standing on a pad, and replaces the player's vertical velocity to a configured strength. Also provides console commands to place, list and clear pads in the editor.

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

namespace NZombies;

/// <summary>
/// SPRINGBOARDS — the map's invisible pads that fling a player straight up (`MapConfig.Springboards`, Q &gt; Placeables &gt;
/// Transportation). Banana Colada's springboard (`Placeable.TickSpringboard`) made part of the map: no uses, no clock, nothing
/// drawn, and its strength and radius set per pad (the user, 2026-10-01: *"its like the banana colada springboard, but
/// invisible and i can set how much strength it has and the radius"*).
///
/// ⛔ EACH MACHINE THROWS ONLY ITS OWN PLAYER (`NZPlayer.Local`). A body is driven by the machine that owns it, so a launch
/// written to somebody else's copy would fight the transform arriving from its owner (`ShoveGuard`'s reason). Every machine
/// has the config, so every machine knows the pads.
///
/// ⚠️ IT THROWS ON LANDING ON THE PAD, NOT ON CROSSING ITS EDGE. The perk's pad fires on entering its volume, so it also caught
/// a player coming back down 72 units above it, and threw them again in mid-air. Here a player has to touch the floor inside
/// the circle, as on a real springboard: walk on, or land on it, and up you go; land on it again and it throws you again.
///
/// ⚠️ A SYSTEM, NOT A MANAGER: there is nothing to build, so there is nothing for `NZGame.ShowConfig` to rebuild. It reads the
/// config every frame, and a pad placed in Creative works at once.
/// </summary>
public sealed class SpringboardSystem : GameObjectSystem<SpringboardSystem>
{
	public SpringboardSystem( Scene scene ) : base( scene )
	{
		Listen( Stage.StartUpdate, 0, Tick, "nz.springboard" );
	}

	static float? _padHeight;
	/// <summary>
	/// How far above or below a pad a player's feet may be and still count as on it. 48.
	///
	/// ⚠️ UNDER A FLOOR'S HEIGHT, so a pad does not throw someone standing on a walkway above it, and over a stair step, so a
	/// pad laid on a slope or a lumpy displacement still catches the whole of its circle.
	/// </summary>
	public static float PadHeight { get => _padHeight ?? 48f; set => _padHeight = value; }

	/// <summary>
	/// The pad the local player stood on last frame, or -1.
	///
	/// ⚠️ ONE THROW PER TOUCH. The launch fires when this changes to a pad, so a player the launch could not lift (a low
	/// ceiling) is not thrown again every frame; they step off and back on.
	/// </summary>
	int _onPad = -1;

	void Tick()
	{
		var scene = Scene;
		if ( !scene.IsValid() || scene.IsEditor ) return;

		var list = ActiveConfig.Current?.Springboards;
		if ( list is null || list.Count == 0 ) { _onPad = -1; return; }

		var player = NZPlayer.Local;
		if ( !player.IsValid() || !player.GameObject.Active || player.IsDown || player.IsOutOfRound ) { _onPad = -1; return; }

		// ⚠️ A FROZEN BODY IS LEFT ALONE: noclip keeps the controller but stops the body's motion (`Noclip.Detach`), and a
		// launch there would only nudge a flying player 12 units up
		var c = player.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );
		if ( !c.IsValid() || !c.Enabled || !c.IsOnGround || !c.Body.IsValid() || !c.Body.MotionEnabled )
		{
			_onPad = -1;
			return;
		}

		var pad = PadAt( list, player.WorldPosition );
		if ( pad == _onPad ) return;

		_onPad = pad;
		if ( pad >= 0 && list[pad].Strength > 0f ) Launch( player, c, list[pad], pad );
	}

	/// <summary>
	/// The index of the pad whose circle a point stands in, the nearest middle if several overlap, or -1. A pad of strength 0
	/// counts, so the tool can still find it to re-set or remove; it just throws nobody.
	/// </summary>
	public static int PadAt( List<SpringboardSpot> list, Vector3 at )
	{
		var best = -1;
		var bestD = float.MaxValue;

		for ( var i = 0; i < (list?.Count ?? 0); i++ )
		{
			var s = list[i];
			if ( s is null ) continue;

			var d = at - s.Position;
			var flat = d.WithZ( 0f ).Length;
			if ( flat > s.Radius || MathF.Abs( d.z ) > PadHeight || flat >= bestD ) continue;

			bestD = flat;
			best = i;
		}

		return best;
	}

	/// <summary>
	/// Throw the local player straight up, the perk's way (`Placeable.Launch`): lifted off the floor first, or the controller
	/// cancels the upward speed and re-snaps them to it, then the body's vertical speed replaced, never added to, so every
	/// throw from a pad reaches the same height.
	/// </summary>
	static void Launch( NZPlayer player, PlayerController c, SpringboardSpot s, int index )
	{
		var body = c.Body;
		if ( !body.IsValid() ) return;

		player.WorldPosition += Vector3.Up * Placeable.GroundBreak;
		body.Velocity = body.Velocity.WithZ( s.Strength );

		ShoveGuard.Of( player )?.NoteLaunch();
		if ( s.SafeLanding ) ShoveGuard.Of( player )?.SpareNextLanding();

		Log.Info( $"[nz-spring] springboard #{index} threw you at {s.Strength:0} u/s"
			+ $" (about {ApexHeight( s.Strength, player.Scene ):0} units up)"
			+ ( s.SafeLanding ? "" : " — ⚠ fall damage on the landing" ) );
	}

	/// <summary>
	/// How high a throw at this speed goes, in units: v² ÷ 2g, from the scene's own gravity (800 when it has none).
	///
	/// ⚠️ THE SAME FOR EVERY THROW. The launch replaces the vertical speed rather than adding to it, so a run-up or a jump onto
	/// the pad changes how far a player travels, not how high.
	/// </summary>
	public static float ApexHeight( float speed, Scene scene )
	{
		var g = MathF.Abs( scene?.PhysicsWorld?.Gravity.z ?? 0f );
		if ( g < 1f ) g = 800f;

		return speed * speed / ( 2f * g );
	}
}

/// <summary>
/// Console access to the springboards, so one can be placed, listed and cleared without clicking (MCP can't click).
/// </summary>
public static class SpringboardCommands
{
	static MapEditor Editor
		=> Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();

	/// <summary>
	/// `nz_springboard [strength] [radius]` — one at your feet, with the tool panel's numbers, or these. In Creative.
	/// </summary>
	[ConCmd( "nz_springboard" )]
	public static void Place( float strength = -1f, float radius = -1f )
	{
		var ed = Editor;
		var p = NZPlayer.Local;
		if ( !ed.IsValid() || !p.IsValid() ) { Log.Warning( "[nz-spring] no editor or player — in Creative only" ); return; }

		if ( strength >= 0f ) ed.SpringboardStrength = strength;
		if ( radius > 0f ) ed.SpringboardRadius = radius;

		ed.AddSpringboardAt( p.WorldPosition, Vector3.Up );
	}

	/// <summary>`nz_springboard_list` — every pad, with its strength, radius and how high it throws.</summary>
	[ConCmd( "nz_springboard_list" )]
	public static void List()
	{
		var list = ActiveConfig.Current?.Springboards;

		if ( list is null || list.Count == 0 )
		{
			Log.Info( "[nz-spring] none placed — Q > Placeables > Transportation > Springboard, or nz_springboard" );
			return;
		}

		for ( var i = 0; i < list.Count; i++ )
		{
			var s = list[i];
			Log.Info( $"[nz-spring] [{i}] {s.Position}  {s.Strength:0} u/s"
				+ $" (about {SpringboardSystem.ApexHeight( s.Strength, Game.ActiveScene ):0} up)  radius {s.Radius:0}"
				+ ( s.SafeLanding ? "  safe landing" : "  ⚠ fall damage" ) );
		}
	}

	/// <summary>`nz_springboard_clear` — remove them all.</summary>
	[ConCmd( "nz_springboard_clear" )]
	public static void Clear()
	{
		var list = ActiveConfig.Current?.Springboards;
		var n = list?.Count ?? 0;
		list?.Clear();

		Log.Info( $"[nz-spring] removed {n}" );
	}
}