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>
This commit is contained in:
John McCardle 2026-02-20 23:17:24 -05:00
commit 80e14163f9
7 changed files with 2471 additions and 314 deletions

View file

@ -3,11 +3,63 @@
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.
@ -16,13 +68,16 @@ class CharacterAssembler:
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):
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.
@ -44,8 +99,8 @@ class CharacterAssembler:
def build(self, name="<composed>"):
"""Composite all queued layers into a single Texture.
Loads each layer file, applies HSL shifts if any, then composites
all layers bottom-to-top using alpha blending.
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
@ -62,9 +117,7 @@ class CharacterAssembler:
textures = []
for path, h, s, l in self.layers:
tex = mcrfpy.Texture(path, self.fmt.tile_w, self.fmt.tile_h)
if h != 0.0 or s != 0.0 or l != 0.0:
tex = tex.hsl_shift(h, s, l)
tex = self.cache.get(path, self.fmt.tile_w, self.fmt.tile_h, h, s, l)
textures.append(tex)
if len(textures) == 1: