Game/WorkshopThumbnail.cs
using System;
using System.Collections.Generic;
using Sandbox;
namespace BlockParty;
/// <summary>
/// Renders a level's workshop thumbnail — the image on the item's Steam web page (in-game browse
/// tiles draw a live <see cref="LevelNodePreview"/> instead). Rasterises the same
/// <see cref="LevelPreview"/> schematic the UI previews draw, into a square opaque
/// <see cref="Bitmap"/> (the publish modal wants 512x512, no transparency), and imitates the node
/// preview's dressing: the level's drifting-block squares (static, same deterministic layout), the
/// real block sprites on their spawn slots, and spike strips as actual teeth triangles.
/// NOTE: always build Rects via <c>new Rect(x, y, w, h)</c> — Bitmap's (x,y,w,h) DrawRect overload
/// mis-constructs its Rect (passes x+w/y+h into a width/height ctor) and draws oversized.
/// </summary>
public static class WorkshopThumbnail
{
/// <summary>One tooth of a spike strip, logical units (matches LevelNodePreview.SPIKE_TOOTH).</summary>
private const float SPIKE_TOOTH = 12f;
public static Bitmap Render( LevelDef def, int size = 512, bool previewOverlay = false )
{
var model = LevelPreview.Build( def );
var bmp = new Bitmap( size, size );
bmp.SetAntialias( false ); // crisp edges, matching the game's chunky look
// The playfield is a 1px light/dark dither in game; at thumbnail scale that reads as the
// average of the two shades, so paint that flat (dark = light * 235/255, see LevelPreview).
float mix = (1f + 235f / 255f) * 0.5f;
var bg = model.Background;
// Replay previews retain their CSS playfield patterns beneath this shared static overlay.
bmp.Clear( previewOverlay ? Color.Transparent : new Color( bg.r * mix, bg.g * mix, bg.b * mix, 1f ) );
float scale = size / (float)Arena.WIDTH;
// Logical space is bottom-left origin, +Y up; the bitmap is top-left origin, +Y down.
Rect Map( float x, float y, float w, float h ) => new( x * scale, size - (y + h) * scale, w * scale, h * scale );
DrawBackgroundBlocks( bmp, model, def, Map );
DrawSpawnBlocks( bmp, def, Map );
foreach ( var r in model.Rects )
{
if ( r.Spike == PreviewSpike.None )
{
bmp.SetFill( r.Color );
bmp.DrawRect( Map( r.X, r.Y, r.W, r.H ) );
}
else
{
DrawTeeth( bmp, r, size, scale );
}
}
if ( !previewOverlay ) DrawForcedCharacter( bmp, def, Map );
return bmp;
}
// Forced-character badge in the lower-left corner, matching the editor browser's tiles: the soft
// glow behind the character sprite so its dark pixels read against the level. Sized like the
// browser's 40px-marker-on-a-150px-tile, converted to logical units (x 240/150).
private static void DrawForcedCharacter( Bitmap bmp, LevelDef def, Func<float, float, float, float, Rect> map )
{
if ( !Characters.TryGet( def?.ForcedCharacterId, out var character ) )
return;
float marker = 40f * (240f / 150f) * character.PreviewScale;
const float inset = 11f;
// Glow: 222% of the marker, centred on it, at the UI badge's 0.3 opacity (the paint's alpha
// modulates bitmap draws).
float glow = marker * 2.22f;
float gx = inset + (marker - glow) * 0.5f;
using ( var glowSprite = LoadSprite( "sprites/ui/icon_glow.png" ) )
{
if ( glowSprite is not null )
{
bmp.SetFill( Color.White.WithAlpha( 0.3f ) );
bmp.DrawBitmap( glowSprite, map( gx, gx, glow, glow ) );
}
}
bmp.SetFill( Color.White );
// A paired character (the twins) composes like the picker icon: the partner behind, up-right
// and slightly smaller; the character in front, down-left. The CSS offsets are translate(±11%,
// ∓9%) in screen space (+Y down); logical space is +Y up, so the signs flip on Y.
float cx = inset + marker * 0.5f, cy = inset + marker * 0.5f;
if ( character.Partner is CharacterDef partner )
{
float rear = marker * 0.94f * partner.PreviewScale / character.PreviewScale;
DrawContained( bmp, partner.PreviewImage, cx + marker * 0.11f, cy + marker * 0.09f, rear, map );
DrawContained( bmp, character.PreviewImage, cx - marker * 0.11f, cy - marker * 0.09f, marker, map );
return;
}
DrawContained( bmp, character.PreviewImage, cx, cy, marker, map );
}
// Draw a sprite fitted into a square box centred on (cx, cy), preserving its aspect — the bitmap
// equivalent of the UI badges' background-size: contain, so a non-square preview (they're 12x14)
// isn't stretched to the square.
private static void DrawContained( Bitmap bmp, string path, float cx, float cy, float box,
Func<float, float, float, float, Rect> map )
{
using var sprite = LoadSprite( path );
if ( sprite is null || sprite.Width <= 0 || sprite.Height <= 0 ) return;
float aspect = sprite.Width / (float)sprite.Height;
float w = aspect < 1f ? box * aspect : box;
float h = aspect < 1f ? box : box / aspect;
bmp.DrawBitmap( sprite, map( cx - w * 0.5f, cy - h * 0.5f, w, h ) );
}
private static Bitmap LoadSprite( string path )
{
try { return Bitmap.CreateFromBytes( FileSystem.Mounted.ReadAllBytes( path ).ToArray() ); }
catch { return null; }
}
// The level's decorative drifting-block squares — the SHARED deterministic layout the node
// preview animates (LevelPreview.BuildBgBlocks), drawn static here since a PNG can't drift.
private static void DrawBackgroundBlocks( Bitmap bmp, LevelPreviewModel model, LevelDef def, Func<float, float, float, float, Rect> map )
{
foreach ( var b in LevelPreview.BuildBgBlocks( def, model ) )
{
bmp.SetFill( b.Color );
bmp.DrawRect( map( b.Pos.x, b.Pos.y, b.Size, b.Size ) );
}
}
// The blocks on their spawn slots (shared picker: LevelPreview.BuildSpawnBlocks). Real sprite art
// when it loads, the previews' shared fallback colour square otherwise.
private static void DrawSpawnBlocks( Bitmap bmp, LevelDef def, Func<float, float, float, float, Rect> map )
{
const float SIZE = 40f;
foreach ( var (type, pos) in LevelPreview.BuildSpawnBlocks( def ) )
{
var dest = map( pos.x - SIZE * 0.5f, pos.y - SIZE * 0.5f, SIZE, SIZE );
using var sprite = LoadSprite( $"sprites/ui/blocks/{LevelPreview.BlockPreviewImage( type )}.png" );
if ( sprite is not null )
{
bmp.SetFill( Color.White ); // full paint alpha — it modulates bitmap draws
bmp.DrawBitmap( sprite, dest );
}
else
{
bmp.SetFill( LevelPreview.BlockFallbackColor( type ) );
bmp.DrawRect( dest );
}
}
}
// A teeth OVERLAY rect covers band + spike depth (each half, since WALL_VIS == SPIKE_EXTRA); the
// band beneath is its own flat rect, so this draws only the triangles: bases on the rect's
// midline, apexes on the spike-direction edge, tiled by stretching whole teeth across the span
// (like the UI's tiling art). Filled with the strip's tint (white; glass-shaded for glass).
private static void DrawTeeth( Bitmap bmp, PreviewRect r, int size, float scale )
{
bool horizontal = r.Spike is PreviewSpike.Up or PreviewSpike.Down;
float span = horizontal ? r.W : r.H;
if ( span <= 0f ) return;
int count = Math.Max( 1, (int)MathF.Round( span / SPIKE_TOOTH ) );
float tooth = span / count;
Vector2 Px( float x, float y ) => new( x * scale, size - y * scale );
bmp.SetFill( r.Tint );
for ( int i = 0; i < count; i++ )
{
float a = (horizontal ? r.X : r.Y) + i * tooth;
Vector2[] points = r.Spike switch
{
PreviewSpike.Up => new[] { Px( a, r.Y + r.H * 0.5f ), Px( a + tooth, r.Y + r.H * 0.5f ), Px( a + tooth * 0.5f, r.Y + r.H ) },
PreviewSpike.Down => new[] { Px( a, r.Y + r.H * 0.5f ), Px( a + tooth, r.Y + r.H * 0.5f ), Px( a + tooth * 0.5f, r.Y ) },
PreviewSpike.Right => new[] { Px( r.X + r.W * 0.5f, a ), Px( r.X + r.W * 0.5f, a + tooth ), Px( r.X + r.W, a + tooth * 0.5f ) },
_ => new[] { Px( r.X + r.W * 0.5f, a ), Px( r.X + r.W * 0.5f, a + tooth ), Px( r.X, a + tooth * 0.5f ) }, // Left
};
bmp.DrawPolygon( points );
}
}
}