FishActor.cs
namespace NoChillquarium;
/// <summary>
/// One living fish in a tank. Positions are tank-local pixels.
/// </summary>
public sealed class FishActor
{
public string InstanceId { get; init; }
/// <summary>Mutable so Trade can swap species in place.</summary>
public FishSpecies Species { get; set; }
/// <summary>Optional player nickname. Empty/null = use species name.</summary>
public string Nickname { get; set; }
public float X { get; set; }
public float Y { get; set; }
public float Vx { get; set; }
public float Vy { get; set; }
public float BobPhase { get; set; }
public float BobSpeed { get; set; }
public float BobAmp { get; set; }
/// <summary>Seconds spent sick from wrong salinity (dies around threshold).</summary>
public float SickTimer { get; set; }
/// <summary>Meth keeps wrong-water fish alive — jittery, chaotic, profitable.</summary>
public bool OnMeth { get; set; }
/// <summary>Seconds remaining on the high (wears off after this).</summary>
public float MethTimer { get; set; }
/// <summary>0–100 chemical dependence. Builds with each dose.</summary>
public float Addiction { get; set; }
/// <summary>Seconds of withdrawal left after a high ends.</summary>
public float WithdrawalTimer { get; set; }
/// <summary>Training stats 0–10 (start ~3).</summary>
public float Endurance { get; set; } = 3f;
public float SpeedStat { get; set; } = 3f;
public float Agility { get; set; } = 3f;
/// <summary>Steroid jacked state — bigger, faster, loud income.</summary>
public bool Jacked { get; set; }
public float JackedTimer { get; set; }
/// <summary>Weed gummies high — slow, floaty, trippy visuals.</summary>
public bool Stoned { get; set; }
/// <summary>Seconds left on the gummy high.</summary>
public float StonedTimer { get; set; }
/// <summary>Seconds left of click-scare dart (overrides wander / pellet seek).</summary>
public float ScurryTimer { get; set; }
/// <summary>
/// Post-fight recovery lockout (seconds). Looks sick and cannot enter battles.
/// </summary>
public float FightInjuryTimer { get; set; }
/// <summary>
/// Away on Adventure Mode (flushed) — hidden from tank until faucet return.
/// Still exists for crawl combat via LotRunSystem.
/// </summary>
public bool OnAdventure { get; set; }
/// <summary>0–100 belly from pellets. Overfeeding makes them visibly fat.</summary>
public float Fat { get; set; }
/// <summary>
/// Seconds left carrying eggs (rare passive breed). While >0 the fish stays fat
/// and will lay tank eggs that hatch into guppies.
/// </summary>
public float PregnantTimer { get; set; }
/// <summary>Seconds before this fish can court again after mating / laying.</summary>
public float MateCooldown { get; set; }
/// <summary>Seconds alive in tanks (growth clock).</summary>
public float AgeSeconds { get; set; }
/// <summary>
/// Permanent bulk from eating smaller fish (0–100). Monsters only.
/// Feeds size, presence, and a little income.
/// </summary>
public float PredationBulk { get; set; }
/// <summary>Cooldown before this monster can try another hunt (seconds).</summary>
public float PredationCooldown { get; set; }
/// <summary>How many tankmates this fish has swallowed (flavor / banner).</summary>
public int FishEatenCount { get; set; }
/// <summary>Rolled on spawn/buy — never changes.</summary>
public FishRarity Rarity { get; set; } = FishRarity.Common;
public const float WeedHighDuration = 22f;
/// <summary>How long a lost fight benches a fish (short slap — requeue soon).</summary>
public const float FightInjuryDuration = 28f;
/// <summary>
/// Natural seconds to full adult (Chillquarium-style real-time grow).
/// Feeding adds age bursts so active play finishes faster.
/// Tuned for addiction: idle adult ~5 min; mash feed ~2 min.
/// </summary>
public const float AdultAgeSeconds = 4f * 60f; // ~4 min idle · mash feed ~90s
/// <summary>Extra seconds after adult toward "mature" bulk / peak sell.</summary>
public const float MatureExtraSeconds = 6f * 60f; // +6 min
/// <summary>Cap age (adult + mature). Feed can't push past this.</summary>
public static float MaxAgeSeconds => AdultAgeSeconds + MatureExtraSeconds;
/// <summary>Swim direction only (+1 right, -1 left). Visual mirror is computed in UI.</summary>
public int Facing => Vx >= 0f ? 1 : -1;
public float DrawY => Y + MathF.Sin( BobPhase ) * BobAmp;
public string DisplayName =>
!string.IsNullOrWhiteSpace( Nickname ) ? Nickname.Trim() : (Species?.Name ?? "Fish");
public string RarityLabel => FishRarityInfo.Label( Rarity );
public string RarityCss => FishRarityInfo.CssClass( Rarity );
public bool IsMonster => Species?.IsMonster == true;
/// <summary>Average of END/SPD/AGI — used for fight strength and "failure" scrap.</summary>
public float CombatAvg => (Endurance + SpeedStat + Agility) / 3f;
/// <summary>0–1 bulk from predation (size / presence).</summary>
public float PredationBulkNorm => Math.Clamp( PredationBulk / 100f, 0f, 1f );
/// <summary>
/// Approximate mass for "who can eat whom" — growth × species box × fat × bulk.
/// </summary>
public float MassScore
{
get
{
if ( Species is null )
return 0f;
var box = Species.Width * Species.Height * 0.01f;
var m = box * GrowthScale;
m *= 1f + FatNorm * 0.45f;
m *= 1f + PredationBulkNorm * 0.9f;
if ( IsMonster )
m *= 1.35f;
if ( Jacked )
m *= 1.15f;
return m;
}
}
/// <summary>
/// Weak / untrained abom — Grandma calls it a failure and wants it M80'd.
/// (Commons with soft stats, or any monster under ~4 combat avg.)
/// </summary>
public bool IsAbominationFailure =>
IsMonster
&& ( CombatAvg < 4.0f || Rarity <= FishRarity.Common );
/// <summary>
/// Sprite to draw — meth aboms, then overfeed fat sheets, else base art.
/// </summary>
public string DisplaySpriteId
{
get
{
if ( Species is null )
return "";
if ( IsMonster && OnMeth )
{
// Cycle 5 meth-abom sheets by monster type.
var n = ((Species.MonsterType - 1) % 5) + 1;
if ( n < 1 ) n = 1;
return SwimSpriteOr( $"monster_meth_{n}" );
}
var baseId = Species.SpriteId;
// Excessive overfeed: dedicated fat sheets when available.
if ( FatStage >= 2 && !string.IsNullOrEmpty( baseId ) )
{
var fatId = baseId + "_fat";
if ( SpriteCatalog.Has( fatId ) )
return SwimSpriteOr( fatId );
}
return SwimSpriteOr( baseId );
}
}
/// <summary>2–4 frame GBA swim if <c>{stem}_swim_0</c> exists; else the still.</summary>
string SwimSpriteOr( string stem )
{
if ( string.IsNullOrEmpty( stem ) )
return stem;
var n = SpriteCatalog.SwimFrameCount( stem );
if ( n <= 0 )
return stem;
var fps = 5f;
var i = ((int)(Time.Now * fps + BobPhase * 1.7f) % n + n) % n;
return stem + "_swim_" + i;
}
/// <summary>True when we're drawing a dedicated fat sheet (not just scale).</summary>
public bool UsingFatSprite
{
get
{
if ( Species is null || FatStage < 2 )
return false;
return SpriteCatalog.Has( Species.SpriteId + "_fat" );
}
}
/// <summary>0 = newborn guppy, 1 = full adult (before mature bulk).</summary>
public float Growth01 =>
Math.Clamp( AgeSeconds / AdultAgeSeconds, 0f, 1f );
/// <summary>0–1 into mature phase after adult.</summary>
public float Mature01 =>
AgeSeconds <= AdultAgeSeconds
? 0f
: Math.Clamp( (AgeSeconds - AdultAgeSeconds) / MatureExtraSeconds, 0f, 1f );
/// <summary>Guppy → Juvenile → Adult → Mature.</summary>
public string GrowthStageName
{
get
{
if ( Growth01 < 0.28f ) return "Guppy";
if ( Growth01 < 0.62f ) return "Juvenile";
if ( Mature01 < 0.35f ) return "Adult";
return "Mature";
}
}
/// <summary>0 guppy … 3 mature — for CSS.</summary>
public int GrowthStage
{
get
{
if ( Growth01 < 0.28f ) return 0;
if ( Growth01 < 0.62f ) return 1;
if ( Mature01 < 0.35f ) return 2;
return 3;
}
}
/// <summary>
/// Visual size factor from age only (~0.50 guppy → 1.0 adult → ~1.14 mature).
/// Combined with fat/jacked in <see cref="DrawScaleX"/>.
/// Floor is high so a new goldfish still reads in the 1280 glass.
/// </summary>
public float GrowthScale
{
get
{
// Ease-out so early growth is visible, then settles.
var t = Growth01;
var eased = t * t * (3f - 2f * t); // smoothstep
var s = 0.50f + eased * 0.50f;
s += Mature01 * 0.14f;
return s;
}
}
/// <summary>
/// Income fraction from size: babies still earn, adults full base, mature peak.
/// Floor is high enough that starter fish feel alive — crumbs kill the first minute.
/// </summary>
public float GrowthIncomeMul
{
get
{
var t = Growth01;
// Smoothstep: readable early income, full rate at adult, mature AFK prize.
var eased = t * t * (3f - 2f * t);
var mul = 0.48f + eased * 0.52f;
mul += Mature01 * 0.4f;
return mul;
}
}
/// <summary>0–100 growth toward adult (menu bar).</summary>
public int GrowthPercent => (int)MathF.Round( Growth01 * 100f );
public float RarityIncomeMul => FishRarityInfo.IncomeMult( Rarity );
public float RaritySellMul => FishRarityInfo.SellMult( Rarity );
/// <summary>Species base × growth × rarity (before meth/train/etc.).</summary>
public float EffectiveIncomePerSecond
{
get
{
if ( Species is null )
return 0f;
var rate = Species.IncomePerSecond * GrowthIncomeMul * RarityIncomeMul * IncomeScale;
// Aboms print a bit harder at baseline (training already in IncomeScale).
if ( IsMonster )
rate *= 1.18f;
// Apex predators that snack print a little harder.
if ( PredationBulkNorm > 0f )
rate *= 1f + PredationBulkNorm * 0.35f;
return rate;
}
}
public void TickGrowth( float dt )
{
if ( dt <= 0f )
return;
var before = GrowthStageName;
AgeSeconds = MathF.Min( MaxAgeSeconds, AgeSeconds + dt );
NotifyGrowthStageIfChanged( before, pay: false );
}
/// <summary>
/// Chillquarium feed XP — each pellet ages the fish (better food = bigger bite).
/// Active feeding should finish adult in ~2–3 minutes, not a second job.
/// </summary>
public void AddGrowthFromFood( FoodDef food )
{
// Flakes ~55s, mid foods ~70–90s, nightmare foods ~110s+.
var bite = 36f;
if ( food is not null )
bite = 30f + food.BoostMultiplier * 18f + food.ChaosOnFeed * 3.2f;
var before = GrowthStageName;
AgeSeconds = MathF.Min( MaxAgeSeconds, AgeSeconds + bite );
NotifyGrowthStageIfChanged( before, pay: true );
}
/// <summary>Stage-up banner + optional Ð snack so growth always feels like a win.</summary>
void NotifyGrowthStageIfChanged( string beforeStage, bool pay )
{
if ( beforeStage == GrowthStageName )
return;
// Skip newborn→guppy noise; celebrate real rungs.
if ( GrowthStage < 1 )
return;
TankSim.ShowBanner( $"{DisplayName} · {GrowthStageName}!" );
StatFloat.PushOverFish( this, GrowthStageName.ToUpperInvariant() + "!", "stage", life: 1.9f );
if ( GrowthStage >= 2 )
{
BoomFeel.SoftPunch( 0.35f );
GameAudio.PlayLayered( GameAudio.Upgrade, GameAudio.Notice, 0.4f );
}
else
GameAudio.PlayUi( GameAudio.Notice );
if ( !pay )
return;
// Small stage tips — adult is the money moment.
var tip = GrowthStage switch
{
1 => 2.0, // Juvenile
2 => 5.0, // Adult
3 => 8.0, // Mature
_ => 0.0
};
if ( tip > 0 )
Economy.AddForced( tip * RaritySellMul );
}
/// <summary>Max length for player nicknames (short HUD labels).</summary>
public const int MaxNicknameLength = 20;
/// <summary>
/// Set or clear nickname. Empty/whitespace clears (back to species name).
/// Returns false if the string has invalid characters.
/// </summary>
public bool TrySetNickname( string name )
{
if ( string.IsNullOrWhiteSpace( name ) )
{
Nickname = null;
return true;
}
var cleaned = SanitizeNickname( name );
if ( cleaned is null )
return false;
if ( cleaned.Length == 0 )
{
Nickname = null;
return true;
}
// Same as species label — treat as "no custom name"
if ( Species is not null
&& string.Equals( cleaned, Species.Name, StringComparison.OrdinalIgnoreCase ) )
{
Nickname = null;
return true;
}
Nickname = cleaned;
return true;
}
/// <summary>Letters, numbers, space, and a few friendly symbols. Null = invalid.</summary>
public static string SanitizeNickname( string raw )
{
if ( raw is null )
return "";
var sb = new System.Text.StringBuilder( MaxNicknameLength );
foreach ( var ch in raw.Trim() )
{
if ( sb.Length >= MaxNicknameLength )
break;
if ( char.IsLetterOrDigit( ch ) || ch is ' ' or '-' or '_' or '\'' or '.' )
sb.Append( ch );
else
return null;
}
// Collapse multi-spaces
var s = sb.ToString().Trim();
while ( s.Contains( " ", StringComparison.Ordinal ) )
s = s.Replace( " ", " ", StringComparison.Ordinal );
return s;
}
/// <summary>0 = skinny, 1 = stuffed chonk.</summary>
public float FatNorm => Math.Clamp( Fat / 100f, 0f, 1f );
/// <summary>True once overfeeding is obvious.</summary>
public bool IsFat => Fat >= 28f;
/// <summary>1–3 for CSS (mild / chunky / unit).</summary>
public int FatStage
{
get
{
if ( Fat < 28f ) return 0;
if ( Fat < 55f ) return 1;
if ( Fat < 80f ) return 2;
return 3;
}
}
/// <summary>Horizontal draw scale (growth + belly + speed length + jacked bulk).</summary>
public float DrawScaleX
{
get
{
var s = GrowthScale;
// Fat sheet already sells the belly — dial scale so we don't double-inflate.
var fatMul = UsingFatSprite
? 1f + FatNorm * 0.28f
: 1f + FatNorm * 0.95f;
s *= fatMul;
// Pregnant: extra midsection chonk on top of fat.
if ( IsPregnant )
s *= 1.12f + (1f - PregnantProgress) * 0.1f;
// Fast fish read longer / more torpedo-shaped.
s *= 1f + SpeedNorm * 0.14f;
// Endurance bulk pads the midsection a bit.
s *= 1f + EnduranceNorm * 0.08f;
if ( Jacked )
s *= 1.35f;
// Mythical / painted get a slight presence bump so they read special.
if ( Rarity >= FishRarity.PaintedRare )
s *= 1.06f;
if ( Rarity >= FishRarity.Legendary )
s *= 1.08f;
// Eaten fish stick — permanent chonk from predation.
if ( PredationBulkNorm > 0f )
s *= 1f + PredationBulkNorm * 0.7f;
// Monsters on meth: REALLY big and gross (capped so they still fit the glass).
if ( IsMonster && OnMeth )
s *= 2.35f + AddictionNorm * 0.45f;
else if ( IsMonster )
s *= 1.12f;
return Math.Min( s, IsMonster && OnMeth ? 3.6f : 2.9f );
}
}
/// <summary>Vertical draw scale (growth + fat, endurance bulk, agile fin height).</summary>
public float DrawScaleY
{
get
{
var s = GrowthScale;
var fatMul = UsingFatSprite
? 1f + FatNorm * 0.18f
: 1f + FatNorm * 0.38f;
s *= fatMul;
if ( IsPregnant )
s *= 1.22f + (1f - PregnantProgress) * 0.14f;
// Tanky fish look stockier.
s *= 1f + EnduranceNorm * 0.12f;
// Agile fish carry taller fin presence.
s *= 1f + AgilityNorm * 0.06f;
if ( Jacked )
s *= 1.35f;
if ( Rarity >= FishRarity.PaintedRare )
s *= 1.05f;
if ( Rarity >= FishRarity.Legendary )
s *= 1.06f;
if ( PredationBulkNorm > 0f )
s *= 1f + PredationBulkNorm * 0.55f;
if ( IsMonster && OnMeth )
s *= 2.15f + AddictionNorm * 0.4f;
else if ( IsMonster )
s *= 1.1f;
return Math.Min( s, IsMonster && OnMeth ? 3.4f : 2.65f );
}
}
public bool IsWrongWater( WaterType tankWater )
{
if ( Species is null || Species.Water == WaterType.Any )
return false;
return Species.Water != tankWater;
}
public bool IsSick => SickTimer > 0f && !OnMeth;
public bool InWithdrawal => !OnMeth && WithdrawalTimer > 0f;
public bool IsAddicted => Addiction >= 15f;
/// <summary>Carrying eggs after a rare courtship.</summary>
public bool IsPregnant => PregnantTimer > 0.05f;
/// <summary>1 at just impregnated, 0 when about to lay.</summary>
public float PregnantProgress =>
IsPregnant
? Math.Clamp( PregnantTimer / 72f, 0f, 1f )
: 0f;
/// <summary>Benched after a loss — sick look, no fights until timer ends.</summary>
public bool IsFightInjured => FightInjuryTimer > 0.05f;
/// <summary>1 at full injury, 0 when recovered.</summary>
public float FightInjuryProgress =>
IsFightInjured
? Math.Clamp( FightInjuryTimer / FightInjuryDuration, 0f, 1f )
: 0f;
/// <summary>1–3 visual severity while fight-injured (heavier right after the loss).</summary>
public int FightInjuryStage
{
get
{
if ( !IsFightInjured )
return 0;
var p = FightInjuryProgress;
if ( p > 0.66f ) return 3;
if ( p > 0.33f ) return 2;
return 1;
}
}
/// <summary>Salinity sick or post-fight hurt — shared tank “sick” look.</summary>
public bool LooksSick => IsSick || IsFightInjured;
/// <summary>Strongest sick-looking stage for CSS (salinity or fight injury).</summary>
public int DisplaySickStage =>
Math.Max( IsSick ? SickStage : 0, FightInjuryStage );
/// <summary>Apply 3-minute fight injury (loss). Refreshes timer if already hurt.</summary>
public void ApplyFightLossInjury()
{
FightInjuryTimer = FightInjuryDuration;
// Slow them down after the beating.
Vx *= 0.45f;
Vy *= 0.45f;
BobAmp = MathF.Min( 9f, BobAmp + 1.2f );
}
/// <summary>True if this tick cleared the fight injury lockout.</summary>
public bool TickFightInjury( float dt )
{
if ( FightInjuryTimer <= 0f || dt <= 0f )
return false;
var was = FightInjuryTimer;
FightInjuryTimer = MathF.Max( 0f, FightInjuryTimer - dt );
if ( was > 0f && FightInjuryTimer <= 0f )
{
BobAmp = MathF.Max( 2f, BobAmp * 0.85f );
return true;
}
return false;
}
/// <summary>MM:SS left on fight bench, or empty if ready.</summary>
public string FightCooldownLeftText
{
get
{
if ( !IsFightInjured )
return "";
var sec = (int)MathF.Ceiling( FightInjuryTimer );
var m = sec / 60;
var s = sec % 60;
return $"{m}:{s:00}";
}
}
/// <summary>0 = clean, 1 = fully hooked.</summary>
public float AddictionNorm => Math.Clamp( Addiction / 100f, 0f, 1f );
/// <summary>1–4 for CSS / HUD.</summary>
public int AddictionStage
{
get
{
if ( Addiction < 15f ) return 0;
if ( Addiction < 35f ) return 1;
if ( Addiction < 55f ) return 2;
if ( Addiction < 75f ) return 3;
return 4;
}
}
/// <summary>0 = healthy, 1 = about to die.</summary>
public float SickProgress
{
get
{
if ( !IsSick || TankSim.SickDeathSeconds <= 0f )
return 0f;
return Math.Clamp( SickTimer / TankSim.SickDeathSeconds, 0f, 1f );
}
}
/// <summary>1–4 visual severity for CSS classes.</summary>
public int SickStage => DeathFeel.ProgressToStage( SickProgress );
public bool IsDoomed => IsSick && SickProgress >= 0.55f;
/// <summary>Training average 0–1 for combat/income flavor.</summary>
public float TrainingNorm =>
Math.Clamp( (Endurance + SpeedStat + Agility) / 30f, 0f, 1f );
/// <summary>0–1 norms for each trained stat (0.5 floor → 0, 10 → 1).</summary>
public float SpeedNorm => Math.Clamp( (SpeedStat - 0.5f) / 9.5f, 0f, 1f );
public float AgilityNorm => Math.Clamp( Agility / 10f, 0f, 1f );
public float EnduranceNorm => Math.Clamp( Endurance / 10f, 0f, 1f );
/// <summary>
/// Swim speed multiplier from SpeedStat. ~1.0 at starting 3, up to ~2× at 10, down to ~0.55 at 0.5.
/// </summary>
public float SpeedMoveMul =>
Math.Clamp( 0.55f + SpeedStat * 0.145f, 0.5f, 2.05f );
/// <summary>
/// Turn sharpness from Agility. High = bigger heading snaps, more frequent course changes.
/// ~0.75 at starting 3, ~1.6 at 10.
/// </summary>
public float AgilityTurnMul =>
Math.Clamp( 0.35f + Agility * 0.125f, 0.3f, 1.65f );
/// <summary>Max heading delta (radians) when choosing a new wander direction.</summary>
public float MaxTurnRadians =>
MathF.PI * Math.Clamp( 0.22f + AgilityNorm * 0.78f, 0.22f, 1.0f );
/// <summary>How hard a new heading replaces the old one (0–1).</summary>
public float TurnSnap =>
Math.Clamp( 0.32f + Agility * 0.075f, 0.3f, 1f );
/// <summary>Wander re-nudge rate (events per second, roughly).</summary>
public float WanderRate =>
0.18f + Agility * 0.09f + (OnMeth ? 2.5f : 0f);
/// <summary>1–3 visual tier for CSS (0 = baseline starter-ish).</summary>
public int SpeedStage => StatStage( SpeedStat );
public int AgilityStage => StatStage( Agility );
public int EnduranceStage => StatStage( Endurance );
static int StatStage( float stat )
{
if ( stat < 4.2f ) return 0;
if ( stat < 6.2f ) return 1;
if ( stat < 8.2f ) return 2;
return 3;
}
/// <summary>
/// Status mult only (train / meth / sick / fat). Growth + rarity applied in
/// <see cref="EffectiveIncomePerSecond"/> / tank income.
/// </summary>
public float IncomeScale
{
get
{
var scale = 1f + TrainingNorm * 0.35f;
if ( Jacked )
scale *= 1.55f;
if ( OnMeth )
{
// Monsters love it — income goes stupid.
if ( IsMonster )
scale *= 2.8f + AddictionNorm * 1.1f;
else
scale *= 1.35f + AddictionNorm * 0.55f;
}
else if ( InWithdrawal )
{
// Monsters crash harder after the party.
if ( IsMonster )
scale *= MathF.Max( 0.05f, 0.22f - AddictionNorm * 0.12f );
else
scale *= MathF.Max( 0.08f, 0.35f - AddictionNorm * 0.25f );
}
else if ( IsSick )
scale *= 0.35f;
else if ( IsAddicted )
scale *= 1f - AddictionNorm * 0.12f;
// Stoned: chill productivity, munchies already fat-buffed below.
if ( Stoned )
scale *= 1.18f;
// Well-fed fish print a bit more; stuffed chonks slow the grind slightly.
if ( FatNorm > 0.15f )
scale *= 1f + MathF.Min( 0.2f, FatNorm * 0.18f );
if ( FatNorm > 0.75f )
scale *= 0.92f;
return scale;
}
}
/// <summary>Absorb a food pellet — growth XP + fat (Chillquarium feed loop).</summary>
public void EatPellet( float foodChaos = 0f, string foodId = null )
{
var food = FoodDef.FindExact( foodId );
// Growth first — this is the chill grind.
var beforeGrowth = GrowthPercent;
AddGrowthFromFood( food );
// Soft juice: sparkle + occasional GROW so feeding feels like XP.
ExplodeSystem.SpawnFeedSpark( X, DrawY );
if ( GrowthPercent > beforeGrowth && GrowthPercent % 5 == 0 )
StatFloat.PushOverFish( this, "GROW", "grow", life: 1.1f );
// First bites fill when empty; once stuffed, excess piles on hard.
var bite = 12f + FatNorm * 10f;
if ( FatNorm > 0.4f )
bite += 8f + FatNorm * 12f; // excessive overfeed → real chonk
if ( foodChaos > 2f )
bite += 4f; // weird food = greasier
// Munchies: weed bites hit the belly harder.
if ( foodId == FoodDef.WeedGummies.Id || Stoned )
bite += 8f;
var before = Fat;
var beforeStage = FatStage;
Fat = Math.Clamp( Fat + bite, 0f, 100f );
// Lazy after gorging
Vx *= 0.9f;
Vy *= 0.9f;
BobAmp = MathF.Min( 9f, BobAmp + 0.2f );
if ( foodId == FoodDef.WeedGummies.Id )
GetStoned();
// Stage banners — sprite swap lands at stage 2 (Fat >= 55).
if ( beforeStage < 1 && FatStage >= 1 )
TankSim.ShowBanner( $"{DisplayName} · getting round" );
else if ( beforeStage < 2 && FatStage >= 2 )
TankSim.ShowBanner( $"{DisplayName} · thick. New look." );
else if ( beforeStage < 3 && FatStage >= 3 )
TankSim.ShowBanner( $"{DisplayName} · meatball unit" );
}
/// <summary>Weed gummies kick in — floaty stats + trippy VFX timer.</summary>
public void GetStoned()
{
var was = Stoned;
Stoned = true;
StonedTimer = MathF.Max( StonedTimer, WeedHighDuration );
// Soft stats: chill (endurance), munchies path already fat; speed tanks, agility wavy.
var r = (float)(InstanceId?.GetHashCode() ?? 0);
r = MathF.Abs( r % 1000f ) / 1000f;
var endGain = 0.35f + r * 0.25f;
var agiGain = 0.2f + (1f - r) * 0.2f;
var spdLoss = 0.15f + r * 0.12f;
Endurance = MathF.Min( 10f, Endurance + endGain );
Agility = MathF.Min( 10f, Agility + agiGain );
SpeedStat = MathF.Max( 0.5f, SpeedStat - spdLoss );
// Drift like a lava lamp
Vx *= 0.45f;
Vy *= 0.45f;
BobAmp = MathF.Min( 12f, BobAmp + 1.8f );
BobSpeed = MathF.Max( 0.4f, BobSpeed * 0.7f );
StatFloat.SpawnWeedHigh( this, endGain, agiGain, spdLoss );
if ( !was )
TankSim.ShowBanner( $"{Species?.Name ?? "Fish"} · baked" );
}
public void TickStoned( float dt )
{
if ( !Stoned || dt <= 0f )
return;
StonedTimer -= dt;
if ( StonedTimer <= 0f )
{
Stoned = false;
StonedTimer = 0f;
BobAmp = MathF.Max( 2f, BobAmp * 0.7f );
}
}
/// <summary>Slow burn-off when not slamming pellets.</summary>
public void TickFat( float dt )
{
if ( Fat <= 0f || dt <= 0f )
return;
// Eggs need the belly — don't burn fat while pregnant.
if ( IsPregnant )
return;
// ~90s empty from full if they stop eating; faster burn while jacked/meth.
// Stuffed fish keep the fat sheet a little longer so the joke lands.
var burn = FatNorm > 0.55f ? 1.05f : 1.25f;
if ( Jacked )
burn *= 1.25f;
if ( OnMeth )
burn *= 1.4f;
// Stoned munchies hold the belly longer.
if ( Stoned )
burn *= 0.5f;
Fat = MathF.Max( 0f, Fat - burn * dt );
}
/// <summary>Short status label for the fish menu.</summary>
public string FatStatusLabel =>
IsPregnant
? "Eggs"
: FatStage switch
{
1 => "Chonk",
2 => "Unit",
3 => "Meatball",
_ => ""
};
}