EasterEgg/ShootableManager.cs

Manager component that spawns and tracks shootable targets defined in the active config. It can ensure a singleton exists in a scene, build targets from config spots, and destroy/rebuild them. Each built target is a GameObject with a visual child, collider, model renderer and a Shootable component; mounting rotation is computed for wall or floor placement.

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

namespace NZombies;

/// <summary>
/// Builds the config's shootables.
///
/// ⚠️ A PLACEHOLDER BOX FOR NOW, like the pressable and the misery device — a real symbol prop
/// comes later. Flat and wall-mounted by default, because a shot target is a mark ON something
/// far more often than an object standing in a room.
///
/// ⛔ THE COLLIDER IS NOT DECORATION HERE, IT IS THE TRIGGER. A pressable works by proximity and
/// would still function with no collider at all; a shootable is found by `Weapon.TraceBullet`,
/// so a target without one is invisible to every bullet and completely inert.
/// </summary>
public sealed class ShootableManager : Component
{
	public static ShootableManager Instance { get; private set; }

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

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

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

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

	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?.Shootables;
		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.
			spot.Step?.RegisterFlags();
			Build( spot );
		}

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

	void Build( ShootableSpot spot )
	{
		var go = Scene.CreateObject();
		go.Name = "Shootable";
		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 TARGET PUSHES OUT ALONG ITS OWN FORWARD; A FLOOR ONE SITS ON ITS BASE — the
		// pressable's split, in the frame the rotation just established.
		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;

		// Cyan-white, so it does not read as the pressable's gold at a distance: one is walked
		// up to and one is shot from across the room, and mistaking them wastes the trip.
		mr.Tint = new Color( 0.45f, 0.95f, 1f, 1f );

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

		var shoot = go.Components.Create<Shootable>();
		shoot.Spot = spot;
		shoot.Visual = vis;

		_built.Add( go );
	}

	/// <summary>Wall or floor — see `PressableManager.OnSurface`, which this matches exactly so
	/// the two kinds of target mount the same way.</summary>
	static Rotation OnSurface( ShootableSpot 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 );
	}
}