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