Entities/Blocks/BlockVenom.cs
namespace BlockParty;

/// <summary>
/// Venom block — like the <see cref="BlockStasis"/> block it lays a fading wake as it travels, but the wake
/// is only a tiny 3×3-pixel strip down the CENTRE of the block, and it is INSTANTLY LETHAL: any body that
/// touches a live cell dies. Dangerous acid-green / hazard colouring (bright toxic core, hotter yellow-green
/// on the freshest cells) warns the player off the thread it leaves behind.
///
/// It shares the whole trail mechanism (grid, oldest-first decay, run-length translucent overlay, spatial
/// query) with <see cref="BlockStasis"/> via <see cref="BlockTrail"/>; this block only decides what to stamp
/// (a 3px centre strip), how it looks, and that touching it kills.
///
/// Behaviour by phase (a phase-up escalates the whole trail — the wake lives longer + glows hotter):
/// <list type="bullet">
/// <item>Phase 0: no trail (harmless, roams).</item>
/// <item>Phase 1: lays a short-lived toxic thread.</item>
/// <item>Phase 2: a longer-lived, more intense thread.</item>
/// </list>
///
/// Determinism: the trail + the kill test are pure functions of positions over time + the fixed step (no
/// Rng), so replays reproduce them exactly. The drifting motes are cosmetic (cosmetic Rng, non-colliding).
/// </summary>
public sealed class BlockVenom : Block
{
	// 1px cells + an odd-width stamp centred on the block == a strip down the block's centre line (kept odd so
	// it stays centred on the middle cell). Phase 2 lays a WIDER strip so the escalation reads. Each cell ages
	// independently so the thread recedes smoothly from the oldest end.
	const float TRAIL_CELL = 1.0f;          // px per grid cell
	const int TRAIL_WIDTH_P1 = 3;           // phase-1 stamp width in cells (3px centre strip)
	const int TRAIL_WIDTH_P2 = 5;           // phase-2 stamp width in cells (5px — noticeably fatter/deadlier)
	const float TRAIL_LIFETIME_P1 = 2.5f;   // seconds a phase-1 cell lives before it has fully faded
	const float TRAIL_LIFETIME_P2 = 6.0f;   // phase-2 threads linger much longer
	const float STAMP_MIN_MOVE = 1.0f;      // stamp a fresh dot once the block has moved this far (1 in-game pixel)

	// Overlay (the visible thread). Dangerous acid green; a cell the block re-crosses restarts at full
	// alpha. Phase 2 burns a hotter toxic yellow-green AND breathes (see the pulse below) so it reads as a
	// live, charged hazard next to the steady phase-1 line.
	const int OVERLAY_BANDS = 10;           // life quantisation levels (also the number of distinct alpha steps)
	const int OVERLAY_POOL_MAX = 768;       // hard cap on overlay rects (finer bands split more runs per row)
	const float P1_R = 0.50f, P1_G = 1.00f, P1_B = 0.16f, P1_ALPHA = 0.98f; // phase 1: bright acid green
	const float P2_R = 0.95f, P2_G = 0.88f, P2_B = 0.08f, P2_ALPHA = 1.00f; // phase 2: hotter hazard yellow-green
	const float PULSE_SPEED = 7.0f;         // rad/s of the phase-2 brightness pulse
	const float PULSE_AMOUNT = 0.18f;       // how much the phase-2 alpha dips at the bottom of the pulse

	// Alpha is driven by LETHALITY, not by raw remaining life: a cell that can still kill stays near-solid
	// (only easing from 1 down to LETHAL_ALPHA_MIN across its whole lethal life), then SNAPS to a dull,
	// desaturated remnant the moment it drops below the kill threshold. A smooth linear fade read as
	// "faded enough to be safe" long before it actually was, and players ate deaths on a ghost-looking thread.
	const float LETHAL_ALPHA_MIN = 0.80f;   // dimmest a still-lethal cell ever draws (fraction of the phase alpha)
	const float SAFE_ALPHA = 0.20f;         // the spent, harmless remnant — obviously dead
	const float SAFE_DESAT = 0.65f;         // …and drained toward grey so "dead" reads by hue too

	// A cell that has faded below this fraction of its life is a dull, nearly-gone remnant — still visible
	// but no longer lethal, so a player can safely brush the very tail of a dissipating thread. Phase 2 stays
	// lethal deeper into its fade (less forgiving) than phase 1.
	const float KILL_MIN_LIFE_FRACTION_P1 = 0.22f;
	const float KILL_MIN_LIFE_FRACTION_P2 = 0.10f;

	// Cosmetic acid motes rising off the thread.
	const float PARTICLE_PER_CELL = 0.5f;   // emission rate scales with thread length…
	const float PARTICLE_RATE_MIN = 4.0f;   // …clamped to a sensible band
	const float PARTICLE_RATE_MAX = 30.0f;
	const float PARTICLE_DRIFT = 22.0f;     // px/s — a light upward acid shimmer
	const float PARTICLE_LIFE_MIN = 0.35f;
	const float PARTICLE_LIFE_MAX = 0.80f;

	// The shared fading pixel-grid wake. Lazily created on first Tick (needs Stage for the overlay pool).
	BlockTrail _trail;

	// Footprint stamping state.
	bool _stamped;
	Vector2 _lastStampPos;
	float _emitAccum;

	// Free-running clock driving the phase-2 brightness pulse (cosmetic; render only).
	float _time;
	float _pulse = 1f;

	public override void Tick( float dt )
	{
		base.Tick( dt ); // move / bounce / phase / eye-cycle first, so we stamp the post-move position

		_trail ??= new BlockTrail( TRAIL_CELL, OVERLAY_BANDS, OVERLAY_POOL_MAX, () => Stage.CreateOverlaySprite() );
		_trail.Decay( dt );

		// Phase-2 breathing pulse (1 at the top, 1-PULSE_AMOUNT at the bottom); applied in OverlayColor.
		_time += dt;
		_pulse = 1f - PULSE_AMOUNT * (0.5f - 0.5f * MathF.Cos( _time * PULSE_SPEED ));

		// Lay a fresh centre dot once we've reached phase 1 and moved at least a pixel (the first stamp seeds
		// the thread the moment we phase up, even before moving).
		if ( Phase >= 1 && !IsDead )
		{
			if ( !_stamped || (Position - _lastStampPos).Length >= STAMP_MIN_MOVE )
			{
				StampCentre();
				_lastStampPos = Position;
				_stamped = true;
			}
		}

		KillTouchingBodies();
		_trail.Render( OverlayColor, Globals.DepthToZ( Globals.DEPTH_LANE_OVERLAY, StageIndex ) );
		EmitParticles( dt );
	}

	public override void DestroyStageVisuals()
	{
		base.DestroyStageVisuals();
		// The kill test lives in this instance's Tick, so the thread dies with it — destroy the drawn
		// trail too rather than leaving a lethal-looking acid thread frozen on screen that never kills.
		_trail?.Destroy();
		_trail = null; // Tick lazily recreates (??=), so a stale reference can't be re-rendered
	}

	/// <summary>Stamp an odd-width dot centred on the block's centre (wider at phase 2), refreshing covered
	/// cells to full life (re-crossing the same spot restarts its timer).</summary>
	void StampCentre()
	{
		int ccx = _trail.CellIndex( X );
		int ccy = _trail.CellIndex( Y );
		int half = (Phase >= 2 ? TRAIL_WIDTH_P2 : TRAIL_WIDTH_P1) / 2; // odd width → 'half' cells either side of centre

		float lifetime = Phase >= 2 ? TRAIL_LIFETIME_P2 : TRAIL_LIFETIME_P1;
		_trail.StampCells( ccx - half, ccx + half, ccy - half, ccy + half, lifetime );
	}

	// ------------------------------------------------------------------------------------------
	/// <summary>Any body whose rect overlaps a live trail cell dies — the real player AND any Summoner
	/// impostors (Impostors is empty on levels without them, so this is a no-op there).</summary>
	void KillTouchingBodies()
	{
		// Skip while dead so the win sequence (all blocks maxed → Die()) doesn't let a lingering trail keep
		// killing the player through the victory hold (same guard the Slicer/Shockwave lethal blocks use).
		if ( Phase < 1 || IsDead ) return;
		// Phase 2 stays lethal deeper into a cell's fade than phase 1 (less forgiving tail).
		float killFrac = Phase >= 2 ? KILL_MIN_LIFE_FRACTION_P2 : KILL_MIN_LIFE_FRACTION_P1;
		foreach ( var player in Stage.LivingPlayers ) KillIfTouching( player, killFrac );
		foreach ( var imp in Stage.Impostors ) KillIfTouching( imp, killFrac );
	}

	void KillIfTouching( Player player, float killFrac )
	{
		if ( player is null || player.IsDead ) return;
		// Rect-overlap (not a single-point query) so brushing the thin thread with any part of the body is
		// lethal — but only a still-potent cell kills; a nearly-faded tail cell (below killFrac of its life)
		// is harmless. A dedicated venom death bursts the player, spraying blood upward off the ground thread.
		if ( _trail.OverlapsRect( player.GetRect(), killFrac ) )
			player.KilledByVenom( new Vector2( 0f, 1f ) );
	}

	// ------------------------------------------------------------------------------------------
	/// <summary>Thread tint for a life-band, chosen by the block's CURRENT phase (acid green at phase 1, a
	/// hotter toxic yellow-green at phase 2), fading out with the band.</summary>
	Color OverlayColor( int band )
	{
		bool p2 = Phase >= 2;
		float killFrac = p2 ? KILL_MIN_LIFE_FRACTION_P2 : KILL_MIN_LIFE_FRACTION_P1;

		// The band spans life fractions (lowT .. highT]. Treat it as lethal if ANY cell in it can still kill,
		// so a lethal cell is never drawn with the harmless remnant's alpha.
		float highT = band / (float)OVERLAY_BANDS;
		float lowT = (band - 1) / (float)OVERLAY_BANDS;

		float r = p2 ? P2_R : P1_R, g = p2 ? P2_G : P1_G, b = p2 ? P2_B : P1_B;

		if ( highT <= killFrac ) // spent remnant: dull and drained, and it doesn't breathe — it's inert
		{
			float grey = (r + g + b) / 3f;
			return new Color(
				MathX.Lerp( r, grey, SAFE_DESAT ),
				MathX.Lerp( g, grey, SAFE_DESAT ),
				MathX.Lerp( b, grey, SAFE_DESAT ),
				SAFE_ALPHA * (p2 ? P2_ALPHA : P1_ALPHA) );
		}

		// Still lethal — ease only across the top of the range, from LETHAL_ALPHA_MIN at the last killing
		// band up to full at the freshest, so the thread stays obviously solid for as long as it bites.
		float k = Math.Clamp( (lowT - killFrac) / MathF.Max( 1f - killFrac, 0.001f ), 0f, 1f );
		float a = MathX.Lerp( LETHAL_ALPHA_MIN, 1f, k );
		return p2
			? new Color( r, g, b, a * P2_ALPHA * _pulse )
			: new Color( r, g, b, a * P1_ALPHA );
	}

	// ------------------------------------------------------------------------------------------
	/// <summary>Sprinkle a light acid shimmer off the thread (cosmetic, cosmetic-Rng).</summary>
	void EmitParticles( float dt )
	{
		if ( _trail.LiveCount == 0 ) return;

		float rate = Math.Clamp( _trail.LiveCount * PARTICLE_PER_CELL, PARTICLE_RATE_MIN, PARTICLE_RATE_MAX );
		_emitAccum += rate * dt;
		while ( _emitAccum >= 1f )
		{
			_emitAccum -= 1f;
			EmitParticle();
		}
	}

	void EmitParticle()
	{
		if ( !_trail.TryPickCellCenter( out Vector2 cellCenter ) ) return;

		Vector2 pos = cellCenter + new Vector2(
			Rng.CosmeticFloat( -TRAIL_CELL * 0.5f, TRAIL_CELL * 0.5f ),
			Rng.CosmeticFloat( -TRAIL_CELL * 0.5f, TRAIL_CELL * 0.5f ) );

		// A light upward acid shimmer (biased up so the motes read as fumes lifting off the thread).
		Vector2 vel = new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( 0.2f, 1f ) ) * Rng.CosmeticFloat( 0f, PARTICLE_DRIFT );
		// One CosmeticValue draw either way → replay-safe.
		var kind = Rng.CosmeticValue() < 0.5f ? ParticleKind.VenomTrail0 : ParticleKind.VenomTrail1;
		float life = Rng.CosmeticFloat( PARTICLE_LIFE_MIN, PARTICLE_LIFE_MAX );

		Stage.AddWindParticle( pos, vel, decel: 0.92f, gravity: 0f, kind, life, Rng.CosmeticInt( 1, 3 ) );
	}
}