Transposer/Entity.cs
namespace Sandbox.Transposer;
/// <summary>
/// Callback invoked when a PlayOnce animation reaches its last frame.
/// </summary>
public delegate void AnimationCompleteDelegate();
/// <summary>
/// Callback for scheduled (delayed) actions.
/// </summary>
public delegate void ScheduledDelegate();
/// <summary>
/// Callback invoked when a specific animation frame is entered.
/// </summary>
public delegate void SetFrameDelegate();
/// <summary>
/// A delayed callback that fires after <see cref="Delay"/> seconds.
/// </summary>
public struct ScheduledDelegateData
{
public ScheduledDelegate Callback;
public float Delay;
public ScheduledDelegateData( ScheduledDelegate callback, float delay )
{
Callback = callback;
Delay = delay;
}
}
/// <summary>
/// Base class for all Transposer game entities.
///
/// Direct port of the original Unity Entity class as a plain C# object
/// (not an engine Component). Entities are managed by a <see cref="TransposerScene"/>
/// which handles update ordering, draw sorting, and deferred add/remove.
///
/// Uses <see cref="SpriteManager"/> and the framework's <see cref="PixelSpriteComponent"/>
/// internally for animation playback and rendering, while keeping the original
/// Entity API surface intact.
/// </summary>
public class Entity
{
// ── Position ─────────────────────────────────────────────────────────
// Sub-pixel float position — rounded to integer for drawing.
protected Vector2 _position;
public Vector2 ExactPos
{
get => _position;
set => _position = value;
}
public float ExactX
{
get => _position.x;
set => _position = new Vector2( value, _position.y );
}
public float ExactY
{
get => _position.y;
set => _position = new Vector2( _position.x, value );
}
/// <summary>
/// Integer pixel position, using rounding (same as original).
/// </summary>
public PixelPoint PixelPosition
{
get => new( (int)MathF.Round( _position.x ), (int)MathF.Round( _position.y ) );
set => _position = new Vector2( value.X, value.Y );
}
public int PixelX
{
get => (int)MathF.Round( _position.x );
set => _position = new Vector2( value, _position.y );
}
public int PixelY
{
get => (int)MathF.Round( _position.y );
set => _position = new Vector2( _position.x, value );
}
/// <summary>
/// Center of the entity in pixel coordinates (offset by half anim size).
/// </summary>
public PixelPoint CenterPixelPos
{
get
{
if ( _currentAnimation is not null )
return new PixelPoint(
PixelX + (int)MathF.Round( (_currentAnimation.AnimSize.X - 1) / 2f ),
PixelY + (int)MathF.Round( (_currentAnimation.AnimSize.Y - 1) / 2f ) );
return PixelPosition;
}
}
public Vector2 CenterExactPos
{
get
{
if ( _currentAnimation is not null )
return new Vector2(
ExactX + _currentAnimation.AnimSize.X * 0.5f,
ExactY + _currentAnimation.AnimSize.Y * 0.5f );
return ExactPos;
}
}
// ── Sprite type ──────────────────────────────────────────────────────
protected string _type;
public string Type => _type;
// ── Depth/layer for draw sorting ─────────────────────────────────────
protected int _depth;
public int Depth { get => _depth; set => _depth = value; }
protected int _layer;
public int Layer { get => _layer; set => _layer = value; }
// ── Hitbox / collision ───────────────────────────────────────────────
protected PixelRect _hitbox;
public PixelRect Hitbox => _hitbox;
/// <summary>Hitbox offset by current pixel position.</summary>
public PixelRect OffsetHitbox => _hitbox + PixelPosition;
public PixelRect GetOffsetHitbox( PixelPoint position ) => _hitbox + position;
public PixelRect GetOffsetHitbox( int x, int y ) => _hitbox + new PixelPoint( x, y );
protected bool _collideable;
public bool Collideable => _collideable;
// ── Scene reference ──────────────────────────────────────────────────
protected TransposerScene _scene;
public TransposerScene ContainingScene { get => _scene; set => _scene = value; }
// ── Animation state ──────────────────────────────────────────────────
protected AnimationData _currentAnimation;
protected int _currentFrameNumber;
protected float _currentFrameTime;
protected FrameData _currentFrameData;
protected bool _pingPongForward;
protected float _animationTimeScale = 1.0f;
public float AnimTimeScale { get => _animationTimeScale; set => _animationTimeScale = value; }
private bool _overrideLoopMode;
private LoopMode _overriddenLoopMode;
private AnimationCompleteDelegate _animCompleteCallback;
public void SetAnimCompleteCallback( AnimationCompleteDelegate callback ) => _animCompleteCallback = callback;
private bool _playOnceAnimFinished;
private Dictionary<int, SetFrameDelegate> _setFrameDelegates = new();
public void AddSetFrameCallback( int frameNumber, SetFrameDelegate callback ) =>
_setFrameDelegates[frameNumber] = callback;
public void ClearSetFrameCallbacks() => _setFrameDelegates.Clear();
// ── Flip ─────────────────────────────────────────────────────────────
protected bool _flipX;
public bool FlipX { get => _flipX; set => _flipX = value; }
protected bool _flipY;
public bool FlipY { get => _flipY; set => _flipY = value; }
// ── Color override ───────────────────────────────────────────────────
private bool _overrideColor;
private bool _overrideAlpha;
private Color _overriddenColor;
public void SetOverriddenColor( Color color )
{
_overrideColor = true;
_overriddenColor = color;
}
public void RemoveOverriddenColor() => _overrideColor = false;
public void SetOverrideAlpha( bool overrideAlpha ) => _overrideAlpha = overrideAlpha;
// ── Scheduled callbacks ──────────────────────────────────────────────
private List<ScheduledDelegateData> _scheduledCallbacks = new();
public void AddScheduledCallback( float delay, ScheduledDelegate callback ) =>
_scheduledCallbacks.Add( new ScheduledDelegateData( callback, delay ) );
// ── Screen reference ─────────────────────────────────────────────────
protected PixelScreen Screen => PixelScreen.Instance;
// ── Update ───────────────────────────────────────────────────────────
public virtual void UpdateEntity( float deltaTime )
{
if ( _currentAnimation is not null )
HandleAnimation( deltaTime );
if ( _scheduledCallbacks.Count > 0 )
HandleScheduledCallbacks( deltaTime );
}
private void HandleScheduledCallbacks( float deltaTime )
{
// Iterate backwards so RemoveAt(i) doesn't shift unvisited entries.
// Expired callbacks are fired immediately; remaining ones are re-added
// at the end of the list with the updated time. The re-add is safe here
// because we're iterating from the back toward the front.
for ( int i = _scheduledCallbacks.Count - 1; i >= 0; i-- )
{
ScheduledDelegateData data = _scheduledCallbacks[i];
float newTime = data.Delay - deltaTime;
_scheduledCallbacks.RemoveAt( i );
if ( newTime <= 0f )
data.Callback();
else
_scheduledCallbacks.Add( new ScheduledDelegateData( data.Callback, newTime ) );
}
}
private void HandleAnimation( float deltaTime )
{
LoopMode loopMode = _overrideLoopMode ? _overriddenLoopMode : _currentAnimation.LoopMode;
int numFrames = _currentAnimation.Frames.Count;
_currentFrameTime += deltaTime * _animationTimeScale;
var animTime = _currentAnimation.Frames[_currentFrameNumber].AnimTime;
if ( _currentFrameTime <= animTime )
return;
switch ( loopMode )
{
case LoopMode.Loops when numFrames > 1:
SetAnimationFrame( _currentFrameNumber == numFrames - 1 ? 0 : _currentFrameNumber + 1 );
break;
case LoopMode.PlayOnce when !_playOnceAnimFinished:
// Guard prevents re-firing the callback if PlayAnimation is called again
// on the same animation while still on the last frame.
if ( _currentFrameNumber < numFrames - 1 )
{
SetAnimationFrame( _currentFrameNumber + 1 );
}
else
{
_playOnceAnimFinished = true;
AnimationCompleteDelegate cb = _animCompleteCallback;
_animCompleteCallback = null;
cb?.Invoke();
}
break;
case LoopMode.PingPong when numFrames > 1:
if ( _pingPongForward )
{
if ( _currentFrameNumber < numFrames - 1 )
SetAnimationFrame( _currentFrameNumber + 1 );
else
{
SetAnimationFrame( _currentFrameNumber - 1 );
_pingPongForward = false;
}
}
else
{
if ( _currentFrameNumber > 0 )
SetAnimationFrame( _currentFrameNumber - 1 );
else
{
SetAnimationFrame( _currentFrameNumber + 1 );
_pingPongForward = true;
}
}
break;
case LoopMode.RandomFrame when numFrames > 1:
int newFrame = Game.Random.Next( 0, numFrames );
while ( newFrame == _currentFrameNumber )
newFrame = Game.Random.Next( 0, numFrames );
SetAnimationFrame( newFrame );
break;
}
//_currentFrameTime = 0f;
_currentFrameTime -= animTime;
}
private void SetAnimationFrame( int frameNumber )
{
if ( _setFrameDelegates.TryGetValue( frameNumber, out SetFrameDelegate cb ) )
cb?.Invoke();
_currentFrameNumber = frameNumber;
_currentFrameData = _currentAnimation.Frames[_currentFrameNumber];
}
// ── Animation playback ───────────────────────────────────────────────
public virtual void PlayAnimation( string animName )
{
List<AnimationData> anims = SpriteManager.GetAnimations( _type );
if ( anims is null )
{
Log.Error( $"Entity: no entity type called '{_type}'" );
return;
}
foreach ( AnimationData anim in anims )
{
if ( anim.Name != animName )
continue;
_currentAnimation = anim;
_hitbox = anim.Hitbox;
SetAnimationFrame( 0 );
_currentFrameTime = 0f;
_overrideLoopMode = false;
_playOnceAnimFinished = false;
_pingPongForward = true; // Always start ping-pong animations going forward.
return;
}
Log.Error( $"Entity: no animation '{animName}' for '{_type}'" );
}
public virtual void PlayAnimation( string animName, LoopMode overriddenLoopMode )
{
PlayAnimation( animName );
_overrideLoopMode = true;
_overriddenLoopMode = overriddenLoopMode;
}
// ── Drawing ──────────────────────────────────────────────────────────
public virtual void Draw()
{
if ( _currentAnimation is null )
return;
if ( !IsInScreenBounds() && !Screen.DrawInGUISpace )
return;
List<PixelData> pixels = GetCurrentPixels();
foreach ( PixelData pixel in pixels )
{
Color32 color = ApplyColorOverride( pixel.Color );
// Fully opaque → SetPixel (no blend math). Semi-transparent → AddPixel
// (Porter-Duff over). Fully transparent (a == 0) → skip entirely.
if ( color.a == 255 )
Screen.SetPixel( PixelX + pixel.Position.X, PixelY + pixel.Position.Y, color );
else if ( color.a > 0 )
Screen.AddPixel( PixelX + pixel.Position.X, PixelY + pixel.Position.Y, color );
}
}
/// <summary>
/// Draw arbitrary pixel data at a position with optional scale and random colour.
/// Used for text rendering and particle effects.
/// </summary>
public virtual void DrawPixels( List<PixelData> pixelDataList, int x, int y, int scale = 1, bool randomColor = false )
{
foreach ( PixelData pixel in pixelDataList )
{
Color32 color;
if ( randomColor )
{
color = new Color32(
(byte)Game.Random.Next( 0, 100 ),
(byte)Game.Random.Next( 0, 100 ),
(byte)Game.Random.Next( 0, 100 ),
(byte)Game.Random.Next( 0, 256 ) );
}
else
{
color = ApplyColorOverride( pixel.Color );
}
for ( int xOff = 0; xOff < scale; xOff++ )
{
for ( int yOff = 0; yOff < scale; yOff++ )
{
int px = x + pixel.Position.X * scale + xOff;
int py = y + pixel.Position.Y * scale + yOff;
if ( color.a == 255 )
Screen.SetPixel( px, py, color );
else if ( color.a > 0 )
Screen.AddPixel( px, py, color );
}
}
}
}
// ── Pixel data access ────────────────────────────────────────────────
/// <summary>
/// Get the current frame's pixel data, respecting flip state.
/// </summary>
public List<PixelData> GetPixelDataList()
{
if ( _currentAnimation is null )
return null;
return GetFlippedPixels( _currentAnimation.Frames[_currentFrameNumber], _currentAnimation.AnimSize );
}
/// <summary>
/// Get pixel data for a specific type/anim/frame, optionally flipped.
/// Used by the pixel-colour collision system to check against a known frame.
/// </summary>
public List<PixelData> GetPixelDataList( string type, string animName, int frameNum = 0, bool flippedX = false, bool flippedY = false )
{
AnimationData anim = SpriteManager.GetAnimation( type, animName );
if ( anim is null || frameNum >= anim.Frames.Count )
return null;
FrameData frame = anim.Frames[frameNum];
int w = anim.AnimSize.X;
int h = anim.AnimSize.Y;
if ( flippedX && flippedY ) return frame.GetPixelsFlippedXY( w, h );
if ( flippedX ) return frame.GetPixelsFlippedX( w );
if ( flippedY ) return frame.GetPixelsFlippedY( h );
return new List<PixelData>( frame.Pixels );
}
/// <summary>Get animation size for a specific type/anim.</summary>
public PixelPoint GetAnimSize( string type, string animName )
{
AnimationData anim = SpriteManager.GetAnimation( type, animName );
return anim?.AnimSize ?? PixelPoint.Zero;
}
/// <summary>Get animation hitbox for a specific type/anim.</summary>
public PixelRect GetAnimHitbox( string type, string animName )
{
AnimationData anim = SpriteManager.GetAnimation( type, animName );
return anim?.Hitbox ?? new PixelRect( 0, 0, 0, 0 );
}
/// <summary>Get the AnimationData for a specific type/anim.</summary>
public AnimationData GetAnimationData( string type, string animName )
{
return SpriteManager.GetAnimation( type, animName );
}
// ── Collision (delegates to scene) ───────────────────────────────────
protected Entity Collide( string type, int xPixel, int yPixel )
{
return _scene?.Collide( this, type, xPixel, yPixel );
}
protected Entity Collide( string type )
{
return _scene?.Collide( this, type, PixelX, PixelY );
}
protected List<Entity> CollideWithAll( string type, int xPixel, int yPixel )
{
return _scene?.CollideWithAll( this, type, xPixel, yPixel );
}
protected List<Entity> CollideWithAll( string type )
{
return _scene?.CollideWithAll( this, type, PixelX, PixelY );
}
protected bool IsInBounds( int xPixel, int yPixel, int padding = 0 )
{
return _scene?.IsInBounds( this, xPixel, yPixel, padding ) ?? false;
}
protected void Unpenetrate( Entity other )
{
if ( !OffsetHitbox.Overlaps( other.OffsetHitbox ) )
return;
PixelRect myHitbox = OffsetHitbox;
PixelRect otherHitbox = other.OffsetHitbox;
int leftPen = myHitbox.XMax - otherHitbox.XMin + 1;
int rightPen = otherHitbox.XMax - myHitbox.XMin + 1;
int upPen = otherHitbox.YMax - myHitbox.YMin + 1;
int downPen = myHitbox.YMax - otherHitbox.YMin + 1;
Direction dir = Direction.Up;
int amount = upPen;
if ( downPen < amount ) { dir = Direction.Down; amount = downPen; }
if ( leftPen < amount ) { dir = Direction.Left; amount = leftPen; }
if ( rightPen < amount ) { dir = Direction.Right; amount = rightPen; }
switch ( dir )
{
case Direction.Up: PixelY += amount; break;
case Direction.Down: PixelY -= amount; break;
case Direction.Left: PixelX -= amount; break;
case Direction.Right: PixelX += amount; break;
}
}
// ── Bounds check ─────────────────────────────────────────────────────
public bool IsInScreenBounds()
{
if ( _currentAnimation is null )
return false;
// Conservative AABB cull: allows one full sprite width/height of slack
// so sprites whose origin is just off-screen but whose pixels are still
// visible don't get incorrectly culled.
int w = _currentAnimation.AnimSize.X;
int h = _currentAnimation.AnimSize.Y;
if ( PixelX < Screen.CameraPos.X - w ) return false;
if ( PixelX > Screen.PixelWidth + Screen.CameraPos.X ) return false;
if ( PixelY < Screen.CameraPos.Y - h ) return false;
if ( PixelY > Screen.PixelHeight + Screen.CameraPos.Y ) return false;
return true;
}
// ── Helpers ──────────────────────────────────────────────────────────
private List<PixelData> GetCurrentPixels()
{
return GetFlippedPixels( _currentFrameData, _currentAnimation.AnimSize );
}
private List<PixelData> GetFlippedPixels( FrameData frame, PixelPoint animSize )
{
if ( _flipX && _flipY ) return frame.GetPixelsFlippedXY( animSize.X, animSize.Y );
if ( _flipX ) return frame.GetPixelsFlippedX( animSize.X );
if ( _flipY ) return frame.GetPixelsFlippedY( animSize.Y );
return frame.Pixels; // No flip — return the original list directly (no copy needed; Draw only reads it).
}
/// <summary>
/// Apply colour/alpha override to a pixel, matching the original Entity.Draw logic.
/// </summary>
private Color32 ApplyColorOverride( Color32 color )
{
if ( !_overrideColor )
return color;
if ( _overrideAlpha )
{
float colorAlpha = color.a / 255f;
if ( color.a == 0 ) return color;
return new Color32(
(byte)(_overriddenColor.r * 255f),
(byte)(_overriddenColor.g * 255f),
(byte)(_overriddenColor.b * 255f),
(byte)(_overriddenColor.a * colorAlpha * 255f) );
}
return new Color32(
(byte)(_overriddenColor.r * 255f),
(byte)(_overriddenColor.g * 255f),
(byte)(_overriddenColor.b * 255f),
color.a );
}
}