SpriteCatalog.cs
namespace NoChillquarium;
/// <summary>
/// Loads game sprites as <see cref="Texture"/> for UI panels.
/// Runtime uses embedded PNG bytes for small sprites. Large plates
/// (<c>loc_*</c>, <c>stand_*</c>, <c>ui_trailer_lot</c>, wallpaper) load from
/// package files — those globs must stay in <c>.sbproj</c> Resources.
/// </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",
["guppy"] = "sprites/fish/guppy.png",
["molly"] = "sprites/fish/molly.png",
["koi"] = "sprites/fish/koi.png",
["angelfish"] = "sprites/fish/angelfish.png",
["piranha"] = "sprites/fish/piranha.png",
["oscar"] = "sprites/fish/oscar.png",
["axolotl"] = "sprites/fish/axolotl.png",
["seahorse"] = "sprites/fish/seahorse.png",
["puffer"] = "sprites/fish/puffer.png",
["lionfish"] = "sprites/fish/lionfish.png",
["mandarin"] = "sprites/fish/mandarin.png",
["barracuda"] = "sprites/fish/barracuda.png",
["molly_fat"] = "sprites/fish/molly_fat.png",
["koi_fat"] = "sprites/fish/koi_fat.png",
["angelfish_fat"] = "sprites/fish/angelfish_fat.png",
["piranha_fat"] = "sprites/fish/piranha_fat.png",
["oscar_fat"] = "sprites/fish/oscar_fat.png",
["axolotl_fat"] = "sprites/fish/axolotl_fat.png",
["seahorse_fat"] = "sprites/fish/seahorse_fat.png",
["puffer_fat"] = "sprites/fish/puffer_fat.png",
["lionfish_fat"] = "sprites/fish/lionfish_fat.png",
["mandarin_fat"] = "sprites/fish/mandarin_fat.png",
["barracuda_fat"] = "sprites/fish/barracuda_fat.png",
["guppy_fat"] = "sprites/fish/guppy_fat.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",
// Overfeed fat sheets (swap in at FatStage 2+)
["goldfish_fat"] = "sprites/fish/goldfish_fat.png",
["neon_tetra_fat"] = "sprites/fish/neon_tetra_fat.png",
["betta_fat"] = "sprites/fish/betta_fat.png",
["clownfish_fat"] = "sprites/fish/clownfish_fat.png",
["blue_tang_fat"] = "sprites/fish/blue_tang_fat.png",
["monster_01"] = "sprites/fish/monster_01.png",
["monster_tentacle"] = "sprites/fish/monster_tentacle.png",
["monster_02"] = "sprites/fish/monster_02.png",
["monster_03"] = "sprites/fish/monster_03.png",
["monster_04"] = "sprites/fish/monster_04.png",
["monster_05"] = "sprites/fish/monster_05.png",
["monster_06"] = "sprites/fish/monster_06.png",
["monster_07"] = "sprites/fish/monster_07.png",
["monster_08"] = "sprites/fish/monster_08.png",
["monster_09"] = "sprites/fish/monster_09.png",
["monster_10"] = "sprites/fish/monster_10.png",
["monster_01_fat"] = "sprites/fish/monster_01_fat.png",
["monster_02_fat"] = "sprites/fish/monster_02_fat.png",
["monster_03_fat"] = "sprites/fish/monster_03_fat.png",
["monster_04_fat"] = "sprites/fish/monster_04_fat.png",
["monster_05_fat"] = "sprites/fish/monster_05_fat.png",
["monster_06_fat"] = "sprites/fish/monster_06_fat.png",
["monster_07_fat"] = "sprites/fish/monster_07_fat.png",
["monster_08_fat"] = "sprites/fish/monster_08_fat.png",
["monster_09_fat"] = "sprites/fish/monster_09_fat.png",
["monster_10_fat"] = "sprites/fish/monster_10_fat.png",
["monster_goldfish"] = "sprites/fish/monster_goldfish.png",
["monster_betta"] = "sprites/fish/monster_betta.png",
["monster_neon"] = "sprites/fish/monster_neon.png",
["monster_clown"] = "sprites/fish/monster_clown.png",
["monster_tang"] = "sprites/fish/monster_tang.png",
["monster_goldfish_fat"] = "sprites/fish/monster_goldfish_fat.png",
["monster_betta_fat"] = "sprites/fish/monster_betta_fat.png",
["monster_neon_fat"] = "sprites/fish/monster_neon_fat.png",
["monster_clown_fat"] = "sprites/fish/monster_clown_fat.png",
["monster_tang_fat"] = "sprites/fish/monster_tang_fat.png",
["hybrid_plant"] = "sprites/fish/hybrid_plant.png",
["hybrid_chest"] = "sprites/fish/hybrid_chest.png",
["hybrid_bubble"] = "sprites/fish/hybrid_bubble.png",
["hybrid_forty"] = "sprites/fish/hybrid_forty.png",
["hybrid_cuffs"] = "sprites/fish/hybrid_cuffs.png",
["hybrid_boot"] = "sprites/fish/hybrid_boot.png",
["hybrid_meds"] = "sprites/fish/hybrid_meds.png",
["hybrid_doge"] = "sprites/fish/hybrid_doge.png",
["hybrid_m80"] = "sprites/fish/hybrid_m80.png",
["monster_meth_1"] = "sprites/fish/monster_meth_1.png",
["monster_meth_2"] = "sprites/fish/monster_meth_2.png",
["monster_meth_3"] = "sprites/fish/monster_meth_3.png",
["monster_meth_4"] = "sprites/fish/monster_meth_4.png",
["monster_meth_5"] = "sprites/fish/monster_meth_5.png",
["evo_gold_betta"] = "sprites/fish/evo_gold_betta.png",
["evo_gold_betta_apex"] = "sprites/fish/evo_gold_betta_apex.png",
["evo_gold_betta_fat"] = "sprites/fish/evo_gold_betta_fat.png",
["evo_gold_betta_apex_fat"] = "sprites/fish/evo_gold_betta_apex_fat.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",
["rock_pile"] = "sprites/decor/rock_pile.png",
["plastic_castle"] = "sprites/decor/plastic_castle.png",
["rusty_anchor"] = "sprites/decor/rusty_anchor.png",
["road_cone"] = "sprites/decor/road_cone.png",
["lava_lamp"] = "sprites/decor/lava_lamp.png",
["pizza_box"] = "sprites/decor/pizza_box.png",
["cheap_trophy"] = "sprites/decor/cheap_trophy.png",
["fake_coral"] = "sprites/decor/fake_coral.png",
["bg_newspaper"] = "sprites/decor/bg_newspaper.png",
["bg_magazines"] = "sprites/decor/bg_magazines.png",
["bg_aqua_poster"] = "sprites/decor/bg_aqua_poster.png",
["bg_reef_poster"] = "sprites/decor/bg_reef_poster.png",
["bg_value_menu"] = "sprites/decor/bg_value_menu.png",
["bg_greek_ruins"] = "sprites/decor/bg_greek_ruins.png",
["bg_wood_panel"] = "sprites/decor/bg_wood_panel.png",
["bg_leopard"] = "sprites/decor/bg_leopard.png",
["gravel_brown"] = "sprites/decor/gravel_brown.png",
["gravel_neon"] = "sprites/decor/gravel_neon.png",
["gravel_black"] = "sprites/decor/gravel_black.png",
["gravel_blue"] = "sprites/decor/gravel_blue.png",
["gravel_gold"] = "sprites/decor/gravel_gold.png",
["gravel_white"] = "sprites/decor/gravel_white.png",
["gravel_rainbow"] = "sprites/decor/gravel_rainbow.png",
["gravel_slime"] = "sprites/decor/gravel_slime.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_bloodworms"] = "sprites/ui/inv_bloodworms.png",
["pellet_bloodworm"] = "sprites/ui/pellet_bloodworm.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",
["ui_grandma"] = "sprites/ui/ui_grandma.png",
["ui_janitor"] = "sprites/ui/ui_janitor.png",
["ui_bully"] = "sprites/ui/ui_bully.png",
["ui_you"] = "sprites/ui/ui_you.png",
["adv_mite"] = "sprites/ui/adv_mite.png",
["adv_eel"] = "sprites/ui/adv_eel.png",
["adv_carp"] = "sprites/ui/adv_carp.png",
["adv_valve"] = "sprites/ui/adv_valve.png",
["adv_grate"] = "sprites/ui/adv_grate.png",
["adv_coin"] = "sprites/ui/adv_coin.png",
["adv_cg_janitor"] = "sprites/ui/adv_cg_janitor.png",
["adv_cg_grandma"] = "sprites/ui/adv_cg_grandma.png",
["adv_cg_brad"] = "sprites/ui/adv_cg_brad.png",
["adv_cg_you"] = "sprites/ui/adv_cg_you.png",
["adv_cg_flush"] = "sprites/ui/adv_cg_flush.png",
["adv_cg_faucet"] = "sprites/ui/adv_cg_faucet.png",
["adv_cg_principal"] = "sprites/ui/adv_cg_principal.png",
["adv_cg_loot"] = "sprites/ui/adv_cg_loot.png",
["adv_cg_hazard"] = "sprites/ui/adv_cg_hazard.png",
["adv_cg_boss"] = "sprites/ui/adv_cg_boss.png",
["adv_cg_mite"] = "sprites/ui/adv_cg_mite.png",
["ui_trailer_lot"] = "sprites/ui/ui_trailer_lot.png",
["loc_porch"] = "sprites/ui/loc_porch.png",
["loc_bathroom"] = "sprites/ui/loc_bathroom.png",
["loc_yard"] = "sprites/ui/loc_yard.png",
["loc_neighbor"] = "sprites/ui/loc_neighbor.png",
["loc_lake"] = "sprites/ui/loc_lake.png",
["loc_sewer"] = "sprites/ui/loc_sewer.png",
["loc_gulf"] = "sprites/ui/loc_gulf.png",
["loc_school"] = "sprites/ui/loc_school.png",
["loc_alley"] = "sprites/ui/loc_alley.png",
["loc_gas"] = "sprites/ui/loc_gas.png",
["loc_bingo"] = "sprites/ui/loc_bingo.png",
["stand_cinder"] = "sprites/ui/stand_cinder.png",
["stand_porch"] = "sprites/ui/stand_porch.png",
["stand_bathroom"] = "sprites/ui/stand_bathroom.png",
["stand_yard"] = "sprites/ui/stand_yard.png",
["stand_neighbor"] = "sprites/ui/stand_neighbor.png",
["stand_lake"] = "sprites/ui/stand_lake.png",
["stand_sewer"] = "sprites/ui/stand_sewer.png",
["stand_gulf"] = "sprites/ui/stand_gulf.png",
["stand_school"] = "sprites/ui/stand_school.png",
["stand_alley"] = "sprites/ui/stand_alley.png",
["stand_gas"] = "sprites/ui/stand_gas.png",
["stand_bingo"] = "sprites/ui/stand_bingo.png",
["stand_dock"] = "sprites/ui/stand_dock.png",
["stand_front_porch"] = "sprites/ui/stand_front_porch.png",
["stand_front_tile"] = "sprites/ui/stand_front_tile.png",
["stand_front_pool"] = "sprites/ui/stand_front_pool.png",
["stand_front_dock"] = "sprites/ui/stand_front_dock.png",
["stand_front_pipe"] = "sprites/ui/stand_front_pipe.png",
["water_drop"] = "sprites/ui/water_drop.png",
["water_blob"] = "sprites/ui/water_blob.png",
["water_splash"] = "sprites/ui/water_splash.png",
["water_jet"] = "sprites/ui/water_jet.png",
// Glass stress (meth-rush miss) — clean transparent line art
["crack_a"] = "sprites/ui/crack_a.png",
["crack_b"] = "sprites/ui/crack_b.png",
["crack_c"] = "sprites/ui/crack_c.png",
["crack_d"] = "sprites/ui/crack_d.png",
["crack_spider"] = "sprites/ui/crack_spider.png",
};
public static int Version => _version;
static readonly Dictionary<string, int> _swimFrames = new( StringComparer.OrdinalIgnoreCase );
/// <summary>How many <c>{stem}_swim_0..N</c> frames exist (0 = still only).</summary>
public static int SwimFrameCount( string stem )
{
if ( string.IsNullOrWhiteSpace( stem ) )
return 0;
if ( _swimFrames.TryGetValue( stem, out var cached ) )
return cached;
var n = 0;
while ( n < 4 && Has( stem + "_swim_" + n ) )
n++;
_swimFrames[stem] = n;
return n;
}
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;
// Small sprites: embeds (always in the DLL). Large plates: raw PNG bytes
// (need .sbproj Resources) then compiled Texture.Load.
var tex = TryCreateFromEmbedded( key ) ?? TryCreateFromFile( 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();
_swimFrames.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 )
{
var missing = _failed.Keys.OrderBy( k => k ).ToArray();
Log.Warning( $"[NO-CHILLquarium] {total - ok} sprite(s) missing: {string.Join( ", ", missing )} — embeds or .sbproj Resources." );
}
}
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 TextureFromPngBytes( string key, byte[] bytes )
{
if ( bytes is null || bytes.Length < 8 )
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_{key}" )
.Finish();
if ( tex is null )
return null;
tex.Update( bmp );
return tex;
}
catch ( Exception e )
{
if ( !_quietLoad )
Log.Warning( $"[NO-CHILLquarium] Sprite bytes {key}: {e.Message}" );
return null;
}
}
/// <summary>
/// Large loc/stand plates are not embedded (CS8103). Read PNG bytes from the mount.
/// </summary>
static Texture TryCreateFromFile( string key )
{
foreach ( var candidate in FileCandidates( key ) )
{
try
{
if ( !FileSystem.Mounted.FileExists( candidate ) )
continue;
// s&box ReadAllBytes is a Span — copy to a real array for Bitmap.
var bytes = FileSystem.Mounted.ReadAllBytes( candidate ).ToArray();
var tex = TextureFromPngBytes( key, bytes );
if ( tex is not null )
return tex;
}
catch
{
// next path
}
}
return null;
}
static IEnumerable<string> FileCandidates( string key )
{
var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
void Add( string s )
{
if ( string.IsNullOrEmpty( s ) )
return;
seen.Add( s.Replace( '\\', '/' ) );
}
Add( "ui/sprites/" + key + ".png" );
Add( "sprites/ui/" + key + ".png" );
Add( "assets/ui/sprites/" + key + ".png" );
Add( "assets/sprites/ui/" + key + ".png" );
var path = ResolvePath( key );
if ( !string.IsNullOrEmpty( path ) )
{
Add( path );
Add( "assets/" + path.TrimStart( '/' ) );
}
foreach ( var s in seen )
yield return s;
}
static Texture TryLoadPackageTexture( string key )
{
// Compiled textures + Resource Files. warnOnMissing:false avoids console spam
// for candidates that simply aren't in this package.
foreach ( var candidate in TextureCandidates( key ) )
{
try
{
var tex = Texture.Load( candidate, false );
if ( tex.IsValid() && tex.Width > 1 && tex.Height > 1 )
return tex;
}
catch
{
// next path
}
try
{
#pragma warning disable CS0618
var tex = Texture.Load( FileSystem.Mounted, candidate, false );
#pragma warning restore CS0618
if ( tex.IsValid() && tex.Width > 1 && tex.Height > 1 )
return tex;
}
catch
{
// next path
}
}
return null;
}
static IEnumerable<string> TextureCandidates( string key )
{
var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
void Add( string s )
{
if ( string.IsNullOrEmpty( s ) )
return;
seen.Add( s.Replace( '\\', '/' ) );
}
var path = ResolvePath( key );
if ( !string.IsNullOrEmpty( path ) )
{
Add( path );
if ( !path.EndsWith( ".png", StringComparison.OrdinalIgnoreCase ) )
Add( path + ".png" );
Add( "assets/" + path.TrimStart( '/' ) );
}
Add( "sprites/ui/" + key + ".png" );
Add( "ui/sprites/" + key + ".png" );
Add( "sprites/fish/" + key + ".png" );
Add( "sprites/decor/" + key + ".png" );
Add( "sprites/ui/" + key + ".sprite" );
Add( "sprites/fish/" + key + ".sprite" );
Add( "sprites/decor/" + key + ".sprite" );
foreach ( var s in seen )
yield return s;
}
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
{
return TextureFromPngBytes( key, bytes );
}
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++;
}
}