Transposer/Entities/Player.cs
namespace Sandbox.Transposer;

/// <summary>
/// The player entity. Moves in 4 directions, constantly drips blood particles,
/// becomes invincible (rainbow colours) after collecting a coin, and dies on
/// enemy contact with a dramatic blood explosion.
/// </summary>
public class Player : Entity
{
	private const float MOVE_SPEED = 44f;
	private const float INVINCIBLE_TIME = 1.33f;
	private const float BLOOD_DRIP_INTERVAL = 1f / 30f; // ~30 drips per second

	private bool _isMoving;
	private bool _isMovingLeft;

	private bool _isInvincible;
	private float _invincibleTimer;
	private float _bloodDripTimer;

	private bool _isDead;
	public bool IsDead => _isDead;

	public Player( int x, int y, TransposerScene scene )
	{
		PixelPosition = new PixelPoint( x, y );
		_scene = scene;
		_type = "player";
		PlayAnimation( "idle" );
		UpdateSpurtAnimationCallback();
		_collideable = true;
		_layer = Globals.DEPTH_PLAYER;
	}

	public override void UpdateEntity( float deltaTime )
	{
		base.UpdateEntity( deltaTime );

		if ( _isDead )
			return;

		// ── Build combined move direction ────────────────────────────────
		Vector2 moveDir = Vector2.Zero;

		if ( InputManager.LeftPressed )
		{
			moveDir += new Vector2( -1, 0 );
			if ( !_isMoving || !_isMovingLeft )
			{
				_isMoving = true;
				_isMovingLeft = true;
				PlayAnimation( "walk_left" );
				_flipX = false;
				UpdateSpurtAnimationCallback();
			}
		}
		else if ( InputManager.RightPressed )
		{
			moveDir += new Vector2( 1, 0 );
			if ( !_isMoving || _isMovingLeft )
			{
				_isMoving = true;
				_isMovingLeft = false;
				PlayAnimation( "walk_left" );
				_flipX = true;
				UpdateSpurtAnimationCallback();
			}
		}

		if ( InputManager.UpPressed )
		{
			moveDir += new Vector2( 0, 1 );
			if ( !_isMoving )
			{
				_isMoving = true;
				PlayAnimation( "walk_left" );
				_flipX = !_isMovingLeft;
				UpdateSpurtAnimationCallback();
			}
		}
		else if ( InputManager.DownPressed )
		{
			moveDir += new Vector2( 0, -1 );
			if ( !_isMoving )
			{
				_isMoving = true;
				PlayAnimation( "walk_left" );
				_flipX = !_isMovingLeft;
				UpdateSpurtAnimationCallback();
			}
		}

		// Normalize so diagonal speed equals cardinal speed, then apply analog scale.
		if ( moveDir.LengthSquared > 0f )
			moveDir = moveDir.Normal * InputManager.MoveSpeedScale;

		// Slow the walk animation proportionally when the stick is only partly pushed.
		_animationTimeScale = InputManager.MoveSpeedScale;

		Move( moveDir, deltaTime );

		// ── Stop animation ───────────────────────────────────────────────
		if ( _isMoving && !InputManager.LeftPressed && !InputManager.RightPressed
			&& !InputManager.UpPressed && !InputManager.DownPressed )
		{
			PlayAnimation( "idle2" );
			_isMoving = false;
			UpdateSpurtAnimationCallback();
		}

		// ── Invincibility timer ──────────────────────────────────────────
		if ( _isInvincible )
		{
			_invincibleTimer -= deltaTime;
			if ( _invincibleTimer <= 0f )
			{
				_isInvincible = false;
				UpdateSpurtAnimationCallback();
			}
		}
		else
		{
			// Constant blood drip, framerate-independent.
			_bloodDripTimer -= deltaTime;
			if ( _bloodDripTimer <= 0f )
			{
				// += instead of = prevents drift: if a frame takes 2× the interval,
				// the next drip fires sooner to compensate rather than losing time.
				_bloodDripTimer += BLOOD_DRIP_INTERVAL;
				int bx = PixelX + 5 + (Game.Random.Next( 0, 2 ) == 0 ? 1 : 0);
				int by = PixelY + 2;
				((GameScene)_scene).CreateBloodParticle( bx, by, Vector2.Zero,
					3f, Game.Random.Float( -2f, -1f ) );
			}
		}
	}

	public void BecomeInvincible()
	{
		_isInvincible = true;
		_invincibleTimer = INVINCIBLE_TIME;
		ClearSetFrameCallbacks();
	}

	public override void Draw()
	{
		if ( _isInvincible )
		{
			// Rainbow effect: each pixel gets an independent random colour every frame.
			// SetOverriddenColor can't do this (it applies one colour to all pixels),
			// so we bypass base.Draw() and write each pixel manually.
			// The colour range shrinks as _invincibleTimer decreases, fading the rainbow
			// back toward a neutral yellow before it ends.
			List<PixelData> pixelDataList = GetPixelDataList();
			if ( pixelDataList is null ) return;

			foreach ( PixelData pd in pixelDataList )
			{
				PixelPoint pos = PixelPosition + pd.Position;
				Color color = new(
					0.75f + Game.Random.Float( -1f, 1f ) * _invincibleTimer,
					0.75f + Game.Random.Float( -1f, 1f ) * _invincibleTimer,
					0.35f + Game.Random.Float( -1f, 1f ) * _invincibleTimer,
					1f );
				Screen.AddPixel( pos.X, pos.Y, (Color32)color );
			}
		}
		else
		{
			base.Draw();
		}
	}

	/// <summary>
	/// Set up walk-frame callbacks for blood spurts and footstep SFX.
	/// </summary>
	private void UpdateSpurtAnimationCallback()
	{
		ClearSetFrameCallbacks();

		if ( _isMoving )
		{
			AddSetFrameCallback( 0, () =>
			{
				int numSpurts = Game.Random.Next( 2, 5 );
				for ( int i = 0; i < numSpurts; i++ )
				{
					int bx = PixelX + 5 + (Game.Random.Next( 0, 2 ) == 0 ? 1 : 0);
					int by = PixelY + 2 + (Game.Random.Next( 0, 2 ) == 0 ? 1 : 0);
					Vector2 vel = new( Game.Random.Float( -80f, 80f ), Game.Random.Float( 50f, 80f ) );
					((GameScene)_scene).CreateBloodParticle( bx, by, vel,
						Game.Random.Float( 4f, 18f ), Game.Random.Float( -10f, -5f ), true );
				}
				AudioManager.PlaySfx( Sfx.Footstep );
			} );

			AddSetFrameCallback( 2, () =>
			{
				AudioManager.PlaySfx( Sfx.Footstep );
			} );
		}
		else
		{
			AddSetFrameCallback( 0, () =>
			{
				int numSpurts = Game.Random.Next( 0, 3 );
				for ( int i = 0; i < numSpurts; i++ )
				{
					int bx = PixelX + 5 + (Game.Random.Next( 0, 2 ) == 0 ? 1 : 0);
					int by = PixelY + 2 + (Game.Random.Next( 0, 2 ) == 0 ? 1 : 0);
					Vector2 vel = new( Game.Random.Float( -80f, 80f ), Game.Random.Float( 50f, 80f ) );
					((GameScene)_scene).CreateBloodParticle( bx, by, vel,
						Game.Random.Float( 4f, 18f ), Game.Random.Float( -10f, -5f ), true );
				}
			} );
		}
	}

	private void Move( Vector2 moveVector, float deltaTime )
	{
		Vector2 delta = moveVector * MOVE_SPEED * deltaTime;
		Vector2 newPos = ExactPos + delta;
		PixelPoint newPx = new( (int)MathF.Round( newPos.x ), (int)MathF.Round( newPos.y ) );

		// Try full diagonal move first.
		if ( IsInBounds( newPx.X, newPx.Y, 3 ) )
		{
			ExactPos = newPos;
			return;
		}

		// Full move was out of bounds — try each axis independently.
		// This lets the player slide along the screen edge instead of stopping dead.
		if ( delta.x != 0f )
		{
			Vector2 hPos = new( ExactPos.x + delta.x, ExactPos.y );
			PixelPoint hPx = new( (int)MathF.Round( hPos.x ), PixelY );
			if ( IsInBounds( hPx.X, hPx.Y, 3 ) )
				ExactPos = hPos;
		}

		if ( delta.y != 0f )
		{
			Vector2 vPos = new( ExactPos.x, ExactPos.y + delta.y );
			PixelPoint vPx = new( PixelX, (int)MathF.Round( vPos.y ) );
			if ( IsInBounds( vPx.X, vPx.Y, 3 ) )
				ExactPos = vPos;
		}
	}

	public void Die()
	{
		if ( _isDead )
			return;

		AudioManager.PlaySfx( Sfx.EnemyTouchPlayer );
		PlayAnimation( "die" );

		AddSetFrameCallback( 6, () =>
		{
			for ( int i = 0; i < 50; i++ )
			{
				int bx = PixelX + 5 + (Game.Random.Next( 0, 2 ) == 0 ? 1 : 0);
				int by = PixelY + 2 + (Game.Random.Next( 0, 2 ) == 0 ? 1 : 0);
				Vector2 vel = new( Game.Random.Float( -80f, 80f ), Game.Random.Float( 50f, 80f ) );
				((GameScene)_scene).CreateBloodParticle( bx, by, vel,
					Game.Random.Float( 4f, 18f ), Game.Random.Float( -10f, -5f ), force: true );
			}

			AddScheduledCallback( 2f, () =>
			{
				_scene.SetFadeToBlackCallback( () => _scene.SwitchScene( SceneType.MainMenu ) );
				_scene.FadeToBlack( 1f );
			} );
		} );

		_isDead = true;
	}
}