Entities/Blocks/BlockSlicer.cs
namespace BlockParty;

/// <summary>
/// Slicer block — on a hard SLAM it lays down a full-level "laser wall": a thin line that spans the
/// entire arena, oriented parallel to the face that slammed (i.e. perpendicular to its travel direction),
/// pinned at the block's flush contact position. Each line starts as a harmless grey
/// telegraph, then arms and becomes lethal at full strength for a few seconds, then powers down over a
/// short HARMLESS fade-out. Touching a LIVE line kills the player.
///
/// <list type="bullet">
/// <item>Phase 0: no lines (the block hasn't woken up).</item>
/// <item>Phase 1: block slams create one line on the contact face; arena-wall slams create none.</item>
/// <item>Phase 2: block and arena-wall slams create one line on the contact face.</item>
/// </list>
///
/// The lines sit BELOW the blocks (so blocks slide over them) but their kill band is a plain rect test
/// against the player, unaffected by draw order. Determinism: line positions come from the block's
/// flush impact rect, and their arm/expire timing + the kill test are pure functions of the fixed step
/// and player position; nothing reads Time.Delta — so replays reproduce them exactly. The render
/// (pulse/flicker) reads the accumulated age, which is itself deterministic.
///
/// This block draws from BOTH Rng streams, and the split is load-bearing:
/// <list type="bullet">
/// <item>AUTHORITATIVE (<see cref="AddLine"/>) — only the phase-2 lethal lifetime. That value decides how
/// long the kill band is live, so it IS simulation state and belongs on the seeded sim stream.</item>
/// <item>COSMETIC (<see cref="EmitSendParticles"/>) — every charge-particle property: where on the line it
/// lands, its spawn jitter, colour, size, lifetime and accel. None of it touches the kill band, the arm
/// timing or anything else the sim reads, so it must NOT advance the authoritative stream. The particle
/// COUNT and cadence are fixed constants driven off the same fixed step, so a given input-replay still
/// reproduces the visuals identically — the two streams just can't perturb each other.</item>
/// </list>
/// SendParticlesRemaining/SendParticleTimer live on the line for convenience but are write-only as far as
/// the sim is concerned: nothing outside the particle emitter ever reads them.
/// </summary>
public sealed class BlockSlicer : Block
{
	// --- timing -------------------------------------------------------------------------------
	// A grey warning line appears first (harmless), then the line arms and is lethal at FULL strength for
	// its whole deadly window, then powers down over a short HARMLESS fade-out.
	const float INDICATOR_TIME = 1.25f;  // grey telegraph before it arms
	const float DEADLY_TIME_P1 = 2.6f;   // phase-1 lethal window (fully lethal, full brightness)
	// Phase 2 lines last a bit longer, with a randomised lifetime. (Authoritative Rng: lifetime changes
	// how long the kill band is live, so it's simulation state — deterministic for replays.)
	const float DEADLY_TIME_P2_MIN = 3.2f;
	const float DEADLY_TIME_P2_MAX = 5.2f;
	// After the lethal window the line visibly powers down over this long — it is NOT deadly during the
	// fade (the fade telegraphs "switching off" so a dim, near-gone line can't be a hidden instakill).
	const float FADE_OUT_TIME = 0.45f;

	// --- visuals ------------------------------------------------------------------------------
	const float INDICATOR_MIN_THICKNESS = 3.0f;
	const float INDICATOR_MAX_THICKNESS = 5.0f;  // thickens as it's about to arm
	const float INDICATOR_WAVE_AMPLITUDE = 3.0f;
	const float INDICATOR_WAVELENGTH = 40.0f;
	const float INDICATOR_WAVE_SPEED = 10.0f;
	const float INDICATOR_SEGMENT_LENGTH = 8.0f;
	const float INDICATOR_FADE_IN_TIME = 0.5f;
	const int SEND_PARTICLE_COUNT = 26;
	const float SEND_PARTICLE_EMIT_TIME = 0.25f;
	const float SEND_PARTICLE_HORIZONTAL_JITTER = 8f;
	const float SEND_PARTICLE_MIN_HEIGHT = 4f;
	const float SEND_PARTICLE_MAX_HEIGHT = 12f;
	const float DEADLY_THICKNESS = 3.0f;         // the lethal core width (also the kill-band width)
	const float GLOW_THICKNESS = 12.0f;          // wide soft halo behind the core
	// How far to shove a wall-hugging line's centre toward the arena centre so it isn't half-hidden
	// behind the wall (moves the whole line off the ~2px wall band into open play).
	const float WALL_EDGE_NUDGE = 2.0f;
	// The non-core layers (halo + inner glow) breathe — their SIZE pulses (subtly + rapidly) so the beam
	// feels alive; the two layers pulse OUT OF SYNC (a phase offset) so they don't move as one.
	const float SIZE_PULSE_SPEED = 18.0f;
	const float HALO_PULSE_AMOUNT = 0.14f;       // ± fraction of the halo width
	const float GLOW_PULSE_AMOUNT = 0.11f;       // ± fraction of the inner-glow width
	const float GLOW_PULSE_PHASE = 2.1f;         // radians the glow layer lags the halo (off-sync)
	// A vibrant brightness/colour flash that pulses the whole live beam.
	const float COLOR_FLASH_SPEED = 16.0f;


	const float INDICATOR_PULSE_SPEED = 9.0f;    // grey line breathes so it reads as "charging"
	const float DEADLY_FLICKER_SPEED = 40.0f;    // fast crackle on the live beam
	const float ARM_FLASH_TIME = 0.15f;          // duration of the white-hot flash the instant it arms

	static readonly Color INDICATOR_COLOR = new( 0.70f, 0.73f, 0.78f ); // neutral grey warning
	// Phase 1 = hot red/orange laser; phase 2 = a distinct electric magenta/violet so the escalated
	// wall slam reads differently at a glance.
	static readonly Color DEADLY_CORE_COLOR_P1 = new( 1.0f, 0.12f, 0.10f ); // hot red core
	static readonly Color DEADLY_GLOW_COLOR_P1 = new( 1.0f, 0.38f, 0.14f ); // orange-red halo
	static readonly Color DEADLY_CORE_COLOR_P2 = new( 1.0f, 0.15f, 0.95f ); // hot magenta core
	static readonly Color DEADLY_GLOW_COLOR_P2 = new( 0.65f, 0.20f, 1.0f );  // violet halo

	// --- state --------------------------------------------------------------------------------
	sealed class LaserLine
	{
		public bool Vertical;   // true = vertical line (fixed X, spans full height); false = horizontal
		public float Coord;     // the fixed coordinate the line sits at (X if vertical, else Y)
		public float Age;
		public float DeadlyTime; // how long this line stays lethal after arming (randomised in phase 2)
		public bool Phase2;     // colour set to use (captured at spawn, so a later phase-up doesn't recolour it)
		public bool ArmedSfxPlayed;
		public int SendParticlesRemaining;
		public float SendParticleTimer;
		public Vector2 SendOrigin;
	}

	readonly List<LaserLine> _lines = new();
	// Pooled line quads (shared across all this block's live lines and indicator segments). Stage-level overlay
	// sprites so they sit in world space independent of the block's own transform.
	readonly List<SpriteRenderer> _lineSprites = new();

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

		UpdateLines( dt );
		RenderLines();
	}

	public override void DestroyStageVisuals()
	{
		base.DestroyStageVisuals();
		// The lines' kill test lives in this instance's Tick, so they die with it — drop them along with
		// their quads rather than leaving deadly-looking beams frozen on screen.
		_lines.Clear();
		DestroyOverlayPool( _lineSprites );
	}

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

		// Phase 1 activates only against another block. Phase 2 also activates against wall-like solids,
		// which arrive as a null block (LastImpactOther distinguishes arena walls from obstacles).
		// The block is snapped flush here, so GetRect() gives the exact contact edges.
		bool canSpawnLine = Phase >= 2 || (Phase == 1 && otherBlock is not null);
		if ( canSpawnLine && SlammedThisTick )
			SpawnLine();

		CloseEyesAndPickNewDirection();
	}

	void SpawnLine()
	{
		RectF r = GetRect();

		// The contact face is perpendicular to the direction of travel.
		AddLine( MoveDirection, r );

		// A telegraph sizzle as the warning line appears.
		Audio.PlaySfx( SfxType.LaserTracer, Position, 0.3f );
	}

	/// <summary>Add a full-level line at <paramref name="face"/> of the block's flush impact rect. A
	/// left/right face gives a vertical line (spanning the height); a top/bottom face gives a horizontal
	/// one — always oriented parallel to the face (perpendicular to the block's travel into it).</summary>
	void AddLine( Direction face, RectF r )
	{
		bool vertical = face is Direction.Left or Direction.Right;
		float coord = face switch
		{
			Direction.Left => r.Left,
			Direction.Right => r.Right,
			Direction.Up => r.Top,
			Direction.Down => r.Bottom,
			_ => 0f,
		};

		// If the line lands right along an arena wall (a slam into the wall), nudge its centre a few px
		// toward the arena centre — otherwise the thin line half-hides under/behind the wall and is hard
		// to see. (Shifts the kill band too, keeping it matched to what's drawn.)
		float span = vertical ? Arena.WIDTH : Arena.HEIGHT;
		if ( coord <= Arena.WALL_SIZE + 0.5f ) coord += WALL_EDGE_NUDGE;
		else if ( coord >= span - Arena.WALL_SIZE - 0.5f ) coord -= WALL_EDGE_NUDGE;

		bool phase2 = Phase >= 2;
		float deadly = phase2 ? Rng.Float( DEADLY_TIME_P2_MIN, DEADLY_TIME_P2_MAX ) : DEADLY_TIME_P1;
		_lines.Add( new LaserLine
		{
			Vertical = vertical,
			Coord = coord,
			Phase2 = phase2,
			DeadlyTime = deadly,
			SendParticlesRemaining = SEND_PARTICLE_COUNT,
			SendOrigin = Position,
		} );
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Age each line, arm/expire it, and while it's live kill the player if they touch its band.</summary>
	void UpdateLines( float dt )
	{
		for ( int i = _lines.Count - 1; i >= 0; i-- )
		{
			var line = _lines[i];
			line.Age += dt;
			EmitSendParticles( line, dt );
			if ( line.Age >= INDICATOR_TIME + line.DeadlyTime + FADE_OUT_TIME )
			{
				_lines.RemoveAt( i );
				continue;
			}

			// Lethal ONLY during the deadly window — not the leading grey telegraph nor the trailing
			// power-down fade (both harmless, so the visible fade can't hide a still-live line).
			bool deadly = line.Age >= INDICATOR_TIME && line.Age < INDICATOR_TIME + line.DeadlyTime;
			if ( deadly && !line.ArmedSfxPlayed )
			{
				Audio.PlaySfx( SfxType.Laser, Position, 0.33f );
				line.ArmedSfxPlayed = true;
			}

			// Don't kill during the win sequence (blocks die, but the player lingers) — only a live line
			// from an alive block is lethal.
			if ( deadly && !IsDead )
			{
				float half = DEADLY_THICKNESS * 0.5f;
				RectF band = line.Vertical
					? new RectF( line.Coord - half, 0f, line.Coord + half, Arena.HEIGHT )
					: new RectF( 0f, line.Coord - half, Arena.WIDTH, line.Coord + half );

				// A live line kills any player body it touches — the real player AND any Summoner impostors.
				if ( Stage is not null )
				{
					foreach ( var player in Stage.LivingPlayers ) KillInBand( player );
					foreach ( var imp in Stage.Impostors ) KillInBand( imp );
				}

				void KillInBand( Player body )
				{
					if ( body is null || body.IsDead || !band.Intersects( body.GetRect() ) ) return;
					// Spray blood away from the line (out along its normal, with a slight upward bias).
					Vector2 dir = line.Vertical
						? new Vector2( body.X >= line.Coord ? 1f : -1f, 0.2f )
						: new Vector2( 0.2f, body.Y >= line.Coord ? 1f : -1f );
					body.KilledByLaser( Utils.Normalized( dir ) );
				}
			}
		}
	}

	void EmitSendParticles( LaserLine line, float dt )
	{
		if ( line.SendParticlesRemaining <= 0 || Stage is null ) return;

		line.SendParticleTimer -= dt;
		float interval = SEND_PARTICLE_EMIT_TIME / SEND_PARTICLE_COUNT;
		while ( line.SendParticlesRemaining > 0 && line.SendParticleTimer <= 0f )
		{
			float along = Rng.CosmeticFloat( 0f, line.Vertical ? Arena.HEIGHT : Arena.WIDTH );
			Vector2 target = line.Vertical ? new Vector2( line.Coord, along ) : new Vector2( along, line.Coord );
			Vector2 start = line.SendOrigin + new Vector2(
				Rng.CosmeticFloat( -SEND_PARTICLE_HORIZONTAL_JITTER, SEND_PARTICLE_HORIZONTAL_JITTER ),
				Rng.CosmeticFloat( SEND_PARTICLE_MIN_HEIGHT, SEND_PARTICLE_MAX_HEIGHT ) );
			// Colour the stream like the beam it's charging — phase 1 red/orange, phase 2 magenta/violet —
			// reading line.Phase2 (captured at spawn) so a mid-charge phase-up can't recolour it, exactly
			// like the render below. These were AddSpikes0/1, which is the stream Spikey sends at a face
			// right before spikes graft there: the same motes can't mean two different incoming threats.
			// ONE draw either way, so the accent stays a flat 25% and the cosmetic stream is phase-agnostic.
			bool accent = Rng.CosmeticValue() < 0.25f;
			ParticleKind kind = line.Phase2
				? (accent ? ParticleKind.SlicerChargeDeep1 : ParticleKind.SlicerChargeDeep0)
				: (accent ? ParticleKind.SlicerCharge1 : ParticleKind.SlicerCharge0);

			TargetedParticle particle = Stage.AddTargetedParticle( start, Vector2.Zero, 0.9f, 0f, kind,
				Rng.CosmeticFloat( 1.25f, 1.55f ), Rng.CosmeticInt( 2, 5 ), target,
				Rng.CosmeticFloat( 4800f, 6000f ), null );
			particle.SettleAtTarget();
			line.SendParticlesRemaining--;
			line.SendParticleTimer += interval;
		}
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Draw each line: a breathing grey telegraph while charging, then an orange glow + hot red
	/// core (with a white-hot flash as it arms) while live, fading out at the end. Below the blocks.</summary>
	void RenderLines()
	{
		int idx = 0;
		float baseZ = Globals.DepthToZ( Globals.DEPTH_BLOCK - 1, StageIndex ); // below blocks, above background

		foreach ( var line in _lines )
		{
			if ( line.Age < INDICATOR_TIME )
			{
				// Charging: thick, animated grey wave so wall-edge indicators remain obvious.
				float p = line.Age / INDICATOR_TIME; // 0 → 1
				float thick = INDICATOR_MIN_THICKNESS + (INDICATOR_MAX_THICKNESS - INDICATOR_MIN_THICKNESS) * p;
				float pulse = 0.6f + 0.4f * (0.5f + 0.5f * MathF.Sin( line.Age * INDICATOR_PULSE_SPEED ));
				float fadeIn = Math.Clamp( line.Age / INDICATOR_FADE_IN_TIME, 0f, 1f );
				fadeIn *= fadeIn;
				float alpha = fadeIn * (0.35f + 0.45f * p) * pulse;
				idx = PlaceWavyIndicator( idx, line, thick, INDICATOR_COLOR.WithAlpha( alpha ), baseZ + 0.002f );
			}
			else
			{
				// Live: wide soft halo + bright glow + white-hot core. White flash at the moment it arms;
				// full strength for the whole lethal window, then a harmless power-down fade. Layered +
				// higher alpha so the live beam clearly pops off the field.
				float deadlyElapsed = line.Age - INDICATOR_TIME;
				bool inDeadly = deadlyElapsed < line.DeadlyTime;
				// Full brightness while lethal; fade to 0 over FADE_OUT_TIME once the lethal window ends.
				float fade = inDeadly ? 1f : Math.Clamp( 1f - (deadlyElapsed - line.DeadlyTime) / FADE_OUT_TIME, 0f, 1f );
				float hot = Math.Clamp( 1f - deadlyElapsed / ARM_FLASH_TIME, 0f, 1f ); // white flash as it arms
				float flicker = 0.9f + 0.1f * MathF.Sin( line.Age * DEADLY_FLICKER_SPEED );

				// Vibrant brightness flash pulsing the whole live beam (0 → 1), plus a size pulse for the
				// non-core layers (halo/glow breathe out of sync; the core stays a steady kill line).
				float flash = 0.5f + 0.5f * MathF.Sin( line.Age * COLOR_FLASH_SPEED );
				float haloScale = 1f + HALO_PULSE_AMOUNT * MathF.Sin( line.Age * SIZE_PULSE_SPEED );
				float glowScale = 1f + GLOW_PULSE_AMOUNT * MathF.Sin( line.Age * SIZE_PULSE_SPEED + GLOW_PULSE_PHASE );

				Color glow = line.Phase2 ? DEADLY_GLOW_COLOR_P2 : DEADLY_GLOW_COLOR_P1;
				Color core = line.Phase2 ? DEADLY_CORE_COLOR_P2 : DEADLY_CORE_COLOR_P1;

				// Wide faint outer halo — a soft bloom that makes the beam read from across the arena.
				idx = PlaceLine( idx, line, GLOW_THICKNESS * 2f * haloScale, glow.WithAlpha( (0.16f + 0.16f * flash) * fade * flicker ), baseZ );
				// Inner glow — flashes toward white for a vivid pulse.
				Color glowCol = Color.Lerp( glow, Color.White, hot * 0.5f + 0.35f * flash );
				idx = PlaceLine( idx, line, GLOW_THICKNESS * glowScale, glowCol.WithAlpha( (0.4f + 0.3f * flash) * fade * flicker ), baseZ + 0.002f );
				// Hot core — steady width (the kill band), but brightness flashes vibrantly toward white.
				Color coreCol = Color.Lerp( core, Color.White, Math.Clamp( 0.3f + 0.45f * hot + 0.35f * flash, 0f, 1f ) );
				idx = PlaceLine( idx, line, DEADLY_THICKNESS, coreCol.WithAlpha( fade ), baseZ + 0.004f );
			}
		}

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

	/// <summary>Draw the charging indicator as an animated polyline that settles onto the straight laser
	/// coordinate as it arms. At arena edges the wave bends only inward instead of behind the wall.</summary>
	int PlaceWavyIndicator( int idx, LaserLine line, float thickness, Color color, float z )
	{
		float length = line.Vertical ? Arena.HEIGHT : Arena.WIDTH;
		float crossSpan = line.Vertical ? Arena.WIDTH : Arena.HEIGHT;
		float waveProgress = Math.Clamp( 1f - line.Age / INDICATOR_TIME, 0f, 1f );
		float waveAmplitude = INDICATOR_WAVE_AMPLITUDE * waveProgress;
		bool atMinEdge = line.Coord <= Arena.WALL_SIZE + WALL_EDGE_NUDGE + 0.5f;
		bool atMaxEdge = line.Coord >= crossSpan - Arena.WALL_SIZE - WALL_EDGE_NUDGE - 0.5f;
		int segmentCount = (int)MathF.Ceiling( length / INDICATOR_SEGMENT_LENGTH );

		for ( int segment = 0; segment < segmentCount; segment++ )
		{
			float startAlong = segment * INDICATOR_SEGMENT_LENGTH;
			float endAlong = Math.Min( startAlong + INDICATOR_SEGMENT_LENGTH, length );
			Vector2 start = WavePoint( startAlong );
			Vector2 end = WavePoint( endAlong );
			Vector2 delta = end - start;
			float segmentLength = delta.Length;
			PlaceSegment( idx++, (start + end) * 0.5f, Utils.Normalized( delta ), segmentLength + 1f, thickness, color, z );
		}

		return idx;

		Vector2 WavePoint( float along )
		{
			float phase = along / INDICATOR_WAVELENGTH * MathF.PI * 2f - line.Age * INDICATOR_WAVE_SPEED;
			float offset = MathF.Sin( phase ) * waveAmplitude;
			float inwardWave = (offset + waveAmplitude) * 0.5f;
			if ( atMinEdge ) offset = inwardWave;
			else if ( atMaxEdge ) offset = -inwardWave;

			return line.Vertical
				? new Vector2( line.Coord + offset, along )
				: new Vector2( along, line.Coord + offset );
		}
	}

	/// <summary>Place one full-level line quad (a thin rect spanning the whole arena along the line axis).</summary>
	int PlaceLine( int idx, LaserLine line, float thickness, Color color, float z )
	{
		Vector2 direction = line.Vertical ? Vector2.Up : Vector2.Right;
		Vector2 centre = line.Vertical
			? new Vector2( line.Coord, Arena.HEIGHT * 0.5f )
			: new Vector2( Arena.WIDTH * 0.5f, line.Coord );
		float length = line.Vertical ? Arena.HEIGHT : Arena.WIDTH;
		PlaceSegment( idx, centre, direction, length, thickness, color, z );
		return idx + 1;
	}

	void PlaceSegment( int idx, Vector2 centre, Vector2 direction, float length, float thickness, Color color, float z )
	{
		while ( idx >= _lineSprites.Count )
		{
			var segment = Stage.CreateOverlaySprite();
			_lineSprites.Add( segment );
		}

		var sr = _lineSprites[idx];
		sr.Enabled = true;
		sr.Color = color;
		sr.Size = new Vector2( length, thickness );
		sr.GameObject.WorldRotation = SpriteLayer.FlatRotation( new Vector2( -direction.y, direction.x ) );
		sr.GameObject.WorldPosition = new Vector3( centre.x, centre.y, z );
	}
}