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;
}
}