- Joined
- Jan 30, 2013
- Messages
- 12,870
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 
| CaptureTimer v1.0.0 |
Requirements
|
| 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:
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:
Here is a sample that drains 2 life from a target every 0.1 seconds:
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 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:
|
Features
|
Installation
|
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