• Check out the results of the Techtree Contest #19!
  • Listen to a special audio message from Bill Roper to the Hive Workshop community (Bill is a former Vice President of Blizzard Entertainment, Producer, Designer, Musician, Voice Actor) 🔗Click here to hear his message!
  • Read Evilhog's interview with Gregory Alper, the original composer of the music for WarCraft: Orcs & Humans 🔗Click here to read the full interview.
  • Create a void inspired texture for Warcraft 3 and enter Hive's 34th Texturing Contest: Void! Click here to enter!
  • The Hive's 22nd Icon Contest: Creep Abilities is now concluded, time to vote for your favourite set of icons! Click here to vote!

CaptureTimer (GUI-Friendly Timers)

This bundle is marked as pending. It has not been reviewed by a staff member yet.
CaptureTimer v1.0.0
Requirements
  • Dependencies: None (DebugUtils recommended)
  • Requirements: Lua
Overview

This system is designed to allow GUI users to take advantage of the simplicity of Lua timers and closures. Typically, programming a timed-spell in Lua is as simple as starting a timer and capturing some locals during the run:
Lua:
local caster = GetTriggerUnit()
TimerStart(CreateTimer(), 0.03, true, function ()
    -- ... do stuff with `caster`
end)

However, since GUI cannot easily work with local variables, they often have to resort to dynamic indexing or hashtables to track their state over time, which becomes cumbersome very quickly (especially for arrays). This system provides a simpler alternative:
  1. Give the system a list of global variables to "capture" with the timer
  2. Start the timer (via Custom Script)
  3. Use your globals as you normally would
This drastically reduces the amount of triggers and variables needed to write timed spells in GUI.

Here is a sample that drains 2 life from a target every 0.1 seconds:
  • SiphonLife
    • Events
      • Unit - A unit Starts the effect of an ability
    • Conditions
      • (Ability being cast) Equal to Siphon Life
    • Actions
      • -------- --------
      • -------- Set up your globals with the state you want to track --------
      • -------- Then write their names in a capture list variable (like below, be sure to match spelling and capitalization exactly!) --------
      • -------- --------
      • Set VariableSet Caster = (Triggering unit)
      • Set VariableSet Target = (Target unit of ability being cast)
      • Set VariableSet ElapsedTime = 0.00
      • Custom script: local captureList = { "Caster", "Target", "ElapsedTime" }
      • -------- --------
      • -------- Start a periodic timer that runs every 0.1 seconds, providing your list of variables --------
      • -------- Then execute your logic as normal between the 'function ()' and 'end)' lines, using your regular variables --------
      • -------- --------
      • Custom script: CaptureTimer.StartPeriodic(captureList, 0.1, function ()
      • Unit - Cause Caster to damage Target, dealing 2.00 damage of attack type Spells and damage type Normal
      • Unit - Set life of Caster to ((Life of Caster) + 2.00)
      • Set VariableSet ElapsedTime = (ElapsedTime + 0.10)
      • If (All Conditions are True) then do (Then Actions) else do (Else Actions)
        • If - Conditions
          • ElapsedTime Greater than or equal to 3.00
        • Then - Actions
          • -------- Once your spell is complete, run EndPeriodicLoop --------
          • Custom script: CaptureTimer.EndPeriodic()
        • Else - Actions
      • Custom script: end)
This works by "capturing" the values of the globals when CaptureTimer.StartPeriodic is called. Once the timer fires, those globals will be re-assigned to that snapshot. If you modify those globals between the function() and end) lines, those values will be saved and usable on the next tick (e.g. "ElapsedTime"). This way, you get the advantages of locals with the benefits of globals :thumbs_up: (namely: ease-of-use in GUI and the ability to use it across function boundaries, e.g. if/then/else statements and pick every unit... functions).

Once your spell is done, just be sure to call CaptureTimer.EndPeriodic() to stop the timer. It will automatically infer which instance to clean-up for you.

This supports one-shot timers as well--useful when you want to delay something briefly without resorting to waits. Simply use CaptureTimer.Delay instead. Clean-up will happen automatically once the timer completes:
  • Actions
    • Special Effect - Create a special effect attached to the chest of (Triggering unit) using Abilities\Spells\Orc\Ensnare\ensnareTarget.mdl
    • Set VariableSet MyEffect = (Last created special effect)
    • Custom script: local captureList = { "MyEffect" }
    • Custom script: CaptureTimer.Delay(captureList, 1, function ()
    • Special Effect - Destroy MyEffect
    • Custom script: end)
Features
  1. Create periodic timers via CaptureTimer.StartPeriodic or one-shot timers via CaptureTimer.Delay.
  2. Take advantage of timer pooling via CaptureTimer.StartPeriodicPooled. The syntax and usage is exactly the same as StartPeriodic, but it'll try to use an existing timer with the same period wherever possible--useful for performance-sensitive code. However, it is only recommended to use timer pooling for high frequency timers (e.g. 0.1 seconds or below). Pooled timers run on the same "tick", which can be quite noticeable for timers with longer periods. In addition, since a pooled timer will "piggyback-off" an existing timer, the first tick may fire sooner than the actual period--so if you need very precise timing, just use StartPeriodic.
  3. You can also capture arrays, which were notoriously difficult to store using dynamic indexing. The syntax is slightly different, however. In your capture list, you'll have to simply provide the "slice" of the array that you want to capture. In the sample map, for example, one of the spells keeps track of 64 effects in an array--so you would tell the system to "capture" that like so:
    • Custom script: local captureList = { "DarkEnergyEffect[1...64]" }
    The syntax is <variableName>[<minimumIndex>...<maximumIndex>]. You can capture as many slices as you desire.
  4. This system is multiplayer-compatible out of the box (and tested) and MUI (when used as suggested!).
  5. If you need to end the timer externally (e.g. in another trigger), there is a delegate ID provided in the timer callback for that. See the system details for more information.
  6. The system will warn you if you attempt to capture a non-existent variable. To disable that, set the variable in the script WARN_FOR_UNDEFINED_GLOBALS to false.
Installation
  1. Ensure your map script is set to Lua (Scenario > Map Options > Script Language). I strongly recommend making a back-up before attempting this on an existing map.
  2. In the sample map below, copy the entire "CaptureTimer" script and paste it into your map.
  3. (Optional) For better debugging, I recommend also copying the "OptionalDependencies" folder (which includes DebugUtils).
  4. Done!

Source Code

Lua:
if Debug and Debug.beginFile then Debug.beginFile("CaptureTimer") end

-------------------------------------------------------------------------------
--
--  CaptureTimer
--      v.1.0.0
--      Author: Purgeandfire
--      Required Dependencies: None
--      Requirements: Lua (JASS is not supported)
--
-------------------------------------------------------------------------------
--
--  Overview
--  ========
--
--  This system is designed to allow GUI users to take advantage of the simplicity
--  of Lua timers and closures. Typically, programming a timed-spell in Lua is as
--  simple as starting a timer and capturing some locals during the run:
--  ```
--  local caster = GetTriggerUnit()
--  TimerStart(CreateTimer(), 0.03, true, function ()
--      // ... do stuff with `caster`
--  end)
--  ```
--
--  However, since GUI cannot easily work with local variables, they often
--  have to resort to dynamic indexing or hashtables to track their state over
--  time, which becomes cumbersome very quickly.
--
--  This system provides a simpler alternative:
--    1. Give the system a list of global variables to "capture" with the timer
--    2. Start the timer (via Custom Script)
--    3. Use your globals as you normally would
--
--  This drastically reduces the amount of triggers and variables needed to
--  write timed spells in GUI.
--
--  Here is a sample that drains 2 life from a target every 0.1 seconds:
--  ```
--  SiphonLife
--    Events
--        Unit - A unit Starts the effect of an ability
--    Conditions
--        (Ability being cast) Equal to Siphon Life
--    Actions
--        -------- Set up your globals with the state you want to track --------
--        Set VariableSet Caster = (Triggering unit)
--        Set VariableSet Target = (Target unit of ability being cast)
--        Set VariableSet ElapsedTime = 0.00
--        -------- Put those variable names in a list ("udg_" prefix is optional) --------
--        Custom script:   local captureList = { "Caster", "Target", "ElapsedTime" }
--        -------- Start a periodic timer that runs every 0.1 seconds --------
--        Custom script:   CaptureTimer.StartPeriodic(captureList, 0.1, function ()
--        -------- Execute your periodic spell logic between the `function ()` and `end)` lines --------
--        Unit - Cause Caster to damage Target, dealing 2.00 damage of attack type Spells and damage type Normal
--        Unit - Set life of Caster to ((Life of Caster) + 2.00)
--        Set VariableSet ElapsedTime = (ElapsedTime + 0.10)
--        -------- Once your spell is complete, run EndPeriodicLoop --------
--        If (All Conditions are True) then do (Then Actions) else do (Else Actions)
--            If - Conditions
--                ElapsedTime Greater than or equal to 3.00
--            Then - Actions
--                Custom script:   CaptureTimer.EndPeriodic()
--            Else - Actions
--     Custom script:   end)
--  ```
--
--  This works by "capturing" the values of the globals when `StartPeriodic` is called.
--  Once the timer runs, those globals will be re-assigned to that snapshot.
--  If you modify those globals between the `function()` and `end)` lines, those values
--  will be saved and usable on the next tick (e.g. "ElapsedTime").
--
-------------------------------------------------------------------------------
--
--  API
--  ===
--
--
--  `CaptureTimer.StartPeriodic(captureList: string[], period: number, fn: fun(delegateId: number))`
--      - Starts a new timer that runs every `period` seconds, capturing the list of globals
--        provided in `captureList`. The timer will continue to run until
--        `CaptureTimer.EndPeriodic()` is called.
--
--  `CaptureTimer.StartPeriodicPooled(captureList: string[], period: number, fn: fun(delegateId: number))`
--      - Starts a timer from an existing timer pool where possible, running every `period`
--        seconds, capturing the list of globals provided in `captureList`. The timer will
--        continue to run until `CaptureTimer.EndPeriodic()` is called. This function is
--        useful for performance-sensitive situations, particularly for high frequency timers
--        (e.g. periods of 0.1 or less), as it is more efficient to run one timer every X
--        seconds than to have many timers running every X seconds.
--
--  `CaptureTimer.Delay(captureList: string[], period: number, fn: fun(delegateId: number))`
--      - Starts a new timer than runs once, calling `fn` after `period` seconds, capturing
--        the list of globals provided in `captureList`. This timer will clean itself up
--        automatically after it fires.
--
--  `CaptureTimer.EndPeriodic(delegateId: number?)`
--      - Stops a periodic timer and cleans up any state associated with it.
--        For most cases, you can call this function with no parameters and it will infer
--        which delegate to remove based off `GetExpiredTimer()` and the last delegate that
--        was executed. If, for some reason though, you need to clean-up the timer outside
--        the timer callback (e.g. to "cancel" a spell externally), then you can pass in
--        a specific integer ID to clean-up.
--
-------------------------------------------------------------------------------
--
--  Usage
--  =====
--
--  See the sample map for detailed usage examples.
--
--  Capture Lists
--  =============
--
--  When creating a capture list, write the variable names exactly as they are written
--  within the editor (spelling and capitlization must match). For example:
--  `local captureList = { "Caster", "Target", "ElapsedTime" }`
--
--  ^This would capture the variables "Caster", "Target", and "ElapsedTime". If you
--  have a typo, it will warn you (unless `WARN_FOR_UNDEFINED_GLOBALS` is false).
--
--  Arrays
--  ======
--
--  Capturing global arrays is possible, but you must format your capture string
--  with the following pattern:
--      variableName[startIndex...endIndex]
--
--  For example:
--  `local captureList = { "Caster", "Target", "Missile[1...8]" }`
--
--  ^This would capture the variables "Caster", "Target", and every value from
--  Missile[1] to Missile[8]. It is also possible to capture multiple slices
--  of an array, or a single value from an array:
--
--  Multiple slices: `{ "Missile[1...8]", "Missile[10...12]" }`
--  Single value: `{ "Missile[1...1]" }`
--
--  Do's & Don'ts:
--  ==============
--
--    + Using unique globals per trigger is not required, but recommended.
--      A lot of spells will trigger other events to occur (e.g. unit takes damage),
--      and if those globals get re-used for that trigger, then they may get
--      overwritten and cause some hard-to-track bugs (this isn't really an issue
--      with this system though--this applies to global variables in general).
--    + Using `StartPeriodicPooled` is useful for performance-sensitive situations
--      for high-frequency timers (0.1 or lower). This way, if two or more timers
--      are started with the same period, a single timer will be used for performance.
--      However, this causes those timers to tick at the same time--which can be
--      particularly noticeable for higher periods (> 0.1). This also may cause
--      the first tick of the timer to wait LESS than the period (since the timer
--      is already running), so if you need to wait a precise amount of time, use
--      `StartPeriodic` or `Delay`.
--    + Importing `DebugUtils` (and `IngameConsole`) is recommended so that you
--      can be notified of any exceptions in Lua (e.g. due to typos).
--    + `WARN_FOR_UNDEFINED_GLOBALS` is recommended for debugging, but it will add
--      slight overhead due to having to scan the global table occasionally.
--    - This system is less ideal for capturing state that needs to travel
--      across thread boundaries (e.g. triggers). For example, if you have a spell
--      that applies some timed-buff with some data attached to it, and then you
--      want to work with that data when that unit takes damage, then you're better
--      off using hashtables.
--    - Do not use "waits" inside timer callbacks. Instead, use `Delay` if needed.
--    - Calling `CaptureTimer.EndPeriodic()` outside of the `function()` and `end)`
--      will likely not work. If you do need to end a timer externally (e.g. due
--      to a spell being interrupted/turned off), then you can do the following:
--        1. Update `StartPeriodic(...)` to have `function(delegateId)` instead
--           of `function()`. This will allow you to access the unique integer ID
--           for that timer delegate instance.
--        2. Inside the callback (between `function()` and `end)`), store that
--           delegateId somewhere persistent, e.g. in a custom value or hashtable.
--        3. In your other trigger that needs to stop the timer, load that
--           delegateId and call `CaptureTimer.EndPeriodic(delegateId)`.
--
-------------------------------------------------------------------------------

do
    ---------------------------------------------------------------------------
    --
    --   Configuration
    --
    ---------------------------------------------------------------------------
 
    ---If true, providing an invalid global name will log an error.
    local WARN_FOR_UNDEFINED_GLOBALS = true

    ---------------------------------------------------------------------------
    --
    --   Utilities
    --
    ---------------------------------------------------------------------------

    local printError = Debug and Debug.throwError or print
    local errorPrefix = "[CaptureTimerError] "

    ---Returns true if `a` and `b` are equal within a threshold `epsilon`.
    ---@param a number first number to compare
    ---@param b number second number to compare
    ---@param epsilon? number defaults to 1e-9
    ---@return boolean
    local function approximatelyEqual(a, b, epsilon)
        epsilon = epsilon or 1e-9
        return math.abs(a - b) < epsilon
    end

    ---Returns true if the string starts with the given prefix
    ---@param str string
    ---@param prefix string
    ---@return boolean
    local function hasPrefix(str, prefix)
        return string.sub(str, 1, #prefix) == prefix
    end

    local globalMap = {}        ---@type table<string, boolean>
    local globalsLoaded = false

    ---Loads all user-defined globals into the global map for existence checks.
    local function loadGlobals()
        if globalsLoaded then
            return
        end

        for name, _ in pairs(_G) do
            if hasPrefix(name, "udg_") then
                globalMap[name] = true
            end
        end

        globalsLoaded = true
    end

    ---Attempts to retry loading an individual global to check its existence.
    ---This is necessary for cases where certain globals are lazy-loaded upon
    ---first accessing them.
    ---@param globalName string
    ---@return boolean
    local function retryLoadGlobal(globalName)
        for name, _ in pairs(_G) do
            if name == globalName then
                globalMap[name] = true
                return true
            end
        end

        return false
    end

    ---Parses a variable representing an array slice, e.g. `myArray[1...16]`.
    ---If successful, it'll return the name, min index, and max index.
    ---If it fails, it'll return `nil` for each field.
    ---@param str string
    ---@return string?, number?, number?
    local function parseArrayComponents(str)
        -- Pattern: (name)[(min)...(max)]
        local name, minStr, maxStr = str:match("^([\x25w_]+)\x25[(\x25d+)\x25.\x25.\x25.(\x25d+)\x25]$")
        if name and minStr and maxStr then
            return name, tonumber(minStr), tonumber(maxStr)
        end
        return nil, nil, nil
    end

    ---Parses a variable to capture and modifies the state table
    ---to store information about that variable.
    ---@param input string variable name or array pattern (udg_ optional)
    ---@param state table<string, any>
    local function parseCaptureListInput(input, state)
        -- Add `udg_` prefix if it is missing
        if not hasPrefix(input, "udg_") then
            input = "udg_" .. input
        end

        local arrayName, arrayMinIndex, arrayMaxIndex = parseArrayComponents(input)
        local variableName = arrayName or input

        if WARN_FOR_UNDEFINED_GLOBALS then
            loadGlobals()
            if globalMap[variableName] == nil then
                --Attempt to reload that global if the global was lazily entered into `_G`
                if not retryLoadGlobal(variableName) then
                    printError(errorPrefix .. "Attempted to capture variable that does not exist: `" .. variableName .. "`. Please double check the spelling and capitalization. If those are correct, try assigning an initial value to the variable prior to capture.")
                    return
                end
            end
        end

        -- If this variable matches the array pattern
        if arrayName ~= nil and arrayMinIndex ~= nil and arrayMaxIndex ~= nil then
            local array = _G[variableName]
            if type(array) ~= "table" then
                printError(errorPrefix .. "Failed to capture array variable: `" .. input .. "`.")
                return
            end

            if arrayMinIndex > arrayMaxIndex or arrayMinIndex < 0 or arrayMaxIndex < 0 then
                printError(errorPrefix .. "Invalid index range: `" .. input .. "`.")
                return
            end

            state[input] = {
                variableName = variableName,
                isArray = true,
                minIndex = arrayMinIndex,
                maxIndex = arrayMaxIndex
            }

            -- Store every value in the array from minIndex ... maxIndex
            state[input].values = setmetatable({}, { __mode = "v" })
            for index = arrayMinIndex, arrayMaxIndex do
                state[input].values[index] = array[index]
            end
        else
            state[input] = setmetatable({
                variableName = variableName,
                isArray = false,
                value = _G[variableName]
            }, { __mode = "v" })
        end
    end

    ---------------------------------------------------------------------------
    --
    --   Alloc (for unique id generation)
    --
    ---------------------------------------------------------------------------

    ---@class Alloc
    local Alloc = {
        instanceCount = 0,  ---@type number
        free = {},          ---@type number[]
    }

    ---Returns an unused integer as an identifier.
    ---@return number
    function Alloc:allocate()
        if #self.free > 0 then
            local instance = self.free[#self.free]
            self.free[#self.free] = nil
            return instance
        end

        self.instanceCount = self.instanceCount + 1
        return self.instanceCount
    end

    ---Marks an identifier as free for use by new instances.
    ---@param instance number
    function Alloc:deallocate(instance)
        table.insert(self.free, instance)
    end

    ---------------------------------------------------------------------------
    --
    --   TimerDelegate
    --
    ---------------------------------------------------------------------------

    local timers = {}               ---@type table<timer, TimerInstance>
    local timersByDelegateId = {}   ---@type table<number, TimerInstance>

    ---Represents a timer callback. This class is responsible for
    ---updating global values prior to invoking the provided `fn`.
    ---@class TimerDelegate
    ---@field id number
    ---@field delegate? function
    local TimerDelegate = {}
    TimerDelegate.__index = TimerDelegate

    ---Creates a timer delegate with a list of global variable names
    ---to capture and update prior to invoking `fn`.
    ---@param captureList string[]
    ---@param fn function
    ---@return TimerDelegate
    function TimerDelegate.create(captureList, fn)
        local new = { id = Alloc:allocate() }
        setmetatable(new, TimerDelegate)

        if WARN_FOR_UNDEFINED_GLOBALS then
            loadGlobals()
        end

        local state = {}
        for _, variableName in ipairs(captureList) do
            parseCaptureListInput(variableName, state)
        end

        new.delegate = function ()
            -- Assign the global variables to the last captured values
            for _, info in pairs(state) do
                if info.isArray then
                    for index = info.minIndex, info.maxIndex do
                        _G[info.variableName][index] = info.values[index]
                    end
                else
                    _G[info.variableName] = info.value
                end
            end

            fn(new)

            -- Capture the latest global values into our stored state (if we are still valid)
            if new.id ~= 0 then
                for key, info in pairs(state) do
                    if info.isArray then
                        for index = info.minIndex, info.maxIndex do
                            info.values[index] = _G[info.variableName][index]
                        end
                    else
                        info.value = _G[info.variableName]
                    end
                end
            end
        end

        return new
    end

    ---Clears any state related to this delegate.
    function TimerDelegate:destroy()
        if self.id == 0 then
            return
        end

        timersByDelegateId[self.id] = nil
        Alloc:deallocate(self.id)
        self.id = 0
    end

    ---------------------------------------------------------------------------
    --
    --   TimerInstance
    --
    ---------------------------------------------------------------------------

    ---Represents the data associated with a single game timer.
    ---If pooling is enabled, this game timer can multi-cast to
    ---multiple delegate functions (for performance).
    ---@class TimerInstance
    ---@field id number
    ---@field timer timer
    ---@field period number
    ---@field periodic boolean
    ---@field delegates TimerDelegate[]
    ---@field allowPooling boolean
    ---@field started boolean
    ---@field lastDelegateRan? TimerDelegate
    local TimerInstance = {}
    TimerInstance.__index = TimerInstance

    ---@param period number interval for the timer in seconds
    ---@param periodic boolean if `true`, this timer will run periodically
    ---@param allowPooling any enables multiple delegates to be run via a single timer
    ---@return TimerInstance
    function TimerInstance.create(period, periodic, allowPooling)
        local new = {
            id = Alloc:allocate(),
            timer = CreateTimer(),
            period = period,
            periodic = periodic,
            delegates = {},
            allowPooling = allowPooling,
            started = false,
            lastDelegateRan = nil
        }
        setmetatable(new, TimerInstance)
        timers[new.timer] = new
        return new
    end

    ---Attempts to return an active timer from the timer pool (if `allowPooling` is true),
    ---otherwise returns a new timer instance.
    ---@param period number
    ---@param periodic boolean
    ---@param allowPooling boolean
    ---@return TimerInstance
    function TimerInstance.get(period, periodic, allowPooling)
        if not allowPooling then
            return TimerInstance.create(period, periodic, false)
        end

        for _, instance in pairs(timers) do
            if approximatelyEqual(instance.period, period) and instance.periodic == periodic and instance.allowPooling then
                return instance
            end
        end
        return TimerInstance.create(period, periodic, allowPooling)
    end

    ---Adds a delegate to be called whenever the timer fires.
    ---@param delegate TimerDelegate
    function TimerInstance:addDelegate(delegate)
        table.insert(self.delegates, delegate)
        timersByDelegateId[delegate.id] = self
    end

    ---Removes a delegate by its ID. If no delegates remain after
    ---this call, then the timer instance will be destroyed.
    ---@param id number
    function TimerInstance:removeDelegateById(id)
        for index, delegate in ipairs(self.delegates) do
            if delegate.id == id then
                delegate:destroy()
                table.remove(self.delegates, index)
                break
            end
        end

        if #self.delegates == 0 then
            self:destroy()
        end
    end

    ---Convenience method to remove a specific delegate.
    ---@param delegate TimerDelegate
    function TimerInstance:removeDelegate(delegate)
        self:removeDelegateById(delegate.id)
    end

    ---Starts the timer (if not started), calling each delegate
    ---as it fires. If the timer is not periodic, the delegate will
    ---automatically be removed after the timer fires.
    function TimerInstance:start()
        if self.started then
            return
        end

        TimerStart(self.timer, self.period, self.periodic, function ()
            for i = #self.delegates, 1, -1 do
                local delegate = self.delegates[i]
           
                self.lastDelegateRan = delegate
                delegate.delegate()

                if not self.periodic then
                    self:removeDelegate(delegate)
                end
            end
        end)
    end

    ---Clears any state associated with this timer instance.
    function TimerInstance:destroy()
        if self.timer == nil then
            return
        end

        timers[self.timer] = nil
        DestroyTimer(self.timer)
        Alloc:deallocate(self.id)
        self.timer = nil
        self.started = false
    end

    ---Convenience method to remove a specific timer delegate (by id),
    ---or to remove the last delegate that was executed for the expired timer.
    ---@param delegateId number
    function TimerInstance.endPeriodic(delegateId)
        if type(delegateId) == "number" then
            local instance = timersByDelegateId[delegateId]
            if instance ~= nil then
                instance:removeDelegateById(delegateId)
            else
                printError(errorPrefix .. "Attempted to remove timer delegate that does not exist, id:" .. delegateId)
            end
        else
            local instance = timers[GetExpiredTimer()]
            if instance ~= nil then
                if instance.lastDelegateRan ~= nil then
                    instance:removeDelegate(instance.lastDelegateRan)
                else
                    printError(errorPrefix .. "Failed to remove delegate.")
                end
            else
                printError(errorPrefix .. "Failed to find timer to remove. This may occur when `EndPeriodic` is called outside of a timer callback or when it is incorrectly called twice for the same timer.")
            end
        end
    end

    ---------------------------------------------------------------------------
    --
    --   CaptureTimer (public API)
    --
    ---------------------------------------------------------------------------

    CaptureTimer = {
        ---Runs the input function periodically, capturing the provided global variables.
        ---The `captureList` should be an array of global variable names as strings. Including
        ---`udg_` in the prefix of each variable name is optional.
        ---See the system description for more information on "capturing".
        ---This timer will continue to run until `EndPeriodic` is called.
        ---@param captureList string[] list of global variable names to capture (case-sensitive)
        ---@param period number timer interval
        ---@param fn fun(delegateId: number) function to call each time the timer fires. a delegate id
        ---is provided that can be passed into `EndPeriodic` for special cases.
        StartPeriodic = function (captureList, period, fn)
            local instance = TimerInstance.get(period, true, false)
            local delegate = TimerDelegate.create(captureList, fn)
            instance:addDelegate(delegate)
            instance:start()
        end,

        ---Runs the input function periodically, capturing the provided global variables.
        ---This variant uses a timer pool when possible to minimize the number of active game timers.
        ---The `captureList` should be an array of global variable names as strings. Including
        ---`udg_` in the prefix of each variable name is optional.
        ---See the system description for more information on "capturing".
        ---This timer will continue to run until `EndPeriodic` is called.
        ---@param captureList string[] list of global variable names to capture (case-sensitive)
        ---@param period number timer interval
        ---@param fn fun(delegateId: number) function to call each time the timer fires. a delegate id
        ---is provided that can be passed into `EndPeriodic` for special cases.
        StartPeriodicPooled = function (captureList, period, fn)
            local instance = TimerInstance.get(period, true, true)
            local delegate = TimerDelegate.create(captureList, fn)
            instance:addDelegate(delegate)
            instance:start()
        end,

        ---Runs the input function after a delay (once), capturing the provided global variables.
        ---The `captureList` should be an array of global variable names as strings. Including
        ---`udg_` in the prefix of each variable name is optional.
        ---See the system description for more information on "capturing".
        ---This timer will clean itself up automatically after firing.
        ---@param captureList string[] list of global variable names to capture (case-sensitive)
        ---@param period number timer interval
        ---@param fn fun(delegateId: number) function to call each time the timer fires.
        Delay = function (captureList, period, fn)
            local instance = TimerInstance.get(period, false, false)
            local delegate = TimerDelegate.create(captureList, fn)
            instance:addDelegate(delegate)
            instance:start()
        end,

        ---Stops a periodic timer and cleans up any state associated with it.
        ---For most cases, you can call this function with no parameters and it will infer
        ---which delegate to remove based off `GetExpiredTimer()` and the last delegate that
        ---was executed. If, for some reason though, you need to clean-up the timer outside
        ---the timer callback (e.g. to "cancel" a spell externally), then you can pass in
        ---a specific integer ID to clean-up.
        ---@param delegateId number? optional delegate id to clean-up
        EndPeriodic = function (delegateId)
            TimerInstance.endPeriodic(delegateId)
        end,
    }
end

if Debug and Debug.endFile then Debug.endFile() end
Contents

CaptureTimer (Map)

This basically sets Lua as the superior language for GUI support now. I can basically ditch all the enormous requirement from TSELL or TSE chain and make my spell like breathing Wait at a whim :D
haha thank you! I actually ended up writing this system because I felt like it was so hard to give people simple GUI examples of things like "how to destroy an effect after X seconds" without resorting to a bunch of triggers and variables that would leave them confused. I'm sure you probably felt that way too trying to implement a linked list in GUI. :grin:

That being said, I still think separate systems for things like timed effects are super useful since it has a lot of the configurability that GUI-users would love, but perhaps this system would allow the code to be trimmed down quite a bit!

On the note of trimming down--this system itself was actually surprisingly tiny in its first form, maybe like 50 lines lol. But once I decided to add support for timer pooling, arrays, and warnings--it got a lot longer. :ugly: But I'm pretty happy with the feature set it landed on.

Definitely consider trying it for any of your future spells--I'd love to get feedback on whether there's any quirks/issues that make it hard to work with :)
 
Back
Top