Game/Projectile.cs
namespace Monolith;

/// <summary>
/// A real projectile, not a tracer. It leaves the muzzle, travels, and the blast happens where
/// and when it actually lands.
///
/// This replaced a cosmetic version where the blast applied on click. That was the safer choice
/// for a game firing twenty times a second, but it meant nothing could ever get in the way of a
/// shot, so interceptors and lead-the-target play were impossible. Travel time is what makes
/// those exist.
///
/// Each projectile marches the voxel grid itself rather than using physics: the monolith has no
/// colliders (see VoxelWorld.TraceRay), so a step-wise grid march is both the only option and
/// the cheap one.
/// </summary>
public sealed class Projectile : Component
{
	private static Model boltModel;
	private static int liveCount;

	/// <summary>
	/// Hard ceiling. Multi-shot plus a high fire rate can otherwise flood the scene.
	///
	/// **This is why the gun went quiet.** At 21 projectiles per shot, 220 is TEN trigger pulls'
	/// worth, and past the cap Fire just returns without spawning anything. It stayed invisible
	/// for a long time because bolts used to die almost immediately: firing was gated on the
	/// crosshair being over rock, so every shot hit something within a few hundred units.
	///
	/// Then I removed that gate so hazards in open sky could be shot at all. Correct fix, but it
	/// means a shot at the sky, or through one of the holes you have just blown in the shape,
	/// now flies for its full lifetime. A few of those per trigger pull and the cap is saturated,
	/// after which most of your shots silently never exist.
	///
	/// Raised, and paired with a much shorter lifetime and an arena cull below so the population
	/// actually turns over instead of just having a higher ceiling to fill.
	/// </summary>
	private const int MaxLive = 900;

	public static int LiveCount => liveCount;

	private Vector3 direction;
	private float speed;
	private float blastRadius;
	private bool isCharge;

	/// <summary>Drone shots cannot break a shield node. See Tuning.ShieldBreakSeconds.</summary>
	private bool fromDrone;
	private GameTimeSince timeSinceSpawn;
	private float maxLifetime;
	private ModelRenderer renderer;
	private Color tint;

	/// <summary>
	/// Fires one projectile. Direction should already be normalised.
	/// </summary>
	/// <param name="essential">
	/// The primary bolt of a volley. These IGNORE the population cap.
	///
	/// The cap used to apply to everything equally, which meant that under pressure it ate whole
	/// trigger pulls at random: you clicked and nothing came out. Since drones draw from the same
	/// global count, a busy player also silently starved their own drones.
	///
	/// Now the shot you actually asked for always exists and only the multi-shot EXTRAS thin out.
	/// The gun therefore always responds; what degrades under load is throughput, which is a
	/// number, rather than responsiveness, which is a feeling.
	/// </param>
	public static void Fire( Scene scene, Vector3 from, Vector3 direction,
		float blastRadius, bool isCharge, bool heavy = false, bool fromDrone = false,
		bool essential = false )
	{
		if ( scene == null )
			return;

		// Essential bolts still have a ceiling, just a far higher one, so a pathological case
		// cannot allocate without bound.
		int ceiling = essential ? MaxLive * 2 : MaxLive;

		if ( liveCount >= ceiling )
			return;

		var obj = new GameObject( true, "Projectile" );
		obj.NetworkMode = NetworkMode.Never;
		obj.WorldPosition = from;
		obj.WorldRotation = Rotation.LookAt( direction );

		var bolt = obj.AddComponent<Projectile>();
		bolt.direction = direction.Normal;
		bolt.speed = heavy ? Tuning.ChargeProjectileSpeed : Tuning.ProjectileSpeed;
		bolt.blastRadius = blastRadius;
		bolt.isCharge = isCharge;
		bolt.fromDrone = fromDrone;
		bolt.maxLifetime = Tuning.ProjectileLifetime;

		bolt.tint = isCharge
			? new Color( 1f, 0.42f, 0.10f )
			: new Color( 1f, 0.78f, 0.34f );

		var mr = obj.AddComponent<ModelRenderer>();
		mr.Model = GetBoltModel();
		mr.Tint = bolt.tint;

		float thickness = heavy ? 6.5f : 3.4f;
		obj.WorldScale = new Vector3( thickness * 5f, thickness, thickness );

		bolt.renderer = mr;
		liveCount++;
	}

	protected override void OnStart()
	{
		timeSinceSpawn = 0;
	}

	protected override void OnDestroy()
	{
		liveCount = Math.Max( 0, liveCount - 1 );
	}

	/// <summary>
	/// Z of the arena floor, so shrapnel lands on it instead of falling through the world.
	/// Read from the player rather than stored: the floor moves with every stage.
	/// </summary>
	private float FloorHeightFor( MonolithManager manager )
	{
		var movement = Scene?.GetAllComponents<PlayerMovement>().FirstOrDefault();

		if ( movement.IsValid() )
			return movement.FloorZ;

		return manager?.World?.WorldBounds.Mins.z ?? 0f;
	}

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

		if ( timeSinceSpawn > maxLifetime )
		{
			GameObject.Destroy();
			return;
		}

		var manager = MonolithManager.Instance;
		if ( !manager.IsValid() || manager.World == null )
		{
			GameObject.Destroy();
			return;
		}

		// Anything past the walls is never coming back. Culling it frees its slot in the live
		// cap immediately rather than after the full lifetime, which is what keeps the
		// population turning over when you are firing into open space.
		var centre = manager.World.WorldBounds.Center;
		float leash = manager.ArenaHalfExtent * 1.5f;

		if ( WorldPosition.Distance( centre ) > leash )
		{
			GameObject.Destroy();
			return;
		}

		float step = speed * Time.Delta;
		var start = WorldPosition;

		// Barriers first: they are solid and unkillable, so nothing gets past one. The lattice is
		// see-through, so the absorb has to be loud: the shot dies exactly on the panel and the
		// whole panel flares, otherwise it reads as the gun failing rather than as being blocked.
		if ( OrbitalBarrier.Blocks( start, direction, step, out var barrier, out var absorbedAt ) )
		{
			barrier.Absorb();

			// Bigger and brighter than a rock hit on purpose. A shot that achieved NOTHING has to
			// look more dramatic than one that worked, or the feedback teaches the wrong lesson:
			// silence reads as "it went through" and you keep firing into a wall.
			BlastEffect.Spawn( Scene, absorbedAt, 210f, true );
			Debris.Burst( Scene, absorbedAt, 90f, false );
			Audio.Play( Audio.MetalHit, absorbedAt, 0.75f, Audio.Vary( 0.62f ) );
			GameObject.Destroy();
			return;
		}

		// The Spotter hovers above everything, so it is checked early. Shooting it down is one
		// of the three answers to a lock and has to actually work under pressure.
		if ( Spotter.TryHit( start, direction, step, out var spotter ) )
		{
			spotter.TakeHit( isCharge ? Tuning.InterceptorChargeDamage : 1 );

			BlastEffect.Spawn( Scene, spotter.WorldPosition, 110f, false );
			GameObject.Destroy();
			return;
		}

		// The shield node hangs in the open above the shape and is YOUR answer to a shielded
		// stage, so a drone shot passes straight through it. If drones could clear their own
		// jam the shield would undo itself and go back to being something you wait out.
		if ( !fromDrone && ShieldShell.TryHitNode( start, direction, step, out var node ) )
		{
			node.Strike();

			BlastEffect.Spawn( Scene, node.NodePosition, 180f, true );
			Audio.Play( Audio.Explosion, node.NodePosition, 0.8f, 1.5f );
			GameObject.Destroy();
			return;
		}

		// Incoming orbs are checked FIRST of all the killables. They are the only thing here on
		// a clock, so a shot aimed at one must never be stolen by something behind it.
		if ( SentinelOrb.TryHit( start, direction, step, out var orb ) )
		{
			orb.Shatter();
			GameObject.Destroy();
			return;
		}

		if ( Sentinel.TryHit( start, direction, step, out var sentinel ) )
		{
			sentinel.TakeHit( isCharge ? Tuning.InterceptorChargeDamage : 1 );

			BlastEffect.Spawn( Scene, sentinel.WorldPosition, 110f, false );
			GameObject.Destroy();
			return;
		}

		if ( Crawler.TryHit( start, direction, step, out var crawler ) )
		{
			crawler.TakeHit( isCharge ? Tuning.InterceptorChargeDamage : 1 );

			BlastEffect.Spawn( Scene, crawler.WorldPosition, 90f, false );
			GameObject.Destroy();
			return;
		}

		if ( Anchor.TryHit( start, direction, step, out var anchor ) )
		{
			anchor.TakeHit( isCharge ? Tuning.InterceptorChargeDamage : 1 );

			BlastEffect.Spawn( Scene, anchor.WorldPosition, 100f, false );
			GameObject.Destroy();
			return;
		}

		// Leeches sit on the surface, so they are checked before the rock they are clamped to,
		// otherwise you could never hit one.
		if ( Leech.TryHit( start, direction, step, out var leech ) )
		{
			leech.TakeHit( isCharge ? Tuning.InterceptorChargeDamage : 1 );

			BlastEffect.Spawn( Scene, leech.WorldPosition, 80f, false );
			GameObject.Destroy();
			return;
		}

		// Interceptors are checked next: something physically in the way should stop the shot
		// before it can reach the rock behind it.
		if ( Interceptor.TryIntercept( Scene, start, direction, step, out var blocker ) )
		{
			blocker.TakeHit( isCharge ? Tuning.InterceptorChargeDamage : 1 );

			BlastEffect.Spawn( Scene, blocker.WorldPosition, 90f, false );
			GameObject.Destroy();
			return;
		}

		if ( manager.World.TraceRay( start, direction, step, out var hit ) )
		{
			Impact( manager, hit.Voxel );
			return;
		}

		WorldPosition = start + direction * step;
	}

	private void Impact( MonolithManager manager, Vector3Int voxel )
	{
		var progress = PlayerProgress.Local;

		// A volatile cube only goes off when a shot lands squarely on it. This is now the ONLY
		// source of a big detonation from ordinary fire, so aiming at the red blocks is the
		// skill the Instability upgrade buys you more of.
		var impact = manager.World.VoxelToWorld( voxel.x, voxel.y, voxel.z );

		if ( Stages.IsVolatile( voxel.x, voxel.y, voxel.z ) )
		{
			float volatileRadius = progress.IsValid()
				? progress.VolatileRadius
				: Tuning.VolatileRadiusBase;

			manager.RequestBlast( voxel, volatileRadius, true );

			// A volatile hit is the skill shot in ordinary fire, so it gets the loudest
			// confirmation an ordinary shot can earn.
			Audio.Play( Audio.Explosion, impact, 0.75f, Audio.Vary( 1.15f ) );

			// AND IT THROWS THE ROCK AT YOU. The best shot in ordinary fire is now also the one
			// that creates a hazard, so a bigger Instability radius grows the payoff and the
			// danger together rather than the payoff alone.
			Shrapnel.Burst( Scene, impact,
				volatileRadius * Tuning.VoxelSize, FloorHeightFor( manager ) );

			if ( progress.IsValid() )
			{
				progress.Data.VolatileDetonations++;
				progress.AddResonance();
			}
		}
		else
		{
			manager.RequestBlast( voxel, blastRadius, isCharge );

			// Rock breaking. Quiet and pitched up for drone fire so a flock of them stays a
			// texture rather than a wall of noise, and always varied: this plays many times a
			// second and an unvaried sample becomes unbearable faster than any other in the game.
			Audio.Play( Audio.RockHit, impact,
				fromDrone ? 0.16f : 0.34f,
				Audio.Vary( fromDrone ? 1.5f : 1.0f ) );

			// Charges feed the chain; ordinary shots do not, or it would never lapse.
			if ( isCharge && progress.IsValid() )
				progress.AddResonance();
		}

		GameObject.Destroy();
	}

	/// <summary>Shared with the demolition charge so both read as the same kind of object.</summary>
	public static Model SharedBoltModel => GetBoltModel();

	/// <summary>A unit cube, stretched per bolt. Built once and shared by every projectile.</summary>
	private static Model GetBoltModel()
	{
		if ( boltModel != null )
			return boltModel;

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

		Vector3[] normals =
		{
			Vector3.Forward, Vector3.Backward, Vector3.Left,
			Vector3.Right, Vector3.Up, Vector3.Down,
		};

		int index = 0;

		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 centre = n * 0.5f;

			var p0 = centre - u * 0.5f - v * 0.5f;
			var p1 = centre + u * 0.5f - v * 0.5f;
			var p2 = centre + u * 0.5f + v * 0.5f;
			var p3 = centre - u * 0.5f + v * 0.5f;

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

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

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