From 9ab618079a9852d7a1e5af80f04073c6645bf0f6 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sun, 4 Jan 2026 15:32:14 -0500 Subject: [PATCH] .animate helper: create and start an animation directly on a target. Preferred use pattern; closes #175 --- src/Animation.cpp | 11 +- src/UIArc.cpp | 17 ++ src/UIArc.h | 2 + src/UIBase.h | 41 ++++- src/UICaption.cpp | 21 +++ src/UICaption.h | 2 + src/UICircle.cpp | 17 ++ src/UICircle.h | 2 + src/UIDrawable.cpp | 153 ++++++++++++++++++ src/UIDrawable.h | 8 +- src/UIEntity.cpp | 143 ++++++++++++++++- src/UIEntity.h | 7 +- src/UIFrame.cpp | 21 +++ src/UIFrame.h | 2 + src/UIGrid.cpp | 17 ++ src/UIGrid.h | 2 + src/UILine.cpp | 18 +++ src/UILine.h | 2 + src/UISprite.cpp | 14 ++ src/UISprite.h | 1 + tests/unit/animate_method_test.py | 248 ++++++++++++++++++++++++++++++ 21 files changed, 738 insertions(+), 11 deletions(-) create mode 100644 tests/unit/animate_method_test.py diff --git a/src/Animation.cpp b/src/Animation.cpp index adef173..de298ec 100644 --- a/src/Animation.cpp +++ b/src/Animation.cpp @@ -40,17 +40,18 @@ Animation::Animation(const std::string& targetProperty, Animation::~Animation() { // Decrease reference count for Python callback if we still own it + // Guard with Py_IsInitialized() because destructor may run during interpreter shutdown PyObject* callback = pythonCallback; - if (callback) { + if (callback && Py_IsInitialized()) { pythonCallback = nullptr; - + PyGILState_STATE gstate = PyGILState_Ensure(); Py_DECREF(callback); PyGILState_Release(gstate); } - - // Clean up cache entry - if (serial_number != 0) { + + // Clean up cache entry (also guard - PythonObjectCache may use Python) + if (serial_number != 0 && Py_IsInitialized()) { PythonObjectCache::getInstance().remove(serial_number); } } diff --git a/src/UIArc.cpp b/src/UIArc.cpp index 62a95c1..e2e93ae 100644 --- a/src/UIArc.cpp +++ b/src/UIArc.cpp @@ -303,6 +303,23 @@ bool UIArc::getProperty(const std::string& name, sf::Vector2f& value) const { return false; } +bool UIArc::hasProperty(const std::string& name) const { + // Float properties + if (name == "radius" || name == "start_angle" || name == "end_angle" || + name == "thickness" || name == "x" || name == "y") { + return true; + } + // Color properties + if (name == "color") { + return true; + } + // Vector2f properties + if (name == "center") { + return true; + } + return false; +} + // Python API implementation PyObject* UIArc::get_center(PyUIArcObject* self, void* closure) { auto center = self->data->getCenter(); diff --git a/src/UIArc.h b/src/UIArc.h index 12a248c..d8bef3a 100644 --- a/src/UIArc.h +++ b/src/UIArc.h @@ -85,6 +85,8 @@ public: bool getProperty(const std::string& name, sf::Color& value) const override; bool getProperty(const std::string& name, sf::Vector2f& value) const override; + bool hasProperty(const std::string& name) const override; + // Python API static PyObject* get_center(PyUIArcObject* self, void* closure); static int set_center(PyUIArcObject* self, PyObject* value, void* closure); diff --git a/src/UIBase.h b/src/UIBase.h index f9d4bea..570571b 100644 --- a/src/UIBase.h +++ b/src/UIBase.h @@ -71,13 +71,25 @@ static PyObject* UIDrawable_resize(T* self, PyObject* args) if (!PyArg_ParseTuple(args, "ff", &w, &h)) { return NULL; } - + self->data->resize(w, h); Py_RETURN_NONE; } -// Macro to add common UIDrawable methods to a method array -#define UIDRAWABLE_METHODS \ +// animate method implementation - shorthand for creating and starting animations +// This free function is implemented in UIDrawable.cpp +// We use a free function instead of UIDrawable::animate_helper to avoid incomplete type issues +class UIDrawable; +PyObject* UIDrawable_animate_impl(std::shared_ptr target, PyObject* args, PyObject* kwds); + +template +static PyObject* UIDrawable_animate(T* self, PyObject* args, PyObject* kwds) +{ + return UIDrawable_animate_impl(self->data, args, kwds); +} + +// Macro to add common UIDrawable methods to a method array (without animate - for base types) +#define UIDRAWABLE_METHODS_BASE \ {"get_bounds", (PyCFunction)UIDrawable_get_bounds, METH_NOARGS, \ MCRF_METHOD(Drawable, get_bounds, \ MCRF_SIG("()", "tuple"), \ @@ -104,6 +116,29 @@ static PyObject* UIDrawable_resize(T* self, PyObject* args) MCRF_NOTE("For Caption and Sprite, this may not change actual size if determined by content.") \ )} +// Macro to add common UIDrawable methods to a method array (includes animate for UIDrawable derivatives) +#define UIDRAWABLE_METHODS \ + UIDRAWABLE_METHODS_BASE, \ + {"animate", (PyCFunction)UIDrawable_animate, METH_VARARGS | METH_KEYWORDS, \ + MCRF_METHOD(Drawable, animate, \ + MCRF_SIG("(property: str, target: Any, duration: float, easing=None, delta=False, callback=None, conflict_mode='replace')", "Animation"), \ + MCRF_DESC("Create and start an animation on this drawable's property."), \ + MCRF_ARGS_START \ + MCRF_ARG("property", "Name of the property to animate (e.g., 'x', 'fill_color', 'opacity')") \ + MCRF_ARG("target", "Target value - type depends on property (float, tuple for color/vector, etc.)") \ + MCRF_ARG("duration", "Animation duration in seconds") \ + MCRF_ARG("easing", "Easing function: Easing enum value, string name, or None for linear") \ + MCRF_ARG("delta", "If True, target is relative to current value; if False, target is absolute") \ + MCRF_ARG("callback", "Optional callable invoked when animation completes") \ + MCRF_ARG("conflict_mode", "'replace' (default), 'queue', or 'error' if property already animating") \ + MCRF_RETURNS("Animation object for monitoring progress") \ + MCRF_RAISES("ValueError", "If property name is not valid for this drawable type") \ + MCRF_NOTE("This is a convenience method that creates an Animation, starts it, and adds it to the AnimationManager.") \ + )} + +// Legacy macro for backwards compatibility - same as UIDRAWABLE_METHODS +#define UIDRAWABLE_METHODS_FULL UIDRAWABLE_METHODS + // Property getters/setters for visible and opacity template static PyObject* UIDrawable_get_visible(T* self, void* closure) diff --git a/src/UICaption.cpp b/src/UICaption.cpp index 2a76175..155342a 100644 --- a/src/UICaption.cpp +++ b/src/UICaption.cpp @@ -657,3 +657,24 @@ bool UICaption::getProperty(const std::string& name, std::string& value) const { return false; } +bool UICaption::hasProperty(const std::string& name) const { + // Float properties + if (name == "x" || name == "y" || + name == "font_size" || name == "size" || name == "outline" || + name == "fill_color.r" || name == "fill_color.g" || + name == "fill_color.b" || name == "fill_color.a" || + name == "outline_color.r" || name == "outline_color.g" || + name == "outline_color.b" || name == "outline_color.a") { + return true; + } + // Color properties + if (name == "fill_color" || name == "outline_color") { + return true; + } + // String properties + if (name == "text") { + return true; + } + return false; +} + diff --git a/src/UICaption.h b/src/UICaption.h index 851b960..a760ab0 100644 --- a/src/UICaption.h +++ b/src/UICaption.h @@ -28,6 +28,8 @@ public: bool getProperty(const std::string& name, sf::Color& value) const override; bool getProperty(const std::string& name, std::string& value) const override; + bool hasProperty(const std::string& name) const override; + static PyObject* get_float_member(PyUICaptionObject* self, void* closure); static int set_float_member(PyUICaptionObject* self, PyObject* value, void* closure); static PyObject* get_vec_member(PyUICaptionObject* self, void* closure); diff --git a/src/UICircle.cpp b/src/UICircle.cpp index d190cc4..7cfa5da 100644 --- a/src/UICircle.cpp +++ b/src/UICircle.cpp @@ -248,6 +248,23 @@ bool UICircle::getProperty(const std::string& name, sf::Vector2f& value) const { return false; } +bool UICircle::hasProperty(const std::string& name) const { + // Float properties + if (name == "radius" || name == "outline" || + name == "x" || name == "y") { + return true; + } + // Color properties + if (name == "fill_color" || name == "outline_color") { + return true; + } + // Vector2f properties + if (name == "center" || name == "position") { + return true; + } + return false; +} + // Python API implementations PyObject* UICircle::get_radius(PyUICircleObject* self, void* closure) { return PyFloat_FromDouble(self->data->getRadius()); diff --git a/src/UICircle.h b/src/UICircle.h index cb9afe8..5210b2e 100644 --- a/src/UICircle.h +++ b/src/UICircle.h @@ -77,6 +77,8 @@ public: bool getProperty(const std::string& name, sf::Color& value) const override; bool getProperty(const std::string& name, sf::Vector2f& value) const override; + bool hasProperty(const std::string& name) const override; + // Python API static PyObject* get_radius(PyUICircleObject* self, void* closure); static int set_radius(PyUICircleObject* self, PyObject* value, void* closure); diff --git a/src/UIDrawable.cpp b/src/UIDrawable.cpp index 441e57d..7994e90 100644 --- a/src/UIDrawable.cpp +++ b/src/UIDrawable.cpp @@ -9,6 +9,9 @@ #include "GameEngine.h" #include "McRFPy_API.h" #include "PythonObjectCache.h" +#include "Animation.h" +#include "PyAnimation.h" +#include "PyEasing.h" UIDrawable::UIDrawable() : position(0.0f, 0.0f) { click_callable = NULL; } @@ -1441,3 +1444,153 @@ int UIDrawable::set_on_move(PyObject* self, PyObject* value, void* closure) { } return 0; } + +// Animation shorthand helper - creates and starts an animation on a UIDrawable +// This is a free function (not a member) to avoid incomplete type issues in UIBase.h template +PyObject* UIDrawable_animate_impl(std::shared_ptr self, PyObject* args, PyObject* kwds) { + static const char* keywords[] = {"property", "target", "duration", "easing", "delta", "callback", "conflict_mode", nullptr}; + + const char* property_name; + PyObject* target_value; + float duration; + PyObject* easing_arg = Py_None; + int delta = 0; + PyObject* callback = nullptr; + const char* conflict_mode_str = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|OpOs", const_cast(keywords), + &property_name, &target_value, &duration, + &easing_arg, &delta, &callback, &conflict_mode_str)) { + return NULL; + } + + // Validate property exists on this drawable + if (!self->hasProperty(property_name)) { + PyErr_Format(PyExc_ValueError, + "Property '%s' is not valid for animation on this object. " + "Check spelling or use a supported property name.", + property_name); + return NULL; + } + + // Validate callback is callable if provided + if (callback && callback != Py_None && !PyCallable_Check(callback)) { + PyErr_SetString(PyExc_TypeError, "callback must be callable"); + return NULL; + } + + // Convert None to nullptr for C++ + if (callback == Py_None) { + callback = nullptr; + } + + // Convert Python target value to AnimationValue + AnimationValue animValue; + + if (PyFloat_Check(target_value)) { + animValue = static_cast(PyFloat_AsDouble(target_value)); + } + else if (PyLong_Check(target_value)) { + animValue = static_cast(PyLong_AsLong(target_value)); + } + else if (PyList_Check(target_value)) { + // List of integers for sprite animation + std::vector indices; + Py_ssize_t size = PyList_Size(target_value); + for (Py_ssize_t i = 0; i < size; i++) { + PyObject* item = PyList_GetItem(target_value, i); + if (PyLong_Check(item)) { + indices.push_back(PyLong_AsLong(item)); + } else { + PyErr_SetString(PyExc_TypeError, "Sprite animation list must contain only integers"); + return NULL; + } + } + animValue = indices; + } + else if (PyTuple_Check(target_value)) { + Py_ssize_t size = PyTuple_Size(target_value); + if (size == 2) { + // Vector2f + float x = PyFloat_AsDouble(PyTuple_GetItem(target_value, 0)); + float y = PyFloat_AsDouble(PyTuple_GetItem(target_value, 1)); + if (PyErr_Occurred()) return NULL; + animValue = sf::Vector2f(x, y); + } + else if (size == 3 || size == 4) { + // Color (RGB or RGBA) + int r = PyLong_AsLong(PyTuple_GetItem(target_value, 0)); + int g = PyLong_AsLong(PyTuple_GetItem(target_value, 1)); + int b = PyLong_AsLong(PyTuple_GetItem(target_value, 2)); + int a = size == 4 ? PyLong_AsLong(PyTuple_GetItem(target_value, 3)) : 255; + if (PyErr_Occurred()) return NULL; + animValue = sf::Color(r, g, b, a); + } + else { + PyErr_SetString(PyExc_ValueError, "Tuple must have 2 elements (vector) or 3-4 elements (color)"); + return NULL; + } + } + else if (PyUnicode_Check(target_value)) { + // String for text animation + const char* str = PyUnicode_AsUTF8(target_value); + animValue = std::string(str); + } + else { + PyErr_SetString(PyExc_TypeError, "Target value must be float, int, list, tuple, or string"); + return NULL; + } + + // Get easing function from argument + EasingFunction easingFunc; + if (!PyEasing::from_arg(easing_arg, &easingFunc, nullptr)) { + return NULL; // Error already set by from_arg + } + + // Parse conflict mode + AnimationConflictMode conflict_mode = AnimationConflictMode::REPLACE; + if (conflict_mode_str) { + if (strcmp(conflict_mode_str, "replace") == 0) { + conflict_mode = AnimationConflictMode::REPLACE; + } else if (strcmp(conflict_mode_str, "queue") == 0) { + conflict_mode = AnimationConflictMode::QUEUE; + } else if (strcmp(conflict_mode_str, "error") == 0) { + conflict_mode = AnimationConflictMode::ERROR; + } else { + PyErr_Format(PyExc_ValueError, + "Invalid conflict_mode '%s'. Must be 'replace', 'queue', or 'error'.", conflict_mode_str); + return NULL; + } + } + + // Create the Animation + auto animation = std::make_shared(property_name, animValue, duration, easingFunc, delta != 0, callback); + + // Start on this drawable + animation->start(self); + + // Add to AnimationManager + AnimationManager::getInstance().addAnimation(animation, conflict_mode); + + // Check if ERROR mode raised an exception + if (PyErr_Occurred()) { + return NULL; + } + + // Create and return a PyAnimation wrapper + PyTypeObject* animType = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Animation"); + if (!animType) { + PyErr_SetString(PyExc_RuntimeError, "Could not find Animation type"); + return NULL; + } + + PyAnimationObject* pyAnim = (PyAnimationObject*)animType->tp_alloc(animType, 0); + Py_DECREF(animType); + + if (!pyAnim) { + return NULL; + } + + pyAnim->data = animation; + return (PyObject*)pyAnim; +} diff --git a/src/UIDrawable.h b/src/UIDrawable.h index 90acc8c..484117f 100644 --- a/src/UIDrawable.h +++ b/src/UIDrawable.h @@ -156,7 +156,13 @@ public: virtual bool getProperty(const std::string& name, sf::Color& value) const { return false; } virtual bool getProperty(const std::string& name, sf::Vector2f& value) const { return false; } virtual bool getProperty(const std::string& name, std::string& value) const { return false; } - + + // Check if a property name is valid for animation on this drawable type + virtual bool hasProperty(const std::string& name) const { return false; } + + // Note: animate_helper is now a free function (UIDrawable_animate_impl) declared in UIBase.h + // to avoid incomplete type issues with template instantiation. + // Python object cache support uint64_t serial_number = 0; diff --git a/src/UIEntity.cpp b/src/UIEntity.cpp index bc6b5dc..da5b8cb 100644 --- a/src/UIEntity.cpp +++ b/src/UIEntity.cpp @@ -2,10 +2,14 @@ #include "UIGrid.h" #include "McRFPy_API.h" #include +#include #include "PyObjectUtils.h" #include "PyVector.h" #include "PythonObjectCache.h" #include "PyFOV.h" +#include "Animation.h" +#include "PyAnimation.h" +#include "PyEasing.h" // UIDrawable methods now in UIBase.h #include "UIEntityPyMethods.h" @@ -775,8 +779,26 @@ PyMethodDef UIEntity::methods[] = { typedef PyUIEntityObject PyObjectType; // Combine base methods with entity-specific methods +// Note: Use UIDRAWABLE_METHODS_BASE (not UIDRAWABLE_METHODS) because UIEntity is NOT a UIDrawable +// and the template-based animate helper won't work. Entity has its own animate() method. PyMethodDef UIEntity_all_methods[] = { - UIDRAWABLE_METHODS, + UIDRAWABLE_METHODS_BASE, + {"animate", (PyCFunction)UIEntity::animate, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(Entity, animate, + MCRF_SIG("(property: str, target: Any, duration: float, easing=None, delta=False, callback=None, conflict_mode='replace')", "Animation"), + MCRF_DESC("Create and start an animation on this entity's property."), + MCRF_ARGS_START + MCRF_ARG("property", "Name of the property to animate (e.g., 'x', 'y', 'sprite_index')") + MCRF_ARG("target", "Target value - float or int depending on property") + MCRF_ARG("duration", "Animation duration in seconds") + MCRF_ARG("easing", "Easing function: Easing enum value, string name, or None for linear") + MCRF_ARG("delta", "If True, target is relative to current value; if False, target is absolute") + MCRF_ARG("callback", "Optional callable invoked when animation completes") + MCRF_ARG("conflict_mode", "'replace' (default), 'queue', or 'error' if property already animating") + MCRF_RETURNS("Animation object for monitoring progress") + MCRF_RAISES("ValueError", "If property name is not valid for Entity (x, y, sprite_scale, sprite_index)") + MCRF_NOTE("Entity animations use grid coordinates for x/y, not pixel coordinates.") + )}, {"at", (PyCFunction)UIEntity::at, METH_O}, {"index", (PyCFunction)UIEntity::index, METH_NOARGS, "Return the index of this entity in its grid's entity collection"}, {"die", (PyCFunction)UIEntity::die, METH_NOARGS, "Remove this entity from its grid"}, @@ -885,3 +907,122 @@ bool UIEntity::getProperty(const std::string& name, float& value) const { } return false; } + +bool UIEntity::hasProperty(const std::string& name) const { + // Float properties + if (name == "x" || name == "y" || name == "sprite_scale") { + return true; + } + // Int properties + if (name == "sprite_index" || name == "sprite_number") { + return true; + } + return false; +} + +// Animation shorthand for Entity - creates and starts an animation +PyObject* UIEntity::animate(PyUIEntityObject* self, PyObject* args, PyObject* kwds) { + static const char* keywords[] = {"property", "target", "duration", "easing", "delta", "callback", "conflict_mode", nullptr}; + + const char* property_name; + PyObject* target_value; + float duration; + PyObject* easing_arg = Py_None; + int delta = 0; + PyObject* callback = nullptr; + const char* conflict_mode_str = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|OpOs", const_cast(keywords), + &property_name, &target_value, &duration, + &easing_arg, &delta, &callback, &conflict_mode_str)) { + return NULL; + } + + // Validate property exists on this entity + if (!self->data->hasProperty(property_name)) { + PyErr_Format(PyExc_ValueError, + "Property '%s' is not valid for animation on Entity. " + "Valid properties: x, y, sprite_scale, sprite_index, sprite_number", + property_name); + return NULL; + } + + // Validate callback is callable if provided + if (callback && callback != Py_None && !PyCallable_Check(callback)) { + PyErr_SetString(PyExc_TypeError, "callback must be callable"); + return NULL; + } + + // Convert None to nullptr for C++ + if (callback == Py_None) { + callback = nullptr; + } + + // Convert Python target value to AnimationValue + // Entity only supports float and int properties + AnimationValue animValue; + + if (PyFloat_Check(target_value)) { + animValue = static_cast(PyFloat_AsDouble(target_value)); + } + else if (PyLong_Check(target_value)) { + animValue = static_cast(PyLong_AsLong(target_value)); + } + else { + PyErr_SetString(PyExc_TypeError, "Entity animations only support float or int target values"); + return NULL; + } + + // Get easing function from argument + EasingFunction easingFunc; + if (!PyEasing::from_arg(easing_arg, &easingFunc, nullptr)) { + return NULL; // Error already set by from_arg + } + + // Parse conflict mode + AnimationConflictMode conflict_mode = AnimationConflictMode::REPLACE; + if (conflict_mode_str) { + if (strcmp(conflict_mode_str, "replace") == 0) { + conflict_mode = AnimationConflictMode::REPLACE; + } else if (strcmp(conflict_mode_str, "queue") == 0) { + conflict_mode = AnimationConflictMode::QUEUE; + } else if (strcmp(conflict_mode_str, "error") == 0) { + conflict_mode = AnimationConflictMode::ERROR; + } else { + PyErr_Format(PyExc_ValueError, + "Invalid conflict_mode '%s'. Must be 'replace', 'queue', or 'error'.", conflict_mode_str); + return NULL; + } + } + + // Create the Animation + auto animation = std::make_shared(property_name, animValue, duration, easingFunc, delta != 0, callback); + + // Start on this entity (uses startEntity, not start) + animation->startEntity(self->data); + + // Add to AnimationManager + AnimationManager::getInstance().addAnimation(animation, conflict_mode); + + // Check if ERROR mode raised an exception + if (PyErr_Occurred()) { + return NULL; + } + + // Create and return a PyAnimation wrapper + PyTypeObject* animType = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Animation"); + if (!animType) { + PyErr_SetString(PyExc_RuntimeError, "Could not find Animation type"); + return NULL; + } + + PyAnimationObject* pyAnim = (PyAnimationObject*)animType->tp_alloc(animType, 0); + Py_DECREF(animType); + + if (!pyAnim) { + return NULL; + } + + pyAnim->data = animation; + return (PyObject*)pyAnim; +} diff --git a/src/UIEntity.h b/src/UIEntity.h index 3bdc9fc..f3313d9 100644 --- a/src/UIEntity.h +++ b/src/UIEntity.h @@ -78,7 +78,12 @@ public: bool setProperty(const std::string& name, float value); bool setProperty(const std::string& name, int value); bool getProperty(const std::string& name, float& value) const; - + bool hasProperty(const std::string& name) const; + + // Animation shorthand helper - creates and starts an animation on this entity + // Returns a PyAnimation object. Used by the .animate() method. + static PyObject* animate(PyUIEntityObject* self, PyObject* args, PyObject* kwds); + // Methods that delegate to sprite sf::FloatRect get_bounds() const { return sprite.get_bounds(); } void move(float dx, float dy) { sprite.move(dx, dy); position.x += dx; position.y += dy; } diff --git a/src/UIFrame.cpp b/src/UIFrame.cpp index b667c78..7103212 100644 --- a/src/UIFrame.cpp +++ b/src/UIFrame.cpp @@ -885,3 +885,24 @@ bool UIFrame::getProperty(const std::string& name, sf::Vector2f& value) const { } return false; } + +bool UIFrame::hasProperty(const std::string& name) const { + // Float properties + if (name == "x" || name == "y" || name == "w" || name == "h" || + name == "outline" || + name == "fill_color.r" || name == "fill_color.g" || + name == "fill_color.b" || name == "fill_color.a" || + name == "outline_color.r" || name == "outline_color.g" || + name == "outline_color.b" || name == "outline_color.a") { + return true; + } + // Color properties + if (name == "fill_color" || name == "outline_color") { + return true; + } + // Vector2f properties + if (name == "position" || name == "size") { + return true; + } + return false; +} diff --git a/src/UIFrame.h b/src/UIFrame.h index fb93a20..fd85939 100644 --- a/src/UIFrame.h +++ b/src/UIFrame.h @@ -67,6 +67,8 @@ public: bool getProperty(const std::string& name, float& value) const override; bool getProperty(const std::string& name, sf::Color& value) const override; bool getProperty(const std::string& name, sf::Vector2f& value) const override; + + bool hasProperty(const std::string& name) const override; }; // Forward declaration of methods array diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index c3de431..dfdf809 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -3436,3 +3436,20 @@ bool UIGrid::getProperty(const std::string& name, sf::Vector2f& value) const { } return false; } + +bool UIGrid::hasProperty(const std::string& name) const { + // Float properties + if (name == "x" || name == "y" || + name == "w" || name == "h" || name == "width" || name == "height" || + name == "center_x" || name == "center_y" || name == "zoom" || + name == "z_index" || + name == "fill_color.r" || name == "fill_color.g" || + name == "fill_color.b" || name == "fill_color.a") { + return true; + } + // Vector2f properties + if (name == "position" || name == "size" || name == "center") { + return true; + } + return false; +} diff --git a/src/UIGrid.h b/src/UIGrid.h index 3751466..62bb1b0 100644 --- a/src/UIGrid.h +++ b/src/UIGrid.h @@ -138,6 +138,8 @@ public: bool getProperty(const std::string& name, float& value) const override; bool getProperty(const std::string& name, sf::Vector2f& value) const override; + bool hasProperty(const std::string& name) const override; + static int init(PyUIGridObject* self, PyObject* args, PyObject* kwds); static PyObject* get_grid_size(PyUIGridObject* self, void* closure); static PyObject* get_grid_x(PyUIGridObject* self, void* closure); diff --git a/src/UILine.cpp b/src/UILine.cpp index 7920b6e..a08429c 100644 --- a/src/UILine.cpp +++ b/src/UILine.cpp @@ -327,6 +327,24 @@ bool UILine::getProperty(const std::string& name, sf::Vector2f& value) const { return false; } +bool UILine::hasProperty(const std::string& name) const { + // Float properties + if (name == "thickness" || name == "x" || name == "y" || + name == "start_x" || name == "start_y" || + name == "end_x" || name == "end_y") { + return true; + } + // Color properties + if (name == "color") { + return true; + } + // Vector2f properties + if (name == "start" || name == "end") { + return true; + } + return false; +} + // Python API implementation PyObject* UILine::get_start(PyUILineObject* self, void* closure) { auto vec = self->data->getStart(); diff --git a/src/UILine.h b/src/UILine.h index 29f3707..60a33c3 100644 --- a/src/UILine.h +++ b/src/UILine.h @@ -76,6 +76,8 @@ public: bool getProperty(const std::string& name, sf::Color& value) const override; bool getProperty(const std::string& name, sf::Vector2f& value) const override; + bool hasProperty(const std::string& name) const override; + // Python API static PyObject* get_start(PyUILineObject* self, void* closure); static int set_start(PyUILineObject* self, PyObject* value, void* closure); diff --git a/src/UISprite.cpp b/src/UISprite.cpp index b243360..638453c 100644 --- a/src/UISprite.cpp +++ b/src/UISprite.cpp @@ -626,3 +626,17 @@ bool UISprite::getProperty(const std::string& name, int& value) const { } return false; } + +bool UISprite::hasProperty(const std::string& name) const { + // Float properties + if (name == "x" || name == "y" || + name == "scale" || name == "scale_x" || name == "scale_y" || + name == "z_index") { + return true; + } + // Int properties + if (name == "sprite_index" || name == "sprite_number") { + return true; + } + return false; +} diff --git a/src/UISprite.h b/src/UISprite.h index 9e99d25..03128a8 100644 --- a/src/UISprite.h +++ b/src/UISprite.h @@ -63,6 +63,7 @@ public: bool getProperty(const std::string& name, float& value) const override; bool getProperty(const std::string& name, int& value) const override; + bool hasProperty(const std::string& name) const override; static PyObject* get_float_member(PyUISpriteObject* self, void* closure); static int set_float_member(PyUISpriteObject* self, PyObject* value, void* closure); diff --git a/tests/unit/animate_method_test.py b/tests/unit/animate_method_test.py new file mode 100644 index 0000000..f7bd58b --- /dev/null +++ b/tests/unit/animate_method_test.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Test the new .animate() shorthand method on UIDrawable and UIEntity. + +This tests issue #177 - ergonomic animation API. +""" +import mcrfpy +import sys + +def test_frame_animate(): + """Test animate() on Frame""" + frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200)) + + # Basic float property animation + anim = frame.animate("x", 300.0, 1.0) + assert anim is not None, "animate() should return an Animation object" + + # Color property animation + anim2 = frame.animate("fill_color", (255, 0, 0, 255), 0.5) + assert anim2 is not None + + # Vector property animation + anim3 = frame.animate("position", (400.0, 400.0), 0.5) + assert anim3 is not None + + print(" Frame animate() - PASS") + +def test_caption_animate(): + """Test animate() on Caption""" + caption = mcrfpy.Caption(text="Hello", pos=(50, 50)) + + # Position animation + anim = caption.animate("y", 200.0, 1.0) + assert anim is not None + + # Font size animation + anim2 = caption.animate("font_size", 24.0, 0.5) + assert anim2 is not None + + print(" Caption animate() - PASS") + +def test_sprite_animate(): + """Test animate() on Sprite""" + # Create with default texture + sprite = mcrfpy.Sprite(pos=(100, 100)) + + # Scale animation + anim = sprite.animate("scale", 2.0, 1.0) + assert anim is not None + + # Sprite index animation + anim2 = sprite.animate("sprite_index", 5, 0.5) + assert anim2 is not None + + print(" Sprite animate() - PASS") + +def test_grid_animate(): + """Test animate() on Grid""" + grid = mcrfpy.Grid(grid_size=(10, 10), pos=(50, 50), size=(300, 300)) + + # Zoom animation + anim = grid.animate("zoom", 2.0, 1.0) + assert anim is not None + + # Center animation + anim2 = grid.animate("center", (100.0, 100.0), 0.5) + assert anim2 is not None + + print(" Grid animate() - PASS") + +def test_entity_animate(): + """Test animate() on Entity""" + grid = mcrfpy.Grid(grid_size=(20, 20)) + entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid) + + # Position animation + anim = entity.animate("x", 10.0, 1.0) + assert anim is not None + + anim2 = entity.animate("y", 15.0, 1.0) + assert anim2 is not None + + # Sprite index animation + anim3 = entity.animate("sprite_index", 3, 0.5) + assert anim3 is not None + + print(" Entity animate() - PASS") + +def test_invalid_property_raises(): + """Test that invalid property names raise ValueError""" + frame = mcrfpy.Frame() + + try: + frame.animate("invalid_property", 100.0, 1.0) + print(" ERROR: Should have raised ValueError for invalid property") + return False + except ValueError as e: + # Should contain the property name in the error message + if "invalid_property" in str(e): + print(" Invalid property detection - PASS") + return True + else: + print(f" ERROR: ValueError message doesn't mention property: {e}") + return False + except Exception as e: + print(f" ERROR: Wrong exception type: {type(e).__name__}: {e}") + return False + +def test_entity_invalid_property(): + """Test that invalid property names raise ValueError for Entity""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + entity = mcrfpy.Entity(grid=grid) + + try: + entity.animate("invalid_property", 100.0, 1.0) + print(" ERROR: Should have raised ValueError for invalid Entity property") + return False + except ValueError as e: + if "invalid_property" in str(e): + print(" Entity invalid property detection - PASS") + return True + else: + print(f" ERROR: ValueError message doesn't mention property: {e}") + return False + except Exception as e: + print(f" ERROR: Wrong exception type: {type(e).__name__}: {e}") + return False + +def test_easing_options(): + """Test various easing parameter formats""" + frame = mcrfpy.Frame() + + # String easing + anim1 = frame.animate("x", 100.0, 1.0, "easeInOut") + assert anim1 is not None, "String easing should work" + + # Easing enum (if available) + try: + anim2 = frame.animate("x", 100.0, 1.0, mcrfpy.Easing.EaseIn) + assert anim2 is not None, "Enum easing should work" + except AttributeError: + pass # Easing enum might not exist + + # None for linear + anim3 = frame.animate("x", 100.0, 1.0, None) + assert anim3 is not None, "None easing (linear) should work" + + print(" Easing options - PASS") + +def test_delta_mode(): + """Test delta=True for relative animations""" + frame = mcrfpy.Frame(pos=(100, 100)) + + # Absolute animation (default) + anim1 = frame.animate("x", 200.0, 1.0, delta=False) + assert anim1 is not None + + # Relative animation + anim2 = frame.animate("x", 50.0, 1.0, delta=True) + assert anim2 is not None + + print(" Delta mode - PASS") + +def test_callback(): + """Test callback parameter""" + frame = mcrfpy.Frame() + callback_called = [False] # Use list for mutability in closure + + def my_callback(): + callback_called[0] = True + + anim = frame.animate("x", 100.0, 0.01, callback=my_callback) + assert anim is not None, "Animation with callback should be created" + + print(" Callback parameter - PASS") + +def run_tests(): + """Run all tests""" + print("Testing .animate() shorthand method:") + + all_passed = True + + # Test UIDrawable types + try: + test_frame_animate() + except Exception as e: + print(f" Frame animate() - FAIL: {e}") + all_passed = False + + try: + test_caption_animate() + except Exception as e: + print(f" Caption animate() - FAIL: {e}") + all_passed = False + + try: + test_sprite_animate() + except Exception as e: + print(f" Sprite animate() - FAIL: {e}") + all_passed = False + + try: + test_grid_animate() + except Exception as e: + print(f" Grid animate() - FAIL: {e}") + all_passed = False + + try: + test_entity_animate() + except Exception as e: + print(f" Entity animate() - FAIL: {e}") + all_passed = False + + # Test property validation + if not test_invalid_property_raises(): + all_passed = False + + if not test_entity_invalid_property(): + all_passed = False + + # Test optional parameters + try: + test_easing_options() + except Exception as e: + print(f" Easing options - FAIL: {e}") + all_passed = False + + try: + test_delta_mode() + except Exception as e: + print(f" Delta mode - FAIL: {e}") + all_passed = False + + try: + test_callback() + except Exception as e: + print(f" Callback parameter - FAIL: {e}") + all_passed = False + + if all_passed: + print("\nAll tests PASSED!") + sys.exit(0) + else: + print("\nSome tests FAILED!") + sys.exit(1) + +# Run tests immediately (no timer needed for this test) +run_tests() +