Entities/Abilities/HardenAbility.cs
namespace BlockParty;

/// <summary>Charges Twin2's temporary immovable obstacle form from held crouch (toward-feet) input.</summary>
public sealed class HardenAbility : PlayerAbility
{
	private const float HOLD_TIME = 0.55f;
	private const float HARDEN_TIME = 4f;
	private const float WARNING_TIME = 0.5f;
	private const float COOLDOWN_TIME = 9f;
	private const float CHARGE_PARTICLE_INTERVAL_MAX = 0.16f;
	private const float CHARGE_PARTICLE_INTERVAL_MIN = 0.035f;
	// Builds over the hold so the pad tells you how close the stone is; tops out under the slam pulse.
	private const float CHARGE_HAPTIC_MIN = 0.05f;
	private const float CHARGE_HAPTIC_MAX = 0.5f;
	private static readonly Color STONE_TINT = new( 0.18f, 0.21f, 0.23f );
	private static readonly Color STONE_ESSENCE_DARK = new( 0.42f, 0.45f, 0.47f, 0.7f );
	private static readonly Color STONE_ESSENCE_LIGHT = new( 0.7f, 0.73f, 0.74f, 0.7f );
	private static readonly Color COOLDOWN_EYE_TINT = new( 0.86f, 0.18f, 0.18f, 0.82f );

	private float _holdTimer;
	private float _hardenTimer;
	private float _cooldownTimer;
	private float _chargeParticleTimer;
	private TwinAbilityEyeTint _eyeTint;

	public override void OnSpawn( Player player )
	{
		var tintObject = new GameObject( true, "harden cooldown eye tint" );
		tintObject.SetParent( player.GameObject );
		tintObject.LocalPosition = Vector3.Zero;
		_eyeTint = tintObject.Components.Create<TwinAbilityEyeTint>();
		_eyeTint.Setup( player, adjustTwinOneWalk: false );
	}

	public override void PreTick( Player player, float dt )
	{
		if ( player.IsHardened )
		{
			_hardenTimer -= dt;
			if ( _hardenTimer <= 0f )
			{
				EndHarden( player, playEffects: true );
				return;
			}

			if ( _hardenTimer <= WARNING_TIME )
			{
				float sign = Rng.CosmeticInt( 0, 2 ) == 0 ? -1f : 1f;
				player.SetShakeOffset( new Vector2( sign * Rng.CosmeticFloat( 0.65f, 1f ), 0f ) );
			}
			else
				player.ClearShakeOffset();
			return;
		}

		if ( _cooldownTimer > 0f )
		{
			if ( _eyeTint is not null ) _eyeTint.Tint = COOLDOWN_EYE_TINT;
			ResetChargeVisuals( player );
			_cooldownTimer -= dt;
			if ( _cooldownTimer <= 0f )
			{
				_cooldownTimer = 0f;
				if ( _eyeTint is not null ) _eyeTint.Tint = Color.Transparent;
				PlayCooldownReadyEffects( player );
			}
			return;
		}

		if ( !player.CrouchHeld )
		{
			ResetChargeVisuals( player );
			return;
		}

		_holdTimer += dt;
		float chargeProgress = Math.Clamp( _holdTimer / HOLD_TIME, 0f, 1f );
		player.SetAbilityTint( STONE_TINT, chargeProgress );
		TickChargeParticles( player, dt, chargeProgress );
		if ( player.DrivesHaptics )
			Haptics.Sustain( HapticChannel.HardenCharge,
				Utils.Map( chargeProgress, 0f, 1f, CHARGE_HAPTIC_MIN, CHARGE_HAPTIC_MAX, true, EasingType.Linear ),
				0f, Haptics.TONE_HEAVY );
		if ( _holdTimer < HOLD_TIME ) return;

		// Hand the build-up straight over to the slam rather than letting it decay across it.
		Haptics.ReleaseSustain( HapticChannel.HardenCharge );
		ResetChargeVisuals( player );
		_hardenTimer = HARDEN_TIME;
		player.SetHardened( true );
		player.Shake( new Vector2( 0.9f, 0.45f ), 4 );
		Audio.PlaySfx( SfxType.SpikesAdd, player.Position, 0.6f, 0.8f );
		if ( player.DrivesHaptics ) Haptics.Pulse( 0.75f, 0.14f, 0f, Haptics.TONE_HEAVY );
		SpawnTransitionParticles( player, 6, 11f, 22f, 2, 4 );
		player.Stage.PlayerHardened( player );
	}

	public override void OnRemoved( Player player )
	{
		EndHarden( player, playEffects: false );
		_eyeTint?.GameObject?.Destroy();
		_eyeTint = null;
	}

	public override void OnDeath( Player player )
	{
		EndHarden( player, playEffects: false );
		if ( _eyeTint is not null ) _eyeTint.Tint = Color.Transparent;
	}

	private void EndHarden( Player player, bool playEffects )
	{
		ResetChargeVisuals( player );
		_hardenTimer = 0f;
		player.ClearShakeOffset();
		player.SetHardened( false );
		if ( !playEffects ) return;

		_cooldownTimer = COOLDOWN_TIME;
		if ( _eyeTint is not null ) _eyeTint.Tint = COOLDOWN_EYE_TINT;
		Audio.PlaySfx( SfxType.SpikesRetract, player.Position, 0.6f, 1.05f );
		SpawnTransitionParticles( player, 8, 16f, 30f, 3, 5 );
	}

	private void TickChargeParticles( Player player, float dt, float progress )
	{
		_chargeParticleTimer -= dt;
		if ( _chargeParticleTimer > 0f ) return;

		_chargeParticleTimer = Utils.Map( progress, 0f, 1f,
			CHARGE_PARTICLE_INTERVAL_MAX, CHARGE_PARTICLE_INTERVAL_MIN, true );
		float angle = Rng.CosmeticFloat( 0f, MathF.Tau );
		Vector2 outward = new( MathF.Cos( angle ), MathF.Sin( angle ) );
		float radius = Rng.CosmeticFloat( 9f, 14f );
		Vector2 position = player.Position + outward * radius;
		float speed = Utils.Map( progress, 0f, 1f, 24f, 42f, true );
		Color color = Color.Lerp( STONE_ESSENCE_DARK, STONE_ESSENCE_LIGHT, Rng.CosmeticValue() );
		player.Stage.AddColoredParticle( position, -outward * speed, 0.94f, color,
			Rng.CosmeticFloat( 0.2f, 0.3f ), progress > 0.7f ? 2 : 1,
			Globals.DEPTH_PARTICLE_1, translucent: true );
	}

	private void ResetChargeVisuals( Player player )
	{
		_holdTimer = 0f;
		_chargeParticleTimer = 0f;
		player.SetAbilityTint( null );
	}

	private static void PlayCooldownReadyEffects( Player player )
	{
		Audio.PlaySfx( SfxType.Glitter0, player.Position, 0.28f, 1.4f );
		for ( int i = 0; i < 8; i++ )
		{
			float angle = i * MathF.PI * 0.25f + MathF.PI * 0.125f;
			Vector2 direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
			ParticleKind kind = i % 2 == 0 ? ParticleKind.Glitter0 : ParticleKind.Glitter1;
			player.Stage.AddParticle( player.Position + direction * 5f, direction * 18f, 0.9f, 0f,
				kind, 0.24f, 3 );
		}
	}

	private static void SpawnTransitionParticles( Player player, int count, float horizontalSpeed,
		float verticalSpeed, int minSize, int maxSize )
	{
		for ( int i = 0; i < count; i++ )
		{
			Vector2 velocity = new Vector2(
				Rng.CosmeticFloat( -horizontalSpeed, horizontalSpeed ),
				Rng.CosmeticFloat( verticalSpeed * 0.35f, verticalSpeed ) );
			player.Stage.AddParticle( player.Position, velocity, 0.92f, Globals.GRAVITY_STR_DUST,
				ParticleKind.Dust, Rng.CosmeticFloat( 0.22f, 0.38f ), Rng.CosmeticInt( minSize, maxSize + 1 ) );
		}
	}
}