Game ending system for NoChillquarium. Determines unlocks, purchase logic for late-game items (WMD and blue whale), checks readiness for three ending kinds (Good, Bad, Mixed), starts and advances ending sequences, stores small ending state for saves and exposes UI text/hints.
namespace NoChillquarium;
public enum EndingKind
{
None,
GoodBoy, // chill / sanctuary (enum kept for save/achievement ids)
BadBoy, // WMD + whale
Mixed // neither pure path
}
/// <summary>
/// Dual endings + late-game WMD / whale keys.
/// Tuned for a ~4 hour full run (not a demo sprint).
/// Good: long care, high karma, low chaos, thriving late tank, no WMD.
/// Bad: own WMD + blue whale, high chaos or low karma, late playtime.
/// Mixed: full-length play, neither pure path.
/// </summary>
public static class EndingSystem
{
// Late-game Ð sinks — you should be in gulf/ocean income before these.
public const double WmdCost = 4000;
public const double WhaleCost = 6000;
/// <summary>~3h20 before the crate even shows as buyable.</summary>
public const float WmdUnlockPlaytime = 2.5f * 3600f;
/// <summary>~3h before whale paperwork.</summary>
public const float WhaleUnlockPlaytime = 3f * 3600f;
/// <summary>~3h30 — earliest chill credits if you played clean.</summary>
public const float GoodPlaytime = 3.5f * 3600f;
/// <summary>~3h45 — apocalypse needs the toys + time.</summary>
public const float BadPlaytime = 3.75f * 3600f;
/// <summary>~4h — awkward middle path.</summary>
public const float MixedPlaytime = 4f * 3600f;
static bool _hasWmd;
static bool _hasWhale;
static EndingKind _kind = EndingKind.None;
static bool _reached;
static float _creditsTimer;
static int _version;
static int _lineIndex;
static float _lineTimer;
// Sanctuary / chill path — earned quiet, not a cold-open relative cameo.
static readonly string[] GoodLines =
{
"Night settles on the glass.",
"Every fish you didn't detonate is still here.",
"The chaos meter gets bored and goes home.",
"Your tanks look like a place. Not a crime scene.",
"You sit. They swim. Nobody explodes.",
"CHILL credits. You earned the quiet."
};
static readonly string[] BadLines =
{
"You arm the WMD. Safety is a suggestion.",
"The blue whale surfaces. It is not chill.",
"Dogecoin graphs invert. Metal howls.",
"Somewhere, an insurance adjuster weeps.",
"The ocean accepts your apology. Barely.",
"NO-CHILL credits. Strobe optional. You are trending for the wrong reasons."
};
static readonly string[] MixedLines =
{
"You raised a sanctuary and a war crime in the same week.",
"Half your fish are champions. Half are evidence.",
"The whale waves. The inspector leaves. Both are correct.",
"Nobody is fully proud. Nobody is fully ruined.",
"Credits roll with jazz-metal confusion."
};
public static int Version => _version;
public static bool HasWmd => _hasWmd;
public static bool HasWhale => _hasWhale;
public static bool Reached => _reached;
public static EndingKind Kind => _kind;
public static float CreditsTimer => _creditsTimer;
public static string CurrentLine
{
get
{
var lines = LinesFor( _kind );
if ( lines.Length == 0 )
return "";
var i = Math.Clamp( _lineIndex, 0, lines.Length - 1 );
return lines[i];
}
}
public static int LineIndex => _lineIndex;
public static int LineCount => LinesFor( _kind ).Length;
public static string Title => _kind switch
{
EndingKind.GoodBoy => "CHILL ENDING",
EndingKind.BadBoy => "NO-CHILL ENDING",
EndingKind.Mixed => "MIXED ENDING",
_ => ""
};
public static string Subtitle => _kind switch
{
EndingKind.GoodBoy => "Sanctuary secured. You actually took care of them.",
EndingKind.BadBoy => "WMD + blue whale. Peak no-chill.",
EndingKind.Mixed => "Neither pure. Extremely human.",
_ => ""
};
static string[] LinesFor( EndingKind k ) => k switch
{
EndingKind.GoodBoy => GoodLines,
EndingKind.BadBoy => BadLines,
EndingKind.Mixed => MixedLines,
_ => Array.Empty<string>()
};
public static void Reset()
{
_hasWmd = false;
_hasWhale = false;
_kind = EndingKind.None;
_reached = false;
_creditsTimer = 0f;
_lineIndex = 0;
_lineTimer = 0f;
_version++;
}
public static void Load( bool wmd, bool whale, string endingId )
{
_hasWmd = wmd;
_hasWhale = whale;
_reached = false;
_kind = EndingKind.None;
_creditsTimer = 0f;
_lineIndex = 0;
// Don't auto-replay ending on continue — flags only.
_version++;
}
public static void WriteToSave( SaveData data )
{
if ( data is null )
return;
data.HasWmd = _hasWmd;
data.HasWhale = _hasWhale;
data.EndingReached = _reached ? _kind.ToString() : "";
}
// ---- Shop ----
public static string WmdUnlockText
{
get
{
if ( Economy.PlaytimeSeconds >= WmdUnlockPlaytime )
return "Available";
var left = WmdUnlockPlaytime - Economy.PlaytimeSeconds;
return $"Locked {FormatLeft( left )}";
}
}
public static string WhaleUnlockText
{
get
{
if ( Economy.PlaytimeSeconds >= WhaleUnlockPlaytime )
return "Available";
var left = WhaleUnlockPlaytime - Economy.PlaytimeSeconds;
return $"Locked {FormatLeft( left )}";
}
}
public static bool CanBuyWmd() =>
TankSim.IsActive
&& !_hasWmd
&& !_reached
&& Economy.PlaytimeSeconds >= WmdUnlockPlaytime
&& Economy.Balance >= WmdCost;
public static bool CanBuyWhale() =>
TankSim.IsActive
&& !_hasWhale
&& !_reached
&& Economy.PlaytimeSeconds >= WhaleUnlockPlaytime
&& Economy.Balance >= WhaleCost;
public static bool TryBuyWmd()
{
if ( _hasWmd )
{
Shop.FailPublic( "Already own a WMD." );
return false;
}
if ( Economy.PlaytimeSeconds < WmdUnlockPlaytime )
{
Shop.FailPublic( WmdUnlockText );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( !Economy.TrySpend( WmdCost ) )
{
GameAudio.PlayUi( GameAudio.Error );
return false;
}
_hasWmd = true;
Chaos.Add( 18f );
Karma.Add( -20f );
GameAudio.PlayUi( GameAudio.Upgrade );
TankSim.ShowBanner( "WMD acquired." );
Achievements.OnWmdBought();
SaveGame.TrySave();
_version++;
return true;
}
public static bool TryBuyWhale()
{
if ( _hasWhale )
{
Shop.FailPublic( "Already own a whale." );
return false;
}
if ( Economy.PlaytimeSeconds < WhaleUnlockPlaytime )
{
Shop.FailPublic( WhaleUnlockText );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( !Economy.TrySpend( WhaleCost ) )
{
GameAudio.PlayUi( GameAudio.Error );
return false;
}
_hasWhale = true;
Chaos.Add( 12f );
Karma.Add( -8f );
TankSim.ShowBanner( "Blue whale acquired." );
GameAudio.PlayUi( GameAudio.Trade );
Achievements.OnWhaleBought();
SaveGame.TrySave();
_version++;
return true;
}
// ---- Readiness ----
/// <summary>
/// Late-game thriving: real collection, both water types, no junkie crash-out.
/// </summary>
public static bool ThrivingTank =>
TankSim.IsActive
&& TankSim.FishCount >= 12
&& TankSim.FishCount >= TankSim.Capacity * 0.35f
&& TankSim.AddictedFishCount == 0
&& TankSim.WithdrawingFishCount == 0
&& TankSim.SaltUnlocked
&& TankSim.HabitatRank >= 4;
public static bool CanGoodBoy
{
get
{
if ( _reached || !TankSim.IsActive )
return false;
if ( Economy.PlaytimeSeconds < GoodPlaytime )
return false;
if ( _hasWmd )
return false;
if ( Karma.Value < 65f )
return false;
if ( Chaos.Value > 28f )
return false;
if ( !ThrivingTank )
return false;
return true;
}
}
public static bool CanBadBoy
{
get
{
if ( _reached || !TankSim.IsActive )
return false;
if ( Economy.PlaytimeSeconds < BadPlaytime )
return false;
if ( !_hasWmd || !_hasWhale )
return false;
// Dark path: chaos high OR karma in the gutter
if ( Chaos.Value < 50f && Karma.Value > 30f )
return false;
return true;
}
}
public static bool CanMixed
{
get
{
if ( _reached || !TankSim.IsActive )
return false;
if ( Economy.PlaytimeSeconds < MixedPlaytime )
return false;
// Neither pure path: have some sin and some virtue
if ( CanGoodBoy || CanBadBoy )
return false;
if ( Karma.Value < 30f || Karma.Value > 75f )
return false;
if ( Chaos.Value < 22f || Chaos.Value > 75f )
return false;
return TankSim.FishCount >= 8 && TankSim.HabitatRank >= 3;
}
}
public static string GoodHint
{
get
{
if ( CanGoodBoy )
return "Ready — claim it";
if ( Economy.PlaytimeSeconds < GoodPlaytime )
return $"Wait {FormatLeft( GoodPlaytime - Economy.PlaytimeSeconds )}";
if ( _hasWmd )
return "Blocked (own WMD)";
if ( Karma.Value < 65f )
return $"Karma {Karma.ValueText}/65";
if ( Chaos.Value > 28f )
return $"Chaos too high ({Chaos.ValueText})";
if ( !TankSim.SaltUnlocked )
return "Need salt tank";
if ( TankSim.HabitatRank < 4 )
return "Need bigger habitat";
if ( TankSim.AddictedFishCount > 0 || TankSim.WithdrawingFishCount > 0 )
return "Clear addiction first";
if ( TankSim.FishCount < 12 )
return $"Need 12+ fish ({TankSim.FishCount})";
if ( TankSim.FishCount < TankSim.Capacity * 0.35f )
return "Fill more of the tank";
return "Almost…";
}
}
public static string BadHint
{
get
{
if ( CanBadBoy )
return "Ready — launch it";
if ( Economy.PlaytimeSeconds < WmdUnlockPlaytime && !_hasWmd )
return $"WMD {WmdUnlockText.ToLower()}";
if ( !_hasWmd )
return "Buy WMD crate";
if ( Economy.PlaytimeSeconds < WhaleUnlockPlaytime && !_hasWhale )
return $"Whale {WhaleUnlockText.ToLower()}";
if ( !_hasWhale )
return "Buy blue whale";
if ( Economy.PlaytimeSeconds < BadPlaytime )
return $"Wait {FormatLeft( BadPlaytime - Economy.PlaytimeSeconds )}";
if ( Chaos.Value < 50f && Karma.Value > 30f )
return "Raise chaos / cut karma";
return "Almost…";
}
}
public static string MixedHint
{
get
{
if ( CanMixed )
return "Ready — claim it";
if ( Economy.PlaytimeSeconds < MixedPlaytime )
return $"Wait {FormatLeft( MixedPlaytime - Economy.PlaytimeSeconds )}";
if ( CanGoodBoy || CanBadBoy )
return "A pure path is open";
return "Stay mid karma + chaos";
}
}
/// <summary>Short product line for WMD shop row (not full ending checklist).</summary>
public static string WmdShopNote =>
_hasWmd ? "owned" : Economy.PlaytimeSeconds < WmdUnlockPlaytime ? WmdUnlockText : "no-chill key";
/// <summary>Short product line for whale shop row.</summary>
public static string WhaleShopNote =>
_hasWhale ? "owned" : Economy.PlaytimeSeconds < WhaleUnlockPlaytime ? WhaleUnlockText : "no-chill key";
/// <summary>One-line arc summary for shop.</summary>
public static string ArcTip =>
"~4h · care or chaos";
static string FormatLeft( float seconds )
{
if ( seconds < 0f )
seconds = 0f;
var totalMin = (int)(seconds / 60f);
if ( totalMin >= 60 )
{
var h = totalMin / 60;
var m = totalMin % 60;
return m > 0 ? $"{h}h {m}m" : $"{h}h";
}
var s = (int)(seconds % 60f);
return totalMin > 0 ? $"{totalMin}m {s:00}s" : $"{s}s";
}
public static bool TryStartGood()
{
if ( !CanGoodBoy )
{
TankSim.ShowBanner( GoodHint );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
return Begin( EndingKind.GoodBoy );
}
public static bool TryStartBad()
{
if ( !CanBadBoy )
{
TankSim.ShowBanner( BadHint );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
return Begin( EndingKind.BadBoy );
}
public static bool TryStartMixed()
{
if ( !CanMixed )
{
TankSim.ShowBanner( MixedHint );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
return Begin( EndingKind.Mixed );
}
static bool Begin( EndingKind kind )
{
_kind = kind;
_reached = true;
_lineIndex = 0;
_lineTimer = 0f;
_creditsTimer = 0f;
_version++;
BattleSystem.Clear();
ExplodeSystem.Clear();
TrainingSystem.CancelInject();
SharkSystem.Clear();
GameFlow.SetScreen( GameScreen.Ending );
if ( kind == EndingKind.GoodBoy )
GameAudio.StartMusicChillEnding();
else
GameAudio.StartMusicMetalEnding( kind == EndingKind.BadBoy );
Achievements.OnEnding( kind );
SaveGame.TrySave();
Log.Info( $"[NO-CHILLquarium] Ending: {kind}" );
return true;
}
public static void Tick( float dt )
{
if ( !_reached || GameFlow.Screen != GameScreen.Ending )
return;
if ( dt <= 0f )
return;
if ( dt > 0.05f )
dt = 0.05f;
_creditsTimer += dt;
_lineTimer += dt;
var lines = LinesFor( _kind );
if ( lines.Length == 0 )
return;
// Advance story lines every ~3.2s
if ( _lineTimer >= 3.2f && _lineIndex < lines.Length - 1 )
{
_lineTimer = 0f;
_lineIndex++;
_version++;
GameAudio.PlayUi( GameAudio.Notice );
}
}
public static void AdvanceLine()
{
if ( !_reached )
return;
var lines = LinesFor( _kind );
if ( _lineIndex < lines.Length - 1 )
{
_lineIndex++;
_lineTimer = 0f;
_version++;
GameAudio.PlayUi( GameAudio.Pop );
}
}
public static bool LinesFinished =>
_reached && _lineIndex >= LinesFor( _kind ).Length - 1;
public static void FinishToMenu()
{
GameAudio.PlayUi( GameAudio.Confirm );
GameAudio.StopMusic();
// Keep save with ending flags; clear live tank for menu.
FeedSystem.Clear();
ExplodeSystem.Clear();
BattleSystem.Clear();
SharkSystem.Clear();
DeathFeel.Clear();
BoomFeel.Clear();
TankSim.Clear();
_reached = false; // allow new game; flags stay in save until NewGame resets
_kind = EndingKind.None;
GameFlow.SetScreen( GameScreen.MainMenu );
GameAudio.StartMenuMusic();
_version++;
}
}