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