From f0aee49de2647b920a87b97d064928f4b88a0e35 Mon Sep 17 00:00:00 2001 From: dutchie031 Date: Sun, 14 Dec 2025 13:54:48 +0100 Subject: [PATCH] Briefing as location (#64) * Added briefing location as primary location coords * added pin briefing and briefing duration chagnes * updated command menu logic, sorting and naming --------- Co-authored-by: dutchie032 --- README.md | 99 +- classes/configuration/GlobalConfig.lua | 34 + classes/spearhead_base.lua | 2718 ++++++++--------- .../helpers/MissionCommandsHelper.lua | 1053 ++++--- .../missions/BuildableMission.lua | 2 +- .../missions/baseMissions/Mission.lua | 5 +- config.lua | 4 + 7 files changed, 2020 insertions(+), 1895 deletions(-) create mode 100644 classes/configuration/GlobalConfig.lua diff --git a/README.md b/README.md index ee94ccb..482b122 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,53 @@ -# Spearhead - - -## Contributing. - -I'm always happy to see contributions!
-Especially if you've found an issue, annoyance or feature that you know how to solve, fix or create!
- -### Keep it small. - -Keeping your contributions small and to the point helps both me and yourself.
-It'll help the PR review and the speed of improvements.
-If you have a big feature in mind? Go for it!
-Does it require an entire refactor of parts or all of the program? Maybe make sure to do step 0 first. - -### Steps (for first timers) -0. Reach out (Optional) - -If you want to let me know you want to do something to contribute, please do!
-Maybe I'm already working on the thing you wanted to build, or someone else is.
-Creating an issue is even better!
- - -1. Fork the repository - -By forking you can create your own working set of code.
-Whether you keep that fork public or private is up to you!
- -Keep in mind, forking is alright, but it's best with the intent to contribute back.
-After all, instead of having 5 slightly different versions and products, it might be nicer to have 1 much better version.
- -2. Create a Draft PR as soon as possible! - -As soon as you've found some times to create an initial version, please create a draft PR.
-That way you can let me and everyone know it's being worked on and people can see what conflicts might arise with their own changes.
- -3. Test, test, test - -With DCS and Lua there's a bunch of edge cases that are really hard to get to.
-However, you kinda need to make sure most if not all cases are caught and tested.
-Since automated testing for now doesn't seem feasible, please make sure to verify and test functionality after touching stuff.
- -4. Finalise and publish the PR. - -Finalise the PR and let the maintainers know!
-We can all have a look and discuss the changes.
+# Spearhead + + +## Contributing. + +I'm always happy to see contributions!
+Especially if you've found an issue, annoyance or feature that you know how to solve, fix or create!
+ +### Keep it small. + +Keeping your contributions small and to the point helps both me and yourself.
+It'll help the PR review and the speed of improvements.
+If you have a big feature in mind? Go for it!
+Does it require an entire refactor of parts or all of the program? Maybe make sure to do step 0 first. + +### Steps (for first timers) +0. Reach out (Optional) + +If you want to let me know you want to do something to contribute, please do!
+Maybe I'm already working on the thing you wanted to build, or someone else is.
+Creating an issue is even better!
+ + +1. Fork the repository + +By forking you can create your own working set of code.
+Whether you keep that fork public or private is up to you!
+ +Keep in mind, forking is alright, but it's best with the intent to contribute back.
+After all, instead of having 5 slightly different versions and products, it might be nicer to have 1 much better version.
+ +2. Create a Draft PR as soon as possible! + +As soon as you've found some times to create an initial version, please create a draft PR.
+That way you can let me and everyone know it's being worked on and people can see what conflicts might arise with their own changes.
+ +3. Test, test, test + +With DCS and Lua there's a bunch of edge cases that are really hard to get to.
+However, you kinda need to make sure most if not all cases are caught and tested.
+Since automated testing for now doesn't seem feasible, please make sure to verify and test functionality after touching stuff.
+ +4. Finalise and publish the PR. + +Finalise the PR and let the maintainers know!
+We can all have a look and discuss the changes.
+ + + + +# TODO: + +- Would it be possible to generate optional markers on mission briefing locations for Tomcat / Phantom navigation? Then Jester can type it in for the pilots automatically. diff --git a/classes/configuration/GlobalConfig.lua b/classes/configuration/GlobalConfig.lua new file mode 100644 index 0000000..011efe2 --- /dev/null +++ b/classes/configuration/GlobalConfig.lua @@ -0,0 +1,34 @@ + + + +local briefingMessageTime = nil + +---@class GlobalConfig +---@field private _briefingTime number ; +local GlobalConfig = {} +GlobalConfig.__index = GlobalConfig; + +---@return GlobalConfig +function GlobalConfig.New() + + local self = setmetatable({}, GlobalConfig) + + self._briefingTime = 30 + + if SpearheadConfig then + if SpearheadConfig.briefingMessageDuration then + self._briefingTime = SpearheadConfig.briefingMessageDuration + end + end + + return self +end + +function GlobalConfig:getBriefingTime() + return self._briefingTime or 30 +end + + +if Spearhead == nil then Spearhead = {} end +Spearhead.GlobalConfig = GlobalConfig.New() + diff --git a/classes/spearhead_base.lua b/classes/spearhead_base.lua index 8617ac2..3cb8336 100644 --- a/classes/spearhead_base.lua +++ b/classes/spearhead_base.lua @@ -1,1359 +1,1359 @@ ---- DEFAULT Values -if Spearhead == nil then Spearhead = {} end - -local UTIL = {} -do -- INIT UTIL - ---splits a string in sub parts by seperator - ---@param input string - ---@param seperator string - ---@return table result list of strings - function UTIL.split_string(input, seperator) - if seperator == nil then - seperator = " " - end - - local result = {} - if input == nil then - return result - end - - for str in string.gmatch(input, "[^" .. seperator .. "]+") do - table.insert(result, str) - end - return result - end - - ---comment - ---@param table any - ---@return number - function UTIL.tableLength(table) - if table == nil then return 0 end - - local count = 0 - for _ in pairs(table) do count = count + 1 end - return count - end - - ---@param orig table - ---@return table copy - function UTIL.deepCopyTable(orig) - local orig_type = type(orig) - local copy - if orig_type == 'table' then - copy = {} - for orig_key, orig_value in next, orig, nil do - copy[UTIL.deepCopyTable(orig_key)] = UTIL.deepCopyTable(orig_value) - end - setmetatable(copy, UTIL.deepCopyTable(getmetatable(orig))) - else -- number, string, boolean, etc - copy = orig - end - return copy - end - - ---Gets a random from the list - ---@param list Array - ---@return any @random element from the list - function UTIL.randomFromList(list) - local max = #list - - if max == 0 or max == nil then - return nil - end - - local random = math.random(0, max) - if random == 0 then random = 1 end - - return list[random] - end - - ---@param list Array - ---@param start number start - ---@param n number length - ---@return Array - function UTIL.sublist(list, start, n) - local result = {} - for i = start, n do - result[#result + 1] = list[i] - end - return result - end - - ---@param str string - ---@param find string - ---@param replace string - ---@return string - function UTIL.replaceString(str, find, replace) - if str == nil then return "" end - if find == nil or replace == nil then return str end - - local result = str:gsub(find, replace) - return result - end - - local function table_print(tt, indent, done) - done = done or {} - indent = indent or 0 - if type(tt) == "table" then - local sb = {} - for key, value in pairs(tt) do - table.insert(sb, string.rep(" ", indent)) -- indent it - if type(value) == "table" and not done[value] then - done[value] = true - table.insert(sb, "[\"" ..key .. "\"]" .. " = {\n"); - table.insert(sb, table_print(value, indent + 2, done)) - table.insert(sb, string.rep(" ", indent)) -- indent it - table.insert(sb, "},\n"); - elseif "number" == type(key) then - table.insert(sb, string.format("\"%s\",\n", tostring(value))) - else - table.insert(sb, string.format( - "[\"%s\"] = \"%s\",\n", tostring(key), tostring(value))) - end - end - return table.concat(sb) - else - return tt .. "\n" - end - end - - ---comment - ---@param str string - ---@param findable string - ---@param ignoreCase boolean? - ---@return boolean - UTIL.startswith = function(str, findable, ignoreCase) - if ignoreCase == true then - return string.lower(str):find('^' .. string.lower(findable)) ~= nil - end - - return str:find('^' .. findable) ~= nil - end - - ---comment - ---@param str string - ---@param findable string - ---@return boolean - UTIL.strContains = function(str, findable) - return str:find(findable) ~= nil - end - - ---comment - ---@param str string - ---@param findableTable table - ---@return boolean - UTIL.startswithAny = function(str, findableTable) - for key, value in pairs(findableTable) do - if type(value) == "string" and UTIL.startswith(str, value) then return true end - end - return false - end - - function UTIL.toString(something) - if something == nil then - return "nil" - elseif "table" == type(something) then - return table_print(something) - elseif "string" == type(something) then - return something - else - return tostring(something) - end - end - - ---comment - ---@param a Vec2 - ---@param b Vec2 - ---@return number - function UTIL.VectorDistance2d(a, b) - return math.sqrt((b.x - a.x) ^ 2 + (b.y - a.y) ^ 2) - end - - ---comment - ---@param a Vec3 - ---@param b Vec3 - ---@return number - function UTIL.VectorDistance3d(a, b) - return UTIL.vectorMagnitude({ x = a.x - b.x, y = a.y - b.y, z = a.z - b.z }) - end - - ---comment - ---@param vec Vec3 - ---@return number - function UTIL.vectorMagnitude(vec) - return (vec.x ^ 2 + vec.y ^ 2 + vec.z ^ 2) ^ 0.5 - end - - ---@param vec Vec3 - ---@return Vec3 - function UTIL.vectorNormalize(vec) - local magnitude = UTIL.vectorMagnitude(vec) - if magnitude == 0 then - return { x = 0, y = 0, z = 0 } - end - return { x = vec.x / magnitude, y = vec.y / magnitude, z = vec.z / magnitude } - end - - ---@param vec Vec2 - ---@param direction number @in degrees - ---@param distance number - ---@return Vec2 - function UTIL.vectorMove(vec, direction, distance) - local rad = math.rad(direction) - local x = vec.x + (math.cos(rad) * distance) - local y = vec.y + (math.sin(rad) * distance) - - return { x = x, y = y} - end - - ---comment - ---@param vec1 Vec2 - ---@param vec2 Vec2 - ---@return number in degrees - function UTIL.vectorHeadingFromTo(vec1, vec2) - local dx = vec2.x - vec1.x - local dy = vec2.y - vec1.y - local heading = math.deg(math.atan2(dy, dx)) - if heading < 0 then - heading = heading + 360 - end - return heading - end - - - - ---@param vec1 Vec3 - ---@param vec2 Vec3 - ---@return number alignment a number in range [-1,1]. > 0 same direction. < 0 opposite direction - function UTIL.vectorAlignment(vec1, vec2) - local vec1Norm = UTIL.vectorNormalize(vec1) - local vec2Norm = UTIL.vectorNormalize(vec2) - - return ((vec1Norm.x * vec2Norm.x) + (vec1Norm.y * vec2Norm.y) + (vec1Norm.z * vec2Norm.z)) - end - - local function isInComplexPolygon(polygon, x, y) - local function getEdges(poly) - local result = {} - for i = 1, #poly do - local point1 = poly[i] - local point2Index = i + 1 - if point2Index > #poly then point2Index = 1 end - local point2 = poly[point2Index] - local edge = { x1 = point1.x, z1 = point1.y, x2 = point2.x, z2 = point2.y } - table.insert(result, edge) - end - return result - end - - local edges = getEdges(polygon) - local count = 0; - for _, edge in pairs(edges) do - if (x < edge.x1) ~= (x < edge.x2) and y < edge.z1 + ((x - edge.x1) / (edge.x2 - edge.x1)) * (edge.z2 - edge.z1) then - count = count + 1 - -- if (yp < y1) != (yp < y2) and xp < x1 + ((yp-y1)/(y2-y1))*(x2-x1) then - -- count = count + 1 - end - end - return count % 2 == 1 - end - - ---comment - ---@param polygon Array of pairs { x, y } - ---@param x number X location - ---@param y number Y location - ---@return boolean - function UTIL.IsPointInPolygon(polygon, x, y) - return isInComplexPolygon(polygon, x, y) - end - - ---@param point Vec3 - ---@param zone SpearheadTriggerZone - function UTIL.is3dPointInZone(point, zone) - if zone.zone_type == "Polygon" and zone.verts then - if UTIL.IsPointInPolygon(zone.verts, point.x, point.z) == true then - return true - end - else - if (((point.x - zone.location.x) ^ 2 + (point.z - zone.location.y) ^ 2) ^ 0.5 <= zone.radius) then - return true - end - end - - return false - end - - ---@param point Vec2 - ---@param zone SpearheadTriggerZone - function UTIL.is2dPointInZone(point, zone) - if zone.zone_type == "Polygon" and zone.verts then - if UTIL.IsPointInPolygon(zone.verts, point.x, point.y) == true then - return true - end - else - if (((point.x - zone.location.x) ^ 2 + (point.y - zone.location.y) ^ 2) ^ 0.5 <= zone.radius) then - return true - end - end - - return false - end - - ---comment - ---@param points Array points - ---@return Array hullPoints - function UTIL.getConvexHull(points) - if #points == 0 then - return {} - end - - ---comment - ---@param a Vec2 - ---@param b Vec2 - ---@param c Vec2 - ---@return boolean - local function ccw(a, b, c) - return (b.y - a.y) * (c.x - a.x) > (b.x - a.x) * (c.y - a.y) - end - - table.sort(points, function(left, right) - return left.y < right.y - end) - - local hull = {} - -- lower hull - for _, point in pairs(points) do - while #hull >= 2 and not ccw(hull[#hull - 1], hull[#hull], point) do - table.remove(hull, #hull) - end - table.insert(hull, point) - end - - -- upper hull - local t = #hull + 1 - for i = #points, 1, -1 do - local point = points[i] - while #hull >= t and not ccw(hull[#hull - 1], hull[#hull], point) do - table.remove(hull, #hull) - end - table.insert(hull, point) - end - table.remove(hull, #hull) - return hull - end - - ---Splits points into clusters with at least minSeparation between clusters, returns convex hull for each cluster - ---@param points Array - ---@param minSeparation number - ---@return Array> hulls - function UTIL.getSeparatedConvexHulls(points, minSeparation) - if #points == 0 then return {} end - - -- Simple clustering: group points that are within minSeparation of each other - local clusters = {} - local assigned = {} - - for i, p in ipairs(points) do - if not assigned[i] then - local cluster = { p } - assigned[i] = true - -- Find all points close to p (BFS) - local queue = { i } - while #queue > 0 do - local idx = table.remove(queue) - local base = points[idx] - for j, q in ipairs(points) do - if not assigned[j] then - local dx = base.x - q.x - local dy = base.y - q.y - if (dx * dx + dy * dy) <= (minSeparation * minSeparation) then - table.insert(cluster, q) - assigned[j] = true - table.insert(queue, j) - end - end - end - end - table.insert(clusters, cluster) - end - end - - -- Compute convex hull for each cluster - local hulls = {} - for _, cluster in ipairs(clusters) do - local hull = UTIL.getConvexHull(cluster) - if #hull > 0 then - table.insert(hulls, hull) - end - end - - return hulls - end - - ---@param points Array - function UTIL.enlargeConvexHull(points, meters) - if points == nil or #points == 0 then - return {} - end - - ---@type Array - local allpoints = {} - - for _, point in pairs(points) do - table.insert(allpoints, point) - - allpoints[#allpoints + 1] = point - allpoints[#allpoints+1] = { x = point.x + meters, y = point.y, } - allpoints[#allpoints+1] = { x = point.x - meters, y = point.y, } - allpoints[#allpoints+1] = { x = point.x, y = point.y + meters, } - allpoints[#allpoints+1] = { x = point.x, y = point.y - meters, } - - allpoints[#allpoints+1] = { x = point.x + math.cos(math.rad(45)) * meters, y = point.y + math.sin(math.rad(45)) * meters, } - allpoints[#allpoints+1] = { x = point.x - math.cos(math.rad(45)) * meters, y = point.y - math.sin(math.rad(45)) * meters, } - allpoints[#allpoints+1] = { x = point.x - math.cos(math.rad(45)) * meters, y = point.y + math.sin(math.rad(45)) * meters, } - allpoints[#allpoints+1] = { x = point.x + math.cos(math.rad(45)) * meters, y = point.y - math.sin(math.rad(45)) * meters, } - end - - return UTIL.getConvexHull(allpoints) - end - - ---Returns hull points visible from origin (not blocked by hull edges) ----@param hull Array ----@param origin Vec2 ----@return Array -function UTIL.GetVisibleHullPointsFromOrigin(hull, origin) - local function segmentsIntersect(a, b, c, d) - -- Helper: returns true if segment ab intersects cd (excluding endpoints) - local function ccw(p1, p2, p3) - return (p3.y - p1.y) * (p2.x - p1.x) > (p2.y - p1.y) * (p3.x - p1.x) - end - return (ccw(a, c, d) ~= ccw(b, c, d)) and (ccw(a, b, c) ~= ccw(a, b, d)) - end - - local n = #hull - local visible = {} - - for i = 1, n do - local p = hull[i] - local isVisible = true - - -- Check against all hull edges except those incident to p - for j = 1, n do - local a = hull[j] - local b = hull[(j % n) + 1] - -- skip edges incident to p - if (a ~= p and b ~= p) then - if segmentsIntersect(origin, p, a, b) then - isVisible = false - break - end - end - end - - if isVisible then - table.insert(visible, p) - end - end - - return visible -end - ----Returns the two tangent points ("scratch points") from origin to the convex hull ----@param hull Array ----@param origin Vec2 ----@return Array -function UTIL.GetTangentHullPointsFromOrigin(hull, origin) - - if hull == nil or #hull <= 0 then - return {} - end - - local function orientation(a, b, c) - -- Returns >0 if c is to the left of ab, <0 if to the right, 0 if colinear - return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) - end - - local n = #hull - if n == 0 then return {} end - if n == 1 then return { hull[1] } end - - -- Find left tangent: the hull point where all other points are to the right of the line from origin to that point - local function findTangent(isLeft) - local best = 1 - for i = 2, n do - local o = orientation(origin, hull[best], hull[i]) - if (isLeft and o < 0) or (not isLeft and o > 0) then - best = i - end - end - return hull[best] - end - - local leftTangent = findTangent(true) - local rightTangent = findTangent(false) - - -- If tangents are the same (can happen if origin is colinear), only return one - if leftTangent.x == rightTangent.x and leftTangent.y == rightTangent.y then - return { leftTangent } - else - return { leftTangent, rightTangent } - end -end - -end -Spearhead.Util = UTIL - ----DCS UTIL Takes inspiration from MIST but only takes the things it needs, changes for DCS updates and different vision for advanced mission scripting stuff. ----It also adds functions that make the other TDCS scripts easier without taking too much "control" away like MOOSE can sometimes. -local DCS_UTIL = {} -do -- INIT DCS_UTIL - do -- local databases - --[[ - groupdata = { - category, - country_id, - group_template - } - ]] -- - - --[[ - zone = { - name, - - zone_type, - x, - y, - radius - verts, - - } - ]] -- - - ---@alias SpearheadTriggerZoneType - ---| "Cilinder" - ---| "Polygon" - - ---@class SpearheadTriggerZone - ---@field name string - ---@field location Vec2 - ---@field radius number - ---@field verts Array - ---@field zone_type SpearheadTriggerZoneType - ---@field properties Array? - - ---@type Array - DCS_UTIL.__trigger_zones = {} - end - - DCS_UTIL.Coalition = - { - NEUTRAL = 0, - RED = 1, - BLUE = 2 - } - - DCS_UTIL.ZoneType = { - Cilinder = 0, - Polygon = 2 - } - - ---@enum SpearheadGroupCategory - DCS_UTIL.GroupCategory = { - AIRPLANE = 0, - HELICOPTER = 1, - GROUND = 2, - SHIP = 3, - TRAIN = 4, - STATIC = 5 --CUSTOM CATEGORY - } - - DCS_UTIL.__airbaseNamesById = {} - - ---@type table - DCS_UTIL.__airbaseZonesByName = {} - - DCS_UTIL.__airportsStartingCoalition = {} - DCS_UTIL.__warehouseStartingCoalition = {} - function DCS_UTIL.__INIT() - do -- INITS ALL TABLES WITH DATA THAT's from the MIZ environment - - do --init trigger zones - for i, trigger_zone in pairs(env.mission.triggers.zones) do - -- reorder verts as they are not ordered correctly in the ME - local verts = {} - if Spearhead.Util.tableLength(trigger_zone.verticies) >= 4 then - table.insert(verts, { x = trigger_zone.verticies[4].x, y = trigger_zone.verticies[4].y }) - table.insert(verts, { x = trigger_zone.verticies[3].x, y = trigger_zone.verticies[3].y }) - table.insert(verts, { x = trigger_zone.verticies[2].x, y = trigger_zone.verticies[2].y }) - table.insert(verts, { x = trigger_zone.verticies[1].x, y = trigger_zone.verticies[1].y }) - end - - local zoneType = "Cilinder" - if trigger_zone.type == DCS_UTIL.ZoneType.Polygon then - zoneType = "Polygon" - end - - ---@type SpearheadTriggerZone - local zone = { - name = trigger_zone.name, - zone_type = zoneType, - location = { x = trigger_zone.x, y = trigger_zone.y }, - radius = trigger_zone.radius, - verts = verts, - properties = {} - } - - if trigger_zone.properties then - for _, kvPair in pairs(trigger_zone.properties) do - local key = kvPair["key"] - local value = kvPair["value"] - zone.properties[#zone.properties + 1] = { key = key, value = value } - end - end - - DCS_UTIL.__trigger_zones[zone.name] = zone - end - end - - do -- init airports and warehouses - if env.warehouses.airports then - for warehouse_id, value in pairs(env.warehouses.airports) do - if warehouse_id ~= nil then - warehouse_id = tostring(warehouse_id) or "nil" - local coalitionNumber = DCS_UTIL.stringToCoalition(value.coalition) - DCS_UTIL.__airportsStartingCoalition[warehouse_id] = coalitionNumber - end - end - end - - if env.warehouses.warehouses then - DCS_UTIL.__warehouseStartingCoalition[-1] = "placeholder" - for warehouse_id, value in pairs(env.warehouses.warehouses) do - if warehouse_id ~= nil then - warehouse_id = tostring(warehouse_id) or "nil" - local coalitionNumber = DCS_UTIL.stringToCoalition(value.coalition) - DCS_UTIL.__warehouseStartingCoalition[warehouse_id] = coalitionNumber - end - end - end - end - - do -- fill airbaseNames and zones - local airbases = world.getAirbases() - if airbases then - for _, airbase in pairs(airbases) do - local name = airbase:getName() - - airbase:autoCapture(false) - - DCS_UTIL.__airbaseNamesById[tostring(airbase:getID())] = name - - if name then - ---@type Array - local relevantPoints = {} - for _, x in pairs(airbase:getRunways()) do - if x.position and x.position.x and x.position.z then - table.insert(relevantPoints, { x = x.position.x, y = x.position.z }) - end - end - - for _, x in pairs(airbase:getParking()) do - if x.vTerminalPos and x.vTerminalPos.x and x.vTerminalPos.z then - table.insert(relevantPoints, { x = x.vTerminalPos.x, y = x.vTerminalPos.z }) - end - end - - local points = UTIL.getConvexHull(relevantPoints) - local enlargedPoints = UTIL.enlargeConvexHull(points, 750) - - local triggerZone = { - name = name, - location = { x = airbase:getPoint().x, y = airbase:getPoint().z }, - zone_type = "Polygon", - radius = 0, - verts = enlargedPoints - } - - if SpearheadConfig and SpearheadConfig.debugEnabled == true then - DCS_UTIL.DrawZone(triggerZone, { r = 0, g = 1, b = 0, a = 1 }, { a = 0, r = 0, g = 1, b = 0 }, 1) - end - - DCS_UTIL.__airbaseZonesByName[name] = triggerZone - end - end - end - end - end - end - - ---maps the coalition name to the DCS coalition integer - ---@param input string the name - ---@return integer - function DCS_UTIL.stringToCoalition(input) - --[[ - coalition.side = { - NEUTRAL = 0 - RED = 1 - BLUE = 2 - } - ]] -- - local input = string.lower(input) - if input == 'neutrals' or input == "neutral" or input == "0" then - return DCS_UTIL.Coalition.NEUTRAL - end - - if input == 'red' or input == "1" then - return DCS_UTIL.Coalition.RED - end - - if input == 'blue' or input == "2" then - return DCS_UTIL.Coalition.BLUE - end - - return -1 - end - - - ---destroy the given unit - ---@param unitName string - function DCS_UTIL.DestroyUnit(unitName) - local unit = Unit.getByName(unitName) - if unit and unit:isExist() then - unit:destroy() - end - end - - --- takes a list of units and returns all the units that are in any of the zones - ---@param unit_names table unit names - ---@param zone_names table zone names - ---@return table unit list of objects { unit = UNIT, zone_name = zoneName} - function DCS_UTIL.getUnitsInZones(unit_names, zone_names) - local units = {} - - ---@type Array - local zones = {} - - for k = 1, #unit_names do - local unit = Unit.getByName(unit_names[k]) or StaticObject.getByName(unit_names[k]) - if unit and unit:isExist() == true then - units[#units + 1] = unit - end - end - - for index, zone_name in pairs(zone_names) do - local zone = DCS_UTIL.__trigger_zones[zone_name] - if zone then - zones[#zones + 1] = zone - end - end - - local in_zone_units = {} - for units_ind = 1, #units do - local lUnit = units[units_ind] - local unit_pos = lUnit:getPosition().p - local lCat = Object.getCategory(lUnit) - for zone_name, zone in pairs(zones) do - if unit_pos and ((lCat == 1 and lUnit:isActive() == true) or lCat ~= 1) then -- it is a unit and is active or it is not a unit - local isInZone = UTIL.is3dPointInZone(unit_pos, zone) - if isInZone == true then - in_zone_units[#in_zone_units + 1] = { unit = lUnit, zone_name = zone.name } - end - end - end - end - return in_zone_units - end - - --- takes a list of groups and returns all the group leaders that are in any of the zones - ---@param group_names table unit names - ---@param zone_name string zone names - ---@return table groupnames list of group names - function DCS_UTIL.getGroupsInZone(group_names, zone_name) - local zone = DCS_UTIL.__trigger_zones[zone_name] - if zone == nil then - return {} - end - - return DCS_UTIL.areGroupsInCustomZone(group_names, zone) - end - - --- takes a x, y poistion and checks if it is inside any of the zones - ---@param group_names Array North South position - ---@param zone SpearheadTriggerZone - ---@return Array groupnames list of groups that are in the zone - function DCS_UTIL.areGroupsInCustomZone(group_names, zone) - local units = {} - if Spearhead.Util.tableLength(group_names) < 1 then return {} end - - for k = 1, #group_names do - local entry = nil - local group = Group.getByName(group_names[k]) - if group ~= nil then - entry = { unit = group:getUnit(1), groupname = group_names[k] } - else - entry = { unit = StaticObject.getByName(group_names[k]), groupname = group_names[k] } - end - - if entry and entry.unit and entry.unit:isExist() == true then - units[#units + 1] = entry - end - end - - local result_groups = {} - for _, entry in pairs(units) do - local pos = entry.unit:getPoint() - local isInZone = UTIL.is3dPointInZone(pos, zone) - if isInZone == true then - table.insert(result_groups, entry.groupname) - end - end - return result_groups - end - - --- takes a x, y poistion and checks if it is inside any of the zones - ---@param x number North South position - ---@param z number West East position - ---@param zone_names table zone names - ---@return table zones list of objects { zone_name = zoneName} - function DCS_UTIL.isPositionInZones(x, z, zone_names) - ---@type Array - local zones = {} - for index, zone_name in pairs(zone_names) do - local zone = DCS_UTIL.__trigger_zones[zone_name] - if zone then - zones[#zones + 1] = zone - end - end - - local result_zones = {} - for zone_name, zone in pairs(zones) do - if UTIL.is3dPointInZone({ x = x, z = z, y = 0 }, zone) == true then - result_zones[#result_zones + 1] = zone.name - end - end - return result_zones - end - - --- takes a x, y poistion and checks if it is inside any of the zones - ---@param x number North South position - ---@param z number West East position - ---@param zone_name string zone name - ---@return boolean result - function DCS_UTIL.isPositionInZone(x, z, zone_name) - local zone = DCS_UTIL.__trigger_zones[zone_name] - if UTIL.is3dPointInZone({ x = x, y = 0, z = z }, zone) then - return true - end - return false - end - - --- takes a x, y poistion and checks if it is inside any of the zones - ---@param zone_name string - ---@param parent_zone_name string - ---@return boolean result - function DCS_UTIL.isZoneInZone(zone_name, parent_zone_name) - local zoneA = DCS_UTIL.__trigger_zones[zone_name] --[[@as SpearheadTriggerZone]] - if zoneA == nil then return false end - local zoneB = DCS_UTIL.__trigger_zones[parent_zone_name] --[[@as SpearheadTriggerZone]] - if zoneB == nil then return false end - return UTIL.is3dPointInZone({ x = zoneA.location.x, y = 0, z = zoneA.location.y }, zoneB) - end - - ---comment - ---@param zone_name any - ---@return SpearheadTriggerZone? zone { name,b zone_type, x, z, radius, verts } - function DCS_UTIL.getZoneByName(zone_name) - if zone_name == nil then return nil end - return DCS_UTIL.__trigger_zones[zone_name] - end - - ---comment - ---@param airbaseName string - ---@return SpearheadTriggerZone? zone - function DCS_UTIL.getAirbaseZoneByName(airbaseName) - if airbaseName == nil then return nil end - return DCS_UTIL.__airbaseZonesByName[airbaseName] - end - - ---maps the category name to the DCS group category - ---@param input string the name - ---@return integer? - function DCS_UTIL.stringToGroupCategory(input) - input = string.lower(input) - if input == 'airplane' or input == 'plane' then - return DCS_UTIL.GroupCategory.AIRPLANE - end - if input == 'helicopter' then - return DCS_UTIL.GroupCategory.HELICOPTER - end - if input == 'ground' or input == 'vehicle' then - return DCS_UTIL.GroupCategory.GROUND - end - if input == 'ship' then - return DCS_UTIL.GroupCategory.SHIP - end - if input == 'train' then - return DCS_UTIL.GroupCategory.TRAIN - end - if input == "static" then - return DCS_UTIL.GroupCategory.STATIC - end - return nil; - end - - ---@type table - local config = - { - ["ah-64d_blk_ii"] = "MGRS", - ["fa-18c_hornet"] = "DMS", - ["av8bna"] = "DMS", - ["f-14b"] = "DMS", - ["f-14a-135-gr"] = "DMS" - } - - - ---@param location Vec2 - ---@param unitType string - function DCS_UTIL.convertVec2ToUnitUsableType(location, unitType) - - local height = land.getHeight(location) - local vec3 = { x = location.x, y = height, z = location.y } - - local unitType = string.lower(unitType or "") - local conversionType = config[unitType] - - if not conversionType then conversionType = "DDM" end - return DCS_UTIL.convertToDisplayCoord(vec3, conversionType) - end - - ---@alias CoordType - ---| "MGRS" MGRS - ---| "DMS" DECIMAL SECONDS - ---| "DDM" DECIMAL MINUTES - - ---@private - ---@param location Vec3 - ---@param coordType CoordType - function DCS_UTIL.convertToDisplayCoord(location, coordType) - local lattitude, longitude, altitude = coord.LOtoLL(location) - - if coordType == "MGRS" then - local mgrs = coord.LLtoMGRS(lattitude, longitude) - return string.format("%s %s %s %s", mgrs.UTMZone, mgrs.MGRSDigraph, mgrs.Easting, mgrs.Northing) - end - - -- Convert DD to DDM (Degrees Decimal Minutes) - local function dd_to_ddm(dd) - local degrees = math.floor(math.abs(dd)) - local minutes = (math.abs(dd) - degrees) * 60 - local sign = dd >= 0 and 1 or -1 - return degrees * sign, minutes - end - - local lat_deg, lat_min = dd_to_ddm(lattitude) - local lon_deg, lon_min = dd_to_ddm(longitude) - - local lat_hemisphere = lattitude >= 0 and "N" or "S" - local lon_hemisphere = longitude >= 0 and "E" or "W" - - if coordType == "DDM" then - return string.format("%s%02d°%06.3f' %s%03d°%06.3f' %dft", - lat_hemisphere, math.abs(lat_deg), lat_min, - lon_hemisphere, math.abs(lon_deg), lon_min, - altitude * 3,28084) - end - - if coordType == "DMS" then - - local lat_min_display = math.floor(lat_min) - local lon_min_display = math.floor(lon_min) - - local lat_sec_display = math.floor((lat_min - lat_min_display) * 60) - local lon_sec_display = math.floor((lon_min - lon_min_display) * 60) - - return string.format("%s%02d°%02d'%02d %s%03d°%02d'%02d %dft", - lat_hemisphere, math.abs(lat_deg), lat_min_display, lat_sec_display, - lon_hemisphere, math.abs(lon_deg), lon_min_display, lon_sec_display, - altitude * 3,28084) - end - - end - - ---@param group Group - function DCS_UTIL.getUnitTypeFromGroup(group) - for _, unit in pairs(group:getUnits()) do - if unit and unit:isExist() then - return unit:getTypeName() - end - end - end - - - ---comment Get all units that are players - ---@return Array units - function DCS_UTIL.getAllPlayerUnits() - local units = {} - for i = 0, 2 do - local players = coalition.getPlayers(i) - for key, unit in pairs(players) do - units[#units + 1] = unit - end - end - return units - end - - ---get base name from ID - ---@param baseId number - ---@return string? name - function DCS_UTIL.getAirbaseName(baseId) - local stringified = tostring(baseId) - return DCS_UTIL.__airbaseNamesById[stringified] - end - - ---get base from id - ---@param baseId number - ---@return Airbase? table - function DCS_UTIL.getAirbaseById(baseId) - local name = DCS_UTIL.getAirbaseName(baseId) - if name == nil then return nil end - return Airbase.getByName(name) - end - - ---Get the starting coalition of a farp or airbase - ---@param airbase Airbase - ---@return number? coalition - function DCS_UTIL.getStartingCoalition(airbase) - if airbase == nil then - return nil - end - - --STRING based dictionary otherwise it'll be a string/collapsed array - local baseId = tostring(airbase:getID()) - - local result = DCS_UTIL.__airportsStartingCoalition[baseId] - if result == nil then - result = DCS_UTIL.__warehouseStartingCoalition[baseId] - end - return result - end - - function DCS_UTIL.CleanCorpse(unitName) - local unitName = "dead_" .. unitName - - local object = StaticObject.getByName(unitName) - - if object then - object:destroy() - end - end - - ---@class DrawColor - ---@field r number - ---@field g number - ---@field b number - ---@field a number - - local drawID = 400 - ---@param zone SpearheadTriggerZone - ---@param lineColor DrawColor - ---@param fillColor DrawColor - ---@param lineStyle LineType - ---@return number drawID - function DCS_UTIL.DrawZone(zone, lineColor, fillColor, lineStyle) - if lineStyle == nil then lineStyle = 4 end - drawID = drawID + 1 - if zone.zone_type == "Cilinder" then - trigger.action.circleToAll(-1, drawID, { x = zone.location.x, y = 0, z = zone.location.y }, zone.radius, - { 0, 0, 0, 0 }, { 0, 0, 0, 0 }, lineStyle, true) - else - local functionString = "trigger.action.markupToAll(7, -1, " .. drawID .. "," - for _, vecpoint in pairs(zone.verts) do - functionString = functionString .. " { x=" .. vecpoint.x .. ", y=0,z=" .. vecpoint.y .. "}," - end - functionString = functionString .. "{0,1,0,1}, {0,1,0,1}, " .. lineStyle .. ")" - - ---@diagnostic disable-next-line: deprecated - local f, err = loadstring(functionString) - if f then - f() - else - env.error("Something failed when drawing complex drawing" .. err) - end - end - local fillColorMapped = { - fillColor.r or 0, - fillColor.g or 0, - fillColor.b or 0, - fillColor.a or 0.5 - } - - local lineColorMapped = { - lineColor.r or 0, - lineColor.g or 0, - lineColor.b or 0, - lineColor.a or 1 - } - - trigger.action.setMarkupColorFill(drawID, fillColorMapped) - trigger.action.setMarkupColor(drawID, lineColorMapped) - - return drawID - end - - ---@param start Vec3 - ---@param finish Vec3 - ---@param lineColor DrawColor - ---@param lineStyle LineType - function DCS_UTIL.DrawLine(start, finish, lineColor, lineStyle) - if lineStyle == nil then lineStyle = 4 end - drawID = drawID + 1 - - local lineColorMapped = { - lineColor.r or 0, - lineColor.g or 0, - lineColor.b or 0, - lineColor.a or 1 - } - - trigger.action.lineToAll(-1, drawID, start, finish, lineColorMapped, lineStyle) - return drawID - end - - ---@param groupID number - ---@param text string - ---@param location Vec3 - ---@return number markID - function DCS_UTIL.AddMarkToGroup(groupID, text, location) - drawID = drawID + 1 - trigger.action.markToGroup(drawID, text, location, groupID, true, nil) - return drawID - end - - ---comment - ---@param text any - ---@param location Vec3 - ---@return integer - function DCS_UTIL.AddMarkToAll(text, location) - drawID = drawID + 1 - trigger.action.markToAll(drawID, text, location, true, nil) - return drawID - end - - ---@param markId number - function DCS_UTIL.RemoveMark(markId) - if markId ~= nil then - trigger.action.removeMark(markId) - end - end - - ---comment - ---@param drawID number - ---@param lineColor DrawColor - function DCS_UTIL.SetLineColor(drawID, lineColor) - local lineColorMapped = { - lineColor.r or 0, - lineColor.g or 0, - lineColor.b or 0, - lineColor.a or 1 - } - trigger.action.setMarkupColor(drawID, lineColorMapped) - end - - ---comment - ---@param drawID number - ---@param fillColor DrawColor - function DCS_UTIL.SetFillColor(drawID, fillColor) - local lineColorMapped = { - fillColor.r or 0, - fillColor.g or 0, - fillColor.b or 0, - fillColor.a or 1 - } - trigger.action.setMarkupColorFill(drawID, lineColorMapped) - end - - function DCS_UTIL.RemoveZoneDraw(drawID) - if drawID ~= nil then - trigger.action.removeMark(drawID) - end - end - - - ---@return number? id - function DCS_UTIL.GetNeutralCountry() - for name, id in pairs(country.id) do - if coalition.getCountryCoalition(id) == DCS_UTIL.Coalition.NEUTRAL then - return id - end - end - end - - - function DCS_UTIL.NeedsRTBInTen(groupName, fuelOffset) - - local isBingo = DCS_UTIL.IsBingoFuel(groupName, fuelOffset) - if isBingo then return true end - - local aliveUnits = 0 - local group = Group.getByName(groupName) - if group then - for _ , unit in pairs(group:getUnits()) do - if unit and unit:isExist() == true and unit:inAir() == true then - aliveUnits = aliveUnits + 1 - end - end - - if aliveUnits / group:getInitialSize() <= 0.5 then - return true - end - end - - return false - end - - ---@return boolean - function DCS_UTIL.IsBingoFuel(groupName, offset) - if offset == nil then offset = 0 end - local bingoSetting = 0.20 - bingoSetting = bingoSetting + offset - - local group = Group.getByName(groupName) - if group then - for _, unit in pairs(group:getUnits()) do - if unit and unit:isExist() == true and unit:inAir() == true and unit:getFuel() < bingoSetting then - return true - end - end - end - - return false - end - - ---comment - ---@param groupId number - ---@return Group? - function DCS_UTIL.GetPlayerGroupByGroupID(groupId) - for i = 0, 2 do - local players = coalition.getPlayers(i) - for key, unit in pairs(players) do - if unit and unit:isExist() == true then - local group = unit:getGroup() - if group and group:getID() == groupId then - return group - end - end - end - end - end - - ---@param unitID number - ---@return Unit? - function DCS_UTIL.GetPLayerUnitByID(unitID) - for i = 0, 2 do - local players = coalition.getPlayers(i) - for key, unit in pairs(players) do - if unit and unit:getID() == unitID then - return unit - end - end - end - end - - DCS_UTIL.__INIT(); -end -Spearhead.DcsUtil = DCS_UTIL - ---- @class Logger ---- @field LoggerName string the name of the logger ---- @field LogLevel string the log level of the logger -local LOGGER = {} -do - local PreFix = "Spearhead" - - ---comment - ---@param logger_name any - ---@param logLevel LogLevel - ---@return Logger - function LOGGER.new(logger_name, logLevel) - LOGGER.__index = LOGGER - local self = setmetatable({}, LOGGER) - self.LoggerName = logger_name or "(loggername not set)" - self.LogLevel = logLevel or "INFO" - - return self - end - - ---@param message any the message - function LOGGER:info(message) - if message == nil then - return - end - message = UTIL.toString(message) - - if self.LogLevel == "INFO" or self.LogLevel == "DEBUG" then - env.info("[" .. PreFix .. "]" .. "[" .. self.LoggerName .. "] " .. message) - end - end - - ---comment - ---@param message string - function LOGGER:warn(message) - if message == nil then - return - end - message = UTIL.toString(message) - - if self.LogLevel == "INFO" or self.LogLevel == "DEBUG" or self.LogLevel == "WARN" then - env.warning("[" .. PreFix .. "]" .. "[" .. self.LoggerName .. "] " .. message) - end - end - - ---@param message any -- the message - function LOGGER:error(message) - if message == nil then - return - end - - message = UTIL.toString(message) - - if self.LogLevel == "INFO" or self.LogLevel == "DEBUG" or self.LogLevel == "WARN" or self.LogLevel == "ERROR" then - env.error("[" .. PreFix .. "]" .. "[" .. self.LoggerName .. "] " .. message) - end - end - - ---@param message any the message - function LOGGER:debug(message) - if message == nil then - return - end - - message = UTIL.toString(message) - if self.LogLevel == "DEBUG" then - env.info("[" .. PreFix .. "]" .. "[" .. self.LoggerName .. "][DEBUG] " .. message) - end - end -end -Spearhead.LoggerTemplate = LOGGER - -Spearhead.MissionEditingWarnings = {} -function Spearhead.AddMissionEditorWarning(warningMessage) - table.insert(Spearhead.MissionEditingWarnings, warningMessage or "skip") -end - -local loadDone = false -Spearhead.LoadingDone = function() - if loadDone == true then - return - end - - local warningLogger = Spearhead.LoggerTemplate.new("MISSIONPARSER", "INFO") - if Spearhead.Util.tableLength(Spearhead.MissionEditingWarnings) > 0 then - for key, message in pairs(Spearhead.MissionEditingWarnings) do - warningLogger:warn(message) - end - else - warningLogger:info("No issues detected") - end - - loadDone = true -end +--- DEFAULT Values +if Spearhead == nil then Spearhead = {} end + +local UTIL = {} +do -- INIT UTIL + ---splits a string in sub parts by seperator + ---@param input string + ---@param seperator string + ---@return table result list of strings + function UTIL.split_string(input, seperator) + if seperator == nil then + seperator = " " + end + + local result = {} + if input == nil then + return result + end + + for str in string.gmatch(input, "[^" .. seperator .. "]+") do + table.insert(result, str) + end + return result + end + + ---comment + ---@param table any + ---@return number + function UTIL.tableLength(table) + if table == nil then return 0 end + + local count = 0 + for _ in pairs(table) do count = count + 1 end + return count + end + + ---@param orig table + ---@return table copy + function UTIL.deepCopyTable(orig) + local orig_type = type(orig) + local copy + if orig_type == 'table' then + copy = {} + for orig_key, orig_value in next, orig, nil do + copy[UTIL.deepCopyTable(orig_key)] = UTIL.deepCopyTable(orig_value) + end + setmetatable(copy, UTIL.deepCopyTable(getmetatable(orig))) + else -- number, string, boolean, etc + copy = orig + end + return copy + end + + ---Gets a random from the list + ---@param list Array + ---@return any @random element from the list + function UTIL.randomFromList(list) + local max = #list + + if max == 0 or max == nil then + return nil + end + + local random = math.random(0, max) + if random == 0 then random = 1 end + + return list[random] + end + + ---@param list Array + ---@param start number start + ---@param n number length + ---@return Array + function UTIL.sublist(list, start, n) + local result = {} + for i = start, n do + result[#result + 1] = list[i] + end + return result + end + + ---@param str string + ---@param find string + ---@param replace string + ---@return string + function UTIL.replaceString(str, find, replace) + if str == nil then return "" end + if find == nil or replace == nil then return str end + + local result = str:gsub(find, replace) + return result + end + + local function table_print(tt, indent, done) + done = done or {} + indent = indent or 0 + if type(tt) == "table" then + local sb = {} + for key, value in pairs(tt) do + table.insert(sb, string.rep(" ", indent)) -- indent it + if type(value) == "table" and not done[value] then + done[value] = true + table.insert(sb, "[\"" ..key .. "\"]" .. " = {\n"); + table.insert(sb, table_print(value, indent + 2, done)) + table.insert(sb, string.rep(" ", indent)) -- indent it + table.insert(sb, "},\n"); + elseif "number" == type(key) then + table.insert(sb, string.format("\"%s\",\n", tostring(value))) + else + table.insert(sb, string.format( + "[\"%s\"] = \"%s\",\n", tostring(key), tostring(value))) + end + end + return table.concat(sb) + else + return tt .. "\n" + end + end + + ---comment + ---@param str string + ---@param findable string + ---@param ignoreCase boolean? + ---@return boolean + UTIL.startswith = function(str, findable, ignoreCase) + if ignoreCase == true then + return string.lower(str):find('^' .. string.lower(findable)) ~= nil + end + + return str:find('^' .. findable) ~= nil + end + + ---comment + ---@param str string + ---@param findable string + ---@return boolean + UTIL.strContains = function(str, findable) + return str:find(findable) ~= nil + end + + ---comment + ---@param str string + ---@param findableTable table + ---@return boolean + UTIL.startswithAny = function(str, findableTable) + for key, value in pairs(findableTable) do + if type(value) == "string" and UTIL.startswith(str, value) then return true end + end + return false + end + + function UTIL.toString(something) + if something == nil then + return "nil" + elseif "table" == type(something) then + return table_print(something) + elseif "string" == type(something) then + return something + else + return tostring(something) + end + end + + ---comment + ---@param a Vec2 + ---@param b Vec2 + ---@return number + function UTIL.VectorDistance2d(a, b) + return math.sqrt((b.x - a.x) ^ 2 + (b.y - a.y) ^ 2) + end + + ---comment + ---@param a Vec3 + ---@param b Vec3 + ---@return number + function UTIL.VectorDistance3d(a, b) + return UTIL.vectorMagnitude({ x = a.x - b.x, y = a.y - b.y, z = a.z - b.z }) + end + + ---comment + ---@param vec Vec3 + ---@return number + function UTIL.vectorMagnitude(vec) + return (vec.x ^ 2 + vec.y ^ 2 + vec.z ^ 2) ^ 0.5 + end + + ---@param vec Vec3 + ---@return Vec3 + function UTIL.vectorNormalize(vec) + local magnitude = UTIL.vectorMagnitude(vec) + if magnitude == 0 then + return { x = 0, y = 0, z = 0 } + end + return { x = vec.x / magnitude, y = vec.y / magnitude, z = vec.z / magnitude } + end + + ---@param vec Vec2 + ---@param direction number @in degrees + ---@param distance number + ---@return Vec2 + function UTIL.vectorMove(vec, direction, distance) + local rad = math.rad(direction) + local x = vec.x + (math.cos(rad) * distance) + local y = vec.y + (math.sin(rad) * distance) + + return { x = x, y = y} + end + + ---comment + ---@param vec1 Vec2 + ---@param vec2 Vec2 + ---@return number in degrees + function UTIL.vectorHeadingFromTo(vec1, vec2) + local dx = vec2.x - vec1.x + local dy = vec2.y - vec1.y + local heading = math.deg(math.atan2(dy, dx)) + if heading < 0 then + heading = heading + 360 + end + return heading + end + + + + ---@param vec1 Vec3 + ---@param vec2 Vec3 + ---@return number alignment a number in range [-1,1]. > 0 same direction. < 0 opposite direction + function UTIL.vectorAlignment(vec1, vec2) + local vec1Norm = UTIL.vectorNormalize(vec1) + local vec2Norm = UTIL.vectorNormalize(vec2) + + return ((vec1Norm.x * vec2Norm.x) + (vec1Norm.y * vec2Norm.y) + (vec1Norm.z * vec2Norm.z)) + end + + local function isInComplexPolygon(polygon, x, y) + local function getEdges(poly) + local result = {} + for i = 1, #poly do + local point1 = poly[i] + local point2Index = i + 1 + if point2Index > #poly then point2Index = 1 end + local point2 = poly[point2Index] + local edge = { x1 = point1.x, z1 = point1.y, x2 = point2.x, z2 = point2.y } + table.insert(result, edge) + end + return result + end + + local edges = getEdges(polygon) + local count = 0; + for _, edge in pairs(edges) do + if (x < edge.x1) ~= (x < edge.x2) and y < edge.z1 + ((x - edge.x1) / (edge.x2 - edge.x1)) * (edge.z2 - edge.z1) then + count = count + 1 + -- if (yp < y1) != (yp < y2) and xp < x1 + ((yp-y1)/(y2-y1))*(x2-x1) then + -- count = count + 1 + end + end + return count % 2 == 1 + end + + ---comment + ---@param polygon Array of pairs { x, y } + ---@param x number X location + ---@param y number Y location + ---@return boolean + function UTIL.IsPointInPolygon(polygon, x, y) + return isInComplexPolygon(polygon, x, y) + end + + ---@param point Vec3 + ---@param zone SpearheadTriggerZone + function UTIL.is3dPointInZone(point, zone) + if zone.zone_type == "Polygon" and zone.verts then + if UTIL.IsPointInPolygon(zone.verts, point.x, point.z) == true then + return true + end + else + if (((point.x - zone.location.x) ^ 2 + (point.z - zone.location.y) ^ 2) ^ 0.5 <= zone.radius) then + return true + end + end + + return false + end + + ---@param point Vec2 + ---@param zone SpearheadTriggerZone + function UTIL.is2dPointInZone(point, zone) + if zone.zone_type == "Polygon" and zone.verts then + if UTIL.IsPointInPolygon(zone.verts, point.x, point.y) == true then + return true + end + else + if (((point.x - zone.location.x) ^ 2 + (point.y - zone.location.y) ^ 2) ^ 0.5 <= zone.radius) then + return true + end + end + + return false + end + + ---comment + ---@param points Array points + ---@return Array hullPoints + function UTIL.getConvexHull(points) + if #points == 0 then + return {} + end + + ---comment + ---@param a Vec2 + ---@param b Vec2 + ---@param c Vec2 + ---@return boolean + local function ccw(a, b, c) + return (b.y - a.y) * (c.x - a.x) > (b.x - a.x) * (c.y - a.y) + end + + table.sort(points, function(left, right) + return left.y < right.y + end) + + local hull = {} + -- lower hull + for _, point in pairs(points) do + while #hull >= 2 and not ccw(hull[#hull - 1], hull[#hull], point) do + table.remove(hull, #hull) + end + table.insert(hull, point) + end + + -- upper hull + local t = #hull + 1 + for i = #points, 1, -1 do + local point = points[i] + while #hull >= t and not ccw(hull[#hull - 1], hull[#hull], point) do + table.remove(hull, #hull) + end + table.insert(hull, point) + end + table.remove(hull, #hull) + return hull + end + + ---Splits points into clusters with at least minSeparation between clusters, returns convex hull for each cluster + ---@param points Array + ---@param minSeparation number + ---@return Array> hulls + function UTIL.getSeparatedConvexHulls(points, minSeparation) + if #points == 0 then return {} end + + -- Simple clustering: group points that are within minSeparation of each other + local clusters = {} + local assigned = {} + + for i, p in ipairs(points) do + if not assigned[i] then + local cluster = { p } + assigned[i] = true + -- Find all points close to p (BFS) + local queue = { i } + while #queue > 0 do + local idx = table.remove(queue) + local base = points[idx] + for j, q in ipairs(points) do + if not assigned[j] then + local dx = base.x - q.x + local dy = base.y - q.y + if (dx * dx + dy * dy) <= (minSeparation * minSeparation) then + table.insert(cluster, q) + assigned[j] = true + table.insert(queue, j) + end + end + end + end + table.insert(clusters, cluster) + end + end + + -- Compute convex hull for each cluster + local hulls = {} + for _, cluster in ipairs(clusters) do + local hull = UTIL.getConvexHull(cluster) + if #hull > 0 then + table.insert(hulls, hull) + end + end + + return hulls + end + + ---@param points Array + function UTIL.enlargeConvexHull(points, meters) + if points == nil or #points == 0 then + return {} + end + + ---@type Array + local allpoints = {} + + for _, point in pairs(points) do + table.insert(allpoints, point) + + allpoints[#allpoints + 1] = point + allpoints[#allpoints+1] = { x = point.x + meters, y = point.y, } + allpoints[#allpoints+1] = { x = point.x - meters, y = point.y, } + allpoints[#allpoints+1] = { x = point.x, y = point.y + meters, } + allpoints[#allpoints+1] = { x = point.x, y = point.y - meters, } + + allpoints[#allpoints+1] = { x = point.x + math.cos(math.rad(45)) * meters, y = point.y + math.sin(math.rad(45)) * meters, } + allpoints[#allpoints+1] = { x = point.x - math.cos(math.rad(45)) * meters, y = point.y - math.sin(math.rad(45)) * meters, } + allpoints[#allpoints+1] = { x = point.x - math.cos(math.rad(45)) * meters, y = point.y + math.sin(math.rad(45)) * meters, } + allpoints[#allpoints+1] = { x = point.x + math.cos(math.rad(45)) * meters, y = point.y - math.sin(math.rad(45)) * meters, } + end + + return UTIL.getConvexHull(allpoints) + end + + ---Returns hull points visible from origin (not blocked by hull edges) +---@param hull Array +---@param origin Vec2 +---@return Array +function UTIL.GetVisibleHullPointsFromOrigin(hull, origin) + local function segmentsIntersect(a, b, c, d) + -- Helper: returns true if segment ab intersects cd (excluding endpoints) + local function ccw(p1, p2, p3) + return (p3.y - p1.y) * (p2.x - p1.x) > (p2.y - p1.y) * (p3.x - p1.x) + end + return (ccw(a, c, d) ~= ccw(b, c, d)) and (ccw(a, b, c) ~= ccw(a, b, d)) + end + + local n = #hull + local visible = {} + + for i = 1, n do + local p = hull[i] + local isVisible = true + + -- Check against all hull edges except those incident to p + for j = 1, n do + local a = hull[j] + local b = hull[(j % n) + 1] + -- skip edges incident to p + if (a ~= p and b ~= p) then + if segmentsIntersect(origin, p, a, b) then + isVisible = false + break + end + end + end + + if isVisible then + table.insert(visible, p) + end + end + + return visible +end + +---Returns the two tangent points ("scratch points") from origin to the convex hull +---@param hull Array +---@param origin Vec2 +---@return Array +function UTIL.GetTangentHullPointsFromOrigin(hull, origin) + + if hull == nil or #hull <= 0 then + return {} + end + + local function orientation(a, b, c) + -- Returns >0 if c is to the left of ab, <0 if to the right, 0 if colinear + return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) + end + + local n = #hull + if n == 0 then return {} end + if n == 1 then return { hull[1] } end + + -- Find left tangent: the hull point where all other points are to the right of the line from origin to that point + local function findTangent(isLeft) + local best = 1 + for i = 2, n do + local o = orientation(origin, hull[best], hull[i]) + if (isLeft and o < 0) or (not isLeft and o > 0) then + best = i + end + end + return hull[best] + end + + local leftTangent = findTangent(true) + local rightTangent = findTangent(false) + + -- If tangents are the same (can happen if origin is colinear), only return one + if leftTangent.x == rightTangent.x and leftTangent.y == rightTangent.y then + return { leftTangent } + else + return { leftTangent, rightTangent } + end +end + +end +Spearhead.Util = UTIL + +---DCS UTIL Takes inspiration from MIST but only takes the things it needs, changes for DCS updates and different vision for advanced mission scripting stuff. +---It also adds functions that make the other TDCS scripts easier without taking too much "control" away like MOOSE can sometimes. +local DCS_UTIL = {} +do -- INIT DCS_UTIL + do -- local databases + --[[ + groupdata = { + category, + country_id, + group_template + } + ]] -- + + --[[ + zone = { + name, + + zone_type, + x, + y, + radius + verts, + + } + ]] -- + + ---@alias SpearheadTriggerZoneType + ---| "Cilinder" + ---| "Polygon" + + ---@class SpearheadTriggerZone + ---@field name string + ---@field location Vec2 + ---@field radius number + ---@field verts Array + ---@field zone_type SpearheadTriggerZoneType + ---@field properties Array? + + ---@type Array + DCS_UTIL.__trigger_zones = {} + end + + DCS_UTIL.Coalition = + { + NEUTRAL = 0, + RED = 1, + BLUE = 2 + } + + DCS_UTIL.ZoneType = { + Cilinder = 0, + Polygon = 2 + } + + ---@enum SpearheadGroupCategory + DCS_UTIL.GroupCategory = { + AIRPLANE = 0, + HELICOPTER = 1, + GROUND = 2, + SHIP = 3, + TRAIN = 4, + STATIC = 5 --CUSTOM CATEGORY + } + + DCS_UTIL.__airbaseNamesById = {} + + ---@type table + DCS_UTIL.__airbaseZonesByName = {} + + DCS_UTIL.__airportsStartingCoalition = {} + DCS_UTIL.__warehouseStartingCoalition = {} + function DCS_UTIL.__INIT() + do -- INITS ALL TABLES WITH DATA THAT's from the MIZ environment + + do --init trigger zones + for i, trigger_zone in pairs(env.mission.triggers.zones) do + -- reorder verts as they are not ordered correctly in the ME + local verts = {} + if Spearhead.Util.tableLength(trigger_zone.verticies) >= 4 then + table.insert(verts, { x = trigger_zone.verticies[4].x, y = trigger_zone.verticies[4].y }) + table.insert(verts, { x = trigger_zone.verticies[3].x, y = trigger_zone.verticies[3].y }) + table.insert(verts, { x = trigger_zone.verticies[2].x, y = trigger_zone.verticies[2].y }) + table.insert(verts, { x = trigger_zone.verticies[1].x, y = trigger_zone.verticies[1].y }) + end + + local zoneType = "Cilinder" + if trigger_zone.type == DCS_UTIL.ZoneType.Polygon then + zoneType = "Polygon" + end + + ---@type SpearheadTriggerZone + local zone = { + name = trigger_zone.name, + zone_type = zoneType, + location = { x = trigger_zone.x, y = trigger_zone.y }, + radius = trigger_zone.radius, + verts = verts, + properties = {} + } + + if trigger_zone.properties then + for _, kvPair in pairs(trigger_zone.properties) do + local key = kvPair["key"] + local value = kvPair["value"] + zone.properties[#zone.properties + 1] = { key = key, value = value } + end + end + + DCS_UTIL.__trigger_zones[zone.name] = zone + end + end + + do -- init airports and warehouses + if env.warehouses.airports then + for warehouse_id, value in pairs(env.warehouses.airports) do + if warehouse_id ~= nil then + warehouse_id = tostring(warehouse_id) or "nil" + local coalitionNumber = DCS_UTIL.stringToCoalition(value.coalition) + DCS_UTIL.__airportsStartingCoalition[warehouse_id] = coalitionNumber + end + end + end + + if env.warehouses.warehouses then + DCS_UTIL.__warehouseStartingCoalition[-1] = "placeholder" + for warehouse_id, value in pairs(env.warehouses.warehouses) do + if warehouse_id ~= nil then + warehouse_id = tostring(warehouse_id) or "nil" + local coalitionNumber = DCS_UTIL.stringToCoalition(value.coalition) + DCS_UTIL.__warehouseStartingCoalition[warehouse_id] = coalitionNumber + end + end + end + end + + do -- fill airbaseNames and zones + local airbases = world.getAirbases() + if airbases then + for _, airbase in pairs(airbases) do + local name = airbase:getName() + + airbase:autoCapture(false) + + DCS_UTIL.__airbaseNamesById[tostring(airbase:getID())] = name + + if name then + ---@type Array + local relevantPoints = {} + for _, x in pairs(airbase:getRunways()) do + if x.position and x.position.x and x.position.z then + table.insert(relevantPoints, { x = x.position.x, y = x.position.z }) + end + end + + for _, x in pairs(airbase:getParking()) do + if x.vTerminalPos and x.vTerminalPos.x and x.vTerminalPos.z then + table.insert(relevantPoints, { x = x.vTerminalPos.x, y = x.vTerminalPos.z }) + end + end + + local points = UTIL.getConvexHull(relevantPoints) + local enlargedPoints = UTIL.enlargeConvexHull(points, 750) + + local triggerZone = { + name = name, + location = { x = airbase:getPoint().x, y = airbase:getPoint().z }, + zone_type = "Polygon", + radius = 0, + verts = enlargedPoints + } + + if SpearheadConfig and SpearheadConfig.debugEnabled == true then + DCS_UTIL.DrawZone(triggerZone, { r = 0, g = 1, b = 0, a = 1 }, { a = 0, r = 0, g = 1, b = 0 }, 1) + end + + DCS_UTIL.__airbaseZonesByName[name] = triggerZone + end + end + end + end + end + end + + ---maps the coalition name to the DCS coalition integer + ---@param input string the name + ---@return integer + function DCS_UTIL.stringToCoalition(input) + --[[ + coalition.side = { + NEUTRAL = 0 + RED = 1 + BLUE = 2 + } + ]] -- + local input = string.lower(input) + if input == 'neutrals' or input == "neutral" or input == "0" then + return DCS_UTIL.Coalition.NEUTRAL + end + + if input == 'red' or input == "1" then + return DCS_UTIL.Coalition.RED + end + + if input == 'blue' or input == "2" then + return DCS_UTIL.Coalition.BLUE + end + + return -1 + end + + + ---destroy the given unit + ---@param unitName string + function DCS_UTIL.DestroyUnit(unitName) + local unit = Unit.getByName(unitName) + if unit and unit:isExist() then + unit:destroy() + end + end + + --- takes a list of units and returns all the units that are in any of the zones + ---@param unit_names table unit names + ---@param zone_names table zone names + ---@return table unit list of objects { unit = UNIT, zone_name = zoneName} + function DCS_UTIL.getUnitsInZones(unit_names, zone_names) + local units = {} + + ---@type Array + local zones = {} + + for k = 1, #unit_names do + local unit = Unit.getByName(unit_names[k]) or StaticObject.getByName(unit_names[k]) + if unit and unit:isExist() == true then + units[#units + 1] = unit + end + end + + for index, zone_name in pairs(zone_names) do + local zone = DCS_UTIL.__trigger_zones[zone_name] + if zone then + zones[#zones + 1] = zone + end + end + + local in_zone_units = {} + for units_ind = 1, #units do + local lUnit = units[units_ind] + local unit_pos = lUnit:getPosition().p + local lCat = Object.getCategory(lUnit) + for zone_name, zone in pairs(zones) do + if unit_pos and ((lCat == 1 and lUnit:isActive() == true) or lCat ~= 1) then -- it is a unit and is active or it is not a unit + local isInZone = UTIL.is3dPointInZone(unit_pos, zone) + if isInZone == true then + in_zone_units[#in_zone_units + 1] = { unit = lUnit, zone_name = zone.name } + end + end + end + end + return in_zone_units + end + + --- takes a list of groups and returns all the group leaders that are in any of the zones + ---@param group_names table unit names + ---@param zone_name string zone names + ---@return table groupnames list of group names + function DCS_UTIL.getGroupsInZone(group_names, zone_name) + local zone = DCS_UTIL.__trigger_zones[zone_name] + if zone == nil then + return {} + end + + return DCS_UTIL.areGroupsInCustomZone(group_names, zone) + end + + --- takes a x, y poistion and checks if it is inside any of the zones + ---@param group_names Array North South position + ---@param zone SpearheadTriggerZone + ---@return Array groupnames list of groups that are in the zone + function DCS_UTIL.areGroupsInCustomZone(group_names, zone) + local units = {} + if Spearhead.Util.tableLength(group_names) < 1 then return {} end + + for k = 1, #group_names do + local entry = nil + local group = Group.getByName(group_names[k]) + if group ~= nil then + entry = { unit = group:getUnit(1), groupname = group_names[k] } + else + entry = { unit = StaticObject.getByName(group_names[k]), groupname = group_names[k] } + end + + if entry and entry.unit and entry.unit:isExist() == true then + units[#units + 1] = entry + end + end + + local result_groups = {} + for _, entry in pairs(units) do + local pos = entry.unit:getPoint() + local isInZone = UTIL.is3dPointInZone(pos, zone) + if isInZone == true then + table.insert(result_groups, entry.groupname) + end + end + return result_groups + end + + --- takes a x, y poistion and checks if it is inside any of the zones + ---@param x number North South position + ---@param z number West East position + ---@param zone_names table zone names + ---@return table zones list of objects { zone_name = zoneName} + function DCS_UTIL.isPositionInZones(x, z, zone_names) + ---@type Array + local zones = {} + for index, zone_name in pairs(zone_names) do + local zone = DCS_UTIL.__trigger_zones[zone_name] + if zone then + zones[#zones + 1] = zone + end + end + + local result_zones = {} + for zone_name, zone in pairs(zones) do + if UTIL.is3dPointInZone({ x = x, z = z, y = 0 }, zone) == true then + result_zones[#result_zones + 1] = zone.name + end + end + return result_zones + end + + --- takes a x, y poistion and checks if it is inside any of the zones + ---@param x number North South position + ---@param z number West East position + ---@param zone_name string zone name + ---@return boolean result + function DCS_UTIL.isPositionInZone(x, z, zone_name) + local zone = DCS_UTIL.__trigger_zones[zone_name] + if UTIL.is3dPointInZone({ x = x, y = 0, z = z }, zone) then + return true + end + return false + end + + --- takes a x, y poistion and checks if it is inside any of the zones + ---@param zone_name string + ---@param parent_zone_name string + ---@return boolean result + function DCS_UTIL.isZoneInZone(zone_name, parent_zone_name) + local zoneA = DCS_UTIL.__trigger_zones[zone_name] --[[@as SpearheadTriggerZone]] + if zoneA == nil then return false end + local zoneB = DCS_UTIL.__trigger_zones[parent_zone_name] --[[@as SpearheadTriggerZone]] + if zoneB == nil then return false end + return UTIL.is3dPointInZone({ x = zoneA.location.x, y = 0, z = zoneA.location.y }, zoneB) + end + + ---comment + ---@param zone_name any + ---@return SpearheadTriggerZone? zone { name,b zone_type, x, z, radius, verts } + function DCS_UTIL.getZoneByName(zone_name) + if zone_name == nil then return nil end + return DCS_UTIL.__trigger_zones[zone_name] + end + + ---comment + ---@param airbaseName string + ---@return SpearheadTriggerZone? zone + function DCS_UTIL.getAirbaseZoneByName(airbaseName) + if airbaseName == nil then return nil end + return DCS_UTIL.__airbaseZonesByName[airbaseName] + end + + ---maps the category name to the DCS group category + ---@param input string the name + ---@return integer? + function DCS_UTIL.stringToGroupCategory(input) + input = string.lower(input) + if input == 'airplane' or input == 'plane' then + return DCS_UTIL.GroupCategory.AIRPLANE + end + if input == 'helicopter' then + return DCS_UTIL.GroupCategory.HELICOPTER + end + if input == 'ground' or input == 'vehicle' then + return DCS_UTIL.GroupCategory.GROUND + end + if input == 'ship' then + return DCS_UTIL.GroupCategory.SHIP + end + if input == 'train' then + return DCS_UTIL.GroupCategory.TRAIN + end + if input == "static" then + return DCS_UTIL.GroupCategory.STATIC + end + return nil; + end + + ---@type table + local config = + { + ["ah-64d_blk_ii"] = "MGRS", + ["fa-18c_hornet"] = "DMS", + ["av8bna"] = "DMS", + ["f-14b"] = "DMS", + ["f-14a-135-gr"] = "DMS" + } + + + ---@param location Vec2 + ---@param unitType string + function DCS_UTIL.convertVec2ToUnitUsableType(location, unitType) + + local height = land.getHeight(location) + local vec3 = { x = location.x, y = height, z = location.y } + + local unitType = string.lower(unitType or "") + local conversionType = config[unitType] + + if not conversionType then conversionType = "DDM" end + return DCS_UTIL.convertToDisplayCoord(vec3, conversionType) + end + + ---@alias CoordType + ---| "MGRS" MGRS + ---| "DMS" DECIMAL SECONDS + ---| "DDM" DECIMAL MINUTES + + ---@private + ---@param location Vec3 + ---@param coordType CoordType + function DCS_UTIL.convertToDisplayCoord(location, coordType) + local lattitude, longitude, altitude = coord.LOtoLL(location) + + if coordType == "MGRS" then + local mgrs = coord.LLtoMGRS(lattitude, longitude) + return string.format("%s %s %s %s", mgrs.UTMZone, mgrs.MGRSDigraph, mgrs.Easting, mgrs.Northing) + end + + -- Convert DD to DDM (Degrees Decimal Minutes) + local function dd_to_ddm(dd) + local degrees = math.floor(math.abs(dd)) + local minutes = (math.abs(dd) - degrees) * 60 + local sign = dd >= 0 and 1 or -1 + return degrees * sign, minutes + end + + local lat_deg, lat_min = dd_to_ddm(lattitude) + local lon_deg, lon_min = dd_to_ddm(longitude) + + local lat_hemisphere = lattitude >= 0 and "N" or "S" + local lon_hemisphere = longitude >= 0 and "E" or "W" + + if coordType == "DDM" then + return string.format("%s%02d°%06.3f' %s%03d°%06.3f' %dft", + lat_hemisphere, math.abs(lat_deg), lat_min, + lon_hemisphere, math.abs(lon_deg), lon_min, + altitude * 3,28084) + end + + if coordType == "DMS" then + + local lat_min_display = math.floor(lat_min) + local lon_min_display = math.floor(lon_min) + + local lat_sec_display = math.floor((lat_min - lat_min_display) * 60) + local lon_sec_display = math.floor((lon_min - lon_min_display) * 60) + + return string.format("%s%02d°%02d'%02d %s%03d°%02d'%02d %dft", + lat_hemisphere, math.abs(lat_deg), lat_min_display, lat_sec_display, + lon_hemisphere, math.abs(lon_deg), lon_min_display, lon_sec_display, + altitude * 3,28084) + end + + end + + ---@param group Group + function DCS_UTIL.getUnitTypeFromGroup(group) + for _, unit in pairs(group:getUnits()) do + if unit and unit:isExist() then + return unit:getTypeName() + end + end + end + + + ---comment Get all units that are players + ---@return Array units + function DCS_UTIL.getAllPlayerUnits() + local units = {} + for i = 0, 2 do + local players = coalition.getPlayers(i) + for key, unit in pairs(players) do + units[#units + 1] = unit + end + end + return units + end + + ---get base name from ID + ---@param baseId number + ---@return string? name + function DCS_UTIL.getAirbaseName(baseId) + local stringified = tostring(baseId) + return DCS_UTIL.__airbaseNamesById[stringified] + end + + ---get base from id + ---@param baseId number + ---@return Airbase? table + function DCS_UTIL.getAirbaseById(baseId) + local name = DCS_UTIL.getAirbaseName(baseId) + if name == nil then return nil end + return Airbase.getByName(name) + end + + ---Get the starting coalition of a farp or airbase + ---@param airbase Airbase + ---@return number? coalition + function DCS_UTIL.getStartingCoalition(airbase) + if airbase == nil then + return nil + end + + --STRING based dictionary otherwise it'll be a string/collapsed array + local baseId = tostring(airbase:getID()) + + local result = DCS_UTIL.__airportsStartingCoalition[baseId] + if result == nil then + result = DCS_UTIL.__warehouseStartingCoalition[baseId] + end + return result + end + + function DCS_UTIL.CleanCorpse(unitName) + local unitName = "dead_" .. unitName + + local object = StaticObject.getByName(unitName) + + if object then + object:destroy() + end + end + + ---@class DrawColor + ---@field r number + ---@field g number + ---@field b number + ---@field a number + + local drawID = 400 + ---@param zone SpearheadTriggerZone + ---@param lineColor DrawColor + ---@param fillColor DrawColor + ---@param lineStyle LineType + ---@return number drawID + function DCS_UTIL.DrawZone(zone, lineColor, fillColor, lineStyle) + if lineStyle == nil then lineStyle = 4 end + drawID = drawID + 1 + if zone.zone_type == "Cilinder" then + trigger.action.circleToAll(-1, drawID, { x = zone.location.x, y = 0, z = zone.location.y }, zone.radius, + { 0, 0, 0, 0 }, { 0, 0, 0, 0 }, lineStyle, true) + else + local functionString = "trigger.action.markupToAll(7, -1, " .. drawID .. "," + for _, vecpoint in pairs(zone.verts) do + functionString = functionString .. " { x=" .. vecpoint.x .. ", y=0,z=" .. vecpoint.y .. "}," + end + functionString = functionString .. "{0,1,0,1}, {0,1,0,1}, " .. lineStyle .. ")" + + ---@diagnostic disable-next-line: deprecated + local f, err = loadstring(functionString) + if f then + f() + else + env.error("Something failed when drawing complex drawing" .. err) + end + end + local fillColorMapped = { + fillColor.r or 0, + fillColor.g or 0, + fillColor.b or 0, + fillColor.a or 0.5 + } + + local lineColorMapped = { + lineColor.r or 0, + lineColor.g or 0, + lineColor.b or 0, + lineColor.a or 1 + } + + trigger.action.setMarkupColorFill(drawID, fillColorMapped) + trigger.action.setMarkupColor(drawID, lineColorMapped) + + return drawID + end + + ---@param start Vec3 + ---@param finish Vec3 + ---@param lineColor DrawColor + ---@param lineStyle LineType + function DCS_UTIL.DrawLine(start, finish, lineColor, lineStyle) + if lineStyle == nil then lineStyle = 4 end + drawID = drawID + 1 + + local lineColorMapped = { + lineColor.r or 0, + lineColor.g or 0, + lineColor.b or 0, + lineColor.a or 1 + } + + trigger.action.lineToAll(-1, drawID, start, finish, lineColorMapped, lineStyle) + return drawID + end + + ---@param groupID number + ---@param text string + ---@param location Vec3 + ---@return number markID + function DCS_UTIL.AddMarkToGroup(groupID, text, location) + drawID = drawID + 1 + trigger.action.markToGroup(drawID, text, location, groupID, true, nil) + return drawID + end + + ---comment + ---@param text any + ---@param location Vec3 + ---@return integer + function DCS_UTIL.AddMarkToAll(text, location) + drawID = drawID + 1 + trigger.action.markToAll(drawID, text, location, true, nil) + return drawID + end + + ---@param markId number + function DCS_UTIL.RemoveMark(markId) + if markId ~= nil then + trigger.action.removeMark(markId) + end + end + + ---comment + ---@param drawID number + ---@param lineColor DrawColor + function DCS_UTIL.SetLineColor(drawID, lineColor) + local lineColorMapped = { + lineColor.r or 0, + lineColor.g or 0, + lineColor.b or 0, + lineColor.a or 1 + } + trigger.action.setMarkupColor(drawID, lineColorMapped) + end + + ---comment + ---@param drawID number + ---@param fillColor DrawColor + function DCS_UTIL.SetFillColor(drawID, fillColor) + local lineColorMapped = { + fillColor.r or 0, + fillColor.g or 0, + fillColor.b or 0, + fillColor.a or 1 + } + trigger.action.setMarkupColorFill(drawID, lineColorMapped) + end + + function DCS_UTIL.RemoveZoneDraw(drawID) + if drawID ~= nil then + trigger.action.removeMark(drawID) + end + end + + + ---@return number? id + function DCS_UTIL.GetNeutralCountry() + for name, id in pairs(country.id) do + if coalition.getCountryCoalition(id) == DCS_UTIL.Coalition.NEUTRAL then + return id + end + end + end + + + function DCS_UTIL.NeedsRTBInTen(groupName, fuelOffset) + + local isBingo = DCS_UTIL.IsBingoFuel(groupName, fuelOffset) + if isBingo then return true end + + local aliveUnits = 0 + local group = Group.getByName(groupName) + if group then + for _ , unit in pairs(group:getUnits()) do + if unit and unit:isExist() == true and unit:inAir() == true then + aliveUnits = aliveUnits + 1 + end + end + + if aliveUnits / group:getInitialSize() <= 0.5 then + return true + end + end + + return false + end + + ---@return boolean + function DCS_UTIL.IsBingoFuel(groupName, offset) + if offset == nil then offset = 0 end + local bingoSetting = 0.20 + bingoSetting = bingoSetting + offset + + local group = Group.getByName(groupName) + if group then + for _, unit in pairs(group:getUnits()) do + if unit and unit:isExist() == true and unit:inAir() == true and unit:getFuel() < bingoSetting then + return true + end + end + end + + return false + end + + ---comment + ---@param groupId number + ---@return Group? + function DCS_UTIL.GetPlayerGroupByGroupID(groupId) + for i = 0, 2 do + local players = coalition.getPlayers(i) + for key, unit in pairs(players) do + if unit and unit:isExist() == true then + local group = unit:getGroup() + if group and group:getID() == groupId then + return group + end + end + end + end + end + + ---@param unitID number + ---@return Unit? + function DCS_UTIL.GetPLayerUnitByID(unitID) + for i = 0, 2 do + local players = coalition.getPlayers(i) + for key, unit in pairs(players) do + if unit and unit:getID() == unitID then + return unit + end + end + end + end + + DCS_UTIL.__INIT(); +end +Spearhead.DcsUtil = DCS_UTIL + +--- @class Logger +--- @field LoggerName string the name of the logger +--- @field LogLevel string the log level of the logger +local LOGGER = {} +do + local PreFix = "Spearhead" + + ---comment + ---@param logger_name any + ---@param logLevel LogLevel + ---@return Logger + function LOGGER.new(logger_name, logLevel) + LOGGER.__index = LOGGER + local self = setmetatable({}, LOGGER) + self.LoggerName = logger_name or "(loggername not set)" + self.LogLevel = logLevel or "INFO" + + return self + end + + ---@param message any the message + function LOGGER:info(message) + if message == nil then + return + end + message = UTIL.toString(message) + + if self.LogLevel == "INFO" or self.LogLevel == "DEBUG" then + env.info("[" .. PreFix .. "]" .. "[" .. self.LoggerName .. "] " .. message) + end + end + + ---comment + ---@param message string + function LOGGER:warn(message) + if message == nil then + return + end + message = UTIL.toString(message) + + if self.LogLevel == "INFO" or self.LogLevel == "DEBUG" or self.LogLevel == "WARN" then + env.warning("[" .. PreFix .. "]" .. "[" .. self.LoggerName .. "] " .. message) + end + end + + ---@param message any -- the message + function LOGGER:error(message) + if message == nil then + return + end + + message = UTIL.toString(message) + + if self.LogLevel == "INFO" or self.LogLevel == "DEBUG" or self.LogLevel == "WARN" or self.LogLevel == "ERROR" then + env.error("[" .. PreFix .. "]" .. "[" .. self.LoggerName .. "] " .. message) + end + end + + ---@param message any the message + function LOGGER:debug(message) + if message == nil then + return + end + + message = UTIL.toString(message) + if self.LogLevel == "DEBUG" then + env.info("[" .. PreFix .. "]" .. "[" .. self.LoggerName .. "][DEBUG] " .. message) + end + end +end +Spearhead.LoggerTemplate = LOGGER + +Spearhead.MissionEditingWarnings = {} +function Spearhead.AddMissionEditorWarning(warningMessage) + table.insert(Spearhead.MissionEditingWarnings, warningMessage or "skip") +end + +local loadDone = false +Spearhead.LoadingDone = function() + if loadDone == true then + return + end + + local warningLogger = Spearhead.LoggerTemplate.new("MISSIONPARSER", "INFO") + if Spearhead.Util.tableLength(Spearhead.MissionEditingWarnings) > 0 then + for key, message in pairs(Spearhead.MissionEditingWarnings) do + warningLogger:warn(message) + end + else + warningLogger:info("No issues detected") + end + + loadDone = true +end diff --git a/classes/stageClasses/helpers/MissionCommandsHelper.lua b/classes/stageClasses/helpers/MissionCommandsHelper.lua index 40fc50e..5c78a58 100644 --- a/classes/stageClasses/helpers/MissionCommandsHelper.lua +++ b/classes/stageClasses/helpers/MissionCommandsHelper.lua @@ -1,485 +1,568 @@ - ----@class MissionCommandsHelper ----@field missionsByCode table @table of missions by their code ----@field enabledByCode table @table of enabled missions by their code ----@field updateNeeded boolean @flag to indicate if an update is needed ----@field lastUpdate number @last update time ----@field updateContinuous fun(self: MissionCommandsHelper, time: number): number @function to update commands continuously ----@field pinnedByGroup table @table of pinned missions by group ID ----@field private _stageBriefings table @table of stage briefings by stage name ----@field private _supplyHubGroups table @table of supply hub groups by their ID ----@field private _logger Logger @logger instance for logging ----@field private _supplyUnitsTracker SupplyUnitsTracker @supply units tracker instance -local MissionCommandsHelper = {} -MissionCommandsHelper.__index = MissionCommandsHelper - -local id = 0 - -local instance = nil - ----@return MissionCommandsHelper ----@param logLevel string @log level for the logger -function MissionCommandsHelper.getOrCreate(logLevel) - if instance == nil then - instance = setmetatable({}, MissionCommandsHelper) - - instance._logger = Spearhead.LoggerTemplate.new("MissionCommandsHelper", logLevel) - - instance._logger:info("Creating MissionCommandsHelper instance") - - instance.missionsByCode = {} - instance.enabledByCode = {} - instance.updateNeeded = false - instance.pinnedByGroup = {} - instance.lastUpdate = 0 - instance._supplyHubGroups = {} - instance._stageBriefings = {} - - instance._supplyUnitsTracker = Spearhead.classes.stageClasses.helpers.SupplyUnitsTracker.getOrCreate(logLevel) - - ---comment - ---@param selfA MissionCommandsHelper - ---@param time number - ---@return number - instance.updateContinuous = function(selfA, time) - if selfA.updateNeeded == false then - return time + 10 - end - - for _, unit in pairs(Spearhead.DcsUtil.getAllPlayerUnits()) do - if unit and unit:isExist() then - local group = unit:getGroup() - if group then - selfA:updateCommandsForGroup(group:getID()) - end - end - end - - selfA.lastUpdate = timer.getTime() - selfA.updateNeeded = false - return time + 10 - end - - timer.scheduleFunction(instance.updateContinuous, instance, timer.getTime() + 5) - Spearhead.Events.AddOnPlayerEnterUnitListener(instance) - - end - - return instance -end - -function MissionCommandsHelper:AddStageBriefing(stageName, briefing) - self._stageBriefings[stageName] = briefing -end - -function MissionCommandsHelper:RemoveStageBriefing(stageName) - self._stageBriefings[stageName] = nil -end - ----@param mission Mission -function MissionCommandsHelper:AddMissionToCommands(mission) - self._logger:debug("Adding mission to commands: [" .. mission.code .. "]" .. mission.name) - self.missionsByCode[tostring(mission.code)] = mission - self.enabledByCode[tostring(mission.code)] = true - self.updateNeeded = true -end - ----Removes a mission from the F10 commands menu ----@param mission Mission -function MissionCommandsHelper:RemoveMissionToCommands(mission) - self.enabledByCode[tostring(mission.code)] = false - self.updateNeeded = true -end - ----@param groupID number -function MissionCommandsHelper:MarkUnitInSupplyHub(groupID) - self._logger:debug("Marking unit in supply hub: " .. tostring(groupID)) - local updateNeeded = false - if self._supplyHubGroups[tostring(groupID)] ~= true then - updateNeeded = true - end - - self._supplyHubGroups[tostring(groupID)] = true - if updateNeeded == true then self:updateCommandsForGroup(groupID) end -end - - ----@param groupID number -function MissionCommandsHelper:MarkUnitOutsideSupplyHub(groupID) - self._logger:debug("Marking unit outide supply hub: " .. tostring(groupID)) - local updateNeeded = false - if self._supplyHubGroups[tostring(groupID)] == true then - updateNeeded = true - end - - self._supplyHubGroups[tostring(groupID)] = false - if updateNeeded == true then self:updateCommandsForGroup(groupID) end -end - - - ----@param unit Unit -function MissionCommandsHelper:OnPlayerEntersUnit(unit) - if unit then - local group = unit:getGroup() - if group then self:updateCommandsForGroup(group:getID()) end - end -end - ----@class MissionBriefingRequestedArgs ----@field mission Mission @the mission object ----@field groupId integer @the group ID of the player requesting the briefing - ----comment ----@param args MissionBriefingRequestedArgs -local missionBriefingRequested = function(args) - ---@type Mission - local mission = args.mission - local groupID = args.groupId - - mission:ShowBriefing(groupID) -end - ----@class PinMissionCommandArgs ----@field self MissionCommandsHelper @the MissionCommandsHelper instance ----@field groupId integer @the group ID of the player requesting the briefing ----@field mission Mission @the mission object - -local pinMissionCommand = function(args) - ---@type MissionCommandsHelper - local self = args.self - local groupID = args.groupId - local mission = args.mission - - if mission then - self:PinMission(mission, groupID) - end -end - ----@private -function MissionCommandsHelper:AddOverviewCommand(groupID) - - local MissionsOverviewToGroup = function (id) - - local text = "Missions Overview\n\n" - - local group = Spearhead.DcsUtil.GetPlayerGroupByGroupID(id) - - ---comment - ---@param mission Mission - ---@return string - local function formatLine(mission) - - local distanceText = "?" - if group then - local lead = group:getUnit(1) - if lead and lead:isExist() == true then - local pos = lead:getPoint() - local Vec2Pos = { x= pos.x, y=pos.z } - local distance = Spearhead.Util.VectorDistance2d(Vec2Pos, mission.location) / 1852 - distanceText = string.format("~%d", math.floor(distance)) - end - end - - return string.format("[%s]\t%s \t%s \t%s %% \t%s nM\n", mission.code, mission.missionTypeDisplay, mission.name, mission:PercentageComplete(), distanceText) - end - - for _, briefing in pairs(self._stageBriefings) do - text = text .. briefing .. "\n\n" - end - - ---Primary missions - text = text .. "Primary Missions\n" - for code, enabled in pairs(self.enabledByCode) do - - if enabled == true then - local mission = self.missionsByCode[code] - if mission and mission:getState() == "ACTIVE" and mission.priority == "primary" then - text = text .. formatLine(mission) - end - end - end - - ---Secondary missions - text = text .. "\nSecondary Missions\n" - for code, enabled in pairs(self.enabledByCode) do - - if enabled == true then - local mission = self.missionsByCode[code] - if mission and mission:getState() == "ACTIVE" and mission.priority == "secondary" then - text = text .. formatLine(mission) - end - end - end - - trigger.action.outTextForGroup(id, text, 20, true) - end - - missionCommands.removeItemForGroup(groupID, { "Overview" } ) - missionCommands.addCommandForGroup(groupID, "Overview", nil, MissionsOverviewToGroup, groupID) -end - ----@private ----@param groupID number -function MissionCommandsHelper:AddPinnedMission(groupID) - - local pinndedMission = self.pinnedByGroup[tostring(groupID)] - missionCommands.removeItemForGroup(groupID, { "Pinned Mission" }) - - if pinndedMission and self.enabledByCode[tostring(pinndedMission.code)] == true then - missionCommands.addCommandForGroup(groupID, "Pinned Mission", nil, missionBriefingRequested, { groupId = groupID, mission = pinndedMission }) - end - -end - ----@param groupID number -function MissionCommandsHelper:updateCommandsForGroup(groupID) - - self._logger:debug("Updating commands for group: " .. tostring(groupID)) - - self:AddPinnedMission(groupID) - self:AddOverviewCommand(groupID) - - self:ResetFolders(groupID) - - self:AddAllMissionCommandsToGroup(groupID) - - self:AddSupplyHubCommandsIfApplicable(groupID) - self:AddCargoCommands(groupID) - - ---@param id number - local clearView = function(id) - trigger.action.outTextForGroup(id, "clearing...", 1, true) - end - - missionCommands.removeItemForGroup(groupID, { "Clear View" } ) - missionCommands.addCommandForGroup(groupID, "Clear View", nil, clearView, groupID) - -end - -local folderNames = { - primary = "Primary Missions", - secondary = "Secondary Missions", - supplyHub = "Supply Hub", - cargo = "Cargo" -} - - -function MissionCommandsHelper:PinMission(mission, groupID) - self._logger:debug("Pinning mission: [" .. mission.code .. "]" .. mission.name) - self.pinnedByGroup[tostring(groupID)] = mission - trigger.action.outTextForGroup(groupID, "Pinned mission: [" .. mission.code .. "]" .. mission.name, 3, true) - - self:updateCommandsForGroup(groupID) -end - -function MissionCommandsHelper:AddAllMissionCommandsToGroup(groupID) - - local perFolder = 9 - - do --- primary missions - local count = 0 - local path = { [1] = folderNames.primary } - for code, enabled in pairs(self.enabledByCode) do - if enabled == true then - local mission = self.missionsByCode[code] - if mission and mission.priority == "primary" then - count = count + 1 - if count <= perFolder then - local copied = Spearhead.Util.deepCopyTable(path) - self:addMissionCommands(groupID, copied, mission) - else - local name = "Next Menu ..." - missionCommands.addSubMenuForGroup(groupID, name, path) - path[#path+1] = name - count = 0 - end - end - end - end - end - - do --- secondary missions - local count = 0 - local path = { [1] = folderNames.secondary } - for code, enabled in pairs(self.enabledByCode) do - if enabled == true then - local mission = self.missionsByCode[code] - if mission and mission.priority == "secondary" then - count = count + 1 - if count <= perFolder then - local copied = Spearhead.Util.deepCopyTable(path) - self:addMissionCommands(groupID, copied, mission) - else - local name = "Next Menu ..." - missionCommands.addSubMenuForGroup(groupID, name, path) - path[#path+1] = "more missions ..." - end - end - end - end - end -end - ----comment ----@private ----@param groupId integer ----@param path Array ----@param mission Mission -function MissionCommandsHelper:addMissionCommands(groupId, path, mission) - - if path then - local missionFolderName = "[" .. mission.code .. "]" .. mission.name - missionCommands.addSubMenuForGroup(groupId, missionFolderName, path) - table.insert(path, missionFolderName) - - ---@type MissionBriefingRequestedArgs - local missionBriefingRequestedArgs = { groupId = groupId, mission = mission } - missionCommands.addCommandForGroup(groupId, "Briefing", path, missionBriefingRequested,missionBriefingRequestedArgs) - - ---@type PinMissionCommandArgs - local pinMissionCommandArgs = { self = self, groupId = groupId, mission = mission } - missionCommands.addCommandForGroup(groupId, "Pin", path, pinMissionCommand, pinMissionCommandArgs) - end -end - ----@private ----@param groupID integer -function MissionCommandsHelper:AddSupplyHubCommandsIfApplicable(groupID) - - if self._supplyHubGroups[tostring(groupID)] ~= true then return end - - self._logger:debug("Adding supply hub commands for group: " .. tostring(groupID)) - - local group = Spearhead.DcsUtil.GetPlayerGroupByGroupID(groupID) - if group == nil then return end - - local unit = group:getUnit(1) - if unit == nil then return end - - ---@class LoadCargoCommandParams - ---@field unitID number - ---@field groupID number - ---@field crateType CrateType - ---@field supplyUnitsTracker SupplyUnitsTracker - - ---comment - ---@param params LoadCargoCommandParams - local loadCargoCommand = function(params) - local crateType = params.crateType - local supplyUnitsTracker = params.supplyUnitsTracker - if supplyUnitsTracker then - supplyUnitsTracker:UnitRequestCrateLoading(params.groupID, crateType) - end - end - - local path = { [1] = folderNames.supplyHub } - - - ---@type LoadCargoCommandParams - local farpParams1000 = { unitID = unit:getID(), groupID = group:getID(), crateType = "FARP_CRATE_1000", supplyUnitsTracker = self._supplyUnitsTracker } - missionCommands.addCommandForGroup(groupID, "Load FARP Crate (1000)", path, loadCargoCommand, farpParams1000) - - ---@type LoadCargoCommandParams - local farpParams2000 = { unitID = unit:getID(), groupID = group:getID(), crateType = "FARP_CRATE_2000", supplyUnitsTracker = self._supplyUnitsTracker } - missionCommands.addCommandForGroup(groupID, "Load FARP Crate (2000)", path, loadCargoCommand, farpParams2000) - - ---@type LoadCargoCommandParams - local samParms1000 = { unitID = unit:getID(), groupID = group:getID(), crateType = "SAM_CRATE_2000", supplyUnitsTracker = self._supplyUnitsTracker } - missionCommands.addCommandForGroup(groupID, "Load SAM Crate (1000)", path, loadCargoCommand, samParms1000) - - ---@type LoadCargoCommandParams - local samParms2000 = { unitID = unit:getID(), groupID = group:getID(), crateType = "SAM_CRATE_2000", supplyUnitsTracker = self._supplyUnitsTracker } - missionCommands.addCommandForGroup(groupID, "Load SAM Crate (2000)", path, loadCargoCommand, samParms2000) - - ---@type LoadCargoCommandParams - local samParms2000 = { unitID = unit:getID(), groupID = group:getID(), crateType = "AIRBASE_CRATE_2000", supplyUnitsTracker = self._supplyUnitsTracker } - missionCommands.addCommandForGroup(groupID, "Airbase Crate (2000)", path, loadCargoCommand, samParms2000) -end - -function MissionCommandsHelper:AddCargoCommands(groupID) - - local group = Spearhead.DcsUtil.GetPlayerGroupByGroupID(groupID) - if group == nil then return end - - local unit = group:getUnit(1) - if unit == nil then return end - - ---@class UnloadCargoCommandParams - ---@field unitID number - ---@field crateType CrateType - ---@field supplyUnitsTracker SupplyUnitsTracker - - ---comment - ---@param params UnloadCargoCommandParams - local unloadCargoCommand = function(params) - local unitID = params.unitID - local crateType = params.crateType - params.supplyUnitsTracker:UnloadRequested(unitID, crateType) - end - - local cargo = self._supplyUnitsTracker:GetCargoInUnit(unit:getID()) - if cargo then - for cargoType, amount in pairs(cargo) do - local cargoConfig = Spearhead.classes.stageClasses.helpers.supplies.SupplyConfigHelper.getSupplyConfig(cargoType) - if cargoConfig then - for i = 1, amount do - local path = { [1] = folderNames.cargo } - ---@type UnloadCargoCommandParams - local params = { unitID = unit:getID(), crateType = cargoType, supplyUnitsTracker = self._supplyUnitsTracker } - missionCommands.addCommandForGroup(groupID, "Unload " .. cargoConfig.displayName, path, unloadCargoCommand, params) - end - end - end - end -end - - - ----@private ----@param groupId integer -function MissionCommandsHelper:addMissionFolders(groupId) - - missionCommands.addSubMenuForGroup(groupId, folderNames.primary) - missionCommands.addSubMenuForGroup(groupId, folderNames.secondary) - - if self._supplyHubGroups[tostring(groupId)] == true then - self._logger:debug("Adding supply hub commands folder for group: " .. tostring(groupId)) - missionCommands.addSubMenuForGroup(groupId, folderNames.supplyHub) - end - - local group = Spearhead.DcsUtil.GetPlayerGroupByGroupID(groupId) - if group == nil then return end - - local unit = group:getUnit(1) - if unit == nil then return end - - local cargo = self._supplyUnitsTracker:GetCargoInUnit(unit:getID()) - if cargo ~= nil then - missionCommands.addSubMenuForGroup(groupId, folderNames.cargo) - end -end - ----@private ----@param groupId integer -function MissionCommandsHelper:removeMissionFolders(groupId) - missionCommands.removeItemForGroup(groupId, { folderNames.primary }) - missionCommands.removeItemForGroup(groupId, { folderNames.secondary }) - missionCommands.removeItemForGroup(groupId, { folderNames.supplyHub }) - missionCommands.removeItemForGroup(groupId, { folderNames.cargo }) -end - ----@private -function MissionCommandsHelper:ResetFolders(groupID) - -- Cleanup mission folder - self:removeMissionFolders(groupID) - - -- Add mission folders - self:addMissionFolders(groupID) -end - -if Spearhead == nil then Spearhead = {} end -if Spearhead.classes == nil then Spearhead.classes = {} end -if Spearhead.classes.stageClasses == nil then Spearhead.classes.stageClasses = {} end -if Spearhead.classes.stageClasses.helpers == nil then Spearhead.classes.stageClasses.helpers = {} end -Spearhead.classes.stageClasses.helpers.MissionCommandsHelper = MissionCommandsHelper + +---@class MissionCommandsHelper +---@field missionsByCode table @table of missions by their code +---@field enabledByCode table @table of enabled missions by their code +---@field updateNeeded boolean @flag to indicate if an update is needed +---@field lastUpdate number @last update time +---@field updateContinuous fun(self: MissionCommandsHelper, time: number): number @function to update commands continuously +---@field pinnedByGroup table @table of pinned missions by group ID +---@field private _stageBriefings table @table of stage briefings by stage name +---@field private _supplyHubGroups table @table of supply hub groups by their ID +---@field private _logger Logger @logger instance for logging +---@field private _supplyUnitsTracker SupplyUnitsTracker @supply units tracker instance +local MissionCommandsHelper = {} +MissionCommandsHelper.__index = MissionCommandsHelper + +---@param list Array +---@param groupPos Vec2 +local function sortMissions(list, groupPos) + table.sort(list, function(a, b) + local distA = Spearhead.Util.VectorDistance2d(groupPos, a.location or {x=0, y=0}) + local distB = Spearhead.Util.VectorDistance2d(groupPos, b.location or {x=0, y=0}) + return distA < distB; + end) +end + +local id = 0 + +local instance = nil + +---@return MissionCommandsHelper +---@param logLevel string @log level for the logger +function MissionCommandsHelper.getOrCreate(logLevel) + if instance == nil then + instance = setmetatable({}, MissionCommandsHelper) + + instance._logger = Spearhead.LoggerTemplate.new("MissionCommandsHelper", logLevel) + + instance._logger:info("Creating MissionCommandsHelper instance") + + instance.missionsByCode = {} + instance.enabledByCode = {} + instance.updateNeeded = false + instance.pinnedByGroup = {} + instance.lastUpdate = 0 + instance._supplyHubGroups = {} + instance._stageBriefings = {} + + instance._supplyUnitsTracker = Spearhead.classes.stageClasses.helpers.SupplyUnitsTracker.getOrCreate(logLevel) + + ---comment + ---@param selfA MissionCommandsHelper + ---@param time number + ---@return number + instance.updateContinuous = function(selfA, time) + if selfA.updateNeeded == false then + return time + 10 + end + + for _, unit in pairs(Spearhead.DcsUtil.getAllPlayerUnits()) do + if unit and unit:isExist() then + local group = unit:getGroup() + if group then + selfA:updateCommandsForGroup(group:getID()) + end + end + end + + selfA.lastUpdate = timer.getTime() + selfA.updateNeeded = false + return time + 10 + end + + timer.scheduleFunction(instance.updateContinuous, instance, timer.getTime() + 5) + Spearhead.Events.AddOnPlayerEnterUnitListener(instance) + + end + + return instance +end + +function MissionCommandsHelper:AddStageBriefing(stageName, briefing) + self._stageBriefings[stageName] = briefing +end + +function MissionCommandsHelper:RemoveStageBriefing(stageName) + self._stageBriefings[stageName] = nil +end + +---@param mission Mission +function MissionCommandsHelper:AddMissionToCommands(mission) + self._logger:debug("Adding mission to commands: [" .. mission.code .. "]" .. mission.name) + self.missionsByCode[tostring(mission.code)] = mission + self.enabledByCode[tostring(mission.code)] = true + self.updateNeeded = true +end + +---Removes a mission from the F10 commands menu +---@param mission Mission +function MissionCommandsHelper:RemoveMissionToCommands(mission) + self.enabledByCode[tostring(mission.code)] = false + self.updateNeeded = true +end + +---@param groupID number +function MissionCommandsHelper:MarkUnitInSupplyHub(groupID) + self._logger:debug("Marking unit in supply hub: " .. tostring(groupID)) + local updateNeeded = false + if self._supplyHubGroups[tostring(groupID)] ~= true then + updateNeeded = true + end + + self._supplyHubGroups[tostring(groupID)] = true + if updateNeeded == true then self:updateCommandsForGroup(groupID) end +end + + +---@param groupID number +function MissionCommandsHelper:MarkUnitOutsideSupplyHub(groupID) + self._logger:debug("Marking unit outide supply hub: " .. tostring(groupID)) + local updateNeeded = false + if self._supplyHubGroups[tostring(groupID)] == true then + updateNeeded = true + end + + self._supplyHubGroups[tostring(groupID)] = false + if updateNeeded == true then self:updateCommandsForGroup(groupID) end +end + + + +---@param unit Unit +function MissionCommandsHelper:OnPlayerEntersUnit(unit) + if unit then + local group = unit:getGroup() + if group then self:updateCommandsForGroup(group:getID()) end + end +end + +---@class MissionBriefingRequestedArgs +---@field mission Mission @the mission object +---@field groupId integer @the group ID of the player requesting the briefing + +---comment +---@param args MissionBriefingRequestedArgs +local missionBriefingRequested = function(args) + ---@type Mission + local mission = args.mission + local groupID = args.groupId + + mission:ShowBriefing(groupID) +end + +---@class PinMissionCommandArgs +---@field self MissionCommandsHelper @the MissionCommandsHelper instance +---@field groupId integer @the group ID of the player requesting the briefing +---@field mission Mission @the mission object + +---@param args PinMissionCommandArgs +local pinMissionCommand = function(args) + ---@type MissionCommandsHelper + local self = args.self + local groupID = args.groupId + local mission = args.mission + + if mission then + self:PinMission(mission, groupID) + end +end + +---@private +function MissionCommandsHelper:AddOverviewCommand(groupID) + + local MissionsOverviewToGroup = function (id) + + local text = "Missions Overview\n\n" + + local group = Spearhead.DcsUtil.GetPlayerGroupByGroupID(id) + ---@type Vec2 + local groupPos = { x=0, y=0 } + if group then + local pos = group:getUnit(1):getPosition().p + groupPos = { x= pos.x, y=pos.z } + end + + ---comment + ---@param mission Mission + ---@return string + local function formatLine(mission) + + local distanceText = "?" + if group then + local lead = group:getUnit(1) + if lead and lead:isExist() == true then + local pos = lead:getPoint() + local Vec2Pos = { x= pos.x, y=pos.z } + local distance = Spearhead.Util.VectorDistance2d(Vec2Pos, mission.location) / 1852 + distanceText = string.format("~%d", math.floor(distance)) + end + end + + return string.format("[%s]\t%s \t%s \t%s %% \t%s nM\n", mission.code, mission.missionTypeDisplay, mission.name, mission:PercentageComplete(), distanceText) + end + + for _, briefing in pairs(self._stageBriefings) do + text = text .. briefing .. "\n\n" + end + + + + + ---Primary missions + text = text .. "Primary Missions\n" + + ---@type Array + local primaryMissions = {} + for code, enabled in pairs(self.enabledByCode) do + if enabled == true then + local mission = self.missionsByCode[code] + if mission and mission:getState() == "ACTIVE" and mission.priority == "primary" then + table.insert(primaryMissions, mission) + end + end + end + + sortMissions(primaryMissions, groupPos) + + for _, mission in pairs(primaryMissions) do + text = text .. formatLine(mission) + end + + ---Secondary missions + text = text .. "\nSecondary Missions\n" + + ---@type Array + local secondaryMissions = {} + for code, enabled in pairs(self.enabledByCode) do + + if enabled == true then + local mission = self.missionsByCode[code] + if mission and mission:getState() == "ACTIVE" and mission.priority == "secondary" then + table.insert(secondaryMissions, mission) + end + end + end + + sortMissions(secondaryMissions, groupPos) + for _, mission in pairs(secondaryMissions) do + text = text .. formatLine(mission) + end + + + trigger.action.outTextForGroup(id, text, 20, true) + end + + missionCommands.removeItemForGroup(groupID, { "Overview" } ) + missionCommands.addCommandForGroup(groupID, "Overview", nil, MissionsOverviewToGroup, groupID) +end + +---@private +---@param groupID number +function MissionCommandsHelper:AddPinnedMission(groupID) + + local pinndedMission = self.pinnedByGroup[tostring(groupID)] + missionCommands.removeItemForGroup(groupID, { "Pinned Mission" }) + + if pinndedMission and self.enabledByCode[tostring(pinndedMission.code)] == true then + missionCommands.addCommandForGroup(groupID, "Pinned Mission", nil, missionBriefingRequested, { groupId = groupID, mission = pinndedMission }) + end + +end + +---@param groupID number +function MissionCommandsHelper:updateCommandsForGroup(groupID) + + self._logger:debug("Updating commands for group: " .. tostring(groupID)) + + self:AddPinnedMission(groupID) + self:AddOverviewCommand(groupID) + + self:ResetFolders(groupID) + + self:AddAllMissionCommandsToGroup(groupID) + + self:AddSupplyHubCommandsIfApplicable(groupID) + self:AddCargoCommands(groupID) + + ---@param id number + local clearView = function(id) + trigger.action.outTextForGroup(id, "clearing...", 1, true) + end + + missionCommands.removeItemForGroup(groupID, { "Clear View" } ) + missionCommands.addCommandForGroup(groupID, "Clear View", nil, clearView, groupID) + + missionCommands.removeItemForGroup(groupID, { "Refresh Missions" } ) + missionCommands.addCommandForGroup(groupID, "Refresh Missions", nil, function(refresh_mission_id) + self._logger:debug("Manual refresh of missions for group: " .. tostring(refresh_mission_id)) + self:updateCommandsForGroup(refresh_mission_id) + end, groupID) + +end + +local folderNames = { + primary = "Primary Missions", + secondary = "Secondary Missions", + supplyHub = "Supply Hub", + cargo = "Cargo" +} + + +---@param mission Mission +---@param groupID integer +function MissionCommandsHelper:PinMission(mission, groupID) + self._logger:debug("Pinning mission: [" .. mission.code .. "]" .. mission.name) + self.pinnedByGroup[tostring(groupID)] = mission + trigger.action.outTextForGroup(groupID, "Pinned mission: [" .. mission.code .. "]" .. mission.name, 3, true) + + self:updateCommandsForGroup(groupID) + mission:ShowBriefing(groupID) +end + +function MissionCommandsHelper:AddAllMissionCommandsToGroup(groupID) + + local perFolder = 9 + + local group = Spearhead.DcsUtil.GetPlayerGroupByGroupID(groupID) + ---@type Vec2 + local groupPos = { x=0, y=0 } + if group then + local pos = group:getUnit(1):getPosition().p + groupPos = { x= pos.x, y=pos.z } + end + + do --- primary missions + local count = 0 + local path = { [1] = folderNames.primary } + + ---@type Array + local primaryMissions = {} + + for code, enabled in pairs(self.enabledByCode) do + if enabled == true then + local mission = self.missionsByCode[code] + if mission and mission.priority == "primary" then + table.insert(primaryMissions, mission) + end + end + end + + sortMissions(primaryMissions, groupPos) + for _, mission in pairs(primaryMissions) do + count = count + 1 + if count <= perFolder then + local copied = Spearhead.Util.deepCopyTable(path) + self:addMissionCommands(groupID, copied, mission) + else + local name = "Next Menu ..." + missionCommands.addSubMenuForGroup(groupID, name, path) + path[#path+1] = name + count = 0 + end + end + end + + do --- secondary missions + local count = 0 + local path = { [1] = folderNames.secondary } + + local secondaryMissions = {} + for code, enabled in pairs(self.enabledByCode) do + if enabled == true then + local mission = self.missionsByCode[code] + if mission and mission.priority == "secondary" then + table.insert(secondaryMissions, mission) + end + end + end + + sortMissions(secondaryMissions, groupPos) + for _, mission in pairs(secondaryMissions) do + count = count + 1 + if count <= perFolder then + local copied = Spearhead.Util.deepCopyTable(path) + self:addMissionCommands(groupID, copied, mission) + else + local name = "Next Menu ..." + missionCommands.addSubMenuForGroup(groupID, name, path) + path[#path+1] = name + count = 0 + end + end + end +end + +---comment +---@private +---@param groupId integer +---@param path Array +---@param mission Mission +function MissionCommandsHelper:addMissionCommands(groupId, path, mission) + + if path then + + local group = Spearhead.DcsUtil.GetPlayerGroupByGroupID(groupId) + local distance = "[?]" + if group then + local lead = group:getUnit(1) + if lead and lead:isExist() == true then + local pos = lead:getPoint() + local Vec2Pos = { x= pos.x, y=pos.z } + local dist = Spearhead.Util.VectorDistance2d(Vec2Pos, mission.location) / 1852 + distance = "[" .. string.format("~%dnM", math.floor(dist)) .. "]" + end + end + + local missionFolderName = "[" .. mission.code .. "]" .. distance .. mission.name .. "( " .. mission.missionTypeDisplay .. " )" + missionCommands.addSubMenuForGroup(groupId, missionFolderName, path) + table.insert(path, missionFolderName) + + ---@type MissionBriefingRequestedArgs + local missionBriefingRequestedArgs = { groupId = groupId, mission = mission } + missionCommands.addCommandForGroup(groupId, "Briefing", path, missionBriefingRequested,missionBriefingRequestedArgs) + + ---@type PinMissionCommandArgs + local pinMissionCommandArgs = { self = self, groupId = groupId, mission = mission } + missionCommands.addCommandForGroup(groupId, "Pin", path, pinMissionCommand, pinMissionCommandArgs) + end +end + +---@private +---@param groupID integer +function MissionCommandsHelper:AddSupplyHubCommandsIfApplicable(groupID) + + if self._supplyHubGroups[tostring(groupID)] ~= true then return end + + self._logger:debug("Adding supply hub commands for group: " .. tostring(groupID)) + + local group = Spearhead.DcsUtil.GetPlayerGroupByGroupID(groupID) + if group == nil then return end + + local unit = group:getUnit(1) + if unit == nil then return end + + ---@class LoadCargoCommandParams + ---@field unitID number + ---@field groupID number + ---@field crateType CrateType + ---@field supplyUnitsTracker SupplyUnitsTracker + + ---comment + ---@param params LoadCargoCommandParams + local loadCargoCommand = function(params) + local crateType = params.crateType + local supplyUnitsTracker = params.supplyUnitsTracker + if supplyUnitsTracker then + supplyUnitsTracker:UnitRequestCrateLoading(params.groupID, crateType) + end + end + + local path = { [1] = folderNames.supplyHub } + + ---@type LoadCargoCommandParams + local farpParams1000 = { unitID = unit:getID(), groupID = group:getID(), crateType = "FARP_CRATE_1000", supplyUnitsTracker = self._supplyUnitsTracker } + missionCommands.addCommandForGroup(groupID, "Load FARP Crate (1000)", path, loadCargoCommand, farpParams1000) + + ---@type LoadCargoCommandParams + local farpParams2000 = { unitID = unit:getID(), groupID = group:getID(), crateType = "FARP_CRATE_2000", supplyUnitsTracker = self._supplyUnitsTracker } + missionCommands.addCommandForGroup(groupID, "Load FARP Crate (2000)", path, loadCargoCommand, farpParams2000) + + ---@type LoadCargoCommandParams + local samParms1000 = { unitID = unit:getID(), groupID = group:getID(), crateType = "SAM_CRATE_2000", supplyUnitsTracker = self._supplyUnitsTracker } + missionCommands.addCommandForGroup(groupID, "Load SAM Crate (1000)", path, loadCargoCommand, samParms1000) + + ---@type LoadCargoCommandParams + local samParms2000 = { unitID = unit:getID(), groupID = group:getID(), crateType = "SAM_CRATE_2000", supplyUnitsTracker = self._supplyUnitsTracker } + missionCommands.addCommandForGroup(groupID, "Load SAM Crate (2000)", path, loadCargoCommand, samParms2000) + + ---@type LoadCargoCommandParams + local samParms2000 = { unitID = unit:getID(), groupID = group:getID(), crateType = "AIRBASE_CRATE_2000", supplyUnitsTracker = self._supplyUnitsTracker } + missionCommands.addCommandForGroup(groupID, "Airbase Crate (2000)", path, loadCargoCommand, samParms2000) +end + +function MissionCommandsHelper:AddCargoCommands(groupID) + + local group = Spearhead.DcsUtil.GetPlayerGroupByGroupID(groupID) + if group == nil then return end + + local unit = group:getUnit(1) + if unit == nil then return end + + ---@class UnloadCargoCommandParams + ---@field unitID number + ---@field crateType CrateType + ---@field supplyUnitsTracker SupplyUnitsTracker + + ---comment + ---@param params UnloadCargoCommandParams + local unloadCargoCommand = function(params) + local unitID = params.unitID + local crateType = params.crateType + params.supplyUnitsTracker:UnloadRequested(unitID, crateType) + end + + local cargo = self._supplyUnitsTracker:GetCargoInUnit(unit:getID()) + if cargo then + for cargoType, amount in pairs(cargo) do + local cargoConfig = Spearhead.classes.stageClasses.helpers.supplies.SupplyConfigHelper.getSupplyConfig(cargoType) + if cargoConfig then + for i = 1, amount do + local path = { [1] = folderNames.cargo } + ---@type UnloadCargoCommandParams + local params = { unitID = unit:getID(), crateType = cargoType, supplyUnitsTracker = self._supplyUnitsTracker } + missionCommands.addCommandForGroup(groupID, "Unload " .. cargoConfig.displayName, path, unloadCargoCommand, params) + end + end + end + end +end + + + +---@private +---@param groupId integer +function MissionCommandsHelper:addMissionFolders(groupId) + + missionCommands.addSubMenuForGroup(groupId, folderNames.primary) + missionCommands.addSubMenuForGroup(groupId, folderNames.secondary) + + if self._supplyHubGroups[tostring(groupId)] == true then + self._logger:debug("Adding supply hub commands folder for group: " .. tostring(groupId)) + missionCommands.addSubMenuForGroup(groupId, folderNames.supplyHub) + end + + local group = Spearhead.DcsUtil.GetPlayerGroupByGroupID(groupId) + if group == nil then return end + + local unit = group:getUnit(1) + if unit == nil then return end + + local cargo = self._supplyUnitsTracker:GetCargoInUnit(unit:getID()) + if cargo ~= nil then + missionCommands.addSubMenuForGroup(groupId, folderNames.cargo) + end +end + +---@private +---@param groupId integer +function MissionCommandsHelper:removeMissionFolders(groupId) + missionCommands.removeItemForGroup(groupId, { folderNames.primary }) + missionCommands.removeItemForGroup(groupId, { folderNames.secondary }) + missionCommands.removeItemForGroup(groupId, { folderNames.supplyHub }) + missionCommands.removeItemForGroup(groupId, { folderNames.cargo }) +end + +---@private +function MissionCommandsHelper:ResetFolders(groupID) + -- Cleanup mission folder + self:removeMissionFolders(groupID) + + -- Add mission folders + self:addMissionFolders(groupID) +end + +if Spearhead == nil then Spearhead = {} end +if Spearhead.classes == nil then Spearhead.classes = {} end +if Spearhead.classes.stageClasses == nil then Spearhead.classes.stageClasses = {} end +if Spearhead.classes.stageClasses.helpers == nil then Spearhead.classes.stageClasses.helpers = {} end +Spearhead.classes.stageClasses.helpers.MissionCommandsHelper = MissionCommandsHelper diff --git a/classes/stageClasses/missions/BuildableMission.lua b/classes/stageClasses/missions/BuildableMission.lua index de35ce9..745d855 100644 --- a/classes/stageClasses/missions/BuildableMission.lua +++ b/classes/stageClasses/missions/BuildableMission.lua @@ -119,7 +119,7 @@ function BuildableMission:ShowBriefing(groupID) "\n\n" .. "NOTE: Do not land in the orange construction zone!" - trigger.action.outTextForGroup(groupID, briefing, 30) + trigger.action.outTextForGroup(groupID, briefing, Spearhead.GlobalConfig:getBriefingTime()) end function BuildableMission:MarkMissionAreaToGroup(groupID) diff --git a/classes/stageClasses/missions/baseMissions/Mission.lua b/classes/stageClasses/missions/baseMissions/Mission.lua index 5434ee2..0acaac9 100644 --- a/classes/stageClasses/missions/baseMissions/Mission.lua +++ b/classes/stageClasses/missions/baseMissions/Mission.lua @@ -94,7 +94,7 @@ function Mission:ShowBriefing(groupId) local text = "Mission [" .. self.code .. "] " .. self.name .. "\n \n" .. briefing .. " \n \n" .. stateString - trigger.action.outTextForGroup(groupId, text, 30); + trigger.action.outTextForGroup(groupId, text, Spearhead.GlobalConfig:getBriefingTime()); end @@ -143,9 +143,6 @@ if not Spearhead.classes.stageClasses.missions then Spearhead.classes.stageClass if not Spearhead.classes.stageClasses.missions.baseMissions then Spearhead.classes.stageClasses.missions.baseMissions = {} end Spearhead.classes.stageClasses.missions.baseMissions.Mission = Mission - - - do --aliases --- @alias MissionPriority diff --git a/config.lua b/config.lua index 458c890..44014cf 100644 --- a/config.lua +++ b/config.lua @@ -4,6 +4,10 @@ SpearheadConfig = { ---DEBUG LOGGING debugEnabled = false, -- default false + --- The time briefings should be displayed by default. + --- Players can always "Clear Messages" through the F10 menu, so setting it to a high value can be + briefingMessageDuration = 30, --default 30 + CapConfig = { --quickly enable of disable the entire CAP Logic --(you can also just rename all units to not be named "CAP_")