• 🏆 Texturing Contest #33 is OPEN! Contestants must re-texture a SD unit model found in-game (Warcraft 3 Classic), recreating the unit into a peaceful NPC version. 🔗Click here to enter!
  • ✅ The POLL for Hive's Texturing Contest #33 is OPEN! Vote for the TOP 3 SKINS! 🔗Click here to cast your vote!

TimerQueue & Stopwatch

Overview

Documentation & Code

Changelog

TimerQueue

A TimerQueue is an object based on a single timer that lets you execute any number of delayed function calls.

Main Use

Lua:
-- TimerQueue:callDelayed(delayInSeconds, function, ...)
-- will call the specified function after the specified delay with the specified arguments (...)

--print "Hello World" after 5 seconds delay
TimerQueue:callDelayed(5., print, "Hello World")
--create a footman after 3 seconds delay (i.e. 2 seconds before printing "Hello World")
TimerQueue:callDelayed(3., CreateUnit, Player(0), FourCC('hfoo'), 0, 0,0)
--It also works well with anonymous functions
TimerQueue:callDelayed(2., function() print("Hello") end)

A TimerQueue really shines, when you have many functions waiting for execution at the same time. That normally would require starting a lot of independent timers, while the TimerQueue handles it with a single one (which basically times the deltas in between calls), thus saving performance overhead. You can of course disregard these internals. Simply use its callDelayed-method and the TimerQueue will handle the rest.

Periodic executions

TimerQueue provides the :callPeriodically method for periodic execution:
Lua:
-- TimerQueue:callPeriodically(delayInSeconds, stopCondition, func, ...)
-- will periodicaly call func(...), until stopCondition(...) resolves to true.

-- This example adds 10 to the gold of Player Red every two seconds, until his gold reaches 1000.
do
    ---Adds 10 to the specified player's gold.
    ---@param p player
    local function add10ToPlayerGold(p)
        AdjustPlayerStateBJ( 10, p, PLAYER_STATE_RESOURCE_GOLD )
    end
    ---Returns true, if the specified player's gold is at least 1000.
    ---@param p player
    ---@return boolean
    local function stopAt1000(p)
        return GetPlayerState(p, PLAYER_STATE_RESOURCE_GOLD) >= 1000
    end

    TimerQueue:callPeriodically(2., stopAt1000, add10ToPlayerGold, Player(0))
end

Alternatively, periodic executions can be achieved by simple recursion. As TimerQueues are based off a single timer, you don't need to worry about overhead from unnecessary timer creation.
Lua:
function printPeriodically(x)
    print(x)
    if <condition> then TimerQueue:callDelayed(2., printPeriodically, x) end
end

printPeriodically("Hello") --prints "Hello" every 2 seconds as long as the specified condition is met.

Instantiability

The above example used :callDelayed() directly on the TimerQueue class.
That works just fine and will satisfy most use cases, but sometimes it's just more convenient to create separate TimerQueues for dfferent things:
Lua:
tq = TimerQueue.create()
tq:callDelayed(5., print, "tq is a separate TimerQueue instance")

Creating a TimerQueue instance enables you to :pause(), :resume(), :reset() and :destroy() the instance as a whole.
Pausing a TimerQueue will pause the elapsed time for all currently queued function calls, until the TimerQueue is resumed.
Resetting a TimerQueue will discard all currently queued function calls without executing any of them.
Destroying a TimerQueue will reset it and destroy the underlying timer, which is eventually necessary to prevent memory leaks.

These methods have favorable advantages, if you want a specific set of your delayed executions to be potentially cancelable.
-> You basically call all cancel-candidates on a dedicated TimerQueue and can reset that queue at any time.
I personally need this cancelling of queued delayed functions pretty often during coding.

Imagine that you create a map centered around boss fights and the current boss is creating hostile effects on the ground, which players need to evade. These hostile effects display as red circles a few seconds before they apply, so code-wise they involve creating the circle plus a delayed function call for the actual effect. However, as soon as the boss dies, he is not supposed to cast any more abilities, even if those have already been announced by red circles, so you are required to cancel those waiting effect executions.
At the same time, players might also cast abilities involving delayed effects, which shall continue to resolve after the boss has died.

A TimerQueue can achieve that with ease: queue all boss effects into the same TimerQueue, so you can cancel all waiting executions with a single :reset().

Lua:
--We create a dedicated TimerQueue to queue the boss abilities and another one for the player abilities
bossTQ = TimerQueue.create()
playerTQ = TimerQueue.create() --in reality, we might actually want to create a separate queue per player to be able to cancel their effects upon death individually

--As the battle progresses, more and more delayed calls are added to the queue
bossTQ:callDelayed(delay, letBossDoSomething, ...)
playerTQ:callDelayed(delay, resolveSomePlayerEffect, ...)

--Finally the boss dies and you want to cancel all executions of boss effects that are still waiting in the queue
bossTQ:reset() --discards all waiting executions from the queue. You can still add new callbacks afterwards.

--The player timerqueue is not affected and will continue to resolve all waiting executions

Disabling queued callbacks

You will normally cancel queued callbacks by resetting the underlying TimerQueue, but sometimes that's just not convenient. In that case, every queued callback can also be disabled individually, if you have the TimerQueueElement at hand that returned from your :callDelayed execution:

Lua:
--You can save the TimerQueueElement resulting from callDelayed into a local variable
local someTQElement = TimerQueue:callDelayed(2., function() print("Hello World") end) ---@type TimerQueueElement
--some time later
someTQElement:disable() --prevents the callback from executing on timeout.

Disabling TimerQueueElements will neither pause them nor remove them from the queue. It will simply cause the callback to not execute on timeout (at which point they are regularly being removed from the queue without any effect).
You can :enable() a TimerQueueElement after disabling, if you changed your mind. This will (quite obviously) not have any effect on callbacks after timeout, even if they were disabled at that time (it's too late).

Note that TimerQueue:callPeriodically doesn't return a TimerQueueElement, so you can't disable it. This is a security measure to prevent dead periodic callbacks to sit in your TimerQueue forever. If you want to cancel or pause a periodic callback, either choose an appropriate stop-condition (and re-queue manually if desired) or create a dedicated TimerQueue that you can :reset() or :pause().

Error Handling

The TimerQueue class provides the .debugMode property, which - in case true - lets all TimerQueues print error messages upon executing erroneous function calls. This can save a lot of frustration and time, as bugs within delayed function calls are rather hard to spot and debug.
TimerQueue.debugMode is set to true by default. It's wise to turn it off before releasing your map to the public.

Stopwatch

A Stopwatch is a timer that counts upwards, which (surprise) you can use to stop time.
You can create a new stopwatch sw via sw = Stopwatch.create(), start it with sw:start() and receive the current elapsed time via sw:getElapsed().
As you would expect, you can also :pause(), :resume() and :destroy() a stopwatch. Please refer to the Documentation & Code tab for further information.

Installation

Create a new script in your trigger editor (above your other code) and copy the code from the Documentation & Code tab into it.

Credits

Initial credits for this resource go to @AGD. We have developed this resource together in this thread, but AGD became inactive before publishing a final version. I eventually added instantiability and here we are.


Lua:
if Debug and Debug.beginFile then Debug.beginFile("TimerQueue") end
--[[------------------------------------------------------------------------------------------------------------------------------------------------------------
*
*    --------------------------------
*    | TimerQueue and Stopwatch 1.2 |
*    --------------------------------
*
*    - by Eikonium and AGD
*
*    -> https://www.hiveworkshop.com/threads/timerqueue-stopwatch.353718/
*    - This is basically the enhanced and instancifiable version of ExecuteDelayed 1.0.4 by AGD https://www.hiveworkshop.com/threads/lua-delayedaction.321072/
*
* --------------------
* | TimerQueue class |
* --------------------
*        - A TimerQueue is an object used to execute delayed function calls. It can queue any number of function calls at the same time, while being based on a single timer. This offers much better performance than starting many separate timers.
*        - The class also provides methods to pause, resume, reset and destroy a TimerQueue - and even includes error handling.
*        - As such, you can create as many independent TimerQueues as you like, which you can individually use, pause, reset, etc.
*        - All methods can also be called on the class directly, which frees you from needing to create a TimerQueue object in the first place. You still need colon-notation!
*    TimerQueue.create() --> TimerQueue
*        - Creates a new TimerQueue with its own independent timer and function queue.
*    <TimerQueue>:callDelayed(number delay, function callback, ...) --> TimerQueueElement
*        - Calls the specified function (or callable table) after the specified delay (in seconds) with the specified arguments (...). Does not delay the following lines of codes.
*        - The returned TimerQueueElement can usually be discarded. Saving it to a local var allows you to :disable() or re-:enable() it later.
*    <TimerQueue>:callPeriodically(number delay, function|nil stopCondition, function callback, ...)
*        - Periodically calls the specified function (or callable table) after the specified delay (in seconds) with the specified arguments (...). Stops, when the specified condition resolves to true.
*        - The stop-condition must be a function returning a boolean. It is checked after each callback execution and is passed the same arguments as the callback (...) (which you can still ignore).
*        - You can pass nil instead of a function to let the periodic execution repeat forever.
*        - Resetting the TimerQueue will stop all periodic executions, even if the reset happened within the periodic callback.
*        - Doesn't return a TimerQueueElement, so disabling a periodic callback is only possible via either meeting the stop-condition or resetting the queue.
*    <TimerQueue>:reset()
*        - Discards all queued function calls from the Timer Queue. Discarded function calls are not executed.
*        - You can continue to use <TimerQueue>:callDelayed after resetting it.
*    <TimerQueue>:pause()
*        - Pauses the TimerQueue at its current point in time, effectively freezing all delayed function calls that it currently holds, until the queue is resumed.
*        - Using <TimerQueue>:callDelayed on a paused queue will correctly add the new callback to the queue, but time will start ticking only after resuming the queue.
*    <TimerQueue>:isPaused() --> boolean
*        - Returns true, if the TimerQueue is paused, and false otherwise.
*    <TimerQueue>:resume()
*        - Resumes a TimerQueue that was previously paused. Has no effect on TimerQueues that are not paused.
*    <TimerQueue>:destroy()
*        - Destroys the Timer Queue. Remaining function calls are discarded and not being executed.
*    <TimerQueue>:tostring() --> string
*        - Represents a TimerQueue as a list of its tasks. For debugging purposes.
*    <TimerQueue>.debugMode : boolean
*        - TimerQueues come with their own error handling in case you are not using DebugUtils (https://www.hiveworkshop.com/threads/debug-utils-ingame-console-etc.330758/).
*        - Set to true to let erroneous function calls through <TimerQueue>:callDelayed print error messages on screen (only takes effect, if Debug Utils is not present. Otherwise you get Debug Utils error handling, which is even better).
*        - Set to false to not trigger error messages after erroneous callbacks. Do this before map release.
*        - Default: true.
*    <TimerQueueElement>:disable()
*        - Disables this TimerQueueElement (as returned by TimerQueue:callDelayed), making the callback not execute upon timeout.
*        - Use this to cancel a future callback, when resetting the whole queue is not a suitable solution.
*        - The disabled TimerQueue-Element will continue to tick, but will forward to the next callback upon timeout instead of executing itself.
*    <TimerQueueElement>:enable()
*        - Enables this TimerQueueElement after you have previously disabled it, making the callback again execute normally upon timer expiration.
*        - Enabling a TimerQueueElement after its timeout has already passed (which happens even while disabled) will not have any effect.
* -------------------
* | Stopwatch class |
* -------------------
*        - Stopwatches count upwards, i.e. they measure the time passed since you've started them. Thus, they can't trigger any callbacks (use normal timers or TimerQueues for that).
*    Stopwatch.create(boolean startImmediately_yn) --> Stopwatch
*        - Creates a Stopwatch. Set boolean param to true to start it immediately.
*    <Stopwatch>:start()
*        - Starts or restarts a Stopwatch, i.e. resets the elapsed time of the Stopwatch to zero and starts counting upwards.
*    <Stopwatch>:getElapsed() --> number
*        - Returns the time in seconds that a Stopwatch is currently running, i.e. the elapsed time since start.
*    <Stopwatch>:pause()
*        - Pauses a Stopwatch, so it will retain its current elapsed time, until resumed.
*    <Stopwatch>:resume()
*        - Resumes a Stopwatch after having been paused.
*    <Stopwatch>:destroy()
*        - Destroys a Stopwatch. Maybe necessary to prevent memory leaks. Not sure, if lua garbage collection also collects warcraft objects...
---------------------------------------------------------------------------------------------------------------------------------------------------------]]

do

    ---@class TimerQueueElement
    ---@field [integer] any arguments to be passed to callback
    TimerQueueElement = {
        next = nil                      ---@type TimerQueueElement next TimerQueueElement to expire after this one
        ,   timeout = 0.                ---@type number time between previous callback and this one
        ,   callback = function() end   ---@type function callback to be executed
        ,   n = 0                       ---@type integer number of arguments passed
        ,   enabled = true              ---@type boolean defines whether the callback shall be executed on timeout or not.
    }
    TimerQueueElement.__index = TimerQueueElement
    TimerQueueElement.__name = 'TimerQueueElement'

    ---Creates a new TimerQueueElement, which points to itself.
    ---@param timeout? number
    ---@param callback? function
    ---@param ... any arguments for callback
    ---@return TimerQueueElement
    function TimerQueueElement.create(timeout, callback, ...)
        local new = setmetatable({timeout = timeout, callback = callback, n = select('#', ...), ...}, TimerQueueElement)
        new.next = new
        return new
    end

    ---Disables this TimerQueueElement, making the callback not execute upon timer expiration.
    ---Use this to cancel a future callback, when resetting the whole queue is not suitable.
    ---The disabled TimerQueue-Element will technically still be part of the queue, but will just forward to the next callback instead of executing itself.
    function TimerQueueElement:disable()
        self.enabled = false
    end

    ---Enables this TimerQueueElement after you have previously disabled it, making the callback again execute normally upon timer expiration.
    function TimerQueueElement:enable()
        self.enabled = true
    end

    ---@class TimerQueue
    TimerQueue = {
        timer = nil                     ---@type timer the single timer this system is based on (one per instance of course)
        ,   queue = TimerQueueElement.create() -- queue of waiting callbacks to be executed in the future
        ,   n = 0                       ---@type integer number of elements in the queue
        ,   on_expire = function() end  ---@type function callback to be executed upon timer expiration (defined further below).
        ,   debugMode = true           ---@type boolean setting this to true will print error messages, when the input function couldn't be executed properly. Set this to false before releasing your map.
        ,   paused = false              ---@type boolean whether the queue is paused or not
    }

    TimerQueue.__index = TimerQueue
    TimerQueue.__name = 'TimerQueue'

    --Creates a timer on first access of the static TimerQueue:callDelayed method. Avoids timer creation inside the Lua root.
    setmetatable(TimerQueue, {__index = function(t,k) if k == 'timer' then t[k] = CreateTimer() end; return rawget(t,k) end})

    local unpack, max, timerStart, timerGetElapsed, pauseTimer = table.unpack, math.max, TimerStart, TimerGetElapsed, PauseTimer

    ---@param timerQueue TimerQueue
    local function on_expire(timerQueue)
        local queue, timer = timerQueue.queue, timerQueue.timer
        local topOfQueue = queue.next
        queue.next = topOfQueue.next
        timerQueue.n = timerQueue.n - 1
        if timerQueue.n > 0 then
            timerStart(timer, queue.next.timeout, false, timerQueue.on_expire)
        else
            -- These two functions below may not be necessary
            timerStart(timer, 0, false, nil) --don't put in on_expire as handlerFunc, because it can still expire and reduce n to a value < 0.
            pauseTimer(timer)
        end
        if topOfQueue.enabled then
            if Debug and Debug.try then
                Debug.try(topOfQueue.callback, unpack(topOfQueue, 1, topOfQueue.n))
            else
                local errorStatus, errorMessage = pcall(topOfQueue.callback, unpack(topOfQueue, 1, topOfQueue.n))
                if timerQueue.debugMode and not errorStatus then
                    print("|cffff5555ERROR during TimerQueue callback: " .. errorMessage .. "|r")
                end
            end
        end
    end

    TimerQueue.on_expire = function() on_expire(TimerQueue) end

    ---@return TimerQueue
    function TimerQueue.create()
        local new = {}
        setmetatable(new, TimerQueue)
        new.timer = CreateTimer()
        new.queue = TimerQueueElement.create()
        new.on_expire = function() on_expire(new) end
        return new
    end

    ---Calls a function (or callable table) after the specified timeout (in seconds) with all specified arguments (...). Does not delay the following lines of codes.
    ---@param timeout number
    ---@param callback function|table if table, must be callable
    ---@param ... any arguments of the callback function
    ---@return TimerQueueElement queuedTask usually discarded. Can be saved to local var to :disable() or re-:enable() later.
    function TimerQueue:callDelayed(timeout, callback, ...)
        timeout = math.max(timeout, 0.)
        local queue = self.queue
        self.n = self.n + 1
        -- Sort timeouts in descending order
        local current = queue
        local current_timeout = current.next.timeout - max(timerGetElapsed(self.timer), 0.) -- don't use TimerGetRemaining to prevent bugs for expired and previously paused timers.
        while current.next ~= queue and timeout >= current_timeout do --there is another element in the queue and the new element shall be executed later than the current
            timeout = timeout - current_timeout
            current = current.next
            current_timeout = current.next.timeout
        end
        -- after loop, current is the element that executes right before the new callback. If the new is the front of the queue, current is the root element (queue).
        local new = TimerQueueElement.create(timeout, callback, ...)
        new.next = current.next
        current.next = new
        -- if the new callback is the next to expire, restart timer with new timeout
        if current == queue then --New callback is the next to expire
            new.next.timeout = max(current_timeout - timeout, 0.) --adapt element that was previously on top. Subtract new timeout and subtract timer elapsed time to get new timeout.
            timerStart(self.timer, timeout, false, self.on_expire)
            if self.paused then
                self:pause()
            end
        else
            new.next.timeout = max(new.next.timeout - timeout, 0.) --current.next might be the root element (queue), so prevent that from dropping below 0. (although it doesn't really matter)
        end
        return new
    end

    ---Calls the specified callback with the specified argumets (...) every <timeout> seconds, until the specified stop-condition holds.
    ---The stop-condition must be a function returning a boolean. It is checked after every callback execution. All arguments (...) are also passed to the stop-conditon (you can still ignore them).
    ---Resetting the TimerQueue will stop all periodic executions, even if the reset happened within the periodic callback.
    ---Doesn't return a TimerQueue-Element, so disabling is only possible by either meeting the stop-condition or resetting the queue.
    ---@param timeout number time between calls
    ---@param stopCondition? fun(...):boolean callback will stop to repeat, when this condition holds. You can pass nil to skip the condition (i.e. the periodic execution will run forever).
    ---@param callback fun(...) the callback to be executed
    ---@param ... any arguments for the callback
    function TimerQueue:callPeriodically(timeout, stopCondition, callback, ...)
        local func
        func = function(...)
            local queue = self.queue --memorize queue element to check later, whether TimerQueue:reset has been called in the meantime.
            callback(...) --execute callback first
            --re-queue, if stopCondition doesn't hold and the TimerQueue has not been reset during the callback (checked via queue == self.queue)
            if queue == self.queue and not (stopCondition and stopCondition(...)) then
                self:callDelayed(timeout, func, ...)
            end
        end
        self:callDelayed(timeout, func, ...)
    end

    ---Removes all queued calls from the Timer Queue, so any remaining actions will not be executed.
    ---Using <TimerQueue>:callDelayed afterwards will still work.
    function TimerQueue:reset()
        timerStart(self.timer, 0., false, nil) --dont't put in on_expire as handlerFunc. callback can still expire after pausing and resuming the empty queue, which would set n to a value < 0.
        pauseTimer(self.timer)
        self.n = 0
        self.queue = TimerQueueElement.create()
    end

    ---Pauses the TimerQueue at its current point in time, preventing all queued callbacks from being executed, until the queue is resumed.
    ---Using <TimerQueue>:callDelayed on a paused queue will correctly add the new callback to the queue, but time will start ticking only after the queue is being resumed.
    function TimerQueue:pause()
        self.paused = true
        pauseTimer(self.timer)
    end

    ---Returns true, if the timer queue is paused, and false otherwise.
    ---@return boolean
    function TimerQueue:isPaused()
        return self.paused
    end

    ---Resumes a TimerQueue that was paused previously. Has no effect on running TimerQueues.
    function TimerQueue:resume()
        if self.paused then
            self.paused = false
            self.queue.next.timeout = self.queue.next.timeout - timerGetElapsed(self.timer) --need to restart from 0, because TimerGetElapsed(resumedTimer) is doing so as well after a timer is resumed.
            ResumeTimer(self.timer)
        end
    end

    ---Destroys the timer object behind the TimerQueue. The Lua object will be automatically garbage collected once you ensure that there is no more reference to it.
    function TimerQueue:destroy()
        pauseTimer(self.timer) --https://www.hiveworkshop.com/threads/issues-with-timer-functions.309433/ suggests that non-paused destroyed timers can still execute their callback
        DestroyTimer(self.timer)
    end

    ---Prints the queued callbacks within the TimerQueue. For debugging purposes.
    ---@return string
    function TimerQueue:tostring()
        local current, result, i = self.queue.next, {}, 0
        local args = {}
        while current ~= self.queue do
            i = i + 1
            for j = 1, current.n do
                args[j] = tostring(current[j])
            end
            result[i] = '(i=' .. i .. ',timeout=' .. current.timeout .. ',enabled = ' .. tostring(current.enabled) .. ',f=' .. tostring(current.callback) .. ',args={' .. table.concat(args, ',',1,current.n) .. '})'
            current = current.next
        end
        return '{n = ' .. self.n .. ',queue=(' .. table.concat(result, ',', 1, i) .. ')}'
    end

    ---@class Stopwatch
    Stopwatch = {
        timer = {}                                  ---@type timer the countdown-timer permanently cycling
        ,   elapsed = 0.                            ---@type number the number of times the timer reached 0 and restarted
        ,   increaseElapsed = function() end        ---@type function timer callback function to increase numCycles by 1 for a specific Stopwatch.
    }
    Stopwatch.__index = Stopwatch

    local CYCLE_LENGTH = 3600. --time in seconds that a timer needs for one cycle. doesn't really matter.

    ---Creates a Stopwatch.
    ---@param startImmediately_yn boolean Set to true to start immediately. If not specified or set to false, the Stopwatch will not start to count upwards.
    function Stopwatch.create(startImmediately_yn)
        local new = {}
        setmetatable(new, Stopwatch)
        new.timer = CreateTimer()
        new.elapsed = 0.
        new.increaseElapsed = function() new.elapsed = new.elapsed + CYCLE_LENGTH end
        if startImmediately_yn then
            new:start()
        end
        return new
    end

    ---Starts or restarts a Stopwatch, i.e. resets the elapsed time of the Stopwatch to zero and starts counting upwards.
    function Stopwatch:start()
        self.elapsed = 0.
        TimerStart(self.timer, CYCLE_LENGTH, true, self.increaseElapsed)
    end

    ---Returns the time in seconds that a Stopwatch is currently running, i.e. the elapsed time since start.
    ---@return number
    function Stopwatch:getElapsed()
        return self.elapsed + TimerGetElapsed(self.timer)
    end

    ---Pauses a Stopwatch, so it will retain its current elapsed time, until resumed.
    function Stopwatch:pause()
        PauseTimer(self.timer)
    end

    ---Resumes a Stopwatch after having been paused.
    function Stopwatch:resume()
        self.elapsed = self.elapsed + TimerGetElapsed(self.timer)
        TimerStart(self.timer, CYCLE_LENGTH, true, self.increaseElapsed) --not using ResumeTimer here, as it actually starts timer from new with the remaining time and thus screws up TimerGetElapsed().
    end

    ---Destroys the timer object behind the Stopwatch. The Lua object will be automatically garbage collected once you ensure that there is no more reference to it.
    function Stopwatch:destroy()
        DestroyTimer(self.timer)
    end
end
if Debug and Debug.endFile then Debug.endFile() end

30.4.2022, v1.0 (Initial Release):
  • TimerQueue as an instancifiable version of [Lua] - DelayedAction
  • Added :pause(), :resume(), [ icode=Lua]:reset()[/icode], :destroy() and error handling for erroneous callbacks.
  • Added Stopwatch functionality

01.05.2023, v1.1:
  • Switched from array-based to linked-list-based queue to improve performance of TimerQueue:callDelayed.
  • Switched from absolute timestamps to relative timestamps to drastically improve performance of TimerQueue:pause().
  • Integrated the newest version of Debug Utils for better error messages and local file references in particular.
  • Optimized script to prevent unnecessary table creation.
  • Added TimerQueue:printPeriodically(timeout, stopCondition, callback, ...), :isPaused() and TimerQueue:tostring().
  • Fixed a bug, where using :callDelayed on a paused empty TimerQueue would unintentionally resume the Queue. Thanks to @Insanity_AI for reporting this.
12.08.2024, v1.2:
  • Added methods :disable() and :enable() for TimerQueueElements. Thanks @maddeem for the suggestion.
  • Resetting a TimerQueue inside a periodic callback will now prevent the callback from re-queueing after the reset, as requested by @Insanity_AI.

Contents

TimerQueue & Stopwatch (Binary)

Reviews
Antares
Originally approved by @Bribe : "I see no reason why this should sit in the Submissions forum any longer. This is yet another high-quality resource of yours that is each instructive, functional, beautifully-designed and serves a unique purpose."...
Originally approved by @Bribe :

"I see no reason why this should sit in the Submissions forum any longer. This is yet another high-quality resource of yours that is each instructive, functional, beautifully-designed and serves a unique purpose."

And, if I may add, this is how a documentation should be written! Beautiful! ❤️

Approved
 
Last edited:
The left script (version 1.1) is the one on the main post of [Lua] - TimerQueue & Stopwatch, which is 99.9% identical to the Insanity_AI's fix. The right script (1.0) looks like the initial version.
This is Back to The Past, I guess? 😁
Isn't the code from this page exactly the one from your left screenshot? :)
 
Level 6
Joined
Jun 30, 2017
Messages
42
Greetings, I bring forth another bug report:

Consider the following code:

Lua:
if Debug then Debug.beginFile "Example" end
OnInit.final(function(require)
    require "TimerQueue"

    -- Granted, this all could have been done smarter, but this is just the bug showcase

    local timer = TimerQueue.create()

    local isRunning = false
    local tick = 0

    local function onInterval()
        print "Tick"
        tick = tick + 1

        if tick >= 20 then
            print "End ticking"
            tick = 0
            timer:reset()
            isRunning = false
        end
    end

    function StartTicking()
        if not isRunning then
            isRunning = true
            timer:callPeriodically(0.03, nil, onInterval)
        end
    end

end)
if Debug then Debug.endFile() end

What was expected to happen:
- only one onInterval function being executed for the 20 ticks regardless of how many StartTicking calls happen

What actually happens:
  • after the first StartTicking call has ticked out (tick >= 20), and doing a second call, it appears that the entire thing starts ticking twice as fast.
  • EDIT: actually, I think this example would print "End ticking" but it wouldn't stop, just keep going from 0 to 20 over and over again with just 1 StartTicking call

Reason:
- timer:reset does not clear out the queue like you expect it, and the cause is actually with timer:callPeriodically


Lua:
    function TimerQueue:callPeriodically(timeout, stopCondition, callback, ...)
        local func
        func = function(...)
            callback(...)
            if not (stopCondition and stopCondition(...)) then
            --->    self:callDelayed(timeout, func, ...)   <---
            end
        end
        self:callDelayed(timeout, func, ...)
    end
If the reset is called in callback, it does not encompass the upcoming callDelayed call that happens in this case, making it so that the timer will always have at least 1 periodic function despite the amount of times the reset is called in that periodic callback function
 
Last edited:
Greetings, I bring forth another bug report:
This is a tough one. From my point of view, this is working as intended, but I totally understand that the behaviour is not intuitive and should maybe be improved.

Why I think it's working as intended:
  • TimerQueue:callPeriodically is supposed to re-queue the callback after every execution in case the stop-condition is not met.
  • You are resetting during callback execution, which doesn't prevent re-queuing afterwards.
  • Documentation states that the stop-condition (implying the re-queuing) is evaluated after the callback has been executed.
Still your issue holds. It's not intuitive to keep the active periodic callback in the queue, when all others are discarded. And I can't expect users to have knowledge about re-queuing behaviour of :callDelayed.
I will work on an update :)
 
Update to version 1.2:
  • Added methods :disable() and :enable() for TimerQueueElements.
    This allows you to disable a single callback instead of resetting the whole queue:
    Lua:
    --You can save the TimerQueueElement resulting from callDelayed into a local variable
    local someTQElement = TimerQueue:callDelayed(2., function() print("Hello World") end) ---@type TimerQueueElement
    --1s later
    someTQElement:disable() --prevents the callback from executing on timeout.
    Disabling a TimerQueueElement will not remove it from the queue, but just skip callback execution on timeout (same result).
    Thanks @maddeem for the suggestion.
  • Resetting a TimerQueue inside a periodic callback will now prevent the callback from re-queueing after the reset, as requested by @Insanity_AI.
 
I want to preface my review by saying I have slightly altered an older version of this to make use of table recycling.

Okay so the way this system allows you to seamlessly queue delayed functions with whatever arguments you want is just supremely powerful. Before using this I was always at least a bit hesitant to introduce timers into a code snippet because of how unwieldy they are.

Using this has caused a total paradigm shift for me and how I approach problems. I'm adding delays everywhere, especially for sound design or animation. Then as if it can't get better you can run all of your game logic through one timer and pause it to effectively pause the game.

I have seriously battle tested this system with many thousands of callbacks in the queue and it performs like a champion. Thank you for making this, it has really opened my eyes to making better tooling for better development.
 
Top