TrainingSystem static class for a fish-training minigame and an inject/steroid minigame. Manages state, timing, UI feedback, applying stat gains to FishActor instances, session summaries, buying/using syringes, and per-frame ticking of progress markers.
namespace NoChillquarium;
/// <summary>
/// Fish training minigame (3-rep timing) + steroid inject minigame.
/// Gym is free; training items (meth, juice) are Ð sinks in the shop.
/// Steroids unlock after ~1 hour playtime.
/// </summary>
public static class TrainingSystem
{
public const float SteroidUnlockSeconds = 3600f;
public const double SyringeCost = 60;
public const float JackedDuration = 48f;
public const float TrainCooldown = 3.5f;
public const int TrainReps = 3;
static readonly Random _rng = new();
static float _trainCooldown;
static int _version;
static int _syringes;
// ---- Inject minigame ----
static bool _injectOpen;
static float _marker;
static float _markerDir = 1f;
static float _markerSpeed = 1.15f;
public const float ZoneMin = 0.42f;
public const float ZoneMax = 0.62f;
// ---- Training minigame (single-fish focus) ----
static bool _trainOpen;
static string _trainTargetId = "";
static float _trainMarker;
static float _trainMarkerPrev; // last-frame pos for late-click grace
static float _trainMarkerDir = 1f;
static float _trainMarkerSpeed = 1.0f;
static float _trainZoneMin = 0.38f;
static float _trainZoneMax = 0.62f;
static int _trainRep; // 0..TrainReps-1 while playing; TrainReps when done
static int _trainHits;
static int _trainPerfects;
static string _trainFeedback = "";
static float _trainFeedbackTimer;
static float _trainHitLock; // brief pause after a rep so zone isn't a blur
static string _trainStatFocus = "Endurance"; // which stat this session emphasizes
static int _trainStatIndex;
/// <summary>Extra hit width beyond the green bar (covers click lag + marker thickness).</summary>
const float TrainHitPad = 0.06f;
// Session totals (real 0–10 applied) for end-of-set notification
static float _sessionEnd;
static float _sessionSpd;
static float _sessionAgi;
static string _sessionSummary = "";
static float _sessionSummaryTimer;
public static int Version => _version;
public static int Syringes => _syringes;
public static bool InjectOpen => _injectOpen;
public static float Marker => _marker;
public static float TrainCooldownLeft => _trainCooldown;
public static bool CanTrain =>
TankSim.IsActive
&& TankSim.FishCount > 0
&& _trainCooldown <= 0f
&& !_trainOpen
&& !_injectOpen;
public static bool TrainOpen => _trainOpen;
public static string TrainTargetId => _trainTargetId;
public static FishActor TrainTarget =>
string.IsNullOrEmpty( _trainTargetId ) ? null : TankSim.FindFish( _trainTargetId );
public static float TrainMarker => _trainMarker;
public static float TrainZoneMin => _trainZoneMin;
public static float TrainZoneMax => _trainZoneMax;
public static int TrainRep => _trainRep;
public static int TrainHits => _trainHits;
public static int TrainPerfects => _trainPerfects;
public static string TrainFeedback => _trainFeedbackTimer > 0f ? _trainFeedback : "";
public static string TrainStatFocus => _trainStatFocus;
public static string TrainRepLabel =>
_trainOpen ? $"Rep {_trainRep + 1} / {TrainReps}" : "Train";
public static string TrainTargetName =>
TrainTarget?.DisplayName ?? "Fish";
/// <summary>End-of-session gains line, e.g. "END +4 SPD +2 AGI +1" (lingers after gym closes).</summary>
public static string SessionSummary =>
_sessionSummaryTimer > 0f ? _sessionSummary : "";
public static bool HasSessionSummary => _sessionSummaryTimer > 0f && !string.IsNullOrEmpty( _sessionSummary );
public static float TrainZoneWidth => MathF.Max( 0.04f, _trainZoneMax - _trainZoneMin );
public static float TrainZoneLeftPct => _trainZoneMin * 100f;
public static float TrainZoneWidthPct => TrainZoneWidth * 100f;
public static bool SteroidsUnlocked =>
Economy.PlaytimeSeconds >= SteroidUnlockSeconds;
public static string SteroidUnlockText
{
get
{
if ( SteroidsUnlocked )
return "Unlocked";
var left = SteroidUnlockSeconds - Economy.PlaytimeSeconds;
var m = (int)(left / 60f);
var s = (int)(left % 60f);
return $"Unlocks in {m}:{s:00}";
}
}
public static void Clear()
{
_trainCooldown = 0f;
_syringes = 0;
_injectOpen = false;
_trainOpen = false;
_trainTargetId = "";
_marker = 0f;
_markerDir = 1f;
_trainMarker = 0f;
_trainMarkerPrev = 0f;
_trainMarkerDir = 1f;
_trainRep = 0;
_trainHits = 0;
_trainPerfects = 0;
_trainFeedback = "";
_trainFeedbackTimer = 0f;
_trainHitLock = 0f;
_sessionEnd = 0f;
_sessionSpd = 0f;
_sessionAgi = 0f;
_sessionSummary = "";
_sessionSummaryTimer = 0f;
_version++;
}
public static void Load( int syringes )
{
_syringes = Math.Max( 0, syringes );
_injectOpen = false;
_trainOpen = false;
_trainTargetId = "";
_version++;
}
public static void WriteToSave( SaveData data )
{
if ( data is not null )
data.Syringes = _syringes;
}
public static void Tick( float dt )
{
if ( dt <= 0f )
return;
if ( dt > 0.05f )
dt = 0.05f;
if ( _trainCooldown > 0f )
_trainCooldown = MathF.Max( 0f, _trainCooldown - dt );
if ( _trainFeedbackTimer > 0f )
{
_trainFeedbackTimer -= dt;
_version++;
}
if ( _sessionSummaryTimer > 0f )
{
_sessionSummaryTimer = MathF.Max( 0f, _sessionSummaryTimer - dt );
_version++;
}
if ( _injectOpen )
TickMarker( ref _marker, ref _markerDir, ref _markerSpeed, dt, 0.95f );
if ( _trainOpen )
{
if ( _trainHitLock > 0f )
{
// Freeze the bar briefly after a hit so feedback is readable
_trainHitLock = MathF.Max( 0f, _trainHitLock - dt );
_version++;
}
else
{
// Readable pace — was ~1s full sweeps (too fast for click latency)
var baseSpeed = 0.62f + _trainRep * 0.12f;
_trainMarkerPrev = _trainMarker;
TickMarker( ref _trainMarker, ref _trainMarkerDir, ref _trainMarkerSpeed, dt, baseSpeed );
}
}
}
static void TickMarker( ref float marker, ref float dir, ref float speed, float dt, float baseSpeed )
{
// Use baseSpeed directly (don't stack old speed state)
speed = baseSpeed + MathF.Sin( Time.Now * 2.1f ) * 0.08f;
marker += dir * speed * dt;
if ( marker >= 1f )
{
marker = 1f;
dir = -1f;
}
else if ( marker <= 0f )
{
marker = 0f;
dir = 1f;
}
_version++;
}
public static void TickFish( FishActor fish, float dt )
{
if ( fish is null )
return;
if ( fish.JackedTimer > 0f )
{
fish.JackedTimer -= dt;
if ( fish.JackedTimer <= 0f )
{
fish.Jacked = false;
fish.JackedTimer = 0f;
fish.Vx *= 0.6f;
fish.Vy *= 0.6f;
}
}
}
// ---------- TRAINING MINIGAME ----------
/// <summary>Train a specific fish (from fish menu). Free gym; starts timing minigame.</summary>
public static bool TryStartTrain( string fishInstanceId )
{
if ( !TankSim.IsActive )
{
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( MethRush.Open )
{
TankSim.ShowBanner( "Finish meth rush first." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
var fish = TankSim.FindFish( fishInstanceId );
if ( fish is null )
{
TankSim.ShowBanner( "That fish swam off." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( _trainOpen )
{
// Already training this one — keep panel open
if ( _trainTargetId == fishInstanceId )
return true;
TankSim.ShowBanner( "Finish the current gym set first." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( _injectOpen )
{
TankSim.ShowBanner( "Finish the inject minigame first." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( _trainCooldown > 0f )
{
TankSim.ShowBanner( $"Gym cooldown {_trainCooldown:0.0}s" );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
_trainTargetId = fish.InstanceId;
_trainOpen = true;
_trainRep = 0;
_trainHits = 0;
_trainPerfects = 0;
_trainHitLock = 0f;
_trainFeedback = "";
_trainFeedbackTimer = 0f;
_sessionEnd = 0f;
_sessionSpd = 0f;
_sessionAgi = 0f;
_sessionSummary = "";
_sessionSummaryTimer = 0f;
_trainFeedback = "Hit the green zone!";
_trainFeedbackTimer = 1.4f;
_trainStatIndex = _rng.Next( 3 );
_trainStatFocus = _trainStatIndex switch
{
0 => "Endurance",
1 => "Speed",
_ => "Agility"
};
SetupTrainRep();
GameAudio.PlayUi( GameAudio.Notice );
TankSim.ShowBanner( $"Train {fish.DisplayName} · {_trainStatFocus}" );
_version++;
return true;
}
/// <summary>Legacy: train a random active fish.</summary>
public static bool TryStartTrain()
{
if ( TankSim.FishCount == 0 )
{
TankSim.ShowBanner( "No fish to train." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
var list = TankSim.Fish;
var pick = list[_rng.Next( list.Count )];
return TryStartTrain( pick.InstanceId );
}
public static bool TryTrain() => TryStartTrain();
public static void CancelTrain()
{
if ( !_trainOpen )
return;
// Short cooldown so bail-spam doesn't feel free of effort.
_trainOpen = false;
_trainTargetId = "";
_trainCooldown = TrainCooldown * 0.5f;
TankSim.ShowBanner( "Gym skipped." );
GameAudio.PlayUi( GameAudio.Close );
_version++;
}
static void SetupTrainRep()
{
// Zone drifts + gently shrinks each rep (still wide enough to hit)
var width = 0.30f - _trainRep * 0.035f;
width = Math.Clamp( width, 0.18f, 0.32f );
var center = 0.28f + (float)_rng.NextDouble() * 0.44f;
_trainZoneMin = center - width * 0.5f;
_trainZoneMax = center + width * 0.5f;
// Keep fully on the bar
if ( _trainZoneMin < 0.04f )
{
_trainZoneMax += 0.04f - _trainZoneMin;
_trainZoneMin = 0.04f;
}
if ( _trainZoneMax > 0.96f )
{
_trainZoneMin -= _trainZoneMax - 0.96f;
_trainZoneMax = 0.96f;
}
_trainZoneMin = Math.Clamp( _trainZoneMin, 0.04f, 0.9f );
_trainZoneMax = Math.Clamp( _trainZoneMax, _trainZoneMin + 0.12f, 0.96f );
_trainMarker = _rng.NextDouble() < 0.5 ? 0.04f : 0.96f;
_trainMarkerPrev = _trainMarker;
_trainMarkerDir = _trainMarker < 0.5f ? 1f : -1f;
_trainMarkerSpeed = 0.62f + _trainRep * 0.12f;
_trainHitLock = 0f;
}
/// <summary>True if a 0–1 marker position is inside the green (with optional pad).</summary>
static bool MarkerInZone( float m, float pad )
{
return m >= _trainZoneMin - pad && m <= _trainZoneMax + pad;
}
/// <summary>
/// True if the marker is in the green now, was last frame, or crossed the zone this frame.
/// Covers UI click lag so “looks green” ≈ “counts as hit”.
/// </summary>
static bool HitGreenZone()
{
if ( MarkerInZone( _trainMarker, TrainHitPad ) )
return true;
if ( MarkerInZone( _trainMarkerPrev, TrainHitPad ) )
return true;
// Segment cross: prev → current straddles the zone
var a = MathF.Min( _trainMarkerPrev, _trainMarker );
var b = MathF.Max( _trainMarkerPrev, _trainMarker );
var z0 = _trainZoneMin - TrainHitPad;
var z1 = _trainZoneMax + TrainHitPad;
return a <= z1 && b >= z0;
}
/// <summary>Player hits on the current rep — gains + floating +stat pops on hit.</summary>
public static bool TryHitTrain()
{
if ( !_trainOpen )
return false;
// Ignore double-taps during post-hit freeze
if ( _trainHitLock > 0.05f )
return false;
var inZone = HitGreenZone();
var mid = ( _trainZoneMin + _trainZoneMax ) * 0.5f;
var half = MathF.Max( 0.02f, ( _trainZoneMax - _trainZoneMin ) * 0.5f );
// Perfect uses true zone center (no pad) for a skill read
var perfect = MarkerInZone( _trainMarker, 0f )
&& MathF.Abs( _trainMarker - mid ) <= half * 0.45f;
if ( inZone )
{
_trainHits++;
if ( perfect )
{
_trainPerfects++;
_trainFeedback = "PERFECT!";
}
else
{
_trainFeedback = "Good rep!";
}
GameAudio.PlayTrainRep( hit: true, perfect: perfect );
ApplyRepGains( perfect );
}
else
{
_trainFeedback = "Miss — weak form.";
GameAudio.PlayTrainRep( hit: false );
Chaos.Add( 0.5f );
}
_trainFeedbackTimer = 0.9f;
_trainHitLock = 0.28f; // freeze bar so next zone is clear
_trainRep++;
if ( _trainRep >= TrainReps )
{
FinishTrainSession();
return inZone;
}
SetupTrainRep();
_trainHitLock = 0.22f; // short beat before marker moves on next rep
_version++;
return inZone;
}
/// <summary>Per-rep gains on the trainee + RPG float pops over the fish.</summary>
static void ApplyRepGains( bool perfect )
{
var fish = TrainTarget;
if ( fish is null )
return;
var mul = perfect ? 1.35f : 1.0f;
// Readable pops: ~+2…+5 primary per rep (display = gain × 10).
var baseGain = (0.28f + _trainRep * 0.06f) * mul;
baseGain *= 0.95f + (float)_rng.NextDouble() * 0.12f;
var primary = ApplyGain( fish, _trainStatIndex, baseGain );
var secondary = ApplyGain( fish, ( _trainStatIndex + 1 ) % 3, baseGain * 0.5f );
var tertiary = ApplyGain( fish, ( _trainStatIndex + 2 ) % 3, baseGain * 0.3f );
// Display-only pad if cap ate the real gain (session totals stay honest).
var showPrimary = primary > 0.001f ? primary : 0.1f;
var showSecondary = secondary > 0.001f ? secondary : 0.05f;
var ends = new float[3];
ends[_trainStatIndex] = showPrimary;
ends[( _trainStatIndex + 1 ) % 3] = showSecondary;
ends[( _trainStatIndex + 2 ) % 3] = tertiary;
StatFloat.SpawnTrio( fish, ends[0], ends[1], ends[2] );
// Accumulate real applied for the end-of-set card.
AddSessionGain( _trainStatIndex, primary );
AddSessionGain( ( _trainStatIndex + 1 ) % 3, secondary );
AddSessionGain( ( _trainStatIndex + 2 ) % 3, tertiary );
// Little celebratory dart
fish.Vx *= 1.04f + mul * 0.06f;
_version++;
}
static void AddSessionGain( int stat, float amount )
{
if ( amount <= 0.001f )
return;
switch ( stat )
{
case 0: _sessionEnd += amount; break;
case 1: _sessionSpd += amount; break;
default: _sessionAgi += amount; break;
}
}
static void FinishTrainSession()
{
_trainOpen = false;
_trainCooldown = TrainCooldown;
var hitMul = _trainHits / (float)TrainReps;
var fish = TrainTarget;
var name = fish?.DisplayName ?? "Fish";
// Perfect-set bonus before summary
if ( fish is not null && _trainPerfects >= TrainReps )
{
var bonus = ApplyGain( fish, _trainStatIndex, 0.35f );
AddSessionGain( _trainStatIndex, bonus );
}
var gainsLine = FormatSessionGains( _sessionEnd, _sessionSpd, _sessionAgi );
var hasGains = !string.IsNullOrEmpty( gainsLine );
if ( _trainHits == 0 )
{
_sessionSummary = "No gains — hit the green next time";
_sessionSummaryTimer = 3.5f;
TankSim.ShowBanner( $"{name} quit early." );
GameAudio.PlayTrainSessionDone( anyHits: false );
Karma.Add( 0.3f );
}
else if ( _trainPerfects >= TrainReps )
{
_sessionSummary = hasGains ? gainsLine : $"{_trainStatFocus} up";
_sessionSummaryTimer = 5f;
TankSim.ShowBanner( hasGains
? $"{name} · PERFECT set · {gainsLine}"
: $"{name} · PERFECT {_trainStatFocus} set!" );
GameAudio.PlayTrainSessionDone( anyHits: true );
Karma.Add( 3.5f );
Chaos.Add( 1f );
if ( fish is not null && hasGains )
StatFloat.ShowSessionSummary( fish, _sessionEnd, _sessionSpd, _sessionAgi );
}
else if ( _trainHits >= TrainReps )
{
_sessionSummary = hasGains ? gainsLine : $"{_trainStatFocus} up";
_sessionSummaryTimer = 4.5f;
TankSim.ShowBanner( hasGains
? $"{name} trained · {gainsLine}"
: $"{name} · clean set · {_trainStatFocus} up." );
GameAudio.PlayTrainSessionDone( anyHits: true );
Karma.Add( 2.5f );
if ( fish is not null && hasGains )
StatFloat.ShowSessionSummary( fish, _sessionEnd, _sessionSpd, _sessionAgi );
}
else
{
_sessionSummary = hasGains
? $"{_trainHits}/{TrainReps} hits · {gainsLine}"
: $"{_trainHits}/{TrainReps} hits";
_sessionSummaryTimer = 4.5f;
TankSim.ShowBanner( hasGains
? $"{name} · {_trainHits}/{TrainReps} · {gainsLine}"
: $"{name} · {_trainHits}/{TrainReps} hits · {_trainStatFocus}." );
GameAudio.PlayTrainSessionDone( anyHits: true );
Karma.Add( 1.2f + _trainHits * 0.4f );
if ( fish is not null && hasGains )
StatFloat.ShowSessionSummary( fish, _sessionEnd, _sessionSpd, _sessionAgi );
}
if ( fish is not null && _trainPerfects >= 2 )
{
var ang = (float)(_rng.NextDouble() * Math.PI * 2);
fish.Vx = MathF.Cos( ang ) * fish.Species.Speed * (1.4f + hitMul);
fish.Vy = MathF.Sin( ang ) * fish.Species.Speed * 0.5f;
}
if ( _trainHits > 0 )
Achievements.OnTrained();
_trainTargetId = "";
SaveGame.TrySave();
_version++;
}
/// <summary>Display points = tenths of 0–10 stat (0.35 → +4). Skips zero stats.</summary>
public static string FormatSessionGains( float end, float spd, float agi )
{
var parts = new List<string>( 3 );
void Add( string label, float gain )
{
if ( gain <= 0.001f )
return;
var pts = Math.Max( 1, (int)MathF.Round( gain * 10f ) );
parts.Add( $"{label} +{pts}" );
}
// Largest first so the focus stat tends to lead
var ordered = new[]
{
("END", end),
("SPD", spd),
("AGI", agi)
};
Array.Sort( ordered, ( a, b ) => b.Item2.CompareTo( a.Item2 ) );
foreach ( var (label, gain) in ordered )
Add( label, gain );
return string.Join( " ", parts );
}
/// <returns>Actual applied amount after 0–10 cap.</returns>
static float ApplyGain( FishActor fish, int stat, float gain )
{
if ( fish is null || gain <= 0f )
return 0f;
float before;
switch ( stat )
{
case 0:
before = fish.Endurance;
fish.Endurance = MathF.Min( 10f, fish.Endurance + gain );
return fish.Endurance - before;
case 1:
before = fish.SpeedStat;
fish.SpeedStat = MathF.Min( 10f, fish.SpeedStat + gain );
return fish.SpeedStat - before;
default:
before = fish.Agility;
fish.Agility = MathF.Min( 10f, fish.Agility + gain );
return fish.Agility - before;
}
}
/// <summary>
/// Meth bag dose: chemical gym. Strong SPD lean, some END/AGI.
/// Diminishing returns as addiction climbs (tolerance). Spawns +stat floats.
/// </summary>
public static void ApplyMethTraining( FishActor fish )
{
if ( fish is null )
return;
// ~0.35 solid hit when fresh; ~0.18 when full client — still trains, just less.
var gain = 0.38f * (0.55f + (1f - fish.AddictionNorm) * 0.45f);
gain *= 0.9f + (float)_rng.NextDouble() * 0.2f;
// Zoomies theme: speed primary, agility secondary, endurance a little.
var spd = ApplyGain( fish, 1, gain );
var agi = ApplyGain( fish, 2, gain * 0.55f );
var end = ApplyGain( fish, 0, gain * 0.35f );
StatFloat.SpawnTrio( fish, end, spd, agi );
_version++;
}
// ---------- STEROIDS ----------
public static bool CanBuySyringe() =>
TankSim.IsActive && SteroidsUnlocked && Economy.Balance >= SyringeCost;
public static bool TryBuySyringe()
{
if ( !SteroidsUnlocked )
{
TankSim.ShowBanner( $"Steroids {SteroidUnlockText.ToLower()}." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( !Economy.TrySpend( SyringeCost ) )
{
TankSim.ShowBanner( $"Need Ð{Economy.FormatDoge( SyringeCost )} for juice." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
_syringes++;
GameAudio.PlayUi( GameAudio.Trade );
Chaos.Add( 3f );
TankSim.ShowBanner( $"Bought juice ×{_syringes}." );
SaveGame.TrySave();
_version++;
return true;
}
public static bool TryStartInject()
{
if ( !TankSim.IsActive || TankSim.FishCount == 0 )
{
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( MethRush.Open )
{
TankSim.ShowBanner( "Finish meth rush first." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( _trainOpen )
{
TankSim.ShowBanner( "Finish training first." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( _syringes <= 0 )
{
TankSim.ShowBanner( "No juice — buy in Shady." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
_injectOpen = true;
_marker = 0f;
_markerDir = 1f;
_markerSpeed = 1.2f;
GameAudio.PlayUi( GameAudio.Notice );
_version++;
return true;
}
public static void CancelInject()
{
_injectOpen = false;
_version++;
}
public static bool TryHitInject()
{
if ( !_injectOpen )
return false;
var hit = _marker >= ZoneMin && _marker <= ZoneMax;
_injectOpen = false;
if ( !hit )
{
if ( _syringes > 0 )
_syringes--;
Chaos.Add( 4f );
GameAudio.PlayUi( GameAudio.Error );
Achievements.OnInjectFail();
TankSim.ShowBanner( "Missed. Syringe wasted." );
_version++;
SaveGame.TrySave();
return false;
}
if ( _syringes > 0 )
_syringes--;
var n = 0;
foreach ( var fish in TankSim.Fish )
{
fish.Jacked = true;
fish.JackedTimer = JackedDuration;
var end = ApplyGain( fish, 0, 1.2f );
var spd = ApplyGain( fish, 1, 1.5f );
var agi = ApplyGain( fish, 2, 0.8f );
StatFloat.SpawnTrio( fish, end, spd, agi );
var ang = (float)(_rng.NextDouble() * Math.PI * 2);
fish.Vx = MathF.Cos( ang ) * fish.Species.Speed * 2.2f;
fish.Vy = MathF.Sin( ang ) * fish.Species.Speed * 0.8f;
n++;
}
Chaos.Add( 8f + n );
Karma.Add( -5f - n * 0.4f );
GameAudio.PlayUi( GameAudio.Inject );
Achievements.OnInjectSuccess( n );
TankSim.ShowBanner( n == 1 ? "Fish jacked." : $"{n} fish jacked." );
SaveGame.TrySave();
_version++;
return true;
}
}