From 5d41292bf63aa3cedc6147e15616384a6f956502 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sat, 3 Jan 2026 19:21:37 -0500 Subject: [PATCH 1/4] Timer refactor: stopwatch-like semantics, mcrfpy.timers collection closes #173 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major Timer API improvements: - Add `stopped` flag to Timer C++ class for proper state management - Add `start()` method to restart stopped timers (preserves callback) - Add `stop()` method that removes from engine but preserves callback - Make `active` property read-write (True=start/resume, False=pause) - Add `start=True` init parameter to create timers in stopped state - Add `mcrfpy.timers` module-level collection (tuple of active timers) - One-shot timers now set stopped=true instead of clearing callback - Remove deprecated `setTimer()` and `delTimer()` module functions Timer callbacks now receive (timer, runtime) instead of just (runtime). Updated all tests to use new Timer API and callback signature. šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/GameEngine.cpp | 42 ++--- src/GameEngine.h | 2 +- src/McRFPy_API.cpp | 73 ++++----- src/McRFPy_API.h | 7 +- src/PyTimer.cpp | 234 ++++++++++++++++++++-------- src/PyTimer.h | 25 +-- src/Timer.cpp | 60 ++++--- src/Timer.h | 23 +-- tests/unit/api_timer_test.py | 128 ++++++++++----- tests/unit/test_grid_cell_events.py | 12 +- tests/unit/test_headless_click.py | 12 +- tests/unit/test_mouse_enter_exit.py | 4 +- tests/unit/test_on_move.py | 12 +- tests/unit/test_step_function.py | 8 +- tests/unit/test_timer_once.py | 34 ++-- tests/unit/working_timer_test.py | 26 ++-- 16 files changed, 440 insertions(+), 262 deletions(-) diff --git a/src/GameEngine.cpp b/src/GameEngine.cpp index aa762fc..b6f5eb3 100644 --- a/src/GameEngine.cpp +++ b/src/GameEngine.cpp @@ -357,51 +357,35 @@ std::shared_ptr GameEngine::getTimer(const std::string& name) return nullptr; } -void GameEngine::manageTimer(std::string name, PyObject* target, int interval) -{ - auto it = timers.find(name); - - // #153 - In headless mode, use simulation_time instead of real-time clock - int now = headless ? simulation_time : runtime.getElapsedTime().asMilliseconds(); - - if (it != timers.end()) // overwrite existing - { - if (target == NULL || target == Py_None) - { - // Delete: Overwrite existing timer with one that calls None. This will be deleted in the next timer check - // see gitea issue #4: this allows for a timer to be deleted during its own call to itself - timers[name] = std::make_shared(Py_None, 1000, now); - return; - } - } - if (target == NULL || target == Py_None) - { - std::cout << "Refusing to initialize timer to None. It's not an error, it's just pointless." << std::endl; - return; - } - timers[name] = std::make_shared(target, interval, now); -} +// Note: manageTimer() removed in #173 - use Timer objects directly void GameEngine::testTimers() { - int now = runtime.getElapsedTime().asMilliseconds(); + int now = headless ? simulation_time : runtime.getElapsedTime().asMilliseconds(); auto it = timers.begin(); while (it != timers.end()) { // Keep a local copy of the timer to prevent use-after-free. - // If the callback calls delTimer(), the map entry gets replaced, + // If the callback calls stop(), the timer may be marked for removal, // but we need the Timer object to survive until test() returns. auto timer = it->second; - timer->test(now); - // Remove timers that have been cancelled or are one-shot and fired. + // Skip stopped timers (they'll be removed below) + if (!timer->isStopped()) { + timer->test(now); + } + + // Remove timers that have been stopped (including one-shot timers that fired). + // The stopped flag is the authoritative marker for "remove from map". // Note: Check it->second (current map value) in case callback replaced it. - if (!it->second->getCallback() || it->second->getCallback() == Py_None) + if (it->second->isStopped()) { it = timers.erase(it); } else + { it++; + } } } diff --git a/src/GameEngine.h b/src/GameEngine.h index 69a667c..c5d5aba 100644 --- a/src/GameEngine.h +++ b/src/GameEngine.h @@ -169,7 +169,7 @@ public: int getFrame() { return currentFrame; } float getFrameTime() { return frameTime; } sf::View getView() { return visible; } - void manageTimer(std::string, PyObject*, int); + // Note: manageTimer() removed in #173 - use Timer objects directly std::shared_ptr getTimer(const std::string& name); void setWindowScale(float); bool isHeadless() const { return headless; } diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index dcbcb74..0f07fbc 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -24,6 +24,7 @@ #include "GridLayers.h" #include "Resources.h" #include "PyScene.h" +#include "PythonObjectCache.h" #include #include #include @@ -52,6 +53,10 @@ static PyObject* mcrfpy_module_getattr(PyObject* self, PyObject* args) return McRFPy_API::api_get_scenes(); } + if (strcmp(name, "timers") == 0) { + return McRFPy_API::api_get_timers(); + } + if (strcmp(name, "default_transition") == 0) { return PyTransition::to_python(PyTransition::default_transition); } @@ -80,6 +85,11 @@ static int mcrfpy_module_setattro(PyObject* self, PyObject* name, PyObject* valu return -1; } + if (strcmp(name_str, "timers") == 0) { + PyErr_SetString(PyExc_AttributeError, "'timers' is read-only"); + return -1; + } + if (strcmp(name_str, "default_transition") == 0) { TransitionType trans; if (!PyTransition::from_arg(value, &trans, nullptr)) { @@ -138,26 +148,7 @@ static PyTypeObject McRFPyModuleType = { static PyMethodDef mcrfpyMethods[] = { - {"setTimer", McRFPy_API::_setTimer, METH_VARARGS, - MCRF_FUNCTION(setTimer, - MCRF_SIG("(name: str, handler: callable, interval: int)", "None"), - MCRF_DESC("Create or update a recurring timer."), - MCRF_ARGS_START - MCRF_ARG("name", "Unique identifier for the timer") - MCRF_ARG("handler", "Function called with (runtime: float) parameter") - MCRF_ARG("interval", "Time between calls in milliseconds") - MCRF_RETURNS("None") - MCRF_NOTE("If a timer with this name exists, it will be replaced. The handler receives the total runtime in seconds as its argument.") - )}, - {"delTimer", McRFPy_API::_delTimer, METH_VARARGS, - MCRF_FUNCTION(delTimer, - MCRF_SIG("(name: str)", "None"), - MCRF_DESC("Stop and remove a timer."), - MCRF_ARGS_START - MCRF_ARG("name", "Timer identifier to remove") - MCRF_RETURNS("None") - MCRF_NOTE("No error is raised if the timer doesn't exist.") - )}, + // Note: setTimer and delTimer removed in #173 - use Timer objects instead {"step", McRFPy_API::_step, METH_VARARGS, MCRF_FUNCTION(step, MCRF_SIG("(dt: float = None)", "float"), @@ -883,22 +874,34 @@ PyObject* McRFPy_API::_setScene(PyObject* self, PyObject* args) { return Py_None; } -PyObject* McRFPy_API::_setTimer(PyObject* self, PyObject* args) { // TODO - compare with UIDrawable mouse & Scene Keyboard methods - inconsistent responsibility for incref/decref around mcrogueface - const char* name; - PyObject* callable; - int interval; - if (!PyArg_ParseTuple(args, "sOi", &name, &callable, &interval)) return NULL; - game->manageTimer(name, callable, interval); - Py_INCREF(Py_None); - return Py_None; -} +// #173: Get all timers as a tuple of Python Timer objects +PyObject* McRFPy_API::api_get_timers() +{ + if (!game) { + return PyTuple_New(0); + } -PyObject* McRFPy_API::_delTimer(PyObject* self, PyObject* args) { - const char* name; - if (!PyArg_ParseTuple(args, "s", &name)) return NULL; - game->manageTimer(name, NULL, 0); - Py_INCREF(Py_None); - return Py_None; + // Count timers that have Python wrappers + std::vector timer_objs; + for (auto& pair : game->timers) { + auto& timer = pair.second; + if (timer && timer->serial_number != 0) { + PyObject* timer_obj = PythonObjectCache::getInstance().lookup(timer->serial_number); + if (timer_obj && timer_obj != Py_None) { + timer_objs.push_back(timer_obj); + } + } + } + + PyObject* tuple = PyTuple_New(timer_objs.size()); + if (!tuple) return NULL; + + for (Py_ssize_t i = 0; i < static_cast(timer_objs.size()); i++) { + Py_INCREF(timer_objs[i]); + PyTuple_SET_ITEM(tuple, i, timer_objs[i]); + } + + return tuple; } // #153 - Headless simulation control diff --git a/src/McRFPy_API.h b/src/McRFPy_API.h index b04d893..f6e7440 100644 --- a/src/McRFPy_API.h +++ b/src/McRFPy_API.h @@ -43,9 +43,7 @@ public: // Internal - used by PySceneObject::activate() static PyObject* _setScene(PyObject*, PyObject*); - // timer control - static PyObject* _setTimer(PyObject*, PyObject*); - static PyObject* _delTimer(PyObject*, PyObject*); + // Note: setTimer/delTimer removed in #173 - use Timer objects instead // #153 - Headless simulation control static PyObject* _step(PyObject*, PyObject*); @@ -88,6 +86,9 @@ public: static int api_set_current_scene(PyObject* value); static PyObject* api_get_scenes(); + // #173: Module-level timer collection accessor + static PyObject* api_get_timers(); + // Exception handling - signal game loop to exit on unhandled Python exceptions static std::atomic exception_occurred; static std::atomic exit_code; diff --git a/src/PyTimer.cpp b/src/PyTimer.cpp index 95f619a..9d43b66 100644 --- a/src/PyTimer.cpp +++ b/src/PyTimer.cpp @@ -10,13 +10,15 @@ PyObject* PyTimer::repr(PyObject* self) { PyTimerObject* timer = (PyTimerObject*)self; std::ostringstream oss; oss << "data) { oss << "interval=" << timer->data->getInterval() << "ms "; if (timer->data->isOnce()) { oss << "once=True "; } - if (timer->data->isPaused()) { + if (timer->data->isStopped()) { + oss << "stopped"; + } else if (timer->data->isPaused()) { oss << "paused"; // Get current time to show remaining int current_time = 0; @@ -25,15 +27,15 @@ PyObject* PyTimer::repr(PyObject* self) { } oss << " (remaining=" << timer->data->getRemaining(current_time) << "ms)"; } else if (timer->data->isActive()) { - oss << "active"; + oss << "running"; } else { - oss << "cancelled"; + oss << "inactive"; } } else { oss << "uninitialized"; } oss << ">"; - + return PyUnicode_FromString(oss.str().c_str()); } @@ -48,38 +50,39 @@ PyObject* PyTimer::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) { } int PyTimer::init(PyTimerObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"name", "callback", "interval", "once", NULL}; + static const char* kwlist[] = {"name", "callback", "interval", "once", "start", NULL}; const char* name = nullptr; PyObject* callback = nullptr; int interval = 0; - int once = 0; // Use int for bool parameter - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOi|p", const_cast(kwlist), - &name, &callback, &interval, &once)) { + int once = 0; // Use int for bool parameter + int start = 1; // Default: start=True + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOi|pp", const_cast(kwlist), + &name, &callback, &interval, &once, &start)) { return -1; } - + if (!PyCallable_Check(callback)) { PyErr_SetString(PyExc_TypeError, "callback must be callable"); return -1; } - + if (interval <= 0) { PyErr_SetString(PyExc_ValueError, "interval must be positive"); return -1; } - + self->name = name; - + // Get current time from game engine int current_time = 0; if (Resources::game) { current_time = Resources::game->runtime.getElapsedTime().asMilliseconds(); } - - // Create the timer - self->data = std::make_shared(callback, interval, current_time, (bool)once); - + + // Create the timer with start parameter + self->data = std::make_shared(callback, interval, current_time, (bool)once, (bool)start); + // Register in Python object cache if (self->data->serial_number == 0) { self->data->serial_number = PythonObjectCache::getInstance().assignSerial(); @@ -89,12 +92,17 @@ int PyTimer::init(PyTimerObject* self, PyObject* args, PyObject* kwds) { Py_DECREF(weakref); // Cache owns the reference now } } - - // Register with game engine - if (Resources::game) { + + // Register with game engine only if starting + if (Resources::game && start) { + // If a timer with this name already exists, stop it first + auto it = Resources::game->timers.find(self->name); + if (it != Resources::game->timers.end() && it->second != self->data) { + it->second->stop(); + } Resources::game->timers[self->name] = self->data; } - + return 0; } @@ -103,7 +111,7 @@ void PyTimer::dealloc(PyTimerObject* self) { if (self->weakreflist != nullptr) { PyObject_ClearWeakRefs((PyObject*)self); } - + // Remove from game engine if still registered if (Resources::game && !self->name.empty()) { auto it = Resources::game->timers.find(self->name); @@ -111,28 +119,71 @@ void PyTimer::dealloc(PyTimerObject* self) { Resources::game->timers.erase(it); } } - + // Explicitly destroy std::string self->name.~basic_string(); - - // Clear shared_ptr + + // Clear shared_ptr - this is the only place that truly destroys the Timer self->data.reset(); - + Py_TYPE(self)->tp_free((PyObject*)self); } // Timer control methods +PyObject* PyTimer::start(PyTimerObject* self, PyObject* Py_UNUSED(ignored)) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Timer not initialized"); + return nullptr; + } + + int current_time = 0; + if (Resources::game) { + current_time = Resources::game->runtime.getElapsedTime().asMilliseconds(); + + // If another timer has this name, stop it first + auto it = Resources::game->timers.find(self->name); + if (it != Resources::game->timers.end() && it->second != self->data) { + it->second->stop(); + } + + // Add to engine map + Resources::game->timers[self->name] = self->data; + } + + self->data->start(current_time); + Py_RETURN_NONE; +} + +PyObject* PyTimer::stop(PyTimerObject* self, PyObject* Py_UNUSED(ignored)) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Timer not initialized"); + return nullptr; + } + + // Remove from game engine map (but preserve the Timer data!) + if (Resources::game && !self->name.empty()) { + auto it = Resources::game->timers.find(self->name); + if (it != Resources::game->timers.end() && it->second == self->data) { + Resources::game->timers.erase(it); + } + } + + self->data->stop(); + // NOTE: We do NOT reset self->data here - the timer can be restarted + Py_RETURN_NONE; +} + PyObject* PyTimer::pause(PyTimerObject* self, PyObject* Py_UNUSED(ignored)) { if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Timer not initialized"); return nullptr; } - + int current_time = 0; if (Resources::game) { current_time = Resources::game->runtime.getElapsedTime().asMilliseconds(); } - + self->data->pause(current_time); Py_RETURN_NONE; } @@ -142,32 +193,13 @@ PyObject* PyTimer::resume(PyTimerObject* self, PyObject* Py_UNUSED(ignored)) { PyErr_SetString(PyExc_RuntimeError, "Timer not initialized"); return nullptr; } - + int current_time = 0; if (Resources::game) { current_time = Resources::game->runtime.getElapsedTime().asMilliseconds(); } - - self->data->resume(current_time); - Py_RETURN_NONE; -} -PyObject* PyTimer::cancel(PyTimerObject* self, PyObject* Py_UNUSED(ignored)) { - if (!self->data) { - PyErr_SetString(PyExc_RuntimeError, "Timer not initialized"); - return nullptr; - } - - // Remove from game engine - if (Resources::game && !self->name.empty()) { - auto it = Resources::game->timers.find(self->name); - if (it != Resources::game->timers.end() && it->second == self->data) { - Resources::game->timers.erase(it); - } - } - - self->data->cancel(); - self->data.reset(); + self->data->resume(current_time); Py_RETURN_NONE; } @@ -176,12 +208,23 @@ PyObject* PyTimer::restart(PyTimerObject* self, PyObject* Py_UNUSED(ignored)) { PyErr_SetString(PyExc_RuntimeError, "Timer not initialized"); return nullptr; } - + int current_time = 0; if (Resources::game) { current_time = Resources::game->runtime.getElapsedTime().asMilliseconds(); + + // Ensure timer is in engine map + auto it = Resources::game->timers.find(self->name); + if (it == Resources::game->timers.end()) { + // Timer was stopped, re-add it + Resources::game->timers[self->name] = self->data; + } else if (it->second != self->data) { + // Another timer has this name, stop it and replace + it->second->stop(); + Resources::game->timers[self->name] = self->data; + } } - + self->data->restart(current_time); Py_RETURN_NONE; } @@ -240,14 +283,62 @@ PyObject* PyTimer::get_paused(PyTimerObject* self, void* closure) { return PyBool_FromLong(self->data->isPaused()); } +PyObject* PyTimer::get_stopped(PyTimerObject* self, void* closure) { + if (!self->data) { + return Py_True; // Uninitialized is effectively stopped + } + + return PyBool_FromLong(self->data->isStopped()); +} + PyObject* PyTimer::get_active(PyTimerObject* self, void* closure) { if (!self->data) { return Py_False; } - + return PyBool_FromLong(self->data->isActive()); } +int PyTimer::set_active(PyTimerObject* self, PyObject* value, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Timer not initialized"); + return -1; + } + + bool want_active = PyObject_IsTrue(value); + + int current_time = 0; + if (Resources::game) { + current_time = Resources::game->runtime.getElapsedTime().asMilliseconds(); + } + + if (want_active) { + if (self->data->isStopped()) { + // Reactivate a stopped timer + if (Resources::game) { + // Handle name collision + auto it = Resources::game->timers.find(self->name); + if (it != Resources::game->timers.end() && it->second != self->data) { + it->second->stop(); + } + Resources::game->timers[self->name] = self->data; + } + self->data->start(current_time); + } else if (self->data->isPaused()) { + // Resume from pause + self->data->resume(current_time); + } + // If already running, do nothing + } else { + // Setting active=False means pause + if (!self->data->isPaused() && !self->data->isStopped()) { + self->data->pause(current_time); + } + } + + return 0; +} + PyObject* PyTimer::get_callback(PyTimerObject* self, void* closure) { if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Timer not initialized"); @@ -312,19 +403,35 @@ PyGetSetDef PyTimer::getsetters[] = { {"interval", (getter)PyTimer::get_interval, (setter)PyTimer::set_interval, MCRF_PROPERTY(interval, "Timer interval in milliseconds (int). Must be positive. Can be changed while timer is running."), NULL}, {"remaining", (getter)PyTimer::get_remaining, NULL, - MCRF_PROPERTY(remaining, "Time remaining until next trigger in milliseconds (int, read-only). Preserved when timer is paused."), NULL}, + MCRF_PROPERTY(remaining, "Time remaining until next trigger in milliseconds (int, read-only). Full interval when stopped."), NULL}, {"paused", (getter)PyTimer::get_paused, NULL, MCRF_PROPERTY(paused, "Whether the timer is paused (bool, read-only). Paused timers preserve their remaining time."), NULL}, - {"active", (getter)PyTimer::get_active, NULL, - MCRF_PROPERTY(active, "Whether the timer is active and not paused (bool, read-only). False if cancelled or paused."), NULL}, + {"stopped", (getter)PyTimer::get_stopped, NULL, + MCRF_PROPERTY(stopped, "Whether the timer is stopped (bool, read-only). Stopped timers are not in the engine tick loop but preserve their callback."), NULL}, + {"active", (getter)PyTimer::get_active, (setter)PyTimer::set_active, + MCRF_PROPERTY(active, "Running state (bool, read-write). True if running (not paused, not stopped). Set True to start/resume, False to pause."), NULL}, {"callback", (getter)PyTimer::get_callback, (setter)PyTimer::set_callback, - MCRF_PROPERTY(callback, "The callback function to be called when timer fires (callable). Can be changed while timer is running."), NULL}, + MCRF_PROPERTY(callback, "The callback function (callable). Preserved when stopped, allowing timer restart."), NULL}, {"once", (getter)PyTimer::get_once, (setter)PyTimer::set_once, - MCRF_PROPERTY(once, "Whether the timer stops after firing once (bool). If False, timer repeats indefinitely."), NULL}, + MCRF_PROPERTY(once, "Whether the timer stops after firing once (bool). One-shot timers can be restarted."), NULL}, {NULL} }; PyMethodDef PyTimer::methods[] = { + {"start", (PyCFunction)PyTimer::start, METH_NOARGS, + MCRF_METHOD(Timer, start, + MCRF_SIG("()", "None"), + MCRF_DESC("Start the timer, adding it to the engine tick loop."), + MCRF_RETURNS("None") + MCRF_NOTE("Resets progress and begins counting toward the next fire. If another timer has this name, it will be stopped.") + )}, + {"stop", (PyCFunction)PyTimer::stop, METH_NOARGS, + MCRF_METHOD(Timer, stop, + MCRF_SIG("()", "None"), + MCRF_DESC("Stop the timer and remove it from the engine tick loop."), + MCRF_RETURNS("None") + MCRF_NOTE("The callback is preserved, so the timer can be restarted with start() or restart().") + )}, {"pause", (PyCFunction)PyTimer::pause, METH_NOARGS, MCRF_METHOD(Timer, pause, MCRF_SIG("()", "None"), @@ -339,19 +446,12 @@ PyMethodDef PyTimer::methods[] = { MCRF_RETURNS("None") MCRF_NOTE("Has no effect if the timer is not paused. Timer will fire after the remaining time elapses.") )}, - {"cancel", (PyCFunction)PyTimer::cancel, METH_NOARGS, - MCRF_METHOD(Timer, cancel, - MCRF_SIG("()", "None"), - MCRF_DESC("Cancel the timer and remove it from the timer system."), - MCRF_RETURNS("None") - MCRF_NOTE("The timer will no longer fire and cannot be restarted. The callback will not be called again.") - )}, {"restart", (PyCFunction)PyTimer::restart, METH_NOARGS, MCRF_METHOD(Timer, restart, MCRF_SIG("()", "None"), - MCRF_DESC("Restart the timer from the beginning."), + MCRF_DESC("Restart the timer from the beginning and ensure it's running."), MCRF_RETURNS("None") - MCRF_NOTE("Resets the timer to fire after a full interval from now, regardless of remaining time.") + MCRF_NOTE("Resets progress and adds timer to engine if stopped. Equivalent to stop() followed by start().") )}, {NULL} }; \ No newline at end of file diff --git a/src/PyTimer.h b/src/PyTimer.h index 3ee210c..438b83a 100644 --- a/src/PyTimer.h +++ b/src/PyTimer.h @@ -23,9 +23,10 @@ public: static void dealloc(PyTimerObject* self); // Timer control methods + static PyObject* start(PyTimerObject* self, PyObject* Py_UNUSED(ignored)); + static PyObject* stop(PyTimerObject* self, PyObject* Py_UNUSED(ignored)); static PyObject* pause(PyTimerObject* self, PyObject* Py_UNUSED(ignored)); static PyObject* resume(PyTimerObject* self, PyObject* Py_UNUSED(ignored)); - static PyObject* cancel(PyTimerObject* self, PyObject* Py_UNUSED(ignored)); static PyObject* restart(PyTimerObject* self, PyObject* Py_UNUSED(ignored)); // Timer property getters @@ -34,7 +35,9 @@ public: static int set_interval(PyTimerObject* self, PyObject* value, void* closure); static PyObject* get_remaining(PyTimerObject* self, void* closure); static PyObject* get_paused(PyTimerObject* self, void* closure); + static PyObject* get_stopped(PyTimerObject* self, void* closure); static PyObject* get_active(PyTimerObject* self, void* closure); + static int set_active(PyTimerObject* self, PyObject* value, void* closure); static PyObject* get_callback(PyTimerObject* self, void* closure); static int set_callback(PyTimerObject* self, PyObject* value, void* closure); static PyObject* get_once(PyTimerObject* self, void* closure); @@ -53,35 +56,39 @@ namespace mcrfpydef { .tp_dealloc = (destructor)PyTimer::dealloc, .tp_repr = PyTimer::repr, .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR("Timer(name, callback, interval, once=False)\n\n" + .tp_doc = PyDoc_STR("Timer(name, callback, interval, once=False, start=True)\n\n" "Create a timer that calls a function at regular intervals.\n\n" "Args:\n" " name (str): Unique identifier for the timer\n" " callback (callable): Function to call - receives (timer, runtime) args\n" " interval (int): Time between calls in milliseconds\n" - " once (bool): If True, timer stops after first call. Default: False\n\n" + " once (bool): If True, timer stops after first call. Default: False\n" + " start (bool): If True, timer starts immediately. Default: True\n\n" "Attributes:\n" " interval (int): Time between calls in milliseconds\n" " remaining (int): Time until next call in milliseconds (read-only)\n" " paused (bool): Whether timer is paused (read-only)\n" - " active (bool): Whether timer is active and not paused (read-only)\n" - " callback (callable): The callback function\n" + " stopped (bool): Whether timer is stopped (read-only)\n" + " active (bool): Running state (read-write). Set True to start, False to pause\n" + " callback (callable): The callback function (preserved when stopped)\n" " once (bool): Whether timer stops after firing once\n\n" "Methods:\n" + " start(): Start the timer, adding to engine tick loop\n" + " stop(): Stop the timer (removes from engine, preserves callback)\n" " pause(): Pause the timer, preserving time remaining\n" " resume(): Resume a paused timer\n" - " cancel(): Stop and remove the timer\n" - " restart(): Reset timer to start from beginning\n\n" + " restart(): Reset timer and ensure it's running\n\n" "Example:\n" " def on_timer(timer, runtime):\n" " print(f'Timer {timer} fired at {runtime}ms')\n" " if runtime > 5000:\n" - " timer.cancel()\n" + " timer.stop() # Stop but can restart later\n" " \n" " timer = mcrfpy.Timer('my_timer', on_timer, 1000)\n" " timer.pause() # Pause timer\n" " timer.resume() # Resume timer\n" - " timer.once = True # Make it one-shot"), + " timer.stop() # Stop completely\n" + " timer.start() # Restart from beginning"), .tp_methods = PyTimer::methods, .tp_getset = PyTimer::getsetters, .tp_init = (initproc)PyTimer::init, diff --git a/src/Timer.cpp b/src/Timer.cpp index 8873a39..cda7775 100644 --- a/src/Timer.cpp +++ b/src/Timer.cpp @@ -4,14 +4,14 @@ #include "McRFPy_API.h" #include "GameEngine.h" -Timer::Timer(PyObject* _target, int _interval, int now, bool _once) +Timer::Timer(PyObject* _target, int _interval, int now, bool _once, bool _start) : callback(std::make_shared(_target)), interval(_interval), last_ran(now), - paused(false), pause_start_time(0), total_paused_time(0), once(_once) + paused(false), pause_start_time(0), total_paused_time(0), once(_once), stopped(!_start) {} Timer::Timer() : callback(std::make_shared(Py_None)), interval(0), last_ran(0), - paused(false), pause_start_time(0), total_paused_time(0), once(false) + paused(false), pause_start_time(0), total_paused_time(0), once(false), stopped(true) {} Timer::~Timer() { @@ -22,24 +22,24 @@ Timer::~Timer() { bool Timer::hasElapsed(int now) const { - if (paused) return false; + if (paused || stopped) return false; return now >= last_ran + interval; } bool Timer::test(int now) { - if (!callback || callback->isNone()) return false; - + if (!callback || callback->isNone() || stopped) return false; + if (hasElapsed(now)) { last_ran = now; - + // Get the PyTimer wrapper from cache to pass to callback PyObject* timer_obj = nullptr; if (serial_number != 0) { timer_obj = PythonObjectCache::getInstance().lookup(serial_number); } - + // Build args: (timer, runtime) or just (runtime) if no wrapper found PyObject* args; if (timer_obj) { @@ -48,10 +48,10 @@ bool Timer::test(int now) // Fallback to old behavior if no wrapper found args = Py_BuildValue("(i)", now); } - + PyObject* retval = callback->call(args, NULL); Py_DECREF(args); - + if (!retval) { std::cerr << "Timer callback raised an exception:" << std::endl; @@ -63,16 +63,16 @@ bool Timer::test(int now) McRFPy_API::signalPythonException(); } } else if (retval != Py_None) - { + { std::cout << "Timer returned a non-None value. It's not an error, it's just not being saved or used." << std::endl; Py_DECREF(retval); } - - // Handle one-shot timers + + // Handle one-shot timers: stop but preserve callback for potential restart if (once) { - cancel(); + stopped = true; // Will be removed from map by testTimers(), but callback preserved } - + return true; } return false; @@ -101,23 +101,41 @@ void Timer::restart(int current_time) { last_ran = current_time; paused = false; + stopped = false; // Ensure timer is running pause_start_time = 0; total_paused_time = 0; } -void Timer::cancel() +void Timer::start(int current_time) { - // Cancel by setting callback to None - callback = std::make_shared(Py_None); + // Start/resume the timer - clear stopped flag, reset progress + stopped = false; + paused = false; + last_ran = current_time; + pause_start_time = 0; + total_paused_time = 0; +} + +void Timer::stop() +{ + // Stop the timer - it will be removed from engine map, but callback is preserved + stopped = true; + paused = false; + pause_start_time = 0; + total_paused_time = 0; } bool Timer::isActive() const { - return callback && !callback->isNone() && !paused; + return callback && !callback->isNone() && !paused && !stopped; } int Timer::getRemaining(int current_time) const { + if (stopped) { + // When stopped, progress is reset - full interval remaining + return interval; + } if (paused) { // When paused, calculate time remaining from when it was paused int elapsed_when_paused = pause_start_time - last_ran; @@ -129,6 +147,10 @@ int Timer::getRemaining(int current_time) const int Timer::getElapsed(int current_time) const { + if (stopped) { + // When stopped, progress is reset + return 0; + } if (paused) { return pause_start_time - last_ran; } diff --git a/src/Timer.h b/src/Timer.h index c52b261..1c87b46 100644 --- a/src/Timer.h +++ b/src/Timer.h @@ -17,37 +17,42 @@ private: bool paused; int pause_start_time; int total_paused_time; - + // One-shot timer support bool once; + + // Stopped state: timer is not in engine map, but callback is preserved + bool stopped; public: uint64_t serial_number = 0; // For Python object cache - + Timer(); // for map to build - Timer(PyObject* target, int interval, int now, bool once = false); + Timer(PyObject* target, int interval, int now, bool once = false, bool start = true); ~Timer(); - + // Core timer functionality bool test(int now); bool hasElapsed(int now) const; - + // Timer control methods void pause(int current_time); void resume(int current_time); void restart(int current_time); - void cancel(); - + void start(int current_time); // Clear stopped flag, reset progress + void stop(); // Set stopped flag, preserve callback + // Timer state queries bool isPaused() const { return paused; } - bool isActive() const; + bool isStopped() const { return stopped; } + bool isActive() const; // Running: not paused AND not stopped AND has callback int getInterval() const { return interval; } void setInterval(int new_interval) { interval = new_interval; } int getRemaining(int current_time) const; int getElapsed(int current_time) const; bool isOnce() const { return once; } void setOnce(bool value) { once = value; } - + // Callback management PyObject* getCallback(); void setCallback(PyObject* new_callback); diff --git a/tests/unit/api_timer_test.py b/tests/unit/api_timer_test.py index d9af861..8d46aa3 100644 --- a/tests/unit/api_timer_test.py +++ b/tests/unit/api_timer_test.py @@ -1,70 +1,126 @@ #!/usr/bin/env python3 -"""Test for mcrfpy.setTimer() and delTimer() methods""" +"""Test for mcrfpy.Timer class - replaces old setTimer/delTimer tests (#173)""" import mcrfpy import sys def test_timers(): - """Test timer API methods""" - print("Testing mcrfpy timer methods...") - + """Test Timer class API""" + print("Testing mcrfpy.Timer class...") + # Test 1: Create a simple timer try: call_count = [0] - def simple_callback(runtime): + def simple_callback(timer, runtime): call_count[0] += 1 print(f"Timer callback called, count={call_count[0]}, runtime={runtime}") - - mcrfpy.setTimer("test_timer", simple_callback, 100) - print("āœ“ setTimer() called successfully") + + timer = mcrfpy.Timer("test_timer", simple_callback, 100) + print("āœ“ Timer() created successfully") + print(f" Timer repr: {timer}") except Exception as e: - print(f"āœ— setTimer() failed: {e}") + print(f"āœ— Timer() failed: {e}") print("FAIL") return - - # Test 2: Delete the timer + + # Test 2: Stop the timer try: - mcrfpy.delTimer("test_timer") - print("āœ“ delTimer() called successfully") + timer.stop() + print("āœ“ timer.stop() called successfully") + assert timer.stopped == True, "Timer should be stopped" + print(f" Timer after stop: {timer}") except Exception as e: - print(f"āœ— delTimer() failed: {e}") + print(f"āœ— timer.stop() failed: {e}") print("FAIL") return - - # Test 3: Delete non-existent timer (should not crash) + + # Test 3: Restart the timer try: - mcrfpy.delTimer("nonexistent_timer") - print("āœ“ delTimer() accepts non-existent timer names") + timer.start() + print("āœ“ timer.start() called successfully") + assert timer.stopped == False, "Timer should not be stopped" + assert timer.active == True, "Timer should be active" + timer.stop() # Clean up except Exception as e: - print(f"āœ— delTimer() failed on non-existent timer: {e}") + print(f"āœ— timer.start() failed: {e}") print("FAIL") return - - # Test 4: Create multiple timers + + # Test 4: Create timer with start=False try: - def callback1(rt): pass - def callback2(rt): pass - def callback3(rt): pass - - mcrfpy.setTimer("timer1", callback1, 500) - mcrfpy.setTimer("timer2", callback2, 750) - mcrfpy.setTimer("timer3", callback3, 250) + def callback2(timer, runtime): pass + timer2 = mcrfpy.Timer("timer2", callback2, 500, start=False) + assert timer2.stopped == True, "Timer with start=False should be stopped" + print("āœ“ Timer with start=False created in stopped state") + timer2.start() + assert timer2.active == True, "Timer should be active after start()" + timer2.stop() + except Exception as e: + print(f"āœ— Timer with start=False failed: {e}") + print("FAIL") + return + + # Test 5: Create multiple timers + try: + def callback3(t, rt): pass + + t1 = mcrfpy.Timer("multi1", callback3, 500) + t2 = mcrfpy.Timer("multi2", callback3, 750) + t3 = mcrfpy.Timer("multi3", callback3, 250) print("āœ“ Multiple timers created successfully") - + # Clean up - mcrfpy.delTimer("timer1") - mcrfpy.delTimer("timer2") - mcrfpy.delTimer("timer3") - print("āœ“ Multiple timers deleted successfully") + t1.stop() + t2.stop() + t3.stop() + print("āœ“ Multiple timers stopped successfully") except Exception as e: print(f"āœ— Multiple timer test failed: {e}") print("FAIL") return - - print("\nAll timer API tests passed") + + # Test 6: mcrfpy.timers collection + try: + # Create a timer that's running + running_timer = mcrfpy.Timer("running_test", callback3, 1000) + + timers = mcrfpy.timers + assert isinstance(timers, tuple), "mcrfpy.timers should be a tuple" + print(f"āœ“ mcrfpy.timers returns tuple with {len(timers)} timer(s)") + + # Clean up + running_timer.stop() + except Exception as e: + print(f"āœ— mcrfpy.timers test failed: {e}") + print("FAIL") + return + + # Test 7: active property is read-write + try: + active_timer = mcrfpy.Timer("active_test", callback3, 1000) + assert active_timer.active == True, "New timer should be active" + + active_timer.active = False # Should pause + assert active_timer.paused == True, "Timer should be paused after active=False" + + active_timer.active = True # Should resume + assert active_timer.active == True, "Timer should be active after active=True" + + active_timer.stop() + active_timer.active = True # Should restart from stopped + assert active_timer.active == True, "Timer should restart from stopped via active=True" + + active_timer.stop() + print("āœ“ active property is read-write") + except Exception as e: + print(f"āœ— active property test failed: {e}") + print("FAIL") + return + + print("\nAll Timer API tests passed") print("PASS") # Run the test test_timers() # Exit cleanly -sys.exit(0) \ No newline at end of file +sys.exit(0) diff --git a/tests/unit/test_grid_cell_events.py b/tests/unit/test_grid_cell_events.py index 9620d51..5594447 100644 --- a/tests/unit/test_grid_cell_events.py +++ b/tests/unit/test_grid_cell_events.py @@ -68,9 +68,7 @@ def test_cell_hover(): automation.moveTo(150, 150) automation.moveTo(200, 200) - def check_hover(runtime): - mcrfpy.delTimer("check_hover") - + def check_hover(timer, runtime): print(f" Enter events: {len(enter_events)}, Exit events: {len(exit_events)}") print(f" Hovered cell: {grid.hovered_cell}") @@ -82,7 +80,7 @@ def test_cell_hover(): # Continue to click test test_cell_click() - mcrfpy.setTimer("check_hover", check_hover, 200) + mcrfpy.Timer("check_hover", check_hover, 200, once=True) def test_cell_click(): @@ -105,9 +103,7 @@ def test_cell_click(): automation.click(200, 200) - def check_click(runtime): - mcrfpy.delTimer("check_click") - + def check_click(timer, runtime): print(f" Click events: {len(click_events)}") if len(click_events) >= 1: @@ -118,7 +114,7 @@ def test_cell_click(): print("\n=== All grid cell event tests passed! ===") sys.exit(0) - mcrfpy.setTimer("check_click", check_click, 200) + mcrfpy.Timer("check_click", check_click, 200, once=True) if __name__ == "__main__": diff --git a/tests/unit/test_headless_click.py b/tests/unit/test_headless_click.py index 5078bed..422774e 100644 --- a/tests/unit/test_headless_click.py +++ b/tests/unit/test_headless_click.py @@ -36,9 +36,7 @@ def test_headless_click(): automation.click(150, 150) # Give time for events to process - def check_results(runtime): - mcrfpy.delTimer("check_click") # Clean up timer - + def check_results(timer, runtime): if len(start_clicks) >= 1: print(f" - Click received: {len(start_clicks)} click(s)") # Verify position @@ -53,7 +51,7 @@ def test_headless_click(): print(f" - No clicks received: FAIL") sys.exit(1) - mcrfpy.setTimer("check_click", check_results, 200) + mcrfpy.Timer("check_click", check_results, 200, once=True) def test_click_miss(): @@ -84,9 +82,7 @@ def test_click_miss(): print(" Clicking outside frame at (50, 50)...") automation.click(50, 50) - def check_miss_results(runtime): - mcrfpy.delTimer("check_miss") # Clean up timer - + def check_miss_results(timer, runtime): if miss_count[0] == 0: print(" - No click on miss: PASS") # Now run the main click test @@ -95,7 +91,7 @@ def test_click_miss(): print(f" - Unexpected {miss_count[0]} click(s): FAIL") sys.exit(1) - mcrfpy.setTimer("check_miss", check_miss_results, 200) + mcrfpy.Timer("check_miss", check_miss_results, 200, once=True) def test_position_tracking(): diff --git a/tests/unit/test_mouse_enter_exit.py b/tests/unit/test_mouse_enter_exit.py index c2fa7a4..9caae60 100644 --- a/tests/unit/test_mouse_enter_exit.py +++ b/tests/unit/test_mouse_enter_exit.py @@ -153,7 +153,7 @@ def test_enter_exit_simulation(): automation.moveTo(50, 50) # Give time for callbacks to execute - def check_results(runtime): + def check_results(timer, runtime): global enter_count, exit_count if enter_count >= 1 and exit_count >= 1: @@ -166,7 +166,7 @@ def test_enter_exit_simulation(): print("\n=== Basic Mouse Enter/Exit tests passed! ===") sys.exit(0) - mcrfpy.setTimer("check", check_results, 200) + mcrfpy.Timer("check", check_results, 200, once=True) def run_basic_tests(): diff --git a/tests/unit/test_on_move.py b/tests/unit/test_on_move.py index 957c1a7..4a379d0 100644 --- a/tests/unit/test_on_move.py +++ b/tests/unit/test_on_move.py @@ -57,9 +57,7 @@ def test_on_move_fires(): automation.moveTo(200, 200) automation.moveTo(250, 250) - def check_results(runtime): - mcrfpy.delTimer("check_move") - + def check_results(timer, runtime): if move_count[0] >= 2: print(f" - on_move fired {move_count[0]} times: PASS") print(f" Positions: {positions[:5]}...") @@ -71,7 +69,7 @@ def test_on_move_fires(): print("\n=== on_move basic tests passed! ===") sys.exit(0) - mcrfpy.setTimer("check_move", check_results, 200) + mcrfpy.Timer("check_move", check_results, 200, once=True) def test_on_move_not_outside(): @@ -99,9 +97,7 @@ def test_on_move_not_outside(): automation.moveTo(60, 60) automation.moveTo(70, 70) - def check_results(runtime): - mcrfpy.delTimer("check_outside") - + def check_results(timer, runtime): if move_count[0] == 0: print(" - No on_move outside bounds: PASS") # Chain to the firing test @@ -110,7 +106,7 @@ def test_on_move_not_outside(): print(f" - Unexpected {move_count[0]} move(s) outside bounds: FAIL") sys.exit(1) - mcrfpy.setTimer("check_outside", check_results, 200) + mcrfpy.Timer("check_outside", check_results, 200, once=True) def test_all_types_have_on_move(): diff --git a/tests/unit/test_step_function.py b/tests/unit/test_step_function.py index d92a4e4..7fd576d 100644 --- a/tests/unit/test_step_function.py +++ b/tests/unit/test_step_function.py @@ -63,13 +63,13 @@ def run_tests(): print("Test 5: Timer fires after step() advances past interval") timer_fired = [False] # Use list for mutable closure - def on_timer(runtime): - """Timer callback - receives runtime in ms""" + def on_timer(timer, runtime): + """Timer callback - receives timer object and runtime in ms""" timer_fired[0] = True print(f" Timer fired at simulation time={runtime}ms") # Set a timer for 500ms - mcrfpy.setTimer("test_timer", on_timer, 500) + test_timer = mcrfpy.Timer("test_timer", on_timer, 500) # Step 600ms - timer should fire (500ms interval + some buffer) dt = mcrfpy.step(0.6) @@ -88,7 +88,7 @@ def run_tests(): print(" Skipping timer test in windowed mode") # Clean up - mcrfpy.delTimer("test_timer") + test_timer.stop() print() # Test 6: Error handling - invalid argument type diff --git a/tests/unit/test_timer_once.py b/tests/unit/test_timer_once.py index 8e7e4fd..fc4bcbb 100644 --- a/tests/unit/test_timer_once.py +++ b/tests/unit/test_timer_once.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """ Test once=True timer functionality +Uses mcrfpy.step() to advance time in headless mode. """ import mcrfpy import sys @@ -18,20 +19,8 @@ def repeat_callback(timer, runtime): repeat_count += 1 print(f"Repeat timer fired! Count: {repeat_count}, Timer.once: {timer.once}") -def check_results(runtime): - print(f"\nFinal results:") - print(f"Once timer fired {once_count} times (expected: 1)") - print(f"Repeat timer fired {repeat_count} times (expected: 3+)") - - if once_count == 1 and repeat_count >= 3: - print("PASS: Once timer fired exactly once, repeat timer fired multiple times") - sys.exit(0) - else: - print("FAIL: Timer behavior incorrect") - sys.exit(1) - # Set up the scene -test_scene = mcrfpy.Scene("test_scene") +test_scene = mcrfpy.Scene("test_scene") test_scene.activate() # Create timers @@ -43,5 +32,20 @@ print("\nCreating repeat timer with once=False (default)...") repeat_timer = mcrfpy.Timer("repeat_timer", repeat_callback, 100) print(f"Timer: {repeat_timer}, once={repeat_timer.once}") -# Check results after 500ms -mcrfpy.setTimer("check", check_results, 500) \ No newline at end of file +# Advance time using step() to let timers fire +# Step 600ms total - once timer (100ms) fires once, repeat timer fires ~6 times +print("\nAdvancing time with step()...") +for i in range(6): + mcrfpy.step(0.1) # 100ms each + +# Check results +print(f"\nFinal results:") +print(f"Once timer fired {once_count} times (expected: 1)") +print(f"Repeat timer fired {repeat_count} times (expected: 3+)") + +if once_count == 1 and repeat_count >= 3: + print("PASS: Once timer fired exactly once, repeat timer fired multiple times") + sys.exit(0) +else: + print("FAIL: Timer behavior incorrect") + sys.exit(1) diff --git a/tests/unit/working_timer_test.py b/tests/unit/working_timer_test.py index bddeff4..bda2600 100644 --- a/tests/unit/working_timer_test.py +++ b/tests/unit/working_timer_test.py @@ -23,20 +23,28 @@ caption = mcrfpy.Caption(pos=(150, 150), caption.font_size = 24 ui.append(caption) -# Timer callback with correct signature -def timer_callback(runtime): +# Timer callback with new signature (timer, runtime) +def timer_callback(timer, runtime): print(f"\nāœ“ Timer fired successfully at runtime: {runtime}") - + # Take screenshot filename = f"timer_success_{int(runtime)}.png" result = automation.screenshot(filename) print(f"Screenshot saved: {filename} - Result: {result}") - - # Cancel timer and exit - mcrfpy.delTimer("success_timer") + + # Stop timer and exit + timer.stop() print("Exiting...") mcrfpy.exit() -# Set timer -mcrfpy.setTimer("success_timer", timer_callback, 1000) -print("Timer set for 1 second. Game loop starting...") \ No newline at end of file +# Create timer (new API) +success_timer = mcrfpy.Timer("success_timer", timer_callback, 1000, once=True) +print("Timer set for 1 second. Using step() to advance time...") + +# In headless mode, advance time manually +for i in range(11): # 1100ms total + mcrfpy.step(0.1) + +print("PASS") +import sys +sys.exit(0) From cec76b63dc73fa48e8e8795703426bcf51e2c80c Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sat, 3 Jan 2026 22:44:53 -0500 Subject: [PATCH 2/4] Timer overhaul: update tests --- tests/benchmarks/benchmark_moving_entities.py | 8 +- tests/benchmarks/benchmark_suite.py | 9 +- tests/benchmarks/entity_scale_benchmark.py | 4 +- tests/benchmarks/layer_performance_test.py | 22 +++-- tests/benchmarks/stress_test_suite.py | 6 +- tests/benchmarks/tcod_fov_isolated.py | 4 +- tests/benchmarks/tcod_scale_test.py | 4 +- tests/demo/demo_main.py | 4 +- tests/demo/perspective_patrol_demo.py | 5 +- tests/demo/screens/focus_system_demo.py | 8 +- tests/geometry_demo/geometry_main.py | 4 +- tests/geometry_demo/screens/base.py | 12 +-- .../screens/pathfinding_animated_demo.py | 2 +- .../screens/solar_system_demo.py | 2 +- .../dijkstra_interactive_enhanced.py | 4 +- tests/integration/dijkstra_test.py | 14 +-- tests/notes/test_exception_exit.py | 6 +- .../regression/issue_123_chunk_system_test.py | 4 +- .../regression/issue_146_fov_returns_none.py | 4 +- tests/regression/issue_147_grid_layers.py | 4 +- .../regression/issue_148_layer_dirty_flags.py | 4 +- tests/regression/issue_76_test.py | 4 +- .../issue_79_color_properties_test.py | 4 +- .../issue_99_texture_font_properties_test.py | 4 +- tests/regression/issue_9_minimal_test.py | 4 +- .../issue_9_rendertexture_resize_test.py | 4 +- tests/regression/issue_9_test.py | 4 +- .../test_type_preservation_solution.py | 6 +- tests/unit/WORKING_automation_test_example.py | 27 +++--- tests/unit/benchmark_logging_test.py | 4 +- tests/unit/debug_empty_paths.py | 4 +- tests/unit/generate_docs_screenshots.py | 30 ++++--- tests/unit/generate_grid_screenshot.py | 8 +- tests/unit/generate_sprite_screenshot.py | 8 +- tests/unit/keypress_scene_validation_test.py | 4 +- tests/unit/simple_screenshot_test.py | 14 +-- tests/unit/simple_timer_screenshot_test.py | 12 +-- tests/unit/test_animation_callback_simple.py | 32 +++---- tests/unit/test_animation_chaining.py | 25 +++--- tests/unit/test_animation_debug.py | 44 +++++----- tests/unit/test_animation_property_locking.py | 4 +- tests/unit/test_animation_raii.py | 42 ++++----- tests/unit/test_animation_removal.py | 9 +- tests/unit/test_astar.py | 4 +- tests/unit/test_color_helpers.py | 4 +- tests/unit/test_dijkstra_pathfinding.py | 4 +- tests/unit/test_documentation.py | 14 +-- tests/unit/test_empty_animation_manager.py | 8 +- tests/unit/test_entity_animation.py | 11 +-- tests/unit/test_entity_fix.py | 4 +- tests/unit/test_frame_clipping.py | 20 +++-- tests/unit/test_frame_clipping_advanced.py | 26 +++--- tests/unit/test_grid_background.py | 74 ++++++++-------- tests/unit/test_grid_children.py | 12 +-- tests/unit/test_headless_detection.py | 10 +-- tests/unit/test_headless_modes.py | 4 +- tests/unit/test_metrics.py | 57 ++++++------ tests/unit/test_no_arg_constructors.py | 4 +- tests/unit/test_path_colors.py | 4 +- tests/unit/test_pathfinding_integration.py | 6 +- tests/unit/test_properties_quick.py | 6 +- tests/unit/test_python_object_cache.py | 4 +- tests/unit/test_scene_transitions.py | 2 +- tests/unit/test_simple_callback.py | 4 +- tests/unit/test_simple_drawable.py | 6 +- tests/unit/test_text_input.py | 34 ++++---- tests/unit/test_timer_callback.py | 29 +++---- tests/unit/test_timer_legacy.py | 14 +-- tests/unit/test_timer_object.py | 86 +++++++++---------- tests/unit/test_uiarc.py | 13 +-- tests/unit/test_uicaption_visual.py | 6 +- tests/unit/test_uicircle.py | 13 +-- tests/unit/test_utf8_encoding.py | 4 +- tests/unit/test_vector_arithmetic.py | 4 +- tests/unit/test_viewport_scaling.py | 70 +++++++-------- tests/unit/test_visibility.py | 14 +-- tests/unit/test_visual_path.py | 6 +- tests/unit/ui_Grid_none_texture_test.py | 4 +- 78 files changed, 521 insertions(+), 495 deletions(-) diff --git a/tests/benchmarks/benchmark_moving_entities.py b/tests/benchmarks/benchmark_moving_entities.py index c4c7b50..a8e73b7 100644 --- a/tests/benchmarks/benchmark_moving_entities.py +++ b/tests/benchmarks/benchmark_moving_entities.py @@ -97,7 +97,7 @@ def handle_key(key, state): benchmark.on_key = handle_key # Update entity positions -def update_entities(ms): +def update_entities(timer, ms): dt = ms / 1000.0 # Convert to seconds for entity in entities: @@ -119,13 +119,13 @@ def update_entities(ms): entity.y = new_y # Run movement update every frame (16ms) -mcrfpy.setTimer("movement", update_entities, 16) +movement_timer = mcrfpy.Timer("movement", update_entities, 16) # Benchmark statistics frame_count = 0 start_time = None -def benchmark_timer(ms): +def benchmark_callback(timer, ms): global frame_count, start_time if start_time is None: @@ -152,4 +152,4 @@ def benchmark_timer(ms): print("=" * 60) # Don't exit - let user review -mcrfpy.setTimer("benchmark", benchmark_timer, 100) +benchmark_timer = mcrfpy.Timer("benchmark", benchmark_callback, 100) diff --git a/tests/benchmarks/benchmark_suite.py b/tests/benchmarks/benchmark_suite.py index 9a8f2c7..f2eff04 100644 --- a/tests/benchmarks/benchmark_suite.py +++ b/tests/benchmarks/benchmark_suite.py @@ -31,7 +31,7 @@ frame_count = 0 metrics_samples = [] -def collect_metrics(runtime): +def collect_metrics(timer, runtime): """Timer callback to collect metrics each frame.""" global frame_count, metrics_samples @@ -65,9 +65,9 @@ def collect_metrics(runtime): def finish_scenario(): """Calculate statistics and store results for current scenario.""" - global results, current_scenario, metrics_samples + global results, current_scenario, metrics_samples, benchmark_timer - mcrfpy.delTimer("benchmark_collect") + benchmark_timer.stop() if not metrics_samples: print(f" WARNING: No samples collected for {current_scenario}") @@ -149,7 +149,8 @@ def run_next_scenario(): scenarios[next_idx][1]() # Start collection timer (runs every frame) - mcrfpy.setTimer("benchmark_collect", collect_metrics, 1) + global benchmark_timer + benchmark_timer = mcrfpy.Timer("benchmark_collect", collect_metrics, 1) # ============================================================================ diff --git a/tests/benchmarks/entity_scale_benchmark.py b/tests/benchmarks/entity_scale_benchmark.py index 74fa472..b739c32 100644 --- a/tests/benchmarks/entity_scale_benchmark.py +++ b/tests/benchmarks/entity_scale_benchmark.py @@ -427,7 +427,7 @@ def print_analysis(): print(f" Note: This overhead is acceptable given query speedups") -def run_benchmarks(runtime=None): +def run_benchmarks(timer=None, runtime=None): """Main benchmark runner.""" global results @@ -458,4 +458,4 @@ if __name__ == "__main__": if "--headless" in sys.argv or True: # Always run immediately for benchmarks run_benchmarks() else: - mcrfpy.setTimer("run_bench", run_benchmarks, 100) + bench_timer = mcrfpy.Timer("run_bench", run_benchmarks, 100, once=True) diff --git a/tests/benchmarks/layer_performance_test.py b/tests/benchmarks/layer_performance_test.py index b62844b..6c30a28 100644 --- a/tests/benchmarks/layer_performance_test.py +++ b/tests/benchmarks/layer_performance_test.py @@ -34,7 +34,7 @@ frame_count = 0 test_results = {} # Store filenames for each test -def run_test_phase(runtime): +def run_test_phase(timer, runtime): """Run through warmup and measurement phases.""" global frame_count @@ -51,7 +51,7 @@ def run_test_phase(runtime): test_results[current_test] = filename print(f" {current_test}: saved to {filename}") - mcrfpy.delTimer("test_phase") + timer.stop() run_next_test() @@ -90,7 +90,8 @@ def run_next_test(): print(f"\n[{next_idx + 1}/{len(tests)}] Running: {current_test}") tests[next_idx][1]() - mcrfpy.setTimer("test_phase", run_test_phase, 1) + global test_phase_timer + test_phase_timer = mcrfpy.Timer("test_phase", run_test_phase, 1) # ============================================================================ @@ -130,14 +131,15 @@ def setup_base_layer_modified(): # Timer to modify one cell per frame (triggers dirty flag each frame) mod_counter = [0] - def modify_cell(runtime): + def modify_cell(timer, runtime): x = mod_counter[0] % GRID_SIZE y = (mod_counter[0] // GRID_SIZE) % GRID_SIZE layer.set(x, y, mcrfpy.Color(255, 0, 0, 255)) mod_counter[0] += 1 test_base_mod.activate() - mcrfpy.setTimer("modify", modify_cell, 1) + global modify_timer + modify_timer = mcrfpy.Timer("modify", modify_cell, 1) def setup_color_layer_static(): @@ -170,14 +172,15 @@ def setup_color_layer_modified(): # Timer to modify one cell per frame - triggers re-render mod_counter = [0] - def modify_cell(runtime): + def modify_cell(timer, runtime): x = mod_counter[0] % GRID_SIZE y = (mod_counter[0] // GRID_SIZE) % GRID_SIZE layer.set(x, y, mcrfpy.Color(255, 0, 0, 255)) mod_counter[0] += 1 test_color_mod.activate() - mcrfpy.setTimer("modify", modify_cell, 1) + global modify_timer + modify_timer = mcrfpy.Timer("modify", modify_cell, 1) def setup_tile_layer_static(): @@ -222,7 +225,7 @@ def setup_tile_layer_modified(): # Timer to modify one cell per frame mod_counter = [0] - def modify_cell(runtime): + def modify_cell(timer, runtime): if layer: x = mod_counter[0] % GRID_SIZE y = (mod_counter[0] // GRID_SIZE) % GRID_SIZE @@ -230,7 +233,8 @@ def setup_tile_layer_modified(): mod_counter[0] += 1 test_tile_mod.activate() - mcrfpy.setTimer("modify", modify_cell, 1) + global modify_timer + modify_timer = mcrfpy.Timer("modify", modify_cell, 1) def setup_multi_layer_static(): diff --git a/tests/benchmarks/stress_test_suite.py b/tests/benchmarks/stress_test_suite.py index ba2546d..5f92c5a 100644 --- a/tests/benchmarks/stress_test_suite.py +++ b/tests/benchmarks/stress_test_suite.py @@ -31,7 +31,7 @@ class StressTestRunner: def add_test(self, name, setup_fn, description=""): self.tests.append({'name': name, 'setup': setup_fn, 'description': description}) - def tick(self, runtime): + def tick(self, timer, runtime): """Single timer callback that manages all test flow""" self.frames_counted += 1 @@ -103,7 +103,7 @@ class StressTestRunner: self.results[test['name']] = {'error': str(e)} def finish_suite(self): - mcrfpy.delTimer("tick") + self.tick_timer.stop() print("\n" + "="*50) print("STRESS TEST COMPLETE") @@ -137,7 +137,7 @@ class StressTestRunner: ui = init.children ui.append(mcrfpy.Frame(pos=(0,0), size=(10,10))) # Required for timer to fire init.activate() - mcrfpy.setTimer("tick", self.tick, TIMER_INTERVAL_MS) + self.tick_timer = mcrfpy.Timer("tick", self.tick, TIMER_INTERVAL_MS) # ============================================================================= diff --git a/tests/benchmarks/tcod_fov_isolated.py b/tests/benchmarks/tcod_fov_isolated.py index ee491e3..ff3f106 100644 --- a/tests/benchmarks/tcod_fov_isolated.py +++ b/tests/benchmarks/tcod_fov_isolated.py @@ -6,7 +6,7 @@ import mcrfpy import sys import time -def run_test(runtime): +def run_test(timer, runtime): print("=" * 60) print("FOV Isolation Test - Is TCOD slow, or is it the Python wrapper?") print("=" * 60) @@ -96,4 +96,4 @@ def run_test(runtime): init = mcrfpy.Scene("init") init.activate() -mcrfpy.setTimer("test", run_test, 100) +test_timer = mcrfpy.Timer("test", run_test, 100, once=True) diff --git a/tests/benchmarks/tcod_scale_test.py b/tests/benchmarks/tcod_scale_test.py index 6bb38da..3fe3f04 100644 --- a/tests/benchmarks/tcod_scale_test.py +++ b/tests/benchmarks/tcod_scale_test.py @@ -134,7 +134,7 @@ init = mcrfpy.Scene("init") init.activate() # Use a timer to let the engine initialize -def run_benchmark(runtime): +def run_benchmark(timer, runtime): main() -mcrfpy.setTimer("bench", run_benchmark, 100) +bench_timer = mcrfpy.Timer("bench", run_benchmark, 100, once=True) diff --git a/tests/demo/demo_main.py b/tests/demo/demo_main.py index ceae6c2..aa1a47b 100644 --- a/tests/demo/demo_main.py +++ b/tests/demo/demo_main.py @@ -114,7 +114,7 @@ class DemoRunner: self.current_index = 0 self.render_wait = 0 - def screenshot_cycle(runtime): + def screenshot_cycle(timer, runtime): if self.render_wait == 0: # Set scene and wait for render if self.current_index >= len(self.screens): @@ -139,7 +139,7 @@ class DemoRunner: print("Done!") sys.exit(0) - mcrfpy.setTimer("screenshot", screenshot_cycle, 50) + self.screenshot_timer = mcrfpy.Timer("screenshot", screenshot_cycle, 50) def run_interactive(self): """Run in interactive mode with menu.""" diff --git a/tests/demo/perspective_patrol_demo.py b/tests/demo/perspective_patrol_demo.py index 2bab026..bad5adc 100644 --- a/tests/demo/perspective_patrol_demo.py +++ b/tests/demo/perspective_patrol_demo.py @@ -126,9 +126,10 @@ def setup_scene(): patrol_demo.on_key = on_keypress # Start patrol timer - mcrfpy.setTimer("patrol", patrol_step, move_timer_ms) + global patrol_timer + patrol_timer = mcrfpy.Timer("patrol", patrol_step, move_timer_ms) -def patrol_step(runtime): +def patrol_step(timer, runtime): """Move entity one step toward current waypoint""" global current_waypoint, patrol_paused diff --git a/tests/demo/screens/focus_system_demo.py b/tests/demo/screens/focus_system_demo.py index 4d8bd8f..8de447c 100644 --- a/tests/demo/screens/focus_system_demo.py +++ b/tests/demo/screens/focus_system_demo.py @@ -784,12 +784,12 @@ def run_demo(): demo_state = create_demo_scene() # Set up exit timer for headless testing - def check_exit(dt): + def check_exit(timer, dt): # In headless mode, exit after a short delay # In interactive mode, this won't trigger pass - # mcrfpy.setTimer("demo_check", check_exit, 100) + # check_exit_timer = mcrfpy.Timer("demo_check", check_exit, 100) # Run if executed directly @@ -801,8 +801,8 @@ if __name__ == "__main__": # If --screenshot flag, take a screenshot and exit if "--screenshot" in sys.argv or len(sys.argv) > 1: - def take_screenshot(dt): + def take_screenshot(timer, dt): automation.screenshot("focus_demo_screenshot.png") print("Screenshot saved: focus_demo_screenshot.png") sys.exit(0) - mcrfpy.setTimer("screenshot", take_screenshot, 200) + screenshot_timer = mcrfpy.Timer("screenshot", take_screenshot, 200, once=True) diff --git a/tests/geometry_demo/geometry_main.py b/tests/geometry_demo/geometry_main.py index 92c90ae..0a3fe21 100644 --- a/tests/geometry_demo/geometry_main.py +++ b/tests/geometry_demo/geometry_main.py @@ -135,7 +135,7 @@ class GeometryDemoRunner: self.current_index = 0 self.render_wait = 0 - def screenshot_cycle(runtime): + def screenshot_cycle(timer, runtime): if self.render_wait == 0: if self.current_index >= len(self.screens): print("Done!") @@ -162,7 +162,7 @@ class GeometryDemoRunner: print("Done!") sys.exit(0) - mcrfpy.setTimer("screenshot", screenshot_cycle, 100) + self.screenshot_timer = mcrfpy.Timer("screenshot", screenshot_cycle, 100) def run_interactive(self): """Run in interactive mode with menu.""" diff --git a/tests/geometry_demo/screens/base.py b/tests/geometry_demo/screens/base.py index 992208e..4a02949 100644 --- a/tests/geometry_demo/screens/base.py +++ b/tests/geometry_demo/screens/base.py @@ -46,17 +46,19 @@ class GeometryDemoScreen: def cleanup(self): """Clean up timers when leaving screen.""" - for timer_name in self.timers: + for timer in self.timers: try: - mcrfpy.delTimer(timer_name) + timer.stop() except: pass def restart_timers(self): """Re-register timers after cleanup.""" + self.timers = [] # Clear old timer references for name, callback, interval in self._timer_configs: try: - mcrfpy.setTimer(name, callback, interval) + timer = mcrfpy.Timer(name, callback, interval) + self.timers.append(timer) except Exception as e: print(f"Timer restart failed: {e}") @@ -111,6 +113,6 @@ class GeometryDemoScreen: if callback is None: print(f"Warning: Timer '{name}' callback is None, skipping") return - mcrfpy.setTimer(name, callback, interval) - self.timers.append(name) + timer = mcrfpy.Timer(name, callback, interval) + self.timers.append(timer) self._timer_configs.append((name, callback, interval)) diff --git a/tests/geometry_demo/screens/pathfinding_animated_demo.py b/tests/geometry_demo/screens/pathfinding_animated_demo.py index a1fc7b7..c59637d 100644 --- a/tests/geometry_demo/screens/pathfinding_animated_demo.py +++ b/tests/geometry_demo/screens/pathfinding_animated_demo.py @@ -269,7 +269,7 @@ class PathfindingAnimatedDemo(GeometryDemoScreen): self.dist_label.fill_color = mcrfpy.Color(150, 150, 150) self.ui.append(self.dist_label) - def _tick(self, runtime): + def _tick(self, timer, runtime): """Advance one turn.""" self.current_time += 1 self.time_label.text = f"Turn: {self.current_time}" diff --git a/tests/geometry_demo/screens/solar_system_demo.py b/tests/geometry_demo/screens/solar_system_demo.py index bd6eead..4c984c1 100644 --- a/tests/geometry_demo/screens/solar_system_demo.py +++ b/tests/geometry_demo/screens/solar_system_demo.py @@ -255,7 +255,7 @@ class SolarSystemDemo(GeometryDemoScreen): self.ui.append(moon_path) self.orbit_rings[moon.name + "_path"] = moon_path - def _tick(self, runtime): + def _tick(self, timer, runtime): """Advance time by one turn and update planet positions.""" self.current_time += 1 diff --git a/tests/integration/dijkstra_interactive_enhanced.py b/tests/integration/dijkstra_interactive_enhanced.py index 69e57fe..c5d3933 100644 --- a/tests/integration/dijkstra_interactive_enhanced.py +++ b/tests/integration/dijkstra_interactive_enhanced.py @@ -266,7 +266,7 @@ def handle_keypress(scene_name, keycode): sys.exit(0) # Timer callback for animation -def update_animation(dt): +def update_animation(timer, dt): """Update animation state""" animate_movement(dt / 1000.0) # Convert ms to seconds @@ -335,7 +335,7 @@ for i, entity in enumerate(entities): dijkstra_enhanced.on_key = handle_keypress # Set up animation timer (60 FPS) -mcrfpy.setTimer("animation", update_animation, 16) +animation_timer = mcrfpy.Timer("animation", update_animation, 16) # Show the scene dijkstra_enhanced.activate() diff --git a/tests/integration/dijkstra_test.py b/tests/integration/dijkstra_test.py index 79da530..3d1bcef 100644 --- a/tests/integration/dijkstra_test.py +++ b/tests/integration/dijkstra_test.py @@ -88,11 +88,11 @@ def test_dijkstra(grid, entities): return results -def run_test(runtime): +def run_test(timer, runtime): """Timer callback to run tests and take screenshot""" # Run pathfinding tests results = test_dijkstra(grid, entities) - + # Update display with results y_pos = 380 for result in results: @@ -100,9 +100,9 @@ def run_test(runtime): caption.fill_color = mcrfpy.Color(200, 200, 200) ui.append(caption) y_pos += 20 - - # Take screenshot - mcrfpy.setTimer("screenshot", lambda rt: take_screenshot(), 500) + + # Take screenshot (one-shot timer) + screenshot_timer = mcrfpy.Timer("screenshot", lambda t, rt: take_screenshot(), 500, once=True) def take_screenshot(): """Take screenshot and exit""" @@ -140,7 +140,7 @@ ui.append(legend) # Set scene dijkstra_test.activate() -# Run test after scene loads -mcrfpy.setTimer("test", run_test, 100) +# Run test after scene loads (one-shot timer) +test_timer = mcrfpy.Timer("test", run_test, 100, once=True) print("Running Dijkstra tests...") \ No newline at end of file diff --git a/tests/notes/test_exception_exit.py b/tests/notes/test_exception_exit.py index 0acc236..cad7370 100644 --- a/tests/notes/test_exception_exit.py +++ b/tests/notes/test_exception_exit.py @@ -9,7 +9,7 @@ This test verifies that: import mcrfpy import sys -def timer_that_raises(runtime): +def timer_that_raises(timer, runtime): """A timer callback that raises an exception""" raise ValueError("Intentional test exception") @@ -17,8 +17,8 @@ def timer_that_raises(runtime): test = mcrfpy.Scene("test") test.activate() -# Schedule the timer - it will fire after 50ms -mcrfpy.setTimer("raise_exception", timer_that_raises, 50) +# Schedule the timer - it will fire after 50ms (one-shot timer) +exception_timer = mcrfpy.Timer("raise_exception", timer_that_raises, 50, once=True) # This test expects: # - Default behavior: exit with code 1 after first exception diff --git a/tests/regression/issue_123_chunk_system_test.py b/tests/regression/issue_123_chunk_system_test.py index f26e260..de5ee41 100644 --- a/tests/regression/issue_123_chunk_system_test.py +++ b/tests/regression/issue_123_chunk_system_test.py @@ -158,7 +158,7 @@ def test_edge_cases(): print(" Edge cases: PASS") return True -def run_test(runtime): +def run_test(timer, runtime): """Timer callback to run tests after scene is active""" results = [] @@ -185,4 +185,4 @@ if __name__ == "__main__": test.activate() # Run tests after scene is active - mcrfpy.setTimer("test", run_test, 100) + test_timer = mcrfpy.Timer("test", run_test, 100, once=True) diff --git a/tests/regression/issue_146_fov_returns_none.py b/tests/regression/issue_146_fov_returns_none.py index c8b9d8f..4bfb1f8 100644 --- a/tests/regression/issue_146_fov_returns_none.py +++ b/tests/regression/issue_146_fov_returns_none.py @@ -14,7 +14,7 @@ import mcrfpy import sys import time -def run_test(runtime): +def run_test(timer, runtime): print("=" * 60) print("Issue #146 Regression Test: compute_fov() returns None") print("=" * 60) @@ -111,4 +111,4 @@ def run_test(runtime): # Initialize and run init = mcrfpy.Scene("init") init.activate() -mcrfpy.setTimer("test", run_test, 100) +test_timer = mcrfpy.Timer("test", run_test, 100, once=True) diff --git a/tests/regression/issue_147_grid_layers.py b/tests/regression/issue_147_grid_layers.py index 0f51d26..6f8214b 100644 --- a/tests/regression/issue_147_grid_layers.py +++ b/tests/regression/issue_147_grid_layers.py @@ -11,7 +11,7 @@ Tests: import mcrfpy import sys -def run_test(runtime): +def run_test(timer, runtime): print("=" * 60) print("Issue #147 Regression Test: Dynamic Layer System for Grid") print("=" * 60) @@ -190,4 +190,4 @@ def run_test(runtime): # Initialize and run init = mcrfpy.Scene("init") init.activate() -mcrfpy.setTimer("test", run_test, 100) +test_timer = mcrfpy.Timer("test", run_test, 100, once=True) diff --git a/tests/regression/issue_148_layer_dirty_flags.py b/tests/regression/issue_148_layer_dirty_flags.py index fbe53c5..ec91d89 100644 --- a/tests/regression/issue_148_layer_dirty_flags.py +++ b/tests/regression/issue_148_layer_dirty_flags.py @@ -14,7 +14,7 @@ import mcrfpy import sys import time -def run_test(runtime): +def run_test(timer, runtime): print("=" * 60) print("Issue #148 Regression Test: Layer Dirty Flags and Caching") print("=" * 60) @@ -154,4 +154,4 @@ def run_test(runtime): # Initialize and run init = mcrfpy.Scene("init") init.activate() -mcrfpy.setTimer("test", run_test, 100) +test_timer = mcrfpy.Timer("test", run_test, 100, once=True) diff --git a/tests/regression/issue_76_test.py b/tests/regression/issue_76_test.py index 52dbadd..8be11c3 100644 --- a/tests/regression/issue_76_test.py +++ b/tests/regression/issue_76_test.py @@ -17,7 +17,7 @@ class CustomEntity(mcrfpy.Entity): def custom_method(self): return "Custom method called" -def run_test(runtime): +def run_test(timer, runtime): """Test that derived entity classes maintain their type in collections""" try: # Create a grid @@ -85,4 +85,4 @@ test = mcrfpy.Scene("test") test.activate() # Schedule test to run after game loop starts -mcrfpy.setTimer("test", run_test, 100) \ No newline at end of file +test_timer = mcrfpy.Timer("test", run_test, 100, once=True) \ No newline at end of file diff --git a/tests/regression/issue_79_color_properties_test.py b/tests/regression/issue_79_color_properties_test.py index 97b9c3c..03e08c6 100644 --- a/tests/regression/issue_79_color_properties_test.py +++ b/tests/regression/issue_79_color_properties_test.py @@ -149,7 +149,7 @@ def test_color_properties(): return tests_passed == tests_total -def run_test(runtime): +def run_test(timer, runtime): """Timer callback to run the test""" try: success = test_color_properties() @@ -167,4 +167,4 @@ test = mcrfpy.Scene("test") test.activate() # Schedule test to run after game loop starts -mcrfpy.setTimer("test", run_test, 100) \ No newline at end of file +test_timer = mcrfpy.Timer("test", run_test, 100, once=True) \ No newline at end of file diff --git a/tests/regression/issue_99_texture_font_properties_test.py b/tests/regression/issue_99_texture_font_properties_test.py index 6f5e1f3..b4cb094 100644 --- a/tests/regression/issue_99_texture_font_properties_test.py +++ b/tests/regression/issue_99_texture_font_properties_test.py @@ -183,7 +183,7 @@ def test_property_introspection(): return tests_passed, tests_total -def run_test(runtime): +def run_test(timer, runtime): """Timer callback to run the test""" try: print("=== Testing Texture and Font Properties (Issue #99) ===\n") @@ -221,4 +221,4 @@ test = mcrfpy.Scene("test") test.activate() # Schedule test to run after game loop starts -mcrfpy.setTimer("test", run_test, 100) \ No newline at end of file +test_timer = mcrfpy.Timer("test", run_test, 100, once=True) \ No newline at end of file diff --git a/tests/regression/issue_9_minimal_test.py b/tests/regression/issue_9_minimal_test.py index 31e769e..545e1cc 100644 --- a/tests/regression/issue_9_minimal_test.py +++ b/tests/regression/issue_9_minimal_test.py @@ -7,7 +7,7 @@ import mcrfpy from mcrfpy import automation import sys -def run_test(runtime): +def run_test(timer, runtime): """Test RenderTexture resizing""" print("Testing Issue #9: RenderTexture resize (minimal)") @@ -64,4 +64,4 @@ test = mcrfpy.Scene("test") test.activate() # Schedule test -mcrfpy.setTimer("test", run_test, 100) \ No newline at end of file +test_timer = mcrfpy.Timer("test", run_test, 100, once=True) \ No newline at end of file diff --git a/tests/regression/issue_9_rendertexture_resize_test.py b/tests/regression/issue_9_rendertexture_resize_test.py index b6060a0..b62b062 100644 --- a/tests/regression/issue_9_rendertexture_resize_test.py +++ b/tests/regression/issue_9_rendertexture_resize_test.py @@ -209,7 +209,7 @@ def test_rendertexture_resize(): print(f"\nScreenshots saved to /tmp/issue_9_*.png") -def run_test(runtime): +def run_test(timer, runtime): """Timer callback to run the test""" try: test_rendertexture_resize() @@ -226,4 +226,4 @@ test = mcrfpy.Scene("test") test.activate() # Schedule test to run after game loop starts -mcrfpy.setTimer("test", run_test, 100) \ No newline at end of file +test_timer = mcrfpy.Timer("test", run_test, 100, once=True) \ No newline at end of file diff --git a/tests/regression/issue_9_test.py b/tests/regression/issue_9_test.py index e7b2cd2..c357082 100644 --- a/tests/regression/issue_9_test.py +++ b/tests/regression/issue_9_test.py @@ -9,7 +9,7 @@ import mcrfpy from mcrfpy import automation import sys -def run_test(runtime): +def run_test(timer, runtime): """Test that UIGrid properly handles resizing""" try: # Create a grid with initial size @@ -86,4 +86,4 @@ test = mcrfpy.Scene("test") test.activate() # Schedule test to run after game loop starts -mcrfpy.setTimer("test", run_test, 100) \ No newline at end of file +test_timer = mcrfpy.Timer("test", run_test, 100, once=True) \ No newline at end of file diff --git a/tests/regression/test_type_preservation_solution.py b/tests/regression/test_type_preservation_solution.py index 5f297c7..560f02b 100644 --- a/tests/regression/test_type_preservation_solution.py +++ b/tests/regression/test_type_preservation_solution.py @@ -64,7 +64,7 @@ def demonstrate_solution(): } """) -def run_test(runtime): +def run_test(timer, runtime): """Timer callback""" try: demonstrate_solution() @@ -74,10 +74,10 @@ def run_test(runtime): print(f"\nError: {e}") import traceback traceback.print_exc() - + sys.exit(0) # Set up scene and run test = mcrfpy.Scene("test") test.activate() -mcrfpy.setTimer("test", run_test, 100) \ No newline at end of file +test_timer = mcrfpy.Timer("test", run_test, 100, once=True) \ No newline at end of file diff --git a/tests/unit/WORKING_automation_test_example.py b/tests/unit/WORKING_automation_test_example.py index ba23ce8..bfa8b82 100644 --- a/tests/unit/WORKING_automation_test_example.py +++ b/tests/unit/WORKING_automation_test_example.py @@ -4,43 +4,44 @@ import mcrfpy from mcrfpy import automation from datetime import datetime -def run_automation_tests(): +def run_automation_tests(timer, runtime): """This runs AFTER the game loop has started and rendered frames""" print("\n=== Automation Test Running (1 second after start) ===") - + # NOW we can take screenshots that will show content! timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"WORKING_screenshot_{timestamp}.png" - + # Take screenshot - this should now show our red frame result = automation.screenshot(filename) print(f"Screenshot taken: {filename} - Result: {result}") - + # Test clicking on the frame automation.click(200, 200) # Click in center of red frame - + # Test keyboard input automation.typewrite("Hello from timer callback!") - + # Take another screenshot to show any changes filename2 = f"WORKING_screenshot_after_click_{timestamp}.png" automation.screenshot(filename2) print(f"Second screenshot: {filename2}") - + print("Test completed successfully!") print("\nThis works because:") print("1. The game loop has been running for 1 second") print("2. The scene has been rendered multiple times") print("3. The RenderTexture now contains actual rendered content") - + # Cancel this timer so it doesn't repeat - mcrfpy.delTimer("automation_test") - + timer.stop() + # Optional: exit after a moment - def exit_game(): + def exit_game(t, r): print("Exiting...") mcrfpy.exit() - mcrfpy.setTimer("exit", exit_game, 500) # Exit 500ms later + global exit_timer + exit_timer = mcrfpy.Timer("exit", exit_game, 500, once=True) # This code runs during --exec script execution print("=== Setting Up Test Scene ===") @@ -73,7 +74,7 @@ frame.on_click = frame_clicked print("Scene setup complete. Setting timer for automation tests...") # THIS IS THE KEY: Set timer to run AFTER the game loop starts -mcrfpy.setTimer("automation_test", run_automation_tests, 1000) +automation_test_timer = mcrfpy.Timer("automation_test", run_automation_tests, 1000, once=True) print("Timer set. Game loop will start after this script completes.") print("Automation tests will run 1 second later when content is visible.") diff --git a/tests/unit/benchmark_logging_test.py b/tests/unit/benchmark_logging_test.py index 2600b05..c47b08b 100644 --- a/tests/unit/benchmark_logging_test.py +++ b/tests/unit/benchmark_logging_test.py @@ -5,7 +5,7 @@ import sys import os import json -def run_test(runtime): +def run_test(timer, runtime): """Timer callback to test benchmark logging""" # Stop the benchmark and get filename try: @@ -132,4 +132,4 @@ test = mcrfpy.Scene("test") test.activate() # Schedule test completion after ~100ms (to capture some frames) -mcrfpy.setTimer("test", run_test, 100) +test_timer = mcrfpy.Timer("test", run_test, 100, once=True) diff --git a/tests/unit/debug_empty_paths.py b/tests/unit/debug_empty_paths.py index 6aea589..f76a6b0 100644 --- a/tests/unit/debug_empty_paths.py +++ b/tests/unit/debug_empty_paths.py @@ -68,13 +68,13 @@ print("\nTest 7: Path after potential sync") path4 = grid.compute_astar_path(0, 0, 5, 5) print(f" A* path: {path4}") -def timer_cb(dt): +def timer_cb(timer, runtime): sys.exit(0) # Quick UI setup ui = debug.children ui.append(grid) debug.activate() -mcrfpy.setTimer("exit", timer_cb, 100) +exit_timer = mcrfpy.Timer("exit", timer_cb, 100, once=True) print("\nStarting timer...") \ No newline at end of file diff --git a/tests/unit/generate_docs_screenshots.py b/tests/unit/generate_docs_screenshots.py index cd5b085..997c43d 100755 --- a/tests/unit/generate_docs_screenshots.py +++ b/tests/unit/generate_docs_screenshots.py @@ -404,48 +404,50 @@ screenshots = [ ("combined_example", "ui_combined_example.png") ] -def take_screenshots(runtime): +def take_screenshots(timer, runtime): """Timer callback to take screenshots sequentially""" global current_screenshot - + if current_screenshot >= len(screenshots): print("\nAll screenshots captured successfully!") print(f"Screenshots saved to: {output_dir}/") mcrfpy.exit() return - + scene_name, filename = screenshots[current_screenshot] - + # Switch to the scene mcrfpy.current_scene = scene_name - + # Take screenshot after a short delay to ensure rendering - def capture(): + def capture(t, r): global current_screenshot full_path = f"{output_dir}/{filename}" result = automation.screenshot(full_path) print(f"Screenshot {current_screenshot + 1}/{len(screenshots)}: {filename} - {'Success' if result else 'Failed'}") - + current_screenshot += 1 - + # Schedule next screenshot - mcrfpy.setTimer("next_screenshot", take_screenshots, 200) - + global next_screenshot_timer + next_screenshot_timer = mcrfpy.Timer("next_screenshot", take_screenshots, 200, once=True) + # Give scene time to render - mcrfpy.setTimer("capture", lambda r: capture(), 100) + global capture_timer + capture_timer = mcrfpy.Timer("capture", capture, 100, once=True) # Start with the first scene caption_example.activate() # Start the screenshot process print(f"\nStarting screenshot capture of {len(screenshots)} scenes...") -mcrfpy.setTimer("start", take_screenshots, 500) +start_timer = mcrfpy.Timer("start", take_screenshots, 500, once=True) # Safety timeout -def safety_exit(runtime): +def safety_exit(timer, runtime): print("\nERROR: Safety timeout reached! Exiting...") mcrfpy.exit() -mcrfpy.setTimer("safety", safety_exit, 30000) +safety_timer = mcrfpy.Timer("safety", safety_exit, 30000, once=True) print("Setup complete. Game loop starting...") \ No newline at end of file diff --git a/tests/unit/generate_grid_screenshot.py b/tests/unit/generate_grid_screenshot.py index 51754e7..0cfcfa0 100644 --- a/tests/unit/generate_grid_screenshot.py +++ b/tests/unit/generate_grid_screenshot.py @@ -5,13 +5,13 @@ import mcrfpy from mcrfpy import automation import sys -def capture_grid(runtime): +def capture_grid(timer, runtime): """Capture grid example after render loop starts""" - + # Take screenshot automation.screenshot("mcrogueface.github.io/images/ui_grid_example.png") print("Grid screenshot saved!") - + # Exit after capturing sys.exit(0) @@ -112,4 +112,4 @@ ui.append(info) grid.activate() # Set timer to capture after rendering starts -mcrfpy.setTimer("capture", capture_grid, 100) \ No newline at end of file +capture_timer = mcrfpy.Timer("capture", capture_grid, 100, once=True) \ No newline at end of file diff --git a/tests/unit/generate_sprite_screenshot.py b/tests/unit/generate_sprite_screenshot.py index d45197c..5191b7d 100644 --- a/tests/unit/generate_sprite_screenshot.py +++ b/tests/unit/generate_sprite_screenshot.py @@ -5,13 +5,13 @@ import mcrfpy from mcrfpy import automation import sys -def capture_sprites(runtime): +def capture_sprites(timer, runtime): """Capture sprite examples after render loop starts""" - + # Take screenshot automation.screenshot("mcrogueface.github.io/images/ui_sprite_example.png") print("Sprite screenshot saved!") - + # Exit after capturing sys.exit(0) @@ -157,4 +157,4 @@ ui.append(scale_label) sprites.activate() # Set timer to capture after rendering starts -mcrfpy.setTimer("capture", capture_sprites, 100) \ No newline at end of file +capture_timer = mcrfpy.Timer("capture", capture_sprites, 100, once=True) \ No newline at end of file diff --git a/tests/unit/keypress_scene_validation_test.py b/tests/unit/keypress_scene_validation_test.py index de1b8f4..5d037c6 100644 --- a/tests/unit/keypress_scene_validation_test.py +++ b/tests/unit/keypress_scene_validation_test.py @@ -3,7 +3,7 @@ Test for keypressScene() validation - should reject non-callable arguments """ -def test_keypress_validation(timer_name): +def test_keypress_validation(timer, runtime): """Test that keypressScene validates its argument is callable""" import mcrfpy import sys @@ -90,4 +90,4 @@ def test_keypress_validation(timer_name): # Execute the test after a short delay import mcrfpy -mcrfpy.setTimer("test", test_keypress_validation, 100) \ No newline at end of file +test_timer = mcrfpy.Timer("test", test_keypress_validation, 100, once=True) \ No newline at end of file diff --git a/tests/unit/simple_screenshot_test.py b/tests/unit/simple_screenshot_test.py index a23aeae..74c4fa1 100644 --- a/tests/unit/simple_screenshot_test.py +++ b/tests/unit/simple_screenshot_test.py @@ -6,17 +6,17 @@ from mcrfpy import automation import sys import time -def take_screenshot(runtime): +def take_screenshot(timer, runtime): """Take screenshot after render starts""" print(f"Timer callback fired at runtime: {runtime}") - + # Try different paths paths = [ "test_screenshot.png", - "./test_screenshot.png", + "./test_screenshot.png", "mcrogueface.github.io/images/test_screenshot.png" ] - + for path in paths: try: print(f"Trying to save to: {path}") @@ -24,7 +24,7 @@ def take_screenshot(runtime): print(f"Success: {path}") except Exception as e: print(f"Failed {path}: {e}") - + sys.exit(0) # Create minimal scene @@ -41,5 +41,5 @@ test.activate() # Use timer to ensure rendering has started print("Setting timer...") -mcrfpy.setTimer("screenshot", take_screenshot, 500) # Wait 0.5 seconds -print("Timer set, entering game loop...") \ No newline at end of file +mcrfpy.Timer("screenshot", take_screenshot, 500, once=True) # Wait 0.5 seconds +print("Timer set, entering game loop...") diff --git a/tests/unit/simple_timer_screenshot_test.py b/tests/unit/simple_timer_screenshot_test.py index cfa61fc..b4f0d63 100644 --- a/tests/unit/simple_timer_screenshot_test.py +++ b/tests/unit/simple_timer_screenshot_test.py @@ -6,18 +6,18 @@ from mcrfpy import automation # Counter to track timer calls call_count = 0 -def take_screenshot_and_exit(): +def take_screenshot_and_exit(timer, runtime): """Timer callback that takes screenshot then exits""" global call_count call_count += 1 - + print(f"\nTimer callback fired! (call #{call_count})") - + # Take screenshot filename = f"timer_screenshot_test_{call_count}.png" result = automation.screenshot(filename) print(f"Screenshot result: {result} -> {filename}") - + # Exit after first call if call_count >= 1: print("Exiting game...") @@ -35,6 +35,6 @@ frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200), ui.append(frame) print("Setting timer to fire in 100ms...") -mcrfpy.setTimer("screenshot_timer", take_screenshot_and_exit, 100) +mcrfpy.Timer("screenshot_timer", take_screenshot_and_exit, 100, once=True) -print("Setup complete. Game loop starting...") \ No newline at end of file +print("Setup complete. Game loop starting...") diff --git a/tests/unit/test_animation_callback_simple.py b/tests/unit/test_animation_callback_simple.py index 9a759c6..48b5163 100644 --- a/tests/unit/test_animation_callback_simple.py +++ b/tests/unit/test_animation_callback_simple.py @@ -6,6 +6,7 @@ import sys # Global state to track callback callback_count = 0 +callback_demo = None # Will be set in setup_and_run def my_callback(anim, target): """Simple callback that prints when animation completes""" @@ -16,47 +17,48 @@ def my_callback(anim, target): def setup_and_run(): """Set up scene and run animation with callback""" + global callback_demo # Create scene callback_demo = mcrfpy.Scene("callback_demo") callback_demo.activate() - + # Create a frame to animate frame = mcrfpy.Frame((100, 100), (200, 200), fill_color=(255, 0, 0)) ui = callback_demo.children ui.append(frame) - + # Create animation with callback print("Starting animation with callback...") anim = mcrfpy.Animation("x", 400.0, 1.0, "easeInOutQuad", callback=my_callback) anim.start(frame) - - # Schedule check after animation should complete - mcrfpy.setTimer("check", check_result, 1500) -def check_result(runtime): + # Schedule check after animation should complete + mcrfpy.Timer("check", check_result, 1500, once=True) + +def check_result(timer, runtime): """Check if callback fired correctly""" - global callback_count - + global callback_count, callback_demo + if callback_count == 1: print("SUCCESS: Callback fired exactly once!") - + # Test 2: Animation without callback print("\nTesting animation without callback...") ui = callback_demo.children frame = ui[0] - + anim2 = mcrfpy.Animation("y", 300.0, 0.5, "linear") anim2.start(frame) - - mcrfpy.setTimer("final", final_check, 700) + + mcrfpy.Timer("final", final_check, 700, once=True) else: print(f"FAIL: Expected 1 callback, got {callback_count}") sys.exit(1) -def final_check(runtime): +def final_check(timer, runtime): """Final check - callback count should still be 1""" global callback_count - + if callback_count == 1: print("SUCCESS: No unexpected callbacks fired!") print("\nAnimation callback feature working correctly!") @@ -68,4 +70,4 @@ def final_check(runtime): # Start the demo print("Animation Callback Demo") print("=" * 30) -setup_and_run() \ No newline at end of file +setup_and_run() diff --git a/tests/unit/test_animation_chaining.py b/tests/unit/test_animation_chaining.py index 2e069dd..b91b5f8 100644 --- a/tests/unit/test_animation_chaining.py +++ b/tests/unit/test_animation_chaining.py @@ -35,35 +35,36 @@ class PathAnimator: if self.current_index >= len(self.path): # Path complete self.animating = False - mcrfpy.delTimer(self.check_timer_name) + if hasattr(self, '_check_timer'): + self._check_timer.stop() if self.on_complete: self.on_complete() return - + # Get target position target_x, target_y = self.path[self.current_index] - + # Create animations self.anim_x = mcrfpy.Animation("x", float(target_x), self.step_duration, "easeInOut") self.anim_y = mcrfpy.Animation("y", float(target_y), self.step_duration, "easeInOut") - + # Start animations self.anim_x.start(self.entity) self.anim_y.start(self.entity) - + # Update visibility if entity has this method if hasattr(self.entity, 'update_visibility'): self.entity.update_visibility() - + # Set timer to check completion - mcrfpy.setTimer(self.check_timer_name, self._check_completion, 50) - - def _check_completion(self, dt): + self._check_timer = mcrfpy.Timer(self.check_timer_name, self._check_completion, 50) + + def _check_completion(self, timer, runtime): """Check if current animation is complete""" if hasattr(self.anim_x, 'is_complete') and self.anim_x.is_complete: # Move to next step self.current_index += 1 - mcrfpy.delTimer(self.check_timer_name) + timer.stop() self._animate_next_step() # Create test scene @@ -165,7 +166,7 @@ def animate_both(): # Camera follow test camera_follow = False -def update_camera(dt): +def update_camera(timer, runtime): """Update camera to follow player if enabled""" if camera_follow and player_animator and player_animator.animating: # Smooth camera follow @@ -205,7 +206,7 @@ chain_test.activate() chain_test.on_key = handle_input # Camera update timer -mcrfpy.setTimer("cam_update", update_camera, 100) +cam_update_timer = mcrfpy.Timer("cam_update", update_camera, 100) print("Animation Chaining Test") print("=======================") diff --git a/tests/unit/test_animation_debug.py b/tests/unit/test_animation_debug.py index 9f1c4ce..f297df6 100644 --- a/tests/unit/test_animation_debug.py +++ b/tests/unit/test_animation_debug.py @@ -38,25 +38,25 @@ class AnimationTracker: # Track it active_animations[self.name] = self - + # Set timer to check completion check_interval = 100 # ms - mcrfpy.setTimer(f"check_{self.name}", self._check_complete, check_interval) + self._check_timer = mcrfpy.Timer(f"check_{self.name}", self._check_complete, check_interval) - def _check_complete(self, dt): + def _check_complete(self, timer, runtime): """Check if animation is complete""" if self.animation and hasattr(self.animation, 'is_complete') and self.animation.is_complete: # Log completion log_entry = f"COMPLETE: {self.name}" animation_log.append(log_entry) print(log_entry) - + # Remove from active if self.name in active_animations: del active_animations[self.name] - + # Stop checking - mcrfpy.delTimer(f"check_{self.name}") + timer.stop() # Create test scene anim_debug = mcrfpy.Scene("anim_debug") @@ -117,14 +117,15 @@ def test_rapid_fire(): # Start first animation anim1 = AnimationTracker("rapid_1", entity, "x", 8.0, 2.0) anim1.start() - + # Start another after 500ms (before first completes) - def start_second(dt): + def start_second(timer, runtime): anim2 = AnimationTracker("rapid_2", entity, "x", 12.0, 1.0) anim2.start() - mcrfpy.delTimer("rapid_timer") - - mcrfpy.setTimer("rapid_timer", start_second, 500) + timer.stop() + + global rapid_timer + rapid_timer = mcrfpy.Timer("rapid_timer", start_second, 500, once=True) def test_sequential(): """Test proper sequential animations""" @@ -142,14 +143,14 @@ def test_sequential(): if index >= len(sequence): print("Sequence complete!") return - + name, prop, value, duration = sequence[index] anim = AnimationTracker(name, entity, prop, value, duration) anim.start() - + # Schedule next delay = int(duration * 1000) + 100 # Add buffer - mcrfpy.setTimer(f"seq_timer_{index}", lambda dt: run_sequence(index + 1), delay) + mcrfpy.Timer(f"seq_timer_{index}", lambda t, r: run_sequence(index + 1), delay, once=True) run_sequence() @@ -163,19 +164,20 @@ def test_conflicting(): anim1.start() # After 1 second, start conflicting animation to x=2 - def start_conflict(dt): + def start_conflict(timer, runtime): print("Starting conflicting animation!") anim2 = AnimationTracker("conflict_2", entity, "x", 2.0, 1.0) anim2.start() - mcrfpy.delTimer("conflict_timer") - - mcrfpy.setTimer("conflict_timer", start_conflict, 1000) + timer.stop() + + global conflict_timer + conflict_timer = mcrfpy.Timer("conflict_timer", start_conflict, 1000, once=True) # Update display -def update_display(dt): +def update_display(timer, runtime): pos_display.text = f"Entity position: ({entity.x:.2f}, {entity.y:.2f})" active_display.text = f"Active animations: {len(active_animations)}" - + # Show active animation names if active_animations: names = ", ".join(active_animations.keys()) @@ -217,7 +219,7 @@ def handle_input(key, state): # Setup anim_debug.activate() anim_debug.on_key = handle_input -mcrfpy.setTimer("update", update_display, 100) +update_display_timer = mcrfpy.Timer("update", update_display, 100) print("Animation Debug Tool") print("====================") diff --git a/tests/unit/test_animation_property_locking.py b/tests/unit/test_animation_property_locking.py index 694206e..165fde7 100644 --- a/tests/unit/test_animation_property_locking.py +++ b/tests/unit/test_animation_property_locking.py @@ -210,7 +210,7 @@ def test_8_replace_completes_old(): test_result("Replace completes old animation", False, str(e)) -def run_all_tests(runtime): +def run_all_tests(timer, runtime): """Run all property locking tests""" print("\nRunning Animation Property Locking Tests...") print("-" * 50) @@ -246,4 +246,4 @@ test = mcrfpy.Scene("test") test.activate() # Start tests after a brief delay to allow scene to initialize -mcrfpy.setTimer("start", run_all_tests, 100) +mcrfpy.Timer("start", run_all_tests, 100, once=True) diff --git a/tests/unit/test_animation_raii.py b/tests/unit/test_animation_raii.py index b7e556b..438e323 100644 --- a/tests/unit/test_animation_raii.py +++ b/tests/unit/test_animation_raii.py @@ -93,32 +93,32 @@ def test_3_complete_animation(): def test_4_multiple_animations_timer(): """Test creating multiple animations in timer callback""" success = False - - def create_animations(runtime): + + def create_animations(timer, runtime): nonlocal success try: ui = test.children frame = mcrfpy.Frame(pos=(200, 200), size=(100, 100)) ui.append(frame) - + # Create multiple animations rapidly (this used to crash) for i in range(10): anim = mcrfpy.Animation("x", 300.0 + i * 10, 1000, "linear") anim.start(frame) - + success = True except Exception as e: print(f"Timer animation error: {e}") finally: - mcrfpy.setTimer("exit", lambda t: None, 100) - + mcrfpy.Timer("exit", lambda t, r: None, 100, once=True) + # Clear scene ui = test.children while len(ui) > 0: ui.remove(len(ui) - 1) - - mcrfpy.setTimer("test", create_animations, 50) - mcrfpy.setTimer("check", lambda t: test_result("Multiple animations in timer", success), 200) + + mcrfpy.Timer("test", create_animations, 50, once=True) + mcrfpy.Timer("check", lambda t, r: test_result("Multiple animations in timer", success), 200, once=True) def test_5_scene_cleanup(): """Test that changing scenes cleans up animations""" @@ -168,38 +168,38 @@ def test_6_animation_after_clear(): except Exception as e: test_result("Animation after UI clear", False, str(e)) -def run_all_tests(runtime): +def run_all_tests(timer, runtime): """Run all RAII tests""" print("\nRunning RAII Animation Tests...") print("-" * 40) - + test_1_basic_animation() test_2_remove_animated_object() test_3_complete_animation() test_4_multiple_animations_timer() test_5_scene_cleanup() test_6_animation_after_clear() - - # Schedule result summary - mcrfpy.setTimer("results", print_results, 500) -def print_results(runtime): + # Schedule result summary + mcrfpy.Timer("results", print_results, 500, once=True) + +def print_results(timer, runtime): """Print test results""" print("\n" + "=" * 40) print(f"Tests passed: {tests_passed}") print(f"Tests failed: {tests_failed}") - + if tests_failed == 0: - print("\nāœ“ All tests passed! RAII implementation is working correctly.") + print("\n+ All tests passed! RAII implementation is working correctly.") else: - print(f"\nāœ— {tests_failed} tests failed.") + print(f"\nx {tests_failed} tests failed.") print("\nFailed tests:") for name, passed, details in test_results: if not passed: print(f" - {name}: {details}") - + # Exit - mcrfpy.setTimer("exit", lambda t: sys.exit(0 if tests_failed == 0 else 1), 500) + mcrfpy.Timer("exit", lambda t, r: sys.exit(0 if tests_failed == 0 else 1), 500, once=True) # Setup and run test = mcrfpy.Scene("test") @@ -212,4 +212,4 @@ bg.fill_color = mcrfpy.Color(20, 20, 30) ui.append(bg) # Start tests -mcrfpy.setTimer("start", run_all_tests, 100) \ No newline at end of file +start_timer = mcrfpy.Timer("start", run_all_tests, 100, once=True) \ No newline at end of file diff --git a/tests/unit/test_animation_removal.py b/tests/unit/test_animation_removal.py index ec0191f..f5baaea 100644 --- a/tests/unit/test_animation_removal.py +++ b/tests/unit/test_animation_removal.py @@ -6,7 +6,7 @@ Test if the crash is related to removing animated objects import mcrfpy import sys -def clear_and_recreate(runtime): +def clear_and_recreate(timer, runtime): """Clear UI and recreate - mimics demo switching""" print(f"\nTimer called at {runtime}") @@ -31,9 +31,10 @@ def clear_and_recreate(runtime): anim.start(f) print("New objects created and animated") - + # Schedule exit - mcrfpy.setTimer("exit", lambda t: sys.exit(0), 2000) + global exit_timer + exit_timer = mcrfpy.Timer("exit", lambda t, r: sys.exit(0), 2000, once=True) # Create initial scene print("Creating scene...") @@ -60,6 +61,6 @@ for i in range(10): print(f"Initial scene has {len(ui)} elements") # Schedule the clear and recreate -mcrfpy.setTimer("switch", clear_and_recreate, 1000) +switch_timer = mcrfpy.Timer("switch", clear_and_recreate, 1000, once=True) print("\nEntering game loop...") \ No newline at end of file diff --git a/tests/unit/test_astar.py b/tests/unit/test_astar.py index db49633..e2377a1 100644 --- a/tests/unit/test_astar.py +++ b/tests/unit/test_astar.py @@ -114,7 +114,7 @@ print(" - Empty paths returned for blocked destinations") print(" - Diagonal movement supported") # Quick visual test -def visual_test(runtime): +def visual_test(timer, runtime): print("\nVisual test timer fired") sys.exit(0) @@ -125,6 +125,6 @@ grid.position = (50, 50) grid.size = (400, 400) astar_test.activate() -mcrfpy.setTimer("visual", visual_test, 100) +visual_test_timer = mcrfpy.Timer("visual", visual_test, 100, once=True) print("\nStarting visual test...") \ No newline at end of file diff --git a/tests/unit/test_color_helpers.py b/tests/unit/test_color_helpers.py index 95e4aa3..795ee31 100644 --- a/tests/unit/test_color_helpers.py +++ b/tests/unit/test_color_helpers.py @@ -6,7 +6,7 @@ Test #94: Color helper methods - from_hex, to_hex, lerp import mcrfpy import sys -def test_color_helpers(runtime): +def test_color_helpers(timer, runtime): """Test Color helper methods""" all_pass = True @@ -179,4 +179,4 @@ def test_color_helpers(runtime): # Run test test = mcrfpy.Scene("test") -mcrfpy.setTimer("test", test_color_helpers, 100) \ No newline at end of file +test_timer = mcrfpy.Timer("test", test_color_helpers, 100, once=True) \ No newline at end of file diff --git a/tests/unit/test_dijkstra_pathfinding.py b/tests/unit/test_dijkstra_pathfinding.py index 7276e13..f92df53 100644 --- a/tests/unit/test_dijkstra_pathfinding.py +++ b/tests/unit/test_dijkstra_pathfinding.py @@ -183,7 +183,7 @@ def test_multi_target_scenario(): cell.tilesprite = 83 # S for safe grid._color_layer.set(best_pos[0], best_pos[1], mcrfpy.Color(0, 255, 0)) -def run_test(runtime): +def run_test(timer, runtime): """Timer callback to run tests after scene loads""" test_basic_dijkstra() test_libtcod_interface() @@ -221,7 +221,7 @@ title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) # Set timer to run tests -mcrfpy.setTimer("test", run_test, 100) +test_timer = mcrfpy.Timer("test", run_test, 100, once=True) # Show scene dijkstra_test.activate() \ No newline at end of file diff --git a/tests/unit/test_documentation.py b/tests/unit/test_documentation.py index 961a417..384a338 100644 --- a/tests/unit/test_documentation.py +++ b/tests/unit/test_documentation.py @@ -20,7 +20,7 @@ def test_method_docs(): 'createSoundBuffer', 'loadMusic', 'setMusicVolume', 'setSoundVolume', 'playSound', 'getMusicVolume', 'getSoundVolume', 'sceneUI', 'currentScene', 'setScene', 'createScene', 'keypressScene', - 'setTimer', 'delTimer', 'exit', 'setScale', 'find', 'findAll', + 'exit', 'setScale', 'find', 'findAll', 'getMetrics' ] @@ -40,7 +40,7 @@ def test_class_docs(): """Test class documentation.""" print("=== Class Documentation ===") - classes = ['Frame', 'Caption', 'Sprite', 'Grid', 'Entity', 'Color', 'Vector', 'Texture', 'Font'] + classes = ['Frame', 'Caption', 'Sprite', 'Grid', 'Entity', 'Color', 'Vector', 'Texture', 'Font', 'Timer'] for class_name in classes: if hasattr(mcrfpy, class_name): @@ -80,12 +80,12 @@ def test_method_signatures(): else: print("āœ— setScene signature incorrect or missing") - if hasattr(mcrfpy, 'setTimer'): - doc = mcrfpy.setTimer.__doc__ - if doc and 'setTimer(name: str, handler: callable, interval: int)' in doc: - print("āœ“ setTimer signature correct") + if hasattr(mcrfpy, 'Timer'): + doc = mcrfpy.Timer.__doc__ + if doc and 'Timer' in doc: + print("+ Timer class documentation present") else: - print("āœ— setTimer signature incorrect or missing") + print("x Timer class documentation missing") if hasattr(mcrfpy, 'find'): doc = mcrfpy.find.__doc__ diff --git a/tests/unit/test_empty_animation_manager.py b/tests/unit/test_empty_animation_manager.py index 52955ae..225bbde 100644 --- a/tests/unit/test_empty_animation_manager.py +++ b/tests/unit/test_empty_animation_manager.py @@ -12,9 +12,9 @@ test.activate() print("Scene created, no animations added") print("Starting game loop in 100ms...") -def check_alive(runtime): +def check_alive(timer, runtime): print(f"Timer fired at {runtime}ms - AnimationManager survived!") - mcrfpy.setTimer("exit", lambda t: mcrfpy.exit(), 100) + mcrfpy.Timer("exit", lambda t, r: mcrfpy.exit(), 100, once=True) -mcrfpy.setTimer("check", check_alive, 1000) -print("If this crashes immediately, AnimationManager has an issue with empty state") \ No newline at end of file +mcrfpy.Timer("check", check_alive, 1000, once=True) +print("If this crashes immediately, AnimationManager has an issue with empty state") diff --git a/tests/unit/test_entity_animation.py b/tests/unit/test_entity_animation.py index d28a89a..0d92338 100644 --- a/tests/unit/test_entity_animation.py +++ b/tests/unit/test_entity_animation.py @@ -77,10 +77,10 @@ current_waypoint = 0 animating = False waypoints = [(5,5), (10,5), (10,10), (5,10), (5,5)] -def update_position_display(dt): +def update_position_display(timer, runtime): """Update position display every 200ms""" pos_display.text = f"Entity Position: ({entity.x:.2f}, {entity.y:.2f})" - + # Check if entity is at expected position if animating and current_waypoint > 0: target = waypoints[current_waypoint - 1] @@ -124,9 +124,10 @@ def animate_to_next_waypoint(): print(f"Started animations: x to {float(target_x)}, y to {float(target_y)}, duration: {duration}s") current_waypoint += 1 - + # Schedule next waypoint - mcrfpy.setTimer("next_waypoint", lambda dt: animate_to_next_waypoint(), int(duration * 1000 + 100)) + global next_waypoint_timer + next_waypoint_timer = mcrfpy.Timer("next_waypoint", lambda t, r: animate_to_next_waypoint(), int(duration * 1000 + 100), once=True) def start_animation(): """Start or restart the animation sequence""" @@ -186,7 +187,7 @@ test_anim.activate() test_anim.on_key = handle_input # Start position update timer -mcrfpy.setTimer("update_pos", update_position_display, 200) +update_pos_timer = mcrfpy.Timer("update_pos", update_position_display, 200) # No perspective (omniscient view) grid.perspective = -1 diff --git a/tests/unit/test_entity_fix.py b/tests/unit/test_entity_fix.py index 6f35167..ee8377a 100644 --- a/tests/unit/test_entity_fix.py +++ b/tests/unit/test_entity_fix.py @@ -72,7 +72,7 @@ status.fill_color = mcrfpy.Color(200, 200, 200) ui.append(status) # Update display -def update_display(dt): +def update_display(timer, runtime): pos_info.text = f"Entity Grid Position: ({entity.x:.2f}, {entity.y:.2f})" # We can't access sprite position from Python, but in C++ it would show # the issue: sprite position would be (2, 2) instead of pixel coords @@ -113,7 +113,7 @@ def handle_input(key, state): # Setup fix_demo.activate() fix_demo.on_key = handle_input -mcrfpy.setTimer("update", update_display, 100) +update_timer = mcrfpy.Timer("update", update_display, 100) print("Ready to demonstrate the issue.") print() diff --git a/tests/unit/test_frame_clipping.py b/tests/unit/test_frame_clipping.py index c568a1a..0ec5c09 100644 --- a/tests/unit/test_frame_clipping.py +++ b/tests/unit/test_frame_clipping.py @@ -8,9 +8,9 @@ import sys # Module-level state to avoid closures _test_state = {} -def take_second_screenshot(runtime): +def take_second_screenshot(timer, runtime): """Take final screenshot and exit""" - mcrfpy.delTimer("screenshot2") + timer.stop() from mcrfpy import automation automation.screenshot("frame_clipping_animated.png") print("\nTest completed successfully!") @@ -19,20 +19,21 @@ def take_second_screenshot(runtime): print(" - frame_clipping_animated.png (with animation)") sys.exit(0) -def animate_frames(runtime): +def animate_frames(timer, runtime): """Animate frames to demonstrate clipping""" - mcrfpy.delTimer("animate") + timer.stop() scene = test.children # Move child frames parent1 = scene[0] parent2 = scene[1] parent1.children[1].x = 50 parent2.children[1].x = 50 - mcrfpy.setTimer("screenshot2", take_second_screenshot, 500) + global screenshot2_timer + screenshot2_timer = mcrfpy.Timer("screenshot2", take_second_screenshot, 500, once=True) -def test_clipping(runtime): +def test_clipping(timer, runtime): """Test that clip_children property works correctly""" - mcrfpy.delTimer("test_clipping") + timer.stop() print("Testing UIFrame clipping functionality...") @@ -115,7 +116,8 @@ def test_clipping(runtime): print(f"PASS: clip_children correctly rejected non-boolean: {e}") # Start animation after a short delay - mcrfpy.setTimer("animate", animate_frames, 100) + global animate_timer + animate_timer = mcrfpy.Timer("animate", animate_frames, 100, once=True) def handle_keypress(key, modifiers): if key == "c": @@ -129,5 +131,5 @@ print("Creating test scene...") test = mcrfpy.Scene("test") test.activate() test.on_key = handle_keypress -mcrfpy.setTimer("test_clipping", test_clipping, 100) +test_clipping_timer = mcrfpy.Timer("test_clipping", test_clipping, 100, once=True) print("Test scheduled, running...") diff --git a/tests/unit/test_frame_clipping_advanced.py b/tests/unit/test_frame_clipping_advanced.py index 2b50e02..5c18331 100644 --- a/tests/unit/test_frame_clipping_advanced.py +++ b/tests/unit/test_frame_clipping_advanced.py @@ -5,9 +5,9 @@ import mcrfpy from mcrfpy import Color, Frame, Caption, Vector import sys -def test_nested_clipping(runtime): +def test_nested_clipping(timer, runtime): """Test nested frames with clipping""" - mcrfpy.delTimer("test_nested_clipping") + timer.stop() print("Testing advanced UIFrame clipping with nested frames...") @@ -62,8 +62,8 @@ def test_nested_clipping(runtime): print(f"Inner frame size: {inner.w}x{inner.h}") # Dynamically resize frames to test RenderTexture recreation - def resize_test(runtime): - mcrfpy.delTimer("resize_test") + def resize_test(timer, runtime): + timer.stop() print("Resizing frames to test RenderTexture recreation...") outer.w = 450 outer.h = 350 @@ -71,12 +71,13 @@ def test_nested_clipping(runtime): inner.h = 250 print(f"New outer frame size: {outer.w}x{outer.h}") print(f"New inner frame size: {inner.w}x{inner.h}") - + # Take screenshot after resize - mcrfpy.setTimer("screenshot_resize", take_resize_screenshot, 500) - - def take_resize_screenshot(runtime): - mcrfpy.delTimer("screenshot_resize") + global screenshot_resize_timer + screenshot_resize_timer = mcrfpy.Timer("screenshot_resize", take_resize_screenshot, 500, once=True) + + def take_resize_screenshot(timer, runtime): + timer.stop() from mcrfpy import automation automation.screenshot("frame_clipping_resized.png") print("\nAdvanced test completed!") @@ -88,9 +89,10 @@ def test_nested_clipping(runtime): from mcrfpy import automation automation.screenshot("frame_clipping_nested.png") print("Initial screenshot saved: frame_clipping_nested.png") - + # Schedule resize test - mcrfpy.setTimer("resize_test", resize_test, 1000) + global resize_test_timer + resize_test_timer = mcrfpy.Timer("resize_test", resize_test, 1000, once=True) # Main execution print("Creating advanced test scene...") @@ -98,6 +100,6 @@ test = mcrfpy.Scene("test") test.activate() # Schedule the test -mcrfpy.setTimer("test_nested_clipping", test_nested_clipping, 100) +test_nested_clipping_timer = mcrfpy.Timer("test_nested_clipping", test_nested_clipping, 100, once=True) print("Advanced test scheduled, running...") \ No newline at end of file diff --git a/tests/unit/test_grid_background.py b/tests/unit/test_grid_background.py index 4fee8b9..64975f5 100644 --- a/tests/unit/test_grid_background.py +++ b/tests/unit/test_grid_background.py @@ -42,25 +42,25 @@ def test_grid_background(): # Activate the scene test.activate() - def run_tests(dt): + def run_tests(timer, runtime): """Run background color tests""" - mcrfpy.delTimer("run_tests") + timer.stop() print("\nTest 1: Default background color") default_color = grid.background_color print(f"Default: R={default_color.r}, G={default_color.g}, B={default_color.b}, A={default_color.a}") color_display.text = f"R:{default_color.r} G:{default_color.g} B:{default_color.b}" - def test_set_color(dt): - mcrfpy.delTimer("test_set") + def test_set_color(timer, runtime): + timer.stop() print("\nTest 2: Set background to blue") grid.background_color = mcrfpy.Color(20, 40, 100) new_color = grid.background_color - print(f"āœ“ Set to: R={new_color.r}, G={new_color.g}, B={new_color.b}") + print(f"+ Set to: R={new_color.r}, G={new_color.g}, B={new_color.b}") color_display.text = f"R:{new_color.r} G:{new_color.g} B:{new_color.b}" - - def test_animation(dt): - mcrfpy.delTimer("test_anim") + + def test_animation(timer, runtime): + timer.stop() print("\nTest 3: Manual color cycling") # Manually change color to test property is working colors = [ @@ -68,55 +68,55 @@ def test_grid_background(): mcrfpy.Color(20, 200, 20), # Green mcrfpy.Color(20, 20, 200), # Blue ] - + color_index = [0] # Use list to allow modification in nested function - - def cycle_red(dt): - mcrfpy.delTimer("cycle_0") + + def cycle_red(t, r): + t.stop() grid.background_color = colors[0] c = grid.background_color color_display.text = f"R:{c.r} G:{c.g} B:{c.b}" - print(f"āœ“ Set to Red: R={c.r}, G={c.g}, B={c.b}") - - def cycle_green(dt): - mcrfpy.delTimer("cycle_1") + print(f"+ Set to Red: R={c.r}, G={c.g}, B={c.b}") + + def cycle_green(t, r): + t.stop() grid.background_color = colors[1] c = grid.background_color color_display.text = f"R:{c.r} G:{c.g} B:{c.b}" - print(f"āœ“ Set to Green: R={c.r}, G={c.g}, B={c.b}") - - def cycle_blue(dt): - mcrfpy.delTimer("cycle_2") + print(f"+ Set to Green: R={c.r}, G={c.g}, B={c.b}") + + def cycle_blue(t, r): + t.stop() grid.background_color = colors[2] c = grid.background_color color_display.text = f"R:{c.r} G:{c.g} B:{c.b}" - print(f"āœ“ Set to Blue: R={c.r}, G={c.g}, B={c.b}") - + print(f"+ Set to Blue: R={c.r}, G={c.g}, B={c.b}") + # Cycle through colors - mcrfpy.setTimer("cycle_0", cycle_red, 100) - mcrfpy.setTimer("cycle_1", cycle_green, 400) - mcrfpy.setTimer("cycle_2", cycle_blue, 700) - - def test_complete(dt): - mcrfpy.delTimer("complete") + mcrfpy.Timer("cycle_0", cycle_red, 100, once=True) + mcrfpy.Timer("cycle_1", cycle_green, 400, once=True) + mcrfpy.Timer("cycle_2", cycle_blue, 700, once=True) + + def test_complete(timer, runtime): + timer.stop() print("\nTest 4: Final color check") final_color = grid.background_color print(f"Final: R={final_color.r}, G={final_color.g}, B={final_color.b}") - - print("\nāœ“ Grid background color tests completed!") + + print("\n+ Grid background color tests completed!") print("- Default background color works") print("- Setting background color works") print("- Color cycling works") - + sys.exit(0) - + # Schedule tests - mcrfpy.setTimer("test_set", test_set_color, 1000) - mcrfpy.setTimer("test_anim", test_animation, 2000) - mcrfpy.setTimer("complete", test_complete, 4500) - + mcrfpy.Timer("test_set", test_set_color, 1000, once=True) + mcrfpy.Timer("test_anim", test_animation, 2000, once=True) + mcrfpy.Timer("complete", test_complete, 4500, once=True) + # Start tests - mcrfpy.setTimer("run_tests", run_tests, 100) + mcrfpy.Timer("run_tests", run_tests, 100, once=True) if __name__ == "__main__": test_grid_background() \ No newline at end of file diff --git a/tests/unit/test_grid_children.py b/tests/unit/test_grid_children.py index 26f7f39..306f8d9 100644 --- a/tests/unit/test_grid_children.py +++ b/tests/unit/test_grid_children.py @@ -4,18 +4,18 @@ import mcrfpy from mcrfpy import automation import sys -def take_screenshot(runtime): +def take_screenshot(timer, runtime): """Take screenshot after render completes""" - mcrfpy.delTimer("screenshot") + timer.stop() automation.screenshot("test_grid_children_result.png") print("Screenshot saved to test_grid_children_result.png") print("PASS - Grid.children test completed") sys.exit(0) -def run_test(runtime): +def run_test(timer, runtime): """Main test - runs after scene is set up""" - mcrfpy.delTimer("test") + timer.stop() # Get the scene UI ui = test.children @@ -119,11 +119,11 @@ def run_test(runtime): print(f"\nFinal children count: {len(grid.children)}") # Schedule screenshot for next frame - mcrfpy.setTimer("screenshot", take_screenshot, 100) + mcrfpy.Timer("screenshot", take_screenshot, 100, once=True) # Create a test scene test = mcrfpy.Scene("test") test.activate() # Schedule test to run after game loop starts -mcrfpy.setTimer("test", run_test, 50) +mcrfpy.Timer("test", run_test, 50, once=True) diff --git a/tests/unit/test_headless_detection.py b/tests/unit/test_headless_detection.py index 658d93b..4dbf1c3 100644 --- a/tests/unit/test_headless_detection.py +++ b/tests/unit/test_headless_detection.py @@ -15,12 +15,12 @@ frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200)) frame.fill_color = mcrfpy.Color(255, 100, 100, 255) ui.append(frame) -def test_mode(runtime): +def test_mode(timer, runtime): try: # Try to take a screenshot - this should work in both modes automation.screenshot("test_screenshot.png") print("PASS: Screenshot capability available") - + # Check if we can interact with the window try: # In headless mode, this should still work but via the headless renderer @@ -28,12 +28,12 @@ def test_mode(runtime): print("PASS: Click automation available") except Exception as e: print(f"Click failed: {e}") - + except Exception as e: print(f"Screenshot failed: {e}") - + print("Test complete") sys.exit(0) # Run test after render loop starts -mcrfpy.setTimer("test", test_mode, 100) \ No newline at end of file +test_timer = mcrfpy.Timer("test", test_mode, 100, once=True) \ No newline at end of file diff --git a/tests/unit/test_headless_modes.py b/tests/unit/test_headless_modes.py index ab80a4f..8847c14 100644 --- a/tests/unit/test_headless_modes.py +++ b/tests/unit/test_headless_modes.py @@ -22,8 +22,8 @@ ui.append(caption) print("Script started. Window should appear unless --headless was specified.") # Exit after 2 seconds -def exit_test(runtime): +def exit_test(timer, runtime): print("Test complete. Exiting.") sys.exit(0) -mcrfpy.setTimer("exit", exit_test, 2000) \ No newline at end of file +exit_timer = mcrfpy.Timer("exit", exit_test, 2000, once=True) \ No newline at end of file diff --git a/tests/unit/test_metrics.py b/tests/unit/test_metrics.py index 06b6f24..f6580c2 100644 --- a/tests/unit/test_metrics.py +++ b/tests/unit/test_metrics.py @@ -5,13 +5,17 @@ import mcrfpy import sys import time -def test_metrics(runtime): +# Track success across callbacks +success = True + +def test_metrics(timer, runtime): """Test the metrics after timer starts""" + global success print("\nRunning metrics test...") - + # Get metrics metrics = mcrfpy.getMetrics() - + print("\nPerformance Metrics:") print(f" Frame Time: {metrics['frame_time']:.2f} ms") print(f" Avg Frame Time: {metrics['avg_frame_time']:.2f} ms") @@ -21,17 +25,16 @@ def test_metrics(runtime): print(f" Visible Elements: {metrics['visible_elements']}") print(f" Current Frame: {metrics['current_frame']}") print(f" Runtime: {metrics['runtime']:.2f} seconds") - + # Test that metrics are reasonable - success = True - + # Frame time should be positive if metrics['frame_time'] <= 0: print(" FAIL: Frame time should be positive") success = False else: print(" PASS: Frame time is positive") - + # FPS should be reasonable (between 1 and 20000 in headless mode) # In headless mode, FPS can be very high since there's no vsync if metrics['fps'] < 1 or metrics['fps'] > 20000: @@ -39,72 +42,76 @@ def test_metrics(runtime): success = False else: print(f" PASS: FPS {metrics['fps']} is reasonable") - + # UI elements count (may be 0 if scene hasn't rendered yet) if metrics['ui_elements'] < 0: print(f" FAIL: UI elements count {metrics['ui_elements']} is negative") success = False else: print(f" PASS: UI element count {metrics['ui_elements']} is valid") - + # Visible elements should be <= total elements if metrics['visible_elements'] > metrics['ui_elements']: print(" FAIL: Visible elements > total elements") success = False else: print(" PASS: Visible element count is valid") - + # Current frame should be > 0 if metrics['current_frame'] <= 0: print(" FAIL: Current frame should be > 0") success = False else: print(" PASS: Current frame is positive") - + # Runtime should be > 0 if metrics['runtime'] <= 0: print(" FAIL: Runtime should be > 0") success = False else: print(" PASS: Runtime is positive") - + # Test metrics update over multiple frames print("\n\nTesting metrics over multiple frames...") - + + # Store initial metrics for comparison + initial_frame = metrics['current_frame'] + initial_runtime = metrics['runtime'] + # Schedule another check after 100ms - def check_later(runtime2): + def check_later(timer2, runtime2): + global success metrics2 = mcrfpy.getMetrics() - + print(f"\nMetrics after 100ms:") print(f" Frame Time: {metrics2['frame_time']:.2f} ms") print(f" Avg Frame Time: {metrics2['avg_frame_time']:.2f} ms") print(f" FPS: {metrics2['fps']}") print(f" Current Frame: {metrics2['current_frame']}") - + # Frame count should have increased - if metrics2['current_frame'] > metrics['current_frame']: + if metrics2['current_frame'] > initial_frame: print(" PASS: Frame count increased") else: print(" FAIL: Frame count did not increase") - nonlocal success success = False - + # Runtime should have increased - if metrics2['runtime'] > metrics['runtime']: + if metrics2['runtime'] > initial_runtime: print(" PASS: Runtime increased") else: print(" FAIL: Runtime did not increase") success = False - + print("\n" + "="*50) if success: print("ALL METRICS TESTS PASSED!") else: print("SOME METRICS TESTS FAILED!") - + sys.exit(0 if success else 1) - - mcrfpy.setTimer("check_later", check_later, 100) + + mcrfpy.Timer("check_later", check_later, 100, once=True) # Set up test scene print("Setting up metrics test scene...") @@ -136,4 +143,4 @@ ui.append(grid) print(f"Created {len(ui)} UI elements (1 invisible)") # Schedule test to run after render loop starts -mcrfpy.setTimer("test", test_metrics, 50) \ No newline at end of file +mcrfpy.Timer("test", test_metrics, 50, once=True) diff --git a/tests/unit/test_no_arg_constructors.py b/tests/unit/test_no_arg_constructors.py index 238d136..1c884d3 100644 --- a/tests/unit/test_no_arg_constructors.py +++ b/tests/unit/test_no_arg_constructors.py @@ -7,7 +7,7 @@ This verifies the fix for requiring arguments even with safe default constructor import mcrfpy import sys -def test_ui_constructors(runtime): +def test_ui_constructors(timer, runtime): """Test that UI classes can be instantiated without arguments""" print("Testing UI class instantiation without arguments...") @@ -88,4 +88,4 @@ def test_ui_constructors(runtime): test = mcrfpy.Scene("test") # Schedule the test to run after game initialization -mcrfpy.setTimer("test", test_ui_constructors, 100) \ No newline at end of file +test_timer = mcrfpy.Timer("test", test_ui_constructors, 100, once=True) \ No newline at end of file diff --git a/tests/unit/test_path_colors.py b/tests/unit/test_path_colors.py index 0a95932..8a246bd 100644 --- a/tests/unit/test_path_colors.py +++ b/tests/unit/test_path_colors.py @@ -63,7 +63,7 @@ for y in range(5): print("\nIf colors are changing in data but not visually, it may be a rendering issue.") # Quick visual test -def check_visual(runtime): +def check_visual(timer, runtime): print("\nTimer fired - checking if scene is rendering...") # Take screenshot to see actual rendering try: @@ -81,6 +81,6 @@ grid.position = (50, 50) grid.size = (250, 250) test.activate() -mcrfpy.setTimer("check", check_visual, 500) +check_timer = mcrfpy.Timer("check", check_visual, 500, once=True) print("\nStarting render test...") \ No newline at end of file diff --git a/tests/unit/test_pathfinding_integration.py b/tests/unit/test_pathfinding_integration.py index a5d3836..0fe1107 100644 --- a/tests/unit/test_pathfinding_integration.py +++ b/tests/unit/test_pathfinding_integration.py @@ -48,11 +48,11 @@ print("\nāœ“ Pathfinding integration working correctly!") print("Enhanced demos are ready for interactive use.") # Quick animation test -def test_timer(dt): - print(f"Timer callback received: dt={dt}ms") +def test_timer(timer, runtime): + print(f"Timer callback received: runtime={runtime}ms") sys.exit(0) # Set a quick timer to test animation system -mcrfpy.setTimer("test", test_timer, 100) +timer = mcrfpy.Timer("test", test_timer, 100, once=True) print("\nTesting timer system for animations...") \ No newline at end of file diff --git a/tests/unit/test_properties_quick.py b/tests/unit/test_properties_quick.py index 1a6bb69..0fd6ee3 100644 --- a/tests/unit/test_properties_quick.py +++ b/tests/unit/test_properties_quick.py @@ -3,8 +3,8 @@ import mcrfpy import sys -def test_properties(runtime): - mcrfpy.delTimer("test_properties") +def test_properties(timer, runtime): + timer.stop() print("\n=== Testing Properties ===") @@ -54,4 +54,4 @@ def test_properties(runtime): sys.exit(0) test = mcrfpy.Scene("test") -mcrfpy.setTimer("test_properties", test_properties, 100) \ No newline at end of file +test_properties_timer = mcrfpy.Timer("test_properties", test_properties, 100, once=True) \ No newline at end of file diff --git a/tests/unit/test_python_object_cache.py b/tests/unit/test_python_object_cache.py index 6d882f5..dbf83e3 100644 --- a/tests/unit/test_python_object_cache.py +++ b/tests/unit/test_python_object_cache.py @@ -21,7 +21,7 @@ def test(condition, message): test_results.append(f"āœ— {message}") test_passed = False -def run_tests(runtime): +def run_tests(timer, runtime): """Timer callback to run tests after game loop starts""" global test_passed @@ -146,6 +146,6 @@ test_scene = mcrfpy.Scene("test_scene") test_scene.activate() # Schedule tests to run after game loop starts -mcrfpy.setTimer("test", run_tests, 100) +test_timer = mcrfpy.Timer("test", run_tests, 100, once=True) print("Python object cache test initialized. Running tests...") diff --git a/tests/unit/test_scene_transitions.py b/tests/unit/test_scene_transitions.py index 03dae42..220a922 100644 --- a/tests/unit/test_scene_transitions.py +++ b/tests/unit/test_scene_transitions.py @@ -186,7 +186,7 @@ for s in (red_scene, blue_scene, green_scene, menu_scene): # Option to run automatic test if len(sys.argv) > 1 and sys.argv[1] == "--auto": - mcrfpy.setTimer("auto_test", test_automatic_transitions, 1000) + mcrfpy.Timer("auto_test", lambda t, r: test_automatic_transitions(r), 1000, once=True) else: print("\nManual test mode. Use keyboard controls shown on screen.") print("Run with --auto flag for automatic transition demo.") diff --git a/tests/unit/test_simple_callback.py b/tests/unit/test_simple_callback.py index 5e6aa47..7e7cd6a 100644 --- a/tests/unit/test_simple_callback.py +++ b/tests/unit/test_simple_callback.py @@ -7,8 +7,8 @@ def cb(a, t): print("CB") test = mcrfpy.Scene("test") -test.activate() +test.activate() e = mcrfpy.Entity((0, 0), texture=None, sprite_index=0) a = mcrfpy.Animation("x", 1.0, 0.1, "linear", callback=cb) a.start(e) -mcrfpy.setTimer("exit", lambda r: sys.exit(0), 200) \ No newline at end of file +mcrfpy.Timer("exit", lambda t, r: sys.exit(0), 200, once=True) diff --git a/tests/unit/test_simple_drawable.py b/tests/unit/test_simple_drawable.py index 5943b36..63d37c3 100644 --- a/tests/unit/test_simple_drawable.py +++ b/tests/unit/test_simple_drawable.py @@ -3,8 +3,8 @@ import mcrfpy import sys -def simple_test(runtime): - mcrfpy.delTimer("simple_test") +def simple_test(timer, runtime): + timer.stop() try: # Test basic functionality @@ -27,4 +27,4 @@ def simple_test(runtime): sys.exit(0) test = mcrfpy.Scene("test") -mcrfpy.setTimer("simple_test", simple_test, 100) \ No newline at end of file +simple_test_timer = mcrfpy.Timer("simple_test", simple_test, 100, once=True) \ No newline at end of file diff --git a/tests/unit/test_text_input.py b/tests/unit/test_text_input.py index 007bdc4..d4402c9 100644 --- a/tests/unit/test_text_input.py +++ b/tests/unit/test_text_input.py @@ -16,7 +16,7 @@ def create_demo(): # Create scene text_demo = mcrfpy.Scene("text_demo") scene = text_demo.children - + # Background bg = mcrfpy.Frame(pos=(0, 0), size=(800, 600)) bg.fill_color = mcrfpy.Color(40, 40, 40, 255) @@ -26,55 +26,55 @@ def create_demo(): title = mcrfpy.Caption(pos=(20, 20), text="Text Input Widget Demo") title.fill_color = mcrfpy.Color(255, 255, 255, 255) scene.append(title) - + # Focus manager focus_mgr = FocusManager() - + # Create inputs inputs = [] - + # Name input name_input = TextInput(50, 100, 300, label="Name:", placeholder="Enter your name") name_input._focus_manager = focus_mgr focus_mgr.register(name_input) name_input.add_to_scene(scene) inputs.append(name_input) - + # Email input email_input = TextInput(50, 160, 300, label="Email:", placeholder="user@example.com") email_input._focus_manager = focus_mgr focus_mgr.register(email_input) email_input.add_to_scene(scene) inputs.append(email_input) - + # Tags input tags_input = TextInput(50, 220, 400, label="Tags:", placeholder="comma, separated, tags") tags_input._focus_manager = focus_mgr focus_mgr.register(tags_input) tags_input.add_to_scene(scene) inputs.append(tags_input) - + # Comment input comment_input = TextInput(50, 280, 500, height=30, label="Comment:", placeholder="Add a comment...") comment_input._focus_manager = focus_mgr focus_mgr.register(comment_input) comment_input.add_to_scene(scene) inputs.append(comment_input) - + # Status display status = mcrfpy.Caption(pos=(50, 360), text="Ready for input...") status.fill_color = mcrfpy.Color(150, 255, 150, 255) scene.append(status) - + # Update handler def update_status(text=None): values = [inp.get_text() for inp in inputs] status.text = f"Data: {values[0]} | {values[1]} | {values[2]} | {values[3]}" - + # Set change handlers for inp in inputs: inp.on_change = update_status - + # Keyboard handler def handle_keys(scene_name, key): if not focus_mgr.handle_key(key): @@ -85,12 +85,12 @@ def create_demo(): for i, inp in enumerate(inputs): print(f" Field {i+1}: '{inp.get_text()}'") sys.exit(0) - + text_demo.on_key = "text_demo", handle_keys text_demo.activate() - + # Run demo test - def run_test(timer_name): + def run_test(timer, runtime): print("\n=== Text Input Widget Test ===") print("Features:") print("- Click to focus fields") @@ -102,9 +102,9 @@ def create_demo(): print("- Visual focus indication") print("- Press Escape to exit") print("\nTry it out!") - - mcrfpy.setTimer("info", run_test, 100) + + info_timer = mcrfpy.Timer("info", run_test, 100, once=True) if __name__ == "__main__": - create_demo() \ No newline at end of file + create_demo() diff --git a/tests/unit/test_timer_callback.py b/tests/unit/test_timer_callback.py index 24f71f6..6f46efe 100644 --- a/tests/unit/test_timer_callback.py +++ b/tests/unit/test_timer_callback.py @@ -1,34 +1,27 @@ #!/usr/bin/env python3 """ -Test timer callback arguments +Test timer callback arguments with new Timer API (#173) """ import mcrfpy import sys call_count = 0 -def old_style_callback(arg): - """Old style callback - should receive just runtime""" +def new_style_callback(timer, runtime): + """New style callback - receives timer object and runtime""" global call_count call_count += 1 - print(f"Old style callback called with: {arg} (type: {type(arg)})") + print(f"Callback called with: timer={timer} (type: {type(timer)}), runtime={runtime} (type: {type(runtime)})") + if hasattr(timer, 'once'): + print(f"Got Timer object! once={timer.once}") if call_count >= 2: + print("PASS") sys.exit(0) -def new_style_callback(arg1, arg2=None): - """New style callback - should receive timer object and runtime""" - print(f"New style callback called with: arg1={arg1} (type: {type(arg1)}), arg2={arg2} (type: {type(arg2) if arg2 else 'None'})") - if hasattr(arg1, 'once'): - print(f"Got Timer object! once={arg1.once}") - sys.exit(0) - # Set up the scene -test_scene = mcrfpy.Scene("test_scene") +test_scene = mcrfpy.Scene("test_scene") test_scene.activate() -print("Testing old style timer with setTimer...") -mcrfpy.setTimer("old_timer", old_style_callback, 100) - -print("\nTesting new style timer with Timer object...") -timer = mcrfpy.Timer("new_timer", new_style_callback, 200) -print(f"Timer created: {timer}") \ No newline at end of file +print("Testing new Timer callback signature (timer, runtime)...") +timer = mcrfpy.Timer("test_timer", new_style_callback, 100) +print(f"Timer created: {timer}") diff --git a/tests/unit/test_timer_legacy.py b/tests/unit/test_timer_legacy.py index f1a7863..388125d 100644 --- a/tests/unit/test_timer_legacy.py +++ b/tests/unit/test_timer_legacy.py @@ -1,26 +1,28 @@ #!/usr/bin/env python3 """ -Test legacy timer API still works +Test Timer API works correctly (#173) +Replaces old legacy setTimer test """ import mcrfpy import sys count = 0 -def timer_callback(runtime): +def timer_callback(timer, runtime): global count count += 1 print(f"Timer fired! Count: {count}, Runtime: {runtime}") - + if count >= 3: print("Test passed - timer fired 3 times") + print("PASS") sys.exit(0) # Set up the scene test_scene = mcrfpy.Scene("test_scene") test_scene.activate() -# Create a timer the old way -mcrfpy.setTimer("test_timer", timer_callback, 100) +# Create a timer with new API +timer = mcrfpy.Timer("test_timer", timer_callback, 100) -print("Legacy timer test starting...") \ No newline at end of file +print("Timer test starting...") diff --git a/tests/unit/test_timer_object.py b/tests/unit/test_timer_object.py index a9ab7b0..4e374b1 100644 --- a/tests/unit/test_timer_object.py +++ b/tests/unit/test_timer_object.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """ -Test the new mcrfpy.Timer object with pause/resume/cancel functionality +Test the new mcrfpy.Timer object with pause/resume/stop functionality +Updated for new Timer API (#173) """ import mcrfpy import sys @@ -25,13 +26,13 @@ def cancel_test_callback(timer, runtime): cancel_test_count += 1 print(f"Cancel test timer: {cancel_test_count} - This should only print once!") -def run_tests(runtime): +def run_tests(timer, runtime): """Main test function that runs after game loop starts""" - # Delete the timer that called us to prevent re-running - mcrfpy.delTimer("run_tests") - + # Stop the timer that called us to prevent re-running + timer.stop() + print("\n=== Testing mcrfpy.Timer object ===\n") - + # Test 1: Create a basic timer print("Test 1: Creating Timer object") timer1 = mcrfpy.Timer("test_timer", timer_callback, 500) @@ -39,103 +40,102 @@ def run_tests(runtime): print(f" Interval: {timer1.interval}ms") print(f" Active: {timer1.active}") print(f" Paused: {timer1.paused}") - + # Test 2: Test pause/resume print("\nTest 2: Testing pause/resume functionality") timer2 = mcrfpy.Timer("pause_test", pause_test_callback, 200) - + # Schedule pause after 250ms - def pause_timer2(runtime): + def pause_timer2(t, rt): print(" Pausing timer2...") timer2.pause() print(f" Timer2 paused: {timer2.paused}") print(f" Timer2 active: {timer2.active}") # Schedule resume after another 400ms - def resume_timer2(runtime): + def resume_timer2(t2, rt2): print(" Resuming timer2...") timer2.resume() print(f" Timer2 paused: {timer2.paused}") print(f" Timer2 active: {timer2.active}") - mcrfpy.setTimer("resume_timer2", resume_timer2, 400) - - mcrfpy.setTimer("pause_timer2", pause_timer2, 250) - - # Test 3: Test cancel - print("\nTest 3: Testing cancel functionality") - timer3 = mcrfpy.Timer("cancel_test", cancel_test_callback, 300) - - # Cancel after 350ms (should fire once) - def cancel_timer3(runtime): - mcrfpy.delTimer("cancel_timer3") # Make this a one-shot timer - print(" Canceling timer3...") - timer3.cancel() - print(" Timer3 canceled") + mcrfpy.Timer("resume_timer2", resume_timer2, 400, once=True) + + mcrfpy.Timer("pause_timer2", pause_timer2, 250, once=True) + + # Test 3: Test cancel/stop + print("\nTest 3: Testing stop functionality") + timer3 = mcrfpy.Timer("cancel_test", cancel_test_callback, 300) + + # Cancel after 350ms (should fire once) + def cancel_timer3(t, rt): + print(" Stopping timer3...") + timer3.stop() + print(" Timer3 stopped") + + mcrfpy.Timer("cancel_timer3", cancel_timer3, 350, once=True) - mcrfpy.setTimer("cancel_timer3", cancel_timer3, 350) - # Test 4: Test interval modification print("\nTest 4: Testing interval modification") def interval_test(timer, runtime): print(f" Interval test fired at {runtime}ms") - + timer4 = mcrfpy.Timer("interval_test", interval_test, 1000) print(f" Original interval: {timer4.interval}ms") timer4.interval = 500 print(f" Modified interval: {timer4.interval}ms") - + # Test 5: Test remaining time print("\nTest 5: Testing remaining time") - def check_remaining(runtime): + def check_remaining(t, rt): if timer1.active: print(f" Timer1 remaining: {timer1.remaining}ms") if timer2.active or timer2.paused: print(f" Timer2 remaining: {timer2.remaining}ms (paused: {timer2.paused})") - - mcrfpy.setTimer("check_remaining", check_remaining, 150) - + + remaining_timer = mcrfpy.Timer("check_remaining", check_remaining, 150) + # Test 6: Test restart print("\nTest 6: Testing restart functionality") restart_count = [0] - + def restart_test(timer, runtime): restart_count[0] += 1 print(f" Restart test: {restart_count[0]}") if restart_count[0] == 2: print(" Restarting timer...") timer.restart() - + timer5 = mcrfpy.Timer("restart_test", restart_test, 400) - + # Final verification after 2 seconds - def final_check(runtime): + def final_check(t, rt): print("\n=== Final Results ===") print(f"Timer1 call count: {call_count} (expected: ~4)") print(f"Pause test count: {pause_test_count} (expected: ~6-7, with pause gap)") print(f"Cancel test count: {cancel_test_count} (expected: 1)") print(f"Restart test count: {restart_count[0]} (expected: ~5 with restart)") - + # Verify timer states try: print(f"\nTimer1 active: {timer1.active}") print(f"Timer2 active: {timer2.active}") - print(f"Timer3 active: {timer3.active} (should be False after cancel)") + print(f"Timer3 active: {timer3.active} (should be False after stop)") print(f"Timer4 active: {timer4.active}") print(f"Timer5 active: {timer5.active}") except: print("Some timers may have been garbage collected") - + print("\nāœ“ All Timer object tests completed!") sys.exit(0) - - mcrfpy.setTimer("final_check", final_check, 2000) + + mcrfpy.Timer("final_check", final_check, 2000, once=True) # Create a minimal scene timer_test = mcrfpy.Scene("timer_test") timer_test.activate() # Start tests after game loop begins -mcrfpy.setTimer("run_tests", run_tests, 100) +mcrfpy.Timer("run_tests", run_tests, 100, once=True) -print("Timer object tests starting...") \ No newline at end of file +print("Timer object tests starting...") diff --git a/tests/unit/test_uiarc.py b/tests/unit/test_uiarc.py index a308202..6c2cfd8 100644 --- a/tests/unit/test_uiarc.py +++ b/tests/unit/test_uiarc.py @@ -4,18 +4,18 @@ import mcrfpy from mcrfpy import automation import sys -def take_screenshot(runtime): +def take_screenshot(timer, runtime): """Take screenshot after render completes""" - mcrfpy.delTimer("screenshot") + timer.stop() automation.screenshot("test_uiarc_result.png") print("Screenshot saved to test_uiarc_result.png") print("PASS - UIArc test completed") sys.exit(0) -def run_test(runtime): +def run_test(timer, runtime): """Main test - runs after scene is set up""" - mcrfpy.delTimer("test") + timer.stop() # Get the scene UI ui = test.children @@ -127,11 +127,12 @@ def run_test(runtime): print(f" Arc 10 (reverse): {a10}") # Schedule screenshot for next frame - mcrfpy.setTimer("screenshot", take_screenshot, 50) + global screenshot_timer + screenshot_timer = mcrfpy.Timer("screenshot", take_screenshot, 50, once=True) # Create a test scene test = mcrfpy.Scene("test") test.activate() # Schedule test to run after game loop starts -mcrfpy.setTimer("test", run_test, 50) +test_timer = mcrfpy.Timer("test", run_test, 50, once=True) diff --git a/tests/unit/test_uicaption_visual.py b/tests/unit/test_uicaption_visual.py index 6f0946c..3cfb4a4 100644 --- a/tests/unit/test_uicaption_visual.py +++ b/tests/unit/test_uicaption_visual.py @@ -6,7 +6,7 @@ from mcrfpy import automation import sys import time -def run_visual_test(runtime): +def run_visual_test(timer, runtime): """Timer callback to run visual tests and take screenshots.""" print("\nRunning visual tests...") @@ -89,9 +89,9 @@ def main(): ui.append(frame) print("Scene setup complete. Scheduling visual tests...") - + # Schedule visual test to run after render loop starts - mcrfpy.setTimer("visual_test", run_visual_test, 100) + visual_test_timer = mcrfpy.Timer("visual_test", run_visual_test, 100, once=True) if __name__ == "__main__": main() \ No newline at end of file diff --git a/tests/unit/test_uicircle.py b/tests/unit/test_uicircle.py index 2116fe3..835f6a0 100644 --- a/tests/unit/test_uicircle.py +++ b/tests/unit/test_uicircle.py @@ -4,18 +4,18 @@ import mcrfpy from mcrfpy import automation import sys -def take_screenshot(runtime): +def take_screenshot(timer, runtime): """Take screenshot after render completes""" - mcrfpy.delTimer("screenshot") + timer.stop() automation.screenshot("test_uicircle_result.png") print("Screenshot saved to test_uicircle_result.png") print("PASS - UICircle test completed") sys.exit(0) -def run_test(runtime): +def run_test(timer, runtime): """Main test - runs after scene is set up""" - mcrfpy.delTimer("test") + timer.stop() # Get the scene UI ui = test.children @@ -118,11 +118,12 @@ def run_test(runtime): print(f" c1 moved from {old_center} to {new_center}") # Schedule screenshot for next frame - mcrfpy.setTimer("screenshot", take_screenshot, 50) + global screenshot_timer + screenshot_timer = mcrfpy.Timer("screenshot", take_screenshot, 50, once=True) # Create a test scene test = mcrfpy.Scene("test") test.activate() # Schedule test to run after game loop starts -mcrfpy.setTimer("test", run_test, 50) +test_timer = mcrfpy.Timer("test", run_test, 50, once=True) diff --git a/tests/unit/test_utf8_encoding.py b/tests/unit/test_utf8_encoding.py index 4980a42..dee8fd1 100644 --- a/tests/unit/test_utf8_encoding.py +++ b/tests/unit/test_utf8_encoding.py @@ -6,7 +6,7 @@ Test UTF-8 encoding support import mcrfpy import sys -def test_utf8(runtime): +def test_utf8(timer, runtime): """Test UTF-8 encoding in print statements""" # Test various unicode characters @@ -32,4 +32,4 @@ def test_utf8(runtime): # Run test test = mcrfpy.Scene("test") -mcrfpy.setTimer("test", test_utf8, 100) \ No newline at end of file +test_timer = mcrfpy.Timer("test", test_utf8, 100, once=True) \ No newline at end of file diff --git a/tests/unit/test_vector_arithmetic.py b/tests/unit/test_vector_arithmetic.py index 5a8390b..8534a18 100644 --- a/tests/unit/test_vector_arithmetic.py +++ b/tests/unit/test_vector_arithmetic.py @@ -7,7 +7,7 @@ import mcrfpy import sys import math -def test_vector_arithmetic(runtime): +def test_vector_arithmetic(timer, runtime): """Test vector arithmetic operations""" all_pass = True @@ -244,4 +244,4 @@ def test_vector_arithmetic(runtime): # Run test test = mcrfpy.Scene("test") -mcrfpy.setTimer("test", test_vector_arithmetic, 100) \ No newline at end of file +test_timer = mcrfpy.Timer("test", test_vector_arithmetic, 100, once=True) \ No newline at end of file diff --git a/tests/unit/test_viewport_scaling.py b/tests/unit/test_viewport_scaling.py index 0576d3c..5d077f4 100644 --- a/tests/unit/test_viewport_scaling.py +++ b/tests/unit/test_viewport_scaling.py @@ -5,9 +5,9 @@ import mcrfpy from mcrfpy import Window, Frame, Caption, Color, Vector import sys -def test_viewport_modes(runtime): +def test_viewport_modes(timer, runtime): """Test all three viewport scaling modes""" - mcrfpy.delTimer("test_viewport") + timer.stop() print("Testing viewport scaling modes...") @@ -82,47 +82,47 @@ def test_viewport_modes(runtime): scene.append(instructions) # Test changing modes - def test_mode_changes(runtime): - mcrfpy.delTimer("test_modes") + def test_mode_changes(t, r): + t.stop() from mcrfpy import automation - + print("\nTesting scaling modes:") - + # Test center mode window.scaling_mode = "center" print(f"Set to center mode: {window.scaling_mode}") mode_text.text = f"Mode: center (1:1 pixels)" automation.screenshot("viewport_center_mode.png") - + # Schedule next mode test - mcrfpy.setTimer("test_stretch", test_stretch_mode, 1000) - - def test_stretch_mode(runtime): - mcrfpy.delTimer("test_stretch") + mcrfpy.Timer("test_stretch", test_stretch_mode, 1000, once=True) + + def test_stretch_mode(t, r): + t.stop() from mcrfpy import automation - + window.scaling_mode = "stretch" print(f"Set to stretch mode: {window.scaling_mode}") mode_text.text = f"Mode: stretch (fill window)" automation.screenshot("viewport_stretch_mode.png") - + # Schedule next mode test - mcrfpy.setTimer("test_fit", test_fit_mode, 1000) - - def test_fit_mode(runtime): - mcrfpy.delTimer("test_fit") + mcrfpy.Timer("test_fit", test_fit_mode, 1000, once=True) + + def test_fit_mode(t, r): + t.stop() from mcrfpy import automation - + window.scaling_mode = "fit" print(f"Set to fit mode: {window.scaling_mode}") mode_text.text = f"Mode: fit (aspect ratio maintained)" automation.screenshot("viewport_fit_mode.png") - + # Test different window sizes - mcrfpy.setTimer("test_resize", test_window_resize, 1000) - - def test_window_resize(runtime): - mcrfpy.delTimer("test_resize") + mcrfpy.Timer("test_resize", test_window_resize, 1000, once=True) + + def test_window_resize(t, r): + t.stop() from mcrfpy import automation print("\nTesting window resize with fit mode:") @@ -133,13 +133,13 @@ def test_viewport_modes(runtime): print(f"Window resized to: {window.resolution}") automation.screenshot("viewport_fit_wide.png") # Make window taller - mcrfpy.setTimer("test_tall", test_tall_window, 1000) + mcrfpy.Timer("test_tall", test_tall_window, 1000, once=True) except RuntimeError as e: print(f" Skipping window resize tests (headless mode): {e}") - mcrfpy.setTimer("test_game_res", test_game_resolution, 100) - - def test_tall_window(runtime): - mcrfpy.delTimer("test_tall") + mcrfpy.Timer("test_game_res", test_game_resolution, 100, once=True) + + def test_tall_window(t, r): + t.stop() from mcrfpy import automation try: @@ -150,10 +150,10 @@ def test_viewport_modes(runtime): print(f" Skipping tall window test (headless mode): {e}") # Test game resolution change - mcrfpy.setTimer("test_game_res", test_game_resolution, 1000) - - def test_game_resolution(runtime): - mcrfpy.delTimer("test_game_res") + mcrfpy.Timer("test_game_res", test_game_resolution, 1000, once=True) + + def test_game_resolution(t, r): + t.stop() print("\nTesting game resolution change:") window.game_resolution = (800, 600) @@ -178,9 +178,9 @@ def test_viewport_modes(runtime): window.scaling_mode = "fit" sys.exit(0) - + # Start test sequence - mcrfpy.setTimer("test_modes", test_mode_changes, 500) + mcrfpy.Timer("test_modes", test_mode_changes, 500, once=True) # Set up keyboard handler for manual testing def handle_keypress(key, state): @@ -240,7 +240,7 @@ test.activate() test.on_key = handle_keypress # Schedule the test -mcrfpy.setTimer("test_viewport", test_viewport_modes, 100) +test_viewport_timer = mcrfpy.Timer("test_viewport", test_viewport_modes, 100, once=True) print("Viewport test running...") print("Use number keys to switch modes, R to resize window, G to change game resolution") \ No newline at end of file diff --git a/tests/unit/test_visibility.py b/tests/unit/test_visibility.py index c735558..b8d858d 100644 --- a/tests/unit/test_visibility.py +++ b/tests/unit/test_visibility.py @@ -75,7 +75,7 @@ print(f" Expected: {20 * 15}") print("\nTest 2: Updating visibility for each entity") for i, entity in enumerate(entities): entity.update_visibility() - + # Count visible/discovered cells visible_count = sum(1 for state in entity.gridstate if state.visible) discovered_count = sum(1 for state in entity.gridstate if state.discovered) @@ -95,9 +95,9 @@ except IndexError as e: print(f" āœ“ Correctly rejected invalid perspective: {e}") # Test 4: Visual demonstration -def visual_test(runtime): +def visual_test(timer, runtime): print(f"\nVisual test - cycling perspectives at {runtime}ms") - + # Cycle through perspectives current = grid.perspective if current == -1: @@ -112,7 +112,7 @@ def visual_test(runtime): else: grid.perspective = -1 print(" Switched to omniscient view") - + # Take screenshot from mcrfpy import automation filename = f"visibility_perspective_{grid.perspective}.png" @@ -159,14 +159,14 @@ ui.append(legend) visibility_test.activate() # Set timer to cycle perspectives -mcrfpy.setTimer("cycle", visual_test, 2000) # Every 2 seconds +cycle_timer = mcrfpy.Timer("cycle", visual_test, 2000) # Every 2 seconds print("\nTest complete! Visual demo cycling through perspectives...") print("Perspectives will cycle: Omniscient → Entity 0 → Entity 1 → Entity 2 → Omniscient") # Quick test to exit after screenshots -def exit_timer(dt): +def exit_timer_cb(timer, runtime): print("\nExiting after demo...") sys.exit(0) -mcrfpy.setTimer("exit", exit_timer, 10000) # Exit after 10 seconds \ No newline at end of file +exit_timer_obj = mcrfpy.Timer("exit", exit_timer_cb, 10000, once=True) # Exit after 10 seconds diff --git a/tests/unit/test_visual_path.py b/tests/unit/test_visual_path.py index 7c8c133..27d89c5 100644 --- a/tests/unit/test_visual_path.py +++ b/tests/unit/test_visual_path.py @@ -19,9 +19,9 @@ grid.fill_color = mcrfpy.Color(0, 0, 0) # Add color layer for cell coloring color_layer = grid.add_layer("color", z_index=-1) -def check_render(dt): +def check_render(timer, runtime): """Timer callback to verify rendering""" - print(f"\nTimer fired after {dt}ms") + print(f"\nTimer fired after {runtime}ms") # Take screenshot from mcrfpy import automation @@ -78,6 +78,6 @@ ui.append(title) visual_test.activate() # Set timer to check rendering -mcrfpy.setTimer("check", check_render, 500) +check_timer = mcrfpy.Timer("check", check_render, 500, once=True) print("\nScene ready. Path should be visible in green.") \ No newline at end of file diff --git a/tests/unit/ui_Grid_none_texture_test.py b/tests/unit/ui_Grid_none_texture_test.py index 668ac7e..e416eb2 100644 --- a/tests/unit/ui_Grid_none_texture_test.py +++ b/tests/unit/ui_Grid_none_texture_test.py @@ -4,7 +4,7 @@ import mcrfpy from mcrfpy import automation import sys -def test_grid_none_texture(runtime): +def test_grid_none_texture(timer, runtime): """Test Grid functionality without texture""" print("\n=== Testing Grid with None texture ===") @@ -95,5 +95,5 @@ background = mcrfpy.Frame(pos=(0, 0), size=(800, 600), ui.append(background) # Schedule test -mcrfpy.setTimer("test", test_grid_none_texture, 100) +test_timer = mcrfpy.Timer("test", test_grid_none_texture, 100, once=True) print("Test scheduled...") \ No newline at end of file From 357c2ac7d763b715ddf87a9a2d73d9ad49434f8b Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sun, 4 Jan 2026 00:45:16 -0500 Subject: [PATCH 3/4] Animation fixes: 0-duration edge case, integer value bug resolution --- src/Animation.cpp | 86 +++++++++++++++++++------ src/PyAnimation.cpp | 42 ++++++++++++ src/PyAnimation.h | 54 +++++++++++++++- src/PyColor.h | 29 ++++++++- src/UICaption.cpp | 8 ++- src/UIFrame.cpp | 8 ++- src/UIGrid.cpp | 4 +- stubs/mcrfpy.pyi | 153 +++++++++++++++++++++++++++++++++++++------- 8 files changed, 333 insertions(+), 51 deletions(-) diff --git a/src/Animation.cpp b/src/Animation.cpp index 93dc394..adef173 100644 --- a/src/Animation.cpp +++ b/src/Animation.cpp @@ -57,15 +57,15 @@ Animation::~Animation() { void Animation::start(std::shared_ptr target) { if (!target) return; - + targetWeak = target; elapsed = 0.0f; callbackTriggered = false; // Reset callback state - + // Capture start value from target std::visit([this, &target](const auto& targetVal) { using T = std::decay_t; - + if constexpr (std::is_same_v) { float value; if (target->getProperty(targetProperty, value)) { @@ -73,9 +73,15 @@ void Animation::start(std::shared_ptr target) { } } else if constexpr (std::is_same_v) { - int value; - if (target->getProperty(targetProperty, value)) { - startValue = value; + // Most UI properties use float, so try float first, then int + float fvalue; + if (target->getProperty(targetProperty, fvalue)) { + startValue = static_cast(fvalue); + } else { + int ivalue; + if (target->getProperty(targetProperty, ivalue)) { + startValue = ivalue; + } } } else if constexpr (std::is_same_v>) { @@ -104,19 +110,29 @@ void Animation::start(std::shared_ptr target) { } } }, 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; + } } void Animation::startEntity(std::shared_ptr target) { if (!target) return; - + entityTargetWeak = target; elapsed = 0.0f; callbackTriggered = false; // Reset callback state - + // 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)) { @@ -131,6 +147,16 @@ void Animation::startEntity(std::shared_ptr target) { } // 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); + applyValue(target.get(), finalValue); + if (pythonCallback && !callbackTriggered) { + triggerCallback(); + } + callbackTriggered = true; + } } bool Animation::hasValidTarget() const { @@ -169,39 +195,55 @@ 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(); - + // If both are null, target was destroyed if (!target && !entity) { return false; // Remove this animation } - + + // Handle already-complete animations (e.g., duration=0) + // Apply final value once before returning if (isComplete()) { + if (!callbackTriggered) { + // Apply final value for zero-duration animations + AnimationValue finalValue = interpolate(1.0f); + if (target) { + applyValue(target.get(), finalValue); + } else if (entity) { + applyValue(entity.get(), finalValue); + } + // Trigger callback + if (pythonCallback) { + triggerCallback(); + } + callbackTriggered = true; + } return false; } - + elapsed += deltaTime; elapsed = std::min(elapsed, duration); - + // Calculate easing value (0.0 to 1.0) float t = duration > 0 ? elapsed / duration : 1.0f; float easedT = easingFunc(t); - + // Get interpolated value AnimationValue currentValue = interpolate(easedT); - + // Apply to whichever target is valid if (target) { applyValue(target.get(), currentValue); } else if (entity) { applyValue(entity.get(), currentValue); } - + // Trigger callback when animation completes // Check pythonCallback again in case it was cleared during update if (isComplete() && !callbackTriggered && pythonCallback) { triggerCallback(); } - + return !isComplete(); } @@ -310,15 +352,19 @@ AnimationValue Animation::interpolate(float t) const { void Animation::applyValue(UIDrawable* target, const AnimationValue& value) { if (!target) return; - + std::visit([this, target](const auto& val) { using T = std::decay_t; - + if constexpr (std::is_same_v) { target->setProperty(targetProperty, val); } else if constexpr (std::is_same_v) { - target->setProperty(targetProperty, val); + // Most UI properties use float setProperty, so try float first + if (!target->setProperty(targetProperty, static_cast(val))) { + // Fall back to int if float didn't work + target->setProperty(targetProperty, val); + } } else if constexpr (std::is_same_v) { target->setProperty(targetProperty, val); diff --git a/src/PyAnimation.cpp b/src/PyAnimation.cpp index 952aefc..9044b9f 100644 --- a/src/PyAnimation.cpp +++ b/src/PyAnimation.cpp @@ -113,6 +113,48 @@ void PyAnimation::dealloc(PyAnimationObject* self) { Py_TYPE(self)->tp_free((PyObject*)self); } +PyObject* PyAnimation::repr(PyAnimationObject* self) { + if (!self->data) { + return PyUnicode_FromString(""); + } + + std::string property = self->data->getTargetProperty(); + float duration = self->data->getDuration(); + float elapsed = self->data->getElapsed(); + bool complete = self->data->isComplete(); + bool delta = self->data->isDelta(); + bool hasTarget = self->data->hasValidTarget(); + + // Format: + // or: + // or: + // or: + + std::string status; + if (!hasTarget) { + status = "(no target)"; + } else if (complete) { + status = "complete"; + } else { + char buf[32]; + snprintf(buf, sizeof(buf), "elapsed=%.2fs", elapsed); + status = buf; + } + + char result[256]; + if (delta) { + snprintf(result, sizeof(result), + "", + property.c_str(), duration, status.c_str()); + } else { + snprintf(result, sizeof(result), + "", + property.c_str(), duration, status.c_str()); + } + + return PyUnicode_FromString(result); +} + PyObject* PyAnimation::get_property(PyAnimationObject* self, void* closure) { return PyUnicode_FromString(self->data->getTargetProperty().c_str()); } diff --git a/src/PyAnimation.h b/src/PyAnimation.h index 964844e..ec463f9 100644 --- a/src/PyAnimation.h +++ b/src/PyAnimation.h @@ -16,6 +16,7 @@ public: static PyObject* create(PyTypeObject* type, PyObject* args, PyObject* kwds); static int init(PyAnimationObject* self, PyObject* args, PyObject* kwds); static void dealloc(PyAnimationObject* self); + static PyObject* repr(PyAnimationObject* self); // Properties static PyObject* get_property(PyAnimationObject* self, void* closure); @@ -42,8 +43,59 @@ namespace mcrfpydef { .tp_basicsize = sizeof(PyAnimationObject), .tp_itemsize = 0, .tp_dealloc = (destructor)PyAnimation::dealloc, + .tp_repr = (reprfunc)PyAnimation::repr, .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR("Animation object for animating UI properties"), + .tp_doc = PyDoc_STR( + "Animation(property: str, target: Any, duration: float, easing: str = 'linear', delta: bool = False, callback: Callable = None)\n" + "\n" + "Create an animation that interpolates a property value over time.\n" + "\n" + "Args:\n" + " property: Property name to animate. Valid properties depend on target type:\n" + " - Position/Size: 'x', 'y', 'w', 'h', 'pos', 'size'\n" + " - Appearance: 'fill_color', 'outline_color', 'outline', 'opacity'\n" + " - Sprite: 'sprite_index', 'sprite_number', 'scale'\n" + " - Grid: 'center', 'zoom'\n" + " - Caption: 'text'\n" + " - Sub-properties: 'fill_color.r', 'fill_color.g', 'fill_color.b', 'fill_color.a'\n" + " target: Target value for the animation. Type depends on property:\n" + " - float: For numeric properties (x, y, w, h, scale, opacity, zoom)\n" + " - int: For integer properties (sprite_index)\n" + " - tuple (r, g, b[, a]): For color properties\n" + " - tuple (x, y): For vector properties (pos, size, center)\n" + " - list[int]: For sprite animation sequences\n" + " - str: For text animation\n" + " duration: Animation duration in seconds.\n" + " easing: Easing function name. Options:\n" + " - 'linear' (default)\n" + " - 'easeIn', 'easeOut', 'easeInOut'\n" + " - 'easeInQuad', 'easeOutQuad', 'easeInOutQuad'\n" + " - 'easeInCubic', 'easeOutCubic', 'easeInOutCubic'\n" + " - 'easeInQuart', 'easeOutQuart', 'easeInOutQuart'\n" + " - 'easeInSine', 'easeOutSine', 'easeInOutSine'\n" + " - 'easeInExpo', 'easeOutExpo', 'easeInOutExpo'\n" + " - 'easeInCirc', 'easeOutCirc', 'easeInOutCirc'\n" + " - 'easeInElastic', 'easeOutElastic', 'easeInOutElastic'\n" + " - '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" + "\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" + ), .tp_methods = PyAnimation::methods, .tp_getset = PyAnimation::getsetters, .tp_init = (initproc)PyAnimation::init, diff --git a/src/PyColor.h b/src/PyColor.h index c5cb2fb..0ade897 100644 --- a/src/PyColor.h +++ b/src/PyColor.h @@ -47,7 +47,34 @@ namespace mcrfpydef { .tp_repr = PyColor::repr, .tp_hash = PyColor::hash, .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR("SFML Color Object"), + .tp_doc = PyDoc_STR( + "Color(r: int = 0, g: int = 0, b: int = 0, a: int = 255)\n" + "\n" + "RGBA color representation.\n" + "\n" + "Args:\n" + " r: Red component (0-255)\n" + " g: Green component (0-255)\n" + " b: Blue component (0-255)\n" + " a: Alpha component (0-255, default 255 = opaque)\n" + "\n" + "Note:\n" + " When accessing colors from UI elements (e.g., frame.fill_color),\n" + " you receive a COPY of the color. Modifying it doesn't affect the\n" + " original. To change a component:\n" + "\n" + " # This does NOT work:\n" + " frame.fill_color.r = 255 # Modifies a temporary copy\n" + "\n" + " # Do this instead:\n" + " c = frame.fill_color\n" + " c.r = 255\n" + " frame.fill_color = c\n" + "\n" + " # Or use Animation for sub-properties:\n" + " anim = mcrfpy.Animation('fill_color.r', 255, 0.5, 'linear')\n" + " anim.start(frame)\n" + ), .tp_methods = PyColor::methods, .tp_getset = PyColor::getsetters, .tp_init = (initproc)PyColor::init, diff --git a/src/UICaption.cpp b/src/UICaption.cpp index 2434916..2a76175 100644 --- a/src/UICaption.cpp +++ b/src/UICaption.cpp @@ -268,8 +268,12 @@ PyGetSetDef UICaption::getsetters[] = { //{"w", (getter)PyUIFrame_get_float_member, (setter)PyUIFrame_set_float_member, "width of the rectangle", (void*)2}, //{"h", (getter)PyUIFrame_get_float_member, (setter)PyUIFrame_set_float_member, "height of the rectangle", (void*)3}, {"outline", (getter)UICaption::get_float_member, (setter)UICaption::set_float_member, "Thickness of the border", (void*)4}, - {"fill_color", (getter)UICaption::get_color_member, (setter)UICaption::set_color_member, "Fill color of the text", (void*)0}, - {"outline_color", (getter)UICaption::get_color_member, (setter)UICaption::set_color_member, "Outline color of the text", (void*)1}, + {"fill_color", (getter)UICaption::get_color_member, (setter)UICaption::set_color_member, + "Fill color of the text. Returns a copy; modifying components requires reassignment. " + "For animation, use 'fill_color.r', 'fill_color.g', etc.", (void*)0}, + {"outline_color", (getter)UICaption::get_color_member, (setter)UICaption::set_color_member, + "Outline color of the text. Returns a copy; modifying components requires reassignment. " + "For animation, use 'outline_color.r', 'outline_color.g', etc.", (void*)1}, //{"children", (getter)PyUIFrame_get_children, NULL, "UICollection of objects on top of this one", NULL}, {"text", (getter)UICaption::get_text, (setter)UICaption::set_text, "The text displayed", NULL}, {"font_size", (getter)UICaption::get_float_member, (setter)UICaption::set_float_member, "Font size (integer) in points", (void*)5}, diff --git a/src/UIFrame.cpp b/src/UIFrame.cpp index 4a74123..b667c78 100644 --- a/src/UIFrame.cpp +++ b/src/UIFrame.cpp @@ -434,8 +434,12 @@ PyGetSetDef UIFrame::getsetters[] = { {"w", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member, "width of the rectangle", (void*)((intptr_t)PyObjectsEnum::UIFRAME << 8 | 2)}, {"h", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member, "height of the rectangle", (void*)((intptr_t)PyObjectsEnum::UIFRAME << 8 | 3)}, {"outline", (getter)UIFrame::get_float_member, (setter)UIFrame::set_float_member, "Thickness of the border", (void*)4}, - {"fill_color", (getter)UIFrame::get_color_member, (setter)UIFrame::set_color_member, "Fill color of the rectangle", (void*)0}, - {"outline_color", (getter)UIFrame::get_color_member, (setter)UIFrame::set_color_member, "Outline color of the rectangle", (void*)1}, + {"fill_color", (getter)UIFrame::get_color_member, (setter)UIFrame::set_color_member, + "Fill color of the rectangle. Returns a copy; modifying components requires reassignment. " + "For animation, use 'fill_color.r', 'fill_color.g', etc.", (void*)0}, + {"outline_color", (getter)UIFrame::get_color_member, (setter)UIFrame::set_color_member, + "Outline color of the rectangle. Returns a copy; modifying components requires reassignment. " + "For animation, use 'outline_color.r', 'outline_color.g', etc.", (void*)1}, {"children", (getter)UIFrame::get_children, NULL, "UICollection of objects on top of this one", NULL}, {"on_click", (getter)UIDrawable::get_click, (setter)UIDrawable::set_click, MCRF_PROPERTY(on_click, diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index 7b59b12..c3de431 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -2059,7 +2059,9 @@ PyGetSetDef UIGrid::getsetters[] = { ), (void*)PyObjectsEnum::UIGRID}, {"texture", (getter)UIGrid::get_texture, NULL, "Texture of the grid", NULL}, //TODO 7DRL-day2-item5 - {"fill_color", (getter)UIGrid::get_fill_color, (setter)UIGrid::set_fill_color, "Background fill color of the grid", NULL}, + {"fill_color", (getter)UIGrid::get_fill_color, (setter)UIGrid::set_fill_color, + "Background fill color of the grid. Returns a copy; modifying components requires reassignment. " + "For animation, use 'fill_color.r', 'fill_color.g', etc.", NULL}, {"perspective", (getter)UIGrid::get_perspective, (setter)UIGrid::set_perspective, "Entity whose perspective to use for FOV rendering (None for omniscient view). " "Setting an entity automatically enables perspective mode.", NULL}, diff --git a/stubs/mcrfpy.pyi b/stubs/mcrfpy.pyi index 55c9886..e685e48 100644 --- a/stubs/mcrfpy.pyi +++ b/stubs/mcrfpy.pyi @@ -12,26 +12,44 @@ Transition = Union[str, None] # Classes class Color: - """SFML Color Object for RGBA colors.""" - + """RGBA color representation. + + Note: + When accessing colors from UI elements (e.g., frame.fill_color), + you receive a COPY of the color. Modifying it doesn't affect the + original. To change a component: + + # This does NOT work: + frame.fill_color.r = 255 # Modifies a temporary copy + + # Do this instead: + c = frame.fill_color + c.r = 255 + frame.fill_color = c + + # Or use Animation for sub-properties: + anim = mcrfpy.Animation('fill_color.r', 255, 0.5, 'linear') + anim.start(frame) + """ + r: int g: int b: int a: int - + @overload def __init__(self) -> None: ... @overload def __init__(self, r: int, g: int, b: int, a: int = 255) -> None: ... - + def from_hex(self, hex_string: str) -> 'Color': """Create color from hex string (e.g., '#FF0000' or 'FF0000').""" ... - + def to_hex(self) -> str: """Convert color to hex string format.""" ... - + def lerp(self, other: 'Color', t: float) -> 'Color': """Linear interpolation between two colors.""" ... @@ -534,31 +552,118 @@ class Window: ... class Animation: - """Animation object for animating UI properties.""" - - target: Any - property: str - duration: float - easing: str - loop: bool - on_complete: Optional[Callable] - - def __init__(self, target: Any, property: str, start_value: Any, end_value: Any, - duration: float, easing: str = 'linear', loop: bool = False, - on_complete: Optional[Callable] = None) -> None: ... - - def start(self) -> None: - """Start the animation.""" + """Animation for interpolating UI properties over time. + + Create an animation targeting a specific property, then call start() on a + UI element to begin the animation. The AnimationManager handles updates + automatically. + + Example: + # Move a frame to x=500 over 2 seconds with easing + anim = mcrfpy.Animation('x', 500.0, 2.0, 'easeInOut') + anim.start(my_frame) + + # Animate color with completion callback + def on_done(anim, target): + print('Fade complete!') + fade = mcrfpy.Animation('fill_color.a', 0, 1.0, callback=on_done) + fade.start(my_sprite) + """ + + @property + def property(self) -> str: + """Target property name being animated (read-only).""" ... - + + @property + def duration(self) -> float: + """Animation duration in seconds (read-only).""" + ... + + @property + def elapsed(self) -> float: + """Time elapsed since animation started in seconds (read-only).""" + ... + + @property + def is_complete(self) -> bool: + """Whether the animation has finished (read-only).""" + ... + + @property + def is_delta(self) -> bool: + """Whether animation uses delta/additive mode (read-only).""" + ... + + def __init__(self, + property: str, + target: Union[float, int, Tuple[float, float], Tuple[int, int, int], Tuple[int, int, int, int], List[int], str], + duration: float, + easing: str = 'linear', + delta: bool = False, + callback: Optional[Callable[['Animation', Any], None]] = None) -> None: + """Create an animation for a UI property. + + Args: + property: Property name to animate. Common properties: + - Position/Size: 'x', 'y', 'w', 'h', 'pos', 'size' + - Appearance: 'fill_color', 'outline_color', 'opacity' + - Sprite: 'sprite_index', 'scale' + - Grid: 'center', 'zoom' + - Sub-properties: 'fill_color.r', 'fill_color.g', etc. + target: Target value. Type depends on property: + - float: For x, y, w, h, scale, opacity, zoom + - int: For sprite_index + - (r, g, b) or (r, g, b, a): For colors + - (x, y): For pos, size, center + - [int, ...]: For sprite animation sequences + - str: For text animation + duration: Animation duration in seconds. + easing: Easing function. Options: 'linear', 'easeIn', 'easeOut', + 'easeInOut', 'easeInQuad', 'easeOutQuad', 'easeInOutQuad', + 'easeInCubic', 'easeOutCubic', 'easeInOutCubic', + 'easeInElastic', 'easeOutElastic', 'easeInOutElastic', + 'easeInBounce', 'easeOutBounce', 'easeInOutBounce', and more. + delta: If True, target value is added to start value. + callback: Function(animation, target) called on completion. + """ + ... + + def start(self, target: UIElement, conflict_mode: str = 'replace') -> None: + """Start the animation on a UI element. + + Args: + target: The UI element to animate (Frame, Caption, Sprite, Grid, or Entity) + conflict_mode: How to handle if property is already animating: + - 'replace': Stop existing animation, start new one (default) + - 'queue': Wait for existing animation to complete + - 'error': Raise RuntimeError if property is busy + """ + ... + def update(self, dt: float) -> bool: - """Update animation, returns True if still running.""" + """Update animation by time delta. Returns True if still running. + + Note: Normally called automatically by AnimationManager. + """ ... - + def get_current_value(self) -> Any: """Get the current interpolated value.""" ... + def complete(self) -> None: + """Complete the animation immediately, jumping to final value.""" + ... + + def hasValidTarget(self) -> bool: + """Check if the animation target still exists.""" + ... + + def __repr__(self) -> str: + """Return string representation showing property, duration, and status.""" + ... + # Module-level attributes __version__: str From d878c8684d8b745180c63911df75a41f30587df4 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sun, 4 Jan 2026 12:59:28 -0500 Subject: [PATCH 4/4] Easing functions as enum --- src/McRFPy_API.cpp | 8 ++ src/PyAnimation.cpp | 22 +++-- src/PyEasing.cpp | 228 ++++++++++++++++++++++++++++++++++++++++++++ src/PyEasing.h | 29 ++++++ 4 files changed, 278 insertions(+), 9 deletions(-) create mode 100644 src/PyEasing.cpp create mode 100644 src/PyEasing.h diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 0f07fbc..9550b36 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -10,6 +10,7 @@ #include "PySceneObject.h" #include "PyFOV.h" #include "PyTransition.h" +#include "PyEasing.h" #include "PySound.h" #include "PyMusic.h" #include "PyKeyboard.h" @@ -429,6 +430,13 @@ PyObject* PyInit_mcrfpy() // Note: default_transition and default_transition_duration are handled via // mcrfpy_module_getattr/setattro using PyTransition::default_transition/default_duration + // Add Easing enum class (uses Python's IntEnum) + PyObject* easing_class = PyEasing::create_enum_class(m); + if (!easing_class) { + // If enum creation fails, continue without it (non-fatal) + PyErr_Clear(); + } + // Add automation submodule PyObject* automation_module = McRFPy_Automation::init_automation_module(); if (automation_module != NULL) { diff --git a/src/PyAnimation.cpp b/src/PyAnimation.cpp index 9044b9f..7208fe8 100644 --- a/src/PyAnimation.cpp +++ b/src/PyAnimation.cpp @@ -1,6 +1,7 @@ #include "PyAnimation.h" #include "McRFPy_API.h" #include "McRFPy_Doc.h" +#include "PyEasing.h" #include "UIDrawable.h" #include "UIFrame.h" #include "UICaption.h" @@ -20,16 +21,16 @@ 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}; - + const char* property_name; PyObject* target_value; float duration; - const char* easing_name = "linear"; + PyObject* easing_arg = Py_None; int delta = 0; PyObject* callback = nullptr; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|spO", const_cast(keywords), - &property_name, &target_value, &duration, &easing_name, &delta, &callback)) { + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|OpO", const_cast(keywords), + &property_name, &target_value, &duration, &easing_arg, &delta, &callback)) { return -1; } @@ -98,10 +99,13 @@ int PyAnimation::init(PyAnimationObject* self, PyObject* args, PyObject* kwds) { PyErr_SetString(PyExc_TypeError, "Target value must be float, int, list, tuple, or string"); return -1; } - - // Get easing function - EasingFunction easingFunc = EasingFunctions::getByName(easing_name); - + + // Get easing function from argument (enum, string, int, or None) + EasingFunction easingFunc; + if (!PyEasing::from_arg(easing_arg, &easingFunc, nullptr)) { + return -1; // Error already set by from_arg + } + // Create the Animation self->data = std::make_shared(property_name, animValue, duration, easingFunc, delta != 0, callback); diff --git a/src/PyEasing.cpp b/src/PyEasing.cpp new file mode 100644 index 0000000..95e8978 --- /dev/null +++ b/src/PyEasing.cpp @@ -0,0 +1,228 @@ +#include "PyEasing.h" +#include "McRFPy_API.h" + +// Static storage for cached enum class reference +PyObject* PyEasing::easing_enum_class = nullptr; + +// Easing function table - maps enum value to function and name +struct EasingEntry { + const char* name; + int value; + EasingFunction func; +}; + +static const EasingEntry easing_table[] = { + {"LINEAR", 0, EasingFunctions::linear}, + {"EASE_IN", 1, EasingFunctions::easeIn}, + {"EASE_OUT", 2, EasingFunctions::easeOut}, + {"EASE_IN_OUT", 3, EasingFunctions::easeInOut}, + {"EASE_IN_QUAD", 4, EasingFunctions::easeInQuad}, + {"EASE_OUT_QUAD", 5, EasingFunctions::easeOutQuad}, + {"EASE_IN_OUT_QUAD", 6, EasingFunctions::easeInOutQuad}, + {"EASE_IN_CUBIC", 7, EasingFunctions::easeInCubic}, + {"EASE_OUT_CUBIC", 8, EasingFunctions::easeOutCubic}, + {"EASE_IN_OUT_CUBIC", 9, EasingFunctions::easeInOutCubic}, + {"EASE_IN_QUART", 10, EasingFunctions::easeInQuart}, + {"EASE_OUT_QUART", 11, EasingFunctions::easeOutQuart}, + {"EASE_IN_OUT_QUART", 12, EasingFunctions::easeInOutQuart}, + {"EASE_IN_SINE", 13, EasingFunctions::easeInSine}, + {"EASE_OUT_SINE", 14, EasingFunctions::easeOutSine}, + {"EASE_IN_OUT_SINE", 15, EasingFunctions::easeInOutSine}, + {"EASE_IN_EXPO", 16, EasingFunctions::easeInExpo}, + {"EASE_OUT_EXPO", 17, EasingFunctions::easeOutExpo}, + {"EASE_IN_OUT_EXPO", 18, EasingFunctions::easeInOutExpo}, + {"EASE_IN_CIRC", 19, EasingFunctions::easeInCirc}, + {"EASE_OUT_CIRC", 20, EasingFunctions::easeOutCirc}, + {"EASE_IN_OUT_CIRC", 21, EasingFunctions::easeInOutCirc}, + {"EASE_IN_ELASTIC", 22, EasingFunctions::easeInElastic}, + {"EASE_OUT_ELASTIC", 23, EasingFunctions::easeOutElastic}, + {"EASE_IN_OUT_ELASTIC", 24, EasingFunctions::easeInOutElastic}, + {"EASE_IN_BACK", 25, EasingFunctions::easeInBack}, + {"EASE_OUT_BACK", 26, EasingFunctions::easeOutBack}, + {"EASE_IN_OUT_BACK", 27, EasingFunctions::easeInOutBack}, + {"EASE_IN_BOUNCE", 28, EasingFunctions::easeInBounce}, + {"EASE_OUT_BOUNCE", 29, EasingFunctions::easeOutBounce}, + {"EASE_IN_OUT_BOUNCE", 30, EasingFunctions::easeInOutBounce}, +}; + +// Old string names (for backwards compatibility) +static const char* legacy_names[] = { + "linear", "easeIn", "easeOut", "easeInOut", + "easeInQuad", "easeOutQuad", "easeInOutQuad", + "easeInCubic", "easeOutCubic", "easeInOutCubic", + "easeInQuart", "easeOutQuart", "easeInOutQuart", + "easeInSine", "easeOutSine", "easeInOutSine", + "easeInExpo", "easeOutExpo", "easeInOutExpo", + "easeInCirc", "easeOutCirc", "easeInOutCirc", + "easeInElastic", "easeOutElastic", "easeInOutElastic", + "easeInBack", "easeOutBack", "easeInOutBack", + "easeInBounce", "easeOutBounce", "easeInOutBounce" +}; + +static const int NUM_EASING_ENTRIES = sizeof(easing_table) / sizeof(easing_table[0]); + +const char* PyEasing::easing_name(int value) { + if (value >= 0 && value < NUM_EASING_ENTRIES) { + return easing_table[value].name; + } + return "LINEAR"; +} + +PyObject* PyEasing::create_enum_class(PyObject* module) { + // Import IntEnum from enum module + PyObject* enum_module = PyImport_ImportModule("enum"); + if (!enum_module) { + return NULL; + } + + PyObject* int_enum = PyObject_GetAttrString(enum_module, "IntEnum"); + Py_DECREF(enum_module); + if (!int_enum) { + return NULL; + } + + // Create dict of enum members + PyObject* members = PyDict_New(); + if (!members) { + Py_DECREF(int_enum); + return NULL; + } + + // Add all easing function members + for (int i = 0; i < NUM_EASING_ENTRIES; i++) { + PyObject* value = PyLong_FromLong(easing_table[i].value); + if (!value) { + Py_DECREF(members); + Py_DECREF(int_enum); + return NULL; + } + if (PyDict_SetItemString(members, easing_table[i].name, value) < 0) { + Py_DECREF(value); + Py_DECREF(members); + Py_DECREF(int_enum); + return NULL; + } + Py_DECREF(value); + } + + // Call IntEnum("Easing", members) to create the enum class + PyObject* name = PyUnicode_FromString("Easing"); + if (!name) { + Py_DECREF(members); + Py_DECREF(int_enum); + return NULL; + } + + // IntEnum(name, members) using functional API + PyObject* args = PyTuple_Pack(2, name, members); + Py_DECREF(name); + Py_DECREF(members); + if (!args) { + Py_DECREF(int_enum); + return NULL; + } + + PyObject* easing_class = PyObject_Call(int_enum, args, NULL); + Py_DECREF(args); + Py_DECREF(int_enum); + + if (!easing_class) { + return NULL; + } + + // Cache the reference for fast type checking + easing_enum_class = easing_class; + Py_INCREF(easing_enum_class); + + // Add to module + if (PyModule_AddObject(module, "Easing", easing_class) < 0) { + Py_DECREF(easing_class); + easing_enum_class = nullptr; + return NULL; + } + + return easing_class; +} + +int PyEasing::from_arg(PyObject* arg, EasingFunction* out_func, bool* was_none) { + if (was_none) *was_none = false; + + // Accept None -> default to linear + if (arg == Py_None) { + if (was_none) *was_none = true; + *out_func = EasingFunctions::linear; + return 1; + } + + // Accept Easing enum member (check if it's an instance of our enum) + if (easing_enum_class && PyObject_IsInstance(arg, easing_enum_class)) { + // IntEnum members have a 'value' attribute + PyObject* value = PyObject_GetAttrString(arg, "value"); + if (!value) { + return 0; + } + long val = PyLong_AsLong(value); + Py_DECREF(value); + if (val == -1 && PyErr_Occurred()) { + return 0; + } + if (val >= 0 && val < NUM_EASING_ENTRIES) { + *out_func = easing_table[val].func; + return 1; + } + PyErr_Format(PyExc_ValueError, + "Invalid Easing value: %ld. Must be 0-%d.", val, NUM_EASING_ENTRIES - 1); + return 0; + } + + // Accept int (for backwards compatibility and direct enum value access) + if (PyLong_Check(arg)) { + long val = PyLong_AsLong(arg); + if (val == -1 && PyErr_Occurred()) { + return 0; + } + if (val >= 0 && val < NUM_EASING_ENTRIES) { + *out_func = easing_table[val].func; + return 1; + } + PyErr_Format(PyExc_ValueError, + "Invalid easing value: %ld. Must be 0-%d or use mcrfpy.Easing enum.", + val, NUM_EASING_ENTRIES - 1); + return 0; + } + + // Accept string (for backwards compatibility) + if (PyUnicode_Check(arg)) { + const char* name = PyUnicode_AsUTF8(arg); + if (!name) { + return 0; + } + + // Check legacy string names first + for (int i = 0; i < NUM_EASING_ENTRIES; i++) { + if (strcmp(name, legacy_names[i]) == 0) { + *out_func = easing_table[i].func; + return 1; + } + } + + // Also check enum-style names (EASE_IN_OUT, etc.) + for (int i = 0; i < NUM_EASING_ENTRIES; i++) { + if (strcmp(name, easing_table[i].name) == 0) { + *out_func = easing_table[i].func; + return 1; + } + } + + // Build error message with available options + PyErr_Format(PyExc_ValueError, + "Unknown easing function: '%s'. Use mcrfpy.Easing enum (e.g., Easing.EASE_IN_OUT) " + "or legacy string names: 'linear', 'easeIn', 'easeOut', 'easeInOut', 'easeInQuad', etc.", + name); + return 0; + } + + PyErr_SetString(PyExc_TypeError, + "Easing must be mcrfpy.Easing enum member, string, int, or None"); + return 0; +} diff --git a/src/PyEasing.h b/src/PyEasing.h new file mode 100644 index 0000000..43f2575 --- /dev/null +++ b/src/PyEasing.h @@ -0,0 +1,29 @@ +#pragma once +#include "Common.h" +#include "Python.h" +#include "Animation.h" + +// Module-level Easing enum class (created at runtime using Python's IntEnum) +// Stored as a module attribute: mcrfpy.Easing + +class PyEasing { +public: + // Create the Easing enum class and add to module + // Returns the enum class (new reference), or NULL on error + static PyObject* create_enum_class(PyObject* module); + + // Helper to extract easing function from Python arg + // Accepts Easing enum, string (for backwards compatibility), int, or None + // Returns 1 on success, 0 on error (with exception set) + // If arg is None, sets *out_func to linear and sets *was_none to true + static int from_arg(PyObject* arg, EasingFunction* out_func, bool* was_none = nullptr); + + // Convert easing enum value to string name + static const char* easing_name(int value); + + // Cached reference to the Easing enum class for fast type checking + static PyObject* easing_enum_class; + + // Number of easing functions + static const int NUM_EASING_FUNCTIONS = 32; +};