Feed system for the game. Defines a FoodPellet data class and a static FeedSystem that tracks unlocked foods, selected food, spawning pellets when feeding, boost multipliers, pellet lifetime, collision with fish, saving/loading unlocked/selected food, and simple UI/audio feedback.
namespace NoChillquarium;
public sealed class FoodPellet
{
public float X { get; set; }
public float Y { get; set; }
public float Life { get; set; }
public string Color { get; set; } = "#e8c060";
/// <summary>Food type that spawned this pellet (for eat effects).</summary>
public string FoodId { get; set; }
}
/// <summary>
/// Food ladder: selected feed type, pellets, boost multipliers, chaos.
/// </summary>
public static class FeedSystem
{
const float Cooldown = 0.45f;
const float PelletLife = 4.5f;
static readonly List<FoodPellet> _pellets = new();
static readonly HashSet<string> _unlocked = new();
static readonly Random _rng = new();
static float _cooldown;
static float _boostLeft;
static float _boostMul = 1f;
static float _bobMul = 1f;
static float _speedMul = 1f;
static int _version;
static FoodDef _selected;
public static IReadOnlyList<FoodPellet> Pellets => _pellets;
public static int Version => _version;
public static bool IsBoosted => _boostLeft > 0f;
public static float IncomeMultiplier => IsBoosted ? _boostMul : 1f;
public static float FishBobMul => IsBoosted ? _bobMul : 1f;
public static float FishSpeedMul => IsBoosted ? _speedMul : 1f;
/// <summary>Currently selected food, or null if nothing owned yet.</summary>
public static FoodDef Selected => _selected;
public static bool HasAnyFood => _unlocked.Count > 0;
public static string BoostText =>
IsBoosted && _selected is not null ? $"{_selected.Name} ×{_boostMul:0.##}" : "Hungry";
public static bool IsUnlocked( string id ) =>
!string.IsNullOrEmpty( id ) && _unlocked.Contains( id );
public static bool IsUnlocked( FoodDef def ) =>
def is not null && _unlocked.Contains( def.Id );
public static void Clear()
{
_pellets.Clear();
_cooldown = 0f;
_boostLeft = 0f;
_boostMul = 1f;
_bobMul = 1f;
_speedMul = 1f;
_selected = null;
_unlocked.Clear();
_version++;
}
public static void Load( List<string> unlockedIds, string selectedId )
{
// Drop mid-session pellets / boost; restore owned food from save.
_pellets.Clear();
_cooldown = 0f;
_boostLeft = 0f;
_boostMul = 1f;
_bobMul = 1f;
_speedMul = 1f;
_unlocked.Clear();
if ( unlockedIds is not null )
{
foreach ( var id in unlockedIds )
{
if ( !string.IsNullOrWhiteSpace( id ) && FoodDef.FindExact( id ) is not null )
_unlocked.Add( id );
}
}
_selected = null;
if ( !string.IsNullOrWhiteSpace( selectedId ) && IsUnlocked( selectedId ) )
_selected = FoodDef.FindExact( selectedId );
if ( _selected is null )
_selected = FoodDef.Catalog.FirstOrDefault( IsUnlocked );
_version++;
}
public static void WriteToSave( SaveData data )
{
if ( data is null )
return;
data.UnlockedFoods = _unlocked.ToList();
data.SelectedFoodId = _selected?.Id ?? "";
}
public static bool CanUnlock( FoodDef def )
{
if ( def is null || IsUnlocked( def ) )
return false;
if ( Economy.PlaytimeSeconds < def.UnlockPlaytime )
return false;
if ( def.UnlockCost > 0 && Economy.Balance < def.UnlockCost )
return false;
return true;
}
public static string UnlockBlockedReason( FoodDef def )
{
if ( def is null )
return "Invalid";
if ( IsUnlocked( def ) )
return "Owned";
if ( Economy.PlaytimeSeconds < def.UnlockPlaytime )
{
var left = def.UnlockPlaytime - Economy.PlaytimeSeconds;
var totalMin = (int)(left / 60f);
if ( totalMin >= 60 )
{
var h = totalMin / 60;
var m = totalMin % 60;
return m > 0 ? $"Wait {h}h {m}m" : $"Wait {h}h";
}
return totalMin > 0 ? $"Wait {totalMin}m" : $"Wait {(int)left}s";
}
if ( def.UnlockCost > 0 && Economy.Balance < def.UnlockCost )
return $"Need Ð{Economy.FormatDoge( def.UnlockCost )}";
return "";
}
public static bool TryUnlock( FoodDef def )
{
if ( def is null || IsUnlocked( def ) )
return false;
if ( Economy.PlaytimeSeconds < def.UnlockPlaytime )
{
TankSim.ShowBanner( $"{def.Name} unlocks after more playtime." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( def.UnlockCost > 0 && !Economy.TrySpend( def.UnlockCost ) )
{
TankSim.ShowBanner( $"Need Ð{Economy.FormatDoge( def.UnlockCost )} for {def.Name}." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
_unlocked.Add( def.Id );
_selected = def;
GameAudio.PlayUi( GameAudio.Upgrade );
TankSim.ShowBanner( $"Bought {def.Name}." );
Achievements.OnFoodUnlocked( def.Id );
SaveGame.TrySave();
_version++;
return true;
}
public static void Select( FoodDef def )
{
if ( def is null || !IsUnlocked( def ) )
{
GameAudio.PlayUi( GameAudio.Error );
return;
}
_selected = def;
GameAudio.PlayUi( GameAudio.Pop );
_version++;
}
/// <summary>Cycle to next unlocked food (dock shortcut).</summary>
public static void CycleSelected()
{
var list = FoodDef.Catalog.Where( IsUnlocked ).ToList();
if ( list.Count == 0 )
{
GameAudio.PlayUi( GameAudio.Error );
TankSim.ShowBanner( "No food yet — Shop → Food." );
return;
}
if ( list.Count == 1 )
return;
var i = list.FindIndex( f => f.Id == _selected?.Id );
i = (i + 1) % list.Count;
_selected = list[i];
GameAudio.PlayUi( GameAudio.Pop );
_version++;
}
public static bool TryFeed()
{
if ( !TankSim.IsActive )
return false;
if ( !HasAnyFood || _selected is null || !IsUnlocked( _selected ) )
{
GameAudio.PlayUi( GameAudio.Error );
TankSim.ShowBanner( "No food — Shop → Food." );
return false;
}
if ( _cooldown > 0f )
{
GameAudio.PlayUi( GameAudio.Error );
return false;
}
var food = _selected;
_cooldown = Cooldown;
_boostLeft = food.BoostDuration;
_boostMul = food.BoostMultiplier;
_bobMul = food.BobMulWhileBoost;
_speedMul = food.SpeedMulWhileBoost;
if ( food.ChaosOnFeed > 0f )
Chaos.Add( food.ChaosOnFeed );
// Proper diet builds karma; horrors spend it.
if ( food.ChaosOnFeed <= 0.5f )
Karma.Add( 1.2f );
else if ( food.ChaosOnFeed < 8f )
Karma.Add( -1.5f );
else
Karma.Add( -4f );
for ( var i = 0; i < food.PelletCount; i++ )
{
_pellets.Add( new FoodPellet
{
X = 40f + (float)_rng.NextDouble() * (TankSim.Width - 80f),
Y = 18f + (float)_rng.NextDouble() * 50f,
Life = PelletLife * (0.7f + (float)_rng.NextDouble() * 0.5f),
Color = food.PelletColor,
FoodId = food.Id
} );
}
// Special joke feeds
if ( food.Id == FoodDef.HomelessMan.Id )
GameAudio.PlayUi( GameAudio.Notice );
else if ( food.Id == FoodDef.WeedGummies.Id )
GameAudio.PlayUi( GameAudio.Notice );
else
GameAudio.PlayUi( GameAudio.Feed );
Achievements.OnFed();
Achievements.OnFoodFed( food.Id );
TankSim.ShowBanner( food.Id == FoodDef.WeedGummies.Id
? "Weed gummies in the water. Somebody's about to see God."
: $"Fed {food.Name}." );
_version++;
return true;
}
public static void Tick( float dt )
{
if ( dt <= 0f )
return;
if ( dt > 0.05f )
dt = 0.05f;
if ( _cooldown > 0f )
_cooldown = MathF.Max( 0f, _cooldown - dt );
if ( _boostLeft > 0f )
{
_boostLeft = MathF.Max( 0f, _boostLeft - dt );
if ( _boostLeft <= 0f )
{
_boostMul = 1f;
_bobMul = 1f;
_speedMul = 1f;
}
}
for ( var i = _pellets.Count - 1; i >= 0; i-- )
{
var p = _pellets[i];
p.Y += 22f * dt;
p.Life -= dt;
if ( p.Y > TankSim.Height - 20f )
p.Y = TankSim.Height - 20f;
foreach ( var fish in TankSim.Fish )
{
// Fatter fish have a bigger mouth hitbox (and get fatter still).
var reach = 18f + fish.FatNorm * 10f;
var dx = fish.X - p.X;
var dy = fish.DrawY - p.Y;
if ( dx * dx + dy * dy < reach * reach )
{
var food = FoodDef.FindExact( p.FoodId ) ?? _selected ?? FoodDef.Flakes;
fish.EatPellet( food.ChaosOnFeed, food.Id );
p.Life = 0f;
break;
}
}
if ( p.Life <= 0f )
_pellets.RemoveAt( i );
}
if ( _pellets.Count > 0 || IsBoosted )
_version++;
}
public static bool TryGetNearestPellet( float x, float y, out float px, out float py )
{
px = 0;
py = 0;
if ( _pellets.Count == 0 )
return false;
FoodPellet best = null;
var bestD = float.MaxValue;
foreach ( var p in _pellets )
{
var dx = p.X - x;
var dy = p.Y - y;
var d = dx * dx + dy * dy;
if ( d < bestD )
{
bestD = d;
best = p;
}
}
if ( best is null )
return false;
px = best.X;
py = best.Y;
return true;
}
}