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 );
		}
	}
}