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