Sweep cap (#54)

* Added initial implementation of Sweep groups

* Added initial implementation of Sweep groups

* Added initial implementation of Sweep groups

* Added initial implementation of Sweep groups

* fixed issues when testing

* Intercept cap (#55)

* First intial WIP for intercept units

* First intial WIP for intercept units

* major updates

---------

Co-authored-by: ex61wi <tim.rorije@ing.com>
Co-authored-by: dutchie032 <dutchie032>

---------

Co-authored-by: ex61wi <tim.rorije@ing.com>
Co-authored-by: dutchie032 <dutchie032>
This commit is contained in:
2025-06-12 22:40:55 +02:00
committed by GitHub
co-authored by ex61wi dutchie032 <dutchie032>
parent bdf7154333
commit ef65f5a353
22 changed files with 1834 additions and 353 deletions
+15
View File
@@ -113,6 +113,19 @@ function AirGroup:Spawn()
self:SpawnInternal(false)
end
function AirGroup:IsInAir()
local group = Group.getByName(self._groupName)
if group then
local units = group:getUnits()
for _, unit in pairs(units) do
if unit:inAir() == true then
return true
end
end
end
return false
end
---@param force boolean
---@param withoutLoadout boolean?
---@protected
@@ -423,6 +436,8 @@ Spearhead.classes.capClasses.airGroups.AirGroup = AirGroup
---@alias AirGroupType
---| "CAP"
---| "SWEEP"
---| "INTERCEPT"
---| "CAS"
---| "SEAD"
+44 -45
View File
@@ -80,63 +80,62 @@ end
---@private
function CapGroup:InitWithName(groupName)
local split_string = Spearhead.Util.split_string(groupName, "_")
local partCount = Spearhead.Util.tableLength(split_string)
if partCount >= 3 then
local partCount = Spearhead.Util.tableLength(split_string)
if partCount >= 3 then
local configPart = split_string[2]
local first = configPart:sub(1, 1)
if first == "A" then
self._isBackup = false
configPart = string.sub(configPart, 2, #configPart)
elseif first == "B" then
configPart = string.sub(configPart, 2, #configPart)
self._isBackup = true
elseif first == "[" then
self._isBackup = false
else
Spearhead.AddMissionEditorWarning("Could not parse the CAP config for group: " .. groupName)
return
end
local configPart = split_string[2]
local first = configPart:sub(1, 1)
if first == "A" then
self._isBackup = false
configPart = string.sub(configPart, 2, #configPart)
elseif first == "B" then
configPart = string.sub(configPart, 2, #configPart)
self._isBackup = true
elseif first == "[" then
self._isBackup = false
else
Spearhead.AddMissionEditorWarning("Could not parse the CAP config for group: " .. groupName)
return
end
local subsplit = Spearhead.Util.split_string(configPart, "|")
if subsplit then
for key, value in pairs(subsplit) do
local keySplit = Spearhead.Util.split_string(value, "]")
local targetZone = keySplit[2]
local allActives = string.sub(keySplit[1], 2, #keySplit[1])
local commaSeperated = Spearhead.Util.split_string(allActives, ",")
for _, value in pairs(commaSeperated) do
local dashSeperated = Spearhead.Util.split_string(value, "-")
if Spearhead.Util.tableLength(dashSeperated) > 1 then
local from = tonumber(dashSeperated[1])
local till = tonumber(dashSeperated[2])
local subsplit = Spearhead.Util.split_string(configPart, "|")
if subsplit then
for key, value in pairs(subsplit) do
local keySplit = Spearhead.Util.split_string(value, "]")
local targetZone = keySplit[2]
local allActives = string.sub(keySplit[1], 2, #keySplit[1])
local commaSeperated = Spearhead.Util.split_string(allActives, ",")
for _, value in pairs(commaSeperated) do
local dashSeperated = Spearhead.Util.split_string(value, "-")
if Spearhead.Util.tableLength(dashSeperated) > 1 then
local from = tonumber(dashSeperated[1])
local till = tonumber(dashSeperated[2])
for i = from, till do
if Spearhead.Util.strContains(targetZone, "A") == true then
self._targetZoneIdPerStage[tostring(i)] = string.gsub(targetZone, "A", tostring(i))
else
self._targetZoneIdPerStage[tostring(i)] = targetZone
end
end
else
for i = from, till do
if Spearhead.Util.strContains(targetZone, "A") == true then
self._targetZoneIdPerStage[tostring(dashSeperated[1])] = string.gsub(targetZone, "A", tostring(dashSeperated[1]))
self._targetZoneIdPerStage[tostring(i)] = string.gsub(targetZone, "A", tostring(i))
else
self._targetZoneIdPerStage[tostring(dashSeperated[1])] = targetZone
self._targetZoneIdPerStage[tostring(i)] = targetZone
end
end
else
if Spearhead.Util.strContains(targetZone, "A") == true then
self._targetZoneIdPerStage[tostring(dashSeperated[1])] = string.gsub(targetZone, "A", tostring(dashSeperated[1]))
else
self._targetZoneIdPerStage[tostring(dashSeperated[1])] = targetZone
end
end
end
end
env.info("Capgroup parsed with table: " .. Spearhead.Util.toString(self._targetZoneIdPerStage))
else
Spearhead.AddMissionEditorWarning("CAP Group with name: " .. groupName .. "should have at least 3 parts, but has " .. partCount)
return nil
end
env.info("Capgroup parsed with table: " .. Spearhead.Util.toString(self._targetZoneIdPerStage))
else
Spearhead.AddMissionEditorWarning("CAP Group with name: " .. groupName .. "should have at least 3 parts, but has " .. partCount)
end
end
if not Spearhead then Spearhead = {} end
@@ -0,0 +1,358 @@
---@class InterceptGroup : AirGroup
---@field private _targetNames Array<string>
---@field private _targetZoneName string
---@field private _detectionManager DetectionManager
---@field private _coalitionSide CoalitionSide
---@field private _lastKnownTargetAt number
---@field private _currentTargetName string?
---@field private _targetZoneIdPerStage table<string, string>
---@field private _config CapConfig
---@field private _airbase Airbase
---@field private _maxSpeed number?
---@field private _updateTaskID number|nil
local InterceptGroup = {}
InterceptGroup.__index = InterceptGroup
---@param groupName string
---@param config CapConfig
---@param logger Logger
---@param detectionManager DetectionManager
---@return InterceptGroup?
function InterceptGroup.New(groupName, config, logger, detectionManager)
setmetatable(InterceptGroup, Spearhead.classes.capClasses.airGroups.AirGroup)
local self = setmetatable({}, InterceptGroup)
Spearhead.classes.capClasses.airGroups.AirGroup.New(self, groupName, "INTERCEPT", config, logger)
local group = Group.getByName(groupName)
if not group then
logger:error("InterceptGroup: Group " .. groupName .. " does not exist")
return nil
end
local unit = group:getUnit(1)
if unit then
local desc = unit:getDesc()
if desc["speedMax10K"] then
self._maxSpeed = desc["speedMax10K"] * 0.75
self._logger:debug("InterceptGroup: Max speed for group " .. groupName .. " is set to " .. self._maxSpeed .. " m/s")
end
end
self._coalitionSide = group:getCoalition()
self._detectionManager = detectionManager
self._config = config
self._targetNames = {}
self._targetZoneIdPerStage = {}
self:InitWithName(groupName)
return self
end
---comment
---@param units Array<string>
---@param homeAirbase Airbase
function InterceptGroup:SendToInterceptUnits(units, zoneName, homeAirbase)
self._airbase = homeAirbase
self._targetZoneName = zoneName
self:SetTargetUnits(units)
end
---@return string?
function InterceptGroup:GetZoneIDWhenStageID(stageID)
return self._targetZoneIdPerStage[stageID]
end
---@return string?
function InterceptGroup:GetCurrentTargetZone()
return self._targetZoneName
end
---comment
---@param unitNames Array<string>
function InterceptGroup:SetTargetUnits(unitNames)
self._targetNames = unitNames
self:UpdateTask()
if self._updateTaskID then
timer.removeFunction(self._updateTaskID)
end
local updateContinous = function(selfA, time)
local next = selfA:UpdateTask()
if next then
return time + next
else
return nil
end
end
self._updateTaskID = timer.scheduleFunction(updateContinous, self, timer.getTime() + 30)
end
function InterceptGroup:RemoveTargetUnit(unit)
if not unit then return end
local name = unit:getName()
for key, value in pairs(self._targetNames) do
if value == "name" then
table.remove(self._targetNames, key)
self._logger:debug("InterceptGroup: Removed target unit " .. name .. " from group " .. self._groupName)
break
end
end
end
---@private
---@return Unit?
---@return Vec3? groupPoint
function InterceptGroup:GetClosestTarget()
local group = Group.getByName(self._groupName)
if not group then return nil end
local groupPoint = nil
for _, unit in pairs(group:getUnits()) do
if unit and unit:isExist() then
groupPoint = unit:getPoint()
break
end
end
if not groupPoint then return nil end
local closestUnit = nil
local closestDistance = math.huge
for _, targetName in pairs(self._targetNames) do
local targetUnit = Unit.getByName(targetName)
if targetUnit and targetUnit:isExist() then
local pos = targetUnit:getPoint()
local distance = Spearhead.Util.VectorDistance3d(groupPoint, pos)
if distance < closestDistance then
closestDistance = distance
closestUnit = targetUnit
end
end
end
return closestUnit, groupPoint
end
---@return number? @interval or null if no retry required
function InterceptGroup:UpdateTask()
local group = Group.getByName(self._groupName)
if not group then return end
local closestUnit, groupPoint = self:GetClosestTarget()
if not closestUnit then return 15 end
if not groupPoint then return 15 end
local closesUnitVec = closestUnit:getPoint()
local alt = closesUnitVec.y
if alt < 1000 then
alt = 1000 -- Ensure minimum altitude for intercept
end
local selfDetected = false
for _, unit in pairs(group:getUnits()) do
if unit and unit:isExist() then
local controller = unit:getController()
if controller then
for _, detected in pairs(controller:getDetectedTargets(Controller.Detection.VISUAL, Controller.Detection.OPTIC, Controller.Detection.RADAR)) do
if detected and detected.object and detected.distance == true then
if detected.object:getName() == closestUnit:getName() then
selfDetected = true
break
end
end
end
end
end
end
if Spearhead.DcsUtil.IsBingoFuel(self._groupName) then
self._logger:debug("InterceptGroup: " .. self._groupName .. " is at bingo fuel, returning to base")
self:SendRTB(self._airbase)
return nil -- Return to base if bingo fuel
end
if selfDetected and self:IsInAir() == true then
if self._currentTargetName and self._currentTargetName == closestUnit:getName() then
local distance = Spearhead.Util.VectorDistance3d(closestUnit:getPoint(), groupPoint)
if distance < 30 * 1852 then
-- If the target is within 10 nautical miles, continue attacking
self._logger:debug("InterceptGroup: " .. self._groupName .. " continues attacking target " .. closestUnit:getName())
return 15 -- Continue attacking the detected target
end
end
self._logger:debug("InterceptGroup: " .. self._groupName .. " has detected target " .. closestUnit:getName() .. ", creating intercept mission")
local vec3 = closestUnit:getPoint()
local vec2 = { x = vec3.x, y = vec3.z }
local groupPointVec2 = { x = groupPoint.x, y = groupPoint.z }
local mission = Spearhead.classes.capClasses.taskings.INTERCEPT.getUnitInterceptMissionFromAir(self._groupName, groupPointVec2, vec2, closestUnit, self._airbase, self._config, self._maxSpeed, alt)
self:SetMission(mission)
self._currentTargetName = closestUnit:getName()
return 15 -- Just continue attacking the detected target
else
self._currentTargetName = nil
end
local speed = self._config:getMaxSpeed()
local interceptPoint = self:GetInterceptPoint(groupPoint, speed, closestUnit)
if interceptPoint == nil then
return 30 -- Return to base if no intercept point could be calculated
end
local mission = nil
if self:IsInAir() == true then
-- If the group is in the air, create an intercept mission
mission = Spearhead.classes.capClasses.taskings.INTERCEPT.getMissionFromInAir(
self._groupName,
{ x = groupPoint.x, y = groupPoint.z },
interceptPoint,
self._airbase,
self._config,
self._maxSpeed,
alt
)
else
mission = Spearhead.classes.capClasses.taskings.INTERCEPT.getMissionFromAirbase(
self._groupName,
interceptPoint,
self._airbase,
self._config,
self._maxSpeed,
alt
)
end
if mission then
self:SetMission(mission)
else
self._logger:warn("InterceptGroup: Could not create mission for group " .. self._groupName)
return 30 -- Return to base if mission could not be created
end
return 15
end
---@param originatingUnit Vec3
---@param speed number
---@param targetUnit Unit
---@return Vec2? @Returns the intercept point as a Vec2 or nil if no intercept point could be calculated
function InterceptGroup:GetInterceptPoint(originatingUnit, speed, targetUnit)
-- Calculate intercept point for a moving target
-- originatingUnit: Vec3 (our position)
-- speed: our speed (scalar, m/s)
-- targetUnit: Unit (target)
if not targetUnit or not targetUnit:isExist() then return nil end
local targetPos = targetUnit:getPoint()
local targetVel = targetUnit:getVelocity() -- Vec3
local relPos = {
x = targetPos.x - originatingUnit.x,
y = targetPos.y - originatingUnit.y,
z = targetPos.z - originatingUnit.z
}
local relVel = {
x = targetVel.x,
y = targetVel.y,
z = targetVel.z
}
local relPos2 = relPos.x^2 + relPos.y^2 + relPos.z^2
local relVel2 = relVel.x^2 + relVel.y^2 + relVel.z^2
local speed2 = speed^2
local dot = relPos.x * relVel.x + relPos.y * relVel.y + relPos.z * relVel.z
-- Quadratic formula: a*t^2 + b*t + c = 0
local a = relVel2 - speed2
local b = 2 * dot
local c = relPos2
local discriminant = b^2 - 4*a*c
if discriminant < 0 or a == 0 then
-- No solution, just head to current position
return { x = targetPos.x, y = targetPos.z }
end
local sqrtDisc = math.sqrt(discriminant)
local t1 = (-b + sqrtDisc) / (2*a)
local t2 = (-b - sqrtDisc) / (2*a)
local t = math.min(t1, t2)
if t < 0 then t = math.max(t1, t2) end
if t < 0 then
-- No valid intercept time, just head to current position
return { x = targetPos.x, y = targetPos.z }
end
-- Intercept point
local intercept = {
x = targetPos.x + targetVel.x * t,
y = targetPos.z + targetVel.z * t
}
return intercept
end
---@private
function InterceptGroup:InitWithName(groupName)
local split_string = Spearhead.Util.split_string(groupName, "_")
local partCount = Spearhead.Util.tableLength(split_string)
if partCount >= 3 then
local configPart = split_string[2]
configPart = string.sub(configPart, 2, #configPart)
local subsplit = Spearhead.Util.split_string(configPart, "|")
if subsplit then
for key, value in pairs(subsplit) do
local keySplit = Spearhead.Util.split_string(value, "]")
local targetZone = keySplit[2]
local allActives = string.sub(keySplit[1], 2, #keySplit[1])
local commaSeperated = Spearhead.Util.split_string(allActives, ",")
for _, value in pairs(commaSeperated) do
local dashSeperated = Spearhead.Util.split_string(value, "-")
if Spearhead.Util.tableLength(dashSeperated) > 1 then
local from = tonumber(dashSeperated[1])
local till = tonumber(dashSeperated[2])
for i = from, till do
if Spearhead.Util.strContains(targetZone, "A") == true then
self._targetZoneIdPerStage[tostring(i)] = string.gsub(targetZone, "A", tostring(i))
else
self._targetZoneIdPerStage[tostring(i)] = targetZone
end
end
else
if Spearhead.Util.strContains(targetZone, "A") == true then
self._targetZoneIdPerStage[tostring(dashSeperated[1])] = string.gsub(targetZone, "A", tostring(dashSeperated[1]))
else
self._targetZoneIdPerStage[tostring(dashSeperated[1])] = targetZone
end
end
end
end
end
env.info("interceptGroup parsed with table: " .. Spearhead.Util.toString(self._targetZoneIdPerStage))
else
Spearhead.AddMissionEditorWarning("CAP Group with name: " .. groupName .. "should have at least 3 parts, but has " .. partCount)
end
end
if not Spearhead then Spearhead = {} end
if not Spearhead.classes then Spearhead.classes = {} end
if not Spearhead.classes.capClasses then Spearhead.classes.capClasses = {} end
if not Spearhead.classes.capClasses.airGroups then Spearhead.classes.capClasses.airGroups = {} end
Spearhead.classes.capClasses.airGroups.InterceptGroup = InterceptGroup
+120
View File
@@ -0,0 +1,120 @@
---@class SweepGroup : AirGroup
---@field _targetZoneIdPerStage table<number, string>
---@field _currentTargetZoneID string?
local SweepGroup = {}
SweepGroup.__index = SweepGroup
---@param groupName string
---@param config CapConfig
---@param logger Logger
---@return SweepGroup
function SweepGroup.New(groupName, config, logger)
setmetatable(SweepGroup, Spearhead.classes.capClasses.airGroups.AirGroup)
local self = setmetatable({}, SweepGroup) --[[@as SweepGroup]]
Spearhead.classes.capClasses.airGroups.AirGroup.New(self, groupName, "SWEEP", config, logger)
self._targetZoneIdPerStage = {}
self:InitWithName(groupName)
return self
end
---@return string?
function SweepGroup:GetZoneIDWhenStageID(stageID)
return self._targetZoneIdPerStage[stageID]
end
---@return string?
function SweepGroup:GetCurrentTargetZoneID()
return self._currentTargetZoneID
end
---@class SetTaskParams
---@field task table
---@field self SweepGroup
---@param params SetTaskParams
local setMissionDelayedTask = function(params, time)
params.self:SetMissionPrivate(params.task)
end
function SweepGroup:SendToZone(zone, targetZoneID, airbase)
self._logger:debug("Airgroup " .. self._groupName .. " called to zone: " .. zone.name)
self._currentTargetZoneID = targetZoneID
local group = Group.getByName(self._groupName)
local mission = Spearhead.classes.capClasses.taskings.SWEEP.getAsMissionFromAirbase(self._groupName, airbase, zone, self._config)
if mission then
---@type SetTaskParams
local params = {
task = mission,
self = self
}
local delay = math.random(120, 600)
timer.scheduleFunction(setMissionDelayedTask, params, timer.getTime() + delay)
else
self._logger:error("SweepGroup:SendToZone - Mission could not be created for group: " .. self._groupName)
end
end
function SweepGroup:SendToZoneInternal()
end
---@private
function SweepGroup:InitWithName(groupName)
local split_string = Spearhead.Util.split_string(groupName, "_")
local partCount = Spearhead.Util.tableLength(split_string)
if partCount >= 3 then
local configPart = split_string[2]
configPart = string.sub(configPart, 2, #configPart)
local subsplit = Spearhead.Util.split_string(configPart, "|")
if subsplit then
for key, value in pairs(subsplit) do
local keySplit = Spearhead.Util.split_string(value, "]")
local targetZone = keySplit[2]
local allActives = string.sub(keySplit[1], 2, #keySplit[1])
local commaSeperated = Spearhead.Util.split_string(allActives, ",")
for _, value in pairs(commaSeperated) do
local dashSeperated = Spearhead.Util.split_string(value, "-")
if Spearhead.Util.tableLength(dashSeperated) > 1 then
local from = tonumber(dashSeperated[1])
local till = tonumber(dashSeperated[2])
for i = from, till do
if Spearhead.Util.strContains(targetZone, "A") == true then
self._targetZoneIdPerStage[tostring(i)] = string.gsub(targetZone, "A", tostring(i))
else
self._targetZoneIdPerStage[tostring(i)] = targetZone
end
end
else
if Spearhead.Util.strContains(targetZone, "A") == true then
self._targetZoneIdPerStage[tostring(dashSeperated[1])] = string.gsub(targetZone, "A",
tostring(dashSeperated[1]))
else
self._targetZoneIdPerStage[tostring(dashSeperated[1])] = targetZone
end
end
end
end
end
env.info("SweepGroup parsed with table: " .. Spearhead.Util.toString(self._targetZoneIdPerStage))
else
Spearhead.AddMissionEditorWarning("SWEEP Group with name: " ..
groupName .. "should have at least 3 parts, but has " .. partCount)
end
end
if not Spearhead then Spearhead = {} end
if not Spearhead.classes then Spearhead.classes = {} end
if not Spearhead.classes.capClasses then Spearhead.classes.capClasses = {} end
if not Spearhead.classes.capClasses.airGroups then Spearhead.classes.capClasses.airGroups = {} end
Spearhead.classes.capClasses.airGroups.SweepGroup = SweepGroup