mirror of
https://github.com/iTracerFacer/DCS_MissionDev.git
synced 2025-12-03 04:14:46 +00:00
“repair/augment” crates and a build hook so when you drop a repair crate near an existing SAM site, it respawns repaired and with +N launchers, consuming the crate(s).
This commit is contained in:
parent
e03f5af35b
commit
cdca9733d2
@ -93,7 +93,7 @@ CTLD.HoverCoachConfig = {
|
||||
CTLD.Messages = {
|
||||
-- Crates
|
||||
crate_spawn_requested = "Request received—spawning {type} crate at {zone}.",
|
||||
pickup_zone_required = "Move within {zone_dist} {zone_dist_u} of a Supply Zone to request crates.",
|
||||
pickup_zone_required = "Move within {zone_dist} {zone_dist_u} of a Supply Zone to request crates. Bearing {zone_brg}° to nearest zone.",
|
||||
no_pickup_zones = "No Pickup Zones are configured for this coalition. Ask the mission maker to add supply zones or disable the pickup zone requirement.",
|
||||
crate_re_marked = "Re-marking crate {id} with {mark}.",
|
||||
crate_expired = "Crate {id} expired and was removed.",
|
||||
@ -121,7 +121,7 @@ CTLD.Messages = {
|
||||
troops_unloaded_coalition = "{player} deployed {count} troops.",
|
||||
no_troops = "No troops onboard.",
|
||||
troops_deploy_failed = "Deploy failed: {reason}.",
|
||||
troop_pickup_zone_required = "Move inside a Supply Zone to load troops. Nearest zone is {zone_dist} {zone_dist_u} away.",
|
||||
troop_pickup_zone_required = "Move inside a Supply Zone to load troops. Nearest zone is {zone_dist}, {zone_dist_u} away bearing {zone_brg}°.",
|
||||
|
||||
-- Coach & nav
|
||||
vectors_to_crate = "Nearest crate {id}: bearing {brg}°, range {rng} {rng_u}.",
|
||||
@ -236,8 +236,8 @@ CTLD.Config = {
|
||||
Inventory = {
|
||||
Enabled = true, -- master switch for per-location stock control
|
||||
FOBStockFactor = 0.25, -- starting stock at newly built FOBs relative to pickup-zone initialStock
|
||||
ShowStockInMenu = true, -- if true, append simple stock hints to menu labels (per current nearest zone)
|
||||
HideZeroStockMenu = true, -- if true, add a filtered submenu that only shows items in stock at the nearest Pickup Zone
|
||||
ShowStockInMenu = true, -- if true, append simple stock hints to menu labels (per current nearest zone)
|
||||
HideZeroStockMenu = false, -- removed: previously created an "In Stock Here" submenu; now disabled by default
|
||||
},
|
||||
|
||||
-- Hover pickup configuration (Ciribob-style inspired)
|
||||
@ -261,6 +261,7 @@ CTLD.Config = {
|
||||
weight = 120, -- affects sling/limits only informationally (no physics in script)
|
||||
dcsCargoType = 'uh1h_cargo', -- static cargo type (can adjust per DCS version); user-tunable
|
||||
required = 2, -- number of crates to assemble
|
||||
canAttackMove = true, -- infantry can move to engage
|
||||
side = coalition.side.BLUE,
|
||||
category = Group.Category.GROUND,
|
||||
build = function(point, headingDeg)
|
||||
@ -279,6 +280,7 @@ CTLD.Config = {
|
||||
weight = 400,
|
||||
dcsCargoType = 'container_cargo',
|
||||
required = 3,
|
||||
canAttackMove = false, -- static emplacement; do not attempt attack-move
|
||||
side = coalition.side.BLUE,
|
||||
category = Group.Category.GROUND,
|
||||
build = function(point, headingDeg)
|
||||
@ -299,6 +301,7 @@ CTLD.Config = {
|
||||
dcsCargoType = 'container_cargo',
|
||||
required = 4,
|
||||
isFOB = true, -- mark as FOB recipe for zone restrictions/auto-build
|
||||
canAttackMove = false, -- logistics site; never attack-move
|
||||
side = coalition.side.BLUE,
|
||||
category = Group.Category.GROUND,
|
||||
build = function(point, headingDeg, spawnSide)
|
||||
@ -1278,13 +1281,17 @@ function CTLD:BuildGroupMenus(group)
|
||||
end
|
||||
end)
|
||||
end
|
||||
-- Troop transport submenu at top
|
||||
local troopsRoot = MENU_GROUP:New(group, 'Troop Transport', root)
|
||||
CMD('Load Troops', troopsRoot, function() self:LoadTroops(group) end)
|
||||
do
|
||||
local tr = (self.Config.AttackAI and self.Config.AttackAI.TroopSearchRadius) or 3000
|
||||
CMD('Deploy [Hold Position]', troopsRoot, function() self:UnloadTroops(group, { behavior = 'defend' }) end)
|
||||
CMD(string.format('Deploy [Attack (%dm)]', tr), troopsRoot, function() self:UnloadTroops(group, { behavior = 'attack' }) end)
|
||||
end
|
||||
-- Request crate submenu per catalog entry
|
||||
local reqRoot = MENU_GROUP:New(group, 'Request Crate', root)
|
||||
-- Optional filtered submenu that only shows items in stock at the nearest Pickup Zone
|
||||
local reqInStockRoot
|
||||
if (self.Config.Inventory and self.Config.Inventory.Enabled and self.Config.Inventory.HideZeroStockMenu) then
|
||||
reqInStockRoot = MENU_GROUP:New(group, 'Request Crate (In Stock Here)', root)
|
||||
end
|
||||
-- Removed: previously created a filtered "Request Crate (In Stock Here)" submenu
|
||||
-- Optional: parallel Recipe Info submenu to display detailed requirements
|
||||
local infoRoot = MENU_GROUP:New(group, 'Recipe Info', root)
|
||||
if self.Config.UseCategorySubmenus then
|
||||
@ -1330,30 +1337,19 @@ function CTLD:BuildGroupMenus(group)
|
||||
end
|
||||
end
|
||||
|
||||
-- Build or refresh the filtered "In Stock Here" submenu
|
||||
if reqInStockRoot then
|
||||
self:_BuildOrRefreshInStockMenu(group, reqInStockRoot)
|
||||
end
|
||||
-- Troops
|
||||
CMD('Load Troops', root, function() self:LoadTroops(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
|
||||
-- Removed: filtered "In Stock Here" submenu
|
||||
-- (Troop Transport submenu created at top)
|
||||
|
||||
-- Build
|
||||
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
|
||||
-- Build (Advanced): per-item attack/defend options
|
||||
local buildAdvRoot = MENU_GROUP:New(group, 'Build (Advanced)', root)
|
||||
MENU_GROUP_COMMAND:New(group, 'Refresh Buildable List', buildAdvRoot, function()
|
||||
self:_BuildOrRefreshBuildAdvancedMenu(group, buildAdvRoot)
|
||||
MESSAGE:New('Buildable list refreshed.', 6):ToGroup(group)
|
||||
end)
|
||||
-- Initial populate
|
||||
self:_BuildOrRefreshBuildAdvancedMenu(group, buildAdvRoot)
|
||||
|
||||
-- Crate management (loaded crates)
|
||||
CMD('Drop One Loaded Crate', root, function() self:DropLoadedCrates(group, 1) end)
|
||||
@ -1423,6 +1419,21 @@ function CTLD:BuildGroupMenus(group)
|
||||
end
|
||||
zone, dist = _nearestZonePoint(unit, list)
|
||||
if not zone then
|
||||
-- Fallback: try any configured pickup zone even if inactive to provide vectors
|
||||
local allDefs = self.Config and self.Config.Zones and self.Config.Zones.PickupZones or {}
|
||||
if allDefs and #allDefs > 0 then
|
||||
local fbZone, fbDist = _nearestZonePoint(unit, allDefs)
|
||||
if fbZone then
|
||||
local up = unit:GetPointVec3(); local zp = fbZone:GetPointVec3()
|
||||
local from = { x = up.x, z = up.z }
|
||||
local to = { x = zp.x, z = zp.z }
|
||||
local brg = _bearingDeg(from, to)
|
||||
local isMetric = _getPlayerIsMetric(unit)
|
||||
local rngV, rngU = _fmtRange(fbDist or 0, isMetric)
|
||||
_eventSend(self, group, nil, 'vectors_to_pickup_zone', { zone = fbZone:GetName(), brg = brg, rng = rngV, rng_u = rngU })
|
||||
return
|
||||
end
|
||||
end
|
||||
_eventSend(self, group, nil, 'no_pickup_zones', {})
|
||||
return
|
||||
end
|
||||
@ -1462,13 +1473,7 @@ function CTLD:BuildGroupMenus(group)
|
||||
|
||||
-- Admin/Help (nested under CTLD group menu when using group menus)
|
||||
local admin = MENU_GROUP:New(group, 'Admin/Help', root)
|
||||
-- When filtered "In Stock" menu is enabled, add a quick refresh command under Admin for convenience
|
||||
if (self.Config.Inventory and self.Config.Inventory.Enabled and self.Config.Inventory.HideZeroStockMenu) then
|
||||
MENU_GROUP_COMMAND:New(group, 'Refresh "In Stock" Menu Now', admin, function()
|
||||
self:_BuildOrRefreshInStockMenu(group)
|
||||
MESSAGE:New('In-Stock menu refreshed for nearest supply zone.', 6):ToGroup(group)
|
||||
end)
|
||||
end
|
||||
-- Removed: 'In Stock' quick refresh admin command
|
||||
CMD('Enable CTLD Debug Logging', admin, function()
|
||||
self.Config.Debug = true
|
||||
env.info(string.format('[Moose_CTLD][%s] Debug ENABLED via Admin menu', tostring(self.Side)))
|
||||
@ -1527,7 +1532,23 @@ function CTLD:_BuildOrRefreshInStockMenu(group, rootMenu)
|
||||
local zone, dist = self:_nearestActivePickupZone(unit)
|
||||
if not zone then
|
||||
MENU_GROUP_COMMAND:New(group, 'No active supply zone nearby', inRoot, function()
|
||||
-- Inform and also provide vectors to nearest configured zone if any
|
||||
_eventSend(self, group, nil, 'no_pickup_zones', {})
|
||||
-- Fallback: try any configured pickup zone (ignoring active state) for helpful vectors
|
||||
local list = self.Config and self.Config.Zones and self.Config.Zones.PickupZones or {}
|
||||
if list and #list > 0 then
|
||||
local unit = group:GetUnit(1)
|
||||
if unit and unit:IsAlive() then
|
||||
local fallbackZone, fallbackDist = _nearestZonePoint(unit, list)
|
||||
if fallbackZone then
|
||||
local up = unit:GetPointVec3(); local zp = fallbackZone:GetPointVec3()
|
||||
local brg = _bearingDeg({x=up.x,z=up.z}, {x=zp.x,z=zp.z})
|
||||
local isMetric = _getPlayerIsMetric(unit)
|
||||
local rngV, rngU = _fmtRange(fallbackDist or 0, isMetric)
|
||||
_eventSend(self, group, nil, 'vectors_to_pickup_zone', { zone = fallbackZone:GetName(), brg = brg, rng = rngV, rng_u = rngU })
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
-- Still add a refresh item
|
||||
MENU_GROUP_COMMAND:New(group, 'Refresh In-Stock List', inRoot, function() self:_BuildOrRefreshInStockMenu(group) end)
|
||||
@ -1539,7 +1560,9 @@ function CTLD:_BuildOrRefreshInStockMenu(group, rootMenu)
|
||||
MENU_GROUP_COMMAND:New(group, string.format('Nearest zone %s is beyond limit (%.0f m).', zname, dist or 0), inRoot, function()
|
||||
local isMetric = _getPlayerIsMetric(unit)
|
||||
local v, u = _fmtRange(math.max(0, (dist or 0) - maxd), isMetric)
|
||||
_eventSend(self, group, nil, 'pickup_zone_required', { zone_dist = v, zone_dist_u = u })
|
||||
local up = unit:GetPointVec3(); local zp = zone:GetPointVec3()
|
||||
local brg = _bearingDeg({x=up.x,z=up.z}, {x=zp.x,z=zp.z})
|
||||
_eventSend(self, group, nil, 'pickup_zone_required', { zone_dist = v, zone_dist_u = u, zone_brg = brg })
|
||||
end)
|
||||
MENU_GROUP_COMMAND:New(group, 'Refresh In-Stock List', inRoot, function() self:_BuildOrRefreshInStockMenu(group) end)
|
||||
return
|
||||
@ -1594,6 +1617,395 @@ function CTLD:_BuildOrRefreshInStockMenu(group, rootMenu)
|
||||
end
|
||||
end
|
||||
|
||||
-- Create or refresh the dynamic Build (Advanced) menu for a group.
|
||||
function CTLD:_BuildOrRefreshBuildAdvancedMenu(group, rootMenu)
|
||||
if not group or not group:IsAlive() then return end
|
||||
-- Clear previous dynamic children if any by recreating the submenu root when rootMenu passed
|
||||
-- We'll remove and recreate inner items by making a temporary child root
|
||||
local gname = group:GetName()
|
||||
-- Remove existing dynamic children by creating a fresh inner menu
|
||||
local dynRoot = MENU_GROUP:New(group, 'Buildable Near You', rootMenu)
|
||||
|
||||
local unit = group:GetUnit(1)
|
||||
if not unit or not unit:IsAlive() then return end
|
||||
local p = unit:GetPointVec3()
|
||||
local here = { x = p.x, z = p.z }
|
||||
local hdg = unit:GetHeading() or 0
|
||||
local buildOffset = math.max(0, tonumber(self.Config.BuildSpawnOffset or 0) or 0)
|
||||
local spawnAt = (buildOffset > 0) and { x = here.x + math.sin(hdg) * buildOffset, z = here.z + math.cos(hdg) * buildOffset } or { x = here.x, z = here.z }
|
||||
local radius = self.Config.BuildRadius or 60
|
||||
local nearby = self:GetNearbyCrates(here, radius)
|
||||
local filtered = {}
|
||||
for _,c in ipairs(nearby) do if c.meta.side == self.Side then table.insert(filtered, c) end end
|
||||
nearby = filtered
|
||||
-- Count by key
|
||||
local counts = {}
|
||||
for _,c in ipairs(nearby) do counts[c.meta.key] = (counts[c.meta.key] or 0) + 1 end
|
||||
-- Include carried crates if allowed
|
||||
if self.Config.BuildRequiresGroundCrates ~= true then
|
||||
local gname = group:GetName()
|
||||
local carried = CTLD._loadedCrates[gname]
|
||||
if carried and carried.byKey then
|
||||
for k,v in pairs(carried.byKey) do counts[k] = (counts[k] or 0) + v end
|
||||
end
|
||||
end
|
||||
-- FOB restriction context
|
||||
local insideFOBZone = select(1, self:IsPointInFOBZones(here))
|
||||
|
||||
-- Build list of buildable recipes
|
||||
local items = {}
|
||||
for key,cat in pairs(self.Config.CrateCatalog or {}) do
|
||||
local sideOk = (not cat.side) or cat.side == self.Side
|
||||
if sideOk and cat and cat.build then
|
||||
local ok = false
|
||||
if type(cat.requires) == 'table' then
|
||||
ok = true
|
||||
for reqKey,qty in pairs(cat.requires) do if (counts[reqKey] or 0) < (qty or 0) then ok = false; break end end
|
||||
else
|
||||
ok = ((counts[key] or 0) >= (cat.required or 1))
|
||||
end
|
||||
if ok then
|
||||
if not (cat.isFOB and self.Config.RestrictFOBToZones and not insideFOBZone) then
|
||||
table.insert(items, { key = key, def = cat })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #items == 0 then
|
||||
MENU_GROUP_COMMAND:New(group, 'None buildable here. Drop required crates close to your aircraft.', dynRoot, function()
|
||||
MESSAGE:New('No buildable items with nearby crates. Use Recipe Info to check requirements.', 10):ToGroup(group)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- Stable ordering by label
|
||||
table.sort(items, function(a,b)
|
||||
local la = (a.def and (a.def.menu or a.def.description)) or a.key
|
||||
local lb = (b.def and (b.def.menu or b.def.description)) or b.key
|
||||
return tostring(la) < tostring(lb)
|
||||
end)
|
||||
|
||||
-- Create per-item submenus
|
||||
local function CMD(title, parent, cb)
|
||||
return MENU_GROUP_COMMAND:New(group, title, parent, function()
|
||||
local ok, err = pcall(cb)
|
||||
if not ok then env.info('[Moose_CTLD] BuildAdv menu error: '..tostring(err)); MESSAGE:New('CTLD menu error: '..tostring(err), 8):ToGroup(group) end
|
||||
end)
|
||||
end
|
||||
|
||||
for _,it in ipairs(items) do
|
||||
local label = (it.def and (it.def.menu or it.def.description)) or it.key
|
||||
local perItem = MENU_GROUP:New(group, label, dynRoot)
|
||||
-- Hold Position
|
||||
CMD('Build [Hold Position]', perItem, function()
|
||||
self:BuildSpecificAtGroup(group, it.key, { behavior = 'defend' })
|
||||
end)
|
||||
-- Attack variant (render even if canAttackMove=false; we message accordingly)
|
||||
local vr = (self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000
|
||||
CMD(string.format('Build [Attack (%dm)]', vr), perItem, function()
|
||||
if it.def and it.def.canAttackMove == false then
|
||||
MESSAGE:New('This unit is static or not suited to move; it will hold position.', 8):ToGroup(group)
|
||||
self:BuildSpecificAtGroup(group, it.key, { behavior = 'defend' })
|
||||
else
|
||||
self:BuildSpecificAtGroup(group, it.key, { behavior = 'attack' })
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- Build a specific recipe at the group position if crates permit; supports behavior opts
|
||||
function CTLD:BuildSpecificAtGroup(group, recipeKey, opts)
|
||||
local unit = group:GetUnit(1)
|
||||
if not unit or not unit:IsAlive() then return end
|
||||
-- Reuse Build cooldown/confirm logic
|
||||
local now = timer.getTime()
|
||||
local gname = group:GetName()
|
||||
if self.Config.BuildCooldownEnabled then
|
||||
local last = CTLD._buildCooldown[gname]
|
||||
if last and (now - last) < (self.Config.BuildCooldownSeconds or 60) then
|
||||
local rem = math.max(0, math.ceil((self.Config.BuildCooldownSeconds or 60) - (now - last)))
|
||||
_msgGroup(group, string.format('Build on cooldown. Try again in %ds.', rem))
|
||||
return
|
||||
end
|
||||
end
|
||||
if self.Config.BuildConfirmEnabled then
|
||||
local first = CTLD._buildConfirm[gname]
|
||||
local win = self.Config.BuildConfirmWindowSeconds or 10
|
||||
if not first or (now - first) > win then
|
||||
CTLD._buildConfirm[gname] = now
|
||||
_msgGroup(group, string.format('Confirm build: select again within %ds to proceed.', win))
|
||||
return
|
||||
else
|
||||
CTLD._buildConfirm[gname] = nil
|
||||
end
|
||||
end
|
||||
|
||||
local def = self.Config.CrateCatalog[recipeKey]
|
||||
if not def or not def.build then _msgGroup(group, 'Unknown or unbuildable recipe: '..tostring(recipeKey)); return end
|
||||
|
||||
local p = unit:GetPointVec3()
|
||||
local here = { x = p.x, z = p.z }
|
||||
local hdg = unit:GetHeading() or 0
|
||||
local buildOffset = math.max(0, tonumber(self.Config.BuildSpawnOffset or 0) or 0)
|
||||
local spawnAt = (buildOffset > 0) and { x = here.x + math.sin(hdg) * buildOffset, z = here.z + math.cos(hdg) * buildOffset } or { x = here.x, z = here.z }
|
||||
local radius = self.Config.BuildRadius or 60
|
||||
local nearby = self:GetNearbyCrates(here, radius)
|
||||
local filtered = {}
|
||||
for _,c in ipairs(nearby) do if c.meta.side == self.Side then table.insert(filtered, c) end end
|
||||
nearby = filtered
|
||||
if #nearby == 0 and self.Config.BuildRequiresGroundCrates ~= true then
|
||||
-- still can build using carried crates
|
||||
elseif #nearby == 0 then
|
||||
_eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey })
|
||||
return
|
||||
end
|
||||
|
||||
-- Count by key
|
||||
local counts = {}
|
||||
for _,c in ipairs(nearby) do counts[c.meta.key] = (counts[c.meta.key] or 0) + 1 end
|
||||
-- Include carried crates
|
||||
local carried = CTLD._loadedCrates[gname]
|
||||
if self.Config.BuildRequiresGroundCrates ~= true then
|
||||
if carried and carried.byKey then for k,v in pairs(carried.byKey) do counts[k] = (counts[k] or 0) + v end end
|
||||
end
|
||||
|
||||
-- Helper to consume crates of a given key/qty (prefers carried when allowed)
|
||||
local function consumeCrates(key, qty)
|
||||
local removed = 0
|
||||
if self.Config.BuildRequiresGroundCrates ~= true then
|
||||
if carried and carried.byKey and (carried.byKey[key] or 0) > 0 then
|
||||
local take = math.min(qty, carried.byKey[key])
|
||||
carried.byKey[key] = carried.byKey[key] - take
|
||||
if carried.byKey[key] <= 0 then carried.byKey[key] = nil end
|
||||
carried.total = math.max(0, (carried.total or 0) - take)
|
||||
removed = removed + take
|
||||
end
|
||||
end
|
||||
for _,c in ipairs(nearby) do
|
||||
if removed >= qty then break end
|
||||
if c.meta.key == key then
|
||||
local obj = StaticObject.getByName(c.name)
|
||||
if obj then obj:destroy() end
|
||||
CTLD._crates[c.name] = nil
|
||||
removed = removed + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- FOB restriction check
|
||||
if def.isFOB and self.Config.RestrictFOBToZones then
|
||||
local inside = select(1, self:IsPointInFOBZones(here))
|
||||
if not inside then _eventSend(self, group, nil, 'fob_restricted', {}); return end
|
||||
end
|
||||
|
||||
-- Special-case: SAM Site Repair/Augment entries (isRepair)
|
||||
if def.isRepair == true or tostring(recipeKey):find('_REPAIR', 1, true) then
|
||||
-- Map recipe key family to a template definition
|
||||
local function identifyTemplate(key)
|
||||
if key:find('HAWK', 1, true) then
|
||||
return {
|
||||
name='HAWK', side=def.side or self.Side,
|
||||
baseUnits={ {type='Hawk sr', dx=12, dz=8}, {type='Hawk tr', dx=-12, dz=8}, {type='Hawk pcp', dx=18, dz=12}, {type='Hawk cwar', dx=-18, dz=12} },
|
||||
launcherType='Hawk ln', launcherStart={dx=0, dz=0}, launcherStep={dx=6, dz=0}, maxLaunchers=6
|
||||
}
|
||||
elseif key:find('PATRIOT', 1, true) then
|
||||
return {
|
||||
name='PATRIOT', side=def.side or self.Side,
|
||||
baseUnits={ {type='Patriot str', dx=14, dz=10}, {type='Patriot ECS', dx=-14, dz=10} },
|
||||
launcherType='Patriot ln', launcherStart={dx=0, dz=0}, launcherStep={dx=8, dz=0}, maxLaunchers=6
|
||||
}
|
||||
elseif key:find('KUB', 1, true) then
|
||||
return {
|
||||
name='KUB', side=def.side or self.Side,
|
||||
baseUnits={ {type='Kub 1S91 str', dx=12, dz=8} },
|
||||
launcherType='Kub 2P25 ln', launcherStart={dx=0, dz=0}, launcherStep={dx=6, dz=0}, maxLaunchers=3
|
||||
}
|
||||
elseif key:find('BUK', 1, true) then
|
||||
return {
|
||||
name='BUK', side=def.side or self.Side,
|
||||
baseUnits={ {type='SA-11 Buk SR 9S18M1', dx=12, dz=8}, {type='SA-11 Buk CC 9S470M1', dx=-12, dz=8} },
|
||||
launcherType='SA-11 Buk LN 9A310M1', launcherStart={dx=0, dz=0}, launcherStep={dx=6, dz=0}, maxLaunchers=6
|
||||
}
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local tpl = identifyTemplate(tostring(recipeKey))
|
||||
if not tpl then _msgGroup(group, 'No matching SAM site type for repair: '..tostring(recipeKey)); return end
|
||||
|
||||
-- Determine how many repair crates to apply
|
||||
local cratesAvail = counts[recipeKey] or 0
|
||||
if cratesAvail <= 0 then _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }); return end
|
||||
|
||||
-- Find nearest existing site group that matches template
|
||||
local function vec2(u)
|
||||
local p = u:getPoint(); return { x = p.x, z = p.z }
|
||||
end
|
||||
local function dist2(a,b)
|
||||
local dx, dz = a.x-b.x, a.z-b.z; return math.sqrt(dx*dx+dz*dz)
|
||||
end
|
||||
local searchR = math.max(250, (self.Config.BuildRadius or 60) * 10)
|
||||
local groups = coalition.getGroups(tpl.side, Group.Category.GROUND) or {}
|
||||
local here2 = { x = here.x, z = here.z }
|
||||
local bestG, bestD, bestInfo = nil, 1e9, nil
|
||||
for _,g in ipairs(groups) do
|
||||
if g and g:isExist() then
|
||||
local units = g:getUnits() or {}
|
||||
if #units > 0 then
|
||||
-- Compute center and count types
|
||||
local cx, cz = 0, 0
|
||||
local byType = {}
|
||||
for _,u in ipairs(units) do
|
||||
local pt = u:getPoint(); cx = cx + pt.x; cz = cz + pt.z
|
||||
local tname = u:getTypeName() or ''
|
||||
byType[tname] = (byType[tname] or 0) + 1
|
||||
end
|
||||
cx = cx / #units; cz = cz / #units
|
||||
local d = dist2(here2, { x = cx, z = cz })
|
||||
if d <= searchR then
|
||||
-- Check presence of base units (at least 1 each)
|
||||
local ok = true
|
||||
for _,u in ipairs(tpl.baseUnits) do if (byType[u.type] or 0) < 1 then ok = false; break end end
|
||||
-- Require at least 1 launcher or allow 0 (initial repair to full base)? we'll allow 0 too.
|
||||
if ok then
|
||||
if d < bestD then
|
||||
bestG, bestD = g, d
|
||||
bestInfo = { byType = byType, center = { x = cx, z = cz }, headingDeg = function()
|
||||
local h = 0; local leader = units[1]; if leader and leader.isExist and leader:isExist() then h = math.deg(leader:getHeading() or 0) end; return h
|
||||
end }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not bestG then
|
||||
_msgGroup(group, 'No matching SAM site found nearby to repair/augment.')
|
||||
return
|
||||
end
|
||||
|
||||
-- Current launchers in site
|
||||
local curLaunchers = (bestInfo.byType[tpl.launcherType] or 0)
|
||||
local maxL = tpl.maxLaunchers or (curLaunchers + cratesAvail)
|
||||
local canAdd = math.max(0, (maxL - curLaunchers))
|
||||
if canAdd <= 0 then
|
||||
_msgGroup(group, 'SAM site is already at max launchers.')
|
||||
return
|
||||
end
|
||||
local addNum = math.min(cratesAvail, canAdd)
|
||||
|
||||
-- Build new group composition: base units + (curLaunchers + addNum) launchers
|
||||
local function buildSite(point, headingDeg, side, launcherCount)
|
||||
local hdg = math.rad(headingDeg or 0)
|
||||
local function off(dx, dz)
|
||||
-- rotate offsets by heading
|
||||
local s, c = math.sin(hdg), math.cos(hdg)
|
||||
local rx = dx * c + dz * s
|
||||
local rz = -dx * s + dz * c
|
||||
return { x = point.x + rx, z = point.z + rz }
|
||||
end
|
||||
local units = {}
|
||||
-- Place launchers in a row starting at launcherStart and stepping by launcherStep
|
||||
for i=0, (launcherCount-1) do
|
||||
local dx = (tpl.launcherStart.dx or 0) + (tpl.launcherStep.dx or 0) * i
|
||||
local dz = (tpl.launcherStart.dz or 0) + (tpl.launcherStep.dz or 0) * i
|
||||
local p = off(dx, dz)
|
||||
table.insert(units, { type = tpl.launcherType, name = string.format('CTLD-%s-%d', tpl.launcherType, math.random(100000,999999)), x = p.x, y = p.z, heading = hdg })
|
||||
end
|
||||
-- Place base units at their template offsets
|
||||
for _,u in ipairs(tpl.baseUnits) do
|
||||
local p = off(u.dx or 0, u.dz or 0)
|
||||
table.insert(units, { type = u.type, name = string.format('CTLD-%s-%d', u.type, math.random(100000,999999)), x = p.x, y = p.z, heading = hdg })
|
||||
end
|
||||
return { visible=false, lateActivation=false, tasks={}, task='Ground Nothing', route={}, units=units, name=string.format('CTLD_SITE_%d', math.random(100000,999999)) }
|
||||
end
|
||||
|
||||
_eventSend(self, group, nil, 'build_started', { build = def.description or recipeKey })
|
||||
-- Destroy old group, spawn new one
|
||||
local oldName = bestG:getName()
|
||||
local newLauncherCount = curLaunchers + addNum
|
||||
local center = bestInfo.center
|
||||
local headingDeg = bestInfo.headingDeg()
|
||||
if Group.getByName(oldName) then pcall(function() Group.getByName(oldName):destroy() end) end
|
||||
local gdata = buildSite({ x = center.x, z = center.z }, headingDeg, tpl.side, newLauncherCount)
|
||||
local newG = _coalitionAddGroup(tpl.side, Group.Category.GROUND, gdata)
|
||||
if not newG then _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }); return end
|
||||
-- Consume used repair crates
|
||||
consumeCrates(recipeKey, addNum)
|
||||
_eventSend(self, nil, self.Side, 'build_success_coalition', { build = (def.description or recipeKey), player = _playerNameFromGroup(group) })
|
||||
if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end
|
||||
return
|
||||
end
|
||||
|
||||
-- Verify counts and build
|
||||
if type(def.requires) == 'table' then
|
||||
for reqKey,qty in pairs(def.requires) do if (counts[reqKey] or 0) < (qty or 0) then _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }); return end end
|
||||
local gdata = def.build({ x = spawnAt.x, z = spawnAt.z }, math.deg(hdg), def.side or self.Side)
|
||||
_eventSend(self, group, nil, 'build_started', { build = def.description or recipeKey })
|
||||
local g = _coalitionAddGroup(def.side or self.Side, def.category or Group.Category.GROUND, gdata)
|
||||
if not g then _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }); return end
|
||||
for reqKey,qty in pairs(def.requires) do consumeCrates(reqKey, qty or 0) end
|
||||
_eventSend(self, nil, self.Side, 'build_success_coalition', { build = def.description or recipeKey, player = _playerNameFromGroup(group) })
|
||||
if def.isFOB then pcall(function() self:_CreateFOBPickupZone({ x = spawnAt.x, z = spawnAt.z }, def, hdg) end) end
|
||||
-- behavior
|
||||
local behavior = opts and opts.behavior or nil
|
||||
if behavior == 'attack' and (def.canAttackMove ~= false) 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(spawnAt, t.point)
|
||||
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(spawnAt, t.point)
|
||||
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
|
||||
elseif behavior == 'attack' and def.canAttackMove == false then
|
||||
MESSAGE:New('This unit is static or not suited to move; it will hold position.', 8):ToGroup(group)
|
||||
end
|
||||
if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end
|
||||
return
|
||||
else
|
||||
-- single-key
|
||||
local need = def.required or 1
|
||||
if (counts[recipeKey] or 0) < need then _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }); return end
|
||||
local gdata = def.build({ x = spawnAt.x, z = spawnAt.z }, math.deg(hdg), def.side or self.Side)
|
||||
_eventSend(self, group, nil, 'build_started', { build = def.description or recipeKey })
|
||||
local g = _coalitionAddGroup(def.side or self.Side, def.category or Group.Category.GROUND, gdata)
|
||||
if not g then _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }); return end
|
||||
consumeCrates(recipeKey, need)
|
||||
_eventSend(self, nil, self.Side, 'build_success_coalition', { build = def.description or recipeKey, player = _playerNameFromGroup(group) })
|
||||
-- behavior
|
||||
local behavior = opts and opts.behavior or nil
|
||||
if behavior == 'attack' and (def.canAttackMove ~= false) 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(spawnAt, t.point)
|
||||
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(spawnAt, t.point)
|
||||
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
|
||||
elseif behavior == 'attack' and def.canAttackMove == false then
|
||||
MESSAGE:New('This unit is static or not suited to move; it will hold position.', 8):ToGroup(group)
|
||||
end
|
||||
if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
function CTLD:BuildCoalitionMenus(root)
|
||||
-- Optional: implement coalition-level crate spawns at pickup zones
|
||||
for key,_ in pairs(self.Config.CrateCatalog) do
|
||||
@ -1830,7 +2242,12 @@ function CTLD:RequestCrateForGroup(group, crateKey)
|
||||
if self.Config.RequirePickupZoneForCrateRequest then
|
||||
local isMetric = _getPlayerIsMetric(unit)
|
||||
local v, u = _fmtRange(math.max(0, dist - maxd), isMetric)
|
||||
_eventSend(self, group, nil, 'pickup_zone_required', { zone_dist = v, zone_dist_u = u })
|
||||
local brg = 0
|
||||
if zone then
|
||||
local up = unit:GetPointVec3(); local zp = zone:GetPointVec3()
|
||||
brg = _bearingDeg({x=up.x,z=up.z}, {x=zp.x,z=zp.z})
|
||||
end
|
||||
_eventSend(self, group, nil, 'pickup_zone_required', { zone_dist = v, zone_dist_u = u, zone_brg = brg })
|
||||
return
|
||||
else
|
||||
-- fallback: spawn near aircraft current position (safe offset)
|
||||
@ -2061,7 +2478,7 @@ function CTLD:BuildAtGroup(group, opts)
|
||||
return
|
||||
end
|
||||
end
|
||||
::continue_composite::
|
||||
-- continue_composite (Lua 5.1 compatible: no labels)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -2107,7 +2524,7 @@ function CTLD:BuildAtGroup(group, opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
::continue_single::
|
||||
-- continue_single (Lua 5.1 compatible: no labels)
|
||||
end
|
||||
|
||||
if fobBlocked then
|
||||
@ -2451,7 +2868,14 @@ function CTLD:LoadTroops(group, opts)
|
||||
local rZone = (zone and (self:_getZoneRadius(zone) or 0)) or 0
|
||||
local delta = math.max(0, (dist or 0) - rZone)
|
||||
local v, u = _fmtRange(delta, isMetric)
|
||||
_eventSend(self, group, nil, 'troop_pickup_zone_required', { zone_dist = v, zone_dist_u = u })
|
||||
-- Bearing from player to zone center
|
||||
local up = unit:GetPointVec3()
|
||||
local zp = zone and zone:GetPointVec3() or nil
|
||||
local brg = 0
|
||||
if zp then
|
||||
brg = _bearingDeg({ x = up.x, z = up.z }, { x = zp.x, z = zp.z })
|
||||
end
|
||||
_eventSend(self, group, nil, 'troop_pickup_zone_required', { zone_dist = v, zone_dist_u = u, zone_brg = brg })
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
Binary file not shown.
@ -123,6 +123,12 @@ cat['BLUE_HAWK_CWAR'] = { menuCategory='SAM mid range', menu='HAWK CWAR'
|
||||
cat['BLUE_HAWK_SITE'] = { menuCategory='SAM mid range', menu='HAWK - All crates', description='HAWK Site', dcsCargoType='container_cargo', requires={ BLUE_HAWK_LN=1, BLUE_HAWK_SR=1, BLUE_HAWK_TR=1, BLUE_HAWK_PCP=1, BLUE_HAWK_CWAR=1 }, initialStock=0, side=BLUE, category=Group.Category.GROUND,
|
||||
build=multiUnits({ {type='Hawk ln'}, {type='Hawk sr', dx=12, dz=8}, {type='Hawk tr', dx=-12, dz=8}, {type='Hawk pcp', dx=18, dz=12}, {type='Hawk cwar', dx=-18, dz=12} }) }
|
||||
|
||||
-- HAWK site repair/augment (adds +1 launcher, repairs site by respawn)
|
||||
cat['BLUE_HAWK_REPAIR'] = { menuCategory='SAM mid range', menu='HAWK Repair/Launcher +1', description='HAWK Repair (adds launcher)', dcsCargoType='container_cargo', required=1, initialStock=8, side=BLUE, category=Group.Category.GROUND, isRepair=true, build=function(point, headingDeg)
|
||||
-- Build is handled specially in CTLD:BuildSpecificAtGroup for isRepair entries
|
||||
return singleUnit('Ural-375')(point, headingDeg)
|
||||
end }
|
||||
|
||||
cat['BLUE_NASAMS_LN'] = { menuCategory='SAM mid range', menu='NASAMS Launcher 120C', description='NASAMS LN 120C', dcsCargoType='container_cargo', required=1, initialStock=8, side=BLUE, category=Group.Category.GROUND, build=singleUnit('NASAMS_LN_C') }
|
||||
cat['BLUE_NASAMS_RADAR'] = { menuCategory='SAM mid range', menu='NASAMS Search/Track Radar', description='NASAMS Radar', dcsCargoType='container_cargo', required=1, initialStock=8, side=BLUE, category=Group.Category.GROUND, build=singleUnit('NASAMS_Radar_MPQ64F1') }
|
||||
cat['BLUE_NASAMS_CP'] = { menuCategory='SAM mid range', menu='NASAMS Command Post', description='NASAMS CP', dcsCargoType='container_cargo', required=1, initialStock=8, side=BLUE, category=Group.Category.GROUND, build=singleUnit('NASAMS_Command_Post') }
|
||||
@ -135,6 +141,11 @@ cat['RED_KUB_RADAR'] = { menuCategory='SAM mid range', menu='KUB Radar'
|
||||
cat['RED_KUB_SITE'] = { menuCategory='SAM mid range', menu='KUB - All crates', description='KUB Site', dcsCargoType='container_cargo', requires={ RED_KUB_LN=1, RED_KUB_RADAR=1 }, initialStock=0, side=RED, category=Group.Category.GROUND,
|
||||
build=multiUnits({ {type='Kub 2P25 ln'}, {type='Kub 1S91 str', dx=12, dz=8} }) }
|
||||
|
||||
-- KUB site repair/augment (adds +1 launcher, repairs site by respawn)
|
||||
cat['RED_KUB_REPAIR'] = { menuCategory='SAM mid range', menu='KUB Repair/Launcher +1', description='KUB Repair (adds launcher)', dcsCargoType='container_cargo', required=1, initialStock=8, side=RED, category=Group.Category.GROUND, isRepair=true, build=function(point, headingDeg)
|
||||
return singleUnit('Ural-375')(point, headingDeg)
|
||||
end }
|
||||
|
||||
-- SAM long range (BLUE) Patriot
|
||||
cat['BLUE_PATRIOT_LN'] = { menuCategory='SAM long range', menu='Patriot Launcher', description='Patriot Launcher', dcsCargoType='container_cargo', required=1, initialStock=6, side=BLUE, category=Group.Category.GROUND, build=singleUnit('Patriot ln') }
|
||||
cat['BLUE_PATRIOT_RADAR'] = { menuCategory='SAM long range', menu='Patriot Radar', description='Patriot Radar', dcsCargoType='container_cargo', required=1, initialStock=6, side=BLUE, category=Group.Category.GROUND, build=singleUnit('Patriot str') }
|
||||
@ -142,6 +153,11 @@ cat['BLUE_PATRIOT_ECS'] = { menuCategory='SAM long range', menu='Patriot E
|
||||
cat['BLUE_PATRIOT_SITE'] = { menuCategory='SAM long range', menu='Patriot - All crates', description='Patriot Site', dcsCargoType='container_cargo', requires={ BLUE_PATRIOT_LN=1, BLUE_PATRIOT_RADAR=1, BLUE_PATRIOT_ECS=1 }, initialStock=0, side=BLUE, category=Group.Category.GROUND,
|
||||
build=multiUnits({ {type='Patriot ln'}, {type='Patriot str', dx=14, dz=10}, {type='Patriot ECS', dx=-14, dz=10} }) }
|
||||
|
||||
-- Patriot site repair/augment (adds +1 launcher, repairs site by respawn)
|
||||
cat['BLUE_PATRIOT_REPAIR'] = { menuCategory='SAM long range', menu='Patriot Repair/Launcher +1', description='Patriot Repair (adds launcher)', dcsCargoType='container_cargo', required=1, initialStock=6, side=BLUE, category=Group.Category.GROUND, isRepair=true, build=function(point, headingDeg)
|
||||
return singleUnit('Ural-375')(point, headingDeg)
|
||||
end }
|
||||
|
||||
-- SAM long range (RED) BUK
|
||||
cat['RED_BUK_LN'] = { menuCategory='SAM long range', menu='BUK Launcher', description='BUK Launcher', dcsCargoType='container_cargo', required=1, initialStock=6, side=RED, category=Group.Category.GROUND, build=singleUnit('SA-11 Buk LN 9A310M1') }
|
||||
cat['RED_BUK_SR'] = { menuCategory='SAM long range', menu='BUK Search Radar', description='BUK Search Radar', dcsCargoType='container_cargo', required=1, initialStock=6, side=RED, category=Group.Category.GROUND, build=singleUnit('SA-11 Buk SR 9S18M1') }
|
||||
@ -149,6 +165,11 @@ cat['RED_BUK_CC'] = { menuCategory='SAM long range', menu='BUK CC Ra
|
||||
cat['RED_BUK_SITE'] = { menuCategory='SAM long range', menu='BUK - All crates', description='BUK Site', dcsCargoType='container_cargo', requires={ RED_BUK_LN=1, RED_BUK_SR=1, RED_BUK_CC=1 }, initialStock=0, side=RED, category=Group.Category.GROUND,
|
||||
build=multiUnits({ {type='SA-11 Buk LN 9A310M1'}, {type='SA-11 Buk SR 9S18M1', dx=12, dz=8}, {type='SA-11 Buk CC 9S470M1', dx=-12, dz=8} }) }
|
||||
|
||||
-- BUK site repair/augment (adds +1 launcher, repairs site by respawn)
|
||||
cat['RED_BUK_REPAIR'] = { menuCategory='SAM long range', menu='BUK Repair/Launcher +1', description='BUK Repair (adds launcher)', dcsCargoType='container_cargo', required=1, initialStock=6, side=RED, category=Group.Category.GROUND, isRepair=true, build=function(point, headingDeg)
|
||||
return singleUnit('Ural-375')(point, headingDeg)
|
||||
end }
|
||||
|
||||
-- Drones (JTAC)
|
||||
cat['BLUE_MQ9'] = { menuCategory='Drones', menu='MQ-9 Reaper - JTAC', description='MQ-9 JTAC', dcsCargoType='container_cargo', required=1, initialStock=3, side=BLUE, category=Group.Category.AIRPLANE, build=singleUnit('MQ-9 Reaper') }
|
||||
cat['RED_WINGLOONG'] = { menuCategory='Drones', menu='WingLoong-I - JTAC', description='WingLoong-I JTAC', dcsCargoType='container_cargo', required=1, initialStock=3, side=RED, category=Group.Category.AIRPLANE, build=singleUnit('WingLoong-I') }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user