Game/Shrapnel.cs
namespace Monolith;

/// <summary>
/// Chunks of the monolith thrown out by a volatile detonation, which land on marked ground and
/// take the stage if you are still standing there.
///
/// **It is a MOVER, not a killer, and that is the design.** The first version threw pieces on a
/// fixed ballistic arc and hoped they would hit you, which they almost never did: they flew about
/// 1400 units while the player stands roughly 450 from the shape, so every burst arced over your
/// head and landed behind you. Impressive, and no threat at all.
///
/// What makes it matter is not a better chance of hitting. It is that each piece TELLS YOU where
/// it is going to land, and that place is near you. Without a telegraph this is a die roll:
/// either a chunk happens to be where you are or it does not, and neither outcome is a decision.
/// Drawn as a ring on the floor it becomes a place you must not be standing in a moment, which is
/// a decision, and one that competes with everything else asking you to be somewhere.
///
/// **The danger is mostly what it moves you into.** Being made to leave a spot is only
/// interesting because of what else is out there: a floor tile part way through its amber
/// warning, a crawler winding up, a violet slab you were routing around. Shrapnel does not have
/// to be lethal often. It has to be lethal ENOUGH that you always leave, and leaving is what puts
/// you in front of the rest of the game.
///
/// **You cause it.** Nothing spawns this but your own shot landing on a red cube, which is the
/// skill shot the Instability upgrade sells you more of. So the biggest reward in ordinary fire
/// carries its own risk, and a bigger blast grows the payoff and the danger together.
/// </summary>
public sealed class Shrapnel : Component
{
	private static Model cubeModel;
	private static Model ringModel;
	private static int liveCount;

	/// <summary>
	/// Shared across every piece, exactly like the Crawler grab cooldown and for the same reason:
	/// a single detonation throws a dozen chunks, and without this one unlucky moment would be
	/// charged to you a dozen times over.
	/// </summary>
	private static GameTimeSince timeSinceAnyHit = 99f;

	private Vector3 velocity;
	private Vector3 spin;
	private float size;
	private GameTimeSince timeSinceSpawn;
	private ModelRenderer renderer;
	private PointLight glow;
	private float floorZ;

	/// <summary>Where this piece is going to land. Drifts toward the player as it flies.</summary>
	private Vector3 target;

	/// <summary>Total seconds from launch to impact. Fixed, so the arc can be solved exactly.</summary>
	private float flightTime;

	/// <summary>The ring drawn on the floor where it will land.</summary>
	private GameObject marker;
	private ModelRenderer markerRenderer;

	public static IReadOnlyList<Shrapnel> All => all;
	private static readonly List<Shrapnel> all = new();

	/// <summary>
	/// Throws a burst from a volatile detonation, aimed at the ground around the player.
	/// </summary>
	/// <remarks>
	/// Pieces are pushed OUTWARD from the arena centre through the detonation, then scattered
	/// along and around that lane. Outward is measured from the centre rather than from the
	/// surface normal on purpose: the normal sends pieces off whichever face was hit, including
	/// back through the middle of the monolith, while this always sweeps them across the open
	/// floor. Some land short of the player and some long, so the pattern is a spread you have to
	/// leave rather than a single dot to sidestep.
	/// </remarks>
	public static void Burst( Scene scene, Vector3 centre, float worldRadius, float floorZ )
	{
		if ( scene == null )
			return;

		var manager = MonolithManager.Instance;
		var arenaCentre = manager.IsValid() && manager.World != null
			? manager.World.WorldBounds.Center
			: centre;

		var outward = (centre - arenaCentre).WithZ( 0f );

		// A detonation dead in the middle has no outward direction to speak of. Pick one.
		outward = outward.Length < 1f
			? new Vector3( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ), 0f ).Normal
			: outward.Normal;

		var player = scene.GetAllComponents<PlayerMovement>().FirstOrDefault();

		var aim = player.IsValid()
			? player.WorldPosition.WithZ( floorZ )
			: centre + outward * 600f;

		int count = Math.Clamp(
			(int)(worldRadius / Tuning.ShrapnelPerRadius), 3, Tuning.ShrapnelMaxPerBurst );

		for ( int i = 0; i < count; i++ )
		{
			if ( liveCount >= Tuning.ShrapnelMaxLive )
				return;

			var obj = new GameObject( true, "Shrapnel" );
			obj.NetworkMode = NetworkMode.Never;
			obj.WorldPosition = centre + Vector3.Random.Normal * (worldRadius * 0.3f);
			obj.WorldRotation = Rotation.Random;

			var piece = obj.AddComponent<Shrapnel>();

			piece.size = Tuning.VoxelSize * Game.Random.Float( 1.4f, 2.6f );
			obj.WorldScale = piece.size;

			float yaw = Game.Random.Float(
				-Tuning.ShrapnelSpreadDegrees, Tuning.ShrapnelSpreadDegrees );

			var lane = Rotation.FromAxis( Vector3.Up, yaw ) * outward;

			var scatter = new Vector3(
				Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ), 0f );

			piece.target = (aim
				+ lane * Game.Random.Float( -Tuning.ShrapnelScatter, Tuning.ShrapnelScatter )
				+ scatter * Tuning.ShrapnelScatter).WithZ( floorZ );

			piece.flightTime = Game.Random.Float(
				Tuning.ShrapnelFlightTime * 0.85f, Tuning.ShrapnelFlightTime * 1.15f );

			piece.spin = Vector3.Random.Normal * Game.Random.Float( 90f, 300f );
			piece.floorZ = floorZ;

			var mr = obj.AddComponent<ModelRenderer>();
			mr.Model = GetCubeModel();
			mr.Tint = new Color( 1f, 0.35f, 0.1f ) * 2.2f;
			piece.renderer = mr;

			// Its own light. This has to be readable against a black void while you are looking
			// somewhere else, and a tint alone does not carry that far.
			var light = obj.AddComponent<PointLight>();
			light.LightColor = new Color( 1f, 0.4f, 0.12f ) * 3f;
			light.Radius = 300f;
			light.Shadows = false;
			piece.glow = light;

			piece.Solve();
			piece.MakeMarker();

			liveCount++;
		}
	}

	/// <summary>Wipes every piece. Called when a stage changes under us.</summary>
	public static void ClearAll()
	{
		foreach ( var piece in all.ToList() )
			piece?.GameObject?.Destroy();

		// NOT a loop on `all.Count`: Destroy is deferred, so the list does not shrink until the
		// end of the frame and waiting for it here would hang. This bug has been written four
		// times in this project already.
		all.Clear();
		liveCount = 0;
		timeSinceAnyHit = 99f;
	}

	protected override void OnStart()
	{
		timeSinceSpawn = 0;
		all.Add( this );
	}

	protected override void OnDestroy()
	{
		marker?.Destroy();
		marker = null;

		all.Remove( this );
		liveCount = Math.Max( 0, liveCount - 1 );
	}

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

		float t = Math.Clamp( timeSinceSpawn / flightTime, 0f, 1f );

		if ( t >= 1f )
		{
			Impact();
			return;
		}

		Drift();
		Solve();

		velocity -= Vector3.Up * Tuning.ShrapnelGravity * GameTime.Delta;
		WorldPosition += velocity * GameTime.Delta;

		WorldRotation *= Rotation.From(
			spin.x * GameTime.Delta, spin.y * GameTime.Delta, spin.z * GameTime.Delta );

		UpdateMarker( t );

		if ( renderer.IsValid() )
			renderer.Tint = Color.Lerp(
				new Color( 1f, 0.35f, 0.1f ) * 2.2f,
				new Color( 1f, 0.2f, 0.05f ) * 2.8f,
				t );

		if ( glow.IsValid() )
			glow.LightColor = new Color( 1f, 0.4f, 0.12f ) * (2f + t * 2f);

		CheckHit();
	}

	/// <summary>
	/// Solves the arc so the piece arrives at <see cref="target"/> exactly when its flight time
	/// runs out.
	/// </summary>
	/// <remarks>
	/// Recomputed every frame against the REMAINING time rather than once at launch. That is what
	/// lets the target drift toward the player without the landing ring becoming a lie: the piece
	/// re-aims continuously, so wherever the ring is drawn is genuinely where it will land.
	///
	/// It is also why the flight is expressed as a duration rather than a speed. Solving for a
	/// fixed arrival time is two divisions. Solving a launch speed for a given range is a
	/// quadratic with two roots and a failure case when the target is out of reach.
	/// </remarks>
	private void Solve()
	{
		float remaining = MathF.Max( 0.06f, flightTime - timeSinceSpawn );

		var delta = target - WorldPosition;

		float vz = (delta.z + 0.5f * Tuning.ShrapnelGravity * remaining * remaining) / remaining;

		velocity = new Vector3( delta.x / remaining, delta.y / remaining, vz );
	}

	/// <summary>
	/// Walks the landing point toward the player, slowly.
	/// </summary>
	/// <remarks>
	/// **A capped speed in units per second, deliberately well under a walk.** The player moves
	/// at 420 and this drifts at a fraction of that, so stepping out of a marked ring always works
	/// and standing in one never does. That asymmetry is the entire mechanic: it is not trying to
	/// hit you, it is trying to make you leave.
	/// </remarks>
	private void Drift()
	{
		var player = Scene?.GetAllComponents<PlayerMovement>().FirstOrDefault();
		if ( !player.IsValid() )
			return;

		var wanted = player.WorldPosition.WithZ( floorZ );
		var delta = (wanted - target).WithZ( 0f );

		float step = Tuning.ShrapnelDriftSpeed * GameTime.Delta;

		target = delta.Length <= step
			? wanted
			: target + delta.Normal * step;
	}

	private void MakeMarker()
	{
		marker = new GameObject( true, "Shrapnel Marker" );
		marker.NetworkMode = NetworkMode.Never;
		marker.WorldPosition = target + Vector3.Up * 3f;
		marker.WorldScale = Tuning.ShrapnelImpactRadius;

		markerRenderer = marker.AddComponent<ModelRenderer>();
		markerRenderer.Model = GetRingModel();
	}

	private void UpdateMarker( float t )
	{
		if ( !marker.IsValid() )
			return;

		marker.WorldPosition = target + Vector3.Up * 3f;

		// Brightens and then flashes as impact approaches, so urgency is readable without a
		// number and without looking away from whatever you were aiming at.
		if ( markerRenderer.IsValid() )
			markerRenderer.Tint = Color.Lerp(
				new Color( 1f, 0.5f, 0.1f ) * 1.2f,
				new Color( 1f, 0.15f, 0.05f ) * (2.2f + MathF.Sin( Time.Now * 30f ) * 0.8f),
				t );
	}

	/// <summary>
	/// Lands. The blast is what hurts, not the pebble.
	/// </summary>
	/// <remarks>
	/// Killing on the LANDING rather than on contact in flight is what makes the ring honest: it
	/// says where the danger is, and the danger happens there. Contact in flight still counts, but
	/// it is the rare case rather than the mechanic.
	/// </remarks>
	private void Impact()
	{
		Audio.Play( Audio.Explosion, target, 0.7f, Audio.Vary( 1.25f ) );
		BlastEffect.Spawn( Scene, target, Tuning.ShrapnelImpactRadius, false );

		Hurt( Tuning.ShrapnelImpactRadius, target );

		GameObject.Destroy();
	}

	/// <summary>Direct contact with a piece still in the air. Secondary to the landing.</summary>
	private void CheckHit()
	{
		if ( timeSinceSpawn < Tuning.ShrapnelArmSeconds )
			return;

		Hurt( Tuning.ShrapnelHitRadius + size * 0.5f, WorldPosition );
	}

	private void Hurt( float radius, Vector3 at )
	{
		if ( timeSinceAnyHit < Tuning.ShrapnelHitCooldown )
			return;

		var manager = MonolithManager.Instance;
		if ( !manager.IsValid() )
			return;

		foreach ( var player in Scene.GetAllComponents<PlayerMovement>() )
		{
			// Measured FLAT, with a separate height gate. Comparing full 3D distance would quietly
			// make every impact survivable by hopping over it, and that is the one answer which
			// must not work here: the lethal floor already rewards being airborne, and this exists
			// to make the air cost something too.
			var flat = (player.WorldPosition - at).WithZ( 0f );

			if ( flat.Length > radius )
				continue;

			if ( MathF.Abs( player.WorldPosition.z - floorZ ) > Tuning.ShrapnelImpactHeight )
				continue;

			timeSinceAnyHit = 0f;

			Audio.Play( Audio.Explosion, at, 1f, 0.7f );
			manager.ResetStageProgress( "Caught by shrapnel" );
			return;
		}
	}

	/// <summary>A flat ring, built once, used for every landing marker.</summary>
	private static Model GetRingModel()
	{
		if ( ringModel != null )
			return ringModel;

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

		const int segments = 28;
		const float inner = 0.84f;
		int index = 0;

		for ( int i = 0; i < segments; i++ )
		{
			float a0 = i / (float)segments * MathF.Tau;
			float a1 = (i + 1) / (float)segments * MathF.Tau;

			var o0 = new Vector3( MathF.Cos( a0 ), MathF.Sin( a0 ), 0f );
			var o1 = new Vector3( MathF.Cos( a1 ), MathF.Sin( a1 ), 0f );

			var p0 = o0;
			var p1 = o1;
			var p2 = o1 * inner;
			var p3 = o0 * inner;

			var up = Vector3.Up;
			var tangent = (p1 - p0).Normal;

			vb.Add( new Vertex( p0, up, tangent, new Vector4( 0, 0, 0, 0 ) ) );
			vb.Add( new Vertex( p1, up, tangent, new Vector4( 1, 0, 0, 0 ) ) );
			vb.Add( new Vertex( p2, up, tangent, new Vector4( 1, 1, 0, 0 ) ) );
			vb.Add( new Vertex( p3, up, tangent, 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 );

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

	/// <summary>One unit cube, built once and shared by every piece.</summary>
	private static Model GetCubeModel()
	{
		if ( cubeModel != null )
			return cubeModel;

		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 tangent = MathF.Abs( n.z ) > 0.9f ? Vector3.Forward : Vector3.Up;
			var u = Vector3.Cross( n, tangent ).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 );

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