Compare commits
No commits in common. "453ea4a7ebbd97247531cfa6c3e8334d062e8fcd" and "97181537097f4e3e2f9e3bede7c9a89e77347dc4" have entirely different histories.
453ea4a7eb
...
9718153709
21 changed files with 503 additions and 3116 deletions
|
|
@ -266,11 +266,6 @@ if(MCRF_SDL2)
|
||||||
target_compile_definitions(mcrogueface PRIVATE MCRF_SDL2)
|
target_compile_definitions(mcrogueface PRIVATE MCRF_SDL2)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Asset/script directories for WASM preloading (game projects override these)
|
|
||||||
set(MCRF_ASSETS_DIR "${CMAKE_SOURCE_DIR}/assets" CACHE PATH "Assets directory for WASM preloading")
|
|
||||||
set(MCRF_SCRIPTS_DIR "${CMAKE_SOURCE_DIR}/src/scripts" CACHE PATH "Scripts directory for WASM preloading")
|
|
||||||
set(MCRF_SCRIPTS_PLAYGROUND_DIR "${CMAKE_SOURCE_DIR}/src/scripts_playground" CACHE PATH "Playground scripts for WASM")
|
|
||||||
|
|
||||||
# Emscripten-specific link options (use ports for zlib, bzip2, sqlite3)
|
# Emscripten-specific link options (use ports for zlib, bzip2, sqlite3)
|
||||||
if(EMSCRIPTEN)
|
if(EMSCRIPTEN)
|
||||||
# Base Emscripten options
|
# Base Emscripten options
|
||||||
|
|
@ -292,9 +287,9 @@ if(EMSCRIPTEN)
|
||||||
# Preload Python stdlib into virtual filesystem at /lib/python3.14
|
# Preload Python stdlib into virtual filesystem at /lib/python3.14
|
||||||
--preload-file=${CMAKE_SOURCE_DIR}/wasm_stdlib/lib@/lib
|
--preload-file=${CMAKE_SOURCE_DIR}/wasm_stdlib/lib@/lib
|
||||||
# Preload game scripts into /scripts (use playground scripts if MCRF_PLAYGROUND is set)
|
# Preload game scripts into /scripts (use playground scripts if MCRF_PLAYGROUND is set)
|
||||||
--preload-file=$<IF:$<BOOL:${MCRF_PLAYGROUND}>,${MCRF_SCRIPTS_PLAYGROUND_DIR},${MCRF_SCRIPTS_DIR}>@/scripts
|
--preload-file=${CMAKE_SOURCE_DIR}/src/$<IF:$<BOOL:${MCRF_PLAYGROUND}>,scripts_playground,scripts>@/scripts
|
||||||
# Preload assets
|
# Preload assets
|
||||||
--preload-file=${MCRF_ASSETS_DIR}@/assets
|
--preload-file=${CMAKE_SOURCE_DIR}/assets@/assets
|
||||||
# Use custom HTML shell - game shell (fullscreen) or playground shell (REPL)
|
# Use custom HTML shell - game shell (fullscreen) or playground shell (REPL)
|
||||||
--shell-file=${CMAKE_SOURCE_DIR}/src/$<IF:$<BOOL:${MCRF_GAME_SHELL}>,shell_game.html,shell.html>
|
--shell-file=${CMAKE_SOURCE_DIR}/src/$<IF:$<BOOL:${MCRF_GAME_SHELL}>,shell_game.html,shell.html>
|
||||||
# Pre-JS to fix browser zoom causing undefined values in events
|
# Pre-JS to fix browser zoom causing undefined values in events
|
||||||
|
|
|
||||||
267
ROADMAP.md
267
ROADMAP.md
|
|
@ -1,120 +1,223 @@
|
||||||
# McRogueFace - Development Roadmap
|
# McRogueFace - Development Roadmap
|
||||||
|
|
||||||
**Version**: 0.2.6-prerelease | **Era**: McRogueFace (2D roguelikes)
|
## Project Status
|
||||||
|
|
||||||
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).
|
**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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What Has Shipped
|
## 🎯 Strategic Vision
|
||||||
|
|
||||||
**Alpha 0.1** (2024) -- First complete release. Milestone: all datatypes behaving.
|
### Engine Philosophy
|
||||||
|
|
||||||
**0.2 series** (Jan-Feb 2026) -- Weekly updates to GitHub. Key additions:
|
- **C++ First**: Performance-critical code stays in C++
|
||||||
- 3D/Voxel pipeline (experimental): Viewport3D, Camera3D, Entity3D, VoxelGrid with greedy meshing and serialization
|
- **Python Close Behind**: Rich scripting without frame-rate impact
|
||||||
- Procedural generation: HeightMap, BSP, NoiseSource, DiscreteMap
|
- **Game-Ready**: Each improvement should benefit actual game development
|
||||||
- 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
|
|
||||||
|
|
||||||
**Proving grounds**: Crypt of Sokoban (7DRL 2025) was the first complete game. 7DRL 2026 is the current target.
|
### 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current Focus: 7DRL 2026
|
## 🏗️ Architecture Decisions
|
||||||
|
|
||||||
**Dates**: February 28 -- March 8, 2026
|
### Three-Layer Grid Architecture
|
||||||
|
Following successful roguelike patterns (Caves of Qud, Cogmind, DCSS):
|
||||||
|
|
||||||
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.
|
1. **Visual Layer** (UIGridPoint) - Sprites, colors, animations
|
||||||
|
2. **World State Layer** (TCODMap) - Walkability, transparency, physics
|
||||||
|
3. **Entity Perspective Layer** (UIGridPointState) - Per-entity FOV, knowledge
|
||||||
|
|
||||||
Open prep items:
|
### Performance Architecture
|
||||||
- **#248** -- Crypt of Sokoban Remaster (game content for the jam)
|
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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Post-7DRL: The Road to 1.0
|
## 🚀 Development Phases
|
||||||
|
|
||||||
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.
|
For detailed task tracking and current priorities, see the [Gitea issue tracker](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues).
|
||||||
|
|
||||||
### API Freeze Process
|
### Phase 1: Foundation Stabilization ✅
|
||||||
1. Catalog every public Python class, method, and property
|
**Status**: Complete
|
||||||
2. Identify anything that should change before committing (naming, signatures, defaults)
|
**Key Issues**: #7 (Safe Constructors), #71 (Base Class), #87 (Visibility), #88 (Opacity)
|
||||||
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
|
|
||||||
|
|
||||||
### Post-Jam Priorities
|
### Phase 2: Constructor & API Polish ✅
|
||||||
- Fix pain points discovered during actual 7DRL game development
|
**Status**: Complete
|
||||||
- Progress on the r/roguelikedev tutorial series (#167)
|
**Key Features**: Pythonic API, tuple support, standardized defaults
|
||||||
- API consistency audit and freeze
|
|
||||||
- Better pip/virtualenv integration for adding packages to McRogueFace's embedded interpreter
|
### 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Engine Eras
|
## 🔮 Future Vision: Pure Python Extension Architecture
|
||||||
|
|
||||||
One engine, accumulating capabilities. Nothing is thrown away.
|
### Concept: McRogueFace as a Traditional Python Package
|
||||||
|
**Status**: Long-term vision
|
||||||
|
**Complexity**: Major architectural overhaul
|
||||||
|
|
||||||
| Era | Focus | Status |
|
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`.
|
||||||
|-----|-------|--------|
|
|
||||||
| **McRogueFace** | 2D tiles, roguelike systems, procgen | Active -- approaching 1.0 |
|
### Technical Approach
|
||||||
| **McVectorFace** | Sparse grids, vector graphics, physics | Planned |
|
|
||||||
| **McVoxelFace** | Voxel terrain, 3D gameplay | Proof-of-concept complete |
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3D/Voxel Pipeline (Experimental)
|
## 📋 Major Feature Areas
|
||||||
|
|
||||||
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.
|
For current status and detailed tasks, see the corresponding Gitea issue labels:
|
||||||
|
|
||||||
**What exists**: Viewport3D, Camera3D, Entity3D, MeshLayer, Model3D (glTF), Billboard, Shader3D, VoxelGrid with greedy meshing, face culling, RLE serialization, and navigation projection.
|
### 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
|
||||||
|
|
||||||
**Known gaps**: Some Entity3D collection methods, animation stubs, shader pipeline incomplete.
|
### Performance Optimization
|
||||||
|
- **#115**: SpatialHash for 10,000+ entities
|
||||||
|
- **#116**: Dirty flag system
|
||||||
|
- **#113**: Batch operations for NumPy-style access
|
||||||
|
- **#117**: Memory pool for entities
|
||||||
|
|
||||||
**Maturity track**: These modules will mature on their own timeline, driven by games that need 3D. They won't block 2D stability.
|
### 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Future Directions
|
## 📚 Resources
|
||||||
|
|
||||||
These are ideas on the horizon -- not yet concrete enough for issues, but worth capturing.
|
|
||||||
|
|
||||||
### McRogueFace Lite
|
|
||||||
A spiritual port to MicroPython targeting the PicoCalc and other microcontrollers. Could provide a migration path to retro ROMs or compete in the Pico-8 space. The core idea: strip McRogueFace down to its essential tile/entity/scene model and run it on constrained hardware.
|
|
||||||
|
|
||||||
### McVectorFace Era
|
|
||||||
The next major capability expansion. Sparse grid layers, a polygon/shape rendering class, and eventually physics integration. This would support games that aren't purely tile-based -- top-down action, strategy maps with irregular regions, or hybrid tile+vector visuals. See the [Strategic Direction](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Strategic-Direction) wiki for the full era model.
|
|
||||||
|
|
||||||
### McRogueFace Standard Library
|
|
||||||
A built-in collection of reusable GUI widgets and game UI patterns: menus, dialogs, inventory screens, stat bars, text input fields, scrollable lists. These would ship with the engine as importable Python modules, saving every game from reimplementing the same UI primitives. Think of it as `mcrfpy.widgets` -- batteries included.
|
|
||||||
|
|
||||||
### Pip/Virtualenv Integration
|
|
||||||
Rather than inverting the architecture to make McRogueFace a pip-installable package, the nearer-term goal is better integration in the other direction: making it easy to install and use third-party Python packages within McRogueFace's embedded interpreter. This could mean virtualenv awareness, a `mcrf install` command, or bundling pip itself.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open Issues by Area
|
|
||||||
|
|
||||||
30 open issues across the tracker. Key groupings:
|
|
||||||
|
|
||||||
- **Multi-tile entities** (#233-#237) -- Oversized sprites, composite entities, origin offsets
|
|
||||||
- **Grid enhancements** (#152, #149, #67) -- Sparse layers, refactoring, infinite worlds
|
|
||||||
- **Performance** (#117, #124, #145) -- Memory pools, grid point animation, texture reuse
|
|
||||||
- **LLM agent testbed** (#154, #156, #55) -- Multi-agent simulation, turn-based orchestration
|
|
||||||
- **Platform/distribution** (#70, #54, #62, #53) -- Packaging, Jupyter, multiple windows, input methods
|
|
||||||
- **WASM tooling** (#238-#240) -- Debug infrastructure, automated browser testing, troubleshooting docs
|
|
||||||
- **Rendering** (#107, #218) -- Particle system, Color/Vector animation targets
|
|
||||||
|
|
||||||
See the [Gitea issue tracker](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues) for current status.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- **Issue Tracker**: [Gitea Issues](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues)
|
- **Issue Tracker**: [Gitea Issues](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues)
|
||||||
- **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)
|
- **Source Code**: [Gitea Repository](https://gamedev.ffwf.net/gitea/john/McRogueFace)
|
||||||
- **Build Guide**: See `CLAUDE.md` for build instructions
|
- **Documentation**: See `CLAUDE.md` for build instructions and development guide
|
||||||
- **Tutorial**: `roguelike_tutorial/` for implementation examples
|
- **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).*
|
||||||
|
|
|
||||||
422
mcrf-init.sh
422
mcrf-init.sh
|
|
@ -1,422 +0,0 @@
|
||||||
#!/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."
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"include": ["src/scripts", "shade_sprite", "tests"],
|
|
||||||
"extraPaths": ["stubs"],
|
|
||||||
"pythonVersion": "3.14",
|
|
||||||
"pythonPlatform": "Linux",
|
|
||||||
"typeCheckingMode": "basic",
|
|
||||||
"reportMissingModuleSource": false
|
|
||||||
}
|
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
# shade_sprite Evaluation Report
|
|
||||||
|
|
||||||
**Date:** 2026-02-17
|
|
||||||
**Purpose:** 7DRL readiness assessment of the character assembly and animation system
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Module Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
shade_sprite/
|
|
||||||
__init__.py - Clean public API, __all__ exports
|
|
||||||
formats.py - SheetFormat definitions, Direction enum, AnimFrame/AnimDef dataclasses
|
|
||||||
animation.py - AnimatedSprite state machine
|
|
||||||
assembler.py - CharacterAssembler (layer compositing + HSL recoloring)
|
|
||||||
demo.py - 6-scene interactive demo
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Component Assessment
|
|
||||||
|
|
||||||
### 1. CharacterAssembler
|
|
||||||
|
|
||||||
**Status: FUNCTIONAL, with caveats**
|
|
||||||
|
|
||||||
**What it does:**
|
|
||||||
- Accepts N layer PNGs added bottom-to-top via `add_layer(path, hue_shift, sat_shift, lit_shift)`
|
|
||||||
- Loads each as an `mcrfpy.Texture`, applies HSL shift if any non-zero values
|
|
||||||
- Composites all layers via `mcrfpy.Texture.composite()` (alpha blending)
|
|
||||||
- Returns a single `mcrfpy.Texture` ready for use with `mcrfpy.Sprite`
|
|
||||||
- Method chaining supported (`asm.add_layer(...).add_layer(...)`)
|
|
||||||
- `clear()` resets layers for reuse
|
|
||||||
|
|
||||||
**What it supports:**
|
|
||||||
- Loading separate layer PNGs (body, armor, weapon, etc.): **YES**
|
|
||||||
- Compositing them back-to-front: **YES** (via Texture.composite)
|
|
||||||
- Recoloring via HSL shift: **YES** (per-layer hue/sat/lit adjustments)
|
|
||||||
- Palette swap: **NO** (only continuous HSL rotation, not indexed palette remapping)
|
|
||||||
|
|
||||||
**Limitations:**
|
|
||||||
- No layer visibility toggle (must rebuild without the layer)
|
|
||||||
- No per-layer offset/transform (all layers must be pixel-aligned same-size sheets)
|
|
||||||
- No caching — every `build()` call reloads textures from disk
|
|
||||||
- No export/save — composite exists only as an in-memory mcrfpy.Texture
|
|
||||||
- No layer ordering control beyond insertion order
|
|
||||||
|
|
||||||
### 2. AnimatedSprite
|
|
||||||
|
|
||||||
**Status: COMPLETE AND WELL-TESTED**
|
|
||||||
|
|
||||||
**What it supports:**
|
|
||||||
- 8-directional facing (N/S/E/W/NE/NW/SE/SW): **YES** via `Direction` IntEnum
|
|
||||||
- 4-directional with diagonal rounding: **YES** (SW->S, NE->N, etc.)
|
|
||||||
- 1-directional (for slimes etc.): **YES**
|
|
||||||
- Programmatic direction setting: **YES** (`anim.direction = Direction.E`)
|
|
||||||
- Animation states: **YES** — any named animation from the format's dict
|
|
||||||
|
|
||||||
**Available animations (PUNY_29 format, 10 total):**
|
|
||||||
| Animation | Type | Frames | Behavior |
|
|
||||||
|-----------|------|--------|----------|
|
|
||||||
| idle | loop | 2 | Default start state |
|
|
||||||
| walk | loop | 4 | Movement |
|
|
||||||
| slash | one-shot | 4 | Melee attack, chains to idle |
|
|
||||||
| bow | one-shot | 4 | Ranged attack, chains to idle |
|
|
||||||
| thrust | one-shot | 4 | Spear/polearm, chains to idle |
|
|
||||||
| spellcast | one-shot | 4 | Magic attack, chains to idle |
|
|
||||||
| hurt | one-shot | 3 | Damage taken, chains to idle |
|
|
||||||
| death | one-shot | 3 | Death, no chain (stays on last frame) |
|
|
||||||
| dodge | one-shot | 4 | Evasion, chains to idle |
|
|
||||||
| item_use | one-shot | 1 | Item activation, chains to idle |
|
|
||||||
|
|
||||||
**PUNY_24 format (free pack, 8 animations):** Same minus dodge and item_use.
|
|
||||||
|
|
||||||
**Programmatic control:**
|
|
||||||
- `play("walk")` — start named animation, resets frame counter
|
|
||||||
- `tick(dt_ms)` — advance clock, auto-advances frames
|
|
||||||
- `set_direction(Direction.E)` — change facing, updates sprite immediately
|
|
||||||
- `finished` property — True when one-shot completes without chain
|
|
||||||
- Animation chaining — one-shot animations auto-transition to `chain_to`
|
|
||||||
|
|
||||||
**Architecture:** Wraps an `mcrfpy.Sprite` and updates its `sprite_index` property. Requires external `tick()` calls (typically from an `mcrfpy.Timer`).
|
|
||||||
|
|
||||||
### 3. Sprite Sheet Layout
|
|
||||||
|
|
||||||
**Status: WELL-DEFINED**
|
|
||||||
|
|
||||||
**Standard layout:** Rows = directions, Columns = animation frames.
|
|
||||||
|
|
||||||
| Format | Tile Size | Columns | Rows | Directions | Sheet Pixels |
|
|
||||||
|--------|-----------|---------|------|------------|--------------|
|
|
||||||
| PUNY_29 | 32x32 | 29 | 8 | 8 | 928x256 |
|
|
||||||
| PUNY_24 | 32x32 | 24 | 8 | 8 | 768x256 |
|
|
||||||
| CREATURE_RPGMAKER | 24x24 | 3 | 4 | 4 | 72x96 |
|
|
||||||
| SLIME | 32x32 | 15 | 1 | 1 | 480x32 |
|
|
||||||
|
|
||||||
**Auto-detection:** `detect_format(width, height)` maps pixel dimensions to format. Works for all 4 formats.
|
|
||||||
|
|
||||||
**Consistency:** All formats share the same `SheetFormat` abstraction. The `sprite_index(col, direction)` method computes flat tile indices consistently: `row * columns + col`.
|
|
||||||
|
|
||||||
### 4. Demo (demo.py)
|
|
||||||
|
|
||||||
**Status: FUNCTIONAL (6 scenes)**
|
|
||||||
|
|
||||||
**Runs without errors:** YES. Tested both headless (`--headless --exec`) and confirmed no Python exceptions. The KeyError from session 38f29994 has been resolved — the current code uses `mcrfpy.Key.NUM_1` etc. (not string-based lookups).
|
|
||||||
|
|
||||||
**Scene inventory:**
|
|
||||||
| Scene | Key | Content | Status |
|
|
||||||
|-------|-----|---------|--------|
|
|
||||||
| Animation Viewer | 1 | Cycle sheets/anims/directions, compass layout, slime | Complete |
|
|
||||||
| HSL Recolor | 2 | Live hue/sat/lit adjustment, 6-step hue wheel | Complete |
|
|
||||||
| Character Gallery | 3 | 5-column grid of all sheets, shared anim/dir control | Complete |
|
|
||||||
| Faction Generator | 4 | 4 random factions, 5 hue-shifted characters each | Complete |
|
|
||||||
| Layer Compositing | 5 | Base + overlay + composite side-by-side, hue row | Complete |
|
|
||||||
| Equipment Customizer | 6 | 3-slot system, procedural variant generation | Complete |
|
|
||||||
|
|
||||||
**Asset requirement:** Demo looks for PNGs in three search paths. The `~/Development/7DRL2026_Liber_Noster_jmccardle/assets_sources/Puny-Characters/` path resolves on this machine. Without assets, all scenes show a "no assets found" fallback message.
|
|
||||||
|
|
||||||
**Keyboard controls verified:** All 22 Key enum references (NUM_1-6, Q/E, A/D, W/S, LEFT/RIGHT, UP/DOWN, Z/X, TAB, T, R, SPACE) confirmed valid against current mcrfpy.Key enum.
|
|
||||||
|
|
||||||
### 5. Tests
|
|
||||||
|
|
||||||
**Status: ALL 25 PASS**
|
|
||||||
|
|
||||||
```
|
|
||||||
=== Format Definitions === 8 tests (dimensions, columns, rows, directions, animation counts, chaining)
|
|
||||||
=== Format Detection === 5 tests (all 4 formats + unknown)
|
|
||||||
=== Direction === 8 tests (enum values, 8-dir mapping, 4-dir mapping with diagonal rounding)
|
|
||||||
=== Sprite Index === 5 tests (flat index computation for PUNY_29 and SLIME)
|
|
||||||
=== AnimatedSprite === 14 tests (creation, play, tick timing, direction change, one-shot chaining, death, error handling)
|
|
||||||
=== CharacterAssembler === 2 tests (creation, empty build error)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test coverage gaps:**
|
|
||||||
- CharacterAssembler `build()` with actual layers is NOT tested (only error case tested)
|
|
||||||
- HSL shift integration is NOT tested (requires real texture data)
|
|
||||||
- No test for Texture.composite() through the assembler
|
|
||||||
- No visual regression tests (screenshots)
|
|
||||||
- No performance/memory tests for bulk texture generation
|
|
||||||
|
|
||||||
### 6. Assets
|
|
||||||
|
|
||||||
**Status: EXTERNAL DEPENDENCY, NOT IN REPO**
|
|
||||||
|
|
||||||
**Available on this machine (not in McRogueFace repo):**
|
|
||||||
|
|
||||||
*Free pack* (`Puny-Characters/`, 768x256 PUNY_24):
|
|
||||||
- 19 pre-composed character sheets (Warrior, Soldier, Archer, Mage, Human-Soldier, Human-Worker, Orc variants)
|
|
||||||
- 1 Character-Base.png (body-only layer)
|
|
||||||
- 1 Slime.png (480x32 SLIME format)
|
|
||||||
- 4 Environment tiles
|
|
||||||
|
|
||||||
*Paid pack* (`PUNY_CHARACTERS_v2.1/`, 928x256 PUNY_29):
|
|
||||||
- 8 individual layer categories: Skins (14 variants), Shoes, Clothes (7 body types x colors), Gloves, Hairstyle, Eyes, Headgears, Add-ons
|
|
||||||
- Pre-made composite sheets organized by race (Humans, Elves, Dwarves, Orcs, etc.)
|
|
||||||
- Photoshop/GIMP source files
|
|
||||||
- Tools (deleter overlays, weapon overlayer)
|
|
||||||
|
|
||||||
**The free pack has ONE layer file** (Character-Base.png) suitable for compositing. True multi-layer assembly requires the paid PUNY_CHARACTERS_v2.1 pack.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7DRL Gap Analysis
|
|
||||||
|
|
||||||
### Gap 1: Procedural Faction Generation
|
|
||||||
|
|
||||||
**Current capability:** Scene 4 (Faction Generator) demonstrates hue-shifting pre-composed sheets to create "factions." Scene 6 (Equipment Customizer) shows multi-layer compositing with per-slot HSL control.
|
|
||||||
|
|
||||||
**What works for 7DRL:**
|
|
||||||
- Applying a faction hue to existing character sheets creates visually distinct groups
|
|
||||||
- HSL shift covers hue (color identity), saturation (vibrancy), and lightness (dark/light variants)
|
|
||||||
- Random hue selection per faction produces reasonable visual variety
|
|
||||||
|
|
||||||
**What's missing:**
|
|
||||||
- **Species variation via layer swap:** The assembler supports this IF you have separate layer PNGs (the paid pack has them, the free pack does not). No code exists to enumerate available layers by category (skins, clothes, etc.) or randomly select from each category.
|
|
||||||
- **No "faction recipe" data structure:** There's no serializable faction definition that says "skin=Orc2, clothes=VikingBody-Red+hue180, hair=none." The demo builds composites imperatively.
|
|
||||||
- **No palette-indexed recoloring:** HSL shift rotates all hues uniformly. A red-and-blue character shifted +120 degrees becomes green-and-purple. True faction coloring would need selective recoloring (e.g., only shift the clothing layer, not the skin).
|
|
||||||
|
|
||||||
**Verdict:** Functional for simple hue-based faction differentiation. For species + equipment variety, you need the paid layer PNGs and a layer-category enumeration helper.
|
|
||||||
|
|
||||||
### Gap 2: Bulk Generation
|
|
||||||
|
|
||||||
**Can you generate 2-4 character variants per faction on a virtual tile sheet?**
|
|
||||||
|
|
||||||
**Current capability:** Each `assembler.build()` call produces a separate `mcrfpy.Texture`. There is no API to pack multiple characters onto a single tile sheet.
|
|
||||||
|
|
||||||
**What works:**
|
|
||||||
- Generate N separate textures (one per character variant), each assigned to a separate `mcrfpy.Sprite`
|
|
||||||
- The demo already does this: Scene 4 creates 4 factions x 5 characters = 20 separate textures
|
|
||||||
- Each texture is runtime-only (not saved to disk)
|
|
||||||
|
|
||||||
**What's missing:**
|
|
||||||
- **No tile-sheet packer:** Cannot combine 4 character textures into a single 4-wide sprite sheet for use with a single Entity on a Grid
|
|
||||||
- **Texture.from_bytes could theoretically be used** to manually blit multiple characters into one sheet, but this would require reading back pixel data (not currently exposed)
|
|
||||||
- **No Texture.read_pixels() or similar** to extract raw bytes from an existing texture
|
|
||||||
|
|
||||||
**Verdict:** For 7DRL, the simplest approach is one Texture per character variant (each gets its own Sprite/Entity). This works but means more GPU texture objects. A tile-sheet packer would be a nice-to-have but is not blocking.
|
|
||||||
|
|
||||||
### Gap 3: Runtime Integration
|
|
||||||
|
|
||||||
**Can McRogueFace entities use assembled sprites at runtime?**
|
|
||||||
|
|
||||||
**Status: YES, fully runtime**
|
|
||||||
|
|
||||||
- `CharacterAssembler.build()` returns an `mcrfpy.Texture` immediately usable with `mcrfpy.Sprite`
|
|
||||||
- `AnimatedSprite` wraps any `mcrfpy.Sprite` and drives its `sprite_index`
|
|
||||||
- Timer-based `tick()` integrates with the game loop
|
|
||||||
- The entire pipeline (load layers -> HSL shift -> composite -> animate) runs at runtime
|
|
||||||
- No build-time step required
|
|
||||||
|
|
||||||
**Integration pattern (from demo.py):**
|
|
||||||
```python
|
|
||||||
# Create composite texture at runtime
|
|
||||||
asm = CharacterAssembler(PUNY_24)
|
|
||||||
asm.add_layer("Character-Base.png")
|
|
||||||
asm.add_layer("Warrior-Red.png", hue_shift=120.0)
|
|
||||||
tex = asm.build("faction_warrior")
|
|
||||||
|
|
||||||
# Use with sprite
|
|
||||||
sprite = mcrfpy.Sprite(texture=tex, pos=(x, y), scale=2.0)
|
|
||||||
scene.children.append(sprite)
|
|
||||||
|
|
||||||
# Animate
|
|
||||||
anim = AnimatedSprite(sprite, PUNY_24, Direction.S)
|
|
||||||
anim.play("walk")
|
|
||||||
|
|
||||||
# Drive from timer
|
|
||||||
def tick(timer, runtime):
|
|
||||||
anim.tick(timer.interval)
|
|
||||||
mcrfpy.Timer("anim", tick, 50)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary Scorecard
|
|
||||||
|
|
||||||
| Component | Status | 7DRL Ready? |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| AnimatedSprite | Complete, well-tested | YES |
|
|
||||||
| Direction system (8-dir) | Complete | YES |
|
|
||||||
| Animation definitions (10 states) | Complete | YES |
|
|
||||||
| Format auto-detection | Complete | YES |
|
|
||||||
| CharacterAssembler (compositing) | Functional | YES (with paid pack layers) |
|
|
||||||
| HSL recoloring | Functional | YES |
|
|
||||||
| Demo | 6 scenes, no errors | YES |
|
|
||||||
| Unit tests | 25/25 pass | YES (coverage gaps in assembler) |
|
|
||||||
| Faction generation | Proof-of-concept in demo | PARTIAL — needs recipe/category system |
|
|
||||||
| Bulk sheet packing | Not implemented | NO — use 1 texture per character |
|
|
||||||
| Assets in repo | Not present | NO — external dependency |
|
|
||||||
| Layer category enumeration | Not implemented | NO — would need helper for paid pack |
|
|
||||||
|
|
||||||
## Recommendations for 7DRL
|
|
||||||
|
|
||||||
1. **Copy needed assets into the game project's assets directory** (or symlink). Don't rely on hardcoded paths to the 7DRL2026 project.
|
|
||||||
|
|
||||||
2. **For faction generation with the free pack:** Hue-shift pre-composed sheets. This gives color variety but not equipment/species variety. Sufficient for a jam game.
|
|
||||||
|
|
||||||
3. **For faction generation with the paid pack:** Build a small helper that scans the layer directories by category (Skins/, Clothes/, etc.) and randomly picks one from each. The CharacterAssembler already handles the compositing — you just need the selection logic.
|
|
||||||
|
|
||||||
4. **Don't build a tile-sheet packer.** One texture per character is fine for 7DRL scope. The engine handles many textures without issue.
|
|
||||||
|
|
||||||
5. **Add a texture cache in CharacterAssembler** if generating many variants. Currently every `build()` reloads PNGs from disk. A simple dict cache of `path -> Texture` would avoid redundant I/O.
|
|
||||||
|
|
||||||
6. **The demo is ready as a showcase/testing tool.** All 6 scenes work with keyboard navigation. It demonstrates every capability the module offers.
|
|
||||||
|
|
@ -29,14 +29,6 @@ For layered characters:
|
||||||
assembler.add_layer("clothes/BasicBlue-Body.png", hue_shift=120.0)
|
assembler.add_layer("clothes/BasicBlue-Body.png", hue_shift=120.0)
|
||||||
assembler.add_layer("hair/M-Hairstyle1-Black.png")
|
assembler.add_layer("hair/M-Hairstyle1-Black.png")
|
||||||
texture = assembler.build("my_character")
|
texture = assembler.build("my_character")
|
||||||
|
|
||||||
For procedural factions:
|
|
||||||
from shade_sprite import FactionGenerator, AssetLibrary
|
|
||||||
|
|
||||||
lib = AssetLibrary()
|
|
||||||
gen = FactionGenerator(seed=42, library=lib)
|
|
||||||
recipe = gen.generate()
|
|
||||||
textures = recipe.build_role_textures(assembler)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .formats import (
|
from .formats import (
|
||||||
|
|
@ -52,23 +44,12 @@ from .formats import (
|
||||||
detect_format,
|
detect_format,
|
||||||
)
|
)
|
||||||
from .animation import AnimatedSprite
|
from .animation import AnimatedSprite
|
||||||
from .assembler import CharacterAssembler, TextureCache
|
from .assembler import CharacterAssembler
|
||||||
from .assets import AssetLibrary, LayerFile
|
|
||||||
from .factions import (
|
|
||||||
FactionRecipe,
|
|
||||||
FactionGenerator,
|
|
||||||
RoleDefinition,
|
|
||||||
Biome,
|
|
||||||
Element,
|
|
||||||
Aesthetic,
|
|
||||||
RoleType,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Core classes
|
# Core classes
|
||||||
"AnimatedSprite",
|
"AnimatedSprite",
|
||||||
"CharacterAssembler",
|
"CharacterAssembler",
|
||||||
"TextureCache",
|
|
||||||
# Format definitions
|
# Format definitions
|
||||||
"Direction",
|
"Direction",
|
||||||
"AnimFrame",
|
"AnimFrame",
|
||||||
|
|
@ -82,15 +63,4 @@ __all__ = [
|
||||||
"ALL_FORMATS",
|
"ALL_FORMATS",
|
||||||
# Utilities
|
# Utilities
|
||||||
"detect_format",
|
"detect_format",
|
||||||
# Asset scanning
|
|
||||||
"AssetLibrary",
|
|
||||||
"LayerFile",
|
|
||||||
# Faction generation
|
|
||||||
"FactionRecipe",
|
|
||||||
"FactionGenerator",
|
|
||||||
"RoleDefinition",
|
|
||||||
"Biome",
|
|
||||||
"Element",
|
|
||||||
"Aesthetic",
|
|
||||||
"RoleType",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -3,63 +3,11 @@
|
||||||
Uses the engine's Texture.composite() and texture.hsl_shift() methods to
|
Uses the engine's Texture.composite() and texture.hsl_shift() methods to
|
||||||
build composite character textures from multiple layer PNG files, without
|
build composite character textures from multiple layer PNG files, without
|
||||||
requiring PIL or any external Python packages.
|
requiring PIL or any external Python packages.
|
||||||
|
|
||||||
HSL notes (from C++ investigation):
|
|
||||||
- tex.hsl_shift(h, s, l) always creates a NEW texture by copying all
|
|
||||||
pixels, converting RGB->HSL, applying shifts, converting back.
|
|
||||||
- Works on any texture: file-loaded, from_bytes, composite, or
|
|
||||||
previously shifted. Alpha is preserved; transparent pixels skipped.
|
|
||||||
- No engine-level caching exists -- repeated identical calls produce
|
|
||||||
separate texture objects. The TextureCache below avoids redundant
|
|
||||||
loads and shifts at the Python level.
|
|
||||||
"""
|
"""
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
from .formats import PUNY_29, SheetFormat
|
from .formats import PUNY_29, SheetFormat
|
||||||
|
|
||||||
|
|
||||||
class TextureCache:
|
|
||||||
"""Cache for loaded and HSL-shifted textures to avoid redundant disk I/O.
|
|
||||||
|
|
||||||
Keys are (path, hue_shift, sat_shift, lit_shift) tuples.
|
|
||||||
Call clear() to free all cached textures.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._cache = {}
|
|
||||||
|
|
||||||
def get(self, path, tile_w, tile_h, hue=0.0, sat=0.0, lit=0.0):
|
|
||||||
"""Load a texture, using cached version if available.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path: File path to the PNG
|
|
||||||
tile_w: Sprite tile width
|
|
||||||
tile_h: Sprite tile height
|
|
||||||
hue: Hue rotation in degrees
|
|
||||||
sat: Saturation adjustment
|
|
||||||
lit: Lightness adjustment
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
mcrfpy.Texture
|
|
||||||
"""
|
|
||||||
key = (path, hue, sat, lit)
|
|
||||||
if key not in self._cache:
|
|
||||||
tex = mcrfpy.Texture(path, tile_w, tile_h)
|
|
||||||
if hue != 0.0 or sat != 0.0 or lit != 0.0:
|
|
||||||
tex = tex.hsl_shift(hue, sat, lit)
|
|
||||||
self._cache[key] = tex
|
|
||||||
return self._cache[key]
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
"""Drop all cached textures."""
|
|
||||||
self._cache.clear()
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return len(self._cache)
|
|
||||||
|
|
||||||
def __contains__(self, key):
|
|
||||||
return key in self._cache
|
|
||||||
|
|
||||||
|
|
||||||
class CharacterAssembler:
|
class CharacterAssembler:
|
||||||
"""Build composite character sheets from layer files.
|
"""Build composite character sheets from layer files.
|
||||||
|
|
||||||
|
|
@ -68,16 +16,13 @@ class CharacterAssembler:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
fmt: SheetFormat describing the sprite dimensions (default: PUNY_29)
|
fmt: SheetFormat describing the sprite dimensions (default: PUNY_29)
|
||||||
cache: Optional TextureCache for reusing loaded textures across
|
|
||||||
multiple build() calls. If None, a private cache is created.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, fmt=None, cache=None):
|
def __init__(self, fmt=None):
|
||||||
if fmt is None:
|
if fmt is None:
|
||||||
fmt = PUNY_29
|
fmt = PUNY_29
|
||||||
self.fmt = fmt
|
self.fmt = fmt
|
||||||
self.layers = []
|
self.layers = []
|
||||||
self.cache = cache if cache is not None else TextureCache()
|
|
||||||
|
|
||||||
def add_layer(self, path, hue_shift=0.0, sat_shift=0.0, lit_shift=0.0):
|
def add_layer(self, path, hue_shift=0.0, sat_shift=0.0, lit_shift=0.0):
|
||||||
"""Queue a layer PNG with optional HSL recoloring.
|
"""Queue a layer PNG with optional HSL recoloring.
|
||||||
|
|
@ -99,8 +44,8 @@ class CharacterAssembler:
|
||||||
def build(self, name="<composed>"):
|
def build(self, name="<composed>"):
|
||||||
"""Composite all queued layers into a single Texture.
|
"""Composite all queued layers into a single Texture.
|
||||||
|
|
||||||
Loads each layer file (using the cache to avoid redundant disk reads
|
Loads each layer file, applies HSL shifts if any, then composites
|
||||||
and HSL computations), then composites all layers bottom-to-top.
|
all layers bottom-to-top using alpha blending.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Optional name for the resulting texture
|
name: Optional name for the resulting texture
|
||||||
|
|
@ -117,7 +62,9 @@ class CharacterAssembler:
|
||||||
|
|
||||||
textures = []
|
textures = []
|
||||||
for path, h, s, l in self.layers:
|
for path, h, s, l in self.layers:
|
||||||
tex = self.cache.get(path, self.fmt.tile_w, self.fmt.tile_h, h, s, l)
|
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)
|
||||||
textures.append(tex)
|
textures.append(tex)
|
||||||
|
|
||||||
if len(textures) == 1:
|
if len(textures) == 1:
|
||||||
|
|
|
||||||
|
|
@ -1,295 +0,0 @@
|
||||||
"""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)"
|
|
||||||
1300
shade_sprite/demo.py
1300
shade_sprite/demo.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,494 +0,0 @@
|
||||||
"""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)
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
// McRogueFace version string (#164)
|
// McRogueFace version string (#164)
|
||||||
#define MCRFPY_VERSION "0.2.7-prerelease-7drl2026"
|
#define MCRFPY_VERSION "0.2.6-prerelease-7drl2026"
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,7 @@ PySound::PySound(std::shared_ptr<SoundBufferData> bufData)
|
||||||
: source("<SoundBuffer>"), loaded(false), bufferData(bufData)
|
: source("<SoundBuffer>"), loaded(false), bufferData(bufData)
|
||||||
{
|
{
|
||||||
if (bufData && !bufData->samples.empty()) {
|
if (bufData && !bufData->samples.empty()) {
|
||||||
// Rebuild the sf::SoundBuffer from sample data directly
|
buffer = bufData->getSfBuffer();
|
||||||
// (avoids copy-assign which is deleted on SDL2 backend)
|
|
||||||
buffer.loadFromSamples(bufData->samples.data(), bufData->samples.size(),
|
|
||||||
bufData->channels, bufData->sampleRate);
|
|
||||||
sound.setBuffer(buffer);
|
sound.setBuffer(buffer);
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -446,16 +446,6 @@ PyObject* PySoundBuffer::bit_crush(PySoundBufferObject* self, PyObject* args) {
|
||||||
return PySoundBuffer_from_data(std::move(data));
|
return PySoundBuffer_from_data(std::move(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
PyObject* PySoundBuffer::gain(PySoundBufferObject* self, PyObject* args) {
|
|
||||||
double factor;
|
|
||||||
if (!PyArg_ParseTuple(args, "d", &factor)) return NULL;
|
|
||||||
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
|
|
||||||
|
|
||||||
auto result = AudioEffects::gain(self->data->samples, factor);
|
|
||||||
auto data = std::make_shared<SoundBufferData>(std::move(result), self->data->sampleRate, self->data->channels);
|
|
||||||
return PySoundBuffer_from_data(std::move(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
PyObject* PySoundBuffer::normalize(PySoundBufferObject* self, PyObject* args) {
|
PyObject* PySoundBuffer::normalize(PySoundBufferObject* self, PyObject* args) {
|
||||||
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
|
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
|
||||||
|
|
||||||
|
|
@ -738,13 +728,6 @@ PyMethodDef PySoundBuffer::methods[] = {
|
||||||
MCRF_SIG("(bits: int, rate_divisor: int)", "SoundBuffer"),
|
MCRF_SIG("(bits: int, rate_divisor: int)", "SoundBuffer"),
|
||||||
MCRF_DESC("Reduce bit depth and sample rate for lo-fi effect.")
|
MCRF_DESC("Reduce bit depth and sample rate for lo-fi effect.")
|
||||||
)},
|
)},
|
||||||
{"gain", (PyCFunction)PySoundBuffer::gain, METH_VARARGS,
|
|
||||||
MCRF_METHOD(SoundBuffer, gain,
|
|
||||||
MCRF_SIG("(factor: float)", "SoundBuffer"),
|
|
||||||
MCRF_DESC("Multiply all samples by a scalar factor. Use for volume/amplitude control before mixing."),
|
|
||||||
MCRF_ARGS_START
|
|
||||||
MCRF_ARG("factor", "Amplitude multiplier (0.5 = half volume, 2.0 = double). Clamps to int16 range.")
|
|
||||||
)},
|
|
||||||
{"normalize", (PyCFunction)PySoundBuffer::normalize, METH_NOARGS,
|
{"normalize", (PyCFunction)PySoundBuffer::normalize, METH_NOARGS,
|
||||||
MCRF_METHOD(SoundBuffer, normalize,
|
MCRF_METHOD(SoundBuffer, normalize,
|
||||||
MCRF_SIG("()", "SoundBuffer"),
|
MCRF_SIG("()", "SoundBuffer"),
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,6 @@ namespace PySoundBuffer {
|
||||||
PyObject* reverb(PySoundBufferObject* self, PyObject* args);
|
PyObject* reverb(PySoundBufferObject* self, PyObject* args);
|
||||||
PyObject* distortion(PySoundBufferObject* self, PyObject* args);
|
PyObject* distortion(PySoundBufferObject* self, PyObject* args);
|
||||||
PyObject* bit_crush(PySoundBufferObject* self, PyObject* args);
|
PyObject* bit_crush(PySoundBufferObject* self, PyObject* args);
|
||||||
PyObject* gain(PySoundBufferObject* self, PyObject* args);
|
|
||||||
PyObject* normalize(PySoundBufferObject* self, PyObject* args);
|
PyObject* normalize(PySoundBufferObject* self, PyObject* args);
|
||||||
PyObject* reverse(PySoundBufferObject* self, PyObject* args);
|
PyObject* reverse(PySoundBufferObject* self, PyObject* args);
|
||||||
PyObject* slice(PySoundBufferObject* self, PyObject* args);
|
PyObject* slice(PySoundBufferObject* self, PyObject* args);
|
||||||
|
|
|
||||||
|
|
@ -289,21 +289,6 @@ std::vector<int16_t> normalize(const std::vector<int16_t>& samples) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Gain (multiply all samples by scalar factor)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
std::vector<int16_t> gain(const std::vector<int16_t>& samples, double factor) {
|
|
||||||
if (samples.empty()) return samples;
|
|
||||||
|
|
||||||
std::vector<int16_t> result(samples.size());
|
|
||||||
for (size_t i = 0; i < samples.size(); i++) {
|
|
||||||
double s = samples[i] * factor;
|
|
||||||
result[i] = static_cast<int16_t>(std::max(-32768.0, std::min(32767.0, s)));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Reverse (frame-aware for multichannel)
|
// Reverse (frame-aware for multichannel)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,6 @@ std::vector<int16_t> bitCrush(const std::vector<int16_t>& samples, int bits, int
|
||||||
// Scale to 95% of int16 max
|
// Scale to 95% of int16 max
|
||||||
std::vector<int16_t> normalize(const std::vector<int16_t>& samples);
|
std::vector<int16_t> normalize(const std::vector<int16_t>& samples);
|
||||||
|
|
||||||
// Multiply all samples by a scalar factor (volume/amplitude control)
|
|
||||||
std::vector<int16_t> gain(const std::vector<int16_t>& samples, double factor);
|
|
||||||
|
|
||||||
// Reverse sample order (frame-aware for multichannel)
|
// Reverse sample order (frame-aware for multichannel)
|
||||||
std::vector<int16_t> reverse(const std::vector<int16_t>& samples, unsigned int channels);
|
std::vector<int16_t> reverse(const std::vector<int16_t>& samples, unsigned int channels);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -218,17 +218,6 @@ std::vector<int16_t> sfxr_synthesize(const SfxrParams& p) {
|
||||||
for (int si2 = 0; si2 < OVERSAMPLE; si2++) {
|
for (int si2 = 0; si2 < OVERSAMPLE; si2++) {
|
||||||
double sample = 0.0;
|
double sample = 0.0;
|
||||||
phase++;
|
phase++;
|
||||||
|
|
||||||
// Wrap phase at period boundary (critical for square/saw waveforms)
|
|
||||||
if (phase >= period) {
|
|
||||||
phase %= period;
|
|
||||||
if (p.wave_type == 3) { // Refresh noise buffer each period
|
|
||||||
for (int i = 0; i < 32; i++) {
|
|
||||||
noise_buffer[i] = ((std::rand() % 20001) / 10000.0) - 1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
double fphase = static_cast<double>(phase) / period;
|
double fphase = static_cast<double>(phase) / period;
|
||||||
|
|
||||||
// Waveform generation
|
// Waveform generation
|
||||||
|
|
|
||||||
|
|
@ -459,12 +459,6 @@ public:
|
||||||
pixels_.resize(width * height * 4, 0);
|
pixels_.resize(width * height * 4, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
void create(unsigned int width, unsigned int height, const Uint8* pixels) {
|
|
||||||
size_ = Vector2u(width, height);
|
|
||||||
size_t byteCount = static_cast<size_t>(width) * height * 4;
|
|
||||||
pixels_.assign(pixels, pixels + byteCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool loadFromFile(const std::string& filename) { return false; }
|
bool loadFromFile(const std::string& filename) { return false; }
|
||||||
bool saveToFile(const std::string& filename) const { return false; }
|
bool saveToFile(const std::string& filename) const { return false; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -621,12 +621,6 @@ public:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void create(unsigned int width, unsigned int height, const Uint8* pixels) {
|
|
||||||
size_ = Vector2u(width, height);
|
|
||||||
size_t byteCount = static_cast<size_t>(width) * height * 4;
|
|
||||||
pixels_.assign(pixels, pixels + byteCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool loadFromFile(const std::string& filename); // Implemented in SDL2Renderer.cpp (uses stb_image)
|
bool loadFromFile(const std::string& filename); // Implemented in SDL2Renderer.cpp (uses stb_image)
|
||||||
bool saveToFile(const std::string& filename) const; // Implemented in SDL2Renderer.cpp (uses stb_image_write)
|
bool saveToFile(const std::string& filename) const; // Implemented in SDL2Renderer.cpp (uses stb_image_write)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,329 +0,0 @@
|
||||||
"""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)
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
"""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)
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue