Buyables/PackAPunchManager.cs

Manager component that spawns Pack-a-Punch machines from the active level config into the scene. It ensures a singleton instance, can be created per-scene with Ensure, rebuilds by destroying existing machines and instantiating new ones at configured spots, and computes machine rotation flattened to the floor normal.

Networking
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// Builds the config's Pack-a-Punch machines into the world.
///
/// ⚠️ The same shape as DebrisManager / BarricadeManager / MysteryBoxManager —
/// Ensure creates it on demand, NotSaved keeps it out of the map, Rebuild is the
/// single entry point. Copying that shape is deliberate: a manager refreshed
/// differently from its siblings is one more thing to remember at every call site
/// that puts a config into the world.
/// </summary>
public sealed class PackAPunchManager : Component
{
	public static PackAPunchManager Instance { get; private set; }

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

	public static PackAPunchManager Ensure( Scene scene )
	{
		if ( Instance.IsValid() ) return Instance;
		if ( !scene.IsValid() ) return null;

		var go = scene.CreateObject();
		go.Name = "Pack-a-Punch Manager";
		go.Flags |= GameObjectFlags.NotSaved;
		return go.Components.Create<PackAPunchManager>();
	}

	/// <summary>Destroy what is standing and build the config again.</summary>
	public void Rebuild()
	{
		foreach ( var m in PackAPunch.All.ToList() )
			m?.GameObject?.Destroy();
		PackAPunch.All.Clear();

		var list = ActiveConfig.Current?.PackAPunches;
		if ( list is null || list.Count == 0 ) return;

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

		Log.Info( $"[nz] {list.Count} Pack-a-Punch machine(s) built" );
	}

	void Build( PackAPunchSpot spot )
	{
		var go = Scene.CreateObject();
		go.Name = "Pack-a-Punch";
		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 );

		go.Components.Create<PackAPunch>();
	}

	/// <summary>
	/// A rotation facing `yaw` but lying flat on the spot's floor.
	///
	/// ⛔ The SAME maths MysteryBoxManager documents — the heading is projected onto
	/// the surface plane before LookAt sees it, because feeding LookAt a yaw that
	/// is not perpendicular to the up vector lets it renormalise however it likes
	/// and twists the machine on a slope.
	/// </summary>
	static Rotation OnFloor( PackAPunchSpot 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 );
	}
}