mirror of
https://github.com/iTracerFacer/DCS_MissionDev.git
synced 2025-12-03 04:14:46 +00:00
Attack/Defend AI behavior for deployed troops and built vehicles
This commit is contained in:
parent
75d4c2947f
commit
e03f5af35b
@ -145,6 +145,11 @@ CTLD.Messages = {
|
|||||||
-- Zone state changes
|
-- Zone state changes
|
||||||
zone_activated = "{kind} Zone {zone} is now ACTIVE.",
|
zone_activated = "{kind} Zone {zone} is now ACTIVE.",
|
||||||
zone_deactivated = "{kind} Zone {zone} is now INACTIVE.",
|
zone_deactivated = "{kind} Zone {zone} is now INACTIVE.",
|
||||||
|
|
||||||
|
-- Attack/Defend announcements
|
||||||
|
attack_enemy_announce = "{unit_name} deployed by {player} has spotted an enemy {enemy_type} at {brg}°, {rng} {rng_u}. Moving to engage!",
|
||||||
|
attack_base_announce = "{unit_name} deployed by {player} is moving to capture {base_name} at {brg}°, {rng} {rng_u}.",
|
||||||
|
attack_no_targets = "{unit_name} deployed by {player} found no targets within {rng} {rng_u}. Holding position.",
|
||||||
}
|
}
|
||||||
|
|
||||||
CTLD.Config = {
|
CTLD.Config = {
|
||||||
@ -174,12 +179,22 @@ CTLD.Config = {
|
|||||||
RequirePickupZoneForTroopLoad = true, -- if true, troops can only be loaded while inside a Supply (Pickup) Zone
|
RequirePickupZoneForTroopLoad = true, -- if true, troops can only be loaded while inside a Supply (Pickup) Zone
|
||||||
PickupZoneMaxDistance = 10000, -- meters; nearest pickup zone must be within this distance to allow a request
|
PickupZoneMaxDistance = 10000, -- meters; nearest pickup zone must be within this distance to allow a request
|
||||||
|
|
||||||
|
-- Attack/Defend AI behavior for deployed troops and built vehicles
|
||||||
|
AttackAI = {
|
||||||
|
Enabled = true, -- master switch for attack behavior
|
||||||
|
TroopSearchRadius = 3000, -- meters: when deploying troops with Attack, search radius for targets/bases
|
||||||
|
VehicleSearchRadius = 6000, -- meters: when building vehicles with Attack, search radius
|
||||||
|
PrioritizeEnemyBases = true, -- if true, prefer enemy-held bases over ground units when both are in range
|
||||||
|
TroopAdvanceSpeedKmh = 20, -- movement speed for troops when ordered to attack
|
||||||
|
VehicleAdvanceSpeedKmh = 35, -- movement speed for vehicles when ordered to attack
|
||||||
|
},
|
||||||
|
|
||||||
-- Optional: draw zones on the F10 map using trigger.action.* markup (ME Draw-like)
|
-- Optional: draw zones on the F10 map using trigger.action.* markup (ME Draw-like)
|
||||||
MapDraw = {
|
MapDraw = {
|
||||||
Enabled = true, -- master switch for any map drawings created by this script
|
Enabled = true, -- master switch for any map drawings created by this script
|
||||||
DrawPickupZones = true, -- draw Pickup/Supply zones as shaded circles with labels
|
DrawPickupZones = true, -- draw Pickup/Supply zones as shaded circles with labels
|
||||||
DrawDropZones = false, -- optionally draw Drop zones
|
DrawDropZones = true, -- optionally draw Drop zones
|
||||||
DrawFOBZones = false, -- optionally draw FOB zones
|
DrawFOBZones = true, -- optionally draw FOB zones
|
||||||
FontSize = 18, -- label text size
|
FontSize = 18, -- label text size
|
||||||
ReadOnly = true, -- prevent clients from removing the shapes
|
ReadOnly = true, -- prevent clients from removing the shapes
|
||||||
ForAll = false, -- if true, draw shapes to all (-1) instead of coalition only (useful for testing/briefing)
|
ForAll = false, -- if true, draw shapes to all (-1) instead of coalition only (useful for testing/briefing)
|
||||||
@ -691,6 +706,120 @@ local function _fmtTemplate(tpl, data)
|
|||||||
end))
|
end))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Coalition utility: return opposite side (BLUE<->RED); NEUTRAL returns RED by default
|
||||||
|
local function _enemySide(side)
|
||||||
|
if coalition and coalition.side then
|
||||||
|
if side == coalition.side.BLUE then return coalition.side.RED end
|
||||||
|
if side == coalition.side.RED then return coalition.side.BLUE end
|
||||||
|
end
|
||||||
|
return coalition.side.RED
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Find nearest enemy-held base within radius; returns {point=vec3, name=string, dist=meters}
|
||||||
|
function CTLD:_findNearestEnemyBase(point, radius)
|
||||||
|
local enemy = _enemySide(self.Side)
|
||||||
|
local ok, bases = pcall(function()
|
||||||
|
if coalition and coalition.getAirbases then return coalition.getAirbases(enemy) end
|
||||||
|
return {}
|
||||||
|
end)
|
||||||
|
if not ok or not bases then return nil end
|
||||||
|
local best
|
||||||
|
for _,ab in ipairs(bases) do
|
||||||
|
local p = ab:getPoint()
|
||||||
|
local dx = (p.x - point.x); local dz = (p.z - point.z)
|
||||||
|
local d = math.sqrt(dx*dx + dz*dz)
|
||||||
|
if d <= radius and ((not best) or d < best.dist) then
|
||||||
|
best = { point = { x = p.x, z = p.z }, name = ab:getName() or 'Base', dist = d }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return best
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Find nearest enemy ground group centroid within radius; returns {point=vec3, group=GROUP|nil, dcsGroupName=string, dist=meters, type=string}
|
||||||
|
function CTLD:_findNearestEnemyGround(point, radius)
|
||||||
|
local enemy = _enemySide(self.Side)
|
||||||
|
local best
|
||||||
|
-- Use MOOSE SET_GROUP to enumerate enemy ground groups
|
||||||
|
local set = SET_GROUP:New():FilterCoalitions(enemy):FilterCategories(Group.Category.GROUND):FilterActive(true):FilterStart()
|
||||||
|
set:ForEachGroup(function(g)
|
||||||
|
local alive = g:IsAlive()
|
||||||
|
if alive then
|
||||||
|
local c = g:GetCoordinate()
|
||||||
|
if c then
|
||||||
|
local v3 = c:GetVec3()
|
||||||
|
local dx = (v3.x - point.x); local dz = (v3.z - point.z)
|
||||||
|
local d = math.sqrt(dx*dx + dz*dz)
|
||||||
|
if d <= radius and ((not best) or d < best.dist) then
|
||||||
|
-- Try to infer a type label from first unit
|
||||||
|
local ut = 'unit'
|
||||||
|
local u1 = g:GetUnit(1)
|
||||||
|
if u1 then ut = _getUnitType(u1) or ut end
|
||||||
|
best = { point = { x = v3.x, z = v3.z }, group = g, dcsGroupName = g:GetName(), dist = d, type = ut }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
return best
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Order a ground group by name to move toward target point at a given speed (km/h). Uses MOOSE route when available.
|
||||||
|
function CTLD:_orderGroundGroupToPointByName(groupName, targetPoint, speedKmh)
|
||||||
|
if not groupName or not targetPoint then return end
|
||||||
|
local mg
|
||||||
|
local ok = pcall(function() mg = GROUP:FindByName(groupName) end)
|
||||||
|
if ok and mg then
|
||||||
|
local vec2 = VECTOR2:New(targetPoint.x, targetPoint.z)
|
||||||
|
-- RouteGroundTo(speed km/h). Use pcall to avoid mission halt if API differs.
|
||||||
|
local _, _ = pcall(function() mg:RouteGroundTo(vec2, speedKmh or 25) end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- Fallback: DCS Group controller simple mission to single waypoint
|
||||||
|
local dg = Group.getByName(groupName)
|
||||||
|
if not dg then return end
|
||||||
|
local ctrl = dg:getController()
|
||||||
|
if not ctrl then return end
|
||||||
|
-- Try to set a simple go-to task
|
||||||
|
local task = {
|
||||||
|
id = 'Mission',
|
||||||
|
params = {
|
||||||
|
route = {
|
||||||
|
points = {
|
||||||
|
{
|
||||||
|
x = targetPoint.x, y = targetPoint.z, speed = 5, action = 'Off Road', task = {}, type = 'Turning Point', ETA = 0, ETA_locked = false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pcall(function() ctrl:setTask(task) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Assign attack behavior to a newly spawned ground group by name
|
||||||
|
function CTLD:_assignAttackBehavior(groupName, originPoint, isVehicle)
|
||||||
|
if not (self.Config.AttackAI and self.Config.AttackAI.Enabled) then return end
|
||||||
|
local radius = isVehicle and (self.Config.AttackAI.VehicleSearchRadius or 5000) or (self.Config.AttackAI.TroopSearchRadius or 3000)
|
||||||
|
local prioBase = (self.Config.AttackAI.PrioritizeEnemyBases ~= false)
|
||||||
|
local speed = isVehicle and (self.Config.AttackAI.VehicleAdvanceSpeedKmh or 35) or (self.Config.AttackAI.TroopAdvanceSpeedKmh or 20)
|
||||||
|
local player = 'Player'
|
||||||
|
-- Try to infer last requesting player from crate/troop context is complex; caller should pass announcements separately when needed.
|
||||||
|
-- Target selection
|
||||||
|
local target
|
||||||
|
local pickedBase
|
||||||
|
if prioBase then
|
||||||
|
local base = self:_findNearestEnemyBase(originPoint, radius)
|
||||||
|
if base then target = { point = base.point, name = base.name, kind = 'base', dist = base.dist } pickedBase = base end
|
||||||
|
end
|
||||||
|
if not target then
|
||||||
|
local eg = self:_findNearestEnemyGround(originPoint, radius)
|
||||||
|
if eg then target = { point = eg.point, name = eg.dcsGroupName, kind = 'enemy', dist = eg.dist, etype = eg.type } end
|
||||||
|
end
|
||||||
|
-- Order movement if we have a target
|
||||||
|
if target then
|
||||||
|
self:_orderGroundGroupToPointByName(groupName, target.point, speed)
|
||||||
|
end
|
||||||
|
return target -- caller will handle announcement
|
||||||
|
end
|
||||||
|
|
||||||
local function _bearingDeg(from, to)
|
local function _bearingDeg(from, to)
|
||||||
local dx = (to.x - from.x)
|
local dx = (to.x - from.x)
|
||||||
local dz = (to.z - from.z)
|
local dz = (to.z - from.z)
|
||||||
@ -1208,9 +1337,23 @@ function CTLD:BuildGroupMenus(group)
|
|||||||
-- Troops
|
-- Troops
|
||||||
CMD('Load Troops', root, function() self:LoadTroops(group) end)
|
CMD('Load Troops', root, function() self:LoadTroops(group) end)
|
||||||
CMD('Unload Troops', root, function() self:UnloadTroops(group) end)
|
CMD('Unload Troops', root, function() self:UnloadTroops(group) end)
|
||||||
|
-- New: Deploy behavior variants
|
||||||
|
do
|
||||||
|
local tr = (self.Config.AttackAI and self.Config.AttackAI.TroopSearchRadius) or 3000
|
||||||
|
local labelAtk = string.format('Deploy Troops (Attack [%dm])', tr)
|
||||||
|
CMD('Deploy Troops (Defend)', root, function() self:UnloadTroops(group, { behavior = 'defend' }) end)
|
||||||
|
CMD(labelAtk, root, function() self:UnloadTroops(group, { behavior = 'attack' }) end)
|
||||||
|
end
|
||||||
|
|
||||||
-- Build
|
-- Build
|
||||||
CMD('Build Here', root, function() self:BuildAtGroup(group) end)
|
CMD('Build Here', root, function() self:BuildAtGroup(group) end)
|
||||||
|
-- New: Build behavior variants for combat vehicles/groups
|
||||||
|
do
|
||||||
|
local vr = (self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000
|
||||||
|
local labelBA = string.format('Build Here (Attack [%dm])', vr)
|
||||||
|
CMD('Build Here (Defend)', root, function() self:BuildAtGroup(group, { behavior = 'defend' }) end)
|
||||||
|
CMD(labelBA, root, function() self:BuildAtGroup(group, { behavior = 'attack' }) end)
|
||||||
|
end
|
||||||
|
|
||||||
-- Crate management (loaded crates)
|
-- Crate management (loaded crates)
|
||||||
CMD('Drop One Loaded Crate', root, function() self:DropLoadedCrates(group, 1) end)
|
CMD('Drop One Loaded Crate', root, function() self:DropLoadedCrates(group, 1) end)
|
||||||
@ -1779,7 +1922,7 @@ end
|
|||||||
-- Build logic
|
-- Build logic
|
||||||
-- =========================
|
-- =========================
|
||||||
-- #region Build logic
|
-- #region Build logic
|
||||||
function CTLD:BuildAtGroup(group)
|
function CTLD:BuildAtGroup(group, opts)
|
||||||
local unit = group:GetUnit(1)
|
local unit = group:GetUnit(1)
|
||||||
if not unit or not unit:IsAlive() then return end
|
if not unit or not unit:IsAlive() then return end
|
||||||
-- Build cooldown/confirmation guardrails
|
-- Build cooldown/confirmation guardrails
|
||||||
@ -1893,6 +2036,24 @@ function CTLD:BuildAtGroup(group)
|
|||||||
self:_CreateFOBPickupZone({ x = spawnAt.x, z = spawnAt.z }, cat, hdg)
|
self:_CreateFOBPickupZone({ x = spawnAt.x, z = spawnAt.z }, cat, hdg)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
-- Assign optional behavior for built vehicles/groups
|
||||||
|
local behavior = opts and opts.behavior or nil
|
||||||
|
if behavior == 'attack' and self.Config.AttackAI and self.Config.AttackAI.Enabled then
|
||||||
|
local t = self:_assignAttackBehavior(g:getName(), spawnAt, true)
|
||||||
|
local isMetric = _getPlayerIsMetric(group:GetUnit(1))
|
||||||
|
if t and t.kind == 'base' then
|
||||||
|
local brg = _bearingDeg({ x = spawnAt.x, z = spawnAt.z }, { x = t.point.x, z = t.point.z })
|
||||||
|
local v, u = _fmtRange(t.dist or 0, isMetric)
|
||||||
|
_eventSend(self, nil, self.Side, 'attack_base_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), base_name = t.name, brg = brg, rng = v, rng_u = u })
|
||||||
|
elseif t and t.kind == 'enemy' then
|
||||||
|
local brg = _bearingDeg({ x = spawnAt.x, z = spawnAt.z }, { x = t.point.x, z = t.point.z })
|
||||||
|
local v, u = _fmtRange(t.dist or 0, isMetric)
|
||||||
|
_eventSend(self, nil, self.Side, 'attack_enemy_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), enemy_type = t.etype or 'unit', brg = brg, rng = v, rng_u = u })
|
||||||
|
else
|
||||||
|
local v, u = _fmtRange((self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000, isMetric)
|
||||||
|
_eventSend(self, nil, self.Side, 'attack_no_targets', { unit_name = g:getName(), player = _playerNameFromGroup(group), rng = v, rng_u = u })
|
||||||
|
end
|
||||||
|
end
|
||||||
if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end
|
if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end
|
||||||
return
|
return
|
||||||
else
|
else
|
||||||
@ -1920,6 +2081,24 @@ function CTLD:BuildAtGroup(group)
|
|||||||
consumeCrates(key, cat.required or 1)
|
consumeCrates(key, cat.required or 1)
|
||||||
-- No single-unit cap counters when caps are disabled
|
-- No single-unit cap counters when caps are disabled
|
||||||
_eventSend(self, nil, self.Side, 'build_success_coalition', { build = cat.description or key, player = _playerNameFromGroup(group) })
|
_eventSend(self, nil, self.Side, 'build_success_coalition', { build = cat.description or key, player = _playerNameFromGroup(group) })
|
||||||
|
-- Assign optional behavior for built vehicles/groups
|
||||||
|
local behavior = opts and opts.behavior or nil
|
||||||
|
if behavior == 'attack' and self.Config.AttackAI and self.Config.AttackAI.Enabled then
|
||||||
|
local t = self:_assignAttackBehavior(g:getName(), spawnAt, true)
|
||||||
|
local isMetric = _getPlayerIsMetric(group:GetUnit(1))
|
||||||
|
if t and t.kind == 'base' then
|
||||||
|
local brg = _bearingDeg({ x = spawnAt.x, z = spawnAt.z }, { x = t.point.x, z = t.point.z })
|
||||||
|
local v, u = _fmtRange(t.dist or 0, isMetric)
|
||||||
|
_eventSend(self, nil, self.Side, 'attack_base_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), base_name = t.name, brg = brg, rng = v, rng_u = u })
|
||||||
|
elseif t and t.kind == 'enemy' then
|
||||||
|
local brg = _bearingDeg({ x = spawnAt.x, z = spawnAt.z }, { x = t.point.x, z = t.point.z })
|
||||||
|
local v, u = _fmtRange(t.dist or 0, isMetric)
|
||||||
|
_eventSend(self, nil, self.Side, 'attack_enemy_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), enemy_type = t.etype or 'unit', brg = brg, rng = v, rng_u = u })
|
||||||
|
else
|
||||||
|
local v, u = _fmtRange((self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000, isMetric)
|
||||||
|
_eventSend(self, nil, self.Side, 'attack_no_targets', { unit_name = g:getName(), player = _playerNameFromGroup(group), rng = v, rng_u = u })
|
||||||
|
end
|
||||||
|
end
|
||||||
if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end
|
if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end
|
||||||
return
|
return
|
||||||
else
|
else
|
||||||
@ -2285,7 +2464,7 @@ function CTLD:LoadTroops(group, opts)
|
|||||||
_eventSend(self, group, nil, 'troops_loaded', { count = capacity })
|
_eventSend(self, group, nil, 'troops_loaded', { count = capacity })
|
||||||
end
|
end
|
||||||
|
|
||||||
function CTLD:UnloadTroops(group)
|
function CTLD:UnloadTroops(group, opts)
|
||||||
local gname = group:GetName()
|
local gname = group:GetName()
|
||||||
local load = CTLD._troopsLoaded[gname]
|
local load = CTLD._troopsLoaded[gname]
|
||||||
if not load or (load.count or 0) == 0 then _eventSend(self, group, nil, 'no_troops', {}) return end
|
if not load or (load.count or 0) == 0 then _eventSend(self, group, nil, 'no_troops', {}) return end
|
||||||
@ -2316,6 +2495,25 @@ function CTLD:UnloadTroops(group)
|
|||||||
if spawned then
|
if spawned then
|
||||||
CTLD._troopsLoaded[gname] = nil
|
CTLD._troopsLoaded[gname] = nil
|
||||||
_eventSend(self, nil, self.Side, 'troops_unloaded_coalition', { count = #units, player = _playerNameFromGroup(group) })
|
_eventSend(self, nil, self.Side, 'troops_unloaded_coalition', { count = #units, player = _playerNameFromGroup(group) })
|
||||||
|
-- Assign optional behavior
|
||||||
|
local behavior = opts and opts.behavior or nil
|
||||||
|
if behavior == 'attack' and self.Config.AttackAI and self.Config.AttackAI.Enabled then
|
||||||
|
local t = self:_assignAttackBehavior(spawned:getName(), center, false)
|
||||||
|
-- Announce intentions globally
|
||||||
|
local isMetric = _getPlayerIsMetric(group:GetUnit(1))
|
||||||
|
if t and t.kind == 'base' then
|
||||||
|
local brg = _bearingDeg({ x = center.x, z = center.z }, { x = t.point.x, z = t.point.z })
|
||||||
|
local v, u = _fmtRange(t.dist or 0, isMetric)
|
||||||
|
_eventSend(self, nil, self.Side, 'attack_base_announce', { unit_name = spawned:getName(), player = _playerNameFromGroup(group), base_name = t.name, brg = brg, rng = v, rng_u = u })
|
||||||
|
elseif t and t.kind == 'enemy' then
|
||||||
|
local brg = _bearingDeg({ x = center.x, z = center.z }, { x = t.point.x, z = t.point.z })
|
||||||
|
local v, u = _fmtRange(t.dist or 0, isMetric)
|
||||||
|
_eventSend(self, nil, self.Side, 'attack_enemy_announce', { unit_name = spawned:getName(), player = _playerNameFromGroup(group), enemy_type = t.etype or 'unit', brg = brg, rng = v, rng_u = u })
|
||||||
|
else
|
||||||
|
local v, u = _fmtRange((self.Config.AttackAI and self.Config.AttackAI.TroopSearchRadius) or 3000, isMetric)
|
||||||
|
_eventSend(self, nil, self.Side, 'attack_no_targets', { unit_name = spawned:getName(), player = _playerNameFromGroup(group), rng = v, rng_u = u })
|
||||||
|
end
|
||||||
|
end
|
||||||
else
|
else
|
||||||
_eventSend(self, group, nil, 'troops_deploy_failed', { reason = 'DCS group spawn error' })
|
_eventSend(self, group, nil, 'troops_deploy_failed', { reason = 'DCS group spawn error' })
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user