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