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