Entities/Portal.cs
namespace BlockParty;

public readonly record struct PortalPointerSettings(
	float MinOffset,
	float MaxOffset,
	Color ColorA,
	Color ColorB,
	float MinOpacity,
	float MaxOpacity,
	float OpacityPower,
	float PulseSpeed,
	float MinOscillationDistance,
	float MaxOscillationDistance,
	float MinOscillationSpeed,
	float MaxOscillationSpeed
);

/// <summary>
/// The Swapper character's portal: a persistent marker the player can swap positions with (see
/// <see cref="SwapPortalAbility"/>). It never expires and does not collide with anything. A projectile
/// hit can anchor it to a block at the impact offset; otherwise it remains stationary in world space.
///
/// Visually it's an outwardly-expanding, taller-than-wide rectangular portal: a small pool of ring
/// quads, staggered in phase, each growing from tiny to a player+-sized rectangle while fading, in
/// two alternating tones (a cyan and a slightly bluer cyan) so that at any moment two differently
/// coloured rects are expanding at once — the "portal breathing" look. It renders just above the
/// block/laser layer but below the player (see the child-order band below), so the player is always
/// drawn on top of their own portal.
///
/// Deterministic / replay-safe: the ring animation is driven purely by the fixed tick <c>dt</c> with
/// no RNG, and the entity only moves on an (input-driven, recorded) swap — so a replay reproduces it
/// exactly. It ticks inside the sim loop, so it also freezes during hit-stop along with everything else.
/// </summary>
public sealed class Portal : Entity2D
{
	public GameStage Stage { get; set; }

	// --- ring animation tuning ----------------------------------------------------------
	// Number of expanding ring quads. Each gets its own child-order slot so they never share a Z
	// (translucent sprites at equal Z would flicker); the band 5..8 sits above the block face layers
	// (child 0-4) and the squid beam (child 6-8 is close, but the top ring at child 8+ clears it) yet
	// stays below the player, whose sprite is a whole depth layer up (see Globals depth constants).
	private const int NUM_RINGS = 4;
	private const int FIRST_CHILD_ORDER = 5;

	// One ring's grow-and-fade cycle (seconds). The rings are evenly staggered across this so the
	// expansion reads as continuous.
	private const float RING_PERIOD = 0.9f;

	// The rectangular ring's size, tiny at birth to full (player+ sized, taller than it is wide) at
	// the end of its expansion. The player art is 12x12 / collision 8x10, so this reads as clearly
	// bigger than the character and portrait-shaped.
	private static readonly Vector2 RING_MIN_SIZE = new( 2f, 3f );
	private static readonly Vector2 RING_FULL_SIZE = new( 14f, 24f );

	// The two alternating tones. Cyan matches the Swapper's body; the second is a touch bluer so the
	// overlapping rects read as a two-colour portal rather than one flat glow.
	private static readonly Color RING_COLOR_A = new( 72 / 255f, 205 / 255f, 218 / 255f );
	private static readonly Color RING_COLOR_B = new( 60 / 255f, 150 / 255f, 210 / 255f );
	private const float POINTER_HIDE_SPEED = 6f;
	private const float POINTER_FULL_SIZE_SPEED = 220f;
	private const float POINTER_MIN_LENGTH = 5f;
	private const float POINTER_MAX_LENGTH = 8f;
	private const int POINTER_CHILD_ORDER = FIRST_CHILD_ORDER + NUM_RINGS;

	private SpriteRenderer[] _rings;
	private SceneCustomObject _pointerSceneObject;
	private Material _pointerMaterial;
	private readonly List<Vertex> _pointerVertices = new( 3 );
	private Vector2 _pointerVelocity;
	private PortalPointerSettings _pointerSettings;
	private bool _pointerEnabled;
	private float _pointerPulsePhase;
	private float _pointerOscillationPhase;
	private float _phase; // 0..1 master phase, advanced by dt each tick
	private int? _anchorBlockStageIndex;
	private Vector2 _anchorOffset;

	public void CreateVisuals()
	{
		Size = RING_FULL_SIZE;                // logical footprint (unused for collision — purely nominal)
		Depth = Globals.DEPTH_BLOCK;          // base Z = block layer; the child orders lift the rings above it

		_rings = new SpriteRenderer[NUM_RINGS];
		for ( int i = 0; i < NUM_RINGS; i++ )
		{
			var sr = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", RING_MIN_SIZE, "idle", FIRST_CHILD_ORDER + i );
			// Smooth translucency (same setup as GameStage.CreateOverlaySprite): don't discard low-alpha
			// pixels and alpha-blend instead of dither, so the rings fade cleanly.
			sr.Opaque = false;
			sr.AlphaCutoff = 0f;
			_rings[i] = sr;
		}

		CreatePointerSceneObject();

		UpdateRings();
	}

	protected override void OnEnabled()
	{
		// Components are enabled before GameStage calls CreateVisuals; only recreate after that setup exists.
		if ( _rings is not null ) CreatePointerSceneObject();
	}

	protected override void OnDisabled()
	{
		_pointerSceneObject?.Delete();
		_pointerSceneObject = null;
	}

	private void CreatePointerSceneObject()
	{
		if ( _pointerSceneObject is not null ) return;

		_pointerMaterial ??= Material.FromShader( "shaders/portal_pointer.shader" );
		_pointerSceneObject = new SceneCustomObject( Scene.SceneWorld )
		{
			Transform = global::Transform.Zero,
			RenderLayer = SceneRenderLayer.OverlayWithDepth,
			RenderOverride = RenderPointer,
		};
		_pointerSceneObject.Flags.IsOpaque = false;
		_pointerSceneObject.Flags.IsTranslucent = true;
		_pointerSceneObject.Flags.CastShadows = false;
		UpdatePointer();
	}

	public override void Tick( float dt )
	{
		UpdateAnchoredPosition();

		_phase += dt / RING_PERIOD;
		_phase -= MathF.Floor( _phase ); // wrap to [0,1) without drift
		UpdateRings();

		float magnitude = _pointerVelocity.Length;
		float sizeFraction = PointerSizeFraction( magnitude );
		_pointerPulsePhase = WrapPhase( _pointerPulsePhase + dt * Math.Max( 0f, _pointerSettings.PulseSpeed ) );
		float oscillationSpeed = LerpOrdered(
			_pointerSettings.MinOscillationSpeed,
			_pointerSettings.MaxOscillationSpeed,
			sizeFraction
		);
		_pointerOscillationPhase = WrapPhase( _pointerOscillationPhase + dt * Math.Max( 0f, oscillationSpeed ) );
		UpdatePointer();
	}

	/// <summary>Re-snap to the anchor block's CURRENT position. Runs every Tick — but Portals tick
	/// AFTER Players, so the swap must call this before reading <see cref="Entity2D.Pos"/> or it
	/// teleports to where the block was LAST tick (up to a block-step behind the rendered portal,
	/// biased into a moving block).</summary>
	public void UpdateAnchoredPosition()
	{
		if ( _anchorBlockStageIndex is not int stageIndex ) return;

		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block.StageIndex != stageIndex ) continue;
			Pos = block.Pos + _anchorOffset;
			return;
		}

		_anchorBlockStageIndex = null;
	}

	private void UpdateRings()
	{
		if ( _rings is null ) return;

		for ( int i = 0; i < _rings.Length; i++ )
		{
			// Evenly stagger each ring across the cycle so the expansion is continuous.
			float t = _phase + (float)i / NUM_RINGS;
			t -= MathF.Floor( t );

			// Grow fast then settle toward full size.
			float w = Utils.Map( t, 0f, 1f, RING_MIN_SIZE.x, RING_FULL_SIZE.x, true, EasingType.SineEaseOut );
			float h = Utils.Map( t, 0f, 1f, RING_MIN_SIZE.y, RING_FULL_SIZE.y, true, EasingType.SineEaseOut );

			// Fade in quickly, hold, then fade out over the back half — so a ring is visible while it's
			// actually expanding and never pops in/out at full/zero size.
			float alpha = MathF.Min( MathF.Min( t / 0.12f, (1f - t) / 0.5f ), 1f );
			if ( alpha < 0f ) alpha = 0f;

			var sr = _rings[i];
			sr.Size = new Vector2( w, h );
			Color baseColor = (i % 2 == 0) ? RING_COLOR_A : RING_COLOR_B;
			sr.Color = baseColor.WithAlpha( alpha );
		}
	}

	/// <summary>Show the velocity the player will receive on the next swap.</summary>
	public void SetStoredVelocityPointer( Vector2 velocity, bool enabled, PortalPointerSettings settings )
	{
		_pointerVelocity = velocity;
		_pointerEnabled = enabled;
		_pointerSettings = settings;
		UpdatePointer();
	}

	private void UpdatePointer()
	{
		_pointerVertices.Clear();
		if ( _pointerSceneObject is null ) return;

		float magnitude = _pointerVelocity.Length;
		if ( !_pointerEnabled || magnitude <= POINTER_HIDE_SPEED ) return;

		Vector2 direction = _pointerVelocity / magnitude;
		Vector2 perpendicular = new( -direction.y, direction.x );
		float sizeFraction = PointerSizeFraction( magnitude );
		float minOffset = Math.Max( 0f, _pointerSettings.MinOffset );
		float maxOffset = Math.Max( minOffset, _pointerSettings.MaxOffset );
		float baseOffset = minOffset + (maxOffset - minOffset) * sizeFraction;
		float oscillationDistance = LerpOrdered(
			_pointerSettings.MinOscillationDistance,
			_pointerSettings.MaxOscillationDistance,
			sizeFraction
		);
		float oscillation = MathF.Sin( _pointerOscillationPhase * MathF.PI * 2f ) * Math.Max( 0f, oscillationDistance );
		float length = POINTER_MIN_LENGTH + (POINTER_MAX_LENGTH - POINTER_MIN_LENGTH) * sizeFraction;
		float halfWidth = 1.8f + 0.8f * sizeFraction;
		Vector2 center = Pos + direction * (baseOffset + oscillation);
		Vector2 tip = center + direction * length * 0.55f;
		Vector2 rearCenter = center - direction * length * 0.45f;
		Vector2 rearA = rearCenter + perpendicular * halfWidth;
		Vector2 rearB = rearCenter - perpendicular * halfWidth;
		float z = Globals.DepthToZ( Globals.DEPTH_BLOCK ) + POINTER_CHILD_ORDER * SpriteLayer.LAYER_Z_STEP;
		float pulse = 0.5f - 0.5f * MathF.Cos( _pointerPulsePhase * MathF.PI * 2f );
		float minOpacity = Math.Clamp( _pointerSettings.MinOpacity, 0f, 1f );
		float maxOpacity = Math.Clamp( _pointerSettings.MaxOpacity, minOpacity, 1f );
		float opacityFraction = MathF.Pow( sizeFraction, Math.Max( 0.01f, _pointerSettings.OpacityPower ) );
		float opacity = minOpacity + (maxOpacity - minOpacity) * opacityFraction;
		Color32 color = Color.Lerp( _pointerSettings.ColorA, _pointerSettings.ColorB, pulse ).WithAlpha( opacity );
		_pointerVertices.Add( new Vertex( new Vector3( rearA.x, rearA.y, z ), color ) );
		_pointerVertices.Add( new Vertex( new Vector3( rearB.x, rearB.y, z ), color ) );
		_pointerVertices.Add( new Vertex( new Vector3( tip.x, tip.y, z ), color ) );
		// Vertices are already world-space; this transform supplies the translucent object's sort position.
		_pointerSceneObject.Transform = new Transform( new Vector3( center.x, center.y, z ) );
	}

	private static float PointerSizeFraction( float magnitude )
	{
		float fraction = Math.Clamp(
			(magnitude - POINTER_HIDE_SPEED) / (POINTER_FULL_SIZE_SPEED - POINTER_HIDE_SPEED),
			0f,
			1f
		);
		return MathF.Sin( fraction * MathF.PI * 0.5f );
	}

	private static float LerpOrdered( float min, float max, float fraction )
	{
		min = Math.Max( 0f, min );
		max = Math.Max( min, max );
		return min + (max - min) * fraction;
	}

	private static float WrapPhase( float phase ) => phase - MathF.Floor( phase );

	private void RenderPointer( SceneObject sceneObject )
	{
		if ( _pointerVertices.Count == 0 || _pointerMaterial is null ) return;
		Graphics.Draw( _pointerVertices, _pointerVertices.Count, _pointerMaterial, primitiveType: Graphics.PrimitiveType.Triangles );
	}

	/// <summary>Teleport the portal in world space (used by the swap), detaching it from any block.</summary>
	public void MoveTo( Vector2 pos )
	{
		_anchorBlockStageIndex = null;
		Pos = pos;
	}

	/// <summary>Anchor the portal to a block while preserving the exact world-space impact position.</summary>
	public void MoveToBlock( Vector2 pos, Block block )
	{
		_anchorBlockStageIndex = block.StageIndex;
		_anchorOffset = pos - block.Pos;
		Pos = pos;
	}

	/// <summary>Place the portal just outside a contacted solid face, using its visual footprint.</summary>
	public void MoveToSurface( Vector2 impact, Direction surfaceFace, float padding )
	{
		_anchorBlockStageIndex = null;
		Vector2 normal = Globals.GetVectorForDirection( surfaceFace );
		float halfExtent = normal.x != 0f ? Width * 0.5f : Height * 0.5f;
		Pos = impact + normal * (halfExtent + Math.Max( 0f, padding ));
	}
}