UI panel component that renders a drifting, parallaxed backdrop of square blocks for the options overlay. It imperatively creates Panel children, advances their positions each Tick, bounces them inside a 240x240 logical arena, and converts preset linear colors to gamma for visual match.
@using System
@using System.Collections.Generic
@using Sandbox
@using Sandbox.UI
@inherits Panel
@namespace BlockParty
@* Drifting cosmetic background blocks for the in-run / in-replay options overlay, redrawn in UI space.
The arena behind the overlay is FROZEN (the sim is paused while the menu is open), so its live
drifting blocks stop; this panel re-creates the main-menu options screen's moving-block look purely
in the overlay. Three parallax layers of stepped teal squares drift on cardinal paths and bounce off
the panel bounds. Created + animated imperatively (see Tick / BuildBlocks) so it never rebuilds the
panel. Fills its parent (the overlay's checkerboard panel) and sits BEHIND the options labels.
All critical positioning is INLINE (root absolute-fill, blocks Position=Absolute set in C#) rather
than relying on a freshly-added component's .scss, which can fail to apply on the first render. *@
<root style="position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden; pointer-events: none;"></root>
@code
{
// A single drifting square: logical arena-space position/velocity (origin bottom-left, +Y up) plus
// its size, shade and depth layer. Panels are moved (not rebuilt) each frame.
private sealed class BgBlock
{
public Panel Panel;
public Vector2 Pos; // logical arena units (0..240)
public Vector2 Vel; // logical units / second (purely cardinal)
public float Size; // logical units
public Color Color;
public int Depth; // 0 = nearest/lightest, 2 = deepest/darkest
}
private readonly List<BgBlock> _blocks = new();
private bool _blocksBuilt;
// Session-cosmetic only (never gameplay / replay), so a plain non-deterministic RNG is fine.
private readonly Random _rng = new();
// The default teal drifting-block shades (front -> back), matching StageBase.SpawnBackgroundBlocks.
// In-game these feed the sprite tint as LINEAR (no gamma->linear pre-convert), so the display
// gamma-encodes them and they read BRIGHTER on screen; the UI paints in gamma space, so mirror that
// brightening (linear->gamma) here so the overlay blocks match the colours the game actually shows.
private static readonly Color[] Shades =
{
LinearToGamma( new Color( 0f, 150f / 255f, 157f / 255f ) ),
LinearToGamma( new Color( 0f, 135f / 255f, 142f / 255f ) ),
LinearToGamma( new Color( 0f, 120f / 255f, 127f / 255f ) ),
};
// Per-frame hook: advance each block along its cardinal velocity and bounce it off the panel bounds.
public override void Tick()
{
base.Tick();
// (Re)build on first tick, or when a render pass (hot-reload / reconcile) has WIPED the
// imperatively-created child panels — they aren't in the declarative template, so a render can
// delete them while _blocks still holds stale refs.
if ( !_blocksBuilt || ChildrenCount != _blocks.Count )
BuildBlocks();
float dt = Time.Delta;
foreach ( var b in _blocks )
{
var p = b.Pos + b.Vel * dt;
float maxX = Arena.WIDTH - b.Size;
float maxY = Arena.HEIGHT - b.Size;
// Bounce off the square bounds by flipping the offending axis.
if ( p.x < 0f ) { p.x = 0f; b.Vel = new Vector2( MathF.Abs( b.Vel.x ), b.Vel.y ); }
else if ( p.x > maxX ) { p.x = maxX; b.Vel = new Vector2( -MathF.Abs( b.Vel.x ), b.Vel.y ); }
if ( p.y < 0f ) { p.y = 0f; b.Vel = new Vector2( b.Vel.x, MathF.Abs( b.Vel.y ) ); }
else if ( p.y > maxY ) { p.y = maxY; b.Vel = new Vector2( b.Vel.x, -MathF.Abs( b.Vel.y ) ); }
b.Pos = p;
PositionBlock( b );
}
}
// (Re)create the drifting-block panels: three parallax layers (6/5/4 blocks, sizes 50/42/34),
// mirroring StageBase.SpawnBackgroundBlocks. Deeper layers are darker and drift a touch slower.
private void BuildBlocks()
{
_blocks.Clear();
DeleteChildren( true );
_blocksBuilt = true;
var built = new List<BgBlock>();
for ( int layer = 0; layer < 3; layer++ )
{
int numBlocks = 6 - layer;
float size = 50f - 8f * layer;
for ( int j = 0; j < numBlocks; j++ )
{
// Spawn fully inside the square.
float x = NextFloat() * ( Arena.WIDTH - size );
float y = NextFloat() * ( Arena.HEIGHT - size );
// Gentle cardinal drift; deeper layers move slightly slower (subtle parallax).
float speed = ( 20f - layer * 4f ) + NextFloat() * 12f;
Vector2 vel = NextFloat() < 0.5f
? new Vector2( NextFloat() < 0.5f ? -speed : speed, 0f )
: new Vector2( 0f, NextFloat() < 0.5f ? -speed : speed );
built.Add( new BgBlock
{
Pos = new Vector2( x, y ),
Vel = vel,
Size = size,
Color = Shades[layer],
Depth = layer,
} );
}
}
// Darkest (deepest) first so lighter, nearer blocks paint on top.
built.Sort( ( a, b ) => b.Depth.CompareTo( a.Depth ) );
foreach ( var b in built )
{
b.Panel = new Panel( this, "bg-block" );
b.Panel.Style.Position = PositionMode.Absolute;
b.Panel.Style.Width = Length.Percent( b.Size / Arena.WIDTH * 100f );
b.Panel.Style.Height = Length.Percent( b.Size / Arena.HEIGHT * 100f );
b.Panel.Style.BackgroundColor = b.Color;
_blocks.Add( b );
PositionBlock( b );
}
}
private static void PositionBlock( BgBlock b )
{
if ( b.Panel is null ) return;
// Logical (bottom-left origin, +Y up) -> CSS percentages of the 240x240 square.
b.Panel.Style.Left = Length.Percent( b.Pos.x / Arena.WIDTH * 100f );
b.Panel.Style.Top = Length.Percent( ( Arena.HEIGHT - b.Pos.y - b.Size ) / Arena.HEIGHT * 100f );
}
// xorshift-free simple [0,1); plain Random is fine for a cosmetic UI layer.
private float NextFloat() => (float)_rng.NextDouble();
// The overlay never changes structurally, so it only needs to build once; the drifting is driven
// imperatively in Tick, not by rebuilds.
protected override int BuildHash() => 0;
private static Color LinearToGamma( Color c ) => new Color( L2G( c.r ), L2G( c.g ), L2G( c.b ), c.a );
// Standard sRGB linear->gamma transfer (matches what the display applies to the shader's linear output).
private static float L2G( float v ) =>
v <= 0.0031308f ? v * 12.92f : 1.055f * MathF.Pow( v, 1f / 2.4f ) - 0.055f;
}