feat: Animation property locking prevents conflicting animations (closes #120)
Add AnimationConflictMode enum with three modes: - REPLACE (default): Complete existing animation and start new one - QUEUE: Wait for existing animation to complete before starting - ERROR: Raise RuntimeError if property is already being animated Changes: - AnimationManager now tracks property locks per (target, property) pair - Animation.start() accepts optional conflict_mode parameter - Queued animations start automatically when property becomes free - Updated type stubs with ConflictMode type alias 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
366ccecb7d
commit
58efffd2fd
6 changed files with 522 additions and 38 deletions
|
|
@ -631,17 +631,118 @@ AnimationManager& AnimationManager::getInstance() {
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
void AnimationManager::addAnimation(std::shared_ptr<Animation> animation) {
|
void* AnimationManager::getAnimationTarget(const std::shared_ptr<Animation>& anim) const {
|
||||||
if (animation && animation->hasValidTarget()) {
|
return anim ? anim->getTargetPtr() : nullptr;
|
||||||
if (isUpdating) {
|
}
|
||||||
// Defer adding during update to avoid iterator invalidation
|
|
||||||
pendingAnimations.push_back(animation);
|
bool AnimationManager::isPropertyAnimating(void* target, const std::string& property) const {
|
||||||
|
if (!target) return false;
|
||||||
|
PropertyKey key{target, property};
|
||||||
|
auto it = propertyLocks.find(key);
|
||||||
|
if (it == propertyLocks.end()) return false;
|
||||||
|
// Check if the animation is still valid
|
||||||
|
return !it->second.expired();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AnimationManager::cleanupPropertyLocks() {
|
||||||
|
// Remove expired locks
|
||||||
|
for (auto it = propertyLocks.begin(); it != propertyLocks.end(); ) {
|
||||||
|
if (it->second.expired()) {
|
||||||
|
it = propertyLocks.erase(it);
|
||||||
} else {
|
} else {
|
||||||
activeAnimations.push_back(animation);
|
++it;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AnimationManager::processQueue() {
|
||||||
|
// Try to start queued animations whose properties are now free
|
||||||
|
for (auto it = animationQueue.begin(); it != animationQueue.end(); ) {
|
||||||
|
const auto& key = it->first;
|
||||||
|
auto& anim = it->second;
|
||||||
|
|
||||||
|
// Check if property is now free
|
||||||
|
auto lockIt = propertyLocks.find(key);
|
||||||
|
bool propertyFree = (lockIt == propertyLocks.end()) || lockIt->second.expired();
|
||||||
|
|
||||||
|
if (propertyFree && anim && anim->hasValidTarget()) {
|
||||||
|
// Property is free, start the animation
|
||||||
|
propertyLocks[key] = anim;
|
||||||
|
activeAnimations.push_back(anim);
|
||||||
|
it = animationQueue.erase(it);
|
||||||
|
} else if (!anim || !anim->hasValidTarget()) {
|
||||||
|
// Animation target was destroyed, remove from queue
|
||||||
|
it = animationQueue.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AnimationManager::addAnimation(std::shared_ptr<Animation> animation,
|
||||||
|
AnimationConflictMode conflict_mode) {
|
||||||
|
if (!animation || !animation->hasValidTarget()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void* target = getAnimationTarget(animation);
|
||||||
|
std::string property = animation->getTargetProperty();
|
||||||
|
PropertyKey key{target, property};
|
||||||
|
|
||||||
|
// Check for existing animation on this property (#120)
|
||||||
|
auto existingIt = propertyLocks.find(key);
|
||||||
|
bool hasExisting = (existingIt != propertyLocks.end()) && !existingIt->second.expired();
|
||||||
|
|
||||||
|
if (hasExisting) {
|
||||||
|
auto existingAnim = existingIt->second.lock();
|
||||||
|
|
||||||
|
switch (conflict_mode) {
|
||||||
|
case AnimationConflictMode::REPLACE:
|
||||||
|
// Complete the existing animation and replace it
|
||||||
|
if (existingAnim) {
|
||||||
|
existingAnim->complete();
|
||||||
|
// Remove from active animations
|
||||||
|
activeAnimations.erase(
|
||||||
|
std::remove(activeAnimations.begin(), activeAnimations.end(), existingAnim),
|
||||||
|
activeAnimations.end()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fall through to add the new animation
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AnimationConflictMode::QUEUE:
|
||||||
|
// Add to queue - will start when existing completes
|
||||||
|
if (isUpdating) {
|
||||||
|
// Also defer queue additions during update
|
||||||
|
pendingAnimations.push_back(animation);
|
||||||
|
} else {
|
||||||
|
animationQueue.emplace_back(key, animation);
|
||||||
|
}
|
||||||
|
return; // Don't add to active animations yet
|
||||||
|
|
||||||
|
case AnimationConflictMode::ERROR:
|
||||||
|
// Raise Python exception
|
||||||
|
PyGILState_STATE gstate = PyGILState_Ensure();
|
||||||
|
PyErr_Format(PyExc_RuntimeError,
|
||||||
|
"Animation conflict: property '%s' is already being animated on this target. "
|
||||||
|
"Use conflict_mode='replace' to override or 'queue' to wait.",
|
||||||
|
property.c_str());
|
||||||
|
PyGILState_Release(gstate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register property lock and add animation
|
||||||
|
propertyLocks[key] = animation;
|
||||||
|
|
||||||
|
if (isUpdating) {
|
||||||
|
// Defer adding during update to avoid iterator invalidation
|
||||||
|
pendingAnimations.push_back(animation);
|
||||||
|
} else {
|
||||||
|
activeAnimations.push_back(animation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void AnimationManager::update(float deltaTime) {
|
void AnimationManager::update(float deltaTime) {
|
||||||
// Set flag to defer new animations
|
// Set flag to defer new animations
|
||||||
isUpdating = true;
|
isUpdating = true;
|
||||||
|
|
@ -658,11 +759,33 @@ void AnimationManager::update(float deltaTime) {
|
||||||
// Clear update flag
|
// Clear update flag
|
||||||
isUpdating = false;
|
isUpdating = false;
|
||||||
|
|
||||||
|
// Clean up expired property locks (#120)
|
||||||
|
cleanupPropertyLocks();
|
||||||
|
|
||||||
|
// Process queued animations - start any that are now unblocked (#120)
|
||||||
|
processQueue();
|
||||||
|
|
||||||
// Add any animations that were created during update
|
// Add any animations that were created during update
|
||||||
if (!pendingAnimations.empty()) {
|
if (!pendingAnimations.empty()) {
|
||||||
activeAnimations.insert(activeAnimations.end(),
|
// Re-add pending animations through addAnimation to handle conflicts properly
|
||||||
pendingAnimations.begin(),
|
for (auto& anim : pendingAnimations) {
|
||||||
pendingAnimations.end());
|
if (anim && anim->hasValidTarget()) {
|
||||||
|
// Check if this was a queued animation or a new one
|
||||||
|
void* target = getAnimationTarget(anim);
|
||||||
|
std::string property = anim->getTargetProperty();
|
||||||
|
PropertyKey key{target, property};
|
||||||
|
|
||||||
|
// If not already locked, add it
|
||||||
|
auto lockIt = propertyLocks.find(key);
|
||||||
|
if (lockIt == propertyLocks.end() || lockIt->second.expired()) {
|
||||||
|
propertyLocks[key] = anim;
|
||||||
|
activeAnimations.push_back(anim);
|
||||||
|
} else {
|
||||||
|
// Property still locked, re-queue
|
||||||
|
animationQueue.emplace_back(key, anim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
pendingAnimations.clear();
|
pendingAnimations.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -678,4 +801,7 @@ void AnimationManager::clear(bool completeAnimations) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
activeAnimations.clear();
|
activeAnimations.clear();
|
||||||
|
pendingAnimations.clear();
|
||||||
|
animationQueue.clear();
|
||||||
|
propertyLocks.clear();
|
||||||
}
|
}
|
||||||
|
|
@ -12,6 +12,15 @@
|
||||||
class UIDrawable;
|
class UIDrawable;
|
||||||
class UIEntity;
|
class UIEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConflictMode - How to handle multiple animations on the same property (#120)
|
||||||
|
*/
|
||||||
|
enum class AnimationConflictMode {
|
||||||
|
REPLACE, // Stop/complete existing animation, start new one (default)
|
||||||
|
QUEUE, // Queue new animation to run after existing one completes
|
||||||
|
ERROR // Raise an error if property is already being animated
|
||||||
|
};
|
||||||
|
|
||||||
// Forward declare namespace
|
// Forward declare namespace
|
||||||
namespace EasingFunctions {
|
namespace EasingFunctions {
|
||||||
float linear(float t);
|
float linear(float t);
|
||||||
|
|
@ -72,6 +81,13 @@ public:
|
||||||
bool isComplete() const { return elapsed >= duration; }
|
bool isComplete() const { return elapsed >= duration; }
|
||||||
bool isDelta() const { return delta; }
|
bool isDelta() const { return delta; }
|
||||||
|
|
||||||
|
// Get raw target pointer for property locking (#120)
|
||||||
|
void* getTargetPtr() const {
|
||||||
|
if (auto sp = targetWeak.lock()) return sp.get();
|
||||||
|
if (auto sp = entityTargetWeak.lock()) return sp.get();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::string targetProperty; // Property name to animate (e.g., "x", "color.r", "sprite_number")
|
std::string targetProperty; // Property name to animate (e.g., "x", "color.r", "sprite_number")
|
||||||
AnimationValue startValue; // Starting value (captured when animation starts)
|
AnimationValue startValue; // Starting value (captured when animation starts)
|
||||||
|
|
@ -159,7 +175,9 @@ public:
|
||||||
static AnimationManager& getInstance();
|
static AnimationManager& getInstance();
|
||||||
|
|
||||||
// Add an animation to be managed
|
// Add an animation to be managed
|
||||||
void addAnimation(std::shared_ptr<Animation> animation);
|
// conflict_mode determines behavior when property is already animated (#120)
|
||||||
|
void addAnimation(std::shared_ptr<Animation> animation,
|
||||||
|
AnimationConflictMode conflict_mode = AnimationConflictMode::REPLACE);
|
||||||
|
|
||||||
// Update all animations
|
// Update all animations
|
||||||
void update(float deltaTime);
|
void update(float deltaTime);
|
||||||
|
|
@ -167,9 +185,52 @@ public:
|
||||||
// Clear all animations (optionally completing them first)
|
// Clear all animations (optionally completing them first)
|
||||||
void clear(bool completeAnimations = false);
|
void clear(bool completeAnimations = false);
|
||||||
|
|
||||||
|
// Get/set default conflict mode
|
||||||
|
AnimationConflictMode getDefaultConflictMode() const { return defaultConflictMode; }
|
||||||
|
void setDefaultConflictMode(AnimationConflictMode mode) { defaultConflictMode = mode; }
|
||||||
|
|
||||||
|
// Check if a property is currently being animated on a target
|
||||||
|
bool isPropertyAnimating(void* target, const std::string& property) const;
|
||||||
|
|
||||||
|
// Get active animation count (for debugging/testing)
|
||||||
|
size_t getActiveAnimationCount() const { return activeAnimations.size(); }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
AnimationManager() = default;
|
AnimationManager() = default;
|
||||||
std::vector<std::shared_ptr<Animation>> activeAnimations;
|
std::vector<std::shared_ptr<Animation>> activeAnimations;
|
||||||
std::vector<std::shared_ptr<Animation>> pendingAnimations; // Animations to add after update
|
std::vector<std::shared_ptr<Animation>> pendingAnimations; // Animations to add after update
|
||||||
bool isUpdating = false; // Flag to track if we're in update loop
|
bool isUpdating = false; // Flag to track if we're in update loop
|
||||||
|
AnimationConflictMode defaultConflictMode = AnimationConflictMode::REPLACE;
|
||||||
|
|
||||||
|
// Property lock tracking for conflict detection (#120)
|
||||||
|
// Key: (target_ptr, property_name) -> weak reference to active animation
|
||||||
|
struct PropertyKey {
|
||||||
|
void* target;
|
||||||
|
std::string property;
|
||||||
|
|
||||||
|
bool operator==(const PropertyKey& other) const {
|
||||||
|
return target == other.target && property == other.property;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PropertyKeyHash {
|
||||||
|
size_t operator()(const PropertyKey& key) const {
|
||||||
|
return std::hash<void*>()(key.target) ^
|
||||||
|
(std::hash<std::string>()(key.property) << 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
std::unordered_map<PropertyKey, std::weak_ptr<Animation>, PropertyKeyHash> propertyLocks;
|
||||||
|
|
||||||
|
// Queued animations waiting for property to become available
|
||||||
|
std::vector<std::pair<PropertyKey, std::shared_ptr<Animation>>> animationQueue;
|
||||||
|
|
||||||
|
// Helper to get target pointer from animation
|
||||||
|
void* getAnimationTarget(const std::shared_ptr<Animation>& anim) const;
|
||||||
|
|
||||||
|
// Clean up expired property locks
|
||||||
|
void cleanupPropertyLocks();
|
||||||
|
|
||||||
|
// Process queued animations
|
||||||
|
void processQueue();
|
||||||
};
|
};
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
#include "UIGrid.h"
|
#include "UIGrid.h"
|
||||||
#include "UIEntity.h"
|
#include "UIEntity.h"
|
||||||
#include "UI.h" // For the PyTypeObject definitions
|
#include "UI.h" // For the PyTypeObject definitions
|
||||||
#include <cstring>
|
#include <cstring> // For strcmp in parseConflictMode
|
||||||
|
|
||||||
PyObject* PyAnimation::create(PyTypeObject* type, PyObject* args, PyObject* kwds) {
|
PyObject* PyAnimation::create(PyTypeObject* type, PyObject* args, PyObject* kwds) {
|
||||||
PyAnimationObject* self = (PyAnimationObject*)type->tp_alloc(type, 0);
|
PyAnimationObject* self = (PyAnimationObject*)type->tp_alloc(type, 0);
|
||||||
|
|
@ -133,12 +133,38 @@ PyObject* PyAnimation::get_is_delta(PyAnimationObject* self, void* closure) {
|
||||||
return PyBool_FromLong(self->data->isDelta());
|
return PyBool_FromLong(self->data->isDelta());
|
||||||
}
|
}
|
||||||
|
|
||||||
PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) {
|
// Helper to convert Python string to AnimationConflictMode
|
||||||
|
static bool parseConflictMode(const char* mode_str, AnimationConflictMode& mode) {
|
||||||
|
if (!mode_str || strcmp(mode_str, "replace") == 0) {
|
||||||
|
mode = AnimationConflictMode::REPLACE;
|
||||||
|
} else if (strcmp(mode_str, "queue") == 0) {
|
||||||
|
mode = AnimationConflictMode::QUEUE;
|
||||||
|
} else if (strcmp(mode_str, "error") == 0) {
|
||||||
|
mode = AnimationConflictMode::ERROR;
|
||||||
|
} else {
|
||||||
|
PyErr_Format(PyExc_ValueError,
|
||||||
|
"Invalid conflict_mode '%s'. Must be 'replace', 'queue', or 'error'.", mode_str);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args, PyObject* kwds) {
|
||||||
|
static const char* kwlist[] = {"target", "conflict_mode", nullptr};
|
||||||
PyObject* target_obj;
|
PyObject* target_obj;
|
||||||
if (!PyArg_ParseTuple(args, "O", &target_obj)) {
|
const char* conflict_mode_str = nullptr;
|
||||||
|
|
||||||
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|s", const_cast<char**>(kwlist),
|
||||||
|
&target_obj, &conflict_mode_str)) {
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse conflict mode
|
||||||
|
AnimationConflictMode conflict_mode;
|
||||||
|
if (!parseConflictMode(conflict_mode_str, conflict_mode)) {
|
||||||
|
return NULL; // Error already set
|
||||||
|
}
|
||||||
|
|
||||||
// Get type objects from the module to ensure they're initialized
|
// Get type objects from the module to ensure they're initialized
|
||||||
PyObject* frame_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame");
|
PyObject* frame_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame");
|
||||||
PyObject* caption_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption");
|
PyObject* caption_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption");
|
||||||
|
|
@ -153,7 +179,7 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) {
|
||||||
PyUIFrameObject* frame = (PyUIFrameObject*)target_obj;
|
PyUIFrameObject* frame = (PyUIFrameObject*)target_obj;
|
||||||
if (frame->data) {
|
if (frame->data) {
|
||||||
self->data->start(frame->data);
|
self->data->start(frame->data);
|
||||||
AnimationManager::getInstance().addAnimation(self->data);
|
AnimationManager::getInstance().addAnimation(self->data, conflict_mode);
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -161,7 +187,7 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) {
|
||||||
PyUICaptionObject* caption = (PyUICaptionObject*)target_obj;
|
PyUICaptionObject* caption = (PyUICaptionObject*)target_obj;
|
||||||
if (caption->data) {
|
if (caption->data) {
|
||||||
self->data->start(caption->data);
|
self->data->start(caption->data);
|
||||||
AnimationManager::getInstance().addAnimation(self->data);
|
AnimationManager::getInstance().addAnimation(self->data, conflict_mode);
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -169,7 +195,7 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) {
|
||||||
PyUISpriteObject* sprite = (PyUISpriteObject*)target_obj;
|
PyUISpriteObject* sprite = (PyUISpriteObject*)target_obj;
|
||||||
if (sprite->data) {
|
if (sprite->data) {
|
||||||
self->data->start(sprite->data);
|
self->data->start(sprite->data);
|
||||||
AnimationManager::getInstance().addAnimation(self->data);
|
AnimationManager::getInstance().addAnimation(self->data, conflict_mode);
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -177,7 +203,7 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) {
|
||||||
PyUIGridObject* grid = (PyUIGridObject*)target_obj;
|
PyUIGridObject* grid = (PyUIGridObject*)target_obj;
|
||||||
if (grid->data) {
|
if (grid->data) {
|
||||||
self->data->start(grid->data);
|
self->data->start(grid->data);
|
||||||
AnimationManager::getInstance().addAnimation(self->data);
|
AnimationManager::getInstance().addAnimation(self->data, conflict_mode);
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -186,7 +212,7 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) {
|
||||||
PyUIEntityObject* entity = (PyUIEntityObject*)target_obj;
|
PyUIEntityObject* entity = (PyUIEntityObject*)target_obj;
|
||||||
if (entity->data) {
|
if (entity->data) {
|
||||||
self->data->startEntity(entity->data);
|
self->data->startEntity(entity->data);
|
||||||
AnimationManager::getInstance().addAnimation(self->data);
|
AnimationManager::getInstance().addAnimation(self->data, conflict_mode);
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -203,6 +229,11 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) {
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if an error was set (e.g., from ERROR conflict mode)
|
||||||
|
if (PyErr_Occurred()) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
Py_RETURN_NONE;
|
Py_RETURN_NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,14 +307,19 @@ PyGetSetDef PyAnimation::getsetters[] = {
|
||||||
};
|
};
|
||||||
|
|
||||||
PyMethodDef PyAnimation::methods[] = {
|
PyMethodDef PyAnimation::methods[] = {
|
||||||
{"start", (PyCFunction)start, METH_VARARGS,
|
{"start", (PyCFunction)start, METH_VARARGS | METH_KEYWORDS,
|
||||||
MCRF_METHOD(Animation, start,
|
MCRF_METHOD(Animation, start,
|
||||||
MCRF_SIG("(target: UIDrawable)", "None"),
|
MCRF_SIG("(target: UIDrawable, conflict_mode: str = 'replace')", "None"),
|
||||||
MCRF_DESC("Start the animation on a target UI element."),
|
MCRF_DESC("Start the animation on a target UI element."),
|
||||||
MCRF_ARGS_START
|
MCRF_ARGS_START
|
||||||
MCRF_ARG("target", "The UI element to animate (Frame, Caption, Sprite, Grid, or Entity)")
|
MCRF_ARG("target", "The UI element to animate (Frame, Caption, Sprite, Grid, or Entity)")
|
||||||
|
MCRF_ARG("conflict_mode", "How to handle conflicts if property is already animating: "
|
||||||
|
"'replace' (default) - complete existing animation and start new one; "
|
||||||
|
"'queue' - wait for existing animation to complete; "
|
||||||
|
"'error' - raise RuntimeError if property is busy")
|
||||||
MCRF_RETURNS("None")
|
MCRF_RETURNS("None")
|
||||||
MCRF_NOTE("The animation will automatically stop if the target is destroyed. Call AnimationManager.update(delta_time) each frame to progress animations.")
|
MCRF_RAISES("RuntimeError", "When conflict_mode='error' and property is already animating")
|
||||||
|
MCRF_NOTE("The animation will automatically stop if the target is destroyed.")
|
||||||
)},
|
)},
|
||||||
{"update", (PyCFunction)update, METH_VARARGS,
|
{"update", (PyCFunction)update, METH_VARARGS,
|
||||||
MCRF_METHOD(Animation, update,
|
MCRF_METHOD(Animation, update,
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ public:
|
||||||
static PyObject* get_is_delta(PyAnimationObject* self, void* closure);
|
static PyObject* get_is_delta(PyAnimationObject* self, void* closure);
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
static PyObject* start(PyAnimationObject* self, PyObject* args);
|
static PyObject* start(PyAnimationObject* self, PyObject* args, PyObject* kwds);
|
||||||
static PyObject* update(PyAnimationObject* self, PyObject* args);
|
static PyObject* update(PyAnimationObject* self, PyObject* args);
|
||||||
static PyObject* get_current_value(PyAnimationObject* self, PyObject* args);
|
static PyObject* get_current_value(PyAnimationObject* self, PyObject* args);
|
||||||
static PyObject* complete(PyAnimationObject* self, PyObject* args);
|
static PyObject* complete(PyAnimationObject* self, PyObject* args);
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@
|
||||||
Core game engine interface for creating roguelike games with Python.
|
Core game engine interface for creating roguelike games with Python.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any, List, Dict, Tuple, Optional, Callable, Union, overload
|
from typing import Any, List, Dict, Tuple, Optional, Callable, Union, overload, Literal
|
||||||
|
|
||||||
# Type aliases
|
# Type aliases
|
||||||
UIElement = Union['Frame', 'Caption', 'Sprite', 'Grid']
|
UIElement = Union['Frame', 'Caption', 'Sprite', 'Grid']
|
||||||
Transition = Union[str, None]
|
Transition = Union[str, None]
|
||||||
|
ConflictMode = Literal['replace', 'queue', 'error']
|
||||||
|
|
||||||
# Classes
|
# Classes
|
||||||
|
|
||||||
|
|
@ -461,8 +462,19 @@ class Animation:
|
||||||
duration: float, easing: str = 'linear', loop: bool = False,
|
duration: float, easing: str = 'linear', loop: bool = False,
|
||||||
on_complete: Optional[Callable] = None) -> None: ...
|
on_complete: Optional[Callable] = None) -> None: ...
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self, target: Any, conflict_mode: ConflictMode = 'replace') -> None:
|
||||||
"""Start the animation."""
|
"""Start the animation on a target UI element.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target: The UI element to animate (Frame, Caption, Sprite, Grid, or Entity)
|
||||||
|
conflict_mode: How to handle conflicts if property is already animating:
|
||||||
|
- 'replace' (default): Complete existing animation and start new one
|
||||||
|
- 'queue': Wait for existing animation to complete
|
||||||
|
- 'error': Raise RuntimeError if property is busy
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: When conflict_mode='error' and property is already animating
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def update(self, dt: float) -> bool:
|
def update(self, dt: float) -> bool:
|
||||||
|
|
|
||||||
249
tests/unit/test_animation_property_locking.py
Normal file
249
tests/unit/test_animation_property_locking.py
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test Animation Property Locking (#120)
|
||||||
|
Verifies that multiple animations on the same property are handled correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
|
||||||
|
print("Animation Property Locking Test Suite (#120)")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Test state
|
||||||
|
tests_passed = 0
|
||||||
|
tests_failed = 0
|
||||||
|
test_results = []
|
||||||
|
|
||||||
|
def test_result(name, passed, details=""):
|
||||||
|
global tests_passed, tests_failed
|
||||||
|
if passed:
|
||||||
|
tests_passed += 1
|
||||||
|
result = f"PASS: {name}"
|
||||||
|
else:
|
||||||
|
tests_failed += 1
|
||||||
|
result = f"FAIL: {name}: {details}"
|
||||||
|
print(result)
|
||||||
|
test_results.append((name, passed, details))
|
||||||
|
|
||||||
|
|
||||||
|
def test_1_replace_mode_default():
|
||||||
|
"""Test that REPLACE mode is the default and works correctly"""
|
||||||
|
try:
|
||||||
|
ui = mcrfpy.sceneUI("test")
|
||||||
|
frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100))
|
||||||
|
ui.append(frame)
|
||||||
|
|
||||||
|
# Start first animation
|
||||||
|
anim1 = mcrfpy.Animation("x", 500.0, 2.0, "linear")
|
||||||
|
anim1.start(frame) # Default is replace mode
|
||||||
|
|
||||||
|
# Immediately start second animation on same property
|
||||||
|
anim2 = mcrfpy.Animation("x", 200.0, 1.0, "linear")
|
||||||
|
anim2.start(frame) # Should replace anim1
|
||||||
|
|
||||||
|
# anim1 should have been completed (jumped to final value)
|
||||||
|
# and anim2 should now be active
|
||||||
|
# The frame should be at x=500 (anim1's final value) then animating to 200
|
||||||
|
|
||||||
|
# If we got here without error, replace worked
|
||||||
|
test_result("Replace mode (default)", True)
|
||||||
|
except Exception as e:
|
||||||
|
test_result("Replace mode (default)", False, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def test_2_replace_mode_explicit():
|
||||||
|
"""Test explicit REPLACE mode"""
|
||||||
|
try:
|
||||||
|
ui = mcrfpy.sceneUI("test")
|
||||||
|
frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100))
|
||||||
|
ui.append(frame)
|
||||||
|
|
||||||
|
anim1 = mcrfpy.Animation("x", 500.0, 2.0, "linear")
|
||||||
|
anim1.start(frame, conflict_mode="replace")
|
||||||
|
|
||||||
|
anim2 = mcrfpy.Animation("x", 200.0, 1.0, "linear")
|
||||||
|
anim2.start(frame, conflict_mode="replace")
|
||||||
|
|
||||||
|
test_result("Replace mode (explicit)", True)
|
||||||
|
except Exception as e:
|
||||||
|
test_result("Replace mode (explicit)", False, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def test_3_queue_mode():
|
||||||
|
"""Test QUEUE mode - animation should be queued"""
|
||||||
|
try:
|
||||||
|
ui = mcrfpy.sceneUI("test")
|
||||||
|
frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100))
|
||||||
|
ui.append(frame)
|
||||||
|
|
||||||
|
# Start first animation (short duration for test)
|
||||||
|
anim1 = mcrfpy.Animation("y", 300.0, 0.5, "linear")
|
||||||
|
anim1.start(frame)
|
||||||
|
|
||||||
|
# Queue second animation
|
||||||
|
anim2 = mcrfpy.Animation("y", 100.0, 0.5, "linear")
|
||||||
|
anim2.start(frame, conflict_mode="queue")
|
||||||
|
|
||||||
|
# Both should be accepted without error
|
||||||
|
# anim2 will start after anim1 completes
|
||||||
|
test_result("Queue mode", True)
|
||||||
|
except Exception as e:
|
||||||
|
test_result("Queue mode", False, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def test_4_error_mode():
|
||||||
|
"""Test ERROR mode - should raise RuntimeError"""
|
||||||
|
try:
|
||||||
|
ui = mcrfpy.sceneUI("test")
|
||||||
|
frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100))
|
||||||
|
ui.append(frame)
|
||||||
|
|
||||||
|
anim1 = mcrfpy.Animation("w", 200.0, 2.0, "linear")
|
||||||
|
anim1.start(frame)
|
||||||
|
|
||||||
|
# Try to start second animation with error mode
|
||||||
|
anim2 = mcrfpy.Animation("w", 300.0, 1.0, "linear")
|
||||||
|
try:
|
||||||
|
anim2.start(frame, conflict_mode="error")
|
||||||
|
test_result("Error mode", False, "Expected RuntimeError but none was raised")
|
||||||
|
except RuntimeError as e:
|
||||||
|
# This is expected!
|
||||||
|
if "conflict" in str(e).lower() or "already" in str(e).lower():
|
||||||
|
test_result("Error mode", True)
|
||||||
|
else:
|
||||||
|
test_result("Error mode", False, f"Wrong error message: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
test_result("Error mode", False, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def test_5_invalid_conflict_mode():
|
||||||
|
"""Test that invalid conflict_mode raises ValueError"""
|
||||||
|
try:
|
||||||
|
ui = mcrfpy.sceneUI("test")
|
||||||
|
frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100))
|
||||||
|
ui.append(frame)
|
||||||
|
|
||||||
|
anim = mcrfpy.Animation("h", 200.0, 1.0, "linear")
|
||||||
|
try:
|
||||||
|
anim.start(frame, conflict_mode="invalid_mode")
|
||||||
|
test_result("Invalid conflict_mode", False, "Expected ValueError but none raised")
|
||||||
|
except ValueError as e:
|
||||||
|
if "invalid" in str(e).lower():
|
||||||
|
test_result("Invalid conflict_mode", True)
|
||||||
|
else:
|
||||||
|
test_result("Invalid conflict_mode", False, f"Wrong error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
test_result("Invalid conflict_mode", False, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def test_6_different_properties_no_conflict():
|
||||||
|
"""Test that different properties can animate simultaneously"""
|
||||||
|
try:
|
||||||
|
ui = mcrfpy.sceneUI("test")
|
||||||
|
frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100))
|
||||||
|
ui.append(frame)
|
||||||
|
|
||||||
|
# Animate different properties - should not conflict
|
||||||
|
anim_x = mcrfpy.Animation("x", 500.0, 1.0, "linear")
|
||||||
|
anim_y = mcrfpy.Animation("y", 500.0, 1.0, "linear")
|
||||||
|
anim_w = mcrfpy.Animation("w", 200.0, 1.0, "linear")
|
||||||
|
|
||||||
|
anim_x.start(frame, conflict_mode="error")
|
||||||
|
anim_y.start(frame, conflict_mode="error")
|
||||||
|
anim_w.start(frame, conflict_mode="error")
|
||||||
|
|
||||||
|
# All should succeed without error since they're different properties
|
||||||
|
test_result("Different properties no conflict", True)
|
||||||
|
except RuntimeError as e:
|
||||||
|
test_result("Different properties no conflict", False, f"Unexpected conflict: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
test_result("Different properties no conflict", False, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def test_7_different_targets_no_conflict():
|
||||||
|
"""Test that same property on different targets doesn't conflict"""
|
||||||
|
try:
|
||||||
|
ui = mcrfpy.sceneUI("test")
|
||||||
|
frame1 = mcrfpy.Frame(pos=(100, 100), size=(100, 100))
|
||||||
|
frame2 = mcrfpy.Frame(pos=(200, 200), size=(100, 100))
|
||||||
|
ui.append(frame1)
|
||||||
|
ui.append(frame2)
|
||||||
|
|
||||||
|
# Same property, different targets - should not conflict
|
||||||
|
anim1 = mcrfpy.Animation("x", 500.0, 1.0, "linear")
|
||||||
|
anim2 = mcrfpy.Animation("x", 600.0, 1.0, "linear")
|
||||||
|
|
||||||
|
anim1.start(frame1, conflict_mode="error")
|
||||||
|
anim2.start(frame2, conflict_mode="error")
|
||||||
|
|
||||||
|
test_result("Different targets no conflict", True)
|
||||||
|
except RuntimeError as e:
|
||||||
|
test_result("Different targets no conflict", False, f"Unexpected conflict: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
test_result("Different targets no conflict", False, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def test_8_replace_completes_old():
|
||||||
|
"""Test that REPLACE mode completes the old animation's value"""
|
||||||
|
try:
|
||||||
|
ui = mcrfpy.sceneUI("test")
|
||||||
|
frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
||||||
|
ui.append(frame)
|
||||||
|
|
||||||
|
# Start animation to move x to 500
|
||||||
|
anim1 = mcrfpy.Animation("x", 500.0, 10.0, "linear") # Long duration
|
||||||
|
anim1.start(frame)
|
||||||
|
|
||||||
|
# Immediately replace - should complete anim1 (jump to 500)
|
||||||
|
anim2 = mcrfpy.Animation("x", 200.0, 1.0, "linear")
|
||||||
|
anim2.start(frame, conflict_mode="replace")
|
||||||
|
|
||||||
|
# Frame should now be at x=500 (anim1's final) and animating to 200
|
||||||
|
# Due to immediate completion, x should equal 500 right now
|
||||||
|
if frame.x == 500.0:
|
||||||
|
test_result("Replace completes old animation", True)
|
||||||
|
else:
|
||||||
|
test_result("Replace completes old animation", False,
|
||||||
|
f"Expected x=500, got x={frame.x}")
|
||||||
|
except Exception as e:
|
||||||
|
test_result("Replace completes old animation", False, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def run_all_tests(runtime):
|
||||||
|
"""Run all property locking tests"""
|
||||||
|
print("\nRunning Animation Property Locking Tests...")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
test_1_replace_mode_default()
|
||||||
|
test_2_replace_mode_explicit()
|
||||||
|
test_3_queue_mode()
|
||||||
|
test_4_error_mode()
|
||||||
|
test_5_invalid_conflict_mode()
|
||||||
|
test_6_different_properties_no_conflict()
|
||||||
|
test_7_different_targets_no_conflict()
|
||||||
|
test_8_replace_completes_old()
|
||||||
|
|
||||||
|
# Print results
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"Tests passed: {tests_passed}")
|
||||||
|
print(f"Tests failed: {tests_failed}")
|
||||||
|
|
||||||
|
if tests_failed == 0:
|
||||||
|
print("\nAll tests passed!")
|
||||||
|
else:
|
||||||
|
print(f"\n{tests_failed} tests failed:")
|
||||||
|
for name, passed, details in test_results:
|
||||||
|
if not passed:
|
||||||
|
print(f" - {name}: {details}")
|
||||||
|
|
||||||
|
# Exit with appropriate code
|
||||||
|
sys.exit(0 if tests_failed == 0 else 1)
|
||||||
|
|
||||||
|
|
||||||
|
# Setup and run
|
||||||
|
mcrfpy.createScene("test")
|
||||||
|
mcrfpy.setScene("test")
|
||||||
|
|
||||||
|
# Start tests after a brief delay to allow scene to initialize
|
||||||
|
mcrfpy.setTimer("start", run_all_tests, 100)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue