Buyables/BuyableEndingManager.cs

Component that creates and manages buyable ending props from the active configuration. It ensures a singleton, builds GameObjects for each configured EndingSpot with model, position and rotation aligned to the surface, and rebuilds/destroys them on demand.

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

namespace NZombies;

/// <summary>
/// Builds the buyable endings the config lists.
///
/// ⚠️ THE SHAPE IS `AmmoBoxManager`'s, deliberately — Ensure creates on demand,
/// NotSaved keeps it out of the map file, Rebuild is the single way anything gets
/// built. Every placeable here works this way so a mapper's Rebuild does the same
/// thing whichever tool they used.
/// </summary>
public sealed class BuyableEndingManager : Component
{
	public static BuyableEndingManager Instance { get; private set; }

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

	public static BuyableEndingManager Ensure( Scene scene = null )
	{
		if ( Instance.IsValid() ) return Instance;

		scene ??= Game.ActiveScene;
		if ( !scene.IsValid() ) return null;

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

	readonly List<GameObject> _built = new();

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

		foreach ( var spot in list )
			Build( spot );

		// ⚠️ Says how many are STANDING, not how many are configured. A model that fails
		// to load leaves a spot in the config and nothing in the world, and those two
		// numbers disagreeing is the cheapest way to see it.
		Log.Info( $"[nz] {Built} of {list.Count} buyable ending(s) built" );
	}

	void Build( EndingSpot spot )
	{
		var go = Scene.CreateObject();
		go.Name = "Buyable Ending";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)

		// ⚠️ Position and rotation together, because a wall mount needs BOTH — the prop has
		// to come off the surface as well as turn to face out of it.
		//
		// ⚠️ `* ModelTweak`, NOT `ModelTweak *`. The tweak corrects how the PROP is authored,
		// so it has to be applied in the surface rotation's own frame — the other order would
		// turn the mount instead of the model, and a wall placement would stop facing out.
		go.WorldRotation = OnSurface( spot ) * ModelTweak;
		go.WorldPosition = spot.Position + SurfaceOffset( spot );

		// ⛔ THE PATH COMES FROM THE SPOT, NOT FROM A CONSTANT. The original's tool
		// exposes the model as a free-text field and only DEFAULTS to the teddy bear, so
		// a map may put the exit on a door, a radio or a helicopter. A `ModelPath` const
		// here — which is how every other manager in this folder does it — would be
		// inventing a restriction upstream does not have.
		var path = string.IsNullOrWhiteSpace( spot.Model )
			? BuyableEnding.DefaultModel
			: spot.Model;

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

		// ⛔ `Model.Load` RETURNS THE ERROR MODEL, NEVER NULL, when a path is wrong — the
		// trap `KnifeViewModel` documents. Since the path is MAPPER-TYPED here rather than
		// a constant we control, a typo is expected rather than exceptional, and it has to
		// say so: a silent purple-and-black box beside the real exit is indistinguishable
		// from a deliberately odd prop.
		if ( r.Model is null || r.Model.IsError )
			Log.Warning( $"[nz] buyable ending model '{path}' not found — check the Model Path "
				+ $"field on this ending (nz_ending_list shows what each is set to)" );

		var end = go.Components.Create<BuyableEnding>();
		end.Spot = spot;

		_built.Add( go );
	}

	/// <summary>
	/// Fixed correction for how the prop itself is authored, applied on top of the surface
	/// alignment. `Rotation.From( pitch, yaw, roll )`.
	///
	/// Requested as "180 degrees around the vertical axis, then 90 on the left-to-right one":
	/// vertical is YAW, left-to-right is PITCH, and `Rotation.From` composes yaw before pitch.
	///
	/// ⛔ PITCH IS 270, NOT 90 — MEASURED IN GAME, NOT DERIVED. The obvious reading of "90 on
	/// the left-to-right axis" gave `From( 90, 180, 0 )` and it came out wrong; the working
	/// value is 270 (i.e. -90, the same rotation the other way). The description does not say
	/// which way round, and nothing but looking at it could.
	///
	/// ⚠️ ONE TWEAK FOR EVERY ENDING, not per spot. It describes the MODEL, not the
	/// placement, so a per-spot field would invite fixing the same authoring problem
	/// separately on every copy and getting a different answer each time.
	///
	/// ⚠️ A `set` AND A COMMAND, because a rotation described in words is guessed until it
	/// is seen. `nz_ending_rotate` retunes and rebuilds live rather than needing a compile
	/// per attempt — the same reason `WallBuyManager.ModelTweak` is settable.
	/// </summary>
	public static Rotation ModelTweak { get; set; } = Rotation.From( 270f, 180f, 0f );

	/// <summary>Is this spot's surface flat enough to stand on? 0.7 is the same threshold
	/// every other placeable refuses below, so "floor" means one thing across the tools.</summary>
	static bool IsFloor( EndingSpot spot )
		=> spot.Normal.IsNearlyZero() || spot.Normal.Normal.z > 0.7f;

	/// <summary>
	/// How far off the surface to sit, so a wall mount is not half inside the wall.
	///
	/// ⚠️ 1.5 UNITS, THE WALLBUY'S NUMBER — it is the only other thing in the project that
	/// mounts to a wall, and two wall-mounted objects floating at different distances is a
	/// difference nobody would think to look for.
	///
	/// ⚠️ ZERO ON A FLOOR. A prop's origin is at its base, so lifting it would leave the
	/// bear hovering.
	/// </summary>
	static Vector3 SurfaceOffset( EndingSpot spot )
		=> IsFloor( spot ) ? Vector3.Zero : spot.Normal.Normal * 1.5f;

	/// <summary>
	/// How the prop sits on whatever it was placed against.
	///
	/// ⛔ TWO CASES, AND THE SURFACE PICKS. On a FLOOR it stands upright and turns to the
	/// yaw the mapper placed it at, tilted onto the floor's own normal so a ramp does not
	/// tip it — the `AmmoBoxManager.OnFloor` behaviour, lifted deliberately so two
	/// placeables never align differently on the same slope. On a WALL it faces OUT along
	/// the normal, which is the wallbuy's convention.
	///
	/// ⚠️ WITHOUT THE SPLIT, REMOVING THE STEEP REFUSAL WOULD HAVE LAID THE PROP ON ITS
	/// SIDE. The floor branch treats the normal as UP; a wall's normal is horizontal, so it
	/// would have used a sideways up-vector and produced a bear lying flat in mid-air.
	/// </summary>
	static Rotation OnSurface( EndingSpot spot )
	{
		var n = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;

		// Wall or ceiling: face out of the surface, like a wallbuy.
		if ( !IsFloor( spot ) ) return Rotation.LookAt( n );

		var heading = Rotation.FromYaw( spot.Yaw ).Forward;
		var forward = (heading - n * heading.Dot( n )).Normal;

		if ( forward.IsNearlyZero() )
			return Rotation.From( 0f, spot.Yaw, 0f );

		return Rotation.LookAt( forward, n );
	}
}