diff --git a/CMakeLists.txt b/CMakeLists.txt index dc2a63d..21f79d3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -266,6 +266,11 @@ if(MCRF_SDL2) target_compile_definitions(mcrogueface PRIVATE MCRF_SDL2) 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) if(EMSCRIPTEN) # Base Emscripten options @@ -287,9 +292,9 @@ if(EMSCRIPTEN) # Preload Python stdlib into virtual filesystem at /lib/python3.14 --preload-file=${CMAKE_SOURCE_DIR}/wasm_stdlib/lib@/lib # Preload game scripts into /scripts (use playground scripts if MCRF_PLAYGROUND is set) - --preload-file=${CMAKE_SOURCE_DIR}/src/$,scripts_playground,scripts>@/scripts + --preload-file=$,${MCRF_SCRIPTS_PLAYGROUND_DIR},${MCRF_SCRIPTS_DIR}>@/scripts # 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) --shell-file=${CMAKE_SOURCE_DIR}/src/$,shell_game.html,shell.html> # Pre-JS to fix browser zoom causing undefined values in events diff --git a/ROADMAP.md b/ROADMAP.md index 4386775..9468f3a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,223 +1,120 @@ # McRogueFace - Development Roadmap -## Project Status +**Version**: 0.2.6-prerelease | **Era**: McRogueFace (2D roguelikes) -**Current State**: Active development - C++ game engine with Python scripting -**Latest Release**: Alpha 0.1 -**Issue Tracking**: See [Gitea Issues](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues) for current tasks and bugs +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). --- -## 🎯 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++ -- **Python Close Behind**: Rich scripting without frame-rate impact -- **Game-Ready**: Each improvement should benefit actual game development +**0.2 series** (Jan-Feb 2026) -- Weekly updates to GitHub. Key additions: +- 3D/Voxel pipeline (experimental): Viewport3D, Camera3D, Entity3D, VoxelGrid with greedy meshing and serialization +- 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 - -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 +**Proving grounds**: Crypt of Sokoban (7DRL 2025) was the first complete game. 7DRL 2026 is the current target. --- -## 🏗️ Architecture Decisions +## Current Focus: 7DRL 2026 -### Three-Layer Grid Architecture -Following successful roguelike patterns (Caves of Qud, Cogmind, DCSS): +**Dates**: February 28 -- March 8, 2026 -1. **Visual Layer** (UIGridPoint) - Sprites, colors, animations -2. **World State Layer** (TCODMap) - Walkability, transparency, physics -3. **Entity Perspective Layer** (UIGridPointState) - Per-entity FOV, knowledge +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. -### Performance Architecture -Critical for large maps (1000x1000): - -- **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 +Open prep items: +- **#248** -- Crypt of Sokoban Remaster (game content for the jam) --- -## 🚀 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 ✅ -**Status**: Complete -**Key Issues**: #7 (Safe Constructors), #71 (Base Class), #87 (Visibility), #88 (Opacity) +### API Freeze Process +1. Catalog every public Python class, method, and property +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 ✅ -**Status**: Complete -**Key Features**: Pythonic API, tuple support, standardized defaults - -### Phase 3: Entity Lifecycle Management ✅ -**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. +### Post-Jam Priorities +- Fix pain points discovered during actual 7DRL game development +- Progress on the r/roguelikedev tutorial series (#167) +- API consistency audit and freeze +- Better pip/virtualenv integration for adding packages to McRogueFace's embedded interpreter --- -## 🔮 Future Vision: Pure Python Extension Architecture +## Engine Eras -### Concept: McRogueFace as a Traditional Python Package -**Status**: Long-term vision -**Complexity**: Major architectural overhaul +One engine, accumulating capabilities. Nothing is thrown away. -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`. - -### Technical Approach - -1. **Separate Core Engine from Python Embedding** - - 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. +| Era | Focus | Status | +|-----|-------|--------| +| **McRogueFace** | 2D tiles, roguelike systems, procgen | Active -- approaching 1.0 | +| **McVectorFace** | Sparse grids, vector graphics, physics | Planned | +| **McVoxelFace** | Voxel terrain, 3D gameplay | Proof-of-concept complete | --- -## 📋 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 -- **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 +**What exists**: Viewport3D, Camera3D, Entity3D, MeshLayer, Model3D (glTF), Billboard, Shader3D, VoxelGrid with greedy meshing, face culling, RLE serialization, and navigation projection. -### Performance Optimization -- **#115**: SpatialHash for 10,000+ entities -- **#116**: Dirty flag system -- **#113**: Batch operations for NumPy-style access -- **#117**: Memory pool for entities +**Known gaps**: Some Entity3D collection methods, animation stubs, shader pipeline incomplete. -### Advanced Features -- **#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 +**Maturity track**: These modules will mature on their own timeline, driven by games that need 3D. They won't block 2D stability. --- -## 📚 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) -- **Source Code**: [Gitea Repository](https://gamedev.ffwf.net/gitea/john/McRogueFace) -- **Documentation**: See `CLAUDE.md` for build instructions and development guide -- **Tutorial**: See `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).* +- **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) +- **Build Guide**: See `CLAUDE.md` for build instructions +- **Tutorial**: `roguelike_tutorial/` for implementation examples diff --git a/mcrf-init.sh b/mcrf-init.sh new file mode 100755 index 0000000..d33a7d8 --- /dev/null +++ b/mcrf-init.sh @@ -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." diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..adfcbf4 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,8 @@ +{ + "include": ["src/scripts", "shade_sprite", "tests"], + "extraPaths": ["stubs"], + "pythonVersion": "3.14", + "pythonPlatform": "Linux", + "typeCheckingMode": "basic", + "reportMissingModuleSource": false +} diff --git a/shade_sprite/EVALUATION.md b/shade_sprite/EVALUATION.md new file mode 100644 index 0000000..0ee8605 --- /dev/null +++ b/shade_sprite/EVALUATION.md @@ -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. diff --git a/shade_sprite/__init__.py b/shade_sprite/__init__.py index b3da59d..800076a 100644 --- a/shade_sprite/__init__.py +++ b/shade_sprite/__init__.py @@ -29,6 +29,14 @@ For layered characters: assembler.add_layer("clothes/BasicBlue-Body.png", hue_shift=120.0) assembler.add_layer("hair/M-Hairstyle1-Black.png") 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 ( @@ -44,12 +52,23 @@ from .formats import ( detect_format, ) 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__ = [ # Core classes "AnimatedSprite", "CharacterAssembler", + "TextureCache", # Format definitions "Direction", "AnimFrame", @@ -63,4 +82,15 @@ __all__ = [ "ALL_FORMATS", # Utilities "detect_format", + # Asset scanning + "AssetLibrary", + "LayerFile", + # Faction generation + "FactionRecipe", + "FactionGenerator", + "RoleDefinition", + "Biome", + "Element", + "Aesthetic", + "RoleType", ] diff --git a/shade_sprite/assembler.py b/shade_sprite/assembler.py index d70e4f8..5780370 100644 --- a/shade_sprite/assembler.py +++ b/shade_sprite/assembler.py @@ -3,11 +3,63 @@ Uses the engine's Texture.composite() and texture.hsl_shift() methods to build composite character textures from multiple layer PNG files, without requiring PIL or any external Python packages. + +HSL notes (from C++ investigation): + - tex.hsl_shift(h, s, l) always creates a NEW texture by copying all + pixels, converting RGB->HSL, applying shifts, converting back. + - Works on any texture: file-loaded, from_bytes, composite, or + previously shifted. Alpha is preserved; transparent pixels skipped. + - No engine-level caching exists -- repeated identical calls produce + separate texture objects. The TextureCache below avoids redundant + loads and shifts at the Python level. """ import mcrfpy from .formats import PUNY_29, SheetFormat +class TextureCache: + """Cache for loaded and HSL-shifted textures to avoid redundant disk I/O. + + Keys are (path, hue_shift, sat_shift, lit_shift) tuples. + Call clear() to free all cached textures. + """ + + def __init__(self): + self._cache = {} + + def get(self, path, tile_w, tile_h, hue=0.0, sat=0.0, lit=0.0): + """Load a texture, using cached version if available. + + Args: + path: File path to the PNG + tile_w: Sprite tile width + tile_h: Sprite tile height + hue: Hue rotation in degrees + sat: Saturation adjustment + lit: Lightness adjustment + + Returns: + mcrfpy.Texture + """ + key = (path, hue, sat, lit) + if key not in self._cache: + tex = mcrfpy.Texture(path, tile_w, tile_h) + if hue != 0.0 or sat != 0.0 or lit != 0.0: + tex = tex.hsl_shift(hue, sat, lit) + self._cache[key] = tex + return self._cache[key] + + def clear(self): + """Drop all cached textures.""" + self._cache.clear() + + def __len__(self): + return len(self._cache) + + def __contains__(self, key): + return key in self._cache + + class CharacterAssembler: """Build composite character sheets from layer files. @@ -16,13 +68,16 @@ class CharacterAssembler: Args: fmt: SheetFormat describing the sprite dimensions (default: PUNY_29) + cache: Optional TextureCache for reusing loaded textures across + multiple build() calls. If None, a private cache is created. """ - def __init__(self, fmt=None): + def __init__(self, fmt=None, cache=None): if fmt is None: fmt = PUNY_29 self.fmt = fmt self.layers = [] + self.cache = cache if cache is not None else TextureCache() def add_layer(self, path, hue_shift=0.0, sat_shift=0.0, lit_shift=0.0): """Queue a layer PNG with optional HSL recoloring. @@ -44,8 +99,8 @@ class CharacterAssembler: def build(self, name=""): """Composite all queued layers into a single Texture. - Loads each layer file, applies HSL shifts if any, then composites - all layers bottom-to-top using alpha blending. + Loads each layer file (using the cache to avoid redundant disk reads + and HSL computations), then composites all layers bottom-to-top. Args: name: Optional name for the resulting texture @@ -62,9 +117,7 @@ class CharacterAssembler: textures = [] for path, h, s, l in self.layers: - tex = mcrfpy.Texture(path, self.fmt.tile_w, self.fmt.tile_h) - if h != 0.0 or s != 0.0 or l != 0.0: - tex = tex.hsl_shift(h, s, l) + tex = self.cache.get(path, self.fmt.tile_w, self.fmt.tile_h, h, s, l) textures.append(tex) if len(textures) == 1: diff --git a/shade_sprite/assets.py b/shade_sprite/assets.py new file mode 100644 index 0000000..73eda4c --- /dev/null +++ b/shade_sprite/assets.py @@ -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)" diff --git a/shade_sprite/demo.py b/shade_sprite/demo.py index 43d3ee1..e7548f8 100644 --- a/shade_sprite/demo.py +++ b/shade_sprite/demo.py @@ -1,16 +1,16 @@ """shade_sprite interactive demo. Run from the build directory: - ./mcrogueface --exec ../shade_sprite/demo.py - -Or copy the shade_sprite directory into build/scripts/ and run: - ./mcrogueface --exec scripts/shade_sprite/demo.py + ./mcrogueface ../shade_sprite/demo.py Scenes: - 1 - Animation Viewer: cycle animations and directions - 2 - HSL Recolor: live hue/saturation/lightness shifting - 3 - Creature Gallery: grid of animated characters - 4 - Faction Generator: random faction color schemes + 1 - Animation Viewer: cycle through all animations and 8 facing directions + 2 - HSL Recolor: live hue/saturation/lightness shifting side-by-side + 3 - Character Gallery: 4x4 grid of all available character sheets + 4 - Faction Generator: random faction color schemes applied to squads + 5 - Layer Compositing: demonstrates CharacterAssembler layered texture building + 6 - Equipment Customizer: procedural + user-driven layer coloring for gear + 7 - Asset Inventory: browse discovered layer categories and files Controls shown on-screen per scene. """ @@ -22,27 +22,25 @@ import random # --------------------------------------------------------------------------- # Asset discovery # --------------------------------------------------------------------------- - -# Search paths for Puny Character sprites _SEARCH_PATHS = [ "assets/Puny-Characters", "../assets/Puny-Characters", - # 7DRL dev location os.path.expanduser( "~/Development/7DRL2026_Liber_Noster_jmccardle/" "assets_sources/Puny-Characters" ), ] + def _find_asset_dir(): for p in _SEARCH_PATHS: if os.path.isdir(p): return p return None + ASSET_DIR = _find_asset_dir() -# Character sheets available in the free CC0 pack _CHARACTER_FILES = [ "Warrior-Red.png", "Warrior-Blue.png", "Soldier-Red.png", "Soldier-Blue.png", "Soldier-Yellow.png", @@ -55,6 +53,7 @@ _CHARACTER_FILES = [ "Character-Base.png", ] + def _available_sheets(): """Return list of full paths to available character sheets.""" if not ASSET_DIR: @@ -66,79 +65,128 @@ def _available_sheets(): sheets.append(p) return sheets -# Import shade_sprite (handle being run from different locations) + +def _slime_path(): + """Return path to Slime.png if available.""" + if not ASSET_DIR: + return None + p = os.path.join(ASSET_DIR, "Slime.png") + return p if os.path.isfile(p) else None + + +def _base_path(): + """Return path to Character-Base.png if available.""" + if not ASSET_DIR: + return None + p = os.path.join(ASSET_DIR, "Character-Base.png") + return p if os.path.isfile(p) else None + + +# Import shade_sprite if __name__ == "__main__": - # Add parent dir to path so shade_sprite can be imported script_dir = os.path.dirname(os.path.abspath(__file__)) parent_dir = os.path.dirname(script_dir) if parent_dir not in sys.path: sys.path.insert(0, parent_dir) from shade_sprite import ( - AnimatedSprite, Direction, PUNY_24, PUNY_29, SLIME, - detect_format, CharacterAssembler, + AnimatedSprite, Direction, PUNY_24, SLIME, + CharacterAssembler, + AssetLibrary, FactionGenerator, ) # --------------------------------------------------------------------------- -# Globals +# Colors # --------------------------------------------------------------------------- -_animated_sprites = [] # all AnimatedSprite instances to tick -_active_scene = None +BG = mcrfpy.Color(30, 30, 40) +TITLE_COLOR = mcrfpy.Color(220, 220, 255) +LABEL_COLOR = mcrfpy.Color(180, 180, 200) +DIM_COLOR = mcrfpy.Color(120, 120, 140) +WARN_COLOR = mcrfpy.Color(255, 100, 100) +ACCENT_COLOR = mcrfpy.Color(100, 200, 255) +HIGHLIGHT_COLOR = mcrfpy.Color(255, 220, 100) + +# --------------------------------------------------------------------------- +# Global animation state +# --------------------------------------------------------------------------- +_animated_sprites = [] def _tick_all(timer, runtime): - """Global animation tick callback.""" for a in _animated_sprites: a.tick(timer.interval) +def _no_assets_fallback(scene, scene_name): + """Add 'no assets' message and basic navigation to a scene.""" + ui = scene.children + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=BG) + ui.append(bg) + title = mcrfpy.Caption(text=f"shade_sprite - {scene_name}", + pos=(20, 10), fill_color=TITLE_COLOR) + ui.append(title) + msg = mcrfpy.Caption( + text="No sprite assets found. Place Puny-Characters PNGs in assets/Puny-Characters/", + pos=(20, 60), fill_color=WARN_COLOR) + ui.append(msg) + controls = mcrfpy.Caption( + text="[1-7] Switch scenes", + pos=(20, 740), fill_color=DIM_COLOR) + ui.append(controls) + + def on_key(key, action): + if action != mcrfpy.InputState.PRESSED: + return + _handle_scene_switch(key) + scene.on_key = on_key + return scene + + +def _handle_scene_switch(key): + """Common scene switching for number keys.""" + scene_map = { + mcrfpy.Key.NUM_1: "viewer", + mcrfpy.Key.NUM_2: "hsl", + mcrfpy.Key.NUM_3: "gallery", + mcrfpy.Key.NUM_4: "factions", + mcrfpy.Key.NUM_5: "layers", + mcrfpy.Key.NUM_6: "equip", + mcrfpy.Key.NUM_7: "inventory", + } + name = scene_map.get(key) + if name: + mcrfpy.Scene(name).activate() + return True + return False + + # --------------------------------------------------------------------------- # Scene 1: Animation Viewer # --------------------------------------------------------------------------- def _build_scene_viewer(): scene = mcrfpy.Scene("viewer") - ui = scene.children - - # Background - bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), - fill_color=mcrfpy.Color(30, 30, 40)) - ui.append(bg) - - # Title - title = mcrfpy.Caption(text="shade_sprite - Animation Viewer", - pos=(20, 10), - fill_color=mcrfpy.Color(220, 220, 255)) - ui.append(title) - sheets = _available_sheets() if not sheets: - msg = mcrfpy.Caption( - text="No sprite assets found. Place Puny-Characters PNGs in assets/Puny-Characters/", - pos=(20, 60), - fill_color=mcrfpy.Color(255, 100, 100)) - ui.append(msg) - return scene + return _no_assets_fallback(scene, "Animation Viewer") - # State - state = { - "sheet_idx": 0, - "anim_idx": 0, - "dir_idx": 0, - } + ui = scene.children + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=BG) + ui.append(bg) - # Determine format - fmt = PUNY_24 # Free pack is 768x256 + title = mcrfpy.Caption(text="[1] Animation Viewer", + pos=(20, 10), fill_color=TITLE_COLOR) + ui.append(title) + fmt = PUNY_24 anim_names = list(fmt.animations.keys()) - dir_names = [d.name for d in Direction] + state = {"sheet_idx": 0, "anim_idx": 0, "dir_idx": 0} # Load first sheet tex = mcrfpy.Texture(sheets[0], fmt.tile_w, fmt.tile_h) - # Main sprite display (scaled up 4x) - sprite = mcrfpy.Sprite(texture=tex, pos=(200, 200), scale=6.0) + # Main sprite (large) + sprite = mcrfpy.Sprite(texture=tex, pos=(80, 180), scale=6.0) ui.append(sprite) - anim = AnimatedSprite(sprite, fmt, Direction.S) anim.play("idle") _animated_sprites.append(anim) @@ -146,35 +194,39 @@ def _build_scene_viewer(): # Info labels sheet_label = mcrfpy.Caption( text=f"Sheet: {os.path.basename(sheets[0])}", - pos=(20, 50), - fill_color=mcrfpy.Color(180, 180, 200)) + pos=(20, 50), fill_color=LABEL_COLOR) ui.append(sheet_label) - anim_label = mcrfpy.Caption( - text=f"Animation: idle", - pos=(20, 80), - fill_color=mcrfpy.Color(180, 180, 200)) + text="Animation: idle", pos=(20, 80), fill_color=LABEL_COLOR) ui.append(anim_label) - dir_label = mcrfpy.Caption( - text=f"Direction: S", - pos=(20, 110), - fill_color=mcrfpy.Color(180, 180, 200)) + text="Direction: S (0)", pos=(20, 110), fill_color=LABEL_COLOR) ui.append(dir_label) + frame_info = mcrfpy.Caption( + text="", pos=(20, 140), fill_color=ACCENT_COLOR) + ui.append(frame_info) - controls = mcrfpy.Caption( - text="[Q/E] Sheet [A/D] Animation [W/S] Direction [2] HSL [3] Gallery [4] Factions", - pos=(20, 740), - fill_color=mcrfpy.Color(120, 120, 140)) - ui.append(controls) + # 8 directional previews in a compass layout + compass_cx, compass_cy = 620, 350 + compass_offsets = { + Direction.N: (0, -120), + Direction.NE: (100, -85), + Direction.E: (140, 0), + Direction.SE: (100, 85), + Direction.S: (0, 120), + Direction.SW: (-100, 85), + Direction.W: (-140, 0), + Direction.NW: (-100, -85), + } - # Also show all 8 directions as small sprites dir_sprites = [] dir_anims = [] - for i, d in enumerate(Direction): - dx = 500 + (i % 4) * 80 - dy = 200 + (i // 4) * 100 - s = mcrfpy.Sprite(texture=tex, pos=(dx, dy), scale=3.0) + dir_labels = [] + for d in Direction: + ox, oy = compass_offsets[d] + x = compass_cx + ox - 16 # center 32px * 2 scale + y = compass_cy + oy - 16 + s = mcrfpy.Sprite(texture=tex, pos=(x, y), scale=2.0) ui.append(s) a = AnimatedSprite(s, fmt, d) a.play("idle") @@ -182,45 +234,51 @@ def _build_scene_viewer(): dir_sprites.append(s) dir_anims.append(a) - # Direction label - lbl = mcrfpy.Caption(text=d.name, pos=(dx + 10, dy - 18), - fill_color=mcrfpy.Color(150, 150, 170)) + lbl = mcrfpy.Caption(text=d.name, pos=(x + 5, y - 18), + fill_color=DIM_COLOR) ui.append(lbl) + dir_labels.append(lbl) - def on_key(key, action): - if action != mcrfpy.InputState.PRESSED: - return + # Compass center label + compass_title = mcrfpy.Caption(text="8-Dir Compass", + pos=(compass_cx - 50, compass_cy - 10), + fill_color=DIM_COLOR) + ui.append(compass_title) - if key == mcrfpy.Key.Q: - # Previous sheet - state["sheet_idx"] = (state["sheet_idx"] - 1) % len(sheets) - _reload_sheet() - elif key == mcrfpy.Key.E: - # Next sheet - state["sheet_idx"] = (state["sheet_idx"] + 1) % len(sheets) - _reload_sheet() - elif key == mcrfpy.Key.A: - # Previous animation - state["anim_idx"] = (state["anim_idx"] - 1) % len(anim_names) - _update_anim() - elif key == mcrfpy.Key.D: - # Next animation - state["anim_idx"] = (state["anim_idx"] + 1) % len(anim_names) - _update_anim() - elif key == mcrfpy.Key.W: - # Previous direction - state["dir_idx"] = (state["dir_idx"] - 1) % 8 - _update_dir() - elif key == mcrfpy.Key.S: - # Next direction - state["dir_idx"] = (state["dir_idx"] + 1) % 8 - _update_dir() - elif key == mcrfpy.Key.Num2: - mcrfpy.Scene("hsl").activate() - elif key == mcrfpy.Key.Num3: - mcrfpy.Scene("gallery").activate() - elif key == mcrfpy.Key.Num4: - mcrfpy.Scene("factions").activate() + # Slime demo (different format) + slime_path = _slime_path() + slime_anim = None + if slime_path: + slime_lbl = mcrfpy.Caption(text="Slime (1-dir, SLIME format):", + pos=(80, 520), fill_color=LABEL_COLOR) + ui.append(slime_lbl) + slime_tex = mcrfpy.Texture(slime_path, SLIME.tile_w, SLIME.tile_h) + slime_spr = mcrfpy.Sprite(texture=slime_tex, pos=(80, 550), scale=4.0) + ui.append(slime_spr) + slime_anim = AnimatedSprite(slime_spr, SLIME, Direction.S) + slime_anim.play("walk") + _animated_sprites.append(slime_anim) + + # Animation list reference + anim_ref_y = 520 if not slime_path else 640 + anim_ref = mcrfpy.Caption( + text="Animations: " + ", ".join(anim_names), + pos=(20, anim_ref_y), fill_color=DIM_COLOR) + ui.append(anim_ref) + + controls = mcrfpy.Caption( + text="[Q/E] Sheet [A/D] Animation [W/S] Direction [1-7] Scenes", + pos=(20, 740), fill_color=DIM_COLOR) + ui.append(controls) + + def _update_frame_info(): + a = fmt.animations[anim_names[state["anim_idx"]]] + nf = len(a.frames) + loop_str = "loop" if a.loop else "one-shot" + chain_str = f" -> {a.chain_to}" if a.chain_to else "" + frame_info.text = f"Frames: {nf} ({loop_str}{chain_str})" + + _update_frame_info() def _reload_sheet(): path = sheets[state["sheet_idx"]] @@ -237,11 +295,36 @@ def _build_scene_viewer(): for a in dir_anims: a.play(name) anim_label.text = f"Animation: {name}" + _update_frame_info() def _update_dir(): d = Direction(state["dir_idx"]) anim.direction = d - dir_label.text = f"Direction: {d.name}" + dir_label.text = f"Direction: {d.name} ({d.value})" + + def on_key(key, action): + if action != mcrfpy.InputState.PRESSED: + return + if _handle_scene_switch(key): + return + if key == mcrfpy.Key.Q: + state["sheet_idx"] = (state["sheet_idx"] - 1) % len(sheets) + _reload_sheet() + elif key == mcrfpy.Key.E: + state["sheet_idx"] = (state["sheet_idx"] + 1) % len(sheets) + _reload_sheet() + elif key == mcrfpy.Key.A: + state["anim_idx"] = (state["anim_idx"] - 1) % len(anim_names) + _update_anim() + elif key == mcrfpy.Key.D: + state["anim_idx"] = (state["anim_idx"] + 1) % len(anim_names) + _update_anim() + elif key == mcrfpy.Key.W: + state["dir_idx"] = (state["dir_idx"] - 1) % 8 + _update_dir() + elif key == mcrfpy.Key.S: + state["dir_idx"] = (state["dir_idx"] + 1) % 8 + _update_dir() scene.on_key = on_key return scene @@ -252,78 +335,88 @@ def _build_scene_viewer(): # --------------------------------------------------------------------------- def _build_scene_hsl(): scene = mcrfpy.Scene("hsl") - ui = scene.children - - bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), - fill_color=mcrfpy.Color(30, 30, 40)) - ui.append(bg) - - title = mcrfpy.Caption(text="shade_sprite - HSL Recoloring", - pos=(20, 10), - fill_color=mcrfpy.Color(220, 220, 255)) - ui.append(title) - sheets = _available_sheets() if not sheets: - msg = mcrfpy.Caption( - text="No sprite assets found.", - pos=(20, 60), - fill_color=mcrfpy.Color(255, 100, 100)) - ui.append(msg) + return _no_assets_fallback(scene, "HSL Recoloring") - def on_key(key, action): - if action == mcrfpy.InputState.PRESSED and key == mcrfpy.Key.Num1: - mcrfpy.Scene("viewer").activate() - scene.on_key = on_key - return scene + ui = scene.children + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=BG) + ui.append(bg) + + title = mcrfpy.Caption(text="[2] HSL Recoloring", + pos=(20, 10), fill_color=TITLE_COLOR) + ui.append(title) fmt = PUNY_24 + state = {"hue": 0.0, "sat": 0.0, "lit": 0.0, "sheet_idx": 0} - state = { - "hue": 0.0, - "sat": 0.0, - "lit": 0.0, - "sheet_idx": 0, - } - - # Original sprite (left) + # Original (left) orig_tex = mcrfpy.Texture(sheets[0], fmt.tile_w, fmt.tile_h) - orig_sprite = mcrfpy.Sprite(texture=orig_tex, pos=(150, 250), scale=6.0) + orig_sprite = mcrfpy.Sprite(texture=orig_tex, pos=(120, 200), scale=6.0) ui.append(orig_sprite) orig_anim = AnimatedSprite(orig_sprite, fmt, Direction.S) orig_anim.play("walk") _animated_sprites.append(orig_anim) - - orig_label = mcrfpy.Caption(text="Original", pos=(170, 220), - fill_color=mcrfpy.Color(180, 180, 200)) + orig_label = mcrfpy.Caption(text="Original", pos=(145, 170), + fill_color=LABEL_COLOR) ui.append(orig_label) - # Shifted sprite (right) - shifted_sprite = mcrfpy.Sprite(texture=orig_tex, pos=(550, 250), scale=6.0) + # Shifted (center) + shifted_sprite = mcrfpy.Sprite(texture=orig_tex, pos=(420, 200), scale=6.0) ui.append(shifted_sprite) shifted_anim = AnimatedSprite(shifted_sprite, fmt, Direction.S) shifted_anim.play("walk") _animated_sprites.append(shifted_anim) - - shifted_label = mcrfpy.Caption(text="Shifted", pos=(570, 220), - fill_color=mcrfpy.Color(180, 180, 200)) + shifted_label = mcrfpy.Caption(text="HSL Shifted", pos=(430, 170), + fill_color=LABEL_COLOR) ui.append(shifted_label) + # Hue wheel preview: show 6 hue rotations at once (right side) + wheel_label = mcrfpy.Caption(text="Hue Wheel (60-degree steps):", + pos=(700, 80), fill_color=LABEL_COLOR) + ui.append(wheel_label) + + wheel_sprites = [] + wheel_anims = [] + for i in range(6): + hue = i * 60.0 + y = 110 + i * 90 + shifted_tex = orig_tex.hsl_shift(hue) + s = mcrfpy.Sprite(texture=shifted_tex, pos=(730, y), scale=2.5) + ui.append(s) + a = AnimatedSprite(s, fmt, Direction.S) + a.play("walk") + _animated_sprites.append(a) + wheel_sprites.append(s) + wheel_anims.append(a) + lbl = mcrfpy.Caption(text=f"{hue:.0f} deg", pos=(810, y + 20), + fill_color=DIM_COLOR) + ui.append(lbl) + # HSL value displays - hue_label = mcrfpy.Caption(text="Hue: 0.0", pos=(20, 500), + hue_label = mcrfpy.Caption(text="Hue: 0", pos=(120, 440), fill_color=mcrfpy.Color(255, 180, 180)) ui.append(hue_label) - sat_label = mcrfpy.Caption(text="Sat: 0.0", pos=(20, 530), + sat_label = mcrfpy.Caption(text="Sat: 0.0", pos=(120, 470), fill_color=mcrfpy.Color(180, 255, 180)) ui.append(sat_label) - lit_label = mcrfpy.Caption(text="Lit: 0.0", pos=(20, 560), + lit_label = mcrfpy.Caption(text="Lit: 0.0", pos=(120, 500), fill_color=mcrfpy.Color(180, 180, 255)) ui.append(lit_label) + # Explanation + explain = mcrfpy.Caption( + text="Hue rotates color wheel. Sat adjusts vibrancy. Lit adjusts brightness.", + pos=(120, 540), fill_color=DIM_COLOR) + ui.append(explain) + explain2 = mcrfpy.Caption( + text="tex.hsl_shift(hue, sat, lit) returns a NEW texture (original unchanged)", + pos=(120, 565), fill_color=DIM_COLOR) + ui.append(explain2) + controls = mcrfpy.Caption( - text="[Left/Right] Hue +/-30 [Up/Down] Sat +/-0.1 [Z/X] Lit +/-0.1 [Q/E] Sheet [1] Viewer", - pos=(20, 740), - fill_color=mcrfpy.Color(120, 120, 140)) + text="[Left/Right] Hue +/-30 [Up/Down] Sat +/-0.1 [Z/X] Lit +/-0.1 [Q/E] Sheet [1-7] Scenes", + pos=(20, 740), fill_color=DIM_COLOR) ui.append(controls) def _rebuild_shifted(): @@ -335,10 +428,21 @@ def _build_scene_hsl(): sat_label.text = f"Sat: {state['sat']:.1f}" lit_label.text = f"Lit: {state['lit']:.1f}" + def _reload(): + path = sheets[state["sheet_idx"]] + new_tex = mcrfpy.Texture(path, fmt.tile_w, fmt.tile_h) + orig_sprite.texture = new_tex + # Update hue wheel with new base + for i, s in enumerate(wheel_sprites): + hue = i * 60.0 + s.texture = new_tex.hsl_shift(hue) + _rebuild_shifted() + def on_key(key, action): if action != mcrfpy.InputState.PRESSED: return - + if _handle_scene_switch(key): + return changed = False if key == mcrfpy.Key.LEFT: state["hue"] = (state["hue"] - 30.0) % 360.0 @@ -364,27 +468,10 @@ def _build_scene_hsl(): elif key == mcrfpy.Key.E: state["sheet_idx"] = (state["sheet_idx"] + 1) % len(sheets) _reload() - elif key == mcrfpy.Key.Num1: - mcrfpy.Scene("viewer").activate() - return - elif key == mcrfpy.Key.Num3: - mcrfpy.Scene("gallery").activate() - return - elif key == mcrfpy.Key.Num4: - mcrfpy.Scene("factions").activate() - return - if changed: _rebuild_shifted() - def _reload(): - path = sheets[state["sheet_idx"]] - new_tex = mcrfpy.Texture(path, fmt.tile_w, fmt.tile_h) - orig_sprite.texture = new_tex - _rebuild_shifted() - scene.on_key = on_key - _rebuild_shifted() return scene @@ -393,43 +480,30 @@ def _build_scene_hsl(): # --------------------------------------------------------------------------- def _build_scene_gallery(): scene = mcrfpy.Scene("gallery") - ui = scene.children - - bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), - fill_color=mcrfpy.Color(30, 30, 40)) - ui.append(bg) - - title = mcrfpy.Caption(text="shade_sprite - Character Gallery", - pos=(20, 10), - fill_color=mcrfpy.Color(220, 220, 255)) - ui.append(title) - sheets = _available_sheets() if not sheets: - msg = mcrfpy.Caption( - text="No sprite assets found.", - pos=(20, 60), - fill_color=mcrfpy.Color(255, 100, 100)) - ui.append(msg) + return _no_assets_fallback(scene, "Character Gallery") - def on_key(key, action): - if action == mcrfpy.InputState.PRESSED and key == mcrfpy.Key.Num1: - mcrfpy.Scene("viewer").activate() - scene.on_key = on_key - return scene + ui = scene.children + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=BG) + ui.append(bg) + + title = mcrfpy.Caption(text="[3] Character Gallery", + pos=(20, 10), fill_color=TITLE_COLOR) + ui.append(title) fmt = PUNY_24 - directions = [Direction.S, Direction.SW, Direction.W, Direction.NW, - Direction.N, Direction.NE, Direction.E, Direction.SE] + anim_names = list(fmt.animations.keys()) + state = {"dir_idx": 0, "anim_idx": 1} # start with walk - # Layout: grid of characters, 4 columns - cols = 4 - x_start, y_start = 40, 60 - x_spacing, y_spacing = 240, 130 - scale = 3.0 + # 5-column grid + cols = 5 + x_start, y_start = 30, 60 + x_spacing, y_spacing = 195, 130 + scale = 2.5 gallery_anims = [] - count = min(len(sheets), 16) # max 4x4 grid + count = min(len(sheets), 20) for i in range(count): col = i % cols @@ -438,8 +512,7 @@ def _build_scene_gallery(): y = y_start + row * y_spacing tex = mcrfpy.Texture(sheets[i], fmt.tile_w, fmt.tile_h) - sprite = mcrfpy.Sprite(texture=tex, pos=(x + 20, y + 20), - scale=scale) + sprite = mcrfpy.Sprite(texture=tex, pos=(x + 30, y + 25), scale=scale) ui.append(sprite) a = AnimatedSprite(sprite, fmt, Direction.S) @@ -448,48 +521,65 @@ def _build_scene_gallery(): gallery_anims.append(a) name = os.path.basename(sheets[i]).replace(".png", "") - lbl = mcrfpy.Caption(text=name, pos=(x, y), - fill_color=mcrfpy.Color(150, 150, 170)) + lbl = mcrfpy.Caption(text=name, pos=(x, y + 5), + fill_color=DIM_COLOR) ui.append(lbl) - state = {"dir_idx": 0, "anim_idx": 1} # start with walk - anim_names = list(fmt.animations.keys()) + # Slime in gallery too + slime_p = _slime_path() + slime_anim_ref = None + if slime_p: + row = count // cols + col = count % cols + x = x_start + col * x_spacing + y = y_start + row * y_spacing + stex = mcrfpy.Texture(slime_p, SLIME.tile_w, SLIME.tile_h) + sspr = mcrfpy.Sprite(texture=stex, pos=(x + 30, y + 25), scale=scale) + ui.append(sspr) + slime_anim_ref = AnimatedSprite(sspr, SLIME, Direction.S) + slime_anim_ref.play("walk") + _animated_sprites.append(slime_anim_ref) + lbl = mcrfpy.Caption(text="Slime", pos=(x, y + 5), fill_color=DIM_COLOR) + ui.append(lbl) + + dir_info = mcrfpy.Caption(text="Direction: S Animation: walk", + pos=(20, 700), fill_color=LABEL_COLOR) + ui.append(dir_info) controls = mcrfpy.Caption( - text="[W/S] Direction [A/D] Animation [1] Viewer [2] HSL [4] Factions", - pos=(20, 740), - fill_color=mcrfpy.Color(120, 120, 140)) + text="[W/S] Direction [A/D] Animation [1-7] Scenes", + pos=(20, 740), fill_color=DIM_COLOR) ui.append(controls) def on_key(key, action): if action != mcrfpy.InputState.PRESSED: return + if _handle_scene_switch(key): + return if key == mcrfpy.Key.W: state["dir_idx"] = (state["dir_idx"] - 1) % 8 d = Direction(state["dir_idx"]) for a in gallery_anims: a.direction = d + dir_info.text = f"Direction: {d.name} Animation: {anim_names[state['anim_idx']]}" elif key == mcrfpy.Key.S: state["dir_idx"] = (state["dir_idx"] + 1) % 8 d = Direction(state["dir_idx"]) for a in gallery_anims: a.direction = d + dir_info.text = f"Direction: {d.name} Animation: {anim_names[state['anim_idx']]}" elif key == mcrfpy.Key.A: state["anim_idx"] = (state["anim_idx"] - 1) % len(anim_names) name = anim_names[state["anim_idx"]] for a in gallery_anims: a.play(name) + dir_info.text = f"Direction: {Direction(state['dir_idx']).name} Animation: {name}" elif key == mcrfpy.Key.D: state["anim_idx"] = (state["anim_idx"] + 1) % len(anim_names) name = anim_names[state["anim_idx"]] for a in gallery_anims: a.play(name) - elif key == mcrfpy.Key.Num1: - mcrfpy.Scene("viewer").activate() - elif key == mcrfpy.Key.Num2: - mcrfpy.Scene("hsl").activate() - elif key == mcrfpy.Key.Num4: - mcrfpy.Scene("factions").activate() + dir_info.text = f"Direction: {Direction(state['dir_idx']).name} Animation: {name}" scene.on_key = on_key return scene @@ -498,136 +588,734 @@ def _build_scene_gallery(): # --------------------------------------------------------------------------- # Scene 4: Faction Generator # --------------------------------------------------------------------------- +_FACTION_NAMES = [ + "Iron Guard", "Shadow Pact", "Dawn Order", "Ember Clan", + "Frost Legion", "Vine Court", "Storm Band", "Ash Wardens", + "Gold Company", "Crimson Oath", "Azure Fleet", "Jade Circle", + "Silver Hand", "Night Watch", "Sun Speakers", "Bone Reavers", +] + + def _build_scene_factions(): scene = mcrfpy.Scene("factions") - ui = scene.children - - bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), - fill_color=mcrfpy.Color(30, 30, 40)) - ui.append(bg) - - title = mcrfpy.Caption(text="shade_sprite - Faction Generator", - pos=(20, 10), - fill_color=mcrfpy.Color(220, 220, 255)) - ui.append(title) - sheets = _available_sheets() if not sheets: - msg = mcrfpy.Caption( - text="No sprite assets found.", - pos=(20, 60), - fill_color=mcrfpy.Color(255, 100, 100)) - ui.append(msg) + return _no_assets_fallback(scene, "Faction Generator") - def on_key(key, action): - if action == mcrfpy.InputState.PRESSED and key == mcrfpy.Key.Num1: - mcrfpy.Scene("viewer").activate() - scene.on_key = on_key - return scene + ui = scene.children + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=BG) + ui.append(bg) + + title = mcrfpy.Caption(text="[4] Faction Generator", + pos=(20, 10), fill_color=TITLE_COLOR) + ui.append(title) fmt = PUNY_24 scale = 3.0 + faction_anims = [] - # State for generated factions - faction_anims = [] # store references for animation ticking - faction_sprites = [] # sprites to update on re-roll - faction_labels = [] - - # Faction colors (HSL hue values) - faction_hues = [0, 60, 120, 180, 240, 300] - faction_names_pool = [ - "Iron Guard", "Shadow Pact", "Dawn Order", "Ember Clan", - "Frost Legion", "Vine Court", "Storm Band", "Ash Wardens", - "Gold Company", "Crimson Oath", "Azure Fleet", "Jade Circle", - ] - - def _generate_factions(): - # Clear old faction animations from global list + def _populate(): + """Generate 4 random factions with hue-shifted squads.""" + # Remove old faction anims from global list for a in faction_anims: if a in _animated_sprites: _animated_sprites.remove(a) faction_anims.clear() - # Pick 4 factions with random hues and characters - hues = random.sample(faction_hues, min(4, len(faction_hues))) - names = random.sample(faction_names_pool, 4) - - # We'll create sprites dynamically - # Clear old sprites (rebuild scene content below bg/title/controls) - while len(ui) > 3: # keep bg, title, controls - # Can't easily remove from UICollection, so we rebuild the scene - pass - # Actually, just position everything and update textures - return hues, names - - def _build_faction_display(): - for a in faction_anims: - if a in _animated_sprites: - _animated_sprites.remove(a) - faction_anims.clear() - faction_sprites.clear() - faction_labels.clear() - hues = [random.uniform(0, 360) for _ in range(4)] - names = random.sample(faction_names_pool, 4) + names = random.sample(_FACTION_NAMES, 4) - y_start = 80 + y_start = 70 for fi in range(4): - y = y_start + fi * 160 + y = y_start + fi * 165 hue = hues[fi] - # Faction name + # Faction header with colored indicator lbl = mcrfpy.Caption( - text=f"{names[fi]} (hue {hue:.0f})", - pos=(20, y), - fill_color=mcrfpy.Color(200, 200, 220)) + text=f"{names[fi]} (hue {hue:.0f})", + pos=(20, y), fill_color=HIGHLIGHT_COLOR) ui.append(lbl) - faction_labels.append(lbl) - # Pick 4 random character sheets for this faction - chosen = random.sample(sheets, min(4, len(sheets))) + # Pick 5 random characters for this faction + chosen = random.sample(sheets, min(5, len(sheets))) for ci, path in enumerate(chosen): - x = 40 + ci * 200 - - # Apply faction hue shift + x = 30 + ci * 180 base_tex = mcrfpy.Texture(path, fmt.tile_w, fmt.tile_h) shifted_tex = base_tex.hsl_shift(hue) - s = mcrfpy.Sprite(texture=shifted_tex, pos=(x, y + 30), scale=scale) ui.append(s) - faction_sprites.append(s) - a = AnimatedSprite(s, fmt, Direction.S) a.play("walk") _animated_sprites.append(a) faction_anims.append(a) - controls = mcrfpy.Caption( - text="[Space] Re-roll factions [1] Viewer [2] HSL [3] Gallery", - pos=(20, 740), - fill_color=mcrfpy.Color(120, 120, 140)) - ui.append(controls) + # Character name below + cname = os.path.basename(path).replace(".png", "") + nlbl = mcrfpy.Caption(text=cname, pos=(x, y + 130), + fill_color=DIM_COLOR) + ui.append(nlbl) - _build_faction_display() + _populate() + + controls = mcrfpy.Caption( + text="[Space] Re-roll factions [1-7] Scenes", + pos=(20, 740), fill_color=DIM_COLOR) + ui.append(controls) def on_key(key, action): if action != mcrfpy.InputState.PRESSED: return + if _handle_scene_switch(key): + return if key == mcrfpy.Key.SPACE: - # Rebuild scene - _rebuild_factions_scene() - elif key == mcrfpy.Key.Num1: - mcrfpy.Scene("viewer").activate() - elif key == mcrfpy.Key.Num2: - mcrfpy.Scene("hsl").activate() - elif key == mcrfpy.Key.Num3: - mcrfpy.Scene("gallery").activate() + # Rebuild scene from scratch + new_scene = _build_scene_factions() + new_scene.activate() - def _rebuild_factions_scene(): - # Easiest: rebuild the whole scene - new_scene = _build_scene_factions() - new_scene.activate() + scene.on_key = on_key + return scene + + +# --------------------------------------------------------------------------- +# Scene 5: Layer Compositing +# --------------------------------------------------------------------------- +def _build_scene_layers(): + scene = mcrfpy.Scene("layers") + sheets = _available_sheets() + base_p = _base_path() + if not sheets or not base_p: + return _no_assets_fallback(scene, "Layer Compositing") + + ui = scene.children + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=BG) + ui.append(bg) + + title = mcrfpy.Caption(text="[5] Layer Compositing (CharacterAssembler)", + pos=(20, 10), fill_color=TITLE_COLOR) + ui.append(title) + + fmt = PUNY_24 + scale = 5.0 + + # Explanation + explain = mcrfpy.Caption( + text="CharacterAssembler composites multiple texture layers with HSL shifts.", + pos=(20, 45), fill_color=LABEL_COLOR) + ui.append(explain) + explain2 = mcrfpy.Caption( + text="Base layer (skin) + overlay (equipment) with color variation = unique characters.", + pos=(20, 70), fill_color=LABEL_COLOR) + ui.append(explain2) + + # Find sheets that aren't Character-Base for overlay + overlay_sheets = [s for s in sheets + if "Character-Base" not in os.path.basename(s)] + + # --- Column 1: Show the base layer alone --- + col1_x = 30 + base_lbl = mcrfpy.Caption(text="Base Layer", pos=(col1_x, 110), + fill_color=ACCENT_COLOR) + ui.append(base_lbl) + + base_tex = mcrfpy.Texture(base_p, fmt.tile_w, fmt.tile_h) + base_spr = mcrfpy.Sprite(texture=base_tex, pos=(col1_x + 10, 140), scale=scale) + ui.append(base_spr) + base_anim = AnimatedSprite(base_spr, fmt, Direction.S) + base_anim.play("walk") + _animated_sprites.append(base_anim) + base_note = mcrfpy.Caption(text="Character-Base.png", pos=(col1_x, 310), + fill_color=DIM_COLOR) + ui.append(base_note) + + # --- Column 2: Show an overlay alone --- + col2_x = 250 + overlay_lbl = mcrfpy.Caption(text="Overlay Layer", pos=(col2_x, 110), + fill_color=ACCENT_COLOR) + ui.append(overlay_lbl) + + state = {"overlay_idx": 0, "hue": 0.0} + overlay_tex = mcrfpy.Texture(overlay_sheets[0], fmt.tile_w, fmt.tile_h) + overlay_spr = mcrfpy.Sprite(texture=overlay_tex, pos=(col2_x + 10, 140), + scale=scale) + ui.append(overlay_spr) + overlay_anim = AnimatedSprite(overlay_spr, fmt, Direction.S) + overlay_anim.play("walk") + _animated_sprites.append(overlay_anim) + overlay_name_lbl = mcrfpy.Caption( + text=os.path.basename(overlay_sheets[0]), + pos=(col2_x, 310), fill_color=DIM_COLOR) + ui.append(overlay_name_lbl) + + # --- Column 3: Composite result --- + col3_x = 470 + comp_lbl = mcrfpy.Caption(text="Composite Result", pos=(col3_x, 110), + fill_color=ACCENT_COLOR) + ui.append(comp_lbl) + + # Build initial composite + assembler = CharacterAssembler(fmt) + assembler.add_layer(base_p) + assembler.add_layer(overlay_sheets[0]) + comp_tex = assembler.build("demo_composite") + + comp_spr = mcrfpy.Sprite(texture=comp_tex, pos=(col3_x + 10, 140), + scale=scale) + ui.append(comp_spr) + comp_anim = AnimatedSprite(comp_spr, fmt, Direction.S) + comp_anim.play("walk") + _animated_sprites.append(comp_anim) + + comp_note = mcrfpy.Caption(text="Base + Overlay composited", + pos=(col3_x, 310), fill_color=DIM_COLOR) + ui.append(comp_note) + + # --- Column 4: Composite with hue shift --- + col4_x = 690 + shifted_lbl = mcrfpy.Caption(text="Shifted Composite", pos=(col4_x, 110), + fill_color=ACCENT_COLOR) + ui.append(shifted_lbl) + + assembler2 = CharacterAssembler(fmt) + assembler2.add_layer(base_p) + assembler2.add_layer(overlay_sheets[0], hue_shift=120.0) + shifted_comp_tex = assembler2.build("demo_shifted") + + shifted_comp_spr = mcrfpy.Sprite(texture=shifted_comp_tex, + pos=(col4_x + 10, 140), scale=scale) + ui.append(shifted_comp_spr) + shifted_comp_anim = AnimatedSprite(shifted_comp_spr, fmt, Direction.S) + shifted_comp_anim.play("walk") + _animated_sprites.append(shifted_comp_anim) + + hue_note = mcrfpy.Caption(text=f"Overlay hue: {state['hue']:.0f}", + pos=(col4_x, 310), fill_color=DIM_COLOR) + ui.append(hue_note) + + # --- Row 2: Show multiple hue-shifted composites --- + row2_y = 370 + row2_lbl = mcrfpy.Caption( + text="Same base + overlay, 6 hue rotations (60-degree increments):", + pos=(30, row2_y), fill_color=LABEL_COLOR) + ui.append(row2_lbl) + + row2_anims = [] + for i in range(6): + hue = i * 60.0 + x = 30 + i * 160 + y = row2_y + 30 + + asm = CharacterAssembler(fmt) + asm.add_layer(base_p) + asm.add_layer(overlay_sheets[0], hue_shift=hue) + tex = asm.build(f"row2_{i}") + + s = mcrfpy.Sprite(texture=tex, pos=(x + 20, y), scale=3.0) + ui.append(s) + a = AnimatedSprite(s, fmt, Direction.S) + a.play("walk") + _animated_sprites.append(a) + row2_anims.append((s, a)) + + lbl = mcrfpy.Caption(text=f"hue={hue:.0f}", pos=(x + 10, y + 100), + fill_color=DIM_COLOR) + ui.append(lbl) + + # Code example + code_lbl = mcrfpy.Caption( + text='asm = CharacterAssembler(PUNY_24)', + pos=(30, 600), fill_color=mcrfpy.Color(150, 200, 150)) + ui.append(code_lbl) + code_lbl2 = mcrfpy.Caption( + text='asm.add_layer("Character-Base.png")', + pos=(30, 625), fill_color=mcrfpy.Color(150, 200, 150)) + ui.append(code_lbl2) + code_lbl3 = mcrfpy.Caption( + text='asm.add_layer("Warrior-Red.png", hue_shift=120.0)', + pos=(30, 650), fill_color=mcrfpy.Color(150, 200, 150)) + ui.append(code_lbl3) + code_lbl4 = mcrfpy.Caption( + text='texture = asm.build("my_character")', + pos=(30, 675), fill_color=mcrfpy.Color(150, 200, 150)) + ui.append(code_lbl4) + + controls = mcrfpy.Caption( + text="[Q/E] Overlay sheet [Left/Right] Overlay hue +/-30 [1-7] Scenes", + pos=(20, 740), fill_color=DIM_COLOR) + ui.append(controls) + + def _rebuild(): + path = overlay_sheets[state["overlay_idx"]] + hue = state["hue"] + + # Update overlay preview + new_overlay_tex = mcrfpy.Texture(path, fmt.tile_w, fmt.tile_h) + overlay_spr.texture = new_overlay_tex + overlay_name_lbl.text = os.path.basename(path) + + # Rebuild unshifted composite + asm = CharacterAssembler(fmt) + asm.add_layer(base_p) + asm.add_layer(path) + comp_spr.texture = asm.build("demo_composite") + + # Rebuild shifted composite + asm2 = CharacterAssembler(fmt) + asm2.add_layer(base_p) + asm2.add_layer(path, hue_shift=hue) + shifted_comp_spr.texture = asm2.build("demo_shifted") + hue_note.text = f"Overlay hue: {hue:.0f}" + + # Rebuild row2 + for i, (s, a) in enumerate(row2_anims): + h = i * 60.0 + asm3 = CharacterAssembler(fmt) + asm3.add_layer(base_p) + asm3.add_layer(path, hue_shift=h) + s.texture = asm3.build(f"row2_{i}") + + def on_key(key, action): + if action != mcrfpy.InputState.PRESSED: + return + if _handle_scene_switch(key): + return + if key == mcrfpy.Key.Q: + state["overlay_idx"] = (state["overlay_idx"] - 1) % len(overlay_sheets) + _rebuild() + elif key == mcrfpy.Key.E: + state["overlay_idx"] = (state["overlay_idx"] + 1) % len(overlay_sheets) + _rebuild() + elif key == mcrfpy.Key.LEFT: + state["hue"] = (state["hue"] - 30.0) % 360.0 + _rebuild() + elif key == mcrfpy.Key.RIGHT: + state["hue"] = (state["hue"] + 30.0) % 360.0 + _rebuild() + + scene.on_key = on_key + return scene + + +# --------------------------------------------------------------------------- +# Scene 6: Equipment Customizer +# --------------------------------------------------------------------------- +def _build_scene_equip(): + scene = mcrfpy.Scene("equip") + sheets = _available_sheets() + base_p = _base_path() + if not sheets or not base_p: + return _no_assets_fallback(scene, "Equipment Customizer") + + ui = scene.children + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=BG) + ui.append(bg) + + title = mcrfpy.Caption(text="[6] Equipment Customizer", + pos=(20, 10), fill_color=TITLE_COLOR) + ui.append(title) + + fmt = PUNY_24 + overlay_sheets = [s for s in sheets + if "Character-Base" not in os.path.basename(s)] + + # Three independent equipment "slots" - each selects overlay + hue + # Simulates: Body armor, Weapon style, Trim/accent + slot_names = ["Body Armor", "Weapon Style", "Accent Layer"] + slot_defaults = [ + {"sheet_idx": 0, "hue": 0.0, "sat": 0.0, "lit": 0.0, "enabled": True}, + {"sheet_idx": min(2, len(overlay_sheets) - 1), "hue": 120.0, + "sat": 0.0, "lit": 0.0, "enabled": True}, + {"sheet_idx": min(4, len(overlay_sheets) - 1), "hue": 240.0, + "sat": 0.0, "lit": -0.3, "enabled": False}, + ] + slots = [dict(d) for d in slot_defaults] + state = {"active_slot": 0, "dir_idx": 0} + + # Main character preview (large) + preview_spr = mcrfpy.Sprite(pos=(400, 150), scale=8.0) + ui.append(preview_spr) + preview_anim = AnimatedSprite(preview_spr, fmt, Direction.S) + preview_anim.play("walk") + _animated_sprites.append(preview_anim) + + # Direction label + dir_lbl = mcrfpy.Caption(text="Direction: S", pos=(400, 420), + fill_color=LABEL_COLOR) + ui.append(dir_lbl) + + # Slot panels (left side) + slot_labels = [] + slot_info_labels = [] + slot_indicators = [] + + for i, sname in enumerate(slot_names): + y = 80 + i * 180 + + # Slot header + indicator = mcrfpy.Caption( + text=f">>> {sname} <<<" if i == 0 else f" {sname}", + pos=(20, y), + fill_color=HIGHLIGHT_COLOR if i == 0 else LABEL_COLOR) + ui.append(indicator) + slot_indicators.append(indicator) + + # Status + slot = slots[i] + enabled_str = "ON" if slot["enabled"] else "OFF" + sheet_name = os.path.basename(overlay_sheets[slot["sheet_idx"]]).replace(".png", "") + info = mcrfpy.Caption( + text=f"[{enabled_str}] {sheet_name} H:{slot['hue']:.0f} S:{slot['sat']:.1f} L:{slot['lit']:.1f}", + pos=(20, y + 30), + fill_color=ACCENT_COLOR if slot["enabled"] else mcrfpy.Color(80, 80, 100)) + ui.append(info) + slot_info_labels.append(info) + + # Small preview for this slot + slot_tex = mcrfpy.Texture(overlay_sheets[slot["sheet_idx"]], + fmt.tile_w, fmt.tile_h) + if slot["hue"] != 0.0 or slot["sat"] != 0.0 or slot["lit"] != 0.0: + slot_tex = slot_tex.hsl_shift(slot["hue"], slot["sat"], slot["lit"]) + slot_spr = mcrfpy.Sprite(texture=slot_tex, pos=(20, y + 55), scale=3.0) + ui.append(slot_spr) + slot_labels.append(slot_spr) + + # Row of procedurally generated variants at bottom + row_y = 550 + row_lbl = mcrfpy.Caption( + text="Procedural Variants (randomized per slot):", + pos=(20, row_y), fill_color=LABEL_COLOR) + ui.append(row_lbl) + + variant_sprites = [] + variant_anims = [] + for i in range(6): + x = 30 + i * 155 + s = mcrfpy.Sprite(pos=(x + 20, row_y + 30), scale=3.0) + ui.append(s) + a = AnimatedSprite(s, fmt, Direction.S) + a.play("walk") + _animated_sprites.append(a) + variant_sprites.append(s) + variant_anims.append(a) + + def _build_composite(): + """Build composite texture from current slot settings.""" + asm = CharacterAssembler(fmt) + asm.add_layer(base_p) + for slot in slots: + if slot["enabled"]: + path = overlay_sheets[slot["sheet_idx"]] + asm.add_layer(path, hue_shift=slot["hue"], + sat_shift=slot["sat"], lit_shift=slot["lit"]) + return asm.build("equip_preview") + + def _update_preview(): + """Rebuild main preview and slot info.""" + tex = _build_composite() + preview_spr.texture = tex + + for i, slot in enumerate(slots): + enabled_str = "ON" if slot["enabled"] else "OFF" + sheet_name = os.path.basename( + overlay_sheets[slot["sheet_idx"]]).replace(".png", "") + slot_info_labels[i].text = ( + f"[{enabled_str}] {sheet_name} " + f"H:{slot['hue']:.0f} S:{slot['sat']:.1f} L:{slot['lit']:.1f}") + if slot["enabled"]: + slot_info_labels[i].fill_color = ACCENT_COLOR + else: + slot_info_labels[i].fill_color = mcrfpy.Color(80, 80, 100) + + # Update slot preview sprite + stex = mcrfpy.Texture(overlay_sheets[slot["sheet_idx"]], + fmt.tile_w, fmt.tile_h) + if slot["hue"] != 0.0 or slot["sat"] != 0.0 or slot["lit"] != 0.0: + stex = stex.hsl_shift(slot["hue"], slot["sat"], slot["lit"]) + slot_labels[i].texture = stex + + # Update slot indicators + for i, ind in enumerate(slot_indicators): + sname = slot_names[i] + if i == state["active_slot"]: + ind.text = f">>> {sname} <<<" + ind.fill_color = HIGHLIGHT_COLOR + else: + ind.text = f" {sname}" + ind.fill_color = LABEL_COLOR + + def _generate_variants(): + """Create 6 random procedural variants.""" + for i in range(6): + asm = CharacterAssembler(fmt) + asm.add_layer(base_p) + # Each variant gets 1-2 random layers with random hues + n_layers = random.randint(1, 2) + for _ in range(n_layers): + path = random.choice(overlay_sheets) + hue = random.uniform(0, 360) + sat = random.uniform(-0.3, 0.3) + lit = random.uniform(-0.2, 0.1) + asm.add_layer(path, hue_shift=hue, sat_shift=sat, lit_shift=lit) + variant_sprites[i].texture = asm.build(f"variant_{i}") + + _update_preview() + _generate_variants() + + controls = mcrfpy.Caption( + text="[Tab] Slot [Q/E] Sheet [Left/Right] Hue [Up/Down] Sat [Z/X] Lit [T] Toggle [R] Randomize [1-7] Scenes", + pos=(20, 740), fill_color=DIM_COLOR) + ui.append(controls) + + def on_key(key, action): + if action != mcrfpy.InputState.PRESSED: + return + if _handle_scene_switch(key): + return + + slot = slots[state["active_slot"]] + + if key == mcrfpy.Key.TAB: + state["active_slot"] = (state["active_slot"] + 1) % len(slots) + _update_preview() + elif key == mcrfpy.Key.T: + slot["enabled"] = not slot["enabled"] + _update_preview() + elif key == mcrfpy.Key.Q: + slot["sheet_idx"] = (slot["sheet_idx"] - 1) % len(overlay_sheets) + _update_preview() + elif key == mcrfpy.Key.E: + slot["sheet_idx"] = (slot["sheet_idx"] + 1) % len(overlay_sheets) + _update_preview() + elif key == mcrfpy.Key.LEFT: + slot["hue"] = (slot["hue"] - 30.0) % 360.0 + _update_preview() + elif key == mcrfpy.Key.RIGHT: + slot["hue"] = (slot["hue"] + 30.0) % 360.0 + _update_preview() + elif key == mcrfpy.Key.UP: + slot["sat"] = min(1.0, slot["sat"] + 0.1) + _update_preview() + elif key == mcrfpy.Key.DOWN: + slot["sat"] = max(-1.0, slot["sat"] - 0.1) + _update_preview() + elif key == mcrfpy.Key.Z: + slot["lit"] = max(-1.0, slot["lit"] - 0.1) + _update_preview() + elif key == mcrfpy.Key.X: + slot["lit"] = min(1.0, slot["lit"] + 0.1) + _update_preview() + elif key == mcrfpy.Key.R: + _generate_variants() + elif key == mcrfpy.Key.W: + state["dir_idx"] = (state["dir_idx"] - 1) % 8 + d = Direction(state["dir_idx"]) + preview_anim.direction = d + for a in variant_anims: + a.direction = d + dir_lbl.text = f"Direction: {d.name}" + elif key == mcrfpy.Key.S: + state["dir_idx"] = (state["dir_idx"] + 1) % 8 + d = Direction(state["dir_idx"]) + preview_anim.direction = d + for a in variant_anims: + a.direction = d + dir_lbl.text = f"Direction: {d.name}" + + scene.on_key = on_key + return scene + + +# --------------------------------------------------------------------------- +# Scene 7: Asset Inventory Browser +# --------------------------------------------------------------------------- +def _build_scene_inventory(): + scene = mcrfpy.Scene("inventory") + ui = scene.children + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=BG) + ui.append(bg) + + title = mcrfpy.Caption(text="[7] Asset Inventory", + pos=(20, 10), fill_color=TITLE_COLOR) + ui.append(title) + + lib = AssetLibrary() + if not lib.available: + msg = mcrfpy.Caption( + text="No paid Puny Characters v2.1 pack found.", + pos=(20, 60), fill_color=WARN_COLOR) + ui.append(msg) + msg2 = mcrfpy.Caption( + text="The AssetLibrary scans the 'Individual Spritesheets' directory.", + pos=(20, 90), fill_color=DIM_COLOR) + ui.append(msg2) + controls = mcrfpy.Caption(text="[1-7] Switch scenes", + pos=(20, 740), fill_color=DIM_COLOR) + ui.append(controls) + + def on_key(key, action): + if action != mcrfpy.InputState.PRESSED: + return + _handle_scene_switch(key) + scene.on_key = on_key + return scene + + # Build category data + categories = lib.categories + cat_data = [] # list of (key, label, count, subcats_with_counts) + for cat_key in categories: + files = lib.layers(cat_key) + subcats = lib.subcategories(cat_key) + sub_info = [] + for sc in subcats: + sc_files = lib.layers_in(cat_key, sc) + label = sc if sc else "(root)" + sub_info.append((label, len(sc_files), [f.name for f in sc_files])) + display_name = cat_key.replace("_", " ").title() + cat_data.append((cat_key, display_name, len(files), sub_info)) + + state = {"cat_idx": 0, "sub_idx": 0, "scroll": 0} + MAX_VISIBLE_FILES = 18 + + # Summary header + summary = lib.summary() + total = sum(summary.values()) + species_list = ", ".join(lib.species) + summary_lbl = mcrfpy.Caption( + text=f"Found {total} layer files in {len(categories)} categories. Species: {species_list}", + pos=(20, 45), fill_color=LABEL_COLOR) + ui.append(summary_lbl) + + # Left panel: category list + left_x = 20 + cat_labels = [] + for i, (key, display, count, _) in enumerate(cat_data): + y = 85 + i * 28 + prefix = ">>>" if i == 0 else " " + lbl = mcrfpy.Caption( + text=f"{prefix} {display} ({count})", + pos=(left_x, y), + fill_color=HIGHLIGHT_COLOR if i == 0 else LABEL_COLOR) + ui.append(lbl) + cat_labels.append(lbl) + + # Center panel: subcategory list + center_x = 280 + sub_header = mcrfpy.Caption(text="Subcategories:", + pos=(center_x, 85), fill_color=ACCENT_COLOR) + ui.append(sub_header) + + # We'll dynamically create labels for subcategories + sub_labels = [] + sub_label_pool = [] # pre-allocated caption objects + for i in range(12): + lbl = mcrfpy.Caption(text="", pos=(center_x, 110 + i * 25), + fill_color=LABEL_COLOR) + ui.append(lbl) + sub_label_pool.append(lbl) + + # Right panel: file list + right_x = 560 + file_header = mcrfpy.Caption(text="Files:", + pos=(right_x, 85), fill_color=ACCENT_COLOR) + ui.append(file_header) + + file_label_pool = [] + for i in range(MAX_VISIBLE_FILES): + lbl = mcrfpy.Caption(text="", pos=(right_x, 110 + i * 25), + fill_color=DIM_COLOR) + ui.append(lbl) + file_label_pool.append(lbl) + + scroll_info = mcrfpy.Caption(text="", pos=(right_x, 110 + MAX_VISIBLE_FILES * 25), + fill_color=DIM_COLOR) + ui.append(scroll_info) + + def _refresh(): + cat_key, display, count, sub_info = cat_data[state["cat_idx"]] + + # Update category highlights + for i, lbl in enumerate(cat_labels): + key, disp, cnt, _ = cat_data[i] + if i == state["cat_idx"]: + lbl.text = f">>> {disp} ({cnt})" + lbl.fill_color = HIGHLIGHT_COLOR + else: + lbl.text = f" {disp} ({cnt})" + lbl.fill_color = LABEL_COLOR + + # Update subcategory list + for i, lbl in enumerate(sub_label_pool): + if i < len(sub_info): + sc_label, sc_count, _ = sub_info[i] + prefix = ">" if i == state["sub_idx"] else " " + lbl.text = f"{prefix} {sc_label} ({sc_count})" + lbl.fill_color = ACCENT_COLOR if i == state["sub_idx"] else LABEL_COLOR + else: + lbl.text = "" + + # Update file list for selected subcategory + if state["sub_idx"] < len(sub_info): + _, _, file_names = sub_info[state["sub_idx"]] + else: + file_names = [] + + scroll = state["scroll"] + visible = file_names[scroll:scroll + MAX_VISIBLE_FILES] + for i, lbl in enumerate(file_label_pool): + if i < len(visible): + lbl.text = visible[i] + else: + lbl.text = "" + + if len(file_names) > MAX_VISIBLE_FILES: + scroll_info.text = f"({scroll + 1}-{min(scroll + MAX_VISIBLE_FILES, len(file_names))} of {len(file_names)}, PgUp/PgDn)" + else: + scroll_info.text = "" + + _refresh() + + controls = mcrfpy.Caption( + text="[W/S] Category [A/D] Subcategory [PgUp/PgDn] Scroll files [1-7] Scenes", + pos=(20, 740), fill_color=DIM_COLOR) + ui.append(controls) + + def on_key(key, action): + if action != mcrfpy.InputState.PRESSED: + return + if _handle_scene_switch(key): + return + + _, _, _, sub_info = cat_data[state["cat_idx"]] + + if key == mcrfpy.Key.W: + state["cat_idx"] = (state["cat_idx"] - 1) % len(cat_data) + state["sub_idx"] = 0 + state["scroll"] = 0 + _refresh() + elif key == mcrfpy.Key.S: + state["cat_idx"] = (state["cat_idx"] + 1) % len(cat_data) + state["sub_idx"] = 0 + state["scroll"] = 0 + _refresh() + elif key == mcrfpy.Key.A: + if sub_info: + state["sub_idx"] = (state["sub_idx"] - 1) % len(sub_info) + state["scroll"] = 0 + _refresh() + elif key == mcrfpy.Key.D: + if sub_info: + state["sub_idx"] = (state["sub_idx"] + 1) % len(sub_info) + state["scroll"] = 0 + _refresh() + elif key == mcrfpy.Key.PAGEDOWN: + if state["sub_idx"] < len(sub_info): + _, _, file_names = sub_info[state["sub_idx"]] + max_scroll = max(0, len(file_names) - MAX_VISIBLE_FILES) + state["scroll"] = min(state["scroll"] + MAX_VISIBLE_FILES, max_scroll) + _refresh() + elif key == mcrfpy.Key.PAGEUP: + state["scroll"] = max(0, state["scroll"] - MAX_VISIBLE_FILES) + _refresh() scene.on_key = on_key return scene @@ -643,14 +1331,18 @@ def main(): print("The demo will show placeholder messages.") print() - # Build all scenes _build_scene_viewer() _build_scene_hsl() _build_scene_gallery() _build_scene_factions() + _build_scene_layers() + _build_scene_equip() + _build_scene_inventory() # Start animation timer (20fps animation updates) - mcrfpy.Timer("shade_anim", _tick_all, 50) + # Keep a reference so the Python cache lookup works and (timer, runtime) is passed + global _anim_timer + _anim_timer = mcrfpy.Timer("shade_anim", _tick_all, 50) # Activate first scene mcrfpy.Scene("viewer").activate() @@ -658,4 +1350,4 @@ def main(): if __name__ == "__main__": main() - sys.exit(0) + # Do NOT call sys.exit(0) here - let the game loop run diff --git a/shade_sprite/factions.py b/shade_sprite/factions.py new file mode 100644 index 0000000..04ca0ac --- /dev/null +++ b/shade_sprite/factions.py @@ -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) diff --git a/src/McRogueFaceVersion.h b/src/McRogueFaceVersion.h index dafc1f0..14072a0 100644 --- a/src/McRogueFaceVersion.h +++ b/src/McRogueFaceVersion.h @@ -1,4 +1,4 @@ #pragma once // McRogueFace version string (#164) -#define MCRFPY_VERSION "0.2.6-prerelease-7drl2026" +#define MCRFPY_VERSION "0.2.7-prerelease-7drl2026" diff --git a/src/PySound.cpp b/src/PySound.cpp index f6e2889..45d801a 100644 --- a/src/PySound.cpp +++ b/src/PySound.cpp @@ -18,7 +18,10 @@ PySound::PySound(std::shared_ptr bufData) : source(""), loaded(false), bufferData(bufData) { 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); loaded = true; } diff --git a/src/PySoundBuffer.cpp b/src/PySoundBuffer.cpp index bff1673..a6d35b1 100644 --- a/src/PySoundBuffer.cpp +++ b/src/PySoundBuffer.cpp @@ -446,6 +446,16 @@ PyObject* PySoundBuffer::bit_crush(PySoundBufferObject* self, PyObject* args) { 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(std::move(result), self->data->sampleRate, self->data->channels); + return PySoundBuffer_from_data(std::move(data)); +} + PyObject* PySoundBuffer::normalize(PySoundBufferObject* self, PyObject* args) { 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_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, MCRF_METHOD(SoundBuffer, normalize, MCRF_SIG("()", "SoundBuffer"), diff --git a/src/PySoundBuffer.h b/src/PySoundBuffer.h index 0e050ae..9e07aa4 100644 --- a/src/PySoundBuffer.h +++ b/src/PySoundBuffer.h @@ -72,6 +72,7 @@ namespace PySoundBuffer { PyObject* reverb(PySoundBufferObject* self, PyObject* args); PyObject* distortion(PySoundBufferObject* self, PyObject* args); PyObject* bit_crush(PySoundBufferObject* self, PyObject* args); + PyObject* gain(PySoundBufferObject* self, PyObject* args); PyObject* normalize(PySoundBufferObject* self, PyObject* args); PyObject* reverse(PySoundBufferObject* self, PyObject* args); PyObject* slice(PySoundBufferObject* self, PyObject* args); diff --git a/src/audio/AudioEffects.cpp b/src/audio/AudioEffects.cpp index 98f5463..c699b46 100644 --- a/src/audio/AudioEffects.cpp +++ b/src/audio/AudioEffects.cpp @@ -289,6 +289,21 @@ std::vector normalize(const std::vector& samples) { return result; } +// ============================================================================ +// Gain (multiply all samples by scalar factor) +// ============================================================================ + +std::vector gain(const std::vector& samples, double factor) { + if (samples.empty()) return samples; + + std::vector result(samples.size()); + for (size_t i = 0; i < samples.size(); i++) { + double s = samples[i] * factor; + result[i] = static_cast(std::max(-32768.0, std::min(32767.0, s))); + } + return result; +} + // ============================================================================ // Reverse (frame-aware for multichannel) // ============================================================================ diff --git a/src/audio/AudioEffects.h b/src/audio/AudioEffects.h index d02f0ec..f775e15 100644 --- a/src/audio/AudioEffects.h +++ b/src/audio/AudioEffects.h @@ -32,6 +32,9 @@ std::vector bitCrush(const std::vector& samples, int bits, int // Scale to 95% of int16 max std::vector normalize(const std::vector& samples); +// Multiply all samples by a scalar factor (volume/amplitude control) +std::vector gain(const std::vector& samples, double factor); + // Reverse sample order (frame-aware for multichannel) std::vector reverse(const std::vector& samples, unsigned int channels); diff --git a/src/audio/SfxrSynth.cpp b/src/audio/SfxrSynth.cpp index 88470e3..24fb3e2 100644 --- a/src/audio/SfxrSynth.cpp +++ b/src/audio/SfxrSynth.cpp @@ -218,6 +218,17 @@ std::vector sfxr_synthesize(const SfxrParams& p) { for (int si2 = 0; si2 < OVERSAMPLE; si2++) { double sample = 0.0; 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(phase) / period; // Waveform generation diff --git a/src/platform/HeadlessTypes.h b/src/platform/HeadlessTypes.h index 40c066f..8478aed 100644 --- a/src/platform/HeadlessTypes.h +++ b/src/platform/HeadlessTypes.h @@ -459,6 +459,12 @@ public: 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(width) * height * 4; + pixels_.assign(pixels, pixels + byteCount); + } + bool loadFromFile(const std::string& filename) { return false; } bool saveToFile(const std::string& filename) const { return false; } diff --git a/src/platform/SDL2Types.h b/src/platform/SDL2Types.h index 4dd94de..6353caa 100644 --- a/src/platform/SDL2Types.h +++ b/src/platform/SDL2Types.h @@ -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(width) * height * 4; + pixels_.assign(pixels, pixels + byteCount); + } + 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) diff --git a/tests/unit/shade_sprite_factions_test.py b/tests/unit/shade_sprite_factions_test.py new file mode 100644 index 0000000..ad931f5 --- /dev/null +++ b/tests/unit/shade_sprite_factions_test.py @@ -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) diff --git a/tests/unit/soundbuffer_waveform_test.py b/tests/unit/soundbuffer_waveform_test.py new file mode 100644 index 0000000..bc19e8e --- /dev/null +++ b/tests/unit/soundbuffer_waveform_test.py @@ -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)