Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- function widget:GetInfo()
- return {
- name = "Combat Advisor",
- desc = "Five panels answering one question at five scales, in a single widget: "
- .. "what did my scout just find, what does the enemy have, which factory "
- .. "should I be running against it, what should I build next, and should I "
- .. "take this fight. Enemy Intel "
- .. "remembers every enemy unit you have seen and subtracts the ones you have "
- .. "watched die, with a strip weighing your army against it and the "
- .. "remembered army marked on the minimap. Build Proposer ranks what to build next against that army, "
- .. "through a counter matrix over Zero-K's unit archetypes, and will queue the "
- .. "picks for you. Fac Switcher puts the same matrix to whole factories and "
- .. "names the three whose rosters best cover that army. Engagement Predictor "
- .. "takes a selection, gathers the fight around it and reports a win "
- .. "probability, and rings that fight and every other front on the map in "
- .. "its colour. Scout Cam opens a viewer down the left edge the first time a "
- .. "scout finds a major enemy structure, counts down enemy superweapons, can "
- .. "mark enemy anti-nukes on the map with their build progress, and rings "
- .. "your key buildings an enemy nuke could reach. Build Herald puts a map "
- .. "marker on every large unit or building your team completes, on the "
- .. "first enemy unit of each new type and class, and on your eco when it is "
- .. "raided. Each panel shows and "
- .. "hides on its own, "
- .. "from the one page at Settings/HUD Panels/Combat Advisor.",
- author = "Thomas",
- date = "2026",
- license = "GPL-v2",
- layer = 0,
- enabled = false, -- enable from the F11 widget list
- }
- end
- --------------------------------------------------------------------------------
- -- One widget, one panel per module
- --
- -- Enemy Intel, Build Proposer and Engagement Predictor were three widgets that were only
- -- ever useful together: the second ranks builds against the army the first remembers, and
- -- the third scores a fight with the matrix the second publishes. Merged, they load and
- -- unload as one and cannot be left half-wired. Fac Switcher was written here rather than
- -- as a fourth file, for the same reason: it is a third reader of those same two tables.
- -- Scout Cam joins them on a weaker claim -- it reads neither table -- but it is answering
- -- the same question at the scouting end of it, and a player who wants the advisor wants to
- -- be told what the scout found.
- --
- -- Each module keeps its own `do ... end` block, and that is structural rather than
- -- decorative. Lua allows 200 locals per function and these bodies declare well over 300
- -- between them at file scope, so a flat concatenation does not compile. A block releases
- -- its locals at its `end`, so they never hold registers at the same time, while the
- -- closures each one registers below keep whatever they captured alive as upvalues --
- -- exactly the arrangement each had as its own chunk. The three merged bodies are otherwise
- -- untouched, indentation included, so each still diffs against the widget it came from.
- --
- -- They still speak to each other through WG.EnemyIntel and WG.CounterMatrix rather than
- -- reaching across the blocks. That is the published contract: the consumers are written to
- -- degrade gracefully when either is missing, other widgets may read them, and a merge is
- -- no reason to grow a second, private path saying the same thing. It is also why a new
- -- module goes in below the producers rather than beside them -- registration order is
- -- Initialize order forwards and Shutdown order backwards, so every consumer is gone before
- -- the thing it consumes.
- --------------------------------------------------------------------------------
- local modules = {} -- in load order; Shutdown walks it backwards
- -- One widget, one settings page. Every option lands here in module order, each run opening
- -- with a sub-header naming the panel it belongs to -- `type = 'label'` is epicmenu's own
- -- section divider, and the page a module used to own supplies the name.
- --
- -- Keys still carry a module prefix, because EPIC Menu files every option of one widget
- -- under `epic_<widget name>_<option key>` in one flat namespace, and three modules bring
- -- three `updateRate`s between them. Display names are disambiguated in the modules
- -- themselves, so that a name still reads correctly in epicmenu's search and in the hotkey
- -- list, where there is no section header standing above it.
- local OPTIONS_PATH = 'Settings/HUD Panels/Combat Advisor'
- local allOptions, allOptionsOrder = {}, {}
- local function Add(key, opt)
- allOptions[key] = opt
- allOptionsOrder[#allOptionsOrder + 1] = key
- end
- local function RegisterModule(def)
- modules[#modules + 1] = def.callins
- -- `noSave`, because a section header has nothing to persist and its caption lives in
- -- the same `value` field epicmenu would otherwise try to load a saved setting into.
- local title = def.optionsPath:match("([^/]+)$")
- Add(def.prefix .. "section", {type = 'label', name = title, value = title, noSave = true})
- for i = 1, #def.optionsOrder do
- local key = def.optionsOrder[i]
- Add(def.prefix .. key, def.options[key])
- end
- end
- --------------------------------------------------------------------------------
- -- Enemy Intel
- --
- -- Was gui_enemy_intel.lua.
- --------------------------------------------------------------------------------
- do
- local M = {} -- this module's callins, collected by RegisterModule below
- local options_path, options_order, options -- were file-scope globals
- --------------------------------------------------------------------------------
- -- Includes / static per-unitDef data
- --------------------------------------------------------------------------------
- -- Tactical icon resolution, same pattern as gui_contextmenu.lua / gui_icontransition.lua
- local iconTypesPath = LUAUI_DIRNAME .. "Configs/icontypes.lua"
- local icontypes = VFS.FileExists(iconTypesPath) and VFS.Include(iconTypesPath)
- local iconFormat = ".dds"
- do
- -- chilitip_conf.lua returns: ignoreweapon, iconFormat
- local ok, _, fmt = pcall(VFS.Include, LUAUI_DIRNAME .. "Configs/chilitip_conf.lua", nil, VFS.ZIP)
- if ok and fmt then
- iconFormat = fmt
- end
- end
- local spGetUnitDefID = Spring.GetUnitDefID
- local spGetUnitTeam = Spring.GetUnitTeam
- local spGetUnitIsDead = Spring.GetUnitIsDead
- local spGetAllUnits = Spring.GetAllUnits
- local spGetTeamInfo = Spring.GetTeamInfo
- local spGetMyAllyTeamID = Spring.GetMyAllyTeamID
- local spGetTeamList = Spring.GetTeamList
- local spGetUnitPosition = Spring.GetUnitPosition
- local spGetGameSeconds = Spring.GetGameSeconds
- local spGetAllFeatures = Spring.GetAllFeatures
- local spGetFeatureDefID = Spring.GetFeatureDefID
- local spGetFeatureTeam = Spring.GetFeatureTeam
- local spGetFeatureAllyTeam = Spring.GetFeatureAllyTeam
- local spGetFeaturePosition = Spring.GetFeaturePosition
- local spGetTeamUnits = Spring.GetTeamUnits
- local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt
- local spGetMyTeamID = Spring.GetMyTeamID
- local iconFile = {}
- local unitCost = {}
- local unitName = {}
- local isStaticDef = {}
- local isCommDef = {} -- every chassis and level is its own def; the table shows them as one
- -- Units that are not intel about anybody's army. Zero-K models terraforming as a unit:
- -- every patch of ground being levelled is a static, permanently unfinished `terraunit`
- -- called Terraform, flagged `dontcount` and priced at 100000 metal -- so counting it put a
- -- blank icon at the top of the Static column for every enemy ramp. The same flag covers
- -- fakeunit_los, the Planet Wars props, starlight_satellite, statictempshield, tele_beacon
- -- and wolverine_mine. Drones carry `is_drone` and are gated with it in Zero-K's own
- -- widgets; they are left in here, because a carrier's drones do shoot at you.
- local notIntel = {}
- -- Which bar of the army balance strip a unit type fills: "ground", "air" or "defence", or
- -- nil for everything that is not fighting strength -- constructors, economy, radar, the
- -- factories themselves. Armed means it carries a weapon at all, so an armed builder such
- -- as the Funnelweb, and every commander, counts as ground. The minimap ghosts draw the
- -- same ground and air set: the remembered army, not the remembered builders.
- local bucketOf = {}
- local DEFAULT_ICON = "icons/default.dds"
- for defID = 1, #UnitDefs do
- local ud = UnitDefs[defID]
- local it = ud.iconType or "default"
- local path = (icontypes and icontypes[it] and icontypes[it].bitmap)
- or ("icons/" .. it .. iconFormat)
- -- Zero-K's own square build texture, which the engine renders from the model, rather
- -- than icons/default.dds -- that file is fully transparent, so a missing icon drew a
- -- blank box with a count beside it (submarine, none, the legacy tank/kbot set).
- if not VFS.FileExists(path) then
- path = "#" .. defID
- end
- iconFile[defID] = path
- unitCost[defID] = ud.metalCost or 0
- unitName[defID] = ud.humanName or ud.name or "?"
- isStaticDef[defID] = (ud.speed or 0) == 0
- local cp = ud.customParams
- isCommDef[defID] = cp and (cp.dynamic_comm or cp.commtype) and true or nil
- -- customParams arrive stringified, so a flag that is present but off is the string
- -- "false", which is truthy in Lua.
- local dc = cp and cp.dontcount
- notIntel[defID] = (dc and dc ~= "false" and dc ~= "0") and true or nil
- if not notIntel[defID] and #(ud.weapons or {}) > 0 and not ud.isFactory then
- if isStaticDef[defID] then
- bucketOf[defID] = "defence"
- else
- bucketOf[defID] = ud.canFly and "air" or "ground"
- end
- end
- end
- -- Wreck/debris featureDef -> the unitDef it came from. Same resolution Zero-K's own
- -- cursortip uses (gui_chili_selections_and_cursortip.lua): an explicit customParams.unit
- -- when the def declares one, otherwise strip the trailing '_dead' / '_dead2' / '_heap'.
- -- Defs that resolve to no unit (map features, module_wreck_N for dynamic commanders) are
- -- simply absent from the table and get ignored downstream.
- local wreckUnitDef = {}
- for fdID, fd in pairs(FeatureDefs) do
- if type(fdID) == "number" then
- local uName = (fd.customParams and fd.customParams.unit)
- or (fd.name and fd.name:gsub("(.*)_.*", "%1"))
- local ud = uName and UnitDefNames[uName]
- if ud then
- wreckUnitDef[fdID] = ud.id
- end
- end
- end
- --------------------------------------------------------------------------------
- -- State (declared before `options` so the option closures can capture them)
- --------------------------------------------------------------------------------
- local seen = {} -- unitID -> unitDefID, every enemy unit observed minus confirmed deaths
- local lastSeen = {} -- unitID -> game time (s) this unit was last in sight or on radar
- local counts = {} -- unitDefID -> count
- -- The same tally split by owner, so a consumer can answer one opponent's army rather than
- -- the whole enemy side. Kept beside `counts` rather than replacing it: every reader that
- -- does not care who owns what would otherwise have to sum this back up on every pass.
- local teamOf = {} -- unitID -> the enemy team that owns it
- local countsByTeam = {} -- teamID -> {unitDefID -> count}
- local dirty = true
- local needFullRebuild = false
- local lastSweep = 0 -- game time (s) of the last full sweep, published for WG consumers
- -- Wreck-derived intel. Deductions are kept apart from `counts` on purpose: the 5s sweep
- -- re-adds any live unit missing from `seen`, so mutating `seen` to deduct would just be
- -- undone on the next pass.
- local visibleByDef = {} -- unitDefID -> enemies of that type in LOS as of the last sweep
- local wreckDeduct = {} -- unitDefID -> deaths inferred from wrecks
- local knownFeature = {} -- featureID -> featureDefID, unit wrecks already accounted for
- local ignoredFeature = {} -- featureID -> featureDefID, map/allied/unresolvable, skip cheaply
- local deathSites = {} -- unitDefID -> array of {x, z, clock, absorbed = {featureDefID}}
- local gaiaTeamID = -1
- -- Wrecks slide around under feature_debris_physics, so a corpse is matched to its death
- -- within this radius rather than exactly.
- local MATCH_RADIUS = 48
- local MATCH_RADIUS_SQ = MATCH_RADIUS * MATCH_RADIUS
- -- Only sites that never received a corpse expire, and even then slowly. Corpses are found
- -- by polling what is in LOS, so the wreck of a kill you witnessed and then walked away
- -- from is not seen until you return -- possibly many minutes later. Expiring the site
- -- before then would let that corpse deduct a death already counted. A site that did
- -- absorb a corpse is kept for the game, since the corpse can decay into debris much later
- -- and the site is the only thing stopping the debris being counted as a second death.
- local BARREN_SITE_MAX_AGE = 900
- local MAX_SITES_PER_DEF = 256
- local myAllyTeamID = spGetMyAllyTeamID()
- local teamAlly = {}
- -- The army balance strip. `mine` is summed from our own units in the 5 s sweep; the enemy
- -- side is read from GetEffectiveCounts at layout time, so it can never disagree with the
- -- rows beneath it. `rows` holds the Chili controls, one per bucket.
- local BUCKETS = {"ground", "air", "defence"}
- local balance = {mine = {}, rows = {}}
- -- Last known positions of remembered enemy mobiles, for the minimap. `pos[unitID]` is
- -- {x, z, t}, refreshed once a second while the engine still gives a position (sight or
- -- radar) and left standing when it stops. `list` is what DrawInMiniMap draws: the ones out
- -- of sight and radar, bucketed, rebuilt on the same second.
- local ghosts = {pos = {}, list = {}, next = 0}
- local ResetIntel -- forward declaration, defined below
- local SetPanelVisible -- forward declaration, defined below
- --------------------------------------------------------------------------------
- -- Options (auto-registered into the settings menu, persisted to ZK_data.lua)
- --------------------------------------------------------------------------------
- options_path = 'Settings/HUD Panels/Enemy Intel'
- options_order = {'showPanel', 'updateRate', 'memoryTime', 'iconSize', 'minCount', 'sortMode', 'showStatic', 'staticFirst', 'hideCons', 'useWrecks', 'showBalance', 'balanceSide', 'minimapGhosts', 'reset'}
- options = {
- showPanel = {
- name = 'Show Enemy Intel panel',
- desc = 'Hide the on-screen table without unloading the widget. Intel is still tracked '
- .. 'while hidden, so the counts are right the moment you bring it back, and the '
- .. 'Build Proposer keeps reading them through WG.EnemyIntel either way.',
- type = 'bool',
- value = true,
- OnChange = function(self)
- SetPanelVisible(self.value)
- end,
- },
- updateRate = {
- name = 'Intel update interval (s)',
- desc = 'How often the table redraws. Higher is cheaper.',
- type = 'number',
- min = 0.1, max = 3, step = 0.1, value = 0.1,
- },
- memoryTime = {
- name = 'Forget unseen units after (min)',
- desc = 'A remembered unit is dropped once this long has passed without seeing it again. '
- .. 'Units currently in sight never expire.',
- type = 'number',
- min = 1, max = 60, step = 1, value = 10,
- OnChange = function()
- dirty = true
- end,
- },
- iconSize = {
- name = 'Icon size',
- type = 'number',
- min = 16, max = 64, step = 2, value = 28,
- OnChange = function()
- needFullRebuild = true
- end,
- },
- minCount = {
- name = 'Hide unit types below N',
- desc = 'Suppress rows with fewer than this many known units.',
- type = 'number',
- min = 1, max = 20, step = 1, value = 1,
- OnChange = function()
- dirty = true
- end,
- },
- sortMode = {
- name = 'Sort by',
- type = 'radioButton',
- value = 'value',
- items = {
- {key = 'value', name = 'Metal value', desc = 'Biggest threat first.'},
- {key = 'count', name = 'Count', desc = 'Most numerous first.'},
- },
- OnChange = function()
- dirty = true
- end,
- },
- showStatic = {
- name = 'Show Static section',
- desc = 'Show the buildings/turrets section below the mobile army.',
- type = 'bool',
- value = true,
- OnChange = function()
- dirty = true
- end,
- },
- hideCons = {
- name = 'Hide enemy constructors',
- desc = 'Leave mobile constructors (and Athenas) out of the table; Caretakers and '
- .. 'factories stay. They are still counted, so Build Proposer '
- .. 'and the other panels see them exactly as before; this only takes their icons '
- .. 'off the panel.',
- type = 'bool',
- value = true,
- OnChange = function()
- dirty = true
- end,
- },
- staticFirst = {
- name = 'Static column first',
- desc = 'Swap the two columns, so buildings/turrets sit on the left and the mobile '
- .. 'army on the right.',
- type = 'bool',
- value = true,
- OnChange = function()
- dirty = true
- end,
- },
- useWrecks = {
- name = 'Deduct from wrecks',
- desc = 'Scouting an enemy wreck proves the unit died, even if you never saw the kill. '
- .. 'Turn off to count only kills you witnessed.',
- type = 'bool',
- value = true,
- OnChange = function()
- dirty = true
- end,
- },
- showBalance = {
- name = 'Show army balance strip',
- desc = 'Three bars above the table comparing fighting strength in metal: ground, air '
- .. 'and static defence. Green is your side, red is the enemy army you remember. '
- .. 'Constructors, economy and factories are left out of both sides.',
- type = 'bool',
- value = true,
- OnChange = function()
- dirty = true
- end,
- },
- balanceSide = {
- name = 'My side of the balance is',
- desc = 'Whose army the balance strip puts against the enemy. The enemy side is '
- .. 'everything your team has seen, so your whole team is the like-for-like '
- .. 'comparison.',
- type = 'radioButton',
- value = 'team',
- items = {
- {key = 'team', name = 'My whole team', desc = 'You and your allies.'},
- {key = 'me', name = 'Only me', desc = 'Just your own units.'},
- },
- OnChange = function()
- balance.due = true
- end,
- },
- minimapGhosts = {
- name = 'Show remembered enemy army on the minimap',
- desc = 'Mark on the minimap where remembered enemy army units were last seen, once '
- .. 'they are out of sight and off radar. Nearby units share one mark, sized by '
- .. 'their metal value, and each mark fades out over the "forget unseen units" time.',
- type = 'bool',
- value = true,
- },
- reset = {
- name = 'Reset intel memory',
- desc = 'Clear all remembered enemy units. Use when counts have drifted stale.',
- type = 'button',
- OnChange = function()
- if ResetIntel then ResetIntel() end
- end,
- },
- }
- --------------------------------------------------------------------------------
- -- Intel readout
- --------------------------------------------------------------------------------
- -- unitDefID -> how many of that type we still believe the enemy has.
- -- Wrecks only subtract, and never past what is standing in front of you: a unit in LOS
- -- is alive, and no amount of corpse evidence can argue otherwise.
- -- Nothing is filtered for display here. `minCount` is presentation, and widgets reading
- -- this through WG.EnemyIntel want the whole belief, including the single sightings.
- local function GetEffectiveCounts()
- local applyWrecks = options.useWrecks.value
- local out = {}
- for defID, raw in pairs(counts) do
- local n = raw
- if applyWrecks then
- n = raw - (wreckDeduct[defID] or 0)
- local onScreen = visibleByDef[defID]
- if onScreen and onScreen > n then
- n = onScreen
- end
- end
- if n > 0 then
- out[defID] = n
- end
- end
- return out
- end
- -- The same belief, restricted to one enemy team. Wreck evidence cannot be attributed to
- -- an owner -- a corpse proves a unit of that type died, not whose it was -- so each type's
- -- deduction is shared out across the teams holding it in proportion to how many each
- -- holds. Counts therefore come back fractional. They are threat weights, not a roster.
- -- Returns nil for a team nothing has ever been seen from, which is a different answer
- -- from an empty table: one means "no intel", the other means "nothing left standing".
- local function GetEffectiveCountsForTeam(teamID)
- local perTeam = countsByTeam[teamID]
- if not perTeam then
- return nil
- end
- local effective = GetEffectiveCounts()
- local out = {}
- for defID, raw in pairs(perTeam) do
- local total = counts[defID] or 0
- local eff = effective[defID]
- if eff and total > 0 then
- local n = raw * (eff / total)
- if n > 0 then
- out[defID] = n
- end
- end
- end
- return out
- end
- -- Enemy teams we currently hold any intel on, which is not the same as the enemy teams in
- -- the game: a player we have never scouted is simply absent.
- local function GetEnemyTeams()
- local out = {}
- for teamID in pairs(countsByTeam) do
- out[#out + 1] = teamID
- end
- table.sort(out)
- return out
- end
- --------------------------------------------------------------------------------
- -- Tracking
- --------------------------------------------------------------------------------
- local function RebuildTeamMap()
- teamAlly = {}
- local teams = spGetTeamList()
- if teams then
- for i = 1, #teams do
- local t = teams[i]
- teamAlly[t] = select(6, spGetTeamInfo(t, false))
- end
- end
- myAllyTeamID = spGetMyAllyTeamID()
- end
- local function IsEnemyTeam(unitTeam)
- if not unitTeam then
- return false
- end
- -- Gaia owns the critters and the map's own units. They are not an opponent, and
- -- counting them puts a fake player in front of every consumer that reads
- -- GetEnemyTeams. The wreck scan already excludes Gaia for the same reason.
- if unitTeam == gaiaTeamID then
- return false
- end
- local at = teamAlly[unitTeam]
- if at == nil then
- at = select(6, spGetTeamInfo(unitTeam, false))
- teamAlly[unitTeam] = at
- end
- return at ~= myAllyTeamID
- end
- local function AddUnit(unitID, unitDefID, unitTeam)
- if not unitID then
- return
- end
- if seen[unitID] then
- lastSeen[unitID] = spGetGameSeconds() -- already tracked; this sighting restarts its timer
- return
- end
- if not IsEnemyTeam(unitTeam) then
- return
- end
- unitDefID = unitDefID or spGetUnitDefID(unitID)
- if not unitDefID or not UnitDefs[unitDefID] then
- return -- radar dot with no known type yet
- end
- if notIntel[unitDefID] then
- return
- end
- seen[unitID] = unitDefID
- lastSeen[unitID] = spGetGameSeconds()
- counts[unitDefID] = (counts[unitDefID] or 0) + 1
- teamOf[unitID] = unitTeam
- local perTeam = countsByTeam[unitTeam]
- if not perTeam then
- perTeam = {}
- countsByTeam[unitTeam] = perTeam
- end
- perTeam[unitDefID] = (perTeam[unitDefID] or 0) + 1
- dirty = true
- end
- local function RemoveUnit(unitID)
- local defID = seen[unitID]
- if not defID then
- return
- end
- seen[unitID] = nil
- lastSeen[unitID] = nil
- ghosts.pos[unitID] = nil
- local n = (counts[defID] or 1) - 1
- counts[defID] = (n > 0) and n or nil
- -- Clamp the wreck evidence to what is left, the same way the 5 s sweep does.
- -- Without it a deduction taken against a count that has since fallen can exceed
- -- it, and GetEffectiveCounts drops the whole type until the next sweep -- a live
- -- unit gone from the panel, and from every WG.EnemyIntel consumer, for up to 5 s.
- local owed = wreckDeduct[defID]
- if owed and owed > n then
- wreckDeduct[defID] = (n > 0) and n or nil
- end
- local team = teamOf[unitID]
- teamOf[unitID] = nil
- local perTeam = team and countsByTeam[team]
- if perTeam then
- local m = (perTeam[defID] or 1) - 1
- perTeam[defID] = (m > 0) and m or nil
- if not next(perTeam) then
- countsByTeam[team] = nil
- end
- end
- dirty = true
- end
- --------------------------------------------------------------------------------
- -- Wreck tracking
- --
- -- Every death is counted once, whether it was witnessed or only inferred from the
- -- corpse. A death site is the bridge: a kill seen in LOS plants one (already counted),
- -- and a corpse that lands on an existing site is that same death rather than a new one.
- --
- -- Each site holds at most one feature per featureDefID. That is what keeps a pile of
- -- identical kills honest -- a second '<unit>_dead' cannot settle on a site that already
- -- holds one, so it walks on to the next site or opens its own -- while still letting a
- -- wreck and the debris it later decays into share a single site for free, since the two
- -- stages carry different featureDefIDs.
- --------------------------------------------------------------------------------
- -- Returns the site that claims this corpse, or nil when the death is news to us.
- local function ClaimSite(unitDefID, featureDefID, x, z)
- local sites = deathSites[unitDefID]
- if not sites then
- return nil
- end
- for i = 1, #sites do
- local site = sites[i]
- if not site.absorbed[featureDefID] then
- local dx, dz = site.x - x, site.z - z
- if dx * dx + dz * dz <= MATCH_RADIUS_SQ then
- site.absorbed[featureDefID] = true
- return site
- end
- end
- end
- return nil
- end
- local function AddSite(unitDefID, x, z, featureDefID)
- local sites = deathSites[unitDefID]
- if not sites then
- sites = {}
- deathSites[unitDefID] = sites
- end
- local site = {x = x, z = z, clock = os.clock(), absorbed = {}}
- if featureDefID then
- site.absorbed[featureDefID] = true
- end
- sites[#sites + 1] = site
- end
- -- A kill we watched happen: already deducted by RemoveUnit, so the site exists purely to
- -- stop the corpse that is about to appear from deducting the same death a second time.
- local function NoteWitnessedDeath(unitID, unitDefID)
- local x, _, z = spGetUnitPosition(unitID)
- if x and unitDefID then
- AddSite(unitDefID, x, z, nil)
- end
- end
- local function PruneSites()
- local cutoff = os.clock() - BARREN_SITE_MAX_AGE
- for defID, sites in pairs(deathSites) do
- local keep = {}
- for i = 1, #sites do
- local site = sites[i]
- if next(site.absorbed) or site.clock >= cutoff then
- keep[#keep + 1] = site
- end
- end
- -- Sites are appended in time order, so the overflow to drop is at the front.
- local excess = #keep - MAX_SITES_PER_DEF
- if excess > 0 then
- local trimmed = {}
- for i = excess + 1, #keep do
- trimmed[#trimmed + 1] = keep[i]
- end
- keep = trimmed
- end
- deathSites[defID] = (#keep > 0) and keep or nil
- end
- end
- -- Features are not ghosted by the engine: GetAllFeatures returns only what is known right
- -- now, and GetFeatureDefID goes nil the moment a feature leaves LOS (the same behaviour
- -- unit_ghostsite.lua leans on). So we poll, and never drop a record just because a wreck
- -- went dark -- the unit is still dead.
- -- `silent` records what is already lying around without deducting for it. A wreck that
- -- predates our counting is not evidence against a tally that had not started yet; it is
- -- still registered so that the debris it later decays into does not deduct either.
- local function ScanFeatures(silent)
- local features = spGetAllFeatures()
- if not features then
- return
- end
- for i = 1, #features do
- local fID = features[i]
- local fdID = spGetFeatureDefID(fID)
- -- Feature IDs are recycled, so a record only counts while the def still matches.
- if fdID and knownFeature[fID] ~= fdID and ignoredFeature[fID] ~= fdID then
- local unitDefID = wreckUnitDef[fdID]
- local fTeam = unitDefID and spGetFeatureTeam(fID)
- local fAlly = unitDefID and spGetFeatureAllyTeam(fID)
- -- Map features sit on Gaia; anything else carries the team of the unit that died.
- if unitDefID and fTeam and fTeam ~= gaiaTeamID
- and fAlly and fAlly >= 0 and fAlly ~= myAllyTeamID then
- local x, _, z = spGetFeaturePosition(fID)
- if x then
- knownFeature[fID] = fdID
- if not ClaimSite(unitDefID, fdID, x, z) then
- -- No death we know of put this here: a kill we never saw.
- AddSite(unitDefID, x, z, fdID)
- -- Capped at what we actually believe is out there. A battlefield can
- -- hold far more enemy corpses than we ever saw alive -- most of them
- -- died to allies, out of our sight, before we scouted the type at all.
- -- Letting the debt run past the tally would bury later sightings under
- -- deductions for deaths that were never in our count to begin with.
- local owed = wreckDeduct[unitDefID] or 0
- if not silent and owed < (counts[unitDefID] or 0) then
- wreckDeduct[unitDefID] = owed + 1
- dirty = true
- end
- end
- end
- else
- ignoredFeature[fID] = fdID
- end
- end
- end
- end
- --------------------------------------------------------------------------------
- -- Army balance and the minimap ghosts
- --------------------------------------------------------------------------------
- -- Our side of the strip: finished units only, since a nanoframe is not yet strength. The
- -- whole ally team by default, because the enemy side is everything the team has seen.
- local function UpdateBalanceMine()
- local teams
- if options.balanceSide.value == 'me' then
- teams = {spGetMyTeamID()}
- else
- teams = {}
- for t, at in pairs(teamAlly) do
- if at == myAllyTeamID and t ~= gaiaTeamID then
- teams[#teams + 1] = t
- end
- end
- end
- local sums = {ground = 0, air = 0, defence = 0}
- for i = 1, #teams do
- local units = spGetTeamUnits(teams[i])
- for j = 1, (units and #units or 0) do
- local uID = units[j]
- local defID = spGetUnitDefID(uID)
- local b = defID and bucketOf[defID]
- if b and not spGetUnitIsBeingBuilt(uID) then
- sums[b] = sums[b] + unitCost[defID]
- end
- end
- end
- local old = balance.mine
- for i = 1, #BUCKETS do
- local b = BUCKETS[i]
- if old[b] ~= sums[b] then
- dirty = true
- end
- end
- balance.mine = sums
- balance.due = false
- end
- local function BalanceTheirs()
- local sums = {ground = 0, air = 0, defence = 0}
- for defID, n in pairs(GetEffectiveCounts()) do
- local b = bucketOf[defID]
- if b then
- sums[b] = sums[b] + n * unitCost[defID]
- end
- end
- return sums
- end
- local function FmtMetal(v)
- if v >= 10000 then
- return string.format("%dk", math.floor(v / 1000 + 0.5))
- elseif v >= 1000 then
- return string.format("%.1fk", v / 1000)
- end
- return tostring(math.floor(v + 0.5))
- end
- -- Coarse on purpose: a mark on the minimap says "an army was here", and one per unit would
- -- be a smear of dots that reads worse than the cluster it came from.
- local GHOST_CELL = 512
- -- Once a second. A position the engine still hands us is sight or radar, both of which the
- -- minimap already shows, so that unit only has its record refreshed. One it no longer
- -- hands us is out of both, and joins the mark for its cell. A cell's fade follows its
- -- freshest sighting, so a trickle of new sightings keeps a staging area lit.
- local function RefreshGhosts(gameNow)
- local pos = ghosts.pos
- local cells, list = {}, {}
- for uID, defID in pairs(seen) do
- local b = bucketOf[defID]
- if b == "ground" or b == "air" then
- local x, _, z = spGetUnitPosition(uID)
- local p = pos[uID]
- if x then
- if p then
- p[1], p[2], p[3] = x, z, gameNow
- else
- pos[uID] = {x, z, gameNow}
- end
- elseif p then
- local key = math.floor(p[1] / GHOST_CELL) .. ":" .. math.floor(p[2] / GHOST_CELL)
- local c = cells[key]
- if not c then
- c = {x = 0, z = 0, w = 0, value = 0, t = p[3]}
- cells[key] = c
- list[#list + 1] = c
- end
- local v = unitCost[defID]
- local w = math.max(v, 1)
- c.x, c.z, c.w = c.x + p[1] * w, c.z + p[2] * w, c.w + w
- c.value = c.value + v
- if p[3] > c.t then
- c.t = p[3]
- end
- end
- end
- end
- local fadeOver = math.max(1, options.memoryTime.value * 60)
- for i = 1, #list do
- local c = list[i]
- c.x, c.z = c.x / c.w, c.z / c.w
- c.alpha = math.max(0.15, 1 - (gameNow - c.t) / fadeOver)
- end
- ghosts.list = list
- end
- -- A unit circle, built once; DrawInMiniMap scales it per mark.
- local CIRCLE = {}
- for i = 0, 15 do
- local a = i / 16 * 2 * math.pi
- CIRCLE[#CIRCLE + 1] = {math.cos(a), math.sin(a)}
- end
- local function CircleVerts(x, y, r, centre)
- if centre then
- gl.Vertex(x, y)
- end
- for i = 1, #CIRCLE do
- gl.Vertex(x + CIRCLE[i][1] * r, y + CIRCLE[i][2] * r)
- end
- if centre then
- gl.Vertex(x + CIRCLE[1][1] * r, y + CIRCLE[1][2] * r)
- end
- end
- --------------------------------------------------------------------------------
- -- Chili UI
- --------------------------------------------------------------------------------
- local Chili
- local window_main, scrollPanel, holder
- local headerMobile, headerStatic
- local rowByDefID = {}
- -- Inner padding, and the gap between the Mobile and Static columns.
- local PAD_L, PAD_T, PAD_R, PAD_B = 8, 6, 8, 8
- local COL_GAP = 14
- local HEADER_H = 20
- local COUNT_GAP = 10 -- icon -> count label
- local COUNT_TRAIL = 4 -- breathing room after the widest count label
- local MIN_COL_W = 40
- local WIN_VPAD = 12 -- window padding, top + bottom
- local WIN_HPAD = 12 -- window padding, left + right
- local MIN_WIN_H = 56
- local MIN_WIN_W = 80
- local MAX_WIN_FRAC = 0.6 -- never grow past this fraction of screen height
- local BAL_ROW_H = 16 -- army balance strip: one row per bucket
- local BAL_NAME_W = 54 -- "Defence" and a little air
- local BAL_BAR_W = 64
- local BAL_BAR_H = 8
- -- Chili exposes each control's font; fall back to a rough estimate if it does not.
- local function TextWidth(ctrl, text)
- local font = ctrl and ctrl.font
- if font and font.GetTextWidth then
- local ok, w = pcall(font.GetTextWidth, font, text)
- if ok and w then
- return math.ceil(w)
- end
- end
- return math.ceil(#text * 7)
- end
- local function DestroyRows()
- for _, row in pairs(rowByDefID) do
- if row.ctrl then
- row.ctrl:Dispose()
- end
- end
- rowByDefID = {}
- end
- ResetIntel = function()
- seen = {}
- lastSeen = {}
- counts = {}
- teamOf = {}
- countsByTeam = {}
- wreckDeduct = {}
- visibleByDef = {}
- knownFeature = {}
- ignoredFeature = {}
- deathSites = {}
- ghosts.pos, ghosts.list = {}, {}
- -- Reset means "count from now", so visible wrecks are re-registered as history rather
- -- than immediately charged against the fresh tally.
- ScanFeatures(true)
- dirty = true
- end
- local function EnsureRow(defID)
- local row = rowByDefID[defID]
- if row then
- return row
- end
- local size = options.iconSize.value
- -- Width is assigned per-column by RefreshLayout, so no `right` anchor here.
- local ctrl = Chili.Control:New{
- parent = holder,
- x = 0, y = 0,
- width = 160,
- height = size + 4,
- padding = {0, 0, 0, 0},
- tooltip = unitName[defID],
- }
- Chili.Image:New{
- parent = ctrl,
- x = 2, y = 2,
- width = size, height = size,
- keepAspect = true,
- file = iconFile[defID],
- }
- local lblCount = Chili.Label:New{
- parent = ctrl,
- x = size + COUNT_GAP, y = math.floor(size / 2) - 6,
- font = {
- size = 14,
- color = {1, 1, 1, 1},
- outline = true,
- outlineWidth = 3,
- outlineWeight = 6,
- outlineColor = {0, 0, 0, 1},
- },
- caption = "",
- }
- row = {ctrl = ctrl, count = lblCount}
- rowByDefID[defID] = row
- return row
- end
- local function RefreshLayout()
- if not (window_main and holder) then
- return
- end
- local minCount = options.minCount.value
- local mobile, static = {}, {}
- -- Commanders are folded into one row here, at display time only: WG.EnemyIntel keeps
- -- them apart, since a level 5 Strike and a level 1 Recon are different fights. The row
- -- wears the icon of the most numerous kind, and the tooltip says what is in it.
- local comm = {count = 0, value = 0, names = {}, best = 0}
- -- Constructors by Build Proposer's own classification when it is running, so the two
- -- panels agree on what one is; otherwise build power, unarmed, not a commander -- the
- -- same test Engagement Predictor uses, which keeps an armed builder like the Funnelweb
- -- on the panel. Mobile only either way: that classification also calls every factory,
- -- plate, silo and Caretaker a "con", and hiding the enemy's factories is not the point.
- local hideCons = options.hideCons.value
- local cmRole = hideCons and WG.CounterMatrix and WG.CounterMatrix.role
- for defID, n in pairs(GetEffectiveCounts()) do
- local ud = UnitDefs[defID]
- if hideCons and ud and (ud.speed or 0) > 0 and ((cmRole and cmRole[defID] == "con")
- or (not cmRole and (ud.buildSpeed or 0) > 0 and not isCommDef[defID]
- and #(ud.weapons or {}) == 0)) then
- -- left off the panel
- elseif isCommDef[defID] then
- if n > 0 then
- comm.count = comm.count + n
- comm.value = comm.value + n * unitCost[defID]
- local nm = unitName[defID]
- comm.names[nm] = (comm.names[nm] or 0) + n
- if n > comm.best or (n == comm.best and defID < comm.defID) then
- comm.best, comm.defID = n, defID
- end
- end
- elseif n >= minCount then
- local entry = {defID = defID, count = n, value = n * unitCost[defID]}
- if isStaticDef[defID] then
- static[#static + 1] = entry
- else
- mobile[#mobile + 1] = entry
- end
- end
- end
- if comm.defID and comm.count >= minCount then
- local lines = {}
- for nm, n in pairs(comm.names) do
- lines[#lines + 1] = n .. " x " .. nm
- end
- table.sort(lines)
- mobile[#mobile + 1] = {defID = comm.defID, count = comm.count, value = comm.value,
- tip = "Commanders\n" .. table.concat(lines, "\n")}
- end
- local byCount = (options.sortMode.value == "count")
- local function cmp(a, b)
- if byCount then
- if a.count ~= b.count then return a.count > b.count end
- return a.value > b.value
- end
- if a.value ~= b.value then return a.value > b.value end
- return a.count > b.count
- end
- table.sort(mobile, cmp)
- table.sort(static, cmp)
- local used = {}
- local iconSize = options.iconSize.value
- local rowH = iconSize + 4
- local sections = {{header = headerMobile, label = "Mobile", list = mobile}}
- if options.showStatic.value then
- local st = {header = headerStatic, label = "Static", list = static}
- if options.staticFirst.value then
- table.insert(sections, 1, st)
- else
- sections[#sections + 1] = st
- end
- end
- -- Only sections with rows get a column, so an empty one costs no width at all.
- local shown = {}
- for i = 1, #sections do
- if #sections[i].list > 0 then
- shown[#shown + 1] = sections[i]
- end
- end
- if #shown == 0 then
- shown[1] = sections[1] -- nothing scouted yet: keep one header rather than a sliver
- end
- headerMobile:Hide()
- headerStatic:Hide()
- -- The army balance strip sits above both columns, one row per bucket that either side
- -- has anything in: no air on the field means no air row, not an empty bar.
- local top = PAD_T
- local stripW = 0
- local theirs = options.showBalance.value and BalanceTheirs()
- for i = 1, #BUCKETS do
- local b = BUCKETS[i]
- local r = balance.rows[b]
- local mine = balance.mine[b] or 0
- local their = theirs and theirs[b] or 0
- if r and theirs and (mine > 0 or their > 0) then
- local cap = FmtMetal(mine) .. " vs " .. FmtMetal(their)
- r.name:SetPos(PAD_L, top)
- r.name:Show()
- r.bar:SetPos(PAD_L + BAL_NAME_W, top + math.floor((BAL_ROW_H - BAL_BAR_H) / 2),
- BAL_BAR_W, BAL_BAR_H)
- r.bar:SetValue(math.floor(100 * mine / (mine + their) + 0.5))
- r.bar:Show()
- r.nums:SetCaption(cap)
- r.nums:SetPos(PAD_L + BAL_NAME_W + BAL_BAR_W + 6, top)
- r.nums:Show()
- r.shown = cap
- stripW = math.max(stripW, BAL_NAME_W + BAL_BAR_W + 6 + TextWidth(r.nums, cap))
- top = top + BAL_ROW_H
- elseif r then
- r.name:Hide()
- r.bar:Hide()
- r.nums:Hide()
- r.shown = nil
- end
- end
- if top > PAD_T then
- top = top + 4
- end
- -- Measure before placing: a column is as wide as its widest row (icon + count
- -- text) or its own header, whichever is greater.
- local function MeasureSection(s)
- local w = TextWidth(s.header, s.label)
- for i = 1, #s.list do
- local e = s.list[i]
- e.row = EnsureRow(e.defID)
- e.caption = tostring(e.count)
- local rowW = iconSize + COUNT_GAP + TextWidth(e.row.count, e.caption) + COUNT_TRAIL
- if rowW > w then
- w = rowW
- end
- end
- return math.max(w, MIN_COL_W)
- end
- local function PlaceSection(s, colX, colW)
- local y = top
- s.header:SetCaption(s.label)
- s.header:SetPos(colX, y)
- s.header:Show()
- y = y + HEADER_H
- for i = 1, #s.list do
- local e = s.list[i]
- used[e.defID] = true
- e.row.ctrl:SetPos(colX, y, colW)
- e.row.ctrl:Show()
- e.row.count:SetCaption(e.caption)
- -- Rows are cached per def, so a row that once stood for all the commanders
- -- has to be given its own name back when it is just one unit type again.
- e.row.ctrl.tooltip = e.tip or unitName[e.defID]
- y = y + rowH
- end
- return y
- end
- -- Each column runs its own y, so the sections sit side by side.
- local colX = PAD_L
- local contentBottom = top + HEADER_H
- for i = 1, #shown do
- local s = shown[i]
- local colW = MeasureSection(s)
- local endY = PlaceSection(s, colX, colW)
- if endY > contentBottom then
- contentBottom = endY
- end
- colX = colX + colW + COL_GAP
- end
- local contentW = math.max(colX - COL_GAP, PAD_L + stripW) + PAD_R
- for defID, row in pairs(rowByDefID) do
- if not used[defID] then
- row.ctrl:Hide()
- end
- end
- local contentH = contentBottom + PAD_B
- holder:SetPos(nil, nil, nil, contentH)
- -- Both dimensions follow the content. Height is capped so a large army cannot
- -- run off screen; past the cap the overflow is simply clipped, since the panel
- -- has no scrollbar to give away width to.
- local screenH = (Chili.Screen0 and Chili.Screen0.height) or 1080
- local screenW = (Chili.Screen0 and Chili.Screen0.width) or 1920
- local maxH = math.floor(screenH * MAX_WIN_FRAC)
- local wantH = math.max(math.min(contentH + WIN_VPAD, maxH), MIN_WIN_H)
- local wantW = math.max(contentW + WIN_HPAD, MIN_WIN_W)
- if window_main.width ~= wantW or window_main.height ~= wantH then
- local x = window_main.x
- -- Grow towards the middle of the screen: a panel on the right half keeps its
- -- right edge planted, otherwise the left edge is the one that stays put.
- if x + window_main.width * 0.5 > screenW * 0.5 then
- x = math.max(0, x + window_main.width - wantW)
- end
- window_main:SetPos(x, window_main.y, wantW, wantH)
- end
- end
- local function CreateWindow()
- window_main = Chili.Window:New{
- parent = Chili.Screen0,
- name = "EnemyIntel",
- classname = "main_window_small_very_flat",
- -- Fully transparent panel: the skin bitmap and the background are both tinted
- -- to zero alpha, so the rows float over the map with no frame behind them.
- color = {0, 0, 0, 0},
- backgroundColor = {0, 0, 0, 0},
- borderColor = {0, 0, 0, 0},
- dockable = true,
- dockableSavePositionOnly = true,
- parentWidgetName = widget:GetInfo().name, -- for gui_chili_docking.lua (minimize)
- x = 0,
- y = 41,
- clientWidth = 165, -- starting values only; RefreshLayout fits both axes
- clientHeight = 80, -- to the content on the first refresh
- minWidth = MIN_WIN_W,
- minHeight = MIN_WIN_H,
- draggable = false,
- resizable = false,
- tweakDraggable = true,
- tweakResizable = true,
- padding = {6, 6, 6, 6},
- }
- scrollPanel = Chili.ScrollPanel:New{
- parent = window_main,
- x = 0, y = 0, right = 0, bottom = 0,
- horizontalScrollbar = false,
- verticalScrollbar = false, -- clip the overflow instead of showing a bar
- padding = {0, 0, 0, 0},
- borderColor = {0, 0, 0, 0},
- backgroundColor = {0, 0, 0, 0},
- }
- holder = Chili.Control:New{
- parent = scrollPanel,
- x = 0, y = 0, right = 0,
- height = 1,
- padding = {0, 0, 0, 0},
- }
- headerMobile = Chili.Label:New{
- parent = holder, x = 2, y = 0,
- caption = "Mobile",
- font = {
- size = 13,
- color = {1.0, 0.85, 0.40, 1},
- outline = true,
- outlineWidth = 3,
- outlineWeight = 6,
- outlineColor = {0, 0, 0, 1},
- },
- }
- headerStatic = Chili.Label:New{
- parent = holder, x = 2, y = 0,
- caption = "Static",
- font = {
- size = 13,
- color = {0.60, 0.80, 1.00, 1},
- outline = true,
- outlineWidth = 3,
- outlineWeight = 6,
- outlineColor = {0, 0, 0, 1},
- },
- }
- local BAL_TITLE = {ground = "Ground", air = "Air", defence = "Defence"}
- balance.rows = {}
- for i = 1, #BUCKETS do
- local b = BUCKETS[i]
- local function Font(c)
- return {size = 12, color = c, outline = true, outlineWidth = 3, outlineWeight = 6,
- outlineColor = {0, 0, 0, 1}}
- end
- balance.rows[b] = {
- name = Chili.Label:New{parent = holder, x = PAD_L, y = 0, caption = BAL_TITLE[b],
- font = Font({0.85, 0.85, 0.85, 1})},
- -- The bar is the split itself, as in Engagement Predictor: filled is your side,
- -- the ground behind it is theirs.
- bar = Chili.Progressbar:New{
- parent = holder, x = PAD_L + BAL_NAME_W, y = 0,
- width = BAL_BAR_W, height = BAL_BAR_H,
- min = 0, max = 100, value = 50,
- noFont = true,
- color = {0.35, 0.80, 0.40, 0.95},
- backgroundColor = {0.55, 0.16, 0.16, 0.85},
- },
- nums = Chili.Label:New{parent = holder, x = 0, y = 0, caption = "",
- font = Font({1, 1, 1, 1})},
- }
- end
- end
- -- Show/hide only. The window is kept alive so its docked position, and anything the
- -- tweak-mode layout did to it, survive being toggled off and on again.
- function SetPanelVisible(visible)
- if not window_main then
- return
- end
- if visible then
- window_main:Show()
- dirty = true -- the table went stale while hidden; redraw before it is looked at
- else
- window_main:Hide()
- end
- end
- --------------------------------------------------------------------------------
- -- Callins
- --------------------------------------------------------------------------------
- local nextRefresh = 0
- local nextSweep = 0
- function M:Initialize()
- Chili = WG.Chili
- if not Chili then
- Spring.Echo("Enemy Intel: WG.Chili unavailable, removing widget.")
- widgetHandler:RemoveWidget(widget)
- return
- end
- gaiaTeamID = Spring.GetGaiaTeamID() or -1
- RebuildTeamMap()
- CreateWindow()
- SetPanelVisible(options.showPanel.value)
- -- Seed from everything currently visible, so enabling mid-game is not blind.
- local units = spGetAllUnits()
- if units then
- for i = 1, #units do
- local uID = units[i]
- AddUnit(uID, spGetUnitDefID(uID), spGetUnitTeam(uID))
- end
- end
- ScanFeatures(true) -- wrecks already on the field predate the tally; register, don't deduct
- dirty = true
- balance.due = true -- our side of the strip, on the first Update rather than the first sweep
- -- Published so gui_build_proposer.lua scores against the same numbers this panel
- -- shows, rather than re-deriving the tally and drifting out of step with it.
- WG.EnemyIntel = {
- -- 2 adds the per-team readouts. A consumer should check for the functions rather
- -- than the number, so that an older copy of this widget still drives it.
- version = 2,
- GetEffectiveCounts = GetEffectiveCounts, -- unitDefID -> count, every enemy
- GetEffectiveCountsForTeam = GetEffectiveCountsForTeam, -- teamID -> that, or nil
- GetEnemyTeams = GetEnemyTeams, -- array of enemy teamIDs we hold intel on
- GetLastUpdate = function() return lastSweep end, -- game seconds
- }
- end
- function M:Shutdown()
- WG.EnemyIntel = nil
- DestroyRows()
- if window_main then
- window_main:Dispose()
- window_main = nil
- end
- balance.rows = {} -- went with the window
- end
- -- Deliberately no UnitLeftLos handler: that is what makes counts persist.
- function M:UnitEnteredLos(unitID)
- AddUnit(unitID, spGetUnitDefID(unitID), spGetUnitTeam(unitID))
- end
- function M:UnitCreated(unitID, unitDefID, unitTeam)
- AddUnit(unitID, unitDefID, unitTeam)
- end
- function M:UnitFinished(unitID, unitDefID, unitTeam)
- AddUnit(unitID, unitDefID, unitTeam)
- end
- function M:UnitDestroyed(unitID, unitDefID)
- local trackedDefID = seen[unitID]
- RemoveUnit(unitID)
- if trackedDefID then
- NoteWitnessedDeath(unitID, unitDefID or trackedDefID)
- end
- end
- function M:PlayerChanged()
- -- Fires for ANY player's change, so only act when our own viewpoint moved.
- -- Wiping on every event would clear intel whenever someone disconnected.
- local prevAllyTeam = myAllyTeamID
- RebuildTeamMap()
- if myAllyTeamID ~= prevAllyTeam then
- ResetIntel() -- now looking from a different side, old counts are meaningless
- end
- balance.due = true -- our own side of the strip may have changed seat too
- end
- function M:DrawInMiniMap(sx, sy)
- local list = ghosts.list
- if #list == 0 or not options.minimapGhosts.value then
- return
- end
- local util = Spring.Utilities
- local flipped = util and util.IsMinimapFlipped and util.IsMinimapFlipped()
- local rx, rz = sx / Game.mapSizeX, sy / Game.mapSizeZ
- for i = 1, #list do
- local c = list[i]
- -- Minimap pixels, origin bottom left: the same mapping as Zero-K's Point Tracker.
- local x, y = c.x * rx, sy - c.z * rz
- if flipped then
- x, y = sx - x, sy - y
- end
- local r = math.min(12, 2.5 + 1.2 * math.sqrt(c.value / 100))
- gl.Color(1.0, 0.35, 0.20, 0.30 * c.alpha)
- gl.BeginEnd(GL.TRIANGLE_FAN, CircleVerts, x, y, r, true)
- gl.Color(1.0, 0.45, 0.30, 0.90 * c.alpha)
- gl.BeginEnd(GL.LINE_LOOP, CircleVerts, x, y, r, false)
- end
- gl.Color(1, 1, 1, 1)
- end
- function M:Update()
- local now = os.clock()
- if now >= nextSweep then
- nextSweep = now + 5
- local gameNow = spGetGameSeconds()
- lastSweep = gameNow
- -- Pick up anything the callins missed, and drop confirmed deaths.
- local units = spGetAllUnits()
- if units then
- visibleByDef = {}
- for i = 1, #units do
- local uID = units[i]
- local uDefID = spGetUnitDefID(uID)
- local uTeam = spGetUnitTeam(uID)
- if not seen[uID] then
- AddUnit(uID, uDefID, uTeam)
- elseif teamOf[uID] ~= uTeam then
- -- Captured, gifted or resigned into another team: re-file it under its
- -- new owner. AddUnit drops it outright if that owner is now an ally.
- RemoveUnit(uID)
- AddUnit(uID, uDefID, uTeam)
- end
- -- Radar dots have no defID and are not "seen" in any useful sense.
- if uDefID and IsEnemyTeam(uTeam) then
- visibleByDef[uDefID] = (visibleByDef[uDefID] or 0) + 1
- end
- -- Anything the engine still hands us is in sight or on radar right now,
- -- so its memory timer restarts here.
- if seen[uID] then
- lastSeen[uID] = gameNow
- end
- end
- end
- local forgetAfter = options.memoryTime.value * 60
- for uID in pairs(seen) do
- -- Returns nil for units we cannot see, so this never removes remembered ghosts.
- if spGetUnitIsDead(uID) == true then
- RemoveUnit(uID)
- elseif gameNow - (lastSeen[uID] or gameNow) >= forgetAfter then
- -- Out of sight for the whole memory window: the intel is too old to trust,
- -- so the unit stops counting until we lay eyes on it again.
- RemoveUnit(uID)
- end
- end
- for defID, owed in pairs(wreckDeduct) do
- local have = counts[defID] or 0
- if owed > have then
- wreckDeduct[defID] = (have > 0) and have or nil
- end
- end
- ScanFeatures()
- PruneSites()
- if options.showBalance.value then
- UpdateBalanceMine()
- end
- elseif balance.due and options.showBalance.value then
- UpdateBalanceMine() -- the side option changed; do not wait out the sweep
- end
- -- The minimap is not the panel, so this runs whether or not the panel is shown.
- if now >= ghosts.next then
- ghosts.next = now + 1
- if options.minimapGhosts.value then
- RefreshGhosts(spGetGameSeconds())
- elseif #ghosts.list > 0 then
- ghosts.list = {}
- end
- end
- if not options.showPanel.value then
- -- Nothing to lay out while hidden. `dirty` and `needFullRebuild` are left standing
- -- so the first refresh after unhiding rebuilds against the intel gathered meanwhile.
- return
- end
- if needFullRebuild then
- needFullRebuild = false
- DestroyRows()
- dirty = true
- end
- if dirty and now >= nextRefresh then
- nextRefresh = now + options.updateRate.value
- dirty = false
- RefreshLayout()
- end
- end
- RegisterModule{
- callins = M,
- prefix = 'ei_',
- optionsPath = options_path,
- options = options,
- optionsOrder = options_order,
- }
- end
- --------------------------------------------------------------------------------
- -- Build Proposer
- --
- -- Was gui_build_proposer.lua.
- --------------------------------------------------------------------------------
- do
- local M = {} -- this module's callins, collected by RegisterModule below
- local options_path, options_order, options -- were file-scope globals
- --------------------------------------------------------------------------------
- -- Includes / static per-unitDef data
- --------------------------------------------------------------------------------
- -- Tactical icon resolution, same pattern as gui_enemy_intel.lua
- local iconTypesPath = LUAUI_DIRNAME .. "Configs/icontypes.lua"
- local icontypes = VFS.FileExists(iconTypesPath) and VFS.Include(iconTypesPath)
- local iconFormat = ".dds"
- do
- -- chilitip_conf.lua returns: ignoreweapon, iconFormat
- local ok, _, fmt = pcall(VFS.Include, LUAUI_DIRNAME .. "Configs/chilitip_conf.lua", nil, VFS.ZIP)
- if ok and fmt then
- iconFormat = fmt
- end
- end
- local spGetTeamResources = Spring.GetTeamResources
- local spGetTeamRulesParam = Spring.GetTeamRulesParam
- local spGetTeamUnits = Spring.GetTeamUnits
- local spGetUnitDefID = Spring.GetUnitDefID
- local spGetUnitIsDead = Spring.GetUnitIsDead
- local spGetFactoryCommands = Spring.GetFactoryCommands
- local spGetFullBuildQueue = Spring.GetFullBuildQueue
- local spGiveOrderToUnit = Spring.GiveOrderToUnit
- local spGetMyTeamID = Spring.GetMyTeamID
- local spGetMyAllyTeamID = Spring.GetMyAllyTeamID
- local spGetTeamList = Spring.GetTeamList
- local spGetTeamInfo = Spring.GetTeamInfo
- local spGetPlayerInfo = Spring.GetPlayerInfo
- local spGetAIInfo = Spring.GetAIInfo
- local spGetModKeyState = Spring.GetModKeyState
- local spGetGroundHeight = Spring.GetGroundHeight
- -- Zero-K loads this helper into the widget environment too, but fall back rather than
- -- depend on load order.
- local spGetUnitRepeat = (Spring.Utilities and Spring.Utilities.GetUnitRepeat)
- or function(unitID)
- local states = Spring.GetUnitStates(unitID)
- return states and states["repeat"]
- end
- local DEFAULT_ICON = "icons/default.dds"
- --------------------------------------------------------------------------------
- -- Archetype classification
- --
- -- Every factory in Zero-K carries the same set of functional archetypes, so counters
- -- transfer between factories by role. Classification is therefore the whole job: get the
- -- archetype right and the matrix below does the rest.
- --
- -- The curated table is keyed by the internal unitDef name rather than the display name,
- -- because Zero-K renames units constantly while the internal names have not moved in
- -- years. Rocko is `cloakskirm`, displayed as Ronin; Warrior is `cloakriot`, now Reaver;
- -- Zeus is `cloakassault`, now Knight; Tick is `cloakbomb`, now Imp; Roach is `shieldbomb`,
- -- now Snitch; Sharpshooter is `cloaksnipe`, now Phantom; Leveler is `vehriot`, now Ripper;
- -- Wolverine is `veharty`, now Badger; Reaper is `tankassault`, now Minotaur; Goliath is
- -- `tankheavyassault`, now Cyclops; Pillager is `tankarty`, now Emissary; Banisher is
- -- `tankriot`, now Ogre; Copperhead is `tankaa`, now Ettin; Bantha is `striderbantha`, now
- -- Paladin; Catapult is `striderarty`, now Merlin; Sumo is `jumpsumo`, now Jugglenaut;
- -- Freaker is `jumpaa`, now Toad; Crabe is `spidercrabe`, now Crab; Brawler is
- -- `gunshipassault`, now Revenant; Rapier is `gunshipskirm`, now Harpy; Banshee is
- -- `gunshipraid`, now Locust; the AA gunship Vandal is `gunshipaa`, now Trident, and the
- -- name Vandal moved to the Shieldbot AA, `shieldaa`. Slasher, Panther, Infiltrator and
- -- Archangel no longer exist in any form, so nothing maps to them.
- --------------------------------------------------------------------------------
- local ARCHETYPE = {}
- do
- local curated = {
- -- Fast, high DPS for cost, weak armour and range. Rushes, harassment, flanking.
- raider = {
- "cloakraid", "cloakheavyraid", "shieldraid", "vehscout", "vehraid", "amphraid",
- "jumpraid", "jumpscout", "tankraid", "tankheavyraid", "hoverraid", "hoverheavyraid",
- "shiptorpraider", "subraider",
- },
- -- Extreme speed, no armour. There to see and to screen rather than to fight.
- scout = {
- "spiderscout", "shieldscout", "shipscout", "planescout", "planelightscout",
- },
- -- Tanky mainline. Slow turrets and projectiles, wins the straight brawl.
- assault = {
- "cloakassault", "shieldassault", "vehassault", "spiderassault", "spidercrabe",
- "jumpassault", "jumpsumo", "tankassault", "tankheavyassault", "amphassault",
- "hoverassault", "shipassault",
- "striderbantha", "striderdetriment",
- },
- -- Range-focused, light, kites. Beats whatever has to walk into it.
- skirm = {
- "cloakskirm", "shieldskirm", "spiderskirm", "jumpskirm", "amphfloater",
- "hoverskirm", "hoverarty", "shipskirm",
- },
- -- Short range, high DPS, area damage. Exists to delete cheap things in bulk.
- riot = {
- "cloakriot", "shieldriot", "vehriot", "spiderriot", "spideremp", "tankriot",
- "amphimpulse", "amphriot", "hoverriot", "hoverdepthcharge",
- "shipriot", "striderdante",
- },
- -- Outranges everything, kills structures, helpless once anything reaches it.
- arty = {
- "cloakarty", "cloaksnipe", "shieldarty", "veharty", "vehheavyarty", "jumparty",
- "tankarty", "tankheavyarty", "amphsupport", "shiparty", "shipheavyarty",
- "subtacmissile", "striderarty",
- },
- -- Crawling and jumping bombs. Cheap, one-use, aimed at things that clump.
- bomb = {
- "cloakbomb", "shieldbomb", "amphbomb", "jumpbomb",
- },
- -- The solver layer: cheap answers to units far more expensive than themselves.
- antiheavy = {
- "striderscorpion", "striderantiheavy", "spiderantiheavy", "vehcapture",
- },
- -- Only shoots aircraft, and is useless at everything else by design.
- aa = {
- "cloakaa", "shieldaa", "vehaa", "spideraa", "jumpaa", "tankaa", "hoveraa",
- "amphaa", "shipaa",
- },
- -- Air superiority. Counters other air, cannot touch the ground.
- fighter = {
- "planefighter", "planeheavyfighter", "gunshipaa",
- },
- -- Hover-air that engages the ground directly.
- gunship = {
- "gunshipraid", "gunshipskirm", "gunshipheavyskirm", "gunshipassault", "gunshipemp",
- "gunshipbomb", "gunshipkrow",
- },
- -- Fixed-wing that makes a pass and leaves.
- bomber = {
- "bomberprec", "bomberriot", "bomberdisarm", "bomberheavy", "bomberassault",
- "bomberstrike",
- },
- -- Carries a shield for the army rather than damage for itself.
- shield = {
- "shieldfelon", "shieldshield", "striderfunnelweb",
- },
- -- Long range, low DPS, does something other than damage.
- support = {
- "vehsupport", "amphlaunch", "amphtele", "shipcarrier",
- },
- -- Hides other units. Never a combat answer itself, but a target archetype.
- cloaker = {
- "cloakjammer", "staticjammer",
- },
- -- Commanders are not listed here. They are recognised from the unitDef instead, at
- -- the top of the classification below, because there are seventy-two of them --
- -- twelve chassis by six levels, built by clonedefs.lua -- and naming all seventy-two
- -- here would still miss the campaign ones and anything a future level adds.
- --
- -- Build power. Never proposed, and the thing raiders go looking for.
- con = {
- "cloakcon", "shieldcon", "vehcon", "spidercon", "jumpcon", "tankcon", "amphcon",
- "hovercon", "shipcon", "gunshipcon", "planecon", "staticcon",
- },
- }
- for arch, names in pairs(curated) do
- for i = 1, #names do
- ARCHETYPE[names[i]] = arch
- end
- end
- end
- -- Fallback for anything the curated table misses. Zero-K's iconType vocabulary is itself a
- -- role taxonomy (kbotraider, tankriot, hoverskirm, spideraa ...), so the archetype falls
- -- out of the suffix. First match wins, so the specific entries come first.
- local ARCH_SUFFIX = {
- {"torpraider", "raider"},
- {"torpassault", "assault"},
- {"torpriot", "riot"},
- {"wideriot", "riot"},
- {"heavyraid", "raider"},
- {"skirmaa", "aa"},
- {"heavysam", "aa"},
- {"lrarty", "arty"},
- {"depthcharge", "riot"},
- {"raider", "raider"},
- {"assault", "assault"},
- {"skirm", "skirm"},
- {"riot", "riot"},
- {"arty", "arty"},
- {"lrm", "arty"},
- {"sniper", "arty"},
- {"fighter", "fighter"},
- {"bomb", "bomb"},
- {"scout", "scout"},
- {"aa", "aa"},
- {"builder", "con"},
- {"con", "con"},
- {"shield", "shield"},
- {"jammer", "cloaker"},
- {"stealth", "cloaker"},
- {"radar", "support"},
- {"transport", "support"},
- {"support", "support"},
- {"special", "support"},
- {"generic", "support"},
- {"ears", "support"},
- }
- --------------------------------------------------------------------------------
- -- The counter matrix
- --
- -- One number per ordered pair of units: how much that pick is worth against that
- -- target. 1.0 is even, 1.5 and up is a counter, 0.5 and down is being counter-picked.
- -- There are no layers. The value in the table is the value the ranking uses -- nothing
- -- is averaged, weighted or blended, and the archetype/unit-vs-archetype/unit-vs-unit
- -- structure that used to live here has been resolved into the flat table and archived
- -- beside it in LuaUI/Configs/gui_build_proposer_layers_archive.lua, which nothing loads.
- --
- -- Each row also carries "#"-prefixed fallback columns. They are not a layer: they are
- -- what a lookup falls back to when the TARGET is not a row of the table -- the buildings
- -- outside the 35 statics it names, chickens, drones, and anything ZK adds later. Without
- -- them a raider would stop preferring undefended economy, since most eco buildings are
- -- not rows. "#defense" is any armed building and "#eco" any unarmed one.
- --------------------------------------------------------------------------------
- local NEUTRAL = 1.0
- -- The one place a unit is not looked up under its own name.
- --
- -- There are seventy-two commander unitDefs -- twelve chassis by six levels, built by
- -- clonedefs.lua -- and seventy-two rows of a counter matrix would be seventy-two copies of
- -- five, because level changes how hard a commander hits rather than what it is good
- -- against, and the matrix only ever answers the second question. So every commander is
- -- looked up under its chassis, and the level digit is simply dropped.
- --
- -- The legacy chassis share a row with the dynamic one they resemble, being the same unit
- -- wearing a different model. The chassis a multiplayer game fields are Strike, Guardian
- -- (dynassault), Recon and Engineer (dynsupport); the Knight (dynknight) is the campaign
- -- commander, a Strike with another model, so it reads the Strike row too. Its own row stays
- -- in the matrix file but nothing looks it up. A commander this table has never heard of --
- -- another campaign comm, or a chassis added later -- keeps its own name, finds no row, and
- -- is answered by the "#commander" fallback column like any other stranger.
- local COMMANDER_ROW = {
- dynstrike = "dynstrike", commstrike = "dynstrike", armcom = "dynstrike",
- dynassault = "dynassault", corcom = "dynassault", benzcom = "dynassault",
- dynknight = "dynstrike", cremcom = "dynstrike", -- campaign Knight: a Strike clone
- dynrecon = "dynrecon", commrecon = "dynrecon",
- dynsupport = "dynsupport", commsupport = "dynsupport",
- }
- -- A real game's commanders are mostly not named like the chassis. The starting commander is
- -- generated as "<profile>_base" -- dyntrainer_assault_base for a Guardian picked at the
- -- start, or a player's own profile id -- copied from dyn<chassis>1 with its customParams, so
- -- statsname still says "dynassault1". Only after a morph is the unit dyn<chassis><level>.
- -- statsname first, then the unit name, then commtype; nil if none of them is a chassis.
- local COMMTYPE_ROW = {["1"] = "dynstrike", ["3"] = "dynrecon", ["4"] = "dynsupport",
- ["5"] = "dynassault", ["6"] = "dynstrike"}
- local function CommanderRow(ud, cp)
- local function strip(n) return n and (tostring(n):gsub("%d+$", "")) end
- return COMMANDER_ROW[strip(cp.statsname)] or COMMANDER_ROW[strip(ud.name)]
- or COMMTYPE_ROW[tostring(cp.commtype or "")]
- end
- local MATRIX_FILE = VFS.Include("LuaUI/Configs/gui_build_proposer_matrix.lua")
- local MATRIX = MATRIX_FILE.matrix
- -- What counters each class. Frozen at the 0.55 threshold this used to be derived at, back
- -- when there was an archetype table to read it out of; only the "archetype columns" setting
- -- of the counter-source option consults it, and the matrix reading is the default.
- local COUNTERED_BY = MATRIX_FILE.counteredBy
- --------------------------------------------------------------------------------
- -- Unit quality
- --
- -- The matrix above answers "what beats what". It has nothing to say about the fact that
- -- two units can occupy the same square of it and still not be worth the same metal: a
- -- Glaive and a Blitz are both raiders that lose to riots, and picking between them is not
- -- a matchup question at all. That judgement is what a tier list is, so this is one --
- -- Aquanim's, at zero-k.info/mediawiki/User:Aquanim/UnitTierList, transcribed by internal
- -- name and read through the tier's own stated meaning: S is "I would happily make a new
- -- factory to make only this unit" and F is "I do not make this unit, and you should not
- -- make it either".
- --
- -- It is a single opinion about a moving target, which is exactly why it sits on its own
- -- slider and is applied as a multiplier on the finished score rather than being folded
- -- into the matrix. At weight 0 it does nothing and the ranking is pure counter-picking;
- -- at weight 1 a tier is worth about a sixth of a matchup step; turn it up if you would
- -- rather be told what is good than what is clever. Anything not listed is unrated and
- -- scores 1.0, so a gap in the table is neutral rather than a penalty.
- --
- -- The tier list's water-map section is deliberately not transcribed: those ratings are
- -- explicitly conditional on the map (Conch is "better than mex" there and ordinary on
- -- land), and a flat table cannot say that.
- --------------------------------------------------------------------------------
- local TIER_VALUE = {S = 1.30, A = 1.15, B = 1.00, C = 0.88, D = 0.75, F = 0.62}
- local UNIT_TIER = {
- -- Constructors
- shieldcon = "A", tankcon = "A", jumpcon = "A", cloakcon = "A",
- spidercon = "B", vehcon = "B", hovercon = "B", amphcon = "B",
- gunshipcon = "C", planecon = "D",
- -- Raiders and scouts
- cloakraid = "S",
- vehraid = "A", shieldraid = "A",
- tankraid = "B", jumpraid = "B", cloakheavyraid = "B", hoverraid = "B",
- tankheavyraid = "C",
- spiderscout = "S", vehscout = "A", jumpscout = "A", shieldscout = "B",
- -- Anti-raider escorts
- hoverheavyraid = "B", spideremp = "B", amphimpulse = "B",
- -- Skirmishers and fire support
- vehsupport = "A", hoverskirm = "A", amphfloater = "A", shieldfelon = "A",
- spiderskirm = "B", jumpskirm = "B", spidercrabe = "B", shieldskirm = "B",
- amphsupport = "B",
- cloakskirm = "C", vehcapture = "D",
- -- Riots
- cloakriot = "A", hoverriot = "A", tankriot = "A",
- spiderriot = "B", vehriot = "B", shieldriot = "B", striderdante = "B",
- amphriot = "C", hoverdepthcharge = "C",
- -- Assaults
- tankheavyassault = "S",
- tankassault = "A", shieldassault = "A", jumpsumo = "A",
- hoverassault = "B", jumpassault = "B", amphassault = "B",
- cloakassault = "C", vehassault = "C", spiderassault = "C",
- -- Artillery
- vehheavyarty = "S",
- hoverarty = "A", cloaksnipe = "A", jumparty = "A",
- cloakarty = "B", veharty = "B", striderarty = "B", tankheavyarty = "B",
- tankarty = "C",
- -- Anti-heavy and bombs
- spiderantiheavy = "S", shieldbomb = "S",
- cloakbomb = "A", amphbomb = "A", shieldarty = "A", striderscorpion = "A",
- striderantiheavy = "B", jumpbomb = "C",
- -- Support and utility
- amphlaunch = "S", gunshiptrans = "S",
- cloakjammer = "A", shieldshield = "A", jumpblackhole = "A", amphtele = "A",
- striderfunnelweb = "A",
- -- Fixed wing
- planefighter = "S", bomberheavy = "S", planescout = "S", bomberdisarm = "S",
- bomberassault = "A", bomberriot = "A",
- planeheavyfighter = "B", bomberprec = "B", planelightscout = "B",
- bomberstrike = "C",
- -- Gunships
- gunshipaa = "A", gunshipbomb = "A",
- gunshipassault = "B", gunshipskirm = "B", gunshipemp = "B", gunshipkrow = "B",
- gunshipheavyskirm = "C", gunshipraid = "D",
- -- Anti-air
- amphaa = "A", turretaalaser = "A", tankaa = "A",
- vehaa = "B", spideraa = "B", shieldaa = "B", hoveraa = "B", jumpaa = "B",
- turretaaheavy = "B", turretaafar = "B", turretaaflak = "B",
- cloakaa = "C", turretaaclose = "C",
- -- Other static defence
- turretheavylaser = "A", turretlaser = "A",
- turretmissile = "B", turretimpulse = "B", turretemp = "B", turretgauss = "B",
- staticarty = "B",
- turretriot = "C", turretheavy = "C", turretantiheavy = "D",
- -- Tactical missiles
- empmissile = "S", tacnuke = "S",
- napalmmissile = "B", missileslow = "B", seismic = "B",
- -- Endgame
- staticnuke = "S", striderbantha = "A",
- striderdetriment = "B", zenith = "B", mahlazer = "B",
- staticheavyarty = "C", raveparty = "D",
- }
- local QUALITY = {} -- internal unitDef name -> score multiplier
- for name, tier in pairs(UNIT_TIER) do
- QUALITY[name] = TIER_VALUE[tier] or 1
- end
- local ROLE_PLURAL = {
- raider = "raiders", scout = "scouts", assault = "assaults", skirm = "skirmishers",
- riot = "riots", arty = "artillery", bomb = "mobile bombs", antiheavy = "anti-heavy units",
- aa = "anti-air", fighter = "fighters", gunship = "gunships", bomber = "bombers",
- shield = "shield units", support = "support units", cloaker = "cloakers",
- con = "constructors", defense = "static defence", eco = "economy",
- commander = "commanders",
- }
- -- Archetypes that are never a proposition, whatever the enemy is fielding. Support stays
- -- in: Fencer and Lance are real answers to an assault push. Anything genuinely unarmed --
- -- transports, jammers, Djinn -- is dropped by the weapon gate in PairScore rather than by
- -- name.
- local NON_COMBAT = {con = true, cloaker = true}
- -- Effectiveness never falls below this no matter how one-sided the enemy composition is.
- local COUNTER_FLOOR = 0.1
- --------------------------------------------------------------------------------
- -- Per-unitDef tables, built once at load
- --------------------------------------------------------------------------------
- local iconFile = {}
- local unitName = {}
- local defName = {} -- internal unitDef name
- local matrixName = {} -- the name the counter matrix is keyed by, which is not always defName
- local role = {}
- local cost = {}
- local buildTime = {}
- local health = {} -- effective HP, shields folded in
- local speed = {}
- local range = {}
- local dpsGround = {}
- local dpsAir = {}
- local dpsWater = {} -- against submerged targets
- local isAir = {}
- local isStatic = {}
- local isArmed = {}
- local isAA = {} -- can shoot air and nothing else
- local isSub = {}
- local needsWater = {}
- local isFactory = {}
- local buildSpeed = {}
- -- Which plants the auto-queue can be told to leave alone. Both are read off the def rather
- -- than off a list of unit names: the silo is the one thing carrying the customParam its own
- -- LuaRules logic keys off, and an air plant is simply a factory with nothing but aircraft on
- -- its roster -- which catches the Plane Plate and the Gunship Plate for free, since a plate
- -- builds its parent's roster.
- local isAirFactory = {}
- local isMissileSilo = {}
- local AIR_CATEGORY = {fixedwing = true, gunship = true}
- local function ArchFromIcon(iconType)
- for i = 1, #ARCH_SUFFIX do
- local suffix, r = ARCH_SUFFIX[i][1], ARCH_SUFFIX[i][2]
- if #iconType >= #suffix and iconType:sub(-#suffix) == suffix then
- return r
- end
- end
- return nil
- end
- for defID = 1, #UnitDefs do
- local ud = UnitDefs[defID]
- local it = ud.iconType or "default"
- local path = (icontypes and icontypes[it] and icontypes[it].bitmap)
- or ("icons/" .. it .. iconFormat)
- -- Zero-K's own square build texture, which the engine renders from the model, rather
- -- than icons/default.dds -- that file is fully transparent, so a missing icon drew a
- -- blank box with a count beside it (submarine, none, the legacy tank/kbot set).
- if not VFS.FileExists(path) then
- path = "#" .. defID
- end
- iconFile[defID] = path
- unitName[defID] = ud.humanName or ud.name or "?"
- defName[defID] = ud.name or ""
- cost[defID] = ud.metalCost or 0
- buildTime[defID] = ud.buildTime or 0
- speed[defID] = ud.speed or 0
- isAir[defID] = ud.canFly or false
- isStatic[defID] = (ud.speed or 0) == 0
- isFactory[defID] = ud.isFactory or false
- buildSpeed[defID] = ud.buildSpeed or 0
- isMissileSilo[defID] = (ud.customParams and ud.customParams.missile_silo_capacity) and true or false
- if ud.isFactory and ud.buildOptions then
- -- "All of the roster flies", not "any of it does": a Gunship Plant is air, while a
- -- Hovercraft Platform that happens to list one flying scout is not.
- local mobile, flying = 0, 0
- for i = 1, #ud.buildOptions do
- local od = UnitDefs[ud.buildOptions[i]]
- if od and (od.speed or 0) > 0 then
- mobile = mobile + 1
- if od.canFly then
- flying = flying + 1
- end
- end
- end
- isAirFactory[defID] = mobile > 0 and mobile == flying
- end
- -- Anything with a minimum water depth needs somewhere to float, which is what the dry
- -- map gate keys off. That figure cannot tell a submarine from a surface ship though --
- -- both sit at 5 to 15 -- so the SUB category is what decides who is submerged, and a
- -- submerged target is only reachable by torpedoes.
- needsWater[defID] = (ud.minWaterDepth or 0) > 0
- isSub[defID] = (ud.modCategories and ud.modCategories.sub) or false
- local ehp = ud.health or 1
- local g, a, w = 0, 0, 0
- local maxRange = 0
- local anyWeapon, onlyAir = false, true
- local weapons = ud.weapons or {}
- for i = 1, #weapons do
- local wpn = weapons[i]
- local wd = wpn.weaponDef and WeaponDefs[wpn.weaponDef]
- if wd then
- if wd.isShield then
- -- A shield is stored HP under another name, so it belongs in the EHP figure.
- ehp = ehp + (wd.shieldPower or 0)
- else
- local cp = wd.customParams or {}
- -- Zero-K publishes a curated per-shot damage figure for its own stats panels.
- -- It is more trustworthy than reconstructing one from the raw engine fields.
- local dmg = tonumber(cp.stats_damage) or (wd.damages and wd.damages[0]) or 0
- if wd.paralyzer then
- -- EMP does not kill, it buys time. Worth something, not full value.
- dmg = dmg * 0.5
- end
- local salvo = (wd.salvoSize or 1) * (wd.projectiles or 1)
- local reload = wd.reload or 0
- if reload <= 0 then
- reload = 1
- end
- local dps = dmg * salvo / reload
- if dps > 0 then
- anyWeapon = true
- if (wd.range or 0) > maxRange then
- maxRange = wd.range or 0
- end
- -- onlyTargets is keyed by lowercase category name, the same read
- -- LuaUI/Configs/burst_damages.lua does. An empty table means the weapon
- -- was never restricted, so it hits everything.
- local ot = wpn.onlyTargets
- local hitsAir, hitsGround = false, false
- if not ot or not next(ot) then
- hitsAir, hitsGround = true, true
- else
- for cat in pairs(ot) do
- if AIR_CATEGORY[cat] then
- hitsAir = true
- else
- hitsGround = true
- end
- end
- end
- if hitsAir then
- a = a + dps
- end
- if hitsGround then
- g = g + dps
- onlyAir = false
- if wd.waterWeapon then
- w = w + dps
- end
- end
- end
- end
- end
- end
- health[defID] = ehp
- range[defID] = maxRange
- dpsGround[defID] = g
- dpsAir[defID] = a
- dpsWater[defID] = w
- isArmed[defID] = anyWeapon
- isAA[defID] = anyWeapon and onlyAir
- -- Archetype: commanders first, then the curated table, then the icon suffix, then a
- -- guess from the stats. Aircraft are decided by what they are rather than by their icon,
- -- because the air side of the matrix splits three ways and the icon vocabulary does not.
- --
- -- Commanders have to be caught before any of that, and by flag rather than by name. They
- -- carry build power, so the stats guess at the bottom of this chain called every one of
- -- them a constructor, and "there is an enemy commander in my base" was being answered
- -- with the number for "there is an enemy Conjurer in my base". The test is the one
- -- Zero-K's own unitdefs_post.lua uses to find them, so it covers the dynamic chassis,
- -- the legacy ones, and the campaign comms alike, at every level.
- local cp = ud.customParams or {}
- local r = (cp.dynamic_comm or cp.commtype) and "commander" or ARCHETYPE[ud.name]
- if not r and ud.canFly then
- if isAA[defID] then
- r = "fighter"
- elseif ud.name:sub(1, 6) == "bomber" then
- r = "bomber"
- elseif (ud.buildSpeed or 0) > 0 then
- r = "con"
- else
- r = "gunship"
- end
- end
- r = r or ArchFromIcon(it)
- if not r then
- if isAA[defID] then
- r = "aa"
- elseif (ud.buildSpeed or 0) > 0 then
- r = "con"
- elseif not anyWeapon then
- r = "support"
- elseif maxRange > 600 and (ud.speed or 0) < 60 then
- r = "arty"
- elseif (ud.speed or 0) > 90 then
- r = "raider"
- else
- r = "assault"
- end
- end
- role[defID] = r
- -- Everything is keyed by its own name except a commander, which answers to its chassis.
- if r == "commander" then
- matrixName[defID] = CommanderRow(ud, cp) or defName[defID]
- else
- matrixName[defID] = defName[defID]
- end
- end
- --------------------------------------------------------------------------------
- -- State
- --------------------------------------------------------------------------------
- local myTeamID = spGetMyTeamID()
- local myAllyTeamID = spGetMyAllyTeamID()
- local amSpectating = false
- -- Engagement tracking, for the focused modes below.
- --
- -- Damage is the only signal a widget can read that says who you are actually fighting.
- -- Positions say where the armies are, not whose army is shooting at yours, and the
- -- engine will not hand out an enemy team's stats mid-game. So every hit that lands
- -- between our units and theirs is tallied against the team on the other end of it,
- -- ours dealt and ours taken alike -- being shot at is as much a fight as shooting.
- --
- -- What they have landed on us is then tallied a second time on its own, because "who am I
- -- fighting" and "who is hurting me" stop being the same question the moment you are
- -- winning one fight and losing another. An opponent you are shelling for free and one who
- -- is taking your base apart can trade the same total with you, and only the second one
- -- says anything about what you need out of the factory next.
- local enemyTeams = {} -- teamID -> true, every hostile team in the game
- local engagement = {} -- enemy teamID -> damage traded with us, decayed over time
- local harm = {} -- enemy teamID -> damage *they* landed on us, same decay
- local lastFought = {} -- enemy teamID -> clock of the last shot traded with them
- -- And a third tally, one level below the player: what has shot at us, unit by unit.
- --
- -- Even the narrowest player mode answers a whole roster, and a roster is mostly things you
- -- have never met -- their other front, their home defence, the raiders that died to an ally.
- -- What has actually put damage on you is a far shorter and far more specific list, it is the
- -- list that says what is beating you right now, and unlike a player it stops existing: a dead
- -- Ripper is not something to counter-build against. So this is keyed by unitID rather than by
- -- type, and dropped the moment the unit dies.
- local attackers = {} -- enemy unitID -> {def, team, dmg it did us, clock of its last hit}
- local focusTeams = nil -- teamID -> true, whose army is being answered; nil for everyone
- local focusWeights = nil -- teamID -> how heavily that army counts, 1 for the main front
- local focusUnits = nil -- unitDefID -> count, an army assembled from single units, not teams
- local focusMain = nil -- the hardest-fought opponent, kept for the header and hysteresis
- local focusNote = nil -- why we are not focusing, when the mode asked us to
- local focusActive = false -- whether the last ranking really was restricted to one front
- local lastDecay = os.clock()
- local clockNow = lastDecay -- refreshed once per Update, so UnitDamaged need not ask the OS
- -- A challenger has to beat the incumbent by this much before the panel changes its mind.
- -- A fight opening on a second front should not swing the whole ranking the moment it edges
- -- ahead, and a lull on the main front should not hand the panel to whoever poked us last.
- local FOCUS_SWITCH_MARGIN = 1.25
- -- Below this much traded damage nobody counts as an opponent you are fighting. A scout
- -- trading pot shots is not a front, and one stray shot should not redirect production.
- local FOCUS_MIN_DAMAGE = 400
- local nextRefresh = 0 -- clock of the next recompute; reset to 0 to ask for one now
- local picks = {} -- ranked results, at most PICK_COUNT of them
- local facPicks = {} -- facID -> defID, the best-scoring unit that factory can build itself
- local emptyReason = nil -- why there are no picks, for the empty panel
- local dirty = true
- local waterFraction = 0
- -- Auto-queue bookkeeping. Every order we place is recorded so it can be taken back;
- -- nothing we did not place is ever touched.
- local autoOrders = {} -- array of {facID, tag, defID}, orders we placed and can take back
- local pendingAuto = nil -- an order issued but not yet visible in the queue
- local ourTags = {} -- tag -> true, rebuilt from autoOrders each poll
- local seenTags = {} -- facID -> {tag -> true} from the previous poll
- local armed = false -- currently spending metal that would otherwise be wasted
- local yielded = false -- the player queued something during this excess; hands off while it is queued
- local playerTags = {} -- facID -> {tag -> true}, the player orders `yielded` is waiting on
- -- One table rather than two locals, because this block is close to Lua's 200-local ceiling.
- -- picks: the last non-empty facPicks. The ranking goes blank whenever everything seen
- -- is filtered out (commanders, cons and buildings early on, say), and a blank
- -- ranking used to leave every factory idle while the bar sat full.
- -- refused: facID -> clock time until which it is skipped, after an order to it never
- -- showed up. It stays the emptiest queue, so it used to be picked again every
- -- pass and every other factory waited behind it forever.
- local aqHold = {picks = {}, refused = {}}
- local REFUSED_COOLDOWN = 60
- local nextAutoAdd = 0
- local PICK_COUNT = 4
- local STATIC_THREAT_WEIGHT = 0.5 -- enemy buildings can be walked around, so they count for less
- local WATER_THRESHOLD = 0.12 -- below this share of submerged map, ships are pointless
- local RetractAll -- forward declaration, defined below
- local SetPanelVisible -- forward declaration, defined below
- local SetDebugVisible -- forward declaration, defined below
- local LogRoles -- forward declaration, defined below
- -- Everything the last ranking did, for the debug panel to read. Nil whenever the ranking
- -- bailed out early, in which case `emptyReason` above is the whole story and the panel says
- -- so. Only built while that panel is on: see BuildTrace.
- local trace = nil
- --------------------------------------------------------------------------------
- -- Options (auto-registered into the settings menu, persisted to ZK_data.lua)
- --------------------------------------------------------------------------------
- options_path = 'Settings/HUD Panels/Build Proposer'
- options_order = {'showPanel', 'iconOnly', 'debugPanel', 'updateRate', 'horizon', 'availWeight',
- 'qualityWeight', 'counterStrength', 'counterSource',
- 'mobileOnly', 'ignoreCommanders', 'ignoreCons', 'ignoreAA', 'ignoreAir',
- 'ignoreEco', 'noBoats',
- 'focusMode', 'focusBias', 'focusHarmBias', 'focusIdle', 'focusMemory',
- 'dampOwnRoles', 'showFactoryTag', 'autoQueueMode', 'leadTime',
- 'maxAutoQueued', 'autoQueueSkipSilos', 'autoQueueSkipAir', 'logRoles'}
- options = {
- showPanel = {
- name = 'Show Build Proposer panel',
- desc = 'Hide the on-screen list without unloading the widget. Everything behind it keeps '
- .. 'running while hidden -- the ranking, the fronts it is tracking, and the '
- .. 'auto-queue below, which goes on placing and retracting orders unwatched. '
- .. 'WG.CounterMatrix stays published either way.',
- type = 'bool',
- value = true,
- OnChange = function(self)
- SetPanelVisible(self.value)
- end,
- },
- iconOnly = {
- name = 'Build Proposer: icons only',
- desc = 'Replace the four-row list with one icon and name for every factory that has a '
- .. 'unit queued, showing the best unit for that factory. With every queue empty it '
- .. 'shows the single best pick instead. Hover an icon for the reasoning the list would '
- .. 'have shown, click it to queue at that factory exactly as a row does. It is its own '
- .. 'window, so it docks separately and keeps its own position in tweak mode.',
- type = 'bool',
- value = true,
- OnChange = function()
- SetPanelVisible(options.showPanel.value)
- end,
- },
- debugPanel = {
- name = 'Show Build Proposer debug panel',
- desc = 'A second window showing the working behind the ranking rather than its result: '
- .. 'which enemy units are being countered and how heavily each one weighs, the '
- .. 'multiplier chain behind each of the four picks, and what the top pick is being '
- .. 'charged for. Off by default, and free while off -- the breakdowns are only '
- .. 'gathered when something is going to read them. Drag it in tweak mode like any '
- .. 'other panel.',
- type = 'bool',
- value = false,
- OnChange = function(self)
- SetDebugVisible(self.value)
- end,
- },
- updateRate = {
- name = 'Ranking update interval (s)',
- desc = 'How often the ranking is recomputed. Scoring is heavier than a plain unit count, '
- .. 'so this is slower than the Enemy Intel table by default.',
- type = 'number',
- min = 0.25, max = 5, step = 0.25, value = 0.25,
- },
- horizon = {
- name = 'Availability horizon (s)',
- desc = 'How far ahead you care about. A unit that takes about this long to afford and '
- .. 'build is scored at half weight. Lower values favour cheap units you can field now.',
- type = 'number',
- min = 5, max = 120, step = 5, value = 30,
- OnChange = function() dirty = true end,
- },
- availWeight = {
- name = 'Weight on availability',
- desc = '0 ranks purely on how well the unit counters the enemy, ignoring whether you can '
- .. 'afford it. Higher values push the ranking towards what you can actually field soon.',
- type = 'number',
- min = 0, max = 2, step = 0.1, value = 0,
- OnChange = function() dirty = true end,
- },
- qualityWeight = {
- name = 'Weight: pick quality',
- desc = 'How much a unit being generally good counts, on top of how well it happens to '
- .. 'counter what the enemy has. Two units can sit in the same square of the matrix '
- .. 'and still not be worth the same metal, and this is the tier list that says so. '
- .. '0 ignores it entirely and ranks on counter-picking alone; 1 makes a tier worth '
- .. 'about a sixth of a matchup step. Raise it to be told what is good rather than '
- .. 'what is clever. Unrated units are unaffected either way.',
- type = 'number',
- min = 0, max = 3, step = 0.1, value = 0,
- OnChange = function() dirty = true end,
- },
- counterStrength = {
- name = 'Counter-presence penalty',
- desc = 'How much the mere presence of a hard counter drags a pick down across the whole '
- .. 'enemy army, not just against those units. A riot screen protects everything '
- .. 'behind it from your raiders. 0 turns this off and scores each matchup on its own.',
- type = 'number',
- min = 0, max = 2, step = 0.1, value = 1,
- OnChange = function() dirty = true end,
- },
- counterSource = {
- name = 'Counter presence reads',
- desc = 'What the penalty above looks at. Archetype columns is the blunt reading: a pick '
- .. 'is held back by whatever share of the enemy army sits in a column its own '
- .. 'archetype row scores badly against, whichever units those happen to be. The '
- .. 'matrix reading puts every enemy unit type to the counter matrix instead, in '
- .. 'both directions, so a single named counter standing on the field -- an Outlaw '
- .. 'against your raiders, a Racketeer against your shield ball -- holds a pick back '
- .. 'even when its archetype is fine, while an enemy that cannot shoot your pick at '
- .. 'all stops holding it back at all. It is graded rather than counted, so at the '
- .. 'same setting of the slider above it damps more gently than the columns do; '
- .. 'raise that slider if you preferred the harder version. Set it to 0 to turn '
- .. 'either reading off.',
- type = 'radioButton',
- value = 'matrix',
- items = {
- {
- key = 'matrix',
- name = 'Every enemy unit, through the matrix',
- desc = 'Each enemy unit type is looked up against your pick in the counter '
- .. 'matrix, and the ones that beat it drag it down in proportion to how '
- .. 'much of the enemy army they are and how badly they beat it. Sees the '
- .. 'specific counters that a per-archetype reading cannot -- an Outlaw '
- .. 'against your raiders, a Racketeer against your shield ball -- and sees '
- .. 'that the wall of anti air in front of you is not holding your tanks '
- .. 'back. Every pairing gets an answer, because a row that does not name '
- .. 'the target falls back to its column for that class.',
- },
- {
- key = 'arch',
- name = 'Archetype columns only',
- desc = 'Count the share of the enemy army sitting in the columns this pick\'s '
- .. 'archetype row scores at or below 0.55, and damp by that share. Coarser '
- .. 'and blind to which particular unit it is, reading only the class the '
- .. 'target belongs to rather than the target.',
- },
- },
- OnChange = function() dirty = true end,
- },
- mobileOnly = {
- name = 'Counter mobile units only',
- desc = 'Ignore enemy buildings when ranking, so the picks answer their army rather than '
- .. 'their turrets and economy. Static anti-air still counts against air picks either '
- .. 'way, since it shoots you whether or not you meant to attack it.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- ignoreCons = {
- name = 'Ignore enemy constructors',
- desc = 'Leave enemy constructors out of the army being answered. They are not a fight, '
- .. 'and everything beats them, so a ranking that counts them drifts towards whatever '
- .. 'is quickest at chasing builders rather than towards what wins the engagement. '
- .. 'Leave it off if hunting their expansion is the plan, since raiders score well '
- .. 'here for exactly that reason.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- ignoreAA = {
- name = 'Ignore enemy anti-air',
- desc = 'Leave enemy anti-air out of the army being answered -- the archetype that only '
- .. 'shoots aircraft, mobile and turret alike. It cannot touch a ground army, so if '
- .. 'you are not flying it is metal that will never trade with you, and counting it '
- .. 'only dilutes the units that will. Fighters are a separate archetype and are not '
- .. 'affected. This does not make the sky look emptier: whether an air push is worth '
- .. 'trying is still judged against every anti-air they own, on the same reasoning as '
- .. 'the mobile-only setting above.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- ignoreAir = {
- name = 'Ignore enemy air',
- desc = 'Leave enemy aircraft out of the army being answered -- gunships, planes and '
- .. 'bombers, and the fighters and air constructors with them. Most of a ground '
- .. 'army cannot shoot up at all, so a handful of gunships scouted across the map '
- .. 'carries far more of the ranking than it should and pulls the picks towards '
- .. 'anti-air that the rest of their army then walks straight past. Turn this on '
- .. 'when their air is for somebody else to answer -- static anti-air you are placing '
- .. 'yourself, or an ally holding the sky -- and off whenever meeting it with your '
- .. 'army is the plan. It does not make your own air picks look safer: whether an '
- .. 'air push is worth trying is still judged against every anti-air they own, on '
- .. 'the same reasoning as the settings above.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- ignoreEco = {
- name = 'Ignore enemy economy buildings',
- desc = 'Leave unarmed enemy buildings out of the army being answered -- mexes, solars, '
- .. 'storage, radar, the whole undefended half of their base -- while their turrets '
- .. 'still count. The narrower half of "counter mobile units only": that one drops '
- .. 'their static defence too, this one keeps it, so the picks still have to get past '
- .. 'a Stinger while no longer being scored on how fast they flatten a solar farm.',
- type = 'bool',
- value = false,
- OnChange = function() dirty = true end,
- },
- ignoreCommanders = {
- name = 'Ignore enemy commanders',
- desc = 'Leave enemy commanders out of the army being answered. A commander is 1200 metal '
- .. 'that is always on the field, so early on it can be most of the enemy metal you '
- .. 'have scouted, and the ranking answers it rather than the army coming at you -- '
- .. 'anti-heavy and assassination picks at the top while their raiders are the actual '
- .. 'problem. Turn this on when you have no intention of going for the commander, off '
- .. 'when killing it is the plan. Enemy anti-air is still judged across everything they '
- .. 'own either way, commanders included, on the same reasoning as the setting above.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- focusMode = {
- name = 'Whose army to answer',
- desc = 'In a team game the enemy side owns far more than you will ever meet. Countering '
- .. 'all of it builds against three players you are not fighting; countering only the '
- .. 'front you are on builds against the army actually coming at you. Who you are '
- .. 'fighting is worked out from damage traded, ours dealt and ours taken alike; who '
- .. 'is hurting you from their half of that alone. Both fade out over the memory '
- .. 'below. Falls back to the whole enemy side whenever no fight has been seen yet, '
- .. 'and does nothing at all in a 1v1.',
- type = 'radioButton',
- value = 'balanced',
- items = {
- {
- key = 'all',
- name = 'Everyone',
- desc = 'Every enemy unit Enemy Intel remembers, whoever owns it.',
- },
- {
- key = 'fighting',
- name = 'Players you are fighting',
- desc = 'Only the armies of the opponents you have actually traded damage with, '
- .. 'each weighted by how much damage you have traded with them, so the front '
- .. 'you are fighting hardest shapes the ranking most. The right answer when '
- .. 'you are holding against two of them at once, or when an ally is fighting '
- .. 'someone you never meet.',
- },
- {
- key = 'balanced',
- name = 'Fighting, balanced against who hurts you',
- desc = 'The same opponents as above, but each army weighted by a blend of the '
- .. 'damage you have traded with it and the damage it has landed on you, '
- .. 'mixed by the second slider below. The front you are busiest on and the '
- .. 'front that is actually hurting you are rarely the same player: a fight '
- .. 'you are winning eats most of your shots and none of your army. This '
- .. 'answers both in proportion instead of picking one.',
- },
- {
- key = 'main',
- name = 'The player you are fighting most',
- desc = 'Only the army of the single opponent you have traded the most damage '
- .. 'with. The sharpest counter-pick, and the one that swings hardest when '
- .. 'the fight moves to a different player.',
- },
- {
- key = 'threat',
- name = 'The player hurting you most',
- desc = 'Only the army of the single opponent that has dealt the most damage to '
- .. 'your units, ignoring everything you have dealt back. Answers whoever is '
- .. 'actually killing your things rather than whoever you have spent the most '
- .. 'shots on, which is the question worth asking while you are losing '
- .. 'ground. Falls back to the whole enemy side until something has hurt you.',
- },
- {
- key = 'hurting',
- name = 'Every player hurting you, in proportion',
- desc = 'Every opponent that has landed damage on you, each weighted by its share '
- .. 'of that damage, so two players grinding you down are both answered and '
- .. 'the one hurting you twice as hard counts twice as much. The spread '
- .. 'version of the mode above: it will not throw the whole ranking at one '
- .. 'player the moment they edge ahead, while a front you are shelling for '
- .. 'free still shapes nothing. An opponent you have traded shots with but '
- .. 'taken no damage from drops out entirely.',
- },
- {
- key = 'attackers',
- name = 'Only the units that have shot you',
- desc = 'Forget players. Rank against the individual enemy units that have '
- .. 'actually damaged you, each weighted by how much damage it has done, and '
- .. 'drop each one the moment it dies. The narrowest answer there is: even a '
- .. 'single opponent owns far more than has ever met you, so this counters the '
- .. 'Ripper killing your Glaives rather than the Ripper company parked across '
- .. 'the map, and it forgets a threat as soon as you have killed it. The only '
- .. 'mode here that does something in a 1v1. Enemy anti-air is still judged '
- .. 'against the whole armies behind those units, because AA that has not shot '
- .. 'you yet is exactly the AA that kills your first gunship.',
- },
- },
- OnChange = function() dirty = true end,
- },
- focusBias = {
- name = 'Bias towards the main front',
- desc = 'Only used by the modes that spread across several opponents: the two "fighting" '
- .. 'ones and "Every player hurting you". How hard the ranking leans towards the front '
- .. 'leading their tally. 0 weighs every front you are on equally. 1 weighs '
- .. 'each army by its share of that tally, so a front counting half as much shapes '
- .. 'the ranking half as much. Higher values push further towards answering the '
- .. 'leading fight alone.',
- type = 'number',
- min = 0, max = 3, step = 0.1, value = 1,
- OnChange = function() dirty = true end,
- },
- focusHarmBias = {
- name = 'Weigh damage taken over damage traded',
- desc = 'Only used by "Fighting, balanced against who hurts you". 0 ranks the fronts '
- .. 'purely by damage traded, which is what "Players you are fighting" does. 1 ranks '
- .. 'them purely by the damage they have landed on you, so a front you are shelling '
- .. 'for free stops shaping the picks and one that is quietly grinding you down '
- .. 'starts. In between, every front is scored on both, each as a share of the front '
- .. 'leading it.',
- type = 'number',
- min = 0, max = 1, step = 0.05, value = 0.5,
- OnChange = function() dirty = true end,
- },
- focusIdle = {
- name = 'Drop a quiet front after (s)',
- desc = 'An opponent you have not traded a shot with for this long stops counting '
- .. 'towards the ranking entirely. A fight you won and walked away from should not '
- .. 'keep shaping what you build. 0 never drops a front and leaves the memory '
- .. 'half-life below to fade it out on its own. In "Only the units that have shot '
- .. 'you" the same rule applies one level down, to each individual unit.',
- type = 'number',
- min = 0, max = 900, step = 30, value = 300,
- OnChange = function() dirty = true end,
- },
- focusMemory = {
- name = 'Engagement memory (s)',
- desc = 'Half-life of the damage tally that decides who you are fighting. A front you '
- .. 'stop fighting fades to half its weight over this long, so the panel follows the '
- .. 'battle rather than remembering a skirmish from ten minutes ago. Fades the damage '
- .. 'taken alongside it, and the per-unit tally behind the units mode with it. Only '
- .. 'used by the focused modes above.',
- type = 'number',
- min = 30, max = 600, step = 15, value = 120,
- OnChange = function() dirty = true end,
- },
- dampOwnRoles = {
- name = 'Damp roles you already have',
- desc = 'Off by default: the four weights are a pure counter ranking, so near-identical '
- .. 'picks can appear together. Turn on to push the list towards a mixed army instead.',
- type = 'bool',
- value = false,
- OnChange = function() dirty = true end,
- },
- showFactoryTag = {
- name = 'Show factory tag',
- desc = 'Label each pick with the factory that would build it.',
- type = 'bool',
- value = false,
- OnChange = function() dirty = true end,
- },
- autoQueueMode = {
- name = 'Build the proposals automatically',
- type = 'radioButton',
- value = 'excess',
- items = {
- {
- key = 'off',
- name = 'Off',
- desc = 'The panel only proposes. Nothing is ever queued unless you click it.',
- },
- {
- key = 'excess',
- name = 'Only when excessing metal',
- desc = 'When your income is about to fill metal storage, quietly queue at every '
- .. 'factory, each building the best answer it can, so the metal is not '
- .. 'wasted. Once the bar comes back down, anything of ours not yet started '
- .. 'is pulled, so only overflow is ever spent. The moment you queue anything '
- .. 'yourself, our orders are pulled at every factory and nothing more is '
- .. 'added until what you queued is built or cancelled, or the next time '
- .. 'metal overflows. Orders you placed are never touched.',
- },
- {
- key = 'always',
- name = 'Continuously, at every factory',
- desc = 'Keep every factory working on the best answer it can build itself, whatever '
- .. 'the metal situation. Each factory is topped up to the queue length below, '
- .. 'counting your own orders, so anything you queue is built first and we only '
- .. 'fill what is left.',
- },
- },
- OnChange = function(self)
- -- The player-order watch keeps a snapshot per factory, and a snapshot taken
- -- under another mode is not something to draw conclusions from.
- seenTags = {}
- yielded = false
- playerTags = {}
- if self.value == 'off' then
- armed = false
- if RetractAll then RetractAll() end
- end
- end,
- },
- leadTime = {
- name = 'Start this long before excess (s)',
- desc = 'Begin queueing once the current net income would fill the remaining metal '
- .. 'storage within this many seconds, rather than waiting for the bar to cap.',
- type = 'number',
- min = 0, max = 30, step = 1, value = 30,
- },
- maxAutoQueued = {
- name = 'Queue depth',
- desc = 'Per factory, either way. When excessing: how many orders of our own each '
- .. 'factory may have outstanding at once. Continuously: how deep each factory '
- .. 'queue is kept, your own orders included.',
- type = 'number',
- min = 1, max = 20, step = 1, value = 6,
- },
- noBoats = {
- name = 'Build Proposer: never propose boats',
- desc = 'Leave ships and submarines out of the ranking altogether, so they are never '
- .. 'proposed and never auto-queued. Hovercraft and amphibious units are not boats '
- .. 'and stay in. A Shipyard with nothing else to build gets no pick. On a map too '
- .. 'dry to float anything, boats are left out anyway.',
- type = 'bool',
- value = false,
- OnChange = function() dirty = true end,
- },
- autoQueueSkipSilos = {
- name = 'Auto-queue: skip missile silos',
- desc = 'Keep the auto-queue off the Missile Silo. A silo shot is a decision about when '
- .. 'and where to spend it rather than about what should be coming off a line, and '
- .. 'filler that quietly stocks four warheads out of an overflow has taken that '
- .. 'decision away from you. The panel still ranks silo munitions like anything '
- .. 'else -- this only stops them being queued on your behalf. Orders of ours '
- .. 'already sitting at a silo are taken back when you switch this on, bar one '
- .. 'already being built.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- autoQueueSkipAir = {
- name = 'Auto-queue: skip air factories',
- desc = 'Keep the auto-queue off the Airplane Plant and the Gunship Plant, and off '
- .. 'their plates. Air is a commitment rather than a unit -- it wants to be massed, '
- .. 'flown together and kept away from anti-air -- so a plane queued on its own out '
- .. 'of an overflow usually flies off and dies alone. Leaves the rest of the base '
- .. 'auto-building as before. Same as above: they are still proposed, just never '
- .. 'queued for you, and orders of ours already at such a plant are taken back.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- logRoles = {
- name = 'Log unit roles to console',
- desc = 'Prints how every unit your factories can build has been classified, and marks '
- .. 'the ones the icon and the curated list disagree about. For checking the model.',
- type = 'button',
- OnChange = function()
- if LogRoles then LogRoles() end
- end,
- },
- }
- --------------------------------------------------------------------------------
- -- Economy
- --
- -- Spring reports a storage figure inflated by Zero-K's hidden buffer (HIDDEN_STORAGE in
- -- LuaRules/Configs/constants.lua), and every Zero-K panel subtracts it before showing or
- -- comparing anything. Using the raw figure is why this never fired: with 1500 real metal
- -- storage the bar caps at 1500, but GetTeamResources reports 11500.
- --------------------------------------------------------------------------------
- local HIDDEN_STORAGE = 10000
- local MIN_STORAGE = 0.5
- -- How full the bar has to stay for an arming to hold. Arming reads the net rate, but the
- -- rate stops being a fair reading of the surplus the moment we act on it, so what we are
- -- already sitting on is what keeps us armed. See UpdateArmed.
- local EXCESS_HOLD = 0.9
- -- Returns current metal, real storage, and the net rate, all as the economy panel sees them.
- local function GetMetalState()
- local mCurr, mStor, mPull, mInco, _, _, _, mReci = spGetTeamResources(myTeamID, "metal")
- if not mCurr then
- return nil
- end
- -- Zero-K charges some pull outside the engine's own accounting.
- mPull = (mPull or 0) + (spGetTeamRulesParam(myTeamID, "extraMetalPull") or 0)
- local stor = math.max((mStor or 0) - HIDDEN_STORAGE, MIN_STORAGE)
- if mCurr > stor then
- mCurr = stor
- end
- return mCurr, stor, (mInco or 0) - mPull + (mReci or 0)
- end
- -- True when storage is full, or when the current net rate would fill what is left of it
- -- within `lead` seconds. Waiting for the bar to actually cap means the first metal is
- -- already wasted by the time anything is queued, hence starting early.
- local function ProjectedToExcess(lead)
- local cur, stor, net = GetMetalState()
- if not cur or net <= 0 then
- return false
- end
- return (stor - cur) <= net * lead
- end
- --------------------------------------------------------------------------------
- -- Who we are fighting
- --
- -- `UnitDamaged` reaches a widget for its own units always and for enemy units in sight,
- -- which between them covers every exchange we are part of: our shots land on units we can
- -- see by definition, and shots landing on us are ours to see either way. Damage dealt by
- -- an ally to someone we never meet is invisible here, which is exactly the intent -- the
- -- question is which front *we* are on, not which fronts exist.
- --------------------------------------------------------------------------------
- local function RebuildEnemyTeams()
- enemyTeams = {}
- local gaia = Spring.GetGaiaTeamID()
- local teams = spGetTeamList()
- if not teams then
- return
- end
- for i = 1, #teams do
- local t = teams[i]
- if t ~= gaia then
- local _, _, _, _, _, allyTeam = spGetTeamInfo(t, false)
- if allyTeam and allyTeam ~= myAllyTeamID then
- enemyTeams[t] = true
- end
- end
- end
- end
- -- Zero-K's own convention: a team is named after its leader, or after the AI running it.
- local function TeamName(teamID)
- local _, leader, _, isAI = spGetTeamInfo(teamID, false)
- local name
- if isAI then
- name = select(2, spGetAIInfo(teamID))
- elseif leader then
- name = spGetPlayerInfo(leader, false)
- end
- return name or ("Team " .. tostring(teamID))
- end
- -- Damage fades rather than accumulating for the game, so the panel follows the battle
- -- instead of remembering a skirmish from ten minutes ago.
- local function DecayEngagement(now)
- local dt = now - lastDecay
- if dt <= 0 then
- return
- end
- lastDecay = now
- local half = options.focusMemory.value
- if half <= 0 then
- return
- end
- local keep = 0.5 ^ (dt / half)
- for team, v in pairs(engagement) do
- v = v * keep
- engagement[team] = (v >= 1) and v or nil
- end
- for team, v in pairs(harm) do
- v = v * keep
- harm[team] = (v >= 1) and v or nil
- end
- -- The per-unit tally fades on the same half-life and for the same reason: the Ripper
- -- that clipped you once four minutes ago is not what you are building against now. It is
- -- also the only way a unit that died out of sight ever leaves the books, since a death
- -- we did not witness never reaches UnitDestroyed.
- for uID, rec in pairs(attackers) do
- local v = rec.dmg * keep
- if v >= 1 then
- rec.dmg = v
- else
- attackers[uID] = nil
- end
- end
- end
- -- The tally a mode ranks opponents by.
- --
- -- "Fighting" and "fought most" ask who the shooting is between, so they read the traded
- -- total. The two modes that ask who is winning it -- "hurting you most" and "every player
- -- hurting you" -- read only what has landed on us. The balanced mode scores every front on
- -- both, each as a share of the front leading that tally, and mixes them by the slider: an
- -- opponent who has hurt you badly still counts when most of your own shooting has gone
- -- somewhere else, and one you are shelling for free stops drowning them out. The blend is
- -- rescaled back into damage at the end so the thresholds it meets still mean what they say.
- local function FocusMetric(mode, fought)
- if mode == 'threat' or mode == 'hurting' then
- return harm
- end
- if mode ~= 'balanced' then
- return engagement
- end
- local tradedMax, harmMax = 0, 0
- for team in pairs(fought) do
- local t, h = engagement[team] or 0, harm[team] or 0
- if t > tradedMax then tradedMax = t end
- if h > harmMax then harmMax = h end
- end
- local b = options.focusHarmBias.value
- local blended = {}
- for team in pairs(fought) do
- local t = (tradedMax > 0) and ((engagement[team] or 0) / tradedMax) or 0
- local h = (harmMax > 0) and ((harm[team] or 0) / harmMax) or 0
- blended[team] = ((1 - b) * t + b * h) * tradedMax
- end
- return blended
- end
- -- The units mode, which answers a hand-assembled army rather than anybody's roster.
- --
- -- Downstream the ranking weighs each enemy type by `count * cost`, so damage is turned back
- -- into a count by dividing the cost out again: a type that has done twice the damage then
- -- carries twice the weight whatever it costs, which is what "in proportion to how much it
- -- hurt you" has to mean. Weighing the survivors by cost instead would say a Detriment that
- -- has grazed you matters more than the Ripper company taking your front apart.
- --
- -- Teams are still worked out, but only so the header and the tooltip can say whose units
- -- these are, and so the anti-air check in Recompute has whole armies to look at.
- local function AttackerFocus(incumbent)
- local units, total = {}, 0
- for _, rec in pairs(attackers) do
- local c = cost[rec.def]
- if c and c > 0 then
- units[rec.def] = (units[rec.def] or 0) + rec.dmg / c
- total = total + rec.dmg
- end
- end
- -- The same floor the player modes use, for the same reason: one stray shot is not a
- -- reason to redirect production.
- if total < FOCUS_MIN_DAMAGE then
- focusNote = (total > 0) and "nothing has hurt you much yet" or "nothing has hurt you yet"
- return
- end
- focusUnits = units
- local perTeam, best, bestVal = {}, nil, 0
- for _, rec in pairs(attackers) do
- perTeam[rec.team] = (perTeam[rec.team] or 0) + rec.dmg
- end
- for team, v in pairs(perTeam) do
- if v > bestVal then
- best, bestVal = team, v
- end
- end
- local held = incumbent and perTeam[incumbent]
- if held and best ~= incumbent and bestVal <= held * FOCUS_SWITCH_MARGIN then
- best = incumbent
- end
- focusMain = best
- focusTeams, focusWeights = {}, {}
- for team, v in pairs(perTeam) do
- focusTeams[team] = true
- focusWeights[team] = v / bestVal
- end
- end
- -- Decides whose army the ranking answers this pass. Sets `focusTeams` to nil whenever the
- -- answer is "everyone", which is also what every failure falls back to: a panel that
- -- proposes against the whole enemy side is merely blunt, one that proposes against nothing
- -- is useless.
- local function UpdateFocus()
- local mode = options.focusMode.value
- local incumbent = focusMain -- read before the reset below; the hysteresis needs it
- focusTeams, focusWeights, focusUnits, focusMain, focusNote = nil, nil, nil, nil, nil
- if mode == 'all' then
- return
- end
- -- A front that has gone quiet stops counting outright, rather than fading for as long
- -- as the half-life takes. A fight you won ten minutes ago and walked away from says
- -- nothing about what to build now, however hard it was at the time.
- local idle = options.focusIdle.value
- if idle > 0 then
- for team, when in pairs(lastFought) do
- if clockNow - when > idle then
- engagement[team] = nil
- harm[team] = nil
- lastFought[team] = nil
- end
- end
- -- The same rule one level down: a unit that shot you once and has left you alone
- -- since is no more part of the fight than a player who has.
- for uID, rec in pairs(attackers) do
- if clockNow - rec.last > idle then
- attackers[uID] = nil
- end
- end
- end
- -- Deliberately ahead of the head count below. Counting single units narrows the ranking
- -- in a 1v1 just as much as in a team game -- one opponent still owns far more than has
- -- ever met you -- so the "nothing to narrow to" shortcut does not apply here.
- if mode == 'attackers' then
- AttackerFocus(incumbent)
- return
- end
- -- With a single opponent the modes are the whole enemy side anyway, and saying so is
- -- better than waiting for first blood to say the same thing.
- local only, count = nil, 0
- for t in pairs(enemyTeams) do
- count = count + 1
- only = t
- end
- if count == 1 then
- focusMain = only
- return
- end
- if count == 0 then
- return -- spectating a finished game, or teams not resolved yet
- end
- -- Which teams are on a front at all is one question for every mode, and the traded total
- -- answers it: a mode that ranks by damage taken still should not count a player we have
- -- never met. Damage taken is part of the traded total, so nothing any mode cares about
- -- can qualify on its own tally without qualifying here first.
- local fought, anyFought = {}, false
- for team, v in pairs(engagement) do
- if v >= FOCUS_MIN_DAMAGE then
- fought[team], anyFought = true, true
- end
- end
- if not anyFought then
- focusNote = "no fight seen yet"
- return
- end
- local metric = FocusMetric(mode, fought)
- local best, bestVal = nil, 0
- for team in pairs(fought) do
- local v = metric[team] or 0
- if v > bestVal then
- best, bestVal = team, v
- end
- end
- if not best then
- -- Shots have been traded, but nothing has landed on us, so the modes that rank by
- -- damage taken have nothing to rank yet. Answering the whole enemy side beats picking
- -- a front out of a tally that is all zeroes.
- focusNote = "nothing has hurt you yet"
- return
- end
- -- Hysteresis on the leader only. Which teams qualify at all is a plain threshold: a
- -- front either has a fight on it or it does not, and there is nothing to flip between.
- local held = incumbent and metric[incumbent]
- if held and best ~= incumbent and bestVal <= held * FOCUS_SWITCH_MARGIN then
- best = incumbent
- end
- focusMain = best
- if mode == 'main' or mode == 'threat' then
- focusTeams, focusWeights = {[best] = true}, {[best] = 1}
- return
- end
- -- Every front you are on counts, but not equally: each army is weighted by its share of
- -- this mode's tally, so two fronts are answered in the proportion you are actually
- -- fighting them rather than as one undifferentiated enemy. Shares are taken against the
- -- heaviest front rather than the hysteresis pick, so the weights follow the damage even
- -- in the moment the header has not caught up yet. A front scoring zero on the tally is
- -- left out entirely, which is how the blend at its extreme drops a fight you are taking
- -- nothing from.
- local bias = options.focusBias.value
- focusTeams, focusWeights = {}, {}
- for team in pairs(fought) do
- local v = metric[team] or 0
- if v > 0 or team == best then
- focusTeams[team] = true
- focusWeights[team] = (bias > 0) and ((v / bestVal) ^ bias) or 1
- end
- end
- end
- function M:UnitDamaged(unitID, unitDefID, unitTeam, damage, paralyzer, _, _,
- attackerID, attackerDefID, attackerTeam)
- -- Tallied whatever the mode is set to, so that turning a focused mode on mid-game
- -- narrows the ranking immediately rather than waiting for the next fight to start.
- if not damage or damage <= 0 or not unitTeam then
- return
- end
- local enemy, theirShot
- if unitTeam == myTeamID then
- -- what is shooting at us; nil when the shooter is out of sight
- enemy, theirShot = attackerTeam, true
- elseif attackerTeam == myTeamID then
- enemy = unitTeam -- what we are shooting at
- end
- if not enemy or not enemyTeams[enemy] then
- return
- end
- -- Paralyser numbers are large and kill nothing, and overkill is reported in full, so a
- -- Detriment shot at a Glaive would otherwise register as a battle on its own.
- if paralyzer then
- damage = damage * 0.5
- end
- local hp = unitDefID and health[unitDefID]
- if hp and damage > hp then
- damage = hp
- end
- engagement[enemy] = (engagement[enemy] or 0) + damage
- if theirShot then
- harm[enemy] = (harm[enemy] or 0) + damage
- -- The shooter itself, for the units mode. Both are nil when the shot came from
- -- something we cannot see, which is the same blind spot that leaves `attackerTeam`
- -- nil above: an unseen shooter is simply not on anybody's list.
- if attackerID and attackerDefID then
- local rec = attackers[attackerID]
- if rec and rec.def == attackerDefID then
- rec.dmg = rec.dmg + damage
- rec.last = clockNow
- else
- -- No matching record, or the engine has recycled that unitID onto a new unit.
- attackers[attackerID] = {def = attackerDefID, team = enemy,
- dmg = damage, last = clockNow}
- end
- end
- end
- lastFought[enemy] = clockNow
- end
- -- What makes the units mode a different question from every mode above it: a dead unit is
- -- not something to build against, so it leaves the tally outright rather than fading.
- --
- -- This only reaches us for enemies visible as they die, which is nearly always true of
- -- something that has been shooting at us. Anything that dies out of sight decays out on the
- -- engagement half-life instead, which is slower but gets there.
- function M:UnitDestroyed(unitID)
- attackers[unitID] = nil
- end
- --------------------------------------------------------------------------------
- -- Scoring
- --------------------------------------------------------------------------------
- -- The archetype an enemy presents to the matrix. A building is a position rather than a
- -- unit -- it can neither chase nor retreat -- so an armed one is answered as static
- -- defence and an unarmed one is just economy standing in the open.
- local function ColumnArch(enemyDef)
- if isStatic[enemyDef] then
- return isArmed[enemyDef] and "defense" or "eco"
- end
- return role[enemyDef]
- end
- -- One lookup, no arithmetic. The number in the table is the answer.
- --
- -- Both sides go in under `matrixName` rather than `defName`, which differs only for
- -- commanders: all seventy-two of them answer to one of four chassis rows.
- --
- -- `col` is only reached for a target the table does not name -- an unlisted building, a
- -- chicken, a drone, a unit added after the table was generated -- and picks the row's
- -- fallback column for that class. A row we have never heard of at all comes back even.
- local function MatrixValue(myDef, enemyDef, col)
- local row = MATRIX[matrixName[myDef]]
- if not row then
- return NEUTRAL
- end
- return row[matrixName[enemyDef]] or row["#" .. col] or NEUTRAL
- end
- -- The tier multiplier for a unit, independent of what it is being scored against.
- -- Unrated units come back 1.0, so this can only ever move a pick relative to its peers.
- local function Quality(defID)
- return QUALITY[defName[defID]] or 1
- end
- -- Can this even be a fight? No entry in the matrix means anything against a target you
- -- have no weapon for. Both of the terms below gate on this, in opposite directions: our
- -- pick shooting the enemy, and the enemy shooting our pick.
- local function CanEngage(shooter, target)
- if isAir[target] then
- return dpsAir[shooter] > 0
- elseif isSub[target] then
- return dpsWater[shooter] > 0
- end
- return dpsGround[shooter] > 0
- end
- -- The whole proposition calculation. One lookup in the counter matrix, gated on whether the
- -- fight can happen at all. There is no second term: cost, HP and DPS decide nothing here,
- -- only what counters what.
- local function PairScore(myDef, enemyDef, enemyAAShare)
- if not CanEngage(myDef, enemyDef) then
- return 0
- end
- local score = MatrixValue(myDef, enemyDef, ColumnArch(enemyDef))
- -- Dedicated AA hard-counters all air, and it does so with the AA already standing on
- -- the field, not only with the AA our pick happens to be scored against.
- if isAir[myDef] and enemyAAShare > 0 then
- score = score * ((1 - enemyAAShare) ^ 1.5)
- end
- return score
- end
- -- How hard one enemy unit type holds a pick of ours back, as a non-negative figure in log
- -- space: 0 is "no opinion, or the matchup is ours", and about 0.8 is an outright counter.
- -- Weighted by that unit's share of the enemy army and summed over the army, this is what
- -- the matrix reading of the counter-presence penalty damps by. It is deliberately one-sided:
- -- a pairing our pick wins says nothing here, because winning it is what the ranking itself
- -- is already made of, and paying for the same matchup twice is how a term like this turns
- -- into a second opinion about the same question.
- --
- -- Both directions of the matrix are asked and the louder one answers, rather than the two
- -- being added. A named pair is written into the table in both directions already, so summing
- -- would charge for a hard counter twice; but each direction also knows something the other
- -- does not -- static defence is a column and never a row, so a Picket can only be read
- -- backwards -- and averaging would quietly halve whichever one of them spoke.
- local function CounterPressure(myDef, enemyDef)
- -- The gate above, read the other way round: something that cannot shoot our pick cannot
- -- screen anything from it, whatever the matrix thinks of the two archetypes in the
- -- abstract. This is what stops a wall of enemy anti-air holding back our tanks.
- if not CanEngage(enemyDef, myDef) then
- return 0
- end
- local bad = 0
- local reverse = MatrixValue(enemyDef, myDef, ColumnArch(myDef))
- if reverse > 1 then
- bad = math.log(reverse)
- end
- -- The forward direction only gets a say where our pick can shoot back at all. Having no
- -- weapon for a target is a hole in the answer rather than a claim about who wins the
- -- fight, and the pairwise average has already charged for it by scoring that pairing at
- -- nothing.
- if CanEngage(myDef, enemyDef) then
- local forward = MatrixValue(myDef, enemyDef, ColumnArch(enemyDef))
- if forward < 1 then
- local f = -math.log(forward)
- if f > bad then
- bad = f
- end
- end
- end
- return bad
- end
- -- A plant that takes orders and never builds them. Two of those: a factory plate whose
- -- parent is dead, stunned or out of range -- Zero-K's unit_factory_plate zeroes its build
- -- speed and publishes `nofactory` = 1 while it has no parent -- and a factory that is itself
- -- still a nanoframe. Both matter more than they sound, because both have an *empty queue*,
- -- and an empty queue is exactly what GatherCandidates below reads as "this is the plant that
- -- would get to it first". So an unlinked plate quietly won the attribution for every unit it
- -- can build, and the auto-queue fed it orders that could never come out. Fac Switcher asks a
- -- plate the same question to decide whether we already have its factory.
- local function PlantIdle(facID, facDefID)
- facDefID = facDefID or spGetUnitDefID(facID)
- if not facDefID then
- return true
- end
- local cp = UnitDefs[facDefID].customParams
- if cp and cp.child_of_factory and Spring.GetUnitRulesParam(facID, "nofactory") ~= 0 then
- return true
- end
- return Spring.GetUnitIsBeingBuilt(facID) == true
- end
- -- Every unit any factory we own could start building right now, mapped to the factory
- -- that would build it. Nothing outside this set can ever be proposed.
- --
- -- The second return is the other way round: every factory we own and what it can build,
- -- which is what continuous production needs. The first is a global ranking and names only
- -- one plant per unit, so it cannot say what the other plants should be doing.
- local function GatherCandidates()
- local candidates = {} -- unitDefID -> {facID, facDefID, queueLen}
- local factories = {} -- facID -> {facDefID, canBuild = {unitDefID -> true}}
- local units = spGetTeamUnits(myTeamID)
- if not units then
- return candidates, factories
- end
- for i = 1, #units do
- local facID = units[i]
- local facDefID = spGetUnitDefID(facID)
- if facDefID and isFactory[facDefID] and not PlantIdle(facID, facDefID) then
- local opts = UnitDefs[facDefID].buildOptions
- if opts then
- local queue = spGetFullBuildQueue(facID)
- local queueLen = 0
- if queue then
- for j = 1, #queue do
- for _, n in pairs(queue[j]) do
- queueLen = queueLen + n
- end
- end
- end
- local canBuild = {}
- factories[facID] = {facDefID = facDefID, canBuild = canBuild}
- for j = 1, #opts do
- local defID = opts[j]
- canBuild[defID] = true
- local prev = candidates[defID]
- -- Prefer whichever factory has the least queued up, so a pick is
- -- attributed to the plant that would actually get to it first.
- if not prev or queueLen < prev.queueLen then
- candidates[defID] = {
- facID = facID,
- facDefID = facDefID,
- queueLen = queueLen,
- }
- end
- end
- end
- end
- end
- return candidates, factories
- end
- -- Dumps the archetype of everything currently buildable. Classification is what the whole
- -- counter matrix hangs off, and it is the part of this widget most likely to be quietly
- -- wrong, so it is worth being able to read it.
- LogRoles = function()
- local list = {}
- for defID in pairs(GatherCandidates()) do
- list[#list + 1] = defID
- end
- if #list == 0 then
- Spring.Echo("Build Proposer: no factories owned, nothing to classify.")
- return
- end
- table.sort(list, function(a, b)
- if role[a] ~= role[b] then
- return role[a] < role[b]
- end
- return unitName[a] < unitName[b]
- end)
- Spring.Echo("Build Proposer: " .. #list .. " buildable units")
- for i = 1, #list do
- local d = list[i]
- local ud = UnitDefs[d]
- local flags = {}
- if isAA[d] then flags[#flags + 1] = "AA" end
- if isAir[d] then flags[#flags + 1] = "air" end
- if isSub[d] then flags[#flags + 1] = "sub" end
- if needsWater[d] then flags[#flags + 1] = "water" end
- -- Where the role came from, and whether the sources agreed.
- local iconGuess = ArchFromIcon(ud.iconType or "default")
- local curated = ARCHETYPE[ud.name]
- local note = ""
- if curated and iconGuess and curated ~= iconGuess then
- note = " (icon said " .. iconGuess .. ")"
- elseif not curated and not iconGuess then
- note = " (guessed from stats)"
- end
- Spring.Echo(string.format(" %-9s %-20s %-12s%s",
- role[d], unitName[d], table.concat(flags, ","), note))
- end
- end
- -- How soon this unit could realistically be standing on the map: the wait to afford it
- -- plus the time the factory needs to produce it.
- local function Availability(defID, cand, mCurr, netMetal, horizon)
- local bp = buildSpeed[cand.facDefID]
- local prodTime = (bp > 0) and (buildTime[defID] / bp) or 0
- local affordWait = 0
- local shortfall = cost[defID] - mCurr
- if shortfall > 0 then
- affordWait = shortfall / math.max(netMetal, 0.1)
- end
- return 1 / (1 + (prodTime + affordWait) / horizon)
- end
- --------------------------------------------------------------------------------
- -- The debug trace
- --
- -- A ranking is a chain of multipliers over a threat-weighted average of matchups, and the
- -- main panel shows only where that chain ended up. That is the right thing for it to show
- -- and the wrong thing to tune against: a pick sitting at 22% could be there because it
- -- counters the army, because everything better is unaffordable, or because the one enemy
- -- unit it loses to is a fifth of the field, and the panel reads the same in all three cases.
- -- This gathers the middle of the calculation so the panel below can say which it was.
- --
- -- It is a second pass rather than something the scoring loop accumulates as it goes, because
- -- every interesting breakdown is per enemy unit type and only four picks are ever read.
- -- Collecting them inside the loop would mean building fifty to throw away forty-six.
- --------------------------------------------------------------------------------
- local function BuildTrace(env)
- local threat, totalThreat = env.threat, env.totalThreat
- local t = {
- totalThreat = totalThreat,
- enemyAAShare = env.enemyAAShare,
- mobileOnly = env.mobileOnly,
- dropped = env.dropped, -- already ordered, heaviest exclusion first
- counterStrength = env.counterStrength,
- matrixCounter = env.matrixCounter,
- qualityWeight = env.qualityWeight,
- availWeight = env.availWeight,
- dampOwnRoles = env.dampOwnRoles,
- horizon = env.horizon,
- candidateCount = 0,
- army = {}, -- every enemy type the ranking answered, heaviest first
- picks = {}, -- the multiplier chain behind each of the four
- }
- for _ in pairs(env.candidates) do
- t.candidateCount = t.candidateCount + 1
- end
- -- Who is being countered. This list is the ranking's entire view of the enemy: anything
- -- not on it moved nothing, whatever Enemy Intel remembers of it.
- for defID, w in pairs(threat) do
- t.army[#t.army + 1] = {defID = defID, threat = w, share = w / totalThreat}
- end
- table.sort(t.army, function(a, b) return a.threat > b.threat end)
- for i = 1, #picks do
- local p = picks[i]
- local d = p.defID
- local e = {
- defID = d,
- base = p.base or 0,
- counterDamp = p.counterDamp or 1,
- mQuality = p.mQuality or 1,
- mAvail = p.mAvail or 1,
- mOwn = p.mOwn or 1,
- score = p.score,
- barFill = p.barFill,
- pressure = p.pressure or 0,
- counterUnit = p.counterUnit,
- counterRole = p.counterRole,
- tier = UNIT_TIER[defName[d]],
- facDefID = p.facDefID,
- gain = 0, weak = 0, blind = 0,
- matchups = {}, blindOn = {}, pressureOn = {},
- }
- -- The matchup average taken apart. `base` is a threat-weighted mean of pair scores
- -- around 1.0, so base = 1 + gain - weak - blind exactly: what this pick beats, what
- -- beats it, and what it has no weapon for. Three terms of one number rather than
- -- three opinions about it, which is what makes them worth showing side by side.
- for eDefID, w in pairs(threat) do
- local share = w / totalThreat
- local pair = PairScore(d, eDefID, env.enemyAAShare)
- if pair > 1 then
- e.gain = e.gain + share * (pair - 1)
- elseif not CanEngage(d, eDefID) then
- -- Charged the whole of its share: a pairing scored at nothing still divides
- -- into totalThreat, which is exactly why an unanswerable slice of the enemy
- -- army drags a pick down without ever appearing in the matchup list.
- e.blind = e.blind + share
- e.blindOn[#e.blindOn + 1] = {defID = eDefID, share = share}
- elseif pair < 1 then
- e.weak = e.weak + share * (1 - pair)
- end
- -- The same walk the ranking made, kept per enemy this time rather than summed.
- -- Only the matrix reading is per unit; the archetype one is a share of columns,
- -- and the leave-one-out below rebuilds it from `col` instead.
- local bad = 0
- if env.matrixCounter and env.counterStrength > 0 then
- bad = CounterPressure(d, eDefID) * share
- if bad > 0 then
- e.pressureOn[#e.pressureOn + 1] = {defID = eDefID, bad = bad}
- end
- end
- e.matchups[#e.matchups + 1] = {
- defID = eDefID, pair = pair, share = share, contrib = share * pair,
- bad = bad, col = ColumnArch(eDefID),
- }
- end
- table.sort(e.matchups, function(a, b) return a.contrib > b.contrib end)
- table.sort(e.blindOn, function(a, b) return a.share > b.share end)
- table.sort(e.pressureOn, function(a, b) return a.bad > b.bad end)
- -- The availability multiplier said in seconds, which is the form it was computed in
- -- and the only form worth arguing with: "x0.83" says nothing, "8s to afford, 6s to
- -- build, against a 30s horizon" says which slider to move.
- local cand = env.candidates[d]
- if cand then
- local bp = buildSpeed[cand.facDefID]
- e.prodTime = (bp > 0) and (buildTime[d] / bp) or 0
- local shortfall = cost[d] - env.mCurr
- e.affordWait = (shortfall > 0) and (shortfall / math.max(env.netMetal, 0.1)) or 0
- end
- t.picks[i] = e
- end
- -- Which enemy units are holding the top pick back, one at a time.
- --
- -- Both halves of the score decompose over the enemy army, which is what makes the
- -- question answerable rather than merely arguable: the matchup average is a sum over
- -- enemy types, and the counter damping is exp() of a sum over them, so a type can be
- -- lifted out of both and the score rebuilt without it. What that rebuild is worth is the
- -- honest figure -- how much better this pick would score if that unit type were not on
- -- the field -- and it is one number per enemy, on the same scale as the score itself.
- --
- -- Not "its matchup value", which double-counts a unit that is both a bad matchup and a
- -- counter, and says nothing at all about a unit whose whole contribution is pressure.
- local top = t.picks[1]
- if top and #top.matchups > 1 then
- local cs = env.counterStrength
- local counters = (not env.matrixCounter) and COUNTERED_BY[role[top.defID]] or nil
- -- The ranking's own damping, re-run over a smaller army. Mirrors the branch in
- -- Recompute rather than simplifying it, floor included, so the two cannot disagree.
- local function Damp(pressure, roleShare)
- if cs <= 0 then
- return 1
- end
- local counterShare = 0
- if env.matrixCounter then
- if pressure > 0 then
- counterShare = 1 - math.exp(-pressure)
- end
- elseif counters then
- for r in pairs(counters) do
- counterShare = counterShare + (roleShare[r] or 0)
- end
- if counterShare > 1 then
- counterShare = 1
- end
- end
- if counterShare <= 0 then
- return 1
- end
- return math.max((1 - counterShare) ^ cs, COUNTER_FLOOR)
- end
- local fixed = top.mQuality * top.mAvail * top.mOwn
- top.perUnit = {}
- for i = 1, #top.matchups do
- local m = top.matchups[i]
- local rest = 1 - m.share
- if rest > 0.0001 then
- -- Every share is renormalised against the smaller army, because that is what
- -- the ranking would have done: totalThreat is the denominator of both terms.
- local base = (top.base - m.share * m.pair) / rest
- local pressure = (top.pressure - m.bad) / rest
- local without
- if counters then
- local rs = {}
- for r, v in pairs(env.roleShare) do
- rs[r] = v / rest
- end
- rs[m.col] = ((env.roleShare[m.col] or 0) - m.share) / rest
- without = base * Damp(pressure, rs)
- else
- without = base * Damp(pressure)
- end
- without = without * fixed
- top.perUnit[#top.perUnit + 1] = {
- defID = m.defID,
- share = m.share,
- pair = m.pair,
- bad = m.bad,
- -- Positive means the pick would score better without it: a drag.
- drag = without - top.score,
- }
- end
- end
- table.sort(top.perUnit, function(a, b) return a.drag > b.drag end)
- end
- trace = t
- end
- -- What can be left out of the army we answer.
- --
- -- Every one of these is the player saying "I am not going to fight that", not a claim about
- -- the matrix. It matters because the ranking is a threat-weighted average: metal that will
- -- never trade with you still divides into the total, so three anti-air turrets you have no
- -- intention of attacking quietly spend a third of the ranking on themselves.
- --
- -- Order is the order they are tested in, and the first match is the one reported, so the
- -- broad exclusions come before the narrow ones they subsume -- "buildings" before "unarmed
- -- buildings", which is why turning both on never double-counts a solar.
- local EXCLUSIONS = {
- {key = 'mobileOnly', label = "buildings",
- test = function(d) return isStatic[d] end},
- {key = 'ignoreEco', label = "economy buildings",
- test = function(d) return isStatic[d] and not isArmed[d] end},
- {key = 'ignoreCommanders', label = "commanders",
- test = function(d) return role[d] == "commander" end},
- -- Mobile only: Zero-K gives every factory, plate, Strider Hub, Missile Silo, Rearm Pad
- -- and Caretaker build power, so they all classify as "con", and this filter is about
- -- not chasing builders rather than about ignoring their base.
- {key = 'ignoreCons', label = "constructors",
- test = function(d) return role[d] == "con" and not isStatic[d] end},
- {key = 'ignoreAA', label = "anti-air",
- test = function(d) return role[d] == "aa" end},
- -- Last of the five, so a Trident or a plane constructor is reported under the narrower
- -- heading it also matches; every one of those is air anyway, so nothing escapes by it.
- {key = 'ignoreAir', label = "aircraft",
- test = function(d) return isAir[d] end},
- }
- local function Recompute()
- picks = {}
- facPicks = {}
- emptyReason = nil
- focusActive = false
- -- Cleared with the picks, so every early return below leaves the debug panel with
- -- nothing to draw and `emptyReason` as the whole explanation.
- trace = nil
- local intel = WG.EnemyIntel
- if not intel then
- emptyReason = "Enemy Intel is not running"
- return
- end
- -- Whose army we are answering. Every way this can fail falls back to the whole enemy
- -- side: a ranking against more of the enemy than you will meet is merely blunt, one
- -- against nothing at all is useless.
- local counts
- local aaCounts -- what air is judged against, which is not always what we rank against
- -- The remembered armies of the focused teams, merged. The ranking itself in the player
- -- modes; only the anti-air backdrop in the units mode.
- local function MergeFocusArmies()
- if not (focusTeams and intel.GetEffectiveCountsForTeam) then
- return nil
- end
- local merged, any = {}, false
- for team in pairs(focusTeams) do
- local c = intel.GetEffectiveCountsForTeam(team)
- local w = (focusWeights and focusWeights[team]) or 1
- if c and w > 0 then
- -- Threat is normalised against its own total further down, so scaling
- -- a front here only ever changes its share of the answer.
- for defID, n in pairs(c) do
- merged[defID] = (merged[defID] or 0) + n * w
- any = true
- end
- end
- end
- return any and merged or nil
- end
- if focusUnits then
- -- The units mode assembled its own army out of single units, so there is no roster to
- -- look up: what has shot us is what we answer. Anti-air is the one deliberate
- -- exception, because a Razor that has never shot you is precisely the Razor that
- -- kills your first gunship -- so that question alone is still put to the whole armies
- -- behind those units.
- counts, focusActive = focusUnits, true
- aaCounts = MergeFocusArmies() or intel.GetEffectiveCounts()
- elseif focusTeams then
- if intel.GetEffectiveCountsForTeam then
- local merged = MergeFocusArmies()
- if merged then
- counts, focusActive = merged, true
- else
- focusNote = "nothing scouted on that front"
- end
- else
- focusNote = "Enemy Intel too old to split by player"
- end
- end
- counts = counts or intel.GetEffectiveCounts()
- if not counts or not next(counts) then
- emptyReason = "No enemy intel yet"
- return
- end
- aaCounts = aaCounts or counts
- local candidates, factories = GatherCandidates()
- if not next(candidates) then
- emptyReason = "No factories owned"
- return
- end
- -- Whether an air push is viable is judged against everything the enemy owns, static AA
- -- included and regardless of the mobile-only setting below. A Razor shoots your
- -- gunships whether or not you ever intended to attack it.
- local aaThreat, allThreat = 0, 0
- for defID, n in pairs(aaCounts) do
- local t = n * cost[defID]
- if t > 0 then
- allThreat = allThreat + t
- if isAA[defID] or dpsAir[defID] > 0 then
- aaThreat = aaThreat + t
- end
- end
- end
- local enemyAAShare = (allThreat > 0) and math.min(aaThreat / allThreat, 0.95) or 0
- -- Threat weighting: what fraction of the enemy's committed metal each unit type is.
- -- Which exclusions are switched on, resolved once rather than per enemy unit type.
- local active = {}
- for i = 1, #EXCLUSIONS do
- if options[EXCLUSIONS[i].key].value then
- active[#active + 1] = EXCLUSIONS[i]
- end
- end
- local mobileOnly = options.mobileOnly.value
- local threat, totalThreat = {}, 0
- local dropped, droppedOrder = {}, {} -- label -> what it took out, for the debug panel
- for defID, n in pairs(counts) do
- local out
- for i = 1, #active do
- if active[i].test(defID) then
- out = active[i].label
- break
- end
- end
- if out then
- local d = dropped[out]
- if not d then
- d = {label = out, types = 0, metal = 0}
- dropped[out] = d
- droppedOrder[#droppedOrder + 1] = d
- end
- d.types = d.types + 1
- d.metal = d.metal + n * cost[defID]
- else
- local t = n * cost[defID]
- if isStatic[defID] then
- t = t * STATIC_THREAT_WEIGHT
- end
- if t > 0 then
- threat[defID] = t
- totalThreat = totalThreat + t
- end
- end
- end
- if totalThreat <= 0 then
- -- Everything seen has been filtered out. Name what did the filtering, because
- -- "no enemy intel yet" would be a lie about a setting the player can move.
- if #droppedOrder > 0 then
- local names = {}
- for i = 1, #droppedOrder do
- names[i] = droppedOrder[i].label
- end
- table.sort(names)
- emptyReason = "Only " .. table.concat(names, ", ") .. " seen"
- else
- emptyReason = "No enemy intel yet"
- end
- return
- end
- table.sort(droppedOrder, function(a, b) return a.metal > b.metal end)
- -- What fraction of the enemy's metal sits in each matrix column, for the
- -- counter-presence damping below. Columns, not roles, so a wall of turrets registers
- -- as static defence rather than as whatever archetype the turrets resemble.
- local roleShare = {}
- for defID, t in pairs(threat) do
- local r = ColumnArch(defID)
- roleShare[r] = (roleShare[r] or 0) + t
- end
- for r, v in pairs(roleShare) do
- roleShare[r] = v / totalThreat
- end
- -- Economy, for the availability term.
- local mCurr, _, netMetal = GetMetalState()
- mCurr = mCurr or 0
- netMetal = netMetal or 0
- local horizon = options.horizon.value
- local availWeight = options.availWeight.value
- local counterStrength = options.counterStrength.value
- local matrixCounter = options.counterSource.value == 'matrix'
- local qualityWeight = options.qualityWeight.value
- -- Optional: what we already own, by role, to damp what we are saturated in.
- local ownRoleShare = {}
- if options.dampOwnRoles.value then
- local ownTotal = 0
- local units = spGetTeamUnits(myTeamID)
- if units then
- for i = 1, #units do
- local d = spGetUnitDefID(units[i])
- if d and not isStatic[d] and not NON_COMBAT[role[d]] then
- local v = cost[d]
- ownRoleShare[role[d]] = (ownRoleShare[role[d]] or 0) + v
- ownTotal = ownTotal + v
- end
- end
- end
- if ownTotal > 0 then
- for r, v in pairs(ownRoleShare) do
- ownRoleShare[r] = v / ownTotal
- end
- end
- end
- local scored = {}
- local dryMap = waterFraction < WATER_THRESHOLD
- local noBoats = options.noBoats.value
- for defID, cand in pairs(candidates) do
- -- A boat, for the noBoats option: anything that needs water under it, plus the
- -- boats that do not, which only their move class gives away (the Cutter floats
- -- at depth 0 on BOAT3; submarines are UBOAT). Hovercraft are HOVER and amphibious
- -- units AKBOT/ATANK, so neither matches. Worked out here rather than in a table
- -- at load, because this block is at the local ceiling.
- local boat = false
- if noBoats then
- local md = UnitDefs[defID].moveDef
- local mclass = (md and md.name or ""):lower()
- boat = needsWater[defID] or mclass:find("^u?boat") ~= nil
- end
- -- Constructors and cloakers are never an answer to an army; anything unarmed drops
- -- out at the weapon gate inside PairScore. A ship is no answer at all on a map with
- -- nowhere to float it.
- if not NON_COMBAT[role[defID]] and not (needsWater[defID] and dryMap) and not boat then
- local sum, best = 0, {}
- -- Counter pressure: the threat-weighted sum of how badly each enemy unit type
- -- beats this pick, and whichever type is carrying most of it. Gathered in the
- -- same pass, but deliberately outside the p > 0 branch, because an enemy our
- -- pick cannot even shoot back at is precisely one that should hold it back.
- local wantPressure = matrixCounter and counterStrength > 0
- local pressure, worstDef, worstPressure = 0, nil, 0
- for eDefID, t in pairs(threat) do
- local p = PairScore(defID, eDefID, enemyAAShare)
- if wantPressure then
- local bad = CounterPressure(defID, eDefID)
- if bad > 0 then
- bad = bad * t / totalThreat
- pressure = pressure + bad
- if bad > worstPressure then
- worstPressure, worstDef = bad, eDefID
- end
- end
- end
- if p > 0 then
- local contrib = t * p
- sum = sum + contrib
- best[#best + 1] = {defID = eDefID, contrib = contrib}
- end
- end
- if sum > 0 then
- local base = sum / totalThreat
- local score = base
- -- Counter presence. Averaging the matchups treats an army that is a quarter
- -- riots as three quarters raider-friendly, which is not how it plays: the
- -- riots screen everything behind them, so raiders lose value against the
- -- whole army rather than against that quarter of it. This sits deliberately
- -- on top of the pairwise matrix value, because being screened out of the
- -- fight and losing the fight are two separate costs.
- --
- -- Two readings of "what is standing out there that beats this", on the one
- -- slider: the archetype columns, or the matrix asked about every enemy unit
- -- type in turn. Both end as a 0..1 severity and are charged for the same way.
- local counterShare, worstRole, worstUnit = 0, nil, nil
- local counterDamp = 1
- if counterStrength > 0 then
- if matrixCounter then
- -- exp(-pressure) is the threat-weighted geometric mean of how badly
- -- the enemy army beats this pick, so an army that is a tenth hard
- -- counter holds it back a tenth as hard as one that is all counter,
- -- and a counter twice as sharp counts for twice as much.
- if pressure > 0 then
- counterShare = 1 - math.exp(-pressure)
- worstUnit = worstDef
- end
- else
- local counters = COUNTERED_BY[role[defID]]
- if counters then
- for r in pairs(counters) do
- local s = roleShare[r] or 0
- counterShare = counterShare + s
- if not worstRole or s > (roleShare[worstRole] or 0) then
- worstRole = r
- end
- end
- if counterShare > 1 then
- counterShare = 1
- end
- end
- end
- if counterShare > 0 then
- counterDamp = math.max((1 - counterShare) ^ counterStrength, COUNTER_FLOOR)
- score = score * counterDamp
- end
- end
- -- Tier. Deliberately last of the multipliers and deliberately gentle: it
- -- breaks ties between picks the matrix rates alike, and is not meant to
- -- overrule a matchup it disagrees with.
- --
- -- Each multiplier is named on the way past rather than folded straight into
- -- `score`, so the debug panel can report what the ranking actually charged
- -- this pick for. Three numbers on a table that is being built anyway, so it
- -- is not worth making conditional -- and a decomposition gathered only while
- -- someone is watching is a decomposition worth nobody's trust.
- local mQuality, mAvail, mOwn = 1, 1, 1
- if qualityWeight > 0 then
- mQuality = Quality(defID) ^ qualityWeight
- score = score * mQuality
- end
- if availWeight > 0 then
- mAvail = Availability(defID, cand, mCurr, netMetal, horizon) ^ availWeight
- score = score * mAvail
- end
- if options.dampOwnRoles.value then
- mOwn = 1 - 0.6 * (ownRoleShare[role[defID]] or 0)
- score = score * mOwn
- end
- -- Keep only the loudest few contributors, for the tooltip.
- table.sort(best, function(a, b) return a.contrib > b.contrib end)
- local why = {}
- for i = 1, math.min(3, #best) do
- why[i] = best[i].defID
- end
- scored[#scored + 1] = {
- defID = defID,
- facID = cand.facID,
- facDefID = cand.facDefID,
- score = score,
- why = why,
- counterShare = counterShare,
- counterRole = worstRole,
- counterUnit = worstUnit,
- counterDamp = counterDamp,
- -- The working, for the debug panel. Read only for the four that survive
- -- the sort below, and ignored entirely by the panel above.
- base = base,
- pressure = pressure,
- mQuality = mQuality,
- mAvail = mAvail,
- mOwn = mOwn,
- }
- end
- end
- end
- table.sort(scored, function(a, b) return a.score > b.score end)
- -- Sorted, so the first is the largest and the guard needs no sum of its own.
- if not scored[1] or scored[1].score <= 0 then
- -- Nothing buildable can engage what was seen at all: air-only enemy and no AA, say.
- emptyReason = "No answer available"
- return
- end
- -- The panel prints the score itself, so nothing is normalised into a share of the four
- -- here any more. A share of the four was a poor thing to read: it moved when the *other*
- -- picks changed while this one stood still, and it squeezed every ranking into the same
- -- narrow band, so beating the field by a nose and beating it by double both came out
- -- near the same number.
- --
- -- The one derived figure left is the bar, which is each score against the best of them.
- -- That makes the bar a picture of the number printed beside it, and it fills the top row
- -- rather than stopping at whatever fraction of a hidden total the leader happened to be.
- local best = scored[1].score
- for i = 1, math.min(PICK_COUNT, #scored) do
- local p = scored[i]
- p.barFill = p.score / best
- picks[i] = p
- end
- -- The icon-only layout falls back to a single unit when no factory has a queue, and it
- -- should not be one from a plant the auto-queue skip settings have closed: the best pick
- -- from a factory still open. Taken
- -- from the whole ranking rather than the top four, which can all be air. Kept on the
- -- picks table itself so a blank ranking clears it along with the rest.
- local skipAir, skipSilo = options.autoQueueSkipAir.value, options.autoQueueSkipSilos.value
- for i = 1, #scored do
- local fd = scored[i].facDefID
- if not ((skipAir and isAirFactory[fd]) or (skipSilo and isMissileSilo[fd])) then
- picks.iconPick = scored[i]
- break
- end
- end
- -- What each factory should be making, for continuous production. Walking the ranking
- -- in order and taking the first entry a factory can build gives every plant its own
- -- best answer, rather than leaving the ones that cannot build the overall top pick
- -- with nothing to do.
- --
- -- The icon-only layout shows one icon per busy factory, so it keeps the whole entry
- -- too, re-attributed to that plant: the ranking names whichever plant has the shortest
- -- queue, and a click on this factory's icon has to queue at this factory. Plants the
- -- skip settings close get no icon, as they get no iconPick.
- picks.byFactory = {}
- for facID, fac in pairs(factories) do
- for i = 1, #scored do
- if fac.canBuild[scored[i].defID] then
- facPicks[facID] = scored[i].defID
- local fd = fac.facDefID
- if not ((skipAir and isAirFactory[fd]) or (skipSilo and isMissileSilo[fd])) then
- local own = {}
- for k, v in pairs(scored[i]) do
- own[k] = v
- end
- own.facID, own.facDefID = facID, fd
- picks.byFactory[facID] = own
- end
- break
- end
- end
- end
- -- Last, because it describes the finished ranking rather than taking part in it, and
- -- skipped whenever the panel that reads it is closed.
- if options.debugPanel.value then
- BuildTrace{
- threat = threat, totalThreat = totalThreat, enemyAAShare = enemyAAShare,
- counts = counts, candidates = candidates, roleShare = roleShare,
- mCurr = mCurr, netMetal = netMetal, horizon = horizon,
- mobileOnly = mobileOnly, dropped = droppedOrder,
- counterStrength = counterStrength,
- matrixCounter = matrixCounter, qualityWeight = qualityWeight,
- availWeight = availWeight, dampOwnRoles = options.dampOwnRoles.value,
- }
- end
- end
- --------------------------------------------------------------------------------
- -- Queueing
- --
- -- Factory build commands always append to the end of the queue, so anything we add sits
- -- behind everything the player has asked for. Removing ours back off the end therefore
- -- cannot disturb a player order. OPT_SHIFT is Zero-K's "times five" modifier here, not a
- -- queue-position flag, which is why it is only used for the click path.
- --------------------------------------------------------------------------------
- -- Alt puts the order directly behind whatever the factory is building right now, so it
- -- comes out next rather than last. Shift is five either way.
- local function QueueUnit(facID, defID, five, behindCurrent)
- if amSpectating or not facID or not defID then
- return
- end
- -- Position 1 is the slot just after the unit under construction. This is the same
- -- INSERT that Zero-K's own build menu uses for its "alt inserts behind" option, down
- -- to the ALT+CTRL flags: ALT makes the first parameter a queue position rather than a
- -- command tag.
- --
- -- It has to be skipped while the factory is on repeat. Repeat depends on the engine
- -- removing each command once it completes, and an inserted command does not behave
- -- that way, so the queue would grow forever. Falling through to a plain append keeps
- -- the click working instead of silently doing nothing.
- if behindCurrent and not spGetUnitRepeat(facID) then
- for _ = 1, (five and 5 or 1) do
- spGiveOrderToUnit(facID, CMD.INSERT, {1, -defID, 0}, CMD.OPT_ALT + CMD.OPT_CTRL)
- end
- return
- end
- spGiveOrderToUnit(facID, -defID, {}, five and CMD.OPT_SHIFT or 0)
- end
- -- How many units this factory has left to build, ours and the player's alike. Read live
- -- rather than from the last ranking pass, because orders land between passes.
- local function FactoryQueueLen(facID)
- local queue = spGetFullBuildQueue(facID)
- if not queue then
- return nil
- end
- local n = 0
- for i = 1, #queue do
- for _, count in pairs(queue[i]) do
- n = n + count
- end
- end
- return n
- end
- -- Queue tags for this unit type in this factory, in queue order.
- local function QueueTags(facID, defID)
- local cmds = spGetFactoryCommands(facID, -1)
- if not cmds then
- return nil
- end
- local out = {}
- for i = 1, #cmds do
- local c = cmds[i]
- if c.id == -defID then
- out[#out + 1] = c.tag
- end
- end
- return out
- end
- -- Issues one unit. The tag it lands on cannot be read back here: an order from a widget
- -- travels through the command network and only appears in the queue a frame or more
- -- later, so claiming it is left to ResolvePending.
- --
- -- What gets recorded is the set of tags present beforehand, not a count. A tag identifies
- -- one queue slot, so whichever tag is there afterwards and was not there before is ours,
- -- no matter what else was added or removed in between.
- local function AutoQueueOne(pick)
- local facID, defID = pick.facID, pick.defID
- if spGetUnitIsDead(facID) ~= false then
- return false
- end
- local before = QueueTags(facID, defID)
- if not before then
- return false
- end
- local beforeSet = {}
- for i = 1, #before do
- beforeSet[before[i]] = true
- end
- spGiveOrderToUnit(facID, -defID, {}, 0)
- pendingAuto = {
- facID = facID,
- defID = defID,
- before = beforeSet,
- -- Generous, because giving up early is the worse mistake: an order that lands after
- -- its claim has expired has no owner, and the player-order watch takes it for the
- -- player's and stands the whole base down. Waiting only delays our next order.
- deadline = os.clock() + 10,
- }
- return true
- end
- -- Removes one of our orders, but only after proving it is still the order we placed.
- -- Command tags get recycled, so a tag that now belongs to something else must be dropped
- -- rather than removed.
- local function RetractOne(rec)
- if spGetUnitIsDead(rec.facID) ~= false then
- return
- end
- local cmds = spGetFactoryCommands(rec.facID, -1)
- if not cmds then
- return
- end
- -- Index 1 is already under construction; cancelling it would waste the metal spent.
- for i = #cmds, 2, -1 do
- local c = cmds[i]
- if c.tag == rec.tag and c.id == -rec.defID then
- spGiveOrderToUnit(rec.facID, CMD.REMOVE, {rec.tag}, CMD.OPT_CTRL)
- return
- end
- end
- end
- RetractAll = function()
- -- Last in, first out: our newest order is the one furthest from being built.
- for i = #autoOrders, 1, -1 do
- RetractOne(autoOrders[i])
- end
- autoOrders = {}
- ourTags = {}
- if pendingAuto then
- -- An order still in flight cannot be taken back until we know which slot it landed
- -- in, so mark it to be pulled the moment it shows up.
- pendingAuto.retractOnArrival = true
- end
- end
- -- Claims the tag of an order once it reaches the queue, or gives up on it.
- local function ResolvePending(now)
- local p = pendingAuto
- if not p then
- return
- end
- if spGetUnitIsDead(p.facID) ~= false then
- pendingAuto = nil
- return
- end
- local tags = QueueTags(p.facID, p.defID)
- local found
- if tags then
- -- Search from the back: ours was appended, and if the player queued the same unit
- -- in the same moment the two are interchangeable anyway.
- for i = #tags, 1, -1 do
- if not p.before[tags[i]] then
- found = tags[i]
- break
- end
- end
- end
- if found then
- local rec = {facID = p.facID, tag = found, defID = p.defID}
- local retract = p.retractOnArrival
- pendingAuto = nil
- if retract then
- RetractOne(rec)
- -- Never recorded as ours, so the player-order watch would see a tag it has no
- -- snapshot of and take it for the player's. It was ours; say so in the snapshot.
- if seenTags[p.facID] then
- seenTags[p.facID][found] = true
- end
- else
- autoOrders[#autoOrders + 1] = rec
- end
- elseif now >= p.deadline then
- -- It never arrived: refused, or the player cleared the queue underneath it. Either
- -- way, try the rest of the base before this plant again.
- aqHold.refused[p.facID] = now + REFUSED_COOLDOWN
- pendingAuto = nil
- end
- end
- -- Forget orders that have already been built or that the player cancelled, so the
- -- outstanding count stays honest.
- local function PruneAutoOrders()
- local keep = {}
- for i = 1, #autoOrders do
- local rec = autoOrders[i]
- if spGetUnitIsDead(rec.facID) == false then
- local cmds = spGetFactoryCommands(rec.facID, -1)
- if cmds then
- for j = 1, #cmds do
- if cmds[j].tag == rec.tag and cmds[j].id == -rec.defID then
- keep[#keep + 1] = rec
- break
- end
- end
- end
- end
- end
- autoOrders = keep
- end
- -- Plants the auto-queue has been told to leave to you. This gates only what we queue: the
- -- ranking is untouched, so such a factory still gets its pick, the panel still proposes from
- -- it, and clicking that proposal still queues it. What stops is us placing the order
- -- unasked -- which is the whole difference between a suggestion and a silo firing itself.
- --
- -- Asked per factory rather than filtered out of `facPicks`, because the pick is what the
- -- retraction below compares against and what the panel prints; a factory that vanished from
- -- that table would read as one with nothing to build, which is a different thing entirely
- -- and is deliberately left alone by RetractStale.
- local function AutoQueueSkips(facID)
- local facDefID = spGetUnitDefID(facID)
- if not facDefID then
- return false
- end
- return (options.autoQueueSkipSilos.value and isMissileSilo[facDefID])
- or (options.autoQueueSkipAir.value and isAirFactory[facDefID])
- end
- -- Takes back orders of ours that the ranking has since moved on from. A proposal is a
- -- claim about what should be coming off the line next, so filler queued under an older
- -- one is not worth building merely because we happened to ask for it first: the queue is
- -- pulled back to whatever the panel is saying now, and the fill passes below re-stock it
- -- with the current pick.
- --
- -- Only what has not been started is touched. RetractOne already refuses to cancel the
- -- order at the head of a factory queue, which is the one the metal is going into, so a
- -- unit already being built is left to finish.
- --
- -- A factory with no pick at all is left alone rather than emptied. There is no pick
- -- during the blank spells -- no enemy seen yet, intel briefly gone -- and those are
- -- silence about what to build, not an instruction to build nothing.
- --
- -- A plant the skip settings have since closed is emptied though, pick or no pick. Switching
- -- one of those on mid-game is an instruction about what to build, and leaving four warheads
- -- on the silo queue would be answering it only for the orders not yet placed.
- local function RetractStale()
- -- Newest first, the same order RetractAll uses: our latest order is the one furthest
- -- from being built, so it is the one that can be taken back most cleanly.
- for i = #autoOrders, 1, -1 do
- local rec = autoOrders[i]
- local want = facPicks[rec.facID]
- if AutoQueueSkips(rec.facID) or (want and want ~= rec.defID) then
- RetractOne(rec)
- end
- end
- -- Records are not dropped here. A removal travels through the command network like
- -- any other order, so the queue still holds the slot for a frame or more; leaving the
- -- record for PruneAutoOrders to clear on a later pass means the count stays honest in
- -- the meantime and a removal that never lands is simply reissued.
- end
- --------------------------------------------------------------------------------
- -- Auto-queue
- --
- -- Arming is predictive: it starts a little before the metal bar caps, so the first metal
- -- is not already going to waste by the time an order lands.
- --
- -- Standing down is not tied to the excess at all. It happens when the player queues
- -- something of their own, which is the signal that they have taken production back. Our
- -- filler comes off the queue and theirs is left exactly as they placed it.
- --------------------------------------------------------------------------------
- local function RebuildOurTags()
- ourTags = {}
- for i = 1, #autoOrders do
- ourTags[autoOrders[i].tag] = true
- end
- end
- -- Watches our factories for build orders that appeared without us placing them. Tags are
- -- unique per queue slot, so anything new that is not ours was queued by the player.
- -- Returns them as a list of {facID, tag}, empty when there are none.
- local function PlayerQueuedSomething()
- local found = {}
- local units = spGetTeamUnits(myTeamID)
- if not units then
- return found
- end
- local live = {}
- for i = 1, #units do
- local facID = units[i]
- local facDefID = spGetUnitDefID(facID)
- -- A repeating factory re-appends its own finished orders under fresh tags, which
- -- is indistinguishable from the player queueing more, so it is left out of this
- -- entirely -- both the watching and the queueing.
- if facDefID and isFactory[facDefID] and not spGetUnitRepeat(facID) then
- local cmds = spGetFactoryCommands(facID, -1)
- if cmds then
- local current = {}
- local prev = seenTags[facID]
- for j = 1, #cmds do
- local c = cmds[j]
- if c.id and c.id < 0 then
- current[c.tag] = true
- -- No previous snapshot means this factory is new to us, so this
- -- pass only seeds it rather than reading anything into it.
- if prev and not prev[c.tag] and not ourTags[c.tag] then
- found[#found + 1] = {facID = facID, tag = c.tag}
- end
- end
- end
- live[facID] = current
- end
- end
- end
- seenTags = live
- return found
- end
- -- Whether any order the player placed during this excess is still waiting or being built.
- -- Reads the snapshot PlayerQueuedSomething just took, so it has to run after that.
- local function PlayerOrdersLeft()
- for facID, tags in pairs(playerTags) do
- local queued = seenTags[facID]
- if queued then
- for tag in pairs(tags) do
- if queued[tag] then
- return true
- end
- end
- end
- end
- return false
- end
- local function UpdateArmed()
- -- Only the excess mode arms: continuous production is not waiting for anything.
- if amSpectating or options.autoQueueMode.value ~= 'excess' then
- armed = false
- return
- end
- if ProjectedToExcess(options.leadTime.value) then
- -- A fresh overflow is a fresh offer: whatever the player queued during the last one
- -- was an answer to that one. So was anything queued in the moments before it, which
- -- is why the snapshot starts over too. Otherwise an order that landed after the last
- -- disarmed poll counted as one placed during this overflow and stood the widget down
- -- before it had queued anything.
- if not armed then
- yielded = false
- playerTags = {}
- seenTags = {}
- end
- armed = true
- return
- end
- -- Staying armed is deliberately stickier than arming, because the projection cannot
- -- read the surplus honestly once we have started spending it: a factory draws metal as
- -- soon as it picks our order up, so the net rate that armed us goes negative one order
- -- in. Re-deriving `armed` from that rate stood the whole thing down after a single
- -- order per factory, which is why the queue depth below never appeared to do anything
- -- here -- the allowance was never what we ran out of first.
- --
- -- So while the bar is still nearly full the surplus plainly has not been spent yet,
- -- whatever the rate says, and we stay armed until it has actually come down.
- if armed then
- local cur, stor = GetMetalState()
- armed = (cur ~= nil) and cur > stor * EXCESS_HOLD
- end
- end
- -- Continuous production. Every factory is topped up to the configured queue depth with
- -- whatever it can build that answers the enemy best.
- --
- -- The depth counts the whole queue rather than only our own orders, which is what keeps
- -- this out of the player's way: anything they queue is built first, and we only fill the
- -- room that is left. That is also why this mode does not stand down when the player
- -- queues something -- there is nothing to stand down from, since a full queue already
- -- means we add nothing.
- --
- -- One order per pass, because a queued order cannot be identified until it appears in the
- -- queue a frame or more later and only one such claim can be outstanding at a time.
- local function UpdateContinuous()
- PruneAutoOrders()
- local depth = options.maxAutoQueued.value
- local now = os.clock()
- for facID, defID in pairs(aqHold.picks) do
- if spGetUnitIsDead(facID) == false and not spGetUnitRepeat(facID)
- and not AutoQueueSkips(facID) and not ((aqHold.refused[facID] or 0) > now)
- and not PlantIdle(facID) then
- local len = FactoryQueueLen(facID)
- if len and len < depth and AutoQueueOne({facID = facID, defID = defID}) then
- return
- end
- end
- end
- end
- -- The excess mode: wait until metal is about to be wasted, then spend it across every
- -- factory, and hand production straight back the moment the player queues anything.
- local function UpdateOnExcess()
- -- Our own orders have to be known before the queue can be read for anyone else's. The
- -- watch runs on every pass, armed or not, so its snapshot is never stale when it counts.
- RebuildOurTags()
- local playerOrders = PlayerQueuedSomething()
- -- The overflow is gone, so is the reason for anything of ours not yet started. The unit
- -- at the head of each queue is left to finish (RetractOne never cancels it); the rest
- -- would be spending metal the player now has a use for.
- if not armed then
- if #autoOrders > 0 then
- RetractAll()
- end
- return
- end
- -- The player has taken production back. Pulling our orders and then re-adding them on the
- -- next pass, because the bar is still full, is what used to make this last one second:
- -- `yielded` holds it while anything the player queued is still on a factory queue.
- if #playerOrders > 0 then
- for i = 1, #playerOrders do
- local o = playerOrders[i]
- playerTags[o.facID] = playerTags[o.facID] or {}
- playerTags[o.facID][o.tag] = true
- end
- yielded = true
- aqHold.yieldAt = os.clock()
- RetractAll()
- return
- end
- -- The yield ends when the player's orders are built or cancelled, not only at the next
- -- overflow. The next overflow only begins once the bar has dropped under EXCESS_HOLD, and
- -- a bar still pinned full after the player's unit comes out never drops. Waiting for that
- -- left every factory idle with the metal running to waste until the widget was
- -- restarted. It is also what makes a false yield recoverable: an order of ours that lands
- -- after its claim expired reads as the player's, and it goes away once it is built.
- -- An order parked somewhere that never builds -- a stunned plant, a plate that lost its
- -- parent -- never leaves a queue, and the base stayed handed back for the rest of the
- -- game. After ninety seconds the overflow is taken back: it is being wasted either way,
- -- and nothing of the player's is ever touched.
- if yielded then
- if PlayerOrdersLeft() and (os.clock() - (aqHold.yieldAt or 0)) < 90 then
- return
- end
- yielded = false
- playerTags = {}
- end
- PruneAutoOrders()
- -- Where the top pick alone used to be fed, every factory is now, each with the best
- -- answer it can build itself -- the same per-factory picks continuous production uses.
- -- A surplus is a whole-team one and a single plant only turns so much of it into units
- -- per second, so pinning the spend to whichever factory happened to own the overall
- -- top pick left the rest of the base idle while metal still overflowed.
- --
- -- The queue depth is a per-factory allowance, the way it already reads in continuous
- -- production: each factory may hold that many orders of ours, so what we commit grows
- -- with the number of plants there are to spend the surplus. Unlike continuous mode it
- -- counts only our own orders, because this mode retracts on a player order rather than
- -- filling in behind one.
- local depth = options.maxAutoQueued.value
- local outstanding = {}
- for i = 1, #autoOrders do
- local facID = autoOrders[i].facID
- outstanding[facID] = (outstanding[facID] or 0) + 1
- end
- -- Ties go to the factory whose pick ranked highest, so the best answers are still the
- -- ones built first; factories with no pick in the visible ranking come after those.
- local rank = {}
- for i = 1, #picks do
- local facID = picks[i].facID
- if not rank[facID] then
- rank[facID] = i
- end
- end
- -- Emptiest queue first, so the allowances fill across the base together rather than
- -- one factory reaching its own before the next gets anything.
- local bestFac, bestDefID, bestCount, bestRank
- local now = os.clock()
- for facID, defID in pairs(aqHold.picks) do
- -- A repeating factory re-appends its own finished orders under fresh tags, which
- -- is indistinguishable from a player order, so it is left out here exactly as it
- -- is left out of the player-order watch.
- if spGetUnitIsDead(facID) == false and not spGetUnitRepeat(facID)
- and not AutoQueueSkips(facID) and not ((aqHold.refused[facID] or 0) > now)
- and not PlantIdle(facID) then
- local count = outstanding[facID] or 0
- local r = rank[facID] or math.huge
- if count < depth and (not bestFac or count < bestCount
- or (count == bestCount and r < bestRank)
- or (count == bestCount and r == bestRank and facID < bestFac)) then
- bestFac, bestDefID, bestCount, bestRank = facID, defID, count, r
- end
- end
- end
- if bestFac then
- AutoQueueOne({facID = bestFac, defID = bestDefID})
- end
- end
- local function UpdateAutoQueue(now)
- if now < nextAutoAdd then
- return
- end
- local mode = options.autoQueueMode.value
- -- Only one order can be in flight at a time, so the pass rate is the fill rate: at one a
- -- second a five-plant base took fifteen seconds to reach depth three, with the metal
- -- already running to waste. An overflow is the one moment worth hurrying for.
- nextAutoAdd = now + ((mode == 'excess' and armed) and 0.34 or 1)
- if amSpectating or mode == 'off' then
- return
- end
- -- Both modes claim the tag of the order they placed, and only one claim can be in
- -- flight at a time. Waiting also keeps an order of ours that has not landed yet from
- -- reading as a player order below; nothing is lost, because the next pass still
- -- compares against the older snapshot and catches a genuine player order.
- if pendingAuto then
- -- An order that went out under a pick the ranking has since dropped is stale
- -- before it even arrives. It cannot be taken back while its slot is still unknown,
- -- so it is marked to be pulled the moment it lands, the same way RetractAll marks
- -- one it catches in flight.
- local want = facPicks[pendingAuto.facID]
- if want and want ~= pendingAuto.defID then
- pendingAuto.retractOnArrival = true
- end
- -- Likewise one that went out during an overflow that has since ended, or one
- -- headed for a plant the skip settings closed while it was in flight.
- if mode == 'excess' and (not armed or yielded) then
- pendingAuto.retractOnArrival = true
- end
- if AutoQueueSkips(pendingAuto.facID) then
- pendingAuto.retractOnArrival = true
- end
- return
- end
- -- The picks the fill passes work from: the current ones, or the last ones there were
- -- while the ranking is blank. RetractStale still reads the live facPicks, so a blank
- -- spell never pulls anything.
- if next(facPicks) then
- aqHold.picks = facPicks
- end
- -- Before anything is added: what we already have queued has to agree with what the
- -- panel is proposing now, or the depth is spent holding answers to an older read of
- -- the enemy.
- RetractStale()
- if mode == 'always' then
- UpdateContinuous()
- else
- UpdateOnExcess()
- end
- end
- -- What the auto-queue is doing, in a few words and a colour. The panel has no other way of
- -- saying it: the header that carried "spending excess" only exists on the four-row layout,
- -- and the icon layout -- which is the one being used -- has no header at all, so a queue
- -- that had quietly stopped feeding the base looked exactly like one that was working.
- --
- -- The two idle answers are the ones worth having. "Nothing to build" is a ranking with no
- -- picks in it; "every plant skipped" is a base where each factory is on repeat, closed by
- -- the skip settings, standing off after a refused order, or unable to build at all.
- local AQ_COLOR = {
- off = {0.55, 0.55, 0.55, 0.40}, -- the grip's ordinary grey
- idle = {0.95, 0.45, 0.35, 0.75}, -- wanted to build and could not: the one to notice
- wait = {0.45, 0.65, 0.95, 0.60}, -- armed for an overflow that has not come
- on = {0.45, 0.90, 0.50, 0.75}, -- placing orders
- yield = {1.00, 0.75, 0.25, 0.75}, -- stood down, the player is spending
- }
- local function AutoQueueState()
- local mode = options.autoQueueMode.value
- if amSpectating or mode == 'off' then
- return "Auto-queue off", "off"
- end
- if not next(aqHold.picks) then
- return "Auto-queue: nothing to build", "idle"
- end
- local now = os.clock()
- local eligible = false
- for facID in pairs(aqHold.picks) do
- if spGetUnitIsDead(facID) == false and not spGetUnitRepeat(facID)
- and not AutoQueueSkips(facID) and not ((aqHold.refused[facID] or 0) > now)
- and not PlantIdle(facID) then
- eligible = true
- break
- end
- end
- if not eligible then
- return "Auto-queue: every plant skipped", "idle"
- end
- if mode == 'always' then
- return "Auto-queue: filling factories", "on"
- end
- if yielded then
- return "Auto-queue: yours to spend", "yield"
- end
- if armed then
- return "Auto-queue: spending excess", "on"
- end
- return "Auto-queue: waiting for overflow", "wait"
- end
- --------------------------------------------------------------------------------
- -- Chili UI
- --------------------------------------------------------------------------------
- local Chili
- local window_main, holder, headerLabel, emptyLabel
- local rows = {}
- -- The compact alternative to the list: its window, its grip, one slot (icon and name) per
- -- busy factory and the picks those slots are showing. Kept in a table rather than as more
- -- locals because this block is close to Lua's 200-local ceiling.
- local iconPanel = {}
- local ICON_BIG = 48 -- plus 2px between stacked slots, written inline for the local ceiling
- local ICON_NAME_W = 120 -- room for the name beside the icon
- local PAD = 4
- local ICON = 26
- local ROW_H = 34
- local HEADER_H = 18
- local WIN_W = 250
- local TEXT_X = ICON + 8
- local BAR_H = 6
- local FONT_MAIN = {
- size = 13,
- color = {1, 1, 1, 1},
- outline = true,
- outlineWidth = 3,
- outlineWeight = 6,
- outlineColor = {0, 0, 0, 1},
- }
- local FONT_DIM = {
- size = 11,
- color = {0.70, 0.75, 0.85, 1},
- outline = true,
- outlineWidth = 3,
- outlineWeight = 6,
- outlineColor = {0, 0, 0, 1},
- }
- -- Inline font colour escape, the standard Spring form. Components must be 1-255; a zero
- -- byte would terminate the string, which is why Zero-K writes 001 rather than 000.
- local TAG_COLOR = "\255\160\170\190"
- local FONT_HEADER = {
- size = 13,
- color = {1.0, 0.85, 0.40, 1},
- outline = true,
- outlineWidth = 3,
- outlineWeight = 6,
- outlineColor = {0, 0, 0, 1},
- }
- -- Chili's Object:New turns the table you hand it into the instance itself, so a font table
- -- cannot be shared between controls: the second control to use it would take over the font
- -- object the first one is still pointing at. Every control gets its own copy.
- local function FontCopy(spec)
- local out = {}
- for k, v in pairs(spec) do
- if type(v) == "table" then
- local sub = {}
- for i, x in pairs(v) do
- sub[i] = x
- end
- out[k] = sub
- else
- out[k] = v
- end
- end
- return out
- end
- -- "factorycloak" -> "Cloak". Short enough to sit at the end of a row without crowding it.
- local function FactoryTag(facDefID)
- local n = UnitDefs[facDefID] and UnitDefs[facDefID].name or "?"
- local tag = n:match("^factory(.+)$") or n
- return tag:sub(1, 1):upper() .. tag:sub(2)
- end
- -- Longest opponent name the header can carry before it runs into the auto-mode suffix.
- local NAME_MAX = 14
- -- The header is one short line, so the reasoning goes here rather than into the caption.
- local function FocusTooltip()
- local mode = options.focusMode.value
- if mode == 'all' then
- return "Ranking against every enemy unit Enemy Intel remembers.\n"
- .. "Settings/HUD Panels/Build Proposer can narrow this to the players you fight."
- end
- if mode == 'attackers' then
- if not focusActive then
- return "Ranking against every enemy: "
- .. (focusNote or "nothing has shot you yet") .. "."
- end
- local n = 0
- for _ in pairs(attackers) do
- n = n + 1
- end
- local owners = {}
- for team in pairs(focusTeams) do
- owners[#owners + 1] = TeamName(team)
- end
- table.sort(owners)
- local tip = string.format("Ranking against the %d enemy unit%s that have actually shot "
- .. "you (%s).", n, (n == 1) and "" or "s",
- table.concat(owners, ", "))
- .. "\nEach weighted by the damage it has done to you, halving every "
- .. options.focusMemory.value .. "s."
- .. "\nA unit stops counting the moment it dies"
- if options.focusIdle.value > 0 then
- tip = tip .. ", or after " .. options.focusIdle.value .. "s without shooting you"
- end
- return tip .. ".\nTheir anti-air is still judged across their whole armies."
- end
- if focusActive then
- -- Heaviest front first, each named with the weight its army is carrying, so a pick
- -- that only makes sense against the second front is still explainable.
- local fronts = {}
- for team in pairs(focusTeams) do
- fronts[#fronts + 1] = {name = TeamName(team), w = (focusWeights and focusWeights[team]) or 1}
- end
- table.sort(fronts, function(a, b)
- if a.w ~= b.w then return a.w > b.w end
- return a.name < b.name
- end)
- local parts = {}
- for i = 1, #fronts do
- local f = fronts[i]
- parts[i] = (f.w >= 0.995) and f.name
- or string.format("%s at %d%%", f.name, math.floor(f.w * 100 + 0.5))
- end
- local lead
- if mode == 'main' then
- lead = "Ranking against the opponent you have fought most:"
- elseif mode == 'threat' then
- lead = "Ranking against the opponent hurting you most:"
- elseif mode == 'balanced' then
- lead = "Ranking against the opponents you are fighting, balanced towards who hurts you:"
- elseif mode == 'hurting' then
- lead = "Ranking against every opponent that has hurt you:"
- else
- lead = "Ranking against the opponents you are fighting:"
- end
- local tally = "damage traded"
- if mode == 'threat' or mode == 'hurting' then
- tally = "damage taken"
- elseif mode == 'balanced' then
- tally = string.format("damage traded and damage taken, mixed %d%% towards taken",
- math.floor(options.focusHarmBias.value * 100 + 0.5))
- end
- local tip = lead .. "\n" .. table.concat(parts, ", ")
- .. "\nWeighted by " .. tally .. ", halving every " .. options.focusMemory.value .. "s."
- if options.focusIdle.value > 0 then
- tip = tip .. "\nA front goes quiet for " .. options.focusIdle.value
- .. "s and stops counting."
- end
- return tip
- end
- if focusMain and not focusNote then
- return "Only one opponent in this game, so there is nothing to narrow to."
- end
- return "Ranking against every enemy: " .. (focusNote or "no opponent picked out yet") .. "."
- end
- local function RowClicked(self, _, _, mouseButton)
- if mouseButton ~= 1 then
- return
- end
- local pick
- if self.iconSlot then
- pick = iconPanel.shown and iconPanel.shown[self.iconSlot]
- else
- pick = picks[self.pickIndex]
- end
- if not pick then
- return
- end
- local alt, _, _, shift = spGetModKeyState()
- QueueUnit(pick.facID, pick.defID, shift, alt)
- end
- local function CreateWindow()
- window_main = Chili.Window:New{
- parent = Chili.Screen0,
- name = "BuildProposer",
- classname = "main_window_small_very_flat",
- -- Fully transparent, matching Enemy Intel: the rows float over the map with no frame.
- color = {0, 0, 0, 0},
- backgroundColor = {0, 0, 0, 0},
- borderColor = {0, 0, 0, 0},
- dockable = true,
- dockableSavePositionOnly = true,
- parentWidgetName = widget:GetInfo().name, -- for gui_chili_docking.lua (minimize)
- right = 2,
- y = 42,
- clientWidth = WIN_W,
- clientHeight = HEADER_H + PICK_COUNT * ROW_H + PAD * 2,
- draggable = false,
- resizable = false,
- tweakDraggable = true,
- tweakResizable = true,
- padding = {6, 6, 6, 6},
- }
- holder = Chili.Control:New{
- parent = window_main,
- x = 0, y = 0, right = 0, bottom = 0,
- padding = {0, 0, 0, 0},
- }
- headerLabel = Chili.Label:New{
- parent = holder,
- x = 2, y = 0,
- caption = "Best builds",
- font = FontCopy(FONT_HEADER),
- }
- emptyLabel = Chili.Label:New{
- parent = holder,
- x = 2, y = HEADER_H + 4,
- caption = "No enemy intel yet",
- font = FontCopy(FONT_DIM),
- }
- local barW = WIN_W - TEXT_X - PAD * 2 - 4
- for i = 1, PICK_COUNT do
- local y = HEADER_H + (i - 1) * ROW_H
- local btn = Chili.Button:New{
- parent = holder,
- x = PAD, y = y,
- width = WIN_W - PAD * 2,
- height = ROW_H - 2,
- caption = "",
- padding = {0, 0, 0, 0},
- backgroundColor = {0, 0, 0, 0},
- borderColor = {0, 0, 0, 0},
- focusColor = {1, 1, 1, 0.10},
- OnClick = {RowClicked},
- }
- btn.pickIndex = i
- local row = {
- ctrl = btn,
- -- Chili's Image inherits Button and swallows the hit, so the icon needs its own
- -- copy of the handler or clicking the picture would do nothing. Labels and the
- -- progress bar inherit Control instead and stay click-through.
- icon = Chili.Image:New{
- parent = btn,
- x = 0, y = 2,
- width = ICON, height = ICON,
- keepAspect = true,
- file = DEFAULT_ICON,
- pickIndex = i,
- OnClick = {RowClicked},
- },
- name = Chili.Label:New{
- parent = btn,
- x = TEXT_X, y = 0,
- caption = "",
- font = FontCopy(FONT_MAIN),
- },
- -- autosize is on by default, and for a right-aligned label it rewrites both x
- -- and width from the text on every SetCaption, so the label would walk left a
- -- little further each refresh. Fixed boxes instead: DrawInBox does the aligning.
- score = Chili.Label:New{
- parent = btn,
- x = TEXT_X, y = 0,
- width = barW, height = 16,
- autosize = false,
- align = "right",
- caption = "",
- font = FontCopy(FONT_MAIN),
- },
- bar = Chili.Progressbar:New{
- parent = btn,
- x = TEXT_X, y = 17,
- width = barW, height = BAR_H,
- min = 0, max = 1, value = 0,
- caption = "",
- color = {0.45, 0.75, 1.00, 0.9},
- backgroundColor = {0.1, 0.1, 0.1, 0.5},
- },
- }
- rows[i] = row
- btn:Hide()
- end
- end
- -- One pick per busy factory: its picture and its name, no score, no bar, no header. Its own
- -- window rather than the list window shrunk, so each layout keeps its own docked position
- -- and size -- api_chili_docking keys positions by window name. The slots are made on demand
- -- by RefreshIconPanel, since how many there are is however many plants are working.
- local function CreateIconWindow()
- -- The icons and names are all click target, so there was nothing left to grab. A grip
- -- strip on the left: a Panel has no handlers and no tooltip, so the hit falls through to
- -- the window, which (being draggable) starts the drag there.
- local GRIP_W = 8
- iconPanel.slots = {}
- iconPanel.shown = {}
- iconPanel.window = Chili.Window:New{
- parent = Chili.Screen0,
- name = "BuildProposerIcon",
- classname = "main_window_small_very_flat",
- color = {0, 0, 0, 0},
- backgroundColor = {0, 0, 0, 0},
- borderColor = {0, 0, 0, 0},
- dockable = true,
- dockableSavePositionOnly = true,
- parentWidgetName = widget:GetInfo().name,
- right = 406,
- y = 0,
- clientWidth = GRIP_W + 2 + ICON_BIG + ICON_NAME_W,
- clientHeight = ICON_BIG,
- draggable = true,
- resizable = false,
- tweakDraggable = true,
- tweakResizable = true,
- padding = {4, 4, 4, 4},
- }
- -- The grip is also the status light: it is the only part of this layout that is always
- -- there, pick or no pick.
- iconPanel.grip = Chili.Panel:New{
- parent = iconPanel.window,
- x = 0, y = 0, width = GRIP_W, bottom = 0,
- backgroundColor = AQ_COLOR.off,
- }
- -- Slot n, made the first time that many plants are busy at once. Each is a transparent
- -- button under the icon and the name, as in the list rows, so clicking the name queues
- -- too; the image swallows its own hit and needs its own copy of the handler. `iconSlot`
- -- is what RowClicked looks up in iconPanel.shown. Installed here rather than declared
- -- as a local, for the local ceiling, and it goes with the window when Shutdown drops
- -- the table.
- iconPanel.Slot = function(n)
- local slot = iconPanel.slots[n]
- if slot then
- return slot
- end
- local button = Chili.Button:New{
- parent = iconPanel.window,
- x = GRIP_W + 2, y = (n - 1) * (ICON_BIG + 2),
- right = 0, height = ICON_BIG,
- caption = "",
- padding = {0, 0, 0, 0},
- backgroundColor = {0, 0, 0, 0},
- borderColor = {0, 0, 0, 0},
- focusColor = {1, 1, 1, 0.10},
- iconSlot = n,
- OnClick = {RowClicked},
- }
- slot = {
- button = button,
- image = Chili.Image:New{
- parent = button,
- x = 0, y = 0,
- width = ICON_BIG, height = ICON_BIG,
- keepAspect = true,
- file = DEFAULT_ICON,
- iconSlot = n,
- OnClick = {RowClicked},
- },
- name = Chili.Label:New{
- parent = button,
- -- Fixed box, as for the list's score label: autosize would rewrite the
- -- height from the text and undo the vertical centring.
- x = ICON_BIG + 6, y = 0,
- width = ICON_NAME_W - 6, height = ICON_BIG,
- autosize = false,
- valign = "center",
- caption = "",
- font = FontCopy(FONT_MAIN),
- },
- }
- iconPanel.slots[n] = slot
- return slot
- end
- end
- -- Show/hide only. The windows are kept alive so their docked positions, and anything the
- -- tweak-mode layout did to them, survive being toggled off and on again. Only one of the two
- -- layouts is ever up; `visible` says whether the panel is wanted at all.
- function SetPanelVisible(visible)
- if not (window_main and iconPanel.window) then
- return
- end
- local iconOnly = options.iconOnly.value
- if visible and not iconOnly then
- window_main:Show()
- else
- window_main:Hide()
- end
- if visible and iconOnly then
- iconPanel.window:Show()
- else
- iconPanel.window:Hide()
- end
- if visible then
- dirty = true -- the panel went stale while hidden; redraw before it is looked at
- end
- end
- -- Say which enemy units drove the pick, so the number is arguable rather than something the
- -- panel just asserts. Shared by both layouts, so the lone icon says as much as a row does.
- local function PickTooltip(pick)
- local why = {}
- for j = 1, #pick.why do
- why[j] = unitName[pick.why[j]]
- end
- local tip = unitName[pick.defID] .. " - " .. (cost[pick.defID]) .. " metal"
- if #why > 0 then
- tip = tip .. "\nAnswers: " .. table.concat(why, ", ")
- end
- -- Only worth saying once it actually moved the number. The two readings of counter
- -- presence answer different questions, so they say so differently: one names the share
- -- of the army that screens this pick out, the other names the unit doing most of the
- -- screening and what the whole of it cost the score.
- if pick.counterShare and pick.counterShare >= 0.05 then
- if pick.counterUnit then
- tip = tip .. string.format("\nHeld back most by enemy %s (-%d%% in all)",
- unitName[pick.counterUnit],
- math.floor((1 - (pick.counterDamp or 1)) * 100 + 0.5))
- elseif pick.counterRole then
- tip = tip .. string.format("\nHeld back by enemy %s (%d%% of their army)",
- ROLE_PLURAL[pick.counterRole] or pick.counterRole,
- math.floor(pick.counterShare * 100 + 0.5))
- end
- end
- return tip .. "\nFrom: " .. FactoryTag(pick.facDefID)
- .. "\nClick to queue at the back, shift for five."
- .. "\nAlt queues it next instead, behind the unit being built."
- end
- -- The compact layout: one icon and name for every factory with something in its queue,
- -- showing what the ranking wants from that plant, the reasoning left to the tooltip. With
- -- every queue empty it falls back to the single best pick, so there is always something to
- -- click. The queues are read here rather than in Recompute, because orders land between
- -- passes and a refresh is the moment the panel is looked at.
- local function RefreshIconPanel()
- local state, key = AutoQueueState()
- iconPanel.grip.backgroundColor = AQ_COLOR[key]
- iconPanel.grip.tooltip = state
- iconPanel.grip:Invalidate()
- local shown = {}
- for facID, pick in pairs(picks.byFactory or {}) do
- -- nil for a plant that died since the ranking, so it drops out here too.
- if (FactoryQueueLen(facID) or 0) > 0 then
- shown[#shown + 1] = pick
- end
- end
- -- By unit id, which is build order: a plant keeps its place in the stack while others
- -- come and go, rather than the icons trading places every refresh as scores move.
- table.sort(shown, function(a, b) return a.facID < b.facID end)
- if #shown == 0 then
- shown[1] = picks.iconPick -- may be nil too, and then the stack is empty
- end
- iconPanel.shown = shown
- for i = 1, math.max(#shown, #iconPanel.slots) do
- local pick = shown[i]
- if pick then
- local slot = iconPanel.Slot(i)
- local tip = PickTooltip(pick) .. "\n" .. state
- slot.image.file = iconFile[pick.defID]
- slot.image.tooltip = tip
- slot.image:Invalidate()
- slot.name:SetCaption(unitName[pick.defID])
- slot.button.tooltip = tip
- slot.button:Show()
- else
- iconPanel.slots[i].button:Hide()
- end
- end
- -- The window follows the stack, so the empty space under it does not swallow clicks on
- -- the map. Height only: the width is the user's if tweak mode resized it. One slot's
- -- worth at least, for the grip.
- local n = math.max(#shown, 1)
- if iconPanel.count ~= n then
- iconPanel.count = n
- iconPanel.window:Resize(nil, n * ICON_BIG + (n - 1) * 2, true)
- end
- end
- local function RefreshLayout()
- if not (window_main and holder) then
- return
- end
- if options.iconOnly.value then
- if iconPanel.window then
- RefreshIconPanel()
- end
- return
- end
- local showTag = options.showFactoryTag.value
- for i = 1, PICK_COUNT do
- local row = rows[i]
- local pick = picks[i]
- if pick then
- row.icon.file = iconFile[pick.defID]
- row.icon:Invalidate()
- -- The factory goes inline after the name, dimmed. A second right-aligned label
- -- would have sat on top of the weight bar.
- local caption = unitName[pick.defID]
- if showTag then
- caption = caption .. TAG_COLOR .. " [" .. FactoryTag(pick.facDefID) .. "]"
- end
- row.name:SetCaption(caption)
- row.score:SetCaption(string.format("%.3f", pick.score))
- row.bar:SetValue(pick.barFill)
- row.ctrl.tooltip = PickTooltip(pick)
- row.ctrl:Show()
- else
- row.ctrl:Hide()
- end
- end
- if picks[1] then
- emptyLabel:Hide()
- else
- emptyLabel:SetCaption(emptyReason or "No enemy intel yet")
- emptyLabel:Show()
- end
- -- Say which of the two automatic modes is actually doing something right now, so the
- -- panel is not silently queueing units the player has forgotten they asked for, and
- -- name the front when the ranking has been narrowed to one, since a pick that looks
- -- wrong against the enemy side can be exactly right against the half of it you face.
- local caption = "Best builds"
- if focusActive and focusUnits then
- -- Naming a player here would be a lie: this ranking answers a handful of their units,
- -- not their army. How many units are on the books says more anyway, since it is also
- -- the sample size the ranking is resting on.
- local n = 0
- for _ in pairs(attackers) do
- n = n + 1
- end
- caption = "Best builds vs " .. n .. " attacker" .. ((n == 1) and "" or "s")
- elseif focusActive and focusMain then
- local others = -1
- for _ in pairs(focusTeams) do
- others = others + 1
- end
- local name = TeamName(focusMain)
- if #name > NAME_MAX then
- name = name:sub(1, NAME_MAX - 1) .. "."
- end
- caption = "Best builds vs " .. name
- if others > 0 then
- caption = caption .. " +" .. others
- end
- end
- if options.autoQueueMode.value == 'always' then
- caption = caption .. " - filling factories"
- elseif armed and yielded then
- caption = caption .. " - excess, yours to spend"
- elseif armed then
- caption = caption .. " - spending excess"
- end
- headerLabel:SetCaption(caption)
- headerLabel.tooltip = FocusTooltip() .. "\n" .. (AutoQueueState())
- end
- --------------------------------------------------------------------------------
- -- The debug panel
- --
- -- A second window, off by default, showing the working rather than the result: who is being
- -- countered and how heavily each enemy type weighs, the multiplier chain behind each of the
- -- four picks, and an itemised account of what the top pick is being charged for.
- --
- -- Separate from the panel above rather than an expansion of it, for three reasons. It is
- -- read while tuning the sliders and closed again afterwards, so it wants its own show/hide
- -- and its own docked position. It wants a width the four-row panel would look absurd at. And
- -- it is the only window here that is read rather than glanced at, so it is the only one that
- -- keeps a background instead of floating transparently over the map.
- --------------------------------------------------------------------------------
- local dbgWindow, dbgScroll, dbgHolder
- local dbgLines = {} -- pooled labels, one per line, grown to the high-water mark and reused
- local DBG_W = 470 -- text column width; the window is this plus padding and scrollbar
- local DBG_LINE_H = 15
- local DBG_PAD = 6
- local DBG_MAX_ARMY = 12 -- enemy types listed in full before the tail is summarised
- local DBG_MAX_LIST = 3 -- units named inside a single line of reasoning
- -- Inline colour escapes rather than a font per line, because the panel is one pool of
- -- identical labels and each line carries its own colour -- which is what lets a section be
- -- reordered, lengthened or dropped without touching the layout. Components must be 1-255,
- -- so a zero byte is written 001; none of these needs one.
- local DBG_COLOR = {
- head = "\255\255\215\100",
- sub = "\255\150\200\255",
- text = "\255\230\235\245",
- dim = "\255\150\160\180",
- good = "\255\130\220\140",
- bad = "\255\240\140\125",
- }
- local FONT_DBG = {
- size = 12,
- color = {1, 1, 1, 1},
- outline = true,
- outlineWidth = 3,
- outlineWeight = 6,
- outlineColor = {0, 0, 0, 1},
- }
- -- The whole panel as a flat list of coloured lines. Kept apart from the Chili plumbing
- -- below because this is the part worth reading, and the part that has to change whenever the
- -- scoring does.
- --
- -- No column past the first is aligned, and that is deliberate: Zero-K ships no monospaced
- -- font, so padding a name to a fixed width buys ragged columns rather than straight ones.
- -- The one number that does line up is the leading percentage, which is what the eye runs
- -- down.
- local function DebugText()
- local out = {}
- local C = DBG_COLOR
- local function Add(colour, fmt, ...)
- out[#out + 1] = colour .. ((select('#', ...) > 0) and string.format(fmt, ...) or fmt)
- end
- local function Pct(x)
- -- A share that is not zero must not print as zero, and one that is not the whole
- -- army must not print as the whole army. On a panel of small numbers both roundings
- -- read as a bug in the ranking rather than as a rounding, and the second one hides
- -- exactly the case worth seeing: a lopsided army with a tail that still counts.
- if x ~= 0 and math.abs(x) < 0.005 then
- return (x > 0) and "<1%" or "<-1%"
- end
- if x > 0 and x < 1 and x >= 0.995 then
- return ">99%"
- end
- return string.format("%.0f%%", x * 100)
- end
- -- Names a few entries of a sorted list and counts the rest, so a long tail shows up as a
- -- quantity even where it is not worth listing. `from` is there for the lines that have
- -- already named the head of the list and want what follows it.
- local function Names(list, n, from)
- from = from or 1
- local parts = {}
- for i = from, math.min(from + n - 1, #list) do
- parts[#parts + 1] = unitName[list[i].defID]
- end
- if #parts == 0 then
- return "nothing"
- end
- local s = table.concat(parts, ", ")
- local rest = #list - (from - 1) - #parts
- if rest > 0 then
- s = s .. string.format(" +%d more", rest)
- end
- return s
- end
- ----------------------------------------------------------------------------
- -- What is being countered
- ----------------------------------------------------------------------------
- Add(C.head, "COUNTERING")
- -- Whose army, in the main panel's own words. Borrowed rather than restated so the two
- -- cannot drift apart, and it already explains every focus mode and every fallback.
- for line in (FocusTooltip() .. "\n"):gmatch("(.-)\n") do
- if line ~= "" then
- Add(C.dim, " " .. line)
- end
- end
- local t = trace
- if not t then
- Add(C.bad, " no ranking to show: %s", emptyReason or "waiting for the first one")
- return out
- end
- Add(C.dim, " threat = how many there are x what each costs, buildings counted at half;")
- Add(C.dim, " share = that unit type as a fraction of the threat total, which is what")
- Add(C.dim, " every matchup below is weighted by")
- Add(C.text, " %d enemy unit type%s carrying %d metal of threat",
- #t.army, (#t.army == 1) and "" or "s", t.totalThreat)
- -- What the exclusions took out, as a quantity rather than as a list of settings. A
- -- ranking ignoring half the metal on the field is worth being told about, and each of
- -- these is a deliberate choice that is easy to forget having made.
- for i = 1, #t.dropped do
- local d = t.dropped[i]
- Add(C.dim, " %s excluded: %d type%s, %d metal, not being answered",
- d.label, d.types, (d.types == 1) and "" or "s", d.metal)
- end
- Add(C.dim, " enemy anti-air is %s of their metal; %d buildable units were considered",
- Pct(t.enemyAAShare), t.candidateCount)
- for i = 1, math.min(#t.army, DBG_MAX_ARMY) do
- local a = t.army[i]
- local note = role[a.defID]
- if isStatic[a.defID] then
- note = note .. ", static so counted at half"
- end
- Add(C.text, " %5s %s - %d threat (%s)",
- Pct(a.share), unitName[a.defID], a.threat, note)
- end
- if #t.army > DBG_MAX_ARMY then
- local rest = 0
- for i = DBG_MAX_ARMY + 1, #t.army do
- rest = rest + t.army[i].share
- end
- Add(C.dim, " and %d more type%s, %s of the threat between them",
- #t.army - DBG_MAX_ARMY, (#t.army - DBG_MAX_ARMY == 1) and "" or "s", Pct(rest))
- end
- ----------------------------------------------------------------------------
- -- How the four picks were arrived at
- ----------------------------------------------------------------------------
- Add(C.dim, " ")
- Add(C.head, "TOP 4 CALCULATION")
- local chain = "matchup"
- if t.counterStrength > 0 then chain = chain .. " x counter" end
- if t.qualityWeight > 0 then chain = chain .. " x tier" end
- if t.availWeight > 0 then chain = chain .. " x availability" end
- if t.dampOwnRoles then chain = chain .. " x own roles" end
- Add(C.dim, " score = %s", chain)
- Add(C.dim, " matchup the threat-weighted mean of the counter matrix over their whole")
- Add(C.dim, " army; 1.000 is an even fight, above is counter-picking them")
- Add(C.dim, " won / lost / unreachable how far from even each pairing lands, weighted by")
- Add(C.dim, " share, so the three add back to the matchup exactly")
- Add(C.dim, " x terms multipliers on that mean; 1.000 changes nothing, lower is a cost")
- Add(C.dim, " score all of the above multiplied together, in matchup units, and")
- Add(C.dim, " what the panel prints beside each pick")
- Add(C.dim, " the bar on the panel, each score against the best of the four; the top")
- Add(C.dim, " row is always full, so the bar compares and the number measures")
- for i = 1, #t.picks do
- local e = t.picks[i]
- Add(C.sub, "%d. %s - score %.3f", i, unitName[e.defID], e.score)
- -- base = 1 + won - lost - unreachable exactly, so the three terms take the average
- -- apart rather than commenting on it.
- Add(C.text, " matchup %.3f = 1.000 + %.3f won - %.3f lost - %.3f unreachable",
- e.base, e.gain, e.weak, e.blind)
- if t.counterStrength > 0 then
- local worst = "nothing above it"
- if e.counterUnit then
- worst = "worst " .. unitName[e.counterUnit]
- elseif e.counterRole then
- worst = "worst " .. (ROLE_PLURAL[e.counterRole] or e.counterRole)
- end
- Add(C.text, " counter x%.3f - %s", e.counterDamp, worst)
- end
- if t.qualityWeight > 0 then
- Add(C.text, " tier x%.3f - %s", e.mQuality,
- e.tier and ("tier " .. e.tier) or "unrated, so untouched")
- end
- if t.availWeight > 0 then
- Add(C.text, " avail x%.3f - %d metal, %.0fs to afford + %.0fs to build",
- e.mAvail, cost[e.defID], e.affordWait or 0, e.prodTime or 0)
- end
- if t.dampOwnRoles then
- Add(C.text, " own roles x%.3f - your army is %s %s",
- e.mOwn, Pct((1 - e.mOwn) / 0.6), ROLE_PLURAL[role[e.defID]] or role[e.defID])
- end
- Add(C.dim, " biggest contributors to that mean: %s", Names(e.matchups, DBG_MAX_LIST))
- end
- ----------------------------------------------------------------------------
- -- What is holding the top pick back
- ----------------------------------------------------------------------------
- local top = t.picks[1]
- if not top then
- return out
- end
- Add(C.dim, " ")
- Add(C.head, "DRAGGING DOWN " .. (unitName[top.defID] or "?"):upper() .. ": BY TERM")
- Add(C.dim, " each figure is the share of the score that term took off; the terms")
- Add(C.dim, " multiply rather than add, so they will not sum to the total")
- local eq = string.format(" %.3f = %.3f matchup", top.score, top.base)
- if t.counterStrength > 0 then eq = eq .. string.format(" x %.3f counter", top.counterDamp) end
- if t.qualityWeight > 0 then eq = eq .. string.format(" x %.3f tier", top.mQuality) end
- if t.availWeight > 0 then eq = eq .. string.format(" x %.3f avail", top.mAvail) end
- if t.dampOwnRoles then eq = eq .. string.format(" x %.3f own", top.mOwn) end
- Add(C.text, eq)
- -- Every cost the pick was charged, in one order, so the largest is the one worth arguing
- -- with. The multipliers are read as the fraction of the score each removed; the two
- -- matchup losses are read as the fraction of a clean sweep of the army they cost, which
- -- puts all five on the same scale rather than mixing a multiplier with a raw difference.
- local clean = top.base + top.weak + top.blind
- local drag = {}
- local function Charge(loss, label, detail)
- if loss > 0.005 then
- drag[#drag + 1] = {loss = loss, label = label, detail = detail}
- end
- end
- local counterDetail
- if t.matrixCounter then
- local worst = top.pressureOn[1]
- counterDetail = string.format("%.2f counter pressure over the army", top.pressure)
- if worst then
- counterDetail = counterDetail .. string.format("; %s carries %.2f of it",
- unitName[worst.defID], worst.bad)
- if #top.pressureOn > 1 then
- counterDetail = counterDetail .. string.format(", then %s",
- Names(top.pressureOn, DBG_MAX_LIST, 2))
- end
- end
- elseif top.counterRole then
- counterDetail = string.format("the enemy has enough %s to screen this out",
- ROLE_PLURAL[top.counterRole] or top.counterRole)
- end
- Charge(1 - top.counterDamp, "counter presence", counterDetail)
- Charge(1 - top.mAvail, "availability", string.format(
- "%d metal: %.0fs to afford, %.0fs to build, against a %ds horizon",
- cost[top.defID], top.affordWait or 0, top.prodTime or 0, t.horizon))
- Charge(1 - top.mOwn, "own roles", string.format("your army is already %s %s",
- Pct((1 - top.mOwn) / 0.6), ROLE_PLURAL[role[top.defID]] or role[top.defID]))
- Charge(1 - top.mQuality, "tier", top.tier and ("rated " .. top.tier) or "unrated")
- if clean > 0 then
- Charge(top.blind / clean, "no weapon for part of their army", string.format(
- "%s of their metal it cannot shoot at all: %s",
- Pct(top.blind), Names(top.blindOn, DBG_MAX_LIST)))
- -- Which pairings the "lost" term is actually made of, worst first. Weighted by share,
- -- because a hopeless matchup against 2% of the army is not what is holding a pick down.
- local losers = {}
- for i = 1, #top.matchups do
- local m = top.matchups[i]
- if m.pair > 0 and m.pair < 1 then
- losers[#losers + 1] = {
- defID = m.defID, pair = m.pair, share = m.share,
- loss = m.share * (1 - m.pair),
- }
- end
- end
- table.sort(losers, function(a, b) return a.loss > b.loss end)
- local detail = "nothing in particular"
- if losers[1] then
- detail = string.format("%s at %.2f (%s of their army)",
- unitName[losers[1].defID], losers[1].pair, Pct(losers[1].share))
- if losers[2] then
- detail = detail .. string.format(", then %s at %.2f",
- unitName[losers[2].defID], losers[2].pair)
- end
- end
- Charge(top.weak / clean, "matchups it loses", detail)
- end
- table.sort(drag, function(a, b) return a.loss > b.loss end)
- if #drag == 0 then
- Add(C.good, " nothing is holding it back -- every term is at or above 1.")
- end
- for i = 1, #drag do
- local d = drag[i]
- Add(C.bad, " -%-5s %s", Pct(d.loss), d.label)
- if d.detail then
- Add(C.dim, " %s", d.detail)
- end
- end
- -- The ceiling, so the costs above have something to be a fraction of: what this pick
- -- would score against this army if it beat everything in it and nothing charged it.
- Add(C.dim, " a clean sweep of this army would score %.3f, against %.3f now",
- clean * math.max(top.mQuality, 1), top.score)
- local second = t.picks[2]
- if second and second.score > 0 then
- Add(C.dim, " ahead of %s by %.3f, which is %s more",
- unitName[second.defID], top.score - second.score,
- Pct(top.score / second.score - 1))
- end
- ----------------------------------------------------------------------------
- -- What is holding the top pick back, enemy by enemy
- ----------------------------------------------------------------------------
- if top.perUnit and #top.perUnit > 0 then
- Add(C.dim, " ")
- Add(C.head, "DRAGGING DOWN " .. (unitName[top.defID] or "?"):upper() .. ": BY UNIT")
- Add(C.dim, " each figure is what the score would gain if that unit type left the")
- Add(C.dim, " field: the matchup average and the counter damping both rebuilt over")
- Add(C.dim, " the army without it. One number per enemy, on the score's own scale.")
- local drags, helps = {}, {}
- for i = 1, #top.perUnit do
- local u = top.perUnit[i]
- -- Rounded to the same place it is printed at, so nothing appears on a list
- -- claiming to have moved the score by 0%.
- if u.drag / top.score >= 0.005 then
- drags[#drags + 1] = u
- elseif u.drag / top.score <= -0.005 then
- helps[#helps + 1] = u
- end
- end
- if #drags == 0 then
- Add(C.good, " no enemy unit type is holding it back on its own.")
- end
- for i = 1, #drags do
- local u = drags[i]
- Add(C.bad, " +%-5s %s", Pct(u.drag / top.score), unitName[u.defID])
- local how
- if u.pair <= 0 then
- how = "cannot be shot at all"
- elseif u.pair < 1 then
- how = string.format("matrix %.2f against it", u.pair)
- else
- how = string.format("matrix %.2f, so the loss is its weight, not the matchup",
- u.pair)
- end
- -- Only once it survives the rounding it is printed at: "0.00 of the counter
- -- pressure" is a claim that something is happening, made about nothing.
- if u.bad >= 0.005 then
- how = how .. string.format("; %.2f of the counter pressure", u.bad)
- end
- Add(C.dim, " %s of their army, %s", Pct(u.share), how)
- end
- -- The other direction, because a pick sitting where it is on the back of one enemy
- -- unit type is exactly as fragile as one held down by one, and reads the same on the
- -- main panel.
- for i = 1, math.min(2, #helps) do
- local u = helps[#helps + 1 - i]
- Add(C.good, " %-6s %s is carrying it: %s of their army, matrix %.2f",
- Pct(u.drag / top.score), unitName[u.defID], Pct(u.share), u.pair)
- end
- end
- return out
- end
- local function RefreshDebug()
- if not (dbgWindow and dbgHolder) then
- return
- end
- local lines = DebugText()
- for i = 1, #lines do
- local lbl = dbgLines[i]
- if not lbl then
- -- autosize off, as on the main panel: an autosizing label rewrites its own x and
- -- width from the caption on every SetCaption, and these captions change every
- -- refresh. A fixed box lets DrawInBox do the placing instead.
- lbl = Chili.Label:New{
- parent = dbgHolder,
- x = DBG_PAD, y = 0,
- width = DBG_W, height = DBG_LINE_H,
- autosize = false,
- caption = "",
- font = FontCopy(FONT_DBG),
- }
- dbgLines[i] = lbl
- end
- lbl:SetPos(DBG_PAD, DBG_PAD + (i - 1) * DBG_LINE_H)
- lbl:SetCaption(lines[i])
- lbl:Show()
- end
- -- Kept rather than disposed: the line count swings with the enemy army and with which
- -- terms are switched on, and rebuilding a label pool every second to save a few hidden
- -- controls is a worse trade than holding the high-water mark.
- for i = #lines + 1, #dbgLines do
- dbgLines[i]:Hide()
- end
- -- The holder is what the scroll panel scrolls; the window keeps whatever size it was
- -- dragged to in tweak mode.
- dbgHolder:SetPos(nil, nil, nil, DBG_PAD * 2 + #lines * DBG_LINE_H)
- end
- local function CreateDebugWindow()
- dbgWindow = Chili.Window:New{
- parent = Chili.Screen0,
- name = "BuildProposerDebug",
- classname = "main_window_small_very_flat",
- dockable = true,
- dockableSavePositionOnly = true,
- parentWidgetName = widget:GetInfo().name, -- for gui_chili_docking.lua (minimize)
- x = 1,
- y = 46,
- -- Room for the scrollbar beside the text column.
- clientWidth = DBG_W + DBG_PAD * 2 + 16,
- clientHeight = 460,
- draggable = false,
- resizable = false,
- tweakDraggable = true,
- tweakResizable = true,
- padding = {6, 6, 6, 6},
- -- The one window here that is read line by line, so the one that does not float
- -- transparently: a wall of small numbers over the map is unreadable without a ground
- -- behind it.
- color = {0, 0, 0, 0},
- backgroundColor = {0.05, 0.06, 0.09, 0.85},
- borderColor = {0.40, 0.45, 0.55, 0.55},
- }
- dbgScroll = Chili.ScrollPanel:New{
- parent = dbgWindow,
- x = 0, y = 0, right = 0, bottom = 0,
- padding = {0, 0, 0, 0},
- borderColor = {0, 0, 0, 0},
- backgroundColor = {0, 0, 0, 0},
- horizontalScrollbar = false,
- }
- dbgHolder = Chili.Control:New{
- parent = dbgScroll,
- x = 0, y = 0, right = 0,
- height = 1,
- padding = {0, 0, 0, 0},
- }
- end
- -- Show/hide only, like the panel above, so the docked position survives a toggle. Unlike the
- -- panel above this one also has to ask for a ranking: nothing is traced while it is closed,
- -- so on opening there is no working to show until the next recompute, and waiting a whole
- -- update interval to find that out is how a debug panel gets a reputation for being broken.
- function SetDebugVisible(visible)
- if not dbgWindow then
- return
- end
- if visible then
- dbgWindow:Show()
- nextRefresh = 0 -- recompute now, with tracing on this time
- dirty = true
- else
- dbgWindow:Hide()
- end
- end
- --------------------------------------------------------------------------------
- -- Callins
- --------------------------------------------------------------------------------
- local function RefreshTeamState()
- myTeamID = spGetMyTeamID()
- myAllyTeamID = spGetMyAllyTeamID()
- amSpectating = select(1, Spring.GetSpectatingState())
- RebuildEnemyTeams()
- end
- function M:Initialize()
- Chili = WG.Chili
- if not Chili then
- Spring.Echo("Build Proposer: WG.Chili unavailable, removing widget.")
- widgetHandler:RemoveWidget(widget)
- return
- end
- RefreshTeamState()
- -- Sample the map once to find how much of it is under water. Ships and submarines are
- -- not worth proposing on a map that has nowhere to float them.
- local sx, sz = Game.mapSizeX, Game.mapSizeZ
- local step = 256
- local below, total = 0, 0
- for x = 0, sx, step do
- for z = 0, sz, step do
- total = total + 1
- if (spGetGroundHeight(x, z) or 0) < 0 then
- below = below + 1
- end
- end
- end
- waterFraction = (total > 0) and (below / total) or 0
- -- Published so the Engagement Predictor scores a fight with the same archetype
- -- classification and counter matrix that ranks the build here, rather than carrying a
- -- second copy that drifts apart every time this one is tuned. The scoring functions take
- -- no weights and blend nothing: the flat matrix holds one value per ordered pair, and a
- -- row that does not name the target falls back to its column for that class, so every
- -- pairing has an answer without a consumer having to ask for one.
- WG.CounterMatrix = {
- -- 2 adds UNIT_TIER / TIER_VALUE / Quality. Consumers should treat Quality as
- -- optional and fall back to 1 so an older copy of this widget still works.
- -- 3 adds matrixName and the commander rows behind it.
- -- 4 adds waterFraction, so a consumer deciding whether to propose something that
- -- floats does not have to sample the map a second time.
- version = 4,
- -- Per-unitDef, built once at load.
- role = role, cost = cost, health = health, range = range, speed = speed,
- dpsGround = dpsGround, dpsAir = dpsAir, dpsWater = dpsWater,
- isAir = isAir, isStatic = isStatic, isArmed = isArmed, isAA = isAA, isSub = isSub,
- isFactory = isFactory, buildSpeed = buildSpeed,
- defName = defName, unitName = unitName, iconFile = iconFile,
- -- Share of the map under water, sampled once above, and the share below which there
- -- is nowhere worth floating a ship. Both, rather than the verdict: a consumer that
- -- wants a different threshold should not have to re-sample the map to get one.
- waterFraction = waterFraction, WATER_THRESHOLD = WATER_THRESHOLD,
- -- The key to index MATRIX by. Same as defName for everything except a commander,
- -- which answers to one of four chassis rows rather than to its own unitDef.
- matrixName = matrixName,
- -- The matrix itself: MATRIX[matrixName[my]][matrixName[enemy]] is the whole answer,
- -- with MATRIX[...]["#" .. col] as the fallback for a target it does not name.
- MATRIX = MATRIX, ROLE_PLURAL = ROLE_PLURAL, COUNTERED_BY = COUNTERED_BY,
- -- Scoring. ColumnArch(enemyDef); MatrixValue(myDef, enemyDef, col);
- -- PairScore(myDef, enemyDef, enemyAAShare).
- ColumnArch = ColumnArch, MatrixValue = MatrixValue, PairScore = PairScore,
- -- The other side of the same question. CanEngage(shooter, target) -> bool;
- -- CounterPressure(myDef, enemyDef) -> how hard that one enemy holds myDef back,
- -- in log space and never below 0. Weight it by the enemy unit's share of the army
- -- and sum over the army; 1 - exp(-sum) is the severity this widget damps by.
- CanEngage = CanEngage, CounterPressure = CounterPressure,
- -- Tier list. Quality(defID) -> multiplier, 1.0 for anything unrated. Orthogonal to
- -- the matrix: it says how good a unit is, not what it is good against, so a consumer
- -- applies it per unit rather than per pairing.
- UNIT_TIER = UNIT_TIER, TIER_VALUE = TIER_VALUE, Quality = Quality,
- }
- CreateWindow()
- CreateIconWindow()
- SetPanelVisible(options.showPanel.value)
- CreateDebugWindow()
- SetDebugVisible(options.debugPanel.value)
- dirty = true
- end
- function M:Shutdown()
- WG.CounterMatrix = nil
- -- Leaving our orders behind would be worse than never having placed them.
- RetractAll()
- if window_main then
- window_main:Dispose()
- window_main = nil
- end
- if iconPanel.window then
- iconPanel.window:Dispose()
- iconPanel = {}
- end
- if dbgWindow then
- dbgWindow:Dispose()
- dbgWindow = nil
- dbgHolder, dbgScroll = nil, nil
- dbgLines = {} -- the pool went with the window
- end
- end
- function M:PlayerChanged()
- local prevTeam = myTeamID
- RefreshTeamState()
- if myTeamID ~= prevTeam then
- -- Now looking from a different seat: those orders are not ours to manage any more.
- autoOrders = {}
- pendingAuto = nil
- ourTags = {}
- seenTags = {}
- armed = false
- yielded = false
- playerTags = {}
- -- Whoever the previous seat was fighting is not our fight any more.
- engagement = {}
- harm = {}
- lastFought = {}
- attackers = {}
- focusTeams, focusWeights, focusUnits, focusMain, focusNote = nil, nil, nil, nil, nil
- focusActive = false
- dirty = true
- end
- end
- function M:Update()
- local now = os.clock()
- clockNow = now
- -- Runs unconditionally: an order still in flight has to be claimed before it can ever
- -- be pulled back off the queue.
- ResolvePending(now)
- UpdateArmed()
- if now >= nextRefresh then
- nextRefresh = now + options.updateRate.value
- DecayEngagement(now)
- UpdateFocus()
- Recompute()
- dirty = true
- end
- UpdateAutoQueue(now)
- -- Nothing to lay out while hidden, and `dirty` is left standing so the first refresh
- -- after unhiding draws the ranking as it stands then rather than as it was. The two
- -- windows hide independently, so either one being open is reason enough to redraw.
- if dirty and (options.showPanel.value or options.debugPanel.value) then
- dirty = false
- if options.showPanel.value then
- RefreshLayout()
- end
- if options.debugPanel.value then
- RefreshDebug()
- end
- end
- end
- RegisterModule{
- callins = M,
- prefix = 'bp_',
- optionsPath = options_path,
- options = options,
- optionsOrder = options_order,
- }
- end
- --------------------------------------------------------------------------------
- -- Fac Switcher
- --
- -- Build Proposer answers "what should this factory make next". This answers the question
- -- one level up: given what the enemy has, which factory should be on the field at all.
- --
- -- A factory is not a unit, so it is not ranked like one. It is a roster, and what matters
- -- is whether that roster has *an* answer to each thing the enemy owns -- not how good its
- -- average unit is. So each enemy unit type is scored against the best answer the factory
- -- could build for it, and those per-enemy answers are then averaged over the enemy army by
- -- threat. A factory with one crushing answer and nothing for the other half of the field
- -- ranks below one that covers everything adequately, which is the whole reason the average
- -- is a power mean rather than an arithmetic one: see `holeWeight`.
- --
- -- It owns no scoring of its own. The archetypes, the matrix and PairScore all arrive
- -- through WG.CounterMatrix and the enemy army through WG.EnemyIntel, exactly as
- -- Engagement Predictor takes them, so a factory ranking and a unit ranking can never
- -- disagree about what counters what.
- --------------------------------------------------------------------------------
- do
- local M = {} -- this module's callins, collected by RegisterModule below
- local options_path, options_order, options -- were file-scope globals
- --------------------------------------------------------------------------------
- -- Includes / static per-unitDef data
- --
- -- Only what is needed to draw a row, price a factory and know what it builds. Everything
- -- that decides a matchup comes from WG.CounterMatrix at refresh time.
- --------------------------------------------------------------------------------
- local iconTypesPath = LUAUI_DIRNAME .. "Configs/icontypes.lua"
- local icontypes = VFS.FileExists(iconTypesPath) and VFS.Include(iconTypesPath)
- local iconFormat = ".dds"
- do
- -- chilitip_conf.lua returns: ignoreweapon, iconFormat
- local ok, _, fmt = pcall(VFS.Include, LUAUI_DIRNAME .. "Configs/chilitip_conf.lua", nil, VFS.ZIP)
- if ok and fmt then
- iconFormat = fmt
- end
- end
- local spGetTeamUnits = Spring.GetTeamUnits
- local spGetUnitDefID = Spring.GetUnitDefID
- local spGetMyTeamID = Spring.GetMyTeamID
- local spGetCmdDescIndex = Spring.GetCmdDescIndex
- local spSetActiveCommand = Spring.SetActiveCommand
- local spGetModKeyState = Spring.GetModKeyState
- local spEcho = Spring.Echo
- local floor, max, min, exp, log = math.floor, math.max, math.min, math.exp, math.log
- local DEFAULT_ICON = "icons/default.dds"
- local iconFile = {}
- local unitName = {}
- local cost = {}
- local isStatic = {}
- local needsWater = {}
- -- facDefID -> the mobile units it builds, and nil for everything else. Having a roster here
- -- is the whole test for being a factory worth ranking, and three things are turned away at
- -- this one gate rather than at each use.
- --
- -- `isFactory` is an engine flag over buildings that build, so it is also true of a Caretaker
- -- and of the Missile Silo; what separates them from a plant you would switch to is not the
- -- flag but whether anything mobile comes out, so that is what is asked.
- --
- -- A factory plate builds its parent's roster from a second, cheaper pad. Ranking one is
- -- ranking the factory it is attached to, so it would simply duplicate a row.
- local roster = {}
- for defID = 1, #UnitDefs do
- local ud = UnitDefs[defID]
- local it = ud.iconType or "default"
- local path = (icontypes and icontypes[it] and icontypes[it].bitmap)
- or ("icons/" .. it .. iconFormat)
- -- Zero-K's own square build texture, which the engine renders from the model, rather
- -- than icons/default.dds -- that file is fully transparent, so a missing icon drew a
- -- blank box with a count beside it (submarine, none, the legacy tank/kbot set).
- if not VFS.FileExists(path) then
- path = "#" .. defID
- end
- iconFile[defID] = path
- unitName[defID] = ud.humanName or ud.name or "?"
- cost[defID] = ud.metalCost or 0
- isStatic[defID] = (ud.speed or 0) == 0
- needsWater[defID] = (ud.minWaterDepth or 0) > 0
- local cp = ud.customParams
- local isPlate = (cp and cp.child_of_factory) and true or false
- local opts = ud.isFactory and not isPlate and ud.buildOptions
- if opts then
- local list, n = {}, 0
- for i = 1, #opts do
- local o = opts[i]
- if (UnitDefs[o].speed or 0) > 0 then
- n = n + 1
- list[n] = o
- end
- end
- if n > 0 then
- roster[defID] = list
- end
- end
- end
- -- "factorycloak" -> "Cloak", "striderhub" -> "Strider". Short enough for a header line,
- -- where the full "Cloakbot Factory" would not fit beside the verdict.
- local function FacTag(facDefID)
- local n = UnitDefs[facDefID] and UnitDefs[facDefID].name or "?"
- local tag = n:match("^factory(.+)$") or n:match("^(.+)hub$") or n
- return tag:sub(1, 1):upper() .. tag:sub(2)
- end
- --------------------------------------------------------------------------------
- -- State (declared before `options` so the option closures can capture them)
- --------------------------------------------------------------------------------
- local PICK_COUNT = 3
- -- The lowest value in the counter matrix. A roster with nothing at all for some enemy type
- -- scores that type at the floor rather than at zero: "the worst matchup in the table" is a
- -- fair reading of having no answer, and it keeps the geometric mean below from collapsing
- -- the whole factory to nothing over one unit it cannot touch.
- local MIN_ANSWER = 0.05
- -- Enemy buildings can usually be walked around, so they weigh less than their metal. Same
- -- figure Build Proposer uses, deliberately: the two panels rank against one army and should
- -- not privately disagree about how much of it is worth answering.
- local STATIC_THREAT_WEIGHT = 0.5
- local WATER_THRESHOLD = 0.12 -- fallback only; WG.CounterMatrix 4 publishes the real one
- -- What an answer may cost before it starts being charged for: a quarter of the enemy army,
- -- or 750 metal, whichever is more. The floor is set above the ordinary unit range on
- -- purpose. This term exists to rule out answering a skirmish with a Detriment, not to pick
- -- between a Ronin and a Ripper, and without a floor the first Glaive anyone scouts makes
- -- every unit in the game look like an extravagance.
- local COMMIT_FREE = 0.25
- local COMMIT_REF_MIN = 3000
- local picks = {} -- top PICK_COUNT, best first
- local ownedBest = nil -- highest-ranked factory we already own, for the switch verdict
- local emptyReason = nil
- local dirty = true
- local nextRefresh = 0
- local myTeamID = spGetMyTeamID()
- local amSpectating = false
- local SetPanelVisible -- forward declaration, defined below
- --------------------------------------------------------------------------------
- -- Options (auto-registered into the settings menu, persisted to ZK_data.lua)
- --------------------------------------------------------------------------------
- options_path = 'Settings/HUD Panels/Fac Switcher'
- options_order = {'showPanel', 'updateRate', 'holeWeight', 'commitWeight', 'qualityWeight',
- 'showOwned',
- 'mobileOnly', 'ignoreCommanders', 'ignoreCons', 'ignoreAA', 'ignoreAir',
- 'ignoreEco'}
- options = {
- showPanel = {
- name = 'Show Fac Switcher panel',
- desc = 'Hide the factory ranking without unloading the widget. Nothing here outlives '
- .. 'the panel -- the ranking is recomputed from live intel every tick and '
- .. 'published to no one -- so while hidden it stops scoring entirely, and picks '
- .. 'the current enemy army back up the moment it is shown.',
- type = 'bool',
- value = true,
- OnChange = function(self)
- SetPanelVisible(self.value)
- end,
- },
- updateRate = {
- name = 'Fac Switcher update interval (s)',
- desc = 'How often the ranking is recomputed. A factory choice moves over minutes, so '
- .. 'there is nothing to gain from a fast refresh here.',
- type = 'number',
- min = 0.5, max = 10, step = 0.5, value = 2,
- },
- holeWeight = {
- name = 'Punish gaps in the roster',
- desc = 'How much a factory is judged by what it has no answer to, rather than by its '
- .. 'answers on average. At 0 a crushing counter to half the enemy army makes up '
- .. 'for having nothing for the other half. At 0.5 the answers are multiplied '
- .. 'rather than added, so a gap drags the whole factory down. At 1 the worst '
- .. 'matchup in the roster very nearly decides the ranking on its own.',
- type = 'number',
- min = 0, max = 1, step = 0.05, value = 0.5,
- OnChange = function() dirty = true end,
- },
- commitWeight = {
- name = 'Punish answers you could not field',
- desc = 'How hard an answer is held back for costing more than the fight is worth. The '
- .. 'counter matrix rates a matchup by class and says nothing about price, so '
- .. 'without this a Detriment is the answer to a Glaive swarm and the Strider Hub '
- .. 'wins every ranking. At 0 the matrix is taken at its word.',
- type = 'number',
- min = 0, max = 2, step = 0.05, value = 1,
- OnChange = function() dirty = true end,
- },
- qualityWeight = {
- name = 'Weight of the unit tier list',
- desc = 'How much a factory is credited for answering with a strong unit rather than '
- .. 'merely a correct one. Applied while choosing which unit answers each enemy '
- .. 'type, so it can change the answer and not only the score.',
- type = 'number',
- min = 0, max = 2, step = 0.05, value = 0.5,
- OnChange = function() dirty = true end,
- },
- showOwned = {
- name = 'Include factories you own',
- desc = 'Keep the factories already on the field in the ranking, so the panel can say '
- .. 'your current plant is still the right one. Turn off to see only what you '
- .. 'would be switching to.',
- type = 'bool',
- value = true,
- OnChange = function()
- dirty = true
- end,
- },
- -- The same six filters as Build Proposer, with the same tests, in the same order, but
- -- set separately: which enemy units a factory has to answer is not necessarily the
- -- same question as which ones the next unit has to.
- mobileOnly = {
- name = 'Fac Switcher: counter mobile units only',
- desc = 'Leave enemy buildings out of the army the factories are ranked against. Static anti-air still counts against air factories either way.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- ignoreCommanders = {
- name = 'Fac Switcher: ignore enemy commanders',
- desc = 'Leave enemy commanders out of the army the factories are ranked against.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- ignoreCons = {
- name = 'Fac Switcher: ignore enemy constructors',
- desc = 'Leave enemy constructors out of the army the factories are ranked against.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- ignoreAA = {
- name = 'Fac Switcher: ignore enemy anti-air',
- desc = 'Leave enemy anti-air (the units that only shoot aircraft) out of the army the factories are ranked against. Whether an air factory is viable is still judged against all of it.',
- type = 'bool',
- value = false,
- OnChange = function() dirty = true end,
- },
- ignoreAir = {
- name = 'Fac Switcher: ignore enemy air',
- desc = 'Leave enemy aircraft out of the army the factories are ranked against.',
- type = 'bool',
- value = false,
- OnChange = function() dirty = true end,
- },
- ignoreEco = {
- name = 'Fac Switcher: ignore enemy economy buildings',
- desc = 'Leave unarmed enemy buildings out of the army the factories are ranked against, while their turrets still count.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- }
- --------------------------------------------------------------------------------
- -- The ranking
- --------------------------------------------------------------------------------
- -- Every factory we could put down, plus every one already standing. The first comes off our
- -- own builders rather than off a fixed list, so a mission or mod that restricts what a
- -- constructor may build restricts this too. The second is unioned in because a plant we
- -- own is the thing every other row is being compared against, and dropping it out of the
- -- ranking would leave the panel proposing a switch with nothing to switch away from.
- local function GatherFactories()
- local buildable, owned = {}, {}
- local units = spGetTeamUnits(myTeamID)
- if not units then
- return buildable, owned
- end
- for i = 1, #units do
- local unitID = units[i]
- local defID = spGetUnitDefID(unitID)
- if defID then
- if roster[defID] then
- owned[defID] = true
- end
- -- A finished plate that is linked to a working factory -- ours or a teammate's --
- -- builds that factory's whole roster, so for choosing a factory it is as good as
- -- owning one. The link is Zero-K's own verdict: unit_factory_plate publishes
- -- `nofactory` = 0 while the plate has a live, unstunned parent in range.
- -- "plate" rather than true, so the row can say which it is; a factory we really
- -- own overwrites it whichever order the two come up in.
- local cp = UnitDefs[defID].customParams
- local parent = cp and cp.child_of_factory and UnitDefNames[cp.child_of_factory]
- if parent and roster[parent.id] and not owned[parent.id]
- and Spring.GetUnitRulesParam(unitID, "nofactory") == 0
- and not Spring.GetUnitIsBeingBuilt(unitID) then
- owned[parent.id] = "plate"
- end
- local opts = UnitDefs[defID].buildOptions
- if opts then
- for j = 1, #opts do
- local o = opts[j]
- if roster[o] then
- buildable[o] = true
- end
- end
- end
- end
- end
- for defID in pairs(owned) do
- buildable[defID] = true
- end
- return buildable, owned
- end
- local function Recompute()
- picks, ownedBest, emptyReason = {}, nil, nil
- -- Read at refresh time rather than cached at Initialize, the same way Engagement
- -- Predictor reads them: load order is not guaranteed and either producer can be
- -- toggled mid-game.
- local cm = WG.CounterMatrix
- if not cm then
- emptyReason = "Build Proposer is not running"
- return
- end
- local intel = WG.EnemyIntel
- if not intel then
- emptyReason = "Enemy Intel is not running"
- return
- end
- -- Deliberately the whole enemy side, with none of Build Proposer's per-opponent focus
- -- or exclusions. A factory is a commitment for the rest of the game and gets built
- -- once; narrowing it to the two raiders that happen to be shooting at you now is how
- -- you end up with a plant that answers this skirmish and nothing after it.
- local counts = intel.GetEffectiveCounts()
- if not counts or not next(counts) then
- emptyReason = "No enemy intel yet"
- return
- end
- local buildable, owned = GatherFactories()
- if not next(buildable) then
- emptyReason = "Nothing here can build a factory"
- return
- end
- local PairScore = cm.PairScore
- local Quality = cm.Quality -- optional: absent from WG.CounterMatrix version 1
- local cmIsAA, cmDpsAir = cm.isAA, cm.dpsAir
- -- Whether an air factory is viable is judged against everything the enemy owns, static
- -- anti-air included: a Razor shoots your gunships whether or not you meant to attack it.
- local aaThreat, allThreat = 0, 0
- for defID, n in pairs(counts) do
- local t = n * cost[defID]
- if t > 0 then
- allThreat = allThreat + t
- if cmIsAA[defID] or (cmDpsAir[defID] or 0) > 0 then
- aaThreat = aaThreat + t
- end
- end
- end
- local enemyAAShare = (allThreat > 0) and min(aaThreat / allThreat, 0.95) or 0
- -- Build Proposer's exclusions, read through the matrix it publishes so the two panels
- -- cannot disagree about what counts as a constructor. The first test that matches names
- -- the reason, broad before narrow, exactly as there. A field missing from an older
- -- matrix just means that filter never matches.
- local cmRole, cmIsAir, cmIsArmed = cm.role or {}, cm.isAir or {}, cm.isArmed or {}
- local exclusions = {
- {key = 'mobileOnly', label = "buildings",
- test = function(d) return isStatic[d] end},
- {key = 'ignoreEco', label = "economy buildings",
- test = function(d) return isStatic[d] and not cmIsArmed[d] end},
- {key = 'ignoreCommanders', label = "commanders",
- test = function(d) return cmRole[d] == "commander" end},
- {key = 'ignoreCons', label = "constructors",
- test = function(d) return cmRole[d] == "con" and not isStatic[d] end},
- {key = 'ignoreAA', label = "anti-air",
- test = function(d) return cmRole[d] == "aa" end},
- {key = 'ignoreAir', label = "aircraft",
- test = function(d) return cmIsAir[d] end},
- }
- local active = {}
- for i = 1, #exclusions do
- if options[exclusions[i].key].value then
- active[#active + 1] = exclusions[i]
- end
- end
- -- Threat weighting: what fraction of the enemy's committed metal each unit type is.
- local threat, totalThreat = {}, 0
- local droppedLabels = {}
- for defID, n in pairs(counts) do
- local out
- for i = 1, #active do
- if active[i].test(defID) then
- out = active[i].label
- break
- end
- end
- if out then
- droppedLabels[out] = true
- else
- local t = n * cost[defID]
- if isStatic[defID] then
- t = t * STATIC_THREAT_WEIGHT
- end
- if t > 0 then
- threat[defID] = t
- totalThreat = totalThreat + t
- end
- end
- end
- if totalThreat <= 0 then
- -- Name the filters that emptied it: "no enemy intel yet" would be a lie about a
- -- setting the player can move.
- local names = {}
- for label in pairs(droppedLabels) do
- names[#names + 1] = label
- end
- table.sort(names)
- emptyReason = (#names > 0) and ("Only " .. table.concat(names, ", ") .. " seen")
- or "No enemy intel yet"
- return
- end
- -- What one unit may cost before answering with it counts as over-committing. Measured
- -- against the enemy army rather than against our bank, deliberately: a factory is a
- -- decision for the next several minutes, and one that flipped every time the metal bar
- -- filled would be telling you about your storage rather than about the enemy.
- local commitWeight = options.commitWeight.value
- local commitFree = max(totalThreat, COMMIT_REF_MIN) * COMMIT_FREE
- -- No published figure means an older Build Proposer that never sampled the map, and 0
- -- reads as a dry one: a ranking that leaves ships out where they were viable is merely
- -- narrow, one that proposes them on dry land is wrong.
- local dryMap = (cm.waterFraction or 0) < (cm.WATER_THRESHOLD or WATER_THRESHOLD)
- local qualityWeight = options.qualityWeight.value
- local includeOwned = options.showOwned.value
- -- The per-enemy answers are combined with a power mean, and `holeWeight` is its
- -- exponent read backwards: 0 gives the arithmetic mean, 0.5 the geometric mean, 1 the
- -- harmonic one. The exponent is what decides whether a gap can be paid for with a
- -- strength elsewhere -- at p = 1 it can, and the further below that it goes the more
- -- the smallest answer in the set drags the result down towards itself.
- local p = 1 - 2 * options.holeWeight.value
- local geometric = (p > -1e-6 and p < 1e-6)
- local scored = {}
- for facDefID in pairs(buildable) do
- -- A Shipyard cannot be put down on dry land, so on a dry map it is not a choice at
- -- all -- and the per-unit gate below cannot say that for it, because the Shipyard
- -- does build one thing that floats in a puddle. The two gates answer different
- -- questions and the Strider Hub is why both are needed: it stands on land, so it is
- -- never turned away here, but three of the eleven things it builds are ships and
- -- those drop out below. A factory we already own is exempt: whatever this map
- -- samples as, the water it is standing in is evidently real.
- local unplaceable = needsWater[facDefID] and dryMap and not owned[facDefID]
- if (includeOwned or not owned[facDefID]) and not unplaceable then
- -- The roster minus what this map has nowhere to put, filtered once per factory
- -- rather than once per enemy unit type. Nothing else needs excluding: a
- -- constructor or a jammer has no weapon, so PairScore already scores it at
- -- nothing and it can never come out of the max below.
- --
- -- Each survivor carries the multiplier for what it costs to field, worked out
- -- here rather than inside the enemy loop below: it is a fact about the unit and
- -- the size of the fight, and asking it again for every enemy type would be
- -- asking the same question thirty times over.
- local list = roster[facDefID]
- local pool, poolMult, poolN = {}, {}, 0
- for i = 1, #list do
- local d = list[i]
- if not (needsWater[d] and dryMap) then
- poolN = poolN + 1
- pool[poolN] = d
- local m = 1
- if commitWeight > 0 and cost[d] > commitFree then
- m = (commitFree / cost[d]) ^ commitWeight
- end
- poolMult[poolN] = m
- end
- end
- if poolN > 0 then
- local acc = 0
- local answers = {} -- one row per enemy type, for the tooltip
- local worstDef, worstGap, worstAnswer, worstValue = nil, 0, nil, 1
- for eDefID, t in pairs(threat) do
- local w = t / totalThreat
- -- The best this factory could put in front of that enemy type. Tier and
- -- the cost of fielding it are applied here rather than to the finished
- -- score, because both are part of choosing the answer: a factory whose
- -- only correct answer is a weak unit, or one it could never afford to
- -- mass, has answered worse rather than merely scored worse.
- local bestV, bestD = 0, nil
- for i = 1, poolN do
- local d = pool[i]
- local v = PairScore(d, eDefID, enemyAAShare)
- if v > 0 then
- if Quality and qualityWeight > 0 then
- v = v * Quality(d) ^ qualityWeight
- end
- v = v * poolMult[i]
- end
- if v > bestV then
- bestV, bestD = v, d
- end
- end
- local answered = bestD -- nil when nothing in the roster can engage it
- if bestV < MIN_ANSWER then
- bestV = MIN_ANSWER
- end
- acc = acc + w * (geometric and log(bestV) or bestV ^ p)
- answers[#answers + 1] = {
- defID = eDefID, by = answered, value = bestV, contrib = w * bestV,
- }
- -- The hole that costs most: how badly this type is answered, weighted by
- -- how much of the enemy army it is. An unanswerable unit nobody fields
- -- is not what loses the game.
- if bestV < 1 then
- local gap = w * -log(bestV)
- if gap > worstGap then
- worstGap, worstDef = gap, eDefID
- worstAnswer, worstValue = answered, bestV
- end
- end
- end
- local coverage = geometric and exp(acc) or acc ^ (1 / p)
- local score = coverage
- -- Built for every factory and read for three, the same way Build Proposer
- -- gathers its contributors: a dozen sorts of thirty entries once every two
- -- seconds is not worth the bookkeeping of an inline top-three.
- table.sort(answers, function(a, b) return a.contrib > b.contrib end)
- scored[#scored + 1] = {
- defID = facDefID,
- score = score,
- coverage = coverage,
- owned = owned[facDefID] or false,
- answers = answers,
- worstDef = worstDef,
- worstAnswer = worstAnswer,
- worstValue = worstValue,
- }
- end
- end
- end
- if not scored[1] then
- emptyReason = includeOwned and "No factory answers this army"
- or "Every factory here is already built"
- return
- end
- table.sort(scored, function(a, b) return a.score > b.score end)
- -- The incumbent, whether or not it made the top three: it is what the verdict in the
- -- header is measured against.
- for i = 1, #scored do
- if scored[i].owned then
- ownedBest = scored[i]
- break
- end
- end
- local best = scored[1].score
- for i = 1, min(PICK_COUNT, #scored) do
- local pick = scored[i]
- pick.barFill = (best > 0) and (pick.score / best) or 0
- picks[i] = pick
- end
- end
- --------------------------------------------------------------------------------
- -- Chili UI
- --------------------------------------------------------------------------------
- local Chili
- local window_main, holder, headerLabel, emptyLabel
- local rows = {}
- local PAD = 4
- local ICON = 26
- local ROW_H = 34
- local HEADER_H = 18
- local WIN_W = 260
- local TEXT_X = ICON + 8
- local BAR_H = 6
- local FONT_MAIN = {
- size = 13,
- color = {1, 1, 1, 1},
- outline = true,
- outlineWidth = 3,
- outlineWeight = 6,
- outlineColor = {0, 0, 0, 1},
- }
- local FONT_DIM = {
- size = 11,
- color = {0.70, 0.75, 0.85, 1},
- outline = true,
- outlineWidth = 3,
- outlineWeight = 6,
- outlineColor = {0, 0, 0, 1},
- }
- local FONT_HEADER = {
- size = 13,
- color = {1.0, 0.85, 0.40, 1},
- outline = true,
- outlineWidth = 3,
- outlineWeight = 6,
- outlineColor = {0, 0, 0, 1},
- }
- -- Inline font colour escape, the standard Spring form. Components must be 1-255; a zero
- -- byte would terminate the string, which is why Zero-K writes 001 rather than 000.
- local TAG_COLOR = "\255\160\170\190"
- -- Chili's Object:New turns the table you hand it into the instance itself, so a font table
- -- cannot be shared between controls. Every control gets its own copy.
- local function FontCopy(spec)
- local out = {}
- for k, v in pairs(spec) do
- if type(v) == "table" then
- local sub = {}
- for i, x in pairs(v) do
- sub[i] = x
- end
- out[k] = sub
- else
- out[k] = v
- end
- end
- return out
- end
- -- Clicking a row picks the factory's build command up onto the cursor, which is as far as
- -- a panel should go: where a factory is placed decides half of what it is worth, and that
- -- is the player's call rather than this widget's.
- local function RowClicked(self, _, _, mouseButton)
- if mouseButton ~= 1 then
- return
- end
- local pick = picks[self.pickIndex]
- if not pick or amSpectating then
- return
- end
- local index = spGetCmdDescIndex(-pick.defID)
- if not index then
- -- The command only exists while something that can build it is selected, so this is
- -- the ordinary case of clicking with an army selected rather than an error.
- spEcho("Fac Switcher: select a constructor to place the "
- .. unitName[pick.defID] .. ".")
- return
- end
- local alt, ctrl, meta, shift = spGetModKeyState()
- spSetActiveCommand(index, 1, true, false, alt, ctrl, meta, shift)
- end
- local function CreateWindow()
- window_main = Chili.Window:New{
- parent = Chili.Screen0,
- name = "FacSwitcher",
- classname = "main_window_small_very_flat",
- -- Fully transparent, matching the other panels: the rows float over the map.
- color = {0, 0, 0, 0},
- backgroundColor = {0, 0, 0, 0},
- borderColor = {0, 0, 0, 0},
- dockable = true,
- dockableSavePositionOnly = true,
- parentWidgetName = widget:GetInfo().name, -- for gui_chili_docking.lua (minimize)
- right = 0,
- y = 43,
- clientWidth = WIN_W,
- clientHeight = HEADER_H + PICK_COUNT * ROW_H + PAD * 2,
- draggable = false,
- resizable = false,
- tweakDraggable = true,
- tweakResizable = true,
- padding = {6, 6, 6, 6},
- }
- holder = Chili.Control:New{
- parent = window_main,
- x = 0, y = 0, right = 0, bottom = 0,
- padding = {0, 0, 0, 0},
- }
- headerLabel = Chili.Label:New{
- parent = holder,
- x = 2, y = 0,
- caption = "Best factories",
- font = FontCopy(FONT_HEADER),
- }
- emptyLabel = Chili.Label:New{
- parent = holder,
- x = 2, y = HEADER_H + 4,
- caption = "No enemy intel yet",
- font = FontCopy(FONT_DIM),
- }
- local barW = WIN_W - TEXT_X - PAD * 2 - 4
- for i = 1, PICK_COUNT do
- local y = HEADER_H + (i - 1) * ROW_H
- local btn = Chili.Button:New{
- parent = holder,
- x = PAD, y = y,
- width = WIN_W - PAD * 2,
- height = ROW_H - 2,
- caption = "",
- padding = {0, 0, 0, 0},
- backgroundColor = {0, 0, 0, 0},
- borderColor = {0, 0, 0, 0},
- focusColor = {1, 1, 1, 0.10},
- OnClick = {RowClicked},
- }
- btn.pickIndex = i
- rows[i] = {
- ctrl = btn,
- -- Chili's Image inherits Button and swallows the hit, so the icon needs its own
- -- copy of the handler or clicking the picture would do nothing.
- icon = Chili.Image:New{
- parent = btn,
- x = 0, y = 2,
- width = ICON, height = ICON,
- keepAspect = true,
- file = DEFAULT_ICON,
- pickIndex = i,
- OnClick = {RowClicked},
- },
- name = Chili.Label:New{
- parent = btn,
- x = TEXT_X, y = 0,
- caption = "",
- font = FontCopy(FONT_MAIN),
- },
- -- autosize is on by default, and for a right-aligned label it rewrites both x
- -- and width from the text on every SetCaption, so the label would walk left a
- -- little further each refresh. Fixed box instead: DrawInBox does the aligning.
- score = Chili.Label:New{
- parent = btn,
- x = TEXT_X, y = 0,
- width = barW, height = 16,
- autosize = false,
- align = "right",
- caption = "",
- font = FontCopy(FONT_MAIN),
- },
- bar = Chili.Progressbar:New{
- parent = btn,
- x = TEXT_X, y = 17,
- width = barW, height = BAR_H,
- min = 0, max = 1, value = 0,
- caption = "",
- color = {0.45, 0.75, 1.00, 0.9},
- backgroundColor = {0.1, 0.1, 0.1, 0.5},
- },
- }
- btn:Hide()
- end
- end
- -- Show/hide only. The window is kept alive so its docked position, and anything the
- -- tweak-mode layout did to it, survive being toggled off and on again.
- function SetPanelVisible(visible)
- if not window_main then
- return
- end
- if visible then
- window_main:Show()
- dirty = true -- the ranking went stale while hidden; redraw before it is looked at
- else
- window_main:Hide()
- end
- end
- -- Which of the factory's units answer which enemy units, and what it has no answer to. The
- -- score is a single number over a whole roster, so without this the panel would be
- -- asserting a factory choice with nothing to argue from.
- local function RowTooltip(pick)
- local tip = unitName[pick.defID] .. " - " .. floor(cost[pick.defID] + 0.5) .. " metal"
- if pick.owned == "plate" then
- tip = tip .. " (you have its plate, linked to a working factory)"
- elseif pick.owned then
- tip = tip .. " (already built)"
- end
- -- Only the matchups this factory actually wins, heaviest first, and never the one named
- -- as the weakness below. Listing by threat alone put the biggest enemy type on both
- -- lines, which read as the panel contradicting itself.
- local shown = 0
- for i = 1, #pick.answers do
- local a = pick.answers[i]
- if a.by and a.value > 1 and a.defID ~= pick.worstDef then
- if shown == 0 then
- tip = tip .. "\nAnswers:"
- end
- tip = tip .. "\n " .. unitName[a.defID] .. " -> " .. unitName[a.by]
- shown = shown + 1
- if shown >= 3 then
- break
- end
- end
- end
- if pick.worstDef then
- if pick.worstAnswer then
- tip = tip .. string.format("\nWeakest against %s: the best it has is %s (x%.2f)",
- unitName[pick.worstDef], unitName[pick.worstAnswer], pick.worstValue)
- else
- tip = tip .. "\nNothing it builds can touch " .. unitName[pick.worstDef]
- end
- end
- return tip .. "\nClick to place it, with a constructor selected."
- end
- local function RefreshLayout()
- if not (window_main and holder) then
- return
- end
- for i = 1, PICK_COUNT do
- local row = rows[i]
- local pick = picks[i]
- if pick then
- row.icon.file = iconFile[pick.defID]
- row.icon:Invalidate()
- local caption = unitName[pick.defID]
- if pick.owned then
- -- Inline and dimmed, after the name. A second right-aligned label would
- -- have sat on top of the score.
- caption = caption .. TAG_COLOR .. (pick.owned == "plate" and " [plate]" or " [built]")
- end
- row.name:SetCaption(caption)
- row.score:SetCaption(string.format("%.3f", pick.score))
- row.bar:SetValue(pick.barFill)
- row.ctrl.tooltip = RowTooltip(pick)
- row.ctrl:Show()
- else
- row.ctrl:Hide()
- end
- end
- if picks[1] then
- emptyLabel:Hide()
- else
- emptyLabel:SetCaption(emptyReason or "No enemy intel yet")
- emptyLabel:Show()
- end
- -- The header carries the verdict rather than repeating the list: the ranking is only
- -- interesting relative to the plant you already have, and "your factory is still the
- -- right one" is an answer worth stating out loud rather than leaving to be inferred
- -- from which row happens to be on top.
- local caption = "Best factories"
- local top = picks[1]
- if top then
- if top.owned then
- caption = "Factories - stay " .. FacTag(top.defID)
- elseif ownedBest and ownedBest.score > 0 then
- caption = string.format("Factories - switch to %s +%d%%",
- FacTag(top.defID),
- floor((top.score / ownedBest.score - 1) * 100 + 0.5))
- else
- caption = "Factories - open with " .. FacTag(top.defID)
- end
- end
- headerLabel:SetCaption(caption)
- headerLabel.tooltip =
- "Factories ranked by how well the units they build answer every enemy unit "
- .. "Enemy Intel remembers.\nEach enemy type is scored against the best answer the "
- .. "factory could build for it, and those are averaged over the enemy army by "
- .. "metal.\nSettings/HUD Panels/Combat Advisor decides how hard a gap in the "
- .. "roster is punished."
- end
- --------------------------------------------------------------------------------
- -- Callins
- --------------------------------------------------------------------------------
- function M:Initialize()
- Chili = WG.Chili
- if not Chili then
- Spring.Echo("Fac Switcher: WG.Chili unavailable, removing widget.")
- widgetHandler:RemoveWidget(widget)
- return
- end
- myTeamID = spGetMyTeamID()
- amSpectating = select(1, Spring.GetSpectatingState())
- CreateWindow()
- SetPanelVisible(options.showPanel.value)
- dirty = true
- end
- function M:Shutdown()
- if window_main then
- window_main:Dispose()
- window_main = nil
- end
- rows = {}
- end
- function M:PlayerChanged()
- myTeamID = spGetMyTeamID()
- amSpectating = select(1, Spring.GetSpectatingState())
- dirty = true
- end
- function M:Update()
- if not options.showPanel.value then
- -- Scoring exists only to fill this panel and is published to no one, so while it is
- -- hidden there is nothing worth computing. `dirty` is left standing so the army on
- -- the field when it comes back is ranked fresh rather than shown as it was.
- return
- end
- local now = os.clock()
- if not dirty and now < nextRefresh then
- return
- end
- dirty = false
- nextRefresh = now + options.updateRate.value
- Recompute()
- RefreshLayout()
- end
- RegisterModule{
- callins = M,
- prefix = 'fs_',
- optionsPath = options_path,
- options = options,
- optionsOrder = options_order,
- }
- end
- --------------------------------------------------------------------------------
- -- Engagement Predictor
- --
- -- Was gui_engagement_predictor.lua.
- --------------------------------------------------------------------------------
- do
- local M = {} -- this module's callins, collected by RegisterModule below
- local options_path, options_order, options -- were file-scope globals
- --------------------------------------------------------------------------------
- -- Includes / static per-unitDef data
- --
- -- The archetypes, the counter matrix and the scoring functions all live in
- -- gui_build_proposer.lua and arrive through WG.CounterMatrix. What is built here is only
- -- what is needed to draw a row and to price a unit, so the panel still has something to
- -- say when that widget is switched off.
- --------------------------------------------------------------------------------
- local iconTypesPath = LUAUI_DIRNAME .. "Configs/icontypes.lua"
- local icontypes = VFS.FileExists(iconTypesPath) and VFS.Include(iconTypesPath)
- local iconFormat = ".dds"
- do
- -- chilitip_conf.lua returns: ignoreweapon, iconFormat
- local ok, _, fmt = pcall(VFS.Include, LUAUI_DIRNAME .. "Configs/chilitip_conf.lua", nil, VFS.ZIP)
- if ok and fmt then
- iconFormat = fmt
- end
- end
- local spGetSelectedUnits = Spring.GetSelectedUnits
- local spGetUnitDefID = Spring.GetUnitDefID
- local spGetUnitPosition = Spring.GetUnitPosition
- local spGetUnitHealth = Spring.GetUnitHealth
- local spGetUnitTeam = Spring.GetUnitTeam
- local spGetUnitAllyTeam = Spring.GetUnitAllyTeam
- local spGetUnitsInRectangle = Spring.GetUnitsInRectangle
- local spGetMyAllyTeamID = Spring.GetMyAllyTeamID
- local spGetMyTeamID = Spring.GetMyTeamID
- local spGetGaiaTeamID = Spring.GetGaiaTeamID
- local spEcho = Spring.Echo
- local floor, ceil, max, min, abs, huge =
- math.floor, math.ceil, math.max, math.min, math.abs, math.huge
- -- Zero-K loads this helper into the widget environment too. It resolves commander and
- -- Tech-K cost scaling, neither of which the unitDef knows about, so it is worth preferring
- -- wherever there is a live unitID to ask about.
- local spuGetUnitCost = Spring.Utilities and Spring.Utilities.GetUnitCost
- local DEFAULT_ICON = "icons/default.dds"
- -- The key every radar contact that could not be resolved to a real type is filed under.
- -- Negative so it can never collide with a unitDefID, which forces every loop over the two
- -- force tables to decide what to do with it rather than silently indexing a nil.
- local BLIP_KEY = -1
- local cost = {}
- local unitName = {}
- local iconFile = {}
- local isStatic = {}
- local isCon = {} -- build power and no weapon at all, commanders aside
- local isAA = {} -- every weapon that does damage can only aim at aircraft
- local skipDef = {} -- drones and anything flagged dontcount: already priced into a parent
- -- customparams arrive stringified (see gamedata/unitdefs_post.lua), so a flag that is
- -- present but off is the string "false", which is perfectly truthy in Lua.
- local function Flag(cp, key)
- local v = cp and cp[key]
- return v ~= nil and v ~= false and v ~= "false" and v ~= "0" and v ~= 0
- end
- local AIR_CATEGORY = {fixedwing = true, gunship = true}
- for defID = 1, #UnitDefs do
- local ud = UnitDefs[defID]
- local it = ud.iconType or "default"
- local path = (icontypes and icontypes[it] and icontypes[it].bitmap)
- or ("icons/" .. it .. iconFormat)
- -- Zero-K's own square build texture, which the engine renders from the model, rather
- -- than icons/default.dds -- that file is fully transparent, so a missing icon drew a
- -- blank box with a count beside it (submarine, none, the legacy tank/kbot set).
- if not VFS.FileExists(path) then
- path = "#" .. defID
- end
- iconFile[defID] = path
- unitName[defID] = ud.humanName or ud.name or "?"
- cost[defID] = ud.metalCost or 0
- isStatic[defID] = (ud.speed or 0) == 0
- local cp = ud.customParams
- skipDef[defID] = Flag(cp, "dontcount") or Flag(cp, "is_drone")
- -- The same two tests Build Proposer classifies by, repeated here so the filters still
- -- work with that module switched off. A shield counts as a weapon for the constructor
- -- test, which is what keeps Funnelweb -- 40 build power -- out of it; commanders have
- -- build power and guns both, but are caught by flag regardless.
- local weapons = ud.weapons or {}
- local anyWeapon, onlyAir = false, true
- for i = 1, #weapons do
- local wpn = weapons[i]
- local wd = wpn.weaponDef and WeaponDefs[wpn.weaponDef]
- if wd and not wd.isShield then
- local dmg = tonumber(wd.customParams and wd.customParams.stats_damage)
- or (wd.damages and wd.damages[0]) or 0
- if dmg > 0 then
- anyWeapon = true
- local ot = wpn.onlyTargets
- if not ot or not next(ot) then
- onlyAir = false
- else
- for cat in pairs(ot) do
- if not AIR_CATEGORY[cat] then
- onlyAir = false
- end
- end
- end
- end
- end
- end
- isAA[defID] = anyWeapon and onlyAir
- local isComm = Flag(cp, "dynamic_comm") or Flag(cp, "commtype")
- isCon[defID] = (ud.buildSpeed or 0) > 0 and not ud.isFactory and not isComm
- and #weapons == 0
- end
- --------------------------------------------------------------------------------
- -- State (declared before `options` so the option closures can capture them)
- --------------------------------------------------------------------------------
- local NEUTRAL = 1.0
- local result = nil -- the last computed prediction, or nil for the idle panel
- local idleText = "Select units to predict a fight."
- local dirty = true
- local nextRefresh = 0
- local myTeamID = spGetMyTeamID()
- local gaiaTeamID = spGetGaiaTeamID()
- local LogDetail -- forward declaration, defined below
- local SetPanelVisible -- forward declaration, defined below
- local ApplyPalette -- forward declaration: the options below are built before the colours
- local CommitFronts -- forward declaration: FindFronts commits an empty list itself
- -- The rings on the map. `hitAt[unitID]` is the game time one of our side's units was last
- -- hit or last landed a hit; the fronts are clusters of those, rescored once a second.
- --
- -- Two layers. `fronts` (and `result`, for the selection) are what was *scored*; `sel` and
- -- `shown` are what is *drawn*: rings that ease towards the last score every frame and fade
- -- in and out, so nothing on the map jumps when a score lands. The fronts are scored one
- -- cluster per frame off `queue`, collected in `pending`, and committed to `fronts` as a
- -- whole when the queue runs dry.
- local rings = {
- hitAt = {},
- fronts = {},
- next = 0,
- queue = {},
- qi = 1,
- pending = {},
- sel = nil,
- shown = {},
- clock = nil, -- the timer Animate last ran at
- t = 0, -- wall seconds of animation, for the dashes and the pulse
- MEMORY = 8, -- s a hit keeps a unit on its front
- JOIN = 600, -- elmos: a hit unit this close to a front's middle belongs to that front
- MAX = 6, -- fronts drawn at once, the busiest first
- EASE_POS = 0.12, -- s time constant for the centre and radius
- EASE_P = 0.2, -- s time constant for the chance, and so the colour and the arc
- FADE = 0.35, -- s to fade a ring fully in or out
- TREND = 3, -- s back the trend arrow looks
- TREND_MIN = 0.05, -- how far the chance must move in that time to earn an arrow
- FILL_A = 0.30, -- the inside's alpha at full: coloured, but the ground still reads
- HIT_HOLD = 2, -- s after a ringed unit was hit, or hit something, it still counts as fighting
- HIT_FADE = 3, -- s the fighting state then takes to fade
- FLASH_PERIOD = 2, -- s for one colour -> clear -> colour cycle of the fighting flash
- gt = 0, -- game seconds, as of the last Animate
- marks = {}, -- units circled by nearEnemies/nearAll: {uid, enemy, radius} each
- nextMark = 0, -- os.clock of the next marks sweep
- MARK_EVERY = 0.25, -- s between marks sweeps; the circles follow the units every frame
- }
- --------------------------------------------------------------------------------
- -- Options (auto-registered into the settings menu, persisted to ZK_data.lua)
- --------------------------------------------------------------------------------
- options_path = 'Settings/HUD Panels/Engagement Predictor'
- options_order = {'showPanel', 'selRing', 'showFronts', 'plainRings',
- 'fillRange', 'fillFar', 'fillFlash',
- 'nearDist', 'nearEnemies', 'nearAll', 'nearOnly', 'nearReach',
- 'cbPalette',
- 'friendlyRadius', 'enemyRadius', 'updateRate', 'decisiveness',
- 'qualityWeight',
- 'includeStatic', 'includeCons', 'includeAA',
- 'includeAllies', 'useBlips', 'enemiesFromSelectionOnly',
- 'showBreakdown', 'showFactors', 'logDetail'}
- options = {
- showPanel = {
- name = 'Show Engagement Predictor panel',
- desc = 'Hide the on-screen readout without unloading the widget. Nothing here outlives '
- .. 'the panel -- the prediction is recomputed from the live selection every tick '
- .. 'and published to no one -- so while hidden it stops gathering and scoring '
- .. 'entirely, and picks the current fight back up the moment it is shown.',
- type = 'bool',
- value = true,
- OnChange = function(self)
- SetPanelVisible(self.value)
- end,
- },
- selRing = {
- name = 'Ring the selected fight on the map',
- desc = 'Draw a circle round your selected units, coloured by how the fight looks from '
- .. 'red through amber to green. The ring is also a gauge: the bright arc runs '
- .. 'clockwise from the far edge for your chance, the dim rest is theirs. Over it '
- .. 'the chance, an arrow when it has moved 5 points in the last 3 seconds, the '
- .. 'metal on each side, and a word: "Favourable - engage" from 65%, "Even fight" '
- .. 'from 45%, "Unfavourable" from 25%, and "Retreat!" below that, where the ring '
- .. 'also pulses. Works with the panel hidden.',
- type = 'bool',
- value = true,
- },
- showFronts = {
- name = 'Ring every active front',
- desc = 'Also ring every place your side is fighting right now, selected or not, each '
- .. 'with its own predicted chance. A front is a group of your units that were hit, '
- .. 'or hit something, in the last 8 seconds. Scored with the same settings as the '
- .. 'panel, once a second, the busiest six at most. Drawn as slowly turning dashes, '
- .. 'bright for as much of the circle as your chance, so they never read as the '
- .. 'selection. Works with the panel hidden.',
- type = 'bool',
- value = true,
- OnChange = function(self)
- if not self.value then
- rings.fronts, rings.hitAt, rings.shown = {}, {}, {}
- rings.queue, rings.qi, rings.pending = {}, 1, {}
- end
- end,
- },
- fillRange = {
- name = 'Ring fill fade distance',
- desc = 'The inside of each ring is filled in its colour: fully while an enemy is at or '
- .. 'inside the ring, or while the ringed units are hitting or being hit (and for 2 '
- .. 'seconds after), fading as the nearest enemy gets farther away. This is how far '
- .. 'outside the ring the enemy must be for the fill to have faded all the way.',
- type = 'number',
- min = 200, max = 2000, step = 50, value = 800,
- },
- fillFar = {
- name = 'Ring fill with the enemy far off (%)',
- desc = 'How much of the fill is left once the nearest enemy is past the fade distance and '
- .. 'nothing is shooting. 0 leaves such a ring hollow; 100 keeps every ring filled.',
- type = 'number',
- min = 0, max = 100, step = 5, value = 0,
- },
- fillFlash = {
- name = 'Flash the ring fill while fighting',
- desc = 'While the ringed units are hitting or being hit, the fill slowly flashes from its '
- .. 'colour to clear and back, once every 2 seconds, so a fight that has started '
- .. 'stands out from one still closing. It fades out over a few seconds once the '
- .. 'shooting stops.',
- type = 'bool',
- value = true,
- },
- nearDist = {
- name = 'Near distance',
- desc = 'The distance, in elmos out from a ring\'s edge, that the four options below use: '
- .. 'which units get a circle, how close an enemy must be before a ring shows, and '
- .. 'the radius of the faint outer circle.',
- type = 'number',
- min = 100, max = 3000, step = 50, value = 600,
- },
- nearEnemies = {
- name = 'Circle enemies near a ring',
- desc = 'A small circle under every enemy unit inside a ring or within the near distance '
- .. 'of it, in the losing colour, so you can see exactly which enemies are close.',
- type = 'bool',
- value = false,
- },
- nearAll = {
- name = 'Circle units of both sides near a ring',
- desc = 'A small circle under every unit, yours and your allies\' as well as the enemy\'s, '
- .. 'inside a ring or within the near distance of it: yours in the winning colour, '
- .. 'theirs in the losing one. Shows who is actually in the fight.',
- type = 'bool',
- value = false,
- },
- nearOnly = {
- name = 'Only ring a fight once an enemy is near',
- desc = 'Keep a ring hidden until the nearest enemy is within the near distance of it, '
- .. 'and fade it out again when they pull back, so fights still far apart show '
- .. 'nothing. Covers the selection ring and the fronts.',
- type = 'bool',
- value = false,
- },
- nearReach = {
- name = 'Show the near distance round each ring',
- desc = 'A faint dashed circle round each ring at the near distance, a preview of how '
- .. 'far out the options above are looking.',
- type = 'bool',
- value = false,
- },
- plainRings = {
- name = 'Plain dashed rings',
- desc = 'Draw every ring as a dashed line and nothing else: no coloured fill, no glow and '
- .. 'no dark outline under the line, and the numbers get a black outline instead of a '
- .. 'box. The dashes still '
- .. 'light up for as much of the circle as your chance, and still pulse when losing; '
- .. 'the selection\'s stand still and the fronts\' turn. The fill settings below do '
- .. 'nothing while this is on.',
- type = 'bool',
- value = false,
- },
- cbPalette = {
- name = 'Colour-blind palette (orange / grey / blue)',
- desc = 'Show a fight going badly as orange, an even one as grey and a good one as blue, '
- .. 'instead of red, amber and green. Covers the rings on the map and the panel.',
- type = 'bool',
- value = false,
- OnChange = function(self)
- ApplyPalette(self.value)
- end,
- },
- friendlyRadius = {
- name = 'Friendly gather radius',
- desc = 'Allied units this far from any selected unit join the fight on your side. '
- .. '0 counts only what you actually have selected.',
- type = 'number',
- min = 0, max = 3000, step = 50, value = 1500,
- OnChange = function() dirty = true end,
- },
- enemyRadius = {
- name = 'Enemy gather radius',
- desc = 'Enemies this far from any unit of that friendly force are counted as being in '
- .. 'the fight. Roughly the distance the fight can pull reinforcements in from.',
- type = 'number',
- min = 100, max = 3000, step = 50, value = 2000,
- OnChange = function() dirty = true end,
- },
- updateRate = {
- name = 'Prediction update interval (s)',
- desc = 'How often the prediction is recomputed. Changing the selection always '
- .. 'recomputes immediately, whatever this says.',
- type = 'number',
- min = 0.25, max = 5, step = 0.25, value = 0.25,
- },
- decisiveness = {
- name = 'Decisiveness',
- desc = 'How hard an edge in effective strength is pushed towards certainty. At 1 a '
- .. 'fight is won in proportion to strength; at 2 a 1.5x edge reads as about 70%; '
- .. 'higher swings the number harder for the same two armies. Taste, not physics.',
- type = 'number',
- min = 0.5, max = 5, step = 0.1, value = 2,
- OnChange = function() dirty = true end,
- },
- qualityWeight = {
- name = 'Weight: unit quality',
- desc = 'How much a unit simply being good counts, on top of the matchups. The counter '
- .. 'matrix will happily rate two units the same because they answer the enemy the '
- .. 'same way, and the tier list published alongside it is the opinion that says '
- .. 'which of them you would rather have brought. 0 predicts the fight on matchups '
- .. 'and metal alone; 1 lets a tier move a side by roughly a sixth of a matchup '
- .. 'step. Unrated units, and any fight scored while Build Proposer is off, are '
- .. 'unaffected.',
- type = 'number',
- min = 0, max = 3, step = 0.1, value = 0,
- OnChange = function() dirty = true end,
- },
- includeStatic = {
- name = 'Count buildings',
- desc = 'Include turrets and structures on both sides. Turn off to compare the two '
- .. 'armies alone, as if the fight happened in open ground.',
- type = 'bool',
- value = false,
- OnChange = function() dirty = true end,
- },
- includeCons = {
- name = 'Count constructors',
- desc = 'Include unarmed builders on both sides -- constructors, Athena, caretakers. '
- .. 'Commanders and Funnelweb are not constructors here and always count. Turn off '
- .. 'so a con escort padding the metal total does not read as fighting strength.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- includeAA = {
- name = 'Count anti-air',
- desc = 'Include units that can only shoot aircraft, on both sides: mobile AA, AA '
- .. 'turrets, Raptor and Trident. Swift has a gun that reaches the ground, so it '
- .. 'still counts. Turn off to score a ground fight without AA inflating a side '
- .. 'it cannot help -- but it also hides the AA an air strike would fly into.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- includeAllies = {
- name = 'Count allied units',
- desc = 'Pull in your team-mates units as well as your own. Off restricts the friendly '
- .. 'force to units you personally own.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- useBlips = {
- name = 'Estimate radar contacts',
- desc = 'Give each unidentified radar contact a probable unit type, drawn from the '
- .. 'enemy army the Enemy Intel widget remembers minus what is already identified. '
- .. 'Off ignores contacts entirely, which flatters your odds.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- enemiesFromSelectionOnly = {
- name = 'Measure enemies from selection',
- desc = 'On, the enemy radius is measured only from the units you actually selected. '
- .. 'Off, it is measured from every unit of the gathered friendly force, so a '
- .. 'supporting group brings its own frontage with it.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- showBreakdown = {
- name = 'Show force breakdown',
- desc = 'List both sides by archetype, with counts and metal, so the number can be '
- .. 'read rather than just believed.',
- type = 'bool',
- value = false,
- OnChange = function() dirty = true end,
- },
- showFactors = {
- name = 'Show key factors',
- desc = 'Name the two or three matchups doing the most to swing the result, plus '
- .. 'anything on the field you have no weapon for at all.',
- type = 'bool',
- value = false,
- OnChange = function() dirty = true end,
- },
- logDetail = {
- name = 'Log this fight to infolog',
- desc = 'Echo both forces, their archetypes and the scoring to infolog.txt. For '
- .. 'checking that a surprising number came from where you think it did.',
- type = 'button',
- OnChange = function() if LogDetail then LogDetail() end end,
- },
- }
- --------------------------------------------------------------------------------
- -- Spatial hash
- --
- -- The naive gather is one engine query per selected unit, which stops being free the
- -- moment a real army is selected. Instead: one rectangle query over the bounding box, then
- -- an exact distance test against a hash of the source positions. Cell size is the radius,
- -- so a candidate only ever has to look at the nine cells around it.
- --------------------------------------------------------------------------------
- local GRID_STRIDE = 100003 -- prime, so cx*stride+cz collides only on absurd coordinates
- local function BuildGrid(px, pz, n, cell)
- local g = {}
- for i = 1, n do
- local key = floor(px[i] / cell) * GRID_STRIDE + floor(pz[i] / cell)
- local bucket = g[key]
- if bucket then
- bucket[#bucket + 1] = i
- else
- g[key] = {i}
- end
- end
- return g
- end
- local function NearAny(g, px, pz, cell, x, z, r2)
- local cx, cz = floor(x / cell), floor(z / cell)
- for dx = -1, 1 do
- local base = (cx + dx) * GRID_STRIDE
- for dz = -1, 1 do
- local bucket = g[base + cz + dz]
- if bucket then
- for i = 1, #bucket do
- local j = bucket[i]
- local ex, ez = px[j] - x, pz[j] - z
- if ex * ex + ez * ez <= r2 then
- return true
- end
- end
- end
- end
- end
- return false
- end
- --------------------------------------------------------------------------------
- -- Gathering the two forces
- --------------------------------------------------------------------------------
- local function Keep(defID)
- if defID == BLIP_KEY then
- return true
- end
- if skipDef[defID] then
- return false
- end
- if isStatic[defID] and not options.includeStatic.value then
- return false
- end
- if isCon[defID] and not options.includeCons.value then
- return false
- end
- if isAA[defID] and not options.includeAA.value then
- return false
- end
- return true
- end
- -- Metal, discounted by how much of the unit is left. A Minotaur down to a third of its
- -- health is not a third of a Minotaur in a fight, but it is a great deal less than one,
- -- and counting it whole is the error that makes a losing push look winnable.
- local function UnitValue(unitID, defID)
- local base = (spuGetUnitCost and spuGetUnitCost(unitID, defID)) or cost[defID] or 0
- local hp, maxHp = spGetUnitHealth(unitID)
- if hp and maxHp and maxHp > 0 then
- local frac = hp / maxHp
- if frac < 0.05 then
- frac = 0.05
- elseif frac > 1 then
- frac = 1
- end
- return base * frac
- end
- return base
- end
- -- Every enemy type we can currently put a name to, anywhere on the map. Subtracting this
- -- from what Enemy Intel remembers leaves the pool the radar contacts must be drawn from.
- local function IdentifiedEnemiesEverywhere(refAlly)
- local out = {}
- local all = spGetUnitsInRectangle(0, 0, Game.mapSizeX, Game.mapSizeZ)
- for i = 1, #all do
- local uid = all[i]
- local did = spGetUnitDefID(uid)
- if did and spGetUnitAllyTeam(uid) ~= refAlly and spGetUnitTeam(uid) ~= gaiaTeamID then
- out[did] = (out[did] or 0) + 1
- end
- end
- return out
- end
- -- unitDefID -> probability that any given radar contact is of that type, or nil when there
- -- is nothing to go on.
- local function BuildBlipProfile(refAlly)
- local intel = WG.EnemyIntel
- if not (intel and intel.GetEffectiveCounts) then
- return nil
- end
- local believed = intel.GetEffectiveCounts()
- if not believed then
- return nil
- end
- local ident = IdentifiedEnemiesEverywhere(refAlly)
- local profile, total = {}, 0
- for did, n in pairs(believed) do
- local residual = n - (ident[did] or 0)
- if residual > 0 and Keep(did) then
- profile[did] = residual
- total = total + residual
- end
- end
- if total <= 0 then
- return nil
- end
- for did, n in pairs(profile) do
- profile[did] = n / total
- end
- return profile
- end
- -- Returns two aggregates keyed by unitDefID rather than two unit lists. Collapsing to
- -- distinct types here is what keeps the scoring loop below at a few dozen squared instead
- -- of a few hundred squared.
- -- `seed` stands in for the selection: the front overlay gathers each front from the units
- -- being hit there. With no seed it is the selection, exactly as before.
- local function Gather(seed)
- local sel = seed or spGetSelectedUnits()
- if not sel or #sel == 0 then
- return nil, "Select units to predict a fight."
- end
- local refAlly = spGetUnitAllyTeam(sel[1]) or spGetMyAllyTeamID()
- local ownOnly = not options.includeAllies.value
- -- Selection positions, and the bounding box the friendly query needs.
- local px, pz, n = {}, {}, 0
- local minX, minZ, maxX, maxZ = huge, huge, -huge, -huge
- local force, fn = {}, 0
- local inForce = {}
- for i = 1, #sel do
- local uid = sel[i]
- local x, _, z = spGetUnitPosition(uid)
- if x then
- n = n + 1
- px[n], pz[n] = x, z
- if x < minX then minX = x end
- if x > maxX then maxX = x end
- if z < minZ then minZ = z end
- if z > maxZ then maxZ = z end
- fn = fn + 1
- force[fn] = uid
- inForce[uid] = true
- end
- end
- if n == 0 then
- return nil, "Selection has no position on the map."
- end
- -- Where the selection stands, for the ring drawn round it on the map: the middle of the
- -- selected units and the distance to the farthest of them, plus a margin so the ring
- -- clears the models rather than cutting through them. Worked out here, ahead of the
- -- enemy pass, because that pass measures how far the nearest enemy is from this ring.
- local cx, cz = 0, 0
- for i = 1, n do
- cx, cz = cx + px[i], cz + pz[i]
- end
- cx, cz = cx / n, cz / n
- local r2max = 0
- for i = 1, n do
- local dx, dz = px[i] - cx, pz[i] - cz
- local d2 = dx * dx + dz * dz
- if d2 > r2max then
- r2max = d2
- end
- end
- local ringR = r2max ^ 0.5 + 40
- -- The last time any ringed unit was hit or landed a hit (UnitDamaged keeps the times).
- -- The ringed units only, not the allies gathered below: a hit on one of those a screen
- -- away is not this group fighting.
- local lastHit = -huge
- for i = 1, n do
- local t = rings.hitAt[force[i]]
- if t and t > lastHit then
- lastHit = t
- end
- end
- -- Friendly force: the selection, plus whatever stands close enough to join in.
- local fx, fz = {}, {}
- for i = 1, n do
- fx[i], fz[i] = px[i], pz[i]
- end
- local fMinX, fMinZ, fMaxX, fMaxZ = minX, minZ, maxX, maxZ
- local fR = options.friendlyRadius.value
- if fR > 0 then
- local cell = max(fR, 1)
- local grid = BuildGrid(px, pz, n, cell)
- local r2 = fR * fR
- local cand = spGetUnitsInRectangle(minX - fR, minZ - fR, maxX + fR, maxZ + fR)
- for i = 1, #cand do
- local uid = cand[i]
- if not inForce[uid] then
- local ok = (spGetUnitAllyTeam(uid) == refAlly)
- if ok and ownOnly then
- ok = (spGetUnitTeam(uid) == myTeamID)
- end
- if ok then
- local x, _, z = spGetUnitPosition(uid)
- if x and NearAny(grid, px, pz, cell, x, z, r2) then
- inForce[uid] = true
- fn = fn + 1
- force[fn] = uid
- fx[fn], fz[fn] = x, z
- if x < fMinX then fMinX = x end
- if x > fMaxX then fMaxX = x end
- if z < fMinZ then fMinZ = z end
- if z > fMaxZ then fMaxZ = z end
- end
- end
- end
- end
- end
- local fByDef, fCount, Vf, fUnits = {}, {}, 0, 0
- for i = 1, fn do
- local uid = force[i]
- local did = spGetUnitDefID(uid)
- if did and Keep(did) then
- local v = UnitValue(uid, did)
- fByDef[did] = (fByDef[did] or 0) + v
- fCount[did] = (fCount[did] or 0) + 1
- Vf = Vf + v
- fUnits = fUnits + 1
- end
- end
- if fUnits == 0 or Vf <= 0 then
- return nil, "Nothing in the selection counts as a force."
- end
- -- Enemy force, measured from the whole friendly force unless told otherwise.
- local sx, sz, sn, bMinX, bMinZ, bMaxX, bMaxZ
- if options.enemiesFromSelectionOnly.value then
- sx, sz, sn = px, pz, n
- bMinX, bMinZ, bMaxX, bMaxZ = minX, minZ, maxX, maxZ
- else
- sx, sz, sn = fx, fz, fn
- bMinX, bMinZ, bMaxX, bMaxZ = fMinX, fMinZ, fMaxX, fMaxZ
- end
- local eR = options.enemyRadius.value
- local cell = max(eR, 1)
- local grid = BuildGrid(sx, sz, sn, cell)
- local r2 = eR * eR
- local eByDef, eCount, Ve, eUnits = {}, {}, 0, 0
- local blips = 0
- local gap2 = huge -- squared distance from the ring's middle to the nearest enemy counted
- local cand = spGetUnitsInRectangle(bMinX - eR, bMinZ - eR, bMaxX + eR, bMaxZ + eR)
- for i = 1, #cand do
- local uid = cand[i]
- if not inForce[uid] and spGetUnitAllyTeam(uid) ~= refAlly
- and spGetUnitTeam(uid) ~= gaiaTeamID then
- local x, _, z = spGetUnitPosition(uid)
- if x and NearAny(grid, sx, sz, cell, x, z, r2) then
- local ddx, ddz = x - cx, z - cz
- local d2 = ddx * ddx + ddz * ddz
- if d2 < gap2 then
- gap2 = d2
- end
- local did = spGetUnitDefID(uid)
- if did == nil then
- -- On radar but not in sight: allegiance and position known, type not.
- blips = blips + 1
- elseif Keep(did) then
- local v = UnitValue(uid, did)
- eByDef[did] = (eByDef[did] or 0) + v
- eCount[did] = (eCount[did] or 0) + 1
- Ve = Ve + v
- eUnits = eUnits + 1
- end
- end
- end
- end
- -- Radar contacts, spread across the types they could plausibly be. Fractional counts
- -- are fine: nothing downstream cares whether a weight came from whole units.
- local blipsResolved = false
- if blips > 0 and options.useBlips.value then
- local profile = BuildBlipProfile(refAlly)
- if profile then
- blipsResolved = true
- for did, p in pairs(profile) do
- local v = blips * p * (cost[did] or 0)
- eByDef[did] = (eByDef[did] or 0) + v
- eCount[did] = (eCount[did] or 0) + blips * p
- Ve = Ve + v
- end
- else
- -- Nothing remembered to draw from. A contact is still a unit, so price it as an
- -- average of what we can see and let it score every matchup as even.
- local mean = (eUnits > 0) and (Ve / eUnits) or 200
- local v = blips * mean
- eByDef[BLIP_KEY] = (eByDef[BLIP_KEY] or 0) + v
- eCount[BLIP_KEY] = (eCount[BLIP_KEY] or 0) + blips
- Ve = Ve + v
- end
- end
- if Ve <= 0 then
- return nil, "No enemies within range of this force."
- end
- return {
- fByDef = fByDef, fCount = fCount, Vf = Vf, fUnits = fUnits,
- eByDef = eByDef, eCount = eCount, Ve = Ve, eUnits = eUnits,
- blips = blips, blipsResolved = blipsResolved,
- cx = cx, cz = cz, radius = ringR,
- -- How far outside the ring the nearest enemy stands; 0 once one is at or inside it.
- gap = max(0, gap2 ^ 0.5 - ringR),
- lastHit = lastHit,
- }
- end
- --------------------------------------------------------------------------------
- -- Scoring
- --------------------------------------------------------------------------------
- local function RoleOf(cm, defID)
- if defID == BLIP_KEY then
- return "unknown"
- end
- -- ColumnArch rather than the raw archetype: a building is a position rather than a
- -- unit, and the matrix has columns for exactly that. It reads better in the breakdown
- -- too, where "static defence" says more than "riot" does about a Stinger.
- return (cm.ColumnArch and cm.ColumnArch(defID)) or "support"
- end
- local function Plural(cm, role)
- if role == "unknown" then
- return "unidentified units"
- end
- return (cm and cm.ROLE_PLURAL and cm.ROLE_PLURAL[role]) or (role .. "s")
- end
- -- Four of the matrix's role names are mass nouns -- "artillery", "anti-air", "static
- -- defence", "economy" -- so as the subject of a factor line they want a singular verb.
- -- Without this the panel says "Their static defence dominate your raiders".
- local MASS_NOUN_ROLE = {arty = true, aa = true, defense = true, eco = true}
- local function Verb(role, stem)
- return MASS_NOUN_ROLE[role] and (stem .. "s") or stem
- end
- local function RollUp(cm, byDef, counts)
- local acc = {}
- for did, v in pairs(byDef) do
- local role = cm and RoleOf(cm, did) or "all"
- local a = acc[role]
- if not a then
- a = {role = role, value = 0, count = 0, topDef = did, topVal = -1}
- acc[role] = a
- end
- a.value = a.value + v
- a.count = a.count + (counts[did] or 0)
- if v > a.topVal then
- a.topVal, a.topDef = v, did
- end
- end
- local list = {}
- for _, a in pairs(acc) do
- list[#list + 1] = a
- end
- table.sort(list, function(x, y) return x.value > y.value end)
- return list
- end
- -- One gathered fight in, one prediction out. Split from Recompute so the front overlay can
- -- score fights nobody has selected.
- local function Score(g)
- local cm = WG.CounterMatrix
- local degraded = (cm == nil)
- local fByDef, eByDef, Vf, Ve = g.fByDef, g.eByDef, g.Vf, g.Ve
- local Pf, Pe = Vf, Ve
- local blindF, blindE = 0, 0
- local qAvgF, qAvgE = 1, 1 -- value-weighted tier multiplier per side, for the log
- local netPair = {}
- if not degraded then
- local PairScore, isAA = cm.PairScore, cm.isAA or {}
- -- Each side's AA presence as a share of its value. PairScore uses it to price air
- -- against the AA already standing on the field, rather than only against whatever
- -- single unit it happens to be scored against at that moment.
- local fAA, eAA = 0, 0
- for did, v in pairs(fByDef) do
- if did ~= BLIP_KEY and isAA[did] then
- fAA = fAA + v
- end
- end
- for did, v in pairs(eByDef) do
- if did ~= BLIP_KEY and isAA[did] then
- eAA = eAA + v
- end
- end
- local fAAShare = (Vf > 0) and (fAA / Vf) or 0
- local eAAShare = (Ve > 0) and (eAA / Ve) or 0
- local hitByF, hitByE = {}, {}
- local roleF, roleE = {}, {}
- for did in pairs(fByDef) do
- roleF[did] = RoleOf(cm, did)
- end
- for did in pairs(eByDef) do
- roleE[did] = RoleOf(cm, did)
- end
- -- Tier, as a per-unit multiplier on the value each side brings. It sits outside the
- -- pair loops on purpose: the matrix prices a matchup, while this prices the unit,
- -- and a good unit is worth having whichever end of the field it is on. Absent from
- -- an older Build Proposer, in which case every unit is unrated and nothing moves.
- local qw = options.qualityWeight.value
- local Quality = cm.Quality
- local qF, qE = {}, {}
- if Quality and qw > 0 then
- for did in pairs(fByDef) do
- qF[did] = (did ~= BLIP_KEY) and (Quality(did) ^ qw) or 1
- end
- for did in pairs(eByDef) do
- qE[did] = (did ~= BLIP_KEY) and (Quality(did) ^ qw) or 1
- end
- local sf, se = 0, 0
- for did, v in pairs(fByDef) do
- sf = sf + v * (qF[did] or 1)
- end
- for did, v in pairs(eByDef) do
- se = se + v * (qE[did] or 1)
- end
- qAvgF = (Vf > 0) and (sf / Vf) or 1
- qAvgE = (Ve > 0) and (se / Ve) or 1
- end
- Pf = 0
- for d, vd in pairs(fByDef) do
- local acc = 0
- for e, ve in pairs(eByDef) do
- local s = NEUTRAL
- if d ~= BLIP_KEY and e ~= BLIP_KEY then
- s = PairScore(d, e, eAAShare)
- end
- local share = ve / Ve
- acc = acc + share * s
- if s > 0 then
- hitByF[e] = true
- end
- local key = roleF[d] .. "/" .. roleE[e]
- netPair[key] = (netPair[key] or 0) + vd * share * (s - NEUTRAL)
- end
- Pf = Pf + vd * acc * (qF[d] or 1)
- end
- Pe = 0
- for e, ve in pairs(eByDef) do
- local acc = 0
- for d, vd in pairs(fByDef) do
- local s = NEUTRAL
- if d ~= BLIP_KEY and e ~= BLIP_KEY then
- s = PairScore(e, d, fAAShare)
- end
- local share = vd / Vf
- acc = acc + share * s
- if s > 0 then
- hitByE[d] = true
- end
- local key = roleF[d] .. "/" .. roleE[e]
- netPair[key] = (netPair[key] or 0) - ve * share * (s - NEUTRAL)
- end
- Pe = Pe + ve * acc * (qE[e] or 1)
- end
- -- Value on the field this side has no weapon for at all. It already scores zero
- -- inside the sums above, but "I cannot ever kill that" is worse than averaging zero
- -- against it: whatever else happens, that part of the enemy force walks away intact
- -- and comes back. This is the term that makes gunships over an army with no AA read
- -- as hopeless rather than merely bad.
- for e, ve in pairs(eByDef) do
- if not hitByF[e] then
- blindF = blindF + ve
- end
- end
- for d, vd in pairs(fByDef) do
- if not hitByE[d] then
- blindE = blindE + vd
- end
- end
- blindF = (Ve > 0) and (blindF / Ve) or 0
- blindE = (Vf > 0) and (blindE / Vf) or 0
- Pf = Pf * (1 - blindF)
- Pe = Pe * (1 - blindE)
- end
- -- Effective strength to a probability. Written as 1/(1+(Pe/Pf)^k) rather than
- -- Pf^k/(Pf^k+Pe^k) so a large army and a high exponent cannot overflow on the way.
- local k = options.decisiveness.value
- local p
- if Pf <= 0 and Pe <= 0 then
- p = 0.5
- elseif Pf <= 0 then
- p = 0
- elseif Pe <= 0 then
- p = 1
- else
- p = 1 / (1 + (Pe / Pf) ^ k)
- end
- -- Never claim certainty. Micro, terrain and reinforcements are all outside this model.
- if p < 0.01 then
- p = 0.01
- elseif p > 0.99 then
- p = 0.99
- end
- -- Key factors, from a single net figure per role pair so the same matchup cannot be
- -- reported twice from both directions at once.
- local factors = {}
- if blindF > 0.005 then
- factors[#factors + 1] = {
- sign = -1,
- text = ("You cannot hit %d%% of their force"):format(floor(blindF * 100 + 0.5)),
- }
- end
- if blindE > 0.005 then
- factors[#factors + 1] = {
- sign = 1,
- text = ("They cannot hit %d%% of your force"):format(floor(blindE * 100 + 0.5)),
- }
- end
- if not degraded then
- local ranked = {}
- local scale = max(Vf + Ve, 1)
- for key, net in pairs(netPair) do
- local mag = abs(net) / scale
- if mag > 0.01 then
- ranked[#ranked + 1] = {key = key, net = net, mag = mag}
- end
- end
- table.sort(ranked, function(a, b) return a.mag > b.mag end)
- for i = 1, min(3, #ranked) do
- if #factors >= 4 then
- break
- end
- local rec = ranked[i]
- local rf, re = rec.key:match("^(.-)/(.*)$")
- local stem = (rec.mag > 0.12) and "dominate" or "beat"
- local text
- if rec.net > 0 then
- text = ("Your %s %s their %s")
- :format(Plural(cm, rf), Verb(rf, stem), Plural(cm, re))
- else
- text = ("Their %s %s your %s")
- :format(Plural(cm, re), Verb(re, stem), Plural(cm, rf))
- end
- factors[#factors + 1] = {sign = (rec.net > 0) and 1 or -1, text = text}
- end
- end
- local notes = {}
- if degraded then
- notes[#notes + 1] = "Build Proposer off - comparing metal only"
- end
- if g.blips > 0 then
- local how
- if not options.useBlips.value then
- how = "ignored"
- elseif g.blipsResolved then
- how = "estimated from Enemy Intel"
- else
- how = "estimated at average cost"
- end
- notes[#notes + 1] = ("%d of %d enemies unidentified (%s)")
- :format(g.blips, g.blips + g.eUnits, how)
- end
- return {
- p = p, Pf = Pf, Pe = Pe, Vf = Vf, Ve = Ve,
- fUnits = g.fUnits, eUnits = g.eUnits + g.blips,
- degraded = degraded,
- qAvgF = qAvgF, qAvgE = qAvgE,
- rolesF = RollUp(cm, fByDef, g.fCount),
- rolesE = RollUp(cm, eByDef, g.eCount),
- factors = factors,
- notes = notes,
- cx = g.cx, cz = g.cz, radius = g.radius, gap = g.gap, lastHit = g.lastHit,
- }
- end
- local function Recompute()
- local g, why = Gather()
- if not g then
- result = nil
- idleText = why or "No prediction."
- return
- end
- result = Score(g)
- end
- LogDetail = function()
- Recompute()
- local r = result
- if not r then
- spEcho("Engagement Predictor: " .. idleText)
- return
- end
- spEcho(("Engagement Predictor: win %d%% power %d vs %d metal %d vs %d%s")
- :format(floor(r.p * 100 + 0.5), floor(r.Pf), floor(r.Pe), floor(r.Vf), floor(r.Ve),
- r.degraded and " [no counter matrix]" or ""))
- -- Power is metal times matchups times tier, and the tier part is the one that moves
- -- without anything on screen saying so, so name it here.
- if not r.degraded and (abs(r.qAvgF - 1) > 0.005 or abs(r.qAvgE - 1) > 0.005) then
- spEcho((" tier multiplier us %.2fx them %.2fx"):format(r.qAvgF, r.qAvgE))
- end
- local function Side(label, list)
- for i = 1, #list do
- local a = list[i]
- spEcho((" %s %-14s x%-7.1f %d m"):format(label, a.role, a.count, floor(a.value)))
- end
- end
- Side("us ", r.rolesF)
- Side("them", r.rolesE)
- for i = 1, #r.factors do
- spEcho((" %s %s"):format(r.factors[i].sign > 0 and "+" or "-", r.factors[i].text))
- end
- for i = 1, #r.notes do
- spEcho(" . " .. r.notes[i])
- end
- end
- --------------------------------------------------------------------------------
- -- Chili UI
- --------------------------------------------------------------------------------
- local Chili
- local window_main, scrollPanel, holder
- local lblWin, barWin, lblTotals
- local rowPool = {}
- local PAD_L, PAD_T, PAD_R, PAD_B = 8, 6, 8, 8
- local ICON = 18
- local ROW_H_ICON = 22
- local ROW_H_TEXT = 15
- local GAP = 6
- local WIN_H = 32 -- the big percentage
- local BAR_W = 180
- local BAR_H = 10
- local TOTALS_H = 16
- local ICON_GAP = 6
- local MIN_WIN_W = 200
- local MAX_WIN_W = 420
- local MIN_WIN_H = 70
- local WIN_VPAD = 12
- local WIN_HPAD = 12
- local SCROLLBAR_W = 14
- local MAX_WIN_FRAC = 0.7
- local COL_GOOD = {0.35, 0.85, 0.40, 1}
- local COL_BAD = {0.90, 0.30, 0.28, 1}
- local COL_EVEN = {0.95, 0.82, 0.30, 1}
- local COL_HEAD = {0.72, 0.78, 0.90, 1}
- local COL_TEXT = {0.92, 0.92, 0.92, 1}
- local COL_NOTE = {0.62, 0.62, 0.62, 1}
- -- The two palettes the verdict colours come from. ApplyPalette copies one into COL_GOOD,
- -- COL_EVEN and COL_BAD *in place*, so every holder of those tables follows the switch.
- local PALETTES = {
- [false] = {bad = {0.90, 0.30, 0.28}, even = {0.95, 0.82, 0.30}, good = {0.35, 0.85, 0.40}},
- [true] = {bad = {0.95, 0.55, 0.15}, even = {0.72, 0.72, 0.72}, good = {0.30, 0.60, 1.00}},
- }
- ApplyPalette = function(colourBlind)
- local pal = PALETTES[colourBlind and true or false]
- for i = 1, 3 do
- COL_BAD[i], COL_EVEN[i], COL_GOOD[i] = pal.bad[i], pal.even[i], pal.good[i]
- end
- dirty = true -- the panel's colours were taken at its last rebuild
- end
- -- Red through amber to green, so the colour carries the result as fast as the number does.
- -- Three numbers rather than a table: the rings call this every frame.
- local function WinRGB(p)
- local a, b, t
- if p < 0.5 then
- a, b, t = COL_BAD, COL_EVEN, p * 2
- else
- a, b, t = COL_EVEN, COL_GOOD, (p - 0.5) * 2
- end
- return a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t
- end
- -- The panel keeps the table form: a Chili font wants its colour as one.
- local function WinColor(p)
- local r, g, b = WinRGB(p)
- return {r, g, b, 1}
- end
- -- Chili exposes each control's font; fall back to a rough estimate if it does not.
- local function TextWidth(ctrl, text)
- local font = ctrl and ctrl.font
- if font and font.GetTextWidth then
- local ok, w = pcall(font.GetTextWidth, font, text)
- if ok and w then
- return ceil(w)
- end
- end
- return ceil(#text * 7)
- end
- -- One pooled row shape covers every line the panel draws: an optional tactical icon and a
- -- caption. Rows are recycled rather than rebuilt, because a Chili control is expensive to
- -- create and this panel re-lays itself out twice a second.
- local function EnsureRow(i)
- local row = rowPool[i]
- if row then
- return row
- end
- local ctrl = Chili.Control:New{
- parent = holder,
- x = 0, y = 0,
- width = MAX_WIN_W, height = ROW_H_TEXT,
- padding = {0, 0, 0, 0},
- }
- local img = Chili.Image:New{
- parent = ctrl,
- x = 0, y = 2,
- width = ICON, height = ICON,
- keepAspect = true,
- file = DEFAULT_ICON,
- }
- local lbl = Chili.Label:New{
- parent = ctrl,
- x = 0, y = 1,
- font = {
- size = 12,
- color = COL_TEXT,
- outline = true,
- outlineWidth = 3,
- outlineWeight = 6,
- outlineColor = {0, 0, 0, 1},
- },
- caption = "",
- }
- row = {ctrl = ctrl, img = img, lbl = lbl}
- rowPool[i] = row
- return row
- end
- local function Rebuild()
- if not (window_main and holder) then
- return
- end
- local r = result
- local y = PAD_T
- local used = 0
- local widest = 0
- local function Line(text, color, icon, indent)
- used = used + 1
- local row = EnsureRow(used)
- local h = icon and ROW_H_ICON or ROW_H_TEXT
- local x = indent or 0
- row.ctrl:SetPos(PAD_L, y, MAX_WIN_W, h)
- row.ctrl:Show()
- if icon then
- row.img.file = icon
- row.img:SetPos(x, floor((h - ICON) / 2), ICON, ICON)
- row.img:Show()
- row.img:Invalidate()
- x = x + ICON + ICON_GAP
- else
- row.img:Hide()
- end
- row.lbl:SetPos(x, floor((h - 12) / 2))
- row.lbl.font.color = color or COL_TEXT
- row.lbl:SetCaption(text)
- row.lbl:Invalidate()
- local w = x + TextWidth(row.lbl, text)
- if w > widest then
- widest = w
- end
- y = y + h
- end
- if not r then
- lblWin:SetCaption("--")
- lblWin.font.color = COL_NOTE
- lblWin:SetPos(PAD_L, y, MAX_WIN_W, WIN_H)
- lblWin:Invalidate()
- barWin:Hide()
- lblTotals:Hide()
- y = y + WIN_H + GAP
- Line(idleText, COL_NOTE)
- else
- local pct = floor(r.p * 100 + 0.5)
- lblWin:SetCaption(pct .. "%")
- lblWin.font.color = WinColor(r.p)
- lblWin:SetPos(PAD_L, y, MAX_WIN_W, WIN_H)
- lblWin:Invalidate()
- y = y + WIN_H
- barWin:SetPos(PAD_L, y, BAR_W, BAR_H)
- barWin:SetValue(pct)
- barWin:Show()
- y = y + BAR_H + 4
- lblTotals:SetPos(PAD_L, y, MAX_WIN_W, TOTALS_H)
- lblTotals:SetCaption(("You %d m (%d) vs %d m (%d)")
- :format(floor(r.Vf), r.fUnits, floor(r.Ve), r.eUnits))
- lblTotals:Show()
- lblTotals:Invalidate()
- y = y + TOTALS_H + GAP
- if widest < BAR_W then
- widest = BAR_W
- end
- if options.showBreakdown.value and not r.degraded then
- local cm = WG.CounterMatrix
- local function Section(label, list)
- Line(label, COL_HEAD)
- for i = 1, #list do
- local a = list[i]
- -- No icon for the unidentified: DEFAULT_ICON is fully transparent, so it
- -- drew a blank box. Line hides the image when the icon is nil.
- local icon = (a.topDef ~= BLIP_KEY) and iconFile[a.topDef] or nil
- Line(("%d %s %d m"):format(floor(a.count + 0.5), Plural(cm, a.role),
- floor(a.value)),
- COL_TEXT, icon, 4)
- end
- end
- Section("Your force", r.rolesF)
- if #r.rolesE > 0 then
- y = y + 2
- Section("Their force", r.rolesE)
- end
- end
- if options.showFactors.value and #r.factors > 0 then
- y = y + GAP
- Line("Why", COL_HEAD)
- for i = 1, #r.factors do
- local f = r.factors[i]
- Line((f.sign > 0 and "+ " or "- ") .. f.text,
- (f.sign > 0) and COL_GOOD or COL_BAD, nil, 4)
- end
- end
- for i = 1, #r.notes do
- if i == 1 then
- y = y + GAP
- end
- Line(r.notes[i], COL_NOTE)
- end
- end
- for i = used + 1, #rowPool do
- rowPool[i].ctrl:Hide()
- end
- local contentH = y + PAD_B
- local contentW = widest + PAD_L + PAD_R
- holder:SetPos(nil, nil, nil, contentH)
- -- Both dimensions follow the content. Height is capped so a big fight cannot run off
- -- the screen; past the cap the ScrollPanel takes over and the width pays for a bar.
- local screenH = (Chili.Screen0 and Chili.Screen0.height) or 1080
- local screenW = (Chili.Screen0 and Chili.Screen0.width) or 1920
- local maxH = floor(screenH * MAX_WIN_FRAC)
- local wantH = max(min(contentH + WIN_VPAD, maxH), MIN_WIN_H)
- local scrolling = (contentH + WIN_VPAD) > maxH
- local wantW = contentW + WIN_HPAD + (scrolling and SCROLLBAR_W or 0)
- wantW = max(min(wantW, MAX_WIN_W), MIN_WIN_W)
- if window_main.width ~= wantW or window_main.height ~= wantH then
- local x = window_main.x
- -- Grow towards the middle of the screen, so a panel parked on the right keeps its
- -- right edge planted instead of walking off it.
- if x + window_main.width * 0.5 > screenW * 0.5 then
- x = max(0, x + window_main.width - wantW)
- end
- window_main:SetPos(x, window_main.y, wantW, wantH)
- end
- end
- local function CreateWindow()
- window_main = Chili.Window:New{
- parent = Chili.Screen0,
- name = "EngagementPredictor",
- classname = "main_window_small_very_flat",
- color = {0, 0, 0, 0},
- backgroundColor = {0, 0, 0, 0},
- borderColor = {0, 0, 0, 0},
- dockable = true,
- dockableSavePositionOnly = true,
- parentWidgetName = widget:GetInfo().name, -- for gui_chili_docking.lua (minimize)
- right = 273, y = 44,
- clientWidth = 240,
- clientHeight = 120,
- minWidth = MIN_WIN_W, minHeight = MIN_WIN_H,
- draggable = false, resizable = false,
- tweakDraggable = true, tweakResizable = true,
- padding = {6, 6, 6, 6},
- }
- scrollPanel = Chili.ScrollPanel:New{
- parent = window_main,
- x = 0, y = 0, right = 0, bottom = 0,
- padding = {0, 0, 0, 0},
- borderColor = {0, 0, 0, 0},
- backgroundColor = {0, 0, 0, 0},
- horizontalScrollbar = false,
- }
- holder = Chili.Control:New{
- parent = scrollPanel,
- x = 0, y = 0, right = 0,
- height = 1,
- padding = {0, 0, 0, 0},
- }
- lblWin = Chili.Label:New{
- parent = holder,
- x = PAD_L, y = PAD_T,
- width = MAX_WIN_W, height = WIN_H,
- font = {
- size = 26,
- color = COL_EVEN,
- outline = true,
- outlineWidth = 4,
- outlineWeight = 8,
- outlineColor = {0, 0, 0, 1},
- },
- caption = "--",
- }
- barWin = Chili.Progressbar:New{
- parent = holder,
- x = PAD_L, y = PAD_T + WIN_H,
- width = BAR_W, height = BAR_H,
- min = 0, max = 100, value = 50,
- noFont = true,
- -- The bar is the split itself: filled is your side, the ground behind it is theirs.
- color = {0.35, 0.80, 0.40, 0.95},
- backgroundColor = {0.55, 0.16, 0.16, 0.85},
- }
- lblTotals = Chili.Label:New{
- parent = holder,
- x = PAD_L, y = PAD_T + WIN_H + BAR_H + 4,
- width = MAX_WIN_W, height = TOTALS_H,
- font = {
- size = 12,
- color = COL_NOTE,
- outline = true,
- outlineWidth = 3,
- outlineWeight = 6,
- outlineColor = {0, 0, 0, 1},
- },
- caption = "",
- }
- end
- -- Show/hide only. The window is kept alive so its docked position, and anything the
- -- tweak-mode layout did to it, survive being toggled off and on again.
- function SetPanelVisible(visible)
- if not window_main then
- return
- end
- if visible then
- window_main:Show()
- dirty = true -- whatever is selected now was never scored; do it before it is read
- else
- window_main:Hide()
- end
- end
- --------------------------------------------------------------------------------
- -- Callins
- --------------------------------------------------------------------------------
- --------------------------------------------------------------------------------
- -- Rings on the map
- --------------------------------------------------------------------------------
- -- The word over the selection ring. The same thresholds are in the option's description.
- local function Verdict(p)
- if p >= 0.65 then
- return "Favourable - engage"
- elseif p >= 0.45 then
- return "Even fight"
- elseif p >= 0.25 then
- return "Unfavourable"
- end
- return "Retreat!"
- end
- -- Clusters of recently hit units of ours, each scored as if it were the selection. Greedy
- -- clustering against running centres is plenty for a handful of fights: two groups a
- -- screen apart never share a front, and one group never splits over a grid line.
- local function FindFronts(gt)
- local hitAt = rings.hitAt
- local clusters = {}
- for uid, t in pairs(hitAt) do
- local x, _, z = spGetUnitPosition(uid)
- if gt - t > rings.MEMORY or not x then
- hitAt[uid] = nil
- else
- local join
- for i = 1, #clusters do
- local c = clusters[i]
- local dx, dz = c.x / c.n - x, c.z / c.n - z
- if dx * dx + dz * dz <= rings.JOIN * rings.JOIN then
- join = c
- break
- end
- end
- if not join then
- join = {x = 0, z = 0, n = 0, units = {}}
- clusters[#clusters + 1] = join
- end
- join.x, join.z, join.n = join.x + x, join.z + z, join.n + 1
- join.units[join.n] = uid
- end
- end
- table.sort(clusters, function(a, b)
- if a.n ~= b.n then return a.n > b.n end
- return a.units[1] < b.units[1]
- end)
- -- The selection already has its ring and its word; a front on top of it would only say
- -- the same thing again in a second colour.
- -- Scoring is not done here. Gather and Score for six fronts in one frame was a visible
- -- hitch once a second, so the clusters are queued and Update scores one per frame.
- local sel = options.selRing.value and result
- local queue = {}
- for i = 1, #clusters do
- local c = clusters[i]
- local mx, mz = c.x / c.n, c.z / c.n
- local dup = false
- if sel and sel.cx then
- local dx, dz = sel.cx - mx, sel.cz - mz
- dup = dx * dx + dz * dz <= (sel.radius + rings.JOIN * 0.5) ^ 2
- end
- if not dup then
- queue[#queue + 1] = c.units
- end
- end
- rings.queue, rings.qi, rings.pending = queue, 1, {}
- if #queue == 0 then
- CommitFronts(gt)
- end
- end
- -- One queued cluster scored. The list is committed whole, once the queue runs dry or the
- -- cap is reached, so the fronts are never half this second's and half the last's.
- local function ScoreNextFront()
- local queue, i = rings.queue, rings.qi
- if i > #queue then
- return
- end
- rings.qi = i + 1
- local g = Gather(queue[i])
- if g then
- local r = Score(g)
- local pending = rings.pending
- pending[#pending + 1] = {cx = r.cx, cz = r.cz, radius = r.radius, p = r.p, gap = r.gap,
- lastHit = r.lastHit}
- end
- if rings.qi > #queue or #rings.pending >= rings.MAX then
- CommitFronts(Spring.GetGameSeconds())
- end
- end
- --------------------------------------------------------------------------------
- -- The drawn rings: easing, fading, and the ground geometry
- --------------------------------------------------------------------------------
- local exp, sin, cos, TAU = math.exp, math.sin, math.cos, 2 * math.pi
- local RING_SEG = 64 -- segments round a ring; the heights are cached at these
- local DASHES = 24 -- dashes round a front
- local LIFT = 3 -- elmos the lines sit above the ground
- -- Point a drawn ring at a fresh score, and remember the chance for the trend arrow. A
- -- paused game scores the same second again and again: that overwrites, so the history
- -- cannot grow while game time stands still.
- local function Retarget(s, r, gt)
- s.tx, s.tz, s.tr, s.tp = r.cx, r.cz, r.radius, r.p
- s.tgap = r.gap or 0
- s.lastHit = r.lastHit or -huge
- s.Vf, s.Ve = r.Vf, r.Ve
- local ht, hp = s.ht, s.hp
- local n = #ht
- if n > 0 and ht[n] >= gt then
- hp[n] = r.p
- else
- ht[n + 1], hp[n + 1] = gt, r.p
- end
- while #ht > 1 and ht[1] < gt - 2 * rings.TREND do
- table.remove(ht, 1)
- table.remove(hp, 1)
- end
- end
- -- A drawn ring born where its score says, invisible, to fade in from there.
- local function NewShown(r, gt)
- local s = {
- cx = r.cx, cz = r.cz, radius = r.radius, p = r.p, gap = r.gap or 0,
- alpha = 0, live = true,
- ht = {}, hp = {},
- hOut = {}, hIn = {},
- }
- Retarget(s, r, gt)
- return s
- end
- -- +1, -1 or 0: how the chance has moved against the newest score at least TREND s old.
- local function Trend(s)
- local ht, hp = s.ht, s.hp
- local n = #ht
- if n < 2 then
- return 0
- end
- local cut = ht[n] - rings.TREND
- for i = n - 1, 1, -1 do
- if ht[i] <= cut then
- local d = hp[n] - hp[i]
- if d > rings.TREND_MIN then
- return 1
- elseif d < -rings.TREND_MIN then
- return -1
- end
- return 0
- end
- end
- return 0
- end
- -- Each new front takes over the nearest drawn ring within JOIN, keeping its history and
- -- its place on screen; anything left over is a new fight and fades in. A drawn ring no
- -- front claimed is over and fades out -- or back in, if the fight flares up again first.
- CommitFronts = function(gt)
- local fronts = rings.pending
- rings.fronts = fronts
- rings.queue, rings.qi, rings.pending = {}, 1, {}
- local shown = rings.shown
- for j = 1, #shown do
- shown[j].live = false
- end
- for i = 1, #fronts do
- local f = fronts[i]
- local best, bestD = nil, rings.JOIN * rings.JOIN
- for j = 1, #shown do
- local s = shown[j]
- if not s.live then
- local dx, dz = s.tx - f.cx, s.tz - f.cz
- local d = dx * dx + dz * dz
- if d < bestD then
- best, bestD = s, d
- end
- end
- end
- if best then
- Retarget(best, f, gt)
- else
- best = NewShown(f, gt)
- shown[#shown + 1] = best
- end
- best.live = true
- end
- end
- -- Frame-rate independent: an exponential ease over dt reaches the same place whether it
- -- got there in 30 steps or 144.
- local function EaseRing(s, dt)
- local kp = 1 - exp(-dt / rings.EASE_POS)
- local kc = 1 - exp(-dt / rings.EASE_P)
- s.cx = s.cx + (s.tx - s.cx) * kp
- s.cz = s.cz + (s.tz - s.cz) * kp
- s.radius = s.radius + (s.tr - s.radius) * kp
- s.p = s.p + (s.tp - s.p) * kc
- s.gap = s.gap + (s.tgap - s.gap) * kc
- local step = dt / rings.FADE
- -- nearOnly hides a ring whose nearest enemy is still beyond the near distance. It only
- -- fades it: the ring stays live, keeping its place and history for when they close in.
- local hidden = options.nearOnly.value and s.tgap > options.nearDist.value
- if s.live and not hidden then
- s.alpha = min(1, s.alpha + step)
- else
- s.alpha = max(0, s.alpha - step)
- end
- end
- -- Once a frame, from Update. It follows the selection's score into `rings.sel`, snapping
- -- (and fading in) rather than gliding when the new fight is somewhere else entirely.
- local function Animate()
- local now = Spring.GetTimer()
- local dt = rings.clock and Spring.DiffTimers(now, rings.clock) or 0
- rings.clock = now
- if dt > 0.1 then
- dt = 0.1 -- after a hitch, carry on from where it was rather than leaping
- end
- rings.t = rings.t + dt
- local gt = Spring.GetGameSeconds()
- rings.gt = gt
- local s = rings.sel
- if not options.selRing.value then
- s = nil
- elseif result and result.cx then
- if s then
- local dx, dz = result.cx - s.cx, result.cz - s.cz
- if dx * dx + dz * dz > rings.JOIN * rings.JOIN then
- s = nil
- end
- end
- if not s then
- s = NewShown(result, gt)
- elseif s.src ~= result then
- Retarget(s, result, gt)
- end
- s.src, s.live = result, true
- elseif s then
- s.live = false
- end
- if s then
- EaseRing(s, dt)
- if not s.live and s.alpha <= 0 then
- s = nil
- end
- end
- rings.sel = s
- local shown = rings.shown
- for i = #shown, 1, -1 do
- local f = shown[i]
- EaseRing(f, dt)
- if not f.live and f.alpha <= 0 then
- table.remove(shown, i)
- end
- end
- end
- local function Ground(x, z)
- local h = Spring.GetGroundHeight(x, z) or 0
- return ((h > 0) and h or 0) + LIFT -- over water the ring floats, as the units do
- end
- -- The heights round a ring are sampled only when the ring has moved more than a few elmos
- -- since the last sample. x and z are always the live eased ones, so the ring never lags;
- -- only the height is a frame or two stale while it glides, which nothing can see.
- local function Geo(s)
- if s.gr and abs(s.cx - s.gx) < 6 and abs(s.cz - s.gz) < 6 and abs(s.radius - s.gr) < 6 then
- return
- end
- local ro = s.radius
- local ri = ro - min(40, ro * 0.3)
- local hOut, hIn = s.hOut, s.hIn
- for i = 0, RING_SEG - 1 do
- local a = i / RING_SEG * TAU
- local ux, uz = sin(a), -cos(a)
- hOut[i] = Ground(s.cx + ux * ro, s.cz + uz * ro)
- hIn[i] = Ground(s.cx + ux * ri, s.cz + uz * ri)
- end
- hOut[RING_SEG], hIn[RING_SEG] = hOut[0], hIn[0]
- s.gx, s.gz, s.gr = s.cx, s.cz, s.radius
- s.gy = Ground(s.cx, s.cz)
- end
- -- Angles are fractions of a turn, clockwise seen from above, from the far (-z) edge: the
- -- edge the label sits on, so the gauge starts under the number.
- -- `R` and `hs` default to the ring itself; the reach circle passes its own radius and heights.
- local function RingVertex(s, f, R, hs)
- R, hs = R or s.radius, hs or s.hOut
- local idx = (f % 1) * RING_SEG
- local i = floor(idx)
- local h0 = hs[i]
- local y = h0 + (hs[i + 1] - h0) * (idx - i)
- local a = f * TAU
- gl.Vertex(s.cx + sin(a) * R, y, s.cz - cos(a) * R)
- end
- -- The heights for the reach circle, cached like Geo's, but at radius + near distance.
- local function ReachGeo(s, R)
- if s.rgr and abs(s.cx - s.rgx) < 6 and abs(s.cz - s.rgz) < 6 and abs(R - s.rgr) < 6 then
- return
- end
- local hs = s.hReach or {}
- s.hReach = hs
- for i = 0, RING_SEG - 1 do
- local a = i / RING_SEG * TAU
- hs[i] = Ground(s.cx + sin(a) * R, s.cz - cos(a) * R)
- end
- hs[RING_SEG] = hs[0]
- s.rgx, s.rgz, s.rgr = s.cx, s.cz, R
- end
- -- 48 short dashes, each a single segment: at this radius they are short enough to sit on
- -- the ground without bending.
- local function ReachVerts(s, R)
- local hs = s.hReach
- for k = 0, 47 do
- local f0 = k / 48
- RingVertex(s, f0, R, hs)
- RingVertex(s, f0 + 1 / 96, R, hs)
- end
- end
- -- An arc from f0 to f1, through every segment boundary between, so it hugs the ground.
- local function ArcVerts(s, f0, f1)
- RingVertex(s, f0)
- for i = floor(f0 * RING_SEG) + 1, ceil(f1 * RING_SEG) - 1 do
- RingVertex(s, i / RING_SEG)
- end
- RingVertex(s, f1)
- end
- -- The glow: bright at the line, nothing a band's width inside it. Outer/inner pairs, drawn
- -- as a triangle strip (the same band a quad strip gives, without the deprecated primitive).
- local function BandVerts(s, r, g, b, a)
- local ro = s.radius
- local ri = ro - min(40, ro * 0.3)
- for i = 0, RING_SEG do
- local ang = i / RING_SEG * TAU
- local ux, uz = sin(ang), -cos(ang)
- gl.Color(r, g, b, a)
- gl.Vertex(s.cx + ux * ro, s.hOut[i], s.cz + uz * ro)
- gl.Color(r, g, b, 0)
- gl.Vertex(s.cx + ux * ri, s.hIn[i], s.cz + uz * ri)
- end
- end
- -- The front's dashes, turning with `phase`. Each is lit if its middle is inside the gauge's
- -- arc and dim otherwise, so the lit run reads as the chance while the dashes slide round.
- -- Two segments a dash, so a big front's dashes still bend with the ground.
- local function DashVerts(s, phase, r, g, b, a, dr, dg, db, da)
- local len = 0.5 / DASHES
- for k = 0, DASHES - 1 do
- local f0 = (k / DASHES + phase) % 1
- if (f0 + len * 0.5) % 1 < s.p then
- gl.Color(r, g, b, a)
- else
- gl.Color(dr, dg, db, da)
- end
- local fm = f0 + len * 0.5
- RingVertex(s, f0)
- RingVertex(s, fm)
- RingVertex(s, fm)
- RingVertex(s, f0 + len)
- end
- end
- local function LoopVerts(s)
- for i = 0, RING_SEG - 1 do
- RingVertex(s, i / RING_SEG)
- end
- end
- local function FanVerts(s)
- gl.Vertex(s.cx, s.gy, s.cz)
- for i = 0, RING_SEG do
- RingVertex(s, i / RING_SEG)
- end
- end
- -- 1 while the ringed units are hitting or being hit and for HIT_HOLD s after, easing to 0
- -- over HIT_FADE s more. Read on game time, so a paused fight stays lit.
- local function Combat(s)
- local since = rings.gt - (s.lastHit or -huge)
- if since <= rings.HIT_HOLD then
- return 1
- end
- local t = (since - rings.HIT_HOLD) / rings.HIT_FADE
- if t >= 1 then
- return 0
- end
- return 1 - t * t * (3 - 2 * t)
- end
- -- How strongly the inside is filled. Full while an enemy is at or inside the ring (gap 0) or
- -- while the ring is fighting; otherwise it eases down with the nearest enemy's distance to
- -- the `fillFar` share at `fillRange` elmos out. Not the enemy gather radius: at 2000 elmos
- -- that put every enemy close enough to shoot at nine tenths full, so nothing ever changed.
- local function FillFactor(s)
- local t = min(1, max(0, s.gap / max(options.fillRange.value, 1)))
- local near = 1 - t * t * (3 - 2 * t)
- local level = max(near, Combat(s))
- local floorShare = options.fillFar.value / 100
- return floorShare + (1 - floorShare) * level
- end
- -- Fill, glow, dark halo, then the gauge: a solid arc for the selection, dashes for a front. Below
- -- 25% the whole ring pulses. Everything is scaled by the ring's fade.
- local function DrawShown(s, width, dashed)
- s.onScreen = false
- if s.alpha <= 0.01 then
- return
- end
- Geo(s)
- local reach = options.nearReach.value and (s.radius + options.nearDist.value)
- if not Spring.IsSphereInView(s.cx, s.gy, s.cz, reach or s.radius) then
- return
- end
- s.onScreen = true
- local r, g, b = WinRGB(s.p)
- local a = s.alpha
- local pulse = 1
- if s.p < 0.25 then
- pulse = 1 + 0.35 * sin(rings.t * TAU * 1.6)
- end
- local lit = a * min(1, 0.8 + 0.2 * pulse)
- if reach then
- ReachGeo(s, reach)
- gl.LineWidth(1)
- gl.Color(r, g, b, 0.3 * a)
- gl.BeginEnd(GL.LINES, ReachVerts, s, reach)
- end
- if options.plainRings.value then
- -- The dashes alone. The selection's stand still, so it still reads apart from a front.
- local phase = dashed and (rings.t * 0.03) % 1 or 0
- gl.LineWidth(width * pulse)
- gl.BeginEnd(GL.LINES, DashVerts, s, phase, r, g, b, lit,
- COL_BAD[1], COL_BAD[2], COL_BAD[3], 0.35 * a)
- return
- end
- local fill = rings.FILL_A * a * FillFactor(s)
- if options.fillFlash.value then
- -- Only while shooting is going on; Combat's tail makes the flash die away, not stop.
- local fighting = Combat(s)
- if fighting > 0 then
- local wave = 0.5 + 0.5 * cos(rings.t * TAU / rings.FLASH_PERIOD) -- 1, 0, 1
- fill = fill * (1 - fighting * (1 - wave))
- end
- end
- if fill > 0.005 then
- gl.Color(r, g, b, fill)
- gl.BeginEnd(GL.TRIANGLE_FAN, FanVerts, s)
- end
- gl.BeginEnd(GL.TRIANGLE_STRIP, BandVerts, s, r, g, b, 0.22 * a * pulse)
- gl.LineWidth(width * pulse + 3)
- gl.Color(0, 0, 0, 0.45 * a)
- gl.BeginEnd(GL.LINE_LOOP, LoopVerts, s)
- gl.LineWidth(width * pulse)
- local dr, dg, db = COL_BAD[1], COL_BAD[2], COL_BAD[3]
- if dashed then
- gl.BeginEnd(GL.LINES, DashVerts, s, (rings.t * 0.03) % 1, r, g, b, lit, dr, dg, db, 0.35 * a)
- else
- gl.Color(r, g, b, lit)
- gl.BeginEnd(GL.LINE_STRIP, ArcVerts, s, 0, s.p)
- gl.Color(dr, dg, db, 0.35 * a)
- gl.BeginEnd(GL.LINE_STRIP, ArcVerts, s, s.p, 1)
- end
- end
- --------------------------------------------------------------------------------
- -- Circles under the units near a ring (nearEnemies / nearAll)
- --------------------------------------------------------------------------------
- local function MarkRing(s, marks, seen, myAlly, all)
- if not s or s.alpha <= 0.01 then
- return
- end
- local us = Spring.GetUnitsInCylinder(s.cx, s.cz, s.radius + options.nearDist.value)
- for i = 1, #(us or {}) do
- local uid = us[i]
- if not seen[uid] then
- seen[uid] = true
- if spGetUnitTeam(uid) ~= gaiaTeamID then
- local enemy = spGetUnitAllyTeam(uid) ~= myAlly
- if enemy or all then
- local rad = Spring.GetUnitRadius(uid) or 20
- marks[#marks + 1] = {uid, enemy, min(60, max(12, rad))}
- end
- end
- end
- end
- end
- -- Who is near is swept a few times a second, a unit counted once however many rings it is
- -- near; the circles themselves follow the units every frame, in DrawMarks.
- local function CollectMarks()
- local marks = {}
- local all = options.nearAll.value
- if all or options.nearEnemies.value then
- local myAlly, seen = spGetMyAllyTeamID(), {}
- MarkRing(rings.sel, marks, seen, myAlly, all)
- if options.showFronts.value then
- local shown = rings.shown
- for i = 1, #shown do
- MarkRing(shown[i], marks, seen, myAlly, all)
- end
- end
- end
- rings.marks = marks
- end
- local function DrawMarks()
- local marks = rings.marks
- if #marks == 0 then
- return
- end
- gl.LineWidth(1.5)
- for i = 1, #marks do
- local m = marks[i]
- local x, y, z = spGetUnitPosition(m[1])
- if x then
- local c = m[2] and COL_BAD or COL_GOOD
- gl.Color(c[1], c[2], c[3], 0.75)
- gl.DrawGroundCircle(x, y, z, m[3], 16)
- end
- end
- end
- --------------------------------------------------------------------------------
- -- The label over a ring
- --------------------------------------------------------------------------------
- local PILL_PAD = 5
- local PILL_R = 5
- -- A rounded rectangle as one fan: the middle, then the four corner arcs anticlockwise.
- local function PillVerts(x0, y0, x1, y1, rd)
- gl.Vertex((x0 + x1) * 0.5, (y0 + y1) * 0.5)
- for q = 0, 3 do
- local ccx = (q == 0 or q == 3) and (x1 - rd) or (x0 + rd)
- local ccy = (q < 2) and (y1 - rd) or (y0 + rd)
- for j = 0, 4 do
- local ang = (q * 4 + j) / 16 * TAU
- gl.Vertex(ccx + cos(ang) * rd, ccy + sin(ang) * rd)
- end
- end
- gl.Vertex(x1, y1 - rd)
- end
- local function TriVerts(x, y, h, up)
- if up then
- gl.Vertex(x - h * 0.6, y - h * 0.5)
- gl.Vertex(x + h * 0.6, y - h * 0.5)
- gl.Vertex(x, y + h * 0.5)
- else
- gl.Vertex(x - h * 0.6, y + h * 0.5)
- gl.Vertex(x, y - h * 0.5)
- gl.Vertex(x + h * 0.6, y + h * 0.5)
- end
- end
- local function Kilo(v)
- v = v or 0
- if v >= 1000 then
- return ("%.1fk"):format(v / 1000)
- end
- return tostring(floor(v + 0.5))
- end
- local function TextW(text, size)
- return (gl.GetTextWidth and gl.GetTextWidth(text) or #text * 0.55) * size
- end
- -- The label's strings and their widths only change when the whole percentage does, so they
- -- are rebuilt then and not every frame.
- local function LabelText(s, full)
- local pct = floor(s.p * 100 + 0.5)
- if pct == s.pctN and (not full or s.Vf == s.metalVf and s.Ve == s.metalVe) then
- return
- end
- s.pctN = pct
- s.pctText = pct .. "%"
- if full then
- s.verdict = Verdict(pct / 100) -- from the shown number, so the two never disagree
- s.metalText = Kilo(s.Vf) .. " vs " .. Kilo(s.Ve)
- s.metalVf, s.metalVe = s.Vf, s.Ve
- end
- end
- -- The label sits on the ring's far edge, so it reads above the fight rather than on it: a
- -- dark pill, the chance big, and for the selection the verdict and the metal under it.
- local function Label(s, full)
- if not s.onScreen or s.alpha <= 0.01 then
- return
- end
- local fx, fz = s.cx, s.cz - s.radius
- local sx, sy, sz = Spring.WorldToScreenCoords(fx, s.hOut[0] or s.gy or 0, fz)
- if not sx or (sz and sz > 1) then
- return -- behind the camera
- end
- LabelText(s, full)
- local a = s.alpha
- local r, g, b = WinRGB(s.p)
- local big = full and 20 or 14
- local small, tiny = 12, 11
- local trend = Trend(s)
- local arrow = (trend ~= 0) and big * 0.6 or 0
- local w = TextW(s.pctText, big) + ((arrow > 0) and (arrow + 4) or 0)
- local h = big
- if full then
- w = max(w, TextW(s.verdict, small), TextW(s.metalText, tiny))
- h = h + small + tiny + 4
- end
- local x0, x1 = sx - w * 0.5 - PILL_PAD, sx + w * 0.5 + PILL_PAD
- local y0 = sy + 6
- local y1 = y0 + h + PILL_PAD * 2
- -- The plain style has no box: every line gets a black outline instead. That is gl.Text's
- -- lower-case "o"; the capital "O" is the *light* outline, which reads as a white halo.
- local plain = options.plainRings.value
- local opts = plain and "co" or "c"
- if not plain then
- gl.Color(0, 0, 0, 0.55 * a)
- gl.BeginEnd(GL.TRIANGLE_FAN, PillVerts, x0, y0, x1, y1, PILL_R)
- end
- -- Top to bottom: the chance (and its arrow), the verdict, the metal.
- local ty = y1 - PILL_PAD - big
- local pctW = TextW(s.pctText, big)
- local px = sx - ((arrow > 0) and (arrow + 4) * 0.5 or 0)
- gl.Color(r, g, b, a)
- gl.Text(s.pctText, px, ty, big, plain and "co" or "cO") -- normal style keeps its old look
- if arrow > 0 then
- local up = trend > 0
- local c = up and COL_GOOD or COL_BAD
- gl.Color(c[1], c[2], c[3], a)
- gl.BeginEnd(GL.TRIANGLES, TriVerts, px + pctW * 0.5 + 4 + arrow * 0.5, ty + big * 0.4, arrow, up)
- end
- if full then
- ty = ty - small - 2
- gl.Color(r, g, b, a)
- gl.Text(s.verdict, sx, ty, small, opts)
- ty = ty - tiny - 2
- gl.Color(0.8, 0.8, 0.8, 0.9 * a)
- gl.Text(s.metalText, sx, ty, tiny, opts)
- end
- end
- function M:UnitDamaged(unitID, unitDefID, unitTeam, damage, paralyzer, _, _,
- attackerID, attackerDefID, attackerTeam)
- -- Both rings read the hits: the fronts are made of them, and a ring whose units are
- -- hitting or being hit is fighting, however far off the nearest enemy it can see is.
- if not (options.showFronts.value or options.selRing.value) then
- return
- end
- local myAlly = spGetMyAllyTeamID()
- local gt = Spring.GetGameSeconds()
- if spGetUnitAllyTeam(unitID) == myAlly then
- rings.hitAt[unitID] = gt
- elseif attackerID and spGetUnitAllyTeam(attackerID) == myAlly then
- rings.hitAt[attackerID] = gt
- end
- end
- function M:DrawWorldPreUnit()
- if Spring.IsGUIHidden() then
- return
- end
- local sel, shown = rings.sel, rings.shown
- if not sel and #shown == 0 then
- return
- end
- -- No gl.Smoothing: this engine build does not have it, and calling it removed the widget.
- DrawMarks() -- first, so the rings draw over the unit circles
- if options.showFronts.value then
- for i = 1, #shown do
- DrawShown(shown[i], 2, true)
- end
- end
- if sel then
- DrawShown(sel, 3, false) -- last, so it sits on top of any front it overlaps
- end
- gl.LineWidth(1)
- gl.Color(1, 1, 1, 1)
- end
- function M:DrawScreen()
- if Spring.IsGUIHidden() then
- return
- end
- local sel, shown = rings.sel, rings.shown
- if not sel and #shown == 0 then
- return
- end
- if options.showFronts.value then
- for i = 1, #shown do
- Label(shown[i], false)
- end
- end
- if sel then
- Label(sel, true)
- end
- gl.Color(1, 1, 1, 1)
- end
- function M:Initialize()
- Chili = WG.Chili
- if not Chili then
- spEcho("Engagement Predictor: WG.Chili unavailable, removing widget.")
- widgetHandler:RemoveWidget(widget)
- return
- end
- myTeamID = spGetMyTeamID()
- gaiaTeamID = spGetGaiaTeamID()
- ApplyPalette(options.cbPalette.value) -- the saved choice is loaded before this runs
- CreateWindow()
- SetPanelVisible(options.showPanel.value)
- dirty = true
- end
- function M:Shutdown()
- if window_main then
- window_main:Dispose()
- window_main = nil
- end
- rowPool = {}
- end
- function M:SelectionChanged()
- dirty = true
- end
- function M:PlayerChanged()
- myTeamID = spGetMyTeamID()
- dirty = true
- end
- function M:Update()
- local panel = options.showPanel.value
- local selRing = options.selRing.value
- local now = os.clock()
- -- Every frame, whatever else happens below: the rings ease and fade on wall time.
- Animate()
- if now >= rings.nextMark then
- rings.nextMark = now + rings.MARK_EVERY
- CollectMarks()
- end
- -- Fronts on their own clock: they depend on who is being hit, not on the selection.
- -- Clustering is once a second; the scoring it queues is one front a frame.
- if options.showFronts.value then
- if now >= rings.next then
- rings.next = now + 1
- FindFronts(Spring.GetGameSeconds())
- end
- ScoreNextFront()
- elseif selRing and now >= rings.next then
- -- No fronts to age the hits out, but the selection ring still records them.
- rings.next = now + 1
- local gt = Spring.GetGameSeconds()
- for uid, t in pairs(rings.hitAt) do
- if gt - t > rings.MEMORY then
- rings.hitAt[uid] = nil
- end
- end
- end
- if not panel and not selRing then
- -- Gathering and scoring the selection exist only for the panel and the ring, so with
- -- both off there is nothing worth computing. `dirty` is left standing so the fight on
- -- screen when one comes back is scored fresh rather than shown as it was.
- result = nil
- return
- end
- if not dirty and now < nextRefresh then
- return
- end
- dirty = false
- nextRefresh = now + options.updateRate.value
- -- WG.CounterMatrix is read inside Recompute rather than cached at Initialize: widget
- -- load order is not guaranteed, and Build Proposer can be toggled mid-game.
- Recompute()
- if panel then
- Rebuild()
- end
- end
- RegisterModule{
- callins = M,
- prefix = 'ep_',
- optionsPath = options_path,
- options = options,
- optionsOrder = options_order,
- }
- end
- --------------------------------------------------------------------------------
- -- Scout Cam
- --
- -- The other four panels answer questions about the enemy army as a tally. This one
- -- answers the question a tally cannot: *what did my scout just find*. The moment a
- -- scout lays eyes on an enemy factory, a fusion, a nuke silo -- anything expensive
- -- enough to be a decision rather than a building -- a small viewer opens down the left
- -- edge showing that structure's model, named, for a few seconds. Clicking it takes the
- -- camera there.
- --
- -- It fires once per site and never again. A scout that circles the same base, a second
- -- scout following the first, a nanoframe re-acquired under a new unitID after the ghost
- -- was dropped: all of it is the same find, and a find you have already been shown is not
- -- news. `sighted` keys the unit and `sightedSite` keys the ground it stands on, so both
- -- the unit-level and the rebuilt-in-place cases are covered.
- --
- -- It is drawn in DrawScreen rather than built out of Chili, unlike every other panel
- -- here. Two reasons. The content is a unit model, and gl.UnitShape wants a scissor box in
- -- absolute screen coordinates and a depth buffer of its own -- both awkward to reach from
- -- inside a Chili control's transform. And these are transient popups pinned to the screen
- -- edge, so there is no docked position to preserve and nothing for tweak mode to move:
- -- the whole of what Chili would be buying us here is layout we are not using.
- --
- -- The model drawing is the recipe from Zero-K's own gui_unitgroups.lua -- material setup,
- -- front-face culling because the screen projection flips handedness, and a fit computed
- -- from Spring.GetUnitDefDimensions. It reads no widget state and publishes none, so it
- -- registers last and dies first.
- --------------------------------------------------------------------------------
- do
- local M = {} -- this module's callins, collected by RegisterModule below
- local options_path, options_order, options -- were file-scope globals
- --------------------------------------------------------------------------------
- -- Includes / static per-unitDef data
- --------------------------------------------------------------------------------
- local spGetUnitDefID = Spring.GetUnitDefID
- local spGetUnitTeam = Spring.GetUnitTeam
- local spGetUnitPosition = Spring.GetUnitPosition
- local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt
- local spGetAllUnits = Spring.GetAllUnits
- local spGetMyAllyTeamID = Spring.GetMyAllyTeamID
- local spGetTeamList = Spring.GetTeamList
- local spGetTeamInfo = Spring.GetTeamInfo
- local spSetCameraTarget = Spring.SetCameraTarget
- local spGetUnitDefDimensions = Spring.GetUnitDefDimensions
- local spIsGUIHidden = Spring.IsGUIHidden
- local spGetUnitsInCylinder = Spring.GetUnitsInCylinder
- local spGetFeaturesInCylinder = Spring.GetFeaturesInCylinder
- local spGetFeatureDefID = Spring.GetFeatureDefID
- local spEcho = Spring.Echo
- local spMarkerAddPoint = Spring.MarkerAddPoint
- local spMarkerErasePosition = Spring.MarkerErasePosition
- local glColor = gl.Color
- local glRect = gl.Rect
- local glText = gl.Text
- local glScissor = gl.Scissor
- local glPushMatrix = gl.PushMatrix
- local glPopMatrix = gl.PopMatrix
- local glTranslate = gl.Translate
- local glRotate = gl.Rotate
- local glScale = gl.Scale
- local glUnitShape = gl.UnitShape
- local glUnit = gl.Unit
- local glFeature = gl.Feature
- -- (defID, push), not (bool): these bind the textures belonging to one model, and the
- -- first live build crashed on being handed a boolean. The call is copied from Zero-K's own
- -- unit_ghostsite.lua, which is the only shipped user of it.
- local glUnitShapeTextures = gl.UnitShapeTextures
- local glFeatureShapeTextures = gl.FeatureShapeTextures
- local glMaterial = gl.Material
- local glTexture = gl.Texture
- local glBeginEnd = gl.BeginEnd
- local glVertex = gl.Vertex
- local glViewport = gl.Viewport
- local glMatrixMode = gl.MatrixMode
- local glLoadIdentity = gl.LoadIdentity
- local floor = math.floor
- local max = math.max
- local min = math.min
- local tan = math.tan
- local rad = math.rad
- local unitName = {}
- local cost = {}
- local isStatic = {}
- -- What the module means by "a factory": a building that builds, whose roster contains
- -- something mobile. The engine's isFactory is also true of a Caretaker and of the Missile
- -- Silo, and a factory plate is its parent's second pad rather than a find of its own --
- -- the same gate Fac Switcher applies one block up, and for the same reason.
- local isRealFactory = {}
- -- The heaviest thing each plant can put on the field: Cyclops out of the tank factory,
- -- Grizzly out of amph, Jugglenaut out of jump, Crab, Krow, Likho -- and Detriment out of
- -- the Strider Hub, which is the same idea one tier up. Zero-K has no flag for this class,
- -- and a bare cost floor dates badly, so it is derived rather than listed: the most
- -- expensive mobile in a factory's roster is that factory's flagship by definition, and
- -- stays the right answer when the balance numbers move under it.
- local isFlagship = {}
- -- Pseudo-units: things the engine models as units that are not buildings anyone built.
- -- Zero-K flags them `dontcount`, and the whole set is a trap for a cost-based filter
- -- because several are priced absurdly to keep the engine away from them. Terraform is the
- -- one that actually bites: every patch of ground being levelled is a static, permanently
- -- unfinished 100000-metal "unit" called Terraform, so an unfiltered cost floor turns a
- -- single enemy ramp into a column of identical cams. The others -- the LOS fakeunit,
- -- Planet Wars props, the Starlight satellite, temporary shields, teleport beacons, laid
- -- mines -- are the same kind of mistake waiting to happen.
- --
- -- This is the gate Engagement Predictor already applies two blocks up, for the same
- -- reason and with the same helper: customparams arrive stringified, so a flag that is
- -- present but off is the string "false", which is perfectly truthy in Lua.
- local skipDef = {}
- -- Anti-nukes, for the map marker. Derived from the weapons rather than listed: a def is an
- -- anti-nuke if any of its weapons intercepts. In Zero-K today that is the Antinuke alone.
- local isAntinuke = {}
- local function Flag(cp, key)
- local v = cp and cp[key]
- return v ~= nil and v ~= false and v ~= "false" and v ~= "0" and v ~= 0
- end
- -- Superweapons and nukes, in one table because this block is short of locals.
- -- isSuper the five game-enders whose cams count down to completion while they go up
- -- isNuke Zero-K's own is_nuke flag, the silos the coverage check plans against
- -- cover an anti-nuke's real reach, customParams.nuke_coverage: its weapon claims a
- -- coverage of 100000, and Zero-K's gadgets use this figure instead
- -- enemy unitID -> {x, z} of every enemy silo found, kept while out of sight
- -- exposed our key buildings a known silo could hit unintercepted, as {x, y, z, r}
- local nukes = {
- isSuper = {}, isNuke = {}, cover = {}, enemy = {}, exposed = {}, next = 0,
- SUPER_NAMES = {staticnuke = true, staticheavyarty = true, zenith = true, mahlazer = true,
- raveparty = true},
- }
- -- Zero-K's own intercept test, which accounts for the missile's path and not only where it
- -- lands. A plain distance check stands in if the include is not there.
- do
- local ok, fn = pcall(function()
- return VFS.Include("LuaRules/Gadgets/Include/GetNukeIntercepted.lua", nil, VFS.GAME)
- end)
- nukes.Intercepted = (ok and type(fn) == "function") and fn
- or function(ux, uz, px, pz, tx, tz, radiusSq)
- return (tx - ux) ^ 2 + (tz - uz) ^ 2 < radiusSq
- end
- end
- for defID = 1, #UnitDefs do
- local ud = UnitDefs[defID]
- unitName[defID] = ud.humanName or ud.name or "?"
- cost[defID] = ud.metalCost or 0
- isStatic[defID] = (ud.speed or 0) == 0
- local cp = ud.customParams
- skipDef[defID] = Flag(cp, "dontcount") or Flag(cp, "is_drone")
- nukes.isSuper[defID] = nukes.SUPER_NAMES[ud.name] or nil
- nukes.isNuke[defID] = Flag(cp, "is_nuke") or nil
- nukes.cover[defID] = cp and tonumber(cp.nuke_coverage) or nil
- local weapons = ud.weapons
- for i = 1, (weapons and #weapons or 0) do
- local wd = WeaponDefs[weapons[i].weaponDef]
- if wd and (wd.interceptor or 0) ~= 0 then
- isAntinuke[defID] = true
- end
- end
- local isPlate = (cp and cp.child_of_factory) and true or false
- local opts = ud.isFactory and not isPlate and ud.buildOptions
- if opts then
- local best, bestCost
- for i = 1, #opts do
- local o = opts[i]
- local od = UnitDefs[o]
- if (od.speed or 0) > 0 then
- isRealFactory[defID] = true
- -- od.metalCost, not cost[o]: this loop fills `cost` as it goes, so a
- -- roster entry with a higher defID than the factory has not been reached.
- local c = od.metalCost or 0
- if not bestCost or c > bestCost then
- best, bestCost = o, c
- end
- end
- end
- if best then
- isFlagship[best] = true
- end
- end
- end
- -- Populated on first use: GetUnitDefDimensions builds the model's bounding box, which
- -- means loading the model. Doing all 249 at load would stall the game for a table most
- -- of whose rows will never be looked at.
- local dimsOf = {}
- local function Dims(defID)
- local d = dimsOf[defID]
- if d == nil then
- d = spGetUnitDefDimensions(defID) or false
- dimsOf[defID] = d
- end
- return d or nil
- end
- --------------------------------------------------------------------------------
- -- State (declared before `options` so the option closures can capture them)
- --------------------------------------------------------------------------------
- local cams = {} -- newest last; index is also the slot down the left edge
- local sighted = {} -- unitID -> true, sites this unit has already been shown for
- local sightedSite = {} -- "defID:gx:gz" -> true, the same by ground, for rebuilds and ghost churn
- -- Wide enough that a structure re-acquired after its ghost was dropped lands in the same
- -- cell, tight enough that two Stingers side by side do not collapse into one find.
- local SITE_GRID = 64
- local GAP = 6 -- between cams, across and down
- local CAPTION_H = 18 -- the name strip, overlaid on the bottom of the image
- local PROGRESS_H = 3 -- the build bar, sitting directly on top of that strip
- local FADE = 0.8 -- seconds of fade-out once the hold time is up
- local ASPECT = 1 -- height over width: square, so a spinning cam reads the same from every side
- local myAllyTeamID = spGetMyAllyTeamID()
- local teamAlly = {}
- local vsx, vsy = widgetHandler:GetViewSizes()
- -- The live view's own clipping planes. Near is generous because nothing is ever closer
- -- than the orbit distance; far has to clear the map's diagonal so a cam aimed across a
- -- valley does not slice the far side off.
- local NEAR_PLANE = 16
- local FAR_PLANE = 20000
- local COL_GROUND = {0.42, 0.44, 0.40} -- the terrain quad, when it arrives untextured
- local MAP_X = Game.mapSizeX
- local MAP_Z = Game.mapSizeZ
- -- Built once. Every colour in the live view is carried by the material, because
- -- GL_COLOR_MATERIAL is off in this pass and gl.Color goes nowhere.
- local MAT_LIT = {
- ambient = {0.55, 0.55, 0.55, 1.0},
- diffuse = {1.0, 1.0, 1.0, 1.0},
- emission = {0.0, 0.0, 0.0, 1.0},
- specular = {0.2, 0.2, 0.2, 1.0},
- shininess = 16.0,
- }
- local MAT_GROUND = {
- ambient = {COL_GROUND[1] * 0.6, COL_GROUND[2] * 0.6, COL_GROUND[3] * 0.6, 1.0},
- diffuse = {COL_GROUND[1], COL_GROUND[2], COL_GROUND[3], 1.0},
- emission = {0.0, 0.0, 0.0, 1.0},
- specular = {0.0, 0.0, 0.0, 1.0},
- shininess = 0.0,
- }
- local COL_FRAME = {0.62, 0.66, 0.74}
- local COL_FRAME_F = {0.95, 0.80, 0.35} -- a factory is the find worth a brighter border
- local COL_BG = {0.04, 0.05, 0.07}
- local COL_FLOOR = {0.10, 0.12, 0.15}
- local COL_CAPTION = {0, 0, 0}
- local COL_BUILD = {1.00, 0.72, 0.20} -- nanoframe amber, the same signal the game uses
- local COL_DONE = {0.45, 0.85, 0.45}
- -- Enemy anti-nukes carrying one of our map markers, by unitID: where the marker is, the
- -- percentage it states (100 once finished), and what was last seen while in sight. See
- -- MarkAntinuke below.
- local antiMarks = {}
- local DismissAll, TrimStack, EraseAntinukeMarks -- forward declarations, defined below
- -- Declared up here, not beside the draw that sets it, because the viewMode option's
- -- OnChange closes over it and the options table is built further up this block. A `local`
- -- written later would leave that closure assigning a global instead -- silently, which is
- -- the whole reason this file forward-declares rather than trusting the order it reads in.
- local liveFailed = false
- --------------------------------------------------------------------------------
- -- Options (auto-registered into the settings menu, persisted to ZK_data.lua)
- --------------------------------------------------------------------------------
- options_path = 'Settings/HUD Panels/Scout Cam'
- options_order = {'showPanel', 'viewMode', 'groundStyle', 'camZoom', 'camPitch', 'spin',
- 'showWrecks',
- 'minCost', 'mobileCost', 'flagshipCost', 'includeFactories',
- 'includeNanoframes', 'predictProgress', 'antinukeMarker',
- 'superEta', 'nukeCoverage', 'keyCost',
- 'holdTime', 'maxCams', 'camWidth', 'camColumns', 'topOffset', 'leftOffset',
- 'dismissAll'}
- options = {
- showPanel = {
- name = 'Show Scout Cam popups',
- desc = 'Open a viewer down the left edge the first time a scout finds a major enemy '
- .. 'structure. Turning this off closes any that are open and stops new ones; the '
- .. 'sites already found stay found, so turning it back on will not replay them.',
- type = 'bool',
- value = true,
- OnChange = function(self)
- if not self.value and DismissAll then
- DismissAll()
- end
- end,
- },
- viewMode = {
- name = 'Cam shows',
- desc = 'Live view renders the site itself: the real terrain, and whatever is standing '
- .. 'there right now, moving as it moves. It is a second render of the map rather '
- .. 'than a picture of one, so the ground arrives as shape without the map '
- .. 'texture on it, and only what is in line of sight can be drawn -- a cam whose '
- .. 'scout has moved on shows empty ground. Unit model falls back to turning the model '
- .. 'of the structure in front of a flat backdrop, which asks nothing of the '
- .. 'driver and always draws something. Name only drops the picture altogether: '
- .. 'each cam shrinks to a strip with the site name and, for a construction '
- .. 'site, its build progress.',
- type = 'radioButton',
- value = 'text',
- items = {
- {key = 'live', name = 'Live view', desc = 'The site as it is now.'},
- {key = 'model', name = 'Unit model', desc = 'The model of the structure, turning.'},
- {key = 'text', name = 'Name only', desc = 'The name and build progress, no view.'},
- },
- OnChange = function(self)
- -- Choosing Live again is the way to ask for another go after it has stood itself
- -- down, so a driver problem that turns out to be transient does not need a
- -- reload to clear.
- if self.value == 'live' then
- liveFailed = false
- end
- end,
- },
- groundStyle = {
- name = 'Cam ground',
- desc = 'The live view draws the terrain itself, but the map texture belongs to the '
- .. 'engine ground pass, which has finished by then -- so the paintwork has to be '
- .. 'put back by hand, from the map overview image. Which way up that image sits '
- .. 'is the one thing that cannot be worked out from here, so if the ground comes '
- .. 'out mirrored, take the other one. Flat shading skips it and gives plain lit '
- .. 'relief.',
- type = 'radioButton',
- value = 'flat',
- items = {
- {key = 'map', name = 'Map texture', desc = 'The map overview image.'},
- {key = 'map_unflip', name = 'Map texture, flipped', desc = 'The same, the other way up.'},
- {key = 'flat', name = 'Flat shading', desc = 'Lit relief, no texture.'},
- },
- },
- camZoom = {
- name = 'Cam zoom out',
- desc = 'How much ground around the site the live view takes in, as a multiple of the '
- .. 'footprint of the structure itself. 1 is tight on the building; raise it to see what '
- .. 'is parked around it.',
- type = 'number',
- min = 0.6, max = 8, step = 0.1, value = 3.5,
- },
- camPitch = {
- name = 'Cam angle above ground',
- desc = 'Degrees. Low is a dramatic ground-level shot and hides what is behind things; '
- .. 'high looks down on the site and shows the layout.',
- type = 'number',
- min = 5, max = 85, step = 1, value = 56,
- },
- showWrecks = {
- name = 'Draw wrecks in the live view',
- desc = 'Include features -- wreckage, rocks, trees -- not just units. Wreckage around '
- .. 'a structure is half the story of what happened there.',
- type = 'bool',
- value = true,
- },
- minCost = {
- name = 'Cam sites costing at least (metal)',
- desc = 'How expensive an enemy building has to be before finding it is worth a cam. '
- .. 'Low values will pop for every solar and turret on the map.',
- type = 'number',
- min = 50, max = 3000, step = 50, value = 1000,
- },
- mobileCost = {
- name = 'Cam units being built costing at least (metal)',
- desc = 'Heavy units, wherever they are made: a strider on a Strider Hub pad, a '
- .. 'Cyclops or a Grizzly in a factory. One going up is a site like any other -- '
- .. 'it is not going anywhere, and how far along it is decides the next ten '
- .. 'minutes -- so it gets a cam, with the same build bar as a building. Only '
- .. 'while it is being built: once it walks it is an army, and the other panels '
- .. 'answer armies. Set to 0 to leave this rule out; the one below still applies.',
- type = 'number',
- min = 0, max = 20000, step = 100, value = 1500,
- },
- flagshipCost = {
- name = 'Cam a factory heaviest unit if it costs at least (metal)',
- desc = 'The most expensive thing each plant can build -- Cyclops, Grizzly, '
- .. 'Jugglenaut, Crab, Krow, and Detriment out of the Strider Hub. Worked out '
- .. 'from the rosters rather than listed, so it stays right when the costs move. '
- .. 'It catches them under the floor above, which is the point, but not every '
- .. 'plant has a heavy unit to be proud of: at the default this leaves out the '
- .. 'cheap flagships -- Aspis, Impaler, Phantom -- and keeps the ones that are '
- .. 'really a decision. Set to 0 to leave this rule out.',
- type = 'number',
- min = 0, max = 5000, step = 50, value = 1000,
- },
- includeFactories = {
- name = 'Always cam factories',
- desc = 'Show a cam for any enemy factory, whatever it cost. Which factory they are '
- .. 'running is the single most useful thing a scout brings back, and the cheap '
- .. 'plants would otherwise fall under the cost floor above.',
- type = 'bool',
- value = true,
- },
- includeNanoframes = {
- name = 'Cam construction sites too',
- desc = 'Pop for a site that is still being built, not just a finished building. '
- .. 'A nanoframe is the earlier warning of the two, and it is the only one you '
- .. 'get for something that finishes out of sight. These cams carry a build bar '
- .. 'and a percentage, which keep running while you can see the site and dim to '
- .. 'the last known figure when you cannot.',
- type = 'bool',
- value = true,
- },
- predictProgress = {
- name = 'Predict progress out of sight',
- desc = 'Once a construction site leaves sight, keep its percentage moving at the '
- .. 'build rate measured while it was visible, marked with a ~. The bar shows the '
- .. 'last known figure solid and the predicted part faint. It is a guess: it cannot '
- .. 'know about builders arriving, leaving or running out of metal. Needs about a '
- .. 'second of watching to measure a rate; without one it holds the last figure.',
- type = 'bool',
- value = true,
- },
- antinukeMarker = {
- name = 'Mark enemy anti-nukes on the map',
- desc = 'Drop a map marker on every enemy anti-nuke a scout finds, labelled with how '
- .. 'far along it was and the game time it was seen at: "Enemy Antinuke 43% (12:34)", '
- .. 'or "complete". Once the scout loses sight of it, the marker is replaced with the last '
- .. 'figure seen, and it is replaced again the moment the anti-nuke is seen to '
- .. 'finish. This works whether or not the cams are on, and whatever the cost floors '
- .. 'say. "For me only" markers are private. "For my team" markers are placed like '
- .. 'a ping of your own, so your allies see them and hear them arrive. Zero-K\'s own '
- .. 'Unit Marker (Settings/Interface/Unit Marker) can also mark the Antinuke, so '
- .. 'leave that one off or you will get two markers.',
- type = 'radioButton',
- value = 'local',
- items = {
- {key = 'off', name = 'Off', desc = 'No markers.'},
- {key = 'local', name = 'For me only', desc = 'Only you see the markers.'},
- {key = 'allies', name = 'For my team', desc = 'Your allies see them too.'},
- },
- OnChange = function(self)
- if self.value == 'off' and EraseAntinukeMarks then
- EraseAntinukeMarks()
- end
- end,
- },
- superEta = {
- name = 'Show superweapon countdowns',
- desc = 'A cam for an enemy Trinity, Big Bertha, Zenith, Starlight or Disco Rave Party '
- .. 'still under construction shows how long it has left at the build rate seen, '
- .. 'as "~2:10", and stays up until it is due rather than fading with the others. '
- .. 'Out of sight the countdown runs on the predicted progress. Needs a second of '
- .. 'watching to measure a rate.',
- type = 'bool',
- value = true,
- },
- nukeCoverage = {
- name = 'Check anti-nuke coverage of key buildings',
- desc = 'Once a scout has found an enemy nuke silo, ring every key building of your '
- .. 'team that a missile from that silo could reach without passing through a '
- .. 'finished allied anti-nuke, and say how many there are above the cam stack. '
- .. 'Uses Zero-K\'s own intercept test, which follows the missile\'s path. '
- .. 'Checked every 2 seconds.',
- type = 'bool',
- value = true,
- OnChange = function(self)
- if not self.value then
- nukes.exposed = {}
- end
- end,
- },
- keyCost = {
- name = 'Key buildings cost at least (metal)',
- desc = 'Which of your buildings the anti-nuke check cares about. Factories always count.',
- type = 'number',
- min = 100, max = 10000, step = 100, value = 1000,
- },
- holdTime = {
- name = 'Keep each cam for (s)',
- desc = 'How long a cam stays up before fading out. Set to 0 to keep every cam until '
- .. 'you dismiss it with a right click.',
- type = 'number',
- min = 0, max = 120, step = 1, value = 10,
- },
- maxCams = {
- name = 'Most cams at once',
- desc = 'When a find arrives and the stack is full, the least important cam it outranks '
- .. 'makes room for it -- by metal cost, with a bonus for factories. The new find is '
- .. 'always shown. Name only strips stack a lot more of these into a screen than the '
- .. 'square views do.',
- type = 'number',
- min = 1, max = 24, step = 1, value = 8,
- OnChange = function()
- if TrimStack then TrimStack() end
- end,
- },
- camWidth = {
- name = 'Cam size (px)',
- desc = 'Each cam is square, so this is both its width and its height.',
- type = 'number',
- min = 60, max = 400, step = 10, value = 150,
- },
- camColumns = {
- name = 'Cam columns',
- desc = 'How many cams sit side by side before the stack starts a new row. The most '
- .. 'important find is top left, the next one to its right.',
- type = 'number',
- min = 1, max = 4, step = 1, value = 2,
- },
- topOffset = {
- name = 'Cam stack top offset (px)',
- desc = 'How far down the left edge the first cam starts, so the stack clears whatever '
- .. 'you keep in the top-left corner.',
- type = 'number',
- min = 0, max = 800, step = 5, value = 105,
- },
- leftOffset = {
- name = 'Cam stack left offset (px)',
- type = 'number',
- min = 0, max = 400, step = 5, value = 165,
- },
- spin = {
- name = 'Orbit the cam',
- desc = 'Turn each cam slowly around its site, so it is read from more than one side. '
- .. 'Off holds it at a fixed three-quarters angle.',
- type = 'bool',
- value = false,
- },
- dismissAll = {
- name = 'Dismiss all cams',
- desc = 'Close everything currently on screen. The sites stay found.',
- type = 'button',
- OnChange = function()
- if DismissAll then DismissAll() end
- end,
- },
- }
- --------------------------------------------------------------------------------
- -- Tracking
- --------------------------------------------------------------------------------
- local function RebuildTeamMap()
- teamAlly = {}
- local teams = spGetTeamList()
- if teams then
- for i = 1, #teams do
- local t = teams[i]
- teamAlly[t] = select(6, spGetTeamInfo(t, false))
- end
- end
- myAllyTeamID = spGetMyAllyTeamID()
- end
- local function IsEnemyTeam(unitTeam)
- if not unitTeam then
- return false
- end
- -- Gaia owns the critters and the map's own units; a cam for one of those would be
- -- a find about nobody. Asked of the engine rather than cached, because this
- -- module keeps no team state of its own beyond the ally map.
- if unitTeam == Spring.GetGaiaTeamID() then
- return false
- end
- local at = teamAlly[unitTeam]
- if at == nil then
- at = select(6, spGetTeamInfo(unitTeam, false))
- teamAlly[unitTeam] = at
- end
- return at ~= myAllyTeamID
- end
- -- Enemy, and expensive enough to be a decision rather than a building. Asked fresh each
- -- time rather than baked into a table at load, because the cost floors are settings and a
- -- player who drops one mid-game means it for the next find, not for the next reload.
- --
- -- `building` is what lets a mobile unit through. A Detriment going up on a Strider Hub pad
- -- is a site in every sense that matters here -- it is not going anywhere, it is the most
- -- expensive thing on the map, and how far along it is decides the next ten minutes. Once
- -- it walks off the pad it stops being a site and becomes an army, which is what the other
- -- four panels are for; so a strider already on its feet does not open a cam.
- -- The flagship rule, as its own question because the cheap pre-filter in Consider asks it
- -- too and the two must never disagree about what qualifies.
- local function IsFlagshipFind(defID)
- if not isFlagship[defID] then
- return false
- end
- local floor = options.flagshipCost.value
- return floor > 0 and cost[defID] >= floor
- end
- local function IsMajorDef(defID, building)
- if not defID or skipDef[defID] then
- return false
- end
- if not isStatic[defID] then
- if not building then
- return false
- end
- if IsFlagshipFind(defID) then
- return true
- end
- local floor = options.mobileCost.value
- return floor > 0 and cost[defID] >= floor
- end
- if isRealFactory[defID] and options.includeFactories.value then
- return true
- end
- return cost[defID] >= options.minCost.value
- end
- -- What the stack is ordered by, and what it drops first when it is full. Metal is most of
- -- the answer -- it is what the enemy chose to spend, which is what a scout is really
- -- reporting -- but a factory is worth more than its price tag says: which plant they are
- -- running is the single most useful fact a scout brings home, and the cheapest plants cost
- -- less than a heavy turret. The bonus is sized to put any factory above the ordinary run
- -- of buildings while leaving the genuine game-enders -- nukes, striders -- above it.
- local FACTORY_BONUS = 1500
- local function Importance(defID)
- local v = cost[defID] or 0
- if isRealFactory[defID] then
- v = v + FACTORY_BONUS
- end
- return v
- end
- local function SiteKey(defID, x, z)
- return defID .. ":" .. floor(x / SITE_GRID) .. ":" .. floor(z / SITE_GRID)
- end
- DismissAll = function()
- for i = #cams, 1, -1 do
- cams[i] = nil
- end
- end
- -- The stack reads top-down in order of importance, so the thing worth reacting to is the
- -- one nearest the top of the screen however late it was found. Ties go to the newer find,
- -- and unitID settles the rest: table.sort needs a strict order and will raise on a
- -- comparator that ever says two entries are both smaller.
- local function SortCams()
- table.sort(cams, function(a, b)
- if a.score ~= b.score then return a.score > b.score end
- if a.born ~= b.born then return a.born > b.born end
- return a.unitID > b.unitID
- end)
- end
- -- Bring the stack back within the limit by dropping the least important of what is on it,
- -- oldest first among equals. `keep` is exempt, which is how a new find is guaranteed a
- -- slot: it can push something out but can never be the thing pushed out, so the cam that
- -- just opened is always one you actually get to see.
- TrimStack = function(keep)
- local limit = options.maxCams.value
- while #cams > limit do
- local at
- for i = 1, #cams do
- local c = cams[i]
- if c ~= keep and (at == nil or c.score < cams[at].score
- or (c.score == cams[at].score and c.born < cams[at].born)) then
- at = i
- end
- end
- if not at then
- break -- everything left is exempt
- end
- table.remove(cams, at)
- end
- end
- -- Every path into the module funnels here, and this is the only place that decides a find
- -- is new. `quiet` is the seeding pass: it marks what is already on screen as found without
- -- opening anything, so enabling the widget in the eightieth minute does not dump the whole
- -- map into the stack.
- local function Consider(unitID, unitDefID, unitTeam, quiet)
- if not unitID or sighted[unitID] then
- return
- end
- unitDefID = unitDefID or spGetUnitDefID(unitID)
- if not unitDefID or skipDef[unitDefID] then
- return
- end
- -- The cheap half of the test, first: this runs for every unit that enters line of
- -- sight, which in a real game is most of them. A mobile unit cannot qualify at all
- -- unless it is dear enough to be a strider, and that is one table lookup.
- local mobile = not isStatic[unitDefID]
- if mobile and not IsFlagshipFind(unitDefID) then
- local floor = options.mobileCost.value
- if floor <= 0 or cost[unitDefID] < floor then
- return
- end
- end
- unitTeam = unitTeam or spGetUnitTeam(unitID)
- if not IsEnemyTeam(unitTeam) then
- return
- end
- local x, y, z = spGetUnitPosition(unitID)
- if not x then
- -- A radar blip has a unitID and no position we can point a camera at. It is also
- -- not marked as found: the scout that actually gets eyes on it still deserves
- -- its cam.
- return
- end
- -- Nil when the unit is out of LOS rather than when it is finished, so a ghost reads as
- -- complete. That is the right way round: a ghost is the memory of something we saw.
- -- The second return is how far along it is, which is what the cam's bar tracks.
- local building, progress = spGetUnitIsBeingBuilt(unitID)
- building = building or false
- if not IsMajorDef(unitDefID, building) then
- return
- end
- -- `includeNanoframes` is about buildings: whether a site counts before it is finished.
- -- A strider is only ever a site while it is being built, so it is governed by its own
- -- cost floor instead and this gate does not apply to it.
- if building and not mobile and not options.includeNanoframes.value and not quiet then
- -- Skipped, and deliberately not filed. With construction sites turned off, the cam
- -- this site is owed is the one for the finished building, and filing the nanoframe
- -- here would swallow it -- leaving a site that was scouted early showing nothing at
- -- all, ever. The seeding pass is the exception: what was on screen when the widget
- -- loaded is old news whatever state it is in.
- return
- end
- sighted[unitID] = true
- -- Ground identifies a building and nothing else. A strider is a thing, not a place --
- -- two of them built on the same pad are two finds -- so a mobile is deduplicated by
- -- unitID alone.
- if not mobile then
- local key = SiteKey(unitDefID, x, z)
- if sightedSite[key] then
- return -- same ground, same building: this is the find we already showed
- end
- sightedSite[key] = true
- end
- if quiet or not options.showPanel.value then
- return
- end
- local cam = {
- unitID = unitID,
- defID = unitDefID,
- teamID = unitTeam,
- x = x, y = y, z = z,
- mobile = mobile,
- score = Importance(unitDefID),
- factory = isRealFactory[unitDefID] or false,
- super = nukes.isSuper[unitDefID],
- name = unitName[unitDefID],
- -- nil for something found already finished, which is what keeps the bar off it.
- progress = building and (progress or 0) or nil,
- polling = building, -- keep asking, until it tops out or we lose sight of it
- stale = false,
- born = os.clock(),
- yaw = 35, -- degrees; the spin starts from three-quarters view, not head-on
- }
- cams[#cams + 1] = cam
- TrimStack(cam)
- SortCams()
- end
- --------------------------------------------------------------------------------
- -- Anti-nuke markers
- --
- -- These are separate from the cams. A cam holds your attention for a few seconds, while a
- -- marker stays on the map for when a nuke is being planned. Two things are copied from
- -- Zero-K's own unit_marker.lua. The engine cannot edit a marker, so a marker is replaced
- -- rather than changed. And the replacement goes down half a second after the old one is
- -- erased, because an erase and an add in the same frame leave the new marker gone within
- -- a second.
- --
- -- When the marker is replaced: when the anti-nuke is seen to finish, which is the one
- -- change worth saying at once, and when the scout loses sight of it, so the marker carries
- -- the last figure seen rather than the first. Nothing is replaced while it stays in sight,
- -- because the cam is already showing the live figure. A team marker pings every ally, and
- -- a scout on the edge of sight must not ping them once a second.
- --------------------------------------------------------------------------------
- local MARK_DEFER = 0.5 -- s between erasing a marker and putting its replacement down
- local MARK_SPACING = 10 -- s, the least time between two replacements for one anti-nuke
- local nextMarkPoll = 0
- -- What was seen, kept apart from what the marker says (`pct`, `text`) until it is replaced.
- -- Short of 100 until the engine says it is finished: 99.6% rounds to 99, not to done.
- local function ObserveAntinuke(m, building, progress, x, y, z)
- m.seenPct = building and min(99, floor((progress or 0) * 100)) or 100
- local s = floor(Spring.GetGameSeconds and Spring.GetGameSeconds() or 0)
- m.seenAt = string.format("%d:%02d", floor(s / 60), s % 60)
- m.seenX, m.seenY, m.seenZ = x, y, z
- end
- local function PlaceAntinukeMark(m, now, defer)
- if m.text and not m.due then
- spMarkerErasePosition(m.x, m.y, m.z, nil, m.localOnly)
- end
- m.x, m.y, m.z = m.seenX, m.seenY, m.seenZ
- m.pct = m.seenPct
- m.text = "Enemy " .. unitName[m.defID]
- .. ((m.pct >= 100) and " complete" or (" " .. m.pct .. "%"))
- .. " (" .. m.seenAt .. ")"
- m.localOnly = options.antinukeMarker.value ~= 'allies'
- m.markedAt = now
- if defer then
- m.due = now + MARK_DEFER
- else
- spMarkerAddPoint(m.x, m.y, m.z, m.text, m.localOnly)
- m.due = nil
- end
- end
- -- First sight. Every callin that can hand us a unit comes through here. After that, the
- -- poll below owns the marker.
- local function MarkAntinuke(unitID, unitDefID, unitTeam)
- if options.antinukeMarker.value == 'off' or antiMarks[unitID] then
- return
- end
- unitDefID = unitDefID or spGetUnitDefID(unitID)
- if not (unitDefID and isAntinuke[unitDefID]) then
- return
- end
- -- A spectator sees all of it anyway, and a team marker from one goes to the spectators.
- if Spring.GetSpectatingState() or not IsEnemyTeam(unitTeam or spGetUnitTeam(unitID)) then
- return
- end
- -- Nil for a radar blip. There is no figure to give yet, and the scout that gets eyes on
- -- it will come back through here.
- local building, progress = spGetUnitIsBeingBuilt(unitID)
- local x, y, z = spGetUnitPosition(unitID)
- if building == nil or not x then
- return
- end
- local m = {defID = unitDefID, inSight = true}
- ObserveAntinuke(m, building, progress, x, y, z)
- antiMarks[unitID] = m
- PlaceAntinukeMark(m, os.clock(), false)
- end
- local function PollAntinukeMarks(now)
- if now < nextMarkPoll or not next(antiMarks) then
- return
- end
- nextMarkPoll = now + 0.25
- for unitID, m in pairs(antiMarks) do
- if m.due and now >= m.due then
- spMarkerAddPoint(m.x, m.y, m.z, m.text, m.localOnly)
- m.due = nil
- end
- local building, progress = spGetUnitIsBeingBuilt(unitID)
- local x, y, z = spGetUnitPosition(unitID)
- if building ~= nil and x then
- ObserveAntinuke(m, building, progress, x, y, z)
- m.inSight = true
- if not building and m.pct < 100 then
- PlaceAntinukeMark(m, now, true)
- end
- else
- -- Out of sight. `stale` stays set until the marker agrees with the last thing
- -- seen, so a loss of sight inside MARK_SPACING is caught up afterwards rather than dropped.
- if m.inSight then
- m.stale = true
- end
- m.inSight = false
- if m.stale and m.seenPct == m.pct then
- m.stale = false
- elseif m.stale and not m.due and now >= m.markedAt + MARK_SPACING then
- PlaceAntinukeMark(m, now, true)
- m.stale = false
- end
- end
- end
- end
- EraseAntinukeMarks = function()
- for _, m in pairs(antiMarks) do
- if not m.due then
- spMarkerErasePosition(m.x, m.y, m.z, nil, m.localOnly)
- end
- end
- antiMarks = {}
- end
- --------------------------------------------------------------------------------
- -- Superweapons
- --
- -- A cam for a superweapon under construction counts down to completion at the build rate
- -- the predicted-progress code already measures, and is held on screen until it is due:
- -- ten seconds of "Trinity 12%" is the least useful way to learn the enemy is building one.
- -- Separately, once an enemy silo is known, every key building of ours a missile from it
- -- could reach unintercepted is ringed on the map.
- --------------------------------------------------------------------------------
- -- Game seconds until the site is finished, or nil with nothing to go on: no rate measured
- -- yet, a rate that is not climbing, or a site that is done.
- local function SuperEta(cam)
- local p = cam.progress
- if not (p and p < 1 and cam.polling and cam.rate and cam.rate > 0) then
- return nil
- end
- local cur = (cam.stale and options.predictProgress.value and cam.predicted) or p
- if cur >= 1 then
- return nil
- end
- return (1 - cur) / cam.rate
- end
- -- Held while still going up as far as we know. The prediction reaching 100% releases it
- -- too, since a site that died out of sight would otherwise be held for the whole game.
- local function Pinned(cam)
- return cam.super and options.superEta.value and cam.polling
- and not (cam.stale and cam.predicted and cam.predicted >= 1)
- end
- local function NoteNuke(unitID, unitDefID, unitTeam)
- unitDefID = unitDefID or spGetUnitDefID(unitID)
- if not (unitDefID and nukes.isNuke[unitDefID]) or nukes.enemy[unitID] then
- return
- end
- if not IsEnemyTeam(unitTeam or spGetUnitTeam(unitID)) then
- return
- end
- local x, _, z = spGetUnitPosition(unitID)
- if x then
- nukes.enemy[unitID] = {x = x, z = z}
- end
- end
- local function CheckCoverage()
- nukes.exposed = {}
- if not options.nukeCoverage.value or not next(nukes.enemy) then
- return
- end
- -- A silo that died out of sight leaves a record behind. Seeing the ground it stood on
- -- with nothing there says so; ZK's Antinuke Coverage widget drops them the same way.
- for uID, n in pairs(nukes.enemy) do
- if not spGetUnitDefID(uID) and select(2, Spring.GetPositionLosState(n.x, 0, n.z)) then
- nukes.enemy[uID] = nil
- end
- end
- local antis, keys = {}, {}
- local keyCost = options.keyCost.value
- local units = spGetAllUnits()
- for i = 1, (units and #units or 0) do
- local uID = units[i]
- if Spring.GetUnitAllyTeam(uID) == myAllyTeamID then
- local defID = spGetUnitDefID(uID)
- local finished = defID and not spGetUnitIsBeingBuilt(uID)
- if finished and nukes.cover[defID] then
- local x, _, z = spGetUnitPosition(uID)
- antis[#antis + 1] = {x, z, nukes.cover[defID] ^ 2}
- elseif finished and isStatic[defID] and not skipDef[defID]
- and (cost[defID] >= keyCost or isRealFactory[defID]) then
- keys[#keys + 1] = uID
- end
- end
- end
- for i = 1, #keys do
- local uID = keys[i]
- local x, y, z = spGetUnitPosition(uID)
- local open = false
- for _, n in pairs(nukes.enemy) do
- local stopped = false
- for j = 1, #antis do
- local a = antis[j]
- if nukes.Intercepted(a[1], a[2], n.x, n.z, x, z, a[3]) then
- stopped = true
- break
- end
- end
- if not stopped then
- open = true
- break
- end
- end
- if open then
- local d = Dims(spGetUnitDefID(uID))
- local r = d and max(d.maxx - d.minx, d.maxz - d.minz) * 0.6 or 60
- nukes.exposed[#nukes.exposed + 1] = {x, y, z, max(40, r)}
- end
- end
- end
- --------------------------------------------------------------------------------
- -- Drawing
- --------------------------------------------------------------------------------
- -- Screen rectangle of the i-th cam, in GL's own frame: origin bottom left, y upwards.
- -- Both the draw and the click test read it from here, so a cam can never be drawn
- -- somewhere other than where it can be clicked.
- local function CamRect(i)
- local w = options.camWidth.value
- local col, row
- if options.viewMode.value == 'text' then
- -- Name only is just the chrome: the caption strip with the build bar on its top
- -- edge. Strips are small enough that one column holds a lot of them, so they fill
- -- a column top to bottom and only start the next when the screen runs out.
- local h = CAPTION_H + PROGRESS_H
- local perCol = max(1, floor((vsy - options.topOffset.value + GAP) / (h + GAP)))
- col = floor((i - 1) / perCol)
- row = (i - 1) % perCol
- local x0 = options.leftOffset.value + col * (w + GAP)
- local yTop = vsy - options.topOffset.value - row * (h + GAP)
- return x0, yTop - h, x0 + w, yTop
- end
- local h = floor(w * ASPECT)
- -- Filled a row at a time, so the stack's order -- most important first -- reads left to
- -- right and then down, and the top row is always the two finds that matter most.
- local cols = options.camColumns.value
- col = (i - 1) % cols
- row = floor((i - 1) / cols)
- local x0 = options.leftOffset.value + col * (w + GAP)
- local yTop = vsy - options.topOffset.value - row * (h + GAP)
- return x0, yTop - h, x0 + w, yTop
- end
- -- How much ground the cam takes in, in elmos. Derived from the structure's own footprint
- -- so a Solar and a Strider Hub both fill their frame, then opened up by the zoom setting.
- local function CamRadius(cam)
- local d = Dims(cam.defID)
- local hSize = d and max(d.maxx - d.minx, d.maxz - d.minz) or 64
- return max(40, hSize * 0.9) * options.camZoom.value
- end
- --------------------------------------------------------------------------------
- -- The live view
- --
- -- This is a second render of the map, not a picture of one. The engine gives LuaUI no way
- -- to point a second camera at something -- SetCameraState and the dolly API all move the
- -- one real camera, and RenderToTexture has no world pass to put inside it -- but it does
- -- expose the pieces the world pass is built from. So the camera is built by hand:
- -- gl.Frustum for the projection, an orbit around the site for the view, gl.Viewport and
- -- gl.Scissor to confine the whole thing to the cam's box, and then the actual live scene
- -- is drawn into it: gl.DrawGroundQuad for the terrain, gl.Unit and gl.Feature for whatever
- -- is standing there *now*. Units move in it, turrets turn in it, a nanoframe fills in.
- --
- -- Two things follow from the engine's own passes not being involved, and neither is a bug
- -- to be fixed here:
- --
- -- The terrain arrives as geometry, not as the map's texture. The ground shader and its
- -- texture belong to the engine's ground pass, which is long finished by the time this
- -- runs, so the quad is lit and coloured by the state set below. Relief reads; the map's
- -- paintwork does not.
- --
- -- Only what is in LOS draws, because only what is in LOS can be asked for. A cam whose
- -- scout has moved on shows the ground and nothing on it. That is the honest answer, and
- -- it is the same thing the main view would show you if you flew there.
- --
- -- It runs in DrawScreenEffects rather than DrawWorld deliberately. DrawWorld would put it
- -- inside the engine's own pass, with the right state already bound -- but the engine is
- -- not finished at that point, and projectiles, particles and LUPS would all paint over a
- -- box we had just cleared. DrawScreenEffects is the first callin after the world is
- -- complete.
- --------------------------------------------------------------------------------
- local FOV_TAN = tan(rad(45) * 0.5) -- half-angle of a 45-degree vertical field of view
- local function DrawLive(cam, i)
- local x0, y0, x1, y1 = CamRect(i)
- local w, h = x1 - x0, y1 - y0
- local tx, ty, tz = cam.x, cam.y, cam.z
- local radius = CamRadius(cam)
- -- Far enough back that a sphere of `radius` about the site fits the shorter axis.
- local dist = radius / FOV_TAN
- glViewport(x0, y0, w, h)
- glScissor(x0, y0, w, h)
- -- Both buffers, and both only inside the scissor: the colour clear is this cam's
- -- backdrop, and the depth clear is what stops the world's own depth values -- written
- -- by a camera that has nothing to do with this one -- rejecting every fragment.
- gl.Clear(GL.COLOR_BUFFER_BIT, COL_BG[1], COL_BG[2], COL_BG[3], 1)
- gl.Clear(GL.DEPTH_BUFFER_BIT, 1)
- glMatrixMode(GL.PROJECTION)
- glPushMatrix()
- glLoadIdentity()
- local top = NEAR_PLANE * FOV_TAN
- local right = top * (w / h)
- gl.Frustum(-right, right, -top, top, NEAR_PLANE, FAR_PLANE)
- glMatrixMode(GL.MODELVIEW)
- glPushMatrix()
- glLoadIdentity()
- -- Read bottom-up, which is the order these actually apply in: put the site at the
- -- origin, spin it by the cam's yaw, tip it down by the pitch, then back the camera off
- -- along its own -Z. An orbit, in other words, with the site at the centre of it.
- glTranslate(0, 0, -dist)
- glRotate(options.camPitch.value, 1, 0, 0)
- glRotate(cam.yaw, 0, 1, 0)
- glTranslate(-tx, -ty, -tz)
- gl.DepthTest(true)
- gl.DepthMask(true)
- gl.Culling(GL.BACK)
- gl.Lighting(true)
- gl.Blending(false)
- -- The ground. Colour here comes from the *material*, never from gl.Color: with
- -- GL_COLOR_MATERIAL off -- and nothing in this pass turns it on -- a glColor is simply
- -- ignored and the material's diffuse is what reaches the screen. A white diffuse and a
- -- white ground are the same bug.
- local style = options.groundStyle.value
- local textured = (style ~= 'flat') and glTexture("$minimap") or false
- if textured then
- glMaterial(MAT_LIT)
- -- The map's own top-down image, cropped to the patch under the cam. `$minimap` is
- -- the whole map in one texture, so the crop is just the site's extent expressed as
- -- a fraction of the map, and the only real question is which way up it is -- hence
- -- the setting rather than a guess.
- local u0 = (tx - radius) / MAP_X
- local u1 = (tx + radius) / MAP_X
- local v0 = (tz - radius) / MAP_Z
- local v1 = (tz + radius) / MAP_Z
- if style == 'map' then
- v0, v1 = 1 - v0, 1 - v1
- end
- gl.DrawGroundQuad(tx - radius, tz - radius, tx + radius, tz + radius, true, true,
- u0, v0, u1, v1)
- glTexture(false)
- else
- glMaterial(MAT_GROUND)
- -- Normals on, texture coordinates off: nothing is bound to put them to.
- gl.DrawGroundQuad(tx - radius, tz - radius, tx + radius, tz + radius, true, false)
- end
- -- Units and wrecks. gl.Unit with the raw flag is what keeps them in *this* camera --
- -- the engine's own unit path binds a shader that reads the engine's view and
- -- projection, and would draw them where the real camera thinks they are. The cost of
- -- the raw path is that it binds nothing, so the model textures have to be bound by
- -- hand around it; without this the models arrive as white geometry.
- glMaterial(MAT_LIT)
- local us = spGetUnitsInCylinder(tx, tz, radius)
- if us then
- -- Textures belong to the model, so they are bound per unitDef rather than per unit,
- -- and only when the def actually changes. A base is mostly the same few types, so
- -- this collapses to a handful of binds however many units are standing in it.
- local bound
- for j = 1, #us do
- local uid = us[j]
- local udid = spGetUnitDefID(uid)
- if udid then
- if udid ~= bound then
- if bound then
- glUnitShapeTextures(bound, false)
- end
- glUnitShapeTextures(udid, true)
- bound = udid
- end
- glUnit(uid, true)
- end
- end
- if bound then
- glUnitShapeTextures(bound, false)
- end
- end
- if options.showWrecks.value then
- local fs = spGetFeaturesInCylinder(tx, tz, radius)
- if fs then
- local bound
- for j = 1, #fs do
- local fid = fs[j]
- local fdid = spGetFeatureDefID(fid)
- if fdid then
- if fdid ~= bound then
- if bound then
- glFeatureShapeTextures(bound, false)
- end
- glFeatureShapeTextures(fdid, true)
- bound = fdid
- end
- glFeature(fid, true)
- end
- end
- if bound then
- glFeatureShapeTextures(bound, false)
- end
- end
- end
- gl.Lighting(false)
- gl.Culling(false)
- gl.DepthMask(false)
- gl.DepthTest(false)
- gl.Blending(true)
- glMatrixMode(GL.PROJECTION)
- glPopMatrix()
- glMatrixMode(GL.MODELVIEW)
- glPopMatrix()
- glScissor(false)
- glViewport(0, 0, vsx, vsy)
- glColor(1, 1, 1, 1)
- end
- --------------------------------------------------------------------------------
- -- The model view
- --
- -- The fallback, and the thing to reach for if the live view comes out wrong on a given
- -- driver: no second camera, no world geometry, just the structure's own model turned in
- -- front of a flat backdrop. The recipe is Zero-K's own gui_unitgroups.lua, front-face
- -- culling included -- the screen projection mirrors the world one, so the winding the
- -- model was built with arrives reversed.
- --------------------------------------------------------------------------------
- local function SetupModelDrawing()
- gl.DepthTest(true)
- gl.DepthMask(true)
- gl.Culling(GL.FRONT)
- gl.Lighting(true)
- gl.Blending(false)
- gl.Material({
- ambient = {0.35, 0.35, 0.35, 1.0},
- diffuse = {1.0, 1.0, 1.0, 1.0},
- emission = {0.0, 0.0, 0.0, 1.0},
- specular = {0.2, 0.2, 0.2, 1.0},
- shininess = 16.0,
- })
- end
- local function RevertModelDrawing()
- gl.Blending(true)
- gl.Lighting(false)
- gl.Culling(false)
- gl.DepthMask(false)
- gl.DepthTest(false)
- end
- -- Scale the model so its longest side very nearly fills the box, then bring its bounding
- -- box centre to the origin.
- local function FitUnitDef(defID, boxW, boxH)
- local d = Dims(defID)
- if not d then
- return false
- end
- local xSize = d.maxx - d.minx
- local ySize = d.maxy - d.miny
- local zSize = d.maxz - d.minz
- local hSize = max(xSize, zSize)
- if hSize <= 0 or ySize <= 0 then
- return false
- end
- -- The model is turned towards the viewer before this runs, so its height on screen is
- -- somewhere between ySize and the diagonal. Fitting against both dimensions and taking
- -- the tighter of the two keeps a flat, wide building and a tall thin one equally inside
- -- the frame.
- local scale = min(boxW / hSize, boxH / (ySize + hSize * 0.45)) * 0.82
- glScale(scale, scale, scale)
- glTranslate(-0.5 * (d.maxx + d.minx), -0.5 * (d.maxy + d.miny), -0.5 * (d.maxz + d.minz))
- return true
- end
- local function DrawModel(cam, i)
- local x0, y0, x1, y1 = CamRect(i)
- local w, h = x1 - x0, y1 - y0
- glTexture(false)
- glColor(COL_BG[1], COL_BG[2], COL_BG[3], 0.88)
- glRect(x0, y0, x1, y1)
- glColor(COL_FLOOR[1], COL_FLOOR[2], COL_FLOOR[3], 0.9)
- glRect(x0, y0, x1, y0 + floor(h * 0.30))
- if not Dims(cam.defID) then
- return
- end
- gl.Clear(GL.DEPTH_BUFFER_BIT)
- SetupModelDrawing()
- glScissor(x0, y0, w, h)
- glPushMatrix()
- -- Below centre: a building is widest at its base, and leaving more room above it keeps
- -- the caption strip off the model.
- glTranslate(x0 + w * 0.5, y0 + h * 0.44, 0)
- glRotate(22, 1, 0, 0)
- glRotate(cam.yaw, 0, 1, 0)
- if FitUnitDef(cam.defID, w, h) then
- glUnitShape(cam.defID, cam.teamID)
- end
- glPopMatrix()
- glScissor(false)
- RevertModelDrawing()
- glColor(1, 1, 1, 1)
- end
- --------------------------------------------------------------------------------
- -- Chrome
- --
- -- The name strip and the frame, drawn over whichever view filled the box. Kept apart from
- -- both because the live view runs a callin earlier, in DrawScreenEffects, and 2D over a
- -- custom viewport and projection is not worth the matrix juggling.
- --------------------------------------------------------------------------------
- local function DrawChrome(cam, i, now)
- local x0, y0, x1, y1 = CamRect(i)
- local w = x1 - x0
- local alpha = 1
- local hold = options.holdTime.value
- if hold > 0 then
- local over = (now - (cam.heldAt or cam.born)) - hold
- if over > 0 then
- alpha = max(0, 1 - over / FADE)
- end
- end
- glTexture(false)
- glColor(COL_CAPTION[1], COL_CAPTION[2], COL_CAPTION[3], 0.66 * alpha)
- glRect(x0, y0, x1, y0 + CAPTION_H)
- -- How far along the construction is, on the strip's top edge. Only a site that was
- -- still going up when it was found carries one, so a finished building gets its name
- -- and nothing else. The bar stays at full once it tops out rather than vanishing: that
- -- it finished while the cam was watching is the thing worth having seen.
- local p = cam.progress
- if p then
- local done = p >= 1
- local by = y0 + CAPTION_H
- glColor(0, 0, 0, 0.55 * alpha)
- glRect(x0, by, x1, by + PROGRESS_H)
- local c = done and COL_DONE or COL_BUILD
- -- Dimmed while we cannot see it: the figure is the last one we had, not the one
- -- that is true now, and it should not read as live.
- local a = alpha * (cam.stale and 0.45 or 1)
- glColor(c[1], c[2], c[3], a)
- glRect(x0, by, x0 + w * (p > 1 and 1 or p), by + PROGRESS_H)
- local pp = cam.stale and cam.predicted
- if pp and pp > p and options.predictProgress.value then
- glColor(c[1], c[2], c[3], alpha * 0.2)
- glRect(x0 + w * p, by, x0 + w * pp, by + PROGRESS_H)
- end
- end
- local label = cam.name
- local pp = p and cam.stale and cam.predicted
- if pp and pp > p and options.predictProgress.value then
- label = label .. (pp >= 1 and " ~complete"
- or (" ~%d%%"):format(floor(pp * 100 + 0.5)))
- elseif p then
- label = label .. (p >= 1 and " complete"
- or (" %d%%"):format(floor(p * 100 + 0.5)))
- end
- local eta = cam.super and options.superEta.value and SuperEta(cam)
- if eta then
- label = label .. (" ~%d:%02d"):format(floor(eta / 60), floor(eta % 60))
- end
- glColor(1, 1, 1, alpha)
- glText(label, x0 + w * 0.5, y0 + 5, 11, "co")
- local fc = cam.factory and COL_FRAME_F or COL_FRAME
- glColor(fc[1], fc[2], fc[3], 0.9 * alpha)
- glBeginEnd(GL.LINE_LOOP, function()
- glVertex(x0 + 0.5, y0 + 0.5)
- glVertex(x1 - 0.5, y0 + 0.5)
- glVertex(x1 - 0.5, y1 - 0.5)
- glVertex(x0 + 0.5, y1 - 0.5)
- end)
- glColor(1, 1, 1, 1)
- end
- -- Set once if the live path ever throws. The live view is the one part of this module
- -- that cannot be checked outside a running game -- it reaches into GL calls whose exact
- -- arguments the engine is the only authority on -- and an unguarded error there does not
- -- merely break the cams: cawidgets removes the whole widget, taking Enemy Intel, Build
- -- Proposer, Fac Switcher and Engagement Predictor down with it. So the live draw is
- -- guarded, and a failure stands the live path down for the session and falls back to the
- -- model view rather than repeating the error every frame. `liveFailed` itself is declared
- -- with the rest of the state, where the option that clears it can see it.
- local function ResetLiveState()
- pcall(function()
- glScissor(false)
- glViewport(0, 0, vsx, vsy)
- gl.ResetState()
- gl.ResetMatrices()
- end)
- end
- -- True when the stack should be on screen at all. Asked by all three draw callins, so a
- -- hidden cam cannot leave half its chrome behind.
- local function Showing()
- return #cams > 0 and options.showPanel.value and not spIsGUIHidden()
- end
- -- A cam whose box has run off the screen is skipped rather than clamped: the settings that
- -- put it there are the place to pull it back, and silently stacking cams on top of each
- -- other would hide the fact that the offsets are wrong.
- local function OnScreen(i)
- local x0, y0, x1 = CamRect(i)
- return y0 >= 0 and x1 <= vsx
- end
- --------------------------------------------------------------------------------
- -- Callins
- --------------------------------------------------------------------------------
- local lastClock = os.clock()
- function M:Initialize()
- vsx, vsy = widgetHandler:GetViewSizes()
- RebuildTeamMap()
- -- Everything already standing in LOS predates the widget, so it is filed as found
- -- without opening a cam for it. Loading mid-game is not a scouting run.
- local units = spGetAllUnits()
- if units then
- for i = 1, #units do
- local uID = units[i]
- Consider(uID, spGetUnitDefID(uID), spGetUnitTeam(uID), true)
- NoteNuke(uID)
- end
- end
- end
- function M:Shutdown()
- DismissAll()
- sighted, sightedSite = {}, {}
- nukes.enemy, nukes.exposed = {}, {}
- end
- function M:ViewResize(newX, newY)
- vsx, vsy = newX, newY
- end
- function M:UnitEnteredLos(unitID)
- Consider(unitID)
- MarkAntinuke(unitID)
- NoteNuke(unitID)
- end
- function M:UnitCreated(unitID, unitDefID, unitTeam)
- Consider(unitID, unitDefID, unitTeam)
- MarkAntinuke(unitID, unitDefID, unitTeam)
- NoteNuke(unitID, unitDefID, unitTeam)
- end
- function M:UnitFinished(unitID, unitDefID, unitTeam)
- Consider(unitID, unitDefID, unitTeam)
- MarkAntinuke(unitID, unitDefID, unitTeam)
- NoteNuke(unitID, unitDefID, unitTeam)
- end
- function M:UnitDestroyed(unitID)
- sighted[unitID] = nil -- the ground stays keyed, so a rebuild on the spot is still old news
- -- A dead anti-nuke is worth knowing too, but a marker that still says it is there is
- -- worse than none.
- local m = antiMarks[unitID]
- if m then
- if not m.due then
- spMarkerErasePosition(m.x, m.y, m.z, nil, m.localOnly)
- end
- antiMarks[unitID] = nil
- end
- nukes.enemy[unitID] = nil
- -- A site destroyed in front of us has nothing left to count down to.
- for i = 1, #cams do
- if cams[i].unitID == unitID and cams[i].polling then
- cams[i].polling = false
- end
- end
- end
- function M:PlayerChanged()
- -- Fires for any player's change, so only act when our own viewpoint moved. Looking
- -- from a different side makes every find so far somebody else's, and the cams on
- -- screen are answers to a question we are no longer asking.
- local prevAllyTeam = myAllyTeamID
- RebuildTeamMap()
- if myAllyTeamID ~= prevAllyTeam then
- DismissAll()
- EraseAntinukeMarks()
- sighted, sightedSite = {}, {}
- nukes.enemy, nukes.exposed = {}, {}
- local units = spGetAllUnits()
- if units then
- for i = 1, #units do
- local uID = units[i]
- Consider(uID, spGetUnitDefID(uID), spGetUnitTeam(uID), true)
- NoteNuke(uID)
- end
- end
- end
- end
- function M:Update()
- local now = os.clock()
- local dt = now - lastClock
- lastClock = now
- -- Before the empty-stack return: markers outlive their cams, and work with cams off.
- PollAntinukeMarks(now)
- if now >= nukes.next then
- nukes.next = now + 2
- CheckCoverage()
- end
- local n = #cams
- if n == 0 then
- return
- end
- -- Expiry lives here rather than in the draw, so a cam whose time is up goes away even
- -- while the GUI is hidden and nothing is being drawn at all.
- local hold = options.holdTime.value
- if hold > 0 then
- for i = n, 1, -1 do
- local cam = cams[i]
- if Pinned(cam) then
- cam.heldAt = now -- the hold time starts over once it is released
- end
- if (now - (cam.heldAt or cam.born)) - hold > FADE then
- table.remove(cams, i)
- end
- end
- end
- for i = 1, #cams do
- local cam = cams[i]
- if cam.mobile then
- -- A strider cam is aimed at a pad, and the strider steps off it the moment it
- -- is done. Following it while it is in sight keeps the cam on the thing it was
- -- opened for rather than on the ground it used to stand on; lose sight and it
- -- holds the last place we saw it, which is the honest answer.
- local ux, uy, uz = spGetUnitPosition(cam.unitID)
- if ux then
- cam.x, cam.y, cam.z = ux, uy, uz
- end
- end
- if cam.polling then
- local beingBuilt, prog = spGetUnitIsBeingBuilt(cam.unitID)
- -- Game time for the build rate, so a pause or a replay at 4x does not skew it.
- local gt = Spring.GetGameSeconds and Spring.GetGameSeconds() or now
- if beingBuilt == nil then
- -- Out of sight, or dead. Either way the last figure we had is all there is,
- -- so it is kept and flagged rather than dropped: "47%, last time we looked"
- -- is intel, and a bar that emptied itself would be a lie.
- cam.stale = true
- -- The prediction runs beside it, never over it: cam.progress stays the
- -- last thing actually seen.
- if cam.rate and cam.rate > 0 and cam.seenT then
- cam.predicted = min(1, cam.progress + cam.rate * (gt - cam.seenT))
- end
- else
- cam.stale = false
- cam.predicted = nil
- cam.progress = prog or cam.progress
- -- Build rate, sampled at most once a second: per-frame deltas are mostly
- -- zero between sim frames. A sample spanning a spell out of sight is still
- -- a true average, so it is not thrown away.
- local p = cam.progress
- if not cam.rateT then
- cam.rateT, cam.rateP = gt, p
- elseif gt - cam.rateT >= 1 then
- local r = (p - cam.rateP) / (gt - cam.rateT)
- cam.rate = cam.rate and (cam.rate + r) * 0.5 or r
- cam.rateT, cam.rateP = gt, p
- end
- cam.seenT = gt
- if not beingBuilt then
- cam.progress = 1
- cam.polling = false -- topped out; nothing left to ask
- end
- end
- end
- if options.spin.value then
- cam.yaw = (cam.yaw + dt * 18) % 360
- end
- end
- end
- -- The live view goes here rather than in DrawScreen because this is the first callin after
- -- the engine has finished with the world: in DrawWorld the projectiles, the particles and
- -- LUPS are all still to come, and every one of them would paint over a box we had just
- -- cleared to draw into.
- function M:DrawScreenEffects()
- if not Showing() or options.viewMode.value ~= 'live' or liveFailed then
- return
- end
- for i = 1, #cams do
- if OnScreen(i) then
- local ok, err = pcall(DrawLive, cams[i], i)
- if not ok then
- ResetLiveState() -- the failed draw left the matrices and viewport its way
- liveFailed = true
- spEcho("Combat Advisor: Scout Cam live view failed, falling back to the unit "
- .. "model. " .. tostring(err))
- return
- end
- end
- end
- end
- function M:DrawWorldPreUnit()
- local exposed = nukes.exposed
- if #exposed == 0 or spIsGUIHidden() then
- return
- end
- glColor(1.0, 0.25, 0.20, 0.9)
- gl.LineWidth(2)
- for i = 1, #exposed do
- local e = exposed[i]
- gl.DrawGroundCircle(e[1], e[2], e[3], e[4], 32)
- end
- gl.LineWidth(1)
- glColor(1, 1, 1, 1)
- end
- function M:DrawScreen()
- -- Above the stack, and drawn whether or not any cam is up: the silo that caused it may
- -- have been found long ago.
- local nExposed = #nukes.exposed
- if nExposed > 0 and not spIsGUIHidden() then
- glColor(1.0, 0.35, 0.30, 1)
- glText(("%d key building%s open to enemy nukes"):format(nExposed, nExposed == 1 and "" or "s"),
- options.leftOffset.value, vsy - options.topOffset.value + 6, 12, "o")
- glColor(1, 1, 1, 1)
- end
- if not Showing() then
- return
- end
- local mode = options.viewMode.value
- local live = mode == 'live' and not liveFailed
- local model = not live and mode ~= 'text'
- if model then
- -- Once for the stack, not once per cam: the models are depth-tested against each
- -- other only inside their own scissor box, and each box starts from the same empty
- -- state. The live view does its own clearing, inside its own scissor.
- gl.Clear(GL.DEPTH_BUFFER_BIT)
- end
- local now = os.clock()
- for i = 1, #cams do
- if OnScreen(i) then
- if model then
- DrawModel(cams[i], i)
- end
- DrawChrome(cams[i], i, now)
- end
- end
- end
- -- Left click takes the camera to the site, right click dismisses the cam. Returning true
- -- is what stops the click also landing on the world behind it, so it is returned only for
- -- a click that actually hit a cam.
- function M:MousePress(mx, my, button)
- if not Showing() then
- return false
- end
- if button ~= 1 and button ~= 3 then
- return false
- end
- for i = 1, #cams do
- local x0, y0, x1, y1 = CamRect(i)
- if mx >= x0 and mx <= x1 and my >= y0 and my <= y1 then
- local cam = cams[i]
- if button == 1 then
- spSetCameraTarget(cam.x, cam.y, cam.z, 1)
- else
- table.remove(cams, i)
- end
- return true
- end
- end
- return false
- end
- -- Only reached for a press we claimed above, and there is nothing to finish: -1 hands the
- -- mouse straight back rather than leaving the widget holding it.
- function M:MouseRelease()
- return -1
- end
- RegisterModule{
- callins = M,
- prefix = 'sc_',
- optionsPath = options_path,
- options = options,
- optionsOrder = options_order,
- }
- end
- --------------------------------------------------------------------------------
- -- Build Herald
- --
- -- Scout Cam's mirror image: that one reports what the enemy just put up, this one reports
- -- what your own side just finished. When a large unit or building of your team is
- -- completed, a map marker goes down on it with its name, and the owner's name if it is not
- -- yours. A strider walking off the pad, an ally's Singularity coming online, or your own
- -- anti-nuke going live is worth a glance at the minimap without anyone typing it into chat.
- --
- -- The markers clear themselves after a while by default, since a marker per fusion would
- -- bury the map by the thirtieth minute. It publishes nothing.
- --
- -- Two more kinds of marker live here, because the engine erases markers by place rather
- -- than by marker: two modules each clearing their own would take each other's with them,
- -- and one owner of every marker this widget places is what keeps the waiting in Update
- -- honest. Tech pings mark the first enemy unit of each new type, and of each new class --
- -- air, cloakers, amphibious, submarines, striders, artillery -- where it is seen. Raid
- -- alerts mark our metal extractors and energy buildings when armed enemies are at them and
- -- nothing armed of ours is. Artillery is Build Proposer's role, read from WG.CounterMatrix.
- --------------------------------------------------------------------------------
- do
- local M = {} -- this module's callins, collected by RegisterModule below
- local options_path, options_order, options
- local spGetUnitPosition = Spring.GetUnitPosition
- local spGetTeamInfo = Spring.GetTeamInfo
- local spGetPlayerInfo = Spring.GetPlayerInfo
- local spGetMyTeamID = Spring.GetMyTeamID
- local spGetMyAllyTeamID = Spring.GetMyAllyTeamID
- local spGetSpectatingState = Spring.GetSpectatingState
- local spMarkerAddPoint = Spring.MarkerAddPoint
- local spMarkerErasePosition = Spring.MarkerErasePosition
- local spGetUnitDefID = Spring.GetUnitDefID
- local spGetUnitTeam = Spring.GetUnitTeam
- local spGetUnitAllyTeam = Spring.GetUnitAllyTeam
- local spGetAllUnits = Spring.GetAllUnits
- local spGetTeamUnits = Spring.GetTeamUnits
- local spGetUnitsInCylinder = Spring.GetUnitsInCylinder
- -- The engine erases every marker within a short distance of the point it is given, not one
- -- marker. So two of ours closer than this share one clearing. See Update.
- local ERASE_NEAR = 150
- -- Customparams arrive stringified, so a flag that is present but off is the string "false".
- local function Flag(cp, key)
- local v = cp and cp[key]
- return v ~= nil and v ~= false and v ~= "false" and v ~= "0" and v ~= 0
- end
- local unitName, cost, isStatic, skip = {}, {}, {}, {}
- -- For the tech pings and the raid alerts. `classes[defID]` lists the static classes of a
- -- mobile unit type we would ping about; artillery is added at ping time from Build
- -- Proposer's roles. Pseudo-units -- mines, terraform -- are static and skipped, so they
- -- never get a list.
- local classes, isArmed, isEco = {}, {}, {}
- local CLASS_NAME = {air = "air", cloak = "cloakers", amph = "amphibious", sub = "submarines",
- strider = "striders", arty = "artillery"}
- local strider = {}
- for defID = 1, #UnitDefs do
- local ud = UnitDefs[defID]
- if ud.name == "striderhub" then
- for i = 1, #(ud.buildOptions or {}) do
- strider[ud.buildOptions[i]] = true
- end
- end
- end
- for defID = 1, #UnitDefs do
- local ud = UnitDefs[defID]
- local cp = ud.customParams
- unitName[defID] = ud.humanName or ud.name or "?"
- cost[defID] = ud.metalCost or 0
- isStatic[defID] = (ud.speed or 0) == 0
- -- Pseudo-units, as in Scout Cam: never finished, or not anyone's build. Commanders
- -- too, because a morph finishes a fresh commander unit, and "your commander is your
- -- commander" is not news.
- skip[defID] = Flag(cp, "dontcount") or Flag(cp, "is_drone") or Flag(cp, "dynamic_comm")
- or Flag(cp, "commtype")
- isArmed[defID] = #(ud.weapons or {}) > 0
- -- Zero-K pays metal through a gadget rather than the engine's extractsMetal, and moves
- -- energyMake into income_energy, which factories and commanders also carry: hence
- -- static, and builds nothing.
- isEco[defID] = isStatic[defID] and (ud.buildSpeed or 0) == 0 and not ud.isFactory
- and ((tonumber(cp and cp.metal_extractor_mult) or 0) > 0
- or (tonumber(cp and cp.income_energy) or 0) > 0)
- if not isStatic[defID] and not skip[defID] then
- local c = {}
- local move = ud.moveDef and ud.moveDef.name or ""
- if ud.canFly then c[#c + 1] = "air" end
- -- Personal cloak. Area cloak makes nearly every mobile canCloak, so that flag says
- -- nothing; a cloak cost, or being the area cloaker itself, does.
- if (ud.cloakCost or 0) > 0 or Flag(cp, "area_cloak") then c[#c + 1] = "cloak" end
- if move:find("^akbot") or move:find("^bhover") then c[#c + 1] = "amph" end
- if move:find("^uboat") then c[#c + 1] = "sub" end
- -- The Strider Hub's roster, less the Athena that builds it.
- if strider[defID] and (ud.buildSpeed or 0) == 0 then c[#c + 1] = "strider" end
- classes[defID] = c
- end
- end
- local myTeamID, myAllyTeamID = spGetMyTeamID(), spGetMyAllyTeamID()
- local marks = {} -- {x, y, z, localOnly, expires}; ours, still on the map
- -- What the enemy has been seen fielding, by unit type and by class, since we took this seat.
- local tech = {types = {}, classes = {}}
- -- Raid alert: a cooldown per map cell so one raid is one marker, and the check's own clock.
- local raid = {cool = {}, next = 0, CELL = 600, GUARD = 400}
- options_path = 'Settings/HUD Panels/Build Herald'
- options_order = {'enabled', 'onlyMine', 'buildingCost', 'unitCost',
- 'techTypes', 'techClasses', 'techMinCost',
- 'raidEnabled', 'raidRadius', 'raidCooldown',
- 'share', 'clearAfter'}
- options = {
- enabled = {
- name = 'Mark large completions of your team',
- desc = 'When a large unit or building of your team is completed, put a map marker on '
- .. 'it with its name. An ally\'s build also carries the owner\'s name.',
- type = 'bool',
- value = true,
- },
- onlyMine = {
- name = 'Build Herald: only my own units',
- desc = 'Mark only what you built yourself, not your allies\' builds.',
- type = 'bool',
- value = false,
- },
- buildingCost = {
- name = 'Build Herald: buildings costing at least (metal)',
- desc = 'At the default a Singularity Reactor, Antinuke, Trinity, Big Bertha, Lucifer '
- .. 'or Cerberus is marked, and a fusion is not.',
- type = 'number',
- min = 100, max = 20000, step = 100, value = 1500,
- },
- unitCost = {
- name = 'Build Herald: units costing at least (metal)',
- desc = 'At the default striders, Cyclops, Likho, Krow and Reef are marked, and the '
- .. 'cheaper heavies such as Grizzly, Jugglenaut and Crab are not.',
- type = 'number',
- min = 100, max = 20000, step = 100, value = 2000,
- },
- techTypes = {
- name = 'Tech ping: first of each enemy unit type',
- desc = 'Put a map marker on the first enemy unit of every type you see, "Enemy '
- .. 'Hammerhead". Mobile units only; Scout Cam covers buildings. Commanders are '
- .. 'left out, since every morph is a new type.',
- type = 'bool',
- value = true,
- },
- techClasses = {
- name = 'Tech ping: first of each enemy unit class',
- desc = 'Put a map marker on the first enemy unit of each class you see: air, '
- .. 'cloakers, amphibious, submarines, striders and artillery, "Enemy air: '
- .. 'Hammerhead". Ignores the cost floor below. When a unit is both, the class '
- .. 'marker is the one placed.',
- type = 'bool',
- value = true,
- },
- techMinCost = {
- name = 'Tech ping: unit types costing at least (metal)',
- desc = 'Only ping a new unit type from this cost up. 0 pings every type.',
- type = 'number',
- min = 0, max = 5000, step = 50, value = 0,
- },
- raidEnabled = {
- name = 'Raid alert: ping enemies at undefended eco',
- desc = 'Put a map marker on your metal extractor or energy building when armed enemy '
- .. 'units are at it and nothing armed of your team is within 400 elmos, "Raid: 3 '
- .. 'Glaive". Checked every second, one marker per area per cooldown.',
- type = 'bool',
- value = true,
- },
- raidRadius = {
- name = 'Raid alert: enemy within (elmos)',
- type = 'number',
- min = 100, max = 1000, step = 25, value = 350,
- },
- raidCooldown = {
- name = 'Raid alert: same area at most every (s)',
- desc = 'The map is cut into 600-elmo squares, and a square that has just been marked '
- .. 'stays quiet this long.',
- type = 'number',
- min = 5, max = 300, step = 5, value = 30,
- },
- share = {
- name = 'Herald, tech ping and raid markers are',
- desc = 'For me only: a private marker. For my team: placed like a ping of your own, so '
- .. 'allies see it and hear it arrive. If more than one of you runs this with that '
- .. 'setting, you will get one marker each.',
- type = 'radioButton',
- value = 'local',
- items = {
- {key = 'local', name = 'For me only', desc = 'Only you see them.'},
- {key = 'allies', name = 'For my team', desc = 'Your allies see them too.'},
- },
- },
- clearAfter = {
- name = 'Clear Herald, tech ping and raid markers after (s)',
- desc = 'Remove each marker after this long. 0 leaves them until you erase them. '
- .. 'The engine clears by place, not by marker, so clearing also takes any other '
- .. 'marker right on that spot with it. That includes an ally\'s, for everyone, if '
- .. 'the markers are shared.',
- type = 'number',
- min = 0, max = 600, step = 5, value = 30,
- },
- }
- local function TeamName(teamID)
- local _, leader, _, isAI = spGetTeamInfo(teamID, false)
- if isAI then
- local _, botName = Spring.GetAIInfo(teamID)
- return botName or "AI"
- end
- return (leader and spGetPlayerInfo(leader, false)) or ("team " .. teamID)
- end
- -- Every marker this module places goes through here, so Update can clear them all.
- local function Mark(x, y, z, text)
- local localOnly = options.share.value ~= 'allies'
- spMarkerAddPoint(x, y, z, text, localOnly)
- local hold = options.clearAfter.value
- if hold > 0 then
- marks[#marks + 1] = {x = x, y = y, z = z, localOnly = localOnly, expires = os.clock() + hold}
- end
- end
- local function IsEnemy(team)
- return team ~= nil and team ~= Spring.GetGaiaTeamID()
- and select(6, spGetTeamInfo(team, false)) ~= myAllyTeamID
- end
- -- `quiet` files what is already in sight when we start, without a marker for any of it.
- local function TechSeen(unitID, defID, quiet)
- local c = defID and classes[defID]
- if not c then
- return -- not a mobile we ping about, or a radar dot with no type yet
- end
- local newType = not tech.types[defID]
- tech.types[defID] = true
- local all = c
- local role = WG.CounterMatrix and WG.CounterMatrix.role
- if role and role[defID] == "arty" then
- all = {"arty"}
- for i = 1, #c do
- all[#all + 1] = c[i]
- end
- end
- local fresh = {}
- for i = 1, #all do
- if not tech.classes[all[i]] then
- tech.classes[all[i]] = true
- fresh[#fresh + 1] = CLASS_NAME[all[i]]
- end
- end
- if quiet then
- return
- end
- local text
- if #fresh > 0 and options.techClasses.value then
- text = "Enemy " .. table.concat(fresh, ", ") .. ": " .. unitName[defID]
- elseif newType and options.techTypes.value and cost[defID] >= options.techMinCost.value then
- text = "Enemy " .. unitName[defID]
- end
- if text then
- local x, y, z = spGetUnitPosition(unitID)
- if x then
- Mark(x, y, z, text)
- end
- end
- end
- local function SeedTech()
- tech = {types = {}, classes = {}}
- local units = spGetAllUnits()
- for i = 1, (units and #units or 0) do
- local uID = units[i]
- if IsEnemy(spGetUnitTeam(uID)) then
- TechSeen(uID, spGetUnitDefID(uID), true)
- end
- end
- end
- local function TechEvent(unitID, unitDefID, unitTeam)
- if spGetSpectatingState() then
- return
- end
- if IsEnemy(unitTeam or spGetUnitTeam(unitID)) then
- TechSeen(unitID, unitDefID or spGetUnitDefID(unitID), false)
- end
- end
- -- Once a second: every eco building of ours with armed enemies at it and nothing armed of
- -- ours in guarding distance. Radar dots are left out: an unidentified dot at a mex is as
- -- likely a scout as a raid, and the marker names what is there. Enemy mines are static,
- -- and a mine field by a mex is not a raid.
- local function CheckRaids(now)
- if not options.raidEnabled.value or spGetSpectatingState() then
- return
- end
- local R = options.raidRadius.value
- local reach = math.max(R, raid.GUARD)
- local gaia = Spring.GetGaiaTeamID()
- local units = spGetTeamUnits(myTeamID)
- for i = 1, (units and #units or 0) do
- local uID = units[i]
- local defID = spGetUnitDefID(uID)
- local x, y, z = spGetUnitPosition(uID)
- if defID and isEco[defID] and x then
- local key = math.floor(x / raid.CELL) .. ":" .. math.floor(z / raid.CELL)
- if (raid.cool[key] or 0) <= now then
- local byDef, guarded, any = {}, false, false
- local near = spGetUnitsInCylinder(x, z, reach)
- for j = 1, (near and #near or 0) do
- local n = near[j]
- local nd = spGetUnitDefID(n)
- local nx, _, nz = spGetUnitPosition(n)
- if nd and nx and isArmed[nd] then
- local d2 = (nx - x) ^ 2 + (nz - z) ^ 2
- if spGetUnitAllyTeam(n) == myAllyTeamID then
- if d2 <= raid.GUARD * raid.GUARD then
- guarded = true
- break
- end
- elseif not isStatic[nd] and d2 <= R * R and spGetUnitTeam(n) ~= gaia then
- byDef[nd] = (byDef[nd] or 0) + 1
- any = true
- end
- end
- end
- if any and not guarded then
- local list = {}
- for d, k in pairs(byDef) do
- list[#list + 1] = {d = d, k = k}
- end
- table.sort(list, function(p, q)
- if p.k ~= q.k then return p.k > q.k end
- return p.d < q.d
- end)
- local parts = {}
- for j = 1, math.min(2, #list) do
- parts[j] = list[j].k .. " " .. unitName[list[j].d]
- end
- Mark(x, y, z, "Raid: " .. table.concat(parts, ", "))
- raid.cool[key] = now + options.raidCooldown.value
- end
- end
- end
- end
- end
- local function IsHeraldDef(defID)
- if not defID or skip[defID] then
- return false
- end
- local floor = isStatic[defID] and options.buildingCost.value or options.unitCost.value
- return cost[defID] >= floor
- end
- function M:Initialize()
- myTeamID, myAllyTeamID = spGetMyTeamID(), spGetMyAllyTeamID()
- SeedTech()
- end
- function M:Shutdown()
- -- The markers are left where they are, as notes on the map. Only the clearing stops.
- marks = {}
- end
- function M:PlayerChanged()
- local prevAlly = myAllyTeamID
- myTeamID, myAllyTeamID = spGetMyTeamID(), spGetMyAllyTeamID()
- if myAllyTeamID ~= prevAlly then
- SeedTech() -- a new seat: what that side has already seen is not news to it
- raid.cool = {}
- end
- end
- function M:UnitEnteredLos(unitID, unitTeam)
- TechEvent(unitID, nil, unitTeam)
- end
- function M:UnitCreated(unitID, unitDefID, unitTeam)
- TechEvent(unitID, unitDefID, unitTeam)
- end
- function M:UnitFinished(unitID, unitDefID, unitTeam)
- if not options.enabled.value or not IsHeraldDef(unitDefID) then
- return
- end
- -- A spectator's "team" is whoever they are looking through, and a shared marker from one
- -- goes to the spectators, so nothing is marked while spectating.
- if spGetSpectatingState() then
- return
- end
- if unitTeam ~= myTeamID then
- if options.onlyMine.value or select(6, spGetTeamInfo(unitTeam, false)) ~= myAllyTeamID then
- return
- end
- end
- local x, y, z = spGetUnitPosition(unitID)
- if not x then
- return
- end
- local text = unitName[unitDefID]
- if unitTeam ~= myTeamID then
- text = text .. " (" .. TeamName(unitTeam) .. ")"
- end
- Mark(x, y, z, text)
- end
- function M:Update()
- local now = os.clock()
- if now >= raid.next then
- raid.next = now + 1
- CheckRaids(now)
- end
- local n = #marks
- if n == 0 then
- return
- end
- for i = n, 1, -1 do
- local m = marks[i]
- if now >= m.expires then
- -- An erase here would take a newer marker of ours next to it too, so this one waits
- -- until that one is due and they go together.
- local wait
- for j = 1, #marks do
- local o = marks[j]
- if o ~= m and o.expires > m.expires then
- local dx, dz = o.x - m.x, o.z - m.z
- if dx * dx + dz * dz < ERASE_NEAR * ERASE_NEAR then
- wait = math.max(wait or 0, o.expires)
- end
- end
- end
- if wait then
- m.expires = wait
- else
- spMarkerErasePosition(m.x, m.y, m.z, nil, m.localOnly)
- table.remove(marks, i)
- end
- end
- end
- end
- RegisterModule{
- callins = M,
- prefix = 'bh_',
- optionsPath = options_path,
- options = options,
- optionsOrder = options_order,
- }
- end
- -- Handed to EPIC Menu only once every module has filed its own.
- options_path = OPTIONS_PATH
- options = allOptions
- options_order = allOptionsOrder
- --------------------------------------------------------------------------------
- -- Callin dispatch
- --------------------------------------------------------------------------------
- -- Only the callins somebody actually implements get defined, so the engine is not handed an
- -- empty UnitDamaged to call on every point of damage traded in the game. Where exactly one
- -- module wants a callin -- SelectionChanged, DrawInMiniMap, MousePress -- its function is
- -- installed directly: none of these bodies looks at `self`, and a fan-out of one is not
- -- worth a trampoline on a hot callin. UnitDamaged has two implementors since the front
- -- overlay (Build Proposer and Engagement Predictor), so keep both handlers cheap.
- --
- -- Installing directly is also the only form that carries a return value back to the engine,
- -- and MousePress has to be able to say it consumed the click. So a callin whose answer
- -- matters must stay in exactly one module: a second implementor would silently turn it into
- -- the fan-out below, which calls each in turn and returns nothing.
- local CALLINS = {
- "Update", "DrawScreen", "DrawScreenEffects", "DrawWorldPreUnit", "DrawInMiniMap",
- "ViewResize", "PlayerChanged",
- "SelectionChanged",
- "MousePress", "MouseRelease",
- "UnitCreated", "UnitFinished", "UnitDestroyed", "UnitEnteredLos", "UnitDamaged",
- }
- for i = 1, #CALLINS do
- local name = CALLINS[i]
- local want = {}
- for j = 1, #modules do
- if modules[j][name] then
- want[#want + 1] = modules[j]
- end
- end
- if #want == 1 then
- widget[name] = want[1][name]
- elseif #want > 1 then
- widget[name] = function(_, ...)
- for j = 1, #want do
- want[j][name](want[j], ...)
- end
- end
- end
- end
- function widget:Initialize()
- -- Hoisted out of the modules, all three of which want Chili and each of which would
- -- otherwise pull the whole widget down on its own while the others carried on starting.
- if not WG.Chili then
- Spring.Echo("Combat Advisor: WG.Chili unavailable, removing widget.")
- widgetHandler:RemoveWidget(widget)
- return
- end
- for i = 1, #modules do
- modules[i]:Initialize()
- end
- end
- function widget:Shutdown()
- -- Backwards: Fac Switcher and Engagement Predictor both read what Build Proposer
- -- publishes, which reads what Enemy Intel publishes, so every consumer is gone before
- -- the thing it consumes.
- for i = #modules, 1, -1 do
- modules[i]:Shutdown()
- end
- end
Add Comment
Please, Sign In to add comment