Game/GifExporter.cs
using System;
using System.Collections.Generic;
using System.Text;
namespace BlockParty;
/// <summary>
/// Captures a segment of the currently-watched replay and writes it out as an animated GIF.
///
/// Capture is driven incrementally (call <see cref="Step"/> once per UI tick), one frame per engine
/// update — a hard limit, not a pacing choice: sprite batches upload once per main-loop update, so a seek
/// (<see cref="GameManager.SeekReplayToFrame"/>) only becomes renderable the following update. Each step
/// captures the previously seeked tick into an <see cref="OutputSize"/> bitmap (<c>RenderToBitmap</c>), feeds the pixels
/// straight into the incremental <see cref="GifEncoder"/>, then seeks the next tick. When every tick has
/// been captured it finishes the GIF and writes the bytes to
/// the <c>gifs/</c> folder of the game data directory (<see cref="FileSystem.Data"/>, see
/// <see cref="DirName"/>) — the sbox sandbox has no native save dialog, so we surface the absolute
/// path instead (<see cref="ResultPath"/>).
///
/// The export re-frames the camera tight on the arena while it captures (independent of the overlay's
/// shrunk preview framing), so the GIF is always the full arena regardless of what the preview is showing,
/// then restores the preview framing when done.
///
/// Frames bake the retro quantize: rendered at the native 240px grid (one game pixel per texel — the CRT
/// shader's quantize) and integer-upscaled nearest-neighbour to <see cref="Scale"/>. Scanlines/curvature/
/// vignette/halation are deliberately left out — chunky pixels only.
/// </summary>
public sealed class GifExporter
{
public enum Phase { Capturing, Done, Failed }
/// <summary>Exported GIFs live in their own subfolder of the game data directory, beside the
/// player's <c>levels/</c> folder (see <see cref="LocalLevels"/>).</summary>
public const string DirName = "gifs";
/// <summary>Largest integer upscale of the 240px arena offered by the export overlay (3x = 720px).</summary>
public const int MaxScale = 3;
/// <summary>Upscale used when the player hasn't picked one (see <see cref="GameSettings.GifScale"/>).</summary>
public const int DefaultScale = 3;
/// <summary>Clamp to the offered 1..<see cref="MaxScale"/> range. The single validation point — applied by
/// the <see cref="GameSettings.GifScale"/> setter, which every export/estimate reads its scale from.</summary>
public static int ClampScale( int scale ) => Math.Clamp( scale, 1, MaxScale );
/// <summary>Square output size for an integer upscale of the arena's native pixel grid.</summary>
public static int OutputSizeFor( int scale ) => Arena.HEIGHT * scale;
/// <summary>Integer upscale from the arena's native pixel grid this export renders at.</summary>
public int Scale { get; }
/// <summary>Output GIF dimensions (square): the 240px arena at <see cref="Scale"/>.</summary>
public int OutputSize => OutputSizeFor( Scale );
/// <summary>Per-frame delay in centiseconds. Every tick is captured and played at 2cs (~50fps) — the
/// fastest delay browsers reliably honour, so it's the smoothest near-real-time playback a GIF allows
/// (60Hz gameplay shown at 50fps). Anything faster (1cs) gets clamped to 10fps by most browsers.</summary>
public const int FrameDelayCs = 2;
/// <summary>Hard cap on the number of frames (every tick is captured). Frames are compressed as they're
/// captured (see GifEncoder), so memory isn't the limit — this bounds export time (one frame per engine
/// update, see Step) and the output file size (frames are delta-encoded, so per-frame cost scales with
/// how much of the arena changes). ~1800 frames at 60Hz is 30s of gameplay.</summary>
public const int MaxFrames = 1800;
public Phase State { get; private set; } = Phase.Capturing;
public float Progress { get; private set; }
public string ResultPath { get; private set; }
public string Error { get; private set; }
private readonly GameManager _mgr;
private readonly List<int> _ticks = new();
private readonly int _delayCs;
private readonly string _fileName;
// Frames are quantized + compressed straight into this writer as they're captured (no raw-frame buffer).
private readonly GifEncoder _writer;
private int _cursor;
private bool _pendingCapture;
private bool _started;
private Vector3 _savedCamPos;
private float _savedOrtho;
/// <param name="mgr">The game manager owning the replay being captured.</param>
/// <param name="startTick">First recorded tick of the segment (inclusive).</param>
/// <param name="endTick">Last recorded tick of the segment (inclusive).</param>
/// <param name="fileName">Destination file name within the data folder's <see cref="DirName"/> directory.</param>
/// <param name="scale">Integer upscale of the arena (<see cref="GameSettings.GifScale"/>); see <see cref="Scale"/>.</param>
public GifExporter( GameManager mgr, int startTick, int endTick, string fileName, int scale )
{
_mgr = mgr;
Scale = scale;
int last = Math.Max( 0, mgr.ReplayLength - 1 );
startTick = Math.Clamp( startTick, 0, last );
endTick = Math.Clamp( endTick, startTick, last );
// Capture every tick across the span.
for ( int t = startTick; t <= endTick; t++ )
_ticks.Add( t );
_delayCs = FrameDelayCs;
_fileName = fileName;
_writer = new GifEncoder( OutputSize, OutputSize, 0 ); // always loop
}
/// <summary>Total frames the finished GIF will contain.</summary>
public int TotalFrames => _ticks.Count;
/// <summary>Advance the export by one chunk. Safe to call every tick; does nothing once finished.</summary>
public void Step()
{
if ( State != Phase.Capturing )
return;
if ( _mgr is null || !_mgr.IsReplaying || _mgr.Camera is null )
{
Fail( "replay is no longer active" );
return;
}
if ( !_started )
{
_started = true;
SetTightFraming();
}
try
{
// Sprite batches upload to the GPU once per main-loop update (SceneSpriteSystem, at
// Stage.FinishUpdate), and RenderToBitmap draws whatever was last uploaded — a seek made THIS
// update can only be captured NEXT update. So the capture is pipelined at one frame per update:
// capture the tick seeked last update, then seek the next one. (Capturing more per update just
// duplicates the same image — that bug shipped as 4 identical frames per update for a while.)
if ( _pendingCapture )
{
_writer.AddFrame( CaptureFrame(), _delayCs ); // quantized + compressed now; pixels not retained
_pendingCapture = false;
_cursor++;
}
Progress = _ticks.Count == 0 ? 1f : (float)_cursor / _ticks.Count;
if ( _cursor >= _ticks.Count )
{
Finish();
return;
}
_mgr.SeekReplayToFrame( _ticks[_cursor], playSfx: false );
_pendingCapture = true;
}
catch ( Exception e )
{
Log.Warning( $"BlockParty GIF export failed: {e}" );
Fail( e.Message );
}
}
private void Finish()
{
var bytes = _writer.Finish();
var path = $"{DirName}/{_fileName}";
FileSystem.Data.CreateDirectory( DirName );
FileSystem.Data.WriteAllBytes( path, bytes );
ResultPath = FileSystem.Data.GetFullPath( path );
Log.Info( $"BlockParty GIF export: wrote {TotalFrames} frames ({bytes.Length / 1024} KB) to {ResultPath}" );
// Only a completed export counts (Fail() doesn't reach here). Deduplicated per replay inside.
Achievements.SubmitGifExport( _mgr.CurrentReplayKey );
State = Phase.Done;
RestoreFraming();
}
private void Fail( string reason )
{
Error = reason;
State = Phase.Failed;
if ( _started )
RestoreFraming();
}
private Color32[] CaptureFrame() => Upscale( CaptureNative( _mgr ), Scale );
// Render the camera's current (tight-framed) view at the arena's native pixel grid — one game pixel
// per texel, which IS the CRT shader's 240px quantize — and read the pixels back. GetPixels32 is
// row-major top-down, the orientation GifEncoder expects, so no flip is needed. Internal so
// GifSizeEstimator samples through the identical pipeline (and caches the native frames).
internal static Color32[] CaptureNative( GameManager mgr )
{
using var bmp = new Bitmap( Arena.HEIGHT, Arena.HEIGHT );
mgr.Camera.RenderToBitmap( bmp, false );
return bmp.GetPixels32();
}
// Plain nearest-neighbour integer upscale of a native frame: crisp chunky pixels, no shading — the
// frame's palette stays exactly the source colors (a scanline mask was tried and removed by request).
// 1x returns the source as-is.
internal static Color32[] Upscale( Color32[] src, int scale )
{
if ( scale == 1 )
return src;
int size = OutputSizeFor( scale );
var dst = new Color32[size * size];
for ( int y = 0; y < Arena.HEIGHT; y++ )
{
int srcRow = y * Arena.HEIGHT;
for ( int sy = 0; sy < scale; sy++ )
{
int d = (y * scale + sy) * size;
for ( int x = 0; x < Arena.HEIGHT; x++ )
{
var c = src[srcRow + x];
for ( int sx = 0; sx < scale; sx++ )
dst[d++] = c;
}
}
}
return dst;
}
private void SetTightFraming()
{
var c = _mgr.Camera;
_savedCamPos = c.WorldPosition;
_savedOrtho = c.OrthographicHeight;
c.WorldPosition = new Vector3( Arena.WIDTH / 2f, Arena.HEIGHT / 2f, _savedCamPos.z );
c.OrthographicHeight = Arena.HEIGHT;
}
private void RestoreFraming()
{
var c = _mgr?.Camera;
if ( c is null )
return;
c.WorldPosition = _savedCamPos;
c.OrthographicHeight = _savedOrtho;
}
// ── static helpers (used by the overlay's readout) ──────────────────────────────────────────────
/// <summary>Frames a GIF of this segment would contain (every tick is captured), for the readout.</summary>
public static int FrameCount( int startTick, int endTick, int replayLength )
{
int last = Math.Max( 0, replayLength - 1 );
startTick = Math.Clamp( startTick, 0, last );
endTick = Math.Clamp( endTick, startTick, last );
return endTick - startTick + 1;
}
/// <summary>
/// Build a short, filesystem-safe GIF name from the Steam name of the player who recorded the replay
/// (falling back to the local player for own runs), the level's map tag and display name, today's
/// date, the segment, the output size and the run's input hash:
/// e.g. <c>player_3A_Spike-Garden_2026-06-30_120-480_720px_a1b2c3d4.gif</c>.
/// Tag and level name are omitted when the level doesn't have them. The size is always present (not
/// just for non-default scales) so exports of one span at different sizes never overwrite each other,
/// whatever the default becomes. The run hash keeps two runs of one level exported the same day with
/// the same span and size apart (the default span is the same for every run longer than the frame
/// cap), while re-exporting the same clip of the same run still replaces the earlier file.
/// </summary>
public static string BuildFileName( GameManager mgr, int startTick, int endTick, int scale )
{
string name = "player";
try
{
var submitter = mgr is not null ? mgr.ReplaySubmitterInfo : default;
string n = submitter.Name;
if ( string.IsNullOrWhiteSpace( n ) )
{
long steamId = submitter.SteamId != 0 ? submitter.SteamId : (long)Game.SteamId;
n = new Friend( steamId ).Name;
}
if ( !string.IsNullOrWhiteSpace( n ) )
name = n;
}
catch { /* keep the fallback */ }
name = Sanitize( name, 16 );
string tag = "", level = "";
if ( mgr?.Stage is GameStage stage && stage.Level is LevelDef def )
{
level = Sanitize( def.Name ?? "", 24, fallback: "" );
if ( !string.IsNullOrEmpty( def.Id ) )
tag = Sanitize( LevelMap.Find( def.Id )?.Tag ?? "", 8, fallback: "" );
}
// DateTimeOffset.Now is the sandbox-legal "now" the game already uses (LocalReplays); read its
// numeric components directly rather than a culture-dependent date format string.
var now = DateTimeOffset.Now;
string date = $"{now.Year}-{now.Month:00}-{now.Day:00}";
string range = $"{startTick}-{endTick}";
string size = $"{OutputSizeFor( scale )}px";
string run = mgr?.CurrentReplayRunHash ?? "";
var parts = new[] { name, tag, level, date, range, size, run }.Where( p => p != "" );
return $"{string.Join( "_", parts )}.gif";
}
// Keep [A-Za-z0-9-_], collapse runs of anything else to a single underscore, trim and truncate.
private static string Sanitize( string s, int maxLen, string fallback = "player" )
{
var sb = new StringBuilder( s.Length );
bool lastUnderscore = false;
foreach ( char c in s )
{
bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-';
if ( ok )
{
sb.Append( c );
lastUnderscore = false;
}
else if ( !lastUnderscore )
{
sb.Append( '_' );
lastUnderscore = true;
}
}
string result = sb.ToString().Trim( '_', '-' );
if ( result.Length > maxLen )
result = result[..maxLen].Trim( '_', '-' );
return string.IsNullOrEmpty( result ) ? fallback : result;
}
}