commit 929a188497f29adb527ef4e1e15e5e99af78538b Author: Christian Franz Date: Wed Jan 19 20:55:23 2022 +0100 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/Doc/DML Documentation.pdf b/Doc/DML Documentation.pdf new file mode 100644 index 0000000..05551c3 Binary files /dev/null and b/Doc/DML Documentation.pdf differ diff --git a/modules/FARPZones.lua b/modules/FARPZones.lua new file mode 100644 index 0000000..ef32af2 --- /dev/null +++ b/modules/FARPZones.lua @@ -0,0 +1,455 @@ +FARPZones = {} +FARPZones.version = "1.0.2" +--[[-- + Version History + 1.0.0 - Initial Version + 1.0.1 - support "none" as defender types + - default types for defenders to none + 1.0.2 - hiddenRed, hiddenBlue, hiddenGrey + +--]]-- +FARPZones.requiredLibs = { + "dcsCommon", -- common is of course needed for everything + -- pretty stupid to check for this since we + -- need common to invoke the check, but anyway + "cfxZones", -- Zones, of course +-- "cfxCommander", -- to make troops do stuff +-- "cfxGroundTroops", -- generic when dropping troops +} + +-- *** DOES NOT EXTEND ZONES, USES OWN STRUCT *** +-- *** SETS ZONE.OWNER IF PRESENT, POSSIBLE CONFLICT +-- *** WITH OWNED ZONES +-- *** DOES NOT WORK WITH AIRFIELDS! +-- *** USE OWNED ZONES AND SPAWNZONES FOR AIRFIELDS + +--[[-- + Functioning, capturable FARPS with all services. To use, + place a FARP on a map, an a Zone that contains the FARP, + then add the following attributes + + Z O N E A T T R I B U T E S + + - FARP : indicate that this is a FARP ZONE. Must + contain at least one FARP inside the zone. The first FARP + found will become main FARP to determine ownership + - rPhiHDef - r, phi, heading separated by coma, eg + <120, 245, 0> that defines radius and Phi (in degrees) for + the center position, and heading for the group of defenders + for this FARP. r, phi relative to ZONE center, not FARP + - redDefenders - Type Strings for defender vehicles, coma + separated. e.g. "BTR-80,BTR-80" will create two BTR-80 + vehicles when owned by RED + - blueDefenders - Type Strings for defender vehicles, coma + separated when owned by blue + - formation - formation for defenders, e.g. "grid". + optional, defaults to "circle-out". Span raidus is 100m + - rPhiHRes - r, phi and H separated by coma to determine + the location and heading of FARP Resopurces (services). + They always auto-gen all vehicles for all services. They + always spawn as "line_V" through center with radius 50 meters + Optional. Will spawn around zone center else. Remember that + all these vehicles MUST be within 150m of FARP Center + - hidden - if true, no circle on map, else (default) visible + to all with owner color + +--]]-- + +FARPZones.resourceTypes = { + "M978 HEMTT Tanker", -- BLUE fuel + "M 818", -- BLUE ammo + "M 818", -- Blue Power (repair???) + "Hummer", -- BLUE ATC + + "ATMZ-5", -- RED refuel + "KAMAZ Truck", -- rearming + "SKP-11", -- communication + "ZiL-131 APA-80", -- Power +} + +FARPZones.spinUpDelay = 30 -- seconds until FARP becomes operational after capture + + +FARPZones.allFARPZones = {} +FARPZones.startingUp = false + +-- FARP ZONE ACCESS +function FARPZones.addFARPZone(aFARP) + FARPZones.allFARPZones[aFARP.zone] = aFARP +end + +function FARPZones.removeFARPZone(aFARP) + FARPZones.allFARPZones[aFARP.zone] = nil +end + +function FARPZones.getFARPForZone(aZone) + return FARPZones.allFARPZones[aZone] +end + +function FARPZones.getFARPZoneForFARP(aFarp) + -- find the first FARP zone that associates with + -- aFARP (an airField) + for idx, aFarpZone in pairs(FARPZones.allFARPZones) do + local associatedFarps = aFarpZone.myFarps + for itoo, assocF in pairs(associatedFarps) do + if assocF == aFarp then return aFarpZone end + end + end + + return nil +end + +function FARPZones.createFARPFromZone(aZone) + -- WARNING: WILL SET ZONE.OWNER + local theFarp = {} + theFarp.zone = aZone + theFarp.name = aZone.name + + -- find the FARPS that belong to this zone + local thePoint = aZone.point + local mapFarps = dcsCommon.getAirbasesInRangeOfPoint( + thePoint, + aZone.radius, + 1 -- FARPS = Helipads + ) + -- only #1 is significant for owner + theFarp.myFarps = mapFarps + theFarp.owner = 0 -- start with neutral + aZone.owner = 0 + if #mapFarps == 0 then + trigger.action.outText("***Farp Zones: no FARP found for zone " .. aZone.name, 30) + else + for idx, aFarp in pairs(mapFarps) do +-- trigger.action.outText("Associated FARP " .. aFarp:getName() .. " with FARP Zone " .. aZone.name, 30) + end + + theFarp.mainFarp = theFarp.myFarps[1] + theFarp.point = theFarp.mainFarp:getPoint() -- this is FARP, not zone!!! + theFarp.owner = theFarp.mainFarp:getCoalition() + aZone.owner = theFarp.owner + end + + -- get r and phi for defenders + local rPhi = cfxZones.getVectorFromZoneProperty( + aZone, + "rPhiHDef", + 3) +-- trigger.action.outText("*** DEF rPhi are " .. rPhi[1] .. " and " .. rPhi[2], 30) + -- get r and phi for facilities + -- create a new defenderzone for this + local r = rPhi[1] + local phi = rPhi[2] * 0.0174533 -- 1 degree = 0.0174533 rad + local dx = aZone.point.x + r * math.cos(phi) + local dz = aZone.point.z + r * math.sin(phi) + theFarp.defZone = cfxZones.createSimpleZone(aZone.name .. "-Def", {x=dx, y = 0, z=dz}, 100) + theFarp.defHeading = rPhi[3] + + rPhi = cfxZones.getVectorFromZoneProperty( + aZone, + "rPhiHRes", + 3) -- optional, will reterurn {0,0} else +-- trigger.action.outText("*** RES rPhi are " .. rPhi[1] .. " and " .. rPhi[2] .. " heading " .. rPhi[3], 30) + r = rPhi[1] + phi = rPhi[2] * 0.0174533 -- 1 degree = 0.0174533 rad + dx = aZone.point.x + r * math.cos(phi) + dz = aZone.point.z + r * math.sin(phi) + theFarp.resZone = cfxZones.createSimpleZone(aZone.name .. "-Res", {x=dx, y = 0, z=dz}, 50) + theFarp.resHeading = rPhi[3] + + -- get redDefenders - defenders produced when red owned + theFarp.redDefenders = cfxZones.getStringFromZoneProperty(aZone, "redDefenders", "none") + -- get blueDefenders - defenders produced when blue owned + theFarp.blueDefenders = cfxZones.getStringFromZoneProperty(aZone, "blueDefenders", "none") + -- get formation for defenders + theFarp.formation = cfxZones.getStringFromZoneProperty(aZone, "formation", "circle_out") + theFarp.count = 0 -- for uniqueness + theFarp.hideRed = cfxZones.getBoolFromZoneProperty(aZone, "hideRed") + theFarp.hideBlue = cfxZones.getBoolFromZoneProperty(aZone, "hideBlue") + theFarp.hideGrey = cfxZones.getBoolFromZoneProperty(aZone, "hideGrey") + theFarp.hidden = cfxZones.getBoolFromZoneProperty(aZone, "hidden") + + return theFarp +end +--[[-- +function FARPZones.drawFarp(theFarp) + local theZone = theFarp.zone + local theOwner = theFarp.owner + FARPZones.drawZoneInMap(theZone, theOwner) +end +--]]-- +function FARPZones.drawFARPCircleInMap(theFarp) + if not theFarp then return end + + if theFarp.zone and theFarp.zone.markID then + -- remove previous mark + trigger.action.removeMark(theFarp.zone.markID) + theFarp.zone.markID = nil + end + + if theFarp.hideRed and + theFarp.owner == 1 then + -- hide only when red + return + end + + if theFarp.hideBlue and + theFarp.owner == 2 then + -- hide only when blue + return + end + + if theFarp.hideGrey and + theFarp.owner == 0 then + -- hide only when blue + return + end + + if theFarp.hidden then + return + end + -- owner is 0 = neutral, 1 = red, 2 = blue + -- will save markID in zone's markID + -- should be able to only show owned + -- draws 2km radius circle around main (first) FARP + local aZone = theFarp.zone + local thePoint = theFarp.point + local owner = theFarp.owner + + local lineColor = {1.0, 0, 0, 1.0} -- red + local fillColor = {1.0, 0, 0, 0.2} -- red + + if owner == 2 then + lineColor = {0.0, 0, 1.0, 1.0} + fillColor = {0.0, 0, 1.0, 0.2} + elseif owner == 0 then + lineColor = {0.8, 0.8, 0.8, 1.0} + fillColor = {0.8, 0.8, 0.8, 0.2} + end + + local theShape = 2 -- circle + local markID = dcsCommon.numberUUID() + + trigger.action.circleToAll(-1, markID, thePoint, 2000, lineColor, fillColor, 1, true, "") + aZone.markID = markID + +end + +function FARPZones.drawZoneInMap(aZone, owner) + -- owner is 0 = neutral, 1 = red, 2 = blue + -- will save markID in zone's markID + -- should be moved to cfxZones + -- should be able to only show owned + + if aZone.markID then + trigger.action.removeMark(aZone.markID) + end + + + local lineColor = {1.0, 0, 0, 1.0} -- red + local fillColor = {1.0, 0, 0, 0.2} -- red + + if owner == 2 then + lineColor = {0.0, 0, 1.0, 1.0} + fillColor = {0.0, 0, 1.0, 0.2} + elseif owner == 0 then + lineColor = {0.8, 0.8, 0.8, 1.0} + fillColor = {0.8, 0.8, 0.8, 0.2} + end + + local theShape = 2 -- circle + local markID = dcsCommon.numberUUID() + + trigger.action.circleToAll(-1, markID, aZone.point, aZone.radius, lineColor, fillColor, 1, true, "") + aZone.markID = markID + +end + +function FARPZones.scheduedProduction(args) + -- args contain [aFarp, owner] + -- make sure that owner is still the same + -- and if so, branch to produce vehicles + -- ***write-though to zone.owner + + local theFarp = args[1] + local owner = args[2] + + -- make sure the farp wasn't conquered in the meantime + if owner == theFarp.mainFarp:getCoalition() then + -- ok, still same owner , go ahead and spawn + theFarp.owner = owner + theFarp.zone.owner = owner + FARPZones.produceVehicles(theFarp) + trigger.action.outTextForCoalition(theFarp.owner, "FARP " .. theFarp.name .. " has become operational!", 30) + trigger.action.outSoundForCoalition(theFarp.owner, "Quest Snare 3.wav") + + end +end + +function FARPZones.produceVehicles(theFarp) + -- first, remove anything that may still be there + if theFarp.defenders and theFarp.defenders:isExist() then + theFarp.defenders:destroy() + end + + if theFarp.resources and theFarp.resources:isExist() then + theFarp.resources:destroy() + end + + theFarp.defenders = nil + theFarp.resources = nil + + -- spawn defenders + local owner = theFarp.owner -- coalition + local theTypes = theFarp.redDefenders + if owner == 2 then theTypes = theFarp.blueDefenders end + local unitTypes = dcsCommon.splitString(theTypes, ",") + if #unitTypes < 1 then + table.insert(unitTypes, "Soldier M4") -- make it one m4 trooper as fallback + end + + -- trigger.action.outText("*** ENTER produce vehicles, will produce " .. theTypes , 30) + local theCoalition = theFarp.owner + + if theTypes ~= "none" then + local theGroup = cfxZones.createGroundUnitsInZoneForCoalition ( + theCoalition, + theFarp.name .. "-D" .. theFarp.count, -- must be unique + theFarp.defZone, + unitTypes, + theFarp.formation, + theFarp.defHeading) + -- we do not add these troops to ground troop management + theFarp.defenders = theGroup -- but we retain a handle just in case + end + + unitTypes = FARPZones.resourceTypes + local theGroup = cfxZones.createGroundUnitsInZoneForCoalition ( + theCoalition, + theFarp.name .. "-R" .. theFarp.count, -- must be unique + theFarp.resZone, + unitTypes, + "line_v", + theFarp.resHeading) + theFarp.resources = theGroup + + -- update unique counter + theFarp.count = theFarp.count + 1 +end + +-- +-- EVENT PROCESSING +-- +FARPZones.myEvents = {10, } -- 10: S_EVENT_BASE_CAPTURED +function FARPZones.isInteresting(eventID) + -- return true if we are interested in this event, false else + for key, evType in pairs(FARPZones.myEvents) do + if evType == eventID then return true end + end + return false +end + +function FARPZones.preProcessor(event) + if not event then return false end + if not event.place then return false end + + return FARPZones.isInteresting(event.id) +end + +function FARPZones.postProcessor(event) + -- don't do anything +end + +function FARPZones.somethingHappened(event) + -- *** writes to zone.owner + local theUnit = event.initiator + local ID = event.id + + trigger.action.outText("FZ: something happened", 30) + local aFarp = event.place + local zonedFarp = FARPZones.getFARPZoneForFARP(aFarp) + + if not zonedFarp then + trigger.action.outText("Hand change, NOT INTERESTING", 30) + return + end + + local newOwner = aFarp:getCoalition() + local blueRed = "Red" + if newOwner == 2 then blueRed = "Blue" end + trigger.action.outText("FARP " .. zonedFarp.zone.name .. " captured by " .. blueRed .."!", 30) + trigger.action.outSound("Quest Snare 3.wav") + zonedFarp.owner = newOwner + zonedFarp.zone.owner = newOwner + -- better: sound winm and lose to different sides + -- update color in map +-- FARPZones.drawFarp(zonedFarp) + FARPZones.drawFARPCircleInMap(zonedFarp) + + -- remove all existing resources immediately, + -- no more service available + if zonedFarp.resources and zonedFarp.resources:isExist() then + zonedFarp.resources:destroy() + zonedFarp.resources = nil + end + + -- now schedule operational after spin-up delay + timer.scheduleFunction( + FARPZones.scheduedProduction, + {zonedFarp, newOwner}, -- pass farp struct and current owner + timer.getTime() + FARPZones.spinUpDelay + ) + +end + + + +-- +-- Start +-- + + +function FARPZones.start() + -- check libs + if not dcsCommon.libCheck("cfx FARP Zones", + FARPZones.requiredLibs) then + return false + end + + FARPZones.startingUp = true + + -- install callbacks for FARP-relevant events + dcsCommon.addEventHandler(FARPZones.somethingHappened, + FARPZones.preProcessor, + FARPZones.postProcessor) + + -- collect all FARP Zones + local theZones = cfxZones.getZonesWithAttributeNamed("FARP") + for k, aZone in pairs(theZones) do + local aFARP = FARPZones.createFARPFromZone(aZone) -- read attributes from DCS + FARPZones.addFARPZone(aFARP) -- add to managed zones +-- FARPZones.drawFarp(aFARP) + FARPZones.drawFARPCircleInMap(aFARP) -- mark in map + FARPZones.produceVehicles(aFARP) -- allocate initial vehicles + --trigger.action.outText("processed FARP " .. aZone.name .. " now owned by " .. aZone.owner, 30) + end + + FARPZones.startingUp = false + + trigger.action.outText("cf/x FARP Zones v" .. FARPZones.version .. " started", 30) + return true +end + + +-- let's get rolling +if not FARPZones.start() then + trigger.action.outText("cf/x FARP Zones aborted: missing libraries", 30) + FARPZones = nil +end + +--[[-- +Improvements: + per FARP/Helipad in zone: create resources (i.e. support multi 4-Pad FARPS out of the box + + make hidden farps only appear for owning side + +--]]-- \ No newline at end of file diff --git a/modules/cargoSuper.lua b/modules/cargoSuper.lua new file mode 100644 index 0000000..ba33560 --- /dev/null +++ b/modules/cargoSuper.lua @@ -0,0 +1,203 @@ +cargoSuper = {} +cargoSuper.version = "1.1.0" +--[[-- +version history + 1.0.0 - initial version + 1.1.0 - removeMassObjectFrom supports name directly for mass object + - cargoSuper tracks all mass objects + - deleteMassObject() + - removeMassObjectFrom supports forget option + - createMassObject supports auto-gen UUID name + - removeAllMassForCargo renamed to removeAllMassForCategory + - category default "cSup!DefCat" + - getAllCategoriesFor alias for getAllCargos + - getManifestForCategory alias for getManifestFor + - removeAllMassFor() + +CargoSuper manages weigth for a logical named unit. Weight can be added +to arbitrary categories like 'passengers', 'cargo' or "whatever". In order +to add weight to a unit, first create a massObject through createMassObject +and then add that mass object to the unit via addMassTo, with a category name +you can get access to massobjects via getMassObjects +When done, you can remove the mass object via removeMassObject or +removeAll + +To get a unit's total weight, use getTotalMass() + +IMPORTANT: +This module does ***N*O*T*** call trigger.action.setUnitInternalCargo +you must do that yourself + +--]]-- +cargoSuper.requiredLibs = { + "dcsCommon", -- common is of course needed for everything + -- pretty stupid to check for this since we + -- need common to invoke the check, but anyway + "nameStats", -- generic data module for weight +} + +cargoSuper.cargos = {} +cargoSuper.massObjects = {} + +-- create a massObject. reference object can be used to store +-- anything that links an associated object:getSampleRate() +-- massName can be anything but must be unique as it is used to store. pass nil for UUID-created name +function cargoSuper.createMassObject(massInKg, massName, referenceObject) + local theObject = {} + theObject.mass = massInKg + theObject.name = massName + theObject.ref = referenceObject + + if not massName then + massName = dcsCommon.uuid("cSup!N") + end + + local existingMO = cargoSuper.massObjects[massName] + if existingMO then + trigger.action.outText("+++cSuper: WARNING - " .. massName .. " exists already, overwritten!", 30) + end + cargoSuper.massObjects[massName] = theObject + return theObject +end + +function cargoSuper.deleteMassObject(massObject) + if not massObject then return end + local theName = "" + if type(massObject) == "string" then + theName = massObject + else + theName = massObject.name + end + cargoSuper.massObjects[massName] = nil +end + +function cargoSuper.addMassObjectTo(name, category, theMassObject) + if not theMassObject then return end + if not category then category = "cSup!DefCat" end + -- use nameStats to access private data table + local theMassTable = nameStats.getTable(name, category, cargoSuper.cargos) + theMassTable[theMassObject.name] = theMassObject +end + +function cargoSuper.removeMassObjectFrom(name, category, theMassObject, forget) + if not theMassObject then return end + if not category then category = "cSup!DefCat" end + if not forget then forget = true end + -- use nameStats to access private data table + -- return the data table stored under category. category *can* be nil + -- v1.0.1 can also provide mass object name + -- instead of mass object itself. no check!! + local moName = "" + if type(theMassObject) == "string" then + moName = theMassObject + else + moName = theMassObject.name + end + local theMassTable = nameStats.getTable(name, category, cargoSuper.cargos) + theMassTable[moName] = nil + if forget then + cargoSuper.deleteMassObject(theMassObject) + end +end + +-- DO NOT PUBLISH. Provided only for backwards compatibility +function cargoSuper.removeAllMassForCargo(name, catergory) + if not category then category = "cSup!DefCat" end + nameStats.reset(name, category, cargoSuper.cargos) +end + +-- alias for removeAllMassForCargo +function cargoSuper.removeAllMassForCategory(name, catergory) + cargoSuper.removeAllMassForCargo(name, catergory) +end + +function cargoSuper.removeAllMassFor(name) + if not name then return end + local categories = nameStats.getAllPathes(name, cargoSuper.cargos) + for idx, cat in pairs(categories) do + cargoSuper.removeAllMassForCategory(name, cat) + end +end + +-- returns all cargo categories for name +-- DO NOT PUBLISH. BAD NAMING +function cargoSuper.getAllCargosFor(name) + local categories = nameStats.getAllPathes(name, cargoSuper.cargos) + return categories +end + +-- alias for badly named method above +function cargoSuper.getAllCategoriesFor(name) + cargoSuper.getAllCargosFor(name) +end + +-- return all mass objects that are in name, category as table +-- that can be accessed as *array* +-- DO NOT PUBLISH. NAMING IS BAD +function cargoSuper.getManifestFor(name, category) + if not category then category = "cSup!DefCat" end + local theMassTable = nameStats.getTable(name, category, cargoSuper.cargos) + return dcsCommon.enumerateTable(theMassTable) +end + +-- alias for badly named method above +function cargoSuper.getManifestForCategory(name, category) + cargoSuper.getManifestFor(name, category) +end + +function getManifestTextFor(name, category, includeTotal) + if not category then category = "cSup!DefCat" end + local theMassTable = cargoSuper.getManifestFor(name, category) + local desc = "" + local totalMass = 0 + local isFirst = true + for idx, massObject in pairs(theMassTable) do + if not isFirst then + desc = desc .. "\n" + end + totalMass = totalMass + massObject.mass + desc = desc .. massObject.name .. " (" .. massObject.mass .. "kg)" + isFirst = false + end + if includeTotal and (isFirst == false) then + -- we only do this if we have at least one (isFirst is false) + desc = desc .. "\nTotal Weight: " .. totalMass .. "kg" + end + return desc +end + +function cargoSuper.calculateTotalMassForCategory(name, category) + if not category then category = "cSup!DefCat" end + theMasses = cargoSuper.getManifestFor(name, category) + local totalMass = 0 + for massName, massObject in pairs(theMasses) do + totalMass = totalMass + massObject.mass + end + return totalMass +end + +function cargoSuper.calculateTotalMassFor(name) + local allCategories = cargoSuper.getAllCargosFor(name) + local totalMass = 0 + for idx, category in pairs(allCategories) do + totalMass = totalMass + cargoSuper.calculateTotalMassForCategory(name, category) + end + return totalMass +end + +function cargoSuper.start() + -- make sure we have loaded all relevant libraries + if not dcsCommon.libCheck("cfx CargoSuper", cargoSuper.requiredLibs) then + trigger.action.outText("cf/x CargoSuper aborted: missing libraries", 30) + return false + end + + trigger.action.outText("cf/x CargoSuper v" .. cargoSuper.version .. " loaded", 30) + return true + +end + +-- go go go +if not cargoSuper.start() then + cargoSuper = nil +end \ No newline at end of file diff --git a/modules/cfxArtillery.lua b/modules/cfxArtillery.lua new file mode 100644 index 0000000..a8adf2c --- /dev/null +++ b/modules/cfxArtillery.lua @@ -0,0 +1,406 @@ +cfxArtilleryDemon = {} +cfxArtilleryDemon.version = "1.0.2" +-- based on cfx stage demon v 1.0.2 + +cfxArtilleryDemon.messageToAll = true -- set to false if messages should be sent only to the group that set the mark +cfxArtilleryDemon.messageTime = 30 -- how long a message stays on the sceeen + +-- cfxArtillery hooks into DCS's mark system to intercept user +-- transactions with the mark system and uses that for arty targeting +-- used to interactively add ArtilleryZones during gameplay + +-- Copyright (c) 2021 by Christian Franz and cf/x AG + +cfxArtilleryDemon.autostart = true -- start automatically + +-- whenever you begin a Mark with the string below, it will be taken as a command +-- and run through the command parser, stripping the mark, and then splitting +-- by blanks +cfxArtilleryDemon.markOfDemon = "-" -- all commands must start with this sequence +cfxArtilleryDemon.splitDelimiter = " " + +cfxArtilleryDemon.unitFilterMethod = nil -- optional user filtering redirection. currently + -- set to allow all users use cfxArtillery +cfxArtilleryDemon.processCommandMethod = nil -- optional initial command processing redirection + -- currently set to cfxArtillery's own processor +cfxArtilleryDemon.commandTable = {} -- key, value pair for command processing per keyword + -- all commands cfxArtillery understands are used as keys and + -- the functions that process them are used as values + -- making the parser a trivial table :) + +cfxArtilleryDemon.demonID = nil -- used only for suspending the event callback + +-- unit authorization. You return false to disallow this unit access +-- to commands +-- simple authorization checks would be to allow only players +-- on neutral side, or players in range of location with Lino of sight +-- to that point +-- +function cfxArtilleryDemon.authorizeAllUnits(event) + -- units/groups that are allowed to give a command can be filtered. + -- return true if the unit/group may give commands + -- cfxArtillery allows anyone to give it commands + return true +end + +function cfxArtilleryDemon.hasMark(theString) + -- check if the string begins with the sequece to identify commands + if not theString then return false end + return theString:find(cfxArtilleryDemon.markOfDemon) == 1 +end + +function cfxArtilleryDemon.splitString(inputstr, sep) + if sep == nil then + sep = "%s" + end + if inputstr == nil then + inputstr = "" + end + + local t={} + for str in string.gmatch(inputstr, "([^"..sep.."]+)") do + table.insert(t, str) + end + return t +end + +function cfxArtilleryDemon.str2num(inVal, default) + if not default then default = 0 end + if not inVal then return default end + if type(inVal) == "number" then return inVal end + local num = nil + if type(inVal) == "string" then num = tonumber(inVal) end + if not num then return default end + return num +end + +-- +-- output method. can be customized, so we have a central place where we +-- can control how output is handled. Is currently outText and outTextToGroup +-- +function cfxArtilleryDemon.outMessage(theMessage, args) + if not args then + args = {} + end + local toAll = args.toAll -- will only be true if defined and set to true + + if not args.group then + toAll = true + else + if not args.group:isExist() then + toAll = true + end + end + toAll = toAll or cfxArtilleryDemon.messageToAll + if not toAll then + trigger.action.outTextToGroup(args.group, theMessage, cfxArtilleryDemon.messageTime) + else + trigger.action.outText(theMessage, cfxArtilleryDemon.messageTime) + end +end + +-- +-- get all player groups - since there is no getGroupByIndex in DCS (yet) +-- we simply collect all player groups (since only palyers can place marks) +-- and try to match their group ID to the one given by mark +function cfxArtilleryDemon.getAllPayerGroups() + local coalitionSides = {0, 1, 2} -- we currently have neutral, red, blue + local playerGroups = {} + for i=1, #coalitionSides do + local theSide = coalitionSides[i] + -- get all players for this side + local thePlayers = coalition.getPlayers(theSide) + for p=1, #thePlayers do + aPlayerUnit = thePlayers[p] -- docs say this is a unit table, not a person! + if aPlayerUnit:isExist() then + local theGroup = aPlayerUnit:getGroup() + if theGroup:isExist() then + local gID = theGroup:getID() + playerGroups[gID] = theGroup -- multiple players per group results in one group + end + end + end + end + return playerGroups +end + +function cfxArtilleryDemon.retrieveGroupFromEvent(theEvent) +-- DEBUG CODE + if theEvent.initiator then + trigger.action.outText("EVENT: initiator set to " .. theEvent.initiator:getName(), 30) + else + trigger.action.outText("EVENT: NO INITIATOR", 30) + end + + trigger.action.outText("EVENT: groupID = " .. theEvent.groupID, 30) + + -- trivial case: initiator is set, and we can access the group + if theEvent.initiator then + if theEvent.initiator:isExist() then + return theEvent.initiator:getGroup() + end + end + + -- ok, bad news: initiator wasn't filled. let's try the fallback: event.groupID + if theEvent.groupID and theEvent.groupID > 0 then + local playerGroups = cfxArtilleryDemon.getAllPayerGroups() + if playerGroups[theEvent.groupID] then + return palyerGroups[theEvent.groupID] + end + end + + -- nope, return nil + return nil +end + +-- main hook into DCS. Called whenever a Mark-related event happens +-- very simple: look if text begins with special sequence, and if so, +-- call the command processor. Note that you can hook your own command +-- processor in by changing the value of processCommandMethod +function cfxArtilleryDemon:onEvent(theEvent) + -- while we can hook into any of the three events, + -- we curently only utilize CHANGE Mark + if not (theEvent.id == world.event.S_EVENT_MARK_ADDED) and + not (theEvent.id == world.event.S_EVENT_MARK_CHANGE) and + not (theEvent.id == world.event.S_EVENT_MARK_REMOVED) then + -- not of interest for us, bye bye + return + end + + -- build the messageOut() arg table + local args = {} + args.toAll = cfxArtilleryDemon.toAll +-- +-- + args.toAll = false -- FORCE GROUPS FOR DEBUGGING OF NEW CODE +-- +-- + if not args.toAll then + -- we want group-targeted messaging + -- so we need to retrieve the group + local theGroup = cfxArtilleryDemon.retrieveGroupFromEvent(theEvent) + if not theGroup then + args.toAll = true + trigger.action.messageOut("*** WARNING: cfxArtilleryDemon can't find group for command", 30) + else + args.group = theGroup + end + end + cfxArtilleryDemon.args = args -- copy reference so we can easily use it in messageOut + + -- when we get here, we have a mark event + -- see if the unit filter lets it pass + if not cfxArtilleryDemon.unitFilterMethod(theEvent) then + return -- unit is not allowed to give demon orders. bye bye + end + + if theEvent.id == world.event.S_EVENT_MARK_ADDED then + -- add mark is quite useless for us as we are called when the user clicks, with no + -- text in the description yet. Later abilities may want to use it though + end + + if theEvent.id == world.event.S_EVENT_MARK_CHANGE then + -- when changed, the mark's text is examined for a command + -- if it starts with the 'mark' string ("*" by default) it is processed + -- by the command processor + -- if it is processed succesfully, the mark is immediately removed + -- else an error is displayed and the mark remains. + if cfxArtilleryDemon.hasMark(theEvent.text) then + -- strip the mark + local commandString = theEvent.text:sub(1+cfxArtilleryDemon.markOfDemon:len()) + -- break remainder apart into ... + local commands = cfxArtilleryDemon.splitString(commandString, cfxArtilleryDemon.splitDelimiter) + + -- this is a command. process it and then remove it if it was executed successfully + local success = cfxArtilleryDemon.processCommandMethod(commands, theEvent) + + -- remove this mark after successful execution + if success then + trigger.action.removeMark(theEvent.idx) + cfxArtilleryDemon.outMessage("executed command <" .. commandString .. "> from unit" .. theEvent.initiator:getName(), args) + else + -- we could play some error sound + end + end + end + + if theEvent.id == world.event.S_EVENT_MARK_REMOVED then + end +end + +-- +-- add / remove commands to/from cfxArtillerys vocabulary +-- +function cfxArtilleryDemon.addCommndProcessor(command, processor) + cfxArtilleryDemon.commandTable[command:upper()] = processor +end + +function cfxArtilleryDemon.removeCommandProcessor(command) + cfxArtilleryDemon.commandTable[command:upper()] = nil +end + +-- +-- process input arguments. Here we simply move them +-- up by one. +-- +function cfxArtilleryDemon.getArgs(theCommands) + local args = {} + for i=2, #theCommands do + table.insert(args, theCommands[i]) + end + return args +end + +-- +-- stage demon's main command interpreter. +-- magic lies in using the keywords as keys into a +-- function table that holds all processing functions +-- I wish we had that back in the Oberon days. +-- +function cfxArtilleryDemon.executeCommand(theCommands, event) +-- trigger.action.outText("executor: *" .. theCommands[1] .. "*", 30) + -- see if theCommands[1] exists in the command table + local cmd = theCommands[1] + local arguments = cfxArtilleryDemon.getArgs(theCommands) + if not cmd then return false end + + -- use the command as index into the table of functions + -- that handle them. + if cfxArtilleryDemon.commandTable[cmd:upper()] then + local theInvoker = cfxArtilleryDemon.commandTable[cmd:upper()] + local success = theInvoker(arguments, event) + return success + else + trigger.action.outText("***error: unknown command <".. cmd .. ">", 30) + return false + end + + return true +end + +-- +-- SMOKE COMMAND +-- + +-- known commands and their processors +function cfxArtilleryDemon.smokeColor2Index (theColor) + local color = theColor:lower() + if color == "red" then return 1 end + if color == "white" then return 2 end + if color == "orange" then return 3 end + if color == "blue" then return 4 end + return 0 +end + +-- this is the command processing template for your own commands +-- when you add a command processor via addCommndProcessor() +-- smoke command syntax: '-smoke ' with optional color, color being red, green, blue, white or orange +function cfxArtilleryDemon.processSmokeCommand(args, event) + if not args[1] then args[1] = "red" end -- default to red color + local thePoint = event.pos + thePoint.y = land.getHeight({x = thePoint.x, y = thePoint.z}) +3 -- elevate to ground height + trigger.action.smoke(thePoint, cfxArtilleryDemon.smokeColor2Index(args[1])) + return true +end + +-- +-- BOOM command +-- +function cfxArtilleryDemon.doBoom(args) + --trigger.action.outText("sim shell str=" .. args.strength .. " x=" .. args.point.x .. " z = " .. args.point.z .. " Tdelta = " .. args.tDelta, 30) +-- trigger.action.smoke(args.point, 2) + trigger.action.explosion(args.point, args.strength) + +end + +function cfxArtilleryDemon.processBoomCommand(args, event) + if not args[1] then args[1] = "750" end -- default to 750 strength + local transitionTime = 20 -- seconds until shells hit + local shellNum = 17 + local shellBaseStrength = 500 + local shellvariance = 0.2 -- 10% + local center = event.pos -- center of where shells hit + center.y = land.getHeight({x = center.x, y = center.z}) + 3 + -- we now can 'dirty' the position by something. not yet + for i=1, shellNum do + local thePoint = dcsCommon.randomPointInCircle(100, 0, center.x, center.z) + local boomArgs = {} + local strVar = shellBaseStrength * shellvariance + strVar = strVar * (2 * dcsCommon.randomPercent() - 1.0) -- go from -1 to 1 + + boomArgs.strength = shellBaseStrength + strVar + thePoint.y = land.getHeight({x = thePoint.x, y = thePoint.z}) + 1 -- elevate to ground height + 1 + boomArgs.point = thePoint + local timeVar = 5 * (2 * dcsCommon.randomPercent() - 1.0) -- +/- 1.5 seconds + boomArgs.tDelta = timeVar + timer.scheduleFunction(cfxArtilleryDemon.doBoom, boomArgs, timer.getTime() + transitionTime + timeVar) + end + trigger.action.outText("Fire command confirmed. Artillery is firing at your designated co-ordinates.", 30) + trigger.action.smoke(center, 2) -- mark location visually + return true +end + +-- +-- cfxArtilleryZones interface +-- + +function cfxArtilleryDemon.processTargetCommand(args, event) + -- get position + local center = event.pos -- center of where shells hit + center.y = land.getHeight({x = center.x, y = center.z}) + + if not event.initiator then + trigger.action.outText("Target entry aborted: no initiator.", 30) + return true + end + local theUnit = event.initiator + local theGroup = theUnit:getGroup() + local coalition = theGroup:getCoalition() + local spotRange = 3000 + local autoAdd = true + local params = "" + + for idx, param in pairs(args) do + if params == "" then params = ": " + else params = params .. " " + end + params = params .. param + end + + local name = "TgtData".. params .. " (" .. theUnit:getName() .. ")@T+" .. math.floor(timer.getTime()) + -- feed into arty zones + cfxArtilleryZones.createArtilleryZone(name, center, coalition, spotRange, 500, autoAdd) -- 500 is base strength + + trigger.action.outTextForCoalition(coalition, "New ARTY coordinates received from " .. theUnit:getName() .. ", standing by", 30) + return true +end + +-- +-- cfxArtillery init and start +-- + +function cfxArtilleryDemon.init() + cfxArtilleryDemon.unitFilterMethod = cfxArtilleryDemon.authorizeAllUnits + cfxArtilleryDemon.processCommandMethod = cfxArtilleryDemon.executeCommand + + -- now add known commands to interpreter. Add your own commands the same way + cfxArtilleryDemon.addCommndProcessor("smoke", cfxArtilleryDemon.processSmokeCommand) + + cfxArtilleryDemon.addCommndProcessor("bumm", cfxArtilleryDemon.processBoomCommand) + + cfxArtilleryDemon.addCommndProcessor("tgt", + cfxArtilleryDemon.processTargetCommand) + + -- you can add and remove command the same way + trigger.action.outText("cf/x cfx Artillery Demon v" .. cfxArtilleryDemon.version .. " loaded", 30) +end + +function cfxArtilleryDemon.start() + cfxArtilleryDemon.demonID = world.addEventHandler(cfxArtilleryDemon) + trigger.action.outText("cf/x cfxArtilleryDemon v" .. cfxArtilleryDemon.version .. " started", 30) +end + +cfxArtilleryDemon.init() +if cfxArtilleryDemon.autostart then + cfxArtilleryDemon.start() +end \ No newline at end of file diff --git a/modules/cfxArtilleryUI.lua b/modules/cfxArtilleryUI.lua new file mode 100644 index 0000000..fec06f1 --- /dev/null +++ b/modules/cfxArtilleryUI.lua @@ -0,0 +1,714 @@ +cfxArtilleryUI = {} +cfxArtilleryUI.version = "1.1.0" +cfxArtilleryUI.requiredLibs = { + "dcsCommon", -- always + "cfxPlayer", -- get all players + "cfxZones", -- Zones, of course + "cfxArtilleryZones", -- this is where we get zones from +} +-- +-- UI for ArtilleryZones module, implements LOS, Observer, SMOKE +-- Copyright (c) 2021, 2022 by Christian Franz and cf/x AG + +--[[-- VERSION HISTORY + - 1.0.0 - based on jtacGrpUI + - 1.0.1 - tgtCheckSum + - smokeCheckSum + - ability to smoke target zone in range + - 1.1.0 - config zone + - allowPlanes flag + - config smoke color + - collect zones recognizes moving zones, updates landHeight + - allSeeing god mode attribute: always observing. + - allRanging god mode attribute: always in range. + + +--]]-- +cfxArtilleryUI.allowPlanes = false -- if false, heli only +cfxArtilleryUI.smokeColor = "red" -- for smoking target zone + +-- find artiller zones, command fire +cfxArtilleryUI.updateDelay = 1 -- seconds until we update target list +cfxArtilleryUI.groupConfig = {} -- all inited group private config data +cfxArtilleryUI.updateSound = "UI_SCI-FI_Tone_Bright_Dry_20_stereo.wav" +cfxArtilleryUI.maxSmokeDist = 30000 -- in meters. Distance to target to populate smoke menu +-- +-- C O N F I G H A N D L I N G +-- ============================= +-- +-- Each group has their own config block that can be used to +-- store group-private data and configuration items. +-- + +function cfxArtilleryUI.resetConfig(conf) + conf.tgtCheckSum = nil -- used to determine if we need to update target menu + conf.smokeCheckSum = nil -- used to determine if we need to update smoke menu +end + +function cfxArtilleryUI.createDefaultConfig(theGroup) + local conf = {} + conf.theGroup = theGroup + conf.name = theGroup:getName() + conf.id = theGroup:getID() + conf.coalition = theGroup:getCoalition() + + cfxArtilleryUI.resetConfig(conf) + + conf.mainMenu = nil; -- this is where we store the main menu if we branch + conf.myCommands = nil; -- this is where we store the commands if we branch + + return conf +end + +-- getConfigFor group will allocate if doesn't exist in DB +-- and add to it +function cfxArtilleryUI.getConfigForGroup(theGroup) + if not theGroup then + trigger.action.outText("+++WARNING: cfxArtilleryUI nil group in getConfigForGroup!", 30) + return nil + end + local theName = theGroup:getName() + local c = cfxArtilleryUI.getConfigByGroupName(theName) -- we use central accessor + if not c then + c = cfxArtilleryUI.createDefaultConfig(theGroup) + cfxArtilleryUI.groupConfig[theName] = c -- should use central accessor... + end + return c +end + +function cfxArtilleryUI.getConfigByGroupName(theName) -- DOES NOT allocate when not exist + if not theName then return nil end + return cfxArtilleryUI.groupConfig[theName] +end + + +function cfxArtilleryUI.getConfigForUnit(theUnit) + -- simple one-off step by accessing the group + if not theUnit then + trigger.action.outText("+++WARNING: cfxArtilleryUI nil unit in getConfigForUnit!", 30) + return nil + end + + local theGroup = theUnit:getGroup() + return getConfigForGroup(theGroup) +end + +-- +-- +-- M E N U H A N D L I N G +-- ========================= +-- +-- +function cfxArtilleryUI.clearCommsTargets(conf) + if conf.myTargets then + for i=1, #conf.myTargets do + missionCommands.removeItemForGroup(conf.id, conf.myTargets[i]) + end + end + conf.myTargets={} + conf.tgtCheckSum = nil +end + +function cfxArtilleryUI.clearCommsSmokes(conf) + if conf.mySmokes then + for i=1, #conf.mySmokes do + missionCommands.removeItemForGroup(conf.id, conf.mySmokes[i]) + end + end + conf.mySmokes={} + conf.smokeCheckSum = nil +end + + +function cfxArtilleryUI.clearCommsSubmenus(conf) + if conf.myCommands then + for i=1, #conf.myCommands do + missionCommands.removeItemForGroup(conf.id, conf.myCommands[i]) + end + end + conf.myCommands = {} + + -- now clear target menu + cfxArtilleryUI.clearCommsTargets(conf) + if conf.myTargetMenu then + missionCommands.removeItemForGroup(conf.id, conf.myTargetMenu) + conf.myTargetMenu = nil + end + + -- now clear smoke menu + cfxArtilleryUI.clearCommsSmokes(conf) + if conf.mySmokeMenu then + missionCommands.removeItemForGroup(conf.id, conf.mySmokeMenu) + conf.mySmokeMenu = nil + end + +end + +function cfxArtilleryUI.removeCommsFromConfig(conf) + cfxArtilleryUI.clearCommsSubmenus(conf) + + if conf.myMainMenu then + missionCommands.removeItemForGroup(conf.id, conf.myMainMenu) + conf.myMainMenu = nil + end +end + +-- this only works in single-unit groups. may want to check if group +-- has disappeared +function cfxArtilleryUI.removeCommsForUnit(theUnit) + if not theUnit then return end + if not theUnit:isExist() then return end + -- perhaps add code: check if group is empty + local conf = cfxArtilleryUI.getConfigForUnit(theUnit) + cfxArtilleryUI.removeCommsFromConfig(conf) +end + +function cfxArtilleryUI.removeCommsForGroup(theGroup) + if not theGroup then return end + if not theGroup:isExist() then return end + local conf = cfxArtilleryUI.getConfigForGroup(theGroup) + cfxArtilleryUI.removeCommsFromConfig(conf) +end + +-- +-- set main root in F10 Other. All sub menus click into this +-- +function cfxArtilleryUI.isEligibleForMenu(theGroup) + if cfxArtilleryUI.allowPlanes then return true end + + -- only allow helicopters for Forward Observervation + local cat = theGroup:getCategory() + if cat ~= Group.Category.HELICOPTER then return false end + return true +end + +function cfxArtilleryUI.setCommsMenuForUnit(theUnit) + if not theUnit then + trigger.action.outText("+++WARNING: cfxArtilleryUI nil UNIT in setCommsMenuForUnit!", 30) + return + end + if not theUnit:isExist() then return end + + local theGroup = theUnit:getGroup() + cfxArtilleryUI.setCommsMenu(theGroup) +end + +function cfxArtilleryUI.setCommsMenu(theGroup) + -- depending on own load state, we set the command structure + -- it begins at 10-other, and has 'jtac' as main menu with submenus + -- as required + if not theGroup then return end + if not theGroup:isExist() then return end + + -- we test here if this group qualifies for + -- the menu. if not, exit + if not cfxArtilleryUI.isEligibleForMenu(theGroup) then return end + + local conf = cfxArtilleryUI.getConfigForGroup(theGroup) + conf.id = theGroup:getID(); -- we do this ALWAYS so it is current even after a crash + + -- ok, first, if we don't have an F-10 menu, create one + if not (conf.myMainMenu) then + conf.myMainMenu = missionCommands.addSubMenuForGroup(conf.id, 'Forward Observer') + + end + + -- clear out existing commands + cfxArtilleryUI.clearCommsSubmenus(conf) + + -- now we have a menu without submenus. + -- add our own submenus + cfxArtilleryUI.addSubMenus(conf) + +end + + +function cfxArtilleryUI.addSubMenus(conf) + -- add menu items to choose from after + -- user clickedf on MAIN MENU. In this implementation + -- they all result invoked methods + + local commandTxt = "List Artillery Targets" + local theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + conf.myMainMenu, + cfxArtilleryUI.redirectCommandListTargets, + {conf, "arty list"} + ) + table.insert(conf.myCommands, theCommand) + + -- add a targets menu. this will be regularly set (every x seconds) + conf.myTargetMenu = missionCommands.addSubMenuForGroup(conf.id, 'Artillery Fire Control', conf.myMainMenu) + + -- populate this target menu with commands + -- creates a tgtCheckSum to very each time + cfxArtilleryUI.populateTargetMenu(conf) + + conf.mySmokeMenu = missionCommands.addSubMenuForGroup(conf.id, 'Mark Artillery Target', conf.myMainMenu) + cfxArtilleryUI.populateSmokeMenu(conf) + +end + +function cfxArtilleryUI.populateTargetMenu(conf) + local targetList = cfxArtilleryUI.collectArtyTargets(conf) + -- iterate the list + + -- we use a control string to know if we have to change + local tgtCheckSum = "" + -- now filter target list + local filteredTargets = {} + for idx, aTarget in pairs(targetList) do + local inRange = cfxArtilleryUI.allRanging or aTarget.range * 1000 < aTarget.spotRange + if inRange then + isVisible = cfxArtilleryUI.allSeeing or land.isVisible(aTarget.here, aTarget.there) + if isVisible then + table.insert(filteredTargets, aTarget) + tgtCheckSum = tgtCheckSum .. aTarget.name + end + end + end + + -- now compare old control string with new, and only + -- re-populate if the old is different + if tgtCheckSum == conf.tgtCheckSum then +-- trigger.action.outText("*** yeah old targets", 30) + return + elseif not conf.tgtCheckSum then +-- trigger.action.outText("+++ new target menu", 30) + else + trigger.action.outTextForGroup(conf.id, "Artillery target updates", 30) + trigger.action.outSoundForGroup(conf.id, cfxArtilleryUI.updateSound) +-- trigger.action.outText("!!! target update ", 30) + end + + -- we need to re-populate. erase old values + cfxArtilleryUI.clearCommsTargets(conf) + conf.tgtCheckSum = tgtCheckSum -- remember for last time + --trigger.action.outText("new targets", 30) + + if #filteredTargets < 1 then + -- simply put one-line dummy in there + local commandTxt = "(No unobscured target areas)" + local theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + conf.myTargetMenu, + cfxArtilleryUI.dummyCommand, + {conf, "nix is"} + ) + table.insert(conf.myTargets, theCommand) + return + end + + -- now populate target menu, max 8 items + local numTargets = #filteredTargets + if numTargets > 8 then numTargets = 8 end + for i=1, numTargets do + -- make a target command for each + local aTarget = filteredTargets[i] + + commandTxt = "Fire at: <" .. aTarget.name .. ">" + theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + conf.myTargetMenu, + cfxArtilleryUI.redirectFireCommand, + {conf, aTarget} + ) + table.insert(conf.myTargets, theCommand) + end +end + +function cfxArtilleryUI.populateSmokeMenu(conf) + local targetList = cfxArtilleryUI.collectArtyTargets(conf) -- we can use target gathering + + -- now iterate the reulting list + -- we use a control string to know if we have to change + local smokeCheckSum = "" + -- now filter target list + local filteredTargets = {} + for idx, aTarget in pairs(targetList) do + local inRange = cfxArtilleryUI.allRanging or aTarget.range * 1000 < cfxArtilleryUI.maxSmokeDist + if inRange then + table.insert(filteredTargets, aTarget) + smokeCheckSum = smokeCheckSum .. aTarget.name + end + end + + -- now compare old control string with new, and only + -- re-populate if the old is different + if smokeCheckSum == conf.smokeCheckSum then + -- nothing changed since last time, do nothing and return immediately + return + end + + -- we need to re-populate. erase old values + cfxArtilleryUI.clearCommsSmokes(conf) + conf.smokeCheckSum = smokeCheckSum -- remember for last time + + if #filteredTargets < 1 then + -- simply put one-line dummy in there + local commandTxt = "(No targets in range)" + local theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + conf.mySmokeMenu, + cfxArtilleryUI.dummyCommand, -- the "nix is" command + {conf, "nix is"} + ) + table.insert(conf.mySmokes, theCommand) + return + end + + -- now populate target menu, max 8 items + local numTargets = #filteredTargets + if numTargets > 10 then numTargets = 10 end + for i=1, numTargets do + -- make a target command for each + local aTarget = filteredTargets[i] + + commandTxt = "Smoke <" .. aTarget.name .. ">" + theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + conf.mySmokeMenu, + cfxArtilleryUI.redirectSmokeCommand, + {conf, aTarget} + ) + table.insert(conf.mySmokes, theCommand) + end +end + +-- +-- each menu item has a redirect and timed invoke to divorce from the +-- no-debug zone in the menu invocation. Delay is .1 seconds +-- +function cfxArtilleryUI.dummyCommand(args) + -- do nothing, dummy! +end + +function cfxArtilleryUI.redirectFireCommand(args) + timer.scheduleFunction(cfxArtilleryUI.doFireCommand, args, timer.getTime() + 0.1) +end + +function cfxArtilleryUI.doFireCommand(args) + local conf = args[1] -- < conf in here + local aTarget = args[2] -- < second argument in here + local theGroup = conf.theGroup + local now = timer.getTime() + -- recalc range since it may be 10 seconds old + local here = dcsCommon.getGroupLocation(theGroup) + local there = aTarget.there + local lRange = dcsCommon.dist(here, there) + aTarget.range = lRange/1000 + aTarget.range = math.floor(aTarget.range * 10) / 10 + local inTime = cfxArtilleryUI.allTiming or now > aTarget.zone.artyCooldownTimer + if inTime then + -- invoke fire command for artyZone + trigger.action.outTextForGroup(conf.id, "Roger, " .. theGroup:getName() .. ", firing at " .. aTarget.name, 30) + if cfxArtilleryUI.allRanging then lRange = 1 end -- max accuracy + cfxArtilleryZones.simFireAtZone(aTarget.zone, theGroup, lRange) + else + -- way want to stay silent and simply iterate? + trigger.action.outTextForGroup(conf.id, "Artillery is reloading", 30) + end +end + +-- +-- SMOKE EM +-- + +function cfxArtilleryUI.redirectSmokeCommand(args) + timer.scheduleFunction(cfxArtilleryUI.doSmokeCommand, args, timer.getTime() + 0.1) +end + +function cfxArtilleryUI.doSmokeCommand(args) + local conf = args[1] -- < conf in here + local aTarget = args[2] -- < second argument in here + local theGroup = conf.theGroup + -- invoke smoke command for artyZone + trigger.action.outTextForGroup(conf.id, "Roger, " .. theGroup:getName() .. ", marking " .. aTarget.name, 30) + cfxArtilleryZones.simSmokeZone(aTarget.zone, theGroup, cfxArtilleryUI.smokeColor) +end + +-- +-- +-- + +function cfxArtilleryUI.redirectCommandListTargets(args) + timer.scheduleFunction(cfxArtilleryUI.doCommandListTargets, args, timer.getTime() + 0.1) +end + +function cfxArtilleryUI.doCommandListTargets(args) + local conf = args[1] -- < conf in here + local what = args[2] -- < second argument in here + local theGroup = conf.theGroup +-- trigger.action.outTextForGroup(conf.id, "+++ groupUI: processing comms menu for <" .. what .. ">", 30) + local targetList = cfxArtilleryUI.collectArtyTargets(conf) + -- iterate the list + if #targetList < 1 then + trigger.action.outTextForGroup(conf.id, "\nArtillery Targets:\nNo active artillery targets\n", 30) + return + end + + local desc = "Artillery Targets:\n" +-- trigger.action.outTextForGroup(conf.id, "Target Report:", 30) + for i=1, #targetList do + local aTarget = targetList[i] + local inRange = cfxArtilleryUI.allRanging or aTarget.range * 1000 < aTarget.spotRange + if inRange then + isVisible = cfxArtilleryUI.allSeeing or land.isVisible(aTarget.here, aTarget.there) + if isVisible then + desc = desc .. "\n" .. aTarget.name .. " - OBSERVING" + else + desc = desc .. "\n" .. aTarget.name .. " - TARGET OBSCURED" + end + else + desc = desc .. "\n" .. aTarget.name .. " [" .. aTarget.range .. "km at " .. aTarget.bearing .. "°]" + end + + end + trigger.action.outTextForGroup(conf.id, desc .. "\n", 30, true) +end + +function cfxArtilleryUI.collectArtyTargets(conf) + -- iterate all target zones, for those that are on my side + -- calculate range, bearing, and then order by distance + + local theTargets = {} + for idx, aZone in pairs(cfxArtilleryZones.artilleryZones) do + if aZone.coalition == conf.coalition then + table.insert(theTargets, aZone) + end + end + + -- we now have a list of all Arty target Zones + -- get bearing and range to targets, and sort them accordingly + -- WARNING: aTarget.range is in KILOmeters! + local targetList = {} + local here = dcsCommon.getGroupLocation(conf.theGroup) -- this is me + + for idx, aZone in pairs (theTargets) do + local aTarget = {} + -- establish our location + aTarget.zone = aZone + aTarget.name = aZone.name + aTarget.here = here + --aTarget.targetName = aZone.name + aTarget.spotRange = aZone.spotRange + -- get the target we are lazing + local zP = cfxZones.getPoint(aZone) -- zone can move! + aZone.landHeight = land.getHeight({x = zP.x, y= zP.z}) + local there = {x = zP.x, y = aZone.landHeight + 1, z=zP.z} + aTarget.there = there + aTarget.range = dcsCommon.dist(here, there) / 1000 -- (in km) + + aTarget.range = math.floor(aTarget.range * 10) / 10 + aTarget.bearing = dcsCommon.bearingInDegreesFromAtoB(here, there) + --aTarget.jtacName = troop.name + table.insert(targetList, aTarget) + end + + -- now sort by range + table.sort(targetList, function (left, right) return left.range < right.range end ) + + -- return list sorted by distance + return targetList +end + + + + +function cfxArtilleryUI.redirectCommandFire(args) + timer.scheduleFunction(cfxArtilleryUI.doCommandFire, args, timer.getTime() + 0.1) +end + +function cfxArtilleryUI.doCommandFire(args) + local conf = args[1] -- < conf in here + local what = args[2] -- < second argument in here + local theGroup = conf.theGroup + + -- sort all arty groups by distance + local targetList = cfxArtilleryUI.collectArtyTargets(conf, true) + + if #targetList < 1 then + trigger.action.outTextForGroup(conf.id, "You are currently not observing a target zone. Move closer.", 30) + return + end + local now = timer.getTime() + -- for all that we are in range, that are visible and that can fire + for idx, aTarget in pairs(targetList) do + local inRange = (aTarget.range * 1000 < aTarget.spotRange) or cfxArtilleryUI.allRanging + local inTime = cfxArtilleryUI.allTiming or now > aTarget.zone.artyCooldownTimer + if inRange then + isVisible = cfxArtilleryUI.allSeeing or land.isVisible(aTarget.here, aTarget.there) + if isVisible then + if inTime then + -- invoke fire command for artyZone + cfxArtilleryZones.simFireAtZone(aTarget.zone, theGroup, aTarget.range) + -- return -- we only fire one zone + else + -- way want to stay silent and simply iterate? + trigger.action.outTextForGroup(conf.id, "Artillery reloading", 30) + end + end + return -- after the first in range we stop, no matter what + else + -- not interesting + end + end + + -- issue a fire command + +end + +-- +-- G R O U P M A N A G E M E N T +-- +-- Group Management is required to make sure all groups +-- receive a comms menu and that they receive a clean-up +-- when required +-- +-- Callbacks are provided by cfxPlayer module to which we +-- subscribe during init +-- +function cfxArtilleryUI.playerChangeEvent(evType, description, player, data) + --trigger.action.outText("+++ groupUI: received <".. evType .. "> Event", 30) + if evType == "newGroup" then + -- initialized attributes are in data as follows + -- .group - new group + -- .name - new group's name + -- .primeUnit - the unit that trigggered new group appearing + -- .primeUnitName - name of prime unit + -- .id group ID + --theUnit = data.primeUnit + cfxArtilleryUI.setCommsMenu(data.group) + + return + end + + if evType == "removeGroup" then + -- data is the player record that no longer exists. it consists of + -- .name + -- we must remove the comms menu for this group else we try to add another one to this group later + local conf = cfxArtilleryUI.getConfigByGroupName(data.name) + + if conf then + cfxArtilleryUI.removeCommsFromConfig(conf) -- remove menus + cfxArtilleryUI.resetConfig(conf) -- re-init this group for when it re-appears + else + trigger.action.outText("+++ jtacUI: can't retrieve group <" .. data.name .. "> config: not found!", 30) + end + + return + end + + if evType == "leave" then + -- player unit left. + end + + if evType == "unit" then + -- player changed units. + end + +end + +-- +-- update +-- + +function cfxArtilleryUI.updateGroup(theGroup) + if not theGroup then return end + if not theGroup:isExist() then return end + -- we test here if this group qualifies for + -- the menu. if not, exit + if not cfxArtilleryUI.isEligibleForMenu(theGroup) then return end + local conf = cfxArtilleryUI.getConfigForGroup(theGroup) + conf.id = theGroup:getID(); -- we do this ALWAYS + -- populateTargetMenu erases old settings by itself + cfxArtilleryUI.populateTargetMenu(conf) -- update targets + cfxArtilleryUI.populateSmokeMenu(conf) -- update targets +end + +function cfxArtilleryUI.update() + -- reschedule myself in x seconds + timer.scheduleFunction(cfxArtilleryUI.update, {}, timer.getTime() + cfxArtilleryUI.updateDelay) + + -- iterate all groups, and rebuild their target menus + local allPlayerGroups = cfxPlayerGroups -- cfxPlayerGroups is a global, don't fuck with it! + -- contains per group player record. Does not resolve on unit level! + for gname, pgroup in pairs(allPlayerGroups) do + local theUnit = pgroup.primeUnit -- single unit groups! + local theGroup = theUnit:getGroup() + cfxArtilleryUI.updateGroup(theGroup) + end + +end + +-- +-- Config Zone +-- + +function cfxArtilleryUI.readConfigZone() + -- note: must match exactly!!!! + local theZone = cfxZones.getZoneByName("ArtilleryUIConfig") + if not theZone then + trigger.action.outText("+++A-UI: no config zone!", 30) + return + end + + trigger.action.outText("+++A-UI: found config zone!", 30) + + cfxArtilleryUI.verbose = cfxZones.getBoolFromZoneProperty(theZone, "verbose", false) + cfxArtilleryUI.allowPlanes = cfxZones.getBoolFromZoneProperty(theZone, "allowPlanes", false) + cfxArtilleryUI.smokeColor = cfxZones.getSmokeColorStringFromZoneProperty(theZone, "smokeColor", "red") + cfxArtilleryUI.allSeeing = cfxZones.getBoolFromZoneProperty(theZone, "allSeeing", false) + cfxArtilleryUI.allRanging = cfxZones.getBoolFromZoneProperty(theZone, "allRanging", false) + cfxArtilleryUI.allTiming = cfxZones.getBoolFromZoneProperty(theZone, "allTiming", false) +end + +-- +-- Start +-- +function cfxArtilleryUI.start() + if not dcsCommon.libCheck("cfx Artillery UI", + cfxArtilleryUI.requiredLibs) then + return false + end + + -- read config + cfxArtilleryUI.readConfigZone() + + -- iterate existing groups so we have a start situation + -- now iterate through all player groups and install the Assault Troop Menu + local allPlayerGroups = cfxPlayerGroups -- cfxPlayerGroups is a global, don't fuck with it! + -- contains per group player record. Does not resolve on unit level! + for gname, pgroup in pairs(allPlayerGroups) do + local theUnit = pgroup.primeUnit -- get any unit of that group + cfxArtilleryUI.setCommsMenuForUnit(theUnit) -- set up + end + + -- now install the new group notifier to install Assault Troops menu + cfxPlayer.addMonitor(cfxArtilleryUI.playerChangeEvent) + + -- run an update loop for the target menu + cfxArtilleryUI.update() + + trigger.action.outText("cf/x cfxArtilleryUI v" .. cfxArtilleryUI.version .. " started", 30) + return true +end + +-- +-- GO GO GO +-- + +if not cfxArtilleryUI.start() then + trigger.action.outText("Loading cf/x Artillery UI aborted.", 30) + cfxArtilleryUI = nil +end + +--[[-- +TODO: transition times based on distance - requires real bound arty first +DONE: ui for smoking target zone: list ten closest zones, and provide menu to smoke zone +--]]-- \ No newline at end of file diff --git a/modules/cfxArtilleryZones.lua b/modules/cfxArtilleryZones.lua new file mode 100644 index 0000000..a1b6284 --- /dev/null +++ b/modules/cfxArtilleryZones.lua @@ -0,0 +1,448 @@ +cfxArtilleryZones = {} +cfxArtilleryZones.version = "2.0.1" +cfxArtilleryZones.requiredLibs = { + "dcsCommon", -- always + "cfxZones", -- Zones, of course +} +cfxArtilleryZones.verbose = false +--[[-- + Version History + 1.0.0 - initial version + 1.0.1 - simSmokeZone + 2.0.0 - zone attributes for shellNum, shellVariance, + cooldown, addMark, transitionTime + - doFireAt method + - simFireAt now calls doFireAt + - added all params to crteateArtilleryTarget + - createArtillerTarget replaced createArtilleryZone + - addMark now used so arty zones can be hidden on map + - added triggerFlag attribute + - update now fires every time when flag changes + 2.0.1 - added verbose setting + - base accuracy now derived from radius + - added coalition check for ZonesInRange + - att transition time to zone info mark + - made compatible with linked zones + - added silent attribute + - added transition time to arty command chatter + + Artillery Target Zones *** EXTENDS ZONES *** + Target Zones for artillery. Can determine which zones are in range and visible and then handle artillery barrage to this zone + Copyright (c) 2021, 2022 by Christian Franz and cf/x AG + + USAGE + Via ME: Add the relevant attributes to the zone + Via Script: Use createArtilleryTarget() + + + Callbacks + when fire at target is invoked, a callback can be + invoked so your code knows that fire control has been + given a command or that projectiles are impacting. + Signature + callback(rason, zone, data), with + reason: 'firing' - fire command given for zone + 'impact' a projectile has hit + zone: artilleryZone + data: empty on 'fire' + .point where impact point + .strength power of explosion + +--]]-- +cfxArtilleryZones.artilleryZones = {} +cfxArtilleryZones.updateDelay = 1 -- every second + + +-- +-- C A L L B A C K S +-- +cfxArtilleryZones.callbacks = {} +function cfxArtilleryZones.addCallback(theCallback) + table.insert(cfxArtilleryZones.callbacks, theCallback) +end + +function cfxArtilleryZones.invokeCallbacksFor(reason, zone, data) + for idx, theCB in pairs (cfxArtilleryZones.callbacks) do + theCB(reason, zone, data) + end +end + +function cfxArtilleryZones.demoCallback(reason, zone, data) + -- reason: 'fire' or 'impact' + -- fire has no data, impact has data.point and data.strength +end + +function cfxArtilleryZones.createArtilleryTarget(name, point, coalition, spotRange, transitionTime, baseAccuracy, shellNum, shellStrength, shellVariance, triggerFlag, addMark, cooldown, silent, autoAdd) -- was: createArtilleryZone, changed params list + if not point then return end + if not autoAdd then autoAdd = false end + if not coalition then coalition = 0 end + if not spotRange then spotRange = 3000 end + if not shellStrength then shellStrength = 500 end + if not transitionTime then transitionTime = 20 end + if not shellNum then shellNum = 17 end + if not addMark then addMark = false end + if not name then name = "dftZName" end + if not shellVariance then shellVariance = 0.2 end + if not cooldown then cooldown = 120 end + if not baseAccuracy then baseAccuracy = 100 end + if not silent then silent = false end + + name = cfxZones.createUniqueZoneName(name) + + local newZone = cfxZones.createSimpleZone(name, + point, + 100, + autoAdd) + newZone.spotRange = spotRange + newZone.coalition = coalition + newZone.landHeight = land.getHeight({x = newZone.point.x, y= newZone.point.z}) + newZone.transitionTime = transitionTime + newZone.shellNum = shellNum + newZone.shellStrength = shellStrength + newZone.triggerFlag = triggerFlag -- can be nil + if triggerFlag then + newZone.lastTriggerValue = trigger.misc.getUserFlag(triggerFlag) -- save last value + end + newZone.addMark = addMark + if autoAdd then cfxArtilleryZones.addArtilleryZone(newZone) end + newZone.shellVariance = shellVariance + newZone.cooldown = cooldown + newZone.silent = silent +end + +function cfxArtilleryZones.processArtilleryZone(aZone) + aZone.artilleryTarget = cfxZones.getStringFromZoneProperty(aZone, "artilleryTarget", aZone.name) + aZone.coalition = cfxZones.getCoalitionFromZoneProperty(aZone, "coalition", 0) -- side that marks it on map, and who fires arty + aZone.spotRange = cfxZones.getNumberFromZoneProperty(aZone, "spotRange", 3000) -- FO max range to direct fire + aZone.shellStrength = cfxZones.getNumberFromZoneProperty(aZone, "shellStrength", 500) -- power of shells (strength) + + aZone.shellNum = cfxZones.getNumberFromZoneProperty(aZone, "shellNum", 17) -- number of shells in bombardment + aZone.transitionTime = cfxZones.getNumberFromZoneProperty(aZone, "transitionTime", 20) -- average time of travel for projectiles + aZone.addMark = cfxZones.getBoolFromZoneProperty(aZone, "addMark", true) -- note: defaults to true + aZone.shellVariance = cfxZones.getNumberFromZoneProperty(aZone, "shellVariance", 0.2) -- strength of explosion can vary by +/- this amount + if cfxZones.hasProperty(aZone, "f?") then + aZone.triggerFlag = cfxZones.getStringFromZoneProperty(aZone, "f?", "none") + end + if cfxZones.hasProperty(aZone, "triggerFlag") then + aZone.triggerFlag = cfxZones.getStringFromZoneProperty(aZone, "triggerFlag", "none") + end + if aZone.triggerFlag then + aZone.lastTriggerValue = trigger.misc.getUserFlag(aZone.triggerFlag) -- save last value + end + aZone.cooldown =cfxZones.getNumberFromZoneProperty(aZone, "cooldown", 120) -- seconds + aZone.baseAccuracy = cfxZones.getNumberFromZoneProperty(aZone, "baseAccuracy", aZone.radius) -- meters from center radius shell impact + -- use zone radius as mase accuracy for simple placement + aZone.silent = cfxZones.getBoolFromZoneProperty(aZone, "silent", false) +end + +function cfxArtilleryZones.addArtilleryZone(aZone) + -- add landHeight to this zone + aZone.landHeight = land.getHeight({x = aZone.point.x, y= aZone.point.z}) + -- mark it on the map + aZone.artyCooldownTimer = -1000 + cfxArtilleryZones.placeMarkForSide(aZone.point, aZone.coalition, aZone.name .. ", FO=" .. aZone.spotRange .. "m" .. ", tt=" .. aZone.transitionTime) + table.insert(cfxArtilleryZones.artilleryZones, aZone) +end + +function cfxArtilleryZones.findArtilleryZoneNamed(aName) + aZone = cfxZones.getZoneByName(aName) + if not aZone then return nil end + -- check if it is an arty zone + if not aZone.artilleryTarget then return nil end + -- all is well + return aZone +end + +function cfxArtilleryZones.removeArtilleryZone(aZone) + if type(aZone) == "string" then + aZone = cfxArtilleryZones.findArtilleryZoneNamed(aZone) + end + if not aZone then return end + + -- now create new table + local filtered = {} + for idx, theZone in pairs(cfxArtilleryZones.artilleryZones) do + if theZone ~= aZone then + table.insert(filtered, theZone) + end + end + cfxArtilleryZones.artilleryZones = filtered +end + +function cfxArtilleryZones.artilleryZonesInRangeOfUnit(theUnit) + if not theUnit then return {} end + if not theUnit:isExist() then return {} end + local myCoalition = theUnit:getCoalition() + local zonesInRange = {} + local p = theUnit:getPoint() + + for idx, aZone in pairs(cfxArtilleryZones.artilleryZones) do + -- is it one of mine? + if aZone.coalition == myCoalition then + -- is it close enough? + local zP = cfxZones.getPoint(aZone) + aZone.landHeight = land.getHeight({x = zP.x, y= zP.z}) + local zonePoint = {x = zP.x, y = aZone.landHeight, z = zP.z} + local d = dcsCommon.dist(p,zonePoint) + if d < aZone.spotRange then + -- LOS check + if land.isVisible(p, zonePoint) then + -- yeah, add to list + table.insert(zonesInRange, aZone) + end + end + end + end + return zonesInRange +end + + +-- +-- MARK ON MAP +-- +cfxArtilleryZones.uuidCount = 0 +function cfxArtilleryZones.uuid() + cfxArtilleryZones.uuidCount = cfxArtilleryZones.uuidCount + 1 + return cfxArtilleryZones.uuidCount +end + +function cfxArtilleryZones.placeMarkForSide(location, theSide, theDesc) + local theID = cfxArtilleryZones.uuid() + local theDesc = "ARTY: ".. theDesc + trigger.action.markToCoalition( + theID, + theDesc, + location, + theSide, + false, + nil) + return theID +end + +function cfxArtilleryZones.removeMarkForArgs(args) + local theID = args[1] + trigger.action.removeMark(theID) +end + +-- +-- FIRE AT A ZONE +-- + +-- +-- BOOM command +-- +function cfxArtilleryZones.doBoom(args) + trigger.action.explosion(args.point, args.strength) + data = {} + data.point = args.point + data.strength = args.strength + cfxArtilleryZones.invokeCallbacksFor('impact', args.zone, data) +end + +function cfxArtilleryZones.doFireAt(aZone, maxDistFromCenter) + if type(aZone) == "string" then + local mZone = cfxArtilleryZones.findArtilleryZoneNamed(aZone) + aZone = mZone + end + if not aZone then return end + + if not maxDistFromCenter then maxDistFromCenter = aZone.baseAccuracy end + + local accuracy = maxDistFromCenter + local zP = cfxZones.getPoint(aZone) + aZone.landHeight = land.getHeight({x = zP.x, y= zP.z}) + local center = {x=zP.x, y=aZone.landHeight, z=zP.z} -- center of where shells hit + local shellNum = aZone.shellNum + local shellBaseStrength = aZone.shellStrength + local shellVariance = aZone.shellVariance + local transitionTime = aZone.transitionTime + + for i=1, shellNum do + local thePoint = dcsCommon.randomPointInCircle(accuracy, 0, center.x, center.z) + thePoint.y = land.getHeight({x=thePoint.x, y=thePoint.z}) + local boomArgs = {} + local strVar = shellBaseStrength * shellVariance + strVar = strVar * (2 * dcsCommon.randomPercent() - 1.0) -- go from -1 to 1 + + boomArgs.strength = shellBaseStrength + strVar + thePoint.y = land.getHeight({x = thePoint.x, y = thePoint.z}) + 1 -- elevate to ground height + 1 + boomArgs.point = thePoint + boomArgs.zone = aZone + local timeVar = 5 * (2 * dcsCommon.randomPercent() - 1.0) -- +/- 1.5 seconds + if timeVar < 0 then timeVar = -timeVar end +-- if transitionTime + timeVar < 0 then timeVar = -timeVar end + --boomArgs.tDelta = timeVar + timer.scheduleFunction(cfxArtilleryZones.doBoom, boomArgs, timer.getTime() + transitionTime + timeVar) + end + + -- invoke callbacks + cfxArtilleryZones.invokeCallbacksFor('fire', aZone, {}) +end + +function cfxArtilleryZones.simFireAtZone(aZone, aGroup, dist) + -- all very simple. We simulate shellNum + -- projectiles impacting in aZone + -- before calling doFire, we calculate accuracy + -- for given dist + + if not dist then dist = aZone.spotRange end + --local transitionTime = 20 -- seconds until shells hit + --transitionTime = aZone.transitionTime + --local shellNum = 17 + --if aZone.shellNum then shellNum = aZone.shellNum end + --local shellBaseStrength = 500 + --if aZone.shellStrength then + local shellBaseStrength = aZone.shellStrength + --local shellVariance = 0.2 -- +/-10% + --shellVariance = aZone.shellVariance + --local center = {x=aZone.point.x, y=aZone.landHeight, z=aZone.point.z} -- center of where shells hit + + local maxAccuracy = 100 -- m radius when close + local minAccuracy = 500 -- m radius whan at max sport dist + local currAccuracy = minAccuracy + if dist <= 1000 then + currAccuracy = maxAccuracy + else + local percent = (dist-1000) / (aZone.spotRange-1000) + currAccuracy = dcsCommon.lerp(maxAccuracy, minAccuracy, percent) + end + currAccuracy = math.floor(currAccuracy) + cfxArtilleryZones.doFireAt(aZone, currAccuracy) + --[[-- Old code follows + for i=1, shellNum do + + local thePoint = dcsCommon.randomPointInCircle(currAccuracy, 0, center.x, center.z) + thePoint.y = land.getHeight({x=thePoint.x, y=thePoint.z}) + local boomArgs = {} + local strVar = shellBaseStrength * shellVariance + strVar = strVar * (2 * dcsCommon.randomPercent() - 1.0) -- go from -1 to 1 + + boomArgs.strength = shellBaseStrength + strVar + thePoint.y = land.getHeight({x = thePoint.x, y = thePoint.z}) + 1 -- elevate to ground height + 1 + boomArgs.point = thePoint + local timeVar = 5 * (2 * dcsCommon.randomPercent() - 1.0) -- +/- 1.5 seconds + boomArgs.tDelta = timeVar + timer.scheduleFunction(cfxArtilleryZones.doBoom, boomArgs, timer.getTime() + transitionTime + timeVar) + end + --]]-- + aZone.artyCooldownTimer = timer.getTime() + aZone.cooldown -- 120 -- 2 minutes reload + if not aZone.silent then + local addInfo = " with d=" .. dist .. ", var = " .. currAccuracy .. " pB=" .. shellBaseStrength .. " tt=" .. aZone.transitionTime + + trigger.action.outTextForCoalition(aGroup:getCoalition(), "Artillery firing on ".. aZone.name .. addInfo, 30) + end + --trigger.action.smoke(center, 2) -- mark location visually +end + +function cfxArtilleryZones.simSmokeZone(aZone, aGroup, aColor) + -- this is simsmoke: transition time is fixed, and we do not + -- use arty units. all very simple. we merely place smoke on + -- ground + if not aColor then aColor = "red" end + if type(aColor) == "string" then + aColor = dcsCommon.smokeColor2Num(aColor) + end + local zP = cfxZones.getPoint(aZone) + aZone.landHeight = land.getHeight({x = zP.x, y= zP.z}) + + local transitionTime = aZone.transitionTime --17 -- seconds until phosphor lands + local center = {x = zP.x, + y =aZone.landHeight + 3, + z = zP.z + } -- center of where shells hit + -- we now can 'dirty' the position by something. not yet + local currAccuracy = 200 + + local thePoint = dcsCommon.randomPointInCircle(currAccuracy, 50, center.x, center.z) +-- thePoint.y = land.getHeight({ x = thePoint.x, y = thePoint.z}) + + timer.scheduleFunction(cfxArtilleryZones.doSmoke, {thePoint, aColor}, timer.getTime() + transitionTime) + + if not aGroup then return end + if aZone.silent then return end + + trigger.action.outTextForCoalition(aGroup:getCoalition(), "Artillery firing single phosphor round at ".. aZone.name, 30) +end + +function cfxArtilleryZones.doSmoke(args) + local thePoint = args[1] + local aColor = args[2] + dcsCommon.markPointWithSmoke(thePoint, aColor) +end + +-- +-- UPDATE +-- + +function cfxArtilleryZones.update() + -- call me in a couple of minutes to 'rekindle' + timer.scheduleFunction(cfxArtilleryZones.update, {}, timer.getTime() + cfxArtilleryZones.updateDelay) + + -- iterate all zones to see if a trigger has changed + for idx, aZone in pairs(cfxArtilleryZones.artilleryZones) do + if aZone.triggerFlag then + local currTriggerVal = trigger.misc.getUserFlag(aZone.triggerFlag) + if currTriggerVal ~= aZone.lastTriggerValue + then + -- a triggered release! + cfxArtilleryZones.doFireAt(aZone) -- all from zone vars! + + if cfxArtilleryZones.verbose then + local addInfo = " with var = " .. aZone.baseAccuracy .. " pB=" .. aZone.shellStrength + trigger.action.outText("Artillery T-Firing on ".. aZone.name .. addInfo, 30) + end + aZone.lastTriggerValue = currTriggerVal + end + + end + end +end + +-- +-- START +-- + +function cfxArtilleryZones.start() + if not dcsCommon.libCheck("cfx Artillery Zones", + cfxArtilleryZones.requiredLibs) then + return false + end + + -- collect all spawn zones + local attrZones = cfxZones.getZonesWithAttributeNamed("artilleryTarget") + + -- now create a spawner for all, add them to the spawner updater, and spawn for all zones that are not + -- paused + for k, aZone in pairs(attrZones) do + cfxArtilleryZones.processArtilleryZone(aZone) -- process attribute and add to zone + cfxArtilleryZones.addArtilleryZone(aZone) -- remember it so we can smoke it + end + + -- start update loop + cfxArtilleryZones.update() + + -- say hi + trigger.action.outText("cfx Artillery Zones v" .. cfxArtilleryZones.version .. " started.", 30) + return true +end + +-- let's go +if not cfxArtilleryZones.start() then + trigger.action.outText("cf/x Artillery Zones aborted: missing libraries", 30) + cfxArtilleryZones = nil +end + + +--[[-- + +TODO: link artillery units that are starting fire, stop when all inside is destroyed +TODO: "Free" link: look for closest artillery zone that is not cooling down or engaged with diofferent targets and is in range +DONE: smoke target zon +DONE: add smoke to UI menu +DONE: move doBoom from demon to this module +DONE: trigger on flag +DONE: inflight time for arty projectiles +DONE: invoke callback for firing +TODO: duration param for bombardment +TODO: silent attribute +--]]-- \ No newline at end of file diff --git a/modules/cfxCargoManager.lua b/modules/cfxCargoManager.lua new file mode 100644 index 0000000..2f1ffcd --- /dev/null +++ b/modules/cfxCargoManager.lua @@ -0,0 +1,171 @@ +cfxCargoManager = {} +cfxCargoManager.version = "1.0.2" +cfxCargoManager.ups = 1 -- updates per second +--[[-- + Version History + - 1.0.0 - initial version + - 1.0.1 - isexist check on remove cargo + - 1.0.2 - ability to access a cargo status + + Cargo Manager is a module that watches cargo that is handed for + management, and initiates callbacks when a cargo event happens + Cargo events are (string) + - lifted (cargo is lifted from ground: ground-->air transition) + - grounded (cargo was put on the ground: air-->ground transition) + - disappeared (cargo was deleted): isExits() failed + - dead (cargo was destroyed) life < 1 + - new (cargo was added to manager) + - remove (cargo was removed from manager) + + callback signature + theCB(event, theCargoObject, cargoName) +--]]-- +cfxCargoManager.requiredLibs = { + "dcsCommon", -- always + "cfxZones", -- Zones, of course +} + +cfxCargoManager.callbacks = {} +cfxCargoManager.allCargo = {} +cfxCargoManager.cargoStatus = {} +cfxCargoManager.cargoPosition = {} +-- callback management + +cfxCargoManager.monitor = false + +function cfxCargoManager.addCallback(cb) + table.insert(cfxCargoManager.callbacks, cb) +end + +function cfxCargoManager.invokeCallback(event, obj, name) + for idx, cb in pairs(cfxCargoManager.callbacks) do + cb(event, obj, name) + end +end + +function cfxCargoManager.standardCallback(event, object, name) + trigger.action.outText("Cargo event <" .. event .. "> for " .. name, 30) +end + +-- get cargo status +function cfxCargoManager.getCargoStatusFor(theCargoObject) + if not theCargoObject then return nil end + local cargoName = "" + if type(theCargoObject) == "string" then + cargoName = theCargoObject + else + cargoName = theCargoObject:getName() + end + + if not cargoName then return nil end + + return cargoStatus[cargoName] + +end +-- add / remove cargo +function cfxCargoManager.addCargo(theCargoObject) + if not theCargoObject then return end + if not theCargoObject:isExist() then return end + local cargoName = theCargoObject:getName() + cfxCargoManager.allCargo[cargoName] = theCargoObject + cfxCargoManager.cargoStatus[cargoName] = "new" +-- cfxCargoManager.cargoStatus[cargoName] = nil + cfxCargoManager.cargoPosition[cargoName] = theCargoObject:getPoint() + cfxCargoManager.invokeCallback("new", theCargoObject, cargoName) +end + +function cfxCargoManager.removeCargoByName(cargoName) + if not cargoName then return end + local theCargoObject = cfxCargoManager.allCargo[cargoName] + cfxCargoManager.invokeCallback("remove", theCargoObject, cargoName) + cfxCargoManager.allCargo[cargoName] = nil + cfxCargoManager.cargoStatus[cargoName] = nil + cfxCargoManager.cargoPosition[cargoName] = nil +end + +function cfxCargoManager.removeCargo(theCargoObject) + if not theCargoObject then return end + if not theCargoObject:isExist() then return end + local cargoName = theCargoObject:getName() + cfxCargoManager.removeCargoByName(cargoName) +end + +-- get all cargo gets all cargos (default) or all cargos +-- that have a certain state, e.g. lifted +function cfxCargoManager.getAllCargo(filterForState) + local theCargo = {} + for name, cargo in pairs(cfxCargoManager.allCargo) do + if (filterForState == nil) or + (filterForState == cfxCargoManager.cargoStatus[name]) + then + table.insert(theCargo, cargo) + end + end + return theCargo +end + +-- update loop +function cfxCargoManager.determineCargoStatus(cargo) + if not cargo then return "disappeared" end + if not cargo:isExist() then return "disappeared" end + -- note that inAir() currently always returns false + local name = cargo:getName() + local oldPos = cfxCargoManager.cargoPosition[name] + local newPos = cargo:getPoint() + cfxCargoManager.cargoPosition[name] = newPos -- update + local delta = dcsCommon.dist(oldPos, newPos) +-- if cargo:inAir() then return "lifted" end -- currentl doesn't work + if delta > 1 then return "lifted" end -- moving + if cargo:getLife() < 1 then return "dead" end + local agl = dcsCommon.getUnitAGL(cargo) + if agl > 5 then return "lifted" end -- not moving but still above ground. good hover! + + -- if velocity > 1 m/s this thing is moving +-- if dcsCommon.vMag(cargo:getVelocity()) > 1 then return "lifted" end -- currently doesn't work + + -- this thing simply sits on the ground + return "grounded" +end + +function cfxCargoManager.update() + -- re-schedule in ups + timer.scheduleFunction(cfxCargoManager.update, {}, timer.getTime() + 1/cfxCargoManager.ups) + + -- iterate all cargos + local newCargoManifest = {} + for name, cargo in pairs(cfxCargoManager.allCargo) do + local newStatus = cfxCargoManager.determineCargoStatus(cargo) + local oldStatus = cfxCargoManager.cargoStatus[name] + if newStatus ~= oldStatus then + cfxCargoManager.invokeCallback(newStatus, cargo, name) + cfxCargoManager.cargoStatus[name] = newStatus + end + if newStatus == "dead" or newStatus == "disappeared" then + cfxCargoManager.removeCargoByName(name) -- we are changing what we iterate? + end + end +end + +-- start up + +function cfxCargoManager.start() + if not dcsCommon.libCheck("cfx Cargo Manager", + cfxCargoManager.requiredLibs) then + return false + end + + -- start update loop + cfxCargoManager.update() + + -- say hi + trigger.action.outText("cfx Cargo Manager v" .. cfxCargoManager.version .. " started.", 30) + return true +end + +-- let's go +if not cfxCargoManager.start() then + trigger.action.outText("cf/x Cargo Manager aborted: missing libraries", 30) + cfxCargoManager = nil +elseif cfxCargoManager.monitor then + cfxCargoManager.addCallback(cfxCargoManager.standardCallback) +end \ No newline at end of file diff --git a/modules/cfxCargoReceiver.lua b/modules/cfxCargoReceiver.lua new file mode 100644 index 0000000..9936ae3 --- /dev/null +++ b/modules/cfxCargoReceiver.lua @@ -0,0 +1,256 @@ +cfxCargoReceiver = {} +cfxCargoReceiver.version = "1.1.0" +cfxCargoReceiver.ups = 1 -- once a second +cfxCargoReceiver.maxDirectionRange = 500 -- in m. distance when cargo manager starts talking to pilots who are carrying that cargo +cfxCargoReceiver.requiredLibs = { + "dcsCommon", -- always + "cfxPlayer", -- for directions + "cfxZones", -- Zones, of course + "cfxCargoManager", -- will notify me on a cargo event +} +--[[-- + Version history + - 1.0.0 initial vbersion + - 1.1.0 added flag manipulation options + no negative agl on announcement + silent attribute + + + CargoReceiver is a zone enhancement you use to be automatically + notified if a cargo was delivered inside the zone. + It also provides BRA when in range to a cargo receiver + + *** EXTENDS ZONES + + Callback signature: + cb(event, obj, name, zone) with + - event being string, currently defined: 'deliver' + - obj being the cargo object + - name being cargo object name + - zone in which cargo was dropped (if dropped) + +--]]-- +cfxCargoReceiver.receiverZones = {} +function cfxCargoReceiver.processReceiverZone(aZone) -- process attribute and add to zone + -- since the attribute is there, simply set the zones + -- isCargoReceiver flag and we are good + aZone.isCargoReceiver = true + -- we can add additional processing here + aZone.autoRemove = cfxZones.getBoolFromZoneProperty(aZone, "autoRemove", false) -- maybe add a removedelay + + aZone.silent = cfxZones.getBoolFromZoneProperty(aZone, "silent", false) + + --trigger.action.outText("+++rcv: recognized receiver zone: " .. aZone.name , 30) + + -- same integration as object destruct detector for flags + if cfxZones.hasProperty(aZone, "setFlag") then + aZone.setFlag = cfxZones.getStringFromZoneProperty(aZone, "setFlag", "999") + end + if cfxZones.hasProperty(aZone, "f=1") then + aZone.setFlag = cfxZones.getStringFromZoneProperty(aZone, "f=1", "999") + end + if cfxZones.hasProperty(aZone, "clearFlag") then + aZone.clearFlag = cfxZones.getStringFromZoneProperty(aZone, "clearFlag", "999") + end + if cfxZones.hasProperty(aZone, "f=0") then + aZone.clearFlag = cfxZones.getStringFromZoneProperty(aZone, "f=0", "999") + end + if cfxZones.hasProperty(aZone, "increaseFlag") then + aZone.increaseFlag = cfxZones.getStringFromZoneProperty(aZone, "increaseFlag", "999") + end + if cfxZones.hasProperty(aZone, "f+1") then + aZone.increaseFlag = cfxZones.getStringFromZoneProperty(aZone, "f+1", "999") + end + if cfxZones.hasProperty(aZone, "decreaseFlag") then + aZone.decreaseFlag = cfxZones.getStringFromZoneProperty(aZone, "decreaseFlag", "999") + end + if cfxZones.hasProperty(aZone, "f-1") then + aZone.decreaseFlag = cfxZones.getStringFromZoneProperty(aZone, "f-1", "999") + end +end + +function cfxCargoReceiver.addReceiverZone(aZone) + if not aZone then return end + cfxCargoReceiver.receiverZones[aZone.name] = aZone +end + + +-- callback handling +cfxCargoReceiver.callbacks = {} +function cfxCargoReceiver.addCallback(cb) + table.insert(cfxCargoReceiver.callbacks, cb) +end + +function cfxCargoReceiver.invokeCallback(event, obj, name, zone) + for idx, cb in pairs(cfxCargoReceiver.callbacks) do + cb(event, obj, name, zone) + end +end + +function cfxCargoReceiver.standardCallback(event, object, name, zone) + trigger.action.outText("Cargo received event <" .. event .. "> for " .. name .. " in " .. zone.name , 30) +end + +-- +-- cargo event happened. Called by Cargo Manager +-- +function cfxCargoReceiver.cargoEvent(event, object, name) + --trigger.action.outText("Cargo Receiver: event <" .. event .. "> for " .. name, 30) + if not event then return end + if event == "grounded" then + --trigger.action.outText("+++rcv: grounded for " .. name, 30) + -- this is actually the only one that interests us + if not object then + --trigger.action.outText("+++rcv: " .. name .. " has null object", 30) + return + end + if not object:isExist() then + --trigger.action.outText("+++rcv: " .. name .. " no longer exists", 30) + return + end + loc = object:getPoint() + + -- now invoke callbacks for all zones + -- this is in + for name, aZone in pairs(cfxCargoReceiver.receiverZones) do + if cfxZones.pointInZone(loc, aZone) then + cfxCargoReceiver.invokeCallback("deliver", object, name, aZone) + + -- set flags as indicated + if aZone.setFlag then + trigger.action.setUserFlag(aZone.setFlag, 1) + end + if aZone.clearFlag then + trigger.action.setUserFlag(aZone.clearFlag, 0) + end + if aZone.increaseFlag then + local val = trigger.misc.getUserFlag(aZone.increaseFlag) + 1 + trigger.action.setUserFlag(aZone.increaseFlag, val) + end + if aZone.decreaseFlag then + local val = trigger.misc.getUserFlag(aZone.decreaseFlag) - 1 + trigger.action.setUserFlag(aZone.decreaseFlag, val) + end + + + --trigger.action.outText("+++rcv: " .. name .. " delivered in zone " .. aZone.name, 30) + --trigger.action.outSound("Quest Snare 3.wav") + if aZone.autoRemove then + -- maybe schedule this in a few seconds? + object:destroy() + end + end + end + end +end + +-- update loop +function cfxCargoReceiver.update() + -- schedule me in 1/ups + timer.scheduleFunction(cfxCargoReceiver.update, {}, timer.getTime() + 1/cfxCargoReceiver.ups) + + -- we now get all cargos that are in the air + local liftedCargos = cfxCargoManager.getAllCargo("lifted") + + + -- new we see if any of these are close to a delivery zone + for idx, aCargo in pairs(liftedCargos) do + local thePoint = aCargo:getPoint() + local receiver, delta = cfxZones.getClosestZone( + thePoint, + cfxCargoReceiver.receiverZones -- must be indexed by name + ) + -- we now check if we are in 'speaking range' and receiver can talk + if (receiver.silent == false) and + (delta < cfxCargoReceiver.maxDirectionRange) then + -- this cargo can be talked down. + -- find the player unit that is closest to in in hopes + -- that that is the one carrying it + local allPlayers = cfxPlayer.getAllPlayers() -- idx by name + for pname, info in pairs(allPlayers) do + -- iterate all player units + local closestUnit = nil + local minDelta = math.huge + local theUnit = info.unit + if theUnit:isExist() then + local uPoint = theUnit:getPoint() + local currDelta = dcsCommon.dist(thePoint, uPoint) + if currDelta < minDelta then + minDelta = currDelta + closestUnit = theUnit + end + end + + -- see if we got a player unit close enough + if closestUnit ~= nil and minDelta < 100 then + -- get group and communicate the relevant info + local theGroup = closestUnit:getGroup() + local insideZone = cfxZones.pointInZone(thePoint, receiver) + local message = aCargo:getName() + if insideZone then + message = message .. " is inside delivery zone " .. receiver.name + else + -- get bra to center + local ownHeading = dcsCommon.getUnitHeadingDegrees(closestUnit) + local oclock = dcsCommon.clockPositionOfARelativeToB( + receiver.point, + thePoint, + ownHeading) .. " o'clock" + message = receiver.name .. " is " .. math.floor(delta) .. "m at your " .. oclock + end + -- add agl + local agl = dcsCommon.getUnitAGL(aCargo) + if agl < 0 then agl = 0 end + message = message .. ". Cargo is " .. math.floor(agl) .. "m AGL." + -- now say so. 5 second staying power, one second override + -- full erase screen + trigger.action.outTextForGroup(theGroup:getID(), + message, 5, true) + else + -- cargo in range, no player + + end + end + end + end +end + +-- +-- GO! +-- + +function cfxCargoReceiver.start() + if not dcsCommon.libCheck("cfx Cargo Receiver", + cfxCargoReceiver.requiredLibs) then + return false + end + + -- scan all zones for cargoReceiver flag + local attrZones = cfxZones.getZonesWithAttributeNamed("cargoReceiver") + + -- now create a spawner for all, add them to the spawner updater, and spawn for all zones that are not + -- paused + for k, aZone in pairs(attrZones) do + cfxCargoReceiver.processReceiverZone(aZone) -- process attribute and add to zone + cfxCargoReceiver.addReceiverZone(aZone) -- remember it so we can smoke it + end + + -- tell cargoManager that I want to be involved + cfxCargoManager.addCallback(cfxCargoReceiver.cargoEvent) + + -- start update loop + cfxCargoReceiver.update() + + -- say hi + trigger.action.outText("cfx Cargo Receiver v" .. cfxCargoReceiver.version .. " started.", 30) + return true +end + +-- let's go +if not cfxCargoReceiver.start() then + trigger.action.outText("cf/x Cargo Receiver aborted: missing libraries", 30) + cfxCargoReceiver = nil +end + +-- TODO: config zone for talking down pilots +-- TODO: f+/f-/f=1/f=0 \ No newline at end of file diff --git a/modules/cfxCommander.lua b/modules/cfxCommander.lua new file mode 100644 index 0000000..2387cda --- /dev/null +++ b/modules/cfxCommander.lua @@ -0,0 +1,515 @@ +-- cfxCommander - issue dcs commands to groups etc +-- +-- supports scheduling +-- *** EXTENDS ZONES: 'pathing' attribute +-- +cfxCommander = {} +cfxCommander.version = "1.1.2" +--[[-- VERSION HISTORY + - 1.0.5 - createWPListForGroupToPointViaRoads: detect no road found + - 1.0.6 - build in more group checks in assign wp list + - added sanity checks for doScheduledTask + - assignWPListToGroup now can schedule tasks + - makeGroupGoThere supports scheduling + - makeGroupGoTherePreferringRoads supports scheduling + - scheduleTaskForGroup supports immediate execution + - makeGroupHalt + - 1.0.7 - warning if road shorter than direct + - forceOffRoad option + - noRoadsAtAll option + - 1.1.0 - load libs + - pathing zones. Currently only supports + - offroad to override road-usage + - pathing zones are overridden by noRoadsAtAll + - CommanderConfig zones + - 1.1.1 - default pathing for pathing zone is normal, not offroad + - 1.1.2 - makeGroupTransmit + - makeGroupStopTransmitting + - verbose check before path warning + - added delay defaulting for most scheduling functions +--]]-- + +cfxCommander.requiredLibs = { + "dcsCommon", -- common is of course needed for everything + "cfxZones", -- zones management for pathing zones +} + +cfxCommander.verbose = false +cfxCommander.forceOffRoad = true -- if true, vehicles path follow roads, but may drive offroad (they follow vertex points from path but not the road as they are still commanded 'offroad') +cfxCommander.noRoadsAtAll = true -- if true, always go direct, overrides forceOffRoad when true. Always a two-point path. Here, there, bang! +cfxCommander.pathZones = {} -- zones that can override road settings + +-- +-- path zone +-- +function cfxCommander.processPathingZone(aZone) -- process attribute and add to zone + local pathing = cfxZones.getStringFromZoneProperty(aZone, "pathing", "normal") -- must be "offroad" to force offroad + pathing = pathing:lower() + -- currently no validation of attribute + aZone.pathing = pathing +end + +function cfxCommander.addPathingZone(aZone) + table.insert(cfxCommander.pathZones, aZone) +end + +function cfxCommander.hasPathZoneFor(here, there) + for idx, aZone in pairs(cfxCommander.pathZones) do + if cfxZones.pointInZone(here, aZone) then return aZone end + if cfxZones.pointInZone(there, aZone) then return aZone end + end + return nil +end + +-- +-- Config Zone Reading if present +-- +function cfxCommander.readConfigZone() + -- note: must match exactly!!!! + local theZone = cfxZones.getZoneByName("CommanderConfig") + if not theZone then + trigger.action.outText("+++cmdr: no config zone!", 30) + return + end + + trigger.action.outText("+++cmdr: found config zone!", 30) + + cfxCommander.verbose = cfxZones.getBoolFromZoneProperty(theZone, "verbose", false) + cfxCommander.forceOffRoad = cfxZones.getBoolFromZoneProperty(theZone, "forceOffRoad", false) -- if true, vehicles path follow roads, but may drive offroad + cfxCommander.noRoadsAtAll = cfxZones.getBoolFromZoneProperty(theZone, "noRoadsAtAll", false) + +end + +-- +-- Options are key, value pairs. Scheduler when you are creating groups +-- + +function cfxCommander.doOption(data) + if cfxCommander.verbose then + trigger.action.outText("Commander: setting option " .. data.key .. " --> " .. data.value, 30) + end + + local theController = data.group:getController() + theController:setOption(data.key, data.value) +end + +function cfxCommander.scheduleOptionForGroup(group, key, value, delay) + local data = {} + if not delay then delay = 0.1 end + data.group = group + data.key = key + data.value = value + timer.scheduleFunction(cfxCommander.doOption, data, timer.getTime() + delay) +end + +-- +-- performCommand is a special version of issuing a command +-- that can be easily schduled by pushing the commandData on +-- the stack with scheduling it +-- group or name must be filled to get the group, +-- and the command table is what is going to be passed to the setCommand +-- commands are given in an array, so you can stack commands +function cfxCommander.performCommands(commandData) + -- see if we have a group + if not commandData.group then + commandData.group = Group.getByName(commandData.name) -- better be inited! + end + -- get the AI + local theController = commandData.group:getController() + for i=1, #commandData.commands do + if cfxCommander.verbose then + trigger.action.outText("Commander: performing " .. commandData.commands[i].id, 30) + end + theController:setCommand(commandData.commands[i]) + end + + return nil -- a timer called us, so we return no desire to be rescheduled +end + +function cfxCommander.scheduleCommands(data, delay) + if not delay then delay = 1 end + timer.scheduleFunction(cfxCommander.performCommands, data, timer.getTime() + delay) +end + +function cfxCommander.scheduleSingleCommand(group, command, delay) + if not delay then delay = 1 end + local data = createCommandDataTableFor(group) + cfxCommander.addCommand(data, command) + cfxCommander.scheduleCommands(data, delay) +end + + +function cfxCommander.createCommandDataTableFor(group, name) + local cD = {} + if not group then + cD.name = name + else + cD.group = group + end + cD.commands={} + return cD +end + +function cfxCommander.addCommand(theCD, theCommand) + if not theCD then return end + if not theCommand then return end + + table.insert(theCD.commands, theCommand) +end + +function cfxCommander.createSetFrequencyCommand(freq, modulator) + local theCmd = {} + if not freq then freq = 100 end + if not modulator then modulator = 0 end -- AM = 0, default + theCmd.id = 'SetFrequency' + theCmd.params = {} + theCmd.params.frequency = freq * 10000 -- 88 --> 880000. 124 --> 1.24 MHz + theCmd.params.modulation = modulator + return theCmd +end + +-- oneShot is optional. if present and anything but false, will cause message to +-- me sent only once, no loops +function cfxCommander.createTransmissionCommand(filename, oneShot) + local looping = true + if not filename then filename = "dummy" end + if oneShot then looping = false end + local theCmd = {} + theCmd.id = 'TransmitMessage' + theCmd.params = {} + theCmd.params.loop = looping + theCmd.params.file = "l10n/DEFAULT/" .. filename -- need to prepend the resource string + return theCmd +end + +function cfxCommander.createStopTransmissionCommand() + local theCmd = {} + theCmd.id = 'stopTransmission' + theCmd.params = {} + return theCmd +end + +-- +-- tasks +-- + +function cfxCommander.doScheduledTask(data) + if cfxCommander.verbose then + trigger.action.outText("Commander: setting task " .. data.task.id .. " for group " .. data.group:getName(), 30) + end + local theGroup = data.group + if not theGroup then return end + if not theGroup.isExist then return end + + local theController = theGroup:getController() + theController:pushTask(data.task) +end + +function cfxCommander.scheduleTaskForGroup(group, task, delay) + if not delay then delay = 0 end + local data = {} + data.group = group + data.task = task + if delay < 0.001 then + cfxCommander.doScheduledTask(data) -- immediate execution + return + end + timer.scheduleFunction(cfxCommander.doScheduledTask, data, timer.getTime() + delay) +end + +function cfxCommander.createAttackGroupCommand(theGroupToAttack) + local task = {} + task.id = 'AttackGroup' + task.params = {} + task.params.groupID = theGroupToAttack:getID() + return task +end + +function cfxCommander.createEngageGroupCommand(theGroupToAttack) + local task = {} + task.id = 'EngageGroup' + task.params = {} + task.params.groupID = theGroupToAttack:getID() + return task +end + +-- +-- waypoints, routes etc +-- + +-- basic waypoint is for ground units. point can be xyz or xy +function cfxCommander.createBasicWaypoint(point, speed, formation) + local wp = {} + wp.x = point.x + -- support xyz and xy format + if point.z then + wp.y = point.z + else + wp.y = point.y + end + + if not speed then speed = 6 end -- 6 m/s = 20 kph + wp.speed = speed + + if cfxCommander.forceOffRoad then + formation = "Off Road" + end + + if not formation then formation = "Off Road" end + -- legal formations: + -- Off road + -- On Road -- second letter upper case? + -- Cone + -- Rank + -- Diamond + -- Vee + -- EchelonR + -- EchelonL + wp.action = formation -- silly name, but that's how ME does it + wp.type = 'Turning Point' + return wp + +end + +function cfxCommander.buildTaskFromWPList(wpList) + -- build the task that will make a group follow the WP list + -- we do this by creating a "Mission" task around the WP List + -- WP list is consumed by this action + local missionTask = {} + missionTask.id = "Mission" + missionTask.params = {} + missionTask.params.route = {} + missionTask.params.route.points=wpList + return missionTask +end + +function cfxCommander.assignWPListToGroup(group, wpList, delay) + if not delay then delay = 0 end + if not group then return end + if type(group) == 'string' then -- group name, nice mist trick + group = Group.getByName(group) + end + if not group then return end + if not group:isExist() then return end + + local theTask = cfxCommander.buildTaskFromWPList(wpList) + local ctrl = group:getController() + +--[[-- + if delay < 0.001 then -- immediate action + if ctrl then + ctrl:setTask(theTask) + end + else + -- delay execution of this command by the specified amount + -- of seconds + cfxCommander.scheduleTaskForGroup(group, theTask, delay) + end +--]]-- + cfxCommander.scheduleTaskForGroup(group, theTask, delay) +end + +function cfxCommander.createWPListForGroupToPoint(group, point, speed, formation) + if type(group) == 'string' then -- group name + group = Group.getByName(group) + end + + local wpList = {} + -- here we are, and we want to go there. In DCS, this means that + -- we need to create a wp list consisting of here and there + local here = dcsCommon.getGroupLocation(group) + local wpHere = cfxCommander.createBasicWaypoint(here, speed, formation) + local wpThere = cfxCommander.createBasicWaypoint(point, speed, formation) + wpList[1] = wpHere + wpList[2] = wpThere + return wpList +end + +-- make a ground units group head to a waypoint by replacing the entire mission +-- with a two-waypoint lsit from (here) to there at speed and formation. formation +-- default is 'off road' +function cfxCommander.makeGroupGoThere(group, there, speed, formation, delay) + if not delay then delay = 0 end + if type(group) == 'string' then -- group name + group = Group.getByName(group) + end + local wp = cfxCommander.createWPListForGroupToPoint(group, there, speed, formation) + + cfxCommander.assignWPListToGroup(group, wp, delay) +end + +function cfxCommander.calculatePathLength(roadPoints) + local totalLen = 0 + if #roadPoints < 2 then return 0 end + for i=1, #roadPoints-1 do + totalLen = totalLen + dcsCommon.dist(roadPoints[i], roadPoints[i+1]) + end + return totalLen +end + +-- make ground units go from here (group location) to there, using roads if possible +function cfxCommander.createWPListForGroupToPointViaRoads(group, point, speed) + if type(group) == 'string' then -- group name + group = Group.getByName(group) + end + + local wpList = {} + -- here we are, and we want to go there. In DCS, this means that + -- we need to create a wp list consisting of here and there + -- when going via roads, we add to more wayoints: + -- go on-roads and leaveRoads. + -- only if we can get these two additional points, we do that, else we + -- fall back to direct route + + local here = dcsCommon.getGroupLocation(group) + + -- now generate a list of all points from here to there that uses roads + local rawRoadPoints = land.findPathOnRoads('roads', here.x, here.z, point.x, point.z) + -- this is the entire path. calculate the length and make + -- sure that path on-road isn't more than twice as long + -- that can happen if a bridge is out or we need to go around a hill + if not rawRoadPoints or #rawRoadPoints<3 then + trigger.action.outText("+++ no roads leading there. Taking direct approach", 30) + return cfxCommander.createWPListForGroupToPoint(group, point, speed) + end + + local pathLength = cfxCommander.calculatePathLength(rawRoadPoints) + local direct = dcsCommon.dist(here, point) + if pathLength < direct and cfxCommander.verbose then + trigger.action.outText("+++dcsC: WARNING road path (" .. pathLength .. ") shorter than direct route(" .. direct .. "), will not path correctly", 30) + end + + if pathLength > (2 * direct) then + -- road takes too long, take direct approach + --trigger.action.outText("+++ road path (" .. pathLength .. ") > twice direct route(" .. direct .. "), commencing direct off-road", 30) + return cfxCommander.createWPListForGroupToPoint(group, point, speed) + end + + --trigger.action.outText("+++ ".. group:getName() .. ": choosing road path l=" .. pathLength .. " over direct route d=" .. direct, 30) + + -- if we are here, the road trip is valid + for idx, wp in pairs(rawRoadPoints) do + -- createBasic... supports w.xy format + local theNewWP = cfxCommander.createBasicWaypoint(wp, speed, "On Road") -- force off road for better compatibility? + table.insert(wpList, theNewWP) + end + + + + -- now make first and last entry OFF Road + local wpc = wpList[1] + wpc.action = "Off Road" + wpc = wpList[#wpList] + wpc.action = "Off Road" + + return wpList +end + +function cfxCommander.makeGroupGoTherePreferringRoads(group, there, speed, delay) + if type(group) == 'string' then -- group name + group = Group.getByName(group) + end + if not delay then delay = 0 end + + + if cfxCommander.noRoadsAtAll then + -- we don't even follow roads, completely forced off + cfxCommander.makeGroupGoThere(group, there, speed, "Off Road", delay) + return + end + + -- see if we have an override situation + -- for one of the two points where a pathing Zone + -- overrides the roads setting + if #cfxCommander.pathZones > 0 then + local here = dcsCommon.getGroupLocation(group) + local oRide = cfxCommander.hasPathZoneFor(here, there) + if oRide and oRide.pathing == "offroad" then + -- yup, override road preference + cfxCommander.makeGroupGoThere(group, there, speed, "Off Road", delay) + --trigger.action.outText("pathing: override offroad") + return + end + end + + -- viaRoads will only use roads if the road trip isn't more than twice + -- as long as the direct route + local wp = cfxCommander.createWPListForGroupToPointViaRoads(group, there, speed) + cfxCommander.assignWPListToGroup(group, wp, delay) +end + + +function cfxCommander.makeGroupHalt(group, delay) + if not group then return end + if not group:isExist() then return end + if not delay then delay = 0 end + local theTask = {id = 'Hold', params = {}} + cfxCommander.scheduleTaskForGroup(group, theTask, delay) +end + +function cfxCommander.makeGroupTransmit(group, tenKHz, filename, oneShot, delay) + if not group then return end + if not tenKHz then tenKHz = 20 end -- default to 200KHz + if not delay then delay = 1.0 end + if not filename then return end + if not oneShot then oneShot = false end + + -- now build the transmission command + local theCommands = cfxCommander.createCommandDataTableFor(group) + local cmd = cfxCommander.createSetFrequencyCommand(tenKHz) -- freq in 10000 Hz + cfxCommander.addCommand(theCommands, cmd) + cmd = cfxCommander.createTransmissionCommand(filename, oneShot) + cfxCommander.addCommand(theCommands, cmd) + cfxCommander.scheduleCommands(theCommands, delay) +end + +function cfxCommander.makeGroupStopTransmitting(group, delay) + if not delay then delay = 1 end + if not group then return end + local theCommands = cfxCommander.createCommandDataTableFor(group) + local cmd = cfxCommander.createStopTransmissionCommand() + cfxCommander.addCommand(theCommands, cmd) + cfxCommander.scheduleCommands(theCommands, delay) +end + + +function cfxCommander.start() + -- make sure we have loaded all relevant libraries + if not dcsCommon.libCheck("cfx Commander", cfxCommander.requiredLibs) then + trigger.action.outText("cf/x Commander aborted: missing libraries", 30) + return false + end + + -- identify and process all 'pathing' zones + local pathZones = cfxZones.getZonesWithAttributeNamed("pathing") + + -- now create a spawner for all, add them to the spawner updater, and spawn for all zones that are not + -- paused + for k, aZone in pairs(pathZones) do + cfxCommander.processPathingZone(aZone) -- process attribute and add to zone + cfxCommander.addPathingZone(aZone) -- remember it so we can smoke it + end + + -- read config overides + cfxCommander.readConfigZone() + + return true +end + +if cfxCommander.start() then + trigger.action.outText("cfxCommander v" .. cfxCommander.version .. " loaded", 30) +else + trigger.action.outText("+++cfxCommander load FAILED", 30) + cfxCommander = nil +end + +--[[-- known issues + +- troops remain motionless until all are repaired or produced after cature +- long roads / roads not taken in persia +- all troops red and blue become motionless when one zone is occupied +- after capture, the troop capturing remains, all others can go on. one will always remain there +- rethink the factor to add to road, and simply add 100m + + TODO: break long distances into smaller paths, and gravitate towards pathing zones if they have a 'gravitate' or similar attribute +--]]-- diff --git a/modules/cfxGroudTroops.lua b/modules/cfxGroudTroops.lua new file mode 100644 index 0000000..eb428f0 --- /dev/null +++ b/modules/cfxGroudTroops.lua @@ -0,0 +1,1138 @@ +cfxGroundTroops = {} +cfxGroundTroops.version = "1.7.4" +cfxGroundTroops.ups = 1 +cfxGroundTroops.verbose = false +cfxGroundTroops.requiredLibs = { + "dcsCommon", -- common is of course needed for everything + -- pretty stupid to check for this since we + -- need common to invoke the check, but anyway + "cfxCommander", -- generic data module for weight + -- cfxOwnedZones is optional +} +-- ground troops: a module to manage ground toops. makes groups of ground troops +-- patrol and engage enemies and signal idle +-- understands cfxOwnedZones orders 'attackOwnedZone' and will re-direct +-- troops when a zone was captured by interacting with cfxOwnedZones to +-- find the nearest non-owned zone and direct the group there + +-- USAGE +-- Allocate a group in game and issue them marching orders towars a goal +-- then createGroundTroops to allocate a structure used by this +-- module and addTroopsToPool to have them then managed by this +-- module + +cfxGroundTroops.deployedTroops = {} + +-- version history +-- 1.3.0 - added "wait-" prefix to have toops do nothing +-- - added lazing +-- 1.3.1 - sound for lazing msg is "UI_SCI-FI_Tone_Bright_Dry_20_stereo.wav" +-- - lazing --> lasing in text +-- 1.3.2 - set ups to 2 +-- 1.4.0 - queued updates except for lazers +-- 1.4.1 - makeTroopsEngageZone now issues hold before moving on 5 seconds later +-- - getTroopReport +-- - include size of group +-- 1.4.2 - uses unitIsInfantry from dcsCommon +-- 1.5.0 - new scheduled updates per troop to reduce processor load +-- - tiebreak code +-- 1.5.1 - small bugfix in scheduled code +-- 1.5.2 - checkSchedule +-- - speed warning in scheduler +-- - go off road when speed warning too much +-- 1.5.3 - monitor troops +-- - managed queue for ground troops +-- - on second switch to offroad now removed from MQ +-- 1.5.4 - removed debugging messages +-- 1.5.5 - removed bug in troop report reading nil destination +-- 1.6.0 - check modules +-- 1.6.1 - troopsCallback management so you can be informed if a +-- troop you have added to the pool is dead or has achieved a goal. +-- callback will list reasons "dead" and "arrived" +-- updateAttackers +-- 1.6.2 - also accept 'lase' as 'laze', translate directly +-- 1.7.0 - now can use groundTroopsConfig zone +-- 1.7.1 - addTroopsDeadCallback() renamed to addTroopsCallback() +-- - invokeCallbacksFor also accepts and passes on data block +-- - troops is always passed in data block as .troops +-- 1.7.2 - callback when group is neutralized on guard orders +-- - callback when group is being engaged under guard orders +-- 1.7.3 - callbacks for lase:tracking and lase:stop +-- 1.7.4 - verbose flag, warnings suppressed + + + + +-- an entry into the deployed troop has the following attributes +-- - group - the group +-- - orders: "guard" - will guard the spot and look for enemies in range +-- "patrol" - will walk between way points back and forth +-- "laze" - will stay in place and try to laze visible vehicles in range +-- "attackOwnedZone" - interface to cfxOwnedZones module, seeks out +-- enemy zones to attack and capture them +-- "wait-" do nothing. the "wait" prefix will be removed some time and then revealed. Used at least by heloTroops +-- "train" - target dummies. ROE=HOLD, no ground loop +-- "attack" - transition to destination, once there, stop and +-- switch to guard. requires destination zone be sez to a valid cfxZone +-- - coalition - the coalition from the group +-- - enemy - if set, the group this group it is engaging. this means the group is fighting and not idle +-- - name - name of group, dan be freely changed +-- - signature - "cfx" to tell apart from dcs groups +-- - range = range to look for enemies. default is 300m. In "laze" orders, range to laze +-- - lazeTarget - target currently lazing +-- - lazeCode - laser code. default is 1688 + +-- +-- usage: +-- take a dcs group of ground troops and create a cfx ground troop record with +-- createGroundTroops() +-- then add this to the manager with +-- addGroundTroopsToPool() +-- +-- you can control what the group is to do by changing the cfx troop attribute orders +-- you can install a callback that will notify you if a troop reached a goal or +-- was killed with addTroopsCallback() which will also give a reason +-- callback pattern is myCallback(reason, theGroup, orders, data) with troop being the +-- group, and orders the original orders, and reason a string containing why the +-- callback was invoked. Currently defined reasons are +-- - "dead" - entire group was killed +-- - "arrived" - at least a part of group arrived at destination (only with some orders) +-- + +-- +-- UPDATE MODELS +-- standard is update all every time: fastest, but may cause +-- performance issues +-- queued will work one every pass (except for lazed), distributing the load much better +-- schedueld installs a callback for each group separately and thus distributes the load over time much better + +cfxGroundTroops.queuedUpdates = false -- set to true to process one group per turn. To work this way, scheduledUpdates must be false +cfxGroundTroops.scheduledUpdates = true -- set to false to allow queing of standard updates. overrides queuedUpdates +cfxGroundTroops.monitorNumbers = false -- set to true to debug managed group size + +cfxGroundTroops.standardScheduleInterval = 30 -- 30 seconds between calls +cfxGroundTroops.guardUpdateInterval = 30 -- every 30 seconds we check up on guards +cfxGroundTroops.trackingUpdateInterval = 0.5 -- 0.5 seconds for lazer tracking etc + +cfxGroundTroops.maxManagedTroops = 67 -- -1 is infinite, any positive number turn on cap on managed troops and palces excess troops in queue +cfxGroundTroops.troopQueue = {} -- FIFO stack +-- return the best tracking interval for this type of orders + +-- +-- READ CONFIG ZONE TO OVERRIDE SETTING +-- +function cfxGroundTroops.readConfigZone() + -- note: must match exactly!!!! + local theZone = cfxZones.getZoneByName("groundTroopsConfig") + if not theZone then + if cfxGroundTroops.verbose then + trigger.action.outText("***gndT: NO config zone!", 30) + end + return + end + + -- ok, for each property, load it if it exists + if cfxZones.hasProperty(theZone, "queuedUpdates") then + cfxGroundTroops.queuedUpdates = cfxZones.getBoolFromZoneProperty(theZone, "queuedUpdates", false) + end + + if cfxZones.hasProperty(theZone, "scheduledUpdates") then + cfxGroundTroops.scheduledUpdates = cfxZones.getBoolFromZoneProperty(theZone, "scheduledUpdates", false) + end + + if cfxZones.hasProperty(theZone, "maxManagedTroops") then + cfxGroundTroops.maxManagedTroops = cfxZones.getNumberFromZoneProperty(theZone, "maxManagedTroops", 65) + end + + if cfxZones.hasProperty(theZone, "monitorNumbers") then + cfxGroundTroops.monitorNumbers = cfxZones.getBoolFromZoneProperty(theZone, "monitorNumbers", false) + end + + if cfxZones.hasProperty(theZone, "standardScheduleInterval") then + cfxGroundTroops.standardScheduleInterval = cfxZones.getNumberFromZoneProperty(theZone, "standardScheduleInterval", 30) + end + + if cfxZones.hasProperty(theZone, "guardUpdateInterval") then + cfxGroundTroops.guardUpdateInterval = cfxZones.getNumberFromZoneProperty(theZone, "guardUpdateInterval", 30) + end + + if cfxZones.hasProperty(theZone, "trackingUpdateInterval") then + cfxGroundTroops.trackingUpdateInterval = cfxZones.getNumberFromZoneProperty(theZone, "trackingUpdateInterval", 0.5) + end + + if cfxZones.hasProperty(theZone, "verbose") then + cfxGroundTroops.verbose = cfxZones.getBoolFromZoneProperty(theZone, "verbose", false) + end + + if cfxGroundTroops.verbose then + trigger.action.outText("+++gndT: read config zone!", 30) + end +end + + +-- +-- Callback handling +-- + +cfxGroundTroops.troopsCallback = {} + +function cfxGroundTroops.addTroopsCallback(theCallback) + table.insert(cfxGroundTroops.troopsCallback, theCallback) +end + +function cfxGroundTroops.invokeCallbacksFor(reason, troops, data) + if not data then data = {} end + data.troops = troops + for idx, theCB in pairs (cfxGroundTroops.troopsCallback) do + theCB(reason, troops.group, troops.orders, data) + end +end + +function cfxGroundTroops.getScheduleInterval(orders) + if orders == "laze" then + return cfxGroundTroops.trackingUpdateInterval + end + return cfxGroundTroops.standardScheduleInterval +end + +-- create controller commands to attack a group "enemies" +-- enemies are an attribute of the troop structure +function cfxGroundTroops.makeTroopsEngageEnemies(troop) + local group = troop.group + local enemies = troop.enemy + local from = dcsCommon.getGroupLocation(group) + if not from then return end -- the commandos died + local there = dcsCommon.getGroupLocation(enemies) + if not there then return end + + -- we lerp to 2/3 of enemy location + there = dcsCommon.vLerp(from, there, 0.66) + + local speed = 10 -- m/s = 10 km/h + cfxCommander.makeGroupGoThere(group, there, speed) + local attask = cfxCommander.createAttackGroupCommand(enemies) + cfxCommander.scheduleTaskForGroup(group, attask, 0.5) +end + +-- make the troops engage a cfxZone passed in the destination +-- attribute +function cfxGroundTroops.makeTroopsEngageZone(troop) + local group = troop.group + local enemyZone = troop.destination -- must be cfxZone + local from = dcsCommon.getGroupLocation(group) + if not from then return end -- the group died + local there = enemyZone.point -- access zone position + if not there then return end + + -- we lerp to 102% of enemy location to force overshoot and engagement + --there = dcsCommon.vLerp(from, there, 1.02) + + local speed = 14 -- m/s; 10 m/s = 36 km/h + -- we prefer going over roads since we don't know + -- what is there + + -- make troops stop in 1 second, then start in 5 seconds to give AI respite + cfxCommander.makeGroupHalt(group, 1) -- 1 second delay + cfxCommander.makeGroupGoTherePreferringRoads(group, there, speed, 5) + -- no attack command since we don't know what is there + -- but mayhaps we should issue weapons free? + -- we'll soon test that by sticking in a troop on the way + +-- local attask = cfxCommander.createAttackGroupCommand(enemies) +-- cfxCommander.scheduleTaskForGroup(group, attask, 0.5) +end + +function cfxGroundTroops.switchToOffroad(troops) + -- we may need to test if we already did this, + -- but not for now + + -- this is called when troops are stuck + -- on their route for longer than allowed + -- we now force a direct approach + local group = troops.group + local enemies = troops.destination + local from = dcsCommon.getGroupLocation(group) + if not from then return end -- the commandos died + local there = enemies.point + if not there then return end + + local speed = 14 -- m/s; 10 m/s = 36 km/h + + cfxCommander.makeGroupHalt(group, 0) -- no delay, halt now + cfxCommander.makeGroupGoThere(group, there, speed, "Off Road", 5) + + troops.lastOrderDate = timer.getTime() + troops.speedWarning = 0 +end + +-- +-- update loop for troops that have 'attackOwnedZones' as +-- their orders +-- if they have no destination zone, or the zone they are +-- are heading for is already owned by their side, then look for +-- the closest enemy zone, and cut attack orders to move there +function cfxGroundTroops.getClosestEnemyZone(troop) + local p = dcsCommon.getGroupLocation(troop.group) + local tempZone = cfxZones.createSimpleZone("tz", p, 100) + tempZone.owner = troop.side + local newTarget = cfxOwnedZones.getNearestEnemyOwnedZone(tempZone, true) -- 'true' will also target neutral zones + return newTarget +end + +function cfxGroundTroops.updateZoneAttackers(troop) + if not troop then return end + troop.insideDestination = false -- mark as not inside + + local newTargetZone = cfxGroundTroops.getClosestEnemyZone(troop) + if not newTargetZone then + -- all target zones are friendly, go to guard mode +-- trigger.action.outTextForCoalition(troop.side, troop.name .. " holding position", 30) + troop.orders = "guard" + return + end + + if newTargetZone ~= troop.destination then +-- trigger.action.outTextForCoalition(troop.side, troop.name .. " enroute to " .. newTargetZone.name, 30) + troop.destination = newTargetZone + cfxGroundTroops.makeTroopsEngageZone(troop) + troop.lastOrderDate = timer.getTime() + troop.speedWarning = 0 + return + end + + -- if we get here, we are under way to troop.destination + -- check if we are inside the zone, and if so, set variable to true + local p = dcsCommon.getGroupLocation(troop.group) + troop.insideDestination = cfxZones.isPointInsideZone(p, troop.destination) + +-- if we get here, we need no change + + +end + +-- attackers simply travel to their destination, and then switch to +-- guard orders once they arrive +function cfxGroundTroops.updateAttackers(troop) + if not troop then return end + if not troop.destination then return end + + if cfxZones.isGroupPartiallyInZone(troop.group, troop.destination) then + -- we have arrived + -- we could now also initiate a general callback with reason + cfxGroundTroops.invokeCallbacksFor("arrived", troop) + troop.orders = "guard" + return + end + + +-- if we get here, we need no change +end + +-- update loop for a group that has "guard" orders. +-- basically it stands around and looks for enemies +-- until it finds a group, and then engages the enemy +-- when engaged, it is not looking for other enemies +-- 'engaged' means that the troop.enemy attribute is set + +function cfxGroundTroops.updateGuards(troop) + local theEnemy = troop.enemy + if theEnemy then + -- see if enemy is dead + if not dcsCommon.isGroupAlive(theEnemy) then + troop.enemy = nil + -- yup, zed's dead. next time around, we won't be checking this again + trigger.action.outText(troop.name .. " has neutralized enemy forces", 30) + --DONE: invoke callback for defeating troops + local data = {} + data.enemy = theEnemy + cfxGroundTroops.invokeCallbacksFor("neutralized", troop, data) + return + end + -- yes, we are still engaged + return + end + + -- we are currently unengaged. look for an enemy + if not troop.range then troop.range = 300 end + troop.coalition = troop.group:getCoalition() + local enemyCoal = dcsCommon.getEnemyCoalitionFor(troop.coalition) + local cat = Group.Category.GROUND + local p = dcsCommon.getGroupLocation(troop.group) + local enemies, enemyDist = dcsCommon.getClosestLivingGroupToPoint(p, enemyCoal, cat) + local maxRange = troop.range -- meters + -- if we have enemies then schedule a path to go there + if enemies and (enemyDist < maxRange) then + troop.enemy = enemies + --timer.scheduleFunction(cfxGroundTroops.makeGroupEngageEnemies, troop, timer.getTime() + 1.0) + cfxGroundTroops.makeTroopsEngageEnemies(troop) + trigger.action.outText(troop.name .. " is engaging enemy forces at range " .. math.floor(enemyDist) .. "meters", 30) + --DONE: invoke callback for engaging troops, pass data + local data = {} + data.enemy = enemies + cfxGroundTroops.invokeCallbacksFor("engaging", troop, data) + elseif enemies then + --trigger.action.outText(troop.name .. " enemiy out of range: " .. math.floor(enemyDist) .. "meters", 30) + else + --trigger.action.outText(troop.name .. " no enemies", 30) + end +end + +-- +-- update loop for units that laze targets. +-- they can only laze if they are alive, but update +-- will take care of that, so when we are here, there +-- is at least one of them alive +-- + +function cfxGroundTroops.findLazeTarget(troop) + local here = troop.group:getUnit(1):getPoint() + troop.coalition = troop.group:getCoalition() + local enemyCoal = dcsCommon.getEnemyCoalitionFor(troop.coalition) + --local enemySide = dcsCommon.getEnemyCoalitionFor(troop.side) + local cat = Group.Category.GROUND + local enemyGroups = dcsCommon.getLivingGroupsAndDistInRangeToPoint(here, troop.range, enemyCoal, cat) + -- we now have a list of possible targets in range + if #enemyGroups < 1 then + -- no targets in range + return nil + end + + here = {x = here.x, y = here.y + 2.0, z = here.z} -- raise by 2.0m + + -- iterate through the list until we find the first target + -- that fits the bill and return it +-- trigger.action.outText("+++ looking at " .. #enemyGroups .. " laze groups", 30) + for i=1, #enemyGroups do + -- get all units for this group + local aGroup = enemyGroups[i].group -- remember, they are in a {dist, group} tuple + local theUnits = aGroup:getUnits() + -- iterate all units + for udx, aUnit in pairs(theUnits) do + if (aUnit:isExist() and aUnit:getLife() > 1) then + -- unit lives + -- now, we need to filter infantry. we do this by + -- pre-fetching the typeString + --troop.lazeTargetType = aUnit:getTypeName() + -- and checking if the name contains some infantry- + -- typical strings. Idea taken from JTAC script + local isInfantry = dcsCommon.unitIsInfantry(theUnit) + + + if not isInfantry then + -- this is a vehicle, is it in line of sight? + -- raise the point 2m above ground for both points + -- as done in jtac script + local there = aUnit:getPoint() + there = {x = there.x, y = there.y + 2.0, z = there.z} + + if land.isVisible(here, there) then + -- we found a visible vehicle in + -- the nearest group to us in range + -- that is visible! + return aUnit + else + --trigger.action.outText("+++ ".. aUnit:getName() .."cant be seen", 30) + end -- if visible + else + -- trigger.action.outText("+++ ".. aUnit:getName() .." (".. troop.lazeTargetType .. ") is infantry", 30) + end -- if not infantry + end -- if alive + end -- for all units + end -- for all enemy groups + --trigger.action.outText("+++ find nearest laze target did not find anything to laze", 30) + return nil -- no unit found +end + +function cfxGroundTroops.lazerOff(troop) + if troop.lazerPointer then + troop.lazerPointer:destroy() + end + troop.lazerPointer = nil + troop.lazingUnit = nil +end + +function cfxGroundTroops.trackLazer(troop) + -- the only thing that must be set when entering here is + -- lazeTarget. We set up the rest + if not troop.lazingUnit then + troop.lazingUnit = troop.group:getUnit(1) -- get first unit + if troop.lazingUnit:getLife() < 1 then + trigger.action.outText("+++ LazingUnit is dead, getUnit works differently from what docs say, need to filter for lively units", 30) + end + end + + if not troop.lazerPointer then + local there = troop.lazeTarget:getPoint() + troop.lazerPointer = Spot.createLaser(troop.lazingUnit,{x = 0, y = 2, z = 0}, there, 1688) + troop.lazeTargetType = troop.lazeTarget:getTypeName() + trigger.action.outTextForCoalition(troop.side, troop.name .. " tally target - lasing " .. troop.lazeTargetType .. "!", 30) + trigger.action.outSoundForCoalition(troop.side, "UI_SCI-FI_Tone_Bright_Dry_20_stereo.wav") + troop.lastLazerSpot = there -- remember last spot + local data = {} + data.enemy = troop.lazeTarget + data.tracker = troop.lazingUnit + cfxGroundTroops.invokeCallbacksFor("lase:tracking", troop, data) + return + end + + -- if true then return end + + -- if we get here, we update the lazerPointer + local there = troop.lazeTarget:getPoint() + -- we may only want to update the laser spot when dist > trigger + troop.lazerPointer:setPoint(there) + -- we may want to report dist + troop.lastLazerSpot = there +end + +function cfxGroundTroops.updateLaze(troop) + -- check if we have a laze target. + -- check if lazing unit was killed, and therefore lost target + if troop.lazingUnit then + -- check that unit still alive + if troop.lazingUnit:isExist() and + troop.lazingUnit:getLife() >= 1 then + else + cfxGroundTroops.lazerOff(troop) + troop.lazeTarget = nil + trigger.action.outTextForCoalition(troop.side, troop.name .. " reports lasing " .. troop.lazeTargetType .. " interrupted. Re-acquiring.", 30) + trigger.action.outSoundForCoalition(troop.side, "UI_SCI-FI_Tone_Bright_Dry_20_stereo.wav") + troop.lazingUnit = nil + cfxGroundTroops.invokeCallbacksFor("lase:stop", troop) + return -- we'll re-acquire through a new unit next round + end + end + + -- if we get here, a lazing unit + --local here = troop.lazingUnit:getPoint() + + if troop.lazeTarget then + -- check if that target is alive and in range + if troop.lazeTarget:isExist() and troop.lazeTarget:getLife() >= 1 then + -- note: when we laze a target, we know that we have a lazing unit + local here = troop.lazingUnit:getPoint() + -- check if it has moved out of range + local there = troop.lazeTarget:getPoint() + if dcsCommon.dist(here, there) > troop.range then + -- troop out of range + trigger.action.outTextForCoalition(troop.side, troop.name .. " lost sight of lazed target " .. troop.lazeTargetType, 30) + trigger.action.outSoundForCoalition(troop.side, "UI_SCI-FI_Tone_Bright_Dry_20_stereo.wav") + troop.lazeTarget = nil + cfxGroundTroops.lazerOff(troop) + troop.lazingUnit = nil + cfxGroundTroops.invokeCallbacksFor("lase:stop", troop) + return + end + + -- if we get here, we need to update the target point + cfxGroundTroops.trackLazer(troop) + return + else + -- target died + trigger.action.outTextForCoalition(troop.side, troop.name .. " confirms kill for " .. troop.lazeTargetType, 30) + trigger.action.outSoundForCoalition(troop.side, "UI_SCI-FI_Tone_Bright_Dry_20_stereo.wav") + troop.lazeTarget = nil + cfxGroundTroops.lazerOff(troop) + troop.lazingUnit = nil + cfxGroundTroops.invokeCallbacksFor("lase:stop", troop) + return + end + end + + -- if we get here, we must look for a laze target + troop.lazeTarget = cfxGroundTroops.findLazeTarget(troop) + if troop.lazeTarget then + cfxGroundTroops.trackLazer(troop) -- will also set up lazing unit + end +end + + +function cfxGroundTroops.updateWait(troop) + -- currently nothing to do + +end + +function cfxGroundTroops.updateTroops(troop) + -- if orders start with "wait-" then the troops + -- simply do nothing + if dcsCommon.stringStartsWith(troop.orders, "wait-") then + -- the troops are waiting to be picked update + -- when they are dropped again, thre prefix to + -- their order is removed, and the 'real' orders + -- are revealed. For now, do nothing + cfxGroundTroops.updateWait(troop) + + elseif troop.orders == "guard" then + cfxGroundTroops.updateGuards(troop) + + elseif troop.orders == "attackOwnedZone" then + cfxGroundTroops.updateZoneAttackers(troop) + + elseif troop.orders == "laze" then + cfxGroundTroops.updateLaze(troop) + + elseif troop.orders == "attackZone" then + cfxGroundTroops.updateAttackers(troop) + + else + trigger.action.outText("+++ updated troops " .. troop.name .. " have unknown orders " .. troop.orders, 30) + end + +end + +-- +-- we have to systems to process during update: +-- once all, and one per turn, with the exception +-- of lazers, who get updated every turn +-- + +-- +-- all at once +-- +function cfxGroundTroops.update() + cfxGroundTroops.updateSchedule = timer.scheduleFunction(cfxGroundTroops.update, {}, timer.getTime() + 1/cfxGroundTroops.ups) + -- iterate all my troops and build next + -- versions pool + local liveTroops = {} + for idx, troop in pairs(cfxGroundTroops.deployedTroops) do + local group = troop.group + if not dcsCommon.isGroupAlive(group) then + -- group dead. remove from pool + -- this happens by not copying it into the poos + -- trigger.action.outText("+++ removing ground troops " .. troop.name, 30) + cfxGroundTroops.invokeCallbacksFor("dead", troop) -- notify anyone who is interested that we are no longer proccing these + else + -- work with this groop according to its orders + cfxGroundTroops.updateTroops(troop) +-- trigger.action.outText("+++ updated troops " .. troop.name, 30) + -- since group is alive remember it for next loop + --table.insert(liveTroops, troop) + liveTroops[idx] = troop -- do NOT use insert as we have indexed table + end + end + -- liveTroops holds all troops that are still alive and will + -- be revisited next loop + cfxGroundTroops.deployedTroops = liveTroops +end + +-- +-- UpdateQueued looks for the first unordered (.receivedOrders == false) group +-- and processes them. if orders are 'laze', it will always be ordered +-- + + +function cfxGroundTroops.updateQueued() + cfxGroundTroops.updateSchedule = timer.scheduleFunction(cfxGroundTroops.updateQueued, {}, timer.getTime() + 1/cfxGroundTroops.ups) + -- iterate all my troops and build next + -- versions pool + local liveTroops = {} + local hasOrdered = false -- so far, no orders have been given + for idx, troop in pairs(cfxGroundTroops.deployedTroops) do + local group = troop.group + if not dcsCommon.isGroupAlive(group) then + -- group dead. remove from pool + -- this happens by not copying it to liveTroops + -- trigger.action.outText("+++ removing ground troops " .. troop.name, 30) + cfxGroundTroops.invokeCallbacksFor("dead", troop) -- notify anyone who is interested that we are no longer proccing these + else + -- check if this is a lazer + if troop.orders == "laze" then + -- lazers are updated each turn + cfxGroundTroops.updateLaze(troop) + else + if not hasOrdered and not (troop.receivedOrders) then + -- work with this groop according to its orders + cfxGroundTroops.updateTroops(troop) + troop.receivedOrders = true -- this one has received orders + hasOrdered = true + end + end + liveTroops[idx] = troop -- do NOT use insert as we have indexed table + end + end + -- liveTroops holds all troops that are still alive and will + -- be revisited next loop + cfxGroundTroops.deployedTroops = liveTroops + + -- if no orders have been passed, clear all troop's .receivedOrders flag + -- and the loop starts anew next loop + if not hasOrdered then + for idx, troop in pairs(cfxGroundTroops.deployedTroops) do + troop.receivedOrders = nil + end + end +end + +-- +-- in updateCheckOnly we simply check the ground queue +-- if there are troops added that need scheduling (i.e. have +-- been passed in by addTroops and schedule them +-- +function cfxGroundTroops.updateCheckOnly() + -- re-schedule myself in 1 second + timer.scheduleFunction(cfxGroundTroops.updateCheckOnly, {}, timer.getTime() + 1) + + -- iterate through all troops, and + -- see if there are any that have not been scheduled + -- to schedule them for updates in 1 second + -- that will be the first time that they are scheduled, + -- all others will be self-scheduled + for idx, troop in pairs(cfxGroundTroops.deployedTroops) do + if not troop.hasBeenScheduled then + local params = {troop} + troop.hasBeenScheduled = true + troop.updateID = timer.scheduleFunction(cfxGroundTroops.updateSingleScheduled, params, timer.getTime() + 1) + --trigger.action.outText("+++groundT: scheduling troops <".. troop.group:getName() .."> with orders <" .. troop.orders .. ">", 30) + end + end + -- note that alive checks are now done during the scheduled + -- update, not every time for all + +end + +function cfxGroundTroops.updateSingleScheduled(params) + local troops = params[1] + troops.updateID = nil -- erase update id + if not troops then + trigger.action.outText("+++groundT WARNING: nil troop in updateSingle", 30) + return -- no further action required, no longer updates + end + + local group = troops.group + -- see if we have been taken out of the pool or updated + -- if so, exit + + if not group:isExist() then + -- simply never again look at it. + return + end + + if cfxGroundTroops.deployedTroops[troops.group:getName()] ~= troops then + -- trigger.action.outText("+++groundT NOTE: troops <".. troops.group:getName() .."> was removed from pool. Cancel Update", 30) + return -- no further reschedule + end + + -- see if scheduling is turned off + if not troops.reschedule then + trigger.action.outText("+++groundT NOTE: no longer updating <".. troops.group:getName() .."> per reschedule param", 30) + return + end + + -- now, check if still alive + if not dcsCommon.isGroupAlive(group) then + -- group dead, no longer updates + --trigger.action.outText("+++groundT NOTE: <".. troops.group:getName() .."> dead, removing", 30) + cfxGroundTroops.invokeCallbacksFor("dead", troops) -- notify anyone who is interested that we are no longer proccing these + cfxGroundTroops.removeTroopsFromPool(troops) + return -- nothing else to do + end + + -- now, execute the update itself, standard update + --trigger.action.outText("+++groundT: singleU troop <".. troops.group:getName() .."> with orders <" .. troops.orders .. ">", 30) + cfxGroundTroops.updateTroops(troops) + + -- check max speed of group. if < 0.1 then note and increase + -- speedWarning. if not, reset speed warning + if troops.orders == "attackOwnedZone" and dcsCommon.getGroupMaxSpeed(troops.group) < 0.1 then + if not troops.speedWarning then troops.speedWarning = 0 end + troops.speedWarning = troops.speedWarning + 1 + else + troops.speedWarning = 0 -- reset + end + + if troops.speedWarning > 5 then -- make me 5 + lastOrder = timer.getTime() - troops.lastOrderDate + --trigger.action.outText("+++groundT WARNING: <".. troops.group:getName() .."> (S:".. troops.side .. ") to " .. troops.destination.name .. ": stopped for " .. troops.speedWarning .. " iters, orderage=" .. lastOrder, 30) + -- this may be a matter of too many waypoints. + -- maybe issue orders to go to their destination directly? + -- now force an order to go directly. + if troops.speedWarning > 5 then + if troops.isOffroad then + -- we already switched to off-road. take me + -- out of the managed queue, I'm not going + -- anywhere + -- trigger.action.outText("+++groundT <".. troops.group:getName() .."> is going nowhere. Removed from managed troops", 30) + cfxGroundTroops.removeTroopsFromPool(troops) + else + cfxGroundTroops.switchToOffroad(troops) + -- trigger.action.outText("+++groundT <".. troops.group:getName() .."> SWITCHED TO OFFROAD", 30) + troops.isOffroad = true -- so we know that we already did that + end + end + end + + -- now reschedule updte for my best time + local updateTime = cfxGroundTroops.getScheduleInterval(troops.orders) + troops.updateID = timer.scheduleFunction(cfxGroundTroops.updateSingleScheduled, params, timer.getTime() + updateTime) +end + + +-- +-- PILEUP and TIE BRAKERS +-- +-- there may come a situation where troops gather in +-- one zone because the zone isn't won - some other troops +-- are there and noone moves. +-- a tie-break is required +-- + +-- checkpile up: every so often, we test if we have run into a +-- pileup-situation. this happens if there are more than n +-- units with group-attacker order in the same zone, and that +-- zone is their destination +-- this can be easily detected by the insideDestination flag +-- checkPileUp should be run every minute or so + +function cfxGroundTroops.checkPileUp() + -- schedule my next call + --trigger.action.outText("+++groundT: pileup check", 30) + timer.scheduleFunction(cfxGroundTroops.checkPileUp, {}, timer.getTime() + 60) + local thePiles = {} + if not cfxOwnedZones then + -- trigger.action.outText("+++groundT: pileUp - owned zones not yet ready", 30) + return + end + + -- create a list of all piles + for idx, oz in pairs(cfxOwnedZones.zones) do + local newPile = {} + newPile[1] = 0 -- no red inZone here + newPile[2] = 0 -- no blue inZone here + newPile.zone = oz -- the zone we are looking at + thePiles[oz] = newPile + end + + -- now iterate through all currently alive groups and + -- attribute them to their piles + for idx, troop in pairs(cfxGroundTroops.deployedTroops) do + -- get each group and count them if they are inside + -- their destination + if troop.insideDestination then + local side = troop.group:getCoalition() + local thePile = thePiles[troop.destination] + local theSide = troop.group:getCoalition() + thePile[theSide] = thePile[theSide] + 1 -- we count groups, not units + end + end + + -- a pileup happens, if there are more than 3 groups in destination zone + -- with NO other troops present (usually the case) + -- or when there are 5 groups more than the number for the other side + -- so now scan all piles + for idx, thePile in pairs(thePiles) do + -- check red pileup + if thePile[1] > 3 and thePile[2] == 0 then + -- simple pileup. 3 groups, no others except defenders and + -- perhaps transients + cfxGroundTroops.breakTie(thePile, 1) + elseif thePile[1] >= thePile[2] + 5 then + -- numerical pileup + cfxGroundTroops.breakTie(thePile, 1) + end + + -- check blue loside + if thePile[2] >= 3 and thePile[1] == 0 then + -- simple pileup. 3 groups, no others except defenders and + -- perhaps transients + cfxGroundTroops.breakTie(thePile, 2) + elseif thePile[2] >= thePile[1] + 5 then + -- numerical pileup + cfxGroundTroops.breakTie(thePile, 2) + end + end +end + +function cfxGroundTroops.breakTie(thePile, winner) + trigger.action.outText("+++ groundT: TIEBREAK - winner is " .. winner .. " in zone " .. thePile.zone.name .. ": " .. thePile[1] .. ":" .. thePile[2] , 30) + -- now add some code to do the actual tie breaking: remove all units that + -- are inside the zone and who belong to the other side + local loser = 1 -- red default + local theZone = thePile.zone + if winner == 1 then loser = 2 end + -- now get all ground groups for the losing side + local losingGround = coalition.getGroups(loser, Group.Category.GROUND) + for idx, theGroup in pairs(losingGround) do + -- if alive, check if inside the zone + if theGroup:isExist() and dcsCommon.isGroupAlive(theGroup) then + -- make sure it's not a transient + if not isDeployedGroundTroop(theGroup) then + local p = dcsCommon.getGroupLocation(theGroup) + if cfxZones.isPointInsideZone(p, theZone) then + trigger.action.outText("+++ groundT: TIEBREAK - destroying group " .. theGroup:getName() , 30) + -- we delete this group now + theGroup:destroy() + end + end + end + end +end + +-- +-- sanity checks for rescheduling +-- +function cfxGroundTroops.checkSchedules() + timer.scheduleFunction(cfxGroundTroops.checkSchedules, {}, timer.getTime() + 10) + for idx, troop in pairs(cfxGroundTroops.deployedTroops) do + -- check if troop is not scheduled + -- if this happens to a group more than a certain times, + -- it has somehow dropped out of the reschedule + -- plan and needs to be scheduled + if troop.updateID == nil then + troop.unscheduleCount = troop.unscheduleCount + 1 + if (troop.unscheduleCount > 1) then + trigger.action.outText("+++ groundT: unscheduled group " .. troop.group:getName() .. " cnt=" .. troop.unscheduleCount , 30) + end + end + end +end + +-- +-- REPORTING +-- +-- +-- get a report of troops as string +-- +function cfxGroundTroops.getTroopReport(theSide, ignoreInfantry) + if not ignoreInfantry then ignoreInfantry = false end + local report = "GROUND FORCES REPORT" + for idx, troop in pairs(cfxGroundTroops.deployedTroops) do + if troop.side == theSide then + local unitNum = troop.group:getSize() + report = report .. "\n" .. troop.name .. " (".. unitNum .."): <" .. troop.orders .. ">" + if troop.orders == "attackOwnedZone" then + if troop.destination then + report = report .. " move towards " .. troop.destination.name + else + report = report .. " (selecting destination)" + end + end + end + end + report = report .. "\n---END REPORT\n" + return report +end + + +-- +-- CREATE / ADD / REMOVE +-- + +-- +-- createGroundTroop +-- use this to create a cfxGroundTroops from a dcs group +-- +function cfxGroundTroops.createGroundTroops(inGroup, range, orders) + local newTroops = {} + if not orders then + orders = "guard" + --trigger.action.outText("+++ adding ground troops <".. inGroup:getName() ..">with default orders", 30) + else + --trigger.action.outText("+++ adding ground troops <".. inGroup:getName() ..">with orders " .. orders, 30) + end + if orders:lower() == "lase" then + orders = "laze" -- we use WRONG spelling here, cause we're cool + end + newTroops.insideDestination = false + newTroops.unscheduleCount = 0 -- will count up as we aren't scheduled + newTroops.speedWarning = 0 + newTroops.isOffroad = false -- if true, we switched to direct orders, not roads, after standstill + newTroops.group = inGroup + newTroops.orders = orders + newTroops.coalition = inGroup:getCoalition() + newTroops.side = newTroops.coalition -- because we'e been using both. + newTroops.name = inGroup:getName() + newTroops.signature = "cfx" -- to verify this is groundTroop group, not dcs groups + if not range then range = 300 end + newTroops.range = range + return newTroops +end + +function cfxGroundTroops.addGroundTroopsToPool(troops) -- troops MUST be a table that I understand, with + if not troops then return end + if troops.signature ~= "cfx" then + trigger.action.outText("+++ adding ground troops with unsupported troop signature", 30) + return + end + + troops.reschedule = true -- in case we use scheduled update + -- we now add to internal array. this is worked on by all + -- update meths, on scheduled upadtes, it is only used to + -- pick up, and do the initial schedule, after that they + -- all re-schedule themselves + troops.hasBeenScheduled = false -- so far, no updates + -- hasBeenScheduled is used by updateCheckOnly when scheduled + -- updates are used. + + -- now add to actively managed table or queue it if enabled + if cfxGroundTroops.maxManagedTroops > 0 and dcsCommon.getSizeOfTable(cfxGroundTroops.deployedTroops) >= cfxGroundTroops.maxManagedTroops then + -- we need to queue + table.insert(cfxGroundTroops.troopQueue, troops) + -- trigger.action.outText("enqued " .. troops.group:getName() .. " at pos ".. #cfxGroundTroops.troopQueue ..", manage cap surpassed.", 30) + else + -- add to deployed set + cfxGroundTroops.deployedTroops[troops.group:getName()] = troops + end +end + +function cfxGroundTroops.removeTroopsFromPool(troops) + if not troops then return end + if troops.signature ~= "cfx" then return end + + if cfxGroundTroops.deployedTroops[troops.group:getName()] then + local troop = cfxGroundTroops.deployedTroops[troops.group:getName()] + troops.reschedule = false -- so a reschedule wont update any more + cfxGroundTroops.deployedTroops[troops.group:getName()] = nil + return + end + + -- if we get here, we need to check if perhaps the troops + -- are in the queue + for i=1, #cfxGroundTroops.troopQueue do + if cfxGroundTroops.troopQueue[i] == troops then + table.remove(cfxGroundTroops.troopQueue, i) + return + end + end +end + +function isDeployedGroundTroop(aGroup) + if not aGroup then return false end + -- see if its already managed + if cfxGroundTroops.deployedTroops[aGroup:getName()] ~= nil then + return true + end + + -- see if it's in the queue + for i=1, #cfxGroundTroops.troopQueue do + if cfxGroundTroops.troopQueue[i] == troops then + return true + end + end + -- if we get here, it's neither managed nor queued + return false +-- return cfxGroundTroops.deployedTroops[aGroup:getName()] ~= nil +end + +function cfxGroundTroops.getGroundTroopsForGroup(aGroup) + if not (cfxGroundTroops.deployedTroops[aGroup:getName()]) then + -- see if it's queued + for i=1, #cfxGroundTroops.troopQueue do + local troops = cfxGroundTroops.troopQueue[i] + if troops.group == aGroup then + return troops + end + end + + if cfxGroundTroops.verbose then + trigger.action.outText("+++gndT - WARNING: cannot find group " .. aGroup:getName() .. " for troop retrieval. Known troops are:", 30) + end + for k,v in pairs(cfxGroundTroops.deployedTroops) do + trigger.action.outText("+++ ".. k .. ": has v: " .. v.name, 30) + end + return nil + end + + return cfxGroundTroops.deployedTroops[aGroup:getName()] +end + +function cfxGroundTroops.monitorQueues() + timer.scheduleFunction(cfxGroundTroops.monitorQueues, {}, timer.getTime() + 5) + + -- calculate the numbers + local num = dcsCommon.getSizeOfTable(cfxGroundTroops.deployedTroops) + + local msg = "+++ gndT - Groups Managed: <" .. num .. ">" + -- display the numbers + if cfxGroundTroops.maxManagedTroops > 0 then + msg = msg .. " capped at " .. cfxGroundTroops.maxManagedTroops .. ", q size is <" .. #cfxGroundTroops.troopQueue .. ">" + end + trigger.action.outText(msg, 30) +end + + +-- manageQueue: if depth of deployedTroops is below max and we have +-- items in queue, pop off first one and put in managed table +-- checked once every 2 seconds +function cfxGroundTroops.manageQueues() + timer.scheduleFunction(cfxGroundTroops.manageQueues, {}, timer.getTime() + 2) + if cfxGroundTroops.maxManagedTroops < 1 then return end + + -- if we get here, we have a limit on managed + -- items + if #cfxGroundTroops.troopQueue < 1 then return end + + -- if we here, there are items waiting in the queue + while dcsCommon.getSizeOfTable(cfxGroundTroops.deployedTroops) < cfxGroundTroops.maxManagedTroops and #cfxGroundTroops.troopQueue > 0 do + -- trnasfer items from the front to the managed queue + local theTroops = cfxGroundTroops.troopQueue[1] + table.remove(cfxGroundTroops.troopQueue, 1) + cfxGroundTroops.deployedTroops[theTroops.group:getName()] = theTroops + -- trigger.action.outText("+++gT: dequed and activaed " .. theTroops.group:getName(), 30) + end +end + + +function cfxGroundTroops.start() + if not dcsCommon.libCheck("cfx Ground Troops", + cfxGroundTroops.requiredLibs) + then + trigger.action.outText("cf/x Ground Troops aborted: missing libraries", 30) + return false + end + + -- read optional config zone + cfxGroundTroops.readConfigZone() + + if cfxGroundTroops.scheduledUpdates then + cfxGroundTroops.queuedUpdates = false + cfxGroundTroops.updateCheckOnly() + cfxGroundTroops.checkSchedules() -- check regularly if all troops have been updated by checking their ID + elseif cfxGroundTroops.queuedUpdates then + cfxGroundTroops.updateQueued() + else + cfxGroundTroops.update() + end + -- now install a regular pileup check + timer.scheduleFunction(cfxGroundTroops.checkPileUp, {}, timer.getTime() + 60) + + if cfxGroundTroops.monitorNumbers then + timer.scheduleFunction(cfxGroundTroops.monitorQueues, {}, timer.getTime() + 5) + end + + if cfxGroundTroops.maxManagedTroops > 0 then + timer.scheduleFunction(cfxGroundTroops.manageQueues, {}, timer.getTime() + 1) + end + + trigger.action.outText("cf/x Ground Troops v" .. cfxGroundTroops.version .. " started", 30) + + if not cfxOwnedZones then + --trigger.action.outText("+++groundT: pileUp - owned zones not yet ready", 30) + end + return true +end + +if not cfxGroundTroops.start() then + cfxGroundTroops = nil + trigger.action.outText("cfxGroundTroops aborted load", 30) +end + +--[[-- + TO DO + + - implement 'patrol' orders!!! + + when ordering a new route, issue a command to stop in 1 second + and another with new marching orders in 5 seconds + look at setTask() and resetTask() for controller + - change group logic to set itself up to 'requestOrders' with group as parameter, so they can decide themselves how quickly they want to be re-tasked + + - DONE enqueue and dequeue methods with capped ground troops size + - named locs have strategic values attached (default = 1), and distance is divided by strat value to get at priority when rerouting + + - difficulty increase: make enemy troops better by raining their spawned level + + - check out simple slot block SSB (pre-moose) to see if we can implement slot blocking for downed pilots + + - new 'wanda' (wander) module to make airports more lively: zone, have individuals/single vehicle wander around. two waypoints (start and stop), that are zones, and whenever they reach one or are at speed 0, they get a new one. may have pause before they go to next. + variant on above: selection of zones that are somehow connected, and destinations are made between these for patrolling zone. can force order, loop, and ping-pong. +--]]-- \ No newline at end of file diff --git a/modules/cfxGroups.lua b/modules/cfxGroups.lua new file mode 100644 index 0000000..8ba0c15 --- /dev/null +++ b/modules/cfxGroups.lua @@ -0,0 +1,158 @@ +cfxGroups = {} +cfxGroups.version = "1.1.0" +--[[-- + +Module to read Unit data from DCS and make it available to scripts +DOES NOT KEEP TRACK OF MISSION-CREATED GROUPS!!!! +Main use is to access player groups for slot blocking etc since these +groups can't be allocated dynamically + +Version history + + 1.0.0 - initial version + 1.1.0 - for each player unit, store point(x, 0, y), and action for first WP, as well as name + +--]]-- + +cfxGroups.groups = {} -- all groups, indexed by name + +--[[-- group objects are + { + name= "", + coalition = "" (red, blue, neutral), + coanum = # (0, 1, 2 for neutral, red, blue) + category = "" (helicopter, ship, plane, vehicle, static), + hasPlayer = true/false, + playerUnits = {} (for each player unit in group: name, point, action) + + } + +--]]-- + +function cfxGroups.fetchAllGroupsFromDCS() + -- a mission is a lua table that is loaded by executing the miz. it builds + -- the environment mission table, accessible as env.mission + -- iterate the "coalition" table of the mission (note: NOT coalitionS) + -- inspired by mist, GIANT tip o'the hat to Grimes! + + for coa_name_miz, coa_data in pairs(env.mission.coalition) do -- iterate all coalitions + local coa_name = coa_name_miz + if string.lower(coa_name_miz) == 'neutrals' then -- convert "neutrals" to "neutral", singular + coa_name = 'neutral' + end + -- directly convert coalition into number for easier access later + local coaNum = 0 + if coa_name == "red" then coaNum = 1 end + if coa_name == "blue" then coaNum = 2 end + + if type(coa_data) == 'table' then + if coa_data.country then -- make sure there a country table for this coalition + for cntry_id, cntry_data in pairs(coa_data.country) do -- iterate all countries for this + local countryName = string.lower(cntry_data.name) + if type(cntry_data) == 'table' then --just making sure + for obj_type_name, obj_type_data in pairs(cntry_data) do + if obj_type_name == "helicopter" or obj_type_name == "ship" or obj_type_name == "plane" or obj_type_name == "vehicle" or obj_type_name == "static" then --should be an unncessary check + local category = obj_type_name + if ((type(obj_type_data) == 'table') and obj_type_data.group and (type(obj_type_data.group) == 'table') and (#obj_type_data.group > 0)) then --there's a group! + + for group_num, group_data in pairs(obj_type_data.group) do + if group_data and group_data.units and type(group_data.units) == 'table' then --making sure again- this is a valid group + local groupName = group_data.name + if env.mission.version > 7 then -- translate raw to actual + groupName = env.getValueDictByKey(groupName) + end + local hasPlayer = false + local playerUnits = {} + for unit_num, unit_data in pairs(group_data.units) do -- iterate units + -- see if there is at least one player in group + if unit_data.skill then + if unit_data.skill == "Client" or unit_data.skill == "Player" then + -- this is player unit. save it, remember + hasPlayer = true + local playerData = {} + playerData.name = unit_data.name + playerData.point = {} + playerData.point.x = unit_data.x + playerData.point.y = 0 + playerData.point.z = unit_data.y + playerData.action = "none" -- default + + -- access initial waypoint data by 'reaching up' + -- into group data and extract route.points[1] + if group_data.route and group_data.route.points and (#group_data.route.points > 0) then + playerData.action = group_data.route.points[1].action + end + table.insert(playerUnits, playerData) + end + end + end --for all units in group + + local entry = {} + entry.name = groupName + entry.coalition = coa_name + entry.coaNum = coaNum + entry.category = category + entry.hasPlayer = hasPlayer + entry.playerUnits = playerUnits + -- add to db + cfxGroups.groups[groupName] = entry + + end --if has group_data and group_data.units then + end --for all groups in category + end --if has category data + end --if plane, helo etc... category + end --for all objects in country + end --if has country data + end --for all countries in coalition + end --if coalition has country table + end -- if there is coalition data + end --for all coalitions in mission +end + +-- simply dump all groups to the screen +function cfxGroups.showAllGroups() + for gName, gData in pairs (cfxGroups.groups) do + local isP = "(NPC)" + if gData.hasPlayer then isP = "*PLAYER GROUP (".. #gData.playerUnits ..")*" end + trigger.action.outText(gData.name.. ": " .. isP .. " - " .. gData.category .. ", F:" .. gData.coalition + .. " (" .. gData.coaNum .. ")", 30) + end +end + +-- return all cfxGroups that can have players in them +-- includes groups that currently are not or not anymore alive +function cfxGroups.getPlayerGroup() + local playerGroups = {} + for gName, gData in pairs (cfxGroups.groups) do + if gData.hasPlayer then + table.insert(playerGroups, gData) + end + end + return playerGroups +end + +-- return all group names that can have players in them +-- includes groups that currently are not or not anymore alive +function cfxGroups.getPlayerGroupNames() + local playerGroups = {} + for gName, gData in pairs (cfxGroups.groups) do + if gData.hasPlayer then + table.insert(playerGroups, gName) + end + end + return playerGroups +end + + +function cfxGroups.start() + cfxGroups.fetchAllGroupsFromDCS() -- read all groups from mission. +-- cfxGroups.showAllGroups() + + trigger.action.outText("cfxGroups version " .. cfxGroups.version .. " started", 30) + return true + +end + +cfxGroups.start() + + \ No newline at end of file diff --git a/modules/cfxHeloTroops.lua b/modules/cfxHeloTroops.lua new file mode 100644 index 0000000..b6ce258 --- /dev/null +++ b/modules/cfxHeloTroops.lua @@ -0,0 +1,826 @@ +cfxHeloTroops = {} +cfxHeloTroops.version = "2.1.0" +cfxHeloTroops.verbose = false +cfxHeloTroops.autoDrop = true +cfxHeloTroops.autoPickup = false +cfxHeloTroops.pickupRange = 100 -- meters +-- +-- +-- VERSION HISTORY +-- 1.1.3 -- repaired forgetting 'wait-' when loading/disembarking +-- 1.1.4 -- corrected coalition bug in deployTroopsFromHelicopter +-- 2.0.0 -- added weight change when troops enter and leave the helicopter + -- idividual troop capa max per helicopter +-- 2.0.1 -- lib loader verification +-- -- uses dcsCommon.isTroopCarrier(theUnit) +-- 2.0.2 -- can now deploy from spawners with "requestable" attribute +-- 2.1.0 -- supports config zones +-- -- check spawner legality by types +-- -- updated types to include 2.7.6 additions to infantry +-- -- updated types to include stinger/manpads + +-- +-- cfxHeloTroops -- a module to pick up and drop infantry. Can be used with any helo, +-- might be used to configure to only certain +-- currently only supports a single helicopter per group +-- only helicopters that can transport troops will have this feature +-- Copyright (c) 2021, 2022 by Christian Franz and cf/x AG +-- + + +cfxHeloTroops.requiredLibs = { + "dcsCommon", -- common is of course needed for everything + -- pretty stupid to check for this since we + -- need common to invoke the check, but anyway + "cfxZones", -- Zones, of course + "cfxPlayer", -- player events + "cfxCommander", -- to make troops do stuff + "cfxGroundTroops", -- generic when dropping troops +} + +cfxHeloTroops.unitConfigs = {} -- all configs are stored by unit's name +cfxHeloTroops.myEvents = {3, 4, 5} -- 3- takeoff, 4 - land, 5 - crash +cfxHeloTroops.legalTroops = {"Soldier AK", "Infantry AK", "Infantry AK ver2", "Infantry AK ver3", "Infantry AK Ins", "Soldier M249", "Soldier M4 GRG", "Soldier M4", "Soldier RPG", "Paratrooper AKS-74", "Paratrooper RPG-16", "Stinger comm dsr", "Stinger comm", "Soldier stinger", "SA-18 Igla-S comm", "SA-18 Igla-S manpad", "Igla manpad INS", "SA-18 Igla comm", "SA-18 Igla manpad",} + +cfxHeloTroops.troopWeight = 100 -- kg average weight per trooper + +function cfxHeloTroops.resetConfig(conf) + conf.autoDrop = cfxHeloTroops.autoDrop --if true, will drop troops on-board upon touchdown + conf.autoPickup = cfxHeloTroops.autoPickup -- if true will load nearest troops upon touchdown + conf.pickupRange = cfxHeloTroops.pickupRange --meters, maybe make per helo? + -- maybe set up max seats by type + conf.currentState = -1 -- 0 = landed, 1 = airborne, -1 undetermined + conf.troopsOnBoardNum = 0 -- if not 0, we have troops and can spawnm/drop + conf.troopCapacity = 8 -- should be depending on airframe + conf.troopsOnBoard = {} -- table with the following + + + conf.troopsOnBoard.name = "***reset***" + conf.dropFormation = "circle_out" -- may be chosen later? +end + +function cfxHeloTroops.createDefaultConfig(theUnit) + local conf = {} + cfxHeloTroops.resetConfig(conf) + + conf.myMainMenu = nil -- this is where the main menu for group will be stored + conf.myCommands = nil -- this is where we put all teh commands in + return conf +end + + + +function cfxHeloTroops.getUnitConfig(theUnit) -- will create new config if not existing + if not theUnit then + trigger.action.outText("+++WARNING: nil unit in get config!", 30) + return nil + end + local c = cfxHeloTroops.unitConfigs[theUnit:getName()] + if not c then + c = cfxHeloTroops.createDefaultConfig(theUnit) + cfxHeloTroops.unitConfigs[theUnit:getName()] = c + end + return c +end + +function cfxHeloTroops.getConfigForUnitNamed(aName) + return cfxHeloTroops.unitConfigs[aName] +end + +function cfxHeloTroops.removeConfigForUnitNamed(aName) + if cfxHeloTroops.unitConfigs[aName] then cfxHeloTroops.unitConfigs[aName] = nil end +end + +function cfxHeloTroops.setState(theUnit, isLanded) + -- called to set the current state of the helicopter (group) + -- currently one helicopter per group max +end + + + +-- +-- E V E N T H A N D L I N G +-- +function cfxHeloTroops.isInteresting(eventID) + -- return true if we are interested in this event, false else + for key, evType in pairs(cfxHeloTroops.myEvents) do + if evType == eventID then return true end + end + return false +end + +function cfxHeloTroops.preProcessor(event) + -- make sure it has an initiator + if not event.initiator then return false end -- no initiator + local theUnit = event.initiator + if not cfxPlayer.isPlayerUnit(theUnit) then return false end -- not a player unit + local cat = theUnit:getCategory() + if cat ~= Group.Category.HELICOPTER then return false end + + return cfxHeloTroops.isInteresting(event.id) +end + +function cfxHeloTroops.postProcessor(event) + -- don't do anything +end + +function cfxHeloTroops.somethingHappened(event) + -- when this is invoked, the preprocessor guarantees that + -- it's an interesting event + -- unit is valid and player + -- airframe category is helicopter + + local theUnit = event.initiator + local ID = event.id + + + local myType = theUnit:getTypeName() + + if ID == 4 then + cfxHeloTroops.heloLanded(theUnit) + end + + if ID == 3 then + cfxHeloTroops.heloDeparted(theUnit) + end + + if ID == 5 then + cfxHeloTroops.heloCrashed(theUnit) + end + + cfxHeloTroops.setCommsMenu(theUnit) +end + +-- +-- +-- LANDED +-- +-- +function cfxHeloTroops.loadClosestGroup(conf) + local p = conf.unit:getPosition().p + local cat = Group.Category.GROUND + local unitsToLoad = dcsCommon.getLivingGroupsAndDistInRangeToPoint(p, conf.pickupRange, conf.unit:getCoalition(), cat) + + -- now, the groups may contain units that are not for transport. + -- later we can filter this by weight, or other cool stuff + -- for now we simply only troopy with legal type strings + unitsToLoad = cfxHeloTroops.filterTroopsByType(unitsToLoad) + + -- now limit the options to the five closest legal groups + local numUnits = #unitsToLoad + if numUnits < 1 then return false end -- on false will drop through + + local aTeam = unitsToLoad[1] -- get first (closest) entry + local dist = aTeam.dist + local group = aTeam.group + cfxHeloTroops.doLoadGroup({conf, group}) + return true -- will have loaded and reset menu +end + +function cfxHeloTroops.heloLanded(theUnit) + -- when we have landed, + if not dcsCommon.isTroopCarrier(theUnit) then return end + + local conf = cfxHeloTroops.getUnitConfig(theUnit) + conf.unit = theUnit + conf.currentState = 0 + + -- we look if we auto-unload + if conf.autoDrop then + if conf.troopsOnBoardNum > 0 then + cfxHeloTroops.doDeployTroops({conf, "autodrop"}) + -- already called set menu, can exit directly + return + end + -- when we get here, we have no troops to drop on board + -- so nothing to do really except look if we can pick up troops + -- set menu will do that for us + end + + if conf.autoPickup then + if conf.troopsOnBoardNum < 1 then + -- load the closest group + if cfxHeloTroops.loadClosestGroup(conf) then + return + end + end + end + + -- when we get here, we simply set the newest menus and are done + -- reset menu + cfxHeloTroops.removeComms(conf.unit) + cfxHeloTroops.setCommsMenu(conf.unit) +end + + +-- +-- +-- Helo took off +-- +-- + +function cfxHeloTroops.heloDeparted(theUnit) + if not dcsCommon.isTroopCarrier(theUnit) then return end + + -- when we take off, all that needs to be done is to change the state + -- to airborne, and then set the status flag + local conf = cfxHeloTroops.getUnitConfig(theUnit) + conf.currentState = 1 -- in the air + + cfxHeloTroops.removeComms(conf.unit) + cfxHeloTroops.setCommsMenu(conf.unit) + +end + +-- +-- +-- Helo Crashed +-- +-- +function cfxHeloTroops.heloCrashed(theUnit) + if not dcsCommon.isTroopCarrier(theUnit) then return end + + -- clean up + local conf = cfxHeloTroops.getUnitConfig(theUnit) + conf.unit = theUnit + conf.troopsOnBoardNum = 0 -- all dead + conf.currentState = -1 -- (we don't know) + -- conf.troopsOnBoardTypes = "" -- no troops, remember? + conf.troopsOnBoard = {} + cfxHeloTroops.removeComms(conf.unit) +end + +-- +-- +-- M E N U H A N D L I N G & R E S P O N S E +-- +-- +function cfxHeloTroops.clearCommsSubmenus(conf) + if conf.myCommands then + for i=1, #conf.myCommands do + missionCommands.removeItemForGroup(conf.id, conf.myCommands[i]) + end + end + conf.myCommands = {} +end + +function cfxHeloTroops.removeCommsFromConfig(conf) + cfxHeloTroops.clearCommsSubmenus(conf) + + if conf.myMainMenu then + missionCommands.removeItemForGroup(conf.id, conf.myMainMenu) + conf.myMainMenu = nil + end +end + +function cfxHeloTroops.removeComms(theUnit) + if not theUnit then return end + if not theUnit:isExist() then return end + + local group = theUnit:getGroup() + local id = group:getID() + local conf = cfxHeloTroops.getUnitConfig(theUnit) + conf.id = id + conf.unit = theUnit + + + cfxHeloTroops.removeCommsFromConfig(conf) +end + +function cfxHeloTroops.addConfigMenu(conf) + -- we add the a menu showing current state + -- and the option to change fro auto drop + -- and auto pickup + local onOff = "OFF" + if conf.autoDrop then onOff = "ON" end + local theCommand = missionCommands.addCommandForGroup( + conf.id, + 'Auto-Drop: ' .. onOff .. ' - Select to change', + conf.myMainMenu, + cfxHeloTroops.redirectToggleConfig, + {conf, "drop"} + ) + table.insert(conf.myCommands, theCommand) + onOff = "OFF" + if conf.autoPickup then onOff = "ON" end + theCommand = missionCommands.addCommandForGroup( + conf.id, + 'Auto-Pickup: ' .. onOff .. ' - Select to change', + conf.myMainMenu, + cfxHeloTroops.redirectToggleConfig, + {conf, "pickup"} + ) + table.insert(conf.myCommands, theCommand) +end + +function cfxHeloTroops.setCommsMenu(theUnit) + -- depending on own load state, we set the command structure + -- it begins at 10-other, and has 'Assault Troops' as main menu with submenus + -- as required + if not theUnit then return end + if not theUnit:isExist() then return end + + -- we only add this menu to troop carriers + if not dcsCommon.isTroopCarrier(theUnit) then return end + + local group = theUnit:getGroup() + local id = group:getID() + local conf = cfxHeloTroops.getUnitConfig(theUnit) --cfxHeloTroops.unitConfigs[theUnit:getName()] + conf.id = id; -- we do this ALWAYS to it is current even after a crash + conf.unit = theUnit -- link back + + --local conf = cfxHeloTroops.getUnitConfig(theUnit) + -- ok, first, if we don't have an F-10 menu, create one + if not (conf.myMainMenu) then + conf.myMainMenu = missionCommands.addSubMenuForGroup(id, 'Airlift Troops') + end + + -- clear out existing commands + cfxHeloTroops.clearCommsSubmenus(conf) + + -- now we have a menu without submenus. + -- add our own submenus + cfxHeloTroops.addConfigMenu(conf) + + -- now see if we are on the ground or in the air + -- or unknown + if conf.currentState < 0 then + conf.currentState = 0 -- landed + if theUnit:inAir() then + conf.currentState = 1 + end + end + + if conf.currentState == 0 then + cfxHeloTroops.addGroundMenu(conf) + else + cfxHeloTroops.addAirborneMenu(conf) + end + +end + +function cfxHeloTroops.addAirborneMenu(conf) + -- while we are airborne, there isn't much to do except add a status menu that does nothing + -- but we can add some instructions + -- let's begin by assuming no troops aboard + local commandTxt = "(To load troops, land in proximity to them)" + if conf.troopsOnBoardNum > 0 then + commandTxt = "(You are carrying " .. conf.troopsOnBoardNum .. " Assault Troops. Land to deploy them" + end + local theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + conf.myMainMenu, + cfxHeloTroops.redirectNoAction, + {conf, "none"} + ) + table.insert(conf.myCommands, theCommand) +end + +function cfxHeloTroops.redirectNoAction(args) + -- actually, we do not redirect since there is nothing to do +end + +function cfxHeloTroops.addGroundMenu(conf) + -- this is the most complex menu. Player can deploy troops when loaded + -- and load troops when they are in proximity + + -- case 1: troops aboard + if conf.troopsOnBoardNum > 0 then + local theCommand = missionCommands.addCommandForGroup( + conf.id, + "Deploy Team <" .. conf.troopsOnBoard.name .. ">", + conf.myMainMenu, + cfxHeloTroops.redirectDeployTroops, + {conf, "deploy"} + ) + table.insert(conf.myCommands, theCommand) + return + end + + -- case 2A: no troops aboard, and spawners in range + -- that are requestable + local p = conf.unit:getPosition().p + local mySide = conf.unit:getCoalition() + + if cfxSpawnZones then + -- only if SpawnZones is implemented + local availableSpawnersRaw = cfxSpawnZones.getRequestableSpawnersInRange(p, 500, mySide) + -- DONE: requestable spawners must check for troop compatibility + local availableSpawners = {} + for idx, aSpawner in pairs(availableSpawnersRaw) do + -- filter all spawners that spawn "illegal" troops + local theTypes = aSpawner.types + local typeArray = dcsCommon.splitString(theTypes, ',') + typeArray = dcsCommon.trimArray(typeArray) + local allLegal = true + for idy, aType in pairs(typeArray) do + if not dcsCommon.arrayContainsString(cfxHeloTroops.legalTroops, aType) then + allLegal = false + end + end + if allLegal then + table.insert(availableSpawners, aSpawner) + end + end + + local numSpawners = #availableSpawners + if numSpawners > 5 then numSpawners = 5 end + while numSpawners > 0 do + -- for each spawner in range, create a + -- spawn menu item + local spawner = availableSpawners[numSpawners] + local theName = spawner.baseName + local comm = "Request <" .. theName .. "> troops for transport" -- .. math.floor(aTeam.dist) .. "m away" + local theCommand = missionCommands.addCommandForGroup( + conf.id, + comm, + conf.myMainMenu, + cfxHeloTroops.redirectSpawnGroup, + {conf, spawner} + ) + table.insert(conf.myCommands, theCommand) + numSpawners = numSpawners - 1 + end + + end + + -- case 2B: no troops aboard. see if there are troops around + -- that we can load up + + local cat = Group.Category.GROUND + local unitsToLoad = dcsCommon.getLivingGroupsAndDistInRangeToPoint(p, conf.pickupRange, conf.unit:getCoalition(), cat) + + -- now, the groups may contain units that are not for transport. + -- later we can filter this by weight, or other cool stuff + -- for now we simply only troopy with legal type strings + -- TODO: add weight filtering + unitsToLoad = cfxHeloTroops.filterTroopsByType(unitsToLoad) + + -- now limit the options to the five closest legal groups + local numUnits = #unitsToLoad + if numUnits > 5 then numUnits = 5 end + + if numUnits < 1 then + local theCommand = missionCommands.addCommandForGroup( + conf.id, + "(No units in range)", + conf.myMainMenu, + cfxHeloTroops.redirectNoAction, + {conf, "none"} + ) + table.insert(conf.myCommands, theCommand) + return + end + + -- add an entry for each group in units to load + for i=1, numUnits do + local aTeam = unitsToLoad[i] + local dist = aTeam.dist + local group = aTeam.group + local tNum = group:getSize() + local comm = "Load <" .. group:getName() .. "> " .. tNum .. " Members" -- .. math.floor(aTeam.dist) .. "m away" + local theCommand = missionCommands.addCommandForGroup( + conf.id, + comm, + conf.myMainMenu, + cfxHeloTroops.redirectLoadGroup, + {conf, group} + ) + table.insert(conf.myCommands, theCommand) + end + + +end + +function cfxHeloTroops.filterTroopsByType(unitsToLoad) + local filteredGroups = {} + for idx, aTeam in pairs(unitsToLoad) do + local group = aTeam.group + local theTypes = dcsCommon.getGroupTypeString(group) + + local aT = dcsCommon.splitString(theTypes, ",") + local pass = true + for iT, sT in pairs(aT) do + -- check if this is a valid type + if not dcsCommon.arrayContainsString(cfxHeloTroops.legalTroops, sT) then + pass = false + break + end + end + if pass then + table.insert(filteredGroups, aTeam) + end + end + return filteredGroups +end +-- +-- T O G G L E S +-- + +function cfxHeloTroops.redirectToggleConfig(args) + timer.scheduleFunction(cfxHeloTroops.doToggleConfig, args, timer.getTime() + 0.1) +end + +function cfxHeloTroops.doToggleConfig(args) + local conf = args[1] + local what = args[2] + if what == "drop" then + conf.autoDrop = not conf.autoDrop + if conf.autoDrop then + trigger.action.outTextForGroup(conf.id, "Now deploying troops immediately after landing", 30) + else + trigger.action.outTextForGroup(conf.id, "Troops will now only deploy when told to", 30) + end + else + conf.autoPickup = not conf.autoPickup + if conf.autoPickup then + trigger.action.outTextForGroup(conf.id, "Nearest troops will now automatically board after landing", 30) + else + trigger.action.outTextForGroup(conf.id, "Troops will now board only after being ordered to do so", 30) + end + + end + + cfxHeloTroops.setCommsMenu(conf.unit) +end + + + +-- +-- Deploying Troops +-- + +function cfxHeloTroops.redirectDeployTroops(args) + timer.scheduleFunction(cfxHeloTroops.doDeployTroops, args, timer.getTime() + 0.1) +end + +function cfxHeloTroops.doDeployTroops(args) + local conf = args[1] + local what = args[2] + -- deploy the troops I have on board in formation + cfxHeloTroops.deployTroopsFromHelicopter(conf) + + -- set own troops to 0 and erase type string + conf.troopsOnBoardNum = 0 + conf.troopsOnBoard = {} +-- conf.troopsOnBoardTypes = "" + conf.troopsOnBoard.name = "***wasdeployed***" + + -- reset menu + cfxHeloTroops.removeComms(conf.unit) + cfxHeloTroops.setCommsMenu(conf.unit) +end + + +function cfxHeloTroops.deployTroopsFromHelicopter(conf) +-- we have troops, drop them now + local unitTypes = {} -- build type names + local theUnit = conf.unit + local p = theUnit:getPoint() + + --for i=1, scenario.troopSize[theUnit:getName()] do + -- table.insert(unitTypes, "Soldier M4") + --end + + -- split the conf.troopsOnBoardTypes into an array of types + unitTypes = dcsCommon.splitString(conf.troopsOnBoard.types, ",") + if #unitTypes < 1 then + table.insert(unitTypes, "Soldier M4") -- make it one m4 trooper as fallback + end + + local range = conf.troopsOnBoard.range + local orders = conf.troopsOnBoard.orders + if not orders then orders = "guard" end + + -- order processing: if the orders were pre-pended with "wait-" + -- we now remove that, so after dropping they do what their + -- orders where AFTER being picked up + if dcsCommon.stringStartsWith(orders, "wait-") then + orders = dcsCommon.removePrefix(orders, "wait-") + trigger.action.outTextForGroup(conf.id, "+++ <" .. conf.troopsOnBoard.name .. "> revoke 'wait' orders, proceed with <".. orders .. ">", 30) + end + + local chopperZone = cfxZones.createSimpleZone("choppa", p, 12) -- 12 m ratius around choppa + --local theCoalition = theUnit:getCountry() -- make it choppers country + local theCoalition = theUnit:getGroup():getCoalition() -- make it choppers COALITION + local theGroup = cfxZones.createGroundUnitsInZoneForCoalition ( + theCoalition, + conf.troopsOnBoard.name, -- dcsCommon.uuid("Assault"), -- maybe use config name as loaded from the group + chopperZone, + unitTypes, + conf.dropFormation, + 90) + local troop = cfxGroundTroops.createGroundTroops(theGroup, range, orders) -- use default range and orders + -- instead of scheduling tasking in one second, we add to + -- ground troops pool, and the troop pool manager will assign some enemies + cfxGroundTroops.addGroundTroopsToPool(troop) + trigger.action.outTextForGroup(conf.id, "<" .. theGroup:getName() .. "> have deployed to the ground with orders " .. orders .. "!", 30) + +end + + +-- +-- Loading Troops +-- +function cfxHeloTroops.redirectLoadGroup(args) + timer.scheduleFunction(cfxHeloTroops.doLoadGroup, args, timer.getTime() + 0.1) +end + +function cfxHeloTroops.doLoadGroup(args) + local conf = args[1] + local group = args[2] + conf.troopsOnBoard = {} + -- all we need to do is disassemble the group into type + conf.troopsOnBoard.types = dcsCommon.getGroupTypeString(group) + -- get the size + conf.troopsOnBoardNum = group:getSize() + -- and name + conf.troopsOnBoard.name = group:getName() + -- and put it all into the helicopter config + + -- now we need to destroy the group. First, remove it from the pool + local pooledGroup = cfxGroundTroops.getGroundTroopsForGroup(group) + if pooledGroup then + -- copy some important info from the troops + -- if they are set + conf.troopsOnBoard.orders = pooledGroup.orders + conf.troopsOnBoard.range = pooledGroup.range + + cfxGroundTroops.removeTroopsFromPool(pooledGroup) + trigger.action.outTextForGroup(conf.id, "Team '".. conf.troopsOnBoard.name .."' loaded and has orders <" .. conf.troopsOnBoard.orders .. ">", 30) + else + --trigger.action.outTextForGroup(conf.id, "Team '".. conf.troopsOnBoard.name .."' loaded!", 30) + if cfxHeloTroops.verbose then + trigger.action.outText("+++heloT: ".. conf.troopsOnBoard.name .." was not committed to ground troops", 30) + end + end + + -- now simply destroy the group + -- we'll re-assemble it when we deploy it + -- we currently can't change the weight of the helicopter + -- TODO: add weight changing code + -- TODO: ensure compatibility with CSAR module + group:destroy() + + -- say so + trigger.action.outTextForGroup(conf.id, "Team '".. conf.troopsOnBoard.name .."' aboard, ready to go!", 30) + -- reset menu + cfxHeloTroops.removeComms(conf.unit) + cfxHeloTroops.setCommsMenu(conf.unit) +end + +-- +-- spawning troops +-- +function cfxHeloTroops.redirectSpawnGroup(args) + timer.scheduleFunction(cfxHeloTroops.doSpawnGroup, args, timer.getTime() + 0.1) +end + +function cfxHeloTroops.delayedCommsResetForUnit(args) + local theUnit = args[1] + cfxHeloTroops.removeComms(theUnit) + cfxHeloTroops.setCommsMenu(theUnit) +end + +function cfxHeloTroops.doSpawnGroup(args) + local conf = args[1] + local theSpawner = args[2] + + -- make sure cooldown on spawner has timed out, else + -- notify that you have to wait + local now = timer.getTime() + if now < (theSpawner.lastSpawnTimeStamp + theSpawner.cooldown) then + local delta = math.floor(theSpawner.lastSpawnTimeStamp + theSpawner.cooldown - now) + trigger.action.outTextForGroup(conf.id, "Still redeploying (" .. delta .. " seconds left)", 30) + return + end + + cfxSpawnZones.spawnWithSpawner(theSpawner) + trigger.action.outTextForGroup(conf.id, "Deploying <" .. theSpawner.baseName .. "> now...", 30) + + -- reset all comms so we can include new troops + -- into load menu + timer.scheduleFunction(cfxHeloTroops.delayedCommsResetForUnit, {conf.unit, "ignore"}, now + 1.0) + +end + + +-- +-- Player event callbacks +-- +function cfxHeloTroops.playerChangeEvent(evType, description, player, data) + if evType == "newGroup" then + theUnit = data.primeUnit + cfxHeloTroops.setCommsMenu(theUnit) + + return + end + + if evType == "removeGroup" then +-- trigger.action.outText("+++Helo Troops: a group disappeared", 30) + -- data.name contains the name of the group. nil the entry in config list, so all + -- troops that group was carrying are gone + -- we must remove the comms menu for this group else we try to add another one to this group later + -- we assume a one-unit group structure, else the following may fail + local conf = cfxHeloTroops.getConfigForUnitNamed(data.primeUnitName) + if conf then + cfxHeloTroops.removeCommsFromConfig(conf) + end + return + end + + if evType == "leave" then + local conf = cfxHeloTroops.getConfigForUnitNamed(player.unitName) + if conf then + cfxHeloTroops.resetConfig(conf) + end + end + + if evType == "unit" then + -- player changed units. almost never in MP, but possible in solo + -- we need to reset the conf so no troops are carried any longer + local conf = cfxHeloTroops.getConfigForUnitNamed(data.oldUnitName) + if conf then + cfxHeloTroops.resetConfig(conf) + end + end + +end + + +-- +-- read config zone +-- +function cfxHeloTroops.readConfigZone() + -- note: must match exactly!!!! + local theZone = cfxZones.getZoneByName("heloTroopsConfig") + if not theZone then + trigger.action.outText("+++heloT: no config zone!", 30) + return + end + + cfxHeloTroops.verbose = cfxZones.getBoolFromZoneProperty(theZone, "verbose", false) + + if cfxZones.hasProperty(theZone, "legalTroops") then + local theTypesString = cfxZones.getStringFromZoneProperty(theZone, "legalTroops", "") + local unitTypes = dcsCommon.splitString(aSpawner.types, ",") + if #unitTypes < 1 then + unitTypes = {"Soldier AK", "Infantry AK", "Infantry AK ver2", "Infantry AK ver3", "Infantry AK Ins", "Soldier M249", "Soldier M4 GRG", "Soldier M4", "Soldier RPG", "Paratrooper AKS-74", "Paratrooper RPG-16", "Stinger comm dsr", "Stinger comm", "Soldier stinger", "SA-18 Igla-S comm", "SA-18 Igla-S manpad", "Igla manpad INS", "SA-18 Igla comm", "SA-18 Igla manpad",} -- default + else + unitTypes = dcsCommon.trimArray(unitTypes) + end + cfxHeloTroops.legalTroops = unitTypes + end + + cfxHeloTroops.troopWeight = cfxZones.getNumberFromZoneProperty(theZone, "troopWeight", 100) -- kg average weight per trooper + + cfxHeloTroops.autoDrop = cfxZones.getBoolFromZoneProperty(theZone, "autoDrop", false) + cfxHeloTroops.autoPickup = cfxZones.getBoolFromZoneProperty(theZone, "autoPickup", false) + cfxHeloTroops.pickupRange = cfxZones.getNumberFromZoneProperty(theZone, "pickupRange", 100) + +end + +-- +-- Start +-- +function cfxHeloTroops.start() + -- check libs + if not dcsCommon.libCheck("cfx Helo Troops", + cfxHeloTroops.requiredLibs) then + return false + end + + -- read config zone + + -- install callbacks for helo-relevant events + dcsCommon.addEventHandler(cfxHeloTroops.somethingHappened, cfxHeloTroops.preProcessor, cfxHeloTroops.postProcessor) + + -- now iterate through all player groups and install the Assault Troop Menu + allPlayerGroups = cfxPlayerGroups -- cfxPlayerGroups is a global, don't fuck with it! + -- contains per group a player record, use prime unit to access player's unit + for gname, pgroup in pairs(allPlayerGroups) do + local aUnit = pgroup.primeUnit -- get any unit of that group + cfxHeloTroops.setCommsMenu(aUnit) + end + -- now install the new group notifier to install Assault Troops menu + + cfxPlayer.addMonitor(cfxHeloTroops.playerChangeEvent) + trigger.action.outText("cf/x Helo Troops v" .. cfxHeloTroops.version .. " started", 30) + return true +end + +-- let's get rolling +if not cfxHeloTroops.start() then + trigger.action.outText("cf/x Helo Troops aborted: missing libraries", 30) + cfxHeloTroops = nil +end + +--[[-- + - interface with spawnable: request troops via comms menu if + - spawnZones defined + - spawners in range and + - spawner auf 'paused' und 'requestable' + +--]]-- +-- TODO: weight when loading troops \ No newline at end of file diff --git a/modules/cfxMapMarkers.lua b/modules/cfxMapMarkers.lua new file mode 100644 index 0000000..a5f61cc --- /dev/null +++ b/modules/cfxMapMarkers.lua @@ -0,0 +1,99 @@ +cfxMapMarkers = {} +cfxMapMarkers.version = "1.0.2" +cfxMapMarkers.autostart = true +--[[-- +Version History + - 1.0.1 - initial version + - 1.0.2 - coalition property processing cleanup + +--]]-- +-- cfxMapMarkers places Annotations for players on the F10 map +-- Annotations are derived from Zones, and their properties control who can see them +-- Any Zone with the Property "mapMarker" will put the property's content on the Map +-- Markers are shown to everyone, except when +-- - an additional Property 'coalition' is present, the Map Marker is only shown to that coalition. Possible coalition values are "ALL" (default), "RED", "BLUE", "NEUTRAL" +-- - an additional Property 'group' is present, the Map Marker is shown to the group with that name. Note that +-- 'coalition' and 'group' are mutually exclusive. Group overrides Coalition +-- you can access map markers by their zones, turn them on and off individually +-- if autostart is on, all zones are scanned for map markers and displayed according to their properties + +-- NOTE: when placing a mark, this will add the 'markID' attribute to the zone table to identify the mark + +--cfxMapMarkers.simpleUUID = 76543 -- a number to start. as good as any +--function cfxMapMarkers.uuid() +-- cfxMapMarkers.simpleUUID = cfxMapMarkers.simpleUUID + 1 +-- return cfxMapMarkers.simpleUUID +--end + +function cfxMapMarkers.addMapMarkerForZone(theZone) + local markText = cfxZones.getZoneProperty(theZone, "mapMarker") + if not markText then return end + if markText == "" then markText = "I am empty of content, devoid of meaning" end + + -- if there is a map marker already, remove it + cfxMapMarkers.removeMapMarkerForZone(theZone) + + -- get a new map marker ID + local markID = dcsCommon.numberUUID() + + -- see if there is a group or coalition target + local coal = cfxZones.getStringFromZoneProperty(theZone, "coalition", "ALL") + if coal == "1" then coal = "RED" end + if coal == "2" then coal = "BLUE" end + + coal = coal:upper() + --coal = string.upper(coal) + + local toGroup = cfxZones.getZoneProperty(theZone, "group") + if toGroup then toGroup = Group.getByName(toGroup) end + if toGroup then + -- mark to group + local groupID = toGroup:getID() + trigger.action.markToGroup(markID, markText, theZone.point, groupID, true, "") + theZone.markID = markID + return + end + + -- make sure we have a legal coalition + if coal ~= "BLUE" and coal ~= "RED" and coal ~= "NEUTRAL" then + coal = "ALL" + end + + if coal == "ALL" then + -- place the map marker to ALL coalitions + trigger.action.markToAll(markID, markText, theZone.point, true, "") + theZone.markID = markID + return + end + + -- if we get here. we should mark by coalition + local theSide = 0 -- neutral (default) + if coal == "RED" then + theSide = 1 + end + if coal == "BLUE" then + theSide = 2 + end + trigger.action.markToCoalition(markID, markText, theZone.point, theSide, true, "") + theZone.markID = markID +end + +function cfxMapMarkers.removeMapMarkerForZone(theZone) + if theZone.markID then + trigger.action.removeMark(theZone.markID) + end +end + +function cfxMapMarkers.start() + -- collect all zones that have the 'MapMarker" Attribute + local attrZones = cfxZones.getZonesWithAttributeNamed("mapMarker") + + -- process every zone + for k, aZone in pairs(attrZones) do + cfxMapMarkers.addMapMarkerForZone(aZone) + end +end + +if cfxMapMarkers.autostart then cfxMapMarkers.start() end + +trigger.action.outText("cfx Map Markers v" .. cfxMapMarkers.version .. " started.", 30) \ No newline at end of file diff --git a/modules/cfxNDB.lua b/modules/cfxNDB.lua new file mode 100644 index 0000000..921e6ff --- /dev/null +++ b/modules/cfxNDB.lua @@ -0,0 +1,126 @@ +cfxNDB = {} +cfxNDB.version = "1.0.0" +cfxNDB.verbose = false +cfxNDB.ups = 10 -- every 10 seconds +cfxNDB.requiredLibs = { + "dcsCommon", + "cfxZones", +} +cfxNDB.refresh = 10 -- for moving ndb: interval in secs between refresh +cfxNDB.power = 100 + +cfxNDB.ndbs = {} -- all ndbs + +-- +-- NDB zone - *** EXTENDS ZONES *** +-- + +function cfxNDB.startNDB(theNDB) + theNDB.ndbRefreshTime = timer.getTime() + theNDB.ndbRefresh -- only used in linkedUnit, but set up anyway + -- generate new ID + theNDB.ndbID = dcsCommon.uuid("ndb") + local fileName = "l10n/DEFAULT/" .. theNDB.ndbSound -- need to prepend the resource string + local modulation = 0 + if theNDB.fm then modulation = 1 end + + local loc = cfxZones.getPoint(theNDB) + trigger.action.radioTransmission(fileName, loc, modulation, true, theNDB.freq, theNDB.power, theNDB.ndbID) + + if cfxNDB.verbose then + local dsc = "" + if theNDB.linkedUnit then + dsc = " (linked to ".. theNDB.linkedUnit:getName() .. "!, r=" .. theNDB.ndbRefresh .. ") " + end + trigger.action.outText("+++ndb: started " .. theNDB.name .. dsc .. " at " .. theNDB.freq/1000000 .. "mod " .. modulation .. " with w=" .. theNDB.power .. " s=<" .. fileName .. ">", 30) + end +end + +function cfxNDB.stopNDB(theNDB) + trigger.action.stopRadioTransmission(theNDB.ndbID) +end + +function cfxNDB.createNDBWithZone(theZone) + theZone.freq = cfxZones.getNumberFromZoneProperty(theZone, "NDB", 124) -- in MHz + -- convert MHz to Hz + theZone.freq = theZone.freq * 1000000 -- Hz + theZone.fm = cfxZones.getBoolFromZoneProperty(theZone, "fm", false) + theZone.ndbSound = cfxZones.getStringFromZoneProperty(theZone, "soundFile", "") + theZone.power = cfxZones.getNumberFromZoneProperty(theZone, "watts", cfxNDB.power) + theZone.loop = true -- always. NDB always loops + -- UNSUPPORTED refresh. Although read individually, it only works + -- when LARGER than module's refresh. + theZone.ndbRefresh = cfxZones.getNumberFromZoneProperty(theZone, "ndbRefresh", cfxNDB.refresh) -- only used if linked + theZone.ndbRefreshTime = timer.getTime() + theZone.ndbRefresh -- only used with linkedUnit, but set up nonetheless + -- start it + cfxNDB.startNDB(theZone) + + -- add it to my watchlist + table.insert(cfxNDB.ndbs, theZone) +end + +-- +-- update +-- +function cfxNDB.update() + timer.scheduleFunction(cfxNDB.update, {}, timer.getTime() + 1/cfxNDB.ups) + local now = timer.getTime() + -- walk through all NDB and see if they need a refresh + for idx, theNDB in pairs (cfxNDB.ndbs) do + -- see if this ndb is linked, meaning it's potentially + -- moving with the linked unit + if theNDB.linkedUnit then + -- yupp, need to update + if now > theNDB.ndbRefreshTime then + cfxNDB.stopNDB(theNDB) + cfxNDB.startNDB(theNDB) + end + end + end +end + +-- +-- start up +-- +function cfxNDB.readConfig() + local theZone = cfxZones.getZoneByName("ndbConfig") + if not theZone then + if cfxNDB.verbose then + trigger.action.outText("***ndb: NO config zone!", 30) + end + return + end + + cfxNDB.verbose = cfxZones.getBoolFromZoneProperty(theZone, "verbose", false) + cfxNDB.ndbRefresh = cfxZones.getNumberFromZoneProperty(theZone, "ndbRefresh", 10) + + if cfxNDB.verbose then + trigger.action.outText("***ndb: read config", 30) + end +end + +function cfxNDB.start() + -- lib check + if not dcsCommon.libCheck("cfx NDB", + cfxNDB.requiredLibs) then + return false + end + + -- config + cfxNDB.readConfig() + + -- read zones + local attrZones = cfxZones.getZonesWithAttributeNamed("NDB") + for idx, aZone in pairs(attrZones) do + cfxNDB.createNDBWithZone(aZone) + end + + -- start update + cfxNDB.update() + + return true +end + +if not cfxNDB.start() then + trigger.action.outText("cf/x NDB aborted: missing libraries", 30) + cfxNDB = nil +end \ No newline at end of file diff --git a/modules/cfxObjectDestructDetector.lua b/modules/cfxObjectDestructDetector.lua new file mode 100644 index 0000000..3dd101b --- /dev/null +++ b/modules/cfxObjectDestructDetector.lua @@ -0,0 +1,150 @@ +cfxObjectDestructDetector = {} +cfxObjectDestructDetector.version = "1.0.1" +cfxObjectDestructDetector.requiredLibs = { + "dcsCommon", -- always + "cfxZones", -- Zones, of course +} +cfxObjectDestructDetector.verbose = false +--[[-- + VERSION HISTORY + 1.0.0 initial version, based on parashoo, arty zones + 1.0.1 fixed bug: trigger.MISC.getUserFlag() + + Detect when an object with OBJECT ID as assigned in ME dies + *** EXTENDS ZONES + +--]]-- +cfxObjectDestructDetector.objectZones = {} + +-- +-- C A L L B A C K S +-- +cfxObjectDestructDetector.callbacks = {} +function cfxObjectDestructDetector.addCallback(theCallback) + table.insert(cfxObjectDestructDetector.callbacks, theCallback) +end + +function cfxObjectDestructDetector.invokeCallbacksFor(zone) + for idx, theCB in pairs (cfxObjectDestructDetector.callbacks) do + theCB(zone, zone.ID, zone.name) + end +end + +-- +-- zone handling +-- +function cfxObjectDestructDetector.addObjectDetectZone(aZone) + -- add landHeight to this zone + table.insert(cfxObjectDestructDetector.objectZones, aZone) +end + +-- +-- processing of zones +-- +function cfxObjectDestructDetector.processObjectDestructZone(aZone) + aZone.name = cfxZones.getStringFromZoneProperty(aZone, "NAME", aZone.name) +-- aZone.coalition = cfxZones.getCoalitionFromZoneProperty(aZone, "coalition", 0) + aZone.ID = cfxZones.getNumberFromZoneProperty(aZone, "OBJECT ID", 1) -- THIS! + if cfxZones.hasProperty(aZone, "setFlag") then + aZone.setFlag = cfxZones.getStringFromZoneProperty(aZone, "setFlag", "999") + end + if cfxZones.hasProperty(aZone, "f=1") then + aZone.setFlag = cfxZones.getStringFromZoneProperty(aZone, "f=1", "999") + end + if cfxZones.hasProperty(aZone, "clearFlag") then + aZone.clearFlag = cfxZones.getStringFromZoneProperty(aZone, "clearFlag", "999") + end + if cfxZones.hasProperty(aZone, "f=0") then + aZone.clearFlag = cfxZones.getStringFromZoneProperty(aZone, "f=0", "999") + end + if cfxZones.hasProperty(aZone, "increaseFlag") then + aZone.increaseFlag = cfxZones.getStringFromZoneProperty(aZone, "increaseFlag", "999") + end + if cfxZones.hasProperty(aZone, "f+1") then + aZone.increaseFlag = cfxZones.getStringFromZoneProperty(aZone, "f+1", "999") + end + if cfxZones.hasProperty(aZone, "decreaseFlag") then + aZone.decreaseFlag = cfxZones.getStringFromZoneProperty(aZone, "decreaseFlag", "999") + end + if cfxZones.hasProperty(aZone, "f-1") then + aZone.decreaseFlag = cfxZones.getStringFromZoneProperty(aZone, "f-1", "999") + end +end +-- +-- MAIN DETECTOR +-- +-- invoke callbacks when an object was destroyed +function cfxObjectDestructDetector:onEvent(event) + if event.id == world.event.S_EVENT_DEAD then + if not event.initiator then return end + local id = event.initiator:getName() + if not id then return end + + for idx, aZone in pairs(cfxObjectDestructDetector.objectZones) do + if aZone.ID == id then + -- flag manipulation + if aZone.setFlag then + trigger.action.setUserFlag(aZone.setFlag, 1) + end + if aZone.clearFlag then + trigger.action.setUserFlag(aZone.clearFlag, 0) + end + if aZone.increaseFlag then + local val = trigger.misc.getUserFlag(aZone.increaseFlag) + 1 + trigger.action.setUserFlag(aZone.increaseFlag, val) + end + if aZone.decreaseFlag then + local val = trigger.misc.getUserFlag(aZone.decreaseFlag) - 1 + trigger.action.setUserFlag(aZone.decreaseFlag, val) + end + + -- invoke callbacks + cfxObjectDestructDetector.invokeCallbacksFor(aZone) + if cfxObjectDestructDetector.verbose then + trigger.action.outText("OBJECT KILL: " .. id, 30) + end + + -- we could now remove the object from the list + -- for better performance since it cant + -- die twice + + return + end + end + + end + +end +-- add event handler + + +function cfxObjectDestructDetector.start() + if not dcsCommon.libCheck("cfx Object Destruct Detector", + cfxObjectDestructDetector.requiredLibs) then + return false + end + + -- collect all zones with 'smoke' attribute + -- collect all spawn zones + local attrZones = cfxZones.getZonesWithAttributeNamed("OBJECT ID") + + -- now create a spawner for all, add them to the spawner updater, and spawn for all zones that are not + -- paused + for k, aZone in pairs(attrZones) do + cfxObjectDestructDetector.processObjectDestructZone(aZone) -- process attribute and add to zone properties (extend zone) + cfxObjectDestructDetector.addObjectDetectZone(aZone) -- remember it so we can smoke it + end + + -- add myself as event handler + world.addEventHandler(cfxObjectDestructDetector) + + -- say hi + trigger.action.outText("cfx Object Destruct Zones v" .. cfxObjectDestructDetector.version .. " started.", 30) + return true +end + +-- let's go +if not cfxObjectDestructDetector.start() then + trigger.action.outText("cf/x Object Destruct Zones aborted: missing libraries", 30) + cfxObjectDestructDetector = nil +end diff --git a/modules/cfxObjectSpawnZones.lua b/modules/cfxObjectSpawnZones.lua new file mode 100644 index 0000000..e6cddb7 --- /dev/null +++ b/modules/cfxObjectSpawnZones.lua @@ -0,0 +1,481 @@ +cfxObjectSpawnZones = {} +cfxObjectSpawnZones.version = "1.1.3" +cfxObjectSpawnZones.requiredLibs = { + "dcsCommon", -- common is of course needed for everything + -- pretty stupid to check for this since we + -- need common to invoke the check, but anyway + "cfxZones", -- Zones, of course. MUST HAVE RUN +} +cfxObjectSpawnZones.ups = 1 + +-- +-- Zones that conform with this requirements spawn toops automatically +-- *** DOES NOT EXTEND ZONES *** +-- +-- version history +-- 1.0.0 - based on 1.4.6 version from cfxSpawnZones +-- 1.1.0 - uses linkedUnit, so spawnming can occur on ships +-- make sure you also enable useOffset to place the +-- statics away from the center of the ship +-- 1.1.1 - also processes paused flag +-- - despawnRemaining(spawner) +-- 1.1.2 - autoRemove option re-installed +-- - added possibility to autoUnlink +-- 1.1.3 - ME-triggered flag via f? and triggerFlag + +-- Object spawn zones have the following major uses: +-- - dynamically spawn cargo +-- - litter a zone with static obejcts quickly +-- - linking static dynamic objects with ships, including cargo +-- +-- +-- How do we recognize an object spawn zone? +-- contains a "objectSpawner" attribute +-- a spawner must also have the following attributes +-- - objectSpawner - anything, must be present to signal. put in 'ground' to be able to expand to other types +-- - types - type strings, comma separated. They all spawn in the same spot. +-- see here: https://github.com/mrSkortch/DCS-miscScripts/tree/master/ObjectDB +-- - count - repeat types n times. optional, defaults to 1 +-- - country - defaults to 2 (usa) -- see here https://wiki.hoggitworld.com/view/DCS_enum_country +-- some important: 0 = Russia, 2 = US, 82 = UN neutral +-- country is converted to coalition and then assigned to +-- Joint Task Force upon spawn +-- - baseName - MANDATORY, for naming spawned objects - MUST BE UNIQUE!!!! +-- - heading in DEGREES (default 0 = north ) direction entire group is facing +-- - weight - weight in kg if transportable +-- - isCargo - boolean yes means it can be carried by other units +-- - cooldown - seconds to cool down before re-spawning +-- - maxSpawns - how many times we spawn. default = 1, -1 = unlimited +-- - requestable -- via comms menu, will auto-pause +-- - managed -- if cargo, it's automatically passed to cfx cargo manager for handling (if cargo manager is loaded) +-- - (linkedUnit) for placing on ships +-- - (useOffset) for not using ship's center + +-- respawn currently happens after theSpawns is deleted and cooldown seconds have passed +cfxObjectSpawnZones.allSpawners = {} +cfxObjectSpawnZones.callbacks = {} -- signature: cb(reason, group, spawner) + + +-- +-- C A L L B A C K S +-- +function cfxObjectSpawnZones.addCallback(theCallback) + table.insert(cfxObjectSpawnZones.callbacks, theCallback) +end + +function cfxObjectSpawnZones.invokeCallbacksFor(reason, theSpawns, theSpawner) + for idx, theCB in pairs (cfxObjectSpawnZones.callbacks) do + theCB(reason, theSpawns, theSpawner) + end +end + + +-- +-- creating a spawner +-- +function cfxObjectSpawnZones.createSpawner(inZone) + local theSpawner = {} + theSpawner.zone = inZone + theSpawner.name = inZone.name + + -- connect with ME if a trigger flag is given + if cfxZones.hasProperty(inZone, "f?") then + theSpawner.triggerFlag = cfxZones.getStringFromZoneProperty(inZone, "f?", "none") + theSpawner.lastTriggerValue = trigger.misc.getUserFlag(theSpawner.triggerFlag) + end + + --theSpawner.types = cfxZones.getZoneProperty(inZone, "types") + theSpawner.types = cfxZones.getStringFromZoneProperty(inZone, "types", "White_Tyre") + local n = cfxZones.getNumberFromZoneProperty(inZone, "count", 1) -- DO NOT CONFUSE WITH OWN PROPERTY COUNT for unique names!!! + if n < 1 then n = 1 end -- sanity check. + + theSpawner.numObj = n + + theSpawner.country = cfxZones.getNumberFromZoneProperty(inZone, "country", 2) -- coalition2county(theSpawner.owner) + + theSpawner.rawOwner = coalition.getCountryCoalition(theSpawner.country) + theSpawner.baseName = cfxZones.getStringFromZoneProperty(inZone, "baseName", dcsCommon.uuid("objSpwn")) + --cfxZones.getZoneProperty(inZone, "baseName") + theSpawner.cooldown = cfxZones.getNumberFromZoneProperty(inZone, "cooldown", 60) + theSpawner.lastSpawnTimeStamp = -10000 -- just init so it will always work + theSpawner.autoRemove = cfxZones.getBoolFromZoneProperty(inZone, "autoRemove", false) + theSpawner.autoLink = cfxZones.getBoolFromZoneProperty(inZone, "autoLink", true) + + theSpawner.heading = cfxZones.getNumberFromZoneProperty(inZone, "heading", 0) + theSpawner.weight = cfxZones.getNumberFromZoneProperty(inZone, "weight", 0) + if theSpawner.weight < 0 then theSpawner.weight = 0 end + + theSpawner.isCargo = cfxZones.getBoolFromZoneProperty(inZone, "isCargo", false) + if theSpawner.isCargo == true and theSpawner.weight < 100 then theSpawner.weight = 100 end + theSpawner.managed = cfxZones.getBoolFromZoneProperty(inZone, "managed", true) -- defaults to managed cargo + + theSpawner.cdTimer = 0 -- used for cooldown. if timer.getTime < this value, don't spawn + -- theSpawner.cdStarted = false -- used to initiate cooldown when all items in theSpawns disappear + theSpawner.count = 1 -- used to create names, and count how many groups created + theSpawner.theSpawns = {} -- all items that are spawned. re-spawn happens if they are all out + theSpawner.maxSpawns = cfxZones.getNumberFromZoneProperty(inZone, "maxSpawns", 1) + theSpawner.paused = cfxZones.getBoolFromZoneProperty(inZone, "paused", false) + theSpawner.requestable = cfxZones.getBoolFromZoneProperty(inZone, "requestable", false) + if theSpawner.requestable then theSpawner.paused = true end + + + -- see if it is linked to a ship to set realtive orig headiong + + if inZone.linkedUnit then + local shipUnit = inZone.linkedUnit + theSpawner.linkedUnit = shipUnit + --trigger.action.outText("+++obSpZ: zone " .. inZone.name .. " linked to ship (?) " .. shipUnit:getName() .. " of cat " .. shipUnit:getCategory(), 30) + + local origHeading = dcsCommon.getUnitHeadingDegrees(shipUnit) + + -- calculate initial delta + local delta = dcsCommon.vSub(inZone.point,shipUnit:getPoint()) -- delta = B - A + theSpawner.dx = delta.x + theSpawner.dy = delta.z + + --theSpawner.dx = inZone.dx + --theSpawner.dy = inZone.dy + theSpawner.origHeading = origHeading + --trigger.action.outText("+++obSpZ: with dx = " .. theSpawner.dx .. " dy = " .. theSpawner.dy .. " hdg = " .. origHeading, 30) + end + return theSpawner +end + +function cfxObjectSpawnZones.addSpawner(aSpawner) + cfxObjectSpawnZones.allSpawners[aSpawner.zone] = aSpawner +end + +function cfxObjectSpawnZones.removeSpawner(aSpawner) + cfxObjectSpawnZones.allSpawners[aSpawner.zone] = nil +end + +function cfxObjectSpawnZones.getSpawnerForZone(aZone) + return cfxObjectSpawnZones.allSpawners[aZone] +end + +function cfxObjectSpawnZones.getSpawnerForZoneNamed(aName) + local aZone = cfxZones.getZoneByName(aName) + return cfxObjectSpawnZones.getSpawnerForZone(aZone) +end + + +function cfxObjectSpawnZones.getRequestableSpawnersInRange(aPoint, aRange, aSide) + -- trigger.action.outText("enter requestable spawners for side " .. aSide , 30) + if not aSide then aSide = 0 end +-- currently, WE FORCE A SIDE MATCH + aSide = 0 + + if not aRange then aRange = 200 end + if not aPoint then return {} end + + local theSpawners = {} + for aZone, aSpawner in pairs(cfxObjectSpawnZones.allSpawners) do + -- iterate all zones and collect those that match + local hasMatch = true + -- update the zone's point if it is linked to a ship, i.e. + local delta = dcsCommon.dist(aPoint, cfxZones.getPoint(aZone)) + if delta>aRange then hasMatch = false end + if aSide ~= 0 then + --[[-- + -- check if side is correct for owned zone + + --]]-- + end + --[[-- + if aSide ~= aSpawner.rawOwner then + -- only return spawners with this side + -- note: this will NOT work with neutral players + hasMatch = false + end + --]]-- + + if not aSpawner.requestable then + hasMatch = false + end + + if hasMatch then + table.insert(theSpawners, aSpawner) + end + end + + return theSpawners +end + +-- +-- spawn troops +-- + +function cfxObjectSpawnZones.verifySpawnOwnership(spawner) + --[[-- + -- returns false ONLY if masterSpawn disagrees + + --]]-- + return true +end + +function cfxObjectSpawnZones.spawnObjectNTimes(aSpawner, theType, n, container) + if not aSpawner then return end + if not container then container = {} end + if not n then n = 1 end + if not theType then return end + local aZone = aSpawner.zone + if not aZone then return end + if n < 1 then return end + + local center = cfxZones.getPoint(aZone) -- magically works with moving zones and offset + + if n == 1 then + -- spawn in the middle, only a single object + local ox = center.x + local oy = center.z + local theStaticData = dcsCommon.createStaticObjectData( + aSpawner.baseName .. "-" .. aSpawner.count, + theType, + aSpawner.heading, + false, -- dead? + aSpawner.isCargo, + aSpawner.weight) + aSpawner.count = aSpawner.count + 1 + + -- more to global position + dcsCommon.moveStaticDataTo(theStaticData, ox, oy) + + -- if linked, relative-link instead to ship + -- NOTE: it is possible that we have to re-calc heading + -- if ship turns relative to original designation position. + if aZone.linkedUnit and aZone.autoLink then + dcsCommon.linkStaticDataToUnit( + theStaticData, + aZone.linkedUnit, + aSpawner.dx, + aSpawner.dy, + aSpawner.origHeading) + end + + -- spawn in dcs + local theObject = coalition.addStaticObject(aSpawner.rawOwner, theStaticData) -- create in dcs + table.insert(container, theObject) -- add to collection + if aSpawner.isCargo and aSpawner.managed then + if cfxCargoManager then + cfxCargoManager.addCargo(theObject) + end + end + + return + end + + local numObjects = n + local degrees = 3.14157 / 180 + local degreeIncrement = (360 / numObjects) * degrees + local currDegree = 0 + local missionObjects = {} + for i=1, numObjects do + local rx = math.cos(currDegree) * aZone.radius + local ry = math.sin(currDegree) * aZone.radius + local ox = center.x + rx + local oy = center.z + ry -- note: z! + + local theStaticData = dcsCommon.createStaticObjectData( + aSpawner.baseName .. "-" .. aSpawner.count, + theType, + aSpawner.heading, + false, -- dead? + aSpawner.isCargo, + aSpawner.weight) + + theStaticData.canCargo = aSpawner.isCargo -- should be false, but you never know + if theStaticData.canCargo then +-- theStaticData.mass = aSpawner.weight + trigger.action.outText("+++ obSpw is cargo with w=" .. theStaticData.mass .. " for " .. theStaticData.name, 30) + end + + aSpawner.count = aSpawner.count + 1 + dcsCommon.moveStaticDataTo(theStaticData, ox, oy) + + if aZone.linkedUnit and aZone.autoLink then + dcsCommon.linkStaticDataToUnit(theStaticData, aZone.linkedUnit, aSpawner.dx + rx, aSpawner.dy + ry, aSpawner.origHeading) + end + + -- spawn in dcs + local theObject = coalition.addStaticObject(aSpawner.rawOwner, theStaticData) -- this will generate an event! + table.insert(container, theObject) + -- see if it is managed cargo + if aSpawner.isCargo and aSpawner.managed then + if cfxCargoManager then + cfxCargoManager.addCargo(theObject) + end + end + -- update rotation + currDegree = currDegree + degreeIncrement + end +end + +function cfxObjectSpawnZones.spawnWithSpawner(aSpawner) + + if type(aSpawner) == "string" then -- return spawner for zone of that name + aSpawner = cfxObjectSpawnZones.getSpawnerForZoneNamed(aName) + end + if not aSpawner then return end + + -- will NOT check if conditions are met. This forces a spawn + local unitTypes = {} -- build type names + local p = cfxZones.getPoint(aSpawner.zone) -- aSpawner.zone.point + + -- split the conf.troopsOnBoardTypes into an array of types + unitTypes = dcsCommon.splitString(aSpawner.types, ",") + if #unitTypes < 1 then + table.insert(unitTypes, "White_Flag") -- make it one m4 trooper as fallback + end + + -- now iterate through all types and create objects for each name + -- overlaying them all n times + aSpawner.theSpawns = {} -- forget whatever there was before. + for idx, typeName in pairs(unitTypes) do + cfxObjectSpawnZones.spawnObjectNTimes( + aSpawner, + typeName, + aSpawner.numObj, + aSpawner.theSpawns) + end + + -- reset cooldown (forced) + if true then -- or not spawner.cdStarted then -- forced on + -- no, start cooldown + --spawner.cdStarted = true + aSpawner.cdTimer = timer.getTime() + aSpawner.cooldown + end + + -- callback to all who want to know + cfxObjectSpawnZones.invokeCallbacksFor("spawned", aSpawner.theSpawns, aSpawner) + + -- timestamp so we can check against cooldown on manual spawn + aSpawner.lastSpawnTimeStamp = timer.getTime() + -- make sure a requestable spawner is always paused + if aSpawner.requestable then + aSpawner.paused = true + end + + if aSpawner.autoRemove then + -- simply remove the group + aSpawner.theSpawns = {} -- empty group -- forget all + end +end + +function cfxObjectSpawnZones.despawnRemaining(spawner) + for idx, anObject in pairs (spawner.theSpawns) do + if anObject and anObject:isExist() then + anObject:destroy() + end + end +end + +-- +-- U P D A T E +-- +function cfxObjectSpawnZones.needsSpawning(spawner) + if spawner.paused then return false end + if spawner.requestable then return false end + if spawner.maxSpawns == 0 then return false end + if #spawner.theSpawns > 0 then return false end + if timer.getTime() < spawner.cdTimer then return false end + + return cfxObjectSpawnZones.verifySpawnOwnership(spawner) +end + +function cfxObjectSpawnZones.update() + cfxObjectSpawnZones.updateSchedule = timer.scheduleFunction(cfxObjectSpawnZones.update, {}, timer.getTime() + 1/cfxObjectSpawnZones.ups) + + for key, spawner in pairs (cfxObjectSpawnZones.allSpawners) do + -- see if the spawn is dead or was removed + -- forget all dead spawns + local objectsToKeep = {} +-- if not spawner.requestable then + for idx, anObject in pairs (spawner.theSpawns) do + if not anObject:isExist() then + --trigger.action.outText("+++ obSpwn: INEXIST removing object in zone " .. spawner.zone.name, 30) + elseif anObject:getLife() < 1 then + --trigger.action.outText("+++ obSpwn: dead. removing object ".. anObject:getName() .." in zone " .. spawner.zone.name, 30) + else + table.insert(objectsToKeep, anObject) + end + end +-- end + + -- see if we killed off all objects and start cd if so + if #objectsToKeep == 0 and #spawner.theSpawns > 0 then + spawner.cdTimer = timer.getTime() + spawner.cooldown + end + -- transfer kept items + spawner.theSpawns = objectsToKeep + + local needsSpawn = cfxObjectSpawnZones.needsSpawning(spawner) + -- check if perhaps our watchtrigger causes spawn + if spawner.triggerFlag then + local currTriggerVal = trigger.misc.getUserFlag(spawner.triggerFlag) + if currTriggerVal ~= spawner.lastTriggerValue then + needsSpawn = true + spawner.lastTriggerValue = currTriggerVal + end + end + + if needsSpawn then + cfxObjectSpawnZones.spawnWithSpawner(spawner) + if spawner.maxSpawns > 0 then + spawner.maxSpawns = spawner.maxSpawns - 1 + end + if spawner.maxSpawns == 0 then + spawner.paused = true + + end + else + -- trigger.action.outText("+++ NOSPAWN for zone " .. spawner.zone.name, 30) + end + end +end + +function cfxObjectSpawnZones.start() + if not dcsCommon.libCheck("cfx Object Spawn Zones", + cfxObjectSpawnZones.requiredLibs) then + return false + end + + -- collect all spawn zones + local attrZones = cfxZones.getZonesWithAttributeNamed("objectSpawner") + + -- now create a spawner for all, add them to the spawner updater, and spawn for all zones that are not + -- paused + for k, aZone in pairs(attrZones) do + local aSpawner = cfxObjectSpawnZones.createSpawner(aZone) + cfxObjectSpawnZones.addSpawner(aSpawner) + if (not aSpawner.paused) and + cfxObjectSpawnZones.verifySpawnOwnership(aSpawner) and + aSpawner.maxSpawns ~= 0 + then + cfxObjectSpawnZones.spawnWithSpawner(aSpawner) + -- update spawn count and make sure we haven't spawned the one and only + if aSpawner.maxSpawns > 0 then + aSpawner.maxSpawns = aSpawner.maxSpawns - 1 + end + if aSpawner.maxSpawns == 0 then + aSpawner.paused = true + --trigger.action.outText("+++ maxspawn -- turning off zone " .. aSpawner.zone.name, 30) + end + end + end + + -- and start the regular update calls + cfxObjectSpawnZones.update() + + trigger.action.outText("cfx Object Spawn Zones v" .. cfxObjectSpawnZones.version .. " started.", 30) + return true +end + +if not cfxObjectSpawnZones.start() then + trigger.action.outText("cf/x Spawn Zones aborted: missing libraries", 30) + cfxObjectSpawnZones = nil +end + +--[[-- +IMPROVEMENTS + 'formations' - concentric, pile, random, array etc. +--]]-- \ No newline at end of file diff --git a/modules/cfxOwnedZones.lua b/modules/cfxOwnedZones.lua new file mode 100644 index 0000000..5a42228 --- /dev/null +++ b/modules/cfxOwnedZones.lua @@ -0,0 +1,860 @@ +cfxOwnedZones = {} +cfxOwnedZones.version = "1.1.0" +cfxOwnedZones.verbose = false +cfxOwnedZones.announcer = true +--[[-- VERSION HISTORY + +1.0.3 - added getNearestFriendlyZone + - added getNearestOwnedZone + - added hasOwnedZones + - added getNearestOwnedZoneToPoint +1.0.4 - changed addOwnedZone code to use cfxZone.getCoalitionFromZoneProperty + - changed to use dcsCommon.coalition2county + - changed to using correct coalition for spawing attackers and defenders +1.0.5 - repairing defenders switches to country instead coalition when calling createGroundUnitsInZoneForCoalition -- fixed +1.0.6 - removed call to conqTemplate + - verified that pause will also apply to init + - unbeatable zones + - untargetable zones + - hidden attribute +1.0.7 - optional cfxGroundTroops module, error message when attackers + - support of 'none' type string to indicate no attackers/defenders + - updated property access + - module check + - cfxOwnedTroop.usesDefenders(aZone) + - verifyZone +1.0.8 - repairDefenders trims types to allow blanks in + type separator +1.1.0 - config zone + - bang! support r, b, n capture + - defaulting attackDelta to 10 instead of radius + - verbose code for spawning + - verbose code for state transition + - attackers have (A) in name, defenders (D) + - exit createDefenders if no troops + - exit createAttackers if no troops + - usesAttackers/usesDefenders checks for neutral ownership + - verbose state change + - nearestZone supports moving zones + - remove exiting defenders from zone after cap to avoid + shocked state + - announcer + +--]]-- +cfxOwnedZones.requiredLibs = { + "dcsCommon", -- common is of course needed for everything + -- pretty stupid to check for this since we + -- need common to invoke the check, but anyway + "cfxZones", -- Zones, of course + "cfxCommander", -- to make troops do stuff +-- "cfxGroundTroops", -- optional, used for attackers only +} + +cfxOwnedZones.zones = {} +cfxOwnedZones.ups = 1 +cfxOwnedZones.initialized = false +cfxOwnedZones.defendingTime = 100 -- 100 seconds until new defenders are produced +cfxOwnedZones.attackingTime = 300 -- 300 seconds until new attackers are produced +cfxOwnedZones.shockTime = 200 -- 200 -- 'shocked' period of inactivity +cfxOwnedZones.repairTime = 200 -- 200 -- time until we raplace one lost unit, also repairs all other units to 100% + +-- owned zones is a module that managers 'conquerable' zones and keeps a +-- record of who owns the zone +-- based on some simple rules that are regularly checked + +-- +-- *** EXTENTDS ZONES ***, so compatible with cfxZones, pilotSafe (limited airframes), may conflict with FARPZones +-- + +-- owned zones are identified by the 'owner' property. It can be initially set to nothing (default), NEUTRAL, RED or BLUE + +-- when a zone changes hands, a callback can be installed to be told of that fact +-- callback has the format (zone, newOwner, formerOwner) with zone being the Zone, and new owner and former owners +cfxOwnedZones.conqueredCallbacks = {} + +-- +-- zone attributes when owned +-- owner: coalition that owns the zone +-- status: FSM for spawning +-- defendersRED/BLUE - coma separated type string for the group to spawm on defense cycle completion +-- attackersRED/BLUE - as above for attack cycle. +-- timeStamp - time when zone switched into current state +-- spawnRadius - overrides zone's radius when placing defenders. can be use to place defenders inside or outside zone itself +-- formation - defender's formation +-- attackFormation - attackers formation +-- attackRadius - radius of circle in which attackers are spawned. informs formation +-- attackDelta - polar coord: r from zone center where attackers are spawned +-- attackPhi - polar degrees where attackers are to be spawned +-- paused - will not spawn. default is false +-- unbeatable - can't be conquered by other side. default is false +-- untargetable - will not be targeted by either side. make unbeatable +-- owned zones untargetable, or they'll become a troop magnet for +-- zoneAttackers +-- hidden - if set (default no), it no markings on the map +-- +-- to create an owned zone that can't be conquered and does nothing +-- add the following properties to a zone +-- owner = , paused = true, unbeatable = true + +-- +-- callback handling +-- + +function cfxOwnedZones.addCallBack(conqCallback) + local cb = {} + cb.callback = conqCallback -- we use this so we can add more data later + cfxOwnedZones.conqueredCallbacks[conqCallback] = cb + +end + +function cfxOwnedZones.invokeConqueredCallbacks(aZone, newOwner, lastOwner) + for key, cb in pairs (cfxOwnedZones.conqueredCallbacks) do + cb.aZone = aZone -- set these up for if we need them later + cb.newOwner = newOwner + cb.lastOwner = lastOwner + -- invoke callback + cb.callback(aZone, newOwner, lastOwner) + end +end + +function cfxOwnedZones.side2name(theSide) + if theSide == 1 then return "REDFORCE" end + if theSide == 2 then return "BLUEFORCE" end + return "Neutral" +end + +function cfxOwnedZones.conqTemplate(aZone, newOwner, lastOwner) + if true then return end -- do not output + + if lastOwner == 0 then + trigger.action.outText(cfxOwnedZones.side2name(newOwner) .. " have taken possession zone " .. aZone.name, 30) + return + end + + trigger.action.outText("Zone " .. aZone.name .. " was taken by ".. cfxOwnedZones.side2name(newOwner) .. " from " .. cfxOwnedZones.side2name(lastOwner), 30) +end + +-- +-- M I S C +-- + +function cfxOwnedZones.drawZoneInMap(aZone) + -- will save markID in zone's markID + if aZone.markID then + trigger.action.removeMark(aZone.markID) + end + if aZone.hidden then return end + + local lineColor = {1.0, 0, 0, 1.0} -- red + local fillColor = {1.0, 0, 0, 0.2} -- red + local owner = cfxOwnedZones.getOwnerForZone(aZone) + if owner == 2 then + lineColor = {0.0, 0, 1.0, 1.0} + fillColor = {0.0, 0, 1.0, 0.2} + elseif owner == 0 then + lineColor = {0.8, 0.8, 0.8, 1.0} + fillColor = {0.8, 0.8, 0.8, 0.2} + end + + local theShape = 2 -- circle + local markID = dcsCommon.numberUUID() + + trigger.action.circleToAll(-1, markID, aZone.point, aZone.radius, lineColor, fillColor, 1, true, "") + aZone.markID = markID + +end + + +function cfxOwnedZones.addOwnedZone(aZone) + local owner = cfxZones.getCoalitionFromZoneProperty(aZone, "owner", 0) -- is already readm read it again + + aZone.owner = owner -- add this attribute to zone + + -- now init all other owned zone properties + aZone.state = "init" + aZone.timeStamp = timer.getTime() + --aZone.defendersRED = "Soldier M4,Soldier M4,Soldier M4,Soldier M4,Soldier M4" -- vehicles allocated to defend when red + + aZone.defendersRED = cfxZones.getStringFromZoneProperty(aZone, "defendersRED", "none") + aZone.defendersBLUE = cfxZones.getStringFromZoneProperty(aZone, "defendersBLUE", "none") + + aZone.attackersRED = cfxZones.getStringFromZoneProperty(aZone, "attackersRED", "none") + aZone.attackersBLUE = cfxZones.getStringFromZoneProperty(aZone, "attackersBLUE", "none") + + local formation = cfxZones.getZoneProperty(aZone, "formation") + if not formation then formation = "circle_out" end + aZone.formation = formation + formation = cfxZones.getZoneProperty(aZone, "attackFormation") + if not formation then formation = "circle_out" end + aZone.attackFormation = formation + local spawnRadius = cfxZones.getNumberFromZoneProperty(aZone, "spawnRadius", aZone.radius-5) -- "-5" so they remaininside radius + + aZone.spawnRadius = spawnRadius + + local attackRadius = cfxZones.getNumberFromZoneProperty(aZone, "attackRadius", aZone.radius) + aZone.attackRadius = attackRadius + local attackDelta = cfxZones.getNumberFromZoneProperty(aZone, "attackDelta", 10) -- aZone.radius) + aZone.attackDelta = attackDelta + local attackPhi = cfxZones.getNumberFromZoneProperty(aZone, "attackPhi", 0) + aZone.attackPhi = attackPhi + + local paused = cfxZones.getBoolFromZoneProperty(aZone, "paused", false) + aZone.paused = paused + + aZone.unbeatable = cfxZones.getBoolFromZoneProperty(aZone, "unbeatable", false) + aZone.untargetable = cfxZones.getBoolFromZoneProperty(aZone, "untargetable", false) + aZone.hidden = cfxZones.getBoolFromZoneProperty(aZone, "hidden", false) + cfxOwnedZones.zones[aZone] = aZone + cfxOwnedZones.drawZoneInMap(aZone) + cfxOwnedZones.verifyZone(aZone) +end + +function cfxOwnedZones.verifyZone(aZone) + -- do some sanity checks + if not cfxGroundTroops and (aZone.attackersRED ~= "none" or aZone.attackersBLUE ~= "none") then + trigger.action.outText("+++owdZ: " .. aZone.name .. " attackers need cfxGroundTroops to function") + end + +end + +function cfxOwnedZones.getOwnerForZone(aZone) + local theZone = cfxOwnedZones.zones[aZone] + if not theZone then return 0 end -- unknown zone, return neutral as default + return theZone.owner +end + +function cfxOwnedZones.getEnemyZonesFor(aCoalition) + local enemyZones = {} + local ourEnemy = dcsCommon.getEnemyCoalitionFor(aCoalition) + for zKey, aZone in pairs(cfxOwnedZones.zones) do + if aZone.owner == ourEnemy then -- only check enemy owned zones + -- note: will include untargetable zones + table.insert(enemyZones, aZone) + end + end + return enemyZones +end + +function cfxOwnedZones.getNearestOwnedZoneToPoint(aPoint) + local shortestDist = math.huge + local closestZone = nil + + for zKey, aZone in pairs(cfxOwnedZones.zones) do + local zPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(zPoint, aPoint) + if aZone.untargetable ~= true and + currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + + return closestZone, shortestDist +end + +function cfxOwnedZones.getNearestOwnedZone(theZone) + local shortestDist = math.huge + local closestZone = nil + local aPoint = cfxZones.getPoint(theZone) + for zKey, aZone in pairs(cfxOwnedZones.zones) do + local zPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(zPoint, aPoint) + if aZone.untargetable ~= true and currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + + return closestZone, shortestDist +end + +function cfxOwnedZones.getNearestEnemyOwnedZone(theZone, targetNeutral) + if not targetNeutral then targetNeutral = false else targetNeutral = true end + local shortestDist = math.huge + local closestZone = nil + local ourEnemy = dcsCommon.getEnemyCoalitionFor(theZone.owner) + if not ourEnemy then return nil end -- we called for a neutral zone. they have no enemies + local zPoint = cfxZones.getPoint(theZone) + + for zKey, aZone in pairs(cfxOwnedZones.zones) do + if targetNeutral then + -- return all zones that do not belong to us + if aZone.owner ~= theZone.owner then + local aPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(aPoint, zPoint) + if aZone.untargetable ~= true and currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + else + -- return zones that are taken by the Enenmy + if aZone.owner == ourEnemy then -- only check own zones + local aPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(zPoint, aPoint) + if aZone.untargetable ~= true and currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + end + end + + return closestZone, shortestDist +end + +function cfxOwnedZones.getNearestFriendlyZone(theZone, targetNeutral) + if not targetNeutral then targetNeutral = false else targetNeutral = true end + local shortestDist = math.huge + local closestZone = nil + local ourEnemy = dcsCommon.getEnemyCoalitionFor(theZone.owner) + if not ourEnemy then return nil end -- we called for a neutral zone. they have no enemies nor friends, all zones would be legal. + local zPoint = cfxZones.getPoint(theZone) + for zKey, aZone in pairs(cfxOwnedZones.zones) do + if targetNeutral then + -- target all zones that do not belong to the enemy + if aZone.owner ~= ourEnemy then + local aPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(zPoint, aPoint) + if aZone.untargetable ~= true and currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + else + -- only target zones that are taken by us + if aZone.owner == theZone.owner then -- only check own zones + local aPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(zPoint, aPoint) + if aZone.untargetable ~= true and currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + end + end + + return closestZone, shortestDist +end + +function cfxOwnedZones.enemiesRemaining(aZone) + if cfxOwnedZones.getNearestEnemyOwnedZone(aZone) then return true end + return false +end + +function cfxOwnedZones.spawnAttackTroops(theTypes, aZone, aCoalition, aFormation) + local unitTypes = {} -- build type names + -- split theTypes into an array of types + unitTypes = dcsCommon.splitString(theTypes, ",") + if #unitTypes < 1 then + table.insert(unitTypes, "Soldier M4") -- make it one m4 trooper as fallback + -- simply exit, no troops specified + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: no attackers for " .. aZone.name .. ". exiting", 30) + end + return + end + + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: spawning attackers for " .. aZone.name, 30) + end + + --local theCountry = dcsCommon.coalition2county(aCoalition) + + local spawnPoint = {x = aZone.point.x, y = aZone.point.y, z = aZone.point.z} -- copy struct + + local rads = aZone.attackPhi * 0.01745 + spawnPoint.x = spawnPoint.x + math.cos(aZone.attackPhi) * aZone.attackDelta + spawnPoint.y = spawnPoint.y + math.sin(aZone.attackPhi) * aZone.attackDelta + + local spawnZone = cfxZones.createSimpleZone("attkSpawnZone", spawnPoint, aZone.attackRadius) + + local theGroup = cfxZones.createGroundUnitsInZoneForCoalition ( + aCoalition, -- theCountry, + aZone.name .. " (A) " .. dcsCommon.numberUUID(), -- must be unique + spawnZone, + unitTypes, + aFormation, -- outward facing + 0) + return theGroup +end + +function cfxOwnedZones.spawnDefensiveTroops(theTypes, aZone, aCoalition, aFormation) + local unitTypes = {} -- build type names + -- split theTypes into an array of types + unitTypes = dcsCommon.splitString(theTypes, ",") + if #unitTypes < 1 then + table.insert(unitTypes, "Soldier M4") -- make it one m4 trooper as fallback + -- simply exit, no troops specified + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: no defenders for " .. aZone.name .. ". exiting", 30) + end + return + end + + --local theCountry = dcsCommon.coalition2county(aCoalition) + local spawnZone = cfxZones.createSimpleZone("spawnZone", aZone.point, aZone.spawnRadius) + local theGroup = cfxZones.createGroundUnitsInZoneForCoalition ( + aCoalition, --theCountry, + aZone.name .. " (D) " .. dcsCommon.numberUUID(), -- must be unique + spawnZone, unitTypes, + aFormation, -- outward facing + 0) + return theGroup +end +-- +-- U P D A T E +-- + +function cfxOwnedZones.sendOutAttackers(aZone) + -- only spawn if there are zones to attack + if not cfxOwnedZones.enemiesRemaining(aZone) then + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ - no enemies, resting ".. aZone.name, 30) + end + return + end + + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ - attack cycle for ".. aZone.name, 30) + end + -- load the attacker typestring + + -- step one: get the attackers + local attackers = aZone.attackersRED; + if (aZone.owner == 2) then attackers = aZone.attackersBLUE end + + if attackers == "none" then return end + + local theGroup = cfxOwnedZones.spawnAttackTroops(attackers, aZone, aZone.owner, aZone.attackFormation) + + -- submit them to ground troops handler as zoneseekers + -- and our groundTroops module will handle the rest + if cfxGroundTroops then + local troops = cfxGroundTroops.createGroundTroops(theGroup) + troops.orders = "attackOwnedZone" + troops.side = aZone.owner + cfxGroundTroops.addGroundTroopsToPool(troops) -- hand off to ground troops + else + if cfxOwnedZones.verbose then + trigger.action.outText("+++ Owned Zones: no ground troops module on send out attackers", 30) + end + end +end + +-- bang support + +function cfxOwnedZones.bangNeutral(value) + if not cfxOwnedZones.neutralTriggerFlag then return end + local newVal = trigger.misc.getUserFlag(cfxOwnedZones.neutralTriggerFlag) + value + trigger.action.setUserFlag(cfxOwnedZones.neutralTriggerFlag, newVal) +end + +function cfxOwnedZones.bangRed(value) + if not cfxOwnedZones.redTriggerFlag then return end + local newVal = trigger.misc.getUserFlag(cfxOwnedZones.redTriggerFlag) + value + trigger.action.setUserFlag(cfxOwnedZones.redTriggerFlag, newVal) +end + +function cfxOwnedZones.bangBlue(value) + if not cfxOwnedZones.blueTriggerFlag then return end + local newVal = trigger.misc.getUserFlag(cfxOwnedZones.blueTriggerFlag) + value + trigger.action.setUserFlag(cfxOwnedZones.blueTriggerFlag, newVal) +end + +function cfxOwnedZones.bangSide(theSide, value) + if theSide == 2 then + cfxOwnedZones.bangBlue(value) + return + end + if theSide == 1 then + cfxOwnedZones.bangRed(value) + return + end + cfxOwnedZones.bangNeutral(value) +end + +function cfxOwnedZones.zoneConquered(aZone, theSide, formerOwner) -- 0 = neutral 1 = RED 2 = BLUE + local who = "REDFORCE" + if theSide == 2 then who = "BLUEFORCE" end + if cfxOwnedZones.announcer then + trigger.action.outText(who .. " have secured zone " .. aZone.name, 30) + aZone.owner = theSide + -- play different sounds depending on who's won + if theSide == 1 then + trigger.action.outSoundForCoalition(1, "Quest Snare 3.wav") + trigger.action.outSoundForCoalition(2, "Death BRASS.wav") + else + trigger.action.outSoundForCoalition(2, "Quest Snare 3.wav") + trigger.action.outSoundForCoalition(1, "Death BRASS.wav") + end + end + -- invoke callbacks now + cfxOwnedZones.invokeConqueredCallbacks(aZone, theSide, formerOwner) + + -- bang! flag support + cfxOwnedZones.bangSide(theSide, 1) -- winner + cfxOwnedZones.bangSide(formerOwner, -1) -- loser + + -- update map + cfxOwnedZones.drawZoneInMap(aZone) -- update status in map. will erase previous version + -- remove all defenders to avoid shock state + aZone.defenders = nil + + -- change to captured + + aZone.state = "captured" + aZone.timeStamp = timer.getTime() +end + +function cfxOwnedZones.repairDefenders(aZone) + --trigger.action.outText("+++ enter repair for ".. aZone.name, 30) + -- find a unit that is missing from my typestring and replace it + -- one by one until we are back to full strength + -- step one: get the defenders and create a type array + local defenders = aZone.defendersRED; + if (aZone.owner == 2) then defenders = aZone.defendersBLUE end + local unitTypes = {} -- build type names + + -- if none, we are done + if defenders == "none" then return end + + -- split theTypes into an array of types + allTypes = dcsCommon.trimArray( + dcsCommon.splitString(defenders, ",") + ) + local livingTypes = {} -- init to emtpy, so we can add to it if none are alive + if (aZone.defenders) then + -- some remain. add one of the killed + livingTypes = dcsCommon.getGroupTypes(aZone.defenders) + -- we now iterate over the living types, and remove their + -- counterparts from the allTypes. We then take the first that + -- is left + + if #livingTypes > 0 then + for key, aType in pairs (livingTypes) do + if not dcsCommon.findAndRemoveFromTable(allTypes, aType) then + trigger.action.outText("+++OwdZ WARNING: found unmatched type <" .. aType .. "> while trying to repair defenders for ".. aZone.name, 30) + else + -- all good + end + end + end + end + + -- when we get here, allTypes is reduced to those that have been killed + if #allTypes < 1 then + trigger.action.outText("+++owdZ: WARNING: all types exist when repairing defenders for ".. aZone.name, 30) + else + table.insert(livingTypes, allTypes[1]) -- we simply use the first that we find + end + -- remove the old defenders + if aZone.defenders then + aZone.defenders:destroy() + end + + -- now livingTypes holds the full array of units we need to spawn + local theCountry = dcsCommon.getACountryForCoalition(aZone.owner) + local spawnZone = cfxZones.createSimpleZone("spawnZone", aZone.point, aZone.spawnRadius) + local theGroup = cfxZones.createGroundUnitsInZoneForCoalition ( + aZone.owner, -- was wrongly: theCountry + aZone.name .. dcsCommon.numberUUID(), -- must be unique + spawnZone, + livingTypes, + + aZone.formation, -- outward facing + 0) + aZone.defenders = theGroup + aZone.lastDefenders = theGroup:getSize() +end + +function cfxOwnedZones.inShock(aZone) + -- a unit was destroyed, everyone else is in shock, no rerpairs + -- group can re-shock when another unit is destroyed +end + +function cfxOwnedZones.spawnDefenders(aZone) + local defenders = aZone.defendersRED; + + if (aZone.owner == 2) then defenders = aZone.defendersBLUE end + -- before we spawn new defenders, remove the old ones + if aZone.defenders then + if aZone.defenders:isExist() then + aZone.defenders:destroy() + end + aZone.defenders = nil + end + + -- if 'none', simply exit + if defenders == "none" then return end + + local theGroup = cfxOwnedZones.spawnDefensiveTroops(defenders, aZone, aZone.owner, aZone.formation) + -- the troops reamin, so no orders to move, no handing off to ground troop manager + aZone.defenders = theGroup; + if theGroup then + aZone.defenderMax = theGroup:getInitialSize() -- so we can determine if some units were destroyed + aZone.lastDefenders = aZone.defenderMax -- if this is larger than current number, someone bit the dust + --trigger.action.outText("+++ spawned defenders for ".. aZone.name, 30) + else + trigger.action.outText("+++owdZ: WARNING: spawned no defenders for ".. aZone.name, 30) + end +end + +-- +-- per-zone update, run down the FSM to determine what to do. +-- FSM uses timeStamp since when state was set. Possible states are +-- - init -- has just been inited for the first time. will usually immediately produce defenders, +-- and then transition to defending +-- - catured -- has just been captured. transition to defending +-- - defending -- wait until timer has reached goal, then produce defending units and transition to attacking. +-- - attacking -- wait until timer has reached goal, and then produce attacking units and send them to closest enemy zone. +-- state is interrupted as soon as a defensive unit is lost. state then goes to defending with timer starting +-- - idle - do nothing, zone's actions are turned off +-- - shocked -- a unit was destroyed. group is in shock for a time until it starts repairing. If another unit is +-- destroyed during the shocked period, the timer resets to zero and repairs are delayed +-- - repairing -- as long as we aren't at full strength, units get replaced one by one until at full strength +-- each time the timer counts down, another missing unit is replaced, and all other unit's health +-- is reset to 100% +-- +-- a Zone with the paused attribute set to true will cause it to not do anything +-- +-- check if defenders are specified +function cfxOwnedZones.usesDefenders(aZone) + if aZone.owner == 0 then return false end + local defenders = aZone.defendersRED; + if (aZone.owner == 2) then defenders = aZone.defendersBLUE end + + return defenders ~= "none" +end + +function cfxOwnedZones.usesAttackers(aZone) + if aZone.owner == 0 then return false end + local attackers = aZone.attackersRED; + if (aZone.owner == 2) then defenders = aZone.attackersBLUE end + + return attackers ~= "none" +end + +function cfxOwnedZones.updateZone(aZone) + -- a zone can be paused, causing it to not progress anything + -- even if zone status is still init, will NOT produce anything + -- if paused is on. + if aZone.paused then return end + + nextState = aZone.state; + + -- first, check if my defenders have been attacked and one of them has been killed + -- if so, we immediately switch to 'shocked' + if cfxOwnedZones.usesDefenders(aZone) and + aZone.defenders then + -- we have defenders + if aZone.defenders:isExist() then + -- isee if group was damaged + if aZone.defenders:getSize() < aZone.lastDefenders then + -- yes, at least one unit destroyed + aZone.timeStamp = timer.getTime() + aZone.lastDefenders = aZone.defenders:getSize() + if aZone.lastDefenders == 0 then + aZone.defenders = nil + end + aZone.state = "shocked" + + return + end + + else + -- group was destroyed. erase link, and go into shock for the last time + aZone.state = "shocked" + aZone.timeStamp = timer.getTime() + aZone.lastDefenders = 0 + aZone.defenders = nil + + return + end + end + + + if aZone.state == "init" then + -- during init we instantly create the defenders since + -- we assume the zone existed already + if aZone.owner > 0 then + cfxOwnedZones.spawnDefenders(aZone) + -- now drop into attacking mode to produce attackers + nextState = "attacking" + else + nextState = "idle" + end + + aZone.timeStamp = timer.getTime() + + elseif aZone.state == "idle" then + -- nothing to do, zone is effectively switched off. + -- used for neutal zones or when forced to turn off + -- in some special cases + + elseif aZone.state == "captured" then + -- start the clock on defenders + nextState = "defending" + aZone.timeStamp = timer.getTime() + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) + end + elseif aZone.state == "defending" then + if timer.getTime() > aZone.timeStamp + cfxOwnedZones.defendingTime then + cfxOwnedZones.spawnDefenders(aZone) + -- now drop into attacking mode to produce attackers + nextState = "attacking" + aZone.timeStamp = timer.getTime() + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) + end + end + + elseif aZone.state == "repairing" then + -- we are currently rebuilding defenders unit by unit + if timer.getTime() > aZone.timeStamp + cfxOwnedZones.repairTime then + aZone.timeStamp = timer.getTime() + cfxOwnedZones.repairDefenders(aZone) + if aZone.defenders:getSize() >= aZone.defenderMax then +-- + -- we are at max size, time to produce some attackers + nextState = "attacking" + aZone.timeStamp = timer.getTime() + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) + end + end + -- see if we are full strenght and if so go to attack, else set timer to reair the next unit + end + + elseif aZone.state == "shocked" then + -- we are currently rebuilding defenders unit by unit + if timer.getTime() > aZone.timeStamp + cfxOwnedZones.shockTime then + nextState = "repairing" + aZone.timeStamp = timer.getTime() + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) + end + end + + elseif aZone.state == "attacking" then + if timer.getTime() > aZone.timeStamp + cfxOwnedZones.attackingTime then + cfxOwnedZones.sendOutAttackers(aZone) + -- reset timer + aZone.timeStamp = timer.getTime() + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: State " .. aZone.state .. " reset for " .. aZone.name, 30) + end + end + else + -- unknown zone state + end + aZone.state = nextState +end + +function cfxOwnedZones.update() + cfxOwnedZones.updateSchedule = timer.scheduleFunction(cfxOwnedZones.update, {}, timer.getTime() + 1/cfxOwnedZones.ups) + + -- iterate all zones, and determine their current ownership status + for key, aZone in pairs(cfxOwnedZones.zones) do + -- a hand change can only happen if there are only ground troops from the OTHER side in + -- the zone + local categ = Group.Category.GROUND + local theBlues = cfxZones.groupsOfCoalitionPartiallyInZone(2, aZone, categ) + local theReds = cfxZones.groupsOfCoalitionPartiallyInZone(1, aZone, categ) + local currentOwner = aZone.owner + if #theBlues > 0 and #theReds == 0 and aZone.unbeatable ~= true then + -- this now belongs to blue + if currentOwner ~= 2 then + cfxOwnedZones.zoneConquered(aZone, 2, currentOwner) + end + elseif #theBlues == 0 and #theReds > 0 and aZone.unbeatable ~= true then + -- this now belongs to red + if currentOwner ~= 1 then + cfxOwnedZones.zoneConquered(aZone, 1, currentOwner) + end + end + + -- now, perhaps with their new owner call updateZone() + cfxOwnedZones.updateZone(aZone) + end + +end + +function cfxOwnedZones.sideOwnsAll(theSide) + for key, aZone in pairs(cfxOwnedZones.zones) do + if aZone.owner ~= theSide then + return false + end + end + -- if we get here, all your base are belong to us + return true +end + +function cfxOwnedZones.hasOwnedZones() + for idx, zone in pairs (cfxOwnedZones.zones) do + return true -- even the first returns true + end + -- no owned zones + return false +end + +function cfxOwnedZones.readConfigZone(theZone) + cfxOwnedZones.verbose = cfxZones.getBoolFromZoneProperty(theZone, "verbose", false) + cfxOwnedZones.announcer = cfxZones.getBoolFromZoneProperty(theZone, "announcer", true) + + if cfxZones.hasProperty(theZone, "r!") then + cfxOwnedZones.redTriggerFlag = cfxZones.getStringFromZoneProperty(theZone, "r!", "") + end + if cfxZones.hasProperty(theZone, "b!") then + cfxOwnedZones.blueTriggerFlag = cfxZones.getStringFromZoneProperty(theZone, "b!", "") + end + if cfxZones.hasProperty(theZone, "n!") then + cfxOwnedZones.neutralTriggerFlag = cfxZones.getStringFromZoneProperty(theZone, "n!", "") + end + cfxOwnedZones.defendingTime = cfxZones.getNumberFromZoneProperty(theZone, "defendingTime", 100) + cfxOwnedZones.attackingTime = cfxZones.getNumberFromZoneProperty(theZone, "attackingTime", 300) + cfxOwnedZones.shockTime = cfxZones.getNumberFromZoneProperty(theZone, "shockTime", 200) + cfxOwnedZones.repairTime = cfxZones.getNumberFromZoneProperty(theZone, "repairTime", 200) +end + +function cfxOwnedZones.init() + -- check libs + if not dcsCommon.libCheck("cfx Owned Zones", + cfxOwnedZones.requiredLibs) then + return false + end + + -- read my config zone + local theZone = cfxZones.getZoneByName("ownedZonesConfig") + if not theZone then + trigger.action.outText("+++ownZ: no config", 30) + else + cfxOwnedZones.readConfigZone(theZone) + end + + -- collect all owned zones by their 'owner' property + -- start the process + local pZones = cfxZones.zonesWithProperty("owner") + + -- now add all zones to my zones table, and convert the owner property into + -- a proper attribute + for k, aZone in pairs(pZones) do + cfxOwnedZones.addOwnedZone(aZone) + end + + initialized = true + cfxOwnedZones.updateSchedule = timer.scheduleFunction(cfxOwnedZones.update, {}, timer.getTime() + 1/cfxOwnedZones.ups) + + trigger.action.outText("cx/x owned zones v".. cfxOwnedZones.version .. " started", 30) + + return true +end + +if not cfxOwnedZones.init() then + trigger.action.outText("cf/x Owned Zones aborted: missing libraries", 30) + cfxOwnedZones = nil +end + + + diff --git a/modules/cfxPlayer.lua b/modules/cfxPlayer.lua new file mode 100644 index 0000000..ca2029a --- /dev/null +++ b/modules/cfxPlayer.lua @@ -0,0 +1,644 @@ +-- cfx player handler for DCS Missions by cf/x AG +-- +-- a module that provides easy access to a mission's player data +-- multi-player only +-- + +cfxPlayer = {} + -- a call to cfxPlayer.start() +cfxPlayer.version = "3.0.1" +--[[-- VERSION HISTORY + +- 2.2.3 - fixed isPlayerUnit() wrong return of true instead of nil +- 2.2.4 - getFirstGroupPlayer +- 2.3.0 - added event filtering for monitors + - limited code clean-up + - removed XXXmatchUnitToPlayer + - corrected isPlayerUnit once more + - removed autostart option + - removed detectPlayersLeaving option +- 3.0.0 - added detection of network players + - added new events newPlayer, changePlayer + - and leavePlayer (never called) +- 3.0.1 - isPlayerUnit guard against scenery object or map object +--]]-- + +cfxPlayer.verbose = false; +cfxPlayer.running = false +cfxPlayer.ups = 1 -- updates per second: how often do we query the players + -- a good value is 1 +cfxPlayer.playerDB = {} -- the list of all player UNITS + -- attributes + -- name - name of unit occupied by player + -- unit - unit this player is controlling + -- unitName - same as name + -- group - group this unit belongs to. can't change without also changing unit + -- groupName - name of group + -- coalition +cfxPlayerGroups = {} -- GLOBAL VAR +-- list of all current groups that have players in them +-- can call out to handlers if group is added or removed +-- use this in MP games to organise messaging and keep score +-- by default, groupinfo merely contains the .group reference +-- and is accessed by name as key which is also accessible by .name + +cfxPlayer.netPlayers = {} -- new for version 3: real player detection +-- a dict sorted by player name that containts the unit name for last pass + +cfxPlayer.updateSchedule = 0 -- ID used for scheduling update +cfxPlayer.coalitionSides = {0, 1, 2} -- we currently have neutral, red, blue + +cfxPlayer.monitors = {} -- callbacks for events + +--- +-- structure of playerInfo +-- - name - player's unit name +-- - unit - the unit the player is occupying. Multi-Crew: many people can be in same unit +-- - unitName same as name + +-- - coalition - the side the unit is on, as a number +function cfxPlayer.dumpRawPlayers() + trigger.action.outText("+++ debug: raw player dump ---", 30) + for i=1, #cfxPlayer.coalitionSides do + local theSide = cfxPlayer.coalitionSides[i] + -- get all players for this side + local thePlayers = coalition.getPlayers(theSide) + for p=1, #thePlayers do + aPlayerUnit = thePlayers[p] -- docs say this is a unit table, not a person table! + trigger.action.outText(i .. "-" .. p ..": unit: " .. aPlayerUnit:getName() .. " controlled by " .. aPlayerUnit:getPlayerName() , 30) + end + end + trigger.action.outText("+++ debug: END DUMP ----", 30) +end + + +function cfxPlayer.getAllPlayers() + return cfxPlayer.playerDB -- get entire db. make sure not to screw around with it +end + + +function cfxPlayer.getPlayerInfoByName(theUnitName) -- note: UNIT name + thePlayer = cfxPlayer.playerDB[theUnitName] -- access the entry, not we are accessing by unit name + return thePlayer +end + +function cfxPlayer.getPlayerInfoByIndex(theIndex) + local enumeratedInfo = dcsCommon.enumerateTable(cfxPlayer.playerDB) + if (theIndex > #enumeratedInfo) then + trigger.action.outText("WARNING: player index " .. theIndex .. " out of bounds - max = " .. #enumeratedInfo, 30) + return nil end + if (theIndex < 1) then return nil end + + return enumeratedInfo[theIndex] +end + +-- this is now a true/false function that returns true if unit is player +function cfxPlayer.XXXmatchUnitToPlayer(theUnit) -- what's difference to getPlayerInfo? GetPlayerInfo ALLOCATES if not exists + + if not (theUnit) then return false end + if not (theUnit:isExist()) then return false end + + -- PATCH: if theUnit:getPlayerName() returns anything but nil + -- this is a player unit + -- unfortunately, this can sometimes fail + -- so make sure the function existst + -- it failed because the next level up function + -- returned true if i returned anything but nil, and I return + -- true or false, both not nil + -- this proc works + if not theUnit.getPlayerName then return false end + + local pName = theUnit:getPlayerName() + if pName ~= nil then + -- trigger.action.outText("+++matchUnit: player name " .. pName .. " for unit " .. theUnit:getName(), 30) + return true + end + + if (true) then + return false + end + + -- ignmore old code below + + for pname, pInfo in pairs(cfxPlayer.playerDB) do + if (pInfo.unit == theUnit) then + return pInfo + end + end + return nil +end + +function cfxPlayer.XXXisPlayerUnitAlt(theUnit) + for pname, pInfo in pairs(cfxPlayer.playerDB) do + if (pInfo.unit == theUnit) then + return true + end + end + return false +end + +function cfxPlayer.isPlayerUnit(theUnit) + -- new patch. simply check if getPlayerName returns something + if not theUnit then return false end + if not theUnit.getPlayerName then return false end -- map/static object + local pName = theUnit:getPlayerName() + if pName then return true end + return false + -- + -- fixed, erroneously expected a nil from matchUnitToPlayer + --return (cfxPlayer.matchUnitToPlayer(theUnit)) -- was: ~=nil, wrong because match returns true/false +end + + + +function cfxPlayer.getPlayerUnitType(thePlayerInfo) -- + if (thePlayerInfo) then + theUnit = thePlayerInfo.unit + if (theUnit) and (theUnit:isExist()) then + return theUnit:getTypeName() + end + end + return nil +end + + +-- get player's unit info +-- accesses player DB and returns the player's info record for the +-- player's Unit. If record does not exist in db, a new record is allocated +-- returns true if verification succeeeds: player unit existed before, and +-- false otherwise. in the latter case, A NEW playerInfo object is returned +function cfxPlayer.getPlayerInfo(theUnit) + local playerName = theUnit:getPlayerName() -- retrieve the name + --- PATCH!!!!!!! + --- on multi-crew, we only have the pilot as getPlayerName. + --- we now switch to the unit's name instead + playerName = theUnit:getName() + + -- trigger.action.outText("Player: ".. playerName, 10) + + local existingPlayer = cfxPlayer.getPlayerInfoByName(playerName) -- try and access DB + if existingPlayer then + -- this player exists in the db. return the record + return true, existingPlayer; + + else + -- this is a new player. + -- set up a new playerinfo record for this name + local newPlayerInfo = {} + newPlayerInfo.name = playerName + newPlayerInfo.unit = theUnit + newPlayerInfo.unitName = theUnit:getName() + newPlayerInfo.group = theUnit:getGroup() + newPlayerInfo.groupName = newPlayerInfo.group:getName() + newPlayerInfo.coalition = theUnit:getCoalition() -- seems to work when first param is class self + -- note that this record did not exist, and return record + return false, newPlayerInfo + end + +end; + +function cfxPlayer.getSinglePlayerAirframe() + -- ALWAYS return a string! This is for debugging purposes + local thePlayers = {} + local count = 0 + local theAirframe = "(none)" + for pname, pinfo in pairs(cfxPlayer.playerDB) do + count = count + 1 + theAirframe = pinfo.unit:getTypeName() + end + if count < 2 then return theAirframe end -- also returns if count == 0 + return "" +end + +function cfxPlayer.getAnyPlayerAirframe() + -- use this for debugging, in single-player missions, or where it + -- is unimportant which player, just a player + -- assumes that all players use the same airframe or are in the + -- same group / unit + for pname, pinfo in pairs(cfxPlayer.playerDB) do + if (pinfo.unit:isExist()) then + -- player may just have crashed or left + local theAirframe = pinfo.unit:getTypeName() + return theAirframe -- we simply stop after first successfuly access + end + end + return "error: no player" +end + +function cfxPlayer.getFirstGroupPlayerName(theGroup) + -- get the name of player of the first + -- player-controlled unit I come across in + -- this group + local allGroupUnits = theGroup:getUnits() + for ukey, uvalue in pairs(allGroupUnits) do + -- iterate units in group + if (uvalue:isExist()) then -- and cfxPlayer.isPlayerUnit(uvalue)) + -- player may just have crashed or left + -- but all units in the same group have the same type when they are aircraft + if cfxPlayer.isPlayerUnit(uvalue) then + return uvalue:getPlayerName(), uvalue + end + end + end + return nil +end + +function cfxPlayer.getAnyGroupPlayerAirframe(theGroup) + -- get the first player-driven unit in the group + -- and pass back the airframe that is being used + local allGroupUnits = theGroup:getUnits() + for ukey, uvalue in pairs(allGroupUnits) do + + if (uvalue:isExist()) then -- and cfxPlayer.isPlayerUnit(uvalue)) + -- player may just have crashed or left + -- but all units in the same group have the same type when they are aircraft + local theAirframe = uvalue:getTypeName() + return theAirframe -- we simply stop after first successfuly access + end + end + return "error: no live player in group " +end + + + +function cfxPlayer.getAnyPlayerPosition() + -- use this for debugging, in single-player missions, or where it + -- is unimportant which player, just a player + -- will cause issues when you derive location info or group info + -- from that player + for pname, pinfo in pairs(cfxPlayer.playerDB) do + if (pinfo.unit:isExist()) then + local thePoint = pinfo.unit:getPoint() + return thePoint -- we simply stop after first successfuly access + end + end + return nil +end + +function cfxPlayer.getAnyGroupPlayerPosition(theGroup) + -- enter with dcs group to search for player units within + -- step one: get all units that belong to that group + local allGroupUnits = theGroup:getUnits() + -- we now iterate all returned units and look for + -- a unit that is a player unit. + for ukey, uvalue in pairs(allGroupUnits) do + -- we currently assume single-unit groups for players + if (uvalue:isExist()) then -- and cfxPlayer.isPlayerUnit(uvalue)) + -- player may just have crashed or left + local thePoint = uvalue:getPoint() + return thePoint -- we simply stop after first successfuly access + end + + end + return nil +end + +function cfxPlayer.getAnyGroupPlayerInfo(theGroup) + for pname, pinfo in pairs(cfxPlayer.playerDB) do + if (pinfo.unit:isExist() and pinfo.group == theGroup) then + return pinfo -- we simply stop after first successfuly access + end + end + return "error: no player" +end + + +function cfxPlayer.getAllPlayerGroups() + -- merely accessot. better would be returning a copy + return cfxPlayerGroups +end + +function cfxPlayer.getGroupDataForGroupNamed(name) + if not name then return nil end + return cfxPlayerGroups[name] +end + +function cfxPlayer.getPlayersInGroup(theGroup) + if not theGroup then return {} end + if not theGroup:isExist() then return {} end + local gName = theGroup:getName() + local thePlayers = {} + + for pname, pinfo in pairs(cfxPlayer.playerDB) do + local pgName = "" + if pinfo.group:isExist() then pgName = pinfo.group:getName() end + if (gName == pgName) then + table.insert(thePlayers, pinfo) + end + end + return thePlayers +end + +-- update() is called regularly to check up on the players +-- when a mismatch to last player state is found, callbacks +-- can be invoked + +function cfxPlayer.update() + + -- first, re-schedule my next invocation + cfxPlayer.updateSchedule = timer.scheduleFunction(cfxPlayer.update, {}, timer.getTime() + 1/cfxPlayer.ups) + + -- now scan the coalitions for all players + local currCount = 0 -- number of players found this pass + local currDB = {} -- db of player units this pass + local currPlayerUnitsByNames = {} + -- iterate over all colaitions + for i=1, #cfxPlayer.coalitionSides do + local theSide = cfxPlayer.coalitionSides[i] + -- get all player units for this side + local thePlayers = coalition.getPlayers(theSide) -- returns UNITs!!! + + for p=1, #thePlayers do + -- we now iterate the Units and compare what we find + local thePlayerUnit = thePlayers[p] + local isExistingPlayerUnit, theInfo = cfxPlayer.getPlayerInfo(thePlayerUnit) + + if (not isExistingPlayerUnit) then + -- add Unit (not player!) to db + cfxPlayer.playerDB [theInfo.name] = theInfo + cfxPlayer.invokeMonitorsForEvent("new", "Player Unit " .. theInfo.name .. " entered mission", theInfo, {}) + + else + -- player's unit existed last time around + -- see if something changed: + +-- currently, we track units, not players. side changes for units can't happen AT ALL + + if theInfo.coalition ~= thePlayerUnit:getCoalition() then + local theData = {} + theData.old = theInfo.coalition + theData.new = thePlayerUnit:getCoalition() + + -- we invoke a callback + cfxPlayer.invokeMonitorsForEvent("side", "Player " .. theInfo.name .. " switched sides to " .. thePlayerUnit:getCoalition(), theInfo, theData) + + end; + +-- we now check if the player has changed groups +-- sinced we track units, this CANT HAPPEN AT ALL + + if theInfo.group ~= thePlayerUnit:getGroup() then + local theData = {} + theData.old = theInfo.group + theData.new = thePlayerUnit:getGroup() + cfxPlayer.invokeMonitorsForEvent("group", "Player changed group to " .. thePlayerUnit:getGroup():getName(), theInfo, theData) + trigger.action.outText("+++ debug: Player " .. theInfo.name .. " changed GROUP to: " .. thePlayerUnit:getGroup():getName(), 30) + end + + -- we should now check if the player has changed units +-- since we track units, this cant happen at all + if theInfo.unit ~= thePlayerUnit then + -- player changed unit + local theData = {} + theData.old = theInfo.unit + -- the old unit's name is still available in theInfo.unitName + theData.oldUnitName = theInfo.unitName + theData.new = thePlayerUnit + -- update Player Info + cfxPlayer.invokeMonitorsForEvent("unit", "Player changed unit to " .. thePlayerUnit:getName(), theInfo, theData) + + end + -- update the playerEntry. always done + theInfo.unit = thePlayerUnit + theInfo.unitName = thePlayerUnit:getName() + theInfo.coalition = thePlayerUnit:getCoalition() + theInfo.group = thePlayerUnit:getGroup() + end; + + -- add this entry to current pass db so we can detect + -- any discrepancies to last pass + currDB[theInfo.name] = theInfo + + -- now update current network player name db + local playerUnitName = thePlayerUnit:getName() + if not thePlayerUnit:isExist() then playerUnitName = "" end + currPlayerUnitsByNames[thePlayerUnit:getPlayerName()] = playerUnitName + end -- for all player units of this side + end -- for all sides + + -- we can now check if a player unit has disappeared + -- we do this by checking that all old entries from cfxPlayer.playerDB + -- have an existing counterpart in new currDB + for name, info in pairs(cfxPlayer.playerDB) do + local matchingEntry = currDB[name] + if matchingEntry then + -- allright nothing to do + else + -- whoa, this record is missing! + -- do we care? + if true then -- (cfxPlayer.detectPlayersLeaving) then + -- yes! trigger an event + cfxPlayer.invokeMonitorsForEvent("leave", "Player left mission", info, {}) + -- we don't need to destroy entry, as we simply replace the + -- playerDB with currDB at end of update + else + -- no, just copy old data over. They'll be back + currDB[name] = info + end + end + end; + + -- we now perform a group check and update all groups for players + local currPlayerGroups = {} + for pName, pInfo in pairs(currDB) do + -- retrieve player unit and make sure it still exists + local theUnit = pInfo.unit + if theUnit:isExist() then + -- yeah, it exists allright. let's get to the group + local theGroup = theUnit:getGroup() + local gName = theGroup:getName() + -- see if this group is new + local thePGroup = cfxPlayerGroups[gName] + if not thePGroup then + -- allocate new group + thePGroup = {} + thePGroup.group = theGroup + thePGroup.name = gName + thePGroup.primeUnit = theUnit -- may be used as fallback + thePGroup.primeUnitName = theUnit:getName() -- also fallback only + thePGroup.id = theGroup:getID() + cfxPlayer.invokeMonitorsForEvent("newGroup", "New Player Group " .. gName .. " appeared", nil, thePGroup) + end + currPlayerGroups[gName] = thePGroup -- update group table + end + end + + -- now check if a player group has disappeared + for gkey, gval in pairs(cfxPlayerGroups) do + if not currPlayerGroups[gkey] then + cfxPlayer.invokeMonitorsForEvent("removeGroup", "A Player Group " .. gkey .. " vanished", nil, gval) -- gval is OLD set, contains group + end + end + + -- version 3 addion: track network players + -- see if a new player has appeared + for aPlayerName, aPlayerUnitName in pairs(currPlayerUnitsByNames) do + -- see if this name was already in last + if cfxPlayer.netPlayers[aPlayerName] then + -- yes. but was it the same unit? + if cfxPlayer.netPlayers[aPlayerName] == currPlayerUnitsByNames[aPlayerName] then + -- all is well, no change + else + -- player has changed units + -- since they can't disappear, + -- this event can happen + local data = {} + data.oldUnitName = cfxPlayer.netPlayers[aPlayerName] + data.newUnitName = aPlayerUnitName + data.playerName = aPlayerName + if aPlayerUnitName == "" then aPlayerUnitName = "" end + if aPlayerUnitName == "" then + -- unit no longer exists, player probably dead, + -- parachuting or spectating. Maybe even left game + -- resgisters as 'change' -- is 'left unit' + cfxPlayer.invokeMonitorsForEvent("changePlayer", "A Player left unit " .. data.oldUnitName, nil, data) + else + -- changed to new unit + cfxPlayer.invokeMonitorsForEvent("changePlayer", "A Player changed to unit " .. aPlayerUnitName, nil, data) + end + end + else + -- this is a new player + local data = {} + data.playerName = aPlayerName + data.newUnitName = aPlayerUnitName + cfxPlayer.invokeMonitorsForEvent("newPlayer", "New Player appeared " .. aPlayerName .. " in unit " .. aPlayerUnitName, nil, data) + end + end + + -- version 3: detect if a player left + for oldPlayerName, oldUnitName in pairs(cfxPlayer.netPlayers) do + if not currPlayerUnitsByNames[oldPlayerName] then + --local data = {} + --data.playerName = oldPlayerName + --data.oldUnitName = oldUnitName + --cfxPlayer.invokeMonitorsForEvent("leavePlayer", "Player " .. oldPlayerName .. " disappeared from unit " .. oldUnitName, nil, data) + -- + -- we keep the player in the db by copying + -- it over and set the unit name to "" + -- will cause at least once 'change' event later + -- probably two in MP + currPlayerUnitsByNames[oldPlayerName] = "" + end + end + + -- update playerGroups for this cycle + cfxPlayerGroups = currPlayerGroups + + -- update network player for this c" then return false end + return true +end + +-- add a monitor to be notified of player events +-- may provide a whitelist of events as array of strings +function cfxPlayer.addMonitor(callback, events) + local newMonitor = {} + newMonitor.callback = callback + newMonitor.events = events + cfxPlayer.monitors[callback] = newMonitor +end; + +function cfxPlayer.removeMonitor(callback) + if (cfxMonitos[callback]) then + cfxMonitos[callback] = nil + end +end + +function cfxPlayer.invokeMonitorsForEvent(evType, description, player, data) + for callback, monitor in pairs(cfxPlayer.monitors) do + -- should filter if evType is in monitor.events + if monitor.events and #monitor.events > 0 then + -- only invoke if this event is listed + if dcsCommon.arrayContainsString(monitor.events, evType) then + monitor.callback(evType, description, player, data) + end + else + monitor.callback(evType, description, player, data) + end + end +end + +function cfxPlayer.getAllExistingPlayerUnitsRaw() + local apu = {} + for i=1, #cfxPlayer.coalitionSides do + local theSide = cfxPlayer.coalitionSides[i] + -- get all players for this side + local thePlayers = coalition.getPlayers(theSide) + for p=1, #thePlayers do + local aUnit = thePlayers[p] + if aUnit and aUnit:isExist() then + table.insert(apu, aUnit) + end + end + end + return apu +end + +-- evType that can actually happen are 'new', 'leave' for units, +-- 'newGroup' and 'removeGroup' for groups +function cfxPlayer.defaultMonitor(evType, description, info, data) + if cfxPlayer.verbose then + trigger.action.outText("+++Plr - evt '".. evType .."': <" .. description .. ">", 30) + if (info) then + trigger.action.outText("+++Plr: for unit named: " .. info.name, 30) + else + --trigger.action.outText("+++Plr: no player data", 30) + end + --trigger.action.outText("+++Plr: desc: '".. evType .."'<" .. description .. ">", 30) + -- we ignore the data block + end +end + +function cfxPlayer.start() + trigger.action.outText("cf/x player v".. cfxPlayer.version .. ": started", 10) + cfxPlayer.running = true + cfxPlayer.update() +end + +function cfxPlayer.stop() + if cfxPlayer.verbose then + trigger.action.outText("cf/x player v".. cfxPlayer.version .. ": stopped", 10) + end + timer.removeFunction(cfxPlayer.updateSchedule) -- will require another start() to resume + cfxPlayer.running = false +end + +function cfxPlayer.init() + trigger.action.outText("cf/x player v".. cfxPlayer.version .. ": loaded", 10) + -- when verbose, we also add a monitor to display player event + if cfxPlayer.verbose then + cfxPlayer.addMonitor(cfxPlayer.defaultMonitor, {}) + trigger.action.outText("cf/x player isd verbose", 10) + end + + cfxPlayer.start() +end + +-- get everything rolling, but will only start if autostart is true +cfxPlayer.init() + +--TODO: player status: ground, air, dead, none +-- TODO: event when status changes ground/air/... \ No newline at end of file diff --git a/modules/cfxPlayerScore.lua b/modules/cfxPlayerScore.lua new file mode 100644 index 0000000..1a7482f --- /dev/null +++ b/modules/cfxPlayerScore.lua @@ -0,0 +1,350 @@ +cfxPlayerScore = {} +cfxPlayerScore.version = "1.2.0" +cfxPlayerScore.badSound = "Death BRASS.wav" +cfxPlayerScore.scoreSound = "Quest Snare 3.wav" +cfxPlayerScore.announcer = true +--[[-- VERSION HISTORY + 1.0.1 - bug fixes to killDetected + 1.0.2 - messaging clean-up, less verbose + 1.1.0 - integrated score base system + - accepts configZones + - module validation + - isNamedUnit(theUnit) + - notify if named unit killed + - kill weapon reported + 1.2.0 - score table + - announcer attribute + - badSound name + - scoreSound name + +--]]-- +cfxPlayerScore.requiredLibs = { + "dcsCommon", -- this is doing score keeping + "cfxPlayer", -- player events, comms + "cfxZones", -- zones for config +} +cfxPlayerScore.playerScore = {} -- init to empty + +-- typeScore: dictionary sorted by typeString for score +-- extend to add more types. It is used by unitType2score to +-- determine the base unit score +cfxPlayerScore.typeScore = {} + +-- +-- we subscribe to the kill event. each time a unit +-- is killed, we check if it was killed by a player +-- and if so, that player record is updated and the side +-- whom the player belongs to is informed +-- +cfxPlayerScore.aircraft = 50 +cfxPlayerScore.helo = 40 +cfxPlayer.ground = 10 +cfxPlayerScore.ship = 80 +cfxPlayerScore.train = 5 + +function cfxPlayerScore.cat2BaseScore(inCat) + if inCat == 0 then return cfxPlayerScore.aircraft end -- airplane + if inCat == 1 then return cfxPlayerScore.helo end -- helo + if inCat == 2 then return cfxPlayer.ground end -- ground + if inCat == 3 then return cfxPlayerScore.ship end -- ship + if inCat == 4 then return cfxPlayerScore.train end -- train + + trigger.action.outText("+++scr c2bs: unknown category for lookup: <" .. inCat .. ">, returning 1", 30) + + return 1 +end + +function cfxPlayerScore.unit2score(inUnit) + local vicGroup = inUnit:getGroup() + local vicCat = vicGroup:getCategory() + local vicType = inUnit:getTypeName() + local vicName = inUnit:getName() + + -- simply extend by adding items to the typescore table.concat + -- we first try by unit name. This allows individual + -- named hi-value targets to have individual scores + local uScore = cfxPlayerScore.typeScore[vicName] + if uScore == nil then + -- WE NOW TRY TO ACCESS BY VICTIM'S TYPE STRING + uScore = cfxPlayerScore.typeScore[vicType] + else + + end + if type(uScore) == "string" then + -- convert string to number + uScore = tonumber(uScore) + end + + if uScore == nil then uScore = 0 end + if uScore > 0 then return uScore end + + -- only apply base scores when the lookup did not give a result + uScore = cfxPlayerScore.cat2BaseScore(vicCat) + return uScore +end + +function cfxPlayerScore.getPlayerScore(playerName) + local thePlayerScore = cfxPlayerScore.playerScore[playerName] + if thePlayerScore == nil then + thePlayerScore = {} + thePlayerScore.name = playerName + thePlayerScore.score = 0 -- score + thePlayerScore.killTypes = {} -- the type strings killed + thePlayerScore.totalKills = 0 -- number of kills total + end + return thePlayerScore +end + +function cfxPlayerScore.setPlayerScore(playerName, thePlayerScore) + cfxPlayerScore.playerScore[playerName] = thePlayerScore +end + +function cfxPlayerScore.updateScoreForPlayer(playerName, score) + local thePlayerScore = cfxPlayerScore.getPlayerScore(playerName) + + thePlayerScore.score = thePlayerScore.score + score + cfxPlayerScore.setPlayerScore(playerName, thePlayerScore) + return thePlayerScore.score +end + +function cfxPlayerScore.logKillForPlayer(playerName, theUnit) + if not theUnit then return end + if not playerName then return end + + local thePlayerScore = cfxPlayerScore.getPlayerScore(playerName) + + local theType = theUnit:getTypeName() + local killCount = thePlayerScore.killTypes[theType] + if killCount == nil then + killCount = 0 + end + killCount = killCount + 1 + thePlayerScore.totalKills = thePlayerScore.totalKills + 1 + thePlayerScore.killTypes[theType] = killCount + + cfxPlayerScore.setPlayerScore(playerName, thePlayerScore) +end + +function cfxPlayerScore.playerScore2text(thePlayerScore) + local desc = thePlayerScore.name .. " - score: ".. thePlayerScore.score .. " - kills: " .. thePlayerScore.totalKills .. "\n" + -- now go through all killSide + for theType, quantity in pairs(thePlayerScore.killTypes) do + desc = desc .. " - " .. theType .. ": " .. quantity .. "\n" + end + return desc +end + +function cfxPlayerScore.scoreTextForPlayerNamed(playerName) + local thePlayerScore = cfxPlayerScore.getPlayerScore(playerName) + return cfxPlayerScore.playerScore2text(thePlayerScore) +end + +function cfxPlayerScore.isNamedUnit(theUnit) + if not theUnit then return false end + local theName = "(cfx_none)" + if type(theUnit) == "string" then + theName = theUnit -- direct name assignment + -- WARNING: NO EXIST CHECK DONE! + else + -- after kill, unit is dead, so will no longer exist! + theName = theUnit:getName() + if not theName then return false end + end + if cfxPlayerScore.typeScore[theName] then + return true + end + return false +end +-- +-- EVENT HANDLING +-- +function cfxPlayerScore.preProcessor(theEvent) + -- return true if the event should be processed + -- by us + + if theEvent.id == 28 then + -- we only are interested in kill events where + -- there is an initiator, and the initiator is + -- a player + if theEvent.initiator == nil then + -- trigger.action.outText("+++scr pre: nil INITIATOR", 30) + return false + end + --trigger.action.outText("+++scr pre: initiator is " .. theEvent.initiator:getName(), 30) + + local killer = theEvent.initiator + if theEvent.target == nil then + if cfxPlayerScore.verbose then trigger.action.outText("+++scr pre: nil TARGET", 30) end + return false + end + + local wasPlayer = cfxPlayer.isPlayerUnit(killer) + if wasPlayer then + else + end + + return wasPlayer + end + return false +end + +function cfxPlayerScore.postProcessor(theEvent) + -- don't do anything +end + +function cfxPlayerScore.killDetected(theEvent) + -- we are only getting called when and if + -- a kill occured and killer was a player + -- and target exists + local killer = theEvent.initiator + local killerName = killer:getPlayerName() + if not killerName then killerName = "" end + local killSide = killer:getCoalition() + local killVehicle = killer:getTypeName() + if not killVehicle then killVehicle = "" end + local victim = theEvent.target + + -- was it a player kill? + local pk = cfxPlayer.isPlayerUnit(victim) + + -- was it a scenery object + local wasBuilding = dcsCommon.isSceneryObject(victim) + if wasBuilding then + return + end + + -- was it fraternicide? + local vicSide = victim:getCoalition() + local fraternicide = killSide == vicSide + local vicDesc = victim:getTypeName() + local scoreMod = 1 -- start at one + + -- see what kind of unit (category) we killed + -- and look up base score + if not victim.getGroup then + if cfxPlayerScore.verbose then trigger.action.outText("+++scr: no group for " .. killVehicle .. ", killed by " .. killerName .. ", no score", 30) end + return + end + + local vicGroup = victim:getGroup() + local vicCat = vicGroup:getCategory() + local unitScore = cfxPlayerScore.unit2score(victim) + + -- see which weapon was used. gun kills score 2x + local killMeth = "" + local killWeap = theEvent.weapon + if killWeap then + local killWeapType = killWeap:getCategory() + if killWeapType == 0 then + killMeth = " with GUNS" + scoreMod = scoreMod * 2 + else + local kWeapon = killWeap:getTypeName() + killMeth = " with " .. kWeapon + end + else + + end + + if pk then + vicDesc = victim:getPlayerName() .. " in " .. vicDesc + scoreMod = scoreMod * 10 + end + + if fraternicide then + scoreMod = scoreMod * -2 + if cfxPlayerScore.announcer then + trigger.action.outTextForCoalition(killSide, killerName .. " in " .. killVehicle .. " killed FRIENDLY " .. vicDesc .. killMeth .. "!", 30) + trigger.action.outSoundForCoalition(killSide, cfxPlayerScore.badSound) + end + else + if cfxPlayerScore.announcer then + trigger.action.outText(killerName .. " in " .. killVehicle .." killed " .. vicDesc .. killMeth .."!", 30) + trigger.action.outSoundForCoalition(vicSide, cfxPlayerScore.badSound) + trigger.action.outSoundForCoalition(killSide, cfxPlayerScore.scoreSound) + end + -- since not fraticide, log this kill + -- logging kills does not impct score + cfxPlayerScore.logKillForPlayer(killerName, victim) + end + + -- see if it was a named target + if cfxPlayerScore.isNamedUnit(victim) then + if cfxPlayerScore.announcer then + trigger.action.outTextForCoalition(killSide, killerName .. " reports killing strategic unit '" .. victim:getName() .. "'", 30) + end + end + + local totalScore = unitScore * scoreMod + + local playerScore = cfxPlayerScore.updateScoreForPlayer(killerName, totalScore) + + if cfxPlayerScore.announcer then + trigger.action.outTextForCoalition(killSide, "Killscore: " .. totalScore .. " for a total of " .. playerScore .. " for " .. killerName, 30) + end + --trigger.action.outTextForCoalition(killSide, cfxPlayerScore.scoreTextForPlayerNamed(killerName), 30) +end + +function cfxPlayerScore.readConfigZone(theZone) + cfxPlayerScore.verbose = cfxZones.getBoolFromZoneProperty(theZone, "verbose", false) + -- default scores + cfxPlayerScore.aircraft = cfxZones.getNumberFromZoneProperty(theZone, "aircraft", 50) + cfxPlayerScore.helo = cfxZones.getNumberFromZoneProperty(theZone, "helo", 40) + cfxPlayer.ground = cfxZones.getNumberFromZoneProperty(theZone, "ground", 10) + cfxPlayerScore.ship = cfxZones.getNumberFromZoneProperty(theZone, "ship", 80) + cfxPlayerScore.train = cfxZones.getNumberFromZoneProperty(theZone, "train", 5) + + cfxPlayerScore.announcer = cfxZones.getBoolFromZoneProperty(theZone, "announcer", true) + + if cfxZones.hasProperty(theZone, "badSound") then + cfxReconMode.badSound = cfxZones.getStringFromZoneProperty(theZone, "badSound", "") + end + if cfxZones.hasProperty(theZone, "scoreSound") then + cfxReconMode.scoreSound = cfxZones.getStringFromZoneProperty(theZone, "scoreSound", "") + end +end + +function cfxPlayerScore.start() + if not dcsCommon.libCheck("cfx Player Score", + cfxPlayerScore.requiredLibs) + then + return false + end + + -- read my score table + -- identify and process a score table zones + local theZone = cfxZones.getZoneByName("playerScoreTable") + if not theZone then + trigger.action.outText("+++scr: no score table!", 30) + else + -- read all into my types registry, replacing whatever is there + cfxPlayerScore.typeScore = cfxZones.getAllZoneProperties(theZone) + trigger.action.outText("+++scr: read score table", 30) + end + + -- now read my config zone + local theZone = cfxZones.getZoneByName("playerScoreConfig") + if not theZone then + trigger.action.outText("+++scr: no config!", 30) + else + cfxPlayerScore.readConfigZone(theZone) + trigger.action.outText("+++scr: read config", 30) + end + + -- subscribe to events and use dcsCommon's handler structure + dcsCommon.addEventHandler(cfxPlayerScore.killDetected, + cfxPlayerScore.preProcessor, + cfxPlayerScore.postProcessor) + trigger.action.outText("cfxPlayerScore v" .. cfxPlayerScore.version .. " started", 30) + return true +end + + +if not cfxPlayerScore.start() then + trigger.action.outText("+++ aborted cfxPlayerScore v" .. cfxPlayerScore.version .. " -- libcheck failed", 30) + cfxPlayerScore = nil +end + +-- TODO: score mod for weapons type +-- TODO: player kill score + \ No newline at end of file diff --git a/modules/cfxPlayerScoreUI.lua b/modules/cfxPlayerScoreUI.lua new file mode 100644 index 0000000..4afcf1e --- /dev/null +++ b/modules/cfxPlayerScoreUI.lua @@ -0,0 +1,303 @@ +cfxPlayerScoreUI = {} +cfxPlayerScoreUI.version = "1.0.3" +--[[-- VERSION HISTORY + - 1.0.2 - initial version + - 1.0.3 - module check + +--]]-- + +-- WARNING: REQUIRES cfxPlayerScore to work. +-- WARNING: ASSUMES SINGLE_PLAYER GROUPS! +cfxPlayerScoreUI.requiredLibs = { + "cfxPlayerScore", -- this is doing score keeping + "cfxPlayer", -- player events, comms +} +-- find & command cfxGroundTroops-based jtacs +-- UI installed via OTHER for all groups with players +-- module based on xxxGrpUI and jtacUI + +cfxPlayerScoreUI.groupConfig = {} -- all inited group private config data +cfxPlayerScoreUI.simpleCommands = true -- if true, f10 other invokes directly + +-- +-- C O N F I G H A N D L I N G +-- ============================= +-- +-- Each group has their own config block that can be used to +-- store group-private data and configuration items. +-- + +function cfxPlayerScoreUI.resetConfig(conf) +end + +function cfxPlayerScoreUI.createDefaultConfig(theGroup) + local conf = {} + conf.theGroup = theGroup + conf.name = theGroup:getName() + conf.id = theGroup:getID() + conf.coalition = theGroup:getCoalition() + + cfxPlayerScoreUI.resetConfig(conf) + + conf.mainMenu = nil; -- this is where we store the main menu if we branch + conf.myCommands = nil; -- this is where we store the commands if we branch + + return conf +end + +-- getConfigFor group will allocate if doesn't exist in DB +-- and add to it +function cfxPlayerScoreUI.getConfigForGroup(theGroup) + if not theGroup then + trigger.action.outText("+++WARNING: cfxPlayerScoreUI nil group in getConfigForGroup!", 30) + return nil + end + local theName = theGroup:getName() + local c = cfxPlayerScoreUI.getConfigByGroupName(theName) -- we use central accessor + if not c then + c = cfxPlayerScoreUI.createDefaultConfig(theGroup) + cfxPlayerScoreUI.groupConfig[theName] = c -- should use central accessor... + end + return c +end + +function cfxPlayerScoreUI.getConfigByGroupName(theName) -- DOES NOT allocate when not exist + if not theName then return nil end + return cfxPlayerScoreUI.groupConfig[theName] +end + + +function cfxPlayerScoreUI.getConfigForUnit(theUnit) + -- simple one-off step by accessing the group + if not theUnit then + trigger.action.outText("+++WARNING: cfxPlayerScoreUI nil unit in getConfigForUnit!", 30) + return nil + end + + local theGroup = theUnit:getGroup() + return getConfigForGroup(theGroup) +end + +-- +-- +-- M E N U H A N D L I N G +-- ========================= +-- +-- + function cfxPlayerScoreUI.clearCommsSubmenus(conf) + if conf.myCommands then + for i=1, #conf.myCommands do + missionCommands.removeItemForGroup(conf.id, conf.myCommands[i]) + end + end + conf.myCommands = {} +end + +function cfxPlayerScoreUI.removeCommsFromConfig(conf) + cfxPlayerScoreUI.clearCommsSubmenus(conf) + + if conf.myMainMenu then + missionCommands.removeItemForGroup(conf.id, conf.myMainMenu) + conf.myMainMenu = nil + end +end + +-- this only works in single-unit groups. may want to check if group +-- has disappeared +function cfxPlayerScoreUI.removeCommsForUnit(theUnit) + if not theUnit then return end + if not theUnit:isExist() then return end + -- perhaps add code: check if group is empty + local conf = cfxPlayerScoreUI.getConfigForUnit(theUnit) + cfxPlayerScoreUI.removeCommsFromConfig(conf) +end + +function cfxPlayerScoreUI.removeCommsForGroup(theGroup) + if not theGroup then return end + if not theGroup:isExist() then return end + local conf = cfxPlayerScoreUI.getConfigForGroup(theGroup) + cfxPlayerScoreUI.removeCommsFromConfig(conf) +end + +-- +-- set main root in F10 Other. All sub menus click into this +-- +--function cfxPlayerScoreUI.isEligibleForMenu(theGroup) +-- return true +--end + +function cfxPlayerScoreUI.setCommsMenuForUnit(theUnit) + if not theUnit then + trigger.action.outText("+++WARNING: cfxPlayerScoreUI nil UNIT in setCommsMenuForUnit!", 30) + return + end + if not theUnit:isExist() then return end + + local theGroup = theUnit:getGroup() + cfxPlayerScoreUI.setCommsMenu(theGroup) +end + +function cfxPlayerScoreUI.setCommsMenu(theGroup) + -- depending on own load state, we set the command structure + -- it begins at 10-other, and has 'grpUI' as main menu with submenus + -- as required + if not theGroup then return end + if not theGroup:isExist() then return end + + -- we test here if this group qualifies for + -- the menu. if not, exit + --if not cfxPlayerScoreUI.isEligibleForMenu(theGroup) then return end + + local conf = cfxPlayerScoreUI.getConfigForGroup(theGroup) + conf.id = theGroup:getID(); -- we do this ALWAYS so it is current even after a crash + + + if cfxPlayerScoreUI.simpleCommands then + -- we install directly in F-10 other + if not conf.myMainMenu then + local commandTxt = "Score / Kills" + local theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + nil, + cfxPlayerScoreUI.redirectCommandX, + {conf, "score"} + ) + conf.myMainMenu = theCommand + end + + return + end + + + -- ok, first, if we don't have an F-10 menu, create one + if not (conf.myMainMenu) then + conf.myMainMenu = missionCommands.addSubMenuForGroup(conf.id, 'Score / Kills') + end + + -- clear out existing commands + cfxPlayerScoreUI.clearCommsSubmenus(conf) + + -- now we have a menu without submenus. + -- add our own submenus + cfxPlayerScoreUI.addSubMenus(conf) + +end + +function cfxPlayerScoreUI.addSubMenus(conf) + -- add menu items to choose from after + -- user clickedf on MAIN MENU. In this implementation + -- they all result invoked methods + + local commandTxt = "Show Score / Kills" + local theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + conf.myMainMenu, + cfxPlayerScoreUI.redirectCommandX, + {conf, "score"} + ) + table.insert(conf.myCommands, theCommand) + +end + +-- +-- each menu item has a redirect and timed invoke to divorce from the +-- no-debug zone in the menu invocation. Delay is .1 seconds +-- + +function cfxPlayerScoreUI.redirectCommandX(args) + timer.scheduleFunction(cfxPlayerScoreUI.doCommandX, args, timer.getTime() + 0.1) +end + +function cfxPlayerScoreUI.doCommandX(args) + local conf = args[1] -- < conf in here + local what = args[2] -- < second argument in here + local theGroup = conf.theGroup + -- now fetch the first player that drives a unit in this group + -- a simpler method would be to access conf.primeUnit + + local playerName, playerUnit = cfxPlayer.getFirstGroupPlayerName(theGroup) + if playerName == nil or playerUnit == nil then + trigger.action.outText("scoreUI: nil player name or unit for group " .. theGroup:getName(), 30) + return + end + + + local desc = cfxPlayerScore.scoreTextForPlayerNamed(playerName) + + trigger.action.outTextForGroup(conf.id, desc, 30) + trigger.action.outSoundForGroup(conf.id, "Quest Snare 3.wav") + +end + + +-- +-- G R O U P M A N A G E M E N T +-- +-- Group Management is required to make sure all groups +-- receive a comms menu and that they receive a clean-up +-- when required +-- +-- Callbacks are provided by cfxPlayer module to which we +-- subscribe during init +-- +function cfxPlayerScoreUI.playerChangeEvent(evType, description, player, data) + + if evType == "newGroup" then + + cfxPlayerScoreUI.setCommsMenu(data.group) + + return + end + + if evType == "removeGroup" then + + -- we must remove the comms menu for this group else we try to add another one to this group later + local conf = cfxPlayerScoreUI.getConfigByGroupName(data.name) + + if conf then + cfxPlayerScoreUI.removeCommsFromConfig(conf) -- remove menus + cfxPlayerScoreUI.resetConfig(conf) -- re-init this group for when it re-appears + else + trigger.action.outText("+++ scoreUI: can't retrieve group <" .. data.name .. "> config: not found!", 30) + end + + return + end + +end + +-- +-- Start +-- + +function cfxPlayerScoreUI.start() + if not dcsCommon.libCheck("cfx PlayerScoreUI", + cfxPlayerScoreUI.requiredLibs) + then + return false + end + -- iterate existing groups so we have a start situation + -- now iterate through all player groups and install the Assault Troop Menu + allPlayerGroups = cfxPlayerGroups -- cfxPlayerGroups is a global, don't fuck with it! + -- contains per group player record. Does not resolve on unit level! + for gname, pgroup in pairs(allPlayerGroups) do + local theUnit = pgroup.primeUnit -- get any unit of that group + cfxPlayerScoreUI.setCommsMenuForUnit(theUnit) -- set up + end + -- now install the new group notifier to install Assault Troops menu + + cfxPlayer.addMonitor(cfxPlayerScoreUI.playerChangeEvent) + trigger.action.outText("cf/x cfxPlayerScoreUI v" .. cfxPlayerScoreUI.version .. " started", 30) + return true +end + +-- +-- GO GO GO +-- + +if not cfxPlayerScoreUI.start() then + cfxPlayerScoreUI = nil + trigger.action.outText("cf/x PlayerScore UI aborted: missing libraries", 30) +end diff --git a/modules/cfxReconGUI.lua b/modules/cfxReconGUI.lua new file mode 100644 index 0000000..d915d38 --- /dev/null +++ b/modules/cfxReconGUI.lua @@ -0,0 +1,385 @@ +cfxReconGUI = {} +cfxReconGUI.version = "1.0.0" +--[[-- VERSION HISTORY + - 1.0.0 - initial version + +--]]-- +-- find & command cfxGroundTroops-based jtacs +-- UI installed via OTHER for all groups with players +-- module based on xxxGrpUI + +cfxReconGUI.groupConfig = {} -- all inited group private config data +cfxReconGUI.simpleCommands = true -- if true, f10 other invokes directly + +-- +-- C O N F I G H A N D L I N G +-- ============================= +-- +-- Each group has their own config block that can be used to +-- store group-private data and configuration items. +-- + +function cfxReconGUI.resetConfig(conf) + if conf.scouting then + -- after a crash or other, reset + cfxReconMode.removeScout(conf.unit) + trigger.action.outTextForGroup(conf.id, "Lost contact to scout...", 30) + end + conf.scouting = false -- if true, we are currently scouting. +end + +function cfxReconGUI.createDefaultConfig(theGroup) + local conf = {} + conf.theGroup = theGroup + conf.name = theGroup:getName() + conf.id = theGroup:getID() + conf.coalition = theGroup:getCoalition() + + local groupUnits = theGroup:getUnits() + conf.unit = groupUnits[1] -- WARNING: ASSUMES ONE-UNIT GROUPS + cfxReconGUI.resetConfig(conf) + + conf.mainMenu = nil; -- this is where we store the main menu if we branch + conf.myCommands = nil; -- this is where we store the commands if we branch + + return conf +end + +-- getConfigFor group will allocate if doesn't exist in DB +-- and add to it +function cfxReconGUI.getConfigForGroup(theGroup) + if not theGroup then + trigger.action.outText("+++WARNING: cfxReconGUI nil group in getConfigForGroup!", 30) + return nil + end + local theName = theGroup:getName() + local c = cfxReconGUI.getConfigByGroupName(theName) -- we use central accessor + if not c then + c = cfxReconGUI.createDefaultConfig(theGroup) + cfxReconGUI.groupConfig[theName] = c -- should use central accessor... + end + return c +end + +function cfxReconGUI.getConfigByGroupName(theName) -- DOES NOT allocate when not exist + if not theName then return nil end + return cfxReconGUI.groupConfig[theName] +end + + +function cfxReconGUI.getConfigForUnit(theUnit) + -- simple one-off step by accessing the group + if not theUnit then + trigger.action.outText("+++WARNING: cfxReconGUI nil unit in getConfigForUnit!", 30) + return nil + end + + local theGroup = theUnit:getGroup() + local conf = getConfigForGroup(theGroup) + conf.unit = theUnit + return conf +end + +-- +-- +-- M E N U H A N D L I N G +-- ========================= +-- +-- + function cfxReconGUI.clearCommsSubmenus(conf) + if conf.myCommands then + for i=1, #conf.myCommands do + missionCommands.removeItemForGroup(conf.id, conf.myCommands[i]) + end + end + conf.myCommands = {} +end + +function cfxReconGUI.removeCommsFromConfig(conf) + cfxReconGUI.clearCommsSubmenus(conf) + + if conf.myMainMenu then + missionCommands.removeItemForGroup(conf.id, conf.myMainMenu) + conf.myMainMenu = nil + end +end + +-- this only works in single-unit groups. may want to check if group +-- has disappeared +function cfxReconGUI.removeCommsForUnit(theUnit) + if not theUnit then return end + if not theUnit:isExist() then return end + -- perhaps add code: check if group is empty + local conf = cfxReconGUI.getConfigForUnit(theUnit) + cfxReconGUI.removeCommsFromConfig(conf) +end + +function cfxReconGUI.removeCommsForGroup(theGroup) + if not theGroup then return end + if not theGroup:isExist() then return end + local conf = cfxReconGUI.getConfigForGroup(theGroup) + cfxReconGUI.removeCommsFromConfig(conf) +end + +-- +-- set main root in F10 Other. All sub menus click into this +-- +function cfxReconGUI.isEligibleForMenu(theGroup) + return true +end + +function cfxReconGUI.setCommsMenuForUnit(theUnit) + if not theUnit then + trigger.action.outText("+++WARNING: cfxReconGUI nil UNIT in setCommsMenuForUnit!", 30) + return + end + if not theUnit:isExist() then + trigger.action.outText("+++WARNING: cfxReconGUI unit:ISEXIST() failed in setCommsMenuForUnit!", 30) + return + end + + local theGroup = theUnit:getGroup() + cfxReconGUI.setCommsMenu(theGroup) +end + +function cfxReconGUI.setCommsMenu(theGroup) + -- depending on own load state, we set the command structure + -- it begins at 10-other, and has 'jtac' as main menu with submenus + -- as required + if not theGroup then return end + if not theGroup:isExist() then return end + + -- we test here if this group qualifies for + -- the menu. if not, exit + if not cfxReconGUI.isEligibleForMenu(theGroup) then return end + + local conf = cfxReconGUI.getConfigForGroup(theGroup) + conf.id = theGroup:getID(); -- we do this ALWAYS so it is current even after a crash +-- trigger.action.outText("+++ setting group <".. conf.theGroup:getName() .. "> jtac command", 30) + + if cfxReconGUI.simpleCommands then + -- we install directly in F-10 other + if not conf.myMainMenu then + local commandTxt = "Recon: " + local unitName = "bogus" + if conf.unit and conf.unit:isExist() then + unitName = conf.unit:getName() + elseif conf.unit then + trigger.action.outText("+++Recon: ISEXIST failed for unit in comms setup!", 30) + commandTxt = commandTxt .. "***" + else + trigger.action.outText("+++Recon: NIL unit in comms setup!", 30) + commandTxt = commandTxt .. "***" + end + + if conf.scouting then + commandTxt = commandTxt .. " Stop Reporting" + else + commandTxt = commandTxt .. " Commence Reports" + end + local theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + nil, + cfxReconGUI.redirectCommandX, + {conf, "recon", unitName} + ) + conf.myMainMenu = theCommand + end + + return + end + + + -- ok, first, if we don't have an F-10 menu, create one + if not (conf.myMainMenu) then + conf.myMainMenu = missionCommands.addSubMenuForGroup(conf.id, 'Recon') + end + + -- clear out existing commands + cfxReconGUI.clearCommsSubmenus(conf) + + -- now we have a menu without submenus. + -- add our own submenus + cfxReconGUI.addSubMenus(conf) + +end + +function cfxReconGUI.addSubMenus(conf) + -- add menu items to choose from after + -- user clickedf on MAIN MENU. In this implementation + -- they all result invoked methods + + local commandTxt = "Recon" + local unitName = "bogus" + if conf.unit and conf.unit:getName()then + unitName = conf.unit:getName() + else + trigger.action.outTextForCoalition("+++Recon: no unit in comms setup!", message, 30) + commandTxt = commandTxt .. "***" + end + + local theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + conf.myMainMenu, + cfxReconGUI.redirectCommandX, + {conf, "recon", unitName} + ) + table.insert(conf.myCommands, theCommand) +--[[-- + commandTxt = "This is another important command" + theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + conf.myMainMenu, + cfxReconGUI.redirectCommandX, + {conf, "Sub2"} + ) + table.insert(conf.myCommands, theCommand) +--]]-- +end + +-- +-- each menu item has a redirect and timed invoke to divorce from the +-- no-debug zone in the menu invocation. Delay is .1 seconds +-- + +function cfxReconGUI.redirectCommandX(args) + timer.scheduleFunction(cfxReconGUI.doCommandX, args, timer.getTime() + 0.1) +end + +function cfxReconGUI.doCommandX(args) + local conf = args[1] -- < conf in here + local what = args[2] -- < second argument in here + local unitName = args[3] + if not unitName then + trigger.action.outText("+++ reconUI: doCommand: UNDEF unitName!", 30) + return + elseif unitName == "bogus" then + trigger.action.outText("+++ reconUI: doCommand: BOGUS unitName!", 30) + end + + local theGroup = conf.theGroup +-- trigger.action.outTextForGroup(conf.id, "+++ groupUI: processing comms menu for <" .. what .. ">", 30) + + -- whenever we get here, we toggle the recon mode + local theUnit = conf.unit + local message = "Scout ".. unitName .. " has stopped reporting." + local theSide = conf.coalition + if conf.scouting then + -- end recon + cfxReconMode.removeScoutByName(unitName) + if theUnit:isExist() then + message = theUnit:getName() .. " folds map, recon terminated." + end + conf.scouting = false + else + -- start recon + if theUnit and theUnit:isExist() then + cfxReconMode.addScout(theUnit) + message = theUnit:getName() .. " reports bright eyes, commencing recon." + conf.scouting = true + else + message = "+++ reconGUI: " .. unitName .. " has invalid unit" + end + end + trigger.action.outTextForCoalition(theSide, message, 30) + + -- reset comms + cfxReconGUI.removeCommsForGroup(theGroup) + cfxReconGUI.setCommsMenu(theGroup) +end + + + +-- +-- G R O U P M A N A G E M E N T +-- +-- Group Management is required to make sure all groups +-- receive a comms menu and that they receive a clean-up +-- when required +-- +-- Callbacks are provided by cfxPlayer module to which we +-- subscribe during init +-- +function cfxReconGUI.playerChangeEvent(evType, description, player, data) + --trigger.action.outText("+++ groupUI: received <".. evType .. "> Event", 30) + if evType == "newGroup" then + -- initialized attributes are in data as follows + -- .group - new group + -- .name - new group's name + -- .primeUnit - the unit that trigggered new group appearing + -- .primeUnitName - name of prime unit + -- .id group ID + --theUnit = data.primeUnit + -- ensure group data exists and is updated + local conf = cfxReconGUI.getConfigForGroup(data.group) + conf.unit = data.primeUnit + conf.unitName = conf.unit:getName() -- will break if no exist + + cfxReconGUI.setCommsMenu(data.group) +-- trigger.action.outText("+++ groupUI: added " .. theUnit:getName() .. " to comms menu", 30) + return + end + + if evType == "removeGroup" then + -- data is the player record that no longer exists. it consists of + -- .name + -- we must remove the comms menu for this group else we try to add another one to this group later + local conf = cfxReconGUI.getConfigByGroupName(data.name) + + if conf then + cfxReconGUI.removeCommsFromConfig(conf) -- remove menus + cfxReconGUI.resetConfig(conf) -- re-init this group for when it re-appears + else + trigger.action.outText("+++ reconUI: can't retrieve group <" .. data.name .. "> config: not found!", 30) + end + + return + end + + if evType == "leave" then + -- player unit left. we don't care since we only work on group level + -- if they were the only, this is followed up by group disappeared + + end + + if evType == "unit" then + -- player changed units. almost never in MP, but possible in solo + -- because of 1 seconds timing loop + -- will result in a new group appearing and a group disappearing, so we are good + -- may need some logic to clean up old configs and/or menu items + + end + +end + +-- +-- Start +-- +function cfxReconGUI.start() + + -- iterate existing groups so we have a start situation + -- now iterate through all player groups and install the Assault Troop Menu + allPlayerGroups = cfxPlayerGroups -- cfxPlayerGroups is a global, don't fuck with it! + -- contains per group player record. Does not resolve on unit level! + for gname, pgroup in pairs(allPlayerGroups) do + local theUnit = pgroup.primeUnit -- get any unit of that group + cfxReconGUI.setCommsMenuForUnit(theUnit) -- set up + end + -- now install the new group notifier to install Assault Troops menu + + cfxPlayer.addMonitor(cfxReconGUI.playerChangeEvent) + trigger.action.outText("cf/x cfxReconGUI v" .. cfxReconGUI.version .. " started", 30) + +end + +-- +-- GO GO GO +-- +if not cfxReconMode then + trigger.action.outText("cf/x cfxReconGUI REQUIRES cfxReconMode to work.", 30) +else + cfxReconGUI.start() +end \ No newline at end of file diff --git a/modules/cfxReconMode.lua b/modules/cfxReconMode.lua new file mode 100644 index 0000000..8c22787 --- /dev/null +++ b/modules/cfxReconMode.lua @@ -0,0 +1,606 @@ +cfxReconMode = {} +cfxReconMode.version = "1.4.1" +cfxReconMode.verbose = false -- set to true for debug info +cfxReconMode.reconSound = "UI_SCI-FI_Tone_Bright_Dry_20_stereo.wav" -- to be played when somethiong discovered + +cfxReconMode.prioList = {} -- group names that are high prio and generate special event +cfxReconMode.blackList = {} -- group names that are NEVER detected. Comma separated strings, e.g. {"Always Hidden", "Invisible Group"} + +cfxReconMode.requiredLibs = { + "dcsCommon", -- always + "cfxZones", -- Zones, of course +} + +--[[-- +VERSION HISTORY + 1.0.0 - initial version + 1.0.1 - removeScoutByName() + 1.0.2 - garbage collection + 1.1.0 - autoRecon - any aircraft taking off immediately + signs up, no message when signing up or closing down + standalone - copied common procs lerp, agl, dist, distflat + from dcsCommon + report numbers + verbose flag + 1.2.0 - queued recons. One scout per second for more even + performance + removed gc since it's now integrated into + update queue + removeScout optimization when directly passing name + playerOnlyRecon for autoRecon + red, blue, grey side filtering on auto scout + 1.2.1 - parametrized report sound + 1.3.0 - added black list, prio list functionality + 1.3.1 - callbacks now also push name, as group can be dead + - removed bug when removing dead groups from map + 1.4.0 - import dcsCommon, cfxZones etc + - added lib check + - config zone + - prio+ + - detect+ + 1.4.1 - invocation no longer happen twice for prio. + - recon sound + - read all flight groups at start to get rid of the + - late activation work-around + + + cfxReconMode is a script that allows units to perform reconnaissance + missions and, after detecting units, marks them on the map with + markers for their coalition and some text + Also, a callback is initiated for scouts as follows + signature: (reason, theSide, theSout, theGroup) with + reason a string + 'detected' a group was detected + 'removed' a mark for a group timed out + 'priority' a member of prio group was detected + 'start' a scout started scouting + 'end' a scout stopped scouting + 'dead' a scout has died and was removed from pool + theSide - side of the SCOUT that detected units + theScout - the scout that detected the group + theGroup - the group that is detected + theName - the group's name +--]]-- + +cfxReconMode.detectionMinRange = 3000 -- meters at ground level +cfxReconMode.detectionMaxRange = 12000 -- meters at max alt (10'000m) +cfxReconMode.maxAlt = 9000 -- alt for maxrange (9km = 27k feet) + +cfxReconMode.autoRecon = true -- add all airborne units, unless +cfxReconMode.redScouts = false -- set to false to prevent red scouts in auto mode +cfxReconMode.blueScouts = true -- set to false to prevent blue scouts in auto-mode +cfxReconMode.greyScouts = false -- set to false to prevent neutral scouts in auto mode +cfxReconMode.playerOnlyRecon = false -- only players can do recon +cfxReconMode.reportNumbers = true -- also add unit count in report +cfxReconMode.prioFlag = nil +cfxReconMode.detectFlag = nil +cfxReconMode.applyMarks = true + +cfxReconMode.ups = 1 -- updates per second. +cfxReconMode.scouts = {} -- units that are performing scouting. +cfxReconMode.processedScouts = {} -- for managing performance: queue +cfxReconMode.detectedGroups = {} -- so we know which have been detected +cfxReconMode.marksFadeAfter = 30*60 -- after detection, marks disappear after + -- this amount of seconds. -1 means no fade + -- 60 is one minute + +cfxReconMode.callbacks = {} -- sig: cb(reason, side, scout, group) +cfxReconMode.uuidCount = 0 -- for unique marks + + +-- end standalone dcsCommon extract + +function cfxReconMode.uuid() + cfxReconMode.uuidCount = cfxReconMode.uuidCount + 1 + return cfxReconMode.uuidCount +end + +function cfxReconMode.addCallback(theCB) + table.insert(cfxReconMode.callbacks, theCB) +end + +function cfxReconMode.invokeCallbacks(reason, theSide, theScout, theGroup, theName) + for idx, theCB in pairs(cfxReconMode.callbacks) do + theCB(reason, theSide, theScout, theGroup, theName) + end +end + +-- add a priority/blackList group name to prio list +function cfxReconMode.addToPrioList(aGroup) + if not aGroup then return end + if type(aGroup) == "table" and aGroup.getName then + aGroup = aGroup:getName() + end + if type(aGroup) == "string" then + table.insert(cfxReconMode.prioList, aGroup) + end +end + +function cfxReconMode.addToBlackList(aGroup) + if not aGroup then return end + if type(aGroup) == "table" and aGroup.getName then + aGroup = aGroup:getName() + end + if type(aGroup) == "string" then + table.insert(cfxReconMode.blackList, aGroup) + end +end + + +function cfxReconMode.isStringInList(theString, theList) + if not theString then return false end + if not theList then return false end + if type(theString) == "string" then + for idx,anItem in pairs(theList) do + if anItem == theString then return true end + end + end + return false +end + +-- addScout directly adds a scout unit. Use from external +-- to manually add a unit (e.g. via GUI when autoscout isExist +-- off, or to force a scout unit (e.g. when scouts for a side +-- are not allowed but you still want a unit from that side +-- to scout +-- since we use a queue for scouts, also always check the +-- processed queue before adding to make sure a scout isn't +-- entered multiple times + +function cfxReconMode.addScout(theUnit) + if not theUnit then + trigger.action.outText("+++cfxRecon: WARNING - nil Unit on add", 30) + return + end + + if type(theUnit) == "string" then + local u = Unit.getByName(theUnit) + theUnit = u + end + + if not theUnit then + trigger.action.outText("+++cfxRecon: WARNING - did not find unit on add", 30) + return + end + if not theUnit:isExist() then return end + -- find out if this an update or a new scout + local thisID = tonumber(theUnit:getID()) + local theName = theUnit:getName() + local lastUnit = cfxReconMode.scouts[theName] + local isProcced = false -- may also be in procced line + if not lastUnit then + lastUnit = cfxReconMode.processedScouts[theName] + if lastUnit then isProcced = true end + end + + if lastUnit then + -- this is merely an overwrite + if cfxReconMode.verbose then trigger.action.outText("+++rcn: UPDATE scout " .. theName .. " -- no CB invoke", 30) end + else + if cfxReconMode.verbose then trigger.action.outText("+++rcn: new scout " .. theName .. " with ID " .. thisID, 30) end + -- a new scout! Invoke callbacks + local scoutGroup = theUnit:getGroup() + local theSide = scoutGroup:getCoalition() + cfxReconMode.invokeCallbacks("start", theSide, theUnit, nil, "") + end + + if isProcced then + -- overwrite exiting entry in procced queue + cfxReconMode.processedScouts[theName] = theUnit + else + -- add / overwrite into normal queue + cfxReconMode.scouts[theName] = theUnit + end + + if cfxReconMode.verbose then + trigger.action.outText("+++rcn: addded scout " .. theUnit:getName(), 30) + end +end + + +function cfxReconMode.removeScout(theUnit) + if not theUnit then + trigger.action.outText("+++cfxRecon: WARNING - nil Unit on remove", 30) + return + end + + if type(theUnit) == "string" then + cfxReconMode.removeScoutByName(theUnit) + return + end + + if not theUnit then return end + if not theUnit:isExist() then return end + cfxReconMode.removeScoutByName(theUnit:getName()) + local scoutGroup = theUnit:getGroup() + local theSide = scoutGroup:getCoalition() + cfxReconMode.invokeCallbacks("end", theSide, theUnit, nil, "") +end + +-- warning: removeScoutByName does NOT invoke callbacks, always +-- use removeScout instead! +function cfxReconMode.removeScoutByName(aName) + cfxReconMode.scouts[aName] = nil + cfxReconMode.processedScouts[aName] = nil -- also remove from processed stack + if cfxReconMode.verbose then + trigger.action.outText("+++rcn: removed scout " .. aName, 30) + end +end + + +function cfxReconMode.canDetect(scoutPos, theGroup, visRange) + -- determine if a member of theGroup can be seen from + -- scoutPos at visRange + -- returns true and pos when detected + local allUnits = theGroup:getUnits() + for idx, aUnit in pairs(allUnits) do + if aUnit:isExist() and aUnit:getLife() >= 1 then + local uPos = aUnit:getPoint() + uPos.y = uPos.y + 3 -- raise my 3 meters + local d = dcsCommon.distFlat(scoutPos, uPos) + if d < visRange then + -- is in visual range. do we have LOS? + if land.isVisible(scoutPos, uPos) then + -- group is visible, stop here, return true + return true, uPos + end + else + -- OPTIMIZATION: if a unit is outside + -- detect range, we assume that entire group + -- is, since they are bunched together + -- edge cases may get lucky tests + return false, nil + end + end + end + return false, nil -- nothing visible +end + +function cfxReconMode.placeMarkForUnit(location, theSide, theGroup) + local theID = cfxReconMode.uuid() + local theDesc = "Contact: "..theGroup:getName() + if cfxReconMode.reportNumbers then + theDesc = theDesc .. " (" .. theGroup:getSize() .. " units)" + end + trigger.action.markToCoalition( + theID, + theDesc, + location, + theSide, + false, + nil) + return theID +end + +function cfxReconMode.removeMarkForArgs(args) + local theSide = args[1] + local theScout = args[2] + local theGroup = args[3] + local theID = args[4] + local theName = args[5] + +-- if not theGroup then return end +-- if not theGroup:isExist then return end + + trigger.action.removeMark(theID) + cfxReconMode.detectedGroups[theName] = nil + + -- invoke callbacks + cfxReconMode.invokeCallbacks("removed", theSide, theScout, theGroup, theName) +end + + +function cfxReconMode.detectedGroup(mySide, theScout, theGroup, theLoc) + -- put a mark on the map + if cfxReconMode.applyMarks then + local theID = cfxReconMode.placeMarkForUnit(theLoc, mySide, theGroup) + + -- schedule removal if desired + if cfxReconMode.marksFadeAfter > 0 then + args = {mySide, theScout, theGroup, theID, theGroup:getName()} + timer.scheduleFunction(cfxReconMode.removeMarkForArgs, args, timer.getTime() + cfxReconMode.marksFadeAfter) + end + end + -- say something + if cfxReconMode.announcer then + trigger.action.outTextForCoalition(mySide, theScout:getName() .. " reports new ground contact " .. theGroup:getName(), 30) + -- play a sound + trigger.action.outSoundForCoalition(mySide, cfxReconMode.reconSound) + end + + -- see if it was a prio target + if cfxReconMode.isStringInList(theGroup:getName(), cfxReconMode.prioList) then + if cfxReconMode.announcer then + trigger.action.outTextForCoalition(mySide, "Priority target confirmed", 30) + end + -- invoke callbacks + cfxReconMode.invokeCallbacks("priotity", mySide, theScout, theGroup, theGroup:getName()) + + -- increase prio flag + if cfxReconMode.prioFlag then + local currVal = trigger.misc.getUserFlag(cfxReconMode.prioFlag) + trigger.action.setUserFlag(cfxReconMode.prioFlag, currVal + 1) + end + else + -- invoke callbacks + cfxReconMode.invokeCallbacks("detected", mySide, theScout, theGroup, theGroup:getName()) + + -- increase normal flag + if cfxReconMode.detectFlag then + local currVal = trigger.misc.getUserFlag(cfxReconMode.detectFlag) + trigger.action.setUserFlag(cfxReconMode.detectFlag, currVal + 1) + end + end +end + +function cfxReconMode.performReconForUnit(theScout) + if not theScout then return end + if not theScout:isExist() then return end -- will be gc'd soon + -- get altitude above ground to calculate visual range + local alt = dcsCommon.getUnitAGL(theScout) + local visRange = dcsCommon.lerp(cfxReconMode.detectionMinRange, cfxReconMode.detectionMaxRange, alt/cfxReconMode.maxAlt) + local scoutPos = theScout:getPoint() + -- figure out which groups we are looking for + local myCoal = theScout:getCoalition() + local enemyCoal = 1 + if myCoal == 1 then enemyCoal = 2 end + + -- iterate all enemy units until we find one + -- and then stop this iteration (can only detect one + -- group per pass) + local enemyGroups = coalition.getGroups(enemyCoal) + for idx, theGroup in pairs (enemyGroups) do + -- make sure it's a ground unit + local isGround = theGroup:getCategory() == 2 + if theGroup:isExist() and isGround then + local visible, location = cfxReconMode.canDetect(scoutPos, theGroup, visRange) + if visible then + -- see if we already detected this one + local groupName = theGroup:getName() + if cfxReconMode.detectedGroups[groupName] == nil then + -- only now check against blackList + if not cfxReconMode.isStringInList(groupName, cfxReconMode.blackList) then + -- visible and not yet seen + -- perhaps add some percent chance now + -- remember that we know this group + cfxReconMode.detectedGroups[groupName] = theGroup + cfxReconMode.detectedGroup(myCoal, theScout, theGroup, location) + return -- stop, as we only detect one group per pass + end + end + end + end + end +end + + + +function cfxReconMode.updateQueues() + -- schedule next call + timer.scheduleFunction(cfxReconMode.updateQueues, {}, timer.getTime() + 1/cfxReconMode.ups) + + -- we only process the first aircraft in + -- the scouts array, move it to processed and then shrink + -- scouts table until it's empty. When empty, transfer all + -- back and start cycle anew + + local theFocusScoutName = nil + local procCount = 0 -- no iterations done yet + for name, scout in pairs(cfxReconMode.scouts) do + theFocusScoutName = name -- remember so we can delete + if not scout:isExist() then + -- we ignore the scout, and it's + -- forgotten since no longer transferred + -- i.e. built-in GC + if cfxReconMode.verbose then + trigger.action.outText("+++rcn: GC - removing scout " .. name .. " because it no longer exists", 30) + end + -- invoke 'end' for this scout + cfxReconMode.invokeCallbacks("dead", -1, nil, nil, name) + else + -- scan for this scout + cfxReconMode.performReconForUnit(scout) + -- move it to processed table + cfxReconMode.processedScouts[name] = scout + end + procCount = 1 -- remember we went through one iteration + break -- always end after first iteration + end + + -- remove processed scouts from scouts array + if procCount > 0 then + -- we processed one scout (even if scout itself did not exist) + -- remove that scout from active scouts table + cfxReconMode.scouts[theFocusScoutName] = nil + else + -- scouts is empty. copy processed table back to scouts + -- restart scouts array, contains GC already + cfxReconMode.scouts = cfxReconMode.processedScouts + cfxReconMode.processedScouts = {} -- start new empty processed queue + end +end + +-- event handler +function cfxReconMode:onEvent(event) + if not event then return end + if not event.initiator then return end + local theUnit = event.initiator + + -- we simply add scouts as they are garbage-collected + -- every so often when they do not exist + if event.id == 15 or -- birth + event.id == 3 -- take-off. should already have been taken + -- care of by birth, but you never know + then + -- check if a side must not have scouts. + -- this will prevent player units to auto- + -- scout when they are on that side. in that case + -- you must add manually + local theSide = theUnit:getCoalition() + if theSide == 0 and not cfxReconMode.greyScouts then + return -- grey scouts are not allowed + end + if theSide == 1 and not cfxReconMode.redScouts then + return -- grey scouts are not allowed + end + if theSide == 2 and not cfxReconMode.blueScouts then + return -- grey scouts are not allowed + end + + if cfxReconMode.playerOnlyRecon then + if not theUnit:getPlayerName() then + return -- only players can do recon. this unit is AI + end + end + if cfxReconMode.verbose then + trigger.action.outText("+++rcn: event " .. event.id .. " for unit " .. theUnit:getName(), 30) + end + cfxReconMode.addScout(theUnit) + end +end + +-- +-- read all existing planes +-- +function cfxReconMode.processScoutGroups(theGroups) + for idx, aGroup in pairs(theGroups) do + -- process all planes in that group + -- we are very early in the mission, only few groups really + -- exist now, the rest of the units come in with 15 event + if aGroup:isExist() then + local allUnits = Group.getUnits(aGroup) + for idy, aUnit in pairs (allUnits) do + if aUnit:isExist() then + cfxReconMode.addScout(aUnit) + if cfxReconMode.verbose then + trigger.action.outText("+++rcn: added unit " ..aUnit:getName() .. " to pool at startup", 30) + end + end + end + end + end +end + +function cfxReconMode.initScouts() + -- get all groups of aircraft. Unrolled loop 0..2 + local theAirGroups = {} + if cfxReconMode.greyScouts then + theAirGroups = coalition.getGroups(0, 0) -- 0 = aircraft + cfxReconMode.processScoutGroups(theAirGroups) + end + if cfxReconMode.redScouts then + theAirGroups = coalition.getGroups(1, 0) -- 1 = red, 0 = aircraft + cfxReconMode.processScoutGroups(theAirGroups) + end + + if cfxReconMode.blueScouts then + theAirGroups = coalition.getGroups(2, 0) -- 2 = blue, 0 = aircraft + cfxReconMode.processScoutGroups(theAirGroups) + end +end + +-- +-- read config +-- +function cfxReconMode.readConfigZone() + -- note: must match exactly!!!! + local theZone = cfxZones.getZoneByName("reconModeConfig") + if not theZone then + if cfxReconMode.verbose then + trigger.action.outText("+++rcn: no config zone!", 30) + end + return + end + if cfxReconMode.verbose then + trigger.action.outText("+++rcn: found config zone!", 30) + end + + cfxReconMode.verbose = cfxZones.getBoolFromZoneProperty(theZone, "verbose", false) + + cfxReconMode.autoRecon = cfxZones.getBoolFromZoneProperty(theZone, "autoRecon", true) + cfxReconMode.redScouts = cfxZones.getBoolFromZoneProperty(theZone, "redScouts", false) + cfxReconMode.blueScouts = cfxZones.getBoolFromZoneProperty(theZone, "blueScouts", true) + cfxReconMode.greyScouts = cfxZones.getBoolFromZoneProperty(theZone, "greyScouts", false) + cfxReconMode.playerOnlyRecon = cfxZones.getBoolFromZoneProperty(theZone, "playerOnlyRecon", false) + cfxReconMode.reportNumbers = cfxZones.getBoolFromZoneProperty(theZone, "reportNumbers", true) + + cfxReconMode.detectionMinRange = cfxZones.getNumberFromZoneProperty(theZone, "detectionMinRange", 3000) + cfxReconMode.detectionMaxRange = cfxZones.getNumberFromZoneProperty(theZone, "detectionMaxRange", 12000) + cfxReconMode.maxAlt = cfxZones.getNumberFromZoneProperty(theZone, "maxAlt", 9000) + + if cfxZones.hasProperty(theZone, "prio+") then + cfxReconMode.prioFlag = cfxZones.getStringFromZoneProperty(theZone, "prio+", "none") + end + + if cfxZones.hasProperty(theZone, "detect+") then + cfxReconMode.detectFlag = cfxZones.getStringFromZoneProperty(theZone, "detect+", "none") + end + + + cfxReconMode.applyMarks = cfxZones.getBoolFromZoneProperty(theZone, "applyMarks", true) + cfxReconMode.announcer = cfxZones.getBoolFromZoneProperty(theZone, "announcer", true) + + if cfxZones.hasProperty(theZone, "reconSound") then + cfxReconMode.reconSound = cfxZones.getStringFromZoneProperty(theZone, "reconSound", "") + end +end + +-- +-- start +-- +function cfxReconMode.start() + -- lib check + if not dcsCommon.libCheck("cfx Recon Mode", + cfxReconMode.requiredLibs) then + return false + end + + -- read config + cfxReconMode.readConfigZone() + + -- gather exiting planes + cfxReconMode.initScouts() + + -- start update cycle + cfxReconMode.updateQueues() + + if cfxReconMode.autoRecon then + -- install own event handler to detect + -- when a unit takes off and add it to scout + -- roster + world.addEventHandler(cfxReconMode) + end + + trigger.action.outText("cfx Recon version " .. cfxReconMode.version .. " started.", 30) + return true +end + +-- +-- test callback +-- +function cfxReconMode.demoReconCB(reason, theSide, theScout, theGroup, theName) + trigger.action.outText("recon CB: " .. reason .. " -- " .. theScout:getName() .. " spotted " .. theName, 30) +end + +if not cfxReconMode.start() then + cfxReconMode = nil +end + +-- debug: wire up my own callback +-- cfxReconMode.addCallback(cfxReconMode.demoReconCB) + + +--[[-- + +ideas: + +- renew lease. when already sighted, simply renew lease, maybe update location. +- update marks and renew lease +TODO: red+ and blue+ - flags to increase when a plane of the other side is detected + + +--]]-- + + + + \ No newline at end of file diff --git a/modules/cfxSSBClient.lua b/modules/cfxSSBClient.lua new file mode 100644 index 0000000..5a025c0 --- /dev/null +++ b/modules/cfxSSBClient.lua @@ -0,0 +1,470 @@ +cfxSSBClient = {} +cfxSSBClient.version = "2.0.0" +cfxSSBClient.verbose = false +cfxSSBClient.singleUse = false -- set to true to block crashed planes +-- NOTE: singleUse (true) requires SSB to disable immediate respawn after kick +cfxSSBClient.reUseAfter = -1 -- seconds for re-use delay + -- only when singleUse is in effect. -1 means never + +cfxSSBClient.requiredLibs = { + "dcsCommon", -- always + "cfxGroups", -- for slot access + "cfxZones", -- Zones, of course +} + +--[[-- +Version History + 1.0.0 - initial version + 1.1.0 - detect airfield by action and location, not group name + 1.1.1 - performance tuning. only read player groups once + - and remove in-air-start groups from scan. this requires + - ssb (server) be not modified + 1.2.0 - API to close airfields: invoke openAirfieldNamed() + and closeAirfieldNamed() with name as string (exact match required) + to block an airfield for any player aircraft. + Works for FARPS as well + API to associate a player group with any airfied's status (nil for unbind): + cfxSSBClient.bindGroupToAirfield(group, airfieldName) + API shortcut to unbind groups: cfxSSBClient.unbindGroup(group) + verbose messages now identify better: "+++SSB:" + keepInAirGroups option + 2.0.0 - include single-use ability: crashed airplanes are blocked from further use + - single-use can be turned off + - getPlayerGroupForGroupNamed() + - split setSlotAccess to single accessor + and interator + - reUseAfter option for single-use + - dcsCommon, cfxZones import + +WHAT IT IS +SSB Client is a small script that forms the client-side counterpart to +Ciribob's simple slot block. It will block slots for all client airframes +that are on an airfield that does not belong to the faction that currently +owns the airfield. + +REQUIRES CIRIBOB's SIMPLE SLOT BLOCK (SSB) TO RUN ON THE SERVER + +If run without SSB, your planes will not be blocked. + +In order to work, a plane that should be blocked when the airfield or +FARP doesn't belong to the player's faction, the group's first unit +must be within 3000 meters of the airfield and on the ground. +Previous versions of this script relied on group names. No longer. + + +WARNING: +If you modified ssb's flag values, this script will not work + +YOU DO NOT NEED TO ACTIVATE SBB, THIS SCRIPT DOES SO AUTOMAGICALLY + + +--]]-- + +-- below value for enabled MUST BE THE SAME AS THE VALUE OF THE SAME NAME +-- IN SSB. DEFAULT IS ZERO, AND THIS WILL WORK + +cfxSSBClient.enabledFlagValue = 0 -- DO NOT CHANGE, MUST MATCH SSB +cfxSSBClient.disabledFlagValue = cfxSSBClient.enabledFlagValue + 100 -- DO NOT CHANGE +cfxSSBClient.allowNeutralFields = false -- set to FALSE if players can't spawn on neutral airfields +cfxSSBClient.maxAirfieldRange = 3000 -- meters to airfield before group is no longer associated with airfield +-- actions to home in on when a player plane is detected and a slot may +-- be blocked. Currently, homing in on airfield, but not fly over +cfxSSBClient.slotActions = { + "From Runway", + "From Parking Area", + "From Parking Area Hot", + "From Ground Area", + "From Ground Area Hot", +} +cfxSSBClient.keepInAirGroups = false -- if false we only look at planes starting on the ground +-- setting this to true only makes sense if you plan to bind in-air starts to airfields + +cfxSSBClient.playerGroups = {} +cfxSSBClient.closedAirfields = {} -- list that closes airfields for any aircrafts +cfxSSBClient.playerPlanes = {} -- names of units that a player is flying +cfxSSBClient.crashedGroups = {} -- names of groups to block after crash of their player-flown plane + + +function cfxSSBClient.closeAirfieldNamed(name) + if not name then return end + cfxSSBClient.closedAirfields[name] = true + cfxSSBClient.setSlotAccessByAirfieldOwner() + if cfxSSBClient.verbose then + trigger.action.outText("+++SSB: Airfield " .. name .. " now closed", 30) + end +end + +function cfxSSBClient.openAirFieldNamed(name) + cfxSSBClient.closedAirfields[name] = nil + cfxSSBClient.setSlotAccessByAirfieldOwner() + if cfxSSBClient.verbose then + trigger.action.outText("+++SSB: Airfield " .. name .. " just opened", 30) + end +end + +function cfxSSBClient.unbindGroup(groupName) + cfxSSBClient.bindGroupToAirfield(groupName, nil) +end + +function cfxSSBClient.bindGroupToAirfield(groupName, airfieldName) + if not groupName then return end + local airfield = nil + if airfieldName then airfield = Airbase.getByName(airfieldName) end + for idx, theGroup in pairs(cfxSSBClient.playerGroups) do + if theGroup.name == groupName then + if cfxSSBClient.verbose then + local newBind = "NIL" + if airfield then newBind = airfieldName end + trigger.action.outText("+++SSB: Group " .. theGroup.name .. " changed binding to " .. newBind, 30) + end + theGroup.airfield = airfield + return + end + end + if not airfieldName then airfieldName = "" end + trigger.action.outText("+++SSB: Binding Group " .. groupName .. " to " .. airfieldName .. " failed.", 30) +end +--[[-- +function cfxSSBClient.dist(point1, point2) -- returns distance between two points + local x = point1.x - point2.x + local y = point1.y - point2.y + local z = point1.z - point2.z + + return (x*x + y*y + z*z)^0.5 +end +--]]-- + +-- see if instring conatins what, defaults to case insensitive +--[[-- +function cfxSSBClient.containsString(inString, what, caseSensitive) + if (not caseSensitive) then + inString = string.upper(inString) + what = string.upper(what) + end + return string.find(inString, what) +end +--]]-- +--[[-- +function cfxSSBClient.arrayContainsString(theArray, theString) + -- warning: case sensitive! + if not theArray then return false end + if not theString then return false end + for i = 1, #theArray do + if theArray[i] == theString then return true end + end + return false +end +--]]-- + +function cfxSSBClient.getClosestAirbaseTo(thePoint) + local delta = math.huge + local allYourBase = world.getAirbases() -- get em all + local closestBase = nil + for idx, aBase in pairs(allYourBase) do + -- iterate them all + local abPoint = aBase:getPoint() + newDelta = dcsCommon.dist(thePoint, {x=abPoint.x, y = 0, z=abPoint.z}) + if newDelta < delta then + delta = newDelta + closestBase = aBase + end + end + return closestBase, delta +end + +function cfxSSBClient.setSlotAccessForGroup(theGroup) + if not theGroup then return end + -- WARNING: theGroup is cfxGroup record + local theName = theGroup.name + local theMatchingAirfield = theGroup.airfield + -- airfield was attached at startup to group + if cfxSSBClient.singleUse and cfxSSBClient.crashedGroups[theName] then + -- we don't check, as we know it's blocked after crash + -- and leave it as it is. Nothing to do at all now + + elseif theMatchingAirfield ~= nil then + -- we have found a plane that is tied to an airfield + -- so this group will receive a block/unblock + -- we always set all block/unblock every time + -- note: since caching, above guard not needed + local airFieldSide = theMatchingAirfield:getCoalition() + local groupCoalition = theGroup.coaNum + local blockState = cfxSSBClient.enabledFlagValue -- we default to ALLOW the block + local comment = "available" + + -- see if airfield is closed + local afName = theMatchingAirfield:getName() + if cfxSSBClient.closedAirfields[afName] then + -- airfield is closed. no take-offs + blockState = cfxSSBClient.disabledFlagValue + comment = "!closed airfield!" + end + + -- on top of that, check coalitions + if groupCoalition ~= airFieldSide then + -- we have a problem. sides don't match + if airFieldSide == 3 + or (cfxSSBClient.allowNeutralFields and airFieldSide == 0) + then + -- all is well, airfield is contested or neutral and + -- we allow this plane to spawn here + else + -- DISALLOWED!!!! + blockState = cfxSSBClient.disabledFlagValue + comment = "!!!BLOCKED!!!" + end + end + -- set the ssb flag for this group so the server can see it + trigger.action.setUserFlag(theName, blockState) + if cfxSSBClient.verbose then + trigger.action.outText("+++SSB: group ".. theName .. ": " .. comment, 30) + end + else + if cfxSSBClient.verbose then + trigger.action.outText("+++SSB: group ".. theName .. " no bound airfield: available", 30) + end + end +end + +function cfxSSBClient.getPlayerGroupForGroupNamed(aName) + local pGroups = cfxSSBClient.playerGroups + for idx, theGroup in pairs(pGroups) do + if theGroup.name == aName then return theGroup end + end + return nil +end + +function cfxSSBClient.setSlotAccessByAirfieldOwner() + -- get all groups that have a player-controlled aircraft + -- now uses cached, reduced set of player planes + local pGroups = cfxSSBClient.playerGroups -- cfxGroups.getPlayerGroup() -- we want the group.name attribute + for idx, theGroup in pairs(pGroups) do + cfxSSBClient.setSlotAccessForGroup(theGroup) + end + +end + +function cfxSSBClient.reOpenSlotForGroupNamed(args) + -- this is merely the timer shell for opening the crashed slot + gName = args[1] + cfxSSBClient.openSlotForCrashedGroupNamed(gName) +end + +function cfxSSBClient.openSlotForCrashedGroupNamed(gName) + if not gName then return end + local pGroup = cfxSSBClient.getPlayerGroupForGroupNamed(gName) + if not pGroup then return end + cfxSSBClient.crashedGroups[gName] = nil -- set to nil to forget this happened + cfxSSBClient.setSlotAccessForGroup(pGroup) -- set by current occupation status + trigger.action.outText("+++SSBC:SU: re-opened slot for group <" .. gName .. ">", 30) +end + +function cfxSSBClient:onEvent(event) + if event.id == 10 then -- S_EVENT_BASE_CAPTURED + if cfxSSBClient.verbose then + trigger.action.outText("+++SSB: CAPTURE EVENT -- RESETTING SLOTS", 30) + end + cfxSSBClient.setSlotAccessByAirfieldOwner() + end + +-- write down player names and planes + if event.id == 15 then + --trigger.action.outText("+++SSBC:SU: enter event 15", 30) + if not event.initiator then return end + local theUnit = event.initiator -- we know this exists + local uName = theUnit:getName() + if not uName then return end + -- player entered unit + local playerName = theUnit:getPlayerName() + if not playerName then + return -- NPC plane + end + -- remember this unit as player controlled plane + -- because player and plane can easily disconnect + cfxSSBClient.playerPlanes[uName] = playerName + trigger.action.outText("+++SSBC:SU: noted " .. playerName .. " piloting player unit " .. uName, 30) + return + end + + + if cfxSSBClient.singleUse and event.id == 5 then -- crash + if not event.initiator then return end + local theUnit = event.initiator + local uName = theUnit:getName() + if not uName then return end + local theGroup = theUnit:getGroup() + if not theGroup then return end + -- see if a player plane + local thePilot = cfxSSBClient.playerPlanes[uName] + if not thePilot then + -- ignore. not a player plane + trigger.action.outText("+++SSBC:SU: ignored crash for NPC unit <" .. uName .. ">", 30) + return + end + -- if we get here, a player-owned plane has crashed + local gName = theGroup:getName() + if not gName then return end + + -- block this slot. + trigger.action.setUserFlag(gName, cfxSSBClient.disabledFlagValue) + + -- remember this plane to not re-enable if + -- airfield changes hands later + cfxSSBClient.crashedGroups[gName] = thePilot -- set to crash pilot + trigger.action.outText("+++SSBC:SU: Blocked slot for group <" .. gName .. ">", 30) + + if cfxSSBClient.reUseAfter > 0 then + -- schedule re-opening this slot in seconds + timer.scheduleFunction( + cfxSSBClient.reOpenSlotForGroupNamed, + {gName}, + timer.getTime() + cfxSSBClient.reUseAfter + ) + end + end +end + +function cfxSSBClient.update() + -- first, re-schedule me in one minute + timer.scheduleFunction(cfxSSBClient.update, {}, timer.getTime() + 60) + + -- now establish all slot blocks + cfxSSBClient.setSlotAccessByAirfieldOwner() +end + +-- pre-process static player data to minimize +-- processor load on checks +function cfxSSBClient.processPlayerData() + cfxSSBClient.playerGroups = cfxGroups.getPlayerGroup() + local pGroups = cfxSSBClient.playerGroups + local filteredPlayers = {} + for idx, theGroup in pairs(pGroups) do + if theGroup.airfield ~= nil or cfxSSBClient.keepInAirGroups or + cfxSSBClient.singleUse then + -- only transfer groups that have airfields (or also keepInAirGroups or when single-use) + -- attached. Ignore the rest as they are + -- always fine + table.insert(filteredPlayers, theGroup) + end + end + cfxSSBClient.playerGroups = filteredPlayers +end + +-- add airfield information to each player group +function cfxSSBClient.processGroupData() + local pGroups = cfxGroups.getPlayerGroup() -- we want the group.name attribute + for idx, theGroup in pairs(pGroups) do + -- we always use the first player's plane as referenced + local playerData = theGroup.playerUnits[1] + local theAirfield = nil + local delta = -1 + local action = playerData.action + if not action then action = "" end + -- see if the data has any of the slot-interesting actions + if dcsCommon.arrayContainsString(cfxSSBClient.slotActions, action ) then + -- yes, fetch the closest airfield + theAirfield, delta = cfxSSBClient.getClosestAirbaseTo(playerData.point) + local afName = theAirfield:getName() + if cfxSSBClient.verbose then + trigger.action.outText("+++SSB: group: " .. theGroup.name .. " closest to AF " .. afName .. ": " .. delta .. "m" , 30) + end + if delta > cfxSSBClient.maxAirfieldRange then + -- forget airfield + theAirfield = nil + if cfxSSBClient.verbose then + trigger.action.outText("+++SSB: group: " .. theGroup.name .. " unlinked - too far from airfield" , 30) + end + end + theGroup.airfield = theAirfield + else + if cfxSSBClient.verbose then + trigger.action.outText("+++SSB: group: " .. theGroup.name .. " start option " .. action .. " does not concern SSB", 30) + end + end + end +end + +-- +-- read config zone +-- +function cfxSSBClient.readConfigZone() + -- note: must match exactly!!!! + local theZone = cfxZones.getZoneByName("SSBClientConfig") + if not theZone then + trigger.action.outText("+++SSBC: no config zone!", 30) + return + end + + trigger.action.outText("+++SSBC: found config zone!", 30) + + cfxSSBClient.verbose = cfxZones.getBoolFromZoneProperty(theZone, "verbose", false) + + -- single-use + cfxSSBClient.singleUse = cfxZones.getBoolFromZoneProperty(theZone, "singleUse", false) -- use airframes only once? respawn after kick must be disabled in ssb + cfxSSBClient.reUseAfter = cfxZones.getNumberFromZoneProperty(theZone, "reUseAfter", -1) + + -- airfield availability + cfxSSBClient.allowNeutralFields = cfxZones.getBoolFromZoneProperty(theZone, "allowNeutralFields", false) + + cfxSSBClient.maxAirfieldRange = cfxZones.getNumberFromZoneProperty(theZone, "maxAirfieldRange", 3000) -- meters, to find attached airfield + + -- optimization + + cfxSSBClient.keepInAirGroups = cfxZones.getBoolFromZoneProperty(theZone, "keepInAirGroups", false) + + -- SSB direct control. + -- USE ONLY WHEN YOU ARE SURE THAT YOU KNOW WHAT YOU ARE DOING + + cfxSSBClient.enabledFlagValue = cfxZones.getNumberFromZoneProperty(theZone, "enabledFlagValue", 0) + + cfxSSBClient.disabledFlagValue = cfxZones.getNumberFromZoneProperty(theZone, "disabledFlagValue", cfxSSBClient.enabledFlagValue + 100) +end + +-- +-- start +-- +function cfxSSBClient.start() + -- verify modules loaded + if not dcsCommon.libCheck("cfx SSB Client", + cfxSSBClient.requiredLibs) then + return false + end + + -- read config zone if present + cfxSSBClient.readConfigZone() + + -- install callback for events in DCS + world.addEventHandler(cfxSSBClient) + + -- process group data to attach airfields + cfxSSBClient.processGroupData() + + -- process player data to minimize effort and build cache + -- into cfxSSBClient.playerGroups + cfxSSBClient.processPlayerData() + + -- install a timed update just to make sure + -- and start NOW + timer.scheduleFunction(cfxSSBClient.update, {}, timer.getTime() + 1) + + -- now turn on ssb + trigger.action.setUserFlag("SSB",100) + + -- say hi! + trigger.action.outText("cfxSSBClient v".. cfxSSBClient.version .. " running, SBB enabled", 30) + + --cfxSSBClient.allYourBase() + + return true +end + +if not cfxSSBClient.start() then + trigger.action.outText("cfxSSBClient v".. cfxSSBClient.version .. " FAILED loading.", 30) + cfxSSBClient = nil +end + +--[[-- + possible improvements: + - use explicitBlockList that with API. planes on that list are always blocked. Use this for special effects, such as allowing a slot only to open from scripts, e.g. when a condition is met like money or goals reached + +-]]-- \ No newline at end of file diff --git a/modules/cfxSSBSingleUse.lua b/modules/cfxSSBSingleUse.lua new file mode 100644 index 0000000..f8a3fc1 --- /dev/null +++ b/modules/cfxSSBSingleUse.lua @@ -0,0 +1,154 @@ +cfxSSBSingleUse = {} +cfxSSBSingleUse.version = "1.1.0" + +--[[-- +Version History + 1.0.0 - Initial version + 1.1.0 - importing dcsCommon, cfxGroup for simplicity + - save unit name on player enter unit as look-up + - determining ground-start + - place wreck in slot + 1.1.1 - guarding against nil playerName + - using 15 (birth) instead of 20 (player enter) +WHAT IT IS +SSB Single Use is a script that blocks a player slot +after that plane crashes. + + +--]]-- + +cfxSSBSingleUse.enabledFlagValue = 0 -- DO NOT CHANGE, MUST MATCH SSB +cfxSSBSingleUse.disabledFlagValue = cfxSSBSingleUse.enabledFlagValue + 100 -- DO NOT CHANGE + + +cfxSSBSingleUse.playerUnits = {} +cfxSSBSingleUse.slotGroundActions = { +-- "From Runway", -- NOT RUNWAY, as that would litter runway + "From Parking Area", + "From Parking Area Hot", + "From Ground Area", + "From Ground Area Hot", +} + +cfxSSBSingleUse.groundSlots = {} -- players that start on the ground + +function cfxSSBSingleUse:onEvent(event) + + if not event then return end + if not event.id then return end + if not event.initiator then return end + -- if we get here, initiator is set + local theUnit = event.initiator -- we know this exists + + + -- write down player names and planes + if event.id == 15 then + local uName = theUnit:getName() + if not uName then return end + -- player entered unit + local playerName = theUnit:getPlayerName() + if not playerName then + return -- NPC plane + end + -- remember this unit as player unit + cfxSSBSingleUse.playerUnits[uName] = playerName + trigger.action.outText("+++singleUse: noted " .. playerName .. " piloting player unit " .. uName, 30) + return + end + + -- check for a crash + if event.id == 5 then -- S_EVENT_CRASH + local uName = theUnit:getName() + if not uName then return end + + local theGroup = theUnit:getGroup() + if not theGroup then return end + + -- see if a player plane + local thePilot = cfxSSBSingleUse.playerUnits[uName] + + if not thePilot then + -- ignore. not a player plane + trigger.action.outText("+++singleUse: ignored crash for NPC unit <" .. uName .. ">", 30) + return + end + + local gName = theGroup:getName() + if not gName then return end + + -- see if it was a ground slot + local theGroundSlot = cfxSSBSingleUse.groundSlots[gName] + if theGroundSlot then + local unitType = theUnit:getTypeName() + trigger.action.outText("+++singleUse: <" .. uName .. "> starts on Ground. Will place debris for " .. unitType .. " NOW!!!", 30) + cfxSSBSingleUse.placeDebris(unitType, theGroundSlot) + end + + -- block this slot. + trigger.action.setUserFlag(gName, cfxSSBSingleUse.disabledFlagValue) + + trigger.action.outText("+++singleUse: blocked <" .. gName .. "> after " .. thePilot .. " crashed it.", 30) + end +end + +function cfxSSBSingleUse.placeDebris(unitType, theGroundSlot) + if not unitType then return end + -- access location one, we assume single-unit groups + -- or at least that the player sits in unit one + local playerData = theGroundSlot.playerUnits + local theSlotData = playerData[1] + local wreckData = {} + wreckData.heading = 0 + wreckData.name = dcsCommon.uuid("singleUseWreck"..theSlotData.name) + wreckData.x = tonumber(theSlotData.point.x) + wreckData.y = tonumber(theSlotData.point.z) + wreckData.dead = true + wreckData.type = unitType + + coalition.addStaticObject(theGroundSlot.coaNum, wreckData ) + trigger.action.outText("+++singleUse: wreck <" .. unitType .. "> at " .. wreckData.x .. ", " .. wreckData.y .. " for " .. wreckData.name, 30) +end + + +function cfxSSBSingleUse.populateAirfieldSlots() + local pGroups = cfxGroups.getPlayerGroup() + local groundStarters = {} + for idx, theGroup in pairs(pGroups) do + -- we always use the first player's plane as referenced + local playerData = theGroup.playerUnits[1] + local action = playerData.action + if not action then action = "" end + -- see if the data has any of the slot-interesting actions + if dcsCommon.arrayContainsString(cfxSSBSingleUse.slotGroundActions, action ) then + -- ground starter, not from runway + groundStarters[theGroup.name] = theGroup + trigger.action.outText("+++singleUse: <" .. theGroup.name .. "> is ground starter", 30) + end + end + cfxSSBSingleUse.groundSlots = groundStarters +end + +function cfxSSBSingleUse.start() + -- install event monitor + world.addEventHandler(cfxSSBSingleUse) + + -- get all groups and process them to find + -- all planes that are on the ground for + -- eye candy + cfxSSBSingleUse.populateAirfieldSlots() + + -- turn on ssb + trigger.action.setUserFlag("SSB",100) + + trigger.action.outText("SSB Single use v" .. cfxSSBSingleUse.version .. " running", 30) +end + +-- let's go! +cfxSSBSingleUse.start() + +--[[-- +Additional features (later): +- place a wreck in slot when blocking for eye candy +- record player when they enter a unit and only block player planes + +--]]-- \ No newline at end of file diff --git a/modules/cfxSmokeZones.lua b/modules/cfxSmokeZones.lua new file mode 100644 index 0000000..0e38869 --- /dev/null +++ b/modules/cfxSmokeZones.lua @@ -0,0 +1,111 @@ +cfxSmokeZone = {} +cfxSmokeZone.version = "1.0.2" +cfxSmokeZone.requiredLibs = { + "dcsCommon", -- always + "cfxZones", -- Zones, of course +} +--[[-- + Version History + 1.0.0 - initial version + 1.0.1 - added removeSmokeZone + 1.0.2 - added altitude + + SMOKE ZONES *** EXTENDS ZONES *** + keeps 'eternal' smoke up for any zone that has the + 'smoke' attribute + + USAGE + add a 'smoke' attribute to the zone. the value of the attribute + defines the color. Valid values are: red, green, blue, white, orange, 0 (results in green smoke), 1 (red smoke), 2 (white), 3 (orange), 4 (blue) + defaults to "green" + altiude is meters above ground height, defaults to 5m +--]]-- +cfxSmokeZone.smokeZones = {} +cfxSmokeZone.updateDelay = 5 * 60 -- every 5 minutes + +function cfxSmokeZone.processSmokeZone(aZone) + local rawVal = cfxZones.getStringFromZoneProperty(aZone, "smoke", "green") + rawVal = rawVal:lower() + local theColor = 0 + if rawVal == "red" or rawVal == "1" then theColor = 1 end + if rawVal == "white" or rawVal == "2" then theColor = 2 end + if rawVal == "orange" or rawVal == "3" then theColor = 3 end + if rawVal == "blue" or rawVal == "4" then theColor = 4 end + + aZone.smokeColor = theColor + aZone.smokeAlt = cfxZones.getNumberFromZoneProperty(aZone, "altitude", 1) +end + +function cfxSmokeZone.addSmokeZone(aZone) + table.insert(cfxSmokeZone.smokeZones, aZone) +end + +function cfxSmokeZone.addSmokeZoneWithColor(aZone, aColor, anAltitude) + if not aColor then aColor = 0 end -- default green + if not anAltitude then anAltitude = 5 end + if not aZone then return end + aZone.smokeColor = aColor + aZone.smokeAlt = anAltitude + cfxSmokeZone.addSmokeZone(aZone) -- add to update loop + cfxZones.markZoneWithSmoke(aZone, 0, 0, aZone.smokeColor, aZone.smokeAlt) -- smoke on! +end + +function cfxSmokeZone.removeSmokeZone(aZone) + if not aZone then return end + if type(aZone) == "string" then + aZone = cfxZones.getZoneByName(aZone) + end + + -- now create new table + local filtered = {} + for idx, theZone in pairs(cfxSmokeZone.smokeZones) do + if theZone ~= aZone then + table.insert(filtered, theZone) + end + end + cfxSmokeZone.smokeZones = filtered +end + + +function cfxSmokeZone.update() + -- call me in a couple of minutes to 'rekindle' + timer.scheduleFunction(cfxSmokeZone.update, {}, timer.getTime() + cfxSmokeZone.updateDelay) + + -- re-smoke all zones after delay + for idx, aZone in pairs(cfxSmokeZone.smokeZones) do + if aZone.smokeColor then + cfxZones.markZoneWithSmoke(aZone, 0, 0, aZone.smokeColor, aZone.smokeAlt) + end + end +end + +function cfxSmokeZone.start() + if not dcsCommon.libCheck("cfx Smoke Zones", + cfxSmokeZone.requiredLibs) then + return false + end + + -- collect all zones with 'smoke' attribute + -- collect all spawn zones + local attrZones = cfxZones.getZonesWithAttributeNamed("smoke") + + -- now create a spawner for all, add them to the spawner updater, and spawn for all zones that are not + -- paused + for k, aZone in pairs(attrZones) do + cfxSmokeZone.processSmokeZone(aZone) -- process attribute and add to zone + cfxSmokeZone.addSmokeZone(aZone) -- remember it so we can smoke it + end + + -- start update loop + cfxSmokeZone.update() + + -- say hi + trigger.action.outText("cfx Smoke Zones v" .. cfxSmokeZone.version .. " started.", 30) + return true +end + +-- let's go +if not cfxSmokeZone.start() then + trigger.action.outText("cf/x Smoke Zones aborted: missing libraries", 30) + cfxSmokeZone = nil +end \ No newline at end of file diff --git a/modules/cfxSpawnZones.lua b/modules/cfxSpawnZones.lua new file mode 100644 index 0000000..428c815 --- /dev/null +++ b/modules/cfxSpawnZones.lua @@ -0,0 +1,485 @@ +cfxSpawnZones = {} +cfxSpawnZones.version = "1.5.1" +cfxSpawnZones.requiredLibs = { + "dcsCommon", -- common is of course needed for everything + -- pretty stupid to check for this since we + -- need common to invoke the check, but anyway + "cfxZones", -- Zones, of course + "cfxCommander", -- to make troops do stuff + "cfxGroundTroops", -- generic data module for weight +} +cfxSpawnZones.ups = 1 +cfxSpawnZones.verbose = false +-- +-- Zones that conform with this requirements spawn toops automatically +-- *** DOES NOT EXTEND ZONES *** LINKED OWNER via masterOwner *** +-- + +-- version history +-- 1.3.0 +-- - maxSpawn +-- - orders +-- - range +-- 1.3.1 - spawnWithSpawner correct translation of country to coalition +-- - createSpawner - corrected reading from properties +-- 1.3.2 - createSpawner - correct reading 'owner' from properties, now +-- directly reads coalition +-- 1.4.0 - checks modules +-- - orders 'train' or 'training' - will make the +-- ground troops be issued HOLD WEAPS and +-- not added to any queue. 'Training' troops +-- are target dummies. +-- - optional heading attribute +-- - typeMult: repeate type this many time (can produce army in one call) +-- 1.4.1 - 'requestable' attribute. will automatically set zone to +-- - paused, so troops can be produced on call +-- - getRequestableSpawnersInRange +-- 1.4.2 - target attribute. used for +-- - orders: attackZone +-- - spawner internally copies name from cfxZone used for spawning (convenience only) +-- 1.4.3 - can subscribe to callbacks. currently called when spawnForSpawner is invoked, reason is "spawned" +-- - masterOwner to link ownership to other zone +-- 1.4.4 - autoRemove flag to instantly start CD and respawn +-- 1.4.5 - verify that maxSpawns ~= 0 on initial spawn on start-up +-- 1.4.6 - getSpawnerForZoneNamed(aName) +-- - nil-trapping orders before testing for 'training' +-- 1.4.7 - defaulting orders to 'guard' +-- - also accept 'dummy' and 'dummies' as substitute for training +-- 1.4.8 - spawnWithSpawner uses getPoint to support linked spawn zones +-- - update spawn count on initial spawn +-- 1.5.0 - f? support to trigger spawn +-- - spawnWithSpawner made string compatible +-- 1.5.1 - relaxed baseName and default to dcsCommon.uuid() +-- - verbose +-- +-- new version requires cfxGroundTroops, where they are +-- +-- How do we recognize a spawn zone? +-- contains a "spawner" attribute +-- a spawner must also have the following attributes +-- - spawner - anything, must be present to signal. put in 'ground' to be able to expand to other types +-- - types - type strings, comma separated +-- see here: https://github.com/mrSkortch/DCS-miscScripts/tree/master/ObjectDB +-- - typeMult - repeat types n times to create really LOT of troops. optional, defaults to 1 +-- - country - defaults to 2 (usa) -- see here https://wiki.hoggitworld.com/view/DCS_enum_country +-- some important: 0 = Russia, 2 = US, 82 = UN neutral +-- country is converted to coalition and then assigned to +-- Joint Task Force upon spawn +-- - masterOwner - optional name of master cfxZone used to determine whom the surrounding +-- territory belongs to. Spwaner will only spawn if the owner coalition is the +-- the same as the coalition my own county belongs to. +-- if not given, spawner spawns even if inside a zone owned by opposing force +-- - baseName - for naming spawned groups - MUST BE UNIQUE!!!! +-- +-- the following attributes are optional +-- - cooldown, defaults to 60 (seconds) after troops are removed from zone, +-- then the next group spawns. This means troops will only spawn after +-- troops are removed and cooldown timed out +-- - autoRemove - instantly removes spwaned troops, will spawn again +-- again after colldown +-- - formation - default is circle_out; other formations are +-- - line - left lo right (west-east) facing north +-- - line_V - vertical line, facing north +-- - chevron - west-east, point growing to north +-- - scattered, random +-- - circle, circle_forward (all fact north) +-- - circle-in (all face in) +-- - circle-out (all face out) +-- - grid, square, rect arrayed in optimal grid +-- - 2deep, 2cols two columns, deep +-- - 2wide 2 columns wide (2 deep) +-- - heading in DEGREES (deafult 0 = north ) direction entire group is facing +-- - destination - zone name to go to, no destination = stay where you are +-- - paused - defaults to false. If present true, spawning will not happen +-- you can then manually invoke cfxSpawnZones.spawnWithSpawner(spawner) to +-- spawn the troops as they are described in the spawner +-- - orders - tell them what to do. "train" makes them dummies, "guard" +-- "laze", "wait-laze" etc +-- other orders are as defined by cfxGroundTroops, at least +-- guard - hold and defend (default) +-- laze - laze targets +-- wait-xxx for helo troops, stand by until dropped from helo +-- attackOwnedZone - seek nearest owned zone and attack +-- attackZone - move towards the named cfxZone. will generate error if zone not found +-- name of zone to attack is in 'target' attribute +-- - target - names a target cfxZone, used for orders. Troops will immediately +-- start moving towards that zone if defined and such a zone exists +-- - maxSpawns - limit number of spawn cycles. omit or -1 is unlimited +-- - requestable - used with heloTroops to determine if spawning can be ordered by +-- comms when in range +-- respawn currently happens after theSpawn is deleted and cooldown seconds have passed +cfxSpawnZones.allSpawners = {} +cfxSpawnZones.callbacks = {} -- signature: cb(reason, group, spawner) + + +-- +-- C A L L B A C K S +-- +function cfxSpawnZones.addCallback(theCallback) + table.insert(cfxSpawnZones.callbacks, theCallback) +end + +function cfxSpawnZones.invokeCallbacksFor(reason, theGroup, theSpawner) + for idx, theCB in pairs (cfxSpawnZones.callbacks) do + theCB(reason, theGroup, theSpawner) + end +end + + +-- +-- creating a spawner +-- +function cfxSpawnZones.createSpawner(inZone) + local theSpawner = {} + theSpawner.zone = inZone + theSpawner.name = inZone.name + -- connect with ME if a trigger flag is given + if cfxZones.hasProperty(inZone, "f?") then + theSpawner.triggerFlag = cfxZones.getStringFromZoneProperty(inZone, "f?", "none") + theSpawner.lastTriggerValue = trigger.misc.getUserFlag(theSpawner.triggerFlag) + end + + theSpawner.types = cfxZones.getZoneProperty(inZone, "types") + --theSpawner.owner = cfxZones.getCoalitionFromZoneProperty(inZone, "owner", 0) + -- synthesize types * typeMult + local n = cfxZones.getNumberFromZoneProperty(inZone, "typeMult", 1) + local repeater = "" + if n < 1 then n = 1 end + while n > 1 do + repeater = repeater .. "," .. theSpawner.types + n = n - 1 + end + theSpawner.types = theSpawner.types .. repeater + + theSpawner.country = cfxZones.getNumberFromZoneProperty(inZone, "country", 0) -- coalition2county(theSpawner.owner) + theSpawner.masterZoneName = cfxZones.getStringFromZoneProperty(inZone, "masterOwner", "") + if theSpawner.masterZoneName == "" then theSpawner.masterZoneName = nil end + + theSpawner.rawOwner = coalition.getCountryCoalition(theSpawner.country) + --theSpawner.baseName = cfxZones.getZoneProperty(inZone, "baseName") + theSpawner.baseName = cfxZones.getStringFromZoneProperty(inZone, "baseName", dcsCommon.uuid("SpwnDflt")) + + theSpawner.cooldown = cfxZones.getNumberFromZoneProperty(inZone, "cooldown", 60) + theSpawner.autoRemove = cfxZones.getBoolFromZoneProperty(inZone, "autoRemove", false) + theSpawner.lastSpawnTimeStamp = -10000 -- just init so it will always work + theSpawner.heading = cfxZones.getNumberFromZoneProperty(inZone, "heading", 0) + --trigger.action.outText("+++spwn: zone " .. inZone.name .. " owner " .. theSpawner.owner " --> ctry " .. theSpawner.country, 30) + + theSpawner.cdTimer = 0 -- used for cooldown. if timer.getTime < this value, don't spawn + theSpawner.cdStarted = false -- used to initiate cooldown when theSpawn disappears + theSpawner.count = 1 -- used to create names, and count how many groups created + theSpawner.theSpawn = nil -- link to last spawned group + theSpawner.formation = "circle_out" + theSpawner.formation = cfxZones.getStringFromZoneProperty(inZone, "formation", "circle_out") + theSpawner.paused = cfxZones.getBoolFromZoneProperty(inZone, "paused", false) + theSpawner.orders = cfxZones.getStringFromZoneProperty(inZone, "orders", "guard") + --theSpawner.orders = cfxZones.getZoneProperty(inZone, "orders") + -- used to assign special orders, default is 'guard', use "laze" to make them laze targets. can be 'wait-' which may auto-convert to 'guard' after pick-up by helo, to be handled outside. + -- use "train" to tell them to HOLD WEAPONS, don't move and don't participate in loop, so we have in effect target dummies + -- can also use order 'dummy' or 'dummies' to switch to train + if theSpawner.orders:lower() == "dummy" or theSpawner.orders:lower() == "dummies" then theSpawner.orders = "train" end + + theSpawner.range = cfxZones.getNumberFromZoneProperty(inZone, "range", 300) -- if we have a range, for example enemy detection for Lasing or engage range + theSpawner.maxSpawns = cfxZones.getNumberFromZoneProperty(inZone, "maxSpawns", -1) -- if there is a limit on how many troops can spawn. -1 = endless spawns + theSpawner.requestable = cfxZones.getBoolFromZoneProperty(inZone, "requestable", false) + if theSpawner.requestable then + theSpawner.paused = true + end + theSpawner.target = cfxZones.getStringFromZoneProperty(inZone, "target", "") + if theSpawner.target == "" then -- this is the defaut case + theSpawner.target = nil + end + + return theSpawner +end + +function cfxSpawnZones.addSpawner(aSpawner) + cfxSpawnZones.allSpawners[aSpawner.zone] = aSpawner +end + +function cfxSpawnZones.removeSpawner(aSpawner) + cfxSpawnZones.allSpawners[aSpawner.zone] = nil +end + +function cfxSpawnZones.getSpawnerForZone(aZone) + return cfxSpawnZones.allSpawners[aZone] +end + +function cfxSpawnZones.getSpawnerForZoneNamed(aName) + local aZone = cfxZones.getZoneByName(aName) + return cfxSpawnZones.getSpawnerForZone(aZone) +end + + +function cfxSpawnZones.getRequestableSpawnersInRange(aPoint, aRange, aSide) + -- trigger.action.outText("enter requestable spawners for side " .. aSide , 30) + if not aSide then aSide = 0 end + if not aRange then aRange = 200 end + if not aPoint then return {} end + + local theSpawners = {} + for aZone, aSpawner in pairs(cfxSpawnZones.allSpawners) do + -- iterate all zones and collect those that match + local hasMatch = true + local delta = dcsCommon.dist(aPoint, aZone.point) + if delta>aRange then hasMatch = false end + if aSide ~= 0 then + -- check if side is correct for owned zone + if not cfxSpawnZones.verifySpawnOwnership(aSpawner) then + -- failed ownership test. owner of master + -- is not my own zone + hasMatch = false + end + end + + if aSide ~= aSpawner.rawOwner then + -- only return spawners with this side + -- note: this will NOT work with neutral players + hasMatch = false + end + + if not aSpawner.requestable then + hasMatch = false + end + + if hasMatch then + table.insert(theSpawners, aSpawner) + end + end + + return theSpawners +end +-- +-- spawn troops +-- +function cfxSpawnZones.verifySpawnOwnership(spawner) + -- returns false ONLY if masterSpawn disagrees + if not spawner.masterZoneName then + --trigger.action.outText("spawner " .. spawner.name .. " no master, go!", 30) + return true + end -- no master owner, all ok + local myCoalition = spawner.rawOwner + local masterZone = cfxZones.getZoneByName(spawner.masterZoneName) + if not masterZone then + trigger.action.outText("spawner " .. spawner.name .. " DID NOT FIND MASTER ZONE <" .. spawner.masterZoneName .. ">", 30) + end + + if not masterZone.owner then + --trigger.action.outText("spawner " .. spawner.name .. " - masterZone " .. masterZone.name .. " HAS NO OWNER????", 30) + return true + end + + if (myCoalition ~= masterZone.owner) then + -- can't spawn, surrounding area owned by enemy + --trigger.action.outText("spawner " .. spawner.name .. " - spawn suppressed: area not owned: " .. " master owner is " .. masterZone.owner .. ", we are " .. myCoalition, 30) + return false + end + --trigger.action.outText("spawner " .. spawner.name .. " good to go: ", 30) + return true +end + +function cfxSpawnZones.spawnWithSpawner(aSpawner) + if type(aSpawner) == "string" then -- return spawner for zone of that name + aSpawner = cfxSpawnZones.getSpawnerForZoneNamed(aName) + end + if not aSpawner then return end + + -- will NOT check if conditions are met. This forces a spawn + local unitTypes = {} -- build type names + --local p = aSpawner.zone.point + local p = cfxZones.getPoint(aSpawner.zone) -- aSpawner.zone.point + + -- split the conf.troopsOnBoardTypes into an array of types + unitTypes = dcsCommon.splitString(aSpawner.types, ",") + if #unitTypes < 1 then + table.insert(unitTypes, "Soldier M4") -- make it one m4 trooper as fallback + end + + local theCountry = aSpawner.country + local theCoalition = coalition.getCountryCoalition(theCountry) +-- trigger.action.outText("+++ spawn: coal <" .. theCoalition .. "> from country <" .. theCountry .. ">", 30) + + local theGroup = cfxZones.createGroundUnitsInZoneForCoalition ( + theCoalition, + aSpawner.baseName .. "-" .. aSpawner.count, -- must be unique + aSpawner.zone, + unitTypes, + aSpawner.formation, + aSpawner.heading) + aSpawner.theSpawn = theGroup + aSpawner.count = aSpawner.count + 1 + -- we may also want to add this to auto ground troops pool + -- we not only want to, we absolutely need to in order + -- to make this work. + + if aSpawner.orders and ( + aSpawner.orders:lower() == "training" or + aSpawner.orders:lower() == "train" ) + then + -- make them ROE "HOLD" + cfxCommander.scheduleOptionForGroup( + theGroup, + AI.Option.Ground.id.ROE, + AI.Option.Ground.val.ROE.WEAPON_HOLD, + 1.0) + else + local newTroops = cfxGroundTroops.createGroundTroops(theGroup, aSpawner.range, aSpawner.orders) + cfxGroundTroops.addGroundTroopsToPool(newTroops) + + -- see if we have defined a target zone as destination + if aSpawner.target then + local destZone = cfxZones.getZoneByName(aSpawner.target) + if destZone then + newTroops.destination = destZone + cfxGroundTroops.makeTroopsEngageZone(newTroops) + else + trigger.action.outText("+++ spawner " .. aSpawner.name .. " has illegal target " .. aSpawner.target .. ". Pausing.", 30) + aSpawner.paused = true + end + elseif aSpawner.orders == "attackZone" then + trigger.action.outText("+++ spawner " .. aSpawner.name .. " has no target but attackZone command. Pausing.", 30) + aSpawner.paused = true + end + + end + + -- callback to all who want to know + cfxSpawnZones.invokeCallbacksFor("spawned", theGroup, aSpawner) + + -- timestamp so we can check against cooldown on manual spawn + aSpawner.lastSpawnTimeStamp = timer.getTime() + -- make sure a requestable spawner is always paused + if aSpawner.requestable then + aSpawner.paused = true + end + + if aSpawner.autoRemove then + -- simply remove the group + aSpawner.theSpawn = nil + end +end + + +-- +-- U P D A T E +-- + +function cfxSpawnZones.update() + cfxSpawnZones.updateSchedule = timer.scheduleFunction(cfxSpawnZones.update, {}, timer.getTime() + 1/cfxSpawnZones.ups) + + for key, spawner in pairs (cfxSpawnZones.allSpawners) do + -- see if the spawn is dead or was removed + local needsSpawn = true + if spawner.theSpawn then + local group = spawner.theSpawn + if group:isExist() then + -- see how many members of this group are still alive + local liveUnits = dcsCommon.getLiveGroupUnits(group) + -- spawn is still alive, will not spawn + if #liveUnits > 1 then + -- we may want to check if this member is still inside + -- of spawn location. currently we don't do that + needsSpawn = false + end + end + end + + if spawner.paused then needsSpawn = false end + + -- see if we spawned maximum number of times already + -- or have -1 as maxspawn, indicating endless + if needsSpawn and spawner.maxSpawns > -1 then + needsSpawn = spawner.maxSpawns > 0 + end + + if needsSpawn then + -- is this the first time? + if not spawner.cdStarted then + -- no, start cooldown + spawner.cdStarted = true + spawner.cdTimer = timer.getTime() + spawner.cooldown + end + end + + -- still on cooldown? + if timer.getTime() < spawner.cdTimer then needsSpawn = false end + + -- is master zone still alinged with me? + needsSpawn = needsSpawn and cfxSpawnZones.verifySpawnOwnership(spawner) + + -- check if perhaps our watchtrigger causes spawn + if spawner.triggerFlag then + local currTriggerVal = trigger.misc.getUserFlag(spawner.triggerFlag) + if currTriggerVal ~= spawner.lastTriggerValue then + needsSpawn = true + spawner.lastTriggerValue = currTriggerVal + end + end + + -- if we get here, and needsSpawn is still set, we go ahead and spawn + if needsSpawn then +--- trigger.action.outText("+++ spawning for zone " .. spawner.zone.name, 30) + cfxSpawnZones.spawnWithSpawner(spawner) + spawner.cdStarted = false -- reset spawner cd signal + if spawner.maxSpawns > 0 then + spawner.maxSpawns = spawner.maxSpawns - 1 + end + if spawner.maxSpawns == 0 then + spawner.paused = true + if cfxSpawnZones.verbose then + trigger.action.outText("+++ maxspawn -- turning off zone " .. spawner.zone.name, 30) + end + end + else + -- trigger.action.outText("+++ NOSPAWN for zone " .. spawner.zone.name, 30) + end + end +end + +function cfxSpawnZones.start() + if not dcsCommon.libCheck("cfx Spawn Zones", + cfxSpawnZones.requiredLibs) then + return false + end + + -- collect all spawn zones + local attrZones = cfxZones.getZonesWithAttributeNamed("spawner") + + -- now create a spawner for all, add them to the spawner updater, and spawn for all zones that are not + -- paused + for k, aZone in pairs(attrZones) do + local aSpawner = cfxSpawnZones.createSpawner(aZone) + cfxSpawnZones.addSpawner(aSpawner) + if not aSpawner.paused and cfxSpawnZones.verifySpawnOwnership(aSpawner) and aSpawner.maxSpawns ~= 0 then + cfxSpawnZones.spawnWithSpawner(aSpawner) + -- update spawn count and make sure we haven't spawned the one and only + if aSpawner.maxSpawns > 0 then + aSpawner.maxSpawns = aSpawner.maxSpawns - 1 + end + if aSpawner.maxSpawns == 0 then + aSpawner.paused = true + trigger.action.outText("+++ maxspawn -- turning off zone " .. aSpawner.zone.name, 30) + end + end + end + + -- and start the regular update calls + cfxSpawnZones.update() + + trigger.action.outText("cfx Spawn Zones v" .. cfxSpawnZones.version .. " started.", 30) + return true +end + +if not cfxSpawnZones.start() then + trigger.action.outText("cf/x Spawn Zones aborted: missing libraries", 30) + cfxSpawnZones = nil +end + +--[[-- +IMPROVEMENTS + 'notMasterOwner' a flag to invert ownership, so we can spawn blue if masterOwner is red + + take apart owned zone and spawner, so we have a more canonical behaviour + + 'repair' flag - have repair logic for units that are spawned just like now the owned zones do +--]]-- \ No newline at end of file diff --git a/modules/cfxZones.lua b/modules/cfxZones.lua new file mode 100644 index 0000000..093ef6f --- /dev/null +++ b/modules/cfxZones.lua @@ -0,0 +1,1323 @@ +-- cf/x zone management module +-- reads dcs zones and makes them accessible and mutable +-- by scripting. +-- +-- Copyright (c) 2021, 2022 by Christian Franz and cf/x AG +-- + +cfxZones = {} +cfxZones.version = "2.5.1" +--[[-- VERSION HISTORY + - 2.2.4 - getCoalitionFromZoneProperty + - getStringFromZoneProperty + - 2.2.5 - createGroundUnitsInZoneForCoalition corrected coalition --> country + - 2.2.6 - getVectorFromZoneProperty(theZone, theProperty, defaultVal) + - 2.2.7 - allow 'yes' as 'true' for boolean attribute + - 2.2.8 - getBoolFromZoneProperty supports default + - cfxZones.hasProperty + - 2.3.0 - property names are case insensitive + - 2.3.1 - getCoalitionFromZoneProperty allows 0, 1, 2 also + - 2.4.0 - all zones look for owner attribute, and set it to 0 (neutral) if not present + - 2.4.1 - getBoolFromZoneProperty upgraded by expected bool + - markZoneWithSmoke raised by 3 meters + - 2.4.2 - getClosestZone also returns delta + - 2.4.3 - getCoalitionFromZoneProperty() accepts 'all' as neutral + createUniqueZoneName() + getStringFromZoneProperty returns default if property value = "" + corrected bug in addZoneToManagedZones + - 2.4.4 - getPoint(aZone) returns uip-to-date pos for linked and normal zones + - linkUnit can use "useOffset" property to keep relative position + - 2.4.5 - updated various methods to support getPoint when referencing + zone.point + - 2.4.6 - corrected spelling in markZoneWithSmoke + - 2.4.7 - copy reference to dcs zone into cfx zone + - 2.4.8 - getAllZoneProperties + - 2.4.9 - createSimpleZone no longer requires location + - parse dcs adds empty .properties = {} if none tehre + - createCircleZone adds empty properties + - createPolyZone adds empty properties + - 2.4.10 - pickRandomZoneFrom now defaults to all cfxZones.zones + - getBoolFromZoneProperty also recognizes 0, 1 + - removed autostart + - 2.4.11 - removed typo in get closest zone + - 2.4.12 - getStringFromZoneProperty + - 2.5.0 - harden getZoneProperty and all getPropertyXXXX + - 2.5.1 - markZoneWithSmoke supports alt attribute + +--]]-- +cfxZones.verbose = true +cfxZones.caseSensitiveProperties = false -- set to true to make property names case sensitive +cfxZones.ups = 1 -- updates per second. updates moving zones + +cfxZones.zones = {} -- these are the zone as retrieved from the mission. + -- ALWAYS USE THESE, NEVER DCS's ZONES!!!! + +-- a zone has the following attributes +-- x, z -- coordinate of center. note they have correct x, 0, z coordinates so no y-->z mapping +-- radius (zero if quad zone) +-- isCircle (true if quad zone) +-- poly the quad coords are in the poly attribute and are a +-- 1..n, wound counter-clockwise as (currently) in DCS: +-- lower left, lower right upper left, upper right, all coords are x, 0, z +-- bounds - contain the AABB coords for the zone: ul (upper left), ur, ll (lower left), lr +-- for both circle and poly, all (x, 0, z) + +-- zones can carry information in their names that can get processed into attributes +-- use +-- zones can also carry information in their 'properties' tag that ME allows to +-- edit. cfxZones provides an easy method to access these properties +-- - getZoneProperty (returns as string) +-- - getMinMaxFromZoneProperty +-- - getBoolFromZoneProperty +-- - getNumberFromZoneProperty + + +-- SUPPORTED PROPERTIES +-- - "linkedUnit" - zone moves with unit of that name. must be exact match +-- can be combined with other attributes that extend (e.g. scar manager and +-- limited pilots/airframes +-- + +-- +-- readZonesFromDCS is executed exactly once at the beginning +-- from then on, use only the cfxZones.zones table +-- WARNING: cfxZones is NOT case-sensitive. All zone names are +-- indexed by upper case. If you have two zones with same name but +-- different case, one will be replaced +-- + +function cfxZones.readFromDCS(clearfirst) + if (clearfirst) then + cfxZones.zones = {} + end + -- not all missions have triggers or zones + if not env.mission.triggers then + if cfxZones.verbose then + trigger.action.outText("cf/x zones: no env.triggers defined", 10) + end + return + end + + if not env.mission.triggers.zones then + if cfxZones.verbose then + trigger.action.outText("cf/x zones: no zones defined", 10) + end + return; + end + + -- we only retrive the data we need. At this point it is name, location and radius + -- and put this in our own little structure. we also convert to all upper case name for index + -- and assume that the name may also carry meaning, e.g. 'LZ:' defines a landing zone + -- so we can quickly create other sets from this + -- zone object. DCS 2.7 introduced quads, so this is supported as well + -- name - name in upper case + -- isCircle - true if circular zone + -- isPoly - true if zone is defined by convex polygon, e.g. quad + -- point - vec3 (x 0 z) - zone's in-world center, used to place the coordinate + -- radius - number, zero when quad + -- bounds - aabb with attributes ul, ur, ll, lr (upper left .. lower right) as (x, 0, z) + -- poly - array 1..n of poly points, wound counter-clockwise + + for i, dcsZone in pairs(env.mission.triggers.zones) do + if type(dcsZone) == 'table' then -- hint taken from MIST: verify type when reading from dcs + -- dcs data is like a box of chocolates... + local newZone = {} + -- name, converted to upper is used only for indexing + -- the original name remains untouched + newZone.dcsZone = dcsZone + newZone.name = dcsZone.name + newZone.isCircle = false + newZone.isPoly = false + newZone.radius = 0 + newZone.poly = {} + newZone.bounds = {} + newZone.properties = {} -- dcs has this too, copy if present + if dcsZone.properties then + newZone.properties = dcsZone.properties + else + newZone.properties = {} + end -- WARNING: REF COPY. May need to clone + + local upperName = newZone.name:upper() + + -- location as 'point' + -- WARNING: zones locs are 2D (x,y) pairs, whily y in DCS is altitude. + -- so we need to change (x,y) into (x, 0, z). Since Zones have no + -- altitude (they are an infinite cylinder) this works. Remember to + -- drop y from zone calculations to see if inside. + newZone.point = cfxZones.createPoint(dcsZone.x, 0, dcsZone.y) + + + -- start type processing. if zone.type exists, we have a mission + -- created with 2.7 or above, else earlier + local zoneType = 0 + if (dcsZone.type) then + zoneType = dcsZone.type + end + + if zoneType == 0 then + -- circular zone + newZone.isCircle = true + newZone.radius = dcsZone.radius + + elseif zoneType == 2 then + -- polyZone + newZone.isPoly = true + newZone.radius = dcsZone.radius -- radius is still written in DCS, may change later + -- now transfer all point in the poly + -- note: DCS in 2.7 misspells vertices as 'verticies' + -- correct vor this + local verts = {} + if dcsZone.verticies then verts = dcsZone.verticies + else + -- in later versions, this was corrected + verts = dcsZone.vertices -- see if this is ever called + end + + for v=1, #verts do + local dcsPoint = verts[v] + local polyPoint = cfxZones.createPointFromDCSPoint(dcsPoint) -- (x, y) -- (x, 0, y-->z) + newZone.poly[v] = polyPoint + end + else + + trigger.action.outText("cf/x zones: malformed zone #" .. i .. " unknown type " .. zoneType, 10) + end + + + -- calculate bounds + cfxZones.calculateZoneBounds(newZone) + + -- add to my table + cfxZones.zones[upperName] = newZone -- WARNING: UPPER ZONE!!! + else + if cfxZones.verbose then + trigger.action.outText("cf/x zones: malformed zone #" .. i .. " dropped", 10) + end + end -- else var not a table + + end -- for all zones kvp +end -- readFromDCS + +function cfxZones.calculateZoneBounds(theZone) + if not (theZone) then return + end + + local bounds = theZone.bounds -- copy ref! + + if theZone.isCircle then + -- aabb are easy: center +/- radius + local center = theZone.point + local radius = theZone.radius + -- dcs uses z+ is down on map + -- upper left is center - radius + bounds.ul = cfxZones.createPoint(center.x - radius, 0, center.z - radius) + bounds.ur = cfxZones.createPoint(center.x + radius, 0, center.z - radius) + bounds.ll = cfxZones.createPoint(center.x - radius, 0, center.z + radius) + bounds.lr = cfxZones.createPoint(center.x + radius, 0, center.z + radius) + + elseif theZone.isPoly then + local poly = theZone.poly -- ref copy! + -- create the four points + local ll = cfxZones.createPointFromPoint(poly[1]) + local lr = cfxZones.createPointFromPoint(poly[1]) + local ul = cfxZones.createPointFromPoint(poly[1]) + local ur = cfxZones.createPointFromPoint(poly[1]) + + -- now iterate through all points and adjust bounds accordingly + for v=2, #poly do + local vertex = poly[v] + if (vertex.x < ll.x) then ll.x = vertex.x; ul.x = vertex.x end + if (vertex.x > lr.x) then lr.x = vertex.x; ur.x = vertex.x end + if (vertex.z < ul.z) then ul.z = vertex.z; ur.z = vertex.z end + if (vertex.z > ll.z) then ll.z = vertex.z; lr.z = vertex.z end + + end + + -- now keep the new point references + -- and store them in the zone's bounds + bounds.ll = ll + bounds.lr = lr + bounds.ul = ul + bounds.ur = ur + else + -- huston, we have a problem + if cfxZones.verbose then + trigger.action.outText("cf/x zones: calc bounds: zone " .. theZone.name .. " has unknown type", 30) + end + end + +end + +function cfxZones.createPoint(x, y, z) + local newPoint = {} + newPoint.x = x + newPoint.y = y + newPoint.z = z + return newPoint +end + +function cfxZones.copyPoint(inPoint) + local newPoint = {} + newPoint.x = inPoint.x + newPoint.y = inPoint.y + newPoint.z = inPoint.z + return newPoint +end + +function cfxZones.createHeightCorrectedPoint(inPoint) -- this should be in dcsCommon + local cP = cfxZones.createPoint(inPoint.x, land.getHeight({x=inPoint.x, y=inPoint.z}),inPoint.z) + return cP +end + +function cfxZones.getHeightCorrectedZonePoint(theZone) + return cfxZones.createHeightCorrectedPoint(theZone.point) +end + +function cfxZones.createPointFromPoint(inPoint) + return cfxZones.copyPoint(inPoint) +end + +function cfxZones.createPointFromDCSPoint(inPoint) + return cfxZones.createPoint(inPoint.x, 0, inPoint.y) +end + + +function cfxZones.createRandomPointInsideBounds(bounds) + local x = math.random(bounds.ll.x, ur.x) + local z = math.random(bounds.ll.z, ur.z) + return cfxZones.createPoint(x, 0, z) +end + +function cfxZones.addZoneToManagedZones(theZone) + local upperName = string.upper(theZone.name) -- newZone.name:upper() + cfxZones.zones[upperName] = theZone +end + +function cfxZones.createUniqueZoneName(inName, searchSet) + if not inName then return nil end + if not searchSet then searchSet = cfxZones.zones end + inName = inName:upper() + while searchSet[inName] ~= nil do + inName = inName .. "X" + end + return inName +end + +function cfxZones.createSimpleZone(name, location, radius, addToManaged) + if not radius then radius = 10 end + if not addToManaged then addToManaged = false end + if not location then + location = {} + end + if not location.x then location.x = 0 end + if not location.z then location.z = 0 end + + local newZone = cfxZones.createCircleZone(name, location.x, location.z, radius) + + if addToManaged then + cfxZones.addZoneToManagedZones(newZone) + end + return newZone +end + +function cfxZones.createCircleZone(name, x, z, radius) + local newZone = {} + newZone.isCircle = true + newZone.isPoly = false + newZone.poly = {} + newZone.bounds = {} + + newZone.name = name + newZone.radius = radius + newZone.point = cfxZones.createPoint(x, 0, z) + + -- props + newZone.properties = {} + + -- calculate my bounds + cfxZones.calculateZoneBounds(newZone) + + return newZone +end + +function cfxZones.createPolyZone(name, poly) -- poly must be array of point type +local newZone = {} + newZone.isCircle = false + newZone.isPoly = true + newZone.poly = {} + newZone.bounds = {} + + newZone.name = name + newZone.radius = 0 + -- copy poly + for v=1, #poly do + local theVertex = poly[v] + newZone.poly[v] = cfxZones.createPointFromPoint(theVertex) + end + + -- properties + newZone.properties = {} + + cfxZones.calculateZoneBounds(newZone) +end + + + +function cfxZones.createRandomZoneInZone(name, inZone, targetRadius, entirelyInside) + -- create a new circular zone with center placed inside inZone + -- if entirelyInside is false, only the zone's center is guaranteed to be inside + -- inZone. + +-- trigger.action.outText("Zones: creating rZiZ with tr = " .. targetRadius .. " for " .. inZone.name .. " that as r = " .. inZone.radius, 10) + + if inZone.isCircle then + local sourceRadius = inZone.radius + if entirelyInside and targetRadius > sourceRadius then targetRadius = sourceRadius end + if entirelyInside then sourceRadius = sourceRadius - targetRadius end + + -- ok, let's first create a random percentage value for the new radius + local percent = 1 / math.random(100) + -- now lets get a random degree + local degrees = math.random(360) * 3.14152 / 180 -- ok, it's actually radiants. + local r = sourceRadius * percent + local x = inZone.point.x + r * math.cos(degrees) + local z = inZone.point.z + r * math.sin(degrees) + -- construct new zone + local newZone = cfxZones.createCircleZone(name, x, z, targetRadius) + return newZone + + elseif inZone.isPoly then + -- we have a poly zone. the way we do this is simple: + -- generate random x, z with ranges of the bounding box + -- until the point falls within the polygon. + local newPoint = {} + local emergencyBrake = 0 + repeat + newPoint = cfxZones.createRandomPointInsideBounds(inZone.bounds) + emergencyBrake = emergencyBrake + 1 + if (emergencyBrake > 100) then + newPoint = cfxZones.copyPoint(inZone.Point) + trigger.action.outText("CreateZoneInZone: mergency brake for inZone" .. inZone.name, 10) + break + end + until cfxZones.isPointInsidePoly(newPoint, inZone.poly) + + -- construct new zone + local newZone = cfxZones.createCircleZone(name, newPoint.x, newPoint.z, targetRadius) + return newZone + + else + -- zone type unknown + trigger.action.outText("CreateZoneInZone: unknown zone type for inZone =" .. inZone.name , 10) + return nil + end +end + +-- polygon inside zone calculations + + +-- isleft returns true if point P is to the left of line AB +-- by determining the sign (up or down) of the normal vector of +-- the two vectors PA and PB in the y coordinate. We arbitrarily define +-- left as being > 0, so right is <= 0. As long as we always use the +-- same comparison, it does not matter what up or down mean. +-- this is important because we don't know if dcs always winds quads +-- the same way, we must simply assume that they are wound as a polygon +function cfxZones.isLeftXZ(A, B, P) + return ((B.x - A.x)*(P.z - A.z) - (B.z - A.z)*(P.x - A.x)) > 0 +end + +-- returns true/false for inside +function cfxZones.isPointInsideQuad(thePoint, A, B, C, D) + -- Inside test (only convex polygons): + -- point lies on the same side of each quad's vertex AB, BC, CD, DA + -- how do we find out which side a point lies on? via the cross product + -- see isLeft below + + -- so all we need to do is make sure all results of isLeft for all + -- four sides are the same + mustMatch = isLeftXZ(A, B, thePoint) -- all test results must be the same and we are ok + -- they just must be the same side. + if (cfxZones.isLeftXZ(B, C, thePoint ~= mustMatch)) then return false end -- on other side than all before + if (cfxZones.isLeftXZ(C, D, thePoint ~= mustMatch)) then return false end + if (cfxZones.isLeftXZ(D, A, thePoint ~= mustMatch)) then return false end + return true +end + +-- generalized version of insideQuad, assumes winding of poly, poly convex, poly closed +function cfxZones.isPointInsidePoly(thePoint, poly) + local mustMatch = cfxZones.isLeftXZ(poly[1], poly[2], thePoint) + for v=2, #poly-1 do + if cfxZones.isLeftXZ(poly[v], poly[v+1], thePoint) ~= mustMatch then return false end + end + -- final test + if cfxZones.isLeftXZ(poly[#poly], poly[1], thePoint) ~= mustMatch then return false end + + return true +end; + +function cfxZones.isPointInsideZone(thePoint, theZone) + local p = {x=thePoint.x, y = 0, z = thePoint.z} -- zones have no altitude + if (theZone.isCircle) then + local d = dcsCommon.dist(p, theZone.point) + return d < theZone.radius + end + + if (theZone.isPoly) then + return (cfxZones.isPointInsidePoly(p, theZone.poly)) + end + + trigger.action.outText("isPointInsideZone: Unknown zone type for " .. outerZone.name, 10) +end + +-- isZoneInZone returns true if center of innerZone is inside outerZone +function cfxZones.isZoneInsideZone(innerZone, outerZone) + return cfxZones.isPointInsideZone(innerZone.point, outerZone) + + +end + +function cfxZones.getZonesContainingPoint(thePoint, testZones) -- return array + if not testZones then + testZones = cfxZones.zones + end + + local containerZones = {} + for tName, tData in pairs(testZones) do + if cfxZones.isPointInsideZone(thePoint, tData) then + table.insert(containerZones, tData) + end + end + + return containerZones +end + +function cfxZones.getFirstZoneContainingPoint(thePoint, testZones) + if not testZones then + testZones = cfxZones.zones + end + + for tName, tData in pairs(testZones) do + if cfxZones.isPointInsideZone(thePoint, tData) then + return tData + end + end + + return nil +end + +function cfxZones.getAllZonesInsideZone(superZone, testZones) -- returnes array! + if not testZones then + testZones = cfxZones.zones + end + + local containedZones = {} + for zName, zData in pairs(testZones) do + if cfxZones.isZoneInsideZone(zData, superZone) then + if zData ~= superZone then + -- we filter superzone because superzone usually resides + -- inside itself + table.insert(containedZones, zData) + end + end + end + return containedZones +end + +function cfxZones.getZonesWithAttributeNamed(attributeName, testZones) + if not testZones then testZones = cfxZones.zones end + local attributZones = {} + for aName,aZone in pairs(testZones) do + local attr = cfxZones.getZoneProperty(aZone, attributeName) + if attr then + -- this zone has the requested attribute + table.insert(attributZones, aZone) + end + end + return attributZones +end + +-- +-- units / groups in zone +-- +function cfxZones.groupsOfCoalitionPartiallyInZone(coal, theZone, categ) -- categ is optional + local groupsInZone = {} + local allGroups = coalition.getGroups(coal, categ) + for key, group in pairs(allGroups) do -- iterate all groups + if group:isExist() then + + + if cfxZones.isGroupPartiallyInZone(group, theZone) then + + table.insert(groupsInZone, group) + else + + + end + end + end + return groupsInZone +end + +function cfxZones.isGroupPartiallyInZone(aGroup, aZone) + if not aGroup then return false end + if not aZone then return false end + + + -- needs to be implemented + if not aGroup:isExist() then return false end + local allUnits = aGroup:getUnits() + for uk, aUnit in pairs (allUnits) do + if aUnit:isExist() and aUnit:getLife() > 1 then + + local p = aUnit:getPoint() +-- p.y = 0 -- zones have no altitude + -- modification of isPointInsideZone now takes care of this + if cfxZones.isPointInsideZone(p, aZone) then + return true + else + + end + end + end + return false +end + +function cfxZones.isEntireGroupInZone(aGroup, aZone) + if not aGroup then return false end + if not aZone then return false end + -- needs to be implemented + if not aGroup:isExist() then return false end + local allUnits = aGroup:getUnits() + for uk, aUnit in pairs (allUnits) do + if aUnit:isExist() and aUnit:getLife() > 1 then + local p = aUnit:getPoint() + if not cfxZones.isPointInsideZone(p, aZone) then + return false + end + end + end + return true +end + + +-- +-- Zone Manipulation +-- + +function cfxZones.offsetZone(theZone, dx, dz) + -- first, update center + theZone.point.x = theZone.point.x + dx + theZone.point.z = theZone.point.z + dz + + -- now process all polygon points - it's empty for circular, so don't worry + for v=1, #theZone.poly do + theZone.poly[v].x = theZone.poly[v].x + dx + theZone.poly[v].z = theZone.poly[v].z + dz + end +end + +function cfxZones.moveZoneTo(theZone, x, z) + local dx = x - theZone.point.x + local dz = z - theZone.point.z + cfxZones.offsetZone(theZone, dx, dz) +end; + +function cfxZones.centerZoneOnUnit(theZone, theUnit) + local thePoint = theUnit:getPoint() + cfxZones.moveZoneTo(theZone, thePoint.x, thePoint.z) +end + + +--[[ +-- no longer makes sense with poly zones +function cfxZones.isZoneEntirelyInsideZone(innerZone, outerZone) + if (innerZone.radius > outerZone.radius) then return false end -- cant fit inside + local d = dcsCommon.dist(innerZone.point, outerZone.point) + local reducedR = outerZone.radius - innerZone.radius + return d < reducedR +end; +--]] + +function cfxZones.dumpZones(zoneTable) + if not zoneTable then zoneTable = cfxZones.zones end + + trigger.action.outText("Zones START", 10) + for i, zone in pairs(zoneTable) do + local myType = "unknown" + if zone.isCircle then myType = "Circle" end + if zone.isPoly then myType = "Poly" end + + trigger.action.outText("#".. i .. ": " .. zone.name .. " of type " .. myType, 10) + end + trigger.action.outText("Zones END", 10) +end + +function cfxZones.stringStartsWith(theString, thePrefix) + return theString:find(thePrefix) == 1 +end + +function cfxZones.keysForTable(theTable) + local keyset={} + local n=0 + + for k,v in pairs(tab) do + n=n+1 + keyset[n]=k + end + return keyset +end + + +-- +-- return all zones that have a specific named property +-- +function cfxZones.zonesWithProperty(propertyName, searchSet) + if not searchSet then searchSet = cfxZones.zones end + local theZones = {} + for k, aZone in pairs(searchSet) do + if not aZone then + trigger.action.outText("+++zone: nil aZone for " .. k, 30) + else + local lU = cfxZones.getZoneProperty(aZone, propertyName) + if lU then + table.insert(theZones, aZone) + end + end + end + return theZones +end + +-- +-- return all zones from the zone table that begin with string prefix +-- +function cfxZones.zonesStartingWithName(prefix, searchSet) + + if not searchSet then searchSet = cfxZones.zones end + +-- trigger.action.outText("Enter: zonesStartingWithName for " .. prefix , 30) + local prefixZones = {} + prefix = prefix:upper() -- all zones have UPPERCASE NAMES! THEY SCREAM AT YOU + for name, zone in pairs(searchSet) do +-- trigger.action.outText("testing " .. name:upper() .. " starts with " .. prefix , 30) + if cfxZones.stringStartsWith(name:upper(), prefix) then + prefixZones[name] = zone -- note: ref copy! + --trigger.action.outText("zone with prefix <" .. prefix .. "> found: " .. name, 10) + end + end + + return prefixZones +end + +-- +-- return all zones from the zone table that begin with the string or set of strings passed in prefix +-- if you pass 'true' as second (optional) parameter, it will first look for all zones that begin +-- with '+' and return only those. Use during debugging to force finding a specific zone +-- +function cfxZones.zonesStartingWith(prefix, searchSet, debugging) + -- you can force zones by having their name start with "+" + -- which will force them to return immediately if debugging is true for this call + + if (debugging) then + local debugZones = cfxZones.zonesStartingWithName("+", searchSet) + if not (next(debugZones) == nil) then -- # operator only works on array elements + --trigger.action.outText("returning zones with prefix <" .. prefix .. ">", 10) + return debugZones + end + end + + --trigger.action.outText("#debugZones is <" .. #debugZones .. ">", 10) + + if (type(prefix) == "string") then + return cfxZones.zonesStartingWithName(prefix, searchSet) + end + + local allZones = {} + for i=1, #prefix do + -- iterate through all names in prefix set + local theName = prefix[i] + local newZones = cfxZones.zonesStartingWithName(theName, searchSet) + -- add them all to current table + for zName, zInfo in pairs(newZones) do + allZones[zName] = zInfo -- will also replace doublets + end + end + + return allZones +end + +function cfxZones.getZoneByName(aName, searchSet) + if not searchSet then searchSet = cfxZones.zones end + aName = aName:upper() + return searchSet[aName] -- the joys of key value pairs +end + +function cfxZones.getZonesContainingString(aString, searchSet) + if not searchSet then searchSet = cfxZones.zones end + aString = string.upper(aString) + resultSet = {} + for zName, zData in pairs(searchSet) do + if aString == string.upper(zData.name) then + resultSet[zName] = zData + end + end + +end; + +-- filter zones by range to a point. returns indexed set +function cfxZones.getZonesInRange(point, range, theZones) + if not theZones then theZones = cfxZones.zones end + + local inRangeSet = {} + for zName, zData in pairs (theZones) do + if dcsCommon.dist(point, zData.point) < range then + table.insert(inRangeSet, zData) + end + end + return inRangeSet +end + +-- get closest zone returns the zone that is closest to point +function cfxZones.getClosestZone(point, theZones) + if not theZones then theZones = cfxZones.zones end + local currDelta = math.huge + local closestZone = nil + for zName, zData in pairs(theZones) do + local zPoint = cfxZones.getPoint(zData) + local delta = dcsCommon.dist(point, zPoint) + if (delta < currDelta) then + currDelta = delta + closestZone = zData + end + end + return closestZone, currDelta +end + +-- return a random zone from the table passed in zones +function cfxZones.pickRandomZoneFrom(zones) + if not zones then zones = cfxZones.zones end + local indexedZones = dcsCommon.enumerateTable(zones) + local r = math.random(#indexedZones) + return indexedZones[r] +end + +-- return an zone element by index +function cfxZones.getZoneByIndex(theZones, theIndex) + local enumeratedZones = dcsCommon.enumerateTable(theZones) + if (theIndex > #enumeratedZones) then + trigger.action.outText("WARNING: zone index " .. theIndex .. " out of bounds - max = " .. #enumeratedZones, 30) + return nil end + if (theIndex < 1) then return nil end + + return enumeratedZones[theIndex] +end + +-- place a smoke marker in center of zone, offset by dx, dy +function cfxZones.markZoneWithSmoke(theZone, dx, dz, smokeColor, alt) + if not alt then alt = 5 end + local point = cfxZones.getPoint(theZone) --{} -- theZone.point + point.x = point.x + dx -- getpoint updates and returns copy + point.z = point.z + dz + -- get height at point + point.y = land.getHeight({x = point.x, y = point.z}) + alt + -- height-correct + --local newPoint= {x = point.x, y = land.getHeight({x = point.x, y = point.z}) + 3, z= point.z} + trigger.action.smoke(point, smokeColor) +end + +-- place a smoke marker in center of zone, offset by radius and degrees +function cfxZones.markZoneWithSmokePolar(theZone, radius, degrees, smokeColor, alt) + local rads = degrees * math.pi / 180 + local dx = radius * math.sin(rads) + local dz = radius * math.cos(rads) + cfxZones.markZoneWithSmoke(theZone, dx, dz, smokeColor, alt) +end + +-- place a smoke marker in center of zone, offset by radius and randomized degrees +function cfxZones.markZoneWithSmokePolarRandom(theZone, radius, smokeColor) + local degrees = math.random(360) + cfxZones.markZoneWithSmokePolar(theZone, radius, degrees, smokeColor) +end + + +-- unitInZone returns true if theUnit is inside the zone +-- the second value returned is the percentage of distance +-- from center to rim, with 100% being entirely in center, 0 = outside +-- the third value returned is the distance to center +function cfxZones.pointInZone(thePoint, theZone) + if not (theZone) then return false, 0, 0 end + + local pflat = {x = thePoint.x, y = 0, z = thePoint.z} + + local zpoint = cfxZones.getPoint(theZone) -- updates zone if linked + local ppoint = thePoint -- xyz + local pflat = {x = ppoint.x, y = 0, z = ppoint.z} + local dist = dcsCommon.dist(zpoint, pflat) + + if theZone.isCircle then + if theZone.radius <= 0 then + return false, 0, 0 + end + + local success = dist < theZone.radius + local percentage = 0 + if (success) then + percentage = 1 - dist / theZone.radius + end + return success, percentage, dist + + elseif theZone.isPoly then + local success = cfxZones.isPointInsidePoly(pflat, theZone.poly) + return success, 0, dist + else + trigger.action.outText("pointInZone: Unknown zone type for " .. theZone.name, 10) + end + + return false +end + +function cfxZones.unitInZone(theUnit, theZone) + if not (theUnit) then return false, 0, 0 end + if not (theUnit:isExist()) then return false, 0, 0 end + -- force zone update if it is linked to another zone + -- pointInZone does update + local thePoint = theUnit:getPoint() + return cfxZones.pointInZone(thePoint, theZone) + +end + +-- returns all units of the input set that are inside the zone +function cfxZones.unitsInZone(theUnits, theZone) + if not theUnits then return {} end + if not theZone then return {} end + + local zoneUnits = {} + for index, aUnit in pairs(theUnits) do + if cfxZones.unitInZone(aUnit, theZone) then + table.insert( zoneUnits, aUnit) + end + end + return zoneUnits +end + +function cfxZones.closestUnitToZoneCenter(theUnits, theZone) + -- does not care if they really are in zone. call unitsInZone first + -- if you need to have them filtered + -- theUnits MUST BE ARRAY + if not theUnits then return nil end + if #theUnits == 0 then return nil end + local closestUnit = theUnits[1] + for i=2, #theUnits do + local aUnit = theUnits[i] + if dcsCommon.dist(theZone.point, closestUnit:getPoint()) > dcsCommon.dist(theZone.point, aUnit:getPoint()) then + closestUnit = aUnit + end + end + return closestUnit +end + +function cfxZones.anyPlayerInZone(theZone) -- returns first player it finds + for pname, pinfo in pairs(cfxPlayer.playerDB) do + local playerUnit = pinfo.unit + if (cfxZones.unitInZone(playerUnit, theZone)) then + return true, playerUnit + end + end -- for all players + return false, nil +end + + +-- grow zone +function cfxZones.growZone() + -- circular zones simply increase radius + -- poly zones: not defined + +end + + +-- creating units in a zone +function cfxZones.createGroundUnitsInZoneForCoalition (theCoalition, groupName, theZone, theUnits, formation, heading) + -- theUnits can be string or table of string + if not groupName then groupName = "G_"..theZone.name end + -- group name will be taken from zone name and prependend with "G_" + local theGroup = dcsCommon.createGroundGroupWithUnits(groupName, theUnits, theZone.radius, nil, formation) + + -- turn the entire formation to heading + if (not heading) then heading = 0 end + dcsCommon.rotateGroupData(theGroup, heading) -- currently, group is still at origin, no cx, cy + + + -- now move the group to center of theZone + dcsCommon.moveGroupDataTo(theGroup, + theZone.point.x, + theZone.point.z) -- watchit: Z!!! + + + -- create the group in the world and return it + -- first we need to translate the coalition to a legal + -- country. we use UN for neutral, cjtf for red and blue + local theSideCJTF = dcsCommon.coalition2county(theCoalition) + return coalition.addGroup(theSideCJTF, Group.Category.GROUND, theGroup) + +end + +-- parsing zone names. The first part of the name until the first blank " " +-- is the prefix and is dropped unless keepPrefix is true. +-- all others are regarded as key:value pairs and are then added +-- to the zone +-- separated by equal sign "=" AND MUST NOT CONTAIN BLANKS +-- +-- example usage "followZone unit=rotary-1 dx=30 dy=25 rotateWithHeading=true +-- +-- OLD DEPRECATED TECH -- TO BE DECOMMISSIONED SOON, DO NOT USE +-- +--[[-- +function cfxZones.parseZoneNameIntoAttributes(theZone, keepPrefix) +-- trigger.action.outText("Parsing zone: ".. theZone.name, 30) + if not keepPrefix then keepPrefix = false end -- simply for clarity + -- now split the name into space-separated strings + local attributes = dcsCommon.splitString(theZone.name, " ") + if not keepPrefix then table.remove(attributes, 1) end -- pop prefix + + -- now parse all substrings and add them as attributes to theZone + for i=1, #attributes do + local a = attributes[i] + local kvp = dcsCommon.splitString(a, "=") + if #kvp == 2 then + -- we have key value pair + local theKey = kvp[1] + local theValue = kvp[2] + theZone[theKey] = theValue +-- trigger.action.outText("Zone ".. theZone.name .. " parsed: Key = " .. theKey .. ", Value = " .. theValue, 30) + else +-- trigger.action.outText("Zone ".. theZone.name .. ": dropped attribute " .. a, 30) + end + end +end +--]]-- +-- OLD DEPRECATED TECH -- TO BE DECOMMISSIONED SOON, DO NOT USE +--[[-- +function cfxZones.processCraterZones () + local craters = cfxZones.zonesStartingWith("crater") + + + + -- all these zones need to be processed and their name infor placed into attributes + for cName, cZone in pairs(craters) do + cfxZones.parseZoneNameIntoAttributes(cZone) + + -- blow stuff up at the location of the zone + local cPoint = cZone.point + cPoint.y = land.getHeight({x = cPoint.x, y = cPoint.z}) -- compensate for ground level + trigger.action.explosion(cPoint, 900) + + -- now interpret and act on the crater info + -- to destroy and place fire. + + -- fire has small, medium, large + -- eg. fire=large + + end +end +--]]-- + +-- +-- PROPERTY PROCESSING +-- + +function cfxZones.getAllZoneProperties(theZone, caseInsensitive) -- return as dict + if not caseInsensitive then caseInsensitive = false end + if not theZone then return {} end + + local dcsProps = theZone.properties -- zone properties in dcs format + local props = {} + -- dcs has all properties as array with values .key and .value + -- so convert them into a dictionary + for i=1, #dcsProps do + local theProp = dcsProps[i] + local theKey = "dummy" + if string.len(theProp.key) > 0 then theKey = theProp.key end + if caseInsensitive then theKey = theKey:upper() end + props[theKey] = theProp.value + end + return props +end + +function cfxZones.extractPropertyFromDCS(theKey, theProperties) +-- make lower case conversion if not case sensitive + if not cfxZones.caseSensitiveProperties then + theKey = string.lower(theKey) + end + +-- iterate all keys and compare to what we are looking for + for i=1, #theProperties do + local theP = theProperties[i] + local existingKey = theP.key + if not cfxZones.caseSensitiveProperties then + existingKey = string.lower(existingKey) + end + if existingKey == theKey then + return theP.value + end + end + return nil +end + +function cfxZones.getZoneProperty(cZone, theKey) + if not cZone then + trigger.action.outText("+++zone: no zone in getZoneProperty", 30) + return nil + end + if not theKey then + trigger.action.outText("+++zone: no property key in getZoneProperty for zone " .. cZone.name, 30) + breakme.here = 1 + return + end + + local props = cZone.properties + local theVal = cfxZones.extractPropertyFromDCS(theKey, props) + return theVal +end + +function cfxZones.getStringFromZoneProperty(theZone, theProperty, default) + + if not default then default = "" end + local p = cfxZones.getZoneProperty(theZone, theProperty) + if not p then return default end + if type(p) == "string" then + if p == "" then p = default end + return p + end + return default -- warning. what if it was a number first? +end + +function cfxZones.getMinMaxFromZoneProperty(theZone, theProperty) + local p = cfxZones.getZoneProperty(theZone, theProperty) + local theNumbers = dcsCommon.splitString(p, " ") + + return tonumber(theNumbers[1]), tonumber(theNumbers[2]) + +end + +function cfxZones.hasProperty(theZone, theProperty) + return cfxZones.getZoneProperty(theZone, theProperty) ~= nil +end + + +function cfxZones.getBoolFromZoneProperty(theZone, theProperty, defaultVal) + if not defaultVal then defaultVal = false end + if type(defaultVal) ~= "boolean" then + defaultVal = false + end + + if not theZone then + trigger.action.outText("WARNING: NIL Zone in getBoolFromZoneProperty", 30) + return defaultVal + end + + + local p = cfxZones.getZoneProperty(theZone, theProperty) + if not p then return defaultVal end + + -- make sure we compare so default always works when + -- answer isn't exactly the opposite + p = p:lower() + if defaultVal == false then + -- only go true if exact match to yes or true + theBool = false + theBool = (p == 'true') or (p == 'yes') or p == "1" + return theBool + end + + local theBool = true + -- only go false if exactly no or false or "0" + theBool = (p ~= 'false') and (p ~= 'no') and (p ~= "0") + return theBool +end + +function cfxZones.getCoalitionFromZoneProperty(theZone, theProperty, default) + if not default then default = 0 end + local p = cfxZones.getZoneProperty(theZone, theProperty) + if not p then return default end + if type(p) == "number" then -- can't currently really happen + if p == 1 then return 1 end + if p == 2 then return 2 end + return 0 + end + + if type(p) == "string" then + if p == "1" then return 1 end + if p == "2" then return 2 end + if p == "0" then return 0 end + + p = p:lower() + + if p == "red" then return 1 end + if p == "blue" then return 2 end + if p == "neutral" then return 0 end + if p == "all" then return 0 end + return default + end + + return default +end + +function cfxZones.getNumberFromZoneProperty(theZone, theProperty, default) +--TODO: trim string + if not default then default = 0 end + local p = cfxZones.getZoneProperty(theZone, theProperty) + p = tonumber(p) + if not p then return default else return p end +end + +function cfxZones.getVectorFromZoneProperty(theZone, theProperty, minDims, defaultVal) + if not minDims then minDims = 0 end + if not defaultVal then defaultVal = 0 end + local s = cfxZones.getStringFromZoneProperty(theZone, theProperty, "") + local sVec = dcsCommon.splitString(s, ",") + local nVec = {} + for idx, numString in pairs (sVec) do + local n = tonumber(numString) + if not n then n = defaultVal end + table.insert(nVec, n) + end + -- make sure vector contains at least minDims values + while #nVec < minDims do + table.insert(nVec, defaultVal) + end + + return nVec +end + +function cfxZones.getSmokeColorStringFromZoneProperty(theZone, theProperty, default) -- smoke as 'red', 'green', or 1..5 + if not default then default = "red" end + local s = cfxZones.getStringFromZoneProperty(theZone, theProperty, default) + s = s:lower() + s = dcsCommon.trim(s) + -- check numbers + if (s == "0") then return "green" end + if (s == "1") then return "red" end + if (s == "2") then return "white" end + if (s == "3") then return "orange" end + if (s == "4") then return "blue" end + + if s == "green" or + s == "red" or + s == "white" or + s == "orange" or + s == "blue" then return s end + + return default +end + +-- +-- Moving Zones. They contain a link to their unit +-- they are always located at an offset (x,z) or delta, phi +-- to their master unit. delta phi allows adjustment for heading +-- The cool thing about moving zones in cfx is that they do not +-- require special handling, they are always updated +-- and work with 'pointinzone' etc automatically + +-- Always works on cfx Zones, NEVER on DCS zones. +-- +-- requires that readFromDCS has been done +-- +function cfxZones.getPoint(aZone) -- always works, wven linked, point can be reused + if aZone.linkedUnit then + local theUnit = aZone.linkedUnit + -- has a link. is link existing? + if theUnit:isExist() then + -- updates zone position + cfxZones.centerZoneOnUnit(aZone, theUnit) + cfxZones.offsetZone(aZone, aZone.dx, aZone.dy) + end + end + local thePos = {} + thePos.x = aZone.point.x + thePos.y = 0 -- aZone.y + thePos.z = aZone.point.z + return thePos +end + +function cfxZones.linkUnitToZone(theUnit, theZone, dx, dy) -- note: dy is really Z, don't get confused!!!! + theZone.linkedUnit = theUnit + if not dx then dx = 0 end + if not dy then dy = 0 end + theZone.dx = dx + theZone.dy = dy +end + +function cfxZones.updateMovingZones() + cfxZones.updateSchedule = timer.scheduleFunction(cfxZones.updateMovingZones, {}, timer.getTime() + 1/cfxZones.ups) + -- simply scan all cfx zones for the linkedUnit property and if there + -- update the zone's points + for aName,aZone in pairs(cfxZones.zones) do + if aZone.linkedUnit then + local theUnit = aZone.linkedUnit + -- has a link. is link existing? + if theUnit:isExist() then + cfxZones.centerZoneOnUnit(aZone, theUnit) + cfxZones.offsetZone(aZone, aZone.dx, aZone.dy) + --trigger.action.outText("cf/x zones update " .. aZone.name, 30) + end + end + end +end + +function cfxZones.startMovingZones() + -- read all zoness, and look for a property called 'linkedUnit' + -- which will make them a linked zone if there is a unit that exists + for aName,aZone in pairs(cfxZones.zones) do + local lU = cfxZones.getZoneProperty(aZone, "linkedUnit") + if lU then + -- this zone is linked to a unit + theUnit = Unit.getByName(lU) + local useOffset = cfxZones.getBoolFromZoneProperty(aZone, "useOffset", false) + if useOffset then aZone.useOffset = true end + if theUnit then + local dx = 0 + local dz = 0 + if useOffset then + local delta = dcsCommon.vSub(aZone.point,theUnit:getPoint()) -- delta = B - A + dx = delta.x + dz = delta.z + end + cfxZones.linkUnitToZone(theUnit, aZone, dx, dz) + --trigger.action.outText("cf/x zones: linked " .. aZone.name .. " to " .. theUnit:getName(), 30) + if useOffset then + --trigger.action.outText("and dx = " .. dx .. " dz = " .. dz, 30) + end + end + + end + end +end + +-- +-- init +-- + +function cfxZones.init() + -- read all zones into my own db + cfxZones.readFromDCS(true) -- true: erase old + + -- now, pre-read zone owner for all zones + -- note, all zones with this property are by definition owned zones. + -- and hence will be read anyway. this will merely ensure that the + -- ownership is established right away + local pZones = cfxZones.zonesWithProperty("owner") + for n, aZone in pairs(pZones) do + aZone.owner = cfxZones.getCoalitionFromZoneProperty(aZone, "owner", 0) + end + + + -- now initialize moving zones + cfxZones.startMovingZones() + cfxZones.updateMovingZones() -- will auto-repeat + + trigger.action.outText("cf/x Zones v".. cfxZones.version .. ": loaded", 10) +end + +-- get everything rolling +cfxZones.init() diff --git a/modules/cfxmon.lua b/modules/cfxmon.lua new file mode 100644 index 0000000..2c4faf6 --- /dev/null +++ b/modules/cfxmon.lua @@ -0,0 +1,133 @@ +cfxmon = {} +cfxmon.version = "1.0.0" +cfxmon.delay = 30 -- seconds for display +--[[-- + Version History + 1.0.0 - initial version + +cfxmon is a monitor for all cfx events and callbacks +use monConfig to tell cfxmon which events and callbacks +to monitor. a Property with "no" or "false" will turn +that monitor OFF, else it will stay on + +supported modules if loaded + dcsCommon + cfxPlayer + cfxGroundTroops + cfxObjectDestructDetector + cfxSpawnZones + +--]]-- + +-- +-- CALLBACKS +-- +-- dcsCommon Callbacks +function cfxmon.pre(event) + trigger.action.outText("***mon - dcsPre: " .. event.id .. " (" .. dcsCommon.event2text(event.id) .. ")", cfxmon.delay) + return true +end + +function cfxmon.post(event) + trigger.action.outText("***mon - dcsPost: " .. event.id .. " (" .. dcsCommon.event2text(event.id) .. ")", cfxmon.delay) +end + +function cfxmon.rejected(event) + trigger.action.outText("***mon - dcsReject: " .. event.id .. " (" .. dcsCommon.event2text(event.id) .. ")", cfxmon.delay) +end + +function cfxmon.dcsCB(event) + local initiatorStat = "" + if event.initiator then + local theUnit = event.initiator + local theGroup = theUnit:getGroup() + + local theGroupName = "" + if theGroup then theGroupName = theGroup:getName() end + + initiatorStat = ", for " .. theUnit:getName() + initiatorStat = initiatorStat .. " of " .. theGroupName + else + initiatorStat = ", NO Initiator" + end + trigger.action.outText("***mon - dcsMAIN: " .. event.id .. " (" .. dcsCommon.event2text(event.id) .. ")" .. initiatorStat, cfxmon.delay) +end + +-- cfxPlayer callback +function cfxmon.playerEventCB(evType, description, info, data) + trigger.action.outText("***mon - cfxPlayer: ".. evType ..": <" .. description .. ">", cfxmon.delay) +end + +-- cfxGroundTroops callback +function cfxmon.groundTroopsCB(reason, theGroup, orders, data) + trigger.action.outText("***mon - groundTroops: ".. reason ..": for group <" .. theGroup:getName() .. "> with orders " .. orders, cfxmon.delay) +end + +-- object destruct callbacks +function cfxmon.oDestructCB(zone, ObjectID, name) + trigger.action.outText("***mon - object destroyed: ".. ObjectID .." named <" .. name .. "> in zone " .. zone.name, cfxmon.delay) +end + +-- spawner callback +function cfxmon.spawnZoneCB(reason, theGroup, theSpawner) + local gName = "" + if theGroup then gName = theGroup:getName() end + trigger.action.outText("***mon - Spawner: ".. reason .." group <" .. gName .. "> in zone " .. theSpawner.name, cfxmon.delay) +end + +-- READ CONFIG AND SUBSCRIBE +function cfxmon.start () + local theZone = cfxZones.getZoneByName("monConfig") + if not theZone then + trigger.action.outText("***mon: WARNING: NO config, defaulting", cfxmon.delay) + theZone = cfxZones.createSimpleZone("MONCONFIG") + end + + -- own config + cfxmon.delay = cfxZones.getNumberFromZoneProperty(theZone, "delay", 30) + trigger.action.outText("!!!mon: Delay is set to: " .. cfxmon.delay .. seconds, 50) + + -- dcsCommon + if cfxZones.getBoolFromZoneProperty(theZone, "dcsCommon", true) then + -- subscribe to dcs event handlers + -- note we have all, but only connect the main + dcsCommon.addEventHandler(cfxmon.dcsCB) -- we only connect one + trigger.action.outText("!!!mon: +dcsCommon", cfxmon.delay) + else + trigger.action.outText("***mon: -dcsCommon", cfxmon.delay) + end + + -- cfxPlayer + if cfxPlayer and cfxZones.getBoolFromZoneProperty(theZone, "cfxPlayer", true) then + cfxPlayer.addMonitor(cfxmon.playerEventCB) + trigger.action.outText("!!!mon: +cfxPlayer", cfxmon.delay) + else + trigger.action.outText("***mon: -cfxPlayer", cfxmon.delay) + end + + -- cfxGroundTroops + if cfxGroundTroops and cfxZones.getBoolFromZoneProperty(theZone, "cfxGroundTroops", true) then + cfxGroundTroops.addTroopsCallback(cfxmon.groundTroopsCB) + trigger.action.outText("!!!mon: +cfxGroundTroops", cfxmon.delay) + else + trigger.action.outText("***mon: -cfxGroundTroops", cfxmon.delay) + end + + -- objectDestructZones + if cfxObjectDestructDetector and cfxZones.getBoolFromZoneProperty(theZone, "cfxObjectDestructDetector", true) then + cfxObjectDestructDetector.addCallback(cfxmon.oDestructCB) + trigger.action.outText("!!!mon: +cfxObjectDestructDetector", cfxmon.delay) + else + trigger.action.outText("***mon: -cfxObjectDestructDetector", cfxmon.delay) + end + + -- spawnZones + if cfxSpawnZones and cfxZones.getBoolFromZoneProperty(theZone, "cfxSpawnZones", true) then + cfxSpawnZones.addCallback(cfxmon.spawnZoneCB) + trigger.action.outText("!!!mon: +cfxSpawnZones", cfxmon.delay) + else + trigger.action.outText("***mon: -cfxSpawnZones", cfxmon.delay) + end +end + +cfxmon.start() diff --git a/modules/civAir.lua b/modules/civAir.lua new file mode 100644 index 0000000..9d2119a --- /dev/null +++ b/modules/civAir.lua @@ -0,0 +1,572 @@ +civAir = {} +civAir.version = "1.4.0" +--[[-- + 1.0.0 initial version + 1.1.0 exclude list for airfields + 1.1.1 bug fixes with remove flight + and betweenHubs + check if slot is really free before spawning + add overhead waypoint + 1.1.2 inAir start possible + 1.2.0 civAir can use own config file + 1.2.1 slight update to config file (moved active/idle) + 1.3.0 add ability to use zones to add closest airfield to + trafficCenters or excludeAirfields + 1.4.0 ability to load config from zone to override + all configs it finds + module check + removed obsolete civAirConfig module + + +--]]-- + +civAir.ups = 0.05 -- updates per second +civAir.initialAirSpawns = true -- when true has population spawn in-air at start +civAir.verbose = false + +-- aircraftTypes contains the type names for the neutral air traffic +-- each entry has the same chance to be chose, so to make an +-- aircraft more probably to appear, add its type multiple times +-- like here with the Yak-40 +civAir.aircraftTypes = {"Yak-40", "Yak-40", "C-130", "C-17A", "IL-76MD", "An-30M", "An-26B"} -- civilian planes type strings as described here https://github.com/mrSkortch/DCS-miscScripts/tree/master/ObjectDB + +-- maxTraffic is the number of neutral flights that are +-- concurrently under way +civAir.maxTraffic = 10 -- number of flights at the same time +civAir.maxIdle = 8 * 60 -- seconds of ide time before it is removed after landing +civAir.trafficAirbases = { + randomized = 0, -- between any on map + localHubs = 1, -- between any two airfields inside the same random hub listed in trafficCenters + betweenHubs = 2 -- between any in random hub 1 to any in random hub 2 + } + +civAir.trafficRange = 100 -- 120000 -- defines hub size, in meters. Make it 100 to make it only that airfield +-- ABPickmethod determines how airfields are picked +-- for air traffic +civAir.ABPickMethod = civAir.trafficAirbases.betweenHubs +civAir.trafficCenters = { + --"batu", + --"kobul", + --"senaki", + --"kutai", + } -- trafficCenters is used with hubs. Each entry defines a hub + -- where we collect airdromes etc based on range + -- simply add a string to identify the hub center + -- e.g. "senak" to define "Senaki Kolkhi" + -- to have planes only fly between airfields in 100 km range + -- around senaki kolkhi, enter only senaki as traffic center, set + -- trafficRange to 100000 and ABPickMethod to localHubs + -- to have traffic only between any airfields listed + -- in trafficCenters, set trafficRange to a small value + -- like 100 meters and set ABPickMethod to betweenHubs + -- to have flights that always cross the map with multiple + -- airfields, choose two or three hubs that are 300 km apart, + -- then set trafficRange to 150000 and ABPickMethod to betweenHubs + -- you can also place zones on the map and add a + -- civAir attribute. If the attribute value is anything + -- but "exclude", the closest airfield to the zone + -- is added to trafficCenters + -- if you leave this list empty, and do not add airfields + -- by zones, the list is automatically populated by all + -- airfields in the map + +civAir.excludeAirfields = { + --"senaki", + } + -- list all airfields that must NOT be included in + -- civilian activities. Will be used for neither landing + -- nor departure. overrides any airfield that was included + -- in trafficCenters. Here, Senaki is off limits for + -- civilian air traffic + -- can be populated by zone on the map that have the + -- 'civAir' attribute with value "exclude" + +civAir.requiredLibs = { + "dcsCommon", -- common is of course needed for everything + "cfxZones", -- zones management foc CSAR and CSAR Mission zones +} + +civAir.activePlanes = {} +civAir.idlePlanes = {} + +function civAir.readConfigZone() + -- note: must match exactly!!!! + local theZone = cfxZones.getZoneByName("CivAirConfig") + if not theZone then + trigger.action.outText("***civA: NO config zone!", 30) + return + end + + trigger.action.outText("civA: found config zone!", 30) + + -- ok, for each property, load it if it exists + if cfxZones.hasProperty(theZone, "aircraftTypes") then + civAir.aircraftTypes = cfxZones.getStringFromZoneProperty(theZone, "aircraftTypes", "Yak-40") + end + + if cfxZones.hasProperty(theZone, "ups") then + civAir.ups = cfxZones.getNumberFromZoneProperty(theZone, "ups", 0.05) + if civAir.ups < .0001 then civAir.ups = 0.05 end + end + + if cfxZones.hasProperty(theZone, "maxTraffic") then + civAir.maxTraffic = cfxZones.getNumberFromZoneProperty(theZone, "maxTraffic", 10) + end + + if cfxZones.hasProperty(theZone, "maxIdle") then + civAir.maxIdle = cfxZones.getNumberFromZoneProperty(theZone, "maxIdle", 8 * 60) + end + + if cfxZones.hasProperty(theZone, "trafficRange") then + civAir.trafficRange = cfxZones.getNumberFromZoneProperty(theZone, "trafficRange", 120000) -- 120 km + end + + if cfxZones.hasProperty(theZone, "ABPickMethod") then + civAir.ABPickMethod = cfxZones.getNumberFromZoneProperty(theZone, "ABPickMethod", 0) -- randomized any + end + + if cfxZones.hasProperty(theZone, "initialAirSpawns") then + civAir.initialAirSpawns = cfxZones.getBoolFromZoneProperty(theZone, "initialAirSpawns", true) +end + + + +function civAir.addPlane(thePlaneUnit) -- warning: is actually a group + if not thePlaneUnit then return end + civAir.activePlanes[thePlaneUnit:getName()] = thePlaneUnit +end + +function civAir.removePlaneGroupByName(aName) + if not aName then + return + end + if civAir.activePlanes[aName] then + --trigger.action.outText("civA: REMOVING " .. aName .. " ***", 30) + civAir.activePlanes[aName] = nil + else + trigger.action.outText("civA: warning - ".. aName .." remove req but not found", 30) + end +end + +function civAir.removePlane(thePlaneUnit) -- warning: is actually a group + if not thePlaneUnit then return end + if not thePlaneUnit:isExist() then return end + civAir.activePlanes[thePlaneUnit:getName()] = nil +end + +function civAir.getPlane(aName) -- warning: returns GROUP! + return civAir.activePlanes[aName] +end + +-- get an air base, may exclude an airbase from choice +-- method is dependent on +function civAir.getAnAirbase(excludeThisOne) + -- different methods to select a base + -- purely random from current list + local theAB; + if civAir.ABPickMethod == civAir.trafficAirbases.randomized then + repeat + local allAB = dcsCommon.getAirbasesWhoseNameContains("*", 0) -- all airfields, no Ships nor FABS + theAB = dcsCommon.pickRandom(allAB) + until theAB ~= excludeThisOne + return theAB + end + + if civAir.ABPickMethod == civAir.trafficAirbases.localHubs then + -- first, pick a hub name + end + + trigger.action.outText("civA: warning - unknown method <" .. civAir.ABPickMethod .. ">", 30) + return nil +end + +function civAir.excludeAirbases(inList, excludeList) + if not inList then return {} end + if not excludeList then return inList end + if #excludeList < 1 then return inList end + + local theDict = {} + -- build dict + for idx, aBase in pairs(inList) do + theDict[aBase:getName()] = aBase + end + + -- now iterate through all excludes and remove them from dics + for idx, aName in pairs (excludeList) do + local allOfflimitAB = dcsCommon.getAirbasesWhoseNameContains(aName, 0) + for idx2, illegalBase in pairs (allOfflimitAB) do + theDict[illegalBase:getName()] = nil + end + end + -- now linearise (make array) from dict + local theArray = dcsCommon.enumerateTable(theDict) + return theArray +end + +function civAir.getTwoAirbases() + local fAB + local sAB + -- get any two airbases on the map + if civAir.ABPickMethod == civAir.trafficAirbases.randomized then + local allAB = dcsCommon.getAirbasesWhoseNameContains("*", 0) -- all airfields, no Ships nor FABS, all coalitions + -- remove illegal source/dest airfields + allAB = civAir.excludeAirbases(allAB, civAir.excludeAirfields) + + fAB = dcsCommon.pickRandom(allAB) + repeat + sAB = dcsCommon.pickRandom(allAB) + until fAB ~= sAB or (#allAB < 2) + return fAB, sAB + end + + -- pick a hub, and then selct any two different airbases in the hub + if civAir.ABPickMethod == civAir.trafficAirbases.localHubs then + local hubName = dcsCommon.pickRandom(civAir.trafficCenters) + -- get the airfield that is identified by this + local theHub = dcsCommon.getFirstAirbaseWhoseNameContains(hubName, 0) -- only airfields, all coalitions + -- get all airbases that surround in range + local allAB = dcsCommon.getAirbasesInRangeOfAirbase( + theHub, -- centered on this base + true, -- include hub itself + civAir.trafficRange, -- hub size in meters + 0 -- only airfields + ) + allAB = civAir.excludeAirbases(allAB, civAir.excludeAirfields) + fAB = dcsCommon.pickRandom(allAB) + repeat + sAB = dcsCommon.pickRandom(allAB) + until fAB ~= sAB or (#allAB < 2) + return fAB, sAB + end + + -- pick two hubs: one for source, one for destination airfields, + -- then pick an airfield from each hub + if civAir.ABPickMethod == civAir.trafficAirbases.betweenHubs then + --trigger.action.outText("between", 30) + local sourceHubName = dcsCommon.pickRandom(civAir.trafficCenters) + --trigger.action.outText("picked " .. sourceHubName, 30) + local sourceHub = dcsCommon.getFirstAirbaseWhoseNameContains(sourceHubName, 0) + --trigger.action.outText("sourceHub " .. sourceHub:getName(), 30) + + local destHub + repeat destHubName = dcsCommon.pickRandom(civAir.trafficCenters) + until destHubName ~= sourceHubName or #civAir.trafficCenters < 2 + destHub = dcsCommon.getFirstAirbaseWhoseNameContains(destHubName, 0) + --trigger.action.outText("destHub " .. destHub:getName(), 30) + local allAB = dcsCommon.getAirbasesInRangeOfAirbase( + sourceHub, -- centered on this base + true, -- include hub itself + civAir.trafficRange, -- hub size in meters + 0 -- only airfields + ) + allAB = civAir.excludeAirbases(allAB, civAir.excludeAirfields) + fAB = dcsCommon.pickRandom(allAB) + allAB = dcsCommon.getAirbasesInRangeOfAirbase( + destHub, -- centered on this base + true, -- include hub itself + civAir.trafficRange, -- hub size in meters + 0 -- only airfields + ) + allAB = civAir.excludeAirbases(allAB, civAir.excludeAirfields) + sAB = dcsCommon.pickRandom(allAB) + return fAB, sAB + end + + + trigger.action.outText("civA: warning - unknown method <" .. civAir.ABPickMethod .. "> in getTwoAirbases()", 30) +end + +function civAir.parkingIsFree(fromWP) + -- iterate over all currently registres flights and make + -- sure that their location isn't closer than 10m to my new parking + local loc = {} + loc.x = fromWP.x + loc.y = fromWP.alt + loc.z = fromWP.z + + for name, aPlaneGroup in pairs(civAir.activePlanes) do + if aPlaneGroup:isExist() then + local aPlane = aPlaneGroup:getUnit(1) + if aPlane:isExist() then + pos = aPlane:getPoint() + local delta = dcsCommon.dist(loc, pos) + if delta < 21 then + -- way too close + trigger.action.outText("civA: too close for comfort - " .. aPlane:getName() .. " occupies my slot", 30) + return false + end + end + end + end + + return true +end + +civAir.airStartSeparation = 0 +function civAir.createFlight(name, theTypeString, fromAirfield, toAirfield, inAirStart) + if not fromAirfield then + trigger.action.outText("civA: NIL fromAirfield", 30) + return nil + end + + if not toAirfield then + trigger.action.outText("civA: NIL toAirfield", 30) + return nil + end + + local theGroup = dcsCommon.createEmptyAircraftGroupData (name) + local theAUnit = dcsCommon.createAircraftUnitData(name .. "-civA", theTypeString, false) + theAUnit.payload.fuel = 100000 + dcsCommon.addUnitToGroupData(theAUnit, theGroup) + + local fromWP = dcsCommon.createTakeOffFromParkingRoutePointData(fromAirfield) + if not fromWP then + trigger.action.outText("civA: fromWP create failed", 30) + return nil + end + if inAirStart then + -- modify WP into an in-air point + fromWP.alt = fromWP.alt + 3000 + civAir.airStartSeparation -- 9000 ft overhead + separation + fromWP.action = "Turning Point" + fromWP.type = "Turning Point" + + fromWP.speed = 150; + fromWP.airdromeId = nil + + theAUnit.alt = fromWP.alt + theAUnit.speed = fromWP.speed + end + -- sometimes, when landing kicks in too early, the plane lands + -- at the wrong airfield. AI sucks. + -- so we force overflight of target airfield + local overheadWP = dcsCommon.createOverheadAirdromeRoutPintData(toAirfield) + local toWP = dcsCommon.createLandAtAerodromeRoutePointData(toAirfield) + if not toWP then + trigger.action.outText("civA: toWP create failed", 30) + return nil + end + + if not civAir.parkingIsFree(fromWP) then + trigger.action.outText("civA: failed free parking check for flight " .. name, 30) + return nil + end + + dcsCommon.moveGroupDataTo(theGroup, + fromWP.x, + fromWP.y) + dcsCommon.addRoutePointForGroupData(theGroup, fromWP) + dcsCommon.addRoutePointForGroupData(theGroup, overheadWP) + dcsCommon.addRoutePointForGroupData(theGroup, toWP) + + -- spawn + local groupCat = Group.Category.AIRPLANE + local theSpawnedGroup = coalition.addGroup(82, groupCat, theGroup) -- 82 is UN peacekeepers + return theSpawnedGroup +end + +-- flightCount is a global that holds the number of flights we track +civAir.flightCount = 0 +function civAir.createNewFlight(inAirStart) + + civAir.flightCount = civAir.flightCount + 1 + local fAB, sAB = civAir.getTwoAirbases() -- from AB + + local name = fAB:getName() .. "-" .. sAB:getName().. "/" .. civAir.flightCount + local TypeString = dcsCommon.pickRandom(civAir.aircraftTypes) + local theFlight = civAir.createFlight(name, TypeString, fAB, sAB, inAirStart) + + if not theFlight then + -- flight was not able to spawn. + trigger.action.outText("civA: aborted civ spawn on fAB:" .. fAB:getName(), 30) + return + end + + civAir.addPlane(theFlight) -- track it + + if civAir.verbose then + trigger.action.outText("civA: created flight from <" .. fAB:getName() .. "> to <" .. sAB:getName() .. ">", 30) + end +end + +function civAir.airStartPopulation() + local numAirStarts = civAir.maxTraffic / 2 + civAir.airStartSeparation = 0 + while numAirStarts > 0 do + numAirStarts = numAirStarts - 1 + civAir.airStartSeparation = civAir.airStartSeparation + 200 + civAir.createNewFlight(true) + end +end + +-- +-- U P D A T E L O O P +-- + +function civAir.update() + -- reschedule me in the future. ups = updates per second. + timer.scheduleFunction(civAir.update, {}, timer.getTime() + 1/civAir.ups) + + -- clean-up first: + -- any group that no longer exits will be removed from the array + local removeMe = {} + for name, group in pairs (civAir.activePlanes) do + if not group:isExist() then + table.insert(removeMe, name) -- mark for deletion + --Group.destroy(group) -- may break + end + end + + for idx, name in pairs(removeMe) do + civAir.activePlanes[name] = nil + trigger.action.outText("civA: warning - removed " .. name .. " from active roster, no longer exists", 30) + end + + + -- now, run through all existing flights and update their + -- idle times. also count how many planes there are + local planeNum = 0 + local overduePlanes = {} + local now = timer.getTime() + for name, aPlaneGroup in pairs(civAir.activePlanes) do + local speed = 0 + if aPlaneGroup:isExist() then + local aPlane = aPlaneGroup:getUnit(1) + + if aPlane and aPlane:isExist() and aPlane:getLife() >= 1 then + planeNum = planeNum + 1 + local vel = aPlane:getVelocity() + speed = dcsCommon.mag(vel.x, vel.y, vel.z) + else + -- force removal of group + civAir.idlePlanes[name] = -1000 + speed = 0 + end + else + -- force removal + civAir.idlePlanes[name] = -1000 + speed = 0 + end + + if speed < 0.5 then + if not civAir.idlePlanes[name] then + civAir.idlePlanes[name] = now + end + local idleTime = now - civAir.idlePlanes[name] + --trigger.action.outText("civA: Idling <" .. name .. "> for t=" .. idleTime, 30) + if idleTime > civAir.maxIdle then + table.insert(overduePlanes, name) + end + else + -- zero out idle plane + civAir.idlePlanes[name] = nil + end + --]]-- + end + + -- see if we have less than max flights running + if planeNum < civAir.maxTraffic then + -- spawn a new plane. just one per pass + civAir.createNewFlight() + end + + -- now remove all planes that are overdue + for idx, aName in pairs(overduePlanes) do + local aFlight = civAir.getPlane(aName) -- returns a group + civAir.removePlaneGroupByName(aName) -- remove from roster + if aFlight and aFlight:isExist() then + -- destroy can only work if group isexist! + Group.destroy(aFlight) -- remember: flights are groups! + end + end +end + + + +function civAir.doDebug(any) + trigger.action.outText("cf/x civTraffic debugger.", 30) + local desc = "Active Planes:" + local now = timer.getTime() + for name, group in pairs (civAir.activePlanes) do + desc = desc .. "\n" .. name + if civAir.idlePlanes[name] then + delay = now - civAir.idlePlanes[name] + desc = desc .. " (idle for " .. delay .. ")" + end + end + trigger.action.outText(desc, 30) +end + +function civAir.collectHubs() + local pZones = cfxZones.zonesWithProperty("civAir") + + for k, aZone in pairs(pZones) do + local value = cfxZones.getStringFromZoneProperty(aZone, "civAir", "") + local af = dcsCommon.getClosestAirbaseTo(aZone.point, 0) -- 0 = only airfields, not farp or ships + if af then + local afName = af:getName() + if value:lower() == "exclude" then + table.insert(civAir.excludeAirfields, afName) + else + table.insert(civAir.trafficCenters, afName) + end + end + + end +end + +function civAir.listTrafficCenters() + trigger.action.outText("Traffic Centers", 30) + for idx, aName in pairs(civAir.trafficCenters) do + trigger.action.outText(aName, 30) + end +end + +-- start +function civAir.start() + -- module check + if not dcsCommon.libCheck("cfx civAir", civAir.requiredLibs) then + return false + end + + -- see if there is a config zone and load it + civAir.readConfigZone() + + -- look for zones to add to air fields list + civAir.collectHubs() + + -- make sure there is something in trafficCenters + if #civAir.trafficCenters < 1 then + trigger.action.outText("+++civTraffic: auto-populating", 30) + -- simply add airfields on the map + local allBases = dcsCommon.getAirbasesWhoseNameContains("*", 0) + for idx, aBase in pairs(allBases) do + local afName = aBase:getName() + --trigger.action.outText("+++civTraffic: adding " .. afName, 30) + table.insert(civAir.trafficCenters, afName) + end + end + + civAir.listTrafficCenters() + + -- air-start half population if allowed + if civAir.initialAirSpawns then + civAir.airStartPopulation() + end + + -- start the update loop + civAir.update() + + -- say hi! + trigger.action.outText("cf/x civTraffic v" .. civAir.version .. " started.", 30) + return true +end + +if not civAir.start() then + trigger.action.outText("cf/x civAir aborted: missing libraries", 30) + civAir = nil +end + + --[[-- + Additional ideas + source to target method + --]]-- \ No newline at end of file diff --git a/modules/csarManager2.lua b/modules/csarManager2.lua new file mode 100644 index 0000000..192360d --- /dev/null +++ b/modules/csarManager2.lua @@ -0,0 +1,1151 @@ +csarManager = {} +csarManager.version = "2.0.3" +--[[-- VERSION HISTORY + - 1.0.0 initial version + - 1.0.1 - smoke optional + - airframeCrashed method for airframe manager + - removed '(downed )' when re-picked up + - fixed oclock + - 1.0.2 - hover retrieval + - 1.0.3 - corrected a bug in oclock during hovering + - 1.0.4 - now correctly allocates pilot to coalition via dcscommon.coalition2county + - 1.1.0 - pilot adds weight to unit + - module check + - 2.0.0 - weight managed via cargoSuper + - 2.0.1 - getCSARBaseforZone() + - check if zone landed in has owner attribute + to provide compatibility with owned zones, + FARPZones etc that keep zone.owner up to date + - 2.0.2 - use parametric csarManager.hoverAlt + - use hoverDuration + - 2.0.3 - corrected bug in hoverDuration + +--]]-- +-- modules that need to be loaded BEFORE I run +csarManager.requiredLibs = { + "dcsCommon", -- common is of course needed for everything + "cfxZones", -- zones management foc CSAR and CSAR Mission zones + "cfxPlayer", -- player monitoring and group monitoring + "nameStats", -- generic data module for weight + "cargoSuper", +} + +-- *** DOES NOT EXTEND ZONES *** BUT USES OWN STRUCT + +--[[-- + CSAR MANAGER + ============ + + This module can create and manage CSAR missions, i.e. + create a unit on the ground, mark it on the map, handle + if the unit is killed, create enemies in the vicinity + + It will install a menu in any troop helicopter as + determined by dcsCommon.isTroopCarrier() with the + option to list available csar mission. for each created mission + it will give range and frequency for ADF + When a helicopter is in range, it will set smoke to better + visually identify the location. + + When the helicopter lands close enough to a downed pilot, + the pilot is picket up automatically. Their weight is added + to the unit, so it may overload! + + When the helicopter than lands in a CSARBASE Zone, the mission is + a success and a success callback is invoked automatically for + all picked up groups. All zones that have the CSARBASE property are + CSAR Bases, but their coalition must be either neutral or match the + one of the unit that landed + + On start, it scans all zones for a CSAR property, and creates + a CSAR mission with data taken from the properties in the + zone so you can easily create CSAR missions in ME + + WARNING: ASSUMES SINGLE UNIT PLAYER GROUPS + ========================================== + + Main Interface + - createCSARMission(location, side, numCrew, mark, clearing, timeout) + creates a csar mission that can be tracked. + location is the position on the map + side is the side the unit is on (neutal is for any side) + numCrew the number of people (1-4) + mark true if marked on map + clearing will create a clearing + timeout - time in seconds until pilots die. timer stops on pickup + RETURNS true, "ok" -- false, "fail reason" (string) + + - createCSARAdversaries(location, side, numEnemies, radius, maxRadius) + creates some random infantery randomized on a circle around the location + location - center, usually where the downed pilot is + side - side of the enemy red/blue + numEnemies - number of infantry + radius[, maxRadius] distance of the enemy troops + + - in ME, create at least one zone with a property named "CSARBASE" for + each side that supports csar missions. This is where the players + can drop off pilots that they rescued. If you have no CSARBASE zone + defined, you'll receive a warning for that side when you attempt a + rescue + + - in ME you can place zones with a CSAR attribute that will generate + a scar mission. Further attributes are "coalition" (red/blue), "name" (any name you like) and "freq" (for elt ADR, leave empty for random) + + NOTE: + CSARBASE is compatible with the FARP Attribute of + FARP Zones + + +--]]-- +-- +-- OPTIONS +-- +csarManager.useSmoke = false -- smoke is a performance killer, so you can turn it off + + +-- unitConfigs contain the config data for any helicopter +-- currently in the game. The Array is indexed by unit name +csarManager.unitConfigs = {} +csarManager.myEvents = {3, 4, 5} -- 3 = take off, 4 = land, 5 = crash + +-- +-- CASR MISSION +-- +csarManager.openMissions = {} -- all currently available missions +csarManager.csarBases = {} -- all bases where we can drop off rescued pilots + +csarManager.missionID = 1 -- to create uuid +csarManager.rescueRadius = 70 -- must land within 50m to rescue +csarManager.hoverRadius = 30 -- must hover within 10m of unit +csarManager.hoverAlt = 40 -- must hover below this alt +csarManager.hoverDuration = 20 -- must hover for this duration +csarManager.rescueTriggerRange = 2000 -- when the unit pops smoke and radios +csarManager.beaconSound = "Radio_beacon_of_distress_on_121,5_MHz.ogg" +csarManager.pilotWeight = 120 -- kg for the rescued person. added to the unit's weight +-- +-- callbacks +-- +csarManager.csarCompleteCB = {} + +-- +-- CREATING A CSAR +-- +function csarManager.createDownedPilot(theMission) + local aLocation = {} + local aHeading = 0 -- in rads + local newTargetZone = theMission.zone + aLocation, aHeading = dcsCommon.randomPointOnPerimeter(newTargetZone.radius / 2 + 3, newTargetZone.point.x, newTargetZone.point.z) + + local theBoyGroup = dcsCommon.createSingleUnitGroup(theMission.name, + "Soldier M4 GRG", -- "Soldier M4 GRG", + aLocation.x, + aLocation.z, + -aHeading + 1.5) -- + 1.5 to turn inwards + + -- WARNING: + -- coalition.addGroup takes the COUNTRY of the group, and derives the + -- coalition from that. So if mission.sie is 0, we use UN, if it is 1 (red) it + -- is joint red, if 2 it is joint blue + local theSideCJTF = dcsCommon.coalition2county(theMission.side) -- get the correct county CJTF + theMission.group = coalition.addGroup(theSideCJTF, + Group.Category.GROUND, + theBoyGroup) + + if theBoyGroup then +-- trigger.action.outText("+++csar: created csar!", 30) + else + trigger.action.outText("+++csar: FAILED to create csar!", 30) + end + + + -- we now use commands to send radio transmissions + local ADF = 20 + math.random(90) + if theMission.freq then ADF = theMission.freq else theMission.freq = ADF end + local theCommands = cfxCommander.createCommandDataTableFor(theMission.group) + local cmd = cfxCommander.createSetFrequencyCommand(ADF) -- freq in 10000 Hz + cfxCommander.addCommand(theCommands, cmd) + cmd = cfxCommander.createTransmissionCommand(csarManager.beaconSound) + cfxCommander.addCommand(theCommands, cmd) + cfxCommander.scheduleCommands(theCommands, 2) -- in 2 seconds, so unit has time to percolate through DCS +end + +function csarManager.createCSARMissionData(point, theSide, freq, name, numCrew, timeLimit, mapMarker) + -- create a type + if not timeLimit then timeLimit = -1 end + if not point then return nil end + local newMission = {} + newMission.side = theSide + if dcsCommon.stringStartsWith(name, "(downed) ") then + -- remove "downed" - it will be added again later + name = dcsCommon.removePrefix(name, "(downed) ") + end + + newMission.name = "(downed) " .. name .. "-" .. csarManager.missionID -- make it uuid-capable + newMission.zone = cfxZones.createSimpleZone(newMission.name, point, csarManager.rescueRadius) + newMission.marker = mapMarker -- so it can be removed later + newMission.isHot = false -- creating adversaries will make it hot, or when units are near. maybe implement a search later? + -- detection and load stuff + newMission.lastSmokeTime = -1000 -- so it will smoke immediately + newMission.messagedUnits = {} -- so we remember whom the unit radioed + newMission.hoveringUnits = {} -- used when hovering + newMission.freq = freq -- if nil will make random + + -- allocate units + csarManager.createDownedPilot(newMission) + + -- update counter and return + csarManager.missionID = csarManager.missionID + 1 + return newMission +end + +function csarManager.addMission(theMission) + table.insert(csarManager.openMissions, theMission) +end + +function csarManager.removeMission(theMission) + if not theMission then return end + local newMissions = {} + for idx, aMission in pairs (csarManager.openMissions) do + if aMission ~= theMission then + table.insert(newMissions, aMission) + else + end + end + csarManager.openMissions = newMissions -- this is the new batch +end + +function csarManager.removeMissionForGroup(theDownedGroup) + if not theDownedGroup then return end + local newMissions = {} + for idx, aMission in pairs (csarManager.openMissions) do + if aMission.group ~= theDownedGroup then + table.insert(newMissions, aMission) + else + end + end + csarManager.openMissions = newMissions -- this is the new batch +end +-- +-- UNIT CONFIG +-- +function csarManager.resetConfig(conf) + -- reset only ovberwrites mission-relevant data + conf.troopsOnBoard = {} -- number of rescued missions + local myName = conf.name + cargoSuper.removeAllMassForCargo(myName, "Evacuees") -- will allocate new empty table + conf.currentState = -1 -- indetermined, 0 = landed 1 = airborne + conf.timeStamp = timer.getTime() +end + +function csarManager.createDefaultConfig(theUnit) + local conf = {} + conf.theUnit = theUnit + conf.name = theUnit:getName() + csarManager.resetConfig(conf) + --conf.unit = {} -- the unit this is linked to + conf.myMainMenu = nil -- this is the main menu for group + conf.myCommands = nil -- all commands in sub menu + conf.id = theUnit:getID() + return conf +end + + +function csarManager.getUnitConfig(theUnit) -- will create new config if not existing + if not theUnit then + trigger.action.outText("+++csar: nil unit in get config!", 30) + return nil + end + local uName = theUnit:getName() + local c = csarManager.getConfigForUnitNamed(uName) + if not c then + c = csarManager.createDefaultConfig(theUnit) + csarManager.unitConfigs[uName] = c + end + return c +end + +function csarManager.getConfigForUnitNamed(aName) + return csarManager.unitConfigs[aName] +end + + +function csarManager.removeConfigForUnitNamed(aName) + if not aName then return end + if csarManager.unitConfigs[aName] then csarManager.unitConfigs[aName] = nil end +end + +-- +-- E V E N T H A N D L I N G +-- +function csarManager.isInteresting(eventID) + -- return true if we are interested in this event, false else + for key, evType in pairs(csarManager.myEvents) do + if evType == eventID then return true end + end + return false +end + +function csarManager.preProcessor(event) + -- make sure it has an initiator + if not event.initiator then return false end -- no initiator + local theUnit = event.initiator + local cat = theUnit:getCategory() + if cat ~= Unit.Category.HELICOPTER then + return false + end + + --trigger.action.outText("+++csar: event " .. event.id .. " for cat = " .. cat .. " (helicopter?) unit " .. theUnit:getName(), 30) + + if not cfxPlayer.isPlayerUnit(theUnit) then + --trigger.action.outText("+++csar: rejected event: " .. theUnit:getName() .. " not a player helo", 30) + return false + end -- not a player unit + return csarManager.isInteresting(event.id) +end + +function csarManager.postProcessor(event) + -- don't do anything for now +end + +function csarManager.somethingHappened(event) + -- when this is invoked, the preprocessor guarantees that + -- it's an interesting event + -- unit is valid and player + -- airframe category is helicopter + local theUnit = event.initiator + local ID = event.id + + local myType = theUnit:getTypeName() +-- trigger.action.outText("+++csar: event " .. ID .. " for player unit " .. theUnit:getName() .. " of type " .. myType, 30) + + if ID == 4 then -- landed + csarManager.heloLanded(theUnit) + end + + if ID == 3 then -- take off + csarManager.heloDeparted(theUnit) + end + + if ID == 5 then -- crash + csarManager.heloCrashed(theUnit) + end + + csarManager.setCommsMenu(theUnit) +end + +-- +-- +-- CSAR LANDED +-- +-- + +function csarManager.successMission(who, where, theMission) + trigger.action.outTextForCoalition(theMission.side, + who .. " successfully evacuated " .. theMission.name .. " to " .. where .. "!", + 30) + + -- now call callback for coalition side + -- callback has format callback(coalition, success true/false, numberSaved, descriptionText) + + for idx, callback in pairs(csarManager.csarCompleteCB) do + callback(theMission.side, true, 1, "test") + end + trigger.action.outSoundForCoalition(theMission.side, "Quest Snare 3.wav") +end + +function csarManager.heloLanded(theUnit) + -- when we have landed, + if not dcsCommon.isTroopCarrier(theUnit) then return end + local conf = csarManager.getUnitConfig(theUnit) + conf.unit = theUnit + local theGroup = theUnit:getGroup() + conf.id = theGroup:getID() + --conf.id = theUnit:getID() + conf.currentState = 0 + local thePoint = theUnit:getPoint() + local mySide = theUnit:getCoalition() + local myName = theUnit:getName() + + -- first, check if we have landed in a CSAR dropoff zone + -- if so, drop off all loaded csar troops and award the + -- points or airframes + local allEvacuees = cargoSuper.getManifestFor(myName, "Evacuees") -- returns unlinked array + + if #allEvacuees > 0 then -- wasif #conf.troopsOnBoard > 0 then + for idx, base in pairs(csarManager.csarBases) do + -- check if the attached zone has changed hands + -- this can happen if zone has its own owner + -- attribute and is conquered by another side + local currentBaseSide = base.side + + if base.zone.owner then + -- this zone is shared with capturable + -- zone extensions like owned zone, FARP etc. + -- use current owner + currentBaseSide = base.zone.owner +-- trigger.action.outText("+++csar: overriding base.side with zone owner = " .. currentBaseSide .. " for csarB " .. base.name .. ", requiring " .. mySide .. " or 0 to land", 30) + else +-- trigger.action.outText("+++csar: base " .. base.name .. " has no owner - proceeding with side = " .. base.side .. " looking for " .. mySide, 30) + end + + if currentBaseSide == mySide or + currentBaseSide == 0 + then -- can always land in neutral + if cfxZones.pointInZone(thePoint, base.zone) then + for idx, msn in pairs(conf.troopsOnBoard) do + -- each troopsOnboard is actually the + -- csar mission that I picked up + csarManager.successMission(myName, base.name, msn) + end + -- now use cargoSuper to retrieve all evacuees + -- and deliver them to safety + + for idx, theMassObject in pairs(allEvacuees) do + cargoSuper.removeMassObjectFrom( + myName, + "Evacuees", + theMassObject) + msn = theMassObject.ref + -- csarManager.successMission(myName, base.name, msn) + -- to be done when we remove troopsOnBoard + end + -- reset weight + local totalMass = cargoSuper.calculateTotalMassFor(myName) + trigger.action.setUnitInternalCargo(myName, totalMass) -- super recalcs +-- trigger.action.outText("+++csar: delivered - set internal weight for " .. myName .. " to " .. totalMass, 30) + +-- trigger.action.setUnitInternalCargo(myName, 10) -- 10 kg as empty + conf.troopsOnBoard = {} -- empty out troops on board + -- we do *not* return so we can pick up troops on + -- a CSARBASE if they were dropped there + + end + end -- my side? + end + end -- check only if I'm carrying evacuees + + -- if not in a csar dropoff zone, check if we are + -- landed in a csar pickup zone, and start loading + + local pickups = {} + for idx, mission in pairs(csarManager.openMissions) do + if mySide == mission.side then + -- see if we are inside the mission's rescue range + local d = dcsCommon.distFlat(thePoint, mission.zone.point) + if d < csarManager.rescueRadius then + -- pick up this mission an remove it from the + table.insert(pickups, mission) + end + end + end + + -- now process the missions that I've picked up, transfer them to troopsOnBoard, and remove the dudes + local didPickup = false + for idx, theMission in pairs(pickups) do + trigger.action.outTextForCoalition(mySide, + myName .. " is extracting " .. theMission.name .. "!", + 30) + didPickup = true; + + csarManager.removeMission(theMission) + table.insert(conf.troopsOnBoard, theMission) + theMission.group:destroy() -- will shut up radio as well + theMission.group = nil + -- now adapt for cargoSuper + theMassObject = cargoSuper.createMassObject( + csarManager.pilotWeight, + theMission.name, + theMission) + cargoSuper.addMassObjectTo( + myName, + "Evacuees", + theMassObject) + end + if didPickup then + trigger.action.outSoundForCoalition(mySide, "Quest Snare 3.wav") + end + -- reset unit's weight based on people on board + local totalMass = cargoSuper.calculateTotalMassFor(myName) + -- WAS: trigger.action.setUnitInternalCargo(myName, 10 + #conf.troopsOnBoard * csarManager.pilotWeight) -- 10 kg as empty + per-unit time people + trigger.action.setUnitInternalCargo(myName, totalMass) -- 10 kg as empty + per-unit time people +-- trigger.action.outText("+++csar: set internal weight for " .. myName .. " to " .. totalMass, 30) + +end + +-- +-- +-- Helo took off +-- +-- +function csarManager.heloDeparted(theUnit) + if not dcsCommon.isTroopCarrier(theUnit) then return end + -- if we have timed extractions (i.e. not instantaneous), + -- then we need to check if we take off after the timer runs out + + + -- when we take off, all that needs to be done is to change the state + -- to airborne, and then set the status flag + local conf = csarManager.getUnitConfig(theUnit) + conf.unit = theUnit + local theGroup = theUnit:getGroup() + conf.id = theGroup:getID() + conf.currentState = 1 -- in the air + + +end + +-- +-- +-- Helo Crashed +-- +-- + +function csarManager.heloCrashed(theUnit) + if not dcsCommon.isTroopCarrier(theUnit) then return end + -- problem: this isn't called on network games. + + -- clean up + local conf = csarManager.getUnitConfig(theUnit) + conf.unit = theUnit + local theGroup = theUnit:getGroup() + conf.id = theGroup:getID() + conf.currentState = -1 -- (we don't know) + --[[-- + if #conf.troopsOnBoard > 0 then + -- this is where we can create a new CSAR mission + trigger.action.outSoundForCoalition(conf.id, theUnit:getName() .. " crashed while evacuating " .. #conf.troopsOnBoard .. " pilots. Survivors possible.", 30) + trigger.action.outSoundForCoalition(conf.id, "Quest Snare 3.wav") + for i=1, #conf.troopsOnBoard do + local msn = conf.troopsOnBoard[i] -- picked up unit(s) + local theRescuedPilot = msn.name + -- create x new missions in 50m radius + -- except for pilot, that will be called + -- from limitedAirframes + csarManager.createCSARforUnit(theUnit, theRescuedPilot, 50, true) + end + end + --]]-- + conf.troopsOnBoard = {} + local myName = conf.name + cargoSuper.removeAllMassForCargo(myName, "Evacuees") -- will allocate new empty table + csarManager.removeComms(conf.unit) +end + +function csarManager.airframeCrashed(theUnit) + -- called from airframe manager + if not dcsCommon.isTroopCarrier(theUnit) then return end + local conf = csarManager.getUnitConfig(theUnit) + conf.unit = theUnit + local theGroup = theUnit:getGroup() + conf.id = theGroup:getID() + -- may want to do something, for now just nothing + +end + +function csarManager.airframeDitched(theUnit) + -- called from airframe manager + if not dcsCommon.isTroopCarrier(theUnit) then return end + + local conf = csarManager.getUnitConfig(theUnit) + conf.unit = theUnit + local theGroup = theUnit:getGroup() + conf.id = theGroup:getID() + local theSide = theUnit:getCoalition() + if #conf.troopsOnBoard > 0 then + -- this is where we can create a new CSAR mission + trigger.action.outTextForCoalition(theSide, theUnit:getName() .. " abandoned while evacuating " .. #conf.troopsOnBoard .. " pilots. There many be survivors.", 30) +-- trigger.action.outSoundForCoalition(conf.id, "Quest Snare 3.wav") + for i=1, #conf.troopsOnBoard do + local msn = conf.troopsOnBoard[i] -- picked up unit(s) + local theRescuedPilot = msn.name + -- create x new missions in 50m radius + -- except for pilot, that will be called + -- from limitedAirframes + csarManager.createCSARforUnit(theUnit, theRescuedPilot, 50, true) + end + end + -- NYI: re-populate from cargo + local myName = conf.name + cargoSuper.removeAllMassForCargo(myName, "Evacuees") -- will allocate new empty table +end + +-- +-- +-- M E N U H A N D L I N G & R E S P O N S E +-- +-- +function csarManager.clearCommsSubmenus(conf) + if conf.myCommands then + for i=1, #conf.myCommands do + missionCommands.removeItemForGroup(conf.id, conf.myCommands[i]) + end + end + conf.myCommands = {} +end + +function csarManager.removeCommsFromConfig(conf) + csarManager.clearCommsSubmenus(conf) + + if conf.myMainMenu then + missionCommands.removeItemForGroup(conf.id, conf.myMainMenu) + conf.myMainMenu = nil + end +end + +function csarManager.removeComms(theUnit) + if not theUnit then return end + if not theUnit:isExist() then return end + + local group = theUnit:getGroup() + local id = group:getID() + local conf = csarManager.getUnitConfig(theUnit) + conf.id = id + conf.unit = theUnit + + csarManager.removeCommsFromConfig(conf) +end + + +function csarManager.setCommsMenu(theUnit) + if not theUnit then return end + if not theUnit:isExist() then return end + + -- we only add this menu to helicopter troop carriers + -- will also filter out all non-helicopters as nice side effect + if not dcsCommon.isTroopCarrier(theUnit) then return end + + local group = theUnit:getGroup() + local id = group:getID() + local conf = csarManager.getUnitConfig(theUnit) -- will allocate if new. This is important since a group event can call this as well + conf.id = id; -- we do this ALWAYS to it is current even after a crash + conf.unit = theUnit -- link back + + -- reset all coms now + csarManager.removeCommsFromConfig(conf) + + -- ok, first, if we don't have an F-10 menu, create one + conf.myMainMenu = missionCommands.addSubMenuForGroup(id, 'CSAR Missions') + + -- now we have a menu without submenus. + -- add our own submenus + local commandTxt = "List active CSAR requests" + local theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + conf.myMainMenu, + csarManager.redirectListCSARRequests, + {conf, "hi there"} + ) + table.insert(conf.myCommands, theCommand) + commandTxt = "Status of rescued crew aboard" + theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + conf.myMainMenu, + csarManager.redirectStatusCarrying, + {conf, "hi there"} + ) + table.insert(conf.myCommands, theCommand) + + commandTxt = "Unload one evacuee here (rescue later)" + theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + conf.myMainMenu, + csarManager.redirectUnloadOne, + {conf, "unload one"} + ) + table.insert(conf.myCommands, theCommand) +end + + +function csarManager.redirectListCSARRequests(args) + timer.scheduleFunction(csarManager.doListCSARRequests, args, timer.getTime() + 0.1) +end + +function csarManager.doListCSARRequests(args) + local conf = args[1] + local param = args[2] + local theUnit = conf.unit + local point = theUnit:getPoint() + + --trigger.action.outText("+++csar: ".. theUnit:getName() .." issued csar status request", 30) + local report = "\nCrews requesting evacuation\n" + if #csarManager.openMissions < 1 then + report = report .. "\nNo requests, all crew are safe." + else + -- iterate through all troops onboard to get their status + for idx, mission in pairs(csarManager.openMissions) do + local d = dcsCommon.distFlat(point, mission.zone.point) * 0.000539957 + d = math.floor(d * 10) / 10 + local b = dcsCommon.bearingInDegreesFromAtoB(point, mission.zone.point) + local status = "alive" + report = report .. "\n".. mission.name .. ", bearing " .. b .. ", " ..d .."nm, " .. " ADF " .. mission.freq .. "0 kHz - " .. status + end + end + + if #csarManager.csarBases < 1 then + report = report .. "\n\nWARNING: NO CSAR BASES TO DELIVER EVACUEES" + end + + report = report .. "\n" + + trigger.action.outTextForGroup(conf.id, report, 30) + trigger.action.outSoundForGroup(conf.id, "Quest Snare 3.wav") +end + +function csarManager.redirectStatusCarrying(args) + timer.scheduleFunction(csarManager.doStatusCarrying, args, timer.getTime() + 0.1) +end + +function csarManager.doStatusCarrying(args) + local conf = args[1] + local param = args[2] + local theUnit = conf.unit + + --trigger.action.outText("+++csar: ".. theUnit:getName() .." wants to know how their rescued troops are doing", 30) + + -- build status report + local report = "\nCrew Rescue Status:\n" + if #conf.troopsOnBoard < 1 then + report = report .. "\nWe have no evacuees on board" + else + -- iterate through all troops onboard to get their status + for i=1, #conf.troopsOnBoard do + local evacMission = conf.troopsOnBoard[i] + report = report .. "\n".. i .. ") " .. evacMission.name + report = report .. " is stable" -- or 'beat up, but will live' + end + + report = report .. "\n\nTotal added weigth: " .. 10 + #conf.troopsOnBoard * csarManager.pilotWeight .. "kg" + end + + + report = report .. "\n" + + trigger.action.outTextForGroup(conf.id, report, 30) + trigger.action.outSoundForGroup(conf.id, "Quest Snare 3.wav") +end + +function csarManager.redirectUnloadOne(args) + timer.scheduleFunction(csarManager.unloadOne, args, timer.getTime() + 0.1) +end + +function csarManager.unloadOne(args) + local conf = args[1] + local param = args[2] + local theUnit = conf.unit + local myName = theUnit:getName() + + local report = "NYI: unload one" + + if theUnit:inAir() then + report = "STRONGLY recommend we land first, Sir!" + trigger.action.outTextForGroup(conf.id, report, 30) + trigger.action.outSoundForGroup(conf.id, "Quest Snare 3.wav") + return + end + + if #conf.troopsOnBoard < 1 then + report = "No evacuees on board." + trigger.action.outTextForGroup(conf.id, report, 30) + trigger.action.outSoundForGroup(conf.id, "Quest Snare 3.wav") + + else + -- simulate a crash but for one unit + local theSide = theUnit:getCoalition() + -- this is where we can create a new CSAR mission + i= #conf.troopsOnBoard + local msn = conf.troopsOnBoard[i] -- picked up unit(s) + local theRescuedPilot = msn.name + -- create a new missions in 50m radius + csarManager.createCSARforUnit(theUnit, theRescuedPilot, 50, true) + conf.troopsOnBoard[i] = nil -- remove this mission + trigger.action.outTextForCoalition(theSide, myName .. " has aborted evacuating " .. msn.name .. ". New CSAR available.", 30) + trigger.action.outSoundForCoalition(theSide, "Quest Snare 3.wav") + + -- recalc weight + trigger.action.setUnitInternalCargo(myName, 10 + #conf.troopsOnBoard * csarManager.pilotWeight) -- 10 kg as empty + per-unit time people + end + +end +-- +-- Player event callbacks +-- +function csarManager.playerChangeEvent(evType, description, player, data) + if evType == "newGroup" then + local theUnit = data.primeUnit + if not dcsCommon.isTroopCarrier(theUnit) then return end + + csarManager.setCommsMenu(theUnit) -- allocates new config +-- trigger.action.outText("+++csar: added " .. theUnit:getName() .. " to comms menu", 30) + return + end + + if evType == "removeGroup" then +-- trigger.action.outText("+++csar: a group disappeared", 30) + local conf = csarManager.getConfigForUnitNamed(data.primeUnitName) + if conf then + csarManager.removeCommsFromConfig(conf) + end + return + end + + if evType == "leave" then + local conf = csarManager.getConfigForUnitNamed(player.unitName) + if conf then + csarManager.resetConfig(conf) + end + end + + if evType == "unit" then + -- player changed units. almost never in MP, but possible in solo + -- because of 1 seconds timing loop + -- will result in a new group appearing and a group disappearing, so we are good, + -- except we need to reset the conf so no troops are carried any longer + local conf = csarManager.getConfigForUnitNamed(data.oldUnitName) + if conf then + csarManager.resetConfig(conf) + end + end +end + +-- +-- CSAR Bases +-- +-- properties: +-- - zone : the zone +-- - side : coalition +-- - name : name of base, can be overriden with property + +function csarManager.addCSARBase(aZone) + local csarBase = {} + csarBase.zone = aZone + local bName = cfxZones.getStringFromZoneProperty(aZone, "CSARBASE", "XXX") + if bName == "XXX" then bName = aZone.name end + csarBase.name = cfxZones.getStringFromZoneProperty(aZone, "name", bName) + + -- read further properties like facilities that may + -- need to be matched + csarBase.side = cfxZones.getCoalitionFromZoneProperty(aZone, "coalition", 0) + + table.insert(csarManager.csarBases, csarBase) +-- trigger.action.outText("+++csar: found base " .. csarBase.name .. " for side " .. csarBase.side, 30) +end + +function csarManager.getCSARBaseforZone(aZone) + for idx, aCsarBase in pairs(csarManager.csarBases) do + if aCsarBase.zone == aZone then + return aCsarBase + end + end + return nil +end + +-- +-- +-- U P D A T E +-- =========== +-- +-- + + +-- +-- updateCSARMissions: make sure evacuees are still alive +-- +function csarManager.updateCSARMissions() + local newMissions = {} + for idx, aMission in pairs (csarManager.openMissions) do + local stillAlive = dcsCommon.isGroupAlive(aMission.group) + -- now check if a timer was running to rescue this group + -- if dead, set stillAlive to false + if stillAlive then + table.insert(newMissions, aMission) + else + local msg = aMission.name .. " confirmed KIA, repeat KIA. Abort CSAR." + trigger.action.outTextForCoalition(aMission.side, msg, 30) + trigger.action.outSoundForCoalition(aMission.side, "Quest Snare 3.wav") + end + end + csarManager.openMissions = newMissions -- this is the new batch +end + +function csarManager.update() -- every second + -- schedule next invocation + timer.scheduleFunction(csarManager.update, {}, timer.getTime() + 1) + + -- first, check the health of all csar misions and update the table of live units + csarManager.updateCSARMissions() + + -- now scan through all helo groups and see if they are close to a + -- CSAR zone and initiate the help sequence + local allPlayerGroups = cfxPlayerGroups -- cfxPlayerGroups is a global, don't fuck with it! + -- contains per group a player record, use prime unit to access player's unit + for gname, pgroup in pairs(allPlayerGroups) do + local aUnit = pgroup.primeUnit -- get prime unit of that group + if aUnit:isExist() and aUnit:inAir() then -- exists and is flying + local uPoint = aUnit:getPoint() + local uName = aUnit:getName() + local uGroup = aUnit:getGroup() + local uID = uGroup:getID() + local uSide = aUnit:getCoalition() + local agl = dcsCommon.getUnitAGL(aUnit) + if dcsCommon.isTroopCarrier(aUnit) then + -- scan through all available csar missions to see if we are close + -- enough to trigger comms + for idx, csarMission in pairs (csarManager.openMissions) do + -- check if we are inside trigger range on the same side + local d = dcsCommon.distFlat(uPoint, csarMission.zone.point) + if (uSide == csarMission.side) and (d < csarManager.rescueTriggerRange) then + -- we are in trigger distance. if we did not notify before + -- do it now + if not dcsCommon.arrayContainsString(csarMission.messagedUnits, uName) then + -- radio this unit with oclock and tell it they are in 2k range + -- also note if LZ is hot + local ownHeading = dcsCommon.getUnitHeadingDegrees(aUnit) + local oclock = dcsCommon.clockPositionOfARelativeToB(csarMission.zone.point, uPoint, ownHeading) .. " o'clock" + local msg = "\n" .. uName ..", " .. csarMission.name .. ". We can hear you, check your " .. oclock + if csarManager.useSmoke then msg = msg .. " - popping smoke" end + msg = msg .. "." + if csarMission.isHot then + msg = msg .. " Be advised: LZ is hot." + end + msg = msg .. "\n" + trigger.action.outTextForGroup(uID, msg, 30) + trigger.action.outSoundForGroup(uID, "Quest Snare 3.wav") + table.insert(csarMission.messagedUnits, uName) -- remember that we messaged them so we don't do again + end + -- also pop smoke if not popped already, or more than 3 minutes ago + if csarManager.useSmoke and timer.getTime() - csarMission.lastSmokeTime > 179 then + local smokePoint = cfxZones.createHeightCorrectedPoint(csarMission.zone.point) + trigger.action.smoke(smokePoint, 4 ) + end + + -- now check if we are inside hover range and alt + -- in order to simultate winch ops + -- WARNING: WE ALWAYS ONLY CHECK A SINGLE UNIT - the first alive + local evacuee = csarMission.group:getUnit(1) + if evacuee then + ep = evacuee:getPoint() + d = dcsCommon.distFlat(uPoint, ep) + d = math.floor(d * 10) / 10 + if d < csarManager.hoverRadius * 2 then + local ownHeading = dcsCommon.getUnitHeadingDegrees(aUnit) + local oclock = dcsCommon.clockPositionOfARelativeToB(ep, uPoint, ownHeading) .. " o'clock" + -- log distance + local hoverMsg = "Closing on " .. csarMission.name .. ", " .. d * 3 .. "ft on your " .. oclock .. " o'clock" + + if d < csarManager.hoverRadius then + if (agl <= csarManager.hoverAlt) and (agl > 3) then + local hoverTime = csarMission.hoveringUnits[uName] + if not hoverTime then + -- create new entry + hoverTime = timer.getTime() + csarMission.hoveringUnits[uName] = timer.getTime() + end + hoverTime = timer.getTime() - hoverTime -- calculate number of seconds + remainder = math.floor(csarManager.hoverDuration - hoverTime) + if remainder < 1 then remainder = 1 end + hoverMsg = "Steady... " .. d * 3 .. "ft to your " .. oclock .. " o'clock, winching... (" .. remainder .. ")" + if hoverTime > csarManager.hoverDuration then + -- we rescued the guy! + hoverMsg = "We have " .. csarMission.name .. " safely on board!" + local conf = csarManager.getUnitConfig(aUnit) + csarManager.removeMission(csarMission) + table.insert(conf.troopsOnBoard, csarMission) + csarMission.group:destroy() -- will shut up radio as well + csarMission.group = nil + trigger.action.outTextForGroup(uID, hoverMsg, 30, true) + trigger.action.outSoundForGroup(uID, "Quest Snare 3.wav") + + return -- we only ever rescue one + end -- hovered long enough + trigger.action.outTextForGroup(uID, hoverMsg, 30, true) + return -- only ever one winch op + else -- too high for hover + hoverMsg = "Evacuee " .. d * 3 .. "ft on your " .. oclock .. " o'clock; land or descend to between 10 and 90 AGL for winching" + csarMission.hoveringUnits[uName] = nil -- reset timer + end + else -- not inside hover dist + -- remove the hover indicator for this + csarMission.hoveringUnits[uName] = nil + end + trigger.action.outTextForGroup(uID, hoverMsg, 30, true) + return -- only ever one winch op + else + -- remove the hover indicator for this unit + csarMission.hoveringUnits[uName] = nil + end -- inside 2 * hover dist? + + end -- has evacuee + end -- if in range + end -- for all missions + end -- if troop carrier + end -- if exists + end -- for all players +end + +-- +-- create a CSAR Mission for a unit +-- +function csarManager.createCSARforUnit(theUnit, pilotName, radius, silent) + if not silent then silent = false end + if not radius then radius = 1000 end + if not pilotName then pilotName = "Eddie" end + + local point = theUnit:getPoint() + local coal = theUnit:getCoalition() + + local csarPoint = dcsCommon.randomPointInCircle(radius, radius/2, point.x, point.z) + + -- check the ground- water will kill the pilot + csarPoint.y = csarPoint.z + local surf = land.getSurfaceType(csarPoint) + + if surf == 2 or surf == 3 then + if not silent then + trigger.action.outTextForCoalition(coal, "Bad chute! Bad chute! ".. pilotName .. " did not survive ejection out of their " .. theUnit:getTypeName(), 30) + trigger.action.outSoundForGroup(coal, "Quest Snare 3.wav") + end + return + end + + csarPoint.y = land.getHeight(csarPoint) + + -- when we get here, the terrain is ok, so let's drop the pilot + local theMission = csarManager.createCSARMissionData( + csarPoint, + coal, + nil, + pilotName, + 1, + nil, + nil) + csarManager.addMission(theMission) + if not silent then + trigger.action.outTextForCoalition(coal, "MAYDAY MAYDAY MAYDAY! ".. pilotName .. " in " .. theUnit:getTypeName() .. " ejected, report good chute. Prepare CSAR!", 30) + trigger.action.outSoundForGroup(coal, "Quest Snare 3.wav") + end +end + + + +-- +-- Init & Start +-- + +function csarManager.processCSARBASE() + local csarBases = cfxZones.zonesWithProperty("CSARBASE") + + -- now add all zones to my zones table, and init additional info + -- from properties + for k, aZone in pairs(csarBases) do + csarManager.addCSARBase(aZone) + end +end + +function csarManager.processCASRZones() + local csarBases = cfxZones.zonesWithProperty("CSAR") + + -- now add all zones to my zones table, and init additional info + -- from properties + for k, aZone in pairs(csarBases) do + -- gather data, and then create a mission from this + local theSide = cfxZones.getCoalitionFromZoneProperty(aZone, "coalition", 0) + local name = cfxZones.getZoneProperty(aZone, "name") + local freq = cfxZones.getNumberFromZoneProperty(aZone, "freq", 0) + if freq == 0 then freq = nil end + local numCrew = 1 + local mapMarker = nil + local timeLimit = cfxZones.getNumberFromZoneProperty(aZone, "timeLimit", 0) + if timeLimit == 0 then timeLimit = nil else timeLimit = timeLimit * 60 end + + local theMission = csarManager.createCSARMissionData(aZone.point, + theSide, + freq, + name, + numCrew, + timeLimit, + mapMarker) + csarManager.addMission(theMission) + end +end + + +function csarManager.invokeCallbacks(theCoalition, success, numRescued, notes) + -- invoke anyone who wants to know that a group + -- of people was rescued. + for idx, cb in pairs(csarManager.csarCompleteCB) do + cb(theCoalition, success, numRescued, notes) + end +end + +function csarManager.installCallback(theCB) + table.insert(csarManager.csarCompleteCB, theCB) +end + + +function csarManager.start() + -- make sure we have loaded all relevant libraries + if not dcsCommon.libCheck("cfx CSAR", csarManager.requiredLibs) then + trigger.action.outText("cf/x CSAR aborted: missing libraries", 30) + return false + end + + -- install callbacks for helo-relevant events + dcsCommon.addEventHandler(csarManager.somethingHappened, csarManager.preProcessor, csarManager.postProcessor) + + -- now iterate through all player groups and install the CSAR Menu + + local allPlayerGroups = cfxPlayerGroups -- cfxPlayerGroups is a global, don't fuck with it! + -- contains per group a player record, use prime unit to access player's unit + for gname, pgroup in pairs(allPlayerGroups) do + local aUnit = pgroup.primeUnit -- get prime unit of that group + csarManager.setCommsMenu(aUnit) + end + -- now install the new group notifier for new groups so we can remove and add CSAR menus + cfxPlayer.addMonitor(csarManager.playerChangeEvent) + + -- now scan all zones that are CSAR drop-off for quick access + csarManager.processCSARBASE() + + -- now scan all zones to create ME-placed CSAR missions + -- and populate the available mission. + csarManager.processCASRZones() + + -- now call update so we can monitor progress of all helos, and alert them + -- when they are close to a CSAR + csarManager.update() + + -- say hi! + trigger.action.outText("cf/x CSAR v" .. csarManager.version .. " started", 30) + return true +end + +-- let's get rolling +if not csarManager.start() then + csarManager = nil +end + + +--[[-- + improvements + - need to stay on ground for x seconds to load troops + - hot lz + - hover recover + - limit on troops aboard for transport + - delay for drop-off + + - csar when: always, only on eject, + + - repair o'clock + + - nearest csarBase + - red/blue csarbases + - weight + + - compatibility: side/owner - make sure it is compatible + with FARP, and landing on a FARP with opposition ownership + will not disembark + +--]]-- \ No newline at end of file diff --git a/modules/dcsCommon.lua b/modules/dcsCommon.lua new file mode 100644 index 0000000..ae8f686 --- /dev/null +++ b/modules/dcsCommon.lua @@ -0,0 +1,2063 @@ +dcsCommon = {} +dcsCommon.version = "2.5.1" +--[[-- VERSION HISTORY + 2.2.6 - compassPositionOfARelativeToB + - clockPositionOfARelativeToB + 2.2.7 - isTroopCarrier + - distFlat + 2.2.8 - fixed event2text + 2.2.9 - getUnitAGL + - getUnitAlt + - getUnitSpeed + - getUnitHeading + - getUnitHeadingDegrees + - mag + - clockPositionOfARelativeToB with own heading + 2.3.0 - unitIsInfantry + 2.3.1 - bool2YesNo + - bool2Text + 2.3.2 - getGroupAvgSpeed + - getGroupMaxSpeed + 2.3.3 - getSizeOfTable + 2.3.4 - isSceneryObject + coalition2county + 2.3.5 - smallRandom + pickRandom uses smallRandom + airfield handling, parking + flight waypoint handling + landing waypoint creation + take-off waypoint creation + 2.3.6 - createOverheadAirdromeRoutPintData(aerodrome) + 2.3.7 - coalition2county - warning when creating UN + 2.3.8 - improved headingOfBInDegrees, new getClockDirection + 2.3.9 - getClosingVelocity + - dot product + - magSquare + - vMag + 2.4.0 - libCheck + 2.4.1 - grid/square/rect formation + - arrangeGroupInNColumns formation + - 2Columns formation deep and wide formation + 2.4.2 - getAirbasesInRangeOfPoint + 2.4.3 - lerp + 2.4.4 - getClosestAirbaseTo + - fixed bug in containsString when strings equal + 2.4.5 - added cargo and mass options to createStaticObjectData + 2.4.6 - fixed randompercent + 2.4.7 - smokeColor2Num(smokeColor) + 2.4.8 - linkStaticDataToUnit() + 2.4.9 - trim functions + - createGroundUnitData uses trim function to remove leading/trailing blanks + so now we can use blanks after comma to separate types + - dcsCommon.trimArray( + - createStaticObjectData uses trim for type + - getEnemyCoalitionFor understands strings, still returns number + - coalition2county also undertsands 'red' and 'blue' + 2.5.0 - "Line" formation with one unit places unit at center + 2.5.1 - vNorm(a) + 2.5.1 - added SA-18 Igla manpad to unitIsInfantry() +--]]-- + + -- dcsCommon is a library of common lua functions + -- for easy access and simple mission programming + -- (c) 2021, 2022 by Chritian Franz and cf/x AG + + dcsCommon.verbose = false -- set to true to see debug messages. Lots of them + dcsCommon.uuidStr = "uuid-" + + -- globals + dcsCommon.cbID = 0 -- callback id for simple callback scheduling + dcsCommon.troopCarriers = {"Mi-8MT", "UH-1H", "Mi-24P"} -- Ka-50 and Gazelle can't carry troops + + -- verify that a module is loaded. obviously not required + -- for dcsCommon, but all higher-order modules + function dcsCommon.libCheck(testingFor, requiredLibs) + local canRun = true + for idx, libName in pairs(requiredLibs) do + if not _G[libName] then + trigger.action.outText("*** " .. testingFor .. " requires " .. libName, 30) + canRun = false + end + end + return canRun + end + + -- taken inspiration from mist, as dcs lua has issues with + -- random numbers smaller than 50. Given a range of x numbers 1..x, it is + -- repeated a number of times until it fills an array of at least + -- 50 items (usually some more), and only then one itemis picked from + -- that array with a random number that is from a greater range (0..50+) + function dcsCommon.smallRandom(theNum) -- adapted from mist, only support ints + if theNum >= 50 then return math.random(theNum) end + + -- for small randoms (<50) + local lowNum, highNum + highNum = theNum + lowNum = 1 + local total = 1 + if math.abs(highNum - lowNum + 1) < 50 then -- if total values is less than 50 + total = math.modf(50/math.abs(highNum - lowNum + 1)) -- number of times to repeat whole range to get above 50. e.g. 11 would be 5 times 1 .. 11, giving us 55 items total + end + local choices = {} + for i = 1, total do -- iterate required number of times + for x = lowNum, highNum do -- iterate between the range + choices[#choices +1] = x -- add each entry to a table + end + end + local rtnVal; -- = math.random(#choices) -- will now do a math.random of at least 50 choices + for i = 1, 15 do + rtnVal = math.random(#choices) -- iterate 15 times for randomization + end + return choices[rtnVal] -- return indexed + end + + + function dcsCommon.getSizeOfTable(theTable) + local count = 0 + for _ in pairs(theTable) do count = count + 1 end + return count + end + + function dcsCommon.findAndRemoveFromTable(theTable, theElement) -- assumes array + if not theElement then return false end + if not theTable then return false end + for i=1, #theTable do + if theTable[i] == theElement then + -- this element found. remove from table + table.remove(theTable, i) + return true + end + end + end + + function dcsCommon.pickRandom(theTable) + if not theTable then + trigger.action.outText("*** warning: nil table in pick random", 30) + end + + if #theTable < 1 then + trigger.action.outText("*** warning: zero choice in pick random", 30) + local k = i.ll + return nil + end + if #theTable == 1 then return theTable[1] end + r = dcsCommon.smallRandom(#theTable) --r = math.random(#theTable) + return theTable[r] + end + + -- enumerateTable - make an array out of a table for indexed access + function dcsCommon.enumerateTable(theTable) + if not theTable then theTable = {} end + local array = {} + for key, value in pairs(theTable) do + table.insert(array, value) + end + return array + end + +-- +-- A I R F I E L D S A N D F A R P S +-- + + -- airfield management + function dcsCommon.getAirbaseCat(aBase) + if not aBase then return nil end + + local airDesc = aBase:getDesc() + if not airDesc then return nil end + + local airCat = airDesc.category + return airCat + end + + -- get free parking slot. optional parkingType can be used to + -- filter for a scpecific type, e.g. 104 = open field + function dcsCommon.getFirstFreeParkingSlot(aerodrome, parkingType) + if not aerodrome then return nil end + local freeSlots = aerodrome:getParking(true) + + for idx, theSlot in pairs(freeSlots) do + if not parkingType then + -- simply return the first we come across + return theSlot + end + + if theSlot.Term_Type == parkingType then + return theSlot + end + end + + return nil + end + + -- getAirbasesInRangeOfPoint: get airbases that are in range of point + function dcsCommon.getAirbasesInRangeOfPoint(center, range, filterCat, filterCoalition) + if not center then return {} end + if not range then range = 500 end -- 500m default + local basesInRange = {} + + local allAB = dcsCommon.getAirbasesWhoseNameContains("*", filterCat, filterCoalition) + for idx, aBase in pairs(allAB) do + local delta = dcsCommon.dist(center, aBase:getPoint()) + if delta <= range then + table.insert(basesInRange, aBase) + end + end + return basesInRange + end + + -- getAirbasesInRangeOfAirbase returns all airbases that + -- are in range of the given airbase + function dcsCommon.getAirbasesInRangeOfAirbase(airbase, includeCenter, range, filterCat, filterCoalition) + if not airbase then return {} end + if not range then range = 150000 end + local center = airbase:getPoint() + local centerName = airbase:getName() + + local ABinRange = {} + local allAB = dcsCommon.getAirbasesWhoseNameContains("*", filterCat, filterCoalition) + + for idx, aBase in pairs(allAB) do + if aBase:getName() ~= centerName then + local delta = dcsCommon.dist(center, aBase:getPoint()) + if delta <= range then + table.insert(ABinRange, aBase) + end + end + end + + if includeCenter then + table.insert(ABinRange, airbase) + end + + return ABinRange + end + + function dcsCommon.getAirbasesInRangeOfAirbaseList(theCenterList, includeList, range, filterCat, filterCoalition) + local collectorDict = {} + for idx, aCenter in pairs(theCenterList) do + -- get all surrounding airbases. returns list of airfields + local surroundingAB = dcsCommon.getAirbasesInRangeOfAirbase(airbase, includeList, range, filterCat, filterCoalition) + + for idx2, theAirField in pairs (surroundingAB) do + collectorDict[airField] = theAirField + end + end + + -- make result an array + local theABList = dcsCommon.enumerateTable(collectorDict) + return theABList + end + + -- getAirbasesWhoseNameContains - get all airbases containing + -- a name. filterCat is optional and can be aerodrome (0), farp (1), ship (2) + -- filterCoalition is optional and can be 0 (neutral), 1 (red), 2 (blue) + -- if no name given or aName = "*", then all bases are returned prior to filtering + function dcsCommon.getAirbasesWhoseNameContains(aName, filterCat, filterCoalition) + --trigger.action.outText("getAB(name): enter with " .. aName, 30) + if not aName then aName = "*" end + local allYourBase = world.getAirbases() -- get em all + local areBelongToUs = {} + -- now iterate all bases + for idx, aBase in pairs(allYourBase) do + local airBaseName = aBase:getName() -- get display name + if aName == "*" or dcsCommon.containsString(airBaseName, aName) then + -- containsString is case insesitive unless told otherwise + --if aName ~= "*" then + -- trigger.action.outText("getAB(name): matched " .. airBaseName, 30) + --end + local doAdd = true + if filterCat then + -- make sure the airbase is of that category + local airCat = dcsCommon.getAirbaseCat(aBase) + doAdd = doAdd and airCat == filterCat + end + + if filterCoalition then + doAdd = doAdd and filterCoalition == aBase:getCoalition() + end + + if doAdd then + -- all good, add to table + table.insert(areBelongToUs, aBase) + end + end + end + return areBelongToUs + end + + function dcsCommon.getFirstAirbaseWhoseNameContains(aName, filterCat, filterCoalition) + local allBases = dcsCommon.getAirbasesWhoseNameContains(aName, filterCat, filterCoalition) + for idx, aBase in pairs (allBases) do + -- simply return first + return aBase + end + return nil + end + + function dcsCommon.getClosestAirbaseTo(thePoint, filterCat, filterCoalition) + local delta = math.huge + local allYourBase = dcsCommon.getAirbasesWhoseNameContains("*", filterCat, filterCoalition) -- get em all and filter + local closestBase = nil + for idx, aBase in pairs(allYourBase) do + -- iterate them all + local abPoint = aBase:getPoint() + newDelta = dcsCommon.dist(thePoint, {x=abPoint.x, y = 0, z=abPoint.z}) + if newDelta < delta then + delta = newDelta + closestBase = aBase + end + end + return closestBase, delta + end + +-- +-- U N I T S M A N A G E M E N T +-- + + -- number of living units in group + function dcsCommon.livingUnitsInGroup(group) + local living = 0 + local allUnits = group:getUnits() + for key, aUnit in pairs(allUnits) do + if aUnit:isExist() and aUnit:getLife() >= 1 then + living = living + 1 + end + end + return living + end + + -- closest living unit in group to a point + function dcsCommon.getClosestLivingUnitToPoint(group, p) + if not p then return nil end + if not group then return nil end + local closestUnit = nil + local closestDist = math.huge + local allUnits = group:getUnits() + for key, aUnit in pairs(allUnits) do + if aUnit:isExist() and aUnit:getLife() >= 1 then + local thisDist = dcsCommon.dist(p, aUnit:getPoint()) + if thisDist < closestDist then + closestDist = thisDist + closestUnit = aUnit + end + end + end + return closestUnit, closestDist + end + + -- closest living group to a point - cat can be nil or one of Group.Category = { AIRPLANE = 0, HELICOPTER = 1, GROUND = 2, SHIP = 3, TRAIN = 4} + function dcsCommon.getClosestLivingGroupToPoint(p, coal, cat) + if not cat then cat = 2 end -- ground is default + local closestGroup = nil; + local closestGroupDist = math.huge + local allGroups = coalition.getGroups(coal, cat) -- get all groups from this coalition, perhaps filtered by cat + for key, grp in pairs(allGroups) do + local closestUnit, dist = dcsCommon.getClosestLivingUnitToPoint(grp, p) + if closestUnit then + if dist < closestGroupDist then + closestGroup = grp + closestGroupDist = dist + end + end + end + return closestGroup, closestGroupDist + end + + function dcsCommon.getLivingGroupsAndDistInRangeToPoint(p, range, coal, cat) + if not cat then cat = 2 end -- ground is default + local groupsInRange = {}; + local allGroups = coalition.getGroups(coal, cat) -- get all groups from this coalition, perhaps filtered by cat + for key, grp in pairs(allGroups) do + local closestUnit, dist = dcsCommon.getClosestLivingUnitToPoint(grp, p) + if closestUnit then + if dist < range then + table.insert(groupsInRange, {group = grp, dist = dist}) -- array + end + end + end + -- sort the groups by distance + table.sort(groupsInRange, function (left, right) return left.dist < right.dist end ) + return groupsInRange + end + + -- distFlat ignores y, input must be xyz points, NOT xy points + function dcsCommon.distFlat(p1, p2) + local point1 = {x = p1.x, y = 0, z=p1.z} + local point2 = {x = p2.x, y = 0, z=p2.z} + return dcsCommon.dist(point1, point2) + end + + -- distance between points + function dcsCommon.dist(point1, point2) -- returns distance between two points + -- supports xyz and xy notations + if not point1 then + trigger.action.outText("+++ warning: nil point1 in common:dist", 30) + point1 = {x=0, y=0, z=0} + end + + if not point2 then + trigger.action.outText("+++ warning: nil point2 in common:dist", 30) + point2 = {x=0, y=0, z=0} + stop.here.now = 1 + end + + local p1 = {x = point1.x, y = point1.y} + if not point1.z then + p1.z = p1.y + p1.y = 0 + else + p1.z = point1.z + end + + local p2 = {x = point2.x, y = point2.y} + if not point2.z then + p2.z = p2.y + p2.y = 0 + else + p2.z = point2.z + end + + local x = p1.x - p2.x + local y = p1.y - p2.y + local z = p1.z - p2.z + + return (x*x + y*y + z*z)^0.5 + end + + function dcsCommon.delta(name1, name2) -- returns distance (in meters) of two named objects + local n1Pos = Unit.getByName(name1):getPosition().p + local n2Pos = Unit.getByName(name2):getPosition().p + return dcsCommon.dist(n1Pos, n2Pos) + end + + -- lerp between a and b, x being 0..1 (percentage), clipped to [0..1] + function dcsCommon.lerp(a, b, x) + if not a then return 0 end + if not b then return 0 end + if not x then return a end + if x < 0 then x = 0 end + if x > 1 then x = 1 end + return a + (b - a ) * x + end + + function dcsCommon.bearingFromAtoB(A, B) -- coords in x, z + dx = B.x - A.x + dz = B.z - A.z + bearing = math.atan2(dz, dx) -- in radiants + return bearing + end + + function dcsCommon.bearingInDegreesFromAtoB(A, B) + local bearing = dcsCommon.bearingFromAtoB(A, B) -- in rads + bearing = math.floor(bearing / math.pi * 180) + if bearing < 0 then bearing = bearing + 360 end + if bearing > 360 then bearing = bearing - 360 end + return bearing + end + + function dcsCommon.compassPositionOfARelativeToB(A, B) + -- warning: is REVERSE in order for bearing, returns a string like 'Sorth', 'Southwest' + if not A then return "***error:A***" end + if not B then return "***error:B***" end + local bearing = dcsCommon.bearingInDegreesFromAtoB(B, A) -- returns 0..360 + if bearing < 23 then return "North" end + if bearing < 68 then return "NE" end + if bearing < 112 then return "East" end + if bearing < 158 then return "SE" end + if bearing < 202 then return "South" end + if bearing < 248 then return "SW" end + if bearing < 292 then return "West" end + if bearing < 338 then return "NW" end + return "North" + end + + function dcsCommon.clockPositionOfARelativeToB(A, B, headingOfBInDegrees) + -- o'clock notation + if not A then return "***error:A***" end + if not B then return "***error:B***" end + if not headingOfBInDegrees then headingOfBInDegrees = 0 end + + local bearing = dcsCommon.bearingInDegreesFromAtoB(B, A) -- returns 0..360 +-- trigger.action.outText("+++comm: oclock - bearing = " .. bearing .. " and inHeading = " .. headingOfBInDegrees, 30) + bearing = bearing - headingOfBInDegrees + return dcsCommon.getClockDirection(bearing) + + end + + -- given a heading, return clock with 0 being 12, 180 being 6 etc. + function dcsCommon.getClockDirection(direction) -- inspired by cws, improvements my own + if not direction then return 0 end + direction = math.fmod (direction, 360) + while direction < 0 do + direction = direction + 360 + end + + if direction < 15 then -- special case 12 o'clock past 12 o'clock + return 12 + end + + direction = direction + 15 -- add offset so we get all other times correct + return math.floor(direction/30) + + end + + + function dcsCommon.randomDegrees() + local degrees = math.random(360) * 3.14152 / 180 + return degrees + end + + function dcsCommon.randomPercent() + local percent = math.random(100)/100 + return percent + end + + function dcsCommon.randomPointOnPerimeter(sourceRadius, x, z) + return dcsCommon.randomPointInCircle(sourceRadius, sourceRadius-1, x, z) + end + + function dcsCommon.randomPointInCircle(sourceRadius, innerRadius, x, z) + if not x then x = 0 end + --local y = 0 + if not innerRadius then innerRadius = 0 end + if innerRadius < 0 then innerRadius = 0 end + + local percent = dcsCommon.randomPercent() -- 1 / math.random(100) + -- now lets get a random degree + local degrees = dcsCommon.randomDegrees() -- math.random(360) * 3.14152 / 180 -- ok, it's actually radiants. + local r = (sourceRadius-innerRadius) * percent + local x = x + (innerRadius + r) * math.cos(degrees) + local z = z + (innerRadius + r) * math.sin(degrees) + + local thePoint = {} + thePoint.x = x + thePoint.y = 0 + thePoint.z = z + + return thePoint, degrees + end + + -- get group location: get the group's location by + -- accessing the fist existing, alive member of the group that it finds + function dcsCommon.getGroupLocation(group) + -- nifty trick from mist: make this work with group and group name + if type(group) == 'string' then -- group name + group = Group.getByName(group) + end + + -- get all units + local allUnits = group:getUnits() + + -- iterate through all members of group until one is alive and exists + for index, theUnit in pairs(allUnits) do + if (theUnit:isExist() and theUnit:getLife() > 0) then + return theUnit:getPosition().p + end; + end + + -- if we get here, there was no live unit + --trigger.action.outText("+++cmn: A group has no live units. returning nil", 10) + return nil + + end + + -- get the group's first Unit that exists and is + -- alive + function dcsCommon.getGroupUnit(group) + if not group then return nil end + + -- nifty trick from mist: make this work with group and group name + if type(group) == 'string' then -- group name + group = Group.getByName(group) + end + + if not group:isExist() then return nil end + + -- get all units + local allUnits = group:getUnits() + + -- iterate through all members of group until one is alive and exists + for index, theUnit in pairs(allUnits) do + if (theUnit:isExist() and theUnit:getLife() > 0) then + return theUnit + end; + end + + -- if we get here, there was no live unit + --trigger.action.outText("+++cmn A group has no live units. returning nil", 10) + return nil + + end + + -- and here the alias + function dcsCommon.getFirstLivingUnit(group) + return dcsCommon.getGroupUnit(group) + end + + -- isGroupAlive returns true if there is at least one unit in the group that isn't dead + function dcsCommon.isGroupAlive(group) + return (dcsCommon.getGroupUnit(group) ~= nil) + end + + function dcsCommon.getLiveGroupUnits(group) + -- nifty trick from mist: make this work with group and group name + if type(group) == 'string' then -- group name + group = Group.getByName(group) + end + + local liveUnits = {} + -- get all units + local allUnits = group:getUnits() + + -- iterate through all members of group until one is alive and exists + for index, theUnit in pairs(allUnits) do + if (theUnit:isExist() and theUnit:getLife() > 0) then + table.insert(liveUnits, theUnit) + end; + end + + -- if we get here, there was no live unit + return liveUnits + end + + function dcsCommon.getGroupTypeString(group) -- convert into comma separated types + if not group then + trigger.action.outText("+++cmn getGroupTypeString: nil group", 30) + return "" + end + if not dcsCommon.isGroupAlive(group) then + trigger.action.outText("+++cmn getGroupTypeString: dead group", 30) + return "" + end + local theTypes = "" + local liveUnits = dcsCommon.getLiveGroupUnits(group) + for i=1, #liveUnits do + if i > 1 then theTypes = theTypes .. "," end + theTypes = theTypes .. liveUnits[i]:getTypeName() + end + return theTypes + end + + function dcsCommon.getGroupTypes(group) + if not group then + trigger.action.outText("+++cmn getGroupTypes: nil group", 30) + return {} + end + if not dcsCommon.isGroupAlive(group) then + trigger.action.outText("+++cmn getGroupTypes: dead group", 30) + return {} + end + local liveUnits = dcsCommon.getLiveGroupUnits(group) + local unitTypes = {} + for i=1, #liveUnits do + table.insert(unitTypes, liveUnits[i]:getTypeName()) + end + return unitTypes + end + + function dcsCommon.getEnemyCoalitionFor(aCoalition) + if aCoalition == 1 then return 2 end + if aCoalition == 2 then return 1 end + if type(aCoalition) == "string" then + aCoalition = aCoalition:lower() + if aCoalition == "red" then return 2 end + if aCoalition == "blue" then return 1 end + end + return nil + end + + function dcsCommon.getACountryForCoalition(aCoalition) + -- scan the table of countries and get the first country that is part of aCoalition + -- this is useful if you want to create troops for a coalition but don't know the + -- coalition's countries + -- we start with id=0 (Russia), go to id=85 (Slovenia), but skip id = 14 + local i = 0 + while i < 86 do + if i ~= 14 then + if (coalition.getCountryCoalition(i) == aCoalition) then return i end + end + i = i + 1 + end + + return nil + end +-- +-- +-- C A L L B A C K H A N D L E R +-- +-- + + -- installing callbacks + -- based on mist, with optional additional hooks for pre- and post- + -- processing of the event + -- when filtering occurs in pre, an alternative 'rejected' handler can be called + function dcsCommon.addEventHandler(f, pre, post, rejected) -- returns ID + local handler = {} -- build a wrapper and connect the onEvent + --dcsCommon.cbID = dcsCommon.cbID + 1 -- increment unique count + handler.id = dcsCommon.uuid("eventHandler") + handler.f = f -- the callback itself + if (rejected) then handler.rejected = rejected end + -- now set up pre- and post-processors. defaults are set in place + -- so pre and post are optional. If pre returns false, the callback will + -- not be invoked + if (pre) then handler.pre = pre else handler.pre = dcsCommon.preCall end + if (post) then handler.post = post else handler.post = dcsCommon.postCall end + function handler:onEvent(event) + if not self.pre(event) then + if dcsCommon.verbose then +-- trigger.action.outText("event " .. event.id .. " discarded by pre-processor", 10) + end + if (self.rejected) then self.rejected(event) end + return + end + self.f(event) -- call the handler + self.post(event) -- do post-processing + end + world.addEventHandler(handler) + return handler.id + end + + function dcsCommon.preCall(e) + -- we can filter here + -- if we return false, the call is abortet + if dcsCommon.verbose then + trigger.action.outText("event " .. e.id .. " received: PRE-PROCESSING", 10) + end + return true; + end; + + function dcsCommon.postCall(e) + -- we do pos proccing here + if dcsCommon.verbose then + trigger.action.outText("event " .. e.id .. " received: post proc", 10) + end + end + + -- highly specific eventhandler for one event only + -- based on above, with direct filtering built in; skips pre + -- but does post + function dcsCommon.addEventHandlerForEventTypes(f, evTypes, post, rejected) -- returns ID + local handler = {} -- build a wrapper and connect the onEvent + dcsCommon.cbID = dcsCommon.cbID + 1 -- increment unique count + handler.id = dcsCommon.cbID + handler.what = evTypes + if (rejected) then handler.rejected = rejected end + + handler.f = f -- set the callback itself + -- now set up post-processor. pre is hard-coded to match evType + -- post is optional. If event.id is not in evTypes, the callback will + -- not be invoked + if (post) then handler.post = post else handler.post = dcsCommon.postCall end + function handler:onEvent(event) + hasMatch = false; + for key, evType in pairs(self.what) do + if evType == event.id then + hasMatch = true; + break; + end; + end; + if not hasMatch then + if dcsCommon.verbose then + trigger.action.outText("event " .. e.id .. " discarded - not in whitelist evTypes", 10) + end + if (self.rejected) then self.rejected(event) end + return; + end; + + self.f(event) -- call the actual handler as passed to us + self.post(event) -- do post-processing + end + world.addEventHandler(handler) -- add to event handlers + return handler.id + end + + + + -- remove event handler / callback, identical to Mist + -- note we don't call world.removeEventHandler, but rather directly + -- access world.eventHandlers directly and remove kvp directly. + function dcsCommon.removeEventHandler(id) + for key, handler in pairs(world.eventHandlers) do + if handler.id and handler.id == id then + world.eventHandlers[key] = nil + return true + end + end + return false + end + +-- +-- +-- C L O N I N G +-- +-- + -- topClone is a shallow clone of orig, only top level is iterated, + -- all values are ref-copied + function dcsCommon.topClone(orig) + local orig_type = type(orig) + local copy + if orig_type == 'table' then + copy = {} + for orig_key, orig_value in pairs(orig) do + copy[orig_key] = orig_value + end + else -- number, string, boolean, etc + copy = orig + end + return copy + end + + -- clone is a recursive clone which will also clone + -- deeper levels, as used in units + function dcsCommon.clone(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[dcsCommon.clone(orig_key)] = dcsCommon.clone(orig_value) + end + setmetatable(copy, dcsCommon.clone(getmetatable(orig))) + else -- number, string, boolean, etc + copy = orig + end + return copy + end + + +-- +-- +-- S P A W N I N G +-- +-- + + function dcsCommon.createEmptyGroundGroupData (name) + local theGroup = {} -- empty group + theGroup.visible = false + theGroup.taskSelected = true + -- theGroup.route = {} + -- theGroup.groupId = id + theGroup.tasks = {} + -- theGroup.hidden = false -- hidden on f10? + + theGroup.units = { } -- insert units here! -- use addUnitToGroupData + + theGroup.x = 0 + theGroup.y = 0 + theGroup.name = name + -- theGroup.start_time = 0 + theGroup.task = "Ground Nothing" + + return theGroup + end; + + function dcsCommon.createEmptyAircraftGroupData (name) + local theGroup = dcsCommon.createEmptyGroundGroupData(name)--{} -- empty group + + theGroup.task = "Nothing" -- can be others, like Transport, CAS, etc + -- returns with empty route + theGroup.route = dcsCommon.createEmptyAircraftRouteData() -- we can add points here + return theGroup + end; + + function dcsCommon.createAircraftRoutePointData(x, z, altitudeInFeet, knots, altType, action) + local rp = {} + rp.x = x + rp.y = z + rp.action = "Turning Point" + rp.type = "Turning Point" + if action then rp.action = action; rp.type = action end -- warning: may not be correct, need to verify later + rp.alt = altitudeInFeet * 0.3048 + rp.speed = knots * 0.514444 -- we use + rp.alt_type = "BARO" + if (altType) then rp.alt_type = altType end + return rp + end + + function dcsCommon.addRoutePointDataToRouteData(inRoute, x, z, altitudeInFeet, knots, altType, action) + local p = dcsCommon.createAircraftRoutePointData(x, z, altitudeInFeet, knots, altType, action) + local thePoints = inRoute.points + table.insert(thePoints, p) + end + + function dcsCommon.addRoutePointDataToGroupData(group, x, z, altitudeInFeet, knots, altType, action) + if not group.route then group.route = dcsCommon.createEmptyAircraftRouteData() end + local theRoute = group.route + dcsCommon.addRoutePointDataToRouteData(theRoute, x, z, altitudeInFeet, knots, altType, action) + end + + function dcsCommon.addRoutePointForGroupData(theGroup, theRP) + if not theGroup then return end + if not theGroup.route then theGroup.route = dcsCommon.createEmptyAircraftRouteData() end + + local theRoute = theGroup.route + local thePoints = theRoute.points + table.insert(thePoints, theRP) + end + + function dcsCommon.createEmptyAircraftRouteData() + local route = {} + route.points = {} + return route + end + + function dcsCommon.createTakeOffFromParkingRoutePointData(aerodrome) + if not aerodrome then return nil end + + local rp = {} + local freeParkingSlot = dcsCommon.getFirstFreeParkingSlot(aerodrome, 104) -- get big slot first + if not freeParkingSlot then + freeParkingSlot = dcsCommon.getFirstFreeParkingSlot(aerodrome) -- try any size + end + + if not freeParkingSlot then + trigger.action.outText("civA: no free parking at " .. aerodrome:getName(), 30) + return nil + end + + local p = freeParkingSlot.vTerminalPos + + rp.airdromeId = aerodrome:getID() + rp.x = p.x + rp.y = p.z + rp.alt = p.y + rp.action = "From Parking Area" + rp.type = "TakeOffParking" + + rp.speed = 100; -- in m/s? If so, that's 360 km/h + rp.alt_type = "BARO" + return rp + end + + function dcsCommon.createOverheadAirdromeRoutPintData(aerodrome) + if not aerodrome then return nil end + local rp = {} + local p = aerodrome:getPoint() + rp.x = p.x + rp.y = p.z + rp.alt = p.y + 2000 -- 6000 ft overhead + rp.action = "Turning Point" + rp.type = "Turning Point" + + rp.speed = 133; -- in m/s? If so, that's 360 km/h + rp.alt_type = "BARO" + return rp + end + + + function dcsCommon.createLandAtAerodromeRoutePointData(aerodrome) + if not aerodrome then return nil end + + local rp = {} + local p = aerodrome:getPoint() + rp.airdromeId = aerodrome:getID() + rp.x = p.x + rp.y = p.z + rp.alt = p.y + rp.action = "Landing" + rp.type = "Land" + + rp.speed = 100; -- in m/s? If so, that's 360 km/h + rp.alt_type = "BARO" + return rp + end + + + function dcsCommon.createRPFormationData(findex) -- must be added as "task" to an RP. use 4 for Echelon right + local task = {} + task.id = "ComboTask" + local params = {} + task.params = params + local tasks = {} + params.tasks = tasks + local t1 = {} + tasks[1] = t1 + t1.number = 1 + t1.auto = false + t1.id = "WrappedAction" + t1.enabled = true + local t1p = {} + t1.params = t1p + local action = {} + t1p.action = action + action.id = "Option" + local ap = {} + action.params = ap + ap.variantIndex = 3 + ap.name = 5 -- AI.Option.Air.ID 5 = Formation + ap.formationIndex = findex -- 4 is echelon_right + ap.value = 262147 + + return task + end + + function dcsCommon.addTaskDataToRP(theTask, theGroup, rpIndex) + local theRoute = theGroup.route + local thePoints = theRoute.points + local rp = thePoints[rpIndex] + rp.task = theTask + end + + -- create a minimal payload table that is compatible with creating + -- a unit. you may need to alter this before adding the unit to + -- the mission. all params optional + function dcsCommon.createPayload(fuel, flare, chaff, gun) + local payload = {} + payload.pylons = {} + if not fuel then fuel = 1000 end -- in kg. check against fuelMassMax in type desc + if not flare then flare = 0 end + if not chaff then chaff = 0 end + if not gun then gun = 0 end + return payload + + end + + function dcsCommon.createCallsign(cs) + local callsign = {} + callsign[1] = 1 + callsign[2] = 1 + callsign[3] = 1 + if not cs then cs = "Enfield11" end + callsign.name = cs + return callsign + end + + + -- create the data table required to spawn a unit. + -- unit types are defined in https://github.com/mrSkortch/DCS-miscScripts/tree/master/ObjectDB + function dcsCommon.createGroundUnitData(name, unitType, transportable) + local theUnit = {} + unitType = dcsCommon.trim(unitType) + theUnit.type = unitType -- e.g. "LAV-25", + if not transportable then transportable = false end -- elaborate, not requried code + theUnit.transportable = {["randomTransportable"] = transportable} + -- theUnit.unitId = id + theUnit.skill = "Average" -- always average + theUnit.x = 0 -- make it zero, zero! + theUnit.y = 0 + theUnit.name = name + theUnit.playerCanDrive = false + theUnit.heading = 0 + return theUnit + end + + function dcsCommon.createAircraftUnitData(name, unitType, transportable, altitude, speed, heading) + local theAirUnit = dcsCommon.createGroundUnitData(name, unitType, transportable) + theAirUnit.alt = 100 -- make it 100m + if altitude then theAirUnit.alt = altitude end + theAirUnit.alt_type = "RADIO" -- AGL + theAirUnit.speed = 77 -- m/s --> 150 knots + if speed then theAirUnit.speed = speed end + if heading then theAirUnit.heading = heading end + theAirUnit.payload = dcsCommon.createPayload() + theAirUnit.callsign = dcsCommon.createCallsign() + return theAirUnit + end + + + function dcsCommon.addUnitToGroupData(theUnit, theGroup, dx, dy, heading) + -- add a unit to a group, and place it at dx, dy of group's position, + -- taking into account unit's own current location + if not dx then dx = 0 end + if not dy then dy = 0 end + if not heading then heading = 0 end + theUnit.x = theUnit.x + dx + theGroup.x + theUnit.y = theUnit.y + dy + theGroup.y + theUnit.heading = heading + table.insert(theGroup.units, theUnit) + end; + + function dcsCommon.createSingleUnitGroup(name, theUnitType, x, z, heading) + -- create the container + local theNewGroup = dcsCommon.createEmptyGroundGroupData(name) + local aUnit = {} + aUnit = dcsCommon.createGroundUnitData(name .. "-1", theUnitType, false) +-- trigger.action.outText("dcsCommon - unit name retval " .. aUnit.name, 30) + dcsCommon.addUnitToGroupData(aUnit, theNewGroup, x, z, heading) + return theNewGroup + end + + + function dcsCommon.arrangeGroupDataIntoFormation(theNewGroup, radius, minDist, formation, innerRadius) + -- formations: + -- (default) "line" (left to right along x) -- that is Y direction + -- "line_v" a line top to bottom -- that is X direction + -- "chevron" - left to right middle too top + -- "scattered", "random" -- random, innerRadius used to clear area in center + -- "circle", "circle_forward" -- circle, forward facing + -- "circle_in" -- circle, inwarf facing + -- "circle_out" -- circle, outward facing + -- "grid", "square", "rect" -- optimal rectangle + -- "2cols", "2deep" -- 2 columns, n deep + -- "2wide" -- 2 columns wide, 2 deep + + local num = #theNewGroup.units + + -- now do the formation stuff + -- make sure that they keep minimum distance +-- trigger.action.outText("dcsCommon - processing formation " .. formation .. " with radius = " .. radius, 30) + if formation == "LINE_V" then + -- top to bottom in zone (heding 0). -- will run through x-coordinate + -- use entire radius top to bottom + local currX = -radius + local increment = radius * 2/(num - 1) -- MUST NOT TRY WITH 1 UNIT! + for i=1, num do + + local u = theNewGroup.units[i] +-- trigger.action.outText("formation unit " .. u.name .. " currX = " .. currX, 30) + u.x = currX + currX = currX + increment + end + + elseif formation == "LINE" then + -- left to right in zone. runs through Y + -- left and right are y because at heading 0, forward is x (not y as expected) + -- if only one, place in middle of circle and be done + if num == 1 then + -- nothing. just stay in the middle + else + local currY = -radius + local increment = radius * 2/(num - 1) -- MUST NOT TRY WITH 1 UNIT! + for i=1, num do + local u = theNewGroup.units[i] +-- trigger.action.outText("formation unit " .. u.name .. " currX = " .. currY, 30) + u.y = currY + currY = currY + increment + end + end + + elseif formation == "CHEVRON" then + -- left to right in zone. runs through Y + -- left and right are y because at heading 0, forward is x (not y as expected) + local currY = -radius + local currX = 0 + local incrementY = radius * 2/(num - 1) -- MUST NOT TRY WITH 1 UNIT! + local incrementX = radius * 2/(num - 1) -- MUST NOT TRY WITH 1 UNIT! + for i=1, num do + local u = theNewGroup.units[i] +-- trigger.action.outText("formation unit " .. u.name .. " currX = " .. currX .. " currY = " .. currY, 30) + u.x = currX + u.y = currY + -- calc coords for NEXT iteration + currY = currY + incrementY -- march left to right + if i < num / 2 then -- march up + currX = currX + incrementX + elseif i == num / 2 then -- even number, keep height + currX = currX + 0 + else + currX = currX - incrementX -- march down + end + -- note: when unit number even, the wedge is sloped. may need an odd/even test for better looks + end + + elseif formation == "SCATTERED" or formation == "RANDOM" then + -- use randomPointInCircle and tehn iterate over all vehicles for mindelta + processedUnits = {} + for i=1, num do + local emergencyBreak = 1 -- prevent endless loop + local lowDist = 10000 + local uPoint = {} + local thePoint = {} + repeat -- get random point intil mindistance to all is kept or emergencybreak + for idx, rUnit in pairs(processedUnits) do -- get min dist to all positioned units + thePoint = dcsCommon.randomPointInCircle(radius, innerRadius) -- returns x, 0, z + uPoint.x = rUnit.x + uPoint.y = 0 + uPoint.z = rUnit.y + local dist = dcsCommon.dist(thePoint, uPoint) -- measure distance to unit + if (dist < lowDist) then lowDist = dist end + end + emergencyBreak = emergencyBreak + 1 + until (emergencyBreak > 20) or (lowDist > minDist) + -- we have random x, y + local u = theNewGroup.units[i] -- get unit to position + u.x = thePoint.x + u.y = thePoint.z -- z --> y mapping! + -- now add the unit to the 'processed' set + table.insert(processedUnits, u) + end + + elseif dcsCommon.stringStartsWith(formation, "CIRCLE") then + -- units are arranged on perimeter of circle defined by radius +-- trigger.action.outText("formation circle detected", 30) + local currAngle = 0 + local angleInc = 2 * 3.14157 / num -- increase per spoke + for i=1, num do + local u = theNewGroup.units[i] -- get unit + u.x = radius * math.cos(currAngle) + u.y = radius * math.sin(currAngle) + + -- now baldower out heading + -- circle, circle_forward no modifier of heading + if dcsCommon.stringStartsWith(formation, "CIRCLE_IN") then + -- make the heading inward faceing - that's angle + pi + u.heading = u.heading + currAngle + 3.14157 + elseif dcsCommon.stringStartsWith(formation, "CIRCLE_OUT") then + u.heading = u.heading + currAngle + 0 + end + + currAngle = currAngle + angleInc + end + elseif formation == "GRID" or formation == "SQUARE" or formation == "RECT" then + if num < 2 then return end + -- arrange units in an w x h grid + -- e-g- 12 units = 4 x 3. + -- calculate w + local w = math.floor(num^(0.5) + 0.5) + dcsCommon.arrangeGroupInNColumns(theNewGroup, w, radius) + --[[-- + local h = math.floor(num / w) + --trigger.action.outText("AdcsC: num=" .. num .. " w=" .. w .. "h=" .. h .. " -- num%w=" .. num%w, 30) + if (num % w) > 0 then + h = h + 1 + end + + --trigger.action.outText("BdcsC: num=" .. num .. " w=" .. w .. "h=" .. h, 30) + + -- now w * h always >= num and num items fir in that grid + -- w is width, h is height, of course :) + -- now calculat xInc and yInc + local i = 1 + local xInc = 0 + if w > 1 then xInc = 2 * radius / (w-1) end + local yInc = 0 + if h > 1 then yInc = 2 * radius / (h-1) end + local currY = radius + if h < 2 then currY = 0 end -- special:_ place in Y middle if only one row) + while h > 0 do + local currX = radius + local wCnt = w + while wCnt > 0 and (i <= num) do + local u = theNewGroup.units[i] -- get unit + u.x = currX + u.y = currY + currX = currX - xInc + wCnt = wCnt - 1 + i = i + 1 + end + currY = currY - yInc + h = h - 1 + end + --]]-- + elseif formation == "2DEEP" or formation == "2COLS" then + if num < 2 then return end + -- arrange units in an 2 x h grid + local w = 2 + dcsCommon.arrangeGroupInNColumnsDeep(theNewGroup, w, radius) + + elseif formation == "2WIDE" then + if num < 2 then return end + -- arrange units in an 2 x h grid + local w = 2 + dcsCommon.arrangeGroupInNColumns(theNewGroup, w, radius) + else + trigger.action.outText("dcsCommon - unknown formation: " .. formation, 30) + end + + end + + function dcsCommon.arrangeGroupInNColumns(theNewGroup, w, radius) + local num = #theNewGroup.units + local h = math.floor(num / w) + if (num % w) > 0 then + h = h + 1 + end + local i = 1 + local xInc = 0 + if w > 1 then xInc = 2 * radius / (w-1) end + local yInc = 0 + if h > 1 then yInc = 2 * radius / (h-1) end + local currY = radius + if h < 2 then currY = 0 end -- special:_ place in Y middle if only one row) + while h > 0 do + local currX = radius + local wCnt = w + while wCnt > 0 and (i <= num) do + local u = theNewGroup.units[i] -- get unit + u.x = currX + u.y = currY + currX = currX - xInc + wCnt = wCnt - 1 + i = i + 1 + end + currY = currY - yInc + h = h - 1 + end + end + + function dcsCommon.arrangeGroupInNColumnsDeep(theNewGroup, w, radius) + local num = #theNewGroup.units + local h = math.floor(num / w) + if (num % w) > 0 then + h = h + 1 + end + local i = 1 + local yInc = 0 + if w > 1 then yInc = 2 * radius / (w-1) end + local xInc = 0 + if h > 1 then xInc = 2 * radius / (h-1) end + local currX = radius + if h < 2 then currX = 0 end -- special:_ place in Y middle if only one row) + while h > 0 do + local currY = radius + local wCnt = w + while wCnt > 0 and (i <= num) do + local u = theNewGroup.units[i] -- get unit + u.x = currX + u.y = currY + currY = currY - yInc + wCnt = wCnt - 1 + i = i + 1 + end + currX = currX - xInc + h = h - 1 + end + end + + + function dcsCommon.createGroundGroupWithUnits(name, theUnitTypes, radius, minDist, formation, innerRadius) + if not minDist then mindist = 4 end -- meters + if not formation then formation = "line" end + if not radius then radius = 30 end -- meters + if not innerRadius then innerRadius = 0 end + formation = formation:upper() + -- theUnitTypes can be either a single string or a table of strings + -- see here for TypeName https://github.com/mrSkortch/DCS-miscScripts/tree/master/ObjectDB + -- formation defines how the units are going to be arranged in the + -- formation specified. + -- formations: + -- (default) "line" (left to right along x) -- that is Y direction + -- "line_V" a line top to bottom -- that is X direction + -- "chevron" - left to right middle too top + -- "scattered", "random" -- random, innerRadius used to clear area in center + -- "circle", "circle_forward" -- circle, forward facing + -- "circle_in" -- circle, inwarf facing + -- "circle_out" -- circle, outward facing + + -- first, we create a group + local theNewGroup = dcsCommon.createEmptyGroundGroupData(name) + + -- now add a single unit or multiple units + if type(theUnitTypes) ~= "table" then +-- trigger.action.outText("dcsCommon - i am here", 30) +-- trigger.action.outText("dcsCommon - name " .. name, 30) +-- trigger.action.outText("dcsCommon - unit type " .. theUnitTypes, 30) + + local aUnit = {} + aUnit = dcsCommon.createGroundUnitData(name .. "-1", theUnitTypes, false) +-- trigger.action.outText("dcsCommon - unit name retval " .. aUnit.name, 30) + dcsCommon.addUnitToGroupData(aUnit, theNewGroup, 0, 0) -- create with data at location (0,0) + return theNewGroup + end + + -- if we get here, theUnitTypes is a table + -- now loop and create a unit for each table + local num = 1 + for key, theType in pairs(theUnitTypes) do +-- trigger.action.outText("creating unit " .. name .. "-" .. num, 30) + local aUnit = dcsCommon.createGroundUnitData(name .. "-"..num, theType, false) + dcsCommon.addUnitToGroupData(aUnit, theNewGroup, 0, 0) + num = num + 1 + end + + dcsCommon.arrangeGroupDataIntoFormation(theNewGroup, radius, minDist, formation, innerRadius) + return theNewGroup + + + end + +-- create a new group, based on group in mission. Groups coords are 0,0 for group and all +-- x,y and heading + function dcsCommon.createGroupDataFromLiveGroup(name, newName) + if not newName then newName = dcsCommon.uuid("uniqName") end + -- get access to the group + local liveGroup = Group.getByName(name) + if not liveGroup then return nil end + -- get the categorty + local cat = liveGroup:getCategory() + local theNewGroup = {} + + -- create a new empty group at (0,0) + if cat == Group.Category.AIRPLANE or cat == Group.Category.HELICOPTER then + theNewGroup = dcsCommon.createEmptyAircraftGroupData(newName) + elseif cat == Group.Category.GROUND then + theNewGroup = dcsCommon.createEmptyGroudGroupData(newName) + else + trigger.action.outText("dcsCommon - unknown category: " .. cat, 30) + return nil + end + + + -- now get all units from live group and create data units + -- note that unit data for group has x=0, y=0 + liveUnits = liveGroup:getUnits() + + for index, theUnit in pairs(liveUnits) do + -- for each unit we get the desc + local desc = theUnit:getDesc() -- of interest is only typename + local newUnit = dcsCommon.createGroundUnitData(dcsCommon.uuid(newName), + desc.typeName, + false) + -- we now basically have a ground unit at (0,0) + -- add mandatory fields by type + if cat == Group.Category.AIRPLANE or cat == Group.Category.HELICOPTER then + newUnit.alt = 100 -- make it 100m + newUnit.alt_type = "RADIO" -- AGL + newUnit.speed = 77 -- m/s --> 150 knots + newUnit.payload = dcsCommon.createPayload() -- empty payload + newUnit.callsign = dcsCommon.createCallsign() -- 'enfield11' + + elseif cat == Group.Category.GROUND then + -- we got all we need + else + -- trigger.action.outText("dcsCommon - unknown category: " .. cat, 30) + -- return nil + -- we also got all we need + end + + end + + end; + + + function dcsCommon.rotatePointAroundOrigin(inX, inY, angle) -- angle in degrees + local degrees = 3.14152 / 180 -- ok, it's actually radiants. + angle = angle * degrees -- turns into rads + local c = math.cos(angle) + local s = math.sin(angle) + local px + local py + px = inX * c - inY * s + py = inX * s + inY * c + return px, py + end + + function dcsCommon.rotateGroupData(theGroup, degrees, cx, cz) + if not cx then cx = 0 end + if not cy then cy = 0 end + local rads = degrees * 3.14152 / 180 + -- turns all units in group around the group's center by degrees. + -- may also need to turn individual units by same amount + for i, theUnit in pairs (theGroup.units) do + theUnit.x = theUnit.x - cx -- MOVE TO ORIGIN OF ROTATION + theUnit.y = theUnit.y - cy + theUnit.x, theUnit.y = dcsCommon.rotatePointAroundOrigin(theUnit.x, theUnit.y, degrees) + theUnit.x = theUnit.x + cx -- MOVE BACK + theUnit.y = theUnit.y + cy + + -- may also want to increase heading by degreess + theUnit.heading = theUnit.heading + rads + end + end + + function dcsCommon.offsetGroupData(theGroup, dx, dy) + -- add dx and dy to group's and all unit's coords + for i, theUnit in pairs (theGroup.units) do + theUnit.x = theUnit.x + dx + theUnit.y = theUnit.y + dy + end + + theGroup.x = theGroup.x + dx + theGroup.y = theGroup.y + dy + end + + function dcsCommon.moveGroupDataTo(theGroup, xAbs, yAbs) + local dx = xAbs-theGroup.x + local dy = yAbs-theGroup.y + dcsCommon.offsetGroupData(theGroup, dx, dy) + end + + -- static objectr shapes and types are defined here + -- https://github.com/mrSkortch/DCS-miscScripts/tree/master/ObjectDB/Statics + + function dcsCommon.createStaticObjectData(name, objType, heading, dead, cargo, mass) + local staticObj = {} + if not heading then heading = 0 end + if not dead then dead = false end + if not cargo then cargo = false end + objType = dcsCommon.trim(objType) + + staticObj.heading = 0 + -- staticObj.groupId = 0 + -- staticObj.shape_name = shape -- e.g. H-Windsock_RW + staticObj.type = objType -- e.g. Windsock + -- ["unitId"] = 3, + staticObj.rate = 1 -- score when killed + staticObj.name = name + -- staticObj.category = "Fortifications", + staticObj.y = 0 + staticObj.x = 0 + staticObj.dead = dead + staticObj.canCargo = cargo -- to cargo + if cargo then + if not mass then mass = 1234 end + staticObj.mass = mass -- to cargo + end + return staticObj + end + + function dcsCommon.createStaticObjectDataAt(loc, name, objType, heading, dead) + local theData = dcsCommon.createStaticObjectData(name, objType, heading, dead) + theData.x = loc.x + theData.y = loc.z + return theData + end + + function dcsCommon.createStaticObjectForCoalitionAtLocation(theCoalition, loc, name, objType, heading, dead) + if not heading then heading = math.random(360) * 3.1415 / 180 end + local theData = dcsCommon.createStaticObjectDataAt(loc, name, objType, heading, dead) + local theStatic = coalition.addStaticObject(theCoalition, theData) + return theStatic + end + + function dcsCommon.createStaticObjectForCoalitionInRandomRing(theCoalition, objType, x, z, innerRadius, outerRadius, heading, alive) + if not outerRadius then outerRadius = innerRadius end + if not heading then heading = math.random(360) * 3.1415 / 180 end + local dead = not alive + local p = dcsCommon.randomPointInCircle(outerRadius, innerRadius, x, z) + local theData = dcsCommon.createStaticObjectData(dcsCommon.uuid("static"), objType, heading, dead) + theData.x = p.x + theData.y = p.z + + local theStatic = coalition.addStaticObject(theCoalition, theData) + return theStatic + end + + + + function dcsCommon.linkStaticDataToUnit(theStatic, theUnit, dx, dy, heading) + if not theStatic then + trigger.action.OutText("+++dcsC: NIL theStatic on linkStatic!", 30) + return + end + -- NOTE: we may get current heading and subtract/add + -- to original heading + local rotX, rotY = dcsCommon.rotatePointAroundOrigin(dx, dy, -heading) + + if not theUnit then return end + if not theUnit:isExist() then return end + theStatic.linkOffset = true + theStatic.linkUnit = theUnit:getID() + local unitPos = theUnit:getPoint() + local offsets = {} + offsets.x = rotX + offsets.y = rotY + offsets.angle = 0 + theStatic.offsets = offsets + end + + function dcsCommon.offsetStaticData(theStatic, dx, dy) + theStatic.x = theStatic.x + dx + theStatic.y = theStatic.y + dy + -- now check if thre is a route (for linked objects) + if theStatic.route then + -- access points[1] x and y and copy from main + theStatic.route.points[1].x = theStatic.x + theStatic.route.points[1].y = theStatic.y + end + end + + function dcsCommon.moveStaticDataTo(theStatic, x, y) + theStatic.x = x + theStatic.y = y + -- now check if thre is a route (for linked objects) + if theStatic.route then + -- access points[1] x and y and copy from main + theStatic.route.points[1].x = theStatic.x + theStatic.route.points[1].y = theStatic.y + end + + end + +-- +-- +-- M I S C M E T H O D S +-- +-- + + function dcsCommon.arrayContainsString(theArray, theString) + if not theArray then return false end + if not theString then return false end + for i = 1, #theArray do + if theArray[i] == theString then return true end + end + return false + end + + function dcsCommon.splitString(inputstr, sep) + if sep == nil then + sep = "%s" + end + if inputstr == nil then + inputstr = "" + end + + local t={} + for str in string.gmatch(inputstr, "([^"..sep.."]+)") do + table.insert(t, str) + end + return t + + end + + function dcsCommon.trimFront(inputstr) + if not inputstr then return nil end + local s = inputstr + while string.len(s) > 1 and string.sub(s, 1, 1) == " " do + local snew = string.sub(s, 2) -- all except first + s = snew + end + return s + end + + function dcsCommon.trimBack(inputstr) + if not inputstr then return nil end + local s = inputstr + while string.len(s) > 1 and string.sub(s, -1) == " " do + local snew = string.sub(s, 1, -2) -- all except last + s = snew + end + return s + end + + function dcsCommon.trim(inputstr) + local t1 = dcsCommon.trimFront(inputstr) + local t2 = dcsCommon.trimBack(t1) + return t2 + end + + function dcsCommon.trimArray(theArray) + local trimmedArray = {} + for idx, element in pairs(theArray) do + local tel = dcsCommon.trim(element) + table.insert(trimmedArray, tel) + end + return trimmedArray + end + + -- same as cfxZones, this is the commonly used, may need to remove from zones + function dcsCommon.stringStartsWith(theString, thePrefix) + return theString:find(thePrefix) == 1 + end + + function dcsCommon.removePrefix(theString, thePrefix) + if not dcsCommon.stringStartsWith(theString, thePrefix) then + return theString + end; + return theString:sub(1 + #thePrefix) + end + + function dcsCommon.stringEndsWith(theString, theEnding) + return theEnding == "" or str:sub(-#theEnding) == theEnding + end + + function dcsCommon.removeEnding(theString, theEnding) + if not dcsCommon.stringEndsWith(theString, theEnding) then + return theString + end + return theString:sub(1, #theString - #theEnding) + end + + function dcsCommon.containsString(inString, what, caseSensitive) + if (not caseSensitive) then + inString = string.upper(inString) + what = string.upper(what) + end + if inString == what then return true end -- when entire match + return string.find(inString, what) + end + + function dcsCommon.bool2Text(theBool) + if not theBool then theBool = false end + if theBool then return "true" end + return "false" + end + + function dcsCommon.bool2YesNo(theBool) + if not theBool then theBool = false end + if theBool then return "yes" end + return "no" + end + + -- recursively show the contents of a variable + function dcsCommon.dumpVar(key, value, prefix, inrecursion) + if not inrecursion then + -- output a marker to find in the log / screen + env.info("*** dcsCommon vardump START") + end + if not value then value = "nil" end + if not prefix then prefix = "" end + prefix = " " .. prefix + if type(value) == "table" then + env.info(prefix .. key .. ": [ ") + -- iterate through all kvp + for k,v in pairs (value) do + dcsCommon.dumpVar(k, v, prefix, true) + end + env.info(prefix .. " ] - end " .. key) + + elseif type(value) == "boolean" then + local b = "false" + if value then b = "true" end + env.info(prefix .. key .. ": " .. b) + + else -- simple var, show contents, ends recursion + env.info(prefix .. key .. ": " .. value) + end + + if not inrecursion then + -- output a marker to find in the log / screen + trigger.action.outText("=== dcsCommon vardump END", 30) + env.info("=== dcsCommon vardump END") + end + end + + function dcsCommon.dumpVar2Str(key, value, prefix, inrecursion) + if not inrecursion then + -- output a marker to find in the log / screen + trigger.action.outText("*** dcsCommon vardump START",30) + end + if not value then value = "nil" end + if not prefix then prefix = "" end + prefix = " " .. prefix + if type(value) == "table" then + trigger.action.outText(prefix .. key .. ": [ ", 30) + -- iterate through all kvp + for k,v in pairs (value) do + dcsCommon.dumpVar2Str(k, v, prefix, true) + end + trigger.action.outText(prefix .. " ] - end " .. key, 30) + + elseif type(value) == "boolean" then + local b = "false" + if value then b = "true" end + trigger.action.outText(prefix .. key .. ": " .. b, 30) + + else -- simple var, show contents, ends recursion + trigger.action.outText(prefix .. key .. ": " .. value, 30) + end + + if not inrecursion then + -- output a marker to find in the log / screen + trigger.action.outText("=== dcsCommon vardump END", 30) + --env.info("=== dcsCommon vardump END") + end + end + + + dcsCommon.simpleUUID = 76543 -- a number to start. as good as any + function dcsCommon.numberUUID() + dcsCommon.simpleUUID = dcsCommon.simpleUUID + 1 + return dcsCommon.simpleUUID + end + + function dcsCommon.uuid(prefix) + dcsCommon.uuIdent = dcsCommon.uuIdent + 1 + if not prefix then prefix = dcsCommon.uuidStr end + return prefix .. "-" .. dcsCommon.uuIdent + end + + function dcsCommon.event2text(id) + if not id then return "error" end + if id == 0 then return "invalid" end + -- translate the event id to text + local events = {"shot", "hit", "takeoff", "land", + "crash", "eject", "refuel", "dead", + "pilot dead", "base captured", "mission start", "mission end", -- 12 + "took control", "refuel stop", "birth", "human failure", + "det. failure", "engine start", "engine stop", "player enter unit", + "player leave unit", "player comment", "start shoot", "end shoot", + "mark add", "mark changed", "makr removed", "kill", + "score", "unit lost", "land after eject", "Paratrooper land", + "chair discard after eject", "weapon add", "trigger zone", "landing quality mark", + "BDA", "max"} + if id > #events then return "Unknown (ID=" .. id .. ")" end + return events[id] + end + + function dcsCommon.smokeColor2Text(smokeColor) + if (smokeColor == 0) then return "Green" end + if (smokeColor == 1) then return "Red" end + if (smokeColor == 2) then return "White" end + if (smokeColor == 3) then return "Orange" end + if (smokeColor == 4) then return "Blue" end + + return ("unknown: " .. smokeColor) + end + + function dcsCommon.smokeColor2Num(smokeColor) + if not smokeColor then smokeColor = "green" end + if type(smokeColor) ~= "string" then return 0 end + smokeColor = smokeColor:lower() + if (smokeColor == "green") then return 0 end + if (smokeColor == "red") then return 1 end + if (smokeColor == "white") then return 2 end + if (smokeColor == "orange") then return 3 end + if (smokeColor == "blue") then return 4 end + return 0 + end + + function dcsCommon.markPointWithSmoke(p, smokeColor) + local x = p.x + local z = p.z -- do NOT change the point directly + -- height-correct + local y = land.getHeight({x = x, y = z}) + local newPoint= {x = x, y = y + 2, z = z} + trigger.action.smoke(newPoint, smokeColor) + end + +-- +-- +-- V E C T O R M A T H +-- +-- +function dcsCommon.vAdd(a, b) + local r = {} + if not a then a = {x = 0, y = 0, z = 0} end + if not b then b = {x = 0, y = 0, z = 0} end + r.x = a.x + b.x + r.y = a.y + b.y + r.z = a.z + b.z + return r +end + +function dcsCommon.vSub(a, b) + local r = {} + if not a then a = {x = 0, y = 0, z = 0} end + if not b then b = {x = 0, y = 0, z = 0} end + r.x = a.x - b.x + r.y = a.y - b.y + r.z = a.z - b.z + return r +end + +function dcsCommon.vMultScalar(a, f) + local r = {} + if not a then a = {x = 0, y = 0, z = 0} end + if not f then f = 0 end + r.x = a.x * f + r.y = a.y * f + r.z = a.z * f + return r +end + +function dcsCommon.vLerp (a, b, t) + if not a then a = {x = 0, y = 0, z = 0} end + if not b then b = {x = 0, y = 0, z = 0} end + + local d = dcsCommon.vSub(b, a) + local dt = dcsCommon.vMultScalar(d, t) + local r = dcsCommon.vAdd(a, dt) + return r +end + +function dcsCommon.mag(x, y, z) + if not x then x = 0 end + if not y then y = 0 end + if not z then z = 0 end + + return (x * x + y * y + z * z)^0.5 +end + +function dcsCommon.vMag(a) + if not a then return 0 end + if not a.x then a.x = 0 end + if not a.y then a.y = 0 end + if not a.z then a.z = 0 end + return dcsCommon.mag(a.x, a.y, a.z) +end + +function dcsCommon.magSquare(x, y, z) + if not x then x = 0 end + if not y then y = 0 end + if not z then z = 0 end + + return (x * x + y * y + z * z) +end + +function dcsCommon.vNorm(a) + if not a then return {x = 0, y = 0, z = 0} end + m = dcsCommon.vMag(a) + if m <= 0 then return {x = 0, y = 0, z = 0} end + local r = {} + r.x = a.x / m + r.y = a.y / m + r.z = a.z / m + return r +end + +function dcsCommon.dot (a, b) + if not a then a = {} end + if not a.x then a.x = 0 end + if not a.y then a.y = 0 end + if not a.z then a.z = 0 end + if not b then b = {} end + if not b.x then b.x = 0 end + if not b.y then b.y = 0 end + if not b.z then b.z = 0 end + + return a.x * b.x + a.y * b.y + a.z * b.z +end +-- +-- UNIT MISC +-- +function dcsCommon.isSceneryObject(theUnit) + if not theUnit then return false end + return theUnit.getCoalition == nil -- scenery objects do not return a coalition +end + +function dcsCommon.isTroopCarrier(theUnit) + -- return true if conf can carry troups + if not theUnit then return false end + local uType = theUnit:getTypeName() + if dcsCommon.arrayContainsString(dcsCommon.troopCarriers, uType) then + -- may add additional tests before returning true + return true + end + return false +end + +function dcsCommon.getUnitAlt(theUnit) + if not theUnit then return 0 end + if not theUnit:isExist() then return 0 end + local p = theUnit:getPoint() + return p.y +end + +function dcsCommon.getUnitAGL(theUnit) + if not theUnit then return 0 end + if not theUnit:isExist() then return 0 end + local p = theUnit:getPoint() + local alt = p.y + local loc = {x = p.x, y = p.z} + local landElev = land.getHeight(loc) + return alt - landElev +end + +function dcsCommon.getUnitSpeed(theUnit) + if not theUnit then return 0 end + if not theUnit:isExist() then return 0 end + local v = theUnit:getVelocity() + return dcsCommon.mag(v.x, v.y, v.z) +end + +-- closing velocity of u1 and u2, seen from u1 +function dcsCommon.getClosingVelocity(u1, u2) + if not u1 then return 0 end + if not u2 then return 0 end + if not u1:isExist() then return 0 end + if not u2:isExist() then return 0 end + local v1 = u1:getVelocity() + local v2 = u2:getVelocity() + local dV = dcsCommon.vSub(v1,v2) + local a = u1:getPoint() + local b = u2:getPoint() + local aMinusB = dcsCommon.vSub(a,b) -- vector from u2 to u1 + local abMag = dcsCommon.vMag(aMinusB) -- distance u1 to u2 + if abMag < .0001 then return 0 end + -- project deltaV onto vector from u2 to u1 + local vClose = dcsCommon.dot(dV, aMinusB) / abMag + return vClose +end + +function dcsCommon.getGroupAvgSpeed(theGroup) + if not theGroup then return 0 end + if not dcsCommon.isGroupAlive(theGroup) then return 0 end + local totalSpeed = 0 + local cnt = 0 + local livingUnits = theGroup:getUnits() + for idx, theUnit in pairs(livingUnits) do + cnt = cnt + 1 + totalSpeed = totalSpeed + dcsCommon.getUnitSpeed(theUnit) + end + if cnt == 0 then return 0 end + return totalSpeed / cnt +end + +function dcsCommon.getGroupMaxSpeed(theGroup) + if not theGroup then return 0 end + if not dcsCommon.isGroupAlive(theGroup) then return 0 end + local maxSpeed = 0 + local livingUnits = theGroup:getUnits() + for idx, theUnit in pairs(livingUnits) do + currSpeed = dcsCommon.getUnitSpeed(theUnit) + if currSpeed > maxSpeed then maxSpeed = currSpeed end + end + return maxSpeed +end + +function dcsCommon.getUnitHeading(theUnit) + if not theUnit then return 0 end + if not theUnit:isExist() then return 0 end + local pos = theUnit:getPosition() -- returns three vectors, p is location + + local heading = math.atan2(pos.x.z, pos.x.x) + -- make sure positive only, add 260 degrees + if heading < 0 then + heading = heading + 2 * math.pi -- put heading in range of 0 to 2*pi + end + return heading +end + +function dcsCommon.getUnitHeadingDegrees(theUnit) + local heading = dcsCommon.getUnitHeading(theUnit) + return heading * 57.2958 -- 180 / math.pi +end + +function dcsCommon.unitIsInfantry(theUnit) + if not theUnit then return false end + if not theUnit:isExist() then return end + local theType = theUnit:getTypeName() + local isInfantry = + dcsCommon.containsString(theType, "infantry", false) or + dcsCommon.containsString(theType, "paratrooper", false) or + dcsCommon.containsString(theType, "stinger", false) or + dcsCommon.containsString(theType, "manpad", false) or + dcsCommon.containsString(theType, "soldier", false) or + dcsCommon.containsString(theType, "SA-18 Igla", false) + return isInfantry +end + +function dcsCommon.coalition2county(inCoalition) + -- simply return UN troops for 0 neutral, + -- joint red for 1 red + -- joint blue for 2 blue + if inCoalition == 1 then return 81 end -- cjtf red + if inCoalition == 2 then return 80 end -- blue + if type(inCoalition) == "string" then + inCoalition = inCoalition:lower() + if inCoalition == "red" then return 81 end + if inCoalition == "blue" then return 80 end + end + + trigger.action.outText("+++dcsC: coalition2county in (" .. inCoalition .. ") converts to UN (82)!", 30) + return 82 -- UN + +end + +-- +-- +-- INIT +-- +-- + -- init any variables the lib requires internally + function dcsCommon.init() + cbID = 0 + dcsCommon.uuIdent = 0 + if (dcsCommon.verbose) then + trigger.action.outText("dcsCommon v" .. dcsCommon.version .. " loaded successfully", 10) + end + end + + +-- do init. +dcsCommon.init() + +--[[-- + +to do: +- formation 2Column +- formation 3Column + +-]]-- diff --git a/modules/guardianAngel.lua b/modules/guardianAngel.lua new file mode 100644 index 0000000..d8d2c3b --- /dev/null +++ b/modules/guardianAngel.lua @@ -0,0 +1,515 @@ +guardianAngel = {} +guardianAngel.version = "2.0.2" +guardianAngel.ups = 10 +guardianAngel.launchWarning = true -- detect launches and warn pilot +guardianAngel.intervention = true -- remove missiles just before hitting +guardianAngel.explosion = -1 -- small poof when missile explodes. -1 = off. +guardianAngel.verbose = false -- debug info +guardianAngel.announcer = true -- angel talks to you +guardianAngel.private = false -- angel only talks to group +guardianAngel.autoAddPlayers = true + +guardianAngel.requiredLibs = { + "dcsCommon", -- always + "cfxZones", -- Zones, of course +} + +--[[-- + Version History + 1.0.0 - Initial version + 2.0.0 - autoAddPlayer + - verbose + - lib check + - config zone + - sneaky detection logic + - god intervention 100m + - detect re-acquisition + - addUnitToWatch supports string names + - detect non-miss + - announcer + - intervene optional + - launch warning optional + 2.0.1 - warnings go to group + - invokeCallbacks added to module + - reworked CB structure + - private option + 2.0.2 - poof! explosion option to show explosion on intervention + - can be dangerous + + +This script detects missiles launched against protected aircraft an +removes them when they are about to hit + +--]]-- + +guardianAngel.minMissileDist = 50 -- m. below this distance the missile is killed by god, not the angel :) +guardianAngel.myEvents = {1, 15, 20, 21, 23} -- 1 - shot, 15 - birth, 20 - enter unit, 21 - player leave unit, 23 - start shooting +guardianAngel.safetyFactor = 1.8 -- for calculating dealloc range +guardianAngel.unitsToWatchOver = {} -- I'll watch over these + +guardianAngel.missilesInTheAir = {} -- missiles in the air + +guardianAngel.callBacks = {} -- callbacks +-- callback signature: callBack(reason, targetUnitName, weaponName) +-- reasons (string): "launch", "miss", "reacquire", "trackloss", "disappear", "intervention" + +function guardianAngel.addCallback(theCallback) + if theCallback then + table.insert(guardianAngel.callBacks, theCallback) + end +end + +function guardianAngel.invokeCallbacks(reason, targetName, weaponName) + for idx, theCB in pairs(guardianAngel.callBacks) do + theCB(reason, targetName, weaponName) + end +end + +-- +-- units to watch +-- +function guardianAngel.addUnitToWatch(aUnit) + if type(aUnit) == "string" then + aUnit = Unit.getByName(aUnit) + end + if not aUnit then return end + local unitName = aUnit:getName() + guardianAngel.unitsToWatchOver[unitName] = aUnit + if guardianAngel.verbose then + trigger.action.outText("+++gA: now watching unit " .. aUnit:getName(), 30) + end +end + +function guardianAngel.removeUnitToWatch(aUnit) + if type(aUnit) == "string" then + aUnit = Unit.getByName(aUnit) + end + if not aUnit then return end + local unitName = aUnit:getName() + if not unitName then return end + guardianAngel.unitsToWatchOver[unitName] = nil + if guardianAngel.verbose then + trigger.action.outText("+++gA: no longer watching " .. aUnit:getName(), 30) + end +end + +function guardianAngel.getWatchedUnitByName(aName) + if not aName then return nil end + return guardianAngel.unitsToWatchOver[aName] +end + +-- +-- watch q items +-- +function guardianAngel.createQItem(theWeapon, theTarget, detectProbability) + if not theWeapon then return nil end + if not theTarget then return nil end + if not theTarget:isExist() then return nil end + if not detectProbability then detectProbability = 1.0 end + local theItem = {} + theItem.theWeapon = theWeapon + theItem.wP = theWeapon:getPoint() -- save location + theItem.weaponName = theWeapon:getName() + theItem.theTarget = theTarget + theItem.tGroup = theTarget:getGroup() + theItem.tID = theItem.tGroup:getID() + + theItem.targetName = theTarget:getName() + theItem.launchTimeStamp = timer.getTime() + theItem.lastCheckTimeStamp = -1000 + theItem.lastDistance = math.huge + theItem.detected = false + theItem.lostTrack = false -- so we can detect sneakies! + theItem.missed = false -- just keep watching for re-ack + return theItem +end +--[[-- +function guardianAngel.detectItem(theItem) + if theItem.detected then return end + -- perform detection calculations here + +end +--]]-- + +-- calculate a point in direction from plane (pln) to weapon (wpn), dist meters +function guardianAngel.calcSafeExplosionPoint(wpn, pln, dist) + local dirToWpn = dcsCommon.vSub(wpn, pln) -- vector to weapon. + local v = dcsCommon.vNorm(dirToWpn) -- |v| = 1 + local v = dcsCommon.vMultScalar(v, dist) -- |v| = dist + local newPoint = dcsCommon.vAdd(pln, v) + return newPoint +end + +function guardianAngel.monitorItem(theItem) + local w = theItem.theWeapon + local ID = theItem.tID + if not w then return false end + if not w:isExist() then + if (not theItem.missed) and (not theItem.lostTrack) then + local desc = theItem.weaponName .. ": DISAPPEARED" + if guardianAngel.announcer then + if guardianAngel.private then + trigger.action.outTextForGroup(ID, desc, 30) + else + trigger.action.outText(desc, 30) + end + end + guardianAngel.invokeCallbacks("disappear", theItem.targetName, theItem.weaponName) + end + return false + end + + local t = theItem.theTarget + local currentTarget = w:getTarget() + local oldWPos = theItem.wP + local A = w:getPoint() -- A is new point of weapon + theItem.wp = A -- update new position, old is in oldWPos + local B + if currentTarget then B = currentTarget:getPoint() else B = A end + + local d = math.floor(dcsCommon.dist(A, B)) + local desc = theItem.weaponName .. ": " + if t == currentTarget then + desc = desc .. "tracking " .. theItem.targetName .. ", d = " .. d .. "m" + local vcc = dcsCommon.getClosingVelocity(t, w) + desc = desc .. ", Vcc = " .. math.floor(vcc) .. "m/s" + + -- now calculate lethal distance: vcc is in meters per second + -- and we sample ups times per second + -- making the missile cover vcc / ups meters in the next + -- timer interval. If it now is closer than that, we have to + -- destroy the missile + local lethalRange = math.abs(vcc / guardianAngel.ups) * guardianAngel.safetyFactor + desc = desc .. ", LR= " .. math.floor(lethalRange) .. "m" + if guardianAngel.intervention and + d <= lethalRange + 10 + then + desc = desc .. " ANGEL INTERVENTION" + if theItem.lostTrack then desc = desc .. " (little sneak!)" end + if theItem.missed then desc = desc .. " (missed you!)" end + + + if guardianAngel.announcer then + if guardianAngel.private then + trigger.action.outTextForGroup(ID, desc, 30) + else + trigger.action.outText(desc, 30) + end + end + guardianAngel.invokeCallbacks("intervention", theItem.targetName, theItem.weaponName) + w:destroy() + + -- now add some showy explosion so the missile + -- doesn't just disappear + if guardianAngel.explosion > 0 then + local xP = guardianAngel.calcSafeExplosionPoint(A,B, 500) + trigger.action.explosion(xP, guardianAngel.explosion) + end + + return false -- remove from list + end + + if guardianAngel.intervention and + d <= guardianAngel.minMissileDist -- god's override + then + desc = desc .. " GOD INTERVENTION" + if theItem.lostTrack then desc = desc .. " (little sneak!)" end + if theItem.missed then desc = desc .. " (missed you!)" end + + if guardianAngel.announcer then + if guardianAngel.private then + trigger.action.outTextForGroup(ID, desc, 30) + else + trigger.action.outText(desc, 30) + end + end + guardianAngel.invokeCallbacks("intervention", theItem.targetName, theItem.weaponName) + w:destroy() + if guardianAngel.explosion > 0 then + local xP = guardianAngel.calcSafeExplosionPoint(A,B, 500) + trigger.action.explosion(xP, guardianAngel.explosion) + end + return false -- remove from list + end + else + if not theItem.lostTrack then + desc = desc .. "Missile LOST TRACK" + + if guardianAngel.announcer then + if guardianAngel.private then + trigger.action.outTextForGroup(ID, desc, 30) + else + trigger.action.outText(desc, 30) + end + end + guardianAngel.invokeCallbacks("trackloss", theItem.targetName, theItem.weaponName) + theItem.lostTrack = true + end + theItem.lastDistance = d + return true -- true because they can re-acquire! + end + + if d > theItem.lastDistance then + -- this can be wrong because if a missile is launched + -- at an angle, it can initially look as if it missed + if not theItem.missed then + desc = desc .. " Missile MISSED!" + + if guardianAngel.announcer then + if guardianAngel.private then + trigger.action.outTextForGroup(ID, desc, 30) + else + trigger.action.outText(desc, 30) + end + end + guardianAngel.invokeCallbacks("miss", theItem.targetName, theItem.weaponName) + theItem.missed = true + end + theItem.lastDistance = d + return true -- better not disregard - they can re-acquire! + end + + if theItem.missed and d < theItem.lastDistance then + desc = desc .. " Missile RE-ACQUIRED!" + + if guardianAngel.announcer then + if guardianAngel.private then + trigger.action.outTextForGroup(ID, desc, 30) + else + trigger.action.outText(desc, 30) + end + end + theItem.missed = false + guardianAngel.invokeCallbacks("reacquire", theItem.targetName, theItem.weaponName) + end + + theItem.lastDistance = d + + return true +end + +function guardianAngel.monitorMissiles() + local newArray = {} -- we collect all still existing missiles here + -- and replace missilesInTheAir with that for next round + for idx, anItem in pairs (guardianAngel.missilesInTheAir) do + -- we now have an item + -- see about detection + -- guardianAngel.detectItem(anItem) + + -- see if the weapon is still in existence + stillAlive = guardianAngel.monitorItem(anItem) + if stillAlive then + table.insert(newArray, anItem) + end + end + guardianAngel.missilesInTheAir = newArray +end + +-- +-- E V E N T P R O C E S S I N G +-- +function guardianAngel.isInteresting(eventID) + -- return true if we are interested in this event, false else + for key, evType in pairs(guardianAngel.myEvents) do + if evType == eventID then return true end + end + return false +end + +-- event pre-proc: only return true if we need to process this event +function guardianAngel.preProcessor(event) + -- all events must have initiator set + if not event.initiator then return false end + -- see if the event ID is interesting for us + local interesting = guardianAngel.isInteresting(event.id) + return interesting +end + +function guardianAngel.postProcessor(event) + -- don't do anything for now +end + +-- event callback from dcsCommon event handler. preProcessor has returned true +function guardianAngel.somethingHappened(event) + -- when this is invoked, the preprocessor guarantees that + -- it's an interesting event and has initiator + local ID = event.id + local theUnit = event.initiator + local playerName = theUnit:getPlayerName() -- nil if not a player + + if ID == 15 and playerName then + -- this is a player created unit + if guardianAngel.verbose then + trigger.action.outText("+++gA: unit born " .. theUnit:getName(), 30) + end + if guardianAngel.autoAddPlayers then + guardianAngel.addUnitToWatch(theUnit) + end + return + end + + if ID == 20 and playerName then + -- this is a player entered unit + if guardianAngel.verbose then + trigger.action.outText("+++gA: player seated in unit " .. theUnit:getName(), 30) + end + if guardianAngel.autoAddPlayers then + guardianAngel.addUnitToWatch(theUnit) + end + return + end + + if ID == 21 and playerName then + guardianAngel.removeUnitToWatch(theUnit) + return + end + + if ID == 15 or ID == 20 or ID == 21 then + -- non-player events of same type, disregard + return + end + + + if ID == 1 then + -- someone shot something. see if it is fire directed at me + local theWeapon = event.weapon + local theTarget + if theWeapon then + theTarget = theWeapon:getTarget() + else + return + end + if not theTarget then + return + end + if not theTarget:isExist() then return end + + -- if we get here, we have weapon aimed at a target + local targetName = theTarget:getName() + local watchedUnit = guardianAngel.getWatchedUnitByName(targetName) + if not watchedUnit then return end -- fired at some other poor sucker, we don't care + + -- if we get here, someone fired a guided weapon at my watched units + -- create a new item for my queue + local theQItem = guardianAngel.createQItem(theWeapon, theTarget) -- prob 100 + table.insert(guardianAngel.missilesInTheAir, theQItem) + guardianAngel.invokeCallbacks("launch", theQItem.targetName, theQItem.weaponName) + + local unitHeading = dcsCommon.getUnitHeadingDegrees(theTarget) + local A = theWeapon:getPoint() + local B = theTarget:getPoint() + local oclock = dcsCommon.clockPositionOfARelativeToB(A, B, unitHeading) + + local grpID = theTarget:getGroup():getID() + if guardianAngel.launchWarning then + -- currently, we always detect immediately + -- can be moved to update() + if guardianAngel.private then + trigger.action.outTextForGroup(grpID, "Missile, missile, missile, " .. oclock .. " o clock", 30) + else + trigger.action.outText("Missile, missile, missile, " .. oclock .. " o clock", 30) + end + + theQItem.detected = true -- remember: we detected and warned already + end + return + end + + local myType = theUnit:getTypeName() + if guardianAngel.verbose then + trigger.action.outText("+++gA: event " .. ID .. " for unit " .. theUnit:getName() .. " of type " .. myType, 30) + end +end + +-- +-- U P D A T E L O O P +-- + + +function guardianAngel.update() + timer.scheduleFunction(guardianAngel.update, {}, timer.getTime() + 1/guardianAngel.ups) + + guardianAngel.monitorMissiles() +end + +function guardianAngel.collectPlayerUnits() + -- make sure we have all existing player units + -- at start of game + if not guardianAngel.autoAddPlayer then return end + + for i=1, 2 do + -- currently only two factions in dcs + factionUnits = coalition.getPlayers(i) + for idx, aPlayerUnit in pairs(factionUnits) do + -- add all existing faction units + guardianAngel.addUnitToWatch(aPlayerUnit) + end + end +end + +-- +-- config reading +-- +function guardianAngel.readConfigZone() + -- note: must match exactly!!!! + local theZone = cfxZones.getZoneByName("guardianAngelConfig") + if not theZone then + trigger.action.outText("+++gA: no config zone!", 30) + return + end + if guardianAngel.verbose then + trigger.action.outText("+++gA: found config zone!", 30) + end + + guardianAngel.verbose = cfxZones.getBoolFromZoneProperty(theZone, "verbose", false) + + guardianAngel.autoAddPlayer = cfxZones.getBoolFromZoneProperty(theZone, "autoAddPlayer", true) + guardianAngel.launchWarning = cfxZones.getBoolFromZoneProperty(theZone, "launchWarning", true) + guardianAngel.intervention = cfxZones.getBoolFromZoneProperty(theZone, "intervention", true) + guardianAngel.announcer = cfxZones.getBoolFromZoneProperty(theZone, "announcer", true) + guardianAngel.private = cfxZones.getBoolFromZoneProperty(theZone, "private", false) + guardianAngel.explosion = cfxZones.getNumberFromZoneProperty(theZone, "explosion", -1) +end + + +-- +-- start +-- +function guardianAngel.start() + -- lib check + if not dcsCommon.libCheck("cfx Guardian Angel", + guardianAngel.requiredLibs) then + return false + end + + -- read config + guardianAngel.readConfigZone() + + -- install event monitor + dcsCommon.addEventHandler(guardianAngel.somethingHappened, + guardianAngel.preProcessor, + guardianAngel.postProcessor) + + -- collect all units that are already in the game at this point + guardianAngel.collectPlayerUnits() + + -- start update + guardianAngel.update() + + trigger.action.outText("Guardian Angel v" .. guardianAngel.version .. " running", 30) + return true +end + +function guardianAngel.testCB(reason, targetName, weaponName) + trigger.action.outText("gA - CB for ".. reason .. ": " .. targetName .. " w: " .. weaponName, 30) +end + +-- go go go +if not guardianAngel.start() then + trigger.action.outText("Loading Guardian Angel failed.", 30) + guardianAngel = nil +end + +-- test callback +--guardianAngel.addCallback(guardianAngel.testCB) +--guardianAngel.invokeCallbacks("A", "B", "C") diff --git a/modules/jtacGrpUI.lua b/modules/jtacGrpUI.lua new file mode 100644 index 0000000..49f0258 --- /dev/null +++ b/modules/jtacGrpUI.lua @@ -0,0 +1,395 @@ +jtacGrpUI = {} +jtacGrpUI.version = "1.0.2" +--[[-- VERSION HISTORY + - 1.0.2 - also include idling JTACS + - add positional info when using owned zones + +--]]-- +-- find & command cfxGroundTroops-based jtacs +-- UI installed via OTHER for all groups with players +-- module based on xxxGrpUI + +jtacGrpUI.groupConfig = {} -- all inited group private config data +jtacGrpUI.simpleCommands = true -- if true, f10 other invokes directly + +-- +-- C O N F I G H A N D L I N G +-- ============================= +-- +-- Each group has their own config block that can be used to +-- store group-private data and configuration items. +-- + +function jtacGrpUI.resetConfig(conf) +end + +function jtacGrpUI.createDefaultConfig(theGroup) + local conf = {} + conf.theGroup = theGroup + conf.name = theGroup:getName() + conf.id = theGroup:getID() + conf.coalition = theGroup:getCoalition() + + jtacGrpUI.resetConfig(conf) + + conf.mainMenu = nil; -- this is where we store the main menu if we branch + conf.myCommands = nil; -- this is where we store the commands if we branch + + return conf +end + +-- getConfigFor group will allocate if doesn't exist in DB +-- and add to it +function jtacGrpUI.getConfigForGroup(theGroup) + if not theGroup then + trigger.action.outText("+++WARNING: jtacGrpUI nil group in getConfigForGroup!", 30) + return nil + end + local theName = theGroup:getName() + local c = jtacGrpUI.getConfigByGroupName(theName) -- we use central accessor + if not c then + c = jtacGrpUI.createDefaultConfig(theGroup) + jtacGrpUI.groupConfig[theName] = c -- should use central accessor... + end + return c +end + +function jtacGrpUI.getConfigByGroupName(theName) -- DOES NOT allocate when not exist + if not theName then return nil end + return jtacGrpUI.groupConfig[theName] +end + + +function jtacGrpUI.getConfigForUnit(theUnit) + -- simple one-off step by accessing the group + if not theUnit then + trigger.action.outText("+++WARNING: jtacGrpUI nil unit in getConfigForUnit!", 30) + return nil + end + + local theGroup = theUnit:getGroup() + return getConfigForGroup(theGroup) +end + +-- +-- +-- M E N U H A N D L I N G +-- ========================= +-- +-- + function jtacGrpUI.clearCommsSubmenus(conf) + if conf.myCommands then + for i=1, #conf.myCommands do + missionCommands.removeItemForGroup(conf.id, conf.myCommands[i]) + end + end + conf.myCommands = {} +end + +function jtacGrpUI.removeCommsFromConfig(conf) + jtacGrpUI.clearCommsSubmenus(conf) + + if conf.myMainMenu then + missionCommands.removeItemForGroup(conf.id, conf.myMainMenu) + conf.myMainMenu = nil + end +end + +-- this only works in single-unit groups. may want to check if group +-- has disappeared +function jtacGrpUI.removeCommsForUnit(theUnit) + if not theUnit then return end + if not theUnit:isExist() then return end + -- perhaps add code: check if group is empty + local conf = jtacGrpUI.getConfigForUnit(theUnit) + jtacGrpUI.removeCommsFromConfig(conf) +end + +function jtacGrpUI.removeCommsForGroup(theGroup) + if not theGroup then return end + if not theGroup:isExist() then return end + local conf = jtacGrpUI.getConfigForGroup(theGroup) + jtacGrpUI.removeCommsFromConfig(conf) +end + +-- +-- set main root in F10 Other. All sub menus click into this +-- +function jtacGrpUI.isEligibleForMenu(theGroup) + return true +end + +function jtacGrpUI.setCommsMenuForUnit(theUnit) + if not theUnit then + trigger.action.outText("+++WARNING: jtacGrpUI nil UNIT in setCommsMenuForUnit!", 30) + return + end + if not theUnit:isExist() then return end + + local theGroup = theUnit:getGroup() + jtacGrpUI.setCommsMenu(theGroup) +end + +function jtacGrpUI.setCommsMenu(theGroup) + -- depending on own load state, we set the command structure + -- it begins at 10-other, and has 'jtac' as main menu with submenus + -- as required + if not theGroup then return end + if not theGroup:isExist() then return end + + -- we test here if this group qualifies for + -- the menu. if not, exit + if not jtacGrpUI.isEligibleForMenu(theGroup) then return end + + local conf = jtacGrpUI.getConfigForGroup(theGroup) + conf.id = theGroup:getID(); -- we do this ALWAYS so it is current even after a crash +-- trigger.action.outText("+++ setting group <".. conf.theGroup:getName() .. "> jtac command", 30) + + if jtacGrpUI.simpleCommands then + -- we install directly in F-10 other + if not conf.myMainMenu then + local commandTxt = "jtac Lasing Report" + local theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + nil, + jtacGrpUI.redirectCommandX, + {conf, "lasing report"} + ) + conf.myMainMenu = theCommand + end + + return + end + + + -- ok, first, if we don't have an F-10 menu, create one + if not (conf.myMainMenu) then + conf.myMainMenu = missionCommands.addSubMenuForGroup(conf.id, 'jtac') + end + + -- clear out existing commands + jtacGrpUI.clearCommsSubmenus(conf) + + -- now we have a menu without submenus. + -- add our own submenus + jtacGrpUI.addSubMenus(conf) + +end + +function jtacGrpUI.addSubMenus(conf) + -- add menu items to choose from after + -- user clickedf on MAIN MENU. In this implementation + -- they all result invoked methods + + + + local commandTxt = "jtac Lasing Report" + local theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + conf.myMainMenu, + jtacGrpUI.redirectCommandX, + {conf, "lasing report"} + ) + table.insert(conf.myCommands, theCommand) +--[[-- + commandTxt = "This is another important command" + theCommand = missionCommands.addCommandForGroup( + conf.id, + commandTxt, + conf.myMainMenu, + jtacGrpUI.redirectCommandX, + {conf, "Sub2"} + ) + table.insert(conf.myCommands, theCommand) +--]]-- +end + +-- +-- each menu item has a redirect and timed invoke to divorce from the +-- no-debug zone in the menu invocation. Delay is .1 seconds +-- + +function jtacGrpUI.redirectCommandX(args) + timer.scheduleFunction(jtacGrpUI.doCommandX, args, timer.getTime() + 0.1) +end + +function jtacGrpUI.doCommandX(args) + local conf = args[1] -- < conf in here + local what = args[2] -- < second argument in here + local theGroup = conf.theGroup +-- trigger.action.outTextForGroup(conf.id, "+++ groupUI: processing comms menu for <" .. what .. ">", 30) + local targetList = jtacGrpUI.collectJTACtargets(conf, true) + -- iterate the list + if #targetList < 1 then + trigger.action.outTextForGroup(conf.id, "No targets are currently being lased", 30) + return + end + + local desc = "JTAC Target Report:\n" +-- trigger.action.outTextForGroup(conf.id, "Target Report:", 30) + for i=1, #targetList do + local aTarget = targetList[i] + if aTarget.idle then + desc = desc .. "\n" .. aTarget.jtacName .. aTarget.posInfo ..": no target" + else + desc = desc .. "\n" .. aTarget.jtacName .. aTarget.posInfo .." lasing " .. aTarget.lazeTargetType .. " [" .. aTarget.range .. "nm at " .. aTarget.bearing .. "°]" + end + end + trigger.action.outTextForGroup(conf.id, desc .. "\n", 30) +end + +function jtacGrpUI.collectJTACtargets(conf, includeIdle) + -- iterate cfxGroundTroops.deployedTroops to retrieve all + -- troops that are lazing. 'Lazing' are all groups that + -- have an active (non-nil) lazeTarget and 'laze' orders + if not includeIdle then includeIdle = false end + + local theJTACS = {} + for idx, troop in pairs(cfxGroundTroops.deployedTroops) do + if troop.coalition == conf.coalition + and troop.orders == "laze" + and troop.lazeTarget + and troop.lazeTarget:isExist() + then + table.insert(theJTACS, troop) + elseif troop.coalition == conf.coalition + and troop.orders == "laze" + and includeIdle + then + -- we also include idlers + table.insert(theJTACS, troop) + end + end + + -- we now have a list of all ground troops that are lazing. + -- get bearing and range to targets, and sort them accordingly + local targetList = {} + local here = dcsCommon.getGroupLocation(conf.theGroup) -- this is me + + for idx, troop in pairs (theJTACS) do + local aTarget = {} + -- establish our location + aTarget.jtacName = troop.name + aTarget.posInfo = "" + if cfxOwnedZones and cfxOwnedZones.hasOwnedZones() then + local jtacLoc = dcsCommon.getGroupLocation(troop.group) + local nearestZone = cfxOwnedZones.getNearestOwnedZoneToPoint(jtacLoc) + if nearestZone then + local ozRange = dcsCommon.dist(jtacLoc, nearestZone.point) * 0.000621371 + ozRange = math.floor(ozRange * 10) / 10 + local relPos = dcsCommon.compassPositionOfARelativeToB(jtacLoc, nearestZone.point) + aTarget.posInfo = " (" .. ozRange .. "nm " .. relPos .. " of " .. nearestZone.name .. ")" + end + end + -- we may get idlers, catch them now + if not troop.lazeTarget then + aTarget.idle = true + aTarget.range = math.huge + else + -- get the target we are lazing + local there = troop.lazeTarget:getPoint() + aTarget.idle = false + aTarget.range = dcsCommon.dist(here, there) + aTarget.range = aTarget.range * 0.000621371 -- meter to miles + aTarget.range = math.floor(aTarget.range * 10) / 10 + aTarget.bearing = dcsCommon.bearingInDegreesFromAtoB(here, there) + --aTarget.jtacName = troop.name + aTarget.lazeTargetType = troop.lazeTargetType + end + table.insert(targetList, aTarget) + end + + -- now sort by range + table.sort(targetList, function (left, right) return left.range < right.range end ) + + -- return list sorted by distance + return targetList +end + +-- +-- G R O U P M A N A G E M E N T +-- +-- Group Management is required to make sure all groups +-- receive a comms menu and that they receive a clean-up +-- when required +-- +-- Callbacks are provided by cfxPlayer module to which we +-- subscribe during init +-- +function jtacGrpUI.playerChangeEvent(evType, description, player, data) + --trigger.action.outText("+++ groupUI: received <".. evType .. "> Event", 30) + if evType == "newGroup" then + -- initialized attributes are in data as follows + -- .group - new group + -- .name - new group's name + -- .primeUnit - the unit that trigggered new group appearing + -- .primeUnitName - name of prime unit + -- .id group ID + --theUnit = data.primeUnit + jtacGrpUI.setCommsMenu(data.group) +-- trigger.action.outText("+++ groupUI: added " .. theUnit:getName() .. " to comms menu", 30) + return + end + + if evType == "removeGroup" then + -- data is the player record that no longer exists. it consists of + -- .name + -- we must remove the comms menu for this group else we try to add another one to this group later + local conf = jtacGrpUI.getConfigByGroupName(data.name) + + if conf then + jtacGrpUI.removeCommsFromConfig(conf) -- remove menus + jtacGrpUI.resetConfig(conf) -- re-init this group for when it re-appears + else + trigger.action.outText("+++ jtacUI: can't retrieve group <" .. data.name .. "> config: not found!", 30) + end + + return + end + + if evType == "leave" then + -- player unit left. we don't care since we only work on group level + -- if they were the only, this is followed up by group disappeared + + end + + if evType == "unit" then + -- player changed units. almost never in MP, but possible in solo + -- because of 1 seconds timing loop + -- will result in a new group appearing and a group disappearing, so we are good + -- may need some logic to clean up old configs and/or menu items + + end + +end + +-- +-- Start +-- +function jtacGrpUI.start() + + -- iterate existing groups so we have a start situation + -- now iterate through all player groups and install the Assault Troop Menu + allPlayerGroups = cfxPlayerGroups -- cfxPlayerGroups is a global, don't fuck with it! + -- contains per group player record. Does not resolve on unit level! + for gname, pgroup in pairs(allPlayerGroups) do + local theUnit = pgroup.primeUnit -- get any unit of that group + jtacGrpUI.setCommsMenuForUnit(theUnit) -- set up + end + -- now install the new group notifier to install Assault Troops menu + + cfxPlayer.addMonitor(jtacGrpUI.playerChangeEvent) + trigger.action.outText("cf/x jtacGrpUI v" .. jtacGrpUI.version .. " started", 30) + +end + +-- +-- GO GO GO +-- +if not cfxGroundTroops then + trigger.action.outText("cf/x jtacGrpUI REQUIRES cfxGroundTroops to work.", 30) +else + jtacGrpUI.start() +end \ No newline at end of file diff --git a/modules/limitedAirframes.lua b/modules/limitedAirframes.lua new file mode 100644 index 0000000..3e56d3d --- /dev/null +++ b/modules/limitedAirframes.lua @@ -0,0 +1,828 @@ +limitedAirframes = {} +limitedAirframes.version = "1.3.0" +limitedAirframes.enabled = true -- can be turned off +limitedAirframes.userCanToggle = true -- F-10 menu? +limitedAirframes.maxRed = -1 -- -1 == infinite +limitedAirframes.maxBlue = 6 -- -1 = infinite +limitedAirframes.redWinsFlag = "999" +limitedAirframes.blueWinsFlag = "998" + +limitedAirframes.requiredLibs = { + "dcsCommon", -- common is of course needed for everything + -- pretty stupid to check for this since we + -- need common to invoke the check, but anyway + "cfxZones", -- Zones, of course for safe landings + "cfxPlayer", -- callbacks +-- "cfxGroundTroops", -- generic data module for weight +} + +--[[-- VERSION HISTORY + - 1.0.0 - initial version + - 1.0.1 - out text to coalition for switch + - less verbose + - win/lose sound by coalition + - 1.0.2 - corrected some to to-->for Groups typos + - 1.0.3 - renamed to 'pilot' instead of airframe: + pilotSafe attribute + - fixed MP bug, switched to EventMonII code + - 1.0.4 - added CSAR integration: create CSAR and callback + - safe ditch only at less than 7 kmh and 10m agl + - 1.0.5 - replaced 5 (crash) check for helos only + - 1.0.6 - changed alt and speed tests to inAir + reduced verbosity + made reporting of new units for coalition side only + - 1.0.7 - if unlimited pilots it says so when you return one + to base + - 1.0.8 - now can query remaining pilots + - 1.0.9 - better formatted remaining pilots + - 1.1.0 - module manager + - separated out settings + - hand change in pilotsafe zones that can be landed in + - 1.2.0 - limitedAirframesConfig zone + - 1.3.0 - added network dead override logic via unitFlownByPlayer + +--]]-- + +-- limitedAirframes manages the number of available player airframes +-- per scenario and side. Each time a player crashes the plane +-- outside of safe zones, the number is decreased for that side +-- when the number reaches -1 or smaller, other side wins +-- !!!Only affects player planes!! + +-- *** EXTENDS ZONES *** +-- safe zones must have a property "pilotSafe" +-- - pilotSafe - this is a zone to safely change airframes in +-- - redSafe (optional, defaults to true) +-- - blueSafe (optional, defaults to true) +-- set to "false" or "no" to disallow that side to change +-- airframes even when safer +-- if zone can change ownership, player's coalition +-- is checked against current zone ownership +-- zone owner. + +-- when red wins due to blue frame loss, flag 999 is set to true +-- when blue wins due to red frame loss, flag 998 is set to true +-- set a mission trigger to end mission if you want to end mission +-- or simply keep running, and a CHEATER! message will flash +-- every time the losing side enters a new aircraft + +limitedAirframes.safeZones = {} -- safezones are zones where a crash or change plane does not +-- these zones are created by adding an 'pilotSafe' attribute + +limitedAirframes.myEvents = {5, 9, 30, 6, 20, 21, 15 } -- 5 = crash, 9 - dead, 30 - unit lost, 6 - eject, 20 - enter unit, 21 - leave unit, 15 - birth + +-- guarantee a min of 2 seconds between events +-- for this we save last event per player +limitedAirframes.lastEvents = {} +-- each time a plane crashes or is abandoned check +-- that it's a player unit +-- inside a crash free zone +-- update the side's airframe credit + +limitedAirframes.currRed = 0 +limitedAirframes.currRed = 0 + +-- we record all unit names that contain a player +-- so that we can check against these when we receive +-- an ejection event. We also keep a list of players +-- for good measure and their status +limitedAirframes.playerUnits = {} +limitedAirframes.players = {} +limitedAirframes.unitFlownByPlayer = {} -- to detect dead after + -- 21 (player left unit) we store on 15 (birth) + -- which unit a player occupies. if player + -- then levaes and dead has a mismatch, we resolve + -- by not calling dead. + -- works if eject does not call player left unit + -- unit[unitname] = playername. if nil, no longer + -- occupied by player. + +limitedAirframes.theCommand = nil + +-- +-- READ CONFIG ZONE TO OVERRIDE SETTING +-- +function limitedAirframes.readConfigZone() + -- note: must match exactly!!!! + local theZone = cfxZones.getZoneByName("limitedAirframesConfig") + if not theZone then + trigger.action.outText("***LimA: NO config zone!", 30) + return + end + + trigger.action.outText("LimA: found config zone!", 30) + + -- ok, for each property, load it if it exists + if cfxZones.hasProperty(theZone, "enabled") then + limitedAirframes.enabled = cfxZones.getBoolFromZoneProperty(theZone, "enabled", true) + end + + if cfxZones.hasProperty(theZone, "userCanToggle") then + limitedAirframes.userCanToggle = cfxZones.getBoolFromZoneProperty(theZone, "userCanToggle", true) + end + + + if cfxZones.hasProperty(theZone, "maxRed") then + limitedAirframes.maxRed = cfxZones.getNumberFromZoneProperty(theZone, "maxRed", -1) + end + + if cfxZones.hasProperty(theZone, "maxBlue") then + limitedAirframes.maxBlue = cfxZones.getNumberFromZoneProperty(theZone, "maxBlue", -1) + end + + + if cfxZones.hasProperty(theZone, "redWinsFlag") then + limitedAirframes.redWinsFlag = cfxZones.getStringFromZoneProperty(theZone, "redWinsFlag", "999") + end + + if cfxZones.hasProperty(theZone, "blueWinsFlag") then + limitedAirframes.blueWinsFlag = cfxZones.getStringFromZoneProperty(theZone, "blueWinsFlag", "998") + end +end + +-- +-- UNIT AND PLAYER HANDLING +-- +function limitedAirframes.isKnownUnitName(uName) + if limitedAirframes.playerUnits[uName] then return true end + return false +end + +function limitedAirframes.getKnownUnitPilotByUnitName(uName) + if limitedAirframes.isKnownUnitName(uName) then + return limitedAirframes.playerUnits[uName] + end + trigger.action.outText("+++lim: WARNING: " .. uName .. " is unknown!", 30) + return "***Error" +end + +function limitedAirframes.getKnownUnitPilotByUnit(theUnit) + return limitedAirframes.getKnownUnitPilotByUnitName(theUnit:getName()) +end + +-- addPlayerUnit adds a unit as a known player unit +-- and also adds the player if unknown +function limitedAirframes.addPlayerUnit(theUnit) + local theSide = theUnit:getCoalition() + local uName = theUnit:getName() + if not uName then uName = "**XXXX**" end + local pName = theUnit:getPlayerName() + if not pName then pName = "**????**" end + limitedAirframes.updatePlayer(pName, "alive") + + local desc = "unit <" .. uName .. "> controlled by <" .. pName .. ">" + if not(limitedAirframes.isKnownUnitName(uName)) then + --desc = "+++lim: added ".. desc .. " to list of known player units" + else + if limitedAirframes.playerUnits[uName] == pName then + desc = "player unit <".. uName .. "> controlled by <".. limitedAirframes.playerUnits[uName].."> re-seated" + else + desc = "Updated player unit <".. uName .. "> from <".. limitedAirframes.playerUnits[uName].."> to <" .. pName ..">" + end + end + limitedAirframes.playerUnits[uName] = pName + trigger.action.outTextForCoalition(theSide, desc, 30) +end + +function limitedAirframes.killPlayer(pName) + limitedAirframes.updatePlayer(pName, "dead") + --trigger.action.outText("+++lim: PILOT LOST: " .. pName .. ", NO CSAR", 30) +end + +function limitedAirframes.killPlayerInUnit(theUnit) + limitedAirframes.updatePlayerInUnit(theUnit, "dead") + --trigger.action.outText("+++lim: PILOT LOST, NO CSAR", 30) +end + +function limitedAirframes.updatePlayerInUnit(theUnit, status) + local uName = theUnit:getName() + if not limitedAirframes.isKnownUnitName(uName) then + trigger.action.outText("+++lim: WARNING: updatePlayerInUnit to " .. status .. " with unknown pilot for plane", 30) + return + end + local pName = limitedAirframes.getKnownUnitPilotByUnitName(uName) + limitedAirframes.updatePlayer(pName, status) +end + +function limitedAirframes.updatePlayer(pName, status) + if not pName then + trigger.action.outText("+++lim: WARNING - NIL pName in updatePlayer for status " .. status, 30) + return + end + local desc = "" + if not limitedAirframes.players[pName] then + desc = "+++lim: NEW player " .. pName .. ": " .. status + else + if limitedAirframes.players[pName] ~= status then + desc = "+++lim: CHANGE player " .. pName .. " " .. limitedAirframes.players[pName] .. " -> " .. status + else + desc = "+++: player " .. pName .. " no change (" .. status .. ")" + end + end + + limitedAirframes.players[pName] = status + -- if desc then trigger.action.outText(desc, 30) end +end + +function limitedAirframes.getStatusOfPlayerInUnit(theUnit) + local uName = theUnit:getName() + if not limitedAirframes.isKnownUnitName(uName) then + trigger.action.outText("+++lim: WARNING get player status for unknown pilot in plane " .. uName, 30) + return nil + end + local pName = limitedAirframes.getKnownUnitPilotByUnitName(uName) + return limitedAirframes.getStatusOfPlayerNamed(pName) +end + +function limitedAirframes.getStatusOfPlayerNamed(pName) + return limitedAirframes.players[pName] +end + +-- +-- E V E N T H A N D L I N G +-- +function limitedAirframes.XXXisKnownPlayerUnit(theUnit) + if not theUnit then return false end + local aName = theUnit:getName() + if limitedAirframes.playerUnitNames[aName] ~= nil then + return true + end + return false +end + +function limitedAirframes.isInteresting(eventID) + -- return true if we are interested in this event, false else + for key, evType in pairs(limitedAirframes.myEvents) do + if evType == eventID then return true end + end + return false +end + +function limitedAirframes.preProcessor(event) + -- make sure it has an initiator + if not event.initiator then return false end -- no initiator + local theUnit = event.initiator + local uName = theUnit:getName() + + + if event.id == 6 then -- Eject, plane already divorced from player + if limitedAirframes.isKnownUnitName(uName) then + --trigger.action.outText("limAir: detected EJECT for player unit " .. uName .. " player " .. limitedAirframes.getKnownUnitPilotByUnitName(uName), 30) + return true + end + return false -- no longer of interest + end + + if event.id == 5 then -- crash, plane no longer attached to player + + if limitedAirframes.isKnownUnitName(uName) then + --trigger.action.outText("limAir: detected CRASH for player unit " .. uName .. " player " .. limitedAirframes.getKnownUnitPilotByUnitName(uName), 30) + return true + end + return false -- no longer of interest + end + + + if not cfxPlayer.isPlayerUnit(theUnit) then + -- not a player unit. Events 5 and 6 have been + -- handled before, so we can safely ignore + return false + end + + -- exclude all ground units + local theGroup = theUnit:getGroup() + local cat = theGroup:getCategory() + if cat == Group.Category.GROUND then + return false + end + + -- only return true if defined as interesting + return limitedAirframes.isInteresting(event.id) +end + +function limitedAirframes.postProcessor(event) + -- don't do anything +end + +function limitedAirframes.somethingHappened(event) + -- when this is invoked, the preprocessor guarantees that + -- we have: + -- * an interesting event + -- * unit is valid and was a player's unit + + -- the events that are relevant for pilot loss are: + -- * player entered: set pilot 'alive' state, maybe add new + -- unit and pilot to db of players and units + -- * pilot died - decrease pilot count, set 'dead' state + -- * eject - decrease pilot count, 'MIA' state, csar possible + -- * player left - when pilot status 'alive' - check safe zone + -- - outside safe zone, csar possible, set 'MIA' + -- - when pilot anything but 'alive' - ignore + + if not event.initiator then + trigger.action.outText("limAir: ***WARNING: event (" .. ID .. "): no initiator, should not have been procced", 30) + return + end + + local theUnit = event.initiator + local unitName = theUnit:getName() + local ID = event.id + local myType = theUnit:getTypeName() + + -- "20" event (player enter): always processed +--[[-- if ID == 20 or ID == 15 then -- player entered unit + limitedAirframes.addPlayerUnit(theUnit) -- will also update player and player status to 'alive' + -- now procc a 'cheater' since we entered a new airframe/pilot + limitedAirframes.checkPlayerFrameAvailability(event) + return + end +--]]-- + + if ID == 20 then -- 20 ENTER UNIT + local pName = limitedAirframes.getKnownUnitPilotByUnit(theUnit) + if not pName then pName = "***UNKNOWN***" end + --trigger.action.outText("limAir: Received ENTER UNIT (20) for " .. pName .. " in " .. unitName , 30) + return + end + + if ID == 15 then -- birth - this one is called in network, 20 is too unreliable + -- can set where birthed: runway, parking, air etc. + limitedAirframes.addPlayerUnit(theUnit) -- will also update player and player status to 'alive' + -- now procc a 'cheater' since we entered a new airframe/pilot + limitedAirframes.checkPlayerFrameAvailability(event) + local playerName = theUnit:getPlayerName() + limitedAirframes.unitFlownByPlayer[unitName] = playerName + -- TODO: make sure this is the ONLY plane the player + -- is registered under, and mark mismatches + trigger.action.outText("limAir: 15 -- player " .. playerName .. " now in " .. unitName, 30) + return + end + + -- make sure unit's player pilot is known + if not limitedAirframes.getKnownUnitPilotByUnitName(unitName) then + trigger.action.outText("limAir: ***WARNING: Ignored player event (" .. ID .. "): unable to retrieve player name for " .. unitName, 30) + return -- plane no longer of interest cant retrieve pilot -- BUG!!! + end + + + -- event 6 - eject - plane divorced but player pilot is known + if ID == 6 then -- eject + limitedAirframes.pilotEjected(event) + return + end + + -- event 5 - crash - plane divorced but player pilot is known + -- if pilot is still alive, this should now cause a pilot lost event if we are a helicopter + + if ID == 5 then -- crash + -- as of new processing, no longer relevant + -- limitedAirframes.airFrameCrashed(event) + -- helicopters do not call died when helo + -- crashes. so check if we are still seated=alive + -- and call pilot dead then + -- for some reason, pilot died also may not be called + -- so if pilot is still alive and not MIA, he's now dead. + -- forget the helo check, this now applies to all + + + local pStatus = limitedAirframes.getStatusOfPlayerInUnit(theUnit) + if pStatus == "alive" then + -- this frame was carrrying a live player + limitedAirframes.pilotDied(theUnit) + return + else + trigger.action.outText("limAir: Crash of airframe detected - but player status wasn't alive (" .. pStatus .. ")", 30) + return + end + end + +-- removed dual 21 detection here + + if ID == 21 then + -- remove pilot name from unit name + limitedAirframes.unitFlownByPlayer[unitName] = nil + --trigger.action.outText("limAir: 21 -- unit " .. unitName .. " unoccupied", 30) + + trigger.action.outText("limAir: 21 (player left) for unit " .. unitName , 30) + -- player left unit. Happens twice + -- check if player alive, else we have a ditch. + limitedAirframes.handlePlayerLeftUnit(event) + return + end + + + if ID == 9 then -- died + --trigger.action.outText("limAir: 9 (PILOT DEAD) for unit " .. unitName , 30) + local thePilot = limitedAirframes.unitFlownByPlayer[unitName] + if not thePilot then + trigger.action.outText("+++limAir: 9 O'RIDE -- unit " .. unitName .. " was legally vacated before!", 30) + return + end + limitedAirframes.pilotDied(theUnit) + return + end + + if ID == 30 then -- unit lost + return + end + + + + trigger.action.outText("limAir: WARNING unhandled: " .. ID .. " for player unit " .. theUnit:getName() .. " of type " .. myType, 30) +end + +-- +-- HANDLE VARIOUS SITUATIONS +-- + +function limitedAirframes.handlePlayerLeftUnit(event) + local theUnit = event.initiator + -- make sure the pilot is alive + if limitedAirframes.getStatusOfPlayerInUnit(theUnit) ~= "alive" then + -- was already handled. simply exit + local pName = limitedAirframes.getKnownUnitPilotByUnitName(theUnit:getName()) + local pStatus = limitedAirframes.getStatusOfPlayerInUnit(theUnit) + -- player was already dead and has been accounted for + --trigger.action.outText("limAir: Change Plane for player <" .. pName .. "> with status <" .. pStatus .. "> procced.", 30) + return + end + + -- check if the unit was inside a safe zone + -- if so, graceful exit + local uPos = theUnit:getPoint() + local meInside = cfxZones.getZonesContainingPoint(uPos, limitedAirframes.safeZones) + local mySide = theUnit:getCoalition() + --local speed = dcsCommon.getUnitSpeed(theUnit) -- this can cause problems with carriers, so check if below + --local agl = dcsCommon.getUnitAGL(theUnit) -- this will cause problems with FARP and carriers. + -- we now check the inAir + local isInAir = theUnit:inAir() + --trigger.action.outTextForCoalition(mySide, "limAir: safe check for Pilot " .. theUnit:getPlayerName() .. ": agl=" .. agl .. ", speed = " .. speed .. ", air status = " .. dcsCommon.bool2YesNo(isInAir), 30) + + for i=1, #meInside do + -- I'm inside all these zones. We look for the first + -- that saves me + local theSafeZone = meInside[i] + local isSafe = false + if mySide == 1 then + isSafe = theSafeZone.redSafe + elseif mySide == 2 then + isSafe = theSafeZone.blueSafe + else + isSafe = true + end + + if theSafeZone.owner then + -- owned zone. olny allow in neutral or owned by same side + isSafe = isSafe and (mySide == theSafeZone.owner or theSafeZone.owner == 0) + trigger.action.outText("+++: Lim - " .. theSafeZone.name .. " ownership: myside = " .. mySide .. " zone owner is " .. theSafeZone.owner, 30) + else + -- trigger.action.outText("+++: Zone " .. theSafeZone.name .. " has no ownership, skipping check", 30) + end + + -- check we are at rest below 10m height. agl may give + -- misreadings on carriers and FARPs, while speed may read + -- wrongly on carriers (tbd). we now use isInAir to determine if + -- we ditched while in flight + --if speed > 2 or agl > 10 then isSafe = false end + --if not isInAir then return false end -- why *not* isInAir??? + + -- for matter of fact, why did we return ANYTHING??? + -- there may be a bug here + -- maybe it should be "if isInAir then isSafe = false"?? + if isInAir then isSafe = false end + + if isSafe then + trigger.action.outTextForCoalition(mySide, "limAir: Pilot " .. theUnit:getPlayerName() .. " left unit " .. theUnit:getName() .. " legally in zone " .. theSafeZone.name, 30) + -- remove from known player planes + -- no more limitedAirframes.removePlayerUnit(theUnit) + return; + end + end + + -- ditched outside safe harbour + trigger.action.outTextForCoalition(mySide, "Pilot " .. theUnit:getPlayerName() .. " DITCHED unit " .. theUnit:getName() .. " -- PILOT LOSS (MIA)", 30) + limitedAirframes.pilotLost(theUnit) + if csarManager and csarManager.airframeDitched then + csarManager.airframeDitched(theUnit) + end + + limitedAirframes.updatePlayerInUnit(theUnit, "MIA") -- cosmetic only + limitedAirframes.createCSAR(theUnit) +end + + + +function limitedAirframes.pilotEjected(event) + local theUnit = event.initiator + -- do we want to check location? + -- no. if the user ejects, plane is done for + local theSide = theUnit:getCoalition() + local pilot = limitedAirframes.getKnownUnitPilotByUnit(theUnit) + local uName = theUnit:getName() + trigger.action.outTextForCoalition(theSide, "Pilot <" .. pilot .. "> ejected from " .. uName .. ", now MIA", 30) + + local hasLostTheWar = limitedAirframes.pilotLost(theUnit) + + limitedAirframes.updatePlayerInUnit(theUnit, "MIA") -- cosmetic only + -- create CSAR if applicable + if not hasLostTheWar then + limitedAirframes.createCSAR(theUnit) + end +end + +function limitedAirframes.pilotDied(theUnit) + --limitedAirframes.killPlayerInUnit(theUnit) + local theSide = theUnit:getCoalition() + local pilot = limitedAirframes.getKnownUnitPilotByUnit(theUnit) + local uName = theUnit:getName() + trigger.action.outTextForCoalition(theSide, "Pilot <" .. pilot .. "> is confirmed KIA while controlling " .. uName, 30) + limitedAirframes.pilotLost(theUnit) +end + +function limitedAirframes.pilotLost(theUnit) + -- returns true if lost the war + -- MUST NOT MESSAGE PILOT STATUS AS MIA CAN ALSO BE SET + -- first DELETE THE UNIT FROM player-owned unit table + -- so an empty crash after eject/death will not be counted as two losses + limitedAirframes.killPlayerInUnit(theUnit) + + -- now see if we are enabled to limit airframes + if not limitedAirframes.enabled then return false end + + -- find out which side lost the airframe and message side + local theSide = theUnit:getCoalition() + local pilot = limitedAirframes.getKnownUnitPilotByUnit(theUnit) + local uName = theUnit:getName() + --trigger.action.outTextForCoalition(theSide, "Pilot <" .. pilot .. "> is confirmed KIA while controlling " .. uName, 30) + + if theSide == 1 then -- red + theOtherSide = 2 + if limitedAirframes.maxRed < 0 then return false end -- disabled/infinite + + limitedAirframes.currRed = limitedAirframes.currRed - 1 + + if limitedAirframes.currRed == 0 then + trigger.action.outTextForCoalition(theSide, "\nYou have lost almost all of your pilots.\n\nWARNING: Losing any more pilots WILL FAIL THE MISSION\n", 30) + trigger.action.outSoundForCoalition(theSide, "Quest Snare 3.wav") + return false + end + + if limitedAirframes.currRed < 0 then + -- red have lost all airframes + trigger.action.outText("\nREDFORCE has lost all of their pilots.\n\nBLUEFORCE WINS!\n", 30) + trigger.action.outSoundForCoalition(theSide, "Death PIANO.wav") + trigger.action.outSoundForCoalition(theOtherSide, "Triumphant Victory.wav") + trigger.action.setUserFlag(limitedAirframes.blueWinsFlag, 1 ) + return true + end + + + elseif theSide == 2 then -- blue + theOtherSide = 1 + if limitedAirframes.maxBlue < 0 then return false end -- disabled/infinite + limitedAirframes.currBlue = limitedAirframes.currBlue - 1 + if limitedAirframes.currBlue == 0 then + trigger.action.outTextForCoalition(theSide, "\nYou have lost almost all of your pilots.\n\nWARNING: Losing any more pilots WILL FAIL THE MISSION\n", 30) + trigger.action.outSoundForCoalition(theSide, "Quest Snare 3.wav") + return false + end + if limitedAirframes.currBlue < 0 then + -- red have lost all airframes + trigger.action.outText("\nBLUEFORCE has lost all of their pilots.\n\nREDFORCE WINS!\n", 30) + trigger.action.setUserFlag(limitedAirframes.redWinsFlag, 1 ) + trigger.action.outSoundForCoalition(theSide, "Death PIANO.wav") + trigger.action.outSoundForCoalition(theOtherSide, "Triumphant Victory.wav") + return true + end + trigger.action.outSoundForCoalition(theSide, "Quest Snare 3.wav") + trigger.action.outTextForCoalition(theSide, "You have lost a pilot! Remaining: " .. limitedAirframes.currBlue, 30) + end + return false +end + +function limitedAirframes.checkPlayerFrameAvailability(event) + local theUnit = event.initiator + local theSide = theUnit:getCoalition() + if theSide == 1 then -- red + if limitedAirframes.maxRed < 0 then return end -- disabled/infinite + if limitedAirframes.currRed < 0 then + -- red have lost all airframes + trigger.action.outText("\nREDFORCE is a CHEATER!\n", 30) + return + end + elseif theSide == 2 then -- blue + if limitedAirframes.maxBlue < 0 then return end -- disabled/infinite + if limitedAirframes.currBlue < 0 then + -- red have lost all airframes + trigger.action.outText("\nBLUEFORCE is a CHEATER!\n", 30) + return + end + end +end + + +function limitedAirframes.createCSAR(theUnit) + -- only do this if we have installed CSAR Manager + if csarManager and csarManager.createCSARforUnit then + csarManager.createCSARforUnit(theUnit, + limitedAirframes.getKnownUnitPilotByUnit(theUnit), + 100) + end +end + +-- start up + +function limitedAirframes.addSafeZone(aZone) + if not aZone then + trigger.action.outText("WARNING: NIL Zone in addSafeZone", 30) + return + end + + -- transfer properties if they exist + -- blueSafe, redSafe: what side this is safe for, default = yes + + -- add zone to my list + limitedAirframes.safeZones[aZone] = aZone + aZone.redSafe = true + aZone.redSafe = cfxZones.getBoolFromZoneProperty(aZone, "redSafe", true) + aZone.blueSafe = true + aZone.blueSafe = cfxZones.getBoolFromZoneProperty(aZone, "blueSafe", true) + + trigger.action.outText("limAir: added safeZone " .. aZone.name, 30) +end + + + +-- +-- COMMAND & CONFIGURATION +-- +function limitedAirframes.setCommsMenu() + local desc = "Pilot Count (Currently ON)" + local desc2 = "Turn OFF Pilot Count (Cheat)?" + if not limitedAirframes.enabled then + desc = "Pilot Count (Currently OFF)" + desc2 = "ENABLE Pilot Count" + end + -- remove previous version + if limitedAirframes.rootMenu then + missionCommands.removeItem(limitedAirframes.theScore) + missionCommands.removeItem(limitedAirframes.theCommand) + missionCommands.removeItem(limitedAirframes.rootMenu) + end + limitedAirframes.theCommand = nil + limitedAirframes.rootMenu = nil + + -- add current version menu and command + limitedAirframes.rootMenu = missionCommands.addSubMenu(desc, nil) + + limitedAirframes.theScore = missionCommands.addCommand("How many airframes left?" , limitedAirframes.rootMenu, limitedAirframes.redirectAirframeScore, {"none"}) + + limitedAirframes.theCommand = missionCommands.addCommand(desc2 , limitedAirframes.rootMenu, limitedAirframes.redirectToggleAirFrames, {"none"}) + +end + +function limitedAirframes.redirectAirframeScore(args) + timer.scheduleFunction(limitedAirframes.doAirframeScore, args, timer.getTime() + 0.1) +end + +function limitedAirframes.doAirframeScore(args) + local redRemaining = "unlimited" + if limitedAirframes.maxRed >= 0 then + redRemaining = limitedAirframes.currRed .. " of " .. limitedAirframes.maxRed + if limitedAirframes.currRed < 1 then + redRemaining = "no" + end + end + + local blueRemaining = "unlimited" + if limitedAirframes.maxBlue >= 0 then + blueRemaining = limitedAirframes.currBlue .. " of " .. limitedAirframes.maxBlue + if limitedAirframes.currBlue < 1 then + blueRemaining = "no" + end + end + + local msg = "\nRED has " .. redRemaining .. " pilots left,\nBLUE has " .. blueRemaining .. " pilots left\n" + trigger.action.outText(msg, 30, true) + trigger.action.outSound("Quest Snare 3.wav") +end + +function limitedAirframes.redirectToggleAirFrames(args) + timer.scheduleFunction(limitedAirframes.doToggleAirFrames, args, timer.getTime() + 0.1) +end + +function limitedAirframes.doToggleAirFrames(args) + limitedAirframes.enabled = not limitedAirframes.enabled + limitedAirframes.setCommsMenu() + local desc = "\n\nPilot Count rule NOW IN EFFECT\n\n" + + if limitedAirframes.enabled then + trigger.action.outSound("Quest Snare 3.wav") + else + desc = "\n\nYou cowardly disabled Pilot Count\n\n" + trigger.action.outSound("Death PIANO.wav") + end + trigger.action.outText(desc, 30) + limitedAirframes.setCommsMenu() +end + +-- +-- CSAR CALLBACK +-- + +function limitedAirframes.pilotsRescued(theCoalition, success, numRescued, notes) + local availablePilots = 0 + if theCoalition == 1 then -- red + limitedAirframes.currRed = limitedAirframes.currRed + numRescued + if limitedAirframes.currRed > limitedAirframes.maxRed then + limitedAirframes.currRed = limitedAirframes.maxRed + end + availablePilots = limitedAirframes.currRed + if limitedAirframes.maxRed < 0 then + availablePilots = "unlimited" + end + end + + if theCoalition == 2 then -- blue + limitedAirframes.currBlue = limitedAirframes.currBlue + numRescued + if limitedAirframes.currBlue > limitedAirframes.maxBlue then + limitedAirframes.currBlue = limitedAirframes.maxBlue + end + availablePilots = limitedAirframes.currBlue + if limitedAirframes.maxBlue < 0 then + availablePilots = "unlimited" + end + end + trigger.action.outTextForCoalition(theCoalition, "\nPilots returned to flight line, you now have " .. availablePilots..".\n", 30) + trigger.action.outSoundForCoalition(theCoalition, "Quest Snare 3.wav") +end + +-- +-- START +-- + +function limitedAirframes.start() + if not dcsCommon.libCheck("cfx Limited Airframes", + limitedAirframes.requiredLibs) then + return false + end + + -- override config settings if defined as zone + limitedAirframes.readConfigZone() + + -- collect all zones that are airframe safe + local afsZones = cfxZones.zonesWithProperty("pilotSafe") + + -- now add all zones to my zones table, and init additional info + -- from properties + for k, aZone in pairs(afsZones) do + limitedAirframes.addSafeZone(aZone) + end + + -- connect player callback + -- install callbacks for airframe-related events + dcsCommon.addEventHandler(limitedAirframes.somethingHappened, limitedAirframes.preProcessor, limitedAirframes.postProcessor) + + + -- set current values + limitedAirframes.currRed = limitedAirframes.maxRed + limitedAirframes.currBlue = limitedAirframes.maxBlue + + -- collect active player unit names + local allPlayerUnits = cfxPlayer.getAllExistingPlayerUnitsRaw() + for i=1, #allPlayerUnits do + local aUnit = allPlayerUnits[i] + limitedAirframes.addPlayerUnit(aUnit) +-- trigger.action.outText("limAir: detected active player unit " .. aUnit:getName(), 30) + end + + + -- allow configuration menu + if limitedAirframes.userCanToggle then + limitedAirframes.setCommsMenu() + end + + -- connect to csarManager if present + if csarManager and csarManager.installCallback then + csarManager.installCallback(limitedAirframes.pilotsRescued) + trigger.action.outText("+++lim: connected to csar manager", 30) + else + trigger.action.outText("+++lim: NO CSAR integration", 30) + end + + -- say hi + trigger.action.outText("limitedAirframes v" .. limitedAirframes.version .. " started: R:".. limitedAirframes.maxRed .. "/B:" .. limitedAirframes.maxBlue, 30) + return true +end + +if not limitedAirframes.start() then + limitedAirframes = nil + trigger.action.outText("cf/x Limited Airframes aborted: missing libraries", 30) +end + + + +--[[-- + safe ditch: check airspeed and altitude. ditch only counts if less than 10m and 2 kts + report number of airframes left via second instance in switch off menu +--]]-- \ No newline at end of file diff --git a/modules/nameStats.lua b/modules/nameStats.lua new file mode 100644 index 0000000..c671a72 --- /dev/null +++ b/modules/nameStats.lua @@ -0,0 +1,218 @@ +nameStats = {} +nameStats.version = "1.1.0" +--[[-- + package that allows generic and global access to data, stored by + name and path. Can be used to manage score, cargo, statistics etc. + provides its own root by default, but modules can provide their own + private roots to be managed the same way. + + Uses a path metaphor to access fine grained levels + + version history + 1.0.0 - initial release + 1.1.0 - added table to leaf for more general + - added ability to use arbitrary roots + - setValue + - reset + - getAllPathes + 1.1.1 - simplified strings to a single string + - left old logic intact for log extensions + - setString() + +--]]-- +-- statistics container. everything is in here + +nameStats.stats = {} + +--[[-- + to access data, there are the following principles: + - name: uniquely defines a branch where all data for this name + is stored. MANDATORY, MUST NEVER BE nil + this is usually a unit's name + - path: in each branch you can have a path (string) to the data + to more precisely define. for example, you can have separate + paths 'score' and 'weight' under the same name + There currently is no logic attached to a path except that + it must be unique inside the same branch + OPTIONAL. if omittted, a default data set is returned + - rootNode: OPTIONAL storage (table) you can pass to create your own + pricate storage space that can't be accessed unless the + invoking method also passes the same rootNode + + Data + Data is stored in a "leaf node" that has three properties + - value: a numerical value that can be set and changed + - string: a strings that can be set and added to + - table: a table that you can treat as you like and that + is never looked into nor changed by this module + +--]]-- + +function nameStats.getAllNames(theRootNode) -- root node is optional + if not theRootNode then theRootNode = nameStats.stats end + local allNames = {} + for name, entry in pairs(theRootNode) do + table.insert(allNames, name) + end + return allNames +end + +function nameStats.getAllPathes(name, theRootNode) + if not theRootNode then theRootNode = nameStats.stats end + local allPathes = {} + local theEntry = theRootNode[name] + if not theEntry then + return allPathes + end + + for pathName, data in pairs(theEntry.data) do + table.insert(allPathes, pathName) + end + + return allPathes +end + +-- change the numerical value by delta. use negative numbers to decrease +function nameStats.changeValue(name, delta, path, rootNode) + if not name then return nil end + local theLeaf = nameStats.getLeaf(name, path, rootNode) + theLeaf.value = theLeaf.value + delta + return theLeaf.value +end + +-- set to a specific value +function nameStats.setValue(name, newVal, path, rootNode) + if not name then return nil end + local theLeaf = nameStats.getLeaf(name, path, rootNode) + theLeaf.value = newVal + return theLeaf.value +end + +-- add a string to the log +function nameStats.addString(name, aString, path, rootNode) + if not name then return nil end + if not aString then return nil end + local theLeaf = nameStats.getLeaf(name, path, rootNode) + --table.insert(theLeaf.strings, aString) + theLeaf.strings = theLeaf.strings .. aString +-- return aString +end + +-- reset the log +function nameStats.removeAllString(name, path, rootNode) + if not name then return nil end + local theLeaf = nameStats.getLeaf(name, path, rootNode) +-- theLeaf.strings = {} + theLeaf.strings = "" +end + +function nameStats.setString(name, aString, path, rootNode) + if not name then return nil end + local theLeaf = nameStats.getLeaf(name, path, rootNode) + theLeaf.strings = aString +end + +-- set the table variable +function nameStats.setTable(name, path, aTable, rootNode) + if not name then return end + local theLeaf = nameStats.getLeaf(name, path, rootNode) + theLeaf.theTable = aTable +end + +-- get the numerical value associated with name, path +function nameStats.getValue(name, path, rootNode) -- allocate if not exist + if not name then return nil end + local theLeaf = nameStats.getLeaf(name, path, rootNode) + return theLeaf.value +end + +-- get the log associated with name, path +function nameStats.getStrings(name, path, rootNode) + if not name then return nil end + local theLeaf = nameStats.getLeaf(name, path, rootNode) + return theLeaf.strings +end + +-- alias for compatibility reasons +function nameStats.getString(name, path, rootNode) + return nameStats.getStrings(name, path, rootNode) +end + +-- get the table stored under name, path. +function nameStats.getTable(name, path, rootNode) + if not name then return nil end + local theLeaf = nameStats.getLeaf(name, path, rootNode) + return theLeaf.theTable +end + +-- reset whatever is stored under name, path +-- WARNING: passing nil path will entirely reset the whole name +function nameStats.reset(name, path, rootNode) + if not name then return nil end + if not rootNode then rootNode = nameStats.stats end + local theEntry = rootNode[name] + if not theEntry then + -- does not yet exist, create a root entry + theEntry = nameStats.createRoot(name) + rootNode[name] = theEntry + nameStats.getLeaf(name, path, rootNode) -- will alloc an empty leaf + return -- done + end + if not path then -- will delete everything!!! + theEntry = nameStats.createRoot(name) + rootNode[name] = theEntry + return + end + -- create new leaf and replace existing + theLeaf = nameStats.createLeaf() + theEntry.data[path] = theLeaf + +end + +-- +-- +-- private function +-- +-- +function nameStats.getLeaf(name, path, rootNode) + if not name then return nil end + if not rootNode then rootNode = nameStats.stats end + -- will allocate if not existlocal theEntry = nameStats.stats[name] + local theEntry = rootNode[name] + if not theEntry then + -- does not yet exist, create a root entry + theEntry = nameStats.createRoot(name) + rootNode[name] = theEntry + end + -- from here on, the entry exists + if not path then return theEntry.defaultLeaf end + + -- access via path + local theLeaf = theEntry.data[path] + if not theLeaf then + theLeaf = nameStats.createLeaf() + theEntry.data[path] = theLeaf + end + return theLeaf +end + +function nameStats.createLeaf() + local theLeaf = {} + theLeaf.value = 0 + theLeaf.strings = "" + theLeaf.log = {} -- was strings + theLeaf.theTable = {} + return theLeaf +end + +-- for each entry in stats, this is the root container +function nameStats.createRoot(name) + local theRoot = {} -- all nodes are in here + theRoot.name = name + theRoot.data = {} -- dict by path for leafs + theRoot.defaultLeaf = nameStats.createLeaf() + return theRoot +end + +-- say hi! +trigger.action.outText("cf/x NameStats v" .. nameStats.version .. " loaded", 30) diff --git a/modules/parashoo.lua b/modules/parashoo.lua new file mode 100644 index 0000000..f55e209 --- /dev/null +++ b/modules/parashoo.lua @@ -0,0 +1,36 @@ +parashoo = {} +parashoo.version = "1.1.0" +--[[-- + VERSION HISTORY + - 1.0.0 initial version + - 1.1.0 wait 3 minutes before destroying para + guy, else KIA reported when player still + in pilot + +--]]-- +parashoo.killDelay = 3 * 60 -- 3 minutes delay + +function parashoo.removeGuy(args) + local theGuy = args.theGuy + if theGuy and theGuy:isExist() then + Unit.destroy(theGuy) + end +end + + +-- remove parachuted pilots after landing +function parashoo:onEvent(event) + if event.id == 31 then -- landing_after_eject + if event.initiator then + + local args = {} + args.theGuy = event.initiator + timer.scheduleFunction(parashoo.removeGuy, args, timer.getTime() + parashoo.killDelay) + --Unit.destroy(event.initiator) -- old direct remove + end + end +end + +-- add event handler +world.addEventHandler(parashoo) +trigger.action.outText("parashoo v" .. parashoo.version .. " loaded.", 30) diff --git a/tutorial & demo missions/demo - 000 smoke em! DML intro.miz b/tutorial & demo missions/demo - 000 smoke em! DML intro.miz new file mode 100644 index 0000000..aa83fcf Binary files /dev/null and b/tutorial & demo missions/demo - 000 smoke em! DML intro.miz differ diff --git a/tutorial & demo missions/demo - ADF and NDB fun.miz b/tutorial & demo missions/demo - ADF and NDB fun.miz new file mode 100644 index 0000000..c8afbe7 Binary files /dev/null and b/tutorial & demo missions/demo - ADF and NDB fun.miz differ diff --git a/tutorial & demo missions/demo - DML mission template.miz b/tutorial & demo missions/demo - DML mission template.miz new file mode 100644 index 0000000..351c3d1 Binary files /dev/null and b/tutorial & demo missions/demo - DML mission template.miz differ diff --git a/tutorial & demo missions/demo - Event Monitor.miz b/tutorial & demo missions/demo - Event Monitor.miz new file mode 100644 index 0000000..9c7ebd5 Binary files /dev/null and b/tutorial & demo missions/demo - Event Monitor.miz differ diff --git a/tutorial & demo missions/demo - Landing Counter.miz b/tutorial & demo missions/demo - Landing Counter.miz new file mode 100644 index 0000000..1c8ad6f Binary files /dev/null and b/tutorial & demo missions/demo - Landing Counter.miz differ diff --git a/tutorial & demo missions/demo - ME triggered spawn.miz b/tutorial & demo missions/demo - ME triggered spawn.miz new file mode 100644 index 0000000..8d1acb5 Binary files /dev/null and b/tutorial & demo missions/demo - ME triggered spawn.miz differ diff --git a/tutorial & demo missions/demo - Owned Zones ME integration.miz b/tutorial & demo missions/demo - Owned Zones ME integration.miz new file mode 100644 index 0000000..1755277 Binary files /dev/null and b/tutorial & demo missions/demo - Owned Zones ME integration.miz differ diff --git a/tutorial & demo missions/demo - artillery with UI.miz b/tutorial & demo missions/demo - artillery with UI.miz new file mode 100644 index 0000000..eba3f6b Binary files /dev/null and b/tutorial & demo missions/demo - artillery with UI.miz differ diff --git a/tutorial & demo missions/demo - artillery zones triggered.miz b/tutorial & demo missions/demo - artillery zones triggered.miz new file mode 100644 index 0000000..fe88f65 Binary files /dev/null and b/tutorial & demo missions/demo - artillery zones triggered.miz differ diff --git a/tutorial & demo missions/demo - helo cargo.miz b/tutorial & demo missions/demo - helo cargo.miz new file mode 100644 index 0000000..09f7552 Binary files /dev/null and b/tutorial & demo missions/demo - helo cargo.miz differ diff --git a/tutorial & demo missions/demo - helo trooper.miz b/tutorial & demo missions/demo - helo trooper.miz new file mode 100644 index 0000000..98b8526 Binary files /dev/null and b/tutorial & demo missions/demo - helo trooper.miz differ diff --git a/tutorial & demo missions/demo - missile evasion (guardian angel).miz b/tutorial & demo missions/demo - missile evasion (guardian angel).miz new file mode 100644 index 0000000..4283fab Binary files /dev/null and b/tutorial & demo missions/demo - missile evasion (guardian angel).miz differ diff --git a/tutorial & demo missions/demo - moving spawners.miz b/tutorial & demo missions/demo - moving spawners.miz new file mode 100644 index 0000000..c6ed24e Binary files /dev/null and b/tutorial & demo missions/demo - moving spawners.miz differ diff --git a/tutorial & demo missions/demo - object destruct detection.miz b/tutorial & demo missions/demo - object destruct detection.miz new file mode 100644 index 0000000..a3354ed Binary files /dev/null and b/tutorial & demo missions/demo - object destruct detection.miz differ diff --git a/tutorial & demo missions/demo - player score.miz b/tutorial & demo missions/demo - player score.miz new file mode 100644 index 0000000..3753691 Binary files /dev/null and b/tutorial & demo missions/demo - player score.miz differ diff --git a/tutorial & demo missions/demo - recon mode.miz b/tutorial & demo missions/demo - recon mode.miz new file mode 100644 index 0000000..f1f8153 Binary files /dev/null and b/tutorial & demo missions/demo - recon mode.miz differ diff --git a/tutorial & demo missions/demo - spawnZones (training and lasing).miz b/tutorial & demo missions/demo - spawnZones (training and lasing).miz new file mode 100644 index 0000000..c7339bb Binary files /dev/null and b/tutorial & demo missions/demo - spawnZones (training and lasing).miz differ diff --git a/tutorial & demo missions/distressbeacon.ogg b/tutorial & demo missions/distressbeacon.ogg new file mode 100644 index 0000000..6f725e2 Binary files /dev/null and b/tutorial & demo missions/distressbeacon.ogg differ diff --git a/tutorial & demo missions/dml skeleton mission code.lua b/tutorial & demo missions/dml skeleton mission code.lua new file mode 100644 index 0000000..a84fcbb --- /dev/null +++ b/tutorial & demo missions/dml skeleton mission code.lua @@ -0,0 +1,97 @@ +dmlMain = {} +-- +-- DML-based mission skeleton with event loop +-- +dmlMain.ups = 1 -- 1 update per second +-- minimal libraries required +dmlMain.requiredLibs = { + "dcsCommon", -- minimal module for all + "cfxZones", -- Zones, of course + "cfxPlayer", -- player events +} + +dmlMain.config = {} -- for reading config zones + +-- +-- world event handling +-- +function dmlMain.wPreProc(event) + return true -- true means invoke worldEventHanlder() + -- filter here and return false if the event is to be ignored +end + +function dmlMain.worldEventHandler(event) + -- now analyse table and do stuff + trigger.action.outText("DCS World Event " .. event.id .. " (" .. dcsCommon.event2text(event.id) .. ") received", 30) +end + +-- +-- player event handling +-- +function dmlMain.playerEventHandler (evType, description, info, data) + trigger.action.outText("DML Player Event " .. evType .. " received", 30) +end + + +-- +-- update loop +-- +function dmlMain.update() + -- schedule myself in 1/ups seconds + timer.scheduleFunction(dmlMain.update, {}, timer.getTime() + 1/dmlMain.ups) + + -- perform any regular checks here in your main loop + +end + +-- +-- read configuration from zone 'dmlMainConfig' +-- + +function dmlMain.readConfiguration() + local theZone = cfxZones.getZoneByName("dmlMainConfig") + if not theZone then return end + dmlMain.config = cfxZones.getAllZoneProperties(theZone) + -- demo: dump all name/value pairs returned + trigger.action.outText("DML config read from config zone:", 30) + for name, value in pairs(dmlMain.config) do + trigger.action.outText(name .. ":" .. value, 30) + end + trigger.action.outText("---- (end of list)", 30) + +end + +-- +-- start +-- +function dmlMain.start() + -- ensure that all modules have loaded + if not dcsCommon.libCheck("DML Main", + dmlMain.requiredLibs) then + return false + end + + -- read any configuration values placed in a config zone on the map + dmlMain.readConfiguration() + + -- subscribe to world events + dcsCommon.addEventHandler(dmlMain.worldEventHandler, + dmlMain.wPreProc) -- no post nor rejected + + -- subscribe to player events + cfxPlayer.addMonitor(dmlMain.playerEventHandler) + + + -- start the event loop. it will sustain itself + dmlMain.update() + + -- say hi! + trigger.action.outText("DML Main mission running!", 30) + return true +end + +-- start main +if not dmlMain.start() then + trigger.action.outText("Main mission failed to run", 30) + dmlMain = nil +end diff --git a/tutorial & demo missions/ldgCtr.lua b/tutorial & demo missions/ldgCtr.lua new file mode 100644 index 0000000..5c06e21 --- /dev/null +++ b/tutorial & demo missions/ldgCtr.lua @@ -0,0 +1,104 @@ +ldgCtr = {} +-- +-- DML-based mission that counts the number of +-- successful missions for all players. MP-capable +-- + +ldgCtr.ups = 1 -- 1 update per second +-- minimal libraries required +ldgCtr.requiredLibs = { + "dcsCommon", -- minimal module for all + "cfxZones", -- Zones, of course + "cfxPlayer", -- player events +} + +ldgCtr.config = {} -- for reading config zones +ldgCtr.landings = {} -- set all landings to 0 +-- +-- world event handling +-- +function ldgCtr.wPreProc(event) + return event.id == 4 -- look for 'landing event' +end + +function ldgCtr.worldEventHandler(event) + -- wPreProc filters all events EXCEPT landing + local theUnit = event.initiator + local uName = theUnit:getName() + local playerName = theUnit:getPlayerName() + trigger.action.outText(uName .. " has landed.", 30) + if playerName then + -- if a player landed, count their landing + local numLandings = ldgCtr.landings[playerName] + if not numLandings then numLandings = 0 end + numLandings = numLandings + 1 + ldgCtr.landings[playerName] = numLandings + trigger.action.outText("Player " .. playerName .. " completed ".. numLandings .." landings.", 30) + end +end + +-- +-- player event handling +-- +function ldgCtr.playerEventHandler (evType, description, info, data) + -- not needed +end + + +-- +-- update loop +-- +function ldgCtr.update() + -- schedule myself in 1/ups seconds + timer.scheduleFunction(ldgCtr.update, {}, timer.getTime() + 1/ldgCtr.ups) + + -- no regular checks needed +end + +-- +-- read configuration from zone 'ldgCtrConfig' +-- + +function ldgCtr.readConfiguration() + local theZone = cfxZones.getZoneByName("ldgCtrConfig") + if not theZone then return end + ldgCtr.config = cfxZones.getAllZoneProperties(theZone) +end + +-- +-- start +-- +function ldgCtr.start() + -- ensure that all modules have loaded + if not dcsCommon.libCheck("Landing Counter", + ldgCtr.requiredLibs) then + return false + end + + -- read any configuration values placed in a config zone on the map + ldgCtr.readConfiguration() + + -- init variables & state + ldgCtr.landings = {} + + -- subscribe to world events + dcsCommon.addEventHandler(ldgCtr.worldEventHandler, + ldgCtr.wPreProc) -- no post nor rejected + + -- subscribe to player events + cfxPlayer.addMonitor(ldgCtr.playerEventHandler) + + + -- start the event loop. it will sustain itself + ldgCtr.update() + + -- say hi! + trigger.action.outText("Landing Counter mission running!", 30) + return true +end + +-- start main +if not ldgCtr.start() then + trigger.action.outText("Landing Counter failed to run", 30) + ldgCtr = nil +end