Entities/Blocks/BlockStasis.cs

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.

NetworkingFile Access
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 );
	}
}