Transposer/Entities/TitleText.cs
namespace Sandbox.Transposer;
/// <summary>
/// Bouncing title text that floats randomly around the screen with a ghost trail.
/// Used for "transposer" on the main menu, rendered at 2× scale in red.
/// </summary>
public class TitleText : TextDisplay
{
private float _moveSpeed = 30f;
private Vector2 _direction;
private Queue<PixelPoint> _lastPositions = new();
private const int NUM_LAST_POSITIONS = 3;
public TitleText( string text, TransposerScene scene, int x, int y, string fontName, int scale = 1 )
: base( text, scene, x, y, fontName, Color.Black, scale )
{
float angle = Game.Random.Next( 0, 360 );
_direction = GetVectorFromRotation( angle );
}
public override void UpdateEntity( float deltaTime )
{
base.UpdateEntity( deltaTime );
Move( _direction, deltaTime );
// Nudge the direction by a small random angle each frame to create
// a "drunk walk" — the title drifts around rather than moving in a straight line.
float angleChange = Game.Random.Float( -7f, 7f );
float currentAngle = GetRotationFromVector( _direction ) * (180f / MathF.PI);
_direction = GetVectorFromRotation( currentAngle + angleChange );
}
private void Move( Vector2 moveVector, float deltaTime )
{
Vector2 newExactPos = ExactPos + moveVector * _moveSpeed * deltaTime;
PixelPoint newPixelPos = new( (int)MathF.Round( newExactPos.x ), (int)MathF.Round( newExactPos.y ) );
if ( IsInBounds( newPixelPos.X, newPixelPos.Y, 3 ) )
{
if ( newPixelPos != PixelPosition )
AddLastPosition( PixelPosition );
ExactPos = newExactPos;
}
else
{
float angle = Game.Random.Next( 0, 360 );
_direction = GetVectorFromRotation( angle );
}
}
public override void Draw()
{
// Draw ghost trail at previous positions in black.
float opacity = 0.45f;
foreach ( PixelPoint pos in _lastPositions )
{
SetOverriddenColor( new Color( 0, 0, 0, opacity ) );
int currentX = pos.X;
int currentY = pos.Y;
for ( int i = 0; i < _text.Length; i++ )
{
char c = _text[i];
if ( c == '\n' ) { currentY -= _letterHeight * _scale; currentX = PixelX; continue; }
if ( c == ' ' ) { currentX += (_letterWidth + _spacing) * _scale; continue; }
List<PixelData> pxList = GetPixelDataList( _fontName, c.ToString() );
if ( pxList is not null ) DrawPixels( pxList, currentX, currentY, _scale );
currentX += (_letterWidth + _spacing) * _scale;
}
}
// Draw main text in red, semi-transparent.
SetOverriddenColor( new Color( 1f, 0f, 0f, 0.5f ) );
int currX = PixelX;
int currY = PixelY;
for ( int i = 0; i < _text.Length; i++ )
{
char c = _text[i];
if ( c == '\n' ) { currY -= _letterHeight * _scale; currX = PixelX; continue; }
if ( c == ' ' ) { currX += (_letterWidth + _spacing) * _scale; continue; }
List<PixelData> pxList = GetPixelDataList( _fontName, c.ToString() );
if ( pxList is not null ) DrawPixels( pxList, currX, currY, _scale );
currX += (_letterWidth + _spacing) * _scale;
}
}
private void AddLastPosition( PixelPoint pos )
{
if ( _lastPositions.Count >= NUM_LAST_POSITIONS )
_lastPositions.Dequeue();
_lastPositions.Enqueue( pos );
}
// ── Vector/rotation helpers (ported from Utils.cs) ───────────────────
// Converts degrees to a unit direction vector.
// Uses -sin for X so that 0° points up (+Y), 90° points right (+X) — matching
// the convention used in the original Unity port where 0° = "up" not "right".
private static Vector2 GetVectorFromRotation( float degrees )
{
float rad = degrees * (MathF.PI / 180f);
return new Vector2( MathF.Sin( -rad ), MathF.Cos( rad ) );
}
// Converts a direction vector back to degrees in the same convention as above.
// Subtracts Atan2(1,0) (= 90°) to rotate the reference frame so 0° aligns with +Y.
private static float GetRotationFromVector( Vector2 vector )
{
return MathF.Atan2( vector.y, vector.x ) - MathF.Atan2( 1f, 0f );
}
}