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