A game entity class for a Stasis block. It manages a fading pixel-grid trail as the block moves, stamps footprints into a BlockTrail, applies stasis effects to players standing in trail cells, renders an overlay color based on phase, and emits cosmetic particles.
namespace BlockParty;
/// <summary>
/// Stasis block — a moving block that lays down a fading stasis "wake" as it travels. Once it reaches
/// phase 1 it stamps its current pixel-footprint into a trail every time it moves at least one pixel; the
/// trail then fades from the OLDEST cells first, so the block drags a receding tail behind it. A cell the
/// block re-crosses is refreshed to full life (that patch's timer restarts).
///
/// Standing in the trail drops the player into a stasis field, escalating by phase:
/// <list type="bullet">
/// <item>Phase 0: no effect (no trail is laid).</item>
/// <item>Phase 1: a very tiny gravity plus a viscous "held" damping — the player is slowed and gently
/// sinks, but keeps most of their (weakened) control.</item>
/// <item>Phase 2: a deeper, longer-lasting field — even less gravity, thicker damping and weaker control,
/// so the player is nearly frozen in place (but never fully input-dead).</item>
/// </list>
/// The whole trail's behaviour + colour follow the block's CURRENT phase, so a phase-1 trail deepens (and
/// changes tint) the instant the block phases up. The stasis force itself lives on the player
/// (Player.ApplyGravity / StasisControl); this block just reports the trail LEVEL under the player.
///
/// Rendering mirrors the Wind/Magnet lane overlay: a pool of translucent pixel-sprite rects drawn just
/// below the low particle layer (behind every block), built each tick as run-length rows over a small
/// pixel grid. The grid keeps the rects DISJOINT (no alpha stacking) and gives each cell an independent
/// age, so the tail fades smoothly oldest-first rather than in one chunk. Frozen blue/cyan motes drift
/// (mostly static) throughout the trail on top.
///
/// Determinism: the trail is a pure function of the block's positions over time + the fixed step (no Rng),
/// and the stasis it applies to the player is a pure function of the trail cells + the player position, so
/// replays reproduce it exactly. The particles are cosmetic (cosmetic Rng, non-colliding) so streaming
/// them never advances the authoritative sim Rng.
/// </summary>
public sealed class BlockStasis : Block
{
// Trail grid. A 2px cell keeps the stamped footprint essentially block-exact (the block's own pixel
// rect is integer-aligned, so the trail width matches it to within a pixel — no chunky pop-in) while
// still bounding the cell/rect count; each cell independently ages so the tail recedes smoothly.
const float TRAIL_CELL = 2.0f; // px per grid cell
const float TRAIL_LIFETIME_P1 = 5.0f; // seconds a phase-1 cell lives before it has fully faded
const float TRAIL_LIFETIME_P2 = 9.0f; // phase-2 cells linger much longer (deep stasis)
const float STAMP_MIN_MOVE = 1.0f; // stamp a fresh footprint once the block has moved this far (1 in-game pixel)
// The stasis EFFECT stays near full for most of a cell's life, then eases to zero over just the last
// STASIS_FADE_START fraction of its remaining life — so the trail feels equally strong along its length
// and only lets go right at the fading tail (independent of the visual alpha, which fades linearly).
const float STASIS_FADE_START = 0.28f;
// Overlay (the visible trail). A vivid blue; a cell the block re-crosses restarts at full alpha. The
// tint + peak alpha differ by phase so a phase-2 (input-freezing) wake reads distinctly from phase 1.
const int OVERLAY_BANDS = 8; // life quantisation levels (also the number of distinct alpha steps)
const int OVERLAY_POOL_MAX = 512; // hard cap on overlay rects (degrades gracefully past it)
const float P1_R = 0.14f, P1_G = 0.48f, P1_B = 1.00f, P1_ALPHA = 0.62f; // phase 1: brighter ice blue
const float P2_R = 0.10f, P2_G = 0.30f, P2_B = 1.00f, P2_ALPHA = 0.58f; // phase 2: royal blue
// Cosmetic "static" motes sprinkled through the trail.
const float PARTICLE_PER_CELL = 0.06f; // emission rate scales with trail size…
const float PARTICLE_RATE_MIN = 6.0f; // …clamped to a sensible band
const float PARTICLE_RATE_MAX = 42.0f;
const float PARTICLE_DRIFT_P1 = 7.0f; // phase 1: small sparks with a little movement
const float PARTICLE_DRIFT_P2 = 2.0f; // phase 2: larger flakes held almost still
const float PARTICLE_LIFE_MIN_P1 = 0.35f;
const float PARTICLE_LIFE_MAX_P1 = 0.75f;
const float PARTICLE_LIFE_MIN_P2 = 0.65f;
const float PARTICLE_LIFE_MAX_P2 = 1.15f;
// The fading pixel-grid wake itself, shared with BlockVenom via BlockTrail. Lazily created on first
// Tick (needs Stage for the overlay sprite pool).
BlockTrail _trail;
// Footprint stamping state.
bool _stamped;
Vector2 _lastStampPos;
float _emitAccum;
public override void Tick( float dt )
{
base.Tick( dt ); // move / bounce / phase / eye-cycle first, so we stamp the post-move footprint
_trail ??= new BlockTrail( TRAIL_CELL, OVERLAY_BANDS, OVERLAY_POOL_MAX, () => Stage.CreateOverlaySprite() );
_trail.Decay( dt );
// Lay a fresh footprint once we've reached phase 1 and moved at least a pixel (the first stamp
// seeds the trail as soon as we phase up, even before moving).
if ( Phase >= 1 && !IsDead )
{
if ( !_stamped || (Position - _lastStampPos).Length >= STAMP_MIN_MOVE )
{
StampFootprint();
_lastStampPos = Position;
_stamped = true;
}
}
ApplyStasisToPlayer();
_trail.Render( OverlayColor, Globals.DepthToZ( Globals.DEPTH_LANE_OVERLAY, StageIndex ) );
EmitParticles( dt );
}
public override void DestroyStageVisuals()
{
base.DestroyStageVisuals();
_trail?.Destroy();
_trail = null; // Tick lazily recreates (??=), so a stale reference can't be re-rendered
}
// ------------------------------------------------------------------------------------------
/// <summary>Stamp the block's current (integer-aligned) pixel-footprint into the trail, refreshing each
/// covered cell to full life so a cell the block re-crosses restarts its timer.</summary>
void StampFootprint()
{
RectF r = GetPixelRect( X, Y );
// Stamp only the cells FULLY INSIDE the block's pixel rect (ceil/floor-1), so the trail is never
// WIDER than the block — at worst a pixel narrower when the block sits on an odd pixel. (Rounding the
// footprint OUT would leave the ~1px overhang the user noticed.)
int cx0 = (int)MathF.Ceiling( r.Left / TRAIL_CELL );
int cx1 = (int)MathF.Floor( r.Right / TRAIL_CELL ) - 1;
int cy0 = (int)MathF.Ceiling( r.Bottom / TRAIL_CELL );
int cy1 = (int)MathF.Floor( r.Top / TRAIL_CELL ) - 1;
// Phase-2 cells linger far longer (deep stasis); a cell re-crossed at a higher phase adopts the
// longer lifetime.
float lifetime = Phase >= 2 ? TRAIL_LIFETIME_P2 : TRAIL_LIFETIME_P1;
_trail.StampCells( cx0, cx1, cy0, cy1, lifetime );
}
// ------------------------------------------------------------------------------------------
/// <summary>If the player is standing in a live trail cell, put them in the stasis field at this block's
/// level (1 = phase 1, 2 = deeper phase 2) and the cell's fade strength (full for most of its life,
/// easing to 0 at the tail). The actual force lives on the player (Player.ApplyGravity / StasisControl).</summary>
void ApplyStasisToPlayer()
{
if ( Phase < 1 ) return;
foreach ( var player in Stage.LivingPlayers ) ApplyStasisTo( player );
foreach ( var imp in Stage.Impostors ) ApplyStasisTo( imp );
}
void ApplyStasisTo( Player player )
{
if ( player is null || player.IsDead ) return;
if ( _trail.TryGetLife( player.X, player.Y, out float life, out float max ) )
player.ApplyTrailStasis( Phase >= 2 ? 2 : 1, StasisStrength( life / max ) );
}
// ------------------------------------------------------------------------------------------
/// <summary>Trail tint for a life-band, chosen by the block's CURRENT phase (bright ice blue at phase 1,
/// royal blue at phase 2) so the deeper stasis reads distinctly.</summary>
Color OverlayColor( int band )
{
float t = band / (float)OVERLAY_BANDS;
return Phase >= 2
? new Color( P2_R, P2_G, P2_B, t * P2_ALPHA )
: new Color( P1_R, P1_G, P1_B, t * P1_ALPHA );
}
/// <summary>Effect strength for a cell at the given remaining-life fraction (1 = fresh, 0 = expiring):
/// ~1 for most of the life, smoothly easing to 0 over just the last STASIS_FADE_START fraction.</summary>
static float StasisStrength( float lifeFraction )
{
float x = Math.Clamp( lifeFraction / STASIS_FADE_START, 0f, 1f );
return x * x * (3f - 2f * x); // smoothstep
}
// ------------------------------------------------------------------------------------------
/// <summary>Sprinkle slow, mostly-static ice motes through the trail (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 ) );
// Phase 1 uses small drifting sparks; deep stasis uses larger, nearly-static ice flakes.
bool deep = Phase >= 2;
float maxDrift = deep ? PARTICLE_DRIFT_P2 : PARTICLE_DRIFT_P1;
Vector2 vel = new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -0.4f, 1f ) ) * Rng.CosmeticFloat( 0f, maxDrift );
var kind = deep
? (Rng.CosmeticValue() < 0.5f ? ParticleKind.StasisDeep0 : ParticleKind.StasisDeep1)
: (Rng.CosmeticValue() < 0.5f ? ParticleKind.StasisTrail0 : ParticleKind.StasisTrail1);
float life = deep
? Rng.CosmeticFloat( PARTICLE_LIFE_MIN_P2, PARTICLE_LIFE_MAX_P2 )
: Rng.CosmeticFloat( PARTICLE_LIFE_MIN_P1, PARTICLE_LIFE_MAX_P1 );
int sizeRoll = Rng.CosmeticInt( 0, 2 );
int size = deep ? 3 + sizeRoll : 1;
Stage.AddWindParticle( pos, vel, decel: 0.9f, gravity: 0f, kind, life, size );
}
}