function widget:GetInfo() return { name = "Enemy Projectile Predictor v3.0", desc = "Draws solid red predicted curves for enemy Plasma Cannon and MissileLauncher projectiles only", author = "Silver/Codex simplified", date = "2026", version = "3.0", license = "GNU GPL, v2 or later", layer = 0, enabled = true, } end -- File version: gui_enemy_projectiles_v3.0.lua -- Simplified scope: -- * scan only enemy projectiles from standard Plasma Cannon: WeaponDef.type == "Cannon" -- * scan only enemy rockets: WeaponDef.type == "MissileLauncher" -- * no StarburstLauncher, no TorpedoLauncher, no DGun, no EMG, no AircraftBomb -- * draw only solid red curves: no dash, no yellow target marker -- -- Cannon path: current projectile position + fitted velocity + gravity -> ground impact. -- MissileLauncher path: current projectile position -> ground impact point. -- The target is decoded once and always converted to/frozen on terrain, so unit-targeted -- rockets do not deform the path by following a moving unit after launch. -- If trajectoryHeight > 0, the missile path is approximated with the Recoil-style -- trajectoryHeight pursuit curve using Heun's method, then cropped from the current -- projectile position to the frozen ground impact point. -- v3.0 refactor: removed missile target lock option; ground-locking is unconditional. -------------------------------------------------------------------------------- -- Spring aliases -------------------------------------------------------------------------------- local spGetGameFrame = Spring.GetGameFrame local spGetMyTeamID = Spring.GetMyTeamID local spGetUnitTeam = Spring.GetUnitTeam local spAreTeamsAllied = Spring.AreTeamsAllied local spGetUnitPosition = Spring.GetUnitPosition local spGetUnitDefID = Spring.GetUnitDefID local spGetGroundHeight = Spring.GetGroundHeight local spGetProjectileOwnerID = Spring.GetProjectileOwnerID local spGetProjectilePosition = Spring.GetProjectilePosition local spGetProjectileVelocity = Spring.GetProjectileVelocity local spGetProjectileType = Spring.GetProjectileType local spGetProjectileDefID = Spring.GetProjectileDefID local spGetProjectileTarget = Spring.GetProjectileTarget local spGetProjectilesInRectangle = Spring.GetProjectilesInRectangle local glColor = gl.Color local glVertex = gl.Vertex local glBeginEnd = gl.BeginEnd local glLineWidth = gl.LineWidth local glDepthTest = gl.DepthTest local glDepthMask = gl.DepthMask -------------------------------------------------------------------------------- -- Configuration -------------------------------------------------------------------------------- local DEBUG = false local SCAN_EVERY_FRAMES = 1 local PROJECTILE_TRACK_TTL_FRAMES = 12 local PROJECTILE_HINT_TTL_FRAMES = 30 local MIN_SAMPLES = 3 local MAX_SAMPLES = 8 local LINE_WIDTH = 2.5 local CURVE_POINTS_CANNON = 36 local MISSILE_HEUN_SEGMENTS = 8 local MISSILE_TRAJECTORY_HEIGHT_EPS = 0.001 local TRACK_PROJECTILES_WITH_UNKNOWN_OWNER = false local MAP_MIN_X = 0 local MAP_MIN_Z = 0 local MAP_MAX_X = Game.mapSizeX or 16384 local MAP_MAX_Z = Game.mapSizeZ or 16384 local GAME_SPEED = Game.gameSpeed or 30 local GRAVITY_SCALE = 888.888888 local TARGET_TYPE_GROUND = string.byte("g") local TARGET_TYPE_UNIT = string.byte("u") local TARGET_TYPE_FEATURE = string.byte("f") local TARGET_TYPE_PROJECTILE = string.byte("p") -------------------------------------------------------------------------------- -- State -------------------------------------------------------------------------------- local projectileTracks = {} local projectileHints = {} local unitLastWeapon = {} local unitAttackTargetCache = {} -------------------------------------------------------------------------------- -- Small helpers -------------------------------------------------------------------------------- local function debugEcho(...) if DEBUG and Spring.Echo then Spring.Echo("[EnemyProjectilePredictor v3.0]", ...) end end local function clearMap(tbl) for k in pairs(tbl) do tbl[k] = nil end end local function num(v, fallback) v = tonumber(v) if v == nil then return fallback or 0 end return v end local function weaponDefNumber(weaponDef, fallback, ...) if not weaponDef then return fallback or 0 end local names = {...} for i = 1, #names do local value = tonumber(weaponDef[names[i]]) if value ~= nil then return value end end return fallback or 0 end local function clamp(v, lo, hi) if v < lo then return lo end if v > hi then return hi end return v end local function dist2D(ax, az, bx, bz) local dx = ax - bx local dz = az - bz return math.sqrt(dx * dx + dz * dz) end local function dist3D(ax, ay, az, bx, by, bz) local dx = ax - bx local dy = ay - by local dz = az - bz return math.sqrt(dx * dx + dy * dy + dz * dz) end local function isEnemyTeam(teamID) if not teamID then return false end local myTeamID = spGetMyTeamID and spGetMyTeamID() if not myTeamID then return false end if spAreTeamsAllied then return not spAreTeamsAllied(myTeamID, teamID) end return teamID ~= myTeamID end local function isWeaponProjectile(projectileID) local typeSaysNonWeapon = false if spGetProjectileType then local weapon, piece = spGetProjectileType(projectileID) -- Piece projectiles are normally debris/effects, not weapon shots. if piece then return false end if weapon == true or weapon == 1 then return true elseif weapon ~= nil then -- Do not return here. Some engines report a non-true weapon flag while -- GetProjectileDefID still identifies a real WeaponDef. typeSaysNonWeapon = true end end if spGetProjectileDefID then local projectileDefID = spGetProjectileDefID(projectileID) if projectileDefID ~= nil and projectileDefID ~= false and projectileDefID ~= 0 then return true end return false end return not typeSaysNonWeapon end local function isCannonWeapon(weaponDef) return weaponDef and weaponDef.type == "Cannon" end local function isMissileWeapon(weaponDef) return weaponDef and weaponDef.type == "MissileLauncher" end local function isAllowedWeapon(weaponDef) return isCannonWeapon(weaponDef) or isMissileWeapon(weaponDef) end local function findWeaponDefIDInArgs(...) local args = {...} for i = 1, #args do local candidate = args[i] if type(candidate) == "number" and WeaponDefs and WeaponDefs[candidate] and isAllowedWeapon(WeaponDefs[candidate]) then return candidate end end return nil end local function getProjectileGravity(weaponDef) if not weaponDef then return Game.gravity or 0 end local myGravity = num(weaponDef.myGravity, 0) if myGravity > 0 then return myGravity * GRAVITY_SCALE end return Game.gravity or 0 end local function getGroundY(x, z) if spGetGroundHeight then return spGetGroundHeight(x, z) or 0 end return 0 end local function pointInMap(x, z) return x >= MAP_MIN_X and z >= MAP_MIN_Z and x <= MAP_MAX_X and z <= MAP_MAX_Z end -------------------------------------------------------------------------------- -- WeaponDef detection -------------------------------------------------------------------------------- local function getWeaponDefFromOwner(ownerID) if not ownerID or not spGetUnitDefID then return nil, nil end local last = unitLastWeapon[ownerID] if last and last.weaponDefID and WeaponDefs[last.weaponDefID] and isAllowedWeapon(WeaponDefs[last.weaponDefID]) then local frame = spGetGameFrame and spGetGameFrame() or 0 if frame - (last.frame or frame) <= PROJECTILE_HINT_TTL_FRAMES then return last.weaponDefID, WeaponDefs[last.weaponDefID] end end local unitDefID = spGetUnitDefID(ownerID) local unitDef = unitDefID and UnitDefs and UnitDefs[unitDefID] local weapons = unitDef and unitDef.weapons if not weapons then return nil, nil end for i = 1, #weapons do local weaponDefID = weapons[i] and weapons[i].weaponDef local weaponDef = weaponDefID and WeaponDefs and WeaponDefs[weaponDefID] if isAllowedWeapon(weaponDef) then return weaponDefID, weaponDef end end return nil, nil end local function getWeaponDefForProjectile(projectileID, ownerID) local hint = projectileHints[projectileID] if hint and hint.weaponDefID and WeaponDefs and WeaponDefs[hint.weaponDefID] and isAllowedWeapon(WeaponDefs[hint.weaponDefID]) then return hint.weaponDefID, WeaponDefs[hint.weaponDefID] end if spGetProjectileDefID then local projectileDefID = spGetProjectileDefID(projectileID) local weaponDef = projectileDefID and WeaponDefs and WeaponDefs[projectileDefID] if isAllowedWeapon(weaponDef) then return projectileDefID, weaponDef end -- Known non-supported weapon: ignore this projectile entirely. if weaponDef and not isAllowedWeapon(weaponDef) then return projectileDefID, nil, "unsupported_weapon" end end local ownerWeaponDefID, ownerWeaponDef = getWeaponDefFromOwner(ownerID) if ownerWeaponDef then return ownerWeaponDefID, ownerWeaponDef end return nil, nil, "unknown_weapon" end -------------------------------------------------------------------------------- -- Projectile target decoding: Recoil/Spring can return numeric byte codes. -------------------------------------------------------------------------------- local function normalizeTargetType(targetType) if targetType == TARGET_TYPE_GROUND or targetType == "g" or targetType == "ground" then return "ground" end if targetType == TARGET_TYPE_UNIT or targetType == "u" or targetType == "unit" then return "unit" end if targetType == TARGET_TYPE_FEATURE or targetType == "f" or targetType == "feature" then return "feature" end if targetType == TARGET_TYPE_PROJECTILE or targetType == "p" or targetType == "projectile" then return "projectile" end return nil end local function unpackPointTable(t) if type(t) ~= "table" then return nil end local x = t[1] or t.x local y = t[2] or t.y local z = t[3] or t.z if x and z then return x, y or getGroundY(x, z), z end return nil end local function decodeTargetResult(a, b, c, d, e) local x, y, z = unpackPointTable(a) if x then return x, y, z, "point_table" end if type(a) == "number" and type(b) == "number" and type(c) == "number" and not normalizeTargetType(a) then return a, b, c, "xyz" end local targetType = normalizeTargetType(a) if targetType == "ground" then x, y, z = unpackPointTable(b) if x then return x, y, z, "projectile_api:ground_table" end if type(b) == "number" and type(c) == "number" and type(d) == "number" then return b, c, d, "projectile_api:ground_xyz" end elseif targetType == "unit" then local unitID = b if unitID and spGetUnitPosition then x, y, z = spGetUnitPosition(unitID) if x and z then return x, y or getGroundY(x, z), z, "projectile_api:unit" end end end -- Some builds return: targetID, targetType, x, y, z or similar. targetType = normalizeTargetType(b) if targetType == "ground" and type(c) == "number" and type(d) == "number" and type(e) == "number" then return c, d, e, "projectile_api:alt_ground_xyz" elseif targetType == "unit" and a and spGetUnitPosition then x, y, z = spGetUnitPosition(a) if x and z then return x, y or getGroundY(x, z), z, "projectile_api:alt_unit" end end return nil end local function getProjectileTargetPoint(track) if not spGetProjectileTarget or not track or not track.projectileID then return nil end local ok, a, b, c, d, e = pcall(spGetProjectileTarget, track.projectileID) if not ok then return nil end return decodeTargetResult(a, b, c, d, e) end local function getCachedAttackTarget(ownerID) local cache = ownerID and unitAttackTargetCache[ownerID] if not cache then return nil end local frame = spGetGameFrame and spGetGameFrame() or 0 if frame - (cache.frame or frame) > PROJECTILE_HINT_TTL_FRAMES then return nil end if cache.unitID and spGetUnitPosition then local x, y, z = spGetUnitPosition(cache.unitID) if x and z then return x, y or getGroundY(x, z), z, "cached_attack_unit" end end if cache.x and cache.z then return cache.x, cache.y or getGroundY(cache.x, cache.z), cache.z, "cached_attack_ground" end return nil end -------------------------------------------------------------------------------- -- Sample fitting -------------------------------------------------------------------------------- local function addSample(track, frame, x, y, z) local samples = track.samples local last = samples[#samples] if last and last.frame == frame then last.x = x last.y = y last.z = z return end samples[#samples + 1] = {frame = frame, x = x, y = y, z = z} while #samples > MAX_SAMPLES do table.remove(samples, 1) end end local function fitCurrentVelocity(track, gravity) local samples = track.samples if #samples < 2 then return nil end local a = samples[#samples - 1] local b = samples[#samples] local dt = (b.frame - a.frame) / GAME_SPEED if dt <= 0 then return nil end local vx = (b.x - a.x) / dt local vz = (b.z - a.z) / dt local vy = (b.y - a.y) / dt if gravity and gravity > 0 then -- Finite difference gives average velocity over the last interval. -- Convert it to approximate velocity at the current sample. vy = vy - 0.5 * gravity * dt end return { x = b.x, y = b.y, z = b.z, vx = vx, vy = vy, vz = vz, hSpeed = math.sqrt(vx * vx + vz * vz), } end -------------------------------------------------------------------------------- -- Cannon / Plasma prediction -------------------------------------------------------------------------------- local function ballisticPosition(fit, gravity, t) return fit.x + fit.vx * t, fit.y + fit.vy * t - 0.5 * gravity * t * t, fit.z + fit.vz * t end local function findBallisticImpact(fit, gravity) local maxTime = 60 local step = 0.05 local prevT = 0 local prevX, prevY, prevZ = ballisticPosition(fit, gravity, 0) local prevGround = getGroundY(prevX, prevZ) for t = step, maxTime, step do local x, y, z = ballisticPosition(fit, gravity, t) if not pointInMap(x, z) then return nil end local ground = getGroundY(x, z) if y <= ground then local lo = prevT local hi = t local hitX, hitY, hitZ = x, ground, z for _ = 1, 12 do local mid = (lo + hi) * 0.5 local mx, my, mz = ballisticPosition(fit, gravity, mid) local mg = getGroundY(mx, mz) if my <= mg then hi = mid hitX, hitY, hitZ = mx, mg, mz else lo = mid end end return hitX, hitY, hitZ, hi end prevT = t prevX, prevY, prevZ = x, y, z prevGround = ground end return nil end local function makeCannonPrediction(track, weaponDef) if #track.samples < MIN_SAMPLES then return nil end local gravity = getProjectileGravity(weaponDef) if gravity <= 0 then return nil end local fit = fitCurrentVelocity(track, gravity) if not fit or fit.hSpeed <= 0.01 then return nil end local hitX, hitY, hitZ, hitT = findBallisticImpact(fit, gravity) if not hitX then return nil end local points = {} for i = 0, CURVE_POINTS_CANNON do local s = i / CURVE_POINTS_CANNON local t = hitT * s local x, y, z = ballisticPosition(fit, gravity, t) points[#points + 1] = {x = x, y = y, z = z} end return { mode = "cannon", points = points, impactX = hitX, impactY = hitY, impactZ = hitZ, } end -------------------------------------------------------------------------------- -- MissileLauncher / trajectoryHeight prediction -------------------------------------------------------------------------------- local function getMissileSpeedsForEngine(weaponDef, observedSpeed) local maxSpeed = weaponDefNumber(weaponDef, 0, "projectilespeed", "projectileSpeed", "weaponvelocity", "weaponVelocity") local startVel = weaponDefNumber(weaponDef, 0, "startvelocity", "startVelocity") local accel = weaponDefNumber(weaponDef, 0, "weaponacceleration", "weaponAcceleration") -- Lua WeaponDefs usually expose projectile speed in engine units per frame. -- Some games/builds expose raw weapon tags in elmos/second; detect obvious raw -- values and convert to the per-frame scale used by the Recoil formula. if maxSpeed > 50 then maxSpeed = maxSpeed / GAME_SPEED end if startVel > 50 then startVel = startVel / GAME_SPEED end if accel > 10 then accel = accel / (GAME_SPEED * GAME_SPEED) end if maxSpeed <= 0 and observedSpeed and observedSpeed > 0 then maxSpeed = observedSpeed / GAME_SPEED end if startVel <= 0 then startVel = maxSpeed end return maxSpeed, startVel, accel end local function simpleMissileArc(startX, startY, startZ, targetX, targetY, targetZ, trajectoryHeight) local points = {} local dx = targetX - startX local dy = targetY - startY local dz = targetZ - startZ local dist = math.sqrt(dx * dx + dy * dy + dz * dz) local extraHeight = dist * math.max(0, trajectoryHeight or 0) for i = 0, MISSILE_HEUN_SEGMENTS do local s = i / MISSILE_HEUN_SEGMENTS local x = startX + dx * s local y = startY + dy * s + extraHeight * math.sin(math.pi * s) local z = startZ + dz * s points[#points + 1] = {x = x, y = y, z = z} end return points end local function recoilHeunMissilePath(startX, startY, startZ, targetX, targetY, targetZ, weaponDef, observedSpeed) local trajectoryHeight = weaponDefNumber(weaponDef, 0, "trajectoryHeight", "trajectoryheight") if trajectoryHeight <= MISSILE_TRAJECTORY_HEIGHT_EPS then return simpleMissileArc(startX, startY, startZ, targetX, targetY, targetZ, 0) end local rt = dist2D(targetX, targetZ, startX, startZ) if rt <= 0.01 then return simpleMissileArc(startX, startY, startZ, targetX, targetY, targetZ, trajectoryHeight) end local yt = targetY - startY local dist = math.sqrt(rt * rt + yt * yt) local eH = dist * trajectoryHeight local maxSpeed, startVel, accel = getMissileSpeedsForEngine(weaponDef, observedSpeed) if maxSpeed <= 0.001 then return simpleMissileArc(startX, startY, startZ, targetX, targetY, targetZ, trajectoryHeight) end local eHT = math.floor(dist / maxSpeed) local hstep = eHT / MISSILE_HEUN_SEGMENTS if hstep < 1.0 then return simpleMissileArc(startX, startY, startZ, targetX, targetY, targetZ, trajectoryHeight) end local mdist = {} local mheight = {} mdist[0] = 0 mheight[0] = 0 mdist[MISSILE_HEUN_SEGMENTS] = rt mheight[MISSILE_HEUN_SEGMENTS] = yt local t = 0 for i = 1, MISSILE_HEUN_SEGMENTS - 1 do local prevR = mdist[i - 1] local prevY = mheight[i - 1] local virtualY = yt + eH * (1 - t / eHT) local remainingDist = math.sqrt((rt - prevR) * (rt - prevR) + (virtualY - prevY) * (virtualY - prevY)) if remainingDist <= 0.001 then mdist[i] = prevR mheight[i] = prevY else local curSpeed = math.min(startVel + accel * t, maxSpeed) local drdt = curSpeed * (rt - prevR) / remainingDist local dydt = curSpeed * (virtualY - prevY) / remainingDist local rEst = prevR + hstep * drdt local yEst = prevY + hstep * dydt t = t + hstep local virtualY2 = yt + eH * (1 - t / eHT) local remainingDist2 = math.sqrt((rt - rEst) * (rt - rEst) + (virtualY2 - yEst) * (virtualY2 - yEst)) if remainingDist2 <= 0.001 then mdist[i] = rEst mheight[i] = yEst else local curSpeed2 = math.min(startVel + accel * t, maxSpeed) local drdtEst = curSpeed2 * (rt - rEst) / remainingDist2 local dydtEst = curSpeed2 * (virtualY2 - yEst) / remainingDist2 mdist[i] = prevR + (hstep * 0.5) * (drdt + drdtEst) mheight[i] = prevY + (hstep * 0.5) * (dydt + dydtEst) end end end local dirX = (targetX - startX) / rt local dirZ = (targetZ - startZ) / rt local points = {} for i = 0, MISSILE_HEUN_SEGMENTS do local r = mdist[i] or 0 local h = mheight[i] or 0 points[#points + 1] = { x = startX + dirX * r, y = startY + h, z = startZ + dirZ * r, } end return points end local function cropPathFromCurrent(points, currentX, currentY, currentZ) if not points or #points < 2 then return points end local bestIndex = 1 local bestT = 0 local bestD2 = math.huge local bestX, bestY, bestZ = currentX, currentY, currentZ for i = 1, #points - 1 do local a = points[i] local b = points[i + 1] local sx = b.x - a.x local sy = b.y - a.y local sz = b.z - a.z local len2 = sx * sx + sy * sy + sz * sz local t = 0 if len2 > 0.0001 then t = ((currentX - a.x) * sx + (currentY - a.y) * sy + (currentZ - a.z) * sz) / len2 t = clamp(t, 0, 1) end local px = a.x + sx * t local py = a.y + sy * t local pz = a.z + sz * t local dx = currentX - px local dy = currentY - py local dz = currentZ - pz local d2 = dx * dx + dy * dy + dz * dz if d2 < bestD2 then bestD2 = d2 bestIndex = i bestT = t bestX, bestY, bestZ = px, py, pz end end local cropped = {} -- Use the actual projectile position as the first point, so the red curve starts -- exactly at the visible projectile even if the reconstructed launch path is off -- by a muzzle offset or one frame of latency. cropped[#cropped + 1] = {x = currentX, y = currentY, z = currentZ} if bestT < 0.98 then cropped[#cropped + 1] = {x = bestX, y = bestY, z = bestZ} end for i = bestIndex + 1, #points do cropped[#cropped + 1] = points[i] end if #cropped < 2 then cropped[#cropped + 1] = points[#points] end return cropped end -------------------------------------------------------------------------------- -- Runtime threat API -------------------------------------------------------------------------------- local function unitShape(unit) local hitboxX = num(unit.hitbox_x or unit.hitboxX, 0) local hitboxY = num(unit.hitbox_y or unit.hitboxY, 0) local hitboxZ = num(unit.hitbox_z or unit.hitboxZ, 0) local radius = num(unit.radius, 0) radius = math.max(radius, hitboxX * 0.5, hitboxZ * 0.5) return radius, math.max(1.0, hitboxY) end local function weaponDamage(weaponDef) local damage = weaponDef and weaponDef.damages and weaponDef.damages[1] if type(damage) == "number" then return damage end if weaponDef and type(weaponDef.damage) == "table" then return num(weaponDef.damage.default or weaponDef.damage[1], 90) end return 90 end local function closestPoint2D(ax, ay, az, bx, by, bz, ux, uz) local sx = bx - ax local sy = by - ay local sz = bz - az local len2 = sx * sx + sz * sz local t = 0 if len2 > 0.0001 then t = clamp(((ux - ax) * sx + (uz - az) * sz) / len2, 0, 1) end local x = ax + sx * t local y = ay + sy * t local z = az + sz * t return x, y, z, t end local function trackVelocityPerFrame(track) if not track or not track.samples or #track.samples < 2 then return nil end local a = track.samples[#track.samples - 1] local b = track.samples[#track.samples] local frames = math.max(1, (b.frame or 0) - (a.frame or 0)) local vx = (b.x - a.x) / frames local vy = (b.y - a.y) / frames local vz = (b.z - a.z) / frames local speed = math.sqrt(vx * vx + vz * vz) if speed <= 0.0001 then return nil end return vx, vy, vz, speed end local function predictionThreatForUnit(track, unit, hitRadius) local prediction = track and track.prediction local points = prediction and prediction.points if not points or #points < 2 or not unit or not unit.x or not unit.z then return nil end local vx, vy, vz, speed = trackVelocityPerFrame(track) if not speed then return nil end local unitY = num(unit.y, getGroundY(unit.x, unit.z)) local radius, hitboxY = unitShape(unit) local maxScan = math.max(hitRadius or 600, radius * 4.0) local bestDist = math.huge local bestTime = 0 local bestHeight = 0 local elapsed = 0 for i = 1, #points - 1 do local a = points[i] local b = points[i + 1] local cx, cy, cz, t = closestPoint2D(a.x, a.y, a.z, b.x, b.y, b.z, unit.x, unit.z) local dist = dist2D(cx, cz, unit.x, unit.z) local segLen = dist2D(a.x, a.z, b.x, b.z) local segFrames = segLen / math.max(speed, 0.001) if dist < bestDist then bestDist = dist bestTime = elapsed + segFrames * t bestHeight = cy - unitY end elapsed = elapsed + segFrames end local impactX = prediction.impactX or points[#points].x local impactY = prediction.impactY or points[#points].y local impactZ = prediction.impactZ or points[#points].z local impactDist = dist2D(impactX, impactZ, unit.x, unit.z) if bestDist > maxScan and impactDist > maxScan then return nil end local weaponDef = track.weaponDefID and WeaponDefs and WeaponDefs[track.weaponDefID] local aoe = weaponDefNumber(weaponDef, 36, "areaOfEffect", "areaofeffect") local damage = weaponDamage(weaponDef) local directRadius = radius + 3.0 local verticalHit = bestHeight <= hitboxY + 3.0 local directThreat = verticalHit and bestDist <= directRadius * 3.2 local splashThreat = impactDist <= math.max(aoe * 0.75, 90) if not directThreat and not splashThreat then return nil end local posRelX = (track.x or points[1].x) - unit.x local posRelZ = (track.z or points[1].z) - unit.z local dirX = vx / speed local dirZ = vz / speed local shotDistance = dist2D(track.x or impactX, track.z or impactZ, impactX, impactZ) if track.ownerID and spGetUnitPosition then local ownerX, _, ownerZ = spGetUnitPosition(track.ownerID) if ownerX and ownerZ then shotDistance = dist2D(ownerX, ownerZ, impactX, impactZ) end end return { projectile_id = track.projectileID, mode = prediction.mode, time = bestTime, eta = bestTime, ttl = bestTime, perp_dist = bestDist, speed = speed, height_y = bestHeight, rel_height_y = bestHeight, x = track.x or points[1].x, y = (track.y or points[1].y) - unitY, z = track.z or points[1].z, rel_x = impactX - unit.x, rel_y = impactY - unitY, rel_z = impactZ - unit.z, target_x = impactX, target_y = impactY, target_z = impactZ, target_rel_x = impactX - unit.x, target_rel_z = impactZ - unit.z, pos_rel_x = posRelX, pos_rel_z = posRelZ, projectile_rel_x = posRelX, projectile_rel_z = posRelZ, dir_x = dirX, dir_z = dirZ, vx = vx, vy = vy, vz = vz, damage = damage, area_of_effect = aoe, aoe = aoe, shot_distance = shotDistance, } end local function getThreatsForUnit(unit, maxThreats, hitRadius) local threats = {} for _, track in pairs(projectileTracks) do local threat = predictionThreatForUnit(track, unit, hitRadius) if threat then threats[#threats + 1] = threat end end table.sort(threats, function(a, b) if a.time == b.time then return a.perp_dist < b.perp_dist end return a.time < b.time end) local limit = math.min(#threats, maxThreats or #threats) local result = {} for i = 1, limit do result[i] = threats[i] end return result end local function getTrackedProjectiles() return projectileTracks end local function getLockedMissileGroundTarget(track) if track.missileTargetX and track.missileTargetZ then return track.missileTargetX, track.missileTargetY, track.missileTargetZ end local targetX, targetY, targetZ = getProjectileTargetPoint(track) if not targetX then targetX, targetY, targetZ = getCachedAttackTarget(track.ownerID) end if not targetX or not targetZ then return nil end -- Always attach MissileLauncher predictions to the ground point decoded at first target read. -- Recoil/Spring may report a unit target; using that unit position every frame makes the -- curve follow a moving unit and deform. Freezing the ground point makes rockets behave -- like Cannon predictions: the path terminates on terrain. targetY = getGroundY(targetX, targetZ) track.missileTargetX = targetX track.missileTargetY = targetY track.missileTargetZ = targetZ return targetX, targetY, targetZ end local function makeMissilePrediction(track, weaponDef) if #track.samples < MIN_SAMPLES then return nil end local targetX, targetY, targetZ = getLockedMissileGroundTarget(track) if not targetX then return nil end local fit = fitCurrentVelocity(track, nil) local samples = track.samples local current = samples[#samples] local origin = samples[1] if not current or not origin then return nil end local observedSpeed = fit and math.sqrt(fit.vx * fit.vx + fit.vy * fit.vy + fit.vz * fit.vz) or nil local fullPath = recoilHeunMissilePath(origin.x, origin.y, origin.z, targetX, targetY, targetZ, weaponDef, observedSpeed) local points = cropPathFromCurrent(fullPath, current.x, current.y, current.z) return { mode = "missile", points = points, impactX = targetX, impactY = targetY, impactZ = targetZ, } end -------------------------------------------------------------------------------- -- Track update -------------------------------------------------------------------------------- local function updatePrediction(track, frame) local weaponDefID, weaponDef, rejectReason = getWeaponDefForProjectile(track.projectileID, track.ownerID) if rejectReason == "unsupported_weapon" then projectileTracks[track.projectileID] = nil return end if not weaponDef then track.prediction = nil return end track.weaponDefID = weaponDefID track.weaponType = weaponDef.type if isCannonWeapon(weaponDef) then track.prediction = makeCannonPrediction(track, weaponDef) elseif isMissileWeapon(weaponDef) then track.prediction = makeMissilePrediction(track, weaponDef) else track.prediction = nil end track.predictionFrame = frame end local function updateProjectileTrack(projectileID, frame) if not isWeaponProjectile(projectileID) then projectileTracks[projectileID] = nil return end local px, py, pz = spGetProjectilePosition(projectileID) if not px or not pz then projectileTracks[projectileID] = nil return end py = py or getGroundY(px, pz) local ownerID = spGetProjectileOwnerID and spGetProjectileOwnerID(projectileID) local ownerTeam = ownerID and spGetUnitTeam and spGetUnitTeam(ownerID) local oldTrack = projectileTracks[projectileID] if ownerTeam then if not isEnemyTeam(ownerTeam) then projectileTracks[projectileID] = nil return end elseif oldTrack and oldTrack.ownerTeam then if not isEnemyTeam(oldTrack.ownerTeam) then projectileTracks[projectileID] = nil return end elseif not TRACK_PROJECTILES_WITH_UNKNOWN_OWNER then return end local track = oldTrack if not track then track = { projectileID = projectileID, ownerID = ownerID, ownerTeam = ownerTeam, firstFrame = frame, samples = {}, prediction = nil, } projectileTracks[projectileID] = track end track.ownerID = ownerID or track.ownerID track.ownerTeam = ownerTeam or track.ownerTeam track.x = px track.y = py track.z = pz track.frame = frame track.lastSeen = frame addSample(track, frame, px, py, pz) if #track.samples >= MIN_SAMPLES then updatePrediction(track, frame) end end local function updateProjectileTracks(frame) if not spGetProjectilesInRectangle or not spGetProjectilePosition then return end local projectiles = spGetProjectilesInRectangle(MAP_MIN_X, MAP_MIN_Z, MAP_MAX_X, MAP_MAX_Z, false, false) if type(projectiles) ~= "table" then return end for i = 1, #projectiles do updateProjectileTrack(projectiles[i], frame) end for projectileID, track in pairs(projectileTracks) do if frame - (track.lastSeen or frame) > PROJECTILE_TRACK_TTL_FRAMES then projectileTracks[projectileID] = nil end end for projectileID, hint in pairs(projectileHints) do if frame - (hint.frame or frame) > PROJECTILE_HINT_TTL_FRAMES then projectileHints[projectileID] = nil end end for unitID, hint in pairs(unitLastWeapon) do if frame - (hint.frame or frame) > PROJECTILE_HINT_TTL_FRAMES then unitLastWeapon[unitID] = nil end end for unitID, hint in pairs(unitAttackTargetCache) do if frame - (hint.frame or frame) > PROJECTILE_HINT_TTL_FRAMES then unitAttackTargetCache[unitID] = nil end end end -------------------------------------------------------------------------------- -- Drawing: only solid red curves -------------------------------------------------------------------------------- local function drawCurveVertices(points) for i = 1, #points do local p = points[i] glVertex(p.x, p.y, p.z) end end local function drawProjectilePrediction(track) local prediction = track and track.prediction if not prediction or not prediction.points or #prediction.points < 2 then return end glBeginEnd(GL.LINE_STRIP, drawCurveVertices, prediction.points) end function widget:DrawWorld() glDepthTest(false) glDepthMask(false) glLineWidth(LINE_WIDTH) glColor(1.0, 0.05, 0.0, 0.95) for _, track in pairs(projectileTracks) do drawProjectilePrediction(track) end glLineWidth(1.0) glColor(1, 1, 1, 1) glDepthMask(true) glDepthTest(true) end -------------------------------------------------------------------------------- -- Widget call-ins -------------------------------------------------------------------------------- function widget:Initialize() WG.EnemyProjectilePredictor = WG.EnemyProjectilePredictor or {} WG.EnemyProjectilePredictor.GetThreatsForUnit = getThreatsForUnit WG.EnemyProjectilePredictor.GetTrackedProjectiles = getTrackedProjectiles WG.EnemyProjectilePredictor.version = "3.0" debugEcho("initialized") end function widget:Shutdown() if WG.EnemyProjectilePredictor and WG.EnemyProjectilePredictor.GetThreatsForUnit == getThreatsForUnit then WG.EnemyProjectilePredictor = nil end end function widget:GameFrame(frame) if frame % SCAN_EVERY_FRAMES == 0 then updateProjectileTracks(frame) end end function widget:ProjectileCreated(projectileID, ownerID, arg3, arg4) local weaponDefID = findWeaponDefIDInArgs(arg3, arg4) if weaponDefID then projectileHints[projectileID] = { weaponDefID = weaponDefID, frame = spGetGameFrame and spGetGameFrame() or 0, } end end function widget:UnitWeaponFired(unitID, ...) local weaponDefID = findWeaponDefIDInArgs(...) if weaponDefID then unitLastWeapon[unitID] = { weaponDefID = weaponDefID, frame = spGetGameFrame and spGetGameFrame() or 0, } end end function widget:UnitCommand(unitID, unitDefID, teamID, cmdID, cmdParams) if not CMD or cmdID ~= CMD.ATTACK or type(cmdParams) ~= "table" then return end local frame = spGetGameFrame and spGetGameFrame() or 0 if #cmdParams == 1 then unitAttackTargetCache[unitID] = { unitID = cmdParams[1], frame = frame, } elseif #cmdParams >= 3 then unitAttackTargetCache[unitID] = { x = cmdParams[1], y = cmdParams[2], z = cmdParams[3], frame = frame, } end end function widget:Shutdown() clearMap(projectileTracks) clearMap(projectileHints) clearMap(unitLastWeapon) clearMap(unitAttackTargetCache) end