Entities/ProjectileCollision.cs
namespace BlockParty;

/// <summary>
/// Swept collision shared by the player-fired projectiles (<see cref="Bullet"/>, <see cref="SwapperProjectile"/>).
/// Every test sweeps the projectile's rect along its WHOLE step (relative to the solid's own
/// <see cref="Block.StepDisplacement"/> for blocks), so a step that starts inside a solid or jumps clean
/// over a thin one still registers, and <see cref="TryFindFirstHit"/> ranks blocks, obstacles and the
/// arena walls by entry time so only the FIRST surface along the step is struck — a block behind a
/// wall the step also crosses is never pressed.
/// </summary>
internal static class ProjectileCollision
{
	public enum HitKind { None, Block, Obstacle, Wall }

	public struct Hit
	{
		public HitKind Kind;
		/// <summary>Entry time along the step (0 = previousPos, 1 = the current position); negative when the
		/// two already overlapped at the start.</summary>
		public float Time;
		public Vector2 Point;
		/// <summary>The struck face of the solid, or None when no face was crossed (the solid materialised
		/// onto the projectile, or there was no relative motion at all).</summary>
		public Direction Side;
		public Block Block;
		public Entity2D Obstacle;
	}

	/// <summary>The earliest solid the projectile's step from <paramref name="previousPos"/> to its current
	/// position strikes, across live blocks, interior obstacles and the arena walls. Ties keep the first
	/// found (blocks, then obstacles, then walls).</summary>
	public static bool TryFindFirstHit( Entity2D projectile, GameStage stage, Vector2 previousPos, Vector2 travelDirection, out Hit hit )
	{
		hit = new Hit { Time = float.PositiveInfinity };

		foreach ( Block block in stage.GetBlocks() )
		{
			if ( !TryGetBlockImpact( projectile, block, previousPos, out float time, out Vector2 point, out Direction side ) ) continue;
			if ( time >= hit.Time ) continue;
			hit = new Hit { Kind = HitKind.Block, Time = time, Point = point, Side = side, Block = block };
		}

		foreach ( Entity2D ob in stage.GetSolidObstacles() )
		{
			RectF rect = ob.GetRect();
			if ( !TrySweep( projectile, previousPos, rect, Vector2.Zero, out float time, out Direction side ) ) continue;
			if ( time >= hit.Time ) continue;
			Vector2 point = GetImpactPoint( projectile, previousPos, rect, Vector2.Zero, time, side );
			hit = new Hit { Kind = HitKind.Obstacle, Time = time, Point = point, Side = side, Obstacle = ob };
		}

		if ( TryHitArenaWall( projectile, travelDirection, previousPos, out float wallTime, out Vector2 wallPoint, out Direction wallSide ) && wallTime < hit.Time )
			hit = new Hit { Kind = HitKind.Wall, Time = wallTime, Point = wallPoint, Side = wallSide };

		return hit.Kind != HitKind.None;
	}

	private static bool TryGetBlockImpact( Entity2D projectile, Block block, Vector2 previousPos, out float time, out Vector2 impact, out Direction hitSide )
	{
		time = 0f;
		impact = default;
		hitSide = Direction.None;
		if ( block.IsDead || block.PhasingIn ) return false;

		RectF blockRect = block.GetRect();
		// A block that materialised onto the projectile this tick crossed no face: the impact is where the
		// step began and nothing gets pressed.
		if ( block.BecameSolidThisTick && projectile.GetRect( previousPos.x, previousPos.y ).Intersects( blockRect ) )
		{
			impact = previousPos;
			return true;
		}

		Vector2 delta = block.StepDisplacement;
		RectF startRect = new( blockRect.Left - delta.x, blockRect.Bottom - delta.y, blockRect.Right - delta.x, blockRect.Top - delta.y );
		if ( !TrySweep( projectile, previousPos, startRect, delta, out time, out hitSide ) ) return false;
		impact = GetImpactPoint( projectile, previousPos, startRect, delta, time, hitSide );
		return true;
	}

	/// <summary>Sweep the projectile's rect from <paramref name="previousPos"/> to its current position against a
	/// solid that starts at <paramref name="solidStart"/> and moves by <paramref name="solidDelta"/> over the same
	/// step. Overlap is strict (touching edges never collide, matching <see cref="RectF.Intersects"/>). The
	/// entry time is negative when the two already overlap at the start; the face is None only when there is
	/// no relative motion at all.</summary>
	public static bool TrySweep( Entity2D projectile, Vector2 previousPos, RectF solidStart, Vector2 solidDelta, out float entryTime, out Direction hitSide )
	{
		entryTime = 0f;
		hitSide = Direction.None;
		Vector2 rel = (projectile.Pos - previousPos) - solidDelta;

		// Minkowski sum: sweep the projectile's centre as a point through the solid grown by the projectile's half-size.
		float halfWidth = projectile.Width * 0.5f;
		float halfHeight = projectile.Height * 0.5f;
		if ( !AxisSpan( previousPos.x, rel.x, solidStart.Left - halfWidth, solidStart.Right + halfWidth, out float entryX, out float exitX ) ) return false;
		if ( !AxisSpan( previousPos.y, rel.y, solidStart.Bottom - halfHeight, solidStart.Top + halfHeight, out float entryY, out float exitY ) ) return false;

		float entry = MathF.Max( entryX, entryY );
		float exit = MathF.Min( exitX, exitY );
		if ( entry >= exit || entry >= 1f || exit <= 0f ) return false;

		entryTime = entry;
		hitSide = entryX >= entryY
			? (rel.x > 0f ? Direction.Left : rel.x < 0f ? Direction.Right : Direction.None)
			: (rel.y > 0f ? Direction.Down : rel.y < 0f ? Direction.Up : Direction.None);
		return true;
	}

	// Times at which a point moving from p at v enters and leaves the open span (min, max). A stationary
	// point is inside forever or never.
	private static bool AxisSpan( float p, float v, float min, float max, out float entry, out float exit )
	{
		if ( v == 0f )
		{
			entry = float.NegativeInfinity;
			exit = float.PositiveInfinity;
			return p > min && p < max;
		}
		float a = (min - p) / v;
		float b = (max - p) / v;
		entry = MathF.Min( a, b );
		exit = MathF.Max( a, b );
		return true;
	}

	/// <summary>Where the step touched the solid: on the struck face at the entry time, at the projectile's
	/// centre clamped to that face's extent. With no face, the projectile's own position.</summary>
	private static Vector2 GetImpactPoint( Entity2D projectile, Vector2 previousPos, RectF solidStart, Vector2 solidDelta, float entryTime, Direction hitSide )
	{
		if ( hitSide == Direction.None ) return projectile.Pos;

		Vector2 centerAtImpact = previousPos + (projectile.Pos - previousPos) * entryTime;
		float rectLeft = solidStart.Left + solidDelta.x * entryTime;
		float rectRight = solidStart.Right + solidDelta.x * entryTime;
		float rectBottom = solidStart.Bottom + solidDelta.y * entryTime;
		float rectTop = solidStart.Top + solidDelta.y * entryTime;
		return hitSide switch
		{
			Direction.Left => new Vector2( rectLeft, Math.Clamp( centerAtImpact.y, rectBottom, rectTop ) ),
			Direction.Right => new Vector2( rectRight, Math.Clamp( centerAtImpact.y, rectBottom, rectTop ) ),
			Direction.Down => new Vector2( Math.Clamp( centerAtImpact.x, rectLeft, rectRight ), rectBottom ),
			_ => new Vector2( Math.Clamp( centerAtImpact.x, rectLeft, rectRight ), rectTop ),
		};
	}

	/// <summary>The arena wall in the travel direction, if the step reached it. Only the wall ahead can be
	/// reached, so the end rect suffices (the walls are far too thick to jump).</summary>
	private static bool TryHitArenaWall( Entity2D projectile, Vector2 travelDirection, Vector2 previousPos, out float bestTime, out Vector2 point, out Direction hitSide )
	{
		float left = Arena.WALL_SIZE;
		float right = Arena.WIDTH - Arena.WALL_SIZE;
		float bottom = Arena.WALL_SIZE;
		float top = Arena.HEIGHT - Arena.WALL_SIZE;
		Vector2 delta = projectile.Pos - previousPos;
		bestTime = float.PositiveInfinity;
		point = default;
		hitSide = Direction.None;

		if ( travelDirection.x > 0f && projectile.Right >= right )
		{
			float time = (right - projectile.Width * 0.5f - previousPos.x) / delta.x;
			if ( time < bestTime ) { bestTime = time; point = new Vector2( right, Math.Clamp( previousPos.y + delta.y * time, bottom, top ) ); hitSide = Direction.Left; }
		}
		else if ( travelDirection.x < 0f && projectile.Left <= left )
		{
			float time = (left + projectile.Width * 0.5f - previousPos.x) / delta.x;
			if ( time < bestTime ) { bestTime = time; point = new Vector2( left, Math.Clamp( previousPos.y + delta.y * time, bottom, top ) ); hitSide = Direction.Right; }
		}

		if ( travelDirection.y > 0f && projectile.Top >= top )
		{
			float time = (top - projectile.Height * 0.5f - previousPos.y) / delta.y;
			if ( time < bestTime ) { bestTime = time; point = new Vector2( Math.Clamp( previousPos.x + delta.x * time, left, right ), top ); hitSide = Direction.Down; }
		}
		else if ( travelDirection.y < 0f && projectile.Bottom <= bottom )
		{
			float time = (bottom + projectile.Height * 0.5f - previousPos.y) / delta.y;
			if ( time < bestTime ) { bestTime = time; point = new Vector2( Math.Clamp( previousPos.x + delta.x * time, left, right ), bottom ); hitSide = Direction.Up; }
		}

		return bestTime < float.PositiveInfinity;
	}
}