Entities/Blocks/BlockShockwave.cs
namespace BlockParty;

/// <summary>
/// Shockwave block — on a hard SLAM (phase 1+) it emits a quickly-expanding, thin ring from its centre.
/// The ring passes straight through blocks and obstacles (it's a pure force pulse, not a solid), so its
/// only interaction is with the player: the first time the ring's leading edge reaches the player it does
/// a brief hit-stop and REPELS them outward — directly away from the shockwave's origin. Because the push
/// is radial it's not purely negative: a player standing above the slam gets launched upward (a jump
/// boost), one beside it gets flung sideways (a speed boost).
///
/// <list type="bullet">
/// <item>Phase 0: no shockwave (the block hasn't woken up).</item>
/// <item>Phase 1: the ring reaches out about half the level width.</item>
/// <item>Phase 2: the ring covers the whole level (its radius is the arena diagonal, so it reaches every
/// corner no matter where the block slammed).</item>
/// </list>
///
/// The ring is deliberately THIN and bright so it's obvious where it will sweep the player. It lives ~0.5s.
/// If the block wasn't moving fast when it slammed, the ring's radius and repel strength are scaled down a
/// little (never below <see cref="MIN_STRENGTH_SCALE"/>): full strength at/above <see cref="SHOCKWAVE_FULL_SPEED"/>,
/// easing down toward that floor as the slam speed drops to zero.
///
/// Rendering: the ring is a fixed-count polygon of thin rotated rectangles (like the squid beam), placed
/// around the current radius each tick from a pooled set of stage-level overlay sprites. Drawn above the
/// gameplay layers so the telegraph reads clearly over blocks and the player.
///
/// Determinism: the ring's expansion is a pure function of the fixed step, and the collision + knockback
/// are pure functions of the block/player positions (no Rng, no Time.Delta), so replays reproduce it
/// exactly. It ticks inside the sim loop, so it also freezes during its own hit-stop.
/// </summary>
public sealed class BlockShockwave : Block
{
	// --- tuning -------------------------------------------------------------------------------
	// Slam speed at/above which the shockwave is at full strength. Below it the radius + repel scale down
	// toward MIN_STRENGTH_SCALE (but never past it), so a gentle slam still packs a decent pulse.
	const float SHOCKWAVE_FULL_SPEED = 100.0f;
	const float MIN_STRENGTH_SCALE = 0.6f;

	// Seconds the ring lives (a quick burst). Phase 2 covers a much larger radius, so it's given a much
	// LONGER lifetime — that makes its leading edge travel MUCH SLOWER than phase 1's, not faster (a fixed
	// lifetime would have the bigger phase-2 ring sweep out quicker). ~120/0.5 = 240 px/s at phase 1 vs
	// ~339/2.0 = 170 px/s at phase 2, so the max-phase wave visibly creeps across the whole level.
	const float LIFETIME_P1 = 0.5f;
	const float LIFETIME_P2 = 2.0f;
	const float RING_THICKNESS = 2.5f;           // thin, so it's obvious where it will sweep

	// Reach by phase. Phase 1 ≈ half the level width; phase 2 spans the whole arena (the diagonal, so the
	// ring reaches every corner regardless of where the block slammed).
	const float PHASE1_RADIUS = Arena.WIDTH * 0.5f;
	static readonly float PHASE2_RADIUS = MathF.Sqrt( Arena.WIDTH * (float)Arena.WIDTH + Arena.HEIGHT * (float)Arena.HEIGHT );

	// Outward knockback impulse (px/s) at full strength, by phase — phase 2 hits harder.
	// Scaled down by the slam-speed factor below. Applied to the player's isolated shockwave channel, which
	// bounces off solids (Player.HandleShockwaveBounce).
	const float REPEL_STRENGTH_P1 = 225.0f;
	const float REPEL_STRENGTH_P2 = 250.0f;
	const int HIT_STOP_FRAMES = 0; // 0 = disabled currently, still here in case we want to re-enable it later

	// The ring's final moments are weaker: a hit landed in the last TAIL_TIME seconds of a wave's life
	// scales its knockback force AND hit-stop down toward TAIL_MIN_SCALE. So getting clipped by the dying
	// outer edge (which, with the ease-out expansion, is who the far/late hits are) is gentler than the
	// fresh pulse — never below half strength.
	const float TAIL_TIME = 0.25f;
	const float TAIL_MIN_SCALE = 0.5f;

	// The ring is drawn as this many rotated-rectangle segments forming a regular polygon; each segment's
	// length is the chord for one step, so the polygon stays gap-free at any radius with a FIXED sprite
	// count (no growth as it expands).
	const int SEGMENTS = 64;
	const int RING_DEPTH = Globals.DEPTH_PARTICLE_1; // above player/fireball so the telegraph reads clearly

	static readonly Color PHASE1_COLOR = new( 0.35f, 0.80f, 1.00f ); // electric cyan
	static readonly Color PHASE2_COLOR = new( 1.00f, 0.45f, 0.90f ); // hot magenta at max phase
	const float RING_PEAK_ALPHA = 0.95f;

	// Each wave is drawn as several CONCENTRIC layers for a richer "energy pulse" than a single line: a
	// wide soft glow behind, a couple of fainter echo rings trailing just inside, and a crisp white-hot
	// core at the leading edge (the telegraph). Drawn back-to-front, each on its own tiny Z slice so the
	// overlapping translucent layers don't z-fight.
	readonly struct RingLayer
	{
		public readonly float Offset;   // radius offset from the leading edge (px; <=0 = trailing inside)
		public readonly float Thick;    // thickness multiplier of RING_THICKNESS
		public readonly float Alpha;    // alpha multiplier
		public readonly float White;    // blend toward white (a hot leading edge; also scaled by freshness)
		public RingLayer( float o, float t, float a, float w ) { Offset = o; Thick = t; Alpha = a; White = w; }
	}
	static readonly RingLayer[] RING_LAYERS =
	{
		new( 0f,   6.0f, 0.16f, 0.00f ),  // wide soft glow halo
		new( -16f, 0.8f, 0.20f, 0.00f ),  // far echo ripple
		new( -8f,  1.0f, 0.45f, 0.00f ),  // near echo ripple
		new( 0f,   1.4f, 1.00f, 0.60f ),  // crisp white-hot leading core
	};

	// Deterministic angular "crackle": brightness ripples around the ring (SHIMMER_FREQ cycles per
	// revolution) and rotates over time (SHIMMER_SPEED), dipping up to SHIMMER_DEPTH — reads as arcing
	// energy. Pure function of angle + age, so it stays replay-safe (no Rng).
	const float SHIMMER_FREQ = 9f;
	const float SHIMMER_SPEED = 14f;
	const float SHIMMER_DEPTH = 0.30f;


	// --- state --------------------------------------------------------------------------------
	sealed class Wave
	{
		public Vector2 Center;
		public float Age;
		public float Radius;
		public float MaxRadius;
		public float Lifetime;
		public float Strength;
		public bool IsPhase2;
		public System.Collections.Generic.HashSet<Player> HitPlayers = new();
		// Impostors pushed by this wave (once each) — tracked separately from HitPlayer so the real player's
		// once-per-wave logic (and teleport immunity) is untouched.
		public System.Collections.Generic.HashSet<Player> HitImpostors = new();
	}

	readonly List<Wave> _waves = new();
	// Pooled stage-level ring segments (rotated rectangles). Shared across any concurrent waves.
	readonly List<SpriteRenderer> _segments = new();

	// Player continuity tracking (for teleport/wrap immunity — see UpdateWaves). We remember the player's
	// teleport counter from last tick so we can tell a discontinuous jump (wrap/swap/blink) from ordinary
	// movement.
	readonly System.Collections.Generic.Dictionary<Player, int> _lastTeleportSequences = new();

	public override void Tick( float dt )
	{
		base.Tick( dt ); // move / bounce / phase / eye-cycle; a slam calls Impact (below), spawning a wave

		UpdateWaves( dt );
		RenderWaves();
	}

	public override void DestroyStageVisuals()
	{
		base.DestroyStageVisuals();
		// A wave's expansion + hit test live in this instance's Tick, so they die with it — drop the
		// waves along with their ring segments rather than leaving a frozen ring on screen.
		_waves.Clear();
		DestroyOverlayPool( _segments );
	}

	// ----------------------------------------------------------------------------------------
	public override void Impact( Block otherBlock )
	{
		ImpactEffects( otherBlock );
		if ( IsDead ) return;

		// Fire on a genuine SLAM (the loud impact ImpactEffects flags) once awake. _moveSpeed still holds
		// the pre-impact speed here (ImpactEffects only zeroes Velocity, not _moveSpeed), so it drives the
		// strength scale.
		if ( Phase >= 1 && SlammedThisTick )
			SpawnWave();

		CloseEyesAndPickNewDirection();
	}

	void SpawnWave()
	{
		float scale = Utils.Map( _moveSpeed, 0f, SHOCKWAVE_FULL_SPEED, MIN_STRENGTH_SCALE, 1f, true, EasingType.Linear );
		bool phase2 = Phase >= 2;

		_waves.Add( new Wave
		{
			Center = Position,
			MaxRadius = (phase2 ? PHASE2_RADIUS : PHASE1_RADIUS) * scale,
			Lifetime = phase2 ? LIFETIME_P2 : LIFETIME_P1,
			Strength = (phase2 ? REPEL_STRENGTH_P2 : REPEL_STRENGTH_P1) * scale,
			IsPhase2 = phase2,
		} );

		// A low, boomy burst for the pulse (pitched up a touch on the stronger phase-2 blast).
		Audio.PlaySfx( SfxType.BlockExplosion, Position, 0.55f * scale, phase2 ? 1.15f : 0.95f );
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Age each live wave, grow its radius (fast then easing out), and on the tick its leading
	/// edge first reaches the player: hit-stop + radial knockback. The ring passes through everything else.</summary>
	void UpdateWaves( float dt )
	{
		for ( int i = _waves.Count - 1; i >= 0; i-- )
		{
			var w = _waves[i];
			w.Age += dt;
			if ( w.Age >= w.Lifetime )
			{
				_waves.RemoveAt( i );
				continue;
			}

			// Quick expansion (ease-out) — an explosive burst that decelerates as it reaches full reach. The
			// previous radius is where the edge sat while the players ticked (blocks tick first), i.e. the
			// edge a teleport this tick actually had to beat.
			float prevRadius = w.Radius;
			w.Radius = Utils.Map( w.Age, 0f, w.Lifetime, 0f, w.MaxRadius, true, EasingType.SineEaseOut );

			// No stage — age the waves but there is nobody to hit (same guard as the other blocks).
			if ( Stage is null ) continue;

			// Iterate ALL alive players (including hardened statues), not just LivingPlayers — a hardened
			// player is excluded from LivingPlayers, so if we skipped them here the edge would never mark
			// them in HitPlayers, and the wave would retroactively shove them the instant they unharden
			// still inside its (monotonically growing) radius, long after the visible edge swept past.
			foreach ( var player in Stage.Players )
			{
				if ( player.IsDead || w.HitPlayers.Contains( player ) ) continue;
				Vector2 toPlayer = player.Position - w.Center;
				float d = toPlayer.Length;
				// The ring only ever reaches out to MaxRadius, so a player farther than that is never hit.
				// Its radius grows monotonically, so the first tick it passes the player's centre is the hit.
				if ( d <= w.MaxRadius && w.Radius >= d )
				{
					// IMMUNITY: if the player teleported/wrapped this tick and ARRIVED behind the edge (inside
					// the radius the ring had when they jumped — anyone not yet hit was outside it), the active
					// edge never swept them — they dodged it. Mark the wave hit (so it can't fire later) but
					// apply no force. Landing in the band the edge crosses this tick is a legitimate hit, as
					// is running inward into it — only a teleport grants the dodge.
					bool teleported = _lastTeleportSequences.TryGetValue( player, out int lastSequence )
						&& player.TeleportSequence != lastSequence;
					bool dodgedByTeleport = teleported && d <= prevRadius;

					// A hardened statue is immovable: the ring washes over it with no effect. Mark the wave
					// hit (so it can't shove them the moment they unharden inside the still-alive radius) but
					// apply no force or cue — the wave visibly passed straight through the statue.
					if ( !dodgedByTeleport && !player.IsHardened )
					{
						// Right on the origin → push straight up so it reads as a clean jump boost.
						Vector2 dir = d < 0.01f ? new Vector2( 0f, 1f ) : toPlayer;
						// Weaker if the ring catches the player in its dying moments (last TAIL_TIME of its life):
						// both the knockback force and the hit-stop taper toward TAIL_MIN_SCALE.
						float tail = Utils.Map( w.Lifetime - w.Age, 0f, TAIL_TIME, TAIL_MIN_SCALE, 1f, true, EasingType.Linear );
						player.ApplyShockwaveKnockback( dir, w.Strength * tail );
						// A player glued to a sticky block can't be moved (the goo zeroes the knockback):
						// skip the freeze so the sim doesn't punctuate a hit that visibly does nothing.
						// The bounce cue still plays — the wave is heard washing over them.
						if ( !player.IsStuck && HIT_STOP_FRAMES > 0 )
							Stage.RequestHitStop( (int)MathF.Round( HIT_STOP_FRAMES * tail ) );
						Audio.PlaySfx( SfxType.FireballBounce, player.Position, 0.85f, 1.1f );
					}

					w.HitPlayers.Add( player );
				}
			}

			// Impostors are player bodies too — the ring shoves them the same way, but with NO hit-stop (that's
			// the player's feel) and no teleport immunity (they don't teleport). Each is pushed once per wave.
			for ( int k = 0; k < Stage.Impostors.Count; k++ )
			{
				var imp = Stage.Impostors[k];
				if ( imp.IsDead || w.HitImpostors.Contains( imp ) ) continue;
				Vector2 toImp = imp.Position - w.Center;
				float di = toImp.Length;
				if ( di <= w.MaxRadius && w.Radius >= di )
				{
					Vector2 dir = di < 0.01f ? new Vector2( 0f, 1f ) : toImp;
					float tail = Utils.Map( w.Lifetime - w.Age, 0f, TAIL_TIME, TAIL_MIN_SCALE, 1f, true, EasingType.Linear );
					imp.ApplyShockwaveKnockback( dir, w.Strength * tail );
					w.HitImpostors.Add( imp );
				}
			}
		}

		if ( Stage is null ) return;
		// Track every alive player (hardened included) so the teleport-continuity check stays valid across
		// a harden window — matches the hit loop above, which now also sees hardened statues.
		foreach ( var player in Stage.Players )
			if ( !player.IsDead )
				_lastTeleportSequences[player] = player.TeleportSequence;
	}

	/// <summary>A death recovery re-placed <paramref name="player"/> elsewhere (Rewind echo / Swarm clone). The
	/// once-per-wave hit record belongs to the BODY, and the hit body stays behind as its death animation:
	/// forget it (or the recovered body, standing ahead of the edge, would be immune when the edge reaches
	/// it) and let the edge decide afresh at the new position — a landing behind it still dodges. A Swarm
	/// recovery passes the adopted clone as <paramref name="inheritFrom"/> so its own record carries over.</summary>
	public void ResetHitRecord( Player player, Player inheritFrom = null )
	{
		foreach ( var w in _waves )
		{
			w.HitPlayers.Remove( player );
			if ( inheritFrom is not null && w.HitImpostors.Contains( inheritFrom ) ) w.HitPlayers.Add( player );
		}
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Lay each live wave's ring as several concentric layers (glow + echoes + a white-hot core)
	/// of fixed-count polygons, with a rotating angular shimmer, then hide any unused pool segments.</summary>
	void RenderWaves()
	{
		int idx = 0;
		float baseZ = Globals.DepthToZ( RING_DEPTH, StageIndex );

		foreach ( var w in _waves )
		{
			float baseAlpha = RingAlpha( w.Age, w.Lifetime ) * RING_PEAK_ALPHA;
			if ( baseAlpha <= 0f || w.Radius <= 0.5f ) continue;

			Color phase = w.IsPhase2 ? PHASE2_COLOR : PHASE1_COLOR;
			// Leading edge is white-hot when fresh, cooling to the phase colour as the wave ages.
			float hot = 1f - Math.Clamp( w.Age / w.Lifetime, 0f, 1f );

			for ( int li = 0; li < RING_LAYERS.Length; li++ )
			{
				RingLayer layer = RING_LAYERS[li];
				float r = w.Radius + layer.Offset;
				if ( r <= 1f ) continue; // a trailing echo ring that hasn't emerged from the centre yet

				float chord = 2f * r * MathF.Sin( MathF.PI / SEGMENTS );
				float thick = RING_THICKNESS * layer.Thick;
				Color layerCol = Color.Lerp( phase, Color.White, layer.White * hot );
				float layerAlpha = baseAlpha * layer.Alpha;
				float z = baseZ + li * 0.0015f; // own Z slice so overlapping translucent layers don't z-fight

				for ( int s = 0; s < SEGMENTS; s++ )
				{
					float ang = (s / (float)SEGMENTS) * MathF.PI * 2f;
					// Rotating crackle: dip brightness in a travelling sinusoid around the ring.
					float shimmer = 1f - SHIMMER_DEPTH * (0.5f + 0.5f * MathF.Sin( ang * SHIMMER_FREQ - w.Age * SHIMMER_SPEED ));
					Vector2 onRing = w.Center + new Vector2( MathF.Cos( ang ), MathF.Sin( ang ) ) * r;
					Vector2 tangent = new Vector2( -MathF.Sin( ang ), MathF.Cos( ang ) ); // segment length axis
					PlaceSegment( idx++, onRing, tangent, chord, thick, layerCol.WithAlpha( layerAlpha * shimmer ), z );
				}
			}
		}

		for ( int k = idx; k < _segments.Count; k++ )
			_segments[k].Enabled = false;
	}

	/// <summary>Fade curve over a wave's life: a quick fade-in, held bright, then a fade-out over the tail
	/// so the ring is visible the whole time it's actually expanding and never pops in/out.</summary>
	static float RingAlpha( float age, float lifetime )
	{
		float t = age / lifetime;
		float fadeIn = Math.Clamp( t / 0.08f, 0f, 1f );
		float fadeOut = Math.Clamp( (1f - t) / 0.30f, 0f, 1f );
		return Math.Min( fadeIn, fadeOut );
	}

	/// <summary>Place one ring segment: a (length × width) rectangle centred at <paramref name="worldPos"/>
	/// with its long axis along <paramref name="dir"/> (the ring tangent). Mirrors the squid beam's
	/// rolled-quad setup, but on stage-level overlay sprites so the ring sits in world
	/// space independent of the block's own transform.</summary>
	void PlaceSegment( int idx, Vector2 worldPos, Vector2 dir, float length, float width, Color color, float z )
	{
		while ( idx >= _segments.Count )
		{
			var seg = Stage.CreateOverlaySprite();
			_segments.Add( seg );
		}

		var sr = _segments[idx];
		sr.Enabled = true;
		sr.Color = color;
		sr.Size = new Vector2( length, width );
		sr.GameObject.WorldRotation = SpriteLayer.FlatRotation( new Vector2( -dir.y, dir.x ) );
		sr.GameObject.WorldPosition = new Vector3( worldPos.x, worldPos.y, z );
	}
}