From ef05152ea00d2b96eee1632127002b5410ccf79e Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sat, 7 Feb 2026 20:16:02 -0500 Subject: [PATCH] Implement Entity3D.animate(), closes #242 Replaced the NotImplementedError stub with a full animation implementation. Entity3D now supports animating: x, y, z, world_x, world_y, world_z, rotation, rot_y, scale, scale_x, scale_y, scale_z, sprite_index, visible. Added Entity3D as a third target type in the Animation system (alongside UIDrawable and UIEntity), with startEntity3D(), applyValue(Entity3D*), and proper callback support. Co-Authored-By: Claude Opus 4.6 --- src/Animation.cpp | 107 ++++++++++++++++++++++++++-- src/Animation.h | 7 ++ tests/unit/test_entity3d_animate.py | 94 ++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_entity3d_animate.py diff --git a/src/Animation.cpp b/src/Animation.cpp index b59c96e..e369556 100644 --- a/src/Animation.cpp +++ b/src/Animation.cpp @@ -1,6 +1,7 @@ #include "Animation.h" #include "UIDrawable.h" #include "UIEntity.h" +#include "3d/Entity3D.h" #include "PyAnimation.h" #include "McRFPy_API.h" #include "GameEngine.h" @@ -168,8 +169,46 @@ void Animation::startEntity(std::shared_ptr target) { } } +void Animation::startEntity3D(std::shared_ptr target) { + if (!target) return; + + entity3dTargetWeak = target; + elapsed = 0.0f; + callbackTriggered = false; + + // Capture the starting value from the entity + std::visit([this, target](const auto& val) { + using T = std::decay_t; + + if constexpr (std::is_same_v) { + float value = 0.0f; + if (target->getProperty(targetProperty, value)) { + startValue = value; + } + } + else if constexpr (std::is_same_v) { + // For sprite_index/visible: capture via float and convert + float fvalue = 0.0f; + if (target->getProperty(targetProperty, fvalue)) { + startValue = static_cast(fvalue); + } + } + // Entity3D doesn't support other types + }, targetValue); + + // For zero-duration animations, apply final value immediately + if (duration <= 0.0f) { + AnimationValue finalValue = interpolate(1.0f); + applyValue(target.get(), finalValue); + if (pythonCallback && !callbackTriggered) { + triggerCallback(); + } + callbackTriggered = true; + } +} + bool Animation::hasValidTarget() const { - return !targetWeak.expired() || !entityTargetWeak.expired(); + return !targetWeak.expired() || !entityTargetWeak.expired() || !entity3dTargetWeak.expired(); } void Animation::clearCallback() { @@ -198,6 +237,10 @@ void Animation::complete() { AnimationValue finalValue = interpolate(1.0f); applyValue(entity.get(), finalValue); } + else if (auto entity3d = entity3dTargetWeak.lock()) { + AnimationValue finalValue = interpolate(1.0f); + applyValue(entity3d.get(), finalValue); + } } void Animation::stop() { @@ -215,9 +258,10 @@ bool Animation::update(float deltaTime) { // Try to lock weak_ptr to get shared_ptr std::shared_ptr target = targetWeak.lock(); std::shared_ptr entity = entityTargetWeak.lock(); + std::shared_ptr entity3d = entity3dTargetWeak.lock(); - // If both are null, target was destroyed - if (!target && !entity) { + // If all are null, target was destroyed + if (!target && !entity && !entity3d) { return false; // Remove this animation } @@ -231,6 +275,8 @@ bool Animation::update(float deltaTime) { applyValue(target.get(), finalValue); } else if (entity) { applyValue(entity.get(), finalValue); + } else if (entity3d) { + applyValue(entity3d.get(), finalValue); } // Trigger callback if (pythonCallback) { @@ -256,6 +302,8 @@ bool Animation::update(float deltaTime) { applyValue(target.get(), currentValue); } else if (entity) { applyValue(entity.get(), currentValue); + } else if (entity3d) { + applyValue(entity3d.get(), currentValue); } // Trigger callback when animation completes @@ -400,10 +448,10 @@ void Animation::applyValue(UIDrawable* target, const AnimationValue& value) { void Animation::applyValue(UIEntity* entity, const AnimationValue& value) { if (!entity) return; - + std::visit([this, entity](const auto& val) { using T = std::decay_t; - + if constexpr (std::is_same_v) { entity->setProperty(targetProperty, val); } @@ -414,6 +462,22 @@ void Animation::applyValue(UIEntity* entity, const AnimationValue& value) { }, value); } +void Animation::applyValue(mcrf::Entity3D* entity, const AnimationValue& value) { + if (!entity) return; + + std::visit([this, entity](const auto& val) { + using T = std::decay_t; + + if constexpr (std::is_same_v) { + entity->setProperty(targetProperty, val); + } + else if constexpr (std::is_same_v) { + entity->setProperty(targetProperty, val); + } + // Entity3D doesn't support other types + }, value); +} + // #229 - Helper to convert UIDrawable target to Python object static PyObject* convertDrawableToPython(std::shared_ptr drawable) { if (!drawable) { @@ -560,6 +624,37 @@ static PyObject* convertEntityToPython(std::shared_ptr entity) { return (PyObject*)pyObj; } +// Helper to convert Entity3D target to Python object +static PyObject* convertEntity3DToPython(std::shared_ptr entity) { + if (!entity) { + Py_RETURN_NONE; + } + + // Use the entity's cached Python self pointer if available + if (entity->self) { + Py_INCREF(entity->self); + return entity->self; + } + + // Create a new wrapper + PyTypeObject* type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity3D"); + if (!type) { + Py_RETURN_NONE; + } + + auto pyObj = (PyEntity3DObject*)type->tp_alloc(type, 0); + Py_DECREF(type); + + if (!pyObj) { + Py_RETURN_NONE; + } + + pyObj->data = entity; + pyObj->weakreflist = NULL; + + return (PyObject*)pyObj; +} + // #229 - Helper to convert AnimationValue to Python object static PyObject* animationValueToPython(const AnimationValue& value) { return std::visit([](const auto& val) -> PyObject* { @@ -609,6 +704,8 @@ void Animation::triggerCallback() { targetObj = convertDrawableToPython(drawable); } else if (auto entity = entityTargetWeak.lock()) { targetObj = convertEntityToPython(entity); + } else if (auto entity3d = entity3dTargetWeak.lock()) { + targetObj = convertEntity3DToPython(entity3d); } // If target conversion failed, use None diff --git a/src/Animation.h b/src/Animation.h index 40b057f..3fc4e0e 100644 --- a/src/Animation.h +++ b/src/Animation.h @@ -11,6 +11,7 @@ // Forward declarations class UIDrawable; class UIEntity; +namespace mcrf { class Entity3D; } /** * ConflictMode - How to handle multiple animations on the same property (#120) @@ -58,6 +59,9 @@ public: // Apply this animation to an entity (special case since Entity doesn't inherit from UIDrawable) void startEntity(std::shared_ptr target); + + // Apply this animation to a 3D entity + void startEntity3D(std::shared_ptr target); // Complete the animation immediately (jump to final value) void complete(); @@ -90,6 +94,7 @@ public: void* getTargetPtr() const { if (auto sp = targetWeak.lock()) return sp.get(); if (auto sp = entityTargetWeak.lock()) return sp.get(); + if (auto sp = entity3dTargetWeak.lock()) return sp.get(); return nullptr; } @@ -106,6 +111,7 @@ private: // RAII: Use weak_ptr for safe target tracking std::weak_ptr targetWeak; std::weak_ptr entityTargetWeak; + std::weak_ptr entity3dTargetWeak; // Callback support PyObject* pythonCallback = nullptr; // Python callback function (we own a reference) @@ -121,6 +127,7 @@ private: // Helper to apply value to target void applyValue(UIDrawable* target, const AnimationValue& value); void applyValue(UIEntity* entity, const AnimationValue& value); + void applyValue(mcrf::Entity3D* entity, const AnimationValue& value); // Trigger callback when animation completes void triggerCallback(); diff --git a/tests/unit/test_entity3d_animate.py b/tests/unit/test_entity3d_animate.py new file mode 100644 index 0000000..df23945 --- /dev/null +++ b/tests/unit/test_entity3d_animate.py @@ -0,0 +1,94 @@ +"""Test Entity3D.animate() (issue #242)""" +import mcrfpy +import sys + +errors = [] + +vp = mcrfpy.Viewport3D(pos=(0,0), size=(100,100)) +vp.set_grid_size(16, 16) + +# Test 1: Basic x animation +e = mcrfpy.Entity3D(pos=(0,0), scale=1.0) +vp.entities.append(e) +start_x = e.world_pos[0] +anim = e.animate('x', 10.0, 1.0, 'linear') +if anim is None: + errors.append("animate() should return Animation object") +for _ in range(5): + mcrfpy.step(0.25) +if abs(e.world_pos[0] - 10.0) > 0.5: + errors.append(f"x animation: expected ~10.0, got {e.world_pos[0]}") + +# Test 2: Scale animation +e2 = mcrfpy.Entity3D(pos=(0,0), scale=1.0) +vp.entities.append(e2) +e2.animate('scale', 5.0, 0.5, 'linear') +for _ in range(3): + mcrfpy.step(0.25) +if abs(e2.scale - 5.0) > 0.5: + errors.append(f"scale animation: expected ~5.0, got {e2.scale}") + +# Test 3: Rotation animation +e3 = mcrfpy.Entity3D(pos=(0,0), scale=1.0) +vp.entities.append(e3) +e3.animate('rotation', 90.0, 0.5, 'easeInOut') +for _ in range(3): + mcrfpy.step(0.25) +if abs(e3.rotation - 90.0) > 0.5: + errors.append(f"rotation animation: expected ~90.0, got {e3.rotation}") + +# Test 4: Delta animation +e4 = mcrfpy.Entity3D(pos=(3,3), scale=1.0) +vp.entities.append(e4) +start_x = e4.world_pos[0] +e4.animate('x', 2.0, 0.5, 'linear', delta=True) +for _ in range(3): + mcrfpy.step(0.25) +expected = start_x + 2.0 +if abs(e4.world_pos[0] - expected) > 0.5: + errors.append(f"delta animation: expected ~{expected}, got {e4.world_pos[0]}") + +# Test 5: Invalid property raises ValueError +try: + e.animate('nonexistent', 1.0, 1.0, 'linear') + errors.append("Invalid property should raise ValueError") +except ValueError: + pass + +# Test 6: Invalid target type raises TypeError +try: + e.animate('x', "not_a_number", 1.0, 'linear') + errors.append("String target should raise TypeError") +except TypeError: + pass + +# Test 7: Callback +callback_called = [False] +def on_complete(target, prop, value): + callback_called[0] = True + +e5 = mcrfpy.Entity3D(pos=(0,0), scale=1.0) +vp.entities.append(e5) +e5.animate('x', 5.0, 0.25, 'linear', callback=on_complete) +for _ in range(3): + mcrfpy.step(0.15) +if not callback_called[0]: + errors.append("Animation callback was not called") + +# Test 8: Easing enum +e6 = mcrfpy.Entity3D(pos=(0,0), scale=1.0) +vp.entities.append(e6) +try: + e6.animate('x', 5.0, 1.0, mcrfpy.Easing.EASE_IN_OUT) +except Exception as ex: + errors.append(f"Easing enum should work: {ex}") +for _ in range(5): + mcrfpy.step(0.25) + +if errors: + for err in errors: + print(f"FAIL: {err}", file=sys.stderr) + sys.exit(1) +else: + print("PASS: Entity3D.animate() (issue #242)", file=sys.stderr) + sys.exit(0)