function widget:GetInfo() return { name = "Air Smart Targeting Optimized", version = "2.7", date = "17.05.2026", author = "[ur]uncle", license = "GNU GPL, v2 or later", layer = 0, enabled = true } end -- === CONFIGURABLE CONSTANTS === local SCAN_RANGE_MULTIPLIER = 1.4 -- Scan slightly beyond weapon range for early detection local BATCH_SIZE = 15 -- Number of units processed per coroutine batch local LOW_CLASS_AA_COST = 400 -- Threshold for low-class AA unit (metal cost) local UPDATE_RATE = 5 -- How often (in game frames) the widget updates -- Visuals local DEFENDER_MARK_RADIUS = 35 -- Visual radius around AA defenders local ROTATION_PERIOD = 3 -- Seconds required for one full rotation of the visual mark -- Special bomber units by name (not flagged as bombers in UnitDefs) local SPECIAL_BOMBER = { ["armcybr"] = true, ["armliche"] = true } -- Custom command IDs for Spring/Recoil engine local CMD_UNIT_SET_TARGET = 34923 local CMD_UNIT_CANCEL_TARGET = 34924 local ENEMY_UNITS = Spring.ENEMY_UNITS -- === LUA SHORTCUTS === local math_log = math.log local math_sin = math.sin local math_cos = math.cos local math_max = math.max local TWO_PI = math.pi * 2 local INV_LOG_10 = 1 / math_log(10) -- === SPRING API SHORTCUTS === local GetUnitDefID = Spring.GetUnitDefID local GetUnitIsDead = Spring.GetUnitIsDead local GetUnitPosition = Spring.GetUnitPosition local GetUnitSeparation = Spring.GetUnitSeparation local GetUnitsInSphere = Spring.GetUnitsInSphere local GetUnitViewPosition = Spring.GetUnitViewPosition local GetUnitWeaponState = Spring.GetUnitWeaponState local GetUnitHealth = Spring.GetUnitHealth local GiveOrderToUnit = Spring.GiveOrderToUnit local GetMyTeamID = Spring.GetMyTeamID local GetGameFrame = Spring.GetGameFrame local IsReplay = Spring.IsReplay local GetSpectatingState = Spring.GetSpectatingState local GetViewGeometry = Spring.GetViewGeometry local GetTeamUnits = Spring.GetTeamUnits local GetTimer = Spring.GetTimer local DiffTimers = Spring.DiffTimers -- === OPENGL SHORTCUTS === local glColor = gl.Color local glRect = gl.Rect local glText = gl.Text local glLineWidth = gl.LineWidth local glDrawGroundCircle = gl.DrawGroundCircle local glBeginEnd = gl.BeginEnd local glLineStipple = gl.LineStipple local GL_LINE_STRIP = GL.LINE_STRIP local glVertex = gl.Vertex -- === EMPTY COMMAND TABLES / SCRATCH BUFFERS === -- Reused to reduce garbage collection pressure in hot paths. local NO_PARAMS = {} local NO_OPTIONS = {} local setTargetParams = {} -- Candidate scratch arrays reused by checkTargets(). local candidateIDs = {} local candidateScores = {} -- === DATA STRUCTURES === local allowedWeapons = {} -- [unitDefID] = {maxWeaponRange, lowClassWeapon, name, damage} local unitDefsCached = {} -- [unitDefID] = {power, name} local ghostedEnemyData = {} -- [enemyID] = {power, name} for unseen/ghosted units local targetData = {} -- [defenderUnitID] = targetEnemyID, mainly for drawing local assignedDamageByEnemy = {} -- [enemyID] = total assigned damage in current targeting pass local assignedCountByEnemy = {} -- [enemyID] = assigned defender count in current targeting pass local myDefenders = {} -- [unitID] = allowedWeapons[unitDefID] local splitTargetEnabled = false local desiredSplitTargetState = false local widgetInitComplete = false -- === GUI STATE === local guiX, guiY = 1000, 500 local guiW, guiH = 180, 30 local dragging = false local dragOffsetX, dragOffsetY = 0, 0 local vsx, vsy = 0, 0 local gameStarted = false -- === VISUAL ROTATION STATE === local rotationAngle = 0 local lastRotationTimer = GetTimer() local rotationSpeed = TWO_PI / ROTATION_PERIOD local SetSplitTargetState local function maybeRemoveSelf() if GetSpectatingState() and (GetGameFrame() > 0 or gameStarted) then widgetHandler:RemoveWidget() end end local function ClampGuiToScreen() if vsx <= 0 or vsy <= 0 then return end local maxX = math_max(0, vsx - guiW) local maxY = math_max(0, vsy - guiH) if guiX < 0 then guiX = 0 elseif guiX > maxX then guiX = maxX end if guiY < 0 then guiY = 0 elseif guiY > maxY then guiY = maxY end end local function GetPrimaryWeaponIndex(ud) if ud.primaryWeapon and ud.weapons and ud.weapons[ud.primaryWeapon] then return ud.primaryWeapon end if not ud.weapons then return nil end local bestIndex local bestReload = -1 for i = 1, #ud.weapons do local weapon = ud.weapons[i] local weaponDef = weapon and WeaponDefs[weapon.weaponDef] local reload = weaponDef and weaponDef.reload or 0 if reload > bestReload then bestReload = reload bestIndex = i end end return bestIndex end local function IsShieldWeapon(weaponDef) if not weaponDef then return true end return weaponDef.isShield or weaponDef.type == "Shield" or weaponDef.weapontype == "Shield" end local function GetWeaponDamage(weaponDef) local damages = weaponDef and weaponDef.damages if not damages then return 0 end return damages[1] or damages[0] or 0 end local function GetWeaponRange(ud, weaponDef) return (weaponDef and weaponDef.range) or ud.maxWeaponRange or 0 end local function PopulateDefenders() myDefenders = {} local teamUnits = GetTeamUnits(GetMyTeamID()) if not teamUnits then return end for i = 1, #teamUnits do local unitID = teamUnits[i] local defID = GetUnitDefID(unitID) local weaponData = defID and allowedWeapons[defID] if weaponData then myDefenders[unitID] = weaponData end end end local function DisableSplitTargeting() for unitID in pairs(myDefenders) do GiveOrderToUnit(unitID, CMD_UNIT_CANCEL_TARGET, NO_PARAMS, NO_OPTIONS) end myDefenders = {} splitTargetEnabled = false end local function EnableSplitTargeting() splitTargetEnabled = true PopulateDefenders() end SetSplitTargetState = function(shouldEnable, fromExternal) desiredSplitTargetState = shouldEnable and true or false local isExternal = fromExternal and true or false if shouldEnable then if not splitTargetEnabled then EnableSplitTargeting() end -- Keep mutual exclusion with the other AA priority widget from the original version. if not isExternal and WG and WG.AAPriorityTargeting and WG.AAPriorityTargeting.setState then WG.AAPriorityTargeting.setState(false, true) end else if splitTargetEnabled then DisableSplitTargeting() end end end function widget:Initialize() if IsReplay() or GetGameFrame() > 0 then maybeRemoveSelf() end -- Build allowedWeapons table for own ground AA units. for _, ud in pairs(UnitDefs) do local customParams = ud.customParams or NO_PARAMS local primaryWeaponIndex = GetPrimaryWeaponIndex(ud) local mainWeapon = primaryWeaponIndex and ud.weapons and ud.weapons[primaryWeaponIndex] local mainWeaponDef = mainWeapon and WeaponDefs[mainWeapon.weaponDef] local onlyTargets = mainWeapon and mainWeapon.onlyTargets if not customParams.iscommander and not ud.canFly and mainWeapon and mainWeaponDef and not IsShieldWeapon(mainWeaponDef) and onlyTargets and onlyTargets.vtol then local weaponRange = GetWeaponRange(ud, mainWeaponDef) if weaponRange and weaponRange > 0 then allowedWeapons[ud.id] = { maxWeaponRange = weaponRange, lowClassWeapon = (ud.metalCost or 0) < LOW_CLASS_AA_COST, name = ud.name, damage = GetWeaponDamage(mainWeaponDef) } end end end -- Cache threat power for bomber-class enemy air units. -- Same targeting scope as the original: UnitDef bomber flag + explicitly listed special bombers. for _, ud in pairs(UnitDefs) do if ud.isBomberAirUnit or SPECIAL_BOMBER[ud.name] then local cost = ud.metalCost or 0 local power = math_log(1 + cost * 0.01) * INV_LOG_10 unitDefsCached[ud.id] = { power = power, name = ud.name } end end local screenW, screenH = GetViewGeometry() if screenW and screenH then vsx, vsy = screenW, screenH ClampGuiToScreen() end if not WG then WG = {} end WG.SmartAirTarget = { setState = function(active, fromExternal) SetSplitTargetState(active, fromExternal) end, getState = function() return splitTargetEnabled end } widgetInitComplete = true SetSplitTargetState(desiredSplitTargetState, true) if splitTargetEnabled and WG.AAPriorityTargeting and WG.AAPriorityTargeting.getState and WG.AAPriorityTargeting.getState() then SetSplitTargetState(false, true) end end function widget:GameStart() gameStarted = true maybeRemoveSelf() end function widget:PlayerChanged(playerID) maybeRemoveSelf() end function widget:GetConfigData() return { guiX = guiX, guiY = guiY, splitTargetEnabled = splitTargetEnabled } end function widget:SetConfigData(data) if not data then return end if data.guiX then guiX = data.guiX end if data.guiY then guiY = data.guiY end if data.splitTargetEnabled ~= nil then desiredSplitTargetState = data.splitTargetEnabled and true or false if widgetInitComplete then SetSplitTargetState(desiredSplitTargetState, true) else splitTargetEnabled = false end end ClampGuiToScreen() end local coroutineCounter = 0 -- Main function to assign split targets among defenders. -- Optimized version: -- - no table.sort in the hot path, -- - no per-candidate {score, enemyID} tables, -- - reuses command/candidate tables, -- - keeps the original priority rule: power / separation, -- - keeps overkill prevention: assigned damage < current HP. local function checkTargets() for unitID, def in pairs(myDefenders) do coroutineCounter = coroutineCounter + 1 if coroutineCounter >= BATCH_SIZE then coroutineCounter = 0 coroutine.yield() end -- Preserve original behaviour: clear previous custom target before assigning a new one. GiveOrderToUnit(unitID, CMD_UNIT_CANCEL_TARGET, NO_PARAMS, NO_OPTIONS) targetData[unitID] = nil local _, isLoaded = GetUnitWeaponState(unitID, 1) if isLoaded then local range = def.maxWeaponRange * SCAN_RANGE_MULTIPLIER local x, y, z = GetUnitPosition(unitID, true, false) if x then local candidateCount = 0 local nearbyEnemies = GetUnitsInSphere(x, y, z, range, ENEMY_UNITS) -- Gather valid bomber targets. for i = 1, #nearbyEnemies do local enemyID = nearbyEnemies[i] local enemyDefID = GetUnitDefID(enemyID) local cached = enemyDefID and unitDefsCached[enemyDefID] local ghosted = not cached and ghostedEnemyData[enemyID] local power = cached and cached.power or (ghosted and ghosted.power or 0) if power > 0 then local separation = GetUnitSeparation(unitID, enemyID, true) if separation and separation > 0 then candidateCount = candidateCount + 1 candidateIDs[candidateCount] = enemyID candidateScores[candidateCount] = power / separation end end end local originalCandidateCount = candidateCount local chosenTarget -- Equivalent to iterating a descending sorted list, but avoids table.sort. -- If the best target is already overkilled, remove it and test the next best one. while candidateCount > 0 do local bestIndex = 1 local bestScore = candidateScores[1] for i = 2, candidateCount do local score = candidateScores[i] if score > bestScore then bestScore = score bestIndex = i end end local enemyID = candidateIDs[bestIndex] local enemyHP = 0 if GetUnitHealth then enemyHP = select(1, GetUnitHealth(enemyID)) or 0 end local declaredDamage = assignedDamageByEnemy[enemyID] or 0 if declaredDamage < enemyHP then assignedDamageByEnemy[enemyID] = declaredDamage + def.damage assignedCountByEnemy[enemyID] = (assignedCountByEnemy[enemyID] or 0) + 1 targetData[unitID] = enemyID chosenTarget = enemyID break end -- Remove rejected candidate by swap-with-last. candidateIDs[bestIndex] = candidateIDs[candidateCount] candidateScores[bestIndex] = candidateScores[candidateCount] candidateIDs[candidateCount] = nil candidateScores[candidateCount] = nil candidateCount = candidateCount - 1 end -- Clear scratch arrays for reuse by the next defender. for i = 1, originalCandidateCount do candidateIDs[i] = nil candidateScores[i] = nil end if chosenTarget then setTargetParams[1] = chosenTarget GiveOrderToUnit(unitID, CMD_UNIT_SET_TARGET, setTargetParams, NO_OPTIONS) setTargetParams[1] = nil end end end end coroutine.yield() end local myTask = coroutine.create(checkTargets) function widget:GameFrame(frame) if splitTargetEnabled and frame % UPDATE_RATE == 0 then local ok, err = coroutine.resume(myTask) if not ok then Spring.Echo("[Air Smart Targeting Optimized] coroutine error: " .. tostring(err)) targetData = {} assignedDamageByEnemy = {} assignedCountByEnemy = {} coroutineCounter = 0 myTask = coroutine.create(checkTargets) return end if coroutine.status(myTask) == "dead" then targetData = {} assignedDamageByEnemy = {} assignedCountByEnemy = {} coroutineCounter = 0 myTask = coroutine.create(checkTargets) end end end function widget:Update() local now = GetTimer() local delta = DiffTimers(now, lastRotationTimer) if delta > 0 then rotationAngle = (rotationAngle + delta * rotationSpeed) % TWO_PI lastRotationTimer = now end for enemyID in pairs(ghostedEnemyData) do if GetUnitIsDead(enemyID) then ghostedEnemyData[enemyID] = nil end end end function widget:UnitDestroyed(unitID) myDefenders[unitID] = nil targetData[unitID] = nil end function widget:UnitTaken(unitID) myDefenders[unitID] = nil targetData[unitID] = nil end function widget:UnitCreated(unitID, unitDefID, teamID) if teamID == GetMyTeamID() and allowedWeapons[unitDefID] and splitTargetEnabled then myDefenders[unitID] = allowedWeapons[unitDefID] end end function widget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) if newTeam == GetMyTeamID() and allowedWeapons[unitDefID] and splitTargetEnabled then myDefenders[unitID] = allowedWeapons[unitDefID] end end function widget:DrawScreen() glColor(0, 0, 0, 1) glRect(guiX - 1, guiY - 1, guiX + guiW + 1, guiY + guiH + 1) local r, g, b, a = 0.4, 0.1, 0.1, 0.8 if splitTargetEnabled then r, g, b, a = 0, 0.5, 0, 0.7 end glColor(r, g, b, a) glRect(guiX, guiY, guiX + guiW, guiY + guiH) glColor(1, 1, 1, 0.9) glText("Smart Air Targeting: " .. (splitTargetEnabled and "ON" or "OFF"), guiX + 5, guiY + guiH / 2 - 6, 14, "on") end function widget:MousePress(x, y, button) if x >= guiX and x <= guiX + guiW and y >= guiY and y <= guiY + guiH then if button == 2 then dragging = true dragOffsetX = x - guiX dragOffsetY = y - guiY return true elseif button == 1 then SetSplitTargetState(not splitTargetEnabled, false) return true end end end function widget:MouseMove(x, y, dx, dy, button) if dragging then guiX = x - dragOffsetX guiY = y - dragOffsetY ClampGuiToScreen() end end function widget:MouseRelease(x, y, button) if button == 2 then dragging = false end end function widget:ViewResize(newVsx, newVsy) vsx, vsy = newVsx, newVsy ClampGuiToScreen() end local function LineCoords(x1, y1, z1, x2, y2, z2) glVertex(x1, y1, z1) glVertex(x2, y2, z2) end local function DrawLineCoords(x1, y1, z1, x2, y2, z2) glLineStipple(false) glBeginEnd(GL_LINE_STRIP, LineCoords, x1, y1, z1, x2, y2, z2) end function widget:DrawWorld() if not splitTargetEnabled then return end local sinRot = math_sin(rotationAngle) local cosRot = math_cos(rotationAngle) for unitID in pairs(myDefenders) do local ux, uy, uz = GetUnitViewPosition(unitID) if ux then glLineWidth(1) glColor(0, 1, 0, 0.4) glDrawGroundCircle(ux, uy, uz, DEFENDER_MARK_RADIUS, 6) local spinnerHeight = uy + 5 local innerRadius = DEFENDER_MARK_RADIUS - 8 local outerRadius = DEFENDER_MARK_RADIUS + 4 local startX = ux + cosRot * innerRadius local startZ = uz + sinRot * innerRadius local endX = ux + cosRot * outerRadius local endZ = uz + sinRot * outerRadius glLineWidth(2) glColor(0.6, 1, 0.2, 0.8) DrawLineCoords(startX, spinnerHeight, startZ, endX, spinnerHeight, endZ) local oppStartX = ux - cosRot * innerRadius local oppStartZ = uz - sinRot * innerRadius local oppEndX = ux - cosRot * outerRadius local oppEndZ = uz - sinRot * outerRadius DrawLineCoords(oppStartX, spinnerHeight, oppStartZ, oppEndX, spinnerHeight, oppEndZ) local targetID = targetData[unitID] if targetID then local ex, ey, ez = GetUnitViewPosition(targetID) if ex and ey and ez then glLineWidth(3) glColor(1.0, 0.2, 0.0, 0.5) DrawLineCoords(ux, uy, uz, ex, ey, ez) end end end end glLineWidth(1) glColor(1, 1, 1, 1) end function widget:Shutdown() if splitTargetEnabled then DisableSplitTargeting() else myDefenders = {} end if WG then WG.SmartAirTarget = nil end end