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:
parent
9718153709
commit
80e14163f9
7 changed files with 2471 additions and 314 deletions
264
shade_sprite/EVALUATION.md
Normal file
264
shade_sprite/EVALUATION.md
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
# shade_sprite Evaluation Report
|
||||||
|
|
||||||
|
**Date:** 2026-02-17
|
||||||
|
**Purpose:** 7DRL readiness assessment of the character assembly and animation system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
shade_sprite/
|
||||||
|
__init__.py - Clean public API, __all__ exports
|
||||||
|
formats.py - SheetFormat definitions, Direction enum, AnimFrame/AnimDef dataclasses
|
||||||
|
animation.py - AnimatedSprite state machine
|
||||||
|
assembler.py - CharacterAssembler (layer compositing + HSL recoloring)
|
||||||
|
demo.py - 6-scene interactive demo
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Assessment
|
||||||
|
|
||||||
|
### 1. CharacterAssembler
|
||||||
|
|
||||||
|
**Status: FUNCTIONAL, with caveats**
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- Accepts N layer PNGs added bottom-to-top via `add_layer(path, hue_shift, sat_shift, lit_shift)`
|
||||||
|
- Loads each as an `mcrfpy.Texture`, applies HSL shift if any non-zero values
|
||||||
|
- Composites all layers via `mcrfpy.Texture.composite()` (alpha blending)
|
||||||
|
- Returns a single `mcrfpy.Texture` ready for use with `mcrfpy.Sprite`
|
||||||
|
- Method chaining supported (`asm.add_layer(...).add_layer(...)`)
|
||||||
|
- `clear()` resets layers for reuse
|
||||||
|
|
||||||
|
**What it supports:**
|
||||||
|
- Loading separate layer PNGs (body, armor, weapon, etc.): **YES**
|
||||||
|
- Compositing them back-to-front: **YES** (via Texture.composite)
|
||||||
|
- Recoloring via HSL shift: **YES** (per-layer hue/sat/lit adjustments)
|
||||||
|
- Palette swap: **NO** (only continuous HSL rotation, not indexed palette remapping)
|
||||||
|
|
||||||
|
**Limitations:**
|
||||||
|
- No layer visibility toggle (must rebuild without the layer)
|
||||||
|
- No per-layer offset/transform (all layers must be pixel-aligned same-size sheets)
|
||||||
|
- No caching — every `build()` call reloads textures from disk
|
||||||
|
- No export/save — composite exists only as an in-memory mcrfpy.Texture
|
||||||
|
- No layer ordering control beyond insertion order
|
||||||
|
|
||||||
|
### 2. AnimatedSprite
|
||||||
|
|
||||||
|
**Status: COMPLETE AND WELL-TESTED**
|
||||||
|
|
||||||
|
**What it supports:**
|
||||||
|
- 8-directional facing (N/S/E/W/NE/NW/SE/SW): **YES** via `Direction` IntEnum
|
||||||
|
- 4-directional with diagonal rounding: **YES** (SW->S, NE->N, etc.)
|
||||||
|
- 1-directional (for slimes etc.): **YES**
|
||||||
|
- Programmatic direction setting: **YES** (`anim.direction = Direction.E`)
|
||||||
|
- Animation states: **YES** — any named animation from the format's dict
|
||||||
|
|
||||||
|
**Available animations (PUNY_29 format, 10 total):**
|
||||||
|
| Animation | Type | Frames | Behavior |
|
||||||
|
|-----------|------|--------|----------|
|
||||||
|
| idle | loop | 2 | Default start state |
|
||||||
|
| walk | loop | 4 | Movement |
|
||||||
|
| slash | one-shot | 4 | Melee attack, chains to idle |
|
||||||
|
| bow | one-shot | 4 | Ranged attack, chains to idle |
|
||||||
|
| thrust | one-shot | 4 | Spear/polearm, chains to idle |
|
||||||
|
| spellcast | one-shot | 4 | Magic attack, chains to idle |
|
||||||
|
| hurt | one-shot | 3 | Damage taken, chains to idle |
|
||||||
|
| death | one-shot | 3 | Death, no chain (stays on last frame) |
|
||||||
|
| dodge | one-shot | 4 | Evasion, chains to idle |
|
||||||
|
| item_use | one-shot | 1 | Item activation, chains to idle |
|
||||||
|
|
||||||
|
**PUNY_24 format (free pack, 8 animations):** Same minus dodge and item_use.
|
||||||
|
|
||||||
|
**Programmatic control:**
|
||||||
|
- `play("walk")` — start named animation, resets frame counter
|
||||||
|
- `tick(dt_ms)` — advance clock, auto-advances frames
|
||||||
|
- `set_direction(Direction.E)` — change facing, updates sprite immediately
|
||||||
|
- `finished` property — True when one-shot completes without chain
|
||||||
|
- Animation chaining — one-shot animations auto-transition to `chain_to`
|
||||||
|
|
||||||
|
**Architecture:** Wraps an `mcrfpy.Sprite` and updates its `sprite_index` property. Requires external `tick()` calls (typically from an `mcrfpy.Timer`).
|
||||||
|
|
||||||
|
### 3. Sprite Sheet Layout
|
||||||
|
|
||||||
|
**Status: WELL-DEFINED**
|
||||||
|
|
||||||
|
**Standard layout:** Rows = directions, Columns = animation frames.
|
||||||
|
|
||||||
|
| Format | Tile Size | Columns | Rows | Directions | Sheet Pixels |
|
||||||
|
|--------|-----------|---------|------|------------|--------------|
|
||||||
|
| PUNY_29 | 32x32 | 29 | 8 | 8 | 928x256 |
|
||||||
|
| PUNY_24 | 32x32 | 24 | 8 | 8 | 768x256 |
|
||||||
|
| CREATURE_RPGMAKER | 24x24 | 3 | 4 | 4 | 72x96 |
|
||||||
|
| SLIME | 32x32 | 15 | 1 | 1 | 480x32 |
|
||||||
|
|
||||||
|
**Auto-detection:** `detect_format(width, height)` maps pixel dimensions to format. Works for all 4 formats.
|
||||||
|
|
||||||
|
**Consistency:** All formats share the same `SheetFormat` abstraction. The `sprite_index(col, direction)` method computes flat tile indices consistently: `row * columns + col`.
|
||||||
|
|
||||||
|
### 4. Demo (demo.py)
|
||||||
|
|
||||||
|
**Status: FUNCTIONAL (6 scenes)**
|
||||||
|
|
||||||
|
**Runs without errors:** YES. Tested both headless (`--headless --exec`) and confirmed no Python exceptions. The KeyError from session 38f29994 has been resolved — the current code uses `mcrfpy.Key.NUM_1` etc. (not string-based lookups).
|
||||||
|
|
||||||
|
**Scene inventory:**
|
||||||
|
| Scene | Key | Content | Status |
|
||||||
|
|-------|-----|---------|--------|
|
||||||
|
| Animation Viewer | 1 | Cycle sheets/anims/directions, compass layout, slime | Complete |
|
||||||
|
| HSL Recolor | 2 | Live hue/sat/lit adjustment, 6-step hue wheel | Complete |
|
||||||
|
| Character Gallery | 3 | 5-column grid of all sheets, shared anim/dir control | Complete |
|
||||||
|
| Faction Generator | 4 | 4 random factions, 5 hue-shifted characters each | Complete |
|
||||||
|
| Layer Compositing | 5 | Base + overlay + composite side-by-side, hue row | Complete |
|
||||||
|
| Equipment Customizer | 6 | 3-slot system, procedural variant generation | Complete |
|
||||||
|
|
||||||
|
**Asset requirement:** Demo looks for PNGs in three search paths. The `~/Development/7DRL2026_Liber_Noster_jmccardle/assets_sources/Puny-Characters/` path resolves on this machine. Without assets, all scenes show a "no assets found" fallback message.
|
||||||
|
|
||||||
|
**Keyboard controls verified:** All 22 Key enum references (NUM_1-6, Q/E, A/D, W/S, LEFT/RIGHT, UP/DOWN, Z/X, TAB, T, R, SPACE) confirmed valid against current mcrfpy.Key enum.
|
||||||
|
|
||||||
|
### 5. Tests
|
||||||
|
|
||||||
|
**Status: ALL 25 PASS**
|
||||||
|
|
||||||
|
```
|
||||||
|
=== Format Definitions === 8 tests (dimensions, columns, rows, directions, animation counts, chaining)
|
||||||
|
=== Format Detection === 5 tests (all 4 formats + unknown)
|
||||||
|
=== Direction === 8 tests (enum values, 8-dir mapping, 4-dir mapping with diagonal rounding)
|
||||||
|
=== Sprite Index === 5 tests (flat index computation for PUNY_29 and SLIME)
|
||||||
|
=== AnimatedSprite === 14 tests (creation, play, tick timing, direction change, one-shot chaining, death, error handling)
|
||||||
|
=== CharacterAssembler === 2 tests (creation, empty build error)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test coverage gaps:**
|
||||||
|
- CharacterAssembler `build()` with actual layers is NOT tested (only error case tested)
|
||||||
|
- HSL shift integration is NOT tested (requires real texture data)
|
||||||
|
- No test for Texture.composite() through the assembler
|
||||||
|
- No visual regression tests (screenshots)
|
||||||
|
- No performance/memory tests for bulk texture generation
|
||||||
|
|
||||||
|
### 6. Assets
|
||||||
|
|
||||||
|
**Status: EXTERNAL DEPENDENCY, NOT IN REPO**
|
||||||
|
|
||||||
|
**Available on this machine (not in McRogueFace repo):**
|
||||||
|
|
||||||
|
*Free pack* (`Puny-Characters/`, 768x256 PUNY_24):
|
||||||
|
- 19 pre-composed character sheets (Warrior, Soldier, Archer, Mage, Human-Soldier, Human-Worker, Orc variants)
|
||||||
|
- 1 Character-Base.png (body-only layer)
|
||||||
|
- 1 Slime.png (480x32 SLIME format)
|
||||||
|
- 4 Environment tiles
|
||||||
|
|
||||||
|
*Paid pack* (`PUNY_CHARACTERS_v2.1/`, 928x256 PUNY_29):
|
||||||
|
- 8 individual layer categories: Skins (14 variants), Shoes, Clothes (7 body types x colors), Gloves, Hairstyle, Eyes, Headgears, Add-ons
|
||||||
|
- Pre-made composite sheets organized by race (Humans, Elves, Dwarves, Orcs, etc.)
|
||||||
|
- Photoshop/GIMP source files
|
||||||
|
- Tools (deleter overlays, weapon overlayer)
|
||||||
|
|
||||||
|
**The free pack has ONE layer file** (Character-Base.png) suitable for compositing. True multi-layer assembly requires the paid PUNY_CHARACTERS_v2.1 pack.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7DRL Gap Analysis
|
||||||
|
|
||||||
|
### Gap 1: Procedural Faction Generation
|
||||||
|
|
||||||
|
**Current capability:** Scene 4 (Faction Generator) demonstrates hue-shifting pre-composed sheets to create "factions." Scene 6 (Equipment Customizer) shows multi-layer compositing with per-slot HSL control.
|
||||||
|
|
||||||
|
**What works for 7DRL:**
|
||||||
|
- Applying a faction hue to existing character sheets creates visually distinct groups
|
||||||
|
- HSL shift covers hue (color identity), saturation (vibrancy), and lightness (dark/light variants)
|
||||||
|
- Random hue selection per faction produces reasonable visual variety
|
||||||
|
|
||||||
|
**What's missing:**
|
||||||
|
- **Species variation via layer swap:** The assembler supports this IF you have separate layer PNGs (the paid pack has them, the free pack does not). No code exists to enumerate available layers by category (skins, clothes, etc.) or randomly select from each category.
|
||||||
|
- **No "faction recipe" data structure:** There's no serializable faction definition that says "skin=Orc2, clothes=VikingBody-Red+hue180, hair=none." The demo builds composites imperatively.
|
||||||
|
- **No palette-indexed recoloring:** HSL shift rotates all hues uniformly. A red-and-blue character shifted +120 degrees becomes green-and-purple. True faction coloring would need selective recoloring (e.g., only shift the clothing layer, not the skin).
|
||||||
|
|
||||||
|
**Verdict:** Functional for simple hue-based faction differentiation. For species + equipment variety, you need the paid layer PNGs and a layer-category enumeration helper.
|
||||||
|
|
||||||
|
### Gap 2: Bulk Generation
|
||||||
|
|
||||||
|
**Can you generate 2-4 character variants per faction on a virtual tile sheet?**
|
||||||
|
|
||||||
|
**Current capability:** Each `assembler.build()` call produces a separate `mcrfpy.Texture`. There is no API to pack multiple characters onto a single tile sheet.
|
||||||
|
|
||||||
|
**What works:**
|
||||||
|
- Generate N separate textures (one per character variant), each assigned to a separate `mcrfpy.Sprite`
|
||||||
|
- The demo already does this: Scene 4 creates 4 factions x 5 characters = 20 separate textures
|
||||||
|
- Each texture is runtime-only (not saved to disk)
|
||||||
|
|
||||||
|
**What's missing:**
|
||||||
|
- **No tile-sheet packer:** Cannot combine 4 character textures into a single 4-wide sprite sheet for use with a single Entity on a Grid
|
||||||
|
- **Texture.from_bytes could theoretically be used** to manually blit multiple characters into one sheet, but this would require reading back pixel data (not currently exposed)
|
||||||
|
- **No Texture.read_pixels() or similar** to extract raw bytes from an existing texture
|
||||||
|
|
||||||
|
**Verdict:** For 7DRL, the simplest approach is one Texture per character variant (each gets its own Sprite/Entity). This works but means more GPU texture objects. A tile-sheet packer would be a nice-to-have but is not blocking.
|
||||||
|
|
||||||
|
### Gap 3: Runtime Integration
|
||||||
|
|
||||||
|
**Can McRogueFace entities use assembled sprites at runtime?**
|
||||||
|
|
||||||
|
**Status: YES, fully runtime**
|
||||||
|
|
||||||
|
- `CharacterAssembler.build()` returns an `mcrfpy.Texture` immediately usable with `mcrfpy.Sprite`
|
||||||
|
- `AnimatedSprite` wraps any `mcrfpy.Sprite` and drives its `sprite_index`
|
||||||
|
- Timer-based `tick()` integrates with the game loop
|
||||||
|
- The entire pipeline (load layers -> HSL shift -> composite -> animate) runs at runtime
|
||||||
|
- No build-time step required
|
||||||
|
|
||||||
|
**Integration pattern (from demo.py):**
|
||||||
|
```python
|
||||||
|
# Create composite texture at runtime
|
||||||
|
asm = CharacterAssembler(PUNY_24)
|
||||||
|
asm.add_layer("Character-Base.png")
|
||||||
|
asm.add_layer("Warrior-Red.png", hue_shift=120.0)
|
||||||
|
tex = asm.build("faction_warrior")
|
||||||
|
|
||||||
|
# Use with sprite
|
||||||
|
sprite = mcrfpy.Sprite(texture=tex, pos=(x, y), scale=2.0)
|
||||||
|
scene.children.append(sprite)
|
||||||
|
|
||||||
|
# Animate
|
||||||
|
anim = AnimatedSprite(sprite, PUNY_24, Direction.S)
|
||||||
|
anim.play("walk")
|
||||||
|
|
||||||
|
# Drive from timer
|
||||||
|
def tick(timer, runtime):
|
||||||
|
anim.tick(timer.interval)
|
||||||
|
mcrfpy.Timer("anim", tick, 50)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary Scorecard
|
||||||
|
|
||||||
|
| Component | Status | 7DRL Ready? |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| AnimatedSprite | Complete, well-tested | YES |
|
||||||
|
| Direction system (8-dir) | Complete | YES |
|
||||||
|
| Animation definitions (10 states) | Complete | YES |
|
||||||
|
| Format auto-detection | Complete | YES |
|
||||||
|
| CharacterAssembler (compositing) | Functional | YES (with paid pack layers) |
|
||||||
|
| HSL recoloring | Functional | YES |
|
||||||
|
| Demo | 6 scenes, no errors | YES |
|
||||||
|
| Unit tests | 25/25 pass | YES (coverage gaps in assembler) |
|
||||||
|
| Faction generation | Proof-of-concept in demo | PARTIAL — needs recipe/category system |
|
||||||
|
| Bulk sheet packing | Not implemented | NO — use 1 texture per character |
|
||||||
|
| Assets in repo | Not present | NO — external dependency |
|
||||||
|
| Layer category enumeration | Not implemented | NO — would need helper for paid pack |
|
||||||
|
|
||||||
|
## Recommendations for 7DRL
|
||||||
|
|
||||||
|
1. **Copy needed assets into the game project's assets directory** (or symlink). Don't rely on hardcoded paths to the 7DRL2026 project.
|
||||||
|
|
||||||
|
2. **For faction generation with the free pack:** Hue-shift pre-composed sheets. This gives color variety but not equipment/species variety. Sufficient for a jam game.
|
||||||
|
|
||||||
|
3. **For faction generation with the paid pack:** Build a small helper that scans the layer directories by category (Skins/, Clothes/, etc.) and randomly picks one from each. The CharacterAssembler already handles the compositing — you just need the selection logic.
|
||||||
|
|
||||||
|
4. **Don't build a tile-sheet packer.** One texture per character is fine for 7DRL scope. The engine handles many textures without issue.
|
||||||
|
|
||||||
|
5. **Add a texture cache in CharacterAssembler** if generating many variants. Currently every `build()` reloads PNGs from disk. A simple dict cache of `path -> Texture` would avoid redundant I/O.
|
||||||
|
|
||||||
|
6. **The demo is ready as a showcase/testing tool.** All 6 scenes work with keyboard navigation. It demonstrates every capability the module offers.
|
||||||
|
|
@ -29,6 +29,14 @@ For layered characters:
|
||||||
assembler.add_layer("clothes/BasicBlue-Body.png", hue_shift=120.0)
|
assembler.add_layer("clothes/BasicBlue-Body.png", hue_shift=120.0)
|
||||||
assembler.add_layer("hair/M-Hairstyle1-Black.png")
|
assembler.add_layer("hair/M-Hairstyle1-Black.png")
|
||||||
texture = assembler.build("my_character")
|
texture = assembler.build("my_character")
|
||||||
|
|
||||||
|
For procedural factions:
|
||||||
|
from shade_sprite import FactionGenerator, AssetLibrary
|
||||||
|
|
||||||
|
lib = AssetLibrary()
|
||||||
|
gen = FactionGenerator(seed=42, library=lib)
|
||||||
|
recipe = gen.generate()
|
||||||
|
textures = recipe.build_role_textures(assembler)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .formats import (
|
from .formats import (
|
||||||
|
|
@ -44,12 +52,23 @@ from .formats import (
|
||||||
detect_format,
|
detect_format,
|
||||||
)
|
)
|
||||||
from .animation import AnimatedSprite
|
from .animation import AnimatedSprite
|
||||||
from .assembler import CharacterAssembler
|
from .assembler import CharacterAssembler, TextureCache
|
||||||
|
from .assets import AssetLibrary, LayerFile
|
||||||
|
from .factions import (
|
||||||
|
FactionRecipe,
|
||||||
|
FactionGenerator,
|
||||||
|
RoleDefinition,
|
||||||
|
Biome,
|
||||||
|
Element,
|
||||||
|
Aesthetic,
|
||||||
|
RoleType,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Core classes
|
# Core classes
|
||||||
"AnimatedSprite",
|
"AnimatedSprite",
|
||||||
"CharacterAssembler",
|
"CharacterAssembler",
|
||||||
|
"TextureCache",
|
||||||
# Format definitions
|
# Format definitions
|
||||||
"Direction",
|
"Direction",
|
||||||
"AnimFrame",
|
"AnimFrame",
|
||||||
|
|
@ -63,4 +82,15 @@ __all__ = [
|
||||||
"ALL_FORMATS",
|
"ALL_FORMATS",
|
||||||
# Utilities
|
# Utilities
|
||||||
"detect_format",
|
"detect_format",
|
||||||
|
# Asset scanning
|
||||||
|
"AssetLibrary",
|
||||||
|
"LayerFile",
|
||||||
|
# Faction generation
|
||||||
|
"FactionRecipe",
|
||||||
|
"FactionGenerator",
|
||||||
|
"RoleDefinition",
|
||||||
|
"Biome",
|
||||||
|
"Element",
|
||||||
|
"Aesthetic",
|
||||||
|
"RoleType",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,63 @@
|
||||||
Uses the engine's Texture.composite() and texture.hsl_shift() methods to
|
Uses the engine's Texture.composite() and texture.hsl_shift() methods to
|
||||||
build composite character textures from multiple layer PNG files, without
|
build composite character textures from multiple layer PNG files, without
|
||||||
requiring PIL or any external Python packages.
|
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
|
import mcrfpy
|
||||||
from .formats import PUNY_29, SheetFormat
|
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:
|
class CharacterAssembler:
|
||||||
"""Build composite character sheets from layer files.
|
"""Build composite character sheets from layer files.
|
||||||
|
|
||||||
|
|
@ -16,13 +68,16 @@ class CharacterAssembler:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
fmt: SheetFormat describing the sprite dimensions (default: PUNY_29)
|
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:
|
if fmt is None:
|
||||||
fmt = PUNY_29
|
fmt = PUNY_29
|
||||||
self.fmt = fmt
|
self.fmt = fmt
|
||||||
self.layers = []
|
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):
|
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.
|
"""Queue a layer PNG with optional HSL recoloring.
|
||||||
|
|
@ -44,8 +99,8 @@ class CharacterAssembler:
|
||||||
def build(self, name="<composed>"):
|
def build(self, name="<composed>"):
|
||||||
"""Composite all queued layers into a single Texture.
|
"""Composite all queued layers into a single Texture.
|
||||||
|
|
||||||
Loads each layer file, applies HSL shifts if any, then composites
|
Loads each layer file (using the cache to avoid redundant disk reads
|
||||||
all layers bottom-to-top using alpha blending.
|
and HSL computations), then composites all layers bottom-to-top.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Optional name for the resulting texture
|
name: Optional name for the resulting texture
|
||||||
|
|
@ -62,9 +117,7 @@ class CharacterAssembler:
|
||||||
|
|
||||||
textures = []
|
textures = []
|
||||||
for path, h, s, l in self.layers:
|
for path, h, s, l in self.layers:
|
||||||
tex = mcrfpy.Texture(path, self.fmt.tile_w, self.fmt.tile_h)
|
tex = self.cache.get(path, self.fmt.tile_w, self.fmt.tile_h, h, s, l)
|
||||||
if h != 0.0 or s != 0.0 or l != 0.0:
|
|
||||||
tex = tex.hsl_shift(h, s, l)
|
|
||||||
textures.append(tex)
|
textures.append(tex)
|
||||||
|
|
||||||
if len(textures) == 1:
|
if len(textures) == 1:
|
||||||
|
|
|
||||||
295
shade_sprite/assets.py
Normal file
295
shade_sprite/assets.py
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
"""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)"
|
||||||
1310
shade_sprite/demo.py
1310
shade_sprite/demo.py
File diff suppressed because it is too large
Load diff
494
shade_sprite/factions.py
Normal file
494
shade_sprite/factions.py
Normal file
|
|
@ -0,0 +1,494 @@
|
||||||
|
"""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)
|
||||||
329
tests/unit/shade_sprite_factions_test.py
Normal file
329
tests/unit/shade_sprite_factions_test.py
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
"""Unit tests for shade_sprite.factions and shade_sprite.assets modules."""
|
||||||
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
project_root = os.path.dirname(os.path.dirname(script_dir))
|
||||||
|
if project_root not in sys.path:
|
||||||
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
from shade_sprite.assets import AssetLibrary, LayerFile, _parse_species_from_skin
|
||||||
|
from shade_sprite.factions import (
|
||||||
|
FactionRecipe, FactionGenerator, RoleDefinition,
|
||||||
|
Biome, Element, Aesthetic, RoleType,
|
||||||
|
_AESTHETIC_ROLE_POOLS,
|
||||||
|
)
|
||||||
|
from shade_sprite.assembler import CharacterAssembler, TextureCache
|
||||||
|
from shade_sprite.formats import PUNY_29
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
|
||||||
|
def test(name, condition, msg=""):
|
||||||
|
if not condition:
|
||||||
|
errors.append(f"FAIL: {name} - {msg}")
|
||||||
|
print(f" FAIL: {name} {msg}")
|
||||||
|
else:
|
||||||
|
print(f" PASS: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# AssetLibrary tests
|
||||||
|
# ===================================================================
|
||||||
|
print("=== AssetLibrary ===")
|
||||||
|
|
||||||
|
lib = AssetLibrary()
|
||||||
|
has_assets = lib.available
|
||||||
|
|
||||||
|
test("AssetLibrary instantiates", lib is not None)
|
||||||
|
test("AssetLibrary repr", "AssetLibrary" in repr(lib))
|
||||||
|
|
||||||
|
if has_assets:
|
||||||
|
print(" (Paid asset pack detected - running full asset tests)")
|
||||||
|
|
||||||
|
test("has species", len(lib.species) > 0, f"got {lib.species}")
|
||||||
|
test("Human in species", "Human" in lib.species,
|
||||||
|
f"species: {lib.species}")
|
||||||
|
test("Orc in species", "Orc" in lib.species)
|
||||||
|
|
||||||
|
# Skins
|
||||||
|
human_skins = lib.skins_for("Human")
|
||||||
|
test("skins_for Human returns files", len(human_skins) > 0,
|
||||||
|
f"got {len(human_skins)}")
|
||||||
|
test("skin is LayerFile", isinstance(human_skins[0], LayerFile))
|
||||||
|
test("skin has path", os.path.isfile(human_skins[0].path))
|
||||||
|
test("skin category is skins", human_skins[0].category == "skins")
|
||||||
|
|
||||||
|
# No skins for nonexistent species
|
||||||
|
test("no skins for Alien", len(lib.skins_for("Alien")) == 0)
|
||||||
|
|
||||||
|
# Categories
|
||||||
|
cats = lib.categories
|
||||||
|
test("has categories", len(cats) >= 5,
|
||||||
|
f"got {len(cats)}: {cats}")
|
||||||
|
test("skins in categories", "skins" in cats)
|
||||||
|
test("clothes in categories", "clothes" in cats)
|
||||||
|
|
||||||
|
# Clothes
|
||||||
|
clothes = lib.clothes
|
||||||
|
test("has clothes", len(clothes) > 0, f"got {len(clothes)}")
|
||||||
|
|
||||||
|
# Subcategories
|
||||||
|
clothes_subs = lib.subcategories("clothes")
|
||||||
|
test("clothes has subcategories", len(clothes_subs) > 0,
|
||||||
|
f"got {clothes_subs}")
|
||||||
|
|
||||||
|
# clothes_by_style
|
||||||
|
armour = lib.clothes_by_style("armour")
|
||||||
|
test("armour clothes found", len(armour) > 0, f"got {len(armour)}")
|
||||||
|
test("armour subcategory", "Armour" in armour[0].subcategory)
|
||||||
|
|
||||||
|
# Headgears
|
||||||
|
melee_hg = lib.headgears_for_class("melee")
|
||||||
|
test("melee headgears found", len(melee_hg) > 0)
|
||||||
|
mage_hg = lib.headgears_for_class("mage")
|
||||||
|
test("mage headgears found", len(mage_hg) > 0)
|
||||||
|
|
||||||
|
# Add-ons
|
||||||
|
orc_addons = lib.addons_for("Orc")
|
||||||
|
test("Orc add-ons found", len(orc_addons) > 0)
|
||||||
|
elf_addons = lib.addons_for("Elf")
|
||||||
|
test("Elf add-ons found", len(elf_addons) > 0)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
summary = lib.summary()
|
||||||
|
test("summary has entries", len(summary) > 0)
|
||||||
|
test("summary values are ints", all(isinstance(v, int) for v in summary.values()))
|
||||||
|
|
||||||
|
# Shoes, gloves, etc.
|
||||||
|
test("has shoes", len(lib.shoes) > 0)
|
||||||
|
test("has gloves", len(lib.gloves) > 0)
|
||||||
|
test("has hairstyles", len(lib.hairstyles) > 0)
|
||||||
|
test("has eyes", len(lib.eyes) > 0)
|
||||||
|
test("has headgears", len(lib.headgears) > 0)
|
||||||
|
test("has addons", len(lib.addons) > 0)
|
||||||
|
else:
|
||||||
|
print(" (No paid asset pack - running minimal tests)")
|
||||||
|
test("unavailable lib has no species", len(lib.species) == 0)
|
||||||
|
test("unavailable lib layers empty", len(lib.layers("skins")) == 0)
|
||||||
|
test("unavailable lib summary empty", len(lib.summary()) == 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Species parsing ----
|
||||||
|
print("\n=== Species Parsing ===")
|
||||||
|
|
||||||
|
test("parse Human1 -> Human", _parse_species_from_skin("Human1") == "Human")
|
||||||
|
test("parse Human10 -> Human", _parse_species_from_skin("Human10") == "Human")
|
||||||
|
test("parse Orc2 -> Orc", _parse_species_from_skin("Orc2") == "Orc")
|
||||||
|
test("parse NightElf1 -> NightElf", _parse_species_from_skin("NightElf1") == "NightElf")
|
||||||
|
test("parse Cyclops1 -> Cyclops", _parse_species_from_skin("Cyclops1") == "Cyclops")
|
||||||
|
test("parse bare name -> itself", _parse_species_from_skin("Demon") == "Demon")
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# FactionGenerator determinism tests
|
||||||
|
# ===================================================================
|
||||||
|
print("\n=== FactionGenerator Determinism ===")
|
||||||
|
|
||||||
|
gen1 = FactionGenerator(seed=42, library=lib)
|
||||||
|
recipe1 = gen1.generate()
|
||||||
|
|
||||||
|
gen2 = FactionGenerator(seed=42, library=lib)
|
||||||
|
recipe2 = gen2.generate()
|
||||||
|
|
||||||
|
test("same seed -> same name", recipe1.name == recipe2.name,
|
||||||
|
f"'{recipe1.name}' vs '{recipe2.name}'")
|
||||||
|
test("same seed -> same biome", recipe1.biome == recipe2.biome)
|
||||||
|
test("same seed -> same species", recipe1.species == recipe2.species)
|
||||||
|
test("same seed -> same element", recipe1.element == recipe2.element)
|
||||||
|
test("same seed -> same aesthetic", recipe1.aesthetic == recipe2.aesthetic)
|
||||||
|
test("same seed -> same ally count", len(recipe1.ally_species) == len(recipe2.ally_species))
|
||||||
|
test("same seed -> same role count", len(recipe1.roles) == len(recipe2.roles))
|
||||||
|
|
||||||
|
# Check each role matches
|
||||||
|
for i, (r1, r2) in enumerate(zip(recipe1.roles, recipe2.roles)):
|
||||||
|
test(f"role {i} same type", r1.role_type == r2.role_type)
|
||||||
|
test(f"role {i} same species", r1.species == r2.species)
|
||||||
|
test(f"role {i} same skin hue", abs(r1.skin_hue - r2.skin_hue) < 0.001,
|
||||||
|
f"{r1.skin_hue} vs {r2.skin_hue}")
|
||||||
|
|
||||||
|
# Different seeds -> different results
|
||||||
|
gen3 = FactionGenerator(seed=99, library=lib)
|
||||||
|
recipe3 = gen3.generate()
|
||||||
|
# Not guaranteed to be different in every field, but extremely likely
|
||||||
|
# Check at least one attribute differs
|
||||||
|
differs = (recipe1.name != recipe3.name or
|
||||||
|
recipe1.biome != recipe3.biome or
|
||||||
|
recipe1.species != recipe3.species or
|
||||||
|
recipe1.element != recipe3.element or
|
||||||
|
recipe1.aesthetic != recipe3.aesthetic)
|
||||||
|
test("different seed -> likely different recipe", differs)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# FactionRecipe structure tests
|
||||||
|
# ===================================================================
|
||||||
|
print("\n=== FactionRecipe Structure ===")
|
||||||
|
|
||||||
|
test("recipe has name", isinstance(recipe1.name, str) and len(recipe1.name) > 0)
|
||||||
|
test("recipe has biome", isinstance(recipe1.biome, Biome))
|
||||||
|
test("recipe has element", isinstance(recipe1.element, Element))
|
||||||
|
test("recipe has aesthetic", isinstance(recipe1.aesthetic, Aesthetic))
|
||||||
|
test("recipe has species", isinstance(recipe1.species, str))
|
||||||
|
test("recipe has seed", recipe1.seed == 42)
|
||||||
|
test("recipe has 3-5 roles", 3 <= len(recipe1.roles) <= 5,
|
||||||
|
f"got {len(recipe1.roles)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Role generation counts by aesthetic
|
||||||
|
# ===================================================================
|
||||||
|
print("\n=== Role Counts by Aesthetic ===")
|
||||||
|
|
||||||
|
# Generate many factions to verify aesthetic influence on role pools
|
||||||
|
role_type_counts = {aesthetic: {} for aesthetic in Aesthetic}
|
||||||
|
for seed in range(100):
|
||||||
|
gen = FactionGenerator(seed=seed, library=lib)
|
||||||
|
recipe = gen.generate()
|
||||||
|
aes = recipe.aesthetic
|
||||||
|
for role in recipe.roles:
|
||||||
|
rt = role.role_type
|
||||||
|
role_type_counts[aes][rt] = role_type_counts[aes].get(rt, 0) + 1
|
||||||
|
|
||||||
|
# Militaristic should have more melee/ranged
|
||||||
|
mil = role_type_counts[Aesthetic.MILITARISTIC]
|
||||||
|
test("militaristic has melee fighters",
|
||||||
|
mil.get(RoleType.MELEE_FIGHTER, 0) > 0)
|
||||||
|
test("militaristic has ranged fighters",
|
||||||
|
mil.get(RoleType.RANGED_FIGHTER, 0) > 0)
|
||||||
|
|
||||||
|
# Fanatical should have spellcasters
|
||||||
|
fan = role_type_counts[Aesthetic.FANATICAL]
|
||||||
|
test("fanatical has spellcasters",
|
||||||
|
fan.get(RoleType.SPELLCASTER, 0) > 0)
|
||||||
|
|
||||||
|
# Cowardly should have pets/flankers
|
||||||
|
cow = role_type_counts[Aesthetic.COWARDLY]
|
||||||
|
test("cowardly has pet flankers",
|
||||||
|
cow.get(RoleType.PET_FLANKER, 0) > 0)
|
||||||
|
|
||||||
|
# Slavery should have pets (as enslaved allies)
|
||||||
|
slv = role_type_counts[Aesthetic.SLAVERY]
|
||||||
|
test("slavery has pet roles",
|
||||||
|
slv.get(RoleType.PET_RUSHER, 0) + slv.get(RoleType.PET_FLANKER, 0) > 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# RoleDefinition skin hue variation
|
||||||
|
# ===================================================================
|
||||||
|
print("\n=== Skin Hue Variation ===")
|
||||||
|
|
||||||
|
# Within a single faction, main-species roles should have close but distinct hues
|
||||||
|
gen_hue = FactionGenerator(seed=7, library=lib)
|
||||||
|
recipe_hue = gen_hue.generate()
|
||||||
|
|
||||||
|
main_roles = [r for r in recipe_hue.roles if r.species == recipe_hue.species]
|
||||||
|
if len(main_roles) >= 2:
|
||||||
|
hues = [r.skin_hue for r in main_roles]
|
||||||
|
# Check that hues are distinct (not identical)
|
||||||
|
unique_hues = len(set(round(h, 2) for h in hues))
|
||||||
|
test("main species roles have distinct hues",
|
||||||
|
unique_hues == len(hues),
|
||||||
|
f"hues: {[f'{h:.1f}' for h in hues]}")
|
||||||
|
|
||||||
|
# Check that hues are within reasonable range of each other (within 30 degrees)
|
||||||
|
# The generator uses +/-15 degree variation from a base
|
||||||
|
base_hue = sum(hues) / len(hues)
|
||||||
|
all_close = all(
|
||||||
|
min(abs(h - base_hue), 360 - abs(h - base_hue)) < 30
|
||||||
|
for h in hues
|
||||||
|
)
|
||||||
|
test("main species hues are close (within 30 degrees of mean)",
|
||||||
|
all_close, f"hues: {[f'{h:.1f}' for h in hues]}, mean: {base_hue:.1f}")
|
||||||
|
else:
|
||||||
|
print(f" SKIP: only {len(main_roles)} main-species roles (need >= 2 for hue test)")
|
||||||
|
|
||||||
|
# RoleDefinition label
|
||||||
|
test("role has label",
|
||||||
|
"(" in recipe1.roles[0].label and ")" in recipe1.roles[0].label)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# RoleDefinition layer assignments (if assets available)
|
||||||
|
# ===================================================================
|
||||||
|
if has_assets:
|
||||||
|
print("\n=== Layer Assignments ===")
|
||||||
|
|
||||||
|
# Generate a faction and check that roles have actual file paths
|
||||||
|
gen_layers = FactionGenerator(seed=55, library=lib)
|
||||||
|
recipe_layers = gen_layers.generate()
|
||||||
|
|
||||||
|
for i, role in enumerate(recipe_layers.roles):
|
||||||
|
has_skin = role._skin_path is not None
|
||||||
|
test(f"role {i} ({role.role_type.value}) has skin path", has_skin)
|
||||||
|
if has_skin:
|
||||||
|
test(f"role {i} skin file exists", os.path.isfile(role._skin_path))
|
||||||
|
|
||||||
|
# Non-pet roles should generally have clothing
|
||||||
|
if role.role_type not in (RoleType.PET_RUSHER, RoleType.PET_FLANKER):
|
||||||
|
# Check at least one of clothing/headgear/shoes is assigned
|
||||||
|
has_any = (len(role.clothing_layers) > 0 or
|
||||||
|
role.headgear_layer is not None or
|
||||||
|
role.shoe_layer is not None)
|
||||||
|
# Not guaranteed for slavery aesthetic allies, but main species should have gear
|
||||||
|
if not role.is_ally:
|
||||||
|
test(f"role {i} ({role.role_type.value}) has equipment",
|
||||||
|
has_any)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# TextureCache tests
|
||||||
|
# ===================================================================
|
||||||
|
print("\n=== TextureCache ===")
|
||||||
|
|
||||||
|
cache = TextureCache()
|
||||||
|
test("cache starts empty", len(cache) == 0)
|
||||||
|
|
||||||
|
# Create a test texture via from_bytes
|
||||||
|
tex_data = bytes([100, 150, 200, 255] * (928 * 256))
|
||||||
|
test_tex_path = None # Can't test file loading without real files
|
||||||
|
|
||||||
|
# Test cache contains
|
||||||
|
test("cache doesn't contain missing key",
|
||||||
|
("nonexistent.png", 0.0, 0.0, 0.0) not in cache)
|
||||||
|
|
||||||
|
cache.clear()
|
||||||
|
test("cache clear works", len(cache) == 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Enum coverage
|
||||||
|
# ===================================================================
|
||||||
|
print("\n=== Enum Coverage ===")
|
||||||
|
|
||||||
|
test("Biome has 5 values", len(Biome) == 5)
|
||||||
|
test("Element has 4 values", len(Element) == 4)
|
||||||
|
test("Aesthetic has 4 values", len(Aesthetic) == 4)
|
||||||
|
test("RoleType has 6 values", len(RoleType) == 6)
|
||||||
|
|
||||||
|
# All aesthetics have role pools
|
||||||
|
for aes in Aesthetic:
|
||||||
|
pool = _AESTHETIC_ROLE_POOLS[aes]
|
||||||
|
test(f"{aes.value} has role pool", len(pool) >= 3,
|
||||||
|
f"got {len(pool)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Summary
|
||||||
|
# ===================================================================
|
||||||
|
print()
|
||||||
|
if errors:
|
||||||
|
print(f"FAILED: {len(errors)} tests failed")
|
||||||
|
for e in errors:
|
||||||
|
print(f" {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("All tests passed!")
|
||||||
|
sys.exit(0)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue