McRogueFace/shade_sprite/assets.py

295 lines
9.4 KiB
Python
Raw Permalink Normal View History

"""AssetLibrary - scan and enumerate Puny Characters layer assets by category.
Scans the paid Puny Characters v2.1 "Individual Spritesheets" directory tree
and builds an inventory of available layers organized by category. The
FactionGenerator uses this to know what's actually on disk rather than
hardcoding filenames.
Directory structure (paid pack):
PUNY CHARACTERS/Individual Spritesheets/
Layer 0 - Skins/ -> species skins (Human1, Orc1, etc.)
Layer 1 - Shoes/ -> shoe layers
Layer 2 - Clothes/ -> clothing by style subfolder
Layer 3 - Gloves/ -> glove layers
Layer 4 - Hairstyle/ -> hair by gender + facial hair
Layer 5 - Eyes/ -> eye color + eyelashes
Layer 6 - Headgears/ -> helmets/hats by class/culture
Layer 7 - Add-ons/ -> species-specific add-ons (ears, horns, etc.)
Tools/ -> deleter/overlay tools (not used for characters)
"""
import os
import re
from dataclasses import dataclass, field
# Layer directory names inside "Individual Spritesheets"
_LAYER_DIRS = {
"skins": "Layer 0 - Skins",
"shoes": "Layer 1 - Shoes",
"clothes": "Layer 2 - Clothes",
"gloves": "Layer 3 - Gloves",
"hairstyle": "Layer 4 - Hairstyle",
"eyes": "Layer 5 - Eyes",
"headgears": "Layer 6 - Headgears",
"addons": "Layer 7 - Add-ons",
}
# Known search paths for the paid pack's Individual Spritesheets directory
_PAID_PACK_SEARCH_PATHS = [
os.path.expanduser(
"~/Development/7DRL2026_Liber_Noster_jmccardle/"
"assets_sources/PUNY_CHARACTERS_v2.1/"
"PUNY CHARACTERS/Individual Spritesheets"
),
"assets/PUNY_CHARACTERS/Individual Spritesheets",
"../assets/PUNY_CHARACTERS/Individual Spritesheets",
]
@dataclass
class LayerFile:
"""A single layer PNG file with parsed metadata."""
path: str # Full path to the PNG
filename: str # Just the filename (e.g. "Human1.png")
name: str # Name without extension (e.g. "Human1")
category: str # Category key (e.g. "skins", "clothes")
subcategory: str # Subfolder within category (e.g. "Armour Body", "")
def _parse_species_from_skin(name):
"""Extract species name from a skin filename like 'Human1' -> 'Human'."""
match = re.match(r'^([A-Za-z]+?)(\d*)$', name)
if match:
return match.group(1)
return name
class AssetLibrary:
"""Scans and indexes Puny Characters layer assets by category.
Args:
base_path: Path to the "Individual Spritesheets" directory.
If None, searches known locations automatically.
"""
def __init__(self, base_path=None):
if base_path is None:
base_path = self._find_base_path()
self.base_path = base_path
self._layers = {} # category -> list[LayerFile]
self._species_cache = None
if self.base_path:
self._scan()
@staticmethod
def _find_base_path():
for p in _PAID_PACK_SEARCH_PATHS:
if os.path.isdir(p):
return p
return None
@property
def available(self):
"""True if the asset directory was found and scanned."""
return self.base_path is not None and len(self._layers) > 0
def _scan(self):
"""Walk the layer directories and build the inventory."""
for cat_key, dir_name in _LAYER_DIRS.items():
cat_dir = os.path.join(self.base_path, dir_name)
if not os.path.isdir(cat_dir):
continue
files = []
for root, _dirs, filenames in os.walk(cat_dir):
for fn in sorted(filenames):
if not fn.lower().endswith(".png"):
continue
full_path = os.path.join(root, fn)
# Subcategory = relative dir from the category root
rel = os.path.relpath(root, cat_dir)
subcat = "" if rel == "." else rel
name = fn[:-4] # strip .png
files.append(LayerFile(
path=full_path,
filename=fn,
name=name,
category=cat_key,
subcategory=subcat,
))
self._layers[cat_key] = files
# ---- Species (Skins) ----
@property
def species(self):
"""List of distinct species names derived from Skins/ filenames."""
if self._species_cache is None:
seen = {}
for lf in self._layers.get("skins", []):
sp = _parse_species_from_skin(lf.name)
if sp not in seen:
seen[sp] = True
self._species_cache = list(seen.keys())
return list(self._species_cache)
def skins_for(self, species):
"""Return LayerFile list for skins matching a species name.
Args:
species: Species name (e.g. "Human", "Orc", "Demon")
Returns:
list[LayerFile]: Matching skin layers
"""
return [lf for lf in self._layers.get("skins", [])
if _parse_species_from_skin(lf.name) == species]
# ---- Generic category access ----
def layers(self, category):
"""Return all LayerFiles for a category.
Args:
category: One of "skins", "shoes", "clothes", "gloves",
"hairstyle", "eyes", "headgears", "addons"
Returns:
list[LayerFile]
"""
return list(self._layers.get(category, []))
def subcategories(self, category):
"""Return distinct subcategory names within a category.
Args:
category: Category key
Returns:
list[str]: Sorted subcategory names (empty string for root files)
"""
subs = set()
for lf in self._layers.get(category, []):
subs.add(lf.subcategory)
return sorted(subs)
def layers_in(self, category, subcategory):
"""Return LayerFiles within a specific subcategory.
Args:
category: Category key
subcategory: Subcategory name (e.g. "Armour Body")
Returns:
list[LayerFile]
"""
return [lf for lf in self._layers.get(category, [])
if lf.subcategory == subcategory]
# ---- Convenience shortcuts ----
@property
def clothes(self):
"""All clothing layer files."""
return self.layers("clothes")
@property
def shoes(self):
"""All shoe layer files."""
return self.layers("shoes")
@property
def gloves(self):
"""All glove layer files."""
return self.layers("gloves")
@property
def hairstyles(self):
"""All hairstyle layer files (head hair + facial hair)."""
return self.layers("hairstyle")
@property
def eyes(self):
"""All eye layer files (eye color + eyelashes)."""
return self.layers("eyes")
@property
def headgears(self):
"""All headgear layer files."""
return self.layers("headgears")
@property
def addons(self):
"""All add-on layer files (species-specific ears, horns, etc.)."""
return self.layers("addons")
def addons_for(self, species):
"""Return add-ons compatible with a species.
Matches based on subcategory containing the species name
(e.g. "Orc Add-ons" for species "Orc").
Args:
species: Species name
Returns:
list[LayerFile]
"""
result = []
for lf in self._layers.get("addons", []):
# Match "Orc Add-ons" for "Orc", "Elf Add-ons" for "Elf", etc.
if species.lower() in lf.subcategory.lower():
result.append(lf)
return result
# ---- Headgear by class ----
def headgears_for_class(self, class_name):
"""Return headgears matching a combat class.
Args:
class_name: One of "melee", "range", "mage", "assassin"
or a culture like "japanese", "viking", "mongol", "french"
Returns:
list[LayerFile]
"""
# Map common names to subcategory prefixes
lookup = class_name.lower()
result = []
for lf in self._layers.get("headgears", []):
if lookup in lf.subcategory.lower():
result.append(lf)
return result
# ---- Clothes by style ----
def clothes_by_style(self, style):
"""Return clothing matching a style keyword.
Args:
style: Style keyword (e.g. "armour", "basic", "tunic", "viking")
Returns:
list[LayerFile]
"""
lookup = style.lower()
return [lf for lf in self._layers.get("clothes", [])
if lookup in lf.subcategory.lower()]
# ---- Summary ----
@property
def categories(self):
"""List of category keys that have at least one file."""
return [k for k in _LAYER_DIRS if self._layers.get(k)]
def summary(self):
"""Return a dict of category -> file count."""
return {k: len(v) for k, v in self._layers.items() if v}
def __repr__(self):
if not self.available:
return "AssetLibrary(unavailable)"
total = sum(len(v) for v in self._layers.values())
cats = len(self.categories)
return f"AssetLibrary({total} files in {cats} categories)"