Crate spawning update (#17)
Publish Release / build (push) Successful in 42s

Reviewed-on: #16
Co-authored-by: dutchie031 <timrorije@gmail.com>

Issue: #11

-[ ] Tested
-[ ] Reviewed
-[ ] Release notes updatedReviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2026-08-16 11:55:34 +00:00
committed by dutchie031
parent a1e36aa5d7
commit f677a83c8e
5 changed files with 281 additions and 61 deletions
+1
View File
@@ -4,3 +4,4 @@
/dist /dist
.vscode/settings.json .vscode/settings.json
**\settings.json
+5 -1
View File
@@ -7,7 +7,11 @@
### Bug Fixes ### Bug Fixes
- PR #15 - Issue #11 <br>
PR #17 <br>
Supply crate spawning now checks for free space and will not spawn if the area is too crowded. <br>
Additionally different units will spawn in different areas depending on loading side.
- PR #15 <br>
Fixed command wiring for supply hubs for better and more accurate detection of units spawning and entering/exiting zone. Fixed command wiring for supply hubs for better and more accurate detection of units spawning and entering/exiting zone.
@@ -1,20 +0,0 @@
---@class MaxLoadConfig
---@field maxInternalLoad number
---@type table<string, MaxLoadConfig>
local MaxLoadConfig = {
["Mi-8MT"] = {
maxInternalLoad = 4000,
},
["CH-47Fbl1"] = {
maxInternalLoad = 10000
},
["Mi-24P"] = {
maxInternalLoad = 2000
},
["UH-1H"] = {
maxInternalLoad = 2000
}
}
return MaxLoadConfig
@@ -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<DropZoneSlice>
---@type table<string, SupplyLoadConfig>
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
@@ -3,7 +3,7 @@ local Util = require("classes.util.Util")
local DcsUtil = require("classes.util.DcsUtil") local DcsUtil = require("classes.util.DcsUtil")
local SpearheadEvents = require("classes.spearhead_events") local SpearheadEvents = require("classes.spearhead_events")
local SupplyConfigHelper = require("classes.stageClasses.helpers.SupplyConfigHelper") local SupplyConfigHelper = require("classes.stageClasses.helpers.SupplyConfigHelper")
local MaxLoadConfig = require("classes.stageClasses.helpers.MaxLoadConfig") local SupplyLoadConfig = require("classes.stageClasses.helpers.SupplyLoadConfig")
---@class SupplyUnitEventListener ---@class SupplyUnitEventListener
---@field supplyUnitSpawned fun(self:SupplyUnitEventListener, unit:Unit) | nil ---@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) self._logger:debug("Unload requested for unit: " .. unitID .. " crateType: " .. crateType)
local unit = DcsUtil.GetPlayerUnitByID(unitID) 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() local group = unit:getGroup()
if group == nil then if group == nil then
self._logger:warn("Unload requested for unit with no group: " .. unit:getName())
return return
end end
self:RemoveCargoFromUnit(unitID, crateType)
self:UpdateWeightForUnit(unit)
local cargoConfig = SupplyConfigHelper.getSupplyConfig(crateType) local cargoConfig = SupplyConfigHelper.getSupplyConfig(crateType)
@@ -313,7 +314,16 @@ function SupplyUnitsTracker:UnloadRequested(unitID, crateType, missionCommandsHe
return return
end 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 cargoCount = cargoCount + 1
local cargoSpawnObject = { local cargoSpawnObject = {
@@ -323,9 +333,11 @@ function SupplyUnitsTracker:UnloadRequested(unitID, crateType, missionCommandsHe
y = cargoPos.z, 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) local spawned = coalition.addStaticObject(unit:getCoalition(), cargoSpawnObject)
self._droppedCrates[cargoSpawnObject.name] = spawned self._droppedCrates[cargoSpawnObject.name] = spawned
missionCommandsHelper:updateCommandsForGroup(group:getID()) missionCommandsHelper:updateCommandsForGroup(group:getID())
self._logger:debug("Cargo dropped for unit: " .. unit:getName() .. " crateType: " .. crateType)
end end
---@return table<string,StaticObject> ---@return table<string,StaticObject>
@@ -413,7 +425,7 @@ function SupplyUnitsTracker:TryLoadCrateInUnit(unit, crateType, commandHelper)
end end
end end
local unitConfig = MaxLoadConfig[unit:getTypeName()] local unitConfig = SupplyLoadConfig[unit:getTypeName()]
if unitConfig == nil then if unitConfig == nil then
trigger.action.outTextForUnit(unit:getID(), "Your unit type is not configured for logistics: " .. crateType, 5) trigger.action.outTextForUnit(unit:getID(), "Your unit type is not configured for logistics: " .. crateType, 5)
self._logger:error("Invalid unit type: " .. unit:getTypeName()) self._logger:error("Invalid unit type: " .. unit:getTypeName())
@@ -456,53 +468,234 @@ function SupplyUnitsTracker:UnitRequestCrateSpawn(groupID, crateType)
end end
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 ---@private
---@param unit Unit ---@param unit Unit
---@return Vec3 ---@param crateTypeName string
function SupplyUnitsTracker:GetCargoPlacePosition(unit) ---@return Vec3?
function SupplyUnitsTracker:GetCargoPlacePosition(unit, crateTypeName)
local pos = unit:getPosition() local unitPos = unit:getPosition()
local preferredPos = {
x = pos.p.x - 10 * pos.x.x, -- Get unit's heading from the forward vector (x component)
y = pos.p.y - 10 * pos.x.y, -- Heading is calculated as: atan2(forward.z, forward.x)
z = pos.p.z - 10 * pos.x.z 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 }
} }
return preferredPos 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
}
}
-- local volume = { local occupiedObjects = {}
-- id = world.VolumeType.SPHERE, local found = function(foundItem, val)
-- params = { local bbox = self:GetBoundingBoxes(foundItem)
-- point = preferredPos, if bbox then
-- radius = 10 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 occupiedPosX = {} local searchCategories = {}
-- local occupiedPosZ = {} for key, value in pairs(Object.Category) do
self._logger:debug("Adding category to search: " .. tostring(value) .. " (" .. tostring(key) .. ")")
table.insert(searchCategories, value)
end
-- ---@param foundItem Object ---@diagnostic disable-next-line: param-type-mismatch
-- local found = function(foundItem, val) world.searchObjects(searchCategories, searchVolume, found)
-- local foundPos = foundItem:getPoint() local safetyMargin = 3 -- Safety margin around objects
-- local z = math.floor(foundPos.z) -- Search through each slice
-- for i = z - 3 , z + 3 do for _, zone in ipairs(dropZones) do
-- occupiedPosZ[i] = true -- Calculate angle range for this slice
-- end local minAngle = zone.centerAngle - (zone.angleWidth / 2)
local maxAngle = zone.centerAngle + (zone.angleWidth / 2)
-- local x = math.floor(foundPos.x) -- Search outward in rings starting from minRadius
-- for i = x - 3 , x + 3 do for distance = zone.minRadius, zone.maxRadius, zone.spacing do
-- occupiedPosX[i] = true -- Check multiple positions within the angular slice
-- end local angleStep = math.min(15, zone.angleWidth / 3) -- Divide slice into sections
-- end
-- world.searchObjects(volume.id, volume.params, found) 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 })
-- 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
}
}
-- 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
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 end
return SupplyUnitsTracker return SupplyUnitsTracker