Entities/Abilities/BlinkerAbility.cs
using System;

namespace BlockParty;

/// <summary>Directional double-tap blink with a brief aim-and-charge phase and floor recharge.</summary>
public sealed class BlinkerAbility : PlayerAbility
{
	private const float TAP_RELEASE_TIME = 0.1f;
	private const float SECOND_TAP_TIME = 0.09f;
	private const float DIAGONAL_GRACE_TIME = 0.1f;
	private const float PREPARE_TIME = 1.2f;
	private const float MIN_DISTANCE = 5f;
	private const float MAX_DISTANCE = 140f;
	private const float PREPARE_MOTION_FACTOR = 0.18f;
	private const float PREPARE_DAMPING = 5f;
	// Flat low buzz for the whole wind-up: the prepare is a held, unresolved state, so it reads as
	// pressure rather than as progress (the shake below carries the ramp visually).
	private const float PREPARE_HAPTIC_STRENGTH = 0.13f;
	private const float PREPARE_SHAKE_START_STRENGTH = 2f;
	private const float PREPARE_SHAKE_FINISH_STRENGTH = 4f;
	private const int AIM_CHORD_FRAMES = 2;
	private const float PORTAL_LIFETIME = 0.28f;
	private const float PORTAL_HIGHLIGHT_LIFETIME = 0.18f;
	private const int PORTAL_SIZE = 20;
	private const int PORTAL_HIGHLIGHT_SIZE = 11;
	private const int ARRIVAL_HITSTOP_FRAMES = 7;
	private const float INDICATOR_HORIZONTAL_OFFSET = 5.5f;
	private const float INDICATOR_HEIGHT_FROM_FEET = 10f;
	private const float INDICATOR_FOLLOW_SPEED = 24f;
	private const float INDICATOR_FADE_TIME = 0.14f;
	private const float INDICATOR_RENDER_EXTENT = 1.5f;
	private static readonly Color INDICATOR_COLOR = new( 0.82f, 0.22f, 1f );

	/// <summary>Fixed speed applied in the secondary input direction after arriving. Goes out through
	/// Player.ApplyLaunchVelocity, so this is a true speed in every direction — not capped down to the
	/// walk speed with the remainder shunted into a channel steering can't reach.</summary>
	public float PostBlinkForce { get; set; } = 180f;

	private readonly DirectionalDoubleTapGesture _blinkGesture = new(
		TAP_RELEASE_TIME, SECOND_TAP_TIME, DIAGONAL_GRACE_TIME );

	private bool _charged = true;
	private bool _preparing;
	private float _prepareTimer;
	private Vector2 _blinkDirection;
	private bool _aimArmed;
	private int _aimChordFrames;
	private Vector2 _pendingForceDirection;
	private bool _blinkPending;
	private Vector2 _pendingBlinkDirection;
	private Vector2 _pendingBlinkForceDirection;
	private float _pendingBlinkDistance;
	private GameObject _indicatorRoot;
	private Entity2D _indicatorAnchor;
	private SpriteRenderer _indicator;
	private readonly SpriteRenderer[] _indicatorOutline = new SpriteRenderer[4];
	private Vector2 _indicatorPosition;
	private float _indicatorFade = 1f;
	private float _indicatorTime;

	public override void OnSpawn( Player player )
	{
		_indicatorRoot = player.Stage.CreateChild( "BlinkReadyIndicator" );
		_indicatorAnchor = _indicatorRoot.Components.Create<Entity2D>();
		_indicator = SpriteLayer.Add( _indicatorRoot, "sprites/pixel.sprite", Vector2.One, "idle" );
		_indicator.Opaque = false;
		_indicator.AlphaCutoff = 0f;
		_indicator.Color = INDICATOR_COLOR;
		_indicatorOutline[0] = CreateIndicatorOutlinePixel( new Vector2( -1f, 0f ) );
		_indicatorOutline[1] = CreateIndicatorOutlinePixel( new Vector2( 1f, 0f ) );
		_indicatorOutline[2] = CreateIndicatorOutlinePixel( new Vector2( 0f, -1f ) );
		_indicatorOutline[3] = CreateIndicatorOutlinePixel( new Vector2( 0f, 1f ) );
		_indicatorPosition = GetIndicatorTarget( player );
		UpdateIndicatorTransform();
	}

	public override void OnRemoved( Player player )
	{
		_preparing = false;
		_blinkPending = false;
		player.ClearShakeOffset();
		_indicatorRoot?.Destroy();
		_indicatorRoot = null;
		_indicatorAnchor = null;
		_indicator = null;
		for ( int i = 0; i < _indicatorOutline.Length; i++ )
			_indicatorOutline[i] = null;
	}

	public override void OnDeath( Player player )
	{
		SetIndicatorEnabled( false );
	}

	public override void OnReplayPresentationRebuilt( Player player )
	{
		_indicatorPosition = GetIndicatorTarget( player );
		_indicatorFade = _charged ? 1f : 0f;
		SetIndicatorEnabled( _charged && !player.IsDead );
		UpdateIndicatorTransform();
	}

	public override void PreTick( Player player, float dt )
	{
		if ( _preparing )
		{
			TickPrepare( player, dt );
			return;
		}

		if ( player.IsTouchingFloorNow() && !_charged )
		{
			_charged = true;
			ShowIndicator( player );
		}

		TickTapGesture( player, dt );
	}

	public override void PostTick( Player player, float dt )
	{
		bool blinkedThisTick = _blinkPending;
		if ( _blinkPending )
		{
			_blinkPending = false;

			Vector2 start = player.Position;
			Vector2 destination = start + _pendingBlinkDirection * _pendingBlinkDistance;
			player.ClearMotion();
			player.TeleportTo( destination.x, destination.y );
			SpawnEndpointBurst( player.Stage, start, false );
			SpawnEndpointBurst( player.Stage, destination, true );
			SpawnDestinationPortal( player.Stage, destination );
			GhostRecorder.Emit( player, GhostFxKind.Blink, start, destination );
			player.Stage.RequestHitStop( ARRIVAL_HITSTOP_FRAMES );
			Audio.PlaySfx( SfxType.TurnInvisible, player.Position, 0.9f, 1.35f );
			// Arrival jolt, scaled by how far the blink actually threw us (a barely-charged hop is a tap,
			// a full-length one is a slam) and biased the way we went.
			if ( player.DrivesHaptics )
			{
				float reach = Utils.Map( _pendingBlinkDistance, MIN_DISTANCE, MAX_DISTANCE, 0f, 1f, true, EasingType.Linear );
				Haptics.Pulse(
					Utils.Map( reach, 0f, 1f, 0.35f, 1f, true, EasingType.Linear ),
					Utils.Map( reach, 0f, 1f, 0.1f, 0.2f, true, EasingType.Linear ),
					_pendingBlinkDirection.x * 0.5f, Haptics.TONE_HEAVY );
			}
			player.ResolveBlinkDestination();
			if ( !player.IsDead )
			{
				// Applied AFTER the resolve so the launch is the last word on speed — but ADDED to what
				// the resolve left, not substituted for it. Materializing partway inside a solid earns a
				// depth-scaled shove back out; wiping that turned a bad arrival into a limp one and cost
				// the ejection its whole point. The Swapper never lost this because it launches BEFORE
				// its teleport, so its eject stacks on top for free.
				player.ApplyLaunchVelocity( _pendingBlinkForceDirection * PostBlinkForce,
					keepExistingMotion: true );
				// Rebuild contact flags at the arrival point (like Rewind/TwinDash) so the first
				// post-blink tick doesn't reuse contacts captured at the departure spot.
				player.RefreshContactsAfterTeleport();
			}
			// The indicator only FOLLOWS the player (exponential lerp), and the arrival hit-stop freezes
			// the sim before it can catch up — so a recharge on landing/pressing at the destination would
			// gather its motes back at the departure spot and fly them across the arena. Snap it here, at
			// the resolved arrival position, for the same reason the contacts above are rebuilt.
			_indicatorPosition = GetIndicatorTarget( player );
			UpdateIndicatorTransform();
		}

		// Pressing a block side recharges the blink, like touching the floor does. Same exclusions as
		// that recharge (which PreTick's early-return keeps off the wind-up ticks): a press landed
		// while PREPARING — or on the very tick the charge is spent, since the prepare's damped drift
		// and any block driving into the planted player still press faces — would refund the blink
		// before it even finishes, and a row of unpressed faces would then chain blinks indefinitely.
		if ( player.PressedBlockSideThisTick && !_charged && !_preparing && !blinkedThisTick && !player.IsDead )
		{
			_charged = true;
			ShowIndicator( player );
		}

		UpdateIndicator( player, dt );
	}

	private void TickTapGesture( Player player, float dt )
	{
		if ( _blinkGesture.TryTrigger( player, dt, out Vector2 direction ) )
		{
			if ( _charged )
				BeginPrepare( player, direction );
			else
				Audio.PlaySfx( SfxType.Error, player.Position, 0.45f, 1.2f );
		}
	}

	private void BeginPrepare( Player player, Vector2 blinkDirection )
	{
		_charged = false;
		HideIndicator( player );
		_preparing = true;
		_prepareTimer = 0f;
		_blinkDirection = blinkDirection;
		_aimArmed = false;
		_aimChordFrames = 0;
		_pendingForceDirection = Vector2.Zero;
		player.ConsumeDirectionalInputForTick();
		ApplyPrepareEffect( player, Arena.STEP );
		SpawnPrepareMotes( player.Stage, player.Position );
		GhostRecorder.Emit( player, GhostFxKind.BlinkPrepare, player.Position );
		Audio.PlaySfx( SfxType.TurnInvisible, player.Position, 0.35f, 1.65f );
	}

	private void TickPrepare( Player player, float dt )
	{
		var input = player.InputSource;
		_prepareTimer = Math.Min( PREPARE_TIME, _prepareTimer + dt );
		player.ConsumeDirectionalInputForTick();
		ApplyPrepareEffect( player, dt );

		bool anyHeld = input.Left || input.Right || input.Up || input.Down;
		if ( !_aimArmed )
		{
			_aimArmed = !anyHeld;
		}
		else
		{
			bool anyJust = input.LeftJust || input.RightJust || input.UpJust || input.DownJust;
			if ( anyJust )
			{
				Vector2 direction = ReadInputDirection( input );
				if ( direction.Length > 0f ) _pendingForceDirection = direction;
				_aimChordFrames = AIM_CHORD_FRAMES;
			}
			else if ( _aimChordFrames > 0 )
			{
				Vector2 direction = ReadInputDirection( input );
				if ( direction.Length > 0f ) _pendingForceDirection = direction;
				_aimChordFrames--;
				if ( _aimChordFrames == 0 && _pendingForceDirection.Length > 0f )
				{
					FireBlink( player, _pendingForceDirection );
					return;
				}
			}
		}

		// A secondary input tapped in the final ticks may still be mid-chord when the timer expires, so
		// _pendingForceDirection wins outright. Failing that, fall back to whatever is simply HELD.
		//
		// The press that picks the launch direction is also the press that ENDS the wind-up (charge =
		// _prepareTimer / PREPARE_TIME), so a full-distance blink used to mean pressing nothing at all —
		// which fired with a zero force direction, a dead stop. The most natural input there is,
		// "double-tap right and keep holding right", produced exactly that: never releasing means the
		// aim never arms, so the branch above never runs and nothing was ever recorded.
		//
		// Reading the held keys here covers that: a direction held straight through the wind-up now
		// pre-commits its launch and still charges to full. It does NOT decouple the general case — a key
		// pressed after the aim arms still trips the chord and fires early, so "release, then hold left"
		// remains a short blink. Separating those two needs a tap-vs-hold discriminator, which is a
		// bigger change than this fallback.
		//
		// This can't short-circuit the _aimArmed gate: that gate stops the double-tap's own still-held
		// keys from reading as an aim and firing at charge ~0, and this only runs once the timer is
		// already spent (charge == 1).
		if ( _prepareTimer >= PREPARE_TIME )
		{
			FireBlink( player, _pendingForceDirection.Length > 0f
				? _pendingForceDirection
				: ReadInputDirection( input ) );
		}
	}

	private void ApplyPrepareEffect( Player player, float dt )
	{
		int frame = (int)MathF.Floor( _prepareTimer * 60f ) & 3;
		Vector2 shake = frame switch
		{
			0 => new Vector2( 0.35f, 0.10f ),
			1 => new Vector2( -0.30f, -0.15f ),
			2 => new Vector2( 0.20f, -0.30f ),
			_ => new Vector2( -0.15f, 0.30f ),
		};
		float progress = Math.Clamp( _prepareTimer / PREPARE_TIME, 0f, 1f );
		shake *= Utils.Map( progress, 0f, 1f, PREPARE_SHAKE_START_STRENGTH, PREPARE_SHAKE_FINISH_STRENGTH, true, EasingType.Linear );
		player.SetBlinkPrepareEffect( PREPARE_MOTION_FACTOR, MathF.Exp( -PREPARE_DAMPING * dt ), shake );
		// Asserted from the shared prepare-effect step so it covers the wind-up's opening tick too, and
		// so any exit from the prepare (fire, death, form change) simply stops feeding it.
		if ( player.DrivesHaptics )
			Haptics.Sustain( HapticChannel.BlinkPrepare, PREPARE_HAPTIC_STRENGTH, 0f, Haptics.TONE_CRISP );
	}

	private void FireBlink( Player player, Vector2 forceDirection )
	{
		float charge = Math.Clamp( _prepareTimer / PREPARE_TIME, 0f, 1f );
		_preparing = false;
		// Cut the wind-up buzz now rather than letting it go stale, so it doesn't bleed into the blink
		// impulse PostTick fires a moment later.
		Haptics.ReleaseSustain( HapticChannel.BlinkPrepare );
		player.ClearShakeOffset();
		_blinkPending = true;
		_pendingBlinkDirection = Utils.Normalized( _blinkDirection );
		_pendingBlinkForceDirection = Utils.Normalized( forceDirection );
		_pendingBlinkDistance = Utils.Map( charge, 0f, 1f, MIN_DISTANCE, MAX_DISTANCE, true, EasingType.Linear );
	}

	private void UpdateIndicator( Player player, float dt )
	{
		_indicatorTime += dt;
		Vector2 target = GetIndicatorTarget( player );
		float follow = 1f - MathF.Exp( -INDICATOR_FOLLOW_SPEED * dt );
		_indicatorPosition = Vector2.Lerp( _indicatorPosition, target, follow );

		if ( _charged )
			_indicatorFade = Math.Min( 1f, _indicatorFade + dt / INDICATOR_FADE_TIME );

		UpdateIndicatorTransform();
		if ( _indicator is null || !_indicator.Enabled ) return;

		float centerPulse = 0.5f + 0.5f * MathF.Sin( _indicatorTime * 9f );
		Color centerColor = Color.Lerp(
			new Color( 0.68f, 0.04f, 1f ),
			new Color( 1f, 0.38f, 1f ), centerPulse );
		_indicator.Color = centerColor.WithAlpha( (0.7f + 0.29f * centerPulse) * _indicatorFade );

		float outlinePulse = 0.5f + 0.5f * MathF.Sin( _indicatorTime * 7.2f + 0.8f );
		Color outlineColor = Color.Lerp( Color.Black, centerColor, outlinePulse )
			.WithAlpha( (1f - 0.12f * outlinePulse) * _indicatorFade );
		for ( int i = 0; i < _indicatorOutline.Length; i++ )
			if ( _indicatorOutline[i] is not null ) _indicatorOutline[i].Color = outlineColor;
	}

	private Vector2 GetIndicatorTarget( Player player )
	{
		float facingSign = player.BodySpriteRenderer?.FlipHorizontal == true ? -1f : 1f;
		float gravitySign = player.GravityReversed ? -1f : 1f;
		float feetY = player.Y - gravitySign * player.Height * 0.5f;
		float wobbleX = MathF.Sin( _indicatorTime * (MathF.PI * 2f / 0.83f) ) * 0.8f;
		float wobbleY = MathF.Sin( _indicatorTime * (MathF.PI * 2f / 1.17f) + 1.1f ) * 0.8f;
		return new Vector2(
			player.X - facingSign * INDICATOR_HORIZONTAL_OFFSET + wobbleX,
			feetY + gravitySign * INDICATOR_HEIGHT_FROM_FEET + wobbleY );
	}

	private void UpdateIndicatorTransform()
	{
		if ( _indicatorRoot is null ) return;
		Vector2 center = SpriteLayer.PixelAlignedCenter( _indicatorPosition, Vector2.One );
		center = new Vector2(
			Math.Clamp( center.x, Arena.WALL_SIZE + INDICATOR_RENDER_EXTENT,
				Arena.WIDTH - Arena.WALL_SIZE - INDICATOR_RENDER_EXTENT ),
			Math.Clamp( center.y, Arena.WALL_SIZE + INDICATOR_RENDER_EXTENT,
				Arena.HEIGHT - Arena.WALL_SIZE - INDICATOR_RENDER_EXTENT ) );
		if ( _indicatorAnchor is not null ) _indicatorAnchor.Pos = center;
		_indicatorRoot.WorldPosition = new Vector3( center.x, center.y,
			Globals.DepthToZ( Globals.DEPTH_ABILITY_INDICATOR ) );
	}

	private SpriteRenderer CreateIndicatorOutlinePixel( Vector2 offset )
	{
		SpriteRenderer pixel = SpriteLayer.Add( _indicatorRoot, "sprites/pixel.sprite", Vector2.One, "idle", -1 );
		pixel.Opaque = false;
		pixel.AlphaCutoff = 0f;
		pixel.Color = Color.Black.WithAlpha( 0.8f );
		pixel.GameObject.LocalPosition = new Vector3( offset.x, offset.y, -SpriteLayer.LAYER_Z_STEP );
		return pixel;
	}

	private void SetIndicatorEnabled( bool enabled )
	{
		if ( _indicator is not null ) _indicator.Enabled = enabled;
		for ( int i = 0; i < _indicatorOutline.Length; i++ )
			if ( _indicatorOutline[i] is not null ) _indicatorOutline[i].Enabled = enabled;
	}

	private void HideIndicator( Player player )
	{
		SpawnIndicatorDisperse( player, _indicatorAnchor?.Position ?? _indicatorPosition );
		_indicatorFade = 0f;
		SetIndicatorEnabled( false );
	}

	private void ShowIndicator( Player player )
	{
		_indicatorFade = 0f;
		SetIndicatorEnabled( true );
		SpawnIndicatorGather( player, _indicatorAnchor?.Position ?? _indicatorPosition, _indicatorAnchor );
	}

	private static void SpawnIndicatorDisperse( Player player, Vector2 position )
	{
		int count = Rng.CosmeticInt( 8, 12 );
		int requiredBlack = Rng.CosmeticInt( 0, count );
		int requiredColor = (requiredBlack + Rng.CosmeticInt( 1, count )) % count;
		for ( int i = 0; i < count; i++ )
		{
			float angle = MathF.PI * 2f * i / count
				+ Rng.CosmeticFloat( -0.18f, 0.18f );
			Vector2 direction = new( MathF.Cos( angle ), MathF.Sin( angle ) );
			bool black = i == requiredBlack || (i != requiredColor && Rng.CosmeticValue() < 0.28f);
			Color color = IndicatorTransitionColor( black, 0.72f, 1f );
			Particle particle = player.Stage.AddColoredParticle( position,
				direction * Rng.CosmeticFloat( 55f, 95f ),
				0.88f, color, Rng.CosmeticFloat( 0.1f, 0.17f ), 1,
				Globals.DEPTH_ABILITY_INDICATOR, translucent: true );
			particle.Depth = Globals.DEPTH_ABILITY_INDICATOR;
			particle.ClampToArenaBounds();
		}
	}

	private static void SpawnIndicatorGather( Player player, Vector2 position, Entity2D indicatorAnchor )
	{
		int count = Rng.CosmeticInt( 8, 12 );
		int requiredBlack = Rng.CosmeticInt( 0, count );
		int requiredColor = (requiredBlack + Rng.CosmeticInt( 1, count )) % count;
		for ( int i = 0; i < count; i++ )
		{
			float angle = MathF.PI * 2f * (i + 0.5f) / count
				+ Rng.CosmeticFloat( -0.18f, 0.18f );
			Vector2 direction = new( MathF.Cos( angle ), MathF.Sin( angle ) );
			float radius = Rng.CosmeticFloat( 5f, 8f );
			bool black = i == requiredBlack || (i != requiredColor && Rng.CosmeticValue() < 0.28f);
			Color color = IndicatorTransitionColor( black, 0.65f, 0.95f );
			TargetedParticle particle = player.Stage.AddColoredTargetedParticle(
				position + direction * radius, Vector2.Zero, 1f, color,
				Rng.CosmeticFloat( 0.1f, 0.16f ), 1, Globals.DEPTH_ABILITY_INDICATOR,
				Vector2.Zero, 0f, indicatorAnchor, translucent: true );
			particle.Depth = Globals.DEPTH_ABILITY_INDICATOR;
			particle.ClampToArenaBounds();
			particle.LerpToTarget( Rng.CosmeticFloat( 26f, 34f ) );
		}
	}

	private static Color IndicatorTransitionColor( bool black, float minAlpha, float maxAlpha )
	{
		if ( black ) return Color.Black;

		float shade = Rng.CosmeticValue();
		Color color = Rng.CosmeticInt( 0, 3 ) switch
		{
			0 => Color.Lerp( new Color( 0.62f, 0.02f, 1f ), new Color( 0.9f, 0.16f, 1f ), shade ),
			1 => Color.Lerp( new Color( 0.9f, 0.1f, 0.8f ), new Color( 1f, 0.42f, 1f ), shade ),
			_ => Color.Lerp( new Color( 0.36f, 0.12f, 1f ), new Color( 0.7f, 0.35f, 1f ), shade ),
		};
		return color.WithAlpha( Rng.CosmeticFloat( minAlpha, maxAlpha ) );
	}

	private static Vector2 ReadInputDirection( IPlayerInput input )
	{
		// OR in the latched edges alongside held state: a press+release inside one sim step (a fast
		// flick, or any tap made during a hit-stop freeze — frozen steps preserve edges but not held
		// state) reads Just with nothing held, and would otherwise be dropped. The double-tap
		// recognizer already honors these sub-step taps (justPressed && !held); the aim must too.
		// On chord-countdown ticks the edges are always clear (anyJust was false), so this only
		// changes the just-pressed tick.
		float x = ((input.Right || input.RightJust) ? 1f : 0f)
			- ((input.Left || input.LeftJust) ? 1f : 0f);
		float y = ((input.Up || input.UpJust) ? 1f : 0f)
			- ((input.Down || input.DownJust) ? 1f : 0f);
		return new Vector2( x, y );
	}

	/// <summary>Replay a recorded blink visual for the tutorial ghost: the same particles the live
	/// ability spawns, tinted down to the ghost's opacity — no audio, hit-stop or haptics.</summary>
	internal static void PlayGhostFx( GameStage stage, GhostFx fx, float alpha )
	{
		switch ( fx.Kind )
		{
			case GhostFxKind.BlinkPrepare:
				SpawnPrepareMotes( stage, fx.A, alpha );
				break;
			case GhostFxKind.Blink:
				SpawnEndpointBurst( stage, fx.A, false, alpha );
				SpawnEndpointBurst( stage, fx.B, true, alpha );
				SpawnDestinationPortal( stage, fx.B, alpha );
				break;
		}
	}

	private static void Tint( Particle particle, float alpha )
	{
		if ( alpha < 1f ) particle.FadeTint( alpha );
	}

	private static void SpawnEndpointBurst( GameStage stage, Vector2 position, bool portalRing, float alpha = 1f )
	{
		int count = portalRing ? 18 : 10;
		for ( int i = 0; i < count; i++ )
		{
			float angle = MathF.PI * 2f * i / count;
			Vector2 radial = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
			Vector2 velocity = radial * Rng.CosmeticFloat( portalRing ? 55f : 30f, portalRing ? 145f : 90f );
			ParticleKind kind = (i & 1) == 0 ? ParticleKind.BlinkBurst0 : ParticleKind.BlinkBurst1;
			Tint( stage.AddParticle( position, velocity, Rng.CosmeticFloat( 0.90f, 0.95f ), 0f, kind,
				Rng.CosmeticFloat( 0.22f, 0.45f ), Rng.CosmeticInt( 2, 6 ) ), alpha );
		}
	}

	private static void SpawnPrepareMotes( GameStage stage, Vector2 center, float alpha = 1f )
	{
		const int count = 6;
		for ( int i = 0; i < count; i++ )
		{
			float angle = MathF.PI * 2f * (i + 0.5f) / count;
			Vector2 radial = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
			Vector2 position = center + radial * Rng.CosmeticFloat( 5f, 8f );
			Vector2 velocity = radial * -Rng.CosmeticFloat( 16f, 28f );
			ParticleKind kind = i % 3 == 0 ? ParticleKind.BlinkBurst1 : ParticleKind.BlinkBurst0;
			Tint( stage.AddParticle( position, velocity, 0.92f, 0f, kind,
				Rng.CosmeticFloat( 0.18f, 0.30f ), Rng.CosmeticInt( 2, 4 ) ), alpha );
		}
	}

	private static void SpawnDestinationPortal( GameStage stage, Vector2 destination, float alpha = 1f )
	{
		Tint( stage.AddPortalParticle( destination, PORTAL_LIFETIME, PORTAL_SIZE, ParticleKind.BlinkBurst0, Globals.DEPTH_PORTAL ), alpha );
		Tint( stage.AddPortalParticle( destination, PORTAL_HIGHLIGHT_LIFETIME, PORTAL_HIGHLIGHT_SIZE, ParticleKind.BlinkBurst1, Globals.DEPTH_PORTAL_HIGHLIGHT ), alpha );
	}
}