--[[
================================================================================
TOASTS — Toast notification system (Warcraft III, Lua)
Blz Frame API (Reforged 1.31+), no third-party frameworks.
Project: Divine Roguelike
https://www.hiveworkshop.com/threads/346860/
Discord: https://discord.com/invite/Y2zQ8Eqakg
Game design: Philipp Kruglyakov
Load order: ToastsConfig.lua → Toasts.lua
SYNC
----
Call Toasts.show() only from synchronous context on all clients.
GetLocalPlayer() is used for rendering and sounds only.
waitKey / minShowInterval depend on game state — pass identical options everywhere.
LIFETIME
--------
duration / fade start when the toast is actually shown:
activateToast, tryPromotePending, or replace of an active toast (not while pending).
showSound on promote/activate; updateSound on visible replace (not waitKey pending).
Toasts.show() OPTIONS
---------------------
icon, title, text — at least one required (or accumulatedValue for accumulation styles).
style — name from ToastsConfig.styles (default: "default").
key — tracker id; same key + replace updates in place.
replace — merge/update toast with same key (style can set default).
waitKey — queue until active toast with same key expires.
waitKeyFade — with waitKey: promote when previous toast starts fading.
waitKeyFade+replace — swap content in place (pulse), no entry animation.
replace+waitKey — updates pending entry only, not the active toast.
duration — visible seconds before fade (style default, options override).
players — player or { players } list; default: triggering player.
progressCurrent, progressMax — show progress bar per style.progressBar.
accumulatedValue — for isAccumulation styles; summed on replace with same key.
textValueFunction(toast), textColorFunction(toast) — override style functions.
toastLogicSize — weight toward maxVisibleToasts capacity (style/options).
CAPACITY
--------
maxVisibleToasts is logical capacity (not toast count).
toastLogicSize 1.0 = normal; 0.5 allows ~10 toasts in a column of capacity 5.
ACCUMULATION
------------
style.isAccumulation: accumulatedValue adds on replace with same key.
textValueFunction / textColorFunction — in style or options (options win).
UI
--
Default: human-tooltip-background.blp + BlzFrameSetAlpha.
RTL layout: icon right, text right-aligned, progress fills right-to-left.
Progress: solid / segmented (SIMPLESTATUSBAR); optional progressBar="timer".
displayProgress, entry, pulse — frame-lerp (not gameTime).
showSound pool: soundPoolSize instances per path for overlapping playback.
API
---
Toasts.show(options) → success, plays sound locally when applicable.
Toasts.diagnose() — check Blz Frame API availability.
Toasts.setDebug(bool) — verbose logging.
================================================================================
================================================================================
TOASTS — Система тост-уведомлений (Warcraft III, Lua)
Blz Frame API (Reforged 1.31+), без сторонних фреймворков.
Проект: Divine Roguelike
https://www.hiveworkshop.com/threads/346860/
Discord: https://discord.com/invite/Y2zQ8Eqakg
Геймдизайн: Филипп Кругляков (Philipp Kruglyakov)
Порядок загрузки: ToastsConfig.lua → Toasts.lua
СИНХРОНИЗАЦИЯ
-------------
Toasts.show() — только из синхронного контекста на всех клиентах.
GetLocalPlayer() — только отрисовка и звуки.
waitKey / minShowInterval — общее состояние; одинаковые options на всех клиентах.
ВРЕМЯ ЖИЗНИ
-----------
duration / fade стартуют при фактическом показе:
activateToast, tryPromotePending или replace активного (не в pending).
showSound — при promote/activate; updateSound — replace видимого (не pending waitKey).
ПАРАМЕТРЫ Toasts.show()
-----------------------
icon, title, text — нужен хотя бы один (или accumulatedValue для accumulation).
style — имя из ToastsConfig.styles (по умолчанию "default").
key — id трекера; тот же key + replace обновляет на месте.
replace — обновить тост с тем же key (можно задать в стиле).
waitKey — ждать исчезновения активного тоста с тем же key.
waitKeyFade — с waitKey: показать, когда предыдущий начнёт fade.
waitKeyFade+replace — замена плашки на месте (pulse), без entry.
replace+waitKey — обновляет pending, не активный тост.
duration — секунды до fade (стиль по умолчанию, options переопределяют).
players — игрок или список; по умолчанию — вызывающий игрок.
progressCurrent, progressMax — прогресс-бар по style.progressBar.
accumulatedValue — для isAccumulation; суммируется при replace с тем же key.
textValueFunction(toast), textColorFunction(toast) — переопределение функций стиля.
toastLogicSize — вес в лимите maxVisibleToasts (стиль/options).
ЁМКОСТЬ
-------
maxVisibleToasts — логические единицы, не число тостов.
toastLogicSize 1.0 — обычный; 0.5 → до ~10 тостов при лимите 5.
АККУМУЛИРОВАНИЕ
---------------
style.isAccumulation: accumulatedValue суммируется при replace с тем же key.
textValueFunction / textColorFunction — в стиле или options (options важнее).
ИНТЕРФЕЙС
---------
По умолчанию: human-tooltip-background.blp + BlzFrameSetAlpha.
RTL: иконка справа, текст по правому краю, прогресс справа налево.
Прогресс: solid / segmented (SIMPLESTATUSBAR); опционально progressBar="timer".
displayProgress, entry, pulse — frame-lerp (не gameTime).
Пул showSound: soundPoolSize экземпляров на путь для наслоения.
API
---
Toasts.show(options) — успех; звук локально при необходимости.
Toasts.diagnose() — проверка Blz Frame API.
Toasts.setDebug(bool) — подробный лог.
================================================================================
]]
do
Toasts = Toasts or {}
function Toasts._log(msg, isError)
local text = "[Toasts] " .. tostring(msg)
print(text)
if type(BJDebugMsg) == "function" then
BJDebugMsg(text)
end
end
function Toasts._trace(msg)
Toasts._log("[trace] " .. tostring(msg))
end
if Toasts._loaded then
-- уже загружено
else
Toasts._loaded = true
Toasts._debug = false
local CFG = ToastsConfig or {}
local ORIGIN_FRAME_GAME_UI = ORIGIN_FRAME_GAME_UI or 7
local FRAMEPOINT_TOPLEFT = FRAMEPOINT_TOPLEFT or 0
local FRAMEPOINT_TOPRIGHT = FRAMEPOINT_TOPRIGHT or 2
local FRAMEPOINT_BOTTOMLEFT = FRAMEPOINT_BOTTOMLEFT or 6
local FRAMEPOINT_BOTTOMRIGHT = FRAMEPOINT_BOTTOMRIGHT or 8
local FRAME_CONTEXT = 0
local SCREEN_HEIGHT = 0.6
local SCREEN_CENTER_X = 0.4
local ALPHA_OPAQUE = 255
local ALPHA_TRANSPARENT = 0
local PLAYER_SLOT_PLAYING = PLAYER_SLOT_STATE_PLAYING or 1
local PROGRESS_BAR_SOLID = "solid"
local PROGRESS_BAR_SEGMENTED = "segmented"
local PROGRESS_BAR_TIMER = "timer"
local TEXT_JUSTIFY_MIDDLE = TEXT_JUSTIFY_MIDDLE or 1
local TEXT_JUSTIFY_LEFT = TEXT_JUSTIFY_LEFT or 0
local TEXT_JUSTIFY_RIGHT = TEXT_JUSTIFY_RIGHT or 2
local _widescreen = {
ready = false,
parent = 0,
anchor = 0,
timer = nil,
lastWidth = nil,
}
local _toastPool = {}
local _requestPool = {}
local _playerListScratch = {}
local _playerState = {}
local _frameSlots = nil
local _localUiReady = false
local _logicTicker = nil
local _renderTicker = nil
local _gameClock = nil
local _layoutDirty = false
local _soundPools = {}
local getAnchorFrame
local getUIParent
local recalcTargetY
local applyLocalLayout
local ensureLocalUI
local ensureTickers
local computeToastAlpha
local playStyleSound
local function log(msg, isError)
if isError then
Toasts._log(msg, true)
elseif Toasts._debug then
Toasts._log(msg, false)
end
end
local function cfg(key, fallback)
local v = CFG[key]
if v == nil then
return fallback
end
return v
end
local function hasBlzApi()
return type(BlzCreateFrameByType) == "function"
and type(BlzGetOriginFrame) == "function"
and type(BlzFrameSetPoint) == "function"
and type(BlzFrameSetSize) == "function"
and type(BlzFrameSetTexture) == "function"
and type(BlzFrameSetText) == "function"
and type(BlzFrameSetVisible) == "function"
and type(BlzFrameSetAlpha) == "function"
end
local function isValidFrame(frame)
return frame and frame ~= 0
end
local function getGameUI()
return BlzGetOriginFrame(ORIGIN_FRAME_GAME_UI, FRAME_CONTEXT)
end
local function resolveNamedFrame(name)
if name == nil or name == "" then
return 0
end
if type(BlzGetFrameByName) == "function" then
local frame = BlzGetFrameByName(name, FRAME_CONTEXT)
if isValidFrame(frame) then
return frame
end
end
return 0
end
local function getBaseUIParent()
local parentName = cfg("frameParent", "ConsoleUIBackdrop")
local named = resolveNamedFrame(parentName)
if isValidFrame(named) then
return named
end
if parentName ~= nil and parentName ~= "" and parentName ~= "GAME_UI" then
log("getBaseUIParent: frame '" .. tostring(parentName) .. "' not found, using GAME_UI", true)
end
return getGameUI()
end
local function getWidescreenWidth()
if type(BlzGetLocalClientWidth) == "function" and type(BlzGetLocalClientHeight) == "function" then
local clientW = BlzGetLocalClientWidth()
local clientH = BlzGetLocalClientHeight()
if clientW and clientH and clientH > 0 then
return clientW / clientH * SCREEN_HEIGHT
end
end
return 0.8
end
local function applyFrameLevel(frame)
if not isValidFrame(frame) or type(BlzFrameSetLevel) ~= "function" then
return
end
local level = cfg("frameLevel", 1)
if level ~= nil then
BlzFrameSetLevel(frame, level)
end
end
local function slotFrameName(slotId, suffix)
return ("Toasts_%d_%s"):format(slotId, suffix)
end
local function setFrameAlpha(frame, alpha)
if isValidFrame(frame) and type(BlzFrameSetAlpha) == "function" then
BlzFrameSetAlpha(frame, alpha)
end
end
local function setFrameScale(frame, scale)
if isValidFrame(frame) and type(BlzFrameSetScale) == "function" then
BlzFrameSetScale(frame, scale)
end
end
local function clamp01(value)
if value < 0 then
return 0
end
if value > 1 then
return 1
end
return value
end
local function easeOutCubic(t)
t = clamp01(t)
local inv = 1 - t
return 1 - inv * inv * inv
end
local function toastBaseY(toast)
return toast.targetY or cfg("screenMarginBottom", 0.16)
end
local function startEntryAnimation(toast)
if not cfg("entryAnimEnabled", true) then
toast.entryAnimating = false
toast.entryT = 1
toast.entryFromY = nil
toast.displayY = toast.targetY
return
end
toast.entryAnimating = true
toast.entryT = 0
toast.entryFromY = toastBaseY(toast) + cfg("entryAnimFromY", -0.08)
toast.displayY = toast.entryFromY
end
local function triggerUpdatePulse(toast)
if not cfg("updatePulseEnabled", true) then
return
end
toast.pulseActive = true
toast.pulseT = 0
end
local function computeEntryAlphaMult(toast)
if not cfg("entryFadeEnabled", true) or toast.entryAnimating ~= true then
return 1
end
return easeOutCubic(clamp01((toast.entryT or 0) / 0.55))
end
local function advanceEntryAnimation(toast)
if toast.entryAnimating ~= true then
return
end
local speed = cfg("entryAnimSpeed", 0.04)
local t = toast.entryT or 0
t = t + (1 - t) * speed
if t >= 0.999 then
t = 1
toast.entryAnimating = false
toast.entryFromY = nil
end
toast.entryT = t
local target = toastBaseY(toast)
local fromY = toast.entryFromY
if fromY == nil then
fromY = target + cfg("entryAnimFromY", -0.08)
end
toast.displayY = fromY + (target - fromY) * easeOutCubic(t)
end
local function cancelEntryForQueueLerp(toast)
if toast.entryAnimating ~= true then
return
end
toast.entryAnimating = false
toast.entryT = nil
toast.entryFromY = nil
end
local function advanceToastQueuePosition(toast, lerpSpeed)
if toast.entryAnimating == true then
return
end
local target = toastBaseY(toast)
local current = toast.displayY
if current == nil then
current = target
end
if math.abs(target - current) > 0.0001 then
current = current + (target - current) * lerpSpeed
else
current = target
end
toast.displayY = current
end
local function advanceUpdatePulse(toast)
if not cfg("updatePulseEnabled", true) or toast.pulseActive ~= true then
toast.pulseIconScale = 1
toast.pulseStrength = 0
return
end
local step = cfg("updatePulseStep", 0.032)
local phase = (toast.pulseT or 0) + step
if phase >= 1 then
toast.pulseActive = false
toast.pulseT = 0
toast.pulseIconScale = 1
toast.pulseStrength = 0
return
end
toast.pulseT = phase
local pulse = math.sin(phase * math.pi)
local scalePeak = cfg("updatePulseIconScale", 1.42)
toast.pulseIconScale = 1 + (scalePeak - 1) * pulse
toast.pulseStrength = pulse
end
local function advanceToastVisuals(toast, lerpSpeed)
advanceEntryAnimation(toast)
advanceToastQueuePosition(toast, lerpSpeed)
advanceUpdatePulse(toast)
end
local function setFrameSize(frame, width, height)
BlzFrameSetSize(frame, width, height, FRAME_CONTEXT)
end
local function setFramePoint(frame, point, relative, relativePoint, x, y)
BlzFrameClearAllPoints(frame)
BlzFrameSetPoint(frame, point, relative, relativePoint, x, y)
end
local function createFrame(frameType, name, parent, template)
local frame = BlzCreateFrameByType(frameType, name, parent, template or "", FRAME_CONTEXT)
if isValidFrame(frame) then
BlzFrameSetVisible(frame, false)
end
return frame
end
local function showFrame(frame)
if isValidFrame(frame) then
BlzFrameSetVisible(frame, true)
end
end
local function hideFrame(frame)
if isValidFrame(frame) then
BlzFrameSetVisible(frame, false)
end
end
local function enableFrame(frame, enabled)
if isValidFrame(frame) and type(BlzFrameSetEnable) == "function" then
BlzFrameSetEnable(frame, enabled)
end
end
local _panelFdfReady = false
local function scaleAlpha(baseAlpha, fadeAlpha)
local base = baseAlpha or ALPHA_OPAQUE
local fade = fadeAlpha or ALPHA_OPAQUE
return math.floor(base * fade / ALPHA_OPAQUE + 0.5)
end
local function resolvePanelBgAlpha(style, fadeAlpha)
style = style or {}
local base = style.panelBgAlpha
if base == nil then
local transparency = cfg("panelBgTransparency", nil)
if transparency ~= nil then
base = math.floor(ALPHA_OPAQUE * (1 - transparency) + 0.5)
else
base = cfg("panelBgAlpha", 51)
end
end
if fadeAlpha ~= nil then
return scaleAlpha(base, fadeAlpha)
end
return base
end
local function resolvePanelTexture(style)
style = style or {}
if cfg("useImportedTextures", false) then
return style.panelTexture or cfg("importedPanelTexture") or cfg("panelTexture", "")
end
return style.panelTexture or cfg("panelTexture", "UI\\Widgets\\ToolTips\\Human\\human-tooltip-background.blp")
end
local function resolveProgressTrackAlpha(style, fadeAlpha)
style = style or {}
local base = style.progressTrackAlpha or cfg("progressTrackAlpha", 35)
if fadeAlpha ~= nil then
return scaleAlpha(base, fadeAlpha)
end
return base
end
local function resolveProgressFillAlpha(style, fadeAlpha)
style = style or {}
local base = style.progressFillAlpha or cfg("progressFillAlpha", 155)
if fadeAlpha ~= nil then
return scaleAlpha(base, fadeAlpha)
end
return base
end
local function segmentFillRatio(curVal, maxVal, segId, segCount)
if segCount < 1 then
return 0
end
local unitsPerSeg = maxVal / segCount
if unitsPerSeg <= 0 then
return 0
end
local segStart = (segId - 1) * unitsPerSeg
local segEnd = segId * unitsPerSeg
if curVal <= segStart then
return 0
end
if curVal >= segEnd then
return 1
end
return (curVal - segStart) / unitsPerSeg
end
local function bindAllPoints(child, parent)
if isValidFrame(child) and isValidFrame(parent) and type(BlzFrameSetAllPoints) == "function" then
BlzFrameSetAllPoints(child, parent)
end
end
local function ensurePanelFdf()
if _panelFdfReady then
return true
end
if not cfg("useSimplePanel", false) then
return false
end
if type(BlzLoadTOCFile) ~= "function" then
return false
end
local toc = cfg("panelTocFile", "war3mapImported\\Toasts\\Toasts.toc")
local ok, err = pcall(BlzLoadTOCFile, toc)
if not ok then
log("ensurePanelFdf: " .. tostring(err), true)
return false
end
_panelFdfReady = true
return true
end
local function setFrameTexture(frame, texturePath, useBlend)
if not isValidFrame(frame) or type(BlzFrameSetTexture) ~= "function" then
return
end
if useBlend == nil then
useBlend = cfg("panelTextureBlend", true)
end
if useBlend then
BlzFrameSetTexture(frame, texturePath or "", 0, true)
else
BlzFrameSetTexture(frame, texturePath or "", 0)
end
end
local function applyVertexColorToFrame(frame, tint)
if not isValidFrame(frame) or type(BlzFrameSetVertexColor) ~= "function" then
return false
end
if tint == nil then
return false
end
local r = tint.r or 255
local g = tint.g or 255
local b = tint.b or 255
local a = tint.a or cfg("panelBgAlpha", 235)
if type(BlzConvertColor) == "function" then
BlzFrameSetVertexColor(frame, BlzConvertColor(a, r, g, b))
else
BlzFrameSetVertexColor(frame, r, g, b, a)
end
return true
end
local function resolvePanelTint(style)
if not cfg("usePanelTint", false) then
return nil
end
if type(style) == "table" and style.panelTint ~= nil then
return style.panelTint
end
return cfg("panelTint", nil)
end
local function applyPanelStyle(frame, style, fadeAlpha, useSimpleTexture)
if not isValidFrame(frame) then
return
end
style = style or {}
local alpha = resolvePanelBgAlpha(style, fadeAlpha)
if useSimpleTexture then
local texture = style.panelTextureSimple or cfg("importedPanelTextureSimple")
or resolvePanelTexture(style)
setFrameTexture(frame, texture, style.panelTextureBlend)
local tint = resolvePanelTint(style) or cfg("panelTint", nil)
local c = tint or { r = 24, g = 32, b = 48, a = alpha }
applyVertexColorToFrame(frame, {
r = c.r,
g = c.g,
b = c.b,
a = alpha,
})
return
end
setFrameTexture(
frame,
resolvePanelTexture(style),
style.panelTextureBlend ~= nil and style.panelTextureBlend or cfg("panelTextureBlend", false)
)
setFrameAlpha(frame, alpha)
end
local function applyProgressTrackStyle(frame, style)
if not isValidFrame(frame) then
return
end
style = style or {}
local texture = style.progressTrackTexture or cfg("progressTrackTexture", "")
if texture == "" then
texture = style.progressFillTexture or cfg("progressFillTexture", "")
end
setFrameTexture(frame, texture, cfg("progressTextureBlend", false))
end
local function initBarValueRange(bar, initialValue)
if not isValidFrame(bar) then
return
end
local valueMax = cfg("progressValueMax", 100)
if type(BlzFrameSetMinMaxValue) == "function" then
BlzFrameSetMinMaxValue(bar, 0, valueMax)
end
if type(BlzFrameSetValue) == "function" then
BlzFrameSetValue(bar, initialValue or 0)
end
enableFrame(bar, false)
end
local function setupProgressTimerBar(bar)
if not isValidFrame(bar) then
return
end
if type(BlzFrameSetModel) == "function" then
BlzFrameSetModel(
bar,
cfg("progressTimerModel", "UI\\Feedback\\ProgressBar\\TimerBar.mdx"),
0
)
end
if type(BlzFrameSetScale) == "function" then
BlzFrameSetScale(bar, cfg("progressTimerScale", 1.0))
end
initBarValueRange(bar, 0)
end
local function progressTimerValue(ratio)
local valueMax = cfg("progressValueMax", 100)
ratio = math.max(0, math.min(1, ratio or 0))
if cfg("progressTimerInvert", false) then
return (1 - ratio) * valueMax
end
return ratio * valueMax
end
local function applyProgressFillStyle(frame, style)
if not isValidFrame(frame) then
return
end
style = style or {}
local texture = style.progressFillTexture or cfg("progressFillTexture", "")
setFrameTexture(frame, texture, cfg("progressTextureBlend", false))
end
local function setTextAlignment(frame, alignRight)
if not isValidFrame(frame) or type(BlzFrameSetTextAlignment) ~= "function" then
return
end
if alignRight == nil then
alignRight = cfg("textAlignRight", true)
end
local hAlign = alignRight and TEXT_JUSTIFY_RIGHT or TEXT_JUSTIFY_LEFT
BlzFrameSetTextAlignment(frame, TEXT_JUSTIFY_MIDDLE, hAlign)
end
local function createText(parent, name, text, width, height, template)
local f = createFrame("TEXT", name, parent, template or "SystemNormalTextOutline")
if not isValidFrame(f) then
f = createFrame("TEXT", name, parent, "StandardSmallTextTemplate")
end
if not isValidFrame(f) then
return 0
end
BlzFrameSetText(f, text or "")
setFrameSize(f, width, height)
setFrameAlpha(f, ALPHA_OPAQUE)
setTextAlignment(f)
showFrame(f)
return f
end
local function updateWidescreenAnchorFrame()
if not isValidFrame(_widescreen.anchor) then
return
end
local width = getWidescreenWidth()
_widescreen.lastWidth = width
setFrameSize(_widescreen.anchor, width, SCREEN_HEIGHT)
if type(BlzFrameSetAbsPoint) == "function" then
BlzFrameClearAllPoints(_widescreen.anchor)
BlzFrameSetAbsPoint(_widescreen.anchor, FRAMEPOINT_BOTTOM, SCREEN_CENTER_X, 0.0)
end
end
local function refreshToastAnchors()
if cfg("useWidescreenAnchor", true) then
updateWidescreenAnchorFrame()
end
_layoutDirty = true
end
local function startWidescreenTimer()
if _widescreen.timer ~= nil then
return
end
if type(CreateTimer) ~= "function" or type(TimerStart) ~= "function" then
return
end
local interval = cfg("widescreenUpdateInterval", 0.25)
if interval <= 0 then
return
end
_widescreen.timer = CreateTimer()
TimerStart(_widescreen.timer, interval, true, function()
if not cfg("useWidescreenAnchor", true) then
return
end
local width = getWidescreenWidth()
if _widescreen.lastWidth ~= width then
refreshToastAnchors()
end
end)
end
local function ensureWidescreenAnchor()
if not cfg("useWidescreenAnchor", true) then
return false
end
if not _widescreen.ready then
local base = getBaseUIParent()
if not isValidFrame(base) then
return false
end
local parent = createFrame("FRAME", "Toasts_FullScreenParent", base, "")
if not isValidFrame(parent) then
return false
end
applyFrameLevel(parent)
local anchor = createFrame("FRAME", "Toasts_FullScreenAnchor", parent, "")
if not isValidFrame(anchor) then
if type(BlzDestroyFrame) == "function" then
BlzDestroyFrame(parent)
end
return false
end
hideFrame(anchor)
enableFrame(anchor, false)
_widescreen.parent = parent
_widescreen.anchor = anchor
_widescreen.ready = true
showFrame(parent)
updateWidescreenAnchorFrame()
startWidescreenTimer()
end
return isValidFrame(_widescreen.anchor)
end
getAnchorFrame = function()
if ensureWidescreenAnchor() then
return _widescreen.anchor
end
return getGameUI()
end
getUIParent = function()
if ensureWidescreenAnchor() and isValidFrame(_widescreen.parent) then
return _widescreen.parent
end
return getBaseUIParent()
end
local function gameTime()
if _gameClock == nil then
if type(CreateTimer) ~= "function" or type(TimerStart) ~= "function" then
return 0
end
_gameClock = CreateTimer()
TimerStart(_gameClock, 10000000, false, function() end)
end
if type(TimerGetElapsed) == "function" then
return TimerGetElapsed(_gameClock)
end
return 0
end
local function playerId(player)
if type(GetPlayerId) == "function" and player ~= nil then
return GetPlayerId(player)
end
return 0
end
local function localPlayerId()
if type(GetLocalPlayer) == "function" then
return playerId(GetLocalPlayer())
end
return 0
end
local function isPlayerHandle(value)
if value == nil then
return false
end
if type(GetPlayerId) ~= "function" then
return false
end
local ok, pid = pcall(GetPlayerId, value)
return ok and pid ~= nil and pid >= 0
end
local function clearScratchList(list)
for i = #list, 1, -1 do
list[i] = nil
end
end
local function resolvePlayers(target)
clearScratchList(_playerListScratch)
if target == nil then
local maxPlayers = cfg("maxPlayers", 12)
if type(GetPlayerSlotState) == "function" and type(Player) == "function" then
for pid = 0, maxPlayers - 1 do
local p = Player(pid)
if GetPlayerSlotState(p) == PLAYER_SLOT_PLAYING then
_playerListScratch[#_playerListScratch + 1] = p
end
end
end
return _playerListScratch
end
if type(target) == "table" then
return target
end
if isPlayerHandle(target) then
_playerListScratch[1] = target
return _playerListScratch
end
if type(ForForce) == "function" and type(GetEnumPlayer) == "function" then
ForForce(target, function()
_playerListScratch[#_playerListScratch + 1] = GetEnumPlayer()
end)
if #_playerListScratch > 0 then
return _playerListScratch
end
end
_playerListScratch[1] = target
return _playerListScratch
end
local LOGIC_SIZE_EPS = 0.001
local function resolveStyle(styleName)
local styles = cfg("styles", {})
local style = styles[styleName or "default"]
if style == nil then
style = styles.default or {}
end
return style
end
local function resolveIconSize(style)
style = style or {}
if style.iconSize ~= nil then
return style.iconSize
end
return cfg("iconSize", 0.032)
end
local function resolveToastPadding(style)
style = style or {}
if style.toastPadding ~= nil then
return style.toastPadding
end
return cfg("toastPadding", 0.005)
end
local function resolveToastMinHeight(style)
style = style or {}
if style.toastMinHeight ~= nil then
return style.toastMinHeight
end
return cfg("toastMinHeight", 0.042)
end
local function resolveIconTextGap(style)
style = style or {}
if style.iconTextGap ~= nil then
return style.iconTextGap
end
return cfg("iconTextGap", 0.004)
end
local function resolveTextLineHeight(style)
style = style or {}
if style.textHeight ~= nil then
return style.textHeight
end
return cfg("textLineHeight", 0.011)
end
local function resolveToastLogicSize(source)
if source == nil then
return tonumber(cfg("toastLogicSize", 1)) or 1
end
local size = tonumber(source.toastLogicSize)
if size ~= nil and size > 0 then
return size
end
local style = resolveStyle(source.style)
size = tonumber(style.toastLogicSize)
if size ~= nil and size > 0 then
return size
end
return tonumber(cfg("toastLogicSize", 1)) or 1
end
local function maxToastCapacity()
return tonumber(cfg("maxVisibleToasts", 5)) or 5
end
local function maxToastSlotCount()
local configured = cfg("maxToastSlots", nil)
if configured ~= nil then
return math.max(1, math.floor(tonumber(configured) or 1))
end
local minSize = tonumber(cfg("minToastLogicSize", 0.25)) or 0.25
if minSize <= 0 then
minSize = 0.25
end
return math.max(1, math.ceil(maxToastCapacity() / minSize - LOGIC_SIZE_EPS))
end
local function activeLogicLoad(state)
local sum = 0
for i = 1, #state.active do
sum = sum + (state.active[i].logicSize or resolveToastLogicSize(state.active[i]))
end
return sum
end
local function canAcceptToast(state, incomingSize)
incomingSize = incomingSize or 1
return activeLogicLoad(state) + incomingSize <= maxToastCapacity() + LOGIC_SIZE_EPS
end
local function parseAccumulatedValue(value)
if value == nil then
return 0
end
return tonumber(value) or 0
end
local function isAccumulationStyle(styleName)
local style = resolveStyle(styleName)
return style.isAccumulation == true
end
local function resolveReplaceFromOptions(options)
if options.replace ~= nil then
return options.replace == true
end
local style = resolveStyle(options.style)
return style.replace == true
end
local function resolveDurationFromOptions(options)
local duration = tonumber(options.duration)
if duration ~= nil then
return duration
end
local style = resolveStyle(options.style)
return tonumber(style.duration)
end
local function resolveTextColorFunction(record, style)
if type(record.textColorFunction) == "function" then
return record.textColorFunction
end
if type(style.textColorFunction) == "function" then
return style.textColorFunction
end
return nil
end
local function resolveTextValueFunction(record, style)
if type(record.textValueFunction) == "function" then
return record.textValueFunction
end
if type(style.textValueFunction) == "function" then
return style.textValueFunction
end
return nil
end
local function applyToastDisplayText(record, source, style)
style = style or resolveStyle(record.style)
record.textColorFunction = source.textColorFunction
record.textValueFunction = source.textValueFunction
local textFn = resolveTextValueFunction(record, style)
if textFn ~= nil then
local display = textFn(record)
record.text = display ~= nil and tostring(display) or ""
else
record.text = source.text
end
end
local function resolveBodyTextColor(toast, style)
local colorFn = resolveTextColorFunction(toast, style)
if colorFn ~= nil then
local color = colorFn(toast)
if color ~= nil and color ~= "" then
return color
end
end
return style.textColor
end
local function resolveToastWidth(style)
style = style or {}
if style.toastWidth ~= nil then
return style.toastWidth
end
return cfg("toastWidth", 0.22)
end
local function resolveTextCharsPerLine(style)
style = style or {}
if style.textCharsPerLine ~= nil then
return style.textCharsPerLine
end
local toastW = resolveToastWidth(style)
local baseW = cfg("toastWidth", 0.22)
local baseChars = cfg("textCharsPerLine", 28)
if baseW > 0 then
return math.max(8, math.floor(baseChars * toastW / baseW + 0.5))
end
return baseChars
end
local function plainTextLength(text)
return #tostring(text):gsub("|c%x%x%x%x%x%x%x", ""):gsub("|r", ""):gsub("\n", "")
end
local function estimateTextLines(text, style)
local charsPerLine = resolveTextCharsPerLine(style)
local lines = 1
for _ in tostring(text):gmatch("\n") do
lines = lines + 1
end
local plainLen = plainTextLength(text)
lines = math.max(lines, math.ceil(plainLen / charsPerLine))
return lines
end
local function colorizeText(text, color)
if text == nil or text == "" then
return ""
end
if tostring(text):match("|c%x%x%x%x%x%x%x") then
return tostring(text)
end
return tostring(color or "") .. tostring(text) .. "|r"
end
local function parseProgressValue(value)
if value == nil then
return nil
end
local n = tonumber(value)
if n == nil then
return nil
end
return n
end
local function hasProgressData(toast)
local maxVal = parseProgressValue(toast.progressMax)
local curVal = parseProgressValue(toast.progressCurrent)
return maxVal ~= nil and curVal ~= nil and maxVal > 0
end
local function resolveProgressBarType(style)
style = style or {}
local barType = style.progressBar
if barType == nil then
return nil
end
if barType == PROGRESS_BAR_TIMER and not cfg("progressUseTimerBar", false) then
return PROGRESS_BAR_SOLID
end
return barType
end
local function progressBarUsesSimpleFill(barType)
if not cfg("progressUseSimpleBar", true) then
return false
end
return barType == PROGRESS_BAR_SEGMENTED or barType == PROGRESS_BAR_SOLID
end
local function setupSimpleProgressFill(frame)
if not isValidFrame(frame) then
return
end
local tex = cfg("progressFillTexture", "")
if type(BlzFrameSetTexture) == "function" and tex ~= "" then
BlzFrameSetTexture(frame, tex, 0, cfg("progressTextureBlend", false))
end
initBarValueRange(frame, 0)
end
local function progressBarHeight()
return cfg("progressBarHeight", 0.004)
end
local function positionBarOnRoot(frame, root, barInset, barTop, barW, barH, fromRight)
setFrameSize(frame, barW, barH)
if fromRight then
setFramePoint(frame, FRAMEPOINT_TOPRIGHT, root, FRAMEPOINT_TOPRIGHT, -barInset, -barTop)
else
setFramePoint(frame, FRAMEPOINT_TOPLEFT, root, FRAMEPOINT_TOPLEFT, barInset, -barTop)
end
end
local function positionSegmentOnRoot(frame, root, segW, barH, barTop, segRightInset, segX, fromRight)
setFrameSize(frame, segW, barH)
if fromRight then
setFramePoint(frame, FRAMEPOINT_TOPRIGHT, root, FRAMEPOINT_TOPRIGHT, -segRightInset, -barTop)
else
setFramePoint(frame, FRAMEPOINT_TOPLEFT, root, FRAMEPOINT_TOPLEFT, segX, -barTop)
end
end
local function hideProgressTimerBar(slot)
if isValidFrame(slot.progressTimerBar) then
hideFrame(slot.progressTimerBar)
end
end
local function bindFillToTrack(fill, track)
if not isValidFrame(fill) or not isValidFrame(track) then
return
end
if type(BlzFrameSetAllPoints) == "function" then
BlzFrameSetAllPoints(fill, track)
return
end
setFramePoint(fill, FRAMEPOINT_TOPLEFT, track, FRAMEPOINT_TOPLEFT, 0, 0)
setFramePoint(fill, FRAMEPOINT_BOTTOMRIGHT, track, FRAMEPOINT_BOTTOMRIGHT, 0, 0)
end
local function setSimpleFillRatio(fill, ratio, style, alpha, useTrackAlpha)
if not isValidFrame(fill) then
return
end
applyProgressFillStyle(fill, style)
if useTrackAlpha then
setFrameAlpha(fill, resolveProgressTrackAlpha(style, alpha))
else
setFrameAlpha(fill, resolveProgressFillAlpha(style, alpha))
end
if type(BlzFrameSetValue) == "function" then
local valueMax = cfg("progressValueMax", 100)
ratio = math.max(0, math.min(1, ratio or 0))
BlzFrameSetValue(fill, ratio * valueMax)
end
showFrame(fill)
end
local function shouldShowProgress(toast)
if not hasProgressData(toast) then
return false
end
local style = resolveStyle(toast.style)
local barType = resolveProgressBarType(style)
return barType == PROGRESS_BAR_SOLID
or barType == PROGRESS_BAR_SEGMENTED
or barType == PROGRESS_BAR_TIMER
end
local function resolveDisplayProgress(toast)
if not hasProgressData(toast) then
return nil
end
if not cfg("progressAnimate", true) then
return parseProgressValue(toast.progressCurrent)
end
local display = toast.displayProgress
if display == nil then
return 0
end
return display
end
local function updateToastDisplayProgress(toast)
if not hasProgressData(toast) then
toast.displayProgress = nil
return
end
local target = parseProgressValue(toast.progressCurrent)
local maxVal = parseProgressValue(toast.progressMax)
if target == nil or maxVal == nil then
return
end
if target > maxVal then
target = maxVal
end
if target < 0 then
target = 0
end
if not cfg("progressAnimate", true) then
toast.displayProgress = target
return
end
if toast.displayProgress == nil then
toast.displayProgress = 0
end
if toast.displayProgress > maxVal then
toast.displayProgress = maxVal
end
if toast.displayProgress < 0 then
toast.displayProgress = 0
end
local diff = target - toast.displayProgress
local snap = cfg("progressAnimSnapThreshold", 0.002)
if math.abs(diff) <= snap then
toast.displayProgress = target
return
end
toast.displayProgress = toast.displayProgress + diff * cfg("progressLerpSpeed", 0.14)
end
local function tickLocalProgressAnimation()
local state = _playerState[localPlayerId()]
if state == nil then
return
end
for i = 1, #state.active do
updateToastDisplayProgress(state.active[i])
end
end
local function progressSegmentCount(maxVal)
local count = math.floor(maxVal + 0.0001)
if count < 1 then
count = 1
end
local limit = cfg("maxProgressSegments", 12)
if count > limit then
count = limit
end
return count
end
computeToastAlpha = function(toast, now)
if toast.expiresAt == nil then
return ALPHA_OPAQUE
end
if now >= toast.expiresAt then
return ALPHA_TRANSPARENT
end
if toast.fadeStartAt == nil or now < toast.fadeStartAt then
return ALPHA_OPAQUE
end
local fadeDuration = cfg("fadeDuration", 1.0)
if fadeDuration <= 0 then
return ALPHA_TRANSPARENT
end
local elapsed = now - toast.fadeStartAt
local ratio = 1 - (elapsed / fadeDuration)
if ratio < 0 then
ratio = 0
elseif ratio > 1 then
ratio = 1
end
return math.floor(ALPHA_OPAQUE * ratio + 0.5)
end
local function acquireToast()
local toast = table.remove(_toastPool)
if toast == nil then
toast = {}
end
return toast
end
local function releaseToast(toast)
if toast == nil then
return
end
toast.icon = nil
toast.title = nil
toast.text = nil
toast.style = nil
toast.duration = nil
toast.key = nil
toast.replace = nil
toast.accumulatedValue = nil
toast.textColorFunction = nil
toast.textValueFunction = nil
toast.logicSize = nil
toast.progressCurrent = nil
toast.progressMax = nil
toast.displayProgress = nil
toast.expiresAt = nil
toast.fadeStartAt = nil
toast.shownAt = nil
toast.height = nil
toast.displayY = nil
toast.targetY = nil
toast.entryAnimating = nil
toast.entryT = nil
toast.entryFromY = nil
toast.pulseActive = nil
toast.pulseT = nil
toast.pulseIconScale = nil
toast.pulseStrength = nil
_toastPool[#_toastPool + 1] = toast
end
local function acquireRequest()
local req = table.remove(_requestPool)
if req == nil then
req = {}
end
return req
end
local function releaseRequest(req)
if req == nil then
return
end
req.icon = nil
req.title = nil
req.text = nil
req.style = nil
req.duration = nil
req.key = nil
req.replace = nil
req.accumulatedValue = nil
req.textColorFunction = nil
req.textValueFunction = nil
req.logicSize = nil
req.waitKey = nil
req.waitKeyFade = nil
req.progressCurrent = nil
req.progressMax = nil
_requestPool[#_requestPool + 1] = req
end
local function applyRequestFields(req, options)
local style = resolveStyle(options.style)
req.icon = options.icon
req.title = options.title
req.style = options.style or "default"
req.key = options.key
req.replace = resolveReplaceFromOptions(options)
req.waitKey = options.waitKey == true
req.waitKeyFade = options.waitKeyFade == true
req.progressCurrent = options.progressCurrent
req.progressMax = options.progressMax
req.duration = resolveDurationFromOptions(options)
req.logicSize = resolveToastLogicSize(options)
if style.isAccumulation == true then
req.accumulatedValue = parseAccumulatedValue(options.accumulatedValue)
else
req.accumulatedValue = nil
end
applyToastDisplayText(req, options, style)
end
local function copyRequestFromOptions(options)
local req = acquireRequest()
applyRequestFields(req, options)
return req
end
local function hasToastContent(options)
if options == nil then
return false
end
if options.icon ~= nil and options.icon ~= "" then
return true
end
if options.title ~= nil and options.title ~= "" then
return true
end
if options.text ~= nil and options.text ~= "" then
return true
end
if isAccumulationStyle(options.style) and options.accumulatedValue ~= nil then
return true
end
return false
end
local function calcToastHeight(toast)
local style = resolveStyle(toast.style)
local pad = resolveToastPadding(style)
local hasTitle = toast.title ~= nil and toast.title ~= ""
local hasText = toast.text ~= nil and toast.text ~= ""
local hasProgress = shouldShowProgress(toast)
local hasIcon = toast.icon ~= nil and toast.icon ~= ""
local titleH = 0
if hasTitle then
titleH = style.titleHeight or cfg("titleHeight", 0.017)
end
local bodyH = 0
if hasText then
local lines = estimateTextLines(toast.text, style)
bodyH = lines * resolveTextLineHeight(style)
end
local progressH = 0
if hasProgress then
progressH = cfg("progressBarGap", 0.003) + progressBarHeight()
end
local iconH = hasIcon and resolveIconSize(style) or 0
local contentH
if not hasTitle and not hasProgress and hasText then
contentH = math.max(iconH, bodyH) + progressH
else
contentH = math.max(titleH + bodyH + progressH, iconH)
end
local total = contentH + pad * 2
return math.max(total, resolveToastMinHeight(style))
end
local function getPlayerState(pid)
local state = _playerState[pid]
if state == nil then
state = {
active = {},
pending = {},
nextShowAt = 0,
}
_playerState[pid] = state
end
return state
end
local function applyToastFields(toast, source, opts)
opts = opts or {}
local style = resolveStyle(source.style or toast.style or "default")
local isAccum = style.isAccumulation == true
local mergeAccum = opts.mergeAccumulation == true and isAccum
if mergeAccum then
if source.icon ~= nil and source.icon ~= "" then
toast.icon = source.icon
end
if source.title ~= nil and source.title ~= "" then
toast.title = source.title
end
else
toast.icon = source.icon
toast.title = source.title
end
toast.style = source.style or "default"
toast.key = source.key
toast.replace = resolveReplaceFromOptions(source)
toast.progressCurrent = source.progressCurrent
toast.progressMax = source.progressMax
toast.duration = resolveDurationFromOptions(source) or cfg("defaultDuration", 3.0)
toast.logicSize = resolveToastLogicSize(source)
if isAccum then
local incoming = parseAccumulatedValue(source.accumulatedValue)
if mergeAccum then
toast.accumulatedValue = parseAccumulatedValue(toast.accumulatedValue) + incoming
else
toast.accumulatedValue = incoming
end
else
toast.accumulatedValue = nil
end
applyToastDisplayText(toast, source, style)
toast.height = calcToastHeight(toast)
end
-- Lifetime только при фактическом показе (не при постановке в pending).
local function setToastLifetime(toast, now)
local duration = toast.duration
local fadeDuration = cfg("fadeDuration", 1.0)
toast.shownAt = now
toast.expiresAt = now + duration
toast.fadeStartAt = toast.expiresAt - fadeDuration
end
local function createStyleSoundHandle(path)
if type(CreateSound) ~= "function" or type(SetSoundVolume) ~= "function" then
return nil
end
local snd = CreateSound(path, false, false, false, 0, 0, "DefaultEAX")
if snd == nil then
return nil
end
SetSoundVolume(snd, 127)
return snd
end
local function acquireStyleSound(path)
local pool = _soundPools[path]
if pool == nil then
pool = {}
_soundPools[path] = pool
end
if type(GetSoundIsPlaying) == "function" then
for i = 1, #pool do
local snd = pool[i]
if snd ~= nil and not GetSoundIsPlaying(snd) then
return snd, false
end
end
end
local poolSize = cfg("soundPoolSize", 4)
if #pool < poolSize then
local snd = createStyleSoundHandle(path)
if snd == nil then
return nil, false
end
pool[#pool + 1] = snd
return snd, false
end
local snd = createStyleSoundHandle(path)
if snd == nil then
return nil, false
end
return snd, true
end
playStyleSound = function(styleName, soundKey)
local style = resolveStyle(styleName)
local path = style[soundKey]
if path == nil or path == "" then
return
end
if type(GetLocalPlayer) ~= "function" then
return
end
local snd, isTemp = acquireStyleSound(path)
if snd == nil then
return
end
if type(StartSound) == "function" then
StartSound(snd)
end
if isTemp and type(KillSoundWhenDone) == "function" then
KillSoundWhenDone(snd)
end
end
recalcTargetY = function(state)
local y = cfg("screenMarginBottom", 0.16)
local gap = cfg("toastGap", 0.004)
for i = 1, #state.active do
local toast = state.active[i]
local prevTarget = toast.targetY
local newTarget = y
if prevTarget ~= nil and prevTarget ~= newTarget then
if toast.displayY == nil then
toast.displayY = prevTarget
end
cancelEntryForQueueLerp(toast)
end
toast.targetY = newTarget
y = newTarget + toast.height + gap
end
end
local function createProgressSegment(parent, slotId, segId)
if cfg("progressUseSimpleBar", true) then
local track = createFrame("SIMPLESTATUSBAR", slotFrameName(slotId, "pbt_" .. segId), parent, "")
local fill = createFrame("SIMPLESTATUSBAR", slotFrameName(slotId, "pbf_" .. segId), parent, "")
setupSimpleProgressFill(track)
setupSimpleProgressFill(fill)
hideFrame(track)
hideFrame(fill)
return { track = track, fill = fill }
end
local track = createFrame("BACKDROP", slotFrameName(slotId, "pbt_" .. segId), parent, "")
local fill = createFrame("BACKDROP", slotFrameName(slotId, "pbf_" .. segId), track, "")
hideFrame(track)
hideFrame(fill)
return { track = track, fill = fill }
end
local function createToastSlot(slotId)
local parent = getUIParent()
local root = createFrame("FRAME", slotFrameName(slotId, "root"), parent, "")
if not isValidFrame(root) then
return nil
end
applyFrameLevel(root)
enableFrame(root, false)
local useSimplePanel = false
local panelSf = 0
local panelTex = 0
local createContext = slotId - 1
local panelBackdrop = createFrame("BACKDROP", slotFrameName(slotId, "panel"), root, "")
if not isValidFrame(panelBackdrop) then
return nil
end
hideFrame(panelBackdrop)
if cfg("useSimplePanel", false) and ensurePanelFdf() and type(BlzCreateSimpleFrame) == "function" then
local template = cfg("panelFdfTemplate", "ToastsPanelTemplate")
panelSf = BlzCreateSimpleFrame(template, root, createContext)
if isValidFrame(panelSf) and type(BlzGetFrameByName) == "function" then
local texName = cfg("panelTextureFrameName", "ToastsPanelTexture")
panelTex = BlzGetFrameByName(texName, createContext)
if isValidFrame(panelTex) then
useSimplePanel = true
bindAllPoints(panelSf, root)
bindAllPoints(panelTex, panelSf)
else
log("createToastSlot: ToastsPanelTexture not found, context=" .. createContext, true)
end
else
log("createToastSlot: BlzCreateSimpleFrame failed for slot " .. slotId, true)
end
end
local icon = createFrame("BACKDROP", slotFrameName(slotId, "icon"), root, "")
local iconGlow = createFrame("BACKDROP", slotFrameName(slotId, "icon_glow"), root, "")
hideFrame(iconGlow)
local title = createText(root, slotFrameName(slotId, "title"), "", 0.1, 0.1, "SystemNormalTextOutline")
local body = createText(root, slotFrameName(slotId, "body"), "", 0.1, 0.1, "SystemNormalTextOutline")
local progressTrack = createFrame("BACKDROP", slotFrameName(slotId, "p_track"), root, "")
local progressFill
if cfg("progressUseSimpleBar", true) then
progressFill = createFrame("SIMPLESTATUSBAR", slotFrameName(slotId, "p_fill"), progressTrack, "")
setupSimpleProgressFill(progressFill)
else
progressFill = createFrame("BACKDROP", slotFrameName(slotId, "p_fill"), progressTrack, "")
end
hideFrame(progressTrack)
hideFrame(progressFill)
local progressTimerBar = 0
if cfg("progressUseTimerBar", false) then
progressTimerBar = createFrame("STATUSBAR", slotFrameName(slotId, "p_timer"), root, "")
if isValidFrame(progressTimerBar) then
setupProgressTimerBar(progressTimerBar)
hideFrame(progressTimerBar)
else
progressTimerBar = 0
end
end
local progressSegments = {}
local maxSeg = cfg("maxProgressSegments", 12)
for segId = 1, maxSeg do
progressSegments[segId] = createProgressSegment(root, slotId, segId)
end
return {
id = slotId,
root = root,
useSimplePanel = useSimplePanel,
panelSf = panelSf,
panelTex = panelTex,
panelBackdrop = panelBackdrop,
icon = icon,
iconGlow = iconGlow,
title = title,
body = body,
progressTrack = progressTrack,
progressFill = progressFill,
progressTimerBar = progressTimerBar,
progressSegments = progressSegments,
}
end
ensureLocalUI = function()
if _localUiReady then
return true
end
if not hasBlzApi() then
return false
end
if not isValidFrame(getUIParent()) then
return false
end
local slotCount = maxToastSlotCount()
_frameSlots = {}
for slotId = 1, slotCount do
local slot = createToastSlot(slotId)
if slot == nil then
log("ensureLocalUI: failed to create slot " .. tostring(slotId), true)
return false
end
_frameSlots[slotId] = slot
end
_localUiReady = true
_layoutDirty = true
return true
end
local function hideProgressFrames(slot)
if isValidFrame(slot.progressTrack) then
hideFrame(slot.progressTrack)
end
if isValidFrame(slot.progressFill) then
hideFrame(slot.progressFill)
end
if isValidFrame(slot.progressTimerBar) then
hideFrame(slot.progressTimerBar)
end
if slot.progressSegments ~= nil then
for segId = 1, #slot.progressSegments do
local seg = slot.progressSegments[segId]
if seg ~= nil then
hideFrame(seg.track)
hideFrame(seg.fill)
end
end
end
end
local function hideSlotVisuals(slot)
hideProgressFrames(slot)
if isValidFrame(slot.iconGlow) then
hideFrame(slot.iconGlow)
end
if isValidFrame(slot.icon) then
hideFrame(slot.icon)
end
if isValidFrame(slot.title) then
hideFrame(slot.title)
end
if isValidFrame(slot.body) then
hideFrame(slot.body)
end
if isValidFrame(slot.panelSf) then
hideFrame(slot.panelSf)
end
if isValidFrame(slot.panelBackdrop) then
hideFrame(slot.panelBackdrop)
end
end
local function layoutTimerProgress(slot, toast, barInset, barTop, barW, barH, alpha, fromRight)
hideProgressFrames(slot)
local bar = slot.progressTimerBar
if not isValidFrame(bar) then
return
end
local maxVal = parseProgressValue(toast.progressMax)
local curVal = resolveDisplayProgress(toast) or 0
local ratio = math.max(0, math.min(1, curVal / maxVal))
if fromRight == nil then
fromRight = cfg("progressFillFromRight", true)
end
positionBarOnRoot(bar, slot.root, barInset, barTop, barW, barH, fromRight)
if type(BlzFrameSetValue) == "function" then
BlzFrameSetValue(bar, progressTimerValue(ratio))
end
setFrameAlpha(bar, alpha)
showFrame(bar)
end
local function layoutSolidProgress(slot, toast, style, barInset, barTop, barW, barH, alpha, fromRight)
hideProgressTimerBar(slot)
if slot.progressSegments ~= nil then
for segId = 1, #slot.progressSegments do
hideFrame(slot.progressSegments[segId].track)
hideFrame(slot.progressSegments[segId].fill)
end
end
local maxVal = parseProgressValue(toast.progressMax)
local curVal = resolveDisplayProgress(toast) or 0
local ratio = math.max(0, math.min(1, curVal / maxVal))
if fromRight == nil then
fromRight = cfg("progressFillFromRight", true)
end
positionBarOnRoot(slot.progressTrack, slot.root, barInset, barTop, barW, barH, fromRight)
applyProgressTrackStyle(slot.progressTrack, style)
setFrameAlpha(slot.progressTrack, resolveProgressTrackAlpha(style, alpha))
showFrame(slot.progressTrack)
if progressBarUsesSimpleFill(PROGRESS_BAR_SOLID) then
bindFillToTrack(slot.progressFill, slot.progressTrack)
setSimpleFillRatio(slot.progressFill, ratio, style, alpha, false)
else
local fillW = barW * ratio
if fillW > 0.0001 and isValidFrame(slot.progressFill) then
setFrameSize(slot.progressFill, fillW, barH)
if fromRight then
setFramePoint(
slot.progressFill,
FRAMEPOINT_BOTTOMRIGHT,
slot.progressTrack,
FRAMEPOINT_BOTTOMRIGHT,
0,
0
)
else
setFramePoint(
slot.progressFill,
FRAMEPOINT_BOTTOMLEFT,
slot.progressTrack,
FRAMEPOINT_BOTTOMLEFT,
0,
0
)
end
applyProgressFillStyle(slot.progressFill, style)
setFrameAlpha(slot.progressFill, resolveProgressFillAlpha(style, alpha))
showFrame(slot.progressFill)
elseif isValidFrame(slot.progressFill) then
hideFrame(slot.progressFill)
end
end
if slot.progressSegments ~= nil then
for segId = 1, #slot.progressSegments do
hideFrame(slot.progressSegments[segId].track)
hideFrame(slot.progressSegments[segId].fill)
end
end
end
local function layoutSegmentProgressPair(seg, root, segW, barH, barTop, segRightInset, segX, fromRight, fillRatio, style, alpha)
positionSegmentOnRoot(seg.track, root, segW, barH, barTop, segRightInset, segX, fromRight)
setSimpleFillRatio(seg.track, 1, style, alpha, true)
if fillRatio > 0.001 then
positionSegmentOnRoot(seg.fill, root, segW, barH, barTop, segRightInset, segX, fromRight)
setSimpleFillRatio(seg.fill, fillRatio, style, alpha, false)
else
hideFrame(seg.fill)
end
end
local function layoutBackdropSegment(seg, root, segW, barH, barTop, segRightInset, segX, fromRight, fillRatio, style, alpha)
positionSegmentOnRoot(seg.track, root, segW, barH, barTop, segRightInset, segX, fromRight)
applyProgressTrackStyle(seg.track, style)
setFrameAlpha(seg.track, resolveProgressTrackAlpha(style, alpha))
showFrame(seg.track)
local fillW = segW * fillRatio
if fillW > 0.0001 then
setFrameSize(seg.fill, fillW, barH)
if fromRight then
setFramePoint(seg.fill, FRAMEPOINT_BOTTOMRIGHT, seg.track, FRAMEPOINT_BOTTOMRIGHT, 0, 0)
else
setFramePoint(seg.fill, FRAMEPOINT_BOTTOMLEFT, seg.track, FRAMEPOINT_BOTTOMLEFT, 0, 0)
end
applyProgressFillStyle(seg.fill, style)
setFrameAlpha(seg.fill, resolveProgressFillAlpha(style, alpha))
showFrame(seg.fill)
else
hideFrame(seg.fill)
end
end
local function layoutSegmentedProgress(slot, toast, style, barInset, barTop, barW, barH, alpha, fromRight)
hideProgressTimerBar(slot)
local maxVal = parseProgressValue(toast.progressMax)
local curVal = resolveDisplayProgress(toast) or 0
local segCount = progressSegmentCount(maxVal)
local segGap = cfg("progressSegmentGap", 0.001)
local totalGap = segGap * math.max(0, segCount - 1)
local segW = (barW - totalGap) / segCount
if fromRight == nil then
fromRight = cfg("progressFillFromRight", true)
end
hideFrame(slot.progressTrack)
hideFrame(slot.progressFill)
for segId = 1, #slot.progressSegments do
local seg = slot.progressSegments[segId]
if segId <= segCount then
local logicalSegId = segId
if fromRight then
logicalSegId = segCount - segId + 1
end
local fillRatio = segmentFillRatio(curVal, maxVal, logicalSegId, segCount)
local segRightInset = barInset + (segId - 1) * (segW + segGap)
local segX = barInset + (segId - 1) * (segW + segGap)
if progressBarUsesSimpleFill(PROGRESS_BAR_SEGMENTED) then
layoutSegmentProgressPair(
seg,
slot.root,
segW,
barH,
barTop,
segRightInset,
segX,
fromRight,
fillRatio,
style,
alpha
)
else
layoutBackdropSegment(
seg,
slot.root,
segW,
barH,
barTop,
segRightInset,
segX,
fromRight,
fillRatio,
style,
alpha
)
end
else
hideFrame(seg.track)
hideFrame(seg.fill)
end
end
end
local function layoutToastContent(slot, toast, now)
local style = resolveStyle(toast.style)
local pad = resolveToastPadding(style)
local toastW = resolveToastWidth(style)
local iconSize = resolveIconSize(style)
local iconGap = resolveIconTextGap(style)
local iconOnRight = cfg("iconOnRight", true)
local fadeAlpha = computeToastAlpha(toast, now)
local entryMult = computeEntryAlphaMult(toast)
local iconScale = toast.pulseIconScale or 1
local pulseStrength = toast.pulseStrength or 0
local contentFade = math.floor(fadeAlpha * entryMult + 0.5)
local function applyPanelWithPulse(frame, useSimpleTexture)
applyPanelStyle(frame, style, contentFade, useSimpleTexture)
if pulseStrength > 0 and isValidFrame(frame) then
local base = resolvePanelBgAlpha(style, contentFade)
setFrameAlpha(
frame,
math.min(
255,
base + math.floor(cfg("updatePulsePanelAlphaBoost", 130) * pulseStrength + 0.5)
)
)
end
end
local hasIcon = toast.icon ~= nil and toast.icon ~= ""
local hasTitle = toast.title ~= nil and toast.title ~= ""
local hasText = toast.text ~= nil and toast.text ~= ""
local hasProgress = shouldShowProgress(toast)
local iconReserve = hasIcon and (iconSize + iconGap) or 0
local textW = toastW - pad * 2 - iconReserve
local textRightInset = pad + iconReserve
local textLeftInset = pad
local titleH = 0
if hasTitle then
titleH = style.titleHeight or cfg("titleHeight", 0.017)
end
local bodyH = 0
if hasText then
bodyH = estimateTextLines(toast.text, style) * resolveTextLineHeight(style)
end
local progressBlockH = 0
if hasProgress then
progressBlockH = cfg("progressBarGap", 0.003) + progressBarHeight()
end
local textBlockH = titleH + bodyH
local rowH = math.max(hasIcon and iconSize or 0, textBlockH)
if hasText and not hasTitle and not hasProgress then
bodyH = rowH
end
local contentH = math.max(textBlockH + progressBlockH, hasIcon and iconSize or 0, rowH)
local minH = resolveToastMinHeight(style)
local rootH = math.max(toast.height or 0, contentH + pad * 2, minH)
toast.height = rootH
local innerH = rootH - pad * 2
local contentBlockH = math.max(rowH + progressBlockH, hasIcon and iconSize or 0)
local contentTop = pad
if not hasTitle and not hasProgress and innerH > contentBlockH + 0.0001 then
if style.contentVCenter ~= false then
contentTop = pad + (innerH - contentBlockH) * 0.5
end
end
setFrameSize(slot.root, toastW, rootH)
if cfg("showPanelBackground", true) then
if slot.useSimplePanel and isValidFrame(slot.panelTex) then
bindAllPoints(slot.panelSf, slot.root)
bindAllPoints(slot.panelTex, slot.panelSf)
applyPanelWithPulse(slot.panelTex, true)
showFrame(slot.panelSf)
hideFrame(slot.panelBackdrop)
elseif isValidFrame(slot.panelBackdrop) then
bindAllPoints(slot.panelBackdrop, slot.root)
applyPanelWithPulse(slot.panelBackdrop, false)
showFrame(slot.panelBackdrop)
if isValidFrame(slot.panelSf) then
hideFrame(slot.panelSf)
end
end
else
if isValidFrame(slot.panelSf) then
hideFrame(slot.panelSf)
end
if isValidFrame(slot.panelBackdrop) then
hideFrame(slot.panelBackdrop)
end
end
if isValidFrame(slot.iconGlow) then
hideFrame(slot.iconGlow)
end
if hasIcon and isValidFrame(slot.icon) then
local drawIconSize = iconSize * iconScale
setFrameSize(slot.icon, drawIconSize, drawIconSize)
setFrameTexture(slot.icon, toast.icon, true)
setFrameScale(slot.icon, 1)
local iconTop = contentTop
if not hasTitle and not hasProgress then
iconTop = contentTop + (rowH - drawIconSize) * 0.5
end
if iconOnRight then
setFramePoint(slot.icon, FRAMEPOINT_TOPRIGHT, slot.root, FRAMEPOINT_TOPRIGHT, -pad, -iconTop)
else
setFramePoint(slot.icon, FRAMEPOINT_TOPLEFT, slot.root, FRAMEPOINT_TOPLEFT, pad, -iconTop)
end
setFrameAlpha(slot.icon, contentFade)
showFrame(slot.icon)
if pulseStrength > 0.01 and isValidFrame(slot.iconGlow) then
local glowSize = iconSize * (1.15 + 0.35 * pulseStrength)
setFrameTexture(slot.iconGlow, resolvePanelTexture(style), cfg("panelTextureBlend", false))
setFrameSize(slot.iconGlow, glowSize, glowSize)
if iconOnRight then
setFramePoint(slot.iconGlow, FRAMEPOINT_TOPRIGHT, slot.root, FRAMEPOINT_TOPRIGHT, -pad, -iconTop)
else
setFramePoint(slot.iconGlow, FRAMEPOINT_TOPLEFT, slot.root, FRAMEPOINT_TOPLEFT, pad, -iconTop)
end
setFrameAlpha(
slot.iconGlow,
math.floor(cfg("updatePulseGlowAlpha", 210) * pulseStrength * entryMult + 0.5)
)
showFrame(slot.iconGlow)
end
elseif isValidFrame(slot.icon) then
hideFrame(slot.icon)
setFrameScale(slot.icon, 1)
end
local textTop = contentTop
if hasTitle and isValidFrame(slot.title) then
setFrameSize(slot.title, textW, titleH)
BlzFrameSetText(slot.title, colorizeText(toast.title, style.titleColor))
if iconOnRight then
setFramePoint(slot.title, FRAMEPOINT_TOPRIGHT, slot.root, FRAMEPOINT_TOPRIGHT, -textRightInset, -textTop)
else
setFramePoint(slot.title, FRAMEPOINT_TOPLEFT, slot.root, FRAMEPOINT_TOPLEFT, textLeftInset + iconReserve, -textTop)
end
setTextAlignment(slot.title)
setFrameAlpha(slot.title, contentFade)
showFrame(slot.title)
elseif isValidFrame(slot.title) then
hideFrame(slot.title)
end
if hasText and isValidFrame(slot.body) then
local bodyTop = textTop + titleH
if not hasTitle and not hasProgress then
bodyTop = contentTop + (rowH - bodyH) * 0.5
end
setFrameSize(slot.body, textW, bodyH)
BlzFrameSetText(slot.body, colorizeText(toast.text, resolveBodyTextColor(toast, style)))
if iconOnRight then
setFramePoint(slot.body, FRAMEPOINT_TOPRIGHT, slot.root, FRAMEPOINT_TOPRIGHT, -textRightInset, -(bodyTop))
else
setFramePoint(slot.body, FRAMEPOINT_TOPLEFT, slot.root, FRAMEPOINT_TOPLEFT, textLeftInset + iconReserve, -(bodyTop))
end
setTextAlignment(slot.body)
setFrameAlpha(slot.body, contentFade)
showFrame(slot.body)
elseif isValidFrame(slot.body) then
hideFrame(slot.body)
end
if hasProgress then
local barH = progressBarHeight()
local barTop = textTop + titleH + bodyH + cfg("progressBarGap", 0.003)
local barType = resolveProgressBarType(style)
-- Полная ширина тоста: правый край бара = правый край иконки.
local barInset = pad
local barW = toastW - pad * 2
if barType == PROGRESS_BAR_TIMER then
layoutTimerProgress(slot, toast, barInset, barTop, barW, barH, contentFade, iconOnRight)
elseif barType == PROGRESS_BAR_SOLID then
layoutSolidProgress(slot, toast, style, barInset, barTop, barW, barH, contentFade, iconOnRight)
elseif barType == PROGRESS_BAR_SEGMENTED then
layoutSegmentedProgress(slot, toast, style, barInset, barTop, barW, barH, contentFade, iconOnRight)
else
hideProgressFrames(slot)
end
else
hideProgressFrames(slot)
end
end
local function localHasActiveToasts()
local state = _playerState[localPlayerId()]
return state ~= nil and #state.active > 0
end
applyLocalLayout = function(pid)
if pid ~= localPlayerId() then
return
end
if not _localUiReady or _frameSlots == nil then
return
end
local state = _playerState[pid]
if state == nil then
return
end
local anchor = getAnchorFrame()
local marginRight = cfg("screenMarginRight", 0.012)
local slotCount = maxToastSlotCount()
local now = gameTime()
local lerpSpeed = cfg("positionLerpSpeed", 0.18)
for slotId = 1, slotCount do
local slot = _frameSlots[slotId]
local toast = state.active[slotId]
if slot ~= nil and toast ~= nil then
advanceToastVisuals(toast, lerpSpeed)
layoutToastContent(slot, toast, now)
setFramePoint(
slot.root,
FRAMEPOINT_BOTTOMRIGHT,
anchor,
FRAMEPOINT_BOTTOMRIGHT,
-marginRight,
toast.displayY or toast.targetY or 0
)
showFrame(slot.root)
elseif slot ~= nil then
hideSlotVisuals(slot)
hideFrame(slot.root)
end
end
_layoutDirty = false
end
local function playToastSound(styleName, soundEvent)
if soundEvent == "update" then
playStyleSound(styleName, "updateSound")
local style = resolveStyle(styleName)
if style.updateSound == nil or style.updateSound == "" then
playStyleSound(styleName, "showSound")
end
elseif soundEvent == "show" then
playStyleSound(styleName, "showSound")
end
end
local function canStartShowing(state, now)
local minInterval = cfg("minShowInterval", 0)
if minInterval <= 0 then
return true
end
return now >= (state.nextShowAt or 0)
end
local function markShowStarted(state, now)
local minInterval = cfg("minShowInterval", 0)
if minInterval > 0 then
state.nextShowAt = now + minInterval
end
end
local function hasActiveKey(state, key)
if key == nil then
return false
end
for i = 1, #state.active do
if state.active[i].key == key then
return true
end
end
return false
end
local function isKeyWaitBlocking(state, key, waitKeyFade, now)
if key == nil then
return false
end
for i = 1, #state.active do
local toast = state.active[i]
if toast.key == key then
if waitKeyFade == true
and toast.fadeStartAt ~= nil
and now >= toast.fadeStartAt then
return false
end
return true
end
end
return false
end
local function releaseFadingKeyToasts(state, key, now)
if key == nil then
return false
end
local removed = false
for i = #state.active, 1, -1 do
local toast = state.active[i]
if toast.key == key
and toast.fadeStartAt ~= nil
and now >= toast.fadeStartAt then
table.remove(state.active, i)
releaseToast(toast)
removed = true
end
end
if removed then
recalcTargetY(state)
_layoutDirty = true
end
return removed
end
local function findFadingToastByKey(state, key, now)
if key == nil then
return nil
end
for i = 1, #state.active do
local toast = state.active[i]
if toast.key == key
and toast.fadeStartAt ~= nil
and now >= toast.fadeStartAt then
return toast
end
end
return nil
end
-- waitKeyFade + replace: та же плашка, новый контент, без entry.
local function tryReplaceFadingKeyToast(state, source, now)
if source.key == nil or not resolveReplaceFromOptions(source) then
return false
end
local toast = findFadingToastByKey(state, source.key, now)
if toast == nil then
return false
end
applyToastFields(toast, source, {
mergeAccumulation = isAccumulationStyle(source.style or toast.style),
})
setToastLifetime(toast, now)
toast.entryAnimating = nil
toast.entryT = nil
toast.entryFromY = nil
if toast.targetY ~= nil then
toast.displayY = toast.targetY
end
triggerUpdatePulse(toast)
recalcTargetY(state)
_layoutDirty = true
return true
end
local function enqueuePending(state, options)
local maxPending = cfg("maxPendingQueue", 0)
if maxPending > 0 and #state.pending >= maxPending then
releaseRequest(table.remove(state.pending, 1))
end
if maxPending == 0 or #state.pending < maxPending then
state.pending[#state.pending + 1] = copyRequestFromOptions(options)
return true
end
return false
end
local function canPromoteRequest(state, req, now)
if req == nil then
return false
end
if req.waitKey == true and req.key ~= nil then
if isKeyWaitBlocking(state, req.key, req.waitKeyFade == true, now) then
return false
end
end
return true
end
local function tryPromotePending(state, pid, now)
local maxSlots = maxToastSlotCount()
local isLocal = pid == localPlayerId()
while #state.pending > 0 do
local req = state.pending[1]
if not canAcceptToast(state, resolveToastLogicSize(req)) then
break
end
if #state.active >= maxSlots then
break
end
if not canStartShowing(state, now) then
break
end
if not canPromoteRequest(state, req, now) then
break
end
req = table.remove(state.pending, 1)
if req ~= nil then
local replaced = false
if req.waitKeyFade == true and req.key ~= nil then
if req.replace == true then
replaced = tryReplaceFadingKeyToast(state, req, now)
end
if not replaced then
releaseFadingKeyToasts(state, req.key, now)
end
end
if replaced then
markShowStarted(state, now)
if isLocal then
playToastSound(req.style or "default", "update")
end
releaseRequest(req)
else
local toast = acquireToast()
applyToastFields(toast, req)
setToastLifetime(toast, now)
table.insert(state.active, 1, toast)
recalcTargetY(state)
startEntryAnimation(toast)
markShowStarted(state, now)
if isLocal then
playToastSound(toast.style or "default", "show")
end
releaseRequest(req)
_layoutDirty = true
end
end
end
end
local function findPendingByKey(state, key, useLast)
if key == nil then
return nil
end
if useLast then
for i = #state.pending, 1, -1 do
if state.pending[i].key == key then
return i
end
end
return nil
end
for i = 1, #state.pending do
if state.pending[i].key == key then
return i
end
end
return nil
end
local function updatePendingRequest(req, options)
local style = resolveStyle(options.style or req.style)
local mergeAccum = style.isAccumulation == true
and req.key ~= nil
and req.key == options.key
local savedIcon = req.icon
local savedTitle = req.title
local prevAccum = mergeAccum and parseAccumulatedValue(req.accumulatedValue) or 0
applyRequestFields(req, options)
if mergeAccum then
req.accumulatedValue = prevAccum + parseAccumulatedValue(options.accumulatedValue)
if req.icon == nil or req.icon == "" then
req.icon = savedIcon
end
if req.title == nil or req.title == "" then
req.title = savedTitle
end
applyToastDisplayText(req, req, style)
end
end
local function activateToast(state, source, now)
local toast = acquireToast()
applyToastFields(toast, source)
setToastLifetime(toast, now)
table.insert(state.active, 1, toast)
recalcTargetY(state)
startEntryAnimation(toast)
_layoutDirty = true
return toast
end
local function activateAndShowToast(state, source, now)
if source.waitKeyFade == true and source.key ~= nil and resolveReplaceFromOptions(source) then
if tryReplaceFadingKeyToast(state, source, now) then
markShowStarted(state, now)
return true
end
end
if source.waitKeyFade == true and source.key ~= nil then
releaseFadingKeyToasts(state, source.key, now)
end
activateToast(state, source, now)
markShowStarted(state, now)
return false
end
-- soundEvent: nil | "show" | "update"
local function enqueueToastForPlayer(pid, options, now)
local state = getPlayerState(pid)
local key = options.key
local waitKey = options.waitKey == true
local waitKeyFade = options.waitKeyFade == true
local doReplace = resolveReplaceFromOptions(options) and key ~= nil
if doReplace and waitKey then
local pendingIndex = findPendingByKey(state, key, true)
if hasActiveKey(state, key) then
if pendingIndex ~= nil then
updatePendingRequest(state.pending[pendingIndex], options)
else
enqueuePending(state, options)
end
return true, nil
end
if pendingIndex ~= nil then
updatePendingRequest(state.pending[pendingIndex], options)
return true, nil
end
elseif doReplace then
for i = 1, #state.active do
if state.active[i].key == key then
local toast = state.active[i]
applyToastFields(toast, options, {
mergeAccumulation = isAccumulationStyle(options.style or toast.style),
})
setToastLifetime(toast, now)
triggerUpdatePulse(toast)
recalcTargetY(state)
_layoutDirty = true
return true, "update"
end
end
local pendingIndex = findPendingByKey(state, key, true)
if pendingIndex ~= nil then
updatePendingRequest(state.pending[pendingIndex], options)
return true, nil
end
end
if waitKey and key ~= nil and isKeyWaitBlocking(state, key, waitKeyFade, now) then
if doReplace then
local pendingIndex = findPendingByKey(state, key, true)
if pendingIndex ~= nil then
updatePendingRequest(state.pending[pendingIndex], options)
return true, nil
end
end
enqueuePending(state, options)
return true, nil
end
if not canAcceptToast(state, resolveToastLogicSize(options)) then
enqueuePending(state, options)
return true, nil
end
if #state.active >= maxToastSlotCount() then
enqueuePending(state, options)
return true, nil
end
if not canStartShowing(state, now) then
enqueuePending(state, options)
return true, nil
end
local replacedInPlace = activateAndShowToast(state, options, now)
return true, replacedInPlace and "update" or "show"
end
local function tickPlayerState(pid, now)
local state = _playerState[pid]
if state == nil then
return
end
local removed = false
for i = #state.active, 1, -1 do
local toast = state.active[i]
if now >= toast.expiresAt then
table.remove(state.active, i)
releaseToast(toast)
removed = true
end
end
if removed then
recalcTargetY(state)
_layoutDirty = true
end
if #state.pending > 0 then
local pendingBefore = #state.pending
tryPromotePending(state, pid, now)
if #state.pending < pendingBefore then
_layoutDirty = true
end
end
if pid == localPlayerId() and #state.active > 0 then
for i = 1, #state.active do
local toast = state.active[i]
if toast.fadeStartAt ~= nil and now >= toast.fadeStartAt and now < toast.expiresAt then
_layoutDirty = true
break
end
end
end
end
local function tickLogic()
local now = gameTime()
for pid = 0, cfg("maxPlayers", 12) - 1 do
if _playerState[pid] ~= nil then
tickPlayerState(pid, now)
end
end
end
local function tickRender()
local localPid = localPlayerId()
if not _localUiReady then
ensureLocalUI()
end
tickLocalProgressAnimation()
if _layoutDirty or localHasActiveToasts() then
applyLocalLayout(localPid)
end
end
ensureTickers = function()
if type(CreateTimer) ~= "function" or type(TimerStart) ~= "function" then
return
end
local logicInterval = cfg("tickerInterval", 0.03)
if _logicTicker == nil and logicInterval > 0 then
_logicTicker = CreateTimer()
TimerStart(_logicTicker, logicInterval, true, tickLogic)
end
local renderInterval = cfg("renderTickerInterval", 0.016)
if _renderTicker == nil and renderInterval > 0 then
_renderTicker = CreateTimer()
TimerStart(_renderTicker, renderInterval, true, tickRender)
end
end
function Toasts.setDebug(enabled)
Toasts._debug = enabled == true
end
function Toasts.diagnose()
Toasts._log("=== DIAGNOSE ===")
if not hasBlzApi() then
Toasts._log("Blz Frame API unavailable (Reforged 1.31+ required)", true)
return false
end
local ok, ui = pcall(getGameUI)
if not ok or not isValidFrame(ui) then
Toasts._log("BlzGetOriginFrame failed", true)
return false
end
Toasts._log("OK")
return true
end
function Toasts.show(options)
options = options or {}
if not hasToastContent(options) then
log("show: icon, title or text is required", true)
return false
end
ensureTickers()
local now = gameTime()
local players = resolvePlayers(options.players)
local localPid = localPlayerId()
local styleName = options.style or "default"
local needLocalLayout = false
for i = 1, #players do
local pid = playerId(players[i])
local _, soundEvent = enqueueToastForPlayer(pid, options, now)
if pid == localPid then
if soundEvent ~= nil then
playToastSound(styleName, soundEvent)
end
needLocalLayout = true
end
end
if needLocalLayout then
ensureLocalUI()
applyLocalLayout(localPid)
end
return true
end
end
end