diff --git a/.gitignore b/.gitignore
index cd31b20..5d688a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,5 @@
/dist
-.vscode/settings.json
\ No newline at end of file
+.vscode/settings.json
+**\settings.json
\ No newline at end of file
diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index 7dd3c3d..0537517 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -7,7 +7,11 @@
### Bug Fixes
-- PR #15
+- 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.
+- PR #15
Fixed command wiring for supply hubs for better and more accurate detection of units spawning and entering/exiting zone.
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..90b42db
--- /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 = 30, 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 7188e2d..d98d2e0 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
@@ -297,14 +297,15 @@ function SupplyUnitsTracker:UnloadRequested(unitID, crateType, missionCommandsHe
self._logger:debug("Unload requested for unit: " .. unitID .. " crateType: " .. crateType)
local unit = DcsUtil.GetPlayerUnitByID(unitID)
- if unit == nil or unit:isExist() == false then return end
+ if unit == nil or unit:isExist() == false then
+ self._logger:warn("Unload requested for non-existent unit: " .. unitID)
+ return
+ end
local group = unit:getGroup()
if group == nil then
+ self._logger:warn("Unload requested for unit with no group: " .. unit:getName())
return
end
-
- self:RemoveCargoFromUnit(unitID, crateType)
- self:UpdateWeightForUnit(unit)
local cargoConfig = SupplyConfigHelper.getSupplyConfig(crateType)
@@ -313,7 +314,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 = {
@@ -323,9 +333,11 @@ function SupplyUnitsTracker:UnloadRequested(unitID, crateType, missionCommandsHe
y = cargoPos.z,
}
+ self._logger:debug("Spawning crate #" .. cargoCount .. " at (" .. string.format("%.2f", cargoPos.x) .. ", " .. string.format("%.2f", cargoPos.y) .. ", " .. string.format("%.2f", cargoPos.z) .. ")")
local spawned = coalition.addStaticObject(unit:getCoalition(), cargoSpawnObject)
self._droppedCrates[cargoSpawnObject.name] = spawned
missionCommandsHelper:updateCommandsForGroup(group:getID())
+ self._logger:debug("Cargo dropped for unit: " .. unit:getName() .. " crateType: " .. crateType)
end
---@return table
@@ -413,7 +425,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())
@@ -456,53 +468,234 @@ function SupplyUnitsTracker:UnitRequestCrateSpawn(groupID, crateType)
end
end
+---@class SupplyUnitsBoundingBox
+---@field min Vec3 World space minimum
+---@field max Vec3 World space maximum
+---@field heading number? Object heading in radians
+
+---@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
+ local heading = 0
+
+ -- Try to get object heading from position vector's forward direction
+ -- This works for units and other objects that support getPosition
+ pcall(function()
+ local objPosition = foundObject:getPosition()
+ if objPosition and objPosition.x then
+ heading = math.atan2(objPosition.x.z, objPosition.x.x)
+ end
+ end)
+
+ -- For rotated objects, we need to rotate the bounding box
+ local minX = box.min.x
+ local maxX = box.max.x
+ local minZ = box.min.z
+ local maxZ = box.max.z
+
+ -- If object has significant rotation, apply rotation to bbox corners
+ if math.abs(heading) > 0.1 then
+ -- Get all 4 corners of bbox in local space
+ local corners = {
+ {minX, minZ},
+ {minX, maxZ},
+ {maxX, minZ},
+ {maxX, maxZ}
+ }
+
+ -- Rotate corners and find new min/max
+ minX, maxX = math.huge, -math.huge
+ minZ, maxZ = math.huge, -math.huge
+
+ for _, corner in ipairs(corners) do
+ local rotX = corner[1] * math.cos(heading) - corner[2] * math.sin(heading)
+ local rotZ = corner[1] * math.sin(heading) + corner[2] * math.cos(heading)
+ minX = math.min(minX, rotX)
+ maxX = math.max(maxX, rotX)
+ minZ = math.min(minZ, rotZ)
+ maxZ = math.max(maxZ, rotZ)
+ end
+ end
+
+ -- Convert relative bbox to world space by adding object position
+ ---@type SupplyUnitsBoundingBox
+ return {
+ min = {
+ x = objPos.x + minX,
+ y = objPos.y + box.min.y,
+ z = objPos.z + minZ
+ },
+ max = {
+ x = objPos.x + maxX,
+ y = objPos.y + box.max.y,
+ z = objPos.z + maxZ
+ },
+ heading = heading
+ }
+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 unit's heading from the forward vector (x component)
+ -- Heading is calculated as: atan2(forward.z, forward.x)
+ local unitHeading = math.atan2(unitPos.x.z, unitPos.x.x)
+
+ -- 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
+ self._logger:debug("Found object: " .. foundItem:getTypeName() .. " at (" .. foundItem:getPoint().x .. ", " .. foundItem:getPoint().z .. ")")
+ table.insert(occupiedObjects, {
+ pos = foundItem:getPoint(),
+ bbox = bbox
+ })
+ else
+ self._logger:debug("Found object without bbox: " .. foundItem:getTypeName())
+ end
+ end
+ local searchCategories = {}
+ for key, value in pairs(Object.Category) do
+ self._logger:debug("Adding category to search: " .. tostring(value) .. " (" .. tostring(key) .. ")")
+ table.insert(searchCategories, value)
+ end
- -- local volume = {
- -- id = world.VolumeType.SPHERE,
- -- params = {
- -- point = preferredPos,
- -- radius = 10
- -- }
- -- }
+---@diagnostic disable-next-line: param-type-mismatch
+ world.searchObjects(searchCategories, searchVolume, found)
- -- local occupiedPosX = {}
- -- local occupiedPosZ = {}
+ local safetyMargin = 3 -- Safety margin around objects
- -- ---@param foundItem Object
- -- local found = function(foundItem, val)
+ -- 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)
- -- local foundPos = foundItem:getPoint()
+ -- 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, relative to unit's heading
+ -- Angle 0 = forward, 90 = right, 180 = rear, 270 = left
+ -- Apply unit heading to make angles relative to unit orientation
+ local worldAngle = radians + unitHeading
+
+ local candidateX = unitPos.p.x + distance * math.sin(worldAngle)
+ local candidateZ = unitPos.p.z + distance * math.cos(worldAngle)
+ local candidateY = land.getHeight({ x = candidateX, y = candidateZ })
- -- local z = math.floor(foundPos.z)
- -- for i = z - 3 , z + 3 do
- -- occupiedPosZ[i] = true
- -- end
+ -- Convert crate's relative bbox to world space at this position
+ local crateBBoxWorldSpace = {
+ min = {
+ x = candidateX + crateRelativeBBox.min.x,
+ y = candidateY + crateRelativeBBox.min.y,
+ z = candidateZ + crateRelativeBBox.min.z
+ },
+ max = {
+ x = candidateX + crateRelativeBBox.max.x,
+ y = candidateY + crateRelativeBBox.max.y,
+ z = candidateZ + crateRelativeBBox.max.z
+ }
+ }
- -- local x = math.floor(foundPos.x)
- -- for i = x - 3 , x + 3 do
- -- occupiedPosX[i] = true
- -- end
- -- 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
+ self._logger:debug("Collision at angle=" .. angle .. ", distance=" .. distance)
+ break
+ end
+ end
- -- world.searchObjects(volume.id, volume.params, found)
+ if not collides then
+ self._logger:debug("Valid position found at angle=" .. angle .. ", distance=" .. distance .. ", pos=(" .. string.format("%.2f", candidateX) .. ", " .. string.format("%.2f", candidateY) .. ", " .. string.format("%.2f", candidateZ) .. ")")
+ return { x = candidateX, y = candidateY, z = candidateZ }
+ end
+ end
+ end
+ end
-
-
+ -- No free spot found
+ return nil
end
return SupplyUnitsTracker
\ No newline at end of file