Compare commits

...

7 commits

Author SHA1 Message Date
188b312af0 Re-enable ASan leak detection, add Massif heap profiling target
#286: Change detect_leaks=0 to detect_leaks=1 in asan-test target.
LSAN suppressions for CPython intentional leaks (interned strings, type
objects, small int cache, etc.) were already in sanitizers/asan.supp.
Now that #266 and #275 are fixed, real McRogueFace leaks will be caught.

#284: Add make massif-test target that runs stress_test_suite.py under
Valgrind Massif for heap profiling. Output goes to build-debug/massif.out,
viewable with ms_print.

Closes #286, closes #284

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 01:08:54 -04:00
e7462e37a3 Remove camelCase module functions (setScale, findAll, getMetrics, setDevConsole), closes #304
Breaking API change: removes 4 camelCase function aliases from the mcrfpy
module. The snake_case equivalents (set_scale, find_all, get_metrics,
set_dev_console) remain and are the canonical API going forward.

- Removed setScale, findAll, getMetrics, setDevConsole from mcrfpyMethods[]
- Updated game scripts to use snake_case names
- Updated test scripts to use snake_case names
- Removed camelCase entries from type stubs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 01:07:22 -04:00
9ca79baec8 Fix grid layers with z_index=0 rendering on top of entities, closes #257
Layers with z_index <= 0 now render below entities (ground level), and
only layers with z_index > 0 render above entities. Previously z_index=0
was treated as "above entities" which was unintuitive -- entities stand
on ground level (z=0) with their feet, so z=0 layers should be beneath.

Changed in both UIGrid.cpp and UIGridView.cpp render methods:
- "z_index >= 0" to "z_index > 0" for break condition
- "z_index < 0" to "z_index <= 0" for skip condition

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 01:07:08 -04:00
e58b44ef82 Add missing markDirty()/markCompositeDirty() to all Python property setters
Fixes a systemic bug where Python tp_getset property setters bypassed the
render cache dirty flag system (#144). The animation/C++ setProperty() path
had correct dirty propagation, but direct Python property assignments
(e.g. frame.x = 50, caption.text = "Hello") did not invalidate the parent
Frame's render cache when clip_children or cache_subtree was enabled.

Changes by file:
- UIDrawable.cpp: Add markCompositeDirty() to set_float_member (x/y),
  set_pos, set_grid_pos; add markDirty() for w/h resize
- UICaption.cpp: Add markDirty() to set_text, set_color_member,
  set_float_member (outline/font_size); markCompositeDirty() for position
- UICollection.cpp: Add markContentDirty() on owner in append, remove,
  pop, insert, extend, setitem, and slice assignment/deletion
- UISprite.cpp: Add markDirty() to scale/sprite_index/texture setters;
  markCompositeDirty() to position setters
- UICircle.cpp: Add markDirty() to radius/fill_color/outline_color/outline;
  markCompositeDirty() to center setter
- UILine.cpp: Add markDirty() to start/end/color/thickness setters
- UIArc.cpp: Add markDirty() to radius/angles/color/thickness setters;
  markCompositeDirty() to center setter
- UIGrid.cpp: Add markDirty() to center/zoom/camera_rotation/fill_color/
  size/perspective/fov setters

Closes #288, closes #289, closes #290, closes #291

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 01:01:41 -04:00
d73a207535 Add web-playable WASM demo with BSP dungeon crawler
- Create self-contained demo game script (src/scripts_demo/game.py) showcasing:
  BSP dungeon generation, Wang tile autotiling, FOV with fog of war,
  turn-based bump combat, enemy AI, items/treasure, title screen
- Add MCRF_DEMO CMake option for building with demo scripts
- Add web/index.html landing page with dark theme, controls reference,
  feature list, and links to GitHub/Gitea
- Build with: emcmake cmake -DMCRF_SDL2=ON -DMCRF_DEMO=ON -DMCRF_GAME_SHELL=ON

Note: Makefile wasm-demo/serve-demo targets also added locally but Makefile
is gitignored. Use CMake directly or force-add the Makefile to track it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 00:41:57 -04:00
c332772324 Add issue triage document for all 46 open issues
Categorize open issues #53–#304 into 14 system-related groups,
prioritized by impact. Recommends tackling dirty-flag bugs (#288-#291)
and dangling-pointer bugs (#270, #271, #277) first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 00:37:20 -04:00
1805b985bd Update all 13 tutorial scripts to current enum-based API, refs #167
All tutorial parts (1-13) used the old string-based key/action
comparison API removed in 6d5e99a. Every handle_keys function now
uses mcrfpy.Key.* and mcrfpy.InputState.PRESSED enums.

Additional fixes across all parts:
- Replace manual FOV computation with ColorLayer.draw_fov() which
  handles FOV calculation and explored-state tracking in one call
- Replace old grid.add_layer("color") with ColorLayer() constructor
- Fix entity removal bug: entities.remove(index) -> remove(entity_ref)
- Remove manual exploration tracking (draw_fov handles it internally)
- Use tuple positions for compute_fov/is_in_fov: (x, y) not x, y

All 14 parts (0-13) tested and passing in headless mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 23:23:35 -04:00
37 changed files with 2848 additions and 855 deletions

View file

@ -17,6 +17,9 @@ option(MCRF_SDL2 "Build with SDL2+OpenGL ES 2 backend instead of SFML" OFF)
# Playground mode - minimal scripts for web playground (REPL-focused)
option(MCRF_PLAYGROUND "Build with minimal playground scripts instead of full game" OFF)
# Demo mode - self-contained demo game for web showcase
option(MCRF_DEMO "Build with demo scripts (web showcase)" OFF)
# Game shell mode - fullscreen canvas, no REPL chrome (for itch.io / standalone web games)
option(MCRF_GAME_SHELL "Use minimal game-only HTML shell (no REPL)" OFF)
@ -343,6 +346,7 @@ endif()
set(MCRF_ASSETS_DIR "${CMAKE_SOURCE_DIR}/assets" CACHE PATH "Assets directory for WASM preloading")
set(MCRF_SCRIPTS_DIR "${CMAKE_SOURCE_DIR}/src/scripts" CACHE PATH "Scripts directory for WASM preloading")
set(MCRF_SCRIPTS_PLAYGROUND_DIR "${CMAKE_SOURCE_DIR}/src/scripts_playground" CACHE PATH "Playground scripts for WASM")
set(MCRF_SCRIPTS_DEMO_DIR "${CMAKE_SOURCE_DIR}/src/scripts_demo" CACHE PATH "Demo scripts for WASM showcase")
# Emscripten-specific link options (use ports for zlib, bzip2, sqlite3)
if(EMSCRIPTEN)
@ -365,8 +369,8 @@ if(EMSCRIPTEN)
-sALLOW_UNIMPLEMENTED_SYSCALLS=1
# Preload Python stdlib into virtual filesystem at /lib/python3.14
--preload-file=${CMAKE_SOURCE_DIR}/wasm_stdlib/lib@/lib
# Preload game scripts into /scripts (use playground scripts if MCRF_PLAYGROUND is set)
--preload-file=$<IF:$<BOOL:${MCRF_PLAYGROUND}>,${MCRF_SCRIPTS_PLAYGROUND_DIR},${MCRF_SCRIPTS_DIR}>@/scripts
# Preload game scripts into /scripts (playground, demo, or full game)
--preload-file=$<IF:$<BOOL:${MCRF_PLAYGROUND}>,${MCRF_SCRIPTS_PLAYGROUND_DIR},$<IF:$<BOOL:${MCRF_DEMO}>,${MCRF_SCRIPTS_DEMO_DIR},${MCRF_SCRIPTS_DIR}>>@/scripts
# Preload assets
--preload-file=${MCRF_ASSETS_DIR}@/assets
# Use custom HTML shell - game shell (fullscreen) or playground shell (REPL)

327
Makefile Normal file
View file

@ -0,0 +1,327 @@
# McRogueFace Build Makefile
# Usage:
# make - Build for Linux (default)
# make windows - Cross-compile for Windows using MinGW (release)
# make windows-debug - Cross-compile for Windows with console & debug symbols
# make clean - Clean Linux build
# make clean-windows - Clean Windows build
# make run - Run the Linux build
#
# WebAssembly / Emscripten:
# make wasm - Build full game for web (requires emsdk activated)
# make wasm-game - Build game for web with fullscreen canvas (no REPL)
# make playground - Build minimal playground for web REPL
# make serve - Serve wasm build locally on port 8080
# make serve-game - Serve wasm-game build locally on port 8080
# make clean-wasm - Clean Emscripten builds
#
# Packaging:
# make package-windows-light - Windows with minimal stdlib (~5 MB)
# make package-windows-full - Windows with full stdlib (~15 MB)
# make package-linux-light - Linux with minimal stdlib
# make package-linux-full - Linux with full stdlib
# make package-all - All platform/preset combinations
#
# Release:
# make version-bump NEXT_VERSION=x.y.z-suffix
# Tags HEAD with current version, builds all packages, bumps to NEXT_VERSION
.PHONY: all linux windows windows-debug clean clean-windows clean-dist run
.PHONY: wasm wasm-game playground serve serve-game serve-playground clean-wasm
.PHONY: package-windows-light package-windows-full package-linux-light package-linux-full package-all
.PHONY: version-bump
.PHONY: debug debug-test asan asan-test valgrind-test massif-test analyze clean-debug
# Number of parallel jobs for compilation
JOBS := $(shell nproc 2>/dev/null || echo 4)
all: linux
linux:
@echo "Building McRogueFace for Linux..."
@mkdir -p build
@cd build && cmake .. -DCMAKE_BUILD_TYPE=Release && make -j$(JOBS)
@echo "Build complete! Run with: ./build/mcrogueface"
windows:
@echo "Cross-compiling McRogueFace for Windows..."
@mkdir -p build-windows
@cd build-windows && cmake .. \
-DCMAKE_TOOLCHAIN_FILE=../cmake/toolchains/mingw-w64-x86_64.cmake \
-DCMAKE_BUILD_TYPE=Release && make -j$(JOBS)
@echo "Windows build complete! Output: build-windows/mcrogueface.exe"
windows-debug:
@echo "Cross-compiling McRogueFace for Windows (debug with console)..."
@mkdir -p build-windows-debug
@cd build-windows-debug && cmake .. \
-DCMAKE_TOOLCHAIN_FILE=../cmake/toolchains/mingw-w64-x86_64.cmake \
-DCMAKE_BUILD_TYPE=Debug \
-DMCRF_WINDOWS_CONSOLE=ON && make -j$(JOBS)
@echo "Windows debug build complete! Output: build-windows-debug/mcrogueface.exe"
@echo "Run from cmd.exe to see console output"
clean:
@echo "Cleaning Linux build..."
@rm -rf build
clean-windows:
@echo "Cleaning Windows builds..."
@rm -rf build-windows build-windows-debug
clean-dist:
@echo "Cleaning distribution packages..."
@rm -rf dist
clean-all: clean clean-windows clean-wasm clean-debug clean-dist
@echo "All builds and packages cleaned."
run: linux
@cd build && ./mcrogueface
# Debug and sanitizer targets
debug:
@echo "Building McRogueFace with debug Python (pydebug assertions)..."
@mkdir -p build-debug
@cd build-debug && cmake .. \
-DCMAKE_BUILD_TYPE=Debug \
-DMCRF_DEBUG_PYTHON=ON && make -j$(JOBS)
@echo "Debug build complete! Output: build-debug/mcrogueface"
debug-test: debug
@echo "Running test suite with debug Python..."
cd tests && MCRF_BUILD_DIR=../build-debug \
MCRF_LIB_DIR=../__lib_debug \
python3 run_tests.py -v
asan:
@echo "Building McRogueFace with ASan + UBSan..."
@mkdir -p build-asan
@cd build-asan && cmake .. \
-DCMAKE_BUILD_TYPE=Debug \
-DMCRF_DEBUG_PYTHON=ON \
-DMCRF_SANITIZE_ADDRESS=ON \
-DMCRF_SANITIZE_UNDEFINED=ON && make -j$(JOBS)
@echo "ASan build complete! Output: build-asan/mcrogueface"
asan-test: asan
@echo "Running test suite under ASan + UBSan..."
cd tests && MCRF_BUILD_DIR=../build-asan \
MCRF_LIB_DIR=../__lib_debug \
PYTHONMALLOC=malloc \
ASAN_OPTIONS="detect_leaks=1:halt_on_error=1:print_summary=1" \
LSAN_OPTIONS="suppressions=$(CURDIR)/sanitizers/asan.supp" \
UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1" \
python3 run_tests.py -v --sanitizer
valgrind-test: debug
@echo "Running test suite under Valgrind memcheck..."
cd tests && MCRF_BUILD_DIR=../build-debug \
MCRF_LIB_DIR=../__lib_debug \
MCRF_TIMEOUT_MULTIPLIER=50 \
PYTHONMALLOC=malloc \
python3 run_tests.py -v --valgrind
massif-test: debug
@echo "Running heap profiling under Valgrind Massif..."
@mkdir -p build-debug
cd build-debug && valgrind --tool=massif \
--massif-out-file=massif.out \
--pages-as-heap=no \
--detailed-freq=10 \
--max-snapshots=100 \
./mcrogueface --headless --exec ../tests/benchmarks/stress_test_suite.py
@echo "Massif output: build-debug/massif.out"
@echo "View with: ms_print build-debug/massif.out"
analyze:
@echo "Running cppcheck static analysis..."
cppcheck --enable=warning,performance,portability \
--suppress=missingIncludeSystem \
--suppress=unusedFunction \
--suppress=noExplicitConstructor \
--suppress=missingOverride \
--inline-suppr \
-I src/ -I deps/ -I deps/cpython -I deps/Python \
-I src/platform -I src/3d -I src/tiled -I src/ldtk -I src/audio \
--std=c++20 \
--quiet \
src/ 2>&1
@echo "Static analysis complete."
clean-debug:
@echo "Cleaning debug/sanitizer builds..."
@rm -rf build-debug build-asan
# Packaging targets using tools/package.sh
package-windows-light: windows
@./tools/package.sh windows light
package-windows-full: windows
@./tools/package.sh windows full
package-linux-light: linux
@./tools/package.sh linux light
package-linux-full: linux
@./tools/package.sh linux full
package-all: windows linux
@./tools/package.sh all
# Legacy target for backwards compatibility
package-windows: package-windows-full
# Emscripten / WebAssembly targets
# Requires: source ~/emsdk/emsdk_env.sh (or wherever your emsdk is installed)
#
# For iterative development, configure once then rebuild:
# source ~/emsdk/emsdk_env.sh && emmake make -C build-emscripten
#
wasm:
@if ! command -v emcmake >/dev/null 2>&1; then \
echo "Error: emcmake not found. Run 'source ~/emsdk/emsdk_env.sh' first."; \
exit 1; \
fi
@if [ ! -f build-emscripten/Makefile ]; then \
echo "Configuring WebAssembly build (full game)..."; \
mkdir -p build-emscripten; \
cd build-emscripten && emcmake cmake .. \
-DCMAKE_BUILD_TYPE=Release \
-DMCRF_SDL2=ON; \
fi
@echo "Building McRogueFace for WebAssembly..."
@emmake make -C build-emscripten -j$(JOBS)
@echo "WebAssembly build complete! Files in build-emscripten/"
@echo "Run 'make serve' to test locally"
playground:
@if ! command -v emcmake >/dev/null 2>&1; then \
echo "Error: emcmake not found. Run 'source ~/emsdk/emsdk_env.sh' first."; \
exit 1; \
fi
@if [ ! -f build-playground/Makefile ]; then \
echo "Configuring Playground build..."; \
mkdir -p build-playground; \
cd build-playground && emcmake cmake .. \
-DCMAKE_BUILD_TYPE=Release \
-DMCRF_SDL2=ON \
-DMCRF_PLAYGROUND=ON; \
fi
@echo "Building McRogueFace Playground for WebAssembly..."
@emmake make -C build-playground -j$(JOBS)
@echo "Playground build complete! Files in build-playground/"
@echo "Run 'make serve-playground' to test locally"
serve:
@echo "Serving WebAssembly build at http://localhost:8080"
@echo "Press Ctrl+C to stop"
@cd build-emscripten && python3 -m http.server 8080
serve-playground:
@echo "Serving Playground build at http://localhost:8080"
@echo "Press Ctrl+C to stop"
@cd build-playground && python3 -m http.server 8080
wasm-game:
@if ! command -v emcmake >/dev/null 2>&1; then \
echo "Error: emcmake not found. Run 'source ~/emsdk/emsdk_env.sh' first."; \
exit 1; \
fi
@if [ ! -f build-wasm-game/Makefile ]; then \
echo "Configuring WebAssembly game build (fullscreen, no REPL)..."; \
mkdir -p build-wasm-game; \
cd build-wasm-game && emcmake cmake .. \
-DCMAKE_BUILD_TYPE=Release \
-DMCRF_SDL2=ON \
-DMCRF_GAME_SHELL=ON; \
fi
@echo "Building McRogueFace game for WebAssembly..."
@emmake make -C build-wasm-game -j$(JOBS)
@echo "Game build complete! Files in build-wasm-game/"
@echo "Run 'make serve-game' to test locally"
serve-game:
@echo "Serving game build at http://localhost:8080"
@echo "Press Ctrl+C to stop"
@cd build-wasm-game && python3 -m http.server 8080
wasm-demo:
@if ! command -v emcmake >/dev/null 2>&1; then \
echo "Error: emcmake not found. Run 'source ~/emsdk/emsdk_env.sh' first."; \
exit 1; \
fi
@if [ ! -f build-wasm-demo/Makefile ]; then \
echo "Configuring WebAssembly demo build..."; \
mkdir -p build-wasm-demo; \
cd build-wasm-demo && emcmake cmake .. \
-DCMAKE_BUILD_TYPE=Release \
-DMCRF_SDL2=ON \
-DMCRF_DEMO=ON \
-DMCRF_GAME_SHELL=ON; \
fi
@echo "Building McRogueFace demo for WebAssembly..."
@emmake make -C build-wasm-demo -j$(JOBS)
@cp web/index.html build-wasm-demo/index.html
@echo "Demo build complete! Files in build-wasm-demo/"
@echo "Run 'make serve-demo' to test locally"
serve-demo:
@echo "Serving demo build at http://localhost:8080"
@echo "Press Ctrl+C to stop"
@cd build-wasm-demo && python3 -m http.server 8080
clean-wasm:
@echo "Cleaning Emscripten builds..."
@rm -rf build-emscripten build-playground build-wasm-game build-wasm-demo
# Current version extracted from source
CURRENT_VERSION := $(shell grep 'MCRFPY_VERSION' src/McRogueFaceVersion.h | sed 's/.*"\(.*\)"/\1/')
# Release workflow: tag current version, build all packages, bump to next version
# Usage: make version-bump NEXT_VERSION=0.2.6-prerelease-7drl2026
version-bump:
ifndef NEXT_VERSION
$(error Usage: make version-bump NEXT_VERSION=x.y.z-suffix)
endif
@if ! command -v emcmake >/dev/null 2>&1; then \
echo "Error: emcmake not found. Run 'source ~/emsdk/emsdk_env.sh' first."; \
exit 1; \
fi
# git status (clean working dir check), but ignore modules/, because building submodules dirties their subdirs
@if [ -n "$$(git status --porcelain | grep -v modules)" ]; then \
echo "Error: Working tree is not clean. Commit or stash changes first."; \
exit 1; \
fi
@echo "=== Releasing $(CURRENT_VERSION) ==="
@# Idempotent tag: ok if it already points at HEAD (resuming partial run)
@if git rev-parse "$(CURRENT_VERSION)" >/dev/null 2>&1; then \
TAG_COMMIT=$$(git rev-parse "$(CURRENT_VERSION)^{}"); \
HEAD_COMMIT=$$(git rev-parse HEAD); \
if [ "$$TAG_COMMIT" != "$$HEAD_COMMIT" ]; then \
echo "Error: Tag $(CURRENT_VERSION) already exists but points to a different commit."; \
exit 1; \
fi; \
echo "Tag $(CURRENT_VERSION) already exists at HEAD (resuming)."; \
else \
git tag "$(CURRENT_VERSION)"; \
fi
$(MAKE) package-linux-full
$(MAKE) package-windows-full
$(MAKE) wasm
@echo "Packaging WASM build..."
@mkdir -p dist
cd build-emscripten && zip -r ../dist/McRogueFace-$(CURRENT_VERSION)-WASM.zip \
mcrogueface.html mcrogueface.js mcrogueface.wasm mcrogueface.data
@echo ""
@echo "Bumping version: $(CURRENT_VERSION) -> $(NEXT_VERSION)"
@sed -i 's|MCRFPY_VERSION "$(CURRENT_VERSION)"|MCRFPY_VERSION "$(NEXT_VERSION)"|' src/McRogueFaceVersion.h
@TAGGED_HASH=$$(git rev-parse --short HEAD); \
git add src/McRogueFaceVersion.h && \
git commit -m "Version bump: $(CURRENT_VERSION) ($$TAGGED_HASH) -> $(NEXT_VERSION)"
@echo ""
@echo "=== Release $(CURRENT_VERSION) complete ==="
@echo "Tag: $(CURRENT_VERSION)"
@echo "Next: $(NEXT_VERSION)"
@echo "Packages:"
@ls -lh dist/*$(CURRENT_VERSION)* 2>/dev/null

View file

@ -0,0 +1,238 @@
# McRogueFace Issue Triage — April 2026
**46 open issues** across #53#304. Grouped by system, ordered by impact.
---
## Group 1: Render Cache Dirty Flags (Bugfix Cluster)
**4 issues — all quick-to-moderate fixes, high user-visible impact**
These are systemic bugs where Python property setters bypass the render cache invalidation system (#144). They cause stale frames when using `clip_children` or `cache_subtree`. Issue #291 is the umbrella audit; the other three are specific bugs it identified.
| Issue | Title | Difficulty |
|-------|-------|------------|
| #291 | Audit all Python property setters for missing markDirty() calls | Medium — systematic sweep of all tp_getset setters |
| #290 | UIDrawable base x/y/pos setters don't propagate dirty flags to parent | Quick — add markCompositeDirty() call in set_float_member() |
| #289 | Caption Python property setters don't call markDirty() | Quick — add markDirty() to text/font_size/fill_color setters |
| #288 | UICollection mutations don't invalidate parent Frame's render cache | Quick — add markCompositeDirty() in append/remove/etc |
**Dependencies:** None external. #291 depends on #288#290 being fixed first (or done together).
**Recommendation: Tackle first.** These are correctness bugs affecting every user of the caching system. The fixes are mechanical (add missing dirty-flag calls), low risk, and testable. One focused session can close all four.
---
## Group 2: Grid Dangling Pointer Bugs
**3 issues — moderate fixes, memory safety impact**
All three are the same class of bug: raw `UIGrid*` pointers in child objects that dangle when the parent grid is destroyed. Part of the broader memory safety audit (#279).
| Issue | Title | Difficulty |
|-------|-------|------------|
| #270 | GridLayer::parent_grid dangling raw pointer | Moderate — convert to weak_ptr or add invalidation |
| #271 | UIGridPoint::parent_grid dangling raw pointer | Moderate — same pattern as #270 |
| #277 | GridChunk::parent_grid dangling raw pointer | Moderate — same pattern as #270 |
**Dependencies:** These are the last 3 unfixed bugs from the memory safety audit (#279). Fixing all three would effectively close #279.
**Recommendation: Tackle second.** Same fix pattern applied three times. Closes the memory safety audit chapter.
---
## Group 3: Animation System Fixes
**2 issues — one bugfix, one feature**
| Issue | Title | Difficulty |
|-------|-------|------------|
| #256 | Animation system bypasses spatial hash updates for entity position | Moderate — hook animation property changes into spatial hash |
| #218 | Color and Vector animation targets | Minor feature — compound property animation support |
**Dependencies:** #256 is independent. #218 is a nice-to-have that improves DX.
---
## Group 4: Grid Layer & Rendering Fixes
**2 issues — quick fixes**
| Issue | Title | Difficulty |
|-------|-------|------------|
| #257 | Grid layers with z_index of zero are on top of entities | Quick — change `>=0` to `>0` or similar in draw order |
| #152 | Sparse Grid Layers | Major feature — default values + sub-grid chunk optimization |
**Dependencies:** #257 is standalone. #152 builds on the existing layer system.
---
## Group 5: API Cleanup & Consistency
**1 issue — quick, blocks v1.0**
| Issue | Title | Difficulty |
|-------|-------|------------|
| #304 | Remove camelCase module functions before 1.0 | Quick — delete 4 method entries from mcrfpyMethods[], update tests |
**Dependencies:** Snake_case aliases already added. This is a breaking change gated on the 1.0 release.
---
## Group 6: Multi-Tile Entity Rendering
**5 issues — parent + 4 children, all tier3-future**
Umbrella issue #233 with four sub-issues for different approaches to entities larger than one grid cell.
| Issue | Title | Difficulty |
|-------|-------|------------|
| #233 | Enhance Entity rendering and positioning capabilities (parent) | Meta/tracking |
| #234 | Entity origin offset for oversized sprites | Minor — add pixel offset to entity draw position |
| #235 | Texture display bounds for non-uniform sprite content | Minor — support non-cell-aligned sprite regions |
| #236 | Multi-tile entities using oversized sprites | Minor — render single large sprite across cells |
| #237 | Multi-tile entities using composite sprites | Major — multiple sprite indices per entity |
**Dependencies:** #234 is the simplest starting point. #236 and #237 build on #234/#235.
---
## Group 7: Memory Safety Audit Tail
**9 issues — testing/tooling infrastructure for the #279 audit**
These are the remaining items from the 7DRL 2026 post-mortem. The actual bugs are mostly fixed; these are about preventing regressions and improving the safety toolchain.
| Issue | Title | Difficulty |
|-------|-------|------------|
| #279 | Engine memory safety audit — meta/tracking | Meta — close when #270/#271/#277 done |
| #287 | Regression tests for each bug from #258#278 | Medium — write targeted test scripts |
| #285 | CI pipeline for debug-test and asan-test | Medium — CI/CD configuration |
| #286 | Re-enable ASan leak detection | Tiny — remove detect_leaks=0 suppression |
| #284 | Valgrind Massif heap profiling target | Tiny — add Makefile target |
| #283 | Atheris fuzzing harness for Python API | Major — significant new infrastructure |
| #282 | Install modern Clang for TSan/fuzzing | Minor — toolchain upgrade |
| #281 | Free-threaded CPython + TSan Makefile targets | Minor — Makefile additions |
| #280 | Instrumented libtcod debug build | Minor — rebuild libtcod with sanitizers |
**Dependencies:** #286 depends on #266/#275 (both closed). #287 depends on the actual bugs being fixed. #283 depends on #282.
---
## Group 8: Grid Data Model Enhancements
**3 issues — foundation work for game data**
| Issue | Title | Difficulty |
|-------|-------|------------|
| #293 | DiscreteMap serialization via bytes | Minor — add bytes() and from_bytes() to DiscreteMap |
| #294 | Entity.gridstate as DiscreteMap reference | Minor — refactor internal representation |
| #149 | Reduce the size of UIGrid.cpp | Refactoring — break 1400+ line file into logical units |
**Dependencies:** #294 depends on #293. #149 is independent refactoring.
---
## Group 9: Performance Optimization
**4 issues — significant effort, needs benchmarks first**
| Issue | Title | Difficulty |
|-------|-------|------------|
| #255 | Tracking down performance improvement opportunities | Investigation — profiling session |
| #117 | Memory Pool for Entities | Major — custom allocator |
| #145 | TexturePool with power-of-2 RenderTexture reuse | Major — deferred from #144 |
| #124 | Grid Point Animation | Major — per-tile animation system, needs design |
**Dependencies:** #255 should be done first to identify where optimization matters. #145 builds on the dirty-flag system (#144, closed). #124 is a large standalone feature.
---
## Group 10: WASM / Playground Tooling
**3 issues — all tier3-future**
| Issue | Title | Difficulty |
|-------|-------|------------|
| #238 | Emscripten debugging infrastructure (DWARF, source maps) | Minor — build config additions |
| #239 | Automated WASM testing with headless browser | Major — new test infrastructure |
| #240 | Developer troubleshooting docs for WASM deployments | Documentation — write guide |
**Dependencies:** #238 supports #239. #240 is standalone documentation.
---
## Group 11: LLM Agent Testbed
**3 issues — research/demo infrastructure**
| Issue | Title | Difficulty |
|-------|-------|------------|
| #55 | McRogueFace as Agent Simulation Environment | Major — umbrella/vision issue |
| #154 | Grounded Multi-Agent Testbed | Major — research infrastructure |
| #156 | Turn-based LLM Agent Orchestration | Major — orchestration layer |
**Dependencies:** #156 depends on #154. Both depend on #55 conceptually. These also depend on #53 (alternative input methods) and mature API stability.
---
## Group 12: Demo Games & Tutorials
**2 issues — showcase/marketing**
| Issue | Title | Difficulty |
|-------|-------|------------|
| #248 | Crypt of Sokoban Remaster (7DRL prep) | Major — full game remaster |
| #167 | r/roguelikedev Tutorial Series Demo Game | Major — tutorial content + demo game |
**Dependencies:** Both benefit from a stable, well-documented API. #167 specifically needs the API to be settled.
---
## Group 13: Platform & Architecture (Far Future)
**5 issues — large features, mostly deferred**
| Issue | Title | Difficulty |
|-------|-------|------------|
| #70 | Package mcrfpy without embedded interpreter (wheels) | Major — significant build system rework |
| #62 | Multiple Windows | Major — architectural change |
| #67 | Grid Stitching / infinite world prototype | Major — new rendering/data infrastructure |
| #54 | Jupyter Notebook Interface | Major — alternative rendering target |
| #53 | Alternative Input Methods | Major — depends on #220 |
---
## Group 14: Concurrency
**1 issue — deferred**
| Issue | Title | Difficulty |
|-------|-------|------------|
| #220 | Secondary Concurrency Model: Subinterpreter Support | Major — Python 3.12+ subinterpreters |
**Dependencies:** Depends on free-threaded CPython work (#281).
---
## Summary by Priority
| Priority | Groups | Issue Count | Session Estimate |
|----------|--------|-------------|-----------------|
| **Do now** | G1 (dirty flags), G2 (dangling ptrs) | 7 | 1 session |
| **Do soon** | G3 (animation), G4 (grid fixes), G5 (API cleanup) | 5 | 1 session |
| **Foundation** | G7 (safety tests), G8 (grid data) | 12 | 2-3 sessions |
| **When ready** | G6 (multi-tile), G9 (perf), G10 (WASM) | 12 | 3-4 sessions |
| **Future** | G11 (LLM), G12 (demos), G13 (platform), G14 (concurrency) | 10 | unbounded |
## Recommended First Session
**Groups 1 + 2: Dirty flags + dangling pointers (7 issues)**
Rationale:
- All are correctness/safety bugs, not features — fixes don't need design decisions
- Dirty flag fixes (#288-#291) share the same mechanical pattern: add missing `markDirty()` or `markCompositeDirty()` calls
- Dangling pointer fixes (#270, #271, #277) share the same pattern: convert `UIGrid*` to `weak_ptr<UIGrid>` or add invalidation on grid destruction
- Closing these also effectively closes the meta issue #279
- High confidence of completing all 7 in one session
- Clears the way for performance work (Group 9) which depends on correct caching

View file

@ -75,30 +75,30 @@ pos_display.fill_color = mcrfpy.Color(200, 200, 100)
pos_display.font_size = 16
scene.children.append(pos_display)
def handle_keys(key: str, action: str) -> None:
def handle_keys(key, action) -> None:
"""Handle keyboard input to move the player.
Args:
key: The key that was pressed (e.g., "W", "Up", "Space")
action: Either "start" (key pressed) or "end" (key released)
key: A mcrfpy.Key enum value (e.g., Key.W, Key.UP)
action: A mcrfpy.InputState enum value (PRESSED or RELEASED)
"""
# Only respond to key press, not release
if action != "start":
if action != mcrfpy.InputState.PRESSED:
return
# Get current player position
px, py = int(player.x), int(player.y)
# Calculate new position based on key
if key == "W" or key == "Up":
if key == mcrfpy.Key.W or key == mcrfpy.Key.UP:
py -= 1 # Up decreases Y
elif key == "S" or key == "Down":
elif key == mcrfpy.Key.S or key == mcrfpy.Key.DOWN:
py += 1 # Down increases Y
elif key == "A" or key == "Left":
elif key == mcrfpy.Key.A or key == mcrfpy.Key.LEFT:
px -= 1 # Left decreases X
elif key == "D" or key == "Right":
elif key == mcrfpy.Key.D or key == mcrfpy.Key.RIGHT:
px += 1 # Right increases X
elif key == "Escape":
elif key == mcrfpy.Key.ESCAPE:
mcrfpy.exit()
return

View file

@ -160,9 +160,9 @@ scene.children.append(status_display)
# Input Handling
# =============================================================================
def handle_keys(key: str, action: str) -> None:
def handle_keys(key, action) -> None:
"""Handle keyboard input with collision detection."""
if action != "start":
if action != mcrfpy.InputState.PRESSED:
return
# Get current position
@ -171,15 +171,15 @@ def handle_keys(key: str, action: str) -> None:
# Calculate intended new position
new_x, new_y = px, py
if key == "W" or key == "Up":
if key == mcrfpy.Key.W or key == mcrfpy.Key.UP:
new_y -= 1
elif key == "S" or key == "Down":
elif key == mcrfpy.Key.S or key == mcrfpy.Key.DOWN:
new_y += 1
elif key == "A" or key == "Left":
elif key == mcrfpy.Key.A or key == mcrfpy.Key.LEFT:
new_x -= 1
elif key == "D" or key == "Right":
elif key == mcrfpy.Key.D or key == mcrfpy.Key.RIGHT:
new_x += 1
elif key == "Escape":
elif key == mcrfpy.Key.ESCAPE:
mcrfpy.exit()
return
else:

View file

@ -316,26 +316,26 @@ def regenerate_dungeon() -> None:
pos_display.text = f"Position: ({new_x}, {new_y})"
room_display.text = "New dungeon generated!"
def handle_keys(key: str, action: str) -> None:
def handle_keys(key, action) -> None:
"""Handle keyboard input."""
if action != "start":
if action != mcrfpy.InputState.PRESSED:
return
px, py = int(player.x), int(player.y)
new_x, new_y = px, py
if key == "W" or key == "Up":
if key == mcrfpy.Key.W or key == mcrfpy.Key.UP:
new_y -= 1
elif key == "S" or key == "Down":
elif key == mcrfpy.Key.S or key == mcrfpy.Key.DOWN:
new_y += 1
elif key == "A" or key == "Left":
elif key == mcrfpy.Key.A or key == mcrfpy.Key.LEFT:
new_x -= 1
elif key == "D" or key == "Right":
elif key == mcrfpy.Key.D or key == mcrfpy.Key.RIGHT:
new_x += 1
elif key == "R":
elif key == mcrfpy.Key.R:
regenerate_dungeon()
return
elif key == "Escape":
elif key == mcrfpy.Key.ESCAPE:
mcrfpy.exit()
return
else:

View file

@ -71,24 +71,9 @@ class RectangularRoom:
# Exploration Tracking
# =============================================================================
# Track which tiles have been discovered (seen at least once)
explored: list[list[bool]] = []
def init_explored() -> None:
"""Initialize the explored array to all False."""
global explored
explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]
def mark_explored(x: int, y: int) -> None:
"""Mark a tile as explored."""
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
explored[y][x] = True
def is_explored(x: int, y: int) -> bool:
"""Check if a tile has been explored."""
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
return explored[y][x]
return False
# Note: The ColorLayer's draw_fov() method tracks exploration state
# internally - tiles that have been visible at least once are rendered
# with the 'discovered' color. No manual tracking needed!
# =============================================================================
# Dungeon Generation (from Part 3, with transparent property)
@ -151,7 +136,6 @@ def carve_l_tunnel(
def generate_dungeon(grid: mcrfpy.Grid) -> tuple[int, int]:
"""Generate a dungeon with rooms and tunnels."""
fill_with_walls(grid)
init_explored() # Reset exploration when generating new dungeon
rooms: list[RectangularRoom] = []
@ -188,28 +172,25 @@ def generate_dungeon(grid: mcrfpy.Grid) -> tuple[int, int]:
def update_fov(grid: mcrfpy.Grid, fov_layer, player_x: int, player_y: int) -> None:
"""Update the field of view visualization.
Uses the ColorLayer's built-in draw_fov() method, which computes FOV
via libtcod and paints visibility colors in a single call. The layer
tracks explored state automatically.
Args:
grid: The game grid
fov_layer: The ColorLayer for FOV visualization
player_x: Player's X position
player_y: Player's Y position
"""
# Compute FOV from player position
grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW)
# Update each tile's visibility
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
if grid.is_in_fov(x, y):
# Currently visible - mark as explored and show clearly
mark_explored(x, y)
fov_layer.set(x, y, COLOR_VISIBLE)
elif is_explored(x, y):
# Previously seen but not currently visible - show dimmed
fov_layer.set(x, y, COLOR_DISCOVERED)
else:
# Never seen - hide completely
fov_layer.set(x, y, COLOR_UNKNOWN)
# draw_fov computes FOV and paints the color layer in one step.
# It tracks explored state internally so previously-seen tiles stay dimmed.
fov_layer.draw_fov(
(player_x, player_y),
radius=FOV_RADIUS,
visible=COLOR_VISIBLE,
discovered=COLOR_DISCOVERED,
unknown=COLOR_UNKNOWN
)
# =============================================================================
# Collision Detection
@ -244,12 +225,12 @@ grid = mcrfpy.Grid(
player_start_x, player_start_y = generate_dungeon(grid)
# Add a color layer for FOV visualization (below entities)
fov_layer = grid.add_layer("color", z_index=-1)
# Create the layer object, then attach it to the grid
fov_layer = mcrfpy.ColorLayer(z_index=-1, name="fov")
grid.add_layer(fov_layer)
# Initialize the FOV layer to all black (unknown)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
# Create the player
player = mcrfpy.Entity(
@ -312,34 +293,32 @@ def regenerate_dungeon() -> None:
player.y = new_y
# Reset FOV layer to unknown
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
# Calculate new FOV
update_fov(grid, fov_layer, new_x, new_y)
pos_display.text = f"Position: ({new_x}, {new_y})"
def handle_keys(key: str, action: str) -> None:
def handle_keys(key, action) -> None:
"""Handle keyboard input."""
if action != "start":
if action != mcrfpy.InputState.PRESSED:
return
px, py = int(player.x), int(player.y)
new_x, new_y = px, py
if key == "W" or key == "Up":
if key == mcrfpy.Key.W or key == mcrfpy.Key.UP:
new_y -= 1
elif key == "S" or key == "Down":
elif key == mcrfpy.Key.S or key == mcrfpy.Key.DOWN:
new_y += 1
elif key == "A" or key == "Left":
elif key == mcrfpy.Key.A or key == mcrfpy.Key.LEFT:
new_x -= 1
elif key == "D" or key == "Right":
elif key == mcrfpy.Key.D or key == mcrfpy.Key.RIGHT:
new_x += 1
elif key == "R":
elif key == mcrfpy.Key.R:
regenerate_dungeon()
return
elif key == "Escape":
elif key == mcrfpy.Key.ESCAPE:
mcrfpy.exit()
return
else:

View file

@ -120,23 +120,8 @@ class RectangularRoom:
# Exploration Tracking (from Part 4)
# =============================================================================
explored: list[list[bool]] = []
def init_explored() -> None:
"""Initialize the explored array to all False."""
global explored
explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]
def mark_explored(x: int, y: int) -> None:
"""Mark a tile as explored."""
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
explored[y][x] = True
def is_explored(x: int, y: int) -> bool:
"""Check if a tile has been explored."""
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
return explored[y][x]
return False
# Note: The ColorLayer's draw_fov() method tracks exploration state
# internally - no manual tracking needed!
# =============================================================================
# Dungeon Generation (from Part 4)
@ -293,20 +278,15 @@ def clear_enemies(target_grid: mcrfpy.Grid) -> None:
"""Remove all enemies from the grid."""
global entity_data
# Get list of enemies to remove (not the player)
# Collect enemies to remove (not the player)
enemies_to_remove = []
for entity in target_grid.entities:
if entity in entity_data and not entity_data[entity].get("is_player", False):
enemies_to_remove.append(entity)
# Remove from grid and entity_data
# Remove from grid (by entity reference) and from entity_data
for enemy in enemies_to_remove:
# Find and remove from grid.entities
for i, e in enumerate(target_grid.entities):
if e == enemy:
target_grid.entities.remove(i)
break
# Remove from entity_data
target_grid.entities.remove(enemy)
if enemy in entity_data:
del entity_data[enemy]
@ -329,7 +309,7 @@ def update_entity_visibility(target_grid: mcrfpy.Grid) -> None:
# Other entities are only visible if in FOV
ex, ey = int(entity.x), int(entity.y)
entity.visible = target_grid.is_in_fov(ex, ey)
entity.visible = target_grid.is_in_fov((ex, ey))
# =============================================================================
# Field of View (from Part 4)
@ -337,21 +317,18 @@ def update_entity_visibility(target_grid: mcrfpy.Grid) -> None:
def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None:
"""Update the field of view visualization."""
# Compute FOV from player position
target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW)
# draw_fov computes FOV and paints the color layer in one step.
# It also tracks explored state internally.
target_fov_layer.draw_fov(
(player_x, player_y),
radius=FOV_RADIUS,
visible=COLOR_VISIBLE,
discovered=COLOR_DISCOVERED,
unknown=COLOR_UNKNOWN
)
# Update each tile's visibility
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
if target_grid.is_in_fov(x, y):
mark_explored(x, y)
target_fov_layer.set(x, y, COLOR_VISIBLE)
elif is_explored(x, y):
target_fov_layer.set(x, y, COLOR_DISCOVERED)
else:
target_fov_layer.set(x, y, COLOR_UNKNOWN)
# Update entity visibility
# Update entity visibility (draw_fov calls compute_fov internally,
# so is_in_fov() reflects the current FOV state)
update_entity_visibility(target_grid)
# =============================================================================
@ -399,7 +376,6 @@ def generate_dungeon(target_grid: mcrfpy.Grid, texture: mcrfpy.Texture) -> tuple
# Fill with walls
fill_with_walls(target_grid)
init_explored()
rooms: list[RectangularRoom] = []
@ -454,7 +430,6 @@ grid = mcrfpy.Grid(
# Generate the dungeon (without player first to get starting position)
fill_with_walls(grid)
init_explored()
rooms: list[RectangularRoom] = []
@ -489,10 +464,9 @@ else:
player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2
# Add FOV layer
fov_layer = grid.add_layer("color", z_index=-1)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer = mcrfpy.ColorLayer(z_index=-1, name="fov")
grid.add_layer(fov_layer)
fov_layer.fill(COLOR_UNKNOWN)
# Create the player
player = mcrfpy.Entity(
@ -574,7 +548,6 @@ def regenerate_dungeon() -> None:
# Regenerate dungeon structure
fill_with_walls(grid)
init_explored()
rooms = []
@ -618,37 +591,35 @@ def regenerate_dungeon() -> None:
spawn_enemies_in_room(grid, room, texture)
# Reset FOV layer
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
# Update FOV
update_fov(grid, fov_layer, new_x, new_y)
pos_display.text = f"Position: ({new_x}, {new_y})"
status_display.text = "New dungeon generated!"
def handle_keys(key: str, action: str) -> None:
def handle_keys(key, action) -> None:
"""Handle keyboard input."""
global player, grid, fov_layer
if action != "start":
if action != mcrfpy.InputState.PRESSED:
return
px, py = int(player.x), int(player.y)
new_x, new_y = px, py
if key == "W" or key == "Up":
if key == mcrfpy.Key.W or key == mcrfpy.Key.UP:
new_y -= 1
elif key == "S" or key == "Down":
elif key == mcrfpy.Key.S or key == mcrfpy.Key.DOWN:
new_y += 1
elif key == "A" or key == "Left":
elif key == mcrfpy.Key.A or key == mcrfpy.Key.LEFT:
new_x -= 1
elif key == "D" or key == "Right":
elif key == mcrfpy.Key.D or key == mcrfpy.Key.RIGHT:
new_x += 1
elif key == "R":
elif key == mcrfpy.Key.R:
regenerate_dungeon()
return
elif key == "Escape":
elif key == mcrfpy.Key.ESCAPE:
mcrfpy.exit()
return
else:

View file

@ -159,27 +159,7 @@ class RectangularRoom:
self.y2 >= other.y1
)
# =============================================================================
# Exploration Tracking
# =============================================================================
explored: list[list[bool]] = []
def init_explored() -> None:
"""Initialize the explored array to all False."""
global explored
explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]
def mark_explored(x: int, y: int) -> None:
"""Mark a tile as explored."""
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
explored[y][x] = True
def is_explored(x: int, y: int) -> bool:
"""Check if a tile has been explored."""
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
return explored[y][x]
return False
# Exploration tracking is handled internally by draw_fov()
# =============================================================================
# Message Log
@ -357,9 +337,9 @@ def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mc
def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
"""Remove an entity from the grid and data storage."""
# Find and remove from grid
for i, e in enumerate(target_grid.entities):
for e in target_grid.entities:
if e == entity:
target_grid.entities.remove(i)
target_grid.entities.remove(e)
break
# Remove from entity data
@ -487,21 +467,19 @@ def update_entity_visibility(target_grid: mcrfpy.Grid) -> None:
continue
ex, ey = int(entity.x), int(entity.y)
entity.visible = target_grid.is_in_fov(ex, ey)
entity.visible = target_grid.is_in_fov((ex, ey))
def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None:
"""Update the field of view visualization."""
target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
if target_grid.is_in_fov(x, y):
mark_explored(x, y)
target_fov_layer.set(x, y, COLOR_VISIBLE)
elif is_explored(x, y):
target_fov_layer.set(x, y, COLOR_DISCOVERED)
else:
target_fov_layer.set(x, y, COLOR_UNKNOWN)
# draw_fov computes FOV and paints the color layer in one step,
# and tracks exploration internally.
target_fov_layer.draw_fov(
(player_x, player_y),
radius=FOV_RADIUS,
visible=COLOR_VISIBLE,
discovered=COLOR_DISCOVERED,
unknown=COLOR_UNKNOWN
)
update_entity_visibility(target_grid)
@ -595,7 +573,7 @@ def enemy_turn() -> None:
ex, ey = int(enemy.x), int(enemy.y)
# Only act if in player's FOV (aware of player)
if not grid.is_in_fov(ex, ey):
if not grid.is_in_fov((ex, ey)):
continue
# Check if adjacent to player
@ -691,7 +669,6 @@ grid = mcrfpy.Grid(
# Generate initial dungeon structure
fill_with_walls(grid)
init_explored()
rooms: list[RectangularRoom] = []
@ -726,10 +703,9 @@ else:
player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2
# Add FOV layer
fov_layer = grid.add_layer("color", z_index=-1)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer = mcrfpy.ColorLayer(z_index=-1, name="fov")
grid.add_layer(fov_layer)
fov_layer.fill(COLOR_UNKNOWN)
# Create the player
player = mcrfpy.Entity(
@ -826,11 +802,10 @@ def restart_game() -> None:
# Remove all entities from grid
while len(grid.entities) > 0:
grid.entities.remove(0)
grid.entities.remove(grid.entities[0])
# Regenerate dungeon
fill_with_walls(grid)
init_explored()
clear_messages()
rooms = []
@ -889,9 +864,7 @@ def restart_game() -> None:
spawn_enemies_in_room(grid, room, texture)
# Reset FOV layer
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
# Update displays
update_fov(grid, fov_layer, new_x, new_y)
@ -900,19 +873,19 @@ def restart_game() -> None:
add_message("A new adventure begins!", mcrfpy.Color(100, 100, 255))
def handle_keys(key: str, action: str) -> None:
def handle_keys(key, action) -> None:
"""Handle keyboard input."""
global game_over
if action != "start":
if action != mcrfpy.InputState.PRESSED:
return
# Handle restart
if key == "R":
if key == mcrfpy.Key.R:
restart_game()
return
if key == "Escape":
if key == mcrfpy.Key.ESCAPE:
mcrfpy.exit()
return
@ -921,13 +894,13 @@ def handle_keys(key: str, action: str) -> None:
return
# Movement and attack
if key == "W" or key == "Up":
if key == mcrfpy.Key.W or key == mcrfpy.Key.UP:
try_move_or_attack(0, -1)
elif key == "S" or key == "Down":
elif key == mcrfpy.Key.S or key == mcrfpy.Key.DOWN:
try_move_or_attack(0, 1)
elif key == "A" or key == "Left":
elif key == mcrfpy.Key.A or key == mcrfpy.Key.LEFT:
try_move_or_attack(-1, 0)
elif key == "D" or key == "Right":
elif key == mcrfpy.Key.D or key == mcrfpy.Key.RIGHT:
try_move_or_attack(1, 0)
scene.on_key = handle_keys

View file

@ -405,24 +405,7 @@ class RectangularRoom:
self.y2 >= other.y1
)
# =============================================================================
# Exploration Tracking
# =============================================================================
explored: list[list[bool]] = []
def init_explored() -> None:
global explored
explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]
def mark_explored(x: int, y: int) -> None:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
explored[y][x] = True
def is_explored(x: int, y: int) -> bool:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
return explored[y][x]
return False
# Exploration tracking is handled internally by draw_fov()
# =============================================================================
# Dungeon Generation
@ -546,10 +529,7 @@ def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mc
return None
def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
if entity in entity_data:
del entity_data[entity]
@ -633,20 +613,16 @@ def update_entity_visibility(target_grid: mcrfpy.Grid) -> None:
continue
ex, ey = int(entity.x), int(entity.y)
entity.visible = target_grid.is_in_fov(ex, ey)
entity.visible = target_grid.is_in_fov((ex, ey))
def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None:
target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
if target_grid.is_in_fov(x, y):
mark_explored(x, y)
target_fov_layer.set(x, y, COLOR_VISIBLE)
elif is_explored(x, y):
target_fov_layer.set(x, y, COLOR_DISCOVERED)
else:
target_fov_layer.set(x, y, COLOR_UNKNOWN)
target_fov_layer.draw_fov(
(player_x, player_y),
radius=FOV_RADIUS,
visible=COLOR_VISIBLE,
discovered=COLOR_DISCOVERED,
unknown=COLOR_UNKNOWN
)
update_entity_visibility(target_grid)
@ -719,7 +695,7 @@ def enemy_turn() -> None:
ex, ey = int(enemy.x), int(enemy.y)
if not grid.is_in_fov(ex, ey):
if not grid.is_in_fov((ex, ey)):
continue
dx = player_x - ex
@ -791,7 +767,6 @@ grid = mcrfpy.Grid(
# Generate initial dungeon structure
fill_with_walls(grid)
init_explored()
rooms: list[RectangularRoom] = []
@ -826,10 +801,9 @@ else:
player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2
# Add FOV layer
fov_layer = grid.add_layer("color", z_index=-1)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer = mcrfpy.ColorLayer(z_index=-1, name="fov")
grid.add_layer(fov_layer)
fov_layer.fill(COLOR_UNKNOWN)
# Create the player
player = mcrfpy.Entity(
@ -930,11 +904,11 @@ def restart_game() -> None:
entity_data.clear()
while len(grid.entities) > 0:
grid.entities.remove(0)
entities_to_clear = list(grid.entities)
for e in entities_to_clear:
grid.entities.remove(e)
fill_with_walls(grid)
init_explored()
message_log.clear()
rooms = []
@ -989,9 +963,7 @@ def restart_game() -> None:
continue
spawn_enemies_in_room(grid, room, texture)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
update_fov(grid, fov_layer, new_x, new_y)
@ -999,30 +971,30 @@ def restart_game() -> None:
update_ui()
def handle_keys(key: str, action: str) -> None:
def handle_keys(key, action) -> None:
global game_over
if action != "start":
if action != mcrfpy.InputState.PRESSED:
return
if key == "R":
if key == mcrfpy.Key.R:
restart_game()
return
if key == "Escape":
if key == mcrfpy.Key.ESCAPE:
mcrfpy.exit()
return
if game_over:
return
if key == "W" or key == "Up":
if key == mcrfpy.Key.W or key == mcrfpy.Key.UP:
try_move_or_attack(0, -1)
elif key == "S" or key == "Down":
elif key == mcrfpy.Key.S or key == mcrfpy.Key.DOWN:
try_move_or_attack(0, 1)
elif key == "A" or key == "Left":
elif key == mcrfpy.Key.A or key == mcrfpy.Key.LEFT:
try_move_or_attack(-1, 0)
elif key == "D" or key == "Right":
elif key == mcrfpy.Key.D or key == mcrfpy.Key.RIGHT:
try_move_or_attack(1, 0)
scene.on_key = handle_keys

View file

@ -436,24 +436,7 @@ class RectangularRoom:
self.y2 >= other.y1
)
# =============================================================================
# Exploration Tracking
# =============================================================================
explored: list[list[bool]] = []
def init_explored() -> None:
global explored
explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]
def mark_explored(x: int, y: int) -> None:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
explored[y][x] = True
def is_explored(x: int, y: int) -> bool:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
return explored[y][x]
return False
# Exploration tracking is handled internally by draw_fov()
# =============================================================================
# Dungeon Generation
@ -711,10 +694,7 @@ def use_item(index: int) -> bool:
def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
"""Remove an item entity from the grid and item_data."""
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
if entity in item_data:
del item_data[entity]
@ -751,10 +731,7 @@ def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mc
return None
def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
if entity in entity_data:
del entity_data[entity]
@ -775,10 +752,7 @@ def clear_all_entities(target_grid: mcrfpy.Grid) -> None:
if entity in item_data:
del item_data[entity]
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
# =============================================================================
# Combat System
@ -852,20 +826,16 @@ def update_entity_visibility(target_grid: mcrfpy.Grid) -> None:
continue
ex, ey = int(entity.x), int(entity.y)
entity.visible = target_grid.is_in_fov(ex, ey)
entity.visible = target_grid.is_in_fov((ex, ey))
def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None:
target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
if target_grid.is_in_fov(x, y):
mark_explored(x, y)
target_fov_layer.set(x, y, COLOR_VISIBLE)
elif is_explored(x, y):
target_fov_layer.set(x, y, COLOR_DISCOVERED)
else:
target_fov_layer.set(x, y, COLOR_UNKNOWN)
target_fov_layer.draw_fov(
(player_x, player_y),
radius=FOV_RADIUS,
visible=COLOR_VISIBLE,
discovered=COLOR_DISCOVERED,
unknown=COLOR_UNKNOWN
)
update_entity_visibility(target_grid)
@ -938,7 +908,7 @@ def enemy_turn() -> None:
ex, ey = int(enemy.x), int(enemy.y)
if not grid.is_in_fov(ex, ey):
if not grid.is_in_fov((ex, ey)):
continue
dx = player_x - ex
@ -1012,7 +982,6 @@ grid = mcrfpy.Grid(
# Generate initial dungeon
fill_with_walls(grid)
init_explored()
rooms: list[RectangularRoom] = []
@ -1047,10 +1016,9 @@ else:
player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2
# Add FOV layer
fov_layer = grid.add_layer("color", z_index=-1)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer = mcrfpy.ColorLayer(z_index=-1, name="fov")
grid.add_layer(fov_layer)
fov_layer.fill(COLOR_UNKNOWN)
# Create the player
player = mcrfpy.Entity(
@ -1156,11 +1124,10 @@ def restart_game() -> None:
entity_data.clear()
item_data.clear()
while len(grid.entities) > 0:
grid.entities.remove(0)
for e in list(grid.entities):
grid.entities.remove(e)
fill_with_walls(grid)
init_explored()
message_log.clear()
rooms = []
@ -1219,9 +1186,7 @@ def restart_game() -> None:
spawn_enemies_in_room(grid, room, texture)
spawn_items_in_room(grid, room, texture)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
update_fov(grid, fov_layer, new_x, new_y)
@ -1229,17 +1194,17 @@ def restart_game() -> None:
update_ui()
def handle_keys(key: str, action: str) -> None:
def handle_keys(key, action) -> None:
global game_over
if action != "start":
if action != mcrfpy.InputState.PRESSED:
return
if key == "R":
if key == mcrfpy.Key.R:
restart_game()
return
if key == "Escape":
if key == mcrfpy.Key.ESCAPE:
mcrfpy.exit()
return
@ -1247,20 +1212,20 @@ def handle_keys(key: str, action: str) -> None:
return
# Movement
if key == "W" or key == "Up":
if key == mcrfpy.Key.W or key == mcrfpy.Key.UP:
try_move_or_attack(0, -1)
elif key == "S" or key == "Down":
elif key == mcrfpy.Key.S or key == mcrfpy.Key.DOWN:
try_move_or_attack(0, 1)
elif key == "A" or key == "Left":
elif key == mcrfpy.Key.A or key == mcrfpy.Key.LEFT:
try_move_or_attack(-1, 0)
elif key == "D" or key == "Right":
elif key == mcrfpy.Key.D or key == mcrfpy.Key.RIGHT:
try_move_or_attack(1, 0)
# Pickup
elif key == "G" or key == ",":
elif key == mcrfpy.Key.G:
pickup_item()
# Use items by number key
elif key in ["1", "2", "3", "4", "5"]:
index = int(key) - 1
elif key in [mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3, mcrfpy.Key.NUM_4, mcrfpy.Key.NUM_5]:
index = [mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3, mcrfpy.Key.NUM_4, mcrfpy.Key.NUM_5].index(key)
if use_item(index):
enemy_turn() # Using an item takes a turn
update_ui()

View file

@ -457,24 +457,7 @@ class RectangularRoom:
self.y2 >= other.y1
)
# =============================================================================
# Exploration Tracking
# =============================================================================
explored: list[list[bool]] = []
def init_explored() -> None:
global explored
explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]
def mark_explored(x: int, y: int) -> None:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
explored[y][x] = True
def is_explored(x: int, y: int) -> bool:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
return explored[y][x]
return False
# Exploration tracking is handled internally by draw_fov()
# =============================================================================
# Dungeon Generation
@ -635,18 +618,12 @@ def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mc
return None
def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
if entity in entity_data:
del entity_data[entity]
def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
if entity in item_data:
del item_data[entity]
@ -666,10 +643,7 @@ def clear_all_entities(target_grid: mcrfpy.Grid) -> None:
if entity in item_data:
del item_data[entity]
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
# =============================================================================
# Targeting System
@ -701,11 +675,7 @@ def exit_targeting_mode() -> None:
global game_mode, target_cursor, grid
if target_cursor is not None:
# Remove cursor from grid
for i, e in enumerate(grid.entities):
if e == target_cursor:
grid.entities.remove(i)
break
grid.entities.remove(target_cursor)
target_cursor = None
game_mode = GameMode.NORMAL
@ -723,7 +693,7 @@ def move_cursor(dx: int, dy: int) -> None:
return
# Check if position is in FOV (can only target visible tiles)
if not grid.is_in_fov(new_x, new_y):
if not grid.is_in_fov((new_x, new_y)):
message_log.add("You cannot see that location.", COLOR_INVALID)
return
@ -936,20 +906,16 @@ def update_entity_visibility(target_grid: mcrfpy.Grid) -> None:
continue
ex, ey = int(entity.x), int(entity.y)
entity.visible = target_grid.is_in_fov(ex, ey)
entity.visible = target_grid.is_in_fov((ex, ey))
def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None:
target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
if target_grid.is_in_fov(x, y):
mark_explored(x, y)
target_fov_layer.set(x, y, COLOR_VISIBLE)
elif is_explored(x, y):
target_fov_layer.set(x, y, COLOR_DISCOVERED)
else:
target_fov_layer.set(x, y, COLOR_UNKNOWN)
target_fov_layer.draw_fov(
(player_x, player_y),
radius=FOV_RADIUS,
visible=COLOR_VISIBLE,
discovered=COLOR_DISCOVERED,
unknown=COLOR_UNKNOWN
)
update_entity_visibility(target_grid)
@ -1022,7 +988,7 @@ def enemy_turn() -> None:
ex, ey = int(enemy.x), int(enemy.y)
if not grid.is_in_fov(ex, ey):
if not grid.is_in_fov((ex, ey)):
continue
dx = player_x - ex
@ -1095,7 +1061,6 @@ grid = mcrfpy.Grid(
# Generate initial dungeon
fill_with_walls(grid)
init_explored()
rooms: list[RectangularRoom] = []
@ -1130,10 +1095,9 @@ else:
player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2
# Add FOV layer
fov_layer = grid.add_layer("color", z_index=-1)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer = mcrfpy.ColorLayer(z_index=-1, name="fov")
grid.add_layer(fov_layer)
fov_layer.fill(COLOR_UNKNOWN)
# Create the player
player = mcrfpy.Entity(
@ -1247,11 +1211,10 @@ def restart_game() -> None:
entity_data.clear()
item_data.clear()
while len(grid.entities) > 0:
grid.entities.remove(0)
for e in list(grid.entities):
grid.entities.remove(e)
fill_with_walls(grid)
init_explored()
message_log.clear()
rooms = []
@ -1309,9 +1272,7 @@ def restart_game() -> None:
spawn_enemies_in_room(grid, room, texture)
spawn_items_in_room(grid, room, texture)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
update_fov(grid, fov_layer, new_x, new_y)
@ -1320,18 +1281,18 @@ def restart_game() -> None:
mode_display.update(game_mode)
update_ui()
def handle_keys(key: str, action: str) -> None:
def handle_keys(key, action) -> None:
global game_over, game_mode
if action != "start":
if action != mcrfpy.InputState.PRESSED:
return
# Always allow restart and quit
if key == "R":
if key == mcrfpy.Key.R:
restart_game()
return
if key == "Escape":
if key == mcrfpy.Key.ESCAPE:
if game_mode == GameMode.TARGETING:
exit_targeting_mode()
message_log.add("Targeting cancelled.", COLOR_INFO)
@ -1349,41 +1310,41 @@ def handle_keys(key: str, action: str) -> None:
else:
handle_normal_input(key)
def handle_normal_input(key: str) -> None:
def handle_normal_input(key) -> None:
"""Handle input in normal game mode."""
# Movement
if key == "W" or key == "Up":
if key == mcrfpy.Key.W or key == mcrfpy.Key.UP:
try_move_or_attack(0, -1)
elif key == "S" or key == "Down":
elif key == mcrfpy.Key.S or key == mcrfpy.Key.DOWN:
try_move_or_attack(0, 1)
elif key == "A" or key == "Left":
elif key == mcrfpy.Key.A or key == mcrfpy.Key.LEFT:
try_move_or_attack(-1, 0)
elif key == "D" or key == "Right":
elif key == mcrfpy.Key.D or key == mcrfpy.Key.RIGHT:
try_move_or_attack(1, 0)
# Ranged attack (enter targeting mode)
elif key == "F":
elif key == mcrfpy.Key.F:
enter_targeting_mode()
# Pickup
elif key == "G" or key == ",":
elif key == mcrfpy.Key.G:
pickup_item()
# Use items
elif key in ["1", "2", "3", "4", "5"]:
index = int(key) - 1
elif key in [mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3, mcrfpy.Key.NUM_4, mcrfpy.Key.NUM_5]:
index = [mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3, mcrfpy.Key.NUM_4, mcrfpy.Key.NUM_5].index(key)
if use_item(index):
enemy_turn()
update_ui()
def handle_targeting_input(key: str) -> None:
def handle_targeting_input(key) -> None:
"""Handle input in targeting mode."""
if key == "Up" or key == "W":
if key == mcrfpy.Key.UP or key == mcrfpy.Key.W:
move_cursor(0, -1)
elif key == "Down" or key == "S":
elif key == mcrfpy.Key.DOWN or key == mcrfpy.Key.S:
move_cursor(0, 1)
elif key == "Left" or key == "A":
elif key == mcrfpy.Key.LEFT or key == mcrfpy.Key.A:
move_cursor(-1, 0)
elif key == "Right" or key == "D":
elif key == mcrfpy.Key.RIGHT or key == mcrfpy.Key.D:
move_cursor(1, 0)
elif key == "Return" or key == "Space":
elif key == mcrfpy.Key.ENTER or key == mcrfpy.Key.SPACE:
confirm_target()
scene.on_key = handle_keys

View file

@ -511,24 +511,7 @@ class RectangularRoom:
self.y2 >= other.y1
)
# =============================================================================
# Exploration Tracking
# =============================================================================
explored: list[list[bool]] = []
def init_explored() -> None:
global explored
explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]
def mark_explored(x: int, y: int) -> None:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
explored[y][x] = True
def is_explored(x: int, y: int) -> bool:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
return explored[y][x]
return False
# Exploration tracking is handled internally by draw_fov()
# =============================================================================
# Save/Load System
@ -540,7 +523,7 @@ def save_game() -> bool:
Returns:
True if save succeeded, False otherwise
"""
global player, player_inventory, grid, explored, dungeon_level
global player, player_inventory, grid, dungeon_level
try:
# Collect tile data
@ -592,7 +575,6 @@ def save_game() -> bool:
"inventory": player_inventory.to_dict()
},
"tiles": tiles,
"explored": [[explored[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)],
"enemies": enemies,
"items": items_on_ground
}
@ -615,7 +597,7 @@ def load_game() -> bool:
Returns:
True if load succeeded, False otherwise
"""
global player, player_inventory, grid, explored, dungeon_level
global player, player_inventory, grid, dungeon_level
global entity_data, item_data, fov_layer, game_over
if not os.path.exists(SAVE_FILE):
@ -629,8 +611,8 @@ def load_game() -> bool:
entity_data.clear()
item_data.clear()
while len(grid.entities) > 0:
grid.entities.remove(0)
for e in list(grid.entities):
grid.entities.remove(e)
# Restore dungeon level
dungeon_level = save_data.get("dungeon_level", 1)
@ -645,10 +627,7 @@ def load_game() -> bool:
cell.walkable = tile_data["walkable"]
cell.transparent = tile_data["transparent"]
# Restore explored state
global explored
explored_data = save_data["explored"]
explored = [[explored_data[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)]
# Exploration state is tracked internally by draw_fov()
# Restore player
player_data = save_data["player"]
@ -695,9 +674,7 @@ def load_game() -> bool:
item_data[item_entity] = Item.from_dict(item_entry["item"])
# Reset FOV layer
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
# Compute initial FOV
update_fov(grid, fov_layer, int(player.x), int(player.y))
@ -890,18 +867,12 @@ def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mc
return None
def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
if entity in entity_data:
del entity_data[entity]
def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
if entity in item_data:
del item_data[entity]
@ -931,10 +902,7 @@ def exit_targeting_mode() -> None:
global game_mode, target_cursor, grid
if target_cursor is not None:
for i, e in enumerate(grid.entities):
if e == target_cursor:
grid.entities.remove(i)
break
grid.entities.remove(target_cursor)
target_cursor = None
game_mode = GameMode.NORMAL
@ -949,7 +917,7 @@ def move_cursor(dx: int, dy: int) -> None:
if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT:
return
if not grid.is_in_fov(new_x, new_y):
if not grid.is_in_fov((new_x, new_y)):
message_log.add("You cannot see that location.", COLOR_INVALID)
return
@ -1144,20 +1112,16 @@ def update_entity_visibility(target_grid: mcrfpy.Grid) -> None:
continue
ex, ey = int(entity.x), int(entity.y)
entity.visible = target_grid.is_in_fov(ex, ey)
entity.visible = target_grid.is_in_fov((ex, ey))
def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None:
target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
if target_grid.is_in_fov(x, y):
mark_explored(x, y)
target_fov_layer.set(x, y, COLOR_VISIBLE)
elif is_explored(x, y):
target_fov_layer.set(x, y, COLOR_DISCOVERED)
else:
target_fov_layer.set(x, y, COLOR_UNKNOWN)
target_fov_layer.draw_fov(
(player_x, player_y),
radius=FOV_RADIUS,
visible=COLOR_VISIBLE,
discovered=COLOR_DISCOVERED,
unknown=COLOR_UNKNOWN
)
update_entity_visibility(target_grid)
@ -1230,7 +1194,7 @@ def enemy_turn() -> None:
ex, ey = int(enemy.x), int(enemy.y)
if not grid.is_in_fov(ex, ey):
if not grid.is_in_fov((ex, ey)):
continue
dx = player_x - ex
@ -1299,11 +1263,10 @@ def generate_new_game() -> None:
entity_data.clear()
item_data.clear()
while len(grid.entities) > 0:
grid.entities.remove(0)
for e in list(grid.entities):
grid.entities.remove(e)
fill_with_walls(grid)
init_explored()
message_log.clear()
rooms: list[RectangularRoom] = []
@ -1361,9 +1324,7 @@ def generate_new_game() -> None:
spawn_enemies_in_room(grid, room, texture)
spawn_items_in_room(grid, room, texture)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
update_fov(grid, fov_layer, new_x, new_y)
@ -1390,10 +1351,9 @@ grid = mcrfpy.Grid(
)
# Add FOV layer
fov_layer = grid.add_layer("color", z_index=-1)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer = mcrfpy.ColorLayer(z_index=-1, name="fov")
grid.add_layer(fov_layer)
fov_layer.fill(COLOR_UNKNOWN)
# Add grid to scene
scene.children.append(grid)
@ -1456,9 +1416,6 @@ message_log.add_to_scene(scene)
# Initialize Game (Load or New)
# =============================================================================
# Initialize explored array
init_explored()
# Try to load existing save, otherwise generate new game
if has_save_file():
message_log.add("Found saved game. Loading...", COLOR_INFO)
@ -1475,20 +1432,20 @@ else:
# Input Handling
# =============================================================================
def handle_keys(key: str, action: str) -> None:
def handle_keys(key, action) -> None:
global game_over, game_mode
if action != "start":
if action != mcrfpy.InputState.PRESSED:
return
# Always allow restart
if key == "R":
if key == mcrfpy.Key.R:
delete_save()
generate_new_game()
message_log.add("A new adventure begins!", COLOR_INFO)
return
if key == "Escape":
if key == mcrfpy.Key.ESCAPE:
if game_mode == GameMode.TARGETING:
exit_targeting_mode()
message_log.add("Targeting cancelled.", COLOR_INFO)
@ -1500,14 +1457,8 @@ def handle_keys(key: str, action: str) -> None:
mcrfpy.exit()
return
# Save game (Ctrl+S or just S when not moving)
if key == "S" and game_mode == GameMode.NORMAL:
# Check if this is meant to be a save (could add modifier check)
# For simplicity, we will use a dedicated save key
pass
# Dedicated save with period key
if key == "Period" and game_mode == GameMode.NORMAL and not game_over:
if key == mcrfpy.Key.PERIOD and game_mode == GameMode.NORMAL and not game_over:
save_game()
return
@ -1520,39 +1471,39 @@ def handle_keys(key: str, action: str) -> None:
else:
handle_normal_input(key)
def handle_normal_input(key: str) -> None:
def handle_normal_input(key) -> None:
# Movement
if key == "W" or key == "Up":
if key == mcrfpy.Key.W or key == mcrfpy.Key.UP:
try_move_or_attack(0, -1)
elif key == "S" or key == "Down":
elif key == mcrfpy.Key.S or key == mcrfpy.Key.DOWN:
try_move_or_attack(0, 1)
elif key == "A" or key == "Left":
elif key == mcrfpy.Key.A or key == mcrfpy.Key.LEFT:
try_move_or_attack(-1, 0)
elif key == "D" or key == "Right":
elif key == mcrfpy.Key.D or key == mcrfpy.Key.RIGHT:
try_move_or_attack(1, 0)
# Ranged attack
elif key == "F":
elif key == mcrfpy.Key.F:
enter_targeting_mode()
# Pickup
elif key == "G" or key == ",":
elif key == mcrfpy.Key.G:
pickup_item()
# Use items
elif key in ["1", "2", "3", "4", "5"]:
index = int(key) - 1
elif key in [mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3, mcrfpy.Key.NUM_4, mcrfpy.Key.NUM_5]:
index = [mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3, mcrfpy.Key.NUM_4, mcrfpy.Key.NUM_5].index(key)
if use_item(index):
enemy_turn()
update_ui()
def handle_targeting_input(key: str) -> None:
if key == "Up" or key == "W":
def handle_targeting_input(key) -> None:
if key == mcrfpy.Key.UP or key == mcrfpy.Key.W:
move_cursor(0, -1)
elif key == "Down" or key == "S":
elif key == mcrfpy.Key.DOWN or key == mcrfpy.Key.S:
move_cursor(0, 1)
elif key == "Left" or key == "A":
elif key == mcrfpy.Key.LEFT or key == mcrfpy.Key.A:
move_cursor(-1, 0)
elif key == "Right" or key == "D":
elif key == mcrfpy.Key.RIGHT or key == mcrfpy.Key.D:
move_cursor(1, 0)
elif key == "Return" or key == "Space":
elif key == mcrfpy.Key.ENTER or key == mcrfpy.Key.SPACE:
confirm_target()
scene.on_key = handle_keys

View file

@ -567,24 +567,7 @@ class RectangularRoom:
self.y2 >= other.y1
)
# =============================================================================
# Exploration Tracking
# =============================================================================
explored: list[list[bool]] = []
def init_explored() -> None:
global explored
explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]
def mark_explored(x: int, y: int) -> None:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
explored[y][x] = True
def is_explored(x: int, y: int) -> bool:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
return explored[y][x]
return False
# Exploration tracking is handled automatically by ColorLayer.draw_fov()
# =============================================================================
# Dungeon Generation
@ -821,18 +804,12 @@ def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mc
return None
def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
if entity in entity_data:
del entity_data[entity]
def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
if entity in item_data:
del item_data[entity]
@ -851,11 +828,7 @@ def clear_entities_except_player(target_grid: mcrfpy.Grid) -> None:
del entity_data[entity]
if entity in item_data:
del item_data[entity]
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
# =============================================================================
# Level Transition
@ -883,7 +856,6 @@ def descend_stairs() -> bool:
clear_entities_except_player(grid)
# Generate new dungeon
init_explored()
player_start = generate_dungeon(grid, dungeon_level)
# Move player to starting position
@ -896,9 +868,7 @@ def descend_stairs() -> bool:
spawn_entities_for_level(grid, texture, dungeon_level)
# Reset FOV
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
update_fov(grid, fov_layer, player_start[0], player_start[1])
@ -977,7 +947,7 @@ def spawn_entities_for_level(target_grid: mcrfpy.Grid, tex: mcrfpy.Texture, leve
def save_game() -> bool:
"""Save the current game state to a JSON file."""
global player, player_inventory, grid, explored, dungeon_level, stairs_position
global player, player_inventory, grid, dungeon_level, stairs_position
try:
tiles = []
@ -1026,7 +996,6 @@ def save_game() -> bool:
"inventory": player_inventory.to_dict()
},
"tiles": tiles,
"explored": [[explored[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)],
"enemies": enemies,
"items": items_on_ground
}
@ -1043,7 +1012,7 @@ def save_game() -> bool:
def load_game() -> bool:
"""Load a saved game from JSON file."""
global player, player_inventory, grid, explored, dungeon_level
global player, player_inventory, grid, dungeon_level
global entity_data, item_data, fov_layer, game_over, stairs_position
if not os.path.exists(SAVE_FILE):
@ -1057,7 +1026,7 @@ def load_game() -> bool:
item_data.clear()
while len(grid.entities) > 0:
grid.entities.remove(0)
grid.entities.pop(0)
dungeon_level = save_data.get("dungeon_level", 1)
stairs_position = tuple(save_data.get("stairs_position", [0, 0]))
@ -1071,10 +1040,6 @@ def load_game() -> bool:
cell.walkable = tile_data["walkable"]
cell.transparent = tile_data["transparent"]
global explored
explored_data = save_data["explored"]
explored = [[explored_data[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)]
player_data = save_data["player"]
player = mcrfpy.Entity(
grid_pos=(player_data["x"], player_data["y"]),
@ -1114,9 +1079,7 @@ def load_game() -> bool:
grid.entities.append(item_entity)
item_data[item_entity] = Item.from_dict(item_entry["item"])
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
update_fov(grid, fov_layer, int(player.x), int(player.y))
@ -1168,10 +1131,7 @@ def exit_targeting_mode() -> None:
global game_mode, target_cursor, grid
if target_cursor is not None:
for i, e in enumerate(grid.entities):
if e == target_cursor:
grid.entities.remove(i)
break
grid.entities.remove(target_cursor)
target_cursor = None
game_mode = GameMode.NORMAL
@ -1186,7 +1146,7 @@ def move_cursor(dx: int, dy: int) -> None:
if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT:
return
if not grid.is_in_fov(new_x, new_y):
if not grid.is_in_fov((new_x, new_y)):
message_log.add("You cannot see that location.", COLOR_INVALID)
return
@ -1380,21 +1340,16 @@ def update_entity_visibility(target_grid: mcrfpy.Grid) -> None:
continue
ex, ey = int(entity.x), int(entity.y)
entity.visible = target_grid.is_in_fov(ex, ey)
entity.visible = target_grid.is_in_fov((ex, ey))
def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None:
target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
if target_grid.is_in_fov(x, y):
mark_explored(x, y)
target_fov_layer.set(x, y, COLOR_VISIBLE)
elif is_explored(x, y):
target_fov_layer.set(x, y, COLOR_DISCOVERED)
else:
target_fov_layer.set(x, y, COLOR_UNKNOWN)
target_fov_layer.draw_fov(
(player_x, player_y),
radius=FOV_RADIUS,
visible=COLOR_VISIBLE,
discovered=COLOR_DISCOVERED,
unknown=COLOR_UNKNOWN
)
update_entity_visibility(target_grid)
# =============================================================================
@ -1466,7 +1421,7 @@ def enemy_turn() -> None:
ex, ey = int(enemy.x), int(enemy.y)
if not grid.is_in_fov(ex, ey):
if not grid.is_in_fov((ex, ey)):
continue
dx = player_x - ex
@ -1535,9 +1490,8 @@ def generate_new_game() -> None:
item_data.clear()
while len(grid.entities) > 0:
grid.entities.remove(0)
grid.entities.pop(0)
init_explored()
message_log.clear()
player_start = generate_dungeon(grid, dungeon_level)
@ -1562,9 +1516,7 @@ def generate_new_game() -> None:
spawn_entities_for_level(grid, texture, dungeon_level)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
update_fov(grid, fov_layer, player_start[0], player_start[1])
@ -1587,10 +1539,9 @@ grid = mcrfpy.Grid(
zoom=1.0
)
fov_layer = grid.add_layer("color", z_index=-1)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer = mcrfpy.ColorLayer(z_index=-1, name="fov")
grid.add_layer(fov_layer)
fov_layer.fill(COLOR_UNKNOWN)
scene.children.append(grid)
@ -1639,8 +1590,6 @@ message_log.add_to_scene(scene)
# Initialize Game
# =============================================================================
init_explored()
if has_save_file():
message_log.add("Found saved game. Loading...", COLOR_INFO)
if not load_game():
@ -1656,19 +1605,19 @@ else:
# Input Handling
# =============================================================================
def handle_keys(key: str, action: str) -> None:
def handle_keys(key, action) -> None:
global game_over, game_mode
if action != "start":
if action != mcrfpy.InputState.PRESSED:
return
if key == "R":
if key == mcrfpy.Key.R:
delete_save()
generate_new_game()
message_log.add("A new adventure begins!", COLOR_INFO)
return
if key == "Escape":
if key == mcrfpy.Key.ESCAPE:
if game_mode == GameMode.TARGETING:
exit_targeting_mode()
message_log.add("Targeting cancelled.", COLOR_INFO)
@ -1679,7 +1628,7 @@ def handle_keys(key: str, action: str) -> None:
mcrfpy.exit()
return
if key == "Period" and game_mode == GameMode.NORMAL and not game_over:
if key == mcrfpy.Key.PERIOD and game_mode == GameMode.NORMAL and not game_over:
save_game()
return
@ -1691,38 +1640,38 @@ def handle_keys(key: str, action: str) -> None:
else:
handle_normal_input(key)
def handle_normal_input(key: str) -> None:
if key == "W" or key == "Up":
def handle_normal_input(key) -> None:
if key == mcrfpy.Key.W or key == mcrfpy.Key.UP:
try_move_or_attack(0, -1)
elif key == "S" or key == "Down":
elif key == mcrfpy.Key.S or key == mcrfpy.Key.DOWN:
try_move_or_attack(0, 1)
elif key == "A" or key == "Left":
elif key == mcrfpy.Key.A or key == mcrfpy.Key.LEFT:
try_move_or_attack(-1, 0)
elif key == "D" or key == "Right":
elif key == mcrfpy.Key.D or key == mcrfpy.Key.RIGHT:
try_move_or_attack(1, 0)
elif key == "F":
elif key == mcrfpy.Key.F:
enter_targeting_mode()
elif key == "G" or key == ",":
elif key == mcrfpy.Key.G or key == mcrfpy.Key.COMMA:
pickup_item()
elif key == "Period" and mcrfpy.keypressed("LShift"):
elif key == mcrfpy.Key.PERIOD and mcrfpy.keyboard.shift:
# Shift+. (>) to descend stairs
descend_stairs()
elif key in ["1", "2", "3", "4", "5"]:
index = int(key) - 1
elif key in (mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3, mcrfpy.Key.NUM_4, mcrfpy.Key.NUM_5):
index = [mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3, mcrfpy.Key.NUM_4, mcrfpy.Key.NUM_5].index(key)
if use_item(index):
enemy_turn()
update_ui()
def handle_targeting_input(key: str) -> None:
if key == "Up" or key == "W":
def handle_targeting_input(key) -> None:
if key == mcrfpy.Key.UP or key == mcrfpy.Key.W:
move_cursor(0, -1)
elif key == "Down" or key == "S":
elif key == mcrfpy.Key.DOWN or key == mcrfpy.Key.S:
move_cursor(0, 1)
elif key == "Left" or key == "A":
elif key == mcrfpy.Key.LEFT or key == mcrfpy.Key.A:
move_cursor(-1, 0)
elif key == "Right" or key == "D":
elif key == mcrfpy.Key.RIGHT or key == mcrfpy.Key.D:
move_cursor(1, 0)
elif key == "Return" or key == "Space":
elif key == mcrfpy.Key.ENTER or key == mcrfpy.Key.SPACE:
confirm_target()
scene.on_key = handle_keys

View file

@ -723,24 +723,7 @@ class RectangularRoom:
self.y2 >= other.y1
)
# =============================================================================
# Exploration Tracking
# =============================================================================
explored: list[list[bool]] = []
def init_explored() -> None:
global explored
explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]
def mark_explored(x: int, y: int) -> None:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
explored[y][x] = True
def is_explored(x: int, y: int) -> bool:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
return explored[y][x]
return False
# Exploration tracking is handled automatically by ColorLayer.draw_fov()
# =============================================================================
# Dungeon Generation
@ -972,18 +955,12 @@ def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mc
return None
def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
if entity in entity_data:
del entity_data[entity]
def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
if entity in item_data:
del item_data[entity]
@ -1001,11 +978,7 @@ def clear_entities_except_player(target_grid: mcrfpy.Grid) -> None:
del entity_data[entity]
if entity in item_data:
del item_data[entity]
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
# =============================================================================
# XP and Level Up
@ -1059,7 +1032,6 @@ def descend_stairs() -> bool:
clear_entities_except_player(grid)
init_explored()
player_start = generate_dungeon(grid, dungeon_level)
player.x = player_start[0]
@ -1067,9 +1039,7 @@ def descend_stairs() -> bool:
spawn_entities_for_level(grid, texture, dungeon_level)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
update_fov(grid, fov_layer, player_start[0], player_start[1])
@ -1084,7 +1054,7 @@ def descend_stairs() -> bool:
# =============================================================================
def save_game() -> bool:
global player, player_inventory, grid, explored, dungeon_level, stairs_position
global player, player_inventory, grid, dungeon_level, stairs_position
try:
tiles = []
@ -1133,7 +1103,6 @@ def save_game() -> bool:
"inventory": player_inventory.to_dict()
},
"tiles": tiles,
"explored": [[explored[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)],
"enemies": enemies,
"items": items_on_ground
}
@ -1149,7 +1118,7 @@ def save_game() -> bool:
return False
def load_game() -> bool:
global player, player_inventory, grid, explored, dungeon_level
global player, player_inventory, grid, dungeon_level
global entity_data, item_data, fov_layer, game_over, stairs_position
if not os.path.exists(SAVE_FILE):
@ -1163,7 +1132,7 @@ def load_game() -> bool:
item_data.clear()
while len(grid.entities) > 0:
grid.entities.remove(0)
grid.entities.pop(0)
dungeon_level = save_data.get("dungeon_level", 1)
stairs_position = tuple(save_data.get("stairs_position", [0, 0]))
@ -1177,10 +1146,6 @@ def load_game() -> bool:
cell.walkable = tile_data["walkable"]
cell.transparent = tile_data["transparent"]
global explored
explored_data = save_data["explored"]
explored = [[explored_data[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)]
player_data = save_data["player"]
player = mcrfpy.Entity(
grid_pos=(player_data["x"], player_data["y"]),
@ -1220,9 +1185,7 @@ def load_game() -> bool:
grid.entities.append(item_entity)
item_data[item_entity] = Item.from_dict(item_entry["item"])
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
update_fov(grid, fov_layer, int(player.x), int(player.y))
@ -1274,10 +1237,7 @@ def exit_targeting_mode() -> None:
global game_mode, target_cursor, grid
if target_cursor is not None:
for i, e in enumerate(grid.entities):
if e == target_cursor:
grid.entities.remove(i)
break
grid.entities.remove(target_cursor)
target_cursor = None
game_mode = GameMode.NORMAL
@ -1292,7 +1252,7 @@ def move_cursor(dx: int, dy: int) -> None:
if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT:
return
if not grid.is_in_fov(new_x, new_y):
if not grid.is_in_fov((new_x, new_y)):
message_log.add("You cannot see that location.", COLOR_INVALID)
return
@ -1488,21 +1448,16 @@ def update_entity_visibility(target_grid: mcrfpy.Grid) -> None:
continue
ex, ey = int(entity.x), int(entity.y)
entity.visible = target_grid.is_in_fov(ex, ey)
entity.visible = target_grid.is_in_fov((ex, ey))
def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None:
target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
if target_grid.is_in_fov(x, y):
mark_explored(x, y)
target_fov_layer.set(x, y, COLOR_VISIBLE)
elif is_explored(x, y):
target_fov_layer.set(x, y, COLOR_DISCOVERED)
else:
target_fov_layer.set(x, y, COLOR_UNKNOWN)
target_fov_layer.draw_fov(
(player_x, player_y),
radius=FOV_RADIUS,
visible=COLOR_VISIBLE,
discovered=COLOR_DISCOVERED,
unknown=COLOR_UNKNOWN
)
update_entity_visibility(target_grid)
# =============================================================================
@ -1574,7 +1529,7 @@ def enemy_turn() -> None:
ex, ey = int(enemy.x), int(enemy.y)
if not grid.is_in_fov(ex, ey):
if not grid.is_in_fov((ex, ey)):
continue
dx = player_x - ex
@ -1644,9 +1599,8 @@ def generate_new_game() -> None:
item_data.clear()
while len(grid.entities) > 0:
grid.entities.remove(0)
grid.entities.pop(0)
init_explored()
message_log.clear()
player_start = generate_dungeon(grid, dungeon_level)
@ -1673,9 +1627,7 @@ def generate_new_game() -> None:
spawn_entities_for_level(grid, texture, dungeon_level)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
update_fov(grid, fov_layer, player_start[0], player_start[1])
@ -1698,10 +1650,9 @@ grid = mcrfpy.Grid(
zoom=1.0
)
fov_layer = grid.add_layer("color", z_index=-1)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer = mcrfpy.ColorLayer(z_index=-1, name="fov")
grid.add_layer(fov_layer)
fov_layer.fill(COLOR_UNKNOWN)
scene.children.append(grid)
@ -1756,8 +1707,6 @@ message_log.add_to_scene(scene)
# Initialize Game
# =============================================================================
init_explored()
if has_save_file():
message_log.add("Found saved game. Loading...", COLOR_INFO)
if not load_game():
@ -1773,19 +1722,19 @@ else:
# Input Handling
# =============================================================================
def handle_keys(key: str, action: str) -> None:
def handle_keys(key, action) -> None:
global game_over, game_mode
if action != "start":
if action != mcrfpy.InputState.PRESSED:
return
if key == "R":
if key == mcrfpy.Key.R:
delete_save()
generate_new_game()
message_log.add("A new adventure begins!", COLOR_INFO)
return
if key == "Escape":
if key == mcrfpy.Key.ESCAPE:
if game_mode == GameMode.TARGETING:
exit_targeting_mode()
message_log.add("Targeting cancelled.", COLOR_INFO)
@ -1796,7 +1745,7 @@ def handle_keys(key: str, action: str) -> None:
mcrfpy.exit()
return
if key == "Period" and game_mode == GameMode.NORMAL and not game_over:
if key == mcrfpy.Key.PERIOD and game_mode == GameMode.NORMAL and not game_over:
# Check for shift to descend
descend_stairs()
return
@ -1809,35 +1758,35 @@ def handle_keys(key: str, action: str) -> None:
else:
handle_normal_input(key)
def handle_normal_input(key: str) -> None:
if key == "W" or key == "Up":
def handle_normal_input(key) -> None:
if key == mcrfpy.Key.W or key == mcrfpy.Key.UP:
try_move_or_attack(0, -1)
elif key == "S" or key == "Down":
elif key == mcrfpy.Key.S or key == mcrfpy.Key.DOWN:
try_move_or_attack(0, 1)
elif key == "A" or key == "Left":
elif key == mcrfpy.Key.A or key == mcrfpy.Key.LEFT:
try_move_or_attack(-1, 0)
elif key == "D" or key == "Right":
elif key == mcrfpy.Key.D or key == mcrfpy.Key.RIGHT:
try_move_or_attack(1, 0)
elif key == "F":
elif key == mcrfpy.Key.F:
enter_targeting_mode()
elif key == "G" or key == ",":
elif key == mcrfpy.Key.G or key == mcrfpy.Key.COMMA:
pickup_item()
elif key in ["1", "2", "3", "4", "5"]:
index = int(key) - 1
elif key in (mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3, mcrfpy.Key.NUM_4, mcrfpy.Key.NUM_5):
index = [mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3, mcrfpy.Key.NUM_4, mcrfpy.Key.NUM_5].index(key)
if use_item(index):
enemy_turn()
update_ui()
def handle_targeting_input(key: str) -> None:
if key == "Up" or key == "W":
def handle_targeting_input(key) -> None:
if key == mcrfpy.Key.UP or key == mcrfpy.Key.W:
move_cursor(0, -1)
elif key == "Down" or key == "S":
elif key == mcrfpy.Key.DOWN or key == mcrfpy.Key.S:
move_cursor(0, 1)
elif key == "Left" or key == "A":
elif key == mcrfpy.Key.LEFT or key == mcrfpy.Key.A:
move_cursor(-1, 0)
elif key == "Right" or key == "D":
elif key == mcrfpy.Key.RIGHT or key == mcrfpy.Key.D:
move_cursor(1, 0)
elif key == "Return" or key == "Space":
elif key == mcrfpy.Key.ENTER or key == mcrfpy.Key.SPACE:
confirm_target()
scene.on_key = handle_keys

View file

@ -801,24 +801,7 @@ class RectangularRoom:
def intersects(self, other: "RectangularRoom") -> bool:
return self.x1 <= other.x2 and self.x2 >= other.x1 and self.y1 <= other.y2 and self.y2 >= other.y1
# =============================================================================
# Exploration Tracking
# =============================================================================
explored: list[list[bool]] = []
def init_explored() -> None:
global explored
explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]
def mark_explored(x: int, y: int) -> None:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
explored[y][x] = True
def is_explored(x: int, y: int) -> bool:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
return explored[y][x]
return False
# Exploration tracking is handled automatically by ColorLayer.draw_fov()
# =============================================================================
# Dungeon Generation (abbreviated for space)
@ -1014,18 +997,12 @@ def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mc
return None
def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
if entity in entity_data:
del entity_data[entity]
def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
if entity in item_data:
del item_data[entity]
@ -1041,10 +1018,7 @@ def clear_entities_except_player(target_grid: mcrfpy.Grid) -> None:
del entity_data[entity]
if entity in item_data:
del item_data[entity]
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(i)
break
target_grid.entities.remove(entity)
# =============================================================================
# Equipment Actions
@ -1174,14 +1148,11 @@ def descend_stairs() -> bool:
dungeon_level += 1
clear_entities_except_player(grid)
init_explored()
player_start = generate_dungeon(grid, dungeon_level)
player.x, player.y = player_start
spawn_entities_for_level(grid, texture, dungeon_level)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
update_fov(grid, fov_layer, player_start[0], player_start[1])
message_log.add(f"You descend to level {dungeon_level}...", COLOR_DESCEND)
@ -1194,7 +1165,7 @@ def descend_stairs() -> bool:
# =============================================================================
def save_game() -> bool:
global player, player_inventory, grid, explored, dungeon_level, stairs_position
global player, player_inventory, grid, dungeon_level, stairs_position
try:
tiles = []
@ -1238,7 +1209,6 @@ def save_game() -> bool:
"inventory": player_inventory.to_dict()
},
"tiles": tiles,
"explored": [[explored[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)],
"enemies": enemies,
"items": items_on_ground
}
@ -1253,7 +1223,7 @@ def save_game() -> bool:
return False
def load_game() -> bool:
global player, player_inventory, grid, explored, dungeon_level
global player, player_inventory, grid, dungeon_level
global entity_data, item_data, fov_layer, game_over, stairs_position
if not os.path.exists(SAVE_FILE):
@ -1266,7 +1236,7 @@ def load_game() -> bool:
entity_data.clear()
item_data.clear()
while len(grid.entities) > 0:
grid.entities.remove(0)
grid.entities.pop(0)
dungeon_level = save_data.get("dungeon_level", 1)
stairs_position = tuple(save_data.get("stairs_position", [0, 0]))
@ -1280,9 +1250,6 @@ def load_game() -> bool:
cell.walkable = tile_data["walkable"]
cell.transparent = tile_data["transparent"]
global explored
explored = save_data["explored"]
player_data = save_data["player"]
player = mcrfpy.Entity(
grid_pos=(player_data["x"], player_data["y"]),
@ -1321,9 +1288,7 @@ def load_game() -> bool:
grid.entities.append(item_entity)
item_data[item_entity] = item
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
update_fov(grid, fov_layer, int(player.x), int(player.y))
game_over = False
@ -1362,10 +1327,7 @@ def enter_targeting_mode() -> None:
def exit_targeting_mode() -> None:
global game_mode, target_cursor
if target_cursor:
for i, e in enumerate(grid.entities):
if e == target_cursor:
grid.entities.remove(i)
break
grid.entities.remove(target_cursor)
target_cursor = None
game_mode = GameMode.NORMAL
mode_display.update(game_mode)
@ -1375,7 +1337,7 @@ def move_cursor(dx: int, dy: int) -> None:
new_x, new_y = target_x + dx, target_y + dy
if not (0 <= new_x < GRID_WIDTH and 0 <= new_y < GRID_HEIGHT):
return
if not grid.is_in_fov(new_x, new_y):
if not grid.is_in_fov((new_x, new_y)):
message_log.add("Cannot see that location.", COLOR_INVALID)
return
distance = abs(new_x - int(player.x)) + abs(new_y - int(player.y))
@ -1515,19 +1477,16 @@ def update_entity_visibility(target_grid: mcrfpy.Grid) -> None:
if entity == player or entity == target_cursor:
entity.visible = True
else:
entity.visible = target_grid.is_in_fov(int(entity.x), int(entity.y))
entity.visible = target_grid.is_in_fov((int(entity.x), int(entity.y)))
def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None:
target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
if target_grid.is_in_fov(x, y):
mark_explored(x, y)
target_fov_layer.set(x, y, COLOR_VISIBLE)
elif is_explored(x, y):
target_fov_layer.set(x, y, COLOR_DISCOVERED)
else:
target_fov_layer.set(x, y, COLOR_UNKNOWN)
target_fov_layer.draw_fov(
(player_x, player_y),
radius=FOV_RADIUS,
visible=COLOR_VISIBLE,
discovered=COLOR_DISCOVERED,
unknown=COLOR_UNKNOWN
)
update_entity_visibility(target_grid)
# =============================================================================
@ -1577,7 +1536,7 @@ def enemy_turn() -> None:
if not fighter.is_alive:
continue
ex, ey = int(entity.x), int(entity.y)
if not grid.is_in_fov(ex, ey):
if not grid.is_in_fov((ex, ey)):
continue
dx, dy = px - ex, py - ey
@ -1624,9 +1583,8 @@ def generate_new_game() -> None:
entity_data.clear()
item_data.clear()
while len(grid.entities) > 0:
grid.entities.remove(0)
grid.entities.pop(0)
init_explored()
message_log.clear()
player_start = generate_dungeon(grid, dungeon_level)
@ -1642,9 +1600,7 @@ def generate_new_game() -> None:
player_inventory = Inventory(capacity=10)
spawn_entities_for_level(grid, texture, dungeon_level)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer.fill(COLOR_UNKNOWN)
update_fov(grid, fov_layer, player_start[0], player_start[1])
mode_display.update(game_mode)
@ -1666,10 +1622,9 @@ grid = mcrfpy.Grid(
zoom=1.0
)
fov_layer = grid.add_layer("color", z_index=-1)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set(x, y, COLOR_UNKNOWN)
fov_layer = mcrfpy.ColorLayer(z_index=-1, name="fov")
grid.add_layer(fov_layer)
fov_layer.fill(COLOR_UNKNOWN)
scene.children.append(grid)
@ -1706,8 +1661,6 @@ message_log = MessageLog(x=20, y=768 - UI_BOTTOM_HEIGHT + 10, width=990, height=
message_log.add_to_scene(scene)
# Initialize
init_explored()
if has_save_file():
message_log.add("Loading saved game...", COLOR_INFO)
if not load_game():
@ -1722,19 +1675,19 @@ else:
# Input Handling
# =============================================================================
def handle_keys(key: str, action: str) -> None:
def handle_keys(key, action) -> None:
global game_over, game_mode
if action != "start":
if action != mcrfpy.InputState.PRESSED:
return
if key == "R":
if key == mcrfpy.Key.R:
delete_save()
generate_new_game()
message_log.add("New adventure begins!", COLOR_INFO)
return
if key == "Escape":
if key == mcrfpy.Key.ESCAPE:
if game_mode == GameMode.TARGETING:
exit_targeting_mode()
message_log.add("Targeting cancelled.", COLOR_INFO)
@ -1752,39 +1705,39 @@ def handle_keys(key: str, action: str) -> None:
else:
handle_normal_input(key)
def handle_normal_input(key: str) -> None:
if key in ("W", "Up"):
def handle_normal_input(key) -> None:
if key in (mcrfpy.Key.W, mcrfpy.Key.UP):
try_move_or_attack(0, -1)
elif key in ("S", "Down"):
elif key in (mcrfpy.Key.S, mcrfpy.Key.DOWN):
try_move_or_attack(0, 1)
elif key in ("A", "Left"):
elif key in (mcrfpy.Key.A, mcrfpy.Key.LEFT):
try_move_or_attack(-1, 0)
elif key in ("D", "Right"):
elif key in (mcrfpy.Key.D, mcrfpy.Key.RIGHT):
try_move_or_attack(1, 0)
elif key == "F":
elif key == mcrfpy.Key.F:
enter_targeting_mode()
elif key in ("G", ","):
elif key in (mcrfpy.Key.G, mcrfpy.Key.COMMA):
pickup_item()
elif key == "Period":
elif key == mcrfpy.Key.PERIOD:
descend_stairs()
elif key == "E":
elif key == mcrfpy.Key.E:
message_log.add("Press 1-5 to equip an item from inventory.", COLOR_INFO)
elif key in "12345":
index = int(key) - 1
elif key in (mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3, mcrfpy.Key.NUM_4, mcrfpy.Key.NUM_5):
index = [mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3, mcrfpy.Key.NUM_4, mcrfpy.Key.NUM_5].index(key)
if use_item(index):
enemy_turn()
update_ui()
def handle_targeting_input(key: str) -> None:
if key in ("Up", "W"):
def handle_targeting_input(key) -> None:
if key in (mcrfpy.Key.UP, mcrfpy.Key.W):
move_cursor(0, -1)
elif key in ("Down", "S"):
elif key in (mcrfpy.Key.DOWN, mcrfpy.Key.S):
move_cursor(0, 1)
elif key in ("Left", "A"):
elif key in (mcrfpy.Key.LEFT, mcrfpy.Key.A):
move_cursor(-1, 0)
elif key in ("Right", "D"):
elif key in (mcrfpy.Key.RIGHT, mcrfpy.Key.D):
move_cursor(1, 0)
elif key in ("Return", "Space"):
elif key in (mcrfpy.Key.ENTER, mcrfpy.Key.SPACE):
confirm_target()
scene.on_key = handle_keys

View file

@ -1,8 +1,8 @@
# McRogueFace AddressSanitizer Suppression File
#
# Minimal — most CPython false positives are handled by:
# CPython false positives are handled by:
# - PYTHONMALLOC=malloc (bypasses pymalloc)
# - detect_leaks=0 (CPython has intentional lifetime leaks)
# - LSAN suppressions below (CPython has intentional lifetime leaks)
#
# Usage (via ASAN_OPTIONS or LSAN_OPTIONS):
# LSAN_OPTIONS="suppressions=sanitizers/asan.supp"

View file

@ -30,7 +30,7 @@ public:
GridLayerType type;
std::string name; // #150 - Layer name for GridPoint property access
int z_index; // Negative = below entities, >= 0 = above entities
int z_index; // <= 0 = below entities (ground level), > 0 = above entities
int grid_x, grid_y; // Dimensions
GridData* parent_grid; // Parent grid reference (#252: GridData, not UIGrid)
bool visible; // Visibility flag

View file

@ -269,15 +269,6 @@ static PyMethodDef mcrfpyMethods[] = {
MCRF_RETURNS("None")
MCRF_NOTE("This immediately closes the window and terminates the program.")
)},
{"setScale", McRFPy_API::_setScale, METH_VARARGS,
MCRF_FUNCTION(setScale,
MCRF_SIG("(multiplier: float)", "None"),
MCRF_DESC("Deprecated: use Window.resolution instead. Scale the game window size."),
MCRF_ARGS_START
MCRF_ARG("multiplier", "Scale factor (e.g., 2.0 for double size)")
MCRF_RETURNS("None")
MCRF_NOTE("The internal resolution remains 1024x768, but the window is scaled. This is deprecated - use Window.resolution instead.")
)},
{"set_scale", McRFPy_API::_setScale, METH_VARARGS,
MCRF_FUNCTION(set_scale,
MCRF_SIG("(multiplier: float)", "None"),
@ -298,16 +289,6 @@ static PyMethodDef mcrfpyMethods[] = {
MCRF_RETURNS("Frame, Caption, Sprite, Grid, or Entity if found; None otherwise")
MCRF_NOTE("Searches scene UI elements and entities within grids.")
)},
{"findAll", McRFPy_API::_findAll, METH_VARARGS,
MCRF_FUNCTION(findAll,
MCRF_SIG("(pattern: str, scene: str = None)", "list"),
MCRF_DESC("Find all UI elements matching a name pattern. Prefer find_all()."),
MCRF_ARGS_START
MCRF_ARG("pattern", "Name pattern with optional wildcards (* matches any characters)")
MCRF_ARG("scene", "Scene to search in (default: current scene)")
MCRF_RETURNS("list: All matching UI elements and entities")
MCRF_NOTE("Example: find_all('enemy*') finds all elements starting with 'enemy'")
)},
{"find_all", McRFPy_API::_findAll, METH_VARARGS,
MCRF_FUNCTION(find_all,
MCRF_SIG("(pattern: str, scene: str = None)", "list"),
@ -319,12 +300,6 @@ static PyMethodDef mcrfpyMethods[] = {
MCRF_NOTE("Example: find_all('enemy*') finds all elements starting with 'enemy', find_all('*_button') finds all elements ending with '_button'")
)},
{"getMetrics", McRFPy_API::_getMetrics, METH_NOARGS,
MCRF_FUNCTION(getMetrics,
MCRF_SIG("()", "dict"),
MCRF_DESC("Get current performance metrics. Prefer get_metrics()."),
MCRF_RETURNS("dict: Performance data with keys: frame_time, avg_frame_time, fps, draw_calls, ui_elements, visible_elements, current_frame, runtime")
)},
{"get_metrics", McRFPy_API::_getMetrics, METH_NOARGS,
MCRF_FUNCTION(get_metrics,
MCRF_SIG("()", "dict"),
@ -332,15 +307,6 @@ static PyMethodDef mcrfpyMethods[] = {
MCRF_RETURNS("dict: Performance data with keys: frame_time (last frame duration in seconds), avg_frame_time (average frame time), fps (frames per second), draw_calls (number of draw calls), ui_elements (total UI element count), visible_elements (visible element count), current_frame (frame counter), runtime (total runtime in seconds)")
)},
{"setDevConsole", McRFPy_API::_setDevConsole, METH_VARARGS,
MCRF_FUNCTION(setDevConsole,
MCRF_SIG("(enabled: bool)", "None"),
MCRF_DESC("Enable or disable the developer console overlay. Prefer set_dev_console()."),
MCRF_ARGS_START
MCRF_ARG("enabled", "True to enable the console (default), False to disable")
MCRF_RETURNS("None")
MCRF_NOTE("When disabled, the grave/tilde key will not open the console. Use this to ship games without debug features.")
)},
{"set_dev_console", McRFPy_API::_setDevConsole, METH_VARARGS,
MCRF_FUNCTION(set_dev_console,
MCRF_SIG("(enabled: bool)", "None"),

View file

@ -404,6 +404,7 @@ int UIArc::set_center(PyUIArcObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setCenter(vec->data);
self->data->markCompositeDirty(); // #291: position change
return 0;
}
@ -417,6 +418,7 @@ int UIArc::set_radius(PyUIArcObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setRadius(static_cast<float>(PyFloat_AsDouble(value)));
self->data->markDirty(); // #291: visual change
return 0;
}
@ -430,6 +432,7 @@ int UIArc::set_start_angle(PyUIArcObject* self, PyObject* value, void* closure)
return -1;
}
self->data->setStartAngle(static_cast<float>(PyFloat_AsDouble(value)));
self->data->markDirty(); // #291: visual change
return 0;
}
@ -443,6 +446,7 @@ int UIArc::set_end_angle(PyUIArcObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setEndAngle(static_cast<float>(PyFloat_AsDouble(value)));
self->data->markDirty(); // #291: visual change
return 0;
}
@ -462,6 +466,7 @@ int UIArc::set_color(PyUIArcObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setColor(color->data);
self->data->markDirty(); // #291: color change
return 0;
}
@ -475,6 +480,7 @@ int UIArc::set_thickness(PyUIArcObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setThickness(static_cast<float>(PyFloat_AsDouble(value)));
self->data->markDirty(); // #291: visual change
return 0;
}

View file

@ -200,14 +200,22 @@ int UICaption::set_float_member(PyUICaptionObject* self, PyObject* value, void*
PyErr_SetString(PyExc_TypeError, "Value must be a number (int or float)");
return -1;
}
if (member_ptr == 0) //x
if (member_ptr == 0) { //x
self->data->text.setPosition(val, self->data->text.getPosition().y);
else if (member_ptr == 1) //y
self->data->markCompositeDirty(); // #289: position change invalidates parent cache
}
else if (member_ptr == 1) { //y
self->data->text.setPosition(self->data->text.getPosition().x, val);
else if (member_ptr == 4) //outline
self->data->markCompositeDirty(); // #289: position change invalidates parent cache
}
else if (member_ptr == 4) { //outline
self->data->text.setOutlineThickness(val);
else if (member_ptr == 5) // character size
self->data->markDirty(); // #289: content change invalidates own + parent cache
}
else if (member_ptr == 5) { // character size
self->data->text.setCharacterSize(val);
self->data->markDirty(); // #289: content change invalidates own + parent cache
}
return 0;
}
@ -219,6 +227,7 @@ PyObject* UICaption::get_vec_member(PyUICaptionObject* self, void* closure)
int UICaption::set_vec_member(PyUICaptionObject* self, PyObject* value, void* closure)
{
self->data->text.setPosition(PyVector::fromPy(value));
self->data->markCompositeDirty(); // #289: position change invalidates parent cache
return 0;
}
@ -289,10 +298,12 @@ int UICaption::set_color_member(PyUICaptionObject* self, PyObject* value, void*
if (member_ptr == 0)
{
self->data->text.setFillColor(sf::Color(r, g, b, a));
self->data->markDirty(); // #289: color change invalidates own + parent cache
}
else if (member_ptr == 1)
{
self->data->text.setOutlineColor(sf::Color(r, g, b, a));
self->data->markDirty(); // #289: color change invalidates own + parent cache
}
else
{
@ -329,6 +340,7 @@ int UICaption::set_text(PyUICaptionObject* self, PyObject* value, void* closure)
Py_DECREF(temp_bytes);
}
self->data->text.setString(Resources::caption_buffer);
self->data->markDirty(); // #289: text change invalidates own + parent cache
return 0;
}

View file

@ -337,6 +337,7 @@ int UICircle::set_radius(PyUICircleObject* self, PyObject* value, void* closure)
return -1;
}
self->data->setRadius(static_cast<float>(PyFloat_AsDouble(value)));
self->data->markDirty(); // #291: visual change
return 0;
}
@ -355,6 +356,7 @@ int UICircle::set_center(PyUICircleObject* self, PyObject* value, void* closure)
return -1;
}
self->data->setCenter(vec->data);
self->data->markCompositeDirty(); // #291: position change
return 0;
}
@ -382,6 +384,7 @@ int UICircle::set_fill_color(PyUICircleObject* self, PyObject* value, void* clos
return -1;
}
self->data->setFillColor(color);
self->data->markDirty(); // #291: color change
return 0;
}
@ -409,6 +412,7 @@ int UICircle::set_outline_color(PyUICircleObject* self, PyObject* value, void* c
return -1;
}
self->data->setOutlineColor(color);
self->data->markDirty(); // #291: color change
return 0;
}
@ -422,6 +426,7 @@ int UICircle::set_outline(PyUICircleObject* self, PyObject* value, void* closure
return -1;
}
self->data->setOutline(static_cast<float>(PyFloat_AsDouble(value)));
self->data->markDirty(); // #291: visual change
return 0;
}

View file

@ -271,6 +271,11 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject
// #122: Clear the parent before removing
(*self->data)[index]->setParent(nullptr);
self->data->erase(self->data->begin() + index);
// #288: Invalidate parent Frame's render cache
auto owner_ptr = self->owner.lock();
if (owner_ptr) {
owner_ptr->markContentDirty();
}
return 0;
}
@ -302,6 +307,12 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject
// Mark scene as needing resort after replacing element
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
auto owner_ptr = self->owner.lock();
if (owner_ptr) {
owner_ptr->markContentDirty();
}
return 0;
}
@ -494,7 +505,14 @@ int UICollection::ass_subscript(PyUICollectionObject* self, PyObject* key, PyObj
// Mark scene as needing resort after slice deletion
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
{
auto owner_ptr = self->owner.lock();
if (owner_ptr) {
owner_ptr->markContentDirty();
}
}
return 0;
} else {
// Assignment
@ -564,7 +582,14 @@ int UICollection::ass_subscript(PyUICollectionObject* self, PyObject* key, PyObj
// Mark scene as needing resort after slice assignment
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
{
auto owner_ptr = self->owner.lock();
if (owner_ptr) {
owner_ptr->markContentDirty();
}
}
return 0;
}
} else {
@ -635,6 +660,12 @@ PyObject* UICollection::append(PyUICollectionObject* self, PyObject* o)
// Mark scene as needing resort after adding element
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
auto owner_ptr = self->owner.lock();
if (owner_ptr) {
owner_ptr->markContentDirty();
}
Py_INCREF(Py_None);
return Py_None;
}
@ -689,7 +720,12 @@ PyObject* UICollection::extend(PyUICollectionObject* self, PyObject* iterable)
// Mark scene as needing resort after adding elements
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
if (owner_ptr) {
owner_ptr->markContentDirty();
}
Py_INCREF(Py_None);
return Py_None;
}
@ -717,6 +753,11 @@ PyObject* UICollection::remove(PyUICollectionObject* self, PyObject* o)
(*it)->setParent(nullptr);
vec->erase(it);
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
auto owner_ptr = self->owner.lock();
if (owner_ptr) {
owner_ptr->markContentDirty();
}
Py_RETURN_NONE;
}
}
@ -766,6 +807,12 @@ PyObject* UICollection::pop(PyUICollectionObject* self, PyObject* args)
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
auto owner_ptr = self->owner.lock();
if (owner_ptr) {
owner_ptr->markContentDirty();
}
// Convert to Python object and return
return convertDrawableToPython(drawable);
}
@ -817,6 +864,12 @@ PyObject* UICollection::insert(PyUICollectionObject* self, PyObject* args)
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
auto owner_ptr2 = self->owner.lock();
if (owner_ptr2) {
owner_ptr2->markContentDirty();
}
Py_RETURN_NONE;
}

View file

@ -545,10 +545,12 @@ int UIDrawable::set_float_member(PyObject* self, PyObject* value, void* closure)
case 0: // x
drawable->position.x = val;
drawable->onPositionChanged();
drawable->markCompositeDirty(); // #290: position change invalidates parent cache
break;
case 1: // y
drawable->position.y = val;
drawable->onPositionChanged();
drawable->markCompositeDirty(); // #290: position change invalidates parent cache
break;
case 2: // w
case 3: // h
@ -559,6 +561,7 @@ int UIDrawable::set_float_member(PyObject* self, PyObject* value, void* closure)
} else {
drawable->resize(bounds.width, val);
}
drawable->markDirty(); // #290: size change invalidates own + parent cache
}
break;
default:
@ -638,6 +641,7 @@ int UIDrawable::set_pos(PyObject* self, PyObject* value, void* closure) {
drawable->position = sf::Vector2f(x, y);
drawable->onPositionChanged();
drawable->markCompositeDirty(); // #290: position change invalidates parent cache
return 0;
}
@ -873,6 +877,7 @@ int UIDrawable::set_grid_pos(PyObject* self, PyObject* value, void* closure) {
drawable->position.x = grid_x * cell_size.x;
drawable->position.y = grid_y * cell_size.y;
drawable->onPositionChanged();
drawable->markCompositeDirty(); // #290: position change invalidates parent cache
return 0;
}

View file

@ -182,10 +182,10 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target)
if (y_limit > grid_h) y_limit = grid_h;
// #150 - Layers are now the sole source of grid rendering (base layer removed)
// Render layers with z_index < 0 (below entities)
// Render layers with z_index <= 0 (below entities)
sortLayers();
for (auto& layer : layers) {
if (layer->z_index >= 0) break; // Stop at layers that go above entities
if (layer->z_index > 0) break; // Stop at layers that go above entities (#257)
layer->render(*activeTexture, left_spritepixels, top_spritepixels,
left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height);
}
@ -222,9 +222,9 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target)
Resources::game->metrics.totalEntities += totalEntities;
}
// #147 - Render dynamic layers with z_index >= 0 (above entities)
// #147 - Render dynamic layers with z_index > 0 (above entities)
for (auto& layer : layers) {
if (layer->z_index < 0) continue; // Skip layers below entities
if (layer->z_index <= 0) continue; // Skip layers at or below entities (#257)
layer->render(*activeTexture, left_spritepixels, top_spritepixels,
left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height);
}
@ -1074,7 +1074,8 @@ int UIGrid::set_size(PyUIGridObject* self, PyObject* value, void* closure) {
tex_height = std::min(tex_height, 4096u);
self->data->renderTexture.create(tex_width, tex_height);
self->data->markDirty(); // #291: size change
return 0;
}
@ -1091,6 +1092,7 @@ int UIGrid::set_center(PyUIGridObject* self, PyObject* value, void* closure) {
}
self->data->center_x = x;
self->data->center_y = y;
self->data->markDirty(); // #291: camera position change
return 0;
}
@ -1186,6 +1188,14 @@ int UIGrid::set_float_member(PyUIGridObject* self, PyObject* value, void* closur
else if (member_ptr == 7) self->view->camera_rotation = val;
self->view->position = self->view->box.getPosition();
}
// #291: Dirty flag propagation for visual property changes
if (member_ptr == 0 || member_ptr == 1) {
self->data->markCompositeDirty(); // position change
} else {
self->data->markDirty(); // content/size change
}
return 0;
}
// TODO (7DRL Day 2, item 5.) return Texture object
@ -1310,6 +1320,7 @@ int UIGrid::set_fill_color(PyUIGridObject* self, PyObject* value, void* closure)
PyColorObject* color = (PyColorObject*)value;
self->data->fill_color = color->data;
self->data->markDirty(); // #291: color change
return 0;
}
@ -1342,6 +1353,7 @@ int UIGrid::set_perspective(PyUIGridObject* self, PyObject* value, void* closure
if (value == Py_None) {
// Clear perspective but keep perspective_enabled unchanged
self->data->perspective_entity.reset();
self->data->markDirty(); // #291: FOV rendering change
return 0;
}
@ -1354,6 +1366,7 @@ int UIGrid::set_perspective(PyUIGridObject* self, PyObject* value, void* closure
PyUIEntityObject* entity_obj = (PyUIEntityObject*)value;
self->data->perspective_entity = entity_obj->data;
self->data->perspective_enabled = true; // Enable perspective when entity assigned
self->data->markDirty(); // #291: FOV rendering change
return 0;
}
@ -1369,6 +1382,7 @@ int UIGrid::set_perspective_enabled(PyUIGridObject* self, PyObject* value, void*
return -1; // Error occurred
}
self->data->perspective_enabled = enabled;
self->data->markDirty(); // #291: FOV rendering toggle
return 0;
}
@ -1401,6 +1415,7 @@ int UIGrid::set_fov(PyUIGridObject* self, PyObject* value, void* closure)
return -1;
}
self->data->fov_algorithm = algo;
self->data->markDirty(); // #291: FOV algorithm change
return 0;
}
@ -1425,6 +1440,7 @@ int UIGrid::set_fov_radius(PyUIGridObject* self, PyObject* value, void* closure)
return -1;
}
self->data->fov_radius = (int)radius;
self->data->markDirty(); // #291: FOV radius change
return 0;
}

View file

@ -167,10 +167,10 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
int y_limit = top_edge + height_sq + 2;
if (y_limit > grid_data->grid_h) y_limit = grid_data->grid_h;
// Render layers below entities (z_index < 0)
// Render layers below entities (z_index <= 0)
grid_data->sortLayers();
for (auto& layer : grid_data->layers) {
if (layer->z_index >= 0) break;
if (layer->z_index > 0) break; // #257: z_index=0 is ground level (below entities)
layer->render(*activeTexture, left_spritepixels, top_spritepixels,
left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height);
}
@ -191,9 +191,9 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
}
}
// Render layers above entities (z_index >= 0)
// Render layers above entities (z_index > 0)
for (auto& layer : grid_data->layers) {
if (layer->z_index < 0) continue;
if (layer->z_index <= 0) continue; // #257: skip ground-level and below
layer->render(*activeTexture, left_spritepixels, top_spritepixels,
left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height);
}

View file

@ -423,6 +423,7 @@ int UILine::set_start(PyUILineObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setStart(vec->data);
self->data->markDirty(); // #291: visual change
return 0;
}
@ -443,6 +444,7 @@ int UILine::set_end(PyUILineObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setEnd(vec->data);
self->data->markDirty(); // #291: visual change
return 0;
}
@ -462,6 +464,7 @@ int UILine::set_color(PyUILineObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setColor(color->data);
self->data->markDirty(); // #291: color change
return 0;
}
@ -486,6 +489,7 @@ int UILine::set_thickness(PyUILineObject* self, PyObject* value, void* closure)
}
self->data->setThickness(thickness);
self->data->markDirty(); // #291: visual change
return 0;
}

View file

@ -285,16 +285,26 @@ int UISprite::set_float_member(PyUISpriteObject* self, PyObject* value, void* cl
PyErr_SetString(PyExc_TypeError, "Value must be a number (int or float)");
return -1;
}
if (member_ptr == 0) //x
if (member_ptr == 0) { //x
self->data->setPosition(sf::Vector2f(val, self->data->getPosition().y));
else if (member_ptr == 1) //y
self->data->markCompositeDirty(); // #291: position change
}
else if (member_ptr == 1) { //y
self->data->setPosition(sf::Vector2f(self->data->getPosition().x, val));
else if (member_ptr == 2) // scale (uniform)
self->data->markCompositeDirty(); // #291: position change
}
else if (member_ptr == 2) { // scale (uniform)
self->data->setScale(sf::Vector2f(val, val));
else if (member_ptr == 3) // scale_x
self->data->markDirty(); // #291: visual change
}
else if (member_ptr == 3) { // scale_x
self->data->setScale(sf::Vector2f(val, self->data->getScale().y));
else if (member_ptr == 4) // scale_y
self->data->markDirty(); // #291: visual change
}
else if (member_ptr == 4) { // scale_y
self->data->setScale(sf::Vector2f(self->data->getScale().x, val));
self->data->markDirty(); // #291: visual change
}
return 0;
}
@ -339,6 +349,7 @@ int UISprite::set_int_member(PyUISpriteObject* self, PyObject* value, void* clos
}
self->data->setSpriteIndex(val);
self->data->markDirty(); // #291: sprite content change
return 0;
}
@ -364,7 +375,8 @@ int UISprite::set_texture(PyUISpriteObject* self, PyObject* value, void* closure
// Update the sprite's texture
self->data->setTexture(pytexture->data);
self->data->markDirty(); // #291: texture change
return 0;
}
@ -387,6 +399,7 @@ int UISprite::set_pos(PyUISpriteObject* self, PyObject* value, void* closure)
return -1;
}
self->data->setPosition(vec->data);
self->data->markCompositeDirty(); // #291: position change
return 0;
}

View file

@ -0,0 +1,239 @@
"""Main menu scene for Crypt of Sokoban.
Displays a title screen with a live demo grid, play button,
and settings buttons. Replaces the 7DRL's MainMenu class
that duplicated entity spawning code from the Crypt class.
"""
import random
import mcrfpy
from cos import Resources
from cos.constants import (
GRID_ZOOM, ENEMY_PRESETS, COLOR_TEXT,
)
from cos.level.generator import Level
from cos.entities.player import PlayerEntity
from cos.entities.enemies import EnemyEntity
from cos.entities.objects import BoulderEntity, ExitEntity
from cos.ui.widgets import SweetButton
class MenuScene:
"""Main menu with animated demo and navigation buttons.
The demo grid shows a small generated level with entities
that wander randomly, giving the menu visual interest.
"""
def __init__(self):
self.scene = mcrfpy.Scene("menu")
self.ui = self.scene.children
self.play_scene = None
res = Resources()
# -- Demo grid (background) ---------------------------------------
self.entities = [] # entity registry for demo animation
self._demo_level = Level(20, 20)
self.grid = self._demo_level.grid
self.grid.zoom = 1.75
gw = int(self.grid.grid_size.x)
gh = int(self.grid.grid_size.y)
self.grid.center_camera((gw / 2.0, gh / 2.0))
demo_plan = [
("boulder", "boulder", "rat", "cyclops", "boulder"),
("spawn",),
("rat", "big rat"),
("button", "boulder", "exit"),
]
coords = self._demo_level.generate(demo_plan)
self._spawn_demo_entities(coords)
# Wire up engine entities for rendering
for entity in self.entities:
entity.entity.grid = self.grid
self.demo_timer = mcrfpy.Timer("demo_motion", self._demo_tick, 100)
# -- Title text ---------------------------------------------------
drop_shadow = mcrfpy.Caption(
text="Crypt Of Sokoban", pos=(150, 10), font=res.font,
fill_color=(96, 96, 96), outline_color=(192, 0, 0),
)
drop_shadow.outline = 3
drop_shadow.font_size = 64
title = mcrfpy.Caption(
text="Crypt Of Sokoban", pos=(158, 18), font=res.font,
fill_color=COLOR_TEXT,
)
title.font_size = 64
# -- Toast notification -------------------------------------------
self.toast = mcrfpy.Caption(
text="", pos=(150, 400), font=res.font,
fill_color=(0, 0, 0),
)
self.toast.font_size = 28
self.toast.outline = 2
self.toast.outline_color = (255, 255, 255)
self._toast_remaining = None
self._toast_timer = None
# -- Buttons ------------------------------------------------------
play_btn = SweetButton(
(20, 248), "PLAY",
box_width=200, box_height=110,
icon=1, icon_scale=2.0, click=self._on_play,
)
config_btn = SweetButton(
(10, 678), "Settings", icon=2, click=self._on_config,
)
scale_btn = SweetButton(
(266, 678), "Scale up\nto 1080p", icon=15, click=self._on_scale,
)
self._scaled = False
music_btn = SweetButton(
(522, 678), "Music\nON", icon=12, click=self._on_music_toggle,
)
sfx_btn = SweetButton(
(778, 678), "SFX\nON", icon=0, click=self._on_sfx_toggle,
)
# -- Assemble scene -----------------------------------------------
for element in (
self.grid, drop_shadow, title, self.toast,
play_btn.base_frame, config_btn.base_frame,
scale_btn.base_frame, music_btn.base_frame,
sfx_btn.base_frame,
):
self.ui.append(element)
# -- Entity registry (minimal, for demo only) -------------------------
def register_entity(self, entity):
self.entities.append(entity)
def unregister_entity(self, entity):
if entity in self.entities:
self.entities.remove(entity)
# -- Demo animation ---------------------------------------------------
def _spawn_demo_entities(self, coords):
"""Spawn entities for the demo grid. Reuses the same entity
creation as PlayScene to avoid the 7DRL's duplicated spawning."""
buttons = []
for name, pos in sorted(coords, key=lambda c: c[0]):
if name == "spawn":
self.player = PlayerEntity(game=self)
self.player.draw_pos = pos
elif name == "boulder":
BoulderEntity(pos[0], pos[1], game=self)
elif name == "button":
buttons.append(pos)
elif name == "exit":
btn = buttons.pop(0)
ExitEntity(pos[0], pos[1], btn[0], btn[1], game=self)
elif name in ENEMY_PRESETS:
EnemyEntity.from_preset(name, pos[0], pos[1], game=self)
def _demo_tick(self, timer, runtime):
"""Timer callback: animate demo entities randomly."""
try:
dirs = ((1, 0), (-1, 0), (0, 1), (0, -1))
self.player.try_move(*random.choice(dirs))
for entity in self.entities:
entity.act()
except Exception:
pass # demo animation is cosmetic; don't crash the menu
# -- Navigation -------------------------------------------------------
def activate(self):
self.scene.activate()
def _on_play(self, btn, args):
if args[3] == mcrfpy.InputState.RELEASED:
return
self.demo_timer.stop()
from cos.scenes.play import PlayScene
self.play_scene = PlayScene()
self.play_scene.activate()
# -- Settings buttons -------------------------------------------------
def _on_config(self, btn, args):
if args[3] == mcrfpy.InputState.RELEASED:
return
self._show_toast("Settings will go here.")
def _on_scale(self, btn, args):
if args[3] == mcrfpy.InputState.RELEASED:
return
self._scaled = not self._scaled
btn.unpress()
if self._scaled:
self._show_toast("Windowed mode only.\nCheck Settings for fine-tuned controls.")
mcrfpy.set_scale(1.3)
btn.text = "Scale down\nto 1.0x"
else:
mcrfpy.set_scale(1.0)
btn.text = "Scale up\nto 1080p"
def _on_music_toggle(self, btn, args):
if args[3] == mcrfpy.InputState.RELEASED:
return
res = Resources()
res.music_enabled = not res.music_enabled
if res.music_enabled:
res.set_music_volume(res.music_volume)
btn.text = "Music\nON"
btn.sprite_number = 12
else:
self._show_toast("Use your volume keys or\nlook in Settings for a volume meter.")
res.set_music_volume(0)
btn.text = "Music\nOFF"
btn.sprite_number = 17
def _on_sfx_toggle(self, btn, args):
if args[3] == mcrfpy.InputState.RELEASED:
return
res = Resources()
res.sfx_enabled = not res.sfx_enabled
if res.sfx_enabled:
res.set_sfx_volume(res.sfx_volume)
btn.text = "SFX\nON"
btn.sprite_number = 0
else:
self._show_toast("Use your volume keys or\nlook in Settings for a volume meter.")
res.set_sfx_volume(0)
btn.text = "SFX\nOFF"
btn.sprite_number = 17
# -- Toast notification -----------------------------------------------
def _show_toast(self, text):
self.toast.text = text
self._toast_remaining = 350
self.toast.fill_color = (255, 255, 255, 255)
self.toast.outline_color = (0, 0, 0, 255)
if self._toast_timer:
self._toast_timer.stop()
self._toast_timer = mcrfpy.Timer("toast_timer", self._toast_tick, 100)
def _toast_tick(self, timer, runtime):
self._toast_remaining -= 5
if self._toast_remaining < 0:
self._toast_timer.stop()
self._toast_timer = None
self.toast.text = ""
return
alpha = min(self._toast_remaining, 255)
self.toast.fill_color = (255, 255, 255, alpha)
self.toast.outline_color = (0, 0, 0, alpha)

View file

@ -729,10 +729,10 @@ class MainMenu:
sweet_btn.unpress()
if self.scaled:
self.toast_say("Windowed mode only, sorry!\nCheck Settings for for fine-tuned controls.")
mcrfpy.setScale(window_scale)
mcrfpy.set_scale(window_scale)
sweet_btn.text = "Scale down\n to 1.0x"
else:
mcrfpy.setScale(1.0)
mcrfpy.set_scale(1.0)
sweet_btn.text = "Scale up\nto 1080p"
def music_toggle(self, sweet_btn, args):

803
src/scripts_demo/game.py Normal file
View file

@ -0,0 +1,803 @@
"""McRogueFace Web Demo - A roguelike dungeon crawler showcasing engine features.
Features demonstrated:
- BSP dungeon generation with corridors
- Wang tile autotiling for pretty dungeons
- Entity system with player and enemies
- Field of view with fog of war
- Turn-based bump combat
- UI overlays (health bar, messages, depth counter)
- Animations and timers
"""
import mcrfpy
import random
# -- Assets ------------------------------------------------------------------
texture = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16)
font = mcrfpy.Font("assets/JetbrainsMono.ttf")
# Try to load Wang tileset for pretty autotiling
try:
_tileset = mcrfpy.TileSetFile("assets/kenney_TD_MR_IP.tsx")
_wang_set = _tileset.wang_set("dungeon")
_Terrain = _wang_set.terrain_enum()
HAS_WANG = True
except Exception:
HAS_WANG = False
# -- Sprite indices from kenney_TD_MR_IP (12 cols x 55 rows, 16x16) ----------
# Rows are 0-indexed; sprite_index = row * 12 + col
FLOOR_TILE = 145 # open stone floor
WALL_TILE = 251 # solid wall
PLAYER_SPRITE = 84 # hero character
RAT_SPRITE = 123 # small rat enemy
CYCLOPS_SPRITE = 109 # big enemy
SKELETON_SPRITE = 110 # skeleton enemy
HEART_FULL = 210
HEART_HALF = 209
HEART_EMPTY = 208
POTION_SPRITE = 115 # red potion
STAIRS_SPRITE = 91 # stairs down
SKULL_SPRITE = 135 # death indicator
TREASURE_SPRITE = 127 # treasure chest
# -- Configuration ------------------------------------------------------------
MAP_W, MAP_H = 40, 30
ZOOM = 2.0
GRID_PX_W = 1024
GRID_PX_H = 700
FOV_RADIUS = 10
MAX_HP = 10
ENEMY_SIGHT = 8
# =============================================================================
# Wang tile autotiling
# =============================================================================
def paint_tiles(grid, w, h):
"""Apply Wang tile autotiling or fallback to simple tiles."""
if HAS_WANG:
dm = mcrfpy.DiscreteMap((w, h))
for y in range(h):
for x in range(w):
if grid.at((x, y)).walkable:
dm.set(x, y, int(_Terrain.GROUND))
else:
dm.set(x, y, int(_Terrain.WALL))
tiles = _wang_set.resolve(dm)
for y in range(h):
for x in range(w):
tid = tiles[y * w + x]
if tid >= 0:
grid.at((x, y)).tilesprite = tid
else:
grid.at((x, y)).tilesprite = (
FLOOR_TILE if grid.at((x, y)).walkable else WALL_TILE
)
else:
for y in range(h):
for x in range(w):
grid.at((x, y)).tilesprite = (
FLOOR_TILE if grid.at((x, y)).walkable else WALL_TILE
)
# =============================================================================
# Dungeon generator (BSP)
# =============================================================================
class Dungeon:
def __init__(self, grid, w, h):
self.grid = grid
self.w = w
self.h = h
self.rooms = [] # list of (cx, cy) room centers
self.walkable = set() # walkable cell coords
def generate(self):
# Reset all cells to walls
for x in range(self.w):
for y in range(self.h):
self.grid.at((x, y)).walkable = False
self.grid.at((x, y)).transparent = False
# BSP split
bsp = mcrfpy.BSP(pos=(1, 1), size=(self.w - 2, self.h - 2))
bsp.split_recursive(depth=5, min_size=(4, 4), max_ratio=1.5)
leaves = list(bsp.leaves())
# Carve rooms (1-cell margin from leaf edges)
for leaf in leaves:
lx, ly = int(leaf.pos[0]), int(leaf.pos[1])
lw, lh = int(leaf.size[0]), int(leaf.size[1])
cx = lx + lw // 2
cy = ly + lh // 2
self.rooms.append((cx, cy))
for rx in range(lx + 1, lx + lw - 1):
for ry in range(ly + 1, ly + lh - 1):
if 0 <= rx < self.w and 0 <= ry < self.h:
self.grid.at((rx, ry)).walkable = True
self.grid.at((rx, ry)).transparent = True
self.walkable.add((rx, ry))
# Carve corridors using BSP adjacency
adj = bsp.adjacency
connected = set()
for i in range(len(adj)):
for j in adj[i]:
edge = (min(i, j), max(i, j))
if edge in connected:
continue
connected.add(edge)
self._dig_corridor(self.rooms[i], self.rooms[j])
# Apply tile graphics
paint_tiles(self.grid, self.w, self.h)
def _dig_corridor(self, start, end):
x1, x2 = min(start[0], end[0]), max(start[0], end[0])
y1, y2 = min(start[1], end[1]), max(start[1], end[1])
# L-shaped corridor
if random.random() < 0.5:
tx, ty = x1, y2
else:
tx, ty = x2, y1
for x in range(x1, x2 + 1):
if 0 <= x < self.w and 0 <= ty < self.h:
self.grid.at((x, ty)).walkable = True
self.grid.at((x, ty)).transparent = True
self.walkable.add((x, ty))
for y in range(y1, y2 + 1):
if 0 <= tx < self.w and 0 <= y < self.h:
self.grid.at((tx, y)).walkable = True
self.grid.at((tx, y)).transparent = True
self.walkable.add((tx, y))
def random_floor(self, exclude=None):
"""Find a random walkable cell not in the exclude set."""
exclude = exclude or set()
candidates = list(self.walkable - exclude)
if not candidates:
return None
return random.choice(candidates)
# =============================================================================
# Game state
# =============================================================================
class Game:
def __init__(self):
self.scene = mcrfpy.Scene("game")
self.ui = self.scene.children
self.depth = 1
self.player_hp = MAX_HP
self.player_max_hp = MAX_HP
self.player_atk = 2
self.player_def = 0
self.score = 0
self.game_over = False
self.enemies = [] # list of dicts: {entity, hp, atk, def, sprite, name}
self.items = [] # list of dicts: {entity, kind}
self.dungeon = None
self.fog_layer = None
self.grid = None
self.player = None
self.message_timer = None
self.occupied = set() # cells occupied by entities
self._build_ui()
self._new_level()
self.scene.on_key = self.on_key
self.scene.activate()
# -- UI -------------------------------------------------------------------
def _build_ui(self):
# Main grid
self.grid = mcrfpy.Grid(
grid_size=(MAP_W, MAP_H),
texture=texture,
pos=(0, 0),
size=(GRID_PX_W, GRID_PX_H),
)
self.grid.zoom = ZOOM
self.grid.center = (MAP_W / 2.0 * 16, MAP_H / 2.0 * 16)
self.ui.append(self.grid)
# HUD bar at bottom
self.hud = mcrfpy.Frame(
pos=(0, GRID_PX_H), size=(1024, 68),
fill_color=(20, 16, 28, 240)
)
self.ui.append(self.hud)
# Health display
self.health_label = mcrfpy.Caption(
text="HP: 10/10", pos=(12, 6), font=font,
fill_color=(220, 50, 50)
)
self.health_label.font_size = 20
self.health_label.outline = 2
self.health_label.outline_color = (0, 0, 0)
self.hud.children.append(self.health_label)
# Heart sprites
self.hearts = []
for i in range(5):
h = mcrfpy.Sprite(
x=12 + i * 36, y=32,
texture=texture, sprite_index=HEART_FULL, scale=2.0
)
self.hearts.append(h)
self.hud.children.append(h)
# Depth label
self.depth_label = mcrfpy.Caption(
text="Depth: 1", pos=(220, 6), font=font,
fill_color=(180, 180, 220)
)
self.depth_label.font_size = 20
self.depth_label.outline = 2
self.depth_label.outline_color = (0, 0, 0)
self.hud.children.append(self.depth_label)
# Score label
self.score_label = mcrfpy.Caption(
text="Score: 0", pos=(220, 32), font=font,
fill_color=(220, 200, 80)
)
self.score_label.font_size = 18
self.score_label.outline = 2
self.score_label.outline_color = (0, 0, 0)
self.hud.children.append(self.score_label)
# Message area
self.msg_label = mcrfpy.Caption(
text="Arrow keys to move. Bump enemies to attack.",
pos=(450, 6), font=font,
fill_color=(160, 200, 160)
)
self.msg_label.font_size = 16
self.msg_label.outline = 1
self.msg_label.outline_color = (0, 0, 0)
self.hud.children.append(self.msg_label)
self.msg_label2 = mcrfpy.Caption(
text="Find the stairs to descend deeper!",
pos=(450, 30), font=font,
fill_color=(140, 160, 180)
)
self.msg_label2.font_size = 14
self.msg_label2.outline = 1
self.msg_label2.outline_color = (0, 0, 0)
self.hud.children.append(self.msg_label2)
# -- Level generation -----------------------------------------------------
def _new_level(self):
# Clear old entities
while len(self.grid.entities) > 0:
self.grid.entities.pop(0)
self.enemies.clear()
self.items.clear()
self.occupied.clear()
# Generate dungeon
self.dungeon = Dungeon(self.grid, MAP_W, MAP_H)
self.dungeon.generate()
# Place player in first room
px, py = self.dungeon.rooms[0]
if self.player is None:
self.player = mcrfpy.Entity(
grid_pos=(px, py), texture=texture,
sprite_index=PLAYER_SPRITE
)
else:
self.player.grid_pos = (px, py)
self.grid.entities.append(self.player)
self.occupied.add((px, py))
# Place stairs in last room
sx, sy = self.dungeon.rooms[-1]
stairs = mcrfpy.Entity(
grid_pos=(sx, sy), texture=texture,
sprite_index=STAIRS_SPRITE
)
self.grid.entities.append(stairs)
self.stairs_pos = (sx, sy)
# Place enemies (more enemies on deeper levels)
num_enemies = min(3 + self.depth * 2, 15)
enemy_types = self._enemy_table()
for _ in range(num_enemies):
pos = self.dungeon.random_floor(exclude=self.occupied)
if pos is None:
break
etype = random.choice(enemy_types)
e = mcrfpy.Entity(
grid_pos=pos, texture=texture,
sprite_index=etype["sprite"]
)
self.grid.entities.append(e)
self.enemies.append({
"entity": e,
"hp": etype["hp"],
"max_hp": etype["hp"],
"atk": etype["atk"],
"def": etype["def"],
"name": etype["name"],
"sprite": etype["sprite"],
})
self.occupied.add(pos)
# Place health potions
num_potions = random.randint(1, 3)
for _ in range(num_potions):
pos = self.dungeon.random_floor(exclude=self.occupied)
if pos is None:
break
item = mcrfpy.Entity(
grid_pos=pos, texture=texture,
sprite_index=POTION_SPRITE
)
self.grid.entities.append(item)
self.items.append({"entity": item, "kind": "potion", "pos": pos})
self.occupied.add(pos)
# Place treasure
num_treasure = random.randint(1, 2 + self.depth)
for _ in range(num_treasure):
pos = self.dungeon.random_floor(exclude=self.occupied)
if pos is None:
break
item = mcrfpy.Entity(
grid_pos=pos, texture=texture,
sprite_index=TREASURE_SPRITE
)
self.grid.entities.append(item)
self.items.append({"entity": item, "kind": "treasure", "pos": pos})
self.occupied.add(pos)
# Set up fog of war
self.fog_layer = mcrfpy.ColorLayer(name="fog", z_index=10)
self.grid.add_layer(self.fog_layer)
self.fog_layer.fill(mcrfpy.Color(0, 0, 0, 255))
self.discovered = set()
# Center camera on player
self._center_camera()
self._update_fov()
self._update_hud()
# Depth label
self.depth_label.text = f"Depth: {self.depth}"
def _enemy_table(self):
"""Return available enemy types scaled by depth."""
table = [
{"name": "Rat", "sprite": RAT_SPRITE,
"hp": 2 + self.depth // 3, "atk": 1, "def": 0},
]
if self.depth >= 2:
table.append(
{"name": "Skeleton", "sprite": SKELETON_SPRITE,
"hp": 3 + self.depth // 2, "atk": 2, "def": 1}
)
if self.depth >= 4:
table.append(
{"name": "Cyclops", "sprite": CYCLOPS_SPRITE,
"hp": 6 + self.depth, "atk": 3, "def": 2}
)
return table
# -- Camera ---------------------------------------------------------------
def _center_camera(self):
px = int(self.player.grid_pos.x)
py = int(self.player.grid_pos.y)
self.grid.center = (px * 16 + 8, py * 16 + 8)
# -- FOV ------------------------------------------------------------------
def _update_fov(self):
if self.fog_layer is None:
return
px = int(self.player.grid_pos.x)
py = int(self.player.grid_pos.y)
self.grid.compute_fov((px, py), radius=FOV_RADIUS)
for x in range(MAP_W):
for y in range(MAP_H):
if self.grid.is_in_fov((x, y)):
self.discovered.add((x, y))
self.fog_layer.set((x, y), mcrfpy.Color(0, 0, 0, 0))
elif (x, y) in self.discovered:
self.fog_layer.set((x, y), mcrfpy.Color(0, 0, 0, 140))
# else: stays at 255 alpha (fully hidden)
# -- HUD ------------------------------------------------------------------
def _update_hud(self):
self.health_label.text = f"HP: {self.player_hp}/{self.player_max_hp}"
self.score_label.text = f"Score: {self.score}"
# Update heart sprites
for i, h in enumerate(self.hearts):
full = self.player_hp - i * 2
cap = self.player_max_hp - i * 2
if cap < 1:
h.sprite_index = 659 # invisible/blank
elif full >= 2:
h.sprite_index = HEART_FULL
elif full == 1:
h.sprite_index = HEART_HALF
else:
h.sprite_index = HEART_EMPTY
def _show_message(self, line1, line2=""):
self.msg_label.text = line1
self.msg_label2.text = line2
# -- Combat ---------------------------------------------------------------
def _attack_enemy(self, enemy_data):
dmg = max(1, self.player_atk - enemy_data["def"])
enemy_data["hp"] -= dmg
name = enemy_data["name"]
if enemy_data["hp"] <= 0:
self._show_message(
f"You slay the {name}! (+{10 * self.depth} pts)",
f"Hit for {dmg} damage - lethal!"
)
self.score += 10 * self.depth
# Remove from grid
for i in range(len(self.grid.entities)):
if self.grid.entities[i] is enemy_data["entity"]:
self.grid.entities.pop(i)
break
ex = int(enemy_data["entity"].grid_pos.x)
ey = int(enemy_data["entity"].grid_pos.y)
self.occupied.discard((ex, ey))
self.enemies.remove(enemy_data)
else:
self._show_message(
f"You hit the {name} for {dmg}!",
f"{name} HP: {enemy_data['hp']}/{enemy_data['max_hp']}"
)
def _enemy_attacks_player(self, enemy_data):
dmg = max(1, enemy_data["atk"] - self.player_def)
self.player_hp -= dmg
name = enemy_data["name"]
if self.player_hp <= 0:
self.player_hp = 0
self._update_hud()
self._game_over()
return
self._show_message(
f"The {name} hits you for {dmg}!",
f"HP: {self.player_hp}/{self.player_max_hp}"
)
def _game_over(self):
self.game_over = True
# Darken screen
overlay = mcrfpy.Frame(
pos=(0, 0), size=(1024, 768),
fill_color=(0, 0, 0, 180)
)
self.ui.append(overlay)
title = mcrfpy.Caption(
text="YOU DIED", pos=(340, 250), font=font,
fill_color=(200, 30, 30)
)
title.font_size = 60
title.outline = 4
title.outline_color = (0, 0, 0)
overlay.children.append(title)
info = mcrfpy.Caption(
text=f"Reached depth {self.depth} with {self.score} points",
pos=(280, 340), font=font,
fill_color=(200, 200, 200)
)
info.font_size = 22
info.outline = 2
info.outline_color = (0, 0, 0)
overlay.children.append(info)
restart = mcrfpy.Caption(
text="Press R to restart",
pos=(370, 400), font=font,
fill_color=(160, 200, 160)
)
restart.font_size = 20
restart.outline = 2
restart.outline_color = (0, 0, 0)
overlay.children.append(restart)
self.game_over_overlay = overlay
def _restart(self):
self.game_over = False
self.player_hp = MAX_HP
self.player_max_hp = MAX_HP
self.player_atk = 2
self.player_def = 0
self.depth = 1
self.score = 0
self.player = None
# Remove overlay
if hasattr(self, 'game_over_overlay'):
# Rebuild UI from scratch
while len(self.ui) > 0:
self.ui.pop(0)
self._build_ui()
self._new_level()
self.scene.on_key = self.on_key
# -- Items ----------------------------------------------------------------
def _check_items(self, px, py):
for item in self.items[:]:
ix = int(item["entity"].grid_pos.x)
iy = int(item["entity"].grid_pos.y)
if ix == px and iy == py:
if item["kind"] == "potion":
heal = random.randint(2, 4)
self.player_hp = min(self.player_max_hp, self.player_hp + heal)
self._show_message(
f"Healed {heal} HP!",
f"HP: {self.player_hp}/{self.player_max_hp}"
)
elif item["kind"] == "treasure":
pts = random.randint(5, 15) * self.depth
self.score += pts
self._show_message(
f"Found treasure! (+{pts} pts)",
f"Total score: {self.score}"
)
# Remove item entity
for i in range(len(self.grid.entities)):
if self.grid.entities[i] is item["entity"]:
self.grid.entities.pop(i)
break
self.occupied.discard((ix, iy))
self.items.remove(item)
# -- Enemy AI (simple: move toward player if visible) ---------------------
def _enemy_turn(self):
px = int(self.player.grid_pos.x)
py = int(self.player.grid_pos.y)
for edata in self.enemies[:]:
ex = int(edata["entity"].grid_pos.x)
ey = int(edata["entity"].grid_pos.y)
# Only act if in FOV (player can see them)
if not self.grid.is_in_fov((ex, ey)):
continue
# Manhattan distance
dist = abs(ex - px) + abs(ey - py)
if dist > ENEMY_SIGHT:
continue
# Adjacent? Attack!
if dist == 1:
self._enemy_attacks_player(edata)
if self.game_over:
return
continue
# Move toward player (simple greedy)
dx = 0
dy = 0
if abs(ex - px) > abs(ey - py):
dx = 1 if px > ex else -1
else:
dy = 1 if py > ey else -1
nx, ny = ex + dx, ey + dy
# Check bounds and walkability
if (0 <= nx < MAP_W and 0 <= ny < MAP_H
and self.grid.at((nx, ny)).walkable
and (nx, ny) not in self.occupied):
self.occupied.discard((ex, ey))
edata["entity"].grid_pos = (nx, ny)
self.occupied.add((nx, ny))
# -- Input ----------------------------------------------------------------
def on_key(self, key, state):
if state != mcrfpy.InputState.PRESSED:
return
if self.game_over:
if key == mcrfpy.Key.R:
self._restart()
return
dx, dy = 0, 0
if key == mcrfpy.Key.UP or key == mcrfpy.Key.W:
dy = -1
elif key == mcrfpy.Key.DOWN or key == mcrfpy.Key.S:
dy = 1
elif key == mcrfpy.Key.LEFT or key == mcrfpy.Key.A:
dx = -1
elif key == mcrfpy.Key.RIGHT or key == mcrfpy.Key.D:
dx = 1
elif key == mcrfpy.Key.PERIOD:
# Wait a turn
self._enemy_turn()
self._update_fov()
self._update_hud()
return
else:
return
if dx == 0 and dy == 0:
return
px = int(self.player.grid_pos.x)
py = int(self.player.grid_pos.y)
nx, ny = px + dx, py + dy
# Bounds check
if nx < 0 or nx >= MAP_W or ny < 0 or ny >= MAP_H:
return
# Check for enemy at target
for edata in self.enemies:
ex = int(edata["entity"].grid_pos.x)
ey = int(edata["entity"].grid_pos.y)
if ex == nx and ey == ny:
self._attack_enemy(edata)
self._enemy_turn()
self._update_fov()
self._center_camera()
self._update_hud()
return
# Check walkability
if not self.grid.at((nx, ny)).walkable:
return
# Move player
self.occupied.discard((px, py))
self.player.grid_pos = (nx, ny)
self.occupied.add((nx, ny))
# Check stairs
if (nx, ny) == self.stairs_pos:
self.depth += 1
self._show_message(
f"Descending to depth {self.depth}...",
"The dungeon grows more dangerous."
)
self._new_level()
self._enemy_turn()
self._update_fov()
self._center_camera()
self._update_hud()
return
# Check items
self._check_items(nx, ny)
# Enemy turn
self._enemy_turn()
if not self.game_over:
self._update_fov()
self._center_camera()
self._update_hud()
# =============================================================================
# Title screen
# =============================================================================
class TitleScreen:
def __init__(self):
self.scene = mcrfpy.Scene("title")
ui = self.scene.children
# Dark background
bg = mcrfpy.Frame(
pos=(0, 0), size=(1024, 768),
fill_color=(12, 10, 20)
)
ui.append(bg)
# Title
title = mcrfpy.Caption(
text="McRogueFace", pos=(240, 140), font=font,
fill_color=(220, 60, 60)
)
title.font_size = 72
title.outline = 4
title.outline_color = (0, 0, 0)
bg.children.append(title)
# Subtitle
sub = mcrfpy.Caption(
text="A Python-Powered Roguelike Engine",
pos=(270, 240), font=font,
fill_color=(160, 160, 200)
)
sub.font_size = 22
sub.outline = 2
sub.outline_color = (0, 0, 0)
bg.children.append(sub)
# Features list
features = [
"BSP dungeon generation",
"Wang tile autotiling",
"Field of view & fog of war",
"Turn-based combat",
"Entity system with Python scripting",
]
for i, feat in enumerate(features):
dot = mcrfpy.Caption(
text=f" {feat}",
pos=(320, 320 + i * 32), font=font,
fill_color=(140, 180, 140)
)
dot.font_size = 16
dot.outline = 1
dot.outline_color = (0, 0, 0)
bg.children.append(dot)
# Start prompt
start = mcrfpy.Caption(
text="Press ENTER or SPACE to begin",
pos=(310, 540), font=font,
fill_color=(200, 200, 100)
)
start.font_size = 20
start.outline = 2
start.outline_color = (0, 0, 0)
bg.children.append(start)
# Animate the start prompt
self._blink_visible = True
self._start_caption = start
# Controls hint
controls = mcrfpy.Caption(
text="Controls: Arrow keys / WASD to move, . to wait, R to restart",
pos=(200, 600), font=font,
fill_color=(100, 100, 130)
)
controls.font_size = 14
controls.outline = 1
controls.outline_color = (0, 0, 0)
bg.children.append(controls)
# Version info
ver = mcrfpy.Caption(
text="Built with McRogueFace - C++ engine, Python gameplay",
pos=(250, 700), font=font,
fill_color=(60, 60, 80)
)
ver.font_size = 12
bg.children.append(ver)
self.scene.on_key = self.on_key
self.scene.activate()
# Blink timer for "Press ENTER"
self.blink_timer = mcrfpy.Timer("blink", self._blink, 600)
def _blink(self, timer, runtime):
self._blink_visible = not self._blink_visible
if self._blink_visible:
self._start_caption.fill_color = mcrfpy.Color(200, 200, 100)
else:
self._start_caption.fill_color = mcrfpy.Color(200, 200, 100, 60)
def on_key(self, key, state):
if state != mcrfpy.InputState.PRESSED:
return
if key in (mcrfpy.Key.ENTER, mcrfpy.Key.SPACE):
self.blink_timer.stop()
Game()
# =============================================================================
# Entry point
# =============================================================================
title = TitleScreen()

View file

@ -1121,22 +1121,10 @@ 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."""
...
def step(dt: float) -> None:
"""Advance the game loop by dt seconds (headless mode only)."""
...

View file

@ -1298,22 +1298,10 @@ 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."""
...
def step(dt: float) -> None:
"""Advance the game loop by dt seconds (headless mode only)."""
...

View file

@ -42,7 +42,7 @@ def collect_metrics(timer, runtime):
return
# Collect sample
m = mcrfpy.getMetrics()
m = mcrfpy.get_metrics()
metrics_samples.append({
'frame_time': m['frame_time'],
'avg_frame_time': m['avg_frame_time'],

View file

@ -0,0 +1,275 @@
"""Test render cache dirty flag propagation for issues #288-#291.
#288: UICollection mutations don't invalidate parent Frame's render cache
#289: Caption Python property setters don't call markDirty()
#290: UIDrawable base x/y/pos setters don't propagate dirty flags to parent
#291: Audit all Python property setters for missing markDirty() calls
These tests exercise all property setters that were missing dirty flag calls,
inside a clip_children=True Frame (which uses render caching). The test verifies
that no crashes occur and properties are correctly set after modification.
Visual correctness requires a non-headless render test.
"""
import mcrfpy
import sys
test_pass = True
test_count = 0
fail_count = 0
def check(condition, msg):
global test_pass, test_count, fail_count
test_count += 1
if not condition:
print(f" FAIL: {msg}")
test_pass = False
fail_count += 1
# Create a scene with a clipped parent frame (uses render caching)
scene = mcrfpy.Scene("test_dirty_flags")
mcrfpy.current_scene = scene
parent = mcrfpy.Frame(pos=(10, 10), size=(800, 600),
fill_color=mcrfpy.Color(40, 40, 40),
clip_children=True)
scene.children.append(parent)
# ============================================================
# Test #290: UIDrawable base x/y/pos setters (all drawable types)
# ============================================================
print("Testing #290: UIDrawable position setters...")
frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100),
fill_color=mcrfpy.Color(255, 0, 0))
parent.children.append(frame)
# Test x setter
frame.x = 50.0
check(frame.x == 50.0, "frame.x setter")
# Test y setter
frame.y = 60.0
check(frame.y == 60.0, "frame.y setter")
# Test pos setter (tuple)
frame.pos = (70.0, 80.0)
check(frame.x == 70.0 and frame.y == 80.0, "frame.pos setter (tuple)")
# Test w/h setters
frame.w = 200.0
check(frame.w == 200.0, "frame.w setter")
frame.h = 150.0
check(frame.h == 150.0, "frame.h setter")
# ============================================================
# Test #289: Caption property setters
# ============================================================
print("Testing #289: Caption property setters...")
cap = mcrfpy.Caption(text="Hello", pos=(100, 100))
parent.children.append(cap)
# Text setter
cap.text = "World"
check(cap.text == "World", "caption.text setter")
# Fill color setter
cap.fill_color = mcrfpy.Color(255, 0, 0)
c = cap.fill_color
check(c.r == 255 and c.g == 0 and c.b == 0, "caption.fill_color setter")
# Outline color setter
cap.outline_color = mcrfpy.Color(0, 255, 0)
c = cap.outline_color
check(c.r == 0 and c.g == 255 and c.b == 0, "caption.outline_color setter")
# Outline thickness setter
cap.outline = 2.0
check(cap.outline == 2.0, "caption.outline setter")
# Font size setter
cap.font_size = 24
check(cap.font_size == 24, "caption.font_size setter")
# ============================================================
# Test #288: UICollection mutations
# ============================================================
print("Testing #288: UICollection mutations...")
# append (already tested above, but test with clip_children parent)
child1 = mcrfpy.Frame(pos=(0, 0), size=(20, 20),
fill_color=mcrfpy.Color(0, 0, 255))
initial_count = len(parent.children)
parent.children.append(child1)
check(len(parent.children) == initial_count + 1, "collection append")
# insert
child2 = mcrfpy.Frame(pos=(30, 0), size=(20, 20),
fill_color=mcrfpy.Color(0, 255, 0))
parent.children.insert(0, child2)
check(len(parent.children) == initial_count + 2, "collection insert")
# setitem (replace)
child3 = mcrfpy.Frame(pos=(60, 0), size=(20, 20),
fill_color=mcrfpy.Color(255, 255, 0))
parent.children[0] = child3
check(len(parent.children) == initial_count + 2, "collection setitem")
# remove
parent.children.remove(child1)
check(len(parent.children) == initial_count + 1, "collection remove")
# pop
popped = parent.children.pop()
check(len(parent.children) == initial_count, "collection pop")
# extend
extras = [
mcrfpy.Frame(pos=(0, 200), size=(20, 20), fill_color=mcrfpy.Color(128, 128, 128)),
mcrfpy.Frame(pos=(30, 200), size=(20, 20), fill_color=mcrfpy.Color(64, 64, 64))
]
parent.children.extend(extras)
check(len(parent.children) == initial_count + 2, "collection extend")
# slice deletion
del parent.children[initial_count:]
check(len(parent.children) == initial_count, "collection slice delete")
# ============================================================
# Test #291: UISprite property setters
# ============================================================
print("Testing #291: UISprite property setters...")
# Need a texture for sprite tests - use a test texture if available
try:
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
sprite = mcrfpy.Sprite(pos=(200, 200), texture=tex, sprite_index=0)
parent.children.append(sprite)
sprite.scale = 2.0
check(sprite.scale == 2.0, "sprite.scale setter")
sprite.sprite_index = 1
check(sprite.sprite_index == 1, "sprite.sprite_index setter")
# Texture setter
sprite.texture = tex
check(True, "sprite.texture setter (no crash)")
# Pos setter
sprite.pos = (210, 210)
check(True, "sprite.pos setter (no crash)")
except Exception as e:
print(f" (Sprite tests skipped - no test texture: {e})")
# ============================================================
# Test #291: UICircle property setters
# ============================================================
print("Testing #291: UICircle property setters...")
circle = mcrfpy.Circle(radius=25.0, center=(300, 300),
fill_color=mcrfpy.Color(255, 128, 0))
parent.children.append(circle)
circle.radius = 30.0
check(circle.radius == 30.0, "circle.radius setter")
circle.fill_color = mcrfpy.Color(0, 128, 255)
c = circle.fill_color
check(c.r == 0 and c.g == 128 and c.b == 255, "circle.fill_color setter")
circle.outline_color = mcrfpy.Color(255, 255, 255)
c = circle.outline_color
check(c.r == 255, "circle.outline_color setter")
circle.outline = 3.0
check(circle.outline == 3.0, "circle.outline setter")
# ============================================================
# Test #291: UILine property setters
# ============================================================
print("Testing #291: UILine property setters...")
line = mcrfpy.Line(start=(10, 400), end=(200, 400),
thickness=2.0, color=mcrfpy.Color(255, 0, 255))
parent.children.append(line)
line.start = (20, 410)
check(True, "line.start setter (no crash)")
line.end = (210, 410)
check(True, "line.end setter (no crash)")
line.color = mcrfpy.Color(0, 255, 255)
c = line.color
check(c.r == 0 and c.g == 255 and c.b == 255, "line.color setter")
line.thickness = 4.0
check(line.thickness == 4.0, "line.thickness setter")
# ============================================================
# Test #291: UIArc property setters
# ============================================================
print("Testing #291: UIArc property setters...")
arc = mcrfpy.Arc(center=(400, 300), radius=40.0, start_angle=0.0,
end_angle=180.0, color=mcrfpy.Color(128, 0, 255),
thickness=3.0)
parent.children.append(arc)
arc.radius = 50.0
check(arc.radius == 50.0, "arc.radius setter")
arc.start_angle = 45.0
check(arc.start_angle == 45.0, "arc.start_angle setter")
arc.end_angle = 270.0
check(arc.end_angle == 270.0, "arc.end_angle setter")
arc.color = mcrfpy.Color(255, 128, 128)
c = arc.color
check(c.r == 255 and c.g == 128 and c.b == 128, "arc.color setter")
arc.thickness = 5.0
check(arc.thickness == 5.0, "arc.thickness setter")
# ============================================================
# Test #291: UIGrid property setters
# ============================================================
print("Testing #291: UIGrid property setters...")
try:
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(500, 100), size=(200, 200))
parent.children.append(grid)
grid.center_x = 5.0
check(True, "grid.center_x setter (no crash)")
grid.center_y = 5.0
check(True, "grid.center_y setter (no crash)")
grid.zoom = 2.0
check(grid.zoom == 2.0, "grid.zoom setter")
grid.fill_color = mcrfpy.Color(20, 20, 40)
check(True, "grid.fill_color setter (no crash)")
except Exception as e:
print(f" (Grid tests skipped: {e})")
# ============================================================
# Trigger a render cycle to exercise dirty flag code paths
# ============================================================
print("Triggering render cycle...")
mcrfpy.step(0.016) # ~1 frame at 60fps
# ============================================================
# Summary
# ============================================================
print(f"\n{'='*50}")
print(f"Results: {test_count - fail_count}/{test_count} passed")
if test_pass:
print("PASS: All dirty flag propagation tests passed")
sys.exit(0)
else:
print(f"FAIL: {fail_count} test(s) failed")
sys.exit(1)

View file

@ -14,7 +14,7 @@ def test_metrics(timer, runtime):
print("\nRunning metrics test...")
# Get metrics
metrics = mcrfpy.getMetrics()
metrics = mcrfpy.get_metrics()
print("\nPerformance Metrics:")
print(f" Frame Time: {metrics['frame_time']:.2f} ms")
@ -81,7 +81,7 @@ def test_metrics(timer, runtime):
# Schedule another check after 100ms
def check_later(timer2, runtime2):
global success
metrics2 = mcrfpy.getMetrics()
metrics2 = mcrfpy.get_metrics()
print(f"\nMetrics after 100ms:")
print(f" Frame Time: {metrics2['frame_time']:.2f} ms")

428
web/index.html Normal file
View file

@ -0,0 +1,428 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>McRogueFace - Web Demo</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-dark: #0c0a14;
--bg-card: #16132a;
--bg-surface: #1e1a36;
--accent: #e94560;
--accent-glow: #e9456040;
--text: #e8e6f0;
--text-dim: #8886a0;
--text-muted: #5a587a;
--gold: #f0c040;
--green: #40d080;
}
html, body {
width: 100%;
min-height: 100vh;
background: var(--bg-dark);
color: var(--text);
font-family: 'JetBrains Mono', 'Courier New', monospace;
overflow-x: hidden;
}
.hero {
text-align: center;
padding: 40px 20px 20px;
background: linear-gradient(180deg, #14102a 0%, var(--bg-dark) 100%);
}
.hero h1 {
font-size: 3rem;
font-weight: 700;
color: var(--accent);
text-shadow: 0 0 40px var(--accent-glow);
margin-bottom: 8px;
letter-spacing: 2px;
}
.hero .subtitle {
font-size: 1.1rem;
color: var(--text-dim);
margin-bottom: 16px;
}
.hero .tagline {
font-size: 0.85rem;
color: var(--text-muted);
max-width: 600px;
margin: 0 auto 20px;
line-height: 1.5;
}
.canvas-wrapper {
display: flex;
justify-content: center;
padding: 0 20px 30px;
}
.canvas-frame {
position: relative;
background: #000;
border: 2px solid #2a2648;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6), 0 0 60px var(--accent-glow);
/* fixed aspect ratio container for 1024x768 */
width: min(1024px, calc(100vw - 40px));
aspect-ratio: 1024 / 768;
}
#canvas {
display: block;
width: 100%;
height: 100%;
image-rendering: pixelated;
image-rendering: crisp-edges;
-ms-interpolation-mode: nearest-neighbor;
outline: none;
}
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: rgba(12, 10, 20, 0.95);
z-index: 10;
transition: opacity 0.5s ease;
}
.loading-overlay.hidden {
opacity: 0;
pointer-events: none;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid #2a2648;
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#status {
margin-top: 16px;
font-size: 0.85rem;
color: var(--text-dim);
}
.info-section {
max-width: 1024px;
margin: 0 auto;
padding: 30px 20px 50px;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 30px;
}
@media (max-width: 700px) {
.info-grid { grid-template-columns: 1fr; }
.hero h1 { font-size: 2rem; }
}
.info-card {
background: var(--bg-card);
border: 1px solid #2a2648;
border-radius: 8px;
padding: 20px;
}
.info-card h3 {
color: var(--gold);
font-size: 1rem;
margin-bottom: 10px;
}
.info-card p, .info-card ul {
font-size: 0.8rem;
color: var(--text-dim);
line-height: 1.6;
}
.info-card ul {
list-style: none;
padding: 0;
}
.info-card ul li::before {
content: "> ";
color: var(--green);
}
.links {
text-align: center;
padding: 20px;
}
.links a {
display: inline-block;
margin: 0 12px;
padding: 10px 24px;
background: var(--bg-surface);
color: var(--text);
text-decoration: none;
border: 1px solid #2a2648;
border-radius: 6px;
font-size: 0.85rem;
font-family: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
}
.links a:hover {
border-color: var(--accent);
box-shadow: 0 0 20px var(--accent-glow);
}
.footer {
text-align: center;
padding: 20px;
font-size: 0.75rem;
color: var(--text-muted);
border-top: 1px solid #1a1830;
}
.click-to-focus {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background: rgba(12, 10, 20, 0.7);
z-index: 5;
cursor: pointer;
transition: opacity 0.3s ease;
}
.click-to-focus.hidden {
opacity: 0;
pointer-events: none;
}
.click-to-focus span {
font-size: 1.1rem;
color: var(--text);
padding: 12px 24px;
background: var(--bg-surface);
border: 1px solid var(--accent);
border-radius: 6px;
}
</style>
</head>
<body>
<div class="hero">
<h1>McRogueFace</h1>
<p class="subtitle">A Python-Powered Roguelike Engine</p>
<p class="tagline">
C++ engine with Python scripting, compiled to WebAssembly.
BSP dungeons, Wang tile autotiling, field of view, and turn-based combat
&mdash; all running in your browser.
</p>
</div>
<div class="canvas-wrapper">
<div class="canvas-frame">
<canvas id="canvas" oncontextmenu="event.preventDefault()" tabindex="-1"></canvas>
<div class="loading-overlay" id="loading">
<div class="spinner"></div>
<p id="status">Loading...</p>
</div>
<div class="click-to-focus hidden" id="focus-prompt">
<span>Click to play</span>
</div>
</div>
</div>
<div class="info-section">
<div class="info-grid">
<div class="info-card">
<h3>Controls</h3>
<ul>
<li>Arrow keys or WASD to move</li>
<li>Bump into enemies to attack</li>
<li>Walk over potions to heal</li>
<li>Find stairs to descend deeper</li>
<li>Period (.) to wait a turn</li>
<li>R to restart after death</li>
</ul>
</div>
<div class="info-card">
<h3>Engine Features</h3>
<ul>
<li>BSP dungeon generation (libtcod)</li>
<li>Wang tile autotiling (Tiled .tsx)</li>
<li>Field of view &amp; fog of war</li>
<li>Entity/Grid system with layers</li>
<li>Python 3.14 scripting (full interpreter)</li>
<li>SDL2 + OpenGL ES 2 rendering</li>
</ul>
</div>
<div class="info-card">
<h3>About This Demo</h3>
<p>
This entire game is written in Python, running on the McRogueFace
C++ engine compiled to WebAssembly via Emscripten. The game logic,
dungeon generation, AI, and UI are all Python &mdash; the engine
handles rendering, input, and the tile/entity system.
</p>
</div>
<div class="info-card">
<h3>Tech Stack</h3>
<ul>
<li>C++17 game engine core</li>
<li>Python 3.14 (cross-compiled to WASM)</li>
<li>SDL2 + OpenGL ES 2 (via Emscripten)</li>
<li>libtcod (BSP, FOV, pathfinding)</li>
<li>Kenney Tiny Dungeon tileset</li>
<li>~16 MB total download</li>
</ul>
</div>
</div>
<div class="links">
<a href="https://github.com/jmccardle/McRogueFace">GitHub</a>
<a href="https://gamedev.ffwf.net/gitea/john/McRogueFace">Gitea</a>
<a href="https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki">Documentation</a>
</div>
</div>
<div class="footer">
McRogueFace &mdash; Created for 7DRL 2023, actively developed.
Engine by John McCardle. Tiles by Kenney.nl.
</div>
<script>
var canvasElement = document.getElementById('canvas');
var loadingElement = document.getElementById('loading');
var statusElement = document.getElementById('status');
var focusPrompt = document.getElementById('focus-prompt');
var runtimeReady = false;
var hasFocus = false;
function updateCanvasSize() {
// Match canvas backing buffer to its display size
var rect = canvasElement.getBoundingClientRect();
var w = Math.floor(rect.width);
var h = Math.floor(rect.height);
if (runtimeReady) {
Module.ccall('notify_canvas_resize', null, ['number', 'number'], [w, h]);
} else {
canvasElement.width = w;
canvasElement.height = h;
}
}
updateCanvasSize();
window.addEventListener('resize', updateCanvasSize);
// Focus management
canvasElement.addEventListener('click', function() {
canvasElement.focus();
hasFocus = true;
focusPrompt.classList.add('hidden');
});
canvasElement.addEventListener('mousedown', function() {
if (document.activeElement !== canvasElement) {
canvasElement.focus();
hasFocus = true;
focusPrompt.classList.add('hidden');
}
});
canvasElement.addEventListener('blur', function() {
if (runtimeReady) {
hasFocus = false;
focusPrompt.classList.remove('hidden');
}
});
focusPrompt.addEventListener('click', function() {
canvasElement.focus();
hasFocus = true;
focusPrompt.classList.add('hidden');
});
var Module = {
preRun: [function() {
FS.mkdir('/save');
FS.mount(IDBFS, {}, '/save');
Module.addRunDependency('idbfs-restore');
FS.syncfs(true, function(err) {
if (err) console.error('Failed to restore /save/:', err);
Module.removeRunDependency('idbfs-restore');
});
}],
print: function(text) {
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ');
console.log(text);
},
printErr: function(text) {
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ');
console.error(text);
},
canvas: canvasElement,
setStatus: function(text) {
if (!Module.setStatus.last) Module.setStatus.last = { time: Date.now(), text: '' };
if (text === Module.setStatus.last.text) return;
var m = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/);
var now = Date.now();
if (m && now - Module.setStatus.last.time < 30) return;
Module.setStatus.last.time = now;
Module.setStatus.last.text = text;
if (m) text = m[1];
statusElement.textContent = text;
},
totalDependencies: 0,
monitorRunDependencies: function(left) {
this.totalDependencies = Math.max(this.totalDependencies, left);
Module.setStatus(left
? 'Preparing... (' + (this.totalDependencies - left) + '/' + this.totalDependencies + ')'
: 'All downloads complete.');
},
onRuntimeInitialized: function() {
runtimeReady = true;
loadingElement.classList.add('hidden');
canvasElement.focus();
hasFocus = true;
window.dispatchEvent(new Event('resize'));
}
};
Module.setStatus('Downloading...');
window.onerror = function(event) {
Module.setStatus('Error! See browser console.');
};
// Emscripten symbol resolver shim
if (typeof resolveGlobalSymbol === 'undefined') {
window.resolveGlobalSymbol = function(name, direct) {
return {
sym: Module['_' + name] || Module[name],
type: 'function'
};
};
}
</script>
<script async src="mcrogueface.js"></script>
</body>
</html>