Entities/Blocks/BlockTrail.cs

BlockTrail is a utility that stores a fading grid-based "wake" left by moving blocks. It manages cell lifetime, stamping rectangles of cells, decaying cells over time, run-length rendering into pooled SpriteRenderer quads, spatial queries (point/rect overlap), and picking random live cells for cosmetic particles.

Native Interop
namespace BlockParty;

/// <summary>
/// A fading pixel-grid "wake" that a moving block can lay behind itself. Owns the whole trail mechanism —
/// grid storage, oldest-first decay, the run-length translucent overlay render, and a spatial query — so
/// blocks that leave a trail (<see cref="BlockStasis"/>, <see cref="BlockVenom"/>) share one implementation
/// instead of each hand-rolling it. The block on top just decides WHAT to stamp, HOW it fades/looks, and
/// what happens to a player standing in it.
///
/// The grid keeps cells DISJOINT (one rect per contiguous same-life-band run) so translucent overlay quads
/// never stack alpha, and every cell ages independently so the tail recedes smoothly oldest-first. It is a
/// pure function of the stamps + the fixed step (no Rng) so replays reproduce it exactly; the optional
/// particle-placement helper uses the cosmetic Rng stream so streaming motes never advances the sim Rng.
/// </summary>
public sealed class BlockTrail
{
	// Cell coord packing offset (keeps any small negative coord non-negative inside a 16-bit field). cy is
	// the HIGH field so a plain ascending key sort orders cells row-major (cy, then cx) — used by Render.
	const int CELL_OFFSET = 1024;

	readonly float _cell;               // world px per grid cell
	readonly int _bands;                // life quantisation levels (also the number of distinct alpha steps)
	readonly int _poolMax;              // hard cap on overlay rects (degrades gracefully past it)
	readonly System.Func<SpriteRenderer> _spriteFactory; // makes a fresh pooled overlay quad on demand

	// cellKey -> (remaining life, the lifetime it was stamped with) — so alpha/strength can fade over each
	// cell's OWN (possibly per-stamp) lifetime.
	readonly Dictionary<long, (float Life, float Max)> _cells = new();
	// Keys currently alive, kept in sync with _cells. Iterated for decay (O(live cells)), sorted for the
	// run-length render, and indexed for random particle placement.
	readonly List<long> _live = new();
	// Pooled overlay quads (stage-level pixel sprites, driven directly each render like the lane overlay).
	readonly List<SpriteRenderer> _overlay = new();

	public BlockTrail( float cellSize, int overlayBands, int poolMax, System.Func<SpriteRenderer> spriteFactory )
	{
		_cell = cellSize;
		_bands = overlayBands;
		_poolMax = poolMax;
		_spriteFactory = spriteFactory;
	}

	/// <summary>Number of currently-live cells (drives particle emission rate; 0 == empty trail).</summary>
	public int LiveCount => _live.Count;

	/// <summary>Destroy the pooled overlay quads and forget every live cell. The quads are STAGE-parented,
	/// so they outlive the owning block's GameObject — the owner must call this when it is removed mid-run
	/// (the mimic transform swap), or the trail freezes on screen at its last render forever.</summary>
	public void Destroy()
	{
		foreach ( var sr in _overlay )
			GameStage.DestroyOverlaySprite( sr );
		_overlay.Clear();
		_cells.Clear();
		_live.Clear();
	}

	/// <summary>The grid cell index a world coordinate falls in (per-axis).</summary>
	public int CellIndex( float world ) => (int)MathF.Floor( world / _cell );

	// ------------------------------------------------------------------------------------------
	/// <summary>Age every live cell (in place), dropping the ones that hit zero — the oldest go first, so
	/// the tail recedes smoothly rather than in a chunk.</summary>
	public void Decay( float dt )
	{
		int w = 0;
		for ( int i = 0; i < _live.Count; i++ )
		{
			long key = _live[i];
			var cell = _cells[key];
			cell.Life -= dt;
			if ( cell.Life <= 0f )
			{
				_cells.Remove( key );
				continue;
			}
			_cells[key] = cell;
			_live[w++] = key;
		}
		if ( w < _live.Count )
			_live.RemoveRange( w, _live.Count - w );
	}

	/// <summary>Stamp an inclusive rectangle of cells to full life, refreshing (restarting the timer of) any
	/// already-live cell it covers. The caller decides the cell bounds (e.g. the block's full footprint, or
	/// a small centred strip) and the per-stamp lifetime.</summary>
	public void StampCells( int cx0, int cx1, int cy0, int cy1, float lifetime )
	{
		for ( int cy = cy0; cy <= cy1; cy++ )
		{
			for ( int cx = cx0; cx <= cx1; cx++ )
			{
				long key = Key( cx, cy );
				if ( !_cells.ContainsKey( key ) ) _live.Add( key );
				_cells[key] = ( lifetime, lifetime );
			}
		}
	}

	// ------------------------------------------------------------------------------------------
	/// <summary>If the given world point sits in a live cell, report that cell's remaining life + the
	/// lifetime it was stamped with (so the caller can compute a fade fraction).</summary>
	public bool TryGetLife( float worldX, float worldY, out float life, out float max )
	{
		if ( _cells.TryGetValue( Key( CellIndex( worldX ), CellIndex( worldY ) ), out var c ) && c.Life > 0f )
		{
			life = c.Life;
			max = c.Max;
			return true;
		}
		life = max = 0f;
		return false;
	}

	/// <summary>True if any live cell (whose remaining life is at least <paramref name="minLifeFraction"/> of
	/// the lifetime it was stamped with) overlaps the world-space rect — a robust "the body is touching the
	/// trail" test that a single-point query would miss on a thin strip. Cheap: the rect only ever spans a
	/// handful of cells.</summary>
	public bool OverlapsRect( RectF r, float minLifeFraction = 0f )
	{
		int cx0 = CellIndex( r.Left );
		int cx1 = CellIndex( r.Right );
		int cy0 = CellIndex( r.Bottom );
		int cy1 = CellIndex( r.Top );
		for ( int cy = cy0; cy <= cy1; cy++ )
		{
			for ( int cx = cx0; cx <= cx1; cx++ )
			{
				if ( _cells.TryGetValue( Key( cx, cy ), out var c ) && c.Life > 0f && c.Life >= c.Max * minLifeFraction )
					return true;
			}
		}
		return false;
	}

	/// <summary>Pick a uniformly-random live cell's centre (for sprinkling cosmetic motes). Uses the
	/// COSMETIC Rng stream so it never advances the authoritative sim Rng.</summary>
	public bool TryPickCellCenter( out Vector2 center )
	{
		if ( _live.Count == 0 )
		{
			center = default;
			return false;
		}
		long key = _live[Rng.CosmeticInt( 0, _live.Count )];
		int cy = (int)(key >> 16) - CELL_OFFSET;
		int cx = (int)(key & 0xFFFF) - CELL_OFFSET;
		center = new Vector2( (cx + 0.5f) * _cell, (cy + 0.5f) * _cell );
		return true;
	}

	// ------------------------------------------------------------------------------------------
	/// <summary>Draw the trail as run-length rows: sort the live cells row-major (cy, then cx) and merge
	/// contiguous cells in a row that share a life-band into one translucent rect. Cells are disjoint so
	/// alpha never stacks. <paramref name="colorForBand"/> maps a life-band (1..overlayBands, freshest =
	/// top) to the quad tint.</summary>
	public void Render( System.Func<int, Color> colorForBand, float z )
	{
		int idx = 0;
		if ( _live.Count > 0 )
		{
			_live.Sort(); // ascending key == row-major (cy high field, cx low field)

			int i = 0;
			while ( i < _live.Count && idx < _poolMax )
			{
				long key = _live[i];
				int cy = (int)(key >> 16) - CELL_OFFSET;
				int cx = (int)(key & 0xFFFF) - CELL_OFFSET;
				int band = BandOf( _cells[key] );

				// Extend the run across contiguous same-row, same-band cells.
				int endCx = cx;
				int j = i + 1;
				while ( j < _live.Count )
				{
					long k2 = _live[j];
					int cy2 = (int)(k2 >> 16) - CELL_OFFSET;
					int cx2 = (int)(k2 & 0xFFFF) - CELL_OFFSET;
					if ( cy2 != cy || cx2 != endCx + 1 || BandOf( _cells[k2] ) != band ) break;
					endCx = cx2;
					j++;
				}

				PlaceRect( idx++, cx, endCx + 1, cy, colorForBand( band ), z );
				i = j;
			}
		}

		// Hide unused pool rects.
		for ( int k = idx; k < _overlay.Count; k++ )
			_overlay[k].Enabled = false;
	}

	void PlaceRect( int idx, int cxStart, int cxEnd, int cy, Color color, float z )
	{
		while ( idx >= _overlay.Count )
			_overlay.Add( _spriteFactory() );

		float x0 = cxStart * _cell;
		float x1 = cxEnd * _cell;
		float y0 = cy * _cell;
		float y1 = (cy + 1) * _cell;

		var sr = _overlay[idx];
		sr.Enabled = true;
		sr.Size = new Vector2( x1 - x0, y1 - y0 );
		sr.Color = color;
		sr.GameObject.WorldPosition = new Vector3( (x0 + x1) * 0.5f, (y0 + y1) * 0.5f, z );
	}

	/// <summary>Quantise a cell's remaining life (over its own lifetime) into 1..bands (freshest = top).</summary>
	int BandOf( (float Life, float Max) cell )
	{
		float t = cell.Life / cell.Max;
		return Math.Clamp( (int)MathF.Ceiling( t * _bands ), 1, _bands );
	}

	// cy in the HIGH 16 bits, cx in the low — so ascending key order is row-major (see Render).
	static long Key( int cx, int cy ) => ((long)(cy + CELL_OFFSET) << 16) | (uint)(cx + CELL_OFFSET);
}