Entities/Particle.cs
namespace BlockParty;
/// <summary>
/// Kinds of particle, replacing the original's per-particle SwatchIndex.
/// </summary>
public enum ParticleKind { Dust, Blood, Glitter0, Glitter1, AddSpikes0, AddSpikes1, MagnetDust, StickyGoo0, StickyGoo1, WrapPortal0, WrapPortal1, SwapBurst0, SwapBurst1, StasisTrail0, StasisTrail1, StasisDeep0, StasisDeep1, GunSpark0, GunSpark1, MimicCloud0, MimicCloud1, SirenNote0, SirenNote1, WispTrail0, WispTrail1, VenomTrail0, VenomTrail1, ReverseField0, ReverseField1, ReverseFieldDeep0, ReverseFieldDeep1, MirrorBurst0, MirrorBurst1, BlinkBurst0, BlinkBurst1, RewindBurst0, RewindBurst1, SolarCharge, SolarDrain, MimicWow0, MimicWow1, SlicerCharge0, SlicerCharge1, SlicerChargeDeep0, SlicerChargeDeep1, CoinBurst0, CoinBurst1, DashRecharge0, DashRecharge1 }
public enum ParticleMotion
{
Standard,
Feather,
}
public enum ParticleSolidResponse
{
Ignore,
Bounce,
Stop,
}
/// <summary>
/// Visual effect particle — port of the original <c>Particle</c>: a coloured square that
/// shrinks over its lifetime, decelerates, accumulates gravity, bounces off the arena
/// walls and deflects off blocks. Rendered as a tinted 1x1 sprite scaled to its size.
/// </summary>
public class Particle : Entity2D
{
public GameStage Stage { get; set; }
private Color _color;
private float _deceleration;
private float _totalLifetime;
private float _lifetime;
private int _initialSize;
private float _currentGravity;
private float _gravityStrength;
private float _age;
private float _motionPhase;
private ParticleMotion _motion;
private bool _fadesOut;
private ParticleSolidResponse _solidResponse = ParticleSolidResponse.Bounce;
private float _restitution = 1f;
protected bool _collides = true;
/// <summary>When false the particle ignores the arena walls AND obstacles — it flies dead straight
/// and only dies by lifetime (used by the wind stream, where bouncing reads as visual noise).</summary>
protected bool _bounces = true;
protected bool _clampsToArenaBounds;
// Pin-escape tracking (death fragments): see EnablePinEscape.
private bool _pinEscape;
private Vector2 _pinAnchor;
private float _pinLastBounceAge = float.MinValue;
private int _pinnedBounceStreak;
private int _pinFreeBounces;
private float _pinChanceStep;
/// <summary>A pinch re-hits the block every tick or two; a particle hopping on an open face is
/// airborne far longer between contacts. Bounces further apart than this end the streak.</summary>
const float PIN_MAX_BOUNCE_GAP = 0.07f;
/// <summary>How far the particle may drift from the streak's first bounce and still count as
/// "going nowhere". Generous vs the sliver gaps that pin particles (a free bounce between two
/// blocks a room apart resets long before it matters).</summary>
const float PIN_RADIUS = 8f;
// Fragments pinned by the same slam all start their streak the same tick and bounce every tick,
// so a SHARED escape curve makes them pop free within a few frames of each other no matter how
// independent the rolls are (the escape-time distribution is just that narrow). Each particle
// instead draws its own curve from these ranges at spawn: how many bounces it tolerates before
// rolls begin, and how steeply its chance escalates per bounce after that — spreading co-pinned
// escapes across roughly 0.1–0.5s instead of one visual burst.
const int PIN_FREE_BOUNCES_MIN = 3;
const int PIN_FREE_BOUNCES_MAX = 15;
const float PIN_CHANCE_STEP_MIN = 0.02f;
const float PIN_CHANCE_STEP_MAX = 0.08f;
// Lane-drift tracking (wind/magnet lane particles): ride the source block's lateral motion so the
// particle stays inside a lane that sweeps sideways with the block, and stops drifting the moment the
// block halts. See SetLaneDrift.
private bool _tracksLane;
private Block _driftBlock;
private Vector2 _driftPerp; // cross-lane unit axis the block's velocity is projected onto
private Vector2 _baseVelocity; // constant along-lane flow (+ jitter); the live drift is added on top each tick
private SpriteRenderer _sprite;
public void Setup( Vector2 velocity, float deceleration, float gravityStrength, ParticleKind kind, float lifetime, int initialSize, bool collides = true, bool bounces = true )
{
Setup( velocity, deceleration, gravityStrength, KindColor( kind ), lifetime, initialSize, collides, bounces );
}
public void Setup( Vector2 velocity, float deceleration, float gravityStrength, Color color, float lifetime, int initialSize, bool collides = true, bool bounces = true )
{
Velocity = velocity;
_deceleration = deceleration;
_color = color;
_totalLifetime = lifetime;
_lifetime = lifetime;
_initialSize = initialSize;
_gravityStrength = gravityStrength;
_collides = collides;
_bounces = bounces;
_solidResponse = bounces ? ParticleSolidResponse.Bounce : ParticleSolidResponse.Ignore;
Size = new Vector2( initialSize, initialSize );
}
public void CreateVisuals( bool translucent = false )
{
_sprite = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", Size, "idle" );
_sprite.Color = _color;
// Translucent particles (e.g. the reverse-gravity field motes) carry an alpha < 1 in their colour;
// the default AlphaCutoff (0.5) would DISCARD those pixels, so drop it to 0 for a smooth alpha
// blend and opt out of the opaque default (same trick as the lane overlays / Portal rings).
// Solid particles keep SpriteLayer's opaque default and depth-order for free.
if ( translucent )
{
_sprite.Opaque = false;
_sprite.AlphaCutoff = 0f;
}
}
public void SetMotion( ParticleMotion motion, float phase = 0f )
{
_motion = motion;
_motionPhase = phase;
}
/// <summary>Scale the tint's alpha (the tutorial ghost's copies of ability particles) and switch to a
/// smooth alpha blend so the faded colour actually shows. Call after <see cref="CreateVisuals"/>.</summary>
public void FadeTint( float alpha )
{
_color = _color.WithAlpha( _color.a * alpha );
if ( _sprite is null ) return;
_sprite.Color = _color;
_sprite.Opaque = false;
_sprite.AlphaCutoff = 0f;
}
public void EnableFadeOut()
{
_fadesOut = true;
if ( _sprite is null ) return;
_sprite.Opaque = false;
_sprite.AlphaCutoff = 0f;
}
public void SetCollision( bool collidesWithBlocks, ParticleSolidResponse solidResponse, float restitution = 1f )
{
_collides = collidesWithBlocks;
_solidResponse = solidResponse;
_bounces = solidResponse != ParticleSolidResponse.Ignore;
_restitution = Math.Clamp( restitution, 0f, 1f );
}
/// <summary>Make this (cosmetic, non-colliding) lane particle ride a wind/magnet lane's lateral
/// motion: each tick its velocity becomes the fixed along-lane flow plus the source block's CURRENT
/// velocity component ACROSS the lane. This keeps the particle inside a lane that sweeps sideways with
/// the block, and — because it reads the block's LIVE velocity — it stops drifting sideways the instant
/// the block slams to a halt (Velocity→0) instead of coasting out of the now-stationary lane. Only the
/// cross-lane component is applied, so the along-lane flow (and the caller's blocker life-cap) is
/// untouched. Deterministic: reads a sim value, consumes no Rng, particle stays cosmetic.</summary>
public void SetLaneDrift( Block block, Vector2 crossAxis )
{
_driftBlock = block;
_driftPerp = crossAxis;
_baseVelocity = Velocity;
_tracksLane = true;
}
/// <summary>Death fragments pinned in a gap too small to bounce in (wedged between a block and
/// another block/obstacle/wall) never get their bounce arc — they just vibrate at one point until
/// they expire. When enabled, a streak of rapid block-bounces that stays put gives the particle an
/// escalating chance to stop colliding with blocks for the rest of its life; it's lifted to the
/// high particle plane so it stays visible while it sails out through the pinning block. Only acts
/// on the Bounce response (Stop particles rest against blocks on purpose). Cosmetic only.</summary>
public void EnablePinEscape()
{
_pinEscape = true;
// Per-particle escape curve — COSMETIC stream, and rolled here at spawn (not lazily) so the
// draw order stays a fixed function of the spawn sequence.
_pinFreeBounces = Rng.CosmeticInt( PIN_FREE_BOUNCES_MIN, PIN_FREE_BOUNCES_MAX + 1 );
_pinChanceStep = Rng.CosmeticFloat( PIN_CHANCE_STEP_MIN, PIN_CHANCE_STEP_MAX );
}
/// <summary>Keep this particle inside the playable arena without reflecting its velocity.</summary>
public void ClampToArenaBounds()
{
_clampsToArenaBounds = true;
}
public override void Tick( float dt )
{
_age += dt;
int pixelSize = (int)MathF.Round( Utils.Map( _lifetime, _totalLifetime, 0.0f, _initialSize, 1, true, EasingType.SineEaseOut ) );
if ( pixelSize < 1 ) pixelSize = 1;
Size = new Vector2( pixelSize, pixelSize );
if ( _sprite is not null )
{
_sprite.Size = Size;
if ( _fadesOut )
{
float opacity = Math.Clamp( _lifetime / Math.Max( 0.001f, _totalLifetime ), 0f, 1f );
_sprite.Color = _color.WithAlpha( _color.a * opacity );
}
}
// Lane particles track their source block's live lateral motion (see SetLaneDrift). The along-lane
// flow (_baseVelocity) is constant; the cross-lane drift follows the block and vanishes when it
// stops. decel is 1 and gravity 0 for these, so the mutations below leave this recomputed value be.
if ( _tracksLane )
{
// A STOPPED block already reports Velocity 0 (ImpactEffects zeroes it) → no drift. A DEAD block
// (win sequence) keeps a stale non-zero Velocity but no longer translates, so treat it as no
// drift too, otherwise its last particles would coast sideways out of the frozen lane. Replaced
// (the sim-side mimic-swap flag — engine IsValid() flips at frame cadence, never inside a
// one-frame replay rebuild) covers a block that was outright DESTROYED — e.g. a Mimic transform
// removing the Wind/Magnet source — whose handle is non-null but whose frozen Velocity would
// otherwise keep these particles coasting for the rest of their ~0.5s life.
bool blockMoving = _driftBlock is { Replaced: false, IsDead: false };
float cross = blockMoving ? (_driftBlock.Velocity.x * _driftPerp.x + _driftBlock.Velocity.y * _driftPerp.y) : 0f;
Velocity = _baseVelocity + _driftPerp * cross;
}
Pos += Velocity * dt;
if ( _motion == ParticleMotion.Feather )
Pos += new Vector2( MathF.Sin( _age * 7f + _motionPhase ) * 25f * dt, 0f );
Velocity *= _deceleration;
Velocity += new Vector2( 0, _currentGravity * dt );
_currentGravity += _gravityStrength * dt;
bool bounced = false;
if ( _collides )
{
foreach ( Block block in Stage.GetBlocks() )
{
if ( block.GetRect().Intersects( GetRect() ) )
{
if ( _solidResponse == ParticleSolidResponse.Stop )
ResolveSolid( block.GetRect() );
else
{
// COSMETIC stream: a purely-visual particle must never draw from the authoritative Rng,
// or its motion would shift gameplay decisions downstream.
Velocity = Utils.Normalized( Utils.RotateVector( Position - block.Position,
Rng.CosmeticFloat( -25f, 25f ) ) ) * Velocity.Length * _restitution;
if ( _pinEscape )
TickPinEscape();
}
bounced = true;
break;
}
}
}
if ( _bounces )
{
// Glass is included with ordinary obstacles; fences remain excluded.
if ( Stage is not null )
foreach ( Obstacle obstacle in Stage.GetParticleSolidObstacles() )
bounced |= ResolveSolid( obstacle.GetRect() );
bounced |= CheckBounds();
}
else if ( _clampsToArenaBounds )
{
ClampInsideArena();
}
_lifetime -= dt;
if ( _lifetime <= 0 )
Dead = true;
}
/// <summary>Called on every block bounce of a pin-escape particle: maintain the pinned streak and,
/// once it's clearly stuck, roll the escalating escape chance (see EnablePinEscape).</summary>
private void TickPinEscape()
{
// A qualifying streak = block-bounces in rapid succession that stay near one point. Either
// check failing means the particle is genuinely travelling, so the streak restarts here.
bool rapid = _age - _pinLastBounceAge <= PIN_MAX_BOUNCE_GAP;
_pinLastBounceAge = _age;
if ( !rapid || (Pos - _pinAnchor).Length > PIN_RADIUS )
{
_pinAnchor = Pos;
_pinnedBounceStreak = 0;
return;
}
_pinnedBounceStreak++;
float chance = (_pinnedBounceStreak - _pinFreeBounces) * _pinChanceStep;
// COSMETIC stream — same reasoning as the deflection roll in Tick.
if ( chance > 0f && Rng.CosmeticValue() < chance )
{
_collides = false; // ignore blocks for the rest of this particle's life
Depth = Globals.DEPTH_PARTICLE_1; // above blocks, so it stays visible sailing through
}
}
private bool ResolveSolid( RectF rect )
{
return ResolveMinPenetration( rect ) switch
{
ReflectAxis.Horizontal => RespondHorizontal(),
ReflectAxis.Vertical => RespondVertical(),
_ => false,
};
}
private bool CheckBounds()
{
bool bounced = false;
if ( X - (Width * 0.5f) < Arena.WALL_SIZE )
{
X = Arena.WALL_SIZE + (Width * 0.5f);
RespondHorizontal();
bounced = true;
}
else if ( X > Arena.WIDTH - Arena.WALL_SIZE - (Width * 0.5f) )
{
X = Arena.WIDTH - Arena.WALL_SIZE - (Width * 0.5f);
RespondHorizontal();
bounced = true;
}
if ( Y < Arena.WALL_SIZE + (Height * 0.5f) )
{
Y = Arena.WALL_SIZE + (Height * 0.5f);
RespondVertical();
bounced = true;
}
else if ( Y > Arena.HEIGHT - Arena.WALL_SIZE - (Height * 0.5f) )
{
Y = Arena.HEIGHT - Arena.WALL_SIZE - (Height * 0.5f);
RespondVertical();
bounced = true;
}
return bounced;
}
private bool RespondHorizontal()
{
float x = _solidResponse == ParticleSolidResponse.Stop ? 0f : -Velocity.x * _restitution;
Velocity = new Vector2( x, Velocity.y );
return true;
}
private bool RespondVertical()
{
float y = _solidResponse == ParticleSolidResponse.Stop ? 0f : -Velocity.y * _restitution;
Velocity = new Vector2( Velocity.x, y );
return true;
}
protected void ClampInsideArena()
{
float halfWidth = Width * 0.5f;
float halfHeight = Height * 0.5f;
X = Math.Clamp( X, Arena.WALL_SIZE + halfWidth, Arena.WIDTH - Arena.WALL_SIZE - halfWidth );
Y = Math.Clamp( Y, Arena.WALL_SIZE + halfHeight, Arena.HEIGHT - Arena.WALL_SIZE - halfHeight );
}
private static Color KindColor( ParticleKind k ) => k switch
{
ParticleKind.Dust => Rgb( 248, 245, 230 ), // WHITE
ParticleKind.Blood => Rgb( 200, 30, 25 ), // DARK_RED
ParticleKind.Glitter0 => Rgb( 35, 45, 55 ), // GREY
ParticleKind.Glitter1 => Rgb( 35, 45, 55 ),
ParticleKind.AddSpikes0 => Rgb( 200, 90, 85 ), // RED
ParticleKind.AddSpikes1 => Rgb( 58, 64, 76 ), // BLACK
ParticleKind.MagnetDust => Rgb( 192, 140, 228 ), // PURPLE
ParticleKind.StickyGoo0 => Rgb( 232, 90, 160 ), // HOT_PINK (matches the block body)
ParticleKind.StickyGoo1 => Rgb( 255, 150, 205 ), // LIGHT_HOT_PINK (highlight)
ParticleKind.WrapPortal0 => Rgb( 112, 62, 148 ), // DARK_PURPLE (darker shade of the wrap character's body — the portal ring)
ParticleKind.WrapPortal1 => Rgb( 180, 130, 205 ), // PURPLE (the wrap character's body colour — the portal's inner highlight)
ParticleKind.SwapBurst0 => Rgb( 72, 205, 218 ), // CYAN (the swapper's body colour — the swap-endpoint burst)
ParticleKind.SwapBurst1 => Rgb( 150, 235, 245 ), // LIGHT_CYAN (brighter highlight, layered on top of the cyan burst)
ParticleKind.StasisTrail0 => Rgb( 255, 255, 255 ), // phase 1: crisp white 1px spark
ParticleKind.StasisTrail1 => Rgb( 225, 240, 255 ), // subtle blue-white variation
ParticleKind.StasisDeep0 => Rgb( 105, 175, 255 ), // phase 2: distinctly blue ice flake
ParticleKind.StasisDeep1 => Rgb( 165, 215, 255 ), // lighter blue flake
ParticleKind.GunSpark0 => Rgb( 235, 145, 55 ), // ORANGE (the Gunner's body colour — muzzle spark)
ParticleKind.GunSpark1 => Rgb( 255, 205, 110 ),
ParticleKind.MimicCloud0 => Rgb( 232, 226, 246 ), // pale lavender-white transform smoke
ParticleKind.MimicCloud1 => Rgb( 198, 184, 230 ), // light violet puff (layered highlight) // bright amber highlight, layered on the orange sparks
ParticleKind.SirenNote0 => Rgb( 255, 170, 210 ), // soft pink "note" mote (the siren's song stream)
ParticleKind.SirenNote1 => Rgb( 255, 220, 140 ), // pale gold glint, mixed into the stream
ParticleKind.WispTrail0 => Rgb( 150, 235, 205 ), // mint mote (the wisp's float trail)
ParticleKind.WispTrail1 => Rgb( 224, 255, 242 ), // pale spectral glow, mixed into the trail
ParticleKind.VenomTrail0 => Rgb( 120, 220, 40 ), // toxic acid-green mote (the venom thread's fumes)
ParticleKind.VenomTrail1 => Rgb( 190, 255, 70 ), // hot lime glint, mixed into the fumes
ParticleKind.ReverseField0 => Rgba( 150, 95, 240, 0.35f ), // translucent violet field mote (phase 1)
ParticleKind.ReverseField1 => Rgba( 190, 150, 250, 0.30f ), // lighter violet glint (phase 1)
ParticleKind.ReverseFieldDeep0 => Rgba( 214, 90, 226, 0.36f ), // phase-2 magenta-violet mote (slightly hotter)
ParticleKind.ReverseFieldDeep1 => Rgba( 240, 150, 246, 0.30f ), // phase-2 lighter magenta glint
ParticleKind.MirrorBurst0 => Rgb( 174, 214, 224 ), // silver-blue matching the Swarm body
ParticleKind.MirrorBurst1 => Rgb( 235, 250, 252 ), // bright reflected highlight
ParticleKind.BlinkBurst0 => Rgb( 66, 224, 205 ), // turquoise body
ParticleKind.BlinkBurst1 => Rgb( 255, 112, 132 ), // coral helmet/accent
ParticleKind.RewindBurst0 => Rgb( 244, 82, 154 ), // hot-pink upper time slice
ParticleKind.RewindBurst1 => Rgb( 255, 194, 74 ), // gold middle time slice
ParticleKind.SolarCharge => Rgba( 255, 225, 96, 0.55f ),
ParticleKind.SolarDrain => Rgba( 108, 132, 142, 0.42f ),
ParticleKind.MimicWow0 => Rgb( 255, 220, 112 ), // warm celebratory glint
ParticleKind.MimicWow1 => Rgb( 255, 150, 205 ), // pink glint matching Mimic
// Slicer's laser-charge stream. One pair per phase, taken straight from the beam constants in
// BlockSlicer so the particles read as the line they're charging: the majority mote is the CORE
// colour, the 25% accent is the HALO colour. Keep these in sync with DEADLY_CORE/GLOW_COLOR_P1/P2.
ParticleKind.SlicerCharge0 => Rgb( 255, 31, 26 ), // phase 1: hot red core
ParticleKind.SlicerCharge1 => Rgb( 255, 97, 36 ), // phase 1: orange-red halo accent
ParticleKind.SlicerChargeDeep0 => Rgb( 255, 38, 242 ), // phase 2: hot magenta core
ParticleKind.SlicerChargeDeep1 => Rgb( 166, 51, 255 ), // phase 2: violet halo accent
ParticleKind.CoinBurst0 => Rgb( 221, 221, 153 ), // YELLOW (the coin's body — pickup burst)
ParticleKind.CoinBurst1 => Rgb( 248, 245, 230 ), // WHITE glint, mixed into the burst
ParticleKind.DashRecharge0 => Rgb( 74, 154, 224 ), // BLUE (the climber's body — dash-recharge burst)
ParticleKind.DashRecharge1 => Rgb( 226, 238, 242 ), // pale highlight glint, mixed into the burst
_ => Color.White,
};
private static Color Rgb( int r, int g, int b ) => new Color( r / 255f, g / 255f, b / 255f );
private static Color Rgba( int r, int g, int b, float a ) => new Color( r / 255f, g / 255f, b / 255f, a );
}