BreedingSystem.cs

BreedingSystem for a tank game, implements passive guppy breeding: courtship between two adults, pregnancy timer on a parent, laying eggs, egg drifting and hatching into guppies, plus short-lived VFX particles and save/load for eggs.

File Access
namespace NoChillquarium;

/// <summary>One egg drifting in the tank until it hatches into a guppy.</summary>
public sealed class TankEgg
{
	public string Key { get; set; }
	public float X { get; set; }
	public float Y { get; set; }
	public float Life { get; set; }
	public float HatchAt { get; set; } = 16f;
	public float BobPhase { get; set; }
	/// <summary>0–1 hatch progress.</summary>
	public float Progress => Math.Clamp( Life / MathF.Max( 0.5f, HatchAt ), 0f, 1f );
}

/// <summary>Short-lived heart / bubble / hatch spark for tank VFX.</summary>
public sealed class BreedParticle
{
	public string Key { get; set; }
	public string Kind { get; set; } = "heart"; // heart | bubble | hatch
	public float X { get; set; }
	public float Y { get; set; }
	public float Vx { get; set; }
	public float Vy { get; set; }
	public float Life { get; set; }
	public float MaxLife { get; set; } = 1.2f;
	public float Norm => Math.Clamp( Life / MathF.Max( 0.05f, MaxLife ), 0f, 1f );
}

/// <summary>
/// Very rare passive breeding: two healthy adults court → one gets fat/pregnant →
/// lays eggs → eggs hatch into guppies (if tank has room). Animated in the glass.
/// </summary>
public static class BreedingSystem
{
	/// <summary>Mean seconds between courtship attempts when a tank is eligible (~6 min).</summary>
	const float MeanMateIntervalSeconds = 6f * 60f;
	/// <summary>Global cooldown after any successful court so they don't chain.</summary>
	const float GlobalMateCooldownSeconds = 2.5f * 60f;
	const float CourtDuration = 3.2f;
	const float PregnantMin = 22f;
	const float PregnantMax = 42f;
	const float EggHatchMin = 8f;
	const float EggHatchMax = 14f;
	static int _hatchCount;
	const int MaxEggs = 8;
	const int MaxParticles = 28;

	static readonly Random _rng = new();
	static readonly List<TankEgg> _eggs = new();
	static readonly List<BreedParticle> _particles = new();

	static int _version;
	static float _globalCooldown;
	static float _courtTimer;
	static string _courtIdA = "";
	static string _courtIdB = "";
	static float _courtMx;
	static float _courtMy;
	static WaterType _courtWater = WaterType.Fresh;
	static float _layFlash;
	static float _layX;
	static float _layY;

	public static int Version => _version;
	public static IReadOnlyList<TankEgg> Eggs => _eggs;
	public static IReadOnlyList<BreedParticle> Particles => _particles;
	public static bool IsCourting => _courtTimer > 0.02f;
	public static float CourtNorm =>
		IsCourting ? Math.Clamp( 1f - _courtTimer / CourtDuration, 0f, 1f ) : 0f;
	public static float CourtMidX => _courtMx;
	public static float CourtMidY => _courtMy;
	public static string CourtIdA => _courtIdA;
	public static string CourtIdB => _courtIdB;
	/// <summary>Brief pink pulse when eggs drop.</summary>
	public static float LayFlash => _layFlash;
	public static float LayX => _layX;
	public static float LayY => _layY;
	public static int HatchCount => _hatchCount;

	public static void Clear()
	{
		_eggs.Clear();
		_particles.Clear();
		_globalCooldown = 0f;
		_courtTimer = 0f;
		_courtIdA = "";
		_courtIdB = "";
		_layFlash = 0f;
		_hatchCount = 0;
		_version++;
	}

	public static bool IsCourtingFish( string instanceId ) =>
		IsCourting
		&& !string.IsNullOrEmpty( instanceId )
		&& ( instanceId == _courtIdA || instanceId == _courtIdB );

	/// <summary>Tick for one tank (call from TankSim.TickSlot).</summary>
	public static void TickSlot( TankSlot slot, float dt, bool visual )
	{
		if ( slot is null || dt <= 0f )
			return;
		if ( dt > 0.05f )
			dt = 0.05f;

		var dirty = false;
		var isActiveView = slot == TankSim.ActiveSlotOrNull;

		// Once-per-frame VFX timers (only on the tank being viewed).
		if ( isActiveView )
		{
			if ( _layFlash > 0f )
			{
				_layFlash = MathF.Max( 0f, _layFlash - dt * 1.4f );
				dirty = true;
			}
			if ( !IsCourting && _globalCooldown > 0f )
				_globalCooldown = MathF.Max( 0f, _globalCooldown - dt );
			dirty |= TickParticles( dt );
		}

		// Courtship lives on the pair's water (fresh).
		if ( IsCourting && slot.Water == _courtWater )
		{
			TickCourt( slot, dt );
			dirty = true;
		}

		// Pregnancy + cooldowns on this tank's fish.
		for ( var i = 0; i < slot.Fish.Count; i++ )
		{
			var f = slot.Fish[i];
			if ( f is null )
				continue;

			if ( f.MateCooldown > 0f )
				f.MateCooldown = MathF.Max( 0f, f.MateCooldown - dt );

			if ( f.PregnantTimer > 0f )
			{
				// Stay visibly egg-fat while carrying.
				f.Fat = Math.Clamp( MathF.Max( f.Fat, 78f + (1f - f.PregnantProgress) * 14f ), 0f, 100f );
				f.Vx *= 1f - 0.35f * dt;
				f.Vy *= 1f - 0.35f * dt;
				f.PregnantTimer -= dt;
				dirty = true;

				// Soft heart puff mid-pregnancy.
				if ( visual && _rng.NextDouble() < dt * 0.35 )
					SpawnParticle( "heart", f.X, f.DrawY - 6f, 0f, -18f - (float)_rng.NextDouble() * 12f, 1.1f );

				if ( f.PregnantTimer <= 0f )
				{
					f.PregnantTimer = 0f;
					LayEggs( slot, f, visual );
					dirty = true;
				}
			}
		}

		// Eggs / rare mate rolls are fresh-only (guppy fry).
		if ( slot.Water == WaterType.Fresh )
		{
			dirty |= TickEggs( slot, dt, visual );
			if ( !IsCourting && _globalCooldown <= 0f )
				dirty |= TryRollMate( slot, dt, visual );
		}

		if ( dirty )
			_version++;
	}

	static void TickCourt( TankSlot slot, float dt )
	{
		var a = TankSim.FindFishInSlot( slot, _courtIdA );
		var b = TankSim.FindFishInSlot( slot, _courtIdB );
		if ( a is null || b is null || a.OnAdventure || b.OnAdventure )
		{
			// Partner left — abort quietly.
			_courtTimer = 0f;
			_courtIdA = "";
			_courtIdB = "";
			return;
		}

		_courtMx = (a.X + b.X) * 0.5f;
		_courtMy = (a.DrawY + b.DrawY) * 0.5f;

		// Pull them toward each other (court dance).
		PullToward( a, _courtMx, (a.Y + b.Y) * 0.5f, dt, 2.8f );
		PullToward( b, _courtMx, (a.Y + b.Y) * 0.5f, dt, 2.8f );
		a.BobAmp = MathF.Min( 10f, a.BobAmp + 4f * dt );
		b.BobAmp = MathF.Min( 10f, b.BobAmp + 4f * dt );

		if ( _rng.NextDouble() < dt * 4.5 )
		{
			var ox = _courtMx + (float)(_rng.NextDouble() * 20.0 - 10.0);
			var oy = _courtMy + (float)(_rng.NextDouble() * 12.0 - 6.0);
			SpawnParticle( "heart", ox, oy, (float)(_rng.NextDouble() * 16.0 - 8.0), -22f - (float)_rng.NextDouble() * 18f, 1.0f + (float)_rng.NextDouble() * 0.5f );
		}

		_courtTimer -= dt;
		if ( _courtTimer > 0f )
			return;

		// Court done → one parent gets pregnant.
		_courtTimer = 0f;
		var mother = _rng.Next( 2 ) == 0 ? a : b;
		var other = mother == a ? b : a;
		StartPregnancy( mother );
		// Partner also cools off so the pair doesn't re-roll instantly.
		other.MateCooldown = MathF.Max( other.MateCooldown, 2.5f * 60f );
		_globalCooldown = GlobalMateCooldownSeconds;
		_courtIdA = "";
		_courtIdB = "";

		TankSim.ShowBanner( mother.DisplayName + " · round with eggs" );
		GameAudio.PlayLayered( GameAudio.Notice, GameAudio.Pop, 0.45f );
		GameAudio.PulseMusic( 0.06f, 0.5f );
		StatFloat.PushOverFish( mother, "EGGS", "grow", life: 1.3f );
		// Burst of hearts at the kiss.
		for ( var i = 0; i < 8; i++ )
		{
			SpawnParticle(
				"heart",
				_courtMx + (float)(_rng.NextDouble() * 28.0 - 14.0),
				_courtMy + (float)(_rng.NextDouble() * 16.0 - 8.0),
				(float)(_rng.NextDouble() * 40.0 - 20.0),
				-30f - (float)_rng.NextDouble() * 24f,
				1.2f + (float)_rng.NextDouble() * 0.6f );
		}
	}

	static void StartPregnancy( FishActor mother )
	{
		if ( mother is null )
			return;
		mother.PregnantTimer = PregnantMin + (float)_rng.NextDouble() * (PregnantMax - PregnantMin);
		mother.Fat = Math.Clamp( MathF.Max( mother.Fat, 82f ), 0f, 100f );
		mother.MateCooldown = 4f * 60f + (float)_rng.NextDouble() * 3f * 60f;
		mother.Vx *= 0.55f;
		mother.Vy *= 0.55f;
		mother.BobAmp = MathF.Min( 11f, mother.BobAmp + 1.5f );
	}

	static void LayEggs( TankSlot slot, FishActor mother, bool visual )
	{
		if ( slot is null || mother is null )
			return;

		var free = Math.Max( 0, slot.Capacity - slot.Fish.Count );
		// Always lay 2–4 eggs for the show; hatch only when there's room.
		var want = 2 + _rng.Next( 3 ); // 2–4
		if ( free <= 0 )
			want = Math.Min( want, 2 ); // still show eggs even if full (they'll fizzle)

		var laid = 0;
		for ( var i = 0; i < want && _eggs.Count < MaxEggs; i++ )
		{
			var egg = new TankEgg
			{
				Key = Guid.NewGuid().ToString( "N" )[..8],
				X = mother.X + (float)(_rng.NextDouble() * 36.0 - 18.0),
				Y = mother.Y + 6f + (float)(_rng.NextDouble() * 14.0),
				Life = 0f,
				HatchAt = EggHatchMin + (float)_rng.NextDouble() * (EggHatchMax - EggHatchMin),
				BobPhase = (float)(_rng.NextDouble() * Math.PI * 2.0)
			};
			// Stagger hatch a little.
			egg.HatchAt += i * 1.6f;
			ClampEgg( slot, egg );
			_eggs.Add( egg );
			laid++;
		}

		mother.Fat = Math.Clamp( mother.Fat * 0.55f + 12f, 20f, 55f );
		mother.BobAmp = MathF.Max( 2.5f, mother.BobAmp * 0.85f );

		_layX = mother.X;
		_layY = mother.DrawY;
		_layFlash = 1f;

		if ( visual || slot == TankSim.ActiveSlotOrNull )
		{
			TankSim.ShowBanner( mother.DisplayName + " laid " + laid + " egg" + (laid == 1 ? "" : "s") + "." );
			GameAudio.PlayUi( GameAudio.Pop );
			StatFloat.PushTag( "LAID ×" + laid, "fed", mother.X, mother.DrawY - 18f, life: 1.4f );
			for ( var i = 0; i < 5; i++ )
			{
				SpawnParticle(
					"bubble",
					mother.X + (float)(_rng.NextDouble() * 24.0 - 12.0),
					mother.DrawY + (float)(_rng.NextDouble() * 10.0),
					(float)(_rng.NextDouble() * 20.0 - 10.0),
					-12f - (float)_rng.NextDouble() * 16f,
					0.9f );
			}
		}

		Karma.Add( 0.6f );
		Chaos.Add( 0.5f );
	}

	static bool TickEggs( TankSlot slot, float dt, bool visual )
	{
		if ( _eggs.Count == 0 )
			return false;

		// Eggs are fresh-only; only process while ticking a fresh tank.
		if ( slot.Water != WaterType.Fresh )
			return false;

		var dirty = false;
		for ( var i = _eggs.Count - 1; i >= 0; i-- )
		{
			var egg = _eggs[i];
			egg.Life += dt;
			// Slow drift + bob.
			egg.Y += MathF.Sin( egg.Life * 1.7f + egg.BobPhase ) * 4f * dt;
			egg.X += MathF.Cos( egg.Life * 1.1f + egg.BobPhase ) * 3f * dt;
			ClampEgg( slot, egg );
			dirty = true;

			// Pre-hatch shiver bubbles.
			if ( visual && egg.Progress > 0.72f && _rng.NextDouble() < dt * 2.2 )
				SpawnParticle( "bubble", egg.X, egg.Y, 0f, -20f, 0.55f );

			if ( egg.Life < egg.HatchAt )
				continue;

			_eggs.RemoveAt( i );
			HatchEgg( slot, egg, visual );
		}

		return dirty;
	}

	static void HatchEgg( TankSlot slot, TankEgg egg, bool visual )
	{
		if ( slot is null || egg is null )
			return;

		if ( slot.Fish.Count >= slot.Capacity )
		{
			if ( visual || slot == TankSim.ActiveSlotOrNull )
			{
				TankSim.ShowBanner( "Egg ready — no room. Buy capacity." );
				GameAudio.PlayUi( GameAudio.Error );
			}
			SpawnParticle( "bubble", egg.X, egg.Y, 0f, -10f, 0.7f );
			return;
		}

		// Hatch rarity from parents in this tank (max rarity boost) + global luck/pity.
		var parentLuck = TankSim.RarityLuck;
		FishRarity bestParent = FishRarity.Common;
		foreach ( var f in slot.Fish )
		{
			if ( f is null || f.IsMonster )
				continue;
			if ( (int)f.Rarity > (int)bestParent )
				bestParent = f.Rarity;
		}
		parentLuck *= FishRarityInfo.ParentLuckBoost( bestParent, bestParent );
		var rarity = FishRarityInfo.Roll( _rng, parentLuck );

		var ok = TankSim.SpawnAt( FishSpecies.Guppy, rarity, ageSeconds: 0f, egg.X, egg.Y, slot );

		if ( ok )
		{
			_hatchCount++;
			var tip = rarity >= FishRarity.Rare ? 8.0 : rarity >= FishRarity.Uncommon ? 4.0 : 2.0;
			Economy.AddForced( tip );
			if ( visual || slot == TankSim.ActiveSlotOrNull )
			{
				var tag = rarity >= FishRarity.Uncommon
					? FishRarityInfo.Label( rarity ).ToUpperInvariant() + " GUPPY!"
					: "Guppy hatched!";
				TankSim.ShowBanner( tag + " +Ð" + Economy.FormatDoge( tip ) );
				if ( rarity >= FishRarity.Rare )
				{
					GameAudio.PlayBigWin( bark: rarity >= FishRarity.Legendary );
					BoomFeel.SoftPunch( 0.55f );
					ExplodeSystem.SpawnCoinBurst( egg.X, egg.Y, 12 );
					StatFloat.PushTag( tag, "rare", egg.X, egg.Y - 16f, life: 2f );
				}
				else
				{
					GameAudio.PlayUi( GameAudio.Confirm );
					StatFloat.PushTag( "HATCH +" + "Ð" + Economy.FormatDoge( tip ), "grow", egg.X, egg.Y - 14f, life: 1.4f );
				}
			}
			Karma.Add( 0.8f + (int)rarity * 0.25f );
			for ( var i = 0; i < 7; i++ )
			{
				SpawnParticle(
					"hatch",
					egg.X + (float)(_rng.NextDouble() * 18.0 - 9.0),
					egg.Y + (float)(_rng.NextDouble() * 12.0 - 6.0),
					(float)(_rng.NextDouble() * 50.0 - 25.0),
					(float)(_rng.NextDouble() * 40.0 - 30.0),
					0.55f + (float)_rng.NextDouble() * 0.4f );
			}
		}
	}

	static bool TickParticles( float dt )
	{
		if ( _particles.Count == 0 )
			return false;
		var dirty = false;
		for ( var i = _particles.Count - 1; i >= 0; i-- )
		{
			var p = _particles[i];
			p.Life -= dt;
			p.X += p.Vx * dt;
			p.Y += p.Vy * dt;
			if ( p.Kind == "heart" )
				p.Vy -= 8f * dt; // float up
			else if ( p.Kind == "bubble" )
				p.Vy -= 14f * dt;
			if ( p.Life <= 0f )
			{
				_particles.RemoveAt( i );
				dirty = true;
			}
			else
			{
				dirty = true;
			}
		}
		return dirty;
	}

	static bool TryRollMate( TankSlot slot, float dt, bool visual )
	{
		if ( slot.Fish.Count < 2 )
			return false;
		// Need at least one free slot eventually — don't start if full and already have eggs.
		if ( slot.Fish.Count >= slot.Capacity && _eggs.Count >= 2 )
			return false;

		// Fat/happy tanks court faster (overfeed = porch soap opera).
		var fatBoost = 1f;
		var fatAdults = 0;
		foreach ( var f in slot.Fish )
		{
			if ( CanMate( f ) && f.FatNorm > 0.35f )
				fatAdults++;
		}
		if ( fatAdults >= 2 )
			fatBoost = 1.65f;
		else if ( fatAdults == 1 )
			fatBoost = 1.25f;

		// Poisson: mean ~6 min baseline, faster when stuffed.
		if ( _rng.NextDouble() >= dt / (MeanMateIntervalSeconds / fatBoost) )
			return false;

		var eligible = new List<FishActor>();
		foreach ( var f in slot.Fish )
		{
			if ( CanMate( f ) )
				eligible.Add( f );
		}
		if ( eligible.Count < 2 )
			return false;

		// Pick two distinct fish.
		var i0 = _rng.Next( eligible.Count );
		var a = eligible[i0];
		FishActor b = null;
		for ( var tries = 0; tries < 8; tries++ )
		{
			var cand = eligible[_rng.Next( eligible.Count )];
			if ( cand.InstanceId != a.InstanceId )
			{
				b = cand;
				break;
			}
		}
		if ( b is null )
			return false;

		_courtIdA = a.InstanceId;
		_courtIdB = b.InstanceId;
		_courtTimer = CourtDuration;
		_courtWater = slot.Water;
		_courtMx = (a.X + b.X) * 0.5f;
		_courtMy = (a.DrawY + b.DrawY) * 0.5f;

		if ( visual || slot == TankSim.ActiveSlotOrNull )
		{
			TankSim.ShowBanner( a.DisplayName + " + " + b.DisplayName + " · courting" );
			GameAudio.PlayUi( GameAudio.Pop );
		}

		return true;
	}

	public static bool CanMate( FishActor f )
	{
		if ( f?.Species is null )
			return false;
		if ( f.IsMonster || f.OnAdventure )
			return false;
		if ( f.IsFightInjured || f.IsSick )
			return false;
		if ( f.IsPregnant || f.MateCooldown > 0.5f )
			return false;
		// Adults only — guppies don't breed.
		if ( f.Growth01 < 0.5f )
			return false;
		// Meth chaos: too spun to court.
		if ( f.OnMeth )
			return false;
		// Well-fed adults are more romantic (soft nudge, not a hard gate).
		return true;
	}

	/// <summary>Eligible breeders in the active tank (for coach / HUD).</summary>
	public static int EligiblePairCount
	{
		get
		{
			var n = 0;
			foreach ( var f in TankSim.Fish )
			{
				if ( CanMate( f ) )
					n++;
			}
			return n / 2;
		}
	}

	static void PullToward( FishActor f, float tx, float ty, float dt, float strength )
	{
		if ( f is null )
			return;
		var dx = tx - f.X;
		var dy = ty - f.Y;
		f.Vx += dx * strength * dt;
		f.Vy += dy * strength * dt;
		// Cap court speed so they don't rocket.
		var spd = MathF.Sqrt( f.Vx * f.Vx + f.Vy * f.Vy );
		var max = MathF.Max( 40f, f.Species?.Speed * 0.55f ?? 50f );
		if ( spd > max && spd > 0.01f )
		{
			f.Vx *= max / spd;
			f.Vy *= max / spd;
		}
	}

	static void ClampEgg( TankSlot slot, TankEgg egg )
	{
		var w = TankSim.Width;
		var h = TankSim.Height;
		egg.X = Math.Clamp( egg.X, 12f, w - 12f );
		egg.Y = Math.Clamp( egg.Y, 14f, h - 14f );
	}

	static void SpawnParticle( string kind, float x, float y, float vx, float vy, float life )
	{
		if ( _particles.Count >= MaxParticles )
			_particles.RemoveAt( 0 );
		_particles.Add( new BreedParticle
		{
			Key = Guid.NewGuid().ToString( "N" )[..6],
			Kind = kind ?? "heart",
			X = x,
			Y = y,
			Vx = vx,
			Vy = vy,
			Life = life,
			MaxLife = life
		} );
	}

	// ---- Save / load eggs (short-lived; pregnancy is on the fish) ----

	public static List<EggSaveData> WriteEggs() =>
		_eggs.Select( e => new EggSaveData
		{
			X = e.X,
			Y = e.Y,
			Life = e.Life,
			HatchAt = e.HatchAt
		} ).ToList();

	public static void LoadEggs( List<EggSaveData> list )
	{
		_eggs.Clear();
		if ( list is null )
			return;
		foreach ( var e in list )
		{
			if ( _eggs.Count >= MaxEggs )
				break;
			_eggs.Add( new TankEgg
			{
				Key = Guid.NewGuid().ToString( "N" )[..8],
				X = e.X,
				Y = e.Y,
				Life = Math.Max( 0f, e.Life ),
				HatchAt = e.HatchAt > 1f ? e.HatchAt : 16f,
				BobPhase = (float)(_rng.NextDouble() * Math.PI * 2.0)
			} );
		}
		_version++;
	}
}

public sealed class EggSaveData
{
	public float X { get; set; }
	public float Y { get; set; }
	public float Life { get; set; }
	public float HatchAt { get; set; }
}