Client-side battle system for an offline/local AI fish fighting mini-game. It defines templates, fighter and fx types, an auto-resolving turn/beat-based combat loop with visuals, team selection, AI generation, results, rewards and save integration.
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; }
public float UnlockPlaytime { 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; }
}
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 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 = "";
// 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 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 IReadOnlyList<BattleTemplate> Catalog { get; } = new[]
{
new BattleTemplate
{
Id = "garage_guppies",
Name = "Garage Guppies",
Theme = "easy",
Note = "neighborhood scrubs",
TeamSize = 1,
EntryFee = 12,
RewardMin = 22,
RewardMax = 38,
AiStatMin = 1.5f,
AiStatMax = 3.2f,
AiJacked = false,
UnlockPlaytime = 0f,
MinHabitatRank = 0,
ChaosOnWin = 1.5f,
ChaosOnLose = 2f,
DecorChance = 0.05f,
AiColor = "#90a0b0",
AiNames = new[] { "Gary the Guppy", "Bin Betta", "Parking Lot Pike" }
},
new BattleTemplate
{
Id = "pipe_pugs",
Name = "Pipe Pugs",
Theme = "sewer",
Note = "win: Sewage Duct",
TeamSize = 1,
EntryFee = 28,
RewardMin = 40,
RewardMax = 65,
AiStatMin = 2.5f,
AiStatMax = 4.5f,
AiJacked = false,
UnlockPlaytime = 12f * 60f, // ~12 min
MinHabitatRank = 0,
ChaosOnWin = 3f,
ChaosOnLose = 2.5f,
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 = "win: Storm Drain",
TeamSize = 2,
EntryFee = 35,
RewardMin = 55,
RewardMax = 90,
AiStatMin = 3.2f,
AiStatMax = 5.5f,
AiJacked = false,
UnlockPlaytime = 25f * 60f, // ~25 min
MinHabitatRank = 0,
ChaosOnWin = 3.5f,
ChaosOnLose = 3f,
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 = "illegal gains",
TeamSize = 2,
EntryFee = 40,
RewardMin = 70,
RewardMax = 110,
AiStatMin = 4f,
AiStatMax = 7f,
AiJacked = true,
UnlockPlaytime = 45f * 60f, // ~45 min
MinHabitatRank = 0,
ChaosOnWin = 4f,
ChaosOnLose = 3f,
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 = "win: Neighbor's Pool",
TeamSize = 2,
EntryFee = 48,
RewardMin = 80,
RewardMax = 130,
AiStatMin = 4.2f,
AiStatMax = 7.2f,
AiJacked = false,
UnlockPlaytime = 70f * 60f, // ~1h10
MinHabitatRank = 1,
ChaosOnWin = 4f,
ChaosOnLose = 3.5f,
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 = "win: Community Fountain",
TeamSize = 2,
EntryFee = 52,
RewardMin = 85,
RewardMax = 140,
AiStatMin = 4.5f,
AiStatMax = 7.5f,
AiJacked = false,
UnlockPlaytime = 95f * 60f, // ~1h35
MinHabitatRank = 1,
ChaosOnWin = 3.5f,
ChaosOnLose = 3.5f,
DecorChance = 0.2f,
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 = "salty mid-tier",
TeamSize = 2,
EntryFee = 55,
RewardMin = 95,
RewardMax = 150,
AiStatMin = 5f,
AiStatMax = 8f,
AiJacked = false,
UnlockPlaytime = 2f * 3600f, // ~2h
MinHabitatRank = 1,
ChaosOnWin = 3.5f,
ChaosOnLose = 4f,
DecorChance = 0.2f,
AiColor = "#2060c0",
AiNames = new[] { "Trench Trash", "Pressure Cooker", "Abyssal Intern", "Brine Brain" }
},
new BattleTemplate
{
Id = "spa_bloodsport",
Name = "Spa Bloodsport",
Theme = "wellness",
Note = "win: Abandoned Spa",
TeamSize = 3,
EntryFee = 70,
RewardMin = 120,
RewardMax = 190,
AiStatMin = 5.5f,
AiStatMax = 8.5f,
AiJacked = true,
UnlockPlaytime = 2.5f * 3600f, // ~2h30
MinHabitatRank = 2,
ChaosOnWin = 5f,
ChaosOnLose = 4.5f,
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 = "win: Stolen Hot Tub",
TeamSize = 3,
EntryFee = 85,
RewardMin = 150,
RewardMax = 240,
AiStatMin = 6f,
AiStatMax = 9f,
AiJacked = true,
UnlockPlaytime = 3f * 3600f, // ~3h
MinHabitatRank = 2,
ChaosOnWin = 5.5f,
ChaosOnLose = 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 = 100,
RewardMin = 180,
RewardMax = 280,
AiStatMin = 6.5f,
AiStatMax = 9.5f,
AiJacked = true,
UnlockPlaytime = 3.25f * 3600f, // ~3h15
MinHabitatRank = 3,
ChaosOnWin = 6f,
ChaosOnLose = 5f,
DecorChance = 0.55f,
AiColor = "#102838",
AiNames = new[] { "Bait", "Almost Shark", "Fin Debt", "Chum Bucket", "Loan Shark Jr." }
}
};
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;
// First circuit always open; later ones unlock by playtime OR beating the previous.
var idx = CatalogIndex( t );
if ( idx <= 0 )
return true;
if ( TankSim.HabitatRank < t.MinHabitatRank )
return false;
// Beat the previous circuit → unlock this one immediately.
var prev = Catalog[idx - 1];
if ( IsBeaten( prev.Id ) )
return true;
// Or wait out the playtime gate.
if ( Economy.PlaytimeSeconds >= t.UnlockPlaytime )
return true;
return false;
}
public static string UnlockHint( BattleTemplate t )
{
if ( t is null )
return "";
var idx = CatalogIndex( t );
if ( idx > 0 )
{
var prev = Catalog[idx - 1];
if ( !IsBeaten( prev.Id ) && Economy.PlaytimeSeconds < t.UnlockPlaytime )
{
var left = t.UnlockPlaytime - Economy.PlaytimeSeconds;
var totalMin = (int)(left / 60f);
string wait;
if ( totalMin >= 60 )
{
var h = totalMin / 60;
var m = totalMin % 60;
wait = m > 0 ? $"{h}h {m}m" : $"{h}h";
}
else
{
var s = (int)(left % 60f);
wait = $"{totalMin}:{s:00}";
}
return $"Beat {prev.Name} · or play {wait}";
}
}
if ( TankSim.HabitatRank < t.MinHabitatRank )
return $"Need habitat rank {t.MinHabitatRank}+";
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;
_version++;
}
public static void ResetProgress()
{
_wins = 0;
_losses = 0;
_beaten.Clear();
_prizeHabitatsWon.Clear();
_lastPrizeTankName = "";
Clear();
}
public static void Load( int wins, int losses, List<string> beaten, List<string> prizeHabitats = null )
{
_wins = Math.Max( 0, wins );
_losses = Math.Max( 0, losses );
_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();
}
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 );
TankSim.ShowBanner( $"{t.Name} · pick {t.TeamSize} fish · entry Ð{Economy.FormatDoge( t.EntryFee )}" );
GameAudio.PlayUi( GameAudio.Pop );
_version++;
}
public static bool CanFight( FishActor fish ) =>
fish is not null && fish.Species is not null && !fish.IsFightInjured;
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;
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;
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 ) );
}
BuildAiTeam( _template );
LayoutArena();
_phase = BattlePhase.Fighting;
_fightTimer = 0f;
_beatDuration = 0.95f;
_nextBeat = 0.65f; // brief stare-down
_visAtk = null;
_visTgt = null;
_visKind = "";
_impactFired = false;
_pendingFinish = null;
_pendingDmg = 0f;
_pendingKo = false;
_fx.Clear();
_log.Add( $"FIGHT: {_template.Name}" );
_log.Add( $"You put up Ð{Economy.FormatDoge( _template.EntryFee )}." );
GameAudio.PlayUi( GameAudio.Confirm );
Chaos.Add( 1.5f );
_version++;
return true;
}
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,
Player = true,
SourceId = fish.InstanceId,
SpriteId = sp.Id,
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 )
{
_aiTeam.Clear();
var names = t.AiNames ?? new[] { "Rival Fish" };
var catalog = FishSpecies.AllCatalog;
for ( var i = 0; i < t.TeamSize; i++ )
{
var end = Lerp( t.AiStatMin, t.AiStatMax, (float)_rng.NextDouble() );
var spd = Lerp( t.AiStatMin, t.AiStatMax, (float)_rng.NextDouble() );
var agi = Lerp( t.AiStatMin, t.AiStatMax, (float)_rng.NextDouble() );
var power = end * 1.25f + spd * 1.05f + agi * 1.15f + 2f;
if ( t.AiJacked )
power *= 1.35f;
var hp = 28f + end * 14f + (t.AiJacked ? 16f : 0f);
// 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 + (t.AiJacked ? 1.5f : 0f),
Agility = agi,
Jacked = t.AiJacked,
Player = false,
SourceId = $"ai_{i}",
SpriteId = look.Id,
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
} );
}
}
/// <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
_beatDuration = 0.75f + (float)_rng.NextDouble() * 0.35f;
_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;
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;
if ( won )
{
_wins++;
var lo = _template.RewardMin;
var hi = _template.RewardMax;
_payout = lo + _rng.NextDouble() * (hi - lo);
Economy.Add( _payout );
Chaos.Add( _template.ChaosOnWin );
_beaten.Add( _template.Id );
_lastPrizeTankName = "";
var decorLine = "";
if ( _rng.NextDouble() < _template.DecorChance )
{
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}!";
Achievements.OnFightTankWon( prizeHab );
}
else
{
// Couldn't install (wrong water, etc.) — still mark + consoliation 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;
Achievements.OnFightTankWon( prizeHab );
}
}
else if ( prizeHab is not null )
{
tankLine = " (tank already claimed)";
}
_resultTitle = string.IsNullOrEmpty( _lastPrizeTankName ) ? "VICTORY" : "TANK CLAIMED";
_resultBody = $"+Ð{Economy.FormatDoge( _payout )}.{decorLine}{tankLine}";
_log.Add( $"WIN · +Ð{Economy.FormatDoge( _payout )}{tankLine}" );
GameAudio.PlayDogeBark();
Karma.Add( 2.5f );
Achievements.OnBattleWin( _template, _wins );
var banner = string.IsNullOrEmpty( _lastPrizeTankName )
? $"Won {_template.Name}! +Ð{Economy.FormatDoge( _payout )}"
: $"Won {_template.Name}! Claimed {_lastPrizeTankName}!";
TankSim.ShowBanner( banner );
}
else
{
_losses++;
Chaos.Add( _template.ChaosOnLose );
// Bench every fighter for 3 minutes — they look sick and can't re-queue.
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++;
}
_resultTitle = "DEFEAT";
_resultBody = hurt > 0
? "Fee gone. Team hurt — 3 min lockout."
: "Fee gone. Train or juice.";
_log.Add( hurt > 0 ? "LOSS · team benched 3:00" : "LOSS · go train" );
GameAudio.PlayUi( GameAudio.Error );
Achievements.OnBattleLose();
Karma.Add( -1.5f );
TankSim.ShowBanner( hurt > 0
? $"Lost {_template.Name}. 3 min lockout."
: $"Lost {_template.Name}." );
}
// Tiny post-fight bump to participating fish (winners only)
if ( won )
{
foreach ( var pf in _playerTeam )
{
var fish = TankSim.Fish.FirstOrDefault( f => f.InstanceId == pf.SourceId );
if ( fish is null )
continue;
fish.Endurance = MathF.Min( 10f, fish.Endurance + 0.15f );
fish.Agility = MathF.Min( 10f, fish.Agility + 0.1f );
}
}
// Drop injured fish from the lobby roster so AutoSelect doesn't re-pick corpses.
_selectedIds.RemoveWhere( id =>
{
var f = TankSim.Fish.FirstOrDefault( x => x.InstanceId == id );
return f is null || f.IsFightInjured;
} );
SaveGame.TrySave();
_version++;
}
static DecorDef PickRewardDecor()
{
var catalog = DecorDef.Catalog;
if ( catalog is null || catalog.Count == 0 )
return null;
// Prefer slightly nicer pieces for hard fights
if ( _template is not null && _template.Id == "shark_bait_invitational" )
{
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;
// 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++;
}
}