Compare commits

..

5 commits

Author SHA1 Message Date
453ea4a7eb Version bump: 0.2.6-prerelease-7drl2026 (4404d10) -> 0.2.7-prerelease-7drl2026 2026-02-21 07:58:10 -05:00
4404d1082a Update roadmap for 7DRL 2026 and post-jam 1.0 planning
Rewrite ROADMAP.md to reflect current project state:
- Summarize 0.2 series shipped features (3D/voxel, procgen, Tiled/LDtk,
  WASM, animation callbacks, multi-layer grids, doc macros)
- 7DRL 2026 dates (Feb 28 - Mar 8) and remaining prep
- Post-jam priorities: API freeze process, pain point fixes,
  roguelikedev tutorial series, pip/virtualenv integration
- Engine eras model (McRogueFace -> McVectorFace -> McVoxelFace)
- Future directions: McRogueFace Lite (MicroPython/PicoCalc),
  standard library widgets, package management
- Open issue groupings (30 issues across 8 areas)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:18:12 -05:00
9176dca055 Add mcrf-init.sh: game project scaffolding without engine recompilation
New workflow for game developers: run mcrf-init to create a project
directory with symlinks to a pre-built engine, then just write Python
scripts and assets. Games package for distribution (Linux/Windows/WASM)
without ever rebuilding the engine.

mcrf-init.sh creates:
- build/ with symlinked binary and libs, game content in assets/ + scripts/
- build-windows/ (if engine has a Windows build)
- Makefile with run, wasm, dist-linux, dist-windows, dist-wasm targets
- Starter game.py, .gitignore, pyrightconfig.json, VERSION file

CMakeLists.txt: WASM preload paths (assets, scripts) are now
configurable via MCRF_ASSETS_DIR / MCRF_SCRIPTS_DIR cache variables,
so game project Makefiles can point WASM builds at their own content
without modifying the engine.

Also adds pyrightconfig.json for the engine repo itself (IDE support
via stubs/).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:17:59 -05:00
732897426a Audio fixes: gain() DSP effect, sfxr phase wrap, SDL2 backend compat
- SoundBuffer.gain(factor): new DSP method for amplitude scaling before
  mixing (0.5 = half volume, 2.0 = double, clamped to int16 range)
- Fix sfxr square/saw waveform artifacts: phase now wraps at period
  boundary instead of growing unbounded; noise buffer refreshes per period
- Fix PySound construction from SoundBuffer on SDL2 backend: use
  loadFromSamples() directly instead of copy-assign (deleted on SDL2)
- Add Image::create(w, h, pixels) overload to HeadlessTypes and
  SDL2Types for pixel data initialization
- Waveform test suite (62 lines)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:17:41 -05:00
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
21 changed files with 3116 additions and 503 deletions

View file

@ -266,6 +266,11 @@ if(MCRF_SDL2)
target_compile_definitions(mcrogueface PRIVATE MCRF_SDL2) target_compile_definitions(mcrogueface PRIVATE MCRF_SDL2)
endif() endif()
# Asset/script directories for WASM preloading (game projects override these)
set(MCRF_ASSETS_DIR "${CMAKE_SOURCE_DIR}/assets" CACHE PATH "Assets directory for WASM preloading")
set(MCRF_SCRIPTS_DIR "${CMAKE_SOURCE_DIR}/src/scripts" CACHE PATH "Scripts directory for WASM preloading")
set(MCRF_SCRIPTS_PLAYGROUND_DIR "${CMAKE_SOURCE_DIR}/src/scripts_playground" CACHE PATH "Playground scripts for WASM")
# Emscripten-specific link options (use ports for zlib, bzip2, sqlite3) # Emscripten-specific link options (use ports for zlib, bzip2, sqlite3)
if(EMSCRIPTEN) if(EMSCRIPTEN)
# Base Emscripten options # Base Emscripten options
@ -287,9 +292,9 @@ if(EMSCRIPTEN)
# Preload Python stdlib into virtual filesystem at /lib/python3.14 # Preload Python stdlib into virtual filesystem at /lib/python3.14
--preload-file=${CMAKE_SOURCE_DIR}/wasm_stdlib/lib@/lib --preload-file=${CMAKE_SOURCE_DIR}/wasm_stdlib/lib@/lib
# Preload game scripts into /scripts (use playground scripts if MCRF_PLAYGROUND is set) # Preload game scripts into /scripts (use playground scripts if MCRF_PLAYGROUND is set)
--preload-file=${CMAKE_SOURCE_DIR}/src/$<IF:$<BOOL:${MCRF_PLAYGROUND}>,scripts_playground,scripts>@/scripts --preload-file=$<IF:$<BOOL:${MCRF_PLAYGROUND}>,${MCRF_SCRIPTS_PLAYGROUND_DIR},${MCRF_SCRIPTS_DIR}>@/scripts
# Preload assets # Preload assets
--preload-file=${CMAKE_SOURCE_DIR}/assets@/assets --preload-file=${MCRF_ASSETS_DIR}@/assets
# Use custom HTML shell - game shell (fullscreen) or playground shell (REPL) # Use custom HTML shell - game shell (fullscreen) or playground shell (REPL)
--shell-file=${CMAKE_SOURCE_DIR}/src/$<IF:$<BOOL:${MCRF_GAME_SHELL}>,shell_game.html,shell.html> --shell-file=${CMAKE_SOURCE_DIR}/src/$<IF:$<BOOL:${MCRF_GAME_SHELL}>,shell_game.html,shell.html>
# Pre-JS to fix browser zoom causing undefined values in events # Pre-JS to fix browser zoom causing undefined values in events

View file

@ -1,223 +1,120 @@
# McRogueFace - Development Roadmap # McRogueFace - Development Roadmap
## Project Status **Version**: 0.2.6-prerelease | **Era**: McRogueFace (2D roguelikes)
**Current State**: Active development - C++ game engine with Python scripting For detailed architecture, philosophy, and decision framework, see the [Strategic Direction](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Strategic-Direction) wiki page. For per-issue tracking, see the [Issue Roadmap](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Issue-Roadmap).
**Latest Release**: Alpha 0.1
**Issue Tracking**: See [Gitea Issues](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues) for current tasks and bugs
--- ---
## 🎯 Strategic Vision ## What Has Shipped
### Engine Philosophy **Alpha 0.1** (2024) -- First complete release. Milestone: all datatypes behaving.
- **C++ First**: Performance-critical code stays in C++ **0.2 series** (Jan-Feb 2026) -- Weekly updates to GitHub. Key additions:
- **Python Close Behind**: Rich scripting without frame-rate impact - 3D/Voxel pipeline (experimental): Viewport3D, Camera3D, Entity3D, VoxelGrid with greedy meshing and serialization
- **Game-Ready**: Each improvement should benefit actual game development - Procedural generation: HeightMap, BSP, NoiseSource, DiscreteMap
- Tiled and LDtk import with Wang tile / AutoRule resolution
- Emscripten/SDL2 backend for WebAssembly deployment
- Animation callbacks, mouse event system, grid cell callbacks
- Multi-layer grid system with chunk-based rendering and dirty-flag caching
- Documentation macro system with auto-generated API docs, man pages, and type stubs
- Windows cross-compilation, mobile-ish WASM support, SDL2_mixer audio
### Architecture Goals **Proving grounds**: Crypt of Sokoban (7DRL 2025) was the first complete game. 7DRL 2026 is the current target.
1. **Clean Inheritance**: Drawable → UI components, proper type preservation
2. **Collection Consistency**: Uniform iteration, indexing, and search patterns
3. **Resource Management**: RAII everywhere, proper lifecycle handling
4. **Multi-Platform**: Windows/Linux feature parity maintained
--- ---
## 🏗️ Architecture Decisions ## Current Focus: 7DRL 2026
### Three-Layer Grid Architecture **Dates**: February 28 -- March 8, 2026
Following successful roguelike patterns (Caves of Qud, Cogmind, DCSS):
1. **Visual Layer** (UIGridPoint) - Sprites, colors, animations Engine preparation is complete. All 2D systems are production-ready. The jam will expose remaining rough edges in the workflow of building a complete game on McRogueFace.
2. **World State Layer** (TCODMap) - Walkability, transparency, physics
3. **Entity Perspective Layer** (UIGridPointState) - Per-entity FOV, knowledge
### Performance Architecture Open prep items:
Critical for large maps (1000x1000): - **#248** -- Crypt of Sokoban Remaster (game content for the jam)
- **Spatial Hashing** for entity queries (not quadtrees!)
- **Batch Operations** with context managers (10-100x speedup)
- **Memory Pooling** for entities and components
- **Dirty Flag System** to avoid unnecessary updates
- **Zero-Copy NumPy Integration** via buffer protocol
### Key Insight from Research
"Minimizing Python/C++ boundary crossings matters more than individual function complexity"
- Batch everything possible
- Use context managers for logical operations
- Expose arrays, not individual cells
- Profile and optimize hot paths only
--- ---
## 🚀 Development Phases ## Post-7DRL: The Road to 1.0
For detailed task tracking and current priorities, see the [Gitea issue tracker](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues). After 7DRL, the priority shifts from feature development to **API stability**. 1.0 means the Python API is frozen: documented, stable, and not going to break.
### Phase 1: Foundation Stabilization ✅ ### API Freeze Process
**Status**: Complete 1. Catalog every public Python class, method, and property
**Key Issues**: #7 (Safe Constructors), #71 (Base Class), #87 (Visibility), #88 (Opacity) 2. Identify anything that should change before committing (naming, signatures, defaults)
3. Make breaking changes in a single coordinated pass
4. Document the stable API as the contract
5. Experimental modules (3D/Voxel) get an explicit `experimental` label and are exempt from the freeze
### Phase 2: Constructor & API Polish ✅ ### Post-Jam Priorities
**Status**: Complete - Fix pain points discovered during actual 7DRL game development
**Key Features**: Pythonic API, tuple support, standardized defaults - Progress on the r/roguelikedev tutorial series (#167)
- API consistency audit and freeze
### Phase 3: Entity Lifecycle Management ✅ - Better pip/virtualenv integration for adding packages to McRogueFace's embedded interpreter
**Status**: Complete
**Key Issues**: #30 (Entity.die()), #93 (Vector methods), #94 (Color helpers), #103 (Timer objects)
### Phase 4: Visibility & Performance ✅
**Status**: Complete
**Key Features**: AABB culling, name system, profiling tools
### Phase 5: Window/Scene Architecture ✅
**Status**: Complete
**Key Issues**: #34 (Window object), #61 (Scene object), #1 (Resize events), #105 (Scene transitions)
### Phase 6: Rendering Revolution ✅
**Status**: Complete
**Key Issues**: #50 (Grid backgrounds), #6 (RenderTexture), #8 (Viewport rendering)
### Phase 7: Documentation & Distribution ✅
**Status**: Complete (2025-10-30)
**Key Issues**: #85 (Docstrings), #86 (Parameter docs), #108 (Type stubs), #97 (API docs)
**Completed**: All classes and functions converted to MCRF_* macro system with automated HTML/Markdown/man page generation
See [current open issues](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues?state=open) for active work.
--- ---
## 🔮 Future Vision: Pure Python Extension Architecture ## Engine Eras
### Concept: McRogueFace as a Traditional Python Package One engine, accumulating capabilities. Nothing is thrown away.
**Status**: Long-term vision
**Complexity**: Major architectural overhaul
Instead of being a C++ application that embeds Python, McRogueFace could be redesigned as a pure Python extension module that can be installed via `pip install mcrogueface`. | Era | Focus | Status |
|-----|-------|--------|
### Technical Approach | **McRogueFace** | 2D tiles, roguelike systems, procgen | Active -- approaching 1.0 |
| **McVectorFace** | Sparse grids, vector graphics, physics | Planned |
1. **Separate Core Engine from Python Embedding** | **McVoxelFace** | Voxel terrain, 3D gameplay | Proof-of-concept complete |
- Extract SFML rendering, audio, and input into C++ extension modules
- Remove embedded CPython interpreter
- Use Python's C API to expose functionality
2. **Module Structure**
```
mcrfpy/
├── __init__.py # Pure Python coordinator
├── _core.so # C++ rendering/game loop extension
├── _sfml.so # SFML bindings
├── _audio.so # Audio system bindings
└── engine.py # Python game engine logic
```
3. **Inverted Control Flow**
- Python drives the main loop instead of C++
- C++ extensions handle performance-critical operations
- Python manages game logic, scenes, and entity systems
### Benefits
- **Standard Python Packaging**: `pip install mcrogueface`
- **Virtual Environment Support**: Works with venv, conda, poetry
- **Better IDE Integration**: Standard Python development workflow
- **Easier Testing**: Use pytest, standard Python testing tools
- **Cross-Python Compatibility**: Support multiple Python versions
- **Modular Architecture**: Users can import only what they need
### Challenges
- **Major Refactoring**: Complete restructure of codebase
- **Performance Considerations**: Python-driven main loop overhead
- **Build Complexity**: Multiple extension modules to compile
- **Platform Support**: Need wheels for many platform/Python combinations
- **API Stability**: Would need careful design to maintain compatibility
### Example Usage (Future Vision)
```python
import mcrfpy
from mcrfpy import Scene, Frame, Sprite, Grid
# Create game directly in Python
game = mcrfpy.Game(width=1024, height=768)
# Define scenes using Python classes
class MainMenu(Scene):
def on_enter(self):
self.ui.append(Frame(100, 100, 200, 50))
self.ui.append(Sprite("logo.png", x=400, y=100))
def on_keypress(self, key, pressed):
if key == "ENTER" and pressed:
self.game.set_scene("game")
# Run the game
game.add_scene("menu", MainMenu())
game.run()
```
This architecture would make McRogueFace a first-class Python citizen, following standard Python packaging conventions while maintaining high performance through C++ extensions.
--- ---
## 📋 Major Feature Areas ## 3D/Voxel Pipeline (Experimental)
For current status and detailed tasks, see the corresponding Gitea issue labels: The 3D pipeline is proof-of-concept scouting for the McVoxelFace era. It works and is tested but is explicitly **not** part of the 1.0 API freeze.
### Core Systems **What exists**: Viewport3D, Camera3D, Entity3D, MeshLayer, Model3D (glTF), Billboard, Shader3D, VoxelGrid with greedy meshing, face culling, RLE serialization, and navigation projection.
- **UI/Rendering System**: Issues tagged `[Major Feature]` related to rendering
- **Grid/Entity System**: Pathfinding, FOV, entity management
- **Animation System**: Property animation, easing functions, callbacks
- **Scene/Window Management**: Scene lifecycle, transitions, viewport
### Performance Optimization **Known gaps**: Some Entity3D collection methods, animation stubs, shader pipeline incomplete.
- **#115**: SpatialHash for 10,000+ entities
- **#116**: Dirty flag system
- **#113**: Batch operations for NumPy-style access
- **#117**: Memory pool for entities
### Advanced Features **Maturity track**: These modules will mature on their own timeline, driven by games that need 3D. They won't block 2D stability.
- **#118**: Scene as Drawable (scenes can be drawn/animated)
- **#122**: Parent-Child UI System
- **#123**: Grid Subgrid System (256x256 chunks)
- **#124**: Grid Point Animation
- **#106**: Shader support
- **#107**: Particle system
### Documentation
- **#92**: Inline C++ documentation system
- **#91**: Python type stub files (.pyi)
- **#97**: Automated API documentation extraction
- **#126**: Generate perfectly consistent Python interface
--- ---
## 📚 Resources ## Future Directions
These are ideas on the horizon -- not yet concrete enough for issues, but worth capturing.
### McRogueFace Lite
A spiritual port to MicroPython targeting the PicoCalc and other microcontrollers. Could provide a migration path to retro ROMs or compete in the Pico-8 space. The core idea: strip McRogueFace down to its essential tile/entity/scene model and run it on constrained hardware.
### McVectorFace Era
The next major capability expansion. Sparse grid layers, a polygon/shape rendering class, and eventually physics integration. This would support games that aren't purely tile-based -- top-down action, strategy maps with irregular regions, or hybrid tile+vector visuals. See the [Strategic Direction](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Strategic-Direction) wiki for the full era model.
### McRogueFace Standard Library
A built-in collection of reusable GUI widgets and game UI patterns: menus, dialogs, inventory screens, stat bars, text input fields, scrollable lists. These would ship with the engine as importable Python modules, saving every game from reimplementing the same UI primitives. Think of it as `mcrfpy.widgets` -- batteries included.
### Pip/Virtualenv Integration
Rather than inverting the architecture to make McRogueFace a pip-installable package, the nearer-term goal is better integration in the other direction: making it easy to install and use third-party Python packages within McRogueFace's embedded interpreter. This could mean virtualenv awareness, a `mcrf install` command, or bundling pip itself.
---
## Open Issues by Area
30 open issues across the tracker. Key groupings:
- **Multi-tile entities** (#233-#237) -- Oversized sprites, composite entities, origin offsets
- **Grid enhancements** (#152, #149, #67) -- Sparse layers, refactoring, infinite worlds
- **Performance** (#117, #124, #145) -- Memory pools, grid point animation, texture reuse
- **LLM agent testbed** (#154, #156, #55) -- Multi-agent simulation, turn-based orchestration
- **Platform/distribution** (#70, #54, #62, #53) -- Packaging, Jupyter, multiple windows, input methods
- **WASM tooling** (#238-#240) -- Debug infrastructure, automated browser testing, troubleshooting docs
- **Rendering** (#107, #218) -- Particle system, Color/Vector animation targets
See the [Gitea issue tracker](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues) for current status.
---
## Resources
- **Issue Tracker**: [Gitea Issues](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues) - **Issue Tracker**: [Gitea Issues](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues)
- **Source Code**: [Gitea Repository](https://gamedev.ffwf.net/gitea/john/McRogueFace) - **Wiki**: [Strategic Direction](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Strategic-Direction), [Issue Roadmap](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Issue-Roadmap), [Development Workflow](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Development-Workflow)
- **Documentation**: See `CLAUDE.md` for build instructions and development guide - **Build Guide**: See `CLAUDE.md` for build instructions
- **Tutorial**: See `roguelike_tutorial/` for implementation examples - **Tutorial**: `roguelike_tutorial/` for implementation examples
- **Workflow**: See "Gitea-First Workflow" section in `CLAUDE.md` for issue management best practices
---
## 🔄 Development Workflow
**Gitea is the Single Source of Truth** for this project. Before starting any work:
1. **Check Gitea Issues** for existing tasks, bugs, or related work
2. **Create granular issues** for new features or problems
3. **Update issues** when work affects other systems
4. **Document discoveries** - if something is undocumented or misleading, create a task to fix it
5. **Cross-reference commits** with issue numbers (e.g., "Fixes #104")
See the "Gitea-First Workflow" section in `CLAUDE.md` for detailed guidelines on efficient development practices using the Gitea MCP tools.
---
*For current priorities, task tracking, and bug reports, please use the [Gitea issue tracker](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues).*

422
mcrf-init.sh Executable file
View file

@ -0,0 +1,422 @@
#!/bin/bash
#
# mcrf-init.sh - Initialize a McRogueFace game project
#
# Creates a game project directory with symlinks to a pre-built engine,
# so game developers only write Python + assets, never rebuild the engine.
#
# Usage:
# mcrf-init # initialize current directory
# mcrf-init my-game # create and initialize my-game/
#
# Setup (add to ~/.bashrc):
# alias mcrf-init='/path/to/McRogueFace/mcrf-init.sh'
# # or: export PATH="$PATH:/path/to/McRogueFace"
#
# After init:
# make run # run the game
# make dist # package for distribution
set -e
# --- Resolve engine root (where this script lives) ---
ENGINE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- Determine game directory ---
if [ -n "$1" ]; then
GAME_DIR="$(pwd)/$1"
mkdir -p "$GAME_DIR"
else
GAME_DIR="$(pwd)"
fi
# --- Colors ---
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BOLD='\033[1m'
NC='\033[0m'
info() { echo -e "${GREEN}[mcrf]${NC} $1"; }
warn() { echo -e "${YELLOW}[mcrf]${NC} $1"; }
error() { echo -e "${RED}[mcrf]${NC} $1"; }
# --- Safety checks ---
if [ "$GAME_DIR" = "$ENGINE_ROOT" ]; then
error "Cannot init inside the engine directory itself."
exit 1
fi
if [ ! -f "$ENGINE_ROOT/build/mcrogueface" ]; then
error "Engine not built. Run 'make' in $ENGINE_ROOT first."
exit 1
fi
# Engine version for packaging
ENGINE_VERSION=$(grep 'MCRFPY_VERSION' "$ENGINE_ROOT/src/McRogueFaceVersion.h" \
| sed 's/.*"\(.*\)"/\1/')
info "Initializing McRogueFace game project"
info " Engine: $ENGINE_ROOT (v${ENGINE_VERSION})"
info " Game: $GAME_DIR"
echo
# --- Create game directories ---
mkdir -p "$GAME_DIR/assets"
mkdir -p "$GAME_DIR/scripts"
# --- Set up build/ (Linux) ---
info "Setting up build/ (Linux)..."
mkdir -p "$GAME_DIR/build"
# Binary
ln -sfn "$ENGINE_ROOT/build/mcrogueface" "$GAME_DIR/build/mcrogueface"
# Shared libraries (entire lib/ tree: .so files + Python stdlib + extensions)
ln -sfn "$ENGINE_ROOT/build/lib" "$GAME_DIR/build/lib"
# Game content: symlink to project's own directories
ln -sfn ../assets "$GAME_DIR/build/assets"
ln -sfn ../scripts "$GAME_DIR/build/scripts"
# --- Set up build-windows/ (if engine has it) ---
if [ -f "$ENGINE_ROOT/build-windows/mcrogueface.exe" ]; then
info "Setting up build-windows/..."
mkdir -p "$GAME_DIR/build-windows"
# Executable
ln -sfn "$ENGINE_ROOT/build-windows/mcrogueface.exe" "$GAME_DIR/build-windows/mcrogueface.exe"
# DLLs
for dll in "$ENGINE_ROOT/build-windows/"*.dll; do
[ -f "$dll" ] && ln -sfn "$dll" "$GAME_DIR/build-windows/$(basename "$dll")"
done
# Python stdlib zip
[ -f "$ENGINE_ROOT/build-windows/python314.zip" ] && \
ln -sfn "$ENGINE_ROOT/build-windows/python314.zip" "$GAME_DIR/build-windows/python314.zip"
# Python lib directory
[ -d "$ENGINE_ROOT/build-windows/lib" ] && \
ln -sfn "$ENGINE_ROOT/build-windows/lib" "$GAME_DIR/build-windows/lib"
# Game content
ln -sfn ../assets "$GAME_DIR/build-windows/assets"
ln -sfn ../scripts "$GAME_DIR/build-windows/scripts"
else
warn "No Windows build found (skipping build-windows/)"
fi
# --- WASM build info ---
info "WASM support: use 'make wasm' to build (requires emsdk)"
# --- Starter game.py ---
if [ ! -f "$GAME_DIR/scripts/game.py" ]; then
info "Creating starter scripts/game.py..."
cat > "$GAME_DIR/scripts/game.py" << 'PYTHON'
"""McRogueFace Game - created by mcrf-init"""
import mcrfpy
scene = mcrfpy.Scene("title")
ui = scene.children
ui.append(mcrfpy.Caption(
text="My McRogueFace Game",
pos=(512, 300),
font=mcrfpy.Font("assets/JetbrainsMono.ttf"),
fill_color=(255, 255, 255),
font_size=48
))
ui.append(mcrfpy.Caption(
text="Press ESC to quit",
pos=(512, 400),
font=mcrfpy.Font("assets/JetbrainsMono.ttf"),
fill_color=(180, 180, 180),
font_size=24
))
def on_key(key, state):
if key == mcrfpy.Key.ESCAPE and state == mcrfpy.InputState.PRESSED:
mcrfpy.exit()
scene.on_key = on_key
mcrfpy.current_scene = scene
PYTHON
fi
# --- Copy a default font if game assets/ is empty ---
if [ -z "$(ls -A "$GAME_DIR/assets/" 2>/dev/null)" ]; then
if [ -f "$ENGINE_ROOT/assets/JetbrainsMono.ttf" ]; then
info "Copying default font to assets/..."
cp "$ENGINE_ROOT/assets/JetbrainsMono.ttf" "$GAME_DIR/assets/"
fi
fi
# --- .gitignore ---
if [ ! -f "$GAME_DIR/.gitignore" ]; then
info "Creating .gitignore..."
cat > "$GAME_DIR/.gitignore" << 'GITIGNORE'
# McRogueFace game project
build/
build-windows/
build-wasm/
dist/
__pycache__/
*.pyc
*.png.bak
GITIGNORE
fi
# --- pyrightconfig.json (for Vim/LSP autocomplete) ---
if [ ! -f "$GAME_DIR/pyrightconfig.json" ]; then
info "Creating pyrightconfig.json (IDE autocomplete)..."
cat > "$GAME_DIR/pyrightconfig.json" << PYRIGHT
{
"include": ["scripts"],
"extraPaths": ["${ENGINE_ROOT}/stubs"],
"pythonVersion": "3.14",
"pythonPlatform": "Linux",
"typeCheckingMode": "basic",
"reportMissingModuleSource": false
}
PYRIGHT
fi
# --- Makefile ---
info "Creating Makefile..."
cat > "$GAME_DIR/Makefile" << MAKEFILE
# McRogueFace Game Project Makefile
# Generated by mcrf-init
#
# Engine: ${ENGINE_ROOT}
ENGINE_ROOT := ${ENGINE_ROOT}
GAME_NAME := $(basename "$GAME_DIR")
GAME_DIR := \$(shell pwd)
# Game version - edit this or create a VERSION file
VERSION := \$(shell cat VERSION 2>/dev/null || echo "0.1.0")
# Engine version (from the linked build)
ENGINE_VERSION := \$(shell grep 'MCRFPY_VERSION' "\$(ENGINE_ROOT)/src/McRogueFaceVersion.h" \\
| sed 's/.*"\\(.*\\)"/\\1/' 2>/dev/null || echo "unknown")
.PHONY: run run-windows wasm serve-wasm dist dist-linux dist-windows dist-wasm clean-dist clean-wasm info
# ============================================================
# Run targets
# ============================================================
run:
@cd build && ./mcrogueface
run-headless:
@cd build && ./mcrogueface --headless --exec ../scripts/game.py
run-windows:
@echo "Launch build-windows/mcrogueface.exe on a Windows machine or via Wine"
serve-wasm:
@if [ ! -f build-wasm/mcrogueface.html ]; then \\
echo "No WASM build found. Run 'make wasm' first."; \\
exit 1; \\
fi
@echo "Serving WASM build at http://localhost:8080"
@cd build-wasm && python3 -m http.server 8080
# ============================================================
# WASM build (requires emsdk)
# ============================================================
wasm:
@if ! command -v emcmake >/dev/null 2>&1; then \\
echo "Error: emcmake not found. Activate emsdk first:"; \\
echo " source ~/emsdk/emsdk_env.sh"; \\
exit 1; \\
fi
@echo "Building WASM with game assets..."
@emcmake cmake -S "\$(ENGINE_ROOT)" -B build-wasm \\
-DMCRF_SDL2=ON \\
-DMCRF_GAME_SHELL=ON \\
-DMCRF_ASSETS_DIR="\$(GAME_DIR)/assets" \\
-DMCRF_SCRIPTS_DIR="\$(GAME_DIR)/scripts"
@emmake make -C build-wasm -j\$\$(nproc)
@echo ""
@echo "WASM build complete: build-wasm/"
@echo " make serve-wasm - test in browser"
@echo " make dist-wasm - package for distribution"
clean-wasm:
@rm -rf build-wasm
@echo "Cleaned build-wasm/"
# ============================================================
# Distribution packaging
# ============================================================
dist: dist-linux dist-windows dist-wasm
@echo ""
@echo "=== Packages ==="
@ls -lh dist/ 2>/dev/null
dist-linux:
@if [ ! -f build/mcrogueface ]; then \\
echo "Error: build/mcrogueface not found. Run mcrf-init first."; \\
exit 1; \\
fi
@echo "Packaging \$(GAME_NAME) for Linux..."
@mkdir -p dist
\$(eval PKG := dist/\$(GAME_NAME)-\$(VERSION)-Linux)
@rm -rf \$(PKG)
@mkdir -p \$(PKG)/lib
@# Binary
@cp "\$(ENGINE_ROOT)/build/mcrogueface" \$(PKG)/
@# Shared libraries
@for lib in libpython3.14.so.1.0 \\
libsfml-graphics.so.2.6.1 libsfml-window.so.2.6.1 \\
libsfml-system.so.2.6.1 libsfml-audio.so.2.6.1 \\
libtcod.so; do \\
[ -f "\$(ENGINE_ROOT)/__lib/\$\$lib" ] && \\
cp "\$(ENGINE_ROOT)/__lib/\$\$lib" \$(PKG)/lib/; \\
done
@# Library symlinks
@cd \$(PKG)/lib && \\
ln -sf libpython3.14.so.1.0 libpython3.14.so && \\
ln -sf libsfml-graphics.so.2.6.1 libsfml-graphics.so.2.6 && \\
ln -sf libsfml-graphics.so.2.6.1 libsfml-graphics.so && \\
ln -sf libsfml-window.so.2.6.1 libsfml-window.so.2.6 && \\
ln -sf libsfml-window.so.2.6.1 libsfml-window.so && \\
ln -sf libsfml-system.so.2.6.1 libsfml-system.so.2.6 && \\
ln -sf libsfml-system.so.2.6.1 libsfml-system.so && \\
ln -sf libsfml-audio.so.2.6.1 libsfml-audio.so.2.6 && \\
ln -sf libsfml-audio.so.2.6.1 libsfml-audio.so && \\
ln -sf libtcod.so libtcod.so.2
@# Python extension modules
@if [ -d "\$(ENGINE_ROOT)/__lib/Python/lib.linux-x86_64-3.14" ]; then \\
mkdir -p \$(PKG)/lib/Python/lib.linux-x86_64-3.14; \\
for so in "\$(ENGINE_ROOT)/__lib/Python/lib.linux-x86_64-3.14"/*.so; do \\
case "\$\$(basename \$\$so)" in \\
*test*|xxlimited*|_ctypes_test*|_xxtestfuzz*) continue ;; \\
*) cp "\$\$so" \$(PKG)/lib/Python/lib.linux-x86_64-3.14/ ;; \\
esac; \\
done; \\
fi
@# Python stdlib
@if [ -d "\$(ENGINE_ROOT)/__lib/Python/Lib" ]; then \\
mkdir -p \$(PKG)/lib/Python; \\
cp -r "\$(ENGINE_ROOT)/__lib/Python/Lib" \$(PKG)/lib/Python/; \\
rm -rf \$(PKG)/lib/Python/Lib/test \$(PKG)/lib/Python/Lib/tests \\
\$(PKG)/lib/Python/Lib/idlelib \$(PKG)/lib/Python/Lib/tkinter \\
\$(PKG)/lib/Python/Lib/turtledemo \$(PKG)/lib/Python/Lib/pydoc_data \\
\$(PKG)/lib/Python/Lib/lib2to3 \$(PKG)/lib/Python/Lib/ensurepip \\
\$(PKG)/lib/Python/Lib/_pyrepl; \\
find \$(PKG)/lib/Python/Lib -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true; \\
find \$(PKG)/lib/Python/Lib -name "*.pyc" -delete 2>/dev/null || true; \\
find \$(PKG)/lib/Python/Lib -name "test_*.py" -delete 2>/dev/null || true; \\
fi
@# Game content (real copies, not symlinks)
@cp -r assets \$(PKG)/assets
@cp -r scripts \$(PKG)/scripts
@# Run script
@printf '#!/bin/bash\\nDIR="\$\$(cd "\$\$(dirname "\$\$0")" && pwd)"\\nexport LD_LIBRARY_PATH="\$\$DIR/lib:\$\$LD_LIBRARY_PATH"\\nexec "\$\$DIR/mcrogueface" "\$\$@"\\n' > \$(PKG)/run.sh
@chmod +x \$(PKG)/run.sh
@# Archive
@cd dist && tar -czf "\$(GAME_NAME)-\$(VERSION)-Linux.tar.gz" "\$(GAME_NAME)-\$(VERSION)-Linux"
@rm -rf \$(PKG)
@echo "Created: dist/\$(GAME_NAME)-\$(VERSION)-Linux.tar.gz"
dist-windows:
@if [ ! -f "\$(ENGINE_ROOT)/build-windows/mcrogueface.exe" ]; then \\
echo "No Windows build available. Skipping."; \\
exit 0; \\
fi
@echo "Packaging \$(GAME_NAME) for Windows..."
@mkdir -p dist
\$(eval PKG := dist/\$(GAME_NAME)-\$(VERSION)-Windows)
@rm -rf \$(PKG)
@mkdir -p \$(PKG)
@# Executable and DLLs
@cp "\$(ENGINE_ROOT)/build-windows/mcrogueface.exe" \$(PKG)/
@for dll in "\$(ENGINE_ROOT)/build-windows/"*.dll; do \\
[ -f "\$\$dll" ] && cp "\$\$dll" \$(PKG)/; \\
done
@# Python stdlib zip
@[ -f "\$(ENGINE_ROOT)/build-windows/python314.zip" ] && \\
cp "\$(ENGINE_ROOT)/build-windows/python314.zip" \$(PKG)/ || true
@# Python lib directory
@if [ -d "\$(ENGINE_ROOT)/build-windows/lib" ]; then \\
cp -r "\$(ENGINE_ROOT)/build-windows/lib" \$(PKG)/lib; \\
fi
@# Game content (real copies)
@cp -r assets \$(PKG)/assets
@cp -r scripts \$(PKG)/scripts
@# Archive
@cd dist && zip -qr "\$(GAME_NAME)-\$(VERSION)-Windows.zip" "\$(GAME_NAME)-\$(VERSION)-Windows"
@rm -rf \$(PKG)
@echo "Created: dist/\$(GAME_NAME)-\$(VERSION)-Windows.zip"
dist-wasm:
@if ! command -v emcmake >/dev/null 2>&1; then \\
echo "WASM: emsdk not activated. Skipping WASM package."; \\
echo " To include WASM: source ~/emsdk/emsdk_env.sh && make dist-wasm"; \\
exit 0; \\
fi; \\
if [ ! -f build-wasm/mcrogueface.html ]; then \\
echo "Building WASM first..."; \\
\$(MAKE) wasm || exit 1; \\
fi; \\
echo "Packaging \$(GAME_NAME) for Web..."; \\
mkdir -p dist; \\
PKG="dist/\$(GAME_NAME)-\$(VERSION)-Web"; \\
rm -rf "\$\$PKG"; \\
mkdir -p "\$\$PKG"; \\
cp build-wasm/mcrogueface.html "\$\$PKG/index.html"; \\
cp build-wasm/mcrogueface.js "\$\$PKG/"; \\
cp build-wasm/mcrogueface.wasm "\$\$PKG/"; \\
cp build-wasm/mcrogueface.data "\$\$PKG/"; \\
cd dist && zip -qr "\$(GAME_NAME)-\$(VERSION)-Web.zip" "\$(GAME_NAME)-\$(VERSION)-Web"; \\
rm -rf "\$\$PKG"; \\
echo "Created: dist/\$(GAME_NAME)-\$(VERSION)-Web.zip"
clean-dist:
@rm -rf dist
@echo "Cleaned dist/"
# ============================================================
# Info
# ============================================================
info:
@echo "Game: \$(GAME_NAME) v\$(VERSION)"
@echo "Engine: McRogueFace v\$(ENGINE_VERSION)"
@echo "Root: \$(ENGINE_ROOT)"
@echo ""
@echo "Targets:"
@echo " make run Run the game"
@echo " make run-headless Run headless (for testing)"
@echo " make wasm Build for web (requires emsdk)"
@echo " make serve-wasm Test WASM build in browser"
@echo " make dist Package for all platforms"
@echo " make dist-linux Package for Linux only"
@echo " make dist-windows Package for Windows only"
@echo " make dist-wasm Package for web only"
@echo " make clean-dist Remove dist/"
@echo " make clean-wasm Remove build-wasm/"
MAKEFILE
# --- VERSION file ---
if [ ! -f "$GAME_DIR/VERSION" ]; then
echo "0.1.0" > "$GAME_DIR/VERSION"
fi
# --- Done ---
echo
info "Project ready!"
echo
echo -e " ${BOLD}make run${NC} - play the game"
echo -e " ${BOLD}make dist${NC} - package for distribution"
echo -e " ${BOLD}make info${NC} - show project info"
echo
echo -e " Edit ${BOLD}scripts/game.py${NC} and ${BOLD}assets/${NC} to build your game."
echo -e " Edit ${BOLD}VERSION${NC} to set your game version."

8
pyrightconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"include": ["src/scripts", "shade_sprite", "tests"],
"extraPaths": ["stubs"],
"pythonVersion": "3.14",
"pythonPlatform": "Linux",
"typeCheckingMode": "basic",
"reportMissingModuleSource": false
}

264
shade_sprite/EVALUATION.md Normal file
View 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.

View file

@ -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",
] ]

View file

@ -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
View 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)"

File diff suppressed because it is too large Load diff

494
shade_sprite/factions.py Normal file
View 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)

View file

@ -1,4 +1,4 @@
#pragma once #pragma once
// McRogueFace version string (#164) // McRogueFace version string (#164)
#define MCRFPY_VERSION "0.2.6-prerelease-7drl2026" #define MCRFPY_VERSION "0.2.7-prerelease-7drl2026"

View file

@ -18,7 +18,10 @@ PySound::PySound(std::shared_ptr<SoundBufferData> bufData)
: source("<SoundBuffer>"), loaded(false), bufferData(bufData) : source("<SoundBuffer>"), loaded(false), bufferData(bufData)
{ {
if (bufData && !bufData->samples.empty()) { if (bufData && !bufData->samples.empty()) {
buffer = bufData->getSfBuffer(); // Rebuild the sf::SoundBuffer from sample data directly
// (avoids copy-assign which is deleted on SDL2 backend)
buffer.loadFromSamples(bufData->samples.data(), bufData->samples.size(),
bufData->channels, bufData->sampleRate);
sound.setBuffer(buffer); sound.setBuffer(buffer);
loaded = true; loaded = true;
} }

View file

@ -446,6 +446,16 @@ PyObject* PySoundBuffer::bit_crush(PySoundBufferObject* self, PyObject* args) {
return PySoundBuffer_from_data(std::move(data)); return PySoundBuffer_from_data(std::move(data));
} }
PyObject* PySoundBuffer::gain(PySoundBufferObject* self, PyObject* args) {
double factor;
if (!PyArg_ParseTuple(args, "d", &factor)) return NULL;
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
auto result = AudioEffects::gain(self->data->samples, factor);
auto data = std::make_shared<SoundBufferData>(std::move(result), self->data->sampleRate, self->data->channels);
return PySoundBuffer_from_data(std::move(data));
}
PyObject* PySoundBuffer::normalize(PySoundBufferObject* self, PyObject* args) { PyObject* PySoundBuffer::normalize(PySoundBufferObject* self, PyObject* args) {
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; } if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
@ -728,6 +738,13 @@ PyMethodDef PySoundBuffer::methods[] = {
MCRF_SIG("(bits: int, rate_divisor: int)", "SoundBuffer"), MCRF_SIG("(bits: int, rate_divisor: int)", "SoundBuffer"),
MCRF_DESC("Reduce bit depth and sample rate for lo-fi effect.") MCRF_DESC("Reduce bit depth and sample rate for lo-fi effect.")
)}, )},
{"gain", (PyCFunction)PySoundBuffer::gain, METH_VARARGS,
MCRF_METHOD(SoundBuffer, gain,
MCRF_SIG("(factor: float)", "SoundBuffer"),
MCRF_DESC("Multiply all samples by a scalar factor. Use for volume/amplitude control before mixing."),
MCRF_ARGS_START
MCRF_ARG("factor", "Amplitude multiplier (0.5 = half volume, 2.0 = double). Clamps to int16 range.")
)},
{"normalize", (PyCFunction)PySoundBuffer::normalize, METH_NOARGS, {"normalize", (PyCFunction)PySoundBuffer::normalize, METH_NOARGS,
MCRF_METHOD(SoundBuffer, normalize, MCRF_METHOD(SoundBuffer, normalize,
MCRF_SIG("()", "SoundBuffer"), MCRF_SIG("()", "SoundBuffer"),

View file

@ -72,6 +72,7 @@ namespace PySoundBuffer {
PyObject* reverb(PySoundBufferObject* self, PyObject* args); PyObject* reverb(PySoundBufferObject* self, PyObject* args);
PyObject* distortion(PySoundBufferObject* self, PyObject* args); PyObject* distortion(PySoundBufferObject* self, PyObject* args);
PyObject* bit_crush(PySoundBufferObject* self, PyObject* args); PyObject* bit_crush(PySoundBufferObject* self, PyObject* args);
PyObject* gain(PySoundBufferObject* self, PyObject* args);
PyObject* normalize(PySoundBufferObject* self, PyObject* args); PyObject* normalize(PySoundBufferObject* self, PyObject* args);
PyObject* reverse(PySoundBufferObject* self, PyObject* args); PyObject* reverse(PySoundBufferObject* self, PyObject* args);
PyObject* slice(PySoundBufferObject* self, PyObject* args); PyObject* slice(PySoundBufferObject* self, PyObject* args);

View file

@ -289,6 +289,21 @@ std::vector<int16_t> normalize(const std::vector<int16_t>& samples) {
return result; return result;
} }
// ============================================================================
// Gain (multiply all samples by scalar factor)
// ============================================================================
std::vector<int16_t> gain(const std::vector<int16_t>& samples, double factor) {
if (samples.empty()) return samples;
std::vector<int16_t> result(samples.size());
for (size_t i = 0; i < samples.size(); i++) {
double s = samples[i] * factor;
result[i] = static_cast<int16_t>(std::max(-32768.0, std::min(32767.0, s)));
}
return result;
}
// ============================================================================ // ============================================================================
// Reverse (frame-aware for multichannel) // Reverse (frame-aware for multichannel)
// ============================================================================ // ============================================================================

View file

@ -32,6 +32,9 @@ std::vector<int16_t> bitCrush(const std::vector<int16_t>& samples, int bits, int
// Scale to 95% of int16 max // Scale to 95% of int16 max
std::vector<int16_t> normalize(const std::vector<int16_t>& samples); std::vector<int16_t> normalize(const std::vector<int16_t>& samples);
// Multiply all samples by a scalar factor (volume/amplitude control)
std::vector<int16_t> gain(const std::vector<int16_t>& samples, double factor);
// Reverse sample order (frame-aware for multichannel) // Reverse sample order (frame-aware for multichannel)
std::vector<int16_t> reverse(const std::vector<int16_t>& samples, unsigned int channels); std::vector<int16_t> reverse(const std::vector<int16_t>& samples, unsigned int channels);

View file

@ -218,6 +218,17 @@ std::vector<int16_t> sfxr_synthesize(const SfxrParams& p) {
for (int si2 = 0; si2 < OVERSAMPLE; si2++) { for (int si2 = 0; si2 < OVERSAMPLE; si2++) {
double sample = 0.0; double sample = 0.0;
phase++; phase++;
// Wrap phase at period boundary (critical for square/saw waveforms)
if (phase >= period) {
phase %= period;
if (p.wave_type == 3) { // Refresh noise buffer each period
for (int i = 0; i < 32; i++) {
noise_buffer[i] = ((std::rand() % 20001) / 10000.0) - 1.0;
}
}
}
double fphase = static_cast<double>(phase) / period; double fphase = static_cast<double>(phase) / period;
// Waveform generation // Waveform generation

View file

@ -459,6 +459,12 @@ public:
pixels_.resize(width * height * 4, 0); pixels_.resize(width * height * 4, 0);
} }
void create(unsigned int width, unsigned int height, const Uint8* pixels) {
size_ = Vector2u(width, height);
size_t byteCount = static_cast<size_t>(width) * height * 4;
pixels_.assign(pixels, pixels + byteCount);
}
bool loadFromFile(const std::string& filename) { return false; } bool loadFromFile(const std::string& filename) { return false; }
bool saveToFile(const std::string& filename) const { return false; } bool saveToFile(const std::string& filename) const { return false; }

View file

@ -621,6 +621,12 @@ public:
} }
} }
void create(unsigned int width, unsigned int height, const Uint8* pixels) {
size_ = Vector2u(width, height);
size_t byteCount = static_cast<size_t>(width) * height * 4;
pixels_.assign(pixels, pixels + byteCount);
}
bool loadFromFile(const std::string& filename); // Implemented in SDL2Renderer.cpp (uses stb_image) bool loadFromFile(const std::string& filename); // Implemented in SDL2Renderer.cpp (uses stb_image)
bool saveToFile(const std::string& filename) const; // Implemented in SDL2Renderer.cpp (uses stb_image_write) bool saveToFile(const std::string& filename) const; // Implemented in SDL2Renderer.cpp (uses stb_image_write)

View 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)

View file

@ -0,0 +1,62 @@
"""Test that sfxr waveforms produce sustained audio (not single-cycle pops).
Before the phase-wrap fix, square and sawtooth waveforms would only produce
one cycle of audio then become DC, resulting in very quiet output with pops.
After the fix, all waveforms should produce comparable output levels.
"""
import mcrfpy
import sys
# Generate each waveform with identical envelope params
WAVEFORMS = {0: "square", 1: "sawtooth", 2: "sine", 3: "noise"}
durations = {}
sample_counts = {}
for wt, name in WAVEFORMS.items():
buf = mcrfpy.SoundBuffer.sfxr(wave_type=wt, base_freq=0.3,
env_attack=0.0, env_sustain=0.3, env_decay=0.4)
durations[name] = buf.duration
sample_counts[name] = buf.sample_count
print(f"{name}: {buf.sample_count} samples, {buf.duration:.4f}s")
# All waveforms should produce similar duration (same envelope)
# Before fix, they all had the same envelope params so durations should match
for name, dur in durations.items():
assert dur > 0.1, f"FAIL: {name} duration too short ({dur:.4f}s)"
print(f" {name} duration OK: {dur:.4f}s")
# Test that normalize() on a middle slice doesn't massively amplify
# (If the signal is DC/near-silent, normalize would boost enormously,
# changing sample values from near-0 to near-max. With sustained waveforms,
# the signal is already substantial so normalize has less effect.)
for wt, name in [(0, "square"), (1, "sawtooth")]:
buf = mcrfpy.SoundBuffer.sfxr(wave_type=wt, base_freq=0.3,
env_attack=0.0, env_sustain=0.3, env_decay=0.4)
# Slice the sustain portion (not attack/decay edges)
mid = buf.slice(0.05, 0.15)
if mid.sample_count > 0:
# Apply pitch_shift as a transformation test - should change duration
shifted = mid.pitch_shift(2.0)
expected_count = mid.sample_count // 2
actual_count = shifted.sample_count
ratio = actual_count / max(1, expected_count)
print(f" {name} pitch_shift(2.0): {mid.sample_count} -> {shifted.sample_count} "
f"(expected ~{expected_count}, ratio={ratio:.2f})")
assert 0.8 < ratio < 1.2, f"FAIL: {name} pitch shift ratio off ({ratio:.2f})"
else:
print(f" {name} slice returned empty (skipping pitch test)")
# Generate a tone and sfxr with same waveform to compare
# The tone generator was already working, sfxr was broken
tone_sq = mcrfpy.SoundBuffer.tone(440, 0.3, "square")
sfxr_sq = mcrfpy.SoundBuffer.sfxr(wave_type=0, base_freq=0.5,
env_attack=0.0, env_sustain=0.3, env_decay=0.0)
print(f"\nComparison - tone square: {tone_sq.sample_count} samples, {tone_sq.duration:.4f}s")
print(f"Comparison - sfxr square: {sfxr_sq.sample_count} samples, {sfxr_sq.duration:.4f}s")
# Both should have substantial sample counts
assert tone_sq.sample_count > 10000, f"FAIL: tone square too short"
assert sfxr_sq.sample_count > 5000, f"FAIL: sfxr square too short"
print("\nPASS: All waveform tests passed")
sys.exit(0)