-- ==============================================================
-- MODUL: AnimFX - Cinematic Animation Effects Library
-- v8.0.0 - Immersive WOW-Effects for GuildDeathLog
-- ScreenFlash, Particles, Shake, GlowRing, Shimmer, Vignette
-- Classic 1.15.8 & TBC 2.5.5 compatible
-- ==============================================================

local addonName, addon = ...
local GDL = _G["GuildDeathLog"]
local AnimFX = {}

-- ─── LOCAL REFS ─────────────────────────────────────────────────────────────
local math_sin  = math.sin
local math_cos  = math.cos
local math_min  = math.min
local math_max  = math.max
local math_exp  = math.exp
local math_rand = math.random
local math_pi   = math.pi
local C_Timer   = C_Timer

-- ─── PARTICLE POOL ──────────────────────────────────────────────────────────
-- Reusable particle frames: created once, hidden/shown as needed.
local POOL_MAX    = 64
local pool        = {}
local poolCreated = 0

local SPARK_TEXTURES = {
    "Interface\\Cooldown\\star4",
    "Interface\\Buttons\\WHITE8x8",
    "Interface\\Cooldown\\ping4",
    "Interface\\Cooldown\\starburst",
}

local function GetParticle()
    for i = 1, #pool do
        if not pool[i]:IsShown() then
            return pool[i]
        end
    end
    if poolCreated >= POOL_MAX then return nil end
    poolCreated = poolCreated + 1
    local p = CreateFrame("Frame", nil, UIParent)
    p:SetFrameStrata("FULLSCREEN")
    p:SetFrameLevel(8900 + poolCreated)
    p:Hide()
    p:SetScript("OnUpdate", nil)
    p.tex = p:CreateTexture(nil, "OVERLAY")
    p.tex:SetAllPoints()
    p.tex:SetBlendMode("ADD")
    pool[#pool + 1] = p
    return p
end

-- ═══════════════════════════════════════════════════════════════════════════
-- GENERISCHE FX-POOLS
-- Frames sind in WoW NICHT garbage-collectbar. Jeder per-Call erzeugte Frame
-- lebt bis zum /reload. Alle Einmal- und Ambience-Effekte holen sich ihre
-- Frames daher aus diesen Pools und geben sie nach Gebrauch zurueck.
-- ═══════════════════════════════════════════════════════════════════════════

-- FX-Frame: Frame + eine OVERLAY-Textur (ADD-Blend). Konsument setzt
-- Textur/Farbe/Groesse/Strata selbst — der Pool liefert nur die Huelle.
local fxFramePool = {}
local function AcquireFXFrame(parent)
    local f = table.remove(fxFramePool)
    if not f then
        f = CreateFrame("Frame", nil, UIParent)
        f.tex = f:CreateTexture(nil, "OVERLAY")
        f.tex:SetAllPoints()
    end
    f:SetParent(parent or UIParent)
    f:ClearAllPoints()
    f.tex:SetBlendMode("ADD")
    f.tex:SetTexCoord(0, 1, 0, 1)
    f:SetAlpha(1)
    return f
end
local function ReleaseFXFrame(f)
    if not f then return end
    f:SetScript("OnUpdate", nil)
    f:Hide()
    f:SetParent(UIParent)
    fxFramePool[#fxFramePool + 1] = f
end

-- Ticker: nackter Frame nur fuer OnUpdate-Logik (keine Texturen).
local fxTickerPool = {}
local function AcquireTicker()
    local t = table.remove(fxTickerPool)
    if not t then t = CreateFrame("Frame", nil, UIParent) end
    t:Show()
    return t
end
local function ReleaseTicker(t)
    if not t then return end
    t:SetScript("OnUpdate", nil)
    t:Hide()
    fxTickerPool[#fxTickerPool + 1] = t
end

-- ─── SCREEN FLASH ───────────────────────────────────────────────────────────
-- Full-screen color overlay that fades out over `dur` seconds.
-- Mehrere ueberlappende Flashes stacken (ADD); Frames kommen aus dem Pool.
function AnimFX.ScreenFlash(r, g, b, dur, maxAlpha)
    dur      = dur      or 0.35
    maxAlpha = maxAlpha or 0.40
    r, g, b  = r or 1, g or 0.4, b or 0.1

    local f = AcquireFXFrame(UIParent)
    f:SetAllPoints(UIParent)
    f:SetFrameStrata("FULLSCREEN_DIALOG")
    f:SetFrameLevel(9990)
    f.tex:SetTexture("Interface\\Buttons\\WHITE8x8")
    f.tex:SetVertexColor(r, g, b, 1)
    f:SetAlpha(maxAlpha)
    f:Show()

    local el = 0
    f:SetScript("OnUpdate", function(self, dt)
        el = el + dt
        local p = math_min(el / dur, 1)
        self:SetAlpha(maxAlpha * (1 - p * p))
        if el >= dur then
            ReleaseFXFrame(self)
        end
    end)
end

-- ─── DARK VIGNETTE ──────────────────────────────────────────────────────────
-- Darkens the screen edges briefly for dramatic effect.
-- Ein gecachter Frame genuegt — zwei gleichzeitige Vignetten ergeben keinen Sinn.
local _vignetteFrame = nil
function AnimFX.Vignette(dur, maxAlpha)
    dur      = dur      or 1.8
    maxAlpha = maxAlpha or 0.55

    if not _vignetteFrame then
        local f = CreateFrame("Frame", nil, UIParent)
        f:SetAllPoints(UIParent)
        f:SetFrameStrata("FULLSCREEN_DIALOG")
        f:SetFrameLevel(9985)
        local tex = f:CreateTexture(nil, "BACKGROUND")
        tex:SetAllPoints()
        tex:SetTexture("Interface\\Buttons\\WHITE8x8")
        tex:SetVertexColor(0, 0, 0, 1)
        f:Hide()
        _vignetteFrame = f
    end
    local f = _vignetteFrame
    f:SetAlpha(0)
    f:Show()

    local riseDur  = dur * 0.22
    local holdDur  = dur * 0.38
    local fallDur  = dur * 0.40
    local el = 0
    f:SetScript("OnUpdate", function(self, dt)
        el = el + dt
        local alpha
        if el < riseDur then
            alpha = maxAlpha * (el / riseDur)
        elseif el < riseDur + holdDur then
            alpha = maxAlpha
        elseif el < dur then
            alpha = math_max(0, maxAlpha * (1 - (el - riseDur - holdDur) / fallDur))
        else
            self:SetScript("OnUpdate", nil)
            self:Hide()
            return
        end
        self:SetAlpha(alpha)
    end)
end

-- ─── PARTICLE BURST ─────────────────────────────────────────────────────────
-- Fires `count` particles from screen-center-relative position (relX, relY).
-- `spread` = max extra speed, `lifetime` = seconds before fade-out.
function AnimFX.ParticleBurst(relX, relY, count, r, g, b, spread, lifetime)
    count    = count    or 12
    spread   = spread   or 70
    lifetime = lifetime or 0.9
    r, g, b  = r or 1, g or 0.8, b or 0.2

    for i = 1, count do
        local p = GetParticle()
        if not p then break end

        local angle  = (i / count) * math_pi * 2 + math_rand() * 0.8
        local speed  = 30 + math_rand() * spread
        local vx     = math_cos(angle) * speed
        local vy     = math_sin(angle) * speed
        local sz     = 4 + math_rand() * 10
        local life   = lifetime * (0.65 + math_rand() * 0.7)
        local cr     = math_min(1, r + (math_rand() - 0.5) * 0.25)
        local cg     = math_min(1, g + (math_rand() - 0.5) * 0.25)
        local cb     = math_min(1, b + (math_rand() - 0.5) * 0.25)
        local texIdx = math_rand(1, #SPARK_TEXTURES)

        p.tex:SetTexture(SPARK_TEXTURES[texIdx])
        p.tex:SetVertexColor(cr, cg, cb, 1)
        p:SetSize(sz, sz)
        p:ClearAllPoints()
        p:SetPoint("CENTER", UIParent, "CENTER", relX, relY)
        p:SetAlpha(1)
        p:Show()

        local px, py = relX, relY
        local el = 0
        p:SetScript("OnUpdate", function(self, dt)
            el = el + dt
            if el >= life then
                self:SetScript("OnUpdate", nil)
                self:Hide()
                return
            end
            local prog = el / life
            px = px + vx * dt
            py = py + vy * dt
            vy = vy - 90 * dt
            self:ClearAllPoints()
            self:SetPoint("CENTER", UIParent, "CENTER", px, py)
            self:SetAlpha(1 - prog * prog)
            local s = math_max(1, sz * (1 - prog * 0.45))
            self:SetSize(s, s)
        end)
    end
end

-- ─── FRAME SHAKE ────────────────────────────────────────────────────────────
-- Shakes `frame` for `dur` seconds. Restores exact original position on finish.
-- Uses a dedicated ticker so it does NOT overwrite existing OnUpdate scripts.
function AnimFX.ShakeFrame(frame, intensity, dur)
    intensity = intensity or 8
    dur       = dur       or 0.5

    local point, relTo, relPoint, ox, oy = frame:GetPoint(1)
    if not point then return end

    -- Use a pooled ticker frame
    local ticker = AcquireTicker()
    local el = 0
    ticker:SetScript("OnUpdate", function(self, dt)
        el = el + dt
        if el >= dur then
            ReleaseTicker(self)
            frame:ClearAllPoints()
            frame:SetPoint(point, relTo, relPoint, ox, oy)
            return
        end
        local decay = 1 - (el / dur)
        local sx = math_sin(el * 44) * intensity * decay
        local sy = math_cos(el * 38) * intensity * 0.6 * decay
        frame:ClearAllPoints()
        frame:SetPoint(point, relTo, relPoint, ox + sx, oy + sy)
    end)
end

-- ─── GLOW RING EXPAND ────────────────────────────────────────────────────────
-- An expanding ring that emanates from `parent`'s center.
function AnimFX.GlowRing(parent, r, g, b, startSize, dur)
    startSize = startSize or 80
    dur       = dur       or 0.65
    r, g, b   = r or 1, g or 0.8, b or 0.2

    local ring = AcquireFXFrame(parent)
    ring:SetSize(startSize, startSize)
    ring:SetPoint("CENTER", parent, "CENTER")
    ring:SetFrameStrata(parent:GetFrameStrata() or "FULLSCREEN_DIALOG")
    ring:SetFrameLevel((parent:GetFrameLevel() or 100) + 8)

    ring.tex:SetTexture("Interface\\Cooldown\\star4")
    ring.tex:SetVertexColor(r, g, b, 1)
    ring:SetAlpha(0.9)
    ring:Show()

    local el = 0
    ring:SetScript("OnUpdate", function(self, dt)
        el = el + dt
        local prog  = math_min(el / dur, 1)
        local eased = 1 - (1 - prog) ^ 2
        local s = startSize * (1 + eased * 2.8)
        self:SetSize(s, s)
        self:SetAlpha(0.9 * (1 - eased))
        if el >= dur then
            ReleaseFXFrame(self)
        end
    end)
end

-- ─── SHIMMER SWEEP ───────────────────────────────────────────────────────────
-- A bright light streak sweeps across `frame` from left to right.
function AnimFX.ShimmerSweep(frame, r, g, b, dur)
    -- Sanfter radialer Bloom statt 8-Bit-Balken:
    -- 2 überlagerte Starburst-Texturen expandieren aus dem Zentrum heraus.
    dur = dur or 0.55
    r, g, b = r or 1, g or 0.9, b or 0.4

    if not frame:IsShown() then return end
    local W = frame:GetWidth()  or 400
    local H = frame:GetHeight() or 300

    local BLOOMS = {
        { delay = 0.00, texFile = "Interface\\Cooldown\\starburst", maxAlpha = 0.22, startSzF = 0.18, endSzF = 1.10 },
        { delay = 0.07, texFile = "Interface\\Cooldown\\ping4",     maxAlpha = 0.15, startSzF = 0.30, endSzF = 0.95 },
    }
    local baseLevel = (frame:GetFrameLevel() or 100) + 14

    for i, cfg in ipairs(BLOOMS) do
        C_Timer.After(cfg.delay, function()
            if not frame:IsShown() then return end
            local bloomDur = dur - cfg.delay

            local bloom = AcquireFXFrame(frame)
            bloom:SetSize(1, 1)
            bloom:SetPoint("CENTER", frame, "CENTER", 0, 0)
            bloom:SetFrameLevel(baseLevel + i)

            bloom.tex:SetTexture(cfg.texFile)
            bloom.tex:SetVertexColor(r, g, b, 1)
            bloom:SetAlpha(0)
            bloom:Show()

            local startSz = W * cfg.startSzF
            local endSz   = W * cfg.endSzF
            local el = 0
            bloom:SetScript("OnUpdate", function(self, dt)
                el = el + dt
                local prog = math_min(1, el / bloomDur)
                if prog >= 1 then
                    ReleaseFXFrame(self)
                    return
                end
                local sz    = startSz + (endSz - startSz) * (1 - (1 - prog)^2)
                local alpha = math_sin(prog * math_pi) * cfg.maxAlpha
                self:SetSize(sz, sz)
                self:SetAlpha(math_max(0, alpha))
            end)
        end)
    end
end

-- ─── ICON PUNCH (spring bounce) ──────────────────────────────────────────────
-- Scales `frame` from `fromScale` down to 1.0 with a satisfying spring overshoot.
function AnimFX.IconPunch(frame, fromScale, dur)
    fromScale = fromScale or 1.45
    dur       = dur       or 0.32

    local el = 0
    local prevScript = frame:GetScript("OnUpdate")
    frame:SetScale(fromScale)
    frame:SetScript("OnUpdate", function(self, dt)
        el = el + dt
        if el > dur * 2 then
            self:SetScale(1)
            self:SetScript("OnUpdate", prevScript)
            return
        end
        local p  = el / dur
        local s  = 1 + (fromScale - 1) * math_exp(-p * 5.5) * math_cos(p * math_pi * 2.8)
        self:SetScale(math_max(0.5, s))
    end)
end

-- ─── SPARKLE TRAIL ───────────────────────────────────────────────────────────
-- Three sequential sparkle flashes sweeping diagonally across `frame`.
function AnimFX.SparkleTrail(frame, r, g, b, dur)
    dur = dur or 0.75
    r, g, b = r or 1, g or 0.9, b or 0.4

    local w = frame:GetWidth()  or 220
    local h = frame:GetHeight() or 60

    for wave = 0, 2 do
        C_Timer.After(wave * 0.20, function()
            local p = GetParticle()
            if not p then return end

            p.tex:SetTexture("Interface\\Cooldown\\star4")
            p.tex:SetVertexColor(r, g, b, 1)
            p:SetSize(16, 16)
            p:SetParent(frame)
            p:ClearAllPoints()
            p:SetPoint("LEFT", frame, "LEFT", 0, 0)
            p:SetAlpha(1)
            p:Show()

            local el = 0
            p:SetScript("OnUpdate", function(self, dt)
                el = el + dt
                local prog = el / dur
                if prog >= 1 then
                    self:SetScript("OnUpdate", nil)
                    self:SetParent(UIParent)
                    self:Hide()
                    return
                end
                local sweepX = prog * w
                local sweepY = math_sin(prog * math_pi) * h * 0.28
                self:ClearAllPoints()
                self:SetPoint("LEFT", frame, "LEFT", sweepX, sweepY - h * 0.14)
                local alpha = math_sin(prog * math_pi)
                self:SetAlpha(alpha)
                local s = 8 + alpha * 14
                self:SetSize(s, s)
            end)
        end)
    end
end

-- ─── RUNE CIRCLE ─────────────────────────────────────────────────────────────
-- Four rune-like textures that orbit `parent` briefly then fade out.
function AnimFX.RuneCircle(parent, r, g, b, radius, dur)
    radius = radius or 55
    dur    = dur    or 1.0
    r, g, b = r or 1, g or 0.8, b or 0.2

    local runes = {}
    for i = 1, 4 do
        local rune = AcquireFXFrame(parent)
        rune:SetSize(20, 20)
        rune:SetPoint("CENTER", parent, "CENTER")
        rune:SetFrameLevel((parent:GetFrameLevel() or 100) + 6)
        rune.tex:SetTexture("Interface\\Cooldown\\ping4")
        rune.tex:SetVertexColor(r, g, b, 1)
        rune:Show()
        rune:SetAlpha(0)
        runes[i] = rune
    end

    local el = 0
    local startAngles = {0, math_pi * 0.5, math_pi, math_pi * 1.5}
    local ticker = AcquireTicker()
    ticker:SetScript("OnUpdate", function(self, dt)
        el = el + dt
        if el >= dur then
            ReleaseTicker(self)
            for i = 1, 4 do ReleaseFXFrame(runes[i]) end
            return
        end
        local prog     = el / dur
        local alpha
        if prog < 0.2 then
            alpha = prog / 0.2
        elseif prog < 0.7 then
            alpha = 1.0
        else
            alpha = math_max(0, 1 - (prog - 0.7) / 0.3)
        end
        local angSpeed = math_pi * 2.4
        local curRad   = radius * (0.6 + prog * 0.4)
        for i = 1, 4 do
            local ang = startAngles[i] + el * angSpeed
            local rx  = math_cos(ang) * curRad
            local ry  = math_sin(ang) * curRad
            runes[i]:ClearAllPoints()
            runes[i]:SetPoint("CENTER", parent, "CENTER", rx, ry)
            runes[i]:SetAlpha(math_max(0, alpha))
        end
    end)
    ticker:Show()
end

-- ─── DEATH WAIL ──────────────────────────────────────────────────────────────
-- The full cinematic death sequence:
-- Vignette → class flash → white cut → glow ring → particles → shake → shimmer
function AnimFX.DeathWail(overlay, r, g, b)
    r, g, b = r or 0.8, g or 0.1, b or 0.1

    -- 1. Dark vignette descends on world
    AnimFX.Vignette(2.8, 0.52)

    -- 2. Class-colored pulse flash
    AnimFX.ScreenFlash(r * 0.6, g * 0.6, b * 0.6, 0.45, 0.38)

    -- 3. Bright white cut (cinematic smash)
    C_Timer.After(0.12, function()
        AnimFX.ScreenFlash(0.95, 0.95, 0.95, 0.22, 0.65)
    end)

    -- 4. Expanding glow ring
    C_Timer.After(0.06, function()
        local sz = math_max(overlay:GetWidth(), overlay:GetHeight()) * 1.0
        AnimFX.GlowRing(overlay, r, g, b, sz, 0.75)
    end)

    -- 5. Rune orbit for 1.2s
    C_Timer.After(0.08, function()
        AnimFX.RuneCircle(overlay, r, g, b, 80, 1.2)
    end)

    -- 6. Particle explosion from overlay center
    local cx, cy = overlay:GetCenter()
    if cx and cy then
        local relX = cx - UIParent:GetWidth()  * 0.5
        local relY = cy - UIParent:GetHeight() * 0.5
        C_Timer.After(0.14, function()
            AnimFX.ParticleBurst(relX, relY, 22, r, g, b, 95, 1.45)
            AnimFX.ParticleBurst(relX, relY, 10, 1.0, 0.85, 0.30, 50, 0.95)
        end)
    end

    -- 7. Frame shake
    C_Timer.After(0.09, function()
        AnimFX.ShakeFrame(overlay, 13, 0.55)
    end)

    -- 8. Gold shimmer sweep after shake settles
    C_Timer.After(0.65, function()
        AnimFX.ShimmerSweep(overlay, r, g, b, 0.62)
    end)

    -- 9. Second subtle particle wave
    if cx and cy then
        local relX = cx - UIParent:GetWidth()  * 0.5
        local relY = cy - UIParent:GetHeight() * 0.5
        C_Timer.After(0.9, function()
            AnimFX.ParticleBurst(relX, relY, 12, r * 0.8, g * 0.8, b * 0.8, 55, 1.0)
        end)
    end
end

-- ─── MILESTONE FANFARE ───────────────────────────────────────────────────────
-- Gold-toned spectacular unlock effect for milestone toasts.
function AnimFX.MilestoneFanfare(toast, r, g, b)
    r, g, b = r or 1.0, g or 0.82, b or 0.0

    -- Screen flash (golden shimmer)
    AnimFX.ScreenFlash(r, g, b, 0.58, 0.26)

    -- Expanding glow ring
    C_Timer.After(0.05, function()
        local sz = math_max(toast:GetWidth(), toast:GetHeight()) * 1.3
        AnimFX.GlowRing(toast, r, g, b, sz, 0.85)
    end)

    -- Rune orbit briefly
    C_Timer.After(0.06, function()
        AnimFX.RuneCircle(toast, r, g, b, 50, 0.9)
    end)

    -- Particle burst
    local cx, cy = toast:GetCenter()
    if cx and cy then
        local relX = cx - UIParent:GetWidth()  * 0.5
        local relY = cy - UIParent:GetHeight() * 0.5
        C_Timer.After(0.10, function()
            AnimFX.ParticleBurst(relX, relY, 26, r, g, b, 115, 1.65)
            AnimFX.ParticleBurst(relX, relY, 14, 1, 1, 1, 65, 1.1)
        end)
    end

    -- Sparkle sweep across toast
    C_Timer.After(0.18, function()
        AnimFX.SparkleTrail(toast, r, g, b, 0.72)
    end)

    -- Shimmer sweep
    C_Timer.After(0.28, function()
        AnimFX.ShimmerSweep(toast, r, g, b, 0.72)
    end)
end

-- ─── ACHIEVEMENT FANFARE ─────────────────────────────────────────────────────
function AnimFX.AchievementFanfare(toast, r, g, b)
    r, g, b = r or 0.2, g or 1.0, b or 0.45

    AnimFX.ScreenFlash(r, g, b, 0.52, 0.22)

    C_Timer.After(0.06, function()
        local sz = math_max(toast:GetWidth(), toast:GetHeight()) * 1.25
        AnimFX.GlowRing(toast, r, g, b, sz, 0.78)
    end)

    local cx, cy = toast:GetCenter()
    if cx and cy then
        local relX = cx - UIParent:GetWidth()  * 0.5
        local relY = cy - UIParent:GetHeight() * 0.5
        C_Timer.After(0.10, function()
            AnimFX.ParticleBurst(relX, relY, 20, r, g, b, 100, 1.5)
            AnimFX.ParticleBurst(relX, relY, 10, 1, 1, 0.5, 55, 1.0)
        end)
    end

    C_Timer.After(0.22, function()
        AnimFX.ShimmerSweep(toast, r, g, b, 0.65)
    end)
end

-- ─── CALENDAR BELL ───────────────────────────────────────────────────────────
function AnimFX.CalendarBell(toast, isDelete)
    local r, g, b = isDelete and 0.9 or 0.3, isDelete and 0.2 or 0.65, isDelete and 0.1 or 1.0

    AnimFX.ScreenFlash(r, g, b, 0.42, 0.18)

    C_Timer.After(0.05, function()
        local sz = math_max(toast:GetWidth(), toast:GetHeight()) * 1.1
        AnimFX.GlowRing(toast, r, g, b, sz, 0.65)
    end)

    local cx, cy = toast:GetCenter()
    if cx and cy then
        local relX = cx - UIParent:GetWidth()  * 0.5
        local relY = cy - UIParent:GetHeight() * 0.5
        C_Timer.After(0.10, function()
            AnimFX.ParticleBurst(relX, relY, 18, r, g, b, 85, 1.3)
        end)
    end

    if not isDelete then
        C_Timer.After(0.2, function()
            AnimFX.ShimmerSweep(toast, r, g, b, 0.58)
        end)
    end
end

-- ─── BOOK OPEN EFFECT ────────────────────────────────────────────────────────
function AnimFX.BookOpenEffect(bookFrame)
    -- ════════════════════════════════════════════════════════════════════
    -- DAS GRIMOIRE ERWACHT — 6-stufige Kino-Sequenz
    -- ════════════════════════════════════════════════════════════════════
    local W  = bookFrame:GetWidth()  or 800
    local H  = bookFrame:GetHeight() or 500
    local cx, cy = bookFrame:GetCenter()
    local relX = cx and (cx - UIParent:GetWidth()  * 0.5) or 0
    local relY = cy and (cy - UIParent:GetHeight() * 0.5) or 0

    -- ★ t=0.00  Phase 1: Warmer Gold-Flash (Erwachen)
    AnimFX.ScreenFlash(0.88, 0.70, 0.22, 0.80, 0.16)

    -- ★ t=0.00  Phase 1: Erster Rune-Ring expandiert nach außen
    AnimFX.GlowRing(bookFrame, 0.95, 0.78, 0.28, W * 0.42, 0.88)

    -- ★ t=0.00  Phase 1: Kleiner RuneCircle (innen, schnell)
    AnimFX.RuneCircle(bookFrame, 0.95, 0.78, 0.35, 58, 0.90)

    -- ★ t=0.06  Phase 2: Staub steigt aus den unteren Ecken auf
    AnimFX.ParticleBurst(relX - W * 0.30, relY - H * 0.28, 16, 0.78, 0.62, 0.24, 52, 2.4)
    AnimFX.ParticleBurst(relX + W * 0.30, relY - H * 0.28, 16, 0.78, 0.62, 0.24, 52, 2.4)
    AnimFX.ParticleBurst(relX,             relY - H * 0.18, 12, 0.70, 0.56, 0.20, 40, 2.0)
    AnimFX.ParticleBurst(relX,             relY + H * 0.10,  8, 0.85, 0.70, 0.30, 35, 1.6)

    -- ★ t=0.38  Phase 3: AUFPRALL — Buch liegt offen
    C_Timer.After(0.38, function()
        if not bookFrame:IsShown() then return end
        -- Subtiles Aufprall-Zittern
        AnimFX.ShakeFrame(bookFrame, 5, 0.24)
        -- Innerer GlowRing (kompakt, heiß)
        AnimFX.GlowRing(bookFrame, 0.95, 0.78, 0.28, W * 0.76, 0.95)
        -- Äußerer GlowRing (weit, nach Verzögerung)
        C_Timer.After(0.16, function()
            if bookFrame:IsShown() then
                AnimFX.GlowRing(bookFrame, 0.82, 0.52, 0.14, W * 1.12, 0.82)
            end
        end)
        -- RuneCircle (mittlerer Ring, außen)
        AnimFX.RuneCircle(bookFrame, 0.90, 0.70, 0.20, 92, 1.40)
        -- Partikel-Explosion: 3 Schichten (Klasse + Gold + Weiß)
        AnimFX.ParticleBurst(relX, relY,       28, 0.95, 0.78, 0.26, 118, 1.90)
        AnimFX.ParticleBurst(relX, relY,       16, 1.00, 0.94, 0.55,  65, 1.35)
        AnimFX.ParticleBurst(relX, relY,        9, 1.00, 1.00, 1.00,  44, 0.95)
    end)

    -- ★ t=0.55  Phase 4: Shimmer-Welle über das Buch
    C_Timer.After(0.55, function()
        if bookFrame:IsShown() then
            AnimFX.ShimmerSweep(bookFrame, 0.96, 0.82, 0.30, 0.92)
        end
    end)

    -- ★ t=0.80  Phase 5: Zweiter warmer Gold-Flash (sehr subtil)
    C_Timer.After(0.80, function()
        if bookFrame:IsShown() then
            AnimFX.ScreenFlash(0.75, 0.55, 0.15, 0.60, 0.10)
        end
    end)

    -- ★ t=1.10  Phase 6: Äußerster RuneCircle (langsam, majestätisch)
    C_Timer.After(1.10, function()
        if bookFrame:IsShown() then
            AnimFX.RuneCircle(bookFrame, 0.72, 0.48, 0.10, 118, 1.80)
            AnimFX.GlowRing(bookFrame, 0.65, 0.42, 0.08, W * 1.35, 0.55)
        end
    end)
end

-- ═══════════════════════════════════════════════════════════════════════════
-- IMMERSIONS-ARSENAL v8.3 — Das lebendige Grimoire
-- EmberAmbience · CandleFlicker · BreathGlow · CinematicBars
-- TypewriterText · ScrollUnfurl · PageTurn · BellTolls · DustMotes
-- ═══════════════════════════════════════════════════════════════════════════

-- ─── EMBER AMBIENCE ──────────────────────────────────────────────────────────
-- Kontinuierlich aufsteigende Glut-Funken rund ums Grimoire.
-- Rückgabe: handle { active=bool } — handle.active=false stoppt alles.
function AnimFX.EmberAmbience(parent, count, r, g, b)
    count = count or 10
    r, g, b = r or 1.0, g or 0.45, b or 0.06
    local embers = {}
    local function Respawn(e)
        local W = (parent:GetWidth()  or 900) * 0.5
        local H = (parent:GetHeight() or 520) * 0.5
        local ang  = math_rand() * math_pi * 2
        local dist = 0.55 + math_rand() * 0.40
        e.ox      = math_cos(ang) * W * dist
        e.oy      = (math_rand() - 0.68) * H
        e.vx      = (math_rand() - 0.5) * 9
        e.vy      = 16 + math_rand() * 24
        e.life    = 0
        e.maxLife = 1.5 + math_rand() * 1.8
        e.sz      = 2.5 + math_rand() * 4.5
        e.rv      = math_min(1, r + (math_rand()-0.5)*0.22)
        e.gv      = math_min(1, g + (math_rand()-0.5)*0.14)
        e.bv      = math_min(1, b + (math_rand()-0.5)*0.10)
    end
    for i = 1, count do
        local f = AcquireFXFrame(UIParent)
        f:SetSize(4, 4); f:SetFrameStrata("FULLSCREEN"); f:SetFrameLevel(8800 + i)
        f.tex:SetTexture("Interface\\Cooldown\\star4")
        f:Hide()
        local e = { f=f, tex=f.tex, ox=0, oy=0, vx=0, vy=0, life=0, maxLife=2, sz=4, rv=r, gv=g, bv=b }
        Respawn(e); e.life = -math_rand() * 3.0
        embers[i] = e
    end
    local handle = { active = true }
    local ticker = AcquireTicker()
    -- Performance: auf ~30 Hz gedrosselt (Glut braucht keine 144 FPS).
    -- SetPoint ersetzt einen gleichnamigen Anker — ClearAllPoints pro
    -- Frame war reine Anker-Neuberechnungs-Verschwendung. Farben aendern
    -- sich nur beim Respawn, nicht pro Tick.
    local accum = 0
    for i = 1, count do
        embers[i].tex:SetVertexColor(embers[i].rv, embers[i].gv, embers[i].bv, 1)
    end
    ticker:SetScript("OnUpdate", function(self, dt)
        if not handle.active then
            ReleaseTicker(self)
            for _, e in ipairs(embers) do ReleaseFXFrame(e.f) end
            return
        end
        accum = accum + dt
        if accum < 0.033 then return end
        dt, accum = accum, 0
        if not parent:IsShown() then
            for _, e in ipairs(embers) do e.f:Hide() end; return
        end
        local px, py = parent:GetCenter(); if not px then return end
        local cx = px - UIParent:GetWidth() * 0.5
        local cy = py - UIParent:GetHeight() * 0.5
        for i = 1, count do
            local e = embers[i]; e.life = e.life + dt
            if e.life < 0 then e.f:Hide()
            else
                if e.life >= e.maxLife then
                    Respawn(e)
                    e.tex:SetVertexColor(e.rv, e.gv, e.bv, 1)
                end
                e.ox = e.ox + e.vx * dt
                e.oy = e.oy + e.vy * dt
                e.vx = e.vx + (math_rand()-0.5) * 2.5 * dt
                local prog  = e.life / e.maxLife
                local alpha = math_max(0, math_sin(prog * math_pi) * 0.72)
                local sz    = math_max(1, e.sz * (0.5 + 0.5 * math_sin(prog * math_pi)))
                e.f:SetPoint("CENTER", UIParent, "CENTER", cx + e.ox, cy + e.oy)
                e.f:SetAlpha(alpha); e.f:SetSize(sz, sz); e.f:Show()
            end
        end
    end)
    ticker:Show(); handle.ticker = ticker; handle.embers = embers
    return handle
end

-- ─── CANDLE FLICKER ──────────────────────────────────────────────────────────
-- Organisch flackerndes Kerzenlicht am Grimoire-Rand.
-- Rückgabe: glow-Frame (call :Hide() zum Stoppen).
function AnimFX.CandleFlicker(parent, offX, offY, r, g, b)
    offX, offY = offX or 0, offY or 0
    r, g, b    = r or 1.0, g or 0.68, b or 0.14
    local glow = CreateFrame("Frame", nil, parent)
    glow:SetSize(80, 80); glow:SetPoint("CENTER", parent, "CENTER", offX, offY)
    glow:SetFrameLevel((parent:GetFrameLevel() or 5) + 2)
    local outer = glow:CreateTexture(nil, "OVERLAY")
    outer:SetAllPoints(); outer:SetTexture("Interface\\Cooldown\\starburst")
    outer:SetBlendMode("ADD"); outer:SetVertexColor(r, g, b, 1)
    local inner = glow:CreateTexture(nil, "OVERLAY")
    inner:SetSize(26, 26); inner:SetPoint("CENTER")
    inner:SetTexture("Interface\\Cooldown\\ping4"); inner:SetBlendMode("ADD")
    inner:SetVertexColor(1.0, 0.94, 0.60, 1)
    glow:SetAlpha(0.30); glow:Show()
    local curA = 0.30; local targetA = 0.30; local nextChange = 0.0; local el = 0
    glow:SetScript("OnUpdate", function(self, dt)
        if not self:IsShown() then return end
        el = el + dt
        if el >= nextChange then
            targetA    = 0.16 + math_rand() * 0.52
            nextChange = el + 0.06 + math_rand() * 0.20
        end
        local spd = 6.5 + math_rand() * 4.0
        curA = curA + (targetA - curA) * math_min(1, spd * dt)
        self:SetAlpha(math_max(0.08, math_min(0.76, curA)))
    end)
    return glow
end

-- ─── BREATH GLOW ─────────────────────────────────────────────────────────────
-- Langsam atmendes Außenleuchten um einen Frame.
-- Rückgabe: glowFrame, animGroup (beide stoppen zum Entfernen).
function AnimFX.BreathGlow(frame, r, g, b, period, maxAlpha)
    period   = period   or 3.8; maxAlpha = maxAlpha or 0.40
    r, g, b  = r or 0.92, g or 0.72, b or 0.22

    -- Pro Ziel-Frame nur EINMAL bauen — wird beim erneuten Oeffnen
    -- wiederverwendet statt jedes Mal einen neuen Frame zu erzeugen.
    local glow = frame._gdlBreathGlow
    if not glow then
        local W  = (frame:GetWidth()  or 900) + 68
        local H  = (frame:GetHeight() or 520) + 68
        glow = CreateFrame("Frame", nil, frame)
        glow:SetSize(W, H); glow:SetPoint("CENTER", frame, "CENTER")
        glow:SetFrameLevel(math_max(1, (frame:GetFrameLevel() or 5) - 1))
        glow.tex = glow:CreateTexture(nil, "ARTWORK"); glow.tex:SetAllPoints()
        glow.tex:SetTexture("Interface\\Cooldown\\starburst"); glow.tex:SetBlendMode("ADD")
        glow.ag = glow:CreateAnimationGroup(); glow.ag:SetLooping("BOUNCE")
        glow.fade = glow.ag:CreateAnimation("Alpha")
        glow.fade:SetOrder(1)
        frame._gdlBreathGlow = glow
    end
    glow.tex:SetVertexColor(r, g, b, 1)
    glow.fade:SetFromAlpha(0.05); glow.fade:SetToAlpha(maxAlpha)
    glow.fade:SetDuration(period * 0.5); glow.fade:SetSmoothing("IN_OUT")
    glow:SetAlpha(0.05); glow:Show()
    glow.ag:Stop(); glow.ag:Play()
    return glow, glow.ag
end

-- ─── CINEMATIC BARS ──────────────────────────────────────────────────────────
-- Letterbox-Balken für den Tod-Cinematic (oben + unten).
local _cinematicBarsFrame = nil
function AnimFX.CinematicBars(show, dur)
    dur = dur or 0.45
    if not _cinematicBarsFrame then
        local f = CreateFrame("Frame", "GDLCinematicBars", UIParent)
        f:SetAllPoints(UIParent); f:SetFrameStrata("FULLSCREEN_DIALOG"); f:SetFrameLevel(9970)
        local top = f:CreateTexture(nil, "BACKGROUND")
        top:SetColorTexture(0,0,0,1); top:SetPoint("TOPLEFT"); top:SetPoint("TOPRIGHT"); top:SetHeight(60)
        f.top = top
        local bot = f:CreateTexture(nil, "BACKGROUND")
        bot:SetColorTexture(0,0,0,1); bot:SetPoint("BOTTOMLEFT"); bot:SetPoint("BOTTOMRIGHT"); bot:SetHeight(60)
        f.bot = bot
        f:Hide(); _cinematicBarsFrame = f
    end
    local f = _cinematicBarsFrame; f:SetScript("OnUpdate", nil)
    if show then
        f:SetAlpha(0); f:Show()
        local el = 0
        f:SetScript("OnUpdate", function(self, dt)
            el = el + dt; local p = math_min(el / dur, 1)
            self:SetAlpha(1 - (1-p)^3)
            if el >= dur then self:SetScript("OnUpdate", nil) end
        end)
    else
        if not f:IsShown() then return f end
        local el = 0
        f:SetScript("OnUpdate", function(self, dt)
            el = el + dt; local p = math_min(el / dur, 1)
            self:SetAlpha(math_max(0, 1 - p * p))
            if el >= dur then self:SetScript("OnUpdate", nil); self:Hide() end
        end)
    end
    return f
end

-- ─── TYPEWRITER TEXT ─────────────────────────────────────────────────────────
-- Schreibt Text Zeichen für Zeichen in einen FontString.
-- charsPerSec: Zeichen/Sek (Standard 40). Rückgabe: ticker (call :Hide() zum Abbruch).
function AnimFX.TypewriterText(fontstring, text, charsPerSec, onDone)
    charsPerSec = charsPerSec or 40
    local total = #text
    if total == 0 then fontstring:SetText(text); if onDone then onDone() end; return end
    local el = 0; local prev = 0
    local ticker = AcquireTicker()
    ticker:SetScript("OnUpdate", function(self, dt)
        el = el + dt
        local shown = math_min(total, math_max(1, math.floor(el * charsPerSec)))
        if shown ~= prev then prev = shown; fontstring:SetText(string.sub(text, 1, shown)) end
        if shown >= total then
            ReleaseTicker(self)
            if onDone then onDone() end
        end
    end)
    return ticker
end

-- ─── SCROLL UNFURL ───────────────────────────────────────────────────────────
-- Rollt einen Frame wie ein Pergament auf (Scale 0.04→1, Ease-Out).
-- Nutzt frame:SetScale() statt Animation-Scale API (Classic-kompatibel).
function AnimFX.ScrollUnfurl(frame, dur, fromAlpha)
    dur = dur or 0.38; fromAlpha = fromAlpha or 0.0

    -- Ursprüngliche Skalierung des Nutzers bewahren
    local targetScale = frame.currentScale or frame:GetScale() or 1.0
    local startScale  = targetScale * 0.04

    frame:SetScale(startScale)
    frame:SetAlpha(fromAlpha)
    frame:Show()

    local el = 0
    local ticker = AcquireTicker()
    ticker:SetScript("OnUpdate", function(self, dt)
        el = el + dt
        local prog  = math_min(1, el / dur)
        local eased = 1 - (1 - prog) ^ 2   -- Quadratic Ease-Out

        -- Scale: startScale → targetScale
        frame:SetScale(math_max(startScale, targetScale * eased))

        -- Alpha: fromAlpha → 1, schnell (ersten 55% der Dauer)
        local alphaP = math_min(1, prog / 0.55)
        frame:SetAlpha(fromAlpha + (1.0 - fromAlpha) * alphaP)

        if prog >= 1 then
            frame:SetScale(targetScale)
            frame:SetAlpha(1)
            ReleaseTicker(self)
        end
    end)
end

-- ─── PAGE TURN ───────────────────────────────────────────────────────────────
-- Slide contentFrame raus → populateFunc → rein. dir: -1=links, 1=rechts.
function AnimFX.PageTurn(contentFrame, populateFunc, dir, dur)
    dir = dir or -1; dur = dur or 0.22
    local slide = 30
    local pt, rTo, rPt, ox, oy = contentFrame:GetPoint(1)
    if not pt then pt, rTo, rPt, ox, oy = "TOPLEFT", contentFrame:GetParent(), "TOPLEFT", 0, 0 end
    local elOut = 0
    contentFrame:SetScript("OnUpdate", function(self, dt)
        elOut = elOut + dt; local p = math_min(elOut/dur, 1); local e = p*p
        self:ClearAllPoints(); self:SetPoint(pt, rTo, rPt, ox+dir*slide*e, oy)
        self:SetAlpha(math_max(0, 1 - e * 2.2))
        if elOut >= dur then
            self:SetScript("OnUpdate", nil); self:SetAlpha(0)
            if populateFunc then populateFunc() end
            local elIn = 0
            self:SetScript("OnUpdate", function(s2, dt2)
                elIn = elIn + dt2; local p2 = math_min(elIn/dur, 1); local e2 = 1-(1-p2)^2
                s2:ClearAllPoints(); s2:SetPoint(pt, rTo, rPt, ox-dir*slide*(1-e2), oy)
                s2:SetAlpha(e2)
                if elIn >= dur then
                    s2:SetScript("OnUpdate", nil)
                    s2:ClearAllPoints(); s2:SetPoint(pt, rTo, rPt, ox, oy); s2:SetAlpha(1)
                end
            end)
        end
    end)
end

-- ─── BELL TOLLS ──────────────────────────────────────────────────────────────
-- Tod-Herold: Vignette + klassenfarbener Flash. Sound via DeathBell-Modul.
function AnimFX.BellTolls(r, g, b)
    r, g, b = r or 0.45, g or 0.02, b or 0.02
    AnimFX.Vignette(2.4, 0.70)
    AnimFX.ScreenFlash(r*0.45, g*0.08, b*0.04, 1.2, 0.62)
    C_Timer.After(0.18, function() AnimFX.ScreenFlash(0.90, 0.86, 0.76, 0.22, 0.80) end)
end

-- ─── DUST MOTES ──────────────────────────────────────────────────────────────
-- Einmalige Staubwolke beim Kapitelwechsel (kein Endlosloop).
function AnimFX.DustMotes(frame, count, dur)
    count = count or 8; dur = dur or 1.8
    local W = (frame:GetWidth() or 400) * 0.5
    local H = (frame:GetHeight() or 520) * 0.5
    local motes = {}; local handle = { active = true }
    for i = 1, count do
        local m = AcquireFXFrame(UIParent)
        m:SetSize(3, 3); m:SetFrameStrata("FULLSCREEN"); m:SetFrameLevel(8750 + i)
        m.tex:SetTexture("Interface\\Buttons\\WHITE8x8")
        m.tex:SetVertexColor(0.88, 0.76, 0.42, 1); m:Hide()
        local ang = math_rand() * math_pi * 2
        motes[i] = { f=m, life=-math_rand()*(dur*0.5), mLife=dur*(0.5+math_rand()*0.5),
                     ox=(math_rand()-0.5)*W, oy=(math_rand()-0.5)*H,
                     vx=math_cos(ang)*(2+math_rand()*5), vy=math_sin(ang)*(2+math_rand()*5)+4 }
    end
    local ticker = AcquireTicker()
    ticker:SetScript("OnUpdate", function(self, dt)
        if not handle.active then
            ReleaseTicker(self)
            for _, m in ipairs(motes) do ReleaseFXFrame(m.f) end
            return
        end
        local px, py = frame:GetCenter(); if not px then return end
        local cx = px - UIParent:GetWidth()*0.5; local cy = py - UIParent:GetHeight()*0.5
        local anyActive = false
        for i = 1, count do
            local m = motes[i]; m.life = m.life + dt
            if m.life >= 0 and m.life < m.mLife then
                anyActive = true
                m.ox = m.ox + m.vx*dt; m.oy = m.oy + m.vy*dt
                local alpha = math_max(0, math_sin((m.life/m.mLife)*math_pi)*0.38)
                m.f:ClearAllPoints()
                m.f:SetPoint("CENTER", UIParent, "CENTER", cx+m.ox, cy+m.oy)
                m.f:SetAlpha(alpha); m.f:Show()
            else m.f:Hide() end
        end
        if not anyActive then handle.active = false end
    end)
    ticker:Show(); handle.ticker = ticker; handle.motes = motes; return handle
end

-- ═══════════════════════════════════════════════════════════════════════════════
-- IMMERSIONS-ARSENAL v8.4 — CINEMATIC DEATH OVERHAUL
-- SoulRise · ArcaneOrbit · RuneExplosion · PulseRings
-- ScreenBlackout · StartOverlayAmbience · StopOverlayAmbience · DeathCinematic
-- ═══════════════════════════════════════════════════════════════════════════════

-- ─── SOUL RISE ──────────────────────────────────────────────────────────────
-- Seelen-Partikel steigen langsam aus dem unteren Rand des parent-Frames auf.
-- Returns handle { active=bool } — handle.active=false stoppt alles.
function AnimFX.SoulRise(parent, r, g, b)
    r, g, b = r or 0.72, g or 0.82, b or 1.0
    local COUNT = 18
    local SOUL_TEX = {
        "Interface\\Cooldown\\ping4",
        "Interface\\Cooldown\\star4",
        "Interface\\Cooldown\\starburst",
    }

    local function RespawnSoul(s)
        local W = (parent:GetWidth()  or 520) * 0.5
        local H = (parent:GetHeight() or 340) * 0.5
        s.ox     = (math_rand() - 0.5) * W * 1.4
        s.oy     = -H - math_rand() * 20
        s.vx     = (math_rand() - 0.5) * 7
        s.vy     = 15 + math_rand() * 20
        s.wobble = math_rand() * math_pi * 2
        s.wfreq  = 0.9 + math_rand() * 1.1
        s.wamp   = 4 + math_rand() * 7
        s.life   = 0
        s.mLife  = 2.2 + math_rand() * 2.4
        s.sz     = 3 + math_rand() * 9
        s.rv     = math_min(1, r + (math_rand()-0.5)*0.18)
        s.gv     = math_min(1, g + (math_rand()-0.5)*0.12)
        s.bv     = math_min(1, b + (math_rand()-0.5)*0.12)
    end

    local souls = {}
    for i = 1, COUNT do
        local f = AcquireFXFrame(UIParent)
        f:SetSize(6, 6); f:SetFrameStrata("FULLSCREEN"); f:SetFrameLevel(8820 + i)
        f.tex:SetTexture(SOUL_TEX[math_rand(1, #SOUL_TEX)])
        f:Hide()
        local s = { f=f, tex=f.tex, ox=0, oy=0, vx=0, vy=0,
                    life=0, mLife=2, sz=5, rv=r, gv=g, bv=b,
                    wobble=0, wfreq=1, wamp=5 }
        RespawnSoul(s); s.life = -math_rand() * s.mLife
        souls[i] = s
    end

    local handle = { active = true }
    local ticker = AcquireTicker()
    ticker:SetScript("OnUpdate", function(self, dt)
        if not handle.active then
            ReleaseTicker(self)
            for _, s in ipairs(souls) do ReleaseFXFrame(s.f) end
            return
        end
        if not parent:IsShown() then
            for _, s in ipairs(souls) do s.f:Hide() end; return
        end
        local px, py = parent:GetCenter(); if not px then return end
        local cx = px - UIParent:GetWidth()  * 0.5
        local cy = py - UIParent:GetHeight() * 0.5
        for i = 1, COUNT do
            local s = souls[i]; s.life = s.life + dt
            if s.life < 0 then s.f:Hide()
            else
                if s.life >= s.mLife then RespawnSoul(s) end
                s.wobble = s.wobble + s.wfreq * dt
                s.ox = s.ox + (s.vx + math_sin(s.wobble) * s.wamp) * dt
                s.oy = s.oy + s.vy * dt
                local prog  = s.life / s.mLife
                local alpha = math_max(0, math_sin(prog * math_pi) * 0.58)
                local sz    = math_max(1, s.sz * (0.5 + 0.5 * math_sin(prog * math_pi)))
                s.tex:SetVertexColor(s.rv, s.gv, s.bv, 1)
                s.f:ClearAllPoints()
                s.f:SetPoint("CENTER", UIParent, "CENTER", cx + s.ox, cy + s.oy)
                s.f:SetAlpha(alpha); s.f:SetSize(sz, sz); s.f:Show()
            end
        end
    end)
    ticker:Show(); handle.ticker = ticker; handle.souls = souls
    return handle
end

-- ─── ARCANE ORBIT ────────────────────────────────────────────────────────────
-- 5 Runen-Partikel umkreisen den parent-Frame auf elliptischen Bahnen.
-- Returns handle { active=bool } — handle.active=false stoppt alles.
function AnimFX.ArcaneOrbit(parent, r, g, b)
    r, g, b = r or 0.80, g or 0.50, b or 1.0
    local CFG = {
        { radius=135, speed= 0.45, size=16, tex="Interface\\Cooldown\\star4",     alpha=0.52, cr=r,   cg=g,   cb=b   },
        { radius= 98, speed=-0.70, size=12, tex="Interface\\Cooldown\\ping4",     alpha=0.44, cr=1.0, cg=0.80,cb=0.20},
        { radius=158, speed= 0.30, size=18, tex="Interface\\Cooldown\\starburst", alpha=0.38, cr=r,   cg=g,   cb=b   },
        { radius= 78, speed=-0.92, size=10, tex="Interface\\Cooldown\\star4",     alpha=0.48, cr=0.88,cg=0.28,cb=0.28},
        { radius=175, speed= 0.22, size=14, tex="Interface\\Cooldown\\ping4",     alpha=0.32, cr=0.60,cg=0.90,cb=1.00},
    }
    local runes = {}
    for i, cfg in ipairs(CFG) do
        local f = AcquireFXFrame(UIParent)
        f:SetSize(cfg.size, cfg.size)
        f:SetFrameStrata("FULLSCREEN"); f:SetFrameLevel(8830 + i)
        f.tex:SetTexture(cfg.tex)
        f.tex:SetVertexColor(cfg.cr, cfg.cg, cfg.cb, 1)
        f:SetAlpha(0); f:Hide()
        runes[i] = { f=f, cfg=cfg, ang = (i-1) * (math_pi * 2 / 5) }
    end

    local handle = { active = true }
    local el = 0
    local ticker = AcquireTicker()
    ticker:SetScript("OnUpdate", function(self, dt)
        if not handle.active then
            ReleaseTicker(self)
            for _, rn in ipairs(runes) do ReleaseFXFrame(rn.f) end
            return
        end
        if not parent:IsShown() then
            for _, rn in ipairs(runes) do rn.f:Hide() end; return
        end
        el = el + dt
        local px, py = parent:GetCenter(); if not px then return end
        local cx = px - UIParent:GetWidth()  * 0.5
        local cy = py - UIParent:GetHeight() * 0.5
        local fadeIn = math_min(el / 0.80, 1)
        for i, rn in ipairs(runes) do
            rn.ang = rn.ang + rn.cfg.speed * dt
            local rx = math_cos(rn.ang) * rn.cfg.radius
            local ry = math_sin(rn.ang) * rn.cfg.radius * 0.52
            local pulse = rn.cfg.alpha * (0.70 + 0.30 * math_sin(el * 2.0 + i))
            rn.f:ClearAllPoints()
            rn.f:SetPoint("CENTER", UIParent, "CENTER", cx + rx, cy + ry)
            rn.f:SetAlpha(fadeIn * pulse)
            rn.f:Show()
        end
    end)
    ticker:Show(); handle.ticker = ticker; handle.runes = runes
    return handle
end

-- ─── RUNE EXPLOSION ──────────────────────────────────────────────────────────
-- `count` Runen explodieren radial aus dem Zentrum von parent. One-shot.
function AnimFX.RuneExplosion(parent, r, g, b, count)
    count = count or 8; r, g, b = r or 0.80, g or 0.50, b or 1.0
    local TEX = { "Interface\\Cooldown\\star4", "Interface\\Cooldown\\starburst" }
    local px, py = parent:GetCenter(); if not px then return end
    local cx = px - UIParent:GetWidth()  * 0.5
    local cy = py - UIParent:GetHeight() * 0.5

    for i = 1, count do
        local ang   = ((i - 1) / count) * math_pi * 2
        local speed = 200 + math_rand() * 90
        local vx    = math_cos(ang) * speed
        local vy    = math_sin(ang) * speed
        local sz    = 24 + math_rand() * 28
        local dur   = 0.50 + math_rand() * 0.28
        local cr    = math_min(1, r + (math_rand()-0.5)*0.30)
        local cg    = math_min(1, g + (math_rand()-0.5)*0.20)
        local cb    = math_min(1, b + (math_rand()-0.5)*0.20)

        local f = AcquireFXFrame(UIParent)
        f:SetSize(sz, sz); f:SetFrameStrata("FULLSCREEN_DIALOG"); f:SetFrameLevel(8860 + i)
        f.tex:SetTexture(TEX[math_rand(1, 2)])
        f.tex:SetVertexColor(cr, cg, cb, 1)
        f:SetPoint("CENTER", UIParent, "CENTER", cx, cy)
        f:SetAlpha(1); f:Show()

        local rx2, ry2 = cx, cy
        local el = 0
        f:SetScript("OnUpdate", function(self, dt)
            el = el + dt
            if el >= dur then
                ReleaseFXFrame(self)
                return
            end
            local prog  = el / dur
            local decel = math_max(0.05, 1 - prog * 0.90)
            rx2 = rx2 + vx * decel * dt
            ry2 = ry2 + vy * decel * dt
            local s = math_max(1, sz * (0.55 + prog * 1.10))
            self:SetSize(s, s)
            self:ClearAllPoints(); self:SetPoint("CENTER", UIParent, "CENTER", rx2, ry2)
            self:SetAlpha(math_max(0, 1 - prog * prog * 1.40))
        end)
    end
end

-- ─── PULSE RINGS ─────────────────────────────────────────────────────────────
-- Feuert `count` GlowRings in schneller Sequenz mit `interval` Sek. Abstand.
function AnimFX.PulseRings(parent, r, g, b, count, interval)
    count = count or 3; interval = interval or 0.14
    r, g, b = r or 1.0, g or 0.45, b or 0.10
    local base = math_max(parent:GetWidth() or 300, parent:GetHeight() or 200)
    for i = 1, count do
        C_Timer.After((i - 1) * interval, function()
            if not parent:IsShown() then return end
            AnimFX.GlowRing(parent, r, g, b, base * (0.55 + i * 0.25), 0.62 + i * 0.08)
        end)
    end
end

-- ─── SCREEN BLACKOUT ─────────────────────────────────────────────────────────
-- Verdunkelt den Bildschirm. fadeDur=Einblendzeit, maxAlpha=Dunkelheit,
-- holdDur=Haltezeit, onPeak=Callback am Höhepunkt.
local _blackoutFrame = nil
function AnimFX.ScreenBlackout(fadeDur, maxAlpha, holdDur, onPeak)
    fadeDur  = fadeDur  or 0.35
    maxAlpha = maxAlpha or 0.70
    holdDur  = holdDur  or 0.10

    if not _blackoutFrame then
        _blackoutFrame = CreateFrame("Frame", "GDLScreenBlackout", UIParent)
        _blackoutFrame:SetAllPoints()
        _blackoutFrame:SetFrameStrata("FULLSCREEN_DIALOG")
        _blackoutFrame:SetFrameLevel(9980)
        local t = _blackoutFrame:CreateTexture(nil, "BACKGROUND"); t:SetAllPoints()
        t:SetTexture("Interface\\Buttons\\WHITE8x8"); t:SetVertexColor(0, 0, 0, 1)
        _blackoutFrame:Hide()
    end

    local f = _blackoutFrame; f:SetScript("OnUpdate", nil)
    f:SetAlpha(0); f:Show()
    local el = 0; local peaked = false
    f:SetScript("OnUpdate", function(self, dt)
        el = el + dt
        if el < fadeDur then
            self:SetAlpha(maxAlpha * (el / fadeDur))
        elseif el < fadeDur + holdDur then
            self:SetAlpha(maxAlpha)
            if not peaked then
                peaked = true; if onPeak then onPeak() end
            end
        else
            if not peaked then peaked = true; if onPeak then onPeak() end end
            local fadeOut = el - fadeDur - holdDur
            local a = math_max(0, maxAlpha * (1 - fadeOut / (fadeDur * 1.8)))
            self:SetAlpha(a)
            if a <= 0 then self:SetScript("OnUpdate", nil); self:Hide() end
        end
    end)
    return f
end

-- ─── START / STOP OVERLAY AMBIENCE ───────────────────────────────────────────
-- Startet Dauereffekte während das Overlay sichtbar ist.
-- Returns ambience handle { soul, orbit }.
function AnimFX.StartOverlayAmbience(overlay, r, g, b)
    local sr = math_min(1, (r or 0.8) * 0.55 + 0.40)
    local sg = math_min(1, (g or 0.1) * 0.55 + 0.50)
    local sb = math_min(1, (b or 0.1) * 0.55 + 0.80)
    local soulHandle  = AnimFX.SoulRise(overlay, sr, sg, sb)
    local orbitHandle = AnimFX.ArcaneOrbit(overlay, r, g, b)
    return { soul = soulHandle, orbit = orbitHandle }
end

function AnimFX.StopOverlayAmbience(handles)
    if not handles then return end
    if handles.soul  then handles.soul.active  = false end
    if handles.orbit then handles.orbit.active = false end
end

-- ─── DEATH CINEMATIC ─────────────────────────────────────────────────────────
-- MASTER ORCHESTRATOR: Vollständige Kino-Sequenz für den Gildentod.
-- overlay: GDLDeathOverlay Frame  |  r,g,b: Klassenfarbe
function AnimFX.DeathCinematic(overlay, r, g, b)
    r, g, b = r or 0.80, g or 0.10, b or 0.10

    -- ★ t=0.05 — KLASSEN-FLASH + SOUND (kein Blackout/Vignette)
    C_Timer.After(0.05, function()
        AnimFX.ScreenFlash(r * 0.40, g * 0.06, b * 0.03, 1.0, 0.55)
        C_Timer.After(0.16, function()
            AnimFX.ScreenFlash(0.88, 0.84, 0.74, 0.20, 0.75)
        end)
    end)

    -- ★ t=0.44 — POPUP ENTROLLT (ScrollUnfurl)
    C_Timer.After(0.44, function()
        if not overlay:IsShown() then return end
        AnimFX.ScrollUnfurl(overlay, 0.42, 0.0)
    end)

    -- ★ t=0.50 — 1. RUNEN-EXPLOSION (Klassenfarbe)
    C_Timer.After(0.50, function()
        if not overlay:IsShown() then return end
        AnimFX.RuneExplosion(overlay, r, g, b, 8)
    end)

    -- ★ t=0.55 — PULS-RINGE (3 in schneller Folge)
    C_Timer.After(0.55, function()
        if not overlay:IsShown() then return end
        AnimFX.PulseRings(overlay, r, g, b, 3, 0.15)
    end)

    -- ★ t=0.64 — SCREEN SHAKE
    C_Timer.After(0.64, function()
        if not overlay:IsShown() then return end
        AnimFX.ShakeFrame(overlay, 12, 0.52)
    end)

    -- ★ t=0.72 — 2. RUNEN-EXPLOSION (Gold)
    C_Timer.After(0.72, function()
        if not overlay:IsShown() then return end
        AnimFX.RuneExplosion(overlay, 0.95, 0.78, 0.20, 6)
    end)

    -- ★ t=0.75 — MASSIVER GLOW-RING
    C_Timer.After(0.75, function()
        if not overlay:IsShown() then return end
        local sz = (overlay:GetWidth() or 520) * 1.18
        AnimFX.GlowRing(overlay, r, g, b, sz, 0.92)
    end)

    -- ★ t=0.82 — SHIMMER SWEEP (Gold)
    C_Timer.After(0.82, function()
        if not overlay:IsShown() then return end
        AnimFX.ShimmerSweep(overlay, 0.96, 0.80, 0.22, 0.70)
    end)

    -- ★ t=0.88 — PARTIKEL-EXPLOSION (Klasse + Gold + Weiß)
    C_Timer.After(0.88, function()
        if not overlay:IsShown() then return end
        local cx2, cy2 = overlay:GetCenter()
        if cx2 and cy2 then
            local rx = cx2 - UIParent:GetWidth()  * 0.5
            local ry = cy2 - UIParent:GetHeight() * 0.5
            AnimFX.ParticleBurst(rx, ry,      32, r,   g,    b,    140, 1.90)
            AnimFX.ParticleBurst(rx, ry + 18, 16, 1.0, 0.82, 0.22,  85, 1.30)
            AnimFX.ParticleBurst(rx, ry,      12, 1.0, 1.0,  1.0,   60, 0.95)
        end
    end)

    -- ★ t=1.10 — SEELE STEIGT AUF + RUNEN BEGINNEN ZU KREISEN
    C_Timer.After(1.10, function()
        if not overlay:IsShown() then return end
        local h = AnimFX.StartOverlayAmbience(overlay, r, g, b)
        overlay._ambienceHandles = h
    end)

    -- ★ t=1.25 — ZWEITER PARTIKEL-SCHAUER (sanfter Nachhall)
    C_Timer.After(1.25, function()
        if not overlay:IsShown() then return end
        local cx2, cy2 = overlay:GetCenter()
        if cx2 and cy2 then
            local rx = cx2 - UIParent:GetWidth()  * 0.5
            local ry = cy2 - UIParent:GetHeight() * 0.5
            AnimFX.ParticleBurst(rx, ry - 45, 20, r * 0.65, g * 0.65, b * 0.65, 72, 2.30)
        end
    end)
end

-- ─── REGISTER ────────────────────────────────────────────────────────────────
GDL:RegisterModule("AnimFX", AnimFX)
