Buyables/TradeTableManager.cs

A scene component that builds and manages in-world trading table entities from configuration. It ensures a singleton instance, creates non-saved GameObjects with a ModelRenderer and TradeTable component for each configured spot, positions and orients them to match floor normals, and exposes Rebuild to refresh them.

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

namespace NZombies;

/// <summary>
/// TRADING TABLES — builds the config's tables into the world.
///
/// ⚠️ Deliberately the same shape as WunderfizzManager / PackAPunchManager / MysteryBoxManager /
/// DebrisManager: Ensure creates on demand, NotSaved keeps it out of the map, Rebuild is the single
/// entry point. A manager refreshed differently from its siblings is one more thing to remember at
/// every call site that puts a config into the world — and this project has already been bitten by
/// one that was.
/// </summary>
public sealed class TradeTableManager : Component
{
	public static TradeTableManager Instance { get; private set; }

	/// <summary>
	/// The real model — the BO2 TranZit workbench, upstream's own choice (`nz_tradetable/init.lua:12`).
	///
	/// ⚠️ WAS `models/dev/box.vmdl` while the mechanic was being built. Ported via Crowbar ->
	/// `qc_to_vmdl.py` -> `prop_vmt_to_vmat.py`; see
	/// `Tools/PROP_IMPORT_ammobox_tradetable.md` for the exact route and what it cost.
	/// </summary>
	public const string ModelPath = "models/zmb/bo2/tranzit/zm_work_bench.vmdl";

	/// <summary>
	/// Extra yaw applied on top of the spot's own facing, in degrees.
	///
	/// ⛔ THE MESH IS NOT BUILT FACING ITS OWN +X. `zm_work_bench` is 32 units across and 88 long
	/// (its QC's `$bbox -16 -44 -0.3  16 44 65`), so the model's forward runs along its SHORT edge —
	/// place it with the shared `OnFloor` yaw and the bench presents its end to you instead of its
	/// working surface. 90 turns it to face across the long side.
	///
	/// ⚠️ HERE RATHER THAN BAKED INTO THE MODEL. `qc_to_vmdl.py` has a `--rot` for exactly that, but
	/// using it would rotate the collision hull and the $bbox check with the mesh and re-run every
	/// other prop in the tree. A yaw on the placement is one number, reversible, and visible to
	/// whoever wonders why this prop has one.
	/// </summary>
	public static float YawOffset { get; set; } = 90f;



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

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

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

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

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

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

		// ⚠️ Says how many are STANDING, not how many are configured. A model that fails to load
		// leaves a spot in the config and nothing in the world, and those two numbers disagreeing is
		// the cheapest way to see it.
		Log.Info( $"[nz] {Built} of {list.Count} trading table(s) built" );
	}

	void Build( TradeTableSpot spot )
	{
		var go = Scene.CreateObject();
		go.Name = "Trading Table";
		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;

		// ⚠️ Yaw from the spot, tilt from the floor normal — so a box on a ramp sits on the ramp
		// rather than through it.
		go.WorldRotation = OnFloor( spot );

		var r = go.Components.Create<ModelRenderer>();
		r.Model = Model.Load( ModelPath );

		// ⛔ NO SCALING. `BoxSize` and the bounds arithmetic here are GONE with the placeholder: a
		// real prop is authored at the size it should be, and rescaling it to a number picked for a
		// dev cube is how you end up with a trading table that is subtly the wrong shape and nobody can
		// say why.
		if ( r.Model is null )
			Log.Warning( $"[nz] trading table model not found: {ModelPath} — nothing will draw" );


		var box = go.Components.Create<TradeTable>();
		box.Spot = spot;

		// ⚠️ THE CONFIG INDEX, NOT A COUNTER OF WHAT SUCCEEDED. A table whose model failed to load
		// still occupies a slot in the list on every other machine, so numbering by "how many have
		// I built so far" would shift every later table's name on exactly the machine that had a
		// problem — and the two would then disagree about which table a weapon was left on.
		box.Index = ActiveConfig.Current?.TradeTables.IndexOf( spot ) ?? -1;

		_built.Add( go );
	}

	/// <summary>
	/// Face the spot's yaw while lying flat on its floor.
	///
	/// ⚠️ LIFTED FROM `WunderfizzManager.OnFloor`, deliberately identical. Projecting the heading
	/// onto the floor plane is what stops a sloped placement from tipping the object over, and two
	/// placeables aligning differently on the same ramp is a bug nobody would think to look for.
	/// </summary>
	static Rotation OnFloor( TradeTableSpot spot )
	{
		// ⚠️ THE OFFSET GOES INTO THE HEADING, NOT ONTO THE RESULT. Multiplying a finished
		// LookAt rotation would spin the bench about the WORLD up, which tips it on a sloped
		// floor; folding the offset into the yaw before the floor projection keeps the tilt
		// doing its job.
		var yaw = spot.Yaw + YawOffset;

		var up = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;
		var heading = Rotation.FromYaw( yaw ).Forward;

		var forward = (heading - up * heading.Dot( up )).Normal;
		if ( forward.IsNearlyZero() )
			return Rotation.From( 0f, yaw, 0f );

		return Rotation.LookAt( forward, up );
	}
}