DML/modules/artilleryUI.lua
Christian Franz c9694c3176 Version 2.0.0
Version II goes live!
2024-01-11 10:20:59 +01:00

707 lines
22 KiB
Lua

cfxArtilleryUI = {}
cfxArtilleryUI.version = "2.0.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 - 2024 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.
- 2.0.0 - dmlZones, OOP
cleanup
--]]--
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
return
elseif not conf.tgtCheckSum then
else
trigger.action.outTextForGroup(conf.id, "Artillery target updates", 30)
trigger.action.outSoundForGroup(conf.id, cfxArtilleryUI.updateSound)
end
-- we need to re-populate. erase old values
cfxArtilleryUI.clearCommsTargets(conf)
conf.tgtCheckSum = tgtCheckSum -- remember for last time
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
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"
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 = aZone:getPoint() -- 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
theZone = cfxZones.createSimpleZone("ArtilleryUIConfig")
end
cfxArtilleryUI.verbose = theZone.verbose
cfxArtilleryUI.allowPlanes = theZone:getBoolFromZoneProperty("allowPlanes", false)
cfxArtilleryUI.smokeColor = theZone:getSmokeColorStringFromZoneProperty("smokeColor", "red")
cfxArtilleryUI.allSeeing = theZone:getBoolFromZoneProperty("allSeeing", false)
cfxArtilleryUI.allRanging = theZone:getBoolFromZoneProperty("allRanging", false)
cfxArtilleryUI.allTiming = theZone:getBoolFromZoneProperty("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
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
TODO: remove dependency on cfxPlayer
--]]--