EasterEgg/PressableManager.cs

Component that builds and manages pressable button game objects from configuration. It can ensure a singleton manager exists, rebuild pressable instances from ActiveConfig.Current.Pressables, create visual/model/collider components, and orient buttons to wall or floor surfaces.

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

namespace NZombies;

/// <summary>
/// Builds the config's pressables.
///
/// ⚠️ A PLACEHOLDER BOX FOR NOW, like the misery device — a real button prop comes later.
/// Kept small and wall-mountable, because a button belongs on a wall far more often than
/// on the floor.
/// </summary>
public sealed class PressableManager : Component
{
	public static PressableManager Instance { get; private set; }

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

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

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

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

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

		foreach ( var spot in list )
		{
			// ⚠️ REGISTERED ON BUILD, so every flag a step names has a tie-break index before
			// play starts. `EggFlags.Register` is order-dependent and idempotent — doing it
			// here means the indices follow config order rather than whatever order the
			// player happens to complete steps in.
			spot.Step?.RegisterFlags();
			Build( spot );
		}

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

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

		var n = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;
		var wall = n.z <= 0.7f;

		go.WorldPosition = spot.Position;
		go.WorldRotation = OnSurface( spot );

		var size = spot.Size;

		var vis = Scene.CreateObject();
		vis.Name = "visual";
		vis.SetParent( go );
		vis.LocalRotation = Rotation.Identity;

		// ⚠️ A WALL BUTTON PUSHES OUT ALONG ITS OWN FORWARD; A FLOOR ONE SITS ON ITS BASE.
		// `OnSurface` has already turned the object to face out of the wall, so "out" is
		// local forward there and local up on a floor — one offset expressed in the frame
		// the rotation just established, rather than two special cases in world space.
		vis.LocalPosition = wall
			? Vector3.Forward * size.x * 0.5f
			: Vector3.Up * size.z * 0.5f;

		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;
		mr.Tint = new Color( 0.85f, 0.72f, 0.20f, 1f );

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

		var press = go.Components.Create<Pressable>();
		press.Spot = spot;

		// ⚠️ THE BODY IS HANDED OVER, not searched for. `Pressable` switches this whole
		// child off when its step completes, and a lookup by type would find whatever a future
		// prop happens to be built from rather than the thing this method actually made.
		press.Visual = vis;

		_built.Add( go );
	}

	/// <summary>Wall or floor, the buyable ending's split — see `ClueManager.OnSurface`.</summary>
	static Rotation OnSurface( PressableSpot spot )
	{
		var n = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;

		if ( n.z <= 0.7f ) 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 );
	}
}