UI/LevelNodePreview.razor
@using System
@using System.Collections.Generic
@using Sandbox
@using Sandbox.UI
@inherits Panel
@namespace BlockParty
@attribute [StyleSheet( "LevelNodePreview.razor.scss" )]
@* A miniature schematic of a level's arena, drawn to fill a level-select node so the node reads as
the level it launches: the play-field colour as the base, a few static drifting-block squares, the
coloured arena walls, and the obstacle layout with its combined walls, elbow patches and starting
spikes. Pure display; built once per level by LevelPreview.Build. *@
<root class="level-preview" style="background-color: @Css( PreviewBackground )">
<div class="playfield-pattern" style="@PatternStyle( Level?.PlayfieldPattern ?? PlayfieldPattern.Checker, Level?.PlayfieldCellScale ?? 1, Level?.PlayfieldPatternRows, PreviewBackground, Level?.CheckerboardSecondColor, null )"></div>
@if ( Level?.AlternatePlayfieldEnabled == true )
{
@foreach ( RectF rect in Level.AlternatePlayfieldRects ?? Array.Empty<RectF>() )
{
<div class="playfield-pattern alternate" style="@PatternStyle( Level.AlternatePlayfieldPattern, Level.AlternatePlayfieldCellScale, Level.AlternatePlayfieldPatternRows, Level.AlternateCheckerboardColor ?? PreviewBackground, Level.AlternateCheckerboardSecondColor, rect )"></div>
}
}
@if ( StaticThumbnail )
{
<Image class="static-thumbnail" Texture=@Thumbnail />
}
else
{
@* Drifting background-block squares. Created + animated imperatively (see Tick / BuildBlocks) under
this layer, so they can move every frame without rebuilding the panel. Sits behind the walls. *@
<div class="bg-layer" @ref="BgLayer"></div>
@foreach ( var block in LevelPreview.BuildSpawnBlocks( Level ) )
{
<div class="game-block" style="@BlockStyle( block )"></div>
}
@foreach ( var r in Model.Rects )
{
<div class="prect" style="@RectStyle( r )"></div>
}
}
@* Semitransparent map tag in the top-left corner (e.g. "3" or "2B"); hidden for off-map levels. *@
@if ( !string.IsNullOrEmpty( Tag ) )
{
<label class="tag" style="@TagStyle()">@Tag</label>
}
</root>
@code
{
[Parameter] public LevelDef Level { get; set; }
// When set (e.g. the local-replays list), nudge the corner tag up a couple extra pixels to sit
// better in that context's smaller preview.
[Parameter] public bool CompactTag { get; set; }
/// <summary>Replay lists share baked geometry and keep background squares stationary.</summary>
[Parameter] public bool StaticThumbnail { get; set; }
private Color PreviewBackground => Level?.CheckerboardColor ?? new Color( 0f, 189f / 255f, 196f / 255f );
private ReplayPreviewCache.Entry _thumbnail;
private LevelDef _thumbnailLevel;
private Texture Thumbnail
{
get
{
if ( _thumbnail is null || !ReferenceEquals( _thumbnailLevel, Level ) )
{
var next = ReplayPreviewCache.Acquire( Level );
ReplayPreviewCache.Release( _thumbnail );
_thumbnail = next;
_thumbnailLevel = Level;
}
return _thumbnail.Texture;
}
}
public override void OnDeleted()
{
base.OnDeleted();
ReplayPreviewCache.Release( _thumbnail );
_thumbnail = null;
}
// Grid tiles opt in: previews scrolled out of their scrolling ancestor's viewport skip the
// per-frame drift animation entirely (and defer building the drift-block panels until first
// visible), so a 250-tile template grid only pays for the visible window.
[Parameter] public bool CullOffscreen { get; set; }
// The level's ordinal tag on the level-select map (major = "1","2"…; side = "1A","1B"…), resolved
// from the map by level id. Empty for levels not on the map (test/editor previews).
private string Tag => LevelMap.Find( Level?.Id )?.Tag ?? "";
// Whether this level is a MAJOR (spine) node — spine tags render a touch larger and sit slightly
// lower than side-chain tags, which are nudged up a pixel.
private bool TagIsMajor => LevelMap.Find( Level?.Id )?.IsMajor ?? false;
// Per-context corner-tag tuning: spine tags are slightly larger; side tags sit 1px higher; and the
// local-replays list nudges everything up another 2px.
private string TagStyle()
{
float size = TagIsMajor ? 42f : 30f;
int offset = ( TagIsMajor ? -2 : -1 );
// local-replays list
if ( CompactTag )
{
size = 20f;
offset = -1;
}
return $"font-size:{Num( size )}px;margin-top:{offset}px;";
}
// One tooth of a spike strip, in logical arena units (exaggerated from the 5px baked tile so the
// teeth read at node size).
private const float SPIKE_TOOTH = 12f;
private LevelPreviewModel _model;
private LevelDef _builtLevel;
// Reuse static geometry across map visits and previews recreated while scrolling. Definitions
// are immutable authored data; editing/reloading replaces the object, so it gets a fresh entry.
// Bound retained definitions (including old editor revisions) rather than caching indefinitely.
private const int MODEL_CACHE_LIMIT = 128;
private static readonly Dictionary<LevelDef, LevelPreviewModel> ModelCache = new();
private static readonly Queue<LevelDef> ModelCacheOrder = new();
private static LevelPreviewModel GetModel( LevelDef level )
{
if ( level is null ) return LevelPreview.Build( null );
if ( ModelCache.TryGetValue( level, out var model ) ) return model;
model = LevelPreview.Build( level );
while ( ModelCache.Count >= MODEL_CACHE_LIMIT )
ModelCache.Remove( ModelCacheOrder.Dequeue() );
ModelCache.Add( level, model );
ModelCacheOrder.Enqueue( level );
return model;
}
// Lazily (re)built when the level changes; the schematic is otherwise static.
private LevelPreviewModel Model
{
get
{
if ( _model is null || !ReferenceEquals( _builtLevel, Level ) )
{
_model = GetModel( Level );
_builtLevel = Level;
}
return _model;
}
}
// The decorative drifting-block squares are managed imperatively (created once per level, then
// moved every frame in Tick) rather than emitted as static markup, so they can animate without
// rebuilding the panel each frame. Layout (count / positions / sizes / start direction) is seeded
// deterministically from the level Id so a node keeps a stable arrangement; the colours are the
// level's real drifting-block palette (front -> back).
private Panel BgLayer { get; set; }
private sealed class BgBlock
{
public Panel Panel;
public Vector2 Pos; // logical arena space (origin bottom-left, +Y up)
public Vector2 Vel; // logical units / second
public float Size;
public Color Color;
public int Depth;
}
private readonly List<BgBlock> _blocks = new();
private bool _blocksBuilt;
private LevelDef _blocksLevel;
// Per-frame hook (Panel.Tick): advance each block along its cardinal velocity and bounce it off the
// preview square's bounds. Lightweight — only inline left/top are mutated, no panel rebuild.
public override void Tick()
{
base.Tick();
if ( StaticThumbnail ) return;
if ( BgLayer is null ) return;
if ( CullOffscreen && !InScrollViewport() ) return;
// Rebuild when the level changes, or when a panel re-render (hot-reload / reconcile) has WIPED the
// imperatively-created child panels — they live under BgLayer but aren't in the declarative
// template, so a render pass can delete them while _blocks still holds stale (deleted) refs.
if ( !_blocksBuilt || !ReferenceEquals( _blocksLevel, Level ) || BgLayer.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's bounds (not obstacles) 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 );
}
}
// Whether this preview's screen rect intersects every scrolling ancestor's viewport. Unlaid-out
// panels (zero rect, e.g. the frame the grid is created) count as offscreen — they're invisible,
// and it defers all 250 tiles' BuildBlocks past the open frame.
private bool InScrollViewport()
{
var rect = Box.Rect;
if ( rect.Width <= 0f || rect.Height <= 0f ) return false;
for ( var p = Parent; p is not null; p = p.Parent )
{
if ( p.ComputedStyle?.Overflow == OverflowMode.Scroll && !p.Box.Rect.IsInside( rect ) )
return false;
}
return true;
}
// (Re)create the block panels for the current level. Layout comes from the SHARED generator
// (LevelPreview.BuildBgBlocks — also consumed by the baked workshop thumbnail, so the two can't
// drift apart); this side only owns the panels and their animation.
private void BuildBlocks()
{
_blocks.Clear();
BgLayer.DeleteChildren( true );
_blocksBuilt = true;
_blocksLevel = Level;
foreach ( var block in LevelPreview.BuildBgBlocks( Level, Model ) )
{
var b = new BgBlock
{
Pos = block.Pos,
Vel = block.Vel,
Size = block.Size,
Color = block.Color,
Depth = block.Depth,
};
b.Panel = new Panel( BgLayer, "bg-block" );
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 arena.
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 );
}
// The blocks on their spawn slots come from the shared generator too (see LevelPreview.BuildSpawnBlocks).
private static string BlockStyle( (BlockType Type, Vector2 Pos) block )
{
const float size = 40f;
return $"left:{Pct( block.Pos.x - size * 0.5f )};"
+ $"top:{Pct( Arena.HEIGHT - block.Pos.y - size * 0.5f )};"
+ $"width:{Pct( size )};height:{Pct( size )};"
+ $"background-color:{Css( LevelPreview.BlockFallbackColor( block.Type ) )};"
+ $"background-image:url( sprites/ui/blocks/{LevelPreview.BlockPreviewImage( block.Type )}.png );";
}
private static string PatternStyle( PlayfieldPattern pattern, int cellScale, IReadOnlyList<string> customRows, Color light, Color? second, RectF? bounds )
{
RectF rect = bounds ?? new RectF( 0f, 0f, Arena.WIDTH, Arena.HEIGHT );
float width = MathF.Max( 1f, rect.Width );
float height = MathF.Max( 1f, rect.Height );
float scale = Math.Clamp( cellScale, 1, 8 );
Color dark = second ?? new Color( light.r * (235f / 255f), light.g * (235f / 255f), light.b * (235f / 255f), light.a );
string box = $"left:{Pct( rect.Left )};top:{Pct( Arena.HEIGHT - rect.Top )};width:{Pct( width )};height:{Pct( height )};";
string colors = $"background-color:{Css( light )};";
if ( pattern == PlayfieldPattern.Checker )
{
float imageWidth = Arena.WIDTH * scale / width * 100f;
float imageHeight = Arena.HEIGHT * scale / height * 100f;
return box + colors + "background-image:url( sprites/background_neutral.png );background-repeat:repeat;"
+ $"background-size:{Num( imageWidth )}% {Num( imageHeight )}%;background-tint:{Css( light )};image-rendering:pixelated;";
}
float tileX = MathF.Max( 0.1f, scale / width * 100f );
float tileY = MathF.Max( 0.1f, scale / height * 100f );
string lightCss = Css( light );
string darkCss = Css( dark );
// The checker branches set background-tint/image-rendering; the gradient branches must reset
// both to their defaults, or a reconciled panel reused across patterns (the template grid
// re-sorting) keeps the checker's stale tint/pixelation — Styles.Set never clears absentees.
const string gradientReset = "background-tint:white;image-rendering:auto;";
return pattern switch
{
PlayfieldPattern.HorizontalStripes => box + colors + gradientReset
+ $"background-image:linear-gradient(to bottom,{lightCss} 0%,{lightCss} 50%,{darkCss} 50%,{darkCss} 100%);background-size:100% {Num( tileY * 2f )}%;background-repeat:repeat;",
PlayfieldPattern.VerticalStripes => box + colors + gradientReset
+ $"background-image:linear-gradient(to right,{lightCss} 0%,{lightCss} 50%,{darkCss} 50%,{darkCss} 100%);background-size:{Num( tileX * 2f )}% 100%;background-repeat:repeat;",
PlayfieldPattern.Diagonal => box + colors + gradientReset
+ $"background-image:linear-gradient(to bottom right,{lightCss} 0%,{lightCss} 25%,{darkCss} 25%,{darkCss} 50%,{lightCss} 50%,{lightCss} 75%,{darkCss} 75%,{darkCss} 100%);background-size:{Num( tileX * 4f )}% {Num( tileY * 4f )}%;background-repeat:repeat;",
PlayfieldPattern.Grid => box + colors + gradientReset
+ $"background-image:linear-gradient(to bottom right,{darkCss} 0%,{darkCss} 18%,{lightCss} 18%,{lightCss} 50%,{darkCss} 50%,{darkCss} 68%,{lightCss} 68%,{lightCss} 100%);background-size:{Num( tileX * 4f )}% {Num( tileY * 4f )}%;background-repeat:repeat;",
PlayfieldPattern.Dots => CheckerFallback( box, colors, light, width, height, scale * 4f ),
_ => CheckerFallback( box, colors, light, width, height,
scale * Math.Max( 1, Math.Max( customRows?.Count ?? 0, customRows is { Count: > 0 } ? customRows[0]?.Length ?? 0 : 0 ) ) ),
};
}
private static string CheckerFallback( string box, string colors, Color light, float width, float height, float scale )
{
float imageWidth = Arena.WIDTH * scale / width * 100f;
float imageHeight = Arena.HEIGHT * scale / height * 100f;
return box + colors + "background-image:url( sprites/background_neutral.png );background-repeat:repeat;"
+ $"background-size:{Num( imageWidth )}% {Num( imageHeight )}%;background-tint:{Css( light )};image-rendering:pixelated;";
}
private string RectStyle( PreviewRect r )
{
// Logical (bottom-left origin, +Y up) -> CSS percentages of the 240x240 arena.
// Slightly overdraw rectangles so node-scale animations cannot reveal sub-pixel seams between
// adjacent obstacle fill/wall pieces, or between a flush fill and the clipped preview edge.
const float seamPad = 0.75f;
float x = Math.Clamp( r.X - seamPad, -seamPad, Arena.WIDTH + seamPad );
float y = Math.Clamp( r.Y - seamPad, -seamPad, Arena.HEIGHT + seamPad );
float right = Math.Clamp( r.X + r.W + seamPad, -seamPad, Arena.WIDTH + seamPad );
float top = Math.Clamp( r.Y + r.H + seamPad, -seamPad, Arena.HEIGHT + seamPad );
string box = $"left:{Pct( x )};top:{Pct( Arena.HEIGHT - top )};width:{Pct( right - x )};height:{Pct( top - y )};";
// IMPORTANT: both branches must set the UNION of the properties either can set. Razor
// reconciliation reuses these .prect panels across re-renders (a template-grid re-sort hands a
// tile a different level), and the engine's inline-style update only APPLIES the new string's
// properties — it never clears ones that go missing (Styles.Set parses into the existing
// collection). A plain fill reused from a teeth panel kept the teeth art (white stripes inside
// obstacles); a teeth panel reused from a coin rect kept the yellow fill behind its teeth.
if ( r.Spike == PreviewSpike.None )
return box + $"background-color: {Css( r.Color )}; background-image: none;";
// Teeth OVERLAY: this rect sits on top of a separate flat-coloured band rect, so it carries ONLY
// the band-removed teeth art (tile_*_teeth_4), drawn WHITE with no fill. The transparent band
// region shows the coloured band beneath, and the gaps between teeth stay transparent (the arena
// shows through) — matching the in-game WallFace exactly. Horizontal edges tile along X, vertical
// along Y. The teeth always read white regardless of the level's wall colour, like the game.
bool horizontal = r.Spike == PreviewSpike.Up || r.Spike == PreviewSpike.Down;
float span = horizontal ? r.W : r.H;
float toothPct = span > 0f ? SPIKE_TOOTH / span * 100f : 100f;
string size = horizontal ? $"{Num( toothPct )}% 100%" : $"100% {Num( toothPct )}%";
string repeat = horizontal ? "repeat-x" : "repeat-y";
return box
+ "background-color: transparent;"
+ $"background-image: url( sprites/ui/arena/tile_{TileDir( r.Spike )}_teeth_4.png );"
+ $"background-repeat: {repeat}; background-size: {size};"
+ $"background-tint: {Css( r.Tint )}; image-rendering: pixelated;";
}
// The baked tile whose spikes point in the growth direction: the pre-rotated art is named for the
// wall it belongs to, which is the OPPOSITE side of the growth direction (e.g. spikes-up = the
// bottom "down" wall art).
private static string TileDir( PreviewSpike s ) => s switch
{
PreviewSpike.Up => "down",
PreviewSpike.Down => "up",
PreviewSpike.Left => "right",
PreviewSpike.Right => "left",
_ => "down",
};
private static string Pct( float logical ) => Num( logical / Arena.WIDTH * 100f ) + "%";
private static string Num( float v ) => v.ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture );
private static string Css( Color c ) =>
$"rgba( {(int)( c.r * 255f )}, {(int)( c.g * 255f )}, {(int)( c.b * 255f )}, {Num( c.a )} )";
protected override int BuildHash() => System.HashCode.Combine( Level, Level?.PlayfieldPattern, Level?.PlayfieldCellScale,
Level?.AlternatePlayfieldEnabled, Level?.AlternatePlayfieldPattern, Level?.AlternatePlayfieldCellScale,
HashCode.Combine( Level?.AlternatePlayfieldRects?.Count ?? 0, StaticThumbnail, CompactTag ) );
}