A gameplay subsystem implementing a single-run "pipe crawl" mini-game. It spawns enemy, hazard, loot and boss CrawlThing instances, advances them across the field, handles chomp input, hit detection, scoring/payout, combo tracking, HP, and wave progression.
namespace NoChillquarium;
public enum CrawlKind
{
Enemy,
Hazard,
Loot,
Boss
}
public enum CrawlOutcome
{
Idle,
Playing,
Cleared,
Dead
}
public sealed class CrawlThing
{
public CrawlKind Kind;
public string SpriteId = "adv_mite";
public string Name = "";
public float X;
public float Y;
public float W = 48f;
public float H = 36f;
public float Vx = -160f;
public int HitsLeft = 1;
public bool Dead;
public float Pop;
public double Payout;
public bool Resolved;
public string CssKind => Kind switch
{
CrawlKind.Boss => "kind-boss",
CrawlKind.Hazard => "kind-hazard",
CrawlKind.Loot => "kind-loot",
_ => "kind-enemy"
};
}
/// <summary>
/// Adventure pipe crawl — chomp timing, not click-to-continue.
/// One run of HP across rooms. Hits cost real health. Combo pays.
/// </summary>
public static class PipeCrawl
{
public const float FieldW = 640f;
public const float FieldH = 196f;
public const float BiteLo = 148f;
public const float BiteHi = 258f;
public const float FishX = 70f;
public const float FishY = 92f;
static readonly Random _rng = new();
static readonly List<CrawlThing> _things = new();
static readonly List<(float Delay, CrawlThing Thing)> _queue = new();
static CrawlOutcome _outcome = CrawlOutcome.Idle;
static int _hp;
static int _maxHp;
static int _combo;
static int _bestCombo;
static int _chomps;
static int _perfects;
static int _version;
static float _cooldown;
static float _hurtFlash;
static float _chompFlash;
static float _bob;
static float _clearHold;
static float _invuln;
static float _bitePad;
static string _shout = "";
static float _shoutTimer;
static string _fishSprite = "goldfish";
static string _roomTitle = "";
static double _waveLoot;
static bool _bossWave;
public static int Version => _version;
public static CrawlOutcome Outcome => _outcome;
public static bool IsPlaying => _outcome == CrawlOutcome.Playing;
public static int Hp => _hp;
public static int MaxHp => _maxHp;
public static int Combo => _combo;
public static int BestCombo => _bestCombo;
public static int Chomps => _chomps;
public static int Perfects => _perfects;
public static float HurtFlash => _hurtFlash;
public static float ChompFlash => _chompFlash;
public static float Bob => _bob;
public static string Shout => _shoutTimer > 0f ? _shout : "";
public static string FishSprite => _fishSprite;
public static string RoomTitle => _roomTitle;
public static double WaveLoot => _waveLoot;
public static bool BossWave => _bossWave;
public static IReadOnlyList<CrawlThing> Things => _things;
/// <summary>Nearest living thing still ahead of the fish — drives gutter art.</summary>
public static CrawlThing FocusThing
{
get
{
CrawlThing best = null;
var bestX = 9999f;
foreach ( var t in _things )
{
if ( t is null || t.Dead )
continue;
if ( t.X + t.W < 8f )
continue;
if ( t.X < bestX )
{
bestX = t.X;
best = t;
}
}
return best;
}
}
public static float BiteLoPx => BiteLo - _bitePad;
public static float BiteHiPx => BiteHi + _bitePad;
public static float BiteWidth => BiteHiPx - BiteLoPx;
public static bool InBiteWindow =>
_things.Exists( t => !t.Dead && !t.Resolved && InBite( t ) );
public static IEnumerable<bool> HpPips
{
get
{
for ( var i = 0; i < _maxHp; i++ )
yield return i < _hp;
}
}
public static void Stop()
{
_outcome = CrawlOutcome.Idle;
_things.Clear();
_queue.Clear();
_shout = "";
_shoutTimer = 0f;
_waveLoot = 0;
_clearHold = 0f;
_version++;
}
/// <summary>End the location run (HP gone). Next map calls BeginRun again.</summary>
public static void EndRun()
{
Stop();
_hp = 0;
_maxHp = 0;
}
public static void BeginRun( FishActor lead )
{
_fishSprite = lead?.DisplaySpriteId;
if ( string.IsNullOrEmpty( _fishSprite ) )
_fishSprite = "goldfish";
var end = lead?.Endurance ?? 3f;
_maxHp = Math.Clamp( 2 + (int)(end / 2.6f), 2, 6 );
if ( lead?.Jacked == true )
_maxHp++;
_hp = _maxHp;
_combo = 0;
_bestCombo = 0;
_chomps = 0;
_perfects = 0;
_waveLoot = 0;
_hurtFlash = 0f;
_invuln = 0f;
_bitePad = 8f + (lead?.AgilityNorm ?? 0.3f) * 22f;
_outcome = CrawlOutcome.Idle;
_things.Clear();
_queue.Clear();
_version++;
}
public static void StartWave( LotRoomDef room, int roomIndex, int roomCount )
{
_things.Clear();
_queue.Clear();
_waveLoot = 0;
_clearHold = 0f;
_outcome = CrawlOutcome.Playing;
_roomTitle = room?.Title ?? "Pipes";
_bossWave = room?.Kind == LotRoomKind.Boss;
BuildWave( room, roomIndex, roomCount );
Yell( _bossWave ? "BOSS" : "CHOMP", 0.7f );
_version++;
}
public static void Tick( float dt )
{
if ( _outcome != CrawlOutcome.Playing || dt <= 0f )
return;
_bob += dt * 7.2f;
if ( _cooldown > 0f )
_cooldown -= dt;
if ( _hurtFlash > 0f )
_hurtFlash -= dt;
if ( _chompFlash > 0f )
_chompFlash -= dt;
if ( _invuln > 0f )
_invuln -= dt;
if ( _shoutTimer > 0f )
_shoutTimer -= dt;
if ( _clearHold > 0f )
{
_clearHold -= dt;
if ( _clearHold <= 0f )
{
_outcome = CrawlOutcome.Cleared;
_version++;
}
return;
}
// Spawn queued threats.
for ( var i = _queue.Count - 1; i >= 0; i-- )
{
var q = _queue[i];
q.Delay -= dt;
if ( q.Delay <= 0f )
{
_things.Add( q.Thing );
_queue.RemoveAt( i );
}
else
{
_queue[i] = q;
}
}
var live = 0;
foreach ( var t in _things )
{
if ( t.Dead )
{
t.Pop += dt;
continue;
}
live++;
t.X += t.Vx * dt;
t.Y += MathF.Sin( _bob * 0.9f + t.X * 0.02f ) * (t.Kind == CrawlKind.Loot ? 10f : 4f) * dt * 8f;
if ( t.Resolved )
continue;
// Reached the fish — contact hit.
if ( t.X + t.W * 0.35f <= FishX + 36f )
{
t.Resolved = true;
if ( t.Kind == CrawlKind.Loot )
{
t.Dead = true;
t.Pop = 0f;
}
else
{
TakeHit( t.Kind == CrawlKind.Hazard ? "SCRAPE" : "BIT" );
t.Dead = true;
t.Pop = 0f;
if ( _outcome == CrawlOutcome.Dead )
return;
}
}
}
_things.RemoveAll( t => t.Dead && t.Pop > 0.28f );
if ( live == 0 && _queue.Count == 0 && _things.Count == 0 )
{
_clearHold = 0.55f;
Yell( _bossWave ? "DOWN" : "CLEAR", 0.55f );
GameAudio.PlayUi( GameAudio.Confirm );
_version++;
}
}
public static void Chomp()
{
if ( _outcome != CrawlOutcome.Playing || _clearHold > 0f )
return;
if ( _cooldown > 0f )
return;
_cooldown = 0.18f;
_chompFlash = 0.12f;
CrawlThing best = null;
var bestDist = 9999f;
var lo = BiteLoPx;
var hi = BiteHiPx;
var mid = (lo + hi) * 0.5f;
foreach ( var t in _things )
{
if ( t.Dead || t.Resolved )
continue;
var cx = t.X + t.W * 0.5f;
if ( cx < lo || cx > hi )
continue;
var d = MathF.Abs( cx - mid );
if ( d < bestDist )
{
bestDist = d;
best = t;
}
}
if ( best is null )
{
GameAudio.PlayUi( GameAudio.Pop );
_version++;
return;
}
var perfect = bestDist <= 16f;
best.HitsLeft--;
if ( best.HitsLeft > 0 )
{
// Boss chip.
_combo++;
_chomps++;
if ( _combo > _bestCombo )
_bestCombo = _combo;
Yell( perfect ? "CHIP" : "HIT", 0.28f );
GameAudio.PlayUi( GameAudio.Tape );
BoomFeel.SoftPunch( 0.35f );
_version++;
return;
}
best.Dead = true;
best.Resolved = true;
best.Pop = 0f;
_combo++;
_chomps++;
if ( perfect )
_perfects++;
if ( _combo > _bestCombo )
_bestCombo = _combo;
var pay = best.Payout;
if ( pay > 0 )
{
if ( perfect )
pay = Math.Round( pay * 1.4, 1 );
if ( _combo >= 4 )
pay = Math.Round( pay * (1.0 + Math.Min( 0.6, (_combo - 3) * 0.12 )), 1 );
Economy.Add( pay );
LotRunSystem.AddCrawlLoot( pay );
_waveLoot += pay;
}
if ( best.Kind == CrawlKind.Boss )
{
Yell( "CHOMP", 0.45f );
GameAudio.PlayBigWin( bark: pay >= 20 );
BoomFeel.SoftPunch( 0.85f );
}
else if ( best.Kind == CrawlKind.Loot )
{
Yell( perfect ? "SNAG" : "YOINK", 0.28f );
GameAudio.PlayCoin();
}
else if ( best.Kind == CrawlKind.Hazard )
{
Yell( perfect ? "SLIP" : "THRU", 0.28f );
GameAudio.PlayUi( GameAudio.Pop );
BoomFeel.SoftPunch( 0.25f );
}
else
{
Yell( perfect ? "PERFECT" : (_combo >= 3 ? "x" + _combo : "CHOMP"), 0.32f );
GameAudio.PlayUi( GameAudio.Confirm );
BoomFeel.SoftPunch( perfect ? 0.55f : 0.4f );
}
_version++;
}
static void TakeHit( string shout )
{
if ( _invuln > 0f )
return;
_combo = 0;
_hp--;
_hurtFlash = 0.38f;
_invuln = 0.42f;
Yell( shout, 0.4f );
GameAudio.PlayUi( GameAudio.Error );
BoomFeel.SoftPunch( 0.7f );
if ( _hp <= 0 )
{
_hp = 0;
_outcome = CrawlOutcome.Dead;
}
_version++;
}
static void Yell( string text, float life )
{
_shout = text ?? "";
_shoutTimer = life;
}
static bool InBite( CrawlThing t )
{
var cx = t.X + t.W * 0.5f;
return cx >= BiteLoPx && cx <= BiteHiPx;
}
static void BuildWave( LotRoomDef room, int roomIndex, int roomCount )
{
var kind = room?.Kind ?? LotRoomKind.Look;
var speed = 132f + roomIndex * 16f;
if ( kind == LotRoomKind.Boss )
speed += 18f;
var delay = 0.35f;
var names = room?.EnemyNames;
void Q( float wait, CrawlThing t )
{
delay += wait;
t.X = FieldW + 8f;
t.Y = FishY - t.H * 0.35f + (float)(_rng.NextDouble() * 18f - 8f);
t.Vx = -speed * (0.9f + (float)_rng.NextDouble() * 0.22f);
_queue.Add( (delay, t) );
}
switch ( kind )
{
case LotRoomKind.Look:
Q( 0.15f, Enemy( "adv_mite", Pick( names, "Pipe Mite" ), 4, 48, 34, speed ) );
Q( 0.85f, Loot( 5 ) );
Q( 0.75f, Enemy( "adv_mite", Pick( names, "Silt Ray" ), 5, 48, 34, speed ) );
break;
case LotRoomKind.Hazard:
Q( 0.1f, Hazard( "adv_valve", "Valve", speed ) );
Q( 0.7f, Enemy( "adv_mite", Pick( names, "Pipe Mite" ), 5, 48, 34, speed ) );
Q( 0.65f, Hazard( "adv_grate", "Grate", speed ) );
if ( roomIndex >= 2 )
Q( 0.7f, Hazard( "adv_valve", "Valve", speed * 1.05f ) );
break;
case LotRoomKind.Loot:
Q( 0.1f, Loot( 6 ) );
Q( 0.45f, Loot( 5 ) );
Q( 0.55f, Enemy( "adv_eel", Pick( names, "Gutter Spec" ), 6, 62, 30, speed ) );
Q( 0.5f, Loot( 7 ) );
Q( 0.55f, Loot( 8 ) );
break;
case LotRoomKind.Fight:
Q( 0.1f, Enemy( "adv_mite", Pick( names, "Drain Runt" ), 6, 48, 34, speed ) );
Q( 0.62f, Enemy( "adv_eel", Pick( names, "Mop Eel" ), 8, 62, 30, speed ) );
Q( 0.58f, Enemy( "adv_mite", Pick( names, "Lot Skater" ), 6, 48, 34, speed ) );
Q( 0.7f, Enemy( "adv_eel", Pick( names, "Wet Ratfish" ), 9, 62, 30, speed ) );
if ( roomCount > 4 )
Q( 0.6f, Enemy( "adv_mite", Pick( names, "Scale Flake" ), 7, 48, 34, speed ) );
break;
case LotRoomKind.Boss:
Q( 0.1f, Enemy( "adv_mite", "Minion", 5, 48, 34, speed ) );
Q( 0.85f, Boss( room ) );
Q( 1.15f, Enemy( "adv_eel", "Escort", 8, 62, 30, speed * 1.05f ) );
break;
default:
Q( 0.2f, Enemy( "adv_mite", "Pipe Mite", 5, 48, 34, speed ) );
Q( 0.7f, Loot( 5 ) );
break;
}
}
static CrawlThing Enemy( string sprite, string name, double pay, float w, float h, float speed ) =>
new()
{
Kind = CrawlKind.Enemy,
SpriteId = sprite,
Name = name,
W = w,
H = h,
HitsLeft = 1,
Payout = pay,
Vx = -speed
};
static CrawlThing Hazard( string sprite, string name, float speed ) =>
new()
{
Kind = CrawlKind.Hazard,
SpriteId = sprite,
Name = name,
W = 46f,
H = 46f,
HitsLeft = 1,
Payout = 3,
Vx = -speed * 0.92f
};
static CrawlThing Loot( double pay ) =>
new()
{
Kind = CrawlKind.Loot,
SpriteId = "adv_coin",
Name = "Coin",
W = 28f,
H = 28f,
HitsLeft = 1,
Payout = pay,
Vx = -118f
};
static CrawlThing Boss( LotRoomDef room )
{
var lo = room?.FightRewardMin > 0 ? room.FightRewardMin : 18;
var hi = room?.FightRewardMax > lo ? room.FightRewardMax : lo + 16;
var pay = lo + _rng.NextDouble() * (hi - lo);
return new CrawlThing
{
Kind = CrawlKind.Boss,
SpriteId = "adv_carp",
Name = Pick( room?.EnemyNames, "Mainline Carp" ),
W = 92f,
H = 62f,
HitsLeft = 3,
Payout = Math.Round( pay, 1 ),
Vx = -108f
};
}
static string Pick( string[] names, string fallback )
{
if ( names is { Length: > 0 } )
return names[_rng.Next( names.Length )];
return fallback;
}
}