SpriteManager.cs
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Sandbox;
/// <summary>
/// JSON-serialisable container for all sprite data within a single file.
/// Each file holds one "sprite type" (e.g. "player", "box", "text") with
/// one or more named animations.
///
/// Expected location: <c>Assets/sprites/{spriteName}.sprite.json</c>
/// </summary>
public class SpriteFile
{
/// <summary>Sprite type name, e.g. "player".</summary>
public string Name { get; set; }
/// <summary>All animations belonging to this sprite.</summary>
public List<SpriteAnimJson> Animations { get; set; }
}
/// <summary>
/// JSON shape for one animation within a sprite file.
/// </summary>
public class SpriteAnimJson
{
public string Name { get; set; }
public int[] Size { get; set; }
public int[] Hitbox { get; set; }
[JsonConverter( typeof( JsonStringEnumConverter ) )]
public LoopMode LoopMode { get; set; }
public List<SpriteFrameJson> Frames { get; set; }
}
/// <summary>
/// JSON shape for one frame.
/// The <c>Pixels</c> field is a base64-encoded RGBA byte array
/// (width × height × 4 bytes, row-major, y=0 is bottom).
/// </summary>
public class SpriteFrameJson
{
public float Time { get; set; }
public string Pixels { get; set; }
/// <summary>Optional per-frame hitbox [left, bottom, width, height].</summary>
public int[] Hitbox { get; set; }
}
/// <summary>
/// Centralized store for all loaded sprite animation data.
///
/// At startup (or on demand) loads every <c>.sprite.json</c> file found under
/// <c>Assets/sprites/</c>. Game code looks up animations via
/// <see cref="GetAnimation"/> and <see cref="GetAnimations"/>.
///
/// Replaces the Unity framework's SpriteManager + custom text parser, using
/// JSON for a more readable and tooling-friendly format.
/// </summary>
public static class SpriteManager
{
// Keyed by sprite type name → list of animations for that type.
private static Dictionary<string, List<AnimationData>> _animations = new();
/// <summary>
/// Whether sprite data has been loaded yet.
/// </summary>
public static bool IsLoaded { get; private set; }
/// <summary>
/// Load all <c>.sprite.json</c> files from the virtual filesystem.
/// Safe to call multiple times — subsequent calls are no-ops.
/// </summary>
public static void LoadAll()
{
if ( IsLoaded )
return;
IsLoaded = true;
_animations.Clear();
// Find all .sprite.json files in the project's mounted filesystem.
IEnumerable<string> files = FileSystem.Mounted.FindFile( "/", "*.sprite.json", true );
foreach ( string path in files )
{
string json = FileSystem.Mounted.ReadAllText( path );
if ( string.IsNullOrWhiteSpace( json ) )
continue;
try
{
SpriteFile file = JsonSerializer.Deserialize<SpriteFile>( json, JsonOptions );
if ( file?.Animations is null )
continue;
RegisterSpriteFile( file );
}
catch ( Exception ex )
{
Log.Error( $"SpriteManager: failed to parse {path}: {ex.Message}" );
}
}
Log.Info( $"SpriteManager: loaded {_animations.Count} sprite type(s)" );
}
/// <summary>
/// Force a full reload from disk. Useful after hot-reloading asset changes.
/// </summary>
public static void Reload()
{
IsLoaded = false;
LoadAll();
}
/// <summary>
/// Get all animations for a sprite type.
/// </summary>
public static List<AnimationData> GetAnimations( string spriteName )
{
EnsureLoaded();
if ( _animations.TryGetValue( spriteName, out List<AnimationData> list ) )
return list;
Log.Warning( $"SpriteManager: no sprite type called '{spriteName}'" );
return null;
}
/// <summary>
/// Get a specific named animation for a sprite type.
/// </summary>
public static AnimationData GetAnimation( string spriteName, string animName )
{
List<AnimationData> anims = GetAnimations( spriteName );
if ( anims is null )
return null;
foreach ( AnimationData anim in anims )
{
if ( anim.Name == animName )
return anim;
}
Log.Warning( $"SpriteManager: no animation '{animName}' for sprite '{spriteName}'" );
return null;
}
// ── Internal ─────────────────────────────────────────────────────────
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
private static void EnsureLoaded()
{
if ( !IsLoaded )
LoadAll();
}
private static void RegisterSpriteFile( SpriteFile file )
{
string typeName = file.Name;
if ( !_animations.ContainsKey( typeName ) )
_animations[typeName] = new List<AnimationData>();
foreach ( SpriteAnimJson anim in file.Animations )
{
PixelPoint size = new( anim.Size[0], anim.Size[1] );
// When no hitbox is specified in the JSON, default to the full sprite bounds.
PixelRect hitbox = anim.Hitbox is { Length: 4 }
? new PixelRect( anim.Hitbox[0], anim.Hitbox[1], anim.Hitbox[2], anim.Hitbox[3] )
: new PixelRect( 0, 0, size.X, size.Y );
List<FrameData> frames = new();
foreach ( SpriteFrameJson frame in anim.Frames )
{
List<PixelData> pixels = ParseFramePixels( frame.Pixels, size.X, size.Y );
PixelRect? frameHitbox = frame.Hitbox is { Length: 4 }
? new PixelRect( frame.Hitbox[0], frame.Hitbox[1], frame.Hitbox[2], frame.Hitbox[3] )
: null;
frames.Add( new FrameData( pixels, frame.Time, frameHitbox ) );
}
_animations[typeName].Add( new AnimationData( anim.Name, frames, size, hitbox, anim.LoopMode ) );
}
}
/// <summary>
/// Decode a base64-encoded RGBA pixel blob into a list of non-transparent pixels.
/// </summary>
private static List<PixelData> ParseFramePixels( string base64, int width, int height )
{
byte[] bytes = Convert.FromBase64String( base64 );
int expectedLength = width * height * 4;
if ( bytes.Length != expectedLength )
{
Log.Warning( $"SpriteManager: base64 pixel data length {bytes.Length} != expected {expectedLength}" );
return new List<PixelData>();
}
var pixels = new List<PixelData>();
for ( int y = 0; y < height; y++ )
{
for ( int x = 0; x < width; x++ )
{
int idx = (y * width + x) * 4;
byte r = bytes[idx + 0];
byte g = bytes[idx + 1];
byte b = bytes[idx + 2];
byte a = bytes[idx + 3];
// Sparse storage: skip fully transparent pixels so draw loops only iterate
// pixels that will actually write to the screen — saves memory and CPU.
if ( a == 0 ) continue;
pixels.Add( new PixelData( new PixelPoint( x, y ), new Color32( r, g, b, a ) ) );
}
}
return pixels;
}
}