Game/Leech.cs
namespace Monolith;

/// <summary>
/// A parasite that clamps onto the shape and siphons your dust income, storing everything it
/// takes. Kill it and you get the whole hoard back with interest.
///
/// The decision it creates is the point: popping one immediately is safe and small, leaving it
/// to fatten pays far more but throttles your income the entire time it sits there. That is
/// Cookie Clicker's wrinkler, and it is one of the few mechanics in that game where the optimal
/// play is to deliberately let something bad happen to you.
///
/// **It never eats cubes.** Removal from the shape stays tied to player participation, per an
/// explicit rule; a leech costs you income instead, which preserves the tension without
/// breaking that.
/// </summary>
public sealed class Leech : Component
{
	private static readonly List<Leech> all = new();
	public static IReadOnlyList<Leech> All => all;

	private static Model bodyModel;

	[Property] public float Radius { get; set; } = 40f;

	/// <summary>Dust this leech has taken and is holding.</summary>
	public double Stored { get; private set; }

	public int Health { get; private set; } = Tuning.LeechHealth;

	private ModelRenderer renderer;
	private GameTimeSince timeSinceHit = 99f;
	private float bobPhase;

	/// <summary>Total fraction of income currently being siphoned, across every leech.</summary>
	public static float TotalSiphon
		=> MathF.Min( Tuning.LeechSiphonMax, all.Count * Tuning.LeechSiphonEach );

	public static Leech Spawn( Scene scene, Vector3 position )
	{
		if ( scene == null ) return null;

		var obj = new GameObject( true, "Leech" );
		obj.NetworkMode = NetworkMode.Never;
		obj.WorldPosition = position;
		obj.WorldRotation = Rotation.Random;

		var leech = obj.AddComponent<Leech>();
		leech.bobPhase = Game.Random.Float( 0f, 10f );
		obj.WorldScale = leech.Radius * 2f;

		var mr = obj.AddComponent<ModelRenderer>();
		mr.Model = GetBodyModel();
		mr.Tint = new Color( 0.35f, 0.85f, 0.42f );

		leech.renderer = mr;
		return leech;
	}

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

	/// <summary>Splits siphoned dust evenly across every attached leech.</summary>
	public static void Distribute( double amount )
	{
		if ( amount <= 0 || all.Count == 0 )
			return;

		double each = amount / all.Count;

		for ( int i = 0; i < all.Count; i++ )
			all[i].Stored += each;
	}

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

		// Swell as it feeds, so a fat leech is obviously worth more than a fresh one.
		float fed = (float)Math.Clamp( Stored / 5000.0, 0f, 1f );
		float bob = MathF.Sin( Time.Now * 1.8f + bobPhase ) * 0.06f;

		WorldScale = Radius * 2f * (1f + fed * 0.9f + bob);
		WorldRotation *= Rotation.From( 0f, 18f * Time.Delta, 0f );

		if ( renderer.IsValid() )
		{
			float flash = timeSinceHit < 0.12f ? 0.8f : 0f;

			renderer.Tint = Color.Lerp(
				new Color( 0.30f, 0.75f, 0.35f ),
				new Color( 0.85f, 1f, 0.30f ),
				fed ) + new Color( flash, flash, flash * 0.4f );
		}
	}

	/// <summary>Sphere test for one projectile step. Same shape as interceptor testing.</summary>
	public static bool TryHit( Vector3 start, Vector3 direction, float distance, out Leech hit )
	{
		hit = null;
		float best = float.MaxValue;

		for ( int i = 0; i < all.Count; i++ )
		{
			var candidate = all[i];
			if ( !candidate.IsValid() ) continue;

			var toCentre = candidate.WorldPosition - start;
			float along = Vector3.Dot( toCentre, direction );

			if ( along < -candidate.Radius || along > distance + candidate.Radius )
				continue;

			float perpSq = toCentre.LengthSquared - along * along;
			if ( perpSq > candidate.Radius * candidate.Radius )
				continue;

			if ( along < best )
			{
				best = along;
				hit = candidate;
			}
		}

		return hit != null;
	}

	public void TakeHit( int damage )
	{
		Health -= damage;
		timeSinceHit = 0;

		if ( Health > 0 )
			return;

		double payout = Stored * Tuning.LeechPayoutBonus;

		var progress = PlayerProgress.Local;
		if ( progress.IsValid() && payout > 0 )
		{
			progress.AwardDust( payout );
			progress.AddResonance();
			progress.Data.LeechesPopped++;

			Log.Info( $"Leech popped for {Num.Short( payout )} dust " +
				$"({Num.Short( Stored )} siphoned x{Tuning.LeechPayoutBonus})." );
		}

		BlastEffect.Spawn( Scene, WorldPosition, 260f, true );
		Debris.Burst( Scene, WorldPosition, 150f, true );

		GameObject.Destroy();
	}

	/// <summary>A lumpy blob: an octahedron squashed on one axis, distinct from everything else.</summary>
	private static Model GetBodyModel()
	{
		if ( bodyModel != null )
			return bodyModel;

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

		Vector3[] tips = { Vector3.Up * 0.62f, Vector3.Down * 0.38f };
		Vector3[] ring =
		{
			Vector3.Forward * 0.5f, Vector3.Right * 0.42f,
			Vector3.Backward * 0.5f, Vector3.Left * 0.42f,
		};

		int index = 0;

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

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

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