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