Skip to content

Particle Melee Parry ​

Auto-parries enemy heavy melee attacks using particle detection. Demonstrates on_particle_create, owner resolution, distance/visibility/facing gating, and clock()-based debouncing.

lua
-- Particle Melee Parry
-- Detects enemy heavy melee charge via particles and auto-parries.

script_info = {
    name        = "Particle Melee Parry",
    description = "Auto-parry enemy heavy melee via particle detection",
}

settings = {
    { key = "enabled",        label = "Enable",             type = "bool",  default = true },
    { key = "max_dist",       label = "Max Distance (m)",   type = "float", default = 12.0, min = 3.0, max = 25.0 },
    { key = "require_vis",    label = "Require Visible",    type = "bool",  default = true },
    { key = "require_facing", label = "Require Facing Us",  type = "bool",  default = true },
    { key = "facing_angle",   label = "Facing Angle (deg)", type = "float", default = 90.0, min = 30.0, max = 180.0 },
    { key = "show_toast",     label = "Show Toast",         type = "bool",  default = true },
    { key = "cooldown_ms",    label = "Cooldown (ms)",      type = "float", default = 400.0, min = 100.0, max = 2000.0 },
}

local last_parry_time = 0

function on_particle_create(ev)
    if not ev.is_enemy then return end
    if not config.get_bool("enabled", true) then return end
    if not ev.name:find("melee_heavy_activate_charge") then return end

    local owner = ev.owner
    if not owner then return end

    local me = local_player()
    if not me or not me:is_alive() then return end

    -- Visibility
    if config.get_bool("require_vis", true) and not owner:is_visible() then return end

    -- Distance (game units are in inches; divide by 39.37 for meters)
    local my_pos = me:get_position()
    local e_pos = owner:get_position()
    local dx, dy, dz = my_pos.x - e_pos.x, my_pos.y - e_pos.y, my_pos.z - e_pos.z
    local dist = math.sqrt(dx*dx + dy*dy + dz*dz) / 39.37
    if dist > config.get_float("max_dist", 12.0) then return end

    -- Facing check: is the enemy looking toward us?
    if config.get_bool("require_facing", true) then
        local angles = owner:get_view_angles()
        if angles then
            local len2d = math.sqrt(dx*dx + dy*dy)
            if len2d > 1.0 then
                local yaw = math.rad(angles.y)
                local dot = (math.cos(yaw) * dx + math.sin(yaw) * dy) / len2d
                local threshold = math.cos(math.rad(config.get_float("facing_angle", 90.0) / 2))
                if dot < threshold then return end
            end
        end
    end

    -- Cooldown, prevents double-fire across scan frames on the same melee
    local now = clock()
    if (now - last_parry_time) < config.get_float("cooldown_ms", 400.0) / 1000.0 then return end
    last_parry_time = now

    -- Fire parry
    input.parry()

    if config.get_bool("show_toast", true) then
        toast(string.format("Parried %s (%.0fm)", owner:hero_name() or "?", dist))
    end
end

Key patterns ​

Early filtering. Check ev.is_enemy and ev.name:find(...) first, and return right away when they don't match. Most particles are not relevant.

Owner resolution. ev.owner is a full player table, the same shape get_players() returns. Use it for :is_visible(), :get_position(), :get_view_angles(), :hero_name() and so on. It is nil when the owner is not a known player, so check it first.

Distance in meters. Game units are in inches. Divide by 39.37 to convert to meters for human-readable settings.

Facing check. Take the dot product of the enemy's forward vector (from their view-angle yaw) and the direction from them to you. A positive dot means they're facing you. The threshold comes from the cone angle setting: cos(angle / 2).

Debounce via clock(). The same particle can show up on several scans in a row, so this stops a second parry on one charge. clock() counts seconds from when TSUKI's Lua engine first started, not from the start of the game.

See also ​