Transposer/TransposerScene.cs
namespace Sandbox.Transposer;
/// <summary>
/// Available scene types in the game.
/// </summary>
public enum SceneType { MainMenu, Game }
/// <summary>
/// Animation overlay data for a recently swapped grid region.
/// Shows a red flash that fades over 1 second.
/// </summary>
public class SwapAnimationInfo
{
public int A;
public int B;
public int Size;
public float Time;
public SwapAnimationInfo( int a, int b, int size, float time )
{
A = a;
B = b;
Size = size;
Time = time;
}
}
/// <summary>
/// Callback invoked when a fade-to-black completes.
/// </summary>
public delegate void FadeToBlackDelegate();
/// <summary>
/// Base scene class for Transposer. Manages entity lifecycle, draw ordering,
/// the grid-transposing pixel rearrangement effect, and screen fading.
///
/// Direct port of the original Unity Scene class. Not an engine Component —
/// scenes are plain C# objects managed by <see cref="TransposerSceneManager"/>.
/// </summary>
public class TransposerScene
{
// ── Entity management ────────────────────────────────────────────────
protected List<Entity> _entities = new();
protected List<Entity> _entitiesToAdd = new();
protected List<Entity> _entitiesToRemove = new();
// ── Scene switching ──────────────────────────────────────────────────
protected bool _switchingScene;
private SceneType _switchingSceneType;
/// <summary>Set to true the frame the scene switch executes; never reset — the scene is dead after this.</summary>
protected bool _sceneSwitched;
// ── Frame skips ──────────────────────────────────────────────────────
protected int _frameSkips;
public int FrameSkips { get => _frameSkips; set => _frameSkips = value; }
// ── Fading ───────────────────────────────────────────────────────────
protected bool _fadingToBlack;
private float _fadeToBlackTimer;
private float _fadeToBlackTime;
private FadeToBlackDelegate _fadeToBlackCallback;
protected bool _fadingIn;
private float _fadeInTimer;
private float _fadeInTime;
public void FadeToBlack( float time )
{
_fadingToBlack = true;
_fadeToBlackTimer = time;
_fadeToBlackTime = time;
}
public void SetFadeToBlackCallback( FadeToBlackDelegate callback ) =>
_fadeToBlackCallback = callback;
public void FadeIn( float time )
{
_fadingIn = true;
_fadeInTimer = time;
_fadeInTime = time;
}
// ── Grid transposing ─────────────────────────────────────────────────
// The signature mechanic: the screen is divided into grid cells that
// get shuffled each time a coin is collected. Starts at 64px chunks
// and progressively halves (64→32→16→8).
private Dictionary<int, int[]> _gridDrawOrders = new();
private Dictionary<int, List<int>> _gridSwapOrders = new();
private List<SwapAnimationInfo> _swapAnimations = new();
protected int _currentGridSize;
// Tracks which grid sizes have had at least one swap. Sizes with no swaps
// are still in identity order and can be skipped in ApplyGridTransposing.
private HashSet<int> _swappedSizes = new();
// Reusable scratch buffer for ApplyGridTransposing — avoids a per-frame allocation.
private Color32[] _transposeBuffer;
// ── Scene manager reference ──────────────────────────────────────────
public TransposerSceneManager SceneManager { get; set; }
// ── Screen reference ─────────────────────────────────────────────────
protected PixelScreen Screen => PixelScreen.Instance;
// ── Lifecycle ────────────────────────────────────────────────────────
public virtual void Activate()
{
int size = 64;
while ( size >= 8 )
{
int gridW = Screen.PixelWidth / size;
int gridH = Screen.PixelHeight / size;
int[] drawOrder = new int[gridW * gridH];
List<int> swapOrder = new();
for ( int i = 0; i < drawOrder.Length; i++ )
{
drawOrder[i] = i;
swapOrder.Add( i );
}
_gridDrawOrders[size] = drawOrder;
PixelUtils.Shuffle( swapOrder );
_gridSwapOrders[size] = swapOrder;
size /= 2;
}
_currentGridSize = 64;
_switchingScene = false;
_sceneSwitched = false;
}
public virtual void Deactivate()
{
_entities.Clear();
_entitiesToAdd.Clear();
_entitiesToRemove.Clear();
_frameSkips = 0;
_gridDrawOrders.Clear();
_gridSwapOrders.Clear();
_swapAnimations.Clear();
_swappedSizes.Clear();
_fadingToBlack = false;
_fadingIn = false;
_fadeToBlackCallback = null;
_switchingScene = false;
_sceneSwitched = false;
}
// ── Update ───────────────────────────────────────────────────────────
/// <summary>
/// Process deferred entity adds/removes, sort, and draw all base entities
/// without running any UpdateEntity logic. Used when the game is paused.
/// </summary>
public void DrawEntitiesOnly()
{
foreach ( Entity e in _entitiesToAdd )
_entities.Add( e );
_entitiesToAdd.Clear();
foreach ( Entity e in _entitiesToRemove )
_entities.Remove( e );
_entitiesToRemove.Clear();
_entities.Sort( ( a, b ) =>
{
if ( a.Layer != b.Layer ) return b.Layer.CompareTo( a.Layer );
return b.Depth.CompareTo( a.Depth );
} );
foreach ( Entity e in _entities )
e.Draw();
ApplyGridTransposing();
}
public virtual void UpdateScene( float deltaTime )
{
// Process deferred entity adds/removes.
foreach ( Entity e in _entitiesToAdd )
_entities.Add( e );
_entitiesToAdd.Clear();
foreach ( Entity e in _entitiesToRemove )
_entities.Remove( e );
_entitiesToRemove.Clear();
// Sort entities by layer (descending) then depth (descending).
// Higher layer = drawn first (behind), lower layer = on top.
_entities.Sort( ( a, b ) =>
{
if ( a.Layer != b.Layer ) return b.Layer.CompareTo( a.Layer );
return b.Depth.CompareTo( a.Depth );
} );
if ( _frameSkips > 0 )
{
_frameSkips--;
}
else
{
for ( int i = _entities.Count - 1; i >= 0; i-- )
_entities[i].UpdateEntity( deltaTime );
}
if ( _switchingScene )
{
SceneManager?.SetScene( _switchingSceneType );
_switchingScene = false;
_sceneSwitched = true;
}
else
{
foreach ( Entity e in _entities )
e.Draw();
}
// Apply grid transposing to the pixel buffer.
ApplyGridTransposing();
}
// ── Grid transposing ─────────────────────────────────────────────────
/// <summary>
/// Rearrange the pixel buffer according to the shuffled grid draw orders.
/// Called after all entities have drawn their pixels.
/// </summary>
private void ApplyGridTransposing()
{
if ( _swappedSizes.Count == 0 )
return;
Color32[] pixels = Screen.GetPixelsWritable();
int screenW = Screen.PixelWidth;
// Ensure scratch buffer is allocated (persists across frames).
if ( _transposeBuffer is null || _transposeBuffer.Length != pixels.Length )
_transposeBuffer = new Color32[pixels.Length];
int size = 64;
while ( size >= 8 )
{
// Skip sizes with no swaps — their draw order is still identity.
if ( !_swappedSizes.Contains( size ) )
{
size /= 2;
continue;
}
int gridW = screenW / size;
// Snapshot into reusable buffer so remapping reads original positions.
Array.Copy( pixels, _transposeBuffer, pixels.Length );
int[] drawOrder = _gridDrawOrders[size];
for ( int i = 0; i < drawOrder.Length; i++ )
{
int newOrder = drawOrder[i];
if ( newOrder == i )
continue; // Identity cell — no movement needed.
int gridX = i % gridW;
int gridY = i / gridW;
int origPxX = gridX * size;
int origPxY = gridY * size;
int newGridX = newOrder % gridW;
int newGridY = newOrder / gridW;
int newPxX = newGridX * size;
int newPxY = newGridY * size;
for ( int x = 0; x < size; x++ )
{
for ( int y = 0; y < size; y++ )
{
int origIdx = (origPxY + y) * screenW + (origPxX + x);
int newIdx = (newPxY + y) * screenW + (newPxX + x);
if ( origIdx < pixels.Length && newIdx < _transposeBuffer.Length )
pixels[origIdx] = _transposeBuffer[newIdx];
}
}
}
size /= 2;
}
Screen.MarkDirty();
}
/// <summary>
/// Swap two grid cells in the draw order and trigger the red flash.
/// Called when the player collects a coin.
/// </summary>
public void SwapGridSquares()
{
int size = 64;
while ( true )
{
if ( size < 8 )
return;
if ( !_gridSwapOrders.ContainsKey( size ) )
{
size /= 2;
continue;
}
List<int> swapOrder = _gridSwapOrders[size];
if ( swapOrder.Count < 2 )
{
size /= 2;
_currentGridSize = size;
continue;
}
break;
}
List<int> order = _gridSwapOrders[size];
int[] drawOrder = _gridDrawOrders[size];
// Pull the next two cell indices from the front of the pre-shuffled swap order.
// Each call consumes one pair; when the list drops below 2 entries the game
// shrinks to the next size level.
int a = order[0];
int b = order[1];
order.RemoveAt( 0 );
order.RemoveAt( 0 ); // Index 1 has shifted to 0 after the first RemoveAt.
// Swap in draw order.
(drawOrder[a], drawOrder[b]) = (drawOrder[b], drawOrder[a]);
_swappedSizes.Add( size );
_swapAnimations.Add( new SwapAnimationInfo( a, b, size, 1.0f ) );
}
// ── Fade handling ────────────────────────────────────────────────────
protected void HandleFading( float deltaTime )
{
if ( _fadingIn )
{
float opacity = PixelUtils.Map( _fadeInTimer, _fadeInTime, 0f, 1f, 0f, EaseType.SineOut );
Screen.AddPixels( new Color32( 0, 0, 0, (byte)(opacity * 255f) ) );
_fadeInTimer -= deltaTime;
if ( _fadeInTimer <= 0f )
_fadingIn = false;
}
else if ( _fadingToBlack )
{
_fadeToBlackTimer -= deltaTime;
float opacity = PixelUtils.Map( _fadeToBlackTimer, _fadeToBlackTime, 0f, 0f, 1f, EaseType.SineOut );
Screen.AddPixels( new Color32( 0, 0, 0, (byte)(opacity * 255f) ) );
if ( _fadeToBlackTimer <= 0f )
{
_fadingToBlack = false;
_fadeToBlackCallback?.Invoke();
_fadeToBlackCallback = null;
}
}
}
/// <summary>
/// Draw the red flash overlay on recently swapped grid cells.
/// </summary>
// Called AFTER ApplyGridTransposing, so the flash overlay lands at the
// post-swap visual position of each cell — not where the cell was before.
protected void HandleTransposing( float deltaTime )
{
for ( int i = _swapAnimations.Count - 1; i >= 0; i-- )
{
SwapAnimationInfo info = _swapAnimations[i];
int gridW = Screen.PixelWidth / info.Size;
int gridXa = info.A % gridW;
int gridYa = info.A / gridW;
int pxXa = gridXa * info.Size;
int pxYa = gridYa * info.Size;
int gridXb = info.B % gridW;
int gridYb = info.B / gridW;
int pxXb = gridXb * info.Size;
int pxYb = gridYb * info.Size;
float opacity = PixelUtils.Map( info.Time, 1f, 0f, 1f, 0f );
Color32 flashColor = new( 255, 0, 0, (byte)(opacity * 255f) );
for ( int x = 0; x < info.Size; x++ )
{
for ( int y = 0; y < info.Size; y++ )
{
Screen.AddPixel( pxXa + x, Screen.PixelHeight - 1 - pxYa - y, flashColor );
Screen.AddPixel( pxXb + x, Screen.PixelHeight - 1 - pxYb - y, flashColor );
}
}
info.Time -= deltaTime;
if ( info.Time <= 0f )
_swapAnimations.RemoveAt( i );
}
}
// ── Collision ────────────────────────────────────────────────────────
public Entity Collide( Entity entity, string type, int x, int y )
{
foreach ( Entity other in _entities )
{
if ( other.Type != type || !entity.Collideable || !other.Collideable || other == entity )
continue;
if ( entity.GetOffsetHitbox( x, y ).Overlaps( other.OffsetHitbox ) )
return other;
}
return null;
}
public List<Entity> CollideWithAll( Entity entity, string type, int x, int y )
{
List<Entity> results = new();
foreach ( Entity other in _entities )
{
if ( other.Type != type || !entity.Collideable || !other.Collideable || other == entity )
continue;
if ( entity.GetOffsetHitbox( x, y ).Overlaps( other.OffsetHitbox ) )
results.Add( other );
}
return results;
}
public bool IsInBounds( Entity entity, int x, int y, int padding = 0 )
{
PixelRect hitbox = entity.GetOffsetHitbox( x, y );
return hitbox.XMax < Screen.PixelWidth - padding
&& hitbox.XMin >= padding
&& hitbox.YMax < Screen.PixelHeight - padding
&& hitbox.YMin >= padding;
}
// ── HUD helpers ──────────────────────────────────────────────────────
/// <summary>
/// Draw the "mute" sprite at 0.25 opacity in the top-right corner.
/// </summary>
protected void DrawMuteIcon()
{
const int MARGIN_X = 3;
const int MARGIN_Y = 2;
AnimationData anim = SpriteManager.GetAnimation( "mute", "default" );
if ( anim is null || anim.Frames.Count == 0 )
return;
int startX = Screen.PixelWidth - anim.AnimSize.X - MARGIN_X;
int startY = Screen.PixelHeight - anim.AnimSize.Y - MARGIN_Y;
foreach ( PixelData pd in anim.Frames[0].Pixels )
{
Color32 c = pd.Color;
Screen.AddPixel( startX + pd.Position.X, startY + pd.Position.Y,
new Color32( c.r, c.g, c.b, (byte)(c.a * 0.25f) ) );
}
}
// ── Entity management ────────────────────────────────────────────────
public void AddEntity( Entity e )
{
e.ContainingScene = this;
_entitiesToAdd.Add( e );
}
public void RemoveEntity( Entity e )
{
_entitiesToRemove.Add( e );
}
public void SwitchScene( SceneType sceneType )
{
_switchingScene = true;
_switchingSceneType = sceneType;
}
}