PixelFontTest.cs
using Sandbox.Utility;
namespace Sandbox;
/// <summary>
/// Interactive platformer test for the sprite, hitbox, and collision systems.
///
/// Controls:
/// Left / Right — move horizontally
/// Space — jump (when grounded)
/// F — toggle FlipX on the player
/// H — toggle hitbox debug overlay
///
/// Demonstrates:
/// - Gravity, ground collision, and box collision response
/// - Step-by-step axis-separated movement with test-before-commit
/// - Flip-aware hitboxes on the box sprites
/// - PixelCollision registry, Collide queries, and GetUnpenetration
/// - DrawHitbox / DrawHitboxFilled debug visualisation
/// </summary>
[Title( "Pixel Font Test" )]
[Category( "Testing" )]
[Icon( "text_fields" )]
public sealed class PixelFontTest : Component
{
// ── Sprites ──────────────────────────────────────────────────────────
private PixelSpriteComponent _player;
private List<PixelSpriteComponent> _boxes = new();
// ── Collision entries ────────────────────────────────────────────────
private CollisionEntry _playerEntry;
private List<CollisionEntry> _boxEntries = new();
// ── Positions ────────────────────────────────────────────────────────
private float _playerX = 20f;
private float _playerY = 60f;
private struct BoxPlacement
{
public int X;
public int Y;
public bool FlipX;
public BoxPlacement( int x, int y, bool flipX = false )
{
X = x;
Y = y;
FlipX = flipX;
}
}
private readonly BoxPlacement[] _boxPlacements = new BoxPlacement[]
{
// Ground-level stepping stones (player can walk onto these from the side
// since the box top = 4, well below the player's hitbox top).
new( 50, 0 ),
new( 55, 0 ),
new( 60, 0 ),
// Low step — stack of 2 (top at y=8, reachable with a small jump).
new( 75, 0 ),
new( 75, 5 ),
new( 80, 0 ),
new( 80, 5 ),
// Medium ledge — stack of 3 (top at y=12, near max jump from ground).
new( 95, 0 ),
new( 95, 5 ),
new( 95, 10 ),
new( 100, 0 ),
new( 100, 5 ),
new( 100, 10 ),
// One flipped box to visually confirm asymmetric hitbox mirroring.
new( 105, 0, true ),
};
// ── Physics state ────────────────────────────────────────────────────
private float _velocityX;
private float _velocityY;
private bool _grounded;
// ── State ────────────────────────────────────────────────────────────
private bool _showHitboxes = true;
// ── Configuration ────────────────────────────────────────────────────
[Property]
public Color32 BackgroundColor { get; set; } = new Color32( 30, 30, 50, 255 );
[Property]
public Color32 BodyColor { get; set; } = new Color32( 200, 200, 200, 255 );
[Property]
public float MoveSpeed { get; set; } = 30f;
[Property]
public float Gravity { get; set; } = 100f;
[Property]
public float JumpVelocity { get; set; } = 55f;
// ── Lifecycle ────────────────────────────────────────────────────────
protected override void OnStart()
{
SpriteManager.Reload();
SpriteManager.LoadAll();
// Player.
_player = new PixelSpriteComponent { SpriteName = "player" };
_player.PlayAnimation( "default" );
_player.Tags.Add( "player" );
_playerEntry = PixelCollision.Register( _player, (int)_playerX, (int)_playerY );
// Boxes.
foreach ( BoxPlacement bp in _boxPlacements )
{
PixelSpriteComponent box = new() { SpriteName = "box" };
box.PlayAnimation( "idle" );
box.Tags.Add( "solid" );
box.FlipX = bp.FlipX;
_boxes.Add( box );
_boxEntries.Add( PixelCollision.Register( box, bp.X, bp.Y ) );
}
}
protected override void OnDestroy()
{
PixelCollision.Clear();
}
protected override void OnUpdate()
{
PixelScreen screen = PixelScreen.Instance;
if ( screen is null )
return;
float dt = Time.Delta;
HandleInput( dt );
ApplyPhysics( dt );
// Sync collision entry.
_playerEntry.X = (int)_playerX;
_playerEntry.Y = (int)_playerY;
// Advance animations.
_player?.Update( dt );
// ── Draw ─────────────────────────────────────────────────────
screen.Clear( BackgroundColor );
bool wasGui = screen.DrawInGUISpace;
screen.DrawInGUISpace = true;
// Ground line.
screen.DrawLine( 0, 0, screen.PixelWidth - 1, 0, new Color32( 100, 100, 100, 255 ) );
// Boxes.
for ( int i = 0; i < _boxes.Count; i++ )
{
int bx = _boxPlacements[i].X;
int by = _boxPlacements[i].Y;
_boxes[i].Draw( bx, by );
if ( _showHitboxes )
{
_boxes[i].DrawHitboxFilled( bx, by, new Color32( 50, 100, 255, 60 ) );
_boxes[i].DrawHitbox( bx, by, new Color32( 80, 130, 255, 255 ) );
}
}
// Player.
int px = (int)_playerX;
int py = (int)_playerY;
_player?.Draw( px, py );
if ( _showHitboxes )
{
Color32 hitboxColor = _grounded
? new Color32( 50, 255, 50, 60 )
: new Color32( 255, 200, 50, 60 );
_player?.DrawHitboxFilled( px, py, hitboxColor );
_player?.DrawHitbox( px, py, new Color32( 50, 255, 50, 255 ) );
}
// ── HUD ──────────────────────────────────────────────────────
int textY = screen.PixelHeight - 7;
PixelText.Draw( 2, textY, "left right move", BodyColor );
textY -= 7;
PixelText.Draw( 2, textY, "space jump", BodyColor );
textY -= 7;
PixelText.Draw( 2, textY, "f flip h hitbox", BodyColor );
textY -= 9;
if ( _player?.FlipX == true )
{
PixelText.Draw( 2, textY, "flipped", new Color32( 255, 200, 50, 255 ) );
textY -= 7;
}
if ( _grounded )
PixelText.Draw( 2, textY, "grounded", new Color32( 50, 255, 50, 255 ) );
else
PixelText.Draw( 2, textY, "airborne", new Color32( 255, 150, 50, 255 ) );
screen.DrawInGUISpace = wasGui;
}
// ── Input ────────────────────────────────────────────────────────────
private void HandleInput( float dt )
{
// Horizontal input → velocity.
_velocityX = 0f;
if ( Input.Down( "Left" ) ) _velocityX -= MoveSpeed;
if ( Input.Down( "Right" ) ) _velocityX += MoveSpeed;
// Face the direction of movement.
if ( _player is not null )
{
if ( _velocityX < 0f ) _player.FlipX = true;
else if ( _velocityX > 0f ) _player.FlipX = false;
}
// Jump.
if ( Input.Pressed( "Start" ) && _grounded )
{
_velocityY = JumpVelocity;
_grounded = false;
}
// Toggles.
if ( Input.Pressed( "Flashlight" ) && _player is not null )
_player.FlipX = !_player.FlipX;
if ( Input.Keyboard.Pressed( "H" ) )
_showHitboxes = !_showHitboxes;
}
// ── Physics + collision response ─────────────────────────────────────
private void ApplyPhysics( float dt )
{
// Only apply gravity when airborne. This prevents velocity from
// building up while standing on something and then causing a big
// downward snap when sliding off an edge.
if ( !_grounded )
_velocityY -= Gravity * dt;
// Clamp downward velocity to avoid tunnelling through thin platforms.
const float maxFallSpeed = -80f;
if ( _velocityY < maxFallSpeed )
_velocityY = maxFallSpeed;
// Compute desired movement in pixels (sub-pixel accumulation is handled
// by keeping _playerX/_playerY as floats).
float desiredX = _playerX + _velocityX * dt;
float desiredY = _playerY + _velocityY * dt;
// ── Move X axis first, then Y. Test before commit. ──────────
// X axis.
int testX = (int)desiredX;
int testY = (int)_playerY;
if ( PixelCollision.Collide( _player, "solid", testX, testY ) is null )
{
_playerX = desiredX;
}
else
{
// Blocked horizontally — zero X velocity and keep current X.
_velocityX = 0f;
}
// Y axis.
testX = (int)_playerX;
testY = (int)desiredY;
if ( PixelCollision.Collide( _player, "solid", testX, testY ) is null )
{
_playerY = desiredY;
}
else
{
// Blocked vertically — compute a Y-only correction so the player
// sits flush against the surface. We don't use GetUnpenetration here
// because with axis-separated movement we know the fix must be on Y,
// but the general solver might pick X when edges are exactly aligned.
List<CollisionEntry> hits = PixelCollision.CollideWithAll( _player, "solid", testX, testY );
int fixedY = testY;
foreach ( CollisionEntry hit in hits )
{
PixelRect playerBox = _player.GetOffsetHitbox( testX, fixedY );
PixelRect otherBox = hit.Sprite.GetOffsetHitbox( hit.X, hit.Y );
if ( !playerBox.Overlaps( otherBox ) )
continue;
if ( _velocityY <= 0f )
{
// Falling — snap player's bottom to top of box.
fixedY += otherBox.YMax - playerBox.YMin;
}
else
{
// Rising — snap player's top to bottom of box.
fixedY += otherBox.YMin - playerBox.YMax;
}
}
_playerY = fixedY;
// If we were falling and got stopped, we landed.
if ( _velocityY < 0f )
_grounded = true;
_velocityY = 0f;
}
// Ground plane at y = 0.
PixelRect groundCheck = _player.GetOffsetHitbox( (int)_playerX, (int)_playerY );
if ( groundCheck.YMin < 0 )
{
_playerY -= groundCheck.YMin; // push up so YMin == 0
_velocityY = 0f;
_grounded = true;
}
// Detect if we walked off an edge — check one pixel below for ground or solid.
if ( _grounded && _velocityY <= 0f )
{
int belowY = (int)_playerY - 1;
PixelRect belowBox = _player.GetOffsetHitbox( (int)_playerX, belowY );
bool onSolid = PixelCollision.Collide( _player, "solid", (int)_playerX, belowY ) is not null;
bool onGround = belowBox.YMin < 0;
if ( !onSolid && !onGround )
{
_grounded = false;
// Start with a small downward nudge so the first airborne frame
// actually moves the player and doesn't get stuck at the same Y.
_velocityY = -Gravity * dt;
}
}
}
}