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>
This commit is contained in:
John McCardle 2026-01-09 15:51:20 -05:00
commit 1d11b020b0
5 changed files with 118 additions and 10 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

@ -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

@ -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)