From 1d11b020b0a9e93981c1ed124ec944731e31328c Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 9 Jan 2026 15:51:20 -0500 Subject: [PATCH] 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 --- src/GameEngine.cpp | 8 ++- src/McRFPy_API.h | 1 + src/PySceneObject.cpp | 31 +++++++-- src/PySceneObject.h | 4 +- tests/unit/scene_subclass_on_key_test.py | 84 ++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 10 deletions(-) create mode 100644 tests/unit/scene_subclass_on_key_test.py diff --git a/src/GameEngine.cpp b/src/GameEngine.cpp index de149be..8a4c619 100644 --- a/src/GameEngine.cpp +++ b/src/GameEngine.cpp @@ -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() diff --git a/src/McRFPy_API.h b/src/McRFPy_API.h index f6e7440..3c7408d 100644 --- a/src/McRFPy_API.h +++ b/src/McRFPy_API.h @@ -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(); diff --git a/src/PySceneObject.cpp b/src/PySceneObject.cpp index 59cb51b..e388384 100644 --- a/src/PySceneObject.cpp +++ b/src/PySceneObject.cpp @@ -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() { diff --git a/src/PySceneObject.h b/src/PySceneObject.h index 9fb7c23..9e9f189 100644 --- a/src/PySceneObject.h +++ b/src/PySceneObject.h @@ -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" diff --git a/tests/unit/scene_subclass_on_key_test.py b/tests/unit/scene_subclass_on_key_test.py new file mode 100644 index 0000000..829f188 --- /dev/null +++ b/tests/unit/scene_subclass_on_key_test.py @@ -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)