Entities/Blocks/BlockStraightLaser.cs
namespace BlockParty;

/// <summary>
/// A persistent cardinal laser emitter. Phase 1 chooses one face when the phase begins;
/// phase 2 permanently covers all four directions after one shared warning period.
/// </summary>
public sealed class BlockStraightLaser : Block
{
	sealed class BeamVisual
	{
		public Direction Direction;
		public Line Line;
		public readonly List<SpriteRenderer> Core = new();
		public readonly List<SpriteRenderer> Glow = new();
	}

	const float INDICATOR_TIME = 1.25f;
	const float LASER_LENGTH = 384f;
	const float CORE_INCREMENT = 4f;
	const float GLOW_INCREMENT = 8f;
	const int CORE_SEGMENT_COUNT = 60;
	const int GLOW_SEGMENT_COUNT = 30;
	const float WIDTH_STEP_TIME = 0.033f;
	const int WIDTH_CYCLE_LENGTH = 12;
	const float IMPACT_PARTICLE_INTERVAL = 0.06f;
	const float DEADLY_SFX_PITCH = 0.85f;

	// Draw below block bodies so the clipped endpoint cannot paint over the solid which stopped it.
	const float GLOW_LOCAL_Z = -0.35f;
	const float CORE_LOCAL_Z = -0.25f;

	static readonly int[] INDICATOR_WIDTHS = { 1, 1, 2, 3, 2, 1 };
	static readonly int[] LASER_WIDTHS = { 2, 4, 6, 4 };
	static readonly Color INDICATOR_COLOR = new( 0.18f, 0.18f, 0.18f );
	static readonly Color INDICATOR_PULSE_COLOR = new( 0.52f, 0.52f, 0.52f );
	static readonly Color CORE_COLOR = new( 0.82f, 1f, 0.32f );
	static readonly Color GLOW_COLOR = new( 0.08f, 0.82f, 0.42f );

	// Built at construction, NOT in CreateVisuals: lethality flows through these (_activeBeamCount,
	// beam.Line), so the sim must not depend on the visual-setup call having run (same reasoning as
	// BlockLaser's _beams array). CreateVisuals only fills the sprite pools inside them.
	readonly BeamVisual[] _beams = { new(), new(), new(), new() };
	readonly Random _renderRng = new();
	int _activeBeamCount;
	int _beamPhase = -1;
	float _indicatorTimer;
	float _impactParticleTimer;
	float _widthTimer;
	int _widthCounter;
	bool _dangerous;
	bool _intervalOff;

	/// <summary>While boxed in at phase 1+, the authored ability timings loop the laser on/off: eyes
	/// stay open (laser on) for the Interval, shut (laser off) for the ClosedTime, forever. Each
	/// re-open replays the full indicator warning, so the Interval should be authored longer than
	/// <see cref="INDICATOR_TIME"/> to get any deadly time. Non-enclosed lasers never enter the loop.</summary>
	protected override bool HasEnclosedEyeLoop => true;

	/// <summary>An enclosed laser's beams never re-aim (directions are fixed when the phase begins),
	/// so the loop's eye cycles keep the current gaze instead of swinging toward the player.</summary>
	protected override Direction GetEnclosedGazeDirection() => MoveDirection;

	public override void CreateVisuals()
	{
		base.CreateVisuals();
		foreach ( BeamVisual beam in _beams )
		{
			BuildPool( beam.Glow, GLOW_SEGMENT_COUNT );
			BuildPool( beam.Core, CORE_SEGMENT_COUNT );
		}
	}

	void BuildPool( List<SpriteRenderer> pool, int count )
	{
		for ( int i = 0; i < count; i++ )
		{
			SpriteRenderer sprite = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", Vector2.One );
			sprite.Enabled = false;
			pool.Add( sprite );
		}
	}

	public override void Tick( float dt )
	{
		base.Tick( dt );
		if ( IsDead ) return;

		SynchronizePhase();
		if ( _activeBeamCount == 0 || Phase == 0 ) return;

		// The enclosed loop's off-leg: the beams vanish (and can't kill) whenever the eyes are anything
		// but fully open. Beam directions stay armed in _beams, so the re-open resumes the same set.
		if ( EnclosedEyeLoopRunning && EyesOpenAmount < 1f )
		{
			if ( !_intervalOff )
			{
				_intervalOff = true;
				_dangerous = false;
				for ( int i = 0; i < _activeBeamCount; i++ )
				{
					HidePool( _beams[i].Core );
					HidePool( _beams[i].Glow );
				}
			}
			return;
		}
		if ( _intervalOff )
		{
			_intervalOff = false;
			_indicatorTimer = INDICATOR_TIME;
			_impactParticleTimer = 0f;
			Audio.PlaySfx( SfxType.LaserTracer, Position, 0.25f );
		}

		if ( !_dangerous )
		{
			_indicatorTimer -= dt;
			if ( _indicatorTimer <= 0f )
			{
				_dangerous = true;
				Audio.PlaySfx( SfxType.Laser, Position, 0.33f, DEADLY_SFX_PITCH );
			}
		}

		ComputeBeams();
		if ( _dangerous )
		{
			foreach ( Player player in Stage.LivingPlayers ) HitBody( player );
			foreach ( Player impostor in Stage.Impostors ) HitBody( impostor );
			TickImpactParticles( dt );
		}
		RenderBeams( dt );
	}

	protected override void NextPhase()
	{
		int previousPhase = Phase;
		base.NextPhase();
		if ( !IsDead && Phase != previousPhase ) SynchronizePhase();
	}

	void SynchronizePhase()
	{
		if ( _beamPhase == Phase ) return;
		_beamPhase = Phase;
		if ( Phase == 1 )
		{
			StartPhaseOneBeam();
		}
		else if ( Phase >= 2 )
		{
			StartBeamSet( Direction.Left, Direction.Right, Direction.Down, Direction.Up );
		}
		else
		{
			ClearBeams();
		}
	}

	void StartPhaseOneBeam()
	{
		Direction original = Globals.GetOppositeDirection( MoveDirection );
		if ( !IsFaceCenterBlocked( original ) )
		{
			StartBeamSet( original );
			return;
		}

		var openDirections = new List<Direction>( 3 );
		foreach ( Direction direction in Globals.GetAllDirections() )
			if ( direction != original && !IsFaceCenterBlocked( direction ) )
				openDirections.Add( direction );

		Direction selected = openDirections.Count == 0
			? original
			: openDirections[Rng.Int( 0, openDirections.Count )];
		StartBeamSet( selected );
	}

	bool IsFaceCenterBlocked( Direction direction )
	{
		Vector2 outward = Globals.GetVectorForDirection( direction );
		float faceDistance = direction is Direction.Left or Direction.Right ? Width * 0.5f : Height * 0.5f;
		Vector2 faceCenter = Position + outward * faceDistance;
		if ( faceCenter.x <= Arena.WALL_SIZE || faceCenter.x >= Arena.WIDTH - Arena.WALL_SIZE
			|| faceCenter.y <= Arena.WALL_SIZE || faceCenter.y >= Arena.HEIGHT - Arena.WALL_SIZE )
			return true;

		foreach ( Block block in Stage.GetBlocks() )
			if ( block != this && !block.PhasingIn && block.GetRect().Touches( faceCenter ) ) return true;
		foreach ( Entity2D obstacle in Stage.GetSolidObstacles() )
			if ( obstacle.GetRect().Touches( faceCenter ) ) return true;
		return false;
	}

	void StartBeamSet( params Direction[] directions )
	{
		ClearBeams();
		_activeBeamCount = Math.Min( directions.Length, _beams.Length );
		for ( int i = 0; i < _activeBeamCount; i++ )
			_beams[i].Direction = directions[i];
		_indicatorTimer = INDICATOR_TIME;
		_impactParticleTimer = 0f;
		_dangerous = false;
		Audio.PlaySfx( SfxType.LaserTracer, Position, 0.25f );
	}

	void ComputeBeams()
	{
		for ( int i = 0; i < _activeBeamCount; i++ )
			_beams[i].Line = ClipBeam( _beams[i].Direction );
	}

	Line ClipBeam( Direction beamDirection )
	{
		Vector2 direction = Globals.GetVectorForDirection( beamDirection );
		float faceDistance = beamDirection is Direction.Left or Direction.Right ? Width * 0.5f : Height * 0.5f;
		Vector2 start = Position + direction * faceDistance;
		var ray = new Line( start, start + direction * LASER_LENGTH );
		Vector2 end = ArenaEndpoint( start, beamDirection );
		float nearestDistanceSquared = (end - start).LengthSquared;

		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block == this || block.PhasingIn ) continue; // the beam passes straight through a phasing Teleport block
			if ( !ray.Intersects( block.GetRect(), out Vector2 point ) ) continue;
			UseNearest( point );
		}
		foreach ( Entity2D obstacle in Stage.GetSolidObstacles() )
		{
			if ( !ray.Intersects( obstacle.GetRect(), out Vector2 point ) ) continue;
			UseNearest( point );
		}

		return new Line( start, end );

		void UseNearest( Vector2 point )
		{
			float distanceSquared = (point - start).LengthSquared;
			if ( distanceSquared >= nearestDistanceSquared ) return;
			nearestDistanceSquared = distanceSquared;
			end = point;
		}
	}

	static Vector2 ArenaEndpoint( Vector2 start, Direction direction ) => direction switch
	{
		Direction.Left => new Vector2( 0f, start.y ),
		Direction.Right => new Vector2( Arena.WIDTH, start.y ),
		Direction.Down => new Vector2( start.x, 0f ),
		Direction.Up => new Vector2( start.x, Arena.HEIGHT ),
		_ => start,
	};

	void HitBody( Player body )
	{
		if ( body is null || body.IsDead ) return;
		for ( int i = 0; i < _activeBeamCount; i++ )
		{
			BeamVisual beam = _beams[i];
			if ( !beam.Line.Intersects( body.GetRect() ) ) continue;
			body.KilledByLaser( Globals.GetVectorForDirection( beam.Direction ) );
			return;
		}
	}

	void TickImpactParticles( float dt )
	{
		_impactParticleTimer -= dt;
		if ( _impactParticleTimer > 0f ) return;
		_impactParticleTimer += IMPACT_PARTICLE_INTERVAL;

		for ( int i = 0; i < _activeBeamCount; i++ )
		{
			BeamVisual beam = _beams[i];
			Vector2 direction = Globals.GetVectorForDirection( beam.Direction );
			Vector2 tangent = new( -direction.y, direction.x );
			float tangentSpeed = Rng.CosmeticValue() < 0.22f
				? Rng.CosmeticFloat( 65f, 110f ) * (Rng.CosmeticValue() < 0.5f ? -1f : 1f)
				: Rng.CosmeticFloat( -34f, 34f );
			Vector2 velocity = -direction * Rng.CosmeticFloat( 20f, 48f )
				+ tangent * tangentSpeed;
			Color color = Rng.CosmeticValue() < 0.35f ? CORE_COLOR : GLOW_COLOR;
			Stage.AddColoredParticle( beam.Line.B - direction * 0.5f, velocity,
				Rng.CosmeticFloat( 0.82f, 0.92f ), color, Rng.CosmeticFloat( 0.16f, 0.3f ),
				Rng.CosmeticInt( 1, 4 ), Globals.DEPTH_PARTICLE_1, translucent: true );
		}
	}

	void RenderBeams( float dt )
	{
		_widthTimer -= dt;
		if ( _widthTimer <= 0f )
		{
			_widthCounter = (_widthCounter - 1 + WIDTH_CYCLE_LENGTH) % WIDTH_CYCLE_LENGTH;
			_widthTimer = WIDTH_STEP_TIME;
		}

		for ( int i = 0; i < _activeBeamCount; i++ ) RenderBeam( _beams[i] );
	}

	void RenderBeam( BeamVisual beam )
	{
		float length = beam.Line.Length;
		Vector2 direction = length > 0.001f ? (beam.Line.B - beam.Line.A) / length : Vector2.Zero;
		if ( _dangerous )
		{
			PlaceLine( beam.Glow, beam.Line.A, direction, length, GLOW_INCREMENT,
				RenderFloat( 8f, 11f ), GLOW_COLOR, GLOW_LOCAL_Z );
		}
		else
		{
			HidePool( beam.Glow );
		}

		int[] widths = _dangerous ? LASER_WIDTHS : INDICATOR_WIDTHS;
		for ( int i = 0; i < beam.Core.Count; i++ )
		{
			float distance = (i + 0.5f) * CORE_INCREMENT;
			if ( distance - CORE_INCREMENT * 0.5f > length )
			{
				beam.Core[i].Enabled = false;
				continue;
			}
			int pulseIndex = (_widthCounter + i) % widths.Length;
			float width = widths[pulseIndex];
			Color color = _dangerous
				? CORE_COLOR
				: width >= 2f ? INDICATOR_PULSE_COLOR : INDICATOR_COLOR;
			PlaceSegment( beam.Core[i], beam.Line.A + direction * distance, direction,
				CORE_INCREMENT + 1f, width, color, CORE_LOCAL_Z );
		}
	}

	void PlaceLine( List<SpriteRenderer> pool, Vector2 start, Vector2 direction, float length,
		float increment, float width, Color color, float localZ )
	{
		for ( int i = 0; i < pool.Count; i++ )
		{
			float distance = (i + 0.5f) * increment;
			if ( distance - increment * 0.5f > length )
			{
				pool[i].Enabled = false;
				continue;
			}
			PlaceSegment( pool[i], start + direction * distance, direction,
				increment + 1f, width, color, localZ );
		}
	}

	void PlaceSegment( SpriteRenderer sprite, Vector2 worldPosition, Vector2 direction,
		float length, float width, Color color, float localZ )
	{
		sprite.Enabled = true;
		sprite.Color = color;
		sprite.Size = new Vector2( length, width );
		sprite.GameObject.LocalRotation = SpriteLayer.FlatRotation( new Vector2( -direction.y, direction.x ) );
		Vector2 localPosition = worldPosition - Pos;
		sprite.GameObject.LocalPosition = new Vector3( localPosition.x, localPosition.y, localZ );
	}

	float RenderFloat( float min, float max ) => min + (float)_renderRng.NextDouble() * (max - min);

	static void HidePool( List<SpriteRenderer> pool )
	{
		foreach ( SpriteRenderer sprite in pool ) sprite.Enabled = false;
	}

	void ClearBeams()
	{
		foreach ( BeamVisual beam in _beams )
		{
			HidePool( beam.Core );
			HidePool( beam.Glow );
		}
		_activeBeamCount = 0;
		_dangerous = false;
		_intervalOff = false;
	}

	public override void Die()
	{
		base.Die();
		ClearBeams();
	}
}