Entities/Abilities/SolarAbility.cs
using System.Collections.Generic;

namespace BlockParty;

/// <summary>Projects sunlight down from the arena ceiling and converts exposure into movement energy.</summary>
public sealed class SolarAbility : PlayerAbility
{
	internal const float AIR_JUMP_ENERGY_COST = 0.5f;
	const float CHARGE_PER_SECOND = 0.42f;
	const float DRAIN_PER_SECOND = 0.55f;
	const float EMPTY_MOVEMENT_FACTOR = 0.34f;
	const float FULL_MOVEMENT_FACTOR = 1.15f;
	const float EMPTY_JUMP_FACTOR = 0.72f;
	const float FULL_JUMP_FACTOR = 1.15f;
	const float EMPTY_AIR_CONTROL_FACTOR = 0.6f;
	// Strength at zero energy. Each ability reaches its normal authored strength at full energy.
	const float EMPTY_DASH_FACTOR = 0.35f;
	const float EMPTY_WALL_DIVE_FACTOR = 0.5f;
	const float EMPTY_AIR_JUMP_FACTOR = 0.8f;
	const float EMPTY_WALL_CLING_FACTOR = 0.25f;
	const float EMPTY_BOUNCE_FACTOR = 0.7f;
	const float EDGE_EPSILON = 0.05f;
	const float PARTICLE_INTERVAL = 0.12f;
	const float FULL_CHARGE_PARTICLE_INTERVAL = 0.09f;
	internal const float FULL_ENERGY_THRESHOLD = 0.999f;
	const float BLOCK_STUN_TIME = 2.2f;
	// Low, heavy hum while out of the light — the drain is what hurts, so it reads as a dull labouring
	// rumble rather than an alarm.
	const float SHADE_HAPTIC_STRENGTH = 0.1f;
	const float GLOW_PULSE_SPEED = 5f;
	const float ENERGY_BAR_EMPTY_PULSE_SPEED = 8.5f;
	const float ENERGY_BAR_FULL_PULSE_SPEED = 14f;
	const float ENERGY_BAR_WIDTH = 10f;
	const float ENERGY_BAR_INNER_WIDTH = 8f;
	const float ENERGY_BAR_HEIGHT_ABOVE_PLAYER = 4f;
	static readonly Color SUNLIGHT_OUTLINE_COLOR = new( 1f, 244f / 255f, 156f / 255f );
	static readonly Color ENERGY_BAR_BACK_COLOR = new( 0.12f, 0.16f, 0.18f );
	static readonly Color ENERGY_BAR_FILL_COLOR = new( 1f, 0.75f, 0.18f );
	static readonly Color ENERGY_BAR_SHADE_FILL_COLOR = new( 1f, 0.38f, 0.08f );
	static readonly Color ENERGY_BAR_LOW_FILL_COLOR = new( 0.72f, 0.22f, 0.06f );
	static readonly Color ENERGY_BAR_LOW_SHADE_FILL_COLOR = new( 0.58f, 0.12f, 0.04f );
	static readonly Color ROCKET_RED = new( 0.95f, 0.06f, 0.01f );
	static readonly Color ROCKET_ORANGE = new( 1f, 0.38f, 0.02f );
	static readonly Color ROCKET_YELLOW = new( 1f, 0.9f, 0.16f );
	static readonly Color SMOKE_DARK = new( 0.05f, 0.06f, 0.07f );
	static readonly Color SMOKE_LIGHT = new( 0.22f, 0.24f, 0.25f );

	readonly List<RectF> _beams = new();
	readonly List<SpriteRenderer> _overlay = new();
	PlayerSpriteOutline _glow;
	GameObject _energyBarBackRoot;
	GameObject _energyBarFillRoot;
	SpriteRenderer _energyBarBack;
	SpriteRenderer _energyBarFill;
	float _particleTimer;
	float _fullChargeParticleTimer;
	float _glowPhase;

	public override void OnSpawn( Player player )
	{
		player.Energy = Math.Clamp( player.Energy, 0f, 1f );
		UpdateSunlight( player );
		player.SolarInSunlight = IsLit( player.GetRect() );
		var glowObject = new GameObject( true, "solar sunlight outline" );
		glowObject.SetParent( player.GameObject );
		glowObject.LocalPosition = Vector3.Zero;
		_glow = glowObject.Components.Create<PlayerSpriteOutline>();
		_glow.Setup( player );
		UpdateGlow( player );
		CreateEnergyBar( player );
		UpdateEnergyBar( player );
	}

	public override void OnRemoved( Player player )
	{
		player.SolarMovementFactor = 1f;
		player.SolarJumpFactor = 1f;
		player.SolarAirControlFactor = 1f;
		player.SolarDashFactor = 1f;
		player.SolarWallDiveFactor = 1f;
		player.SolarAirJumpFactor = 1f;
		player.SolarWallClingFactor = 1f;
		player.SolarBounceFactor = 1f;
		player.SolarInSunlight = false;
		foreach ( var sprite in _overlay )
			GameStage.DestroyOverlaySprite( sprite );
		_overlay.Clear();
		_beams.Clear();
		_glow?.GameObject?.Destroy();
		_energyBarBackRoot?.Destroy();
		_energyBarFillRoot?.Destroy();
		_glow = null;
		_energyBarBackRoot = null;
		_energyBarFillRoot = null;
		_energyBarBack = null;
		_energyBarFill = null;
	}

	public override void OnDeath( Player player )
	{
		// The sun goes out with the robot: a dead player never ticks, so the beams would otherwise
		// freeze at their last shape while blocks keep moving through them until the run resets.
		foreach ( var sprite in _overlay )
			sprite.Enabled = false;
		_beams.Clear();
		if ( _glow is not null )
			_glow.Enabled = false;
		HideEnergyBar();
	}

	public override void PreTick( Player player, float dt )
	{
		UpdateSunlight( player );
		bool lit = IsLit( player.GetRect() );
		player.SolarInSunlight = lit;
		player.Energy = Math.Clamp( player.Energy + (lit ? CHARGE_PER_SECOND : -DRAIN_PER_SECOND) * dt, 0f, 1f );
		player.SolarMovementFactor = EMPTY_MOVEMENT_FACTOR
			+ (FULL_MOVEMENT_FACTOR - EMPTY_MOVEMENT_FACTOR) * player.Energy;
		player.SolarJumpFactor = EMPTY_JUMP_FACTOR
			+ (FULL_JUMP_FACTOR - EMPTY_JUMP_FACTOR) * player.Energy;
		player.SolarAirControlFactor = EMPTY_AIR_CONTROL_FACTOR
			+ (1f - EMPTY_AIR_CONTROL_FACTOR) * player.Energy;
		player.SolarDashFactor = EnergyFactor( EMPTY_DASH_FACTOR, player.Energy );
		player.SolarWallDiveFactor = EnergyFactor( EMPTY_WALL_DIVE_FACTOR, player.Energy );
		player.SolarAirJumpFactor = EMPTY_AIR_JUMP_FACTOR
			+ (FULL_JUMP_FACTOR - EMPTY_AIR_JUMP_FACTOR) * player.Energy;
		player.SolarWallClingFactor = EnergyFactor( EMPTY_WALL_CLING_FACTOR, player.Energy );
		player.SolarBounceFactor = EnergyFactor( EMPTY_BOUNCE_FACTOR, player.Energy );
		if ( !lit && player.DrivesHaptics )
			Haptics.Sustain( HapticChannel.SolarShade, SHADE_HAPTIC_STRENGTH, 0f, Haptics.TONE_HEAVY );
		_glowPhase += dt;
		UpdateGlow( player );
		TickFullChargeTrail( player, dt );

		bool changing = lit ? player.Energy < 0.999f : player.Energy > 0.001f;
		_particleTimer -= dt;
		if ( !changing )
		{
			// Pin at 0 while steady (fully charged in light / empty in dark) — letting the timer run
			// negative banks a backlog that the += interval below can't absorb, so the next transition
			// would emit a particle EVERY tick until the debt is repaid (a machine-gun spray).
			_particleTimer = Math.Max( _particleTimer, 0f );
		}
		else if ( _particleTimer <= 0f )
		{
			_particleTimer += PARTICLE_INTERVAL;
			EmitEnergyParticle( player, lit );
		}
	}

	public override void PostTick( Player player, float dt )
	{
		UpdateEnergyBar( player );
	}

	public override void OnBouncedOnBlock( Player player, Block block )
	{
		if ( player.Energy < FULL_ENERGY_THRESHOLD ) return;

		player.Energy = 0f;
		block.Stun( BLOCK_STUN_TIME );
		_fullChargeParticleTimer = 0f;
	}

	static float EnergyFactor( float emptyFactor, float energy )
	{
		return emptyFactor + (1f - emptyFactor) * energy;
	}

	internal static void EmitAirJumpParticles( Player player )
	{
		float gravitySign = player.GravityReversed ? -1f : 1f;
		Vector2 origin = new( player.X, player.Y - 5f * gravitySign );

		int rocketCount = Rng.CosmeticInt( 9, 14 );
		for ( int particleIndex = 0; particleIndex < rocketCount; particleIndex++ )
		{
			float speed = Rng.CosmeticFloat( 170f, 275f );
			Vector2 velocity = new(
				Rng.CosmeticFloat( -0.22f, 0.22f ) * speed,
				-gravitySign * speed
			);
			Vector2 position = origin + new Vector2(
				Rng.CosmeticFloat( -2f, 2f ),
				Rng.CosmeticFloat( -1f, 1f )
			);
			Particle rocket = player.Stage.AddPhysicsParticle( position, velocity,
				Rng.CosmeticFloat( 0.988f, 0.995f ), 0f, RocketColor(),
				Rng.CosmeticFloat( 0.75f, 1.15f ), Rng.CosmeticInt( 1, 4 ) );
			rocket.SetCollision( true, ParticleSolidResponse.Bounce, restitution: 0.4f );
		}

		int smokeCount = Rng.CosmeticInt( 5, 9 );
		for ( int particleIndex = 0; particleIndex < smokeCount; particleIndex++ )
		{
			float downwardSpeed = Rng.CosmeticFloat( 18f, 62f );
			var lateralSpread = Rng.CosmeticFloat( -1.5f, 1.5f );
			Vector2 velocity = new(
				lateralSpread * downwardSpeed,
				-gravitySign * downwardSpeed
			);
			Color color = Color.Lerp( SMOKE_DARK, SMOKE_LIGHT, Rng.CosmeticValue() );
			var riseSpeed = Rng.CosmeticFloat( 250f, 1250f );
			Particle smoke = player.Stage.AddPhysicsParticle( origin, velocity,
				Rng.CosmeticFloat( 0.95f, 0.97f ), gravitySign * riseSpeed, color,
				Rng.CosmeticFloat( 0.65f, 0.95f ), Rng.CosmeticInt( 3, 7 ) );
			smoke.SetCollision( false, ParticleSolidResponse.Ignore );
		}
	}

	static Color RocketColor()
	{
		float heat = Rng.CosmeticValue();
		return heat < 0.5f
			? Color.Lerp( ROCKET_RED, ROCKET_ORANGE, heat * 2f )
			: Color.Lerp( ROCKET_ORANGE, ROCKET_YELLOW, (heat - 0.5f) * 2f );
	}

	void UpdateGlow( Player player )
	{
		if ( _glow is null ) return;
		if ( !player.SolarInSunlight || player.IsDead )
		{
			_glow.Tint = Color.Transparent;
			return;
		}

		float pulse = 0.5f + 0.5f * MathF.Sin( _glowPhase * GLOW_PULSE_SPEED );
		float alpha = 0.28f + 0.14f * pulse;
		_glow.Tint = SUNLIGHT_OUTLINE_COLOR.WithAlpha( alpha );
	}

	void CreateEnergyBar( Player player )
	{
		_energyBarBack = CreateEnergyBarPart( player, "SolarEnergyBarBack",
			new Vector2( ENERGY_BAR_WIDTH, 3f ), out _energyBarBackRoot );
		_energyBarFill = CreateEnergyBarPart( player, "SolarEnergyBarFill",
			new Vector2( ENERGY_BAR_INNER_WIDTH, 1f ), out _energyBarFillRoot );
	}

	void UpdateEnergyBar( Player player )
	{
		if ( _energyBarBack is null || _energyBarFill is null ) return;
		bool visible = !player.IsDead;
		_energyBarBack.Enabled = visible;
		_energyBarFill.Enabled = visible;
		if ( !visible ) return;

		float gravitySign = player.GravityReversed ? -1f : 1f;
		float headEdge = player.GravityReversed ? player.Bottom : player.Top;
		float y = headEdge + gravitySign * ENERGY_BAR_HEIGHT_ABOVE_PLAYER;
		Vector2 backCenter = SpriteLayer.PixelAlignedCenter( new Vector2( player.X, y ), _energyBarBack.Size );
		_energyBarBackRoot.WorldPosition = new Vector3( backCenter.x, backCenter.y,
			Globals.DepthToZ( Globals.DEPTH_PARTICLE_1 ) );

		bool empty = player.Energy <= 0.001f;
		bool full = player.Energy >= FULL_ENERGY_THRESHOLD;
		float pulseSpeed = full
			? ENERGY_BAR_FULL_PULSE_SPEED
			: empty ? ENERGY_BAR_EMPTY_PULSE_SPEED : GLOW_PULSE_SPEED;
		float pulse = 0.5f + 0.5f * MathF.Sin( _glowPhase * pulseSpeed );
		if ( empty )
		{
			_energyBarBack.Color = ENERGY_BAR_BACK_COLOR;
			_energyBarFill.Size = new Vector2( ENERGY_BAR_INNER_WIDTH, 1f );
			_energyBarFillRoot.WorldPosition = new Vector3( backCenter.x, backCenter.y,
				Globals.DepthToZ( Globals.DEPTH_PARTICLE_1, childOrder: 1 ) );
			_energyBarFill.Color = new Color( pulse, 0f, 0f );
			return;
		}

		int fillWidth = Math.Clamp( (int)MathF.Ceiling( player.Energy * ENERGY_BAR_INNER_WIDTH ), 1, (int)ENERGY_BAR_INNER_WIDTH );
		float fillX = backCenter.x - ENERGY_BAR_INNER_WIDTH * 0.5f + fillWidth * 0.5f;
		_energyBarFill.Size = new Vector2( fillWidth, 1f );
		_energyBarFillRoot.WorldPosition = new Vector3( fillX, backCenter.y,
			Globals.DepthToZ( Globals.DEPTH_PARTICLE_1, childOrder: 1 ) );

		if ( full )
		{
			_energyBarBack.Color = new Color( 0.04f, 0.22f + 0.12f * pulse, 0.56f + 0.2f * pulse );
			_energyBarFill.Color = new Color( 0.35f + 0.65f * pulse, 0.62f + 0.38f * pulse, 1f );
		}
		else
		{
			_energyBarBack.Color = ENERGY_BAR_BACK_COLOR;
			_energyBarFill.Color = player.Energy < AIR_JUMP_ENERGY_COST
				? player.SolarInSunlight ? ENERGY_BAR_LOW_FILL_COLOR : ENERGY_BAR_LOW_SHADE_FILL_COLOR
				: player.SolarInSunlight ? ENERGY_BAR_FILL_COLOR : ENERGY_BAR_SHADE_FILL_COLOR;
		}
	}

	void HideEnergyBar()
	{
		if ( _energyBarBack is not null ) _energyBarBack.Enabled = false;
		if ( _energyBarFill is not null ) _energyBarFill.Enabled = false;
	}

	static SpriteRenderer CreateEnergyBarPart( Player player, string name, Vector2 size, out GameObject root )
	{
		root = player.Stage.CreateChild( name );
		return SpriteLayer.Add( root, "sprites/pixel.sprite", size, "idle" );
	}

	// Scratch for UpdateSunlight (rebuilt every tick; reused so the rebuild doesn't allocate).
	readonly List<float> _edges = new();

	void UpdateSunlight( Player player )
	{
		_beams.Clear();
		List<float> edges = _edges;
		edges.Clear();
		edges.Add( Arena.WALL_SIZE );
		edges.Add( Arena.WIDTH - Arena.WALL_SIZE );
		foreach ( var block in player.Stage.GetBlocks() )
			AddEdges( edges, block.GetRect() );
		foreach ( var obstacle in player.Stage.GetSolidObstacles() )
			AddEdges( edges, obstacle.GetRect() );
		edges.Sort();

		for ( int i = edges.Count - 1; i > 0; i-- )
			if ( MathF.Abs( edges[i] - edges[i - 1] ) <= EDGE_EPSILON )
				edges.RemoveAt( i );

		for ( int i = 0; i + 1 < edges.Count; i++ )
		{
			float left = edges[i];
			float right = edges[i + 1];
			if ( right - left <= EDGE_EPSILON ) continue;
			float sampleX = (left + right) * 0.5f;
			float sourceY = Arena.HEIGHT - Arena.WALL_SIZE;
			MoveSourceBelowFlushObstacles( player, sampleX, ref sourceY );
			float bottom = FindFirstBlockerTop( player, sampleX, sourceY );
			if ( sourceY - bottom <= EDGE_EPSILON ) continue;

			var beam = new RectF( left, bottom, right, sourceY );
			if ( _beams.Count > 0 )
			{
				var previous = _beams[^1];
				if ( MathF.Abs( previous.Right - beam.Left ) <= EDGE_EPSILON
					&& MathF.Abs( previous.Bottom - beam.Bottom ) <= EDGE_EPSILON
					&& MathF.Abs( previous.Top - beam.Top ) <= EDGE_EPSILON )
				{
					previous.Right = beam.Right;
					_beams[^1] = previous;
					continue;
				}
			}
			_beams.Add( beam );
		}

		Render( player.Stage );
	}

	static void AddEdges( List<float> edges, RectF rect )
	{
		float left = Math.Clamp( rect.Left, Arena.WALL_SIZE, Arena.WIDTH - Arena.WALL_SIZE );
		float right = Math.Clamp( rect.Right, Arena.WALL_SIZE, Arena.WIDTH - Arena.WALL_SIZE );
		if ( right - left > EDGE_EPSILON )
		{
			edges.Add( left );
			edges.Add( right );
		}
	}

	static void MoveSourceBelowFlushObstacles( Player player, float x, ref float sourceY )
	{
		bool moved;
		do
		{
			moved = false;
			foreach ( var obstacle in player.Stage.GetSolidObstacles() )
			{
				var rect = obstacle.GetRect();
				if ( x <= rect.Left || x >= rect.Right
					|| rect.Bottom >= sourceY
					|| rect.Top < sourceY - EDGE_EPSILON ) continue;
				sourceY = rect.Bottom;
				moved = true;
				break;
			}
		}
		while ( moved );
	}

	static float FindFirstBlockerTop( Player player, float x, float sourceY )
	{
		float top = Arena.WALL_SIZE;
		foreach ( var block in player.Stage.GetBlocks() )
		{
			if ( block.PhasingIn ) continue;
			ConsiderBlocker( block.GetRect(), x, sourceY, ref top );
		}
		foreach ( var obstacle in player.Stage.GetSolidObstacles() )
			ConsiderBlocker( obstacle.GetRect(), x, sourceY, ref top );
		return top;
	}

	static void ConsiderBlocker( RectF rect, float x, float sourceY, ref float top )
	{
		if ( x <= rect.Left || x >= rect.Right || rect.Bottom >= sourceY || rect.Top <= top ) return;
		top = rect.Top >= sourceY - EDGE_EPSILON ? sourceY : rect.Top;
	}

	bool IsLit( RectF playerRect )
	{
		foreach ( var beam in _beams )
			if ( beam.Intersects( playerRect ) )
				return true;
		return false;
	}

	void Render( GameStage stage )
	{
		while ( _overlay.Count < _beams.Count )
			_overlay.Add( stage.CreateOverlaySprite() );
		for ( int i = 0; i < _beams.Count; i++ )
		{
			var beam = _beams[i];
			var sprite = _overlay[i];
			float left = MathF.Round( beam.Left );
			float right = MathF.Round( beam.Right );
			float bottom = MathF.Round( beam.Bottom );
			float top = MathF.Round( beam.Top );
			sprite.Enabled = true;
			sprite.Size = new Vector2( right - left, top - bottom );
			sprite.Color = new Color( 1f, 0.86f, 0.28f, 0.22f );
			// childOrder -1 tucks the beams just below every block lane tint (those sit at
			// childOrder = StageIndex, which is never negative) — sharing the block scheme's
			// childOrder 0 exactly would z-fight the first block's overlay. A 0.01 step is enough
			// here: overlay quads are translucent + IsSorted (CPU Z-sort), so only an exact tie
			// fights; the 0.1 LAYER_Z_STEP rule is for the depth-buffered opaque layers.
			sprite.GameObject.WorldPosition = new Vector3(
				(left + right) * 0.5f,
				(bottom + top) * 0.5f,
				Globals.DepthToZ( Globals.DEPTH_LANE_OVERLAY, childOrder: -1 ) );
		}
		for ( int i = _beams.Count; i < _overlay.Count; i++ )
			_overlay[i].Enabled = false;
	}

	void EmitEnergyParticle( Player player, bool charging )
	{
		var offset = new Vector2(
			Rng.CosmeticFloat( -Player.COLLISION_SIZE.x * 0.5f, Player.COLLISION_SIZE.x * 0.5f ),
			Rng.CosmeticFloat( -Player.COLLISION_SIZE.y * 0.35f, Player.COLLISION_SIZE.y * 0.35f ) );
		var velocity = charging
			? new Vector2( Rng.CosmeticFloat( -3f, 3f ), Rng.CosmeticFloat( 8f, 14f ) )
			: new Vector2( Rng.CosmeticFloat( -3f, 3f ), Rng.CosmeticFloat( -14f, -8f ) );
		player.Stage.AddFieldParticle(
			player.Position + offset,
			velocity,
			0.96f,
			0f,
			charging ? ParticleKind.SolarCharge : ParticleKind.SolarDrain,
			0.35f,
			1 );
	}

	void TickFullChargeTrail( Player player, float dt )
	{
		if ( player.Energy < FULL_ENERGY_THRESHOLD )
		{
			_fullChargeParticleTimer = Math.Max( _fullChargeParticleTimer, 0f );
			return;
		}

		_fullChargeParticleTimer -= dt;
		if ( _fullChargeParticleTimer > 0f ) return;
		_fullChargeParticleTimer += FULL_CHARGE_PARTICLE_INTERVAL;

		Vector2 direction = player.Velocity.Length > 1f
			? Utils.Normalized( player.Velocity )
			: new Vector2( 0f, 1f );
		Vector2 offset = new(
			Rng.CosmeticFloat( -Player.COLLISION_SIZE.x * 0.35f, Player.COLLISION_SIZE.x * 0.35f ),
			Rng.CosmeticFloat( -Player.COLLISION_SIZE.y * 0.35f, Player.COLLISION_SIZE.y * 0.1f ) );
		Vector2 velocity = -direction * Rng.CosmeticFloat( 3f, 7f )
			+ new Vector2( Rng.CosmeticFloat( -2f, 2f ), Rng.CosmeticFloat( -1f, 3f ) );
		player.Stage.AddFieldParticle(
			player.Position - direction * 2f + offset,
			velocity,
			0.92f,
			0f,
			ParticleKind.SolarCharge,
			0.2f,
			1 );
	}
}