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