Entities/Blocks/BlockLaser.cs
namespace BlockParty;

/// <summary>
/// A player-targeted laser emitter. Its beams pass through blocks and stop only at authored
/// obstacles or arena walls. Phase 1 fires one beam; phase 2 fires a three-beam, 42-degree fan.
/// </summary>
public class BlockLaser : Block
{
	enum LaserState { Idle, Tracer, Laser }

	sealed class BeamVisual
	{
		public Line Line;
		public readonly List<SpriteRenderer> Core = new();
		public readonly List<SpriteRenderer> Glow = new();
		public readonly List<SpriteRenderer> Sparks = new();
	}

	const float TRACER_TIME = 2f;
	const float LASER_TIME = 5f;
	const float LASER_LENGTH = 384f;
	const float FAN_ANGLE = 42f;
	const float SFX_TRACER_TIME = 1.25f;
	const float SFX_LASER_TIME = 1f;

	const float BEAM_INCREMENT = 4f;
	const int NUM_CORE = 88;
	const float GLOW_INCREMENT = 8f;
	const int NUM_GLOW = 44;
	const int NUM_SPARK = 24;
	const float WIDTH_STEP_TIME = 0.033f;

	// The block root is at DEPTH_BLOCK (2). Bias the beam above block art but below players,
	// avoiding ties with integer depth planes used by particles and gameplay bodies.
	const float GLOW_LOCAL_Z = Globals.DEPTH_PLAYER - Globals.DEPTH_BLOCK - 0.35f;
	const float CORE_LOCAL_Z = GLOW_LOCAL_Z + SpriteLayer.LAYER_Z_STEP;
	const float SPARK_LOCAL_Z = CORE_LOCAL_Z + SpriteLayer.LAYER_Z_STEP;

	static readonly int[] LASER_WIDTHS = { 2, 4, 6, 8, 6, 4 };
	static readonly int[] TRACER_WIDTHS = { 1, 1, 2, 3, 2, 1 };
	static readonly float[] TRACER_PULSE_AMOUNTS = { 0f, 0.04f, 0.12f, 0.22f, 0.12f, 0.04f };
	static readonly Color TRACER_COLOR = new( 0.35f, 0.50f, 0.72f );
	static readonly Color CORE_COLOR = new( 0.15f, 1f, 1f );
	static readonly Color GLOW_COLOR = new( 0.55f, 0.25f, 1f );

	readonly BeamVisual[] _beams = { new(), new(), new() };
	readonly Random _renderRng = new();
	LaserState _state;
	protected override bool CanActivateTimedAbility => _state == LaserState.Idle;
	Vector2 _aimDirection;
	float _timer;
	float _sfxTimer;
	float _widthTimer;
	int _widthCounter;
	// A phase-up during an active volley must not materialize two deadly beams that had no tracer.
	int _activeBeamCount = 1;
	int _activeFiringPhase = 1;
	Color _activeTracerColor = TRACER_COLOR;
	Color _activeCoreColor = CORE_COLOR;
	Color _activeGlowColor = GLOW_COLOR;

	protected virtual Vector2 BeamOriginPosition => Position + new Vector2( 0f, -6f );

	protected virtual int BeamCountForPhase( int phase ) => phase >= 2 ? 3 : 1;
	protected virtual bool UsesShootingMouth => true;
	protected virtual string ShootingEyeAnimation( int phase, Vector2 aimDirection )
		=> $"eyes_shooting_{Globals.GetStringForDirection( ClosestCardinalDirection( aimDirection ) )}";

	static Direction ClosestCardinalDirection( Vector2 direction )
	{
		if ( direction.LengthSquared <= 0.0001f ) return Direction.Down;
		if ( MathF.Abs( direction.x ) > MathF.Abs( direction.y ) )
			return direction.x > 0f ? Direction.Right : Direction.Left;
		return direction.y > 0f ? Direction.Up : Direction.Down;
	}

	protected virtual Vector2 AimDirectionForPhase( int phase )
	{
		Vector2 target = Stage.ClosestTargetablePlayer( BeamOriginPosition )?.Position ?? Arena.Center;
		Vector2 towardTarget = target - BeamOriginPosition;
		return towardTarget.LengthSquared > 0.0001f
			? Utils.Normalized( towardTarget )
			: new Vector2( 0f, -1f );
	}

	protected virtual IEnumerable<Entity2D> BeamBlockers() => Stage.GetSolidObstacles();

	/// <summary>Surfaces a fresh aim won't fire into (its beam would die on the spot). Obstacles and walls
	/// both stop this beam; the piercing variant only cares about walls.</summary>
	protected virtual AimAvoid FreshAimAvoids => AimAvoid.WallsAndObstacles;
	protected virtual Color TracerColorForPhase( int phase ) => TRACER_COLOR;
	protected virtual Color CoreColorForPhase( int phase ) => CORE_COLOR;
	protected virtual Color GlowColorForPhase( int phase ) => GLOW_COLOR;
	protected virtual float BeamGlowLocalZ => GLOW_LOCAL_Z;
	protected virtual float BeamCoreLocalZ => CORE_LOCAL_Z;
	protected virtual float BeamSparkLocalZ => SPARK_LOCAL_Z;
	protected virtual float DeadlyCoreWidthScale => 1f;
	protected virtual float DeadlyGlowWidthScale => 1f;

	public override void CreateVisuals()
	{
		base.CreateVisuals();
		foreach ( BeamVisual beam in _beams )
		{
			BuildPool( beam.Glow, NUM_GLOW );
			BuildPool( beam.Core, NUM_CORE );
			BuildPool( beam.Sparks, NUM_SPARK );
		}
	}

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

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

		if ( _moveSpeed > 50f && Phase >= 1 )
			StartLaser();
		else
			CloseEyesAndPickNewDirection();
	}

	void StartLaser()
	{
		int firingPhase = Phase;
		_activeFiringPhase = firingPhase;
		_activeBeamCount = Math.Clamp( BeamCountForPhase( firingPhase ), 1, _beams.Length );
		_aimDirection = DeflectAimOffSurfaces( BeamOriginPosition, AimDirectionForPhase( firingPhase ), FreshAimAvoids );
		_activeTracerColor = TracerColorForPhase( firingPhase );
		_activeCoreColor = CoreColorForPhase( firingPhase );
		_activeGlowColor = GlowColorForPhase( firingPhase );
		_state = LaserState.Tracer;
		_timer = TRACER_TIME;
		_sfxTimer = 0f;
		// Every volley starts its pulse animation from the same phase (cosmetic — the width cycle
		// would otherwise resume wherever the previous volley left off).
		_widthTimer = 0f;
		_widthCounter = 0;
		SuppressEyeBlink = true;
		_eyes.PlayAnimation( ShootingEyeAnimation( firingPhase, _aimDirection ) );
		if ( UsesShootingMouth )
			_mouth.PlayAnimation( $"mouth_{firingPhase}_shoot_hold" );
	}

	public override void Tick( float dt )
	{
		base.Tick( dt );
		if ( IsDead || _state == LaserState.Idle ) return;

		ComputeBeams();
		if ( _state == LaserState.Laser )
		{
			AddShake( Utils.RandomVector() );
			foreach ( Player player in Stage.LivingPlayers ) HitBody( player );
			foreach ( Player impostor in Stage.Impostors ) HitBody( impostor );
		}

		_sfxTimer -= dt;
		if ( _sfxTimer <= 0f )
		{
			if ( _state == LaserState.Tracer )
			{
				Audio.PlaySfx( SfxType.LaserTracer, Position, 0.25f );
				_sfxTimer = SFX_TRACER_TIME;
			}
			else
			{
				Audio.PlaySfx( SfxType.Laser, Position, 0.33f );
				_sfxTimer = SFX_LASER_TIME;
			}
		}

		_timer -= dt;
		if ( _timer <= 0f )
		{
			if ( _state == LaserState.Tracer )
			{
				_state = LaserState.Laser;
				_timer = LASER_TIME;
				_sfxTimer = 0f;
				if ( UsesShootingMouth )
					_mouth.PlayAnimation( $"mouth_{_activeFiringPhase}_shoot" );
			}
			else
			{
				StopLaser();
				return;
			}
		}

		RenderBeams( dt );
	}

	void HitBody( Player body )
	{
		if ( body is null || body.IsDead ) return;
		for ( int i = 0; i < _activeBeamCount; i++ )
		{
			Line line = _beams[i].Line;
			if ( !line.Intersects( body.GetRect() ) ) continue;
			body.KilledByLaser( Utils.Normalized( body.Position - line.A ) );
			return;
		}
	}

	void ComputeBeams()
	{
		int count = _activeBeamCount;
		for ( int i = 0; i < count; i++ )
		{
			float angle = count == 1 ? 0f : -FAN_ANGLE * 0.5f + FAN_ANGLE * i / (count - 1);
			Vector2 direction = Utils.Normalized( Utils.RotateVector( _aimDirection, angle ) );
			_beams[i].Line = ClipBeam( direction );
		}
	}

	Line ClipBeam( Vector2 direction )
	{
		Vector2 start = BeamOriginPosition;
		var ray = new Line( start, start + direction * LASER_LENGTH );
		float nearestDistanceSquared = float.MaxValue;
		Vector2 end = Vector2.Zero;
		bool hit = false;

		foreach ( Entity2D obstacle in BeamBlockers() )
		{
			if ( !ray.Intersects( obstacle.GetRect(), out Vector2 point ) ) continue;
			float distanceSquared = (point - start).LengthSquared;
			if ( distanceSquared >= nearestDistanceSquared ) continue;
			nearestDistanceSquared = distanceSquared;
			end = point;
			hit = true;
		}

		if ( !hit )
		{
			float width = Arena.WIDTH;
			float height = Arena.HEIGHT;
			if ( ray.Intersects( new Line( Vector2.Zero, new Vector2( width, 0f ) ), out Vector2 point ) ) end = point;
			else if ( ray.Intersects( new Line( Vector2.Zero, new Vector2( 0f, height ) ), out point ) ) end = point;
			else if ( ray.Intersects( new Line( new Vector2( width, 0f ), new Vector2( width, height ) ), out point ) ) end = point;
			else if ( ray.Intersects( new Line( new Vector2( 0f, height ), new Vector2( width, height ) ), out point ) ) end = point;
			else end = ray.B;
		}

		return new Line( start, end );
	}

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

		for ( int i = 0; i < _beams.Length; i++ )
		{
			if ( i < _activeBeamCount ) RenderBeam( _beams[i] );
			else HideBeam( _beams[i] );
		}
	}

	void RenderBeam( BeamVisual beam )
	{
		bool deadly = _state == LaserState.Laser;
		float length = beam.Line.Length;
		Vector2 direction = length > 0.001f ? (beam.Line.B - beam.Line.A) / length : Vector2.Zero;

		if ( deadly )
		{
			float glowWidth = RenderFloat( 9f, 13f ) * DeadlyGlowWidthScale;
			PlaceLine( beam.Glow, beam.Line.A, direction, length, GLOW_INCREMENT, glowWidth, _activeGlowColor, BeamGlowLocalZ );
			DrawSparks( beam, beam.Line.A, 0 );
			DrawSparks( beam, beam.Line.B, NUM_SPARK / 2 );
		}
		else
		{
			HidePool( beam.Glow );
			HidePool( beam.Sparks );
		}

		int[] widths = deadly ? LASER_WIDTHS : TRACER_WIDTHS;
		for ( int i = 0; i < beam.Core.Count; i++ )
		{
			float distance = (i + 0.5f) * BEAM_INCREMENT;
			if ( distance - BEAM_INCREMENT * 0.5f > length )
			{
				beam.Core[i].Enabled = false;
				continue;
			}
			int pulseIndex = (_widthCounter + i) % widths.Length;
			float width = widths[pulseIndex];
			Color color = deadly
				? _activeCoreColor
				: Color.Lerp( _activeTracerColor, Color.White, TRACER_PULSE_AMOUNTS[pulseIndex] );
			if ( deadly ) width *= DeadlyCoreWidthScale;
			PlaceSegment( beam.Core[i], beam.Line.A + direction * distance, direction, BEAM_INCREMENT + 1f, width, color, BeamCoreLocalZ );
		}
	}

	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 DrawSparks( BeamVisual beam, Vector2 origin, int offset )
	{
		for ( int i = 0; i < 6; i++ )
		{
			Vector2 direction = RenderUnitVector();
			float length = RenderFloat( 4f, 12f );
			PlaceSegment( beam.Sparks[offset + i], origin + direction * (length * 0.5f), direction, length, RenderFloat( 2f, 3.5f ), _activeGlowColor, BeamSparkLocalZ );
		}
		for ( int i = 0; i < 6; i++ )
		{
			Vector2 direction = RenderUnitVector();
			float length = RenderFloat( 2f, 8f );
			PlaceSegment( beam.Sparks[offset + 6 + i], origin + direction * (length * 0.5f), direction, length, RenderFloat( 1f, 2f ), _activeCoreColor, BeamSparkLocalZ );
		}
	}

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

	Vector2 RenderUnitVector()
	{
		float angle = RenderFloat( 0f, MathF.PI * 2f );
		return new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
	}

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

	static void HideBeam( BeamVisual beam )
	{
		HidePool( beam.Core );
		HidePool( beam.Glow );
		HidePool( beam.Sparks );
	}

	void StopLaser()
	{
		_state = LaserState.Idle;
		SuppressEyeBlink = false;
		foreach ( BeamVisual beam in _beams ) HideBeam( beam );
		RestoreFaceAndMouth();
		CloseEyesAndPickNewDirection();
	}

	public override void Die()
	{
		base.Die();
		_state = LaserState.Idle;
		SuppressEyeBlink = false;
		foreach ( BeamVisual beam in _beams ) HideBeam( beam );
	}
}