Transposer/Entities/Coin.cs
namespace Sandbox.Transposer;
/// <summary>
/// Collectible coin entity. Spins in place, detects the player via pixel-colour
/// collision, and smoothly relocates to a random position when collected.
/// Spawns Shine sparkle particles periodically.
/// </summary>
public class Coin : Entity
{
// Monotonically-increasing counter shared across all Coin instances.
// Each new coin gets a unique depth so their draw order is stable and
// consistent even when multiple coins exist simultaneously.
private static int _depthCounter;
private bool _isMoving;
private Vector2 _moveVector;
private float _moveTimer;
private const float MOVE_TIME = 0.75f;
private Vector2 _originalPosition;
private PixelPoint _targetPosition;
private float _sparkleTimer;
private const float SPARKLE_INTERVAL_IDLE = 1f / 1.2f; // ~1.2 sparkles/s when stationary
private const float SPARKLE_INTERVAL_MOVING = 1f / 12f; // ~12 sparkles/s when moving
private Queue<PixelPoint> _lastPositions = new();
private const int NUM_LAST_POSITIONS = 4;
public Coin( int x, int y, TransposerScene scene )
{
PixelPosition = new PixelPoint( x, y );
_scene = scene;
_type = "coin";
PlayAnimation( "spin" );
_layer = Globals.DEPTH_COIN;
_depth = _depthCounter++;
}
public override void UpdateEntity( float deltaTime )
{
base.UpdateEntity( deltaTime );
if ( _isMoving )
{
PixelPoint oldPos = PixelPosition;
// SineOut easing: fast start, decelerates smoothly into the target position.
float moveAmount = PixelUtils.Map( _moveTimer, MOVE_TIME, 0f, 0f, 1f, EaseType.SineOut );
ExactPos = _originalPosition + _moveVector * moveAmount;
if ( PixelPosition != oldPos )
AddLastPosition( oldPos );
_moveTimer -= deltaTime;
if ( _moveTimer <= 0f )
{
_isMoving = false;
PixelPosition = _targetPosition;
PlayAnimation( "spin" );
_lastPositions.Clear();
}
}
else
{
// Only check for player collision while stationary — skip during relocation
// so the coin can't be re-collected mid-flight after a swap.
List<PixelData> pixelDataList = GetPixelDataList( "coin", "spin", _currentFrameNumber, false, false );
if ( pixelDataList is not null )
{
foreach ( PixelData pd in pixelDataList )
{
PixelPoint pos = PixelPosition + pd.Position;
Color32 existing = Screen.GetPixel( pos.X, pos.Y );
if ( existing.r == 200 && existing.g == 200 && existing.b == 100 && existing.a == 255 )
{
((GameScene)_scene).PlayerTouchedCoin( this );
break;
}
}
}
}
// Framerate-independent sparkle particles.
float sparkleInterval = _isMoving ? SPARKLE_INTERVAL_MOVING : SPARKLE_INTERVAL_IDLE;
_sparkleTimer -= deltaTime;
if ( _sparkleTimer <= 0f )
{
_sparkleTimer += sparkleInterval;
((GameScene)_scene).CreateShine(
PixelX + Game.Random.Next( -1, 4 ),
PixelY + Game.Random.Next( -1, 4 ) );
}
}
/// <summary>
/// Start a smooth relocation to a random position at least 40px away.
/// </summary>
public void Relocate()
{
const int MIN_DISTANCE = 40;
int newX, newY;
do
{
newX = Game.Random.Next( 2, Screen.PixelWidth - 12 );
newY = Game.Random.Next( 2, Screen.PixelHeight - 12 );
}
while ( Math.Abs( newX - PixelX ) < MIN_DISTANCE && Math.Abs( newY - PixelY ) < MIN_DISTANCE );
_isMoving = true;
_moveTimer = MOVE_TIME;
_sparkleTimer = 0f;
PlayAnimation( "moving" );
_lastPositions.Clear();
_originalPosition = ExactPos;
_targetPosition = new PixelPoint( newX, newY );
_moveVector = new Vector2( newX - ExactPos.x, newY - ExactPos.y );
}
public override void Draw()
{
// Draw golden ghost trail.
List<PixelData> trailPixels = GetPixelDataList( "coin", "moving", 0, false, false );
if ( trailPixels is not null )
{
float opacity = 0.30f;
foreach ( PixelPoint pos in _lastPositions )
{
foreach ( PixelData pd in trailPixels )
{
PixelPoint pp = pos + pd.Position;
Screen.AddPixel( pp.X, pp.Y, new Color32( 191, 191, 0, (byte)(opacity * 255f) ) );
}
}
}
base.Draw();
}
private void AddLastPosition( PixelPoint pos )
{
if ( _lastPositions.Count >= NUM_LAST_POSITIONS )
_lastPositions.Dequeue();
_lastPositions.Enqueue( pos );
}
}