McRogueFace/shade_sprite/assembler.py
John McCardle 80e14163f9 Shade sprite module: faction generation, asset scanning, TextureCache
Extends the shade_sprite module (for merchant-shade.itch.io character
sprite sheets) with procedural faction generation and asset management:

- FactionGenerator: seed-based faction recipes with Biome, Element,
  Aesthetic, and RoleType enums for thematic variety
- AssetLibrary: filesystem scanner that discovers and categorizes
  layer PNGs by type (skins, clothes, hair, etc.)
- TextureCache: avoids redundant disk I/O when building many variants
- CharacterAssembler: HSL shift documentation, method improvements
- Demo expanded to 6 interactive scenes (animation viewer, HSL recolor,
  character gallery, faction generator, layer compositing, equipment)
- EVALUATION.md: 7DRL readiness assessment of the full module
- 329-line faction generation test suite

Assets themselves are not included -- sprite sheets are external
dependencies, some under commercial license.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:17:24 -05:00

128 lines
4.2 KiB
Python

"""CharacterAssembler - composite layered sprite sheets with HSL recoloring.
Uses the engine's Texture.composite() and texture.hsl_shift() methods to
build composite character textures from multiple layer PNG files, without
requiring PIL or any external Python packages.
HSL notes (from C++ investigation):
- tex.hsl_shift(h, s, l) always creates a NEW texture by copying all
pixels, converting RGB->HSL, applying shifts, converting back.
- Works on any texture: file-loaded, from_bytes, composite, or
previously shifted. Alpha is preserved; transparent pixels skipped.
- No engine-level caching exists -- repeated identical calls produce
separate texture objects. The TextureCache below avoids redundant
loads and shifts at the Python level.
"""
import mcrfpy
from .formats import PUNY_29, SheetFormat
class TextureCache:
"""Cache for loaded and HSL-shifted textures to avoid redundant disk I/O.
Keys are (path, hue_shift, sat_shift, lit_shift) tuples.
Call clear() to free all cached textures.
"""
def __init__(self):
self._cache = {}
def get(self, path, tile_w, tile_h, hue=0.0, sat=0.0, lit=0.0):
"""Load a texture, using cached version if available.
Args:
path: File path to the PNG
tile_w: Sprite tile width
tile_h: Sprite tile height
hue: Hue rotation in degrees
sat: Saturation adjustment
lit: Lightness adjustment
Returns:
mcrfpy.Texture
"""
key = (path, hue, sat, lit)
if key not in self._cache:
tex = mcrfpy.Texture(path, tile_w, tile_h)
if hue != 0.0 or sat != 0.0 or lit != 0.0:
tex = tex.hsl_shift(hue, sat, lit)
self._cache[key] = tex
return self._cache[key]
def clear(self):
"""Drop all cached textures."""
self._cache.clear()
def __len__(self):
return len(self._cache)
def __contains__(self, key):
return key in self._cache
class CharacterAssembler:
"""Build composite character sheets from layer files.
Layers are added bottom-to-top (skin first, then clothes, hair, etc).
Each layer can be HSL-shifted for recoloring before compositing.
Args:
fmt: SheetFormat describing the sprite dimensions (default: PUNY_29)
cache: Optional TextureCache for reusing loaded textures across
multiple build() calls. If None, a private cache is created.
"""
def __init__(self, fmt=None, cache=None):
if fmt is None:
fmt = PUNY_29
self.fmt = fmt
self.layers = []
self.cache = cache if cache is not None else TextureCache()
def add_layer(self, path, hue_shift=0.0, sat_shift=0.0, lit_shift=0.0):
"""Queue a layer PNG with optional HSL recoloring.
Args:
path: File path to the layer PNG
hue_shift: Hue rotation in degrees [0, 360)
sat_shift: Saturation adjustment [-1.0, 1.0]
lit_shift: Lightness adjustment [-1.0, 1.0]
"""
self.layers.append((path, hue_shift, sat_shift, lit_shift))
return self # allow chaining
def clear(self):
"""Remove all queued layers."""
self.layers.clear()
return self
def build(self, name="<composed>"):
"""Composite all queued layers into a single Texture.
Loads each layer file (using the cache to avoid redundant disk reads
and HSL computations), then composites all layers bottom-to-top.
Args:
name: Optional name for the resulting texture
Returns:
mcrfpy.Texture: The composited texture
Raises:
ValueError: If no layers have been added
IOError: If a layer file cannot be loaded
"""
if not self.layers:
raise ValueError("No layers added. Call add_layer() first.")
textures = []
for path, h, s, l in self.layers:
tex = self.cache.get(path, self.fmt.tile_w, self.fmt.tile_h, h, s, l)
textures.append(tex)
if len(textures) == 1:
return textures[0]
return mcrfpy.Texture.composite(
textures, self.fmt.tile_w, self.fmt.tile_h, name
)