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