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