Buyables/MiseryDeviceManager.cs

Component that manages placement of "Misery Acceleration Device" game objects from the active config. It can ensure a singleton manager exists, rebuild devices from MiserySpot entries, create a visual cube child with a box collider and a MiseryDevice component, and compute floor-aligned rotation.

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

namespace NZombies;

/// <summary>
/// Builds the config's misery acceleration devices.
///
/// ⚠️ A PLAIN BOX FOR NOW, BY REQUEST — the model comes later. It is a `Model.Cube`
/// scaled to the spot's `Size`, with a real `BoxCollider` so you can walk up to it and
/// it behaves like an object rather than a hologram.
///
/// ⚠️ SAME SHAPE AS EVERY OTHER MACHINE MANAGER — Ensure creates on demand, NotSaved
/// keeps it out of the map file, Rebuild is the single way anything gets built.
/// </summary>
public sealed class MiseryDeviceManager : Component
{
	public static MiseryDeviceManager Instance { get; private set; }

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

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

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

		var go = scene.CreateObject();
		go.Name = "Misery Device Manager";
		go.Flags |= GameObjectFlags.NotSaved;
		return go.Components.Create<MiseryDeviceManager>();
	}

	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?.Miseries;
		if ( list is null || list.Count == 0 ) return;

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

		Log.Info( $"[nz] {Built} of {list.Count} misery device(s) built" );
	}

	void Build( MiserySpot spot )
	{
		var go = Scene.CreateObject();
		go.Name = "Misery Acceleration Device";
		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.Position;
		go.WorldRotation = OnFloor( spot );

		var size = spot.Size;

		// ⚠️ THE BOX IS A CHILD, LIFTED HALF ITS HEIGHT. A cube's origin is its middle and a
		// placeable's origin is its base — without the lift the device would be buried to
		// its waist in the floor, which is exactly how a placeholder gets mistaken for a
		// broken placement.
		var vis = Scene.CreateObject();
		vis.Name = "visual";
		vis.SetParent( go );
		vis.LocalPosition = Vector3.Up * size.z * 0.5f;
		vis.LocalRotation = Rotation.Identity;

		var cube = Model.Cube.Bounds.Size;
		vis.LocalScale = new Vector3( size.x / cube.x, size.y / cube.y, size.z / cube.z );

		var mr = vis.Components.Create<ModelRenderer>();
		mr.Model = Model.Cube;

		// ⚠️ TINTED SO IT READS AS A DEVICE, not as leftover dev geometry. It is the only
		// untextured thing a mapper will have placed on purpose.
		mr.Tint = new Color( 0.85f, 0.15f, 0.15f, 1f );

		var solid = vis.Components.Create<BoxCollider>();
		solid.Scale = cube;

		var dev = go.Components.Create<MiseryDevice>();
		dev.Spot = spot;

		_built.Add( go );
	}

	/// <summary>Face the spot's yaw while lying flat on its floor.
	///
	/// ⚠️ LIFTED FROM `AmmoBoxManager.OnFloor`, deliberately identical — two placeables
	/// aligning differently on the same ramp is a bug nobody would think to look for.</summary>
	static Rotation OnFloor( MiserySpot spot )
	{
		var up = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;
		var heading = Rotation.FromYaw( spot.Yaw ).Forward;

		var forward = (heading - up * heading.Dot( up )).Normal;
		if ( forward.IsNearlyZero() )
			return Rotation.From( 0f, spot.Yaw, 0f );

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