Game simulation for the player's dual fish tanks. Manages tank slots (fresh and salt), fish and decor state, spawning, movement/physics, salinity/meth/addiction mechanics, save/load serialization, UI banners and simple economy interactions.
namespace NoChillquarium;
/// <summary>
/// Dual tanks (fresh + salt), swim AI, wrong-water sickness / meth survival.
/// </summary>
public static class TankSim
{
// Sized for ~1920×1080 play — roomy tank without edge-to-edge chrome.
public const float Width = 1280f;
public const float Height = 620f;
public const int DefaultCapacity = 10;
public const float SickDeathSeconds = 10f;
public const double SaltTankCost = 100;
static readonly Random _rng = new();
static readonly TankSlot _fresh = new()
{
Water = WaterType.Fresh,
Name = HabitatDef.StarterFresh.Name,
Capacity = DefaultCapacity,
Unlocked = true,
HabitatId = HabitatDef.StarterFresh.Id
};
static readonly TankSlot _salt = new()
{
Water = WaterType.Salt,
Name = HabitatDef.StarterSalt.Name,
Capacity = DefaultCapacity,
Unlocked = false,
HabitatId = HabitatDef.StarterSalt.Id
};
static TankSlot _active = _fresh;
static string _banner = "";
static float _bannerTimer;
static int _bannerVersion;
public static float WidthPx => Width;
public static float HeightPx => Height;
public static string TankName => _active.Name;
public static WaterType Water => _active.Water;
public static int Capacity => _active.Capacity;
public static IReadOnlyList<FishActor> Fish => _active.Fish;
public static int FishCount => _active.Fish.Count;
public static int FrameId { get; private set; }
/// <summary>Species counts in the tank you're looking at (for HUD census).</summary>
public static IReadOnlyList<(FishSpecies Species, int Count)> CensusActive()
{
if ( _active.Fish.Count == 0 )
return Array.Empty<(FishSpecies, int)>();
var map = new Dictionary<string, (FishSpecies Sp, int N)>( StringComparer.OrdinalIgnoreCase );
foreach ( var f in _active.Fish )
{
if ( f?.Species is null )
continue;
var id = f.Species.Id;
if ( map.TryGetValue( id, out var e ) )
map[id] = (e.Sp, e.N + 1);
else
map[id] = (f.Species, 1);
}
return map.Values
.OrderByDescending( x => x.N )
.ThenBy( x => x.Sp.Name, StringComparer.OrdinalIgnoreCase )
.Select( x => (x.Sp, x.N) )
.ToList();
}
public static bool IsActive { get; private set; }
public static bool SaltUnlocked => _salt.Unlocked;
/// <summary>True when fresh + salt are both available (move between tanks).</summary>
public static bool HasMultipleTanks => _fresh.Unlocked && _salt.Unlocked;
public static bool ViewingSalt => _active.Water == WaterType.Salt;
/// <summary>Label for the other tank when dual tanks are owned.</summary>
public static string OtherTankLabel => ViewingSalt ? "Fresh" : "Salt";
public static string Banner => _bannerTimer > 0f ? _banner : "";
public static int BannerVersion => _bannerVersion;
public static HabitatDef ActiveHabitat => _active.Habitat;
public static string HabitatCssClass => ActiveHabitat.CssClass ?? "hab-glass";
public static string HabitatId => _active.HabitatId ?? ActiveHabitat.Id;
public static int HabitatRank => _active.HabitatRank;
public static HabitatDef NextHabitat => HabitatDef.NextFor( _active.Water, _active.HabitatRank );
/// <summary>
/// Idle income from <b>all</b> unlocked tanks — switching tabs is only a camera.
/// </summary>
public static double IncomePerSecond
{
get
{
double sum = 0;
sum += SlotIncome( _fresh );
if ( _salt.Unlocked )
sum += SlotIncome( _salt );
return sum;
}
}
/// <summary>Income for the tank you're looking at only (HUD flavor).</summary>
public static double ActiveTankIncomePerSecond => SlotIncome( _active );
static double SlotIncome( TankSlot slot )
{
if ( slot is null || !slot.Unlocked )
return 0;
double sum = 0;
foreach ( var fish in slot.Fish )
sum += fish.Species.IncomePerSecond * fish.IncomeScale;
foreach ( var piece in slot.Decor )
sum += piece.Def.ComfortBonus;
return sum * slot.IncomeMult;
}
public static IReadOnlyList<DecorPiece> ActiveDecor => _active.Decor;
public static int DecorCount => _active.Decor.Count;
public static int MaxDecor => _active.MaxDecor;
public static int AddictedFishCount
{
get
{
var n = 0;
foreach ( var f in _fresh.Fish )
if ( f.IsAddicted ) n++;
if ( _salt.Unlocked )
foreach ( var f in _salt.Fish )
if ( f.IsAddicted ) n++;
return n;
}
}
public static int WithdrawingFishCount
{
get
{
var n = 0;
foreach ( var f in _active.Fish )
if ( f.InWithdrawal ) n++;
return n;
}
}
public static void StartNewGame()
{
_fresh.Fish.Clear();
_salt.Fish.Clear();
_fresh.Decor.Clear();
_salt.Decor.Clear();
_fresh.ResetToStarter();
_salt.ResetToStarter();
_fresh.Unlocked = true;
_salt.Unlocked = false;
_active = _fresh;
IsActive = true;
FrameId = 0;
ClearBanner();
Spawn( FishSpecies.Goldfish );
Spawn( FishSpecies.Goldfish );
Spawn( FishSpecies.NeonTetra );
Spawn( FishSpecies.Betta );
// Free starter plant so the glass isn't barren.
TryPlaceDecor( DecorDef.Plant, announce: false );
ShowBanner( "Fresh tank online." );
}
public static void LoadFromSave( SaveData data )
{
_fresh.Fish.Clear();
_salt.Fish.Clear();
_fresh.Decor.Clear();
_salt.Decor.Clear();
FrameId = 0;
ClearBanner();
if ( data is null )
{
IsActive = false;
return;
}
_fresh.Capacity = data.FreshCapacity > 0 ? data.FreshCapacity : DefaultCapacity;
_salt.Capacity = data.SaltCapacity > 0 ? data.SaltCapacity : DefaultCapacity;
_salt.Unlocked = data.SaltUnlocked;
ApplyHabitatFromSave( _fresh, data.FreshHabitatId, data.FreshTankName );
ApplyHabitatFromSave( _salt, data.SaltHabitatId, data.SaltTankName );
LoadFishList( data.FreshFish, _fresh );
LoadFishList( data.SaltFish, _salt );
LoadDecorList( data.FreshDecor, _fresh );
LoadDecorList( data.SaltDecor, _salt );
// Back-compat: old saves only had one fish list + Water string.
if ( (_fresh.Fish.Count + _salt.Fish.Count) == 0 && data.Fish is { Count: > 0 } )
{
var slot = data.Water == "Salt" ? _salt : _fresh;
if ( data.Water == "Salt" )
_salt.Unlocked = true;
if ( data.Capacity > 0 )
slot.Capacity = data.Capacity;
LoadFishList( data.Fish, slot );
}
_active = (data.ActiveWater == "Salt" && _salt.Unlocked) ? _salt : _fresh;
IsActive = _fresh.Fish.Count + _salt.Fish.Count > 0 || _fresh.Unlocked;
if ( !IsActive )
return;
// Old saves placed decor with tiny sizes / wrong Y — re-seat to current defs.
ReseatAllDecor();
}
static void LoadFishList( List<FishSaveData> list, TankSlot slot )
{
if ( list is null )
return;
foreach ( var entry in list )
{
var species = FishSpecies.Find( entry.SpeciesId );
if ( slot.Fish.Count >= slot.Capacity )
break;
var actor = new FishActor
{
InstanceId = Guid.NewGuid().ToString( "N" )[..8],
Species = species,
X = entry.X,
Y = entry.Y,
Vx = entry.Vx,
Vy = entry.Vy,
BobPhase = (float)(_rng.NextDouble() * Math.PI * 2.0),
BobSpeed = 2.2f + (float)_rng.NextDouble() * 2.5f,
BobAmp = 2.5f + (float)_rng.NextDouble() * 3.5f,
OnMeth = entry.OnMeth,
MethTimer = entry.MethTimer > 0 ? entry.MethTimer : (entry.OnMeth ? MethDurationSeconds : 0f),
SickTimer = entry.SickTimer,
Addiction = Math.Clamp( entry.Addiction, 0f, 100f ),
WithdrawalTimer = Math.Max( 0f, entry.WithdrawalTimer ),
Endurance = entry.Endurance > 0 ? entry.Endurance : 3f,
SpeedStat = entry.SpeedStat > 0 ? entry.SpeedStat : 3f,
Agility = entry.Agility > 0 ? entry.Agility : 3f,
Jacked = entry.Jacked,
JackedTimer = entry.JackedTimer,
Fat = Math.Clamp( entry.Fat, 0f, 100f ),
Stoned = entry.Stoned,
StonedTimer = entry.Stoned
? Math.Max( entry.StonedTimer, 1f )
: Math.Max( 0f, entry.StonedTimer ),
FightInjuryTimer = Math.Max( 0f, entry.FightInjuryTimer )
};
if ( !string.IsNullOrWhiteSpace( entry.Nickname ) )
actor.TrySetNickname( entry.Nickname );
slot.Fish.Add( actor );
}
}
static void LoadDecorList( List<DecorSaveData> list, TankSlot slot )
{
if ( list is null )
return;
foreach ( var entry in list )
{
if ( slot.Decor.Count >= slot.MaxDecor )
break;
if ( string.IsNullOrWhiteSpace( entry.DefId ) )
continue;
slot.Decor.Add( new DecorPiece
{
DefId = entry.DefId,
X = entry.X,
Y = entry.Y
} );
}
}
static void ApplyHabitatFromSave( TankSlot slot, string habitatId, string fallbackName )
{
var def = HabitatDef.Find( habitatId );
if ( def is null || !HabitatDef.MatchesTank( def, slot.Water ) )
{
// Infer from name for older saves, else starter.
def = HabitatDef.StarterFor( slot.Water );
if ( !string.IsNullOrWhiteSpace( fallbackName ) )
{
foreach ( var h in HabitatDef.Catalog )
{
if ( HabitatDef.MatchesTank( h, slot.Water ) && h.Name == fallbackName )
{
def = h;
break;
}
}
}
}
slot.HabitatId = def.Id;
slot.Name = string.IsNullOrWhiteSpace( fallbackName ) ? def.Name : fallbackName;
// Capacity already set from save; floor to habitat base.
slot.Capacity = Math.Max( slot.Capacity, def.BaseCapacity );
}
public static void WriteToSave( SaveData data )
{
if ( data is null )
return;
data.FreshTankName = _fresh.Name;
data.SaltTankName = _salt.Name;
data.FreshHabitatId = _fresh.HabitatId;
data.SaltHabitatId = _salt.HabitatId;
data.FreshCapacity = _fresh.Capacity;
data.SaltCapacity = _salt.Capacity;
data.SaltUnlocked = _salt.Unlocked;
data.ActiveWater = _active.Water == WaterType.Salt ? "Salt" : "Fresh";
// Legacy fields
data.TankName = _active.Name;
data.Water = data.ActiveWater;
data.Capacity = _active.Capacity;
data.FreshFish = SerializeFish( _fresh.Fish );
data.SaltFish = SerializeFish( _salt.Fish );
data.Fish = SerializeFish( _active.Fish );
data.FreshDecor = SerializeDecor( _fresh.Decor );
data.SaltDecor = SerializeDecor( _salt.Decor );
}
static List<FishSaveData> SerializeFish( List<FishActor> list ) =>
list.Select( f => new FishSaveData
{
SpeciesId = f.Species.Id,
Nickname = string.IsNullOrWhiteSpace( f.Nickname ) ? null : f.Nickname.Trim(),
X = f.X,
Y = f.Y,
Vx = f.Vx,
Vy = f.Vy,
OnMeth = f.OnMeth,
MethTimer = f.MethTimer,
SickTimer = f.SickTimer,
Addiction = f.Addiction,
WithdrawalTimer = f.WithdrawalTimer,
Endurance = f.Endurance,
SpeedStat = f.SpeedStat,
Agility = f.Agility,
Jacked = f.Jacked,
JackedTimer = f.JackedTimer,
Fat = f.Fat,
Stoned = f.Stoned,
StonedTimer = f.StonedTimer,
FightInjuryTimer = f.FightInjuryTimer
} ).ToList();
static List<DecorSaveData> SerializeDecor( List<DecorPiece> list ) =>
list.Select( d => new DecorSaveData
{
DefId = d.DefId,
X = d.X,
Y = d.Y
} ).ToList();
public static void Clear()
{
_fresh.Fish.Clear();
_salt.Fish.Clear();
_fresh.Decor.Clear();
_salt.Decor.Clear();
_fresh.ResetToStarter();
_salt.ResetToStarter();
_active = _fresh;
IsActive = false;
FrameId = 0;
ClearBanner();
}
public static bool TryPlaceDecor( DecorDef def, bool announce = true )
{
if ( def is null || !_active.Unlocked )
return false;
if ( _active.Decor.Count >= _active.MaxDecor )
{
if ( announce )
ShowBanner( "Tank is full of junk. Tasteful junk limit reached." );
return false;
}
var w = def.Width;
var h = def.Height;
var margin = 20f;
var maxW = Width - margin * 2f;
if ( w > maxW )
w = maxW;
var x = margin + (float)_rng.NextDouble() * MathF.Max( 1f, Width - w - margin * 2f );
var y = ComputeDecorY( def, h );
_active.Decor.Add( new DecorPiece
{
DefId = def.Id,
X = x,
Y = y
} );
if ( def.ChaosOnPlace > 0f )
Chaos.Add( def.ChaosOnPlace );
if ( announce )
ShowBanner( $"Placed {def.Name}." );
FrameId++;
return true;
}
/// <summary>
/// Bottom of sprite sits on gravel, then sinks by <see cref="DecorDef.GroundSink"/>
/// so plant dirt bases don't float above the sand.
/// </summary>
public static float ComputeDecorRestY( DecorDef def, float h )
{
const float gravelH = 28f;
var gravelTop = Height - gravelH;
var sink = def?.GroundSink ?? 0f;
// Panel top = gravel top - height + sink (positive sink buries the base).
var y = gravelTop - h + sink;
// Keep a few px of headroom under the rim.
if ( y < 6f )
y = 6f;
return y;
}
// Back-compat alias used by place/reseat paths.
static float ComputeDecorY( DecorDef def, float h ) => ComputeDecorRestY( def, h );
public static float ClampDecorX( DecorDef def, float x )
{
if ( def is null )
return x;
var maxX = MathF.Max( 8f, Width - def.Width - 8f );
return Math.Clamp( x, 8f, maxX );
}
public static float ClampDecorY( DecorDef def, float y )
{
if ( def is null )
return y;
var rest = ComputeDecorRestY( def, def.Height );
// Allow floating above gravel; never below rest (buried base).
return Math.Clamp( y, 6f, rest );
}
public static DecorPiece GetActiveDecor( int index )
{
if ( index < 0 || index >= _active.Decor.Count )
return null;
return _active.Decor[index];
}
public static int IndexOfActiveDecor( DecorPiece piece )
{
if ( piece is null )
return -1;
return _active.Decor.IndexOf( piece );
}
/// <summary>Start a player drag — freezes physics on that piece.</summary>
public static bool TryBeginDecorDrag( DecorPiece piece )
{
if ( piece is null || !_active.Decor.Contains( piece ) )
return false;
// Only one held prop at a time.
foreach ( var p in _active.Decor )
p.Held = false;
piece.Held = true;
piece.Vx = 0f;
piece.Vy = 0f;
// Lift slightly so it clearly leaves the gravel while grabbed.
var def = piece.Def;
if ( def is not null )
{
var rest = ComputeDecorRestY( def, def.Height );
if ( piece.Y > rest - 18f )
piece.Y = rest - 18f;
}
FrameId++;
return true;
}
/// <summary>Nudge a held decor by mouse delta (tank px).</summary>
public static void NudgeDecorHeld( DecorPiece piece, float dx, float dy )
{
if ( piece is null || !piece.Held )
return;
var def = piece.Def;
if ( def is null )
return;
piece.X = ClampDecorX( def, piece.X + dx );
piece.Y = ClampDecorY( def, piece.Y + dy );
FrameId++;
}
/// <summary>Snap held decor so its center sits at tank-local (cx, cy).</summary>
public static void SetDecorHeldCenter( DecorPiece piece, float cx, float cy )
{
if ( piece is null || !piece.Held )
return;
var def = piece.Def;
if ( def is null )
return;
piece.X = ClampDecorX( def, cx - def.Width * 0.5f );
piece.Y = ClampDecorY( def, cy - def.Height * 0.5f );
FrameId++;
}
/// <summary>Release drag and apply throw velocity (tank px/s).</summary>
public static void EndDecorDrag( DecorPiece piece, float throwVx, float throwVy )
{
if ( piece is null )
return;
piece.Held = false;
var def = piece.Def;
const float maxThrow = 2600f;
var spd = MathF.Sqrt( throwVx * throwVx + throwVy * throwVy );
if ( spd > maxThrow && spd > 0.01f )
{
var s = maxThrow / spd;
throwVx *= s;
throwVy *= s;
spd = maxThrow;
}
// No deadzone kill — caller decides soft-drop by passing zeros.
piece.Vx = throwVx;
piece.Vy = throwVy;
if ( spd >= 1f && def is not null )
{
var rest = ComputeDecorRestY( def, def.Height );
// Leave the sand so the floor solver can't eat the launch.
piece.Y = MathF.Min( piece.Y, rest - 48f );
// Always give a bit of loft so throws leave gravel even on flat flicks.
if ( piece.Vy > -220f )
piece.Vy = -220f - MathF.Abs( piece.Vx ) * 0.2f;
piece.ThrowGrace = 0.55f;
}
else if ( def is not null )
{
var rest = ComputeDecorRestY( def, def.Height );
if ( piece.Y > rest - 28f )
piece.Y = rest;
piece.Vx = 0f;
piece.Vy = 0f;
piece.ThrowGrace = 0f;
}
FrameId++;
SaveGame.TrySave();
}
/// <summary>Water physics for floating / thrown decor.</summary>
static void TickDecorPhysics( TankSlot slot, float dt )
{
if ( slot is null || slot.Decor.Count == 0 || dt <= 0f )
return;
const float gravity = 650f;
const float waterDrag = 0.85f;
const float bounce = 0.55f;
const float floorFriction = 0.92f;
const float restSpeed = 18f;
var any = false;
foreach ( var piece in slot.Decor )
{
if ( piece is null || piece.Held )
continue;
var def = piece.Def;
if ( def is null )
continue;
if ( piece.ThrowGrace > 0f )
piece.ThrowGrace = MathF.Max( 0f, piece.ThrowGrace - dt );
var restY = ComputeDecorRestY( def, def.Height );
var grace = piece.ThrowGrace > 0f;
var moving = MathF.Abs( piece.Vx ) > 0.5f || MathF.Abs( piece.Vy ) > 0.5f;
var onFloor = piece.Y >= restY - 1.5f && !grace;
if ( !moving && onFloor )
{
piece.Vx = 0f;
piece.Vy = 0f;
piece.Y = restY;
continue;
}
any = true;
// Gravity (always during grace so arc looks right after loft).
piece.Vy += gravity * dt;
var damp = MathF.Exp( -waterDrag * dt );
piece.Vx *= damp;
piece.Vy *= damp;
piece.X += piece.Vx * dt;
piece.Y += piece.Vy * dt;
var maxX = MathF.Max( 8f, Width - def.Width - 8f );
if ( piece.X < 8f )
{
piece.X = 8f;
piece.Vx = MathF.Abs( piece.Vx ) * bounce;
}
else if ( piece.X > maxX )
{
piece.X = maxX;
piece.Vx = -MathF.Abs( piece.Vx ) * bounce;
}
if ( piece.Y < 6f )
{
piece.Y = 6f;
piece.Vy = MathF.Abs( piece.Vy ) * bounce * 0.45f;
}
if ( piece.Y >= restY )
{
piece.Y = restY;
if ( piece.Vy > 0f )
piece.Vy = -piece.Vy * bounce;
if ( !grace )
piece.Vx *= floorFriction;
if ( !grace
&& MathF.Abs( piece.Vy ) < restSpeed
&& MathF.Abs( piece.Vx ) < restSpeed )
{
piece.Vx = 0f;
piece.Vy = 0f;
piece.Y = restY;
piece.ThrowGrace = 0f;
}
}
}
if ( any )
FrameId++;
}
/// <summary>Re-seat every decor piece to current sizes (fixes old tiny/float saves).</summary>
public static void ReseatAllDecor()
{
ReseatSlot( _fresh );
if ( _salt.Unlocked )
ReseatSlot( _salt );
FrameId++;
}
static void ReseatSlot( TankSlot slot )
{
if ( slot is null )
return;
foreach ( var piece in slot.Decor )
{
var def = piece.Def;
if ( def is null )
continue;
var h = def.Height;
// Keep X; clamp so wide props stay on-screen.
piece.X = ClampDecorX( def, piece.X );
piece.Y = ComputeDecorY( def, h );
piece.Vx = 0f;
piece.Vy = 0f;
piece.Held = false;
piece.ThrowGrace = 0f;
}
}
public static bool TryUnlockSaltTank()
{
if ( _salt.Unlocked )
return false;
_salt.Unlocked = true;
_salt.ResetToStarter();
ShowBanner( "Saltwater tank unlocked. Anemones not included." );
return true;
}
/// <summary>Upgrade the active tank to the next habitat in the chain.</summary>
public static bool TryUpgradeHabitat( HabitatDef def )
{
if ( def is null || !_active.Unlocked )
return false;
if ( def.FightPrizeOnly )
{
ShowBanner( "That tank is a fight prize — win it in the arena." );
return false;
}
if ( !HabitatDef.MatchesTank( def, _active.Water ) )
{
ShowBanner( "That habitat doesn't fit this water type." );
return false;
}
if ( def.Rank != _active.HabitatRank + 1 )
{
ShowBanner( "Unlock habitats in order. No skipping the bathtub." );
return false;
}
_active.ApplyHabitat( def );
ShowBanner( string.IsNullOrWhiteSpace( def.Banner ) ? $"Moved into {def.Name}." : def.Banner );
FrameId++;
return true;
}
/// <summary>
/// Claim a tank won in battle — applies to the active tank (fresh or salt view).
/// Keeps capacity floor if already larger; renames the vessel.
/// </summary>
public static bool TryApplyFightPrize( HabitatDef def )
{
if ( def is null || !IsActive || !_active.Unlocked )
return false;
if ( !HabitatDef.MatchesTank( def, _active.Water ) )
{
// Fall back to the other tank if it fits and is unlocked
var other = _active == _fresh ? _salt : _fresh;
if ( other.Unlocked && HabitatDef.MatchesTank( def, other.Water ) )
{
other.ApplyHabitat( def, keepCapacityFloor: true );
other.Name = def.Name;
ShowBanner( string.IsNullOrWhiteSpace( def.Banner )
? $"Fight prize: {def.Name} installed on {other.Water} tank!"
: def.Banner );
FrameId++;
return true;
}
ShowBanner( $"{def.Name} doesn't fit your current tank water." );
return false;
}
_active.ApplyHabitat( def, keepCapacityFloor: true );
_active.Name = def.Name;
ShowBanner( string.IsNullOrWhiteSpace( def.Banner )
? $"Fight prize tank: {def.Name}!"
: def.Banner );
GameAudio.PlayUi( GameAudio.Upgrade );
FrameId++;
return true;
}
public static bool CanUpgradeTo( HabitatDef def )
{
if ( def is null || !_active.Unlocked || !IsActive )
return false;
if ( !HabitatDef.MatchesTank( def, _active.Water ) )
return false;
if ( def.Rank != _active.HabitatRank + 1 )
return false;
if ( Economy.PlaytimeSeconds < def.UnlockPlaytime )
return false;
return true;
}
public static bool TrySwitchTank( WaterType water )
{
if ( water == WaterType.Salt && !_salt.Unlocked )
{
ShowBanner( "No salt tank yet. Buy one in the shop." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
_active = water == WaterType.Salt ? _salt : _fresh;
FrameId++;
ShowBanner( water == WaterType.Salt ? "Now viewing SALT tank." : "Now viewing FRESH tank." );
GameAudio.PlayUi( GameAudio.Notice );
return true;
}
public static void IncreaseCapacity( int amount )
{
if ( amount <= 0 )
return;
_active.Capacity += amount;
}
public static bool RemoveFish( string instanceId )
{
if ( string.IsNullOrEmpty( instanceId ) )
return false;
var removed = _active.Fish.RemoveAll( f => f.InstanceId == instanceId ) > 0;
if ( removed )
FrameId++;
return removed;
}
public static FishActor FindFish( string instanceId )
{
if ( string.IsNullOrEmpty( instanceId ) )
return null;
foreach ( var f in _active.Fish )
{
if ( f.InstanceId == instanceId )
return f;
}
return null;
}
/// <summary>Shop buyback — ~half of buy cost, fat/jacked tweak. Removes fish.</summary>
public static double SellPrice( FishActor fish )
{
if ( fish?.Species is null )
return 1;
var basePrice = Math.Max( 1.0, fish.Species.BuyCost * 0.5 );
if ( fish.Jacked )
basePrice *= 1.25;
if ( fish.FatNorm > 0.5f )
basePrice *= 0.9; // chonk is harder to flip
if ( fish.IsSick )
basePrice *= 0.35;
return Math.Max( 1.0, Math.Round( basePrice, 1 ) );
}
public static bool TrySellFish( string instanceId )
{
var fish = FindFish( instanceId );
if ( fish is null )
return false;
var price = SellPrice( fish );
var name = fish.DisplayName;
if ( !RemoveFish( instanceId ) )
return false;
Economy.Add( price );
GameAudio.PlayCoin();
ShowBanner( $"Sold {name} for Ð{Economy.FormatDoge( price )}." );
Karma.Add( -0.5f );
return true;
}
/// <summary>
/// Move a fish from the active tank to the other unlocked tank.
/// Switches the camera to the destination so you see them arrive.
/// </summary>
public static bool TryMoveFishToOtherTank( string instanceId )
{
if ( !IsActive || !HasMultipleTanks )
{
ShowBanner( "Need both tanks to move fish." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
var fish = FindFish( instanceId );
if ( fish is null )
{
GameAudio.PlayUi( GameAudio.Error );
return false;
}
var dest = ViewingSalt ? _fresh : _salt;
var destLabel = ViewingSalt ? "Fresh" : "Salt";
if ( dest.Fish.Count >= dest.Capacity )
{
ShowBanner( $"{destLabel} tank is full ({dest.Capacity})." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
// Drop M80 tape / fight roster if this fish was armed or picked.
ExplodeSystem.Untape( instanceId );
BattleSystem.DeselectFish( instanceId );
_active.Fish.RemoveAll( f => f.InstanceId == instanceId );
// Drop into the middle of the other tank with a mild kick.
var marginX = fish.Species.Width * fish.DrawScaleX * 0.5f + 12f;
var marginY = fish.Species.Height * fish.DrawScaleY * 0.5f + 12f;
fish.X = marginX + (float)_rng.NextDouble() * (Width - marginX * 2f);
fish.Y = marginY + (float)_rng.NextDouble() * (Height - marginY * 2f);
var ang = (float)(_rng.NextDouble() * Math.PI * 2.0);
var spd = fish.Species.Speed * fish.SpeedMoveMul * (0.55f + (float)_rng.NextDouble() * 0.35f);
fish.Vx = MathF.Cos( ang ) * spd;
fish.Vy = MathF.Sin( ang ) * spd * 0.45f;
dest.Fish.Add( fish );
_active = dest;
var name = fish.DisplayName;
var wrong = fish.IsWrongWater( dest.Water );
if ( wrong )
{
ShowBanner( $"Moved {name} → {destLabel}. Wrong water ⚠" );
Chaos.Add( 2f );
Karma.Add( -1.5f );
}
else
{
ShowBanner( $"Moved {name} → {destLabel}." );
}
GameAudio.PlayUi( GameAudio.Confirm );
SaveGame.TrySave();
FrameId++;
return true;
}
/// <summary>
/// Trade for a different random species of the same water (stats mostly reset).
/// Small fee if upgrade; refund crumbs if downgrade.
/// </summary>
public static bool TryTradeFish( string instanceId )
{
var fish = FindFish( instanceId );
if ( fish is null )
return false;
var pool = FishSpecies.AllCatalog
.Where( s => s.Water == fish.Species.Water || s.Water == WaterType.Any )
.Where( s => s.Id != fish.Species.Id )
.ToList();
if ( pool.Count == 0 )
{
ShowBanner( "Nothing to trade for. Lonely catalog." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
var next = pool[_rng.Next( pool.Count )];
var fee = Math.Max( 0, Math.Round( (next.BuyCost - fish.Species.BuyCost) * 0.35, 1 ) );
if ( fee > 0 && !Economy.TrySpend( fee ) )
{
ShowBanner( $"Trade needs Ð{Economy.FormatDoge( fee )}." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( fee < 0 )
Economy.Add( Math.Abs( fee ) );
var old = fish.Species.Name;
fish.Species = next;
// Mild keep of training; drop jacked/meth comedy state for fairness
fish.Jacked = false;
fish.JackedTimer = 0f;
fish.OnMeth = false;
fish.MethTimer = 0f;
fish.Stoned = false;
fish.StonedTimer = 0f;
fish.Fat = MathF.Max( 0f, fish.Fat * 0.5f );
fish.Vx = fish.Species.Speed * (fish.Vx >= 0 ? 0.7f : -0.7f);
GameAudio.PlayUi( GameAudio.Trade );
ShowBanner( fee > 0
? $"Traded {old} → {next.Name} (−Ð{Economy.FormatDoge( fee )})."
: $"Traded {old} → {next.Name}." );
FrameId++;
return true;
}
public static bool Spawn( FishSpecies species )
{
if ( species is null || _active.Fish.Count >= _active.Capacity )
return false;
var marginX = species.Width * 0.5f + 8f;
var marginY = species.Height * 0.5f + 8f;
var angle = (float)(_rng.NextDouble() * Math.PI * 2.0);
// New fish start at base training (~3) so SpeedMoveMul ≈ 1.
var speed = species.Speed * (0.65f + (float)_rng.NextDouble() * 0.5f);
var fish = new FishActor
{
InstanceId = Guid.NewGuid().ToString( "N" )[..8],
Species = species,
X = marginX + (float)_rng.NextDouble() * (Width - marginX * 2f),
Y = marginY + (float)_rng.NextDouble() * (Height - marginY * 2f),
Vx = MathF.Cos( angle ) * speed,
Vy = MathF.Sin( angle ) * speed * 0.55f,
BobPhase = (float)(_rng.NextDouble() * Math.PI * 2.0),
BobSpeed = 2.2f + (float)_rng.NextDouble() * 2.5f,
BobAmp = 2.5f + (float)_rng.NextDouble() * 3.5f,
Endurance = 3f,
SpeedStat = 3f,
Agility = 3f
};
_active.Fish.Add( fish );
if ( fish.IsWrongWater( _active.Water ) )
{
ShowBanner( $"{species.Name} in {_active.Water} water. Science will notice." );
Chaos.Add( 4f );
Karma.Add( -3f );
Achievements.OnWrongWaterSpawn();
}
return true;
}
public const float MethDurationSeconds = 28f;
/// <summary>Dose every fish in the active tank. Builds addiction, clears withdrawal, zoomies.</summary>
public static int ApplyMethToActiveTank()
{
var count = 0;
var savedWrongWater = 0;
var newlyHooked = 0;
foreach ( var fish in _active.Fish )
{
var wasWrong = fish.IsWrongWater( _active.Water ) || fish.IsSick;
var wasAddicted = fish.IsAddicted;
// Chemical gym first (uses pre-dose addiction for diminishing returns).
TrainingSystem.ApplyMethTraining( fish );
// Tolerance: more addiction → shorter high, nastier redose need.
var duration = MethDurationSeconds * (1f - fish.AddictionNorm * 0.55f);
duration = MathF.Max( 8f, duration );
// Stack the habit hard on each dose.
var before = fish.Addiction;
fish.Addiction = Math.Clamp( fish.Addiction + 16f + fish.AddictionNorm * 10f, 0f, 100f );
if ( !wasAddicted && fish.IsAddicted )
{
newlyHooked++;
Achievements.OnFishAddicted();
}
fish.OnMeth = true;
fish.MethTimer = duration;
fish.WithdrawalTimer = 0f;
fish.SickTimer = 0f;
// Launch manic sprint — harder the more hooked; trained SPD still matters.
var angle = (float)(_rng.NextDouble() * Math.PI * 2.0);
var burst = fish.Species.Speed * fish.SpeedMoveMul * (2.4f + fish.AddictionNorm * 1.2f);
fish.Vx = MathF.Cos( angle ) * burst;
fish.Vy = MathF.Sin( angle ) * burst * 0.75f;
count++;
if ( wasWrong )
savedWrongWater++;
if ( fish.Addiction >= 75f && before < 75f )
ShowBanner( $"{fish.Species.Name} is a full-time client now." );
}
if ( count > 0 )
{
Chaos.Add( 10f + count * 2.5f + newlyHooked * 4f );
Achievements.OnTrained();
if ( newlyHooked > 0 )
ShowBanner( $"{count} dosed + trained. {newlyHooked} just got hooked." );
else if ( savedWrongWater > 0 )
ShowBanner( $"{count} fish FRIED · stats up. {savedWrongWater} wrong-water survivors." );
else
ShowBanner( $"{count} fish juiced · SPD training on the house." );
}
else
{
ShowBanner( "Empty tank. Meth has no audience." );
}
FrameId++;
return count;
}
public static void Tick( float dt )
{
if ( !IsActive || dt <= 0f )
return;
if ( dt > 0.05f )
dt = 0.05f;
if ( _bannerTimer > 0f )
{
_bannerTimer -= dt;
if ( _bannerTimer <= 0f )
_bannerVersion++;
}
// Swim + salinity for the tank you're watching.
TickSlot( _active, dt, visual: true );
// Off-screen tank still suffers salinity (cruel but fair).
var other = _active == _fresh ? _salt : _fresh;
if ( other.Unlocked )
TickSlot( other, dt, visual: false );
// Decor tumble / throw settle (both tanks so mid-flight props keep moving).
TickDecorPhysics( _active, dt );
if ( _salt.Unlocked )
TickDecorPhysics( _salt, dt );
FrameId++;
}
static void TickSlot( TankSlot slot, float dt, bool visual )
{
var recoveredFromFight = 0;
string recoveredName = null;
for ( var i = slot.Fish.Count - 1; i >= 0; i-- )
{
var fish = slot.Fish[i];
TickSalinity( slot, fish, dt );
if ( fish.SickTimer >= SickDeathSeconds && !fish.OnMeth )
{
var name = fish.DisplayName;
var deathX = fish.X;
var deathY = fish.DrawY;
slot.Fish.RemoveAt( i );
if ( visual || slot == _active )
{
ShowBanner( $"{name} died of salinity." );
ExplodeSystem.SpawnDeathPoof( deathX, deathY );
Achievements.OnFishDiedSalinity();
}
Chaos.Add( 3f );
GameAudio.PlayUi( GameAudio.Error );
continue;
}
TrainingSystem.TickFish( fish, dt );
fish.TickFat( dt );
fish.TickStoned( dt );
if ( fish.TickFightInjury( dt ) )
{
recoveredFromFight++;
recoveredName ??= fish.DisplayName;
}
if ( visual )
Integrate( fish, dt, slot );
}
// One banner for fight cooldown ending (not one per fish).
if ( recoveredFromFight > 0 && (visual || slot == _active) )
{
if ( recoveredFromFight == 1 && !string.IsNullOrEmpty( recoveredName ) )
ShowBanner( $"{recoveredName} is fight-ready again." );
else
ShowBanner( $"{recoveredFromFight} fish ready to fight again." );
GameAudio.PlayUi( GameAudio.Notice );
}
if ( visual && slot.Fish.Count > 0 )
{
// Per-fish wander: high Agility turns more often and sharper (via NudgeHeading).
foreach ( var fish in slot.Fish )
{
if ( fish.OnMeth )
{
if ( _rng.NextDouble() < dt * (3.2f + fish.Agility * 0.25f) )
NudgeHeadingMeth( fish );
continue;
}
if ( FeedSystem.TryGetNearestPellet( fish.X, fish.Y, out _, out _ ) )
continue;
if ( _rng.NextDouble() < dt * fish.WanderRate )
NudgeHeading( fish );
}
}
}
static void TickSalinity( TankSlot slot, FishActor fish, float dt )
{
// --- High ---
if ( fish.OnMeth )
{
fish.MethTimer -= dt;
// Habit creeps up while fried.
fish.Addiction = Math.Clamp( fish.Addiction + dt * 0.35f, 0f, 100f );
if ( fish.MethTimer <= 0f )
{
fish.OnMeth = false;
fish.MethTimer = 0f;
// Come-down crash → withdrawal if they've built a habit.
fish.Vx *= 0.28f;
fish.Vy *= 0.28f;
if ( fish.Addiction >= 15f )
{
fish.WithdrawalTimer = 10f + fish.AddictionNorm * 22f;
Achievements.OnWithdrawalStarted();
if ( slot == _active )
ShowBanner( $"{fish.Species.Name} crashed. Withdrawal has entered the chat." );
}
}
else
{
fish.SickTimer = 0f;
fish.WithdrawalTimer = 0f;
if ( _rng.NextDouble() < dt * 0.35 )
Chaos.Add( 0.25f );
}
}
// --- Withdrawal (sober but hurting) ---
else if ( fish.WithdrawalTimer > 0f )
{
fish.WithdrawalTimer -= dt;
// Slow natural recovery of the habit while clean.
fish.Addiction = MathF.Max( 0f, fish.Addiction - dt * 0.55f );
if ( _rng.NextDouble() < dt * 0.4 )
Chaos.Add( 0.15f + fish.AddictionNorm * 0.2f );
// Heavy addicts in withdrawal + wrong water die faster.
if ( fish.IsWrongWater( slot.Water ) )
fish.SickTimer += dt * (1f + fish.AddictionNorm * 0.85f);
}
else if ( fish.IsAddicted && !fish.OnMeth )
{
// Sober addict: very slow recovery, mild restlessness.
fish.Addiction = MathF.Max( 0f, fish.Addiction - dt * 0.2f );
}
if ( !fish.IsWrongWater( slot.Water ) )
{
if ( !fish.OnMeth && !fish.InWithdrawal )
fish.SickTimer = MathF.Max( 0f, fish.SickTimer - dt * 2f );
return;
}
// Wrong water without meth (and not already accelerating via withdrawal branch).
if ( !fish.OnMeth && fish.WithdrawalTimer <= 0f )
fish.SickTimer += dt;
}
/// <summary>Rename a fish in either tank. Empty name clears nickname.</summary>
public static bool TryRenameFish( string instanceId, string nickname )
{
var fish = FindFish( instanceId );
if ( fish is null )
return false;
if ( !fish.TrySetNickname( nickname ) )
{
ShowBanner( "Name needs letters/numbers only." );
return false;
}
// Quiet — fish menu title updates live; no banner spam while cycling names.
SaveGame.TrySave();
return true;
}
/// <summary>Click a fish — it darts away. Does not feed.</summary>
public static bool PokeFish( string instanceId )
{
if ( !IsActive || string.IsNullOrEmpty( instanceId ) )
return false;
FishActor fish = null;
foreach ( var f in _active.Fish )
{
if ( f.InstanceId == instanceId )
{
fish = f;
break;
}
}
if ( fish is null )
return false;
// Burst away from center-ish with some randomness so they "scurry".
var awayX = fish.X - Width * 0.5f;
var awayY = fish.Y - Height * 0.5f;
if ( MathF.Abs( awayX ) < 8f && MathF.Abs( awayY ) < 8f )
{
awayX = (float)(_rng.NextDouble() * 2.0 - 1.0);
awayY = (float)(_rng.NextDouble() * 2.0 - 1.0);
}
// Bias horizontal escape so facing flip is obvious.
awayX += (float)(_rng.NextDouble() * 2.0 - 1.0) * 40f;
awayY += (float)(_rng.NextDouble() * 2.0 - 1.0) * 18f;
var len = MathF.Sqrt( awayX * awayX + awayY * awayY );
if ( len < 0.01f )
len = 1f;
var burst = fish.Species.Speed * fish.SpeedMoveMul * (2.6f + (float)_rng.NextDouble() * 1.1f);
if ( fish.Jacked )
burst *= 1.25f;
if ( fish.OnMeth )
burst *= 1.35f;
// Agile fish snap the dart harder; slow tanks lumber the escape.
burst *= 0.85f + fish.AgilityTurnMul * 0.2f;
fish.Vx = awayX / len * burst;
fish.Vy = awayY / len * burst * 0.72f;
// High agility = shorter, snappier scurry; low = longer coast.
fish.ScurryTimer = (0.4f + (float)_rng.NextDouble() * 0.3f) * (1.15f - fish.AgilityNorm * 0.35f);
fish.BobPhase += 1.2f;
FrameId++;
return true;
}
static void Integrate( FishActor fish, float dt, TankSlot slot )
{
var trainSpd = fish.SpeedMoveMul;
var trainAgi = fish.AgilityTurnMul;
if ( fish.ScurryTimer > 0f )
{
fish.ScurryTimer = MathF.Max( 0f, fish.ScurryTimer - dt );
// Agile fish zigzag harder while darting.
var wiggle = 70f + trainAgi * 55f;
fish.Vx += ((float)_rng.NextDouble() - 0.5f) * wiggle * dt;
fish.Vy += ((float)_rng.NextDouble() - 0.5f) * (wiggle * 0.8f) * dt;
var scurryBob = 2.4f + fish.SpeedNorm;
fish.BobPhase += fish.BobSpeed * dt * scurryBob;
var scurryMul = 1.2f + trainSpd * 0.25f;
fish.X += fish.Vx * dt * scurryMul;
fish.Y += fish.Vy * dt * scurryMul;
ClampFish( fish, dt );
return;
}
// Seek flakes when present — trained speed actually chases faster.
if ( FeedSystem.TryGetNearestPellet( fish.X, fish.Y, out var px, out var py ) )
{
var dx = px - fish.X;
var dy = py - fish.Y;
var len = MathF.Sqrt( dx * dx + dy * dy );
if ( len > 1f )
{
var seek = fish.Species.Speed * trainSpd * 1.15f * FeedSystem.FishSpeedMul;
// Agile fish cut corners toward the pellet (blend less with old vel).
var wantVx = dx / len * seek;
var wantVy = dy / len * seek * 0.85f;
var snap = Math.Clamp( fish.TurnSnap, 0.35f, 1f );
fish.Vx += (wantVx - fish.Vx) * snap;
fish.Vy += (wantVy - fish.Vy) * snap;
}
}
// Meth zoomies / withdrawal shuffle / sick fade / food buzz.
// Trained Speed is the base swim multiplier for healthy fish.
var speedMul = FeedSystem.FishSpeedMul * trainSpd;
var bobMul = FeedSystem.FishBobMul;
// Fast fish bob harder (exertion); tanky fish bob slower/steadier.
bobMul *= 1f + fish.SpeedNorm * 0.35f - fish.EnduranceNorm * 0.12f;
// Continuous micro-steering: agile fish weave; clumsy fish hold a line.
if ( !fish.OnMeth && !fish.Stoned && !fish.IsSick && !fish.IsFightInjured )
{
var weave = 12f + trainAgi * 38f;
fish.Vx += ((float)_rng.NextDouble() - 0.5f) * weave * dt;
fish.Vy += ((float)_rng.NextDouble() - 0.5f) * (weave * 0.7f) * dt;
}
// Post-fight injury: sluggish + limp bob until cooldown ends.
if ( fish.IsFightInjured )
{
var limp = 0.45f + (1f - fish.FightInjuryProgress) * 0.35f;
speedMul *= limp;
bobMul *= 1.4f + fish.FightInjuryProgress * 0.8f;
fish.Vy += 10f * fish.FightInjuryProgress * dt;
}
// Overfed chonks lumber — still swim, just slower and bouncier.
if ( fish.FatNorm > 0.2f )
{
speedMul *= 1f - fish.FatNorm * 0.32f;
bobMul *= 1f + fish.FatNorm * 0.9f;
fish.Vy += fish.FatNorm * 8f * dt; // sink a little under their own weight
}
if ( fish.Jacked && !fish.OnMeth )
{
speedMul *= 1.45f + fish.SpeedStat * 0.05f;
bobMul *= 1.8f;
// Bulk presence — slightly more aggressive wander
fish.Vx += ((float)_rng.NextDouble() - 0.5f) * 40f * dt;
}
if ( fish.OnMeth )
{
// Base zoomies + addiction makes them even more unhinged.
speedMul = (2.7f + fish.AddictionNorm * 0.9f) * trainSpd;
bobMul = 4.2f + fish.AddictionNorm * 2f;
if ( fish.Jacked )
speedMul += 0.4f;
var thrash = 220f + fish.AddictionNorm * 120f + trainAgi * 40f;
fish.Vx += ((float)_rng.NextDouble() - 0.5f) * thrash * dt;
fish.Vy += ((float)_rng.NextDouble() - 0.5f) * (thrash * 0.8f) * dt;
var spd = MathF.Sqrt( fish.Vx * fish.Vx + fish.Vy * fish.Vy );
var want = fish.Species.Speed * trainSpd * (2.3f + fish.AddictionNorm * 0.8f);
if ( spd < want && spd > 0.01f )
{
fish.Vx *= want / spd;
fish.Vy *= want / spd;
}
else if ( spd < 0.01f )
{
NudgeHeadingMeth( fish );
}
}
else if ( fish.Stoned )
{
// Lava-lamp drift — slow, wavy, occasionally forgets where the wall is.
// Trained speed still helps a little; agility becomes floaty weave.
speedMul *= 0.42f;
bobMul *= 2.4f;
fish.BobPhase += dt * 1.1f;
fish.Vx += MathF.Sin( fish.BobPhase * 0.7f ) * (22f + trainAgi * 10f) * dt;
fish.Vy += MathF.Cos( fish.BobPhase * 0.55f ) * (18f + trainAgi * 8f) * dt;
fish.Vx *= 0.992f;
fish.Vy *= 0.992f;
}
else if ( fish.InWithdrawal )
{
// Slow, twitchy, miserable — training barely helps.
speedMul = MathF.Max( 0.18f, (0.45f - fish.AddictionNorm * 0.2f) * (0.7f + trainSpd * 0.3f) );
bobMul = 1.6f + fish.AddictionNorm;
fish.Vx += ((float)_rng.NextDouble() - 0.5f) * 40f * dt;
fish.Vy += ((float)_rng.NextDouble() - 0.5f) * 35f * dt;
fish.Vy += 12f * dt; // sink a little, depressed
}
else if ( fish.IsSick )
{
var p = fish.SickProgress;
speedMul = MathF.Max( 0.12f, (0.7f - p * 0.55f) * (0.75f + trainSpd * 0.25f) );
bobMul = 1f + p * 2.8f;
fish.Vx += ((float)_rng.NextDouble() - 0.5f) * (20f + p * 140f) * dt;
fish.Vy += ((float)_rng.NextDouble() - 0.5f) * (15f + p * 100f) * dt;
fish.Vy += (18f + p * 55f) * dt;
}
fish.BobPhase += fish.BobSpeed * dt * bobMul;
fish.X += fish.Vx * dt * speedMul;
fish.Y += fish.Vy * dt * speedMul;
ClampFish( fish, dt );
}
static void ClampFish( FishActor fish, float dt )
{
var halfW = fish.Species.Width * fish.DrawScaleX * 0.5f;
var halfH = fish.Species.Height * fish.DrawScaleY * 0.5f;
var minX = halfW + 4f;
var maxX = Width - halfW - 4f;
var minY = halfH + 6f;
var maxY = Height - halfH - 10f;
// High agility = snappy wall bank; low = soft bounce (lose speed).
var bank = Math.Clamp( 0.55f + fish.Agility * 0.05f, 0.55f, 1.05f );
if ( fish.X < minX )
{
fish.X = minX;
fish.Vx = MathF.Abs( fish.Vx ) * bank;
}
else if ( fish.X > maxX )
{
fish.X = maxX;
fish.Vx = -MathF.Abs( fish.Vx ) * bank;
}
if ( fish.Y < minY )
{
fish.Y = minY;
fish.Vy = MathF.Abs( fish.Vy ) * bank;
}
else if ( fish.Y > maxY )
{
fish.Y = maxY;
fish.Vy = -MathF.Abs( fish.Vy ) * bank;
}
fish.Vy *= 1f - (0.15f * dt);
// Cruise floor from species + trained speed (food boost when fed).
var minSpeed = fish.Species.Speed * fish.SpeedMoveMul * 0.32f;
if ( FeedSystem.IsBoosted )
minSpeed *= FeedSystem.FishSpeedMul;
var speed = MathF.Sqrt( fish.Vx * fish.Vx + fish.Vy * fish.Vy );
if ( speed < minSpeed && speed > 0.001f )
{
var scale = minSpeed / speed;
fish.Vx *= scale;
fish.Vy *= scale;
}
}
static void NudgeHeading( FishActor fish )
{
// Turn within agility cone around current heading — high AGI = hard cut, low = gentle bank.
var cur = MathF.Atan2( fish.Vy, fish.Vx );
if ( float.IsNaN( cur ) )
cur = 0f;
var maxDelta = fish.MaxTurnRadians;
var delta = ((float)_rng.NextDouble() * 2f - 1f) * maxDelta;
var angle = cur + delta;
var speed = fish.Species.Speed * fish.SpeedMoveMul * (0.7f + (float)_rng.NextDouble() * 0.45f);
var wantVx = MathF.Cos( angle ) * speed;
var wantVy = MathF.Sin( angle ) * speed * (0.35f + fish.AgilityNorm * 0.25f);
var snap = fish.TurnSnap;
fish.Vx += (wantVx - fish.Vx) * snap;
fish.Vy += (wantVy - fish.Vy) * snap;
}
static void NudgeHeadingMeth( FishActor fish )
{
// Manic sprint — still respects trained speed; agility widens thrash angles.
var cur = MathF.Atan2( fish.Vy, fish.Vx );
if ( float.IsNaN( cur ) )
cur = 0f;
var maxDelta = MathF.Min( MathF.PI * 0.95f, fish.MaxTurnRadians * 1.35f );
var delta = ((float)_rng.NextDouble() * 2f - 1f) * maxDelta;
var angle = cur + delta;
var speed = fish.Species.Speed * fish.SpeedMoveMul * (2.2f + (float)_rng.NextDouble() * 1.4f);
var wantVx = MathF.Cos( angle ) * speed;
var wantVy = MathF.Sin( angle ) * speed * 0.85f;
var snap = Math.Clamp( fish.TurnSnap + 0.15f, 0.5f, 1f );
fish.Vx += (wantVx - fish.Vx) * snap;
fish.Vy += (wantVy - fish.Vy) * snap;
}
public static void ShowBanner( string text )
{
_banner = text ?? "";
// Short, discreet overlay — doesn't hold the screen hostage
_bannerTimer = 2.4f;
_bannerVersion++;
}
static void ClearBanner()
{
_banner = "";
_bannerTimer = 0f;
_bannerVersion++;
}
}