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