From 550201d3658ee44ef16c83fcf63cc53fe981adf5 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 27 Feb 2026 22:11:10 -0500 Subject: [PATCH 1/3] CLAUDE guidance --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 99651f4..1715087 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -639,10 +639,10 @@ build/ ## Important Notes -- The project uses SFML for graphics/audio and libtcod for roguelike utilities +- The project uses SFML for graphics/audio (or SDL2 when building for wasm) and libtcod for roguelike utilities - Python scripts are loaded at runtime from the `scripts/` directory - Asset loading expects specific paths relative to the executable -- The game was created for 7DRL 2025 as "Crypt of Sokoban" +- The game was created for 7DRL 2023 - Iterator implementations require careful handling of C++/Python boundaries ## Testing Guidelines From 29fe135161a72bd76c0dca8a667478ef1af20b44 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 27 Feb 2026 22:11:29 -0500 Subject: [PATCH 2/3] animation loop parameter --- src/3d/Entity3D.cpp | 9 +- src/Animation.cpp | 82 +++++++++-- src/Animation.h | 16 ++- src/PyAnimation.cpp | 17 ++- src/PyAnimation.h | 19 ++- src/PyEasing.cpp | 9 +- src/UIBase.h | 5 +- src/UIDrawable.cpp | 9 +- src/UIEntity.cpp | 38 ++++-- tests/unit/animation_loop_test.py | 107 +++++++++++++++ tests/unit/entity_framelist_test.py | 103 ++++++++++++++ tests/unit/ping_pong_easing_test.py | 203 ++++++++++++++++++++++++++++ 12 files changed, 563 insertions(+), 54 deletions(-) create mode 100644 tests/unit/animation_loop_test.py create mode 100644 tests/unit/entity_framelist_test.py create mode 100644 tests/unit/ping_pong_easing_test.py diff --git a/src/3d/Entity3D.cpp b/src/3d/Entity3D.cpp index 903dc58..667efad 100644 --- a/src/3d/Entity3D.cpp +++ b/src/3d/Entity3D.cpp @@ -1141,19 +1141,20 @@ PyObject* Entity3D::py_update_visibility(PyEntity3DObject* self, PyObject* args) PyObject* Entity3D::py_animate(PyEntity3DObject* self, PyObject* args, PyObject* kwds) { - static const char* keywords[] = {"property", "target", "duration", "easing", "delta", "callback", "conflict_mode", nullptr}; + static const char* keywords[] = {"property", "target", "duration", "easing", "delta", "loop", "callback", "conflict_mode", nullptr}; const char* property_name; PyObject* target_value; float duration; PyObject* easing_arg = Py_None; int delta = 0; + int loop_val = 0; PyObject* callback = nullptr; const char* conflict_mode_str = nullptr; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|OpOs", const_cast(keywords), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|OppOs", const_cast(keywords), &property_name, &target_value, &duration, - &easing_arg, &delta, &callback, &conflict_mode_str)) { + &easing_arg, &delta, &loop_val, &callback, &conflict_mode_str)) { return NULL; } @@ -1216,7 +1217,7 @@ PyObject* Entity3D::py_animate(PyEntity3DObject* self, PyObject* args, PyObject* } // Create the Animation - auto animation = std::make_shared(property_name, animValue, duration, easingFunc, delta != 0, callback); + auto animation = std::make_shared(property_name, animValue, duration, easingFunc, delta != 0, loop_val != 0, callback); // Start on this entity (uses startEntity3D) animation->startEntity3D(self->data); diff --git a/src/Animation.cpp b/src/Animation.cpp index e369556..37b602f 100644 --- a/src/Animation.cpp +++ b/src/Animation.cpp @@ -28,17 +28,19 @@ namespace mcrfpydef { } // Animation implementation -Animation::Animation(const std::string& targetProperty, +Animation::Animation(const std::string& targetProperty, const AnimationValue& targetValue, float duration, EasingFunction easingFunc, bool delta, + bool loop, PyObject* callback) : targetProperty(targetProperty) , targetValue(targetValue) , duration(duration) , easingFunc(easingFunc) , delta(delta) + , loop(loop) , pythonCallback(callback) { // Increase reference count for Python callback @@ -123,7 +125,7 @@ void Animation::start(std::shared_ptr target) { // For zero-duration animations, apply final value immediately if (duration <= 0.0f) { - AnimationValue finalValue = interpolate(1.0f); + AnimationValue finalValue = interpolate(easingFunc(1.0f)); applyValue(target.get(), finalValue); if (pythonCallback && !callbackTriggered) { triggerCallback(); @@ -155,12 +157,18 @@ void Animation::startEntity(std::shared_ptr target) { startValue = target->sprite.getSpriteIndex(); } } + else if constexpr (std::is_same_v>) { + // For sprite animation frame lists, get current sprite index + if (targetProperty == "sprite_index" || targetProperty == "sprite_number") { + startValue = target->sprite.getSpriteIndex(); + } + } // Entities don't support other types yet }, targetValue); // For zero-duration animations, apply final value immediately if (duration <= 0.0f) { - AnimationValue finalValue = interpolate(1.0f); + AnimationValue finalValue = interpolate(easingFunc(1.0f)); applyValue(target.get(), finalValue); if (pythonCallback && !callbackTriggered) { triggerCallback(); @@ -198,7 +206,7 @@ void Animation::startEntity3D(std::shared_ptr target) { // For zero-duration animations, apply final value immediately if (duration <= 0.0f) { - AnimationValue finalValue = interpolate(1.0f); + AnimationValue finalValue = interpolate(easingFunc(1.0f)); applyValue(target.get(), finalValue); if (pythonCallback && !callbackTriggered) { triggerCallback(); @@ -228,17 +236,20 @@ void Animation::complete() { // Jump to end of animation elapsed = duration; - // Apply final value + // Apply final value through easing function + // For standard easings, easingFunc(1.0) = 1.0 (no change) + // For ping-pong easings, easingFunc(1.0) = 0.0 (returns to start value) + float finalT = easingFunc(1.0f); if (auto target = targetWeak.lock()) { - AnimationValue finalValue = interpolate(1.0f); + AnimationValue finalValue = interpolate(finalT); applyValue(target.get(), finalValue); } else if (auto entity = entityTargetWeak.lock()) { - AnimationValue finalValue = interpolate(1.0f); + AnimationValue finalValue = interpolate(finalT); applyValue(entity.get(), finalValue); } else if (auto entity3d = entity3dTargetWeak.lock()) { - AnimationValue finalValue = interpolate(1.0f); + AnimationValue finalValue = interpolate(finalT); applyValue(entity3d.get(), finalValue); } } @@ -269,8 +280,9 @@ bool Animation::update(float deltaTime) { // Apply final value once before returning if (isComplete()) { if (!callbackTriggered) { - // Apply final value for zero-duration animations - AnimationValue finalValue = interpolate(1.0f); + // Apply final value through easing function + float finalT = easingFunc(1.0f); + AnimationValue finalValue = interpolate(finalT); if (target) { applyValue(target.get(), finalValue); } else if (entity) { @@ -288,7 +300,11 @@ bool Animation::update(float deltaTime) { } elapsed += deltaTime; - elapsed = std::min(elapsed, duration); + if (loop && duration > 0.0f) { + while (elapsed >= duration) elapsed -= duration; + } else { + elapsed = std::min(elapsed, duration); + } // Calculate easing value (0.0 to 1.0) float t = duration > 0 ? elapsed / duration : 1.0f; @@ -722,8 +738,9 @@ void Animation::triggerCallback() { return; } - // Final value (interpolated at t=1.0) - PyObject* valueObj = animationValueToPython(interpolate(1.0f)); + // Final value (interpolated through easing function at t=1.0) + // For ping-pong easings, this returns the start value (easingFunc(1.0) = 0.0) + PyObject* valueObj = animationValueToPython(interpolate(easingFunc(1.0f))); if (!valueObj) { Py_DECREF(targetObj); Py_DECREF(propertyObj); @@ -956,6 +973,38 @@ float easeInOutBounce(float t) { } } +// Ping-pong easing functions (0 -> 1 -> 0) +// These are designed for looping animations where the value should +// smoothly return to the start position each cycle. + +float pingPong(float t) { + // Linear triangle wave: 0 -> 1 -> 0 + return 1.0f - std::fabs(2.0f * t - 1.0f); +} + +float pingPongSmooth(float t) { + // Sine bell curve: smooth acceleration and deceleration + return std::sin(static_cast(M_PI) * t); +} + +float pingPongEaseIn(float t) { + // Quadratic ease at rest positions (smooth departure/return, sharp peak) + float pp = 1.0f - std::fabs(2.0f * t - 1.0f); + return pp * pp; +} + +float pingPongEaseOut(float t) { + // Ease-out at peak (sharp departure, smooth turnaround) + float pp = 1.0f - std::fabs(2.0f * t - 1.0f); + return pp * (2.0f - pp); +} + +float pingPongEaseInOut(float t) { + // sin^2: smooth everywhere including at loop seam + float s = std::sin(static_cast(M_PI) * t); + return s * s; +} + // Get easing function by name EasingFunction getByName(const std::string& name) { static std::unordered_map easingMap = { @@ -989,7 +1038,12 @@ EasingFunction getByName(const std::string& name) { {"easeInOutBack", easeInOutBack}, {"easeInBounce", easeInBounce}, {"easeOutBounce", easeOutBounce}, - {"easeInOutBounce", easeInOutBounce} + {"easeInOutBounce", easeInOutBounce}, + {"pingPong", pingPong}, + {"pingPongSmooth", pingPongSmooth}, + {"pingPongEaseIn", pingPongEaseIn}, + {"pingPongEaseOut", pingPongEaseOut}, + {"pingPongEaseInOut", pingPongEaseInOut} }; auto it = easingMap.find(name); diff --git a/src/Animation.h b/src/Animation.h index 3fc4e0e..04d15d2 100644 --- a/src/Animation.h +++ b/src/Animation.h @@ -44,11 +44,12 @@ typedef std::variant< class Animation { public: // Constructor - Animation(const std::string& targetProperty, + Animation(const std::string& targetProperty, const AnimationValue& targetValue, float duration, EasingFunction easingFunc = EasingFunctions::linear, bool delta = false, + bool loop = false, PyObject* callback = nullptr); // Destructor - cleanup Python callback reference @@ -86,9 +87,10 @@ public: std::string getTargetProperty() const { return targetProperty; } float getDuration() const { return duration; } float getElapsed() const { return elapsed; } - bool isComplete() const { return elapsed >= duration || stopped; } + bool isComplete() const { return (!loop && elapsed >= duration) || stopped; } bool isStopped() const { return stopped; } bool isDelta() const { return delta; } + bool isLooping() const { return loop; } // Get raw target pointer for property locking (#120) void* getTargetPtr() const { @@ -106,6 +108,7 @@ private: float elapsed = 0.0f; // Elapsed time EasingFunction easingFunc; // Easing function to use bool delta; // If true, targetValue is relative to start + bool loop; // If true, animation repeats from start when complete bool stopped = false; // If true, animation was stopped without completing // RAII: Use weak_ptr for safe target tracking @@ -177,7 +180,14 @@ namespace EasingFunctions { float easeInBounce(float t); float easeOutBounce(float t); float easeInOutBounce(float t); - + + // Ping-pong easing functions (0 -> 1 -> 0, for looping animations) + float pingPong(float t); + float pingPongSmooth(float t); + float pingPongEaseIn(float t); + float pingPongEaseOut(float t); + float pingPongEaseInOut(float t); + // Get easing function by name EasingFunction getByName(const std::string& name); } diff --git a/src/PyAnimation.cpp b/src/PyAnimation.cpp index 272df99..e044b98 100644 --- a/src/PyAnimation.cpp +++ b/src/PyAnimation.cpp @@ -20,17 +20,18 @@ 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", "callback", nullptr}; + static const char* keywords[] = {"property", "target", "duration", "easing", "delta", "loop", "callback", nullptr}; const char* property_name; PyObject* target_value; float duration; PyObject* easing_arg = Py_None; int delta = 0; + int loop_val = 0; PyObject* callback = nullptr; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|OpO", const_cast(keywords), - &property_name, &target_value, &duration, &easing_arg, &delta, &callback)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|OppO", const_cast(keywords), + &property_name, &target_value, &duration, &easing_arg, &delta, &loop_val, &callback)) { return -1; } @@ -107,8 +108,8 @@ int PyAnimation::init(PyAnimationObject* self, PyObject* args, PyObject* kwds) { } // Create the Animation - self->data = std::make_shared(property_name, animValue, duration, easingFunc, delta != 0, callback); - + self->data = std::make_shared(property_name, animValue, duration, easingFunc, delta != 0, loop_val != 0, callback); + return 0; } @@ -179,6 +180,10 @@ PyObject* PyAnimation::get_is_delta(PyAnimationObject* self, void* closure) { return PyBool_FromLong(self->data->isDelta()); } +PyObject* PyAnimation::get_is_looping(PyAnimationObject* self, void* closure) { + return PyBool_FromLong(self->data->isLooping()); +} + // Helper to convert Python string to AnimationConflictMode static bool parseConflictMode(const char* mode_str, AnimationConflictMode& mode) { if (!mode_str || strcmp(mode_str, "replace") == 0) { @@ -356,6 +361,8 @@ PyGetSetDef PyAnimation::getsetters[] = { MCRF_PROPERTY(is_complete, "Whether animation is complete (bool, read-only). True when elapsed >= duration or complete() was called."), NULL}, {"is_delta", (getter)get_is_delta, NULL, MCRF_PROPERTY(is_delta, "Whether animation uses delta mode (bool, read-only). In delta mode, the target value is added to the starting value."), NULL}, + {"is_looping", (getter)get_is_looping, NULL, + MCRF_PROPERTY(is_looping, "Whether animation loops (bool, read-only). Looping animations repeat from the start when they reach the end."), NULL}, {NULL} }; diff --git a/src/PyAnimation.h b/src/PyAnimation.h index b941d0a..5b338cf 100644 --- a/src/PyAnimation.h +++ b/src/PyAnimation.h @@ -24,6 +24,7 @@ public: static PyObject* get_elapsed(PyAnimationObject* self, void* closure); static PyObject* get_is_complete(PyAnimationObject* self, void* closure); static PyObject* get_is_delta(PyAnimationObject* self, void* closure); + static PyObject* get_is_looping(PyAnimationObject* self, void* closure); // Methods static PyObject* start(PyAnimationObject* self, PyObject* args, PyObject* kwds); @@ -47,7 +48,7 @@ namespace mcrfpydef { .tp_repr = (reprfunc)PyAnimation::repr, .tp_flags = Py_TPFLAGS_DEFAULT, .tp_doc = PyDoc_STR( - "Animation(property: str, target: Any, duration: float, easing: str = 'linear', delta: bool = False, callback: Callable = None)\n" + "Animation(property: str, target: Any, duration: float, easing: str = 'linear', delta: bool = False, loop: bool = False, callback: Callable = None)\n" "\n" "Create an animation that interpolates a property value over time.\n" "\n" @@ -80,22 +81,18 @@ namespace mcrfpydef { " - 'easeInBack', 'easeOutBack', 'easeInOutBack'\n" " - 'easeInBounce', 'easeOutBounce', 'easeInOutBounce'\n" " delta: If True, target is relative to start value (additive). Default False.\n" - " callback: Function(animation, target) called when animation completes.\n" + " loop: If True, animation repeats from start when it reaches the end. Default False.\n" + " callback: Function(target, property, value) called when animation completes.\n" + " Not called for looping animations (since they never complete).\n" "\n" "Example:\n" " # Move a frame from current position to x=500 over 2 seconds\n" " anim = mcrfpy.Animation('x', 500.0, 2.0, 'easeInOut')\n" " anim.start(my_frame)\n" "\n" - " # Fade out with callback\n" - " def on_done(anim, target):\n" - " print('Animation complete!')\n" - " fade = mcrfpy.Animation('fill_color.a', 0, 1.0, callback=on_done)\n" - " fade.start(my_sprite)\n" - "\n" - " # Animate through sprite frames\n" - " walk_cycle = mcrfpy.Animation('sprite_index', [0,1,2,3,2,1], 0.5, 'linear')\n" - " walk_cycle.start(my_entity)\n" + " # Looping sprite animation\n" + " walk = mcrfpy.Animation('sprite_index', [0,1,2,3,2,1], 0.6, loop=True)\n" + " walk.start(my_sprite)\n" ), .tp_methods = PyAnimation::methods, .tp_getset = PyAnimation::getsetters, diff --git a/src/PyEasing.cpp b/src/PyEasing.cpp index 95e8978..b47e67b 100644 --- a/src/PyEasing.cpp +++ b/src/PyEasing.cpp @@ -43,6 +43,11 @@ static const EasingEntry easing_table[] = { {"EASE_IN_BOUNCE", 28, EasingFunctions::easeInBounce}, {"EASE_OUT_BOUNCE", 29, EasingFunctions::easeOutBounce}, {"EASE_IN_OUT_BOUNCE", 30, EasingFunctions::easeInOutBounce}, + {"PING_PONG", 31, EasingFunctions::pingPong}, + {"PING_PONG_SMOOTH", 32, EasingFunctions::pingPongSmooth}, + {"PING_PONG_EASE_IN", 33, EasingFunctions::pingPongEaseIn}, + {"PING_PONG_EASE_OUT", 34, EasingFunctions::pingPongEaseOut}, + {"PING_PONG_EASE_IN_OUT", 35, EasingFunctions::pingPongEaseInOut}, }; // Old string names (for backwards compatibility) @@ -56,7 +61,9 @@ static const char* legacy_names[] = { "easeInCirc", "easeOutCirc", "easeInOutCirc", "easeInElastic", "easeOutElastic", "easeInOutElastic", "easeInBack", "easeOutBack", "easeInOutBack", - "easeInBounce", "easeOutBounce", "easeInOutBounce" + "easeInBounce", "easeOutBounce", "easeInOutBounce", + "pingPong", "pingPongSmooth", "pingPongEaseIn", + "pingPongEaseOut", "pingPongEaseInOut" }; static const int NUM_EASING_ENTRIES = sizeof(easing_table) / sizeof(easing_table[0]); diff --git a/src/UIBase.h b/src/UIBase.h index 9e4ae3b..863f527 100644 --- a/src/UIBase.h +++ b/src/UIBase.h @@ -110,7 +110,7 @@ static PyObject* UIDrawable_animate(T* self, PyObject* args, PyObject* kwds) 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_SIG("(property: str, target: Any, duration: float, easing=None, delta=False, loop=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')") \ @@ -118,7 +118,8 @@ static PyObject* UIDrawable_animate(T* self, PyObject* args, PyObject* kwds) 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("loop", "If True, animation repeats from start when it reaches the end (default False)") \ + MCRF_ARG("callback", "Optional callable invoked when animation completes (not called for looping animations)") \ 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") \ diff --git a/src/UIDrawable.cpp b/src/UIDrawable.cpp index 346364a..5219965 100644 --- a/src/UIDrawable.cpp +++ b/src/UIDrawable.cpp @@ -1845,19 +1845,20 @@ int UIDrawable::set_on_move(PyObject* self, PyObject* value, void* closure) { // 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}; + static const char* keywords[] = {"property", "target", "duration", "easing", "delta", "loop", "callback", "conflict_mode", nullptr}; const char* property_name; PyObject* target_value; float duration; PyObject* easing_arg = Py_None; int delta = 0; + int loop_val = 0; PyObject* callback = nullptr; const char* conflict_mode_str = nullptr; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|OpOs", const_cast(keywords), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|OppOs", const_cast(keywords), &property_name, &target_value, &duration, - &easing_arg, &delta, &callback, &conflict_mode_str)) { + &easing_arg, &delta, &loop_val, &callback, &conflict_mode_str)) { return NULL; } @@ -1961,7 +1962,7 @@ PyObject* UIDrawable_animate_impl(std::shared_ptr self, PyObject* ar } // Create the Animation - auto animation = std::make_shared(property_name, animValue, duration, easingFunc, delta != 0, callback); + auto animation = std::make_shared(property_name, animValue, duration, easingFunc, delta != 0, loop_val != 0, callback); // Start on this drawable animation->start(self); diff --git a/src/UIEntity.cpp b/src/UIEntity.cpp index eeb8896..5852dbd 100644 --- a/src/UIEntity.cpp +++ b/src/UIEntity.cpp @@ -945,19 +945,21 @@ PyMethodDef UIEntity_all_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_SIG("(property: str, target: Any, duration: float, easing=None, delta=False, loop=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: 'draw_x', 'draw_y' (tile coords), 'sprite_scale', 'sprite_index'") - MCRF_ARG("target", "Target value - float or int depending on property") + MCRF_ARG("target", "Target value - float, int, or list of int (for sprite frame sequences)") 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("loop", "If True, animation repeats from start when it reaches the end (default False)") + MCRF_ARG("callback", "Optional callable invoked when animation completes (not called for looping animations)") 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 (draw_x, draw_y, sprite_scale, sprite_index)") - MCRF_NOTE("Use 'draw_x'/'draw_y' to animate tile coordinates for smooth movement between grid cells.") + MCRF_NOTE("Use 'draw_x'/'draw_y' to animate tile coordinates for smooth movement between grid cells. " + "Use list target with loop=True for repeating sprite frame animations.") )}, {"at", (PyCFunction)UIEntity::at, METH_VARARGS | METH_KEYWORDS, "at(x, y) or at(pos) -> GridPointState\n\n" @@ -1136,19 +1138,20 @@ bool UIEntity::hasProperty(const std::string& name) const { // 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}; + static const char* keywords[] = {"property", "target", "duration", "easing", "delta", "loop", "callback", "conflict_mode", nullptr}; const char* property_name; PyObject* target_value; float duration; PyObject* easing_arg = Py_None; int delta = 0; + int loop_val = 0; PyObject* callback = nullptr; const char* conflict_mode_str = nullptr; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|OpOs", const_cast(keywords), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|OppOs", const_cast(keywords), &property_name, &target_value, &duration, - &easing_arg, &delta, &callback, &conflict_mode_str)) { + &easing_arg, &delta, &loop_val, &callback, &conflict_mode_str)) { return NULL; } @@ -1173,7 +1176,7 @@ PyObject* UIEntity::animate(PyUIEntityObject* self, PyObject* args, PyObject* kw } // Convert Python target value to AnimationValue - // Entity only supports float and int properties + // Entity supports float, int, and list of int (for sprite frame animation) AnimationValue animValue; if (PyFloat_Check(target_value)) { @@ -1182,8 +1185,23 @@ PyObject* UIEntity::animate(PyUIEntityObject* self, PyObject* args, PyObject* kw 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 { - PyErr_SetString(PyExc_TypeError, "Entity animations only support float or int target values"); + PyErr_SetString(PyExc_TypeError, "Entity animations support float, int, or list of int target values"); return NULL; } @@ -1210,7 +1228,7 @@ PyObject* UIEntity::animate(PyUIEntityObject* self, PyObject* args, PyObject* kw } // Create the Animation - auto animation = std::make_shared(property_name, animValue, duration, easingFunc, delta != 0, callback); + auto animation = std::make_shared(property_name, animValue, duration, easingFunc, delta != 0, loop_val != 0, callback); // Start on this entity (uses startEntity, not start) animation->startEntity(self->data); diff --git a/tests/unit/animation_loop_test.py b/tests/unit/animation_loop_test.py new file mode 100644 index 0000000..12d777b --- /dev/null +++ b/tests/unit/animation_loop_test.py @@ -0,0 +1,107 @@ +"""Test Animation loop parameter. + +Verifies that loop=True causes animations to cycle instead of completing. +""" +import mcrfpy +import sys + +PASS = True +def check(name, condition): + global PASS + if not condition: + print(f"FAIL: {name}") + PASS = False + else: + print(f" ok: {name}") + + +# --- Setup --- +scene = mcrfpy.Scene("test") +mcrfpy.current_scene = scene + +# --- Test 1: Default loop=False, animation completes --- +sprite = mcrfpy.Sprite(pos=(0, 0)) +scene.children.append(sprite) +anim = sprite.animate("x", 100.0, 1.0) +check("default loop is False", anim.is_looping == False) + +# Step past duration +for _ in range(15): + mcrfpy.step(0.1) + +check("non-loop animation completes", anim.is_complete == True) + + +# --- Test 2: loop=True, animation does NOT complete --- +sprite2 = mcrfpy.Sprite(pos=(0, 0)) +scene.children.append(sprite2) +anim2 = sprite2.animate("x", 100.0, 0.5, loop=True) +check("loop=True sets is_looping", anim2.is_looping == True) + +# Step well past duration +for _ in range(20): + mcrfpy.step(0.1) + +check("looping animation never completes", anim2.is_complete == False) +check("looping animation has valid target", anim2.hasValidTarget() == True) + + +# --- Test 3: Sprite frame list with loop --- +sprite3 = mcrfpy.Sprite(pos=(0, 0)) +scene.children.append(sprite3) +anim3 = sprite3.animate("sprite_index", [0, 1, 2, 3], 0.4, loop=True) +check("frame list loop is_looping", anim3.is_looping == True) + +# Step through multiple cycles +for _ in range(20): + mcrfpy.step(0.1) + +check("frame list loop doesn't complete", anim3.is_complete == False) + + +# --- Test 4: Loop animation can be stopped --- +sprite4 = mcrfpy.Sprite(pos=(0, 0)) +scene.children.append(sprite4) +anim4 = sprite4.animate("x", 200.0, 0.5, loop=True) + +for _ in range(10): + mcrfpy.step(0.1) + +check("loop animation running before stop", anim4.is_complete == False) +anim4.stop() +check("loop animation stopped", anim4.is_complete == True) + + +# --- Test 5: Loop animation can be replaced --- +sprite5 = mcrfpy.Sprite(pos=(0, 0)) +scene.children.append(sprite5) +anim5a = sprite5.animate("x", 100.0, 0.5, loop=True) + +for _ in range(5): + mcrfpy.step(0.1) + +# Replace with non-looping +anim5b = sprite5.animate("x", 200.0, 0.5) +check("replacement anim is not looping", anim5b.is_looping == False) + +for _ in range(10): + mcrfpy.step(0.1) + +check("replacement anim completes", anim5b.is_complete == True) + + +# --- Test 6: Animation object created with loop=True via constructor --- +anim6 = mcrfpy.Animation("x", 100.0, 1.0, loop=True) +check("Animation constructor loop=True", anim6.is_looping == True) + +anim7 = mcrfpy.Animation("x", 100.0, 1.0) +check("Animation constructor default loop=False", anim7.is_looping == False) + + +# --- Summary --- +if PASS: + print("PASS") + sys.exit(0) +else: + print("FAIL") + sys.exit(1) diff --git a/tests/unit/entity_framelist_test.py b/tests/unit/entity_framelist_test.py new file mode 100644 index 0000000..edfc034 --- /dev/null +++ b/tests/unit/entity_framelist_test.py @@ -0,0 +1,103 @@ +"""Test Entity.animate() with list of int frame indices. + +Verifies that Entity supports sprite frame list animation, +including with loop=True. +""" +import mcrfpy +import sys + +PASS = True +def check(name, condition): + global PASS + if not condition: + print(f"FAIL: {name}") + PASS = False + else: + print(f" ok: {name}") + + +# --- Setup --- +scene = mcrfpy.Scene("test") +mcrfpy.current_scene = scene + +# Create a grid with an entity +tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) +grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(320, 320)) +scene.children.append(grid) + +entity = mcrfpy.Entity(grid_pos=(1, 1), texture=tex, sprite_index=0) +grid.entities.append(entity) + + +# --- Test 1: Entity.animate with list target --- +anim = entity.animate("sprite_index", [0, 1, 2, 3], 0.4) +check("entity animate with list returns Animation", anim is not None) +check("entity frame list anim has valid target", anim.hasValidTarget() == True) + +# Step to complete +for _ in range(10): + mcrfpy.step(0.1) + +check("entity frame list anim completes", anim.is_complete == True) + + +# --- Test 2: Entity.animate with list + loop=True --- +entity2 = mcrfpy.Entity(grid_pos=(2, 2), texture=tex, sprite_index=0) +grid.entities.append(entity2) + +anim2 = entity2.animate("sprite_index", [10, 11, 12, 13], 0.4, loop=True) +check("entity loop frame list is_looping", anim2.is_looping == True) + +# Step well past duration +for _ in range(20): + mcrfpy.step(0.1) + +check("entity loop frame list doesn't complete", anim2.is_complete == False) + +# The sprite_index should be one of the frame values +idx = entity2.sprite_index +check(f"entity sprite_index is valid frame ({idx})", idx in [10, 11, 12, 13]) + + +# --- Test 3: Invalid list items raise TypeError --- +try: + entity.animate("sprite_index", [1, 2, "bad", 4], 0.5) + check("invalid list item raises TypeError", False) +except TypeError: + check("invalid list item raises TypeError", True) + + +# --- Test 4: Entity.animate with int still works (no regression) --- +entity3 = mcrfpy.Entity(grid_pos=(3, 3), texture=tex, sprite_index=0) +grid.entities.append(entity3) + +anim3 = entity3.animate("sprite_index", 5, 0.2) +check("entity animate with int still works", anim3 is not None) + +for _ in range(5): + mcrfpy.step(0.1) + +check("entity int anim completes", anim3.is_complete == True) +check("entity sprite_index set to target", entity3.sprite_index == 5) + + +# --- Test 5: Entity.animate with float still works (no regression) --- +entity4 = mcrfpy.Entity(grid_pos=(4, 4), texture=tex, sprite_index=0) +grid.entities.append(entity4) + +anim4 = entity4.animate("draw_x", 5.0, 0.3) +check("entity animate with float still works", anim4 is not None) + +for _ in range(10): + mcrfpy.step(0.1) + +check("entity float anim completes", anim4.is_complete == True) + + +# --- Summary --- +if PASS: + print("PASS") + sys.exit(0) +else: + print("FAIL") + sys.exit(1) diff --git a/tests/unit/ping_pong_easing_test.py b/tests/unit/ping_pong_easing_test.py new file mode 100644 index 0000000..d46e654 --- /dev/null +++ b/tests/unit/ping_pong_easing_test.py @@ -0,0 +1,203 @@ +"""Test ping-pong easing functions. + +Verifies that ping-pong easings oscillate (0 -> 1 -> 0) and that +complete()/stop() on ping-pong animations returns to the start value. +""" +import mcrfpy +import sys + +PASS = True +def check(name, condition): + global PASS + if not condition: + print(f"FAIL: {name}") + PASS = False + else: + print(f" ok: {name}") + + +# --- Setup --- +scene = mcrfpy.Scene("test") +mcrfpy.current_scene = scene + + +# --- Test 1: Ping-pong easing enum members exist --- +check("PING_PONG exists", hasattr(mcrfpy.Easing, "PING_PONG")) +check("PING_PONG_SMOOTH exists", hasattr(mcrfpy.Easing, "PING_PONG_SMOOTH")) +check("PING_PONG_EASE_IN exists", hasattr(mcrfpy.Easing, "PING_PONG_EASE_IN")) +check("PING_PONG_EASE_OUT exists", hasattr(mcrfpy.Easing, "PING_PONG_EASE_OUT")) +check("PING_PONG_EASE_IN_OUT exists", hasattr(mcrfpy.Easing, "PING_PONG_EASE_IN_OUT")) + +# Check enum values are sequential from 31 +check("PING_PONG value is 31", int(mcrfpy.Easing.PING_PONG) == 31) +check("PING_PONG_EASE_IN_OUT value is 35", int(mcrfpy.Easing.PING_PONG_EASE_IN_OUT) == 35) + + +# --- Test 2: Ping-pong animation reaches midpoint then returns --- +sprite = mcrfpy.Sprite(pos=(0, 0)) +scene.children.append(sprite) +anim = sprite.animate("x", 100.0, 1.0, mcrfpy.Easing.PING_PONG) +check("ping-pong anim created", anim is not None) + +# Step to midpoint (t=0.5) +for _ in range(5): + mcrfpy.step(0.1) + +midpoint_x = sprite.x +check(f"at midpoint x ({midpoint_x:.1f}) is near 100", midpoint_x > 80.0) + +# Step to end (t=1.0) +for _ in range(5): + mcrfpy.step(0.1) + +final_x = sprite.x +check(f"at end x ({final_x:.1f}) returns near 0", final_x < 20.0) +check("ping-pong anim completes", anim.is_complete == True) + + +# --- Test 3: Ping-pong smooth animation oscillates --- +sprite2 = mcrfpy.Sprite(pos=(0, 0)) +scene.children.append(sprite2) +anim2 = sprite2.animate("x", 200.0, 1.0, mcrfpy.Easing.PING_PONG_SMOOTH) + +# Step to midpoint +for _ in range(5): + mcrfpy.step(0.1) +mid_x2 = sprite2.x +check(f"smooth midpoint x ({mid_x2:.1f}) is near 200", mid_x2 > 150.0) + +# Step to end +for _ in range(5): + mcrfpy.step(0.1) +final_x2 = sprite2.x +check(f"smooth end x ({final_x2:.1f}) returns near 0", final_x2 < 20.0) + + +# --- Test 4: Ping-pong with loop=True cycles continuously --- +sprite3 = mcrfpy.Sprite(pos=(0, 0)) +scene.children.append(sprite3) +anim3 = sprite3.animate("x", 100.0, 0.5, mcrfpy.Easing.PING_PONG, loop=True) +check("ping-pong loop is_looping", anim3.is_looping == True) + +# Step through 2 full cycles +for _ in range(20): + mcrfpy.step(0.1) + +check("ping-pong loop doesn't complete", anim3.is_complete == False) +check("ping-pong loop has valid target", anim3.hasValidTarget() == True) + + +# --- Test 5: complete() on ping-pong returns to start value --- +sprite4 = mcrfpy.Sprite(pos=(50, 0)) +scene.children.append(sprite4) +anim4 = sprite4.animate("x", 200.0, 1.0, mcrfpy.Easing.PING_PONG) + +# Step partway through +for _ in range(3): + mcrfpy.step(0.1) + +# Now complete - should return to start (50), not go to target (200) +anim4.complete() +completed_x = sprite4.x +check(f"complete() returns to start ({completed_x:.1f})", abs(completed_x - 50.0) < 5.0) + + +# --- Test 6: stop() on ping-pong freezes at current value (no jump) --- +sprite5 = mcrfpy.Sprite(pos=(0, 0)) +scene.children.append(sprite5) +anim5 = sprite5.animate("x", 100.0, 1.0, mcrfpy.Easing.PING_PONG_SMOOTH) + +# Step to ~midpoint +for _ in range(5): + mcrfpy.step(0.1) + +pre_stop_x = sprite5.x +anim5.stop() + +# Step more - value shouldn't change +for _ in range(5): + mcrfpy.step(0.1) + +post_stop_x = sprite5.x +check(f"stop() freezes value ({pre_stop_x:.1f} == {post_stop_x:.1f})", + abs(pre_stop_x - post_stop_x) < 1.0) + + +# --- Test 7: Callback receives start value for ping-pong --- +callback_values = [] +def on_complete(target, prop, value): + callback_values.append(value) + +sprite6 = mcrfpy.Sprite(pos=(10, 0)) +scene.children.append(sprite6) +anim6 = sprite6.animate("x", 300.0, 0.5, mcrfpy.Easing.PING_PONG, callback=on_complete) + +# Step to completion +for _ in range(10): + mcrfpy.step(0.1) + +check("callback was triggered", len(callback_values) == 1) +if callback_values: + # Callback value should be near start value (10), not target (300) + check(f"callback value ({callback_values[0]:.1f}) is near start (10)", + abs(callback_values[0] - 10.0) < 5.0) + + +# --- Test 8: EaseInOut ping-pong (sin^2) is smooth at boundaries --- +sprite7 = mcrfpy.Sprite(pos=(0, 0)) +scene.children.append(sprite7) +anim7 = sprite7.animate("x", 100.0, 1.0, mcrfpy.Easing.PING_PONG_EASE_IN_OUT) + +# Capture values at several timesteps +values = [] +for _ in range(10): + mcrfpy.step(0.1) + values.append(sprite7.x) + +# First value should be small (accelerating from 0) +check(f"easeInOut starts slow ({values[0]:.1f} < 30)", values[0] < 30.0) +# Middle values should be larger +check(f"easeInOut peaks in middle ({max(values):.1f} > 70)", max(values) > 70.0) +# Last value should be near 0 again +check(f"easeInOut returns to start ({values[-1]:.1f} < 10)", values[-1] < 10.0) + + +# --- Test 9: Legacy string names work for ping-pong --- +sprite8 = mcrfpy.Sprite(pos=(0, 0)) +scene.children.append(sprite8) +anim8 = sprite8.animate("x", 100.0, 0.5, "pingPong") +check("legacy string 'pingPong' works", anim8 is not None) + +for _ in range(10): + mcrfpy.step(0.1) +check("legacy string anim completes", anim8.is_complete == True) +check(f"legacy string returns to start ({sprite8.x:.1f})", abs(sprite8.x) < 5.0) + + +# --- Test 10: Standard easing complete() is unaffected (regression) --- +sprite9 = mcrfpy.Sprite(pos=(0, 0)) +scene.children.append(sprite9) +anim9 = sprite9.animate("x", 500.0, 1.0, mcrfpy.Easing.EASE_IN_OUT) + +# Step partway +for _ in range(3): + mcrfpy.step(0.1) + +anim9.complete() +check(f"standard easing complete() goes to target ({sprite9.x:.1f})", + abs(sprite9.x - 500.0) < 5.0) + + +# --- Test 11: Animation constructor with ping-pong easing --- +anim10 = mcrfpy.Animation("x", 100.0, 1.0, mcrfpy.Easing.PING_PONG, loop=True) +check("Animation constructor with PING_PONG", anim10 is not None) +check("Animation constructor loop=True", anim10.is_looping == True) + + +# --- Summary --- +if PASS: + print("PASS") + sys.exit(0) +else: + print("FAIL") + sys.exit(1) From a52568cc8dd3f0503c47d18f0ee2fdaee2deff0c Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 27 Feb 2026 22:12:17 -0500 Subject: [PATCH 3/3] entity animation version demo --- shade_sprite/demo.py | 257 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 247 insertions(+), 10 deletions(-) diff --git a/shade_sprite/demo.py b/shade_sprite/demo.py index e7548f8..06f1781 100644 --- a/shade_sprite/demo.py +++ b/shade_sprite/demo.py @@ -11,6 +11,7 @@ Scenes: 5 - Layer Compositing: demonstrates CharacterAssembler layered texture building 6 - Equipment Customizer: procedural + user-driven layer coloring for gear 7 - Asset Inventory: browse discovered layer categories and files + 8 - Entity Animation: engine-native Entity.animate() with loop - all formats Controls shown on-screen per scene. """ @@ -90,7 +91,7 @@ if __name__ == "__main__": sys.path.insert(0, parent_dir) from shade_sprite import ( - AnimatedSprite, Direction, PUNY_24, SLIME, + AnimatedSprite, Direction, PUNY_24, PUNY_29, SLIME, CREATURE_RPGMAKER, CharacterAssembler, AssetLibrary, FactionGenerator, ) @@ -130,7 +131,7 @@ def _no_assets_fallback(scene, scene_name): pos=(20, 60), fill_color=WARN_COLOR) ui.append(msg) controls = mcrfpy.Caption( - text="[1-7] Switch scenes", + text="[1-8] Switch scenes", pos=(20, 740), fill_color=DIM_COLOR) ui.append(controls) @@ -152,6 +153,7 @@ def _handle_scene_switch(key): mcrfpy.Key.NUM_5: "layers", mcrfpy.Key.NUM_6: "equip", mcrfpy.Key.NUM_7: "inventory", + mcrfpy.Key.NUM_8: "entity_anim", } name = scene_map.get(key) if name: @@ -267,7 +269,7 @@ def _build_scene_viewer(): ui.append(anim_ref) controls = mcrfpy.Caption( - text="[Q/E] Sheet [A/D] Animation [W/S] Direction [1-7] Scenes", + text="[Q/E] Sheet [A/D] Animation [W/S] Direction [1-8] Scenes", pos=(20, 740), fill_color=DIM_COLOR) ui.append(controls) @@ -415,7 +417,7 @@ def _build_scene_hsl(): ui.append(explain2) controls = mcrfpy.Caption( - text="[Left/Right] Hue +/-30 [Up/Down] Sat +/-0.1 [Z/X] Lit +/-0.1 [Q/E] Sheet [1-7] Scenes", + text="[Left/Right] Hue +/-30 [Up/Down] Sat +/-0.1 [Z/X] Lit +/-0.1 [Q/E] Sheet [1-8] Scenes", pos=(20, 740), fill_color=DIM_COLOR) ui.append(controls) @@ -547,7 +549,7 @@ def _build_scene_gallery(): ui.append(dir_info) controls = mcrfpy.Caption( - text="[W/S] Direction [A/D] Animation [1-7] Scenes", + text="[W/S] Direction [A/D] Animation [1-8] Scenes", pos=(20, 740), fill_color=DIM_COLOR) ui.append(controls) @@ -659,7 +661,7 @@ def _build_scene_factions(): _populate() controls = mcrfpy.Caption( - text="[Space] Re-roll factions [1-7] Scenes", + text="[Space] Re-roll factions [1-8] Scenes", pos=(20, 740), fill_color=DIM_COLOR) ui.append(controls) @@ -840,7 +842,7 @@ def _build_scene_layers(): ui.append(code_lbl4) controls = mcrfpy.Caption( - text="[Q/E] Overlay sheet [Left/Right] Overlay hue +/-30 [1-7] Scenes", + text="[Q/E] Overlay sheet [Left/Right] Overlay hue +/-30 [1-8] Scenes", pos=(20, 740), fill_color=DIM_COLOR) ui.append(controls) @@ -1062,7 +1064,7 @@ def _build_scene_equip(): _generate_variants() controls = mcrfpy.Caption( - text="[Tab] Slot [Q/E] Sheet [Left/Right] Hue [Up/Down] Sat [Z/X] Lit [T] Toggle [R] Randomize [1-7] Scenes", + text="[Tab] Slot [Q/E] Sheet [Left/Right] Hue [Up/Down] Sat [Z/X] Lit [T] Toggle [R] Randomize [1-8] Scenes", pos=(20, 740), fill_color=DIM_COLOR) ui.append(controls) @@ -1148,7 +1150,7 @@ def _build_scene_inventory(): text="The AssetLibrary scans the 'Individual Spritesheets' directory.", pos=(20, 90), fill_color=DIM_COLOR) ui.append(msg2) - controls = mcrfpy.Caption(text="[1-7] Switch scenes", + controls = mcrfpy.Caption(text="[1-8] Switch scenes", pos=(20, 740), fill_color=DIM_COLOR) ui.append(controls) @@ -1275,7 +1277,7 @@ def _build_scene_inventory(): _refresh() controls = mcrfpy.Caption( - text="[W/S] Category [A/D] Subcategory [PgUp/PgDn] Scroll files [1-7] Scenes", + text="[W/S] Category [A/D] Subcategory [PgUp/PgDn] Scroll files [1-8] Scenes", pos=(20, 740), fill_color=DIM_COLOR) ui.append(controls) @@ -1321,6 +1323,240 @@ def _build_scene_inventory(): return scene +# --------------------------------------------------------------------------- +# Scene 8: Entity Animation (engine-native, all formats) +# --------------------------------------------------------------------------- +def _format_frame_list(fmt, anim_name, direction): + """Convert animation def to flat sprite index list for Entity.animate().""" + anim = fmt.animations[anim_name] + return [fmt.sprite_index(f.col, direction) for f in anim.frames] + + +def _format_duration(fmt, anim_name): + """Total duration in seconds.""" + anim = fmt.animations[anim_name] + return sum(f.duration for f in anim.frames) / 1000.0 + + +def _build_scene_entity_anim(): + scene = mcrfpy.Scene("entity_anim") + sheets = _available_sheets() + if not sheets: + return _no_assets_fallback(scene, "Entity Animation") + + ui = scene.children + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=BG) + ui.append(bg) + + title = mcrfpy.Caption(text="[8] Entity Animation (engine-native loop)", + pos=(20, 10), fill_color=TITLE_COLOR) + ui.append(title) + + explain = mcrfpy.Caption( + text="Entity.animate('sprite_index', [frames], duration, loop=True) - no Python timer needed", + pos=(20, 40), fill_color=LABEL_COLOR) + ui.append(explain) + + # Collect all format sections + # Each section: format, texture path, available animations, grid + entities + sections = [] # (fmt, name, tex, grid, entities, anim_names) + + state = {"anim_idx": 0, "dir_idx": 0} + + section_y = 80 + grid_w, grid_h = 200, 160 + + # --- PUNY_24 --- + puny24_lbl = mcrfpy.Caption(text="PUNY_24 (8-dir, free)", + pos=(20, section_y), fill_color=ACCENT_COLOR) + ui.append(puny24_lbl) + + fmt24 = PUNY_24 + tex24 = mcrfpy.Texture(sheets[0], fmt24.tile_w, fmt24.tile_h) + grid24 = mcrfpy.Grid(grid_size=(8, 1), texture=tex24, + pos=(20, section_y + 25), size=(grid_w * 2, grid_h)) + grid24.zoom = 0.25 + ui.append(grid24) + + entities24 = [] + anim_names24 = list(fmt24.animations.keys()) + for i, d in enumerate(Direction): + e = mcrfpy.Entity(grid_pos=(i, 0), texture=tex24, sprite_index=0) + grid24.entities.append(e) + entities24.append(e) + sections.append((fmt24, "PUNY_24", tex24, grid24, entities24, anim_names24)) + + # Direction labels for compass + for i, d in enumerate(Direction): + lbl = mcrfpy.Caption(text=d.name, pos=(20 + i * 50, section_y + 25 + grid_h + 2), + fill_color=DIM_COLOR) + ui.append(lbl) + + # --- PUNY_29 (if paid sheets exist with 29 cols) --- + # PUNY_29 uses 928px wide sheets; check if any available are that size + puny29_sheet = None + for s in sheets: + try: + # Try loading as PUNY_29 to check + t = mcrfpy.Texture(s, PUNY_29.tile_w, PUNY_29.tile_h) + # Check column count via sprite count (29 cols * 8 rows = 232) + puny29_sheet = s + break + except Exception: + pass + + section_y2 = section_y + grid_h + 45 + if puny29_sheet: + puny29_lbl = mcrfpy.Caption(text="PUNY_29 (8-dir, paid - extra anims)", + pos=(20, section_y2), fill_color=ACCENT_COLOR) + ui.append(puny29_lbl) + + fmt29 = PUNY_29 + tex29 = mcrfpy.Texture(puny29_sheet, fmt29.tile_w, fmt29.tile_h) + grid29 = mcrfpy.Grid(grid_size=(8, 1), texture=tex29, + pos=(20, section_y2 + 25), size=(grid_w * 2, grid_h)) + grid29.zoom = 0.25 + ui.append(grid29) + + entities29 = [] + anim_names29 = list(fmt29.animations.keys()) + for i, d in enumerate(Direction): + e = mcrfpy.Entity(grid_pos=(i, 0), texture=tex29, sprite_index=0) + grid29.entities.append(e) + entities29.append(e) + sections.append((fmt29, "PUNY_29", tex29, grid29, entities29, anim_names29)) + else: + puny29_lbl = mcrfpy.Caption(text="PUNY_29 (not available - need 928px wide sheet)", + pos=(20, section_y2), fill_color=DIM_COLOR) + ui.append(puny29_lbl) + + # --- SLIME --- + section_y3 = section_y2 + grid_h + 45 + slime_p = _slime_path() + if slime_p: + slime_lbl = mcrfpy.Caption(text="SLIME (1-dir, non-directional)", + pos=(20, section_y3), fill_color=ACCENT_COLOR) + ui.append(slime_lbl) + + fmt_slime = SLIME + tex_slime = mcrfpy.Texture(slime_p, fmt_slime.tile_w, fmt_slime.tile_h) + grid_slime = mcrfpy.Grid(grid_size=(2, 1), texture=tex_slime, + pos=(20, section_y3 + 25), size=(120, grid_h)) + grid_slime.zoom = 0.25 + ui.append(grid_slime) + + entities_slime = [] + anim_names_slime = list(fmt_slime.animations.keys()) + for i, aname in enumerate(anim_names_slime): + e = mcrfpy.Entity(grid_pos=(i, 0), texture=tex_slime, sprite_index=0) + grid_slime.entities.append(e) + entities_slime.append(e) + + slime_note = mcrfpy.Caption( + text="idle / walk", pos=(20, section_y3 + 25 + grid_h + 2), + fill_color=DIM_COLOR) + ui.append(slime_note) + + sections.append((fmt_slime, "SLIME", tex_slime, grid_slime, + entities_slime, anim_names_slime)) + else: + slime_lbl = mcrfpy.Caption(text="SLIME (not available)", + pos=(20, section_y3), fill_color=DIM_COLOR) + ui.append(slime_lbl) + + # --- Info panel (right side) --- + info_x = 500 + anim_info = mcrfpy.Caption(text="Animation: idle", pos=(info_x, 80), + fill_color=HIGHLIGHT_COLOR) + ui.append(anim_info) + dir_info = mcrfpy.Caption(text="Direction: S (0)", pos=(info_x, 110), + fill_color=LABEL_COLOR) + ui.append(dir_info) + frame_info = mcrfpy.Caption(text="", pos=(info_x, 140), + fill_color=ACCENT_COLOR) + ui.append(frame_info) + + # Code example + code_y = 200 + code_lines = [ + "# Engine-native sprite frame animation:", + "frames = [fmt.sprite_index(f.col, dir)", + " for f in fmt.animations['walk'].frames]", + "entity.animate('sprite_index', frames,", + " duration, loop=True)", + "", + "# No Python Timer or AnimatedSprite needed!", + "# The C++ AnimationManager handles the loop.", + ] + for i, line in enumerate(code_lines): + c = mcrfpy.Caption(text=line, pos=(info_x, code_y + i * 25), + fill_color=mcrfpy.Color(150, 200, 150)) + ui.append(c) + + # Show all available animation names per format + names_y = code_y + len(code_lines) * 25 + 20 + for fmt, name, _, _, _, anim_names in sections: + albl = mcrfpy.Caption( + text=f"{name}: {', '.join(anim_names)}", + pos=(info_x, names_y), fill_color=DIM_COLOR) + ui.append(albl) + names_y += 25 + + def _apply_anims(): + """Apply current animation to all entities in all sections.""" + d = Direction(state["dir_idx"]) + for fmt, name, tex, grid, entities, anim_names in sections: + idx = state["anim_idx"] % len(anim_names) + anim_name = anim_names[idx] + frames = _format_frame_list(fmt, anim_name, d) + dur = _format_duration(fmt, anim_name) + is_loop = fmt.animations[anim_name].loop + + for e in entities: + e.animate("sprite_index", frames, dur, loop=is_loop) + + # Use first section for info display + if sections: + fmt0, _, _, _, _, anames0 = sections[0] + idx0 = state["anim_idx"] % len(anames0) + aname = anames0[idx0] + adef = fmt0.animations[aname] + nf = len(adef.frames) + loop_str = "loop" if adef.loop else "one-shot" + chain_str = f" -> {adef.chain_to}" if adef.chain_to else "" + anim_info.text = f"Animation: {aname}" + frame_info.text = f"Frames: {nf} ({loop_str}{chain_str})" + dir_info.text = f"Direction: {d.name} ({d.value})" + + _apply_anims() + + controls = mcrfpy.Caption( + text="[A/D] Animation [W/S] Direction [1-8] Scenes", + pos=(20, 740), fill_color=DIM_COLOR) + ui.append(controls) + + def on_key(key, action): + if action != mcrfpy.InputState.PRESSED: + return + if _handle_scene_switch(key): + return + if key == mcrfpy.Key.A: + state["anim_idx"] -= 1 + _apply_anims() + elif key == mcrfpy.Key.D: + state["anim_idx"] += 1 + _apply_anims() + elif key == mcrfpy.Key.W: + state["dir_idx"] = (state["dir_idx"] - 1) % 8 + _apply_anims() + elif key == mcrfpy.Key.S: + state["dir_idx"] = (state["dir_idx"] + 1) % 8 + _apply_anims() + + scene.on_key = on_key + return scene + + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -1338,6 +1574,7 @@ def main(): _build_scene_layers() _build_scene_equip() _build_scene_inventory() + _build_scene_entity_anim() # Start animation timer (20fps animation updates) # Keep a reference so the Python cache lookup works and (timer, runtime) is passed