Entities/Entity2D.cs

Entity2D component representing a 2D game object with centre position, velocity, size and depth. Provides AABB helpers, pixel-grid snapping, bounds clamping, penetration detection and resolution, tick and transform sync hooks for per-step simulation and rendering.

Native Interop
namespace BlockParty;

/// <summary>
/// Base for every simulated object. Port of the original <c>BaseEntity</c>: a centre
/// position + size giving an AABB, with the same bounds/penetration helpers.
///
/// Logic runs in logical pixels via <see cref="Tick"/> (driven by <see cref="GameManager"/>
/// in a deterministic order — NOT sbox's OnUpdate/OnFixedUpdate). <see cref="SyncTransform"/>
/// pushes the logical position into the GameObject transform once per rendered frame.
/// </summary>
public class Entity2D : Component
{
	/// <summary>Centre position in logical pixels.</summary>
	public Vector2 Pos { get; set; }

	/// <summary>Velocity in logical pixels / second.</summary>
	public Vector2 Velocity { get; set; }

	/// <summary>AABB size in logical pixels.</summary>
	public Vector2 Size { get; set; }

	/// <summary>Render depth layer (see <see cref="Globals"/>). Higher = nearer the camera.</summary>
	public int Depth { get; set; }

	/// <summary>Marks the entity for removal by its stage at the end of the tick.</summary>
	public bool Dead { get; set; }

	/// <summary>Alias for <see cref="Pos"/> (the original used <c>Position</c> widely).</summary>
	public Vector2 Position => Pos;

	// Convenience accessors mirroring the original X/Y/VelX/VelY API.
	public float X { get => Pos.x; set => Pos = new Vector2( value, Pos.y ); }
	public float Y { get => Pos.y; set => Pos = new Vector2( Pos.x, value ); }
	public float VelX { get => Velocity.x; set => Velocity = new Vector2( value, Velocity.y ); }
	public float VelY { get => Velocity.y; set => Velocity = new Vector2( Velocity.x, value ); }

	public float Width => Size.x;
	public float Height => Size.y;

	public float Left { get => X - Width / 2; set => X = value + Width / 2; }
	public float Right { get => X + Width / 2; set => X = value - Width / 2; }
	public float Bottom { get => Y - Height / 2; set => Y = value + Height / 2; }
	public float Top { get => Y + Height / 2; set => Y = value - Height / 2; }

	public RectF GetRect() => new RectF( X - Width / 2, Y - Height / 2, X + Width / 2, Y + Height / 2 );
	public RectF GetRect( float x, float y ) => new RectF( x - Width / 2, y - Height / 2, x + Width / 2, y + Height / 2 );

	/// <summary>The entity's AABB snapped to the integer pixel grid the sprites render on, by aligning
	/// its bottom-left CORNER (not its centre). Centre-rounding (MathF.Round on the centre) corrupts an
	/// odd-sized rect: e.g. a 35px-wide obstacle has a .5 centre, and MathF.Round shifts it a FULL pixel
	/// — so its collision edges (and rendered fill) drift half a pixel off the authored integer edges,
	/// leaving moving blocks unable to rest flush against it (they read as perpetually penetrating and
	/// re-pick a direction forever). Corner-alignment keeps integer-authored edges exact and is a no-op
	/// for the even-sized blocks/player (their centre is already on the grid).</summary>
	public RectF GetPixelRect( float x, float y )
	{
		float left = MathF.Round( x - Width / 2f );
		float bottom = MathF.Round( y - Height / 2f );
		return new RectF( left, bottom, left + Width, bottom + Height );
	}

	/// <summary>Per-step simulation hook. Override in entity subclasses.</summary>
	public virtual void Tick( float dt ) { }

	/// <summary>Push the logical position/depth into the GameObject transform (called per render frame).
	/// Snaps by the pixel-grid rect so odd-sized entities (e.g. interior obstacles) keep their authored
	/// integer edges instead of drifting half a pixel; identical to plain centre-rounding for the
	/// even-sized blocks/player.</summary>
	public virtual void SyncTransform()
	{
		RectF r = GetPixelRect( Pos.x, Pos.y );
		WorldPosition = new Vector3( r.Left + Width / 2f, r.Bottom + Height / 2f, Globals.DepthToZ( Depth ) );
	}

	// ----------------------------------------------------------------------------------------
	protected bool IsInBounds( float x, float y, Direction direction )
	{
		bool inBounds = true;
		RectF rect = GetRect( x, y );

		if ( direction == Direction.Left && rect.Left < Arena.WALL_SIZE )
			inBounds = false;
		else if ( direction == Direction.Right && rect.Right > Arena.WIDTH - Arena.WALL_SIZE )
			inBounds = false;

		if ( direction == Direction.Down && rect.Bottom < Arena.WALL_SIZE )
			inBounds = false;
		else if ( direction == Direction.Up && rect.Top > Arena.HEIGHT - Arena.WALL_SIZE )
			inBounds = false;

		return inBounds;
	}

	// ----------------------------------------------------------------------------------------
	protected virtual bool ClampToBounds( float x, float y )
	{
		bool inBounds = true;
		RectF rect = GetRect( x, y );

		if ( rect.Left < Arena.WALL_SIZE )
		{
			Left = Arena.WALL_SIZE;
			inBounds = false;
		}
		else if ( rect.Right > Arena.WIDTH - Arena.WALL_SIZE )
		{
			Right = Arena.WIDTH - Arena.WALL_SIZE;
			inBounds = false;
		}

		if ( rect.Bottom < Arena.WALL_SIZE )
		{
			Bottom = Arena.WALL_SIZE;
			inBounds = false;
		}
		else if ( rect.Top > Arena.HEIGHT - Arena.WALL_SIZE )
		{
			Top = Arena.HEIGHT - Arena.WALL_SIZE;
			inBounds = false;
		}

		return inBounds;
	}

	// ----------------------------------------------------------------------------------------
	public bool DoesPenetrate( float x, float y, Entity2D other )
	{
		return GetRect( x, y ).Intersects( other.GetRect() );
	}

	// ----------------------------------------------------------------------------------------
	public virtual bool Unpenetrate( float x, float y, Entity2D other )
	{
		if ( !GetRect( x, y ).Intersects( other.GetRect() ) )
			return false;

		RectF a = GetRect( x, y );
		RectF b = other.GetRect();

		// The amount we'd have to move in each direction to unpenetrate.
		float leftAmt = a.Right - b.Left;
		float rightAmt = b.Right - a.Left;
		float downAmt = a.Top - b.Bottom;
		float upAmt = b.Top - a.Bottom;

		Direction direction = Direction.None;
		float currAmt = float.MaxValue;

		if ( leftAmt > 0.0f && leftAmt < currAmt ) { direction = Direction.Left; currAmt = leftAmt; }
		if ( rightAmt > 0.0f && rightAmt < currAmt ) { direction = Direction.Right; currAmt = rightAmt; }
		if ( downAmt > 0.0f && downAmt < currAmt ) { direction = Direction.Down; currAmt = downAmt; }
		if ( upAmt > 0.0f && upAmt < currAmt ) { direction = Direction.Up; currAmt = upAmt; }

		if ( direction == Direction.None )
			return false;

		switch ( direction )
		{
			case Direction.Left: X = b.Left - Width / 2; break;
			case Direction.Right: X = b.Right + Width / 2; break;
			case Direction.Down: Y = b.Bottom - Height / 2; break;
			case Direction.Up: Y = b.Top + Height / 2; break;
		}

		return true;
	}

	/// <summary>The axis a min-penetration eject resolved along, so a caller knows which velocity
	/// component to reflect. <see cref="None"/> means the rects weren't overlapping (no-op).</summary>
	protected enum ReflectAxis { None, Horizontal, Vertical }

	/// <summary>
	/// Snap this entity flush out of the shallowest-overlap ("minimum translation") face of
	/// <paramref name="b"/> and report which axis was resolved, leaving the velocity reflection to the
	/// caller (fireballs flip a component + sprite; particles just flip a component). This is the shared
	/// geometry behind the fireball and particle obstacle bounces; it mirrors the same minimum-translation
	/// choice as <see cref="Unpenetrate"/> (which stays separate: it's a position-only, overridable
	/// resolution on the player physics path and doesn't reflect velocity).
	/// </summary>
	protected ReflectAxis ResolveMinPenetration( RectF b )
	{
		RectF a = GetRect();
		if ( !a.Intersects( b ) )
			return ReflectAxis.None;

		float leftAmt = a.Right - b.Left;   // push-out distance to the LEFT
		float rightAmt = b.Right - a.Left;  // ... RIGHT
		float downAmt = a.Top - b.Bottom;   // ... DOWN
		float upAmt = b.Top - a.Bottom;     // ... UP

		float minH = Math.Min( leftAmt, rightAmt );
		float minV = Math.Min( downAmt, upAmt );

		if ( minH <= minV )
		{
			if ( leftAmt < rightAmt ) Right = b.Left; else Left = b.Right;
			return ReflectAxis.Horizontal;
		}
		else
		{
			if ( downAmt < upAmt ) Top = b.Bottom; else Bottom = b.Top;
			return ReflectAxis.Vertical;
		}
	}
}