Entities/Abilities/SwapPortalAbility.cs
using System;

namespace BlockParty;

/// <summary>
/// The Swapper character's signature move. On spawn it drops a persistent <see cref="Portal"/> at the
/// player's start position. Whenever the player presses ALL FOUR directional inputs within a short
/// leniency window (each of Left/Right/Up/Down has its "just pressed" edge set within the last
/// <see cref="SWAP_INPUT_WINDOW_FRAMES"/> ticks — they needn't all land on the exact same frame), the
/// player and the portal trade places: the player teleports to where the portal was, and the portal
/// moves to where the player was.
///
/// Double-tapping a cardinal or diagonal direction fires a slower magical projectile using Gunner's
/// chord rules. Its first block, obstacle, or arena-wall impact moves the portal there, separating it
/// from obstacle and wall faces while leaving any velocity stored for the next swap unchanged.
/// Shooting and swapping share one cooldown. A live-frame blue outline around the player eases in as
/// the cooldown recharges, then becomes fully opaque and pulses once either action is ready again.
///
/// A swap fires a hit-stop whose length scales with how long it's been since the LAST swap — a swap
/// after a long gap gets the full freeze for weight, while rapid back-to-back swaps get little or no
/// freeze so spamming doesn't stutter the game to a crawl.
///
/// After the teleport, the landing is resolved by <see cref="Player.ResolveTeleportPenetration"/>: landing
/// with the hitbox FULLY inside a solid (block or interior obstacle) is an instant death, while a partial
/// overlap ejects the player out the nearest face with a boost proportional to how deep they were — so a
/// precise swap out of a solid's edge is a rewarding launch, but swapping straight into one is fatal, and
/// so is materializing in spikes (a deadly exit face kills instead of launching).
///
/// Deterministic / replay-safe: reads only the recorded <see cref="InputState"/> edges + the player's
/// fixed-step state, advances its timer on the tick <c>dt</c>, and draws only from the cosmetic RNG
/// stream for the endpoint bursts. The portal's position is sim state that changes only on these
/// (recorded) swaps, so a replay reproduces it exactly.
/// </summary>
public sealed class SwapPortalAbility : PlayerAbility
{
	/// <summary>Whether swaps exchange motion with the portal instead of clearing it.</summary>
	public bool EnableStoredVelocity { get; set; } = false;
	/// <summary>Multiplier applied to the velocity bank when it is released by a swap.</summary>
	public float StoredVelocityScale { get; set; } = 2.1f;
	/// <summary>Whether the portal displays the direction and strength of its stored velocity.</summary>
	public bool ShowStoredVelocityPointer { get; set; } = true;
	/// <summary>Pointer offset from the portal at the weakest visible stored velocity, in game pixels.</summary>
	public float StoredVelocityPointerMinOffset { get; set; } = 1.5f;
	/// <summary>Pointer offset from the portal at full stored-velocity magnitude, in game pixels.</summary>
	public float StoredVelocityPointerOffset { get; set; } = 4f;
	/// <summary>First endpoint of the portal pointer's color pulse.</summary>
	public Color StoredVelocityPointerColorA { get; set; } = new( 0.38f, 0.86f, 0.95f );
	/// <summary>Second endpoint of the portal pointer's color pulse.</summary>
	public Color StoredVelocityPointerColorB { get; set; } = new( 0.28f, 0.62f, 0.9f );
	/// <summary>Pointer opacity at the weakest visible stored velocity.</summary>
	public float StoredVelocityPointerMinOpacity { get; set; } = 0.2f;
	/// <summary>Pointer opacity at full stored-velocity magnitude.</summary>
	public float StoredVelocityPointerMaxOpacity { get; set; } = 0.95f;
	/// <summary>Exponent shaping how quickly pointer opacity approaches its maximum as velocity grows.</summary>
	public float StoredVelocityPointerOpacityPower { get; set; } = 1.8f;
	/// <summary>Pointer color-pulse frequency in cycles per second.</summary>
	public float StoredVelocityPointerPulseSpeed { get; set; } = 0.45f;
	/// <summary>Forward/back pointer travel at the weakest visible stored velocity, in game pixels.</summary>
	public float StoredVelocityPointerMinOscillationDistance { get; set; } = 0.25f;
	/// <summary>Forward/back pointer travel at full stored-velocity magnitude, in game pixels.</summary>
	public float StoredVelocityPointerMaxOscillationDistance { get; set; } = 0.8f;
	/// <summary>Pointer oscillation frequency at the weakest visible stored velocity, in cycles per second.</summary>
	public float StoredVelocityPointerMinOscillationSpeed { get; set; } = 0.45f;
	/// <summary>Pointer oscillation frequency at full stored-velocity magnitude, in cycles per second.</summary>
	public float StoredVelocityPointerMaxOscillationSpeed { get; set; } = 0.9f;
	/// <summary>Projectile travel speed in game pixels per second.</summary>
	public float ProjectileSpeed { get; set; } = 180f;
	/// <summary>Hit-stop frames requested immediately after a projectile is fired.</summary>
	public int ProjectileFireHitStopFrames { get; set; } = 2;
	/// <summary>Hit-stop frames requested when a projectile relocates the portal.</summary>
	public int ProjectileImpactHitStopFrames { get; set; } = 4;
	/// <summary>Extra clearance between a relocated portal and the wall or obstacle it struck.</summary>
	public float ProjectilePortalSurfacePadding { get; set; } = 0f;
	/// <summary>Shared lockout after either swapping or firing, in fixed simulation seconds.</summary>
	public float CooldownDuration { get; set; } = 2.1f;
	/// <summary>Whether the live player sprite is surrounded by the cooldown-status outline.</summary>
	public bool ShowCooldownOutline { get; set; } = true;
	/// <summary>Tint used by the cooldown-status outline.</summary>
	public Color CooldownOutlineColor { get; set; } = new( 0.28f, 0.7f, 1f );
	/// <summary>Opacity approached during recharge before readiness snaps to the ready pulse.</summary>
	public float CooldownOutlineRechargeOpacity { get; set; } = 0.5f;
	/// <summary>Lowest opacity used while the fully charged outline pulses.</summary>
	public float CooldownOutlineReadyMinOpacity { get; set; } = 0.8f;
	/// <summary>Opacity used at the ready transition and at the top of the fully charged pulse.</summary>
	public float CooldownOutlineReadyMaxOpacity { get; set; } = 1f;
	/// <summary>Fully charged outline-pulse frequency in cycles per second.</summary>
	public float CooldownOutlinePulseSpeed { get; set; } = 1.5f;
	/// <summary>Number of inward-moving particles emitted when the shared cooldown becomes ready.</summary>
	public int CooldownReadyParticleCount { get; set; } = 8;
	/// <summary>Inward speed of cooldown-ready particles in game pixels per second.</summary>
	public float CooldownReadyParticleSpeed { get; set; } = 22f;
	/// <summary>Lifetime of cooldown-ready particles in seconds.</summary>
	public float CooldownReadyParticleLifetime { get; set; } = 0.28f;
	/// <summary>Volume of the sound played when the shared cooldown becomes ready.</summary>
	public float CooldownReadySfxVolume { get; set; } = 0.35f;
	/// <summary>Pitch of the sound played when the shared cooldown becomes ready.</summary>
	public float CooldownReadySfxPitch { get; set; } = 1.5f;
	/// <summary>Number of outline fragments shed when a projectile consumes readiness.</summary>
	public int ProjectileOutlineDisperseParticleCount { get; set; } = 12;
	/// <summary>Outward speed of projectile outline fragments in game pixels per second.</summary>
	public float ProjectileOutlineDisperseSpeed { get; set; } = 42f;
	/// <summary>Lifetime of projectile outline fragments in seconds.</summary>
	public float ProjectileOutlineDisperseLifetime { get; set; } = 0.3f;
	private static bool _traceEnabled;

	[ConCmd( "swapper_trace" )]
	private static void ToggleTrace()
	{
		if ( !Game.IsEditor ) return;
		_traceEnabled = !_traceEnabled;
		Log.Info( $"[swapper] trace {(_traceEnabled ? "ON" : "OFF")}" );
	}

	// Hit-stop scaling. framesSinceSwap 0 -> the swap was this/last tick -> ~no freeze; at or beyond
	// FULL_RECOVER_FRAMES -> the full MAX_HITSTOP_FRAMES freeze. A few frames apart -> a short freeze.
	private const int MAX_HITSTOP_FRAMES = 8;
	private const int FULL_RECOVER_FRAMES = 40; // ~0.67s at 60fps to recover the full hit-stop
	private const int HOVER_TRAIL_INTERVAL_FRAMES = 3;
	private const float HOVER_HAPTIC_STRENGTH = 0.12f;

	// Leniency window (in fixed ticks) for the all-inputs trigger: the swap fires once every one of the
	// four directions has had a "just pressed" edge within the last this-many ticks — so they don't
	// have to all land on the exact same frame. 1 == must be the same frame; higher == more forgiving.
	private const int SWAP_INPUT_WINDOW_FRAMES = 4;

	// Endpoint burst: a two-tone spray of cyan particles at BOTH ends of the swap.
	private const int BURST_COUNT_MAIN = 10;   // SwapBurst0 (cyan)
	private const int BURST_COUNT_HIGHLIGHT = 5; // SwapBurst1 (lighter cyan), layered on top
	private const float PROJECTILE_MUZZLE_OFFSET = 8f;

	private Portal _portal;
	private int _framesSinceSwap = int.MaxValue / 2; // start "long ago" so the first swap gets full hit-stop

	// Ticks since each direction was last "just pressed" (0 = this tick). Start "long ago" so a fresh
	// game doesn't fire until the player actually presses. Reset back to "long ago" after each swap so a
	// new swap needs four fresh presses rather than re-firing off the same ones.
	private const int NEVER = int.MaxValue / 2;
	private int _sinceLeft = NEVER, _sinceRight = NEVER, _sinceUp = NEVER, _sinceDown = NEVER;
	private bool _swapPending;
	private Vector2 _storedVelocity;
	private int _swapCount;
	private readonly DirectionalDoubleTapGesture _projectileGesture = new();
	private PlayerSpriteOutline _cooldownOutline;
	private SwapperStaffIndicator _staffIndicator;
	private TwinAbilityEyeTint _eyeTint;
	private float _cooldownTimer;
	private float _cooldownPulsePhase;
	private float _staffPulsePhase;
	private int _previousHoverFrames;
	private bool _hoverUsedSinceRecharge;
	private int _hoverTrailFrame;
	private SoundHandle _hoverTone;

	private static readonly Color StaffFullBright = new( 0.32f, 0.58f, 1f );
	private static readonly Color StaffFullDark = new( 0.08f, 0.25f, 0.82f );
	private static readonly Color StaffEmptyBlack = new( 0f, 0f, 0f );
	private static readonly Color StaffEmptyRed = new( 0.3f, 0.015f, 0.025f );
	private static readonly Color StaffBody = LevelCosmeticsRandomizer.GammaToLinear(
		new Color( 204f / 255f, 241f / 255f, 70f / 255f ) );
	private static readonly Color EmptyHoverEyeTint = new( 0.72f, 0.06f, 0.07f, 0.95f );

	public override void OnSpawn( Player player )
	{
		_storedVelocity = Vector2.Zero;
		_swapCount = 0;
		_cooldownTimer = 0f;
		_cooldownPulsePhase = 0f;
		_staffPulsePhase = 0f;
		_previousHoverFrames = player.GetHoverFramesRemaining();
		_hoverUsedSinceRecharge = false;
		_hoverTrailFrame = 0;
		_hoverTone = null;
		_projectileGesture.Reset();
		if ( _traceEnabled )
			Log.Info( $"[swapper] spawn bank=(0.00,0.00) scale={StoredVelocityScale:0.00}" );
		// Drop the portal at the spawn point. player.Pos is set before CreateVisuals (which builds the
		// ability modules), so it's already the spawn location here.
		_portal = player.Stage.AddPortal( player.Pos );
		var outlineObject = new GameObject( true, "swapper cooldown outline" );
		outlineObject.SetParent( player.GameObject );
		outlineObject.LocalPosition = Vector3.Zero;
		_cooldownOutline = outlineObject.Components.Create<PlayerSpriteOutline>();
		_cooldownOutline.Setup( player );
		var staffObject = new GameObject( true, "swapper staff hover indicator" );
		staffObject.SetParent( player.GameObject );
		staffObject.LocalPosition = Vector3.Zero;
		_staffIndicator = staffObject.Components.Create<SwapperStaffIndicator>();
		_staffIndicator.Setup( player );
		var tintObject = new GameObject( true, "swapper hover eye tint" );
		tintObject.SetParent( player.GameObject );
		tintObject.LocalPosition = Vector3.Zero;
		_eyeTint = tintObject.Components.Create<TwinAbilityEyeTint>();
		_eyeTint.Setup( player, adjustTwinOneWalk: false, eyeLeft: 6, eyeTop: 4,
			eyeWidth: 4, eyeHeight: 3, tintDarkPixels: true );
		UpdatePortalPointer();
		UpdateCooldownOutline();
		UpdateStaffIndicator( player, 0f );
	}

	public override void OnRemoved( Player player )
	{
		_projectileGesture.Reset();
		_cooldownOutline?.GameObject?.Destroy();
		_cooldownOutline = null;
		_staffIndicator?.GameObject?.Destroy();
		_staffIndicator = null;
		_eyeTint?.GameObject?.Destroy();
		_eyeTint = null;
		StopHoverTone();
		player.Stage.RemoveSwapperProjectiles( _portal );
		player.Stage.RemovePortal( _portal );
		_portal = null;
	}

	public override void OnDeath( Player player )
	{
		if ( _cooldownOutline is not null )
			_cooldownOutline.Enabled = false;
		if ( _staffIndicator is not null )
			_staffIndicator.Enabled = false;
		if ( _eyeTint is not null )
			_eyeTint.Tint = Color.Transparent;
		StopHoverTone();
	}

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

	public override void OnReplayPresentationRebuilt( Player player )
	{
		_previousHoverFrames = player.GetHoverFramesRemaining();
		_hoverUsedSinceRecharge = _previousHoverFrames < player.GetHoverFramesMaximum();
		_hoverTrailFrame = 0;
		StopHoverTone();
		UpdateStaffIndicator( player, 0f );
	}

	public override void PreTick( Player player, float dt )
	{
		UpdatePortalPointer();
		TickCooldown( player, dt );
		UpdateCooldownOutline();
		if ( _cooldownTimer > 0f )
		{
			_swapPending = false;
			ResetAbilityGestures();
			return;
		}

		var input = player.InputSource;
		// Age the per-direction press timers, then stamp any that were just pressed this tick to 0.
		if ( _sinceLeft < NEVER ) _sinceLeft++;
		if ( _sinceRight < NEVER ) _sinceRight++;
		if ( _sinceUp < NEVER ) _sinceUp++;
		if ( _sinceDown < NEVER ) _sinceDown++;
		if ( input.LeftJust ) _sinceLeft = 0;
		if ( input.RightJust ) _sinceRight = 0;
		if ( input.UpJust ) _sinceUp = 0;
		if ( input.DownJust ) _sinceDown = 0;

		// Trigger: all four directions pressed within the leniency window (not necessarily the same frame).
		_swapPending = _portal is not null
			&& _sinceLeft < SWAP_INPUT_WINDOW_FRAMES
			&& _sinceRight < SWAP_INPUT_WINDOW_FRAMES
			&& _sinceUp < SWAP_INPUT_WINDOW_FRAMES
			&& _sinceDown < SWAP_INPUT_WINDOW_FRAMES;
		if ( _swapPending )
		{
			_projectileGesture.Reset();
			player.ConsumeDirectionalInputForTick();
		}
		else if ( _projectileGesture.TryTrigger( player, dt, out Vector2 projectileDirection ) )
		{
			FireProjectile( player, projectileDirection );
		}
	}

	public override void PostTick( Player player, float dt )
	{
		UpdateStaffIndicator( player, dt );
		if ( _framesSinceSwap < int.MaxValue / 2 )
			_framesSinceSwap++;

		if ( !_swapPending || _portal is null )
			return;
		_swapPending = false;

		// Blocks moved earlier this tick but Portals tick after Players, so a block-anchored portal's
		// Pos is still last tick's sync — land the swap on the block's current position (where the
		// portal renders this frame) instead of one block-step behind it.
		_portal.UpdateAnchoredPosition();

		Vector2 playerPos = player.Pos;
		Vector2 portalPos = _portal.Pos;
		Vector2 velocityToApply = EnableStoredVelocity ? _storedVelocity * StoredVelocityScale : Vector2.Zero;
		_swapCount++;
		if ( _traceEnabled )
		{
			Log.Info( $"[swapper #{_swapCount}] begin from={V( playerPos )} to={V( portalPos )} "
				+ $"velocityBank={(EnableStoredVelocity ? "on" : "off")} bank={V( _storedVelocity )} "
				+ $"scale={StoredVelocityScale:0.00} apply={V( velocityToApply )} "
				+ player.DescribeMotionForTrace() );
		}
		if ( EnableStoredVelocity )
		{
			_storedVelocity = player.ExchangeStoredVelocity( _storedVelocity, StoredVelocityScale );
			if ( _traceEnabled )
			{
				Log.Info( $"[swapper #{_swapCount}] exchanged captured={V( _storedVelocity )} "
					+ $"applied={V( velocityToApply )} {player.DescribeMotionForTrace()}" );
			}
		}
		else
		{
			_storedVelocity = Vector2.Zero;
			player.ClearMotion();
		}

		// Swap the two positions.
		player.TeleportTo( portalPos.x, portalPos.y );
		_portal.MoveTo( playerPos );
		UpdatePortalPointer();

		// Hit-stop scaled by time since the last swap (rapid re-swaps get little/none).
		int frames = (int)MathF.Round( Utils.Map( _framesSinceSwap, 0f, FULL_RECOVER_FRAMES, 0f, MAX_HITSTOP_FRAMES, true, EasingType.Linear ) );
		if ( frames > 0 )
			player.Stage.RequestHitStop( frames );

		// A burst at each end of the swap: where the player arrived and where the portal arrived.
		SpawnBurst( player, portalPos ); // player's new spot
		SpawnBurst( player, playerPos ); // portal's new spot

		// Warpy phase cue (reuse the turn-invisible sound), pitched up so it's distinct from the wrap.
		Audio.PlaySfx( SfxType.TurnInvisible, player.Position, 0.85f, 1.2f );
		// Centred: a swap trades two places at once, so there is no side for it to come from.
		if ( player.DrivesHaptics ) Haptics.Pulse( 0.55f, 0.12f, 0f, Haptics.TONE_NEUTRAL );

		// Resolve the landing: instant death if fully inside a block, else eject + boost by depth.
		player.ResolveTeleportPenetration();
		// Rebuild contact flags at the arrival point (like Rewind/TwinDash): otherwise the first
		// post-swap tick runs against contacts captured at the departure spot — a mid-air arrival
		// still flagged grounded lets floor friction bite the swap launch.
		player.RefreshContactsAfterTeleport();
		player.RefreshHoverFrames();
		if ( _traceEnabled )
		{
			Log.Info( $"[swapper #{_swapCount}] landed pos={V( player.Pos )} dead={player.IsDead} "
				+ player.DescribeMotionForTrace() );
		}

		_framesSinceSwap = 0;
		StartCooldown();
		UpdateCooldownOutline();
		// Consume the presses so the next swap needs four fresh ones (not the same edges next frame).
		ResetAbilityGestures();
	}

	private void UpdateStaffIndicator( Player player, float dt )
	{
		int maximum = Math.Max( 1, player.GetHoverFramesMaximum() );
		int remaining = Math.Clamp( player.GetHoverFramesRemaining(), 0, maximum );
		float fraction = remaining / (float)maximum;
		if ( _eyeTint is not null )
			_eyeTint.Tint = remaining == 0 ? EmptyHoverEyeTint : Color.Transparent;
		float cyclesPerSecond = remaining == maximum ? 10f : remaining == 0 ? 0.65f : 1.2f + 4.8f * fraction;
		_staffPulsePhase = (_staffPulsePhase + dt * cyclesPerSecond * MathF.Tau) % MathF.Tau;

		if ( _staffIndicator is not null )
		{
			if ( remaining == maximum )
				_staffIndicator.Tint = MathF.Sin( _staffPulsePhase ) >= 0f ? StaffFullBright : StaffFullDark;
			else if ( remaining == 0 )
				_staffIndicator.Tint = StaffBody;
			else
			{
				Color dark = Color.Lerp( new Color( 0.018f, 0.002f, 0.004f ), StaffFullDark, fraction );
				Color bright = Color.Lerp( StaffEmptyRed, StaffFullBright, fraction );
				float pulse = 0.5f + 0.5f * MathF.Sin( _staffPulsePhase );
				_staffIndicator.Tint = Color.Lerp( dark, bright, pulse );
			}
		}

		bool hovering = remaining < _previousHoverFrames;
		UpdateHoverTone( hovering, fraction );
		// Light crisp hum under the hover tone, flat for however long the staff holds us up.
		if ( hovering && player.DrivesHaptics )
			Haptics.Sustain( HapticChannel.SwapperHover, HOVER_HAPTIC_STRENGTH, 0f, Haptics.TONE_CRISP );
		UpdateHoverTrail( player, hovering );
		if ( hovering )
		{
			_hoverUsedSinceRecharge = true;
			if ( remaining == 0 )
				SpawnHoverDepletedParticles( player );
		}
		else if ( remaining > _previousHoverFrames )
		{
			if ( _hoverUsedSinceRecharge && player.OnFloor )
				SpawnHoverRechargeParticles( player, remaining - _previousHoverFrames, maximum );
			_hoverUsedSinceRecharge = false;
		}

		_previousHoverFrames = remaining;
	}

	private void UpdateHoverTrail( Player player, bool hovering )
	{
		if ( !hovering )
		{
			_hoverTrailFrame = 0;
			return;
		}

		if ( _hoverTrailFrame == 0 )
			SpawnHoverTrailParticle( player );
		_hoverTrailFrame = (_hoverTrailFrame + 1) % HOVER_TRAIL_INTERVAL_FRAMES;
	}

	private void SpawnHoverTrailParticle( Player player )
	{
		if ( !TryGetStaffPosition( player, out Vector2 position ) ) return;

		Color color = _staffIndicator?.Tint ?? StaffFullBright;
		Vector2 velocity = new(
			Rng.CosmeticFloat( -32f, 32f ),
			Rng.CosmeticFloat( -150f, -105f ) );
		Particle particle = player.Stage.AddPhysicsParticle( position, velocity, decel: 0.985f, gravity: -90f,
			color.WithAlpha( 0.86f ), lifetime:Rng.CosmeticFloat( 0.35f, 0.75f ), size: 1 );
		particle.SetMotion( ParticleMotion.Feather, Rng.CosmeticFloat( 0f, MathF.Tau ) );
		particle.SetCollision( collidesWithBlocks: true, ParticleSolidResponse.Bounce, restitution: 0.62f );
		particle.EnableFadeOut();
	}

	private void UpdateHoverTone( bool hovering, float remainingFraction )
	{
		const float volume = 0.25f;
		if ( !hovering )
		{
			StopHoverTone();
			return;
		}

		if ( _hoverTone is null || !_hoverTone.IsValid )
			_hoverTone = Audio.PlaySwapperHoverTone( volume );
		float pitch = 0.88f + remainingFraction * 0.2f;
		Audio.SetLoopingSfxProperties( _hoverTone, volume, pitch );
	}

	private void StopHoverTone()
	{
		Audio.StopLoopingSfx( _hoverTone );
		_hoverTone = null;
	}

	private void SpawnHoverDepletedParticles( Player player )
	{
		Audio.PlaySfx( SfxType.SpringChargeLost, player.Position, volume: 0.22f, pitch: 0.85f );
		if ( !TryGetStaffPosition( player, out Vector2 position ) ) return;

		for ( int i = 0; i < 4; i++ )
		{
			float angle = MathF.Tau * (i + Rng.CosmeticFloat( -0.12f, 0.12f )) / 4f;
			Vector2 velocity = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) )
				* Rng.CosmeticFloat( 10f, 24f );
			Color color = Color.Lerp( StaffEmptyRed, new Color( 0.12f, 0f, 0.01f ), i / 3f ).WithAlpha( 0.68f );
			player.Stage.AddColoredParticle( position, velocity, 0.84f, color,
				Rng.CosmeticFloat( 0.16f, 0.28f ), 3, Globals.DEPTH_PARTICLE_1, translucent: true );
		}
	}

	private void SpawnHoverRechargeParticles( Player player, int recoveredFrames, int maximum )
	{
		float intensity = Utils.Map( recoveredFrames, 0f, maximum, 0f, 1f, true, EasingType.CubicEaseIn );
		Audio.PlaySfx( SfxType.SpringChargeThreshold, player.Position,
			volume: 0.18f + intensity * 0.12f, pitch: 1.25f + intensity * 0.15f );
		if ( !TryGetStaffPosition( player, out Vector2 position ) ) return;

		int count = 1 + (int)MathF.Round( intensity * 6f );
		float speed = 8f + intensity * 24f;
		Color darkBlue = new Color( 0.08f, 0.25f, 0.82f );
		for ( int i = 0; i < count; i++ )
		{
			float angle = MathF.Tau * (i + Rng.CosmeticFloat( -0.2f, 0.2f )) / count;
			Vector2 direction = new( MathF.Cos( angle ), MathF.Sin( angle ) );
			Vector2 velocity = direction * Rng.CosmeticFloat( speed * 0.65f, speed );
			Color color = Color.Lerp( darkBlue, StaffFullBright, Rng.CosmeticValue() ).WithAlpha( 0.3f + intensity * 0.5f );
			player.Stage.AddColoredParticle( position, velocity, 0.86f, color,
				0.14f + intensity * Rng.CosmeticFloat( 0.1f, 0.2f ), 2, Globals.DEPTH_PARTICLE_1, translucent: true );
		}
	}

	private bool TryGetStaffPosition( Player player, out Vector2 position )
	{
		if ( _staffIndicator is not null && _staffIndicator.TryGetMarkerWorldPosition( out position ) )
			return true;
		position = player.Position;
		return false;
	}

	private void TickCooldown( Player player, float dt )
	{
		if ( _cooldownTimer > 0f )
		{
			_cooldownTimer = Math.Max( 0f, _cooldownTimer - dt );
			if ( _cooldownTimer <= 0f )
			{
				_cooldownPulsePhase = 0f;
				Audio.PlaySfx( SfxType.TurnVisible, player.Position,
					Math.Max( 0f, CooldownReadySfxVolume ), Math.Max( 0.01f, CooldownReadySfxPitch ) );
				SpawnCooldownReadyParticles( player );
			}
			return;
		}

		_cooldownPulsePhase += dt * Math.Max( 0f, CooldownOutlinePulseSpeed ) * MathF.PI * 2f;
	}

	private void StartCooldown()
	{
		_cooldownTimer = Math.Max( 0f, CooldownDuration );
		_cooldownPulsePhase = 0f;
		ResetAbilityGestures();
	}

	private void ResetAbilityGestures()
	{
		_sinceLeft = _sinceRight = _sinceUp = _sinceDown = NEVER;
		_projectileGesture.Reset();
	}

	private void UpdateCooldownOutline()
	{
		if ( _cooldownOutline is null ) return;

		float opacity;
		if ( _cooldownTimer > 0f )
		{
			float duration = Math.Max( 0.001f, CooldownDuration );
			float progress = 1f - Math.Clamp( _cooldownTimer / duration, 0f, 1f );
			opacity = Utils.Map( progress, 0f, 1f, 0f,
				Math.Clamp( CooldownOutlineRechargeOpacity, 0f, 1f ), true, EasingType.CubicEaseIn );
		}
		else
		{
			float minOpacity = Math.Clamp( CooldownOutlineReadyMinOpacity, 0f, 1f );
			float maxOpacity = Math.Clamp( CooldownOutlineReadyMaxOpacity, minOpacity, 1f );
			float pulse = 0.5f + 0.5f * MathF.Cos( _cooldownPulsePhase );
			opacity = minOpacity + (maxOpacity - minOpacity) * pulse;
		}

		_cooldownOutline.Tint = CooldownOutlineColor.WithAlpha( ShowCooldownOutline ? opacity : 0f );
	}

	private void UpdatePortalPointer()
	{
		_portal?.SetStoredVelocityPointer(
			_storedVelocity * StoredVelocityScale,
			EnableStoredVelocity && ShowStoredVelocityPointer,
			new PortalPointerSettings(
				StoredVelocityPointerMinOffset,
				StoredVelocityPointerOffset,
				StoredVelocityPointerColorA,
				StoredVelocityPointerColorB,
				StoredVelocityPointerMinOpacity,
				StoredVelocityPointerMaxOpacity,
				StoredVelocityPointerOpacityPower,
				StoredVelocityPointerPulseSpeed,
				StoredVelocityPointerMinOscillationDistance,
				StoredVelocityPointerMaxOscillationDistance,
				StoredVelocityPointerMinOscillationSpeed,
				StoredVelocityPointerMaxOscillationSpeed
			)
		);
	}

	private static string V( Vector2 value ) => $"({value.x:0.00},{value.y:0.00})";

	private void SpawnCooldownReadyParticles( Player player )
	{
		int count = Math.Max( 0, CooldownReadyParticleCount );
		float speed = Math.Max( 0f, CooldownReadyParticleSpeed );
		float lifetime = Math.Max( 0.01f, CooldownReadyParticleLifetime );
		Color highlight = Color.Lerp( CooldownOutlineColor, Color.White, 0.55f );
		for ( int i = 0; i < count; i++ )
		{
			float angle = MathF.PI * 2f * (i + 0.5f) / Math.Max( 1, count );
			Vector2 inward = new( -MathF.Cos( angle ), -MathF.Sin( angle ) );
			Vector2 position = player.Pos - inward * Rng.CosmeticFloat( 6.5f, 8f );
			Vector2 tangent = new( -inward.y, inward.x );
			Vector2 velocity = inward * speed + tangent * Rng.CosmeticFloat( -5f, 5f );
			Color color = ((i & 1) == 0 ? CooldownOutlineColor : highlight).WithAlpha( 0.88f );
			player.Stage.AddColoredParticle( position, velocity, 0.84f, color,
				lifetime * Rng.CosmeticFloat( 0.85f, 1.15f ), (i % 3) == 0 ? 2 : 1,
				Globals.DEPTH_PARTICLE_1, translucent: true );
		}
	}

	private void SpawnProjectileOutlineDisperseParticles( Player player )
	{
		int count = Math.Max( 0, ProjectileOutlineDisperseParticleCount );
		float speed = Math.Max( 0f, ProjectileOutlineDisperseSpeed );
		float lifetime = Math.Max( 0.01f, ProjectileOutlineDisperseLifetime );
		SpriteRenderer body = player.BodySpriteRenderer;
		Texture texture = body?.Texture;
		if ( count == 0 || body is null || texture is null || texture == Texture.Transparent ) return;

		Vector3 worldPosition = body.GameObject.WorldPosition;
		Vector3 worldScale = body.GameObject.WorldScale;
		float aspect = texture.Width / (float)Math.Max( 1, texture.Height );
		float halfWidth = MathF.Abs( body.Size.x * worldScale.x * MathF.Min( aspect, 1f ) ) * 0.5f + 1f;
		float halfHeight = MathF.Abs( body.Size.y * worldScale.y / MathF.Max( aspect, 1f ) ) * 0.5f + 1f;
		Vector2 center = new( worldPosition.x, worldPosition.y );
		Color highlight = Color.Lerp( CooldownOutlineColor, Color.White, 0.45f );
		for ( int i = 0; i < count; i++ )
		{
			float angle = MathF.PI * 2f * (i + Rng.CosmeticFloat( -0.15f, 0.15f )) / Math.Max( 1, count );
			Vector2 outward = new( MathF.Cos( angle ), MathF.Sin( angle ) );
			float edgeDistance = Math.Min(
				halfWidth / Math.Max( 0.001f, MathF.Abs( outward.x ) ),
				halfHeight / Math.Max( 0.001f, MathF.Abs( outward.y ) ) );
			Vector2 tangent = new( -outward.y, outward.x );
			Vector2 velocity = outward * Rng.CosmeticFloat( speed * 0.8f, speed * 1.2f )
				+ tangent * Rng.CosmeticFloat( -7f, 7f );
			Color color = ((i & 1) == 0 ? CooldownOutlineColor : highlight).WithAlpha( 0.78f );
			player.Stage.AddColoredParticle( center + outward * edgeDistance, velocity, 0.88f, color,
				lifetime * Rng.CosmeticFloat( 0.8f, 1.2f ), 1, Globals.DEPTH_PARTICLE_1, translucent: true );
		}
	}

	private void FireProjectile( Player player, Vector2 direction )
	{
		Vector2 muzzle = player.Pos + direction * PROJECTILE_MUZZLE_OFFSET;
		player.Stage.AddSwapperProjectile( muzzle, direction, sweepOrigin: player.Pos, _portal, ProjectileSpeed,
			ProjectileImpactHitStopFrames, ProjectilePortalSurfacePadding );
		player.Stage.RequestHitStop( Math.Max( 0, ProjectileFireHitStopFrames ) );
		Audio.PlaySfx( SfxType.TurnInvisible, player.Position, 0.55f, 1.4f );
		if ( player.DrivesHaptics )
			Haptics.Pulse( 0.4f, 0.08f, direction.x * 0.5f, Haptics.TONE_CRISP, EasingType.ExpoEaseOut );

		Vector2 perpendicular = new( -direction.y, direction.x );
		for ( int i = 0; i < 6; i++ )
		{
			Vector2 velocity = direction * Rng.CosmeticFloat( 35f, 90f )
				+ perpendicular * Rng.CosmeticFloat( -28f, 28f );
			Color color = (i & 2) == 0
				? new Color( 72 / 255f, 205 / 255f, 218 / 255f, 0.7f )
				: new Color( 1f, 0.82f, 0.38f, 0.72f );
			player.Stage.AddColoredParticle( muzzle, velocity, 0.86f, color,
				Rng.CosmeticFloat( 0.14f, 0.26f ), Rng.CosmeticInt( 1, 4 ), Globals.DEPTH_PARTICLE_1, translucent: true );
		}

		SpawnProjectileOutlineDisperseParticles( player );
		StartCooldown();
		UpdateCooldownOutline();
	}

	/// <summary>Spray a two-tone cyan burst at <paramref name="pos"/> (a swap endpoint). Cosmetic only,
	/// so it draws from the cosmetic RNG stream (never the authoritative sim stream).</summary>
	private static void SpawnBurst( Player player, Vector2 pos )
	{
		for ( int i = 0; i < BURST_COUNT_MAIN; i++ )
		{
			var vel = new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 60f, 200f );
			player.Stage.AddParticle( pos, vel, Rng.CosmeticFloat( 0.9f, 0.95f ), 0f, ParticleKind.SwapBurst0, Rng.CosmeticFloat( 0.25f, 0.5f ), Rng.CosmeticInt( 3, 7 ) );
		}
		for ( int i = 0; i < BURST_COUNT_HIGHLIGHT; i++ )
		{
			var vel = new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 40f, 140f );
			player.Stage.AddParticle( pos, vel, Rng.CosmeticFloat( 0.9f, 0.95f ), 0f, ParticleKind.SwapBurst1, Rng.CosmeticFloat( 0.2f, 0.4f ), Rng.CosmeticInt( 2, 5 ) );
		}
	}
}