Compare commits

..

3 commits

Author SHA1 Message Date
1d11b020b0 Implement Scene subclass on_key callback support
Scene subclasses can now define on_key(self, key, state) methods that
receive keyboard events, matching the existing on_enter, on_exit, and
update lifecycle callbacks.

Changes:
- Rename call_on_keypress to call_on_key (consistent naming with property)
- Add triggerKeyEvent helper in McRFPy_API
- Call triggerKeyEvent from GameEngine when key_callable is not set
- Fix condition to check key_callable.isNone() (not just pointer existence)
- Handle both bound methods and instance-assigned callables

Usage:
    class GameScene(mcrfpy.Scene):
        def on_key(self, key, state):
            if key == "Escape" and state == "end":
                quit_game()

Property assignment (scene.on_key = callable) still works and takes
precedence when key_callable is set via the property setter.

Includes comprehensive test: tests/unit/scene_subclass_on_key_test.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 15:51:20 -05:00
b6eb70748a Remove YAGNI methods from performance systems
GridChunk: Remove getWorldBounds, markAllDirty, getVisibleChunks
- getWorldBounds: Chunk visibility handled by isVisible() instead
- markAllDirty: GridLayers uses per-cell markDirty() pattern
- getVisibleChunks: GridLayers computes visible range inline
- Keep dirtyChunks() for diagnostics

GridLayers: Remove getChunkCoords
- Trivial helper replaced by inline division throughout codebase

SpatialHash: Remove queryRect, totalEntities, cleanBucket
- queryRect: Exceeds #115 scope (only queryRadius required)
- totalEntities: Redundant with separate entity count tracking
- cleanBucket: Dead code - expired weak_ptrs cleaned during remove/update

All removals identified via cppcheck static analysis. Core functionality
of each system remains intact and actively used.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 15:40:13 -05:00
ae27e7deee delete unused file 2026-01-09 15:33:52 -05:00
12 changed files with 118 additions and 215 deletions

View file

@ -457,11 +457,17 @@ void GameEngine::processEvent(const sf::Event& event)
std::string name = currentScene()->action(actionCode);
currentScene()->doAction(name, actionType);
}
else if (currentScene()->key_callable &&
else if (currentScene()->key_callable && !currentScene()->key_callable->isNone() &&
(event.type == sf::Event::KeyPressed || event.type == sf::Event::KeyReleased))
{
// Property-assigned handler (scene.on_key = callable)
currentScene()->key_callable->call(ActionCode::key_str(event.key.code), actionType);
}
else if (event.type == sf::Event::KeyPressed || event.type == sf::Event::KeyReleased)
{
// Try subclass on_key method if no property handler is set
McRFPy_API::triggerKeyEvent(ActionCode::key_str(event.key.code), actionType);
}
}
void GameEngine::sUserInput()

View file

@ -33,13 +33,6 @@ void GridChunk::markDirty() {
// #150 - Removed ensureTexture/renderToTexture - base layer rendering removed
// GridChunk now only provides data storage for GridPoints
sf::FloatRect GridChunk::getWorldBounds(int cell_width, int cell_height) const {
return sf::FloatRect(
sf::Vector2f(world_x * cell_width, world_y * cell_height),
sf::Vector2f(width * cell_width, height * cell_height)
);
}
bool GridChunk::isVisible(float left_edge, float top_edge,
float right_edge, float bottom_edge) const {
// Check if chunk's cell range overlaps with viewport's cell range
@ -147,26 +140,6 @@ const UIGridPoint& ChunkManager::at(int x, int y) const {
return chunk->at(local_x, local_y);
}
void ChunkManager::markAllDirty() {
for (auto& chunk : chunks) {
chunk->markDirty();
}
}
std::vector<GridChunk*> ChunkManager::getVisibleChunks(float left_edge, float top_edge,
float right_edge, float bottom_edge) {
std::vector<GridChunk*> visible;
visible.reserve(chunks.size()); // Pre-allocate for worst case
for (auto& chunk : chunks) {
if (chunk->isVisible(left_edge, top_edge, right_edge, bottom_edge)) {
visible.push_back(chunk.get());
}
}
return visible;
}
void ChunkManager::resize(int new_grid_x, int new_grid_y) {
// For now, simple rebuild - could be optimized to preserve data
grid_x = new_grid_x;

View file

@ -50,9 +50,6 @@ public:
// Mark chunk as dirty
void markDirty();
// Get pixel bounds of this chunk in world coordinates
sf::FloatRect getWorldBounds(int cell_width, int cell_height) const;
// Check if chunk overlaps with viewport
bool isVisible(float left_edge, float top_edge,
float right_edge, float bottom_edge) const;
@ -90,13 +87,6 @@ public:
UIGridPoint& at(int x, int y);
const UIGridPoint& at(int x, int y) const;
// Mark all chunks dirty (for full rebuild)
void markAllDirty();
// Get chunks that overlap with viewport
std::vector<GridChunk*> getVisibleChunks(float left_edge, float top_edge,
float right_edge, float bottom_edge);
// Resize grid (rebuilds chunks)
void resize(int new_grid_x, int new_grid_y);

View file

@ -57,11 +57,6 @@ int GridLayer::getChunkIndex(int cell_x, int cell_y) const {
return cy * chunks_x + cx;
}
void GridLayer::getChunkCoords(int cell_x, int cell_y, int& chunk_x, int& chunk_y) const {
chunk_x = cell_x / CHUNK_SIZE;
chunk_y = cell_y / CHUNK_SIZE;
}
void GridLayer::getChunkBounds(int chunk_x, int chunk_y, int& start_x, int& start_y, int& end_x, int& end_y) const {
start_x = chunk_x * CHUNK_SIZE;
start_y = chunk_y * CHUNK_SIZE;

View file

@ -56,9 +56,6 @@ public:
// Get chunk index for a cell
int getChunkIndex(int cell_x, int cell_y) const;
// Get chunk coordinates for a cell
void getChunkCoords(int cell_x, int cell_y, int& chunk_x, int& chunk_y) const;
// Get cell bounds for a chunk
void getChunkBounds(int chunk_x, int chunk_y, int& start_x, int& start_y, int& end_x, int& end_y) const;

View file

@ -80,6 +80,7 @@ public:
static void triggerSceneChange(const std::string& from_scene, const std::string& to_scene);
static void updatePythonScenes(float dt);
static void triggerResize(int width, int height);
static void triggerKeyEvent(const std::string& key, const std::string& action);
// #151: Module-level scene property accessors
static PyObject* api_get_current_scene();

View file

@ -413,23 +413,28 @@ void PySceneClass::call_on_exit(PySceneObject* self)
}
}
void PySceneClass::call_on_keypress(PySceneObject* self, std::string key, std::string action)
void PySceneClass::call_on_key(PySceneObject* self, const std::string& key, const std::string& action)
{
PyGILState_STATE gstate = PyGILState_Ensure();
PyObject* method = PyObject_GetAttrString((PyObject*)self, "on_keypress");
if (method && PyCallable_Check(method)) {
PyObject* result = PyObject_CallFunction(method, "ss", key.c_str(), action.c_str());
// Look for on_key attribute on the Python object
// This handles both:
// 1. Subclass methods: class MyScene(Scene): def on_key(self, k, s): ...
// 2. Instance attributes: ts.on_key = lambda k, s: ... (when subclass shadows property)
PyObject* attr = PyObject_GetAttrString((PyObject*)self, "on_key");
if (attr && PyCallable_Check(attr) && attr != Py_None) {
// Call it - works for both bound methods and regular callables
PyObject* result = PyObject_CallFunction(attr, "ss", key.c_str(), action.c_str());
if (result) {
Py_DECREF(result);
} else {
PyErr_Print();
}
Py_DECREF(method);
Py_DECREF(attr);
} else {
// Clear AttributeError if method doesn't exist
// Not callable or is None - nothing to call
PyErr_Clear();
Py_XDECREF(method);
Py_XDECREF(attr);
}
PyGILState_Release(gstate);
@ -571,6 +576,18 @@ void McRFPy_API::triggerResize(int width, int height)
}
}
// Helper function to trigger key events on Python scene subclasses
void McRFPy_API::triggerKeyEvent(const std::string& key, const std::string& action)
{
GameEngine* game = McRFPy_API::game;
if (!game) return;
// Only notify the active scene if it has an on_key method (subclass)
if (python_scenes.count(game->scene) > 0) {
PySceneClass::call_on_key(python_scenes[game->scene], key, action);
}
}
// #151: Get the current scene as a Python Scene object
PyObject* McRFPy_API::api_get_current_scene()
{

View file

@ -36,7 +36,7 @@ public:
// Lifecycle callbacks (called from C++)
static void call_on_enter(PySceneObject* self);
static void call_on_exit(PySceneObject* self);
static void call_on_keypress(PySceneObject* self, std::string key, std::string action);
static void call_on_key(PySceneObject* self, const std::string& key, const std::string& action);
static void call_update(PySceneObject* self, float dt);
static void call_on_resize(PySceneObject* self, int width, int height);
@ -75,7 +75,7 @@ namespace mcrfpydef {
"Lifecycle Callbacks (override in subclass):\n"
" on_enter(): Called when scene becomes active via activate().\n"
" on_exit(): Called when scene is deactivated (another scene activates).\n"
" on_keypress(key: str, action: str): Called for keyboard events. Alternative to on_key property.\n"
" on_key(key: str, action: str): Called for keyboard events (subclass method).\n"
" update(dt: float): Called every frame with delta time in seconds.\n"
" on_resize(width: int, height: int): Called when window is resized.\n\n"
"Example:\n"

View file

@ -92,24 +92,6 @@ std::vector<std::pair<int, int>> SpatialHash::getBucketsInRadius(float x, float
return result;
}
std::vector<std::pair<int, int>> SpatialHash::getBucketsInRect(float x, float y, float width, float height) const
{
std::vector<std::pair<int, int>> result;
int min_bx = static_cast<int>(std::floor(x / bucket_size));
int max_bx = static_cast<int>(std::floor((x + width) / bucket_size));
int min_by = static_cast<int>(std::floor(y / bucket_size));
int max_by = static_cast<int>(std::floor((y + height) / bucket_size));
for (int bx = min_bx; bx <= max_bx; ++bx) {
for (int by = min_by; by <= max_by; ++by) {
result.emplace_back(bx, by);
}
}
return result;
}
std::vector<std::shared_ptr<UIEntity>> SpatialHash::queryRadius(float x, float y, float radius) const
{
std::vector<std::shared_ptr<UIEntity>> result;
@ -137,57 +119,7 @@ std::vector<std::shared_ptr<UIEntity>> SpatialHash::queryRadius(float x, float y
return result;
}
std::vector<std::shared_ptr<UIEntity>> SpatialHash::queryRect(float x, float y, float width, float height) const
{
std::vector<std::shared_ptr<UIEntity>> result;
auto bucket_coords = getBucketsInRect(x, y, width, height);
for (const auto& coord : bucket_coords) {
auto it = buckets.find(coord);
if (it == buckets.end()) continue;
for (const auto& wp : it->second) {
auto entity = wp.lock();
if (!entity) continue;
// Check if entity is within the rectangle
float ex = entity->position.x;
float ey = entity->position.y;
if (ex >= x && ex < x + width && ey >= y && ey < y + height) {
result.push_back(entity);
}
}
}
return result;
}
void SpatialHash::clear()
{
buckets.clear();
}
size_t SpatialHash::totalEntities() const
{
size_t count = 0;
for (const auto& [coord, bucket] : buckets) {
for (const auto& wp : bucket) {
if (wp.lock()) {
++count;
}
}
}
return count;
}
void SpatialHash::cleanBucket(std::vector<std::weak_ptr<UIEntity>>& bucket)
{
bucket.erase(
std::remove_if(bucket.begin(), bucket.end(),
[](const std::weak_ptr<UIEntity>& wp) {
return wp.expired();
}),
bucket.end()
);
}

View file

@ -37,15 +37,11 @@ public:
// Returns entities whose positions are within the circular radius
std::vector<std::shared_ptr<UIEntity>> queryRadius(float x, float y, float radius) const;
// Query all entities within a rectangular region
std::vector<std::shared_ptr<UIEntity>> queryRect(float x, float y, float width, float height) const;
// Clear all entities from the hash
void clear();
// Get statistics for debugging
size_t bucketCount() const { return buckets.size(); }
size_t totalEntities() const;
private:
int bucket_size;
@ -72,10 +68,4 @@ private:
// Get all bucket coordinates that overlap with a radius query
std::vector<std::pair<int, int>> getBucketsInRadius(float x, float y, float radius) const;
// Get all bucket coordinates that overlap with a rectangle
std::vector<std::pair<int, int>> getBucketsInRect(float x, float y, float width, float height) const;
// Clean expired weak_ptrs from a bucket
void cleanBucket(std::vector<std::weak_ptr<UIEntity>>& bucket);
};

View file

@ -1,82 +0,0 @@
#pragma once
#include "UIDrawable.h"
#include <vector>
#include <memory>
// Base class for UI containers that provides common click handling logic
class UIContainerBase {
protected:
// Transform a point from parent coordinates to this container's local coordinates
virtual sf::Vector2f toLocalCoordinates(sf::Vector2f point) const = 0;
// Transform a point from this container's local coordinates to child coordinates
virtual sf::Vector2f toChildCoordinates(sf::Vector2f localPoint, int childIndex) const = 0;
// Get the bounds of this container in parent coordinates
virtual sf::FloatRect getBounds() const = 0;
// Check if a local point is within this container's bounds
virtual bool containsPoint(sf::Vector2f localPoint) const = 0;
// Get click handler if this container has one
virtual UIDrawable* getClickHandler() = 0;
// Get children to check for clicks (can be empty)
virtual std::vector<UIDrawable*> getClickableChildren() = 0;
public:
// Standard click handling algorithm for all containers
// Returns the deepest UIDrawable that has a click handler and contains the point
UIDrawable* handleClick(sf::Vector2f point) {
// Transform to local coordinates
sf::Vector2f localPoint = toLocalCoordinates(point);
// Check if point is within our bounds
if (!containsPoint(localPoint)) {
return nullptr;
}
// Check children in reverse z-order (top-most first)
// This ensures that elements rendered on top get first chance at clicks
auto children = getClickableChildren();
// TODO: Sort by z-index if not already sorted
// std::sort(children.begin(), children.end(),
// [](UIDrawable* a, UIDrawable* b) { return a->z_index > b->z_index; });
for (int i = children.size() - 1; i >= 0; --i) {
if (!children[i]->visible) continue;
sf::Vector2f childPoint = toChildCoordinates(localPoint, i);
if (auto target = children[i]->click_at(childPoint)) {
// Child (or its descendant) handled the click
return target;
}
// If child didn't handle it, continue checking other children
// This allows click-through for elements without handlers
}
// No child consumed the click
// Now check if WE have a click handler
return getClickHandler();
}
};
// Helper for containers with simple box bounds
class RectangularContainer : public UIContainerBase {
protected:
sf::FloatRect bounds;
sf::Vector2f toLocalCoordinates(sf::Vector2f point) const override {
return point - sf::Vector2f(bounds.left, bounds.top);
}
bool containsPoint(sf::Vector2f localPoint) const override {
return localPoint.x >= 0 && localPoint.y >= 0 &&
localPoint.x < bounds.width && localPoint.y < bounds.height;
}
sf::FloatRect getBounds() const override {
return bounds;
}
};

View file

@ -0,0 +1,84 @@
"""Test Scene subclass on_key method callback
Verifies that:
1. Subclass on_key method is called for keyboard events
2. Property assignment (scene.on_key = callable) still works
3. Property assignment on subclass overrides the method
"""
import mcrfpy
from mcrfpy import automation
import sys
# Test state
tests_passed = 0
tests_failed = 0
def test_subclass_method():
"""Test that subclass on_key method receives keyboard events"""
global tests_passed, tests_failed
events = []
class TestScene(mcrfpy.Scene):
def on_key(self, key, state):
events.append((key, state))
ts = TestScene('test_method')
ts.activate()
automation.keyDown('a')
automation.keyUp('a')
if len(events) >= 2:
print("PASS: test_subclass_method")
tests_passed += 1
else:
print(f"FAIL: test_subclass_method - got {events}")
tests_failed += 1
def test_property_handler():
"""Test that property assignment works"""
global tests_passed, tests_failed
events = []
scene = mcrfpy.Scene('test_property')
scene.on_key = lambda k, s: events.append((k, s))
scene.activate()
automation.keyDown('b')
automation.keyUp('b')
if len(events) >= 2:
print("PASS: test_property_handler")
tests_passed += 1
else:
print(f"FAIL: test_property_handler - got {events}")
tests_failed += 1
def test_property_overrides_method():
"""Test that property assignment on subclass overrides the method"""
global tests_passed, tests_failed
method_events = []
property_events = []
class TestScene(mcrfpy.Scene):
def on_key(self, key, state):
method_events.append((key, state))
ts = TestScene('test_override')
ts.activate()
ts.on_key = lambda k, s: property_events.append((k, s))
automation.keyDown('c')
automation.keyUp('c')
if len(property_events) >= 2 and len(method_events) == 0:
print("PASS: test_property_overrides_method")
tests_passed += 1
else:
print(f"FAIL: test_property_overrides_method - method={method_events}, property={property_events}")
tests_failed += 1
# Run tests
test_subclass_method()
test_property_handler()
test_property_overrides_method()
print(f"\nResults: {tests_passed} passed, {tests_failed} failed")
sys.exit(0 if tests_failed == 0 else 1)