BattleSystem.cs
namespace NoChillquarium;
/// <summary>
/// Local AI fish battles (v1). Stats + matchup + RNG.
/// No network — designed so a future IBattleService can wrap this.
/// </summary>
public sealed class BattleTemplate
{
public string Id { get; init; }
public string Name { get; init; }
public string Theme { get; init; }
public string Note { get; init; }
public int TeamSize { get; init; }
public double EntryFee { get; init; }
public double RewardMin { get; init; }
public double RewardMax { get; init; }
public float AiStatMin { get; init; }
public float AiStatMax { get; init; }
public bool AiJacked { get; init; }
/// <summary>
/// Lifetime Ð earned required to unlock this circuit (estimated grind target, floored).
/// Replaces playtime gates — first fight ≈ 5 min of earning.
/// </summary>
public double UnlockLifetimeEarned { get; init; }
public int MinHabitatRank { get; init; }
public float ChaosOnWin { get; init; }
public float ChaosOnLose { get; init; }
public float DecorChance { get; init; }
public string AiColor { get; init; }
public string[] AiNames { get; init; }
/// <summary>First-win tank/habitat prize (id in HabitatDef). Empty = cash only.</summary>
public string RewardHabitatId { get; init; }
/// <summary>Multi-round cup: one entry fee, locked team, scaled AI each round.</summary>
public bool IsChampionship { get; init; }
/// <summary>Rounds to clear for the title (0 = normal single fight).</summary>
public int ChampionshipRounds { get; init; }
}
public enum BattleAnim
{
Idle,
Lunge,
Hit,
Dodge,
Down,
Win
}
/// <summary>One fish in the fight — combat stats + arena visuals.</summary>
public sealed class BattleFighter
{
public string Name { get; set; }
public string Color { get; set; }
public string FinColor { get; set; }
public float Hp { get; set; }
public float MaxHp { get; set; }
public float Power { get; set; }
public float Speed { get; set; }
public float Agility { get; set; }
public bool Jacked { get; set; }
public bool IsMonster { get; set; }
public bool Player { get; set; }
public string SourceId { get; set; } // fish instance id if player
public string SpriteId { get; set; }
public bool HasSprite => !string.IsNullOrEmpty( SpriteId ) && SpriteCatalog.Has( SpriteId );
public bool Alive => Hp > 0.01f;
// Arena layout (pixels inside battle stage)
public float HomeX { get; set; }
public float HomeY { get; set; }
public float DrawX { get; set; }
public float DrawY { get; set; }
public float BobPhase { get; set; }
public float Shake { get; set; }
/// <summary>+1 faces right, -1 faces left.</summary>
public int Facing { get; set; } = 1;
public BattleAnim Anim { get; set; } = BattleAnim.Idle;
public float AnimT { get; set; }
public float Width { get; set; } = 48f;
public float Height { get; set; } = 32f;
}
/// <summary>Floating damage / dodge / KO text in the arena.</summary>
public sealed class BattleFxItem
{
public string Key { get; set; }
public string Label { get; set; }
public float X { get; set; }
public float Y { get; set; }
public float Life { get; set; }
public float MaxLife { get; set; }
public float OffsetY { get; set; }
public string Kind { get; set; } // hit, crit, dodge, ko
}
public enum BattlePhase
{
Closed,
Lobby,
Fighting,
Result
}
/// <summary>
/// Auto-resolving team battles against AI templates — with animated arena.
/// </summary>
public static class BattleSystem
{
public const float ArenaW = 620f;
public const float ArenaH = 210f;
static readonly Random _rng = new();
static int _version;
static BattlePhase _phase = BattlePhase.Closed;
static BattleTemplate _template;
static readonly List<BattleFighter> _playerTeam = new();
static readonly List<BattleFighter> _aiTeam = new();
static readonly List<string> _log = new();
static readonly List<BattleFxItem> _fx = new();
static readonly HashSet<string> _selectedIds = new();
static float _fightTimer;
static float _nextBeat;
static float _beatDuration = 0.85f;
static int _round;
static bool _playerWon;
static double _payout;
static string _resultTitle = "";
static string _resultBody = "";
static int _wins;
static int _losses;
static readonly HashSet<string> _beaten = new();
static readonly HashSet<string> _prizeHabitatsWon = new();
static string _lastPrizeTankName = "";
// Championship cup run (multi-round, one entry fee)
static bool _champRunActive;
static int _champRound; // 1-based during a run
static int _champTitles;
static double _champRunEarnings;
static readonly List<string> _champRosterIds = new();
static bool _resultAdvancesChamp; // Result → next round (not lobby)
// Current visual beat
static BattleFighter _visAtk;
static BattleFighter _visTgt;
static string _visKind = ""; // hit, crit, dodge, ko
static float _impactFlash;
static float _arenaShake;
static bool _impactFired;
/// <summary>null = still fighting; true/false = end after current beat anim.</summary>
static bool? _pendingFinish;
static float _pendingDmg;
static bool _pendingKo;
public static int Version => _version;
public static string LastPrizeTankName => _lastPrizeTankName;
public static int PrizeTanksWon => _prizeHabitatsWon.Count;
public static bool HasWonPrizeTank( string habitatId ) =>
!string.IsNullOrEmpty( habitatId ) && _prizeHabitatsWon.Contains( habitatId );
public static BattlePhase Phase => _phase;
public static bool IsOpen => _phase != BattlePhase.Closed;
public static BattleTemplate Template => _template;
public static IReadOnlyList<BattleFighter> PlayerTeam => _playerTeam;
public static IReadOnlyList<BattleFighter> AiTeam => _aiTeam;
public static IReadOnlyList<string> Log => _log;
public static IReadOnlyList<BattleFxItem> Fx => _fx;
public static bool PlayerWon => _playerWon;
public static double Payout => _payout;
public static string ResultTitle => _resultTitle;
public static string ResultBody => _resultBody;
public static void AppendResultNote( string note )
{
if ( string.IsNullOrEmpty( note ) || _phase != BattlePhase.Result )
return;
_resultBody += note;
_version++;
}
public static int Wins => _wins;
public static int Losses => _losses;
public static int Round => _round;
public static float FightPulse => _fightTimer;
public static float ImpactFlash => _impactFlash;
public static float ArenaShake => _arenaShake;
public static string VisKind => _visKind;
public static BattleFighter VisAttacker => _visAtk;
public static BattleFighter VisTarget => _visTgt;
public static bool ChampRunActive => _champRunActive;
public static int ChampRound => _champRound;
public static int ChampionshipTitles => _champTitles;
public static double ChampRunEarnings => _champRunEarnings;
public static bool ResultAdvancesChamp => _resultAdvancesChamp;
public static bool IsChampionshipTemplate => _template?.IsChampionship == true;
public static int ChampRoundsTotal =>
_template is not null && _template.ChampionshipRounds > 0
? _template.ChampionshipRounds
: 0;
// Beat previous circuit also unlocks the next. Lifetime Ð is the soft gate.
// Wins pay ~2× entry so fighting is the fun grind, not a sink.
public static IReadOnlyList<BattleTemplate> Catalog { get; } = new[]
{
new BattleTemplate
{
Id = "garage_guppies",
Name = "Garage Guppies",
Theme = "easy",
Note = "Brad's circuit · unit 4B",
TeamSize = 1,
// First rung: post-train tip (Ð12) should usually cover entry.
EntryFee = 10,
RewardMin = 24,
RewardMax = 38,
AiStatMin = 1.4f,
AiStatMax = 3.0f,
AiJacked = false,
// Low lifetime gate so the first train unlocks the circuit quickly.
UnlockLifetimeEarned = 10,
MinHabitatRank = 0,
ChaosOnWin = 1.5f,
ChaosOnLose = 1.5f,
DecorChance = 0.06f,
AiColor = "#90a0b0",
// Brad's garage crew — try-hard names, lot trash pride.
AiNames = new[] { "Brad's Cousin", "Unit 4B Spec", "Muffler Mouth", "Parking Lot Pike", "Detention Bait" }
},
new BattleTemplate
{
Id = "pipe_pugs",
Name = "Pipe Pugs",
Theme = "sewer",
Note = "prize tank",
TeamSize = 1,
EntryFee = 30,
RewardMin = 65,
RewardMax = 95,
AiStatMin = 2.2f,
AiStatMax = 4.2f,
AiJacked = false,
UnlockLifetimeEarned = 70,
MinHabitatRank = 0,
ChaosOnWin = 2.5f,
ChaosOnLose = 2f,
DecorChance = 0.12f,
AiColor = "#5a6040",
AiNames = new[] { "Flush", "Clog Lord", "Drain Baby", "Grate Expectations" },
RewardHabitatId = "sewage_duct"
},
new BattleTemplate
{
Id = "gutter_gauntlet",
Name = "Gutter Gauntlet",
Theme = "runoff",
Note = "prize tank",
TeamSize = 2,
EntryFee = 55,
RewardMin = 110,
RewardMax = 160,
AiStatMin = 3.0f,
AiStatMax = 5.2f,
AiJacked = false,
UnlockLifetimeEarned = 160,
MinHabitatRank = 0,
ChaosOnWin = 3f,
ChaosOnLose = 2.5f,
DecorChance = 0.15f,
AiColor = "#4a6870",
AiNames = new[] { "Leaf Pack", "Oil Slick", "Curb Surfer", "Manhole Mike" },
RewardHabitatId = "storm_drain"
},
new BattleTemplate
{
Id = "steroid_circuit",
Name = "Steroid Circuit",
Theme = "jacked",
Note = "juiced rivals",
TeamSize = 2,
EntryFee = 75,
RewardMin = 155,
RewardMax = 220,
AiStatMin = 3.8f,
AiStatMax = 6.5f,
AiJacked = true,
UnlockLifetimeEarned = 280,
MinHabitatRank = 0,
ChaosOnWin = 3.5f,
ChaosOnLose = 2.5f,
DecorChance = 0.35f,
AiColor = "#e04040",
AiNames = new[] { "Vein Machine", "Juice Box", "Roid Ray", "Bulkhead" }
},
new BattleTemplate
{
Id = "hoa_heat",
Name = "HOA Heat",
Theme = "suburb",
Note = "prize tank",
TeamSize = 2,
EntryFee = 100,
RewardMin = 200,
RewardMax = 290,
AiStatMin = 4.0f,
AiStatMax = 6.8f,
AiJacked = false,
UnlockLifetimeEarned = 450,
MinHabitatRank = 0,
ChaosOnWin = 3.5f,
ChaosOnLose = 3f,
DecorChance = 0.22f,
AiColor = "#40a070",
AiNames = new[] { "Karen Koi", "Deed Restriction", "Pool Nazi", "Covenant Carp" },
RewardHabitatId = "neighbor_pool"
},
new BattleTemplate
{
Id = "fountain_fracas",
Name = "Fountain Fracas",
Theme = "civic",
Note = "prize tank",
TeamSize = 2,
EntryFee = 120,
RewardMin = 240,
RewardMax = 340,
AiStatMin = 4.3f,
AiStatMax = 7.2f,
AiJacked = false,
UnlockLifetimeEarned = 650,
MinHabitatRank = 0,
ChaosOnWin = 3.5f,
ChaosOnLose = 3f,
DecorChance = 0.22f,
AiColor = "#70b0d0",
AiNames = new[] { "Wish Coin", "Pigeon Boss", "Mayor Minnow", "Tourist Trap" },
RewardHabitatId = "community_fountain"
},
new BattleTemplate
{
Id = "abyss_amateurs",
Name = "Abyss Amateurs",
Theme = "salt",
Note = "salt scrap",
TeamSize = 2,
EntryFee = 150,
RewardMin = 300,
RewardMax = 430,
AiStatMin = 4.8f,
AiStatMax = 7.8f,
AiJacked = false,
UnlockLifetimeEarned = 950,
MinHabitatRank = 0,
ChaosOnWin = 3.5f,
ChaosOnLose = 3.5f,
DecorChance = 0.22f,
AiColor = "#2060c0",
AiNames = new[] { "Trench Trash", "Pressure Cooker", "Abyssal Intern", "Brine Brain" }
},
new BattleTemplate
{
Id = "spa_bloodsport",
Name = "Spa Bloodsport",
Theme = "wellness",
Note = "prize tank",
TeamSize = 3,
EntryFee = 200,
RewardMin = 420,
RewardMax = 600,
AiStatMin = 5.2f,
AiStatMax = 8.2f,
AiJacked = true,
UnlockLifetimeEarned = 1400,
MinHabitatRank = 1,
ChaosOnWin = 4.5f,
ChaosOnLose = 4f,
DecorChance = 0.3f,
AiColor = "#c0a0d0",
AiNames = new[] { "Hot Stone", "Cucumber Eye", "Essential Oil", "Robed Eel", "Sauna Serpent" },
RewardHabitatId = "abandoned_spa"
},
new BattleTemplate
{
Id = "jet_joust",
Name = "Jet Joust",
Theme = "heist",
Note = "prize tank",
TeamSize = 3,
EntryFee = 280,
RewardMin = 580,
RewardMax = 850,
AiStatMin = 5.8f,
AiStatMax = 8.8f,
AiJacked = true,
UnlockLifetimeEarned = 2000,
MinHabitatRank = 1,
ChaosOnWin = 5f,
ChaosOnLose = 4.5f,
DecorChance = 0.4f,
AiColor = "#d08040",
AiNames = new[] { "Bubbles", "Repo Ray", "Jetstream", "Warranty Void", "Foam Party" },
RewardHabitatId = "hot_tub_heist"
},
new BattleTemplate
{
Id = "shark_bait_invitational",
Name = "Shark Bait Invitational",
Theme = "ocean",
Note = "almost sharks",
TeamSize = 3,
EntryFee = 380,
RewardMin = 780,
RewardMax = 1150,
AiStatMin = 6.2f,
AiStatMax = 9.2f,
AiJacked = true,
UnlockLifetimeEarned = 2800,
MinHabitatRank = 1,
ChaosOnWin = 5.5f,
ChaosOnLose = 4.5f,
DecorChance = 0.5f,
AiColor = "#102838",
AiNames = new[] { "Bait", "Almost Shark", "Fin Debt", "Chum Bucket", "Loan Shark Jr." }
},
// 3-round cup: one fee, locked team, title on sweep. Aboms preferred.
new BattleTemplate
{
Id = "championship_cup",
Name = "Abomination Championship",
Theme = "champ",
Note = "3 rounds · Grandma roasting from the porch",
TeamSize = 3,
EntryFee = 450,
RewardMin = 200,
RewardMax = 350,
AiStatMin = 6.5f,
AiStatMax = 9.5f,
AiJacked = true,
UnlockLifetimeEarned = 3400,
MinHabitatRank = 1,
ChaosOnWin = 4f,
ChaosOnLose = 5f,
DecorChance = 0.4f,
AiColor = "#c8a020",
AiNames = new[] { "Bracket Bully", "Seed #1", "Dark Horse", "Belt Snatcher", "Chum Champ" },
IsChampionship = true,
ChampionshipRounds = 3
}
};
public static BattleTemplate Find( string id ) =>
Catalog.FirstOrDefault( t => t.Id == id );
public static bool IsBeaten( string id ) =>
!string.IsNullOrEmpty( id ) && _beaten.Contains( id );
public static bool IsSelected( string fishId ) =>
!string.IsNullOrEmpty( fishId ) && _selectedIds.Contains( fishId );
public static int SelectedCount => _selectedIds.Count;
public static void DeselectFish( string fishId )
{
if ( string.IsNullOrEmpty( fishId ) )
return;
if ( _selectedIds.Remove( fishId ) )
_version++;
}
public static bool IsUnlocked( BattleTemplate t )
{
if ( t is null || !TankSim.IsActive )
return false;
if ( TankSim.HabitatRank < t.MinHabitatRank )
return false;
// Lifetime Ð gate (grind target). Beating the previous circuit also unlocks early.
if ( Economy.LifetimeEarned + 0.001 >= t.UnlockLifetimeEarned )
return true;
var idx = CatalogIndex( t );
if ( idx > 0 )
{
var prev = Catalog[idx - 1];
if ( IsBeaten( prev.Id ) )
return true;
}
return false;
}
/// <summary>True if unlocked and wallet can cover the entry fee.</summary>
public static bool CanAffordEntry( BattleTemplate t ) =>
t is not null && Economy.Balance + 0.001 >= t.EntryFee;
/// <summary>True when at least one unlocked circuit can be entered with current Ð.</summary>
public static bool CanAffordAnyFight()
{
for ( var i = 0; i < Catalog.Count; i++ )
{
var t = Catalog[i];
if ( IsUnlocked( t ) && CanAffordEntry( t ) )
return true;
}
return false;
}
public static string UnlockHint( BattleTemplate t )
{
if ( t is null )
return "";
if ( TankSim.HabitatRank < t.MinHabitatRank )
return $"Need habitat rank {t.MinHabitatRank}+";
if ( Economy.LifetimeEarned + 0.001 < t.UnlockLifetimeEarned )
{
var need = t.UnlockLifetimeEarned - Economy.LifetimeEarned;
var earnedHint = $"Earn Ð{Economy.FormatDoge( need )} more (lifetime)";
var idx = CatalogIndex( t );
if ( idx > 0 )
{
var prev = Catalog[idx - 1];
if ( !IsBeaten( prev.Id ) )
return $"Beat {prev.Name} · or {earnedHint}";
}
return earnedHint;
}
if ( !CanAffordEntry( t ) )
return $"Need Ð{Economy.FormatDoge( t.EntryFee )} entry";
if ( t.IsChampionship )
{
var titles = _champTitles > 0 ? $" · belts {_champTitles}" : "";
return $"{t.ChampionshipRounds} rounds · one fee{titles}";
}
return t.Note;
}
static int CatalogIndex( BattleTemplate t )
{
if ( t is null )
return -1;
for ( var i = 0; i < Catalog.Count; i++ )
{
if ( Catalog[i].Id == t.Id )
return i;
}
return -1;
}
public static void Clear()
{
_phase = BattlePhase.Closed;
_template = null;
_playerTeam.Clear();
_aiTeam.Clear();
_log.Clear();
_fx.Clear();
_selectedIds.Clear();
_fightTimer = 0f;
_nextBeat = 0f;
_beatDuration = 0.85f;
_round = 0;
_playerWon = false;
_payout = 0;
_resultTitle = "";
_resultBody = "";
_visAtk = null;
_visTgt = null;
_visKind = "";
_impactFlash = 0f;
_arenaShake = 0f;
_impactFired = false;
_pendingFinish = null;
_pendingDmg = 0f;
_pendingKo = false;
EndChampRun();
_version++;
}
static void EndChampRun()
{
_champRunActive = false;
_champRound = 0;
_champRunEarnings = 0;
_champRosterIds.Clear();
_resultAdvancesChamp = false;
}
public static void ResetProgress()
{
_wins = 0;
_losses = 0;
_beaten.Clear();
_prizeHabitatsWon.Clear();
_lastPrizeTankName = "";
_champTitles = 0;
Clear();
}
public static void Load( int wins, int losses, List<string> beaten, List<string> prizeHabitats = null, int championshipTitles = 0 )
{
_wins = Math.Max( 0, wins );
_losses = Math.Max( 0, losses );
_champTitles = Math.Max( 0, championshipTitles );
_beaten.Clear();
if ( beaten is not null )
{
foreach ( var id in beaten )
{
if ( !string.IsNullOrWhiteSpace( id ) )
_beaten.Add( id );
}
}
_prizeHabitatsWon.Clear();
if ( prizeHabitats is not null )
{
foreach ( var id in prizeHabitats )
{
if ( !string.IsNullOrWhiteSpace( id ) )
_prizeHabitatsWon.Add( id );
}
}
_lastPrizeTankName = "";
Clear();
}
public static void WriteToSave( SaveData data )
{
if ( data is null )
return;
data.BattleWins = _wins;
data.BattleLosses = _losses;
data.BattlesBeaten = _beaten.ToList();
data.PrizeHabitatsWon = _prizeHabitatsWon.ToList();
data.ChampionshipTitles = _champTitles;
}
public static HabitatDef GetPrizeHabitat( BattleTemplate t )
{
if ( t is null || string.IsNullOrWhiteSpace( t.RewardHabitatId ) )
return null;
return HabitatDef.Find( t.RewardHabitatId );
}
public static bool PrizeAlreadyOwned( BattleTemplate t )
{
var hab = GetPrizeHabitat( t );
return hab is not null && _prizeHabitatsWon.Contains( hab.Id );
}
public static bool TryOpen()
{
if ( !TankSim.IsActive )
return false;
if ( TankSim.FishCount < 1 )
{
TankSim.ShowBanner( "Need at least one fish to fight." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( TrainingSystem.InjectOpen )
TrainingSystem.CancelInject();
if ( ExplodeSystem.ModeActive )
ExplodeSystem.Clear();
_phase = BattlePhase.Lobby;
_template = null;
_selectedIds.Clear();
_playerTeam.Clear();
_aiTeam.Clear();
_log.Clear();
_resultTitle = "";
_resultBody = "";
GameAudio.PlayUi( GameAudio.Notice );
_version++;
return true;
}
public static void Close()
{
if ( _phase == BattlePhase.Fighting )
return; // finish the fight first
Clear();
GameAudio.PlayUi( GameAudio.Close );
}
public static void SelectTemplate( BattleTemplate t )
{
if ( _phase != BattlePhase.Lobby || t is null )
return;
if ( !IsUnlocked( t ) )
{
var hint = UnlockHint( t );
TankSim.ShowBanner( string.IsNullOrEmpty( hint )
? $"{t.Name} is locked."
: $"{t.Name}: {hint}" );
GameAudio.PlayUi( GameAudio.Error );
_version++;
return;
}
// Re-clicking same circuit is fine — keep roster.
if ( _template?.Id == t.Id )
{
GameAudio.PlayUi( GameAudio.Pop );
_version++;
return;
}
_template = t;
_selectedIds.Clear();
// Auto-pick best fish for team size
AutoSelectBest( t.TeamSize );
var entry = Economy.FormatDoge( t.EntryFee );
var banner = t.IsChampionship
? $"{t.Name} · {t.ChampionshipRounds} rounds · pick {t.TeamSize} · entry Ð{entry}"
: $"{t.Name} · pick {t.TeamSize} fish · entry Ð{entry}";
TankSim.ShowBanner( banner );
GameAudio.PlayUi( GameAudio.Pop );
_version++;
}
public static bool CanFight( FishActor fish ) =>
fish is not null && fish.Species is not null && !fish.IsFightInjured && !fish.OnAdventure;
public static string FightBlockedReason( FishActor fish )
{
if ( fish is null )
return "No fish.";
if ( fish.IsFightInjured )
return $"{fish.DisplayName} is hurt · ready in {fish.FightCooldownLeftText}";
return "";
}
public static void ToggleFish( string instanceId )
{
if ( _phase != BattlePhase.Lobby || _template is null || string.IsNullOrEmpty( instanceId ) )
return;
var fish = TankSim.Fish.FirstOrDefault( f => f.InstanceId == instanceId );
if ( fish is null )
return;
if ( _selectedIds.Contains( instanceId ) )
{
_selectedIds.Remove( instanceId );
}
else
{
if ( !CanFight( fish ) )
{
TankSim.ShowBanner( FightBlockedReason( fish ) );
GameAudio.PlayUi( GameAudio.Error );
_version++;
return;
}
EnsureFishOnTeam( instanceId, playSound: false );
GameAudio.PlayUi( GameAudio.Pop );
_version++;
return;
}
GameAudio.PlayUi( GameAudio.Pop );
_version++;
}
/// <summary>
/// Force this fish onto the roster (never toggles off). Used when opening Fight from the fish menu
/// so AutoSelect + Toggle doesn't accidentally empty the team.
/// </summary>
public static void EnsureFishOnTeam( string instanceId, bool playSound = true )
{
if ( _phase != BattlePhase.Lobby || _template is null || string.IsNullOrEmpty( instanceId ) )
return;
var fish = TankSim.Fish.FirstOrDefault( f => f.InstanceId == instanceId );
if ( fish is null )
return;
if ( !CanFight( fish ) )
{
TankSim.ShowBanner( FightBlockedReason( fish ) );
GameAudio.PlayUi( GameAudio.Error );
_version++;
return;
}
if ( _selectedIds.Contains( instanceId ) )
{
if ( playSound )
{
GameAudio.PlayUi( GameAudio.Pop );
_version++;
}
return;
}
if ( _selectedIds.Count >= _template.TeamSize )
{
// Drop the weakest current pick to make room for the requested fish.
string weakestId = null;
var weakestPow = float.MaxValue;
foreach ( var id in _selectedIds )
{
var f = TankSim.Fish.FirstOrDefault( x => x.InstanceId == id );
var p = CombatPower( f );
if ( p < weakestPow )
{
weakestPow = p;
weakestId = id;
}
}
if ( !string.IsNullOrEmpty( weakestId ) )
_selectedIds.Remove( weakestId );
}
_selectedIds.Add( instanceId );
if ( playSound )
GameAudio.PlayUi( GameAudio.Pop );
_version++;
}
static void AutoSelectBest( int n )
{
_selectedIds.Clear();
var ranked = TankSim.Fish
.Where( CanFight )
.OrderByDescending( CombatPower )
.Take( Math.Max( 1, n ) )
.ToList();
foreach ( var f in ranked )
_selectedIds.Add( f.InstanceId );
}
public static float CombatPower( FishActor f )
{
if ( f?.Species is null )
return 0f;
if ( f.IsFightInjured )
return 0f;
var baseP = f.Endurance * 1.25f + f.SpeedStat * 1.05f + f.Agility * 1.15f;
baseP += f.Species.IncomePerSecond * 2f;
// Predators that snack hit harder in the arena.
if ( f.PredationBulkNorm > 0f )
baseP *= 1f + f.PredationBulkNorm * 0.4f;
if ( f.Jacked )
baseP *= 1.4f;
if ( f.OnMeth )
baseP *= 1.2f;
if ( f.InWithdrawal )
baseP *= 0.45f;
if ( f.IsSick )
baseP *= 0.4f;
return baseP;
}
public static bool CanStart()
{
if ( _phase != BattlePhase.Lobby || _template is null )
return false;
if ( _selectedIds.Count < _template.TeamSize )
return false;
if ( Economy.Balance < _template.EntryFee )
return false;
// No injured fish on the card.
foreach ( var id in _selectedIds )
{
var f = TankSim.Fish.FirstOrDefault( x => x.InstanceId == id );
if ( !CanFight( f ) )
return false;
}
return true;
}
public static bool TryStart()
{
if ( !CanStart() )
{
if ( _template is not null && _selectedIds.Count < _template.TeamSize )
TankSim.ShowBanner( $"Pick {_template.TeamSize} fish." );
else if ( _template is not null && _selectedIds.Any( id =>
!CanFight( TankSim.Fish.FirstOrDefault( f => f.InstanceId == id ) ) ) )
TankSim.ShowBanner( "Injured fish can't fight. Wait out the cooldown." );
else if ( _template is not null )
TankSim.ShowBanner( $"Need Ð{Economy.FormatDoge( _template.EntryFee )} entry." );
GameAudio.PlayUi( GameAudio.Error );
_version++;
return false;
}
if ( !Economy.TrySpend( _template.EntryFee ) )
{
GameAudio.PlayUi( GameAudio.Error );
return false;
}
_playerTeam.Clear();
_aiTeam.Clear();
_log.Clear();
_round = 0;
_playerWon = false;
_payout = 0;
_resultAdvancesChamp = false;
foreach ( var id in _selectedIds )
{
var fish = TankSim.Fish.FirstOrDefault( f => f.InstanceId == id );
if ( fish is null )
continue;
_playerTeam.Add( FromPlayer( fish ) );
}
// Pad if fish died mid-select (shouldn't) — never pad with injured fish.
while ( _playerTeam.Count < _template.TeamSize && TankSim.Fish.Count > 0 )
{
var extra = TankSim.Fish
.Where( CanFight )
.OrderByDescending( CombatPower )
.FirstOrDefault( f => _playerTeam.All( p => p.SourceId != f.InstanceId ) );
if ( extra is null )
break;
_playerTeam.Add( FromPlayer( extra ) );
}
// Championship: lock roster + pay once; rounds continue via AcknowledgeResult.
if ( _template.IsChampionship )
{
_champRunActive = true;
_champRound = 1;
_champRunEarnings = 0;
_champRosterIds.Clear();
foreach ( var pf in _playerTeam )
{
if ( !string.IsNullOrEmpty( pf.SourceId ) )
_champRosterIds.Add( pf.SourceId );
}
}
else
{
EndChampRun();
}
BuildAiTeam( _template, ChampStatMult(), ChampForceJacked() );
if ( CrimeSystem.ConsumeBradSpike() )
{
foreach ( var ai in _aiTeam )
{
ai.Power *= 0.78f;
ai.Hp *= 0.86f;
ai.MaxHp = ai.Hp;
ai.Speed *= 1.12f;
ai.Name = "TWKD " + ai.Name;
}
_log.Add( "Brad's bucket was pre-gamed. His fish are doing homework with their gills." );
Chaos.Add( 1.2f );
}
LayoutArena();
BeginFightVisuals();
if ( _champRunActive )
{
_log.Add( $"CUP ROUND {_champRound}/{_template.ChampionshipRounds}: {_template.Name}" );
_log.Add( $"Entry Ð{Economy.FormatDoge( _template.EntryFee )} · team locked." );
}
else
{
_log.Add( $"FIGHT: {_template.Name}" );
_log.Add( $"You put up Ð{Economy.FormatDoge( _template.EntryFee )}." );
}
GameAudio.PlayUi( GameAudio.Confirm );
Chaos.Add( 1.5f );
AchievementSystem.Unlock( "fight" );
_version++;
return true;
}
static float ChampStatMult()
{
if ( !_champRunActive || _template is null || !_template.IsChampionship )
return 1f;
// ~+20% AI stats each round; finals hit hard.
return 1f + Math.Max( 0, _champRound - 1 ) * 0.2f;
}
static bool ChampForceJacked()
{
if ( !_champRunActive || _template is null )
return false;
return _template.IsChampionship && _champRound >= 3;
}
static void BeginFightVisuals()
{
_phase = BattlePhase.Fighting;
_fightTimer = 0f;
// Snappier arena — readable but not a 40s stare-off.
_beatDuration = 0.62f;
_nextBeat = 0.38f; // brief stare-down
_visAtk = null;
_visTgt = null;
_visKind = "";
_impactFired = false;
_pendingFinish = null;
_pendingDmg = 0f;
_pendingKo = false;
_round = 0;
_fx.Clear();
}
/// <summary>Sum of selected lobby fish combat power (0 if none).</summary>
public static float SelectedTeamPower
{
get
{
var sum = 0f;
foreach ( var id in _selectedIds )
{
var f = TankSim.Fish.FirstOrDefault( x => x.InstanceId == id );
if ( CanFight( f ) )
sum += CombatPower( f );
}
return sum;
}
}
/// <summary>Rough mid AI power for the selected circuit (same scale as CombatPower).</summary>
public static float EstimatedAiPower( BattleTemplate t )
{
if ( t is null )
return 0f;
var mid = (t.AiStatMin + t.AiStatMax) * 0.5f;
// Match BuildAiTeam: end/spd/agi ≈ mid each → power ≈ mid*3.45 + 2
var one = mid * 3.45f + 2f;
if ( t.AiJacked )
one *= 1.35f;
return one * Math.Max( 1, t.TeamSize );
}
/// <summary>Lobby odds line — Favor / Even / Underdog. Empty if no template/team.</summary>
public static string OddsLabel
{
get
{
if ( _template is null || _selectedIds.Count == 0 )
return "";
var you = SelectedTeamPower;
var them = EstimatedAiPower( _template );
if ( them < 0.5f )
return "";
var ratio = you / them;
var purse = "win ~Ð" + Economy.FormatDoge( Math.Max( _template.RewardMin, _template.EntryFee * 2.2 ) )
+ "–" + Economy.FormatDoge( Math.Max( _template.RewardMax, _template.EntryFee * 2.2 + 4 ) );
if ( ratio >= 1.2f )
return "FAVOR · power " + you.ToString( "0.#" ) + " vs " + them.ToString( "0.#" ) + " · " + purse;
if ( ratio >= 0.85f )
return "EVEN · power " + you.ToString( "0.#" ) + " vs " + them.ToString( "0.#" ) + " · " + purse;
if ( ratio >= 0.55f )
return "UNDERDOG · power " + you.ToString( "0.#" ) + " vs " + them.ToString( "0.#" ) + " · " + purse + " · bigger purse if you win";
return "LONG SHOT · power " + you.ToString( "0.#" ) + " vs " + them.ToString( "0.#" ) + " · train or pick harder fish";
}
}
/// <summary>Net profit string after a normal win (payout − entry). Empty if no template.</summary>
public static string LastNetProfitLabel
{
get
{
if ( _template is null || !_playerWon || _payout <= 0 )
return "";
var net = _payout - _template.EntryFee;
if ( net >= 0 )
return "Net +" + "Ð" + Economy.FormatDoge( net ) + " after entry";
return "Net −" + "Ð" + Economy.FormatDoge( Math.Abs( net ) ) + " after entry";
}
}
/// <summary>True when result can jump straight into another bout of the same circuit.</summary>
public static bool CanRematch =>
_phase == BattlePhase.Result
&& !_resultAdvancesChamp
&& _template is not null
&& IsUnlocked( _template )
&& CanAffordEntry( _template )
&& TankSim.Fish.Count( CanFight ) >= _template.TeamSize;
/// <summary>After a finished fight: same circuit, fresh healthy team, pay entry again.</summary>
public static bool TryRematch()
{
if ( !CanRematch )
return false;
// Back to lobby state, keep template, auto-pick survivors, start.
_phase = BattlePhase.Lobby;
_playerTeam.Clear();
_aiTeam.Clear();
_log.Clear();
_fx.Clear();
_resultTitle = "";
_resultBody = "";
_payout = 0;
_resultAdvancesChamp = false;
AutoSelectBest( _template.TeamSize );
_version++;
return TryStart();
}
static BattleFighter FromPlayer( FishActor fish )
{
var power = CombatPower( fish );
var hp = 28f + fish.Endurance * 14f + (fish.Jacked ? 18f : 0f);
var sp = fish.Species;
return new BattleFighter
{
Name = fish.DisplayName,
Color = sp.Color,
FinColor = sp.FinColor,
Hp = hp,
MaxHp = hp,
Power = power,
Speed = fish.SpeedStat + (fish.Jacked ? 2f : 0f),
Agility = fish.Agility,
Jacked = fish.Jacked,
IsMonster = fish.IsMonster,
Player = true,
SourceId = fish.InstanceId,
SpriteId = sp.SpriteId,
Width = Math.Clamp( sp.Width * 0.95f, 36f, 56f ),
Height = Math.Clamp( sp.Height * 0.95f, 24f, 42f ),
BobPhase = (float)(_rng.NextDouble() * Math.PI * 2),
Facing = 1
};
}
static void BuildAiTeam( BattleTemplate t, float statMult = 1f, bool forceJacked = false )
{
_aiTeam.Clear();
if ( t is null )
return;
statMult = Math.Clamp( statMult, 0.5f, 3f );
var jacked = t.AiJacked || forceJacked;
var names = ResolveAiNames( t );
var catalog = FishSpecies.AllCatalog;
var min = t.AiStatMin * statMult;
var max = t.AiStatMax * statMult;
for ( var i = 0; i < t.TeamSize; i++ )
{
var end = Lerp( min, max, (float)_rng.NextDouble() );
var spd = Lerp( min, max, (float)_rng.NextDouble() );
var agi = Lerp( min, max, (float)_rng.NextDouble() );
var power = end * 1.25f + spd * 1.05f + agi * 1.15f + 2f;
if ( jacked )
power *= 1.35f;
// Final cup round: extra bite.
var cupFinal = t.IsChampionship && t.ChampionshipRounds > 0
&& _champRound >= t.ChampionshipRounds;
if ( cupFinal )
power *= 1.12f;
var hp = 28f + end * 14f + (jacked ? 16f : 0f);
if ( cupFinal )
hp *= 1.1f;
// Pick a species look so AI has a real sprite to thrash around.
var look = catalog[Math.Abs( (names[i % names.Length] + i).GetHashCode() ) % catalog.Count];
_aiTeam.Add( new BattleFighter
{
Name = names[i % names.Length],
Color = t.AiColor ?? look.Color,
FinColor = look.FinColor,
Hp = hp,
MaxHp = hp,
Power = power,
Speed = spd + (jacked ? 1.5f : 0f),
Agility = agi,
Jacked = jacked,
Player = false,
SourceId = $"ai_{i}",
SpriteId = look.SpriteId,
Width = Math.Clamp( look.Width * 0.95f, 36f, 56f ),
Height = Math.Clamp( look.Height * 0.95f, 24f, 42f ),
BobPhase = (float)(_rng.NextDouble() * Math.PI * 2),
Facing = -1
} );
}
}
static string[] ResolveAiNames( BattleTemplate t )
{
if ( t is null )
return new[] { "Rival Fish" };
var baseNames = t.AiNames ?? new[] { "Rival Fish" };
if ( !t.IsChampionship || !_champRunActive )
return baseNames;
// Prefix cup round so names stay unique without a separate banner table.
var round = Math.Max( 1, _champRound );
var tagged = new string[baseNames.Length];
for ( var i = 0; i < baseNames.Length; i++ )
tagged[i] = $"R{round} {baseNames[i]}";
return tagged;
}
/// <summary>Park teams on left / right of the arena stage.</summary>
static void LayoutArena()
{
PlaceTeam( _playerTeam, playerSide: true );
PlaceTeam( _aiTeam, playerSide: false );
}
static void PlaceTeam( List<BattleFighter> team, bool playerSide )
{
var n = Math.Max( 1, team.Count );
for ( var i = 0; i < team.Count; i++ )
{
var f = team[i];
var lane = (i + 0.5f) / n;
// Leave room under sprites for name + HP bar
var y = 40f + lane * (ArenaH - 88f);
// Slight X stagger so they don't stack perfectly
var xJitter = (float)(_rng.NextDouble() * 18.0 - 9.0);
if ( playerSide )
{
f.HomeX = 70f + (i % 2) * 18f + xJitter;
f.Facing = 1;
}
else
{
f.HomeX = ArenaW - 70f - (i % 2) * 18f + xJitter;
f.Facing = -1;
}
f.HomeY = y;
f.DrawX = f.HomeX;
f.DrawY = f.HomeY;
f.Anim = BattleAnim.Idle;
f.AnimT = 0f;
f.Shake = 0f;
}
}
static float Lerp( float a, float b, float t ) => a + (b - a) * t;
static float EaseOut( float t ) => 1f - (1f - t) * (1f - t);
static float EaseInOut( float t ) =>
t < 0.5f ? 2f * t * t : 1f - MathF.Pow( -2f * t + 2f, 2f ) * 0.5f;
public static void Tick( float dt )
{
if ( _phase != BattlePhase.Fighting && _phase != BattlePhase.Result )
return;
if ( dt <= 0f )
return;
if ( dt > 0.05f )
dt = 0.05f;
if ( _impactFlash > 0f )
_impactFlash = MathF.Max( 0f, _impactFlash - dt * 3.5f );
if ( _arenaShake > 0f )
_arenaShake = MathF.Max( 0f, _arenaShake - dt * 4f );
TickFx( dt );
TickFighterVisuals( dt );
if ( _phase != BattlePhase.Fighting )
{
_version++;
return;
}
_fightTimer += dt;
_nextBeat -= dt;
// Drive lunge / impact within the current beat window
var progress = _beatDuration > 0.01f
? Math.Clamp( 1f - _nextBeat / _beatDuration, 0f, 1f )
: 1f;
UpdateBeatAnim( progress );
if ( _nextBeat > 0f )
{
_version++;
return;
}
// End-of-fight delayed so last lunge / KO is visible
if ( _pendingFinish.HasValue )
{
FinishFight( _pendingFinish.Value );
_pendingFinish = null;
_version++;
return;
}
// Start next beat — punchy exchanges, not a film festival.
_beatDuration = 0.48f + (float)_rng.NextDouble() * 0.22f;
_nextBeat = _beatDuration;
_round++;
_impactFired = false;
ResolveBeat();
_version++;
}
static void TickFx( float dt )
{
for ( var i = _fx.Count - 1; i >= 0; i-- )
{
var fx = _fx[i];
fx.Life -= dt;
fx.OffsetY -= 40f * dt;
if ( fx.Life <= 0f )
_fx.RemoveAt( i );
}
}
static void TickFighterVisuals( float dt )
{
foreach ( var f in _playerTeam.Concat( _aiTeam ) )
{
f.BobPhase += dt * (f.Alive ? 3.2f : 1.2f);
if ( f.Shake > 0f )
f.Shake = MathF.Max( 0f, f.Shake - dt * 8f );
if ( !f.Alive )
{
f.Anim = BattleAnim.Down;
// Sink a little
f.DrawY = Lerp( f.DrawY, f.HomeY + 18f, Math.Clamp( dt * 3f, 0f, 1f ) );
f.DrawX = Lerp( f.DrawX, f.HomeX, Math.Clamp( dt * 2f, 0f, 1f ) );
continue;
}
if ( _phase == BattlePhase.Result )
{
var wonSide = (f.Player && _playerWon) || (!f.Player && !_playerWon);
f.Anim = wonSide ? BattleAnim.Win : BattleAnim.Idle;
// Drift home; winners bounce
var bob = MathF.Sin( f.BobPhase * (wonSide ? 1.6f : 1f) ) * (wonSide ? 8f : 3f);
f.DrawX = Lerp( f.DrawX, f.HomeX, Math.Clamp( dt * 4f, 0f, 1f ) );
f.DrawY = Lerp( f.DrawY, f.HomeY + bob, Math.Clamp( dt * 4f, 0f, 1f ) );
if ( wonSide )
f.Facing = f.Player ? 1 : -1;
}
}
}
/// <summary>Lunge attacker toward target; impact flash mid-beat.</summary>
static void UpdateBeatAnim( float t )
{
// Default: idle at home + bob (applied in Draw helpers via Home + sin)
foreach ( var f in _playerTeam.Concat( _aiTeam ) )
{
if ( !f.Alive )
continue;
if ( f != _visAtk && f != _visTgt )
{
f.Anim = BattleAnim.Idle;
f.DrawX = f.HomeX;
f.DrawY = f.HomeY + MathF.Sin( f.BobPhase ) * 4f;
}
}
if ( _visAtk is null || _visTgt is null )
return;
var atk = _visAtk;
var tgt = _visTgt;
if ( atk.Alive )
{
atk.Facing = atk.HomeX < tgt.HomeX ? 1 : -1;
// Lunge 0—0.42, hold 0.42—0.55, return 0.55—1
if ( t < 0.42f )
{
atk.Anim = BattleAnim.Lunge;
var u = EaseOut( t / 0.42f );
var aimX = Lerp( tgt.HomeX, atk.HomeX, 0.28f ); // stop short of center of target
var aimY = tgt.HomeY;
atk.DrawX = Lerp( atk.HomeX, aimX, u );
atk.DrawY = Lerp( atk.HomeY, aimY, u ) + MathF.Sin( atk.BobPhase ) * 2f;
}
else if ( t < 0.55f )
{
atk.Anim = BattleAnim.Lunge;
var aimX = Lerp( tgt.HomeX, atk.HomeX, 0.28f );
atk.DrawX = aimX;
atk.DrawY = tgt.HomeY;
}
else
{
atk.Anim = BattleAnim.Idle;
var u = EaseInOut( (t - 0.55f) / 0.45f );
var aimX = Lerp( tgt.HomeX, atk.HomeX, 0.28f );
atk.DrawX = Lerp( aimX, atk.HomeX, u );
atk.DrawY = Lerp( tgt.HomeY, atk.HomeY, u ) + MathF.Sin( atk.BobPhase ) * 4f;
}
}
if ( tgt.Alive || _visKind is "hit" or "crit" or "ko" )
{
tgt.Facing = tgt.HomeX < atk.HomeX ? 1 : -1;
if ( _visKind == "dodge" && t >= 0.35f && t < 0.75f )
{
tgt.Anim = BattleAnim.Dodge;
var side = tgt.Player ? -1f : 1f;
var dodgeU = MathF.Sin( (t - 0.35f) / 0.4f * MathF.PI );
tgt.DrawX = tgt.HomeX + side * 22f * dodgeU;
tgt.DrawY = tgt.HomeY - 10f * dodgeU + MathF.Sin( tgt.BobPhase ) * 3f;
}
else if ( (_visKind is "hit" or "crit" or "ko") && t >= 0.4f )
{
if ( !_impactFired && t >= 0.4f )
{
_impactFired = true;
// Apply damage on impact so HP bar drops with the hit
if ( _pendingDmg > 0f && tgt is not null )
{
tgt.Hp = MathF.Max( 0f, tgt.Hp - _pendingDmg );
_pendingDmg = 0f;
if ( _pendingKo || !tgt.Alive )
{
_visKind = "ko";
PushFx( tgt, "KO", "ko" );
_log.Add( $"→ {tgt.Name} is toast." );
TrimLog();
}
_pendingKo = false;
}
_impactFlash = _visKind is "crit" or "ko" ? 1f : 0.65f;
_arenaShake = _visKind is "crit" or "ko" ? 6f : 3.5f;
tgt.Shake = _visKind is "crit" or "ko" ? 10f : 6f;
WaterFx.FightHit( tgt.DrawX, tgt.DrawY, hard: _visKind is "crit" or "ko" );
if ( _visKind == "ko" || !tgt.Alive )
GameAudio.PlayUi( GameAudio.Error );
else if ( _visKind == "crit" )
GameAudio.PlayUi( GameAudio.Confirm );
else
GameAudio.PlayUi( GameAudio.Pop );
}
tgt.Anim = tgt.Alive ? BattleAnim.Hit : BattleAnim.Down;
var knock = (_visKind == "crit" ? 16f : 10f) * (tgt.Player ? -1f : 1f);
var hold = Math.Clamp( (t - 0.4f) / 0.2f, 0f, 1f );
var back = t > 0.6f ? EaseOut( Math.Clamp( (t - 0.6f) / 0.4f, 0f, 1f ) ) : 0f;
tgt.DrawX = tgt.HomeX + knock * hold * (1f - back);
tgt.DrawY = tgt.HomeY + MathF.Sin( tgt.BobPhase ) * 2f
+ (tgt.Alive ? 0f : 12f * hold);
}
else
{
tgt.Anim = tgt.Alive ? BattleAnim.Idle : BattleAnim.Down;
tgt.DrawX = tgt.HomeX;
tgt.DrawY = tgt.HomeY + MathF.Sin( tgt.BobPhase ) * 4f;
}
}
}
/// <summary>One attack per beat so the arena is readable.</summary>
static void ResolveBeat()
{
var pAlive = _playerTeam.Where( f => f.Alive ).ToList();
var aAlive = _aiTeam.Where( f => f.Alive ).ToList();
if ( pAlive.Count == 0 || aAlive.Count == 0 )
{
_pendingFinish = pAlive.Count > 0;
return;
}
// Speed + RNG picks who swings this round
var atk = pAlive.Concat( aAlive )
.OrderByDescending( f => f.Speed + (float)_rng.NextDouble() * 2.5f )
.First();
var foes = atk.Player
? _aiTeam.Where( f => f.Alive ).ToList()
: _playerTeam.Where( f => f.Alive ).ToList();
if ( foes.Count == 0 )
{
_pendingFinish = atk.Player;
return;
}
var target = foes[_rng.Next( foes.Count )];
_visAtk = atk;
_visTgt = target;
_impactFired = false;
// Dodge
var dodgeChance = Math.Clamp( target.Agility * 0.035f, 0.05f, 0.38f );
if ( _rng.NextDouble() < dodgeChance )
{
_visKind = "dodge";
_pendingDmg = 0f;
_pendingKo = false;
_log.Add( $"{target.Name} dodges {atk.Name}!" );
PushFx( target, "DODGE", "dodge" );
TrimLog();
return;
}
var dmg = atk.Power * (0.35f + (float)_rng.NextDouble() * 0.45f);
dmg += atk.Speed * 0.4f;
if ( atk.Jacked )
dmg *= 1.15f;
var crit = _rng.NextDouble() < atk.Agility * 0.025f;
if ( crit )
{
dmg *= 1.6f;
_visKind = "crit";
_log.Add( $"{atk.Name} CRITS {target.Name} for {dmg:0}!" );
PushFx( target, $"CRIT {dmg:0}", "crit" );
}
else
{
_visKind = "hit";
_log.Add( $"{atk.Name} hits {target.Name} ({dmg:0})" );
PushFx( target, $"-{dmg:0}", "hit" );
}
// Damage lands mid-lunge (UpdateBeatAnim impact) so bar + body sync
_pendingDmg = dmg;
_pendingKo = target.Hp - dmg <= 0.01f;
if ( _pendingKo )
_visKind = "ko";
TrimLog();
// Defer result until the lunge/KO plays out (projected HP for target)
bool SideWiped( IEnumerable<BattleFighter> side ) =>
side.All( f => f != target ? !f.Alive : _pendingKo );
if ( SideWiped( _playerTeam ) )
_pendingFinish = false;
else if ( SideWiped( _aiTeam ) )
_pendingFinish = true;
}
static void PushFx( BattleFighter f, string label, string kind )
{
if ( f is null )
return;
_fx.Add( new BattleFxItem
{
Key = Guid.NewGuid().ToString( "N" )[..8],
Label = label,
X = f.HomeX,
Y = f.HomeY - 20f,
Life = 1.1f,
MaxLife = 1.1f,
OffsetY = 0f,
Kind = kind
} );
while ( _fx.Count > 12 )
_fx.RemoveAt( 0 );
}
public static float FxLifeNorm( BattleFxItem fx )
{
if ( fx is null || fx.MaxLife <= 0f )
return 0f;
return Math.Clamp( fx.Life / fx.MaxLife, 0f, 1f );
}
/// <summary>Screen draw position including shake jitter.</summary>
public static void GetDrawPos( BattleFighter f, out float x, out float y )
{
x = f?.DrawX ?? 0f;
y = f?.DrawY ?? 0f;
if ( f is null )
return;
if ( f.Shake > 0.1f )
{
x += (float)(_rng.NextDouble() * 2.0 - 1.0) * f.Shake;
y += (float)(_rng.NextDouble() * 2.0 - 1.0) * f.Shake * 0.6f;
}
if ( _arenaShake > 0.1f )
{
x += (float)(_rng.NextDouble() * 2.0 - 1.0) * _arenaShake;
y += (float)(_rng.NextDouble() * 2.0 - 1.0) * _arenaShake * 0.5f;
}
}
static void TrimLog()
{
while ( _log.Count > 8 )
_log.RemoveAt( 0 );
}
static void FinishFight( bool won )
{
// Safety: flush any impact damage that never animated
if ( _pendingDmg > 0f && _visTgt is not null )
{
_visTgt.Hp = MathF.Max( 0f, _visTgt.Hp - _pendingDmg );
_pendingDmg = 0f;
}
_pendingKo = false;
_playerWon = won;
_phase = BattlePhase.Result;
_payout = 0;
_resultAdvancesChamp = false;
_lastPrizeTankName = "";
if ( _champRunActive && _template is not null && _template.IsChampionship )
{
FinishChampionshipRound( won );
}
else if ( won )
{
FinishNormalWin();
}
else
{
FinishNormalLoss();
}
// Tiny post-fight bump to participating fish (winners only; each cup round too)
if ( won )
{
foreach ( var pf in _playerTeam )
{
var fish = TankSim.Fish.FirstOrDefault( f => f.InstanceId == pf.SourceId );
if ( fish is null )
continue;
var endBump = _resultAdvancesChamp ? 0.08f : 0.15f;
var agiBump = _resultAdvancesChamp ? 0.05f : 0.1f;
fish.Endurance = MathF.Min( 10f, fish.Endurance + endBump );
fish.Agility = MathF.Min( 10f, fish.Agility + agiBump );
}
}
// Drop injured fish from the lobby roster so AutoSelect doesn't re-pick corpses.
// Keep locked cup roster intact between advancing rounds.
if ( !_resultAdvancesChamp )
{
_selectedIds.RemoveWhere( id =>
{
var f = TankSim.Fish.FirstOrDefault( x => x.InstanceId == id );
return f is null || f.IsFightInjured;
} );
}
StorySystem.Evaluate();
SaveGame.TrySave();
_version++;
}
static void FinishChampionshipRound( bool won )
{
var total = Math.Max( 1, _template.ChampionshipRounds );
var round = Math.Clamp( _champRound, 1, total );
var isFinal = won && round >= total;
if ( won )
{
_wins++;
_payout = ChampRoundPayout( round, total, isFinal );
_champRunEarnings += _payout;
Economy.Add( _payout );
Chaos.Add( isFinal ? _template.ChaosOnWin + 1.5f : _template.ChaosOnWin * 0.55f );
var decorLine = "";
var lootChance = isFinal ? Math.Min( 0.95f, _template.DecorChance + 0.35f ) : _template.DecorChance * 0.5f;
if ( _rng.NextDouble() < lootChance )
{
var def = PickRewardDecor();
if ( def is not null && TankSim.TryPlaceDecor( def, announce: false ) )
decorLine = $" Loot: {def.Name}.";
}
if ( isFinal )
{
_champTitles++;
_beaten.Add( _template.Id );
_resultAdvancesChamp = false;
var abomTeam = CountMonsterTeam();
if ( abomTeam > 0 )
{
var abomBonus = 80 + abomTeam * 60;
_payout += abomBonus;
_champRunEarnings += abomBonus;
Economy.Add( abomBonus );
}
_resultTitle = "ABOMINATION CHAMPION";
var pride = abomTeam > 0
? " Grandma: \"That's my little psycho. Sit. Don't look proud. I'll look proud.\""
: " Grandma: \"Cute. Bring something uglier next time. Bingo's gonna hate us.\"";
_resultBody =
$"+Ð{Economy.FormatDoge( _payout )} · title belt #{_champTitles}." +
$" Run total Ð{Economy.FormatDoge( _champRunEarnings )}.{decorLine}{pride}";
_log.Add( $"CUP WON · belt #{_champTitles} · +Ð{Economy.FormatDoge( _payout )}" );
GameAudio.PlayDogeBark();
// Big respect spike — this is the main pride path.
Karma.Add( 12f + abomTeam * 3f );
TankSim.ShowBanner( abomTeam > 0
? $"Abom cup! Grandma beams like a crime boss. +Ð{Economy.FormatDoge( _payout )}"
: $"Championship! Belt #{_champTitles} · +Ð{Economy.FormatDoge( _payout )}" );
var lootLine = CrimeSystem.ApplyFightLoot( won: true, champFinal: true, champMid: false, intensity: _template.ChaosOnWin + 2f );
if ( !string.IsNullOrEmpty( lootLine ) )
_resultBody += lootLine;
CrimeSystem.NotifyFightResult();
if ( _champTitles == 1 )
GrandmaReact.OnFirstChamp();
else if ( _wins == 1 )
GrandmaReact.OnFirstFightWin();
AchievementSystem.Unlock( "win" );
EndChampRun();
}
else
{
_resultAdvancesChamp = true;
_resultTitle = $"ROUND {round} WON";
var lootLine = CrimeSystem.ApplyFightLoot( won: true, champFinal: false, champMid: true, intensity: _template.ChaosOnWin * 0.55f );
_resultBody =
$"+Ð{Economy.FormatDoge( _payout )}.{decorLine}{lootLine} Next: round {round + 1}/{total}.";
_log.Add( $"ROUND {round}/{total} WIN · +Ð{Economy.FormatDoge( _payout )}" );
CrimeSystem.NotifyFightResult();
GameAudio.PlayUi( GameAudio.Confirm );
Karma.Add( 1.2f );
TankSim.ShowBanner( $"Cup round {round}/{total} won · next up!" );
}
}
else
{
_losses++;
Chaos.Add( _template.ChaosOnLose );
var hurt = ApplyTeamInjury();
_resultAdvancesChamp = false;
_resultTitle = "ELIMINATED";
var kept = _champRunEarnings > 0.01
? $" Kept Ð{Economy.FormatDoge( _champRunEarnings )} from earlier rounds."
: "";
_resultBody = hurt > 0
? $"Out on round {round}/{total}. Team hurt ~28s.{kept}"
: $"Out on round {round}/{total}.{kept}";
_log.Add( $"CUP OUT · round {round}/{total}" );
GameAudio.PlayUi( GameAudio.Error );
// At least you entered — not a wuss.
Karma.Add( 1.5f );
TankSim.ShowBanner( $"Eliminated round {round}/{total}. Grandma claps sarcastically." );
if ( _losses == 1 )
GrandmaReact.OnFirstFightLoss();
var drop = CrimeSystem.ApplyFightLoot( won: false, champFinal: false, champMid: false, intensity: 0f );
if ( !string.IsNullOrEmpty( drop ) )
_resultBody += drop;
CrimeSystem.NotifyFightResult();
EndChampRun();
}
}
/// <summary>How many roster fish are fusion aboms.</summary>
static int CountMonsterTeam()
{
var n = 0;
foreach ( var pf in _playerTeam )
{
if ( pf.IsMonster )
{
n++;
continue;
}
var fish = TankSim.Fish.FirstOrDefault( f => f.InstanceId == pf.SourceId );
if ( fish?.IsMonster == true )
n++;
}
return n;
}
static double ChampRoundPayout( int round, int total, bool isFinal )
{
// Mid-round purses scale up; final includes the title purse.
var lo = _template.RewardMin;
var hi = _template.RewardMax;
var t = total <= 1 ? 1f : (round - 1f) / (total - 1f);
var basePay = lo + (hi - lo) * (0.35 + 0.65 * t);
basePay *= 0.55 + 0.15 * round; // earlier rounds lighter, still worth it
if ( isFinal )
basePay += 1600 + _rng.NextDouble() * 400; // title belt bonus
else
basePay += _rng.NextDouble() * 40;
return Math.Max( 20, basePay );
}
static void FinishNormalWin()
{
_wins++;
var lo = _template.RewardMin;
var hi = _template.RewardMax;
// Floor: wins should beat entry by a clear margin (risk → reward).
var floor = _template.EntryFee * 2.2;
lo = Math.Max( lo, floor );
hi = Math.Max( hi, lo + 4 );
_payout = lo + _rng.NextDouble() * (hi - lo);
// Underdog bonus: weaker team vs circuit AI average = juicier purse.
var underdogMul = UnderdogRewardMul();
var underdogLine = "";
if ( underdogMul > 1.05 )
{
_payout *= underdogMul;
underdogLine = $" Underdog ×{underdogMul:0.00}.";
}
// Aboms on the roster print harder — they cost fish to make.
var abomTeam = CountMonsterTeam();
if ( abomTeam > 0 )
_payout += 8 + abomTeam * 12;
_payout = Math.Round( _payout, 1 );
Economy.AddForced( _payout );
BoomFeel.SoftPunch( 0.75f );
GameAudio.PlayBigWin( bark: _wins <= 3 || _payout >= 80 );
Chaos.Add( _template.ChaosOnWin );
_beaten.Add( _template.Id );
var decorLine = "";
// Riskier / harder templates already set DecorChance; underdogs get a loot bump.
var lootChance = _template.DecorChance * (underdogMul > 1.1 ? 1.35f : 1f);
if ( _rng.NextDouble() < lootChance )
{
var def = PickRewardDecor();
if ( def is not null && TankSim.TryPlaceDecor( def, announce: false ) )
decorLine = $" Loot: {def.Name}.";
}
// First-time circuit tank prize (Neighbor's Pool, Sewage Duct, …)
var tankLine = "";
var prizeHab = GetPrizeHabitat( _template );
if ( prizeHab is not null && !_prizeHabitatsWon.Contains( prizeHab.Id ) )
{
if ( TankSim.TryApplyFightPrize( prizeHab ) )
{
_prizeHabitatsWon.Add( prizeHab.Id );
_lastPrizeTankName = prizeHab.Name;
tankLine = $" NEW TANK: {prizeHab.Name}!";
}
else
{
// Couldn't install (wrong water, etc.) — still mark + consolation cash
_prizeHabitatsWon.Add( prizeHab.Id );
var bonus = Math.Max( 40, prizeHab.BaseCapacity );
Economy.Add( bonus );
tankLine = $" Tank deed banked (+Ð{Economy.FormatDoge( bonus )}).";
_lastPrizeTankName = prizeHab.Name;
}
}
else if ( prizeHab is not null )
{
tankLine = " (tank already claimed)";
}
var net = Math.Round( _payout - _template.EntryFee, 1 );
var netLine = net >= 0
? $" Net +Ð{Economy.FormatDoge( net )} after entry."
: $" Net −Ð{Economy.FormatDoge( Math.Abs( net ) )} after entry.";
_resultTitle = string.IsNullOrEmpty( _lastPrizeTankName ) ? "VICTORY" : "TANK CLAIMED";
var lootLine = CrimeSystem.ApplyFightLoot( won: true, champFinal: false, champMid: false, intensity: _template.ChaosOnWin + (underdogMul > 1.1 ? 1f : 0f) );
_resultBody = $"+Ð{Economy.FormatDoge( _payout )}.{netLine}{underdogLine}{decorLine}{tankLine}{lootLine}";
_log.Add( $"WIN · +Ð{Economy.FormatDoge( _payout )}{tankLine}" );
CrimeSystem.NotifyFightResult();
// Audio already fired via PlayBigWin above.
// Ladder wins warm Grandma up toward the cup.
Karma.Add( 3f + abomTeam * 0.8f + (underdogMul > 1.15 ? 1.5f : 0f) );
var banner = string.IsNullOrEmpty( _lastPrizeTankName )
? $"Won {_template.Name}! +Ð{Economy.FormatDoge( _payout )} (net +Ð{Economy.FormatDoge( Math.Max( 0, net ) )})"
: $"Won {_template.Name}! Claimed {_lastPrizeTankName}!";
TankSim.ShowBanner( banner );
if ( _wins == 1 )
GrandmaReact.OnFirstFightWin();
AchievementSystem.Unlock( "win" );
JanitorPeep.On( "fightwin" );
}
static void FinishNormalLoss()
{
_losses++;
Chaos.Add( _template.ChaosOnLose );
var hurt = ApplyTeamInjury();
// Showed up: real scrap tip so losses teach, not rage-quit (never full refund).
var pity = Math.Round( Math.Max( 2.0, _template.EntryFee * 0.28 ), 1 );
if ( pity > 0.5 )
Economy.AddForced( pity );
_resultTitle = "DEFEAT";
_resultBody = hurt > 0
? $"Scrap +Ð{Economy.FormatDoge( pity )}. Team hurt ~28s. Train survivors → rematch."
: $"Scrap +Ð{Economy.FormatDoge( pity )}. Train, combine, rematch when ready.";
_log.Add( hurt > 0 ? "LOSS · team benched · scrap tip" : "LOSS · scrap tip" );
GameAudio.PlayUi( GameAudio.Error );
// Trying beats chicken — small respect for showing up.
Karma.Add( 0.8f );
TankSim.ShowBanner( hurt > 0
? $"Lost {_template.Name}. ~28s lockout · scrap Ð{Economy.FormatDoge( pity )}."
: $"Lost {_template.Name}. Scrap Ð{Economy.FormatDoge( pity )} — train up." );
if ( _losses == 1 )
GrandmaReact.OnFirstFightLoss();
var drop = CrimeSystem.ApplyFightLoot( won: false, champFinal: false, champMid: false, intensity: 0f );
if ( !string.IsNullOrEmpty( drop ) )
_resultBody += drop;
CrimeSystem.NotifyFightResult();
JanitorPeep.On( "fightloss" );
}
/// <summary>
/// 1.0 when evenly matched / favored; up to ~1.4 when you beat a tougher bracket with soft fish.
/// </summary>
static double UnderdogRewardMul()
{
if ( _template is null || _playerTeam.Count == 0 )
return 1.0;
var playerAvg = 0f;
var n = 0;
foreach ( var pf in _playerTeam )
{
// Power stands in for endurance/body in the arena model.
playerAvg += (pf.Power + pf.Speed + pf.Agility) / 3f;
n++;
}
if ( n <= 0 )
return 1.0;
playerAvg /= n;
var aiMid = (_template.AiStatMin + _template.AiStatMax) * 0.5f;
if ( aiMid < 0.5f )
return 1.0;
var ratio = playerAvg / aiMid; // <1 = underdog
if ( ratio >= 0.95f )
return 1.0;
if ( ratio >= 0.75f )
return 1.12;
if ( ratio >= 0.55f )
return 1.25;
return 1.38;
}
static int ApplyTeamInjury()
{
var hurt = 0;
foreach ( var pf in _playerTeam )
{
var fish = TankSim.Fish.FirstOrDefault( f => f.InstanceId == pf.SourceId );
if ( fish is null )
continue;
fish.ApplyFightLossInjury();
hurt++;
}
return hurt;
}
static DecorDef PickRewardDecor()
{
var catalog = DecorDef.Props.ToList();
if ( catalog.Count == 0 )
return null;
// Prefer slightly nicer pieces for hard fights
if ( _template is not null &&
( _template.Id == "shark_bait_invitational" || _template.IsChampionship ) )
{
var fancy = catalog.Where( d => d.Cost >= 25 ).ToList();
if ( fancy.Count > 0 )
return fancy[_rng.Next( fancy.Count )];
}
return catalog[_rng.Next( catalog.Count )];
}
public static void AcknowledgeResult()
{
if ( _phase != BattlePhase.Result )
return;
// Mid-cup: advance straight into the next round (no re-entry fee).
if ( _resultAdvancesChamp && _playerWon && _template is not null && _template.IsChampionship )
{
if ( TryStartNextChampRound() )
return;
// Fall through to lobby if roster collapsed.
}
_resultAdvancesChamp = false;
// Back to lobby for rematch
_phase = BattlePhase.Lobby;
_playerTeam.Clear();
_aiTeam.Clear();
_log.Clear();
_selectedIds.Clear();
if ( _template is not null )
AutoSelectBest( _template.TeamSize );
_version++;
}
static bool TryStartNextChampRound()
{
if ( _template is null || !_template.IsChampionship )
return false;
_champRound = Math.Max( 1, _champRound ) + 1;
if ( _champRound > _template.ChampionshipRounds )
{
EndChampRun();
return false;
}
_champRunActive = true;
_playerTeam.Clear();
_aiTeam.Clear();
_log.Clear();
_selectedIds.Clear();
foreach ( var id in _champRosterIds )
{
var fish = TankSim.Fish.FirstOrDefault( f => f.InstanceId == id );
if ( !CanFight( fish ) )
continue;
_playerTeam.Add( FromPlayer( fish ) );
_selectedIds.Add( id );
}
if ( _playerTeam.Count < 1 )
{
TankSim.ShowBanner( "Cup team unavailable — cup ended." );
EndChampRun();
return false;
}
BuildAiTeam( _template, ChampStatMult(), ChampForceJacked() );
LayoutArena();
BeginFightVisuals();
_playerWon = false;
_payout = 0;
_resultAdvancesChamp = false;
_resultTitle = "";
_resultBody = "";
_log.Add( $"CUP ROUND {_champRound}/{_template.ChampionshipRounds}" );
_log.Add( "Team locked · AI scaled up." );
GameAudio.PlayUi( GameAudio.Confirm );
Chaos.Add( 0.8f );
_version++;
return true;
}
}