Entities/Abilities/TwinDashAbility.cs
namespace BlockParty;

/// <summary>Twin1's fixed-distance, committed directional dash.</summary>
public sealed class TwinDashAbility : PlayerAbility
{
	private const float TAP_RELEASE_TIME = 0.1f;
	private const float SECOND_TAP_TIME = 0.07f;
	private const float DIAGONAL_GRACE_TIME = 0.05f;
	private const float DASH_DISTANCE = 72f;
	private const float DASH_DURATION = 0.9f;
	private const float COOLDOWN_TIME = 6f;
	private const float DASH_SHAKE_STRENGTH = 5f;
	private const float DASH_HAPTIC_STRENGTH = 0.28f;
	private static readonly Color COOLDOWN_EYE_TINT = new( 0.86f, 0.18f, 0.18f, 0.82f );

	// Short diagonal grace: a cardinal second press waits up to ~3 frames for a perpendicular key so
	// diagonals don't need both keys on the same tick. Kept well under the Blinker's 0.1s because this
	// committed, fixed-distance move feels the latency on every cardinal dash.
	private readonly DirectionalDoubleTapGesture _dashGesture = new( TAP_RELEASE_TIME, SECOND_TAP_TIME, DIAGONAL_GRACE_TIME );
	private bool _active;
	private bool _movementBlocked;
	private float _dashTimer;
	private float _distanceRemaining;
	private float _cooldownTimer;
	private Vector2 _dashDirection;
	private TwinAbilityEyeTint _eyeTint;

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

	/// <summary>A mid-run character change (e.g. a Mimic form timing out) can remove the module while a
	/// dash is still flying through solids, so the removal must resolve the landing like a natural dash
	/// end — otherwise the player is silently left embedded in whatever the dash was inside. This runs
	/// BEFORE the new form's rules install, deliberately: a Mimic whose Twin1 form expires inside a block
	/// dies here under Twin1's crush rules. Squashing instead would strand it forever inside a block
	/// that never moves, ruining the run.</summary>
	public override void OnRemoved( Player player )
	{
		bool wasDashing = _active;
		CancelDash( player );
		if ( wasDashing ) player.FinishTwinDash();
		_eyeTint?.GameObject?.Destroy();
		_eyeTint = null;
	}

	public override void OnDeath( Player player )
	{
		CancelDash( player );
		if ( _eyeTint is not null ) _eyeTint.Tint = Color.Transparent;
	}

	public override void OnAnimationChanged( Player player, string animation )
	{
		_eyeTint?.SetAnimation( animation );
	}

	public override void PreTick( Player player, float dt )
	{
		if ( _active )
		{
			player.ConsumeDirectionalInputForTick();
			player.ClearMotion();
			_dashTimer += dt;
			ApplyDashShake( player );
			// Held for the flight and biased the way we're travelling. Asserted per dash tick, so a
			// cancel, a death or a mid-dash form change all end it without a matching stop call.
			if ( player.DrivesHaptics )
				Haptics.Sustain( HapticChannel.TwinDash, DASH_HAPTIC_STRENGTH, _dashDirection.x * 0.5f );
			if ( !_movementBlocked && _distanceRemaining > 0.001f )
			{
				float distance = Math.Min( _distanceRemaining, DASH_DISTANCE / DASH_DURATION * dt );
				bool moved = player.MoveByTwinDash( _dashDirection * distance );
				if ( moved ) _distanceRemaining -= distance;
				else _movementBlocked = true;
			}
			if ( _dashTimer >= DASH_DURATION ) EndDash( player );
			return;
		}

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

		if ( _dashGesture.TryTrigger( player, dt, out Vector2 direction )
			&& player.CanStartTwinDash( direction ) )
			StartDash( player, direction );
	}

	private void StartDash( Player player, Vector2 direction )
	{
		_active = true;
		_movementBlocked = false;
		_dashTimer = 0f;
		_distanceRemaining = DASH_DISTANCE;
		_dashDirection = Utils.Normalized( direction );
		player.SetTwinDashing( true );
		player.ConsumeDirectionalInputForTick();
		ApplyDashShake( player );
		Audio.PlaySfx( SfxType.TurnInvisible, player.Position, 0.42f, 1.45f );
		SpawnTransitionParticles( player, starting: true );
	}

	private void EndDash( Player player )
	{
		_active = false;
		_movementBlocked = false;
		_dashTimer = 0f;
		_distanceRemaining = 0f;
		_cooldownTimer = COOLDOWN_TIME;
		if ( _eyeTint is not null ) _eyeTint.Tint = COOLDOWN_EYE_TINT;
		player.ClearShakeOffset();
		player.FinishTwinDash();
		if ( player.IsDead ) return;
		player.StartScaleBounce();
		SpawnTransitionParticles( player, starting: false );
	}

	private void CancelDash( Player player )
	{
		_active = false;
		_movementBlocked = false;
		_dashTimer = 0f;
		_distanceRemaining = 0f;
		player.ClearShakeOffset();
		player.SetTwinDashing( false );
	}

	private void ApplyDashShake( Player player )
	{
		int frame = (int)MathF.Floor( _dashTimer * 60f ) & 3;
		Vector2 offset = frame switch
		{
			0 => new Vector2( 0.18f, 0.06f ),
			1 => new Vector2( -0.14f, -0.08f ),
			2 => new Vector2( 0.10f, -0.14f ),
			_ => new Vector2( -0.16f, 0.10f ),
		};
		player.SetShakeOffset( offset * DASH_SHAKE_STRENGTH );
	}

	private void ResetGesture()
	{
		_dashGesture.Reset();
	}

	private static void PlayReadyEffects( Player player )
	{
		Audio.PlaySfx( SfxType.TurnVisible, player.Position, 0.25f, 1.55f );
		player.StartScaleBounce();
		for ( int i = 0; i < 8; i++ )
		{
			float angle = MathF.PI * 2f * i / 8f;
			Vector2 direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
			ParticleKind kind = (i & 1) == 0 ? ParticleKind.RewindBurst0 : ParticleKind.RewindBurst1;
			player.Stage.AddParticle( player.Position + direction * 3f, direction * 30f, 0.88f, 0f,
				kind, 0.3f, 4 );
		}
	}

	private static void SpawnTransitionParticles( Player player, bool starting )
	{
		const int count = 8;
		for ( int i = 0; i < count; i++ )
		{
			float angle = MathF.PI * 2f * (i + (starting ? 0.5f : 0f)) / count;
			Vector2 direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
			ParticleKind kind = starting
				? ((i & 1) == 0 ? ParticleKind.SwapBurst0 : ParticleKind.SwapBurst1)
				: ((i & 1) == 0 ? ParticleKind.MirrorBurst0 : ParticleKind.MirrorBurst1);
			float speed = starting ? 24f : 38f;
			player.Stage.AddParticle( player.Position + direction * 2f, direction * speed, 0.9f, 0f,
				kind, starting ? 0.22f : 0.28f, starting ? 2 : 3 );
		}
	}
}