PipeCrawl.cs

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.

Obfuscated Code
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;
	}
}