Game/MiningDrone.cs
namespace Monolith;

/// <summary>
/// A mining drone: a small angular machine that hangs in an arch above you and fires at the
/// shape.
///
/// This replaced an attempt at floating Terry heads, which failed for two reasons worth
/// recording so nobody tries it again:
///
///   1. **Clothing is not a bodygroup.** Hiding the Chest / Legs / Feet / Hands bodygroups
///      hides the BODY, but every clothing item is a separate model parented to a bone. A
///      head-only citizen in a coat is a coat with nothing inside it, which is worse than
///      either extreme.
///   2. `ClothingContainer.Apply` sets bodygroups itself, so applying the player's outfit
///      after switching them off simply switched them back on.
///
/// The result on screen was a ring of full-size citizens crowding the camera. A purpose-built
/// mesh avoids the whole problem: it is small, it is legible at a glance, and it costs one
/// hand-rolled vertex buffer.
///
/// They sit in a RAINBOW, an arch spanning left to right above the player and rising in the
/// middle, rather than a ring around them. A ring puts a drone between you and the shape no
/// matter which way you face; an arch overhead is always in view in third person and never in
/// the way of the crosshair.
/// </summary>
public sealed class MiningDrone : Component
{
	private static readonly List<MiningDrone> all = new();
	public static IReadOnlyList<MiningDrone> All => all;

	private static Model droneModel;

	/// <summary>Width of the arch, left tip to right tip.</summary>
	[Property] public float ArcWidth { get; set; } = 340f;

	/// <summary>How high the ends of the arch sit above the eye.</summary>
	[Property] public float ArcBaseHeight { get; set; } = 70f;

	/// <summary>Extra height at the peak. This is what makes it a rainbow rather than a line.</summary>
	[Property] public float ArcLift { get; set; } = 130f;

	/// <summary>How far in front of the eye the arch sits. Small: it is overhead, not ahead.</summary>
	[Property] public float ArcForward { get; set; } = 30f;

	/// <summary>Body size, in world units. Deliberately tiny next to a 64 unit player.</summary>
	[Property] public float Size { get; set; } = 15f;

	/// <summary>Where its bolt leaves from: just off the nose.</summary>
	public Vector3 MuzzlePosition => WorldPosition + WorldRotation.Forward * (Size * 0.9f);

	private ModelRenderer renderer;
	private PointLight glow;

	private float bobPhase;
	private GameTimeSince timeSinceFired = 99f;

	public static MiningDrone Spawn( Scene scene )
	{
		if ( scene == null ) return null;

		var obj = new GameObject( true, "Mining Drone" );
		obj.NetworkMode = NetworkMode.Never;

		var drone = obj.AddComponent<MiningDrone>();
		drone.bobPhase = Game.Random.Float( 0f, 10f );

		var mr = obj.AddComponent<ModelRenderer>();
		mr.Model = GetDroneModel();
		drone.renderer = mr;

		var light = obj.AddComponent<PointLight>();
		light.LightColor = new Color( 1f, 0.72f, 0.3f ) * 2f;
		light.Radius = 200f;
		light.Shadows = false;
		drone.glow = light;

		obj.WorldScale = drone.Size;

		return drone;
	}

	protected override void OnEnabled() => all.Add( this );
	protected override void OnDisabled() => all.Remove( this );

	/// <summary>Called when this drone takes a shot, so it flashes and you can see which fired.</summary>
	public void OnFired() => timeSinceFired = 0f;

	/// <summary>
	/// Places this drone on the arch and points it at whatever is being mined.
	///
	/// Position is written here rather than by parenting to the player. A parented drone would
	/// inherit your PITCH, so looking down would drive the whole arch into the floor. Only the
	/// yaw should carry.
	/// </summary>
	public void Follow( Vector3 eye, float yaw, Vector3 lookAt, int index, int total )
	{
		// Spread across the arch. A single drone sits at the peak.
		float t = total <= 1 ? 0.5f : index / (float)(total - 1);

		bobPhase += Time.Delta * 1.7f;

		// Sine gives zero at both ends and one in the middle, which is the rainbow.
		float lift = MathF.Sin( t * MathF.PI );

		var local = new Vector3(
			ArcForward,
			(0.5f - t) * ArcWidth,
			ArcBaseHeight + lift * ArcLift + MathF.Sin( bobPhase ) * 6f );

		var target = eye + Rotation.FromYaw( yaw ) * local;

		// Eases in so a drone that has just been bought, or been left behind by a dash, flies
		// into formation instead of appearing in it.
		WorldPosition = Vector3.Lerp( WorldPosition, target, MathF.Min( 1f, Time.Delta * 8f ) );

		var toTarget = lookAt - WorldPosition;

		if ( toTarget.Length > 1f )
			WorldRotation = Rotation.LookAt( toTarget.Normal );

		WorldScale = Size;
	}

	protected override void OnUpdate()
	{
		// Frozen while a blocking screen or the pause menu is up. See GameTime.
		if ( GameTime.Paused )
			return;

		float flash = MathF.Max( 0f, 1f - timeSinceFired / 0.12f );

		// Idles as a dim ember and flares on firing, so the arch reads as a row of working
		// machines rather than decoration.
		if ( renderer.IsValid() )
			renderer.Tint = new Color( 1f, 0.62f, 0.24f ) * (1.5f + flash * 5f);

		if ( glow.IsValid() )
			glow.LightColor = new Color( 1f, 0.72f, 0.3f ) * (1.4f + flash * 7f);
	}

	/// <summary>
	/// Grows or shrinks the arch to match the Drones upgrade.
	/// </summary>
	/// <remarks>
	/// **`GameObject.Destroy()` IS DEFERRED.** The object is not removed from <c>all</c> until
	/// the end of the frame, so a loop written as
	///
	///     while ( all.Count > wanted ) all[^1].GameObject.Destroy();
	///
	/// never terminates: the count it is waiting on cannot change until the frame it is blocking
	/// completes. **That was the collapse hang.** Collapse resets upgrade levels, drones drop
	/// from nine to zero, and the game locks up on the spot - which is why it only ever happened
	/// on Collapse, the one moment a population SHRINKS.
	///
	/// Trimming is now a bounded for-loop over a snapshot, so it terminates regardless of when
	/// the engine actually reaps the objects. **Never loop on a collection that deferred
	/// destruction is supposed to shrink.**
	/// </remarks>
	public static void MatchPopulation( Scene scene, int wanted )
	{
		if ( all.Count > wanted )
		{
			var doomed = all.Skip( Math.Max( 0, wanted ) ).ToList();

			foreach ( var drone in doomed )
			{
				if ( drone.IsValid() )
					drone.GameObject.Destroy();
			}

			// The list will not shrink until end of frame, so there is nothing left to add.
			return;
		}

		while ( all.Count < wanted )
			Spawn( scene );
	}

	/// <summary>
	/// A unit drone: a squat diamond hull with a stubby barrel out the front, so it has an
	/// obvious face and you can tell which way it is aiming.
	/// </summary>
	private static Model GetDroneModel()
	{
		if ( droneModel != null )
			return droneModel;

		var vb = new VertexBuffer();
		vb.Init( true );

		int index = 0;

		// Hull: an octahedron squashed along Z.
		Vector3[] tips = { Vector3.Up * 0.45f, Vector3.Down * 0.45f };
		Vector3[] ring =
		{
			new( 0.55f, 0f, 0f ), new( 0f, 0.5f, 0f ), new( -0.5f, 0f, 0f ), new( 0f, -0.5f, 0f ),
		};

		foreach ( var tip in tips )
		{
			for ( int i = 0; i < ring.Length; i++ )
			{
				var a = ring[i];
				var b = ring[(i + 1) % ring.Length];
				var (p1, p2) = tip.z > 0 ? (a, b) : (b, a);

				var normal = Vector3.Cross( p2 - tip, p1 - tip ).Normal;
				var tangent = (p1 - tip).Normal;

				vb.Add( new Vertex( tip, normal, tangent, new Vector4( 0.5f, 0, 0, 0 ) ) );
				vb.Add( new Vertex( p1, normal, tangent, new Vector4( 0, 1, 0, 0 ) ) );
				vb.Add( new Vertex( p2, normal, tangent, new Vector4( 1, 1, 0, 0 ) ) );

				vb.AddRawIndex( index + 0 );
				vb.AddRawIndex( index + 1 );
				vb.AddRawIndex( index + 2 );
				index += 3;
			}
		}

		// Barrel out along +X.
		AddBox( vb, ref index, new Vector3( 0.7f, 0f, 0f ), new Vector3( 0.5f, 0.16f, 0.16f ) );

		var mesh = new Mesh( Material.Load( "materials/default.vmat" ) );
		mesh.CreateBuffers( vb );

		droneModel = new ModelBuilder().AddMesh( mesh ).Create();
		return droneModel;
	}

	private static void AddBox( VertexBuffer vb, ref int index, Vector3 centre, Vector3 size )
	{
		Vector3[] normals =
		{
			Vector3.Forward, Vector3.Backward, Vector3.Left,
			Vector3.Right, Vector3.Up, Vector3.Down,
		};

		foreach ( var n in normals )
		{
			var reference = MathF.Abs( n.z ) > 0.9f ? Vector3.Forward : Vector3.Up;
			var u = Vector3.Cross( n, reference ).Normal;
			var v = Vector3.Cross( n, u ).Normal;

			var face = centre + n * 0.5f * Project( size, n );

			var du = u * 0.5f * Project( size, u );
			var dv = v * 0.5f * Project( size, v );

			var p0 = face - du - dv;
			var p1 = face + du - dv;
			var p2 = face + du + dv;
			var p3 = face - du + dv;

			vb.Add( new Vertex( p0, n, u, new Vector4( 0, 0, 0, 0 ) ) );
			vb.Add( new Vertex( p1, n, u, new Vector4( 1, 0, 0, 0 ) ) );
			vb.Add( new Vertex( p2, n, u, new Vector4( 1, 1, 0, 0 ) ) );
			vb.Add( new Vertex( p3, n, u, new Vector4( 0, 1, 0, 0 ) ) );

			vb.AddRawIndex( index + 0 ); vb.AddRawIndex( index + 1 ); vb.AddRawIndex( index + 2 );
			vb.AddRawIndex( index + 0 ); vb.AddRawIndex( index + 2 ); vb.AddRawIndex( index + 3 );

			index += 4;
		}
	}

	private static float Project( Vector3 size, Vector3 axis )
		=> MathF.Abs( axis.x ) * size.x + MathF.Abs( axis.y ) * size.y + MathF.Abs( axis.z ) * size.z;
}