295 lines
9.4 KiB
Python
295 lines
9.4 KiB
Python
|
|
"""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)"
|