Compare commits

...

56 Commits

Author SHA1 Message Date
John McCardle acef21593b workaround for gitea label API bugs 2025-11-03 10:59:56 -05:00
John McCardle 354107fc50 version bump for forgejo-mcp binary 2025-11-03 10:59:22 -05:00
John McCardle 8042630cca docs: add comprehensive Gitea label system documentation and MCP tool limitations
- Added complete label category breakdown (System, Priority, Type/Scope, Workflow)
- Documented all 22 labels with descriptions and usage guidelines
- Added example label combinations for common scenarios
- Documented MCP tool label application issues (see #131)
- Provided label ID reference for documentation purposes
- Strong recommendation to apply labels manually via web interface

Related to #131

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 09:23:05 -04:00
John McCardle 8f8b72da4a feat: auto-exit in --headless --exec mode when script completes
Closes #127

Previously, `./mcrogueface --headless --exec <script>` would hang
indefinitely after the script completed because the game loop ran
continuously. This required external timeouts and explicit mcrfpy.exit()
calls in every automation script.

This commit adds automatic exit detection for headless+exec mode:
- Added `auto_exit_after_exec` flag to McRogueFaceConfig
- Set flag automatically when both --headless and --exec are present
- Game loop exits when no timers remain (timers.empty())

Benefits:
- Documentation generation scripts work without explicit exit calls
- Testing scripts don't need timeout wrappers
- Clean process termination for automation
- Backward compatible (scripts with mcrfpy.exit() continue working)

Changes:
- src/McRogueFaceConfig.h: Add auto_exit_after_exec flag
- src/main.cpp: Set flag and recreate engine with modified config
- src/GameEngine.cpp: Check timers.empty() in game loop
- ROADMAP.md: Mark Phase 7 as complete (2025-10-30)
- CLAUDE.md: Add instruction about closing issues with commit messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 22:52:52 -04:00
John McCardle 4e94291cfb docs: Complete Phase 7 documentation system with parser fixes and man pages
Fixed critical documentation generation bugs and added complete multi-format
output support. All documentation now generates cleanly from MCRF_* macros.

## Parser Fixes (tools/generate_dynamic_docs.py)

Fixed parse_docstring() function:
- Added "Raises:" section support (was missing entirely)
- Fixed function name duplication in headings
  - Was: `createSoundBuffercreateSoundBuffer(...)`
  - Now: `createSoundBuffer(filename: str) -> int`
- Proper section separation between Returns and Raises
- Handles MCRF_* macro format correctly

Changes:
- Rewrote parse_docstring() to parse by section markers
- Fixed markdown generation (lines 514-539)
- Fixed HTML generation (lines 385-413, 446-473)
- Added "raises" field to parsed output dict

## Man Page Generation

New files:
- tools/generate_man_page.sh - Pandoc wrapper for man page generation
- docs/mcrfpy.3 - Unix man page (section 3 for library functions)

Uses pandoc with metadata:
- Section 3 (library functions)
- Git version tag in footer
- Current date in header

## Master Orchestration Script

New file: tools/generate_all_docs.sh

Single command generates all documentation formats:
- HTML API reference (docs/api_reference_dynamic.html)
- Markdown API reference (docs/API_REFERENCE_DYNAMIC.md)
- Unix man page (docs/mcrfpy.3)
- Type stubs (stubs/mcrfpy.pyi via generate_stubs_v2.py)

Includes error checking (set -e) and helpful output messages.

## Documentation Updates (CLAUDE.md)

Updated "Regenerating Documentation" section:
- Documents new ./tools/generate_all_docs.sh master script
- Lists all output files with descriptions
- Notes pandoc as system requirement
- Clarifies generate_stubs_v2.py is preferred (has @overload support)

## Type Stub Decision

Assessed generate_stubs.py vs generate_stubs_v2.py:
- generate_stubs.py has critical bugs (missing commas in method signatures)
- generate_stubs_v2.py produces high-quality manually-maintained stubs
- Decision: Keep v2, use it in master script

## Files Modified

Modified:
- CLAUDE.md (25 lines changed)
- tools/generate_dynamic_docs.py (121 lines changed)
- docs/api_reference_dynamic.html (359 lines changed)

Created:
- tools/generate_all_docs.sh (28 lines)
- tools/generate_man_page.sh (12 lines)
- docs/mcrfpy.3 (1070 lines)
- stubs/mcrfpy.pyi (532 lines)
- stubs/mcrfpy/__init__.pyi (213 lines)
- stubs/mcrfpy/automation.pyi (24 lines)
- stubs/py.typed (0 bytes)

Total: 2159 insertions, 225 deletions

## Testing

Verified:
- Man page viewable with `man docs/mcrfpy.3`
- No function name duplication in docs/API_REFERENCE_DYNAMIC.md
- Raises sections properly separated from Returns
- Master script successfully generates all formats

## Related Issues

Addresses requirements from Phase 7 documentation issues.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 21:20:50 -04:00
John McCardle 621d719c25 docs: Phase 3 - Convert 19 module functions to MCRF_FUNCTION macros
Converted all module-level functions in McRFPy_API.cpp to use the MCRF_*
documentation macro system:

Audio functions (7):
- createSoundBuffer, loadMusic, setMusicVolume, setSoundVolume
- playSound, getMusicVolume, getSoundVolume

Scene functions (5):
- sceneUI, currentScene, setScene, createScene, keypressScene

Timer functions (2):
- setTimer, delTimer

Utility functions (5):
- exit, setScale, find, findAll, getMetrics

Each function now uses:
- MCRF_SIG for signatures
- MCRF_DESC for descriptions
- MCRF_ARG for parameters
- MCRF_RETURNS for return values
- MCRF_RAISES for exceptions
- MCRF_NOTE for additional details

Phase 4 assessment: PyCallable.cpp and PythonObjectCache.cpp contain only
internal C++ implementation with no Python API to document.

All conversions tested and verified with test_phase3_docs.py.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 19:38:22 -04:00
John McCardle 29aa6e62be docs: convert Phase 2 classes to documentation macros (Animation, Window, SceneObject)
Converted 3 files to use MCRF_* documentation macros:
- PyAnimation.cpp: 5 methods + 5 properties
- PyWindow.cpp: 3 methods + 8 properties
- PySceneObject.cpp: 3 methods + 2 properties

All conversions build successfully. Enhanced descriptions with implementation details.

Note: PyScene.cpp has no exposed methods/properties, so no conversion needed.

Progress: Phase 1 (4 files) + Phase 2 (3 files) = 7 new classes complete

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 17:03:28 -04:00
John McCardle 67aba5ef1f docs: convert Phase 1 classes to documentation macros (Color, Font, Texture, Timer)
Converted 4 files to use MCRF_* documentation macros:
- PyColor.cpp: 3 methods + 4 properties
- PyFont.cpp: 2 properties (read-only)
- PyTexture.cpp: 6 properties (read-only)
- PyTimer.cpp: 4 methods + 7 properties

All conversions verified with test_phase1_docs.py - 0 placeholders.
Documentation regenerated with enhanced descriptions.

Progress: 11/12 class files complete (92%)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 16:59:40 -04:00
John McCardle 6aa4625b76 fix: correct module docstring newline escaping
Fixed module-level docstring in PyModuleDef where double-backslash newlines
(\\n) were appearing as literal "\n" text in help(mcrfpy) output.

Changed from escaped newlines (\\n) to actual newlines (\n) so the C compiler
interprets them correctly.

Before: help(mcrfpy) showed "McRogueFace Python API\\n\\nCore game..."
After:  help(mcrfpy) shows proper formatting with line breaks

The issue was in the PyDoc_STR() macro call - it doesn't interpret escape
sequences, so the string literal itself needs to have proper newlines.
2025-10-30 15:57:17 -04:00
John McCardle 4c61bee512 docs: update CLAUDE.md with MCRF_* macro documentation system
Updated documentation guidelines to reflect the new macro-based system:
- Documented MCRF_METHOD and MCRF_PROPERTY usage
- Listed all available macros (MCRF_SIG, MCRF_DESC, MCRF_ARG, etc.)
- Added prose guidelines (concise C++, verbose external docs)
- Updated regeneration workflow (removed references to deleted scripts)
- Emphasized single source of truth and zero-drift architecture

Removed references to obsolete hardcoded documentation scripts that were
deleted in previous commits.

Related: #92 (Inline C++ documentation system)
2025-10-30 12:37:04 -04:00
John McCardle cc80964835 fix: update child class property overrides to use MCRF_PROPERTY macros
Fixes critical issue discovered in code review where PyDrawable property
docstrings were being overridden by child classes, making enhanced documentation
invisible to users.

Updated files:
- src/UIBase.h: UIDRAWABLE_GETSETTERS macro (visible, opacity)
- src/UIFrame.cpp: click and z_index properties
- src/UISprite.cpp: click and z_index properties
- src/UICaption.cpp: click and z_index properties
- src/UIGrid.cpp: click and z_index properties

All four UI class hierarchies (Frame, Sprite, Caption, Grid) now expose
consistent, enhanced property documentation to Python users.

Verification:
- tools/test_child_class_docstrings.py: All 16 property tests pass
- All 4 properties (click, z_index, visible, opacity) match across all 4 classes

Related: #92 (Inline C++ documentation system)
2025-10-30 12:33:27 -04:00
John McCardle 326b692908 feat: convert PyDrawable properties to documentation macros
All Drawable properties (click, z_index, visible, opacity) now
use MCRF_PROPERTY with enhanced descriptions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 12:22:00 -04:00
John McCardle dda5305256 feat: convert PyDrawable methods to documentation macros
Converts get_bounds, move, and resize to MCRF_METHOD.
These are inherited by all UI classes (Frame, Caption, Sprite, Grid).

Updated both PyDrawable.cpp and UIBase.h (UIDRAWABLE_METHODS macro).
All method docstrings now include complete Args, Returns, and Note sections.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 12:06:37 -04:00
John McCardle 1f6175bfa5 refactor: remove obsolete documentation generators
Removes ~3,979 lines of hardcoded Python documentation dictionaries.
Documentation is now generated from C++ docstrings via macros.

Deleted:
- generate_complete_api_docs.py (959 lines)
- generate_complete_markdown_docs.py (820 lines)
- generate_api_docs.py (481 lines)
- generate_api_docs_simple.py (118 lines)
- generate_api_docs_html.py (1,601 lines)

Addresses issue #97.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 11:51:21 -04:00
John McCardle 7f253da581 fix: escape HTML in descriptions before link transformation
Fixes HTML injection vulnerability in generate_dynamic_docs.py where
description text was not HTML-escaped before being inserted into HTML
output. Special characters like <, >, & could be interpreted as HTML.

Changes:
- Modified transform_doc_links() to escape all non-link text when
  format='html' or format='web'
- Link text and hrefs are also properly escaped
- Non-HTML formats (markdown, python) remain unchanged
- Added proper handling for descriptions with mixed plain text and links

The fix splits docstrings into link and non-link segments, escapes
non-link segments, and properly escapes content within link patterns.

Tested with comprehensive test suite covering:
- Basic HTML special characters
- Special chars with links
- Special chars in link text
- Multiple links with special chars

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 11:48:09 -04:00
John McCardle fac6a9a457 feat: add link transformation to documentation generator
Adds transform_doc_links() function that converts MCRF_LINK patterns
to appropriate format (HTML links, Markdown links, or plain text).
Addresses issue #97.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 11:39:54 -04:00
John McCardle a8a257eefc feat: convert PyVector properties to use macros
Properties x and y now use MCRF_PROPERTY for consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 11:33:49 -04:00
John McCardle 07e8207a08 feat: complete PyVector documentation macro conversion
All Vector methods now use MCRF_METHOD macros with complete
documentation including Args, Returns, and Notes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 11:27:50 -04:00
John McCardle 23d7882b93 fix: correct normalize() documentation to match implementation
The normalize() method's implementation returns a zero vector when
called on a zero-magnitude vector, rather than raising ValueError as
the documentation claimed. Updated the MCRF_RAISES to MCRF_NOTE to
accurately describe the actual behavior.

Also added test coverage in tools/test_vector_docs.py to verify the
normalize() docstring contains the correct Note section.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 11:25:43 -04:00
John McCardle 91461d0f87 feat: convert PyVector to use documentation macros
Converts magnitude, normalize, and dot methods to MCRF_METHOD macro.
Docstrings now include complete Args/Returns/Raises sections.
Addresses issue #92.
2025-10-30 11:20:48 -04:00
John McCardle a08003bda4 feat: add documentation macro system header
Adds C++ preprocessor macros for consistent API documentation.
Addresses issue #92.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 11:16:44 -04:00
John McCardle e41f83a5b3 docs: Complete wiki migration and issue labeling system
This commit completes a comprehensive documentation migration initiative
that transforms McRogueFace's documentation from scattered markdown files
into a structured, navigable wiki with systematic issue organization.

## Wiki Content Created (20 pages)

**Navigation & Indices:**
- Home page with 3 entry points (by system, use-case, workflow)
- Design Proposals index
- Issue Roadmap (46 issues organized by tier/system)

**System Documentation (5 pages):**
- Grid System (3-layer architecture: Visual/World/Perspective)
- Animation System (property-based, 24+ easing functions)
- Python Binding System (C++/Python integration patterns)
- UI Component Hierarchy (UIDrawable inheritance tree)
- Performance and Profiling (ScopedTimer, F3 overlay)

**Workflow Guides (3 pages):**
- Adding Python Bindings (step-by-step tutorial)
- Performance Optimization Workflow (profile → optimize → verify)
- Writing Tests (direct execution vs game loop tests)

**Use-Case Documentation (5 pages):**
- Entity Management
- Rendering
- AI and Pathfinding
- Input and Events
- Procedural Generation

**Grid System Deep Dives (3 pages):**
- Grid Rendering Pipeline (4-stage process)
- Grid-TCOD Integration (FOV, pathfinding)
- Grid Entity Lifecycle (5 states, memory management)

**Strategic Documentation (2 pages):**
- Proposal: Next-Gen Grid-Entity System (consolidated from 3 files)
- Strategic Direction (extracted from FINAL_RECOMMENDATIONS.md)

## Issue Organization System

Created 14 new labels across 3 orthogonal dimensions:

**System Labels (8):**
- system:grid, system:animation, system:python-binding
- system:ui-hierarchy, system:performance, system:rendering
- system:input, system:documentation

**Priority Labels (3):**
- priority:tier1-active (18 issues) - Critical path to v1.0
- priority:tier2-foundation (11 issues) - Important but not blocking
- priority:tier3-future (17 issues) - Deferred until after v1.0

**Workflow Labels (3):**
- workflow:blocked - Waiting on dependencies
- workflow:needs-benchmark - Needs performance testing
- workflow:needs-documentation - Needs docs before/after implementation

All 46 open issues now labeled with appropriate system/priority/workflow tags.

## Documentation Updates

**README.md:**
- Updated Documentation section to reference Gitea wiki
- Added key wiki page links (Home, Grid System, Python Binding, etc.)
- Updated Contributing section with issue tracking information
- Documented label taxonomy and Issue Roadmap

**Analysis Files:**
- Moved 17 completed analysis files to .archive/ directory:
  - EVAL_*.md (5 files) - Strategic analysis
  - TOPICS_*.md (4 files) - Task analysis
  - NEXT_GEN_GRIDS_ENTITIES_*.md (3 files) - Design proposals
  - FINAL_RECOMMENDATIONS.md, MASTER_TASK_SCHEDULE.md
  - PROJECT_THEMES_ANALYSIS.md, ANIMATION_FIX_IMPLEMENTATION.md
  - compass_artifact_*.md - Research artifacts

## Benefits

This migration provides:
1. **Agent-friendly documentation** - Structured for LLM context management
2. **Multiple navigation paths** - By system, use-case, or workflow
3. **Dense cross-referencing** - Wiki pages link to related content
4. **Systematic issue organization** - Filterable by system AND priority
5. **Living documentation** - Wiki can evolve with the codebase
6. **Clear development priorities** - Tier 1/2/3 system guides focus

Wiki URL: https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki

🤖 Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 20:54:55 -04:00
John McCardle 5205b5d7cd docs: Add Gitea-first workflow guidelines to project documentation
Establish Gitea as the single source of truth for issue tracking,
documentation, and project management to improve development efficiency.

CLAUDE.md changes:
- Add comprehensive "Gitea-First Workflow" section at top of file
- Document 5 core principles for using Gitea effectively
- Provide workflow pattern diagram for development process
- List available Gitea MCP tools for programmatic access
- Explain benefits: reduced context switching, better planning, living docs

ROADMAP.md changes:
- Add "Development Workflow" section referencing Gitea-first approach
- Include 5-step checklist for starting any work
- Link to detailed workflow guidelines in CLAUDE.md
- Emphasize Gitea as single source of truth

Workflow principles:
1. Always check Gitea issues/wiki before starting work
2. Create granular, focused issues for new features/problems
3. Document as you go - update related issues when work affects them
4. If docs mislead, create task to correct/expand them
5. Cross-reference everything - commits, issues, wiki pages

Benefits:
- Avoid re-reading entire codebase by consulting brief issue descriptions
- Reduce duplicate or contradictory work through better planning
- Maintain living documentation that stays current
- Capture historical context and decision rationale
- Improve efficiency using MCP tools for programmatic queries

This establishes best practices for keeping the project organized and
reducing cognitive load during development.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 00:56:21 -04:00
John McCardle 3c20a6be50 docs: Streamline ROADMAP.md and defer to Gitea issue tracking
Removed stale data and duplicate tracking from ROADMAP.md to establish
Gitea as the single source of truth for issue tracking.

Changes:
- Removed outdated urgent priorities from July 2025 (now October)
- Removed extensive checkbox task lists that duplicate Gitea issues
- Removed "Recent Achievements" changelog (use git log instead)
- Removed dated commentary and out-of-sync issue statuses
- Streamlined from 936 lines to 207 lines (~78% reduction)

Kept strategic content:
- Engine philosophy and architecture goals
- Three-layer grid architecture decisions
- Performance optimization patterns
- Development phase summaries with Gitea issue references
- Future vision: Pure Python extension architecture
- Resource links to Gitea issue tracker

The roadmap now focuses on strategic vision and architecture decisions,
while deferring all task tracking, bug reports, and current priorities
to the Gitea issue tracker.

Related: All issue status tracking moved to Gitea
See: https://gamedev.ffwf.net/gitea/john/McRogueFace/issues

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 00:46:17 -04:00
John McCardle e9e9cd2f81 feat: Add comprehensive profiling system with F3 overlay
Add real-time performance profiling infrastructure to monitor frame times,
render performance, and identify bottlenecks.

Features:
- Profiler.h: ScopedTimer RAII helper for automatic timing measurements
- ProfilerOverlay: F3-togglable overlay displaying real-time metrics
- Detailed timing breakdowns: grid rendering, entity rendering, FOV,
  Python callbacks, and animation updates
- Per-frame counters: cells rendered, entities rendered, draw calls
- Performance color coding: green (<16ms), yellow (<33ms), red (>33ms)
- Benchmark suite: static grid and moving entities performance tests

Integration:
- GameEngine: Integrated profiler overlay with F3 toggle
- UIGrid: Added timing instrumentation for grid and entity rendering
- Metrics tracked in ProfilingMetrics struct with 60-frame averaging

Usage:
- Press F3 in-game to toggle profiler overlay
- Run benchmarks with tests/benchmark_*.py scripts
- ScopedTimer automatically measures code block execution time

This addresses issue #104 (Basic profiling/metrics).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 00:45:44 -04:00
John McCardle 8153fd2503 Merge branch 'rogueliketutorial25' - TCOD Tutorial Implementation
This merge brings in the complete TCOD-style tutorial implementation
for McRogueFace, demonstrating the engine as a viable alternative to
python-tcod for roguelike game development.

Key additions:
- Tutorial parts 0-6 with full documentation
- EntityCollection.remove() API improvement (object-based vs index-based)
- Development tooling scripts (test runner, issue tracking)
- Complete API reference documentation

Tutorial follows "forward-only" philosophy where each step builds
on previous work without requiring refactoring, making it more
accessible for beginners.

This work represents 2+ months of development (July-August 2025)
focused on validating McRogueFace's educational value and TCOD
compatibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 13:19:50 -04:00
John McCardle 8b7ea544dd docs: Add complete API reference documentation
Add comprehensive HTML API reference documentation covering
all McRogueFace Python API components, methods, and properties.

This documentation was generated from the C++ inline docstrings
and provides complete reference material for engine users.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 13:19:36 -04:00
John McCardle 3a9f76d850 feat: Add development tooling scripts
Add utility scripts for development workflow:
- tests/run_all_tests.sh: Test runner script for automated testing
- tools/gitea_issues.py: Issue tracking integration tool

These support the development and testing workflow for McRogueFace.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 13:19:25 -04:00
John McCardle 10610db86e feat: Add tutorial Python implementations
Add Python code for tutorial parts 0-6:
- part_0.py: Initial setup and character rendering
- part_1.py, part_1b.py: Movement systems
- part_2.py variants: Movement with naive, queued, and final implementations
- part_3.py: Dungeon generation with BSP
- part_4.py: Field of view implementation
- part_5.py: Enemy entities and basic interaction
- part_6.py: Combat mechanics
- _generated_part_5.py: Machine-generated draft for reference

These implementations demonstrate McRogueFace capabilities
and serve as foundation for tutorial documentation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 13:18:56 -04:00
John McCardle a76ebcd05a feat: Add tutorial parts 0-6 with documentation
Add working tutorial implementations covering:
- Part 0: Basic setup and character display
- Part 1: Movement and grid interaction
- Part 2: Movement variations (naive, queued, final)
- Part 3: Dungeon generation
- Part 4: Field of View
- Part 5: Entities and interactions
- Part 6: Combat system

Each part includes corresponding README with explanations.
Implementation plan document included for parts 6-8.

Tutorial follows "forward-only" philosophy - each step builds
on previous without requiring refactoring.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 13:18:45 -04:00
John McCardle 327da3622a feat: Change EntityCollection.remove() to accept Entity objects
Previously, EntityCollection.remove() required an integer index, which was
inconsistent with Python's list.remove(item) behavior and the broader
Python ecosystem conventions.

Changes:
- remove() now accepts Entity object directly instead of index
- Searches collection by comparing C++ shared_ptr identity
- Raises ValueError if entity not found in collection
- More Pythonic API matching Python's list.remove() semantics

This aligns with Issue #73 and improves API consistency across the
collection system.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 13:17:45 -04:00
John McCardle 1149111f2d Scary better enemies for part 6 with Djikstra, and runs smoother without all the line checks 2025-07-29 23:09:06 -04:00
John McCardle 002c3d3382 Animated Turn based movement (Tutorial part 6) 2025-07-29 22:27:37 -04:00
John McCardle 0938a53c4a Tutorial part 4 and 5 2025-07-29 21:24:21 -04:00
John McCardle 994e8d186e feat: Add Part 5 tutorial - Entity Interactions
Implements comprehensive entity interaction system:
- Entity class hierarchy inheriting from mcrfpy.Entity
- Non-blocking movement animations with destination tracking
- Bump interactions (combat when hitting enemies, pushing boulders)
- Step-on interactions (buttons that open doors)
- Basic enemy AI with line-of-sight pursuit
- Concurrent animation system (enemies move while player moves)

Also fixes C++ animation system to support Python subclasses:
- Changed PyAnimation::start() to use PyObject_IsInstance instead of strcmp
- Now properly supports inherited entity classes
- Animation system works with any subclass of Frame, Caption, Sprite, Grid, or Entity

This completes the core gameplay mechanics needed for roguelike development.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 00:21:58 -04:00
John McCardle 7aef412343 feat: Thread-safe FOV system with improved API
Major improvements to the Field of View (FOV) system:

1. Added thread safety with mutex protection
   - Added mutable std::mutex fov_mutex to UIGrid class
   - Protected computeFOV() and isInFOV() with lock_guard
   - Minimal overhead for current single-threaded operation
   - Ready for future multi-threading requirements

2. Enhanced compute_fov() API to return visible cells
   - Changed return type from void to List[Tuple[int, int, bool, bool]]
   - Returns (x, y, visible, discovered) for all visible cells
   - Maintains backward compatibility by still updating internal FOV state
   - Allows FOV queries without affecting entity states

3. Fixed Part 4 tutorial visibility rendering
   - Added required entity.update_visibility() calls after compute_fov()
   - Fixed black grid issue in perspective rendering
   - Updated hallway generation to use L-shaped corridors

The architecture now properly separates concerns while maintaining
performance and preparing for future enhancements. Each entity can
have independent FOV calculations without race conditions.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 23:00:34 -04:00
John McCardle b5eab85e70 Convert UIGrid perspective from index to weak_ptr<UIEntity>
Major refactor of the perspective system to use entity references instead of indices:

- Replaced `int perspective` with `std::weak_ptr<UIEntity> perspective_entity`
- Added `bool perspective_enabled` flag for explicit control
- Direct entity assignment: `grid.perspective = player`
- Automatic cleanup when entity is destroyed (weak_ptr becomes invalid)
- No issues with collection reordering or entity removal
- PythonObjectCache integration preserves Python derived classes

API changes:
- Old: `grid.perspective = 0` (index), `-1` for omniscient
- New: `grid.perspective = entity` (object), `None` to clear
- New: `grid.perspective_enabled` controls rendering mode

Three rendering states:
1. `perspective_enabled = False`: Omniscient view (default)
2. `perspective_enabled = True` with valid entity: Entity's FOV
3. `perspective_enabled = True` with invalid entity: All black

Also includes:
- Part 3: Procedural dungeon generation with libtcod.line()
- Part 4: Field of view with entity perspective switching

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 23:47:21 -04:00
John McCardle bd6407db29 hotfix: bad documentation links... ...because of trailing slash?! 2025-07-17 23:49:03 -04:00
John McCardle f4343e1e82 Squashed commit of the following: [alpha_presentable]
Author: John McCardle <mccardle.john@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>

commit dc47f2474c7b2642d368f9772894aed857527807
    the UIEntity rant

commit 673ca8e1b089ea670257fc04ae1a676ed95a40ed
    I forget when these tests were written, but I want them in the squash merge

commit 70c71565c684fa96e222179271ecb13a156d80ad
    Fix UI object segfault by switching from managed to manual weakref management

    The UI types (Frame, Caption, Sprite, Grid, Entity) were using
    Py_TPFLAGS_MANAGED_WEAKREF while also trying to manually create weakrefs
    for the PythonObjectCache. This is fundamentally incompatible - when
    Python manages weakrefs internally, PyWeakref_NewRef() cannot access the
    weakref list properly, causing segfaults.

    Changed all UI types to use manual weakref management (like PyTimer):
    - Restored weakreflist field in all UI type structures
    - Removed Py_TPFLAGS_MANAGED_WEAKREF from all UI type flags
    - Added tp_weaklistoffset for all UI types in module initialization
    - Initialize weakreflist=NULL in tp_new and init methods
    - Call PyObject_ClearWeakRefs() in dealloc functions

    This allows the PythonObjectCache to continue working correctly,
    maintaining Python object identity for C++ objects across the boundary.

    Fixes segfault when creating UI objects (e.g., Caption, Grid) that was
    preventing tutorial scripts from running.

This is the bulk of the required behavior for Issue #126.
that issure isn't ready for closure yet; several other sub-issues left.
    closes #110
    mention issue #109 - resolves some __init__ related nuisances

commit 3dce3ec539ae99e32d869007bf3f49d03e4e2f89
    Refactor timer system for cleaner architecture and enhanced functionality

    Major improvements to the timer system:
    - Unified all timer logic in the Timer class (C++)
    - Removed PyTimerCallable subclass, now using PyCallable directly
    - Timer objects are now passed to callbacks as first argument
    - Added 'once' parameter for one-shot timers that auto-stop
    - Implemented proper PythonObjectCache integration with weakref support

    API enhancements:
    - New callback signature: callback(timer, runtime) instead of just (runtime)
    - Timer objects expose: name, interval, remaining, paused, active, once properties
    - Methods: pause(), resume(), cancel(), restart()
    - Comprehensive documentation with examples
    - Enhanced repr showing timer state (active/paused/once/remaining time)

    This cleanup follows the UIEntity/PyUIEntity pattern and makes the timer
    system more Pythonic while maintaining backward compatibility through
    the legacy setTimer/delTimer API.

    closes #121

commit 145834cfc31b8dabc4cb3591b9cb4ed99fc8b964
    Implement Python object cache to preserve derived types in collections

    Add a global cache system that maintains weak references to Python objects,
    ensuring that derived Python classes maintain their identity when stored in
    and retrieved from C++ collections.

    Key changes:
    - Add PythonObjectCache singleton with serial number system
    - Each cacheable object (UIDrawable, UIEntity, Timer, Animation) gets unique ID
    - Cache stores weak references to prevent circular reference memory leaks
    - Update all UI type definitions to support weak references (Py_TPFLAGS_MANAGED_WEAKREF)
    - Enable subclassing for all UI types (Py_TPFLAGS_BASETYPE)
    - Collections check cache before creating new Python wrappers
    - Register objects in cache during __init__ methods
    - Clean up cache entries in C++ destructors

    This ensures that Python code like:
    ```python
    class MyFrame(mcrfpy.Frame):
        def __init__(self):
            super().__init__()
            self.custom_data = "preserved"

    frame = MyFrame()
    scene.ui.append(frame)
    retrieved = scene.ui[0]  # Same MyFrame instance with custom_data intact
    ```

    Works correctly, with retrieved maintaining the derived type and custom attributes.

    Closes #112

commit c5e7e8e298
    Update test demos for new Python API and entity system

    - Update all text input demos to use new Entity constructor signature
    - Fix pathfinding showcase to work with new entity position handling
    - Remove entity_waypoints tracking in favor of simplified movement
    - Delete obsolete exhaustive_api_demo.py (superseded by newer demos)
    - Adjust entity creation calls to match Entity((x, y), texture, sprite_index) pattern

commit 6d29652ae7
    Update animation demo suite with crash fixes and improvements

    - Add warnings about AnimationManager segfault bug in sizzle_reel_final.py
    - Create sizzle_reel_final_fixed.py that works around the crash by hiding objects instead of removing them
    - Increase font sizes for better visibility in demos
    - Extend demo durations for better showcase of animations
    - Remove debug prints from animation_sizzle_reel_working.py
    - Minor cleanup and improvements to all animation demos

commit a010e5fa96
    Update game scripts for new Python API

    - Convert entity position access from tuple to x/y properties
    - Update caption size property to font_size
    - Fix grid boundary checks to use grid_size instead of exceptions
    - Clean up demo timer on menu exit to prevent callbacks

    These changes adapt the game scripts to work with the new standardized
    Python API constructors and property names.

commit 9c8d6c4591
    Fix click event z-order handling in PyScene

    Changed click detection to properly respect z-index by:
    - Sorting ui_elements in-place when needed (same as render order)
    - Using reverse iterators to check highest z-index elements first
    - This ensures top-most elements receive clicks before lower ones

commit dcd1b0ca33
    Add roguelike tutorial implementation files

    Implement Parts 0-2 of the classic roguelike tutorial adapted for McRogueFace:
    - Part 0: Basic grid setup and tile rendering
    - Part 1: Drawing '@' symbol and basic movement
    - Part 1b: Variant with sprite-based player
    - Part 2: Entity system and NPC implementation with three movement variants:
      - part_2.py: Standard implementation
      - part_2-naive.py: Naive movement approach
      - part_2-onemovequeued.py: Queued movement system

    Includes tutorial assets:
    - tutorial2.png: Tileset for dungeon tiles
    - tutorial_hero.png: Player sprite sheet

commit 6813fb5129
    Standardize Python API constructors and remove PyArgHelpers

    - Remove PyArgHelpers.h and all macro-based argument parsing
    - Convert all UI class constructors to use PyArg_ParseTupleAndKeywords
    - Standardize constructor signatures across UICaption, UIEntity, UIFrame, UIGrid, and UISprite
    - Replace PYARGHELPER_SINGLE/MULTI macros with explicit argument parsing
    - Improve error messages and argument validation
    - Maintain backward compatibility with existing Python code

    This change improves code maintainability and consistency across the Python API.

commit 6f67fbb51e
    Fix animation callback crashes from iterator invalidation (#119)

    Resolved segfaults caused by creating new animations from within
    animation callbacks. The issue was iterator invalidation in
    AnimationManager::update() when callbacks modified the active
    animations vector.

    Changes:
    - Add deferred animation queue to AnimationManager
    - New animations created during update are queued and added after
    - Set isUpdating flag to track when in update loop
    - Properly handle Animation destructor during callback execution
    - Add clearCallback() method for safe cleanup scenarios

    This fixes the "free(): invalid pointer" and "malloc(): unaligned
    fastbin chunk detected" errors that occurred with rapid animation
    creation in callbacks.

commit eb88c7b3aa
    Add animation completion callbacks (#119)

    Implement callbacks that fire when animations complete, enabling direct
    causality between animation end and game state changes. This eliminates
    race conditions from parallel timer workarounds.

    - Add optional callback parameter to Animation constructor
    - Callbacks execute synchronously when animation completes
    - Proper Python reference counting with GIL safety
    - Callbacks receive (anim, target) parameters (currently None)
    - Exception handling prevents crashes from Python errors

    Example usage:
    ```python
    def on_complete(anim, target):
        player_moving = False

    anim = mcrfpy.Animation("x", 300.0, 1.0, "easeOut", callback=on_complete)
    anim.start(player)
    ```

    closes #119

commit 9fb428dd01
    Update ROADMAP with GitHub issue numbers (#111-#125)

    Added issue numbers from GitHub tracker to roadmap items:
    - #111: Grid Click Events Broken in Headless
    - #112: Object Splitting Bug (Python type preservation)
    - #113: Batch Operations for Grid
    - #114: CellView API
    - #115: SpatialHash Implementation
    - #116: Dirty Flag System
    - #117: Memory Pool for Entities
    - #118: Scene as Drawable
    - #119: Animation Completion Callbacks
    - #120: Animation Property Locking
    - #121: Timer Object System
    - #122: Parent-Child UI System
    - #123: Grid Subgrid System
    - #124: Grid Point Animation
    - #125: GitHub Issues Automation

    Also updated existing references:
    - #101/#110: Constructor standardization
    - #109: Vector class indexing

    Note: Tutorial-specific items and Python-implementable features
    (input queue, collision reservation) are not tracked as engine issues.

commit 062e4dadc4
    Fix animation segfaults with RAII weak_ptr implementation

    Resolved two critical segmentation faults in AnimationManager:
    1. Race condition when creating multiple animations in timer callbacks
    2. Exit crash when animations outlive their target objects

    Changes:
    - Replace raw pointers with std::weak_ptr for automatic target invalidation
    - Add Animation::complete() to jump animations to final value
    - Add Animation::hasValidTarget() to check if target still exists
    - Update AnimationManager to auto-remove invalid animations
    - Add AnimationManager::clear() call to GameEngine::cleanup()
    - Update Python bindings to pass shared_ptr instead of raw pointers

    This ensures animations can never reference destroyed objects, following
    proper RAII principles. Tested with sizzle_reel_final.py and stress
    tests creating/destroying hundreds of animated objects.

commit 98fc49a978
    Directory structure cleanup and organization overhaul
2025-07-15 21:30:49 -04:00
John McCardle 1a143982e1 hotfix: Windows build, no longer console mode 2025-07-10 17:01:03 -04:00
John McCardle 234551b9fd hotfix: findows build 2025-07-10 16:50:42 -04:00
John McCardle 93a55c6468 hotfix: windows build fixes 2025-07-10 16:43:05 -04:00
John McCardle 96857a41c6 hotfix: windows build, fresh docs 2025-07-10 16:34:46 -04:00
John McCardle 4144cdf067 draft lessons 2025-07-10 00:14:56 -04:00
John McCardle 665689c550 hotfix: Windows build attempt 2025-07-09 23:33:09 -04:00
John McCardle d11f76ac43 Squashed commit of 53 Commits: [alpha_streamline_2]
* Field of View, Pathing courtesy of libtcod
* python-tcod emulation at `mcrfpy.libtcod` - partial implementation
* documentation, tutorial drafts: in middling to good shape

┌────────────┬────────────────────┬───────────┬────────────┬───────────────┬────────────────┬────────────────┬─────────────┐
│ Date       │ Models             │     Input │     Output │  Cache Create │     Cache Read │   Total Tokens │  Cost (USD) │
├────────────┼────────────────────┼───────────┼────────────┼───────────────┼────────────────┼────────────────┼─────────────┤
│ 2025-07-05 │ - opus-4           │    13,630 │    159,500 │     3,854,900 │     84,185,034 │     88,213,064 │     $210.72 │
├────────────┼────────────────────┼───────────┼────────────┼───────────────┼────────────────┼────────────────┼─────────────┤
│ 2025-07-06 │ - opus-4           │     5,814 │    113,190 │     4,242,407 │    150,191,183 │    154,552,594 │     $313.41 │
├────────────┼────────────────────┼───────────┼────────────┼───────────────┼────────────────┼────────────────┼─────────────┤
│ 2025-07-07 │ - opus-4           │     7,244 │    104,599 │     3,894,453 │     81,781,179 │     85,787,475 │     $184.46 │
│            │ - sonnet-4         │           │            │               │                │                │             │
├────────────┼────────────────────┼───────────┼────────────┼───────────────┼────────────────┼────────────────┼─────────────┤
│ 2025-07-08 │ - opus-4           │    50,312 │    158,599 │     5,021,189 │     60,028,561 │     65,258,661 │     $167.05 │
│            │ - sonnet-4         │           │            │               │                │                │             │
├────────────┼────────────────────┼───────────┼────────────┼───────────────┼────────────────┼────────────────┼─────────────┤
│ 2025-07-09 │ - opus-4           │     6,311 │    109,653 │     4,171,140 │     80,092,875 │     84,379,979 │     $193.09 │
│            │ - sonnet-4         │           │            │               │                │                │             │
└────────────┴────────────────────┴───────────┴────────────┴───────────────┴────────────────┴────────────────┴─────────────┘

    🤖 Generated with [Claude Code](https://claude.ai/code)
    Co-Authored-By: Claude <noreply@anthropic.com>

Author: John McCardle <mccardle.john@gmail.com>

    Draft tutorials

Author: John McCardle <mccardle.john@gmail.com>

    docs: update ROADMAP with FOV, A* pathfinding, and GUI text widget completions

    - Mark TCOD Integration Sprint as complete
    - Document FOV system with perspective rendering implementation
    - Update UIEntity pathfinding status to complete with A* and caching
    - Add comprehensive achievement entry for July 10 work
    - Reflect current engine capabilities accurately

    The engine now has all core roguelike mechanics:
    - Field of View with per-entity visibility
    - Pathfinding (both Dijkstra and A*)
    - Text input for in-game consoles
    - Performance optimizations throughout

Author: John McCardle <mccardle.john@gmail.com>

    feat(engine): implement perspective FOV, pathfinding, and GUI text widgets

    Major Engine Enhancements:
    - Complete FOV (Field of View) system with perspective rendering
      - UIGrid.perspective property for entity-based visibility
      - Three-layer overlay colors (unexplored, explored, visible)
      - Per-entity visibility state tracking
      - Perfect knowledge updates only for explored areas

    - Advanced Pathfinding Integration
      - A* pathfinding implementation in UIGrid
      - Entity.path_to() method for direct pathfinding
      - Dijkstra maps for multi-target pathfinding
      - Path caching for performance optimization

    - GUI Text Input Widgets
      - TextInputWidget class with cursor, selection, scrolling
      - Improved widget with proper text rendering and input handling
      - Example showcase of multiple text input fields
      - Foundation for in-game console and chat systems

    - Performance & Architecture Improvements
      - PyTexture copy operations optimized
      - GameEngine update cycle refined
      - UIEntity property handling enhanced
      - UITestScene modernized

    Test Suite:
    - Interactive visibility demos showing FOV in action
    - Pathfinding comparison (A* vs Dijkstra)
    - Debug utilities for visibility and empty path handling
    - Sizzle reel demo combining pathfinding and vision
    - Multiple text input test scenarios

    This commit brings McRogueFace closer to a complete roguelike engine
    with essential features like line-of-sight, intelligent pathfinding,
    and interactive text input capabilities.

Author: John McCardle <mccardle.john@gmail.com>

    feat(demos): enhance interactive pathfinding demos with entity.path_to()

    - dijkstra_interactive_enhanced.py: Animation along paths with smooth movement
      - M key to start movement animation
      - P to pause/resume
      - R to reset positions
      - Visual path gradient for better clarity

    - pathfinding_showcase.py: Advanced multi-entity behaviors
      - Chase mode: enemies pursue player
      - Flee mode: enemies avoid player
      - Patrol mode: entities follow waypoints
      - WASD player movement
      - Dijkstra distance field visualization (D key)
      - Larger dungeon map with multiple rooms

    - Both demos use new entity.path_to() method
    - Smooth interpolated movement animations
    - Real-time pathfinding recalculation
    - Comprehensive test coverage

    These demos showcase the power of integrated pathfinding for game AI.

Author: John McCardle <mccardle.john@gmail.com>

    feat(entity): implement path_to() method for entity pathfinding

    - Add path_to(target_x, target_y) method to UIEntity class
    - Uses existing Dijkstra pathfinding implementation from UIGrid
    - Returns list of (x, y) coordinate tuples for complete path
    - Supports both positional and keyword argument formats
    - Proper error handling for out-of-bounds and no-grid scenarios
    - Comprehensive test suite covering normal and edge cases

    Part of TCOD integration sprint - gives entities immediate pathfinding capabilities.

Author: John McCardle <mccardle.john@gmail.com>

    docs: update roadmap with Dijkstra pathfinding progress

    - Mark UIGrid TCOD Integration as completed
    - Document critical PyArg bug fix achievement
    - Update UIEntity Pathfinding to 50% complete
    - Add detailed progress notes for July 9 sprint work

Author: John McCardle <mccardle.john@gmail.com>

    feat(tcod): complete Dijkstra pathfinding implementation with critical PyArg fix

    - Add complete Dijkstra pathfinding to UIGrid class
      - compute_dijkstra(), get_dijkstra_distance(), get_dijkstra_path()
      - Full TCODMap and TCODDijkstra integration
      - Proper memory management in constructors/destructors

    - Create mcrfpy.libtcod submodule with Python bindings
      - dijkstra_compute(), dijkstra_get_distance(), dijkstra_get_path()
      - line() function for drawing corridors
      - Foundation for future FOV and pathfinding algorithms

    - Fix critical PyArg bug in UIGridPoint color setter
      - PyObject_to_sfColor() now handles both Color objects and tuples
      - Prevents "SystemError: new style getargs format but argument is not a tuple"
      - Proper error handling and exception propagation

    - Add comprehensive test suite
      - test_dijkstra_simple.py validates all pathfinding operations
      - dijkstra_test.py for headless testing with screenshots
      - dijkstra_interactive.py for full user interaction demos

    - Consolidate and clean up test files
      - Removed 6 duplicate/broken demo attempts
      - Two clean versions: headless test + interactive demo

    Part of TCOD integration sprint for RoguelikeDev Tutorial Event.

Author: John McCardle <mccardle.john@gmail.com>

    Roguelike Tutorial Planning + Prep

Author: John McCardle <mccardle.john@gmail.com>

    feat(docs): complete markdown API documentation export

    - Created comprehensive markdown documentation matching HTML completeness
    - Documented all 75 functions, 20 classes, 56 methods, and 20 automation methods
    - Zero ellipsis instances - complete coverage with no missing documentation
    - Added proper markdown formatting with code blocks and navigation
    - Included full parameter documentation, return values, and examples

    Key features:
    - 23KB GitHub-compatible markdown documentation
    - 47 argument sections with detailed parameters
    - 35 return value specifications
    - 23 code examples with syntax highlighting
    - 38 explanatory notes and 10 exception specifications
    - Full table of contents with anchor links
    - Professional markdown formatting

    Both export formats now available:
    - HTML: docs/api_reference_complete.html (54KB, rich styling)
    - Markdown: docs/API_REFERENCE_COMPLETE.md (23KB, GitHub-compatible)

Author: John McCardle <mccardle.john@gmail.com>

    feat(docs): complete API documentation with zero missing methods

    - Eliminated ALL ellipsis instances (0 remaining)
    - Documented 40 functions with complete signatures and examples
    - Documented 21 classes with full method and property documentation
    - Added 56 method descriptions with detailed parameters and return values
    - Included 15 complete property specifications
    - Added 24 code examples and 38 explanatory notes
    - Comprehensive coverage of all collection methods, system classes, and functions

    Key highlights:
    - EntityCollection/UICollection: Complete method docs (append, remove, extend, count, index)
    - Animation: Full property and method documentation with examples
    - Color: All manipulation methods (from_hex, to_hex, lerp) with examples
    - Vector: Complete mathematical operations (magnitude, normalize, dot, distance_to, angle, copy)
    - Scene: All management methods including register_keyboard
    - Timer: Complete control methods (pause, resume, cancel, restart)
    - Window: All management methods (get, center, screenshot)
    - System functions: Complete audio, scene, UI, and system function documentation

Author: John McCardle <mccardle.john@gmail.com>

    feat(docs): create professional HTML API documentation

    - Fixed all formatting issues from original HTML output
    - Added comprehensive constructor documentation for all classes
    - Enhanced visual design with modern styling and typography
    - Fixed literal newline display and markdown link conversion
    - Added proper semantic HTML structure and navigation
    - Includes detailed documentation for Entity, collections, and system types

Author: John McCardle <mccardle.john@gmail.com>

    feat: complete API reference generator and finish Phase 7 documentation

    Implemented comprehensive API documentation generator that:
    - Introspects live mcrfpy module for accurate documentation
    - Generates organized Markdown reference (docs/API_REFERENCE.md)
    - Categorizes classes and functions by type
    - Includes full automation module documentation
    - Provides summary statistics

    Results:
    - 20 classes documented
    - 19 module functions documented
    - 20 automation methods documented
    - 100% coverage of public API
    - Clean, readable Markdown output

    Phase 7 Summary:
    - Completed 4/5 tasks (1 cancelled as architecturally inappropriate)
    - All documentation tasks successful
    - Type stubs, docstrings, and API reference all complete

Author: John McCardle <mccardle.john@gmail.com>

    docs: cancel PyPI wheel task and add future vision for Python extension architecture

    Task #70 Analysis:
    - Discovered fundamental incompatibility with PyPI distribution
    - McRogueFace embeds CPython rather than being loaded by it
    - Traditional wheels expect to extend existing Python interpreter
    - Current architecture is application-with-embedded-Python

    Decisions:
    - Cancelled PyPI wheel preparation as out of scope for Alpha
    - Cleaned up attempted packaging files (pyproject.toml, setup.py, etc.)
    - Identified better distribution methods (installers, package managers)

    Added Future Vision:
    - Comprehensive plan for pure Python extension architecture
    - Would allow true "pip install mcrogueface" experience
    - Requires major refactoring to invert control flow
    - Python would drive main loop with C++ performance extensions
    - Unscheduled but documented as long-term possibility

    This clarifies the architectural boundaries and sets realistic
    expectations for distribution methods while preserving the vision
    of what McRogueFace could become with significant rework.

Author: John McCardle <mccardle.john@gmail.com>

    feat: generate comprehensive .pyi type stubs for IDE support (#108)

    Created complete type stub files for the mcrfpy module to enable:
    - Full IntelliSense/autocomplete in IDEs
    - Static type checking with mypy/pyright
    - Better documentation tooltips
    - Parameter hints and return types

    Implementation details:
    - Manually crafted stubs for accuracy (15KB, 533 lines)
    - Complete coverage: 19 classes, 112 functions/methods
    - Proper type annotations using typing module
    - @overload decorators for multiple signatures
    - Type aliases for common patterns (UIElement union)
    - Preserved all docstrings for IDE help
    - Automation module fully typed
    - PEP 561 compliant with py.typed marker

    Testing:
    - Validated Python syntax with ast.parse()
    - Verified all expected classes and functions
    - Confirmed type annotations are well-formed
    - Checked docstring preservation (80 docstrings)

    Usage:
    - VS Code: Add stubs/ to python.analysis.extraPaths
    - PyCharm: Mark stubs/ directory as Sources Root
    - Other IDEs will auto-detect .pyi files

    This significantly improves the developer experience when using
    McRogueFace as a Python game engine.

Author: John McCardle <mccardle.john@gmail.com>

    docs: add comprehensive parameter documentation to all API methods (#86)

    Enhanced documentation for the mcrfpy module with:
    - Detailed docstrings for all API methods
    - Type hints in documentation (name: type format)
    - Return type specifications
    - Exception documentation where applicable
    - Usage examples for complex methods
    - Module-level documentation with overview and example code

    Specific improvements:
    - Audio API: Added parameter types and return values
    - Scene API: Documented transition types and error conditions
    - Timer API: Clarified handler signature and runtime parameter
    - UI Search: Added wildcard pattern examples for findAll()
    - Metrics API: Documented all dictionary keys returned

    Also fixed method signatures:
    - Changed METH_VARARGS to METH_NOARGS for parameterless methods
    - Ensures proper Python calling conventions

    Test coverage included - all documentation is accessible via Python's
    __doc__ attributes and shows correctly formatted information.

Author: John McCardle <mccardle.john@gmail.com>

    docs: mark issue #85 as completed in Phase 7

Author: John McCardle <mccardle.john@gmail.com>

    docs: replace all 'docstring' placeholders with comprehensive documentation (#85)

    Added proper Python docstrings for all UI component classes:

    UIFrame:
    - Container element that can hold child drawables
    - Documents position, size, colors, outline, and clip_children
    - Includes constructor signature with all parameters

    UICaption:
    - Text display element with font and styling
    - Documents text content, position, font, colors, outline
    - Notes that w/h are computed from text content

    UISprite:
    - Texture/sprite display element
    - Documents position, texture, sprite_index, scale
    - Notes that w/h are computed from texture and scale

    UIGrid:
    - Tile-based grid for game worlds
    - Documents grid dimensions, tile size, texture atlas
    - Includes entities collection and background_color

    All docstrings follow consistent format:
    - Constructor signature with defaults
    - Brief description
    - Args section with types and defaults
    - Attributes section with all properties

    This completes Phase 7 task #85 for documentation improvements.

Author: John McCardle <mccardle.john@gmail.com>

    docs: update ROADMAP with PyArgHelpers infrastructure completion

Author: John McCardle <mccardle.john@gmail.com>

    refactor: implement PyArgHelpers for standardized Python argument parsing

    This major refactoring standardizes how position, size, and other arguments
    are parsed across all UI components. PyArgHelpers provides consistent handling
    for various argument patterns:

    - Position as (x, y) tuple or separate x, y args
    - Size as (w, h) tuple or separate width, height args
    - Grid position and size with proper validation
    - Color parsing with PyColorObject support

    Changes across UI components:
    - UICaption: Migrated to PyArgHelpers, improved resize() for future multiline support
    - UIFrame: Uses standardized position parsing
    - UISprite: Consistent position handling
    - UIGrid: Grid-specific position/size helpers
    - UIEntity: Unified argument parsing

    Also includes:
    - Improved error messages for type mismatches (int or float accepted)
    - Reduced code duplication across constructors
    - Better handling of keyword/positional argument conflicts
    - Maintains backward compatibility with existing API

    This addresses the inconsistent argument handling patterns discovered during
    the inheritance hierarchy work and prepares for Phase 7 documentation.

Author: John McCardle <mccardle.john@gmail.com>

    feat(Python): establish proper inheritance hierarchy for UI types

    All UIDrawable-derived Python types now properly inherit from the Drawable
    base class in Python, matching the C++ inheritance structure.

    Changes:
    - Add Py_TPFLAGS_BASETYPE to PyDrawableType to allow inheritance
    - Set tp_base = &mcrfpydef::PyDrawableType for all UI types
    - Add PyDrawable.h include to UI type headers
    - Rename _Drawable to Drawable and update error message

    This enables proper Python inheritance: Frame, Caption, Sprite, Grid,
    and Entity all inherit from Drawable, allowing shared functionality
    and isinstance() checks.

Author: John McCardle <mccardle.john@gmail.com>

    refactor: move position property to UIDrawable base class (UISprite)

    - Update UISprite to use base class position instead of sprite position
    - Synchronize sprite position with base class position for rendering
    - Implement onPositionChanged() for position synchronization
    - Update all UISprite methods to use base position consistently
    - Add comprehensive test coverage for UISprite position handling

    This is part 3 of moving position to the base class. UIGrid is the final
    class that needs to be updated.

Author: John McCardle <mccardle.john@gmail.com>

    refactor: move position property to UIDrawable base class (UICaption)

    - Update UICaption to use base class position instead of text position
    - Synchronize text position with base class position for rendering
    - Add onPositionChanged() virtual method for position synchronization
    - Update all UICaption methods to use base position consistently
    - Add comprehensive test coverage for UICaption position handling

    This is part 2 of moving position to the base class. UISprite and UIGrid
    will be updated in subsequent commits.

Author: John McCardle <mccardle.john@gmail.com>

    refactor: move position property to UIDrawable base class (UIFrame)

    - Add position member to UIDrawable base class
    - Add common position getters/setters (x, y, pos) to base class
    - Update UIFrame to use base class position instead of box position
    - Synchronize box position with base class position for rendering
    - Update all UIFrame methods to use base position consistently
    - Add comprehensive test coverage for UIFrame position handling

    This is part 1 of moving position to the base class. Other derived classes
    (UICaption, UISprite, UIGrid) will be updated in subsequent commits.

Author: John McCardle <mccardle.john@gmail.com>

    refactor: remove UIEntity collision_pos field

    - Remove redundant collision_pos field from UIEntity
    - Update position getters/setters to use integer-cast position when needed
    - Remove all collision_pos synchronization code
    - Simplify entity position handling to use single float position field
    - Add comprehensive test coverage proving functionality is preserved

    This removes technical debt and simplifies the codebase without changing API behavior.

Author: John McCardle <mccardle.john@gmail.com>

    feat: add PyArgHelpers infrastructure for standardized argument parsing

    - Create PyArgHelpers.h with parsing functions for position, size, grid coordinates, and color
    - Support tuple-based vector arguments with conflict detection
    - Provide consistent error messages and validation
    - Add comprehensive test coverage for infrastructure

    This sets the foundation for standardizing all Python API constructors.

Author: John McCardle <mccardle.john@gmail.com>

    docs: mark Phase 6 (Rendering Revolution) as complete

    Phase 6 is now complete with all core rendering features implemented:

    Completed Features:
    - Grid background colors (#50) - customizable backgrounds with animation
    - RenderTexture overhaul (#6) - UIFrame clipping with opt-in architecture
    - Viewport-based rendering (#8) - three scaling modes with coordinate transform

    Strategic Decisions:
    - UIGrid already has optimal RenderTexture implementation for its viewport needs
    - UICaption/UISprite clipping deemed unnecessary (no children to clip)
    - Effects/Shader/Particle systems deferred to post-Phase 7 for focused delivery

    The rendering foundation is now solid and ready for Phase 7: Documentation & Distribution.

Author: John McCardle <mccardle.john@gmail.com>

    feat(viewport): complete viewport-based rendering system (#8)

    Implements a comprehensive viewport system that allows fixed game resolution
    with flexible window scaling, addressing the primary wishes for issues #34, #49, and #8.

    Key Features:
    - Fixed game resolution independent of window size (window.game_resolution property)
    - Three scaling modes accessible via window.scaling_mode:
      - "center": 1:1 pixels, viewport centered in window
      - "stretch": viewport fills window, ignores aspect ratio
      - "fit": maintains aspect ratio with black bars
    - Automatic window-to-game coordinate transformation for mouse input
    - Full Python API integration with PyWindow properties

    Technical Implementation:
    - GameEngine::ViewportMode enum with Center, Stretch, Fit modes
    - SFML View system for efficient GPU-based viewport scaling
    - updateViewport() recalculates on window resize or mode change
    - windowToGameCoords() transforms mouse coordinates correctly
    - PyScene mouse input automatically uses transformed coordinates

    Tests:
    - test_viewport_simple.py: Basic API functionality
    - test_viewport_visual.py: Visual verification with screenshots
    - test_viewport_scaling.py: Interactive mode switching and resizing

    This completes the viewport-based rendering task and provides the foundation
    for resolution-independent game development as requested for Crypt of Sokoban.

Author: John McCardle <mccardle.john@gmail.com>

    docs: update ROADMAP for Phase 6 progress

    - Marked Phase 6 as IN PROGRESS
    - Updated RenderTexture overhaul (#6) as PARTIALLY COMPLETE
    - Marked Grid background colors (#50) as COMPLETED
    - Added technical notes from implementation experience
    - Identified viewport rendering (#8) as next priority

Author: John McCardle <mccardle.john@gmail.com>

    feat(rendering): implement RenderTexture base infrastructure and UIFrame clipping (#6)

    - Added RenderTexture support to UIDrawable base class
      - std::unique_ptr<sf::RenderTexture> for opt-in rendering
      - Dirty flag system for optimization
      - enableRenderTexture() and markDirty() methods

    - Implemented clip_children property for UIFrame
      - Python-accessible boolean property
      - Automatic RenderTexture creation when enabled
      - Proper coordinate transformation for nested frames

    - Updated UIFrame::render() for clipping support
      - Renders to RenderTexture when clip_children=true
      - Handles nested clipping correctly
      - Only re-renders when dirty flag is set

    - Added comprehensive dirty flag propagation
      - All property setters mark frame as dirty
      - Size changes recreate RenderTexture
      - Animation system integration

    - Created tests for clipping functionality
      - Basic clipping test with visual verification
      - Advanced nested clipping test
      - Dynamic resize handling test

    This is Phase 1 of the RenderTexture overhaul, providing the foundation
    for advanced rendering effects like blur, glow, and viewport rendering.

Author: John McCardle <mccardle.john@gmail.com>

    docs: create RenderTexture overhaul design document

    - Comprehensive design for Issue #6 implementation
    - Opt-in architecture to maintain backward compatibility
    - Phased implementation plan with clear milestones
    - Performance considerations and risk mitigation
    - API design for clipping and future effects

    Also includes Grid background color test

Author: John McCardle <mccardle.john@gmail.com>

    feat(Grid): add customizable background_color property (#50)

    - Added sf::Color background_color member with default dark gray
    - Python property getter/setter for background_color
    - Animation support for individual color components (r/g/b/a)
    - Replaces hardcoded clear color in render method
    - Test demonstrates color changes and property access

    Closes #50

Author: John McCardle <mccardle.john@gmail.com>

    docs: update roadmap for Phase 6 preparation

    - Mark Phase 5 (Window/Scene Architecture) as complete
    - Update issue statuses (#34, #61, #1, #105 completed)
    - Add Phase 6 implementation strategy for RenderTexture overhaul
    - Archive Phase 5 test files to .archive/
    - Identify quick wins and technical approach for rendering work

Author: John McCardle <mccardle.john@gmail.com>

    feat(Phase 5): Complete Window/Scene Architecture

    - Window singleton with properties (resolution, fullscreen, vsync, title)
    - OOP Scene support with lifecycle methods (on_enter, on_exit, on_keypress, update)
    - Window resize events trigger scene.on_resize callbacks
    - Scene transitions (fade, slide_left/right/up/down) with smooth animations
    - Full integration of Python Scene objects with C++ engine

    All Phase 5 tasks (#34, #1, #61, #105) completed successfully.

Author: John McCardle <mccardle.john@gmail.com>

    research: SFML 3.0 migration analysis

    - Analyzed SFML 3.0 breaking changes (event system, scoped enums, C++17)
    - Assessed migration impact on McRogueFace (40+ files affected)
    - Evaluated timing relative to mcrfpy.sfml module plans
    - Recommended deferring migration until after mcrfpy.sfml implementation
    - Created SFML_3_MIGRATION_RESEARCH.md with comprehensive strategy

Author: John McCardle <mccardle.john@gmail.com>

    research: SFML exposure options analysis (#14)

    - Analyzed current SFML 2.6.1 usage throughout codebase
    - Evaluated python-sfml (abandoned, only supports SFML 2.3.2)
    - Recommended direct integration as mcrfpy.sfml module
    - Created comprehensive SFML_EXPOSURE_RESEARCH.md with implementation plan
    - Identified opportunity to provide modern SFML 2.6+ Python bindings

Author: John McCardle <mccardle.john@gmail.com>

    feat: add basic profiling/metrics system (#104)

    - Add ProfilingMetrics struct to track performance data
    - Track frame time (current and 60-frame rolling average)
    - Calculate FPS from average frame time
    - Count draw calls, UI elements, and visible elements per frame
    - Track total runtime and current frame number
    - PyScene counts elements during render
    - Expose metrics via mcrfpy.getMetrics() returning dict

    This provides basic performance monitoring capabilities for
    identifying bottlenecks and optimizing rendering performance.

Author: John McCardle <mccardle.john@gmail.com>

    fix: improve click handling with proper z-order and coordinate transforms

    - UIFrame: Fix coordinate transformation (subtract parent pos, not add)
    - UIFrame: Check children in reverse order (highest z-index first)
    - UIFrame: Skip invisible elements entirely
    - PyScene: Sort elements by z-index before checking clicks
    - PyScene: Stop at first element that handles the click
    - UIGrid: Implement entity click detection with grid coordinate transform
    - UIGrid: Check entities in reverse order, return sprite as target

    Click events now correctly respect z-order (top elements get priority),
    handle coordinate transforms for nested frames, and support clicking
    on grid entities. Elements without click handlers are transparent to
    clicks, allowing elements below to receive them.

    Note: Click testing requires non-headless mode due to PyScene limitation.

    feat: implement name system for finding UI elements (#39/40/41)

    - Add 'name' property to UIDrawable base class
    - All UI elements (Frame, Caption, Sprite, Grid, Entity) support .name
    - Entity delegates name to its sprite member
    - Add find(name, scene=None) function for exact match search
    - Add findAll(pattern, scene=None) with wildcard support (* matches any sequence)
    - Both functions search recursively through Frame children and Grid entities
    - Comprehensive test coverage for all functionality

    This provides a simple way to find UI elements by name in Python scripts,
    supporting both exact matches and wildcard patterns.

Author: John McCardle <mccardle.john@gmail.com>

    fix: prevent segfault when closing window via X button

    - Add cleanup() method to GameEngine to clear Python references before destruction
    - Clear timers and McRFPy_API references in proper order
    - Call cleanup() at end of run loop and in destructor
    - Ensure cleanup is only called once per GameEngine instance

    Also includes:
    - Fix audio ::stop() calls (already in place, OpenAL warning is benign)
    - Add Caption support for x, y keywords (e.g. Caption("text", x=5, y=10))
    - Refactor UIDrawable_methods.h into UIBase.h for better organization
    - Move UIEntity-specific implementations to UIEntityPyMethods.h

Author: John McCardle <mccardle.john@gmail.com>

    feat: stabilize test suite and add UIDrawable methods

    - Add visible, opacity properties to all UI classes (#87, #88)
    - Add get_bounds(), move(), resize() methods to UIDrawable (#89, #98)
    - Create UIDrawable_methods.h with template implementations
    - Fix test termination issues - all tests now exit properly
    - Fix test_sprite_texture_swap.py click handler signature
    - Fix test_drawable_base.py segfault in headless mode
    - Convert audio objects to pointers for cleanup (OpenAL warning persists)
    - Remove debug print statements from UICaption
    - Special handling for UIEntity to delegate drawable methods to sprite

    All test files are now "airtight" - they complete successfully,
    terminate on their own, and handle edge cases properly.

Author: John McCardle <mccardle.john@gmail.com>

    docs: add Phase 1-3 completion summary

    - Document all completed tasks across three phases
    - Show before/after API improvements
    - Highlight technical achievements
    - Outline next steps for Phase 4-7

Author: John McCardle <mccardle.john@gmail.com>

    feat: implement mcrfpy.Timer object with pause/resume/cancel capabilities closes #103

    - Created PyTimer.h/cpp with object-oriented timer interface
    - Enhanced PyTimerCallable with pause/resume state tracking
    - Added timer control methods: pause(), resume(), cancel(), restart()
    - Added timer properties: interval, remaining, paused, active, callback
    - Fixed timing logic to prevent rapid catch-up after resume
    - Timer objects automatically register with game engine
    - Added comprehensive test demonstrating all functionality

Author: John McCardle <mccardle.john@gmail.com>

    feat(Color): add helper methods from_hex, to_hex, lerp closes #94

    - Add Color.from_hex(hex_string) class method for creating colors from hex
    - Support formats: #RRGGBB, RRGGBB, #RRGGBBAA, RRGGBBAA
    - Add color.to_hex() to convert Color to hex string
    - Add color.lerp(other, t) for smooth color interpolation
    - Comprehensive test coverage for all methods

Author: John McCardle <mccardle.john@gmail.com>

    fix: properly configure UTF-8 encoding for Python stdio

    - Use PyConfig to set stdio_encoding="UTF-8" during initialization
    - Set stdio_errors="surrogateescape" for robust handling
    - Configure in both init_python() and init_python_with_config()
    - Cleaner solution than wrapping streams after initialization
    - Fixes UnicodeEncodeError when printing unicode characters

Author: John McCardle <mccardle.john@gmail.com>

    feat(Vector): implement arithmetic operations closes #93

    - Add PyNumberMethods with add, subtract, multiply, divide, negate, absolute
    - Add rich comparison for equality/inequality checks
    - Add boolean check (zero vector is False)
    - Implement vector methods: magnitude(), normalize(), dot(), distance_to(), angle(), copy()
    - Fix UIDrawable::get_click() segfault when click_callable is null
    - Comprehensive test coverage for all arithmetic operations

Author: John McCardle <mccardle.john@gmail.com>

    feat: Complete position argument standardization for all UI classes

    - Frame and Sprite now support pos keyword override
    - Entity now accepts x,y arguments (was pos-only before)
    - All UI classes now consistently support:
      - (x, y) positional
      - ((x, y)) tuple
      - x=x, y=y keywords
      - pos=(x,y) keyword
      - pos=Vector keyword
    - Improves API consistency and flexibility

Author: John McCardle <mccardle.john@gmail.com>

    feat: Standardize position arguments across all UI classes

    - Create PyPositionHelper for consistent position parsing
    - Grid.at() now accepts (x,y), ((x,y)), x=x, y=y, pos=(x,y)
    - Caption now accepts x,y args in addition to pos
    - Grid init fully supports keyword arguments
    - Maintain backward compatibility for all formats
    - Consistent error messages across classes

Author: John McCardle <mccardle.john@gmail.com>

    feat: Add Entity.die() method for lifecycle management closes #30

    - Remove entity from its grid's entity list
    - Clear grid reference after removal
    - Safe to call multiple times (no-op if not on grid)
    - Works with shared_ptr entity management

Author: John McCardle <mccardle.john@gmail.com>

    perf: Skip out-of-bounds entities during Grid rendering closes #52

    - Add visibility bounds check in entity render loop
    - Skip entities outside view with 1 cell margin
    - Improves performance for large grids with many entities
    - Bounds check considers zoom and pan settings

Author: John McCardle <mccardle.john@gmail.com>

    verify: Sprite texture swapping functionality closes #19

    - Texture property getter/setter already implemented
    - Position/scale preservation during swap confirmed
    - Type validation for texture assignment working
    - Tests verify functionality is complete

Author: John McCardle <mccardle.john@gmail.com>

    feat: Grid size tuple support closes #90

    - Add grid_size keyword parameter to Grid.__init__
    - Accept tuple or list of two integers
    - Override grid_x/grid_y if grid_size provided
    - Maintain backward compatibility
    - Add comprehensive test coverage

Author: John McCardle <mccardle.john@gmail.com>

    feat: Phase 1 - safe constructors and _Drawable foundation

    Closes #7 - Make all UI class constructors safe:
    - Added safe default constructors for UISprite, UIGrid, UIEntity, UICaption
    - Initialize all members to predictable values
    - Made Python init functions accept no arguments
    - Added x,y properties to UIEntity

    Closes #71 - Create _Drawable Python base class:
    - Created PyDrawable.h/cpp with base type (not yet inherited by UI types)
    - Registered in module initialization

    Closes #87 - Add visible property:
    - Added bool visible=true to UIDrawable base class
    - All render methods check visibility before drawing

    Closes #88 - Add opacity property:
    - Added float opacity=1.0 to UIDrawable base class
    - UICaption and UISprite apply opacity to alpha channel

    Closes #89 - Add get_bounds() method:
    - Virtual method returns sf::FloatRect(x,y,w,h)
    - Implemented in Frame, Caption, Sprite, Grid

    Closes #98 - Add move() and resize() methods:
    - move(dx,dy) for relative movement
    - resize(w,h) for absolute sizing
    - Caption resize is no-op (size controlled by font)

Author: John McCardle <mccardle.john@gmail.com>

    docs: comprehensive alpha_streamline_2 plan and strategic vision

    - Add 7-phase development plan for alpha_streamline_2 branch
    - Define architectural dependencies and critical path
    - Identify new issues needed (Timer objects, event system, etc.)
    - Add strategic vision document with 3 transformative directions
    - Timeline: 10-12 weeks to solid Beta foundation

Author: John McCardle <mccardle.john@gmail.com>

    feat(Grid): flexible at() method arguments

    - Support tuple argument: grid.at((x, y))
    - Support keyword arguments: grid.at(x=5, y=3)
    - Support pos keyword: grid.at(pos=(2, 8))
    - Maintain backward compatibility with grid.at(x, y)
    - Add comprehensive error handling for invalid arguments

    Improves API ergonomics and Python-like flexibility
2025-07-09 22:41:15 -04:00
John McCardle cd0bd5468b Squashed commit of the following: [alpha_streamline_1]
the low-hanging fruit of pre-existing issues and standardizing the
Python interfaces

Special thanks to Claude Code, ~100k output tokens for this merge

    🤖 Generated with [Claude Code](https://claude.ai/code)
    Co-Authored-By: Claude <noreply@anthropic.com>

commit 99f301e3a0
Author: John McCardle <mccardle.john@gmail.com>
Date:   Sat Jul 5 16:25:32 2025 -0400

    Add position tuple support and pos property to UI elements

    closes #83, closes #84

    - Issue #83: Add position tuple support to constructors
      - Frame and Sprite now accept both (x, y) and ((x, y)) forms
      - Also accept Vector objects as position arguments
      - Caption and Entity already supported tuple/Vector forms
      - Uses PyVector::from_arg for flexible position parsing

    - Issue #84: Add pos property to Frame and Sprite
      - Added pos getter that returns a Vector
      - Added pos setter that accepts Vector or tuple
      - Provides consistency with Caption and Entity which already had pos properties
      - All UI elements now have a uniform way to get/set positions as Vectors

    Both features improve API consistency and make it easier to work with positions.

commit 2f2b488fb5
Author: John McCardle <mccardle.john@gmail.com>
Date:   Sat Jul 5 16:18:10 2025 -0400

    Standardize sprite_index property and add scale_x/scale_y to UISprite

    closes #81, closes #82

    - Issue #81: Standardized property name to sprite_index across UISprite and UIEntity
      - Added sprite_index as the primary property name
      - Kept sprite_number as a deprecated alias for backward compatibility
      - Updated repr() methods to use sprite_index
      - Updated animation system to recognize both names

    - Issue #82: Added scale_x and scale_y properties to UISprite
      - Enables non-uniform scaling of sprites
      - scale property still works for uniform scaling
      - Both properties work with the animation system

    All existing code using sprite_number continues to work due to backward compatibility.

commit 5a003a9aa5
Author: John McCardle <mccardle.john@gmail.com>
Date:   Sat Jul 5 16:09:52 2025 -0400

    Fix multiple low priority issues

    closes #12, closes #80, closes #95, closes #96, closes #99

    - Issue #12: Set tp_new to NULL for GridPoint and GridPointState to prevent instantiation from Python
    - Issue #80: Renamed Caption.size to Caption.font_size for semantic clarity
    - Issue #95: Fixed UICollection repr to show actual derived types instead of generic UIDrawable
    - Issue #96: Added extend() method to UICollection for API consistency with UIEntityCollection
    - Issue #99: Exposed read-only properties for Texture (sprite_width, sprite_height, sheet_width, sheet_height, sprite_count, source) and Font (family, source)

    All issues have corresponding tests that verify the fixes work correctly.

commit e5affaf317
Author: John McCardle <mccardle.john@gmail.com>
Date:   Sat Jul 5 15:50:09 2025 -0400

    Fix critical issues: script loading, entity types, and color properties

    - Issue #37: Fix Windows scripts subdirectory not checked
      - Updated executeScript() to use executable_path() from platform.h
      - Scripts now load correctly when working directory differs from executable

    - Issue #76: Fix UIEntityCollection returns wrong type
      - Updated UIEntityCollectionIter::next() to check for stored Python object
      - Derived Entity classes now preserve their type when retrieved from collections

    - Issue #9: Recreate RenderTexture when resized (already fixed)
      - Confirmed RenderTexture recreation already implemented in set_size() and set_float_member()
      - Uses 1.5x padding and 4096 max size limit

    - Issue #79: Fix Color r, g, b, a properties return None
      - Implemented get_member() and set_member() in PyColor.cpp
      - Color component properties now work correctly with proper validation

    - Additional fix: Grid.at() method signature
      - Changed from METH_O to METH_VARARGS to accept two arguments

    All fixes include comprehensive tests to verify functionality.

    closes #37, closes #76, closes #9, closes #79
2025-07-05 18:56:02 -04:00
John McCardle e6dbb2d560 Squashed commit of the following: [interpreter_mode]
closes #63
closes #69
closes #59
closes #47
closes #2
closes #3
closes #33
closes #27
closes #73
closes #74
closes #78

  I'd like to thank Claude Code for ~200-250M total tokens and 500-700k output tokens

    🤖 Generated with [Claude Code](https://claude.ai/code)
    Co-Authored-By: Claude <noreply@anthropic.com>

commit 9bd1561bfc
Author: John McCardle <mccardle.john@gmail.com>
Date:   Sat Jul 5 11:20:07 2025 -0400

    Alpha 0.1 release
    - Move RenderTexture (#6) out of alpha requirements, I don't need it
      that badly
    - alpha blockers resolved:
      * Animation system (#59)
      * Z-order rendering (#63)
      * Python Sequence Protocol (#69)
      * New README (#47)
      * Removed deprecated methods (#2, #3)

    🍾 McRogueFace 0.1.0

commit 43321487eb
Author: John McCardle <mccardle.john@gmail.com>
Date:   Sat Jul 5 10:36:09 2025 -0400

    Issue #63 (z-order rendering) complete
    - Archive z-order test files

commit 90c318104b
Author: John McCardle <mccardle.john@gmail.com>
Date:   Sat Jul 5 10:34:06 2025 -0400

    Fix Issue #63: Implement z-order rendering with dirty flag optimization

    - Add dirty flags to PyScene and UIFrame to track when sorting is needed
    - Implement lazy sorting - only sort when z_index changes or elements are added/removed
    - Make Frame children respect z_index (previously rendered in insertion order only)
    - Update UIDrawable::set_int to notify when z_index changes
    - Mark collections dirty on append, remove, setitem, and slice operations
    - Remove per-frame vector copy in PyScene::render for better performance

commit e4482e7189
Author: John McCardle <mccardle.john@gmail.com>
Date:   Sat Jul 5 01:58:03 2025 -0400

    Implement complete Python Sequence Protocol for collections (closes #69)

    Major implementation of the full sequence protocol for both UICollection
    and UIEntityCollection, making them behave like proper Python sequences.

    Core Features Implemented:
    - __setitem__ (collection[i] = value) with type validation
    - __delitem__ (del collection[i]) with proper cleanup
    - __contains__ (item in collection) by C++ pointer comparison
    - __add__ (collection + other) returns Python list
    - __iadd__ (collection += other) with full validation before modification
    - Negative indexing support throughout
    - Complete slice support (getting, setting, deletion)
    - Extended slices with step \!= 1
    - index() and count() methods
    - Type safety enforced for all operations

    UICollection specifics:
    - Accepts Frame, Caption, Sprite, and Grid objects only
    - Preserves z_index when replacing items
    - Auto-assigns z_index on append (existing behavior maintained)

    UIEntityCollection specifics:
    - Accepts Entity objects only
    - Manages grid references on add/remove/replace
    - Uses std::list iteration with std::advance()

    Also includes:
    - Default value support for constructors:
      - Caption accepts None for font (uses default_font)
      - Grid accepts None for texture (uses default_texture)
      - Sprite accepts None for texture (uses default_texture)
      - Entity accepts None for texture (uses default_texture)

    This completes Issue #69, removing it as an Alpha Blocker.

commit 70cf44f8f0
Author: John McCardle <mccardle.john@gmail.com>
Date:   Sat Jul 5 00:56:42 2025 -0400

    Implement comprehensive animation system (closes #59)

    - Add Animation class with 30+ easing functions (linear, ease in/out, quad, cubic, elastic, bounce, etc.)
    - Add property system to all UI classes for animation support:
      - UIFrame: position, size, colors (including individual r/g/b/a components)
      - UICaption: position, size, text, colors
      - UISprite: position, scale, sprite_number (with sequence support)
      - UIGrid: position, size, camera center, zoom
      - UIEntity: position, sprite properties
    - Create AnimationManager singleton for frame-based updates
    - Add Python bindings through PyAnimation wrapper
    - Support for delta animations (relative values)
    - Fix segfault when running scripts directly (mcrf_module initialization)
    - Fix headless/windowed mode behavior to respect --headless flag
    - Animations run purely in C++ without Python callbacks per frame

    All UI properties are now animatable with smooth interpolation and professional easing curves.

commit 05bddae511
Author: John McCardle <mccardle.john@gmail.com>
Date:   Fri Jul 4 06:59:02 2025 -0400

    Update comprehensive documentation for Alpha release (Issue #47)

    - Completely rewrote README.md to reflect current features
    - Updated GitHub Pages documentation site with:
      - Modern landing page highlighting Crypt of Sokoban
      - Comprehensive API reference (2700+ lines) with exhaustive examples
      - Updated getting-started guide with installation and first game tutorial
      - 8 detailed tutorials covering all major game systems
      - Quick reference cheat sheet for common operations
    - Generated documentation screenshots showing UI elements
    - Fixed deprecated API references and added new features
    - Added automation API documentation
    - Included Python 3.12 requirement and platform-specific instructions

    Note: Text rendering in headless mode has limitations for screenshots

commit af6a5e090b
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 21:43:58 2025 -0400

    Update ROADMAP.md to reflect completion of Issues #2 and #3

    - Marked both issues as completed with the removal of deprecated action system
    - Updated open issue count from ~50 to ~48
    - These were both Alpha blockers, bringing us closer to release

commit 281800cd23
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 21:43:22 2025 -0400

    Remove deprecated registerPyAction/registerInputAction system (closes #2, closes #3)

    This is our largest net-negative commit yet\! Removed the entire deprecated
    action registration system that provided unnecessary two-step indirection:
    keyboard → action string → Python callback

    Removed components:
    - McRFPy_API::_registerPyAction() and _registerInputAction() methods
    - McRFPy_API::callbacks map for storing Python callables
    - McRFPy_API::doAction() method for executing callbacks
    - ACTIONPY macro from Scene.h for detecting "_py" suffixed actions
    - Scene::registerActionInjected() and unregisterActionInjected() methods
    - tests/api_registerPyAction_issue2_test.py (tested deprecated functionality)

    The game now exclusively uses keypressScene() for keyboard input handling,
    which is simpler and more direct. Also commented out the unused _camFollow
    function that referenced non-existent do_camfollow variable.

commit cc8a7d20e8
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 21:13:59 2025 -0400

    Clean up temporary test files

commit ff83fd8bb1
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 21:13:46 2025 -0400

    Update ROADMAP.md to reflect massive progress today

    - Fixed 12+ critical bugs in a single session
    - Implemented 3 missing features (Entity.index, EntityCollection.extend, sprite validation)
    - Updated Phase 1 progress showing 11 of 12 items complete
    - Added detailed summary of today's achievements with issue numbers
    - Emphasized test-driven development approach used throughout

commit dae400031f
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 21:12:29 2025 -0400

    Remove deprecated player_input and turn-based functions for Issue #3

    Removed the commented-out player_input(), computerTurn(), and playerTurn()
    functions that were part of the old turn-based system. These are no longer
    needed as input is now handled through Scene callbacks.

    Partial fix for #3

commit cb0130b46e
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 21:09:06 2025 -0400

    Implement sprite index validation for Issue #33

    Added validation to prevent setting sprite indices outside the valid
    range for a texture. The implementation:
    - Adds getSpriteCount() method to PyTexture to expose total sprites
    - Validates sprite_number setter to ensure index is within bounds
    - Provides clear error messages showing valid range
    - Works for both Sprite and Entity objects

    closes #33

commit 1e7f5e9e7e
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 21:05:47 2025 -0400

    Implement EntityCollection.extend() method for Issue #27

    Added extend() method to EntityCollection that accepts any iterable
    of Entity objects and adds them all to the collection. The method:
    - Accepts lists, tuples, generators, or any iterable
    - Validates all items are Entity objects
    - Sets the grid association for each added entity
    - Properly handles errors and empty iterables

    closes #27

commit 923350137d
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 21:02:14 2025 -0400

    Implement Entity.index() method for Issue #73

    Added index() method to Entity class that returns the entity's
    position in its parent grid's entity collection. This enables
    proper entity removal patterns using entity.index().

commit 6134869371
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 20:41:03 2025 -0400

    Add validation to keypressScene() for non-callable arguments

    Added PyCallable_Check validation to ensure keypressScene() only
    accepts callable objects. Now properly raises TypeError with a
    clear error message when passed non-callable arguments like
    strings, numbers, None, or dicts.

commit 4715356b5e
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 20:31:36 2025 -0400

    Fix Sprite texture setter 'error return without exception set'

    Implemented the missing UISprite::set_texture method to properly:
    - Validate the input is a Texture instance
    - Update the sprite's texture using setTexture()
    - Return appropriate error messages for invalid inputs

    The setter now works correctly and no longer returns -1 without
    setting an exception.

commit 6dd1cec600
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 20:27:32 2025 -0400

    Fix Entity property setters and PyVector implementation

    Fixed the 'new style getargs format' error in Entity property setters by:
    - Implementing PyObject_to_sfVector2f/2i using PyVector::from_arg
    - Adding proper error checking in Entity::set_position
    - Implementing PyVector get_member/set_member for x/y properties
    - Fixing PyVector::from_arg to handle non-tuple arguments correctly

    Now Entity.pos and Entity.sprite_number setters work correctly with
    proper type validation.

commit f82b861bcd
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 19:48:33 2025 -0400

    Fix Issue #74: Add missing Grid.grid_y property

    Added individual grid_x and grid_y getter properties to the Grid class
    to complement the existing grid_size property. This allows direct access
    to grid dimensions and fixes error messages that referenced these
    properties before they existed.

    closes #74

commit 59e6f8d53d
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 19:42:32 2025 -0400

    Fix Issue #78: Middle mouse click no longer sends 'C' keyboard event

    The bug was caused by accessing event.key.code on a mouse event without
    checking the event type first. Since SFML uses a union for events, this
    read garbage data. The middle mouse button value (2) coincidentally matched
    the keyboard 'C' value (2), causing the spurious keyboard event.

    Fixed by adding event type check before accessing key-specific fields.
    Only keyboard events (KeyPressed/KeyReleased) now trigger key callbacks.

    Test added to verify middle clicks no longer generate keyboard events.

    Closes #78

commit 1c71d8d4f7
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 19:36:15 2025 -0400

    Fix Grid to support None/null texture and fix error message bug

    - Allow Grid to be created with None as texture parameter
    - Use default cell dimensions (16x16) when no texture provided
    - Skip sprite rendering when texture is null, but still render colors
    - Fix issue #77: Corrected copy/paste error in Grid.at() error messages
    - Grid now functional for color-only rendering and entity positioning

    Test created to verify Grid works without texture, showing colored cells.

    Closes #77

commit 18cfe93a44
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 19:25:49 2025 -0400

    Fix --exec interactive prompt bug and create comprehensive test suite

    Major fixes:
    - Fixed --exec entering Python REPL instead of game loop
    - Resolved screenshot transparency issue (requires timer callbacks)
    - Added debug output to trace Python initialization

    Test suite created:
    - 13 comprehensive tests covering all Python-exposed methods
    - Tests use timer callback pattern for proper game loop interaction
    - Discovered multiple critical bugs and missing features

    Critical bugs found:
    - Grid class segfaults on instantiation (blocks all Grid functionality)
    - Issue #78 confirmed: Middle mouse click sends 'C' keyboard event
    - Entity property setters have argument parsing errors
    - Sprite texture setter returns improper error
    - keypressScene() segfaults on non-callable arguments

    Documentation updates:
    - Updated CLAUDE.md with testing guidelines and TDD practices
    - Created test reports documenting all findings
    - Updated ROADMAP.md with test results and new priorities

    The Grid segfault is now the highest priority as it blocks all Grid-based functionality.

commit 9ad0b6850d
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 15:55:24 2025 -0400

    Update ROADMAP.md to reflect Python interpreter and automation API progress

    - Mark #32 (Python interpreter behavior) as 90% complete
      - All major Python flags implemented: -h, -V, -c, -m, -i
      - Script execution with proper sys.argv handling works
      - Only stdin (-) support missing

    - Note that new automation API enables:
      - Automated UI testing capabilities
      - Demo recording and playback
      - Accessibility testing support

    - Flag issues #53 and #45 as potentially aided by automation API

commit 7ec4698653
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 14:57:59 2025 -0400

    Update ROADMAP.md to remove closed issues

    - Remove #72 (iterator improvements - closed)
    - Remove #51 (UIEntity derive from UIDrawable - closed)
    - Update issue counts: 64 open issues from original 78
    - Update dependencies and references to reflect closed issues
    - Clarify that core iterators are complete, only grid points remain

commit 68c1a016b0
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 14:27:01 2025 -0400

    Implement --exec flag and PyAutoGUI-compatible automation API

    - Add --exec flag to execute multiple scripts before main program
    - Scripts are executed in order and share Python interpreter state
    - Implement full PyAutoGUI-compatible automation API in McRFPy_Automation
    - Add screenshot, mouse control, keyboard input capabilities
    - Fix Python initialization issues when multiple scripts are loaded
    - Update CommandLineParser to handle --exec with proper sys.argv management
    - Add comprehensive examples and documentation

    This enables automation testing by allowing test scripts to run alongside
    games using the same Python environment. The automation API provides
    event injection into the SFML render loop for UI testing.

    Closes #32 partially (Python interpreter emulation)
    References automation testing requirements

commit 763fa201f0
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 10:43:17 2025 -0400

    Python command emulation

commit a44b8c93e9
Author: John McCardle <mccardle.john@gmail.com>
Date:   Thu Jul 3 09:42:46 2025 -0400

    Prep: Cleanup for interpreter mode
2025-07-05 17:23:09 -04:00
John McCardle 167636ce8c Iterators, other Python C API improvements
closes #72
ref #69 - this resolves the "UICollection" (not "UIEntityCollection", perhaps renamed since the issue opened) and "UIEntityCollection" portion. The Grid point based iterators were not updated.
**RPATH updates**
Will this RPATH setting allow McRogueFace to execute using its included "lib" subdirectory after being unzipped on a new computer?

The change from "./lib" to "$ORIGIN/./lib" improves portability. The $ORIGIN token is a special Linux/Unix convention that refers to the directory containing the executable itself. This makes the path relative to the executable's location rather than the current working directory, which means McRogueFace will correctly find its libraries in the lib subdirectory regardless of where it's run from after being unzipped on a new computer.

**New standard object initialization**
PyColor, PyVector
  - Fixed all 15 PyTypeObject definitions to use proper designated initializer syntax
  - Replaced PyType_GenericAlloc usage in PyColor.cpp and PyVector.cpp
  - Updated PyObject_New usage in UIEntity.cpp
  - All object creation now uses module-based type lookups instead of static references
  - Created centralized utilities in PyObjectUtils.h

**RAII Wrappers**
automatic reference counting via C++ object lifecycle
  - Created PyRAII.h with PyObjectRef and PyTypeRef classes
  - These provide automatic reference counting management
  - Updated PyColor::from_arg() to demonstrate RAII usage
  - Prevents memory leaks and reference counting errors

**Python object base in type defs:**
`.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}`
PyColor, PyTexture, PyVector, UICaption, UICollection, UIEntity, UIFrame, UIGrid

**convertDrawableToPython**
replace crazy macro to detect the correct Python type of a UIDrawable instance

  - Removed the problematic macro from UIDrawable.h
  - Created template-based functions in PyObjectUtils.h
  - Updated UICollection.cpp to use local helper function
  - The new approach is cleaner, more debuggable, and avoids static type references

**Iterator fixes**
tp_iter on UICollection, UIGrid, UIGridPoint, UISprite
UIGrid logic improved, standard

**List vs Vector usage analysis**
there are different use cases that weren't standardized:
  - UICollection (for Frame children) uses std::vector<std::shared_ptr<UIDrawable>>
  - UIEntityCollection (for Grid entities) uses std::list<std::shared_ptr<UIEntity>>

The rationale is currently connected to frequency of expected changes.
* A "UICollection" is likely either all visible or not; it's also likely to be created once and have a static set of contents. They should be contiguous in memory in hopes that this helps rendering speed.
* A "UIEntityCollection" is expected to be rendered as a subset within the visible rectangle of the UIGrid. Scrolling the grid or gameplay logic is likely to frequently create and destroy entities. In general I expect Entity collections to have a much higher common size than UICollections. For these reasons I've made them Lists in hopes that they never have to be reallocated or moved during a frame.
2025-05-31 09:11:51 -04:00
John McCardle f594998dc3 Final day of changes for 7DRL 2025 - Crypt of Sokoban game code 2025-03-12 22:42:26 -04:00
John McCardle 5b259d0b38 Moving console access to python side, so Windows users won't brick their session. 2025-03-08 21:12:40 -05:00
John McCardle cea084bddf Whoops, never commited the UI icons spritesheet 2025-03-08 20:33:55 -05:00
John McCardle dd2db1586e Whoops, never committed the tile config 2025-03-08 20:31:34 -05:00
John McCardle 6be474da08 7DRL 2025 progress 2025-03-08 10:42:17 -05:00
John McCardle e928dda4b3 Squashed: grid-entity-integration partial features for 7DRL 2025 deployment
This squash commit includes changes from April 21st through 28th, 2024, and the past 3 days of work at 7DRL.
Rather than resume my feature branch work, I made minor changes to safe the C++ functionality and wrote workarounds in Python.

I'm very likely to delete this commit from history by rolling master back to the previous commit, and squash merging a finished feature branch.
2025-03-05 20:26:04 -05:00
John McCardle 232105a893 Squashed commit of the following: [reprs_and_member_names]
Closes #22
Closes #23
Closes #24
Closes #25
Closes #31
Closes #56

commit 43fac8f4f3
Author: John McCardle <mccardle.john@gmail.com>
Date:   Sat Apr 20 18:32:52 2024 -0400

    Typo in UIFrame repr

commit 3fd5ad93e2
Author: John McCardle <mccardle.john@gmail.com>
Date:   Sat Apr 20 18:32:30 2024 -0400

    Add UIGridPoint and UIGridPointState repr

commit 03376897b8
Author: John McCardle <mccardle.john@gmail.com>
Date:   Sat Apr 20 18:32:17 2024 -0400

    Add UIGrid repr

commit 48af072a33
Author: John McCardle <mccardle.john@gmail.com>
Date:   Sat Apr 20 18:32:05 2024 -0400

    Add UIEntity repr
2024-04-20 18:33:18 -04:00
269 changed files with 52322 additions and 2797 deletions

21
.gitignore vendored
View File

@ -9,4 +9,25 @@ obj
build build
lib lib
obj obj
__pycache__
.cache/
7DRL2025 Release/
CMakeFiles/
Makefile
*.md
*.zip
__lib/
_oldscripts/
assets/
cellular_automata_fire/
*.txt
deps/
fetch_issues_txt.py
forest_fire_CA.py
mcrogueface.github.io
scripts/
test_*
tcod_reference
.archive

9
.mcp.json Normal file
View File

@ -0,0 +1,9 @@
{
"mcpServers": {
"gitea": {
"type": "stdio",
"command": "/home/john/Development/discord_for_claude/forgejo-mcp.linux.amd64",
"args": ["stdio", "--server", "https://gamedev.ffwf.net/gitea/", "--token", "f58ec698a5edee82db4960920b13d3f7d0d58d8e"]
}
}
}

573
CLAUDE.md Normal file
View File

@ -0,0 +1,573 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Gitea-First Workflow
**IMPORTANT**: This project uses Gitea for issue tracking, documentation, and project management. Always consult and update Gitea resources before and during development work.
**Gitea Instance**: https://gamedev.ffwf.net/gitea/john/McRogueFace
### Core Principles
1. **Gitea is the Single Source of Truth**
- Issue tracker contains current tasks, bugs, and feature requests
- Wiki contains living documentation and architecture decisions
- Use Gitea MCP tools to query and update issues programmatically
2. **Always Check Gitea First**
- Before starting work: Check open issues for related tasks or blockers
- When using `/roadmap` command: Query Gitea for up-to-date issue status
- When researching a feature: Search Gitea wiki and issues before grepping codebase
- When encountering a bug: Check if an issue already exists
3. **Create Granular Issues**
- Break large features into separate, focused issues
- Each issue should address one specific problem or enhancement
- Tag issues appropriately: `[Bugfix]`, `[Major Feature]`, `[Minor Feature]`, etc.
- Link related issues using dependencies or blocking relationships
4. **Document as You Go**
- When work on one issue interacts with another system: Add notes to related issues
- When discovering undocumented behavior: Create task to document it
- When documentation misleads you: Create task to correct or expand it
- When implementing a feature: Update the Gitea wiki if appropriate
5. **Cross-Reference Everything**
- Commit messages should reference issue numbers (e.g., "Fixes #104", "Addresses #125")
- Issue comments should link to commits when work is done
- Wiki pages should reference relevant issues for implementation details
- Issues should link to each other when dependencies exist
### Workflow Pattern
```
┌─────────────────────────────────────────────────────┐
│ 1. Check Gitea Issues & Wiki │
│ - Is there an existing issue for this? │
│ - What's the current status? │
│ - Are there related issues or blockers? │
└─────────────────┬───────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ 2. Create Issues (if needed) │
│ - Break work into granular tasks │
│ - Tag appropriately │
│ - Link dependencies │
└─────────────────┬───────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ 3. Do the Work │
│ - Implement/fix/document │
│ - Write tests first (TDD) │
│ - Add inline documentation │
└─────────────────┬───────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ 4. Update Gitea │
│ - Add notes to affected issues │
│ - Create follow-up issues for discovered work │
│ - Update wiki if architecture/APIs changed │
│ - Add documentation correction tasks │
└─────────────────┬───────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ 5. Commit & Reference │
│ - Commit messages reference issue numbers │
│ - Close issues or update status │
│ - Add commit links to issue comments │
└─────────────────────────────────────────────────────┘
```
### Benefits of Gitea-First Approach
- **Reduced Context Switching**: Check brief issue descriptions instead of re-reading entire codebase
- **Better Planning**: Issues provide roadmap; avoid duplicate or contradictory work
- **Living Documentation**: Wiki and issues stay current as work progresses
- **Historical Context**: Issue comments capture why decisions were made
- **Efficiency**: MCP tools allow programmatic access to project state
### MCP Tools Available
Claude Code has access to Gitea MCP tools for:
- `list_repo_issues` - Query current issues with filtering
- `get_issue` - Get detailed issue information
- `create_issue` - Create new issues programmatically
- `create_issue_comment` - Add comments to issues
- `edit_issue` - Update issue status, title, body
- `add_issue_labels` - Tag issues appropriately
- `add_issue_dependency` / `add_issue_blocking` - Link related issues
- Plus wiki, milestone, and label management tools
Use these tools liberally to keep the project organized!
### Gitea Label System
**IMPORTANT**: Always apply appropriate labels when creating new issues!
The project uses a structured label system to organize issues:
**Label Categories:**
1. **System Labels** (identify affected codebase area):
- `system:rendering` - Rendering pipeline and visuals
- `system:ui-hierarchy` - UI component hierarchy and composition
- `system:grid` - Grid system and spatial containers
- `system:animation` - Animation and property interpolation
- `system:python-binding` - Python/C++ binding layer
- `system:input` - Input handling and events
- `system:performance` - Performance optimization and profiling
- `system:documentation` - Documentation infrastructure
2. **Priority Labels** (development timeline):
- `priority:tier1-active` - Current development focus - critical path to v1.0
- `priority:tier2-foundation` - Important foundation work - not blocking v1.0
- `priority:tier3-future` - Future features - deferred until after v1.0
3. **Type/Scope Labels** (effort and complexity):
- `Major Feature` - Significant time and effort required
- `Minor Feature` - Some effort required to create or overhaul functionality
- `Tiny Feature` - Quick and easy - a few lines or little interconnection
- `Bugfix` - Fixes incorrect behavior
- `Refactoring & Cleanup` - No new functionality, just improving codebase
- `Documentation` - Documentation work
- `Demo Target` - Functionality to demonstrate
4. **Workflow Labels** (current blockers/needs):
- `workflow:blocked` - Blocked by other work - waiting on dependencies
- `workflow:needs-documentation` - Needs documentation before or after implementation
- `workflow:needs-benchmark` - Needs performance testing and benchmarks
- `Alpha Release Requirement` - Blocker to 0.1 Alpha release
**When creating issues:**
- Apply at least one `system:*` label (what part of codebase)
- Apply one `priority:tier*` label (when to address it)
- Apply one type label (`Major Feature`, `Minor Feature`, `Tiny Feature`, or `Bugfix`)
- Apply `workflow:*` labels if applicable (blocked, needs docs, needs benchmarks)
**Example label combinations:**
- New rendering feature: `system:rendering`, `priority:tier2-foundation`, `Major Feature`
- Python API improvement: `system:python-binding`, `priority:tier1-active`, `Minor Feature`
- Performance work: `system:performance`, `priority:tier1-active`, `Major Feature`, `workflow:needs-benchmark`
**⚠️ CRITICAL BUG**: The Gitea MCP tool (v0.07) has a label application bug documented in `GITEA_MCP_LABEL_BUG_REPORT.md`:
- `add_issue_labels` and `replace_issue_labels` behave inconsistently
- Single ID arrays produce different results than multi-ID arrays for the SAME IDs
- Label IDs do not map reliably to actual labels
**Workaround Options:**
1. **Best**: Apply labels manually via web interface: `https://gamedev.ffwf.net/gitea/john/McRogueFace/issues/<number>`
2. **Automated**: Apply labels ONE AT A TIME using single-element arrays (slower but more reliable)
3. **Use single-ID mapping** (documented below)
**Label ID Reference** (for documentation purposes - see issue #131 for details):
```
1=Major Feature, 2=Alpha Release, 3=Bugfix, 4=Demo Target, 5=Documentation,
6=Minor Feature, 7=tier1-active, 8=tier2-foundation, 9=tier3-future,
10=Refactoring, 11=animation, 12=docs, 13=grid, 14=input, 15=performance,
16=python-binding, 17=rendering, 18=ui-hierarchy, 19=Tiny Feature,
20=blocked, 21=needs-benchmark, 22=needs-documentation
```
## Build Commands
```bash
# Build the project (compiles to ./build directory)
make
# Or use the build script directly
./build.sh
# Run the game
make run
# Clean build artifacts
make clean
# The executable and all assets are in ./build/
cd build
./mcrogueface
```
## Project Architecture
McRogueFace is a C++ game engine with Python scripting support, designed for creating roguelike games. The architecture consists of:
### Core Engine (C++)
- **Entry Point**: `src/main.cpp` initializes the game engine
- **Scene System**: `Scene.h/cpp` manages game states
- **Entity System**: `UIEntity.h/cpp` provides game objects
- **Python Integration**: `McRFPy_API.h/cpp` exposes engine functionality to Python
- **UI Components**: `UIFrame`, `UICaption`, `UISprite`, `UIGrid` for rendering
### Game Logic (Python)
- **Main Script**: `src/scripts/game.py` contains game initialization and scene setup
- **Entity System**: `src/scripts/cos_entities.py` implements game entities (Player, Enemy, Boulder, etc.)
- **Level Generation**: `src/scripts/cos_level.py` uses BSP for procedural dungeon generation
- **Tile System**: `src/scripts/cos_tiles.py` implements Wave Function Collapse for tile placement
### Key Python API (`mcrfpy` module)
The C++ engine exposes these primary functions to Python:
- Scene Management: `createScene()`, `setScene()`, `sceneUI()`
- Entity Creation: `Entity()` with position and sprite properties
- Grid Management: `Grid()` for tilemap rendering
- Input Handling: `keypressScene()` for keyboard events
- Audio: `createSoundBuffer()`, `playSound()`, `setVolume()`
- Timers: `setTimer()`, `delTimer()` for event scheduling
## Development Workflow
### Running the Game
After building, the executable expects:
- `assets/` directory with sprites, fonts, and audio
- `scripts/` directory with Python game files
- Python 3.12 shared libraries in `./lib/`
### Modifying Game Logic
- Game scripts are in `src/scripts/`
- Main game entry is `game.py`
- Entity behavior in `cos_entities.py`
- Level generation in `cos_level.py`
### Adding New Features
1. C++ API additions go in `src/McRFPy_API.cpp`
2. Expose to Python using the existing binding pattern
3. Update Python scripts to use new functionality
## Testing Game Changes
Currently no automated test suite. Manual testing workflow:
1. Build with `make`
2. Run `make run` or `cd build && ./mcrogueface`
3. Test specific features through gameplay
4. Check console output for Python errors
### Quick Testing Commands
```bash
# Test basic functionality
make test
# Run in Python interactive mode
make python
# Test headless mode
cd build
./mcrogueface --headless -c "import mcrfpy; print('Headless test')"
```
## Common Development Tasks
### Compiling McRogueFace
```bash
# Standard build (to ./build directory)
make
# Full rebuild
make clean && make
# Manual CMake build
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
make -j$(nproc)
# The library path issue: if linking fails, check that libraries are in __lib/
# CMakeLists.txt expects: link_directories(${CMAKE_SOURCE_DIR}/__lib)
```
### Running and Capturing Output
```bash
# Run with timeout and capture output
cd build
timeout 5 ./mcrogueface 2>&1 | tee output.log
# Run in background and kill after delay
./mcrogueface > output.txt 2>&1 & PID=$!; sleep 3; kill $PID 2>/dev/null
# Just capture first N lines (useful for crashes)
./mcrogueface 2>&1 | head -50
```
### Debugging with GDB
```bash
# Interactive debugging
gdb ./mcrogueface
(gdb) run
(gdb) bt # backtrace after crash
# Batch mode debugging (non-interactive)
gdb -batch -ex run -ex where -ex quit ./mcrogueface 2>&1
# Get just the backtrace after a crash
gdb -batch -ex "run" -ex "bt" ./mcrogueface 2>&1 | head -50
# Debug with specific commands
echo -e "run\nbt 5\nquit\ny" | gdb ./mcrogueface 2>&1
```
### Testing Different Python Scripts
```bash
# The game automatically runs build/scripts/game.py on startup
# To test different behavior:
# Option 1: Replace game.py temporarily
cd build
cp scripts/my_test_script.py scripts/game.py
./mcrogueface
# Option 2: Backup original and test
mv scripts/game.py scripts/game.py.bak
cp my_test.py scripts/game.py
./mcrogueface
mv scripts/game.py.bak scripts/game.py
# Option 3: For quick tests, create minimal game.py
echo 'import mcrfpy; print("Test"); mcrfpy.createScene("test")' > scripts/game.py
```
### Understanding Key Macros and Patterns
#### RET_PY_INSTANCE Macro (UIDrawable.h)
This macro handles converting C++ UI objects to their Python equivalents:
```cpp
RET_PY_INSTANCE(target);
// Expands to a switch on target->derived_type() that:
// 1. Allocates the correct Python object type (Frame, Caption, Sprite, Grid)
// 2. Sets the shared_ptr data member
// 3. Returns the PyObject*
```
#### Collection Patterns
- `UICollection` wraps `std::vector<std::shared_ptr<UIDrawable>>`
- `UIEntityCollection` wraps `std::list<std::shared_ptr<UIEntity>>`
- Different containers require different iteration code (vector vs list)
#### Python Object Creation Patterns
```cpp
// Pattern 1: Using tp_alloc (most common)
auto o = (PyUIFrameObject*)type->tp_alloc(type, 0);
o->data = std::make_shared<UIFrame>();
// Pattern 2: Getting type from module
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity");
auto o = (PyUIEntityObject*)type->tp_alloc(type, 0);
// Pattern 3: Direct shared_ptr assignment
iterObj->data = self->data; // Shares the C++ object
```
### Working Directory Structure
```
build/
├── mcrogueface # The executable
├── scripts/
│ └── game.py # Auto-loaded Python script
├── assets/ # Copied from source during build
└── lib/ # Python libraries (copied from __lib/)
```
### Quick Iteration Tips
- Keep a test script ready for quick experiments
- Use `timeout` to auto-kill hanging processes
- The game expects a window manager; use Xvfb for headless testing
- Python errors go to stderr, game output to stdout
- Segfaults usually mean Python type initialization issues
## Important Notes
- The project uses SFML for graphics/audio and libtcod for roguelike utilities
- Python scripts are loaded at runtime from the `scripts/` directory
- Asset loading expects specific paths relative to the executable
- The game was created for 7DRL 2025 as "Crypt of Sokoban"
- Iterator implementations require careful handling of C++/Python boundaries
## Testing Guidelines
### Test-Driven Development
- **Always write tests first**: Create automation tests in `./tests/` for all bugs and new features
- **Practice TDD**: Write tests that fail to demonstrate the issue, then pass after the fix is applied
- **Close the loop**: Reproduce issue → change code → recompile → verify behavior change
### Two Types of Tests
#### 1. Direct Execution Tests (No Game Loop)
For tests that only need class initialization or direct code execution:
```python
# These tests can treat McRogueFace like a Python interpreter
import mcrfpy
# Test code here
result = mcrfpy.some_function()
assert result == expected_value
print("PASS" if condition else "FAIL")
```
#### 2. Game Loop Tests (Timer-Based)
For tests requiring rendering, game state, or elapsed time:
```python
import mcrfpy
from mcrfpy import automation
import sys
def run_test(runtime):
"""Timer callback - runs after game loop starts"""
# Now rendering is active, screenshots will work
automation.screenshot("test_result.png")
# Run your tests here
automation.click(100, 100)
# Always exit at the end
print("PASS" if success else "FAIL")
sys.exit(0)
# Set up the test scene
mcrfpy.createScene("test")
# ... add UI elements ...
# Schedule test to run after game loop starts
mcrfpy.setTimer("test", run_test, 100) # 0.1 seconds
```
### Key Testing Principles
- **Timer callbacks are essential**: Screenshots and UI interactions only work after the render loop starts
- **Use automation API**: Always create and examine screenshots when visual feedback is required
- **Exit properly**: Call `sys.exit()` at the end of timer-based tests to prevent hanging
- **Headless mode**: Use `--exec` flag for automated testing: `./mcrogueface --headless --exec tests/my_test.py`
### Example Test Pattern
```bash
# Run a test that requires game loop
./build/mcrogueface --headless --exec tests/issue_78_middle_click_test.py
# The test will:
# 1. Set up the scene during script execution
# 2. Register a timer callback
# 3. Game loop starts
# 4. Timer fires after 100ms
# 5. Test runs with full rendering available
# 6. Test takes screenshots and validates behavior
# 7. Test calls sys.exit() to terminate
```
## Development Best Practices
### Testing and Deployment
- **Keep tests in ./tests, not ./build/tests** - ./build gets shipped, and tests shouldn't be included
## Documentation Guidelines
### Documentation Macro System
**As of 2025-10-30, McRogueFace uses a macro-based documentation system** (`src/McRFPy_Doc.h`) that ensures consistent, complete docstrings across all Python bindings.
#### Include the Header
```cpp
#include "McRFPy_Doc.h"
```
#### Documenting Methods
For methods in PyMethodDef arrays, use `MCRF_METHOD`:
```cpp
{"method_name", (PyCFunction)Class::method, METH_VARARGS,
MCRF_METHOD(ClassName, method_name,
MCRF_SIG("(arg1: type, arg2: type)", "return_type"),
MCRF_DESC("Brief description of what the method does."),
MCRF_ARGS_START
MCRF_ARG("arg1", "Description of first argument")
MCRF_ARG("arg2", "Description of second argument")
MCRF_RETURNS("Description of return value")
MCRF_RAISES("ValueError", "Condition that raises this exception")
MCRF_NOTE("Important notes or caveats")
MCRF_LINK("docs/guide.md", "Related Documentation")
)},
```
#### Documenting Properties
For properties in PyGetSetDef arrays, use `MCRF_PROPERTY`:
```cpp
{"property_name", (getter)getter_func, (setter)setter_func,
MCRF_PROPERTY(property_name,
"Brief description of the property. "
"Additional details about valid values, side effects, etc."
), NULL},
```
#### Available Macros
- `MCRF_SIG(params, ret)` - Method signature
- `MCRF_DESC(text)` - Description paragraph
- `MCRF_ARGS_START` - Begin arguments section
- `MCRF_ARG(name, desc)` - Individual argument
- `MCRF_RETURNS(text)` - Return value description
- `MCRF_RAISES(exception, condition)` - Exception documentation
- `MCRF_NOTE(text)` - Important notes
- `MCRF_LINK(path, text)` - Reference to external documentation
#### Documentation Prose Guidelines
**Keep C++ docstrings concise** (1-2 sentences per section). For complex topics, use `MCRF_LINK` to reference external guides:
```cpp
MCRF_LINK("docs/animation-guide.md", "Animation System Tutorial")
```
**External documentation** (in `docs/`) can be verbose with examples, tutorials, and design rationale.
### Regenerating Documentation
After modifying C++ inline documentation with MCRF_* macros:
1. **Rebuild the project**: `make -j$(nproc)`
2. **Generate all documentation** (recommended - single command):
```bash
./tools/generate_all_docs.sh
```
This creates:
- `docs/api_reference_dynamic.html` - HTML API reference
- `docs/API_REFERENCE_DYNAMIC.md` - Markdown API reference
- `docs/mcrfpy.3` - Unix man page (section 3)
- `stubs/mcrfpy.pyi` - Type stubs for IDE support
3. **Or generate individually**:
```bash
# API docs (HTML + Markdown)
./build/mcrogueface --headless --exec tools/generate_dynamic_docs.py
# Type stubs (manually-maintained with @overload support)
./build/mcrogueface --headless --exec tools/generate_stubs_v2.py
# Man page (requires pandoc)
./tools/generate_man_page.sh
```
**System Requirements:**
- `pandoc` must be installed for man page generation: `sudo apt-get install pandoc`
### Important Notes
- **Single source of truth**: Documentation lives in C++ source files via MCRF_* macros
- **McRogueFace as Python interpreter**: Documentation scripts MUST be run using McRogueFace itself, not system Python
- **Use --headless --exec**: For non-interactive documentation generation
- **Link transformation**: `MCRF_LINK` references are transformed to appropriate format (HTML, Markdown, etc.)
- **No manual dictionaries**: The old hardcoded documentation system has been removed
### Documentation Pipeline Architecture
1. **C++ Source** → MCRF_* macros in PyMethodDef/PyGetSetDef arrays
2. **Compilation** → Macros expand to complete docstrings embedded in module
3. **Introspection** → Scripts use `dir()`, `getattr()`, `__doc__` to extract
4. **Generation** → HTML/Markdown/Stub files created with transformed links
5. **No drift**: Impossible for docs and code to disagree - they're the same file!
The macro system ensures complete, consistent documentation across all Python bindings.
- Close issues automatically in gitea by adding to the commit message "closes #X", where X is the issue number. This associates the issue closure with the specific commit, so granular commits are preferred. You should only use the MCP tool to close issues directly when discovering that the issue is already complete; when committing changes, always such "closes" (or the opposite, "reopens") references to related issues. If on a feature branch, the issue will be referenced by the commit, and when merged to master, the issue will be actually closed (or reopened).

View File

@ -22,11 +22,6 @@ file(GLOB_RECURSE SOURCES "src/*.cpp")
# Create a list of libraries to link against # Create a list of libraries to link against
set(LINK_LIBS set(LINK_LIBS
m
dl
util
pthread
python3.12
sfml-graphics sfml-graphics
sfml-window sfml-window
sfml-system sfml-system
@ -35,22 +30,33 @@ set(LINK_LIBS
# On Windows, add any additional libs and include directories # On Windows, add any additional libs and include directories
if(WIN32) if(WIN32)
# Windows-specific Python library name (no dots)
list(APPEND LINK_LIBS python312)
# Add the necessary Windows-specific libraries and include directories # Add the necessary Windows-specific libraries and include directories
# include_directories(path_to_additional_includes) # include_directories(path_to_additional_includes)
# link_directories(path_to_additional_libs) # link_directories(path_to_additional_libs)
# list(APPEND LINK_LIBS additional_windows_libs) # list(APPEND LINK_LIBS additional_windows_libs)
include_directories(${CMAKE_SOURCE_DIR}/deps/platform/windows) include_directories(${CMAKE_SOURCE_DIR}/deps/platform/windows)
else() else()
# Unix/Linux specific libraries
list(APPEND LINK_LIBS python3.12 m dl util pthread)
include_directories(${CMAKE_SOURCE_DIR}/deps/platform/linux) include_directories(${CMAKE_SOURCE_DIR}/deps/platform/linux)
endif() endif()
# Add the directory where the linker should look for the libraries # Add the directory where the linker should look for the libraries
#link_directories(${CMAKE_SOURCE_DIR}/deps_linux) #link_directories(${CMAKE_SOURCE_DIR}/deps_linux)
link_directories(${CMAKE_SOURCE_DIR}/lib) link_directories(${CMAKE_SOURCE_DIR}/__lib)
# Define the executable target before linking libraries # Define the executable target before linking libraries
add_executable(mcrogueface ${SOURCES}) add_executable(mcrogueface ${SOURCES})
# On Windows, set subsystem to WINDOWS to hide console
if(WIN32)
set_target_properties(mcrogueface PROPERTIES
WIN32_EXECUTABLE TRUE
LINK_FLAGS "/SUBSYSTEM:WINDOWS /ENTRY:mainCRTStartup")
endif()
# Now the linker will find the libraries in the specified directory # Now the linker will find the libraries in the specified directory
target_link_libraries(mcrogueface ${LINK_LIBS}) target_link_libraries(mcrogueface ${LINK_LIBS})
@ -67,9 +73,28 @@ add_custom_command(TARGET mcrogueface POST_BUILD
# Copy Python standard library to build directory # Copy Python standard library to build directory
add_custom_command(TARGET mcrogueface POST_BUILD add_custom_command(TARGET mcrogueface POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_SOURCE_DIR}/lib $<TARGET_FILE_DIR:mcrogueface>/lib) ${CMAKE_SOURCE_DIR}/__lib $<TARGET_FILE_DIR:mcrogueface>/lib)
# rpath for including shared libraries # On Windows, copy DLLs to executable directory
if(WIN32)
# Copy all DLL files from lib to the executable directory
add_custom_command(TARGET mcrogueface POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_SOURCE_DIR}/__lib $<TARGET_FILE_DIR:mcrogueface>
COMMAND ${CMAKE_COMMAND} -E echo "Copied DLLs to executable directory")
# Alternative: Copy specific DLLs if you want more control
# file(GLOB DLLS "${CMAKE_SOURCE_DIR}/__lib/*.dll")
# foreach(DLL ${DLLS})
# add_custom_command(TARGET mcrogueface POST_BUILD
# COMMAND ${CMAKE_COMMAND} -E copy_if_different
# ${DLL} $<TARGET_FILE_DIR:mcrogueface>)
# endforeach()
endif()
# rpath for including shared libraries (Linux/Unix only)
if(NOT WIN32)
set_target_properties(mcrogueface PROPERTIES set_target_properties(mcrogueface PROPERTIES
INSTALL_RPATH "./lib") INSTALL_RPATH "$ORIGIN/./lib")
endif()

54
GNUmakefile Normal file
View File

@ -0,0 +1,54 @@
# Convenience Makefile wrapper for McRogueFace
# This delegates to CMake build in the build directory
.PHONY: all build clean run test dist help
# Default target
all: build
# Build the project
build:
@./build.sh
# Clean build artifacts
clean:
@./clean.sh
# Run the game
run: build
@cd build && ./mcrogueface
# Run in Python mode
python: build
@cd build && ./mcrogueface -i
# Test basic functionality
test: build
@echo "Testing McRogueFace..."
@cd build && ./mcrogueface -V
@cd build && ./mcrogueface -c "print('Test passed')"
@cd build && ./mcrogueface --headless -c "import mcrfpy; print('mcrfpy imported successfully')"
# Create distribution archive
dist: build
@echo "Creating distribution archive..."
@cd build && zip -r ../McRogueFace-$$(date +%Y%m%d).zip . -x "*.o" "CMakeFiles/*" "Makefile" "*.cmake"
@echo "Distribution archive created: McRogueFace-$$(date +%Y%m%d).zip"
# Show help
help:
@echo "McRogueFace Build System"
@echo "======================="
@echo ""
@echo "Available targets:"
@echo " make - Build the project (default)"
@echo " make build - Build the project"
@echo " make clean - Remove all build artifacts"
@echo " make run - Build and run the game"
@echo " make python - Build and run in Python interactive mode"
@echo " make test - Run basic tests"
@echo " make dist - Create distribution archive"
@echo " make help - Show this help message"
@echo ""
@echo "Build output goes to: ./build/"
@echo "Distribution archives are created in project root"

161
README.md
View File

@ -1,30 +1,145 @@
# McRogueFace - 2D Game Engine # McRogueFace
An experimental prototype game engine built for my own use in 7DRL 2023.
*Blame my wife for the name* *Blame my wife for the name*
## Tenets: A Python-powered 2D game engine for creating roguelike games, built with C++ and SFML.
* C++ first, Python close behind. * Core roguelike logic from libtcod: field of view, pathfinding
* Entity-Component system based on David Churchill's Memorial University COMP4300 course lectures available on Youtube. * Animate sprites with multiple frames. Smooth transitions for positions, sizes, zoom, and camera
* Graphics, particles and shaders provided by SFML. * Simple GUI element system allows keyboard and mouse input, composition
* Pathfinding, noise generation, and other Roguelike goodness provided by TCOD. * No compilation or installation necessary. The runtime is a full Python environment; "Zip And Ship"
## Why? ![ Image ]()
I did the r/RoguelikeDev TCOD tutorial in Python. I loved it, but I did not want to be limited to ASCII. I want to be able to draw pixels on top of my tiles (like lines or circles) and eventually incorporate even more polish. **Pre-Alpha Release Demo**: my 7DRL 2025 entry *"Crypt of Sokoban"* - a prototype with buttons, boulders, enemies, and items.
## To-do ## Quick Start
* ✅ Initial Commit **Download**:
* ✅ Integrate scene, action, entity, component system from COMP4300 engine
* ✅ Windows / Visual Studio project - The entire McRogueFace visual framework:
* ✅ Draw Sprites - **Sprite**: an image file or one sprite from a shared sprite sheet
* ✅ Play Sounds - **Caption**: load a font, display text
* ✅ Draw UI, spawn entity from Python code - **Frame**: A rectangle; put other things on it to move or manage GUIs as modules
* ❌ Python AI for entities (NPCs on set paths, enemies towards player) - **Grid**: A 2D array of tiles with zoom + position control
* ✅ Walking / Collision - **Entity**: Lives on a Grid, displays a sprite, and can have a perspective or move along a path
* ❌ "Boards" (stairs / doors / walk off edge of screen) - **Animation**: Change any property on any of the above over time
* ❌ Cutscenes - interrupt normal controls, text scroll, character portraits
* ❌ Mouse integration - tooltips, zoom, click to select targets, cursors ```bash
# Clone and build
git clone <wherever you found this repo>
cd McRogueFace
make
# Run the example game
cd build
./mcrogueface
```
## Example: Creating a Simple Scene
```python
import mcrfpy
# Create a new scene
mcrfpy.createScene("intro")
# Add a text caption
caption = mcrfpy.Caption((50, 50), "Welcome to McRogueFace!")
caption.size = 48
caption.fill_color = (255, 255, 255)
# Add to scene
mcrfpy.sceneUI("intro").append(caption)
# Switch to the scene
mcrfpy.setScene("intro")
```
## Documentation
### 📚 Developer Documentation
For comprehensive documentation about systems, architecture, and development workflows:
**[Project Wiki](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki)**
Key wiki pages:
- **[Home](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Home)** - Documentation hub with multiple entry points
- **[Grid System](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Grid-System)** - Three-layer grid architecture
- **[Python Binding System](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Python-Binding-System)** - C++/Python integration
- **[Performance and Profiling](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Performance-and-Profiling)** - Optimization tools
- **[Adding Python Bindings](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Adding-Python-Bindings)** - Step-by-step binding guide
- **[Issue Roadmap](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Issue-Roadmap)** - All 46 open issues organized by system
### 📖 Development Guides
In the repository root:
- **[CLAUDE.md](CLAUDE.md)** - Build instructions, testing guidelines, common tasks
- **[ROADMAP.md](ROADMAP.md)** - Strategic vision and development phases
- **[roguelike_tutorial/](roguelike_tutorial/)** - Complete roguelike tutorial implementations
## Build Requirements
- C++17 compiler (GCC 7+ or Clang 5+)
- CMake 3.14+
- Python 3.12+
- SFML 2.6
- Linux or Windows (macOS untested)
## Project Structure
```
McRogueFace/
├── assets/ # Sprites, fonts, audio
├── build/ # Build output directory: zip + ship
│ ├─ (*)assets/ # (copied location of assets)
│ ├─ (*)scripts/ # (copied location of src/scripts)
│ └─ lib/ # SFML, TCOD libraries, Python + standard library / modules
├── deps/ # Python, SFML, and libtcod imports can be tossed in here to build
│ └─ platform/ # windows, linux subdirectories for OS-specific cpython config
├── docs/ # generated HTML, markdown docs
│ └─ stubs/ # .pyi files for editor integration
├── modules/ # git submodules, to build all of McRogueFace's dependencies from source
├── src/ # C++ engine source
│ └─ scripts/ # Python game scripts (copied during build)
└── tests/ # Automated test suite
└── tools/ # For the McRogueFace ecosystem: docs generation
```
If you are building McRogueFace to implement game logic or scene configuration in C++, you'll have to compile the project.
If you are writing a game in Python using McRogueFace, you only need to rename and zip/distribute the `build` directory.
## Philosophy
- **C++ every frame, Python every tick**: All rendering data is handled in C++. Structure your UI and program animations in Python, and they are rendered without Python. All game logic can be written in Python.
- **No Compiling Required; Zip And Ship**: Implement your game objects with Python, zip up McRogueFace with your "game.py" to ship
- **Built-in Roguelike Support**: Dungeon generation, pathfinding, and field-of-view via libtcod
- **Hands-Off Testing**: PyAutoGUI-inspired event generation framework. All McRogueFace interactions can be performed headlessly via script: for software testing or AI integration
- **Interactive Development**: Python REPL integration for live game debugging. Use `mcrogueface` like a Python interpreter
## Contributing
PRs will be considered! Please include explicit mention that your contribution is your own work and released under the MIT license in the pull request.
### Issue Tracking
The project uses [Gitea Issues](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues) for task tracking and bug reports. Issues are organized with labels:
- **System labels** (grid, animation, python-binding, etc.) - identify which codebase area
- **Priority labels** (tier1-active, tier2-foundation, tier3-future) - development timeline
- **Type labels** (Major Feature, Minor Feature, Bugfix, etc.) - effort and scope
See the [Issue Roadmap](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Issue-Roadmap) on the wiki for organized view of all open tasks.
## License
This project is licensed under the MIT License - see LICENSE file for details.
## Acknowledgments
- Developed for 7-Day Roguelike 2023, 2024, 2025 - here's to many more
- Built with [SFML](https://www.sfml-dev.org/), [libtcod](https://github.com/libtcod/libtcod), and Python
- Inspired by David Churchill's COMP4300 game engine lectures

223
ROADMAP.md Normal file
View File

@ -0,0 +1,223 @@
# McRogueFace - Development Roadmap
## Project Status
**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
---
## 🎯 Strategic Vision
### Engine Philosophy
- **C++ First**: Performance-critical code stays in C++
- **Python Close Behind**: Rich scripting without frame-rate impact
- **Game-Ready**: Each improvement should benefit actual game development
### 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
---
## 🏗️ Architecture Decisions
### Three-Layer Grid Architecture
Following successful roguelike patterns (Caves of Qud, Cogmind, DCSS):
1. **Visual Layer** (UIGridPoint) - Sprites, colors, animations
2. **World State Layer** (TCODMap) - Walkability, transparency, physics
3. **Entity Perspective Layer** (UIGridPointState) - Per-entity FOV, knowledge
### Performance Architecture
Critical for large maps (1000x1000):
- **Spatial Hashing** for entity queries (not quadtrees!)
- **Batch Operations** with context managers (10-100x speedup)
- **Memory Pooling** for entities and components
- **Dirty Flag System** to avoid unnecessary updates
- **Zero-Copy NumPy Integration** via buffer protocol
### Key Insight from Research
"Minimizing Python/C++ boundary crossings matters more than individual function complexity"
- Batch everything possible
- Use context managers for logical operations
- Expose arrays, not individual cells
- Profile and optimize hot paths only
---
## 🚀 Development Phases
For detailed task tracking and current priorities, see the [Gitea issue tracker](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues).
### Phase 1: Foundation Stabilization ✅
**Status**: Complete
**Key Issues**: #7 (Safe Constructors), #71 (Base Class), #87 (Visibility), #88 (Opacity)
### Phase 2: Constructor & API Polish ✅
**Status**: Complete
**Key Features**: Pythonic API, tuple support, standardized defaults
### Phase 3: Entity Lifecycle Management ✅
**Status**: Complete
**Key Issues**: #30 (Entity.die()), #93 (Vector methods), #94 (Color helpers), #103 (Timer objects)
### Phase 4: Visibility & Performance ✅
**Status**: Complete
**Key Features**: AABB culling, name system, profiling tools
### Phase 5: Window/Scene Architecture ✅
**Status**: Complete
**Key Issues**: #34 (Window object), #61 (Scene object), #1 (Resize events), #105 (Scene transitions)
### Phase 6: Rendering Revolution ✅
**Status**: Complete
**Key Issues**: #50 (Grid backgrounds), #6 (RenderTexture), #8 (Viewport rendering)
### Phase 7: Documentation & Distribution ✅
**Status**: Complete (2025-10-30)
**Key Issues**: #85 (Docstrings), #86 (Parameter docs), #108 (Type stubs), #97 (API docs)
**Completed**: All classes and functions converted to MCRF_* macro system with automated HTML/Markdown/man page generation
See [current open issues](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues?state=open) for active work.
---
## 🔮 Future Vision: Pure Python Extension Architecture
### Concept: McRogueFace as a Traditional Python Package
**Status**: Long-term vision
**Complexity**: Major architectural overhaul
Instead of being a C++ application that embeds Python, McRogueFace could be redesigned as a pure Python extension module that can be installed via `pip install mcrogueface`.
### Technical Approach
1. **Separate Core Engine from Python Embedding**
- Extract SFML rendering, audio, and input into C++ extension modules
- Remove embedded CPython interpreter
- Use Python's C API to expose functionality
2. **Module Structure**
```
mcrfpy/
├── __init__.py # Pure Python coordinator
├── _core.so # C++ rendering/game loop extension
├── _sfml.so # SFML bindings
├── _audio.so # Audio system bindings
└── engine.py # Python game engine logic
```
3. **Inverted Control Flow**
- Python drives the main loop instead of C++
- C++ extensions handle performance-critical operations
- Python manages game logic, scenes, and entity systems
### Benefits
- **Standard Python Packaging**: `pip install mcrogueface`
- **Virtual Environment Support**: Works with venv, conda, poetry
- **Better IDE Integration**: Standard Python development workflow
- **Easier Testing**: Use pytest, standard Python testing tools
- **Cross-Python Compatibility**: Support multiple Python versions
- **Modular Architecture**: Users can import only what they need
### Challenges
- **Major Refactoring**: Complete restructure of codebase
- **Performance Considerations**: Python-driven main loop overhead
- **Build Complexity**: Multiple extension modules to compile
- **Platform Support**: Need wheels for many platform/Python combinations
- **API Stability**: Would need careful design to maintain compatibility
### Example Usage (Future Vision)
```python
import mcrfpy
from mcrfpy import Scene, Frame, Sprite, Grid
# Create game directly in Python
game = mcrfpy.Game(width=1024, height=768)
# Define scenes using Python classes
class MainMenu(Scene):
def on_enter(self):
self.ui.append(Frame(100, 100, 200, 50))
self.ui.append(Sprite("logo.png", x=400, y=100))
def on_keypress(self, key, pressed):
if key == "ENTER" and pressed:
self.game.set_scene("game")
# Run the game
game.add_scene("menu", MainMenu())
game.run()
```
This architecture would make McRogueFace a first-class Python citizen, following standard Python packaging conventions while maintaining high performance through C++ extensions.
---
## 📋 Major Feature Areas
For current status and detailed tasks, see the corresponding Gitea issue labels:
### 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
### Performance Optimization
- **#115**: SpatialHash for 10,000+ entities
- **#116**: Dirty flag system
- **#113**: Batch operations for NumPy-style access
- **#117**: Memory pool for entities
### 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
---
## 📚 Resources
- **Issue Tracker**: [Gitea Issues](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues)
- **Source Code**: [Gitea Repository](https://gamedev.ffwf.net/gitea/john/McRogueFace)
- **Documentation**: See `CLAUDE.md` for build instructions and development guide
- **Tutorial**: See `roguelike_tutorial/` for implementation examples
- **Workflow**: See "Gitea-First Workflow" section in `CLAUDE.md` for issue management best practices
---
## 🔄 Development Workflow
**Gitea is the Single Source of Truth** for this project. Before starting any work:
1. **Check Gitea Issues** for existing tasks, bugs, or related work
2. **Create granular issues** for new features or problems
3. **Update issues** when work affects other systems
4. **Document discoveries** - if something is undocumented or misleading, create a task to fix it
5. **Cross-reference commits** with issue numbers (e.g., "Fixes #104")
See the "Gitea-First Workflow" section in `CLAUDE.md` for detailed guidelines on efficient development practices using the Gitea MCP tools.
---
*For current priorities, task tracking, and bug reports, please use the [Gitea issue tracker](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues).*

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

BIN
assets/kenney_TD_MR_IP.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

BIN
assets/sfx/splat1.ogg Normal file

Binary file not shown.

BIN
assets/sfx/splat2.ogg Normal file

Binary file not shown.

BIN
assets/sfx/splat3.ogg Normal file

Binary file not shown.

BIN
assets/sfx/splat4.ogg Normal file

Binary file not shown.

BIN
assets/sfx/splat5.ogg Normal file

Binary file not shown.

BIN
assets/sfx/splat6.ogg Normal file

Binary file not shown.

BIN
assets/sfx/splat7.ogg Normal file

Binary file not shown.

BIN
assets/sfx/splat8.ogg Normal file

Binary file not shown.

BIN
assets/sfx/splat9.ogg Normal file

Binary file not shown.

54
build.sh Executable file
View File

@ -0,0 +1,54 @@
#!/bin/bash
# Build script for McRogueFace - compiles everything into ./build directory
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}McRogueFace Build Script${NC}"
echo "========================="
# Create build directory if it doesn't exist
if [ ! -d "build" ]; then
echo -e "${YELLOW}Creating build directory...${NC}"
mkdir build
fi
# Change to build directory
cd build
# Run CMake to generate build files
echo -e "${YELLOW}Running CMake...${NC}"
cmake .. -DCMAKE_BUILD_TYPE=Release
# Check if CMake succeeded
if [ $? -ne 0 ]; then
echo -e "${RED}CMake configuration failed!${NC}"
exit 1
fi
# Run make with parallel jobs
echo -e "${YELLOW}Building with make...${NC}"
make -j$(nproc)
# Check if make succeeded
if [ $? -ne 0 ]; then
echo -e "${RED}Build failed!${NC}"
exit 1
fi
echo -e "${GREEN}Build completed successfully!${NC}"
echo ""
echo "The build directory contains:"
ls -la
echo ""
echo -e "${GREEN}To run McRogueFace:${NC}"
echo " cd build"
echo " ./mcrogueface"
echo ""
echo -e "${GREEN}To create a distribution archive:${NC}"
echo " cd build"
echo " zip -r ../McRogueFace-$(date +%Y%m%d).zip ."

36
build_windows.bat Normal file
View File

@ -0,0 +1,36 @@
@echo off
REM Windows build script for McRogueFace
REM Run this over SSH without Visual Studio GUI
echo Building McRogueFace for Windows...
REM Clean previous build
if exist build_win rmdir /s /q build_win
mkdir build_win
cd build_win
REM Generate Visual Studio project files with CMake
REM Use -G to specify generator, -A for architecture
REM Visual Studio 2022 = "Visual Studio 17 2022"
REM Visual Studio 2019 = "Visual Studio 16 2019"
cmake -G "Visual Studio 17 2022" -A x64 ..
if errorlevel 1 (
echo CMake configuration failed!
exit /b 1
)
REM Build using MSBuild (comes with Visual Studio)
REM You can also use cmake --build . --config Release
msbuild McRogueFace.sln /p:Configuration=Release /p:Platform=x64 /m
if errorlevel 1 (
echo Build failed!
exit /b 1
)
echo Build completed successfully!
echo Executable location: build_win\Release\mcrogueface.exe
REM Alternative: Using cmake to build (works with any generator)
REM cmake --build . --config Release --parallel
cd ..

42
build_windows_cmake.bat Normal file
View File

@ -0,0 +1,42 @@
@echo off
REM Windows build script using cmake --build (generator-agnostic)
REM This version works with any CMake generator
echo Building McRogueFace for Windows using CMake...
REM Set build directory
set BUILD_DIR=build_win
set CONFIG=Release
REM Clean previous build
if exist %BUILD_DIR% rmdir /s /q %BUILD_DIR%
mkdir %BUILD_DIR%
cd %BUILD_DIR%
REM Configure with CMake
REM You can change the generator here if needed:
REM -G "Visual Studio 17 2022" (VS 2022)
REM -G "Visual Studio 16 2019" (VS 2019)
REM -G "MinGW Makefiles" (MinGW)
REM -G "Ninja" (Ninja build system)
cmake -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=%CONFIG% ..
if errorlevel 1 (
echo CMake configuration failed!
cd ..
exit /b 1
)
REM Build using cmake (works with any generator)
cmake --build . --config %CONFIG% --parallel
if errorlevel 1 (
echo Build failed!
cd ..
exit /b 1
)
echo.
echo Build completed successfully!
echo Executable: %BUILD_DIR%\%CONFIG%\mcrogueface.exe
echo.
cd ..

112
compile_commands.json Normal file
View File

@ -0,0 +1,112 @@
[
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/GameEngine.cpp.o -c /home/john/Development/McRogueFace/src/GameEngine.cpp",
"file": "/home/john/Development/McRogueFace/src/GameEngine.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/IndexTexture.cpp.o -c /home/john/Development/McRogueFace/src/IndexTexture.cpp",
"file": "/home/john/Development/McRogueFace/src/IndexTexture.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/McRFPy_API.cpp.o -c /home/john/Development/McRogueFace/src/McRFPy_API.cpp",
"file": "/home/john/Development/McRogueFace/src/McRFPy_API.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/PyCallable.cpp.o -c /home/john/Development/McRogueFace/src/PyCallable.cpp",
"file": "/home/john/Development/McRogueFace/src/PyCallable.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/PyColor.cpp.o -c /home/john/Development/McRogueFace/src/PyColor.cpp",
"file": "/home/john/Development/McRogueFace/src/PyColor.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/PyFont.cpp.o -c /home/john/Development/McRogueFace/src/PyFont.cpp",
"file": "/home/john/Development/McRogueFace/src/PyFont.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/PyScene.cpp.o -c /home/john/Development/McRogueFace/src/PyScene.cpp",
"file": "/home/john/Development/McRogueFace/src/PyScene.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/PyTexture.cpp.o -c /home/john/Development/McRogueFace/src/PyTexture.cpp",
"file": "/home/john/Development/McRogueFace/src/PyTexture.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/PyVector.cpp.o -c /home/john/Development/McRogueFace/src/PyVector.cpp",
"file": "/home/john/Development/McRogueFace/src/PyVector.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/Resources.cpp.o -c /home/john/Development/McRogueFace/src/Resources.cpp",
"file": "/home/john/Development/McRogueFace/src/Resources.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/Scene.cpp.o -c /home/john/Development/McRogueFace/src/Scene.cpp",
"file": "/home/john/Development/McRogueFace/src/Scene.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/Timer.cpp.o -c /home/john/Development/McRogueFace/src/Timer.cpp",
"file": "/home/john/Development/McRogueFace/src/Timer.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/UICaption.cpp.o -c /home/john/Development/McRogueFace/src/UICaption.cpp",
"file": "/home/john/Development/McRogueFace/src/UICaption.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/UICollection.cpp.o -c /home/john/Development/McRogueFace/src/UICollection.cpp",
"file": "/home/john/Development/McRogueFace/src/UICollection.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/UIDrawable.cpp.o -c /home/john/Development/McRogueFace/src/UIDrawable.cpp",
"file": "/home/john/Development/McRogueFace/src/UIDrawable.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/UIEntity.cpp.o -c /home/john/Development/McRogueFace/src/UIEntity.cpp",
"file": "/home/john/Development/McRogueFace/src/UIEntity.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/UIFrame.cpp.o -c /home/john/Development/McRogueFace/src/UIFrame.cpp",
"file": "/home/john/Development/McRogueFace/src/UIFrame.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/UIGrid.cpp.o -c /home/john/Development/McRogueFace/src/UIGrid.cpp",
"file": "/home/john/Development/McRogueFace/src/UIGrid.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/UIGridPoint.cpp.o -c /home/john/Development/McRogueFace/src/UIGridPoint.cpp",
"file": "/home/john/Development/McRogueFace/src/UIGridPoint.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/UISprite.cpp.o -c /home/john/Development/McRogueFace/src/UISprite.cpp",
"file": "/home/john/Development/McRogueFace/src/UISprite.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/UITestScene.cpp.o -c /home/john/Development/McRogueFace/src/UITestScene.cpp",
"file": "/home/john/Development/McRogueFace/src/UITestScene.cpp"
},
{
"directory": "/home/john/Development/McRogueFace/build",
"command": "/usr/bin/c++ -I/home/john/Development/McRogueFace/deps -I/home/john/Development/McRogueFace/deps/libtcod -I/home/john/Development/McRogueFace/deps/cpython -I/home/john/Development/McRogueFace/deps/Python -I/home/john/Development/McRogueFace/deps/platform/linux -g -std=gnu++2a -o CMakeFiles/mcrogueface.dir/src/main.cpp.o -c /home/john/Development/McRogueFace/src/main.cpp",
"file": "/home/john/Development/McRogueFace/src/main.cpp"
}
]

View File

@ -1,157 +0,0 @@
aqua #00FFFF
black #000000
blue #0000FF
fuchsia #FF00FF
gray #808080
green #008000
lime #00FF00
maroon #800000
navy #000080
olive #808000
purple #800080
red #FF0000
silver #C0C0C0
teal #008080
white #FFFFFF
yellow #FFFF00
aliceblue #F0F8FF
antiquewhite #FAEBD7
aqua #00FFFF
aquamarine #7FFFD4
azure #F0FFFF
beige #F5F5DC
bisque #FFE4C4
black #000000
blanchedalmond #FFEBCD
blue #0000FF
blueviolet #8A2BE2
brown #A52A2A
burlywood #DEB887
cadetblue #5F9EA0
chartreuse #7FFF00
chocolate #D2691E
coral #FF7F50
cornflowerblue #6495ED
cornsilk #FFF8DC
crimson #DC143C
cyan #00FFFF
darkblue #00008B
darkcyan #008B8B
darkgoldenrod #B8860B
darkgray #A9A9A9
darkgreen #006400
darkkhaki #BDB76B
darkmagenta #8B008B
darkolivegreen #556B2F
darkorange #FF8C00
darkorchid #9932CC
darkred #8B0000
darksalmon #E9967A
darkseagreen #8FBC8F
darkslateblue #483D8B
darkslategray #2F4F4F
darkturquoise #00CED1
darkviolet #9400D3
deeppink #FF1493
deepskyblue #00BFFF
dimgray #696969
dodgerblue #1E90FF
firebrick #B22222
floralwhite #FFFAF0
forestgreen #228B22
fuchsia #FF00FF
gainsboro #DCDCDC
ghostwhite #F8F8FF
gold #FFD700
goldenrod #DAA520
gray #7F7F7F
green #008000
greenyellow #ADFF2F
honeydew #F0FFF0
hotpink #FF69B4
indianred #CD5C5C
indigo #4B0082
ivory #FFFFF0
khaki #F0E68C
lavender #E6E6FA
lavenderblush #FFF0F5
lawngreen #7CFC00
lemonchiffon #FFFACD
lightblue #ADD8E6
lightcoral #F08080
lightcyan #E0FFFF
lightgoldenrodyellow #FAFAD2
lightgreen #90EE90
lightgrey #D3D3D3
lightpink #FFB6C1
lightsalmon #FFA07A
lightseagreen #20B2AA
lightskyblue #87CEFA
lightslategray #778899
lightsteelblue #B0C4DE
lightyellow #FFFFE0
lime #00FF00
limegreen #32CD32
linen #FAF0E6
magenta #FF00FF
maroon #800000
mediumaquamarine #66CDAA
mediumblue #0000CD
mediumorchid #BA55D3
mediumpurple #9370DB
mediumseagreen #3CB371
mediumslateblue #7B68EE
mediumspringgreen #00FA9A
mediumturquoise #48D1CC
mediumvioletred #C71585
midnightblue #191970
mintcream #F5FFFA
mistyrose #FFE4E1
moccasin #FFE4B5
navajowhite #FFDEAD
navy #000080
navyblue #9FAFDF
oldlace #FDF5E6
olive #808000
olivedrab #6B8E23
orange #FFA500
orangered #FF4500
orchid #DA70D6
palegoldenrod #EEE8AA
palegreen #98FB98
paleturquoise #AFEEEE
palevioletred #DB7093
papayawhip #FFEFD5
peachpuff #FFDAB9
peru #CD853F
pink #FFC0CB
plum #DDA0DD
powderblue #B0E0E6
purple #800080
red #FF0000
rosybrown #BC8F8F
royalblue #4169E1
saddlebrown #8B4513
salmon #FA8072
sandybrown #FA8072
seagreen #2E8B57
seashell #FFF5EE
sienna #A0522D
silver #C0C0C0
skyblue #87CEEB
slateblue #6A5ACD
slategray #708090
snow #FFFAFA
springgreen #00FF7F
steelblue #4682B4
tan #D2B48C
teal #008080
thistle #D8BFD8
tomato #FF6347
turquoise #40E0D0
violet #EE82EE
wheat #F5DEB3
white #FFFFFF
whitesmoke #F5F5F5
yellow #FFFF00
yellowgreen #9ACD32

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1070
docs/mcrfpy.3 Normal file

File diff suppressed because it is too large Load Diff

532
docs/stubs/mcrfpy.pyi Normal file
View File

@ -0,0 +1,532 @@
"""Type stubs for McRogueFace Python API.
Core game engine interface for creating roguelike games with Python.
"""
from typing import Any, List, Dict, Tuple, Optional, Callable, Union, overload
# Type aliases
UIElement = Union['Frame', 'Caption', 'Sprite', 'Grid']
Transition = Union[str, None]
# Classes
class Color:
"""SFML Color Object for RGBA colors."""
r: int
g: int
b: int
a: int
@overload
def __init__(self) -> None: ...
@overload
def __init__(self, r: int, g: int, b: int, a: int = 255) -> None: ...
def from_hex(self, hex_string: str) -> 'Color':
"""Create color from hex string (e.g., '#FF0000' or 'FF0000')."""
...
def to_hex(self) -> str:
"""Convert color to hex string format."""
...
def lerp(self, other: 'Color', t: float) -> 'Color':
"""Linear interpolation between two colors."""
...
class Vector:
"""SFML Vector Object for 2D coordinates."""
x: float
y: float
@overload
def __init__(self) -> None: ...
@overload
def __init__(self, x: float, y: float) -> None: ...
def add(self, other: 'Vector') -> 'Vector': ...
def subtract(self, other: 'Vector') -> 'Vector': ...
def multiply(self, scalar: float) -> 'Vector': ...
def divide(self, scalar: float) -> 'Vector': ...
def distance(self, other: 'Vector') -> float: ...
def normalize(self) -> 'Vector': ...
def dot(self, other: 'Vector') -> float: ...
class Texture:
"""SFML Texture Object for images."""
def __init__(self, filename: str) -> None: ...
filename: str
width: int
height: int
sprite_count: int
class Font:
"""SFML Font Object for text rendering."""
def __init__(self, filename: str) -> None: ...
filename: str
family: str
class Drawable:
"""Base class for all drawable UI elements."""
x: float
y: float
visible: bool
z_index: int
name: str
pos: Vector
def get_bounds(self) -> Tuple[float, float, float, float]:
"""Get bounding box as (x, y, width, height)."""
...
def move(self, dx: float, dy: float) -> None:
"""Move by relative offset (dx, dy)."""
...
def resize(self, width: float, height: float) -> None:
"""Resize to new dimensions (width, height)."""
...
class Frame(Drawable):
"""Frame(x=0, y=0, w=0, h=0, fill_color=None, outline_color=None, outline=0, click=None, children=None)
A rectangular frame UI element that can contain other drawable elements.
"""
@overload
def __init__(self) -> None: ...
@overload
def __init__(self, x: float = 0, y: float = 0, w: float = 0, h: float = 0,
fill_color: Optional[Color] = None, outline_color: Optional[Color] = None,
outline: float = 0, click: Optional[Callable] = None,
children: Optional[List[UIElement]] = None) -> None: ...
w: float
h: float
fill_color: Color
outline_color: Color
outline: float
click: Optional[Callable[[float, float, int], None]]
children: 'UICollection'
clip_children: bool
class Caption(Drawable):
"""Caption(text='', x=0, y=0, font=None, fill_color=None, outline_color=None, outline=0, click=None)
A text display UI element with customizable font and styling.
"""
@overload
def __init__(self) -> None: ...
@overload
def __init__(self, text: str = '', x: float = 0, y: float = 0,
font: Optional[Font] = None, fill_color: Optional[Color] = None,
outline_color: Optional[Color] = None, outline: float = 0,
click: Optional[Callable] = None) -> None: ...
text: str
font: Font
fill_color: Color
outline_color: Color
outline: float
click: Optional[Callable[[float, float, int], None]]
w: float # Read-only, computed from text
h: float # Read-only, computed from text
class Sprite(Drawable):
"""Sprite(x=0, y=0, texture=None, sprite_index=0, scale=1.0, click=None)
A sprite UI element that displays a texture or portion of a texture atlas.
"""
@overload
def __init__(self) -> None: ...
@overload
def __init__(self, x: float = 0, y: float = 0, texture: Optional[Texture] = None,
sprite_index: int = 0, scale: float = 1.0,
click: Optional[Callable] = None) -> None: ...
texture: Texture
sprite_index: int
scale: float
click: Optional[Callable[[float, float, int], None]]
w: float # Read-only, computed from texture
h: float # Read-only, computed from texture
class Grid(Drawable):
"""Grid(x=0, y=0, grid_size=(20, 20), texture=None, tile_width=16, tile_height=16, scale=1.0, click=None)
A grid-based tilemap UI element for rendering tile-based levels and game worlds.
"""
@overload
def __init__(self) -> None: ...
@overload
def __init__(self, x: float = 0, y: float = 0, grid_size: Tuple[int, int] = (20, 20),
texture: Optional[Texture] = None, tile_width: int = 16, tile_height: int = 16,
scale: float = 1.0, click: Optional[Callable] = None) -> None: ...
grid_size: Tuple[int, int]
tile_width: int
tile_height: int
texture: Texture
scale: float
points: List[List['GridPoint']]
entities: 'EntityCollection'
background_color: Color
click: Optional[Callable[[int, int, int], None]]
def at(self, x: int, y: int) -> 'GridPoint':
"""Get grid point at tile coordinates."""
...
class GridPoint:
"""Grid point representing a single tile."""
texture_index: int
solid: bool
color: Color
class GridPointState:
"""State information for a grid point."""
texture_index: int
color: Color
class Entity(Drawable):
"""Entity(grid_x=0, grid_y=0, texture=None, sprite_index=0, name='')
Game entity that lives within a Grid.
"""
@overload
def __init__(self) -> None: ...
@overload
def __init__(self, grid_x: float = 0, grid_y: float = 0, texture: Optional[Texture] = None,
sprite_index: int = 0, name: str = '') -> None: ...
grid_x: float
grid_y: float
texture: Texture
sprite_index: int
grid: Optional[Grid]
def at(self, grid_x: float, grid_y: float) -> None:
"""Move entity to grid position."""
...
def die(self) -> None:
"""Remove entity from its grid."""
...
def index(self) -> int:
"""Get index in parent grid's entity collection."""
...
class UICollection:
"""Collection of UI drawable elements (Frame, Caption, Sprite, Grid)."""
def __len__(self) -> int: ...
def __getitem__(self, index: int) -> UIElement: ...
def __setitem__(self, index: int, value: UIElement) -> None: ...
def __delitem__(self, index: int) -> None: ...
def __contains__(self, item: UIElement) -> bool: ...
def __iter__(self) -> Any: ...
def __add__(self, other: 'UICollection') -> 'UICollection': ...
def __iadd__(self, other: 'UICollection') -> 'UICollection': ...
def append(self, item: UIElement) -> None: ...
def extend(self, items: List[UIElement]) -> None: ...
def remove(self, item: UIElement) -> None: ...
def index(self, item: UIElement) -> int: ...
def count(self, item: UIElement) -> int: ...
class EntityCollection:
"""Collection of Entity objects."""
def __len__(self) -> int: ...
def __getitem__(self, index: int) -> Entity: ...
def __setitem__(self, index: int, value: Entity) -> None: ...
def __delitem__(self, index: int) -> None: ...
def __contains__(self, item: Entity) -> bool: ...
def __iter__(self) -> Any: ...
def __add__(self, other: 'EntityCollection') -> 'EntityCollection': ...
def __iadd__(self, other: 'EntityCollection') -> 'EntityCollection': ...
def append(self, item: Entity) -> None: ...
def extend(self, items: List[Entity]) -> None: ...
def remove(self, item: Entity) -> None: ...
def index(self, item: Entity) -> int: ...
def count(self, item: Entity) -> int: ...
class Scene:
"""Base class for object-oriented scenes."""
name: str
def __init__(self, name: str) -> None: ...
def activate(self) -> None:
"""Called when scene becomes active."""
...
def deactivate(self) -> None:
"""Called when scene becomes inactive."""
...
def get_ui(self) -> UICollection:
"""Get UI elements collection."""
...
def on_keypress(self, key: str, pressed: bool) -> None:
"""Handle keyboard events."""
...
def on_click(self, x: float, y: float, button: int) -> None:
"""Handle mouse clicks."""
...
def on_enter(self) -> None:
"""Called when entering the scene."""
...
def on_exit(self) -> None:
"""Called when leaving the scene."""
...
def on_resize(self, width: int, height: int) -> None:
"""Handle window resize events."""
...
def update(self, dt: float) -> None:
"""Update scene logic."""
...
class Timer:
"""Timer object for scheduled callbacks."""
name: str
interval: int
active: bool
def __init__(self, name: str, callback: Callable[[float], None], interval: int) -> None: ...
def pause(self) -> None:
"""Pause the timer."""
...
def resume(self) -> None:
"""Resume the timer."""
...
def cancel(self) -> None:
"""Cancel and remove the timer."""
...
class Window:
"""Window singleton for managing the game window."""
resolution: Tuple[int, int]
fullscreen: bool
vsync: bool
title: str
fps_limit: int
game_resolution: Tuple[int, int]
scaling_mode: str
@staticmethod
def get() -> 'Window':
"""Get the window singleton instance."""
...
class Animation:
"""Animation object for animating UI properties."""
target: Any
property: str
duration: float
easing: str
loop: bool
on_complete: Optional[Callable]
def __init__(self, target: Any, property: str, start_value: Any, end_value: Any,
duration: float, easing: str = 'linear', loop: bool = False,
on_complete: Optional[Callable] = None) -> None: ...
def start(self) -> None:
"""Start the animation."""
...
def update(self, dt: float) -> bool:
"""Update animation, returns True if still running."""
...
def get_current_value(self) -> Any:
"""Get the current interpolated value."""
...
# Module functions
def createSoundBuffer(filename: str) -> int:
"""Load a sound effect from a file and return its buffer ID."""
...
def loadMusic(filename: str) -> None:
"""Load and immediately play background music from a file."""
...
def setMusicVolume(volume: int) -> None:
"""Set the global music volume (0-100)."""
...
def setSoundVolume(volume: int) -> None:
"""Set the global sound effects volume (0-100)."""
...
def playSound(buffer_id: int) -> None:
"""Play a sound effect using a previously loaded buffer."""
...
def getMusicVolume() -> int:
"""Get the current music volume level (0-100)."""
...
def getSoundVolume() -> int:
"""Get the current sound effects volume level (0-100)."""
...
def sceneUI(scene: Optional[str] = None) -> UICollection:
"""Get all UI elements for a scene."""
...
def currentScene() -> str:
"""Get the name of the currently active scene."""
...
def setScene(scene: str, transition: Optional[str] = None, duration: float = 0.0) -> None:
"""Switch to a different scene with optional transition effect."""
...
def createScene(name: str) -> None:
"""Create a new empty scene."""
...
def keypressScene(handler: Callable[[str, bool], None]) -> None:
"""Set the keyboard event handler for the current scene."""
...
def setTimer(name: str, handler: Callable[[float], None], interval: int) -> None:
"""Create or update a recurring timer."""
...
def delTimer(name: str) -> None:
"""Stop and remove a timer."""
...
def exit() -> None:
"""Cleanly shut down the game engine and exit the application."""
...
def setScale(multiplier: float) -> None:
"""Scale the game window size (deprecated - use Window.resolution)."""
...
def find(name: str, scene: Optional[str] = None) -> Optional[UIElement]:
"""Find the first UI element with the specified name."""
...
def findAll(pattern: str, scene: Optional[str] = None) -> List[UIElement]:
"""Find all UI elements matching a name pattern (supports * wildcards)."""
...
def getMetrics() -> Dict[str, Union[int, float]]:
"""Get current performance metrics."""
...
# Submodule
class automation:
"""Automation API for testing and scripting."""
@staticmethod
def screenshot(filename: str) -> bool:
"""Save a screenshot to the specified file."""
...
@staticmethod
def position() -> Tuple[int, int]:
"""Get current mouse position as (x, y) tuple."""
...
@staticmethod
def size() -> Tuple[int, int]:
"""Get screen size as (width, height) tuple."""
...
@staticmethod
def onScreen(x: int, y: int) -> bool:
"""Check if coordinates are within screen bounds."""
...
@staticmethod
def moveTo(x: int, y: int, duration: float = 0.0) -> None:
"""Move mouse to absolute position."""
...
@staticmethod
def moveRel(xOffset: int, yOffset: int, duration: float = 0.0) -> None:
"""Move mouse relative to current position."""
...
@staticmethod
def dragTo(x: int, y: int, duration: float = 0.0, button: str = 'left') -> None:
"""Drag mouse to position."""
...
@staticmethod
def dragRel(xOffset: int, yOffset: int, duration: float = 0.0, button: str = 'left') -> None:
"""Drag mouse relative to current position."""
...
@staticmethod
def click(x: Optional[int] = None, y: Optional[int] = None, clicks: int = 1,
interval: float = 0.0, button: str = 'left') -> None:
"""Click mouse at position."""
...
@staticmethod
def mouseDown(x: Optional[int] = None, y: Optional[int] = None, button: str = 'left') -> None:
"""Press mouse button down."""
...
@staticmethod
def mouseUp(x: Optional[int] = None, y: Optional[int] = None, button: str = 'left') -> None:
"""Release mouse button."""
...
@staticmethod
def keyDown(key: str) -> None:
"""Press key down."""
...
@staticmethod
def keyUp(key: str) -> None:
"""Release key."""
...
@staticmethod
def press(key: str) -> None:
"""Press and release a key."""
...
@staticmethod
def typewrite(text: str, interval: float = 0.0) -> None:
"""Type text with optional interval between characters."""
...

View File

@ -0,0 +1,209 @@
"""Type stubs for McRogueFace Python API.
Auto-generated - do not edit directly.
"""
from typing import Any, List, Dict, Tuple, Optional, Callable, Union
# Module documentation
# McRogueFace Python API\n\nCore game engine interface for creating roguelike games with Python.\n\nThis module provides:\n- Scene management (createScene, setScene, currentScene)\n- UI components (Frame, Caption, Sprite, Grid)\n- Entity system for game objects\n- Audio playback (sound effects and music)\n- Timer system for scheduled events\n- Input handling\n- Performance metrics\n\nExample:\n import mcrfpy\n \n # Create a new scene\n mcrfpy.createScene('game')\n mcrfpy.setScene('game')\n \n # Add UI elements\n frame = mcrfpy.Frame(10, 10, 200, 100)\n caption = mcrfpy.Caption('Hello World', 50, 50)\n mcrfpy.sceneUI().extend([frame, caption])\n
# Classes
class Animation:
"""Animation object for animating UI properties"""
def __init__(selftype(self)) -> None: ...
def get_current_value(self, *args, **kwargs) -> Any: ...
def start(self, *args, **kwargs) -> Any: ...
def update(selfreturns True if still running) -> Any: ...
class Caption:
"""Caption(text='', x=0, y=0, font=None, fill_color=None, outline_color=None, outline=0, click=None)"""
def __init__(selftype(self)) -> None: ...
def get_bounds(selfx, y, width, height) -> Any: ...
def move(selfdx, dy) -> Any: ...
def resize(selfwidth, height) -> Any: ...
class Color:
"""SFML Color Object"""
def __init__(selftype(self)) -> None: ...
def from_hex(selfe.g., '#FF0000' or 'FF0000') -> Any: ...
def lerp(self, *args, **kwargs) -> Any: ...
def to_hex(self, *args, **kwargs) -> Any: ...
class Drawable:
"""Base class for all drawable UI elements"""
def __init__(selftype(self)) -> None: ...
def get_bounds(selfx, y, width, height) -> Any: ...
def move(selfdx, dy) -> Any: ...
def resize(selfwidth, height) -> Any: ...
class Entity:
"""UIEntity objects"""
def __init__(selftype(self)) -> None: ...
def at(self, *args, **kwargs) -> Any: ...
def die(self, *args, **kwargs) -> Any: ...
def get_bounds(selfx, y, width, height) -> Any: ...
def index(self, *args, **kwargs) -> Any: ...
def move(selfdx, dy) -> Any: ...
def path_to(selfx: int, y: int) -> bool: ...
def resize(selfwidth, height) -> Any: ...
def update_visibility(self) -> None: ...
class EntityCollection:
"""Iterable, indexable collection of Entities"""
def __init__(selftype(self)) -> None: ...
def append(self, *args, **kwargs) -> Any: ...
def count(self, *args, **kwargs) -> Any: ...
def extend(self, *args, **kwargs) -> Any: ...
def index(self, *args, **kwargs) -> Any: ...
def remove(self, *args, **kwargs) -> Any: ...
class Font:
"""SFML Font Object"""
def __init__(selftype(self)) -> None: ...
class Frame:
"""Frame(x=0, y=0, w=0, h=0, fill_color=None, outline_color=None, outline=0, click=None, children=None)"""
def __init__(selftype(self)) -> None: ...
def get_bounds(selfx, y, width, height) -> Any: ...
def move(selfdx, dy) -> Any: ...
def resize(selfwidth, height) -> Any: ...
class Grid:
"""Grid(x=0, y=0, grid_size=(20, 20), texture=None, tile_width=16, tile_height=16, scale=1.0, click=None)"""
def __init__(selftype(self)) -> None: ...
def at(self, *args, **kwargs) -> Any: ...
def compute_astar_path(selfx1: int, y1: int, x2: int, y2: int, diagonal_cost: float = 1.41) -> List[Tuple[int, int]]: ...
def compute_dijkstra(selfroot_x: int, root_y: int, diagonal_cost: float = 1.41) -> None: ...
def compute_fov(selfx: int, y: int, radius: int = 0, light_walls: bool = True, algorithm: int = FOV_BASIC) -> None: ...
def find_path(selfx1: int, y1: int, x2: int, y2: int, diagonal_cost: float = 1.41) -> List[Tuple[int, int]]: ...
def get_bounds(selfx, y, width, height) -> Any: ...
def get_dijkstra_distance(selfx: int, y: int) -> Optional[float]: ...
def get_dijkstra_path(selfx: int, y: int) -> List[Tuple[int, int]]: ...
def is_in_fov(selfx: int, y: int) -> bool: ...
def move(selfdx, dy) -> Any: ...
def resize(selfwidth, height) -> Any: ...
class GridPoint:
"""UIGridPoint object"""
def __init__(selftype(self)) -> None: ...
class GridPointState:
"""UIGridPointState object"""
def __init__(selftype(self)) -> None: ...
class Scene:
"""Base class for object-oriented scenes"""
def __init__(selftype(self)) -> None: ...
def activate(self, *args, **kwargs) -> Any: ...
def get_ui(self, *args, **kwargs) -> Any: ...
def register_keyboard(selfalternative to overriding on_keypress) -> Any: ...
class Sprite:
"""Sprite(x=0, y=0, texture=None, sprite_index=0, scale=1.0, click=None)"""
def __init__(selftype(self)) -> None: ...
def get_bounds(selfx, y, width, height) -> Any: ...
def move(selfdx, dy) -> Any: ...
def resize(selfwidth, height) -> Any: ...
class Texture:
"""SFML Texture Object"""
def __init__(selftype(self)) -> None: ...
class Timer:
"""Timer object for scheduled callbacks"""
def __init__(selftype(self)) -> None: ...
def cancel(self, *args, **kwargs) -> Any: ...
def pause(self, *args, **kwargs) -> Any: ...
def restart(self, *args, **kwargs) -> Any: ...
def resume(self, *args, **kwargs) -> Any: ...
class UICollection:
"""Iterable, indexable collection of UI objects"""
def __init__(selftype(self)) -> None: ...
def append(self, *args, **kwargs) -> Any: ...
def count(self, *args, **kwargs) -> Any: ...
def extend(self, *args, **kwargs) -> Any: ...
def index(self, *args, **kwargs) -> Any: ...
def remove(self, *args, **kwargs) -> Any: ...
class UICollectionIter:
"""Iterator for a collection of UI objects"""
def __init__(selftype(self)) -> None: ...
class UIEntityCollectionIter:
"""Iterator for a collection of UI objects"""
def __init__(selftype(self)) -> None: ...
class Vector:
"""SFML Vector Object"""
def __init__(selftype(self)) -> None: ...
def angle(self, *args, **kwargs) -> Any: ...
def copy(self, *args, **kwargs) -> Any: ...
def distance_to(self, *args, **kwargs) -> Any: ...
def dot(self, *args, **kwargs) -> Any: ...
def magnitude(self, *args, **kwargs) -> Any: ...
def magnitude_squared(self, *args, **kwargs) -> Any: ...
def normalize(self, *args, **kwargs) -> Any: ...
class Window:
"""Window singleton for accessing and modifying the game window properties"""
def __init__(selftype(self)) -> None: ...
def center(self, *args, **kwargs) -> Any: ...
def get(self, *args, **kwargs) -> Any: ...
def screenshot(self, *args, **kwargs) -> Any: ...
# Functions
def createScene(name: str) -> None: ...
def createSoundBuffer(filename: str) -> int: ...
def currentScene() -> str: ...
def delTimer(name: str) -> None: ...
def exit() -> None: ...
def find(name: str, scene: str = None) -> UIDrawable | None: ...
def findAll(pattern: str, scene: str = None) -> list: ...
def getMetrics() -> dict: ...
def getMusicVolume() -> int: ...
def getSoundVolume() -> int: ...
def keypressScene(handler: callable) -> None: ...
def loadMusic(filename: str) -> None: ...
def playSound(buffer_id: int) -> None: ...
def sceneUI(scene: str = None) -> list: ...
def setMusicVolume(volume: int) -> None: ...
def setScale(multiplier: float) -> None: ...
def setScene(scene: str, transition: str = None, duration: float = 0.0) -> None: ...
def setSoundVolume(volume: int) -> None: ...
def setTimer(name: str, handler: callable, interval: int) -> None: ...
# Constants
FOV_BASIC: int
FOV_DIAMOND: int
FOV_PERMISSIVE_0: int
FOV_PERMISSIVE_1: int
FOV_PERMISSIVE_2: int
FOV_PERMISSIVE_3: int
FOV_PERMISSIVE_4: int
FOV_PERMISSIVE_5: int
FOV_PERMISSIVE_6: int
FOV_PERMISSIVE_7: int
FOV_PERMISSIVE_8: int
FOV_RESTRICTIVE: int
FOV_SHADOW: int
default_font: Any
default_texture: Any

View File

@ -0,0 +1,24 @@
"""Type stubs for McRogueFace automation API."""
from typing import Optional, Tuple
def click(x=None, y=None, clicks=1, interval=0.0, button='left') -> Any: ...
def doubleClick(x=None, y=None) -> Any: ...
def dragRel(xOffset, yOffset, duration=0.0, button='left') -> Any: ...
def dragTo(x, y, duration=0.0, button='left') -> Any: ...
def hotkey(*keys) - Press a hotkey combination (e.g., hotkey('ctrl', 'c')) -> Any: ...
def keyDown(key) -> Any: ...
def keyUp(key) -> Any: ...
def middleClick(x=None, y=None) -> Any: ...
def mouseDown(x=None, y=None, button='left') -> Any: ...
def mouseUp(x=None, y=None, button='left') -> Any: ...
def moveRel(xOffset, yOffset, duration=0.0) -> Any: ...
def moveTo(x, y, duration=0.0) -> Any: ...
def onScreen(x, y) -> Any: ...
def position() - Get current mouse position as (x, y) -> Any: ...
def rightClick(x=None, y=None) -> Any: ...
def screenshot(filename) -> Any: ...
def scroll(clicks, x=None, y=None) -> Any: ...
def size() - Get screen size as (width, height) -> Any: ...
def tripleClick(x=None, y=None) -> Any: ...
def typewrite(message, interval=0.0) -> Any: ...

0
docs/stubs/py.typed Normal file
View File

BIN
forgejo-mcp.linux.amd64 Normal file

Binary file not shown.

View File

@ -0,0 +1,393 @@
# McRogueFace Tutorial Parts 6-8: Implementation Plan
**Date**: Monday, July 28, 2025
**Target Delivery**: Tuesday, July 29, 2025
## Executive Summary
This document outlines the implementation plan for Parts 6-8 of the McRogueFace roguelike tutorial, adapting the libtcod Python tutorial to McRogueFace's architecture. The key discovery is that Python classes can successfully inherit from `mcrfpy.Entity` and store custom attributes, enabling a clean, Pythonic implementation.
## Key Architectural Insights
### Entity Inheritance Works!
```python
class GameEntity(mcrfpy.Entity):
def __init__(self, x, y, **kwargs):
super().__init__(x=x, y=y, **kwargs)
# Custom attributes work perfectly!
self.hp = 10
self.inventory = []
self.any_attribute = "works"
```
This completely changes our approach from wrapper patterns to direct inheritance.
---
## Part 6: Doing (and Taking) Some Damage
### Overview
Implement a combat system with HP tracking, damage calculation, and death mechanics using entity inheritance.
### Core Components
#### 1. CombatEntity Base Class
```python
class CombatEntity(mcrfpy.Entity):
"""Base class for entities that can fight and take damage"""
def __init__(self, x, y, hp=10, defense=0, power=1, **kwargs):
super().__init__(x=x, y=y, **kwargs)
# Combat stats as direct attributes
self.hp = hp
self.max_hp = hp
self.defense = defense
self.power = power
self.is_alive = True
self.blocks_movement = True
def calculate_damage(self, attacker):
"""Simple damage formula: power - defense"""
return max(0, attacker.power - self.defense)
def take_damage(self, damage, attacker=None):
"""Apply damage and handle death"""
self.hp = max(0, self.hp - damage)
if self.hp == 0 and self.is_alive:
self.is_alive = False
self.on_death(attacker)
def on_death(self, killer=None):
"""Handle death - override in subclasses"""
self.sprite_index = self.sprite_index + 180 # Corpse offset
self.blocks_movement = False
```
#### 2. Entity Types
```python
class PlayerEntity(CombatEntity):
"""Player: HP=30, Defense=2, Power=5"""
def __init__(self, x, y, **kwargs):
kwargs['sprite_index'] = 64 # Hero sprite
super().__init__(x=x, y=y, hp=30, defense=2, power=5, **kwargs)
self.entity_type = "player"
class OrcEntity(CombatEntity):
"""Orc: HP=10, Defense=0, Power=3"""
def __init__(self, x, y, **kwargs):
kwargs['sprite_index'] = 65 # Orc sprite
super().__init__(x=x, y=y, hp=10, defense=0, power=3, **kwargs)
self.entity_type = "orc"
class TrollEntity(CombatEntity):
"""Troll: HP=16, Defense=1, Power=4"""
def __init__(self, x, y, **kwargs):
kwargs['sprite_index'] = 66 # Troll sprite
super().__init__(x=x, y=y, hp=16, defense=1, power=4, **kwargs)
self.entity_type = "troll"
```
#### 3. Combat Integration
- Extend `on_bump()` from Part 5 to include combat
- Add attack animations (quick bump toward target)
- Console messages initially, UI messages in Part 7
- Death changes sprite and removes blocking
### Key Differences from Original Tutorial
- No Fighter component - stats are direct attributes
- No AI component - behavior in entity methods
- Integrated animations for visual feedback
- Simpler architecture overall
---
## Part 7: Creating the Interface
### Overview
Add visual UI elements including health bars, message logs, and colored feedback for combat events.
### Core Components
#### 1. Health Bar
```python
class HealthBar:
"""Health bar that reads entity HP directly"""
def __init__(self, entity, pos=(10, 740), size=(200, 20)):
self.entity = entity # Direct reference!
# Background (dark red)
self.bg = mcrfpy.Frame(pos=pos, size=size)
self.bg.fill_color = mcrfpy.Color(64, 16, 16)
# Foreground (green)
self.fg = mcrfpy.Frame(pos=pos, size=size)
self.fg.fill_color = mcrfpy.Color(0, 96, 0)
# Text overlay
self.text = mcrfpy.Caption(
pos=(pos[0] + 5, pos[1] + 2),
text=f"HP: {entity.hp}/{entity.max_hp}"
)
def update(self):
"""Update based on entity's current HP"""
ratio = self.entity.hp / self.entity.max_hp
self.fg.w = int(self.bg.w * ratio)
self.text.text = f"HP: {self.entity.hp}/{self.entity.max_hp}"
# Color changes at low health
if ratio < 0.25:
self.fg.fill_color = mcrfpy.Color(196, 16, 16) # Red
elif ratio < 0.5:
self.fg.fill_color = mcrfpy.Color(196, 196, 16) # Yellow
```
#### 2. Message Log
```python
class MessageLog:
"""Scrolling message log for combat feedback"""
def __init__(self, pos=(10, 600), size=(400, 120), max_messages=6):
self.frame = mcrfpy.Frame(pos=pos, size=size)
self.messages = [] # List of (text, color) tuples
self.captions = [] # Pre-allocated Caption pool
def add_message(self, text, color=None):
"""Add message with optional color"""
# Handle duplicate detection (x2, x3, etc.)
# Update caption display
```
#### 3. Color System
```python
class Colors:
# Combat colors
PLAYER_ATTACK = mcrfpy.Color(224, 224, 224)
ENEMY_ATTACK = mcrfpy.Color(255, 192, 192)
PLAYER_DEATH = mcrfpy.Color(255, 48, 48)
ENEMY_DEATH = mcrfpy.Color(255, 160, 48)
HEALTH_RECOVERED = mcrfpy.Color(0, 255, 0)
```
### UI Layout
- Health bar at bottom of screen
- Message log above health bar
- Direct binding to entity attributes
- Real-time updates during gameplay
---
## Part 8: Items and Inventory
### Overview
Implement items as entities, inventory management, and a hotbar-style UI for item usage.
### Core Components
#### 1. Item Entities
```python
class ItemEntity(mcrfpy.Entity):
"""Base class for pickupable items"""
def __init__(self, x, y, name, sprite, **kwargs):
kwargs['sprite_index'] = sprite
super().__init__(x=x, y=y, **kwargs)
self.item_name = name
self.blocks_movement = False
self.item_type = "generic"
class HealingPotion(ItemEntity):
"""Consumable healing item"""
def __init__(self, x, y, healing_amount=4):
super().__init__(x, y, "Healing Potion", sprite=33)
self.healing_amount = healing_amount
self.item_type = "consumable"
def use(self, user):
"""Use the potion - returns (success, message)"""
if hasattr(user, 'hp'):
healed = min(self.healing_amount, user.max_hp - user.hp)
if healed > 0:
user.hp += healed
return True, f"You heal {healed} HP!"
```
#### 2. Inventory System
```python
class InventoryMixin:
"""Mixin for entities with inventory"""
def __init__(self, *args, capacity=10, **kwargs):
super().__init__(*args, **kwargs)
self.inventory = []
self.inventory_capacity = capacity
def pickup_item(self, item):
"""Pick up an item entity"""
if len(self.inventory) >= self.inventory_capacity:
return False, "Inventory full!"
self.inventory.append(item)
item.die() # Remove from grid
return True, f"Picked up {item.item_name}."
```
#### 3. Inventory UI
```python
class InventoryDisplay:
"""Hotbar-style inventory display"""
def __init__(self, entity, pos=(200, 700), slots=10):
# Create slot frames and sprites
# Number keys 1-9, 0 for slots
# Highlight selected slot
# Update based on entity.inventory
```
### Key Features
- Items exist as entities on the grid
- Direct inventory attribute on player
- Hotkey-based usage (1-9, 0)
- Visual hotbar display
- Item effects (healing, future: damage boost, etc.)
---
## Implementation Timeline
### Tuesday Morning (Priority 1: Core Systems)
1. **8:00-9:30**: Implement CombatEntity and entity types
2. **9:30-10:30**: Add combat to bump interactions
3. **10:30-11:30**: Basic health display (text or simple bar)
4. **11:30-12:00**: ItemEntity and pickup system
### Tuesday Afternoon (Priority 2: Integration)
1. **1:00-2:00**: Message log implementation
2. **2:00-3:00**: Full health bar with colors
3. **3:00-4:00**: Inventory UI (hotbar)
4. **4:00-5:00**: Testing and bug fixes
### Tuesday Evening (Priority 3: Polish)
1. **5:00-6:00**: Combat animations and effects
2. **6:00-7:00**: Sound integration (use CoS splat sounds)
3. **7:00-8:00**: Additional item types
4. **8:00-9:00**: Documentation and cleanup
---
## Testing Strategy
### Automated Tests
```python
# tests/test_part6_combat.py
- Test damage calculation
- Test death mechanics
- Test combat messages
# tests/test_part7_ui.py
- Test health bar updates
- Test message log scrolling
- Test color system
# tests/test_part8_inventory.py
- Test item pickup/drop
- Test inventory capacity
- Test item usage
```
### Visual Tests
- Screenshot combat states
- Verify UI element positioning
- Check animation smoothness
---
## File Structure
```
roguelike_tutorial/
├── part_6.py # Combat implementation
├── part_7.py # UI enhancements
├── part_8.py # Inventory system
├── combat.py # Shared combat utilities
├── ui_components.py # Reusable UI classes
├── colors.py # Color definitions
└── items.py # Item definitions
```
---
## Risk Mitigation
### Potential Issues
1. **Performance**: Many UI updates per frame
- Solution: Update only on state changes
2. **Entity Collection Bugs**: Known segfault issues
- Solution: Use index-based access when needed
3. **Animation Timing**: Complex with turn-based combat
- Solution: Queue animations, process sequentially
### Fallback Options
1. Start with console messages, add UI later
2. Simple health numbers before bars
3. Basic inventory list before hotbar
---
## Success Criteria
### Part 6
- [x] Entities can have HP and take damage
- [x] Death changes sprite and walkability
- [x] Combat messages appear
- [x] Player can kill enemies
### Part 7
- [x] Health bar shows current/max HP
- [x] Messages appear in scrolling log
- [x] Colors differentiate message types
- [x] UI updates in real-time
### Part 8
- [x] Items can be picked up
- [x] Inventory has capacity limit
- [x] Items can be used/consumed
- [x] Hotbar shows inventory items
---
## Notes for Implementation
1. **Keep It Simple**: Start with minimum viable features
2. **Build Incrementally**: Test each component before integrating
3. **Use Part 5**: Leverage existing entity interaction system
4. **Document Well**: Clear comments for tutorial purposes
5. **Visual Feedback**: McRogueFace excels at animations - use them!
---
## Comparison with Original Tutorial
### What We Keep
- Same combat formula (power - defense)
- Same entity stats (Player, Orc, Troll)
- Same item types (healing potions to start)
- Same UI elements (health bar, message log)
### What's Different
- Direct inheritance instead of components
- Integrated animations and visual effects
- Hotbar inventory instead of menu
- Built-in sound support
- Cleaner architecture overall
### What's Better
- More Pythonic with real inheritance
- Better visual feedback
- Smoother animations
- Simpler to understand
- Leverages McRogueFace's strengths
---
## Conclusion
This implementation plan leverages McRogueFace's support for Python entity inheritance to create a clean, intuitive tutorial series. By using direct attributes instead of components, we simplify the architecture while maintaining all the functionality of the original tutorial. The addition of animations, sound effects, and rich UI elements showcases McRogueFace's capabilities while keeping the code beginner-friendly.
The Tuesday delivery timeline is aggressive but achievable by focusing on core functionality first, then integration, then polish. The modular design allows for easy testing and incremental development.

View File

@ -0,0 +1,100 @@
# Simple TCOD Tutorial Part 1 - Drawing the player sprite and moving it around
This is Part 1 of the Simple TCOD Tutorial adapted for McRogueFace. It implements the sophisticated, refactored TCOD tutorial approach with professional architecture from day one.
## Running the Code
From your tutorial build directory (separate from the engine development build):
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
Note: The `scripts` folder should be a symlink to your `simple_tcod_tutorial` directory.
## Architecture Overview
### Package Structure
```
simple_tcod_tutorial/
├── main.py # Entry point - ties everything together
├── game/ # Game package with proper separation
│ ├── __init__.py
│ ├── entity.py # Entity class - all game objects
│ ├── engine.py # Engine class - game coordinator
│ ├── actions.py # Action classes - command pattern
│ └── input_handlers.py # Input handling - extensible system
```
### Key Concepts Demonstrated
1. **Entity-Centric Design**
- Everything in the game is an Entity
- Entities have position, appearance, and behavior
- Designed to scale to items, NPCs, and effects
2. **Action-Based Command Pattern**
- All player actions are Action objects
- Separates input from game logic
- Enables undo, replay, and AI using same system
3. **Professional Input Handling**
- BaseEventHandler for different input contexts
- Complete movement key support (arrows, numpad, vi, WASD)
- Ready for menus, targeting, and other modes
4. **Engine as Coordinator**
- Manages game state without becoming a god object
- Delegates to appropriate systems
- Clean boundaries between systems
5. **Type Safety**
- Full type annotations throughout
- Forward references with TYPE_CHECKING
- Modern Python best practices
## Differences from Vanilla McRogueFace Tutorial
### Removed
- Animation system (instant movement instead)
- Complex UI elements (focus on core mechanics)
- Real-time features (pure turn-based)
- Visual effects (camera following, smooth scrolling)
- Entity color property (sprites handle appearance)
### Added
- Complete movement key support
- Professional architecture patterns
- Proper package structure
- Type annotations
- Action-based design
- Extensible handler system
- Proper exit handling (Escape/Q actually quits)
### Adapted
- Grid rendering with proper centering
- Simplified entity system (position + sprite ID)
- Using simple_tutorial.png sprite sheet (12 sprites)
- Floor tiles using ground sprites (indices 1 and 2)
- Direct sprite indices instead of character mapping
## Learning Objectives
Students completing Part 1 will understand:
- How to structure a game project professionally
- The value of entity-centric design
- Command pattern for game actions
- Input handling that scales to complex UIs
- Type-driven development in Python
- Architecture that grows without refactoring
## What's Next
Part 2 will add:
- The GameMap class for world representation
- Tile-based movement and collision
- Multiple entities in the world
- Basic terrain (walls and floors)
- Rendering order for entities
The architecture we've built in Part 1 makes these additions natural and painless, demonstrating the value of starting with good patterns.

View File

@ -0,0 +1,82 @@
# Simple TCOD Tutorial Part 2 - The generic Entity, the map, and walls
This is Part 2 of the Simple TCOD Tutorial adapted for McRogueFace. Building on Part 1's foundation, we now introduce proper world representation and collision detection.
## Running the Code
From your tutorial build directory:
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
## New Architecture Components
### GameMap Class (`game/game_map.py`)
The GameMap inherits from `mcrfpy.Grid` and adds:
- **Tile Management**: Uses Grid's built-in point system with walkable property
- **Entity Container**: Manages entity lifecycle with `add_entity()` and `remove_entity()`
- **Spatial Queries**: `get_entities_at()`, `get_blocking_entity_at()`, `is_walkable()`
- **Direct Integration**: Leverages Grid's walkable and tilesprite properties
### Tiles System (`game/tiles.py`)
- **Simple Tile Types**: Using NamedTuple for clean tile definitions
- **Tile Types**: Floor (walkable) and Wall (blocks movement)
- **Grid Integration**: Maps directly to Grid point properties
- **Future-Ready**: Includes transparency for FOV system in Part 4
### Entity Placement System
- **Bidirectional References**: Entities know their map, maps track their entities
- **`place()` Method**: Handles all bookkeeping when entities move between maps
- **Lifecycle Management**: Automatic cleanup when entities leave maps
## Key Changes from Part 1
### Engine Updates
- Replaced direct grid management with GameMap
- Engine creates and configures the GameMap
- Player is placed using the new `place()` method
### Movement System
- MovementAction now checks `is_walkable()` before moving
- Collision detection for both walls and blocking entities
- Clean separation between validation and execution
### Visual Changes
- Walls rendered as trees (sprite index 3)
- Border of walls around the map edge
- Floor tiles still use alternating pattern
## Architectural Benefits
### McRogueFace Integration
- **No NumPy Dependency**: Uses Grid's native tile management
- **Direct Walkability**: Grid points have built-in walkable property
- **Unified System**: Visual and logical tile data in one place
### Separation of Concerns
- **GameMap**: Knows about tiles and spatial relationships
- **Engine**: Coordinates high-level game state
- **Entity**: Manages its own lifecycle through `place()`
- **Actions**: Validate their own preconditions
### Extensibility
- Easy to add new tile types
- Simple to implement different map generation
- Ready for FOV, pathfinding, and complex queries
- Entity system scales to items and NPCs
### Type Safety
- TYPE_CHECKING imports prevent circular dependencies
- Proper type hints throughout
- Forward references maintain clean architecture
## What's Next
Part 3 will add:
- Procedural dungeon generation
- Room and corridor creation
- Multiple entities in the world
- Foundation for enemy placement
The architecture established in Part 2 makes these additions straightforward, demonstrating the value of proper design from the beginning.

View File

@ -0,0 +1,87 @@
# Simple TCOD Tutorial Part 3 - Generating a dungeon
This is Part 3 of the Simple TCOD Tutorial adapted for McRogueFace. We now add procedural dungeon generation to create interesting, playable levels.
## Running the Code
From your tutorial build directory:
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
## New Features
### Procedural Generation Module (`game/procgen.py`)
This dedicated module demonstrates separation of concerns - dungeon generation logic is kept separate from the game map implementation.
#### RectangularRoom Class
- **Clean Abstraction**: Represents a room with position and dimensions
- **Utility Properties**:
- `center` - Returns room center for connections
- `inner` - Returns slice objects for efficient carving
- **Intersection Detection**: `intersects()` method prevents overlapping rooms
#### Tunnel Generation
- **L-Shaped Corridors**: Simple but effective connection method
- **Iterator Pattern**: `tunnel_between()` yields coordinates efficiently
- **Random Variation**: 50/50 chance of horizontal-first vs vertical-first
#### Dungeon Generation Algorithm
```python
def generate_dungeon(max_rooms, room_min_size, room_max_size,
map_width, map_height, engine) -> GameMap:
```
- **Simple Algorithm**: Try to place random rooms, reject overlaps
- **Automatic Connection**: Each room connects to the previous one
- **Player Placement**: First room contains the player
- **Entity-Centric**: Uses `player.place()` for proper lifecycle
## Architecture Benefits
### Modular Design
- Generation logic separate from GameMap
- Easy to swap algorithms later
- Room class reusable for other features
### Forward Thinking
- Engine parameter anticipates entity spawning
- Room list available for future features
- Iterator-based tunnel generation is memory efficient
### Clean Integration
- Works seamlessly with existing entity placement
- Respects GameMap's tile management
- No special cases or hacks needed
## Visual Changes
- Map size increased to 80x45 for better dungeons
- Zoom reduced to 1.0 to see more of the map
- Random room layouts each time
- Connected rooms and corridors
## Algorithm Details
The generation follows these steps:
1. Start with a map filled with walls
2. Try to place up to `max_rooms` rooms
3. For each room attempt:
- Generate random size and position
- Check for intersections with existing rooms
- If valid, carve out the room
- Connect to previous room (if any)
4. Place player in center of first room
This simple algorithm creates playable dungeons while being easy to understand and modify.
## What's Next
Part 4 will add:
- Field of View (FOV) system
- Explored vs unexplored areas
- Light and dark tile rendering
- Torch radius around player
The modular dungeon generation makes it easy to add these visual features without touching the generation code.

View File

@ -0,0 +1,131 @@
# Part 4: Field of View and Exploration
## Overview
Part 4 introduces the Field of View (FOV) system, transforming our fully-visible dungeon into an atmospheric exploration experience. We leverage McRogueFace's built-in FOV capabilities and perspective system for efficient rendering.
## What's New in Part 4
### Field of View System
- **FOV Calculation**: Using `Grid.compute_fov()` with configurable radius
- **Perspective System**: Grid tracks which entity is the viewer
- **Visibility States**: Unexplored (black), explored (dark), visible (lit)
- **Automatic Updates**: FOV recalculates on player movement
### Implementation Details
#### FOV with McRogueFace's Grid
Unlike TCOD which uses numpy arrays for visibility tracking, McRogueFace's Grid has built-in FOV support:
```python
# In GameMap.update_fov()
self.compute_fov(viewer_x, viewer_y, radius, light_walls=True, algorithm=mcrfpy.FOV_BASIC)
```
The Grid automatically:
- Tracks which tiles have been explored
- Applies appropriate color overlays (shroud, dark, light)
- Updates entity visibility based on FOV
#### Perspective System
McRogueFace uses a perspective-based rendering approach:
```python
# Set the viewer
self.game_map.perspective = self.player
# Grid automatically renders from this entity's viewpoint
```
This is more efficient than manually updating tile colors every turn.
#### Color Overlays
We define overlay colors but let the Grid handle application:
```python
# In tiles.py
SHROUD = mcrfpy.Color(0, 0, 0, 255) # Unexplored
DARK = mcrfpy.Color(100, 100, 150, 128) # Explored but not visible
LIGHT = mcrfpy.Color(255, 255, 255, 0) # Currently visible
```
### Key Differences from TCOD
| TCOD Approach | McRogueFace Approach |
|---------------|----------------------|
| `visible` and `explored` numpy arrays | Grid's built-in FOV state |
| Manual tile color switching | Automatic overlay system |
| `tcod.map.compute_fov()` | `Grid.compute_fov()` |
| Render conditionals for each tile | Perspective-based rendering |
### Movement and FOV Updates
The action system now updates FOV after player movement:
```python
# In MovementAction.perform()
if self.entity == engine.player:
engine.update_fov()
```
## Architecture Notes
### Why Grid Perspective?
The perspective system provides several benefits:
1. **Efficiency**: No per-tile color updates needed
2. **Flexibility**: Easy to switch viewpoints (for debugging or features)
3. **Automatic**: Grid handles all rendering details
4. **Clean**: Separates game logic from rendering concerns
### Entity Visibility
Entities automatically update their visibility state:
```python
# After FOV calculation
self.player.update_visibility()
```
This ensures entities are only rendered when visible to the current perspective.
## Files Modified
- `game/tiles.py`: Added FOV color overlay constants
- `game/game_map.py`: Added `update_fov()` method
- `game/engine.py`: Added FOV initialization and update method
- `game/actions.py`: Update FOV after player movement
- `main.py`: Updated part description
## What's Next
Part 5 will add enemies to our dungeon, introducing:
- Enemy entities with AI
- Combat system
- Turn-based gameplay
- Health and damage
The FOV system will make enemies appear and disappear as you explore, adding tension and strategy to the gameplay.
## Learning Points
1. **Leverage Framework Features**: Use McRogueFace's built-in systems rather than reimplementing
2. **Perspective-Based Design**: Think in terms of viewpoints, not global state
3. **Automatic Systems**: Let the framework handle rendering details
4. **Clean Integration**: FOV updates fit naturally into the action system
## Running Part 4
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
You'll now see:
- Black unexplored areas
- Dark blue tint on previously seen areas
- Full brightness only in your field of view
- Smooth exploration as you move through the dungeon

View File

@ -0,0 +1,169 @@
# Part 5: Placing Enemies and Fighting Them
## Overview
Part 5 brings our dungeon to life with enemies! We add rats and spiders that populate the rooms, implement a combat system with melee attacks, and handle entity death by turning creatures into gravestones.
## What's New in Part 5
### Actor System
- **Actor Class**: Extends Entity with combat stats (HP, defense, power)
- **Combat Properties**: Health tracking, damage calculation, alive status
- **Death Handling**: Entities become gravestones when killed
### Enemy Types
Using our sprite sheet, we have two enemy types:
- **Rat** (sprite 5): 10 HP, 0 defense, 3 power - Common enemy
- **Spider** (sprite 4): 16 HP, 1 defense, 4 power - Tougher enemy
### Combat System
#### Bump-to-Attack
When the player tries to move into an enemy:
```python
# In MovementAction.perform()
target = engine.game_map.get_blocking_entity_at(dest_x, dest_y)
if target:
if self.entity == engine.player:
from game.entity import Actor
if isinstance(target, Actor) and target != engine.player:
return MeleeAction(self.entity, self.dx, self.dy).perform(engine)
```
#### Damage Calculation
Simple formula with defense reduction:
```python
damage = attacker.power - target.defense
```
#### Death System
Dead entities become gravestones:
```python
def die(self) -> None:
"""Handle death by becoming a gravestone."""
self.sprite_index = 6 # Tombstone sprite
self.blocks_movement = False
self.name = f"Grave of {self.name}"
```
### Entity Factories
Factory functions create pre-configured entities:
```python
def rat(x: int, y: int, texture: mcrfpy.Texture) -> Actor:
return Actor(
x=x, y=y,
sprite_id=5, # Rat sprite
texture=texture,
name="Rat",
hp=10, defense=0, power=3,
)
```
### Dungeon Population
Enemies are placed randomly in rooms:
```python
def place_entities(room, dungeon, max_monsters, texture):
number_of_monsters = random.randint(0, max_monsters)
for _ in range(number_of_monsters):
x = random.randint(room.x1 + 1, room.x2 - 1)
y = random.randint(room.y1 + 1, room.y2 - 1)
if not any(entity.x == x and entity.y == y for entity in dungeon.entities):
# 80% rats, 20% spiders
if random.random() < 0.8:
monster = entity_factories.rat(x, y, texture)
else:
monster = entity_factories.spider(x, y, texture)
monster.place(x, y, dungeon)
```
## Key Implementation Details
### FOV and Enemy Visibility
Enemies are automatically shown/hidden by the FOV system:
```python
def update_fov(self) -> None:
# Update visibility for all entities
for entity in self.game_map.entities:
entity.update_visibility()
```
### Action System Extension
The action system now handles combat:
- **MovementAction**: Detects collision, triggers attack
- **MeleeAction**: New action for melee combat
- Actions remain decoupled from entity logic
### Gravestone System
Instead of removing dead entities:
- Sprite changes to tombstone (index 6)
- Name changes to "Grave of [Name]"
- No longer blocks movement
- Remains visible as dungeon decoration
## Architecture Notes
### Why Actor Extends Entity?
- Maintains entity hierarchy
- Combat stats only for creatures
- Future items/decorations won't have HP
- Clean separation of concerns
### Why Factory Functions?
- Centralized entity configuration
- Easy to add new enemy types
- Consistent stat management
- Type-safe entity creation
### Combat in Actions
Combat logic lives in actions, not entities:
- Entities store stats
- Actions perform combat
- Clean separation of data and behavior
- Extensible for future combat types
## Files Modified
- `game/entity.py`: Added Actor class with combat stats and death handling
- `game/entity_factories.py`: New module with entity creation functions
- `game/actions.py`: Added MeleeAction for combat
- `game/procgen.py`: Added enemy placement in rooms
- `game/engine.py`: Updated to use Actor type and handle all entity visibility
- `main.py`: Updated to use entity factories and Part 5 description
## What's Next
Part 6 will enhance the combat experience with:
- Health display UI
- Game over conditions
- Combat messages window
- More strategic combat mechanics
## Learning Points
1. **Entity Specialization**: Use inheritance to add features to specific entity types
2. **Factory Pattern**: Centralize object creation for consistency
3. **State Transformation**: Dead entities become decorations, not deletions
4. **Action Extensions**: Combat fits naturally into the action system
5. **Automatic Systems**: FOV handles entity visibility without special code
## Running Part 5
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
You'll now encounter rats and spiders as you explore! Walk into them to attack. Dead enemies become gravestones that mark your battles.
## Sprite Adaptations
Following our sprite sheet (`sprite_sheet.md`), we made these thematic changes:
- Orcs → Rats (same stats, different sprite)
- Trolls → Spiders (same stats, different sprite)
- Corpses → Gravestones (all use same tombstone sprite)
The gameplay remains identical to the TCOD tutorial, just with different visual theming.

View File

@ -0,0 +1,187 @@
# Part 6: Doing (and Taking) Damage
## Overview
Part 6 transforms our basic combat into a complete gameplay loop with visual feedback, enemy AI, and win/lose conditions. We add a health bar, message log, enemy AI that pursues the player, and proper game over handling.
## What's New in Part 6
### User Interface Components
#### Health Bar
A visual representation of the player's current health:
```python
class HealthBar:
def create_ui(self) -> List[mcrfpy.UIDrawable]:
# Dark red background
self.background = mcrfpy.Frame(pos=(x, y), size=(width, height))
self.background.fill_color = mcrfpy.Color(100, 0, 0, 255)
# Bright colored bar (green/yellow/red based on HP)
self.bar = mcrfpy.Frame(pos=(x, y), size=(width, height))
# Text overlay showing HP numbers
self.text = mcrfpy.Caption(pos=(x+5, y+2),
text=f"HP: {hp}/{max_hp}")
```
The bar changes color based on health percentage:
- Green (>60% health)
- Yellow (30-60% health)
- Red (<30% health)
#### Message Log
A scrolling combat log that replaces console print statements:
```python
class MessageLog:
def __init__(self, max_messages: int = 5):
self.messages: deque[str] = deque(maxlen=max_messages)
def add_message(self, message: str) -> None:
self.messages.append(message)
self.update_display()
```
Messages include:
- Combat actions ("Rat attacks Player for 3 hit points.")
- Death notifications ("Spider is dead!")
- Game state changes ("You have died! Press Escape to quit.")
### Enemy AI System
#### Basic AI Component
Enemies now actively pursue and attack the player:
```python
class BasicAI:
def take_turn(self, engine: Engine) -> None:
distance = max(abs(dx), abs(dy)) # Chebyshev distance
if distance <= 1:
# Adjacent: Attack!
MeleeAction(self.entity, attack_dx, attack_dy).perform(engine)
elif distance <= 6:
# Can see player: Move closer
MovementAction(self.entity, move_dx, move_dy).perform(engine)
```
#### Turn-Based System
After each player action, all enemies take their turn:
```python
def handle_enemy_turns(self) -> None:
for entity in self.game_map.entities:
if isinstance(entity, Actor) and entity.ai and entity.is_alive:
entity.ai.take_turn(self)
```
### Game Over Condition
When the player dies:
1. Game state flag is set (`engine.game_over = True`)
2. Player becomes a gravestone (sprite changes)
3. Input is restricted (only Escape works)
4. Death message appears in the message log
```python
def handle_player_death(self) -> None:
self.game_over = True
self.message_log.add_message("You have died! Press Escape to quit.")
```
## Architecture Improvements
### UI Module (`game/ui.py`)
Separates UI concerns from game logic:
- `MessageLog`: Manages combat messages
- `HealthBar`: Displays player health
- Clean interface for updating displays
### AI Module (`game/ai.py`)
Encapsulates enemy behavior:
- `BasicAI`: Simple pursue-and-attack behavior
- Extensible for different AI types
- Uses existing action system
### Turn Management
Player actions trigger enemy turns:
- Movement → Enemy turns
- Attack → Enemy turns
- Wait → Enemy turns
- Maintains turn-based feel
## Key Implementation Details
### UI Updates
Health bar updates occur:
- After player takes damage
- Automatically via `engine.update_ui()`
- Color changes based on HP percentage
### Message Flow
Combat messages follow this pattern:
1. Action generates message text
2. `engine.message_log.add_message(text)`
3. Message appears in UI Caption
4. Old messages scroll up
### AI Decision Making
Basic AI uses simple rules:
1. Check if player is adjacent → Attack
2. Check if player is visible (within 6 tiles) → Move toward
3. Otherwise → Do nothing
### Game State Management
The `game_over` flag prevents:
- Player movement
- Player attacks
- Player waiting
- But allows Escape to quit
## Files Modified
- `game/ui.py`: New module for UI components
- `game/ai.py`: New module for enemy AI
- `game/engine.py`: Added UI setup, enemy turns, game over handling
- `game/entity.py`: Added AI component to Actor
- `game/entity_factories.py`: Attached AI to enemies
- `game/actions.py`: Integrated message log, added enemy turn triggers
- `main.py`: Updated part description
## What's Next
Part 7 will expand the user interface further with:
- More detailed entity inspection
- Possibly inventory display
- Additional UI panels
- Mouse interaction
## Learning Points
1. **UI Separation**: Keep UI logic separate from game logic
2. **Component Systems**: AI as a component allows different behaviors
3. **Turn-Based Flow**: Player action → Enemy reactions creates tactical gameplay
4. **Visual Feedback**: Health bars and message logs improve player understanding
5. **State Management**: Game over flag controls available actions
## Running Part 6
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
You'll now see:
- Health bar at the top showing your current HP
- Message log at the bottom showing combat events
- Enemies that chase you when you're nearby
- Enemies that attack when adjacent
- Death state when HP reaches 0
## Combat Strategy
With enemy AI active, combat becomes more tactical:
- Enemies pursue when they see you
- Fighting in corridors limits how many can attack
- Running away is sometimes the best option
- Health management becomes critical
The game now has a complete combat loop with clear win/lose conditions!

View File

@ -0,0 +1,204 @@
# Part 7: Creating the User Interface
## Overview
Part 7 significantly enhances the user interface, transforming our roguelike from a basic game into a more polished experience. We add mouse interaction, help displays, information panels, and better visual feedback systems.
## What's New in Part 7
### Mouse Interaction
#### Click-to-Inspect System
Since McRogueFace doesn't have mouse motion events, we use click events to show entity information:
```python
def grid_click_handler(pixel_x, pixel_y, button, state):
# Convert pixel coordinates to grid coordinates
grid_x = int(pixel_x / (self.tile_size * self.zoom))
grid_y = int(pixel_y / (self.tile_size * self.zoom))
# Update hover display for this position
self.update_mouse_hover(grid_x, grid_y)
```
Click displays show:
- Entity names
- Current HP for living creatures
- Multiple entities if stacked (e.g., "Grave of Rat")
#### Mouse Handler Registration
The click handler is registered as a local function to avoid issues with bound methods:
```python
# Use a local function instead of a bound method
self.game_map.click = grid_click_handler
```
### Help System
#### Toggle Help Display
Press `?`, `H`, or `F1` to show/hide help:
```python
class HelpDisplay:
def toggle(self) -> None:
self.visible = not self.visible
self.panel.frame.visible = self.visible
```
The help panel includes:
- Movement controls for all input methods
- Combat instructions
- Mouse usage tips
- Gameplay strategies
### Information Panels
#### Player Stats Panel
Always-visible panel showing:
- Player name
- Current/Max HP
- Power and Defense stats
- Current grid position
```python
class InfoPanel:
def create_ui(self, title: str) -> List[mcrfpy.Drawable]:
# Semi-transparent background frame
self.frame = mcrfpy.Frame(pos=(x, y), size=(width, height))
self.frame.fill_color = mcrfpy.Color(20, 20, 40, 200)
# Title and content captions as children
self.frame.children.append(self.title_caption)
self.frame.children.append(self.content_caption)
```
#### Reusable Panel System
The `InfoPanel` class provides:
- Titled panels with borders
- Semi-transparent backgrounds
- Easy content updates
- Consistent visual style
### Enhanced UI Components
#### MouseHoverDisplay Class
Manages tooltip-style hover information:
- Follows mouse position
- Shows/hides automatically
- Offset to avoid cursor overlap
- Multiple entity support
#### UI Module Organization
Clean separation of UI components:
- `MessageLog`: Combat messages
- `HealthBar`: HP visualization
- `MouseHoverDisplay`: Entity inspection
- `InfoPanel`: Generic information display
- `HelpDisplay`: Keyboard controls
## Architecture Improvements
### UI Composition
Using McRogueFace's parent-child system:
```python
# Add caption as child of frame
self.frame.children.append(self.text_caption)
```
Benefits:
- Automatic relative positioning
- Group visibility control
- Clean hierarchy
### Event Handler Extensions
Input handler now manages:
- Keyboard input (existing)
- Mouse motion (new)
- Mouse clicks (prepared for future)
- UI toggles (help display)
### Dynamic Content Updates
All UI elements support real-time updates:
```python
def update_stats_panel(self) -> None:
stats_text = f"""Name: {self.player.name}
HP: {self.player.hp}/{self.player.max_hp}
Power: {self.player.power}
Defense: {self.player.defense}"""
self.stats_panel.update_content(stats_text)
```
## Key Implementation Details
### Mouse Coordinate Conversion
Pixel to grid conversion:
```python
grid_x = int(x / (self.engine.tile_size * self.engine.zoom))
grid_y = int(y / (self.engine.tile_size * self.engine.zoom))
```
### Visibility Management
UI elements can be toggled:
- Help panel starts hidden
- Mouse hover hides when not over entities
- Panels can be shown/hidden dynamically
### Color and Transparency
UI uses semi-transparent overlays:
- Panel backgrounds: `Color(20, 20, 40, 200)`
- Hover tooltips: `Color(255, 255, 200, 255)`
- Borders and outlines for readability
## Files Modified
- `game/ui.py`: Added MouseHoverDisplay, InfoPanel, HelpDisplay classes
- `game/engine.py`: Integrated new UI components, mouse hover handling
- `game/input_handlers.py`: Added mouse motion handling, help toggle
- `main.py`: Registered mouse handlers, updated part description
## What's Next
Part 8 will add items and inventory:
- Collectible items (potions, equipment)
- Inventory management UI
- Item usage mechanics
- Equipment system
## Learning Points
1. **UI Composition**: Use parent-child relationships for complex UI
2. **Event Delegation**: Separate input handling from UI updates
3. **Information Layers**: Multiple UI systems can coexist (hover, panels, help)
4. **Visual Polish**: Small touches like transparency and borders improve UX
5. **Reusable Components**: Generic panels can be specialized for different uses
## Running Part 7
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
New features to try:
- Click on entities to see their details
- Press ? or H to toggle help display
- Watch the stats panel update as you take damage
- See entity HP in hover tooltips
- Notice the visual polish in UI panels
## UI Design Principles
### Consistency
- All panels use similar visual style
- Consistent color scheme
- Uniform text sizing
### Non-Intrusive
- Semi-transparent panels don't block view
- Hover info appears near cursor
- Help can be toggled off
### Information Hierarchy
- Critical info (health) always visible
- Contextual info (hover) on demand
- Help info toggleable
The UI now provides a professional feel while maintaining the roguelike aesthetic!

View File

@ -0,0 +1,297 @@
# Part 8: Items and Inventory
## Overview
Part 8 transforms our roguelike into a proper loot-driven game by adding items that can be collected, managed, and used. We implement a flexible inventory system with capacity limits, create consumable items like healing potions, and build UI for inventory management.
## What's New in Part 8
### Parent-Child Entity Architecture
#### Flexible Entity Ownership
Entities now have parent containers, allowing them to exist in different contexts:
```python
class Entity(mcrfpy.Entity):
def __init__(self, parent: Optional[Union[GameMap, Inventory]] = None):
self.parent = parent
@property
def gamemap(self) -> Optional[GameMap]:
"""Get the GameMap through the parent chain"""
if isinstance(self.parent, Inventory):
return self.parent.gamemap
return self.parent
```
Benefits:
- Items can exist in the world or in inventories
- Clean ownership transfer when picking up/dropping
- Automatic visibility management
### Inventory System
#### Container-Based Design
The inventory acts like a specialized entity container:
```python
class Inventory:
def __init__(self, capacity: int):
self.capacity = capacity
self.items: List[Item] = []
self.parent: Optional[Actor] = None
def add_item(self, item: Item) -> None:
if len(self.items) >= self.capacity:
raise Impossible("Your inventory is full.")
# Transfer ownership
self.items.append(item)
item.parent = self
item.visible = False # Hide from map
```
Features:
- Capacity limits (26 items for letter selection)
- Clean item transfer between world and inventory
- Automatic visual management
### Item System
#### Item Entity Class
Items are entities with consumable components:
```python
class Item(Entity):
def __init__(self, consumable: Optional = None):
super().__init__(blocks_movement=False)
self.consumable = consumable
if consumable:
consumable.parent = self
```
#### Consumable Components
Modular system for item effects:
```python
class HealingConsumable(Consumable):
def activate(self, action: ItemAction) -> None:
if consumer.hp >= consumer.max_hp:
raise Impossible("You are already at full health.")
amount_recovered = min(self.amount, consumer.max_hp - consumer.hp)
consumer.hp += amount_recovered
self.consume() # Remove item after use
```
### Exception-Driven Feedback
#### Clean Error Handling
Using exceptions for user feedback:
```python
class Impossible(Exception):
"""Action cannot be performed"""
pass
class PickupAction(Action):
def perform(self, engine: Engine) -> None:
if not items_here:
raise Impossible("There is nothing here to pick up.")
try:
inventory.add_item(item)
engine.message_log.add_message(f"You picked up the {item.name}!")
except Impossible as e:
engine.message_log.add_message(str(e))
```
Benefits:
- Consistent error messaging
- Clean control flow
- Centralized feedback handling
### Inventory UI
#### Modal Inventory Screen
Interactive inventory management:
```python
class InventoryEventHandler(BaseEventHandler):
def create_ui(self) -> None:
# Semi-transparent background
self.background = mcrfpy.Frame(pos=(100, 100), size=(400, 400))
self.background.fill_color = mcrfpy.Color(0, 0, 0, 200)
# List items with letter keys
for i, item in enumerate(inventory.items):
item_caption = mcrfpy.Caption(
pos=(20, 80 + i * 20),
text=f"{chr(ord('a') + i)}) {item.name}"
)
```
Features:
- Letter-based selection (a-z)
- Separate handlers for use/drop
- ESC to cancel
- Visual feedback
### Enhanced Actions
#### Item Actions
New actions for item management:
```python
class PickupAction(Action):
"""Pick up items at current location"""
class ItemAction(Action):
"""Base for item usage actions"""
class DropAction(ItemAction):
"""Drop item from inventory"""
```
Each action:
- Self-validates
- Provides feedback
- Triggers enemy turns
## Architecture Improvements
### Component Relationships
Parent-based component system:
```python
# Components know their parent
consumable.parent = item
item.parent = inventory
inventory.parent = actor
actor.parent = gamemap
gamemap.engine = engine
```
Benefits:
- Access to game context from any component
- Clean ownership transfer
- Simplified entity lifecycle
### Input Handler States
Modal UI through handler switching:
```python
# Main game
engine.current_handler = MainGameEventHandler(engine)
# Open inventory
engine.current_handler = InventoryActivateHandler(engine)
# Back to game
engine.current_handler = MainGameEventHandler(engine)
```
### Entity Lifecycle Management
Proper creation and cleanup:
```python
# Item spawning
item = entity_factories.health_potion(x, y, texture)
item.place(x, y, dungeon)
# Pickup
inventory.add_item(item) # Removes from map
# Drop
inventory.drop(item) # Returns to map
# Death
actor.die() # Drops all items
```
## Key Implementation Details
### Visibility Management
Items hide/show based on container:
```python
def add_item(self, item):
item.visible = False # Hide when in inventory
def drop(self, item):
item.visible = True # Show when on map
```
### Inventory Capacity
Limited to alphabet keys:
```python
if len(inventory.items) >= 26:
raise Impossible("Your inventory is full.")
```
### Item Generation
Procedural item placement:
```python
def place_entities(room, dungeon, max_monsters, max_items, texture):
# Place 0-2 items per room
number_of_items = random.randint(0, max_items)
for _ in range(number_of_items):
if space_available:
item = entity_factories.health_potion(x, y, texture)
item.place(x, y, dungeon)
```
## Files Modified
- `game/entity.py`: Added parent system, Item class, inventory to Actor
- `game/inventory.py`: New inventory container system
- `game/consumable.py`: New consumable component system
- `game/exceptions.py`: New Impossible exception
- `game/actions.py`: Added PickupAction, ItemAction, DropAction
- `game/input_handlers.py`: Added InventoryEventHandler classes
- `game/engine.py`: Added current_handler, inventory UI methods
- `game/procgen.py`: Added item generation
- `game/entity_factories.py`: Added health_potion factory
- `game/ui.py`: Updated help text with inventory controls
- `main.py`: Updated to Part 8, handler management
## What's Next
Part 9 will add ranged attacks and targeting:
- Targeting UI for selecting enemies
- Ranged damage items (lightning staff)
- Area-of-effect items (fireball staff)
- Confusion effects
## Learning Points
1. **Container Architecture**: Entity ownership through parent relationships
2. **Component Systems**: Modular, reusable components with parent references
3. **Exception Handling**: Clean error propagation and user feedback
4. **Modal UI**: State-based input handling for different screens
5. **Item Systems**: Flexible consumable architecture for varied effects
6. **Lifecycle Management**: Proper entity creation, transfer, and cleanup
## Running Part 8
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
New features to try:
- Press G to pick up healing potions
- Press I to open inventory and use items
- Press O to drop items from inventory
- Heal yourself when injured in combat
- Manage limited inventory space (26 slots)
- Items drop from dead enemies
## Design Principles
### Flexibility Through Composition
- Items gain behavior through consumable components
- Easy to add new item types
- Reusable effect system
### Clean Ownership Transfer
- Entities always have clear parent
- Automatic visibility management
- No orphaned entities
### User-Friendly Feedback
- Clear error messages
- Consistent UI patterns
- Intuitive controls
The inventory system provides the foundation for equipment, spells, and complex item interactions in future parts!

View File

@ -0,0 +1,625 @@
"""
McRogueFace Tutorial - Part 5: Entity Interactions
This tutorial builds on Part 4 by adding:
- Entity class hierarchy (PlayerEntity, EnemyEntity, BoulderEntity, ButtonEntity)
- Non-blocking movement animations with destination tracking
- Bump interactions (combat, pushing)
- Step-on interactions (buttons, doors)
- Concurrent enemy AI with smooth animations
Key concepts:
- Entities inherit from mcrfpy.Entity for proper C++/Python integration
- Logic operates on destination positions during animations
- Player input is processed immediately, not blocked by animations
"""
import mcrfpy
import random
# ============================================================================
# Entity Classes - Inherit from mcrfpy.Entity
# ============================================================================
class GameEntity(mcrfpy.Entity):
"""Base class for all game entities with interaction logic"""
def __init__(self, x, y, **kwargs):
# Extract grid before passing to parent
grid = kwargs.pop('grid', None)
super().__init__(x=x, y=y, **kwargs)
# Current position is tracked by parent Entity.x/y
# Add destination tracking for animation system
self.dest_x = x
self.dest_y = y
self.is_moving = False
# Game properties
self.blocks_movement = True
self.hp = 10
self.max_hp = 10
self.entity_type = "generic"
# Add to grid if provided
if grid:
grid.entities.append(self)
def start_move(self, new_x, new_y, duration=0.2, callback=None):
"""Start animating movement to new position"""
self.dest_x = new_x
self.dest_y = new_y
self.is_moving = True
# Create animations for smooth movement
if callback:
# Only x animation needs callback since they run in parallel
anim_x = mcrfpy.Animation("x", float(new_x), duration, "easeInOutQuad", callback=callback)
else:
anim_x = mcrfpy.Animation("x", float(new_x), duration, "easeInOutQuad")
anim_y = mcrfpy.Animation("y", float(new_y), duration, "easeInOutQuad")
anim_x.start(self)
anim_y.start(self)
def get_position(self):
"""Get logical position (destination if moving, otherwise current)"""
if self.is_moving:
return (self.dest_x, self.dest_y)
return (int(self.x), int(self.y))
def on_bump(self, other):
"""Called when another entity tries to move into our space"""
return False # Block movement by default
def on_step(self, other):
"""Called when another entity steps on us (non-blocking)"""
pass
def take_damage(self, damage):
"""Apply damage and handle death"""
self.hp -= damage
if self.hp <= 0:
self.hp = 0
self.die()
def die(self):
"""Remove entity from grid"""
# The C++ die() method handles removal from grid
super().die()
class PlayerEntity(GameEntity):
"""The player character"""
def __init__(self, x, y, **kwargs):
kwargs['sprite_index'] = 64 # Hero sprite
super().__init__(x=x, y=y, **kwargs)
self.damage = 3
self.entity_type = "player"
self.blocks_movement = True
def on_bump(self, other):
"""Player bumps into something"""
if other.entity_type == "enemy":
# Deal damage
other.take_damage(self.damage)
return False # Can't move into enemy space
elif other.entity_type == "boulder":
# Try to push
dx = self.dest_x - int(self.x)
dy = self.dest_y - int(self.y)
return other.try_push(dx, dy)
return False
class EnemyEntity(GameEntity):
"""Basic enemy with AI"""
def __init__(self, x, y, **kwargs):
kwargs['sprite_index'] = 65 # Enemy sprite
super().__init__(x=x, y=y, **kwargs)
self.damage = 1
self.entity_type = "enemy"
self.ai_state = "wander"
self.hp = 5
self.max_hp = 5
def on_bump(self, other):
"""Enemy bumps into something"""
if other.entity_type == "player":
other.take_damage(self.damage)
return False
return False
def can_see_player(self, player_pos, grid):
"""Check if enemy can see the player position"""
# Simple check: within 6 tiles and has line of sight
mx, my = self.get_position()
px, py = player_pos
dist = abs(px - mx) + abs(py - my)
if dist > 6:
return False
# Use libtcod for line of sight
line = list(mcrfpy.libtcod.line(mx, my, px, py))
if len(line) > 7: # Too far
return False
for x, y in line[1:-1]: # Skip start and end points
cell = grid.at(x, y)
if cell and not cell.transparent:
return False
return True
def ai_turn(self, grid, player):
"""Decide next move"""
px, py = player.get_position()
mx, my = self.get_position()
# Simple AI: move toward player if visible
if self.can_see_player((px, py), grid):
# Calculate direction toward player
dx = 0
dy = 0
if px > mx:
dx = 1
elif px < mx:
dx = -1
if py > my:
dy = 1
elif py < my:
dy = -1
# Prefer cardinal movement
if dx != 0 and dy != 0:
# Pick horizontal or vertical based on greater distance
if abs(px - mx) > abs(py - my):
dy = 0
else:
dx = 0
return (mx + dx, my + dy)
else:
# Random movement
dx, dy = random.choice([(0,1), (0,-1), (1,0), (-1,0)])
return (mx + dx, my + dy)
class BoulderEntity(GameEntity):
"""Pushable boulder"""
def __init__(self, x, y, **kwargs):
kwargs['sprite_index'] = 7 # Boulder sprite
super().__init__(x=x, y=y, **kwargs)
self.entity_type = "boulder"
self.pushable = True
def try_push(self, dx, dy):
"""Attempt to push boulder in direction"""
new_x = int(self.x) + dx
new_y = int(self.y) + dy
# Check if destination is free
if can_move_to(new_x, new_y):
self.start_move(new_x, new_y)
return True
return False
class ButtonEntity(GameEntity):
"""Pressure plate that triggers when stepped on"""
def __init__(self, x, y, target=None, **kwargs):
kwargs['sprite_index'] = 8 # Button sprite
super().__init__(x=x, y=y, **kwargs)
self.blocks_movement = False # Can be walked over
self.entity_type = "button"
self.pressed = False
self.pressed_by = set() # Track who's pressing
self.target = target # Door or other triggerable
def on_step(self, other):
"""Activate when stepped on"""
if other not in self.pressed_by:
self.pressed_by.add(other)
if not self.pressed:
self.pressed = True
self.sprite_index = 9 # Pressed sprite
if self.target:
self.target.activate()
def on_leave(self, other):
"""Deactivate when entity leaves"""
if other in self.pressed_by:
self.pressed_by.remove(other)
if len(self.pressed_by) == 0 and self.pressed:
self.pressed = False
self.sprite_index = 8 # Unpressed sprite
if self.target:
self.target.deactivate()
class DoorEntity(GameEntity):
"""Door that can be opened by buttons"""
def __init__(self, x, y, **kwargs):
kwargs['sprite_index'] = 3 # Closed door sprite
super().__init__(x=x, y=y, **kwargs)
self.entity_type = "door"
self.is_open = False
def activate(self):
"""Open the door"""
self.is_open = True
self.blocks_movement = False
self.sprite_index = 11 # Open door sprite
def deactivate(self):
"""Close the door"""
self.is_open = False
self.blocks_movement = True
self.sprite_index = 3 # Closed door sprite
# ============================================================================
# Global Game State
# ============================================================================
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Create a grid of tiles
grid_width, grid_height = 40, 30
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
# Create the grid
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size,
)
grid.zoom = zoom
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Game state
player = None
enemies = []
all_entities = []
is_player_turn = True
move_duration = 0.2
# ============================================================================
# Dungeon Generation (from Part 3)
# ============================================================================
class Room:
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
def center(self):
return ((self.x1 + self.x2) // 2, (self.y1 + self.y2) // 2)
def intersects(self, other):
return (self.x1 <= other.x2 and self.x2 >= other.x1 and
self.y1 <= other.y2 and self.y2 >= other.y1)
def create_room(room):
"""Carve out a room in the grid"""
for x in range(room.x1 + 1, room.x2):
for y in range(room.y1 + 1, room.y2):
cell = grid.at(x, y)
if cell:
cell.walkable = True
cell.transparent = True
cell.tilesprite = random.choice(FLOOR_TILES)
def create_l_shaped_hallway(x1, y1, x2, y2):
"""Create L-shaped hallway between two points"""
corner_x = x2
corner_y = y1
if random.random() < 0.5:
corner_x = x1
corner_y = y2
for x, y in mcrfpy.libtcod.line(x1, y1, corner_x, corner_y):
cell = grid.at(x, y)
if cell:
cell.walkable = True
cell.transparent = True
cell.tilesprite = random.choice(FLOOR_TILES)
for x, y in mcrfpy.libtcod.line(corner_x, corner_y, x2, y2):
cell = grid.at(x, y)
if cell:
cell.walkable = True
cell.transparent = True
cell.tilesprite = random.choice(FLOOR_TILES)
def generate_dungeon():
"""Generate a simple dungeon with rooms and hallways"""
# Initialize all cells as walls
for x in range(grid_width):
for y in range(grid_height):
cell = grid.at(x, y)
if cell:
cell.walkable = False
cell.transparent = False
cell.tilesprite = random.choice(WALL_TILES)
rooms = []
num_rooms = 0
for _ in range(30):
w = random.randint(4, 8)
h = random.randint(4, 8)
x = random.randint(0, grid_width - w - 1)
y = random.randint(0, grid_height - h - 1)
new_room = Room(x, y, w, h)
# Check if room intersects with existing rooms
if any(new_room.intersects(other_room) for other_room in rooms):
continue
create_room(new_room)
if num_rooms > 0:
# Connect to previous room
new_x, new_y = new_room.center()
prev_x, prev_y = rooms[num_rooms - 1].center()
create_l_shaped_hallway(prev_x, prev_y, new_x, new_y)
rooms.append(new_room)
num_rooms += 1
return rooms
# ============================================================================
# Entity Management
# ============================================================================
def get_entities_at(x, y):
"""Get all entities at a specific position (including moving ones)"""
entities = []
for entity in all_entities:
ex, ey = entity.get_position()
if ex == x and ey == y:
entities.append(entity)
return entities
def get_blocking_entity_at(x, y):
"""Get the first blocking entity at position"""
for entity in get_entities_at(x, y):
if entity.blocks_movement:
return entity
return None
def can_move_to(x, y):
"""Check if a position is valid for movement"""
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
return False
cell = grid.at(x, y)
if not cell or not cell.walkable:
return False
# Check for blocking entities
if get_blocking_entity_at(x, y):
return False
return True
def can_entity_move_to(entity, x, y):
"""Check if specific entity can move to position"""
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
return False
cell = grid.at(x, y)
if not cell or not cell.walkable:
return False
# Check for other blocking entities (not self)
blocker = get_blocking_entity_at(x, y)
if blocker and blocker != entity:
return False
return True
# ============================================================================
# Turn Management
# ============================================================================
def process_player_move(key):
"""Handle player input with immediate response"""
global is_player_turn
if not is_player_turn or player.is_moving:
return # Not player's turn or still animating
px, py = player.get_position()
new_x, new_y = px, py
# Calculate movement direction
if key == "W" or key == "Up":
new_y -= 1
elif key == "S" or key == "Down":
new_y += 1
elif key == "A" or key == "Left":
new_x -= 1
elif key == "D" or key == "Right":
new_x += 1
else:
return # Not a movement key
if new_x == px and new_y == py:
return # No movement
# Check what's at destination
cell = grid.at(new_x, new_y)
if not cell or not cell.walkable:
return # Can't move into walls
blocking_entity = get_blocking_entity_at(new_x, new_y)
if blocking_entity:
# Try bump interaction
if not player.on_bump(blocking_entity):
# Movement blocked, but turn still happens
is_player_turn = False
mcrfpy.setTimer("enemy_turn", process_enemy_turns, 50)
return
# Movement is valid - start player animation
is_player_turn = False
player.start_move(new_x, new_y, duration=move_duration, callback=player_move_complete)
# Update grid center to follow player
grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, move_duration, "linear")
grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, move_duration, "linear")
grid_anim_x.start(grid)
grid_anim_y.start(grid)
# Start enemy turns after a short delay (so player sees their move start first)
mcrfpy.setTimer("enemy_turn", process_enemy_turns, 50)
def process_enemy_turns(timer_name):
"""Process all enemy AI decisions and start their animations"""
enemies_to_move = []
for enemy in enemies:
if enemy.hp <= 0: # Skip dead enemies
continue
if enemy.is_moving:
continue # Skip if still animating
# AI decides next move based on player's destination
target_x, target_y = enemy.ai_turn(grid, player)
# Check if move is valid
cell = grid.at(target_x, target_y)
if not cell or not cell.walkable:
continue
# Check what's at the destination
blocking_entity = get_blocking_entity_at(target_x, target_y)
if blocking_entity and blocking_entity != enemy:
# Try bump interaction
enemy.on_bump(blocking_entity)
# Enemy doesn't move but still took its turn
else:
# Valid move - add to list
enemies_to_move.append((enemy, target_x, target_y))
# Start all enemy animations simultaneously
for enemy, tx, ty in enemies_to_move:
enemy.start_move(tx, ty, duration=move_duration)
def player_move_complete(anim, entity):
"""Called when player animation finishes"""
global is_player_turn
player.is_moving = False
# Check for step-on interactions at new position
for entity in get_entities_at(int(player.x), int(player.y)):
if entity != player and not entity.blocks_movement:
entity.on_step(player)
# Update FOV from new position
update_fov()
# Player's turn is ready again
is_player_turn = True
def update_fov():
"""Update field of view from player position"""
px, py = int(player.x), int(player.y)
grid.compute_fov(px, py, radius=8)
player.update_visibility()
# ============================================================================
# Input Handling
# ============================================================================
def handle_keys(key, state):
"""Handle keyboard input"""
if state == "start":
# Movement keys
if key in ["W", "Up", "S", "Down", "A", "Left", "D", "Right"]:
process_player_move(key)
# Register the key handler
mcrfpy.keypressScene(handle_keys)
# ============================================================================
# Initialize Game
# ============================================================================
# Generate dungeon
rooms = generate_dungeon()
# Place player in first room
if rooms:
start_x, start_y = rooms[0].center()
player = PlayerEntity(start_x, start_y, grid=grid)
all_entities.append(player)
# Place enemies in other rooms
for i in range(1, min(6, len(rooms))):
room = rooms[i]
ex, ey = room.center()
enemy = EnemyEntity(ex, ey, grid=grid)
enemies.append(enemy)
all_entities.append(enemy)
# Place some boulders
for i in range(3):
room = random.choice(rooms[1:])
bx = random.randint(room.x1 + 1, room.x2 - 1)
by = random.randint(room.y1 + 1, room.y2 - 1)
if can_move_to(bx, by):
boulder = BoulderEntity(bx, by, grid=grid)
all_entities.append(boulder)
# Place a button and door in one of the rooms
if len(rooms) > 2:
button_room = rooms[-2]
door_room = rooms[-1]
# Place door at entrance to last room
dx, dy = door_room.center()
door = DoorEntity(dx, door_room.y1, grid=grid)
all_entities.append(door)
# Place button in second to last room
bx, by = button_room.center()
button = ButtonEntity(bx, by, target=door, grid=grid)
all_entities.append(button)
# Set grid perspective to player
grid.perspective = player
grid.center_x = (start_x + 0.5) * 16
grid.center_y = (start_y + 0.5) * 16
# Initial FOV calculation
update_fov()
# Add grid to scene
mcrfpy.sceneUI("tutorial").append(grid)
# Show instructions
title = mcrfpy.Caption((320, 10),
text="Part 5: Entity Interactions - WASD to move, bump enemies, push boulders!",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
print("Part 5: Entity Interactions - Tutorial loaded!")
print("- Bump into enemies to attack them")
print("- Push boulders by walking into them")
print("- Step on buttons to open doors")
print("- Enemies will pursue you when they can see you")

View File

@ -0,0 +1,253 @@
# Part 0 - Setting Up McRogueFace
Welcome to the McRogueFace Roguelike Tutorial! This tutorial will teach you how to create a complete roguelike game using the McRogueFace game engine. Unlike traditional Python libraries, McRogueFace is a complete, portable game engine that includes everything you need to make and distribute games.
## What is McRogueFace?
McRogueFace is a high-performance game engine with Python scripting support. Think of it like Unity or Godot, but specifically designed for roguelikes and 2D games. It includes:
- A complete Python 3.12 runtime (no installation needed!)
- High-performance C++ rendering and entity management
- Built-in UI components and scene management
- Integrated audio system
- Professional sprite-based graphics
- Easy distribution - your players don't need Python installed!
## Prerequisites
Before starting this tutorial, you should:
- Have basic Python knowledge (variables, functions, classes)
- Be comfortable editing text files
- Have a text editor (VS Code, Sublime Text, Notepad++, etc.)
That's it! Unlike other roguelike tutorials, you don't need Python installed - McRogueFace includes everything.
## Getting McRogueFace
### Step 1: Download the Engine
1. Visit the McRogueFace releases page
2. Download the version for your operating system:
- `McRogueFace-Windows.zip` for Windows
- `McRogueFace-MacOS.zip` for macOS
- `McRogueFace-Linux.zip` for Linux
### Step 2: Extract the Archive
Extract the downloaded archive to a folder where you want to develop your game. You should see this structure:
```
McRogueFace/
├── mcrogueface (or mcrogueface.exe on Windows)
├── scripts/
│ └── game.py
├── assets/
│ ├── sprites/
│ ├── fonts/
│ └── audio/
└── lib/
```
### Step 3: Run the Engine
Run the McRogueFace executable:
- **Windows**: Double-click `mcrogueface.exe`
- **Mac/Linux**: Open a terminal in the folder and run `./mcrogueface`
You should see a window open with the default McRogueFace demo. This shows the engine is working correctly!
## Your First McRogueFace Script
Let's modify the engine to display "Hello Roguelike!" instead of the default demo.
### Step 1: Open game.py
Open `scripts/game.py` in your text editor. You'll see the default demo code. Replace it entirely with:
```python
import mcrfpy
# Create a new scene called "hello"
mcrfpy.createScene("hello")
# Switch to our new scene
mcrfpy.setScene("hello")
# Get the UI container for our scene
ui = mcrfpy.sceneUI("hello")
# Create a text caption
caption = mcrfpy.Caption("Hello Roguelike!", 400, 300)
caption.font_size = 32
caption.fill_color = mcrfpy.Color(255, 255, 255) # White text
# Add the caption to our scene
ui.append(caption)
# Create a smaller instruction caption
instruction = mcrfpy.Caption("Press ESC to exit", 400, 350)
instruction.font_size = 16
instruction.fill_color = mcrfpy.Color(200, 200, 200) # Light gray
ui.append(instruction)
# Set up a simple key handler
def handle_keys(key, state):
if state == "start" and key == "Escape":
mcrfpy.setScene(None) # This exits the game
mcrfpy.keypressScene(handle_keys)
print("Hello Roguelike is running!")
```
### Step 2: Save and Run
1. Save the file
2. If McRogueFace is still running, it will automatically reload!
3. If not, run the engine again
You should now see "Hello Roguelike!" displayed in the window.
### Step 3: Understanding the Code
Let's break down what we just wrote:
1. **Import mcrfpy**: This is McRogueFace's Python API
2. **Create a scene**: Scenes are like game states (menu, gameplay, inventory, etc.)
3. **UI elements**: We create Caption objects for text display
4. **Colors**: McRogueFace uses RGB colors (0-255 for each component)
5. **Input handling**: We set up a callback for keyboard input
6. **Scene switching**: Setting the scene to None exits the game
## Key Differences from Pure Python Development
### The Game Loop
Unlike typical Python scripts, McRogueFace runs your code inside its game loop:
1. The engine starts and loads `scripts/game.py`
2. Your script sets up scenes, UI elements, and callbacks
3. The engine runs at 60 FPS, handling rendering and input
4. Your callbacks are triggered by game events
### Hot Reloading
McRogueFace can reload your scripts while running! Just save your changes and the engine will reload automatically. This makes development incredibly fast.
### Asset Pipeline
McRogueFace includes a complete asset system:
- **Sprites**: Place images in `assets/sprites/`
- **Fonts**: TrueType fonts in `assets/fonts/`
- **Audio**: Sound effects and music in `assets/audio/`
We'll explore these in later lessons.
## Testing Your Setup
Let's create a more interactive test to ensure everything is working properly:
```python
import mcrfpy
# Create our test scene
mcrfpy.createScene("test")
mcrfpy.setScene("test")
ui = mcrfpy.sceneUI("test")
# Create a background frame
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(20, 20, 30) # Dark blue-gray
ui.append(background)
# Title text
title = mcrfpy.Caption("McRogueFace Setup Test", 512, 100)
title.font_size = 36
title.fill_color = mcrfpy.Color(255, 255, 100) # Yellow
ui.append(title)
# Status text that will update
status_text = mcrfpy.Caption("Press any key to test input...", 512, 300)
status_text.font_size = 20
status_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status_text)
# Instructions
instructions = [
"Arrow Keys: Test movement input",
"Space: Test action input",
"Mouse Click: Test mouse input",
"ESC: Exit"
]
y_offset = 400
for instruction in instructions:
inst_caption = mcrfpy.Caption(instruction, 512, y_offset)
inst_caption.font_size = 16
inst_caption.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(inst_caption)
y_offset += 30
# Input handler
def handle_input(key, state):
if state != "start":
return
if key == "Escape":
mcrfpy.setScene(None)
else:
status_text.text = f"You pressed: {key}"
status_text.fill_color = mcrfpy.Color(100, 255, 100) # Green
# Set up input handling
mcrfpy.keypressScene(handle_input)
print("Setup test is running! Try pressing different keys.")
```
## Troubleshooting
### Engine Won't Start
- **Windows**: Make sure you extracted all files, not just the .exe
- **Mac**: You may need to right-click and select "Open" the first time
- **Linux**: Make sure the file is executable: `chmod +x mcrogueface`
### Scripts Not Loading
- Ensure your script is named exactly `game.py` in the `scripts/` folder
- Check the console output for Python errors
- Make sure you're using Python 3 syntax
### Performance Issues
- McRogueFace should run smoothly at 60 FPS
- If not, check if your graphics drivers are updated
- The engine shows FPS in the window title
## What's Next?
Congratulations! You now have McRogueFace set up and running. You've learned:
- How to download and run the McRogueFace engine
- The basic structure of a McRogueFace project
- How to create scenes and UI elements
- How to handle keyboard input
- The development workflow with hot reloading
In Part 1, we'll create our player character and implement movement. We'll explore McRogueFace's entity system and learn how to create a game world.
## Why McRogueFace?
Before we continue, let's highlight why McRogueFace is excellent for roguelike development:
1. **No Installation Hassles**: Your players just download and run - no Python needed!
2. **Professional Performance**: C++ engine core means smooth gameplay even with hundreds of entities
3. **Built-in Features**: UI, audio, scenes, and animations are already there
4. **Easy Distribution**: Just zip your game folder and share it
5. **Rapid Development**: Hot reloading and Python scripting for quick iteration
Ready to make a roguelike? Let's continue to Part 1!

View File

@ -0,0 +1,33 @@
import mcrfpy
# Create a new scene called "hello"
mcrfpy.createScene("hello")
# Switch to our new scene
mcrfpy.setScene("hello")
# Get the UI container for our scene
ui = mcrfpy.sceneUI("hello")
# Create a text caption
caption = mcrfpy.Caption("Hello Roguelike!", 400, 300)
caption.font_size = 32
caption.fill_color = mcrfpy.Color(255, 255, 255) # White text
# Add the caption to our scene
ui.append(caption)
# Create a smaller instruction caption
instruction = mcrfpy.Caption("Press ESC to exit", 400, 350)
instruction.font_size = 16
instruction.fill_color = mcrfpy.Color(200, 200, 200) # Light gray
ui.append(instruction)
# Set up a simple key handler
def handle_keys(key, state):
if state == "start" and key == "Escape":
mcrfpy.setScene(None) # This exits the game
mcrfpy.keypressScene(handle_keys)
print("Hello Roguelike is running!")

View File

@ -0,0 +1,55 @@
import mcrfpy
# Create our test scene
mcrfpy.createScene("test")
mcrfpy.setScene("test")
ui = mcrfpy.sceneUI("test")
# Create a background frame
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(20, 20, 30) # Dark blue-gray
ui.append(background)
# Title text
title = mcrfpy.Caption("McRogueFace Setup Test", 512, 100)
title.font_size = 36
title.fill_color = mcrfpy.Color(255, 255, 100) # Yellow
ui.append(title)
# Status text that will update
status_text = mcrfpy.Caption("Press any key to test input...", 512, 300)
status_text.font_size = 20
status_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status_text)
# Instructions
instructions = [
"Arrow Keys: Test movement input",
"Space: Test action input",
"Mouse Click: Test mouse input",
"ESC: Exit"
]
y_offset = 400
for instruction in instructions:
inst_caption = mcrfpy.Caption(instruction, 512, y_offset)
inst_caption.font_size = 16
inst_caption.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(inst_caption)
y_offset += 30
# Input handler
def handle_input(key, state):
if state != "start":
return
if key == "Escape":
mcrfpy.setScene(None)
else:
status_text.text = f"You pressed: {key}"
status_text.fill_color = mcrfpy.Color(100, 255, 100) # Green
# Set up input handling
mcrfpy.keypressScene(handle_input)
print("Setup test is running! Try pressing different keys.")

View File

@ -0,0 +1,457 @@
# Part 1 - Drawing the '@' Symbol and Moving It Around
In Part 0, we set up McRogueFace and created a simple "Hello Roguelike" scene. Now it's time to create the foundation of our game: a player character that can move around the screen.
In traditional roguelikes, the player is represented by the '@' symbol. We'll honor that tradition while taking advantage of McRogueFace's powerful sprite-based rendering system.
## Understanding McRogueFace's Architecture
Before we dive into code, let's understand two key concepts in McRogueFace:
### Grid - The Game World
A `Grid` represents your game world. It's a 2D array of tiles where each tile can be:
- **Walkable or not** (for collision detection)
- **Transparent or not** (for field of view, which we'll cover later)
- **Have a visual appearance** (sprite index and color)
Think of the Grid as the dungeon floor, walls, and other static elements.
### Entity - Things That Move
An `Entity` represents anything that can move around on the Grid:
- The player character
- Monsters
- Items (if you want them to be thrown or moved)
- Projectiles
Entities exist "on top of" the Grid and automatically handle smooth movement animation between tiles.
## Creating Our Game World
Let's start by creating a simple room for our player to move around in. Create a new `game.py`:
```python
import mcrfpy
# Define some constants for our tile types
FLOOR_TILE = 0
WALL_TILE = 1
PLAYER_SPRITE = 2
# Window configuration
mcrfpy.createScene("game")
mcrfpy.setScene("game")
# Configure window properties
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 1"
# Get the UI container for our scene
ui = mcrfpy.sceneUI("game")
# Create a dark background
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
ui.append(background)
```
Now we need to set up our tileset. For this tutorial, we'll use ASCII-style sprites. McRogueFace comes with a built-in ASCII tileset:
```python
# Load the ASCII tileset
# This tileset has characters mapped to sprite indices
# For example: @ = 64, # = 35, . = 46
tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
# Create the game grid
# 50x30 tiles is a good size for a roguelike
GRID_WIDTH = 50
GRID_HEIGHT = 30
grid = mcrfpy.Grid(grid_x=GRID_WIDTH, grid_y=GRID_HEIGHT, texture=tileset)
grid.position = (100, 100) # Position on screen
grid.size = (800, 480) # Size in pixels
# Add the grid to our UI
ui.append(grid)
```
## Initializing the Game World
Now let's fill our grid with a simple room:
```python
def create_room():
"""Create a room with walls around the edges"""
# Fill everything with floor tiles first
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
cell.sprite_index = 46 # '.' character
cell.color = mcrfpy.Color(50, 50, 50) # Dark gray floor
# Create walls around the edges
for x in range(GRID_WIDTH):
# Top wall
cell = grid.at(x, 0)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100) # Gray walls
# Bottom wall
cell = grid.at(x, GRID_HEIGHT - 1)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
for y in range(GRID_HEIGHT):
# Left wall
cell = grid.at(0, y)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
# Right wall
cell = grid.at(GRID_WIDTH - 1, y)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
# Create the room
create_room()
```
## Creating the Player
Now let's add our player character:
```python
# Create the player entity
player = mcrfpy.Entity(x=GRID_WIDTH // 2, y=GRID_HEIGHT // 2, grid=grid)
player.sprite_index = 64 # '@' character
player.color = mcrfpy.Color(255, 255, 255) # White
# The entity is automatically added to the grid when we pass grid= parameter
# This is equivalent to: grid.entities.append(player)
```
## Handling Input
McRogueFace uses a callback system for input. For a turn-based roguelike, we only care about key presses, not releases:
```python
def handle_input(key, state):
"""Handle keyboard input for player movement"""
# Only process key presses, not releases
if state != "start":
return
# Movement deltas
dx, dy = 0, 0
# Arrow keys
if key == "Up":
dy = -1
elif key == "Down":
dy = 1
elif key == "Left":
dx = -1
elif key == "Right":
dx = 1
# Numpad movement (for true roguelike feel!)
elif key == "Num7": # Northwest
dx, dy = -1, -1
elif key == "Num8": # North
dy = -1
elif key == "Num9": # Northeast
dx, dy = 1, -1
elif key == "Num4": # West
dx = -1
elif key == "Num6": # East
dx = 1
elif key == "Num1": # Southwest
dx, dy = -1, 1
elif key == "Num2": # South
dy = 1
elif key == "Num3": # Southeast
dx, dy = 1, 1
# Escape to quit
elif key == "Escape":
mcrfpy.setScene(None)
return
# If there's movement, try to move the player
if dx != 0 or dy != 0:
move_player(dx, dy)
# Register the input handler
mcrfpy.keypressScene(handle_input)
```
## Implementing Movement with Collision Detection
Now let's implement the movement function with proper collision detection:
```python
def move_player(dx, dy):
"""Move the player if the destination is walkable"""
# Calculate new position
new_x = player.x + dx
new_y = player.y + dy
# Check bounds
if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT:
return
# Check if the destination is walkable
destination = grid.at(new_x, new_y)
if destination.walkable:
# Move the player
player.x = new_x
player.y = new_y
# The entity will automatically animate to the new position!
```
## Adding Visual Polish
Let's add some UI elements to make our game look more polished:
```python
# Add a title
title = mcrfpy.Caption("McRogueFace Roguelike", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100) # Yellow
ui.append(title)
# Add instructions
instructions = mcrfpy.Caption("Arrow Keys or Numpad to move, ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200) # Light gray
ui.append(instructions)
# Add a status line at the bottom
status = mcrfpy.Caption("@ You", 100, 600)
status.font_size = 18
status.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(status)
```
## Complete Code
Here's the complete `game.py` for Part 1:
```python
import mcrfpy
# Window configuration
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 1"
# Get the UI container for our scene
ui = mcrfpy.sceneUI("game")
# Create a dark background
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
ui.append(background)
# Load the ASCII tileset
tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
# Create the game grid
GRID_WIDTH = 50
GRID_HEIGHT = 30
grid = mcrfpy.Grid(grid_x=GRID_WIDTH, grid_y=GRID_HEIGHT, texture=tileset)
grid.position = (100, 100)
grid.size = (800, 480)
ui.append(grid)
def create_room():
"""Create a room with walls around the edges"""
# Fill everything with floor tiles first
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
cell.sprite_index = 46 # '.' character
cell.color = mcrfpy.Color(50, 50, 50) # Dark gray floor
# Create walls around the edges
for x in range(GRID_WIDTH):
# Top wall
cell = grid.at(x, 0)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100) # Gray walls
# Bottom wall
cell = grid.at(x, GRID_HEIGHT - 1)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
for y in range(GRID_HEIGHT):
# Left wall
cell = grid.at(0, y)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
# Right wall
cell = grid.at(GRID_WIDTH - 1, y)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
# Create the room
create_room()
# Create the player entity
player = mcrfpy.Entity(x=GRID_WIDTH // 2, y=GRID_HEIGHT // 2, grid=grid)
player.sprite_index = 64 # '@' character
player.color = mcrfpy.Color(255, 255, 255) # White
def move_player(dx, dy):
"""Move the player if the destination is walkable"""
# Calculate new position
new_x = player.x + dx
new_y = player.y + dy
# Check bounds
if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT:
return
# Check if the destination is walkable
destination = grid.at(new_x, new_y)
if destination.walkable:
# Move the player
player.x = new_x
player.y = new_y
def handle_input(key, state):
"""Handle keyboard input for player movement"""
# Only process key presses, not releases
if state != "start":
return
# Movement deltas
dx, dy = 0, 0
# Arrow keys
if key == "Up":
dy = -1
elif key == "Down":
dy = 1
elif key == "Left":
dx = -1
elif key == "Right":
dx = 1
# Numpad movement (for true roguelike feel!)
elif key == "Num7": # Northwest
dx, dy = -1, -1
elif key == "Num8": # North
dy = -1
elif key == "Num9": # Northeast
dx, dy = 1, -1
elif key == "Num4": # West
dx = -1
elif key == "Num6": # East
dx = 1
elif key == "Num1": # Southwest
dx, dy = -1, 1
elif key == "Num2": # South
dy = 1
elif key == "Num3": # Southeast
dx, dy = 1, 1
# Escape to quit
elif key == "Escape":
mcrfpy.setScene(None)
return
# If there's movement, try to move the player
if dx != 0 or dy != 0:
move_player(dx, dy)
# Register the input handler
mcrfpy.keypressScene(handle_input)
# Add UI elements
title = mcrfpy.Caption("McRogueFace Roguelike", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
ui.append(title)
instructions = mcrfpy.Caption("Arrow Keys or Numpad to move, ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(instructions)
status = mcrfpy.Caption("@ You", 100, 600)
status.font_size = 18
status.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(status)
print("Part 1: The @ symbol moves!")
```
## Understanding What We've Built
Let's review the key concepts we've implemented:
1. **Grid-Entity Architecture**: The Grid represents our static world (floors and walls), while the Entity (player) moves on top of it.
2. **Collision Detection**: By checking the `walkable` property of grid cells, we prevent the player from walking through walls.
3. **Turn-Based Input**: By only responding to key presses (not releases), we've created true turn-based movement.
4. **Visual Feedback**: The Entity system automatically animates movement between tiles, giving smooth visual feedback.
## Exercises
Try these modifications to deepen your understanding:
1. **Add More Rooms**: Create multiple rooms connected by corridors
2. **Different Tile Types**: Add doors (walkable but different appearance)
3. **Sprint Movement**: Hold Shift to move multiple tiles at once
4. **Mouse Support**: Click a tile to pathfind to it (we'll cover pathfinding properly later)
## ASCII Sprite Reference
Here are some useful ASCII character indices for the default tileset:
- @ (player): 64
- # (wall): 35
- . (floor): 46
- + (door): 43
- ~ (water): 126
- % (item): 37
- ! (potion): 33
## What's Next?
In Part 2, we'll expand our world with:
- A proper Entity system for managing multiple objects
- NPCs that can also move around
- A more interesting map layout
- The beginning of our game architecture
The foundation is set - you have a player character that can move around a world with collision detection. This is the core of any roguelike game!

View File

@ -0,0 +1,162 @@
import mcrfpy
# Window configuration
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 1"
# Get the UI container for our scene
ui = mcrfpy.sceneUI("game")
# Create a dark background
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
ui.append(background)
# Load the ASCII tileset
tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
# Create the game grid
GRID_WIDTH = 50
GRID_HEIGHT = 30
grid = mcrfpy.Grid(grid_x=GRID_WIDTH, grid_y=GRID_HEIGHT, texture=tileset)
grid.position = (100, 100)
grid.size = (800, 480)
ui.append(grid)
def create_room():
"""Create a room with walls around the edges"""
# Fill everything with floor tiles first
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
cell.sprite_index = 46 # '.' character
cell.color = mcrfpy.Color(50, 50, 50) # Dark gray floor
# Create walls around the edges
for x in range(GRID_WIDTH):
# Top wall
cell = grid.at(x, 0)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100) # Gray walls
# Bottom wall
cell = grid.at(x, GRID_HEIGHT - 1)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
for y in range(GRID_HEIGHT):
# Left wall
cell = grid.at(0, y)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
# Right wall
cell = grid.at(GRID_WIDTH - 1, y)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
# Create the room
create_room()
# Create the player entity
player = mcrfpy.Entity(x=GRID_WIDTH // 2, y=GRID_HEIGHT // 2, grid=grid)
player.sprite_index = 64 # '@' character
player.color = mcrfpy.Color(255, 255, 255) # White
def move_player(dx, dy):
"""Move the player if the destination is walkable"""
# Calculate new position
new_x = player.x + dx
new_y = player.y + dy
# Check bounds
if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT:
return
# Check if the destination is walkable
destination = grid.at(new_x, new_y)
if destination.walkable:
# Move the player
player.x = new_x
player.y = new_y
def handle_input(key, state):
"""Handle keyboard input for player movement"""
# Only process key presses, not releases
if state != "start":
return
# Movement deltas
dx, dy = 0, 0
# Arrow keys
if key == "Up":
dy = -1
elif key == "Down":
dy = 1
elif key == "Left":
dx = -1
elif key == "Right":
dx = 1
# Numpad movement (for true roguelike feel!)
elif key == "Num7": # Northwest
dx, dy = -1, -1
elif key == "Num8": # North
dy = -1
elif key == "Num9": # Northeast
dx, dy = 1, -1
elif key == "Num4": # West
dx = -1
elif key == "Num6": # East
dx = 1
elif key == "Num1": # Southwest
dx, dy = -1, 1
elif key == "Num2": # South
dy = 1
elif key == "Num3": # Southeast
dx, dy = 1, 1
# Escape to quit
elif key == "Escape":
mcrfpy.setScene(None)
return
# If there's movement, try to move the player
if dx != 0 or dy != 0:
move_player(dx, dy)
# Register the input handler
mcrfpy.keypressScene(handle_input)
# Add UI elements
title = mcrfpy.Caption("McRogueFace Roguelike", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
ui.append(title)
instructions = mcrfpy.Caption("Arrow Keys or Numpad to move, ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(instructions)
status = mcrfpy.Caption("@ You", 100, 600)
status.font_size = 18
status.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(status)
print("Part 1: The @ symbol moves!")

View File

@ -0,0 +1,562 @@
# Part 2 - The Generic Entity, the Render Functions, and the Map
In Part 1, we created a player character that could move around a simple room. Now it's time to build a proper architecture for our roguelike. We'll create a flexible entity system, a proper map structure, and organize our code for future expansion.
## Understanding Game Architecture
Before diving into code, let's understand the architecture we're building:
1. **Entities**: Anything that can exist in the game world (player, monsters, items)
2. **Game Map**: The dungeon structure with tiles that can be walls or floors
3. **Game Engine**: Coordinates everything - entities, map, input, and rendering
In McRogueFace, we'll adapt these concepts to work with the engine's scene-based architecture.
## Creating a Flexible Entity System
While McRogueFace provides a built-in `Entity` class, we'll create a wrapper to add game-specific functionality:
```python
class GameObject:
"""Base class for all game objects (player, monsters, items)"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks # Does this entity block movement?
self._entity = None # The McRogueFace entity
self.grid = None # Reference to the grid
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = self.color
def move(self, dx, dy):
"""Move by the given amount if possible"""
if not self.grid:
return
new_x = self.x + dx
new_y = self.y + dy
# Update our position
self.x = new_x
self.y = new_y
# Update the visual entity
if self._entity:
self._entity.x = new_x
self._entity.y = new_y
def destroy(self):
"""Remove this entity from the game"""
if self._entity and self.grid:
# Find and remove from grid's entity list
for i, entity in enumerate(self.grid.entities):
if entity == self._entity:
del self.grid.entities[i]
break
self._entity = None
```
## Building the Game Map
Let's create a proper map class that manages our dungeon:
```python
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = [] # List of GameObjects
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Initialize all tiles as walls
self.fill_with_walls()
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, color=(100, 100, 100))
def set_tile(self, x, y, walkable, transparent, sprite_index, color):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*color)
def create_room(self, x1, y1, x2, y2):
"""Carve out a room in the map"""
# Make sure coordinates are in the right order
x1, x2 = min(x1, x2), max(x1, x2)
y1, y2 = min(y1, y2), max(y1, y2)
# Carve out floor tiles
for y in range(y1, y2 + 1):
for x in range(x1, x2 + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def create_tunnel_h(self, x1, x2, y):
"""Create a horizontal tunnel"""
for x in range(min(x1, x2), max(x1, x2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def create_tunnel_v(self, y1, y2, x):
"""Create a vertical tunnel"""
for y in range(min(y1, y2), max(y1, y2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
# Check map boundaries
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
# Check if tile is walkable
if not self.grid.at(x, y).walkable:
return True
# Check if any blocking entity is at this position
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
```
## Creating the Game Engine
Now let's build our game engine to tie everything together:
```python
class Engine:
"""Main game engine that manages game state"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
# Create the game scene
mcrfpy.createScene("game")
mcrfpy.setScene("game")
# Configure window
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 2"
# Get UI container
self.ui = mcrfpy.sceneUI("game")
# Add background
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
# Load tileset
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
# Create the game world
self.setup_game()
# Setup input handling
self.setup_input()
# Add UI elements
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
# Create the map
self.game_map = GameMap(50, 30)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create some rooms
self.game_map.create_room(10, 10, 20, 20)
self.game_map.create_room(30, 15, 40, 25)
self.game_map.create_room(15, 22, 25, 28)
# Connect rooms with tunnels
self.game_map.create_tunnel_h(20, 30, 15)
self.game_map.create_tunnel_v(20, 22, 20)
# Create player
self.player = GameObject(15, 15, 64, (255, 255, 255), "Player", blocks=True)
self.game_map.add_entity(self.player)
# Create an NPC
npc = GameObject(35, 20, 64, (255, 255, 0), "NPC", blocks=True)
self.game_map.add_entity(npc)
self.entities.append(npc)
# Create some items (non-blocking)
potion = GameObject(12, 12, 33, (255, 0, 255), "Potion", blocks=False)
self.game_map.add_entity(potion)
self.entities.append(potion)
def handle_movement(self, dx, dy):
"""Handle player movement"""
new_x = self.player.x + dx
new_y = self.player.y + dy
# Check if movement is blocked
if not self.game_map.is_blocked(new_x, new_y):
self.player.move(dx, dy)
else:
# Check if we bumped into an entity
target = self.game_map.get_blocking_entity_at(new_x, new_y)
if target:
print(f"You bump into the {target.name}!")
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
# Movement keys
movement = {
"Up": (0, -1),
"Down": (0, 1),
"Left": (-1, 0),
"Right": (1, 0),
"Num7": (-1, -1),
"Num8": (0, -1),
"Num9": (1, -1),
"Num4": (-1, 0),
"Num6": (1, 0),
"Num1": (-1, 1),
"Num2": (0, 1),
"Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
self.handle_movement(dx, dy)
elif key == "Escape":
mcrfpy.setScene(None)
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
# Title
title = mcrfpy.Caption("McRogueFace Roguelike - Part 2", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
# Instructions
instructions = mcrfpy.Caption("Explore the dungeon! ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
```
## Putting It All Together
Here's the complete `game.py` file:
```python
import mcrfpy
class GameObject:
"""Base class for all game objects (player, monsters, items)"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount if possible"""
if not self.grid:
return
new_x = self.x + dx
new_y = self.y + dy
self.x = new_x
self.y = new_y
if self._entity:
self._entity.x = new_x
self._entity.y = new_y
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
self.fill_with_walls()
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, color=(100, 100, 100))
def set_tile(self, x, y, walkable, transparent, sprite_index, color):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*color)
def create_room(self, x1, y1, x2, y2):
"""Carve out a room in the map"""
x1, x2 = min(x1, x2), max(x1, x2)
y1, y2 = min(y1, y2), max(y1, y2)
for y in range(y1, y2 + 1):
for x in range(x1, x2 + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def create_tunnel_h(self, x1, x2, y):
"""Create a horizontal tunnel"""
for x in range(min(x1, x2), max(x1, x2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def create_tunnel_v(self, y1, y2, x):
"""Create a vertical tunnel"""
for y in range(min(y1, y2), max(y1, y2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
class Engine:
"""Main game engine that manages game state"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 2"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(50, 30)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
self.game_map.create_room(10, 10, 20, 20)
self.game_map.create_room(30, 15, 40, 25)
self.game_map.create_room(15, 22, 25, 28)
self.game_map.create_tunnel_h(20, 30, 15)
self.game_map.create_tunnel_v(20, 22, 20)
self.player = GameObject(15, 15, 64, (255, 255, 255), "Player", blocks=True)
self.game_map.add_entity(self.player)
npc = GameObject(35, 20, 64, (255, 255, 0), "NPC", blocks=True)
self.game_map.add_entity(npc)
self.entities.append(npc)
potion = GameObject(12, 12, 33, (255, 0, 255), "Potion", blocks=False)
self.game_map.add_entity(potion)
self.entities.append(potion)
def handle_movement(self, dx, dy):
"""Handle player movement"""
new_x = self.player.x + dx
new_y = self.player.y + dy
if not self.game_map.is_blocked(new_x, new_y):
self.player.move(dx, dy)
else:
target = self.game_map.get_blocking_entity_at(new_x, new_y)
if target:
print(f"You bump into the {target.name}!")
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
self.handle_movement(dx, dy)
elif key == "Escape":
mcrfpy.setScene(None)
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("McRogueFace Roguelike - Part 2", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Explore the dungeon! ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Create and run the game
engine = Engine()
print("Part 2: Entities and Maps!")
```
## Understanding the Architecture
### GameObject Class
Our `GameObject` class wraps McRogueFace's `Entity` and adds:
- Game logic properties (name, blocking)
- Position tracking independent of the visual entity
- Easy attachment/detachment from grids
### GameMap Class
The `GameMap` manages:
- The McRogueFace `Grid` for visual representation
- A list of all entities in the map
- Collision detection including entity blocking
- Map generation utilities (rooms, tunnels)
### Engine Class
The `Engine` coordinates everything:
- Scene and UI setup
- Game state management
- Input handling
- Entity-map interactions
## Key Improvements from Part 1
1. **Proper Entity Management**: Multiple entities can exist and interact
2. **Blocking Entities**: Some entities block movement, others don't
3. **Map Generation**: Tools for creating rooms and tunnels
4. **Collision System**: Checks both tiles and entities
5. **Organized Code**: Clear separation of concerns
## Exercises
1. **Add More Entity Types**: Create different sprites for monsters, items, and NPCs
2. **Entity Interactions**: Make items disappear when walked over
3. **Random Map Generation**: Place rooms and tunnels randomly
4. **Entity Properties**: Add health, damage, or other attributes to GameObjects
## What's Next?
In Part 3, we'll implement proper dungeon generation with:
- Procedurally generated rooms
- Smart tunnel routing
- Entity spawning
- The beginning of a real roguelike dungeon!
We now have a solid foundation with proper entity management and map structure. This architecture will serve us well as we add more complex features to our roguelike!

View File

@ -0,0 +1,217 @@
import mcrfpy
class GameObject:
"""Base class for all game objects (player, monsters, items)"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount if possible"""
if not self.grid:
return
new_x = self.x + dx
new_y = self.y + dy
self.x = new_x
self.y = new_y
if self._entity:
self._entity.x = new_x
self._entity.y = new_y
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
self.fill_with_walls()
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, color=(100, 100, 100))
def set_tile(self, x, y, walkable, transparent, sprite_index, color):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*color)
def create_room(self, x1, y1, x2, y2):
"""Carve out a room in the map"""
x1, x2 = min(x1, x2), max(x1, x2)
y1, y2 = min(y1, y2), max(y1, y2)
for y in range(y1, y2 + 1):
for x in range(x1, x2 + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def create_tunnel_h(self, x1, x2, y):
"""Create a horizontal tunnel"""
for x in range(min(x1, x2), max(x1, x2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def create_tunnel_v(self, y1, y2, x):
"""Create a vertical tunnel"""
for y in range(min(y1, y2), max(y1, y2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
class Engine:
"""Main game engine that manages game state"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 2"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(50, 30)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
self.game_map.create_room(10, 10, 20, 20)
self.game_map.create_room(30, 15, 40, 25)
self.game_map.create_room(15, 22, 25, 28)
self.game_map.create_tunnel_h(20, 30, 15)
self.game_map.create_tunnel_v(20, 22, 20)
self.player = GameObject(15, 15, 64, (255, 255, 255), "Player", blocks=True)
self.game_map.add_entity(self.player)
npc = GameObject(35, 20, 64, (255, 255, 0), "NPC", blocks=True)
self.game_map.add_entity(npc)
self.entities.append(npc)
potion = GameObject(12, 12, 33, (255, 0, 255), "Potion", blocks=False)
self.game_map.add_entity(potion)
self.entities.append(potion)
def handle_movement(self, dx, dy):
"""Handle player movement"""
new_x = self.player.x + dx
new_y = self.player.y + dy
if not self.game_map.is_blocked(new_x, new_y):
self.player.move(dx, dy)
else:
target = self.game_map.get_blocking_entity_at(new_x, new_y)
if target:
print(f"You bump into the {target.name}!")
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
self.handle_movement(dx, dy)
elif key == "Escape":
mcrfpy.setScene(None)
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("McRogueFace Roguelike - Part 2", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Explore the dungeon! ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Create and run the game
engine = Engine()
print("Part 2: Entities and Maps!")

View File

@ -0,0 +1,548 @@
# Part 3 - Generating a Dungeon
In Parts 1 and 2, we created a player that could move around and interact with a hand-crafted dungeon. Now it's time to generate dungeons procedurally - a core feature of any roguelike game!
## The Plan
We'll create a dungeon generator that:
1. Places rectangular rooms randomly
2. Ensures rooms don't overlap
3. Connects rooms with tunnels
4. Places the player in the first room
This is a classic approach used by many roguelikes, and it creates interesting, playable dungeons.
## Creating a Room Class
First, let's create a class to represent rectangular rooms:
```python
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self):
"""Return the center coordinates of the room"""
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self):
"""Return the inner area of the room as a tuple of slices
This property returns the area inside the walls.
We'll add 1 to min coordinates and subtract 1 from max coordinates.
"""
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
def intersects(self, other):
"""Return True if this room overlaps with another RectangularRoom"""
return (
self.x1 <= other.x2
and self.x2 >= other.x1
and self.y1 <= other.y2
and self.y2 >= other.y1
)
```
## Implementing Tunnel Generation
Since McRogueFace doesn't include line-drawing algorithms, let's implement simple L-shaped tunnels:
```python
def tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
# Randomly decide whether to go horizontal first or vertical first
if random.random() < 0.5:
# Horizontal, then vertical
corner_x = x2
corner_y = y1
else:
# Vertical, then horizontal
corner_x = x1
corner_y = y2
# Generate the coordinates
# First line: from start to corner
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
# Second line: from corner to end
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
```
## The Dungeon Generator
Now let's update our GameMap class to generate dungeons:
```python
import random
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = [] # Keep track of rooms for game logic
def generate_dungeon(
self,
max_rooms,
room_min_size,
room_max_size,
player
):
"""Generate a new dungeon map"""
# Start with everything as walls
self.fill_with_walls()
for r in range(max_rooms):
# Random width and height
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
# Random position without going out of bounds
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
# Create the room
new_room = RectangularRoom(x, y, room_width, room_height)
# Check if it intersects with any existing room
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue # This room intersects, so go to the next attempt
# If we get here, it's a valid room
# Carve out this room
self.carve_room(new_room)
# Place the player in the center of the first room
if len(self.rooms) == 0:
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
# All rooms after the first:
# Tunnel between this room and the previous one
self.carve_tunnel(self.rooms[-1].center, new_room.center)
# Finally, append the new room to the list
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(30, 30, 40)) # Slightly different color for tunnels
```
## Complete Code
Here's the complete `game.py` with procedural dungeon generation:
```python
import mcrfpy
import random
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self):
"""Return the center coordinates of the room"""
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self):
"""Return the inner area of the room"""
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
def intersects(self, other):
"""Return True if this room overlaps with another"""
return (
self.x1 <= other.x2
and self.x2 >= other.x1
and self.y1 <= other.y2
and self.y2 >= other.y1
)
def tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
# Generate the coordinates
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, color=(100, 100, 100))
def set_tile(self, x, y, walkable, transparent, sprite_index, color):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*color)
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
self.carve_tunnel(self.rooms[-1].center, new_room.center)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(30, 30, 40))
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 3"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player (before dungeon generation)
self.player = GameObject(0, 0, 64, (255, 255, 255), "Player", blocks=True)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player
)
# Add player to map
self.game_map.add_entity(self.player)
# Add some monsters in random rooms
for i in range(5):
if i < len(self.game_map.rooms) - 1: # Don't spawn in first room
room = self.game_map.rooms[i + 1]
x, y = room.center
# Create an orc
orc = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
self.game_map.add_entity(orc)
self.entities.append(orc)
def handle_movement(self, dx, dy):
"""Handle player movement"""
new_x = self.player.x + dx
new_y = self.player.y + dy
if not self.game_map.is_blocked(new_x, new_y):
self.player.move(dx, dy)
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
self.handle_movement(dx, dy)
elif key == "Escape":
mcrfpy.setScene(None)
elif key == "Space":
# Regenerate the dungeon
self.regenerate_dungeon()
mcrfpy.keypressScene(handle_keys)
def regenerate_dungeon(self):
"""Generate a new dungeon"""
# Clear existing entities
self.game_map.entities.clear()
self.game_map.rooms.clear()
self.entities.clear()
# Clear the entity list in the grid
if self.game_map.grid:
self.game_map.grid.entities.clear()
# Regenerate
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player
)
# Re-add player
self.game_map.add_entity(self.player)
# Add new monsters
for i in range(5):
if i < len(self.game_map.rooms) - 1:
room = self.game_map.rooms[i + 1]
x, y = room.center
orc = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
self.game_map.add_entity(orc)
self.entities.append(orc)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Procedural Dungeon Generation", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Arrow keys to move, SPACE to regenerate, ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Create and run the game
engine = Engine()
print("Part 3: Procedural Dungeon Generation!")
print("Press SPACE to generate a new dungeon")
```
## Understanding the Algorithm
Our dungeon generation algorithm is simple but effective:
1. **Start with solid walls** - The entire map begins filled with wall tiles
2. **Try to place rooms** - Generate random rooms and check for overlaps
3. **Connect with tunnels** - Each new room connects to the previous one
4. **Place entities** - The player starts in the first room, monsters in others
### Room Placement
The algorithm attempts to place `max_rooms` rooms, but may place fewer if many attempts result in overlapping rooms. This is called "rejection sampling" - we generate random rooms and reject ones that don't fit.
### Tunnel Design
Our L-shaped tunnels are simple but effective. They either go:
- Horizontal first, then vertical
- Vertical first, then horizontal
This creates variety while ensuring all rooms are connected.
## Experimenting with Parameters
Try adjusting these parameters to create different dungeon styles:
```python
# Sparse dungeon with large rooms
self.game_map.generate_dungeon(
max_rooms=10,
room_min_size=10,
room_max_size=15,
player=self.player
)
# Dense dungeon with small rooms
self.game_map.generate_dungeon(
max_rooms=50,
room_min_size=4,
room_max_size=6,
player=self.player
)
```
## Visual Enhancements
Notice how we gave tunnels a slightly different color:
- Rooms: `color=(50, 50, 50)` - Medium gray
- Tunnels: `color=(30, 30, 40)` - Darker with blue tint
This subtle difference helps players understand the dungeon layout.
## Exercises
1. **Different Room Shapes**: Create circular or cross-shaped rooms
2. **Better Tunnel Routing**: Implement A* pathfinding for more natural tunnels
3. **Room Types**: Create special rooms (treasure rooms, trap rooms)
4. **Dungeon Themes**: Use different tile sets and colors for different dungeon levels
## What's Next?
In Part 4, we'll implement Field of View (FOV) so the player can only see parts of the dungeon they've explored. This will add mystery and atmosphere to our procedurally generated dungeons!
Our dungeon generator is now creating unique, playable levels every time. The foundation of a true roguelike is taking shape!

View File

@ -0,0 +1,312 @@
import mcrfpy
import random
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self):
"""Return the center coordinates of the room"""
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self):
"""Return the inner area of the room"""
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
def intersects(self, other):
"""Return True if this room overlaps with another"""
return (
self.x1 <= other.x2
and self.x2 >= other.x1
and self.y1 <= other.y2
and self.y2 >= other.y1
)
def tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
# Generate the coordinates
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, color=(100, 100, 100))
def set_tile(self, x, y, walkable, transparent, sprite_index, color):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*color)
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
self.carve_tunnel(self.rooms[-1].center, new_room.center)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(30, 30, 40))
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 3"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player (before dungeon generation)
self.player = GameObject(0, 0, 64, (255, 255, 255), "Player", blocks=True)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player
)
# Add player to map
self.game_map.add_entity(self.player)
# Add some monsters in random rooms
for i in range(5):
if i < len(self.game_map.rooms) - 1: # Don't spawn in first room
room = self.game_map.rooms[i + 1]
x, y = room.center
# Create an orc
orc = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
self.game_map.add_entity(orc)
self.entities.append(orc)
def handle_movement(self, dx, dy):
"""Handle player movement"""
new_x = self.player.x + dx
new_y = self.player.y + dy
if not self.game_map.is_blocked(new_x, new_y):
self.player.move(dx, dy)
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
self.handle_movement(dx, dy)
elif key == "Escape":
mcrfpy.setScene(None)
elif key == "Space":
# Regenerate the dungeon
self.regenerate_dungeon()
mcrfpy.keypressScene(handle_keys)
def regenerate_dungeon(self):
"""Generate a new dungeon"""
# Clear existing entities
self.game_map.entities.clear()
self.game_map.rooms.clear()
self.entities.clear()
# Clear the entity list in the grid
if self.game_map.grid:
self.game_map.grid.entities.clear()
# Regenerate
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player
)
# Re-add player
self.game_map.add_entity(self.player)
# Add new monsters
for i in range(5):
if i < len(self.game_map.rooms) - 1:
room = self.game_map.rooms[i + 1]
x, y = room.center
orc = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
self.game_map.add_entity(orc)
self.entities.append(orc)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Procedural Dungeon Generation", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Arrow keys to move, SPACE to regenerate, ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Create and run the game
engine = Engine()
print("Part 3: Procedural Dungeon Generation!")
print("Press SPACE to generate a new dungeon")

View File

@ -0,0 +1,520 @@
# Part 4 - Field of View
One of the defining features of roguelikes is exploration and discovery. In Part 3, we could see the entire dungeon at once. Now we'll implement Field of View (FOV) so players can only see what their character can actually see, adding mystery and tactical depth to our game.
## Understanding Field of View
Field of View creates three distinct visibility states for each tile:
1. **Visible**: Currently in the player's line of sight
2. **Explored**: Previously seen but not currently visible
3. **Unexplored**: Never seen (completely hidden)
This creates the classic "fog of war" effect where you remember the layout of areas you've explored, but can't see current enemy positions unless they're in your view.
## McRogueFace's FOV System
Good news! McRogueFace includes built-in FOV support through its C++ engine. We just need to enable and configure it. The engine uses an efficient shadowcasting algorithm that provides smooth, realistic line-of-sight calculations.
Let's update our code to use FOV:
```python
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
# Update FOV when player moves
if self.name == "Player":
self.update_fov()
def update_fov(self):
"""Update field of view from this entity's position"""
if self._entity and self.grid:
self._entity.update_fov(radius=8)
```
## Configuring Visibility Rendering
McRogueFace automatically handles the rendering of visible/explored/unexplored tiles. We need to set up our grid to use perspective-based rendering:
```python
class GameMap:
"""Manages the game world"""
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Enable perspective rendering (0 = first entity = player)
self.grid.perspective = 0
return self.grid
```
## Visual Appearance Configuration
Let's define how our tiles look in different visibility states:
```python
# Color configurations for visibility states
COLORS_VISIBLE = {
'wall': (100, 100, 100), # Light gray
'floor': (50, 50, 50), # Dark gray
'tunnel': (30, 30, 40), # Dark blue-gray
}
COLORS_EXPLORED = {
'wall': (50, 50, 70), # Darker, bluish
'floor': (20, 20, 30), # Very dark
'tunnel': (15, 15, 25), # Almost black
}
# Update the tile-setting methods to store the tile type
def set_tile(self, x, y, walkable, transparent, sprite_index, tile_type):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
# Store both visible and explored colors
cell.color = mcrfpy.Color(*COLORS_VISIBLE[tile_type])
# The engine will automatically darken explored tiles
```
## Complete Implementation
Here's the complete updated `game.py` with FOV:
```python
import mcrfpy
import random
# Color configurations for visibility
COLORS_VISIBLE = {
'wall': (100, 100, 100),
'floor': (50, 50, 50),
'tunnel': (30, 30, 40),
}
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
# Update FOV when player moves
if self.name == "Player":
self.update_fov()
def update_fov(self):
"""Update field of view from this entity's position"""
if self._entity and self.grid:
self._entity.update_fov(radius=8)
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self):
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
def intersects(self, other):
return (
self.x1 <= other.x2
and self.x2 >= other.x1
and self.y1 <= other.y2
and self.y2 >= other.y1
)
def tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Enable perspective rendering (0 = first entity = player)
self.grid.perspective = 0
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, tile_type='wall')
def set_tile(self, x, y, walkable, transparent, sprite_index, tile_type):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*COLORS_VISIBLE[tile_type])
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
self.carve_tunnel(self.rooms[-1].center, new_room.center)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='floor')
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='tunnel')
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
self.fov_radius = 8
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 4"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player
self.player = GameObject(0, 0, 64, (255, 255, 255), "Player", blocks=True)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player
)
# Add player to map
self.game_map.add_entity(self.player)
# Add monsters in random rooms
for i in range(10):
if i < len(self.game_map.rooms) - 1:
room = self.game_map.rooms[i + 1]
x, y = room.center
# Randomly offset from center
x += random.randint(-2, 2)
y += random.randint(-2, 2)
# Make sure position is walkable
if self.game_map.grid.at(x, y).walkable:
if i % 2 == 0:
# Create an orc
orc = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
self.game_map.add_entity(orc)
self.entities.append(orc)
else:
# Create a troll
troll = GameObject(x, y, 84, (0, 127, 0), "Troll", blocks=True)
self.game_map.add_entity(troll)
self.entities.append(troll)
# Initial FOV calculation
self.player.update_fov()
def handle_movement(self, dx, dy):
"""Handle player movement"""
new_x = self.player.x + dx
new_y = self.player.y + dy
if not self.game_map.is_blocked(new_x, new_y):
self.player.move(dx, dy)
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
self.handle_movement(dx, dy)
elif key == "Escape":
mcrfpy.setScene(None)
elif key == "v":
# Toggle FOV on/off
if self.game_map.grid.perspective == 0:
self.game_map.grid.perspective = -1 # Omniscient
print("FOV disabled - omniscient view")
else:
self.game_map.grid.perspective = 0 # Player perspective
print("FOV enabled - player perspective")
elif key == "Plus" or key == "Equals":
# Increase FOV radius
self.fov_radius = min(self.fov_radius + 1, 20)
self.player._entity.update_fov(radius=self.fov_radius)
print(f"FOV radius: {self.fov_radius}")
elif key == "Minus":
# Decrease FOV radius
self.fov_radius = max(self.fov_radius - 1, 3)
self.player._entity.update_fov(radius=self.fov_radius)
print(f"FOV radius: {self.fov_radius}")
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Field of View", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Arrow keys to move | V to toggle FOV | +/- to adjust radius | ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# FOV indicator
self.fov_text = mcrfpy.Caption(f"FOV Radius: {self.fov_radius}", 900, 100)
self.fov_text.font_size = 14
self.fov_text.fill_color = mcrfpy.Color(150, 200, 255)
self.ui.append(self.fov_text)
# Create and run the game
engine = Engine()
print("Part 4: Field of View!")
print("Press V to toggle FOV on/off")
print("Press +/- to adjust FOV radius")
```
## How FOV Works
McRogueFace's built-in FOV system uses a shadowcasting algorithm that:
1. **Casts rays** from the player's position to tiles within the radius
2. **Checks transparency** along each ray path
3. **Marks tiles as visible** if the ray reaches them unobstructed
4. **Remembers explored tiles** automatically
The engine handles all the complex calculations in C++ for optimal performance.
## Visibility States in Detail
### Visible Tiles
- Currently in the player's line of sight
- Rendered at full brightness
- Show current entity positions
### Explored Tiles
- Previously seen but not currently visible
- Rendered darker/muted
- Show remembered terrain but not entities
### Unexplored Tiles
- Never been in the player's FOV
- Rendered as black/invisible
- Complete mystery to the player
## FOV Parameters
You can customize FOV behavior:
```python
# Basic FOV update
entity.update_fov(radius=8)
# The grid's perspective property controls rendering:
grid.perspective = 0 # Use first entity's FOV (player)
grid.perspective = 1 # Use second entity's FOV
grid.perspective = -1 # Omniscient (no FOV, see everything)
```
## Performance Considerations
McRogueFace's C++ FOV implementation is highly optimized:
- Uses efficient shadowcasting algorithm
- Only recalculates when needed
- Handles large maps smoothly
- Automatically culls entities outside FOV
## Visual Polish
The engine automatically handles visual transitions:
- Smooth color changes between visibility states
- Entities fade in/out of view
- Explored areas remain visible but dimmed
## Exercises
1. **Variable Vision**: Give different entities different FOV radii
2. **Light Sources**: Create torches that expand local FOV
3. **Blind Spots**: Add pillars that create interesting shadows
4. **X-Ray Vision**: Temporary power-up to see through walls
## What's Next?
In Part 5, we'll place enemies throughout the dungeon and implement basic interactions. With FOV in place, enemies will appear and disappear as you explore, creating tension and surprise!
Field of View transforms our dungeon from a tactical puzzle into a mysterious world to explore. The fog of war adds atmosphere and gameplay depth that's essential to the roguelike experience.

View File

@ -0,0 +1,334 @@
import mcrfpy
import random
# Color configurations for visibility
COLORS_VISIBLE = {
'wall': (100, 100, 100),
'floor': (50, 50, 50),
'tunnel': (30, 30, 40),
}
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
# Update FOV when player moves
if self.name == "Player":
self.update_fov()
def update_fov(self):
"""Update field of view from this entity's position"""
if self._entity and self.grid:
self._entity.update_fov(radius=8)
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self):
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
def intersects(self, other):
return (
self.x1 <= other.x2
and self.x2 >= other.x1
and self.y1 <= other.y2
and self.y2 >= other.y1
)
def tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Enable perspective rendering (0 = first entity = player)
self.grid.perspective = 0
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, tile_type='wall')
def set_tile(self, x, y, walkable, transparent, sprite_index, tile_type):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*COLORS_VISIBLE[tile_type])
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
self.carve_tunnel(self.rooms[-1].center, new_room.center)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='floor')
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='tunnel')
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
self.fov_radius = 8
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 4"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player
self.player = GameObject(0, 0, 64, (255, 255, 255), "Player", blocks=True)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player
)
# Add player to map
self.game_map.add_entity(self.player)
# Add monsters in random rooms
for i in range(10):
if i < len(self.game_map.rooms) - 1:
room = self.game_map.rooms[i + 1]
x, y = room.center
# Randomly offset from center
x += random.randint(-2, 2)
y += random.randint(-2, 2)
# Make sure position is walkable
if self.game_map.grid.at(x, y).walkable:
if i % 2 == 0:
# Create an orc
orc = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
self.game_map.add_entity(orc)
self.entities.append(orc)
else:
# Create a troll
troll = GameObject(x, y, 84, (0, 127, 0), "Troll", blocks=True)
self.game_map.add_entity(troll)
self.entities.append(troll)
# Initial FOV calculation
self.player.update_fov()
def handle_movement(self, dx, dy):
"""Handle player movement"""
new_x = self.player.x + dx
new_y = self.player.y + dy
if not self.game_map.is_blocked(new_x, new_y):
self.player.move(dx, dy)
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
self.handle_movement(dx, dy)
elif key == "Escape":
mcrfpy.setScene(None)
elif key == "v":
# Toggle FOV on/off
if self.game_map.grid.perspective == 0:
self.game_map.grid.perspective = -1 # Omniscient
print("FOV disabled - omniscient view")
else:
self.game_map.grid.perspective = 0 # Player perspective
print("FOV enabled - player perspective")
elif key == "Plus" or key == "Equals":
# Increase FOV radius
self.fov_radius = min(self.fov_radius + 1, 20)
self.player._entity.update_fov(radius=self.fov_radius)
print(f"FOV radius: {self.fov_radius}")
elif key == "Minus":
# Decrease FOV radius
self.fov_radius = max(self.fov_radius - 1, 3)
self.player._entity.update_fov(radius=self.fov_radius)
print(f"FOV radius: {self.fov_radius}")
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Field of View", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Arrow keys to move | V to toggle FOV | +/- to adjust radius | ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# FOV indicator
self.fov_text = mcrfpy.Caption(f"FOV Radius: {self.fov_radius}", 900, 100)
self.fov_text.font_size = 14
self.fov_text.fill_color = mcrfpy.Color(150, 200, 255)
self.ui.append(self.fov_text)
# Create and run the game
engine = Engine()
print("Part 4: Field of View!")
print("Press V to toggle FOV on/off")
print("Press +/- to adjust FOV radius")

View File

@ -0,0 +1,570 @@
# Part 5 - Placing Enemies and Kicking Them (Harmlessly)
Now that we have Field of View working, it's time to populate our dungeon with enemies! In this part, we'll:
- Place enemies randomly in rooms
- Implement entity-to-entity collision detection
- Create basic interactions (bumping into enemies)
- Set the stage for combat in Part 6
## Enemy Spawning System
First, let's create a system to spawn enemies in our dungeon rooms. We'll avoid placing them in the first room (where the player starts) to give players a safe starting area.
```python
def spawn_enemies_in_room(room, game_map, max_enemies=2):
"""Spawn between 0 and max_enemies in a room"""
import random
number_of_enemies = random.randint(0, max_enemies)
for i in range(number_of_enemies):
# Try to find a valid position
attempts = 10
while attempts > 0:
# Random position within room bounds
x = random.randint(room.x1 + 1, room.x2 - 1)
y = random.randint(room.y1 + 1, room.y2 - 1)
# Check if position is valid
if not game_map.is_blocked(x, y):
# 80% chance for orc, 20% for troll
if random.random() < 0.8:
enemy = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
else:
enemy = GameObject(x, y, 84, (0, 127, 0), "Troll", blocks=True)
game_map.add_entity(enemy)
break
attempts -= 1
```
## Enhanced Collision Detection
We need to improve our collision detection to check for entities, not just walls:
```python
class GameMap:
"""Manages the game world"""
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
# Check boundaries
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
# Check walls
if not self.grid.at(x, y).walkable:
return True
# Check entities
if self.get_blocking_entity_at(x, y):
return True
return False
```
## Action System Introduction
Let's create a simple action system to handle different types of interactions:
```python
class Action:
"""Base class for all actions"""
pass
class MovementAction(Action):
"""Action for moving an entity"""
def __init__(self, dx, dy):
self.dx = dx
self.dy = dy
class BumpAction(Action):
"""Action for bumping into something"""
def __init__(self, dx, dy, target=None):
self.dx = dx
self.dy = dy
self.target = target
class WaitAction(Action):
"""Action for waiting/skipping turn"""
pass
```
## Handling Player Actions
Now let's update our movement handling to support bumping into enemies:
```python
def handle_player_turn(self, action):
"""Process the player's action"""
if isinstance(action, MovementAction):
dest_x = self.player.x + action.dx
dest_y = self.player.y + action.dy
# Check what's at the destination
target = self.game_map.get_blocking_entity_at(dest_x, dest_y)
if target:
# We bumped into something!
print(f"You kick the {target.name} in the shins, much to its annoyance!")
elif not self.game_map.is_blocked(dest_x, dest_y):
# Move the player
self.player.move(action.dx, action.dy)
# Update message
self.status_text.text = "Exploring the dungeon..."
else:
# Bumped into a wall
self.status_text.text = "Ouch! You bump into a wall."
elif isinstance(action, WaitAction):
self.status_text.text = "You wait..."
```
## Complete Updated Code
Here's the complete `game.py` with enemy placement and interactions:
```python
import mcrfpy
import random
# Color configurations
COLORS_VISIBLE = {
'wall': (100, 100, 100),
'floor': (50, 50, 50),
'tunnel': (30, 30, 40),
}
# Actions
class Action:
"""Base class for all actions"""
pass
class MovementAction(Action):
"""Action for moving an entity"""
def __init__(self, dx, dy):
self.dx = dx
self.dy = dy
class WaitAction(Action):
"""Action for waiting/skipping turn"""
pass
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
# Update FOV when player moves
if self.name == "Player":
self.update_fov()
def update_fov(self):
"""Update field of view from this entity's position"""
if self._entity and self.grid:
self._entity.update_fov(radius=8)
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self):
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
def intersects(self, other):
return (
self.x1 <= other.x2
and self.x2 >= other.x1
and self.y1 <= other.y2
and self.y2 >= other.y1
)
def tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
def spawn_enemies_in_room(room, game_map, max_enemies=2):
"""Spawn between 0 and max_enemies in a room"""
number_of_enemies = random.randint(0, max_enemies)
enemies_spawned = []
for i in range(number_of_enemies):
# Try to find a valid position
attempts = 10
while attempts > 0:
# Random position within room bounds
x = random.randint(room.x1 + 1, room.x2 - 1)
y = random.randint(room.y1 + 1, room.y2 - 1)
# Check if position is valid
if not game_map.is_blocked(x, y):
# 80% chance for orc, 20% for troll
if random.random() < 0.8:
enemy = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
else:
enemy = GameObject(x, y, 84, (0, 127, 0), "Troll", blocks=True)
game_map.add_entity(enemy)
enemies_spawned.append(enemy)
break
attempts -= 1
return enemies_spawned
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Enable perspective rendering
self.grid.perspective = 0
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, tile_type='wall')
def set_tile(self, x, y, walkable, transparent, sprite_index, tile_type):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*COLORS_VISIBLE[tile_type])
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player, max_enemies_per_room):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
# First room - place player
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
# All other rooms - add tunnel and enemies
self.carve_tunnel(self.rooms[-1].center, new_room.center)
spawn_enemies_in_room(new_room, self, max_enemies_per_room)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='floor')
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='tunnel')
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
if self.get_blocking_entity_at(x, y):
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 5"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player
self.player = GameObject(0, 0, 64, (255, 255, 255), "Player", blocks=True)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player,
max_enemies_per_room=2
)
# Add player to map
self.game_map.add_entity(self.player)
# Store reference to all entities
self.entities = [e for e in self.game_map.entities if e != self.player]
# Initial FOV calculation
self.player.update_fov()
def handle_player_turn(self, action):
"""Process the player's action"""
if isinstance(action, MovementAction):
dest_x = self.player.x + action.dx
dest_y = self.player.y + action.dy
# Check what's at the destination
target = self.game_map.get_blocking_entity_at(dest_x, dest_y)
if target:
# We bumped into something!
print(f"You kick the {target.name} in the shins, much to its annoyance!")
self.status_text.text = f"You kick the {target.name}!"
elif not self.game_map.is_blocked(dest_x, dest_y):
# Move the player
self.player.move(action.dx, action.dy)
self.status_text.text = ""
else:
# Bumped into a wall
self.status_text.text = "Blocked!"
elif isinstance(action, WaitAction):
self.status_text.text = "You wait..."
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
action = None
# Movement keys
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num5": (0, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
if dx == 0 and dy == 0:
action = WaitAction()
else:
action = MovementAction(dx, dy)
elif key == "Period":
action = WaitAction()
elif key == "Escape":
mcrfpy.setScene(None)
return
# Process the action
if action:
self.handle_player_turn(action)
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Placing Enemies", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Arrow keys to move | . to wait | Bump into enemies! | ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Status text
self.status_text = mcrfpy.Caption("", 512, 600)
self.status_text.font_size = 18
self.status_text.fill_color = mcrfpy.Color(255, 200, 200)
self.ui.append(self.status_text)
# Entity count
entity_count = len(self.entities)
count_text = mcrfpy.Caption(f"Enemies: {entity_count}", 900, 100)
count_text.font_size = 14
count_text.fill_color = mcrfpy.Color(150, 150, 255)
self.ui.append(count_text)
# Create and run the game
engine = Engine()
print("Part 5: Placing Enemies!")
print("Try bumping into enemies - combat coming in Part 6!")
```
## Understanding Entity Interactions
### Collision Detection
Our system now checks three things when the player tries to move:
1. **Map boundaries** - Can't move outside the map
2. **Wall tiles** - Can't walk through walls
3. **Blocking entities** - Can't walk through enemies
### The Action System
We've introduced a simple action system that will grow in Part 6:
- `Action` - Base class for all actions
- `MovementAction` - Represents attempted movement
- `WaitAction` - Skip a turn (important for turn-based games)
### Entity Spawning
Enemies are placed randomly in rooms with these rules:
- Never in the first room (player's starting room)
- Random number between 0 and max per room
- 80% orcs, 20% trolls
- Must be placed on walkable, unoccupied tiles
## Visual Feedback
With FOV enabled, enemies will appear and disappear as you explore:
- Enemies in sight are fully visible
- Enemies in explored but dark areas are hidden
- Creates tension and surprise encounters
## Exercises
1. **More Enemy Types**: Add different sprites and names (goblins, skeletons)
2. **Enemy Density**: Adjust spawn rates based on dungeon depth
3. **Special Rooms**: Create rooms with guaranteed enemies or treasures
4. **Better Feedback**: Add sound effects or visual effects for bumping
## What's Next?
In Part 6, we'll transform those harmless kicks into a real combat system! We'll add:
- Health points for all entities
- Damage calculations
- Death and corpses
- Combat messages
- The beginning of a real roguelike!
Right now our enemies are just obstacles. Soon they'll fight back!

View File

@ -0,0 +1,388 @@
import mcrfpy
import random
# Color configurations
COLORS_VISIBLE = {
'wall': (100, 100, 100),
'floor': (50, 50, 50),
'tunnel': (30, 30, 40),
}
# Actions
class Action:
"""Base class for all actions"""
pass
class MovementAction(Action):
"""Action for moving an entity"""
def __init__(self, dx, dy):
self.dx = dx
self.dy = dy
class WaitAction(Action):
"""Action for waiting/skipping turn"""
pass
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
# Update FOV when player moves
if self.name == "Player":
self.update_fov()
def update_fov(self):
"""Update field of view from this entity's position"""
if self._entity and self.grid:
self._entity.update_fov(radius=8)
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self):
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
def intersects(self, other):
return (
self.x1 <= other.x2
and self.x2 >= other.x1
and self.y1 <= other.y2
and self.y2 >= other.y1
)
def tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
def spawn_enemies_in_room(room, game_map, max_enemies=2):
"""Spawn between 0 and max_enemies in a room"""
number_of_enemies = random.randint(0, max_enemies)
enemies_spawned = []
for i in range(number_of_enemies):
# Try to find a valid position
attempts = 10
while attempts > 0:
# Random position within room bounds
x = random.randint(room.x1 + 1, room.x2 - 1)
y = random.randint(room.y1 + 1, room.y2 - 1)
# Check if position is valid
if not game_map.is_blocked(x, y):
# 80% chance for orc, 20% for troll
if random.random() < 0.8:
enemy = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
else:
enemy = GameObject(x, y, 84, (0, 127, 0), "Troll", blocks=True)
game_map.add_entity(enemy)
enemies_spawned.append(enemy)
break
attempts -= 1
return enemies_spawned
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Enable perspective rendering
self.grid.perspective = 0
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, tile_type='wall')
def set_tile(self, x, y, walkable, transparent, sprite_index, tile_type):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*COLORS_VISIBLE[tile_type])
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player, max_enemies_per_room):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
# First room - place player
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
# All other rooms - add tunnel and enemies
self.carve_tunnel(self.rooms[-1].center, new_room.center)
spawn_enemies_in_room(new_room, self, max_enemies_per_room)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='floor')
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='tunnel')
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
if self.get_blocking_entity_at(x, y):
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 5"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player
self.player = GameObject(0, 0, 64, (255, 255, 255), "Player", blocks=True)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player,
max_enemies_per_room=2
)
# Add player to map
self.game_map.add_entity(self.player)
# Store reference to all entities
self.entities = [e for e in self.game_map.entities if e != self.player]
# Initial FOV calculation
self.player.update_fov()
def handle_player_turn(self, action):
"""Process the player's action"""
if isinstance(action, MovementAction):
dest_x = self.player.x + action.dx
dest_y = self.player.y + action.dy
# Check what's at the destination
target = self.game_map.get_blocking_entity_at(dest_x, dest_y)
if target:
# We bumped into something!
print(f"You kick the {target.name} in the shins, much to its annoyance!")
self.status_text.text = f"You kick the {target.name}!"
elif not self.game_map.is_blocked(dest_x, dest_y):
# Move the player
self.player.move(action.dx, action.dy)
self.status_text.text = ""
else:
# Bumped into a wall
self.status_text.text = "Blocked!"
elif isinstance(action, WaitAction):
self.status_text.text = "You wait..."
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
action = None
# Movement keys
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num5": (0, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
if dx == 0 and dy == 0:
action = WaitAction()
else:
action = MovementAction(dx, dy)
elif key == "Period":
action = WaitAction()
elif key == "Escape":
mcrfpy.setScene(None)
return
# Process the action
if action:
self.handle_player_turn(action)
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Placing Enemies", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Arrow keys to move | . to wait | Bump into enemies! | ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Status text
self.status_text = mcrfpy.Caption("", 512, 600)
self.status_text.font_size = 18
self.status_text.fill_color = mcrfpy.Color(255, 200, 200)
self.ui.append(self.status_text)
# Entity count
entity_count = len(self.entities)
count_text = mcrfpy.Caption(f"Enemies: {entity_count}", 900, 100)
count_text.font_size = 14
count_text.fill_color = mcrfpy.Color(150, 150, 255)
self.ui.append(count_text)
# Create and run the game
engine = Engine()
print("Part 5: Placing Enemies!")
print("Try bumping into enemies - combat coming in Part 6!")

View File

@ -0,0 +1,743 @@
# Part 6 - Doing (and Taking) Some Damage
It's time to turn our harmless kicks into real combat! In this part, we'll implement:
- Health points for all entities
- A damage calculation system
- Death and corpse mechanics
- Combat feedback messages
- The foundation of tactical roguelike combat
## Adding Combat Stats
First, let's enhance our GameObject class with combat capabilities:
```python
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name,
blocks=False, hp=0, defense=0, power=0):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
# Combat stats
self.max_hp = hp
self.hp = hp
self.defense = defense
self.power = power
@property
def is_alive(self):
"""Returns True if this entity can act"""
return self.hp > 0
def take_damage(self, amount):
"""Apply damage to this entity"""
damage = amount - self.defense
if damage > 0:
self.hp -= damage
# Check for death
if self.hp <= 0 and self.hp + damage > 0:
self.die()
return damage
def die(self):
"""Handle entity death"""
if self.name == "Player":
# Player death is special - we'll handle it differently
self.sprite_index = 64 # Stay as @ but change color
self.color = (127, 0, 0) # Dark red
if self._entity:
self._entity.color = mcrfpy.Color(127, 0, 0)
print("You have died!")
else:
# Enemy death
self.sprite_index = 37 # % character for corpse
self.color = (127, 0, 0) # Dark red
self.blocks = False # Corpses don't block
self.name = f"remains of {self.name}"
if self._entity:
self._entity.sprite_index = 37
self._entity.color = mcrfpy.Color(127, 0, 0)
```
## The Combat System
Now let's implement actual combat when entities bump into each other:
```python
class MeleeAction(Action):
"""Action for melee attacks"""
def __init__(self, attacker, target):
self.attacker = attacker
self.target = target
def perform(self):
"""Execute the attack"""
if not self.target.is_alive:
return # Can't attack the dead
damage = self.attacker.power - self.target.defense
if damage > 0:
attack_desc = f"{self.attacker.name} attacks {self.target.name} for {damage} damage!"
self.target.take_damage(damage)
else:
attack_desc = f"{self.attacker.name} attacks {self.target.name} but does no damage."
return attack_desc
```
## Entity Factories
Let's create factory functions for consistent entity creation:
```python
def create_player(x, y):
"""Create the player entity"""
return GameObject(
x=x, y=y,
sprite_index=64, # @
color=(255, 255, 255),
name="Player",
blocks=True,
hp=30,
defense=2,
power=5
)
def create_orc(x, y):
"""Create an orc enemy"""
return GameObject(
x=x, y=y,
sprite_index=111, # o
color=(63, 127, 63),
name="Orc",
blocks=True,
hp=10,
defense=0,
power=3
)
def create_troll(x, y):
"""Create a troll enemy"""
return GameObject(
x=x, y=y,
sprite_index=84, # T
color=(0, 127, 0),
name="Troll",
blocks=True,
hp=16,
defense=1,
power=4
)
```
## The Message Log
Combat needs feedback! Let's create a simple message log:
```python
class MessageLog:
"""Manages game messages"""
def __init__(self, max_messages=5):
self.messages = []
self.max_messages = max_messages
def add_message(self, text, color=(255, 255, 255)):
"""Add a message to the log"""
self.messages.append((text, color))
# Keep only recent messages
if len(self.messages) > self.max_messages:
self.messages.pop(0)
def render(self, ui, x, y, line_height=20):
"""Render messages to the UI"""
for i, (text, color) in enumerate(self.messages):
caption = mcrfpy.Caption(text, x, y + i * line_height)
caption.font_size = 14
caption.fill_color = mcrfpy.Color(*color)
ui.append(caption)
```
## Complete Implementation
Here's the complete `game.py` with combat:
```python
import mcrfpy
import random
# Color configurations
COLORS_VISIBLE = {
'wall': (100, 100, 100),
'floor': (50, 50, 50),
'tunnel': (30, 30, 40),
}
# Message colors
COLOR_PLAYER_ATK = (230, 230, 230)
COLOR_ENEMY_ATK = (255, 200, 200)
COLOR_PLAYER_DIE = (255, 100, 100)
COLOR_ENEMY_DIE = (255, 165, 0)
# Actions
class Action:
"""Base class for all actions"""
pass
class MovementAction(Action):
"""Action for moving an entity"""
def __init__(self, dx, dy):
self.dx = dx
self.dy = dy
class MeleeAction(Action):
"""Action for melee attacks"""
def __init__(self, attacker, target):
self.attacker = attacker
self.target = target
def perform(self):
"""Execute the attack"""
if not self.target.is_alive:
return None
damage = self.attacker.power - self.target.defense
if damage > 0:
attack_desc = f"{self.attacker.name} attacks {self.target.name} for {damage} damage!"
self.target.take_damage(damage)
# Choose color based on attacker
if self.attacker.name == "Player":
color = COLOR_PLAYER_ATK
else:
color = COLOR_ENEMY_ATK
return attack_desc, color
else:
attack_desc = f"{self.attacker.name} attacks {self.target.name} but does no damage."
return attack_desc, (150, 150, 150)
class WaitAction(Action):
"""Action for waiting/skipping turn"""
pass
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name,
blocks=False, hp=0, defense=0, power=0):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
# Combat stats
self.max_hp = hp
self.hp = hp
self.defense = defense
self.power = power
@property
def is_alive(self):
"""Returns True if this entity can act"""
return self.hp > 0
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
# Update FOV when player moves
if self.name == "Player":
self.update_fov()
def update_fov(self):
"""Update field of view from this entity's position"""
if self._entity and self.grid:
self._entity.update_fov(radius=8)
def take_damage(self, amount):
"""Apply damage to this entity"""
self.hp -= amount
# Check for death
if self.hp <= 0:
self.die()
def die(self):
"""Handle entity death"""
if self.name == "Player":
# Player death
self.sprite_index = 64 # Stay as @
self.color = (127, 0, 0) # Dark red
if self._entity:
self._entity.color = mcrfpy.Color(127, 0, 0)
else:
# Enemy death
self.sprite_index = 37 # % character for corpse
self.color = (127, 0, 0) # Dark red
self.blocks = False # Corpses don't block
self.name = f"remains of {self.name}"
if self._entity:
self._entity.sprite_index = 37
self._entity.color = mcrfpy.Color(127, 0, 0)
# Entity factories
def create_player(x, y):
"""Create the player entity"""
return GameObject(
x=x, y=y,
sprite_index=64, # @
color=(255, 255, 255),
name="Player",
blocks=True,
hp=30,
defense=2,
power=5
)
def create_orc(x, y):
"""Create an orc enemy"""
return GameObject(
x=x, y=y,
sprite_index=111, # o
color=(63, 127, 63),
name="Orc",
blocks=True,
hp=10,
defense=0,
power=3
)
def create_troll(x, y):
"""Create a troll enemy"""
return GameObject(
x=x, y=y,
sprite_index=84, # T
color=(0, 127, 0),
name="Troll",
blocks=True,
hp=16,
defense=1,
power=4
)
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self):
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
def intersects(self, other):
return (
self.x1 <= other.x2
and self.x2 >= other.x1
and self.y1 <= other.y2
and self.y2 >= other.y1
)
def tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
def spawn_enemies_in_room(room, game_map, max_enemies=2):
"""Spawn between 0 and max_enemies in a room"""
number_of_enemies = random.randint(0, max_enemies)
enemies_spawned = []
for i in range(number_of_enemies):
attempts = 10
while attempts > 0:
x = random.randint(room.x1 + 1, room.x2 - 1)
y = random.randint(room.y1 + 1, room.y2 - 1)
if not game_map.is_blocked(x, y):
# 80% chance for orc, 20% for troll
if random.random() < 0.8:
enemy = create_orc(x, y)
else:
enemy = create_troll(x, y)
game_map.add_entity(enemy)
enemies_spawned.append(enemy)
break
attempts -= 1
return enemies_spawned
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Enable perspective rendering
self.grid.perspective = 0
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, tile_type='wall')
def set_tile(self, x, y, walkable, transparent, sprite_index, tile_type):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*COLORS_VISIBLE[tile_type])
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player, max_enemies_per_room):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
# First room - place player
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
# All other rooms - add tunnel and enemies
self.carve_tunnel(self.rooms[-1].center, new_room.center)
spawn_enemies_in_room(new_room, self, max_enemies_per_room)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='floor')
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='tunnel')
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
if self.get_blocking_entity_at(x, y):
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
self.messages = [] # Simple message log
self.max_messages = 5
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 6"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def add_message(self, text, color=(255, 255, 255)):
"""Add a message to the log"""
self.messages.append((text, color))
if len(self.messages) > self.max_messages:
self.messages.pop(0)
self.update_message_display()
def update_message_display(self):
"""Update the message display"""
# Clear old messages
for caption in self.message_captions:
# Remove from UI (McRogueFace doesn't have remove, so we hide it)
caption.text = ""
# Display current messages
for i, (text, color) in enumerate(self.messages):
if i < len(self.message_captions):
self.message_captions[i].text = text
self.message_captions[i].fill_color = mcrfpy.Color(*color)
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player
self.player = create_player(0, 0)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player,
max_enemies_per_room=2
)
# Add player to map
self.game_map.add_entity(self.player)
# Store reference to all entities
self.entities = [e for e in self.game_map.entities if e != self.player]
# Initial FOV calculation
self.player.update_fov()
# Welcome message
self.add_message("Welcome to the dungeon!", (100, 100, 255))
def handle_player_turn(self, action):
"""Process the player's action"""
if not self.player.is_alive:
return
if isinstance(action, MovementAction):
dest_x = self.player.x + action.dx
dest_y = self.player.y + action.dy
# Check what's at the destination
target = self.game_map.get_blocking_entity_at(dest_x, dest_y)
if target:
# Attack!
attack = MeleeAction(self.player, target)
result = attack.perform()
if result:
text, color = result
self.add_message(text, color)
# Check if target died
if not target.is_alive:
death_msg = f"The {target.name.replace('remains of ', '')} is dead!"
self.add_message(death_msg, COLOR_ENEMY_DIE)
elif not self.game_map.is_blocked(dest_x, dest_y):
# Move the player
self.player.move(action.dx, action.dy)
elif isinstance(action, WaitAction):
pass # Do nothing
# Enemy turns
self.handle_enemy_turns()
def handle_enemy_turns(self):
"""Let all enemies take their turn"""
for entity in self.entities:
if entity.is_alive:
# Simple AI: if player is adjacent, attack. Otherwise, do nothing.
dx = entity.x - self.player.x
dy = entity.y - self.player.y
distance = abs(dx) + abs(dy)
if distance == 1: # Adjacent to player
attack = MeleeAction(entity, self.player)
result = attack.perform()
if result:
text, color = result
self.add_message(text, color)
# Check if player died
if not self.player.is_alive:
self.add_message("You have died!", COLOR_PLAYER_DIE)
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
action = None
# Movement keys
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num5": (0, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
if dx == 0 and dy == 0:
action = WaitAction()
else:
action = MovementAction(dx, dy)
elif key == "Period":
action = WaitAction()
elif key == "Escape":
mcrfpy.setScene(None)
return
# Process the action
if action:
self.handle_player_turn(action)
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Combat System", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Attack enemies by bumping into them!", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Player stats
self.hp_text = mcrfpy.Caption(f"HP: {self.player.hp}/{self.player.max_hp}", 50, 100)
self.hp_text.font_size = 18
self.hp_text.fill_color = mcrfpy.Color(255, 100, 100)
self.ui.append(self.hp_text)
# Message log
self.message_captions = []
for i in range(self.max_messages):
caption = mcrfpy.Caption("", 50, 620 + i * 20)
caption.font_size = 14
caption.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(caption)
self.message_captions.append(caption)
# Timer to update HP display
def update_stats(dt):
self.hp_text.text = f"HP: {self.player.hp}/{self.player.max_hp}"
if self.player.hp <= 0:
self.hp_text.fill_color = mcrfpy.Color(127, 0, 0)
elif self.player.hp < self.player.max_hp // 3:
self.hp_text.fill_color = mcrfpy.Color(255, 100, 100)
else:
self.hp_text.fill_color = mcrfpy.Color(0, 255, 0)
mcrfpy.setTimer("update_stats", update_stats, 100)
# Create and run the game
engine = Engine()
print("Part 6: Combat System!")
print("Attack enemies to defeat them, but watch your HP!")

View File

@ -0,0 +1,568 @@
import mcrfpy
import random
# Color configurations
COLORS_VISIBLE = {
'wall': (100, 100, 100),
'floor': (50, 50, 50),
'tunnel': (30, 30, 40),
}
# Message colors
COLOR_PLAYER_ATK = (230, 230, 230)
COLOR_ENEMY_ATK = (255, 200, 200)
COLOR_PLAYER_DIE = (255, 100, 100)
COLOR_ENEMY_DIE = (255, 165, 0)
# Actions
class Action:
"""Base class for all actions"""
pass
class MovementAction(Action):
"""Action for moving an entity"""
def __init__(self, dx, dy):
self.dx = dx
self.dy = dy
class MeleeAction(Action):
"""Action for melee attacks"""
def __init__(self, attacker, target):
self.attacker = attacker
self.target = target
def perform(self):
"""Execute the attack"""
if not self.target.is_alive:
return None
damage = self.attacker.power - self.target.defense
if damage > 0:
attack_desc = f"{self.attacker.name} attacks {self.target.name} for {damage} damage!"
self.target.take_damage(damage)
# Choose color based on attacker
if self.attacker.name == "Player":
color = COLOR_PLAYER_ATK
else:
color = COLOR_ENEMY_ATK
return attack_desc, color
else:
attack_desc = f"{self.attacker.name} attacks {self.target.name} but does no damage."
return attack_desc, (150, 150, 150)
class WaitAction(Action):
"""Action for waiting/skipping turn"""
pass
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name,
blocks=False, hp=0, defense=0, power=0):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
# Combat stats
self.max_hp = hp
self.hp = hp
self.defense = defense
self.power = power
@property
def is_alive(self):
"""Returns True if this entity can act"""
return self.hp > 0
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
# Update FOV when player moves
if self.name == "Player":
self.update_fov()
def update_fov(self):
"""Update field of view from this entity's position"""
if self._entity and self.grid:
self._entity.update_fov(radius=8)
def take_damage(self, amount):
"""Apply damage to this entity"""
self.hp -= amount
# Check for death
if self.hp <= 0:
self.die()
def die(self):
"""Handle entity death"""
if self.name == "Player":
# Player death
self.sprite_index = 64 # Stay as @
self.color = (127, 0, 0) # Dark red
if self._entity:
self._entity.color = mcrfpy.Color(127, 0, 0)
else:
# Enemy death
self.sprite_index = 37 # % character for corpse
self.color = (127, 0, 0) # Dark red
self.blocks = False # Corpses don't block
self.name = f"remains of {self.name}"
if self._entity:
self._entity.sprite_index = 37
self._entity.color = mcrfpy.Color(127, 0, 0)
# Entity factories
def create_player(x, y):
"""Create the player entity"""
return GameObject(
x=x, y=y,
sprite_index=64, # @
color=(255, 255, 255),
name="Player",
blocks=True,
hp=30,
defense=2,
power=5
)
def create_orc(x, y):
"""Create an orc enemy"""
return GameObject(
x=x, y=y,
sprite_index=111, # o
color=(63, 127, 63),
name="Orc",
blocks=True,
hp=10,
defense=0,
power=3
)
def create_troll(x, y):
"""Create a troll enemy"""
return GameObject(
x=x, y=y,
sprite_index=84, # T
color=(0, 127, 0),
name="Troll",
blocks=True,
hp=16,
defense=1,
power=4
)
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self):
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
def intersects(self, other):
return (
self.x1 <= other.x2
and self.x2 >= other.x1
and self.y1 <= other.y2
and self.y2 >= other.y1
)
def tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
def spawn_enemies_in_room(room, game_map, max_enemies=2):
"""Spawn between 0 and max_enemies in a room"""
number_of_enemies = random.randint(0, max_enemies)
enemies_spawned = []
for i in range(number_of_enemies):
attempts = 10
while attempts > 0:
x = random.randint(room.x1 + 1, room.x2 - 1)
y = random.randint(room.y1 + 1, room.y2 - 1)
if not game_map.is_blocked(x, y):
# 80% chance for orc, 20% for troll
if random.random() < 0.8:
enemy = create_orc(x, y)
else:
enemy = create_troll(x, y)
game_map.add_entity(enemy)
enemies_spawned.append(enemy)
break
attempts -= 1
return enemies_spawned
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Enable perspective rendering
self.grid.perspective = 0
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, tile_type='wall')
def set_tile(self, x, y, walkable, transparent, sprite_index, tile_type):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*COLORS_VISIBLE[tile_type])
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player, max_enemies_per_room):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
# First room - place player
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
# All other rooms - add tunnel and enemies
self.carve_tunnel(self.rooms[-1].center, new_room.center)
spawn_enemies_in_room(new_room, self, max_enemies_per_room)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='floor')
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='tunnel')
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
if self.get_blocking_entity_at(x, y):
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
self.messages = [] # Simple message log
self.max_messages = 5
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 6"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame((0, 0), (1024, 768))
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def add_message(self, text, color=(255, 255, 255)):
"""Add a message to the log"""
self.messages.append((text, color))
if len(self.messages) > self.max_messages:
self.messages.pop(0)
self.update_message_display()
def update_message_display(self):
"""Update the message display"""
# Clear old messages
for caption in self.message_captions:
# Remove from UI (McRogueFace doesn't have remove, so we hide it)
caption.text = ""
# Display current messages
for i, (text, color) in enumerate(self.messages):
if i < len(self.message_captions):
self.message_captions[i].text = text
self.message_captions[i].fill_color = mcrfpy.Color(*color)
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player
self.player = create_player(0, 0)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player,
max_enemies_per_room=2
)
# Add player to map
self.game_map.add_entity(self.player)
# Store reference to all entities
self.entities = [e for e in self.game_map.entities if e != self.player]
# Initial FOV calculation
self.player.update_fov()
# Welcome message
self.add_message("Welcome to the dungeon!", (100, 100, 255))
def handle_player_turn(self, action):
"""Process the player's action"""
if not self.player.is_alive:
return
if isinstance(action, MovementAction):
dest_x = self.player.x + action.dx
dest_y = self.player.y + action.dy
# Check what's at the destination
target = self.game_map.get_blocking_entity_at(dest_x, dest_y)
if target:
# Attack!
attack = MeleeAction(self.player, target)
result = attack.perform()
if result:
text, color = result
self.add_message(text, color)
# Check if target died
if not target.is_alive:
death_msg = f"The {target.name.replace('remains of ', '')} is dead!"
self.add_message(death_msg, COLOR_ENEMY_DIE)
elif not self.game_map.is_blocked(dest_x, dest_y):
# Move the player
self.player.move(action.dx, action.dy)
elif isinstance(action, WaitAction):
pass # Do nothing
# Enemy turns
self.handle_enemy_turns()
def handle_enemy_turns(self):
"""Let all enemies take their turn"""
for entity in self.entities:
if entity.is_alive:
# Simple AI: if player is adjacent, attack. Otherwise, do nothing.
dx = entity.x - self.player.x
dy = entity.y - self.player.y
distance = abs(dx) + abs(dy)
if distance == 1: # Adjacent to player
attack = MeleeAction(entity, self.player)
result = attack.perform()
if result:
text, color = result
self.add_message(text, color)
# Check if player died
if not self.player.is_alive:
self.add_message("You have died!", COLOR_PLAYER_DIE)
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
action = None
# Movement keys
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num5": (0, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
if dx == 0 and dy == 0:
action = WaitAction()
else:
action = MovementAction(dx, dy)
elif key == "Period":
action = WaitAction()
elif key == "Escape":
mcrfpy.setScene(None)
return
# Process the action
if action:
self.handle_player_turn(action)
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Combat System", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Attack enemies by bumping into them!", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Player stats
self.hp_text = mcrfpy.Caption(f"HP: {self.player.hp}/{self.player.max_hp}", 50, 100)
self.hp_text.font_size = 18
self.hp_text.fill_color = mcrfpy.Color(255, 100, 100)
self.ui.append(self.hp_text)
# Message log
self.message_captions = []
for i in range(self.max_messages):
caption = mcrfpy.Caption("", 50, 620 + i * 20)
caption.font_size = 14
caption.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(caption)
self.message_captions.append(caption)
# Timer to update HP display
def update_stats(dt):
self.hp_text.text = f"HP: {self.player.hp}/{self.player.max_hp}"
if self.player.hp <= 0:
self.hp_text.fill_color = mcrfpy.Color(127, 0, 0)
elif self.player.hp < self.player.max_hp // 3:
self.hp_text.fill_color = mcrfpy.Color(255, 100, 100)
else:
self.hp_text.fill_color = mcrfpy.Color(0, 255, 0)
mcrfpy.setTimer("update_stats", update_stats, 100)
# Create and run the game
engine = Engine()
print("Part 6: Combat System!")
print("Attack enemies to defeat them, but watch your HP!")

View File

@ -0,0 +1,80 @@
"""
McRogueFace Tutorial - Part 0: Introduction to Scene, Texture, and Grid
This tutorial introduces the basic building blocks:
- Scene: A container for UI elements and game state
- Texture: Loading image assets for use in the game
- Grid: A tilemap component for rendering tile-based worlds
"""
import mcrfpy
import random
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile)
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Create a grid of tiles
# Each tile is 16x16 pixels, so with 3x zoom: 16*3 = 48 pixels per tile
grid_width, grid_height = 25, 20 # width, height in number of tiles
# calculating the size in pixels to fit the entire grid on-screen
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# calculating the position to center the grid on the screen - assuming default 1024x768 resolution
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size, # height and width on screen
)
grid.zoom = zoom
grid.center = (grid_width/2.0)*16, (grid_height/2.0)*16 # center on the middle of the central tile
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Fill the grid with a simple pattern
for y in range(grid_height):
for x in range(grid_width):
# Create walls around the edges
if x == 0 or x == grid_width-1 or y == 0 or y == grid_height-1:
tile_index = random.choice(WALL_TILES)
else:
# Fill interior with floor tiles
tile_index = random.choice(FLOOR_TILES)
# Set the tile at this position
point = grid.at(x, y)
if point:
point.tilesprite = tile_index
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Add a title caption
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 0",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
# Add instructions
instructions = mcrfpy.Caption((280, 750),
text="Scene + Texture + Grid = Tilemap!",
)
instructions.font_size=18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
print("Tutorial Part 0 loaded!")
print(f"Created a {grid.grid_size[0]}x{grid.grid_size[1]} grid")
print(f"Grid positioned at ({grid.x}, {grid.y})")

View File

@ -0,0 +1,116 @@
"""
McRogueFace Tutorial - Part 1: Entities and Keyboard Input
This tutorial builds on Part 0 by adding:
- Entity: A game object that can be placed in a grid
- Keyboard handling: Responding to key presses to move the entity
"""
import mcrfpy
import random
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile)
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Load the hero sprite texture (32x32 sprite sheet)
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
# Each tile is 16x16 pixels, so with 3x zoom: 16*3 = 48 pixels per tile
grid_width, grid_height = 25, 20 # width, height in number of tiles
# calculating the size in pixels to fit the entire grid on-screen
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# calculating the position to center the grid on the screen - assuming default 1024x768 resolution
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size, # height and width on screen
)
grid.zoom = zoom
grid.center = (grid_width/2.0)*16, (grid_height/2.0)*16 # center on the middle of the central tile
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Fill the grid with a simple pattern
for y in range(grid_height):
for x in range(grid_width):
# Create walls around the edges
if x == 0 or x == grid_width-1 or y == 0 or y == grid_height-1:
tile_index = random.choice(WALL_TILES)
else:
# Fill interior with floor tiles
tile_index = random.choice(FLOOR_TILES)
# Set the tile at this position
point = grid.at(x, y)
if point:
point.tilesprite = tile_index
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Create a player entity at position (4, 4)
player = mcrfpy.Entity(
(4, 4), # Entity positions are tile coordinates
texture=hero_texture,
sprite_index=0 # Use the first sprite in the texture
)
# Add the player entity to the grid
grid.entities.append(player)
# Define keyboard handler
def handle_keys(key, state):
"""Handle keyboard input to move the player"""
if state == "start": # Only respond to key press, not release
# Get current player position in grid coordinates
px, py = player.x, player.y
# Calculate new position based on key press
if key == "W" or key == "Up":
py -= 1
elif key == "S" or key == "Down":
py += 1
elif key == "A" or key == "Left":
px -= 1
elif key == "D" or key == "Right":
px += 1
# Update player position (no collision checking yet)
player.x = px
player.y = py
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add a title caption
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 1",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
# Add instructions
instructions = mcrfpy.Caption((200, 750),
text="Use WASD or Arrow Keys to move the hero!",
)
instructions.font_size=18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
print("Tutorial Part 1 loaded!")
print(f"Player entity created at grid position (4, 4)")
print("Use WASD or Arrow keys to move!")

View File

@ -0,0 +1,117 @@
"""
McRogueFace Tutorial - Part 1: Entities and Keyboard Input
This tutorial builds on Part 0 by adding:
- Entity: A game object that can be placed in a grid
- Keyboard handling: Responding to key presses to move the entity
"""
import mcrfpy
import random
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile)
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Load the hero sprite texture (32x32 sprite sheet)
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
# Each tile is 16x16 pixels, so with 3x zoom: 16*3 = 48 pixels per tile
grid_width, grid_height = 25, 20 # width, height in number of tiles
# calculating the size in pixels to fit the entire grid on-screen
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# calculating the position to center the grid on the screen - assuming default 1024x768 resolution
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size, # height and width on screen
)
grid.zoom = 3.0 # we're not using the zoom variable! It's going to be really big!
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Fill the grid with a simple pattern
for y in range(grid_height):
for x in range(grid_width):
# Create walls around the edges
if x == 0 or x == grid_width-1 or y == 0 or y == grid_height-1:
tile_index = random.choice(WALL_TILES)
else:
# Fill interior with floor tiles
tile_index = random.choice(FLOOR_TILES)
# Set the tile at this position
point = grid.at(x, y)
if point:
point.tilesprite = tile_index
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Create a player entity at position (4, 4)
player = mcrfpy.Entity(
(4, 4), # Entity positions are tile coordinates
texture=hero_texture,
sprite_index=0 # Use the first sprite in the texture
)
# Add the player entity to the grid
grid.entities.append(player)
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16 # grid center is in texture/pixel coordinates
# Define keyboard handler
def handle_keys(key, state):
"""Handle keyboard input to move the player"""
if state == "start": # Only respond to key press, not release
# Get current player position in grid coordinates
px, py = player.x, player.y
# Calculate new position based on key press
if key == "W" or key == "Up":
py -= 1
elif key == "S" or key == "Down":
py += 1
elif key == "A" or key == "Left":
px -= 1
elif key == "D" or key == "Right":
px += 1
# Update player position (no collision checking yet)
player.x = px
player.y = py
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16 # grid center is in texture/pixel coordinates
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add a title caption
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 1",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
# Add instructions
instructions = mcrfpy.Caption((200, 750),
text="Use WASD or Arrow Keys to move the hero!",
)
instructions.font_size=18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
print("Tutorial Part 1 loaded!")
print(f"Player entity created at grid position (4, 4)")
print("Use WASD or Arrow keys to move!")

View File

@ -0,0 +1,149 @@
"""
McRogueFace Tutorial - Part 2: Animated Movement
This tutorial builds on Part 1 by adding:
- Animation system for smooth movement
- Movement that takes 0.5 seconds per tile
- Input blocking during movement animation
"""
import mcrfpy
import random
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile)
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Load the hero sprite texture (32x32 sprite sheet)
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
# Each tile is 16x16 pixels, so with 3x zoom: 16*3 = 48 pixels per tile
grid_width, grid_height = 25, 20 # width, height in number of tiles
# calculating the size in pixels to fit the entire grid on-screen
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# calculating the position to center the grid on the screen - assuming default 1024x768 resolution
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size, # height and width on screen
)
grid.zoom = 3.0 # we're not using the zoom variable! It's going to be really big!
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Fill the grid with a simple pattern
for y in range(grid_height):
for x in range(grid_width):
# Create walls around the edges
if x == 0 or x == grid_width-1 or y == 0 or y == grid_height-1:
tile_index = random.choice(WALL_TILES)
else:
# Fill interior with floor tiles
tile_index = random.choice(FLOOR_TILES)
# Set the tile at this position
point = grid.at(x, y)
if point:
point.tilesprite = tile_index
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Create a player entity at position (4, 4)
player = mcrfpy.Entity(
(4, 4), # Entity positions are tile coordinates
texture=hero_texture,
sprite_index=0 # Use the first sprite in the texture
)
# Add the player entity to the grid
grid.entities.append(player)
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16 # grid center is in texture/pixel coordinates
# Movement state tracking
is_moving = False
move_animations = [] # Track active animations
# Animation completion callback
def movement_complete(runtime):
"""Called when movement animation completes"""
global is_moving
is_moving = False
# Ensure grid is centered on final position
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
motion_speed = 0.30 # seconds per tile
# Define keyboard handler
def handle_keys(key, state):
"""Handle keyboard input to move the player"""
global is_moving, move_animations
if state == "start" and not is_moving: # Only respond to key press when not moving
# Get current player position in grid coordinates
px, py = player.x, player.y
new_x, new_y = px, py
# Calculate new position based on key press
if key == "W" or key == "Up":
new_y -= 1
elif key == "S" or key == "Down":
new_y += 1
elif key == "A" or key == "Left":
new_x -= 1
elif key == "D" or key == "Right":
new_x += 1
# If position changed, start movement animation
if new_x != px or new_y != py:
is_moving = True
# Create animations for player position
anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad")
anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad")
anim_x.start(player)
anim_y.start(player)
# Animate grid center to follow player
center_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear")
center_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear")
center_x.start(grid)
center_y.start(grid)
# Set a timer to mark movement as complete
mcrfpy.setTimer("move_complete", movement_complete, 500)
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add a title caption
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 2",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
# Add instructions
instructions = mcrfpy.Caption((150, 750),
text="Smooth movement! Each step takes 0.5 seconds.",
)
instructions.font_size=18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
print("Tutorial Part 2 loaded!")
print(f"Player entity created at grid position (4, 4)")
print("Movement is now animated over 0.5 seconds per tile!")
print("Use WASD or Arrow keys to move!")

View File

@ -0,0 +1,241 @@
"""
McRogueFace Tutorial - Part 2: Enhanced with Single Move Queue
This tutorial builds on Part 2 by adding:
- Single queued move system for responsive input
- Debug display showing position and queue status
- Smooth continuous movement when keys are held
- Animation callbacks to prevent race conditions
"""
import mcrfpy
import random
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile)
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Load the hero sprite texture (32x32 sprite sheet)
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
# Each tile is 16x16 pixels, so with 3x zoom: 16*3 = 48 pixels per tile
grid_width, grid_height = 25, 20 # width, height in number of tiles
# calculating the size in pixels to fit the entire grid on-screen
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# calculating the position to center the grid on the screen - assuming default 1024x768 resolution
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size, # height and width on screen
)
grid.zoom = 3.0 # we're not using the zoom variable! It's going to be really big!
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Fill the grid with a simple pattern
for y in range(grid_height):
for x in range(grid_width):
# Create walls around the edges
if x == 0 or x == grid_width-1 or y == 0 or y == grid_height-1:
tile_index = random.choice(WALL_TILES)
else:
# Fill interior with floor tiles
tile_index = random.choice(FLOOR_TILES)
# Set the tile at this position
point = grid.at(x, y)
if point:
point.tilesprite = tile_index
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Create a player entity at position (4, 4)
player = mcrfpy.Entity(
(4, 4), # Entity positions are tile coordinates
texture=hero_texture,
sprite_index=0 # Use the first sprite in the texture
)
# Add the player entity to the grid
grid.entities.append(player)
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16 # grid center is in texture/pixel coordinates
# Movement state tracking
is_moving = False
move_queue = [] # List to store queued moves (max 1 item)
#last_position = (4, 4) # Track last position
current_destination = None # Track where we're currently moving to
current_move = None # Track current move direction
# Store animation references
player_anim_x = None
player_anim_y = None
grid_anim_x = None
grid_anim_y = None
# Debug display caption
debug_caption = mcrfpy.Caption((10, 40),
text="Last: (4, 4) | Queue: 0 | Dest: None",
)
debug_caption.font_size = 16
debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255)
mcrfpy.sceneUI("tutorial").append(debug_caption)
# Additional debug caption for movement state
move_debug_caption = mcrfpy.Caption((10, 60),
text="Moving: False | Current: None | Queued: None",
)
move_debug_caption.font_size = 16
move_debug_caption.fill_color = mcrfpy.Color(255, 200, 0, 255)
mcrfpy.sceneUI("tutorial").append(move_debug_caption)
def key_to_direction(key):
"""Convert key to direction string"""
if key == "W" or key == "Up":
return "Up"
elif key == "S" or key == "Down":
return "Down"
elif key == "A" or key == "Left":
return "Left"
elif key == "D" or key == "Right":
return "Right"
return None
def update_debug_display():
"""Update the debug caption with current state"""
queue_count = len(move_queue)
dest_text = f"({current_destination[0]}, {current_destination[1]})" if current_destination else "None"
debug_caption.text = f"Last: ({player.x}, {player.y}) | Queue: {queue_count} | Dest: {dest_text}"
# Update movement state debug
current_dir = key_to_direction(current_move) if current_move else "None"
queued_dir = key_to_direction(move_queue[0]) if move_queue else "None"
move_debug_caption.text = f"Moving: {is_moving} | Current: {current_dir} | Queued: {queued_dir}"
# Animation completion callback
def movement_complete(anim, target):
"""Called when movement animation completes"""
global is_moving, move_queue, current_destination, current_move
global player_anim_x, player_anim_y
print(f"In callback for animation: {anim=} {target=}")
# Clear movement state
is_moving = False
current_move = None
current_destination = None
# Clear animation references
player_anim_x = None
player_anim_y = None
# Update last position to where we actually are now
#last_position = (int(player.x), int(player.y))
# Ensure grid is centered on final position
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
# Check if there's a queued move
if move_queue:
# Pop the next move from the queue
next_move = move_queue.pop(0)
print(f"Processing queued move: {next_move}")
# Process it like a fresh input
process_move(next_move)
update_debug_display()
motion_speed = 0.30 # seconds per tile
def process_move(key):
"""Process a move based on the key"""
global is_moving, current_move, current_destination, move_queue
global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y
# If already moving, just update the queue
if is_moving:
print(f"process_move processing {key=} as a queued move (is_moving = True)")
# Clear queue and add new move (only keep 1 queued move)
move_queue.clear()
move_queue.append(key)
update_debug_display()
return
print(f"process_move processing {key=} as a new, immediate animation (is_moving = False)")
# Calculate new position from current position
px, py = int(player.x), int(player.y)
new_x, new_y = px, py
# Calculate new position based on key press (only one tile movement)
if key == "W" or key == "Up":
new_y -= 1
elif key == "S" or key == "Down":
new_y += 1
elif key == "A" or key == "Left":
new_x -= 1
elif key == "D" or key == "Right":
new_x += 1
# Start the move if position changed
if new_x != px or new_y != py:
is_moving = True
current_move = key
current_destination = (new_x, new_y)
# only animate a single axis, same callback from either
if new_x != px:
player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_x.start(player)
elif new_y != py:
player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_y.start(player)
# Animate grid center to follow player
grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear")
grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear")
grid_anim_x.start(grid)
grid_anim_y.start(grid)
update_debug_display()
# Define keyboard handler
def handle_keys(key, state):
"""Handle keyboard input to move the player"""
if state == "start":
# Only process movement keys
if key in ["W", "Up", "S", "Down", "A", "Left", "D", "Right"]:
print(f"handle_keys producing actual input: {key=}")
process_move(key)
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add a title caption
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 2 Enhanced",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
# Add instructions
instructions = mcrfpy.Caption((150, 750),
text="One-move queue system with animation callbacks!",
)
instructions.font_size=18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
print("Tutorial Part 2 Enhanced loaded!")
print(f"Player entity created at grid position (4, 4)")
print("Movement now uses animation callbacks to prevent race conditions!")
print("Use WASD or Arrow keys to move!")

View File

@ -0,0 +1,149 @@
"""
McRogueFace Tutorial - Part 2: Animated Movement
This tutorial builds on Part 1 by adding:
- Animation system for smooth movement
- Movement that takes 0.5 seconds per tile
- Input blocking during movement animation
"""
import mcrfpy
import random
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile)
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Load the hero sprite texture (32x32 sprite sheet)
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
# Each tile is 16x16 pixels, so with 3x zoom: 16*3 = 48 pixels per tile
grid_width, grid_height = 25, 20 # width, height in number of tiles
# calculating the size in pixels to fit the entire grid on-screen
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# calculating the position to center the grid on the screen - assuming default 1024x768 resolution
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size, # height and width on screen
)
grid.zoom = 3.0 # we're not using the zoom variable! It's going to be really big!
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Fill the grid with a simple pattern
for y in range(grid_height):
for x in range(grid_width):
# Create walls around the edges
if x == 0 or x == grid_width-1 or y == 0 or y == grid_height-1:
tile_index = random.choice(WALL_TILES)
else:
# Fill interior with floor tiles
tile_index = random.choice(FLOOR_TILES)
# Set the tile at this position
point = grid.at(x, y)
if point:
point.tilesprite = tile_index
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Create a player entity at position (4, 4)
player = mcrfpy.Entity(
(4, 4), # Entity positions are tile coordinates
texture=hero_texture,
sprite_index=0 # Use the first sprite in the texture
)
# Add the player entity to the grid
grid.entities.append(player)
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16 # grid center is in texture/pixel coordinates
# Movement state tracking
is_moving = False
move_animations = [] # Track active animations
# Animation completion callback
def movement_complete(runtime):
"""Called when movement animation completes"""
global is_moving
is_moving = False
# Ensure grid is centered on final position
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
motion_speed = 0.30 # seconds per tile
# Define keyboard handler
def handle_keys(key, state):
"""Handle keyboard input to move the player"""
global is_moving, move_animations
if state == "start" and not is_moving: # Only respond to key press when not moving
# Get current player position in grid coordinates
px, py = player.x, player.y
new_x, new_y = px, py
# Calculate new position based on key press
if key == "W" or key == "Up":
new_y -= 1
elif key == "S" or key == "Down":
new_y += 1
elif key == "A" or key == "Left":
new_x -= 1
elif key == "D" or key == "Right":
new_x += 1
# If position changed, start movement animation
if new_x != px or new_y != py:
is_moving = True
# Create animations for player position
anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad")
anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad")
anim_x.start(player)
anim_y.start(player)
# Animate grid center to follow player
center_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear")
center_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear")
center_x.start(grid)
center_y.start(grid)
# Set a timer to mark movement as complete
mcrfpy.setTimer("move_complete", movement_complete, 500)
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add a title caption
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 2",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
# Add instructions
instructions = mcrfpy.Caption((150, 750),
"Smooth movement! Each step takes 0.5 seconds.",
)
instructions.font_size=18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
print("Tutorial Part 2 loaded!")
print(f"Player entity created at grid position (4, 4)")
print("Movement is now animated over 0.5 seconds per tile!")
print("Use WASD or Arrow keys to move!")

View File

@ -0,0 +1,313 @@
"""
McRogueFace Tutorial - Part 3: Procedural Dungeon Generation
This tutorial builds on Part 2 by adding:
- Binary Space Partition (BSP) dungeon generation
- Rooms connected by hallways using libtcod.line()
- Walkable/non-walkable terrain
- Player spawning in a valid location
- Wall tiles that block movement
Key code references:
- src/scripts/cos_level.py (lines 7-15, 184-217, 218-224) - BSP algorithm
- mcrfpy.libtcod.line() for smooth hallway generation
"""
import mcrfpy
import random
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile)
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Load the hero sprite texture
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
grid_width, grid_height = 40, 30 # Larger grid for dungeon
# Calculate the size in pixels to fit the entire grid on-screen
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# Calculate the position to center the grid on the screen
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
# Create the grid with a TCODMap for pathfinding/FOV
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size,
)
grid.zoom = zoom
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Room class for BSP
class Room:
def __init__(self, x, y, w, h):
self.x1 = x
self.y1 = y
self.x2 = x + w
self.y2 = y + h
self.w = w
self.h = h
def center(self):
"""Return the center coordinates of the room"""
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return (center_x, center_y)
def intersects(self, other):
"""Return True if this room overlaps with another"""
return (self.x1 <= other.x2 and self.x2 >= other.x1 and
self.y1 <= other.y2 and self.y2 >= other.y1)
# Dungeon generation functions
def carve_room(room):
"""Carve out a room in the grid - referenced from cos_level.py lines 117-120"""
# Using individual updates for now (batch updates would be more efficient)
for x in range(room.x1, room.x2):
for y in range(room.y1, room.y2):
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def carve_hallway(x1, y1, x2, y2):
"""Carve a hallway between two points using libtcod.line()
Referenced from cos_level.py lines 184-217, improved with libtcod.line()
"""
# Get all points along the line
# Simple solution: works if your characters have diagonal movement
#points = mcrfpy.libtcod.line(x1, y1, x2, y2)
# We don't, so we're going to carve a path with an elbow in it
points = []
if random.choice([True, False]):
# x1,y1 -> x2,y1 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x2, y1))
points.extend(mcrfpy.libtcod.line(x2, y1, x2, y2))
else:
# x1,y1 -> x1,y2 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x1, y2))
points.extend(mcrfpy.libtcod.line(x1, y2, x2, y2))
# Carve out each point
for x, y in points:
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def generate_dungeon(max_rooms=10, room_min_size=4, room_max_size=10):
"""Generate a dungeon using simplified BSP approach
Referenced from cos_level.py lines 218-224
"""
rooms = []
# First, fill everything with walls
for y in range(grid_height):
for x in range(grid_width):
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(WALL_TILES)
point.walkable = False
point.transparent = False
# Generate rooms
for _ in range(max_rooms):
# Random room size
w = random.randint(room_min_size, room_max_size)
h = random.randint(room_min_size, room_max_size)
# Random position (with margin from edges)
x = random.randint(1, grid_width - w - 1)
y = random.randint(1, grid_height - h - 1)
new_room = Room(x, y, w, h)
# Check if it overlaps with existing rooms
failed = False
for other_room in rooms:
if new_room.intersects(other_room):
failed = True
break
if not failed:
# Carve out the room
carve_room(new_room)
# If not the first room, connect to previous room
if rooms:
# Get centers
prev_x, prev_y = rooms[-1].center()
new_x, new_y = new_room.center()
# Carve hallway using libtcod.line()
carve_hallway(prev_x, prev_y, new_x, new_y)
rooms.append(new_room)
return rooms
# Generate the dungeon
rooms = generate_dungeon(max_rooms=8, room_min_size=4, room_max_size=8)
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Spawn player in the first room
if rooms:
spawn_x, spawn_y = rooms[0].center()
else:
# Fallback spawn position
spawn_x, spawn_y = 4, 4
# Create a player entity at the spawn position
player = mcrfpy.Entity(
(spawn_x, spawn_y),
texture=hero_texture,
sprite_index=0
)
# Add the player entity to the grid
grid.entities.append(player)
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
# Movement state tracking (from Part 2)
is_moving = False
move_queue = []
current_destination = None
current_move = None
# Store animation references
player_anim_x = None
player_anim_y = None
grid_anim_x = None
grid_anim_y = None
def movement_complete(anim, target):
"""Called when movement animation completes"""
global is_moving, move_queue, current_destination, current_move
global player_anim_x, player_anim_y
is_moving = False
current_move = None
current_destination = None
player_anim_x = None
player_anim_y = None
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
if move_queue:
next_move = move_queue.pop(0)
process_move(next_move)
motion_speed = 0.20 # Slightly faster for dungeon exploration
def can_move_to(x, y):
"""Check if a position is valid for movement"""
# Boundary check
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
return False
# Walkability check
point = grid.at(x, y)
if point and point.walkable:
return True
return False
def process_move(key):
"""Process a move based on the key"""
global is_moving, current_move, current_destination, move_queue
global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y
if is_moving:
move_queue.clear()
move_queue.append(key)
return
px, py = int(player.x), int(player.y)
new_x, new_y = px, py
if key == "W" or key == "Up":
new_y -= 1
elif key == "S" or key == "Down":
new_y += 1
elif key == "A" or key == "Left":
new_x -= 1
elif key == "D" or key == "Right":
new_x += 1
# Check if we can move to the new position
if new_x != px or new_y != py:
if can_move_to(new_x, new_y):
is_moving = True
current_move = key
current_destination = (new_x, new_y)
if new_x != px:
player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_x.start(player)
elif new_y != py:
player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_y.start(player)
grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear")
grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear")
grid_anim_x.start(grid)
grid_anim_y.start(grid)
else:
# Play a "bump" sound or visual feedback here
print(f"Can't move to ({new_x}, {new_y}) - blocked!")
def handle_keys(key, state):
"""Handle keyboard input to move the player"""
if state == "start":
if key in ["W", "Up", "S", "Down", "A", "Left", "D", "Right"]:
process_move(key)
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add UI elements
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 3: Dungeon Generation",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
instructions = mcrfpy.Caption((150, 750),
text=f"Procedural dungeon with {len(rooms)} rooms connected by hallways!",
)
instructions.font_size = 18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
# Debug info
debug_caption = mcrfpy.Caption((10, 40),
text=f"Grid: {grid_width}x{grid_height} | Player spawned at ({spawn_x}, {spawn_y})",
)
debug_caption.font_size = 16
debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255)
mcrfpy.sceneUI("tutorial").append(debug_caption)
print("Tutorial Part 3 loaded!")
print(f"Generated dungeon with {len(rooms)} rooms")
print(f"Player spawned at ({spawn_x}, {spawn_y})")
print("Walls now block movement!")
print("Use WASD or Arrow keys to explore the dungeon!")

View File

@ -0,0 +1,366 @@
"""
McRogueFace Tutorial - Part 4: Field of View
This tutorial builds on Part 3 by adding:
- Field of view calculation using grid.compute_fov()
- Entity perspective rendering with grid.perspective
- Three visibility states: unexplored (black), explored (dark), visible (lit)
- Memory of previously seen areas
- Enemy entity to demonstrate perspective switching
Key code references:
- tests/unit/test_tcod_fov_entities.py (lines 89-118) - FOV with multiple entities
- ROADMAP.md (lines 216-229) - FOV system implementation details
"""
import mcrfpy
import random
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile)
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Load the hero sprite texture
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
grid_width, grid_height = 40, 30
# Calculate the size in pixels
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# Calculate the position to center the grid on the screen
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
# Create the grid with a TCODMap for pathfinding/FOV
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size,
)
grid.zoom = zoom
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Room class for BSP
class Room:
def __init__(self, x, y, w, h):
self.x1 = x
self.y1 = y
self.x2 = x + w
self.y2 = y + h
self.w = w
self.h = h
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return (center_x, center_y)
def intersects(self, other):
return (self.x1 <= other.x2 and self.x2 >= other.x1 and
self.y1 <= other.y2 and self.y2 >= other.y1)
# Dungeon generation functions (from Part 3)
def carve_room(room):
for x in range(room.x1, room.x2):
for y in range(room.y1, room.y2):
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def carve_hallway(x1, y1, x2, y2):
#points = mcrfpy.libtcod.line(x1, y1, x2, y2)
points = []
if random.choice([True, False]):
# x1,y1 -> x2,y1 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x2, y1))
points.extend(mcrfpy.libtcod.line(x2, y1, x2, y2))
else:
# x1,y1 -> x1,y2 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x1, y2))
points.extend(mcrfpy.libtcod.line(x1, y2, x2, y2))
for x, y in points:
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def generate_dungeon(max_rooms=10, room_min_size=4, room_max_size=10):
rooms = []
# Fill with walls
for y in range(grid_height):
for x in range(grid_width):
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(WALL_TILES)
point.walkable = False
point.transparent = False
# Generate rooms
for _ in range(max_rooms):
w = random.randint(room_min_size, room_max_size)
h = random.randint(room_min_size, room_max_size)
x = random.randint(1, grid_width - w - 1)
y = random.randint(1, grid_height - h - 1)
new_room = Room(x, y, w, h)
failed = False
for other_room in rooms:
if new_room.intersects(other_room):
failed = True
break
if not failed:
carve_room(new_room)
if rooms:
prev_x, prev_y = rooms[-1].center()
new_x, new_y = new_room.center()
carve_hallway(prev_x, prev_y, new_x, new_y)
rooms.append(new_room)
return rooms
# Generate the dungeon
rooms = generate_dungeon(max_rooms=8, room_min_size=4, room_max_size=8)
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Spawn player in the first room
if rooms:
spawn_x, spawn_y = rooms[0].center()
else:
spawn_x, spawn_y = 4, 4
# Create a player entity
player = mcrfpy.Entity(
(spawn_x, spawn_y),
texture=hero_texture,
sprite_index=0
)
# Add the player entity to the grid
grid.entities.append(player)
# Create an enemy entity in another room (to demonstrate perspective switching)
enemy = None
if len(rooms) > 1:
enemy_x, enemy_y = rooms[1].center()
enemy = mcrfpy.Entity(
(enemy_x, enemy_y),
texture=hero_texture,
sprite_index=0 # Enemy sprite
)
grid.entities.append(enemy)
# Set the grid perspective to the player by default
# Note: The new perspective system uses entity references directly
grid.perspective = player
# Initial FOV computation
def update_fov():
"""Update field of view from current perspective
Referenced from test_tcod_fov_entities.py lines 89-118
"""
if grid.perspective == player:
grid.compute_fov(int(player.x), int(player.y), radius=8, algorithm=0)
player.update_visibility()
elif enemy and grid.perspective == enemy:
grid.compute_fov(int(enemy.x), int(enemy.y), radius=6, algorithm=0)
enemy.update_visibility()
# Perform initial FOV calculation
update_fov()
# Center grid on current perspective
def center_on_perspective():
if grid.perspective == player:
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
elif enemy and grid.perspective == enemy:
grid.center = (enemy.x + 0.5) * 16, (enemy.y + 0.5) * 16
center_on_perspective()
# Movement state tracking (from Part 3)
is_moving = False
move_queue = []
current_destination = None
current_move = None
# Store animation references
player_anim_x = None
player_anim_y = None
grid_anim_x = None
grid_anim_y = None
def movement_complete(anim, target):
"""Called when movement animation completes"""
global is_moving, move_queue, current_destination, current_move
global player_anim_x, player_anim_y
is_moving = False
current_move = None
current_destination = None
player_anim_x = None
player_anim_y = None
# Update FOV after movement
update_fov()
center_on_perspective()
if move_queue:
next_move = move_queue.pop(0)
process_move(next_move)
motion_speed = 0.20
def can_move_to(x, y):
"""Check if a position is valid for movement"""
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
return False
point = grid.at(x, y)
if point and point.walkable:
return True
return False
def process_move(key):
"""Process a move based on the key"""
global is_moving, current_move, current_destination, move_queue
global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y
# Only allow player movement when in player perspective
if grid.perspective != player:
return
if is_moving:
move_queue.clear()
move_queue.append(key)
return
px, py = int(player.x), int(player.y)
new_x, new_y = px, py
if key == "W" or key == "Up":
new_y -= 1
elif key == "S" or key == "Down":
new_y += 1
elif key == "A" or key == "Left":
new_x -= 1
elif key == "D" or key == "Right":
new_x += 1
if new_x != px or new_y != py:
if can_move_to(new_x, new_y):
is_moving = True
current_move = key
current_destination = (new_x, new_y)
if new_x != px:
player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_x.start(player)
elif new_y != py:
player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_y.start(player)
grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear")
grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear")
grid_anim_x.start(grid)
grid_anim_y.start(grid)
def handle_keys(key, state):
"""Handle keyboard input"""
if state == "start":
# Movement keys
if key in ["W", "Up", "S", "Down", "A", "Left", "D", "Right"]:
process_move(key)
# Perspective switching
elif key == "Tab":
# Switch perspective between player and enemy
if enemy:
if grid.perspective == player:
grid.perspective = enemy
print("Switched to enemy perspective")
else:
grid.perspective = player
print("Switched to player perspective")
# Update FOV and camera for new perspective
update_fov()
center_on_perspective()
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add UI elements
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 4: Field of View",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
instructions = mcrfpy.Caption((150, 720),
text="Use WASD/Arrows to move. Press Tab to switch perspective!",
)
instructions.font_size = 18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
# FOV info
fov_caption = mcrfpy.Caption((150, 745),
text="FOV: Player (radius 8) | Enemy visible in other room",
)
fov_caption.font_size = 16
fov_caption.fill_color = mcrfpy.Color(100, 200, 255, 255)
mcrfpy.sceneUI("tutorial").append(fov_caption)
# Debug info
debug_caption = mcrfpy.Caption((10, 40),
text=f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Perspective: Player",
)
debug_caption.font_size = 16
debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255)
mcrfpy.sceneUI("tutorial").append(debug_caption)
# Update function for perspective display
def update_perspective_display():
current_perspective = "Player" if grid.perspective == player else "Enemy"
debug_caption.text = f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Perspective: {current_perspective}"
if grid.perspective == player:
fov_caption.text = "FOV: Player (radius 8) | Tab to switch perspective"
else:
fov_caption.text = "FOV: Enemy (radius 6) | Tab to switch perspective"
# Timer to update display
def update_display(runtime):
update_perspective_display()
mcrfpy.setTimer("display_update", update_display, 100)
print("Tutorial Part 4 loaded!")
print("Field of View system active!")
print("- Unexplored areas are black")
print("- Previously seen areas are dark")
print("- Currently visible areas are lit")
print("Press Tab to switch between player and enemy perspective!")
print("Use WASD or Arrow keys to move!")

View File

@ -0,0 +1,363 @@
"""
McRogueFace Tutorial - Part 5: Interacting with other entities
This tutorial builds on Part 4 by adding:
- Subclassing mcrfpy.Entity
- Non-blocking movement animations with destination tracking
- Bump interactions (combat, pushing)
"""
import mcrfpy
import random
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile)
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Load the hero sprite texture
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
grid_width, grid_height = 40, 30
# Calculate the size in pixels
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# Calculate the position to center the grid on the screen
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
# Create the grid with a TCODMap for pathfinding/FOV
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size,
)
grid.zoom = zoom
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Room class for BSP
class Room:
def __init__(self, x, y, w, h):
self.x1 = x
self.y1 = y
self.x2 = x + w
self.y2 = y + h
self.w = w
self.h = h
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return (center_x, center_y)
def intersects(self, other):
return (self.x1 <= other.x2 and self.x2 >= other.x1 and
self.y1 <= other.y2 and self.y2 >= other.y1)
# Dungeon generation functions (from Part 3)
def carve_room(room):
for x in range(room.x1, room.x2):
for y in range(room.y1, room.y2):
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def carve_hallway(x1, y1, x2, y2):
#points = mcrfpy.libtcod.line(x1, y1, x2, y2)
points = []
if random.choice([True, False]):
# x1,y1 -> x2,y1 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x2, y1))
points.extend(mcrfpy.libtcod.line(x2, y1, x2, y2))
else:
# x1,y1 -> x1,y2 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x1, y2))
points.extend(mcrfpy.libtcod.line(x1, y2, x2, y2))
for x, y in points:
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def generate_dungeon(max_rooms=10, room_min_size=4, room_max_size=10):
rooms = []
# Fill with walls
for y in range(grid_height):
for x in range(grid_width):
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(WALL_TILES)
point.walkable = False
point.transparent = False
# Generate rooms
for _ in range(max_rooms):
w = random.randint(room_min_size, room_max_size)
h = random.randint(room_min_size, room_max_size)
x = random.randint(1, grid_width - w - 1)
y = random.randint(1, grid_height - h - 1)
new_room = Room(x, y, w, h)
failed = False
for other_room in rooms:
if new_room.intersects(other_room):
failed = True
break
if not failed:
carve_room(new_room)
if rooms:
prev_x, prev_y = rooms[-1].center()
new_x, new_y = new_room.center()
carve_hallway(prev_x, prev_y, new_x, new_y)
rooms.append(new_room)
return rooms
# Generate the dungeon
rooms = generate_dungeon(max_rooms=8, room_min_size=4, room_max_size=8)
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Spawn player in the first room
if rooms:
spawn_x, spawn_y = rooms[0].center()
else:
spawn_x, spawn_y = 4, 4
class GameEntity(mcrfpy.Entity):
"""An entity whose default behavior is to prevent others from moving into its tile."""
def __init__(self, x, y, walkable=False, **kwargs):
super().__init__(x=x, y=y, **kwargs)
self.walkable = walkable
self.dest_x = x
self.dest_y = y
self.is_moving = False
def get_position(self):
"""Get logical position (destination if moving, otherwise current)"""
if self.is_moving:
return (self.dest_x, self.dest_y)
return (int(self.x), int(self.y))
def on_bump(self, other):
return self.walkable # allow other's motion to proceed if entity is walkable
def __repr__(self):
return f"<{self.__class__.__name__} x={self.x}, y={self.y}, sprite_index={self.sprite_index}>"
class BumpableEntity(GameEntity):
def __init__(self, x, y, **kwargs):
super().__init__(x, y, **kwargs)
def on_bump(self, other):
print(f"Watch it, {other}! You bumped into {self}!")
return False
# Create a player entity
player = GameEntity(
spawn_x, spawn_y,
texture=hero_texture,
sprite_index=0
)
# Add the player entity to the grid
grid.entities.append(player)
for r in rooms:
enemy_x, enemy_y = r.center()
enemy = BumpableEntity(
enemy_x, enemy_y,
grid=grid,
texture=hero_texture,
sprite_index=0 # Enemy sprite
)
# Set the grid perspective to the player by default
# Note: The new perspective system uses entity references directly
grid.perspective = player
# Initial FOV computation
def update_fov():
"""Update field of view from current perspective
Referenced from test_tcod_fov_entities.py lines 89-118
"""
if grid.perspective == player:
grid.compute_fov(int(player.x), int(player.y), radius=8, algorithm=0)
player.update_visibility()
elif enemy and grid.perspective == enemy:
grid.compute_fov(int(enemy.x), int(enemy.y), radius=6, algorithm=0)
enemy.update_visibility()
# Perform initial FOV calculation
update_fov()
# Center grid on current perspective
def center_on_perspective():
if grid.perspective == player:
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
elif enemy and grid.perspective == enemy:
grid.center = (enemy.x + 0.5) * 16, (enemy.y + 0.5) * 16
center_on_perspective()
# Movement state tracking (from Part 3)
#is_moving = False # make it an entity property
move_queue = []
current_destination = None
current_move = None
# Store animation references
player_anim_x = None
player_anim_y = None
grid_anim_x = None
grid_anim_y = None
def movement_complete(anim, target):
"""Called when movement animation completes"""
global move_queue, current_destination, current_move
global player_anim_x, player_anim_y
player.is_moving = False
current_move = None
current_destination = None
player_anim_x = None
player_anim_y = None
# Update FOV after movement
update_fov()
center_on_perspective()
if move_queue:
next_move = move_queue.pop(0)
process_move(next_move)
motion_speed = 0.20
def can_move_to(x, y):
"""Check if a position is valid for movement"""
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
return False
point = grid.at(x, y)
if point and point.walkable:
for e in grid.entities:
if not e.walkable and (x, y) == e.get_position(): # blocking the way
e.on_bump(player)
return False
return True # all checks passed, no collision
return False
def process_move(key):
"""Process a move based on the key"""
global current_move, current_destination, move_queue
global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y
# Only allow player movement when in player perspective
if grid.perspective != player:
return
if player.is_moving:
move_queue.clear()
move_queue.append(key)
return
px, py = int(player.x), int(player.y)
new_x, new_y = px, py
if key == "W" or key == "Up":
new_y -= 1
elif key == "S" or key == "Down":
new_y += 1
elif key == "A" or key == "Left":
new_x -= 1
elif key == "D" or key == "Right":
new_x += 1
if new_x != px or new_y != py:
if can_move_to(new_x, new_y):
player.is_moving = True
current_move = key
current_destination = (new_x, new_y)
if new_x != px:
player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_x.start(player)
elif new_y != py:
player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_y.start(player)
grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear")
grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear")
grid_anim_x.start(grid)
grid_anim_y.start(grid)
def handle_keys(key, state):
"""Handle keyboard input"""
if state == "start":
# Movement keys
if key in ["W", "Up", "S", "Down", "A", "Left", "D", "Right"]:
process_move(key)
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add UI elements
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 5: Entity Collision",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
instructions = mcrfpy.Caption((150, 720),
text="Use WASD/Arrows to move. Try to bump into the other entity!",
)
instructions.font_size = 18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
# Debug info
debug_caption = mcrfpy.Caption((10, 40),
text=f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Perspective: Player",
)
debug_caption.font_size = 16
debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255)
mcrfpy.sceneUI("tutorial").append(debug_caption)
# Update function for perspective display
def update_perspective_display():
current_perspective = "Player" if grid.perspective == player else "Enemy"
debug_caption.text = f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Perspective: {current_perspective}"
# Timer to update display
def update_display(runtime):
update_perspective_display()
mcrfpy.setTimer("display_update", update_display, 100)
print("Tutorial Part 4 loaded!")
print("Field of View system active!")
print("- Unexplored areas are black")
print("- Previously seen areas are dark")
print("- Currently visible areas are lit")
print("Press Tab to switch between player and enemy perspective!")
print("Use WASD or Arrow keys to move!")

View File

@ -0,0 +1,645 @@
"""
McRogueFace Tutorial - Part 6: Turn-based enemy movement
This tutorial builds on Part 5 by adding:
- Turn cycles where enemies move after the player
- Enemy AI that pursues or wanders
- Shared collision detection for all entities
"""
import mcrfpy
import random
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile)
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Load the hero sprite texture
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
grid_width, grid_height = 40, 30
# Calculate the size in pixels
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# Calculate the position to center the grid on the screen
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
# Create the grid with a TCODMap for pathfinding/FOV
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size,
)
grid.zoom = zoom
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Room class for BSP
class Room:
def __init__(self, x, y, w, h):
self.x1 = x
self.y1 = y
self.x2 = x + w
self.y2 = y + h
self.w = w
self.h = h
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return (center_x, center_y)
def intersects(self, other):
return (self.x1 <= other.x2 and self.x2 >= other.x1 and
self.y1 <= other.y2 and self.y2 >= other.y1)
# Dungeon generation functions (from Part 3)
def carve_room(room):
for x in range(room.x1, room.x2):
for y in range(room.y1, room.y2):
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def carve_hallway(x1, y1, x2, y2):
#points = mcrfpy.libtcod.line(x1, y1, x2, y2)
points = []
if random.choice([True, False]):
# x1,y1 -> x2,y1 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x2, y1))
points.extend(mcrfpy.libtcod.line(x2, y1, x2, y2))
else:
# x1,y1 -> x1,y2 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x1, y2))
points.extend(mcrfpy.libtcod.line(x1, y2, x2, y2))
for x, y in points:
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def generate_dungeon(max_rooms=10, room_min_size=4, room_max_size=10):
rooms = []
# Fill with walls
for y in range(grid_height):
for x in range(grid_width):
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(WALL_TILES)
point.walkable = False
point.transparent = False
# Generate rooms
for _ in range(max_rooms):
w = random.randint(room_min_size, room_max_size)
h = random.randint(room_min_size, room_max_size)
x = random.randint(1, grid_width - w - 1)
y = random.randint(1, grid_height - h - 1)
new_room = Room(x, y, w, h)
failed = False
for other_room in rooms:
if new_room.intersects(other_room):
failed = True
break
if not failed:
carve_room(new_room)
if rooms:
prev_x, prev_y = rooms[-1].center()
new_x, new_y = new_room.center()
carve_hallway(prev_x, prev_y, new_x, new_y)
rooms.append(new_room)
return rooms
# Generate the dungeon
rooms = generate_dungeon(max_rooms=8, room_min_size=4, room_max_size=8)
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Spawn player in the first room
if rooms:
spawn_x, spawn_y = rooms[0].center()
else:
spawn_x, spawn_y = 4, 4
class GameEntity(mcrfpy.Entity):
"""An entity whose default behavior is to prevent others from moving into its tile."""
def __init__(self, x, y, walkable=False, **kwargs):
super().__init__(x=x, y=y, **kwargs)
self.walkable = walkable
self.dest_x = x
self.dest_y = y
self.is_moving = False
def get_position(self):
"""Get logical position (destination if moving, otherwise current)"""
if self.is_moving:
return (self.dest_x, self.dest_y)
return (int(self.x), int(self.y))
def on_bump(self, other):
return self.walkable # allow other's motion to proceed if entity is walkable
def __repr__(self):
return f"<{self.__class__.__name__} x={self.x}, y={self.y}, sprite_index={self.sprite_index}>"
class CombatEntity(GameEntity):
def __init__(self, x, y, hp=10, damage=(1,3), **kwargs):
super().__init__(x=x, y=y, **kwargs)
self.hp = hp
self.damage = damage
def is_dead(self):
return self.hp <= 0
def start_move(self, new_x, new_y, duration=0.2, callback=None):
"""Start animating movement to new position"""
self.dest_x = new_x
self.dest_y = new_y
self.is_moving = True
# Define completion callback that resets is_moving
def movement_done(anim, entity):
self.is_moving = False
if callback:
callback(anim, entity)
# Create animations for smooth movement
anim_x = mcrfpy.Animation("x", float(new_x), duration, "easeInOutQuad", callback=movement_done)
anim_y = mcrfpy.Animation("y", float(new_y), duration, "easeInOutQuad")
anim_x.start(self)
anim_y.start(self)
def can_see(self, target_x, target_y):
"""Check if this entity can see the target position"""
mx, my = self.get_position()
# Simple distance check first
dist = abs(target_x - mx) + abs(target_y - my)
if dist > 6:
return False
# Line of sight check
line = list(mcrfpy.libtcod.line(mx, my, target_x, target_y))
for x, y in line[1:-1]: # Skip start and end
cell = grid.at(x, y)
if cell and not cell.transparent:
return False
return True
def ai_turn(self, player_pos):
"""Decide next move"""
mx, my = self.get_position()
px, py = player_pos
# Simple AI: move toward player if visible
if self.can_see(px, py):
# Calculate direction toward player
dx = 0
dy = 0
if px > mx:
dx = 1
elif px < mx:
dx = -1
if py > my:
dy = 1
elif py < my:
dy = -1
# Prefer cardinal movement
if dx != 0 and dy != 0:
# Pick horizontal or vertical based on greater distance
if abs(px - mx) > abs(py - my):
dy = 0
else:
dx = 0
return (mx + dx, my + dy)
else:
# Random wander
dx, dy = random.choice([(0,1), (0,-1), (1,0), (-1,0)])
return (mx + dx, my + dy)
def ai_turn_dijkstra(self):
"""Decide next move using precomputed Dijkstra map"""
mx, my = self.get_position()
# Get current distance to player
current_dist = grid.get_dijkstra_distance(mx, my)
if current_dist is None or current_dist > 20:
# Too far or unreachable - random wander
dx, dy = random.choice([(0,1), (0,-1), (1,0), (-1,0)])
return (mx + dx, my + dy)
# Check all adjacent cells for best move
best_moves = []
for dx, dy in [(0,1), (0,-1), (1,0), (-1,0)]:
nx, ny = mx + dx, my + dy
# Skip if out of bounds
if nx < 0 or nx >= grid_width or ny < 0 or ny >= grid_height:
continue
# Skip if not walkable
cell = grid.at(nx, ny)
if not cell or not cell.walkable:
continue
# Get distance from this cell
dist = grid.get_dijkstra_distance(nx, ny)
if dist is not None:
best_moves.append((dist, nx, ny))
if best_moves:
# Sort by distance
best_moves.sort()
# If multiple moves have the same best distance, pick randomly
best_dist = best_moves[0][0]
equal_moves = [(nx, ny) for dist, nx, ny in best_moves if dist == best_dist]
if len(equal_moves) > 1:
# Random choice among equally good moves
nx, ny = random.choice(equal_moves)
else:
_, nx, ny = best_moves[0]
return (nx, ny)
else:
# No valid moves
return (mx, my)
# Create a player entity
player = CombatEntity(
spawn_x, spawn_y,
texture=hero_texture,
sprite_index=0
)
# Add the player entity to the grid
grid.entities.append(player)
# Track all enemies
enemies = []
# Spawn enemies in other rooms
for i, room in enumerate(rooms[1:], 1): # Skip first room (player spawn)
if i <= 3: # Limit to 3 enemies for now
enemy_x, enemy_y = room.center()
enemy = CombatEntity(
enemy_x, enemy_y,
texture=hero_texture,
sprite_index=0 # Enemy sprite (borrow player's)
)
grid.entities.append(enemy)
enemies.append(enemy)
# Set the grid perspective to the player by default
# Note: The new perspective system uses entity references directly
grid.perspective = player
# Initial FOV computation
def update_fov():
"""Update field of view from current perspective"""
if grid.perspective == player:
grid.compute_fov(int(player.x), int(player.y), radius=8, algorithm=0)
player.update_visibility()
# Perform initial FOV calculation
update_fov()
# Center grid on current perspective
def center_on_perspective():
if grid.perspective == player:
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
center_on_perspective()
# Movement state tracking (from Part 3)
#is_moving = False # make it an entity property
move_queue = []
current_destination = None
current_move = None
# Store animation references
player_anim_x = None
player_anim_y = None
grid_anim_x = None
grid_anim_y = None
def movement_complete(anim, target):
"""Called when movement animation completes"""
global move_queue, current_destination, current_move
global player_anim_x, player_anim_y, is_player_turn
player.is_moving = False
current_move = None
current_destination = None
player_anim_x = None
player_anim_y = None
# Update FOV after movement
update_fov()
center_on_perspective()
# Player turn complete, start enemy turns and queued player move simultaneously
is_player_turn = False
process_enemy_turns_and_player_queue()
motion_speed = 0.20
is_player_turn = True # Track whose turn it is
def get_blocking_entity_at(x, y):
"""Get blocking entity at position"""
for e in grid.entities:
if not e.walkable and (x, y) == e.get_position():
return e
return None
def can_move_to(x, y, mover=None):
"""Check if a position is valid for movement"""
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
return False
point = grid.at(x, y)
if not point or not point.walkable:
return False
# Check for blocking entities
blocker = get_blocking_entity_at(x, y)
if blocker and blocker != mover:
return False
return True
def process_enemy_turns_and_player_queue():
"""Process all enemy AI decisions and player's queued move simultaneously"""
global is_player_turn, move_queue
# Compute Dijkstra map once for all enemies (if using Dijkstra)
if USE_DIJKSTRA:
px, py = player.get_position()
grid.compute_dijkstra(px, py, diagonal_cost=1.41)
enemies_to_move = []
claimed_positions = set() # Track where enemies plan to move
# Collect all enemy moves
for i, enemy in enumerate(enemies):
if enemy.is_dead():
continue
# AI decides next move
if USE_DIJKSTRA:
target_x, target_y = enemy.ai_turn_dijkstra()
else:
target_x, target_y = enemy.ai_turn(player.get_position())
# Check if move is valid and not claimed by another enemy
if can_move_to(target_x, target_y, enemy) and (target_x, target_y) not in claimed_positions:
enemies_to_move.append((enemy, target_x, target_y))
claimed_positions.add((target_x, target_y))
# Start all enemy animations simultaneously
any_enemy_moved = False
if enemies_to_move:
for enemy, tx, ty in enemies_to_move:
enemy.start_move(tx, ty, duration=motion_speed)
any_enemy_moved = True
# Process player's queued move at the same time
if move_queue:
next_move = move_queue.pop(0)
process_player_queued_move(next_move)
else:
# No queued move, set up callback to return control when animations finish
if any_enemy_moved:
# Wait for animations to complete
mcrfpy.setTimer("turn_complete", check_turn_complete, int(motion_speed * 1000) + 50)
else:
# No animations, return control immediately
is_player_turn = True
def process_player_queued_move(key):
"""Process player's queued move during enemy turn"""
global current_move, current_destination
global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y
px, py = int(player.x), int(player.y)
new_x, new_y = px, py
if key == "W" or key == "Up":
new_y -= 1
elif key == "S" or key == "Down":
new_y += 1
elif key == "A" or key == "Left":
new_x -= 1
elif key == "D" or key == "Right":
new_x += 1
if new_x != px or new_y != py:
# Check destination at animation end time (considering enemy destinations)
future_blocker = get_future_blocking_entity_at(new_x, new_y)
if future_blocker:
# Will bump at destination
# Schedule bump for when animations complete
mcrfpy.setTimer("delayed_bump", lambda t: handle_delayed_bump(future_blocker), int(motion_speed * 1000))
elif can_move_to(new_x, new_y, player):
# Valid move, start animation
player.is_moving = True
current_move = key
current_destination = (new_x, new_y)
player.dest_x = new_x
player.dest_y = new_y
# Player animation with callback
player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=player_queued_move_complete)
player_anim_x.start(player)
player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad")
player_anim_y.start(player)
# Move camera with player
grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear")
grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear")
grid_anim_x.start(grid)
grid_anim_y.start(grid)
else:
# Blocked by wall, wait for turn to complete
mcrfpy.setTimer("turn_complete", check_turn_complete, int(motion_speed * 1000) + 50)
def get_future_blocking_entity_at(x, y):
"""Get entity that will be blocking at position after current animations"""
for e in grid.entities:
if not e.walkable and (x, y) == (e.dest_x, e.dest_y):
return e
return None
def handle_delayed_bump(entity):
"""Handle bump after animations complete"""
global is_player_turn
entity.on_bump(player)
is_player_turn = True
def player_queued_move_complete(anim, target):
"""Called when player's queued movement completes"""
global is_player_turn
player.is_moving = False
update_fov()
center_on_perspective()
is_player_turn = True
def check_turn_complete(timer_name):
"""Check if all animations are complete"""
global is_player_turn
# Check if any entity is still moving
if player.is_moving:
mcrfpy.setTimer("turn_complete", check_turn_complete, 50)
return
for enemy in enemies:
if enemy.is_moving:
mcrfpy.setTimer("turn_complete", check_turn_complete, 50)
return
# All done
is_player_turn = True
def process_move(key):
"""Process a move based on the key"""
global current_move, current_destination, move_queue
global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y, is_player_turn
# Only allow player movement on player's turn
if not is_player_turn:
return
# Only allow player movement when in player perspective
if grid.perspective != player:
return
if player.is_moving:
move_queue.clear()
move_queue.append(key)
return
px, py = int(player.x), int(player.y)
new_x, new_y = px, py
if key == "W" or key == "Up":
new_y -= 1
elif key == "S" or key == "Down":
new_y += 1
elif key == "A" or key == "Left":
new_x -= 1
elif key == "D" or key == "Right":
new_x += 1
if new_x != px or new_y != py:
# Check what's at destination
blocker = get_blocking_entity_at(new_x, new_y)
if blocker:
# Bump interaction (combat will go here later)
blocker.on_bump(player)
# Still counts as a turn
is_player_turn = False
process_enemy_turns_and_player_queue()
elif can_move_to(new_x, new_y, player):
player.is_moving = True
current_move = key
current_destination = (new_x, new_y)
player.dest_x = new_x
player.dest_y = new_y
# Start player move animation
player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_x.start(player)
player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad")
player_anim_y.start(player)
# Move camera with player
grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear")
grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear")
grid_anim_x.start(grid)
grid_anim_y.start(grid)
def handle_keys(key, state):
"""Handle keyboard input"""
if state == "start":
# Movement keys
if key in ["W", "Up", "S", "Down", "A", "Left", "D", "Right"]:
process_move(key)
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add UI elements
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 6: Turn-based Movement",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
instructions = mcrfpy.Caption((150, 720),
text="Use WASD/Arrows to move. Enemies move after you!",
)
instructions.font_size = 18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
# Debug info
debug_caption = mcrfpy.Caption((10, 40),
text=f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Enemies: {len(enemies)}",
)
debug_caption.font_size = 16
debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255)
mcrfpy.sceneUI("tutorial").append(debug_caption)
# Update function for turn display
def update_turn_display():
turn_text = "Player" if is_player_turn else "Enemy"
alive_enemies = sum(1 for e in enemies if not e.is_dead())
debug_caption.text = f"Grid: {grid_width}x{grid_height} | Turn: {turn_text} | Enemies: {alive_enemies}/{len(enemies)}"
# Configuration toggle
USE_DIJKSTRA = True # Set to False to use old line-of-sight AI
# Timer to update display
def update_display(runtime):
update_turn_display()
mcrfpy.setTimer("display_update", update_display, 100)
print("Tutorial Part 6 loaded!")
print("Turn-based movement system active!")
print(f"Using {'Dijkstra' if USE_DIJKSTRA else 'Line-of-sight'} AI pathfinding")
print("- Enemies move after the player")
print("- Enemies pursue when they can see you" if not USE_DIJKSTRA else "- Enemies use optimal pathfinding")
print("- Enemies wander when they can't" if not USE_DIJKSTRA else "- All enemies share one pathfinding map")
print("Use WASD or Arrow keys to move!")

View File

@ -0,0 +1,582 @@
"""
McRogueFace Tutorial - Part 6: Turn-based enemy movement
This tutorial builds on Part 5 by adding:
- Turn cycles where enemies move after the player
- Enemy AI that pursues or wanders
- Shared collision detection for all entities
"""
import mcrfpy
import random
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile)
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Load the hero sprite texture
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
grid_width, grid_height = 40, 30
# Calculate the size in pixels
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# Calculate the position to center the grid on the screen
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
# Create the grid with a TCODMap for pathfinding/FOV
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size,
)
grid.zoom = zoom
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Room class for BSP
class Room:
def __init__(self, x, y, w, h):
self.x1 = x
self.y1 = y
self.x2 = x + w
self.y2 = y + h
self.w = w
self.h = h
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return (center_x, center_y)
def intersects(self, other):
return (self.x1 <= other.x2 and self.x2 >= other.x1 and
self.y1 <= other.y2 and self.y2 >= other.y1)
# Dungeon generation functions (from Part 3)
def carve_room(room):
for x in range(room.x1, room.x2):
for y in range(room.y1, room.y2):
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def carve_hallway(x1, y1, x2, y2):
#points = mcrfpy.libtcod.line(x1, y1, x2, y2)
points = []
if random.choice([True, False]):
# x1,y1 -> x2,y1 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x2, y1))
points.extend(mcrfpy.libtcod.line(x2, y1, x2, y2))
else:
# x1,y1 -> x1,y2 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x1, y2))
points.extend(mcrfpy.libtcod.line(x1, y2, x2, y2))
for x, y in points:
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def generate_dungeon(max_rooms=10, room_min_size=4, room_max_size=10):
rooms = []
# Fill with walls
for y in range(grid_height):
for x in range(grid_width):
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(WALL_TILES)
point.walkable = False
point.transparent = False
# Generate rooms
for _ in range(max_rooms):
w = random.randint(room_min_size, room_max_size)
h = random.randint(room_min_size, room_max_size)
x = random.randint(1, grid_width - w - 1)
y = random.randint(1, grid_height - h - 1)
new_room = Room(x, y, w, h)
failed = False
for other_room in rooms:
if new_room.intersects(other_room):
failed = True
break
if not failed:
carve_room(new_room)
if rooms:
prev_x, prev_y = rooms[-1].center()
new_x, new_y = new_room.center()
carve_hallway(prev_x, prev_y, new_x, new_y)
rooms.append(new_room)
return rooms
# Generate the dungeon
rooms = generate_dungeon(max_rooms=8, room_min_size=4, room_max_size=8)
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Spawn player in the first room
if rooms:
spawn_x, spawn_y = rooms[0].center()
else:
spawn_x, spawn_y = 4, 4
class GameEntity(mcrfpy.Entity):
"""An entity whose default behavior is to prevent others from moving into its tile."""
def __init__(self, x, y, walkable=False, **kwargs):
super().__init__(x=x, y=y, **kwargs)
self.walkable = walkable
self.dest_x = x
self.dest_y = y
self.is_moving = False
def get_position(self):
"""Get logical position (destination if moving, otherwise current)"""
if self.is_moving:
return (self.dest_x, self.dest_y)
return (int(self.x), int(self.y))
def on_bump(self, other):
return self.walkable # allow other's motion to proceed if entity is walkable
def __repr__(self):
return f"<{self.__class__.__name__} x={self.x}, y={self.y}, sprite_index={self.sprite_index}>"
class CombatEntity(GameEntity):
def __init__(self, x, y, hp=10, damage=(1,3), **kwargs):
super().__init__(x=x, y=y, **kwargs)
self.hp = hp
self.damage = damage
def is_dead(self):
return self.hp <= 0
def start_move(self, new_x, new_y, duration=0.2, callback=None):
"""Start animating movement to new position"""
self.dest_x = new_x
self.dest_y = new_y
self.is_moving = True
# Define completion callback that resets is_moving
def movement_done(anim, entity):
self.is_moving = False
if callback:
callback(anim, entity)
# Create animations for smooth movement
anim_x = mcrfpy.Animation("x", float(new_x), duration, "easeInOutQuad", callback=movement_done)
anim_y = mcrfpy.Animation("y", float(new_y), duration, "easeInOutQuad")
anim_x.start(self)
anim_y.start(self)
def can_see(self, target_x, target_y):
"""Check if this entity can see the target position"""
mx, my = self.get_position()
# Simple distance check first
dist = abs(target_x - mx) + abs(target_y - my)
if dist > 6:
return False
# Line of sight check
line = list(mcrfpy.libtcod.line(mx, my, target_x, target_y))
for x, y in line[1:-1]: # Skip start and end
cell = grid.at(x, y)
if cell and not cell.transparent:
return False
return True
def ai_turn(self, player_pos):
"""Decide next move"""
mx, my = self.get_position()
px, py = player_pos
# Simple AI: move toward player if visible
if self.can_see(px, py):
# Calculate direction toward player
dx = 0
dy = 0
if px > mx:
dx = 1
elif px < mx:
dx = -1
if py > my:
dy = 1
elif py < my:
dy = -1
# Prefer cardinal movement
if dx != 0 and dy != 0:
# Pick horizontal or vertical based on greater distance
if abs(px - mx) > abs(py - my):
dy = 0
else:
dx = 0
return (mx + dx, my + dy)
else:
# Random wander
dx, dy = random.choice([(0,1), (0,-1), (1,0), (-1,0)])
return (mx + dx, my + dy)
# Create a player entity
player = CombatEntity(
spawn_x, spawn_y,
texture=hero_texture,
sprite_index=0
)
# Add the player entity to the grid
grid.entities.append(player)
# Track all enemies
enemies = []
# Spawn enemies in other rooms
for i, room in enumerate(rooms[1:], 1): # Skip first room (player spawn)
if i <= 3: # Limit to 3 enemies for now
enemy_x, enemy_y = room.center()
enemy = CombatEntity(
enemy_x, enemy_y,
texture=hero_texture,
sprite_index=0 # Enemy sprite (borrow player's)
)
grid.entities.append(enemy)
enemies.append(enemy)
# Set the grid perspective to the player by default
# Note: The new perspective system uses entity references directly
grid.perspective = player
# Initial FOV computation
def update_fov():
"""Update field of view from current perspective"""
if grid.perspective == player:
grid.compute_fov(int(player.x), int(player.y), radius=8, algorithm=0)
player.update_visibility()
# Perform initial FOV calculation
update_fov()
# Center grid on current perspective
def center_on_perspective():
if grid.perspective == player:
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
center_on_perspective()
# Movement state tracking (from Part 3)
#is_moving = False # make it an entity property
move_queue = []
current_destination = None
current_move = None
# Store animation references
player_anim_x = None
player_anim_y = None
grid_anim_x = None
grid_anim_y = None
def movement_complete(anim, target):
"""Called when movement animation completes"""
global move_queue, current_destination, current_move
global player_anim_x, player_anim_y, is_player_turn
player.is_moving = False
current_move = None
current_destination = None
player_anim_x = None
player_anim_y = None
# Update FOV after movement
update_fov()
center_on_perspective()
# Player turn complete, start enemy turns and queued player move simultaneously
is_player_turn = False
process_enemy_turns_and_player_queue()
motion_speed = 0.20
is_player_turn = True # Track whose turn it is
def get_blocking_entity_at(x, y):
"""Get blocking entity at position"""
for e in grid.entities:
if not e.walkable and (x, y) == e.get_position():
return e
return None
def can_move_to(x, y, mover=None):
"""Check if a position is valid for movement"""
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
return False
point = grid.at(x, y)
if not point or not point.walkable:
return False
# Check for blocking entities
blocker = get_blocking_entity_at(x, y)
if blocker and blocker != mover:
return False
return True
def process_enemy_turns_and_player_queue():
"""Process all enemy AI decisions and player's queued move simultaneously"""
global is_player_turn, move_queue
enemies_to_move = []
# Collect all enemy moves
for i, enemy in enumerate(enemies):
if enemy.is_dead():
continue
# AI decides next move based on player's position
target_x, target_y = enemy.ai_turn(player.get_position())
# Check if move is valid
if can_move_to(target_x, target_y, enemy):
enemies_to_move.append((enemy, target_x, target_y))
# Start all enemy animations simultaneously
any_enemy_moved = False
if enemies_to_move:
for enemy, tx, ty in enemies_to_move:
enemy.start_move(tx, ty, duration=motion_speed)
any_enemy_moved = True
# Process player's queued move at the same time
if move_queue:
next_move = move_queue.pop(0)
process_player_queued_move(next_move)
else:
# No queued move, set up callback to return control when animations finish
if any_enemy_moved:
# Wait for animations to complete
mcrfpy.setTimer("turn_complete", check_turn_complete, int(motion_speed * 1000) + 50)
else:
# No animations, return control immediately
is_player_turn = True
def process_player_queued_move(key):
"""Process player's queued move during enemy turn"""
global current_move, current_destination
global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y
px, py = int(player.x), int(player.y)
new_x, new_y = px, py
if key == "W" or key == "Up":
new_y -= 1
elif key == "S" or key == "Down":
new_y += 1
elif key == "A" or key == "Left":
new_x -= 1
elif key == "D" or key == "Right":
new_x += 1
if new_x != px or new_y != py:
# Check destination at animation end time (considering enemy destinations)
future_blocker = get_future_blocking_entity_at(new_x, new_y)
if future_blocker:
# Will bump at destination
# Schedule bump for when animations complete
mcrfpy.setTimer("delayed_bump", lambda t: handle_delayed_bump(future_blocker), int(motion_speed * 1000))
elif can_move_to(new_x, new_y, player):
# Valid move, start animation
player.is_moving = True
current_move = key
current_destination = (new_x, new_y)
player.dest_x = new_x
player.dest_y = new_y
# Player animation with callback
player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=player_queued_move_complete)
player_anim_x.start(player)
player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad")
player_anim_y.start(player)
# Move camera with player
grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear")
grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear")
grid_anim_x.start(grid)
grid_anim_y.start(grid)
else:
# Blocked by wall, wait for turn to complete
mcrfpy.setTimer("turn_complete", check_turn_complete, int(motion_speed * 1000) + 50)
def get_future_blocking_entity_at(x, y):
"""Get entity that will be blocking at position after current animations"""
for e in grid.entities:
if not e.walkable and (x, y) == (e.dest_x, e.dest_y):
return e
return None
def handle_delayed_bump(entity):
"""Handle bump after animations complete"""
global is_player_turn
entity.on_bump(player)
is_player_turn = True
def player_queued_move_complete(anim, target):
"""Called when player's queued movement completes"""
global is_player_turn
player.is_moving = False
update_fov()
center_on_perspective()
is_player_turn = True
def check_turn_complete(timer_name):
"""Check if all animations are complete"""
global is_player_turn
# Check if any entity is still moving
if player.is_moving:
mcrfpy.setTimer("turn_complete", check_turn_complete, 50)
return
for enemy in enemies:
if enemy.is_moving:
mcrfpy.setTimer("turn_complete", check_turn_complete, 50)
return
# All done
is_player_turn = True
def process_move(key):
"""Process a move based on the key"""
global current_move, current_destination, move_queue
global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y, is_player_turn
# Only allow player movement on player's turn
if not is_player_turn:
return
# Only allow player movement when in player perspective
if grid.perspective != player:
return
if player.is_moving:
move_queue.clear()
move_queue.append(key)
return
px, py = int(player.x), int(player.y)
new_x, new_y = px, py
if key == "W" or key == "Up":
new_y -= 1
elif key == "S" or key == "Down":
new_y += 1
elif key == "A" or key == "Left":
new_x -= 1
elif key == "D" or key == "Right":
new_x += 1
if new_x != px or new_y != py:
# Check what's at destination
blocker = get_blocking_entity_at(new_x, new_y)
if blocker:
# Bump interaction (combat will go here later)
blocker.on_bump(player)
# Still counts as a turn
is_player_turn = False
process_enemy_turns_and_player_queue()
elif can_move_to(new_x, new_y, player):
player.is_moving = True
current_move = key
current_destination = (new_x, new_y)
player.dest_x = new_x
player.dest_y = new_y
# Start player move animation
player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_x.start(player)
player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad")
player_anim_y.start(player)
# Move camera with player
grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear")
grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear")
grid_anim_x.start(grid)
grid_anim_y.start(grid)
def handle_keys(key, state):
"""Handle keyboard input"""
if state == "start":
# Movement keys
if key in ["W", "Up", "S", "Down", "A", "Left", "D", "Right"]:
process_move(key)
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add UI elements
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 6: Turn-based Movement",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
instructions = mcrfpy.Caption((150, 720),
text="Use WASD/Arrows to move. Enemies move after you!",
)
instructions.font_size = 18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
# Debug info
debug_caption = mcrfpy.Caption((10, 40),
text=f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Enemies: {len(enemies)}",
)
debug_caption.font_size = 16
debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255)
mcrfpy.sceneUI("tutorial").append(debug_caption)
# Update function for turn display
def update_turn_display():
turn_text = "Player" if is_player_turn else "Enemy"
alive_enemies = sum(1 for e in enemies if not e.is_dead())
debug_caption.text = f"Grid: {grid_width}x{grid_height} | Turn: {turn_text} | Enemies: {alive_enemies}/{len(enemies)}"
# Timer to update display
def update_display(runtime):
update_turn_display()
mcrfpy.setTimer("display_update", update_display, 100)
print("Tutorial Part 6 loaded!")
print("Turn-based movement system active!")
print("- Enemies move after the player")
print("- Enemies pursue when they can see you")
print("- Enemies wander when they can't")
print("Use WASD or Arrow keys to move!")

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -11,10 +11,10 @@ public:
const static int WHEEL_NUM = 4; const static int WHEEL_NUM = 4;
const static int WHEEL_NEG = 2; const static int WHEEL_NEG = 2;
const static int WHEEL_DEL = 1; const static int WHEEL_DEL = 1;
static int keycode(sf::Keyboard::Key& k) { return KEY + (int)k; } static int keycode(const sf::Keyboard::Key& k) { return KEY + (int)k; }
static int keycode(sf::Mouse::Button& b) { return MOUSEBUTTON + (int)b; } static int keycode(const sf::Mouse::Button& b) { return MOUSEBUTTON + (int)b; }
//static int keycode(sf::Mouse::Wheel& w, float d) { return MOUSEWHEEL + (((int)w)<<12) + int(d*16) + 512; } //static int keycode(sf::Mouse::Wheel& w, float d) { return MOUSEWHEEL + (((int)w)<<12) + int(d*16) + 512; }
static int keycode(sf::Mouse::Wheel& w, float d) { static int keycode(const sf::Mouse::Wheel& w, float d) {
int neg = 0; int neg = 0;
if (d < 0) { neg = 1; } if (d < 0) { neg = 1; }
return MOUSEWHEEL + (w * WHEEL_NUM) + (neg * WHEEL_NEG) + 1; return MOUSEWHEEL + (w * WHEEL_NUM) + (neg * WHEEL_NEG) + 1;
@ -32,7 +32,7 @@ public:
return (a & WHEEL_DEL) * factor; return (a & WHEEL_DEL) * factor;
} }
static std::string key_str(sf::Keyboard::Key& keycode) static std::string key_str(const sf::Keyboard::Key& keycode)
{ {
switch(keycode) switch(keycode)
{ {

675
src/Animation.cpp Normal file
View File

@ -0,0 +1,675 @@
#include "Animation.h"
#include "UIDrawable.h"
#include "UIEntity.h"
#include "PyAnimation.h"
#include "McRFPy_API.h"
#include "PythonObjectCache.h"
#include <cmath>
#include <algorithm>
#include <unordered_map>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
// Forward declaration of PyAnimation type
namespace mcrfpydef {
extern PyTypeObject PyAnimationType;
}
// Animation implementation
Animation::Animation(const std::string& targetProperty,
const AnimationValue& targetValue,
float duration,
EasingFunction easingFunc,
bool delta,
PyObject* callback)
: targetProperty(targetProperty)
, targetValue(targetValue)
, duration(duration)
, easingFunc(easingFunc)
, delta(delta)
, pythonCallback(callback)
{
// Increase reference count for Python callback
if (pythonCallback) {
Py_INCREF(pythonCallback);
}
}
Animation::~Animation() {
// Decrease reference count for Python callback if we still own it
PyObject* callback = pythonCallback;
if (callback) {
pythonCallback = nullptr;
PyGILState_STATE gstate = PyGILState_Ensure();
Py_DECREF(callback);
PyGILState_Release(gstate);
}
// Clean up cache entry
if (serial_number != 0) {
PythonObjectCache::getInstance().remove(serial_number);
}
}
void Animation::start(std::shared_ptr<UIDrawable> target) {
if (!target) return;
targetWeak = target;
elapsed = 0.0f;
callbackTriggered = false; // Reset callback state
// Capture start value from target
std::visit([this, &target](const auto& targetVal) {
using T = std::decay_t<decltype(targetVal)>;
if constexpr (std::is_same_v<T, float>) {
float value;
if (target->getProperty(targetProperty, value)) {
startValue = value;
}
}
else if constexpr (std::is_same_v<T, int>) {
int value;
if (target->getProperty(targetProperty, value)) {
startValue = value;
}
}
else if constexpr (std::is_same_v<T, std::vector<int>>) {
// For sprite animation, get current sprite index
int value;
if (target->getProperty(targetProperty, value)) {
startValue = value;
}
}
else if constexpr (std::is_same_v<T, sf::Color>) {
sf::Color value;
if (target->getProperty(targetProperty, value)) {
startValue = value;
}
}
else if constexpr (std::is_same_v<T, sf::Vector2f>) {
sf::Vector2f value;
if (target->getProperty(targetProperty, value)) {
startValue = value;
}
}
else if constexpr (std::is_same_v<T, std::string>) {
std::string value;
if (target->getProperty(targetProperty, value)) {
startValue = value;
}
}
}, targetValue);
}
void Animation::startEntity(std::shared_ptr<UIEntity> target) {
if (!target) return;
entityTargetWeak = target;
elapsed = 0.0f;
callbackTriggered = false; // Reset callback state
// Capture the starting value from the entity
std::visit([this, target](const auto& val) {
using T = std::decay_t<decltype(val)>;
if constexpr (std::is_same_v<T, float>) {
float value = 0.0f;
if (target->getProperty(targetProperty, value)) {
startValue = value;
}
}
else if constexpr (std::is_same_v<T, int>) {
// For entities, we might need to handle sprite_index differently
if (targetProperty == "sprite_index" || targetProperty == "sprite_number") {
startValue = target->sprite.getSpriteIndex();
}
}
// Entities don't support other types yet
}, targetValue);
}
bool Animation::hasValidTarget() const {
return !targetWeak.expired() || !entityTargetWeak.expired();
}
void Animation::clearCallback() {
// Safely clear the callback when PyAnimation is being destroyed
PyObject* callback = pythonCallback;
if (callback) {
pythonCallback = nullptr;
callbackTriggered = true; // Prevent future triggering
PyGILState_STATE gstate = PyGILState_Ensure();
Py_DECREF(callback);
PyGILState_Release(gstate);
}
}
void Animation::complete() {
// Jump to end of animation
elapsed = duration;
// Apply final value
if (auto target = targetWeak.lock()) {
AnimationValue finalValue = interpolate(1.0f);
applyValue(target.get(), finalValue);
}
else if (auto entity = entityTargetWeak.lock()) {
AnimationValue finalValue = interpolate(1.0f);
applyValue(entity.get(), finalValue);
}
}
bool Animation::update(float deltaTime) {
// Try to lock weak_ptr to get shared_ptr
std::shared_ptr<UIDrawable> target = targetWeak.lock();
std::shared_ptr<UIEntity> entity = entityTargetWeak.lock();
// If both are null, target was destroyed
if (!target && !entity) {
return false; // Remove this animation
}
if (isComplete()) {
return false;
}
elapsed += deltaTime;
elapsed = std::min(elapsed, duration);
// Calculate easing value (0.0 to 1.0)
float t = duration > 0 ? elapsed / duration : 1.0f;
float easedT = easingFunc(t);
// Get interpolated value
AnimationValue currentValue = interpolate(easedT);
// Apply to whichever target is valid
if (target) {
applyValue(target.get(), currentValue);
} else if (entity) {
applyValue(entity.get(), currentValue);
}
// Trigger callback when animation completes
// Check pythonCallback again in case it was cleared during update
if (isComplete() && !callbackTriggered && pythonCallback) {
triggerCallback();
}
return !isComplete();
}
AnimationValue Animation::getCurrentValue() const {
float t = duration > 0 ? elapsed / duration : 1.0f;
float easedT = easingFunc(t);
return interpolate(easedT);
}
AnimationValue Animation::interpolate(float t) const {
// Visit the variant to perform type-specific interpolation
return std::visit([this, t](const auto& target) -> AnimationValue {
using T = std::decay_t<decltype(target)>;
if constexpr (std::is_same_v<T, float>) {
// Interpolate float
const float* start = std::get_if<float>(&startValue);
if (!start) return target; // Type mismatch
if (delta) {
return *start + target * t;
} else {
return *start + (target - *start) * t;
}
}
else if constexpr (std::is_same_v<T, int>) {
// Interpolate integer
const int* start = std::get_if<int>(&startValue);
if (!start) return target;
float result;
if (delta) {
result = *start + target * t;
} else {
result = *start + (target - *start) * t;
}
return static_cast<int>(std::round(result));
}
else if constexpr (std::is_same_v<T, std::vector<int>>) {
// For sprite animation, interpolate through the list
if (target.empty()) return target;
// Map t to an index in the vector
size_t index = static_cast<size_t>(t * (target.size() - 1));
index = std::min(index, target.size() - 1);
return static_cast<int>(target[index]);
}
else if constexpr (std::is_same_v<T, sf::Color>) {
// Interpolate color
const sf::Color* start = std::get_if<sf::Color>(&startValue);
if (!start) return target;
sf::Color result;
if (delta) {
result.r = std::clamp(start->r + target.r * t, 0.0f, 255.0f);
result.g = std::clamp(start->g + target.g * t, 0.0f, 255.0f);
result.b = std::clamp(start->b + target.b * t, 0.0f, 255.0f);
result.a = std::clamp(start->a + target.a * t, 0.0f, 255.0f);
} else {
result.r = start->r + (target.r - start->r) * t;
result.g = start->g + (target.g - start->g) * t;
result.b = start->b + (target.b - start->b) * t;
result.a = start->a + (target.a - start->a) * t;
}
return result;
}
else if constexpr (std::is_same_v<T, sf::Vector2f>) {
// Interpolate vector
const sf::Vector2f* start = std::get_if<sf::Vector2f>(&startValue);
if (!start) return target;
if (delta) {
return sf::Vector2f(start->x + target.x * t,
start->y + target.y * t);
} else {
return sf::Vector2f(start->x + (target.x - start->x) * t,
start->y + (target.y - start->y) * t);
}
}
else if constexpr (std::is_same_v<T, std::string>) {
// For text, show characters based on t
const std::string* start = std::get_if<std::string>(&startValue);
if (!start) return target;
// If delta mode, append characters from target
if (delta) {
size_t chars = static_cast<size_t>(target.length() * t);
return *start + target.substr(0, chars);
} else {
// Transition from start text to target text
if (t < 0.5f) {
// First half: remove characters from start
size_t chars = static_cast<size_t>(start->length() * (1.0f - t * 2.0f));
return start->substr(0, chars);
} else {
// Second half: add characters to target
size_t chars = static_cast<size_t>(target.length() * ((t - 0.5f) * 2.0f));
return target.substr(0, chars);
}
}
}
return target; // Fallback
}, targetValue);
}
void Animation::applyValue(UIDrawable* target, const AnimationValue& value) {
if (!target) return;
std::visit([this, target](const auto& val) {
using T = std::decay_t<decltype(val)>;
if constexpr (std::is_same_v<T, float>) {
target->setProperty(targetProperty, val);
}
else if constexpr (std::is_same_v<T, int>) {
target->setProperty(targetProperty, val);
}
else if constexpr (std::is_same_v<T, sf::Color>) {
target->setProperty(targetProperty, val);
}
else if constexpr (std::is_same_v<T, sf::Vector2f>) {
target->setProperty(targetProperty, val);
}
else if constexpr (std::is_same_v<T, std::string>) {
target->setProperty(targetProperty, val);
}
}, value);
}
void Animation::applyValue(UIEntity* entity, const AnimationValue& value) {
if (!entity) return;
std::visit([this, entity](const auto& val) {
using T = std::decay_t<decltype(val)>;
if constexpr (std::is_same_v<T, float>) {
entity->setProperty(targetProperty, val);
}
else if constexpr (std::is_same_v<T, int>) {
entity->setProperty(targetProperty, val);
}
// Entities don't support other types yet
}, value);
}
void Animation::triggerCallback() {
if (!pythonCallback) return;
// Ensure we only trigger once
if (callbackTriggered) return;
callbackTriggered = true;
PyGILState_STATE gstate = PyGILState_Ensure();
// TODO: In future, create PyAnimation wrapper for this animation
// For now, pass None for both parameters
PyObject* args = PyTuple_New(2);
Py_INCREF(Py_None);
Py_INCREF(Py_None);
PyTuple_SetItem(args, 0, Py_None); // animation parameter
PyTuple_SetItem(args, 1, Py_None); // target parameter
PyObject* result = PyObject_CallObject(pythonCallback, args);
Py_DECREF(args);
if (!result) {
// Print error but don't crash
PyErr_Print();
PyErr_Clear(); // Clear the error state
} else {
Py_DECREF(result);
}
PyGILState_Release(gstate);
}
// Easing functions implementation
namespace EasingFunctions {
float linear(float t) {
return t;
}
float easeIn(float t) {
return t * t;
}
float easeOut(float t) {
return t * (2.0f - t);
}
float easeInOut(float t) {
return t < 0.5f ? 2.0f * t * t : -1.0f + (4.0f - 2.0f * t) * t;
}
// Quadratic
float easeInQuad(float t) {
return t * t;
}
float easeOutQuad(float t) {
return t * (2.0f - t);
}
float easeInOutQuad(float t) {
return t < 0.5f ? 2.0f * t * t : -1.0f + (4.0f - 2.0f * t) * t;
}
// Cubic
float easeInCubic(float t) {
return t * t * t;
}
float easeOutCubic(float t) {
float t1 = t - 1.0f;
return t1 * t1 * t1 + 1.0f;
}
float easeInOutCubic(float t) {
return t < 0.5f ? 4.0f * t * t * t : (t - 1.0f) * (2.0f * t - 2.0f) * (2.0f * t - 2.0f) + 1.0f;
}
// Quartic
float easeInQuart(float t) {
return t * t * t * t;
}
float easeOutQuart(float t) {
float t1 = t - 1.0f;
return 1.0f - t1 * t1 * t1 * t1;
}
float easeInOutQuart(float t) {
return t < 0.5f ? 8.0f * t * t * t * t : 1.0f - 8.0f * (t - 1.0f) * (t - 1.0f) * (t - 1.0f) * (t - 1.0f);
}
// Sine
float easeInSine(float t) {
return 1.0f - std::cos(t * M_PI / 2.0f);
}
float easeOutSine(float t) {
return std::sin(t * M_PI / 2.0f);
}
float easeInOutSine(float t) {
return 0.5f * (1.0f - std::cos(M_PI * t));
}
// Exponential
float easeInExpo(float t) {
return t == 0.0f ? 0.0f : std::pow(2.0f, 10.0f * (t - 1.0f));
}
float easeOutExpo(float t) {
return t == 1.0f ? 1.0f : 1.0f - std::pow(2.0f, -10.0f * t);
}
float easeInOutExpo(float t) {
if (t == 0.0f) return 0.0f;
if (t == 1.0f) return 1.0f;
if (t < 0.5f) {
return 0.5f * std::pow(2.0f, 20.0f * t - 10.0f);
} else {
return 1.0f - 0.5f * std::pow(2.0f, -20.0f * t + 10.0f);
}
}
// Circular
float easeInCirc(float t) {
return 1.0f - std::sqrt(1.0f - t * t);
}
float easeOutCirc(float t) {
float t1 = t - 1.0f;
return std::sqrt(1.0f - t1 * t1);
}
float easeInOutCirc(float t) {
if (t < 0.5f) {
return 0.5f * (1.0f - std::sqrt(1.0f - 4.0f * t * t));
} else {
return 0.5f * (std::sqrt(1.0f - (2.0f * t - 2.0f) * (2.0f * t - 2.0f)) + 1.0f);
}
}
// Elastic
float easeInElastic(float t) {
if (t == 0.0f) return 0.0f;
if (t == 1.0f) return 1.0f;
float p = 0.3f;
float a = 1.0f;
float s = p / 4.0f;
float t1 = t - 1.0f;
return -(a * std::pow(2.0f, 10.0f * t1) * std::sin((t1 - s) * (2.0f * M_PI) / p));
}
float easeOutElastic(float t) {
if (t == 0.0f) return 0.0f;
if (t == 1.0f) return 1.0f;
float p = 0.3f;
float a = 1.0f;
float s = p / 4.0f;
return a * std::pow(2.0f, -10.0f * t) * std::sin((t - s) * (2.0f * M_PI) / p) + 1.0f;
}
float easeInOutElastic(float t) {
if (t == 0.0f) return 0.0f;
if (t == 1.0f) return 1.0f;
float p = 0.45f;
float a = 1.0f;
float s = p / 4.0f;
if (t < 0.5f) {
float t1 = 2.0f * t - 1.0f;
return -0.5f * (a * std::pow(2.0f, 10.0f * t1) * std::sin((t1 - s) * (2.0f * M_PI) / p));
} else {
float t1 = 2.0f * t - 1.0f;
return a * std::pow(2.0f, -10.0f * t1) * std::sin((t1 - s) * (2.0f * M_PI) / p) * 0.5f + 1.0f;
}
}
// Back (overshooting)
float easeInBack(float t) {
const float s = 1.70158f;
return t * t * ((s + 1.0f) * t - s);
}
float easeOutBack(float t) {
const float s = 1.70158f;
float t1 = t - 1.0f;
return t1 * t1 * ((s + 1.0f) * t1 + s) + 1.0f;
}
float easeInOutBack(float t) {
const float s = 1.70158f * 1.525f;
if (t < 0.5f) {
return 0.5f * (4.0f * t * t * ((s + 1.0f) * 2.0f * t - s));
} else {
float t1 = 2.0f * t - 2.0f;
return 0.5f * (t1 * t1 * ((s + 1.0f) * t1 + s) + 2.0f);
}
}
// Bounce
float easeOutBounce(float t) {
if (t < 1.0f / 2.75f) {
return 7.5625f * t * t;
} else if (t < 2.0f / 2.75f) {
float t1 = t - 1.5f / 2.75f;
return 7.5625f * t1 * t1 + 0.75f;
} else if (t < 2.5f / 2.75f) {
float t1 = t - 2.25f / 2.75f;
return 7.5625f * t1 * t1 + 0.9375f;
} else {
float t1 = t - 2.625f / 2.75f;
return 7.5625f * t1 * t1 + 0.984375f;
}
}
float easeInBounce(float t) {
return 1.0f - easeOutBounce(1.0f - t);
}
float easeInOutBounce(float t) {
if (t < 0.5f) {
return 0.5f * easeInBounce(2.0f * t);
} else {
return 0.5f * easeOutBounce(2.0f * t - 1.0f) + 0.5f;
}
}
// Get easing function by name
EasingFunction getByName(const std::string& name) {
static std::unordered_map<std::string, EasingFunction> easingMap = {
{"linear", linear},
{"easeIn", easeIn},
{"easeOut", easeOut},
{"easeInOut", easeInOut},
{"easeInQuad", easeInQuad},
{"easeOutQuad", easeOutQuad},
{"easeInOutQuad", easeInOutQuad},
{"easeInCubic", easeInCubic},
{"easeOutCubic", easeOutCubic},
{"easeInOutCubic", easeInOutCubic},
{"easeInQuart", easeInQuart},
{"easeOutQuart", easeOutQuart},
{"easeInOutQuart", easeInOutQuart},
{"easeInSine", easeInSine},
{"easeOutSine", easeOutSine},
{"easeInOutSine", easeInOutSine},
{"easeInExpo", easeInExpo},
{"easeOutExpo", easeOutExpo},
{"easeInOutExpo", easeInOutExpo},
{"easeInCirc", easeInCirc},
{"easeOutCirc", easeOutCirc},
{"easeInOutCirc", easeInOutCirc},
{"easeInElastic", easeInElastic},
{"easeOutElastic", easeOutElastic},
{"easeInOutElastic", easeInOutElastic},
{"easeInBack", easeInBack},
{"easeOutBack", easeOutBack},
{"easeInOutBack", easeInOutBack},
{"easeInBounce", easeInBounce},
{"easeOutBounce", easeOutBounce},
{"easeInOutBounce", easeInOutBounce}
};
auto it = easingMap.find(name);
if (it != easingMap.end()) {
return it->second;
}
return linear; // Default to linear
}
} // namespace EasingFunctions
// AnimationManager implementation
AnimationManager& AnimationManager::getInstance() {
static AnimationManager instance;
return instance;
}
void AnimationManager::addAnimation(std::shared_ptr<Animation> animation) {
if (animation && animation->hasValidTarget()) {
if (isUpdating) {
// Defer adding during update to avoid iterator invalidation
pendingAnimations.push_back(animation);
} else {
activeAnimations.push_back(animation);
}
}
}
void AnimationManager::update(float deltaTime) {
// Set flag to defer new animations
isUpdating = true;
// Remove completed or invalid animations
activeAnimations.erase(
std::remove_if(activeAnimations.begin(), activeAnimations.end(),
[deltaTime](std::shared_ptr<Animation>& anim) {
return !anim || !anim->update(deltaTime);
}),
activeAnimations.end()
);
// Clear update flag
isUpdating = false;
// Add any animations that were created during update
if (!pendingAnimations.empty()) {
activeAnimations.insert(activeAnimations.end(),
pendingAnimations.begin(),
pendingAnimations.end());
pendingAnimations.clear();
}
}
void AnimationManager::clear(bool completeAnimations) {
if (completeAnimations) {
// Complete all animations before clearing
for (auto& anim : activeAnimations) {
if (anim) {
anim->complete();
}
}
}
activeAnimations.clear();
}

175
src/Animation.h Normal file
View File

@ -0,0 +1,175 @@
#pragma once
#include <string>
#include <functional>
#include <memory>
#include <variant>
#include <vector>
#include <SFML/Graphics.hpp>
#include "Python.h"
// Forward declarations
class UIDrawable;
class UIEntity;
// Forward declare namespace
namespace EasingFunctions {
float linear(float t);
}
// Easing function type
typedef std::function<float(float)> EasingFunction;
// Animation target value can be various types
typedef std::variant<
float, // Single float value
int, // Single integer value
std::vector<int>, // List of integers (for sprite animation)
sf::Color, // Color animation
sf::Vector2f, // Vector animation
std::string // String animation (for text)
> AnimationValue;
class Animation {
public:
// Constructor
Animation(const std::string& targetProperty,
const AnimationValue& targetValue,
float duration,
EasingFunction easingFunc = EasingFunctions::linear,
bool delta = false,
PyObject* callback = nullptr);
// Destructor - cleanup Python callback reference
~Animation();
// Apply this animation to a drawable
void start(std::shared_ptr<UIDrawable> target);
// Apply this animation to an entity (special case since Entity doesn't inherit from UIDrawable)
void startEntity(std::shared_ptr<UIEntity> target);
// Complete the animation immediately (jump to final value)
void complete();
// Update animation (called each frame)
// Returns true if animation is still running, false if complete
bool update(float deltaTime);
// Get current interpolated value
AnimationValue getCurrentValue() const;
// Check if animation has valid target
bool hasValidTarget() const;
// Clear the callback (called when PyAnimation is deallocated)
void clearCallback();
// Animation properties
std::string getTargetProperty() const { return targetProperty; }
float getDuration() const { return duration; }
float getElapsed() const { return elapsed; }
bool isComplete() const { return elapsed >= duration; }
bool isDelta() const { return delta; }
private:
std::string targetProperty; // Property name to animate (e.g., "x", "color.r", "sprite_number")
AnimationValue startValue; // Starting value (captured when animation starts)
AnimationValue targetValue; // Target value to animate to
float duration; // Animation duration in seconds
float elapsed = 0.0f; // Elapsed time
EasingFunction easingFunc; // Easing function to use
bool delta; // If true, targetValue is relative to start
// RAII: Use weak_ptr for safe target tracking
std::weak_ptr<UIDrawable> targetWeak;
std::weak_ptr<UIEntity> entityTargetWeak;
// Callback support
PyObject* pythonCallback = nullptr; // Python callback function (we own a reference)
bool callbackTriggered = false; // Ensure callback only fires once
PyObject* pyAnimationWrapper = nullptr; // Weak reference to PyAnimation if created from Python
// Python object cache support
uint64_t serial_number = 0;
// Helper to interpolate between values
AnimationValue interpolate(float t) const;
// Helper to apply value to target
void applyValue(UIDrawable* target, const AnimationValue& value);
void applyValue(UIEntity* entity, const AnimationValue& value);
// Trigger callback when animation completes
void triggerCallback();
};
// Easing functions library
namespace EasingFunctions {
// Basic easing functions
float linear(float t);
float easeIn(float t);
float easeOut(float t);
float easeInOut(float t);
// Advanced easing functions
float easeInQuad(float t);
float easeOutQuad(float t);
float easeInOutQuad(float t);
float easeInCubic(float t);
float easeOutCubic(float t);
float easeInOutCubic(float t);
float easeInQuart(float t);
float easeOutQuart(float t);
float easeInOutQuart(float t);
float easeInSine(float t);
float easeOutSine(float t);
float easeInOutSine(float t);
float easeInExpo(float t);
float easeOutExpo(float t);
float easeInOutExpo(float t);
float easeInCirc(float t);
float easeOutCirc(float t);
float easeInOutCirc(float t);
float easeInElastic(float t);
float easeOutElastic(float t);
float easeInOutElastic(float t);
float easeInBack(float t);
float easeOutBack(float t);
float easeInOutBack(float t);
float easeInBounce(float t);
float easeOutBounce(float t);
float easeInOutBounce(float t);
// Get easing function by name
EasingFunction getByName(const std::string& name);
}
// Animation manager to handle active animations
class AnimationManager {
public:
static AnimationManager& getInstance();
// Add an animation to be managed
void addAnimation(std::shared_ptr<Animation> animation);
// Update all animations
void update(float deltaTime);
// Clear all animations (optionally completing them first)
void clear(bool completeAnimations = false);
private:
AnimationManager() = default;
std::vector<std::shared_ptr<Animation>> activeAnimations;
std::vector<std::shared_ptr<Animation>> pendingAnimations; // Animations to add after update
bool isUpdating = false; // Flag to track if we're in update loop
};

172
src/CommandLineParser.cpp Normal file
View File

@ -0,0 +1,172 @@
#include "CommandLineParser.h"
#include <iostream>
#include <filesystem>
#include <algorithm>
CommandLineParser::CommandLineParser(int argc, char* argv[])
: argc(argc), argv(argv) {}
CommandLineParser::ParseResult CommandLineParser::parse(McRogueFaceConfig& config) {
ParseResult result;
current_arg = 1; // Reset for each parse
// Detect if running as Python interpreter
std::filesystem::path exec_name = std::filesystem::path(argv[0]).filename();
if (exec_name.string().find("python") == 0) {
config.headless = true;
config.python_mode = true;
}
while (current_arg < argc) {
std::string arg = argv[current_arg];
// Handle Python-style single-letter flags
if (arg == "-h" || arg == "--help") {
print_help();
result.should_exit = true;
result.exit_code = 0;
return result;
}
if (arg == "-V" || arg == "--version") {
print_version();
result.should_exit = true;
result.exit_code = 0;
return result;
}
// Python execution modes
if (arg == "-c") {
config.python_mode = true;
current_arg++;
if (current_arg >= argc) {
std::cerr << "Argument expected for the -c option" << std::endl;
result.should_exit = true;
result.exit_code = 1;
return result;
}
config.python_command = argv[current_arg];
current_arg++;
continue;
}
if (arg == "-m") {
config.python_mode = true;
current_arg++;
if (current_arg >= argc) {
std::cerr << "Argument expected for the -m option" << std::endl;
result.should_exit = true;
result.exit_code = 1;
return result;
}
config.python_module = argv[current_arg];
current_arg++;
// Collect remaining args as module args
while (current_arg < argc) {
config.script_args.push_back(argv[current_arg]);
current_arg++;
}
continue;
}
if (arg == "-i") {
config.interactive_mode = true;
config.python_mode = true;
current_arg++;
continue;
}
// McRogueFace specific flags
if (arg == "--headless") {
config.headless = true;
config.audio_enabled = false;
current_arg++;
continue;
}
if (arg == "--audio-off") {
config.audio_enabled = false;
current_arg++;
continue;
}
if (arg == "--audio-on") {
config.audio_enabled = true;
current_arg++;
continue;
}
if (arg == "--screenshot") {
config.take_screenshot = true;
current_arg++;
if (current_arg < argc && argv[current_arg][0] != '-') {
config.screenshot_path = argv[current_arg];
current_arg++;
} else {
config.screenshot_path = "screenshot.png";
}
continue;
}
if (arg == "--exec") {
current_arg++;
if (current_arg >= argc) {
std::cerr << "Argument expected for the --exec option" << std::endl;
result.should_exit = true;
result.exit_code = 1;
return result;
}
config.exec_scripts.push_back(argv[current_arg]);
config.python_mode = true;
current_arg++;
continue;
}
// If no flags matched, treat as positional argument (script name)
if (arg[0] != '-') {
config.script_path = arg;
config.python_mode = true;
current_arg++;
// Remaining args are script args
while (current_arg < argc) {
config.script_args.push_back(argv[current_arg]);
current_arg++;
}
break;
}
// Unknown flag
std::cerr << "Unknown option: " << arg << std::endl;
result.should_exit = true;
result.exit_code = 1;
return result;
}
return result;
}
void CommandLineParser::print_help() {
std::cout << "usage: mcrogueface [option] ... [-c cmd | -m mod | file | -] [arg] ...\n"
<< "Options:\n"
<< " -c cmd : program passed in as string (terminates option list)\n"
<< " -h : print this help message and exit (also --help)\n"
<< " -i : inspect interactively after running script\n"
<< " -m mod : run library module as a script (terminates option list)\n"
<< " -V : print the Python version number and exit (also --version)\n"
<< "\n"
<< "McRogueFace specific options:\n"
<< " --exec file : execute script before main program (can be used multiple times)\n"
<< " --headless : run without creating a window (implies --audio-off)\n"
<< " --audio-off : disable audio\n"
<< " --audio-on : enable audio (even in headless mode)\n"
<< " --screenshot [path] : take a screenshot in headless mode\n"
<< "\n"
<< "Arguments:\n"
<< " file : program read from script file\n"
<< " - : program read from stdin\n"
<< " arg ...: arguments passed to program in sys.argv[1:]\n";
}
void CommandLineParser::print_version() {
std::cout << "Python 3.12.0 (McRogueFace embedded)\n";
}

30
src/CommandLineParser.h Normal file
View File

@ -0,0 +1,30 @@
#ifndef COMMAND_LINE_PARSER_H
#define COMMAND_LINE_PARSER_H
#include <string>
#include <vector>
#include "McRogueFaceConfig.h"
class CommandLineParser {
public:
struct ParseResult {
bool should_exit = false;
int exit_code = 0;
};
CommandLineParser(int argc, char* argv[]);
ParseResult parse(McRogueFaceConfig& config);
private:
int argc;
char** argv;
int current_arg = 1; // Skip program name
bool has_flag(const std::string& short_flag, const std::string& long_flag = "");
std::string get_next_arg(const std::string& flag_name);
void parse_positional_args(McRogueFaceConfig& config);
void print_help();
void print_version();
};
#endif // COMMAND_LINE_PARSER_H

View File

@ -4,67 +4,304 @@
#include "PyScene.h" #include "PyScene.h"
#include "UITestScene.h" #include "UITestScene.h"
#include "Resources.h" #include "Resources.h"
#include "Animation.h"
#include "Timer.h"
#include <cmath>
GameEngine::GameEngine() GameEngine::GameEngine() : GameEngine(McRogueFaceConfig{})
{
}
GameEngine::GameEngine(const McRogueFaceConfig& cfg)
: config(cfg), headless(cfg.headless)
{ {
Resources::font.loadFromFile("./assets/JetbrainsMono.ttf"); Resources::font.loadFromFile("./assets/JetbrainsMono.ttf");
Resources::game = this; Resources::game = this;
window_title = "McRogueFace - 7DRL 2024 Engine Demo"; window_title = "McRogueFace Engine";
window.create(sf::VideoMode(1024, 768), window_title, sf::Style::Titlebar | sf::Style::Close);
visible = window.getDefaultView(); // Initialize rendering based on headless mode
window.setFramerateLimit(30); if (headless) {
headless_renderer = std::make_unique<HeadlessRenderer>();
if (!headless_renderer->init(1024, 768)) {
throw std::runtime_error("Failed to initialize headless renderer");
}
render_target = &headless_renderer->getRenderTarget();
} else {
window = std::make_unique<sf::RenderWindow>();
window->create(sf::VideoMode(1024, 768), window_title, sf::Style::Titlebar | sf::Style::Close | sf::Style::Resize);
window->setFramerateLimit(60);
render_target = window.get();
}
visible = render_target->getDefaultView();
// Initialize the game view
gameView.setSize(static_cast<float>(gameResolution.x), static_cast<float>(gameResolution.y));
// Use integer center coordinates for pixel-perfect rendering
gameView.setCenter(std::floor(gameResolution.x / 2.0f), std::floor(gameResolution.y / 2.0f));
updateViewport();
scene = "uitest"; scene = "uitest";
scenes["uitest"] = new UITestScene(this); scenes["uitest"] = new UITestScene(this);
McRFPy_API::game = this; McRFPy_API::game = this;
// Initialize profiler overlay
profilerOverlay = new ProfilerOverlay(Resources::font);
// Only load game.py if no custom script/command/module/exec is specified
bool should_load_game = config.script_path.empty() &&
config.python_command.empty() &&
config.python_module.empty() &&
config.exec_scripts.empty() &&
!config.interactive_mode &&
!config.python_mode;
if (should_load_game) {
if (!Py_IsInitialized()) {
McRFPy_API::api_init(); McRFPy_API::api_init();
}
McRFPy_API::executePyString("import mcrfpy"); McRFPy_API::executePyString("import mcrfpy");
McRFPy_API::executeScript("scripts/game.py"); McRFPy_API::executeScript("scripts/game.py");
}
// Execute any --exec scripts in order
if (!config.exec_scripts.empty()) {
if (!Py_IsInitialized()) {
McRFPy_API::api_init();
}
McRFPy_API::executePyString("import mcrfpy");
for (const auto& exec_script : config.exec_scripts) {
std::cout << "Executing script: " << exec_script << std::endl;
McRFPy_API::executeScript(exec_script.string());
}
std::cout << "All --exec scripts completed" << std::endl;
}
clock.restart(); clock.restart();
runtime.restart(); runtime.restart();
} }
GameEngine::~GameEngine()
{
cleanup();
for (auto& [name, scene] : scenes) {
delete scene;
}
delete profilerOverlay;
}
void GameEngine::cleanup()
{
if (cleaned_up) return;
cleaned_up = true;
// Clear all animations first (RAII handles invalidation)
AnimationManager::getInstance().clear();
// Clear Python references before destroying C++ objects
// Clear all timers (they hold Python callables)
timers.clear();
// Clear McRFPy_API's reference to this game engine
if (McRFPy_API::game == this) {
McRFPy_API::game = nullptr;
}
// Force close the window if it's still open
if (window && window->isOpen()) {
window->close();
}
}
Scene* GameEngine::currentScene() { return scenes[scene]; } Scene* GameEngine::currentScene() { return scenes[scene]; }
void GameEngine::changeScene(std::string s) void GameEngine::changeScene(std::string s)
{ {
/*std::cout << "Current scene is now '" << s << "'\n";*/ changeScene(s, TransitionType::None, 0.0f);
if (scenes.find(s) != scenes.end()) }
scene = s;
void GameEngine::changeScene(std::string sceneName, TransitionType transitionType, float duration)
{
if (scenes.find(sceneName) == scenes.end())
{
std::cout << "Attempted to change to a scene that doesn't exist (`" << sceneName << "`)" << std::endl;
return;
}
if (transitionType == TransitionType::None || duration <= 0.0f)
{
// Immediate scene change
std::string old_scene = scene;
scene = sceneName;
// Trigger Python scene lifecycle events
McRFPy_API::triggerSceneChange(old_scene, sceneName);
}
else else
std::cout << "Attempted to change to a scene that doesn't exist (`" << s << "`)" << std::endl; {
// Start transition
transition.start(transitionType, scene, sceneName, duration);
// Render current scene to texture
sf::RenderTarget* original_target = render_target;
render_target = transition.oldSceneTexture.get();
transition.oldSceneTexture->clear();
currentScene()->render();
transition.oldSceneTexture->display();
// Change to new scene
std::string old_scene = scene;
scene = sceneName;
// Render new scene to texture
render_target = transition.newSceneTexture.get();
transition.newSceneTexture->clear();
currentScene()->render();
transition.newSceneTexture->display();
// Restore original render target and scene
render_target = original_target;
scene = old_scene;
}
} }
void GameEngine::quit() { running = false; } void GameEngine::quit() { running = false; }
void GameEngine::setPause(bool p) { paused = p; } void GameEngine::setPause(bool p) { paused = p; }
sf::Font & GameEngine::getFont() { /*return font; */ return Resources::font; } sf::Font & GameEngine::getFont() { /*return font; */ return Resources::font; }
sf::RenderWindow & GameEngine::getWindow() { return window; } sf::RenderWindow & GameEngine::getWindow() {
if (!window) {
throw std::runtime_error("Window not available in headless mode");
}
return *window;
}
sf::RenderTarget & GameEngine::getRenderTarget() {
return *render_target;
}
void GameEngine::createScene(std::string s) { scenes[s] = new PyScene(this); } void GameEngine::createScene(std::string s) { scenes[s] = new PyScene(this); }
void GameEngine::setWindowScale(float multiplier) void GameEngine::setWindowScale(float multiplier)
{ {
window.setSize(sf::Vector2u(1024 * multiplier, 768 * multiplier)); // 7DRL 2024: window scaling if (!headless && window) {
//window.create(sf::VideoMode(1024 * multiplier, 768 * multiplier), window_title, sf::Style::Titlebar | sf::Style::Close); window->setSize(sf::Vector2u(gameResolution.x * multiplier, gameResolution.y * multiplier));
updateViewport();
}
} }
void GameEngine::run() void GameEngine::run()
{ {
//std::cout << "GameEngine::run() starting main loop..." << std::endl;
float fps = 0.0; float fps = 0.0;
frameTime = 0.016f; // Initialize to ~60 FPS
clock.restart(); clock.restart();
while (running) while (running)
{ {
// Reset per-frame metrics
metrics.resetPerFrame();
currentScene()->update(); currentScene()->update();
testTimers(); testTimers();
// Update Python scenes
{
ScopedTimer pyTimer(metrics.pythonScriptTime);
McRFPy_API::updatePythonScenes(frameTime);
}
// Update animations (only if frameTime is valid)
if (frameTime > 0.0f && frameTime < 1.0f) {
ScopedTimer animTimer(metrics.animationTime);
AnimationManager::getInstance().update(frameTime);
}
if (!headless) {
sUserInput(); sUserInput();
}
if (!paused) if (!paused)
{ {
} }
// Handle scene transitions
if (transition.type != TransitionType::None)
{
transition.update(frameTime);
if (transition.isComplete())
{
// Transition complete - finalize scene change
scene = transition.toScene;
transition.type = TransitionType::None;
// Trigger Python scene lifecycle events
McRFPy_API::triggerSceneChange(transition.fromScene, transition.toScene);
}
else
{
// Render transition
render_target->clear();
transition.render(*render_target);
}
}
else
{
// Normal scene rendering
currentScene()->render(); currentScene()->render();
}
// Update and render profiler overlay (if enabled)
if (profilerOverlay && !headless) {
profilerOverlay->update(metrics);
profilerOverlay->render(*render_target);
}
// Display the frame
if (headless) {
headless_renderer->display();
// Take screenshot if requested
if (config.take_screenshot) {
headless_renderer->saveScreenshot(config.screenshot_path.empty() ? "screenshot.png" : config.screenshot_path);
config.take_screenshot = false; // Only take one screenshot
}
} else {
window->display();
}
currentFrame++; currentFrame++;
frameTime = clock.restart().asSeconds(); frameTime = clock.restart().asSeconds();
fps = 1 / frameTime; fps = 1 / frameTime;
window.setTitle(window_title + " " + std::to_string(fps) + " FPS");
// Update profiling metrics
metrics.updateFrameTime(frameTime * 1000.0f); // Convert to milliseconds
int whole_fps = metrics.fps;
int tenth_fps = (metrics.fps * 10) % 10;
if (!headless && window) {
window->setTitle(window_title);
} }
// In windowed mode, check if window was closed
if (!headless && window && !window->isOpen()) {
running = false;
}
// In headless exec mode, auto-exit when no timers remain
if (config.auto_exit_after_exec && timers.empty()) {
running = false;
}
}
// Clean up before exiting the run loop
cleanup();
}
std::shared_ptr<Timer> GameEngine::getTimer(const std::string& name)
{
auto it = timers.find(name);
if (it != timers.end()) {
return it->second;
}
return nullptr;
} }
void GameEngine::manageTimer(std::string name, PyObject* target, int interval) void GameEngine::manageTimer(std::string name, PyObject* target, int interval)
@ -76,7 +313,7 @@ void GameEngine::manageTimer(std::string name, PyObject* target, int interval)
{ {
// Delete: Overwrite existing timer with one that calls None. This will be deleted in the next timer check // Delete: Overwrite existing timer with one that calls None. This will be deleted in the next timer check
// see gitea issue #4: this allows for a timer to be deleted during its own call to itself // see gitea issue #4: this allows for a timer to be deleted during its own call to itself
timers[name] = std::make_shared<PyTimerCallable>(Py_None, 1000, runtime.getElapsedTime().asMilliseconds()); timers[name] = std::make_shared<Timer>(Py_None, 1000, runtime.getElapsedTime().asMilliseconds());
return; return;
} }
} }
@ -85,7 +322,7 @@ void GameEngine::manageTimer(std::string name, PyObject* target, int interval)
std::cout << "Refusing to initialize timer to None. It's not an error, it's just pointless." << std::endl; std::cout << "Refusing to initialize timer to None. It's not an error, it's just pointless." << std::endl;
return; return;
} }
timers[name] = std::make_shared<PyTimerCallable>(target, interval, runtime.getElapsedTime().asMilliseconds()); timers[name] = std::make_shared<Timer>(target, interval, runtime.getElapsedTime().asMilliseconds());
} }
void GameEngine::testTimers() void GameEngine::testTimers()
@ -96,7 +333,8 @@ void GameEngine::testTimers()
{ {
it->second->test(now); it->second->test(now);
if (it->second->isNone()) // Remove timers that have been cancelled or are one-shot and fired
if (!it->second->getCallback() || it->second->getCallback() == Py_None)
{ {
it = timers.erase(it); it = timers.erase(it);
} }
@ -105,29 +343,27 @@ void GameEngine::testTimers()
} }
} }
void GameEngine::sUserInput() void GameEngine::processEvent(const sf::Event& event)
{
sf::Event event;
while (window.pollEvent(event))
{ {
std::string actionType; std::string actionType;
int actionCode = 0; int actionCode = 0;
if (event.type == sf::Event::Closed) { running = false; continue; } if (event.type == sf::Event::Closed) { running = false; return; }
// TODO: add resize event to Scene to react; call it after constructor too, maybe
// Handle F3 for profiler overlay toggle
if (event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::F3) {
if (profilerOverlay) {
profilerOverlay->toggle();
}
return;
}
// Handle window resize events
else if (event.type == sf::Event::Resized) { else if (event.type == sf::Event::Resized) {
continue; // 7DRL short circuit. Resizing manually disabled // Update the viewport to handle the new window size
/* updateViewport();
sf::FloatRect area(0.f, 0.f, event.size.width, event.size.height);
//sf::FloatRect area(0.f, 0.f, 1024.f, 768.f); // 7DRL 2024: attempt to set scale appropriately // Notify Python scenes about the resize
//sf::FloatRect area(0.f, 0.f, event.size.width, event.size.width * 0.75); McRFPy_API::triggerResize(event.size.width, event.size.height);
visible = sf::View(area);
window.setView(visible);
//window.setSize(sf::Vector2u(event.size.width, event.size.width * 0.75)); // 7DRL 2024: window scaling
std::cout << "Visible area set to (0, 0, " << event.size.width << ", " << event.size.height <<")"<<std::endl;
actionType = "resize";
//window.setSize(sf::Vector2u(event.size.width, event.size.width * 0.75)); // 7DRL 2024: window scaling
*/
} }
else if (event.type == sf::Event::KeyPressed || event.type == sf::Event::MouseButtonPressed || event.type == sf::Event::MouseWheelScrolled) actionType = "start"; else if (event.type == sf::Event::KeyPressed || event.type == sf::Event::MouseButtonPressed || event.type == sf::Event::MouseWheelScrolled) actionType = "start";
@ -139,52 +375,34 @@ void GameEngine::sUserInput()
actionCode = ActionCode::keycode(event.key.code); actionCode = ActionCode::keycode(event.key.code);
else if (event.type == sf::Event::MouseWheelScrolled) else if (event.type == sf::Event::MouseWheelScrolled)
{ {
// //sf::Mouse::Wheel w = event.MouseWheelScrollEvent.wheel;
if (event.mouseWheelScroll.wheel == sf::Mouse::VerticalWheel) if (event.mouseWheelScroll.wheel == sf::Mouse::VerticalWheel)
{ {
int delta = 1; int delta = 1;
if (event.mouseWheelScroll.delta < 0) delta = -1; if (event.mouseWheelScroll.delta < 0) delta = -1;
actionCode = ActionCode::keycode(event.mouseWheelScroll.wheel, delta ); actionCode = ActionCode::keycode(event.mouseWheelScroll.wheel, delta );
/*
std::cout << "[GameEngine] Generated MouseWheel code w(" << (int)event.mouseWheelScroll.wheel << ") d(" << event.mouseWheelScroll.delta << ") D(" << delta << ") = " << actionCode << std::endl;
std::cout << " test decode: isMouseWheel=" << ActionCode::isMouseWheel(actionCode) << ", wheel=" << ActionCode::wheel(actionCode) << ", delta=" << ActionCode::delta(actionCode) << std::endl;
std::cout << " math test: actionCode && WHEEL_NEG -> " << (actionCode && ActionCode::WHEEL_NEG) << "; actionCode && WHEEL_DEL -> " << (actionCode && ActionCode::WHEEL_DEL) << ";" << std::endl;
*/
} }
// float d = event.MouseWheelScrollEvent.delta;
// actionCode = ActionCode::keycode(0, d);
} }
else else
continue; return;
//std::cout << "Event produced action code " << actionCode << ": " << actionType << std::endl;
if (currentScene()->hasAction(actionCode)) if (currentScene()->hasAction(actionCode))
{ {
std::string name = currentScene()->action(actionCode); std::string name = currentScene()->action(actionCode);
currentScene()->doAction(name, actionType); currentScene()->doAction(name, actionType);
} }
else if (currentScene()->key_callable) else if (currentScene()->key_callable &&
(event.type == sf::Event::KeyPressed || event.type == sf::Event::KeyReleased))
{ {
currentScene()->key_callable->call(ActionCode::key_str(event.key.code), actionType); currentScene()->key_callable->call(ActionCode::key_str(event.key.code), actionType);
/*
PyObject* args = Py_BuildValue("(ss)", ActionCode::key_str(event.key.code).c_str(), actionType.c_str());
PyObject* retval = PyObject_Call(currentScene()->key_callable, args, NULL);
if (!retval)
{
std::cout << "key_callable has raised an exception. It's going to STDERR and being dropped:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else if (retval != Py_None)
{
std::cout << "key_callable returned a non-None value. It's not an error, it's just not being saved or used." << std::endl;
} }
*/
} }
else
void GameEngine::sUserInput()
{ {
//std::cout << "[GameEngine] Action not registered for input: " << actionCode << ": " << actionType << std::endl; sf::Event event;
} while (window && window->pollEvent(event))
{
processEvent(event);
} }
} }
@ -205,3 +423,123 @@ std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>> GameEngine::scene_ui(s
if (scenes.count(target) == 0) return NULL; if (scenes.count(target) == 0) return NULL;
return scenes[target]->ui_elements; return scenes[target]->ui_elements;
} }
void GameEngine::setWindowTitle(const std::string& title)
{
window_title = title;
if (!headless && window) {
window->setTitle(title);
}
}
void GameEngine::setVSync(bool enabled)
{
vsync_enabled = enabled;
if (!headless && window) {
window->setVerticalSyncEnabled(enabled);
}
}
void GameEngine::setFramerateLimit(unsigned int limit)
{
framerate_limit = limit;
if (!headless && window) {
window->setFramerateLimit(limit);
}
}
void GameEngine::setGameResolution(unsigned int width, unsigned int height) {
gameResolution = sf::Vector2u(width, height);
gameView.setSize(static_cast<float>(width), static_cast<float>(height));
// Use integer center coordinates for pixel-perfect rendering
gameView.setCenter(std::floor(width / 2.0f), std::floor(height / 2.0f));
updateViewport();
}
void GameEngine::setViewportMode(ViewportMode mode) {
viewportMode = mode;
updateViewport();
}
std::string GameEngine::getViewportModeString() const {
switch (viewportMode) {
case ViewportMode::Center: return "center";
case ViewportMode::Stretch: return "stretch";
case ViewportMode::Fit: return "fit";
}
return "unknown";
}
void GameEngine::updateViewport() {
if (!render_target) return;
auto windowSize = render_target->getSize();
switch (viewportMode) {
case ViewportMode::Center: {
// 1:1 pixels, centered in window
float viewportWidth = std::min(static_cast<float>(gameResolution.x), static_cast<float>(windowSize.x));
float viewportHeight = std::min(static_cast<float>(gameResolution.y), static_cast<float>(windowSize.y));
// Floor offsets to ensure integer pixel alignment
float offsetX = std::floor((windowSize.x - viewportWidth) / 2.0f);
float offsetY = std::floor((windowSize.y - viewportHeight) / 2.0f);
gameView.setViewport(sf::FloatRect(
offsetX / windowSize.x,
offsetY / windowSize.y,
viewportWidth / windowSize.x,
viewportHeight / windowSize.y
));
break;
}
case ViewportMode::Stretch: {
// Fill entire window, ignore aspect ratio
gameView.setViewport(sf::FloatRect(0, 0, 1, 1));
break;
}
case ViewportMode::Fit: {
// Maintain aspect ratio with black bars
float windowAspect = static_cast<float>(windowSize.x) / windowSize.y;
float gameAspect = static_cast<float>(gameResolution.x) / gameResolution.y;
float viewportWidth, viewportHeight;
float offsetX = 0, offsetY = 0;
if (windowAspect > gameAspect) {
// Window is wider - black bars on sides
// Calculate viewport size in pixels and floor for pixel-perfect scaling
float pixelHeight = static_cast<float>(windowSize.y);
float pixelWidth = std::floor(pixelHeight * gameAspect);
viewportHeight = 1.0f;
viewportWidth = pixelWidth / windowSize.x;
offsetX = (1.0f - viewportWidth) / 2.0f;
} else {
// Window is taller - black bars on top/bottom
// Calculate viewport size in pixels and floor for pixel-perfect scaling
float pixelWidth = static_cast<float>(windowSize.x);
float pixelHeight = std::floor(pixelWidth / gameAspect);
viewportWidth = 1.0f;
viewportHeight = pixelHeight / windowSize.y;
offsetY = (1.0f - viewportHeight) / 2.0f;
}
gameView.setViewport(sf::FloatRect(offsetX, offsetY, viewportWidth, viewportHeight));
break;
}
}
// Apply the view
render_target->setView(gameView);
}
sf::Vector2f GameEngine::windowToGameCoords(const sf::Vector2f& windowPos) const {
if (!render_target) return windowPos;
// Convert window coordinates to game coordinates using the view
return render_target->mapPixelToCoords(sf::Vector2i(windowPos), gameView);
}

View File

@ -6,10 +6,31 @@
#include "IndexTexture.h" #include "IndexTexture.h"
#include "Timer.h" #include "Timer.h"
#include "PyCallable.h" #include "PyCallable.h"
#include "McRogueFaceConfig.h"
#include "HeadlessRenderer.h"
#include "SceneTransition.h"
#include "Profiler.h"
#include <memory>
#include <sstream>
class GameEngine class GameEngine
{ {
sf::RenderWindow window; public:
// Forward declare nested class so private section can use it
class ProfilerOverlay;
// Viewport modes (moved here so private section can use it)
enum class ViewportMode {
Center, // 1:1 pixels, viewport centered in window
Stretch, // viewport size = window size, doesn't respect aspect ratio
Fit // maintains original aspect ratio, leaves black bars
};
private:
std::unique_ptr<sf::RenderWindow> window;
std::unique_ptr<HeadlessRenderer> headless_renderer;
sf::RenderTarget* render_target;
sf::Font font; sf::Font font;
std::map<std::string, Scene*> scenes; std::map<std::string, Scene*> scenes;
bool running = true; bool running = true;
@ -20,28 +41,135 @@ class GameEngine
float frameTime; float frameTime;
std::string window_title; std::string window_title;
sf::Clock runtime; bool headless = false;
//std::map<std::string, Timer> timers; McRogueFaceConfig config;
std::map<std::string, std::shared_ptr<PyTimerCallable>> timers; bool cleaned_up = false;
// Window state tracking
bool vsync_enabled = false;
unsigned int framerate_limit = 60;
// Scene transition state
SceneTransition transition;
// Viewport system
sf::Vector2u gameResolution{1024, 768}; // Fixed game resolution
sf::View gameView; // View for the game content
ViewportMode viewportMode = ViewportMode::Fit;
// Profiling overlay
bool showProfilerOverlay = false; // F3 key toggles this
int overlayUpdateCounter = 0; // Only update overlay every N frames
ProfilerOverlay* profilerOverlay = nullptr; // The actual overlay renderer
void updateViewport();
void testTimers(); void testTimers();
public: public:
sf::Clock runtime;
std::map<std::string, std::shared_ptr<Timer>> timers;
std::string scene; std::string scene;
// Profiling metrics
struct ProfilingMetrics {
float frameTime = 0.0f; // Current frame time in milliseconds
float avgFrameTime = 0.0f; // Average frame time over last N frames
int fps = 0; // Frames per second
int drawCalls = 0; // Draw calls per frame
int uiElements = 0; // Number of UI elements rendered
int visibleElements = 0; // Number of visible elements
// Detailed timing breakdowns (added for profiling system)
float gridRenderTime = 0.0f; // Time spent rendering grids (ms)
float entityRenderTime = 0.0f; // Time spent rendering entities (ms)
float fovOverlayTime = 0.0f; // Time spent rendering FOV overlays (ms)
float pythonScriptTime = 0.0f; // Time spent in Python callbacks (ms)
float animationTime = 0.0f; // Time spent updating animations (ms)
// Grid-specific metrics
int gridCellsRendered = 0; // Number of grid cells drawn this frame
int entitiesRendered = 0; // Number of entities drawn this frame
int totalEntities = 0; // Total entities in scene
// Frame time history for averaging
static constexpr int HISTORY_SIZE = 60;
float frameTimeHistory[HISTORY_SIZE] = {0};
int historyIndex = 0;
void updateFrameTime(float deltaMs) {
frameTime = deltaMs;
frameTimeHistory[historyIndex] = deltaMs;
historyIndex = (historyIndex + 1) % HISTORY_SIZE;
// Calculate average
float sum = 0.0f;
for (int i = 0; i < HISTORY_SIZE; ++i) {
sum += frameTimeHistory[i];
}
avgFrameTime = sum / HISTORY_SIZE;
fps = avgFrameTime > 0 ? static_cast<int>(1000.0f / avgFrameTime) : 0;
}
void resetPerFrame() {
drawCalls = 0;
uiElements = 0;
visibleElements = 0;
// Reset per-frame timing metrics
gridRenderTime = 0.0f;
entityRenderTime = 0.0f;
fovOverlayTime = 0.0f;
pythonScriptTime = 0.0f;
animationTime = 0.0f;
// Reset per-frame counters
gridCellsRendered = 0;
entitiesRendered = 0;
totalEntities = 0;
}
} metrics;
GameEngine(); GameEngine();
GameEngine(const McRogueFaceConfig& cfg);
~GameEngine();
Scene* currentScene(); Scene* currentScene();
void changeScene(std::string); void changeScene(std::string);
void changeScene(std::string sceneName, TransitionType transitionType, float duration);
void createScene(std::string); void createScene(std::string);
void quit(); void quit();
void setPause(bool); void setPause(bool);
sf::Font & getFont(); sf::Font & getFont();
sf::RenderWindow & getWindow(); sf::RenderWindow & getWindow();
sf::RenderTarget & getRenderTarget();
sf::RenderTarget* getRenderTargetPtr() { return render_target; }
void run(); void run();
void sUserInput(); void sUserInput();
void cleanup(); // Clean up Python references before destruction
int getFrame() { return currentFrame; } int getFrame() { return currentFrame; }
float getFrameTime() { return frameTime; } float getFrameTime() { return frameTime; }
sf::View getView() { return visible; } sf::View getView() { return visible; }
void manageTimer(std::string, PyObject*, int); void manageTimer(std::string, PyObject*, int);
std::shared_ptr<Timer> getTimer(const std::string& name);
void setWindowScale(float); void setWindowScale(float);
bool isHeadless() const { return headless; }
void processEvent(const sf::Event& event);
// Window property accessors
const std::string& getWindowTitle() const { return window_title; }
void setWindowTitle(const std::string& title);
bool getVSync() const { return vsync_enabled; }
void setVSync(bool enabled);
unsigned int getFramerateLimit() const { return framerate_limit; }
void setFramerateLimit(unsigned int limit);
// Viewport system
void setGameResolution(unsigned int width, unsigned int height);
sf::Vector2u getGameResolution() const { return gameResolution; }
void setViewportMode(ViewportMode mode);
ViewportMode getViewportMode() const { return viewportMode; }
std::string getViewportModeString() const;
sf::Vector2f windowToGameCoords(const sf::Vector2f& windowPos) const;
// global textures for scripts to access // global textures for scripts to access
std::vector<IndexTexture> textures; std::vector<IndexTexture> textures;
@ -53,3 +181,28 @@ public:
std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>> scene_ui(std::string scene); std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>> scene_ui(std::string scene);
}; };
/**
* @brief Visual overlay that displays real-time profiling metrics
*/
class GameEngine::ProfilerOverlay {
private:
sf::Font& font;
sf::Text text;
sf::RectangleShape background;
bool visible;
int updateInterval;
int frameCounter;
sf::Color getPerformanceColor(float frameTimeMs);
std::string formatFloat(float value, int precision = 1);
std::string formatPercentage(float part, float total);
public:
ProfilerOverlay(sf::Font& fontRef);
void toggle();
void setVisible(bool vis);
bool isVisible() const;
void update(const ProfilingMetrics& metrics);
void render(sf::RenderTarget& target);
};

27
src/HeadlessRenderer.cpp Normal file
View File

@ -0,0 +1,27 @@
#include "HeadlessRenderer.h"
#include <iostream>
bool HeadlessRenderer::init(int width, int height) {
if (!render_texture.create(width, height)) {
std::cerr << "Failed to create headless render texture" << std::endl;
return false;
}
return true;
}
sf::RenderTarget& HeadlessRenderer::getRenderTarget() {
return render_texture;
}
void HeadlessRenderer::saveScreenshot(const std::string& path) {
sf::Image screenshot = render_texture.getTexture().copyToImage();
if (!screenshot.saveToFile(path)) {
std::cerr << "Failed to save screenshot to: " << path << std::endl;
} else {
std::cout << "Screenshot saved to: " << path << std::endl;
}
}
void HeadlessRenderer::display() {
render_texture.display();
}

20
src/HeadlessRenderer.h Normal file
View File

@ -0,0 +1,20 @@
#ifndef HEADLESS_RENDERER_H
#define HEADLESS_RENDERER_H
#include <SFML/Graphics.hpp>
#include <memory>
#include <string>
class HeadlessRenderer {
private:
sf::RenderTexture render_texture;
public:
bool init(int width = 1024, int height = 768);
sf::RenderTarget& getRenderTarget();
void saveScreenshot(const std::string& path);
void display(); // Finalize the current frame
bool isOpen() const { return true; } // Always "open" in headless mode
};
#endif // HEADLESS_RENDERER_H

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@
#include "PyFont.h" #include "PyFont.h"
#include "PyTexture.h" #include "PyTexture.h"
#include "McRogueFaceConfig.h"
class GameEngine; // forward declared (circular members) class GameEngine; // forward declared (circular members)
@ -27,19 +28,18 @@ public:
//static void setSpriteTexture(int); //static void setSpriteTexture(int);
inline static GameEngine* game; inline static GameEngine* game;
static void api_init(); static void api_init();
static void api_init(const McRogueFaceConfig& config, int argc, char** argv);
static PyStatus init_python_with_config(const McRogueFaceConfig& config, int argc, char** argv);
static void api_shutdown(); static void api_shutdown();
// Python API functionality - use mcrfpy.* in scripts // Python API functionality - use mcrfpy.* in scripts
//static PyObject* _drawSprite(PyObject*, PyObject*); //static PyObject* _drawSprite(PyObject*, PyObject*);
static void REPL_device(FILE * fp, const char *filename); static void REPL_device(FILE * fp, const char *filename);
static void REPL(); static void REPL();
static std::vector<sf::SoundBuffer> soundbuffers; static std::vector<sf::SoundBuffer>* soundbuffers;
static sf::Music music; static sf::Music* music;
static sf::Sound sfx; static sf::Sound* sfx;
static std::map<std::string, PyObject*> callbacks;
static PyObject* _registerPyAction(PyObject*, PyObject*);
static PyObject* _registerInputAction(PyObject*, PyObject*);
static PyObject* _createSoundBuffer(PyObject*, PyObject*); static PyObject* _createSoundBuffer(PyObject*, PyObject*);
static PyObject* _loadMusic(PyObject*, PyObject*); static PyObject* _loadMusic(PyObject*, PyObject*);
@ -66,12 +66,23 @@ public:
// accept keyboard input from scene // accept keyboard input from scene
static sf::Vector2i cursor_position; static sf::Vector2i cursor_position;
static void player_input(int, int);
static void computerTurn();
static void playerTurn();
static void doAction(std::string);
static void executeScript(std::string); static void executeScript(std::string);
static void executePyString(std::string); static void executePyString(std::string);
// Helper to mark scenes as needing z_index resort
static void markSceneNeedsSort();
// Name-based finding methods
static PyObject* _find(PyObject*, PyObject*);
static PyObject* _findAll(PyObject*, PyObject*);
// Profiling/metrics
static PyObject* _getMetrics(PyObject*, PyObject*);
// Scene lifecycle management for Python Scene objects
static void triggerSceneChange(const std::string& from_scene, const std::string& to_scene);
static void updatePythonScenes(float dt);
static void triggerResize(int width, int height);
}; };

817
src/McRFPy_Automation.cpp Normal file
View File

@ -0,0 +1,817 @@
#include "McRFPy_Automation.h"
#include "McRFPy_API.h"
#include "GameEngine.h"
#include <fstream>
#include <iostream>
#include <sstream>
#include <unordered_map>
// Helper function to get game engine
GameEngine* McRFPy_Automation::getGameEngine() {
return McRFPy_API::game;
}
// Sleep helper
void McRFPy_Automation::sleep_ms(int milliseconds) {
std::this_thread::sleep_for(std::chrono::milliseconds(milliseconds));
}
// Convert string to SFML key code
sf::Keyboard::Key McRFPy_Automation::stringToKey(const std::string& keyName) {
static const std::unordered_map<std::string, sf::Keyboard::Key> keyMap = {
// Letters
{"a", sf::Keyboard::A}, {"b", sf::Keyboard::B}, {"c", sf::Keyboard::C},
{"d", sf::Keyboard::D}, {"e", sf::Keyboard::E}, {"f", sf::Keyboard::F},
{"g", sf::Keyboard::G}, {"h", sf::Keyboard::H}, {"i", sf::Keyboard::I},
{"j", sf::Keyboard::J}, {"k", sf::Keyboard::K}, {"l", sf::Keyboard::L},
{"m", sf::Keyboard::M}, {"n", sf::Keyboard::N}, {"o", sf::Keyboard::O},
{"p", sf::Keyboard::P}, {"q", sf::Keyboard::Q}, {"r", sf::Keyboard::R},
{"s", sf::Keyboard::S}, {"t", sf::Keyboard::T}, {"u", sf::Keyboard::U},
{"v", sf::Keyboard::V}, {"w", sf::Keyboard::W}, {"x", sf::Keyboard::X},
{"y", sf::Keyboard::Y}, {"z", sf::Keyboard::Z},
// Numbers
{"0", sf::Keyboard::Num0}, {"1", sf::Keyboard::Num1}, {"2", sf::Keyboard::Num2},
{"3", sf::Keyboard::Num3}, {"4", sf::Keyboard::Num4}, {"5", sf::Keyboard::Num5},
{"6", sf::Keyboard::Num6}, {"7", sf::Keyboard::Num7}, {"8", sf::Keyboard::Num8},
{"9", sf::Keyboard::Num9},
// Function keys
{"f1", sf::Keyboard::F1}, {"f2", sf::Keyboard::F2}, {"f3", sf::Keyboard::F3},
{"f4", sf::Keyboard::F4}, {"f5", sf::Keyboard::F5}, {"f6", sf::Keyboard::F6},
{"f7", sf::Keyboard::F7}, {"f8", sf::Keyboard::F8}, {"f9", sf::Keyboard::F9},
{"f10", sf::Keyboard::F10}, {"f11", sf::Keyboard::F11}, {"f12", sf::Keyboard::F12},
{"f13", sf::Keyboard::F13}, {"f14", sf::Keyboard::F14}, {"f15", sf::Keyboard::F15},
// Special keys
{"escape", sf::Keyboard::Escape}, {"esc", sf::Keyboard::Escape},
{"enter", sf::Keyboard::Enter}, {"return", sf::Keyboard::Enter},
{"space", sf::Keyboard::Space}, {" ", sf::Keyboard::Space},
{"tab", sf::Keyboard::Tab}, {"\t", sf::Keyboard::Tab},
{"backspace", sf::Keyboard::BackSpace},
{"delete", sf::Keyboard::Delete}, {"del", sf::Keyboard::Delete},
{"insert", sf::Keyboard::Insert},
{"home", sf::Keyboard::Home},
{"end", sf::Keyboard::End},
{"pageup", sf::Keyboard::PageUp}, {"pgup", sf::Keyboard::PageUp},
{"pagedown", sf::Keyboard::PageDown}, {"pgdn", sf::Keyboard::PageDown},
// Arrow keys
{"left", sf::Keyboard::Left},
{"right", sf::Keyboard::Right},
{"up", sf::Keyboard::Up},
{"down", sf::Keyboard::Down},
// Modifiers
{"ctrl", sf::Keyboard::LControl}, {"ctrlleft", sf::Keyboard::LControl},
{"ctrlright", sf::Keyboard::RControl},
{"alt", sf::Keyboard::LAlt}, {"altleft", sf::Keyboard::LAlt},
{"altright", sf::Keyboard::RAlt},
{"shift", sf::Keyboard::LShift}, {"shiftleft", sf::Keyboard::LShift},
{"shiftright", sf::Keyboard::RShift},
{"win", sf::Keyboard::LSystem}, {"winleft", sf::Keyboard::LSystem},
{"winright", sf::Keyboard::RSystem}, {"command", sf::Keyboard::LSystem},
// Punctuation
{",", sf::Keyboard::Comma}, {".", sf::Keyboard::Period},
{"/", sf::Keyboard::Slash}, {"\\", sf::Keyboard::BackSlash},
{";", sf::Keyboard::SemiColon}, {"'", sf::Keyboard::Quote},
{"[", sf::Keyboard::LBracket}, {"]", sf::Keyboard::RBracket},
{"-", sf::Keyboard::Dash}, {"=", sf::Keyboard::Equal},
// Numpad
{"num0", sf::Keyboard::Numpad0}, {"num1", sf::Keyboard::Numpad1},
{"num2", sf::Keyboard::Numpad2}, {"num3", sf::Keyboard::Numpad3},
{"num4", sf::Keyboard::Numpad4}, {"num5", sf::Keyboard::Numpad5},
{"num6", sf::Keyboard::Numpad6}, {"num7", sf::Keyboard::Numpad7},
{"num8", sf::Keyboard::Numpad8}, {"num9", sf::Keyboard::Numpad9},
{"add", sf::Keyboard::Add}, {"subtract", sf::Keyboard::Subtract},
{"multiply", sf::Keyboard::Multiply}, {"divide", sf::Keyboard::Divide},
// Other
{"pause", sf::Keyboard::Pause},
{"capslock", sf::Keyboard::LControl}, // Note: SFML doesn't have CapsLock
{"numlock", sf::Keyboard::LControl}, // Note: SFML doesn't have NumLock
{"scrolllock", sf::Keyboard::LControl}, // Note: SFML doesn't have ScrollLock
};
auto it = keyMap.find(keyName);
if (it != keyMap.end()) {
return it->second;
}
return sf::Keyboard::Unknown;
}
// Inject mouse event into the game engine
void McRFPy_Automation::injectMouseEvent(sf::Event::EventType type, int x, int y, sf::Mouse::Button button) {
auto engine = getGameEngine();
if (!engine) return;
sf::Event event;
event.type = type;
switch (type) {
case sf::Event::MouseMoved:
event.mouseMove.x = x;
event.mouseMove.y = y;
break;
case sf::Event::MouseButtonPressed:
case sf::Event::MouseButtonReleased:
event.mouseButton.button = button;
event.mouseButton.x = x;
event.mouseButton.y = y;
break;
case sf::Event::MouseWheelScrolled:
event.mouseWheelScroll.wheel = sf::Mouse::VerticalWheel;
event.mouseWheelScroll.delta = static_cast<float>(x); // x is used for scroll amount
event.mouseWheelScroll.x = x;
event.mouseWheelScroll.y = y;
break;
default:
break;
}
engine->processEvent(event);
}
// Inject keyboard event into the game engine
void McRFPy_Automation::injectKeyEvent(sf::Event::EventType type, sf::Keyboard::Key key) {
auto engine = getGameEngine();
if (!engine) return;
sf::Event event;
event.type = type;
if (type == sf::Event::KeyPressed || type == sf::Event::KeyReleased) {
event.key.code = key;
event.key.alt = sf::Keyboard::isKeyPressed(sf::Keyboard::LAlt) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::RAlt);
event.key.control = sf::Keyboard::isKeyPressed(sf::Keyboard::LControl) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::RControl);
event.key.shift = sf::Keyboard::isKeyPressed(sf::Keyboard::LShift) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::RShift);
event.key.system = sf::Keyboard::isKeyPressed(sf::Keyboard::LSystem) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::RSystem);
}
engine->processEvent(event);
}
// Inject text event for typing
void McRFPy_Automation::injectTextEvent(sf::Uint32 unicode) {
auto engine = getGameEngine();
if (!engine) return;
sf::Event event;
event.type = sf::Event::TextEntered;
event.text.unicode = unicode;
engine->processEvent(event);
}
// Screenshot implementation
PyObject* McRFPy_Automation::_screenshot(PyObject* self, PyObject* args) {
const char* filename;
if (!PyArg_ParseTuple(args, "s", &filename)) {
return NULL;
}
auto engine = getGameEngine();
if (!engine) {
PyErr_SetString(PyExc_RuntimeError, "Game engine not initialized");
return NULL;
}
// Get the render target
sf::RenderTarget* target = engine->getRenderTargetPtr();
if (!target) {
PyErr_SetString(PyExc_RuntimeError, "No render target available");
return NULL;
}
// For RenderWindow, we can get a screenshot directly
if (auto* window = dynamic_cast<sf::RenderWindow*>(target)) {
sf::Vector2u windowSize = window->getSize();
sf::Texture texture;
texture.create(windowSize.x, windowSize.y);
texture.update(*window);
if (texture.copyToImage().saveToFile(filename)) {
Py_RETURN_TRUE;
} else {
Py_RETURN_FALSE;
}
}
// For RenderTexture (headless mode)
else if (auto* renderTexture = dynamic_cast<sf::RenderTexture*>(target)) {
if (renderTexture->getTexture().copyToImage().saveToFile(filename)) {
Py_RETURN_TRUE;
} else {
Py_RETURN_FALSE;
}
}
PyErr_SetString(PyExc_RuntimeError, "Unknown render target type");
return NULL;
}
// Get current mouse position
PyObject* McRFPy_Automation::_position(PyObject* self, PyObject* args) {
auto engine = getGameEngine();
if (!engine || !engine->getRenderTargetPtr()) {
return Py_BuildValue("(ii)", 0, 0);
}
// In headless mode, we'd need to track the simulated mouse position
// For now, return the actual mouse position relative to window if available
if (auto* window = dynamic_cast<sf::RenderWindow*>(engine->getRenderTargetPtr())) {
sf::Vector2i pos = sf::Mouse::getPosition(*window);
return Py_BuildValue("(ii)", pos.x, pos.y);
}
// In headless mode, return simulated position (TODO: track this)
return Py_BuildValue("(ii)", 0, 0);
}
// Get screen size
PyObject* McRFPy_Automation::_size(PyObject* self, PyObject* args) {
auto engine = getGameEngine();
if (!engine || !engine->getRenderTargetPtr()) {
return Py_BuildValue("(ii)", 1024, 768); // Default size
}
sf::Vector2u size = engine->getRenderTarget().getSize();
return Py_BuildValue("(ii)", size.x, size.y);
}
// Check if coordinates are on screen
PyObject* McRFPy_Automation::_onScreen(PyObject* self, PyObject* args) {
int x, y;
if (!PyArg_ParseTuple(args, "ii", &x, &y)) {
return NULL;
}
auto engine = getGameEngine();
if (!engine || !engine->getRenderTargetPtr()) {
Py_RETURN_FALSE;
}
sf::Vector2u size = engine->getRenderTarget().getSize();
if (x >= 0 && x < (int)size.x && y >= 0 && y < (int)size.y) {
Py_RETURN_TRUE;
} else {
Py_RETURN_FALSE;
}
}
// Move mouse to position
PyObject* McRFPy_Automation::_moveTo(PyObject* self, PyObject* args, PyObject* kwargs) {
static const char* kwlist[] = {"x", "y", "duration", NULL};
int x, y;
float duration = 0.0f;
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "ii|f", const_cast<char**>(kwlist),
&x, &y, &duration)) {
return NULL;
}
// TODO: Implement smooth movement with duration
injectMouseEvent(sf::Event::MouseMoved, x, y);
if (duration > 0) {
sleep_ms(static_cast<int>(duration * 1000));
}
Py_RETURN_NONE;
}
// Move mouse relative
PyObject* McRFPy_Automation::_moveRel(PyObject* self, PyObject* args, PyObject* kwargs) {
static const char* kwlist[] = {"xOffset", "yOffset", "duration", NULL};
int xOffset, yOffset;
float duration = 0.0f;
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "ii|f", const_cast<char**>(kwlist),
&xOffset, &yOffset, &duration)) {
return NULL;
}
// Get current position
PyObject* pos = _position(self, NULL);
if (!pos) return NULL;
int currentX, currentY;
if (!PyArg_ParseTuple(pos, "ii", &currentX, &currentY)) {
Py_DECREF(pos);
return NULL;
}
Py_DECREF(pos);
// Move to new position
injectMouseEvent(sf::Event::MouseMoved, currentX + xOffset, currentY + yOffset);
if (duration > 0) {
sleep_ms(static_cast<int>(duration * 1000));
}
Py_RETURN_NONE;
}
// Click implementation
PyObject* McRFPy_Automation::_click(PyObject* self, PyObject* args, PyObject* kwargs) {
static const char* kwlist[] = {"x", "y", "clicks", "interval", "button", NULL};
int x = -1, y = -1;
int clicks = 1;
float interval = 0.0f;
const char* button = "left";
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|iiifs", const_cast<char**>(kwlist),
&x, &y, &clicks, &interval, &button)) {
return NULL;
}
// If no position specified, use current position
if (x == -1 || y == -1) {
PyObject* pos = _position(self, NULL);
if (!pos) return NULL;
if (!PyArg_ParseTuple(pos, "ii", &x, &y)) {
Py_DECREF(pos);
return NULL;
}
Py_DECREF(pos);
}
// Determine button
sf::Mouse::Button sfButton = sf::Mouse::Left;
if (strcmp(button, "right") == 0) {
sfButton = sf::Mouse::Right;
} else if (strcmp(button, "middle") == 0) {
sfButton = sf::Mouse::Middle;
}
// Move to position first
injectMouseEvent(sf::Event::MouseMoved, x, y);
// Perform clicks
for (int i = 0; i < clicks; i++) {
if (i > 0 && interval > 0) {
sleep_ms(static_cast<int>(interval * 1000));
}
injectMouseEvent(sf::Event::MouseButtonPressed, x, y, sfButton);
sleep_ms(10); // Small delay between press and release
injectMouseEvent(sf::Event::MouseButtonReleased, x, y, sfButton);
}
Py_RETURN_NONE;
}
// Right click
PyObject* McRFPy_Automation::_rightClick(PyObject* self, PyObject* args, PyObject* kwargs) {
static const char* kwlist[] = {"x", "y", NULL};
int x = -1, y = -1;
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|ii", const_cast<char**>(kwlist), &x, &y)) {
return NULL;
}
// Build new args with button="right"
PyObject* newKwargs = PyDict_New();
PyDict_SetItemString(newKwargs, "button", PyUnicode_FromString("right"));
if (x != -1) PyDict_SetItemString(newKwargs, "x", PyLong_FromLong(x));
if (y != -1) PyDict_SetItemString(newKwargs, "y", PyLong_FromLong(y));
PyObject* result = _click(self, PyTuple_New(0), newKwargs);
Py_DECREF(newKwargs);
return result;
}
// Double click
PyObject* McRFPy_Automation::_doubleClick(PyObject* self, PyObject* args, PyObject* kwargs) {
static const char* kwlist[] = {"x", "y", NULL};
int x = -1, y = -1;
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|ii", const_cast<char**>(kwlist), &x, &y)) {
return NULL;
}
PyObject* newKwargs = PyDict_New();
PyDict_SetItemString(newKwargs, "clicks", PyLong_FromLong(2));
PyDict_SetItemString(newKwargs, "interval", PyFloat_FromDouble(0.1));
if (x != -1) PyDict_SetItemString(newKwargs, "x", PyLong_FromLong(x));
if (y != -1) PyDict_SetItemString(newKwargs, "y", PyLong_FromLong(y));
PyObject* result = _click(self, PyTuple_New(0), newKwargs);
Py_DECREF(newKwargs);
return result;
}
// Type text
PyObject* McRFPy_Automation::_typewrite(PyObject* self, PyObject* args, PyObject* kwargs) {
static const char* kwlist[] = {"message", "interval", NULL};
const char* message;
float interval = 0.0f;
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "s|f", const_cast<char**>(kwlist),
&message, &interval)) {
return NULL;
}
// Type each character
for (size_t i = 0; message[i] != '\0'; i++) {
if (i > 0 && interval > 0) {
sleep_ms(static_cast<int>(interval * 1000));
}
char c = message[i];
// Handle special characters
if (c == '\n') {
injectKeyEvent(sf::Event::KeyPressed, sf::Keyboard::Enter);
injectKeyEvent(sf::Event::KeyReleased, sf::Keyboard::Enter);
} else if (c == '\t') {
injectKeyEvent(sf::Event::KeyPressed, sf::Keyboard::Tab);
injectKeyEvent(sf::Event::KeyReleased, sf::Keyboard::Tab);
} else {
// For regular characters, send text event
injectTextEvent(static_cast<sf::Uint32>(c));
}
}
Py_RETURN_NONE;
}
// Press and hold key
PyObject* McRFPy_Automation::_keyDown(PyObject* self, PyObject* args) {
const char* keyName;
if (!PyArg_ParseTuple(args, "s", &keyName)) {
return NULL;
}
sf::Keyboard::Key key = stringToKey(keyName);
if (key == sf::Keyboard::Unknown) {
PyErr_Format(PyExc_ValueError, "Unknown key: %s", keyName);
return NULL;
}
injectKeyEvent(sf::Event::KeyPressed, key);
Py_RETURN_NONE;
}
// Release key
PyObject* McRFPy_Automation::_keyUp(PyObject* self, PyObject* args) {
const char* keyName;
if (!PyArg_ParseTuple(args, "s", &keyName)) {
return NULL;
}
sf::Keyboard::Key key = stringToKey(keyName);
if (key == sf::Keyboard::Unknown) {
PyErr_Format(PyExc_ValueError, "Unknown key: %s", keyName);
return NULL;
}
injectKeyEvent(sf::Event::KeyReleased, key);
Py_RETURN_NONE;
}
// Hotkey combination
PyObject* McRFPy_Automation::_hotkey(PyObject* self, PyObject* args) {
// Get all keys as separate arguments
Py_ssize_t numKeys = PyTuple_Size(args);
if (numKeys == 0) {
PyErr_SetString(PyExc_ValueError, "hotkey() requires at least one key");
return NULL;
}
// Press all keys
for (Py_ssize_t i = 0; i < numKeys; i++) {
PyObject* keyObj = PyTuple_GetItem(args, i);
const char* keyName = PyUnicode_AsUTF8(keyObj);
if (!keyName) {
return NULL;
}
sf::Keyboard::Key key = stringToKey(keyName);
if (key == sf::Keyboard::Unknown) {
PyErr_Format(PyExc_ValueError, "Unknown key: %s", keyName);
return NULL;
}
injectKeyEvent(sf::Event::KeyPressed, key);
sleep_ms(10); // Small delay between key presses
}
// Release all keys in reverse order
for (Py_ssize_t i = numKeys - 1; i >= 0; i--) {
PyObject* keyObj = PyTuple_GetItem(args, i);
const char* keyName = PyUnicode_AsUTF8(keyObj);
sf::Keyboard::Key key = stringToKey(keyName);
injectKeyEvent(sf::Event::KeyReleased, key);
sleep_ms(10);
}
Py_RETURN_NONE;
}
// Scroll wheel
PyObject* McRFPy_Automation::_scroll(PyObject* self, PyObject* args, PyObject* kwargs) {
static const char* kwlist[] = {"clicks", "x", "y", NULL};
int clicks;
int x = -1, y = -1;
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "i|ii", const_cast<char**>(kwlist),
&clicks, &x, &y)) {
return NULL;
}
// If no position specified, use current position
if (x == -1 || y == -1) {
PyObject* pos = _position(self, NULL);
if (!pos) return NULL;
if (!PyArg_ParseTuple(pos, "ii", &x, &y)) {
Py_DECREF(pos);
return NULL;
}
Py_DECREF(pos);
}
// Inject scroll event
injectMouseEvent(sf::Event::MouseWheelScrolled, clicks, y);
Py_RETURN_NONE;
}
// Other click types using the main click function
PyObject* McRFPy_Automation::_middleClick(PyObject* self, PyObject* args, PyObject* kwargs) {
static const char* kwlist[] = {"x", "y", NULL};
int x = -1, y = -1;
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|ii", const_cast<char**>(kwlist), &x, &y)) {
return NULL;
}
PyObject* newKwargs = PyDict_New();
PyDict_SetItemString(newKwargs, "button", PyUnicode_FromString("middle"));
if (x != -1) PyDict_SetItemString(newKwargs, "x", PyLong_FromLong(x));
if (y != -1) PyDict_SetItemString(newKwargs, "y", PyLong_FromLong(y));
PyObject* result = _click(self, PyTuple_New(0), newKwargs);
Py_DECREF(newKwargs);
return result;
}
PyObject* McRFPy_Automation::_tripleClick(PyObject* self, PyObject* args, PyObject* kwargs) {
static const char* kwlist[] = {"x", "y", NULL};
int x = -1, y = -1;
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|ii", const_cast<char**>(kwlist), &x, &y)) {
return NULL;
}
PyObject* newKwargs = PyDict_New();
PyDict_SetItemString(newKwargs, "clicks", PyLong_FromLong(3));
PyDict_SetItemString(newKwargs, "interval", PyFloat_FromDouble(0.1));
if (x != -1) PyDict_SetItemString(newKwargs, "x", PyLong_FromLong(x));
if (y != -1) PyDict_SetItemString(newKwargs, "y", PyLong_FromLong(y));
PyObject* result = _click(self, PyTuple_New(0), newKwargs);
Py_DECREF(newKwargs);
return result;
}
// Mouse button press/release
PyObject* McRFPy_Automation::_mouseDown(PyObject* self, PyObject* args, PyObject* kwargs) {
static const char* kwlist[] = {"x", "y", "button", NULL};
int x = -1, y = -1;
const char* button = "left";
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|iis", const_cast<char**>(kwlist),
&x, &y, &button)) {
return NULL;
}
// If no position specified, use current position
if (x == -1 || y == -1) {
PyObject* pos = _position(self, NULL);
if (!pos) return NULL;
if (!PyArg_ParseTuple(pos, "ii", &x, &y)) {
Py_DECREF(pos);
return NULL;
}
Py_DECREF(pos);
}
sf::Mouse::Button sfButton = sf::Mouse::Left;
if (strcmp(button, "right") == 0) {
sfButton = sf::Mouse::Right;
} else if (strcmp(button, "middle") == 0) {
sfButton = sf::Mouse::Middle;
}
injectMouseEvent(sf::Event::MouseButtonPressed, x, y, sfButton);
Py_RETURN_NONE;
}
PyObject* McRFPy_Automation::_mouseUp(PyObject* self, PyObject* args, PyObject* kwargs) {
static const char* kwlist[] = {"x", "y", "button", NULL};
int x = -1, y = -1;
const char* button = "left";
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|iis", const_cast<char**>(kwlist),
&x, &y, &button)) {
return NULL;
}
// If no position specified, use current position
if (x == -1 || y == -1) {
PyObject* pos = _position(self, NULL);
if (!pos) return NULL;
if (!PyArg_ParseTuple(pos, "ii", &x, &y)) {
Py_DECREF(pos);
return NULL;
}
Py_DECREF(pos);
}
sf::Mouse::Button sfButton = sf::Mouse::Left;
if (strcmp(button, "right") == 0) {
sfButton = sf::Mouse::Right;
} else if (strcmp(button, "middle") == 0) {
sfButton = sf::Mouse::Middle;
}
injectMouseEvent(sf::Event::MouseButtonReleased, x, y, sfButton);
Py_RETURN_NONE;
}
// Drag operations
PyObject* McRFPy_Automation::_dragTo(PyObject* self, PyObject* args, PyObject* kwargs) {
static const char* kwlist[] = {"x", "y", "duration", "button", NULL};
int x, y;
float duration = 0.0f;
const char* button = "left";
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "ii|fs", const_cast<char**>(kwlist),
&x, &y, &duration, &button)) {
return NULL;
}
// Get current position
PyObject* pos = _position(self, NULL);
if (!pos) return NULL;
int startX, startY;
if (!PyArg_ParseTuple(pos, "ii", &startX, &startY)) {
Py_DECREF(pos);
return NULL;
}
Py_DECREF(pos);
// Mouse down at current position
PyObject* downArgs = Py_BuildValue("(ii)", startX, startY);
PyObject* downKwargs = PyDict_New();
PyDict_SetItemString(downKwargs, "button", PyUnicode_FromString(button));
PyObject* downResult = _mouseDown(self, downArgs, downKwargs);
Py_DECREF(downArgs);
Py_DECREF(downKwargs);
if (!downResult) return NULL;
Py_DECREF(downResult);
// Move to target position
if (duration > 0) {
// Smooth movement
int steps = static_cast<int>(duration * 60); // 60 FPS
for (int i = 1; i <= steps; i++) {
int currentX = startX + (x - startX) * i / steps;
int currentY = startY + (y - startY) * i / steps;
injectMouseEvent(sf::Event::MouseMoved, currentX, currentY);
sleep_ms(1000 / 60); // 60 FPS
}
} else {
injectMouseEvent(sf::Event::MouseMoved, x, y);
}
// Mouse up at target position
PyObject* upArgs = Py_BuildValue("(ii)", x, y);
PyObject* upKwargs = PyDict_New();
PyDict_SetItemString(upKwargs, "button", PyUnicode_FromString(button));
PyObject* upResult = _mouseUp(self, upArgs, upKwargs);
Py_DECREF(upArgs);
Py_DECREF(upKwargs);
if (!upResult) return NULL;
Py_DECREF(upResult);
Py_RETURN_NONE;
}
PyObject* McRFPy_Automation::_dragRel(PyObject* self, PyObject* args, PyObject* kwargs) {
static const char* kwlist[] = {"xOffset", "yOffset", "duration", "button", NULL};
int xOffset, yOffset;
float duration = 0.0f;
const char* button = "left";
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "ii|fs", const_cast<char**>(kwlist),
&xOffset, &yOffset, &duration, &button)) {
return NULL;
}
// Get current position
PyObject* pos = _position(self, NULL);
if (!pos) return NULL;
int currentX, currentY;
if (!PyArg_ParseTuple(pos, "ii", &currentX, &currentY)) {
Py_DECREF(pos);
return NULL;
}
Py_DECREF(pos);
// Call dragTo with absolute position
PyObject* dragArgs = Py_BuildValue("(ii)", currentX + xOffset, currentY + yOffset);
PyObject* dragKwargs = PyDict_New();
PyDict_SetItemString(dragKwargs, "duration", PyFloat_FromDouble(duration));
PyDict_SetItemString(dragKwargs, "button", PyUnicode_FromString(button));
PyObject* result = _dragTo(self, dragArgs, dragKwargs);
Py_DECREF(dragArgs);
Py_DECREF(dragKwargs);
return result;
}
// Method definitions for the automation module
static PyMethodDef automationMethods[] = {
{"screenshot", McRFPy_Automation::_screenshot, METH_VARARGS,
"screenshot(filename) - Save a screenshot to the specified file"},
{"position", McRFPy_Automation::_position, METH_NOARGS,
"position() - Get current mouse position as (x, y) tuple"},
{"size", McRFPy_Automation::_size, METH_NOARGS,
"size() - Get screen size as (width, height) tuple"},
{"onScreen", McRFPy_Automation::_onScreen, METH_VARARGS,
"onScreen(x, y) - Check if coordinates are within screen bounds"},
{"moveTo", (PyCFunction)McRFPy_Automation::_moveTo, METH_VARARGS | METH_KEYWORDS,
"moveTo(x, y, duration=0.0) - Move mouse to absolute position"},
{"moveRel", (PyCFunction)McRFPy_Automation::_moveRel, METH_VARARGS | METH_KEYWORDS,
"moveRel(xOffset, yOffset, duration=0.0) - Move mouse relative to current position"},
{"dragTo", (PyCFunction)McRFPy_Automation::_dragTo, METH_VARARGS | METH_KEYWORDS,
"dragTo(x, y, duration=0.0, button='left') - Drag mouse to position"},
{"dragRel", (PyCFunction)McRFPy_Automation::_dragRel, METH_VARARGS | METH_KEYWORDS,
"dragRel(xOffset, yOffset, duration=0.0, button='left') - Drag mouse relative to current position"},
{"click", (PyCFunction)McRFPy_Automation::_click, METH_VARARGS | METH_KEYWORDS,
"click(x=None, y=None, clicks=1, interval=0.0, button='left') - Click at position"},
{"rightClick", (PyCFunction)McRFPy_Automation::_rightClick, METH_VARARGS | METH_KEYWORDS,
"rightClick(x=None, y=None) - Right click at position"},
{"middleClick", (PyCFunction)McRFPy_Automation::_middleClick, METH_VARARGS | METH_KEYWORDS,
"middleClick(x=None, y=None) - Middle click at position"},
{"doubleClick", (PyCFunction)McRFPy_Automation::_doubleClick, METH_VARARGS | METH_KEYWORDS,
"doubleClick(x=None, y=None) - Double click at position"},
{"tripleClick", (PyCFunction)McRFPy_Automation::_tripleClick, METH_VARARGS | METH_KEYWORDS,
"tripleClick(x=None, y=None) - Triple click at position"},
{"scroll", (PyCFunction)McRFPy_Automation::_scroll, METH_VARARGS | METH_KEYWORDS,
"scroll(clicks, x=None, y=None) - Scroll wheel at position"},
{"mouseDown", (PyCFunction)McRFPy_Automation::_mouseDown, METH_VARARGS | METH_KEYWORDS,
"mouseDown(x=None, y=None, button='left') - Press mouse button"},
{"mouseUp", (PyCFunction)McRFPy_Automation::_mouseUp, METH_VARARGS | METH_KEYWORDS,
"mouseUp(x=None, y=None, button='left') - Release mouse button"},
{"typewrite", (PyCFunction)McRFPy_Automation::_typewrite, METH_VARARGS | METH_KEYWORDS,
"typewrite(message, interval=0.0) - Type text with optional interval between keystrokes"},
{"hotkey", McRFPy_Automation::_hotkey, METH_VARARGS,
"hotkey(*keys) - Press a hotkey combination (e.g., hotkey('ctrl', 'c'))"},
{"keyDown", McRFPy_Automation::_keyDown, METH_VARARGS,
"keyDown(key) - Press and hold a key"},
{"keyUp", McRFPy_Automation::_keyUp, METH_VARARGS,
"keyUp(key) - Release a key"},
{NULL, NULL, 0, NULL}
};
// Module definition for mcrfpy.automation
static PyModuleDef automationModule = {
PyModuleDef_HEAD_INIT,
"mcrfpy.automation",
"Automation API for McRogueFace - PyAutoGUI-compatible interface",
-1,
automationMethods
};
// Initialize automation submodule
PyObject* McRFPy_Automation::init_automation_module() {
PyObject* module = PyModule_Create(&automationModule);
if (module == NULL) {
return NULL;
}
return module;
}

56
src/McRFPy_Automation.h Normal file
View File

@ -0,0 +1,56 @@
#pragma once
#include "Common.h"
#include "Python.h"
#include <SFML/Graphics.hpp>
#include <SFML/Window.hpp>
#include <string>
#include <chrono>
#include <thread>
class GameEngine;
class McRFPy_Automation {
public:
// Initialize the automation submodule
static PyObject* init_automation_module();
// Screenshot functionality
static PyObject* _screenshot(PyObject* self, PyObject* args);
// Mouse position and screen info
static PyObject* _position(PyObject* self, PyObject* args);
static PyObject* _size(PyObject* self, PyObject* args);
static PyObject* _onScreen(PyObject* self, PyObject* args);
// Mouse movement
static PyObject* _moveTo(PyObject* self, PyObject* args, PyObject* kwargs);
static PyObject* _moveRel(PyObject* self, PyObject* args, PyObject* kwargs);
static PyObject* _dragTo(PyObject* self, PyObject* args, PyObject* kwargs);
static PyObject* _dragRel(PyObject* self, PyObject* args, PyObject* kwargs);
// Mouse clicks
static PyObject* _click(PyObject* self, PyObject* args, PyObject* kwargs);
static PyObject* _rightClick(PyObject* self, PyObject* args, PyObject* kwargs);
static PyObject* _middleClick(PyObject* self, PyObject* args, PyObject* kwargs);
static PyObject* _doubleClick(PyObject* self, PyObject* args, PyObject* kwargs);
static PyObject* _tripleClick(PyObject* self, PyObject* args, PyObject* kwargs);
static PyObject* _scroll(PyObject* self, PyObject* args, PyObject* kwargs);
static PyObject* _mouseDown(PyObject* self, PyObject* args, PyObject* kwargs);
static PyObject* _mouseUp(PyObject* self, PyObject* args, PyObject* kwargs);
// Keyboard
static PyObject* _typewrite(PyObject* self, PyObject* args, PyObject* kwargs);
static PyObject* _hotkey(PyObject* self, PyObject* args);
static PyObject* _keyDown(PyObject* self, PyObject* args);
static PyObject* _keyUp(PyObject* self, PyObject* args);
// Helper functions
static void injectMouseEvent(sf::Event::EventType type, int x, int y, sf::Mouse::Button button = sf::Mouse::Left);
static void injectKeyEvent(sf::Event::EventType type, sf::Keyboard::Key key);
static void injectTextEvent(sf::Uint32 unicode);
static sf::Keyboard::Key stringToKey(const std::string& keyName);
static void sleep_ms(int milliseconds);
private:
static GameEngine* getGameEngine();
};

31
src/McRFPy_Doc.h Normal file
View File

@ -0,0 +1,31 @@
#ifndef MCRFPY_DOC_H
#define MCRFPY_DOC_H
// Section builders for documentation
#define MCRF_SIG(params, ret) params " -> " ret "\n\n"
#define MCRF_DESC(text) text "\n\n"
#define MCRF_ARGS_START "Args:\n"
#define MCRF_ARG(name, desc) " " name ": " desc "\n"
#define MCRF_RETURNS(text) "\nReturns:\n " text "\n"
#define MCRF_RAISES(exc, desc) "\nRaises:\n " exc ": " desc "\n"
#define MCRF_NOTE(text) "\nNote:\n " text "\n"
// Link to external documentation
// Format: MCRF_LINK("docs/file.md", "Link Text")
// Parsers detect this pattern and format per output type
#define MCRF_LINK(ref, text) "\nSee also: " text " (" ref ")\n"
// Main documentation macros
#define MCRF_METHOD_DOC(name, sig, desc, ...) \
name sig desc __VA_ARGS__
#define MCRF_FUNCTION(name, ...) \
MCRF_METHOD_DOC(#name, __VA_ARGS__)
#define MCRF_METHOD(cls, name, ...) \
MCRF_METHOD_DOC(#name, __VA_ARGS__)
#define MCRF_PROPERTY(name, desc) \
desc
#endif // MCRFPY_DOC_H

324
src/McRFPy_Libtcod.cpp Normal file
View File

@ -0,0 +1,324 @@
#include "McRFPy_Libtcod.h"
#include "McRFPy_API.h"
#include "UIGrid.h"
#include <vector>
// Helper function to get UIGrid from Python object
static UIGrid* get_grid_from_pyobject(PyObject* obj) {
auto grid_type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid");
if (!grid_type) {
PyErr_SetString(PyExc_RuntimeError, "Could not find Grid type");
return nullptr;
}
if (!PyObject_IsInstance(obj, (PyObject*)grid_type)) {
Py_DECREF(grid_type);
PyErr_SetString(PyExc_TypeError, "First argument must be a Grid object");
return nullptr;
}
Py_DECREF(grid_type);
PyUIGridObject* pygrid = (PyUIGridObject*)obj;
return pygrid->data.get();
}
// Field of View computation
static PyObject* McRFPy_Libtcod::compute_fov(PyObject* self, PyObject* args) {
PyObject* grid_obj;
int x, y, radius;
int light_walls = 1;
int algorithm = FOV_BASIC;
if (!PyArg_ParseTuple(args, "Oiii|ii", &grid_obj, &x, &y, &radius,
&light_walls, &algorithm)) {
return NULL;
}
UIGrid* grid = get_grid_from_pyobject(grid_obj);
if (!grid) return NULL;
// Compute FOV using grid's method
grid->computeFOV(x, y, radius, light_walls, (TCOD_fov_algorithm_t)algorithm);
// Return list of visible cells
PyObject* visible_list = PyList_New(0);
for (int gy = 0; gy < grid->grid_y; gy++) {
for (int gx = 0; gx < grid->grid_x; gx++) {
if (grid->isInFOV(gx, gy)) {
PyObject* pos = Py_BuildValue("(ii)", gx, gy);
PyList_Append(visible_list, pos);
Py_DECREF(pos);
}
}
}
return visible_list;
}
// A* Pathfinding
static PyObject* McRFPy_Libtcod::find_path(PyObject* self, PyObject* args) {
PyObject* grid_obj;
int x1, y1, x2, y2;
float diagonal_cost = 1.41f;
if (!PyArg_ParseTuple(args, "Oiiii|f", &grid_obj, &x1, &y1, &x2, &y2, &diagonal_cost)) {
return NULL;
}
UIGrid* grid = get_grid_from_pyobject(grid_obj);
if (!grid) return NULL;
// Get path from grid
std::vector<std::pair<int, int>> path = grid->findPath(x1, y1, x2, y2, diagonal_cost);
// Convert to Python list
PyObject* path_list = PyList_New(path.size());
for (size_t i = 0; i < path.size(); i++) {
PyObject* pos = Py_BuildValue("(ii)", path[i].first, path[i].second);
PyList_SetItem(path_list, i, pos); // steals reference
}
return path_list;
}
// Line drawing algorithm
static PyObject* McRFPy_Libtcod::line(PyObject* self, PyObject* args) {
int x1, y1, x2, y2;
if (!PyArg_ParseTuple(args, "iiii", &x1, &y1, &x2, &y2)) {
return NULL;
}
// Use TCOD's line algorithm
TCODLine::init(x1, y1, x2, y2);
PyObject* line_list = PyList_New(0);
int x, y;
// Step through line
while (!TCODLine::step(&x, &y)) {
PyObject* pos = Py_BuildValue("(ii)", x, y);
PyList_Append(line_list, pos);
Py_DECREF(pos);
}
return line_list;
}
// Line iterator (generator-like function)
static PyObject* McRFPy_Libtcod::line_iter(PyObject* self, PyObject* args) {
// For simplicity, just call line() for now
// A proper implementation would create an iterator object
return line(self, args);
}
// Dijkstra pathfinding
static PyObject* McRFPy_Libtcod::dijkstra_new(PyObject* self, PyObject* args) {
PyObject* grid_obj;
float diagonal_cost = 1.41f;
if (!PyArg_ParseTuple(args, "O|f", &grid_obj, &diagonal_cost)) {
return NULL;
}
UIGrid* grid = get_grid_from_pyobject(grid_obj);
if (!grid) return NULL;
// For now, just return the grid object since Dijkstra is part of the grid
Py_INCREF(grid_obj);
return grid_obj;
}
static PyObject* McRFPy_Libtcod::dijkstra_compute(PyObject* self, PyObject* args) {
PyObject* grid_obj;
int root_x, root_y;
if (!PyArg_ParseTuple(args, "Oii", &grid_obj, &root_x, &root_y)) {
return NULL;
}
UIGrid* grid = get_grid_from_pyobject(grid_obj);
if (!grid) return NULL;
grid->computeDijkstra(root_x, root_y);
Py_RETURN_NONE;
}
static PyObject* McRFPy_Libtcod::dijkstra_get_distance(PyObject* self, PyObject* args) {
PyObject* grid_obj;
int x, y;
if (!PyArg_ParseTuple(args, "Oii", &grid_obj, &x, &y)) {
return NULL;
}
UIGrid* grid = get_grid_from_pyobject(grid_obj);
if (!grid) return NULL;
float distance = grid->getDijkstraDistance(x, y);
if (distance < 0) {
Py_RETURN_NONE;
}
return PyFloat_FromDouble(distance);
}
static PyObject* McRFPy_Libtcod::dijkstra_path_to(PyObject* self, PyObject* args) {
PyObject* grid_obj;
int x, y;
if (!PyArg_ParseTuple(args, "Oii", &grid_obj, &x, &y)) {
return NULL;
}
UIGrid* grid = get_grid_from_pyobject(grid_obj);
if (!grid) return NULL;
std::vector<std::pair<int, int>> path = grid->getDijkstraPath(x, y);
PyObject* path_list = PyList_New(path.size());
for (size_t i = 0; i < path.size(); i++) {
PyObject* pos = Py_BuildValue("(ii)", path[i].first, path[i].second);
PyList_SetItem(path_list, i, pos); // steals reference
}
return path_list;
}
// Add FOV algorithm constants to module
static PyObject* McRFPy_Libtcod::add_fov_constants(PyObject* module) {
// FOV algorithms
PyModule_AddIntConstant(module, "FOV_BASIC", FOV_BASIC);
PyModule_AddIntConstant(module, "FOV_DIAMOND", FOV_DIAMOND);
PyModule_AddIntConstant(module, "FOV_SHADOW", FOV_SHADOW);
PyModule_AddIntConstant(module, "FOV_PERMISSIVE_0", FOV_PERMISSIVE_0);
PyModule_AddIntConstant(module, "FOV_PERMISSIVE_1", FOV_PERMISSIVE_1);
PyModule_AddIntConstant(module, "FOV_PERMISSIVE_2", FOV_PERMISSIVE_2);
PyModule_AddIntConstant(module, "FOV_PERMISSIVE_3", FOV_PERMISSIVE_3);
PyModule_AddIntConstant(module, "FOV_PERMISSIVE_4", FOV_PERMISSIVE_4);
PyModule_AddIntConstant(module, "FOV_PERMISSIVE_5", FOV_PERMISSIVE_5);
PyModule_AddIntConstant(module, "FOV_PERMISSIVE_6", FOV_PERMISSIVE_6);
PyModule_AddIntConstant(module, "FOV_PERMISSIVE_7", FOV_PERMISSIVE_7);
PyModule_AddIntConstant(module, "FOV_PERMISSIVE_8", FOV_PERMISSIVE_8);
PyModule_AddIntConstant(module, "FOV_RESTRICTIVE", FOV_RESTRICTIVE);
PyModule_AddIntConstant(module, "FOV_SYMMETRIC_SHADOWCAST", FOV_SYMMETRIC_SHADOWCAST);
return module;
}
// Method definitions
static PyMethodDef libtcodMethods[] = {
{"compute_fov", McRFPy_Libtcod::compute_fov, METH_VARARGS,
"compute_fov(grid, x, y, radius, light_walls=True, algorithm=FOV_BASIC)\n\n"
"Compute field of view from a position.\n\n"
"Args:\n"
" grid: Grid object to compute FOV on\n"
" x, y: Origin position\n"
" radius: Maximum sight radius\n"
" light_walls: Whether walls are lit when in FOV\n"
" algorithm: FOV algorithm to use (FOV_BASIC, FOV_SHADOW, etc.)\n\n"
"Returns:\n"
" List of (x, y) tuples for visible cells"},
{"find_path", McRFPy_Libtcod::find_path, METH_VARARGS,
"find_path(grid, x1, y1, x2, y2, diagonal_cost=1.41)\n\n"
"Find shortest path between two points using A*.\n\n"
"Args:\n"
" grid: Grid object to pathfind on\n"
" x1, y1: Starting position\n"
" x2, y2: Target position\n"
" diagonal_cost: Cost of diagonal movement\n\n"
"Returns:\n"
" List of (x, y) tuples representing the path, or empty list if no path exists"},
{"line", McRFPy_Libtcod::line, METH_VARARGS,
"line(x1, y1, x2, y2)\n\n"
"Get cells along a line using Bresenham's algorithm.\n\n"
"Args:\n"
" x1, y1: Starting position\n"
" x2, y2: Ending position\n\n"
"Returns:\n"
" List of (x, y) tuples along the line"},
{"line_iter", McRFPy_Libtcod::line_iter, METH_VARARGS,
"line_iter(x1, y1, x2, y2)\n\n"
"Iterate over cells along a line.\n\n"
"Args:\n"
" x1, y1: Starting position\n"
" x2, y2: Ending position\n\n"
"Returns:\n"
" Iterator of (x, y) tuples along the line"},
{"dijkstra_new", McRFPy_Libtcod::dijkstra_new, METH_VARARGS,
"dijkstra_new(grid, diagonal_cost=1.41)\n\n"
"Create a Dijkstra pathfinding context for a grid.\n\n"
"Args:\n"
" grid: Grid object to use for pathfinding\n"
" diagonal_cost: Cost of diagonal movement\n\n"
"Returns:\n"
" Grid object configured for Dijkstra pathfinding"},
{"dijkstra_compute", McRFPy_Libtcod::dijkstra_compute, METH_VARARGS,
"dijkstra_compute(grid, root_x, root_y)\n\n"
"Compute Dijkstra distance map from root position.\n\n"
"Args:\n"
" grid: Grid object with Dijkstra context\n"
" root_x, root_y: Root position to compute distances from"},
{"dijkstra_get_distance", McRFPy_Libtcod::dijkstra_get_distance, METH_VARARGS,
"dijkstra_get_distance(grid, x, y)\n\n"
"Get distance from root to a position.\n\n"
"Args:\n"
" grid: Grid object with computed Dijkstra map\n"
" x, y: Position to get distance for\n\n"
"Returns:\n"
" Float distance or None if position is invalid/unreachable"},
{"dijkstra_path_to", McRFPy_Libtcod::dijkstra_path_to, METH_VARARGS,
"dijkstra_path_to(grid, x, y)\n\n"
"Get shortest path from position to Dijkstra root.\n\n"
"Args:\n"
" grid: Grid object with computed Dijkstra map\n"
" x, y: Starting position\n\n"
"Returns:\n"
" List of (x, y) tuples representing the path to root"},
{NULL, NULL, 0, NULL}
};
// Module definition
static PyModuleDef libtcodModule = {
PyModuleDef_HEAD_INIT,
"mcrfpy.libtcod",
"TCOD-compatible algorithms for field of view, pathfinding, and line drawing.\n\n"
"This module provides access to TCOD's algorithms integrated with McRogueFace grids.\n"
"Unlike the original TCOD, these functions work directly with Grid objects.\n\n"
"FOV Algorithms:\n"
" FOV_BASIC - Basic circular FOV\n"
" FOV_SHADOW - Shadow casting (recommended)\n"
" FOV_DIAMOND - Diamond-shaped FOV\n"
" FOV_PERMISSIVE_0 through FOV_PERMISSIVE_8 - Permissive variants\n"
" FOV_RESTRICTIVE - Most restrictive FOV\n"
" FOV_SYMMETRIC_SHADOWCAST - Symmetric shadow casting\n\n"
"Example:\n"
" import mcrfpy\n"
" from mcrfpy import libtcod\n\n"
" grid = mcrfpy.Grid(50, 50)\n"
" visible = libtcod.compute_fov(grid, 25, 25, 10)\n"
" path = libtcod.find_path(grid, 0, 0, 49, 49)",
-1,
libtcodMethods
};
// Module initialization
PyObject* McRFPy_Libtcod::init_libtcod_module() {
PyObject* m = PyModule_Create(&libtcodModule);
if (m == NULL) {
return NULL;
}
// Add FOV algorithm constants
add_fov_constants(m);
return m;
}

27
src/McRFPy_Libtcod.h Normal file
View File

@ -0,0 +1,27 @@
#pragma once
#include "Common.h"
#include "Python.h"
#include <libtcod.h>
namespace McRFPy_Libtcod
{
// Field of View algorithms
static PyObject* compute_fov(PyObject* self, PyObject* args);
// Pathfinding
static PyObject* find_path(PyObject* self, PyObject* args);
static PyObject* dijkstra_new(PyObject* self, PyObject* args);
static PyObject* dijkstra_compute(PyObject* self, PyObject* args);
static PyObject* dijkstra_get_distance(PyObject* self, PyObject* args);
static PyObject* dijkstra_path_to(PyObject* self, PyObject* args);
// Line algorithms
static PyObject* line(PyObject* self, PyObject* args);
static PyObject* line_iter(PyObject* self, PyObject* args);
// FOV algorithm constants
static PyObject* add_fov_constants(PyObject* module);
// Module initialization
PyObject* init_libtcod_module();
}

36
src/McRogueFaceConfig.h Normal file
View File

@ -0,0 +1,36 @@
#ifndef MCROGUEFACE_CONFIG_H
#define MCROGUEFACE_CONFIG_H
#include <string>
#include <vector>
#include <filesystem>
struct McRogueFaceConfig {
// McRogueFace specific
bool headless = false;
bool audio_enabled = true;
// Python interpreter emulation
bool python_mode = false;
std::string python_command; // -c command
std::string python_module; // -m module
bool interactive_mode = false; // -i flag
bool show_version = false; // -V flag
bool show_help = false; // -h flag
// Script execution
std::filesystem::path script_path;
std::vector<std::string> script_args;
// Scripts to execute before main script (--exec flag)
std::vector<std::filesystem::path> exec_scripts;
// Screenshot functionality for headless mode
std::string screenshot_path;
bool take_screenshot = false;
// Auto-exit when no timers remain (for --headless --exec automation)
bool auto_exit_after_exec = false;
};
#endif // MCROGUEFACE_CONFIG_H

61
src/Profiler.cpp Normal file
View File

@ -0,0 +1,61 @@
#include "Profiler.h"
#include <iostream>
ProfilingLogger::ProfilingLogger()
: headers_written(false)
{
}
ProfilingLogger::~ProfilingLogger() {
close();
}
bool ProfilingLogger::open(const std::string& filename, const std::vector<std::string>& columns) {
column_names = columns;
file.open(filename);
if (!file.is_open()) {
std::cerr << "Failed to open profiling log file: " << filename << std::endl;
return false;
}
// Write CSV header
for (size_t i = 0; i < columns.size(); ++i) {
file << columns[i];
if (i < columns.size() - 1) {
file << ",";
}
}
file << "\n";
file.flush();
headers_written = true;
return true;
}
void ProfilingLogger::writeRow(const std::vector<float>& values) {
if (!file.is_open()) {
return;
}
if (values.size() != column_names.size()) {
std::cerr << "ProfilingLogger: value count (" << values.size()
<< ") doesn't match column count (" << column_names.size() << ")" << std::endl;
return;
}
for (size_t i = 0; i < values.size(); ++i) {
file << values[i];
if (i < values.size() - 1) {
file << ",";
}
}
file << "\n";
}
void ProfilingLogger::close() {
if (file.is_open()) {
file.flush();
file.close();
}
}

111
src/Profiler.h Normal file
View File

@ -0,0 +1,111 @@
#pragma once
#include <chrono>
#include <string>
#include <vector>
#include <fstream>
/**
* @brief Simple RAII-based profiling timer for measuring code execution time
*
* Usage:
* float timing = 0.0f;
* {
* ScopedTimer timer(timing);
* // ... code to profile ...
* } // timing now contains elapsed milliseconds
*/
class ScopedTimer {
private:
std::chrono::high_resolution_clock::time_point start;
float& target_ms;
public:
/**
* @brief Construct a new Scoped Timer and start timing
* @param target Reference to float that will receive elapsed time in milliseconds
*/
explicit ScopedTimer(float& target)
: target_ms(target)
{
start = std::chrono::high_resolution_clock::now();
}
/**
* @brief Destructor automatically records elapsed time
*/
~ScopedTimer() {
auto end = std::chrono::high_resolution_clock::now();
target_ms = std::chrono::duration<float, std::milli>(end - start).count();
}
// Prevent copying
ScopedTimer(const ScopedTimer&) = delete;
ScopedTimer& operator=(const ScopedTimer&) = delete;
};
/**
* @brief Accumulating timer that adds elapsed time to existing value
*
* Useful for measuring total time across multiple calls in a single frame
*/
class AccumulatingTimer {
private:
std::chrono::high_resolution_clock::time_point start;
float& target_ms;
public:
explicit AccumulatingTimer(float& target)
: target_ms(target)
{
start = std::chrono::high_resolution_clock::now();
}
~AccumulatingTimer() {
auto end = std::chrono::high_resolution_clock::now();
target_ms += std::chrono::duration<float, std::milli>(end - start).count();
}
AccumulatingTimer(const AccumulatingTimer&) = delete;
AccumulatingTimer& operator=(const AccumulatingTimer&) = delete;
};
/**
* @brief CSV profiling data logger for batch analysis
*
* Writes profiling data to CSV file for later analysis with Python/pandas/Excel
*/
class ProfilingLogger {
private:
std::ofstream file;
bool headers_written;
std::vector<std::string> column_names;
public:
ProfilingLogger();
~ProfilingLogger();
/**
* @brief Open a CSV file for writing profiling data
* @param filename Path to CSV file
* @param columns Column names for the CSV header
* @return true if file opened successfully
*/
bool open(const std::string& filename, const std::vector<std::string>& columns);
/**
* @brief Write a row of profiling data
* @param values Data values (must match column count)
*/
void writeRow(const std::vector<float>& values);
/**
* @brief Close the file and flush data
*/
void close();
/**
* @brief Check if logger is ready to write
*/
bool isOpen() const { return file.is_open(); }
};

135
src/ProfilerOverlay.cpp Normal file
View File

@ -0,0 +1,135 @@
#include "GameEngine.h"
#include <sstream>
#include <iomanip>
GameEngine::ProfilerOverlay::ProfilerOverlay(sf::Font& fontRef)
: font(fontRef), visible(false), updateInterval(10), frameCounter(0)
{
text.setFont(font);
text.setCharacterSize(14);
text.setFillColor(sf::Color::White);
text.setPosition(10.0f, 10.0f);
// Semi-transparent dark background
background.setFillColor(sf::Color(0, 0, 0, 180));
background.setPosition(5.0f, 5.0f);
}
void GameEngine::ProfilerOverlay::toggle() {
visible = !visible;
}
void GameEngine::ProfilerOverlay::setVisible(bool vis) {
visible = vis;
}
bool GameEngine::ProfilerOverlay::isVisible() const {
return visible;
}
sf::Color GameEngine::ProfilerOverlay::getPerformanceColor(float frameTimeMs) {
if (frameTimeMs < 16.6f) {
return sf::Color::Green; // 60+ FPS
} else if (frameTimeMs < 33.3f) {
return sf::Color::Yellow; // 30-60 FPS
} else {
return sf::Color::Red; // <30 FPS
}
}
std::string GameEngine::ProfilerOverlay::formatFloat(float value, int precision) {
std::stringstream ss;
ss << std::fixed << std::setprecision(precision) << value;
return ss.str();
}
std::string GameEngine::ProfilerOverlay::formatPercentage(float part, float total) {
if (total <= 0.0f) return "0%";
float pct = (part / total) * 100.0f;
return formatFloat(pct, 0) + "%";
}
void GameEngine::ProfilerOverlay::update(const ProfilingMetrics& metrics) {
if (!visible) return;
// Only update text every N frames to reduce overhead
frameCounter++;
if (frameCounter < updateInterval) {
return;
}
frameCounter = 0;
std::stringstream ss;
ss << "McRogueFace Performance Monitor\n";
ss << "================================\n";
// Frame time and FPS
float frameMs = metrics.avgFrameTime;
ss << "FPS: " << metrics.fps << " (" << formatFloat(frameMs, 1) << "ms/frame)\n";
// Performance warning
if (frameMs > 33.3f) {
ss << "WARNING: Frame time exceeds 30 FPS target!\n";
}
ss << "\n";
// Timing breakdown
ss << "Frame Time Breakdown:\n";
ss << " Grid Render: " << formatFloat(metrics.gridRenderTime, 1) << "ms ("
<< formatPercentage(metrics.gridRenderTime, frameMs) << ")\n";
ss << " Cells: " << metrics.gridCellsRendered << " rendered\n";
ss << " Entities: " << metrics.entitiesRendered << " / " << metrics.totalEntities << " drawn\n";
if (metrics.fovOverlayTime > 0.01f) {
ss << " FOV Overlay: " << formatFloat(metrics.fovOverlayTime, 1) << "ms\n";
}
if (metrics.entityRenderTime > 0.01f) {
ss << " Entity Render: " << formatFloat(metrics.entityRenderTime, 1) << "ms ("
<< formatPercentage(metrics.entityRenderTime, frameMs) << ")\n";
}
if (metrics.pythonScriptTime > 0.01f) {
ss << " Python: " << formatFloat(metrics.pythonScriptTime, 1) << "ms ("
<< formatPercentage(metrics.pythonScriptTime, frameMs) << ")\n";
}
if (metrics.animationTime > 0.01f) {
ss << " Animations: " << formatFloat(metrics.animationTime, 1) << "ms ("
<< formatPercentage(metrics.animationTime, frameMs) << ")\n";
}
ss << "\n";
// Other metrics
ss << "Draw Calls: " << metrics.drawCalls << "\n";
ss << "UI Elements: " << metrics.uiElements << " (" << metrics.visibleElements << " visible)\n";
// Calculate unaccounted time
float accountedTime = metrics.gridRenderTime + metrics.entityRenderTime +
metrics.pythonScriptTime + metrics.animationTime;
float unaccountedTime = frameMs - accountedTime;
if (unaccountedTime > 1.0f) {
ss << "\n";
ss << "Other: " << formatFloat(unaccountedTime, 1) << "ms ("
<< formatPercentage(unaccountedTime, frameMs) << ")\n";
}
ss << "\n";
ss << "Press F3 to hide this overlay";
text.setString(ss.str());
// Update background size to fit text
sf::FloatRect textBounds = text.getLocalBounds();
background.setSize(sf::Vector2f(textBounds.width + 20.0f, textBounds.height + 20.0f));
}
void GameEngine::ProfilerOverlay::render(sf::RenderTarget& target) {
if (!visible) return;
target.draw(background);
target.draw(text);
}

319
src/PyAnimation.cpp Normal file
View File

@ -0,0 +1,319 @@
#include "PyAnimation.h"
#include "McRFPy_API.h"
#include "McRFPy_Doc.h"
#include "UIDrawable.h"
#include "UIFrame.h"
#include "UICaption.h"
#include "UISprite.h"
#include "UIGrid.h"
#include "UIEntity.h"
#include "UI.h" // For the PyTypeObject definitions
#include <cstring>
PyObject* PyAnimation::create(PyTypeObject* type, PyObject* args, PyObject* kwds) {
PyAnimationObject* self = (PyAnimationObject*)type->tp_alloc(type, 0);
if (self != NULL) {
// Will be initialized in init
}
return (PyObject*)self;
}
int PyAnimation::init(PyAnimationObject* self, PyObject* args, PyObject* kwds) {
static const char* keywords[] = {"property", "target", "duration", "easing", "delta", "callback", nullptr};
const char* property_name;
PyObject* target_value;
float duration;
const char* easing_name = "linear";
int delta = 0;
PyObject* callback = nullptr;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|spO", const_cast<char**>(keywords),
&property_name, &target_value, &duration, &easing_name, &delta, &callback)) {
return -1;
}
// Validate callback is callable if provided
if (callback && callback != Py_None && !PyCallable_Check(callback)) {
PyErr_SetString(PyExc_TypeError, "callback must be callable");
return -1;
}
// Convert None to nullptr for C++
if (callback == Py_None) {
callback = nullptr;
}
// Convert Python target value to AnimationValue
AnimationValue animValue;
if (PyFloat_Check(target_value)) {
animValue = static_cast<float>(PyFloat_AsDouble(target_value));
}
else if (PyLong_Check(target_value)) {
animValue = static_cast<int>(PyLong_AsLong(target_value));
}
else if (PyList_Check(target_value)) {
// List of integers for sprite animation
std::vector<int> indices;
Py_ssize_t size = PyList_Size(target_value);
for (Py_ssize_t i = 0; i < size; i++) {
PyObject* item = PyList_GetItem(target_value, i);
if (PyLong_Check(item)) {
indices.push_back(PyLong_AsLong(item));
} else {
PyErr_SetString(PyExc_TypeError, "Sprite animation list must contain only integers");
return -1;
}
}
animValue = indices;
}
else if (PyTuple_Check(target_value)) {
Py_ssize_t size = PyTuple_Size(target_value);
if (size == 2) {
// Vector2f
float x = PyFloat_AsDouble(PyTuple_GetItem(target_value, 0));
float y = PyFloat_AsDouble(PyTuple_GetItem(target_value, 1));
animValue = sf::Vector2f(x, y);
}
else if (size == 3 || size == 4) {
// Color (RGB or RGBA)
int r = PyLong_AsLong(PyTuple_GetItem(target_value, 0));
int g = PyLong_AsLong(PyTuple_GetItem(target_value, 1));
int b = PyLong_AsLong(PyTuple_GetItem(target_value, 2));
int a = size == 4 ? PyLong_AsLong(PyTuple_GetItem(target_value, 3)) : 255;
animValue = sf::Color(r, g, b, a);
}
else {
PyErr_SetString(PyExc_ValueError, "Tuple must have 2 elements (vector) or 3-4 elements (color)");
return -1;
}
}
else if (PyUnicode_Check(target_value)) {
// String for text animation
const char* str = PyUnicode_AsUTF8(target_value);
animValue = std::string(str);
}
else {
PyErr_SetString(PyExc_TypeError, "Target value must be float, int, list, tuple, or string");
return -1;
}
// Get easing function
EasingFunction easingFunc = EasingFunctions::getByName(easing_name);
// Create the Animation
self->data = std::make_shared<Animation>(property_name, animValue, duration, easingFunc, delta != 0, callback);
return 0;
}
void PyAnimation::dealloc(PyAnimationObject* self) {
self->data.reset();
Py_TYPE(self)->tp_free((PyObject*)self);
}
PyObject* PyAnimation::get_property(PyAnimationObject* self, void* closure) {
return PyUnicode_FromString(self->data->getTargetProperty().c_str());
}
PyObject* PyAnimation::get_duration(PyAnimationObject* self, void* closure) {
return PyFloat_FromDouble(self->data->getDuration());
}
PyObject* PyAnimation::get_elapsed(PyAnimationObject* self, void* closure) {
return PyFloat_FromDouble(self->data->getElapsed());
}
PyObject* PyAnimation::get_is_complete(PyAnimationObject* self, void* closure) {
return PyBool_FromLong(self->data->isComplete());
}
PyObject* PyAnimation::get_is_delta(PyAnimationObject* self, void* closure) {
return PyBool_FromLong(self->data->isDelta());
}
PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) {
PyObject* target_obj;
if (!PyArg_ParseTuple(args, "O", &target_obj)) {
return NULL;
}
// Get type objects from the module to ensure they're initialized
PyObject* frame_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame");
PyObject* caption_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption");
PyObject* sprite_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite");
PyObject* grid_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid");
PyObject* entity_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity");
bool handled = false;
// Use PyObject_IsInstance to support inheritance
if (frame_type && PyObject_IsInstance(target_obj, frame_type)) {
PyUIFrameObject* frame = (PyUIFrameObject*)target_obj;
if (frame->data) {
self->data->start(frame->data);
AnimationManager::getInstance().addAnimation(self->data);
handled = true;
}
}
else if (caption_type && PyObject_IsInstance(target_obj, caption_type)) {
PyUICaptionObject* caption = (PyUICaptionObject*)target_obj;
if (caption->data) {
self->data->start(caption->data);
AnimationManager::getInstance().addAnimation(self->data);
handled = true;
}
}
else if (sprite_type && PyObject_IsInstance(target_obj, sprite_type)) {
PyUISpriteObject* sprite = (PyUISpriteObject*)target_obj;
if (sprite->data) {
self->data->start(sprite->data);
AnimationManager::getInstance().addAnimation(self->data);
handled = true;
}
}
else if (grid_type && PyObject_IsInstance(target_obj, grid_type)) {
PyUIGridObject* grid = (PyUIGridObject*)target_obj;
if (grid->data) {
self->data->start(grid->data);
AnimationManager::getInstance().addAnimation(self->data);
handled = true;
}
}
else if (entity_type && PyObject_IsInstance(target_obj, entity_type)) {
// Special handling for Entity since it doesn't inherit from UIDrawable
PyUIEntityObject* entity = (PyUIEntityObject*)target_obj;
if (entity->data) {
self->data->startEntity(entity->data);
AnimationManager::getInstance().addAnimation(self->data);
handled = true;
}
}
// Clean up references
Py_XDECREF(frame_type);
Py_XDECREF(caption_type);
Py_XDECREF(sprite_type);
Py_XDECREF(grid_type);
Py_XDECREF(entity_type);
if (!handled) {
PyErr_SetString(PyExc_TypeError, "Target must be a Frame, Caption, Sprite, Grid, or Entity (or a subclass of these)");
return NULL;
}
Py_RETURN_NONE;
}
PyObject* PyAnimation::update(PyAnimationObject* self, PyObject* args) {
float deltaTime;
if (!PyArg_ParseTuple(args, "f", &deltaTime)) {
return NULL;
}
bool still_running = self->data->update(deltaTime);
return PyBool_FromLong(still_running);
}
PyObject* PyAnimation::get_current_value(PyAnimationObject* self, PyObject* args) {
AnimationValue value = self->data->getCurrentValue();
// Convert AnimationValue back to Python
return std::visit([](const auto& val) -> PyObject* {
using T = std::decay_t<decltype(val)>;
if constexpr (std::is_same_v<T, float>) {
return PyFloat_FromDouble(val);
}
else if constexpr (std::is_same_v<T, int>) {
return PyLong_FromLong(val);
}
else if constexpr (std::is_same_v<T, std::vector<int>>) {
// This shouldn't happen as we interpolate to int
return PyLong_FromLong(0);
}
else if constexpr (std::is_same_v<T, sf::Color>) {
return Py_BuildValue("(iiii)", val.r, val.g, val.b, val.a);
}
else if constexpr (std::is_same_v<T, sf::Vector2f>) {
return Py_BuildValue("(ff)", val.x, val.y);
}
else if constexpr (std::is_same_v<T, std::string>) {
return PyUnicode_FromString(val.c_str());
}
Py_RETURN_NONE;
}, value);
}
PyObject* PyAnimation::complete(PyAnimationObject* self, PyObject* args) {
if (self->data) {
self->data->complete();
}
Py_RETURN_NONE;
}
PyObject* PyAnimation::has_valid_target(PyAnimationObject* self, PyObject* args) {
if (self->data && self->data->hasValidTarget()) {
Py_RETURN_TRUE;
}
Py_RETURN_FALSE;
}
PyGetSetDef PyAnimation::getsetters[] = {
{"property", (getter)get_property, NULL,
MCRF_PROPERTY(property, "Target property name (str, read-only). The property being animated (e.g., 'pos', 'opacity', 'sprite_index')."), NULL},
{"duration", (getter)get_duration, NULL,
MCRF_PROPERTY(duration, "Animation duration in seconds (float, read-only). Total time for the animation to complete."), NULL},
{"elapsed", (getter)get_elapsed, NULL,
MCRF_PROPERTY(elapsed, "Elapsed time in seconds (float, read-only). Time since the animation started."), NULL},
{"is_complete", (getter)get_is_complete, NULL,
MCRF_PROPERTY(is_complete, "Whether animation is complete (bool, read-only). True when elapsed >= duration or complete() was called."), NULL},
{"is_delta", (getter)get_is_delta, NULL,
MCRF_PROPERTY(is_delta, "Whether animation uses delta mode (bool, read-only). In delta mode, the target value is added to the starting value."), NULL},
{NULL}
};
PyMethodDef PyAnimation::methods[] = {
{"start", (PyCFunction)start, METH_VARARGS,
MCRF_METHOD(Animation, start,
MCRF_SIG("(target: UIDrawable)", "None"),
MCRF_DESC("Start the animation on a target UI element."),
MCRF_ARGS_START
MCRF_ARG("target", "The UI element to animate (Frame, Caption, Sprite, Grid, or Entity)")
MCRF_RETURNS("None")
MCRF_NOTE("The animation will automatically stop if the target is destroyed. Call AnimationManager.update(delta_time) each frame to progress animations.")
)},
{"update", (PyCFunction)update, METH_VARARGS,
MCRF_METHOD(Animation, update,
MCRF_SIG("(delta_time: float)", "bool"),
MCRF_DESC("Update the animation by the given time delta."),
MCRF_ARGS_START
MCRF_ARG("delta_time", "Time elapsed since last update in seconds")
MCRF_RETURNS("bool: True if animation is still running, False if complete")
MCRF_NOTE("Typically called by AnimationManager automatically. Manual calls only needed for custom animation control.")
)},
{"get_current_value", (PyCFunction)get_current_value, METH_NOARGS,
MCRF_METHOD(Animation, get_current_value,
MCRF_SIG("()", "Any"),
MCRF_DESC("Get the current interpolated value of the animation."),
MCRF_RETURNS("Any: Current value (type depends on property: float, int, Color tuple, Vector tuple, or str)")
MCRF_NOTE("Return type matches the target property type. For sprite_index returns int, for pos returns (x, y), for fill_color returns (r, g, b, a).")
)},
{"complete", (PyCFunction)complete, METH_NOARGS,
MCRF_METHOD(Animation, complete,
MCRF_SIG("()", "None"),
MCRF_DESC("Complete the animation immediately by jumping to the final value."),
MCRF_RETURNS("None")
MCRF_NOTE("Sets elapsed = duration and applies target value immediately. Completion callback will be called if set.")
)},
{"hasValidTarget", (PyCFunction)has_valid_target, METH_NOARGS,
MCRF_METHOD(Animation, hasValidTarget,
MCRF_SIG("()", "bool"),
MCRF_DESC("Check if the animation still has a valid target."),
MCRF_RETURNS("bool: True if the target still exists, False if it was destroyed")
MCRF_NOTE("Animations automatically clean up when targets are destroyed. Use this to check if manual cleanup is needed.")
)},
{NULL}
};

52
src/PyAnimation.h Normal file
View File

@ -0,0 +1,52 @@
#pragma once
#include "Common.h"
#include "Python.h"
#include "structmember.h"
#include "Animation.h"
#include <memory>
typedef struct {
PyObject_HEAD
std::shared_ptr<Animation> data;
} PyAnimationObject;
class PyAnimation {
public:
static PyObject* create(PyTypeObject* type, PyObject* args, PyObject* kwds);
static int init(PyAnimationObject* self, PyObject* args, PyObject* kwds);
static void dealloc(PyAnimationObject* self);
// Properties
static PyObject* get_property(PyAnimationObject* self, void* closure);
static PyObject* get_duration(PyAnimationObject* self, void* closure);
static PyObject* get_elapsed(PyAnimationObject* self, void* closure);
static PyObject* get_is_complete(PyAnimationObject* self, void* closure);
static PyObject* get_is_delta(PyAnimationObject* self, void* closure);
// Methods
static PyObject* start(PyAnimationObject* self, PyObject* args);
static PyObject* update(PyAnimationObject* self, PyObject* args);
static PyObject* get_current_value(PyAnimationObject* self, PyObject* args);
static PyObject* complete(PyAnimationObject* self, PyObject* args);
static PyObject* has_valid_target(PyAnimationObject* self, PyObject* args);
static PyGetSetDef getsetters[];
static PyMethodDef methods[];
};
namespace mcrfpydef {
static PyTypeObject PyAnimationType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.Animation",
.tp_basicsize = sizeof(PyAnimationObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)PyAnimation::dealloc,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR("Animation object for animating UI properties"),
.tp_methods = PyAnimation::methods,
.tp_getset = PyAnimation::getsetters,
.tp_init = (initproc)PyAnimation::init,
.tp_new = PyAnimation::create,
};
}

View File

@ -5,6 +5,21 @@ PyCallable::PyCallable(PyObject* _target)
target = Py_XNewRef(_target); target = Py_XNewRef(_target);
} }
PyCallable::PyCallable(const PyCallable& other)
{
target = Py_XNewRef(other.target);
}
PyCallable& PyCallable::operator=(const PyCallable& other)
{
if (this != &other) {
PyObject* old_target = target;
target = Py_XNewRef(other.target);
Py_XDECREF(old_target);
}
return *this;
}
PyCallable::~PyCallable() PyCallable::~PyCallable()
{ {
if (target) if (target)
@ -16,49 +31,11 @@ PyObject* PyCallable::call(PyObject* args, PyObject* kwargs)
return PyObject_Call(target, args, kwargs); return PyObject_Call(target, args, kwargs);
} }
bool PyCallable::isNone() bool PyCallable::isNone() const
{ {
return (target == Py_None || target == NULL); return (target == Py_None || target == NULL);
} }
PyTimerCallable::PyTimerCallable(PyObject* _target, int _interval, int now)
: PyCallable(_target), interval(_interval), last_ran(now)
{}
PyTimerCallable::PyTimerCallable()
: PyCallable(Py_None), interval(0), last_ran(0)
{}
bool PyTimerCallable::hasElapsed(int now)
{
return now >= last_ran + interval;
}
void PyTimerCallable::call(int now)
{
PyObject* args = Py_BuildValue("(i)", now);
PyObject* retval = PyCallable::call(args, NULL);
if (!retval)
{
PyErr_Print();
PyErr_Clear();
} else if (retval != Py_None)
{
std::cout << "timer returned a non-None value. It's not an error, it's just not being saved or used." << std::endl;
std::cout << PyUnicode_AsUTF8(PyObject_Repr(retval)) << std::endl;
}
}
bool PyTimerCallable::test(int now)
{
if(hasElapsed(now))
{
call(now);
last_ran = now;
return true;
}
return false;
}
PyClickCallable::PyClickCallable(PyObject* _target) PyClickCallable::PyClickCallable(PyObject* _target)
: PyCallable(_target) : PyCallable(_target)

View File

@ -6,24 +6,15 @@ class PyCallable
{ {
protected: protected:
PyObject* target; PyObject* target;
public:
PyCallable(PyObject*); PyCallable(PyObject*);
PyCallable(const PyCallable& other);
PyCallable& operator=(const PyCallable& other);
~PyCallable(); ~PyCallable();
PyObject* call(PyObject*, PyObject*); PyObject* call(PyObject*, PyObject*);
public: bool isNone() const;
bool isNone(); PyObject* borrow() const { return target; }
};
class PyTimerCallable: public PyCallable
{
private:
int interval;
int last_ran;
void call(int);
public:
bool hasElapsed(int);
bool test(int);
PyTimerCallable(PyObject*, int, int);
PyTimerCallable();
}; };
class PyClickCallable: public PyCallable class PyClickCallable: public PyCallable
@ -33,6 +24,11 @@ public:
PyObject* borrow(); PyObject* borrow();
PyClickCallable(PyObject*); PyClickCallable(PyObject*);
PyClickCallable(); PyClickCallable();
PyClickCallable(const PyClickCallable& other) : PyCallable(other) {}
PyClickCallable& operator=(const PyClickCallable& other) {
PyCallable::operator=(other);
return *this;
}
}; };
class PyKeyCallable: public PyCallable class PyKeyCallable: public PyCallable

View File

@ -1,11 +1,51 @@
#include "PyColor.h" #include "PyColor.h"
#include "McRFPy_API.h" #include "McRFPy_API.h"
#include "PyObjectUtils.h"
#include "PyRAII.h"
#include "McRFPy_Doc.h"
#include <string>
#include <cstdio>
PyGetSetDef PyColor::getsetters[] = { PyGetSetDef PyColor::getsetters[] = {
{"r", (getter)PyColor::get_member, (setter)PyColor::set_member, "Red component", (void*)0}, {"r", (getter)PyColor::get_member, (setter)PyColor::set_member,
{"g", (getter)PyColor::get_member, (setter)PyColor::set_member, "Green component", (void*)1}, MCRF_PROPERTY(r, "Red component (0-255). Automatically clamped to valid range."), (void*)0},
{"b", (getter)PyColor::get_member, (setter)PyColor::set_member, "Blue component", (void*)2}, {"g", (getter)PyColor::get_member, (setter)PyColor::set_member,
{"a", (getter)PyColor::get_member, (setter)PyColor::set_member, "Alpha component", (void*)3}, MCRF_PROPERTY(g, "Green component (0-255). Automatically clamped to valid range."), (void*)1},
{"b", (getter)PyColor::get_member, (setter)PyColor::set_member,
MCRF_PROPERTY(b, "Blue component (0-255). Automatically clamped to valid range."), (void*)2},
{"a", (getter)PyColor::get_member, (setter)PyColor::set_member,
MCRF_PROPERTY(a, "Alpha component (0-255, where 0=transparent, 255=opaque). Automatically clamped to valid range."), (void*)3},
{NULL}
};
PyMethodDef PyColor::methods[] = {
{"from_hex", (PyCFunction)PyColor::from_hex, METH_VARARGS | METH_CLASS,
MCRF_METHOD(Color, from_hex,
MCRF_SIG("(hex_string: str)", "Color"),
MCRF_DESC("Create a Color from a hexadecimal string."),
MCRF_ARGS_START
MCRF_ARG("hex_string", "Hex color string (e.g., '#FF0000', 'FF0000', '#AABBCCDD' for RGBA)")
MCRF_RETURNS("Color: New Color object with values from hex string")
MCRF_RAISES("ValueError", "If hex string is not 6 or 8 characters (RGB or RGBA)")
MCRF_NOTE("This is a class method. Call as Color.from_hex('#FF0000')")
)},
{"to_hex", (PyCFunction)PyColor::to_hex, METH_NOARGS,
MCRF_METHOD(Color, to_hex,
MCRF_SIG("()", "str"),
MCRF_DESC("Convert this Color to a hexadecimal string."),
MCRF_RETURNS("str: Hex string in format '#RRGGBB' or '#RRGGBBAA' (if alpha < 255)")
MCRF_NOTE("Alpha component is only included if not fully opaque (< 255)")
)},
{"lerp", (PyCFunction)PyColor::lerp, METH_VARARGS,
MCRF_METHOD(Color, lerp,
MCRF_SIG("(other: Color, t: float)", "Color"),
MCRF_DESC("Linearly interpolate between this color and another."),
MCRF_ARGS_START
MCRF_ARG("other", "The target Color to interpolate towards")
MCRF_ARG("t", "Interpolation factor (0.0 = this color, 1.0 = other color). Automatically clamped to [0.0, 1.0]")
MCRF_RETURNS("Color: New Color representing the interpolated value")
MCRF_NOTE("All components (r, g, b, a) are interpolated independently")
)},
{NULL} {NULL}
}; };
@ -14,11 +54,16 @@ PyColor::PyColor(sf::Color target)
PyObject* PyColor::pyObject() PyObject* PyColor::pyObject()
{ {
PyObject* obj = PyType_GenericAlloc(&mcrfpydef::PyColorType, 0); PyTypeObject* type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Color");
Py_INCREF(obj); if (!type) return nullptr;
PyColorObject* self = (PyColorObject*)obj;
self->data = data; PyColorObject* obj = (PyColorObject*)type->tp_alloc(type, 0);
return obj; Py_DECREF(type);
if (obj) {
obj->data = data;
}
return (PyObject*)obj;
} }
sf::Color PyColor::fromPy(PyObject* obj) sf::Color PyColor::fromPy(PyObject* obj)
@ -126,25 +171,189 @@ PyObject* PyColor::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds)
PyObject* PyColor::get_member(PyObject* obj, void* closure) PyObject* PyColor::get_member(PyObject* obj, void* closure)
{ {
// TODO PyColorObject* self = (PyColorObject*)obj;
return Py_None; long member = (long)closure;
switch (member) {
case 0: // r
return PyLong_FromLong(self->data.r);
case 1: // g
return PyLong_FromLong(self->data.g);
case 2: // b
return PyLong_FromLong(self->data.b);
case 3: // a
return PyLong_FromLong(self->data.a);
default:
PyErr_SetString(PyExc_AttributeError, "Invalid color member");
return NULL;
}
} }
int PyColor::set_member(PyObject* obj, PyObject* value, void* closure) int PyColor::set_member(PyObject* obj, PyObject* value, void* closure)
{ {
// TODO PyColorObject* self = (PyColorObject*)obj;
long member = (long)closure;
if (!PyLong_Check(value)) {
PyErr_SetString(PyExc_TypeError, "Color values must be integers");
return -1;
}
long val = PyLong_AsLong(value);
if (val < 0 || val > 255) {
PyErr_SetString(PyExc_ValueError, "Color values must be between 0 and 255");
return -1;
}
switch (member) {
case 0: // r
self->data.r = static_cast<sf::Uint8>(val);
break;
case 1: // g
self->data.g = static_cast<sf::Uint8>(val);
break;
case 2: // b
self->data.b = static_cast<sf::Uint8>(val);
break;
case 3: // a
self->data.a = static_cast<sf::Uint8>(val);
break;
default:
PyErr_SetString(PyExc_AttributeError, "Invalid color member");
return -1;
}
return 0; return 0;
} }
PyColorObject* PyColor::from_arg(PyObject* args) PyColorObject* PyColor::from_arg(PyObject* args)
{ {
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Color"); // Use RAII for type reference management
if (PyObject_IsInstance(args, (PyObject*)type)) return (PyColorObject*)args; PyRAII::PyTypeRef type("Color", McRFPy_API::mcrf_module);
auto obj = (PyColorObject*)type->tp_alloc(type, 0); if (!type) {
int err = init(obj, args, NULL);
if (err) {
Py_DECREF(obj);
return NULL; return NULL;
} }
return obj;
// Check if args is already a Color instance
if (PyObject_IsInstance(args, (PyObject*)type.get())) {
return (PyColorObject*)args;
}
// Create new Color object using RAII
PyRAII::PyObjectRef obj(type->tp_alloc(type.get(), 0), true);
if (!obj) {
return NULL;
}
// Initialize the object
int err = init((PyColorObject*)obj.get(), args, NULL);
if (err) {
// obj will be automatically cleaned up when it goes out of scope
return NULL;
}
// Release ownership and return
return (PyColorObject*)obj.release();
}
// Color helper method implementations
PyObject* PyColor::from_hex(PyObject* cls, PyObject* args)
{
const char* hex_str;
if (!PyArg_ParseTuple(args, "s", &hex_str)) {
return NULL;
}
std::string hex(hex_str);
// Remove # if present
if (hex.length() > 0 && hex[0] == '#') {
hex = hex.substr(1);
}
// Validate hex string
if (hex.length() != 6 && hex.length() != 8) {
PyErr_SetString(PyExc_ValueError, "Hex string must be 6 or 8 characters (RGB or RGBA)");
return NULL;
}
// Parse hex values
try {
unsigned int r = std::stoul(hex.substr(0, 2), nullptr, 16);
unsigned int g = std::stoul(hex.substr(2, 2), nullptr, 16);
unsigned int b = std::stoul(hex.substr(4, 2), nullptr, 16);
unsigned int a = 255;
if (hex.length() == 8) {
a = std::stoul(hex.substr(6, 2), nullptr, 16);
}
// Create new Color object
PyTypeObject* type = (PyTypeObject*)cls;
PyColorObject* color = (PyColorObject*)type->tp_alloc(type, 0);
if (color) {
color->data = sf::Color(r, g, b, a);
}
return (PyObject*)color;
} catch (const std::exception& e) {
PyErr_SetString(PyExc_ValueError, "Invalid hex string");
return NULL;
}
}
PyObject* PyColor::to_hex(PyColorObject* self, PyObject* Py_UNUSED(ignored))
{
char hex[10]; // #RRGGBBAA + null terminator
// Include alpha only if not fully opaque
if (self->data.a < 255) {
snprintf(hex, sizeof(hex), "#%02X%02X%02X%02X",
self->data.r, self->data.g, self->data.b, self->data.a);
} else {
snprintf(hex, sizeof(hex), "#%02X%02X%02X",
self->data.r, self->data.g, self->data.b);
}
return PyUnicode_FromString(hex);
}
PyObject* PyColor::lerp(PyColorObject* self, PyObject* args)
{
PyObject* other_obj;
float t;
if (!PyArg_ParseTuple(args, "Of", &other_obj, &t)) {
return NULL;
}
// Validate other color
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Color");
if (!PyObject_IsInstance(other_obj, (PyObject*)type)) {
Py_DECREF(type);
PyErr_SetString(PyExc_TypeError, "First argument must be a Color");
return NULL;
}
PyColorObject* other = (PyColorObject*)other_obj;
// Clamp t to [0, 1]
if (t < 0.0f) t = 0.0f;
if (t > 1.0f) t = 1.0f;
// Perform linear interpolation
sf::Uint8 r = static_cast<sf::Uint8>(self->data.r + (other->data.r - self->data.r) * t);
sf::Uint8 g = static_cast<sf::Uint8>(self->data.g + (other->data.g - self->data.g) * t);
sf::Uint8 b = static_cast<sf::Uint8>(self->data.b + (other->data.b - self->data.b) * t);
sf::Uint8 a = static_cast<sf::Uint8>(self->data.a + (other->data.a - self->data.a) * t);
// Create new Color object
PyColorObject* result = (PyColorObject*)type->tp_alloc(type, 0);
Py_DECREF(type);
if (result) {
result->data = sf::Color(r, g, b, a);
}
return (PyObject*)result;
} }

View File

@ -28,12 +28,19 @@ public:
static PyObject* get_member(PyObject*, void*); static PyObject* get_member(PyObject*, void*);
static int set_member(PyObject*, PyObject*, void*); static int set_member(PyObject*, PyObject*, void*);
// Color helper methods
static PyObject* from_hex(PyObject* cls, PyObject* args);
static PyObject* to_hex(PyColorObject* self, PyObject* Py_UNUSED(ignored));
static PyObject* lerp(PyColorObject* self, PyObject* args);
static PyGetSetDef getsetters[]; static PyGetSetDef getsetters[];
static PyMethodDef methods[];
static PyColorObject* from_arg(PyObject*); static PyColorObject* from_arg(PyObject*);
}; };
namespace mcrfpydef { namespace mcrfpydef {
static PyTypeObject PyColorType = { static PyTypeObject PyColorType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.Color", .tp_name = "mcrfpy.Color",
.tp_basicsize = sizeof(PyColorObject), .tp_basicsize = sizeof(PyColorObject),
.tp_itemsize = 0, .tp_itemsize = 0,
@ -41,6 +48,7 @@ namespace mcrfpydef {
.tp_hash = PyColor::hash, .tp_hash = PyColor::hash,
.tp_flags = Py_TPFLAGS_DEFAULT, .tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR("SFML Color Object"), .tp_doc = PyDoc_STR("SFML Color Object"),
.tp_methods = PyColor::methods,
.tp_getset = PyColor::getsetters, .tp_getset = PyColor::getsetters,
.tp_init = (initproc)PyColor::init, .tp_init = (initproc)PyColor::init,
.tp_new = PyColor::pynew, .tp_new = PyColor::pynew,

211
src/PyDrawable.cpp Normal file
View File

@ -0,0 +1,211 @@
#include "PyDrawable.h"
#include "McRFPy_API.h"
#include "McRFPy_Doc.h"
// Click property getter
static PyObject* PyDrawable_get_click(PyDrawableObject* self, void* closure)
{
if (!self->data->click_callable)
Py_RETURN_NONE;
PyObject* ptr = self->data->click_callable->borrow();
if (ptr && ptr != Py_None)
return ptr;
else
Py_RETURN_NONE;
}
// Click property setter
static int PyDrawable_set_click(PyDrawableObject* self, PyObject* value, void* closure)
{
if (value == Py_None) {
self->data->click_unregister();
} else if (PyCallable_Check(value)) {
self->data->click_register(value);
} else {
PyErr_SetString(PyExc_TypeError, "click must be callable or None");
return -1;
}
return 0;
}
// Z-index property getter
static PyObject* PyDrawable_get_z_index(PyDrawableObject* self, void* closure)
{
return PyLong_FromLong(self->data->z_index);
}
// Z-index property setter
static int PyDrawable_set_z_index(PyDrawableObject* self, PyObject* value, void* closure)
{
if (!PyLong_Check(value)) {
PyErr_SetString(PyExc_TypeError, "z_index must be an integer");
return -1;
}
int val = PyLong_AsLong(value);
self->data->z_index = val;
// Mark scene as needing resort
self->data->notifyZIndexChanged();
return 0;
}
// Visible property getter (new for #87)
static PyObject* PyDrawable_get_visible(PyDrawableObject* self, void* closure)
{
return PyBool_FromLong(self->data->visible);
}
// Visible property setter (new for #87)
static int PyDrawable_set_visible(PyDrawableObject* self, PyObject* value, void* closure)
{
if (!PyBool_Check(value)) {
PyErr_SetString(PyExc_TypeError, "visible must be a boolean");
return -1;
}
self->data->visible = (value == Py_True);
return 0;
}
// Opacity property getter (new for #88)
static PyObject* PyDrawable_get_opacity(PyDrawableObject* self, void* closure)
{
return PyFloat_FromDouble(self->data->opacity);
}
// Opacity property setter (new for #88)
static int PyDrawable_set_opacity(PyDrawableObject* self, PyObject* value, void* closure)
{
float val;
if (PyFloat_Check(value)) {
val = PyFloat_AsDouble(value);
} else if (PyLong_Check(value)) {
val = PyLong_AsLong(value);
} else {
PyErr_SetString(PyExc_TypeError, "opacity must be a number");
return -1;
}
// Clamp to valid range
if (val < 0.0f) val = 0.0f;
if (val > 1.0f) val = 1.0f;
self->data->opacity = val;
return 0;
}
// GetSetDef array for properties
static PyGetSetDef PyDrawable_getsetters[] = {
{"click", (getter)PyDrawable_get_click, (setter)PyDrawable_set_click,
MCRF_PROPERTY(click,
"Callable executed when object is clicked. "
"Function receives (x, y) coordinates of click."
), NULL},
{"z_index", (getter)PyDrawable_get_z_index, (setter)PyDrawable_set_z_index,
MCRF_PROPERTY(z_index,
"Z-order for rendering (lower values rendered first). "
"Automatically triggers scene resort when changed."
), NULL},
{"visible", (getter)PyDrawable_get_visible, (setter)PyDrawable_set_visible,
MCRF_PROPERTY(visible,
"Whether the object is visible (bool). "
"Invisible objects are not rendered or clickable."
), NULL},
{"opacity", (getter)PyDrawable_get_opacity, (setter)PyDrawable_set_opacity,
MCRF_PROPERTY(opacity,
"Opacity level (0.0 = transparent, 1.0 = opaque). "
"Automatically clamped to valid range [0.0, 1.0]."
), NULL},
{NULL} // Sentinel
};
// get_bounds method implementation (#89)
static PyObject* PyDrawable_get_bounds(PyDrawableObject* self, PyObject* Py_UNUSED(args))
{
auto bounds = self->data->get_bounds();
return Py_BuildValue("(ffff)", bounds.left, bounds.top, bounds.width, bounds.height);
}
// move method implementation (#98)
static PyObject* PyDrawable_move(PyDrawableObject* self, PyObject* args)
{
float dx, dy;
if (!PyArg_ParseTuple(args, "ff", &dx, &dy)) {
return NULL;
}
self->data->move(dx, dy);
Py_RETURN_NONE;
}
// resize method implementation (#98)
static PyObject* PyDrawable_resize(PyDrawableObject* self, PyObject* args)
{
float w, h;
if (!PyArg_ParseTuple(args, "ff", &w, &h)) {
return NULL;
}
self->data->resize(w, h);
Py_RETURN_NONE;
}
// Method definitions
static PyMethodDef PyDrawable_methods[] = {
{"get_bounds", (PyCFunction)PyDrawable_get_bounds, METH_NOARGS,
MCRF_METHOD(Drawable, get_bounds,
MCRF_SIG("()", "tuple"),
MCRF_DESC("Get the bounding rectangle of this drawable element."),
MCRF_RETURNS("tuple: (x, y, width, height) representing the element's bounds")
MCRF_NOTE("The bounds are in screen coordinates and account for current position and size.")
)},
{"move", (PyCFunction)PyDrawable_move, METH_VARARGS,
MCRF_METHOD(Drawable, move,
MCRF_SIG("(dx: float, dy: float)", "None"),
MCRF_DESC("Move the element by a relative offset."),
MCRF_ARGS_START
MCRF_ARG("dx", "Horizontal offset in pixels")
MCRF_ARG("dy", "Vertical offset in pixels")
MCRF_NOTE("This modifies the x and y position properties by the given amounts.")
)},
{"resize", (PyCFunction)PyDrawable_resize, METH_VARARGS,
MCRF_METHOD(Drawable, resize,
MCRF_SIG("(width: float, height: float)", "None"),
MCRF_DESC("Resize the element to new dimensions."),
MCRF_ARGS_START
MCRF_ARG("width", "New width in pixels")
MCRF_ARG("height", "New height in pixels")
MCRF_NOTE("For Caption and Sprite, this may not change actual size if determined by content.")
)},
{NULL} // Sentinel
};
// Type initialization
static int PyDrawable_init(PyDrawableObject* self, PyObject* args, PyObject* kwds)
{
PyErr_SetString(PyExc_TypeError, "Drawable is an abstract base class and cannot be instantiated directly");
return -1;
}
namespace mcrfpydef {
PyTypeObject PyDrawableType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.Drawable",
.tp_basicsize = sizeof(PyDrawableObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)[](PyObject* self) {
PyDrawableObject* obj = (PyDrawableObject*)self;
obj->data.reset();
Py_TYPE(self)->tp_free(self);
},
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE,
.tp_doc = PyDoc_STR("Base class for all drawable UI elements"),
.tp_methods = PyDrawable_methods,
.tp_getset = PyDrawable_getsetters,
.tp_init = (initproc)PyDrawable_init,
.tp_new = PyType_GenericNew,
};
}

15
src/PyDrawable.h Normal file
View File

@ -0,0 +1,15 @@
#pragma once
#include "Common.h"
#include "Python.h"
#include "UIDrawable.h"
// Python object structure for UIDrawable base class
typedef struct {
PyObject_HEAD
std::shared_ptr<UIDrawable> data;
} PyDrawableObject;
// Declare the Python type for Drawable base class
namespace mcrfpydef {
extern PyTypeObject PyDrawableType;
}

View File

@ -1,5 +1,6 @@
#include "PyFont.h" #include "PyFont.h"
#include "McRFPy_API.h" #include "McRFPy_API.h"
#include "McRFPy_Doc.h"
PyFont::PyFont(std::string filename) PyFont::PyFont(std::string filename)
@ -61,3 +62,21 @@ PyObject* PyFont::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds)
{ {
return (PyObject*)type->tp_alloc(type, 0); return (PyObject*)type->tp_alloc(type, 0);
} }
PyObject* PyFont::get_family(PyFontObject* self, void* closure)
{
return PyUnicode_FromString(self->data->font.getInfo().family.c_str());
}
PyObject* PyFont::get_source(PyFontObject* self, void* closure)
{
return PyUnicode_FromString(self->data->source.c_str());
}
PyGetSetDef PyFont::getsetters[] = {
{"family", (getter)PyFont::get_family, NULL,
MCRF_PROPERTY(family, "Font family name (str, read-only). Retrieved from font metadata."), NULL},
{"source", (getter)PyFont::get_source, NULL,
MCRF_PROPERTY(source, "Source filename path (str, read-only). The path used to load this font."), NULL},
{NULL} // Sentinel
};

View File

@ -21,10 +21,17 @@ public:
static Py_hash_t hash(PyObject*); static Py_hash_t hash(PyObject*);
static int init(PyFontObject*, PyObject*, PyObject*); static int init(PyFontObject*, PyObject*, PyObject*);
static PyObject* pynew(PyTypeObject* type, PyObject* args=NULL, PyObject* kwds=NULL); static PyObject* pynew(PyTypeObject* type, PyObject* args=NULL, PyObject* kwds=NULL);
// Getters for properties
static PyObject* get_family(PyFontObject* self, void* closure);
static PyObject* get_source(PyFontObject* self, void* closure);
static PyGetSetDef getsetters[];
}; };
namespace mcrfpydef { namespace mcrfpydef {
static PyTypeObject PyFontType = { static PyTypeObject PyFontType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.Font", .tp_name = "mcrfpy.Font",
.tp_basicsize = sizeof(PyFontObject), .tp_basicsize = sizeof(PyFontObject),
.tp_itemsize = 0, .tp_itemsize = 0,
@ -32,6 +39,7 @@ namespace mcrfpydef {
//.tp_hash = PyFont::hash, //.tp_hash = PyFont::hash,
.tp_flags = Py_TPFLAGS_DEFAULT, .tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR("SFML Font Object"), .tp_doc = PyDoc_STR("SFML Font Object"),
.tp_getset = PyFont::getsetters,
//.tp_base = &PyBaseObject_Type, //.tp_base = &PyBaseObject_Type,
.tp_init = (initproc)PyFont::init, .tp_init = (initproc)PyFont::init,
.tp_new = PyType_GenericNew, //PyFont::pynew, .tp_new = PyType_GenericNew, //PyFont::pynew,

Some files were not shown because too many files have changed in this diff Show More