Power/PowerManager.cs

Scene component that spawns and manages in-world power switch objects for the NZombies game mode. It rebuilds switches from configuration, creates visual props or fallback tinted boxes with colliders, handles player aiming and use interactions, updates tint state, and opens zero-cost doors when power is enabled.

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

namespace NZombies;

/// <summary>
/// Spawns the placed power switches and handles using them.
///
/// Mirrors DebrisManager: config is the truth, the scene objects are rebuilt
/// from it, and nothing about the switch is saved in the map file.
/// </summary>
public sealed class PowerManager : Component
{
	public static PowerManager Instance { get; private set; }

	/// <summary>The manager, creating it if the scene has none yet. Same
	/// reasoning as DebrisManager.Ensure — a null-conditional call on a scene
	/// that never used the power commands silently places no switches.</summary>
	public static PowerManager Ensure( Scene scene )
	{
		if ( Instance.IsValid() ) return Instance;
		if ( !scene.IsValid() ) return null;

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

	/// <summary>How far the aim ray is cast — only picks what you mean.</summary>
	[Property] public float UseRange { get; set; } = 160f;

	/// <summary>How close you must actually stand, to the surface point under
	/// the crosshair. Same as the door reach, so the two interactions feel like
	/// one verb.</summary>
	[Property] public float Reach { get; set; } = 80f;

	/// <summary>Placeholder art until there is a switch model.</summary>
	[Property] public Color OffTint { get; set; } = new( 1f, 0.85f, 0.1f );
	[Property] public Color OnTint { get; set; } = new( 0.35f, 1f, 0.35f );

	readonly Dictionary<int, GameObject> _props = new();

	protected override void OnAwake()
	{
		Instance = this;

		// ⚠️ Subscribed here rather than from a static ctor. Power is a static
		// class whose event would otherwise accumulate a subscription per
		// hotload, and the free doors would open two or three times.
		Power.OnPowered -= OpenFreeDoors;
		Power.OnPowered += OpenFreeDoors;
	}

	protected override void OnDestroy()
	{
		Power.OnPowered -= OpenFreeDoors;
		if ( Instance == this ) Instance = null;
	}

	/// <summary>
	/// The switch prop. The original's own — nz_button.lua's model 2, listed in
	/// its dropdown as literally "Power Switch".
	///
	/// ⚠️ A PROPERTY, so a map can be pointed at the tall lever
	/// (`zombies_power_lever`) instead, which is also ported. Set with
	/// nz_power_model; empty falls back to the tinted box.
	/// </summary>
	[Property] public string SwitchModel { get; set; }
		= "models/nzprops/zombies_power_lever_short.vmdl";

	// ── scene objects ────────────────────────────────────────────────────────

	/// <summary>Rebuild every switch from the config.</summary>
	public void Rebuild()
	{
		Clear();

		var list = ActiveConfig.Current.PowerSwitches;
		for ( int i = 0; i < list.Count; i++ ) Spawn( i, list[i] );

		Log.Info( $"[nz] {list.Count} power switch(es) — power {Power.Summary}" );
	}

	void Spawn( int index, PowerSwitch s )
	{
		var go = Scene.CreateObject();
		go.Name = $"PowerSwitch_{index}";
		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 = s.Position;
		go.WorldRotation = s.Rotation;

		// ⚠️ SAME TWO-OBJECT SHAPE AS DebrisManager, for the same two reasons.
		//
		// 1. Colliders multiply by their object's scale, so scaling the parent
		//    would square the collider size. The parent stays unscaled and its
		//    collider is stated in plain world units.
		// 2. Model.Cube is NOT a 1-unit cube — the scale must be derived from its
		//    own bounds. I assumed 1 here first and the switch rendered too small
		//    to see, which is the exact mistake DebrisManager already carries a
		//    comment about.
		var vis = Scene.CreateObject();
		vis.Name = "visual";
		vis.SetParent( go );
		vis.LocalPosition = Vector3.Zero;
		vis.LocalRotation = Rotation.Identity;

		// ⛔ THE REAL PROP, WITH THE TINTED BOX AS A FALLBACK. This is the
		// original's own "Power Switch" — nz_button.lua model 2,
		// `models/nzprops/zombies_power_lever_short.mdl`, ported through
		// Tools/mdl_to_obj.py. A coloured box read as a placeholder no matter
		// what colour it was, which is the same reason Debris got a material.
		//
		// ⚠️ Falls back rather than failing. Model.Load returns the ERROR model
		// (a checkerboard), not null, when a path does not resolve — so the check
		// is IsError, and a missing asset gives back the box that always worked
		// instead of a checkerboard nobody can identify.
		var model = string.IsNullOrWhiteSpace( SwitchModel ) ? null : Model.Load( SwitchModel );
		bool haveModel = model is not null && !model.IsError;
		_usingProp = haveModel;

		var vr = vis.Components.Create<ModelRenderer>();

		if ( haveModel )
		{
			vr.Model = model;
			vis.LocalScale = Vector3.One;

			// ⛔ CENTRED ON ITS BOUNDS, NOT ON ITS ORIGIN. A Source prop's origin
			// is wherever the artist put it — for this one, the BOTTOM — so
			// placing the object at the clicked point stood the box on that point
			// instead of centring it there. Reported as "its centering with the
			// bottom of the model, i want the center really".
			//
			// ⚠️ The offset is the model's own bounds centre, so this is right for
			// ANY prop the switch is pointed at (nz_power_model) rather than a
			// number tuned to this one.
			//
			// ⚠️ On the CHILD. The parent stays exactly on the clicked point, so
			// the collider and the authoring marker — both stated in world units
			// about that point — still agree with each other and with the config.
			vis.LocalPosition = -model.Bounds.Center;

			// ⚠️ NOT TINTED WHEN IT IS THE REAL PROP. The tint existed to say
			// on/off on an untextured box; multiplying it into a painted model
			// just muddies the texture. The switch's state is read from its
			// lever and from the use prompt.
			vr.Tint = Color.White;
		}
		else
		{
			// Model.Cube is NOT a 1-unit cube — the scale must be derived from
			// its own bounds. I assumed 1 here first and the switch rendered too
			// small to see, the exact mistake DebrisManager carries a comment on.
			var cube = Model.Cube.Bounds.Size;
			vis.LocalScale = new Vector3(
				s.Size.x / cube.x, s.Size.y / cube.y, s.Size.z / cube.z );

			vr.Model = Model.Cube;
			vr.Tint = Power.IsOn ? OnTint : OffTint;

			// ⚠️ Only when a path was actually asked for. Blank is the deliberate
			// "give me the box" setting (nz_power_model box), and warning about a
			// choice someone made on purpose is how warnings get ignored.
			if ( !string.IsNullOrWhiteSpace( SwitchModel ) )
				Log.Warning( $"[nz] power switch model '{SwitchModel}' did not load — "
					+ "standing as a tinted box" );
		}

		// ⚠️ STILL A BOX COLLIDER, EVEN WITH THE PROP. The .phy was not ported —
		// mdl_to_obj.py reads render geometry only — and a switch does not need
		// accurate collision, it needs something the aim trace can hit at roughly
		// the right size. Stated in world units on the UNSCALED parent.
		//
		// ⛔ SIZED FROM THE MODEL, not from the config's 24x24x48 placeholder.
		// Now that the prop is centred on the object, a collider that did not
		// match it would be the same complaint wearing a different hat: you would
		// have to aim at the middle of a box whose edges are not where it looks
		// like they are, and the use prompt would refuse the parts that overhang.
		SwitchSize = haveModel ? model.Bounds.Size : s.Size;

		var col = go.Components.Create<BoxCollider>();
		col.Scale = SwitchSize;

		_props[index] = go;
	}

	public void Clear()
	{
		foreach ( var go in _props.Values ) go?.Destroy();
		_props.Clear();
	}

	/// <summary>
	/// True when the switches are standing as the real prop rather than the
	/// fallback box. Set by Spawn; every switch uses the same model, so one flag
	/// covers the lot.
	/// </summary>
	bool _usingProp;

	/// <summary>
	/// The size the switches actually occupy — the prop's bounds when one is
	/// standing, the config's Size when it is the fallback box.
	///
	/// ⚠️ Published so the authoring MARKER can be the same box as the collider.
	/// Two boxes drawn from two sources drift the moment one of them changes, and
	/// the marker's whole job is to report the thing accurately.
	/// </summary>
	public Vector3 SwitchSize { get; private set; }

	/// <summary>Repaint every switch — green once powered.</summary>
	/// <summary>Repaint every switch from its own flipped state. Public because the network
	/// relay calls it — a client is told a lever moved and has to show it.</summary>
	public void Refresh()
	{
		// ⛔ THE TINT IS FOR THE BOX ONLY, and this is where it would have crept
		// back onto the prop. Spawn sets the prop's tint to white, but Refresh
		// runs on every power change and would have painted the painted model
		// green — the exact muddying that dropping the tint was meant to avoid,
		// arriving one power flip AFTER everything looked correct.
		if ( _usingProp ) return;

		// ⚠️ KEYED, NOT JUST VALUES — a flipped switch has to look flipped even while the power is
		// still off, or a three-switch map gives the player no way to tell which ones they have
		// already done.
		foreach ( var kv in _props )
		{
			var go = kv.Value;

			// The renderer lives on the "visual" CHILD, not the parent.
			var m = go?.Components.Get<ModelRenderer>(
				FindMode.EverythingInSelfAndDescendants );

			if ( m.IsValid() ) m.Tint = Power.IsOn || Power.IsFlipped( kv.Key ) ? OnTint : OffTint;
		}
	}

	// ── using ────────────────────────────────────────────────────────────────

	/// <summary>The switch the player is looking at, or -1.</summary>
	public int Aimed( NZPlayer player )
	{
		if ( !player.IsValid() ) return -1;

		var cam = Scene.Camera;
		if ( !cam.IsValid() ) return -1;

		// Ignore the player — the camera sits inside their collider, so the ray
		// otherwise stops on the body it started in. See DebrisManager.Aimed.
		var tr = Scene.Trace
			.Ray( cam.WorldPosition, cam.WorldPosition + cam.WorldRotation.Forward * UseRange )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.Run();

		if ( !tr.Hit || tr.GameObject is null ) return -1;

		// ⚠️ Distance from the PLAYER to the surface point, not ray length from
		// the camera — see DebrisManager.Aimed for why those are not the same
		// thing.
		if ( tr.EndPosition.Distance( player.WorldPosition ) > Reach ) return -1;

		foreach ( var (i, go) in _props )
			if ( go == tr.GameObject ) return i;

		return -1;
	}

	/// <summary>
	/// Flip the switch the player is aiming at. Returns a message describing
	/// what happened, same contract as DebrisManager.Buy.
	/// </summary>
	public string Use( NZPlayer player )
	{
		if ( !player.IsValid() ) return "no player";

		var index = Aimed( player );
		if ( index < 0 ) return "not looking at a switch";

		return UseAt( index );
	}

	public string UseAt( int index )
	{
		if ( !_props.ContainsKey( index ) ) return $"no switch #{index}";
		if ( Power.IsOn ) return "power is already on";

		// ⛔ `Flip( index )`, NOT `TurnOn()`. This is the one route that knows WHICH lever the
		// player is standing at, and on a multi-switch map that is the whole question — `TurnOn`
		// means "power the map" and would make every panel a master switch again.
		var msg = Power.Flip( index );
		Refresh();

		return msg;
	}

	// ── the free doors ───────────────────────────────────────────────────────

	/// <summary>
	/// Open every barrier that costs nothing and needs power.
	///
	/// ⚠️ Price 0 + RequiresPower is NOT "a free door you still have to walk up
	/// to". It is the map author saying "the power opens this" — a shutter that
	/// lifts, a gate that swings — so it must open the moment the power comes on
	/// with no interaction at all. A priced door with RequiresPower still has to
	/// be bought; the power only makes it purchasable.
	/// </summary>
	void OpenFreeDoors()
	{
		var list = ActiveConfig.Current.Debris;
		var opened = 0;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = list[i];
			if ( !d.RequiresPower || d.Price != 0 ) continue;
			if ( DoorLinks.IsOpen( d.Link ) ) continue;

			DebrisManager.Instance?.OpenFree( i );
			opened++;
		}

		if ( opened > 0 )
			Log.Info( $"[nz] power opened {opened} free door(s)" );
	}
}