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.
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);
}