diff --git a/RELEASENOTES.md b/RELEASENOTES.md index fb2d13d..95dd183 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -7,6 +7,10 @@ ### Bug Fixes +- Issue #11 + PR #17 + Supply crate spawning now checks for free space and will not spawn if the area is too crowded. + Additionally different units will spawn in different areas depending on loading side. ## [0.12.1] 2026-07 diff --git a/src/classes/stageClasses/helpers/MaxLoadConfig.lua b/src/classes/stageClasses/helpers/MaxLoadConfig.lua deleted file mode 100644 index 0d7f407..0000000 --- a/src/classes/stageClasses/helpers/MaxLoadConfig.lua +++ /dev/null @@ -1,20 +0,0 @@ ----@class MaxLoadConfig ----@field maxInternalLoad number - ----@type table -local MaxLoadConfig = { - ["Mi-8MT"] = { - maxInternalLoad = 4000, - }, - ["CH-47Fbl1"] = { - maxInternalLoad = 10000 - }, - ["Mi-24P"] = { - maxInternalLoad = 2000 - }, - ["UH-1H"] = { - maxInternalLoad = 2000 - } -} - -return MaxLoadConfig \ No newline at end of file diff --git a/src/classes/stageClasses/helpers/SupplyLoadConfig.lua b/src/classes/stageClasses/helpers/SupplyLoadConfig.lua new file mode 100644 index 0000000..83c651d --- /dev/null +++ b/src/classes/stageClasses/helpers/SupplyLoadConfig.lua @@ -0,0 +1,42 @@ +---@class DropZoneSlice +---@field centerAngle number Angle in degrees (0=forward, 90=right, 180=rear, 270=left) +---@field angleWidth number Total width of the slice in degrees (e.g. 60 = ±30°) +---@field minRadius number Minimum search radius in meters (safe distance from helicopter) +---@field maxRadius number Maximum search radius in meters +---@field spacing number Distance increment when searching outward in meters + +---@class SupplyLoadConfig +---@field maxInternalLoad number +---@field dropZones Array + +---@type table +local SupplyLoadConfig = { + ["Mi-8MT"] = { + maxInternalLoad = 4000, + dropZones = { + { centerAngle = 180, angleWidth = 60, minRadius = 20, maxRadius = 50, spacing = 5 }, + } + }, + ["CH-47Fbl1"] = { + maxInternalLoad = 10000, + dropZones = { + { centerAngle = 180, angleWidth = 60, minRadius = 20, maxRadius = 75, spacing = 5 }, + } + }, + ["Mi-24P"] = { + maxInternalLoad = 2000, + dropZones = { + { centerAngle = 270, angleWidth = 60, minRadius = 15, maxRadius = 50, spacing = 5 }, + { centerAngle = 90, angleWidth = 60, minRadius = 15, maxRadius = 50, spacing = 5 }, + } + }, + ["UH-1H"] = { + maxInternalLoad = 2000, + dropZones = { + { centerAngle = 270, angleWidth = 60, minRadius = 15, maxRadius = 50, spacing = 5 }, + { centerAngle = 90, angleWidth = 60, minRadius = 15, maxRadius = 50, spacing = 5 }, + } + } +} + +return SupplyLoadConfig \ No newline at end of file diff --git a/src/classes/stageClasses/helpers/SupplyUnitsTracker.lua b/src/classes/stageClasses/helpers/SupplyUnitsTracker.lua index 7362529..d26d863 100644 --- a/src/classes/stageClasses/helpers/SupplyUnitsTracker.lua +++ b/src/classes/stageClasses/helpers/SupplyUnitsTracker.lua @@ -3,7 +3,7 @@ local Util = require("classes.util.Util") local DcsUtil = require("classes.util.DcsUtil") local SpearheadEvents = require("classes.spearhead_events") local SupplyConfigHelper = require("classes.stageClasses.helpers.SupplyConfigHelper") -local MaxLoadConfig = require("classes.stageClasses.helpers.MaxLoadConfig") +local SupplyLoadConfig = require("classes.stageClasses.helpers.SupplyLoadConfig") ---@class SupplyUnitEventListener ---@field supplyUnitSpawned fun(self:SupplyUnitEventListener, unit:Unit) | nil @@ -283,9 +283,6 @@ function SupplyUnitsTracker:UnloadRequested(unitID, crateType, missionCommandsHe if group == nil then return end - - self:RemoveCargoFromUnit(unitID, crateType) - self:UpdateWeightForUnit(unit) local cargoConfig = SupplyConfigHelper.getSupplyConfig(crateType) @@ -294,7 +291,16 @@ function SupplyUnitsTracker:UnloadRequested(unitID, crateType, missionCommandsHe return end - local cargoPos = self:GetCargoPlacePosition(unit) + local cargoPos = self:GetCargoPlacePosition(unit, cargoConfig.staticType) + + if cargoPos == nil then + self._logger:warn("No valid position found to drop cargo for unit: " .. unit:getName()) + trigger.action.outTextForUnit(unit:getID(), "No valid position to drop cargo. Unloading area is too crowded.", 10) + return + end + + self:RemoveCargoFromUnit(unitID, crateType) + self:UpdateWeightForUnit(unit) cargoCount = cargoCount + 1 local cargoSpawnObject = { @@ -394,7 +400,7 @@ function SupplyUnitsTracker:TryLoadCrateInUnit(unit, crateType, commandHelper) end end - local unitConfig = MaxLoadConfig[unit:getTypeName()] + local unitConfig = SupplyLoadConfig[unit:getTypeName()] if unitConfig == nil then trigger.action.outTextForUnit(unit:getID(), "Your unit type is not configured for logistics: " .. crateType, 5) self._logger:error("Invalid unit type: " .. unit:getTypeName()) @@ -437,53 +443,173 @@ function SupplyUnitsTracker:UnitRequestCrateSpawn(groupID, crateType) end end +---@class SupplyUnitsBoundingBox +---@field min Vec3 World space minimum +---@field max Vec3 World space maximum + +---@param foundObject Object +---@return SupplyUnitsBoundingBox? +function SupplyUnitsTracker:GetBoundingBoxes(foundObject) + + local desc = foundObject:getDesc() + if foundObject:getCategory() == Object.Category.SCENERY then + desc = SceneryObject.getDescByName(foundObject:getTypeName()) + end + + if desc == nil or desc.box == nil then + return nil + end + + local objPos = foundObject:getPoint() + local box = desc.box + + -- Convert relative bbox to world space by adding object position + ---@type SupplyUnitsBoundingBox + return { + min = { + x = objPos.x + box.min.x, + y = objPos.y + box.min.y, + z = objPos.z + box.min.z + }, + max = { + x = objPos.x + box.max.x, + y = objPos.y + box.max.y, + z = objPos.z + box.max.z + } + } +end + +---Check if two axis-aligned bounding boxes collide with safety margin +---@param crateBBox SupplyUnitsBoundingBox The crate's bbox in world space +---@param objBBox SupplyUnitsBoundingBox The existing object's bbox in world space +---@param safetyMargin number Safety margin around objects +---@return boolean True if collision detected +function SupplyUnitsTracker:CheckBBoxCollision(crateBBox, objBBox, safetyMargin) + + -- Apply safety margin to object bbox + local objMin = { + x = objBBox.min.x - safetyMargin, + y = objBBox.min.y - safetyMargin, + z = objBBox.min.z - safetyMargin + } + local objMax = { + x = objBBox.max.x + safetyMargin, + y = objBBox.max.y + safetyMargin, + z = objBBox.max.z + safetyMargin + } + + -- AABB collision detection + return crateBBox.min.x <= objMax.x and crateBBox.max.x >= objMin.x and + crateBBox.min.y <= objMax.y and crateBBox.max.y >= objMin.y and + crateBBox.min.z <= objMax.z and crateBBox.max.z >= objMin.z +end ---@private ---@param unit Unit ----@return Vec3 -function SupplyUnitsTracker:GetCargoPlacePosition(unit) +---@param crateTypeName string +---@return Vec3? +function SupplyUnitsTracker:GetCargoPlacePosition(unit, crateTypeName) - local pos = unit:getPosition() - local preferredPos = { - x = pos.p.x - 10 * pos.x.x, - y = pos.p.y - 10 * pos.x.y, - z = pos.p.z - 10 * pos.x.z + local unitPos = unit:getPosition() + + -- Get crate bbox - relative to placement position + local crateDesc = StaticObject.getDescByName(crateTypeName) --[[@as table]] + if crateDesc == nil or crateDesc.box == nil then + self._logger:error("Could not get bbox for crate type: " .. crateTypeName) + return nil + end + + local crateRelativeBBox = crateDesc.box + + -- Get drop zone config for this unit + local dropZones = { + { centerAngle = 180, angleWidth = 60, minRadius = 15, maxRadius = 50, spacing = 5 } + } + + if SupplyLoadConfig[unit:getTypeName()] ~= nil then + dropZones = SupplyLoadConfig[unit:getTypeName()].dropZones + end + + -- Get occupied objects with their bboxes + local searchVolume = { + id = world.VolumeType.SPHERE, + params = { + point = { + x = unitPos.p.x, + y = unitPos.p.y, + z = unitPos.p.z + }, + radius = 100 -- Search a large area + } } - return preferredPos + local occupiedObjects = {} + local found = function(foundItem, val) + local bbox = self:GetBoundingBoxes(foundItem) + if bbox then + table.insert(occupiedObjects, { + pos = foundItem:getPoint(), + bbox = bbox + }) + end + end + world.searchObjects(searchVolume.id, searchVolume, found) - -- local volume = { - -- id = world.VolumeType.SPHERE, - -- params = { - -- point = preferredPos, - -- radius = 10 - -- } - -- } + local safetyMargin = 3 -- Safety margin around objects - -- local occupiedPosX = {} - -- local occupiedPosZ = {} + -- Search through each slice + for _, zone in ipairs(dropZones) do + -- Calculate angle range for this slice + local minAngle = zone.centerAngle - (zone.angleWidth / 2) + local maxAngle = zone.centerAngle + (zone.angleWidth / 2) - -- ---@param foundItem Object - -- local found = function(foundItem, val) + -- Search outward in rings starting from minRadius + for distance = zone.minRadius, zone.maxRadius, zone.spacing do + -- Check multiple positions within the angular slice + local angleStep = math.min(15, zone.angleWidth / 3) -- Divide slice into sections + + for angle = minAngle, maxAngle, angleStep do + local radians = math.rad(angle) + + -- Calculate position at this angle and distance + -- Angle 0 = forward, 90 = right, 180 = rear, 270 = left + local candidateX = unitPos.p.x + distance * math.sin(radians) + local candidateY = unitPos.p.y + distance * math.cos(radians) + local candidateZ = land.getHeight({ x = candidateX, y = candidateY }) - -- local foundPos = foundItem:getPoint() + -- Convert crate's relative bbox to world space at this position + local crateBBoxWorldSpace = { + min = { + x = candidateX + crateRelativeBBox.min.x, + y = candidateZ + crateRelativeBBox.min.y, + z = candidateY + crateRelativeBBox.min.z + }, + max = { + x = candidateX + crateRelativeBBox.max.x, + y = candidateZ + crateRelativeBBox.max.y, + z = candidateY + crateRelativeBBox.max.z + } + } - -- local z = math.floor(foundPos.z) - -- for i = z - 3 , z + 3 do - -- occupiedPosZ[i] = true - -- end + -- Check if crate bbox collides with any existing objects + local collides = false + for _, obj in ipairs(occupiedObjects) do + if self:CheckBBoxCollision(crateBBoxWorldSpace, obj.bbox, safetyMargin) then + collides = true + break + end + end - -- local x = math.floor(foundPos.x) - -- for i = x - 3 , x + 3 do - -- occupiedPosX[i] = true - -- end - -- end + if not collides then + return { x = candidateX, y = candidateZ, z = candidateY } + end + end + end + end - -- world.searchObjects(volume.id, volume.params, found) - - - + -- No free spot found + return nil end return SupplyUnitsTracker \ No newline at end of file