From eb88c7b3aab3da519db7569106c34f3510b6e963 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sun, 13 Jul 2025 22:55:39 -0400 Subject: [PATCH] Add animation completion callbacks (#119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement callbacks that fire when animations complete, enabling direct causality between animation end and game state changes. This eliminates race conditions from parallel timer workarounds. - Add optional callback parameter to Animation constructor - Callbacks execute synchronously when animation completes - Proper Python reference counting with GIL safety - Callbacks receive (anim, target) parameters (currently None) - Exception handling prevents crashes from Python errors Example usage: ```python def on_complete(anim, target): player_moving = False anim = mcrfpy.Animation("x", 300.0, 1.0, "easeOut", callback=on_complete) anim.start(player) ``` 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/Animation.cpp | 52 ++++++++++++++++- src/Animation.h | 14 ++++- src/PyAnimation.cpp | 20 +++++-- tests/demo_animation_callback_usage.py | 81 ++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 tests/demo_animation_callback_usage.py diff --git a/src/Animation.cpp b/src/Animation.cpp index 2e061e7..abf2d41 100644 --- a/src/Animation.cpp +++ b/src/Animation.cpp @@ -14,13 +14,28 @@ Animation::Animation(const std::string& targetProperty, const AnimationValue& targetValue, float duration, EasingFunction easingFunc, - bool delta) + bool delta, + PyObject* callback) : targetProperty(targetProperty) , targetValue(targetValue) , duration(duration) , easingFunc(easingFunc) , delta(delta) + , pythonCallback(callback) { + // Increase reference count for Python callback + if (pythonCallback) { + Py_INCREF(pythonCallback); + } +} + +Animation::~Animation() { + // Decrease reference count for Python callback + if (pythonCallback) { + PyGILState_STATE gstate = PyGILState_Ensure(); + Py_DECREF(pythonCallback); + PyGILState_Release(gstate); + } } void Animation::start(std::shared_ptr target) { @@ -149,6 +164,12 @@ bool Animation::update(float deltaTime) { applyValue(entity.get(), currentValue); } + // Trigger callback when animation completes + if (isComplete() && !callbackTriggered && pythonCallback) { + triggerCallback(); + callbackTriggered = true; + } + return !isComplete(); } @@ -295,6 +316,35 @@ void Animation::applyValue(UIEntity* entity, const AnimationValue& value) { }, value); } +void Animation::triggerCallback() { + if (!pythonCallback) return; + + PyGILState_STATE gstate = PyGILState_Ensure(); + + // We need to create PyAnimation wrapper for this animation + // and PyObject wrapper for the target + // For now, we'll pass None for both as a simple implementation + // TODO: In future, wrap the animation and target objects properly + + PyObject* args = PyTuple_New(2); + Py_INCREF(Py_None); + Py_INCREF(Py_None); + PyTuple_SetItem(args, 0, Py_None); // animation parameter + PyTuple_SetItem(args, 1, Py_None); // target parameter + + PyObject* result = PyObject_CallObject(pythonCallback, args); + Py_DECREF(args); + + if (!result) { + // Print error but don't crash + PyErr_Print(); + } else { + Py_DECREF(result); + } + + PyGILState_Release(gstate); +} + // Easing functions implementation namespace EasingFunctions { diff --git a/src/Animation.h b/src/Animation.h index 38fb660..0879ab5 100644 --- a/src/Animation.h +++ b/src/Animation.h @@ -6,6 +6,7 @@ #include #include #include +#include "Python.h" // Forward declarations class UIDrawable; @@ -36,7 +37,11 @@ public: const AnimationValue& targetValue, float duration, EasingFunction easingFunc = EasingFunctions::linear, - bool delta = false); + bool delta = false, + PyObject* callback = nullptr); + + // Destructor - cleanup Python callback reference + ~Animation(); // Apply this animation to a drawable void start(std::shared_ptr target); @@ -77,12 +82,19 @@ private: std::weak_ptr targetWeak; std::weak_ptr entityTargetWeak; + // Callback support + PyObject* pythonCallback = nullptr; // Python callback function + bool callbackTriggered = false; // Ensure callback only fires once + // Helper to interpolate between values AnimationValue interpolate(float t) const; // Helper to apply value to target void applyValue(UIDrawable* target, const AnimationValue& value); void applyValue(UIEntity* entity, const AnimationValue& value); + + // Trigger callback when animation completes + void triggerCallback(); }; // Easing functions library diff --git a/src/PyAnimation.cpp b/src/PyAnimation.cpp index 1adddb1..d45c6eb 100644 --- a/src/PyAnimation.cpp +++ b/src/PyAnimation.cpp @@ -18,19 +18,31 @@ PyObject* PyAnimation::create(PyTypeObject* type, PyObject* args, PyObject* kwds } int PyAnimation::init(PyAnimationObject* self, PyObject* args, PyObject* kwds) { - static const char* keywords[] = {"property", "target", "duration", "easing", "delta", nullptr}; + static const char* keywords[] = {"property", "target", "duration", "easing", "delta", "callback", nullptr}; const char* property_name; PyObject* target_value; float duration; const char* easing_name = "linear"; int delta = 0; + PyObject* callback = nullptr; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|sp", const_cast(keywords), - &property_name, &target_value, &duration, &easing_name, &delta)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|spO", const_cast(keywords), + &property_name, &target_value, &duration, &easing_name, &delta, &callback)) { return -1; } + // Validate callback is callable if provided + if (callback && callback != Py_None && !PyCallable_Check(callback)) { + PyErr_SetString(PyExc_TypeError, "callback must be callable"); + return -1; + } + + // Convert None to nullptr for C++ + if (callback == Py_None) { + callback = nullptr; + } + // Convert Python target value to AnimationValue AnimationValue animValue; @@ -90,7 +102,7 @@ int PyAnimation::init(PyAnimationObject* self, PyObject* args, PyObject* kwds) { EasingFunction easingFunc = EasingFunctions::getByName(easing_name); // Create the Animation - self->data = std::make_shared(property_name, animValue, duration, easingFunc, delta != 0); + self->data = std::make_shared(property_name, animValue, duration, easingFunc, delta != 0, callback); return 0; } diff --git a/tests/demo_animation_callback_usage.py b/tests/demo_animation_callback_usage.py new file mode 100644 index 0000000..7cd019a --- /dev/null +++ b/tests/demo_animation_callback_usage.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +Demonstration of animation callbacks solving race conditions. +Shows how callbacks enable direct causality for game state changes. +""" + +import mcrfpy + +# Game state +player_moving = False +move_queue = [] + +def movement_complete(anim, target): + """Called when player movement animation completes""" + global player_moving, move_queue + + print("Movement animation completed!") + player_moving = False + + # Process next move if queued + if move_queue: + next_pos = move_queue.pop(0) + move_player_to(next_pos) + else: + print("Player is now idle and ready for input") + +def move_player_to(new_pos): + """Move player with animation and proper state management""" + global player_moving + + if player_moving: + print(f"Queueing move to {new_pos}") + move_queue.append(new_pos) + return + + player_moving = True + print(f"Moving player to {new_pos}") + + # Get player entity (placeholder for demo) + ui = mcrfpy.sceneUI("game") + player = ui[0] # Assume first element is player + + # Animate movement with callback + x, y = new_pos + anim_x = mcrfpy.Animation("x", float(x), 0.5, "easeInOutQuad", callback=movement_complete) + anim_y = mcrfpy.Animation("y", float(y), 0.5, "easeInOutQuad") + + anim_x.start(player) + anim_y.start(player) + +def setup_demo(): + """Set up the demo scene""" + # Create scene + mcrfpy.createScene("game") + mcrfpy.setScene("game") + + # Create player sprite + player = mcrfpy.Frame((100, 100), (32, 32), fill_color=(0, 255, 0)) + ui = mcrfpy.sceneUI("game") + ui.append(player) + + print("Demo: Animation callbacks for movement queue") + print("=" * 40) + + # Simulate rapid movement commands + mcrfpy.setTimer("move1", lambda r: move_player_to((200, 100)), 100) + mcrfpy.setTimer("move2", lambda r: move_player_to((200, 200)), 200) # Will be queued + mcrfpy.setTimer("move3", lambda r: move_player_to((100, 200)), 300) # Will be queued + + # Exit after demo + mcrfpy.setTimer("exit", lambda r: exit_demo(), 3000) + +def exit_demo(): + """Exit the demo""" + print("\nDemo completed successfully!") + print("Callbacks ensure proper movement sequencing without race conditions") + import sys + sys.exit(0) + +# Run the demo +setup_demo() \ No newline at end of file