PixelSpriteComponent.cs
namespace Sandbox;
/// <summary>
/// Manages sprite animation playback and renders the current frame's pixels into
/// the <see cref="PixelScreen"/> buffer.
///
/// This is a pure-C# object (not a <see cref="Component"/>) — identical to the
/// Unity framework's design where entities own a SpriteComponent directly.
/// Call <see cref="Update"/> each frame to advance animation, and <see cref="Draw"/>
/// to write pixels.
///
/// Supports flip, palette swap (colour override), per-frame hitboxes with
/// flip-aware transforms, per-frame callbacks, and all four loop modes
/// (Loops, PlayOnce, PingPong, RandomFrame).
/// </summary>
public class PixelSpriteComponent
{
// ── Public state ─────────────────────────────────────────────────────
/// <summary>Sprite type name used to look up animations in <see cref="SpriteManager"/>.</summary>
public string SpriteName { get; set; }
/// <summary>The currently playing animation, or null.</summary>
public AnimationData CurrentAnimation { get; private set; }
/// <summary>Current frame index within <see cref="CurrentAnimation"/>.</summary>
public int CurrentFrameNumber { get; private set; }
/// <summary>Current frame data (pixels + timing).</summary>
public FrameData CurrentFrame { get; private set; }
/// <summary>Mirror pixels horizontally.</summary>
public bool FlipX { get; set; }
/// <summary>Mirror pixels vertically.</summary>
public bool FlipY { get; set; }
/// <summary>Playback speed multiplier. Negative plays in reverse.</summary>
public float AnimTimeScale { get; set; } = 1f;
// ── Collision ────────────────────────────────────────────────────────
/// <summary>Whether this sprite participates in collision queries.</summary>
public bool Collideable { get; set; } = true;
/// <summary>Tags used to filter this sprite during collision queries.</summary>
public List<string> Tags { get; set; } = new();
/// <summary>
/// The local-space hitbox for the current frame.
/// Uses the per-frame hitbox if defined, otherwise falls back to the animation-level hitbox.
/// Returns a zero rect if no animation is playing.
/// </summary>
public PixelRect Hitbox
{
get
{
if ( CurrentAnimation is null )
return default;
return CurrentFrame?.Hitbox ?? CurrentAnimation.Hitbox;
}
}
/// <summary>
/// Get the world-space hitbox at the given entity position, accounting for
/// <see cref="FlipX"/> and <see cref="FlipY"/>.
/// </summary>
public PixelRect GetOffsetHitbox( int x, int y )
{
PixelRect box = Hitbox;
if ( CurrentAnimation is not null )
{
int animW = CurrentAnimation.AnimSize.X;
int animH = CurrentAnimation.AnimSize.Y;
// Mirror the hitbox offset when flipped so it stays aligned with the visible pixels.
if ( FlipX )
box = new PixelRect( animW - box.Left - box.Width, box.Bottom, box.Width, box.Height );
if ( FlipY )
box = new PixelRect( box.Left, animH - box.Bottom - box.Height, box.Width, box.Height );
}
return box + new PixelPoint( x, y );
}
/// <summary>
/// Look up the animation-level hitbox for any animation by name.
/// Useful for checking a hitbox before playing the animation.
/// </summary>
public PixelRect GetAnimHitbox( string animName )
{
AnimationData anim = SpriteManager.GetAnimation( SpriteName, animName );
return anim?.Hitbox ?? default;
}
/// <summary>
/// Compute the minimum displacement needed to push this sprite out of
/// overlap with <paramref name="other"/>. Returns <see cref="PixelPoint.Zero"/>
/// if there is no overlap.
/// </summary>
public PixelPoint GetUnpenetration( int myX, int myY, PixelSpriteComponent other, int otherX, int otherY )
{
PixelRect a = GetOffsetHitbox( myX, myY );
PixelRect b = other.GetOffsetHitbox( otherX, otherY );
if ( !a.Overlaps( b ) )
return PixelPoint.Zero;
// Minimum Translation Vector (MTV): compute penetration depth on all four
// sides, then push along whichever axis has the smallest overlap.
// This produces the shortest possible displacement to separate the two rects.
int pushLeft = a.XMax - b.XMin; // depth if we push 'a' left
int pushRight = b.XMax - a.XMin; // depth if we push 'a' right
int pushDown = a.YMax - b.YMin; // depth if we push 'a' down
int pushUp = b.YMax - a.YMin; // depth if we push 'a' up
int minX = pushLeft < pushRight ? -pushLeft : pushRight;
int minY = pushDown < pushUp ? -pushDown : pushUp;
if ( Math.Abs( minX ) <= Math.Abs( minY ) )
return new PixelPoint( minX, 0 );
return new PixelPoint( 0, minY );
}
// ── Callbacks ────────────────────────────────────────────────────────
/// <summary>Fired when a <see cref="LoopMode.PlayOnce"/> animation finishes.</summary>
public Action OnAnimationComplete { get; set; }
private Dictionary<int, Action> _frameCallbacks = new();
public void AddFrameCallback( int frameNumber, Action callback ) => _frameCallbacks[frameNumber] = callback;
public void ClearFrameCallbacks() => _frameCallbacks.Clear();
// ── Colour overrides (palette swap) ──────────────────────────────────
private Dictionary<uint, Color32> _colorOverrides = new();
/// <summary>
/// When drawing, any pixel whose RGB matches <paramref name="from"/> will be
/// replaced with <paramref name="to"/>. Useful for palette-swapping.
/// </summary>
public void AddColorOverride( Color32 from, Color32 to )
{
uint key = PackRgb( from );
_colorOverrides[key] = to;
}
public void RemoveColorOverride( Color32 from ) => _colorOverrides.Remove( PackRgb( from ) );
public void ClearColorOverrides() => _colorOverrides.Clear();
/// <summary>When true, palette swap preserves the original pixel's alpha.</summary>
public bool PreserveAlphaOnOverride { get; set; }
// ── Internal state ───────────────────────────────────────────────────
private float _frameTimer;
private bool _pingPongForward = true;
private bool _playOnceFinished;
private LoopMode? _loopModeOverride;
// ── Animation control ────────────────────────────────────────────────
/// <summary>
/// Start playing a named animation. No-op if already playing the same animation.
/// </summary>
public void PlayAnimation( string animName )
{
// No-op if already playing — prevents restarting from frame 0 on every
// call when the caller doesn't track whether the animation changed.
if ( CurrentAnimation is not null && CurrentAnimation.Name == animName )
return;
AnimationData anim = SpriteManager.GetAnimation( SpriteName, animName );
if ( anim is null )
return;
CurrentAnimation = anim;
_loopModeOverride = null;
_playOnceFinished = false;
_pingPongForward = true;
SetFrame( 0 );
_frameTimer = 0f;
}
/// <summary>
/// Start playing an animation with a one-off loop-mode override.
/// </summary>
public void PlayAnimation( string animName, LoopMode loopOverride )
{
PlayAnimation( animName );
_loopModeOverride = loopOverride;
}
/// <summary>
/// Advance animation timing by <paramref name="deltaTime"/> seconds.
/// Call once per frame.
/// </summary>
public void Update( float deltaTime )
{
if ( CurrentAnimation is null )
return;
LoopMode loop = _loopModeOverride ?? CurrentAnimation.LoopMode;
int frameCount = CurrentAnimation.Frames.Count;
_frameTimer += deltaTime * AnimTimeScale;
if ( _frameTimer < CurrentFrame.AnimTime )
return;
// Time to advance.
_frameTimer = 0f;
switch ( loop )
{
case LoopMode.Loops when frameCount > 1:
SetFrame( CurrentFrameNumber >= frameCount - 1 ? 0 : CurrentFrameNumber + 1 );
break;
case LoopMode.PlayOnce when !_playOnceFinished:
if ( CurrentFrameNumber < frameCount - 1 )
{
SetFrame( CurrentFrameNumber + 1 );
}
else
{
_playOnceFinished = true;
OnAnimationComplete?.Invoke();
OnAnimationComplete = null; // One-shot: clear so re-playing the same anim doesn't re-fire it.
}
break;
case LoopMode.PingPong when frameCount > 1:
if ( _pingPongForward )
{
if ( CurrentFrameNumber < frameCount - 1 )
SetFrame( CurrentFrameNumber + 1 );
else
{
_pingPongForward = false;
SetFrame( CurrentFrameNumber - 1 );
}
}
else
{
if ( CurrentFrameNumber > 0 )
SetFrame( CurrentFrameNumber - 1 );
else
{
_pingPongForward = true;
SetFrame( CurrentFrameNumber + 1 );
}
}
break;
case LoopMode.RandomFrame when frameCount > 1:
int next = Game.Random.Next( 0, frameCount );
while ( next == CurrentFrameNumber )
next = Game.Random.Next( 0, frameCount );
SetFrame( next );
break;
}
}
// ── Drawing ──────────────────────────────────────────────────────────
/// <summary>
/// Render the current animation frame into the <see cref="PixelScreen"/> at
/// the given pixel position (bottom-left origin of the sprite).
/// </summary>
public void Draw( int x, int y )
{
if ( CurrentAnimation is null || CurrentFrame is null )
return;
PixelScreen screen = PixelScreen.Instance;
if ( screen is null )
return;
List<PixelData> pixels = GetCurrentPixels();
foreach ( PixelData pixel in pixels )
{
Color32 color = ApplyColorOverride( pixel.Color );
int px = x + pixel.Position.X;
int py = y + pixel.Position.Y;
if ( color.a == 255 )
screen.SetPixel( px, py, color );
else if ( color.a > 0 )
screen.AddPixel( px, py, color );
}
}
/// <summary>
/// Draw arbitrary pixel data at a position with optional integer scale.
/// Useful for text rendering or scaled sprites.
/// </summary>
public void DrawPixels( List<PixelData> pixelDataList, int x, int y, int scale = 1 )
{
PixelScreen screen = PixelScreen.Instance;
if ( screen is null )
return;
foreach ( PixelData pixel in pixelDataList )
{
Color32 color = ApplyColorOverride( pixel.Color );
for ( int xo = 0; xo < scale; xo++ )
{
for ( int yo = 0; yo < scale; yo++ )
{
int px = x + pixel.Position.X * scale + xo;
int py = y + pixel.Position.Y * scale + yo;
if ( color.a == 255 )
screen.SetPixel( px, py, color );
else if ( color.a > 0 )
screen.AddPixel( px, py, color );
}
}
}
}
// ── Queries ──────────────────────────────────────────────────────────
/// <summary>
/// Get the current frame's pixel list, accounting for flip state.
/// </summary>
public List<PixelData> GetCurrentPixels()
{
if ( CurrentAnimation is null || CurrentFrame is null )
return new List<PixelData>();
int w = CurrentAnimation.AnimSize.X;
int h = CurrentAnimation.AnimSize.Y;
if ( FlipX && FlipY ) return CurrentFrame.GetPixelsFlippedXY( w, h );
if ( FlipX ) return CurrentFrame.GetPixelsFlippedX( w );
if ( FlipY ) return CurrentFrame.GetPixelsFlippedY( h );
// Return a copy so callers can safely modify the list without corrupting
// the cached frame data. (The flipped paths also return new lists.)
return new List<PixelData>( CurrentFrame.Pixels );
}
/// <summary>
/// Quick visibility check against the current camera viewport.
/// </summary>
public bool IsVisible( int entityX, int entityY )
{
if ( CurrentAnimation is null )
return false;
PixelScreen screen = PixelScreen.Instance;
if ( screen is null )
return false;
int w = CurrentAnimation.AnimSize.X;
int h = CurrentAnimation.AnimSize.Y;
if ( entityX < screen.CameraPos.X - w ) return false;
if ( entityX > screen.PixelWidth + screen.CameraPos.X ) return false;
if ( entityY < screen.CameraPos.Y - h ) return false;
if ( entityY > screen.PixelHeight + screen.CameraPos.Y ) return false;
return true;
}
// ── Debug ────────────────────────────────────────────────────────────
/// <summary>
/// Draw the world-space hitbox outline into the <see cref="PixelScreen"/>.
/// Uses <see cref="PixelScreen.DrawLine"/> for the four edges.
/// Pass a semi-transparent colour to overlay without obscuring sprite pixels.
/// </summary>
public void DrawHitbox( int x, int y, Color32 color )
{
PixelScreen screen = PixelScreen.Instance;
if ( screen is null || CurrentAnimation is null )
return;
PixelRect box = GetOffsetHitbox( x, y );
// Exclusive max → inclusive max for line drawing.
int x0 = box.XMin;
int y0 = box.YMin;
int x1 = box.XMax - 1;
int y1 = box.YMax - 1;
screen.DrawLine( x0, y0, x1, y0, color ); // bottom
screen.DrawLine( x0, y1, x1, y1, color ); // top
screen.DrawLine( x0, y0, x0, y1, color ); // left
screen.DrawLine( x1, y0, x1, y1, color ); // right
}
/// <summary>
/// Draw the world-space hitbox as a filled semi-transparent rectangle.
/// </summary>
public void DrawHitboxFilled( int x, int y, Color32 color )
{
PixelScreen screen = PixelScreen.Instance;
if ( screen is null || CurrentAnimation is null )
return;
PixelRect box = GetOffsetHitbox( x, y );
for ( int py = box.YMin; py < box.YMax; py++ )
{
for ( int px = box.XMin; px < box.XMax; px++ )
{
screen.AddPixel( px, py, color );
}
}
}
// ── Internals ────────────────────────────────────────────────────────
private void SetFrame( int frameNumber )
{
if ( _frameCallbacks.TryGetValue( frameNumber, out Action cb ) )
cb?.Invoke();
CurrentFrameNumber = frameNumber;
CurrentFrame = CurrentAnimation.Frames[frameNumber];
}
private Color32 ApplyColorOverride( Color32 color )
{
if ( _colorOverrides.Count == 0 )
return color;
uint key = PackRgb( color );
if ( !_colorOverrides.TryGetValue( key, out Color32 replacement ) )
return color;
if ( PreserveAlphaOnOverride )
return new Color32( replacement.r, replacement.g, replacement.b, color.a );
return replacement;
}
/// <summary>
/// Pack RGB into a uint for fast dictionary lookup (ignores alpha).
/// </summary>
private static uint PackRgb( Color32 c ) => (uint)(c.r << 16 | c.g << 8 | c.b);
}