Added AutoGroundLoading while in pickup/fob zone. Land near crates, and wait for process to complete. No menu needed.

This commit is contained in:
iTracerFacer 2025-11-15 09:57:28 -06:00
parent 4d74bb935e
commit c6ebc0cc11
3 changed files with 673 additions and 54 deletions

View File

@ -11,6 +11,9 @@
-- Inputs: Config table or defaults. No ME templates needed. Zones may be named ME trigger zones or provided via coordinates in config.
-- Outputs: F10 menus for helo/transport groups; crate spawning/building; troop load/unload; optional JTAC hookup (via FAC module);
-- Error modes: missing Moose -> abort; unknown crate key -> message; spawn blocked in enemy airbase; zone missing -> message.
--
-- Orignal Author of CTLD: Ciribob
-- Moose adaptation: Lathe, Copilot, F99th-TracerFacer
-- #region Config
@ -62,6 +65,14 @@ CTLD.Messages = {
troop_unload_altitude_too_high = "Too high for fast-rope deployment. Maximum: {max_agl} m AGL (current: {current_agl} m). Land or descend.",
troop_unload_altitude_too_low = "Too low for safe fast-rope. Minimum: {min_agl} m AGL (current: {current_agl} m). Climb or land.",
-- Ground auto-load
ground_load_started = "Ground loading: Hold position for {seconds}s to load {count} crate(s)...",
ground_load_progress = "Loading crates... {remaining}s remaining. Hold position.",
ground_load_complete = "Ground load complete! Loaded {count} crate(s).",
ground_load_aborted = "Ground load aborted: aircraft moved or lifted off.",
ground_load_no_zone = "Ground auto-load requires being inside a Pickup Zone. Nearest zone: {zone_dist} {zone_dist_u} at {zone_brg}°.",
ground_load_no_crates = "No crates within {radius}m to load.",
-- Coach & nav
vectors_to_crate = "Nearest crate {id}: bearing {brg}°, range {rng} {rng_u}.",
vectors_to_pickup_zone = "Nearest supply zone {zone}: bearing {brg}°, range {rng} {rng_u}.",
@ -159,6 +170,14 @@ CTLD.Config = {
LogLevel = 1, -- lowered from DEBUG (4) to INFO (2) for production performance
MessageDuration = 15, -- seconds for on-screen messages
-- Debug toggles for detailed crate proximity logging (useful when tuning hover coach / ground autoload)
DebugHoverCrates = true,
DebugHoverCratesInterval = 1.0, -- seconds between hover debug log bursts (per aircraft)
DebugHoverCratesStep = 25, -- log again when nearest crate distance changes by this many meters
DebugGroundCrates = true,
DebugGroundCratesInterval = 2.0, -- seconds between ground debug log bursts (per aircraft)
DebugGroundCratesStep = 10, -- log again when nearest crate distance changes by this many meters
-- === Menu & Catalog ===
UseGroupMenus = true, -- if true, F10 menus per player group; otherwise coalition-wide (leave this alone)
CreateMenuAtMissionStart = false, -- creates empty root menu at mission start to reserve F10 position (populated on player spawn)
@ -471,8 +490,8 @@ CTLD.HoverCoachConfig = {
arrivalDist = 1000, -- m: start guidance "You're close…"
closeDist = 100, -- m: reduce speed / set AGL guidance
precisionDist = 8, -- m: start precision hints
captureHoriz = 8, -- m: horizontal sweet spot radius
captureVert = 8, -- m: vertical sweet spot tolerance around AGL window
captureHoriz = 10, -- m: horizontal sweet spot radius
captureVert = 10, -- m: vertical sweet spot tolerance around AGL window
aglMin = 5, -- m: hover window min AGL
aglMax = 20, -- m: hover window max AGL
maxGS = 8/3.6, -- m/s: 8 km/h for precision, used for errors
@ -489,6 +508,21 @@ CTLD.HoverCoachConfig = {
},
}
-- =========================
-- Ground Auto-Load Configuration
-- =========================
-- Automatic crate loading while landed (for pilots who prefer not to hover)
CTLD.GroundAutoLoadConfig = {
Enabled = true, -- master switch for ground auto-load feature
LoadDelay = 25, -- seconds to hold position on ground before auto-loading
GroundContactAGL = 3.5, -- meters AGL considered "on the ground" (matches MEDEVAC)
MaxGroundSpeed = 2.0, -- m/s maximum ground speed during loading (~4 knots)
SearchRadius = 20, -- meters to search for nearby crates
AbortGrace = 2, -- seconds of movement/liftoff tolerated before aborting
RequirePickupZone = true, -- MUST be inside a pickup zone to auto-load (prevents drop/re-pickup loops)
AllowInFOBZones = true, -- also allow auto-load in FOB zones (once built)
}
-- =========================
-- MEDEVAC Configuration
-- =========================
@ -1823,6 +1857,72 @@ local function _logInfo(msg) _log(LOG_INFO, msg) end
local function _logVerbose(msg) _log(LOG_DEBUG, msg) end
local function _logDebug(msg) _log(LOG_DEBUG, msg) end
-- Emits tagged messages regardless of configured LogLevel (used by explicit debug toggles)
local function _logImmediate(tag, msg)
local text = string.format('[Moose_CTLD][%s] %s', tag or 'DEBUG', tostring(msg))
if env and env.info then
env.info(text)
else
print(text)
end
end
local function _debugCrateSight(kind, params)
if not params or not params.unit then return end
CTLD._debugSightState = CTLD._debugSightState or {}
local key = string.format('%s:%s', kind, params.unit)
local state = CTLD._debugSightState[key] or {}
local now = params.now or timer.getTime()
local interval = params.interval or 1.0
local step = params.step or 10.0
local name = params.name or 'none'
local distance = params.distance or math.huge
local crateCount = params.count or 0
local troopCount = params.troops or 0
local shouldLog = false
if not state.lastTime or interval <= 0 or (now - state.lastTime) >= interval then
shouldLog = true
end
if state.lastName ~= name then
shouldLog = true
end
if state.lastCount ~= crateCount or state.lastTroops ~= troopCount then
shouldLog = true
end
if distance ~= math.huge then
if not state.lastDist or math.abs(distance - state.lastDist) >= step then
shouldLog = true
end
elseif state.lastDist ~= math.huge then
shouldLog = true
end
if not shouldLog then return end
local distText = (distance ~= math.huge) and string.format('d=%.1f', distance) or 'd=n/a'
local summaryParts = { string.format('%d crate(s)', crateCount) }
if troopCount and troopCount > 0 then
table.insert(summaryParts, string.format('%d troop group(s)', troopCount))
end
local summary = table.concat(summaryParts, ', ')
local noteParts = {}
if params.radius then table.insert(noteParts, string.format('radius=%dm', math.floor(params.radius))) end
if params.note then table.insert(noteParts, params.note) end
local noteText = (#noteParts > 0) and (' [' .. table.concat(noteParts, ' ') .. ']') or ''
local targetLabel = params.targetLabel or 'nearest'
local typeHint = params.typeHint and (' type=' .. params.typeHint) or ''
_logImmediate(kind, string.format('Unit %s tracking %s; %s=%s %s%s%s',
params.unit, summary, targetLabel, name, distText, typeHint, noteText))
state.lastTime = now
state.lastName = name
state.lastDist = distance
state.lastCount = crateCount
state.lastTroops = troopCount
CTLD._debugSightState[key] = state
end
function CTLD:_collectEntryUnitTypes(entry)
local collected = {}
local seen = {}
@ -2700,6 +2800,16 @@ function CTLD:_startHoverScheduler()
end, {}, startDelay, interval)
end
function CTLD:_startGroundLoadScheduler()
local groundCfg = CTLD.GroundAutoLoadConfig or {}
if not groundCfg.Enabled or self.GroundLoadSched then return end
local interval = 1.0 -- check every second for ground load conditions
self.GroundLoadSched = SCHEDULER:New(nil, function()
local ok, err = pcall(function() self:ScanGroundAutoLoad() end)
if not ok then _logError('GroundLoadSched ScanGroundAutoLoad error: '..tostring(err)) end
end, {}, interval, interval)
end
-- Adaptive background loop consolidating salvage checks and periodic pruning
function CTLD:_ensureAdaptiveBackgroundLoop()
if self._schedules and self._schedules.backgroundLoop then return end
@ -3724,6 +3834,10 @@ function CTLD:New(cfg)
o._DynamicSalvageQueue = {}
o._jtacRegistry = {}
-- Ground auto-load state tracking
CTLD._groundLoadState = CTLD._groundLoadState or {}
CTLD._groundLoadTimers = CTLD._groundLoadTimers or {}
-- If caller disabled builtin catalog, clear it before merging any globals
if o.Config.UseBuiltinCatalog == false then
o.Config.CrateCatalog = {}
@ -3874,6 +3988,13 @@ function CTLD:New(cfg)
o:_startHoverScheduler()
end
-- Optional: ground auto-load scanner
local groundCfg = CTLD.GroundAutoLoadConfig or {}
if groundCfg.Enabled then
o.GroundLoadSched = nil
o:_startGroundLoadScheduler()
end
-- MEDEVAC auto-pickup and auto-unload scheduler
if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then
local checkInterval = (CTLD.MEDEVAC.AutoPickup and CTLD.MEDEVAC.AutoPickup.CheckInterval) or 3
@ -7759,30 +7880,46 @@ function CTLD:ScanHoverPickup()
local p3 = unit:GetPointVec3()
local ground = land and land.getHeight and land.getHeight({ x = p3.x, y = p3.z }) or 0
local agl = math.max(0, p3.y - ground)
-- speeds (ground/vertical)
local last = CTLD._unitLast[uname]
local gs, vs = 0, 0
if last and (now > (last.t or 0)) then
local dt = now - last.t
if dt > 0 then
local dx = (p3.x - last.x)
local dz = (p3.z - last.z)
gs = math.sqrt(dx*dx + dz*dz) / dt
if last.agl then vs = (agl - last.agl) / dt end
-- Skip hover coaching if on the ground (let ground auto-load handle it)
local groundCfg = CTLD.GroundAutoLoadConfig or {}
local groundContactAGL = groundCfg.GroundContactAGL or 3.5
if agl <= groundContactAGL then
-- On ground, clear hover state and skip hover logic
CTLD._hoverState[uname] = nil
-- Also, when firmly landed, suppress any completed ground-load state
-- so returning to this spot without crates won't keep retriggering.
if CTLD._groundLoadState and CTLD._groundLoadState[uname] and CTLD._groundLoadState[uname].completed then
CTLD._groundLoadState[uname] = nil
end
end
CTLD._unitLast[uname] = { x = p3.x, z = p3.z, t = now, agl = agl }
else
-- speeds (ground/vertical)
local last = CTLD._unitLast[uname]
local gs, vs = 0, 0
if last and (now > (last.t or 0)) then
local dt = now - last.t
if dt > 0 then
local dx = (p3.x - last.x)
local dz = (p3.z - last.z)
gs = math.sqrt(dx*dx + dz*dz) / dt
if last.agl then vs = (agl - last.agl) / dt end
end
end
CTLD._unitLast[uname] = { x = p3.x, z = p3.z, t = now, agl = agl }
-- Use spatial indexing to find nearby crates/troops efficiently
local maxd = coachCfg.autoPickupDistance or 25
local nearby = _getNearbyFromSpatialGrid(p3.x, p3.z, maxd)
local bestName, bestMeta, bestd
local bestType = 'crate'
local maxd = coachCfg.autoPickupDistance or 25
local nearby = _getNearbyFromSpatialGrid(p3.x, p3.z, maxd)
local friendlyCrateCount = 0
local friendlyTroopCount = 0
local bestName, bestMeta, bestd
local bestType = 'crate'
-- Search nearby crates from spatial grid
for name, meta in pairs(nearby.crates) do
for name, meta in pairs(nearby.crates or {}) do
if meta.side == self.Side then
friendlyCrateCount = friendlyCrateCount + 1
local dx = (meta.point.x - p3.x)
local dz = (meta.point.z - p3.z)
local d = math.sqrt(dx*dx + dz*dz)
@ -7794,8 +7931,9 @@ function CTLD:ScanHoverPickup()
end
-- Search nearby deployed troops from spatial grid
for troopGroupName, troopMeta in pairs(nearby.troops) do
for troopGroupName, troopMeta in pairs(nearby.troops or {}) do
if troopMeta.side == self.Side then
friendlyTroopCount = friendlyTroopCount + 1
local troopGroup = GROUP:FindByName(troopGroupName)
if troopGroup and troopGroup:IsAlive() then
local troopPos = troopGroup:GetCoordinate()
@ -7817,13 +7955,37 @@ function CTLD:ScanHoverPickup()
end
end
local coachEnabled = coachCfg.enabled
if CTLD._coachOverride and CTLD._coachOverride[gname] ~= nil then
coachEnabled = CTLD._coachOverride[gname]
end
if CTLD.Config and CTLD.Config.DebugHoverCrates and (friendlyCrateCount > 0 or friendlyTroopCount > 0) then
local debugLabel = bestName or 'none'
local debugType = bestType
local debugDist = bestd
if not bestName and friendlyCrateCount > 0 then
debugLabel = 'pending'
debugType = 'crate'
debugDist = math.huge
end
_debugCrateSight('HoverDebug', {
unit = uname,
now = now,
interval = CTLD.Config.DebugHoverCratesInterval,
step = CTLD.Config.DebugHoverCratesStep,
name = debugLabel,
distance = debugDist,
count = friendlyCrateCount,
troops = friendlyTroopCount,
radius = maxd,
typeHint = debugType,
note = string.format('coachGS<=%.1f', coachCfg.thresholds.captureGS or (4/3.6)),
})
end
local coachEnabled = coachCfg.enabled
if CTLD._coachOverride and CTLD._coachOverride[gname] ~= nil then
coachEnabled = CTLD._coachOverride[gname]
end
-- If coach is on, provide phased guidance
if coachEnabled and bestName and bestMeta then
if coachEnabled and bestName and bestMeta then
local thresholds = coachCfg.thresholds or {}
local isMetric = _getPlayerIsMetric(unit)
@ -7891,19 +8053,19 @@ function CTLD:ScanHoverPickup()
_coachSend(self, group, uname, 'coach_too_low', { agl = v, agl_u = u }, false)
end
end
end
end
-- Auto-load logic using capture thresholds
local capGS = coachCfg.thresholds.captureGS or (4/3.6)
local aglMin = coachCfg.thresholds.aglMin or 5
local aglMax = coachCfg.thresholds.aglMax or 20
local speedOK = gs <= capGS
local heightOK = (agl >= aglMin and agl <= aglMax)
-- Auto-load logic using capture thresholds
local capGS = coachCfg.thresholds.captureGS or (4/3.6)
local aglMin = coachCfg.thresholds.aglMin or 5
local aglMax = coachCfg.thresholds.aglMax or 20
local speedOK = gs <= capGS
local heightOK = (agl >= aglMin and agl <= aglMax)
if bestName and bestMeta and speedOK and heightOK then
if bestName and bestMeta and speedOK and heightOK then
local withinRadius = bestd <= (coachCfg.thresholds.captureHoriz or 2)
if withinRadius then
if withinRadius then
local carried = CTLD._loadedCrates[gname]
local total = carried and carried.total or 0
local currentWeight = carried and carried.totalWeightKg or 0
@ -7956,7 +8118,7 @@ function CTLD:ScanHoverPickup()
end
-- Check both count AND weight limits
if countOK and weightOK then
if countOK and weightOK then
local hs = CTLD._hoverState[uname]
if not hs or hs.targetCrate ~= bestName or hs.targetType ~= bestType then
CTLD._hoverState[uname] = { targetCrate = bestName, targetType = bestType, startTime = now }
@ -7967,16 +8129,14 @@ function CTLD:ScanHoverPickup()
if (now - hs.startTime) >= holdNeeded then
-- load it
if bestType == 'crate' then
local obj = StaticObject.getByName(bestName)
if obj then obj:destroy() end
_cleanupCrateSmoke(bestName) -- Clean up smoke refresh schedule
_removeFromSpatialGrid(bestName, bestMeta.point, 'crate') -- Remove from spatial index
CTLD._crates[bestName] = nil
self:_addLoadedCrate(group, bestMeta.key)
if coachEnabled then
_coachSend(self, group, uname, 'coach_loaded', {}, false)
else
_msgGroup(group, string.format('Loaded %s crate', tostring(bestMeta.key)))
-- Use shared loading function
local success = self:_loadCrateIntoAircraft(group, bestName, bestMeta)
if success then
if coachEnabled then
_coachSend(self, group, uname, 'coach_loaded', {}, false)
else
_msgGroup(group, string.format('Loaded %s crate', tostring(bestMeta.key)))
end
end
elseif bestType == 'troops' then
-- Pick up the troop group
@ -8024,7 +8184,7 @@ function CTLD:ScanHoverPickup()
CTLD._hoverState[uname] = nil
end
end
else
else
-- Aircraft at capacity - notify player with weight/count info
local aircraftType = _getUnitType(unit) or 'aircraft'
if not weightOK then
@ -8039,18 +8199,19 @@ function CTLD:ScanHoverPickup()
_eventSend(self, group, nil, 'troop_aircraft_capacity', { count = bestMeta.count or 0, max = maxTroops, aircraft = aircraftType })
end
CTLD._hoverState[uname] = nil
end
else
end
else
-- lost precision window
if coachEnabled then _coachSend(self, group, uname, 'coach_hover_lost', {}, false) end
CTLD._hoverState[uname] = nil
end
else
end
else
-- reset hover state when outside primary envelope
if CTLD._hoverState[uname] then
if coachEnabled then _coachSend(self, group, uname, 'coach_abort', {}, false) end
end
CTLD._hoverState[uname] = nil
end
end
end
end
@ -8059,6 +8220,316 @@ function CTLD:ScanHoverPickup()
end
-- #endregion Hover pickup scanner
-- =========================
-- Ground Auto-Load Scanner
-- =========================
-- #region Ground auto-load scanner
function CTLD:ScanGroundAutoLoad()
local groundCfg = CTLD.GroundAutoLoadConfig or { Enabled = false }
if not groundCfg.Enabled then return end
local now = timer.getTime()
-- Iterate all groups that have menus (active transports)
for gname, _ in pairs(self.MenusByGroup or {}) do
local group = GROUP:FindByName(gname)
if group and group:IsAlive() then
local unit = group:GetUnit(1)
if unit and unit:IsAlive() then
local typ = _getUnitType(unit)
if _isIn(self.Config.AllowedAircraft, typ) then
local uname = unit:GetName()
-- Ensure ground-load state table exists
CTLD._groundLoadState = CTLD._groundLoadState or {}
-- Check if already carrying crates (skip if at capacity)
local carried = CTLD._loadedCrates[gname]
local currentCount = carried and carried.total or 0
local capacity = _getAircraftCapacity(unit)
if currentCount < capacity.maxCrates then
-- Check basic requirements: on ground, low speed
local agl = _getUnitAGL(unit)
local gs = _getGroundSpeed(unit)
local onGround = (agl <= (groundCfg.GroundContactAGL or 3.5))
local slowEnough = (gs <= (groundCfg.MaxGroundSpeed or 2.0))
-- If a previous ground auto-load completed while we remain effectively stationary
-- on the ground, avoid auto-starting again until the aircraft has moved or lifted off.
local state = CTLD._groundLoadState[uname]
local canProcess = true
if state and state.completed and onGround and slowEnough then
local p3 = unit:GetPointVec3()
local sx = state.completedPosition and state.completedPosition.x or state.startPosition and state.startPosition.x or p3.x
local sz = state.completedPosition and state.completedPosition.z or state.startPosition and state.startPosition.z or p3.z
local dx = (p3.x - sx)
local dz = (p3.z - sz)
local moved = math.sqrt(dx*dx + dz*dz)
-- Require a small reposition (e.g., taxi or liftoff) before new auto-load cycles
if moved < 20 then
canProcess = false
end
end
if onGround and slowEnough and canProcess then
-- Check zone requirement
local inValidZone = false
if groundCfg.RequirePickupZone then
local inPickupZone = self:_isUnitInsidePickupZone(unit, true)
if not inPickupZone and groundCfg.AllowInFOBZones then
-- Check FOB zones too
for _, fobZone in ipairs(self.FOBZones or {}) do
local fname = fobZone:GetName()
if self._ZoneActive.FOB[fname] ~= false then
local fobPoint = fobZone:GetPointVec3()
local unitPoint = unit:GetPointVec3()
local dx = (fobPoint.x - unitPoint.x)
local dz = (fobPoint.z - unitPoint.z)
local d = math.sqrt(dx*dx + dz*dz)
local fobRadius = self:_getZoneRadius(fobZone)
if d <= fobRadius then
inValidZone = true
break
end
end
end
else
inValidZone = inPickupZone
end
else
inValidZone = true -- no zone requirement
end
if inValidZone then
-- Find nearby crates
local p3 = unit:GetPointVec3()
local searchRadius = groundCfg.SearchRadius or 50
local nearby = _getNearbyFromSpatialGrid(p3.x, p3.z, searchRadius)
local bestGroundCrate, bestGroundDist = nil, math.huge
local loadableCrates = {}
for name, meta in pairs(nearby.crates or {}) do
if meta.side == self.Side then
local dx = (meta.point.x - p3.x)
local dz = (meta.point.z - p3.z)
local d = math.sqrt(dx*dx + dz*dz)
if d <= searchRadius then
local entry = { name = name, meta = meta, dist = d }
table.insert(loadableCrates, entry)
if d < bestGroundDist then
bestGroundCrate, bestGroundDist = entry, d
end
end
end
end
if CTLD.Config and CTLD.Config.DebugGroundCrates then
local shouldReport = true
local gst = CTLD._groundLoadState and CTLD._groundLoadState[uname]
if gst and gst.reportHold then
-- only emit periodic heartbeat during hold
local last = gst.reportLast or 0
if (now - last) < (CTLD.Config.DebugGroundCratesInterval or 2.0) then
shouldReport = false
else
CTLD._groundLoadState[uname].reportLast = now
end
end
if shouldReport then
_debugCrateSight('GroundDebug', {
unit = uname,
now = now,
interval = CTLD.Config.DebugGroundCratesInterval,
step = CTLD.Config.DebugGroundCratesStep,
name = bestGroundCrate and bestGroundCrate.name or 'none',
distance = (bestGroundDist ~= math.huge) and bestGroundDist or nil,
count = #loadableCrates,
troops = 0,
radius = searchRadius,
typeHint = 'crate',
note = string.format('freeSlots=%d', math.max(0, capacity.maxCrates - currentCount))
})
end
end
if #loadableCrates > 0 then
-- Sort by distance, load closest first (up to capacity)
table.sort(loadableCrates, function(a, b) return a.dist < b.dist end)
-- Determine how many we can load
local canLoad = math.min(#loadableCrates, capacity.maxCrates - currentCount)
local cratesToLoad = {}
for i = 1, canLoad do
table.insert(cratesToLoad, loadableCrates[i])
end
if #cratesToLoad > 0 then
-- Ground load state machine
local state = CTLD._groundLoadState[uname]
if not state or not state.loading then
-- Start new load sequence
CTLD._groundLoadState[uname] = {
loading = true,
startTime = now,
cratesToLoad = cratesToLoad,
startPosition = { x = p3.x, z = p3.z },
lastCheckTime = now,
}
_coachSend(self, group, uname, 'ground_load_started', {
seconds = groundCfg.LoadDelay,
count = #cratesToLoad
}, false)
else
-- Validate that crates in state still exist
local validCrates = {}
for _, crateInfo in ipairs(state.cratesToLoad or {}) do
-- Check if crate still exists in CTLD._crates
if CTLD._crates[crateInfo.name] then
table.insert(validCrates, crateInfo)
end
end
-- If no valid crates remain, reset state
if #validCrates == 0 then
CTLD._groundLoadState[uname] = nil
else
-- Update state with only valid crates
state.cratesToLoad = validCrates
-- Continue existing sequence
local elapsed = now - state.startTime
local remaining = (groundCfg.LoadDelay or 15) - elapsed
-- Check if moved too much
local dx = (p3.x - state.startPosition.x)
local dz = (p3.z - state.startPosition.z)
local moved = math.sqrt(dx*dx + dz*dz)
if moved > 10 then -- moved more than 10m
_coachSend(self, group, uname, 'ground_load_aborted', {}, false)
CTLD._groundLoadState[uname] = nil
else
-- Progress message every 5 seconds
if (now - state.lastCheckTime) >= 5 and remaining > 1 then
_coachSend(self, group, uname, 'ground_load_progress', {
remaining = math.ceil(remaining)
}, false)
state.lastCheckTime = now
state.reportHold = true
end
-- Check if time elapsed
if remaining <= 0 then
-- Load the crates!
local loadedCount = 0
for _, crateInfo in ipairs(state.cratesToLoad) do
local success = self:_loadCrateIntoAircraft(group, crateInfo.name, crateInfo.meta)
if success then
loadedCount = loadedCount + 1
end
end
if loadedCount > 0 then
_coachSend(self, group, uname, 'ground_load_complete', {
count = loadedCount
}, false)
-- Mark completion and remember where it happened so we don't
-- immediately restart another cycle while still parked.
CTLD._groundLoadState[uname] = {
completed = true,
completedTime = now,
completedPosition = { x = p3.x, z = p3.z },
reportHold = false,
}
else
-- Nothing actually loaded; clear state fully.
CTLD._groundLoadState[uname] = nil
end
end
end
end -- end of validCrates > 0
end -- end of state exists check
else
CTLD._groundLoadState[uname] = nil
end
else
CTLD._groundLoadState[uname] = nil
end
else
-- Not in a valid zone
local state = CTLD._groundLoadState[uname]
if state and state.sentZoneWarning ~= true then
-- Send zone requirement message (once)
local nearestZone, nearestDist = self:_nearestActivePickupZone(unit)
if nearestZone and nearestDist then
local isMetric = _getPlayerIsMetric(unit)
local brg = _bearingTo(unit, nearestZone)
local distV, distU = _fmtDistance(nearestDist, isMetric)
_coachSend(self, group, uname, 'ground_load_no_zone', {
zone_dist = distV,
zone_dist_u = distU,
zone_brg = brg
}, false)
end
if not state then
CTLD._groundLoadState[uname] = { sentZoneWarning = true }
else
state.sentZoneWarning = true
end
end
end
else
-- Not on ground or moving too fast, reset state
local state = CTLD._groundLoadState[uname]
if state and state.loading then
-- Was loading but now lifted off or moved
_coachSend(self, group, uname, 'ground_load_aborted', {}, false)
end
CTLD._groundLoadState[uname] = nil
end
else
-- At capacity, no need to check
CTLD._groundLoadState[uname] = nil
end
end
end
end
end
end
-- Helper: Load a crate into an aircraft (shared by hover and ground load)
function CTLD:_loadCrateIntoAircraft(group, crateName, crateMeta)
if not group or not crateName or not crateMeta then return false end
local gname = group:GetName()
local unit = group:GetUnit(1)
if not unit or not unit:IsAlive() then return false end
-- Destroy the static object first
local obj = StaticObject.getByName(crateName)
if obj then obj:destroy() end
-- Clean up crate smoke refresh schedule
_cleanupCrateSmoke(crateName)
-- Remove from spatial grid and tracking
_removeFromSpatialGrid(crateName, crateMeta.point, 'crate')
CTLD._crates[crateName] = nil
-- Add to loaded crates using existing method (maintains consistency with rest of code)
self:_addLoadedCrate(group, crateMeta.key)
-- Update inventory if enabled
if self.Config.Inventory and self.Config.Inventory.Enabled and crateMeta.spawnZone then
pcall(function() self:_updateInventoryOnPickup(crateMeta.spawnZone, crateMeta.key) end)
end
_logDebug(string.format('[Load] Loaded crate %s (%s) into %s', crateName, crateMeta.key, gname))
return true
end
-- #endregion Ground auto-load scanner
-- =========================
-- Troops
-- =========================
@ -8166,7 +8637,107 @@ function CTLD:LoadTroops(group, opts)
end
end
-- Enforce pickup zone requirement for troop loading (inside zone)
-- Check for nearby deployed troops first (allow pickup regardless of zone restrictions)
local p3 = unit:GetPointVec3()
local maxPickupDistance = 25 -- meters for ground-based troop pickup (matches hover pickup distance)
local nearby = _getNearbyFromSpatialGrid(p3.x, p3.z, maxPickupDistance)
local nearbyTroopGroup = nil
local nearbyTroopMeta = nil
local nearbyTroopDist = nil
-- Search for nearby deployed troops
for troopGroupName, troopMeta in pairs(nearby.troops) do
if troopMeta.side == self.Side then
local troopGroup = GROUP:FindByName(troopGroupName)
if troopGroup and troopGroup:IsAlive() then
local troopPos = troopGroup:GetCoordinate()
if troopPos then
local tp = troopPos:GetVec3()
local dx = (tp.x - p3.x)
local dz = (tp.z - p3.z)
local d = math.sqrt(dx*dx + dz*dz)
if d <= maxPickupDistance and ((not nearbyTroopDist) or d < nearbyTroopDist) then
nearbyTroopGroup = troopGroup
nearbyTroopMeta = troopMeta
nearbyTroopDist = d
end
end
end
end
end
-- If we found nearby deployed troops, pick them up (bypass zone requirement)
if nearbyTroopGroup and nearbyTroopMeta then
-- Load the deployed troops
local troopCount = nearbyTroopMeta.count or 0
local typeKey = nearbyTroopMeta.typeKey or 'AS'
local troopWeight = nearbyTroopMeta.weightKg or 0
-- Check aircraft capacity
local capacity = _getAircraftCapacity(unit)
local maxTroops = capacity.maxTroops
local maxWeight = capacity.maxWeightKg or 0
local currentTroops = CTLD._troopsLoaded[gname]
local currentTroopCount = currentTroops and currentTroops.count or 0
local totalTroopCount = currentTroopCount + troopCount
local carried = CTLD._loadedCrates[gname]
local currentWeight = carried and carried.totalWeightKg or 0
local wouldBeWeight = currentWeight + troopWeight
-- Check capacity
if totalTroopCount > maxTroops then
local aircraftType = _getUnitType(unit) or 'aircraft'
_msgGroup(group, string.format('Troop capacity exceeded! Current: %d, Adding: %d, Max: %d for %s',
currentTroopCount, troopCount, maxTroops, aircraftType))
return
end
if maxWeight > 0 and wouldBeWeight > maxWeight then
local aircraftType = _getUnitType(unit) or 'aircraft'
_msgGroup(group, string.format('Weight capacity exceeded! Current: %dkg, Troops: %dkg, Max: %dkg for %s',
math.floor(currentWeight), math.floor(troopWeight), math.floor(maxWeight), aircraftType))
return
end
-- Load the troops and remove from deployed tracking
if currentTroops then
local troopTypes = currentTroops.troopTypes or { { typeKey = currentTroops.typeKey, count = currentTroops.count } }
table.insert(troopTypes, { typeKey = typeKey, count = troopCount })
CTLD._troopsLoaded[gname] = {
count = totalTroopCount,
typeKey = 'Mixed',
troopTypes = troopTypes,
weightKg = currentTroops.weightKg + troopWeight,
}
else
CTLD._troopsLoaded[gname] = {
count = troopCount,
typeKey = typeKey,
troopTypes = { { typeKey = typeKey, count = troopCount } },
weightKg = troopWeight,
}
end
-- Remove from deployed tracking
local troopGroupName = nearbyTroopGroup:GetName()
_removeFromSpatialGrid(troopGroupName, nearbyTroopMeta.point, 'troops')
CTLD._deployedTroops[troopGroupName] = nil
-- Destroy the troop group
nearbyTroopGroup:Destroy()
self:_refreshLoadedTroopSummaryForGroup(gname)
_eventSend(self, group, nil, 'troops_loaded', { count = totalTroopCount })
_msgGroup(group, string.format('Picked up %d deployed troops (total onboard: %d)', troopCount, totalTroopCount))
return -- Successfully picked up deployed troops, exit function
end
-- No nearby deployed troops found, enforce pickup zone requirement for spawning new troops
if self.Config.RequirePickupZoneForTroopLoad then
local hasPickupZones = (self.PickupZones and #self.PickupZones > 0) or (self.Config.Zones and self.Config.Zones.PickupZones and #self.Config.Zones.PickupZones > 0)
if not hasPickupZones then

View File

@ -931,8 +931,56 @@ function FAC:_buildObserverMenu(group)
CMD('Show Active FAC/RECCE Controllers', function() self:_showFacStatus(group) end)
CMD('Show FAC Codes In Use', function() self:_showCodesCoalition() end)
CMD('FAC/RECCE Help', function()
local msg = 'FAC/RECCE controls are available from aircraft configured as FAC platforms. Join an AFAC/RECCE group or use one of the approved aircraft types to access full targeting tools.'
MESSAGE:New(msg, 12):ToGroup(group)
local types = self.Config.facACTypes or {}
local typeList = (#types > 0) and table.concat(types, ', ') or 'see mission briefing'
local laserCodes = self.Config.FAC_laser_codes or {'1688'}
local defaultCode = laserCodes[1] or '1688'
local allCodes = table.concat(laserCodes, ', ')
local maxDist = tostring(self.Config.FAC_maxDistance or 18520)
local rootName = self.Config.RootMenuName or 'FAC/RECCE'
local markerDefault = self.Config.MarkerDefault or 'FLARES'
local msg = table.concat({
'FAC/RECCE Overview:',
'',
'- This module lets certain aircraft act as an airborne JTAC / artillery spotter.',
'- To get the FAC menu, you must be in a group named with AFAC/RECCE/RECON,',
' or flying one of the approved FAC aircraft types (' .. typeList .. ').',
'',
'Basic Usage:',
'- Open the F10 radio menu and look for "' .. rootName .. '".',
'- Use "Auto Laze ON" to have the module automatically search for and lase nearby enemy targets.',
'- Use "Scan for Close Targets" then "Select Found Target" to manually pick a target from a list.',
'- Use "RECCE: Sweep & Mark" to scan a larger area and drop map markers on detected contacts.',
'',
'Laser Codes:',
'- Default FAC laser code: ' .. defaultCode .. '.',
'- Allowed codes: ' .. allCodes .. '.',
'- Use the "Laser Code" submenu to change your code if another FAC is already using it.',
'- The module will try to avoid code conflicts and will notify you if a different code is assigned.',
'',
'Markers & Smoke:',
'- Default marker type: ' .. markerDefault .. '.',
'- FAC can mark the current target with smoke or flares in different colors.',
'- Use the "Marker" submenu to choose SMOKE or FLARES and a color for your marks.',
'',
'Range & Line of Sight:',
'- FAC search range is about ' .. maxDist .. ' meters (~10 NM).',
'- Targets must be within line-of-sight; hills and terrain can block detection and lasing.',
'',
'Artillery & Air Support:',
'- The "Artillery" and "Air/Naval" menus look for AI units on your side that can fire on the target.',
'- If no suitable unit is in range or has ammo, the module will tell you.',
'- Guided/air/naval options require appropriate AI aircraft or ships placed by the mission designer.',
'',
'If you do not see FAC menus:',
'- Check that your group name contains AFAC/RECCE/RECON, or you are flying a supported FAC aircraft type.',
'- Make sure Moose.lua, Moose_CTLD.lua, and Moose_CTLD_FAC.lua are all loaded in the mission (in that order).',
}, '\n')
MESSAGE:New(msg, 30):ToGroup(group)
end)
return { root = root }

Binary file not shown.