SpriteCatalog.cs

Static sprite catalog for UI, maps ids to embedded or package PNGs, caches created Texture objects, supports warmup and invalidation, and logs missing sprites.

File Access
namespace NoChillquarium;

/// <summary>
/// Loads game sprites as <see cref="Texture"/> for UI panels.
/// Runtime uses embedded PNG bytes (reliable); package paths under
/// <c>Assets/sprites/</c> remain for the Sprite Editor.
/// </summary>
public static class SpriteCatalog
{
	static readonly Dictionary<string, Texture> _textures = new( StringComparer.OrdinalIgnoreCase );
	static readonly Dictionary<string, bool> _failed = new( StringComparer.OrdinalIgnoreCase );
	static int _version;
	static bool _quietLoad;

	static readonly Dictionary<string, string> FishPaths = new( StringComparer.OrdinalIgnoreCase )
	{
		["goldfish"] = "sprites/fish/goldfish.png",
		["neon_tetra"] = "sprites/fish/neon_tetra.png",
		["betta"] = "sprites/fish/betta.png",
		["clownfish"] = "sprites/fish/clownfish.png",
		["blue_tang"] = "sprites/fish/blue_tang.png",
		["cocaine_shark"] = "sprites/fish/cocaine_shark.png",
	};

	static readonly Dictionary<string, string> DecorPaths = new( StringComparer.OrdinalIgnoreCase )
	{
		["plant_green"] = "sprites/decor/plant_green.png",
		["treasure_chest"] = "sprites/decor/treasure_chest.png",
		["bubble_wand"] = "sprites/decor/bubble_wand.png",
		["forty_oz"] = "sprites/decor/forty_oz.png",
		["handcuffs_skeleton"] = "sprites/decor/handcuffs_skeleton.png",
		["old_boot"] = "sprites/decor/old_boot.png",
		["medicine_bottles"] = "sprites/decor/medicine_bottles.png",
		["doge_statue"] = "sprites/decor/doge_statue.png",
		["m80_crate"] = "sprites/decor/m80_crate.png",
	};

	static readonly Dictionary<string, string> UiPaths = new( StringComparer.OrdinalIgnoreCase )
	{
		["wallet_font"] = "sprites/ui/wallet_font.png",
		["inv_food"] = "sprites/ui/inv_food.png",
		["inv_m80"] = "sprites/ui/inv_m80.png",
		["inv_syringe"] = "sprites/ui/inv_syringe.png",
		["inv_meth"] = "sprites/ui/inv_meth.png",
		["menu_wallpaper"] = "sprites/ui/menu_wallpaper.png",
		["ui_laptop"] = "sprites/ui/ui_laptop.png",
		["ui_fish_counter"] = "sprites/ui/ui_fish_counter.png",
	};

	public static int Version => _version;

	public static bool Has( string idOrFile ) => GetTexture( idOrFile ) is not null;

	public static Texture GetTexture( string idOrFile )
	{
		if ( string.IsNullOrWhiteSpace( idOrFile ) )
			return null;

		var key = NormalizeKey( idOrFile );
		if ( _textures.TryGetValue( key, out var cached ) && cached is not null )
			return cached;

		if ( _failed.ContainsKey( key ) )
			return null;

		var tex = TryCreateFromEmbedded( key ) ?? TryLoadPackageTexture( key );
		if ( tex is not null )
		{
			_textures[key] = tex;
			_version++;
			return tex;
		}

		_failed[key] = true;
		_version++;
		if ( !_quietLoad )
			Log.Warning( $"[NO-CHILLquarium] Sprite FAILED: {key}" );
		return null;
	}

	public static void Warmup()
	{
		_textures.Clear();
		_failed.Clear();
		_quietLoad = true;

		var total = 0;
		var ok = 0;
		foreach ( var k in FishPaths.Keys )
		{
			total++;
			if ( GetTexture( k ) is not null )
				ok++;
		}
		foreach ( var k in DecorPaths.Keys )
		{
			total++;
			if ( GetTexture( k ) is not null )
				ok++;
		}
		foreach ( var k in UiPaths.Keys )
		{
			total++;
			if ( GetTexture( k ) is not null )
				ok++;
		}

		_quietLoad = false;
		Log.Info( $"[NO-CHILLquarium] {GameInfo.VersionLabel} sprites {ok}/{total}" );
		if ( ok < total )
			Log.Warning( $"[NO-CHILLquarium] {total - ok} sprite(s) missing — check Assets/ui/sprites embeds." );
	}

	static string NormalizeKey( string idOrFile )
	{
		var s = idOrFile.Trim().Replace( '\\', '/' );
		if ( s.Contains( '/' ) )
		{
			var name = s.Split( '/' )[^1];
			if ( name.EndsWith( ".png", StringComparison.OrdinalIgnoreCase ) )
				name = name[..^4];
			if ( name.EndsWith( ".sprite", StringComparison.OrdinalIgnoreCase ) )
				name = name[..^7];
			return name;
		}
		if ( s.EndsWith( ".png", StringComparison.OrdinalIgnoreCase ) )
			s = s[..^4];
		if ( s.EndsWith( ".sprite", StringComparison.OrdinalIgnoreCase ) )
			s = s[..^7];
		return s;
	}

	static string ResolvePath( string key )
	{
		if ( FishPaths.TryGetValue( key, out var fp ) )
			return fp;
		if ( DecorPaths.TryGetValue( key, out var dp ) )
			return dp;
		if ( UiPaths.TryGetValue( key, out var up ) )
			return up;
		return null;
	}

	static Texture TryLoadPackageTexture( string key )
	{
		// Prefer embeds; package Texture.Load can spam "failed to load resource"
		// when a PNG hasn't been imported yet. Only try quiet fallbacks.
		var path = ResolvePath( key );
		if ( path is null )
			return null;

		// Try common package path forms without throwing to the console if possible.
		foreach ( var candidate in new[]
		{
			path,
			path.EndsWith( ".png", StringComparison.OrdinalIgnoreCase ) ? path : path + ".png",
			"assets/" + path,
			"/" + path
		} )
		{
			try
			{
				var tex = Texture.Load( candidate );
				if ( tex is not null )
					return tex;
			}
			catch
			{
				// ignore — next candidate
			}

			try
			{
#pragma warning disable CS0618
				var tex = Texture.Load( FileSystem.Mounted, candidate );
#pragma warning restore CS0618
				if ( tex is not null )
					return tex;
			}
			catch
			{
				// ignore
			}
		}

		return null;
	}

	static Texture TryCreateFromEmbedded( string key )
	{
		if ( !SpriteEmbedded.PngBase64.TryGetValue( key, out var b64 ) || string.IsNullOrEmpty( b64 ) )
			return null;

		byte[] bytes;
		try
		{
			bytes = Convert.FromBase64String( b64 );
		}
		catch
		{
			return null;
		}

		try
		{
			var bmp = Bitmap.CreateFromBytes( bytes );
			if ( bmp is null )
				return null;

			var size = bmp.Size;
			var w = Math.Max( 1, (int)MathF.Round( size.x ) );
			var h = Math.Max( 1, (int)MathF.Round( size.y ) );

			var tex = Texture.Create( w, h )
				.WithName( $"nochill_embed_{key}" )
				.Finish();
			if ( tex is null )
				return null;

			tex.Update( bmp );
			return tex;
		}
		catch ( Exception e )
		{
			if ( !_quietLoad )
				Log.Warning( $"[NO-CHILLquarium] Embed texture failed ({key}): {e.Message}" );
			return null;
		}
	}

	public static void Invalidate( string key )
	{
		if ( string.IsNullOrWhiteSpace( key ) )
			return;
		key = NormalizeKey( key );
		_textures.Remove( key );
		_failed.Remove( key );
		_version++;
	}
}