McRogueFace/shade_sprite/factions.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

494 lines
16 KiB
Python

"""Faction generation system for procedural army/group creation.
A Faction is a top-level group with a species, biome, element, and aesthetic.
Each faction contains several Roles -- visually and mechanically distinct unit
types with unique appearances built from the faction's species layers.
Key design: clothes, hair, and skin hues are per-ROLE, not per-faction.
The faction defines species and aesthetic; roles define visual specifics.
Usage:
from shade_sprite.factions import FactionGenerator
from shade_sprite.assets import AssetLibrary
from shade_sprite.assembler import CharacterAssembler
lib = AssetLibrary()
gen = FactionGenerator(seed=42, library=lib)
recipe = gen.generate()
assembler = CharacterAssembler()
textures = recipe.build_role_textures(assembler)
# textures["melee_fighter"] -> mcrfpy.Texture
"""
import random
from dataclasses import dataclass, field
from enum import Enum
from .assets import AssetLibrary
# ---------------------------------------------------------------------------
# Domain enums
# ---------------------------------------------------------------------------
class Biome(Enum):
ICE = "ice"
SWAMP = "swamp"
GRASSLAND = "grassland"
SCRUBLAND = "scrubland"
FOREST = "forest"
class Element(Enum):
FIRE = "fire"
WATER = "water"
STONE = "stone"
AIR = "air"
class Aesthetic(Enum):
SLAVERY = "slavery"
MILITARISTIC = "militaristic"
COWARDLY = "cowardly"
FANATICAL = "fanatical"
class RoleType(Enum):
MELEE_FIGHTER = "melee_fighter"
RANGED_FIGHTER = "ranged_fighter"
SPELLCASTER = "spellcaster"
HEALER = "healer"
PET_RUSHER = "pet_rusher"
PET_FLANKER = "pet_flanker"
# ---------------------------------------------------------------------------
# Aesthetic -> role generation templates
# ---------------------------------------------------------------------------
# Each aesthetic defines which roles are generated and their relative counts.
# The generator picks from these to produce 3-5 roles per faction.
_AESTHETIC_ROLE_POOLS = {
Aesthetic.MILITARISTIC: [
RoleType.MELEE_FIGHTER,
RoleType.MELEE_FIGHTER,
RoleType.RANGED_FIGHTER,
RoleType.RANGED_FIGHTER,
RoleType.HEALER,
],
Aesthetic.FANATICAL: [
RoleType.SPELLCASTER,
RoleType.SPELLCASTER,
RoleType.MELEE_FIGHTER,
RoleType.HEALER,
RoleType.PET_RUSHER,
],
Aesthetic.COWARDLY: [
RoleType.RANGED_FIGHTER,
RoleType.PET_RUSHER,
RoleType.PET_FLANKER,
RoleType.PET_FLANKER,
RoleType.HEALER,
],
Aesthetic.SLAVERY: [
RoleType.MELEE_FIGHTER,
RoleType.RANGED_FIGHTER,
RoleType.SPELLCASTER,
RoleType.PET_RUSHER,
RoleType.PET_FLANKER,
],
}
# Headgear class mapping for roles
_ROLE_HEADGEAR_CLASS = {
RoleType.MELEE_FIGHTER: "melee",
RoleType.RANGED_FIGHTER: "range",
RoleType.SPELLCASTER: "mage",
RoleType.HEALER: "mage",
RoleType.PET_RUSHER: None, # pets/allies don't get headgear
RoleType.PET_FLANKER: None,
}
# Clothing style preferences per role
_ROLE_CLOTHING_STYLES = {
RoleType.MELEE_FIGHTER: ["armour", "viking", "mongol"],
RoleType.RANGED_FIGHTER: ["basic", "french", "japanese"],
RoleType.SPELLCASTER: ["tunic", "basic"],
RoleType.HEALER: ["tunic", "basic"],
RoleType.PET_RUSHER: [], # naked or minimal
RoleType.PET_FLANKER: [],
}
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class RoleDefinition:
"""A visually and mechanically distinct unit type within a faction.
Attributes:
role_type: The combat/function role
species: Species name for this role (main or ally species)
skin_hue: Hue shift for the skin layer (degrees)
skin_sat: Saturation shift for the skin layer
skin_lit: Lightness shift for the skin layer
clothing_layers: List of (path, hue, sat, lit) tuples for clothing
headgear_layer: Optional (path, hue, sat, lit) for headgear
addon_layer: Optional (path, hue, sat, lit) for species add-ons
hair_layer: Optional (path, hue, sat, lit) for hairstyle
eye_layer: Optional (path, hue, sat, lit) for eyes
shoe_layer: Optional (path, hue, sat, lit) for shoes
glove_layer: Optional (path, hue, sat, lit) for gloves
is_ally: True if this role uses an ally species (not the main species)
"""
role_type: RoleType
species: str
skin_hue: float = 0.0
skin_sat: float = 0.0
skin_lit: float = 0.0
clothing_layers: list = field(default_factory=list)
headgear_layer: tuple = None
addon_layer: tuple = None
hair_layer: tuple = None
eye_layer: tuple = None
shoe_layer: tuple = None
glove_layer: tuple = None
is_ally: bool = False
@property
def label(self):
"""Human-readable label like 'melee_fighter (Human)'."""
ally_tag = " [ally]" if self.is_ally else ""
return f"{self.role_type.value} ({self.species}{ally_tag})"
@dataclass
class FactionRecipe:
"""A complete faction definition with species, aesthetic, and roles.
Attributes:
name: Display name for the faction
biome: The biome this faction inhabits
species: Main species name
element: Elemental affinity
aesthetic: Behavioral aesthetic
ally_species: List of ally species names
roles: List of RoleDefinitions
seed: The seed used to generate this faction
"""
name: str
biome: Biome
species: str
element: Element
aesthetic: Aesthetic
ally_species: list = field(default_factory=list)
roles: list = field(default_factory=list)
seed: int = 0
def build_role_textures(self, assembler):
"""Build one composite texture per role using CharacterAssembler.
Args:
assembler: A CharacterAssembler instance (format will be reused)
library: An AssetLibrary to resolve species -> skin paths.
If None, skin layers must already be absolute paths in
the role's clothing_layers.
Returns:
dict[str, mcrfpy.Texture]: role label -> texture
"""
import mcrfpy # deferred import for headless testing without engine
textures = {}
for role in self.roles:
assembler.clear()
# Skin layer -- look up from library by species
skin_path = role._skin_path
if skin_path:
assembler.add_layer(
skin_path, role.skin_hue, role.skin_sat, role.skin_lit)
# Shoe layer
if role.shoe_layer:
path, h, s, l = role.shoe_layer
assembler.add_layer(path, h, s, l)
# Clothing layers
for path, h, s, l in role.clothing_layers:
assembler.add_layer(path, h, s, l)
# Glove layer
if role.glove_layer:
path, h, s, l = role.glove_layer
assembler.add_layer(path, h, s, l)
# Add-on layer (species ears, horns, etc.)
if role.addon_layer:
path, h, s, l = role.addon_layer
assembler.add_layer(path, h, s, l)
# Hair layer
if role.hair_layer:
path, h, s, l = role.hair_layer
assembler.add_layer(path, h, s, l)
# Eye layer
if role.eye_layer:
path, h, s, l = role.eye_layer
assembler.add_layer(path, h, s, l)
# Headgear layer (on top)
if role.headgear_layer:
path, h, s, l = role.headgear_layer
assembler.add_layer(path, h, s, l)
tex_name = f"{self.name}_{role.label}".replace(" ", "_")
textures[role.label] = assembler.build(tex_name)
return textures
# ---------------------------------------------------------------------------
# Faction name generation
# ---------------------------------------------------------------------------
_FACTION_PREFIXES = [
"Iron", "Shadow", "Dawn", "Ember", "Frost",
"Vine", "Storm", "Ash", "Gold", "Crimson",
"Azure", "Jade", "Silver", "Night", "Sun",
"Bone", "Blood", "Thorn", "Dusk", "Star",
"Stone", "Flame", "Void", "Moon", "Rust",
]
_FACTION_SUFFIXES = [
"Guard", "Pact", "Order", "Clan", "Legion",
"Court", "Band", "Wardens", "Company", "Oath",
"Fleet", "Circle", "Hand", "Watch", "Speakers",
"Reavers", "Chosen", "Vanguard", "Covenant", "Fang",
"Spire", "Horde", "Shield", "Tide", "Crown",
]
# ---------------------------------------------------------------------------
# Generator
# ---------------------------------------------------------------------------
class FactionGenerator:
"""Deterministic faction generator driven by a seed and asset library.
Args:
seed: Integer seed for reproducible generation
library: AssetLibrary instance (if None, creates one with auto-detection)
"""
def __init__(self, seed, library=None):
self.seed = seed
self.library = library if library is not None else AssetLibrary()
self._rng = random.Random(seed)
def generate(self):
"""Produce a complete FactionRecipe with 3-5 roles.
Returns:
FactionRecipe with fully specified roles
"""
rng = self._rng
# Pick faction attributes
biome = rng.choice(list(Biome))
element = rng.choice(list(Element))
aesthetic = rng.choice(list(Aesthetic))
# Pick species
available_species = self.library.species if self.library.available else [
"Human", "Orc", "Demon", "Skeleton", "NightElf", "Cyclops"]
species = rng.choice(available_species)
# Pick 0-2 ally species (different from main)
other_species = [s for s in available_species if s != species]
n_allies = rng.randint(0, min(2, len(other_species)))
ally_species = rng.sample(other_species, n_allies) if n_allies > 0 else []
# Generate name
name = rng.choice(_FACTION_PREFIXES) + " " + rng.choice(_FACTION_SUFFIXES)
# Generate roles
role_pool = list(_AESTHETIC_ROLE_POOLS[aesthetic])
n_roles = rng.randint(3, min(5, len(role_pool)))
chosen_role_types = rng.sample(role_pool, n_roles)
# Base skin hue for the main species (random starting point)
base_skin_hue = rng.uniform(0, 360)
roles = []
for i, role_type in enumerate(chosen_role_types):
is_pet = role_type in (RoleType.PET_RUSHER, RoleType.PET_FLANKER)
# Determine species for this role
if is_pet and ally_species:
role_species = rng.choice(ally_species)
is_ally = True
else:
role_species = species
is_ally = is_pet and not ally_species
# Skin hue: small variation from base for same species
if role_species == species:
skin_hue = (base_skin_hue + rng.uniform(-15, 15)) % 360
else:
skin_hue = rng.uniform(0, 360)
skin_sat = rng.uniform(-0.15, 0.15)
skin_lit = rng.uniform(-0.1, 0.1)
# For slavery aesthetic, allies get no clothes and dimmer skin
naked = (aesthetic == Aesthetic.SLAVERY and is_ally)
if naked:
skin_lit = rng.uniform(-0.3, -0.1)
role = RoleDefinition(
role_type=role_type,
species=role_species,
skin_hue=skin_hue,
skin_sat=skin_sat,
skin_lit=skin_lit,
is_ally=is_ally,
)
# Resolve actual layer files from the library
self._assign_skin(role, role_species)
if not naked:
self._assign_clothing(role, role_type, rng)
self._assign_headgear(role, role_type, rng)
self._assign_hair(role, rng)
self._assign_shoes(role, rng)
self._assign_gloves(role, role_type, rng)
self._assign_eyes(role, rng)
self._assign_addons(role, role_species, rng)
roles.append(role)
return FactionRecipe(
name=name,
biome=biome,
species=species,
element=element,
aesthetic=aesthetic,
ally_species=ally_species,
roles=roles,
seed=self.seed,
)
# ---- Layer assignment helpers ----
def _assign_skin(self, role, species):
"""Pick a skin layer file for the species."""
skins = self.library.skins_for(species) if self.library.available else []
if skins:
chosen = self._rng.choice(skins)
role._skin_path = chosen.path
else:
role._skin_path = None
def _assign_clothing(self, role, role_type, rng):
"""Pick clothing appropriate to the role type."""
if not self.library.available:
return
preferred_styles = _ROLE_CLOTHING_STYLES.get(role_type, [])
candidates = []
for style in preferred_styles:
candidates.extend(self.library.clothes_by_style(style))
if not candidates:
candidates = self.library.clothes
if not candidates:
return
chosen = rng.choice(candidates)
clothing_hue = rng.uniform(0, 360)
role.clothing_layers.append(
(chosen.path, clothing_hue, 0.0, rng.uniform(-0.1, 0.05)))
def _assign_headgear(self, role, role_type, rng):
"""Pick a headgear matching the role's combat class."""
if not self.library.available:
return
hg_class = _ROLE_HEADGEAR_CLASS.get(role_type)
if hg_class is None:
return
candidates = self.library.headgears_for_class(hg_class)
if not candidates:
candidates = self.library.headgears
if not candidates:
return
chosen = rng.choice(candidates)
role.headgear_layer = (chosen.path, 0.0, 0.0, 0.0)
def _assign_hair(self, role, rng):
"""Pick a hairstyle (50% chance for humanoid roles)."""
if not self.library.available:
return
if rng.random() < 0.5:
return # no hair / covered by headgear
hairs = self.library.hairstyles
if not hairs:
return
# Filter to just actual hairstyles (not facial), pick one
head_hairs = [h for h in hairs if "Facial" not in h.subcategory]
if not head_hairs:
head_hairs = hairs
chosen = rng.choice(head_hairs)
role.hair_layer = (chosen.path, 0.0, 0.0, 0.0)
def _assign_eyes(self, role, rng):
"""Pick an eye color (80% chance)."""
if not self.library.available:
return
if rng.random() < 0.2:
return
eye_colors = self.library.layers_in("eyes", "Eye Color")
if not eye_colors:
return
chosen = rng.choice(eye_colors)
role.eye_layer = (chosen.path, 0.0, 0.0, 0.0)
def _assign_shoes(self, role, rng):
"""Pick shoes (70% chance)."""
if not self.library.available:
return
if rng.random() < 0.3:
return
shoes = self.library.shoes
if not shoes:
return
chosen = rng.choice(shoes)
role.shoe_layer = (chosen.path, 0.0, 0.0, 0.0)
def _assign_gloves(self, role, role_type, rng):
"""Pick gloves for melee/ranged roles (40% chance)."""
if not self.library.available:
return
if role_type not in (RoleType.MELEE_FIGHTER, RoleType.RANGED_FIGHTER):
return
if rng.random() < 0.6:
return
gloves = self.library.gloves
if not gloves:
return
chosen = rng.choice(gloves)
role.glove_layer = (chosen.path, 0.0, 0.0, 0.0)
def _assign_addons(self, role, species, rng):
"""Pick species-specific add-ons if available."""
if not self.library.available:
return
addons = self.library.addons_for(species)
if not addons:
return
# 60% chance to add a species add-on
if rng.random() < 0.4:
return
chosen = rng.choice(addons)
role.addon_layer = (chosen.path, 0.0, 0.0, 0.0)